Skip to content

Commit 732a176

Browse files
Improve agglomeration implementation
1 parent 8b2ac20 commit 732a176

8 files changed

Lines changed: 669 additions & 78 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,9 +1448,18 @@ Differences from nifty:
14481448
- Both `float32` and `float64` inputs are accepted; computation runs in
14491449
`float64` internally.
14501450
- Tie-breaks follow the deterministic order of edge ids returned by
1451-
`UndirectedGraph`, which may differ in rare cases from nifty's. Final
1452-
partitions agree closely; byte-identical label equality is not
1453-
guaranteed.
1451+
`UndirectedGraph`, which may differ from nifty's. On inputs where many
1452+
edges share the same indicator value, this combines with the
1453+
hierarchical agglomeration's positive feedback loop (each tied merge
1454+
changes node sizes, which changes the harmonic size factor `sFac`,
1455+
which changes future priorities) to give cascading divergence. On the
1456+
external multicut problem sample C/medium, where 86% of indicator
1457+
values are non-unique, perturbing the indicators of a single bic run by
1458+
1e-9 random noise can change the final partition's adjusted Rand index
1459+
vs. its own unperturbed output by ~0.5 (the algorithm is chaotically
1460+
sensitive to tie-breaking under non-zero `size_regularizer`). Both
1461+
partitions are valid clusterings; partition agreement (VI, ARI) is the
1462+
appropriate comparison metric, not label equality.
14541463

14551464
### Projecting RAG Node Labels to Pixels
14561465

development/graph/agglomeration/PERFORMANCE_NOTES.md

Lines changed: 307 additions & 0 deletions
Large diffs are not rendered by default.

development/graph/agglomeration/check_gasp.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313

