From 0aefdec8a5bce32ceabee9fa5855c39eddbb34e8 Mon Sep 17 00:00:00 2001 From: melocery <90510617+melocery@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:45:58 +0200 Subject: [PATCH 1/5] add spatialleiden --- src/squidpy/_constants/_constants.py | 1 + src/squidpy/gr/_niche.py | 180 ++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/squidpy/_constants/_constants.py b/src/squidpy/_constants/_constants.py index 403f072ba..f88e41cb6 100644 --- a/src/squidpy/_constants/_constants.py +++ b/src/squidpy/_constants/_constants.py @@ -130,5 +130,6 @@ class NicheDefinitions(ModeEnum): NEIGHBORHOOD = "neighborhood" UTAG = "utag" CELLCHARTER = "cellcharter" + SPATIALLEIDEN = "spatialleiden" SPOT = "spot" BANKSY = "banksy" diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 13c7afef9..c5d406510 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -19,6 +19,9 @@ from spatialdata import SpatialData from spatialdata._logging import logger as logg +import leidenalg as la +import spatialleiden as sl + from squidpy._constants._constants import NicheDefinitions from squidpy._docs import d, inject_docs @@ -29,13 +32,13 @@ @inject_docs(fla=NicheDefinitions) def calculate_niche( data: AnnData | SpatialData, - flavor: Literal["neighborhood", "utag", "cellcharter"], + flavor: Literal["neighborhood", "utag", "cellcharter", "spatialleiden"], library_key: str | None = None, table_key: str | None = None, mask: pd.core.series.Series = None, groups: str | None = None, n_neighbors: int | None = None, - resolutions: float | list[float] | None = None, + resolutions: float | list[float] | tuple[float, float] | None = None, min_niche_size: int | None = None, scale: bool = True, abs_nhood: bool = False, @@ -45,6 +48,11 @@ def calculate_niche( n_components: int | None = None, random_state: int = 42, spatial_connectivities_key: str = "spatial_connectivities", + latent_connectivities_key: str = "connectivities", + layer_ratio: float = 1, + n_iterations: int = -1, + # use_weights: bool | tuple[bool, bool] = True, + use_weights: bool | tuple[bool, bool] = (True, True), inplace: bool = True, ) -> AnnData: """ @@ -59,6 +67,7 @@ def calculate_niche( - `{fla.NEIGHBORHOOD.s!r}` - cluster the neighborhood profile. - `{fla.UTAG.s!r}` - use utag algorithm (matrix multiplication). - `{fla.CELLCHARTER.s!r}` - cluster adjacency matrix with Gaussian Mixture Model (GMM) using CellCharter's approach. + - `{fla.SPATIALLEIDEN.s!r}` - cluster spatially resolved omics data using Multiplex Leiden. %(library_key)s If provided, niches will be calculated separately for each unique value in this column. Each niche will be prefixed with the library identifier. @@ -74,8 +83,10 @@ def calculate_niche( Number of neighbors to use for 'scanpy.pp.neighbors' before clustering using leiden algorithm. Required if flavor == `{fla.NEIGHBORHOOD.s!r}` or flavor == `{fla.UTAG.s!r}`. resolutions - List of resolutions to use for leiden clustering. + List of resolutions to use for leiden clustering. + In the case of spatialleiden you can pass a tuple. Resolution for the latent space and spatial layer, respectively. A single float applies to both layers. Required if flavor == `{fla.NEIGHBORHOOD.s!r}` or flavor == `{fla.UTAG.s!r}`. + Optional if flavor == `{fla.SPATIALLEIDEN.s!r}`. min_niche_size Minimum required size of a niche. Niches with fewer cells will be labeled as 'not_a_niche'. Optional if flavor == `{fla.NEIGHBORHOOD.s!r}`. @@ -99,10 +110,23 @@ def calculate_niche( Number of components to use for GMM. Required if flavor == `{fla.CELLCHARTER.s!r}`. random_state - Random state to use for GMM. - Optional if flavor == `{fla.CELLCHARTER.s!r}`. + Random state to use for GMM or SpatialLeiden. + Optional if flavor == `{fla.CELLCHARTER.s!r}` or flavor == `{fla.SPATIALLEIDEN.s!r}`. spatial_connectivities_key Key in `adata.obsp` where spatial connectivities are stored. + Required if flavor == `{fla.SPATIALLEIDEN.s!r}`. + latent_connectivities_key + Key in `adata.obsp` where gene expression connectivities are stored. + Required if flavor == `{fla.SPATIALLEIDEN.s!r}`. + layer_ratio + The ratio of the weighting of the layers; latent space vs spatial. A higher ratio will increase relevance of the spatial neighbors and lead to more spatially homogeneous clusters. + Optional if flavor == `{fla.SPATIALLEIDEN.s!r}`. + n_iterations + Number of iterations to run the Leiden algorithm. If the number is negative it runs until convergence. + Optional if flavor == `{fla.SPATIALLEIDEN.s!r}`. + use_weights + Whether to use weights for the edges for latent space and spatial neighbors, respectively. A single bool applies to both layers. + Optional if flavor == `{fla.SPATIALLEIDEN.s!r}`. inplace If 'True', perform the operation in place. If 'False', return a new AnnData object with the niche labels. @@ -127,11 +151,21 @@ def calculate_niche( aggregation, n_components, random_state, + spatial_connectivities_key, + latent_connectivities_key, + layer_ratio, + n_iterations, + use_weights, inplace, ) + # if resolutions is None: + # resolutions = [0.5] if resolutions is None: - resolutions = [0.5] + if flavor == "spatialleiden": + resolutions = (1.0, 1.0) + else: + resolutions = [0.5] if distance is None: distance = 1 @@ -148,6 +182,12 @@ def calculate_niche( f"Key '{spatial_connectivities_key}' not found in `adata.obsp`. " "If you haven't computed a spatial neighborhood graph yet, use `sq.gr.spatial_neighbors`." ) + + if latent_connectivities_key not in adata.obsp.keys(): + raise KeyError( + f"Key '{latent_connectivities_key}' not found in `adata.obsp`. " + "If you haven't computed a latent neighborhood graph yet, use `sc.pp.neighbors`." + ) result_columns = _get_result_columns( flavor=flavor, @@ -197,6 +237,10 @@ def calculate_niche( n_components=n_components, random_state=random_state, spatial_connectivities_key=spatial_connectivities_key, + latent_connectivities_key=latent_connectivities_key, + layer_ratio=layer_ratio, + n_iterations=n_iterations, + use_weights=use_weights, inplace=False, ) @@ -225,6 +269,10 @@ def calculate_niche( n_components, random_state, spatial_connectivities_key, + latent_connectivities_key, + layer_ratio, + n_iterations, + use_weights, ) if not inplace: @@ -264,6 +312,10 @@ def _get_result_columns( return [base_column] elif libraries is not None and len(libraries) > 0: return [f"{base_column}_{lib}" for lib in libraries] + + if flavor == "spatialleiden": + base_column = "spatialleiden" + return [base_column] # For neighborhood and utag, we need to handle resolutions if not isinstance(resolutions, list): @@ -293,6 +345,10 @@ def _calculate_niches( n_components: int | None, random_state: int, spatial_connectivities_key: str, + latent_connectivities_key: str, + layer_ratio: float, + n_iterations: int, + use_weights: tuple[bool, bool], ) -> None: """Calculate niches using the specified flavor and parameters.""" if flavor == "neighborhood": @@ -322,6 +378,17 @@ def _calculate_niches( random_state, spatial_connectivities_key, ) + elif flavor == "spatialleiden": + _get_spatialleiden_domains( + adata, + spatial_connectivities_key, + latent_connectivities_key, + resolutions, + layer_ratio, + use_weights, + n_iterations, + random_state, + ) def _get_nhood_profile_niches( @@ -627,6 +694,47 @@ def _get_GMM_clusters(A: NDArray[np.float64], n_components: int, random_state: i return labels +def _get_spatialleiden_domains( + adata, + spatial_connectivities_key, + latent_connectivities_key, + resolutions, + layer_ratio, + use_weights, + n_iterations, + random_state, +) -> None: + + """ + Perform SpatialLeiden clustering. + + This is a wrapper around :py:func:`spatialleiden.multiplex_leiden` that uses :py:class:`anndata.AnnData` as input and works with two layers; one latent space and one spatial layer. + + Adapted from https://github.com/HiDiHlabs/SpatialLeiden/. + """ + # sl.spatialleiden( + # adata, + # resolution = resolutions, + # use_weights = use_weights, + # n_iterations = n_iterations, + # layer_ratio = layer_ratio, + # latent_neighbors_key = latent_connectivities_key, + # spatial_neighbors_key = spatial_connectivities_key, + # random_state = random_state, + # ) + sl.spatialleiden( + adata, + resolution = resolutions, + use_weights = use_weights, + n_iterations = n_iterations, + layer_ratio = layer_ratio, + latent_distance_key = latent_connectivities_key, + spatial_distance_key = spatial_connectivities_key, + seed = random_state, + ) + + return + def _fide_score(adata: AnnData, niche_key: str, average: bool) -> Any: """ @@ -667,12 +775,12 @@ def _jensen_shannon_divergence(adata: AnnData, niche_key: str, library_key: str) def _validate_niche_args( data: AnnData | SpatialData, - flavor: Literal["neighborhood", "utag", "cellcharter"], + flavor: Literal["neighborhood", "utag", "cellcharter", "spatialleiden"], library_key: str | None, table_key: str | None, groups: str | None, n_neighbors: int | None, - resolutions: float | list[float] | None, + resolutions: float | list[float] | tuple[float, float] | None, min_niche_size: int | None, scale: bool, abs_nhood: bool, @@ -681,6 +789,11 @@ def _validate_niche_args( aggregation: str | None, n_components: int | None, random_state: int, + spatial_connectivities_key: str, + latent_connectivities_key: str, + layer_ratio: float, + n_iterations: int, + use_weights: tuple[bool, bool], inplace: bool, ) -> None: """ @@ -697,8 +810,8 @@ def _validate_niche_args( if not isinstance(data, AnnData | SpatialData): raise TypeError(f"'data' must be an AnnData or SpatialData object, got {type(data).__name__}") - if flavor not in ["neighborhood", "utag", "cellcharter"]: - raise ValueError(f"Invalid flavor '{flavor}'. Please choose one of 'neighborhood', 'utag', 'cellcharter'.") + if flavor not in ["neighborhood", "utag", "cellcharter", "spatialleiden"]: + raise ValueError(f"Invalid flavor '{flavor}'. Please choose one of 'neighborhood', 'utag', 'cellcharter', 'spatialleiden'.") if library_key is not None: if not isinstance(library_key, str): @@ -718,10 +831,11 @@ def _validate_niche_args( raise TypeError(f"'n_neighbors' must be an integer, got {type(n_neighbors).__name__}") if resolutions is not None: - if not isinstance(resolutions, float | list): - raise TypeError(f"'resolutions' must be a float or list of floats, got {type(resolutions).__name__}") - if isinstance(resolutions, list) and not all(isinstance(res, float) for res in resolutions): - raise TypeError("All elements in 'resolutions' list must be floats") + if not isinstance(resolutions, (float, list, tuple)): + raise TypeError(f"'resolutions' must be a float, list, or tuple of floats, got {type(resolutions).__name__}") + if isinstance(resolutions, (list, tuple)): + if not all(isinstance(res, float) for res in resolutions): + raise TypeError("All elements in 'resolutions' must be floats") if n_hop_weights is not None and not isinstance(n_hop_weights, list): raise TypeError(f"'n_hop_weights' must be a list of floats, got {type(n_hop_weights).__name__}") @@ -773,6 +887,24 @@ def _validate_niche_args( "n_hop_weights", ], }, + "spatialleiden": { + "required": ["latent_connectivities_key", "spatial_connectivities_key"], + "optional": [ + "resolutions", + "layer_ratio", + "n_iterations", + "use_weights", + "random_state", + ], + "unused": [ + "groups", + "min_niche_size", + "scale", + "abs_nhood", + "n_neighbors", + "n_hop_weights", + ], + }, } for param_name in flavor_param_specs[flavor]["required"]: @@ -831,6 +963,26 @@ def _validate_niche_args( # for mypy if resolutions is None: resolutions = [0.0] + + elif flavor == "spatialleiden": + # if not isinstance(latent_connectivities_key, str): + # raise TypeError(f"'latent_connectivities_key' must be a string, got {type(latent_connectivities_key).__name__}") + # if not isinstance(spatial_connectivities_key, str): + # raise TypeError(f"'spatial_connectivities_key' must be a string, got {type(spatial_connectivities_key).__name__}") + + if not isinstance(layer_ratio, float | int): + raise TypeError(f"'layer_ratio' must be a float, got {type(layer_ratio).__name__}") + if not isinstance(n_iterations, int): + raise TypeError(f"'n_iterations' must be an integer, got {type(n_iterations).__name__}") + if not isinstance(use_weights, tuple) or len(use_weights) != 2 or not all(isinstance(x, bool) for x in use_weights): + # if not isinstance(use_weights, tuple) or not all(isinstance(x, bool) for x in use_weights): + raise TypeError(f"'use_weights' must be a tuple of two bools, got {use_weights}") + if not isinstance(random_state, int): + raise TypeError(f"'random_state' must be an integer, got {type(random_state).__name__}") + + if resolutions is None: + resolutions = (1.0, 1.0) + # resolutions = [1.0] if not isinstance(inplace, bool): raise TypeError(f"'inplace' must be a boolean, got {type(inplace).__name__}") From 65edf84e7edd71701fa25f0ce121a8d9318df0cd Mon Sep 17 00:00:00 2001 From: melocery <90510617+melocery@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:35:57 +0200 Subject: [PATCH 2/5] support updated spatialleiden API + resolution list --- src/squidpy/gr/_niche.py | 99 ++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 54 deletions(-) diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index c5d406510..dc1fbb657 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -19,7 +19,6 @@ from spatialdata import SpatialData from spatialdata._logging import logger as logg -import leidenalg as la import spatialleiden as sl from squidpy._constants._constants import NicheDefinitions @@ -49,10 +48,9 @@ def calculate_niche( random_state: int = 42, spatial_connectivities_key: str = "spatial_connectivities", latent_connectivities_key: str = "connectivities", - layer_ratio: float = 1, + layer_ratio: float = 1.0, n_iterations: int = -1, - # use_weights: bool | tuple[bool, bool] = True, - use_weights: bool | tuple[bool, bool] = (True, True), + use_weights: bool | tuple[bool, bool] = True, inplace: bool = True, ) -> AnnData: """ @@ -159,13 +157,8 @@ def calculate_niche( inplace, ) - # if resolutions is None: - # resolutions = [0.5] if resolutions is None: - if flavor == "spatialleiden": - resolutions = (1.0, 1.0) - else: - resolutions = [0.5] + resolutions = [0.5] if distance is None: distance = 1 @@ -313,15 +306,17 @@ def _get_result_columns( elif libraries is not None and len(libraries) > 0: return [f"{base_column}_{lib}" for lib in libraries] - if flavor == "spatialleiden": - base_column = "spatialleiden" - return [base_column] - # For neighborhood and utag, we need to handle resolutions if not isinstance(resolutions, list): resolutions = [resolutions] - prefix = f"nhood_niche{library_str}" if flavor == "neighborhood" else f"utag_niche{library_str}" + if flavor == "neighborhood": + prefix = f"nhood_niche{library_str}" + elif flavor == "utag": + prefix = f"utag_niche{library_str}" + elif flavor == "spatialleiden": + prefix = f"spatialleiden{library_str}" + if library_key is None: return [f"{prefix}_res={res}" for res in resolutions] else: @@ -695,14 +690,14 @@ def _get_GMM_clusters(A: NDArray[np.float64], n_components: int, random_state: i return labels def _get_spatialleiden_domains( - adata, - spatial_connectivities_key, - latent_connectivities_key, - resolutions, - layer_ratio, - use_weights, - n_iterations, - random_state, + adata: AnnData, + spatial_connectivities_key: str, + latent_connectivities_key: str, + resolutions: float | list[float] | tuple[float, float], + layer_ratio: float, + use_weights: bool | tuple[bool, bool], + n_iterations: int, + random_state: int, ) -> None: """ @@ -712,27 +707,24 @@ def _get_spatialleiden_domains( Adapted from https://github.com/HiDiHlabs/SpatialLeiden/. """ - # sl.spatialleiden( - # adata, - # resolution = resolutions, - # use_weights = use_weights, - # n_iterations = n_iterations, - # layer_ratio = layer_ratio, - # latent_neighbors_key = latent_connectivities_key, - # spatial_neighbors_key = spatial_connectivities_key, - # random_state = random_state, - # ) - sl.spatialleiden( - adata, - resolution = resolutions, - use_weights = use_weights, - n_iterations = n_iterations, - layer_ratio = layer_ratio, - latent_distance_key = latent_connectivities_key, - spatial_distance_key = spatial_connectivities_key, - seed = random_state, - ) - + + if not isinstance(resolutions, list): + resolutions = [resolutions] + + for res in resolutions: + sl.spatialleiden( + adata, + resolution = res, + use_weights = use_weights, + n_iterations = n_iterations, + layer_ratio = layer_ratio, + latent_neighbors_key = latent_connectivities_key, + spatial_neighbors_key = spatial_connectivities_key, + random_state = random_state, + directed = False, + key_added=f"spatialleiden_res={res}" + ) + return @@ -965,24 +957,23 @@ def _validate_niche_args( resolutions = [0.0] elif flavor == "spatialleiden": - # if not isinstance(latent_connectivities_key, str): - # raise TypeError(f"'latent_connectivities_key' must be a string, got {type(latent_connectivities_key).__name__}") - # if not isinstance(spatial_connectivities_key, str): - # raise TypeError(f"'spatial_connectivities_key' must be a string, got {type(spatial_connectivities_key).__name__}") + if not isinstance(latent_connectivities_key, str): + raise TypeError(f"'latent_connectivities_key' must be a string, got {type(latent_connectivities_key).__name__}") + if not isinstance(spatial_connectivities_key, str): + raise TypeError(f"'spatial_connectivities_key' must be a string, got {type(spatial_connectivities_key).__name__}") if not isinstance(layer_ratio, float | int): raise TypeError(f"'layer_ratio' must be a float, got {type(layer_ratio).__name__}") if not isinstance(n_iterations, int): raise TypeError(f"'n_iterations' must be an integer, got {type(n_iterations).__name__}") - if not isinstance(use_weights, tuple) or len(use_weights) != 2 or not all(isinstance(x, bool) for x in use_weights): - # if not isinstance(use_weights, tuple) or not all(isinstance(x, bool) for x in use_weights): - raise TypeError(f"'use_weights' must be a tuple of two bools, got {use_weights}") + if not (isinstance(use_weights, bool) or ( + isinstance(use_weights, tuple) and len(use_weights) == 2 and all(isinstance(x, bool) for x in use_weights))): + raise TypeError(f"'use_weights' must be a bool or a tuple of two bools, got {use_weights!r}") if not isinstance(random_state, int): raise TypeError(f"'random_state' must be an integer, got {type(random_state).__name__}") if resolutions is None: - resolutions = (1.0, 1.0) - # resolutions = [1.0] + resolutions = [1.0] if not isinstance(inplace, bool): raise TypeError(f"'inplace' must be a boolean, got {type(inplace).__name__}") @@ -995,7 +986,7 @@ def _check_unnecessary_args(flavor: str, param_dict: dict[str, Any], param_specs Parameters ---------- flavor - The flavor being used ('neighborhood', 'utag', or 'cellcharter') + The flavor being used ('neighborhood', 'utag', 'cellcharter', or 'spatialleiden') param_dict Dictionary of parameter names to their values param_specs From cb6faed176c59f0bb25b7357c06caa4d466c0fe5 Mon Sep 17 00:00:00 2001 From: melocery <90510617+melocery@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:43:53 +0200 Subject: [PATCH 3/5] update type signatures for resolutions --- src/squidpy/gr/_niche.py | 102 ++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 40 deletions(-) diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index dc1fbb657..cd55c3212 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -9,6 +9,7 @@ import pandas as pd import scanpy as sc import scipy.sparse as sps +import spatialleiden as sl from anndata import AnnData from numpy.typing import NDArray from scipy.sparse import coo_matrix, hstack, issparse, spdiags @@ -19,8 +20,6 @@ from spatialdata import SpatialData from spatialdata._logging import logger as logg -import spatialleiden as sl - from squidpy._constants._constants import NicheDefinitions from squidpy._docs import d, inject_docs @@ -37,7 +36,7 @@ def calculate_niche( mask: pd.core.series.Series = None, groups: str | None = None, n_neighbors: int | None = None, - resolutions: float | list[float] | tuple[float, float] | None = None, + resolutions: float | tuple[float, float] | list[float | tuple[float, float]] | None = None, min_niche_size: int | None = None, scale: bool = True, abs_nhood: bool = False, @@ -81,7 +80,7 @@ def calculate_niche( Number of neighbors to use for 'scanpy.pp.neighbors' before clustering using leiden algorithm. Required if flavor == `{fla.NEIGHBORHOOD.s!r}` or flavor == `{fla.UTAG.s!r}`. resolutions - List of resolutions to use for leiden clustering. + List of resolutions to use for leiden clustering. In the case of spatialleiden you can pass a tuple. Resolution for the latent space and spatial layer, respectively. A single float applies to both layers. Required if flavor == `{fla.NEIGHBORHOOD.s!r}` or flavor == `{fla.UTAG.s!r}`. Optional if flavor == `{fla.SPATIALLEIDEN.s!r}`. @@ -175,7 +174,7 @@ def calculate_niche( f"Key '{spatial_connectivities_key}' not found in `adata.obsp`. " "If you haven't computed a spatial neighborhood graph yet, use `sq.gr.spatial_neighbors`." ) - + if latent_connectivities_key not in adata.obsp.keys(): raise KeyError( f"Key '{latent_connectivities_key}' not found in `adata.obsp`. " @@ -291,7 +290,7 @@ def calculate_niche( def _get_result_columns( flavor: str, - resolutions: float | list[float], + resolutions: float | tuple[float, float] | list[float | tuple[float, float]], library_key: str | None, libraries: list[str] | None, ) -> list[str]: @@ -305,8 +304,8 @@ def _get_result_columns( return [base_column] elif libraries is not None and len(libraries) > 0: return [f"{base_column}_{lib}" for lib in libraries] - - # For neighborhood and utag, we need to handle resolutions + + # For neighborhood, utag and spatialleiden, we need to handle resolutions if not isinstance(resolutions, list): resolutions = [resolutions] @@ -330,7 +329,7 @@ def _calculate_niches( flavor: str, groups: str | None, n_neighbors: int | None, - resolutions: float | list[float], + resolutions: float | tuple[float, float] | list[float | tuple[float, float]], min_niche_size: int | None, scale: bool, abs_nhood: bool, @@ -343,10 +342,11 @@ def _calculate_niches( latent_connectivities_key: str, layer_ratio: float, n_iterations: int, - use_weights: tuple[bool, bool], + use_weights: bool | tuple[bool, bool], ) -> None: """Calculate niches using the specified flavor and parameters.""" if flavor == "neighborhood": + assert isinstance(resolutions, float | list) _get_nhood_profile_niches( adata, mask, @@ -361,6 +361,7 @@ def _calculate_niches( spatial_connectivities_key, ) elif flavor == "utag": + assert isinstance(resolutions, float | list) _get_utag_niches(adata, n_neighbors, resolutions, spatial_connectivities_key) elif flavor == "cellcharter": assert isinstance(aggregation, str) # for mypy @@ -373,7 +374,7 @@ def _calculate_niches( random_state, spatial_connectivities_key, ) - elif flavor == "spatialleiden": + elif flavor == "spatialleiden": _get_spatialleiden_domains( adata, spatial_connectivities_key, @@ -391,7 +392,7 @@ def _get_nhood_profile_niches( mask: pd.core.series.Series | None, groups: str | None, n_neighbors: int | None, - resolutions: float | list[float], + resolutions: float | tuple[float, float] | list[float | tuple[float, float]], min_niche_size: int | None, scale: bool, abs_nhood: bool, @@ -503,7 +504,7 @@ def _get_nhood_profile_niches( def _get_utag_niches( adata: AnnData, n_neighbors: int | None, - resolutions: float | list[float], + resolutions: float | tuple[float, float] | list[float | tuple[float, float]], spatial_connectivities_key: str, ) -> None: """ @@ -689,17 +690,17 @@ def _get_GMM_clusters(A: NDArray[np.float64], n_components: int, random_state: i return labels + def _get_spatialleiden_domains( adata: AnnData, spatial_connectivities_key: str, latent_connectivities_key: str, - resolutions: float | list[float] | tuple[float, float], + resolutions: float | tuple[float, float] | list[float | tuple[float, float]], layer_ratio: float, use_weights: bool | tuple[bool, bool], n_iterations: int, random_state: int, ) -> None: - """ Perform SpatialLeiden clustering. @@ -714,17 +715,17 @@ def _get_spatialleiden_domains( for res in resolutions: sl.spatialleiden( adata, - resolution = res, - use_weights = use_weights, - n_iterations = n_iterations, - layer_ratio = layer_ratio, - latent_neighbors_key = latent_connectivities_key, - spatial_neighbors_key = spatial_connectivities_key, - random_state = random_state, - directed = False, - key_added=f"spatialleiden_res={res}" - ) - + resolution=res, + use_weights=use_weights, + n_iterations=n_iterations, + layer_ratio=layer_ratio, + latent_neighbors_key=latent_connectivities_key, + spatial_neighbors_key=spatial_connectivities_key, + random_state=random_state, + directed=False, + key_added=f"spatialleiden_res={res}", + ) + return @@ -772,7 +773,7 @@ def _validate_niche_args( table_key: str | None, groups: str | None, n_neighbors: int | None, - resolutions: float | list[float] | tuple[float, float] | None, + resolutions: float | tuple[float, float] | list[float | tuple[float, float]] | None, min_niche_size: int | None, scale: bool, abs_nhood: bool, @@ -785,7 +786,7 @@ def _validate_niche_args( latent_connectivities_key: str, layer_ratio: float, n_iterations: int, - use_weights: tuple[bool, bool], + use_weights: bool | tuple[bool, bool], inplace: bool, ) -> None: """ @@ -803,7 +804,9 @@ def _validate_niche_args( raise TypeError(f"'data' must be an AnnData or SpatialData object, got {type(data).__name__}") if flavor not in ["neighborhood", "utag", "cellcharter", "spatialleiden"]: - raise ValueError(f"Invalid flavor '{flavor}'. Please choose one of 'neighborhood', 'utag', 'cellcharter', 'spatialleiden'.") + raise ValueError( + f"Invalid flavor '{flavor}'. Please choose one of 'neighborhood', 'utag', 'cellcharter', 'spatialleiden'." + ) if library_key is not None: if not isinstance(library_key, str): @@ -823,11 +826,20 @@ def _validate_niche_args( raise TypeError(f"'n_neighbors' must be an integer, got {type(n_neighbors).__name__}") if resolutions is not None: - if not isinstance(resolutions, (float, list, tuple)): - raise TypeError(f"'resolutions' must be a float, list, or tuple of floats, got {type(resolutions).__name__}") - if isinstance(resolutions, (list, tuple)): - if not all(isinstance(res, float) for res in resolutions): - raise TypeError("All elements in 'resolutions' must be floats") + if not isinstance(resolutions, float | tuple | list): + raise TypeError( + f"'resolutions' must be a float, a tuple of floats, a list of floats, or a list containing floats and/or tuples of floats, got {type(resolutions).__name__}" + ) + + if isinstance(resolutions, tuple): + if not all(isinstance(x, float) for x in resolutions): + raise TypeError("All elements in the tuple 'resolutions' must be floats.") + elif isinstance(resolutions, list): + for item in resolutions: + if not ( + isinstance(item, float) or (isinstance(item, tuple) and all(isinstance(i, float) for i in item)) + ): + raise TypeError("Each item in the list 'resolutions' must be a float or a tuple of floats.") if n_hop_weights is not None and not isinstance(n_hop_weights, list): raise TypeError(f"'n_hop_weights' must be a list of floats, got {type(n_hop_weights).__name__}") @@ -955,23 +967,33 @@ def _validate_niche_args( # for mypy if resolutions is None: resolutions = [0.0] - + elif flavor == "spatialleiden": if not isinstance(latent_connectivities_key, str): - raise TypeError(f"'latent_connectivities_key' must be a string, got {type(latent_connectivities_key).__name__}") + raise TypeError( + f"'latent_connectivities_key' must be a string, got {type(latent_connectivities_key).__name__}" + ) if not isinstance(spatial_connectivities_key, str): - raise TypeError(f"'spatial_connectivities_key' must be a string, got {type(spatial_connectivities_key).__name__}") + raise TypeError( + f"'spatial_connectivities_key' must be a string, got {type(spatial_connectivities_key).__name__}" + ) if not isinstance(layer_ratio, float | int): raise TypeError(f"'layer_ratio' must be a float, got {type(layer_ratio).__name__}") if not isinstance(n_iterations, int): raise TypeError(f"'n_iterations' must be an integer, got {type(n_iterations).__name__}") - if not (isinstance(use_weights, bool) or ( - isinstance(use_weights, tuple) and len(use_weights) == 2 and all(isinstance(x, bool) for x in use_weights))): + if not ( + isinstance(use_weights, bool) + or ( + isinstance(use_weights, tuple) + and len(use_weights) == 2 + and all(isinstance(x, bool) for x in use_weights) + ) + ): raise TypeError(f"'use_weights' must be a bool or a tuple of two bools, got {use_weights!r}") if not isinstance(random_state, int): raise TypeError(f"'random_state' must be an integer, got {type(random_state).__name__}") - + if resolutions is None: resolutions = [1.0] From 1eed4dd7e49798f7d9eb2dd9164ee12554d829f3 Mon Sep 17 00:00:00 2001 From: melocery <90510617+melocery@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:52:58 +0200 Subject: [PATCH 4/5] optional dependency --- pyproject.toml | 1 + src/squidpy/gr/_niche.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 80615c34a..01e7006ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ docs = [ "myst-nb>=0.17.1", "sphinx_copybutton>=0.5.0", ] +spatialleiden = ["spatialleiden>=0.3.0"] [project.urls] Homepage = "https://github.com/scverse/squidpy" diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index cd55c3212..7c476cd30 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -9,7 +9,6 @@ import pandas as pd import scanpy as sc import scipy.sparse as sps -import spatialleiden as sl from anndata import AnnData from numpy.typing import NDArray from scipy.sparse import coo_matrix, hstack, issparse, spdiags @@ -708,6 +707,11 @@ def _get_spatialleiden_domains( Adapted from https://github.com/HiDiHlabs/SpatialLeiden/. """ + try: + import spatialleiden as sl + except ImportError as e: + msg = "Please install the spatialleiden algorithm: `conda install bioconda::spatialleiden` or `pip install spatialleiden`." + raise ImportError(msg) from e if not isinstance(resolutions, list): resolutions = [resolutions] From fb1d7400da4783df01841d47f0b754c000a154a4 Mon Sep 17 00:00:00 2001 From: melocery <90510617+melocery@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:24:00 +0200 Subject: [PATCH 5/5] try fix test --- src/squidpy/gr/_niche.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 7c476cd30..847853ba8 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -174,7 +174,7 @@ def calculate_niche( "If you haven't computed a spatial neighborhood graph yet, use `sq.gr.spatial_neighbors`." ) - if latent_connectivities_key not in adata.obsp.keys(): + if flavor == "spatialleiden" and (latent_connectivities_key not in adata.obsp.keys()): raise KeyError( f"Key '{latent_connectivities_key}' not found in `adata.obsp`. " "If you haven't computed a latent neighborhood graph yet, use `sc.pp.neighbors`."