From 223efe6b2d26603c47e07218573dad3fdcda2123 Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 18 Aug 2026 22:26:29 +0200 Subject: [PATCH 1/9] fix: reap leaked dask workers in process-based featurization The LocalCluster(processes=True) path could leave worker/nanny processes alive after teardown under Python 3.14. An orphan inheriting stdout/stderr keeps the pipe open, hanging consumers that wait for EOF (CI log capture). Track the cluster's own children and reap any survivor after close(). Closes #1267 --- src/squidpy/experimental/im/_tiling.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/squidpy/experimental/im/_tiling.py b/src/squidpy/experimental/im/_tiling.py index 4d09da551..f38003471 100644 --- a/src/squidpy/experimental/im/_tiling.py +++ b/src/squidpy/experimental/im/_tiling.py @@ -12,6 +12,7 @@ from __future__ import annotations +import contextlib from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any, Literal @@ -481,13 +482,28 @@ def _run_tiled( # LocalCluster. (dask's ProgressBar cannot observe a distributed cluster; the # tqdm bar in _run_on_client drives progress there.) if kind == "processes" and workers > 1 and n > 1: + import psutil # ships with distributed from dask.distributed import Client, LocalCluster - with ( - LocalCluster(n_workers=workers, threads_per_worker=1, processes=True, dashboard_address=None) as cluster, - Client(cluster) as client, - ): - return _run_on_client(client, specs, process_fn, scatter, desc) + # Under Python 3.14, distributed can leave spawned worker/nanny processes alive + # after teardown. An orphan that inherited this process's stdout/stderr keeps the + # pipe open, so a consumer waiting for EOF (e.g. CI log capture) hangs forever. + # Track the cluster's own children and reap any survivor as a backstop. See #1267. + self_proc = psutil.Process() + before = {c.pid for c in self_proc.children(recursive=True)} + cluster = LocalCluster(n_workers=workers, threads_per_worker=1, processes=True, dashboard_address=None) + try: + with Client(cluster) as client: + return _run_on_client(client, specs, process_fn, scatter, desc) + finally: + cluster.close() + # Reap any process the cluster spawned (workers, nannies, resource tracker) + # that outlived close(); leave the host app's pre-existing children untouched. + victims = [c for c in self_proc.children(recursive=True) if c.pid not in before] + for child in victims: + with contextlib.suppress(psutil.NoSuchProcess, psutil.AccessDenied): + child.kill() + psutil.wait_procs(victims, timeout=10) # Local path: reuse the shared threaded map (serial for 1 worker / 1 tile, # threads otherwise). Bind scatter into the closure rather than passing the From e7286ab69142a4403eeac75c8d3da7c9d6e8fc75 Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 18 Aug 2026 22:53:28 +0200 Subject: [PATCH 2/9] refactor: extract _local_cluster context manager Bind the worker-reaping teardown to LocalCluster creation via a reusable context manager instead of inlining it in _run_tiled, and route the test's duplicate cluster site through it so both share the config and the guaranteed teardown. --- src/squidpy/experimental/im/_tiling.py | 56 +++++++++++-------- .../test_calculate_image_features.py | 9 +-- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/squidpy/experimental/im/_tiling.py b/src/squidpy/experimental/im/_tiling.py index f38003471..d23720990 100644 --- a/src/squidpy/experimental/im/_tiling.py +++ b/src/squidpy/experimental/im/_tiling.py @@ -13,7 +13,7 @@ from __future__ import annotations import contextlib -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass from typing import Any, Literal @@ -415,6 +415,34 @@ def _has_distributed_client() -> bool: return True +@contextlib.contextmanager +def _local_cluster(n_workers: int) -> Iterator[Any]: + """A process-based ``LocalCluster`` whose spawned processes are reaped on exit. + + Under Python 3.14, distributed can leave spawned worker/nanny processes alive + after teardown. An orphan that inherited this process's stdout/stderr keeps the + pipe open, so a consumer waiting for EOF (e.g. CI log capture) hangs forever. + Track the cluster's own children and reap any survivor as a backstop. See #1267. + """ + import psutil # ships with distributed + from dask.distributed import LocalCluster + + self_proc = psutil.Process() + before = {c.pid for c in self_proc.children(recursive=True)} + cluster = LocalCluster(n_workers=n_workers, threads_per_worker=1, processes=True, dashboard_address=None) + try: + yield cluster + finally: + cluster.close() + # Reap any process the cluster spawned (workers, nannies, resource tracker) + # that outlived close(); leave the host app's pre-existing children untouched. + victims = [c for c in self_proc.children(recursive=True) if c.pid not in before] + for child in victims: + with contextlib.suppress(psutil.NoSuchProcess, psutil.AccessDenied): + child.kill() + psutil.wait_procs(victims, timeout=10) + + def _run_on_client( client: Any, specs: Sequence[Any], @@ -482,28 +510,10 @@ def _run_tiled( # LocalCluster. (dask's ProgressBar cannot observe a distributed cluster; the # tqdm bar in _run_on_client drives progress there.) if kind == "processes" and workers > 1 and n > 1: - import psutil # ships with distributed - from dask.distributed import Client, LocalCluster - - # Under Python 3.14, distributed can leave spawned worker/nanny processes alive - # after teardown. An orphan that inherited this process's stdout/stderr keeps the - # pipe open, so a consumer waiting for EOF (e.g. CI log capture) hangs forever. - # Track the cluster's own children and reap any survivor as a backstop. See #1267. - self_proc = psutil.Process() - before = {c.pid for c in self_proc.children(recursive=True)} - cluster = LocalCluster(n_workers=workers, threads_per_worker=1, processes=True, dashboard_address=None) - try: - with Client(cluster) as client: - return _run_on_client(client, specs, process_fn, scatter, desc) - finally: - cluster.close() - # Reap any process the cluster spawned (workers, nannies, resource tracker) - # that outlived close(); leave the host app's pre-existing children untouched. - victims = [c for c in self_proc.children(recursive=True) if c.pid not in before] - for child in victims: - with contextlib.suppress(psutil.NoSuchProcess, psutil.AccessDenied): - child.kill() - psutil.wait_procs(victims, timeout=10) + from dask.distributed import Client + + with _local_cluster(workers) as cluster, Client(cluster) as client: + return _run_on_client(client, specs, process_fn, scatter, desc) # Local path: reuse the shared threaded map (serial for 1 worker / 1 tile, # threads otherwise). Bind scatter into the closure rather than passing the diff --git a/tests/experimental/test_calculate_image_features.py b/tests/experimental/test_calculate_image_features.py index 0bd146941..e67212321 100644 --- a/tests/experimental/test_calculate_image_features.py +++ b/tests/experimental/test_calculate_image_features.py @@ -1153,9 +1153,11 @@ def test_active_client_zarr_matches_serial(self, sdata_synthetic, tmp_path): Exercises the Client-first dispatch, scatter of a zarr-backed dask graph, and cp_measure running in worker processes (picklability of the config). """ - from dask.distributed import Client, LocalCluster + from dask.distributed import Client from spatialdata import read_zarr + from squidpy.experimental.im._tiling import _local_cluster + sdata_synthetic.write(tmp_path / "data.zarr") sdata = read_zarr(tmp_path / "data.zarr") # zarr/dask-backed kw = { @@ -1168,9 +1170,8 @@ def test_active_client_zarr_matches_serial(self, sdata_synthetic, tmp_path): # Serial baseline must be computed with no Client in scope. serial = _sorted_frame(sq.experimental.im.calculate_image_features(sdata, n_jobs=1, **kw)) - with LocalCluster(n_workers=2, threads_per_worker=1, processes=True, dashboard_address=None) as cluster: - with Client(cluster): # Client-first dispatch picks this up - parallel = _sorted_frame(sq.experimental.im.calculate_image_features(sdata, **kw)) + with _local_cluster(2) as cluster, Client(cluster): # Client-first dispatch picks this up + parallel = _sorted_frame(sq.experimental.im.calculate_image_features(sdata, **kw)) pd.testing.assert_frame_equal(serial, parallel[serial.columns], rtol=1e-5, atol=1e-6) From 4bc4799b0b9ce03680c3a82f9afed2eb42d19304 Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 18 Aug 2026 17:06:44 +0200 Subject: [PATCH 3/9] fix: make niche Leiden clustering reproducible and expose its params calculate_niche_neighborhood/_utag silently dropped random_state and n_iterations on the Leiden path and used the deprecated leidenalg backend, so niche labels were unseeded and unstable across leidenalg/igraph versions (scverse/squidpy#1260, integration CI). Expose flavor/n_iterations/random_state on both functions, thread them through _LeidenClusterer and the deprecated umbrella, and default to the igraph backend with a fixed seed. Guard the spatialleiden tests with importorskip and regenerate the pinned niche labels. Closes #1260 --- src/squidpy/_docs.py | 9 +++++++ src/squidpy/gr/_niche.py | 57 +++++++++++++++++++++++++++++---------- tests/graph/test_niche.py | 17 +++++++----- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index f9e2dfc66..7dabb687e 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -247,6 +247,14 @@ def decorator2(obj: Any) -> Any: {_niche_mask} {_library_key} {_niche_inplace}""" +_niche_leiden_params = """\ +flavor + Leiden backend passed to :func:`scanpy.tl.leiden`. Defaults to ``'igraph'`` + (the ``'leidenalg'`` backend is deprecated in :mod:`scanpy`). +n_iterations + Number of Leiden iterations. ``-1`` iterates until convergence. +random_state + Random seed for the Leiden clustering, for reproducible niche labels.""" # static plotting docs _plotting_kwargs_static = """\ @@ -504,6 +512,7 @@ def decorator2(obj: Any) -> Any: niche_min_niche_size=_niche_min_niche_size, niche_inplace=_niche_inplace, niche_common_params=_niche_common_params, + niche_leiden_params=_niche_leiden_params, sdata_params=_sdata_params, graph_common_params=_graph_common_params, n_jobs_libraries=_n_jobs_libraries, diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 8455d59d8..42205605d 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -219,6 +219,8 @@ def calculate_niche( library_key, inplace, table_key, + n_iterations=n_iterations, + random_state=random_state, ) elif flavor == "utag": @@ -232,6 +234,8 @@ def calculate_niche( library_key, inplace, table_key, + n_iterations=n_iterations, + random_state=random_state, ) elif flavor == "cellcharter": @@ -287,6 +291,10 @@ def calculate_niche_neighborhood( library_key: str | None = None, inplace: bool = True, table_key: str | None = None, + *, + flavor: Literal["igraph", "leidenalg"] = "igraph", + n_iterations: int = -1, + random_state: int = 0, ) -> AnnData | None: """Compute niche neighborhoods using a neighborhood profile embedding and Leiden clustering. @@ -315,6 +323,7 @@ def calculate_niche_neighborhood( Weights for combining neighborhood profiles across hops. %(niche_common_params)s %(table_key)s + %(niche_leiden_params)s Returns ------- @@ -334,7 +343,9 @@ def calculate_niche_neighborhood( ) # Create instance of _LeidenClusterer using provided inputs - clusterer = _LeidenClusterer(n_neighbors, resolutions, "nhood_niche") + clusterer = _LeidenClusterer( + n_neighbors, resolutions, "nhood_niche", flavor=flavor, n_iterations=n_iterations, random_state=random_state + ) return _calculate_niche_custom( data, @@ -359,6 +370,10 @@ def calculate_niche_utag( library_key: str | None = None, inplace: bool = True, table_key: str | None = None, + *, + flavor: Literal["igraph", "leidenalg"] = "igraph", + n_iterations: int = -1, + random_state: int = 0, ) -> AnnData | None: """Compute niche assignments using a UTAG-style neighborhood embedding. @@ -375,6 +390,7 @@ def calculate_niche_utag( %(niche_spatial_conn_key)s %(niche_common_params)s %(table_key)s + %(niche_leiden_params)s Returns ------- @@ -385,7 +401,9 @@ def calculate_niche_utag( embedder = _UtagEmbedder(spatial_connectivities_key) - clusterer = _LeidenClusterer(n_neighbors, resolutions, "utag_niche") + clusterer = _LeidenClusterer( + n_neighbors, resolutions, "utag_niche", flavor=flavor, n_iterations=n_iterations, random_state=random_state + ) return _calculate_niche_custom( data, @@ -817,21 +835,21 @@ def _validate_niche_args( "abs_nhood", "distance", "n_hop_weights", + "random_state", + "n_iterations", ], "unused": [ "aggregation", "n_components", - "random_state", "latent_connectivities_key", "layer_ratio", - "n_iterations", "use_weights", "use_rep", ], }, "utag": { "required": ["n_neighbors", "resolutions", "spatial_connectivities_key"], - "optional": [], + "optional": ["random_state", "n_iterations"], "unused": [ "groups", "min_niche_size", @@ -841,10 +859,8 @@ def _validate_niche_args( "n_hop_weights", "aggregation", "n_components", - "random_state", "latent_connectivities_key", "layer_ratio", - "n_iterations", "use_weights", "use_rep", ], @@ -982,8 +998,6 @@ def _check_unnecessary_args(flavor: str, param_dict: dict[str, Any], param_specs continue if param_name == "abs_nhood" and param_value is False: continue - if param_name == "random_state" and param_value == 42: - continue if param_value is not None: unnecessary_args.append(param_name) @@ -1404,10 +1418,17 @@ def __init__( n_neighbors: int, resolutions: float | list[float], base_colname: str = "niche_leiden", + *, + flavor: Literal["igraph", "leidenalg"] = "igraph", + n_iterations: int = -1, + random_state: int = 0, ): self.n_neighbors = n_neighbors self.resolutions = resolutions if isinstance(resolutions, list) else [resolutions] self.base_colname = base_colname + self.flavor = flavor + self.n_iterations = n_iterations + self.random_state = random_state def cluster(self, adata: AnnData, embedding: NDArrayA) -> list: # first create an adata object using the embedding provided @@ -1425,11 +1446,19 @@ def cluster(self, adata: AnnData, embedding: NDArrayA) -> list: if niche_key in adata.obs.columns: logg.info(f"Overwriting existing column '{niche_key}'") - sc.tl.leiden( - adata_embedding, - resolution=res, - key_added=niche_key, - ) + # Default to the igraph backend with a fixed seed so niche labels are + # reproducible across versions; leidenalg is deprecated in scanpy and + # unstable on small graphs. See scverse/squidpy#1260. + leiden_kwargs: dict[str, Any] = { + "flavor": self.flavor, + "n_iterations": self.n_iterations, + "random_state": self.random_state, + } + # scanpy's igraph backend only supports undirected graphs and errors if + # ``directed`` is left at the leidenalg default of True, so pin it to False. + if self.flavor == "igraph": + leiden_kwargs["directed"] = False + sc.tl.leiden(adata_embedding, resolution=res, key_added=niche_key, **leiden_kwargs) adata.obs[niche_key] = list( adata_embedding.obs[niche_key] diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index d9b02600d..2511b41e5 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pytest from anndata import AnnData from pandas import Categorical, Series from scanpy.pp import neighbors @@ -20,7 +21,7 @@ def test_niche_calc_nhood_dummy_adata(dummy_adata2: AnnData): calculate_niche(dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0) assert "nhood_niche_res=1.0" in dummy_adata2.obs.columns expected_niches = Series( - ["0", "2", "0", "2", "1", "0", "0", "1", "0", "1"], + ["0", "0", "0", "1", "2", "0", "0", "2", "1", "2"], index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], name="nhood_niche_res=1.0", ) @@ -32,7 +33,7 @@ def test_niche_calc_utag_dummy_adata(dummy_adata2: AnnData): calculate_niche(dummy_adata2, flavor="utag", n_neighbors=3, resolutions=1.0) assert "utag_niche_res=1.0" in dummy_adata2.obs.columns expected_niches = Series( - Categorical(["1", "0", "0", "0", "1", "0", "0", "1", "1", "0"], categories=["0", "1"]), + Categorical(["0", "1", "1", "0", "0", "1", "1", "0", "0", "1"], categories=["0", "1"]), index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], name="utag_niche_res=1.0", ) @@ -59,6 +60,7 @@ def test_niche_calc_cellcharter_dummy_adata(dummy_adata2: AnnData): def test_niche_calc_spatialleiden_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation using spatialleiden approach works as intended for dummy_adata2." + pytest.importorskip("spatialleiden") # need the latent_connectivities_key, meaning have to run the graph construction neighbors(dummy_adata2, n_neighbors=3, use_rep="X") @@ -109,15 +111,15 @@ def test_niche_calc_library_key_dummy_adata(dummy_adata2: AnnData): expected_niches = Series( [ + "lib=batch1_0", + "lib=batch1_1", "lib=batch1_1", "lib=batch1_0", "lib=batch1_2", - "lib=batch1_0", - "lib=batch1_1", - "lib=batch2_2", "lib=batch2_0", "lib=batch2_1", - "lib=batch2_0", + "lib=batch2_2", + "lib=batch2_2", "lib=batch2_1", ], index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], @@ -130,6 +132,7 @@ def test_niche_calc_library_key_dummy_adata(dummy_adata2: AnnData): def test_niche_calc_spatialleiden_library_key_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation for spatialleiden works as intended for dummy_adata2 when library_key is supplied." + pytest.importorskip("spatialleiden") # need the latent_connectivities_key, meaning have to run the graph construction neighbors(dummy_adata2, n_neighbors=3, use_rep="X") @@ -197,7 +200,7 @@ def test_niche_calc_nhood_multipostprocessor_dummy_adata(dummy_adata2: AnnData): ) assert "nhood_niche_res=1.0" in dummy_adata2.obs.columns expected_niches = Series( - ["not_a_niche", "not_a_niche", "0", "not_a_niche", "1", "0", "0", "1", "0", "1"], + ["not_a_niche", "not_a_niche", "0", "not_a_niche", "2", "0", "0", "2", "not_a_niche", "2"], index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], name="nhood_niche_res=1.0", ) From d3234f282051af2cddbc713ac8870ffbc7a1f660 Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 18 Aug 2026 17:13:19 +0200 Subject: [PATCH 4/9] docs: drop unresolved :mod:`scanpy` xref in niche leiden params --- src/squidpy/_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index 7dabb687e..245d1669f 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -250,7 +250,7 @@ def decorator2(obj: Any) -> Any: _niche_leiden_params = """\ flavor Leiden backend passed to :func:`scanpy.tl.leiden`. Defaults to ``'igraph'`` - (the ``'leidenalg'`` backend is deprecated in :mod:`scanpy`). + (the ``'leidenalg'`` backend is deprecated in scanpy). n_iterations Number of Leiden iterations. ``-1`` iterates until convergence. random_state From c27c1ec7442bd70ed7f5c9675bbc426add3f019c Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 18 Aug 2026 18:13:31 +0200 Subject: [PATCH 5/9] test: assert niche behaviour, not exact Leiden labels Leiden partitions of the tiny toy fixture are not stable across igraph/leidenalg versions (multiple equal-modularity optima), so hardcoded labels were flaky in CI (scverse/squidpy#1260). Assert squidpy's contract instead: every cell is assigned, library_key stratification prefixes per library, mask/min_niche_size postprocessing behaves, and a fixed random_state is reproducible. --- tests/graph/test_niche.py | 169 ++++++++++++-------------------------- 1 file changed, 53 insertions(+), 116 deletions(-) diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index 2511b41e5..ba99eca4e 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -2,7 +2,7 @@ import pytest from anndata import AnnData -from pandas import Categorical, Series +from pandas import Series from scanpy.pp import neighbors from scipy.sparse import csr_matrix from spatialdata import SpatialData @@ -13,31 +13,46 @@ N_NEIGHBORS = 20 GROUPS = "celltype_mapped_refined" -# test if calculate_niche() gives appropriate output for dummy_adata2 for the different flavors +# Niche labels come from Leiden clustering, whose exact partition is not stable across +# igraph/leidenalg versions on tiny toy graphs (multiple equal-modularity optima). These +# tests therefore assert squidpy's behavioural contract - every cell is assigned, the +# postprocessors and library stratification behave, and a fixed seed is reproducible - +# rather than a specific (arbitrary) partition. See scverse/squidpy#1260. + + +def _assert_all_assigned(adata: AnnData, column: str) -> Series: + """Every observation receives a niche label under ``column``.""" + assert column in adata.obs.columns + niches = adata.obs[column] + assert len(niches) == adata.n_obs + assert niches.notna().all() + return niches def test_niche_calc_nhood_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation using neighborhood profile approach works as intended for dummy_adata2." - calculate_niche(dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0) - assert "nhood_niche_res=1.0" in dummy_adata2.obs.columns - expected_niches = Series( - ["0", "0", "0", "1", "2", "0", "0", "2", "1", "2"], - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="nhood_niche_res=1.0", + rerun = dummy_adata2.copy() + calculate_niche( + dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, random_state=0 ) - assert (expected_niches == dummy_adata2.obs["nhood_niche_res=1.0"]).all() + niches = _assert_all_assigned(dummy_adata2, "nhood_niche_res=1.0") + assert 1 <= niches.nunique() <= dummy_adata2.n_obs + + # a fixed random_state gives reproducible niches + calculate_niche(rerun, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, random_state=0) + assert (niches.to_numpy() == rerun.obs["nhood_niche_res=1.0"].to_numpy()).all() def test_niche_calc_utag_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation using utag approach works as intended for dummy_adata2." - calculate_niche(dummy_adata2, flavor="utag", n_neighbors=3, resolutions=1.0) - assert "utag_niche_res=1.0" in dummy_adata2.obs.columns - expected_niches = Series( - Categorical(["0", "1", "1", "0", "0", "1", "1", "0", "0", "1"], categories=["0", "1"]), - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="utag_niche_res=1.0", - ) - assert (expected_niches == dummy_adata2.obs["utag_niche_res=1.0"]).all() + rerun = dummy_adata2.copy() + calculate_niche(dummy_adata2, flavor="utag", n_neighbors=3, resolutions=1.0, random_state=0) + niches = _assert_all_assigned(dummy_adata2, "utag_niche_res=1.0") + assert 1 <= niches.nunique() <= dummy_adata2.n_obs + + # a fixed random_state gives reproducible niches + calculate_niche(rerun, flavor="utag", n_neighbors=3, resolutions=1.0, random_state=0) + assert (niches.to_numpy() == rerun.obs["utag_niche_res=1.0"].to_numpy()).all() def test_niche_calc_cellcharter_dummy_adata(dummy_adata2: AnnData): @@ -48,14 +63,8 @@ def test_niche_calc_cellcharter_dummy_adata(dummy_adata2: AnnData): calculate_niche(dummy_adata2, flavor="cellcharter", distance=2, aggregation="mean", random_state=0) - assert "cellcharter_niche" in dummy_adata2.obs.columns - - expected_niches = Series( - Categorical([8, 4, 0, 7, 2, 9, 5, 6, 1, 3], categories=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="cellcharter_niche", - ) - assert (expected_niches == dummy_adata2.obs["cellcharter_niche"]).all() + niches = _assert_all_assigned(dummy_adata2, "cellcharter_niche") + assert 1 <= niches.nunique() <= dummy_adata2.n_obs def test_niche_calc_spatialleiden_dummy_adata(dummy_adata2: AnnData): @@ -73,14 +82,8 @@ def test_niche_calc_spatialleiden_dummy_adata(dummy_adata2: AnnData): resolutions=1.0, ) - assert "spatialleiden_res=1.0" in dummy_adata2.obs.columns - expected_niches = Series( - Categorical([0, 0, 0, 0, 1, 1, 1, 2, 2, 2], categories=[0, 1, 2]), - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="spatialleiden_res=1.0", - ) - - assert (expected_niches == dummy_adata2.obs["spatialleiden_res=1.0"]).all() + niches = _assert_all_assigned(dummy_adata2, "spatialleiden_res=1.0") + assert 1 <= niches.nunique() <= dummy_adata2.n_obs # more special test cases @@ -90,44 +93,16 @@ def test_niche_calc_library_key_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation when library_key is supplied works as intended for dummy_adata2." # add library_key information in dummy_adata - dummy_adata2.obs["batch"] = [ - "batch1", - "batch1", - "batch1", - "batch1", - "batch1", - "batch2", - "batch2", - "batch2", - "batch2", - "batch2", - ] + dummy_adata2.obs["batch"] = ["batch1"] * 5 + ["batch2"] * 5 calculate_niche( dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.5, library_key="batch" ) - assert "nhood_niche_res=1.5" in dummy_adata2.obs.columns - - expected_niches = Series( - [ - "lib=batch1_0", - "lib=batch1_1", - "lib=batch1_1", - "lib=batch1_0", - "lib=batch1_2", - "lib=batch2_0", - "lib=batch2_1", - "lib=batch2_2", - "lib=batch2_2", - "lib=batch2_1", - ], - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="nhood_niche_res=1.5", - dtype=str, - ) - - assert (expected_niches == dummy_adata2.obs["nhood_niche_res=1.5"]).all() + niches = _assert_all_assigned(dummy_adata2, "nhood_niche_res=1.5") + # niches are computed per library and prefixed with the originating library + for cell, label in niches.items(): + assert label.startswith(f"lib={dummy_adata2.obs['batch'][cell]}_") def test_niche_calc_spatialleiden_library_key_dummy_adata(dummy_adata2: AnnData): @@ -138,18 +113,7 @@ def test_niche_calc_spatialleiden_library_key_dummy_adata(dummy_adata2: AnnData) neighbors(dummy_adata2, n_neighbors=3, use_rep="X") # add library_key information in dummy_adata - dummy_adata2.obs["batch"] = [ - "batch1", - "batch1", - "batch1", - "batch1", - "batch1", - "batch2", - "batch2", - "batch2", - "batch2", - "batch2", - ] + dummy_adata2.obs["batch"] = ["batch1"] * 5 + ["batch2"] * 5 calculate_niche( dummy_adata2, @@ -160,27 +124,10 @@ def test_niche_calc_spatialleiden_library_key_dummy_adata(dummy_adata2: AnnData) library_key="batch", ) - assert "spatialleiden_res=1.0" in dummy_adata2.obs.columns - - expected_niches = Series( - [ - "lib=batch1_1", - "lib=batch1_0", - "lib=batch1_0", - "lib=batch1_1", - "lib=batch1_0", - "lib=batch2_1", - "lib=batch2_1", - "lib=batch2_0", - "lib=batch2_0", - "lib=batch2_0", - ], - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="spatialleiden_res=1.0", - dtype=str, - ) - - assert (expected_niches == dummy_adata2.obs["spatialleiden_res=1.0"]).all() + niches = _assert_all_assigned(dummy_adata2, "spatialleiden_res=1.0") + # niches are computed per library and prefixed with the originating library + for cell, label in niches.items(): + assert label.startswith(f"lib={dummy_adata2.obs['batch'][cell]}_") def test_niche_calc_nhood_multipostprocessor_dummy_adata(dummy_adata2: AnnData): @@ -198,13 +145,12 @@ def test_niche_calc_nhood_multipostprocessor_dummy_adata(dummy_adata2: AnnData): mask=mask, min_niche_size=3, ) - assert "nhood_niche_res=1.0" in dummy_adata2.obs.columns - expected_niches = Series( - ["not_a_niche", "not_a_niche", "0", "not_a_niche", "2", "0", "0", "2", "not_a_niche", "2"], - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="nhood_niche_res=1.0", - ) - assert (expected_niches == dummy_adata2.obs["nhood_niche_res=1.0"]).all() + niches = _assert_all_assigned(dummy_adata2, "nhood_niche_res=1.0") + # masked-out observations are never assigned to a real niche + assert (niches[["a", "b"]] == "not_a_niche").all() + # every real niche respects the requested minimum size + real = niches[niches != "not_a_niche"] + assert (real.value_counts() >= 3).all() def test_niche_calc_nhood_dummy_sdata(dummy_adata2: AnnData): @@ -220,16 +166,7 @@ def test_niche_calc_nhood_dummy_sdata(dummy_adata2: AnnData): calculate_niche(sdata, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, table_key="adata") - assert "nhood_niche_res_1.0" in sdata["adata"].obs.columns - - expected_niches = Series( - ["0", "2", "0", "2", "1", "0", "0", "1", "0", "1"], - index=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], - name="nhood_niche_res_1.0", - dtype=str, - ) - - assert (expected_niches == sdata["adata"].obs["nhood_niche_res_1.0"]).all() + _assert_all_assigned(sdata["adata"], "nhood_niche_res_1.0") # older tests From 184fbf03ce5eaf1a958ede30145ae37993346125 Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 18 Aug 2026 18:21:16 +0200 Subject: [PATCH 6/9] test: drop tautological niche-count assertions 1 <= nunique <= n_obs is always true once every cell is asserted assigned, so it locked in nothing. --- tests/graph/test_niche.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index ba99eca4e..82b33c19e 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -36,7 +36,6 @@ def test_niche_calc_nhood_dummy_adata(dummy_adata2: AnnData): dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, random_state=0 ) niches = _assert_all_assigned(dummy_adata2, "nhood_niche_res=1.0") - assert 1 <= niches.nunique() <= dummy_adata2.n_obs # a fixed random_state gives reproducible niches calculate_niche(rerun, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, random_state=0) @@ -48,7 +47,6 @@ def test_niche_calc_utag_dummy_adata(dummy_adata2: AnnData): rerun = dummy_adata2.copy() calculate_niche(dummy_adata2, flavor="utag", n_neighbors=3, resolutions=1.0, random_state=0) niches = _assert_all_assigned(dummy_adata2, "utag_niche_res=1.0") - assert 1 <= niches.nunique() <= dummy_adata2.n_obs # a fixed random_state gives reproducible niches calculate_niche(rerun, flavor="utag", n_neighbors=3, resolutions=1.0, random_state=0) @@ -63,8 +61,7 @@ def test_niche_calc_cellcharter_dummy_adata(dummy_adata2: AnnData): calculate_niche(dummy_adata2, flavor="cellcharter", distance=2, aggregation="mean", random_state=0) - niches = _assert_all_assigned(dummy_adata2, "cellcharter_niche") - assert 1 <= niches.nunique() <= dummy_adata2.n_obs + _assert_all_assigned(dummy_adata2, "cellcharter_niche") def test_niche_calc_spatialleiden_dummy_adata(dummy_adata2: AnnData): @@ -82,8 +79,7 @@ def test_niche_calc_spatialleiden_dummy_adata(dummy_adata2: AnnData): resolutions=1.0, ) - niches = _assert_all_assigned(dummy_adata2, "spatialleiden_res=1.0") - assert 1 <= niches.nunique() <= dummy_adata2.n_obs + _assert_all_assigned(dummy_adata2, "spatialleiden_res=1.0") # more special test cases From a0495edae39e95b6c84e80292e4cab4fef5872fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:28:02 +0200 Subject: [PATCH 7/9] [pre-commit.ci] pre-commit autoupdate (#1264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/biomejs/pre-commit: v2.5.7 → v2.5.8](https://github.com/biomejs/pre-commit/compare/v2.5.7...v2.5.8) - [github.com/tox-dev/pyproject-fmt: v2.27.0 → v2.28.0](https://github.com/tox-dev/pyproject-fmt/compare/v2.27.0...v2.28.0) - [github.com/astral-sh/ruff-pre-commit: v0.16.2 → v0.16.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.2...v0.16.3) - [github.com/zizmorcore/zizmor-pre-commit: v1.24.1 → v1.29.0](https://github.com/zizmorcore/zizmor-pre-commit/compare/v1.24.1...v1.29.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tim Treis --- .pre-commit-config.yaml | 8 ++++---- pyproject.toml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1045173aa..98c15788b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,16 +7,16 @@ default_stages: minimum_pre_commit_version: 2.16.0 repos: - repo: https://github.com/biomejs/pre-commit - rev: v2.5.7 + rev: v2.5.8 hooks: - id: biome-format exclude: ^\.cruft\.json$ # inconsistent indentation with cruft - file never to be modified manually. - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.27.0 + rev: v2.28.0 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.2 + rev: v0.16.3 hooks: - id: ruff-check types_or: [python, pyi, jupyter] @@ -38,7 +38,7 @@ repos: args: [--assume-in-merge] - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.24.1 + rev: v1.29.0 hooks: - id: zizmor args: [--no-progress, --fix] diff --git a/pyproject.toml b/pyproject.toml index 592022819..083f14bd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -290,18 +290,19 @@ strict = true testpaths = [ "tests/" ] [tool.coverage] -run.branch = true +run.source = [ "squidpy" ] run.omit = [ "*/__init__.py", "*/_version.py", "tox/*", ] +run.branch = true run.parallel = true -run.source = [ "squidpy" ] paths.source = [ "squidpy", "*/site-packages/squidpy", ] +report.precision = 2 report.exclude_lines = [ "\\#.*pragma:\\s*no.?cover", "^\\s*raise AssertionError\\b", @@ -309,9 +310,8 @@ report.exclude_lines = [ "^\\s*return NotImplemented\\b", "^if __name__ == .__main__.:$", ] -report.precision = 2 -report.show_missing = true report.skip_empty = true +report.show_missing = true report.sort = "Miss" [tool.cruft] From b44fd290d885ca7ec0954a55bceab839d3106fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:13:17 +0200 Subject: [PATCH 8/9] chore: switch to rngs (#1270) * Rename niche random_state `random_state` is dropped outright rather than deprecated: it is simply gone from the signatures, so passing it raises Python's own TypeError. The released defaults are preserved -- `seed` still defaults to 42, so calls that do not pass it stay reproducible exactly as before. Passing `seed=None` opts out. Internally `seed` now feeds `numpy.random.Generator`s: `spawn_generators` derives an independent generator per library and per resolution, and `rng_to_random_state` converts at the boundary of third-party APIs that take an int but not a Generator (scikit-learn, spatialleiden). Adding a library or a resolution therefore no longer shifts the others. Derived from 777449e5 on feat/cluster-auto-k. * Rename experimental.im random_state Completes the rename, so `random_state` no longer appears as a squidpy parameter name anywhere. `WekaParams.random_state`, `VahadaneParams.random_state` and `_refine_with_background_classifier`'s parameter become `seed`, keeping their existing default of 0. No conversion helper is needed: these are plain `int | None` and go straight into scikit-learn, which accepts that. The `random_state=` keywords that remain are scikit-learn's own, on `RandomForestClassifier` and `NMF`. * Flip unreleased seed defaults `seed` defaults to `None` on everything that has not shipped yet: `calculate_niche_cellcharter`, `calculate_niche_spatialleiden`, `WekaParams`, `VahadaneParams` and `_refine_with_background_classifier`. A new API defaulting to a fixed seed hides non-determinism behind an arbitrary constant; `None` makes the choice explicit and matches the rest of `squidpy.gr`. `calculate_niche` keeps its released default of 42. * Adopt SPEC 7 rng `seed` and `random_state` become `rng`, accepting a seed, a `numpy.random.Generator` or `None`, per SPEC 7. The old names still work and emit a `FutureWarning` naming what happens to the value: it now seeds a generator rather than reaching the underlying library as a legacy `random_state`, so results for a given value can differ. Public entry points normalise once with `numpy.random.default_rng`; everything downstream takes a `Generator`. `spawn_generators` is gone -- after that split it was a one-line wrapper around `Generator.spawn`. The one internal still seeing a raw `rng` is `_validate_niche_args`, which reports on what the caller passed and needs `None` to stay `None`. `_segment_weka` also stops handing the same seed to both the random forest and the refinement classifier; they now draw from one generator. * Unroll rng type aliases `SeedLike` and `RNGLike` become plain unions instead of PEP-695 `type` statements. A `type` statement builds a `TypeAliasType`, which sphinx deliberately renders by name -- so `VahadaneParams.rng` documented itself as `SeedLike | RNGLike | None`, two names that resolve to nothing because `squidpy._utils` is private and undocumented. Plain unions are evaluated, so autodoc expands them to `int | integer | Sequence[int] | SeedSequence | Generator | BitGenerator | None` on attributes, matching what sphinx-autodoc-typehints already produced for function parameters. The aliases no longer appear as names anywhere, so the build is nitpick-clean without ignore entries for them. * Rename rng_to_random_state to legacy_random for clarity and update test fixture to use a seed value directly * Rename deprecated_rng_param to deprecated_randomness_param and update references to legacy_random * Replace 'generator' with 'rng' for consistency in niche calculations and GMM clustering * Update src/squidpy/gr/_niche.py --------- Co-authored-by: Philipp A. --- src/squidpy/_docs.py | 26 ++-- src/squidpy/_utils.py | 33 ++++- src/squidpy/experimental/im/_detect_tissue.py | 27 +++-- .../experimental/im/_stain/_decomposition.py | 7 +- src/squidpy/gr/_ligrec.py | 29 +++-- src/squidpy/gr/_nhood.py | 13 +- src/squidpy/gr/_niche.py | 114 ++++++++++-------- src/squidpy/gr/_ppatterns.py | 13 +- src/squidpy/gr/_ripley.py | 11 +- tests/conftest.py | 2 +- tests/graph/test_ligrec.py | 37 ++++-- tests/graph/test_nhood.py | 14 ++- tests/graph/test_niche.py | 71 +++++++++-- tests/graph/test_ppatterns.py | 14 ++- tests/graph/test_ripley.py | 8 +- tests/graph/test_utils.py | 25 ++++ 16 files changed, 318 insertions(+), 126 deletions(-) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index 245d1669f..c7ba67197 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -46,16 +46,25 @@ def decorator2(obj: Any) -> Any: numba_parallel Whether to use :func:`numba.prange` or not. If `None`, it is determined automatically. For small datasets or small number of interactions, it's recommended to set this to `False`.""" -_seed = """\ -seed - Random seed for reproducibility.""" +_rng = """\ +rng + Pseudorandom number generator state, following + `SPEC 7 `_. When `None`, a new + :class:`numpy.random.Generator` is created using entropy from the operating system. + Types other than :class:`numpy.random.Generator` are passed to + :func:`numpy.random.default_rng` to instantiate a generator.""" _seed_versionchanged = """\ .. versionchanged:: 1.8.4 Every permutation now uses an independent :class:`numpy.random.Generator` spawned from a :class:`numpy.random.SeedSequence`. Consequently the permutation-based results no - longer depend on ``n_jobs`` / ``backend``, but results obtained with a given ``seed`` + longer depend on ``n_jobs`` / ``backend``, but results obtained with a given seed differ from those produced by squidpy < 1.8.4. See `#1232 `_ and `#1233 `_.""" +_rng_versionchanged = """\ +.. versionchanged:: 1.8.4 + ``seed`` / ``random_state`` were renamed to ``rng``, which also accepts a + :class:`numpy.random.Generator` (`SPEC 7 `_). + The old names still work but emit a :class:`FutureWarning`.""" _n_perms = """\ n_perms Number of permutations for the permutation test.""" @@ -247,14 +256,14 @@ def decorator2(obj: Any) -> Any: {_niche_mask} {_library_key} {_niche_inplace}""" -_niche_leiden_params = """\ +_niche_leiden_params = f"""\ flavor Leiden backend passed to :func:`scanpy.tl.leiden`. Defaults to ``'igraph'`` (the ``'leidenalg'`` backend is deprecated in scanpy). n_iterations Number of Leiden iterations. ``-1`` iterates until convergence. -random_state - Random seed for the Leiden clustering, for reproducible niche labels.""" +{_rng} + Every resolution is clustered with an independent rng derived from it.""" # static plotting docs _plotting_kwargs_static = """\ @@ -467,8 +476,9 @@ def decorator2(obj: Any) -> Any: copy=_copy, copy_cont=_copy_cont, numba_parallel=_numba_parallel, - seed=_seed, + rng=_rng, seed_versionchanged=_seed_versionchanged, + rng_versionchanged=_rng_versionchanged, n_perms=_n_perms, img_layer=_img_layer, feature_name=_feature_name, diff --git a/src/squidpy/_utils.py b/src/squidpy/_utils.py index c03484886..8d738a7b9 100644 --- a/src/squidpy/_utils.py +++ b/src/squidpy/_utils.py @@ -237,8 +237,15 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return wrapper -def spawn_generators(seed: int | None, n: int) -> list[np.random.Generator]: - return [np.random.default_rng(s) for s in np.random.SeedSequence(seed).spawn(n)] +# plain assignments, not PEP-695 `type` statements: those stay opaque to sphinx and would +# render as the bare alias names on the `*Params.rng` attribute pages +SeedLike = int | np.integer | Sequence[int] | np.random.SeedSequence +RNGLike = np.random.Generator | np.random.BitGenerator + + +def legacy_random(rng: np.random.Generator) -> int: + """Draw an int seed for third-party APIs that only accept ``random_state``.""" + return int(rng.integers(np.iinfo(np.int32).max)) @contextmanager @@ -373,6 +380,28 @@ def verbosity(level: int) -> Generator[None, None, None]: sc.settings.verbosity = verbosity +def deprecated_randomness_param(func: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + for old in ("seed", "random_state"): + if old not in kwargs: + continue + if "rng" in kwargs: + raise TypeError(f"`{func.__name__}()` got both `{old}` and `rng`; pass only `rng`.") + value = kwargs.pop(old) + warnings.warn( + f"Parameter `{old}` of `{func.__name__}()` is deprecated in favor of `rng` and will be " + f"removed in squidpy v1.9.0. It is now seeding a generator, i.e. `{old}={value!r}` " + f"is used as `numpy.random.default_rng({value!r})`, which may change the result.", + FutureWarning, + stacklevel=2, + ) + kwargs["rng"] = value + return func(*args, **kwargs) + + return wrapper + + def deprecated_params( params: dict[str, str], ) -> Callable[..., Any]: diff --git a/src/squidpy/experimental/im/_detect_tissue.py b/src/squidpy/experimental/im/_detect_tissue.py index c8aa34590..8fac7c2de 100644 --- a/src/squidpy/experimental/im/_detect_tissue.py +++ b/src/squidpy/experimental/im/_detect_tissue.py @@ -22,7 +22,14 @@ from spatialdata.models import Labels2DModel from spatialdata.transformations import get_transformation -from squidpy._utils import _ensure_dim_order, _get_scale_factors, _yx_from_shape +from squidpy._utils import ( + RNGLike, + SeedLike, + _ensure_dim_order, + _get_scale_factors, + _yx_from_shape, + legacy_random, +) from ._utils import flatten_channels, get_element_data @@ -85,7 +92,7 @@ class WekaParams: rf_estimators: int = 100 rf_max_depth: int | None = 10 rf_max_samples: float = 0.05 - random_state: int | None = 0 + rng: SeedLike | RNGLike | None = None # Second-stage refinement with a simple classifier refine_with_classifier: bool = True @@ -742,12 +749,16 @@ def _segment_weka( training_labels_flat[idx] = 2 training_labels = training_labels_flat.reshape(H, W) + # one generator for the whole segmentation, so the forest and the refinement step + # are seeded independently instead of sharing a single seed + rng = np.random.default_rng(weka_params.rng) + clf = RandomForestClassifier( n_estimators=weka_params.rf_estimators, n_jobs=-1, max_depth=weka_params.rf_max_depth, max_samples=weka_params.rf_max_samples, - random_state=weka_params.random_state, + random_state=legacy_random(rng), ) clf = future.fit_segmenter(training_labels, feats, clf) result = future.predict_segmenter(feats, clf) @@ -761,7 +772,7 @@ def _segment_weka( prior_mask=prior_mask, n_samples_per_class=weka_params.refine_n_samples_per_class, bg_prob_threshold=weka_params.refine_bg_prob_threshold, - random_state=weka_params.random_state, + rng=rng, ) return prior_mask @@ -772,7 +783,7 @@ def _refine_with_background_classifier( prior_mask: np.ndarray, n_samples_per_class: int, bg_prob_threshold: float, - random_state: int | None = 0, + rng: np.random.Generator, ) -> np.ndarray: """ Refine a prior tissue mask using a simple classifier on multiscale features. @@ -795,8 +806,8 @@ def _refine_with_background_classifier( bg_prob_threshold Background probability threshold above which an inside-mask pixel is reclassified as background. - random_state - Seed for subsampling; use None for non-deterministic. + rng + Generator used to subsample the training pixels. Returns ------- @@ -815,8 +826,6 @@ def _refine_with_background_classifier( # Nothing to refine return prior_mask - rng = np.random.default_rng(random_state) - # Subsample for training n_tissue = min(n_samples_per_class, idx_tissue.size) n_bg = min(n_samples_per_class, idx_bg.size) diff --git a/src/squidpy/experimental/im/_stain/_decomposition.py b/src/squidpy/experimental/im/_stain/_decomposition.py index 939efb615..48db65008 100644 --- a/src/squidpy/experimental/im/_stain/_decomposition.py +++ b/src/squidpy/experimental/im/_stain/_decomposition.py @@ -14,6 +14,7 @@ import numpy as np import xarray as xr +from squidpy._utils import RNGLike, SeedLike, legacy_random from squidpy.experimental.im._stain._constants import RUIFROK_HE from squidpy.experimental.im._stain._conversion import ( _apply_along_channel, @@ -68,8 +69,8 @@ class VahadaneParams: n_iter: int = 200 """Maximum NMF iterations.""" - random_state: int | None = 0 - """Seed for NMF initialisation tie-breaking; fixed for reproducible fits.""" + rng: SeedLike | RNGLike | None = None + """Source of randomness for NMF initialisation tie-breaking; `None` draws from OS entropy.""" def __post_init__(self) -> None: object.__setattr__(self, "beta", float(self.beta)) @@ -168,7 +169,7 @@ def _vahadane_stain_matrix(od: np.ndarray, params: VahadaneParams) -> np.ndarray nmf = NMF( n_components=2, init="nndsvda", - random_state=params.random_state, + random_state=legacy_random(np.random.default_rng(params.rng)), alpha_W=params.lambda1, l1_ratio=1.0, max_iter=params.n_iter, diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 307af8ae0..0de8b9c46 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -22,7 +22,15 @@ from squidpy._constants._constants import ComplexPolicy, CorrAxis from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs -from squidpy._utils import NDArrayA, deprecated_params, get_n_numba_threads, numba_threads, spawn_generators +from squidpy._utils import ( + NDArrayA, + RNGLike, + SeedLike, + deprecated_params, + deprecated_randomness_param, + get_n_numba_threads, + numba_threads, +) from squidpy._validators import assert_positive, check_tuple_needles from squidpy.gr._utils import ( _assert_categorical_obs, @@ -228,13 +236,14 @@ def prepare( @d.dedent @inject_docs(src=SOURCE, tgt=TARGET, fa=CorrAxis) @deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) + @deprecated_randomness_param def test( self, cluster_key: str, clusters: Cluster_t | None = None, n_perms: int = 1000, threshold: float = 0.01, - seed: int | None = None, + rng: SeedLike | RNGLike | None = None, corr_method: str | None = None, corr_axis: Literal["interactions", "clusters"] | CorrAxis = CorrAxis.INTERACTIONS.v, alpha: float = 0.05, @@ -256,7 +265,7 @@ def test( threshold Do not perform permutation test if any of the interacting components is being expressed in less than ``threshold`` percent of cells within a given cluster. - %(seed)s + %(rng)s %(corr_method)s corr_axis Axis over which to perform the FDR correction. Only used when ``corr_method != None``. Valid options are: @@ -337,7 +346,7 @@ def test( clusters_, threshold=threshold, n_perms=n_perms, - seed=seed, + rng=np.random.default_rng(rng), n_jobs=n_threads, show_progress_bar=show_progress_bar, ) @@ -545,6 +554,7 @@ def prepare( @d.dedent @deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) +@deprecated_randomness_param def ligrec( adata: AnnData | SpatialData, cluster_key: str, @@ -559,7 +569,7 @@ def ligrec( gene_symbols: str | None = None, *, n_perms: int = 1000, - seed: int | None = None, + rng: SeedLike | RNGLike | None = None, clusters: Cluster_t | None = None, alpha: float = 0.05, n_jobs: int | None = None, @@ -601,7 +611,7 @@ def ligrec( clusters=clusters, n_perms=n_perms, threshold=threshold, - seed=seed, + rng=rng, corr_method=corr_method, corr_axis=corr_axis, alpha=alpha, @@ -678,9 +688,10 @@ def _analysis( data: pd.DataFrame, interactions: NDArrayA, interaction_clusters: NDArrayA, + *, + rng: np.random.Generator, threshold: float = 0.1, n_perms: int = 1000, - seed: int | None = None, n_jobs: int = 1, show_progress_bar: bool = True, ) -> TempResult: @@ -698,7 +709,7 @@ def _analysis( threshold Percentage threshold for removing lowly expressed genes in clusters. %(n_perms)s - %(seed)s + %(rng)s n_jobs Number of numba threads to use. Each thread holds two `(n_interactions, n_interaction_clusters)` :class:`numpy.int64` arrays (its private reduction copy plus the per-permutation indicators), @@ -749,7 +760,7 @@ def _analysis( res_means = np.where(nonzero, (m_rec + m_lig) / 2.0, 0.0) # one independent RNG per permutation; a numba typed list so the kernel can index it in prange - generators = List(spawn_generators(seed, n_perms)) + generators = List(rng.spawn(n_perms)) # the whole permutation loop runs in a single numba call; ``n_jobs`` sets its thread count and # the kernel updates ``progress`` (a numba_progress proxy) once per permutation diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index a62262c6a..ebf4c05eb 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -23,11 +23,13 @@ from squidpy._docs import d, inject_docs from squidpy._utils import ( NDArrayA, + RNGLike, + SeedLike, Signal, SigQueue, + deprecated_randomness_param, get_n_processes, parallelize, - spawn_generators, ) from squidpy._validators import assert_positive from squidpy.gr._utils import ( @@ -143,6 +145,7 @@ def _create_function(n_cls: int, parallel: bool = False) -> Callable[[NDArrayA, @d.get_sections(base="nhood_ench", sections=["Parameters"]) @d.dedent +@deprecated_randomness_param def nhood_enrichment( adata: AnnData | SpatialData, cluster_key: str, @@ -150,7 +153,7 @@ def nhood_enrichment( connectivity_key: str | None = None, n_perms: int = 1000, numba_parallel: bool = False, - seed: int | None = None, + rng: SeedLike | RNGLike | None = None, copy: bool = False, n_jobs: int | None = None, backend: str = "loky", @@ -163,6 +166,8 @@ def nhood_enrichment( %(seed_versionchanged)s + %(rng_versionchanged)s + Parameters ---------- %(adata)s @@ -172,7 +177,7 @@ def nhood_enrichment( %(conn_key)s %(n_perms)s %(numba_parallel)s - %(seed)s + %(rng)s %(copy)s %(parallelize)s @@ -210,7 +215,7 @@ def nhood_enrichment( n_jobs = get_n_processes(n_jobs) start = logg.info(f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") - generators = spawn_generators(seed, n_perms) + generators = np.random.default_rng(rng).spawn(n_perms) perms = parallelize( _nhood_enrichment_helper, diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 42205605d..f4290f766 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -18,7 +18,7 @@ from squidpy._constants._constants import NicheDefinitions from squidpy._docs import d, inject_docs -from squidpy._utils import NDArrayA +from squidpy._utils import NDArrayA, RNGLike, SeedLike, deprecated_randomness_param, legacy_random from squidpy._validators import assert_isinstance, assert_key_in_adata, assert_one_of from squidpy.gr._utils import extract_adata_if_sdata @@ -33,6 +33,7 @@ @d.dedent @inject_docs(fla=NicheDefinitions) +@deprecated_randomness_param def calculate_niche( data: AnnData | SpatialData, flavor: Literal["neighborhood", "utag", "cellcharter", "spatialleiden"], @@ -48,7 +49,6 @@ def calculate_niche( n_hop_weights: list[float] | None = None, aggregation: str | None = None, n_components: int | None = None, - random_state: int = 42, spatial_connectivities_key: str = "spatial_connectivities", latent_connectivities_key: str = "connectivities", layer_ratio: float = 1.0, @@ -58,6 +58,7 @@ def calculate_niche( inplace: bool = True, *, table_key: str | None = None, + rng: SeedLike | RNGLike | None = None, ) -> AnnData | None: """ Calculate niches (spatial clusters) based on a user-defined method in 'flavor'. @@ -131,8 +132,7 @@ def calculate_niche( n_components Number of components to use for GMM. Required if flavor == `{fla.CELLCHARTER.s!r}`. - random_state - Random state to use for GMM or SpatialLeiden. + %(rng)s Optional if flavor == `{fla.CELLCHARTER.s!r}` or flavor == `{fla.SPATIALLEIDEN.s!r}`. spatial_connectivities_key Key in `adata.obsp` where spatial connectivities are stored. @@ -190,7 +190,7 @@ def calculate_niche( n_hop_weights, aggregation, n_components, - random_state, + rng, spatial_connectivities_key, latent_connectivities_key, layer_ratio, @@ -220,7 +220,7 @@ def calculate_niche( inplace, table_key, n_iterations=n_iterations, - random_state=random_state, + rng=rng, ) elif flavor == "utag": @@ -235,7 +235,7 @@ def calculate_niche( inplace, table_key, n_iterations=n_iterations, - random_state=random_state, + rng=rng, ) elif flavor == "cellcharter": @@ -243,7 +243,7 @@ def calculate_niche( data, distance, aggregation, - random_state, + rng, spatial_connectivities_key, n_components, use_rep, @@ -263,7 +263,7 @@ def calculate_niche( layer_ratio, n_iterations, use_weights, - random_state, + rng, min_niche_size, mask, prefix=None, @@ -294,7 +294,7 @@ def calculate_niche_neighborhood( *, flavor: Literal["igraph", "leidenalg"] = "igraph", n_iterations: int = -1, - random_state: int = 0, + rng: SeedLike | RNGLike | None = None, ) -> AnnData | None: """Compute niche neighborhoods using a neighborhood profile embedding and Leiden clustering. @@ -344,7 +344,7 @@ def calculate_niche_neighborhood( # Create instance of _LeidenClusterer using provided inputs clusterer = _LeidenClusterer( - n_neighbors, resolutions, "nhood_niche", flavor=flavor, n_iterations=n_iterations, random_state=random_state + n_neighbors, resolutions, "nhood_niche", flavor=flavor, n_iterations=n_iterations, rng=rng ) return _calculate_niche_custom( @@ -373,7 +373,7 @@ def calculate_niche_utag( *, flavor: Literal["igraph", "leidenalg"] = "igraph", n_iterations: int = -1, - random_state: int = 0, + rng: SeedLike | RNGLike | None = None, ) -> AnnData | None: """Compute niche assignments using a UTAG-style neighborhood embedding. @@ -402,7 +402,7 @@ def calculate_niche_utag( embedder = _UtagEmbedder(spatial_connectivities_key) clusterer = _LeidenClusterer( - n_neighbors, resolutions, "utag_niche", flavor=flavor, n_iterations=n_iterations, random_state=random_state + n_neighbors, resolutions, "utag_niche", flavor=flavor, n_iterations=n_iterations, rng=rng ) return _calculate_niche_custom( @@ -422,7 +422,7 @@ def calculate_niche_cellcharter( data: AnnData | SpatialData, distance: int = 3, aggregation: str = "mean", - random_state: int = 42, + rng: SeedLike | RNGLike | None = None, spatial_connectivities_key: str = "spatial_connectivities", n_components: int = 10, use_rep: str | None = None, @@ -445,8 +445,9 @@ def calculate_niche_cellcharter( aggregation Aggregation mode used for neighborhood features, typically ``"mean"`` or ``"variance"``. - random_state - Random seed used by the Gaussian mixture clustering step. + %(rng)s + Seeds the Gaussian mixture clustering step. When stratifying by ``library_key``, + every library is fitted with an independent rng derived from it. %(niche_spatial_conn_key)s n_components Number of embedding components to retain when ``use_rep`` is provided, @@ -466,7 +467,7 @@ def calculate_niche_cellcharter( embedder = _CellcharterEmbedder(distance, aggregation, spatial_connectivities_key, n_components, use_rep) - clusterer = _GMMClusterer(n_components, random_state, base_colname="cellcharter_niche") + clusterer = _GMMClusterer(n_components, np.random.default_rng(rng), base_colname="cellcharter_niche") return _calculate_niche_custom( data, @@ -489,7 +490,7 @@ def calculate_niche_spatialleiden( layer_ratio: float = 1.0, n_iterations: int = -1, use_weights: bool | tuple[bool, bool] = True, - random_state: int = 42, + rng: SeedLike | RNGLike | None = None, min_niche_size: int | None = None, mask: pd.Series | None = None, prefix: str | None = None, @@ -519,8 +520,9 @@ def calculate_niche_spatialleiden( Number of optimization iterations used by SpatialLeiden. use_weights Whether to use edge weights during clustering. - random_state - Random seed passed to the SpatialLeiden routine. + %(rng)s + Each resolution — and each library when stratifying by ``library_key`` — is + clustered with an independent rng derived from it. %(niche_min_niche_size)s %(niche_mask)s prefix @@ -556,14 +558,22 @@ def calculate_niche_spatialleiden( else: adata = orig_adata.copy() + # normalise once here; everything below this point works with rngs only + rng = np.random.default_rng(rng) + if library_key is not None: # first assert that library_key was there in adata.obs, and then, stratify the object according to that library_key and # then re-call calculate_niche_spatialleiden for each subpart, with library_key = None and prefix with appropriate information like "lib=" assert_key_in_adata(adata, library_key, attr="obs") logg.info(f"Stratifying by library_key '{library_key}'") + # each library is an independent clustering problem, so it gets its own rng + # (indexed by `itr` so that skipped empty libraries don't shift the others) + library_ids = adata.obs[library_key].unique() + library_rngs = rng.spawn(len(library_ids)) + # go through each library_id and process the corresponding adata subset - for itr, lib_id in enumerate(adata.obs[library_key].unique()): + for itr, lib_id in enumerate(library_ids): logg.info(f"Processing library '{lib_id}'") lib_indices = adata.obs[adata.obs[library_key] == lib_id].index @@ -583,7 +593,7 @@ def calculate_niche_spatialleiden( layer_ratio, n_iterations, use_weights, - random_state, + library_rngs[itr], min_niche_size, mask, prefix=f"lib={lib_id}_", @@ -608,7 +618,10 @@ def calculate_niche_spatialleiden( if not isinstance(resolutions, list): resolutions = [resolutions] - for res in resolutions: + # every resolution is a separate clustering run, so seed each one independently + resolution_rngs = rng.spawn(len(resolutions)) + + for res, res_rng in zip(resolutions, resolution_rngs, strict=True): sl.spatialleiden( adata, resolution=res, @@ -617,7 +630,7 @@ def calculate_niche_spatialleiden( layer_ratio=layer_ratio, latent_neighbors_key=latent_connectivities_key, spatial_neighbors_key=spatial_connectivities_key, - random_state=random_state, + random_state=legacy_random(res_rng), directed=False, key_added=f"spatialleiden_res={res}", ) @@ -766,7 +779,9 @@ def _validate_niche_args( n_hop_weights: list[float] | None, aggregation: str | None, n_components: int | None, - random_state: int, + # the one internal that sees a raw `rng`: it reports on what the caller passed, and + # `None` must stay `None` here so the "unused for this flavor" check can spot it + rng: SeedLike | RNGLike | None, spatial_connectivities_key: str, latent_connectivities_key: str, layer_ratio: float, @@ -835,7 +850,7 @@ def _validate_niche_args( "abs_nhood", "distance", "n_hop_weights", - "random_state", + "rng", "n_iterations", ], "unused": [ @@ -849,7 +864,7 @@ def _validate_niche_args( }, "utag": { "required": ["n_neighbors", "resolutions", "spatial_connectivities_key"], - "optional": ["random_state", "n_iterations"], + "optional": ["rng", "n_iterations"], "unused": [ "groups", "min_niche_size", @@ -866,8 +881,9 @@ def _validate_niche_args( ], }, "cellcharter": { - "required": ["distance", "aggregation", "random_state", "spatial_connectivities_key"], - "optional": ["n_components", "use_rep"], + "required": ["distance", "aggregation", "spatial_connectivities_key"], + # `rng` is optional: `None` is a valid value meaning "draw from OS entropy" + "optional": ["n_components", "use_rep", "rng"], "unused": [ "groups", "min_niche_size", @@ -889,7 +905,7 @@ def _validate_niche_args( "layer_ratio", "n_iterations", "use_weights", - "random_state", + "rng", ], "unused": ["groups", "min_niche_size", "scale", "abs_nhood", "n_neighbors", "n_hop_weights", "use_rep"], }, @@ -913,7 +929,7 @@ def _validate_niche_args( "n_hop_weights": n_hop_weights, "aggregation": aggregation, "n_components": n_components, - "random_state": random_state, + "rng": rng, "use_rep": use_rep, }, flavor_param_specs[flavor], @@ -943,8 +959,6 @@ def _validate_niche_args( if n_components < 1: raise ValueError(f"'n_components' must be at least 1, got {n_components}") - assert_isinstance(random_state, int, name="random_state") - if use_rep is not None: assert_isinstance(use_rep, str, name="use_rep") @@ -967,7 +981,6 @@ def _validate_niche_args( ) ): raise TypeError(f"'use_weights' must be a bool or a tuple of two bools, got {use_weights!r}") - assert_isinstance(random_state, int, name="random_state") if resolutions is None: resolutions = [1.0] @@ -993,7 +1006,7 @@ def _check_unnecessary_args(flavor: str, param_dict: dict[str, Any], param_specs for param_name in param_specs["unused"]: param_value = param_dict.get(param_name) - # Special handling for boolean parameters with default values + # Special handling for parameters whose default is not None if param_name == "scale" and param_value is True: continue if param_name == "abs_nhood" and param_value is False: @@ -1406,6 +1419,7 @@ class _LeidenClusterer(_NicheClusterer): base_colname Base name for columns added to ``adata.obs``. Resolution is appended to this to unique identify columns for each resolution. + %(niche_leiden_params)s Notes ----- @@ -1421,14 +1435,14 @@ def __init__( *, flavor: Literal["igraph", "leidenalg"] = "igraph", n_iterations: int = -1, - random_state: int = 0, + rng: SeedLike | RNGLike | None = None, ): self.n_neighbors = n_neighbors self.resolutions = resolutions if isinstance(resolutions, list) else [resolutions] self.base_colname = base_colname self.flavor = flavor self.n_iterations = n_iterations - self.random_state = random_state + self.rng = np.random.default_rng(rng) def cluster(self, adata: AnnData, embedding: NDArrayA) -> list: # first create an adata object using the embedding provided @@ -1439,20 +1453,22 @@ def cluster(self, adata: AnnData, embedding: NDArrayA) -> list: # For each resolution, apply leiden on neighborhood profile. Each cluster label equals to a niche label niche_keys = [] - for res in self.resolutions: + # every resolution is a separate clustering run, so seed each one independently + resolution_rngs = self.rng.spawn(len(self.resolutions)) + for res, res_rng in zip(self.resolutions, resolution_rngs, strict=True): niche_key = f"{self.base_colname}_res={res}" niche_keys.append(niche_key) if niche_key in adata.obs.columns: logg.info(f"Overwriting existing column '{niche_key}'") - # Default to the igraph backend with a fixed seed so niche labels are - # reproducible across versions; leidenalg is deprecated in scanpy and - # unstable on small graphs. See scverse/squidpy#1260. + # Default to the igraph backend so niche labels are reproducible across + # versions; leidenalg is deprecated in scanpy and unstable on small graphs. + # See scverse/squidpy#1260. leiden_kwargs: dict[str, Any] = { "flavor": self.flavor, "n_iterations": self.n_iterations, - "random_state": self.random_state, + "random_state": legacy_random(res_rng), } # scanpy's igraph backend only supports undirected graphs and errors if # ``directed`` is left at the leidenalg default of True, so pin it to False. @@ -1475,24 +1491,28 @@ class _GMMClusterer(_NicheClusterer): ---------- n_components Number of mixture components. - random_state - Random seed used by the Gaussian mixture model. + rng + rng supplying the seed of every mixture fit. base_colname Name of the output column added to ``adata.obs``. Notes ----- Cluster assignments are stored as categorical niche labels in ``adata.obs``. + + One instance may be reused for several fits (e.g. once per library when stratifying + by ``library_key``). Each :meth:`cluster` call draws a fresh seed from ``rng``, so the + fits are seeded independently while remaining reproducible as a sequence. """ def __init__( self, n_components: int, - random_state: int, + rng: np.random.Generator, base_colname: str = "niche_gmm", ): self.n_components = n_components - self.random_state = random_state + self.rng = rng self.base_colname = base_colname def cluster(self, adata: AnnData, embedding: NDArrayA) -> list: @@ -1502,7 +1522,7 @@ def cluster(self, adata: AnnData, embedding: NDArrayA) -> list: # cluster concatenated matrix with GMM, each cluster label equals to a niche label gmm = GaussianMixture( n_components=self.n_components, - random_state=self.random_state, + random_state=legacy_random(self.rng), init_params="random_from_data", ) gmm.fit(embedding) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 0fba06e17..b24d8f9cf 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -24,12 +24,14 @@ from squidpy._docs import d, inject_docs from squidpy._utils import ( NDArrayA, + RNGLike, + SeedLike, Signal, SigQueue, deprecated_params, + deprecated_randomness_param, get_n_processes, parallelize, - spawn_generators, ) from squidpy._validators import assert_key_in_adata, assert_positive from squidpy.gr._utils import ( @@ -53,6 +55,7 @@ @d.dedent @inject_docs(key=Key.obsp.spatial_conn(), sp=SpatialAutocorr) +@deprecated_randomness_param def spatial_autocorr( adata: AnnData | SpatialData, connectivity_key: str = Key.obsp.spatial_conn(), @@ -64,7 +67,7 @@ def spatial_autocorr( corr_method: str | None = "fdr_bh", attr: Literal["obs", "X", "obsm"] = "X", layer: str | None = None, - seed: int | None = None, + rng: SeedLike | RNGLike | None = None, use_raw: bool = False, copy: bool = False, n_jobs: int | None = None, @@ -87,6 +90,8 @@ def spatial_autocorr( %(seed_versionchanged)s + %(rng_versionchanged)s + Parameters ---------- %(adata)s @@ -124,7 +129,7 @@ def spatial_autocorr( Layer in :attr:`anndata.AnnData.layers` to use. If `None`, use :attr:`anndata.AnnData.X`. attr Which attribute of :class:`~anndata.AnnData` to access. See ``genes`` parameter for more information. - %(seed)s + %(rng)s %(copy)s %(parallelize)s @@ -220,7 +225,7 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr if n_perms is not None: assert_positive(n_perms, name="n_perms") perms = list(np.arange(n_perms)) - generators = spawn_generators(seed, n_perms) + generators = np.random.default_rng(rng).spawn(n_perms) score_perms = parallelize( _score_helper, diff --git a/src/squidpy/gr/_ripley.py b/src/squidpy/gr/_ripley.py index 43b8d330d..268a9c493 100644 --- a/src/squidpy/gr/_ripley.py +++ b/src/squidpy/gr/_ripley.py @@ -16,7 +16,7 @@ from squidpy._constants._constants import RipleyStat from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs -from squidpy._utils import NDArrayA, spawn_generators +from squidpy._utils import NDArrayA, RNGLike, SeedLike, deprecated_randomness_param from squidpy.gr._utils import _assert_categorical_obs, _assert_spatial_basis, _save_data, extract_adata_if_sdata __all__ = ["ripley"] @@ -24,6 +24,7 @@ @d.dedent @inject_docs(key=Key.obsm.spatial, rp=RipleyStat) +@deprecated_randomness_param def ripley( adata: AnnData | SpatialData, cluster_key: str, @@ -35,7 +36,7 @@ def ripley( n_observations: int = 1000, max_dist: float | None = None, n_steps: int = 50, - seed: int | None = None, + rng: SeedLike | RNGLike | None = None, copy: bool = False, *, table_key: str | None = None, @@ -45,6 +46,8 @@ def ripley( %(seed_versionchanged)s + %(rng_versionchanged)s + According to the `'mode'` argument, it calculates one of the following Ripley's statistics: `{rp.F.s!r}`, `{rp.G.s!r}` or `{rp.L.s!r}` statistics. @@ -96,7 +99,7 @@ def ripley( Maximum distances for the support. If `None`, `max_dist=`:math:`\sqrt{{area \over 2}}`. n_steps Number of steps for the support. - %(seed)s + %(rng)s %(copy)s Returns @@ -135,7 +138,7 @@ def ripley( start = logg.info( f"Calculating Ripley's {mode} statistic for `{le.classes_.shape[0]}` clusters and `{n_simulations}` simulations" ) - obs_rng, *sim_rngs = spawn_generators(seed, n_simulations + 1) + obs_rng, *sim_rngs = np.random.default_rng(rng).spawn(n_simulations + 1) for i in np.arange(np.max(cluster_idx) + 1): coord_c = coordinates[cluster_idx == i, :] diff --git a/tests/conftest.py b/tests/conftest.py index b43696269..6f386dfd0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,7 +339,7 @@ def ligrec_result() -> Mapping[str, pd.DataFrame]: n_jobs=1, show_progress_bar=False, copy=True, - seed=0, + rng=0, ) diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py index 930200429..cd4cd3b1a 100644 --- a/tests/graph/test_ligrec.py +++ b/tests/graph/test_ligrec.py @@ -157,7 +157,7 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): interactions=interactions, n_perms=5, corr_axis="clusters", - seed=42, + rng=np.random.default_rng(42), n_jobs=1, show_progress_bar=False, copy=True, @@ -170,7 +170,7 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): corr_axis="interactions", n_jobs=1, show_progress_bar=False, - seed=42, + rng=np.random.default_rng(42), copy=True, ) @@ -252,7 +252,15 @@ def test_result_is_sparse(self, adata: AnnData, interactions: Interactions_t): if TYPE_CHECKING: assert isinstance(interactions, pd.DataFrame) interactions["metadata"] = "foo" - r = ligrec(adata, _CK, interactions=interactions, n_perms=5, seed=2, copy=True, show_progress_bar=False) + r = ligrec( + adata, + _CK, + interactions=interactions, + n_perms=5, + rng=np.random.default_rng(2), + copy=True, + show_progress_bar=False, + ) assert r["means"].sparse.density <= 0.15 assert r["pvalues"].sparse.density <= 0.95 @@ -272,7 +280,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ n_perms=25, copy=True, show_progress_bar=False, - seed=42, + rng=np.random.default_rng(42), n_jobs=n_jobs, ) r2 = ligrec( @@ -282,7 +290,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ n_perms=25, copy=True, show_progress_bar=False, - seed=42, + rng=np.random.default_rng(42), n_jobs=n_jobs, ) r3 = ligrec( @@ -292,7 +300,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ n_perms=25, copy=True, show_progress_bar=False, - seed=43, + rng=np.random.default_rng(43), n_jobs=n_jobs, ) @@ -306,7 +314,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ def test_n_jobs_invariance(self, adata: AnnData, interactions: Interactions_t): """The number of threads must not change the result (each permutation is seeded independently).""" - kw = {"interactions": interactions, "n_perms": 25, "copy": True, "show_progress_bar": False, "seed": 42} + kw = {"interactions": interactions, "n_perms": 25, "copy": True, "show_progress_bar": False, "rng": 42} res_serial = ligrec(adata, _CK, n_jobs=1, **kw) res_parallel = ligrec(adata, _CK, n_jobs=2, **kw) @@ -316,7 +324,7 @@ def test_n_jobs_invariance(self, adata: AnnData, interactions: Interactions_t): @pytest.mark.parametrize("param", ["numba_parallel", "backend"]) def test_deprecated_parallelization_params(self, adata: AnnData, interactions: Interactions_t, param: str): """The removed parallelization arguments warn instead of raising, on both entry points.""" - kw = {"n_perms": 5, "copy": True, "show_progress_bar": False, "seed": 42} + kw = {"n_perms": 5, "copy": True, "show_progress_bar": False, "rng": 42} with pytest.warns(FutureWarning, match=rf"Parameter `{param}` of `ligrec\(\)` is deprecated"): ligrec(adata, _CK, interactions=interactions, **{param: True}, **kw) @@ -334,7 +342,7 @@ def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame) copy=True, show_progress_bar=False, threshold=0.01, - seed=0, + rng=np.random.default_rng(0), n_perms=1, n_jobs=1, ) @@ -347,7 +355,14 @@ def test_pvalues_reference( self, adata: AnnData, interactions: Interactions_t, ligrec_pvalues_reference: Mapping[str, pd.DataFrame] ): r = ligrec( - adata, _CK, interactions=interactions, n_perms=25, copy=True, show_progress_bar=False, seed=42, n_jobs=1 + adata, + _CK, + interactions=interactions, + n_perms=25, + copy=True, + show_progress_bar=False, + rng=np.random.default_rng(42), + n_jobs=1, ) np.testing.assert_array_equal(r["means"].index, ligrec_pvalues_reference["means"].index) np.testing.assert_array_equal(r["means"].columns, ligrec_pvalues_reference["means"].columns) @@ -400,7 +415,7 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): n_perms=1, copy=True, show_progress_bar=False, - seed=42, + rng=np.random.default_rng(42), ) assert len(res["pvalues"]) == len(expected) diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index fb19fc220..dc71674fa 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -42,9 +42,15 @@ def test_parallel_works(self, adata: AnnData, backend: str): def test_reproducibility(self, adata: AnnData, n_jobs: int): spatial_neighbors_grid(adata) - res1 = nhood_enrichment(adata, cluster_key=_CK, seed=42, n_jobs=n_jobs, n_perms=20, copy=True) - res2 = nhood_enrichment(adata, cluster_key=_CK, seed=42, n_jobs=n_jobs, n_perms=20, copy=True) - res3 = nhood_enrichment(adata, cluster_key=_CK, seed=43, n_jobs=n_jobs, n_perms=20, copy=True) + res1 = nhood_enrichment( + adata, cluster_key=_CK, rng=np.random.default_rng(42), n_jobs=n_jobs, n_perms=20, copy=True + ) + res2 = nhood_enrichment( + adata, cluster_key=_CK, rng=np.random.default_rng(42), n_jobs=n_jobs, n_perms=20, copy=True + ) + res3 = nhood_enrichment( + adata, cluster_key=_CK, rng=np.random.default_rng(43), n_jobs=n_jobs, n_perms=20, copy=True + ) assert len(res1) == len(res2) assert len(res2) == len(res3) @@ -62,7 +68,7 @@ def test_n_jobs_invariance(self, adata: AnnData): """The number of workers must not change the result (one seed is spawned per permutation).""" spatial_neighbors_grid(adata) - kw = {"cluster_key": _CK, "seed": 42, "n_perms": 20, "copy": True} + kw = {"cluster_key": _CK, "rng": 42, "n_perms": 20, "copy": True} res_serial = nhood_enrichment(adata, n_jobs=1, **kw) res_parallel = nhood_enrichment(adata, n_jobs=2, **kw) diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index 82b33c19e..948feef46 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -1,5 +1,6 @@ from __future__ import annotations +import numpy as np import pytest from anndata import AnnData from pandas import Series @@ -8,7 +9,7 @@ from spatialdata import SpatialData from spatialdata.models import TableModel -from squidpy.gr import calculate_niche, spatial_neighbors_knn +from squidpy.gr import _niche, calculate_niche, calculate_niche_cellcharter, spatial_neighbors_knn N_NEIGHBORS = 20 GROUPS = "celltype_mapped_refined" @@ -32,24 +33,22 @@ def _assert_all_assigned(adata: AnnData, column: str) -> Series: def test_niche_calc_nhood_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation using neighborhood profile approach works as intended for dummy_adata2." rerun = dummy_adata2.copy() - calculate_niche( - dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, random_state=0 - ) + calculate_niche(dummy_adata2, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, rng=0) niches = _assert_all_assigned(dummy_adata2, "nhood_niche_res=1.0") - # a fixed random_state gives reproducible niches - calculate_niche(rerun, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, random_state=0) + # a fixed rng gives reproducible niches + calculate_niche(rerun, flavor="neighborhood", groups="celltype", n_neighbors=3, resolutions=1.0, rng=0) assert (niches.to_numpy() == rerun.obs["nhood_niche_res=1.0"].to_numpy()).all() def test_niche_calc_utag_dummy_adata(dummy_adata2: AnnData): "Check whether niche calculation using utag approach works as intended for dummy_adata2." rerun = dummy_adata2.copy() - calculate_niche(dummy_adata2, flavor="utag", n_neighbors=3, resolutions=1.0, random_state=0) + calculate_niche(dummy_adata2, flavor="utag", n_neighbors=3, resolutions=1.0, rng=0) niches = _assert_all_assigned(dummy_adata2, "utag_niche_res=1.0") - # a fixed random_state gives reproducible niches - calculate_niche(rerun, flavor="utag", n_neighbors=3, resolutions=1.0, random_state=0) + # a fixed rng gives reproducible niches + calculate_niche(rerun, flavor="utag", n_neighbors=3, resolutions=1.0, rng=0) assert (niches.to_numpy() == rerun.obs["utag_niche_res=1.0"].to_numpy()).all() @@ -59,7 +58,7 @@ def test_niche_calc_cellcharter_dummy_adata(dummy_adata2: AnnData): # since cellcharter throws an error if the object's expression matrix is not sparse, first ensure that is the case dummy_adata2.X = csr_matrix(dummy_adata2.X) - calculate_niche(dummy_adata2, flavor="cellcharter", distance=2, aggregation="mean", random_state=0) + calculate_niche(dummy_adata2, flavor="cellcharter", distance=2, aggregation="mean", rng=np.random.default_rng(0)) _assert_all_assigned(dummy_adata2, "cellcharter_niche") @@ -77,11 +76,62 @@ def test_niche_calc_spatialleiden_dummy_adata(dummy_adata2: AnnData): latent_connectivities_key="connectivities", spatial_connectivities_key="spatial_connectivities", resolutions=1.0, + rng=np.random.default_rng(0), ) _assert_all_assigned(dummy_adata2, "spatialleiden_res=1.0") +# rng handling + + +def test_niche_cellcharter_rng_reproducible(dummy_adata2: AnnData): + "The same `rng` must give the same niches, a different one must be free to differ." + dummy_adata2.X = csr_matrix(dummy_adata2.X) + kwargs = {"distance": 2, "aggregation": "mean"} + + first = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + second = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + assert (first.obs["cellcharter_niche"] == second.obs["cellcharter_niche"]).all() + + # not a guarantee about the labels themselves, only that the seed is actually wired through + other = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(1), inplace=False, **kwargs) + assert list(other.obs["cellcharter_niche"]) != list(first.obs["cellcharter_niche"]) + + +def test_niche_cellcharter_rng_none_runs(dummy_adata2: AnnData): + "`rng=None` (the default) must work: it means 'draw from OS entropy', not 'missing argument'." + dummy_adata2.X = csr_matrix(dummy_adata2.X) + calculate_niche_cellcharter(dummy_adata2, distance=2, aggregation="mean") + assert "cellcharter_niche" in dummy_adata2.obs.columns + + +def test_niche_cellcharter_library_seeds_are_independent(dummy_adata2: AnnData, monkeypatch): + "Each library must be fitted with its own seed, while the whole run stays reproducible." + dummy_adata2.X = csr_matrix(dummy_adata2.X) + dummy_adata2.obs["batch"] = ["batch1"] * 5 + ["batch2"] * 5 + kwargs = {"distance": 2, "aggregation": "mean", "library_key": "batch", "n_components": 2} + + first = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + second = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + assert (first.obs["cellcharter_niche"] == second.obs["cellcharter_niche"]).all() + + # the clusterer is built once and reused for every library, so record what each fit + # is actually seeded with + seen: list[int] = [] + original = _niche.GaussianMixture + + def spy(*args, **kwargs): + seen.append(kwargs["random_state"]) + return original(*args, **kwargs) + + monkeypatch.setattr(_niche, "GaussianMixture", spy) + calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + + assert len(seen) == 2, "expected one mixture fit per library" + assert seen[0] != seen[1], "libraries were fitted with the same seed" + + # more special test cases @@ -118,6 +168,7 @@ def test_niche_calc_spatialleiden_library_key_dummy_adata(dummy_adata2: AnnData) spatial_connectivities_key="spatial_connectivities", resolutions=1.0, library_key="batch", + rng=np.random.default_rng(0), ) niches = _assert_all_assigned(dummy_adata2, "spatialleiden_res=1.0") diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 4116f9547..2d58a6331 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -20,8 +20,10 @@ def test_spatial_autocorr_seq_par(dummy_adata: AnnData, mode: str): """Check whether spatial autocorr results are the same for seq. and parallel computation.""" spatial_autocorr(dummy_adata, mode=mode) dummy_adata.var["highly_variable"] = np.random.choice([True, False], size=dummy_adata.var_names.shape) - df = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=1, seed=42, n_perms=50) - df_parallel = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=2, seed=42, n_perms=50) + df = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=1, rng=np.random.default_rng(42), n_perms=50) + df_parallel = spatial_autocorr( + dummy_adata, mode=mode, copy=True, n_jobs=2, rng=np.random.default_rng(42), n_perms=50 + ) idx_df = df.index.values idx_adata = dummy_adata[:, dummy_adata.var.highly_variable.values].var_names.values @@ -61,8 +63,8 @@ def test_spatial_autocorr_reproducibility(dummy_adata: AnnData, n_jobs: int, mod spatial_autocorr(dummy_adata, mode=mode) dummy_adata.var["highly_variable"] = rng.choice([True, False], size=dummy_adata.var_names.shape) # seed will work only when multiprocessing/loky - df_1 = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=n_jobs, seed=42, n_perms=50) - df_2 = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=n_jobs, seed=42, n_perms=50) + df_1 = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=n_jobs, rng=np.random.default_rng(42), n_perms=50) + df_2 = spatial_autocorr(dummy_adata, mode=mode, copy=True, n_jobs=n_jobs, rng=np.random.default_rng(42), n_perms=50) idx_df = df_1.index.values idx_adata = dummy_adata[:, dummy_adata.var["highly_variable"].values].var_names.values @@ -95,7 +97,7 @@ def test_spatial_autocorr_reproducibility(dummy_adata: AnnData, n_jobs: int, mod @pytest.mark.parametrize("mode", ["moran", "geary"]) def test_spatial_autocorr_n_jobs_invariance(dummy_adata: AnnData, mode: str): """The number of workers must not change the permutation-based results (seed spawned per permutation).""" - kw = {"mode": mode, "copy": True, "seed": 42, "n_perms": 50} + kw = {"mode": mode, "copy": True, "rng": 42, "n_perms": 50} df_serial = spatial_autocorr(dummy_adata, n_jobs=1, **kw) df_parallel = spatial_autocorr(dummy_adata, n_jobs=2, **kw) @@ -118,7 +120,7 @@ def test_spatial_autocorr_var_norm_formula(dummy_adata: AnnData, mode: str): from squidpy.gr._ppatterns import _g_moments uns_key = MORAN_K if mode == "moran" else GEARY_C - spatial_autocorr(dummy_adata, mode=mode, transformation=True, n_perms=None, seed=0) + spatial_autocorr(dummy_adata, mode=mode, transformation=True, n_perms=None, rng=np.random.default_rng(0)) var_norm = float(dummy_adata.uns[uns_key]["var_norm"].iloc[0]) # Reconstruct the exact (row-standardised) weight matrix the routine used. diff --git a/tests/graph/test_ripley.py b/tests/graph/test_ripley.py index f65652674..8daf909ed 100644 --- a/tests/graph/test_ripley.py +++ b/tests/graph/test_ripley.py @@ -99,14 +99,14 @@ def test_ripley_results( @pytest.mark.parametrize("mode", [RipleyStat.F, RipleyStat.G, RipleyStat.L]) -def test_ripley_seed(adata_ripley: AnnData, mode: RipleyStat): +def test_ripley_rng(adata_ripley: AnnData, mode: RipleyStat): """Same seed reproduces simulations, different seeds change them, and simulations are not all identical.""" adata = adata_ripley kw = {"cluster_key": CLUSTER_KEY, "mode": mode.s, "n_simulations": 20, "copy": True} - res1 = ripley(adata, seed=42, **kw) - res2 = ripley(adata, seed=42, **kw) - res3 = ripley(adata, seed=43, **kw) + res1 = ripley(adata, rng=np.random.default_rng(42), **kw) + res2 = ripley(adata, rng=np.random.default_rng(42), **kw) + res3 = ripley(adata, rng=np.random.default_rng(43), **kw) sims1 = res1["sims_stat"].pivot(index="bins", columns="simulations", values="stats").to_numpy() sims2 = res2["sims_stat"].pivot(index="bins", columns="simulations", values="stats").to_numpy() diff --git a/tests/graph/test_utils.py b/tests/graph/test_utils.py index c10293f3f..e35fc9222 100644 --- a/tests/graph/test_utils.py +++ b/tests/graph/test_utils.py @@ -5,8 +5,10 @@ import pytest import spatialdata as sd from anndata import AnnData +from pandas.testing import assert_frame_equal from squidpy._constants._pkg_constants import Key +from squidpy.gr import spatial_autocorr from squidpy.gr._utils import _shuffle_group, extract_adata_if_sdata @@ -87,3 +89,26 @@ def test_shuffle_group(self, cluster_annotations_type: type, library_annotations out = _shuffle_group(cluster_annotations, libraries, rng) for c in libraries.cat.categories: assert set(out[libraries == c]) == set(cluster_annotations[libraries == c]) + + +class TestRngParam: + """SPEC-7 ``rng``: accepts generators as well as seeds, and the old names still work.""" + + def test_seed_and_generator_agree(self, dummy_adata: AnnData): + kw = {"mode": "moran", "copy": True, "n_perms": 20, "n_jobs": 1} + from_seed = spatial_autocorr(dummy_adata, rng=7, **kw) + from_gen = spatial_autocorr(dummy_adata, rng=np.random.default_rng(7), **kw) + assert_frame_equal(from_seed, from_gen) + + def test_deprecated_seed_is_forwarded(self, dummy_adata: AnnData): + kw = {"mode": "moran", "copy": True, "n_perms": 20, "n_jobs": 1} + expected = spatial_autocorr(dummy_adata, rng=42, **kw) + + with pytest.warns(FutureWarning, match=r"`seed`.*deprecated in favor of `rng`.*default_rng\(42\)"): + got = spatial_autocorr(dummy_adata, seed=42, **kw) + + assert_frame_equal(expected, got) + + def test_seed_and_rng_together_is_an_error(self, dummy_adata: AnnData): + with pytest.raises(TypeError, match="both `seed` and `rng`"): + spatial_autocorr(dummy_adata, mode="moran", copy=True, seed=1, rng=2) From 07082921f75774be06ed4cfc28f94f77143d791e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:03:06 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20update=20niche=20function=20paramete?= =?UTF-8?q?rs=20to=20use=20'copy'=20instead=20of=20'inpla=E2=80=A6=20(#127?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update niche function parameters to use 'copy' instead of 'inplace' for better clarity * add import * add import again resulting from the merge conflict * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix the conflict --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/squidpy/_docs.py | 7 +--- src/squidpy/gr/_niche.py | 80 +++++++++++++++++---------------------- tests/graph/test_niche.py | 33 ++++++++++++---- 3 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index c7ba67197..d98c7e5b3 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -246,16 +246,12 @@ def decorator2(obj: Any) -> Any: min_niche_size Minimum number of observations required for a niche. Niches with fewer observations are relabeled ``'not_a_niche'``.""" -_niche_inplace = """\ -inplace - If `True`, modify the table in place and return `None`. - If `False`, return a modified copy and leave the input unchanged.""" # the postprocessing + output params every user-facing niche function shares, in signature order _niche_common_params = f"""\ {_niche_min_niche_size} {_niche_mask} {_library_key} -{_niche_inplace}""" +{_copy}""" _niche_leiden_params = f"""\ flavor Leiden backend passed to :func:`scanpy.tl.leiden`. Defaults to ``'igraph'`` @@ -520,7 +516,6 @@ def decorator2(obj: Any) -> Any: niche_spatial_conn_key=_niche_spatial_conn_key, niche_mask=_niche_mask, niche_min_niche_size=_niche_min_niche_size, - niche_inplace=_niche_inplace, niche_common_params=_niche_common_params, niche_leiden_params=_niche_leiden_params, sdata_params=_sdata_params, diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index f4290f766..99ce66962 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -216,9 +216,9 @@ def calculate_niche( n_hop_weights, min_niche_size, mask, - library_key, - inplace, - table_key, + library_key=library_key, + copy=not inplace, + table_key=table_key, n_iterations=n_iterations, rng=rng, ) @@ -231,9 +231,9 @@ def calculate_niche( spatial_connectivities_key, min_niche_size, mask, - library_key, - inplace, - table_key, + library_key=library_key, + copy=not inplace, + table_key=table_key, n_iterations=n_iterations, rng=rng, ) @@ -249,9 +249,9 @@ def calculate_niche( use_rep, min_niche_size, mask, - library_key, - inplace, - table_key, + library_key=library_key, + copy=not inplace, + table_key=table_key, ) elif flavor == "spatialleiden": @@ -268,7 +268,7 @@ def calculate_niche( mask, prefix=None, library_key=library_key, - inplace=inplace, + copy=not inplace, table_key=table_key, ) @@ -289,7 +289,7 @@ def calculate_niche_neighborhood( min_niche_size: int | None = None, mask: pd.Series | None = None, library_key: str | None = None, - inplace: bool = True, + copy: bool = False, table_key: str | None = None, *, flavor: Literal["igraph", "leidenalg"] = "igraph", @@ -327,8 +327,8 @@ def calculate_niche_neighborhood( Returns ------- - If ``inplace = True``, modifies ``adata`` in place and returns ``None``. - Otherwise, returns a copy of ``adata`` with niche annotations added to ``.obs``. + If ``copy = True``, returns a copy of ``adata`` with niche annotations added to ``.obs``. + Otherwise, modifies ``adata`` in place and returns ``None``. """ @@ -354,7 +354,7 @@ def calculate_niche_neighborhood( min_niche_size=min_niche_size, mask=mask, library_key=library_key, - inplace=inplace, + copy=copy, table_key=table_key, ) @@ -368,7 +368,7 @@ def calculate_niche_utag( min_niche_size: int | None = None, mask: pd.Series | None = None, library_key: str | None = None, - inplace: bool = True, + copy: bool = False, table_key: str | None = None, *, flavor: Literal["igraph", "leidenalg"] = "igraph", @@ -394,8 +394,8 @@ def calculate_niche_utag( Returns ------- - If ``inplace = True``, modifies ``adata`` in place and returns ``None``. - Otherwise, returns a copy of ``adata`` with niche annotations added to ``.obs``. + If ``copy = True``, returns a copy of ``adata`` with niche annotations added to ``.obs``. + Otherwise, modifies ``adata`` in place and returns ``None``. """ @@ -412,7 +412,7 @@ def calculate_niche_utag( min_niche_size=min_niche_size, mask=mask, library_key=library_key, - inplace=inplace, + copy=copy, table_key=table_key, ) @@ -429,7 +429,7 @@ def calculate_niche_cellcharter( min_niche_size: int | None = None, mask: pd.Series | None = None, library_key: str | None = None, - inplace: bool = True, + copy: bool = False, table_key: str | None = None, ) -> AnnData | None: """Compute niche assignments using a CellCharter-style aggregation embedding. @@ -460,8 +460,8 @@ def calculate_niche_cellcharter( Returns ------- - If ``inplace = True``, modifies ``adata`` in place and returns ``None``. - Otherwise, returns a copy of ``adata`` with niche annotations added to ``.obs``. + If ``copy = True``, returns a copy of ``adata`` with niche annotations added to ``.obs``. + Otherwise, modifies ``adata`` in place and returns ``None``. """ @@ -476,7 +476,7 @@ def calculate_niche_cellcharter( min_niche_size=min_niche_size, mask=mask, library_key=library_key, - inplace=inplace, + copy=copy, table_key=table_key, ) @@ -495,7 +495,7 @@ def calculate_niche_spatialleiden( mask: pd.Series | None = None, prefix: str | None = None, library_key: str | None = None, - inplace: bool = True, + copy: bool = False, table_key: str | None = None, ) -> AnnData | None: """Compute niche assignments using the SpatialLeiden algorithm. @@ -530,13 +530,13 @@ def calculate_niche_spatialleiden( When stratifying by ``library_key``, a library-specific prefix is added automatically (something like "lib="). %(library_key)s - %(niche_inplace)s + %(copy)s %(table_key)s Returns ------- - If ``inplace = True``, modifies ``adata`` in place and returns ``None``. - Otherwise, returns a copy of ``adata`` with niche annotations added to ``.obs``. + If ``copy = True``, returns a copy of ``adata`` with niche annotations added to ``.obs``. + Otherwise, modifies ``adata`` in place and returns ``None``. Notes ----- @@ -553,10 +553,7 @@ def calculate_niche_spatialleiden( # obtain adata if data was of sdata type orig_adata = extract_adata_if_sdata(data, table_key=table_key) - if inplace: - adata = orig_adata - else: - adata = orig_adata.copy() + adata = orig_adata.copy() if copy else orig_adata # normalise once here; everything below this point works with rngs only rng = np.random.default_rng(rng) @@ -598,7 +595,7 @@ def calculate_niche_spatialleiden( mask, prefix=f"lib={lib_id}_", library_key=None, - inplace=True, # to save memory + copy=False, # to save memory table_key=table_key, ) @@ -645,10 +642,7 @@ def calculate_niche_spatialleiden( if isinstance(data, SpatialData): sanitize_table(adata) - if inplace: - return None - else: - return adata + return adata if copy else None @d.dedent @@ -659,7 +653,7 @@ def _calculate_niche_custom( min_niche_size: int | None = None, mask: pd.Series | None = None, library_key: str | None = None, - inplace: bool = True, + copy: bool = False, table_key: str | None = None, ) -> AnnData | None: """Compute niche assignments using user-defined embedding, clustering, and postprocessing. @@ -679,8 +673,8 @@ def _calculate_niche_custom( Returns ------- - If ``inplace = True``, modifies ``adata`` in place and returns ``None``. - Otherwise, returns a copy of ``adata`` with niche annotations added to ``.obs``. + If ``copy = True``, returns a copy of ``adata`` with niche annotations added to ``.obs``. + Otherwise, modifies ``adata`` in place and returns ``None``. Notes ----- @@ -700,10 +694,7 @@ def _calculate_niche_custom( # obtain adata if data was of sdata type orig_adata = extract_adata_if_sdata(data, table_key=table_key) - if inplace: - adata = orig_adata - else: - adata = orig_adata.copy() + adata = orig_adata.copy() if copy else orig_adata if library_key is not None: assert_key_in_adata(adata, library_key, attr="obs") @@ -744,10 +735,7 @@ def _calculate_niche_custom( if isinstance(data, SpatialData): sanitize_table(adata) - if inplace: - return None - else: - return adata + return adata if copy else None def _run_niche_pipeline( diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index 948feef46..2ea09a533 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -9,7 +9,13 @@ from spatialdata import SpatialData from spatialdata.models import TableModel -from squidpy.gr import _niche, calculate_niche, calculate_niche_cellcharter, spatial_neighbors_knn +from squidpy.gr import ( + _niche, + calculate_niche, + calculate_niche_cellcharter, + calculate_niche_neighborhood, + spatial_neighbors_knn, +) N_NEIGHBORS = 20 GROUPS = "celltype_mapped_refined" @@ -90,12 +96,12 @@ def test_niche_cellcharter_rng_reproducible(dummy_adata2: AnnData): dummy_adata2.X = csr_matrix(dummy_adata2.X) kwargs = {"distance": 2, "aggregation": "mean"} - first = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) - second = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + first = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), copy=True, **kwargs) + second = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), copy=True, **kwargs) assert (first.obs["cellcharter_niche"] == second.obs["cellcharter_niche"]).all() # not a guarantee about the labels themselves, only that the seed is actually wired through - other = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(1), inplace=False, **kwargs) + other = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(1), copy=True, **kwargs) assert list(other.obs["cellcharter_niche"]) != list(first.obs["cellcharter_niche"]) @@ -112,8 +118,8 @@ def test_niche_cellcharter_library_seeds_are_independent(dummy_adata2: AnnData, dummy_adata2.obs["batch"] = ["batch1"] * 5 + ["batch2"] * 5 kwargs = {"distance": 2, "aggregation": "mean", "library_key": "batch", "n_components": 2} - first = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) - second = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + first = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), copy=True, **kwargs) + second = calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), copy=True, **kwargs) assert (first.obs["cellcharter_niche"] == second.obs["cellcharter_niche"]).all() # the clusterer is built once and reused for every library, so record what each fit @@ -126,7 +132,7 @@ def spy(*args, **kwargs): return original(*args, **kwargs) monkeypatch.setattr(_niche, "GaussianMixture", spy) - calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), inplace=False, **kwargs) + calculate_niche_cellcharter(dummy_adata2, rng=np.random.default_rng(0), copy=True, **kwargs) assert len(seen) == 2, "expected one mixture fit per library" assert seen[0] != seen[1], "libraries were fitted with the same seed" @@ -250,3 +256,16 @@ def test_niche_calc_utag(adata_seqfish: AnnData): assert niches.isna().sum() == 0 assert niches.nunique() > niches_low_res.nunique() + + +def test_niche_copy_semantics(dummy_adata2: AnnData): + "copy=True returns an annotated copy and leaves the input untouched; copy=False mutates and returns None." + key = "nhood_niche_res=1.0" + kwargs = {"groups": "celltype", "n_neighbors": 3, "resolutions": 1.0} + + out = calculate_niche_neighborhood(dummy_adata2, copy=True, **kwargs) + assert key in out.obs.columns + assert key not in dummy_adata2.obs.columns + + assert calculate_niche_neighborhood(dummy_adata2, **kwargs) is None + assert (dummy_adata2.obs[key] == out.obs[key]).all()