From def5e2380a35333cf2405fb4721f0466640a24ed Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Fri, 25 Sep 2026 21:05:09 +0200 Subject: [PATCH 01/28] Make most arguments keyword-only, enforced by PLR0917 (#1288) Ruff's PLR0917 (preview, selected explicitly) now flags any function with more than five positional arguments. - Public functions take only their data object positionally; everything after it is keyword-only. Released functions keep accepting the old positional calls through legacy-api-wrap, with a FutureWarning naming the arguments. Experimental functions don't: that API promises no stability. - Private functions change only where the rule flags them: arguments with a default become keyword-only, and helpers whose arguments are all required keep only their first argument positional. Numba kernels are exempted, since numba needs positional arguments. - Left as on main: the deprecated calculate_niche and its validator (removed in v1.9.0), and ImageContainer until it is deprecated. - Internal and test callers pass the moved arguments by name. Tests are exempted from the rule, since pytest passes fixtures by name. Preview mode also caught an undefined apply_stain_normalization in squidpy.experimental.im.__all__, removed here. Also documents these rules and the rng conventions in the contribution guide, and drops the unused docs/release/notes-dev.md: release notes come from the PR changelog. --- docs/contributing.md | 6 ++ docs/release/notes-dev.md | 9 -- docs/release_notes.md | 5 - pyproject.toml | 11 ++- src/squidpy/_compat.py | 7 ++ src/squidpy/_utils.py | 1 + src/squidpy/experimental/im/__init__.py | 1 - .../im/_calculate_image_features.py | 19 +++- src/squidpy/experimental/im/_detect_tissue.py | 2 +- src/squidpy/experimental/im/_make_tiles.py | 2 +- src/squidpy/experimental/im/_qc_image.py | 4 +- .../experimental/im/_stain/_normalize.py | 10 +- src/squidpy/experimental/im/_utils.py | 1 + src/squidpy/experimental/pl/_qc_image.py | 1 + src/squidpy/experimental/pl/_tiling_qc.py | 1 + .../experimental/tl/_stitched_labels.py | 1 + src/squidpy/experimental/tl/_tiling_qc.py | 1 + src/squidpy/experimental/tl/_tiling_stitch.py | 2 + src/squidpy/gr/_build.py | 24 ++++- src/squidpy/gr/_ligrec.py | 18 +++- src/squidpy/gr/_nhood.py | 30 ++++-- src/squidpy/gr/_niche.py | 17 ++-- src/squidpy/gr/_ppatterns.py | 26 +++++- src/squidpy/gr/_ripley.py | 16 +++- src/squidpy/gr/_sepal.py | 36 ++++++-- src/squidpy/im/_feature.py | 25 ++++- src/squidpy/im/_process.py | 5 + src/squidpy/pl/_graph.py | 27 ++++++ src/squidpy/pl/_ligrec.py | 18 ++++ src/squidpy/pl/_spatial.py | 4 +- src/squidpy/pl/_spatial_utils.py | 15 +++ src/squidpy/pl/_utils.py | 1 + src/squidpy/pl/_var_by_distance.py | 1 + src/squidpy/tl/_sliding_window.py | 3 +- src/squidpy/tl/_var_by_distance.py | 1 + tests/conftest.py | 2 +- .../test_stain_decompose_public.py | 76 +++++++++------ tests/experimental/test_stain_normalize.py | 88 ++++++++++-------- tests/experimental/test_stain_white_point.py | 6 +- tests/graph/test_ligrec.py | 92 +++++++++++-------- tests/graph/test_nhood.py | 8 +- tests/graph/test_niche.py | 4 + tests/graph/test_spatial_neighbors.py | 12 +-- tests/image/test_features.py | 14 +-- tests/plotting/test_image.py | 2 +- tests/utils/test_old_positionals.py | 17 ++++ 46 files changed, 486 insertions(+), 186 deletions(-) delete mode 100644 docs/release/notes-dev.md create mode 100644 tests/utils/test_old_positionals.py diff --git a/docs/contributing.md b/docs/contributing.md index de383382a..6077fa85f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -167,6 +167,12 @@ Consider enabling this option for [ruff][ruff-editors] and [biome][biome-editors [ruff-editors]: https://docs.astral.sh/ruff/integrations/ [biome-editors]: https://biomejs.dev/guides/integrate-in-editor/ +### Conventions + +- **Keyword-only arguments**: a public function takes its data object (`adata`, `sdata` or `data`) positionally, and every other argument is keyword-only, e.g. `def nhood_enrichment(adata, *, cluster_key, ...)`. +- **Randomness**: take `rng: SeedLike | RNGLike | None = None` ([SPEC 7](https://scientific-python.org/specs/spec-0007/)), never `seed`/`random_state`. Spawn one generator per independent task with `rng.spawn(n)`, and pass `legacy_random(rng)` to APIs that only take an integer seed. +- **Warnings**: `warnings.warn` for anything about the call: deprecations (`FutureWarning`) and arguments whose effect the caller may not expect, such as clusters dropped by `min_cell_count` (`UserWarning`). What the computation is doing, such as the PCA it runs, goes through `logg`. + (writing-tests)= ## Writing tests diff --git a/docs/release/notes-dev.md b/docs/release/notes-dev.md deleted file mode 100644 index 221023e5e..000000000 --- a/docs/release/notes-dev.md +++ /dev/null @@ -1,9 +0,0 @@ -# Squidpy dev (the-future) - -## Features - -- {func}`squidpy.experimental.im.calculate_image_features` now featurizes tiles on a shared dask engine: `n_jobs > 1` runs worker processes via a `dask.distributed.LocalCluster` (or an active `Client`), and per-tile BLAS/OpenMP threads are pinned to avoid oversubscription. This also speeds up the serial path. {func}`squidpy.experimental.tl.calculate_tiling_qc` shares the same engine. Adds `distributed` and `threadpoolctl` as dependencies. -- Fix {func}`squidpy.tl.var_by_distance` behaviour when providing {mod}`numpy` arrays of coordinates as anchor point. -- Update :attr:`squidpy.pl.var_by_distance` to show multiple variables on same plot. - [@LLehner](https://github.com/LLehner) - [#929](https://github.com/scverse/squidpy/pull/929) diff --git a/docs/release_notes.md b/docs/release_notes.md index a73ec3df6..7420b144f 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -1,11 +1,6 @@ # Release Notes ```{eval-rst} -.. toctree:: - :maxdepth: 3 - - release/notes-dev - .. toctree:: :maxdepth: 3 :glob: diff --git a/pyproject.toml b/pyproject.toml index 976d0120a..edba7df30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "fast-array-utils>=1.5", # `types.HasArrayNamespace` "fsspec>=2021.11", "imagecodecs>=2025.8.2,<2026", + "legacy-api-wrap>=1.5", # keyword-only args keep accepting old positional calls (#1288) "matplotlib>=3.3", "matplotlib-scalebar>=0.8", "networkx>=2.6", @@ -191,6 +192,8 @@ lint.select = [ "UP", # pyupgrade "W", # pycodestyle ] +# too many positional arguments: most arguments are keyword-only (#1288) +lint.extend-select = [ "PLR0917" ] lint.ignore = [ # B008 Do not perform function calls in argument defaults. "B008", @@ -250,13 +253,18 @@ lint.ignore = [ # "E111", # "E114", ] +lint.explicit-preview-rules = true # only the preview rules selected by name, i.e. PLR0917 lint.per-file-ignores."*/__init__.py" = [ "D104", "F401" ] lint.per-file-ignores.".scripts/ci/download_data.py" = [ "B", "D" ] lint.per-file-ignores."docs/*" = [ "B", "D" ] lint.per-file-ignores."src/squidpy/_constants/_constants.py" = [ "D101" ] lint.per-file-ignores."src/squidpy/_constants/_pkg_constants.py" = [ "D101", "D102", "D106" ] +# ImageContainer is left as it is until it is deprecated, positional arguments included +lint.per-file-ignores."src/squidpy/im/_container.py" = [ "PLR0917" ] +lint.per-file-ignores."src/squidpy/im/_feature_mixin.py" = [ "PLR0917" ] +lint.per-file-ignores."src/squidpy/im/_segment.py" = [ "PLR0917" ] lint.per-file-ignores."src/squidpy/pl/_ligrec.py" = [ "B", "D" ] -lint.per-file-ignores."tests/*" = [ "D" ] +lint.per-file-ignores."tests/*" = [ "D", "PLR0917" ] lint.unfixable = [ "B", "BLE", @@ -266,6 +274,7 @@ lint.unfixable = [ # Disallow all relative imports. lint.flake8-tidy-imports.ban-relative-imports = "all" lint.isort.required-imports = [ "from __future__ import annotations" ] +lint.preview = true # "squidpy/*.py" = [ "RST303" ] [tool.pytest] diff --git a/src/squidpy/_compat.py b/src/squidpy/_compat.py index 2e56389ec..27b840318 100644 --- a/src/squidpy/_compat.py +++ b/src/squidpy/_compat.py @@ -1,8 +1,10 @@ from __future__ import annotations +from functools import partial from importlib.metadata import version from typing import TYPE_CHECKING +from legacy_api_wrap import legacy_api from packaging.version import Version from scanpy.get import obs_df @@ -11,6 +13,7 @@ from anndata import AnnData __all__ = [ + "old_positionals", # scanpy "set_default_colors_for_categorical_obs", "add_categorical_legend", @@ -26,6 +29,10 @@ "get_vector", ] +#: For arguments that became keyword-only: an old positional call still works, with a +#: ``FutureWarning`` naming them (#1288). +old_positionals = partial(legacy_api, category=FutureWarning) + # Scanpy 1.13 moved the pre-v2 plotting internals under ``scanpy.plotting.legacy``. # ``scanpy.plotting.__getattr__`` forwards attribute access there, but submodule # imports such as ``scanpy.plotting.palettes`` are not covered by it. diff --git a/src/squidpy/_utils.py b/src/squidpy/_utils.py index 8d738a7b9..9baf9009c 100644 --- a/src/squidpy/_utils.py +++ b/src/squidpy/_utils.py @@ -88,6 +88,7 @@ class Signal(Enum): def parallelize( callback: Callable[..., Any], collection: Sequence[Any], + *, n_jobs: int | None = 1, n_split: int | None = None, unit: str = "", diff --git a/src/squidpy/experimental/im/__init__.py b/src/squidpy/experimental/im/__init__.py index e6bef60a8..0983d3850 100644 --- a/src/squidpy/experimental/im/__init__.py +++ b/src/squidpy/experimental/im/__init__.py @@ -30,7 +30,6 @@ "StainReference", "VahadaneParams", "WekaParams", - "apply_stain_normalization", "calculate_image_features", "normalize_stains", "decompose_stains", diff --git a/src/squidpy/experimental/im/_calculate_image_features.py b/src/squidpy/experimental/im/_calculate_image_features.py index 4ae5de1f7..fc8bcbf73 100644 --- a/src/squidpy/experimental/im/_calculate_image_features.py +++ b/src/squidpy/experimental/im/_calculate_image_features.py @@ -634,6 +634,7 @@ def _classify_boundary_cells( def _align_to_image_grid( sdata: SpatialData, + *, image_key: str, labels_key: str, image_da: xr.DataArray, @@ -756,6 +757,7 @@ def _validate_inputs( def _prepare_lazy( sdata: SpatialData, + *, image_key: str | None, labels_key: str | None, shapes_key: str | None, @@ -799,7 +801,14 @@ def _prepare_lazy( # Only meaningful with a real labels element + an image; the shapes->labels # path already rasterized onto the image grid (identity transform -> no-op). if image_da is not None and labels_key is not None: - image_da, labels_da = _align_to_image_grid(sdata, image_key, labels_key, image_da, labels_da, align_mode) + image_da, labels_da = _align_to_image_grid( + sdata, + image_key=image_key, + labels_key=labels_key, + image_da=image_da, + labels_da=labels_da, + align_mode=align_mode, + ) if image_da is None: return image_da, labels_da, [] @@ -1050,7 +1059,13 @@ def calculate_image_features( raise ValueError("`channels` selection requires `image_key`.") image_da, labels_da, channel_names = _prepare_lazy( - sdata, image_key, labels_key, shapes_key, scale, channels, align_mode + sdata, + image_key=image_key, + labels_key=labels_key, + shapes_key=shapes_key, + scale=scale, + channels=channels, + align_mode=align_mode, ) # Warn when per-channel features would be named by positional index because diff --git a/src/squidpy/experimental/im/_detect_tissue.py b/src/squidpy/experimental/im/_detect_tissue.py index 8fac7c2de..4974e4bbb 100644 --- a/src/squidpy/experimental/im/_detect_tissue.py +++ b/src/squidpy/experimental/im/_detect_tissue.py @@ -214,8 +214,8 @@ def _rescale_margins( def detect_tissue( sdata: sd.SpatialData, - image_key: str, *, + image_key: str, scale: str = "auto", method: DetectTissueMethod | str = DetectTissueMethod.OTSU, method_params: FelzenszwalbParams | WekaParams | Mapping[str, Any] | None = None, diff --git a/src/squidpy/experimental/im/_make_tiles.py b/src/squidpy/experimental/im/_make_tiles.py index 87f93d33b..a77657e3d 100644 --- a/src/squidpy/experimental/im/_make_tiles.py +++ b/src/squidpy/experimental/im/_make_tiles.py @@ -162,8 +162,8 @@ def _propagate_spot_classification(sdata: sd.SpatialData, tiles_key: str, spots_ def make_tiles( sdata: sd.SpatialData, - image_key: str, *, + image_key: str, image_mask_key: str | None = None, tissue_mask_key: str | None = None, tile_size: tuple[int, int] = (224, 224), diff --git a/src/squidpy/experimental/im/_qc_image.py b/src/squidpy/experimental/im/_qc_image.py index a8010e87f..9a1f5052f 100644 --- a/src/squidpy/experimental/im/_qc_image.py +++ b/src/squidpy/experimental/im/_qc_image.py @@ -44,8 +44,8 @@ def qc_image( sdata: SpatialData, - image_key: str, *, + image_key: str, scale: str = "scale0", metrics: QCMetric | list[QCMetric] | None = None, tile_size: Literal["auto"] | tuple[int, int] = "auto", @@ -145,7 +145,7 @@ def qc_image( gray = _to_gray_dask_yx(img_yxc) H, W = int(gray.shape[0]), int(gray.shape[1]) - tg = TileGrid(H, W, tile_size) + tg = TileGrid(H, W, tile_size=tile_size) obs_names = tg.names() logger.info("Quantifying image quality.") diff --git a/src/squidpy/experimental/im/_stain/_normalize.py b/src/squidpy/experimental/im/_stain/_normalize.py index 2731c9bc4..024831ecd 100644 --- a/src/squidpy/experimental/im/_stain/_normalize.py +++ b/src/squidpy/experimental/im/_stain/_normalize.py @@ -174,8 +174,8 @@ def _write_image( def estimate_white_point( sdata: sd.SpatialData, - image_key: str, *, + image_key: str, tissue_mask_key: str | None = None, scale: str | Literal["auto"] = "auto", ) -> np.ndarray: @@ -211,8 +211,8 @@ def estimate_white_point( def fit_stain_reference( sdata: sd.SpatialData, - image_key: str, *, + image_key: str, method: StainMethod = "macenko", scale: str | Literal["auto"] = "auto", method_params: MethodParams = None, @@ -294,9 +294,9 @@ def fit_stain_reference( def normalize_stains( sdata: sd.SpatialData, + *, image_key: str, reference: StainReference, - *, scale: str | Literal["auto"] = "auto", method_params: MethodParams = None, image_key_added: str | None = None, @@ -396,9 +396,9 @@ def normalize_stains( def decompose_stains( sdata: sd.SpatialData, + *, image_key: str, reference_or_method: StainReference | Literal["macenko", "vahadane"], - *, scale: str | Literal["auto"] = "auto", method_params: MethodParams = None, white_point: np.ndarray | None = None, @@ -461,7 +461,7 @@ def decompose_stains( raise ValueError(f"method must be one of {list(_DECOMPOSITION_METHODS)}; got {reference_or_method!r}.") reference = fit_stain_reference( sdata, - image_key, + image_key=image_key, method=reference_or_method, scale=scale, method_params=method_params, diff --git a/src/squidpy/experimental/im/_utils.py b/src/squidpy/experimental/im/_utils.py index a18770741..9616cd120 100644 --- a/src/squidpy/experimental/im/_utils.py +++ b/src/squidpy/experimental/im/_utils.py @@ -22,6 +22,7 @@ def __init__( self, H: int, W: int, + *, tile_size: Literal["auto"] | tuple[int, int] = "auto", target_tiles: int = 100, offset_y: int = 0, diff --git a/src/squidpy/experimental/pl/_qc_image.py b/src/squidpy/experimental/pl/_qc_image.py index bf369b753..4a27d2341 100644 --- a/src/squidpy/experimental/pl/_qc_image.py +++ b/src/squidpy/experimental/pl/_qc_image.py @@ -13,6 +13,7 @@ def qc_image( sdata: SpatialData, + *, image_key: str, metrics: QCMetric | list[QCMetric] | None = None, figsize: tuple[int, int] | None = None, diff --git a/src/squidpy/experimental/pl/_tiling_qc.py b/src/squidpy/experimental/pl/_tiling_qc.py index 9844af79e..7bc3a5dc9 100644 --- a/src/squidpy/experimental/pl/_tiling_qc.py +++ b/src/squidpy/experimental/pl/_tiling_qc.py @@ -11,6 +11,7 @@ def tiling_qc( sdata: sd.SpatialData, + *, labels_key: str, qc_key: str | None = None, score_col: Literal[ diff --git a/src/squidpy/experimental/tl/_stitched_labels.py b/src/squidpy/experimental/tl/_stitched_labels.py index 71d8b9ca8..44370ee40 100644 --- a/src/squidpy/experimental/tl/_stitched_labels.py +++ b/src/squidpy/experimental/tl/_stitched_labels.py @@ -392,6 +392,7 @@ def _collapse_groups( def make_stitched_labels( sdata: sd.SpatialData, + *, labels_key: str, qc_table_key: str | None = None, labels_key_added: str | None = None, diff --git a/src/squidpy/experimental/tl/_tiling_qc.py b/src/squidpy/experimental/tl/_tiling_qc.py index c6489f6ad..bffc03979 100644 --- a/src/squidpy/experimental/tl/_tiling_qc.py +++ b/src/squidpy/experimental/tl/_tiling_qc.py @@ -430,6 +430,7 @@ def _compute_centroids_for_labels( def calculate_tiling_qc( sdata: sd.SpatialData, + *, labels_key: str, scale: str | None = None, tile_size: int = 2048, diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index df55f05d2..b5a12d231 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -251,6 +251,7 @@ def _bbox_edge_run( def _extract_cut_edges( labels_da: xr.DataArray | np.ndarray, outlier_ids: Iterable[int], + *, bboxes: dict[int, tuple[int, int, int, int]] | None = None, distance_tol: float = _STITCH_DEFAULTS.distance_tol, min_edge_length: float = _STITCH_DEFAULTS.min_edge_length, @@ -729,6 +730,7 @@ def _assemble_groups( def assign_stitch_groups( sdata: sd.SpatialData, + *, labels_key: str, qc_table_key: str | None = None, min_confidence: float = 0.7, diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 74a385caf..a2644157d 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -23,6 +23,7 @@ get_model, ) +from squidpy._compat import old_positionals from squidpy._constants._constants import CoordType, Transform from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -129,8 +130,26 @@ def _resolve_graph_builder( @d.dedent @inject_docs(t=Transform, c=CoordType) +@old_positionals( + "spatial_key", + "elements_to_coordinate_systems", + "table_key", + "library_key", + "coord_type", + "n_neighs", + "radius", + "delaunay", + "n_rings", + "percentile", + "transform", + "set_diag", + "key_added", + "copy", + "n_jobs", +) def spatial_neighbors( adata: AnnData | SpatialData, + *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, @@ -385,10 +404,11 @@ def _resolve_data( @d.dedent +@old_positionals("builder") def spatial_neighbors_from_builder( data: AnnData | SpatialData, - builder: GraphBuilder[Any, Any], *, + builder: GraphBuilder[Any, Any], spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, @@ -850,8 +870,10 @@ def _run_spatial_neighbors( @d.dedent +@old_positionals("table_key", "polygon_mask", "negative_mask", "spatial_key", "key_added", "copy") def mask_graph( sdata: SpatialData, + *, table_key: str, polygon_mask: Polygon | MultiPolygon, negative_mask: bool = False, diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 0de8b9c46..b7ae172cc 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -19,6 +19,7 @@ from scipy.sparse import csc_matrix from spatialdata import SpatialData +from squidpy._compat import old_positionals from squidpy._constants._constants import ComplexPolicy, CorrAxis from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -240,6 +241,7 @@ def prepare( def test( self, cluster_key: str, + *, clusters: Cluster_t | None = None, n_perms: int = 1000, threshold: float = 0.01, @@ -555,8 +557,21 @@ def prepare( @d.dedent @deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) @deprecated_randomness_param +@old_positionals( + "cluster_key", + "interactions", + "complex_policy", + "threshold", + "corr_method", + "corr_axis", + "use_raw", + "copy", + "key_added", + "gene_symbols", +) def ligrec( adata: AnnData | SpatialData, + *, cluster_key: str, interactions: Interaction_t | None = None, complex_policy: Literal["min", "all"] = ComplexPolicy.MIN.v, @@ -567,7 +582,6 @@ def ligrec( copy: bool = False, key_added: str | None = None, gene_symbols: str | None = None, - *, n_perms: int = 1000, rng: SeedLike | RNGLike | None = None, clusters: Cluster_t | None = None, @@ -624,7 +638,7 @@ def ligrec( @njit(parallel=True, cache=True) -def _score_permutations( +def _score_permutations( # noqa: PLR0917, numba requires positional arguments data: NDArrayA, clustering: NDArrayA, generators: list[np.random.Generator], diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index f428c43a8..cb187d5e3 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -24,6 +24,7 @@ from scipy.sparse import csr_array, csr_matrix, issparse from spatialdata import SpatialData +from squidpy._compat import old_positionals from squidpy._constants._constants import Centrality from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -180,7 +181,7 @@ def _shuffled_labels( @njit(parallel=True, nogil=True, cache=True) -def _permutation_moments_counts( +def _permutation_moments_counts( # noqa: PLR0917, numba requires positional arguments indices: NDArrayA, indptr: NDArrayA, int_clust: NDArrayA, @@ -227,7 +228,7 @@ def _permutation_moments_counts( @njit(parallel=True, nogil=True, cache=True) -def _permutation_moments_normalized( +def _permutation_moments_normalized( # noqa: PLR0917, numba requires positional arguments indices: NDArrayA, indptr: NDArrayA, int_clust: NDArrayA, @@ -298,8 +299,22 @@ def _filter_clusters_by_min_cell_count( @d.dedent @deprecated_randomness_param @deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) +@old_positionals( + "cluster_key", + "library_key", + "connectivity_key", + "n_perms", + "rng", + "copy", + "n_jobs", + "show_progress_bar", + "normalization", + "min_cell_count", + "handle_nan", +) def nhood_enrichment( adata: AnnData | SpatialData, + *, cluster_key: str, library_key: str | None = None, connectivity_key: str | None = None, @@ -311,7 +326,6 @@ def nhood_enrichment( normalization: str = "none", min_cell_count: int = 0, handle_nan: Literal["keep", "zero"] = "keep", - *, table_key: str | None = None, ) -> NhoodEnrichmentResult | None: """ @@ -539,8 +553,10 @@ def nhood_enrichment( @d.dedent @inject_docs(c=Centrality) +@old_positionals("cluster_key", "score", "connectivity_key", "copy", "n_jobs", "backend", "show_progress_bar") def centrality_scores( adata: AnnData | SpatialData, + *, cluster_key: str, score: str | Iterable[str] | None = None, connectivity_key: str | None = None, @@ -548,7 +564,6 @@ def centrality_scores( n_jobs: int | None = None, backend: str = "loky", show_progress_bar: bool = False, - *, table_key: str | None = None, ) -> pd.DataFrame | None: """ @@ -641,14 +656,15 @@ def centrality_scores( @d.dedent +@old_positionals("cluster_key", "connectivity_key", "normalized", "copy", "weights") def interaction_matrix( adata: AnnData | SpatialData, + *, cluster_key: str, connectivity_key: str | None = None, normalized: bool = False, copy: bool = False, weights: bool = False, - *, table_key: str | None = None, ) -> NDArrayA | None: """ @@ -829,7 +845,7 @@ def _build_shuffle_groups( @njit(inline="always", cache=True) -def _expand( +def _expand( # noqa: PLR0917, numba requires positional arguments indptr: NDArrayA, indices: NDArrayA, stamp: NDArrayA, @@ -857,7 +873,7 @@ def _expand( @njit(parallel=True, cache=True) -def _bfs_shells( +def _bfs_shells( # noqa: PLR0917, numba requires positional arguments indptr: NDArrayA, indices: NDArrayA, max_hop: int, diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 0df1c9331..993f2fc23 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -49,7 +49,7 @@ @d.dedent @inject_docs(fla=NicheDefinitions) @deprecated_randomness_param -def calculate_niche( +def calculate_niche( # noqa: PLR0917, deprecated and removed in v1.9.0 data: AnnData | SpatialData, flavor: Literal["neighborhood", "utag", "cellcharter", "spatialleiden"], library_key: str | None = None, @@ -475,8 +475,8 @@ def calculate_niche_neighborhood( return calculate_niche_custom( data, - embedder, - clusterers, + embedder=embedder, + clusterers=clusterers, rng=rng, embedding_key_added=embedding_key_added, min_niche_size=min_niche_size, @@ -598,8 +598,8 @@ def calculate_niche_utag( return calculate_niche_custom( data, - embedder, - clusterers, + embedder=embedder, + clusterers=clusterers, rng=rng, embedding_key_added=embedding_key_added, min_niche_size=min_niche_size, @@ -732,8 +732,8 @@ def calculate_niche_cellcharter( return calculate_niche_custom( data, - embedder, - clusterers, + embedder=embedder, + clusterers=clusterers, rng=rng, embedding_key_added=embedding_key_added, min_niche_size=min_niche_size, @@ -815,6 +815,7 @@ def run(adata: AnnData) -> None: @d.dedent def calculate_niche_custom( data: AnnData | SpatialData, + *, embedder: NicheEmbedder, clusterers: Mapping[str, Clusterer], rng: SeedLike | RNGLike | None = None, @@ -885,7 +886,7 @@ def run(adata: AnnData) -> None: return _on_table(data, table_key=table_key, copy=copy, work=run) -def _validate_niche_args( +def _validate_niche_args( # noqa: PLR0917, deprecated and removed in v1.9.0 data: AnnData | SpatialData, flavor: Literal["neighborhood", "utag", "cellcharter", "spatialleiden"], table_key: str | None, diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index b24d8f9cf..bb9f658c6 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -19,6 +19,7 @@ from spatialdata import SpatialData from statsmodels.stats.multitest import multipletests +from squidpy._compat import old_positionals from squidpy._constants._constants import SpatialAutocorr from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -56,8 +57,26 @@ @d.dedent @inject_docs(key=Key.obsp.spatial_conn(), sp=SpatialAutocorr) @deprecated_randomness_param +@old_positionals( + "connectivity_key", + "genes", + "mode", + "transformation", + "n_perms", + "two_tailed", + "corr_method", + "attr", + "layer", + "rng", + "use_raw", + "copy", + "n_jobs", + "backend", + "show_progress_bar", +) def spatial_autocorr( adata: AnnData | SpatialData, + *, connectivity_key: str = Key.obsp.spatial_conn(), genes: str | int | Sequence[str] | Sequence[int] | None = None, mode: SpatialAutocorr | Literal["moran", "geary"] = "moran", @@ -73,7 +92,6 @@ def spatial_autocorr( n_jobs: int | None = None, backend: str = "loky", show_progress_bar: bool = True, - *, table_key: str | None = None, ) -> pd.DataFrame | None: """ @@ -266,6 +284,7 @@ def _score_helper( g: spmatrix, vals: NDArrayA, generators: Sequence[np.random.Generator], + *, queue: SigQueue | None = None, ) -> pd.DataFrame: score_perms = np.empty((len(perms), vals.shape[0])) @@ -286,7 +305,7 @@ def _score_helper( @njit(parallel=True, fastmath=True, cache=True) -def _occur_count( +def _occur_count( # noqa: PLR0917, numba requires positional arguments spatial_x: NDArrayA, spatial_y: NDArrayA, thresholds: NDArrayA, label_idx: NDArrayA, n: int, k: int, l_val: int ) -> NDArrayA: # Allocate a 2D array to store a flat local result per point. @@ -365,13 +384,14 @@ def _co_occurrence_helper(v_x: NDArrayA, v_y: NDArrayA, v_radium: NDArrayA, labs @d.dedent @deprecated_params({"n_splits": "1.10.0", "n_jobs": "1.10.0", "backend": "1.10.0", "show_progress_bar": "1.10.0"}) +@old_positionals("cluster_key", "spatial_key", "interval", "copy") def co_occurrence( adata: AnnData | SpatialData, + *, cluster_key: str, spatial_key: str = Key.obsm.spatial, interval: int | NDArrayA = 50, copy: bool = False, - *, table_key: str | None = None, ) -> tuple[NDArrayA, NDArrayA] | None: """ diff --git a/src/squidpy/gr/_ripley.py b/src/squidpy/gr/_ripley.py index 268a9c493..74498c373 100644 --- a/src/squidpy/gr/_ripley.py +++ b/src/squidpy/gr/_ripley.py @@ -13,6 +13,7 @@ from sklearn.preprocessing import LabelEncoder from spatialdata import SpatialData +from squidpy._compat import old_positionals from squidpy._constants._constants import RipleyStat from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -25,8 +26,22 @@ @d.dedent @inject_docs(key=Key.obsm.spatial, rp=RipleyStat) @deprecated_randomness_param +@old_positionals( + "cluster_key", + "mode", + "spatial_key", + "metric", + "n_neigh", + "n_simulations", + "n_observations", + "max_dist", + "n_steps", + "rng", + "copy", +) def ripley( adata: AnnData | SpatialData, + *, cluster_key: str, mode: Literal["F", "G", "L"] = "F", spatial_key: str = Key.obsm.spatial, @@ -38,7 +53,6 @@ def ripley( n_steps: int = 50, rng: SeedLike | RNGLike | None = None, copy: bool = False, - *, table_key: str | None = None, ) -> dict[str, pd.DataFrame | NDArrayA]: r""" diff --git a/src/squidpy/gr/_sepal.py b/src/squidpy/gr/_sepal.py index a83bd5627..a7208e894 100644 --- a/src/squidpy/gr/_sepal.py +++ b/src/squidpy/gr/_sepal.py @@ -12,6 +12,7 @@ from sklearn.metrics import pairwise_distances from spatialdata import SpatialData +from squidpy._compat import old_positionals 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, thread_map @@ -30,8 +31,23 @@ @d.dedent @inject_docs(key=Key.obsp.spatial_conn()) @deprecated_params({"backend": "1.10.0"}) +@old_positionals( + "max_neighs", + "genes", + "n_iter", + "dt", + "thresh", + "connectivity_key", + "spatial_key", + "layer", + "use_raw", + "copy", + "n_jobs", + "show_progress_bar", +) def sepal( adata: AnnData | SpatialData, + *, max_neighs: Literal[4, 6], genes: str | Sequence[str] | None = None, n_iter: int | None = 30000, @@ -44,7 +60,6 @@ def sepal( copy: bool = False, n_jobs: int | None = None, show_progress_bar: bool = True, - *, table_key: str | None = None, ) -> pd.DataFrame | None: """ @@ -135,14 +150,14 @@ def sepal( vals = csc_matrix(vals) score = _diffusion_genes( vals, - use_hex, - n_iter, - sat, - sat_idx, - unsat, - unsat_idx, - dt, - thresh, + use_hex=use_hex, + n_iter=n_iter, + sat=sat, + sat_idx=sat_idx, + unsat=unsat, + unsat_idx=unsat_idx, + dt=dt, + thresh=thresh, n_jobs=n_jobs, show_progress_bar=show_progress_bar, ) @@ -163,6 +178,7 @@ def sepal( def _diffusion_genes( vals: NDArrayA | spmatrix, + *, use_hex: bool, n_iter: int, sat: NDArrayA, @@ -207,7 +223,7 @@ def _process_gene(i: int) -> float: @njit(fastmath=True, nogil=True) -def _diffusion( +def _diffusion( # noqa: PLR0917, numba requires positional arguments conc: NDArrayA, use_hex: bool, n_iter: int, diff --git a/src/squidpy/im/_feature.py b/src/squidpy/im/_feature.py index 6c4b738d6..53e0135e0 100644 --- a/src/squidpy/im/_feature.py +++ b/src/squidpy/im/_feature.py @@ -8,6 +8,7 @@ from anndata import AnnData from scanpy import logging as logg +from squidpy._compat import old_positionals from squidpy._constants._constants import ImageFeature from squidpy._docs import d, inject_docs from squidpy._utils import Signal, SigQueue, get_n_processes, parallelize @@ -19,8 +20,21 @@ @d.dedent @inject_docs(f=ImageFeature) +@old_positionals( + "img", + "layer", + "library_id", + "features", + "features_kwargs", + "key_added", + "copy", + "n_jobs", + "backend", + "show_progress_bar", +) def calculate_image_features( adata: AnnData, + *, img: ImageContainer, layer: str | None = None, library_id: str | Sequence[str] | None = None, @@ -94,7 +108,15 @@ def calculate_image_features( n_jobs=n_jobs, backend=backend, show_progress_bar=show_progress_bar, - )(adata, img, layer=layer, library_id=library_id, features=features, features_kwargs=features_kwargs, **kwargs) + )( + adata=adata, + img=img, + layer=layer, + library_id=library_id, + features=features, + features_kwargs=features_kwargs, + **kwargs, + ) if copy: logg.info("Finish", time=start) @@ -105,6 +127,7 @@ def calculate_image_features( def _calculate_image_features_helper( obs_ids: Sequence[str], + *, adata: AnnData, img: ImageContainer, layer: str, diff --git a/src/squidpy/im/_process.py b/src/squidpy/im/_process.py index ac78331e7..52c9b73d1 100644 --- a/src/squidpy/im/_process.py +++ b/src/squidpy/im/_process.py @@ -9,6 +9,7 @@ from scanpy import logging as logg from scipy.ndimage import gaussian_filter as scipy_gf +from squidpy._compat import old_positionals from squidpy._constants._constants import Processing from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -20,8 +21,12 @@ @d.dedent @inject_docs(p=Processing) +@old_positionals( + "layer", "library_id", "method", "chunks", "lazy", "layer_added", "channel_dim", "copy", "apply_kwargs" +) def process( img: ImageContainer, + *, layer: str | None = None, library_id: str | Sequence[str] | None = None, method: str | Callable[..., NDArrayA] = "smooth", diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 18d8cb127..2c18fec78 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -16,6 +16,7 @@ from anndata import AnnData from matplotlib.axes import Axes +from squidpy._compat import old_positionals from squidpy._constants._constants import RipleyStat from squidpy._constants._pkg_constants import Key from squidpy._docs import d @@ -52,8 +53,10 @@ def _get_data(adata: AnnData, cluster_key: str, func_name: str, attr: str = "uns @d.dedent +@old_positionals("cluster_key", "score", "legend_kwargs", "palette", "figsize", "dpi", "save") def centrality_scores( adata: AnnData, + *, cluster_key: str, score: str | Sequence[str] | None = None, legend_kwargs: Mapping[str, Any] = MappingProxyType({}), @@ -124,8 +127,12 @@ def centrality_scores( @d.dedent +@old_positionals( + "cluster_key", "annotate", "method", "title", "cmap", "palette", "cbar_kwargs", "figsize", "dpi", "save", "ax" +) def interaction_matrix( adata: AnnData, + *, cluster_key: str, annotate: bool = False, method: str | None = None, @@ -182,8 +189,23 @@ def interaction_matrix( @d.dedent +@old_positionals( + "cluster_key", + "mode", + "annotate", + "method", + "title", + "cmap", + "palette", + "cbar_kwargs", + "figsize", + "dpi", + "save", + "ax", +) def nhood_enrichment( adata: AnnData, + *, cluster_key: str, mode: Literal["zscore", "count"] = "zscore", annotate: bool = False, @@ -250,6 +272,7 @@ def nhood_enrichment( def nhood_enrichment_dotplot( adata: AnnData, cluster_key: str, + *, annotate: bool = False, title: str | None = None, cmap: str = "RdBu_r", @@ -357,8 +380,10 @@ def nhood_enrichment_dotplot( @d.dedent +@old_positionals("cluster_key", "mode", "plot_sims", "palette", "figsize", "dpi", "save", "ax", "legend_kwargs") def ripley( adata: AnnData, + *, cluster_key: str, mode: Literal["F", "G", "L"] = "F", plot_sims: bool = True, @@ -435,8 +460,10 @@ def ripley( @d.dedent +@old_positionals("cluster_key", "palette", "clusters", "figsize", "dpi", "save", "legend_kwargs") def co_occurrence( adata: AnnData, + *, cluster_key: str, palette: Palette_t = None, clusters: str | Sequence[str] | None = None, diff --git a/src/squidpy/pl/_ligrec.py b/src/squidpy/pl/_ligrec.py index 238a08451..ff5ed1744 100644 --- a/src/squidpy/pl/_ligrec.py +++ b/src/squidpy/pl/_ligrec.py @@ -14,6 +14,7 @@ from scanpy import logging as logg from scipy.cluster import hierarchy as sch +from squidpy._compat import old_positionals from squidpy._constants._constants import DendrogramAxis from squidpy._constants._pkg_constants import Key from squidpy._docs import d @@ -129,8 +130,25 @@ def _plot_colorbar(self, color_legend_ax: Axes, normalize: bool) -> None: @d.dedent +@old_positionals( + "cluster_key", + "source_groups", + "target_groups", + "means_range", + "pvalue_threshold", + "remove_empty_interactions", + "remove_nonsig_interactions", + "dendrogram", + "alpha", + "swap_axes", + "title", + "figsize", + "dpi", + "save", +) def ligrec( adata: AnnData | Mapping[str, pd.DataFrame], + *, cluster_key: str | None = None, source_groups: str | Sequence[str] | None = None, target_groups: str | Sequence[str] | None = None, diff --git a/src/squidpy/pl/_spatial.py b/src/squidpy/pl/_spatial.py index 1c2042f0d..4cbf6a2d9 100644 --- a/src/squidpy/pl/_spatial.py +++ b/src/squidpy/pl/_spatial.py @@ -46,6 +46,7 @@ @d.dedent def _spatial_plot( adata: AnnData, + *, shape: _AvailShapes | None = None, color: str | Sequence[str | None] | None = None, groups: _SeqStr | None = None, @@ -242,7 +243,7 @@ def _spatial_plot( adata_sub, coords_sub, image_sub = _subs( adata, _coords, - _img, + img=_img, library_key=library_key, library_id=_lib, crop_coords=_crops, @@ -441,6 +442,7 @@ def spatial_scatter( def spatial_segment( adata: AnnData, seg_cell_id: str, + *, seg: bool | _SeqArray | None = True, seg_key: str = Key.uns.image_seg_key, seg_contourpx: int | None = None, diff --git a/src/squidpy/pl/_spatial_utils.py b/src/squidpy/pl/_spatial_utils.py index f070a0939..53cd1338b 100644 --- a/src/squidpy/pl/_spatial_utils.py +++ b/src/squidpy/pl/_spatial_utils.py @@ -152,6 +152,7 @@ def _get_library_id( def _get_image( adata: AnnData, library_id: Sequence[str], + *, spatial_key: str = Key.obsm.spatial, img: bool | _SeqArray | None = None, img_res_key: str | None = None, @@ -189,6 +190,7 @@ def _get_image( def _get_segment( adata: AnnData, library_id: Sequence[str], + *, seg_cell_id: str | None = None, library_key: str | None = None, seg: _SeqArray | bool | None = None, @@ -214,6 +216,7 @@ def _get_segment( def _get_scalefactor_size( adata: AnnData, library_id: Sequence[str], + *, spatial_key: str = Key.obsm.spatial, img_res_key: str | None = None, scale_factor: _SeqFloat | None = None, @@ -265,6 +268,7 @@ def _get_scalefactor_size( def _image_spatial_attrs( adata: AnnData, + *, shape: _AvailShapes | None = None, spatial_key: str = Key.obsm.spatial, library_id: Sequence[str] | None = None, @@ -360,6 +364,7 @@ def _set_coords_crops( def _subs( adata: AnnData, coords: NDArrayA, + *, img: NDArrayA | None = None, library_key: str | None = None, library_id: str | None = None, @@ -447,6 +452,7 @@ def _get_list( def _set_color_source_vec( adata: AnnData, value_to_plot: str | None, + *, use_raw: bool | None = None, alt_var: str | None = None, layer: str | None = None, @@ -494,6 +500,7 @@ def _shaped_scatter( y: NDArrayA, s: float, c: NDArrayA | str, + *, shape: _AvailShapes | ScatterShape | None = ScatterShape.CIRCLE, norm: _Normalize | None = None, **kwargs: Any, @@ -544,6 +551,7 @@ def _plot_edges( coords: NDArrayA, connectivity_key: str, ax: Axes, + *, edges_width: float = 0.1, edges_color: str | Sequence[float] | Sequence[str] = "grey", **kwargs: Any, @@ -610,6 +618,7 @@ def _get_scalebar( def _decorate_axs( ax: Axes, + *, cax: PatchCollection, lib_count: int, fig_params: FigParams, @@ -694,6 +703,7 @@ def _map_color_seg( color_vector: NDArrayA | pd.Categorical, color_source_vector: pd.Categorical, cmap_params: CmapParams, + *, seg_erosionpx: int | None = None, seg_boundaries: bool = False, na_color: str | tuple[float, ...] = (0, 0, 0, 0), @@ -733,6 +743,7 @@ def _map_color_seg( def _prepare_args_plot( adata: AnnData, + *, shape: _AvailShapes | None = None, color: Sequence[str | None] | str | None = None, groups: _SeqStr | None = None, @@ -776,6 +787,7 @@ def _prepare_args_plot( def _prepare_params_plot( color_params: ColorParams, spatial_params: SpatialParams, + *, spatial_key: str = Key.obsm.spatial, wspace: float | None = None, hspace: float = 0.25, @@ -874,6 +886,7 @@ def _panel_grid( wspace: float, ncols: int, figsize: tuple[float, float], + *, dpi: int | None = None, ) -> tuple[Figure, GridSpec]: n_panels_x = min(ncols, num_panels) @@ -933,6 +946,7 @@ def _set_outline( def _plot_scatter( coords: NDArrayA, + *, ax: Axes, outline_params: OutlineParams, cmap_params: CmapParams, @@ -989,6 +1003,7 @@ def _plot_scatter( def _plot_segment( seg: NDArrayA, + *, cell_id: NDArrayA, color_vector: NDArrayA | pd.Series[CategoricalDtype], color_source_vector: pd.Series[CategoricalDtype], diff --git a/src/squidpy/pl/_utils.py b/src/squidpy/pl/_utils.py index 38ba2fbeb..5cc4048e4 100644 --- a/src/squidpy/pl/_utils.py +++ b/src/squidpy/pl/_utils.py @@ -537,6 +537,7 @@ def _get_cmap_norm( def _heatmap( adata: AnnData, key: str, + *, title: str = "", method: str | None = None, cont_cmap: str | mcolors.Colormap = "viridis", diff --git a/src/squidpy/pl/_var_by_distance.py b/src/squidpy/pl/_var_by_distance.py index 259b530e5..11538bdd4 100644 --- a/src/squidpy/pl/_var_by_distance.py +++ b/src/squidpy/pl/_var_by_distance.py @@ -28,6 +28,7 @@ def var_by_distance( adata: AnnData, var: str | list[str], anchor_key: str | list[str], + *, design_matrix_key: str = "design_matrix", stack_vars: bool = False, covariate: str | None = None, diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 5709d8591..4c687274e 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -17,6 +17,7 @@ @d.dedent def sliding_window( adata: AnnData | SpatialData, + *, library_key: str | None = None, window_size: int | None = None, overlap: int = 0, @@ -25,7 +26,6 @@ def sliding_window( spatial_key: str = "spatial", drop_partial_windows: bool = False, copy: bool = False, - *, table_key: str | None = None, ) -> pd.DataFrame | None: """ @@ -183,6 +183,7 @@ def _calculate_window_corners( min_y: int, max_y: int, window_size: int, + *, overlap: int = 0, drop_partial_windows: bool = False, ) -> pd.DataFrame: diff --git a/src/squidpy/tl/_var_by_distance.py b/src/squidpy/tl/_var_by_distance.py index 2a77115c1..baa1c0361 100644 --- a/src/squidpy/tl/_var_by_distance.py +++ b/src/squidpy/tl/_var_by_distance.py @@ -23,6 +23,7 @@ def var_by_distance( adata: AnnData, groups: str | list[str] | NDArrayA, + *, cluster_key: str | None = None, library_key: str | None = None, library_id: str | list[str] | None = None, diff --git a/tests/conftest.py b/tests/conftest.py index 6f386dfd0..6eb9c457e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -333,7 +333,7 @@ def ligrec_result() -> Mapping[str, pd.DataFrame]: interactions = tuple(product(adata.raw.var_names[:5], adata.raw.var_names[:5])) return sq.gr.ligrec( adata, - "leiden", + cluster_key="leiden", interactions=interactions, n_perms=25, n_jobs=1, diff --git a/tests/experimental/test_stain_decompose_public.py b/tests/experimental/test_stain_decompose_public.py index abe43199a..5eba71bc1 100644 --- a/tests/experimental/test_stain_decompose_public.py +++ b/tests/experimental/test_stain_decompose_public.py @@ -52,19 +52,19 @@ def _make_sdata(values: np.ndarray, *, with_tissue: bool = True) -> sd.SpatialDa class TestDecompositionThroughDispatchers: def test_fit_and_apply_end_to_end(self, method: str) -> None: sdata = _make_sdata(_synthetic_rgb(seed=1)) - ref = fit_stain_reference(sdata, "img", method=method, white_point=_WHITE) + ref = fit_stain_reference(sdata, image_key="img", method=method, white_point=_WHITE) assert ref.method == method assert ref.stain_matrix.shape == (3, 3) assert ref.max_concentrations.shape == (2,) - out = normalize_stains(sdata, "img", ref, inplace=False) + out = normalize_stains(sdata, image_key="img", reference=ref, inplace=False) assert isinstance(out, xr.DataArray) assert out.sizes["c"] == 3 def test_apply_writes_back(self, method: str) -> None: sdata = _make_sdata(_synthetic_rgb(seed=2)) - ref = fit_stain_reference(sdata, "img", method=method, white_point=_WHITE) - result = normalize_stains(sdata, "img", ref, image_key_added="norm") + ref = fit_stain_reference(sdata, image_key="img", method=method, white_point=_WHITE) + result = normalize_stains(sdata, image_key="img", reference=ref, image_key_added="norm") assert result is None assert get_transformation(sdata.images["norm"], get_all=True).keys() == ( get_transformation(sdata.images["img"], get_all=True).keys() @@ -74,33 +74,51 @@ def test_apply_writes_back(self, method: str) -> None: class TestDecomposeStains: def test_returns_named_concentration_maps(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - conc = decompose_stains(sdata, "img", "macenko", white_point=_WHITE, inplace=False) + conc = decompose_stains( + sdata, image_key="img", reference_or_method="macenko", white_point=_WHITE, inplace=False + ) assert set(conc) == {"hematoxylin", "eosin", "residual"} assert all(set(c.dims) == {"y", "x"} for c in conc.values()) # one (y, x) map per stain assert all(c.dtype == np.float16 for c in conc.values()) # default output_dtype def test_drop_residual(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - conc = decompose_stains(sdata, "img", "macenko", white_point=_WHITE, include_residual=False, inplace=False) + conc = decompose_stains( + sdata, + image_key="img", + reference_or_method="macenko", + white_point=_WHITE, + include_residual=False, + inplace=False, + ) assert set(conc) == {"hematoxylin", "eosin"} def test_output_dtype_override(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - conc = decompose_stains(sdata, "img", "macenko", white_point=_WHITE, output_dtype=np.float32, inplace=False) + conc = decompose_stains( + sdata, + image_key="img", + reference_or_method="macenko", + white_point=_WHITE, + output_dtype=np.float32, + inplace=False, + ) assert all(c.dtype == np.float32 for c in conc.values()) def test_inplace_default_writes_derived_keys(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - ref = fit_stain_reference(sdata, "img", method="macenko", white_point=_WHITE) - out = decompose_stains(sdata, "img", ref) # inplace=True, prefix defaults to image_key + ref = fit_stain_reference(sdata, image_key="img", method="macenko", white_point=_WHITE) + out = decompose_stains( + sdata, image_key="img", reference_or_method=ref + ) # inplace=True, prefix defaults to image_key assert out is None for stain in ("hematoxylin", "eosin", "residual"): assert f"img_{stain}" in sdata.images def test_with_reference_writes_separate_images(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - ref = fit_stain_reference(sdata, "img", method="macenko", white_point=_WHITE) - out = decompose_stains(sdata, "img", ref, image_key_added="conc") + ref = fit_stain_reference(sdata, image_key="img", method="macenko", white_point=_WHITE) + out = decompose_stains(sdata, image_key="img", reference_or_method=ref, image_key_added="conc") assert out is None for stain in ("hematoxylin", "eosin", "residual"): assert f"conc_{stain}" in sdata.images @@ -108,31 +126,31 @@ def test_with_reference_writes_separate_images(self) -> None: def test_atomic_write_aborts_on_any_existing_key(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - ref = fit_stain_reference(sdata, "img", method="macenko", white_point=_WHITE) + ref = fit_stain_reference(sdata, image_key="img", method="macenko", white_point=_WHITE) # pre-occupy only the *eosin* target; the whole write must abort, leaving # no half-written hematoxylin/residual behind. sdata.images["conc_eosin"] = sdata.images["img"] with pytest.raises(ValueError, match="would overwrite"): - decompose_stains(sdata, "img", ref, image_key_added="conc") + decompose_stains(sdata, image_key="img", reference_or_method=ref, image_key_added="conc") assert "conc_hematoxylin" not in sdata.images assert "conc_residual" not in sdata.images def test_reinhard_reference_rejected(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - reinhard_ref = fit_stain_reference(sdata, "img", method="reinhard") + reinhard_ref = fit_stain_reference(sdata, image_key="img", method="reinhard") with pytest.raises(ValueError, match="macenko/vahadane reference"): - decompose_stains(sdata, "img", reinhard_ref) + decompose_stains(sdata, image_key="img", reference_or_method=reinhard_ref) def test_bad_method_rejected(self) -> None: sdata = _make_sdata(_synthetic_rgb()) with pytest.raises(ValueError, match="method must be"): - decompose_stains(sdata, "img", "reinhard") + decompose_stains(sdata, image_key="img", reference_or_method="reinhard") class TestBackgroundDefault: def test_fit_defaults_to_white_when_absent(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - ref = fit_stain_reference(sdata, "img", method="macenko") + ref = fit_stain_reference(sdata, image_key="img", method="macenko") # default I_0 is a fixed full-white point, not an image-derived estimate np.testing.assert_array_equal(ref.white_point, [255.0, 255.0, 255.0]) @@ -140,7 +158,7 @@ def test_explicit_background_is_used(self) -> None: I0 = np.array([240.0, 245.0, 250.0]) # build the synthetic image against this white point so the fit is consistent sdata = _make_sdata(_synthetic_rgb(white=I0)) - ref = fit_stain_reference(sdata, "img", method="vahadane", white_point=I0) + ref = fit_stain_reference(sdata, image_key="img", method="vahadane", white_point=I0) np.testing.assert_array_equal(ref.white_point, I0) @@ -148,26 +166,28 @@ class TestUnknownMethod: def test_fit_unknown_method_raises(self) -> None: sdata = _make_sdata(_synthetic_rgb()) with pytest.raises(ValueError, match="Unknown method"): - fit_stain_reference(sdata, "img", method="bogus") + fit_stain_reference(sdata, image_key="img", method="bogus") class TestDefaultMethodAndGate: def test_default_method_is_macenko(self) -> None: sdata = _make_sdata(_synthetic_rgb()) - ref = fit_stain_reference(sdata, "img") # no method -> default + ref = fit_stain_reference(sdata, image_key="img") # no method -> default assert ref.method == "macenko" def test_max_angle_deg_gate_too_strict_raises(self) -> None: # an impossibly tight tolerance trips the H/E sanity gate sdata = _make_sdata(_synthetic_rgb()) with pytest.raises(StainFittingError, match="deviates"): - fit_stain_reference(sdata, "img", method="macenko", white_point=_WHITE, max_angle_deg=0.01) + fit_stain_reference(sdata, image_key="img", method="macenko", white_point=_WHITE, max_angle_deg=0.01) def test_canonical_reference_passthrough(self) -> None: # passing the Ruifrok canonical explicitly reproduces the default fit sdata = _make_sdata(_synthetic_rgb()) - default = fit_stain_reference(sdata, "img", method="macenko", white_point=_WHITE) - custom = fit_stain_reference(sdata, "img", method="macenko", white_point=_WHITE, canonical_reference=RUIFROK_HE) + default = fit_stain_reference(sdata, image_key="img", method="macenko", white_point=_WHITE) + custom = fit_stain_reference( + sdata, image_key="img", method="macenko", white_point=_WHITE, canonical_reference=RUIFROK_HE + ) np.testing.assert_allclose(default.stain_matrix, custom.stain_matrix) @@ -182,11 +202,13 @@ class TestDecompositionOnHnE: @pytest.mark.parametrize("method", ["macenko", "vahadane"]) def test_fit_apply_decompose_smoke(self, sdata_hne, method: str) -> None: image_key = next(iter(sdata_hne.images)) - sq.experimental.im.detect_tissue(sdata_hne, image_key) - ref = sq.experimental.im.fit_stain_reference(sdata_hne, image_key, method=method) + sq.experimental.im.detect_tissue(sdata_hne, image_key=image_key) + ref = sq.experimental.im.fit_stain_reference(sdata_hne, image_key=image_key, method=method) assert isinstance(ref, StainReference) assert ref.stain_matrix.shape == (3, 3) - normalized = sq.experimental.im.normalize_stains(sdata_hne, image_key, ref, inplace=False) + normalized = sq.experimental.im.normalize_stains(sdata_hne, image_key=image_key, reference=ref, inplace=False) assert normalized.sizes["c"] == 3 - conc = sq.experimental.im.decompose_stains(sdata_hne, image_key, ref, inplace=False) + conc = sq.experimental.im.decompose_stains( + sdata_hne, image_key=image_key, reference_or_method=ref, inplace=False + ) assert set(conc) == {"hematoxylin", "eosin", "residual"} diff --git a/tests/experimental/test_stain_normalize.py b/tests/experimental/test_stain_normalize.py index e09c910ba..cf16262dc 100644 --- a/tests/experimental/test_stain_normalize.py +++ b/tests/experimental/test_stain_normalize.py @@ -43,41 +43,43 @@ def rgb_values() -> np.ndarray: class TestFitStainReference: def test_end_to_end(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") assert isinstance(ref, StainReference) assert ref.method == "reinhard" def test_missing_image_key_raises(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) with pytest.raises(ValueError, match="not found, valid keys"): - fit_stain_reference(sdata, "nope") + fit_stain_reference(sdata, image_key="nope") def test_unknown_method_raises(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) with pytest.raises(ValueError, match="Unknown method"): - fit_stain_reference(sdata, "img", method="bogus") + fit_stain_reference(sdata, image_key="img", method="bogus") def test_rgba_image_rejected(self) -> None: rng = np.random.default_rng(0) rgba = rng.integers(0, 256, size=(4, 16, 16), dtype=np.uint8) # 4-channel, not RGB sdata = sd.SpatialData(images={"img": Image2DModel.parse(rgba, dims=("c", "y", "x"))}) with pytest.raises(ValueError, match="3-channel RGB"): - fit_stain_reference(sdata, "img", method="reinhard") + fit_stain_reference(sdata, image_key="img", method="reinhard") class TestApplyStainNormalization: def test_returns_lazy_and_leaves_sdata_untouched(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard") - out = normalize_stains(sdata, "img", ref, inplace=False) + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + out = normalize_stains(sdata, image_key="img", reference=ref, inplace=False) assert isinstance(out, xr.DataArray) assert isinstance(out.data, da.Array) assert list(sdata.images.keys()) == ["img"] def test_inplace_default_writes_derived_key(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard") - result = normalize_stains(sdata, "img", ref) # inplace=True, image_key_added defaults to f"{key}_normalized" + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + result = normalize_stains( + sdata, image_key="img", reference=ref + ) # inplace=True, image_key_added defaults to f"{key}_normalized" assert result is None assert "img_normalized" in sdata.images out = sdata.images["img_normalized"] @@ -86,14 +88,14 @@ def test_inplace_default_writes_derived_key(self, rgb_values: np.ndarray) -> Non def test_output_dtype_override(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard") - out = normalize_stains(sdata, "img", ref, inplace=False, output_dtype=np.uint16) + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + out = normalize_stains(sdata, image_key="img", reference=ref, inplace=False, output_dtype=np.uint16) assert out.dtype == np.uint16 def test_writes_and_preserves_transform_and_dims(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard") - result = normalize_stains(sdata, "img", ref, image_key_added="norm") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + result = normalize_stains(sdata, image_key="img", reference=ref, image_key_added="norm") assert result is None assert "norm" in sdata.images out = sdata.images["norm"] @@ -106,8 +108,8 @@ def test_writes_and_preserves_transform_and_dims(self, rgb_values: np.ndarray) - def test_multiscale_rebuilds_pyramid(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values, scale_factors=[2]) - ref = fit_stain_reference(sdata, "img", method="reinhard") - normalize_stains(sdata, "img", ref, image_key_added="norm") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + normalize_stains(sdata, image_key="img", reference=ref, image_key_added="norm") src, out = sdata.images["img"], sdata.images["norm"] assert hasattr(out, "keys") src_shapes = [src[k].image.shape for k in src] @@ -120,8 +122,8 @@ def test_preserves_channel_coords_and_nonidentity_transform(self, rgb_values: np sdata = sd.SpatialData(images={"img": img}) h, w = rgb_values.shape[-2], rgb_values.shape[-1] sdata.labels["img_tissue"] = Labels2DModel.parse(np.ones((h, w), dtype=np.uint32), dims=("y", "x")) - ref = fit_stain_reference(sdata, "img", method="reinhard") - normalize_stains(sdata, "img", ref, image_key_added="norm") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + normalize_stains(sdata, image_key="img", reference=ref, image_key_added="norm") out = sdata.images["norm"] assert list(out.coords["c"].values) == ["r", "g", "b"] assert get_transformation(out, get_all=True) == get_transformation(img, get_all=True) @@ -131,20 +133,22 @@ def test_output_is_tagged_rgb(self, rgb_values: np.ndarray) -> None: # the source's channel names) so RGB-aware viewers render it faithfully. sdata = _make_sdata(rgb_values) # parsed without explicit c_coords assert list(sdata.images["img"].coords["c"].values) != ["r", "g", "b"] - ref = fit_stain_reference(sdata, "img", method="reinhard") - out = normalize_stains(sdata, "img", ref, inplace=False) + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") + out = normalize_stains(sdata, image_key="img", reference=ref, inplace=False) assert list(out.coords["c"].values) == ["r", "g", "b"] def test_existing_key_raises(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") with pytest.raises(ValueError, match="already exists"): - normalize_stains(sdata, "img", ref, image_key_added="img") + normalize_stains(sdata, image_key="img", reference=ref, image_key_added="img") def test_method_params_mapping(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) - ref = fit_stain_reference(sdata, "img", method="reinhard", method_params={"mask_background": False}) - out = normalize_stains(sdata, "img", ref, method_params=ReinhardParams(mask_background=False), inplace=False) + ref = fit_stain_reference(sdata, image_key="img", method="reinhard", method_params={"mask_background": False}) + out = normalize_stains( + sdata, image_key="img", reference=ref, method_params=ReinhardParams(mask_background=False), inplace=False + ) assert isinstance(out, xr.DataArray) @@ -152,44 +156,44 @@ class TestTissueMaskMandate: def test_fit_requires_tissue_mask(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values, with_tissue=False) with pytest.raises(KeyError, match="detect_tissue"): - fit_stain_reference(sdata, "img", method="reinhard") + fit_stain_reference(sdata, image_key="img", method="reinhard") def test_apply_requires_tissue_mask(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) # has a mask -> fit works - ref = fit_stain_reference(sdata, "img", method="reinhard") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") del sdata.labels["img_tissue"] # ... but now the source has none with pytest.raises(KeyError, match="detect_tissue"): - normalize_stains(sdata, "img", ref) + normalize_stains(sdata, image_key="img", reference=ref) def test_explicit_missing_key_raises(self, rgb_values: np.ndarray) -> None: sdata = _make_sdata(rgb_values) with pytest.raises(KeyError, match="not found in sdata.labels"): - fit_stain_reference(sdata, "img", tissue_mask_key="nope") + fit_stain_reference(sdata, image_key="img", tissue_mask_key="nope") def test_float_0_255_source_rejected_on_apply(self, rgb_values: np.ndarray) -> None: # A float image holding 0-255 values would otherwise clip to [0, 1] in the # reconstruction (dtype_max(float)=1.0); apply must reject it, not silently destroy it. sdata = _make_sdata(rgb_values) # uint8 - ref = fit_stain_reference(sdata, "img", method="reinhard") + ref = fit_stain_reference(sdata, image_key="img", method="reinhard") floaty = rgb_values.astype(np.float32) sdata.images["floaty"] = Image2DModel.parse(floaty, dims=("c", "y", "x")) sdata.labels["floaty_tissue"] = Labels2DModel.parse( np.ones(floaty.shape[-2:], dtype=np.uint32), dims=("y", "x") ) with pytest.raises(ValueError, match="stored as float"): - normalize_stains(sdata, "floaty", ref) + normalize_stains(sdata, image_key="floaty", reference=ref) def test_mask_is_used_in_the_fit(self, rgb_values: np.ndarray) -> None: # A different tissue region yields different channel statistics, proving # the mask actually drives the fit (not silently ignored). - ref_full = fit_stain_reference(_make_sdata(rgb_values), "img", method="reinhard") + ref_full = fit_stain_reference(_make_sdata(rgb_values), image_key="img", method="reinhard") sdata_part = _make_sdata(rgb_values, with_tissue=False) h, w = rgb_values.shape[-2], rgb_values.shape[-1] partial = np.zeros((h, w), dtype=np.uint32) partial[: h // 2] = 1 # only the top half is tissue sdata_part.labels["img_tissue"] = Labels2DModel.parse(partial, dims=("y", "x")) - ref_part = fit_stain_reference(sdata_part, "img", method="reinhard") + ref_part = fit_stain_reference(sdata_part, image_key="img", method="reinhard") assert not np.allclose(ref_full.mu, ref_part.mu) @@ -207,11 +211,13 @@ def test_background_passthrough_vs_full_frame(self, rgb_values: np.ndarray) -> N shifted = np.clip(rgb_values * np.array([1.3, 0.8, 1.1])[:, None, None], 0, 255).astype(np.uint8) sdata.images["ref_img"] = Image2DModel.parse(shifted, dims=("c", "y", "x")) sdata.labels["ref_img_tissue"] = Labels2DModel.parse(np.ones((h, w), dtype=np.uint32), dims=("y", "x")) - ref = fit_stain_reference(sdata, "ref_img", method="reinhard") + ref = fit_stain_reference(sdata, image_key="ref_img", method="reinhard") original = get_element_data(sdata.images["img"], "auto", "image", "img").values - kept = normalize_stains(sdata, "img", ref, inplace=False).values # preserve_background=True (default) - full = normalize_stains(sdata, "img", ref, preserve_background=False, inplace=False).values + kept = normalize_stains( + sdata, image_key="img", reference=ref, inplace=False + ).values # preserve_background=True (default) + full = normalize_stains(sdata, image_key="img", reference=ref, preserve_background=False, inplace=False).values bg = slice(h // 2, None) np.testing.assert_allclose(kept[:, bg], original[:, bg]) # background untouched @@ -221,10 +227,10 @@ def test_background_passthrough_vs_full_frame(self, rgb_values: np.ndarray) -> N class TestStainNormalizationOnHnE: def test_fit_apply_smoke(self, sdata_hne) -> None: image_key = next(iter(sdata_hne.images)) - sq.experimental.im.detect_tissue(sdata_hne, image_key) - ref = sq.experimental.im.fit_stain_reference(sdata_hne, image_key, method="reinhard") + sq.experimental.im.detect_tissue(sdata_hne, image_key=image_key) + ref = sq.experimental.im.fit_stain_reference(sdata_hne, image_key=image_key, method="reinhard") assert ref.method == "reinhard" - out = sq.experimental.im.normalize_stains(sdata_hne, image_key, ref, inplace=False) + out = sq.experimental.im.normalize_stains(sdata_hne, image_key=image_key, reference=ref, inplace=False) assert "c" in out.dims assert out.sizes["c"] == 3 @@ -233,8 +239,8 @@ class TestStainNormalizationVisual(PlotTester, metaclass=PlotTesterMeta): def test_plot_reinhard_before_after(self, sdata_hne) -> None: """Visual: a re-stained source (left) normalized back to the H&E reference (right).""" image_key = next(iter(sdata_hne.images)) - sq.experimental.im.detect_tissue(sdata_hne, image_key) - reference = fit_stain_reference(sdata_hne, image_key, method="reinhard") + sq.experimental.im.detect_tissue(sdata_hne, image_key=image_key) + reference = fit_stain_reference(sdata_hne, image_key=image_key, method="reinhard") # Deterministically warm/cool the channels to simulate a different # staining batch, so the before/after panels are visibly distinct. @@ -245,7 +251,11 @@ def test_plot_reinhard_before_after(self, sdata_hne) -> None: # `hne_shifted` shares geometry with `image_key`; reuse its tissue mask. normalize_stains( - sdata_hne, "hne_shifted", reference, image_key_added="hne_normalized", tissue_mask_key=f"{image_key}_tissue" + sdata_hne, + image_key="hne_shifted", + reference=reference, + image_key_added="hne_normalized", + tissue_mask_key=f"{image_key}_tissue", ) _, axes = plt.subplots(1, 2, figsize=(8, 4)) diff --git a/tests/experimental/test_stain_white_point.py b/tests/experimental/test_stain_white_point.py index da5fb3595..f0b2cd071 100644 --- a/tests/experimental/test_stain_white_point.py +++ b/tests/experimental/test_stain_white_point.py @@ -68,16 +68,16 @@ def _sdata(self, *, all_tissue: bool = False) -> sd.SpatialData: return sdata def test_recovers_background_median(self) -> None: - wp = estimate_white_point(self._sdata(), "img") + wp = estimate_white_point(self._sdata(), image_key="img") assert wp.shape == (3,) np.testing.assert_allclose(wp, [240.0, 245.0, 250.0], atol=1.0) def test_raises_when_tissue_covers_all(self) -> None: with pytest.raises(StainFittingError, match="covers the whole image"): - estimate_white_point(self._sdata(all_tissue=True), "img") + estimate_white_point(self._sdata(all_tissue=True), image_key="img") def test_requires_a_tissue_mask(self) -> None: values = np.full((3, 16, 16), 240, dtype=np.uint8) sdata = sd.SpatialData(images={"img": Image2DModel.parse(values, dims=("c", "y", "x"))}) with pytest.raises(KeyError, match="detect_tissue"): - estimate_white_point(sdata, "img") + estimate_white_point(sdata, image_key="img") diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py index cd4cd3b1a..f888980b0 100644 --- a/tests/graph/test_ligrec.py +++ b/tests/graph/test_ligrec.py @@ -26,12 +26,12 @@ class TestInvalidBehavior: def test_not_adata(self): with pytest.raises(TypeError, match=r"Expected `adata` to be of type `anndata.AnnData`"): - ligrec(None, _CK) + ligrec(None, cluster_key=_CK) def test_adata_no_raw(self, adata: AnnData): del adata.raw with pytest.raises(AttributeError, match=r"No `.raw` attribute"): - ligrec(adata, _CK, use_raw=True) + ligrec(adata, cluster_key=_CK, use_raw=True) def test_invalid_cluster_key(self, adata: AnnData, interactions: Interactions_t): with pytest.raises(KeyError, match=r"Cluster key `foobar` not found"): @@ -40,61 +40,61 @@ def test_invalid_cluster_key(self, adata: AnnData, interactions: Interactions_t) def test_cluster_key_is_not_categorical(self, adata: AnnData, interactions: Interactions_t): adata.obs[_CK] = adata.obs[_CK].astype("string") with pytest.raises(TypeError, match=rf"Expected `adata.obs\[{_CK!r}\]` to be `categorical`"): - ligrec(adata, _CK, interactions=interactions) + ligrec(adata, cluster_key=_CK, interactions=interactions) def test_only_1_cluster(self, adata: AnnData, interactions: Interactions_t): adata.obs["foo"] = 1 adata.obs["foo"] = adata.obs["foo"].astype("category") with pytest.raises(ValueError, match=r"Expected at least `2` clusters, found `1`."): - ligrec(adata, "foo", interactions=interactions) + ligrec(adata, cluster_key="foo", interactions=interactions) def test_invalid_complex_policy(self, adata: AnnData, interactions: Interactions_t): with pytest.raises(ValueError, match=r"Invalid option `foobar` for `ComplexPolicy`."): - ligrec(adata, _CK, interactions=interactions, complex_policy="foobar") + ligrec(adata, cluster_key=_CK, interactions=interactions, complex_policy="foobar") def test_invalid_fdr_axis(self, adata: AnnData, interactions: Interactions_t): with pytest.raises(ValueError, match=r"Invalid option `foobar` for `CorrAxis`."): - ligrec(adata, _CK, interactions=interactions, corr_axis="foobar", corr_method="fdr_bh") + ligrec(adata, cluster_key=_CK, interactions=interactions, corr_axis="foobar", corr_method="fdr_bh") def test_too_few_permutations(self, adata: AnnData, interactions: Interactions_t): with pytest.raises(ValueError, match=r"Expected `n_perms` to be positive"): - ligrec(adata, _CK, interactions=interactions, n_perms=0) + ligrec(adata, cluster_key=_CK, interactions=interactions, n_perms=0) def test_invalid_interactions_type(self, adata: AnnData): with pytest.raises(TypeError, match=r"Expected either a `pandas.DataFrame`"): - ligrec(adata, _CK, interactions=42) + ligrec(adata, cluster_key=_CK, interactions=42) def test_invalid_interactions_dict(self, adata: AnnData): with pytest.raises(KeyError, match=r"Column .* is not in `interactions`."): - ligrec(adata, _CK, interactions={"foo": ["foo"], "target": ["bar"]}) + ligrec(adata, cluster_key=_CK, interactions={"foo": ["foo"], "target": ["bar"]}) with pytest.raises(KeyError, match=r"Column .* is not in `interactions`."): - ligrec(adata, _CK, interactions={"source": ["foo"], "bar": ["bar"]}) + ligrec(adata, cluster_key=_CK, interactions={"source": ["foo"], "bar": ["bar"]}) def test_invalid_interactions_dataframe(self, adata: AnnData, interactions: Interactions_t): df = pd.DataFrame(interactions, columns=["foo", "target"]) with pytest.raises(KeyError, match=r"Column .* is not in `interactions`."): - ligrec(adata, _CK, interactions=df) + ligrec(adata, cluster_key=_CK, interactions=df) df = pd.DataFrame(interactions, columns=["source", "bar"]) with pytest.raises(KeyError, match=r"Column .* is not in `interactions`."): - ligrec(adata, _CK, interactions=df) + ligrec(adata, cluster_key=_CK, interactions=df) def test_interactions_invalid_sequence(self, adata: AnnData, interactions: Interactions_t): interactions += ("foo", "bar", "bar") # type: ignore with pytest.raises(ValueError, match=r"Not all interactions are of length `2`."): - ligrec(adata, _CK, interactions=interactions) + ligrec(adata, cluster_key=_CK, interactions=interactions) def test_interactions_only_invalid_names(self, adata: AnnData): with pytest.raises(ValueError, match=r"After filtering by genes"): - ligrec(adata, _CK, interactions=["foo", "bar", "baz"]) + ligrec(adata, cluster_key=_CK, interactions=["foo", "bar", "baz"]) def test_invalid_clusters(self, adata: AnnData, interactions: Interactions_t): with pytest.raises(ValueError, match=r"Invalid cluster `'foo'`."): - ligrec(adata, _CK, interactions=interactions, clusters=["foo"]) + ligrec(adata, cluster_key=_CK, interactions=interactions, clusters=["foo"]) def test_invalid_clusters_mix(self, adata: AnnData, interactions: Interactions_t): with pytest.raises(ValueError, match=r"Expected a `tuple` of length `2`, found `3`."): - ligrec(adata, _CK, interactions=interactions, clusters=["foo", ("bar", "baz")]) + ligrec(adata, cluster_key=_CK, interactions=interactions, clusters=["foo", ("bar", "baz")]) class TestValidBehavior: @@ -153,7 +153,7 @@ def test_complex_policy_all(self, adata: AnnData, complexes: Complexes_t): def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): rc = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=5, corr_axis="clusters", @@ -164,7 +164,7 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): ) ri = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=5, corr_axis="interactions", @@ -182,7 +182,7 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): def test_inplace_default_key(self, adata: AnnData, interactions: Interactions_t): key = Key.uns.ligrec(_CK) assert key not in adata.uns - res = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False, show_progress_bar=False) + res = ligrec(adata, cluster_key=_CK, interactions=interactions, n_perms=5, copy=False, show_progress_bar=False) assert res is None assert isinstance(adata.uns[key], dict) @@ -195,7 +195,13 @@ def test_inplace_default_key(self, adata: AnnData, interactions: Interactions_t) def test_inplace_key_added(self, adata: AnnData, interactions: Interactions_t): assert "foobar" not in adata.uns res = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=False, key_added="foobar", show_progress_bar=False + adata, + cluster_key=_CK, + interactions=interactions, + n_perms=5, + copy=False, + key_added="foobar", + show_progress_bar=False, ) assert res is None @@ -209,7 +215,13 @@ def test_inplace_key_added(self, adata: AnnData, interactions: Interactions_t): def test_return_no_write(self, adata: AnnData, interactions: Interactions_t): assert "foobar" not in adata.uns r = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, key_added="foobar", show_progress_bar=False + adata, + cluster_key=_CK, + interactions=interactions, + n_perms=5, + copy=True, + key_added="foobar", + show_progress_bar=False, ) assert "foobar" not in adata.uns @@ -222,7 +234,7 @@ def test_return_no_write(self, adata: AnnData, interactions: Interactions_t): def test_pvals_in_correct_range(self, adata: AnnData, interactions: Interactions_t, fdr_method: str | None): r = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=5, copy=True, @@ -238,7 +250,7 @@ def test_pvals_in_correct_range(self, adata: AnnData, interactions: Interactions assert np.nanmin(r["pvalues"].values) >= 0, np.nanmin(r["pvalues"].values) def test_result_correct_index(self, adata: AnnData, interactions: Interactions_t): - r = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=True, show_progress_bar=False) + r = ligrec(adata, cluster_key=_CK, interactions=interactions, n_perms=5, copy=True, show_progress_bar=False) np.testing.assert_array_equal(r["means"].index, r["pvalues"].index) np.testing.assert_array_equal(r["pvalues"].index, r["metadata"].index) @@ -254,7 +266,7 @@ def test_result_is_sparse(self, adata: AnnData, interactions: Interactions_t): interactions["metadata"] = "foo" r = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=5, rng=np.random.default_rng(2), @@ -275,7 +287,7 @@ def test_result_is_sparse(self, adata: AnnData, interactions: Interactions_t): def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_t, n_jobs: int): r1 = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=25, copy=True, @@ -285,7 +297,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ ) r2 = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=25, copy=True, @@ -295,7 +307,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ ) r3 = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=25, copy=True, @@ -315,8 +327,8 @@ 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, "rng": 42} - res_serial = ligrec(adata, _CK, n_jobs=1, **kw) - res_parallel = ligrec(adata, _CK, n_jobs=2, **kw) + res_serial = ligrec(adata, cluster_key=_CK, n_jobs=1, **kw) + res_parallel = ligrec(adata, cluster_key=_CK, n_jobs=2, **kw) np.testing.assert_allclose(res_serial["means"], res_parallel["means"]) np.testing.assert_allclose(res_serial["pvalues"], res_parallel["pvalues"]) @@ -327,7 +339,7 @@ def test_deprecated_parallelization_params(self, adata: AnnData, interactions: I 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) + ligrec(adata, cluster_key=_CK, interactions=interactions, **{param: True}, **kw) pt = PermutationTest(adata).prepare(interactions=interactions) with pytest.warns(FutureWarning, match=rf"Parameter `{param}` of `test\(\)` is deprecated"): @@ -336,7 +348,7 @@ def test_deprecated_parallelization_params(self, adata: AnnData, interactions: I def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame): res = ligrec( paul15, - "paul15_clusters", + cluster_key="paul15_clusters", interactions=list(paul15_means.index.to_list()), corr_method=None, copy=True, @@ -356,7 +368,7 @@ def test_pvalues_reference( ): r = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=25, copy=True, @@ -381,7 +393,7 @@ def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=5, copy=False, @@ -410,7 +422,7 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): interactions += interactions[:3] # type: ignore res = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=1, copy=True, @@ -423,7 +435,15 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): @pytest.mark.xfail(reason="AnnData cannot handle writing MultiIndex") def test_writeable(self, adata: AnnData, interactions: Interactions_t, tmpdir): - ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False, show_progress_bar=False, key_added="foo") + ligrec( + adata, + cluster_key=_CK, + interactions=interactions, + n_perms=5, + copy=False, + show_progress_bar=False, + key_added="foo", + ) res = adata.uns["foo"] sc.write(tmpdir / "ligrec.h5ad", adata) @@ -438,7 +458,7 @@ def test_gene_symbols(self, adata: AnnData, use_raw: bool): interactions = tuple(product(gene_ids[:5], gene_ids[:5])) res = ligrec( adata, - _CK, + cluster_key=_CK, interactions=interactions, n_perms=5, use_raw=use_raw, diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index 5aa4f7315..d4d8eb4ca 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -179,8 +179,8 @@ def test_interaction_matrix_normalize(nhood_data: AnnData, normalized: bool): def test_interaction_matrix_values(adata_intmat: AnnData): - result_weighted = interaction_matrix(adata_intmat, "cat", weights=True, copy=True) - result_unweighted = interaction_matrix(adata_intmat, "cat", weights=False, copy=True) + result_weighted = interaction_matrix(adata_intmat, cluster_key="cat", weights=True, copy=True) + result_unweighted = interaction_matrix(adata_intmat, cluster_key="cat", weights=False, copy=True) expected_weighted = np.array([[5, 1], [2, 3]]) expected_unweighted = np.array([[4, 1], [2, 2]]) @@ -191,8 +191,8 @@ def test_interaction_matrix_values(adata_intmat: AnnData): def test_interaction_matrix_nan_values(adata_intmat: AnnData): adata_intmat.obs.loc["0", "cat"] = np.nan - result_weighted = interaction_matrix(adata_intmat, "cat", weights=True, copy=True) - result_unweighted = interaction_matrix(adata_intmat, "cat", weights=False, copy=True) + result_weighted = interaction_matrix(adata_intmat, cluster_key="cat", weights=True, copy=True) + result_unweighted = interaction_matrix(adata_intmat, cluster_key="cat", weights=False, copy=True) expected_weighted = np.array([[2, 1], [2, 3]]) expected_unweighted = np.array([[1, 1], [2, 2]]) diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index 80b9630ec..02aa7b963 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -29,6 +29,10 @@ from squidpy.gr._nhood import _aggregate_over, nhood_aggregate from squidpy.gr._niche import _fit_clusterers, compute_hop_adjacency_matrices +# Many tests here exercise the deprecated umbrella, which keeps working until v1.9.0; +# `test_calculate_niche_deprecation_is_a_future_warning` checks the deprecation itself. +pytestmark = pytest.mark.filterwarnings("ignore:Calling `calculate_niche` is deprecated:FutureWarning") + N_NEIGHBORS = 20 # Niche labels come from Leiden clustering, whose exact partition is not stable across diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index a82fca037..0f18d4b97 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -412,8 +412,8 @@ def test_mask_graph( mask_graph( sdata, - "table", - mask_polygon, + table_key="table", + polygon_mask=mask_polygon, negative_mask=False, key_added=key_added, ) @@ -422,8 +422,8 @@ def test_mask_graph( graph_positive_filter = sdata["table"].obsp[mask_conns_key].copy() mask_graph( sdata, - "table", - mask_polygon, + table_key="table", + polygon_mask=mask_polygon, negative_mask=True, key_added=key_added, ) @@ -451,8 +451,8 @@ def test_mask_graph( ): mask_graph( sdata, - "table", - Point((0, 1)), + table_key="table", + polygon_mask=Point((0, 1)), negative_mask=True, key_added=key_added, ) diff --git a/tests/image/test_features.py b/tests/image/test_features.py index e0e454aba..a09564c84 100644 --- a/tests/image/test_features.py +++ b/tests/image/test_features.py @@ -168,15 +168,15 @@ def feature(arr: np.ndarray, foo: np.ndarray): class TestHighLevel: def test_invalid_layer(self, adata: AnnData, cont: ImageContainer): with pytest.raises(KeyError, match=r"Image layer `foo` not found"): - calculate_image_features(adata, cont, layer="foo") + calculate_image_features(adata, img=cont, layer="foo") def test_invalid_feature(self, adata: AnnData, cont: ImageContainer): with pytest.raises(ValueError, match=r"Invalid option `foo` for `ImageFeature`"): - calculate_image_features(adata, cont, features="foo") + calculate_image_features(adata, img=cont, features="foo") def test_passing_spot_crops_kwargs(self, adata: AnnData, cont: ImageContainer, mocker: MockerFixture): spy = mocker.spy(cont, "generate_spot_crops") - calculate_image_features(adata, cont, mask_circle=True) + calculate_image_features(adata, img=cont, mask_circle=True) spy.assert_called_once() call = spy.call_args_list[0] @@ -189,7 +189,7 @@ def dummy(_: np.ndarray, sentinel: bool = False) -> int: res = calculate_image_features( adata, - cont, + img=cont, key_added="foo", features=ImageFeature.CUSTOM.s, features_kwargs={ImageFeature.CUSTOM.s: {"func": dummy, "sentinel": True, "channels": [0]}}, @@ -203,7 +203,7 @@ def dummy(_: np.ndarray, sentinel: bool = False) -> int: def test_key_added(self, adata: AnnData, cont: ImageContainer): assert "foo" not in adata.obsm - res = calculate_image_features(adata, cont, key_added="foo", copy=False) + res = calculate_image_features(adata, img=cont, key_added="foo", copy=False) assert res is None assert "foo" in adata.obsm @@ -211,7 +211,7 @@ def test_key_added(self, adata: AnnData, cont: ImageContainer): def test_copy(self, adata: AnnData, cont: ImageContainer): orig_keys = set(adata.obsm.keys()) - res = calculate_image_features(adata, cont, key_added="foo", copy=True) + res = calculate_image_features(adata, img=cont, key_added="foo", copy=True) assert isinstance(res, pd.DataFrame) np.testing.assert_array_equal(res.index, adata.obs_names) @@ -220,7 +220,7 @@ def test_copy(self, adata: AnnData, cont: ImageContainer): @pytest.mark.parametrize("n_jobs", [1, 2]) def test_parallelize(self, adata: AnnData, cont: ImageContainer, n_jobs: int): features = ["texture", "summary", "histogram"] - res = calculate_image_features(adata, cont, library_id=None, features=features, copy=True, n_jobs=n_jobs) + res = calculate_image_features(adata, img=cont, library_id=None, features=features, copy=True, n_jobs=n_jobs) assert isinstance(res, pd.DataFrame) np.testing.assert_array_equal(res.index, adata.obs_names) diff --git a/tests/plotting/test_image.py b/tests/plotting/test_image.py index 30df02b37..a3732cf49 100644 --- a/tests/plotting/test_image.py +++ b/tests/plotting/test_image.py @@ -71,7 +71,7 @@ def test_transpose_channelwise(self, small_cont_4d: ImageContainer, transpose: b @pytest.mark.parametrize("is_view", [False, True]) def test_extract(adata: AnnData, cont: ImageContainer, caplog, is_view: bool): - sq.im.calculate_image_features(adata, cont, features=["summary"]) + sq.im.calculate_image_features(adata, img=cont, features=["summary"]) # extract columns (default values) extr_adata = sq.pl.extract(adata[:10] if is_view else adata) diff --git a/tests/utils/test_old_positionals.py b/tests/utils/test_old_positionals.py new file mode 100644 index 000000000..ffe07ab44 --- /dev/null +++ b/tests/utils/test_old_positionals.py @@ -0,0 +1,17 @@ +"""Arguments that became keyword-only still accept an old positional call, with a warning (#1288).""" + +from __future__ import annotations + +import numpy as np +import pytest +from anndata import AnnData + +from squidpy.gr import interaction_matrix + + +def test_old_positional_call_warns_and_still_works(nhood_data: AnnData) -> None: + # `cluster_key`, `connectivity_key`, `normalized` and `copy` were positional before #1288 + with pytest.warns(FutureWarning, match="cluster_key"): + old = interaction_matrix(nhood_data, "leiden", "spatial", False, True) + new = interaction_matrix(nhood_data, cluster_key="leiden", connectivity_key="spatial", normalized=False, copy=True) + np.testing.assert_array_equal(old, new) From 82eb2102eceeb6e2644a639d8303c981da7c645e Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Tue, 29 Sep 2026 16:52:35 +0200 Subject: [PATCH 02/28] Take only the data positionally in the remaining pl/tl functions, and name v1.9.0 in the warning --- src/squidpy/_compat.py | 10 +++++++++- src/squidpy/pl/_graph.py | 2 +- src/squidpy/pl/_spatial.py | 6 +++++- src/squidpy/pl/_utils.py | 3 +++ src/squidpy/pl/_var_by_distance.py | 24 ++++++++++++++++++++++-- src/squidpy/tl/_sliding_window.py | 11 +++++++++++ src/squidpy/tl/_var_by_distance.py | 14 +++++++++++++- tests/utils/test_old_positionals.py | 2 +- 8 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/squidpy/_compat.py b/src/squidpy/_compat.py index 27b840318..28b67a78b 100644 --- a/src/squidpy/_compat.py +++ b/src/squidpy/_compat.py @@ -29,9 +29,17 @@ "get_vector", ] + +class _PositionalArgumentWarning(FutureWarning): + """An argument that became keyword-only was passed positionally; that stops working in v1.9.0.""" + + def __init__(self, message: str) -> None: + super().__init__(f"{message}. Passing them positionally stops working in squidpy v1.9.0.") + + #: For arguments that became keyword-only: an old positional call still works, with a #: ``FutureWarning`` naming them (#1288). -old_positionals = partial(legacy_api, category=FutureWarning) +old_positionals = partial(legacy_api, category=_PositionalArgumentWarning) # Scanpy 1.13 moved the pre-v2 plotting internals under ``scanpy.plotting.legacy``. # ``scanpy.plotting.__getattr__`` forwards attribute access there, but submodule diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 2c18fec78..201b831e7 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -271,8 +271,8 @@ def nhood_enrichment( @d.dedent def nhood_enrichment_dotplot( adata: AnnData, - cluster_key: str, *, + cluster_key: str, annotate: bool = False, title: str | None = None, cmap: str = "RdBu_r", diff --git a/src/squidpy/pl/_spatial.py b/src/squidpy/pl/_spatial.py index 4cbf6a2d9..d95b1a629 100644 --- a/src/squidpy/pl/_spatial.py +++ b/src/squidpy/pl/_spatial.py @@ -11,6 +11,7 @@ from matplotlib.colors import Colormap from matplotlib.figure import Figure +from squidpy._compat import old_positionals from squidpy._constants._constants import ScatterShape from squidpy._constants._pkg_constants import Key from squidpy._docs import d @@ -395,8 +396,10 @@ def _wrap_signature(wrapper: Callable[[Any], Any]) -> Callable[[Any], Any]: @d.dedent @_wrap_signature +@old_positionals("shape") def spatial_scatter( adata: AnnData, + *, shape: _AvailShapes | None = ScatterShape.CIRCLE.v, **kwargs: Any, ) -> Axes | Sequence[Axes] | None: @@ -439,10 +442,11 @@ def spatial_scatter( @d.dedent @_wrap_signature # type: ignore[arg-type] +@old_positionals("seg_cell_id", "seg", "seg_key", "seg_contourpx", "seg_outline") def spatial_segment( adata: AnnData, - seg_cell_id: str, *, + seg_cell_id: str, seg: bool | _SeqArray | None = True, seg_key: str = Key.uns.image_seg_key, seg_contourpx: int | None = None, diff --git a/src/squidpy/pl/_utils.py b/src/squidpy/pl/_utils.py index 5cc4048e4..38a63140f 100644 --- a/src/squidpy/pl/_utils.py +++ b/src/squidpy/pl/_utils.py @@ -36,6 +36,7 @@ from skimage import img_as_float32 from skimage.color import rgb2gray +from squidpy._compat import old_positionals from squidpy._constants._pkg_constants import Key from squidpy._docs import d from squidpy._utils import NDArrayA @@ -97,8 +98,10 @@ def save_fig( @d.dedent +@old_positionals("obsm_key", "prefix") def extract( adata: AnnData, + *, obsm_key: list[str] | str = "img_features", prefix: list[str] | str | None = None, ) -> AnnData: diff --git a/src/squidpy/pl/_var_by_distance.py b/src/squidpy/pl/_var_by_distance.py index 11538bdd4..a5bca0e6b 100644 --- a/src/squidpy/pl/_var_by_distance.py +++ b/src/squidpy/pl/_var_by_distance.py @@ -16,7 +16,7 @@ from matplotlib.axes import Axes from scipy.sparse import issparse -from squidpy._compat import panel_grid, set_default_colors_for_categorical_obs +from squidpy._compat import old_positionals, panel_grid, set_default_colors_for_categorical_obs from squidpy._docs import d from squidpy.pl._utils import save_fig @@ -24,11 +24,31 @@ @d.dedent +@old_positionals( + "var", + "anchor_key", + "design_matrix_key", + "stack_vars", + "covariate", + "order", + "show_scatter", + "color", + "line_palette", + "scatter_palette", + "dpi", + "figsize", + "save", + "title", + "axis_label", + "return_ax", + "regplot_kwargs", + "scatterplot_kwargs", +) def var_by_distance( adata: AnnData, + *, var: str | list[str], anchor_key: str | list[str], - *, design_matrix_key: str = "design_matrix", stack_vars: bool = False, covariate: str | None = None, diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 4c687274e..cf9b89f4f 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -8,6 +8,7 @@ from scanpy import logging as logg from spatialdata import SpatialData +from squidpy._compat import old_positionals from squidpy._docs import d from squidpy.gr._utils import _save_data, extract_adata_if_sdata @@ -15,6 +16,16 @@ @d.dedent +@old_positionals( + "library_key", + "window_size", + "overlap", + "coord_columns", + "sliding_window_key", + "spatial_key", + "drop_partial_windows", + "copy", +) def sliding_window( adata: AnnData | SpatialData, *, diff --git a/src/squidpy/tl/_var_by_distance.py b/src/squidpy/tl/_var_by_distance.py index baa1c0361..7911d1e4d 100644 --- a/src/squidpy/tl/_var_by_distance.py +++ b/src/squidpy/tl/_var_by_distance.py @@ -12,6 +12,7 @@ from sklearn.metrics import DistanceMetric from sklearn.neighbors import KDTree +from squidpy._compat import old_positionals from squidpy._docs import d from squidpy._utils import NDArrayA from squidpy.gr._utils import _save_data @@ -20,10 +21,21 @@ @d.dedent +@old_positionals( + "groups", + "cluster_key", + "library_key", + "library_id", + "design_matrix_key", + "covariates", + "metric", + "spatial_key", + "copy", +) def var_by_distance( adata: AnnData, - groups: str | list[str] | NDArrayA, *, + groups: str | list[str] | NDArrayA, cluster_key: str | None = None, library_key: str | None = None, library_id: str | list[str] | None = None, diff --git a/tests/utils/test_old_positionals.py b/tests/utils/test_old_positionals.py index ffe07ab44..e2575b441 100644 --- a/tests/utils/test_old_positionals.py +++ b/tests/utils/test_old_positionals.py @@ -11,7 +11,7 @@ def test_old_positional_call_warns_and_still_works(nhood_data: AnnData) -> None: # `cluster_key`, `connectivity_key`, `normalized` and `copy` were positional before #1288 - with pytest.warns(FutureWarning, match="cluster_key"): + with pytest.warns(FutureWarning, match=r"cluster_key.*stops working in squidpy v1\.9\.0"): old = interaction_matrix(nhood_data, "leiden", "spatial", False, True) new = interaction_matrix(nhood_data, cluster_key="leiden", connectivity_key="spatial", normalized=False, copy=True) np.testing.assert_array_equal(old, new) From ca56c1960af527be9516b11af68d46d18a0a41b4 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Tue, 29 Sep 2026 18:00:31 +0200 Subject: [PATCH 03/28] Take only the data positionally in private helpers too --- .../im/_calculate_image_features.py | 14 +++-- src/squidpy/experimental/im/_detect_tissue.py | 6 +- src/squidpy/experimental/im/_make_tiles.py | 26 ++++---- .../experimental/im/_stain/_normalize.py | 42 +++++++------ .../experimental/tl/_stitched_labels.py | 3 +- src/squidpy/experimental/tl/_tiling_qc.py | 7 ++- src/squidpy/gr/_build.py | 17 +++--- src/squidpy/gr/_nhood.py | 18 +++--- src/squidpy/gr/_niche.py | 59 ++++++++++--------- src/squidpy/gr/_ppatterns.py | 2 +- src/squidpy/gr/_sepal.py | 2 +- src/squidpy/gr/_utils.py | 8 +-- src/squidpy/im/_container.py | 4 +- src/squidpy/pl/_color_utils.py | 1 + src/squidpy/pl/_graph.py | 2 +- src/squidpy/pl/_spatial.py | 10 ++-- src/squidpy/pl/_spatial_utils.py | 13 ++-- src/squidpy/pl/_utils.py | 13 ++-- src/squidpy/tl/_var_by_distance.py | 25 +++++--- tests/graph/test_niche.py | 7 ++- 20 files changed, 158 insertions(+), 121 deletions(-) diff --git a/src/squidpy/experimental/im/_calculate_image_features.py b/src/squidpy/experimental/im/_calculate_image_features.py index fc8bcbf73..0f534e15e 100644 --- a/src/squidpy/experimental/im/_calculate_image_features.py +++ b/src/squidpy/experimental/im/_calculate_image_features.py @@ -536,7 +536,7 @@ def _histogram_features(masked_vals: np.ndarray, ch_name: str) -> dict[str, floa # --------------------------------------------------------------------------- -def _shared_coordinate_system(sdata: SpatialData, image_key: str, labels_key: str) -> str: +def _shared_coordinate_system(sdata: SpatialData, *, image_key: str, labels_key: str) -> str: img_t = get_transformation(sdata.images[image_key], get_all=True) lbl_t = get_transformation(sdata.labels[labels_key], get_all=True) shared = set(img_t) & set(lbl_t) @@ -548,7 +548,7 @@ def _shared_coordinate_system(sdata: SpatialData, image_key: str, labels_key: st return "global" if "global" in shared else sorted(shared)[0] -def _relative_affine(sdata: SpatialData, image_key: str, labels_key: str, cs: str) -> np.ndarray: +def _relative_affine(sdata: SpatialData, *, image_key: str, labels_key: str, cs: str) -> np.ndarray: """Return the 3x3 affine mapping labels-pixel-coords to image-pixel-coords. Uses ``(x, y)`` axis order to match :mod:`spatialdata` convention. @@ -647,8 +647,8 @@ def _align_to_image_grid( ``align_mode='strict'`` a non-pixel-aligned relative transform raises; under ``'rasterize'`` the labels are resampled onto the image grid. """ - cs = _shared_coordinate_system(sdata, image_key, labels_key) - affine = _relative_affine(sdata, image_key, labels_key, cs) + cs = _shared_coordinate_system(sdata, image_key=image_key, labels_key=labels_key) + affine = _relative_affine(sdata, image_key=image_key, labels_key=labels_key, cs=cs) # Integer-pixel offset of labels relative to image. (tx, ty) means labels # pixel (0, 0) lands at image pixel (tx, ty) in (x, y) order. Identity @@ -728,6 +728,7 @@ def _select_scale_array(element: xr.DataTree | xr.DataArray, scale: str | None) def _validate_inputs( sdata: SpatialData, + *, image_key: str | None, labels_key: str | None, shapes_key: str | None, @@ -772,7 +773,7 @@ def _prepare_lazy( for on-demand tile reads. For the shapes->labels path, labels are materialized but wrapped in a DataArray for a uniform interface. """ - _validate_inputs(sdata, image_key, labels_key, shapes_key, scale) + _validate_inputs(sdata, image_key=image_key, labels_key=labels_key, shapes_key=shapes_key, scale=scale) if align_mode not in ("strict", "rasterize"): raise ValueError(f"`align_mode` must be 'strict' or 'rasterize'; got {align_mode!r}.") @@ -844,6 +845,7 @@ def _prepare_lazy( def _compute_centroids( sdata: SpatialData, + *, labels_key: str | None, labels_da: xr.DataArray, scale: str | None, @@ -1089,7 +1091,7 @@ def calculate_image_features( cp_config = _build_cp_config(parsed.cp_flags, channel_names) if parsed.cp_flags is not None else None # --- Warmup: compute centroids without materializing full arrays --- - cell_info = _compute_centroids(sdata, labels_key, labels_da, scale) + cell_info = _compute_centroids(sdata, labels_key=labels_key, labels_da=labels_da, scale=scale) if not cell_info: raise ValueError("No cells found in labels (all zeros).") diff --git a/src/squidpy/experimental/im/_detect_tissue.py b/src/squidpy/experimental/im/_detect_tissue.py index 4974e4bbb..588550c0c 100644 --- a/src/squidpy/experimental/im/_detect_tissue.py +++ b/src/squidpy/experimental/im/_detect_tissue.py @@ -373,7 +373,7 @@ def detect_tissue( if method == DetectTissueMethod.WEKA and _is_zero_margin(base_margin_px): wp_local = cast(WekaParams, resolved_method_params) base_margin_px = getattr(wp_local, "border_margin_px", 0) - target_shape = _get_target_upscale_shape(sdata, image_key) + target_shape = _get_target_upscale_shape(sdata, image_key=image_key) normalized_margins_target = _normalize_margins(base_margin_px, target_shape) # Decide working resolution @@ -441,7 +441,7 @@ def detect_tissue( img_fg_labels = _apply_border_margin(img_fg_labels, normalized_margins) # Upscale to full resolution - target_shape = _get_target_upscale_shape(sdata, image_key) + target_shape = _get_target_upscale_shape(sdata, image_key=image_key) scale_matrix = _get_scaling_matrix(img_fg_labels.shape, target_shape) img_fg_labels_up = _affine_upscale_nearest(img_fg_labels, scale_matrix, target_shape) @@ -505,7 +505,7 @@ def _get_scaling_matrix(current_shape: tuple[int, int], target_shape: tuple[int, return np.array([[scale_y, 0.0], [0.0, scale_x]], dtype=float) -def _get_target_upscale_shape(sdata: sd.SpatialData, image_key: str) -> tuple[int, int]: +def _get_target_upscale_shape(sdata: sd.SpatialData, *, image_key: str) -> tuple[int, int]: """ Select the first multiscale level (assumed largest) or the single-scale shape. """ diff --git a/src/squidpy/experimental/im/_make_tiles.py b/src/squidpy/experimental/im/_make_tiles.py index a77657e3d..059c32fe2 100644 --- a/src/squidpy/experimental/im/_make_tiles.py +++ b/src/squidpy/experimental/im/_make_tiles.py @@ -79,6 +79,7 @@ def _compute_centroids_and_polygons(self) -> tuple[np.ndarray, list[Polygon]]: def _get_largest_scale_dimensions( sdata: sd.SpatialData, + *, image_key: str, ) -> tuple[int, int]: """Get the dimensions (H, W) of the largest/finest scale of an image. @@ -101,6 +102,7 @@ def _get_largest_scale_dimensions( def _save_tiles_to_shapes( sdata: sd.SpatialData, + *, tg: TileGrid, image_key: str, shapes_key: str, @@ -111,6 +113,7 @@ def _save_tiles_to_shapes( def _save_spot_tiles_to_shapes( sdata: sd.SpatialData, + *, tg: _SpotTileGrid, shapes_key: str, spot_ids: np.ndarray, @@ -142,7 +145,7 @@ def _save_spot_tiles_to_shapes( logger.info(f"Saved spot-aligned tiles as 'sdata.shapes[\"{shapes_key}\"]'") -def _propagate_spot_classification(sdata: sd.SpatialData, tiles_key: str, spots_key: str) -> None: +def _propagate_spot_classification(sdata: sd.SpatialData, *, tiles_key: str, spots_key: str) -> None: """Copy tile classifications from a tiles table back to the corresponding spots.""" if tiles_key not in sdata.shapes or spots_key not in sdata.shapes: return @@ -266,7 +269,7 @@ def make_tiles( if center_grid_on_tissue and mask_key_for_grid is not None and scale == "auto": label_node = sdata.labels.get(mask_key_for_grid) if label_node is not None: - target_hw = _get_largest_scale_dimensions(sdata, image_key) + target_hw = _get_largest_scale_dimensions(sdata, image_key=image_key) scale_for_grid = _choose_label_scale_for_image(label_node, target_hw) # Build tile grid (keep locally for filtering) @@ -280,7 +283,7 @@ def make_tiles( scale=scale_for_grid, ) - _save_tiles_to_shapes(sdata, tg, image_key, shapes_key) + _save_tiles_to_shapes(sdata, tg=tg, image_key=image_key, shapes_key=shapes_key) # Filter tiles if tg is not None: @@ -307,7 +310,7 @@ def make_tiles( if scale == "auto": label_node = sdata.labels.get(classification_mask_key) if label_node is not None: - target_hw = _get_largest_scale_dimensions(sdata, image_key) + target_hw = _get_largest_scale_dimensions(sdata, image_key=image_key) scale_used = _choose_label_scale_for_image(label_node, target_hw) else: scale_used = "scale0" @@ -420,13 +423,13 @@ def make_tiles_from_spots( image_cs, ) - coords, spot_ids = _get_spot_coordinates(sdata, spots_key) + coords, spot_ids = _get_spot_coordinates(sdata, spots_key=spots_key) derived_tile = _derive_tile_size_from_spots(coords) logger.info(f"Derived tile size {derived_tile} from {len(coords)} Visium spots (key='{spots_key}').") tg = _SpotTileGrid(centers=coords, tile_size=derived_tile, spot_ids=spot_ids) shapes_key = new_shapes_key or f"{spots_key}_tiles" - _save_spot_tiles_to_shapes(sdata, tg, shapes_key, spot_ids, source_shapes_key=spots_key) + _save_spot_tiles_to_shapes(sdata, tg=tg, shapes_key=shapes_key, spot_ids=spot_ids, source_shapes_key=spots_key) classification_mask_key: str | None = None if tissue_mask_key is not None: @@ -455,7 +458,7 @@ def make_tiles_from_spots( if image_key is not None: mask_key = classification_mask_key if mask_key in sdata.labels: - target_hw = _get_largest_scale_dimensions(sdata, image_key) + target_hw = _get_largest_scale_dimensions(sdata, image_key=image_key) scale = _choose_label_scale_for_image(sdata.labels[mask_key], target_hw) else: scale = "scale0" @@ -482,7 +485,7 @@ def make_tiles_from_spots( min_tissue_fraction=min_tissue_fraction, shapes_key=shapes_key, ) - _propagate_spot_classification(sdata, shapes_key, spots_key) + _propagate_spot_classification(sdata, tiles_key=shapes_key, spots_key=spots_key) else: logger.info("No mask provided or derived; skipping tissue classification.") @@ -510,9 +513,9 @@ def make_tiles_from_spots( def _filter_tiles( sdata: sd.SpatialData, + *, tg: TileGrid, image_key: str | None, - *, tissue_mask_key: str | None = None, scale: str = "scale0", min_tissue_fraction: float = 1.0, @@ -621,8 +624,8 @@ def _filter_tiles( def _make_tiles( sdata: sd.SpatialData, - image_key: str, *, + image_key: str, image_mask_key: str | None = None, tile_size: tuple[int, int] = (224, 224), center_grid_on_tissue: bool = False, @@ -633,7 +636,7 @@ def _make_tiles( Callers must validate *image_key* before calling this helper. """ # Get image dimensions from the largest/finest scale - H, W = _get_largest_scale_dimensions(sdata, image_key) + H, W = _get_largest_scale_dimensions(sdata, image_key=image_key) ty, tx = tile_size @@ -698,6 +701,7 @@ def _make_tiles( def _get_spot_coordinates( sdata: sd.SpatialData, + *, spots_key: str, ) -> tuple[np.ndarray, np.ndarray]: """Extract spot centers (x, y) and IDs from a shapes table. diff --git a/src/squidpy/experimental/im/_stain/_normalize.py b/src/squidpy/experimental/im/_stain/_normalize.py index 024831ecd..b42e7e408 100644 --- a/src/squidpy/experimental/im/_stain/_normalize.py +++ b/src/squidpy/experimental/im/_stain/_normalize.py @@ -63,9 +63,9 @@ def _resolve_image( sdata: sd.SpatialData, + *, image_key: str, scale: str, - *, prefer: Literal["coarsest", "finest"], ) -> xr.DataArray: if image_key not in sdata.images: @@ -77,7 +77,7 @@ def _resolve_image( def _resolve_mask_key_and_scale( - sdata: sd.SpatialData, image_key: str, target_da: xr.DataArray, tissue_mask_key: str | None + sdata: sd.SpatialData, *, image_key: str, target_da: xr.DataArray, tissue_mask_key: str | None ) -> tuple[str, str, tuple[int, int]]: """Resolve the (mandatory) tissue-mask key and the label scale closest to ``target_da``. @@ -92,7 +92,7 @@ def _resolve_mask_key_and_scale( def _resolve_tissue_bool_mask( - sdata: sd.SpatialData, image_key: str, fit_da: xr.DataArray, tissue_mask_key: str | None + sdata: sd.SpatialData, *, image_key: str, fit_da: xr.DataArray, tissue_mask_key: str | None ) -> np.ndarray: """Return a materialised ``(y, x)`` boolean tissue mask aligned to ``fit_da``. @@ -100,7 +100,9 @@ def _resolve_tissue_bool_mask( closest label scale differs. The fits run on a coarse level, so the mask stays small. """ - mask_key, label_scale, target_hw = _resolve_mask_key_and_scale(sdata, image_key, fit_da, tissue_mask_key) + mask_key, label_scale, target_hw = _resolve_mask_key_and_scale( + sdata, image_key=image_key, target_da=fit_da, tissue_mask_key=tissue_mask_key + ) mask = get_mask_materialized(sdata, mask_key, label_scale) > 0 if mask.shape != target_hw: from skimage.transform import resize @@ -110,7 +112,7 @@ def _resolve_tissue_bool_mask( def _resolve_output_tissue_mask( - sdata: sd.SpatialData, image_key: str, target_da: xr.DataArray, tissue_mask_key: str | None + sdata: sd.SpatialData, *, image_key: str, target_da: xr.DataArray, tissue_mask_key: str | None ) -> xr.DataArray: """Return a lazy ``(y, x)`` boolean tissue mask aligned to ``target_da``. @@ -120,7 +122,9 @@ def _resolve_output_tissue_mask( shares the image's scale factors, so the matching level usually lines up exactly; only a residual size mismatch forces a (small) eager resize. """ - mask_key, label_scale, target_hw = _resolve_mask_key_and_scale(sdata, image_key, target_da, tissue_mask_key) + mask_key, label_scale, target_hw = _resolve_mask_key_and_scale( + sdata, image_key=image_key, target_da=target_da, tissue_mask_key=tissue_mask_key + ) coords = {d: target_da.coords[d] for d in ("y", "x") if d in target_da.coords} mask = get_element_data(sdata.labels[mask_key], label_scale, "label", mask_key).squeeze() > 0 if (int(mask.sizes["y"]), int(mask.sizes["x"])) == target_hw: @@ -144,10 +148,10 @@ def _resolve_method_params(method: str, method_params: MethodParams) -> Any: def _write_image( sdata: sd.SpatialData, + *, source_node: Any, image_key_added: str, data_array: xr.DataArray, - *, c_coords: list[Any] | None = None, ) -> None: """Write a derived image element, preserving the source's transforms/pyramid. @@ -203,9 +207,9 @@ def estimate_white_point( Shape-``(3,)`` white point; pass it as ``white_point`` to :func:`fit_stain_reference` / :func:`decompose_stains`. """ - da = _resolve_image(sdata, image_key, scale, prefer="coarsest") + da = _resolve_image(sdata, image_key=image_key, scale=scale, prefer="coarsest") validate_rgb_range(da) - tissue_mask = _resolve_tissue_bool_mask(sdata, image_key, da, tissue_mask_key) + tissue_mask = _resolve_tissue_bool_mask(sdata, image_key=image_key, fit_da=da, tissue_mask_key=tissue_mask_key) return white_point_from_background(da, ~tissue_mask) @@ -272,10 +276,10 @@ def fit_stain_reference( """ if method not in _VALID_METHODS: raise ValueError(f"Unknown method {method!r}; expected one of {list(_VALID_METHODS)}.") - da = _resolve_image(sdata, image_key, scale, prefer="coarsest") + da = _resolve_image(sdata, image_key=image_key, scale=scale, prefer="coarsest") validate_rgb_range(da) params = _resolve_method_params(method, method_params) - tissue_mask = _resolve_tissue_bool_mask(sdata, image_key, da, tissue_mask_key) + tissue_mask = _resolve_tissue_bool_mask(sdata, image_key=image_key, fit_da=da, tissue_mask_key=tissue_mask_key) if method == "reinhard": return fit_reinhard(da, params, tissue_mask=tissue_mask) bg = default_white_point(da) if white_point is None else np.asarray(white_point, np.float64) @@ -351,7 +355,7 @@ def normalize_stains( ``None`` if ``inplace=True`` (the image is written), otherwise the lazy normalized :class:`xarray.DataArray`. """ - da = _resolve_image(sdata, image_key, scale, prefer="finest") + da = _resolve_image(sdata, image_key=image_key, scale=scale, prefer="finest") target_key = image_key_added if image_key_added is not None else f"{image_key}_normalized" if inplace and target_key in sdata.images: raise ValueError(f"image_key_added={target_key!r} already exists in sdata.images.") @@ -359,9 +363,9 @@ def normalize_stains( # Source statistics (Reinhard mu/sigma or the decomposition source matrix) # are reduced on a coarse level with a tissue mask; the lazy transform is # then applied to the full-resolution `da`. - fit_rgb = _resolve_image(sdata, image_key, scale, prefer="coarsest") + fit_rgb = _resolve_image(sdata, image_key=image_key, scale=scale, prefer="coarsest") validate_rgb_range(fit_rgb) # reject mis-typed source (e.g. 0-255 float) before the dtype-clipped reconstruction - tissue_mask = _resolve_tissue_bool_mask(sdata, image_key, fit_rgb, tissue_mask_key) + tissue_mask = _resolve_tissue_bool_mask(sdata, image_key=image_key, fit_da=fit_rgb, tissue_mask_key=tissue_mask_key) out_dtype = da.dtype if output_dtype is None else np.dtype(output_dtype) # clip range + final cast if reference.method == "reinhard": normalized = apply_reinhard( @@ -376,7 +380,7 @@ def normalize_stains( # Keep non-tissue pixels byte-identical to the source: the global colour # map would otherwise recolour background/white pixels (HistomicsTK's # `mask_out`). Stays lazy - the mask aligns to `da` without materialising. - keep = _resolve_output_tissue_mask(sdata, image_key, da, tissue_mask_key) + keep = _resolve_output_tissue_mask(sdata, image_key=image_key, target_da=da, tissue_mask_key=tissue_mask_key) normalized = normalized.where(keep, da) # Deferred cast at the write boundary: the reconstruction was kept in float @@ -390,7 +394,7 @@ def normalize_stains( if not inplace: return normalized - _write_image(sdata, sdata.images[image_key], target_key, normalized) + _write_image(sdata, source_node=sdata.images[image_key], image_key_added=target_key, data_array=normalized) return None @@ -450,7 +454,7 @@ def decompose_stains( :class:`~xarray.DataArray` (``"hematoxylin"``, ``"eosin"``, and ``"residual"`` unless dropped). """ - da = _resolve_image(sdata, image_key, scale, prefer="finest") + da = _resolve_image(sdata, image_key=image_key, scale=scale, prefer="finest") if isinstance(reference_or_method, StainReference): reference = reference_or_method if reference.method not in _DECOMPOSITION_METHODS or reference.stain_matrix is None: @@ -487,5 +491,7 @@ def decompose_stains( source = sdata.images[image_key] for name, key in zip(names, target_keys, strict=True): # keep the c dim (length 1) so Image2DModel.parse accepts it - _write_image(sdata, source, key, concentrations.sel(c=[name]), c_coords=[name]) + _write_image( + sdata, source_node=source, image_key_added=key, data_array=concentrations.sel(c=[name]), c_coords=[name] + ) return None diff --git a/src/squidpy/experimental/tl/_stitched_labels.py b/src/squidpy/experimental/tl/_stitched_labels.py index 44370ee40..94afa9007 100644 --- a/src/squidpy/experimental/tl/_stitched_labels.py +++ b/src/squidpy/experimental/tl/_stitched_labels.py @@ -275,6 +275,7 @@ def _aggregate_X( def _collapse_groups( adata: ad.AnnData, + *, new_labels_key: str, merge_strategy: str | Callable[[pd.Series], object], ) -> ad.AnnData: @@ -535,7 +536,7 @@ def make_stitched_labels( ) new_table = None if write_table: - collapsed = _collapse_groups(adata, out_key, merge_strategy) + collapsed = _collapse_groups(adata, new_labels_key=out_key, merge_strategy=merge_strategy) new_table = TableModel.parse(collapsed) if not inplace: diff --git a/src/squidpy/experimental/tl/_tiling_qc.py b/src/squidpy/experimental/tl/_tiling_qc.py index bffc03979..54807f438 100644 --- a/src/squidpy/experimental/tl/_tiling_qc.py +++ b/src/squidpy/experimental/tl/_tiling_qc.py @@ -402,6 +402,7 @@ def _score_tile( def _compute_centroids_for_labels( sdata: sd.SpatialData, + *, labels_key: str, labels_da: xr.DataArray, scale: str | None, @@ -561,7 +562,7 @@ def calculate_tiling_qc( labels_da = resolve_labels_array(sdata, labels_key, scale) - cell_info = _compute_centroids_for_labels(sdata, labels_key, labels_da, scale) + cell_info = _compute_centroids_for_labels(sdata, labels_key=labels_key, labels_da=labels_da, scale=scale) if not cell_info: raise ValueError("No cells found in labels (all zeros).") @@ -687,13 +688,13 @@ def _process_one(spec): if inplace: table_key = table_key_added if table_key_added is not None else f"{labels_key}_qc" - _warn_if_dropping_stitch_columns(sdata, table_key, labels_key) + _warn_if_dropping_stitch_columns(sdata, table_key=table_key, labels_key=labels_key) sdata.tables[table_key] = TableModel.parse(adata) return None return adata -def _warn_if_dropping_stitch_columns(sdata: sd.SpatialData, table_key: str, labels_key: str) -> None: +def _warn_if_dropping_stitch_columns(sdata: sd.SpatialData, *, table_key: str, labels_key: str) -> None: """Warn if re-running QC would drop downstream stitch results. ``calculate_tiling_qc`` replaces the QC table wholesale, so any columns diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index a2644157d..9fdc04671 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -338,7 +338,7 @@ def spatial_neighbors( return _run_spatial_neighbors( adata, - builder, + builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -349,6 +349,7 @@ def spatial_neighbors( def _resolve_data( data: AnnData | SpatialData, + *, elements_to_coordinate_systems: dict[str, str] | None, table_key: str | None = None, spatial_key: str = Key.obsm.spatial, @@ -471,7 +472,7 @@ def spatial_neighbors_from_builder( ) return _run_spatial_neighbors( adata, - builder, + builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -496,7 +497,7 @@ def _prepare_spatial_neighbors_input( table_key=table_key, library_key=library_key, ) - _assert_spatial_basis(adata, spatial_key) + _assert_spatial_basis(adata, key=spatial_key) return adata, library_key @@ -560,7 +561,7 @@ def spatial_neighbors_knn( ) return _run_spatial_neighbors( adata, - builder, + builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -632,7 +633,7 @@ def spatial_neighbors_radius( ) return _run_spatial_neighbors( adata, - builder, + builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -708,7 +709,7 @@ def spatial_neighbors_delaunay( ) return _run_spatial_neighbors( adata, - builder, + builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -797,7 +798,7 @@ def spatial_neighbors_grid( ) return _run_spatial_neighbors( adata, - builder, + builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -808,8 +809,8 @@ def spatial_neighbors_grid( def _run_spatial_neighbors( adata: AnnData, - builder: GraphBuilder[Any, Any], *, + builder: GraphBuilder[Any, Any], spatial_key: str = Key.obsm.spatial, library_key: str | None = None, key_added: str = "spatial", diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index cb187d5e3..9ca7e4b51 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -380,8 +380,8 @@ def nhood_enrichment( """ adata = extract_adata_if_sdata(adata, table_key=table_key) connectivity_key = Key.obsp.spatial_conn(connectivity_key) - _assert_categorical_obs(adata, cluster_key) - _assert_connectivity_key(adata, connectivity_key) + _assert_categorical_obs(adata, key=cluster_key) + _assert_connectivity_key(adata, key=connectivity_key) assert_positive(n_perms, name="n_perms") if normalization not in _NORM_CODES: @@ -597,8 +597,8 @@ def centrality_scores( """ adata = extract_adata_if_sdata(adata, table_key=table_key) connectivity_key = Key.obsp.spatial_conn(connectivity_key) - _assert_categorical_obs(adata, cluster_key) - _assert_connectivity_key(adata, connectivity_key) + _assert_categorical_obs(adata, key=cluster_key) + _assert_connectivity_key(adata, key=connectivity_key) if isinstance(score, str | Centrality): centrality = [score] @@ -692,8 +692,8 @@ def interaction_matrix( """ adata = extract_adata_if_sdata(adata, table_key=table_key) connectivity_key = Key.obsp.spatial_conn(connectivity_key) - _assert_categorical_obs(adata, cluster_key) - _assert_connectivity_key(adata, connectivity_key) + _assert_categorical_obs(adata, key=cluster_key) + _assert_connectivity_key(adata, key=connectivity_key) cats = adata.obs[cluster_key] mask = ~pd.isnull(cats).values @@ -1043,9 +1043,9 @@ def mean_over(x: Array | CSBase) -> Array | CSBase: raise ValueError(f"'aggregation' must be 'mean', 'sum' or 'variance', got {aggregation!r}") -def _assert_hop_request(adata: AnnData, connectivity_key: str, hops: Sequence[int]) -> None: +def _assert_hop_request(adata: AnnData, *, connectivity_key: str, hops: Sequence[int]) -> None: """Verify a hop request against the graph it is about to run on.""" - _assert_connectivity_key(adata, connectivity_key) + _assert_connectivity_key(adata, key=connectivity_key) if len(hops) == 0: raise ValueError("'hops' must name at least one hop") if any(hop < 0 for hop in hops): @@ -1068,7 +1068,7 @@ def nhood_aggregate( Matrix powers, not disjoint rings, so a hop restates the ones below it. Each cell in reach counts once: a cell two paths away is still one cell. """ - _assert_hop_request(adata, connectivity_key, hops) + _assert_hop_request(adata, connectivity_key=connectivity_key, hops=hops) if aggregation not in ("mean", "sum", "variance"): raise ValueError(f"'aggregation' must be 'mean', 'sum' or 'variance', got {aggregation!r}") weights = [1.0] * len(hops) if hop_weights is None else list(hop_weights) diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 993f2fc23..ef0222b91 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -214,26 +214,26 @@ def calculate_niche( # noqa: PLR0917, deprecated and removed in v1.9.0 _validate_niche_args( data, - flavor, - table_key, - groups, - n_neighbors, - resolutions, - min_niche_size, - scale, - abs_nhood, - distance, - n_hop_weights, - aggregation, - n_components, - rng, - spatial_connectivities_key, - latent_connectivities_key, - layer_ratio, - n_iterations, - use_weights, - use_rep, - inplace, + flavor=flavor, + table_key=table_key, + groups=groups, + n_neighbors=n_neighbors, + resolutions=resolutions, + min_niche_size=min_niche_size, + scale=scale, + abs_nhood=abs_nhood, + distance=distance, + n_hop_weights=n_hop_weights, + aggregation=aggregation, + n_components=n_components, + rng=rng, + spatial_connectivities_key=spatial_connectivities_key, + latent_connectivities_key=latent_connectivities_key, + layer_ratio=layer_ratio, + n_iterations=n_iterations, + use_weights=use_weights, + use_rep=use_rep, + inplace=inplace, ) if resolutions is None: @@ -881,13 +881,16 @@ def run(adata: AnnData) -> None: # called here, not via a helper: another frame would shift the warnings' stacklevel embedding = embedder(adata) adata.obsm[embedding_key_added] = embedding - _fit_clusterers(adata, embedding, clusterers, rng, keep=keep, min_niche_size=min_niche_size) + _fit_clusterers( + adata, embedding=embedding, clusterers=clusterers, rng=rng, keep=keep, min_niche_size=min_niche_size + ) return _on_table(data, table_key=table_key, copy=copy, work=run) def _validate_niche_args( # noqa: PLR0917, deprecated and removed in v1.9.0 data: AnnData | SpatialData, + *, flavor: Literal["neighborhood", "utag", "cellcharter", "spatialleiden"], table_key: str | None, groups: str | None, @@ -1124,7 +1127,7 @@ def _check_unnecessary_args(flavor: str, param_dict: dict[str, Any], param_specs NicheEmbedder = Callable[[AnnData], Array] -def _has_edge_weights(adata: AnnData, key: str) -> bool: +def _has_edge_weights(adata: AnnData, *, key: str) -> bool: """True when the graph carries values other than 0 and 1.""" weights = adata.obsp[key].data return bool(weights.size) and not np.array_equal(weights, weights.astype(bool)) @@ -1149,7 +1152,7 @@ def _nhood_profile_embedding( f"Earlier versions padded a short list with its last value; pass all {distance}." ) - if _has_edge_weights(adata, spatial_connectivities_key): + if _has_edge_weights(adata, key=spatial_connectivities_key): warnings.warn( f"'{spatial_connectivities_key}' carries non-binary edge weights. They weight hop 1, " "so the profile is the share of connectivity reaching each category rather than the " @@ -1205,9 +1208,9 @@ def _nhop_pca_embedding( raise ValueError(f"'distance' must be >= 1, got {distance}") hops = range(distance + 1) - _assert_hop_request(adata, spatial_connectivities_key, hops) + _assert_hop_request(adata, connectivity_key=spatial_connectivities_key, hops=hops) - if _has_edge_weights(adata, spatial_connectivities_key): + if _has_edge_weights(adata, key=spatial_connectivities_key): warnings.warn( f"'{spatial_connectivities_key}' carries non-binary edge weights, which this flavor " "ignores entirely: the hop rings are boolean, as in CellCharter. The 'neighborhood' " @@ -1221,7 +1224,7 @@ def _nhop_pca_embedding( # CellCharter aggregates an already reduced representation, so PCA comes first: the rings then # aggregate a narrow dense matrix instead of `distance + 1` copies of every gene if use_rep is None: - features = _pca_features(adata, n_pca_components) + features = _pca_features(adata, n_pca_components=n_pca_components) elif use_rep == "X": # the spelling `scanpy.pp.neighbors` takes features = to_dense(adata.X) else: @@ -1242,7 +1245,7 @@ def _nhop_pca_embedding( return embedding -def _pca_features(adata: AnnData, n_pca_components: int | None) -> Array: +def _pca_features(adata: AnnData, *, n_pca_components: int | None) -> Array: """The PCA that stands in for a reduced representation when ``use_rep`` is not given.""" # scanpy's `pca(adata)` masks by `highly_variable` but also writes to `adata`; mask here instead if "highly_variable" in adata.var: @@ -1313,10 +1316,10 @@ def _leiden_clusterers( def _fit_clusterers( adata: AnnData, + *, embedding: Array, clusterers: Mapping[str, Clusterer], rng: np.random.Generator, - *, keep: NDArray[np.bool_] | None = None, min_niche_size: int | None = None, ) -> list[str]: diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index bb9f658c6..fb178802e 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -172,7 +172,7 @@ def spatial_autocorr( - :attr:`anndata.AnnData.uns` ``['gearyC']`` - the above mentioned dataframe, if ``mode = {sp.GEARY.s!r}``. """ adata = extract_adata_if_sdata(adata, table_key=table_key) - _assert_connectivity_key(adata, connectivity_key) + _assert_connectivity_key(adata, key=connectivity_key) def extract_X(adata: AnnData, genes: str | Sequence[str] | None) -> tuple[NDArrayA | spmatrix, Sequence[Any]]: if genes is None: diff --git a/src/squidpy/gr/_sepal.py b/src/squidpy/gr/_sepal.py index a7208e894..8d4d7ab71 100644 --- a/src/squidpy/gr/_sepal.py +++ b/src/squidpy/gr/_sepal.py @@ -113,7 +113,7 @@ def sepal( consider re-running the function with increased ``n_iter``. """ adata = extract_adata_if_sdata(adata, table_key=table_key) - _assert_connectivity_key(adata, connectivity_key) + _assert_connectivity_key(adata, key=connectivity_key) _assert_spatial_basis(adata, key=spatial_key) if max_neighs not in (4, 6): raise ValueError(f"Expected `max_neighs` to be either `4` or `6`, found `{max_neighs}`.") diff --git a/src/squidpy/gr/_utils.py b/src/squidpy/gr/_utils.py index c07babc6f..e2217bd04 100644 --- a/src/squidpy/gr/_utils.py +++ b/src/squidpy/gr/_utils.py @@ -51,7 +51,7 @@ def extract_adata_if_sdata(adata: AnnData | SpatialData, *, table_key: str | Non return adata -def _assert_categorical_obs(adata: AnnData, key: str) -> None: +def _assert_categorical_obs(adata: AnnData, *, key: str) -> None: if key not in adata.obs: raise KeyError(f"Cluster key `{key}` not found in `adata.obs`.") @@ -59,7 +59,7 @@ def _assert_categorical_obs(adata: AnnData, key: str) -> None: raise TypeError(f"Expected `adata.obs[{key!r}]` to be `categorical`, found `{infer_dtype(adata.obs[key])}`.") -def _assert_connectivity_key(adata: AnnData, key: str) -> None: +def _assert_connectivity_key(adata: AnnData, *, key: str) -> None: if key not in adata.obsp: key_added = key.replace("_connectivities", "") raise KeyError( @@ -68,7 +68,7 @@ def _assert_connectivity_key(adata: AnnData, key: str) -> None: ) -def _assert_spatial_basis(adata: AnnData, key: str) -> None: +def _assert_spatial_basis(adata: AnnData, *, key: str) -> None: if key not in adata.obsm: raise KeyError(f"Spatial basis `{key}` not found in `adata.obsm`.") @@ -86,7 +86,7 @@ def _save_data(adata: AnnData, *, attr: str, key: str, data: Any, prefix: bool = def _extract_expression( - adata: AnnData, genes: Sequence[str] | None = None, use_raw: bool = False, layer: str | None = None + adata: AnnData, *, genes: Sequence[str] | None = None, use_raw: bool = False, layer: str | None = None ) -> tuple[NDArrayA | spmatrix, Sequence[str]]: if use_raw and adata.raw is None: logg.warning("AnnData object has no attribute `raw`. Setting `use_raw=False`") diff --git a/src/squidpy/im/_container.py b/src/squidpy/im/_container.py index 1656c550d..76f2ed82f 100644 --- a/src/squidpy/im/_container.py +++ b/src/squidpy/im/_container.py @@ -771,7 +771,7 @@ def generate_spot_crops( """ self._assert_not_empty() assert_positive(spot_scale, name="scale") - _assert_spatial_basis(adata, spatial_key) + _assert_spatial_basis(adata, key=spatial_key) # limit to obs_names if obs_names is None: @@ -1235,7 +1235,7 @@ def subset(self, adata: AnnData, spatial_key: str = Key.obsm.spatial, copy: bool if c == _NULL_COORDS: # not a crop return adata.copy() if copy else adata - _assert_spatial_basis(adata, spatial_key) + _assert_spatial_basis(adata, key=spatial_key) coordinates = adata.obsm[spatial_key] coordinates = coordinates * self.data.attrs.get(Key.img.scale, 1) diff --git a/src/squidpy/pl/_color_utils.py b/src/squidpy/pl/_color_utils.py index ff37d3d55..5fd68a9e0 100644 --- a/src/squidpy/pl/_color_utils.py +++ b/src/squidpy/pl/_color_utils.py @@ -34,6 +34,7 @@ def _maybe_set_colors( def _get_palette( adata: AnnData, + *, cluster_key: str | None, categories: Sequence[Any], palette: Palette_t = None, diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 201b831e7..ad43bcccc 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -35,7 +35,7 @@ ] -def _get_data(adata: AnnData, cluster_key: str, func_name: str, attr: str = "uns", **kwargs: Any) -> Any: +def _get_data(adata: AnnData, *, cluster_key: str, func_name: str, attr: str = "uns", **kwargs: Any) -> Any: key = getattr(Key.uns, func_name)(cluster_key, **kwargs) try: diff --git a/src/squidpy/pl/_spatial.py b/src/squidpy/pl/_spatial.py index d95b1a629..2b9fb9d66 100644 --- a/src/squidpy/pl/_spatial.py +++ b/src/squidpy/pl/_spatial.py @@ -164,7 +164,7 @@ def _spatial_plot( %(plotting_returns)s """ sanitize_anndata(adata) - _assert_spatial_basis(adata, spatial_key) + _assert_spatial_basis(adata, key=spatial_key) scalebar_kwargs = dict(scalebar_kwargs) edges_kwargs = dict(edges_kwargs) @@ -243,7 +243,7 @@ def _spatial_plot( _coords = coords[_lib_count] # TODO: do we want to order points? for now no, skip adata_sub, coords_sub, image_sub = _subs( adata, - _coords, + coords=_coords, img=_img, library_key=library_key, library_id=_lib, @@ -253,7 +253,7 @@ def _spatial_plot( ) color_source_vector, color_vector, categorical = _set_color_source_vec( adata_sub, - value_to_plot, + value_to_plot=value_to_plot, layer=layer, use_raw=color_params.use_raw, alt_var=alt_var, @@ -270,8 +270,8 @@ def _spatial_plot( if connectivity_key is not None: _plot_edges( adata_sub, - coords_sub, - connectivity_key, + coords=coords_sub, + connectivity_key=connectivity_key, ax=ax, edges_width=edges_width, edges_color=edges_color, diff --git a/src/squidpy/pl/_spatial_utils.py b/src/squidpy/pl/_spatial_utils.py index 53cd1338b..93a07c09f 100644 --- a/src/squidpy/pl/_spatial_utils.py +++ b/src/squidpy/pl/_spatial_utils.py @@ -151,8 +151,8 @@ def _get_library_id( def _get_image( adata: AnnData, - library_id: Sequence[str], *, + library_id: Sequence[str], spatial_key: str = Key.obsm.spatial, img: bool | _SeqArray | None = None, img_res_key: str | None = None, @@ -189,8 +189,8 @@ def _get_image( def _get_segment( adata: AnnData, - library_id: Sequence[str], *, + library_id: Sequence[str], seg_cell_id: str | None = None, library_key: str | None = None, seg: _SeqArray | bool | None = None, @@ -215,8 +215,8 @@ def _get_segment( def _get_scalefactor_size( adata: AnnData, - library_id: Sequence[str], *, + library_id: Sequence[str], spatial_key: str = Key.obsm.spatial, img_res_key: str | None = None, scale_factor: _SeqFloat | None = None, @@ -342,6 +342,7 @@ def truthy(img: bool | NDArrayA | _SeqArray | None) -> bool: def _set_coords_crops( adata: AnnData, + *, spatial_params: SpatialParams, spatial_key: str, crop_coord: Sequence[_CoordTuple] | _CoordTuple | None = None, @@ -363,8 +364,8 @@ def _set_coords_crops( def _subs( adata: AnnData, - coords: NDArrayA, *, + coords: NDArrayA, img: NDArrayA | None = None, library_key: str | None = None, library_id: str | None = None, @@ -451,8 +452,8 @@ def _get_list( def _set_color_source_vec( adata: AnnData, - value_to_plot: str | None, *, + value_to_plot: str | None, use_raw: bool | None = None, alt_var: str | None = None, layer: str | None = None, @@ -548,10 +549,10 @@ def _make_poly(x: NDArrayA, y: NDArrayA, r: float, n: int, i: int) -> tuple[NDAr def _plot_edges( adata: AnnData, + *, coords: NDArrayA, connectivity_key: str, ax: Axes, - *, edges_width: float = 0.1, edges_color: str | Sequence[float] | Sequence[str] = "grey", **kwargs: Any, diff --git a/src/squidpy/pl/_utils.py b/src/squidpy/pl/_utils.py index 38a63140f..833a0d902 100644 --- a/src/squidpy/pl/_utils.py +++ b/src/squidpy/pl/_utils.py @@ -134,7 +134,7 @@ def extract( If :attr:`anndata.AnnData.obs` ``['{column}']`` already exists, it will be overwritten and a warning will be issued. """ - def _warn_if_exists_obs(adata: AnnData, obs_key: str) -> None: + def _warn_if_exists_obs(adata: AnnData, *, obs_key: str) -> None: if obs_key in adata.obs.columns: logg.warning(f"Overwriting `adata.obs[{obs_key!r}]`") @@ -166,13 +166,13 @@ def _warn_if_exists_obs(adata: AnnData, obs_key: str) -> None: # names will be column_names for col in obsm.columns: obs_key = f"{prefix[i]}{col}" - _warn_if_exists_obs(tmp_adata, obs_key) + _warn_if_exists_obs(tmp_adata, obs_key=obs_key) tmp_adata.obs[obs_key] = obsm[col] else: # names will be integer indices for j in range(obsm.shape[1]): obs_key = f"{prefix[i]}{j}" - _warn_if_exists_obs(tmp_adata, obs_key) + _warn_if_exists_obs(tmp_adata, obs_key=obs_key) # https://github.com/scverse/squidpy/issues/646 tmp_adata.obs[obs_key] = pd.Series(obsm[:, j], index=tmp_adata.obs_names) @@ -509,6 +509,7 @@ def _annotate_heatmap( def _get_cmap_norm( adata: AnnData, + *, key: str, order: tuple[list[int], list[int]] | None | None = None, ) -> tuple[ @@ -539,8 +540,8 @@ def _get_cmap_norm( def _heatmap( adata: AnnData, - key: str, *, + key: str, title: str = "", method: str | None = None, cont_cmap: str | mcolors.Colormap = "viridis", @@ -569,7 +570,7 @@ def _heatmap( row_labels = adata.obs[key].iloc[row_order] data = adata[row_order, col_order].X - row_cmap, col_cmap, row_norm, col_norm, n_cls = _get_cmap_norm(adata, key, order=(row_order, col_order)) + row_cmap, col_cmap, row_norm, col_norm, n_cls = _get_cmap_norm(adata, key=key, order=(row_order, col_order)) row_sm = mpl.cm.ScalarMappable(cmap=row_cmap, norm=row_norm) col_sm = mpl.cm.ScalarMappable(cmap=col_cmap, norm=col_norm) @@ -657,7 +658,7 @@ def sanitize_anndata(adata: AnnData) -> None: adata._sanitize() -def _assert_value_in_obs(adata: AnnData, key: str, val: Sequence[Any] | Any) -> None: +def _assert_value_in_obs(adata: AnnData, *, key: str, val: Sequence[Any] | Any) -> None: assert_key_in_adata(adata, key, attr="obs") if not isinstance(val, list): val = [val] diff --git a/src/squidpy/tl/_var_by_distance.py b/src/squidpy/tl/_var_by_distance.py index 7911d1e4d..72b7988cb 100644 --- a/src/squidpy/tl/_var_by_distance.py +++ b/src/squidpy/tl/_var_by_distance.py @@ -121,22 +121,32 @@ def var_by_distance( if batch_var is not None: if cluster_key is not None: if anchor_var in pd.unique(adata[adata.obs[library_key] == batch_var].obs[cluster_key]): - df = _init_design_matrix(adata, cluster_key, library_key, batch_var) + df = _init_design_matrix( + adata, cluster_key=cluster_key, library_key=library_key, batch_var=batch_var + ) anchor_coord, batch_coord, nan_ids = _get_coordinates( - adata[adata.obs[library_key] == batch_var], anchor_var, cluster_key, spatial_key + adata[adata.obs[library_key] == batch_var], + anchor=anchor_var, + annotation=cluster_key, + spatial_key=spatial_key, ) else: continue else: - df = _init_design_matrix(adata, cluster_key, library_key, batch_var) + df = _init_design_matrix(adata, cluster_key=cluster_key, library_key=library_key, batch_var=batch_var) anchor_coord, batch_coord, nan_ids = _get_coordinates( - adata[adata.obs[library_key] == batch_var], anchor_var, cluster_key, spatial_key + adata[adata.obs[library_key] == batch_var], + anchor=anchor_var, + annotation=cluster_key, + spatial_key=spatial_key, ) anchor_col_id = 1 else: - df = _init_design_matrix(adata, cluster_key, None, None) - anchor_coord, batch_coord, nan_ids = _get_coordinates(adata, anchor_var, cluster_key, spatial_key) + df = _init_design_matrix(adata, cluster_key=cluster_key, library_key=None, batch_var=None) + anchor_coord, batch_coord, nan_ids = _get_coordinates( + adata, anchor=anchor_var, annotation=cluster_key, spatial_key=spatial_key + ) anchor_col_id = 1 tree = KDTree( @@ -212,6 +222,7 @@ def var_by_distance( def _init_design_matrix( adata: AnnData, + *, cluster_key: str | None, library_key: str | None, batch_var: str | None, @@ -232,7 +243,7 @@ def _init_design_matrix( def _get_coordinates( - adata: AnnData, anchor: str | list[int | float], annotation: str | None, spatial_key: str + adata: AnnData, *, anchor: str | list[int | float], annotation: str | None, spatial_key: str ) -> tuple[Any, Any, Any]: """Get anchor point coordinates and coordinates of all observations, excluding nan values.""" # since amount of distances have to match n_obs, the nan ids are stored an inserted after KDTree construction diff --git a/tests/graph/test_niche.py b/tests/graph/test_niche.py index 02aa7b963..259cf8e91 100644 --- a/tests/graph/test_niche.py +++ b/tests/graph/test_niche.py @@ -500,7 +500,12 @@ def test_clusterer_without_a_random_state_is_rejected(): adata = _tiny() with pytest.raises(TypeError, match=r"no 'random_state'"): - _fit_clusterers(adata, np.asarray(to_dense(adata.X)), {"c": DBSCAN(eps=3.0)}, np.random.default_rng(0)) + _fit_clusterers( + adata, + embedding=np.asarray(to_dense(adata.X)), + clusterers={"c": DBSCAN(eps=3.0)}, + rng=np.random.default_rng(0), + ) def test_non_boolean_mask_raises(): From 82beb3e778db30b40c3572e169fa0217ed3820aa Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Wed, 30 Sep 2026 16:03:55 +0200 Subject: [PATCH 04/28] Match the positional shims to v1.8.3's order and point warnings at the caller --- src/squidpy/_compat.py | 15 +++++- src/squidpy/_utils.py | 6 ++- src/squidpy/gr/_build.py | 8 +-- src/squidpy/gr/_nhood.py | 16 +++--- src/squidpy/gr/_ppatterns.py | 4 +- src/squidpy/gr/_ripley.py | 4 +- tests/utils/test_old_positionals.py | 84 +++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 21 deletions(-) diff --git a/src/squidpy/_compat.py b/src/squidpy/_compat.py index 28b67a78b..65c0f3df1 100644 --- a/src/squidpy/_compat.py +++ b/src/squidpy/_compat.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os from functools import partial from importlib.metadata import version from typing import TYPE_CHECKING +import legacy_api_wrap from legacy_api_wrap import legacy_api from packaging.version import Version from scanpy.get import obs_df @@ -14,6 +16,7 @@ __all__ = [ "old_positionals", + "SKIP_OWN_FRAMES", # scanpy "set_default_colors_for_categorical_obs", "add_categorical_legend", @@ -37,9 +40,17 @@ def __init__(self, message: str) -> None: super().__init__(f"{message}. Passing them positionally stops working in squidpy v1.9.0.") +#: ``warnings.warn(..., skip_file_prefixes=SKIP_OWN_FRAMES)`` points at the first frame outside +#: squidpy and the ``legacy_api`` shim, however many wrappers sit in between. +SKIP_OWN_FRAMES = ( + os.path.dirname(__file__) + os.sep, + os.path.dirname(legacy_api_wrap.__file__) + os.sep, +) + #: For arguments that became keyword-only: an old positional call still works, with a -#: ``FutureWarning`` naming them (#1288). -old_positionals = partial(legacy_api, category=_PositionalArgumentWarning) +#: ``FutureWarning`` naming them (#1288). List the names in their v1.8.3 order, and put the +#: shim outside ``deprecated_params`` / ``deprecated_randomness_param`` so they see old names. +old_positionals = partial(legacy_api, category=_PositionalArgumentWarning, skip_file_prefixes=SKIP_OWN_FRAMES) # Scanpy 1.13 moved the pre-v2 plotting internals under ``scanpy.plotting.legacy``. # ``scanpy.plotting.__getattr__`` forwards attribute access there, but submodule diff --git a/src/squidpy/_utils.py b/src/squidpy/_utils.py index 9baf9009c..ef89f92ea 100644 --- a/src/squidpy/_utils.py +++ b/src/squidpy/_utils.py @@ -21,6 +21,8 @@ from scanpy import logging as logg from spatialdata.models import Image2DModel, Labels2DModel +from squidpy._compat import SKIP_OWN_FRAMES + if TYPE_CHECKING: from numba_progress import ProgressBar @@ -395,7 +397,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: 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, + skip_file_prefixes=SKIP_OWN_FRAMES, ) kwargs["rng"] = value return func(*args, **kwargs) @@ -424,7 +426,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: f"Parameter `{k}` of `{func.__name__}()` is deprecated " f"and has no effect. It will be removed in squidpy v{params[k]}.", FutureWarning, - stacklevel=2, + skip_file_prefixes=SKIP_OWN_FRAMES, ) kwargs.pop(k) return func(*args, **kwargs) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 9fdc04671..14607de49 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -23,7 +23,7 @@ get_model, ) -from squidpy._compat import old_positionals +from squidpy._compat import SKIP_OWN_FRAMES, old_positionals from squidpy._constants._constants import CoordType, Transform from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -107,7 +107,7 @@ def _resolve_graph_builder( warnings.warn( "Parameter `n_neighs` is ignored when `delaunay=True` use `spatial_neighbors_delaunay` instead.", FutureWarning, - stacklevel=3, + skip_file_prefixes=SKIP_OWN_FRAMES, ) # Preserve the documented legacy contract: under the deprecated # `spatial_neighbors`, a scalar `radius` with `delaunay=True` is silently @@ -122,7 +122,7 @@ def _resolve_graph_builder( warnings.warn( "Parameter `n_neighs` is ignored when `radius` is set use `spatial_neighbors_radius` instead.", FutureWarning, - stacklevel=3, + skip_file_prefixes=SKIP_OWN_FRAMES, ) return RadiusBuilder(**common, radius=radius, percentile=percentile) return KNNBuilder(n_neighs=n_neighs, **common, percentile=percentile) @@ -315,7 +315,7 @@ def spatial_neighbors( "`spatial_neighbors_delaunay`, `spatial_neighbors_grid`, or " "`spatial_neighbors_from_builder` instead.", FutureWarning, - stacklevel=2, + skip_file_prefixes=SKIP_OWN_FRAMES, ) adata, library_key = _prepare_spatial_neighbors_input( adata, diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 9ca7e4b51..63b742766 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -24,7 +24,7 @@ from scipy.sparse import csr_array, csr_matrix, issparse from spatialdata import SpatialData -from squidpy._compat import old_positionals +from squidpy._compat import SKIP_OWN_FRAMES, old_positionals from squidpy._constants._constants import Centrality from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs @@ -297,21 +297,20 @@ def _filter_clusters_by_min_cell_count( @d.get_sections(base="nhood_ench", sections=["Parameters"]) @d.dedent -@deprecated_randomness_param -@deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) @old_positionals( "cluster_key", "library_key", "connectivity_key", "n_perms", - "rng", + "numba_parallel", + "seed", "copy", "n_jobs", + "backend", "show_progress_bar", - "normalization", - "min_cell_count", - "handle_nan", ) +@deprecated_randomness_param +@deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) def nhood_enrichment( adata: AnnData | SpatialData, *, @@ -436,8 +435,7 @@ def nhood_enrichment( f"{n_filtered / n_total_cells * 100:.3f}% of cells were excluded because their clusters " f"had fewer than {min_cell_count} cells.", UserWarning, - # +2 for the `deprecated_randomness_param` and `deprecated_params` wrappers - stacklevel=4, + skip_file_prefixes=SKIP_OWN_FRAMES, ) indices, indptr = (adj.indices.astype(ndt), adj.indptr.astype(ndt)) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index fb178802e..bf6db3807 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -56,7 +56,6 @@ @d.dedent @inject_docs(key=Key.obsp.spatial_conn(), sp=SpatialAutocorr) -@deprecated_randomness_param @old_positionals( "connectivity_key", "genes", @@ -67,13 +66,14 @@ "corr_method", "attr", "layer", - "rng", + "seed", "use_raw", "copy", "n_jobs", "backend", "show_progress_bar", ) +@deprecated_randomness_param def spatial_autocorr( adata: AnnData | SpatialData, *, diff --git a/src/squidpy/gr/_ripley.py b/src/squidpy/gr/_ripley.py index 74498c373..4c5ebcc05 100644 --- a/src/squidpy/gr/_ripley.py +++ b/src/squidpy/gr/_ripley.py @@ -25,7 +25,6 @@ @d.dedent @inject_docs(key=Key.obsm.spatial, rp=RipleyStat) -@deprecated_randomness_param @old_positionals( "cluster_key", "mode", @@ -36,9 +35,10 @@ "n_observations", "max_dist", "n_steps", - "rng", + "seed", "copy", ) +@deprecated_randomness_param def ripley( adata: AnnData | SpatialData, *, diff --git a/tests/utils/test_old_positionals.py b/tests/utils/test_old_positionals.py index e2575b441..3ca66a6a2 100644 --- a/tests/utils/test_old_positionals.py +++ b/tests/utils/test_old_positionals.py @@ -2,12 +2,83 @@ from __future__ import annotations +import inspect +import warnings +from contextlib import contextmanager +from importlib import import_module + import numpy as np import pytest from anndata import AnnData +import squidpy as sq +from squidpy._compat import _PositionalArgumentWarning from squidpy.gr import interaction_matrix +# Every shimmed function with the parameters it took positionally after the first one in v1.8.3. +# fmt: off +V1_8_3_POSITIONALS = [ + ("squidpy.gr._build", "spatial_neighbors", ("spatial_key", "elements_to_coordinate_systems", "table_key", "library_key", "coord_type", "n_neighs", "radius", "delaunay", "n_rings", "percentile", "transform", "set_diag", "key_added", "copy", "n_jobs")), + ("squidpy.gr._build", "spatial_neighbors_from_builder", ("builder",)), + ("squidpy.gr._build", "mask_graph", ("table_key", "polygon_mask", "negative_mask", "spatial_key", "key_added", "copy")), + ("squidpy.gr._ligrec", "ligrec", ("cluster_key", "interactions", "complex_policy", "threshold", "corr_method", "corr_axis", "use_raw", "copy", "key_added", "gene_symbols")), + ("squidpy.gr._nhood", "nhood_enrichment", ("cluster_key", "library_key", "connectivity_key", "n_perms", "numba_parallel", "seed", "copy", "n_jobs", "backend", "show_progress_bar")), + ("squidpy.gr._nhood", "centrality_scores", ("cluster_key", "score", "connectivity_key", "copy", "n_jobs", "backend", "show_progress_bar")), + ("squidpy.gr._nhood", "interaction_matrix", ("cluster_key", "connectivity_key", "normalized", "copy", "weights")), + ("squidpy.gr._ppatterns", "spatial_autocorr", ("connectivity_key", "genes", "mode", "transformation", "n_perms", "two_tailed", "corr_method", "attr", "layer", "seed", "use_raw", "copy", "n_jobs", "backend", "show_progress_bar")), + ("squidpy.gr._ppatterns", "co_occurrence", ("cluster_key", "spatial_key", "interval", "copy")), + ("squidpy.gr._ripley", "ripley", ("cluster_key", "mode", "spatial_key", "metric", "n_neigh", "n_simulations", "n_observations", "max_dist", "n_steps", "seed", "copy")), + ("squidpy.gr._sepal", "sepal", ("max_neighs", "genes", "n_iter", "dt", "thresh", "connectivity_key", "spatial_key", "layer", "use_raw", "copy", "n_jobs", "show_progress_bar")), + ("squidpy.im._feature", "calculate_image_features", ("img", "layer", "library_id", "features", "features_kwargs", "key_added", "copy", "n_jobs", "backend", "show_progress_bar")), + ("squidpy.im._process", "process", ("layer", "library_id", "method", "chunks", "lazy", "layer_added", "channel_dim", "copy", "apply_kwargs")), + ("squidpy.pl._graph", "centrality_scores", ("cluster_key", "score", "legend_kwargs", "palette", "figsize", "dpi", "save")), + ("squidpy.pl._graph", "interaction_matrix", ("cluster_key", "annotate", "method", "title", "cmap", "palette", "cbar_kwargs", "figsize", "dpi", "save", "ax")), + ("squidpy.pl._graph", "nhood_enrichment", ("cluster_key", "mode", "annotate", "method", "title", "cmap", "palette", "cbar_kwargs", "figsize", "dpi", "save", "ax")), + ("squidpy.pl._graph", "ripley", ("cluster_key", "mode", "plot_sims", "palette", "figsize", "dpi", "save", "ax", "legend_kwargs")), + ("squidpy.pl._graph", "co_occurrence", ("cluster_key", "palette", "clusters", "figsize", "dpi", "save", "legend_kwargs")), + ("squidpy.pl._ligrec", "ligrec", ("cluster_key", "source_groups", "target_groups", "means_range", "pvalue_threshold", "remove_empty_interactions", "remove_nonsig_interactions", "dendrogram", "alpha", "swap_axes", "title", "figsize", "dpi", "save")), + ("squidpy.pl._spatial", "spatial_scatter", ("shape",)), + ("squidpy.pl._spatial", "spatial_segment", ("seg_cell_id", "seg", "seg_key", "seg_contourpx", "seg_outline")), + ("squidpy.pl._utils", "extract", ("obsm_key", "prefix")), + ("squidpy.pl._var_by_distance", "var_by_distance", ("var", "anchor_key", "design_matrix_key", "stack_vars", "covariate", "order", "show_scatter", "color", "line_palette", "scatter_palette", "dpi", "figsize", "save", "title", "axis_label", "return_ax", "regplot_kwargs", "scatterplot_kwargs")), + ("squidpy.tl._sliding_window", "sliding_window", ("library_key", "window_size", "overlap", "coord_columns", "sliding_window_key", "spatial_key", "drop_partial_windows", "copy")), + ("squidpy.tl._var_by_distance", "var_by_distance", ("groups", "cluster_key", "library_key", "library_id", "design_matrix_key", "covariates", "metric", "spatial_key", "copy")), +] +# fmt: on + + +@contextmanager +def _record_body_calls(func): + """Swap the undecorated body for a recorder, keeping every wrapper around it.""" + body = inspect.unwrap(func) + last = func + while last.__wrapped__ is not body: + last = last.__wrapped__ + cell = next(c for c in last.__closure__ if c.cell_contents is body) + calls = [] + cell.cell_contents = lambda *args, **kwargs: calls.append((args, kwargs)) + try: + yield calls + finally: + cell.cell_contents = body + + +@pytest.mark.parametrize(("module", "name", "old"), V1_8_3_POSITIONALS, ids=lambda x: x if isinstance(x, str) else "") +def test_v1_8_3_positional_call_binds_like_v1_8_3(module: str, name: str, old: tuple[str, ...]) -> None: + func = getattr(import_module(module), name) + body_params = inspect.signature(inspect.unwrap(func)).parameters + values = {n: f"<{n}>" for n in old} + # `seed` became `rng`; anything else missing from the body is a deprecated no-op that is dropped + expected = {("rng" if n == "seed" else n): v for n, v in values.items() if n == "seed" or n in body_params} + + with _record_body_calls(func) as calls, warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + func("", *values.values()) + + assert calls == [(("",), expected)] + assert any(issubclass(w.category, _PositionalArgumentWarning) for w in caught) + assert {w.filename for w in caught} == {__file__}, [(w.filename, str(w.message)) for w in caught] + def test_old_positional_call_warns_and_still_works(nhood_data: AnnData) -> None: # `cluster_key`, `connectivity_key`, `normalized` and `copy` were positional before #1288 @@ -15,3 +86,16 @@ def test_old_positional_call_warns_and_still_works(nhood_data: AnnData) -> None: old = interaction_matrix(nhood_data, "leiden", "spatial", False, True) new = interaction_matrix(nhood_data, cluster_key="leiden", connectivity_key="spatial", normalized=False, copy=True) np.testing.assert_array_equal(old, new) + + +def test_warnings_inside_shimmed_functions_point_at_the_caller(nhood_data: AnnData) -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sq.gr.spatial_neighbors(nhood_data, coord_type="generic", n_neighs=5, radius=3.0) + sq.gr.nhood_enrichment( + nhood_data, "leiden", n_perms=5, min_cell_count=nhood_data.n_obs, show_progress_bar=False + ) + messages = " | ".join(str(w.message) for w in caught) + for part in ("Calling `spatial_neighbors`", "`n_neighs` is ignored", "no longer positional", "were excluded"): + assert part in messages + assert {w.filename for w in caught} == {__file__}, [(w.filename, str(w.message)) for w in caught] From 75b4bebaf9b5de4331a4659f419258746dc2ac22 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Thu, 1 Oct 2026 14:07:48 +0200 Subject: [PATCH 05/28] Name the spawned generators rngs, document random_state on sklearn-style estimators --- docs/contributing.md | 2 +- src/squidpy/gr/_ligrec.py | 14 +++++++------- src/squidpy/gr/_nhood.py | 18 +++++++++--------- src/squidpy/gr/_ppatterns.py | 8 ++++---- tests/graph/test_nhood_correctness.py | 4 ++-- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 6077fa85f..6a4379515 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -170,7 +170,7 @@ Consider enabling this option for [ruff][ruff-editors] and [biome][biome-editors ### Conventions - **Keyword-only arguments**: a public function takes its data object (`adata`, `sdata` or `data`) positionally, and every other argument is keyword-only, e.g. `def nhood_enrichment(adata, *, cluster_key, ...)`. -- **Randomness**: take `rng: SeedLike | RNGLike | None = None` ([SPEC 7](https://scientific-python.org/specs/spec-0007/)), never `seed`/`random_state`. Spawn one generator per independent task with `rng.spawn(n)`, and pass `legacy_random(rng)` to APIs that only take an integer seed. +- **Randomness**: take `rng: SeedLike | RNGLike | None = None` ([SPEC 7](https://scientific-python.org/specs/spec-0007/)), never `seed`/`generator`/`random_state`; the exception is `random_state` on sklearn-style estimators (the clusterers), which follow sklearn's `get_params`/`set_params` protocol. Spawn one generator per independent task with `rng.spawn(n)`, and pass `legacy_random(rng)` to APIs that only take an integer seed. - **Warnings**: `warnings.warn` for anything about the call: deprecations (`FutureWarning`) and arguments whose effect the caller may not expect, such as clusters dropped by `min_cell_count` (`UserWarning`). What the computation is doing, such as the PCA it runs, goes through `logg`. (writing-tests)= diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index b7ae172cc..7a9fd4f1f 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -641,7 +641,7 @@ def ligrec( def _score_permutations( # noqa: PLR0917, numba requires positional arguments data: NDArrayA, clustering: NDArrayA, - generators: list[np.random.Generator], + rngs: list[np.random.Generator], inv_counts: NDArrayA, mean_obs: NDArrayA, interactions: NDArrayA, @@ -649,11 +649,11 @@ def _score_permutations( # noqa: PLR0917, numba requires positional arguments valid: NDArrayA, progress: ProgressBar, ) -> NDArrayA: - """Shuffle and score one permutation per RNG in ``generators``; return accumulated p-value counts. + """Shuffle and score one permutation per RNG in ``rngs``; return accumulated p-value counts. - ``generators`` must be a :class:`numba.typed.List`, so that it can be indexed inside ``prange``. + ``rngs`` must be a :class:`numba.typed.List`, so that it can be indexed inside ``prange``. """ - n_perms = len(generators) + n_perms = len(rngs) n_cells = data.shape[0] n_genes = data.shape[1] n_cls = mean_obs.shape[0] @@ -665,7 +665,7 @@ def _score_permutations( # noqa: PLR0917, numba requires positional arguments perm = clustering.copy() # explicit int64 index: under prange the loop var is uint64 and indexing the typed list # would otherwise trigger a (harmless) uint64->int64 NumbaTypeSafetyWarning - generators[np.int64(p)].shuffle(perm) + rngs[np.int64(p)].shuffle(perm) groups = np.zeros((n_cls, n_genes), dtype=np.float64) for cell in range(n_cells): @@ -774,7 +774,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(rng.spawn(n_perms)) + rngs = 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 @@ -785,7 +785,7 @@ def _analysis( pval_counts = _score_permutations( data_arr, clustering, - generators, + rngs, inv_counts, mean_obs, interactions_i32, diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 63b742766..6c1b78c8e 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -189,7 +189,7 @@ def _permutation_moments_counts( # noqa: PLR0917, numba requires positional arg group_indices: NDArrayA, n_cls: int, observed: NDArrayA, - generators: Any, + rngs: Any, progress: Any, ) -> tuple[NDArrayA, NDArrayA]: """Exact integer moments of the permutation distribution for ``normalization='none'``. @@ -202,13 +202,13 @@ def _permutation_moments_counts( # noqa: PLR0917, numba requires positional arg Returns ``(sum_d, sum_d2)``; the caller turns these into the mean, std and z-score. """ - n_perms = len(generators) + n_perms = len(rngs) sum_d = np.zeros((n_cls, n_cls), dtype=np.int64) sum_d2 = np.zeros((n_cls, n_cls), dtype=np.int64) for p in prange(n_perms): # explicit int64 index: under prange the loop var is uint64 and indexing the typed list # would otherwise trigger a (harmless) uint64->int64 NumbaTypeSafetyWarning - rng = generators[np.int64(p)] + rng = rngs[np.int64(p)] shuffled = _shuffled_labels(int_clust, group_offsets, group_indices, rng) out = _nenrich(indices, indptr, shuffled, n_cls) @@ -238,7 +238,7 @@ def _permutation_moments_normalized( # noqa: PLR0917, numba requires positional norm_code: int, sizes: NDArrayA, observed: NDArrayA, - generators: Any, + rngs: Any, progress: Any, ) -> tuple[NDArrayA, NDArrayA]: """Moments of the permutation distribution for the ``'total'`` / ``'conditional'`` modes. @@ -251,11 +251,11 @@ def _permutation_moments_normalized( # noqa: PLR0917, numba requires positional Returns ``(sum_d, sum_d2)``; the caller turns these into the mean, std and z-score. """ - n_perms = len(generators) + n_perms = len(rngs) sum_d = np.zeros((n_cls, n_cls), dtype=np.float64) sum_d2 = np.zeros((n_cls, n_cls), dtype=np.float64) for p in prange(n_perms): - rng = generators[np.int64(p)] + rng = rngs[np.int64(p)] shuffled = _shuffled_labels(int_clust, group_offsets, group_indices, rng) if norm_code == 1: # total @@ -467,7 +467,7 @@ def nhood_enrichment( start = logg.info(f"Calculating neighborhood enrichment using `{n_jobs}` thread(s)") norm_code = _NORM_CODES[normalization] - generators = List(np.random.default_rng(rng).spawn(n_perms)) + rngs = List(np.random.default_rng(rng).spawn(n_perms)) # Group structure for within-group shuffling, as a CSR-like (offsets, indices) pair in category # order with ascending indices per group. Without a `library_key` there is a single group @@ -490,7 +490,7 @@ def nhood_enrichment( group_indices, n_cls, np.ascontiguousarray(count_normalized, dtype=np.int64), - generators, + rngs, progress, ) else: @@ -504,7 +504,7 @@ def nhood_enrichment( norm_code, cluster_sizes, np.ascontiguousarray(count_normalized, dtype=np.float64), - generators, + rngs, progress, ) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index bf6db3807..dddff94b7 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -243,7 +243,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 = np.random.default_rng(rng).spawn(n_perms) + rngs = np.random.default_rng(rng).spawn(n_perms) score_perms = parallelize( _score_helper, @@ -252,7 +252,7 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr n_jobs=n_jobs, backend=backend, show_progress_bar=show_progress_bar, - )(mode=mode, g=g, vals=vals, generators=generators) + )(mode=mode, g=g, vals=vals, rngs=rngs) else: score_perms = None @@ -283,7 +283,7 @@ def _score_helper( mode: SpatialAutocorr, g: spmatrix, vals: NDArrayA, - generators: Sequence[np.random.Generator], + rngs: Sequence[np.random.Generator], *, queue: SigQueue | None = None, ) -> pd.DataFrame: @@ -291,7 +291,7 @@ def _score_helper( func = morans_i if mode == SpatialAutocorr.MORAN else gearys_c for i, p in enumerate(perms): - rng = generators[p] + rng = rngs[p] idx_shuffle = rng.permutation(g.shape[0]) score_perms[i, :] = func(g[idx_shuffle, :], vals) diff --git a/tests/graph/test_nhood_correctness.py b/tests/graph/test_nhood_correctness.py index 8420f6d08..756b9ff1e 100644 --- a/tests/graph/test_nhood_correctness.py +++ b/tests/graph/test_nhood_correctness.py @@ -135,10 +135,10 @@ def _reference_nhood_enrichment( """ observed = _ref_normalize(adj, int_clust, n_cls, normalization) - generators = np.random.default_rng(rng).spawn(n_perms) + rngs = np.random.default_rng(rng).spawn(n_perms) perms = np.empty((n_perms, n_cls, n_cls), dtype=np.float64) for p in range(n_perms): - gen = generators[p] + gen = rngs[p] if libraries is not None: shuffled = _ref_shuffle_group(int_clust, libraries, gen) else: From 0337f4f52a869efff3a3d87b8a4e7a414c11b56b Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Thu, 1 Oct 2026 14:53:28 +0200 Subject: [PATCH 06/28] Keep the positional shim outermost in ligrec, co_occurrence and sepal --- src/squidpy/gr/_ligrec.py | 4 ++-- src/squidpy/gr/_ppatterns.py | 2 +- src/squidpy/gr/_sepal.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 7a9fd4f1f..2ed1f38ac 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -555,8 +555,6 @@ def prepare( @d.dedent -@deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) -@deprecated_randomness_param @old_positionals( "cluster_key", "interactions", @@ -569,6 +567,8 @@ def prepare( "key_added", "gene_symbols", ) +@deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) +@deprecated_randomness_param def ligrec( adata: AnnData | SpatialData, *, diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index dddff94b7..e14d1e17d 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -383,8 +383,8 @@ def _co_occurrence_helper(v_x: NDArrayA, v_y: NDArrayA, v_radium: NDArrayA, labs @d.dedent -@deprecated_params({"n_splits": "1.10.0", "n_jobs": "1.10.0", "backend": "1.10.0", "show_progress_bar": "1.10.0"}) @old_positionals("cluster_key", "spatial_key", "interval", "copy") +@deprecated_params({"n_splits": "1.10.0", "n_jobs": "1.10.0", "backend": "1.10.0", "show_progress_bar": "1.10.0"}) def co_occurrence( adata: AnnData | SpatialData, *, diff --git a/src/squidpy/gr/_sepal.py b/src/squidpy/gr/_sepal.py index 8d4d7ab71..0e2c1ac29 100644 --- a/src/squidpy/gr/_sepal.py +++ b/src/squidpy/gr/_sepal.py @@ -30,7 +30,6 @@ @d.dedent @inject_docs(key=Key.obsp.spatial_conn()) -@deprecated_params({"backend": "1.10.0"}) @old_positionals( "max_neighs", "genes", @@ -45,6 +44,7 @@ "n_jobs", "show_progress_bar", ) +@deprecated_params({"backend": "1.10.0"}) def sepal( adata: AnnData | SpatialData, *, From e5449d013ef55c2a1b907fe425f3d68217fb0165 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Thu, 1 Oct 2026 14:55:03 +0200 Subject: [PATCH 07/28] Take only the data positionally in the remaining private data helpers --- src/squidpy/experimental/im/_make_tiles.py | 4 +-- src/squidpy/experimental/im/_qc_image.py | 14 +++++--- .../experimental/im/_stain/_normalize.py | 6 ++-- src/squidpy/experimental/im/_utils.py | 10 +++--- .../experimental/tl/_stitched_labels.py | 2 +- src/squidpy/experimental/tl/_tiling_qc.py | 2 +- src/squidpy/experimental/tl/_tiling_stitch.py | 2 +- src/squidpy/experimental/utils/_derive_mpp.py | 2 +- src/squidpy/experimental/utils/_labels.py | 2 +- src/squidpy/pl/_spatial_utils.py | 1 + tests/experimental/test_derive_mpp.py | 36 ++++++++++--------- tests/experimental/test_stitched_labels.py | 2 +- tests/experimental/test_tiling_qc.py | 4 +-- 13 files changed, 48 insertions(+), 39 deletions(-) diff --git a/src/squidpy/experimental/im/_make_tiles.py b/src/squidpy/experimental/im/_make_tiles.py index 059c32fe2..13d63d0b9 100644 --- a/src/squidpy/experimental/im/_make_tiles.py +++ b/src/squidpy/experimental/im/_make_tiles.py @@ -108,7 +108,7 @@ def _save_tiles_to_shapes( shapes_key: str, ) -> None: """Save a TileGrid to sdata.shapes as a GeoDataFrame.""" - save_tile_grid_to_shapes(sdata, tg, shapes_key, copy_transforms_from_key=image_key) + save_tile_grid_to_shapes(sdata, tg=tg, shapes_key=shapes_key, copy_transforms_from_key=image_key) def _save_spot_tiles_to_shapes( @@ -565,7 +565,7 @@ def _filter_tiles( else: raise ValueError("tissue_mask_key must be provided when image_key is None.") assert_key_in_sdata(sdata, mask_key, attr="labels") - mask = get_mask_materialized(sdata, mask_key, scale) + mask = get_mask_materialized(sdata, mask_key=mask_key, scale=scale) H_mask, W_mask = mask.shape # Check tissue coverage for each tile diff --git a/src/squidpy/experimental/im/_qc_image.py b/src/squidpy/experimental/im/_qc_image.py index 9a1f5052f..f95966190 100644 --- a/src/squidpy/experimental/im/_qc_image.py +++ b/src/squidpy/experimental/im/_qc_image.py @@ -166,8 +166,10 @@ def qc_image( _tissue_binary_da: da.Array | None = None if InputKind.MASK in groups or detect_tissue: - mask_key_resolved = resolve_tissue_mask(sdata, image_key, scale, tissue_mask_key) - raw_mask = get_mask_dask(sdata, mask_key_resolved, scale) + mask_key_resolved = resolve_tissue_mask( + sdata, image_key=image_key, scale=scale, tissue_mask_key=tissue_mask_key + ) + raw_mask = get_mask_dask(sdata, mask_key=mask_key_resolved, scale=scale) _tissue_binary_da = (raw_mask > 0).astype(np.float32).rechunk((tg.ty, tg.tx)) if InputKind.GRAYSCALE in groups: @@ -302,7 +304,7 @@ def qc_image( shapes_key = f"qc_img_{image_key}_grid" # Build shapes first (need the index for tile_id linkage) - save_tile_grid_to_shapes(sdata, tg, shapes_key, copy_transforms_from_key=image_key) + save_tile_grid_to_shapes(sdata, tg=tg, shapes_key=shapes_key, copy_transforms_from_key=image_key) # Set spatialdata linkage on adata BEFORE TableModel.parse adata.obs["grid_name"] = pd.Categorical([shapes_key] * len(adata)) @@ -367,8 +369,10 @@ def _classify_tiles_by_tissue( Boolean arrays of shape ``(n_tiles,)``. """ if binary_mask_da is None: - mask_key_resolved = resolve_tissue_mask(sdata, image_key, scale, tissue_mask_key) - raw_mask = get_mask_dask(sdata, mask_key_resolved, scale) + mask_key_resolved = resolve_tissue_mask( + sdata, image_key=image_key, scale=scale, tissue_mask_key=tissue_mask_key + ) + raw_mask = get_mask_dask(sdata, mask_key=mask_key_resolved, scale=scale) binary_mask_da = (raw_mask > 0).astype(np.float32).rechunk((tg.ty, tg.tx)) H, W = binary_mask_da.shape diff --git a/src/squidpy/experimental/im/_stain/_normalize.py b/src/squidpy/experimental/im/_stain/_normalize.py index b42e7e408..ac2abb5e8 100644 --- a/src/squidpy/experimental/im/_stain/_normalize.py +++ b/src/squidpy/experimental/im/_stain/_normalize.py @@ -85,7 +85,9 @@ def _resolve_mask_key_and_scale( :func:`!detect_tissue` labels element - raises if none exists. """ - mask_key = resolve_tissue_mask(sdata, image_key, "auto", tissue_mask_key, auto_create=False) + mask_key = resolve_tissue_mask( + sdata, image_key=image_key, scale="auto", tissue_mask_key=tissue_mask_key, auto_create=False + ) target_hw = (int(target_da.sizes["y"]), int(target_da.sizes["x"])) label_scale = _choose_label_scale_for_image(sdata.labels[mask_key], target_hw) return mask_key, label_scale, target_hw @@ -103,7 +105,7 @@ def _resolve_tissue_bool_mask( mask_key, label_scale, target_hw = _resolve_mask_key_and_scale( sdata, image_key=image_key, target_da=fit_da, tissue_mask_key=tissue_mask_key ) - mask = get_mask_materialized(sdata, mask_key, label_scale) > 0 + mask = get_mask_materialized(sdata, mask_key=mask_key, scale=label_scale) > 0 if mask.shape != target_hw: from skimage.transform import resize diff --git a/src/squidpy/experimental/im/_utils.py b/src/squidpy/experimental/im/_utils.py index 9616cd120..9c1404ddd 100644 --- a/src/squidpy/experimental/im/_utils.py +++ b/src/squidpy/experimental/im/_utils.py @@ -231,7 +231,7 @@ def flatten_channels( ) -def get_mask_dask(sdata: SpatialData, mask_key: str, scale: str) -> da.Array: +def get_mask_dask(sdata: SpatialData, *, mask_key: str, scale: str) -> da.Array: """Extract mask as a lazy dask array from ``sdata.labels``.""" if mask_key not in sdata.labels: raise KeyError(f"Mask key '{mask_key}' not found in sdata.labels") @@ -249,9 +249,9 @@ def get_mask_dask(sdata: SpatialData, mask_key: str, scale: str) -> da.Array: return arr -def get_mask_materialized(sdata: SpatialData, mask_key: str, scale: str) -> np.ndarray: +def get_mask_materialized(sdata: SpatialData, *, mask_key: str, scale: str) -> np.ndarray: """Extract a 2D mask array from ``sdata.labels`` at the requested scale (materialized).""" - arr = get_mask_dask(sdata, mask_key, scale) + arr = get_mask_dask(sdata, mask_key=mask_key, scale=scale) return np.asarray(arr.compute()) @@ -275,10 +275,10 @@ def _choose_label_scale_for_image(label_node: Labels2DModel, target_hw: tuple[in def resolve_tissue_mask( sdata: SpatialData, + *, image_key: str, scale: str, tissue_mask_key: str | None = None, - *, auto_create: bool = True, ) -> str: """Return the key of a tissue mask in ``sdata.labels``, creating one if needed. @@ -320,9 +320,9 @@ def resolve_tissue_mask( def save_tile_grid_to_shapes( sdata: SpatialData, + *, tg: TileGrid, shapes_key: str, - *, copy_transforms_from_key: str | None = None, ) -> None: """Save a TileGrid to ``sdata.shapes`` as a GeoDataFrame. diff --git a/src/squidpy/experimental/tl/_stitched_labels.py b/src/squidpy/experimental/tl/_stitched_labels.py index 94afa9007..3a224cff6 100644 --- a/src/squidpy/experimental/tl/_stitched_labels.py +++ b/src/squidpy/experimental/tl/_stitched_labels.py @@ -516,7 +516,7 @@ def make_stitched_labels( qc_params = adata.uns.get("tiling_qc", {}) scale = qc_params.get("scale") - labels_da = resolve_labels_array(sdata, labels_key, scale) + labels_da = resolve_labels_array(sdata, labels_key=labels_key, scale=scale) lut = _build_lookup(adata.obs, labels_da.dtype) new_data = _apply_lut(labels_da, lut) diff --git a/src/squidpy/experimental/tl/_tiling_qc.py b/src/squidpy/experimental/tl/_tiling_qc.py index 54807f438..bf12a6221 100644 --- a/src/squidpy/experimental/tl/_tiling_qc.py +++ b/src/squidpy/experimental/tl/_tiling_qc.py @@ -560,7 +560,7 @@ def calculate_tiling_qc( raise ValueError(f"n_neighbors must be >= 1, got {n_neighbors}.") qc_params = _resolve_qc_params(tiling_qc_params) - labels_da = resolve_labels_array(sdata, labels_key, scale) + labels_da = resolve_labels_array(sdata, labels_key=labels_key, scale=scale) cell_info = _compute_centroids_for_labels(sdata, labels_key=labels_key, labels_da=labels_da, scale=scale) if not cell_info: diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index b5a12d231..39cd95002 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -813,7 +813,7 @@ def assign_stitch_groups( # Resolve which labels DataArray was used at QC time (multi-scale aware). qc_params = adata.uns.get("tiling_qc", {}) scale = qc_params.get("scale") - labels_da = resolve_labels_array(sdata, labels_key, scale) + labels_da = resolve_labels_array(sdata, labels_key=labels_key, scale=scale) label_ids = adata.obs["label_id"].astype(int).to_numpy() is_outlier = adata.obs["is_outlier"].to_numpy(dtype=bool) diff --git a/src/squidpy/experimental/utils/_derive_mpp.py b/src/squidpy/experimental/utils/_derive_mpp.py index 4d5e000fc..b5d051ec0 100644 --- a/src/squidpy/experimental/utils/_derive_mpp.py +++ b/src/squidpy/experimental/utils/_derive_mpp.py @@ -21,9 +21,9 @@ def derive_mpp_from_shapes( sdata: sd.SpatialData, + *, shapes_key: str, coordinate_system: str, - *, um_between_centers: float | None = None, um_diameter: float | None = None, um_square_edge: float | None = None, diff --git a/src/squidpy/experimental/utils/_labels.py b/src/squidpy/experimental/utils/_labels.py index 616f64358..6bd3c82d2 100644 --- a/src/squidpy/experimental/utils/_labels.py +++ b/src/squidpy/experimental/utils/_labels.py @@ -56,7 +56,7 @@ def iter_chunked_regionprops( yield lid, region, y0, x0 -def resolve_labels_array(sdata: sd.SpatialData, labels_key: str, scale: str | None) -> xr.DataArray: +def resolve_labels_array(sdata: sd.SpatialData, *, labels_key: str, scale: str | None) -> xr.DataArray: """Resolve a labels element to its 2-D ``xarray.DataArray``. Single-scale elements pass through; multi-scale (``xarray.DataTree``) diff --git a/src/squidpy/pl/_spatial_utils.py b/src/squidpy/pl/_spatial_utils.py index 93a07c09f..e33f04f7d 100644 --- a/src/squidpy/pl/_spatial_utils.py +++ b/src/squidpy/pl/_spatial_utils.py @@ -119,6 +119,7 @@ class SpatialParams(NamedTuple): def _get_library_id( adata: AnnData, + *, shape: _AvailShapes | None, spatial_key: str = Key.uns.spatial, library_id: Sequence[str] | None = None, diff --git a/tests/experimental/test_derive_mpp.py b/tests/experimental/test_derive_mpp.py index 1f1e677c0..8514c4641 100644 --- a/tests/experimental/test_derive_mpp.py +++ b/tests/experimental/test_derive_mpp.py @@ -74,7 +74,7 @@ def test_pitch(lattice, pitch, transform, expected_mpp): centers = lattice(pitch=pitch) transforms = {"global": transform} if transform is not None else None sdata = _make_sdata(_points_shapes(centers, radius=pitch / 4.0), transforms=transforms) - mpp = derive_mpp_from_shapes(sdata, "shapes", "global", um_between_centers=pitch) + mpp = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_between_centers=pitch) assert mpp == pytest.approx(expected_mpp, rel=1e-9) @@ -85,41 +85,41 @@ def test_pitch_large_grid_subsampling(): n = 120 centers = _square_lattice(pitch=pitch, n=n) sdata = _make_sdata(_points_shapes(centers, radius=pitch / 4.0)) - mpp = derive_mpp_from_shapes(sdata, "shapes", "global", um_between_centers=pitch) + mpp = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_between_centers=pitch) assert mpp == pytest.approx(1.0, rel=1e-9) def test_diameter_points(): centers = _hex_lattice(pitch=100.0) sdata = _make_sdata(_points_shapes(centers, radius=27.5), transforms={"global": Scale([4.0, 4.0], axes=("x", "y"))}) - mpp = derive_mpp_from_shapes(sdata, "shapes", "global", um_diameter=55.0) + mpp = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_diameter=55.0) assert mpp == pytest.approx(0.25, rel=1e-9) def test_square_edge_polygons(): centers = _square_lattice(pitch=8.0) sdata = _make_sdata(_square_polygons_shapes(centers, edge=8.0)) - mpp = derive_mpp_from_shapes(sdata, "shapes", "global", um_square_edge=8.0) + mpp = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_square_edge=8.0) assert mpp == pytest.approx(1.0, rel=1e-9) def test_um_diameter_on_polygons_rejected(): sdata = _make_sdata(_square_polygons_shapes(_square_lattice(pitch=8.0), edge=8.0)) with pytest.raises(ValueError, match=r"um_diameter.*Point geometries"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_diameter=8.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_diameter=8.0) def test_um_square_edge_on_points_rejected(): sdata = _make_sdata(_points_shapes(_square_lattice(pitch=8.0), radius=1.0)) with pytest.raises(ValueError, match=r"um_square_edge.*Polygon geometries"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_square_edge=8.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_square_edge=8.0) def test_um_square_edge_on_non_square_polygons_rejected(): centers = _square_lattice(pitch=10.0) sdata = _make_sdata(_hex_polygons_shapes(centers, edge=3.0)) with pytest.raises(ValueError, match=r"square/rectangular polygons"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_square_edge=3.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_square_edge=3.0) def test_coordinate_system_selection(): @@ -127,8 +127,10 @@ def test_coordinate_system_selection(): _points_shapes(_square_lattice(pitch=8.0), radius=1.0), transforms={"native": Identity(), "downscaled": Scale([0.5, 0.5], axes=("x", "y"))}, ) - mpp_native = derive_mpp_from_shapes(sdata, "shapes", "native", um_between_centers=8.0) - mpp_down = derive_mpp_from_shapes(sdata, "shapes", "downscaled", um_between_centers=8.0) + mpp_native = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="native", um_between_centers=8.0) + mpp_down = derive_mpp_from_shapes( + sdata, shapes_key="shapes", coordinate_system="downscaled", um_between_centers=8.0 + ) assert mpp_down / mpp_native == pytest.approx(2.0, rel=1e-9) @@ -138,7 +140,7 @@ def test_anisotropy_rejected(): transforms={"global": Scale([2.0, 4.0], axes=("x", "y"))}, ) with pytest.raises(ValueError, match=r"anisotropic"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_between_centers=8.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_between_centers=8.0) def test_rotation_preserved(): @@ -153,7 +155,7 @@ def test_rotation_preserved(): _points_shapes(_hex_lattice(pitch=100.0), radius=27.5), transforms={"global": Sequence([Scale([2.0, 2.0], axes=("x", "y")), rotation])}, ) - mpp = derive_mpp_from_shapes(sdata, "shapes", "global", um_between_centers=100.0) + mpp = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_between_centers=100.0) assert mpp == pytest.approx(0.5, rel=1e-6) @@ -163,7 +165,7 @@ def test_three_d_rejected(): ) sdata = SpatialData(shapes={"shapes": gdf}) with pytest.raises(ValueError, match=r"3D"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_between_centers=1.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_between_centers=1.0) def test_multipolygon_rejected(): @@ -172,18 +174,18 @@ def test_multipolygon_rejected(): gdf = ShapesModel.parse(gpd.GeoDataFrame(geometry=[MultiPolygon([p1, p2])])) sdata = SpatialData(shapes={"shapes": gdf}) with pytest.raises(ValueError, match=r"MultiPolygon"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_diameter=1.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_diameter=1.0) def test_single_shape_pitch_rejected(): sdata = _make_sdata(_points_shapes(np.array([[0.0, 0.0]]), radius=1.0)) with pytest.raises(ValueError, match=r"single shape"): - derive_mpp_from_shapes(sdata, "shapes", "global", um_between_centers=100.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_between_centers=100.0) def test_single_shape_diameter_works(): sdata = _make_sdata(_points_shapes(np.array([[0.0, 0.0]]), radius=27.5)) - mpp = derive_mpp_from_shapes(sdata, "shapes", "global", um_diameter=55.0) + mpp = derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", um_diameter=55.0) assert mpp == pytest.approx(1.0, rel=1e-9) @@ -200,7 +202,7 @@ def test_single_shape_diameter_works(): def test_mutex_args_rejected(kwargs): sdata = _make_sdata(_points_shapes(_square_lattice(pitch=8.0), radius=1.0)) with pytest.raises(ValueError, match=r"exactly one"): - derive_mpp_from_shapes(sdata, "shapes", "global", **kwargs) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="global", **kwargs) def test_unknown_coordinate_system(): @@ -209,4 +211,4 @@ def test_unknown_coordinate_system(): transforms={"native": Identity(), "downscaled": Scale([0.5, 0.5], axes=("x", "y"))}, ) with pytest.raises(ValueError, match=r"native.*downscaled|downscaled.*native"): - derive_mpp_from_shapes(sdata, "shapes", "missing", um_between_centers=8.0) + derive_mpp_from_shapes(sdata, shapes_key="shapes", coordinate_system="missing", um_between_centers=8.0) diff --git a/tests/experimental/test_stitched_labels.py b/tests/experimental/test_stitched_labels.py index 61bc0282c..119a94a01 100644 --- a/tests/experimental/test_stitched_labels.py +++ b/tests/experimental/test_stitched_labels.py @@ -490,7 +490,7 @@ def test_multiscale_output_carries_resolved_scale_transform(self): sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels") res = sq.experimental.tl.make_stitched_labels(sdata, labels_key="labels", write_table=False, inplace=False) out = res["labels"] - resolved = resolve_labels_array(sdata, "labels", "scale1") + resolved = resolve_labels_array(sdata, labels_key="labels", scale="scale1") def _affine(elem): t = next(iter(get_transformation(elem, get_all=True).values())) diff --git a/tests/experimental/test_tiling_qc.py b/tests/experimental/test_tiling_qc.py index b0ec4adcb..4326b74e8 100644 --- a/tests/experimental/test_tiling_qc.py +++ b/tests/experimental/test_tiling_qc.py @@ -283,7 +283,7 @@ class TestResolveLabelsArray: def test_single_scale_passthrough(self, sdata_clean): from squidpy.experimental.utils._labels import resolve_labels_array - da = resolve_labels_array(sdata_clean, "labels", scale=None) + da = resolve_labels_array(sdata_clean, labels_key="labels", scale=None) assert da is sdata_clean.labels["labels"] def test_multi_scale_without_scale_raises(self): @@ -301,7 +301,7 @@ def test_multi_scale_without_scale_raises(self): sdata = SpatialData(labels={"labels": ms_labels}) with pytest.raises(ValueError, match="multi-scale"): - resolve_labels_array(sdata, "labels", scale=None) + resolve_labels_array(sdata, labels_key="labels", scale=None) # Tiled centroid backend From c6e312ed9156b7a5c5e19576cc2bd0abdbc80525 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 11 Sep 2026 15:58:04 +0200 Subject: [PATCH 08/28] Numbafy spatial_autocorr permutations One njit(parallel=True, nogil=True) kernel over the permutations replaces the loky pool. Both statistics sum, over the rows of g[perm, :], a per-row quantity the permutation only reindexes, so accumulating it once leaves O(n_cells) per permutation instead of O(nnz): 21-34x end to end. pval_sim, var_sim and the FDR columns are bit-identical; the continuous columns agree to <=5e-10 relative, which is the float reordering scanpy's own kernels already have across thread counts. Reductions are explicit serial loops so the output no longer depends on n_jobs. Sparse vals arrive as CSC, whose row slicing is O(nnz); one tocsr saves 7.9s of a 19s call at 40k cells x 2000 genes. backend is deprecated as co_occurrence and nhood_enrichment deprecate it, and n_jobs now caps the numba thread pool. --- src/squidpy/gr/_ppatterns.py | 174 ++++++++++++++++++++++++++-------- tests/graph/test_ppatterns.py | 63 +++++++++++- 2 files changed, 194 insertions(+), 43 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index e14d1e17d..20e29a425 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -13,11 +13,12 @@ from scanpy import logging as logg from scanpy.metrics import gearys_c, morans_i from scipy import stats -from scipy.sparse import spmatrix +from scipy.sparse import issparse, spmatrix from sklearn.metrics import pairwise_distances from sklearn.preprocessing import normalize from spatialdata import SpatialData from statsmodels.stats.multitest import multipletests +from tqdm.auto import tqdm from squidpy._compat import old_positionals from squidpy._constants._constants import SpatialAutocorr @@ -27,12 +28,10 @@ NDArrayA, RNGLike, SeedLike, - Signal, - SigQueue, deprecated_params, deprecated_randomness_param, - get_n_processes, - parallelize, + get_n_numba_threads, + numba_threads, ) from squidpy._validators import assert_key_in_adata, assert_positive from squidpy.gr._utils import ( @@ -73,6 +72,7 @@ "backend", "show_progress_bar", ) +@deprecated_params({"backend": "1.10.0"}) @deprecated_randomness_param def spatial_autocorr( adata: AnnData | SpatialData, @@ -90,7 +90,6 @@ def spatial_autocorr( use_raw: bool = False, copy: bool = False, n_jobs: int | None = None, - backend: str = "loky", show_progress_bar: bool = True, table_key: str | None = None, ) -> pd.DataFrame | None: @@ -149,7 +148,8 @@ def spatial_autocorr( Which attribute of :class:`~anndata.AnnData` to access. See ``genes`` parameter for more information. %(rng)s %(copy)s - %(parallelize)s + %(n_jobs_threads)s + %(show_progress_bar)s Returns ------- @@ -238,21 +238,13 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr score = params["func"](g, vals) # type: ignore - n_jobs = get_n_processes(n_jobs) - start = logg.info(f"Calculating {mode}'s statistic for `{n_perms}` permutations using `{n_jobs}` core(s)") + n_jobs = get_n_numba_threads(n_jobs) + start = logg.info(f"Calculating {mode}'s statistic for `{n_perms}` permutations using `{n_jobs}` thread(s)") if n_perms is not None: assert_positive(n_perms, name="n_perms") - perms = list(np.arange(n_perms)) - rngs = np.random.default_rng(rng).spawn(n_perms) - - score_perms = parallelize( - _score_helper, - collection=perms, - extractor=np.concatenate, - n_jobs=n_jobs, - backend=backend, - show_progress_bar=show_progress_bar, - )(mode=mode, g=g, vals=vals, rngs=rngs) + score_perms = _score_perms( + g, vals, mode=mode, n_perms=n_perms, rng=rng, n_jobs=n_jobs, show_progress_bar=show_progress_bar + ) else: score_perms = None @@ -278,30 +270,128 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr _save_data(adata, attr="uns", key=mode_str + stat_str, data=df, time=start) -def _score_helper( - perms: Sequence[int], - mode: SpatialAutocorr, - g: spmatrix, - vals: NDArrayA, - rngs: Sequence[np.random.Generator], - *, - queue: SigQueue | None = None, -) -> pd.DataFrame: - score_perms = np.empty((len(perms), vals.shape[0])) - func = morans_i if mode == SpatialAutocorr.MORAN else gearys_c - - for i, p in enumerate(perms): - rng = rngs[p] - idx_shuffle = rng.permutation(g.shape[0]) - score_perms[i, :] = func(g[idx_shuffle, :], vals) - - if queue is not None: - queue.put(Signal.UPDATE) +@njit(parallel=True, nogil=True, cache=True) +def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments + indptr: NDArrayA, + indices: NDArrayA, + data: NDArrayA, + x: NDArrayA, + w: float, + perms: NDArrayA, + moran: bool, +) -> NDArrayA: + """Permutation scores of one feature over the row-permuted graph, one per row of ``perms``. - if queue is not None: - queue.put(Signal.FINISH) + Both statistics sum, over the rows of ``g[perm, :]``, a per-row quantity that depends on the + permutation only through *which* row is visited. Accumulating those quantities once, per row of + the unpermuted graph, leaves ``O(n_cells)`` per permutation instead of a full ``O(nnz)`` pass, + and lets numba own the parallelism with the GIL released. - return score_perms + ``x`` must already be :class:`numpy.float64` and ``data`` the float64 graph weights, matching + the casts :mod:`scanpy` applies before its own kernels. + """ + n_perms, n = perms.shape + out = np.empty(n_perms, dtype=np.float64) + + # The reductions below are written as explicit serial loops on purpose: under ``parallel=True`` + # numba turns ``arr.sum()``/``arr.mean()`` into parallel reductions, whose result depends on the + # thread count. Summing in index order keeps the output identical for any ``n_jobs``. + x_bar = 0.0 + for i in range(n): + x_bar += x[i] + x_bar /= n + + if moran: + # I = n / W * sum_ij w_ij z_i z_j / sum_i z_i^2. The inner row sum is the spatial lag, + # which the permutation only reindexes. + z = x - x_bar + z2ss = 0.0 + for i in range(n): + z2ss += z[i] * z[i] + lag = np.zeros(n, dtype=np.float64) + for k in range(n): + acc = 0.0 + for e in range(indptr[k], indptr[k + 1]): + acc += data[e] * z[indices[e]] + lag[k] = acc + for p in prange(n_perms): + inum = 0.0 + for i in range(n): + inum += lag[perms[p, i]] * z[i] + out[p] = n / w * inum / z2ss + else: + # C = (n - 1) * sum_ij w_ij (x_i - x_j)^2 / (2 W sum_i (x_i - x_bar)^2). Expanding the + # square splits each row into (sum w, sum w x_j, sum w x_j^2), all permutation-independent. + w_sum = np.zeros(n, dtype=np.float64) + wx = np.zeros(n, dtype=np.float64) + wx2 = np.zeros(n, dtype=np.float64) + for k in range(n): + acc_w = 0.0 + acc_x = 0.0 + acc_x2 = 0.0 + for e in range(indptr[k], indptr[k + 1]): + weight = data[e] + xj = x[indices[e]] + acc_w += weight + acc_x += weight * xj + acc_x2 += weight * xj * xj + w_sum[k] = acc_w + wx[k] = acc_x + wx2[k] = acc_x2 + css = 0.0 + for i in range(n): + centered = x[i] - x_bar + css += centered * centered + denom = 2.0 * w * css + for p in prange(n_perms): + total = 0.0 + for i in range(n): + k = perms[p, i] + xi = x[i] + total += xi * xi * w_sum[k] - 2.0 * xi * wx[k] + wx2[k] + out[p] = (n - 1) * total / denom + return out + + +def _score_perms( + g: spmatrix, + vals: NDArrayA | spmatrix, + *, + mode: SpatialAutocorr, + n_perms: int, + rng: SeedLike | RNGLike | None, + n_jobs: int, + show_progress_bar: bool, +) -> NDArrayA: + """Permutation scores for every feature, shaped ``(n_perms, n_features)``.""" + n_cells = g.shape[0] + # Match the casts scanpy applies to its own inputs, so the kernel sees the same numbers. + g = g.astype(np.float64, copy=False) + w = g.data.sum() + + # ponytail: the permutations are materialized up front (n_perms x n_cells int32) so that every + # feature is scored against the same shuffles without re-drawing them; chunk over permutations + # if that array ever outgrows memory. + perms = np.empty((n_perms, n_cells), dtype=np.int32) + for p, generator in enumerate(np.random.default_rng(rng).spawn(n_perms)): + perms[p] = generator.permutation(n_cells) + + moran = mode == SpatialAutocorr.MORAN + sparse_vals = issparse(vals) + if sparse_vals: + # ``vals`` arrives as ``X.T``, i.e. CSC, whose row slicing is O(nnz) rather than O(nnz_row); + # one conversion here makes the per-feature extraction below ~180x cheaper. + vals = vals.tocsr() + # Constant features have a zero denominator; scanpy drops them and reports `nan`, so seed with it. + out = np.full((n_perms, vals.shape[0]), np.nan, dtype=np.float64) + with numba_threads(n_jobs): + for m in tqdm(range(vals.shape[0]), unit="feature", disable=not show_progress_bar): + x = vals[m].toarray().ravel() if sparse_vals else vals[m] + x = np.ascontiguousarray(x, dtype=np.float64) + if x.min() == x.max(): + continue + out[:, m] = _autocorr_perms(g.indptr, g.indices, g.data, x, w, perms, moran) + return out @njit(parallel=True, fastmath=True, cache=True) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 2d58a6331..54f3cba76 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -9,7 +9,7 @@ from squidpy._constants._pkg_constants import Key from squidpy.gr import co_occurrence, spatial_autocorr -from squidpy.gr._ppatterns import _find_min_max +from squidpy.gr._ppatterns import _find_min_max, _score_perms MORAN_K = "moranI" GEARY_C = "gearyC" @@ -218,3 +218,64 @@ def test_use_raw(dummy_adata: AnnData): df = spatial_autocorr(dummy_adata, use_raw=True, copy=True) np.testing.assert_equal(sorted(df.index), sorted(var_names)) + + +@pytest.mark.parametrize("mode", ["moran", "geary"]) +def test_score_perms_matches_scanpy_per_permutation(mode: str): + """The kernel reuses per-row sums across permutations; pin it to the naive scanpy reference. + + ``_score_perms`` accumulates each row of the graph once and reindexes those sums per + permutation. The reference below is the definition it replaced: build ``g[perm, :]`` and hand it + to :mod:`scanpy` for every permutation. Agreement has to hold to floating-point reordering only. + """ + from scanpy.metrics import gearys_c, morans_i + from scipy.sparse import csr_matrix + from sklearn.neighbors import kneighbors_graph + from sklearn.preprocessing import normalize + + from squidpy._constants._constants import SpatialAutocorr + + n, n_genes, n_perms = 300, 4, 6 + rng = np.random.default_rng(0) + g = csr_matrix(kneighbors_graph(rng.random((n, 2)), 5, mode="connectivity")) + normalize(g, norm="l1", axis=1, copy=False) + vals = rng.random((n_genes, n), dtype=np.float32) + + autocorr = SpatialAutocorr(mode) + got = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) + + func = morans_i if autocorr == SpatialAutocorr.MORAN else gearys_c + expected = np.stack([func(g[gen.permutation(n), :], vals) for gen in np.random.default_rng(0).spawn(n_perms)]) + assert got.shape == (n_perms, n_genes) + np.testing.assert_allclose(got, expected, rtol=1e-9) + + +@pytest.mark.parametrize("mode", ["moran", "geary"]) +def test_score_perms_thread_invariant(mode: str): + """Permutation scores must not depend on how many numba threads the kernel runs on.""" + from scipy.sparse import csr_matrix + from sklearn.neighbors import kneighbors_graph + from sklearn.preprocessing import normalize + + from squidpy._constants._constants import SpatialAutocorr + + n, n_perms = 300, 8 + rng = np.random.default_rng(0) + g = csr_matrix(kneighbors_graph(rng.random((n, 2)), 5, mode="connectivity")) + normalize(g, norm="l1", axis=1, copy=False) + vals = rng.random((3, n), dtype=np.float32) + + autocorr = SpatialAutocorr(mode) + serial = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) + threaded = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=4, show_progress_bar=False) + np.testing.assert_array_equal(serial, threaded) + + +def test_spatial_autocorr_backend_deprecated(dummy_adata: AnnData): + """``backend`` no longer selects a process pool; it warns and is ignored.""" + with pytest.warns(FutureWarning, match=r"`backend`.*deprecated"): + with_backend = spatial_autocorr( + dummy_adata, copy=True, n_perms=10, rng=0, backend="loky", show_progress_bar=False + ) + without = spatial_autocorr(dummy_adata, copy=True, n_perms=10, rng=0, show_progress_bar=False) + assert_frame_equal(with_backend, without) From 973fdc502e35d3a1e19683e1c364bf8d95bc4128 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Wed, 30 Sep 2026 16:18:28 +0200 Subject: [PATCH 09/28] Score the observed statistic with the permutation kernel, block the permutations, keep backend in the shim --- src/squidpy/gr/_ppatterns.py | 63 ++++++++++++++++++----------- tests/graph/test_ppatterns.py | 75 ++++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 25 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 20e29a425..df8df6ea9 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -52,6 +52,8 @@ fp = np.float32 bl = nt.boolean +_PERM_BLOCK_SIZE = 2**26 # permutation entries held at once in `spatial_autocorr` + @d.dedent @inject_docs(key=Key.obsp.spatial_conn(), sp=SpatialAutocorr) @@ -72,8 +74,8 @@ "backend", "show_progress_bar", ) -@deprecated_params({"backend": "1.10.0"}) @deprecated_randomness_param +@deprecated_params({"backend": "1.10.0"}) def spatial_autocorr( adata: AnnData | SpatialData, *, @@ -109,6 +111,10 @@ def spatial_autocorr( %(rng_versionchanged)s + .. versionchanged:: 1.8.4 + Permutations run on numba threads, and ``n_jobs = None`` now uses all ``NUMBA_NUM_THREADS`` + threads instead of one process. Pass ``n_jobs = 1`` for the old serial default. + Parameters ---------- %(adata)s @@ -236,17 +242,16 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr if transformation: # row-normalize normalize(g, norm="l1", axis=1, copy=False) - score = params["func"](g, vals) # type: ignore - n_jobs = get_n_numba_threads(n_jobs) start = logg.info(f"Calculating {mode}'s statistic for `{n_perms}` permutations using `{n_jobs}` thread(s)") if n_perms is not None: assert_positive(n_perms, name="n_perms") - score_perms = _score_perms( + # the observed score comes from the same kernel as the permuted ones, so `sims >= score` sees exact ties + score, score_perms = _score_perms( g, vals, mode=mode, n_perms=n_perms, rng=rng, n_jobs=n_jobs, show_progress_bar=show_progress_bar ) else: - score_perms = None + score, score_perms = params["func"](g, vals), None # type: ignore with np.errstate(divide="ignore"): pval_results = _p_value_calc(score, score_perms, g, params) @@ -362,19 +367,18 @@ def _score_perms( rng: SeedLike | RNGLike | None, n_jobs: int, show_progress_bar: bool, -) -> NDArrayA: - """Permutation scores for every feature, shaped ``(n_perms, n_features)``.""" +) -> tuple[NDArrayA, NDArrayA]: + """Observed scores ``(n_features,)`` and permutation scores ``(n_perms, n_features)``.""" n_cells = g.shape[0] # Match the casts scanpy applies to its own inputs, so the kernel sees the same numbers. g = g.astype(np.float64, copy=False) w = g.data.sum() - - # ponytail: the permutations are materialized up front (n_perms x n_cells int32) so that every - # feature is scored against the same shuffles without re-drawing them; chunk over permutations - # if that array ever outgrows memory. - perms = np.empty((n_perms, n_cells), dtype=np.int32) - for p, generator in enumerate(np.random.default_rng(rng).spawn(n_perms)): - perms[p] = generator.permutation(n_cells) + generators = np.random.default_rng(rng).spawn(n_perms) + # ponytail: permutations are drawn in blocks of at most 256 MB (int32), and every feature is + # re-extracted once per block; the block count only exceeds 1 past ~250k cells x 256 perms. + block = int(np.clip(_PERM_BLOCK_SIZE // max(n_cells, 1), 1, n_perms)) + buffer = np.empty((block, n_cells), dtype=np.int32) + identity = np.arange(n_cells, dtype=np.int32)[None] moran = mode == SpatialAutocorr.MORAN sparse_vals = issparse(vals) @@ -382,16 +386,29 @@ def _score_perms( # ``vals`` arrives as ``X.T``, i.e. CSC, whose row slicing is O(nnz) rather than O(nnz_row); # one conversion here makes the per-feature extraction below ~180x cheaper. vals = vals.tocsr() + n_features = vals.shape[0] # Constant features have a zero denominator; scanpy drops them and reports `nan`, so seed with it. - out = np.full((n_perms, vals.shape[0]), np.nan, dtype=np.float64) - with numba_threads(n_jobs): - for m in tqdm(range(vals.shape[0]), unit="feature", disable=not show_progress_bar): - x = vals[m].toarray().ravel() if sparse_vals else vals[m] - x = np.ascontiguousarray(x, dtype=np.float64) - if x.min() == x.max(): - continue - out[:, m] = _autocorr_perms(g.indptr, g.indices, g.data, x, w, perms, moran) - return out + score = np.full(n_features, np.nan, dtype=np.float64) + out = np.full((n_perms, n_features), np.nan, dtype=np.float64) + n_blocks = -(-n_perms // block) + with ( + numba_threads(n_jobs), + tqdm(total=n_blocks * n_features, unit="feature", disable=not show_progress_bar) as pbar, + ): + for lo in range(0, n_perms, block): + perms = buffer[: min(block, n_perms - lo)] + for i in range(len(perms)): + perms[i] = generators[lo + i].permutation(n_cells) + for m in range(n_features): + pbar.update() + x = vals[m].toarray().ravel() if sparse_vals else vals[m] + x = np.ascontiguousarray(x, dtype=np.float64) + if x.min() == x.max(): + continue + if lo == 0: + score[m] = _autocorr_perms(g.indptr, g.indices, g.data, x, w, identity, moran)[0] + out[lo : lo + len(perms), m] = _autocorr_perms(g.indptr, g.indices, g.data, x, w, perms, moran) + return score, out @njit(parallel=True, fastmath=True, cache=True) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 54f3cba76..8d207f7d1 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -107,6 +107,75 @@ def test_spatial_autocorr_n_jobs_invariance(dummy_adata: AnnData, mode: str): np.testing.assert_allclose(df_serial[col].values, df_parallel[col].values, atol=1e-12) +def test_spatial_autocorr_ties_match_scanpy(): + """A gene with one non-zero count ties most permutations exactly; ties must count as in scanpy.""" + import scipy.sparse as sps + from scanpy.metrics import gearys_c + from sklearn.preprocessing import normalize + + from squidpy.gr import spatial_neighbors_knn + + rng = np.random.default_rng(0) + X = np.zeros((300, 2), dtype=np.float32) + X[0, 0] = 5 + X[:, 1] = rng.poisson(2, 300) + adata = AnnData(sps.csr_matrix(X)) + adata.obsm["spatial"] = rng.random((300, 2)) + spatial_neighbors_knn(adata, n_neighs=6) + df = spatial_autocorr(adata, mode="geary", n_perms=50, rng=0, copy=True).loc[adata.var_names] + + # the pre-numba algorithm: scanpy on the row-permuted graph, one spawned generator per permutation + g = normalize(adata.obsp["spatial_connectivities"], norm="l1", axis=1) + vals = adata.X.T + obs = gearys_c(g, vals) + sims = np.stack([gearys_c(g[gen.permutation(300), :], vals) for gen in np.random.default_rng(0).spawn(50)]) + large = (sims >= obs).sum(axis=0) + large = np.minimum(large, 50 - large) + assert large[0] > 0 # the scenario really has ties + np.testing.assert_array_equal(df["pval_sim"].values, (large + 1) / 51) + + +@pytest.mark.parametrize("mode", ["moran", "geary"]) +def test_spatial_autocorr_perm_blocks(dummy_adata: AnnData, mode: str, monkeypatch): + """Drawing the permutations block by block changes neither the result nor its n_jobs invariance.""" + import squidpy.gr._ppatterns as ppatterns + + kw = {"mode": mode, "copy": True, "rng": 42, "n_perms": 50} + expected = spatial_autocorr(dummy_adata, **kw) + monkeypatch.setattr(ppatterns, "_PERM_BLOCK_SIZE", 7 * dummy_adata.n_obs) # 8 blocks, the last one short + for n_jobs in (1, 2): + assert_frame_equal(spatial_autocorr(dummy_adata, n_jobs=n_jobs, **kw), expected) + + +def test_spatial_autocorr_v183_positional_backend(dummy_adata: AnnData): + """A v1.8.3 positional call through ``backend`` binds every value and warns about ``backend``.""" + kw = {"mode": "moran", "n_perms": 20, "rng": 0, "copy": True, "n_jobs": 1, "show_progress_bar": False} + expected = spatial_autocorr(dummy_adata, **kw) + args = ( + "spatial_connectivities", + None, + "moran", + True, + 20, + False, + "fdr_bh", + "X", + None, + 0, + False, + True, + 1, + "loky", + False, + ) + with pytest.warns(FutureWarning) as record: + got = spatial_autocorr(dummy_adata, *args) + messages = [str(w.message) for w in record] + assert any("`backend`" in m for m in messages) + assert any("`seed`" in m for m in messages) + assert_frame_equal(got, expected) + + @pytest.mark.parametrize("mode", ["moran", "geary"]) def test_spatial_autocorr_var_norm_formula(dummy_adata: AnnData, mode: str): """Analytic ``var_norm`` must use the variance matching the chosen statistic. @@ -242,11 +311,12 @@ def test_score_perms_matches_scanpy_per_permutation(mode: str): vals = rng.random((n_genes, n), dtype=np.float32) autocorr = SpatialAutocorr(mode) - got = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) + observed, got = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) func = morans_i if autocorr == SpatialAutocorr.MORAN else gearys_c expected = np.stack([func(g[gen.permutation(n), :], vals) for gen in np.random.default_rng(0).spawn(n_perms)]) assert got.shape == (n_perms, n_genes) + np.testing.assert_allclose(observed, func(g, vals), rtol=1e-9) np.testing.assert_allclose(got, expected, rtol=1e-9) @@ -268,7 +338,8 @@ def test_score_perms_thread_invariant(mode: str): autocorr = SpatialAutocorr(mode) serial = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) threaded = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=4, show_progress_bar=False) - np.testing.assert_array_equal(serial, threaded) + np.testing.assert_array_equal(serial[0], threaded[0]) + np.testing.assert_array_equal(serial[1], threaded[1]) def test_spatial_autocorr_backend_deprecated(dummy_adata: AnnData): From b97a1f393c47adee04426591e91c35070e2fa996 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Thu, 1 Oct 2026 14:08:33 +0200 Subject: [PATCH 10/28] Name the spawned permutation generators rngs --- src/squidpy/gr/_ppatterns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index df8df6ea9..41c41eea5 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -373,7 +373,7 @@ def _score_perms( # Match the casts scanpy applies to its own inputs, so the kernel sees the same numbers. g = g.astype(np.float64, copy=False) w = g.data.sum() - generators = np.random.default_rng(rng).spawn(n_perms) + rngs = np.random.default_rng(rng).spawn(n_perms) # ponytail: permutations are drawn in blocks of at most 256 MB (int32), and every feature is # re-extracted once per block; the block count only exceeds 1 past ~250k cells x 256 perms. block = int(np.clip(_PERM_BLOCK_SIZE // max(n_cells, 1), 1, n_perms)) @@ -398,7 +398,7 @@ def _score_perms( for lo in range(0, n_perms, block): perms = buffer[: min(block, n_perms - lo)] for i in range(len(perms)): - perms[i] = generators[lo + i].permutation(n_cells) + perms[i] = rngs[lo + i].permutation(n_cells) for m in range(n_features): pbar.update() x = vals[m].toarray().ravel() if sparse_vals else vals[m] From b845c6e3e6d5d6d7f104e3ec4340a512ea44bb6e Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 9 Oct 2026 18:09:17 +0200 Subject: [PATCH 11/28] Read connectivities as CSR The permutation kernel indexes indptr/indices/data as CSR, and sklearn's row-normalize silently no-ops on a CSC graph. tocsr(copy=True) keeps the copy normalize(copy=False) needs. --- src/squidpy/gr/_ppatterns.py | 2 +- tests/graph/test_ppatterns.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 41c41eea5..3a2c93e86 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -238,7 +238,7 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr else: raise NotImplementedError(f"Mode `{mode}` is not yet implemented.") - g = adata.obsp[connectivity_key].copy() + g = adata.obsp[connectivity_key].tocsr(copy=True) # the permutation kernel reads CSR; sklearn's row-normalize is a no-op on CSC if transformation: # row-normalize normalize(g, norm="l1", axis=1, copy=False) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 8d207f7d1..4c3ad8c3b 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -147,6 +147,20 @@ def test_spatial_autocorr_perm_blocks(dummy_adata: AnnData, mode: str, monkeypat assert_frame_equal(spatial_autocorr(dummy_adata, n_jobs=n_jobs, **kw), expected) +@pytest.mark.parametrize("mode", ["moran", "geary"]) +def test_spatial_autocorr_csc_connectivities(dummy_adata: AnnData, mode: str): + """A CSC graph must give the CSR result: the kernel reads CSR and sklearn's row-normalize skips CSC.""" + key = Key.obsp.spatial_conn() + kw = {"mode": mode, "copy": True, "rng": 42, "n_perms": 50} + csc = dummy_adata.copy() + csc.obsp[key] = csc.obsp[key].tocsc() + before = csc.obsp[key].copy() + + assert_frame_equal(spatial_autocorr(csc, **kw), spatial_autocorr(dummy_adata, **kw)) + # row-normalization must not leak back into the caller's graph + np.testing.assert_array_equal(csc.obsp[key].toarray(), before.toarray()) + + def test_spatial_autocorr_v183_positional_backend(dummy_adata: AnnData): """A v1.8.3 positional call through ``backend`` binds every value and warns about ``backend``.""" kw = {"mode": "moran", "n_perms": 20, "rng": 0, "copy": True, "n_jobs": 1, "show_progress_bar": False} From 4980258bc9ff6f1499da88c1f4f4c08f5d77c570 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 9 Oct 2026 18:23:32 +0200 Subject: [PATCH 12/28] Parallelize the per-row precomputes Each k owns its output slot and sums its own row in index order, so prange does not reorder any accumulation. Bit-identical; worth 1.4-1.7x once the permutations are blocked and the serial precompute repeats. --- src/squidpy/gr/_ppatterns.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 3a2c93e86..041c9beb2 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -300,7 +300,9 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments # The reductions below are written as explicit serial loops on purpose: under ``parallel=True`` # numba turns ``arr.sum()``/``arr.mean()`` into parallel reductions, whose result depends on the - # thread count. Summing in index order keeps the output identical for any ``n_jobs``. + # thread count. Summing in index order keeps the output identical for any ``n_jobs``. The + # per-row precomputes further down are ``prange`` instead: each ``k`` owns its output slot and + # accumulates its own row in index order, so they parallelize without reordering any sum. x_bar = 0.0 for i in range(n): x_bar += x[i] @@ -314,7 +316,7 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments for i in range(n): z2ss += z[i] * z[i] lag = np.zeros(n, dtype=np.float64) - for k in range(n): + for k in prange(n): acc = 0.0 for e in range(indptr[k], indptr[k + 1]): acc += data[e] * z[indices[e]] @@ -330,7 +332,7 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments w_sum = np.zeros(n, dtype=np.float64) wx = np.zeros(n, dtype=np.float64) wx2 = np.zeros(n, dtype=np.float64) - for k in range(n): + for k in prange(n): acc_w = 0.0 acc_x = 0.0 acc_x2 = 0.0 From 32b9bb9d4a1b8d215dc2f72bf9da85a14004bd6a Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 02:36:40 +0200 Subject: [PATCH 13/28] Score permutations from feature non-zeros Both statistics split into a term that only touches a feature's non-zero cells and a term the permutation leaves invariant, so a permutation costs O(nnz_feature) rather than O(n_cells) and Geary's wx2 term leaves the loop entirely. The precompute scatters g's columns for the same reason, which also means a feature is never densified. `w_sum` and `col_sum` do not depend on the feature, so they move out of the kernel; the O(n_cells) running totals become O(nnz) through `col_sum`. Features are independent and the kernel is nogil, so parallelism moves up to a thread pool over features -- measured faster than an in-kernel prange even when there are fewer features than workers. Permutation draws fan out the same way. The permutation scores are only ever reduced along the permutation axis, so `_score_perms` keeps a tally and a running mean/M2 per feature instead of the (n_perms, n_features) matrix. pval_sim and the FDR columns stay bit-identical; var_sim and pval_z_sim move by ~1e-11 relative on Moran's I, whose expectation sits near zero. --- src/squidpy/gr/_ppatterns.py | 249 ++++++++++++++++++++++++---------- tests/graph/test_ppatterns.py | 40 +++++- 2 files changed, 210 insertions(+), 79 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 041c9beb2..573a77d9e 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from concurrent.futures import ThreadPoolExecutor from typing import Any, Literal import numba.types as nt @@ -52,7 +53,7 @@ fp = np.float32 bl = nt.boolean -_PERM_BLOCK_SIZE = 2**26 # permutation entries held at once in `spatial_autocorr` +_PERM_BLOCK_SIZE = 2**28 # permutation entries held at once in `spatial_autocorr`, i.e. 1 GiB of int32 @d.dedent @@ -190,6 +191,10 @@ def extract_X(adata: AnnData, genes: str | Sequence[str] | None) -> tuple[NDArra genes = [genes] if not use_raw: + # `genes is None` resolves to every gene, and `adata[:, genes]` would then copy the + # whole matrix and reindex its columns to produce exactly what it was given. + if len(genes) == adata.n_vars and np.array_equal(np.asarray(genes), adata.var_names.values): + return (adata.X if layer is None else adata.layers[layer]).T, genes subset = adata[:, genes] return (subset.X if layer is None else subset.layers[layer]).T, genes if adata.raw is None: @@ -238,7 +243,9 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr else: raise NotImplementedError(f"Mode `{mode}` is not yet implemented.") - g = adata.obsp[connectivity_key].tocsr(copy=True) # the permutation kernel reads CSR; sklearn's row-normalize is a no-op on CSC + g = adata.obsp[connectivity_key].tocsr( + copy=True + ) # the permutation kernel reads CSR; sklearn's row-normalize is a no-op on CSC if transformation: # row-normalize normalize(g, norm="l1", axis=1, copy=False) @@ -275,12 +282,14 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr _save_data(adata, attr="uns", key=mode_str + stat_str, data=df, time=start) -@njit(parallel=True, nogil=True, cache=True) -def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments - indptr: NDArrayA, - indices: NDArrayA, - data: NDArrayA, - x: NDArrayA, +def _autocorr_perms_src( # noqa: PLR0917, numba requires positional arguments + tptr: NDArrayA, + tind: NDArrayA, + tdat: NDArrayA, + w_sum: NDArrayA, + col_sum: NDArrayA, + nz: NDArrayA, + xv: NDArrayA, w: float, perms: NDArrayA, moran: bool, @@ -292,74 +301,100 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments the unpermuted graph, leaves ``O(n_cells)`` per permutation instead of a full ``O(nnz)`` pass, and lets numba own the parallelism with the GIL released. - ``x`` must already be :class:`numpy.float64` and ``data`` the float64 graph weights, matching - the casts :mod:`scanpy` applies before its own kernels. + ``nz`` lists the cells where this feature is non-zero. Both statistics split into a term that + only touches those cells and a term that is permutation-invariant (``sum(perm)`` over a bijection + is ``sum``), so the per-permutation loop costs ``O(len(nz))`` rather than ``O(n_cells)``. Pass + ``arange(n)`` for a dense feature. + + ``xv`` must already be :class:`numpy.float64`, and ``tdat`` the float64 graph weights, + matching the casts :mod:`scanpy` applies before its own kernels. """ n_perms, n = perms.shape out = np.empty(n_perms, dtype=np.float64) # The reductions below are written as explicit serial loops on purpose: under ``parallel=True`` # numba turns ``arr.sum()``/``arr.mean()`` into parallel reductions, whose result depends on the - # thread count. Summing in index order keeps the output identical for any ``n_jobs``. The - # per-row precomputes further down are ``prange`` instead: each ``k`` owns its output slot and - # accumulates its own row in index order, so they parallelize without reordering any sum. + # thread count. Summing in index order keeps the output identical for any ``n_jobs``. + n_nz = nz.shape[0] x_bar = 0.0 - for i in range(n): - x_bar += x[i] + for t in range(n_nz): + x_bar += xv[t] x_bar /= n + # sum_i (x_i - x_bar)^2 -- Moran's denominator and Geary's, the same quantity either way. + css = 0.0 + for t in range(n_nz): + d = xv[t] - x_bar + css += d * d + css += (n - n_nz) * x_bar * x_bar # every zero entry contributes x_bar^2 + if moran: # I = n / W * sum_ij w_ij z_i z_j / sum_i z_i^2. The inner row sum is the spatial lag, # which the permutation only reindexes. - z = x - x_bar - z2ss = 0.0 - for i in range(n): - z2ss += z[i] * z[i] lag = np.zeros(n, dtype=np.float64) - for k in prange(n): - acc = 0.0 - for e in range(indptr[k], indptr[k + 1]): - acc += data[e] * z[indices[e]] - lag[k] = acc - for p in prange(n_perms): + # lag = g @ z = (g @ x) - x_bar * rowsum(g). Only columns where x != 0 contribute, so + # this scatters over the feature's non-zeros rather than sweeping the whole graph. + for t in range(n_nz): + j = nz[t] + xj = xv[t] + for e in range(tptr[j], tptr[j + 1]): + lag[tind[e]] += tdat[e] * xj + for k in range(n): + lag[k] -= x_bar * w_sum[k] + # sum_i lag[perm[i]] * z[i] = sum_{x[i]!=0} lag[perm[i]] * x[i] - x_bar * sum_k lag[k], + # the second term being permutation-invariant and so hoisted out of the loop. + # sum_k lag[k] = sum_j x[j] * colsum(g)[j] - x_bar * W: O(nnz), not O(n_cells). + lag_tot = 0.0 + for t in range(n_nz): + lag_tot += xv[t] * col_sum[nz[t]] + lag_tot -= x_bar * w + for p in range(n_perms): inum = 0.0 - for i in range(n): - inum += lag[perms[p, i]] * z[i] - out[p] = n / w * inum / z2ss + for t in range(n_nz): + inum += lag[perms[p, nz[t]]] * xv[t] + out[p] = n / w * (inum - x_bar * lag_tot) / css else: # C = (n - 1) * sum_ij w_ij (x_i - x_j)^2 / (2 W sum_i (x_i - x_bar)^2). Expanding the # square splits each row into (sum w, sum w x_j, sum w x_j^2), all permutation-independent. - w_sum = np.zeros(n, dtype=np.float64) wx = np.zeros(n, dtype=np.float64) wx2 = np.zeros(n, dtype=np.float64) - for k in prange(n): - acc_w = 0.0 - acc_x = 0.0 - acc_x2 = 0.0 - for e in range(indptr[k], indptr[k + 1]): - weight = data[e] - xj = x[indices[e]] - acc_w += weight - acc_x += weight * xj - acc_x2 += weight * xj * xj - w_sum[k] = acc_w - wx[k] = acc_x - wx2[k] = acc_x2 - css = 0.0 - for i in range(n): - centered = x[i] - x_bar - css += centered * centered + for t in range(n_nz): + j = nz[t] + xj = xv[t] + xj2 = xj * xj + for e in range(tptr[j], tptr[j + 1]): + k = tind[e] + weight = tdat[e] + wx[k] += weight * xj + wx2[k] += weight * xj2 denom = 2.0 * w * css - for p in prange(n_perms): + # sum_i wx2[perm[i]] is permutation-invariant; the other two terms vanish where x[i] == 0. + wx2_tot = 0.0 + for t in range(n_nz): + wx2_tot += xv[t] * xv[t] * col_sum[nz[t]] + for p in range(n_perms): total = 0.0 - for i in range(n): - k = perms[p, i] - xi = x[i] - total += xi * xi * w_sum[k] - 2.0 * xi * wx[k] + wx2[k] - out[p] = (n - 1) * total / denom + for t in range(n_nz): + k = perms[p, nz[t]] + xi = xv[t] + total += xi * xi * w_sum[k] - 2.0 * xi * wx[k] + out[p] = (n - 1) * (total + wx2_tot) / denom return out +# Serial, and parallelised one level up: `_score_perms` runs features across a thread pool, which +# beats parallelising inside the kernel even for a couple of features. `nogil` is what lets the pool +# work at all -- numba's default threading layer aborts if a `parallel=True` kernel is entered from +# two Python threads. +_autocorr_perms = njit(parallel=False, nogil=True, cache=True)(_autocorr_perms_src) + + +def _chunks(n: int, workers: int) -> list[range]: + """Contiguous index runs, ~4 per worker so the pool can still balance uneven features.""" + step = max(1, -(-n // (workers * 4))) + return [range(s, min(s + step, n)) for s in range(0, n, step)] + + def _score_perms( g: spmatrix, vals: NDArrayA | spmatrix, @@ -369,8 +404,13 @@ def _score_perms( rng: SeedLike | RNGLike | None, n_jobs: int, show_progress_bar: bool, -) -> tuple[NDArrayA, NDArrayA]: - """Observed scores ``(n_features,)`` and permutation scores ``(n_perms, n_features)``.""" +) -> tuple[NDArrayA, dict[str, Any]]: + """Observed scores ``(n_features,)`` and the permutation accumulators `_p_value_calc` needs. + + The permutation scores are only ever reduced along the permutation axis, so the + ``(n_perms, n_features)`` matrix is never materialized: a tally and a running mean/``M2`` per + feature carry the same information in ``O(n_features)``. + """ n_cells = g.shape[0] # Match the casts scanpy applies to its own inputs, so the kernel sees the same numbers. g = g.astype(np.float64, copy=False) @@ -381,6 +421,9 @@ def _score_perms( block = int(np.clip(_PERM_BLOCK_SIZE // max(n_cells, 1), 1, n_perms)) buffer = np.empty((block, n_cells), dtype=np.int32) identity = np.arange(n_cells, dtype=np.int32)[None] + all_cells = np.arange(n_cells, dtype=np.int32) # `nz` for a dense feature + w_sum = np.asarray(g.sum(axis=1)).ravel() # feature-independent: compute once, not per call + col_sum = np.asarray(g.sum(axis=0)).ravel() # ditto; turns the O(n) totals into O(nnz) moran = mode == SpatialAutocorr.MORAN sparse_vals = issparse(vals) @@ -389,28 +432,84 @@ def _score_perms( # one conversion here makes the per-feature extraction below ~180x cheaper. vals = vals.tocsr() n_features = vals.shape[0] + # Columns of `g` are only needed when some feature is sparse enough for the scatter to win; + # the transpose costs one O(nnz) pass and a second copy of the graph, so build it lazily. + # The kernel walks g by column, so it needs the transpose: one O(nnz) pass, and it replaces + # the per-feature dense vector the row-wise form required. + gt = g.T.tocsr() + tptr, tind, tdat = gt.indptr, gt.indices, gt.data # Constant features have a zero denominator; scanpy drops them and reports `nan`, so seed with it. score = np.full(n_features, np.nan, dtype=np.float64) - out = np.full((n_perms, n_features), np.nan, dtype=np.float64) + count_ge = np.zeros(n_features, dtype=np.int64) # tally of `sims >= score`, exact by construction + seen = np.zeros(n_features, dtype=np.int64) + mean = np.zeros(n_features, dtype=np.float64) + m2 = np.zeros(n_features, dtype=np.float64) n_blocks = -(-n_perms // block) + # Features are independent and the kernel is nogil, so parallelism lives here rather than + # inside the kernel. That holds even when there are fewer features than workers: an in-kernel + # `prange` over permutations measured slower than a half-empty pool. + pool_workers = max(1, min(n_jobs, n_features)) + kernel = _autocorr_perms + + def run_feature(m: int, perms: NDArrayA, first: bool) -> None: + if sparse_vals: + # `vals` is CSR, so this feature's non-zero cells and values are already contiguous. + # The kernel reads them directly and never densifies the feature. + lo_m, hi_m = vals.indptr[m], vals.indptr[m + 1] + nz = vals.indices[lo_m:hi_m].astype(np.int32, copy=False) + xv = np.ascontiguousarray(vals.data[lo_m:hi_m], dtype=np.float64) + else: + nz = all_cells + xv = np.ascontiguousarray(vals[m], dtype=np.float64) + if len(nz) == 0 or (xv.min() == xv.max() and (len(nz) == n_cells or xv[0] == 0.0)): + return # constant feature: scanpy drops it and reports `nan` + args = (tptr, tind, tdat, w_sum, col_sum, nz, xv, w) + if first: + score[m] = kernel(*args, identity, moran)[0] + sims = kernel(*args, perms, moran) + count_ge[m] += int((sims >= score[m]).sum()) + # Chan's parallel combine. `sum(x^2) - mean^2` would cancel catastrophically here: + # the permuted scores cluster tightly around their expectation. + b_n = sims.shape[0] + b_mean = sims.mean() + delta = b_mean - mean[m] + total = seen[m] + b_n + m2[m] += ((sims - b_mean) ** 2).sum() + delta * delta * seen[m] * b_n / total + mean[m] += delta * b_n / total + seen[m] = total + with ( - numba_threads(n_jobs), + numba_threads(1), # the kernel is serial; the pool below owns the parallelism tqdm(total=n_blocks * n_features, unit="feature", disable=not show_progress_bar) as pbar, + ThreadPoolExecutor(pool_workers) as ex, ): for lo in range(0, n_perms, block): perms = buffer[: min(block, n_perms - lo)] - for i in range(len(perms)): + first = lo == 0 + + def fill(i: int, perms: NDArrayA = perms, lo: int = lo) -> None: + # numpy releases the GIL inside `permutation`, so this fans out too. Row `i` always + # comes from `rngs[lo + i]`, so the draw is independent of how work is scheduled. perms[i] = rngs[lo + i].permutation(n_cells) - for m in range(n_features): - pbar.update() - x = vals[m].toarray().ravel() if sparse_vals else vals[m] - x = np.ascontiguousarray(x, dtype=np.float64) - if x.min() == x.max(): - continue - if lo == 0: - score[m] = _autocorr_perms(g.indptr, g.indices, g.data, x, w, identity, moran)[0] - out[lo : lo + len(perms), m] = _autocorr_perms(g.indptr, g.indices, g.data, x, w, perms, moran) - return score, out + + # Hand each worker a contiguous run rather than one item: a single feature is ~0.06 ms + # against a ~0.03 ms queue round-trip, so per-item dispatch would cost as much as the + # work. Each worker owns its own `m`, so every write lands in a distinct slot. + # The defaults bind this block's values rather than the loop's last ones. + list(ex.map(lambda c, fill=fill: [fill(i) for i in c], _chunks(len(perms), pool_workers))) + for done in ex.map( + lambda c, perms=perms, first=first: [run_feature(m, perms, first) for m in c], + _chunks(n_features, pool_workers), + ): + pbar.update(len(done)) + # Constant features never ran, so they keep scanpy's `nan` rather than a zero mean/variance. + ran = seen > 0 + return score, { + "n_perms": n_perms, + "count_ge": count_ge, + "mean": np.where(ran, mean, np.nan), + "var": np.where(ran, m2 / np.where(ran, seen - 1, 1), np.nan), + } @njit(parallel=True, fastmath=True, cache=True) @@ -576,7 +675,7 @@ def _find_min_max(spatial: NDArrayA) -> tuple[float, float]: def _p_value_calc( score: NDArrayA, - sims: NDArrayA | None, + sims: dict[str, Any] | None, weights: spmatrix | NDArrayA, params: dict[str, Any], ) -> dict[str, Any]: @@ -588,7 +687,8 @@ def _p_value_calc( score (n_features,). sims - (n_simulations, n_features). + Permutation accumulators from `_score_perms`: ``n_perms`` and, per feature, the + ``count_ge`` tally plus the ``mean``/``var`` of the permuted scores. params Object to store relevant function parameters. @@ -607,24 +707,23 @@ def _p_value_calc( if sims is None: return results - n_perms = sims.shape[0] - large_perm = (sims >= score).sum(axis=0) + n_perms = sims["n_perms"] + large_perm = sims["count_ge"].copy() # copy: the swap below writes in place # subtract total perm for negative values large_perm[(n_perms - large_perm) < large_perm] = n_perms - large_perm[(n_perms - large_perm) < large_perm] # get p-value based on permutation p_sim: NDArrayA = (large_perm + 1) / (n_perms + 1) # get p-value based on standard normal approximation from permutations - e_score_sim = sims.sum(axis=0) / n_perms - se_score_sim = sims.std(axis=0) + e_score_sim = sims["mean"] + var_sim = sims["var"] + se_score_sim = np.sqrt(var_sim) z_sim = (score - e_score_sim) / se_score_sim p_z_sim = np.empty(z_sim.shape) p_z_sim[z_sim > 0] = 1 - stats.norm.cdf(z_sim[z_sim > 0]) p_z_sim[z_sim <= 0] = stats.norm.cdf(z_sim[z_sim <= 0]) - var_sim = np.var(sims, axis=0) - results["pval_z_sim"] = p_z_sim results["pval_sim"] = p_sim results["var_sim"] = var_sim diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 4c3ad8c3b..0224fca0d 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -9,7 +9,7 @@ from squidpy._constants._pkg_constants import Key from squidpy.gr import co_occurrence, spatial_autocorr -from squidpy.gr._ppatterns import _find_min_max, _score_perms +from squidpy.gr._ppatterns import _autocorr_perms, _find_min_max, _score_perms MORAN_K = "moranI" GEARY_C = "gearyC" @@ -325,13 +325,43 @@ def test_score_perms_matches_scanpy_per_permutation(mode: str): vals = rng.random((n_genes, n), dtype=np.float32) autocorr = SpatialAutocorr(mode) + moran = autocorr == SpatialAutocorr.MORAN observed, got = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) func = morans_i if autocorr == SpatialAutocorr.MORAN else gearys_c expected = np.stack([func(g[gen.permutation(n), :], vals) for gen in np.random.default_rng(0).spawn(n_perms)]) - assert got.shape == (n_perms, n_genes) np.testing.assert_allclose(observed, func(g, vals), rtol=1e-9) - np.testing.assert_allclose(got, expected, rtol=1e-9) + + # `_score_perms` only keeps reductions of the permutation scores, so the per-permutation + # comparison happens one level down, against the kernel that still returns them. + gg = g.astype(np.float64, copy=False) + gt = gg.T.tocsr() + perms = np.stack([gen.permutation(n).astype(np.int32) for gen in np.random.default_rng(0).spawn(n_perms)]) + sims = np.stack( + [ + _autocorr_perms( + gt.indptr, + gt.indices, + gt.data, + np.asarray(gg.sum(axis=1)).ravel(), + np.asarray(gg.sum(axis=0)).ravel(), + np.arange(n, dtype=np.int32), + np.ascontiguousarray(vals[m], np.float64), + gg.data.sum(), + perms, + moran, + ) + for m in range(n_genes) + ] + ).T + assert sims.shape == (n_perms, n_genes) + np.testing.assert_allclose(sims, expected, rtol=1e-9) + + # and the accumulators must be those same scores, reduced + assert got["n_perms"] == n_perms + np.testing.assert_array_equal(got["count_ge"], (sims >= observed).sum(axis=0)) + np.testing.assert_allclose(got["mean"], expected.mean(axis=0), rtol=1e-9) + np.testing.assert_allclose(got["var"], expected.var(axis=0), rtol=1e-9) @pytest.mark.parametrize("mode", ["moran", "geary"]) @@ -353,7 +383,9 @@ def test_score_perms_thread_invariant(mode: str): serial = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=1, show_progress_bar=False) threaded = _score_perms(g, vals, mode=autocorr, n_perms=n_perms, rng=0, n_jobs=4, show_progress_bar=False) np.testing.assert_array_equal(serial[0], threaded[0]) - np.testing.assert_array_equal(serial[1], threaded[1]) + assert serial[1].keys() == threaded[1].keys() + for key in serial[1]: + np.testing.assert_array_equal(serial[1][key], threaded[1][key], err_msg=key) def test_spatial_autocorr_backend_deprecated(dummy_adata: AnnData): From 07784fc07371b96de7562dee3b976c0db4ddfa20 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 02:50:55 +0200 Subject: [PATCH 14/28] Retire comments the rewrite outdated The kernel is compiled serial now, the graph transpose is unconditional, and the block cap is 2**28, so the notes describing the previous shapes were contradicting the code beside them. Inline the one-use source function into its decorator and drop the kernel alias. --- src/squidpy/gr/_ppatterns.py | 63 +++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 573a77d9e..701521611 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -113,7 +113,7 @@ def spatial_autocorr( %(rng_versionchanged)s .. versionchanged:: 1.8.4 - Permutations run on numba threads, and ``n_jobs = None`` now uses all ``NUMBA_NUM_THREADS`` + Permutations run on a thread pool, and ``n_jobs = None`` now uses all ``NUMBA_NUM_THREADS`` threads instead of one process. Pass ``n_jobs = 1`` for the old serial default. Parameters @@ -253,7 +253,8 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr start = logg.info(f"Calculating {mode}'s statistic for `{n_perms}` permutations using `{n_jobs}` thread(s)") if n_perms is not None: assert_positive(n_perms, name="n_perms") - # the observed score comes from the same kernel as the permuted ones, so `sims >= score` sees exact ties + # the observed score comes from the same kernel as the permuted ones, so the tally below + # compares like with like and exact ties stay exact score, score_perms = _score_perms( g, vals, mode=mode, n_perms=n_perms, rng=rng, n_jobs=n_jobs, show_progress_bar=show_progress_bar ) @@ -282,7 +283,8 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr _save_data(adata, attr="uns", key=mode_str + stat_str, data=df, time=start) -def _autocorr_perms_src( # noqa: PLR0917, numba requires positional arguments +@njit(parallel=False, nogil=True, cache=True) +def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments tptr: NDArrayA, tind: NDArrayA, tdat: NDArrayA, @@ -297,9 +299,8 @@ def _autocorr_perms_src( # noqa: PLR0917, numba requires positional arguments """Permutation scores of one feature over the row-permuted graph, one per row of ``perms``. Both statistics sum, over the rows of ``g[perm, :]``, a per-row quantity that depends on the - permutation only through *which* row is visited. Accumulating those quantities once, per row of - the unpermuted graph, leaves ``O(n_cells)`` per permutation instead of a full ``O(nnz)`` pass, - and lets numba own the parallelism with the GIL released. + permutation only through *which* row is visited, so those quantities are accumulated once per + row of the unpermuted graph rather than re-derived for every permutation. ``nz`` lists the cells where this feature is non-zero. Both statistics split into a term that only touches those cells and a term that is permutation-invariant (``sum(perm)`` over a bijection @@ -308,13 +309,18 @@ def _autocorr_perms_src( # noqa: PLR0917, numba requires positional arguments ``xv`` must already be :class:`numpy.float64`, and ``tdat`` the float64 graph weights, matching the casts :mod:`scanpy` applies before its own kernels. + + Compiled serial on purpose: `_score_perms` parallelizes across features instead, which measured + faster than an in-kernel ``prange`` even with fewer features than workers. ``nogil`` is what + makes that legal -- numba's default threading layer aborts when a ``parallel=True`` kernel is + entered from two Python threads. """ n_perms, n = perms.shape out = np.empty(n_perms, dtype=np.float64) - # The reductions below are written as explicit serial loops on purpose: under ``parallel=True`` - # numba turns ``arr.sum()``/``arr.mean()`` into parallel reductions, whose result depends on the - # thread count. Summing in index order keeps the output identical for any ``n_jobs``. + # The reductions below are explicit loops rather than ``arr.sum()``/``arr.mean()`` so the + # summation order is fixed by the source, not by numba's choice of reduction strategy. + # Summing in index order keeps the output identical for any ``n_jobs``. n_nz = nz.shape[0] x_bar = 0.0 for t in range(n_nz): @@ -382,15 +388,8 @@ def _autocorr_perms_src( # noqa: PLR0917, numba requires positional arguments return out -# Serial, and parallelised one level up: `_score_perms` runs features across a thread pool, which -# beats parallelising inside the kernel even for a couple of features. `nogil` is what lets the pool -# work at all -- numba's default threading layer aborts if a `parallel=True` kernel is entered from -# two Python threads. -_autocorr_perms = njit(parallel=False, nogil=True, cache=True)(_autocorr_perms_src) - - def _chunks(n: int, workers: int) -> list[range]: - """Contiguous index runs, ~4 per worker so the pool can still balance uneven features.""" + """Contiguous index runs over ``n``, ~4 per worker so the pool can balance uneven items.""" step = max(1, -(-n // (workers * 4))) return [range(s, min(s + step, n)) for s in range(0, n, step)] @@ -416,8 +415,9 @@ def _score_perms( g = g.astype(np.float64, copy=False) w = g.data.sum() rngs = np.random.default_rng(rng).spawn(n_perms) - # ponytail: permutations are drawn in blocks of at most 256 MB (int32), and every feature is - # re-extracted once per block; the block count only exceeds 1 past ~250k cells x 256 perms. + # ponytail: permutations are drawn in blocks capped at `_PERM_BLOCK_SIZE` entries (1 GiB of + # int32), and every feature is revisited once per block; more than one block only past + # n_perms * n_cells > 2**28. Raise the cap to trade memory for fewer repeats. block = int(np.clip(_PERM_BLOCK_SIZE // max(n_cells, 1), 1, n_perms)) buffer = np.empty((block, n_cells), dtype=np.int32) identity = np.arange(n_cells, dtype=np.int32)[None] @@ -432,8 +432,6 @@ def _score_perms( # one conversion here makes the per-feature extraction below ~180x cheaper. vals = vals.tocsr() n_features = vals.shape[0] - # Columns of `g` are only needed when some feature is sparse enough for the scatter to win; - # the transpose costs one O(nnz) pass and a second copy of the graph, so build it lazily. # The kernel walks g by column, so it needs the transpose: one O(nnz) pass, and it replaces # the per-feature dense vector the row-wise form required. gt = g.T.tocsr() @@ -449,7 +447,6 @@ def _score_perms( # inside the kernel. That holds even when there are fewer features than workers: an in-kernel # `prange` over permutations measured slower than a half-empty pool. pool_workers = max(1, min(n_jobs, n_features)) - kernel = _autocorr_perms def run_feature(m: int, perms: NDArrayA, first: bool) -> None: if sparse_vals: @@ -465,8 +462,8 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: return # constant feature: scanpy drops it and reports `nan` args = (tptr, tind, tdat, w_sum, col_sum, nz, xv, w) if first: - score[m] = kernel(*args, identity, moran)[0] - sims = kernel(*args, perms, moran) + score[m] = _autocorr_perms(*args, identity, moran)[0] + sims = _autocorr_perms(*args, perms, moran) count_ge[m] += int((sims >= score[m]).sum()) # Chan's parallel combine. `sum(x^2) - mean^2` would cancel catastrophically here: # the permuted scores cluster tightly around their expectation. @@ -488,15 +485,19 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: first = lo == 0 def fill(i: int, perms: NDArrayA = perms, lo: int = lo) -> None: - # numpy releases the GIL inside `permutation`, so this fans out too. Row `i` always - # comes from `rngs[lo + i]`, so the draw is independent of how work is scheduled. + # numpy releases the GIL inside `permutation`, so these draws spread over the same + # pool (as far as `pool_workers` allows). Row `i` always comes from `rngs[lo + i]`, + # so the draw never depends on how the work was scheduled. perms[i] = rngs[lo + i].permutation(n_cells) + # Drained before the feature map below starts, so no worker reads `perms` while + # another is still filling it. + list(ex.map(lambda c, fill=fill: [fill(i) for i in c], _chunks(len(perms), pool_workers))) + # Hand each worker a contiguous run rather than one item: a single feature is ~0.06 ms # against a ~0.03 ms queue round-trip, so per-item dispatch would cost as much as the - # work. Each worker owns its own `m`, so every write lands in a distinct slot. - # The defaults bind this block's values rather than the loop's last ones. - list(ex.map(lambda c, fill=fill: [fill(i) for i in c], _chunks(len(perms), pool_workers))) + # work. Each worker owns its own `m`, so every write lands in a distinct slot. The + # lambda defaults bind this block's values rather than the loop's last ones. for done in ex.map( lambda c, perms=perms, first=first: [run_feature(m, perms, first) for m in c], _chunks(n_features, pool_workers), @@ -508,7 +509,7 @@ def fill(i: int, perms: NDArrayA = perms, lo: int = lo) -> None: "n_perms": n_perms, "count_ge": count_ge, "mean": np.where(ran, mean, np.nan), - "var": np.where(ran, m2 / np.where(ran, seen - 1, 1), np.nan), + "var": np.where(ran, m2 / np.where(ran, seen, 1), np.nan), } @@ -689,6 +690,8 @@ def _p_value_calc( sims Permutation accumulators from `_score_perms`: ``n_perms`` and, per feature, the ``count_ge`` tally plus the ``mean``/``var`` of the permuted scores. + weights + The spatial connectivity graph, used for the analytic (normality) p-value. params Object to store relevant function parameters. From 04e406b9f70d955966f2f753d0cec87f20dabd1b Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 03:00:38 +0200 Subject: [PATCH 15/28] Centre the variance on the observed score The permuted scores cluster around the observed one, so shifting by it turns the mean and variance into plain sums: they merge across blocks with `+=` instead of a Chan combine, and `s2/n - (s1/n)**2` has nothing to cancel because `s1/n` is the small residual. Also more accurate than the combine it replaces -- 2e-15 against 2e-09 relative on tightly clustered scores, where the naive `E[x^2] - E[x]^2` is wrong outright. --- src/squidpy/gr/_ppatterns.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 701521611..249604525 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -439,9 +439,11 @@ def _score_perms( # Constant features have a zero denominator; scanpy drops them and reports `nan`, so seed with it. score = np.full(n_features, np.nan, dtype=np.float64) count_ge = np.zeros(n_features, dtype=np.int64) # tally of `sims >= score`, exact by construction - seen = np.zeros(n_features, dtype=np.int64) - mean = np.zeros(n_features, dtype=np.float64) - m2 = np.zeros(n_features, dtype=np.float64) + # Permuted scores cluster around the observed one, so the mean and variance are accumulated as + # sums shifted by it. Both are plain sums: they merge across blocks by `+=`, and the final + # `s2/n - (s1/n)**2` has nothing to cancel because `s1/n` is the small residual. + s1 = np.zeros(n_features, dtype=np.float64) + s2 = np.zeros(n_features, dtype=np.float64) n_blocks = -(-n_perms // block) # Features are independent and the kernel is nogil, so parallelism lives here rather than # inside the kernel. That holds even when there are fewer features than workers: an in-kernel @@ -465,15 +467,9 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: score[m] = _autocorr_perms(*args, identity, moran)[0] sims = _autocorr_perms(*args, perms, moran) count_ge[m] += int((sims >= score[m]).sum()) - # Chan's parallel combine. `sum(x^2) - mean^2` would cancel catastrophically here: - # the permuted scores cluster tightly around their expectation. - b_n = sims.shape[0] - b_mean = sims.mean() - delta = b_mean - mean[m] - total = seen[m] + b_n - m2[m] += ((sims - b_mean) ** 2).sum() + delta * delta * seen[m] * b_n / total - mean[m] += delta * b_n / total - seen[m] = total + shifted = sims - score[m] + s1[m] += shifted.sum() + s2[m] += (shifted * shifted).sum() with ( numba_threads(1), # the kernel is serial; the pool below owns the parallelism @@ -504,12 +500,13 @@ def fill(i: int, perms: NDArrayA = perms, lo: int = lo) -> None: ): pbar.update(len(done)) # Constant features never ran, so they keep scanpy's `nan` rather than a zero mean/variance. - ran = seen > 0 + ran = ~np.isnan(score) + mean_shift = np.where(ran, s1 / n_perms, np.nan) return score, { "n_perms": n_perms, "count_ge": count_ge, - "mean": np.where(ran, mean, np.nan), - "var": np.where(ran, m2 / np.where(ran, seen, 1), np.nan), + "mean": score + mean_shift, + "var": np.where(ran, s2 / n_perms - mean_shift**2, np.nan), } From d7cb6f931288eee428ee8f75ae1f937b55653151 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 03:24:52 +0200 Subject: [PATCH 16/28] Pin the full-gene-list fast path `extract_X` skips the subset when the selection is every gene in var_names order. Without the order check a reordered full-length list takes that path too, returning X in var order against the caller's labels. Nothing caught that; this does. --- tests/graph/test_ppatterns.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 0224fca0d..a80d24795 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -147,6 +147,23 @@ def test_spatial_autocorr_perm_blocks(dummy_adata: AnnData, mode: str, monkeypat assert_frame_equal(spatial_autocorr(dummy_adata, n_jobs=n_jobs, **kw), expected) +def test_spatial_autocorr_full_gene_list_reordered(dummy_adata: AnnData): + """A full-length but reordered `genes` must not take the identity fast path in `extract_X`. + + `extract_X` skips `adata[:, genes]` when the selection is every gene in `var_names` order. + Dropping the order check from that guard leaves a list of the right length taking the fast + path, which returns `X` in var order while labelling the rows in the caller's order. + """ + genes = list(dummy_adata.var_names) + kw = {"mode": "geary", "n_perms": 20, "rng": 0, "copy": True, "show_progress_bar": False} + fwd = spatial_autocorr(dummy_adata, genes=genes, **kw) + rev = spatial_autocorr(dummy_adata, genes=genes[::-1], **kw) + + # a gene's statistic cannot depend on the order the caller listed the genes in + assert set(fwd.index) == set(rev.index) + np.testing.assert_allclose(fwd["C"], rev.loc[fwd.index, "C"], rtol=1e-12) + + @pytest.mark.parametrize("mode", ["moran", "geary"]) def test_spatial_autocorr_csc_connectivities(dummy_adata: AnnData, mode: str): """A CSC graph must give the CSR result: the kernel reads CSR and sklearn's row-normalize skips CSC.""" From 35229d956928ed4ea83e87b56bb511b2d7c52829 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 10:41:18 +0200 Subject: [PATCH 17/28] Record how the block cap was chosen The constant said what it was, not why that value. It exists to keep memory off `n_perms` -- 1000 permutations over 100M cells would want 372 GB unbatched -- and 2**28 comes from measuring the cost of splitting, not from any cache size: ~1.03x at 1M cells, ~1.10x at 10M, and 0.86x at 10M with two blocks, where splitting is the faster arm. Also corrects the reduction note: the kernel is serial, so there is no parallel reduction to avoid, and independence from `n_jobs` comes from the RNG binding and per-feature ownership instead. --- src/squidpy/gr/_ppatterns.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 249604525..80a131815 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -53,7 +53,27 @@ fp = np.float32 bl = nt.boolean -_PERM_BLOCK_SIZE = 2**28 # permutation entries held at once in `spatial_autocorr`, i.e. 1 GiB of int32 +# Permutation entries held at once in `spatial_autocorr`: 2**28 int32 is 1 GiB. +# +# The permutations are drawn once and reused across features, so they are materialized; drawing +# them per feature would cost `n_features` times the shuffles. Without a cap that buffer is +# `n_perms * n_cells * 4`, which couples memory to a parameter raised for statistical reasons -- +# going from 100 to 1000 permutations for usable FDR resolution would want 10x the memory, and +# 1000 permutations over 100M cells would want 372 GB. Capping decouples them: resolution costs +# time, not RAM. +# +# 2**28 was picked by measuring the cost of splitting rather than by the size of any cache. On an +# exclusive 90-core node, full `spatial_autocorr` over tiled MERFISH, batched against a single +# block: ~1.03x at 1M cells (4 blocks, moran 0.99x), ~1.10x at 10M cells (4 blocks), and 0.86x at +# 10M with 2 blocks -- i.e. splitting is sometimes faster. The tax only climbs past ~8 blocks. +# A smaller cap is cheap in memory but reaches that knee sooner; a larger one buys little, since +# one block was never meaningfully faster than four. +# +# It is a ceiling, not a tuning knob: it changes no result (`test_spatial_autocorr_perm_blocks`), +# and nothing about it is calibrated to a particular machine. Block size sets the tax, and block +# size follows `n_cells` alone -- raising `n_perms` adds blocks and work in equal measure, so the +# tax is independent of it. +_PERM_BLOCK_SIZE = 2**28 @d.dedent @@ -318,9 +338,10 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments n_perms, n = perms.shape out = np.empty(n_perms, dtype=np.float64) - # The reductions below are explicit loops rather than ``arr.sum()``/``arr.mean()`` so the - # summation order is fixed by the source, not by numba's choice of reduction strategy. - # Summing in index order keeps the output identical for any ``n_jobs``. + # Explicit loops rather than ``arr.sum()`` to avoid materializing temporaries; this kernel is + # serial, so there is no parallel reduction to worry about either way. Independence from + # ``n_jobs`` comes from `_score_perms`: permutation ``p`` is always drawn from ``rngs[p]``, and + # each feature is owned by exactly one worker. n_nz = nz.shape[0] x_bar = 0.0 for t in range(n_nz): @@ -415,9 +436,8 @@ def _score_perms( g = g.astype(np.float64, copy=False) w = g.data.sum() rngs = np.random.default_rng(rng).spawn(n_perms) - # ponytail: permutations are drawn in blocks capped at `_PERM_BLOCK_SIZE` entries (1 GiB of - # int32), and every feature is revisited once per block; more than one block only past - # n_perms * n_cells > 2**28. Raise the cap to trade memory for fewer repeats. + # Blocks of at most `_PERM_BLOCK_SIZE` entries; every feature is revisited once per block, so + # the per-block scatter is re-paid. More than one block only past n_perms * n_cells > 2**28. block = int(np.clip(_PERM_BLOCK_SIZE // max(n_cells, 1), 1, n_perms)) buffer = np.empty((block, n_cells), dtype=np.int32) identity = np.arange(n_cells, dtype=np.int32)[None] From 937f1af307e431d848f52cc82669f93d346a13b6 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 10:43:12 +0200 Subject: [PATCH 18/28] Trim absolute figures from the block-cap note The ratios are what transfer; the byte counts were specific to the machine and dataset they were measured on. --- src/squidpy/gr/_ppatterns.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 80a131815..ec2062574 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -53,26 +53,23 @@ fp = np.float32 bl = nt.boolean -# Permutation entries held at once in `spatial_autocorr`: 2**28 int32 is 1 GiB. +# Permutation entries held at once in `spatial_autocorr`. # -# The permutations are drawn once and reused across features, so they are materialized; drawing -# them per feature would cost `n_features` times the shuffles. Without a cap that buffer is -# `n_perms * n_cells * 4`, which couples memory to a parameter raised for statistical reasons -- -# going from 100 to 1000 permutations for usable FDR resolution would want 10x the memory, and -# 1000 permutations over 100M cells would want 372 GB. Capping decouples them: resolution costs -# time, not RAM. +# Permutations are drawn once and reused across features, so they are materialized; drawing them +# per feature would cost `n_features` times the shuffles. Uncapped, that buffer is +# `n_perms * n_cells * 4` -- memory tied to a parameter raised for statistical reasons, so asking +# for finer FDR resolution would also ask for proportionally more RAM. The cap decouples them: +# resolution costs time, not memory. # -# 2**28 was picked by measuring the cost of splitting rather than by the size of any cache. On an -# exclusive 90-core node, full `spatial_autocorr` over tiled MERFISH, batched against a single -# block: ~1.03x at 1M cells (4 blocks, moran 0.99x), ~1.10x at 10M cells (4 blocks), and 0.86x at -# 10M with 2 blocks -- i.e. splitting is sometimes faster. The tax only climbs past ~8 blocks. -# A smaller cap is cheap in memory but reaches that knee sooner; a larger one buys little, since -# one block was never meaningfully faster than four. +# The value comes from measuring the cost of splitting, on full `spatial_autocorr` runs over tiled +# MERFISH on an exclusive node, batched against a single block: ~1.03x at 1M cells and ~1.10x at +# 10M, both at four blocks, and 0.86x at 10M with two -- splitting is sometimes the faster arm. +# The tax only climbs past roughly eight blocks. A smaller cap reaches that knee sooner; a larger +# one buys little, since one block was never meaningfully faster than four. # -# It is a ceiling, not a tuning knob: it changes no result (`test_spatial_autocorr_perm_blocks`), -# and nothing about it is calibrated to a particular machine. Block size sets the tax, and block -# size follows `n_cells` alone -- raising `n_perms` adds blocks and work in equal measure, so the -# tax is independent of it. +# A ceiling, not a tuning knob: it changes no result (`test_spatial_autocorr_perm_blocks`) and is +# not calibrated to any machine. Block size sets the tax and follows `n_cells` alone, so raising +# `n_perms` adds blocks and work in equal measure and leaves the tax unchanged. _PERM_BLOCK_SIZE = 2**28 From ae6b24763e875e10e79421463d6e4d608b52244a Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:04:20 +0200 Subject: [PATCH 19/28] Tighten the comments on the block cap Seventeen lines of justification for one constant was more than it needs. --- src/squidpy/gr/_ppatterns.py | 41 ++++++++++++------------------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index ec2062574..5570a9caf 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -53,23 +53,11 @@ fp = np.float32 bl = nt.boolean -# Permutation entries held at once in `spatial_autocorr`. -# -# Permutations are drawn once and reused across features, so they are materialized; drawing them -# per feature would cost `n_features` times the shuffles. Uncapped, that buffer is -# `n_perms * n_cells * 4` -- memory tied to a parameter raised for statistical reasons, so asking -# for finer FDR resolution would also ask for proportionally more RAM. The cap decouples them: -# resolution costs time, not memory. -# -# The value comes from measuring the cost of splitting, on full `spatial_autocorr` runs over tiled -# MERFISH on an exclusive node, batched against a single block: ~1.03x at 1M cells and ~1.10x at -# 10M, both at four blocks, and 0.86x at 10M with two -- splitting is sometimes the faster arm. -# The tax only climbs past roughly eight blocks. A smaller cap reaches that knee sooner; a larger -# one buys little, since one block was never meaningfully faster than four. -# -# A ceiling, not a tuning knob: it changes no result (`test_spatial_autocorr_perm_blocks`) and is -# not calibrated to any machine. Block size sets the tax and follows `n_cells` alone, so raising -# `n_perms` adds blocks and work in equal measure and leaves the tax unchanged. +# Permutation entries held at once. Permutations are reused across features, so they are +# materialized rather than redrawn per feature; the cap keeps that buffer off `n_perms`, which +# users raise for FDR resolution. Splitting costs ~3% at 1M cells and ~10% at 10M (four blocks +# either way), and only becomes expensive past ~8 blocks. It changes no result, see +# `test_spatial_autocorr_perm_blocks`. _PERM_BLOCK_SIZE = 2**28 @@ -270,8 +258,8 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr start = logg.info(f"Calculating {mode}'s statistic for `{n_perms}` permutations using `{n_jobs}` thread(s)") if n_perms is not None: assert_positive(n_perms, name="n_perms") - # the observed score comes from the same kernel as the permuted ones, so the tally below - # compares like with like and exact ties stay exact + # the observed score comes from the same kernel as the permuted ones, so exact ties + # in the tally below stay exact score, score_perms = _score_perms( g, vals, mode=mode, n_perms=n_perms, rng=rng, n_jobs=n_jobs, show_progress_bar=show_progress_bar ) @@ -329,23 +317,22 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments Compiled serial on purpose: `_score_perms` parallelizes across features instead, which measured faster than an in-kernel ``prange`` even with fewer features than workers. ``nogil`` is what - makes that legal -- numba's default threading layer aborts when a ``parallel=True`` kernel is + makes that legal: numba's default threading layer aborts when a ``parallel=True`` kernel is entered from two Python threads. """ n_perms, n = perms.shape out = np.empty(n_perms, dtype=np.float64) - # Explicit loops rather than ``arr.sum()`` to avoid materializing temporaries; this kernel is - # serial, so there is no parallel reduction to worry about either way. Independence from - # ``n_jobs`` comes from `_score_perms`: permutation ``p`` is always drawn from ``rngs[p]``, and - # each feature is owned by exactly one worker. + # Explicit loops rather than ``arr.sum()`` to avoid materializing temporaries. Independence + # from ``n_jobs`` comes from `_score_perms`, where permutation ``p`` is always drawn from + # ``rngs[p]`` and each feature is owned by one worker. n_nz = nz.shape[0] x_bar = 0.0 for t in range(n_nz): x_bar += xv[t] x_bar /= n - # sum_i (x_i - x_bar)^2 -- Moran's denominator and Geary's, the same quantity either way. + # sum_i (x_i - x_bar)^2, which is Moran's denominator and Geary's alike. css = 0.0 for t in range(n_nz): d = xv[t] - x_bar @@ -463,8 +450,8 @@ def _score_perms( s2 = np.zeros(n_features, dtype=np.float64) n_blocks = -(-n_perms // block) # Features are independent and the kernel is nogil, so parallelism lives here rather than - # inside the kernel. That holds even when there are fewer features than workers: an in-kernel - # `prange` over permutations measured slower than a half-empty pool. + # inside the kernel. Even with fewer features than workers, an in-kernel `prange` over + # permutations measured slower than a half-empty pool. pool_workers = max(1, min(n_jobs, n_features)) def run_feature(m: int, perms: NDArrayA, first: bool) -> None: From ee5da4777979b11bda93e9690da0e155007352fc Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:11:33 +0200 Subject: [PATCH 20/28] Dispatch through thread_map `_build` and `_sepal` already map over a thread pool through this helper; `_score_perms` was managing its own. Measured equal or slightly faster interleaved, so the pool it builds per call costs nothing here. Also notes that the v1.8.3 positional test goes when the `backend` shim does, since the signature it pins only exists for that shim. --- src/squidpy/gr/_ppatterns.py | 24 +++++++++++++----------- tests/graph/test_ppatterns.py | 7 ++++++- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 64731779e..185f0193a 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Sequence -from concurrent.futures import ThreadPoolExecutor from typing import Any, Literal import numba.types as nt @@ -33,6 +32,7 @@ deprecated_randomness_param, get_n_numba_threads, numba_threads, + thread_map, ) from squidpy._validators import assert_key_in_adata, assert_positive from squidpy.gr._utils import ( @@ -478,31 +478,33 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: with ( numba_threads(1), # the kernel is serial; the pool below owns the parallelism tqdm(total=n_blocks * n_features, unit="feature", disable=not show_progress_bar) as pbar, - ThreadPoolExecutor(pool_workers) as ex, ): for lo in range(0, n_perms, block): perms = buffer[: min(block, n_perms - lo)] first = lo == 0 - def fill(i: int, perms: NDArrayA = perms, lo: int = lo) -> None: + def fill(chunk: range, perms: NDArrayA = perms, lo: int = lo) -> None: # numpy releases the GIL inside `permutation`, so these draws spread over the same # pool (as far as `pool_workers` allows). Row `i` always comes from `rngs[lo + i]`, # so the draw never depends on how the work was scheduled. - perms[i] = rngs[lo + i].permutation(n_cells) + for i in chunk: + perms[i] = rngs[lo + i].permutation(n_cells) # Drained before the feature map below starts, so no worker reads `perms` while # another is still filling it. - list(ex.map(lambda c, fill=fill: [fill(i) for i in c], _chunks(len(perms), pool_workers))) + thread_map(fill, _chunks(len(perms), pool_workers), n_jobs=pool_workers) # Hand each worker a contiguous run rather than one item: a single feature is ~0.06 ms # against a ~0.03 ms queue round-trip, so per-item dispatch would cost as much as the # work. Each worker owns its own `m`, so every write lands in a distinct slot. The - # lambda defaults bind this block's values rather than the loop's last ones. - for done in ex.map( - lambda c, perms=perms, first=first: [run_feature(m, perms, first) for m in c], - _chunks(n_features, pool_workers), - ): - pbar.update(len(done)) + # default binds this block's values rather than the loop's last ones. + def run_chunk(chunk: range, perms: NDArrayA = perms, first: bool = first) -> int: + for m in chunk: + run_feature(m, perms, first) + return len(chunk) + + for done in thread_map(run_chunk, _chunks(n_features, pool_workers), n_jobs=pool_workers): + pbar.update(done) # Constant features never ran, so they keep scanpy's `nan` rather than a zero mean/variance. ran = ~np.isnan(score) mean_shift = np.where(ran, s1 / n_perms, np.nan) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index c26c5eaf8..6bd26cf1a 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -179,7 +179,12 @@ def test_spatial_autocorr_csc_connectivities(dummy_adata: AnnData, mode: str): def test_spatial_autocorr_v183_positional_backend(dummy_adata: AnnData): - """A v1.8.3 positional call through ``backend`` binds every value and warns about ``backend``.""" + """A v1.8.3 positional call through ``backend`` binds every value and warns about ``backend``. + + Delete this together with the ``backend`` shim. The positional signature it pins only exists + because `@deprecated_params` keeps accepting `backend` in its v1.8.3 slot; once that is dropped + for 1.10.0 there is no positional form left to protect and the call below stops compiling. + """ kw = {"mode": "moran", "n_perms": 20, "rng": 0, "copy": True, "n_jobs": 1, "show_progress_bar": False} expected = spatial_autocorr(dummy_adata, **kw) args = ( From f63f117d504e3994cbbf1249df971ea9a92e46e8 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:13:45 +0200 Subject: [PATCH 21/28] Plainer wording in the new comments --- src/squidpy/gr/_ppatterns.py | 38 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 185f0193a..ee192be0c 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -332,7 +332,7 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments x_bar += xv[t] x_bar /= n - # sum_i (x_i - x_bar)^2, which is Moran's denominator and Geary's alike. + # sum_i (x_i - x_bar)^2: Moran's denominator, and Geary's. css = 0.0 for t in range(n_nz): d = xv[t] - x_bar @@ -352,8 +352,8 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments lag[tind[e]] += tdat[e] * xj for k in range(n): lag[k] -= x_bar * w_sum[k] - # sum_i lag[perm[i]] * z[i] = sum_{x[i]!=0} lag[perm[i]] * x[i] - x_bar * sum_k lag[k], - # the second term being permutation-invariant and so hoisted out of the loop. + # sum_i lag[perm[i]] * z[i] = sum_{x[i]!=0} lag[perm[i]] * x[i] - x_bar * sum_k lag[k]. + # The second term is permutation-invariant, so it is hoisted out of the loop. # sum_k lag[k] = sum_j x[j] * colsum(g)[j] - x_bar * W: O(nnz), not O(n_cells). lag_tot = 0.0 for t in range(n_nz): @@ -436,22 +436,22 @@ def _score_perms( # one conversion here makes the per-feature extraction below ~180x cheaper. vals = vals.tocsr() n_features = vals.shape[0] - # The kernel walks g by column, so it needs the transpose: one O(nnz) pass, and it replaces - # the per-feature dense vector the row-wise form required. + # The kernel walks g by column, so it needs the transpose. One O(nnz) pass, and it removes + # the per-feature dense vector the row-wise form needed. gt = g.T.tocsr() tptr, tind, tdat = gt.indptr, gt.indices, gt.data # Constant features have a zero denominator; scanpy drops them and reports `nan`, so seed with it. score = np.full(n_features, np.nan, dtype=np.float64) - count_ge = np.zeros(n_features, dtype=np.int64) # tally of `sims >= score`, exact by construction - # Permuted scores cluster around the observed one, so the mean and variance are accumulated as - # sums shifted by it. Both are plain sums: they merge across blocks by `+=`, and the final - # `s2/n - (s1/n)**2` has nothing to cancel because `s1/n` is the small residual. + count_ge = np.zeros(n_features, dtype=np.int64) # integer tally of `sims >= score`, so exact + # Permuted scores cluster around the observed one, so mean and variance are accumulated as + # sums shifted by it. Both are plain sums, so they merge across blocks with `+=`, and + # `s2/n - (s1/n)**2` does not cancel: `s1/n` is the small residual. s1 = np.zeros(n_features, dtype=np.float64) s2 = np.zeros(n_features, dtype=np.float64) n_blocks = -(-n_perms // block) - # Features are independent and the kernel is nogil, so parallelism lives here rather than - # inside the kernel. Even with fewer features than workers, an in-kernel `prange` over - # permutations measured slower than a half-empty pool. + # Features are independent and the kernel is nogil, so the parallelism is here rather than + # inside the kernel. That held even with fewer features than workers, where an in-kernel + # `prange` over permutations still measured slower. pool_workers = max(1, min(n_jobs, n_features)) def run_feature(m: int, perms: NDArrayA, first: bool) -> None: @@ -484,9 +484,9 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: first = lo == 0 def fill(chunk: range, perms: NDArrayA = perms, lo: int = lo) -> None: - # numpy releases the GIL inside `permutation`, so these draws spread over the same - # pool (as far as `pool_workers` allows). Row `i` always comes from `rngs[lo + i]`, - # so the draw never depends on how the work was scheduled. + # numpy releases the GIL inside `permutation`, so these draws also spread over + # the pool. Row `i` always comes from `rngs[lo + i]`, so the draw does not depend + # on how the work was scheduled. for i in chunk: perms[i] = rngs[lo + i].permutation(n_cells) @@ -494,10 +494,10 @@ def fill(chunk: range, perms: NDArrayA = perms, lo: int = lo) -> None: # another is still filling it. thread_map(fill, _chunks(len(perms), pool_workers), n_jobs=pool_workers) - # Hand each worker a contiguous run rather than one item: a single feature is ~0.06 ms - # against a ~0.03 ms queue round-trip, so per-item dispatch would cost as much as the - # work. Each worker owns its own `m`, so every write lands in a distinct slot. The - # default binds this block's values rather than the loop's last ones. + # Each worker takes a contiguous run of features rather than one at a time; a single + # feature is short enough that per-item dispatch cost about as much as the work. Each + # `m` belongs to one worker, so the writes in `run_feature` never collide. The defaults + # bind this block's values rather than the loop's last ones. def run_chunk(chunk: range, perms: NDArrayA = perms, first: bool = first) -> int: for m in chunk: run_feature(m, perms, first) From a83d7b93f981cd16e08ce6abaaa283e49c2243ff Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:19:32 +0200 Subject: [PATCH 22/28] Correct the comments that had drifted The `_score_perms` docstring still described a running mean and M2, which the shifted sums replaced. The `extract_X` note claimed `genes is None` means every gene, which is false when `highly_variable` is set. `nogil` was credited with avoiding the `parallel=True` abort, which is what `parallel=False` does. `arr.sum()` materializes nothing, so that argument only applied to the centred square. The fold in `_p_value_calc` was called a swap, and a test docstring still referred to numba threads. --- src/squidpy/gr/_ppatterns.py | 32 ++++++++++++++------------------ tests/graph/test_ppatterns.py | 6 +++--- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index ee192be0c..e72e4fe42 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -196,8 +196,8 @@ def extract_X(adata: AnnData, genes: str | Sequence[str] | None) -> tuple[NDArra genes = [genes] if not use_raw: - # `genes is None` resolves to every gene, and `adata[:, genes]` would then copy the - # whole matrix and reindex its columns to produce exactly what it was given. + # full var_names in order: `adata[:, genes]` would copy X to hand back the same + # columns it was given. if len(genes) == adata.n_vars and np.array_equal(np.asarray(genes), adata.var_names.values): return (adata.X if layer is None else adata.layers[layer]).T, genes subset = adata[:, genes] @@ -248,9 +248,7 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr else: raise NotImplementedError(f"Mode `{mode}` is not yet implemented.") - g = adata.obsp[connectivity_key].tocsr( - copy=True - ) # the permutation kernel reads CSR; sklearn's row-normalize is a no-op on CSC + g = adata.obsp[connectivity_key].tocsr(copy=True) if transformation: # row-normalize normalize(g, norm="l1", axis=1, copy=False) @@ -317,15 +315,14 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments Compiled serial on purpose: `_score_perms` parallelizes across features instead, which measured faster than an in-kernel ``prange`` even with fewer features than workers. ``nogil`` is what - makes that legal: numba's default threading layer aborts when a ``parallel=True`` kernel is - entered from two Python threads. + lets that pool enter it concurrently, which a ``parallel=True`` kernel cannot survive under + numba's default threading layer. """ n_perms, n = perms.shape out = np.empty(n_perms, dtype=np.float64) - # Explicit loops rather than ``arr.sum()`` to avoid materializing temporaries. Independence - # from ``n_jobs`` comes from `_score_perms`, where permutation ``p`` is always drawn from - # ``rngs[p]`` and each feature is owned by one worker. + # Independence from ``n_jobs`` comes from `_score_perms`: permutation ``p`` is always drawn + # from ``rngs[p]``, and each feature is owned by one worker. n_nz = nz.shape[0] x_bar = 0.0 for t in range(n_nz): @@ -334,7 +331,7 @@ def _autocorr_perms( # noqa: PLR0917, numba requires positional arguments # sum_i (x_i - x_bar)^2: Moran's denominator, and Geary's. css = 0.0 - for t in range(n_nz): + for t in range(n_nz): # looped rather than ((xv - x_bar) ** 2).sum() to skip the temporary d = xv[t] - x_bar css += d * d css += (n - n_nz) * x_bar * x_bar # every zero entry contributes x_bar^2 @@ -412,8 +409,8 @@ def _score_perms( """Observed scores ``(n_features,)`` and the permutation accumulators `_p_value_calc` needs. The permutation scores are only ever reduced along the permutation axis, so the - ``(n_perms, n_features)`` matrix is never materialized: a tally and a running mean/``M2`` per - feature carry the same information in ``O(n_features)``. + ``(n_perms, n_features)`` matrix is never materialized: a tally, plus sums of the scores + shifted by the observed one, carry the same information in ``O(n_features)``. """ n_cells = g.shape[0] # Match the casts scanpy applies to its own inputs, so the kernel sees the same numbers. @@ -421,7 +418,7 @@ def _score_perms( w = g.data.sum() rngs = np.random.default_rng(rng).spawn(n_perms) # Blocks of at most `_PERM_BLOCK_SIZE` entries; every feature is revisited once per block, so - # the per-block scatter is re-paid. More than one block only past n_perms * n_cells > 2**28. + # the per-block scatter is re-paid. More than one block only once n_perms * n_cells exceeds it. block = int(np.clip(_PERM_BLOCK_SIZE // max(n_cells, 1), 1, n_perms)) buffer = np.empty((block, n_cells), dtype=np.int32) identity = np.arange(n_cells, dtype=np.int32)[None] @@ -484,9 +481,8 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: first = lo == 0 def fill(chunk: range, perms: NDArrayA = perms, lo: int = lo) -> None: - # numpy releases the GIL inside `permutation`, so these draws also spread over - # the pool. Row `i` always comes from `rngs[lo + i]`, so the draw does not depend - # on how the work was scheduled. + # numpy releases the GIL inside `permutation`, so these draws spread over the + # pool rather than serializing on it. for i in chunk: perms[i] = rngs[lo + i].permutation(n_cells) @@ -716,7 +712,7 @@ def _p_value_calc( return results n_perms = sims["n_perms"] - large_perm = sims["count_ge"].copy() # copy: the swap below writes in place + large_perm = sims["count_ge"].copy() # copy: the fold below writes in place # subtract total perm for negative values large_perm[(n_perms - large_perm) < large_perm] = n_perms - large_perm[(n_perms - large_perm) < large_perm] # get p-value based on permutation diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 6bd26cf1a..ad6fd7fb7 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -166,7 +166,7 @@ def test_spatial_autocorr_full_gene_list_reordered(dummy_adata: AnnData): @pytest.mark.parametrize("mode", ["moran", "geary"]) def test_spatial_autocorr_csc_connectivities(dummy_adata: AnnData, mode: str): - """A CSC graph must give the CSR result: the kernel reads CSR and sklearn's row-normalize skips CSC.""" + """A CSC graph must give the CSR result: `normalize(axis=1, copy=False)` leaves CSC untouched.""" key = Key.obsp.spatial_conn() kw = {"mode": mode, "copy": True, "rng": 42, "n_perms": 50} csc = dummy_adata.copy() @@ -183,7 +183,7 @@ def test_spatial_autocorr_v183_positional_backend(dummy_adata: AnnData): Delete this together with the ``backend`` shim. The positional signature it pins only exists because `@deprecated_params` keeps accepting `backend` in its v1.8.3 slot; once that is dropped - for 1.10.0 there is no positional form left to protect and the call below stops compiling. + for 1.10.0 there is no positional form left to protect and the call below starts raising. """ kw = {"mode": "moran", "n_perms": 20, "rng": 0, "copy": True, "n_jobs": 1, "show_progress_bar": False} expected = spatial_autocorr(dummy_adata, **kw) @@ -396,7 +396,7 @@ def test_score_perms_matches_scanpy_per_permutation(mode: str): @pytest.mark.parametrize("mode", ["moran", "geary"]) def test_score_perms_thread_invariant(mode: str): - """Permutation scores must not depend on how many numba threads the kernel runs on.""" + """Permutation scores must not depend on `n_jobs`, which sizes the thread pool.""" from scipy.sparse import csr_matrix from sklearn.neighbors import kneighbors_graph from sklearn.preprocessing import normalize From 7d8e5c67c1ac8a342dee379c2291e3a813e6ff1a Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:26:12 +0200 Subject: [PATCH 23/28] Give degenerate features a NaN z-test Shifting the variance by the observed score makes it exactly zero when every permutation scores alike, where the old two-pass form left a denormal residual. `z_sim` is then NaN, and neither mask in `_p_value_calc` covers NaN, so `np.empty` left those entries at whatever memory held -- often 0.0, the most significant p-value there is. A gene expressed in one cell hits this, which is routine in raw counts. Both p-value arrays start as NaN now. `multipletests` spreads a NaN over its whole input, so the correction runs over the defined p-values alone and leaves the rest NaN. Also drops the n_jobs coverage duplicated across `seq_par`, `perm_blocks` and a separate invariance test, and the `pval_norm`/`var_norm` assertions that `assert_frame_equal` already makes. --- src/squidpy/gr/_ppatterns.py | 16 ++++++--- tests/graph/test_ppatterns.py | 67 ++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index e72e4fe42..701d26dc4 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -272,8 +272,14 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr if corr_method is not None: for pv in filter(lambda x: "pval" in x, df.columns): - _, pvals_adj, _, _ = multipletests(df[pv].values, alpha=0.05, method=corr_method) - df[f"{pv}_{corr_method}"] = pvals_adj + pvals = df[pv].to_numpy(dtype=np.float64) + # a degenerate feature has no p-value to correct, and `multipletests` would spread its + # NaN over every other gene, so correct the defined ones among themselves + defined = np.isfinite(pvals) + adj = np.full(pvals.shape, np.nan) + if defined.any(): + _, adj[defined], _, _ = multipletests(pvals[defined], alpha=0.05, method=corr_method) + df[f"{pv}_{corr_method}"] = adj df.sort_values(by=params["stat"], ascending=params["ascending"], inplace=True) @@ -723,7 +729,9 @@ def _p_value_calc( var_sim = sims["var"] se_score_sim = np.sqrt(var_sim) z_sim = (score - e_score_sim) / se_score_sim - p_z_sim = np.empty(z_sim.shape) + # NaN where a feature's permutations are all identical, so `var_sim` is exactly 0; the masks + # below skip those and `np.empty` would leave them uninitialized. + p_z_sim = np.full(z_sim.shape, np.nan) p_z_sim[z_sim > 0] = 1 - stats.norm.cdf(z_sim[z_sim > 0]) p_z_sim[z_sim <= 0] = stats.norm.cdf(z_sim[z_sim <= 0]) @@ -765,7 +773,7 @@ def _analytic_pval(score: NDArrayA, g: spmatrix | NDArrayA, params: dict[str, An seScore_norm = Vscore_norm ** (1 / 2.0) z_norm = (score - params["expected"]) / seScore_norm - p_norm = np.empty(score.shape) + p_norm = np.full(score.shape, np.nan) # constant features have a NaN score and match neither mask p_norm[z_norm > 0] = 1 - stats.norm.cdf(z_norm[z_norm > 0]) p_norm[z_norm <= 0] = stats.norm.cdf(z_norm[z_norm <= 0]) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index ad6fd7fb7..c01ff3dca 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -37,22 +37,17 @@ def test_spatial_autocorr_seq_par(dummy_adata: AnnData, mode: str): assert "pval_norm_fdr_bh" in dummy_adata.uns[UNS_KEY] assert dummy_adata.uns[UNS_KEY].columns.shape == (4,) assert df.columns.shape == (9,) - # test pval_norm same - # will need to increase the tolerance because numba parallel computations might not be exactly the same - # these pval_norms don't use the seed anyway so the difference is not due to the seed - # see https://github.com/scverse/squidpy/issues/1030 for more details np.testing.assert_allclose(df["pval_norm"].values, df_parallel["pval_norm"].values, atol=1e-12) # test highly variable assert dummy_adata.uns[UNS_KEY].shape != df.shape # assert idx are sorted and contain same elements assert not np.array_equal(idx_df, idx_adata) np.testing.assert_array_equal(sorted(idx_df), sorted(idx_adata)) - # check parallel gives same results - # each permutation now gets its own seed (spawned from a SeedSequence), so the - # simulated p-values no longer depend on how the permutations are split across jobs - np.testing.assert_allclose(df["pval_sim"].values, df_parallel["pval_sim"].values, atol=1e-12) - np.testing.assert_allclose(df["pval_z_sim"].values, df_parallel["pval_z_sim"].values, atol=1e-12) - np.testing.assert_allclose(df["var_sim"].values, df_parallel["var_sim"].values, atol=1e-12) + # each permutation draws from its own spawned generator, so the simulated columns do not + # depend on how the features were split across workers + df_parallel = df_parallel.loc[df.index] # align in case the stat-based sort ties differently + for col in ("pval_sim", "pval_z_sim", "var_sim"): + np.testing.assert_allclose(df[col].values, df_parallel[col].values, atol=1e-12) @pytest.mark.parametrize("mode", ["moran", "geary"]) @@ -77,12 +72,6 @@ def test_spatial_autocorr_reproducibility(dummy_adata: AnnData, n_jobs: int, mod # assert fdr correction in adata.uns assert "pval_sim_fdr_bh" in df_1 assert "pval_norm_fdr_bh" in dummy_adata.uns[UNS_KEY] - # test pval_norm same - # will need to increase the tolerance because numba parallel computations might not be exactly the same - # see https://github.com/scverse/squidpy/issues/1030 for more details about the tolerance - # these pval_norms don't use the seed anyway so the difference is not due to the seed - np.testing.assert_allclose(df_1["pval_norm"].values, df_2["pval_norm"].values, atol=1e-12) - np.testing.assert_allclose(df_1["var_norm"].values, df_2["var_norm"].values, atol=1e-12) assert dummy_adata.uns[UNS_KEY].columns.shape == (4,) assert df_2.columns.shape == (9,) # test highly variable @@ -95,16 +84,39 @@ 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, "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) +def test_spatial_autocorr_degenerate_feature_is_nan(mode: str): + """A gene whose permutations all score alike has no z-test, and must not poison the FDR column. + + Its variance is exactly zero, so the z-score is undefined. Filling `pval_z_sim` with + `np.empty` left those entries at whatever memory held, which reads as a significant + p-value, and a NaN handed to `multipletests` spreads over every other gene. + """ + import scipy.sparse as sps + + from squidpy.gr import spatial_neighbors_knn + + rng = np.random.default_rng(0) + n = 400 + X = np.zeros((n, 3), dtype=np.float32) + X[5, 0] = 4.0 # expressed in a single cell: every permutation gives the same score + X[:, 1] = rng.poisson(2, n) + X[7, 2] = 1.0 + adata = AnnData(sps.csr_matrix(X)) + adata.var_names = ["solo", "normal", "solo2"] + adata.obsm["spatial"] = rng.random((n, 2)) + spatial_neighbors_knn(adata, n_neighs=6) + + df = spatial_autocorr(adata, mode=mode, n_perms=13, rng=3, copy=True, show_progress_bar=False) + degenerate, real = ["solo", "solo2"], "normal" - # align on the gene index in case the stat-based sort order ties differently - df_parallel = df_parallel.loc[df_serial.index] - for col in ["pval_sim", "pval_z_sim", "var_sim"]: - np.testing.assert_allclose(df_serial[col].values, df_parallel[col].values, atol=1e-12) + assert (df.loc[degenerate, "var_sim"] == 0.0).all() + assert df.loc[degenerate, "pval_z_sim"].isna().all() + assert df.loc[degenerate, "pval_z_sim_fdr_bh"].isna().all() + # the gene with a defined z-test keeps one, and its correction is unaffected + assert np.isfinite(df.loc[real, "pval_z_sim"]) + assert np.isfinite(df.loc[real, "pval_z_sim_fdr_bh"]) + # the permutation p-value is a tally, so it stays defined for every gene + assert df["pval_sim"].notna().all() def test_spatial_autocorr_ties_match_scanpy(): @@ -137,14 +149,13 @@ def test_spatial_autocorr_ties_match_scanpy(): @pytest.mark.parametrize("mode", ["moran", "geary"]) def test_spatial_autocorr_perm_blocks(dummy_adata: AnnData, mode: str, monkeypatch): - """Drawing the permutations block by block changes neither the result nor its n_jobs invariance.""" + """Drawing the permutations block by block must not change the result.""" import squidpy.gr._ppatterns as ppatterns kw = {"mode": mode, "copy": True, "rng": 42, "n_perms": 50} expected = spatial_autocorr(dummy_adata, **kw) monkeypatch.setattr(ppatterns, "_PERM_BLOCK_SIZE", 7 * dummy_adata.n_obs) # 8 blocks, the last one short - for n_jobs in (1, 2): - assert_frame_equal(spatial_autocorr(dummy_adata, n_jobs=n_jobs, **kw), expected) + assert_frame_equal(spatial_autocorr(dummy_adata, **kw), expected) def test_spatial_autocorr_full_gene_list_reordered(dummy_adata: AnnData): From 34ec4f47fb5c18cad7ea4acfd202a8682a5ae220 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:29:48 +0200 Subject: [PATCH 24/28] Correct the note on the shifted variance `s1/n` is the observed score's distance from the permutation mean, so it is largest for the features with the most autocorrelation, not a small residual. The cancellation it causes is real, about eps*z**2. --- src/squidpy/gr/_ppatterns.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 701d26dc4..9792dd44a 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -446,9 +446,11 @@ def _score_perms( # Constant features have a zero denominator; scanpy drops them and reports `nan`, so seed with it. score = np.full(n_features, np.nan, dtype=np.float64) count_ge = np.zeros(n_features, dtype=np.int64) # integer tally of `sims >= score`, so exact - # Permuted scores cluster around the observed one, so mean and variance are accumulated as - # sums shifted by it. Both are plain sums, so they merge across blocks with `+=`, and - # `s2/n - (s1/n)**2` does not cancel: `s1/n` is the small residual. + # Mean and variance accumulate as sums of `sims - score`. Both are plain sums, so blocks merge + # with `+=` and no Welford state is carried. `s1/n` is the observed score's distance from the + # permutation mean, so `s2/n - (s1/n)**2` loses about eps*z**2: ~1e-13 at z=27, against ~1e-16 + # for a two-pass variance. Worth it for the simpler merge, but it is a real difference, and it + # makes the variance exactly 0 when every permutation scores alike (see `_p_value_calc`). s1 = np.zeros(n_features, dtype=np.float64) s2 = np.zeros(n_features, dtype=np.float64) n_blocks = -(-n_perms // block) From 2f8d516967793e38244ea875e8361fe8b0448da7 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:32:26 +0200 Subject: [PATCH 25/28] Correct the block-cap cost at 100M cells The note claimed splitting only gets expensive past ~8 blocks, which held at 10M and not at 100M: the block there is two permutations, so the scatter is re-paid every two and the measured cost is 1.3-1.6x at three to five blocks. Block size drives it, not block count. --- src/squidpy/gr/_ppatterns.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 9792dd44a..37837006a 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -55,9 +55,10 @@ # Permutation entries held at once. Permutations are reused across features, so they are # materialized rather than redrawn per feature; the cap keeps that buffer off `n_perms`, which -# users raise for FDR resolution. Splitting costs ~3% at 1M cells and ~10% at 10M (four blocks -# either way), and only becomes expensive past ~8 blocks. It changes no result, see -# `test_spatial_autocorr_perm_blocks`. +# users raise for FDR resolution. Each block re-pays one scatter per feature, so the cost is set +# by the block size this leaves: measured within a few percent up to 10M cells, where the block is +# still tens of permutations, and ~1.3-1.6x at 100M, where it falls to two. Raise the cap if that +# scale matters. It changes no result, see `test_spatial_autocorr_perm_blocks`. _PERM_BLOCK_SIZE = 2**28 From 0636ad0b8462bad5675837656f9e3eb6dfbdec22 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 11:49:55 +0200 Subject: [PATCH 26/28] Silence degenerate z-score warning --- src/squidpy/gr/_ppatterns.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 37837006a..125c09c9f 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -265,7 +265,8 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr else: score, score_perms = params["func"](g, vals), None # type: ignore - with np.errstate(divide="ignore"): + # a degenerate feature has `var_sim == 0`, so its z score is an invalid 0/0, not a divide + with np.errstate(divide="ignore", invalid="ignore"): pval_results = _p_value_calc(score, score_perms, g, params) data_dict: dict[str, Any] = {str(params["stat"]): score, **pval_results} From 7b71495d23682fca875b6b8b5da79c7400999f0c Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 12:03:18 +0200 Subject: [PATCH 27/28] Centre dense autocorr features --- src/squidpy/gr/_ppatterns.py | 6 +++++- tests/graph/test_ppatterns.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 125c09c9f..8cb663a79 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -470,7 +470,11 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: xv = np.ascontiguousarray(vals.data[lo_m:hi_m], dtype=np.float64) else: nz = all_cells - xv = np.ascontiguousarray(vals[m], dtype=np.float64) + # both statistics are invariant to a shift, and centring here keeps the kernel's + # `g @ x - x_bar * w_sum` from cancelling on a feature whose mean dwarfs its spread. + # A sparse feature cannot reach that ratio, so it pays nothing for this. + xv = np.asarray(vals[m], dtype=np.float64) + xv = xv - xv.mean() if len(nz) == 0 or (xv.min() == xv.max() and (len(nz) == n_cells or xv[0] == 0.0)): return # constant feature: scanpy drops it and reports `nan` args = (tptr, tind, tdat, w_sum, col_sum, nz, xv, w) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index c01ff3dca..d7c8caeef 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -119,6 +119,31 @@ def test_spatial_autocorr_degenerate_feature_is_nan(mode: str): assert df["pval_sim"].notna().all() +@pytest.mark.parametrize(("mode", "stat"), [("moran", "I"), ("geary", "C")]) +def test_spatial_autocorr_offset_feature_precision(mode: str, stat: str): + """Both statistics ignore a constant shift, so a large offset must not move the score. + + The kernel rebuilds the centred quantities by subtraction, e.g. `g @ x - x_bar * w_sum`, + which cancels away roughly `(mean / sd) ** 2` digits. Dense features are centred before + they reach it; without that, an offset of 1e6 moved Moran's I by several percent. + """ + from squidpy.gr import spatial_neighbors_knn + + rng = np.random.default_rng(1) + n = 400 + base = rng.standard_normal(n) + adata = AnnData(np.stack([base, base + 1e6], axis=1).astype(np.float64)) + adata.var_names = ["plain", "offset"] + adata.obsm["spatial"] = rng.random((n, 2)) + spatial_neighbors_knn(adata, n_neighs=6) + + # `n_perms` routes the observed score through the kernel rather than scanpy + df = spatial_autocorr(adata, mode=mode, n_perms=1, rng=0, copy=True, show_progress_bar=False) + # 1e-8 is the floor for recovering a unit-scale value from one offset by 1e6, not slack: + # without the centring the difference was 5e-2 + np.testing.assert_allclose(df.loc["offset", stat], df.loc["plain", stat], rtol=1e-8) + + def test_spatial_autocorr_ties_match_scanpy(): """A gene with one non-zero count ties most permutations exactly; ties must count as in scanpy.""" import scipy.sparse as sps From 00be7bece2b9da438c60edd1a16f55e84be31c5b Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Sat, 10 Oct 2026 12:11:43 +0200 Subject: [PATCH 28/28] Drop GIL note on permutation fill --- src/squidpy/gr/_ppatterns.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 8cb663a79..c242a26d3 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -495,8 +495,6 @@ def run_feature(m: int, perms: NDArrayA, first: bool) -> None: first = lo == 0 def fill(chunk: range, perms: NDArrayA = perms, lo: int = lo) -> None: - # numpy releases the GIL inside `permutation`, so these draws spread over the - # pool rather than serializing on it. for i in chunk: perms[i] = rngs[lo + i].permutation(n_cells)