spsheaf 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
spsheaf/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """spSheaf: sheaf-theoretic spatial domain inference from raw molecules.
2
+
3
+ The method is eight function calls (see ``examples/quickstart.py``):
4
+ ``fibers.build_kernel_fibers`` and ``fibers.normalize_fibers`` put
5
+ kernel-smoothed expression fibers on a grid; ``graph.build_spatial_graph``
6
+ links the grid; ``sheaf.compute_stalks`` fits a local principal subspace at
7
+ every vertex; ``sheaf.compute_restriction_maps`` aligns neighbouring stalks;
8
+ ``sheaf.compute_boundary_scores`` scores every edge by the residual
9
+ disagreement of the aligned coordinates and smooths it into a field;
10
+ ``clustering.boundary_aware_clustering`` partitions the grid with that field
11
+ attenuating the transitions; ``clustering.spatial_cleanup`` removes
12
+ fragments. ``pipeline.run_single_slice`` wraps the eight steps and the
13
+ evaluation against an annotation.
14
+ """
15
+ __version__ = "0.2.0"
16
+ __all__ = ["fibers", "graph", "sheaf", "clustering", "evaluation", "pipeline", "io", "paths"]
@@ -0,0 +1,142 @@
1
+ """Boundary-detection metrics against a ground-truth partition on a graph/grid.
2
+
3
+ Answers reviewer memo point 3: the manuscript is framed around a
4
+ boundary-detection principle but evaluates almost entirely through downstream
5
+ clustering ARI/NMI, which does not test that principle directly.
6
+
7
+ Ground-truth boundary points are grid vertices with at least one graph
8
+ neighbour in a different annotated domain. A candidate scalar field is then
9
+ scored as a soft boundary detector against that set, BSDS-style: a spatial
10
+ tolerance is allowed on both sides of the match, because a boundary predicted
11
+ one grid step away from the annotation is not a false positive in any
12
+ meaningful sense.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ from scipy.spatial import cKDTree
18
+
19
+
20
+ def ground_truth_boundary(gt_int, edges, valid=None):
21
+ """Vertices with a graph neighbour in a different domain."""
22
+ gt_int = np.asarray(gt_int)
23
+ n = len(gt_int)
24
+ if valid is None:
25
+ valid = np.ones(n, bool)
26
+ valid = np.asarray(valid, bool)
27
+ b = np.zeros(n, bool)
28
+ for u, v in edges:
29
+ if valid[u] and valid[v] and gt_int[u] != gt_int[v]:
30
+ b[u] = b[v] = True
31
+ return b
32
+
33
+
34
+ def _match(pred_xy, gt_xy, tol):
35
+ """(precision_hits, recall_hits) under a symmetric distance tolerance."""
36
+ if len(pred_xy) == 0 or len(gt_xy) == 0:
37
+ return 0, 0
38
+ d_p = cKDTree(gt_xy).query(pred_xy)[0] # each prediction -> nearest GT
39
+ d_g = cKDTree(pred_xy).query(gt_xy)[0] # each GT -> nearest prediction
40
+ return int((d_p <= tol).sum()), int((d_g <= tol).sum())
41
+
42
+
43
+ def pr_curve(score, coords, gt_bnd, valid=None, tol=0.0, n_thresh=60):
44
+ """Precision/recall of `score` as a boundary detector, at tolerance `tol`.
45
+
46
+ The sweep is over the *fraction of vertices selected*, not over score
47
+ quantiles. This matters: the candidate fields being compared (sheaf
48
+ disagreement, expression gradient, neighbour distance, principal angle)
49
+ have very differently shaped score distributions, and a fixed grid of
50
+ score quantiles would sample the precision-recall trade-off unevenly
51
+ between them -- penalising the more skewed field for its shape rather
52
+ than its accuracy.
53
+ """
54
+ score = np.asarray(score, float)
55
+ coords = np.asarray(coords, float)
56
+ if valid is None:
57
+ valid = np.ones(len(score), bool)
58
+ valid = np.asarray(valid, bool)
59
+
60
+ s, xy, g = score[valid], coords[valid], np.asarray(gt_bnd, bool)[valid]
61
+ gt_xy = xy[g]
62
+ if g.sum() == 0:
63
+ raise ValueError("no ground-truth boundary vertices")
64
+
65
+ order = np.argsort(-s)
66
+ n = len(s)
67
+ fracs = np.unique(np.geomspace(max(1.0 / n, 1e-4), 1.0, n_thresh))
68
+ prec, rec = [], []
69
+ for f in fracs:
70
+ k = max(int(round(f * n)), 1)
71
+ sel = order[:k]
72
+ tp_p, tp_g = _match(xy[sel], gt_xy, tol)
73
+ prec.append(tp_p / k)
74
+ rec.append(tp_g / len(gt_xy))
75
+ return np.array(prec), np.array(rec)
76
+
77
+
78
+ def average_precision(prec, rec):
79
+ """Average precision: sum of precision weighted by the recall increment.
80
+
81
+ Standard AP, not a trapezoid normalised by the observed recall range --
82
+ that normalisation degenerates for smooth fields, whose recall barely
83
+ varies across thresholds.
84
+ """
85
+ o = np.argsort(rec)
86
+ r, p = rec[o], prec[o]
87
+ if len(r) == 0:
88
+ return float("nan")
89
+ r = np.concatenate([[0.0], r])
90
+ p = np.concatenate([[p[0]], p])
91
+ return float(np.sum(np.diff(r) * p[1:]))
92
+
93
+
94
+ def best_f1(prec, rec):
95
+ f = 2 * prec * rec / np.maximum(prec + rec, 1e-12)
96
+ i = int(np.argmax(f))
97
+ return float(f[i]), float(prec[i]), float(rec[i])
98
+
99
+
100
+ def boundary_distances(score, coords, gt_bnd, valid=None, frac=None):
101
+ """Symmetric distances between the predicted and true boundary sets.
102
+
103
+ The predicted set is the top `frac` of vertices by score; `frac` defaults
104
+ to the true boundary prevalence, so every field is compared at matched
105
+ budget rather than at a threshold that happens to favour one of them.
106
+ """
107
+ score = np.asarray(score, float)
108
+ coords = np.asarray(coords, float)
109
+ if valid is None:
110
+ valid = np.ones(len(score), bool)
111
+ valid = np.asarray(valid, bool)
112
+ s, xy, g = score[valid], coords[valid], np.asarray(gt_bnd, bool)[valid]
113
+ if frac is None:
114
+ frac = g.mean()
115
+ k = max(int(round(frac * len(s))), 1)
116
+ sel = np.zeros(len(s), bool)
117
+ sel[np.argsort(-s)[:k]] = True
118
+
119
+ gt_xy, pr_xy = xy[g], xy[sel]
120
+ d_p = cKDTree(gt_xy).query(pr_xy)[0]
121
+ d_g = cKDTree(pr_xy).query(gt_xy)[0]
122
+ return {
123
+ "mean_pred_to_gt": float(d_p.mean()),
124
+ "mean_gt_to_pred": float(d_g.mean()),
125
+ "hausdorff_95": float(max(np.percentile(d_p, 95), np.percentile(d_g, 95))),
126
+ "hausdorff": float(max(d_p.max(), d_g.max())),
127
+ "frac_used": float(frac),
128
+ }
129
+
130
+
131
+ def evaluate_field(score, coords, gt_bnd, valid=None, tols=(0.0, 15.0, 30.0)):
132
+ """Full report for one candidate boundary field."""
133
+ out = {}
134
+ for tol in tols:
135
+ p, r = pr_curve(score, coords, gt_bnd, valid, tol=tol)
136
+ f1, pi, ri = best_f1(p, r)
137
+ out[f"AP@{tol:g}"] = average_precision(p, r)
138
+ out[f"F1@{tol:g}"] = f1
139
+ out[f"P@{tol:g}"] = pi
140
+ out[f"R@{tol:g}"] = ri
141
+ out.update(boundary_distances(score, coords, gt_bnd, valid))
142
+ return out
spsheaf/clustering.py ADDED
@@ -0,0 +1,332 @@
1
+ """
2
+ Clustering methods for spatial domain inference.
3
+
4
+ Provides:
5
+
6
+ 1. **Boundary-aware clustering** — The sheaf boundary field is used
7
+ to attenuate ambiguous boundary points via exponential weighting.
8
+ Supports an over-cluster-then-merge strategy to preserve small
9
+ domains (e.g. PVT) that standard k-means tends to absorb into
10
+ larger neighbors.
11
+
12
+ 2. **Over-cluster-then-merge** — Run k-means with more clusters than
13
+ needed (e.g. 2×), then merge the most expression-similar clusters
14
+ via agglomerative clustering on their expression centroids. This
15
+ gives small but expression-distinct domains their own initial
16
+ cluster, which survives the merge because its centroid is far
17
+ from all others.
18
+
19
+ 3. **Spatial connectivity cleanup** — After clustering, small
20
+ disconnected fragments are detected via BFS on the spatial graph
21
+ and reassigned to the label of their nearest large-component
22
+ neighbor.
23
+ """
24
+
25
+ import numpy as np
26
+ from collections import deque
27
+ from scipy.spatial import cKDTree
28
+ from sklearn.decomposition import PCA
29
+ from sklearn.preprocessing import StandardScaler
30
+ from sklearn.cluster import KMeans, AgglomerativeClustering
31
+
32
+
33
+ def boundary_aware_clustering(fibers_norm, grid_coords, boundary_smooth,
34
+ edges, n_clusters=8, spatial_weight=0.5,
35
+ boundary_alpha=0.5, n_pca=10, n_init=20,
36
+ overcluster_factor=1.0,
37
+ random_state=42, verbose=True):
38
+ """
39
+ Cluster with boundary-aware exponential weighting.
40
+
41
+ When ``overcluster_factor > 1``, k-means is run with
42
+ ``n_clusters * overcluster_factor`` clusters, then fine clusters
43
+ are merged back to ``n_clusters`` using adjacency-constrained
44
+ agglomerative clustering on expression centroids. Only spatially
45
+ neighboring clusters can merge, so sub-clusters of the same domain
46
+ reunify first, while small expression-distinct domains survive.
47
+
48
+ Steps:
49
+ 1. PCA embedding of normalized fibers
50
+ 2. Exponential interior weight: w = exp(-α · b / median(b))
51
+ 3. Weight expression features row-wise
52
+ 4. Concatenate with scaled spatial coordinates
53
+ 5. K-means with k_fine = n_clusters * overcluster_factor
54
+ 6. Adjacency-constrained merge → n_clusters
55
+
56
+ Parameters
57
+ ----------
58
+ fibers_norm : ndarray, shape (N, G)
59
+ Normalized expression fibers.
60
+ grid_coords : ndarray, shape (N, 2)
61
+ Spatial positions.
62
+ boundary_smooth : ndarray, shape (N,)
63
+ Smoothed boundary scores.
64
+ edges : list of (int, int)
65
+ Spatial graph edges (used for adjacency-constrained merge).
66
+ n_clusters : int
67
+ Final number of output domains.
68
+ spatial_weight : float
69
+ Weight for spatial coordinates in clustering input.
70
+ boundary_alpha : float
71
+ Exponential weighting strength. At median boundary score,
72
+ w ≈ 0.607; at twice median, w ≈ 0.368.
73
+ n_pca : int
74
+ Number of PCA components.
75
+ n_init : int
76
+ Number of k-means random initializations.
77
+ overcluster_factor : float
78
+ Over-clustering factor. Set to 1.0 to disable. Default 2.0
79
+ runs k-means with 2× clusters then merges back.
80
+ random_state : int
81
+ Random seed for reproducibility.
82
+ verbose : bool
83
+ Print cluster sizes.
84
+
85
+ Returns
86
+ -------
87
+ labels : ndarray, shape (N,)
88
+ Predicted domain labels (0-indexed).
89
+ """
90
+ # PCA embedding
91
+ pca = PCA(n_components=n_pca).fit(fibers_norm)
92
+ features = pca.transform(fibers_norm)
93
+
94
+ # Exponential boundary weighting
95
+ med_b = np.median(boundary_smooth) + 1e-10
96
+ interior_weight = np.exp(-boundary_alpha * boundary_smooth / med_b)
97
+
98
+ # Weight and scale
99
+ features_scaled = StandardScaler().fit_transform(features)
100
+ features_weighted = features_scaled * interior_weight[:, None]
101
+
102
+ spatial_scaled = StandardScaler().fit_transform(grid_coords)
103
+ cluster_input = np.hstack([
104
+ features_weighted,
105
+ spatial_scaled * spatial_weight,
106
+ ])
107
+
108
+ # K-means (possibly over-clustered)
109
+ k_fine = int(round(n_clusters * max(overcluster_factor, 1.0)))
110
+ km = KMeans(
111
+ n_clusters=k_fine, n_init=n_init, random_state=random_state,
112
+ )
113
+ labels_fine = km.fit_predict(cluster_input)
114
+
115
+ # Merge if over-clustered
116
+ if k_fine > n_clusters:
117
+ labels = merge_fine_clusters(
118
+ labels_fine, features, edges, n_clusters, verbose=verbose,
119
+ )
120
+ else:
121
+ labels = labels_fine
122
+
123
+ if verbose:
124
+ sizes = sorted(
125
+ [int((labels == c).sum()) for c in range(n_clusters)],
126
+ reverse=True,
127
+ )
128
+ print(f" Clusters ({n_clusters}): {sizes}")
129
+
130
+ return labels
131
+
132
+
133
+ def merge_fine_clusters(labels_fine, features, edges, n_clusters,
134
+ verbose=True):
135
+ """
136
+ Merge fine clusters down to n_clusters using adjacency-constrained
137
+ agglomerative clustering on expression centroids.
138
+
139
+ Only spatially neighboring clusters (sharing at least one edge in
140
+ the spatial graph) can be merged. This ensures that sub-clusters
141
+ of the same domain reunify first, while preventing merges between
142
+ expression-similar but spatially distant clusters.
143
+
144
+ Small expression-distinct domains (e.g. PVT) survive because their
145
+ centroids are far from their spatial neighbors.
146
+
147
+ Parameters
148
+ ----------
149
+ labels_fine : ndarray, shape (N,)
150
+ Fine cluster labels from over-clustered k-means.
151
+ features : ndarray, shape (N, d)
152
+ PCA expression features (unweighted — the raw expression
153
+ signal is used for merging decisions).
154
+ edges : list of (int, int)
155
+ Spatial graph edges.
156
+ n_clusters : int
157
+ Target number of merged clusters.
158
+ verbose : bool
159
+ Print merge summary.
160
+
161
+ Returns
162
+ -------
163
+ labels_merged : ndarray, shape (N,)
164
+ Merged cluster labels (0-indexed, contiguous).
165
+ """
166
+ from scipy.sparse import lil_matrix
167
+
168
+ k_fine = len(set(labels_fine))
169
+
170
+ # Compute expression centroid for each fine cluster
171
+ centroids = np.zeros((k_fine, features.shape[1]))
172
+ for c in range(k_fine):
173
+ mask = labels_fine == c
174
+ if mask.sum() > 0:
175
+ centroids[c] = features[mask].mean(axis=0)
176
+
177
+ # Build cluster-level adjacency: two fine clusters are adjacent
178
+ # if any spatial edge connects a point in one to a point in the other
179
+ adjacency = lil_matrix((k_fine, k_fine), dtype=np.int8)
180
+ for u, v in edges:
181
+ lu, lv = labels_fine[u], labels_fine[v]
182
+ if lu != lv:
183
+ adjacency[lu, lv] = 1
184
+ adjacency[lv, lu] = 1
185
+ connectivity = adjacency.tocsr()
186
+
187
+ # Adjacency-constrained agglomerative clustering on centroids
188
+ agg = AgglomerativeClustering(
189
+ n_clusters=n_clusters, linkage="ward",
190
+ connectivity=connectivity,
191
+ )
192
+ merge_labels = agg.fit_predict(centroids)
193
+
194
+ # Map fine → merged
195
+ fine_to_merged = {c: merge_labels[c] for c in range(k_fine)}
196
+ labels_merged = np.array([fine_to_merged[l] for l in labels_fine])
197
+
198
+ if verbose:
199
+ print(
200
+ f" Merged {k_fine} fine clusters → {n_clusters} domains "
201
+ f"(adjacency-constrained)"
202
+ )
203
+
204
+ return labels_merged
205
+
206
+
207
+ def spatial_cleanup(labels, edges, grid_coords=None,
208
+ min_fragment_frac=0.01, max_iterations=5, verbose=True):
209
+ """
210
+ Remove small disconnected fragments from cluster assignments.
211
+
212
+ After k-means, some clusters may have spatially disconnected fragments
213
+ (small groups of points assigned to a cluster far from its main body).
214
+ This function finds connected components of each cluster on the spatial
215
+ graph and reassigns small fragments to the label of their nearest
216
+ large-component neighbor.
217
+
218
+ Parameters
219
+ ----------
220
+ labels : ndarray, shape (N,)
221
+ Cluster assignments from k-means.
222
+ edges : list of (int, int)
223
+ Spatial graph edges.
224
+ grid_coords : ndarray, shape (N, 2) or None
225
+ Spatial positions (used as fallback for unresolved points).
226
+ min_fragment_frac : float
227
+ Components smaller than this fraction of total points are
228
+ considered fragments. Default: 1%.
229
+ max_iterations : int
230
+ Maximum number of cleanup passes.
231
+ verbose : bool
232
+ Print reassignment statistics.
233
+
234
+ Returns
235
+ -------
236
+ labels_clean : ndarray, shape (N,)
237
+ Cleaned cluster assignments.
238
+ n_reassigned : int
239
+ Total number of points reassigned.
240
+ """
241
+ N = len(labels)
242
+ min_size = max(3, int(N * min_fragment_frac))
243
+ labels_clean = labels.copy()
244
+
245
+ # Build adjacency list from edges
246
+ adj = [[] for _ in range(N)]
247
+ for u, v in edges:
248
+ adj[u].append(v)
249
+ adj[v].append(u)
250
+
251
+ total_reassigned = 0
252
+
253
+ for iteration in range(max_iterations):
254
+ n_reassigned = 0
255
+ unique_labels = sorted(set(labels_clean))
256
+
257
+ # Find connected components for each cluster
258
+ fragment_points = []
259
+
260
+ for lab in unique_labels:
261
+ members = set(np.where(labels_clean == lab)[0])
262
+ if len(members) == 0:
263
+ continue
264
+
265
+ # BFS to find connected components within this cluster
266
+ visited = set()
267
+ components = []
268
+
269
+ for start in members:
270
+ if start in visited:
271
+ continue
272
+ component = []
273
+ queue = deque([start])
274
+ visited.add(start)
275
+ while queue:
276
+ node = queue.popleft()
277
+ component.append(node)
278
+ for nb in adj[node]:
279
+ if nb not in visited and nb in members:
280
+ visited.add(nb)
281
+ queue.append(nb)
282
+ components.append(component)
283
+
284
+ if len(components) <= 1:
285
+ continue
286
+
287
+ # Keep the largest component, mark others as fragments
288
+ components.sort(key=len, reverse=True)
289
+ for comp in components[1:]:
290
+ if len(comp) < min_size:
291
+ fragment_points.extend(comp)
292
+
293
+ if not fragment_points:
294
+ break
295
+
296
+ # Reassign fragment points to nearest non-fragment neighbor
297
+ fragment_set = set(fragment_points)
298
+ unresolved = []
299
+
300
+ for pt in fragment_points:
301
+ reassigned = False
302
+ for nb in adj[pt]:
303
+ if nb not in fragment_set:
304
+ labels_clean[pt] = labels_clean[nb]
305
+ n_reassigned += 1
306
+ reassigned = True
307
+ break
308
+ if not reassigned:
309
+ unresolved.append(pt)
310
+
311
+ # Fallback: nearest non-fragment point by spatial distance
312
+ if unresolved and grid_coords is not None:
313
+ non_frag = np.array([i for i in range(N) if i not in fragment_set])
314
+ if len(non_frag) > 0:
315
+ tree = cKDTree(grid_coords[non_frag])
316
+ for pt in unresolved:
317
+ _, nearest = tree.query(grid_coords[pt])
318
+ labels_clean[pt] = labels_clean[non_frag[nearest]]
319
+ n_reassigned += 1
320
+
321
+ total_reassigned += n_reassigned
322
+
323
+ if n_reassigned == 0:
324
+ break
325
+
326
+ if verbose and total_reassigned > 0:
327
+ print(
328
+ f" Cleanup: {total_reassigned} points reassigned "
329
+ f"({total_reassigned / N:.1%})"
330
+ )
331
+
332
+ return labels_clean, total_reassigned