From 51a6e2180ea96814fa1f47744aa736cd83789483 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Fri, 25 Apr 2025 15:03:05 +0200 Subject: [PATCH 01/24] add conditional and total normalization --- src/squidpy/gr/_nhood.py | 107 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index d8f09ed1d..cb4ad39b3 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -130,6 +130,7 @@ def nhood_enrichment( copy: bool = False, n_jobs: int | None = None, backend: str = "loky", + normalization: str = "none", show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ @@ -180,6 +181,49 @@ def nhood_enrichment( _test = _create_function(n_cls, parallel=numba_parallel) count = _test(indices, indptr, int_clust) + + # NEW + # introduce normalization by number of cells first + # Count how many cells there are per cluster (type A in A-B interaction) + if normalization == "total": + row_sums = count.sum(axis=1, keepdims=True) + row_sums[row_sums == 0] = 1 # avoid division by zero + count_normalized = count / row_sums + elif normalization == "conditional": + # Reconstruct per-cell neighbor counts: res = (n_cells, n_cls) + res = np.zeros((len(int_clust), n_cls), dtype=np.uint32) + for i in range(len(int_clust)): + xs, xe = indptr[i], indptr[i + 1] + neighbors = indices[xs:xe] + for n in neighbors: + res[i, int_clust[n]] += 1 + + # Create boolean matrix: cell i has at least one neighbor of type b + per_cell_neighbor_matrix = res > 0 + + # Compute how many type A cells have at least one neighbor of type B + cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) + for a in range(n_cls): + a_cells = (int_clust == a) + if not np.any(a_cells): + continue + for b in range(n_cls): + has_b_neighbor = per_cell_neighbor_matrix[a_cells, b] + cond_counts[a, b] = has_b_neighbor.sum() + + # Avoid division by zero + cond_counts[cond_counts == 0] = 1.0 + + # Normalize true count matrix + count_normalized = count / cond_counts + # print("Cond count normalized:", count_normalized) + + elif normalization == "none": + count_normalized = count.copy() + else: + raise ValueError(f"Invalid normalization mode `{normalization}`. Choose from 'none', 'all_type_A', 'type_A_with_B_neighbor'.") + ### + n_jobs = _get_n_cores(n_jobs) start = logg.info(f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") @@ -198,17 +242,23 @@ def nhood_enrichment( libraries=libraries, n_cls=n_cls, seed=seed, + normalization = normalization ) - zscore = (count - perms.mean(axis=0)) / perms.std(axis=0) + zscore = (count_normalized - perms.mean(axis=0)) / perms.std(axis=0) + print(f"version 2") if copy: - return zscore, count + return zscore, count_normalized _save_data( adata, attr="uns", key=Key.uns.nhood_enrichment(cluster_key), - data={"zscore": zscore, "count": count}, + data={ + "zscore": zscore, + "count": count_normalized, + #"count_normalized": count_normalized, # <-- new addition + }, time=start, ) @@ -424,6 +474,7 @@ def _nhood_enrichment_helper( n_cls: int, seed: int | None = None, queue: SigQueue | None = None, + normalization: str = "none", # NEW ) -> NDArrayA: perms = np.empty((len(ixs), n_cls, n_cls), dtype=np.float64) int_clust = int_clust.copy() # threading @@ -434,7 +485,53 @@ def _nhood_enrichment_helper( int_clust = _shuffle_group(int_clust, libraries, rs) else: rs.shuffle(int_clust) - perms[i, ...] = callback(indices, indptr, int_clust) + + count_perms = callback(indices, indptr, int_clust) + + # NEW: normalize permuted count matrix + if normalization == "total": + row_sums = count_perms.sum(axis=1, keepdims=True) + row_sums[row_sums == 0] = 1 # avoid division by zero + count_perms = count_perms / row_sums + # Conditional normalization + elif normalization == "conditional": + # Reconstruct per-cell neighbor counts: res = (n_cells, n_cls) + # Build the neighbor matrix: (n_cells, n_cls) + res = np.zeros((len(int_clust), n_cls), dtype=np.uint32) + for i_cell in range(len(int_clust)): + xs, xe = indptr[i_cell], indptr[i_cell + 1] + neighbors = indices[xs:xe] + for n in neighbors: + res[i_cell, int_clust[n]] += 1 + + # Create a boolean matrix: cell i has at least one neighbor of type b + per_cell_neighbor_matrix = res > 0 + + # For each pair (A, B), count how many A cells have at least one B neighbor + cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) + for a in range(n_cls): + a_cells = (int_clust == a) + if not np.any(a_cells): + continue + for b in range(n_cls): + has_b_neighbor = per_cell_neighbor_matrix[a_cells, b] + cond_counts[a, b] = has_b_neighbor.sum() + + # Avoid division by zero + cond_counts[cond_counts == 0] = 1.0 + + # Normalize + count_perms = count_perms / cond_counts + + + elif normalization == "none": + pass + else: + raise ValueError( + f"Invalid normalization mode `{normalization}`. Choose from 'none', 'total', 'conditional'." + ) + + perms[i, ...] = count_perms if queue is not None: queue.put(Signal.UPDATE) @@ -442,4 +539,4 @@ def _nhood_enrichment_helper( if queue is not None: queue.put(Signal.FINISH) - return perms + return perms # FIXED: return perms (not `count_perm`) From a9bdafd9cd569300f2528d91b65be1cf221877d9 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Fri, 25 Apr 2025 15:35:24 +0200 Subject: [PATCH 02/24] remove prints --- src/squidpy/gr/_nhood.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index cb4ad39b3..ec33eb642 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -216,7 +216,6 @@ def nhood_enrichment( # Normalize true count matrix count_normalized = count / cond_counts - # print("Cond count normalized:", count_normalized) elif normalization == "none": count_normalized = count.copy() @@ -246,7 +245,7 @@ def nhood_enrichment( ) zscore = (count_normalized - perms.mean(axis=0)) / perms.std(axis=0) - print(f"version 2") + if copy: return zscore, count_normalized From 52cc04aae65014e8f83ce70f70a939357bbc4436 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 29 Apr 2025 10:24:51 +0200 Subject: [PATCH 03/24] hande nan --- src/squidpy/gr/_nhood.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index ec33eb642..59df3beef 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -131,6 +131,7 @@ def nhood_enrichment( n_jobs: int | None = None, backend: str = "loky", normalization: str = "none", + handle_nan: str = "zero", # or "keep" show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ @@ -201,6 +202,9 @@ def nhood_enrichment( # Create boolean matrix: cell i has at least one neighbor of type b per_cell_neighbor_matrix = res > 0 + # Ensure per_cell_neighbor_matrix is the correct shape + assert per_cell_neighbor_matrix.shape == (len(int_clust), n_cls) + # Compute how many type A cells have at least one neighbor of type B cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) for a in range(n_cls): @@ -244,8 +248,17 @@ def nhood_enrichment( normalization = normalization ) - zscore = (count_normalized - perms.mean(axis=0)) / perms.std(axis=0) - + std = perms.std(axis=0) + std[std == 0] = np.nan # Or np.inf if you prefer to get 0 z-score + zscore = (count_normalized - perms.mean(axis=0)) / std + + if handle_nan == "zero": + zscore = np.nan_to_num(zscore, nan=0.0) + elif handle_nan == "keep": + pass # keep NaN + else: + raise ValueError("handle_nan must be 'keep' or 'zero'") + if copy: return zscore, count_normalized From cafd835358d8efce8e5e52b857ed8e6470c7d8d6 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 29 Apr 2025 10:25:32 +0200 Subject: [PATCH 04/24] add normalization tests --- tests/graph/test_nhood.py | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index a89074b99..1959fa211 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -142,3 +142,65 @@ def test_interaction_matrix_nan_values(adata_intmat: AnnData): np.testing.assert_array_equal(expected_weighted, result_weighted) np.testing.assert_array_equal(expected_unweighted, result_unweighted) + +@pytest.mark.parametrize("normalization", ["none", "total", "conditional"]) +def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str): + spatial_neighbors(adata) + z, count = nhood_enrichment( + adata, + cluster_key=_CK, + normalization=normalization, + n_jobs=1, + n_perms=20, + copy=True + ) + + assert isinstance(z, np.ndarray) + assert isinstance(count, np.ndarray) + assert z.shape == count.shape + assert z.shape[0] == adata.obs[_CK].cat.categories.shape[0] + +def test_conditional_normalization_zero_division(adata: AnnData): + adata = adata.copy() + if _CK not in adata.obs: + raise ValueError(f"Cluster key '{_CK}' not in adata.obs") + if not pd.api.types.is_categorical_dtype(adata.obs[_CK]): + adata.obs[_CK] = adata.obs[_CK].astype("category") + adata.obs[_CK] = adata.obs[_CK].cat.add_categories("isolated") + adata.obs.loc[adata.obs.index[0], _CK] = "isolated" + spatial_neighbors(adata) + + result = nhood_enrichment( + adata, + cluster_key=_CK, + normalization="conditional", + copy=True) + assert result is not None + zscore, count = result + assert not np.any(np.isinf(zscore)) + assert not np.any(np.isnan(zscore)) + assert not np.any(np.isinf(count)) + assert not np.any(np.isnan(count)) + +@pytest.mark.parametrize("normalization, expected_dtype", [ + ("none", np.uint32), + ("total", np.float64), + ("conditional", np.float64), +]) +def test_output_dtype(adata: AnnData, normalization: str, expected_dtype): + spatial_neighbors(adata) + _, count = nhood_enrichment( + adata, + cluster_key=_CK, + normalization=normalization, + n_jobs=1, + n_perms=20, + copy=True, + ) + assert count.dtype == expected_dtype + +def test_invalid_normalization_raises(adata: AnnData): + spatial_neighbors(adata) + with pytest.raises(ValueError, match="Invalid normalization mode"): + nhood_enrichment(adata, cluster_key=_CK, normalization="invalid_mode", copy=True) + From eeef861d58a2c71d65d1061f2c6408e947de4e04 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 29 Apr 2025 10:38:18 +0200 Subject: [PATCH 05/24] add docstring --- src/squidpy/gr/_nhood.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 59df3beef..d638a18ef 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -148,10 +148,19 @@ def nhood_enrichment( %(seed)s %(copy)s %(parallelize)s + normalization + Normalization mode to use: + - ``'none'``: No normalization of neighbor counts (raw counts) + - ``'total'``: Normalize neighbor counts by total number of cells per cluster (SEA) + - ``'conditional'``: Normalize neighbor counts by number of cells with at least one neighbor of given type (COZI) + handle_nan + How to handle NaN values in z-scores: + - ``'zero'``: Replace NaN values with 0 + - ``'keep'``: Keep NaN values (undefined enrichment) Returns ------- - If ``copy = True``, returns a :class:`tuple` with the z-score and the enrichment count. + If ``copy = True``, returns a :class:`tuple` with the z-score and the (normalized) enrichment count. Otherwise, modifies the ``adata`` with the following keys: From 7b89081c067c2a3d1ca1e503b119480eb998fd3f Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 6 May 2025 09:57:43 +0200 Subject: [PATCH 06/24] filter out low cond counts --- src/squidpy/gr/_nhood.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index d638a18ef..f74ed3ea4 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -132,6 +132,7 @@ def nhood_enrichment( backend: str = "loky", normalization: str = "none", handle_nan: str = "zero", # or "keep" + min_cond_count: int = 5, show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ @@ -150,7 +151,7 @@ def nhood_enrichment( %(parallelize)s normalization Normalization mode to use: - - ``'none'``: No normalization of neighbor counts (raw counts) + - ``'none'``: No normalization of neighbor counts - ``'total'``: Normalize neighbor counts by total number of cells per cluster (SEA) - ``'conditional'``: Normalize neighbor counts by number of cells with at least one neighbor of given type (COZI) handle_nan @@ -216,20 +217,27 @@ def nhood_enrichment( # Compute how many type A cells have at least one neighbor of type B cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) + cluster_sizes = np.zeros(n_cls, dtype=np.float64) # Track cluster sizes NEW for a in range(n_cls): a_cells = (int_clust == a) + cluster_sizes[a] = a_cells.sum() #NEW if not np.any(a_cells): continue for b in range(n_cls): has_b_neighbor = per_cell_neighbor_matrix[a_cells, b] cond_counts[a, b] = has_b_neighbor.sum() + # identify pairs with insuffiecient conditional counts + low_count_mask = cond_counts < min_cond_count + # Avoid division by zero - cond_counts[cond_counts == 0] = 1.0 + #cond_counts[cond_counts == 0] = 1.0 # Normalize true count matrix count_normalized = count / cond_counts + count_normalized[low_count_mask] = np.nan + elif normalization == "none": count_normalized = count.copy() else: @@ -496,6 +504,7 @@ def _nhood_enrichment_helper( seed: int | None = None, queue: SigQueue | None = None, normalization: str = "none", # NEW + min_cond_count: int = 5, ) -> NDArrayA: perms = np.empty((len(ixs), n_cls, n_cls), dtype=np.float64) int_clust = int_clust.copy() # threading @@ -530,8 +539,10 @@ def _nhood_enrichment_helper( # For each pair (A, B), count how many A cells have at least one B neighbor cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) + cluster_sizes = np.zeros(n_cls, dtype=np.float64) # Track cluster sizes NEW for a in range(n_cls): a_cells = (int_clust == a) + cluster_sizes[a] = a_cells.sum() #NEW if not np.any(a_cells): continue for b in range(n_cls): @@ -539,18 +550,15 @@ def _nhood_enrichment_helper( cond_counts[a, b] = has_b_neighbor.sum() # Avoid division by zero - cond_counts[cond_counts == 0] = 1.0 + low_count_mask = cond_counts < min_cond_count + #cond_counts[cond_counts == 0] = 1.0 # Normalize count_perms = count_perms / cond_counts - + count_perms[low_count_mask] = np.nan elif normalization == "none": pass - else: - raise ValueError( - f"Invalid normalization mode `{normalization}`. Choose from 'none', 'total', 'conditional'." - ) perms[i, ...] = count_perms From 26e90bff2b6a543eb0eecb920dde58e3c81ff07e Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 6 May 2025 11:25:24 +0200 Subject: [PATCH 07/24] add warnings low cond counts --- src/squidpy/gr/_nhood.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index f74ed3ea4..622f19746 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable, Sequence from functools import partial from typing import Any +import warnings import networkx as nx import numba.types as nt @@ -133,6 +134,7 @@ def nhood_enrichment( normalization: str = "none", handle_nan: str = "zero", # or "keep" min_cond_count: int = 5, + smooth_cond_counts: bool = True, show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ @@ -214,7 +216,16 @@ def nhood_enrichment( # Ensure per_cell_neighbor_matrix is the correct shape assert per_cell_neighbor_matrix.shape == (len(int_clust), n_cls) - + cluster_sizes = np.bincount(int_clust) + small_clusters = np.where(cluster_sizes < min_cond_count)[0] + if len(small_clusters) > 0: + small_cluster_names = original_clust.cat.categories[small_clusters].tolist() + warnings.warn( + f"Clusters {small_cluster_names} have fewer than {min_cond_count} cells. " + "Results may be unreliable.", + UserWarning, + stacklevel=2 + ) # Compute how many type A cells have at least one neighbor of type B cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) cluster_sizes = np.zeros(n_cls, dtype=np.float64) # Track cluster sizes NEW @@ -231,13 +242,25 @@ def nhood_enrichment( low_count_mask = cond_counts < min_cond_count # Avoid division by zero - #cond_counts[cond_counts == 0] = 1.0 + cond_counts[cond_counts == 0] = 1.0 # Normalize true count matrix count_normalized = count / cond_counts count_normalized[low_count_mask] = np.nan + invalid_pairs = cond_counts < min_cond_count + if np.any(invalid_pairs): + frac_invalid = np.mean(invalid_pairs) * 100 + if frac_invalid > 10: # Warn if >10% of pairs are masked + warnings.warn( + f"{frac_invalid:.1f}% of cluster pairs have fewer than {min_cond_count} " + "cells with neighbors of the given type. These pairs will be masked. " + "Interpret results with caution.", + UserWarning, + stacklevel=2, + ) + elif normalization == "none": count_normalized = count.copy() else: @@ -551,7 +574,7 @@ def _nhood_enrichment_helper( # Avoid division by zero low_count_mask = cond_counts < min_cond_count - #cond_counts[cond_counts == 0] = 1.0 + cond_counts[cond_counts == 0] = 1.0 # Normalize count_perms = count_perms / cond_counts From bb0eeb8a7776252bb60b167a44a57ebe99ab8d2d Mon Sep 17 00:00:00 2001 From: chiarasch Date: Thu, 7 Aug 2025 09:52:14 +0200 Subject: [PATCH 08/24] add min_cell_count filter --- src/squidpy/gr/_nhood.py | 148 +++++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 67 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 622f19746..0785671d0 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -117,6 +117,20 @@ def _create_function(n_cls: int, parallel: bool = False) -> Callable[[NDArrayA, return globals()[fn_key] # type: ignore[no-any-return] +def filter_clusters_by_min_cell_count(adata, int_clust, connectivity_key, min_cell_count): + # Count cells per cluster + clust_sizes = pd.Series(int_clust).value_counts() + valid_clusters = clust_sizes[clust_sizes >= min_cell_count].index.to_numpy() + + # Keep only valid cells + valid_mask = np.isin(int_clust, valid_clusters) + valid_cells_idx = np.where(valid_mask)[0] + int_clust = int_clust[valid_mask] + + # Filter adjacency matrix + adj = adata.obsp[connectivity_key][np.ix_(valid_cells_idx, valid_cells_idx)] + return int_clust, adj + @d.get_sections(base="nhood_ench", sections=["Parameters"]) @d.dedent @@ -132,9 +146,8 @@ def nhood_enrichment( n_jobs: int | None = None, backend: str = "loky", normalization: str = "none", + min_cell_count: int = 10, handle_nan: str = "zero", # or "keep" - min_cond_count: int = 5, - smooth_cond_counts: bool = True, show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ @@ -179,9 +192,19 @@ def nhood_enrichment( adj = adata.obsp[connectivity_key] original_clust = adata.obs[cluster_key] - clust_map = {v: i for i, v in enumerate(original_clust.cat.categories.values)} # map categories + clust_map = { + v: i + for i, v in enumerate(original_clust.cat.categories.values) + } # map categories int_clust = np.array([clust_map[c] for c in original_clust], dtype=ndt) - + n_total_cells = len(int_clust) + # Filter clusters by minimum number of cells + int_clust, adj = filter_clusters_by_min_cell_count( + adata=adata, + int_clust=int_clust, + connectivity_key=connectivity_key, + min_cell_count=min_cell_count, + ) if library_key is not None: _assert_categorical_obs(adata, key=library_key) libraries: pd.Series | None = adata.obs[library_key] @@ -193,8 +216,7 @@ def nhood_enrichment( _test = _create_function(n_cls, parallel=numba_parallel) count = _test(indices, indptr, int_clust) - - + conditional_ratio = None # NEW # introduce normalization by number of cells first # Count how many cells there are per cluster (type A in A-B interaction) @@ -214,61 +236,51 @@ def nhood_enrichment( # Create boolean matrix: cell i has at least one neighbor of type b per_cell_neighbor_matrix = res > 0 - # Ensure per_cell_neighbor_matrix is the correct shape - assert per_cell_neighbor_matrix.shape == (len(int_clust), n_cls) - cluster_sizes = np.bincount(int_clust) - small_clusters = np.where(cluster_sizes < min_cond_count)[0] - if len(small_clusters) > 0: - small_cluster_names = original_clust.cat.categories[small_clusters].tolist() - warnings.warn( - f"Clusters {small_cluster_names} have fewer than {min_cond_count} cells. " - "Results may be unreliable.", - UserWarning, - stacklevel=2 - ) # Compute how many type A cells have at least one neighbor of type B cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) - cluster_sizes = np.zeros(n_cls, dtype=np.float64) # Track cluster sizes NEW + conditional_ratio = np.full((n_cls, n_cls), np.nan, + dtype=np.float64) # new + for a in range(n_cls): a_cells = (int_clust == a) - cluster_sizes[a] = a_cells.sum() #NEW if not np.any(a_cells): continue + n_type_a_cells = a_cells.sum() for b in range(n_cls): has_b_neighbor = per_cell_neighbor_matrix[a_cells, b] cond_counts[a, b] = has_b_neighbor.sum() + conditional_ratio[a, :] = cond_counts[a, :] / n_type_a_cells + + # Avoid division by zero by setting denominator to 1 only for normalization + safe_cond_counts = cond_counts.copy() + safe_cond_counts[safe_cond_counts == + 0] = 1.0 # Only avoids division error + + # Normalize counts without setting to NaN — allows avoidance to be detected + count_normalized = count / safe_cond_counts - # identify pairs with insuffiecient conditional counts - low_count_mask = cond_counts < min_cond_count - - # Avoid division by zero - cond_counts[cond_counts == 0] = 1.0 - - # Normalize true count matrix - count_normalized = count / cond_counts - - count_normalized[low_count_mask] = np.nan - - invalid_pairs = cond_counts < min_cond_count - if np.any(invalid_pairs): - frac_invalid = np.mean(invalid_pairs) * 100 - if frac_invalid > 10: # Warn if >10% of pairs are masked - warnings.warn( - f"{frac_invalid:.1f}% of cluster pairs have fewer than {min_cond_count} " - "cells with neighbors of the given type. These pairs will be masked. " - "Interpret results with caution.", - UserWarning, - stacklevel=2, - ) + n_retained_cells = len(int_clust) + n_filtered = n_total_cells - n_retained_cells + frac_filtered = n_filtered / n_total_cells * 100 + + if n_filtered > 0: + warnings.warn( + f"{frac_filtered:.3f}% of cells were excluded because their clusters had fewer than {min_cell_count} cells.", + UserWarning, + stacklevel=2, + ) elif normalization == "none": count_normalized = count.copy() else: - raise ValueError(f"Invalid normalization mode `{normalization}`. Choose from 'none', 'all_type_A', 'type_A_with_B_neighbor'.") + raise ValueError( + f"Invalid normalization mode `{normalization}`. Choose from 'none', 'all_type_A', 'type_A_with_B_neighbor'." + ) ### n_jobs = _get_n_cores(n_jobs) - start = logg.info(f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") + start = logg.info( + f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") perms = parallelize( _nhood_enrichment_helper, @@ -277,16 +289,14 @@ def nhood_enrichment( n_jobs=n_jobs, backend=backend, show_progress_bar=show_progress_bar, - )( - callback=_test, - indices=indices, - indptr=indptr, - int_clust=int_clust, - libraries=libraries, - n_cls=n_cls, - seed=seed, - normalization = normalization - ) + )(callback=_test, + indices=indices, + indptr=indptr, + int_clust=int_clust, + libraries=libraries, + n_cls=n_cls, + seed=seed, + normalization=normalization) std = perms.std(axis=0) std[std == 0] = np.nan # Or np.inf if you prefer to get 0 z-score @@ -300,17 +310,24 @@ def nhood_enrichment( raise ValueError("handle_nan must be 'keep' or 'zero'") if copy: - return zscore, count_normalized + if normalization == "conditional": + return zscore, count_normalized, conditional_ratio + else: + return zscore, count_normalized + + # Build the data dictionary + enrichment_data = { + "zscore": zscore, + "count": count_normalized, + } + if normalization == "conditional": + enrichment_data["conditional_cell_ratio"] = conditional_ratio _save_data( adata, attr="uns", key=Key.uns.nhood_enrichment(cluster_key), - data={ - "zscore": zscore, - "count": count_normalized, - #"count_normalized": count_normalized, # <-- new addition - }, + data=enrichment_data, time=start, ) @@ -546,7 +563,7 @@ def _nhood_enrichment_helper( row_sums = count_perms.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # avoid division by zero count_perms = count_perms / row_sums - # Conditional normalization + # Conditional normalization elif normalization == "conditional": # Reconstruct per-cell neighbor counts: res = (n_cells, n_cls) # Build the neighbor matrix: (n_cells, n_cls) @@ -562,10 +579,10 @@ def _nhood_enrichment_helper( # For each pair (A, B), count how many A cells have at least one B neighbor cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) - cluster_sizes = np.zeros(n_cls, dtype=np.float64) # Track cluster sizes NEW + cluster_sizes = np.zeros(n_cls, dtype=np.float64) for a in range(n_cls): a_cells = (int_clust == a) - cluster_sizes[a] = a_cells.sum() #NEW + cluster_sizes[a] = a_cells.sum() if not np.any(a_cells): continue for b in range(n_cls): @@ -575,10 +592,7 @@ def _nhood_enrichment_helper( # Avoid division by zero low_count_mask = cond_counts < min_cond_count cond_counts[cond_counts == 0] = 1.0 - - # Normalize count_perms = count_perms / cond_counts - count_perms[low_count_mask] = np.nan elif normalization == "none": pass @@ -591,4 +605,4 @@ def _nhood_enrichment_helper( if queue is not None: queue.put(Signal.FINISH) - return perms # FIXED: return perms (not `count_perm`) + return perms From e7a33ab552e8d52ec325d549bac55d878e421684 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Thu, 7 Aug 2025 09:53:18 +0200 Subject: [PATCH 09/24] add CCR dotplot --- src/squidpy/pl/__init__.py | 1 + src/squidpy/pl/_graph.py | 166 +++++++++++++++++++++++++++++++++++-- 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/squidpy/pl/__init__.py b/src/squidpy/pl/__init__.py index 0bcc00233..8879dbf00 100644 --- a/src/squidpy/pl/__init__.py +++ b/src/squidpy/pl/__init__.py @@ -7,6 +7,7 @@ co_occurrence, interaction_matrix, nhood_enrichment, + nhood_enrichment_dotplot, ripley, ) diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 852906225..1828cf9c3 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -216,10 +216,19 @@ def nhood_enrichment( %(plotting_returns)s """ _assert_categorical_obs(adata, key=cluster_key) - array = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")[mode] - - ad = AnnData(X=array, obs={cluster_key: pd.Categorical(adata.obs[cluster_key].cat.categories)}) - _maybe_set_colors(source=adata, target=ad, key=cluster_key, palette=palette) + array = _get_data(adata, + cluster_key=cluster_key, + func_name="nhood_enrichment")[mode] + + ad = AnnData(X=array, + obs={ + cluster_key: + pd.Categorical(adata.obs[cluster_key].cat.categories) + }) + _maybe_set_colors(source=adata, + target=ad, + key=cluster_key, + palette=palette) if title is None: title = "Neighborhood enrichment" fig = _heatmap( @@ -229,7 +238,8 @@ def nhood_enrichment( method=method, cont_cmap=cmap, annotate=annotate, - figsize=(2 * ad.n_obs // 3, 2 * ad.n_obs // 3) if figsize is None else figsize, + figsize=(2 * ad.n_obs // 3, + 2 * ad.n_obs // 3) if figsize is None else figsize, dpi=dpi, cbar_kwargs=cbar_kwargs, ax=ax, @@ -239,6 +249,152 @@ def nhood_enrichment( if save is not None: save_fig(fig, path=save) +@d.dedent +def nhood_enrichment_dotplot( + adata: AnnData, + cluster_key: str, + zscore_key: str = "ct_nhood_enrichment", + annotate: bool = False, + title: str | None = None, + cmap: str = "coolwarm", + palette: Palette_t = None, + cbar_kwargs: Mapping[str, Any] = MappingProxyType({}), + figsize: tuple[float, float] | None = None, + dpi: int | None = None, + size_range: tuple[float, float] = (10, 200), + save: str | Path | None = None, + ax: Axes | None = None, + **kwargs: Any, +) -> None: + """ + Dot plot of neighborhood enrichment. + + This plots the result of :func:`squidpy.gr.nhood_enrichment`, using: + - Color for z-score of enrichment + - Dot size for conditional cell ratio (CCR), scaled continuously + + Parameters + ---------- + adata : AnnData + Annotated data matrix. + cluster_key : str + Key in `adata.obs` where the cluster (cell type) annotation is stored. + zscore_key : str, optional + Key in `adata.uns` where the enrichment results are stored. + annotate : bool, optional + Whether to annotate dots with CCR values. + title : str, optional + Title of the plot. + cmap : str, optional + Colormap used for the z-score values. + palette : Palette_t, optional + Not used, reserved for compatibility. + cbar_kwargs : dict, optional + Keyword arguments for `fig.colorbar`. + figsize : tuple, optional + Figure size. + dpi : int, optional + Dots per inch for the figure. + size_range : tuple of float, optional + Min and max dot sizes for conditional cell ratio scaling. + save : str | Path, optional + Path to save the figure. + ax : matplotlib.axes.Axes, optional + Axes object to draw the plot onto, otherwise a new figure is created. + **kwargs : Any + Additional keyword arguments passed to `plt.scatter`. + + Returns + ------- + None + """ + _assert_categorical_obs(adata, key=cluster_key) + enrichment = _get_data( + adata, cluster_key=cluster_key, func_name="nhood_enrichment" + ) + + zscore = enrichment["zscore"] + ccr = enrichment["conditional_cell_ratio"] + cats = adata.obs[cluster_key].cat.categories + + df = pd.DataFrame({ + "x": np.tile(np.arange(len(cats)), len(cats)), + "y": np.repeat(np.arange(len(cats)), len(cats)), + "zscore": zscore.flatten(), + "ccr": ccr.flatten() + }) + + size_min, size_max = size_range + ccr_norm = (df["ccr"] - df["ccr"].min()) / (df["ccr"].max() - df["ccr"].min() + 1e-10) + df["size"] = size_min + ccr_norm * (size_max - size_min) + + fig, ax = plt.subplots(figsize=figsize, dpi=dpi) if ax is None else (ax.figure, ax) + cmap = "YlGnBu" + sc = ax.scatter( + df["x"], + df["y"], + c=df["zscore"], + s=df["size"], + cmap=cmap, + edgecolors="black", + linewidths=0.3, + **kwargs, + ) + + ax.set_xticks(np.arange(len(cats))) + ax.set_yticks(np.arange(len(cats))) + ax.set_xticklabels(cats, rotation=90) + ax.set_yticklabels(cats) + ax.set_xlabel("Neighbor cell type") + ax.set_ylabel("Index cell type") + + ax.set_title(title or "Neighborhood enrichment (dot plot)") + + # Colorbar + cbar = fig.colorbar(sc, ax=ax, **cbar_kwargs) + cbar.set_label("Z-score") + + # Continuous CCR legend + from matplotlib.lines import Line2D + legend_ccr_vals = np.linspace(df["ccr"].min(), df["ccr"].max(), 5) + legend_sizes = size_min + (legend_ccr_vals - df["ccr"].min()) / ( + df["ccr"].max() - df["ccr"].min() + 1e-10 + ) * (size_max - size_min) + + legend_elements = [ + Line2D( + [0], [0], + marker='o', + color='w', + label=f'{v:.2f}', + markerfacecolor='gray', + markersize=np.sqrt(s), # scatter size is area → sqrt for legend + markeredgecolor='black' + ) + for v, s in zip(legend_ccr_vals, legend_sizes) + ] + + ax.legend( + handles=legend_elements, + title="CCR", + loc='center left', + bbox_to_anchor=(1.3, 0.5), + borderaxespad=0., + frameon=False + ) + + if annotate: + for _, row in df.iterrows(): + ax.text(row["x"], row["y"], f"{row['ccr']:.2f}", + ha="center", va="center") + + ax.invert_yaxis() + ax.set_aspect("equal") + + if save is not None: + save_fig(fig, path=save) + + @d.dedent def ripley( From fc8c1b898d52cba09d648263a7415ab13ff5440f Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 11 Aug 2025 15:53:52 +0200 Subject: [PATCH 10/24] add cozi tests --- src/squidpy/gr/_nhood.py | 2 +- tests/graph/test_nhood.py | 33 +++++++++++++++++++++++++++------ tests/plotting/test_graph.py | 14 ++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 0785671d0..a7b464209 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -147,7 +147,7 @@ def nhood_enrichment( backend: str = "loky", normalization: str = "none", min_cell_count: int = 10, - handle_nan: str = "zero", # or "keep" + handle_nan: str = "keep", # or "keep" show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index 1959fa211..3412eeaef 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -146,7 +146,7 @@ def test_interaction_matrix_nan_values(adata_intmat: AnnData): @pytest.mark.parametrize("normalization", ["none", "total", "conditional"]) def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str): spatial_neighbors(adata) - z, count = nhood_enrichment( + result = nhood_enrichment( adata, cluster_key=_CK, normalization=normalization, @@ -155,6 +155,12 @@ def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str copy=True ) + if normalization == "conditional": + z, count, ccr = result + assert isinstance(ccr, np.ndarray) + else: + z, count = result + assert isinstance(z, np.ndarray) assert isinstance(count, np.ndarray) assert z.shape == count.shape @@ -162,6 +168,7 @@ def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str def test_conditional_normalization_zero_division(adata: AnnData): adata = adata.copy() + min_cells = 10 if _CK not in adata.obs: raise ValueError(f"Cluster key '{_CK}' not in adata.obs") if not pd.api.types.is_categorical_dtype(adata.obs[_CK]): @@ -169,6 +176,10 @@ def test_conditional_normalization_zero_division(adata: AnnData): adata.obs[_CK] = adata.obs[_CK].cat.add_categories("isolated") adata.obs.loc[adata.obs.index[0], _CK] = "isolated" spatial_neighbors(adata) + valid_clusters = [ + c for c, count in adata.obs[_CK].value_counts().items() if count >= min_cells + ] + valid_idx = [i for i, cat in enumerate(adata.obs[_CK].cat.categories) if cat in valid_clusters] result = nhood_enrichment( adata, @@ -176,11 +187,14 @@ def test_conditional_normalization_zero_division(adata: AnnData): normalization="conditional", copy=True) assert result is not None - zscore, count = result + zscore, count_normalized, conditional_ratio = result assert not np.any(np.isinf(zscore)) - assert not np.any(np.isnan(zscore)) - assert not np.any(np.isinf(count)) - assert not np.any(np.isnan(count)) + assert not np.any(np.isinf(count_normalized)) + assert not np.any(np.isinf(conditional_ratio)) + assert not np.isnan(zscore[np.ix_(valid_idx, valid_idx)]).any() + assert not np.isnan(count_normalized[np.ix_(valid_idx, valid_idx)]).any() + assert not np.isnan(conditional_ratio[np.ix_(valid_idx, valid_idx)]).any() + @pytest.mark.parametrize("normalization, expected_dtype", [ ("none", np.uint32), @@ -189,7 +203,7 @@ def test_conditional_normalization_zero_division(adata: AnnData): ]) def test_output_dtype(adata: AnnData, normalization: str, expected_dtype): spatial_neighbors(adata) - _, count = nhood_enrichment( + result = nhood_enrichment( adata, cluster_key=_CK, normalization=normalization, @@ -197,8 +211,15 @@ def test_output_dtype(adata: AnnData, normalization: str, expected_dtype): n_perms=20, copy=True, ) + + if normalization == "conditional": + _, count, _ = result + else: + _, count = result + assert count.dtype == expected_dtype + def test_invalid_normalization_raises(adata: AnnData): spatial_neighbors(adata) with pytest.raises(ValueError, match="Invalid normalization mode"): diff --git a/tests/plotting/test_graph.py b/tests/plotting/test_graph.py index 6e1c20f7d..d9e731cac 100644 --- a/tests/plotting/test_graph.py +++ b/tests/plotting/test_graph.py @@ -66,6 +66,20 @@ def test_plot_nhood_enrichment_ax(self, adata: AnnData): fig, ax = plt.subplots(figsize=(2, 2), constrained_layout=True) pl.nhood_enrichment(adata, cluster_key=C_KEY, ax=ax) + ### TODO How can I create test data? + #def test_plot_nhood_enrichment_dotplot(self, adata: AnnData): + # gr.spatial_neighbors(adata) + # gr.nhood_enrichment(adata, cluster_key=C_KEY, normalization = "conditional") + + # pl.nhood_enrichment_dotplot(adata, cluster_key=C_KEY) + + #def test_plot_nhood_enrichment_dotplot_ax(self, adata: AnnData): + # gr.spatial_neighbors(adata) + # gr.nhood_enrichment(adata, cluster_key=C_KEY, normalization = "conditional") + + # fig, ax = plt.subplots(figsize=(2, 2), constrained_layout=True) + # pl.nhood_enrichment_dotplot(adata, cluster_key=C_KEY, ax=ax) + def test_plot_nhood_enrichment_dendro(self, adata: AnnData): gr.spatial_neighbors(adata) gr.nhood_enrichment(adata, cluster_key=C_KEY) From c9956fc0b6fa69aeb848be7ca91f6c07741c2344 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 11 Aug 2025 15:58:02 +0200 Subject: [PATCH 11/24] clean script --- src/squidpy/gr/_nhood.py | 43 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index a7b464209..03ced1124 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -118,16 +118,13 @@ def _create_function(n_cls: int, parallel: bool = False) -> Callable[[NDArrayA, return globals()[fn_key] # type: ignore[no-any-return] def filter_clusters_by_min_cell_count(adata, int_clust, connectivity_key, min_cell_count): - # Count cells per cluster clust_sizes = pd.Series(int_clust).value_counts() valid_clusters = clust_sizes[clust_sizes >= min_cell_count].index.to_numpy() - # Keep only valid cells valid_mask = np.isin(int_clust, valid_clusters) valid_cells_idx = np.where(valid_mask)[0] int_clust = int_clust[valid_mask] - # Filter adjacency matrix adj = adata.obsp[connectivity_key][np.ix_(valid_cells_idx, valid_cells_idx)] return int_clust, adj @@ -147,7 +144,7 @@ def nhood_enrichment( backend: str = "loky", normalization: str = "none", min_cell_count: int = 10, - handle_nan: str = "keep", # or "keep" + handle_nan: str = "keep", show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | None: """ @@ -195,10 +192,10 @@ def nhood_enrichment( clust_map = { v: i for i, v in enumerate(original_clust.cat.categories.values) - } # map categories + } int_clust = np.array([clust_map[c] for c in original_clust], dtype=ndt) n_total_cells = len(int_clust) - # Filter clusters by minimum number of cells + int_clust, adj = filter_clusters_by_min_cell_count( adata=adata, int_clust=int_clust, @@ -217,15 +214,12 @@ def nhood_enrichment( _test = _create_function(n_cls, parallel=numba_parallel) count = _test(indices, indptr, int_clust) conditional_ratio = None - # NEW - # introduce normalization by number of cells first - # Count how many cells there are per cluster (type A in A-B interaction) + if normalization == "total": row_sums = count.sum(axis=1, keepdims=True) - row_sums[row_sums == 0] = 1 # avoid division by zero + row_sums[row_sums == 0] = 1 count_normalized = count / row_sums elif normalization == "conditional": - # Reconstruct per-cell neighbor counts: res = (n_cells, n_cls) res = np.zeros((len(int_clust), n_cls), dtype=np.uint32) for i in range(len(int_clust)): xs, xe = indptr[i], indptr[i + 1] @@ -233,13 +227,11 @@ def nhood_enrichment( for n in neighbors: res[i, int_clust[n]] += 1 - # Create boolean matrix: cell i has at least one neighbor of type b per_cell_neighbor_matrix = res > 0 - # Compute how many type A cells have at least one neighbor of type B cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) conditional_ratio = np.full((n_cls, n_cls), np.nan, - dtype=np.float64) # new + dtype=np.float64) for a in range(n_cls): a_cells = (int_clust == a) @@ -251,12 +243,10 @@ def nhood_enrichment( cond_counts[a, b] = has_b_neighbor.sum() conditional_ratio[a, :] = cond_counts[a, :] / n_type_a_cells - # Avoid division by zero by setting denominator to 1 only for normalization safe_cond_counts = cond_counts.copy() safe_cond_counts[safe_cond_counts == - 0] = 1.0 # Only avoids division error + 0] = 1.0 - # Normalize counts without setting to NaN — allows avoidance to be detected count_normalized = count / safe_cond_counts n_retained_cells = len(int_clust) @@ -276,7 +266,6 @@ def nhood_enrichment( raise ValueError( f"Invalid normalization mode `{normalization}`. Choose from 'none', 'all_type_A', 'type_A_with_B_neighbor'." ) - ### n_jobs = _get_n_cores(n_jobs) start = logg.info( @@ -299,13 +288,13 @@ def nhood_enrichment( normalization=normalization) std = perms.std(axis=0) - std[std == 0] = np.nan # Or np.inf if you prefer to get 0 z-score + std[std == 0] = np.nan zscore = (count_normalized - perms.mean(axis=0)) / std if handle_nan == "zero": zscore = np.nan_to_num(zscore, nan=0.0) elif handle_nan == "keep": - pass # keep NaN + pass else: raise ValueError("handle_nan must be 'keep' or 'zero'") @@ -315,7 +304,6 @@ def nhood_enrichment( else: return zscore, count_normalized - # Build the data dictionary enrichment_data = { "zscore": zscore, "count": count_normalized, @@ -543,11 +531,11 @@ def _nhood_enrichment_helper( n_cls: int, seed: int | None = None, queue: SigQueue | None = None, - normalization: str = "none", # NEW + normalization: str = "none", min_cond_count: int = 5, ) -> NDArrayA: perms = np.empty((len(ixs), n_cls, n_cls), dtype=np.float64) - int_clust = int_clust.copy() # threading + int_clust = int_clust.copy() rs = np.random.RandomState(seed=None if seed is None else seed + ixs[0]) for i in range(len(ixs)): @@ -558,15 +546,11 @@ def _nhood_enrichment_helper( count_perms = callback(indices, indptr, int_clust) - # NEW: normalize permuted count matrix if normalization == "total": row_sums = count_perms.sum(axis=1, keepdims=True) - row_sums[row_sums == 0] = 1 # avoid division by zero + row_sums[row_sums == 0] = 1 count_perms = count_perms / row_sums - # Conditional normalization elif normalization == "conditional": - # Reconstruct per-cell neighbor counts: res = (n_cells, n_cls) - # Build the neighbor matrix: (n_cells, n_cls) res = np.zeros((len(int_clust), n_cls), dtype=np.uint32) for i_cell in range(len(int_clust)): xs, xe = indptr[i_cell], indptr[i_cell + 1] @@ -574,10 +558,8 @@ def _nhood_enrichment_helper( for n in neighbors: res[i_cell, int_clust[n]] += 1 - # Create a boolean matrix: cell i has at least one neighbor of type b per_cell_neighbor_matrix = res > 0 - # For each pair (A, B), count how many A cells have at least one B neighbor cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) cluster_sizes = np.zeros(n_cls, dtype=np.float64) for a in range(n_cls): @@ -589,7 +571,6 @@ def _nhood_enrichment_helper( has_b_neighbor = per_cell_neighbor_matrix[a_cells, b] cond_counts[a, b] = has_b_neighbor.sum() - # Avoid division by zero low_count_mask = cond_counts < min_cond_count cond_counts[cond_counts == 0] = 1.0 count_perms = count_perms / cond_counts From 28a77dc09888c4f2e9bcc78a7350aabad38fdd1c Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 11 Aug 2025 16:14:54 +0200 Subject: [PATCH 12/24] add cozi parameters in docs --- src/squidpy/_docs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index 7898574fc..3f2bd1523 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -48,6 +48,12 @@ def decorator2(obj: Any) -> Any: _n_perms = """\ n_perms Number of permutations for the permutation test.""" +_normalization = """\ +normalization + Normalization of neighbor counts either `None`, `total` (divide by total number of index cell types) or `conditional` (divided byt number of index cell types with at least one neighbor of neighbor cell type).""" +_min_cell_count = """\ +min_cell_count + Minimum number of cells that have to be in a cluster to be included in analysis. If count > min_cell_count, peir will be set to NA.""" _img_layer = """\ layer Image layer in ``img`` that should be processed. If `None` and only 1 layer is present, it will be selected.""" @@ -367,6 +373,8 @@ def decorator2(obj: Any) -> Any: numba_parallel=_numba_parallel, seed=_seed, n_perms=_n_perms, + normalization=_normalization, + min_cell_count=_min_cell_count, img_layer=_img_layer, feature_name=_feature_name, yx=_yx, From 74e77fca3a81b67a0e874b63aa5221fcba072457 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 08:16:29 +0000 Subject: [PATCH 13/24] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_nhood.py | 45 +++++++++++------------ src/squidpy/pl/_graph.py | 69 ++++++++++++++++-------------------- tests/graph/test_nhood.py | 37 ++++++++----------- tests/plotting/test_graph.py | 4 +-- 4 files changed, 67 insertions(+), 88 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 03ced1124..666525d62 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -2,10 +2,10 @@ from __future__ import annotations +import warnings from collections.abc import Callable, Iterable, Sequence from functools import partial from typing import Any -import warnings import networkx as nx import numba.types as nt @@ -117,6 +117,7 @@ def _create_function(n_cls: int, parallel: bool = False) -> Callable[[NDArrayA, return globals()[fn_key] # type: ignore[no-any-return] + def filter_clusters_by_min_cell_count(adata, int_clust, connectivity_key, min_cell_count): clust_sizes = pd.Series(int_clust).value_counts() valid_clusters = clust_sizes[clust_sizes >= min_cell_count].index.to_numpy() @@ -189,10 +190,7 @@ def nhood_enrichment( adj = adata.obsp[connectivity_key] original_clust = adata.obs[cluster_key] - clust_map = { - v: i - for i, v in enumerate(original_clust.cat.categories.values) - } + clust_map = {v: i for i, v in enumerate(original_clust.cat.categories.values)} int_clust = np.array([clust_map[c] for c in original_clust], dtype=ndt) n_total_cells = len(int_clust) @@ -230,11 +228,10 @@ def nhood_enrichment( per_cell_neighbor_matrix = res > 0 cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) - conditional_ratio = np.full((n_cls, n_cls), np.nan, - dtype=np.float64) + conditional_ratio = np.full((n_cls, n_cls), np.nan, dtype=np.float64) for a in range(n_cls): - a_cells = (int_clust == a) + a_cells = int_clust == a if not np.any(a_cells): continue n_type_a_cells = a_cells.sum() @@ -244,8 +241,7 @@ def nhood_enrichment( conditional_ratio[a, :] = cond_counts[a, :] / n_type_a_cells safe_cond_counts = cond_counts.copy() - safe_cond_counts[safe_cond_counts == - 0] = 1.0 + safe_cond_counts[safe_cond_counts == 0] = 1.0 count_normalized = count / safe_cond_counts @@ -268,8 +264,7 @@ def nhood_enrichment( ) n_jobs = _get_n_cores(n_jobs) - start = logg.info( - f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") + start = logg.info(f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") perms = parallelize( _nhood_enrichment_helper, @@ -278,14 +273,16 @@ def nhood_enrichment( n_jobs=n_jobs, backend=backend, show_progress_bar=show_progress_bar, - )(callback=_test, - indices=indices, - indptr=indptr, - int_clust=int_clust, - libraries=libraries, - n_cls=n_cls, - seed=seed, - normalization=normalization) + )( + callback=_test, + indices=indices, + indptr=indptr, + int_clust=int_clust, + libraries=libraries, + n_cls=n_cls, + seed=seed, + normalization=normalization, + ) std = perms.std(axis=0) std[std == 0] = np.nan @@ -535,7 +532,7 @@ def _nhood_enrichment_helper( min_cond_count: int = 5, ) -> NDArrayA: perms = np.empty((len(ixs), n_cls, n_cls), dtype=np.float64) - int_clust = int_clust.copy() + int_clust = int_clust.copy() rs = np.random.RandomState(seed=None if seed is None else seed + ixs[0]) for i in range(len(ixs)): @@ -548,7 +545,7 @@ def _nhood_enrichment_helper( if normalization == "total": row_sums = count_perms.sum(axis=1, keepdims=True) - row_sums[row_sums == 0] = 1 + row_sums[row_sums == 0] = 1 count_perms = count_perms / row_sums elif normalization == "conditional": res = np.zeros((len(int_clust), n_cls), dtype=np.uint32) @@ -561,9 +558,9 @@ def _nhood_enrichment_helper( per_cell_neighbor_matrix = res > 0 cond_counts = np.zeros((n_cls, n_cls), dtype=np.float64) - cluster_sizes = np.zeros(n_cls, dtype=np.float64) + cluster_sizes = np.zeros(n_cls, dtype=np.float64) for a in range(n_cls): - a_cells = (int_clust == a) + a_cells = int_clust == a cluster_sizes[a] = a_cells.sum() if not np.any(a_cells): continue diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 1828cf9c3..68c75e513 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -216,19 +216,10 @@ def nhood_enrichment( %(plotting_returns)s """ _assert_categorical_obs(adata, key=cluster_key) - array = _get_data(adata, - cluster_key=cluster_key, - func_name="nhood_enrichment")[mode] - - ad = AnnData(X=array, - obs={ - cluster_key: - pd.Categorical(adata.obs[cluster_key].cat.categories) - }) - _maybe_set_colors(source=adata, - target=ad, - key=cluster_key, - palette=palette) + array = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")[mode] + + ad = AnnData(X=array, obs={cluster_key: pd.Categorical(adata.obs[cluster_key].cat.categories)}) + _maybe_set_colors(source=adata, target=ad, key=cluster_key, palette=palette) if title is None: title = "Neighborhood enrichment" fig = _heatmap( @@ -238,8 +229,7 @@ def nhood_enrichment( method=method, cont_cmap=cmap, annotate=annotate, - figsize=(2 * ad.n_obs // 3, - 2 * ad.n_obs // 3) if figsize is None else figsize, + figsize=(2 * ad.n_obs // 3, 2 * ad.n_obs // 3) if figsize is None else figsize, dpi=dpi, cbar_kwargs=cbar_kwargs, ax=ax, @@ -249,6 +239,7 @@ def nhood_enrichment( if save is not None: save_fig(fig, path=save) + @d.dedent def nhood_enrichment_dotplot( adata: AnnData, @@ -309,20 +300,20 @@ def nhood_enrichment_dotplot( None """ _assert_categorical_obs(adata, key=cluster_key) - enrichment = _get_data( - adata, cluster_key=cluster_key, func_name="nhood_enrichment" - ) + enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment") zscore = enrichment["zscore"] ccr = enrichment["conditional_cell_ratio"] cats = adata.obs[cluster_key].cat.categories - df = pd.DataFrame({ - "x": np.tile(np.arange(len(cats)), len(cats)), - "y": np.repeat(np.arange(len(cats)), len(cats)), - "zscore": zscore.flatten(), - "ccr": ccr.flatten() - }) + df = pd.DataFrame( + { + "x": np.tile(np.arange(len(cats)), len(cats)), + "y": np.repeat(np.arange(len(cats)), len(cats)), + "zscore": zscore.flatten(), + "ccr": ccr.flatten(), + } + ) size_min, size_max = size_range ccr_norm = (df["ccr"] - df["ccr"].min()) / (df["ccr"].max() - df["ccr"].min() + 1e-10) @@ -356,20 +347,22 @@ def nhood_enrichment_dotplot( # Continuous CCR legend from matplotlib.lines import Line2D + legend_ccr_vals = np.linspace(df["ccr"].min(), df["ccr"].max(), 5) - legend_sizes = size_min + (legend_ccr_vals - df["ccr"].min()) / ( - df["ccr"].max() - df["ccr"].min() + 1e-10 - ) * (size_max - size_min) + legend_sizes = size_min + (legend_ccr_vals - df["ccr"].min()) / (df["ccr"].max() - df["ccr"].min() + 1e-10) * ( + size_max - size_min + ) legend_elements = [ Line2D( - [0], [0], - marker='o', - color='w', - label=f'{v:.2f}', - markerfacecolor='gray', + [0], + [0], + marker="o", + color="w", + label=f"{v:.2f}", + markerfacecolor="gray", markersize=np.sqrt(s), # scatter size is area → sqrt for legend - markeredgecolor='black' + markeredgecolor="black", ) for v, s in zip(legend_ccr_vals, legend_sizes) ] @@ -377,16 +370,15 @@ def nhood_enrichment_dotplot( ax.legend( handles=legend_elements, title="CCR", - loc='center left', + loc="center left", bbox_to_anchor=(1.3, 0.5), - borderaxespad=0., - frameon=False + borderaxespad=0.0, + frameon=False, ) if annotate: for _, row in df.iterrows(): - ax.text(row["x"], row["y"], f"{row['ccr']:.2f}", - ha="center", va="center") + ax.text(row["x"], row["y"], f"{row['ccr']:.2f}", ha="center", va="center") ax.invert_yaxis() ax.set_aspect("equal") @@ -395,7 +387,6 @@ def nhood_enrichment_dotplot( save_fig(fig, path=save) - @d.dedent def ripley( adata: AnnData, diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index 3412eeaef..02b4f8681 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -143,17 +143,11 @@ def test_interaction_matrix_nan_values(adata_intmat: AnnData): np.testing.assert_array_equal(expected_weighted, result_weighted) np.testing.assert_array_equal(expected_unweighted, result_unweighted) + @pytest.mark.parametrize("normalization", ["none", "total", "conditional"]) def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str): spatial_neighbors(adata) - result = nhood_enrichment( - adata, - cluster_key=_CK, - normalization=normalization, - n_jobs=1, - n_perms=20, - copy=True - ) + result = nhood_enrichment(adata, cluster_key=_CK, normalization=normalization, n_jobs=1, n_perms=20, copy=True) if normalization == "conditional": z, count, ccr = result @@ -166,6 +160,7 @@ def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str assert z.shape == count.shape assert z.shape[0] == adata.obs[_CK].cat.categories.shape[0] + def test_conditional_normalization_zero_division(adata: AnnData): adata = adata.copy() min_cells = 10 @@ -176,16 +171,10 @@ def test_conditional_normalization_zero_division(adata: AnnData): adata.obs[_CK] = adata.obs[_CK].cat.add_categories("isolated") adata.obs.loc[adata.obs.index[0], _CK] = "isolated" spatial_neighbors(adata) - valid_clusters = [ - c for c, count in adata.obs[_CK].value_counts().items() if count >= min_cells - ] + valid_clusters = [c for c, count in adata.obs[_CK].value_counts().items() if count >= min_cells] valid_idx = [i for i, cat in enumerate(adata.obs[_CK].cat.categories) if cat in valid_clusters] - result = nhood_enrichment( - adata, - cluster_key=_CK, - normalization="conditional", - copy=True) + result = nhood_enrichment(adata, cluster_key=_CK, normalization="conditional", copy=True) assert result is not None zscore, count_normalized, conditional_ratio = result assert not np.any(np.isinf(zscore)) @@ -194,13 +183,16 @@ def test_conditional_normalization_zero_division(adata: AnnData): assert not np.isnan(zscore[np.ix_(valid_idx, valid_idx)]).any() assert not np.isnan(count_normalized[np.ix_(valid_idx, valid_idx)]).any() assert not np.isnan(conditional_ratio[np.ix_(valid_idx, valid_idx)]).any() - -@pytest.mark.parametrize("normalization, expected_dtype", [ - ("none", np.uint32), - ("total", np.float64), - ("conditional", np.float64), -]) + +@pytest.mark.parametrize( + "normalization, expected_dtype", + [ + ("none", np.uint32), + ("total", np.float64), + ("conditional", np.float64), + ], +) def test_output_dtype(adata: AnnData, normalization: str, expected_dtype): spatial_neighbors(adata) result = nhood_enrichment( @@ -224,4 +216,3 @@ def test_invalid_normalization_raises(adata: AnnData): spatial_neighbors(adata) with pytest.raises(ValueError, match="Invalid normalization mode"): nhood_enrichment(adata, cluster_key=_CK, normalization="invalid_mode", copy=True) - diff --git a/tests/plotting/test_graph.py b/tests/plotting/test_graph.py index d9e731cac..4811850ee 100644 --- a/tests/plotting/test_graph.py +++ b/tests/plotting/test_graph.py @@ -67,13 +67,13 @@ def test_plot_nhood_enrichment_ax(self, adata: AnnData): pl.nhood_enrichment(adata, cluster_key=C_KEY, ax=ax) ### TODO How can I create test data? - #def test_plot_nhood_enrichment_dotplot(self, adata: AnnData): + # def test_plot_nhood_enrichment_dotplot(self, adata: AnnData): # gr.spatial_neighbors(adata) # gr.nhood_enrichment(adata, cluster_key=C_KEY, normalization = "conditional") # pl.nhood_enrichment_dotplot(adata, cluster_key=C_KEY) - #def test_plot_nhood_enrichment_dotplot_ax(self, adata: AnnData): + # def test_plot_nhood_enrichment_dotplot_ax(self, adata: AnnData): # gr.spatial_neighbors(adata) # gr.nhood_enrichment(adata, cluster_key=C_KEY, normalization = "conditional") From 02fb792858c404e1ae789ee79931a34c4237e565 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 19 Aug 2025 09:35:09 +0200 Subject: [PATCH 14/24] fix precommit hooks --- src/squidpy/gr/_nhood.py | 32 ++++++++++++++++++++++++++++---- src/squidpy/im/_io.py | 7 ++++--- src/squidpy/pl/_graph.py | 2 +- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 666525d62..c762e05ae 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -118,7 +118,32 @@ def _create_function(n_cls: int, parallel: bool = False) -> Callable[[NDArrayA, return globals()[fn_key] # type: ignore[no-any-return] -def filter_clusters_by_min_cell_count(adata, int_clust, connectivity_key, min_cell_count): +def filter_clusters_by_min_cell_count( + adata: AnnData, + int_clust: NDArrayA, + connectivity_key: str, + min_cell_count: int, +) -> tuple[NDArrayA, NDArrayA]: + """ + Filter clusters by minimum cell count. + + Parameters + ---------- + %(adata)s + int_clust + Array of cluster labels per cell + connectivity_key + Key in adata.obsp with adjacency matrix + min_cell_count + Minimum number of cells required to keep a cluster + + Returns + ------- + int_clust_filtered + Filtered cluster labels + adj + Adjacency matrix corresponding to filtered cells + """ clust_sizes = pd.Series(int_clust).value_counts() valid_clusters = clust_sizes[clust_sizes >= min_cell_count].index.to_numpy() @@ -147,7 +172,7 @@ def nhood_enrichment( min_cell_count: int = 10, handle_nan: str = "keep", show_progress_bar: bool = True, -) -> tuple[NDArrayA, NDArrayA] | None: +) -> tuple[NDArrayA, NDArrayA] | tuple[NDArrayA, NDArrayA, NDArrayA] | None: """ Compute neighborhood enrichment by permutation test. @@ -211,7 +236,7 @@ def nhood_enrichment( _test = _create_function(n_cls, parallel=numba_parallel) count = _test(indices, indptr, int_clust) - conditional_ratio = None + conditional_ratio = np.full((n_cls, n_cls), np.nan, dtype=np.float64) if normalization == "total": row_sums = count.sum(axis=1, keepdims=True) @@ -568,7 +593,6 @@ def _nhood_enrichment_helper( has_b_neighbor = per_cell_neighbor_matrix[a_cells, b] cond_counts[a, b] = has_b_neighbor.sum() - low_count_mask = cond_counts < min_cond_count cond_counts[cond_counts == 0] = 1.0 count_perms = count_perms / cond_counts diff --git a/src/squidpy/im/_io.py b/src/squidpy/im/_io.py index 3f092470b..526d4668c 100644 --- a/src/squidpy/im/_io.py +++ b/src/squidpy/im/_io.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from pathlib import Path +from typing import Any import dask.array as da import numpy as np @@ -25,7 +26,7 @@ def _assert_dims_present(dims: tuple[str, ...], include_z: bool = True) -> None: # modification of `skimage`'s `pil_to_ndarray`: # https://github.com/scikit-image/scikit-image/blob/main/skimage/io/_plugins/pil_plugin.py#L55 -def _infer_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: +def _infer_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype[Any]]: def _palette_is_grayscale(pil_image: Image.Image) -> bool: # get palette as an array with R, G, B columns palette = np.asarray(pil_image.getpalette()).reshape((256, 3)) @@ -81,7 +82,7 @@ def _palette_is_grayscale(pil_image: Image.Image) -> bool: raise ValueError(f"Unable to infer image dtype for image mode `{image.mode}`.") -def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: +def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype[Any]]: try: return _infer_shape_dtype(fname) except Image.UnidentifiedImageError as e: @@ -101,7 +102,7 @@ def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: def _infer_dimensions( obj: NDArrayA | xr.DataArray | str, infer_dimensions: InferDimensions | tuple[str, ...] = InferDimensions.DEFAULT, -) -> tuple[tuple[int, ...], tuple[str, ...], np.dtype, tuple[int, ...]]: +) -> tuple[tuple[int, ...], tuple[str, ...], np.dtype[Any], tuple[int, ...]]: """ Infer dimension names of an array. diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 68c75e513..5a6e4e2a1 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -364,7 +364,7 @@ def nhood_enrichment_dotplot( markersize=np.sqrt(s), # scatter size is area → sqrt for legend markeredgecolor="black", ) - for v, s in zip(legend_ccr_vals, legend_sizes) + for v, s in zip(legend_ccr_vals, legend_sizes, strict=True) ] ax.legend( From 3cd8c44453e3d1c53798fd19a9701b0f77e9ff2e Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 19 Aug 2025 13:28:25 +0200 Subject: [PATCH 15/24] set min_cell_count default to 0 --- src/squidpy/gr/_nhood.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index c762e05ae..ce5740dcf 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -169,7 +169,7 @@ def nhood_enrichment( n_jobs: int | None = None, backend: str = "loky", normalization: str = "none", - min_cell_count: int = 10, + min_cell_count: int = 0, handle_nan: str = "keep", show_progress_bar: bool = True, ) -> tuple[NDArrayA, NDArrayA] | tuple[NDArrayA, NDArrayA, NDArrayA] | None: From 785a3eec7a5883c628f101db7dc3dad861621b54 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 19 Aug 2025 16:17:21 +0200 Subject: [PATCH 16/24] add dotplot test image and function --- .../_images/Graph_nhood_enrichment_dotplot.png | Bin 0 -> 7909 bytes tests/plotting/test_graph.py | 16 ++++------------ 2 files changed, 4 insertions(+), 12 deletions(-) create mode 100644 tests/_images/Graph_nhood_enrichment_dotplot.png diff --git a/tests/_images/Graph_nhood_enrichment_dotplot.png b/tests/_images/Graph_nhood_enrichment_dotplot.png new file mode 100644 index 0000000000000000000000000000000000000000..325a25b25fc10786524a965f1b529f7ea91422ec GIT binary patch literal 7909 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 Date: Tue, 19 Aug 2025 16:38:12 +0200 Subject: [PATCH 17/24] remove matplotlib loading from function --- src/squidpy/pl/_graph.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 5a6e4e2a1..ac39285d9 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -13,6 +13,7 @@ import seaborn as sns from anndata import AnnData from matplotlib.axes import Axes +from matplotlib.lines import Line2D from squidpy._constants._constants import RipleyStat from squidpy._constants._pkg_constants import Key @@ -345,9 +346,6 @@ def nhood_enrichment_dotplot( cbar = fig.colorbar(sc, ax=ax, **cbar_kwargs) cbar.set_label("Z-score") - # Continuous CCR legend - from matplotlib.lines import Line2D - legend_ccr_vals = np.linspace(df["ccr"].min(), df["ccr"].max(), 5) legend_sizes = size_min + (legend_ccr_vals - df["ccr"].min()) / (df["ccr"].max() - df["ccr"].min() + 1e-10) * ( size_max - size_min From 005a57bb6cb2241acaebd9e589ccf10b3d93d0f2 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Tue, 19 Aug 2025 16:41:17 +0200 Subject: [PATCH 18/24] fix typo --- src/squidpy/_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index 3f2bd1523..d21ec724c 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -50,7 +50,7 @@ def decorator2(obj: Any) -> Any: Number of permutations for the permutation test.""" _normalization = """\ normalization - Normalization of neighbor counts either `None`, `total` (divide by total number of index cell types) or `conditional` (divided byt number of index cell types with at least one neighbor of neighbor cell type).""" + Normalization of neighbor counts either `None`, `total` (divide by total number of index cell types) or `conditional` (divide by number of index cell types with at least one neighbor of neighbor cell type).""" _min_cell_count = """\ min_cell_count Minimum number of cells that have to be in a cluster to be included in analysis. If count > min_cell_count, peir will be set to NA.""" From b90dbb065caf127a86469c046c5468bbc57f3f10 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 6 Oct 2025 15:21:00 +0200 Subject: [PATCH 19/24] add optional cond_ratio to result class --- src/squidpy/gr/_nhood.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index 0ec43f87b..aa43698e6 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -5,7 +5,7 @@ import warnings from collections.abc import Callable, Iterable, Sequence from functools import partial -from typing import Any, NamedTuple +from typing import Any, NamedTuple, Optional import networkx as nx import numba.types as nt @@ -42,10 +42,13 @@ class NhoodEnrichmentResult(NamedTuple): Z-score values of enrichment statistic. count : NDArray[np.number] Enrichment count. + conditional_ratio : Optional[NDArray[np.number]] + Conditional ratio (only present if normalization='conditional'). """ zscore: NDArray[np.number] counts: NDArray[np.number] # NamedTuple inherits from tuple so cannot use 'count' as attribute name + conditional_ratio: NDArray[np.number] | None = None # data type aliases (both for numpy and numba should match) @@ -218,11 +221,13 @@ def nhood_enrichment( Returns ------- If ``copy = True``, returns a :class:`~squidpy.gr.NhoodEnrichmentResult` with the z-score and the enrichment count. + If normalization = "conditional", also contains the conditional ratio, otherwise it is None. Otherwise, modifies the ``adata`` with the following keys: - :attr:`anndata.AnnData.uns` ``['{cluster_key}_nhood_enrichment']['zscore']`` - the enrichment z-score. - :attr:`anndata.AnnData.uns` ``['{cluster_key}_nhood_enrichment']['count']`` - the enrichment count. + - :attr:`anndata.AnnData.uns` ``['{cluster_key}_nhood_enrichment']['conditional_ratio']`` - the ratio of cells of type A that neighbor type B. """ if isinstance(adata, SpatialData): adata = adata.table @@ -338,14 +343,18 @@ def nhood_enrichment( else: raise ValueError("handle_nan must be 'keep' or 'zero'") + result_kwargs = {"zscore": zscore, "counts": count} + if normalization == "conditional": + result_kwargs["conditional_ratio"] = conditional_ratio + if copy: - return NhoodEnrichmentResult(zscore=zscore, counts=count) + return NhoodEnrichmentResult(**result_kwargs) _save_data( adata, attr="uns", key=Key.uns.nhood_enrichment(cluster_key), - data=NhoodEnrichmentResult(zscore=zscore, counts=count), + data=NhoodEnrichmentResult(**result_kwargs), time=start, ) From 7ac43a471417d40d33322598966d6285f62ddb8d Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 6 Oct 2025 15:32:14 +0200 Subject: [PATCH 20/24] adapt plotting to result class changes --- src/squidpy/pl/_graph.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index ac39285d9..71aed550c 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from collections.abc import Mapping, Sequence from pathlib import Path from types import MappingProxyType @@ -217,7 +218,8 @@ def nhood_enrichment( %(plotting_returns)s """ _assert_categorical_obs(adata, key=cluster_key) - array = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")[mode] + enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")._asdict() + array = enrichment[mode] ad = AnnData(X=array, obs={cluster_key: pd.Categorical(adata.obs[cluster_key].cat.categories)}) _maybe_set_colors(source=adata, target=ad, key=cluster_key, palette=palette) @@ -301,10 +303,20 @@ def nhood_enrichment_dotplot( None """ _assert_categorical_obs(adata, key=cluster_key) - enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment") + enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")._asdict() + + zscore = enrichment.get("zscore") + ccr = enrichment.get("conditional_ratio") + + if ccr is None: + warnings.warn( + "'conditional_ratio' is None in nhood_enrichment results. Please run nhood_erichment with normalization = 'conditional'." + "Dot size will not reflect conditional cell ratios.", + UserWarning, + stacklevel=2, + ) + ccr = np.ones_like(zscore) - zscore = enrichment["zscore"] - ccr = enrichment["conditional_cell_ratio"] cats = adata.obs[cluster_key].cat.categories df = pd.DataFrame( From a82ddaa3bbab7e10c1d225763a8267dcedbdfbde Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 6 Oct 2025 16:50:19 +0200 Subject: [PATCH 21/24] fix optional cond ratio output --- src/squidpy/gr/_nhood.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index aa43698e6..96db48853 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -5,7 +5,7 @@ import warnings from collections.abc import Callable, Iterable, Sequence from functools import partial -from typing import Any, NamedTuple, Optional +from typing import Any, NamedTuple import networkx as nx import numba.types as nt @@ -42,7 +42,7 @@ class NhoodEnrichmentResult(NamedTuple): Z-score values of enrichment statistic. count : NDArray[np.number] Enrichment count. - conditional_ratio : Optional[NDArray[np.number]] + conditional_ratio : NDArray[np.number] | None Conditional ratio (only present if normalization='conditional'). """ @@ -307,9 +307,7 @@ def nhood_enrichment( elif normalization == "none": count_normalized = count.copy() else: - raise ValueError( - f"Invalid normalization mode `{normalization}`. Choose from 'none', 'all_type_A', 'type_A_with_B_neighbor'." - ) + raise ValueError(f"Invalid normalization mode `{normalization}`. Choose from 'none', 'total', 'conditional'.") n_jobs = _get_n_cores(n_jobs) start = logg.info(f"Calculating neighborhood enrichment using `{n_jobs}` core(s)") @@ -343,18 +341,22 @@ def nhood_enrichment( else: raise ValueError("handle_nan must be 'keep' or 'zero'") - result_kwargs = {"zscore": zscore, "counts": count} + result_kwargs = {"zscore": zscore, "count": count} if normalization == "conditional": result_kwargs["conditional_ratio"] = conditional_ratio if copy: - return NhoodEnrichmentResult(**result_kwargs) + return NhoodEnrichmentResult( + zscore=result_kwargs["zscore"], + counts=result_kwargs["count"], + conditional_ratio=result_kwargs.get("conditional_ratio"), + ) _save_data( adata, attr="uns", key=Key.uns.nhood_enrichment(cluster_key), - data=NhoodEnrichmentResult(**result_kwargs), + data=result_kwargs, time=start, ) @@ -571,7 +573,6 @@ def _nhood_enrichment_helper( seed: int | None = None, queue: SigQueue | None = None, normalization: str = "none", - min_cond_count: int = 5, ) -> NDArrayA: perms = np.empty((len(ixs), n_cls, n_cls), dtype=np.float64) int_clust = int_clust.copy() From 7a71607bae5cafbd7aa7615f3d624fb5cda9f9fe Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 6 Oct 2025 16:52:52 +0200 Subject: [PATCH 22/24] remove dict plotting changes again --- src/squidpy/pl/_graph.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/squidpy/pl/_graph.py b/src/squidpy/pl/_graph.py index 71aed550c..10ff335a0 100644 --- a/src/squidpy/pl/_graph.py +++ b/src/squidpy/pl/_graph.py @@ -218,8 +218,7 @@ def nhood_enrichment( %(plotting_returns)s """ _assert_categorical_obs(adata, key=cluster_key) - enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")._asdict() - array = enrichment[mode] + array = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")[mode] ad = AnnData(X=array, obs={cluster_key: pd.Categorical(adata.obs[cluster_key].cat.categories)}) _maybe_set_colors(source=adata, target=ad, key=cluster_key, palette=palette) @@ -303,9 +302,9 @@ def nhood_enrichment_dotplot( None """ _assert_categorical_obs(adata, key=cluster_key) - enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment")._asdict() + enrichment = _get_data(adata, cluster_key=cluster_key, func_name="nhood_enrichment") - zscore = enrichment.get("zscore") + zscore = enrichment["zscore"] ccr = enrichment.get("conditional_ratio") if ccr is None: From 4972f962f4c4c4392e51318ff84e783529bf34c0 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 6 Oct 2025 16:54:21 +0200 Subject: [PATCH 23/24] adapt tests to new output format --- tests/graph/test_nhood.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index 12951ef76..aa3d460f3 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -150,14 +150,14 @@ def test_nhood_enrichment_normalization_modes(adata: AnnData, normalization: str spatial_neighbors(adata) result = nhood_enrichment(adata, cluster_key=_CK, normalization=normalization, n_jobs=1, n_perms=20, copy=True) - if normalization == "conditional": - z, count, ccr = result - assert isinstance(ccr, np.ndarray) - else: - z, count = result + z, count, ccr = result assert isinstance(z, np.ndarray) assert isinstance(count, np.ndarray) + if normalization == "conditional": + assert isinstance(ccr, np.ndarray) + assert z.shape == ccr.shape + assert count.shape == ccr.shape assert z.shape == count.shape assert z.shape[0] == adata.obs[_CK].cat.categories.shape[0] @@ -190,8 +190,8 @@ def test_conditional_normalization_zero_division(adata: AnnData): "normalization, expected_dtype", [ ("none", np.uint32), - ("total", np.float64), - ("conditional", np.float64), + ("total", np.uint32), + ("conditional", np.uint32), ], ) def test_output_dtype(adata: AnnData, normalization: str, expected_dtype): @@ -205,10 +205,7 @@ def test_output_dtype(adata: AnnData, normalization: str, expected_dtype): copy=True, ) - if normalization == "conditional": - _, count, _ = result - else: - _, count = result + count = result.counts assert count.dtype == expected_dtype From c2c40e7646a0bf6197933bd7761fc468207c9738 Mon Sep 17 00:00:00 2001 From: chiarasch Date: Mon, 6 Oct 2025 17:02:18 +0200 Subject: [PATCH 24/24] typo --- src/squidpy/_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index d21ec724c..a715a2ed6 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -50,7 +50,7 @@ def decorator2(obj: Any) -> Any: Number of permutations for the permutation test.""" _normalization = """\ normalization - Normalization of neighbor counts either `None`, `total` (divide by total number of index cell types) or `conditional` (divide by number of index cell types with at least one neighbor of neighbor cell type).""" + Normalization of neighbor counts either `none`, `total` (divide by total number of index cell types) or `conditional` (divide by number of index cell types with at least one neighbor of neighbor cell type).""" _min_cell_count = """\ min_cell_count Minimum number of cells that have to be in a cluster to be included in analysis. If count > min_cell_count, peir will be set to NA."""