Repository: scverse/squidpy
Pull request #991: Ligrec Reproducibility and Bug Fix in Sparse Data.
Author: login: selmanozleyen
Created: 2025-04-22T08:34:39Z | Merged: 2025-05-20T09:21:59Z

Fixes https://github.com/scverse/squidpy/issues/871 and https://github.com/scverse/squidpy/issues/840

When comparing version 1.2.4 and main, I noticed 0's were integers while non zeros were floats in the main branch while in the old version they were all the float. I think this caused some rounding problems and integer divisions. I have some ideas on how to expose these bugs on unit tests but I just wanted to open this PR to show the fix.

Ligrec needs two things but I seperated the other to simplify this PR. Normally we also need to modify numba code because it doesn't parallelize due to an assertion exitting the loop early. Those changes are in this branch: https://github.com/scverse/squidpy/compare/main...fix/ligrec

The notebooks are now reproducable by my local tests. We used notebook tests on the CI in moscot, do you think we can also do this in squidpy @timtreis ?




Diff:
diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py
index d7766e5b0..d54ce91f0 100644
--- a/src/squidpy/gr/_ligrec.py
+++ b/src/squidpy/gr/_ligrec.py
@@ -735,11 +735,15 @@ def extractor(res: Sequence[TempResult]) -> TempResult:
     clustering = np.array(data["clusters"].values, dtype=np.int32)
 
     mean = groups.mean().values.T  # (n_genes, n_clusters)
-    mask = groups.apply(lambda c: ((c > 0).sum() / len(c)) >= threshold).values.T  # (n_genes, n_clusters)
+    # see https://github.com/scverse/squidpy/pull/991#issuecomment-2888506296
+    # for why we need to cast to int64 here
+    mask = groups.apply(
+        lambda c: ((c > 0).astype(np.int64).sum() / len(c)) >= threshold
+    ).values.T  # (n_genes, n_clusters)
+
     # (n_cells, n_genes)
     data = np.array(data[data.columns.difference(["clusters"])].values, dtype=np.float64, order="C")
     # all 3 should be C contiguous
-
     return parallelize(  # type: ignore[no-any-return]
         _analysis_helper,
         np.arange(n_perms, dtype=np.int32).tolist(),
diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py
index 0145999d6..099ecb1b6 100644
--- a/tests/graph/test_ligrec.py
+++ b/tests/graph/test_ligrec.py
@@ -14,6 +14,7 @@
 from pandas.testing import assert_frame_equal
 from scanpy import settings as s
 from scanpy.datasets import blobs
+from scipy.sparse import csc_matrix
 
 from squidpy._constants._pkg_constants import Key
 from squidpy.gr import ligrec
@@ -461,3 +462,104 @@ def test_none_source_target(self, adata: AnnData):
         )
         assert isinstance(pt.interactions, pd.DataFrame)
         assert len(pt.interactions) == 1