1414
def make_parser() -> argparse.ArgumentParser:
1515
arg_parser = base_parser(__doc__ or "")
16+
# ``abs_max`` is intentionally not compared against nifty: nifty has no
17+
# direct sign-aware absolute-maximum linkage. The closest match is
18+
# ``MutexWatershedSettings`` but it additionally installs cannot-link
19+
# constraints, so the comparison is apples-to-oranges. Run the unit
20+
# tests for ``abs_max`` coverage instead.
1621
arg_parser.add_argument(
1722
"--linkage",
1823
default="mean",
19-
choices=["sum", "mean", "max", "min", "abs_max", "mutex_watershed"],
24+
choices=["sum", "mean", "max", "min", "mutex_watershed"],
2025
help="GASP linkage rule.",
2126
)
2227
return arg_parser
@@ -38,7 +43,6 @@ def main() -> None:
3843
"sum": nagglo.SumSettings,
3944
"max": nagglo.MaxSettings,
4045
"min": nagglo.MinSettings,
41-
"abs_max": nagglo.GeneralizedMeanSettings,
4246
"mutex_watershed": nagglo.MutexWatershedSettings,
4347
}[args.linkage]
4448

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Diagnose divergences between bioimage-cpp and nifty agglomeration policies.
2+
3+
Targeted at the cases where the benchmark sweep showed ARI < 0.90:
4+
5+
* mala on A/B/C small and C medium,
6+
* edge_weighted on C medium,
7+
* gasp_max on A/B small.
8+
9+
For each case, run both implementations, compare partitions, and (for the
10+
hypothesised root cause) print enough state to confirm or deny it.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import numpy as np
17+
18+
import bioimage_cpp as bic
19+
20+
from _compatibility import load_problem
21+
22+
23+
def _ari(a: np.ndarray, b: np.ndarray) -> float:
24+
try:
25+
from sklearn.metrics import adjusted_rand_score
26+
return float(adjusted_rand_score(a, b))
27+
except ImportError:
28+
return float("nan")
29+
30+
31+
def _cluster_sizes(labels: np.ndarray, top: int = 8) -> str:
32+
_, counts = np.unique(labels, return_counts=True)
33+
counts = np.sort(counts)[::-1]
34+
head = counts[:top].tolist()
35+
return f"n_clusters={len(counts)} top{top}={head} max={int(counts[0])} median={int(np.median(counts))}"
36+
37+
38+
def diagnose_mala(sample: str, size: str) -> None:
39+
print(f"\n=== MALA sample={sample} size={size} ===")
40+
bic_graph, nifty_graph, indicators, _, _ = load_problem(sample, size, timeout=120.0)
41+
n = int(bic_graph.number_of_nodes)
42+
e = int(bic_graph.number_of_edges)
43+
print(f" nodes={n} edges={e}")
44+
45+
bic_labels = bic.graph.agglomeration.MalaClusterPolicy(
46+
num_bins=40, bin_min=0.0, bin_max=1.0,
47+
num_clusters_stop=1000, threshold=0.5,
48+
).optimize(bic_graph, indicators)
49+
50+
import nifty.graph.agglo as nagglo
51+
policy = nagglo.malaClusterPolicy(
52+
graph=nifty_graph,
53+
edgeIndicators=indicators.astype(np.float32),
54+
nodeSizes=np.ones(n, dtype=np.float32),
55+
edgeSizes=np.ones(e, dtype=np.float32),
56+
threshold=0.5, numberOfNodesStop=1000,
57+
)
58+
clustering = nagglo.agglomerativeClustering(policy)
59+
clustering.run()
60+
nifty_labels = np.asarray(clustering.result(), dtype=np.uint64)
61+
62+
print(f" bic : {_cluster_sizes(bic_labels)}")
63+
print(f" nifty : {_cluster_sizes(nifty_labels)}")
64+
print(f" ARI : {_ari(bic_labels, nifty_labels):.4f}")
65+
# Sample a handful of indicators near 0.5 — the threshold — to highlight
66+
# how bin-center vs interpolated median changes the stop decision.
67+
mid = indicators[np.abs(indicators - 0.5) < 0.1]
68+
print(f" #indicators within 0.1 of threshold=0.5: {len(mid)} "
69+
f"(out of {len(indicators)})")
70+
71+
72+
def diagnose_edge_weighted(sample: str, size: str) -> None:
73+
print(f"\n=== EDGE_WEIGHTED sample={sample} size={size} ===")
74+
bic_graph, nifty_graph, indicators, _, _ = load_problem(sample, size, timeout=120.0)
75+
n = int(bic_graph.number_of_nodes)
76+
e = int(bic_graph.number_of_edges)
77+
print(f" nodes={n} edges={e}")
78+
79+
edge_sizes = np.ones(e, dtype=np.float64)
80+
node_sizes = np.ones(n, dtype=np.float64)
81+
82+
bic_labels = bic.graph.agglomeration.EdgeWeightedClusterPolicy(
83+
num_clusters_stop=1000, size_regularizer=0.5,
84+
).optimize(bic_graph, indicators, edge_sizes=edge_sizes, node_sizes=node_sizes)
85+
86+
import nifty.graph.agglo as nagglo
87+
policy = nagglo.edgeWeightedClusterPolicy(
88+
graph=nifty_graph,
89+
edgeIndicators=indicators.astype(np.float32),
90+
edgeSizes=edge_sizes.astype(np.float32),
91+
nodeSizes=node_sizes.astype(np.float32),
92+
numberOfNodesStop=1000, sizeRegularizer=0.5,
93+
)
94+
clustering = nagglo.agglomerativeClustering(policy)
95+
clustering.run()
96+
nifty_labels = np.asarray(clustering.result(), dtype=np.uint64)
97+
98+
print(f" bic : {_cluster_sizes(bic_labels)}")
99+
print(f" nifty : {_cluster_sizes(nifty_labels)}")
100+
print(f" ARI : {_ari(bic_labels, nifty_labels):.4f}")
101+
102+
# How many edges share the smallest-bucket priority? (tie-breaking
103+
# signal: large equal-priority cohorts let the two impls diverge.)
104+
p = np.round(indicators, 6)
105+
_, counts = np.unique(p, return_counts=True)
106+
top = np.sort(counts)[::-1][:5].tolist()
107+
print(f" unique-priorities up to 6 dp: {len(counts)} top-5 counts: {top}")
108+
109+
110+
def diagnose_gasp_max(sample: str, size: str) -> None:
111+
print(f"\n=== GASP max sample={sample} size={size} ===")
112+
bic_graph, nifty_graph, _, signed_weights, _ = load_problem(sample, size, timeout=120.0)
113+
n = int(bic_graph.number_of_nodes)
114+
e = int(bic_graph.number_of_edges)
115+
print(f" nodes={n} edges={e}")
116+
print(f" signed_weights: min={signed_weights.min():.3f} max={signed_weights.max():.3f} "
117+
f"positive_fraction={(signed_weights > 0).mean():.3f}")
118+
119+
edge_sizes = np.ones(e, dtype=np.float64)
120+
121+
bic_labels = bic.graph.agglomeration.GaspClusterPolicy(
122+
num_clusters_stop=1000, linkage="max",
123+
).optimize(bic_graph, signed_weights, edge_sizes=edge_sizes)
124+
125+
import nifty.graph.agglo as nagglo
126+
policy = nagglo.gaspClusterPolicy(
127+
graph=nifty_graph,
128+
signedWeights=signed_weights.astype(np.float64),
129+
isMergeEdge=np.ones(e, dtype=np.uint8),
130+
edgeSizes=edge_sizes.astype(np.float64),
131+
nodeSizes=np.ones(n, dtype=np.float64),
132+
updateRule0=nagglo.MaxSettings(),
133+
numberOfNodesStop=1000,
134+
)
135+
clustering = nagglo.agglomerativeClustering(policy)
136+
clustering.run()
137+
nifty_labels = np.asarray(clustering.result(), dtype=np.uint64)
138+
139+
print(f" bic : {_cluster_sizes(bic_labels)}")
140+
print(f" nifty : {_cluster_sizes(nifty_labels)}")
141+
print(f" ARI : {_ari(bic_labels, nifty_labels):.4f}")
142+
143+
144+
def main() -> None:
145+
parser = argparse.ArgumentParser(description=__doc__)
146+
parser.add_argument("--policy", choices=["mala", "edge_weighted", "gasp_max", "all"],
147+
default="all")
148+
parser.add_argument("--sample", default=None)
149+
parser.add_argument("--size", default=None)
150+
args = parser.parse_args()
151+
152+
cases_mala = [("A", "small"), ("B", "small"), ("C", "small"), ("C", "medium")]
153+
cases_ew = [("C", "medium")]
154+
cases_gm = [("A", "small"), ("B", "small")]
155+
156+
if args.sample and args.size:
157+
cases_mala = [(args.sample, args.size)]
158+
cases_ew = [(args.sample, args.size)]
159+
cases_gm = [(args.sample, args.size)]
160+
161+
if args.policy in ("mala", "all"):
162+
for s, z in cases_mala:
163+
diagnose_mala(s, z)
164+
if args.policy in ("edge_weighted", "all"):
165+
for s, z in cases_ew:
166+
diagnose_edge_weighted(s, z)
167+
if args.policy in ("gasp_max", "all"):
168+
for s, z in cases_gm:
169+
diagnose_gasp_max(s, z)
170+
171+
172+
if __name__ == "__main__":
173+
main()

