Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7e67cc1
fix
selmanozleyen Aug 24, 2025
dbdda0c
attempt to add tests and add better documentation to the functions to…
selmanozleyen Aug 29, 2025
a3366e0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 29, 2025
b3ddcb7
add vibe coded tests prune later
selmanozleyen Aug 29, 2025
2fffcfd
add tests for spectral transform
selmanozleyen Sep 1, 2025
eeab937
Merge branch 'fix/spectral-transform' of https://github.com/scverse/s…
selmanozleyen Sep 4, 2025
2ce6e85
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 4, 2025
651505a
complete the merge conflict issue
selmanozleyen Sep 4, 2025
303d5c4
add fau and clean up code
selmanozleyen Sep 4, 2025
8c881be
make loops parallel
selmanozleyen Sep 4, 2025
bcb5989
Merge branch 'fix/spectral-transform' of https://github.com/scverse/s…
selmanozleyen Sep 4, 2025
172080d
specify fast arrayutils dep
selmanozleyen Sep 4, 2025
806273a
Merge branch 'main' into fix/spectral-transform
selmanozleyen Sep 4, 2025
a3db2d0
Merge branch 'main' into fix/spectral-transform
flying-sheep Sep 4, 2025
ea457c3
Merge branch 'main' into fix/spectral-transform
selmanozleyen Oct 1, 2025
6e60c6c
Merge branch 'main' into fix/spectral-transform
selmanozleyen Nov 3, 2025
22acdad
Add fast_array_utils to project dependencies
selmanozleyen Nov 3, 2025
017a763
Merge branch 'main' into fix/spectral-transform
selmanozleyen Nov 7, 2025
126fd38
cache kernel
selmanozleyen Nov 7, 2025
a24419a
forgot to save file bf commit
selmanozleyen Nov 7, 2025
4eae80c
Merge branch 'main' into fix/spectral-transform
selmanozleyen Nov 7, 2025
4f6010b
Apply suggestion from @flying-sheep
flying-sheep Nov 7, 2025
8f178cc
remove unused imports (idk why this didn't fail on linter)
selmanozleyen Nov 7, 2025
c7c8f83
remove more unused imports
selmanozleyen Nov 7, 2025
3d82ed5
Merge branch 'main' into fix/spectral-transform
selmanozleyen Nov 10, 2025
6680ec2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 10, 2025
0086dac
move to float32
selmanozleyen Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions src/squidpy/gr/_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,26 @@ def _build_connectivity(


@njit
def outer(indices: NDArrayA, indptr: NDArrayA, degrees: NDArrayA) -> NDArrayA:
def _didj_factors_csr(indices: NDArrayA, indptr: NDArrayA, degrees: NDArrayA) -> NDArrayA:
"""
Return an array F aligned with CSR nonzeros such that
F[k] = d[i] * d[j] for the k-th nonzero (i, j) in CSR order.

Parameters
----------
indices : array of int
CSR `indices` (column indices).
indptr : array of int
CSR `indptr` (row pointer).
degrees : array of float, shape (n,)
Diagonal scaling vector.

Returns
-------
array of float
Length equals len(indices). Entry-wise factors d_i * d_j.
"""

res = np.empty_like(indices, dtype=np.float64)
start = 0
for i in range(len(indptr) - 1):
Expand All @@ -450,17 +469,60 @@ def outer(indices: NDArrayA, indptr: NDArrayA, degrees: NDArrayA) -> NDArrayA:
return res


def csr_bilateral_diag_scale(A: spmatrix, degrees: NDArrayA) -> csr_matrix:
"""
Return D * A * D where D = diag(d) and A is CSR.

For every stored nonzero (i, j):
(D A D)_{ij} = d[i] * A_{ij} * d[j].

Parameters
----------
A : scipy.sparse.csr_matrix
Input sparse matrix. Converted to CSR if needed.
degrees : array_like, shape (n,)
Diagonal vector. len(degrees) must equal A.shape[0].

Returns
-------
scipy.sparse.csr_matrix
Matrix with same sparsity pattern as A.
"""
if A.shape[0] != len(degrees):
raise ValueError("len(d) must equal number of rows of A")
factors = _didj_factors_csr(A.indices, A.indptr, degrees)
F = csr_matrix((factors, A.indices, A.indptr), shape=A.shape)
B = A.multiply(F)
B.eliminate_zeros()
return B


def symmetric_normalize_csr(A: spmatrix) -> csr_matrix:
"""
Return D^{-1/2} * A * D^{-1/2}, where D = diag(degrees).

degrees[i] = sum_j A_{ij} (row degree).
Zeros in degree are left as-is to avoid division by zero.

Parameters
----------
A : scipy.sparse.csr_matrix

Returns
-------
scipy.sparse.csr_matrix
"""
degrees = np.squeeze(np.array(np.sqrt(1.0 / A.sum(axis=0))))
return csr_bilateral_diag_scale(A, degrees)


def _transform_a_spectral(a: spmatrix) -> spmatrix:
if not isspmatrix_csr(a):
a = a.tocsr()
if not a.nnz:
return a

degrees = np.squeeze(np.array(np.sqrt(1.0 / a.sum(axis=0))))
a = a.multiply(outer(a.indices, a.indptr, degrees))
a.eliminate_zeros()

return a
return symmetric_normalize_csr(a)


def _transform_a_cosine(a: spmatrix) -> spmatrix:
Expand Down
53 changes: 52 additions & 1 deletion tests/graph/test_spatial_neighbors.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ def _adata_concat(adata1, adata2):
# TODO: add edge cases
# TODO(giovp): test with reshuffling
@pytest.mark.parametrize(("n_rings", "n_neigh", "sum_dist"), [(1, 6, 0), (2, 18, 30), (3, 36, 84)])
def test_spatial_neighbors_visium(self, visium_adata: AnnData, n_rings: int, n_neigh: int, sum_dist: int):
def test_spatial_neighbors_visium(
self,
visium_adata: AnnData,
n_rings: int,
n_neigh: int,
sum_dist: int,
):
"""
check correctness of neighborhoods for visium coordinates
"""
Expand Down Expand Up @@ -357,3 +363,48 @@ def test_mask_graph(
negative_mask=True,
key_added=key_added,
)

def test_spatial_neighbors_transform_mathematical_properties(self, non_visium_adata: AnnData):
"""
Test mathematical properties of each transform.
"""
# Test spectral transform properties
spatial_neighbors(non_visium_adata, delaunay=True, coord_type=None, transform="spectral")
adj_spectral = non_visium_adata.obsp[Key.obsp.spatial_conn()].toarray()

# Spectral transform should be symmetric
np.testing.assert_allclose(adj_spectral, adj_spectral.T, atol=1e-10)

# Spectral transform should have normalized rows (L2 norm <= 1)
row_norms = np.sqrt(np.sum(adj_spectral**2, axis=1))
np.testing.assert_array_less(row_norms, 1.0 + 1e-10)

# Test cosine transform properties
spatial_neighbors(non_visium_adata, delaunay=True, coord_type=None, transform="cosine")
adj_cosine = non_visium_adata.obsp[Key.obsp.spatial_conn()].toarray()

# Cosine transform should be symmetric
np.testing.assert_allclose(adj_cosine, adj_cosine.T, atol=1e-10)

# Cosine transform should have values in [-1, 1]
np.testing.assert_array_less(-1.0 - 1e-10, adj_cosine)
np.testing.assert_array_less(adj_cosine, 1.0 + 1e-10)

# Diagonal of cosine transform should be 1 (self-similarity)
np.testing.assert_allclose(np.diag(adj_cosine), 1.0, atol=1e-10)

def test_spatial_neighbors_transform_edge_cases(self, non_visium_adata: AnnData):
"""
Test transforms with edge cases (empty graph, single node, etc.).
"""
# Test with a very small dataset
small_adata = non_visium_adata[:5].copy() # Only 5 points

# Test all transforms with small dataset
for transform in [None, "spectral", "cosine"]:
spatial_neighbors(small_adata, delaunay=True, coord_type=None, transform=transform)
assert Key.obsp.spatial_conn() in small_adata.obsp
assert Key.obsp.spatial_dist() in small_adata.obsp

# Verify transform parameter is saved
assert small_adata.uns[Key.uns.spatial_neighs()]["params"]["transform"] == transform
Loading