+
+    def test_ligrec_nan_counts(self):
+        """
+        For the test case with 2 clusters (A, B) and 3 gene pairs (Gene1→Gene2, Gene2→Gene3, Gene3→Gene1):
+
+        The mask is computed for each gene in each cluster as:
+        mask[gene, cluster] = (number of cells with value > 0) / (total cells in cluster) >= threshold
+
+        Number of cells with value > 0 in each cluster:
+        Cluster A: [1, 3, 0]
+        Cluster B: [1, 0, 3]
+
+        Number of cells with value > 0 in each cluster divided by total number of cells in the cluster:
+        Cluster A: [1/3, 3/3, 0/3] = [0.33, 1.0, 0.0]
+        Cluster B: [1/3, 0/3, 3/3] = [0.33, 0.0, 1.0]
+
+        Using threshold=0.8 on this data, the mask is:
+        Cluster A: [False, True, False]
+        Cluster B: [False, False, True]
+
+        A value in the result becomes NaN if either:
+        - The ligand's mask is False in the source cluster, OR
+        - The receptor's mask is False in the target cluster
+
+        Only in one combination, the mask is both True in the source and target cluster.
+        This is the case for Gene2→Gene3 in A→B.
+
+        This means from all the possible cluster pairs (A→A, A→B, B→A, B→B) and gene pairs (Gene1→Gene2, Gene2→Gene3, Gene3→Gene1),
+        (4 cluster pairs * 3 gene pairs = 12 combinations) only one combination is non-NaN.
+
+        Therefore, the total number of NaNs is 11.
+
+        The expected p-values are:
+        cluster_1        A         B
+        cluster_2        A    B    A    B
+        source target
+        GENE1  GENE2   NaN  NaN  NaN  NaN
+        GENE2  GENE3   NaN  0.0  NaN  NaN
+        GENE3  GENE1   NaN  NaN  NaN  NaN
+
+        """
+        # only Gene2→Gene3 is non-NaN
+        #
+
+        expected_pvalues = np.array(
+            [
+                [
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                ],
+                [
+                    np.nan,
+                    0.0,
+                    np.nan,
+                    np.nan,
+                ],
+                [
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                ],
+            ]
+        )
+
+        expected_nans = 11
+        # Setup test data
+        threshold = 0.8
+        interactions = pd.DataFrame({"source": ["Gene1", "Gene2", "Gene3"], "target": ["Gene2", "Gene3", "Gene1"]})
+
+        # Create sparse matrix with test data
+        X = csc_matrix(
+            [
+                [1.0, 0.1, 0.0],  # A1
+                [0.0, 1.0, 0.0],  # A2
+                [0.0, 1.0, 0.0],  # A3
+                [0.1, 0.0, 1.0],  # B1
+                [0.0, 0.0, 1.0],  # B2
+                [0.0, 0.0, 1.0],  # B3
+            ]
+        )
+
+        # Create AnnData object
+        adata = AnnData(
+            X=X,
+            obs=pd.DataFrame({"cluster": ["A"] * 3 + ["B"] * 3}, index=[f"cell{i}" for i in range(1, 7)]),
+            var=pd.DataFrame(index=["Gene1", "Gene2", "Gene3"]),
+        )
+        adata.obs["cluster"] = adata.obs["cluster"].astype("category")
+
+        # Run ligrec and compare NaN counts
+        res = ligrec(
+            adata, cluster_key="cluster", interactions=interactions, threshold=threshold, use_raw=False, copy=True
+        )
+
+        actual_nans = np.sum(np.isnan(res["pvalues"].values))
+
+        assert actual_nans == expected_nans, f"NaN count mismatch: expected {expected_nans}, got {actual_nans}"
+        np.testing.assert_array_equal(res["pvalues"].values, expected_pvalues)


Reviews:
user: login: ilan-gold
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T15:21:06Z
user: login: selmanozleyen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T08:34:59Z
user: login: ilan-gold
body: Small comment.  Once addressed/CI still passes, good to merge from my perspective
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T08:58:20Z

Review comments:
diff_hunk: @@ -461,3 +462,73 @@ def test_none_source_target(self, adata: AnnData):
         )
         assert isinstance(pt.interactions, pd.DataFrame)
         assert len(pt.interactions) == 1