include/bioimage_cpp/graph/agglomeration/gasp.hxx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,20 @@
1717
namespace bioimage_cpp::graph::agglomeration {
1818

1919
// Linkage criterion for GASP. The criterion determines how parallel edges
20-
// fold when two clusters merge; the heap priority is always |edge_weight|.
20+
// fold when two clusters merge and how the agglomeration terminates.
2121
//
22-
// `kMutexWatershed` interprets a negative weight on the heap top as a
23-
// cannot-link constraint (the edge is rejected and a mutex installed
24-
// between the two clusters), exactly matching the mutex-watershed algorithm
25-
// applied to a single edge list.
22+
// For all linkages except ``kMutexWatershed`` the heap priority is
23+
// ``-edge_weight`` (signed): the most attractive edge pops first, and the
24+
// agglomeration stops as soon as no positive-weight edges remain. This
25+
// matches nifty's "stop when no merge candidates are left" behaviour and
26+
// avoids doing unbounded work after all attractive evidence has been
27+
// consumed (otherwise critical for ``kSum`` whose combined weight can grow
28+
// without bound).
29+
//
30+
// ``kMutexWatershed`` instead uses priority ``-|edge_weight|`` so the
31+
// largest-magnitude edge pops first; a negative-weight pop installs a
32+
// permanent cannot-link constraint between the two clusters and is
33+
// rejected, exactly matching the mutex-watershed algorithm.
2634
enum class GaspLinkage {
2735
kSum = 0,
2836
kMean = 1,
@@ -96,10 +104,7 @@ public:
96104
const auto u = static_cast<std::size_t>(uv.first);
97105
const auto v = static_cast<std::size_t>(uv.second);
98106
const auto edge_index = static_cast<std::size_t>(edge_id);
99-
const double weight = edge_weight_[edge_index];
100-
// Min-heap stores ``-|weight|`` so the largest |weight| is the
101-
// first to pop (recovers max-heap-on-absolute-value semantics).
102-
const double priority = -std::abs(weight);
107+
const double priority = priority_of(edge_weight_[edge_index]);
103108
auto &edge = dynamic_graph.edges[edge_index];
104109
edge.u = u;
105110
edge.v = v;
@@ -128,6 +133,16 @@ public:
128133
if (check_mutex(u, v, cannot_link_)) {
129134
return Action::kRejectEdge;
130135
}
136+
// Non-mutex-watershed linkages use signed-weight priority and stop
137+
// as soon as the top of the heap is non-positive (no attractive
138+
// edges remain). Matches `nifty.graph.agglo`'s `isDone` behaviour
139+
// and prevents `kSum` / `kAbsMax` from running away when negative
140+
// weights flood the queue.
141+
if (linkage_ != GaspLinkage::kMutexWatershed) {
142+
if (edge_weight_[edge_id] <= 0.0) {
143+
return Action::kStop;
144+
}
145+
}
131146
if (!is_mergeable_[edge_id]) {
132147
insert_mutex(u, v, cannot_link_);
133148
return Action::kRejectEdge;
@@ -195,10 +210,22 @@ public:
195210
// contribution makes the surviving edge a cannot-link candidate.
196211
is_mergeable_[existing_id] =
197212
(is_mergeable_[existing_id] != 0 && is_mergeable_[fold_id] != 0) ? 1 : 0;
198-
return -std::abs(combined);
213+
return priority_of(combined);
199214
}
200215

201216
private:
217+
// For non-mutex-watershed linkages the priority is the negated signed
218+
// weight (min-heap pops the most-positive weight first; the loop
219+
// terminates when the top is non-positive). The mutex-watershed
220+
// linkage uses ``-|weight|`` so the largest-magnitude edge pops first
221+
// regardless of sign.
222+
double priority_of(const double weight) const {
223+
if (linkage_ == GaspLinkage::kMutexWatershed) {
224+
return -std::abs(weight);
225+
}
226+
return -weight;
227+
}
228+
202229
std::vector<double> edge_weight_;
203230
std::vector<double> edge_size_;
204231
std::vector<std::uint8_t> is_mergeable_;

0 commit comments

Comments
 (0)