Skip to content

Commit 699aefc

Browse files
committed
coherence
1 parent f827187 commit 699aefc

7 files changed

Lines changed: 126 additions & 34 deletions

File tree

‎src/scloop/analyzing/bootstrap.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ def run_single_bootstrap(
201201
extra_diameter_homology_equivalence: float = DEFAULT_EXTRA_DIAM_EQUIVALENCE,
202202
filter_column_homology_equivalence: bool = True,
203203
column_trim_method: ColumnTrimMethod = DEFAULT_COLUMN_TRIM_METHOD,
204+
death_scale: float | None = None,
204205
n_neighbors_column_trim: int = DEFAULT_N_NEIGHBORS_COLUMN_TRIM,
205206
full_pairwise_distance_matrix: csr_matrix | None = None,
206207
full_vertex_ids: list[int] | None = None,
@@ -453,6 +454,8 @@ def run_single_bootstrap(
453454
max_column_diameter=max_column_diameter,
454455
cocycle_edge_mask=cocycle_edge_masks[source_idx],
455456
column_trim_method=column_trim_method,
457+
embedding=embedding,
458+
death_scale=death_scale,
456459
column_scores=(
457460
source_loop.column_proximity_scores(
458461
original_boundary_matrix_d1,
@@ -496,6 +499,10 @@ def run_bootstrap_pipeline(
496499
**kwargs,
497500
) -> list[BootstrapResult]:
498501
results: list[BootstrapResult] = []
502+
_original_deaths = [lc.death for lc in original_loop_classes if lc is not None]
503+
global_death_scale = (
504+
max(_original_deaths) if _original_deaths else meta.bootstrap.threshold_homology
505+
)
499506

500507
full_pairwise_distance_matrix: csr_matrix | None = None
501508
full_vertex_ids: list[int] | None = None
@@ -512,9 +519,7 @@ def run_bootstrap_pipeline(
512519
if kwargs.get("column_trim_method", DEFAULT_COLUMN_TRIM_METHOD) == "loop_proximity":
513520
assert meta.preprocess is not None
514521
assert meta.preprocess.embedding_method is not None
515-
warm_embedding = np.array(
516-
adata.obsm[f"X_{meta.preprocess.embedding_method}"]
517-
)
522+
warm_embedding = np.array(adata.obsm[f"X_{meta.preprocess.embedding_method}"])
518523
for loop_class in original_loop_classes:
519524
if loop_class is not None and loop_class.representatives is not None:
520525
loop_class.column_proximity_scores(
@@ -537,6 +542,7 @@ def run_bootstrap_pipeline(
537542
meta=meta,
538543
original_loop_classes=original_loop_classes,
539544
original_boundary_matrix_d1=original_boundary_matrix_d1,
545+
death_scale=global_death_scale,
540546
full_pairwise_distance_matrix=full_pairwise_distance_matrix,
541547
full_vertex_ids=full_vertex_ids,
542548
reconstruct_on_full_data=reconstruct_on_full_data,

‎src/scloop/computing/coherence.py‎

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,19 @@
33

44
from typing import Mapping, Sequence
55

6+
import numpy as np
7+
68
from ..data.types import HomotopyCoherenceMethod
79
from ..data.utils import edge_idx_decode
10+
from ..utils.distance_metrics import compute_loop_frechet
11+
12+
13+
def global_h1_death_scale(persistence_diagram: list | None) -> float | None:
14+
if not persistence_diagram or len(persistence_diagram) < 2:
15+
return None
16+
deaths = np.asarray(persistence_diagram[1][1], dtype=float)
17+
finite = deaths[np.isfinite(deaths)]
18+
return float(finite.max()) if finite.size else None
819

920

1021
def _flip_triangle(
@@ -33,25 +44,65 @@ def _flip_triangle(
3344
return cycle ^ triangle
3445

3546

36-
def _perimeter(cycle: frozenset[int], edge_lengths: Mapping[int, float]) -> float:
37-
return sum(edge_lengths[edge] for edge in cycle)
47+
def _cycle_vertices(cycle: frozenset[int], num_vertices: int) -> list[int]:
48+
adjacency: dict[int, list[int]] = {}
49+
for edge in cycle:
50+
tail, head = edge_idx_decode(edge, num_vertices)
51+
adjacency.setdefault(tail, []).append(head)
52+
adjacency.setdefault(head, []).append(tail)
53+
start = next(iter(adjacency))
54+
order = [start]
55+
previous, current = -1, start
56+
while len(order) <= len(adjacency):
57+
neighbours = adjacency[current]
58+
if len(neighbours) != 2:
59+
break
60+
first, second = neighbours
61+
following = first if first != previous else second
62+
if following == start:
63+
break
64+
order.append(following)
65+
previous, current = current, following
66+
return order
67+
68+
69+
def _cycle_coords(
70+
cycle: frozenset[int], num_vertices: int, embedding: np.ndarray
71+
) -> np.ndarray:
72+
vertices = _cycle_vertices(cycle, num_vertices)
73+
return np.ascontiguousarray(embedding[vertices], dtype=np.float64)
74+
75+
76+
def _frechet_to_target(
77+
cycle: frozenset[int],
78+
target_coords: np.ndarray,
79+
num_vertices: int,
80+
embedding: np.ndarray,
81+
) -> float:
82+
cycle_coords = _cycle_coords(cycle, num_vertices, embedding)
83+
return float(
84+
min(
85+
compute_loop_frechet(cycle_coords, target_coords),
86+
compute_loop_frechet(target_coords, cycle_coords),
87+
)
88+
)
3889

3990

4091
def _greedy_reversal(
4192
source: frozenset[int],
4293
target: frozenset[int],
4394
triangles: tuple[frozenset[int], ...],
44-
edge_lengths: Mapping[int, float],
4595
num_vertices: int,
96+
embedding: np.ndarray,
97+
death_scale: float | None = None,
4698
) -> float | None:
47-
"""Follow one valid triangle order greedily by target perimeter distance."""
48-
49-
target_length = _perimeter(target, edge_lengths)
50-
source_length = _perimeter(source, edge_lengths)
99+
target_coords = _cycle_coords(target, num_vertices, embedding)
100+
source_coords = _cycle_coords(source, num_vertices, embedding)
51101
cycle = source
52-
length = source_length
53102
reversal = 0.0
54103
total_step_abs = 0.0
104+
max_step_abs = 0.0
105+
n_steps = 0
55106
remaining = set(range(len(triangles)))
56107

57108
while remaining:
@@ -63,47 +114,59 @@ def _greedy_reversal(
63114
if next_cycle is None:
64115
continue
65116

66-
current_remaining_cost = abs(target_length - length)
67-
next_length = _perimeter(next_cycle, edge_lengths)
68-
next_remaining_cost = abs(target_length - next_length)
69-
step_reversal = (
70-
max(
71-
next_remaining_cost - current_remaining_cost,
72-
0.0,
73-
)
74-
/ length
117+
current_remaining_cost = _frechet_to_target(
118+
cycle, target_coords, num_vertices, embedding
119+
)
120+
next_remaining_cost = _frechet_to_target(
121+
next_cycle, target_coords, num_vertices, embedding
122+
)
123+
current_departed_cost = _frechet_to_target(
124+
cycle, source_coords, num_vertices, embedding
125+
)
126+
next_departed_cost = _frechet_to_target(
127+
next_cycle, source_coords, num_vertices, embedding
128+
)
129+
step_reversal = max(
130+
next_remaining_cost - current_remaining_cost,
131+
0.0,
132+
)
133+
step_abs = abs(next_remaining_cost - current_remaining_cost) + abs(
134+
next_departed_cost - current_departed_cost
75135
)
76-
step_abs = abs(next_length - length) / length
77136
key = (next_remaining_cost, triangle_index)
78137
if best_key is None or key < best_key:
79138
best_key = key
80139
best_step = (
81140
triangle_index,
82141
next_cycle,
83-
next_length,
84142
step_reversal,
85143
step_abs,
86144
)
87145

88146
if best_step is None:
89147
return None
90148

91-
triangle_index, cycle, length, step_reversal, step_abs = best_step
149+
triangle_index, cycle, step_reversal, step_abs = best_step
92150
remaining.remove(triangle_index)
93151
reversal += step_reversal
94152
total_step_abs += step_abs
153+
max_step_abs = max(max_step_abs, step_abs)
154+
n_steps += 1
95155

96-
return 1 - reversal / total_step_abs if cycle == target else None
156+
if cycle != target:
157+
return None
158+
# multiply by 2 due to two way frechet
159+
return 1 - min(max_step_abs / (2 * death_scale), 1)
97160

98161

99162
def path_finding_coherence(
100163
source_edges: Sequence[int],
101164
target_edges: Sequence[int],
102165
triangles: Sequence[Sequence[int]],
103-
edge_lengths: Mapping[int, float],
104166
num_vertices: int,
167+
embedding: np.ndarray,
168+
death_scale: float | None = None,
105169
) -> float | None:
106-
"""Return coherence from a greedy valid triangle order."""
107170

108171
source = frozenset(source_edges)
109172
target = frozenset(target_edges)
@@ -112,35 +175,40 @@ def path_finding_coherence(
112175
source,
113176
target,
114177
triangle_sets,
115-
edge_lengths,
116178
num_vertices,
179+
embedding,
180+
death_scale,
117181
)
182+
118183
reverse = _greedy_reversal(
119184
target,
120185
source,
121186
triangle_sets,
122-
edge_lengths,
123187
num_vertices,
188+
embedding,
189+
death_scale,
124190
)
125191

126192
candidates = [cost for cost in (forward, reverse) if cost is not None]
127193
if not candidates:
128194
return None
129-
return max(candidates)
195+
return min(candidates)
130196

131197

132198
def compute_coherence(
133199
source_edges: Sequence[int],
134200
target_edges: Sequence[int],
135201
triangles: Sequence[Sequence[int]],
136-
edge_lengths: Mapping[int, float],
137202
num_vertices: int,
203+
embedding: np.ndarray,
204+
death_scale: float | None = None,
138205
method: HomotopyCoherenceMethod = "path_finding",
139206
) -> float | None:
140207
return path_finding_coherence(
141208
source_edges,
142209
target_edges,
143210
triangles,
144-
edge_lengths,
145211
num_vertices,
212+
embedding,
213+
death_scale,
146214
)

‎src/scloop/computing/matching.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def check_homological_equivalence(
157157
column_trim_method: ColumnTrimMethod = DEFAULT_COLUMN_TRIM_METHOD,
158158
column_scores: np.ndarray | None = None,
159159
embedding: np.ndarray | None = None,
160+
death_scale: float | None = None,
160161
n_neighbors_column_trim: int = DEFAULT_N_NEIGHBORS_COLUMN_TRIM,
161162
) -> LoopClassEquivalence:
162163
if len(source_loops) == 0 or len(target_loops) == 0:
@@ -208,7 +209,7 @@ def check_homological_equivalence(
208209
column_scores=column_scores,
209210
)
210211

211-
if not compute_homotopy_coherence:
212+
if not compute_homotopy_coherence or embedding is None:
212213
return result
213214

214215
assert isinstance(loop_edges_a, LoopEdges)
@@ -243,6 +244,8 @@ def check_homological_equivalence(
243244
],
244245
edge_lengths=edge_lengths,
245246
num_vertices=boundary_matrix_d1.num_vertices,
247+
embedding=embedding,
248+
death_scale=death_scale,
246249
method=homotopy_coherence_method,
247250
)
248251
)
@@ -262,16 +265,18 @@ def check_homological_equivalence(
262265
source_edges=tuple(source ^ relaxation_edges),
263266
target_edges=tuple(target),
264267
triangles=triangles,
265-
edge_lengths=edge_lengths,
266268
num_vertices=boundary_matrix_d1.num_vertices,
269+
embedding=embedding,
270+
death_scale=death_scale,
267271
method=homotopy_coherence_method,
268272
),
269273
compute_coherence(
270274
source_edges=tuple(source),
271275
target_edges=tuple(target ^ relaxation_edges),
272276
triangles=triangles,
273-
edge_lengths=edge_lengths,
274277
num_vertices=boundary_matrix_d1.num_vertices,
278+
embedding=embedding,
279+
death_scale=death_scale,
275280
method=homotopy_coherence_method,
276281
),
277282
]

‎src/scloop/data/containers.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
compute_persistence_diagram_and_cocycles,
2828
)
2929
from ..computing.loops import compute_loop_representatives
30+
from ..computing.coherence import global_h1_death_scale
3031
from ..computing.matching import (
3132
check_homological_equivalence,
3233
cocycle_to_edge_mask,
@@ -815,6 +816,8 @@ def _assess_bootstrap_homology_equivalence(
815816
max_column_diameter=max_column_diameter,
816817
cocycle_edge_mask=cocycle_edge_mask,
817818
column_trim_method=column_trim_method,
819+
embedding=embedding,
820+
death_scale=global_h1_death_scale(self.persistence_diagram),
818821
column_scores=(
819822
source_loop_class.column_proximity_scores(
820823
self.boundary_matrix_d1,

‎src/scloop/data/metadata.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
FeatureSelectionMethod,
1515
IndexListDownSample,
1616
Percent_t,
17+
PositiveFloat,
1718
Size_t,
1819
)
1920

@@ -172,6 +173,8 @@ def from_hdf5_group(cls, group: h5py.Group) -> PreprocessMeta:
172173
class BootstrapMeta(BaseModel):
173174
indices_resample: list[IndexListDownSample] | None = None
174175
life_pct: Percent_t | None = None
176+
threshold_homology: PositiveFloat | None = None
177+
threshold_boundary: PositiveFloat | None = None
175178

176179
def to_hdf5_group(self, group: h5py.Group, compress: bool = True) -> None:
177180
group.attrs["_type"] = "BootstrapMeta"

‎src/scloop/matching/cross_matching.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ def _attribute_to_loop_class(
153153
+ extra_diameter * max_lifetime,
154154
cocycle_edge_mask=cocycle_edge_mask,
155155
column_scores=column_scores,
156+
embedding=side.embedding,
157+
death_scale=max(
158+
(lc.death for lc in side.loop_classes if lc is not None),
159+
default=None,
160+
),
156161
**kwargs_equivalence,
157162
)
158163
if equivalence.is_equivalent(relax=with_relaxation):

‎src/scloop/tools/_loops.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ def find_loops(
147147
boundary_thresh = threshold_boundary
148148
if boundary_thresh is None:
149149
boundary_thresh = threshold_homology
150+
meta.bootstrap.threshold_homology = threshold_homology
151+
meta.bootstrap.threshold_boundary = threshold_boundary
150152
hd._compute_boundary_matrix_d1(
151153
adata=adata, thresh=boundary_thresh, verbose=verbose
152154
)

0 commit comments

Comments
 (0)