+
+    def test_ligrec_nan_counts(self):
+        """
+        For the test case with 2 clusters (A, B) and 3 gene pairs (Gene1→Gene2, Gene2→Gene3, Gene3→Gene1):
+
+        Expression fractions per cluster (computed as fraction of cells with value > 0):
+        Cluster A:
+        - Gene1: 1/3 = 0.33 (only A1 has value > 0)
+        - Gene2: 2/3 = 0.67 (A2 and A3 have value > 0)
+        - Gene3: 0/3 = 0.00 (none have value > 0)
+
+        Cluster B:
+        - Gene1: 1/3 = 0.33 (only B1 has value > 0)
+        - Gene2: 0/3 = 0.00 (none have value > 0)
+        - Gene3: 3/3 = 1.00 (all have value > 0)
+
+        The mask is computed for each gene in each cluster as:
+        mask[gene, cluster] = (number of cells with value > 0) / (total cells in cluster) >= threshold
+
+        With threshold=0.8, the mask is:
+        Cluster A: [False, False, False]  # All genes < 0.8 expression fraction
+        Cluster B: [False, False, True]   # Only Gene3 >= 0.8 expression fraction
+
+        A value in the result becomes NaN if either:
+        - The ligand's mask is False in the source cluster, OR
+        - The receptor's mask is False in the target cluster
path: tests/graph/test_ligrec.py
user: login: ilan-gold
body: Sorry, doesn't this imply they both have to be True to be non-NaN? Wouldn't that exlude `Gene2→Gene3`?
created_at: 2025-05-19T15:15:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -461,3 +462,73 @@ def test_none_source_target(self, adata: AnnData):
         )
         assert isinstance(pt.interactions, pd.DataFrame)
         assert len(pt.interactions) == 1
+
+    def test_ligrec_nan_counts(self):
+        """
+        For the test case with 2 clusters (A, B) and 3 gene pairs (Gene1→Gene2, Gene2→Gene3, Gene3→Gene1):
+
+        Expression fractions per cluster (computed as fraction of cells with value > 0):
+        Cluster A:
+        - Gene1: 1/3 = 0.33 (only A1 has value > 0)
+        - Gene2: 2/3 = 0.67 (A2 and A3 have value > 0)
+        - Gene3: 0/3 = 0.00 (none have value > 0)
+
+        Cluster B:
+        - Gene1: 1/3 = 0.33 (only B1 has value > 0)
+        - Gene2: 0/3 = 0.00 (none have value > 0)
+        - Gene3: 3/3 = 1.00 (all have value > 0)
+
+        The mask is computed for each gene in each cluster as:
+        mask[gene, cluster] = (number of cells with value > 0) / (total cells in cluster) >= threshold
+
+        With threshold=0.8, the mask is:
+        Cluster A: [False, False, False]  # All genes < 0.8 expression fraction
+        Cluster B: [False, False, True]   # Only Gene3 >= 0.8 expression fraction
+
+        A value in the result becomes NaN if either:
+        - The ligand's mask is False in the source cluster, OR
+        - The receptor's mask is False in the target cluster
path: tests/graph/test_ligrec.py
user: login: selmanozleyen
body: You are right thanks for checking it out. Turns out I didn't fully understand it but I think I now got it and made the test a bit more specific as well. I also fixed the explanation.
created_at: 2025-05-20T08:34:59Z
author_association: MEMBER
diff_hunk: @@ -735,11 +735,11 @@ def extractor(res: Sequence[TempResult]) -> TempResult:
     clustering = np.array(data["clusters"].values, dtype=np.int32)
 
     mean = groups.mean().values.T  # (n_genes, n_clusters)
-    mask = groups.apply(lambda c: ((c > 0).sum() / len(c)) >= threshold).values.T  # (n_genes, n_clusters)
+    mask = groups.apply(lambda c: ((c > 0).astype(int).sum() / len(c)) >= threshold).values.T  # (n_genes, n_clusters)
path: src/squidpy/gr/_ligrec.py
user: login: ilan-gold
body: Let's link to the issue on why this `astype` cast is there.  And use "int64" to be explicit (which is what `int` does anyway).
created_at: 2025-05-20T08:57:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: timtreis
created_at: 2025-04-30T20:20:34Z
body: I'm not a big fan of running notebooks as tests, that's usually unnecessarily computationally expensive if the functions themselves are tested well 🤔 Within `SpatialData` we do that for a few heavier notebooks on a dedicated runner but the ligrec function seems light enough to not warrent that.
author_association: MEMBER
user: login: Zethson
created_at: 2025-05-02T17:17:04Z
body: @timtreis I'd always try to run all tutorial notebooks in the CI. Separate job via a tutorial submodule or nbconvert + render on release. Pros:
1. Ensures that the tutorial notebooks actually run.
2. Forces you to keep the tutorials and their datasets small
author_association: MEMBER
user: login: selmanozleyen
created_at: 2025-05-06T10:25:04Z
body: I tried to write a unit test for it but I am not sure much it makes sense because I am not familiar with the context very well. In general I noticed more nan values when I didn't do the fix so I wrote a test based on that. When I run the code the number of nan-pvalues in my unit tests is:
- without fix (fails): 572k nan pvalues
- with fix(passes): 424k nan pvalues

