diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..2aa5f9426 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,156 @@ +# Spatial-neighbor input benchmark for issue #1199 + +The first-pass baseline below measures the implementation at Git commit `f03069089ce7fdc45a513eef83598796073665ac`. +That baseline uses unchanged Squidpy runtime code. The AnnData reference measures +graph construction with already prepared coordinates; no new cache was measured. + +## Method + +- Synthetic 16-vertex polygons, radius 0.25; density one cell per square unit. + Region area increases with cell count. Seed 1199, graph radius 1.6. +- Identical SpatialData-derived coordinates, categorical region groups and row + order for both inputs. Coordinates already exist in the SpatialData table. +- No expression matrix; `copy=True`, `n_jobs=1`, identity coordinate transforms. +- Imports, small-fixture warmup, input construction and coordinate preparation + are outside graph timings. The first graph call is not a cold-start measurement. +- Five repeats per input, alternating execution order. Separate cumulative + timings for `_resolve_data` and `_run_spatial_neighbors`; centroid calls counted. +- Every measured graph agrees with the reference in CSR structure and values + (`rtol=atol=1e-12`). Input observations and coordinates remain unchanged. +- Profiling runs separately. Memory runs in fresh subprocesses without reference + graphs, samples RSS every 2 ms, and keeps both prepared inputs resident. + +## Timing results + +Times are seconds. Repeated-call values are medians of five measurements; +resolver share is the ratio of the two medians. Ratio is SpatialData repeat / +AnnData reference. + +| Cells | Regions | SpatialData first graph | SpatialData repeat | Resolver | Resolver share | AnnData reference | Ratio | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 10,000 | 1 | 0.061400 | 0.059459 | 0.018401 | 30.9% | 0.041753 | 1.42 | +| 100,000 | 1 | 0.519355 | 0.517828 | 0.066715 | 12.9% | 0.453132 | 1.14 | +| 100,000 | 20 | 0.669610 | 0.669665 | 0.275670 | 41.2% | 0.392684 | 1.71 | + +Each SpatialData graph call recomputed centroids once per region; AnnData calls +made zero centroid calls. Existing table coordinates did not remove that work. +At fixed total cell count, the 20-region case incurred more input-preparation +cost than the single-region case. Its resolver profile includes repeated +SpatialData transformation and Dask conversion/materialization work; resolver +time must not be labelled solely polygon-centroid computation. + +## Sampled RSS + +Values are MiB, measured in separate single runs. The incremental column subtracts +RSS just before graph construction from sampled peak RSS. These snapshots measure +resident memory separately from the timing runs; they do not measure allocation totals. + +| Cells | Regions | SData baseline | SData peak | SData increment | AnnData baseline | AnnData peak | AnnData increment | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 10,000 | 1 | 440.72 | 451.64 | 10.92 | 440.98 | 452.84 | 11.86 | +| 100,000 | 1 | 608.47 | 729.05 | 120.58 | 594.31 | 700.02 | 105.70 | +| 100,000 | 20 | 518.58 | 582.50 | 63.92 | 518.91 | 578.55 | 59.64 | + +## Reproduce + +Use an environment satisfying this checkout's `pyproject.toml`, then run from +its root. The updated script compares all three paths and saves new results to +`results/1199-coordinate-reuse`; the first-pass results above remain unchanged: + +```bash +PYTHONPATH="$PWD/src" python benchmarks/bench_spatial_neighbors_input.py +``` + +A smaller smoke run: + +```bash +PYTHONPATH="$PWD/src" python benchmarks/bench_spatial_neighbors_input.py \ + --cases 200:2 --repeats 2 --output /tmp/squidpy-1199-pilot +``` + +The script asserts that Squidpy is imported from this checkout. OMP, OpenBLAS, +MKL and Numba thread limits are set to one in worker processes. `summary.json` +records the interpreter, hardware, dependency versions, parameters, SHA, dirty +working-tree status and SHA-256 hashes of the measured source and benchmark script. +The source hashes identify uncommitted implementations independently of the +editable package version label. + +First-pass environment: Python 3.13.3; macOS-26.7-arm64-arm-64bit-Mach-O. Package versions: spatialdata 0.8.0, anndata 0.13.3.post0, numpy 2.5.3, pandas 3.0.5, scipy 1.18.1, scikit-learn 1.9.1, shapely 2.1.2, dask 2026.8.0. + +Raw measurements: [samples.csv](results/1199-first-pass/samples.csv). +Metadata and summaries: [summary.json](results/1199-first-pass/summary.json). +Independent resolver profiles: [10k / 1 region](results/1199-first-pass/profile-10000-1.txt), +[100k / 1 region](results/1199-first-pass/profile-100000-1.txt), +[100k / 20 regions](results/1199-first-pass/profile-100000-20.txt). + +## Coordinate reuse implementation + +All six public spatial-neighbor interfaces accept `coordinate_source="table"` +to reuse existing coordinates. The default `"elements"` path computes and +transforms centroids. Table mode reads `obsm[spatial_key]` as stored, so the caller +supplies aligned, finite, real numeric NumPy coordinates in the desired coordinate +system. Region and instance association checks apply to both paths. With +`copy=True`, coordinates are copied into a lightweight temporary AnnData; +the expression matrix is not copied. + +This run compares SpatialData element mode, SpatialData table mode and the +AnnData reference on the same prepared inputs. Five repeats rotate the first +path in each trial. The first graph call for each SpatialData mode is recorded +separately; imports, fixture preparation and warmup are excluded. Every timed +graph matches the reference CSR structure and values (`rtol=atol=1e-12`). +Both inputs remain unchanged. Table mode makes zero centroid calls in every +sample, compared with one call per region for element mode. + +These measurements use Linux x86_64 and Python 3.12.14. The paths in this run +can be compared directly. The historical baseline above used macOS, so comparing +the two runs would also include a hardware difference. The measured code is an uncommitted +working-tree implementation based on `704348fcd6418af5deb051fe09050321b88b20c1`; +`summary.json` records dirty status and SHA-256 hashes of the source and script. + +### Repeated graph timings + +Times are seconds; each entry is the median of five runs. The ratio is +element-mode total / table-mode total; values above one indicate faster table mode. + +| Cells | Regions | Elements total | Elements resolver | Table total | Table resolver | AnnData total | Ratio | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 10,000 | 1 | 0.621056 | 0.353286 | 0.272928 | 0.036006 | 0.287705 | 2.28 | +| 100,000 | 1 | 2.418332 | 0.304803 | 2.541684 | 0.089694 | 2.322077 | 0.95 | +| 100,000 | 20 | 4.528471 | 2.584326 | 2.080445 | 0.229310 | 1.515549 | 2.18 | + +Input preparation is about 3.4–11.3 times faster across the three cases. Total +repeated-graph time improves by about 2.3 times for 10k / 1 region and 2.2 times +for 100k / 20 regions. For 100k / 1 region, table mode is about 5% slower overall +despite a faster resolver. Its graph-stage medians are 2.424483 s (table) versus 2.155961 s +(elements). Graph-stage timings vary even though the builder is unchanged; +these five-repeat measurements do not establish a universal end-to-end speedup. +The resolver includes association checks, coordinate validation/copying and +element processing; its savings must not be labelled solely centroid computation. + +### Sampled RSS for the implementation + +Each path runs once in its own fresh process, with both prepared inputs +resident and no reference graph. RSS is sampled every 2 ms. Values are MiB; +increments subtract the pre-graph baseline from the sampled peak. These single-run +observations report sampled resident memory; they do not measure allocation totals +or establish exact peak-memory bounds. + +| Cells | Regions | Elements baseline | Elements peak | Elements increment | Table baseline | Table peak | Table increment | AnnData baseline | AnnData peak | AnnData increment | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 10,000 | 1 | 366.39 | 375.57 | 9.18 | 366.40 | 374.36 | 7.96 | 366.37 | 376.25 | 9.88 | +| 100,000 | 1 | 488.84 | 540.08 | 51.24 | 489.99 | 545.27 | 55.29 | 487.59 | 541.91 | 54.32 | +| 100,000 | 20 | 438.45 | 503.82 | 65.38 | 438.34 | 498.33 | 59.99 | 438.29 | 494.04 | 55.75 | + +The RSS measurements do not show a consistent memory reduction across all +cases. Table mode still allocates graph outputs, performs association checks +and copies coordinates for `copy=True`. Labels may still require reading +instance IDs even when centroid computation is skipped. + +Packages: spatialdata 0.8.0, anndata 0.13.4, numpy 2.5.4, pandas 3.0.6, scipy 1.18.1, scikit-learn 1.9.1, shapely 2.2.0, dask 2026.8.0. + +Implementation measurements: [samples.csv](results/1199-coordinate-reuse/samples.csv). +Metadata and summaries: [summary.json](results/1199-coordinate-reuse/summary.json). +Separate resolver profiles: +- 10k / 1 region: [elements](results/1199-coordinate-reuse/profile-10000-1-elements.txt), [table](results/1199-coordinate-reuse/profile-10000-1-table.txt). +- 100k / 1 region: [elements](results/1199-coordinate-reuse/profile-100000-1-elements.txt), [table](results/1199-coordinate-reuse/profile-100000-1-table.txt). +- 100k / 20 regions: [elements](results/1199-coordinate-reuse/profile-100000-20-elements.txt), [table](results/1199-coordinate-reuse/profile-100000-20-table.txt). diff --git a/benchmarks/bench_spatial_neighbors_input.py b/benchmarks/bench_spatial_neighbors_input.py new file mode 100644 index 000000000..ba1eb27d1 --- /dev/null +++ b/benchmarks/bench_spatial_neighbors_input.py @@ -0,0 +1,398 @@ +"""Measure element-derived and reused table coordinates for issue #1199. + +Run with a Python environment satisfying this checkout's runtime dependencies. +The default cases use 10k/100k polygons and fixed density, radius and complexity. +Each case runs in a fresh process. Imports, warmup and fixture preparation are +excluded from timings; the first sample is a first graph call, not cold startup. +SpatialData and AnnData inputs contain identical, SpatialData-derived coordinates. +Compare SpatialData element and table modes with an AnnData reference. +Memory samples run separately from timing/validation, with no reference graph. +""" + +from __future__ import annotations + +import argparse +import cProfile +import csv +import gc +import hashlib +import importlib.metadata +import json +import os +import platform +import pstats +import statistics +import subprocess +import sys +import threading +import time +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + + +def fixture(n_cells: int, n_regions: int, seed: int, vertices: int): + import geopandas as gpd + import numpy as np + import pandas as pd + import shapely + from anndata import AnnData + from spatialdata import SpatialData + from spatialdata.models import ShapesModel, TableModel + from spatialdata.transformations import Identity + + rng = np.random.default_rng(seed) + regions = [f"region_{i:03d}" for i in range(n_regions)] + shapes, observations = {}, [] + angles = np.linspace(0, 2 * np.pi, vertices, endpoint=False) + offsets = 0.25 * np.column_stack((np.cos(angles), np.sin(angles))) + for i, region in enumerate(regions): + n = n_cells // n_regions + (i < n_cells % n_regions) + # Area grows with n, keeping density at one cell per square unit. + centers = rng.uniform(0, np.sqrt(n), size=(n, 2)) + ids = np.arange(1, n + 1) + polygons = shapely.polygons(centers[:, None, :] + offsets[None, :, :]) + shapes[region] = ShapesModel.parse( + gpd.GeoDataFrame(geometry=polygons, index=ids), + transformations={"global": Identity()}, + ) + observations.append( + pd.DataFrame( + {"region": region, "instance_id": ids}, + index=[f"{region}:{j}" for j in ids], + ) + ) + obs = pd.concat(observations) + obs["region"] = pd.Categorical(obs["region"], categories=regions) + table = TableModel.parse(AnnData(obs=obs), region=regions, region_key="region", instance_key="instance_id") + return SpatialData(shapes=shapes, tables={"table": table}), dict.fromkeys(regions, "global") + + +def inputs(args): + from anndata import AnnData + + from squidpy.gr._build import _resolve_data + + sdata, mapping = fixture(args.cells, args.regions, args.seed, args.vertices) + start = time.perf_counter() + resolved, library_key = _resolve_data(sdata, table_key="table", elements_to_coordinate_systems=mapping, copy=True) + coordinate_preparation_s = time.perf_counter() - start + assert library_key == "region" + coords = resolved.obsm["spatial"] + # Prepare identical coordinates outside all graph timings. + sdata.tables["table"].obsm["spatial"] = coords.copy() + adata = AnnData(obs=resolved.obs.copy(), obsm={"spatial": coords.copy()}) + return sdata, adata, mapping, coordinate_preparation_s + + +def graph(data, mapping, radius, *, coordinate_source="elements"): + from spatialdata import SpatialData + + from squidpy.gr import spatial_neighbors_radius + + kwargs = {"radius": radius, "copy": True, "n_jobs": 1, "library_key": "region"} + if isinstance(data, SpatialData): + kwargs.update(table_key="table", elements_to_coordinate_systems=mapping, coordinate_source=coordinate_source) + return spatial_neighbors_radius(data, **kwargs) + + +def warmup(args): + from anndata import AnnData + + from squidpy.gr._build import _resolve_data + + small, mapping = fixture(200, 1, args.seed, args.vertices) + resolved, _ = _resolve_data(small, table_key="table", elements_to_coordinate_systems=mapping, copy=True) + adata = AnnData(obs=resolved.obs.copy(), obsm={"spatial": resolved.obsm["spatial"]}) + small.tables["table"].obsm["spatial"] = resolved.obsm["spatial"].copy() + graph(small, mapping, args.radius) + graph(small, mapping, args.radius, coordinate_source="table") + graph(adata, mapping, args.radius) + gc.collect() + + +@contextmanager +def instrumentation(): + from squidpy.gr import _build + + values = {"resolve_s": 0.0, "graph_s": 0.0, "centroid_calls": 0} + + def timed(name, function): + def wrapped(*args, **kwargs): + start = time.perf_counter() + try: + return function(*args, **kwargs) + finally: + values[name] += time.perf_counter() - start + + return wrapped + + original_centroids = _build.get_centroids + + def centroids(*args, **kwargs): + values["centroid_calls"] += 1 + return original_centroids(*args, **kwargs) + + with ( + patch.object(_build, "_resolve_data", timed("resolve_s", _build._resolve_data)), + patch.object(_build, "_run_spatial_neighbors", timed("graph_s", _build._run_spatial_neighbors)), + patch.object(_build, "get_centroids", centroids), + ): + yield values + + +def assert_graph_equal(actual, reference): + import numpy as np + + for name in ("connectivities", "distances"): + a, b = getattr(actual, name).copy(), getattr(reference, name).copy() + a.sort_indices() + b.sort_indices() + np.testing.assert_array_equal(a.indptr, b.indptr) + np.testing.assert_array_equal(a.indices, b.indices) + np.testing.assert_allclose(a.data, b.data, rtol=1e-12, atol=1e-12) + + +def timing(args, sdata, adata, mapping): + import numpy as np + import pandas as pd + + # Keep one reference only; these runs are not used for peak-memory reporting. + reference = graph(adata, mapping, args.radius) + before_obs = sdata.tables["table"].obs.copy() + before_coords = sdata.tables["table"].obsm["spatial"].copy() + samples = [] + order = [ + ("spatialdata_first_graph", sdata, "elements", 0), + ("spatialdata_table_first_graph", sdata, "table", 0), + ] + for trial in range(1, args.repeats + 1): + group = [ + ("spatialdata_repeat", sdata, "elements", trial), + ("spatialdata_table_repeat", sdata, "table", trial), + ("anndata_coordinates", adata, "elements", trial), + ] + # Rotate which path runs first, so no mode always benefits from call order. + offset = (trial - 1) % len(group) + order.extend(group[offset:] + group[:offset]) + for kind, data, source, trial in order: + gc.collect() + with instrumentation() as metrics: + start = time.perf_counter() + result = graph(data, mapping, args.radius, coordinate_source=source) + elapsed = time.perf_counter() - start + assert metrics["centroid_calls"] == (args.regions if data is sdata and source == "elements" else 0) + assert_graph_equal(result, reference) + samples.append( + { + "kind": kind, + "trial": trial, + "total_s": elapsed, + **metrics, + "matrix_nnz": int(result.connectivities.nnz), + "nonzero_connections": int(result.connectivities.count_nonzero()), + } + ) + del result + table = sdata.tables["table"] + pd.testing.assert_frame_equal(table.obs, before_obs) + np.testing.assert_array_equal(table.obsm["spatial"], before_coords) + assert table.X is None and not table.obsp + pd.testing.assert_frame_equal(adata.obs, before_obs) + np.testing.assert_array_equal(adata.obsm["spatial"], before_coords) + assert adata.X is None and not adata.obsp + del reference + gc.collect() + + # Profile separately, so profiler overhead never enters timing samples. + from squidpy.gr._build import _resolve_data + + for source in ("elements", "table"): + profile = cProfile.Profile() + profile.runcall( + _resolve_data, + sdata, + table_key="table", + elements_to_coordinate_systems=mapping, + coordinate_source=source, + copy=True, + ) + profile_path = Path(args.output) / f"profile-{args.cells}-{args.regions}-{source}.txt" + with profile_path.open("w") as stream: + pstats.Stats(profile, stream=stream).strip_dirs().sort_stats("cumulative").print_stats(35) + profile_path.write_text(profile_path.read_text().rstrip() + "\n") + return {"samples": samples, "equivalence": "passed", "input_preservation": "passed"} + + +def memory(args, sdata, adata, mapping): + import psutil + + gc.collect() + process = psutil.Process() + baseline = process.memory_info().rss + peak = [baseline] + stop = threading.Event() + + def sample(): + while not stop.wait(0.002): + peak[0] = max(peak[0], process.memory_info().rss) + + sampler = threading.Thread(target=sample, daemon=True) + sampler.start() + try: + data = adata if args.worker == "memory_anndata" else sdata + source = "table" if args.worker == "memory_spatialdata_table" else "elements" + result = graph(data, mapping, args.radius, coordinate_source=source) + peak[0] = max(peak[0], process.memory_info().rss) + nnz = int(result.connectivities.nnz) + finally: + stop.set() + sampler.join() + return { + "kind": args.worker, + "baseline_rss_mib": baseline / 2**20, + "sampled_peak_rss_mib": peak[0] / 2**20, + "sampled_increment_mib": (peak[0] - baseline) / 2**20, + "sampling_interval_ms": 2, + "matrix_nnz": nnz, + } + + +def worker(args): + import scanpy as sc + + import squidpy + + expected = Path(__file__).resolve().parents[1] / "src/squidpy/__init__.py" + assert Path(squidpy.__file__).resolve() == expected + sc.settings.verbosity = 0 + warmup(args) + sdata, adata, mapping, prep_s = inputs(args) + measured = timing(args, sdata, adata, mapping) if args.worker == "timing" else memory(args, sdata, adata, mapping) + packages = ["spatialdata", "anndata", "numpy", "pandas", "scipy", "scikit-learn", "shapely", "dask"] + return { + "cells": args.cells, + "regions": args.regions, + "coordinate_preparation_s": prep_s, + "python": platform.python_version(), + "source_path": "src/squidpy/__init__.py", + "packages": {name: importlib.metadata.version(name) for name in packages}, + **measured, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cases", nargs="+", default=["10000:1", "100000:1", "100000:20"]) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--radius", type=float, default=1.6) + parser.add_argument("--vertices", type=int, default=16) + parser.add_argument("--seed", type=int, default=1199) + parser.add_argument("--output", default="benchmarks/results/1199-coordinate-reuse") + parser.add_argument( + "--worker", choices=["timing", "memory_spatialdata", "memory_spatialdata_table", "memory_anndata"] + ) + parser.add_argument("--cells", type=int) + parser.add_argument("--regions", type=int) + args = parser.parse_args() + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + if args.worker: + print("BENCHMARK_JSON=" + json.dumps(worker(args))) + return + + environment = os.environ.copy() + for name in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMBA_NUM_THREADS"): + environment[name] = "1" + environment["TQDM_DISABLE"] = "1" + case_results, rows = [], [] + for case in args.cases: + cells, regions = map(int, case.split(":")) + if not (cells >= regions >= 1 and args.repeats >= 1 and args.vertices >= 3 and args.radius > 0): + parser.error("Require cells >= regions >= 1, repeats >= 1, vertices >= 3 and radius > 0.") + collected = {} + for mode in ("timing", "memory_spatialdata", "memory_spatialdata_table", "memory_anndata"): + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--worker", + mode, + "--cells", + str(cells), + "--regions", + str(regions), + "--repeats", + str(args.repeats), + "--radius", + str(args.radius), + "--vertices", + str(args.vertices), + "--seed", + str(args.seed), + "--output", + str(output), + ] + completed = subprocess.run(command, env=environment, capture_output=True, text=True, check=False) + if completed.returncode: + raise RuntimeError(f"{case} / {mode} failed:\n{completed.stdout}\n{completed.stderr}") + line = next(line for line in completed.stdout.splitlines() if line.startswith("BENCHMARK_JSON=")) + collected[mode] = json.loads(line.split("=", 1)[1]) + print(f"Completed {case} {mode}", flush=True) + samples = collected["timing"]["samples"] + medians = {} + for kind in ( + "spatialdata_first_graph", + "spatialdata_table_first_graph", + "spatialdata_repeat", + "spatialdata_table_repeat", + "anndata_coordinates", + ): + group = [sample for sample in samples if sample["kind"] == kind] + medians[kind] = { + name: statistics.median(sample[name] for sample in group) + for name in ("total_s", "resolve_s", "graph_s", "centroid_calls") + } + collected["medians"] = medians + case_results.append(collected) + rows.extend({"cells": cells, "regions": regions, **sample} for sample in samples) + print(json.dumps({"case": case, "medians": medians}), flush=True) + + repository = Path(__file__).resolve().parents[1] + sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + summary = { + "git_sha": sha, + "git_dirty": bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=repository, text=True).strip()), + "source_sha256": { + name: hashlib.sha256((repository / name).read_bytes()).hexdigest() + for name in ("src/squidpy/gr/_build.py", "benchmarks/bench_spatial_neighbors_input.py") + }, + "interpreter": Path(sys.executable).name, + "hardware": {"platform": platform.platform(), "machine": platform.machine(), "logical_cpus": os.cpu_count()}, + "thread_environment": { + name: environment[name] + for name in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMBA_NUM_THREADS") + }, + "parameters": vars(args), + "method": { + "density": 1.0, + "polygon_radius": 0.25, + "coordinate_system": "global (Identity)", + "copy": True, + "n_jobs": 1, + "expression_matrix": "absent", + "coordinate_sources": ["SpatialData elements", "SpatialData table", "AnnData coordinates"], + "timing": "warm process; existing coordinates; rotating repeated-path order", + "memory": "separate process per input type; both prepared inputs resident; sampled RSS", + }, + "cases": case_results, + } + (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + with (output / "samples.csv").open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0]), lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + print(f"Results: {output}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/results/1199-coordinate-reuse/profile-10000-1-elements.txt b/benchmarks/results/1199-coordinate-reuse/profile-10000-1-elements.txt new file mode 100644 index 000000000..40fcc6e40 --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/profile-10000-1-elements.txt @@ -0,0 +1,41 @@ + 78919 function calls (76249 primitive calls) in 0.220 seconds + + Ordered by: cumulative time + List reduced from 1889 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 2/1 0.002 0.001 0.195 0.195 _build.py:348(_resolve_data) + 22/10 0.004 0.000 0.179 0.018 functools.py:907(wrapper) + 1 0.000 0.000 0.138 0.138 centroids.py:108(_) + 1 0.001 0.001 0.136 0.136 transform.py:429(_) + 12 0.000 0.000 0.070 0.006 _accessor.py:62(wrapper) + 506/351 0.001 0.000 0.065 0.000 functools.py:982(__get__) + 5 0.001 0.000 0.065 0.013 base.py:368(compute) + 14 0.000 0.000 0.053 0.004 _collections.py:6(new_collection) + 5 0.000 0.000 0.047 0.009 base.py:685(compute) + 8 0.000 0.000 0.043 0.005 _expr.py:928(optimize_until) + 2 0.000 0.000 0.039 0.019 _collection.py:1391(to_dask_array) + 2 0.000 0.000 0.038 0.019 _collection.py:1432(values) + 2 0.000 0.000 0.038 0.019 _collection.py:992(map_partitions) + 2 0.000 0.000 0.038 0.019 _collection.py:6165(map_partitions) + 16 0.000 0.000 0.038 0.002 _expr.py:445(simplify) + 82/26 0.003 0.000 0.037 0.001 _expr.py:367(simplify_once) + 5 0.000 0.000 0.035 0.007 threaded.py:62(get) + 2 0.000 0.000 0.035 0.017 _backends.py:23(create_array_collection) +4306/2724 0.003 0.000 0.034 0.000 {built-in method builtins.len} + 5 0.000 0.000 0.031 0.006 local.py:382(get_async) + 37 0.002 0.000 0.029 0.001 frame.py:4337(__getitem__) + 265/232 0.001 0.000 0.027 0.000 utils.py:777(__call__) + 1 0.000 0.000 0.025 0.025 _collection.py:396(__len__) + 4 0.000 0.000 0.024 0.006 io.py:442(_meta) + 2 0.000 0.000 0.023 0.011 _expr.py:96(optimize) + 2 0.000 0.000 0.023 0.011 _expr.py:903(optimize) + 5 0.000 0.000 0.023 0.005 _expr.py:559(__dask_graph__) +1620/1536 0.001 0.000 0.022 0.000 {built-in method builtins.hasattr} + 72 0.002 0.000 0.021 0.000 _expr.py:60(__new__) + 6 0.000 0.000 0.021 0.003 _expr.py:437(optimize) + 72 0.001 0.000 0.021 0.000 series.py:369(__init__) + 7 0.000 0.000 0.020 0.003 _expr.py:2199(_meta) + 60 0.000 0.000 0.020 0.000 generic.py:6309(dtypes) + 11 0.000 0.000 0.020 0.002 _expr.py:263(_layer) + 345/295 0.001 0.000 0.020 0.000 {built-in method builtins.all} diff --git a/benchmarks/results/1199-coordinate-reuse/profile-10000-1-table.txt b/benchmarks/results/1199-coordinate-reuse/profile-10000-1-table.txt new file mode 100644 index 000000000..ef5048db1 --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/profile-10000-1-table.txt @@ -0,0 +1,41 @@ + 9087 function calls (8788 primitive calls) in 0.032 seconds + + Ordered by: cumulative time + List reduced from 756 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 1 0.000 0.000 0.032 0.032 _build.py:348(_resolve_data) + 1 0.000 0.000 0.023 0.023 relational_query.py:752(match_element_to_table) + 1 0.000 0.000 0.023 0.023 relational_query.py:558(join_spatialelement_table) + 1 0.000 0.000 0.017 0.017 relational_query.py:689(_call_join) + 1 0.000 0.000 0.017 0.017 relational_query.py:516(__call__) + 1 0.000 0.000 0.017 0.017 relational_query.py:293(_right_join_spatialelement_table) + 1 0.000 0.000 0.013 0.013 relational_query.py:203(_get_masked_element) + 1 0.000 0.000 0.009 0.009 relational_query.py:482(_match_rows) + 18 0.001 0.000 0.008 0.000 frame.py:4337(__getitem__) + 2 0.000 0.000 0.006 0.003 models.py:1283(get_model) + 2 0.000 0.000 0.006 0.003 models.py:1302(_validate_and_return) + 2 0.000 0.000 0.006 0.003 models.py:460(validate) + 16 0.000 0.000 0.006 0.000 frame.py:4968(_get_item) + 16 0.000 0.000 0.006 0.000 frame.py:4292(_ixs) + 8 0.000 0.000 0.006 0.001 geodataframe.py:1927(__getitem__) + 1 0.000 0.000 0.005 0.005 __init__.py:83(fn_compatible) + 1 0.000 0.000 0.005 0.005 anndata.py:233(__init__) + 1 0.000 0.000 0.005 0.005 anndata.py:354(_init_as_actual) + 1 0.000 0.000 0.005 0.005 relational_query.py:538(_validate_element_types_for_join) + 1 0.000 0.000 0.005 0.005 merge.py:145(merge) + 7/6 0.000 0.000 0.004 0.001 indexing.py:1192(__getitem__) + 16 0.000 0.000 0.004 0.000 frame.py:4956(_box_col_values) + 1 0.000 0.000 0.004 0.004 merge.py:1134(get_result) + 1 0.000 0.000 0.003 0.003 anndata.py:1590(_check_uniqueness) + 13 0.000 0.000 0.003 0.000 base.py:486(__new__) + 3 0.000 0.000 0.003 0.001 frame.py:702(__init__) + 1 0.000 0.000 0.003 0.003 groupby.py:797(get_group) +1930/1901 0.002 0.000 0.003 0.000 {built-in method builtins.isinstance} + 3 0.000 0.000 0.003 0.001 construction.py:375(dict_to_mgr) + 2/1 0.000 0.000 0.003 0.003 indexing.py:1383(_getitem_tuple) + 2/1 0.000 0.000 0.002 0.002 indexing.py:1058(_getitem_lowerdim) + 1 0.000 0.000 0.002 0.002 groupby.py:628(_get_index) + 3 0.000 0.000 0.002 0.001 groupby.py:557(indices) + 1 0.000 0.000 0.002 0.002 ops.py:649(indices) + 1 0.000 0.000 0.002 0.002 merge.py:1080(_reindex_and_concat) diff --git a/benchmarks/results/1199-coordinate-reuse/profile-100000-1-elements.txt b/benchmarks/results/1199-coordinate-reuse/profile-100000-1-elements.txt new file mode 100644 index 000000000..c6dd8da48 --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/profile-100000-1-elements.txt @@ -0,0 +1,41 @@ + 78919 function calls (76249 primitive calls) in 0.580 seconds + + Ordered by: cumulative time + List reduced from 1889 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 2/1 0.002 0.001 0.552 0.552 _build.py:348(_resolve_data) + 22/10 0.030 0.001 0.489 0.049 functools.py:907(wrapper) + 1 0.000 0.000 0.319 0.319 centroids.py:108(_) + 1 0.001 0.001 0.251 0.251 transform.py:429(_) + 5 0.000 0.000 0.128 0.026 base.py:368(compute) + 506/351 0.003 0.000 0.128 0.000 functools.py:982(__get__) + 5 0.000 0.000 0.114 0.023 base.py:685(compute) + 12 0.000 0.000 0.097 0.008 _accessor.py:62(wrapper) + 5 0.000 0.000 0.095 0.019 threaded.py:62(get) + 1 0.000 0.000 0.092 0.092 base.py:1023(centroid) + 1 0.000 0.000 0.092 0.092 base.py:94(_delegate_property) +2086/2053 0.001 0.000 0.092 0.000 {built-in method builtins.getattr} + 5 0.000 0.000 0.091 0.018 local.py:382(get_async) + 1 0.000 0.000 0.087 0.087 array.py:639(centroid) + 1 0.087 0.087 0.087 0.087 constructive.py:343(centroid) + 14 0.001 0.000 0.080 0.006 _collections.py:6(new_collection) +4306/2724 0.011 0.000 0.068 0.000 {built-in method builtins.len} + 13 0.000 0.000 0.068 0.005 local.py:140(queue_get) + 13 0.000 0.000 0.068 0.005 queue.py:154(get) + 13 0.002 0.000 0.067 0.005 threading.py:323(wait) + 72 0.012 0.000 0.067 0.001 _expr.py:60(__new__) + 41 0.001 0.000 0.066 0.002 _expr.py:875(__new__) + 265/232 0.001 0.000 0.063 0.000 utils.py:777(__call__) + 2 0.000 0.000 0.048 0.024 utils.py:460(__getitem__) + 2 0.001 0.000 0.048 0.024 _collection.py:785(_partitions) + 13 0.000 0.000 0.045 0.003 thread.py:54(run) + 8 0.000 0.000 0.043 0.005 _expr.py:928(optimize_until) + 2 0.000 0.000 0.040 0.020 _collection.py:1391(to_dask_array) + 1 0.000 0.000 0.040 0.040 __init__.py:83(fn_compatible) + 1 0.000 0.000 0.040 0.040 anndata.py:233(__init__) + 1 0.000 0.000 0.040 0.040 anndata.py:354(_init_as_actual) + 2 0.000 0.000 0.039 0.020 _collection.py:1432(values) + 2 0.000 0.000 0.039 0.020 _collection.py:992(map_partitions) + 2 0.000 0.000 0.039 0.020 _collection.py:6165(map_partitions) + 16 0.000 0.000 0.036 0.002 _expr.py:445(simplify) diff --git a/benchmarks/results/1199-coordinate-reuse/profile-100000-1-table.txt b/benchmarks/results/1199-coordinate-reuse/profile-100000-1-table.txt new file mode 100644 index 000000000..1f859c7ae --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/profile-100000-1-table.txt @@ -0,0 +1,41 @@ + 9087 function calls (8788 primitive calls) in 0.097 seconds + + Ordered by: cumulative time + List reduced from 756 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 1 0.002 0.002 0.097 0.097 _build.py:348(_resolve_data) + 1 0.000 0.000 0.052 0.052 __init__.py:83(fn_compatible) + 1 0.000 0.000 0.052 0.052 anndata.py:233(__init__) + 1 0.000 0.000 0.052 0.052 anndata.py:354(_init_as_actual) + 1 0.005 0.005 0.046 0.046 anndata.py:1590(_check_uniqueness) + 6 0.031 0.005 0.039 0.007 base.py:2457(is_unique) + 2 0.000 0.000 0.039 0.020 base.py:2492(has_duplicates) + 1 0.000 0.000 0.033 0.033 relational_query.py:752(match_element_to_table) + 1 0.000 0.000 0.033 0.033 relational_query.py:558(join_spatialelement_table) + 1 0.000 0.000 0.027 0.027 relational_query.py:689(_call_join) + 1 0.000 0.000 0.027 0.027 relational_query.py:516(__call__) + 1 0.000 0.000 0.027 0.027 relational_query.py:293(_right_join_spatialelement_table) + 1 0.000 0.000 0.018 0.018 relational_query.py:203(_get_masked_element) + 7 0.000 0.000 0.010 0.001 base.py:849(_engine) + 8 0.000 0.000 0.009 0.001 base.py:5127(_get_engine_target) + 1 0.000 0.000 0.009 0.009 relational_query.py:482(_match_rows) + 7 0.000 0.000 0.009 0.001 string_arrow.py:325(astype) + 6 0.000 0.000 0.008 0.001 base.py:804(astype) + 9 0.006 0.001 0.008 0.001 array.py:1757(to_numpy) + 11 0.000 0.000 0.008 0.001 {built-in method numpy.array} + 7 0.000 0.000 0.008 0.001 array.py:907(__array__) + 7/6 0.000 0.000 0.008 0.001 indexing.py:1192(__getitem__) + 2 0.000 0.000 0.008 0.004 models.py:1283(get_model) + 2 0.000 0.000 0.008 0.004 models.py:1302(_validate_and_return) + 2 0.000 0.000 0.008 0.004 models.py:460(validate) + 18 0.000 0.000 0.008 0.000 frame.py:4337(__getitem__) + 19 0.000 0.000 0.007 0.000 base.py:5384(__getitem__) + 22 0.002 0.000 0.007 0.000 array.py:778(__getitem__) + 16 0.001 0.000 0.007 0.000 frame.py:4968(_get_item) + 2/1 0.000 0.000 0.006 0.006 indexing.py:1383(_getitem_tuple) + 2/1 0.000 0.000 0.006 0.006 indexing.py:1058(_getitem_lowerdim) + 8 0.000 0.000 0.006 0.001 geodataframe.py:1927(__getitem__) + 1 0.000 0.000 0.006 0.006 relational_query.py:538(_validate_element_types_for_join) + 1 0.000 0.000 0.006 0.006 groupby.py:797(get_group) + 16 0.001 0.000 0.006 0.000 frame.py:4292(_ixs) diff --git a/benchmarks/results/1199-coordinate-reuse/profile-100000-20-elements.txt b/benchmarks/results/1199-coordinate-reuse/profile-100000-20-elements.txt new file mode 100644 index 000000000..651292919 --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/profile-100000-20-elements.txt @@ -0,0 +1,41 @@ + 1530449 function calls (1478448 primitive calls) in 7.845 seconds + + Ordered by: cumulative time + List reduced from 1891 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 2/1 0.008 0.004 6.718 6.718 _build.py:348(_resolve_data) + 288/48 0.033 0.000 5.943 0.124 functools.py:907(wrapper) + 20 0.174 0.009 5.892 0.295 centroids.py:108(_) + 20 0.016 0.001 5.137 0.257 transform.py:429(_) +10120/7020 0.043 0.000 2.940 0.000 functools.py:982(__get__) + 240 0.006 0.000 2.532 0.011 _accessor.py:62(wrapper) + 280 0.007 0.000 2.290 0.008 _collections.py:6(new_collection) + 100 0.003 0.000 1.959 0.020 base.py:368(compute) + 100 0.009 0.000 1.945 0.019 base.py:685(compute) +84120/53196 0.086 0.000 1.307 0.000 {built-in method builtins.len} + 160 0.009 0.000 1.267 0.008 _expr.py:928(optimize_until) + 20 0.000 0.000 1.204 0.060 _collection.py:396(__len__) + 100 0.005 0.000 1.160 0.012 threaded.py:62(get) + 100 0.016 0.000 1.148 0.011 local.py:382(get_async) + 40 0.000 0.000 1.123 0.028 _collection.py:1391(to_dask_array) +5300/4640 0.035 0.000 1.122 0.000 utils.py:777(__call__) + 40 0.000 0.000 1.117 0.028 _collection.py:1432(values) + 40 0.001 0.000 1.113 0.028 _collection.py:992(map_partitions) + 40 0.001 0.000 1.112 0.028 _collection.py:6165(map_partitions) + 320 0.003 0.000 1.104 0.003 _expr.py:445(simplify) + 1640/520 0.040 0.000 1.068 0.002 _expr.py:367(simplify_once) + 1 0.000 0.000 1.064 1.064 relational_query.py:752(match_element_to_table) + 1 0.000 0.000 1.064 1.064 relational_query.py:558(join_spatialelement_table) + 580/560 0.012 0.000 1.038 0.002 indexing.py:1192(__getitem__) + 40 0.004 0.000 1.026 0.026 _backends.py:23(create_array_collection) + 683 0.026 0.000 1.014 0.001 frame.py:4337(__getitem__) + 1 0.000 0.000 0.936 0.936 relational_query.py:689(_call_join) + 1 0.000 0.000 0.936 0.936 relational_query.py:516(__call__) + 1 0.005 0.005 0.936 0.936 relational_query.py:293(_right_join_spatialelement_table) + 140 0.004 0.000 0.881 0.006 _expr.py:2199(_meta) + 540 0.041 0.000 0.775 0.001 indexing.py:1740(_getitem_axis) + 20 0.007 0.000 0.743 0.037 relational_query.py:203(_get_masked_element) +31641/29961 0.044 0.000 0.736 0.000 {built-in method builtins.hasattr} + 40 0.000 0.000 0.733 0.018 _expr.py:96(optimize) + 40 0.000 0.000 0.733 0.018 _expr.py:903(optimize) diff --git a/benchmarks/results/1199-coordinate-reuse/profile-100000-20-table.txt b/benchmarks/results/1199-coordinate-reuse/profile-100000-20-table.txt new file mode 100644 index 000000000..67be06dc2 --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/profile-100000-20-table.txt @@ -0,0 +1,41 @@ + 134191 function calls (129629 primitive calls) in 0.469 seconds + + Ordered by: cumulative time + List reduced from 761 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 1 0.003 0.003 0.469 0.469 _build.py:348(_resolve_data) + 1 0.000 0.000 0.320 0.320 relational_query.py:752(match_element_to_table) + 1 0.000 0.000 0.320 0.320 relational_query.py:558(join_spatialelement_table) + 1 0.000 0.000 0.301 0.301 relational_query.py:689(_call_join) + 1 0.000 0.000 0.301 0.301 relational_query.py:516(__call__) + 1 0.001 0.001 0.301 0.301 relational_query.py:293(_right_join_spatialelement_table) + 20 0.003 0.000 0.243 0.012 relational_query.py:203(_get_masked_element) + 20 0.000 0.000 0.162 0.008 relational_query.py:482(_match_rows) + 140/120 0.001 0.000 0.120 0.001 indexing.py:1192(__getitem__) + 20 0.000 0.000 0.092 0.005 merge.py:145(merge) + 303 0.006 0.000 0.079 0.000 frame.py:4337(__getitem__) + 40 0.001 0.000 0.069 0.002 models.py:1283(get_model) + 40 0.000 0.000 0.066 0.002 models.py:1302(_validate_and_return) + 40 0.001 0.000 0.066 0.002 models.py:460(validate) + 40/20 0.000 0.000 0.065 0.003 indexing.py:1383(_getitem_tuple) + 40/20 0.002 0.000 0.065 0.003 indexing.py:1058(_getitem_lowerdim) + 20 0.000 0.000 0.061 0.003 merge.py:1134(get_result) + 263 0.001 0.000 0.055 0.000 frame.py:4968(_get_item) + 263 0.000 0.000 0.053 0.000 frame.py:4292(_ixs) + 20 0.000 0.000 0.052 0.003 groupby.py:797(get_group) + 100 0.000 0.000 0.052 0.001 indexing.py:1740(_getitem_axis) + 20 0.002 0.000 0.050 0.002 merge.py:1080(_reindex_and_concat) + 20 0.000 0.000 0.048 0.002 indexing.py:1717(_get_list_axis) + 20 0.001 0.000 0.048 0.002 generic.py:3998(take) + 1 0.000 0.000 0.048 0.048 __init__.py:83(fn_compatible) + 1 0.000 0.000 0.048 0.048 anndata.py:233(__init__) + 1 0.000 0.000 0.048 0.048 anndata.py:354(_init_as_actual) + 160 0.003 0.000 0.045 0.000 geodataframe.py:1927(__getitem__) + 20 0.000 0.000 0.044 0.002 managers.py:1065(take) + 41 0.000 0.000 0.042 0.001 frame.py:702(__init__) + 41 0.001 0.000 0.042 0.001 construction.py:375(dict_to_mgr) + 1 0.002 0.002 0.042 0.042 anndata.py:1590(_check_uniqueness) + 82 0.019 0.000 0.042 0.001 base.py:2457(is_unique) + 60 0.001 0.000 0.042 0.001 base.py:1228(take) + 165 0.005 0.000 0.041 0.000 base.py:486(__new__) diff --git a/benchmarks/results/1199-coordinate-reuse/samples.csv b/benchmarks/results/1199-coordinate-reuse/samples.csv new file mode 100644 index 000000000..d6af55bb3 --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/samples.csv @@ -0,0 +1,52 @@ +cells,regions,kind,trial,total_s,resolve_s,graph_s,centroid_calls,matrix_nnz,nonzero_connections +10000,1,spatialdata_first_graph,0,0.45183240399637725,0.20591254798637237,0.24579564899613615,1,79666,79666 +10000,1,spatialdata_table_first_graph,0,0.2622578909940785,0.03290657000616193,0.22882185599883087,0,79666,79666 +10000,1,spatialdata_repeat,1,0.4728917739994358,0.24733796100190375,0.2254364130058093,1,79666,79666 +10000,1,spatialdata_table_repeat,1,0.2729280889907386,0.022170367010403425,0.2505510260089068,0,79666,79666 +10000,1,anndata_coordinates,1,0.30750313099997584,3.955996362492442e-06,0.3065161690028617,0,79666,79666 +10000,1,spatialdata_table_repeat,2,0.39162027000566013,0.03600596099568065,0.34663639499922283,0,79666,79666 +10000,1,anndata_coordinates,2,0.28770514999632724,3.249995643272996e-06,0.28751829400425777,0,79666,79666 +10000,1,spatialdata_repeat,2,1.0971634489978896,0.48269172399886884,0.613478411003598,1,79666,79666 +10000,1,anndata_coordinates,3,0.48593696899479255,2.6679917937144637e-06,0.4858557810075581,0,79666,79666 +10000,1,spatialdata_repeat,3,0.6210555090074195,0.35328647898859344,0.2676452420128044,1,79666,79666 +10000,1,spatialdata_table_repeat,3,0.22402653899916913,0.045746561998385005,0.17796763200021815,0,79666,79666 +10000,1,spatialdata_repeat,4,1.2584441649960354,0.37745292999898084,0.879729150008643,1,79666,79666 +10000,1,spatialdata_table_repeat,4,0.2720052399963606,0.023293631995329633,0.24858683900674805,0,79666,79666 +10000,1,anndata_coordinates,4,0.2433721989946207,2.4300097720697522e-06,0.2432985329942312,0,79666,79666 +10000,1,spatialdata_table_repeat,5,0.31044016299711075,0.037809346002177335,0.27033481300168205,0,79666,79666 +10000,1,anndata_coordinates,5,0.2203373670054134,2.7579953894019127e-06,0.22023334201367106,0,79666,79666 +10000,1,spatialdata_repeat,5,0.4652356299920939,0.23302574901026674,0.23208898499433417,1,79666,79666 +100000,1,spatialdata_first_graph,0,2.534570542993606,0.3761283139901934,2.1582885179959703,1,803176,803176 +100000,1,spatialdata_table_first_graph,0,2.303023870001198,0.08258034099708311,2.2190185739891604,0,803176,803176 +100000,1,spatialdata_repeat,1,3.232323529999121,0.34304247998807114,2.887530785999843,1,803176,803176 +100000,1,spatialdata_table_repeat,1,2.541684206997161,0.11704705499869306,2.4244828340015374,0,803176,803176 +100000,1,anndata_coordinates,1,1.7798896849999437,2.329994458705187e-06,1.7798135630000615,0,803176,803176 +100000,1,spatialdata_table_repeat,2,1.8486283620004542,0.05694841900549363,1.7914544060040498,0,803176,803176 +100000,1,anndata_coordinates,2,1.5988884329999564,2.335000317543745e-06,1.598823322012322,0,803176,803176 +100000,1,spatialdata_repeat,2,2.4183316560083767,0.26122245799342636,2.1559611779957777,1,803176,803176 +100000,1,anndata_coordinates,3,2.6546046220028074,2.531000063754618e-06,2.6544673290045466,0,803176,803176 +100000,1,spatialdata_repeat,3,2.3316111019958043,0.30480307499237824,2.0254560019966448,1,803176,803176 +100000,1,spatialdata_table_repeat,3,1.9603404899971792,0.08969400500063784,1.8697675000003073,0,803176,803176 +100000,1,spatialdata_repeat,4,2.0528068560088286,0.2480228999920655,1.8035573519882746,1,803176,803176 +100000,1,spatialdata_table_repeat,4,2.9697782620060025,0.060506371009978466,2.9037675199942896,0,803176,803176 +100000,1,anndata_coordinates,4,7.4978843889985,2.5619956431910396e-06,7.497670029988512,0,803176,803176 +100000,1,spatialdata_table_repeat,5,5.036594083008822,0.15800933400169015,4.875057462006225,0,803176,803176 +100000,1,anndata_coordinates,5,2.322077116012224,2.224012860096991e-06,2.322005017005722,0,803176,803176 +100000,1,spatialdata_repeat,5,3.011931539993384,0.5121281140018255,2.4992328719963552,1,803176,803176 +100000,20,spatialdata_first_graph,0,4.512674037992838,2.8451289939985145,1.6673659219959518,20,789290,789290 +100000,20,spatialdata_table_first_graph,0,2.039315804009675,0.21194156000274234,1.8271633590047713,0,789290,789290 +100000,20,spatialdata_repeat,1,4.560599703996559,2.584325934003573,1.975218648003647,20,789290,789290 +100000,20,spatialdata_table_repeat,1,2.0804453210002976,0.21438694099197164,1.8647163740097312,0,789290,789290 +100000,20,anndata_coordinates,1,2.336970869000652,2.4500041035935283e-06,2.336731798990513,0,789290,789290 +100000,20,spatialdata_table_repeat,2,2.3889075990009587,0.22930962400278077,2.158650810000836,0,789290,789290 +100000,20,anndata_coordinates,2,2.2643327769910684,2.4360051611438394e-06,2.2634060790005606,0,789290,789290 +100000,20,spatialdata_repeat,2,4.528471062993049,2.9106490870035486,1.6176731339946855,20,789290,789290 +100000,20,anndata_coordinates,3,1.51554939399648,3.440989530645311e-06,1.5146234879939584,0,789290,789290 +100000,20,spatialdata_repeat,3,4.020907958998578,2.3142659470031504,1.7064999419962987,20,789290,789290 +100000,20,spatialdata_table_repeat,3,2.1801542709872592,0.31342200200015213,1.8663395199982915,0,789290,789290 +100000,20,spatialdata_repeat,4,4.458222650006064,2.2094047559949104,2.2485842850001063,20,789290,789290 +100000,20,spatialdata_table_repeat,4,1.6326642890053336,0.26136412599589676,1.3711589629965601,0,789290,789290 +100000,20,anndata_coordinates,4,1.0488305339968065,4.311994416639209e-06,1.04875289699703,0,789290,789290 +100000,20,spatialdata_table_repeat,5,1.2864268220000667,0.1641167710040463,1.1221289249951951,0,789290,789290 +100000,20,anndata_coordinates,5,1.2042815669992706,3.2449897844344378e-06,1.2041423230111832,0,789290,789290 +100000,20,spatialdata_repeat,5,7.115413546998752,4.264996204001363,2.8498174419946736,20,789290,789290 diff --git a/benchmarks/results/1199-coordinate-reuse/summary.json b/benchmarks/results/1199-coordinate-reuse/summary.json new file mode 100644 index 000000000..4a80e041e --- /dev/null +++ b/benchmarks/results/1199-coordinate-reuse/summary.json @@ -0,0 +1,930 @@ +{ + "git_sha": "704348fcd6418af5deb051fe09050321b88b20c1", + "git_dirty": true, + "source_sha256": { + "src/squidpy/gr/_build.py": "75cd452c15827fd260d7b5f81a5f2b503e8bb0cbcbefd1ff820b0fa8c889d9fb", + "benchmarks/bench_spatial_neighbors_input.py": "6b7a9581f18f4d533d5764d50bd9b8d13139036d1a1a3f860339c85e4f362c00" + }, + "interpreter": "python", + "hardware": { + "platform": "Linux-6.18.44-x86_64-with-glibc2.41", + "machine": "x86_64", + "logical_cpus": 5 + }, + "thread_environment": { + "OMP_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + "NUMBA_NUM_THREADS": "1" + }, + "parameters": { + "cases": ["10000:1", "100000:1", "100000:20"], + "repeats": 5, + "radius": 1.6, + "vertices": 16, + "seed": 1199, + "output": "benchmarks/results/1199-coordinate-reuse", + "worker": null, + "cells": null, + "regions": null + }, + "method": { + "density": 1.0, + "polygon_radius": 0.25, + "coordinate_system": "global (Identity)", + "copy": true, + "n_jobs": 1, + "expression_matrix": "absent", + "coordinate_sources": [ + "SpatialData elements", + "SpatialData table", + "AnnData coordinates" + ], + "timing": "warm process; existing coordinates; rotating repeated-path order", + "memory": "separate process per input type; both prepared inputs resident; sampled RSS" + }, + "cases": [ + { + "timing": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.15202647799742408, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "samples": [ + { + "kind": "spatialdata_first_graph", + "trial": 0, + "total_s": 0.45183240399637725, + "resolve_s": 0.20591254798637237, + "graph_s": 0.24579564899613615, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_table_first_graph", + "trial": 0, + "total_s": 0.2622578909940785, + "resolve_s": 0.03290657000616193, + "graph_s": 0.22882185599883087, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 1, + "total_s": 0.4728917739994358, + "resolve_s": 0.24733796100190375, + "graph_s": 0.2254364130058093, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 1, + "total_s": 0.2729280889907386, + "resolve_s": 0.022170367010403425, + "graph_s": 0.2505510260089068, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 1, + "total_s": 0.30750313099997584, + "resolve_s": 3.955996362492442e-6, + "graph_s": 0.3065161690028617, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 2, + "total_s": 0.39162027000566013, + "resolve_s": 0.03600596099568065, + "graph_s": 0.34663639499922283, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 2, + "total_s": 0.28770514999632724, + "resolve_s": 3.249995643272996e-6, + "graph_s": 0.28751829400425777, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 2, + "total_s": 1.0971634489978896, + "resolve_s": 0.48269172399886884, + "graph_s": 0.613478411003598, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 3, + "total_s": 0.48593696899479255, + "resolve_s": 2.6679917937144637e-6, + "graph_s": 0.4858557810075581, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 3, + "total_s": 0.6210555090074195, + "resolve_s": 0.35328647898859344, + "graph_s": 0.2676452420128044, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 3, + "total_s": 0.22402653899916913, + "resolve_s": 0.045746561998385005, + "graph_s": 0.17796763200021815, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 4, + "total_s": 1.2584441649960354, + "resolve_s": 0.37745292999898084, + "graph_s": 0.879729150008643, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 4, + "total_s": 0.2720052399963606, + "resolve_s": 0.023293631995329633, + "graph_s": 0.24858683900674805, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 4, + "total_s": 0.2433721989946207, + "resolve_s": 2.4300097720697522e-6, + "graph_s": 0.2432985329942312, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 5, + "total_s": 0.31044016299711075, + "resolve_s": 0.037809346002177335, + "graph_s": 0.27033481300168205, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 5, + "total_s": 0.2203373670054134, + "resolve_s": 2.7579953894019127e-6, + "graph_s": 0.22023334201367106, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 5, + "total_s": 0.4652356299920939, + "resolve_s": 0.23302574901026674, + "graph_s": 0.23208898499433417, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + } + ], + "equivalence": "passed", + "input_preservation": "passed" + }, + "memory_spatialdata": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.7629316439997638, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata", + "baseline_rss_mib": 366.39453125, + "sampled_peak_rss_mib": 375.57421875, + "sampled_increment_mib": 9.1796875, + "sampling_interval_ms": 2, + "matrix_nnz": 79666 + }, + "memory_spatialdata_table": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.10994342499179766, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata_table", + "baseline_rss_mib": 366.3984375, + "sampled_peak_rss_mib": 374.36328125, + "sampled_increment_mib": 7.96484375, + "sampling_interval_ms": 2, + "matrix_nnz": 79666 + }, + "memory_anndata": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.15832105099980254, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_anndata", + "baseline_rss_mib": 366.37109375, + "sampled_peak_rss_mib": 376.24609375, + "sampled_increment_mib": 9.875, + "sampling_interval_ms": 2, + "matrix_nnz": 79666 + }, + "medians": { + "spatialdata_first_graph": { + "total_s": 0.45183240399637725, + "resolve_s": 0.20591254798637237, + "graph_s": 0.24579564899613615, + "centroid_calls": 1 + }, + "spatialdata_table_first_graph": { + "total_s": 0.2622578909940785, + "resolve_s": 0.03290657000616193, + "graph_s": 0.22882185599883087, + "centroid_calls": 0 + }, + "spatialdata_repeat": { + "total_s": 0.6210555090074195, + "resolve_s": 0.35328647898859344, + "graph_s": 0.2676452420128044, + "centroid_calls": 1 + }, + "spatialdata_table_repeat": { + "total_s": 0.2729280889907386, + "resolve_s": 0.03600596099568065, + "graph_s": 0.2505510260089068, + "centroid_calls": 0 + }, + "anndata_coordinates": { + "total_s": 0.28770514999632724, + "resolve_s": 2.7579953894019127e-6, + "graph_s": 0.28751829400425777, + "centroid_calls": 0 + } + } + }, + { + "timing": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 0.674671237997245, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "samples": [ + { + "kind": "spatialdata_first_graph", + "trial": 0, + "total_s": 2.534570542993606, + "resolve_s": 0.3761283139901934, + "graph_s": 2.1582885179959703, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_table_first_graph", + "trial": 0, + "total_s": 2.303023870001198, + "resolve_s": 0.08258034099708311, + "graph_s": 2.2190185739891604, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 1, + "total_s": 3.232323529999121, + "resolve_s": 0.34304247998807114, + "graph_s": 2.887530785999843, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 1, + "total_s": 2.541684206997161, + "resolve_s": 0.11704705499869306, + "graph_s": 2.4244828340015374, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 1, + "total_s": 1.7798896849999437, + "resolve_s": 2.329994458705187e-6, + "graph_s": 1.7798135630000615, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 2, + "total_s": 1.8486283620004542, + "resolve_s": 0.05694841900549363, + "graph_s": 1.7914544060040498, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 2, + "total_s": 1.5988884329999564, + "resolve_s": 2.335000317543745e-6, + "graph_s": 1.598823322012322, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 2, + "total_s": 2.4183316560083767, + "resolve_s": 0.26122245799342636, + "graph_s": 2.1559611779957777, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 3, + "total_s": 2.6546046220028074, + "resolve_s": 2.531000063754618e-6, + "graph_s": 2.6544673290045466, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 3, + "total_s": 2.3316111019958043, + "resolve_s": 0.30480307499237824, + "graph_s": 2.0254560019966448, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 3, + "total_s": 1.9603404899971792, + "resolve_s": 0.08969400500063784, + "graph_s": 1.8697675000003073, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 4, + "total_s": 2.0528068560088286, + "resolve_s": 0.2480228999920655, + "graph_s": 1.8035573519882746, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 4, + "total_s": 2.9697782620060025, + "resolve_s": 0.060506371009978466, + "graph_s": 2.9037675199942896, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 4, + "total_s": 7.4978843889985, + "resolve_s": 2.5619956431910396e-6, + "graph_s": 7.497670029988512, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 5, + "total_s": 5.036594083008822, + "resolve_s": 0.15800933400169015, + "graph_s": 4.875057462006225, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 5, + "total_s": 2.322077116012224, + "resolve_s": 2.224012860096991e-6, + "graph_s": 2.322005017005722, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 5, + "total_s": 3.011931539993384, + "resolve_s": 0.5121281140018255, + "graph_s": 2.4992328719963552, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + } + ], + "equivalence": "passed", + "input_preservation": "passed" + }, + "memory_spatialdata": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 0.5813565779972123, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata", + "baseline_rss_mib": 488.84375, + "sampled_peak_rss_mib": 540.08203125, + "sampled_increment_mib": 51.23828125, + "sampling_interval_ms": 2, + "matrix_nnz": 803176 + }, + "memory_spatialdata_table": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 0.5147796480014222, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata_table", + "baseline_rss_mib": 489.98828125, + "sampled_peak_rss_mib": 545.2734375, + "sampled_increment_mib": 55.28515625, + "sampling_interval_ms": 2, + "matrix_nnz": 803176 + }, + "memory_anndata": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 2.155047096995986, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_anndata", + "baseline_rss_mib": 487.58984375, + "sampled_peak_rss_mib": 541.90625, + "sampled_increment_mib": 54.31640625, + "sampling_interval_ms": 2, + "matrix_nnz": 803176 + }, + "medians": { + "spatialdata_first_graph": { + "total_s": 2.534570542993606, + "resolve_s": 0.3761283139901934, + "graph_s": 2.1582885179959703, + "centroid_calls": 1 + }, + "spatialdata_table_first_graph": { + "total_s": 2.303023870001198, + "resolve_s": 0.08258034099708311, + "graph_s": 2.2190185739891604, + "centroid_calls": 0 + }, + "spatialdata_repeat": { + "total_s": 2.4183316560083767, + "resolve_s": 0.30480307499237824, + "graph_s": 2.1559611779957777, + "centroid_calls": 1 + }, + "spatialdata_table_repeat": { + "total_s": 2.541684206997161, + "resolve_s": 0.08969400500063784, + "graph_s": 2.4244828340015374, + "centroid_calls": 0 + }, + "anndata_coordinates": { + "total_s": 2.322077116012224, + "resolve_s": 2.335000317543745e-6, + "graph_s": 2.322005017005722, + "centroid_calls": 0 + } + } + }, + { + "timing": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 6.8806479019986, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "samples": [ + { + "kind": "spatialdata_first_graph", + "trial": 0, + "total_s": 4.512674037992838, + "resolve_s": 2.8451289939985145, + "graph_s": 1.6673659219959518, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_table_first_graph", + "trial": 0, + "total_s": 2.039315804009675, + "resolve_s": 0.21194156000274234, + "graph_s": 1.8271633590047713, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 1, + "total_s": 4.560599703996559, + "resolve_s": 2.584325934003573, + "graph_s": 1.975218648003647, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 1, + "total_s": 2.0804453210002976, + "resolve_s": 0.21438694099197164, + "graph_s": 1.8647163740097312, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 1, + "total_s": 2.336970869000652, + "resolve_s": 2.4500041035935283e-6, + "graph_s": 2.336731798990513, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 2, + "total_s": 2.3889075990009587, + "resolve_s": 0.22930962400278077, + "graph_s": 2.158650810000836, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 2, + "total_s": 2.2643327769910684, + "resolve_s": 2.4360051611438394e-6, + "graph_s": 2.2634060790005606, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 2, + "total_s": 4.528471062993049, + "resolve_s": 2.9106490870035486, + "graph_s": 1.6176731339946855, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 3, + "total_s": 1.51554939399648, + "resolve_s": 3.440989530645311e-6, + "graph_s": 1.5146234879939584, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 3, + "total_s": 4.020907958998578, + "resolve_s": 2.3142659470031504, + "graph_s": 1.7064999419962987, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 3, + "total_s": 2.1801542709872592, + "resolve_s": 0.31342200200015213, + "graph_s": 1.8663395199982915, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 4, + "total_s": 4.458222650006064, + "resolve_s": 2.2094047559949104, + "graph_s": 2.2485842850001063, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 4, + "total_s": 1.6326642890053336, + "resolve_s": 0.26136412599589676, + "graph_s": 1.3711589629965601, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 4, + "total_s": 1.0488305339968065, + "resolve_s": 4.311994416639209e-6, + "graph_s": 1.04875289699703, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_table_repeat", + "trial": 5, + "total_s": 1.2864268220000667, + "resolve_s": 0.1641167710040463, + "graph_s": 1.1221289249951951, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 5, + "total_s": 1.2042815669992706, + "resolve_s": 3.2449897844344378e-6, + "graph_s": 1.2041423230111832, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 5, + "total_s": 7.115413546998752, + "resolve_s": 4.264996204001363, + "graph_s": 2.8498174419946736, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + } + ], + "equivalence": "passed", + "input_preservation": "passed" + }, + "memory_spatialdata": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 2.1892831369914347, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata", + "baseline_rss_mib": 438.44921875, + "sampled_peak_rss_mib": 503.82421875, + "sampled_increment_mib": 65.375, + "sampling_interval_ms": 2, + "matrix_nnz": 789290 + }, + "memory_spatialdata_table": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 1.5659198469948024, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata_table", + "baseline_rss_mib": 438.3359375, + "sampled_peak_rss_mib": 498.328125, + "sampled_increment_mib": 59.9921875, + "sampling_interval_ms": 2, + "matrix_nnz": 789290 + }, + "memory_anndata": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 2.145187412999803, + "python": "3.12.14", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.4", + "numpy": "2.5.4", + "pandas": "3.0.6", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.2.0", + "dask": "2026.8.0" + }, + "kind": "memory_anndata", + "baseline_rss_mib": 438.28515625, + "sampled_peak_rss_mib": 494.03515625, + "sampled_increment_mib": 55.75, + "sampling_interval_ms": 2, + "matrix_nnz": 789290 + }, + "medians": { + "spatialdata_first_graph": { + "total_s": 4.512674037992838, + "resolve_s": 2.8451289939985145, + "graph_s": 1.6673659219959518, + "centroid_calls": 20 + }, + "spatialdata_table_first_graph": { + "total_s": 2.039315804009675, + "resolve_s": 0.21194156000274234, + "graph_s": 1.8271633590047713, + "centroid_calls": 0 + }, + "spatialdata_repeat": { + "total_s": 4.528471062993049, + "resolve_s": 2.584325934003573, + "graph_s": 1.975218648003647, + "centroid_calls": 20 + }, + "spatialdata_table_repeat": { + "total_s": 2.0804453210002976, + "resolve_s": 0.22930962400278077, + "graph_s": 1.8647163740097312, + "centroid_calls": 0 + }, + "anndata_coordinates": { + "total_s": 1.51554939399648, + "resolve_s": 3.2449897844344378e-6, + "graph_s": 1.5146234879939584, + "centroid_calls": 0 + } + } + } + ] +} diff --git a/benchmarks/results/1199-first-pass/profile-10000-1.txt b/benchmarks/results/1199-first-pass/profile-10000-1.txt new file mode 100644 index 000000000..807d08826 --- /dev/null +++ b/benchmarks/results/1199-first-pass/profile-10000-1.txt @@ -0,0 +1,41 @@ + 78892 function calls (76221 primitive calls) in 0.036 seconds + + Ordered by: cumulative time + List reduced from 1893 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 2/1 0.000 0.000 0.032 0.032 _build.py:352(_resolve_data) + 22/10 0.000 0.000 0.028 0.003 functools.py:930(wrapper) + 1 0.000 0.000 0.022 0.022 centroids.py:108(_) + 1 0.000 0.000 0.021 0.021 transform.py:429(_) + 12 0.000 0.000 0.011 0.001 _accessor.py:62(wrapper) + 5 0.000 0.000 0.011 0.002 base.py:368(compute) + 506/351 0.000 0.000 0.010 0.000 functools.py:1010(__get__) + 5 0.000 0.000 0.009 0.002 base.py:685(compute) + 14 0.000 0.000 0.008 0.001 _collections.py:6(new_collection) + 5 0.000 0.000 0.006 0.001 threaded.py:62(get) + 8 0.000 0.000 0.006 0.001 _expr.py:928(optimize_until) + 2 0.000 0.000 0.006 0.003 _collection.py:1391(to_dask_array) + 2 0.000 0.000 0.006 0.003 _collection.py:1432(values) + 2 0.000 0.000 0.006 0.003 _collection.py:992(map_partitions) + 2 0.000 0.000 0.006 0.003 _collection.py:6165(map_partitions) + 5 0.000 0.000 0.006 0.001 local.py:382(get_async) + 2 0.000 0.000 0.005 0.003 _backends.py:23(create_array_collection) + 16 0.000 0.000 0.005 0.000 _expr.py:445(simplify) + 82/26 0.000 0.000 0.005 0.000 _expr.py:367(simplify_once) +4313/2730 0.001 0.000 0.004 0.000 {built-in method builtins.len} + 37 0.000 0.000 0.004 0.000 frame.py:4337(__getitem__) + 265/232 0.000 0.000 0.003 0.000 utils.py:777(__call__) + 5 0.000 0.000 0.003 0.001 _expr.py:559(__dask_graph__) +2095/2062 0.000 0.000 0.003 0.000 {built-in method builtins.getattr} + 1 0.000 0.000 0.003 0.003 relational_query.py:752(match_element_to_table) + 1 0.000 0.000 0.003 0.003 relational_query.py:558(join_spatialelement_table) + 72 0.000 0.000 0.003 0.000 _expr.py:60(__new__) + 2 0.000 0.000 0.003 0.002 _expr.py:96(optimize) + 2 0.000 0.000 0.003 0.002 _expr.py:903(optimize) + 6 0.000 0.000 0.003 0.001 _expr.py:437(optimize) + 11 0.000 0.000 0.003 0.000 _expr.py:263(_layer) + 1 0.000 0.000 0.003 0.003 _collection.py:396(__len__) + 1 0.000 0.000 0.003 0.003 base.py:1019(centroid) + 1 0.000 0.000 0.003 0.003 base.py:90(_delegate_property) + 72 0.000 0.000 0.003 0.000 series.py:369(__init__) diff --git a/benchmarks/results/1199-first-pass/profile-100000-1.txt b/benchmarks/results/1199-first-pass/profile-100000-1.txt new file mode 100644 index 000000000..0a1b9f787 --- /dev/null +++ b/benchmarks/results/1199-first-pass/profile-100000-1.txt @@ -0,0 +1,41 @@ + 78892 function calls (76221 primitive calls) in 0.085 seconds + + Ordered by: cumulative time + List reduced from 1893 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 2/1 0.000 0.000 0.078 0.078 _build.py:352(_resolve_data) + 22/10 0.004 0.000 0.069 0.007 functools.py:930(wrapper) + 4 0.000 0.000 0.027 0.007 decorators.py:73(wrapped) +2095/2062 0.000 0.000 0.027 0.000 {built-in method builtins.getattr} + 1 0.000 0.000 0.027 0.027 base.py:1019(centroid) + 1 0.000 0.000 0.027 0.027 base.py:90(_delegate_property) + 1 0.000 0.000 0.027 0.027 centroids.py:108(_) + 1 0.000 0.000 0.027 0.027 array.py:617(centroid) + 1 0.027 0.027 0.027 0.027 constructive.py:345(centroid) + 1 0.000 0.000 0.024 0.024 transform.py:429(_) + 506/351 0.000 0.000 0.013 0.000 functools.py:1010(__get__) + 12 0.000 0.000 0.011 0.001 _accessor.py:62(wrapper) + 5 0.000 0.000 0.011 0.002 base.py:368(compute) + 5 0.000 0.000 0.009 0.002 base.py:685(compute) + 14 0.000 0.000 0.008 0.001 _collections.py:6(new_collection) + 1 0.000 0.000 0.008 0.008 base.py:6220(get_coordinates) + 1 0.000 0.000 0.007 0.007 __init__.py:83(fn_compatible) + 1 0.000 0.000 0.007 0.007 anndata.py:233(__init__) + 1 0.000 0.000 0.007 0.007 anndata.py:354(_init_as_actual) + 1 0.000 0.000 0.007 0.007 decorators.py:171(wrapper) + 1 0.000 0.000 0.007 0.007 coordinates.py:197(get_coordinates) + 1 0.007 0.007 0.007 0.007 {built-in method shapely.lib.get_coordinates} + 8 0.000 0.000 0.007 0.001 _expr.py:928(optimize_until) + 1 0.001 0.001 0.006 0.006 anndata.py:1590(_check_uniqueness) + 5 0.000 0.000 0.006 0.001 threaded.py:62(get) + 2 0.000 0.000 0.006 0.003 _collection.py:1391(to_dask_array) + 2 0.000 0.000 0.006 0.003 _collection.py:1432(values) + 2 0.000 0.000 0.006 0.003 _collection.py:992(map_partitions) + 2 0.000 0.000 0.006 0.003 _collection.py:6165(map_partitions) + 15 0.004 0.000 0.006 0.000 base.py:2457(is_unique) + 5 0.000 0.000 0.006 0.001 local.py:382(get_async) + 2 0.000 0.000 0.005 0.003 _backends.py:23(create_array_collection) + 1 0.000 0.000 0.005 0.005 relational_query.py:752(match_element_to_table) + 1 0.000 0.000 0.005 0.005 relational_query.py:558(join_spatialelement_table) + 16 0.000 0.000 0.005 0.000 _expr.py:445(simplify) diff --git a/benchmarks/results/1199-first-pass/profile-100000-20.txt b/benchmarks/results/1199-first-pass/profile-100000-20.txt new file mode 100644 index 000000000..8c92bb4f1 --- /dev/null +++ b/benchmarks/results/1199-first-pass/profile-100000-20.txt @@ -0,0 +1,41 @@ + 1527213 function calls (1475211 primitive calls) in 0.607 seconds + + Ordered by: cumulative time + List reduced from 1895 to 35 due to restriction <35> + + ncalls tottime percall cumtime percall filename:lineno(function) + 2/1 0.001 0.000 0.555 0.555 _build.py:352(_resolve_data) + 288/48 0.001 0.000 0.498 0.010 functools.py:930(wrapper) + 20 0.004 0.000 0.493 0.025 centroids.py:108(_) + 20 0.001 0.000 0.411 0.021 transform.py:429(_) + 240 0.000 0.000 0.222 0.001 _accessor.py:62(wrapper) + 100 0.000 0.000 0.206 0.002 base.py:368(compute) + 100 0.001 0.000 0.204 0.002 base.py:685(compute) +10120/7020 0.005 0.000 0.183 0.000 functools.py:1010(__get__) + 280 0.000 0.000 0.149 0.001 _collections.py:6(new_collection) + 160 0.000 0.000 0.126 0.001 _expr.py:928(optimize_until) + 100 0.001 0.000 0.120 0.001 threaded.py:62(get) + 100 0.002 0.000 0.118 0.001 local.py:382(get_async) + 40 0.000 0.000 0.115 0.003 _collection.py:1391(to_dask_array) + 40 0.000 0.000 0.114 0.003 _collection.py:1432(values) + 40 0.000 0.000 0.114 0.003 _collection.py:992(map_partitions) + 40 0.000 0.000 0.114 0.003 _collection.py:6165(map_partitions) + 320 0.001 0.000 0.103 0.000 _expr.py:445(simplify) + 40 0.000 0.000 0.103 0.003 _backends.py:23(create_array_collection) + 1640/520 0.005 0.000 0.098 0.000 _expr.py:367(simplify_once) +84127/53202 0.010 0.000 0.078 0.000 {built-in method builtins.len} + 683 0.002 0.000 0.073 0.000 frame.py:4337(__getitem__) +5300/4640 0.003 0.000 0.066 0.000 utils.py:777(__call__) + 100 0.000 0.000 0.066 0.001 _expr.py:559(__dask_graph__) + 40 0.000 0.000 0.065 0.002 _expr.py:96(optimize) + 40 0.000 0.000 0.065 0.002 _expr.py:903(optimize) + 20 0.000 0.000 0.065 0.003 _collection.py:396(__len__) + 1440 0.003 0.000 0.063 0.000 _expr.py:60(__new__) + 120 0.000 0.000 0.061 0.001 _expr.py:437(optimize) + 220 0.000 0.000 0.059 0.000 _expr.py:263(_layer) + 1440 0.007 0.000 0.058 0.000 series.py:369(__init__) +31604/29924 0.005 0.000 0.056 0.000 {built-in method builtins.hasattr} + 1200 0.001 0.000 0.056 0.000 generic.py:6309(dtypes) +6805/5805 0.003 0.000 0.053 0.000 {built-in method builtins.all} + 140 0.000 0.000 0.053 0.000 _expr.py:2199(_meta) + 1200 0.001 0.000 0.052 0.000 utils.py:1513(is_dataframe_like) diff --git a/benchmarks/results/1199-first-pass/samples.csv b/benchmarks/results/1199-first-pass/samples.csv new file mode 100644 index 000000000..353cdc7d7 --- /dev/null +++ b/benchmarks/results/1199-first-pass/samples.csv @@ -0,0 +1,34 @@ +cells,regions,kind,trial,total_s,resolve_s,graph_s,centroid_calls,matrix_nnz,nonzero_connections +10000,1,spatialdata_first_graph,0,0.06139962503220886,0.01972829195437953,0.0416374999913387,1,79666,79666 +10000,1,spatialdata_repeat,1,0.06027383299078792,0.01877258298918605,0.041471167001873255,1,79666,79666 +10000,1,anndata_coordinates,1,0.04170487500960007,9.5803989097476e-07,0.041673165978863835,0,79666,79666 +10000,1,anndata_coordinates,2,0.041753207973670214,7.079797796905041e-07,0.0417280420078896,0,79666,79666 +10000,1,spatialdata_repeat,2,0.05945870804134756,0.018401167006231844,0.04103033401770517,1,79666,79666 +10000,1,spatialdata_repeat,3,0.05926162499235943,0.017972667003050447,0.0412615830427967,1,79666,79666 +10000,1,anndata_coordinates,3,0.0417551250429824,8.330098353326321e-07,0.04173037502914667,0,79666,79666 +10000,1,anndata_coordinates,4,0.041723083006218076,7.919734343886375e-07,0.041697375010699034,0,79666,79666 +10000,1,spatialdata_repeat,4,0.05917741701705381,0.01833512500161305,0.04081316600786522,1,79666,79666 +10000,1,spatialdata_repeat,5,0.06243795802583918,0.019945540989283472,0.04245487501611933,1,79666,79666 +10000,1,anndata_coordinates,5,0.04181108297780156,9.170034900307655e-07,0.04178312496514991,0,79666,79666 +100000,1,spatialdata_first_graph,0,0.5193546669906937,0.06798454199451953,0.4513331669731997,1,803176,803176 +100000,1,spatialdata_repeat,1,0.513898707984481,0.06618779205018654,0.44767100003082305,1,803176,803176 +100000,1,anndata_coordinates,1,0.44526445801602677,8.330098353326321e-07,0.4452365000033751,0,803176,803176 +100000,1,anndata_coordinates,2,0.4554499170044437,8.33999365568161e-07,0.45542324997950345,0,803176,803176 +100000,1,spatialdata_repeat,2,0.5191809589741752,0.06671516702044755,0.45242600003257394,1,803176,803176 +100000,1,spatialdata_repeat,3,0.5173282920150086,0.06704762496519834,0.4502404169761576,1,803176,803176 +100000,1,anndata_coordinates,3,0.4532254579826258,7.92031642049551e-07,0.4531992499832995,0,803176,803176 +100000,1,anndata_coordinates,4,0.45313220901880413,1.0410440154373646e-06,0.4531037909910083,0,803176,803176 +100000,1,spatialdata_repeat,4,0.5178277079830877,0.06842616701032966,0.44935483299195766,1,803176,803176 +100000,1,spatialdata_repeat,5,0.5185865839594044,0.06628324999473989,0.4522644999669865,1,803176,803176 +100000,1,anndata_coordinates,5,0.4508892080048099,7.919734343886375e-07,0.4508621250279248,0,803176,803176 +100000,20,spatialdata_first_graph,0,0.6696101250126958,0.27492275001714006,0.3946475409902632,20,789290,789290 +100000,20,spatialdata_repeat,1,0.6692289999919012,0.27378816704731435,0.39540170901454985,20,789290,789290 +100000,20,anndata_coordinates,1,0.39268429204821587,9.579816833138466e-07,0.3926562919514254,0,789290,789290 +100000,20,anndata_coordinates,2,0.39225929201347753,8.330098353326321e-07,0.3922320419806056,0,789290,789290 +100000,20,spatialdata_repeat,2,0.669468957989011,0.27566991600906476,0.39376237499527633,20,789290,789290 +100000,20,spatialdata_repeat,3,0.6725359169649892,0.2778389999875799,0.3946563749923371,20,789290,789290 +100000,20,anndata_coordinates,3,0.3949818750261329,9.170034900307655e-07,0.3949542499613017,0,789290,789290 +100000,20,anndata_coordinates,4,0.3923478330252692,8.749775588512421e-07,0.3923180410056375,0,789290,789290 +100000,20,spatialdata_repeat,4,0.6696652910322882,0.2745451250229962,0.3950825000065379,20,789290,789290 +100000,20,spatialdata_repeat,5,0.6738140410161577,0.27734708302887157,0.39642829197691754,20,789290,789290 +100000,20,anndata_coordinates,5,0.3938842079951428,8.749775588512421e-07,0.3938557499786839,0,789290,789290 diff --git a/benchmarks/results/1199-first-pass/summary.json b/benchmarks/results/1199-first-pass/summary.json new file mode 100644 index 000000000..d4ebcf81a --- /dev/null +++ b/benchmarks/results/1199-first-pass/summary.json @@ -0,0 +1,635 @@ +{ + "git_sha": "f03069089ce7fdc45a513eef83598796073665ac", + "interpreter": "python", + "parameters": { + "cases": ["10000:1", "100000:1", "100000:20"], + "repeats": 5, + "radius": 1.6, + "vertices": 16, + "seed": 1199, + "output": "benchmarks/results/1199-first-pass", + "worker": null, + "cells": null, + "regions": null + }, + "method": { + "density": 1.0, + "polygon_radius": 0.25, + "coordinate_system": "global (Identity)", + "copy": true, + "n_jobs": 1, + "expression_matrix": "absent", + "timing": "warm process; existing coordinates; alternating repeated-input order", + "memory": "separate process per input type; both prepared inputs resident; sampled RSS" + }, + "cases": [ + { + "timing": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.02001783304149285, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "samples": [ + { + "kind": "spatialdata_first_graph", + "trial": 0, + "total_s": 0.06139962503220886, + "resolve_s": 0.01972829195437953, + "graph_s": 0.0416374999913387, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 1, + "total_s": 0.06027383299078792, + "resolve_s": 0.01877258298918605, + "graph_s": 0.041471167001873255, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 1, + "total_s": 0.04170487500960007, + "resolve_s": 9.5803989097476e-7, + "graph_s": 0.041673165978863835, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 2, + "total_s": 0.041753207973670214, + "resolve_s": 7.079797796905041e-7, + "graph_s": 0.0417280420078896, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 2, + "total_s": 0.05945870804134756, + "resolve_s": 0.018401167006231844, + "graph_s": 0.04103033401770517, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 3, + "total_s": 0.05926162499235943, + "resolve_s": 0.017972667003050447, + "graph_s": 0.0412615830427967, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 3, + "total_s": 0.0417551250429824, + "resolve_s": 8.330098353326321e-7, + "graph_s": 0.04173037502914667, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 4, + "total_s": 0.041723083006218076, + "resolve_s": 7.919734343886375e-7, + "graph_s": 0.041697375010699034, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 4, + "total_s": 0.05917741701705381, + "resolve_s": 0.01833512500161305, + "graph_s": 0.04081316600786522, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "spatialdata_repeat", + "trial": 5, + "total_s": 0.06243795802583918, + "resolve_s": 0.019945540989283472, + "graph_s": 0.04245487501611933, + "centroid_calls": 1, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + }, + { + "kind": "anndata_coordinates", + "trial": 5, + "total_s": 0.04181108297780156, + "resolve_s": 9.170034900307655e-7, + "graph_s": 0.04178312496514991, + "centroid_calls": 0, + "matrix_nnz": 79666, + "nonzero_connections": 79666 + } + ], + "equivalence": "passed", + "input_preservation": "passed" + }, + "memory_spatialdata": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.017994666006416082, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata", + "baseline_rss_mib": 440.71875, + "sampled_peak_rss_mib": 451.640625, + "sampled_increment_mib": 10.921875, + "sampling_interval_ms": 2, + "matrix_nnz": 79666 + }, + "memory_anndata": { + "cells": 10000, + "regions": 1, + "coordinate_preparation_s": 0.018074666033498943, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "kind": "memory_anndata", + "baseline_rss_mib": 440.984375, + "sampled_peak_rss_mib": 452.84375, + "sampled_increment_mib": 11.859375, + "sampling_interval_ms": 2, + "matrix_nnz": 79666 + }, + "medians": { + "spatialdata_first_graph": { + "total_s": 0.06139962503220886, + "resolve_s": 0.01972829195437953, + "graph_s": 0.0416374999913387, + "centroid_calls": 1 + }, + "spatialdata_repeat": { + "total_s": 0.05945870804134756, + "resolve_s": 0.018401167006231844, + "graph_s": 0.0412615830427967, + "centroid_calls": 1 + }, + "anndata_coordinates": { + "total_s": 0.041753207973670214, + "resolve_s": 8.330098353326321e-7, + "graph_s": 0.0417280420078896, + "centroid_calls": 0 + } + } + }, + { + "timing": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 0.07317691697971895, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "samples": [ + { + "kind": "spatialdata_first_graph", + "trial": 0, + "total_s": 0.5193546669906937, + "resolve_s": 0.06798454199451953, + "graph_s": 0.4513331669731997, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 1, + "total_s": 0.513898707984481, + "resolve_s": 0.06618779205018654, + "graph_s": 0.44767100003082305, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 1, + "total_s": 0.44526445801602677, + "resolve_s": 8.330098353326321e-7, + "graph_s": 0.4452365000033751, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 2, + "total_s": 0.4554499170044437, + "resolve_s": 8.33999365568161e-7, + "graph_s": 0.45542324997950345, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 2, + "total_s": 0.5191809589741752, + "resolve_s": 0.06671516702044755, + "graph_s": 0.45242600003257394, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 3, + "total_s": 0.5173282920150086, + "resolve_s": 0.06704762496519834, + "graph_s": 0.4502404169761576, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 3, + "total_s": 0.4532254579826258, + "resolve_s": 7.92031642049551e-7, + "graph_s": 0.4531992499832995, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 4, + "total_s": 0.45313220901880413, + "resolve_s": 1.0410440154373646e-6, + "graph_s": 0.4531037909910083, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 4, + "total_s": 0.5178277079830877, + "resolve_s": 0.06842616701032966, + "graph_s": 0.44935483299195766, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "spatialdata_repeat", + "trial": 5, + "total_s": 0.5185865839594044, + "resolve_s": 0.06628324999473989, + "graph_s": 0.4522644999669865, + "centroid_calls": 1, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + }, + { + "kind": "anndata_coordinates", + "trial": 5, + "total_s": 0.4508892080048099, + "resolve_s": 7.919734343886375e-7, + "graph_s": 0.4508621250279248, + "centroid_calls": 0, + "matrix_nnz": 803176, + "nonzero_connections": 803176 + } + ], + "equivalence": "passed", + "input_preservation": "passed" + }, + "memory_spatialdata": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 0.07022074999986216, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata", + "baseline_rss_mib": 608.46875, + "sampled_peak_rss_mib": 729.046875, + "sampled_increment_mib": 120.578125, + "sampling_interval_ms": 2, + "matrix_nnz": 803176 + }, + "memory_anndata": { + "cells": 100000, + "regions": 1, + "coordinate_preparation_s": 0.07447537494590506, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "kind": "memory_anndata", + "baseline_rss_mib": 594.3125, + "sampled_peak_rss_mib": 700.015625, + "sampled_increment_mib": 105.703125, + "sampling_interval_ms": 2, + "matrix_nnz": 803176 + }, + "medians": { + "spatialdata_first_graph": { + "total_s": 0.5193546669906937, + "resolve_s": 0.06798454199451953, + "graph_s": 0.4513331669731997, + "centroid_calls": 1 + }, + "spatialdata_repeat": { + "total_s": 0.5178277079830877, + "resolve_s": 0.06671516702044755, + "graph_s": 0.4502404169761576, + "centroid_calls": 1 + }, + "anndata_coordinates": { + "total_s": 0.45313220901880413, + "resolve_s": 8.330098353326321e-7, + "graph_s": 0.4531037909910083, + "centroid_calls": 0 + } + } + }, + { + "timing": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 0.27839229098754004, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "samples": [ + { + "kind": "spatialdata_first_graph", + "trial": 0, + "total_s": 0.6696101250126958, + "resolve_s": 0.27492275001714006, + "graph_s": 0.3946475409902632, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 1, + "total_s": 0.6692289999919012, + "resolve_s": 0.27378816704731435, + "graph_s": 0.39540170901454985, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 1, + "total_s": 0.39268429204821587, + "resolve_s": 9.579816833138466e-7, + "graph_s": 0.3926562919514254, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 2, + "total_s": 0.39225929201347753, + "resolve_s": 8.330098353326321e-7, + "graph_s": 0.3922320419806056, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 2, + "total_s": 0.669468957989011, + "resolve_s": 0.27566991600906476, + "graph_s": 0.39376237499527633, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 3, + "total_s": 0.6725359169649892, + "resolve_s": 0.2778389999875799, + "graph_s": 0.3946563749923371, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 3, + "total_s": 0.3949818750261329, + "resolve_s": 9.170034900307655e-7, + "graph_s": 0.3949542499613017, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 4, + "total_s": 0.3923478330252692, + "resolve_s": 8.749775588512421e-7, + "graph_s": 0.3923180410056375, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 4, + "total_s": 0.6696652910322882, + "resolve_s": 0.2745451250229962, + "graph_s": 0.3950825000065379, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "spatialdata_repeat", + "trial": 5, + "total_s": 0.6738140410161577, + "resolve_s": 0.27734708302887157, + "graph_s": 0.39642829197691754, + "centroid_calls": 20, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + }, + { + "kind": "anndata_coordinates", + "trial": 5, + "total_s": 0.3938842079951428, + "resolve_s": 8.749775588512421e-7, + "graph_s": 0.3938557499786839, + "centroid_calls": 0, + "matrix_nnz": 789290, + "nonzero_connections": 789290 + } + ], + "equivalence": "passed", + "input_preservation": "passed" + }, + "memory_spatialdata": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 0.2763021669816226, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "kind": "memory_spatialdata", + "baseline_rss_mib": 518.578125, + "sampled_peak_rss_mib": 582.5, + "sampled_increment_mib": 63.921875, + "sampling_interval_ms": 2, + "matrix_nnz": 789290 + }, + "memory_anndata": { + "cells": 100000, + "regions": 20, + "coordinate_preparation_s": 0.2802530420012772, + "python": "3.13.3", + "source_path": "src/squidpy/__init__.py", + "packages": { + "spatialdata": "0.8.0", + "anndata": "0.13.3.post0", + "numpy": "2.5.3", + "pandas": "3.0.5", + "scipy": "1.18.1", + "scikit-learn": "1.9.1", + "shapely": "2.1.2", + "dask": "2026.8.0" + }, + "kind": "memory_anndata", + "baseline_rss_mib": 518.90625, + "sampled_peak_rss_mib": 578.546875, + "sampled_increment_mib": 59.640625, + "sampling_interval_ms": 2, + "matrix_nnz": 789290 + }, + "medians": { + "spatialdata_first_graph": { + "total_s": 0.6696101250126958, + "resolve_s": 0.27492275001714006, + "graph_s": 0.3946475409902632, + "centroid_calls": 20 + }, + "spatialdata_repeat": { + "total_s": 0.6696652910322882, + "resolve_s": 0.27566991600906476, + "graph_s": 0.3950825000065379, + "centroid_calls": 20 + }, + "anndata_coordinates": { + "total_s": 0.39268429204821587, + "resolve_s": 8.749775588512421e-7, + "graph_s": 0.3926562919514254, + "centroid_calls": 0 + } + } + } + ], + "hardware": { + "platform": "macOS-26.7-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "logical_cpus": 8 + }, + "thread_environment": { + "OMP_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + "NUMBA_NUM_THREADS": "1" + } +} diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index f46c7436f..e1664a5ef 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -442,9 +442,20 @@ def decorator2(obj: Any) -> Any: A dictionary mapping element names of the SpatialData object to coordinate systems. The elements can be either Shapes or Labels. For compatibility, the spatialdata table must annotate all regions keys. Must not be ``None`` if ``adata`` is a :class:`spatialdata.SpatialData`. + With ``coordinate_source="table"``, the element names are used to check table associations and instance + order. Coordinates are read as stored; the coordinate-system values do not trigger a transformation. table_key Key in :attr:`spatialdata.SpatialData.tables` where the spatialdata table is stored. Must not be ``None`` if - ``adata`` is a :class:`spatialdata.SpatialData`.""" + ``adata`` is a :class:`spatialdata.SpatialData`. +coordinate_source + Source of coordinates for :class:`spatialdata.SpatialData` inputs. ``"elements"`` (default) computes + element centroids in the mapped coordinate systems and stores them under ``spatial_key``. + ``"table"`` reads the selected table's existing ``obsm[spatial_key]`` coordinates, skipping centroid + computation and coordinate transformation. Supply a real numeric NumPy array of shape ``(n_obs, 2)`` + with finite values and rows in table observation order. The coordinates must already be in the desired + coordinate system, with units appropriate for the graph builder. Missing or invalid coordinates raise + an error without falling back to element centroids. Both values use existing ``obsm[spatial_key]`` + coordinates for :class:`anndata.AnnData` inputs.""" _graph_common_params = """\ percentile Percentile of the distances to use as threshold. @@ -463,7 +474,14 @@ def decorator2(obj: Any) -> Any: {_n_jobs_thread_rules} Speedup is sub-linear (memory-bandwidth bound), so parallelism mainly pays off for many large libraries.""" -_spatial_neighbors_returns = """\ +_spatial_neighbors_copy_notes = """\ +For SpatialData inputs, ``copy=True`` builds the graph in a lightweight temporary AnnData and leaves +the source table unchanged. The expression matrix is neither copied nor loaded. Table mode gives the +temporary object its own copy of the coordinates. With ``copy=False``, graph results are saved to the +selected table. Element mode writes computed coordinates to ``obsm[spatial_key]``; table mode uses +the existing coordinates.""" +_spatial_neighbors_returns = ( + """\ If ``copy = True``, returns a :class:`~squidpy.gr.SpatialNeighborsResult` with the spatial connectivities and distances matrices. @@ -471,7 +489,11 @@ def decorator2(obj: Any) -> Any: - :attr:`anndata.AnnData.obsp` ``['{key_added}_connectivities']`` - the spatial connectivities. - :attr:`anndata.AnnData.obsp` ``['{key_added}_distances']`` - the spatial distances. - - :attr:`anndata.AnnData.uns` ``['{key_added}']`` - :class:`dict` containing parameters.""" + - :attr:`anndata.AnnData.uns` ``['{key_added}']`` - :class:`dict` containing parameters. + +""" + + _spatial_neighbors_copy_notes +) d = DocstringProcessor( adata=_adata, @@ -532,4 +554,5 @@ def decorator2(obj: Any) -> Any: graph_common_params=_graph_common_params, n_jobs_libraries=_n_jobs_libraries, spatial_neighbors_returns=_spatial_neighbors_returns, + spatial_neighbors_copy_notes=_spatial_neighbors_copy_notes, ) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 613852b4e..98884083e 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import Any, NamedTuple +from typing import Any, Literal, NamedTuple import geopandas as gpd import numpy as np @@ -152,6 +152,7 @@ def spatial_neighbors( adata: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -184,15 +185,7 @@ def spatial_neighbors( ---------- %(adata)s %(spatial_key)s - If `adata` is a :class:`spatialdata.SpatialData`, the coordinates of the centroids will be stored in the - `adata` with this key. - elements_to_coordinate_systems - A dictionary mapping element names of the SpatialData object to coordinate systems. - The elements can be either Shapes or Labels. For compatibility, the spatialdata table must annotate - all regions keys. Must not be `None` if `adata` is a :class:`spatialdata.SpatialData`. - table_key - Key in :attr:`spatialdata.SpatialData.tables` where the spatialdata table is stored. Must not be `None` if - `adata` is a :class:`spatialdata.SpatialData`. + %(sdata_params)s %(library_key)s coord_type Type of coordinate system. Valid options are: @@ -309,6 +302,8 @@ def spatial_neighbors( - :attr:`anndata.AnnData.obsp` ``['{{key_added}}_connectivities']`` - the spatial connectivities. - :attr:`anndata.AnnData.obsp` ``['{{key_added}}_distances']`` - the spatial distances. - :attr:`anndata.AnnData.uns` ``['{{key_added}}']`` - :class:`dict` containing parameters. + + %(spatial_neighbors_copy_notes)s """ warnings.warn( "Calling `spatial_neighbors` is deprecated and will be removed in squidpy " @@ -321,6 +316,7 @@ def spatial_neighbors( adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -355,9 +351,12 @@ def _resolve_data( elements_to_coordinate_systems: dict[str, str] | None, table_key: str | None = None, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", library_key: str | None = None, copy: bool = False, ) -> tuple[AnnData, str | None]: + if coordinate_source not in ("elements", "table"): + raise ValueError(f"Invalid `coordinate_source` {coordinate_source!r}. Expected 'elements' or 'table'.") if not isinstance(data, SpatialData): return data, library_key @@ -387,27 +386,40 @@ def _resolve_data( elem_instances.append(element_instances) element_instances = pd.concat(elem_instances) - if (not np.all(element_instances.values == table.obs[instance_key].values)) or ( - not np.all(ordered_regions_in_table == regions) + if not np.array_equal(element_instances.values, table.obs[instance_key].values) or not np.array_equal( + ordered_regions_in_table, regions ): raise ValueError( "The spatialdata table must annotate all elements keys. Some elements are missing or not ordered correctly, please check the `elements_to_coordinate_systems` dictionary." ) - centroids = [] - for region_ in ordered_regions_in_table: - cs = elements_to_coordinate_systems[region_] - centroid = get_centroids(data[region_], coordinate_system=cs)[["x", "y"]].compute() + if coordinate_source == "table": + coords = table.obsm[spatial_key] + if not isinstance(coords, np.ndarray) or coords.dtype.kind not in "iuf": + raise TypeError(f"`table.obsm[{spatial_key!r}]` must be a real numeric NumPy array.") + if coords.shape != (table.n_obs, 2): + raise ValueError(f"`table.obsm[{spatial_key!r}]` must have shape ({table.n_obs}, 2), found {coords.shape}.") + if not np.isfinite(coords).all(): + raise ValueError(f"`table.obsm[{spatial_key!r}]` must contain only finite coordinates.") + if copy: + coords = coords.copy() + else: + centroids = [] + for region_ in ordered_regions_in_table: + cs = elements_to_coordinate_systems[region_] + centroid = get_centroids(data[region_], coordinate_system=cs)[["x", "y"]].compute() - # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 - if remove_centroids[region_]: - centroid = centroid[1:].copy() - centroids.append(centroid) + # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 + if remove_centroids[region_]: + centroid = centroid[1:].copy() + centroids.append(centroid) + coords = np.concatenate(centroids) if copy: # Graph construction only needs observations, coordinates and the uns keys # used by legacy graph dispatch. Do not copy or load expression matrices. table = AnnData(obs=table.obs.copy(), uns=table.uns.copy()) - table.obsm[spatial_key] = np.concatenate(centroids) + if copy or coordinate_source == "elements": + table.obsm[spatial_key] = coords return table, region_key @@ -418,6 +430,7 @@ def spatial_neighbors_from_builder( *, builder: GraphBuilder[Any, Any], spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -473,6 +486,7 @@ def spatial_neighbors_from_builder( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -493,6 +507,7 @@ def _prepare_spatial_neighbors_input( data: AnnData | SpatialData, *, spatial_key: str, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None, table_key: str | None, library_key: str | None, @@ -502,6 +517,7 @@ def _prepare_spatial_neighbors_input( adata, library_key = _resolve_data( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -516,6 +532,7 @@ def spatial_neighbors_knn( data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -565,6 +582,7 @@ def spatial_neighbors_knn( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -587,6 +605,7 @@ def spatial_neighbors_radius( *, radius: float | tuple[float, float], spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -623,6 +642,20 @@ def spatial_neighbors_radius( ------- %(spatial_neighbors_returns)s + Examples + -------- + Reuse coordinates stored in a SpatialData table. The coordinates must already + be in the desired coordinate system and use the same units as ``radius``. + + >>> sq.gr.spatial_neighbors_radius( + ... sdata, + ... table_key="table", + ... elements_to_coordinate_systems={"cells": "global"}, + ... spatial_key="precomputed_spatial", + ... coordinate_source="table", + ... radius=40, + ... ) + See Also -------- spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.RadiusBuilder` directly for advanced customization. @@ -638,6 +671,7 @@ def spatial_neighbors_radius( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -659,6 +693,7 @@ def spatial_neighbors_delaunay( data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -715,6 +750,7 @@ def spatial_neighbors_delaunay( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -736,6 +772,7 @@ def spatial_neighbors_grid( data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -805,6 +842,7 @@ def spatial_neighbors_grid( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, diff --git a/tests/graph/test_spatial_neighbors_coordinate_source.py b/tests/graph/test_spatial_neighbors_coordinate_source.py new file mode 100644 index 000000000..ae2de0483 --- /dev/null +++ b/tests/graph/test_spatial_neighbors_coordinate_source.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import inspect +from copy import deepcopy +from unittest.mock import PropertyMock, patch + +import numpy as np +import pandas as pd +import pytest +from anndata import AnnData, concat, read_h5ad +from scipy.sparse import csr_matrix +from spatialdata import SpatialData +from spatialdata.models import Labels2DModel, TableModel +from spatialdata.transformations import Scale, set_transformation + +from squidpy import gr +from squidpy.gr import _build +from squidpy.gr.neighbors import KNNBuilder + +pytestmark = pytest.mark.filterwarnings("ignore:Calling `spatial_neighbors` is deprecated:FutureWarning") + +INTERFACES = [ + pytest.param(gr.spatial_neighbors, {"coord_type": "generic", "n_neighs": 2}, id="legacy"), + pytest.param(gr.spatial_neighbors_knn, {"n_neighs": 2}, id="knn"), + pytest.param(gr.spatial_neighbors_radius, {"radius": 2.0}, id="radius"), + pytest.param(gr.spatial_neighbors_delaunay, {}, id="delaunay"), + pytest.param(gr.spatial_neighbors_grid, {"n_neighs": 4}, id="grid"), + pytest.param(gr.spatial_neighbors_from_builder, {"builder": KNNBuilder(n_neighs=2)}, id="builder"), +] + + +@pytest.fixture +def prepared_sdata(sdata_mask_graph): + sdata = sdata_mask_graph + coords = _build.get_centroids(sdata.shapes["circles"], coordinate_system="global")[["x", "y"]].compute() + sdata.tables["table"].obsm["spatial"] = coords.to_numpy() + return sdata + + +def assert_graph_equal(actual, expected): + for name in ("connectivities", "distances"): + np.testing.assert_allclose(getattr(actual, name).toarray(), getattr(expected, name).toarray()) + + +@pytest.mark.parametrize(("function", "kwargs"), INTERFACES) +@pytest.mark.parametrize("copy", [True, False]) +def test_table_coordinates_all_interfaces(prepared_sdata, mocker, function, kwargs, copy): + spatial_key = "precomputed_spatial" + parameter = inspect.signature(function).parameters["coordinate_source"] + assert parameter.kind == inspect.Parameter.KEYWORD_ONLY + assert parameter.default == "elements" + table = prepared_sdata.tables["table"] + coords = table.obsm.pop("spatial") + table.obsm[spatial_key] = coords + reference = AnnData(obs=table.obs.copy(), obsm={spatial_key: coords.copy()}) + expected = function(reference, spatial_key=spatial_key, library_key="region", copy=True, **kwargs) + before_obs, before_var, before_uns = table.obs.copy(), table.var.copy(), deepcopy(table.uns) + before_x = table.X.copy() + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Centroids must not be read")) + + result = function( + prepared_sdata, + table_key="table", + # The value need not name an existing coordinate system in table mode. + elements_to_coordinate_systems={"circles": "already_prepared"}, + coordinate_source="table", + spatial_key=spatial_key, + key_added="reused", + copy=copy, + **kwargs, + ) + + centroid.assert_not_called() + assert table.obsm[spatial_key] is coords + np.testing.assert_array_equal(table.X, before_x) + pd.testing.assert_frame_equal(table.obs, before_obs) + pd.testing.assert_frame_equal(table.var, before_var) + if copy: + assert_graph_equal(result, expected) + assert not table.obsp + assert table.uns == before_uns + else: + assert result is None + np.testing.assert_allclose(table.obsp["reused_connectivities"].toarray(), expected.connectivities.toarray()) + np.testing.assert_allclose(table.obsp["reused_distances"].toarray(), expected.distances.toarray()) + assert table.uns["reused_neighbors"]["connectivities_key"] == "reused_connectivities" + + +def test_default_elements_and_table_agree(prepared_sdata, mocker): + params = { + "table_key": "table", + "elements_to_coordinate_systems": {"circles": "global"}, + "copy": True, + "radius": 2.0, + } + centroid = mocker.spy(_build, "get_centroids") + expected = gr.spatial_neighbors_radius(prepared_sdata, **params) + assert centroid.call_count == 1 + centroid.reset_mock() + actual = gr.spatial_neighbors_radius(prepared_sdata, coordinate_source="table", **params) + centroid.assert_not_called() + assert_graph_equal(actual, expected) + + +def test_table_mode_does_not_transform_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + coords = table.obsm["spatial"].copy() + set_transformation( + prepared_sdata.shapes["circles"], Scale([10.0, 10.0], axes=("x", "y")), to_coordinate_system="scaled" + ) + params = {"table_key": "table", "elements_to_coordinate_systems": {"circles": "scaled"}, "radius": 2.0} + expected = gr.spatial_neighbors_radius(AnnData(obsm={"spatial": coords}), radius=2.0, copy=True) + gr.spatial_neighbors_radius(prepared_sdata, coordinate_source="table", **params) + np.testing.assert_allclose(table.obsp["spatial_distances"].toarray(), expected.distances.toarray()) + np.testing.assert_array_equal(table.obsm["spatial"], coords) + # The default still recomputes and transforms centroids, overwriting stored coordinates. + gr.spatial_neighbors_radius(prepared_sdata, **params) + np.testing.assert_allclose(table.obsm["spatial"], coords * 10.0) + assert table.obsp["spatial_connectivities"].nnz < expected.connectivities.nnz + + +def test_copy_isolates_coordinates_even_for_a_mutating_builder(prepared_sdata, mocker): + table = prepared_sdata.tables["table"] + coords = table.obsm["spatial"] + before = coords.copy() + builder = KNNBuilder(n_neighs=2) + original = builder.build_graph + + def mutate(received): + assert not np.shares_memory(received, coords) + received[:] += 100.0 + return original(received) + + mocker.patch.object(builder, "build_graph", side_effect=mutate) + run = mocker.spy(_build, "_run_spatial_neighbors") + gr.spatial_neighbors_from_builder( + prepared_sdata, + builder=builder, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + resolved = run.call_args.args[0] + assert resolved.X is None + assert not np.shares_memory(resolved.obsm["spatial"], coords) + np.testing.assert_array_equal(coords, before) + assert not table.obsp + + +@pytest.mark.parametrize("input_type", ["anndata", "spatialdata"]) +def test_invalid_source(prepared_sdata, input_type): + data = prepared_sdata if input_type == "spatialdata" else prepared_sdata.tables["table"] + with pytest.raises(ValueError, match="coordinate_source"): + gr.spatial_neighbors_knn(data, coordinate_source="auto", copy=True) + + +def test_anndata_sources_use_existing_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + builder = KNNBuilder(n_neighs=2) + expected = gr.spatial_neighbors_from_builder(table, builder=builder, copy=True) + actual = gr.spatial_neighbors_from_builder(table, builder=builder, coordinate_source="table", copy=True) + assert_graph_equal(actual, expected) + + +def test_missing_coordinates_do_not_fall_back(sdata_mask_graph, mocker): + centroid = mocker.patch.object(_build, "get_centroids") + with pytest.raises(KeyError, match="precomputed_spatial"): + gr.spatial_neighbors_knn( + sdata_mask_graph, + table_key="table", + spatial_key="precomputed_spatial", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + ) + centroid.assert_not_called() + assert not sdata_mask_graph.tables["table"].obsm + assert not sdata_mask_graph.tables["table"].obsp + + +def test_invalid_coordinate_dtype(prepared_sdata): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = table.obsm["spatial"].astype(str) + with pytest.raises(TypeError, match="real numeric NumPy array"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + + +def test_non_numpy_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = csr_matrix(table.obsm["spatial"]) + with pytest.raises(TypeError, match="real numeric NumPy array"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + + +@pytest.mark.parametrize("dtype", [np.int32, np.uint32]) +def test_real_numeric_dtypes(prepared_sdata, dtype): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = np.arange(table.n_obs * 2).reshape(table.n_obs, 2).astype(dtype) + result = gr.spatial_neighbors_knn( + prepared_sdata, + n_neighs=2, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + assert result.connectivities.nnz == table.n_obs * 2 + + +@pytest.mark.parametrize("trailing_shape", [(3,), (2, 1)]) +def test_invalid_coordinate_shape(prepared_sdata, trailing_shape): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = np.ones((table.n_obs, *trailing_shape)) + with pytest.raises(ValueError, match="must have shape"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + + +def test_nonfinite_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + table.obsm["spatial"][0, 0] = np.nan + with pytest.raises(ValueError, match="only finite coordinates"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + assert not table.obsp + + +def test_multiple_regions_preserve_row_order_and_separate_graphs(prepared_sdata, mocker): + sdata = prepared_sdata + sdata.shapes["other"] = sdata.shapes["circles"].copy() + table = concat({"circles": sdata.tables["table"], "other": sdata.tables["table"]}, label="region", index_unique="-") + sdata.tables["table"] = TableModel.parse( + table, region=["circles", "other"], region_key="region", instance_key="instance_id" + ) + before_obs, before_coords = table.obs.copy(), table.obsm["spatial"].copy() + params = { + "table_key": "table", + "elements_to_coordinate_systems": {"circles": "global", "other": "global"}, + "n_neighs": 2, + } + expected = gr.spatial_neighbors_knn(sdata, copy=True, **params) + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Must reuse table coordinates")) + result = gr.spatial_neighbors_knn(sdata, coordinate_source="table", copy=True, n_jobs=2, **params) + centroid.assert_not_called() + assert_graph_equal(result, expected) + split = table.n_obs // 2 + assert result.connectivities[:split, split:].nnz == 0 + assert result.connectivities[split:, :split].nnz == 0 + pd.testing.assert_frame_equal(table.obs, before_obs) + np.testing.assert_array_equal(table.obsm["spatial"], before_coords) + + +def test_reordered_observations_and_custom_table_key(prepared_sdata, mocker): + table = prepared_sdata.tables.pop("table")[::-1].copy() + prepared_sdata.tables["cells_table"] = table + expected = gr.spatial_neighbors_knn(table, n_neighs=2, copy=True) + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Must reuse table coordinates")) + actual = gr.spatial_neighbors_knn( + prepared_sdata, + n_neighs=2, + table_key="cells_table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + centroid.assert_not_called() + assert_graph_equal(actual, expected) + + +@pytest.mark.parametrize("problem", ["unknown_instance", "region_order"]) +def test_association_checks_remain_required(prepared_sdata, problem): + sdata = prepared_sdata + table = sdata.tables["table"] + mapping = {"circles": "global"} + if problem == "unknown_instance": + table.obs.loc[table.obs_names[0], "instance_id"] = 999_999 + else: + sdata.shapes["other"] = sdata.shapes["circles"].copy() + table = concat({"other": table, "circles": table}, label="region", index_unique="-") + sdata.tables["table"] = TableModel.parse( + table, region=["circles", "other"], region_key="region", instance_key="instance_id" + ) + mapping = {"circles": "global", "other": "global"} + with pytest.raises(ValueError, match="ordered"): + gr.spatial_neighbors_knn( + sdata, table_key="table", elements_to_coordinate_systems=mapping, coordinate_source="table", copy=True + ) + + +def test_labels_keep_background_and_instance_checks(mocker): + labels = Labels2DModel.parse(np.array([[0, 1, 1], [2, 2, 3], [0, 3, 3]], dtype=np.int32), dims=("y", "x")) + table = TableModel.parse( + AnnData(obs=pd.DataFrame({"region": pd.Categorical(["cells"] * 3), "instance_id": [1, 2, 3]})), + region="cells", + region_key="region", + instance_key="instance_id", + ) + sdata = SpatialData(labels={"cells": labels}, tables={"table": table}) + params = {"table_key": "table", "elements_to_coordinate_systems": {"cells": "global"}, "n_neighs": 1} + gr.spatial_neighbors_knn(sdata, **params) + expected = gr.spatial_neighbors_knn(sdata, copy=True, **params) + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Must reuse coordinates")) + actual = gr.spatial_neighbors_knn(sdata, coordinate_source="table", copy=True, **params) + centroid.assert_not_called() + assert_graph_equal(actual, expected) + table.obs["instance_id"] = [0, 1, 2] + with pytest.raises(ValueError, match="ordered"): + gr.spatial_neighbors_knn(sdata, coordinate_source="table", copy=True, **params) + + +@pytest.mark.parametrize("fail", [True, False]) +def test_backed_table_does_not_load_expression(prepared_sdata, tmp_path, fail): + original = prepared_sdata.tables["table"] + path = tmp_path / "table.h5ad" + original.write_h5ad(path) + table = read_h5ad(path, backed="r") + prepared_sdata.tables["table"] = table + params = { + "table_key": "table", + "elements_to_coordinate_systems": {"circles": "global"}, + "coordinate_source": "table", + "copy": True, + "n_neighs": table.n_obs + 1 if fail else 2, + } + # A graph needs observations and coordinates only; any access to X is a regression. + with patch.object( + AnnData, "X", new_callable=PropertyMock, side_effect=AssertionError("Expression must not be loaded") + ): + try: + if fail: + with pytest.raises(ValueError, match="n_neighbors"): + gr.spatial_neighbors_knn(prepared_sdata, **params) + else: + actual = gr.spatial_neighbors_knn(prepared_sdata, **params) + expected = gr.spatial_neighbors_knn(original, n_neighs=2, library_key="region", copy=True) + assert_graph_equal(actual, expected) + assert table.isbacked + np.testing.assert_array_equal(table.obsm["spatial"], original.obsm["spatial"]) + pd.testing.assert_frame_equal(table.obs, original.obs) + assert table.uns == original.uns + assert not table.obsp + finally: + table.file.close() + on_disk = read_h5ad(path) + np.testing.assert_array_equal(on_disk.X, original.X) + np.testing.assert_array_equal(on_disk.obsm["spatial"], original.obsm["spatial"]) + assert not on_disk.obsp