Skip to content

Commit 910dffd

Browse files
committed
update: loop construction and code reorganization
1 parent 8763123 commit 910dffd

4 files changed

Lines changed: 226 additions & 39 deletions

File tree

‎src/scloop/computing/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Lightweight compute helpers used by the public API layer."""
2+
# Copyright 2025 Zhiyuan Yu (Heemskerk's lab, University of Michigan)
3+
4+
from .homology import (
5+
compute_boundary_matrix_data,
6+
compute_persistence_diagram,
7+
compute_sparse_pairwise_distance,
8+
)

‎src/scloop/computing/homology.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright 2025 Zhiyuan Yu (Heemskerk's lab, University of Michigan)
2+
from __future__ import annotations
3+
4+
import numpy as np
5+
from anndata import AnnData
6+
from scipy.sparse import csr_matrix
7+
from sklearn.neighbors import radius_neighbors_graph
8+
9+
from ..data.metadata import ScloopMeta
10+
from ..data.ripser_lib import get_boundary_matrix, ripser
11+
from ..data.types import Diameter_t, IndexListDistMatrix
12+
from ..data.utils import encode_triangles_and_edges
13+
14+
15+
def compute_sparse_pairwise_distance(
16+
adata: AnnData,
17+
meta: ScloopMeta,
18+
bootstrap: bool = False,
19+
thresh: Diameter_t | None = None,
20+
**nei_kwargs,
21+
) -> tuple[csr_matrix, IndexListDistMatrix | None]:
22+
assert meta.preprocess is not None
23+
assert meta.preprocess.embedding_method is not None
24+
boot_idx = None
25+
if bootstrap:
26+
boot_idx = np.random.choice(
27+
adata.shape[0], size=adata.shape[0], replace=True
28+
).tolist()
29+
return (
30+
radius_neighbors_graph(
31+
X=adata.obsm[f"X_{meta.preprocess.embedding_method}"],
32+
radius=thresh,
33+
**nei_kwargs,
34+
),
35+
boot_idx,
36+
)
37+
38+
39+
def compute_persistence_diagram(
40+
adata: AnnData, meta: ScloopMeta, thresh: Diameter_t | None = None, **nei_kwargs
41+
) -> tuple[list[np.ndarray], IndexListDistMatrix | None, csr_matrix]:
42+
sparse_pairwise_distance_matrix, boot_idx = compute_sparse_pairwise_distance(
43+
adata=adata, meta=meta, bootstrap=False, thresh=thresh, **nei_kwargs
44+
)
45+
result = ripser(
46+
distance_matrix=sparse_pairwise_distance_matrix,
47+
modulus=2,
48+
dim_max=1,
49+
threshold=thresh,
50+
do_cocyles=True,
51+
)
52+
return result.births_and_deaths_by_dim, boot_idx, sparse_pairwise_distance_matrix
53+
54+
55+
def compute_boundary_matrix_data(
56+
adata: AnnData, meta: ScloopMeta, thresh: Diameter_t | None = None, **nei_kwargs
57+
):
58+
assert meta.preprocess is not None
59+
assert meta.preprocess.num_vertices is not None
60+
sparse_pairwise_distance_matrix, boot_idx = compute_sparse_pairwise_distance(
61+
adata=adata, meta=meta, bootstrap=False, thresh=thresh, **nei_kwargs
62+
)
63+
result = get_boundary_matrix(sparse_pairwise_distance_matrix, thresh)
64+
edge_ids, trig_ids = encode_triangles_and_edges(
65+
np.array(result.triangle_vertices), meta.preprocess.num_vertices
66+
)
67+
return result, edge_ids, trig_ids, boot_idx, sparse_pairwise_distance_matrix

‎src/scloop/data/containers.py‎

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
# Copyright 2025 Zhiyuan Yu (Heemskerk's lab, University of Michigan)
2-
from pydantic.dataclasses import dataclass
32
from abc import abstractmethod
3+
from pydantic.dataclasses import dataclass
44
from pydantic import ConfigDict, BaseModel, Field, field_validator, ValidationInfo
5-
from scipy.spatial import distance_matrix
65
from .metadata import ScloopMeta
76
from .analysis_containers import BootstrapAnalysis, HodgeAnalysis
87
from anndata import AnnData
9-
from sklearn.neighbors import radius_neighbors_graph
108
from scipy.sparse import csr_matrix
11-
from .ripser_lib import ripser, get_boundary_matrix
129
from .loop_reconstruction import reconstruct_n_loop_representatives
1310
from .types import IndexListDistMatrix, Diameter_t, Size_t, Index_t
14-
from .utils import decode_edges, decode_triangles, encode_triangles_and_edges
15-
from numba import jit
11+
from .utils import decode_edges, decode_triangles
1612
import numpy as np
13+
from ..computing.homology import (
14+
compute_sparse_pairwise_distance,
15+
compute_persistence_diagram,
16+
compute_boundary_matrix_data,
17+
)
1718

1819

1920
class BoundaryMatrix(BaseModel):
@@ -88,53 +89,35 @@ def _compute_sparse_pairwise_distance(
8889
thresh: Diameter_t | None = None,
8990
**nei_kwargs,
9091
) -> tuple[csr_matrix, IndexListDistMatrix | None]:
91-
assert self.meta.preprocess is not None
92-
assert self.meta.preprocess.embedding_method is not None
93-
boot_idx = None
94-
if bootstrap:
95-
boot_idx = np.random.choice(
96-
adata.shape[0], size=adata.shape[0], replace=True
97-
).tolist()
98-
return radius_neighbors_graph(
99-
X=adata.obsm[f"X_{self.meta.preprocess.embedding_method}"],
100-
radius=thresh,
92+
return compute_sparse_pairwise_distance(
93+
adata=adata,
94+
meta=self.meta,
95+
bootstrap=bootstrap,
96+
thresh=thresh,
10197
**nei_kwargs,
102-
), boot_idx
98+
)
10399

104100
def _compute_homology(
105101
self, adata: AnnData, thresh: Diameter_t | None = None, **nei_kwargs
106102
) -> None:
107-
sparse_pairwise_distance_matrix, _ = self._compute_sparse_pairwise_distance(
108-
adata=adata, bootstrap=False, thresh=thresh, **nei_kwargs
103+
persistence_diagram, _, _ = compute_persistence_diagram(
104+
adata=adata, meta=self.meta, thresh=thresh, **nei_kwargs
109105
)
110-
result = ripser(
111-
distance_matrix=sparse_pairwise_distance_matrix,
112-
modulus=2,
113-
dim_max=1,
114-
threshold=thresh,
115-
do_cocyles=True,
116-
)
117-
self.persistence_diagram = result.births_and_deaths_by_dim
106+
self.persistence_diagram = persistence_diagram
118107

119108
def _compute_boundary_matrix(
120109
self, adata: AnnData, thresh: Diameter_t | None = None, **nei_kwargs
121110
) -> None:
122-
assert self.meta.preprocess
123-
assert self.meta.preprocess.num_vertices
124-
sparse_pairwise_distance_matrix, _ = self._compute_sparse_pairwise_distance(
125-
adata=adata, bootstrap=False, thresh=thresh, **nei_kwargs
126-
)
127-
result = get_boundary_matrix(sparse_pairwise_distance_matrix, thresh)
128-
edge_ids, trig_ids = encode_triangles_and_edges(
129-
np.array(result.triangle_vertices), self.meta.preprocess.num_vertices
111+
result, *_ = compute_boundary_matrix_data(
112+
adata=adata, meta=self.meta, thresh=thresh, **nei_kwargs
130113
)
131114
self.boundary_matrix_d1 = BoundaryMatrixD1(
132115
num_vertices=self.meta.preprocess.num_vertices,
133116
data=([], []),
134117
shape=(0, 0),
135118
row_simplex_ids=[],
136119
col_simplex_ids=[],
137-
col_simplex_diams=result.traingle_diameters,
120+
col_simplex_diams=result.triangle_diameters,
138121
)
139122

140123
def _compute_loop_representatives(self):

‎src/scloop/data/loop_reconstruction.py‎

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# Copyright 2025 Zhiyuan Yu (Heemskerk's lab, University of Michigan)
2-
import numpy as np
2+
from __future__ import annotations
3+
4+
import math
5+
from typing import Iterable, List, Sequence, Tuple
6+
37
import igraph as ig
4-
from typing import List, Tuple
8+
import numpy as np
59

610

711
def reconstruct_n_loop_representatives(
@@ -18,4 +22,129 @@ def reconstruct_n_loop_representatives(
1822
loop_upper_pct: float = 95,
1923
n_max_cocycles: int = 10,
2024
) -> Tuple[List[List[int]], List[float]]:
21-
return ([], [])
25+
"""
26+
Reconstruct diverse loop representatives using Yen-style deviation rounds.
27+
"""
28+
if n <= 0 or len(edges) == 0:
29+
return [], []
30+
31+
filt_t = loop_birth + (loop_death - loop_birth) * life_pct
32+
33+
# Parse cocycle edges (each entry is [[i, j], coeff])
34+
cocycle_edges: list[tuple[int, int]] = []
35+
for simplex in cocycles_dim1:
36+
try:
37+
verts, coeff = simplex
38+
except ValueError:
39+
continue
40+
if coeff == 0 or len(verts) != 2:
41+
continue
42+
cocycle_edges.append((int(verts[0]), int(verts[1])))
43+
if len(cocycle_edges) == n_max_cocycles:
44+
break
45+
46+
edge_births = np.asarray(edge_births)
47+
mask = edge_births <= filt_t
48+
if not np.any(mask):
49+
return [], []
50+
edges_filt = [e for e, keep in zip(edges, mask) if keep]
51+
weights_filt = edge_births[mask].tolist()
52+
53+
sources = [e[0] for e in edges_filt] + [e[0] for e in cocycle_edges]
54+
destinations = [e[1] for e in edges_filt] + [e[1] for e in cocycle_edges]
55+
weights = weights_filt + [math.inf] * len(cocycle_edges)
56+
if len(sources) == 0:
57+
return [], []
58+
59+
n_vertices = max(max(sources), max(destinations)) + 1
60+
g = ig.Graph(n=n_vertices, edges=list(zip(sources, destinations)), directed=False)
61+
g.es["weight"] = weights
62+
63+
cycles_pool: list[list[int]] = []
64+
cycles_dist: list[float] = []
65+
66+
for _ in range(n_force_deviate):
67+
paths_this_round: list[list[int]] = []
68+
for i, j in cocycle_edges:
69+
paths = _k_shortest_paths(g, i, j, n_reps_per_loop)
70+
if not paths:
71+
continue
72+
for path in paths:
73+
dist = _path_weight(g, path)
74+
cycles_pool.append(path)
75+
paths_this_round.append(path)
76+
cycles_dist.append(dist)
77+
78+
# Force deviation
79+
for path in paths_this_round:
80+
for u, v in zip(path[:-1], path[1:]):
81+
try:
82+
eid = g.get_eid(u, v, directed=False)
83+
except ig._igraph.InternalError:
84+
continue
85+
g.es[eid]["weight"] = math.inf
86+
87+
return _select_diverse_loops(
88+
cycles=cycles_pool,
89+
distances=cycles_dist,
90+
n=n,
91+
lower_pct=loop_lower_pct,
92+
upper_pct=loop_upper_pct,
93+
)
94+
95+
96+
def _k_shortest_paths(g: ig.Graph, source: int, target: int, k: int) -> list[list[int]]:
97+
if source == target:
98+
return []
99+
try:
100+
return g.get_k_shortest_paths(
101+
source, target, k=k, weights=g.es["weight"], mode="ALL"
102+
)
103+
except ig._igraph.InternalError:
104+
return []
105+
106+
107+
def _path_weight(g: ig.Graph, path: Sequence[int]) -> float:
108+
if len(path) < 2:
109+
return math.inf
110+
weight = 0.0
111+
for u, v in zip(path[:-1], path[1:]):
112+
try:
113+
eid = g.get_eid(u, v, directed=False)
114+
except ig._igraph.InternalError:
115+
return math.inf
116+
w = g.es[eid]["weight"]
117+
weight += float(w)
118+
return weight
119+
120+
121+
def _select_diverse_loops(
122+
cycles: Iterable[Sequence[int]],
123+
distances: Iterable[float],
124+
n: int,
125+
lower_pct: float,
126+
upper_pct: float,
127+
) -> Tuple[List[List[int]], List[float]]:
128+
pairs = sorted(
129+
[(float(d), list(c)) for d, c in zip(distances, cycles) if math.isfinite(d)],
130+
key=lambda x: x[0],
131+
)
132+
if not pairs:
133+
return [], []
134+
135+
n_total = len(pairs)
136+
n_return = min(n_total, n)
137+
if n_return == 1:
138+
idxs = [n_total // 2]
139+
else:
140+
step = (upper_pct - lower_pct) / (n_return - 1)
141+
idxs = []
142+
for i in range(n_return):
143+
pct = (lower_pct + step * i) / 100
144+
idx = min(int(math.floor(n_total * pct)), n_total - 1)
145+
idxs.append(idx)
146+
147+
selected = [pairs[i] for i in idxs]
148+
dists = [p[0] for p in selected]
149+
loops = [p[1] for p in selected]
150+
return loops, dists

0 commit comments

Comments
 (0)