From bc581fadb575e1c50e6434603fca8331b5c1fb91 Mon Sep 17 00:00:00 2001 From: luckyy14 Date: Sat, 3 Jan 2026 11:53:42 +0530 Subject: [PATCH 1/2] Add new spatial graph features and tests (closes #901) --- src/squidpy/_constants/_constants.py | 1 + src/squidpy/gr/_ppatterns.py | 7 ++++ src/squidpy/gr/_spagft.py | 38 +++++++++++++++++ tests/graph/test_spagft.py | 61 ++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/squidpy/gr/_spagft.py create mode 100644 tests/graph/test_spagft.py diff --git a/src/squidpy/_constants/_constants.py b/src/squidpy/_constants/_constants.py index 403f072ba..19a20003e 100644 --- a/src/squidpy/_constants/_constants.py +++ b/src/squidpy/_constants/_constants.py @@ -94,6 +94,7 @@ class Symbol(ModeEnum): class SpatialAutocorr(ModeEnum): MORAN = "moran" GEARY = "geary" + SPAGFT = "spagft" @unique diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 0286a63f2..73b548b60 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -39,6 +39,8 @@ __all__ = ["spatial_autocorr", "co_occurrence"] +from squidpy.gr._spagft import _spagft + it = nt.int32 ft = nt.float32 @@ -192,6 +194,11 @@ def extract_obsm(adata: AnnData, ixs: int | Sequence[int] | None) -> tuple[NDArr params["stat"] = "C" params["expected"] = 1.0 params["ascending"] = True + elif mode.s == "spagft": + params["func"] = _spagft + params["stat"] = "GFT" + params["expected"] = 0.0 + params["ascending"] = False else: raise NotImplementedError(f"Mode `{mode}` is not yet implemented.") diff --git a/src/squidpy/gr/_spagft.py b/src/squidpy/gr/_spagft.py new file mode 100644 index 000000000..5a9c95681 --- /dev/null +++ b/src/squidpy/gr/_spagft.py @@ -0,0 +1,38 @@ +import numpy as np +from scipy.sparse import spmatrix +from squidpy._utils import NDArrayA + +def _spagft(g: spmatrix, vals: NDArrayA) -> NDArrayA: + """ + SpaGFT: Identify spatially variable genes using graph Fourier transform. + Returns a score per gene indicating spatial variability. + """ + from scipy.sparse import csgraph + from scipy.sparse.linalg import eigsh + + # g: adjacency matrix (n_cells x n_cells) + # vals: (n_cells x n_genes) + if vals.shape[0] != g.shape[0]: + if vals.shape[1] == g.shape[0]: + vals = vals.T + else: + raise ValueError("vals must have shape (n_cells, n_genes), where n_cells == g.shape[0].") + vals_proc = vals + + # Compute normalized Laplacian + lap = csgraph.laplacian(g, normed=True) + # Compute eigenvectors (graph Fourier basis) + n_eig = min(20, lap.shape[0] - 2) + if n_eig <= 0: + from scipy.sparse.linalg import ArpackError + raise ArpackError("Number of eigenvectors requested must be positive.") + eigvals, eigvecs = eigsh(lap, k=n_eig, which="SM") + + # Project each gene onto Fourier basis, score by energy in low-frequency components + scores = [] + for gene in vals_proc.T: + coeffs = eigvecs.T @ gene + # SVG score: sum squared coeffs for lowest frequencies (spatially smooth signal) + lf_energy = np.sum(coeffs[: n_eig // 2] ** 2) + scores.append(lf_energy) + return np.array(scores) diff --git a/tests/graph/test_spagft.py b/tests/graph/test_spagft.py new file mode 100644 index 000000000..c616af645 --- /dev/null +++ b/tests/graph/test_spagft.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import pytest + +from squidpy.gr import spatial_autocorr +from squidpy._constants._constants import SpatialAutocorr + +def test_spagft_incompatible_shapes(): + from squidpy.gr._spagft import _spagft + import numpy as np + from scipy.sparse import lil_matrix + n = 10 + g = lil_matrix((n, n)) + for i in range(n): + g[i, (i + 1) % n] = 1 + g[i, (i - 1) % n] = 1 + g = g.tocsr() + vals = np.random.rand(5, 7) + with pytest.raises((ValueError)): + _spagft(g, vals) + +def test_spagft_svg_identification(): + import numpy as np + from anndata import AnnData + from squidpy.gr import spatial_autocorr + + n_cells = 50 + np.random.seed(42) + spatial_pattern = np.sin(np.linspace(0, 2 * np.pi, n_cells)) + random_gene = np.random.normal(size=n_cells) + X = np.vstack([spatial_pattern, random_gene]) + adata = AnnData(X=X.T) + from scipy.sparse import lil_matrix + g = lil_matrix((n_cells, n_cells)) + for i in range(n_cells): + g[i, (i + 1) % n_cells] = 1 + g[i, (i - 1) % n_cells] = 1 + adata.obsp["spatial_connectivities"] = g.tocsr() + df = spatial_autocorr(adata, mode="spagft", copy=True) + assert "GFT" in df.columns + assert df["GFT"].iloc[0] > df["GFT"].iloc[1] + +def test_spagft_enum_recognition(): + # Check that the enum contains "spagft" + assert hasattr(SpatialAutocorr, "SPAGFT") + # Check that spatial_autocorr accepts the enum member + import numpy as np + from anndata import AnnData + n_cells = 10 + np.random.seed(0) + X = np.random.normal(size=(n_cells, 2)) + adata = AnnData(X=X) + from scipy.sparse import lil_matrix + g = lil_matrix((n_cells, n_cells)) + for i in range(n_cells): + g[i, (i + 1) % n_cells] = 1 + g[i, (i - 1) % n_cells] = 1 + adata.obsp["spatial_connectivities"] = g.tocsr() + # Should not raise + df = spatial_autocorr(adata, mode=SpatialAutocorr.SPAGFT, copy=True) + assert "GFT" in df.columns \ No newline at end of file From f64a4ebb3be5fcad72f7977038dd06155c88342c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 06:24:43 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_ppatterns.py | 1 - src/squidpy/gr/_spagft.py | 5 +++++ tests/graph/test_spagft.py | 17 +++++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 73b548b60..8ed97c564 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -41,7 +41,6 @@ from squidpy.gr._spagft import _spagft - it = nt.int32 ft = nt.float32 tt = nt.UniTuple diff --git a/src/squidpy/gr/_spagft.py b/src/squidpy/gr/_spagft.py index 5a9c95681..b49d100d4 100644 --- a/src/squidpy/gr/_spagft.py +++ b/src/squidpy/gr/_spagft.py @@ -1,7 +1,11 @@ +from __future__ import annotations + import numpy as np from scipy.sparse import spmatrix + from squidpy._utils import NDArrayA + def _spagft(g: spmatrix, vals: NDArrayA) -> NDArrayA: """ SpaGFT: Identify spatially variable genes using graph Fourier transform. @@ -25,6 +29,7 @@ def _spagft(g: spmatrix, vals: NDArrayA) -> NDArrayA: n_eig = min(20, lap.shape[0] - 2) if n_eig <= 0: from scipy.sparse.linalg import ArpackError + raise ArpackError("Number of eigenvectors requested must be positive.") eigvals, eigvecs = eigsh(lap, k=n_eig, which="SM") diff --git a/tests/graph/test_spagft.py b/tests/graph/test_spagft.py index c616af645..283d73b6e 100644 --- a/tests/graph/test_spagft.py +++ b/tests/graph/test_spagft.py @@ -2,13 +2,16 @@ import pytest -from squidpy.gr import spatial_autocorr from squidpy._constants._constants import SpatialAutocorr +from squidpy.gr import spatial_autocorr + def test_spagft_incompatible_shapes(): - from squidpy.gr._spagft import _spagft import numpy as np from scipy.sparse import lil_matrix + + from squidpy.gr._spagft import _spagft + n = 10 g = lil_matrix((n, n)) for i in range(n): @@ -16,12 +19,14 @@ def test_spagft_incompatible_shapes(): g[i, (i - 1) % n] = 1 g = g.tocsr() vals = np.random.rand(5, 7) - with pytest.raises((ValueError)): + with pytest.raises(ValueError): _spagft(g, vals) + def test_spagft_svg_identification(): import numpy as np from anndata import AnnData + from squidpy.gr import spatial_autocorr n_cells = 50 @@ -31,6 +36,7 @@ def test_spagft_svg_identification(): X = np.vstack([spatial_pattern, random_gene]) adata = AnnData(X=X.T) from scipy.sparse import lil_matrix + g = lil_matrix((n_cells, n_cells)) for i in range(n_cells): g[i, (i + 1) % n_cells] = 1 @@ -40,17 +46,20 @@ def test_spagft_svg_identification(): assert "GFT" in df.columns assert df["GFT"].iloc[0] > df["GFT"].iloc[1] + def test_spagft_enum_recognition(): # Check that the enum contains "spagft" assert hasattr(SpatialAutocorr, "SPAGFT") # Check that spatial_autocorr accepts the enum member import numpy as np from anndata import AnnData + n_cells = 10 np.random.seed(0) X = np.random.normal(size=(n_cells, 2)) adata = AnnData(X=X) from scipy.sparse import lil_matrix + g = lil_matrix((n_cells, n_cells)) for i in range(n_cells): g[i, (i + 1) % n_cells] = 1 @@ -58,4 +67,4 @@ def test_spagft_enum_recognition(): adata.obsp["spatial_connectivities"] = g.tocsr() # Should not raise df = spatial_autocorr(adata, mode=SpatialAutocorr.SPAGFT, copy=True) - assert "GFT" in df.columns \ No newline at end of file + assert "GFT" in df.columns