So I just made an assertion to see if nan's is less than 500k. I am open to any suggestions on this
author_association: MEMBER
user: login: flying-sheep
created_at: 2025-05-09T09:49:19Z
body: The docs say:

> NaN p-values mark combinations for which the mean expression of one of the interacting components was 0 or it didn’t pass the threshold percentage of cells being expressed within a given cluster.

Is it possible to figure out the number of NaNs we *should* see?

If not for these data, then maybe for synthetic data?
author_association: MEMBER
user: login: selmanozleyen
created_at: 2025-05-17T17:33:59Z
body: # The reason for the bug
I found the exact reason why this happens. I'd like to write a more explicit solution for this reason. It is because c.sum is and/or using bool values while when cast to float it is always interpreted as integers when summing.

```python
%load_ext autoreload
%autoreload 2
import pandas as pd
import numpy as np
import scipy.sparse as sp
# Create a simple sparse matrix with some small values
data = np.array([
    [1.0, 0.1, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 1.0, 0.0]
])

# Convert to sparse DataFrame
sparse_df = pd.DataFrame.sparse.from_spmatrix(
    sp.csc_matrix(data),
    columns=['Gene1', 'Gene2', 'Gene3']
)

dense_df = pd.DataFrame(data, columns=['Gene1', 'Gene2', 'Gene3'])

# Let's look at both DataFrames first
print("Sparse DataFrame:")
print(sparse_df)
print("Dense DataFrame:")
print(dense_df)

sparse_gt0 = sparse_df > 0

dense_gt0 = dense_df > 0

print("Sparse sum")
sparse_sums = sparse_gt0.sum()
print(sparse_sums)

print("Dense sum")
dense_sums = dense_gt0.sum()
print(dense_sums)
```


```
Sparse DataFrame:
   Gene1  Gene2  Gene3
0    1.0    0.1      0
1      0    1.0      0
2      0    1.0      0
Dense DataFrame:
   Gene1  Gene2  Gene3
0    1.0    0.1    0.0
1    0.0    1.0    0.0
2    0.0    1.0    0.0
Sparse sum
Gene1     True
Gene2     True
Gene3    False
dtype: Sparse[bool, False]
Dense sum
Gene1    1
Gene2    3
Gene3    0
dtype: int64
```

# function to see the number of nan's expected
```python
def compute_expected_nans(adata: AnnData, interactions: pd.DataFrame, cluster_key: str, threshold: float) -> int:
    """Compute expected NaN count in ligrec results based on expression thresholds.

    A value is NaN if either ligand expression in cluster1 or receptor expression in cluster2
    is below threshold.
    """
    # Convert to dense if sparse and compute expression fractions
    X = adata.X.toarray() if hasattr(adata.X, "toarray") else adata.X
    clusters = adata.obs[cluster_key]
    frac = (
        pd.DataFrame((X > 0).astype(int), index=adata.obs_names, columns=adata.var_names)
        .assign(cluster=clusters)
        .groupby("cluster", observed=True)
        .mean()
    )

    # Count NaNs using boolean operations
    cluster_pairs = pd.MultiIndex.from_product([frac.index, frac.index])
    ligand_mask = frac.loc[cluster_pairs.get_level_values(0), interactions["source"]].values < threshold
    receptor_mask = frac.loc[cluster_pairs.get_level_values(1), interactions["target"]].values < threshold

    return np.sum(ligand_mask | receptor_mask)
```
author_association: MEMBER