Skip to content

Commit 5cbbf7f

Browse files
Agglomeration (#45)
* Add lifted nhood construction functionality * First implementation of graph agglomeration * Improve agglomeration implementation
1 parent fd601d7 commit 5cbbf7f

25 files changed

Lines changed: 3356 additions & 1 deletion

MIGRATION_GUIDE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,80 @@ Notes:
13871387
`ProposalGenerator` and provide your own `_build` returning a C++
13881388
proposal-generator object if you need to extend the set.
13891389

1390+
### Agglomerative Cluster Policies
1391+
1392+
`bioimage_cpp.graph.agglomeration` provides hierarchical agglomerative
1393+
clustering driven by a small set of policy classes, matching the policies
1394+
in `nifty.graph.agglo`. Each policy is a max-heap-style driver (smaller
1395+
edge indicator = stronger merge candidate, matching nifty's convention)
1396+
with policy-specific priority computation, merge rule, and stopping
1397+
criterion. All policies accept any `UndirectedGraph` subclass —
1398+
`RegionAdjacencyGraph`, `GridGraph2D`/`GridGraph3D` included.
1399+
1400+
Nifty:
1401+
1402+
```python
1403+
import nifty.graph.agglo as nagglo
1404+
1405+
# Hierarchical, edge-weighted clustering.
1406+
policy = nagglo.edgeWeightedClusterPolicy(
1407+
graph=graph,
1408+
edgeIndicators=edge_indicators,
1409+
edgeSizes=edge_sizes,
1410+
nodeSizes=node_sizes,
1411+
numberOfNodesStop=number_of_clusters_stop,
1412+
sizeRegularizer=0.5,
1413+
)
1414+
labels = nagglo.agglomerativeClustering(policy).run().result()
1415+
```
1416+
1417+
bioimage-cpp:
1418+
1419+
```python
1420+
labels = bic.graph.agglomeration.EdgeWeightedClusterPolicy(
1421+
num_clusters_stop=number_of_clusters_stop,
1422+
size_regularizer=0.5,
1423+
).optimize(graph, edge_indicators, edge_sizes=edge_sizes, node_sizes=node_sizes)
1424+
```
1425+
1426+
Mapping:
1427+
1428+
| Nifty | bioimage-cpp |
1429+
| --- | --- |
1430+
| `edgeWeightedClusterPolicy(...)` | `EdgeWeightedClusterPolicy(num_clusters_stop=, size_regularizer=).optimize(graph, edge_indicators, edge_sizes=, node_sizes=)` |
1431+
| `nodeAndEdgeWeightedClusterPolicy(...)` | `NodeAndEdgeWeightedClusterPolicy(num_clusters_stop=, size_regularizer=, beta=).optimize(graph, edge_indicators, node_features, edge_sizes=, node_sizes=)` |
1432+
| `malaClusterPolicy(...)` | `MalaClusterPolicy(num_bins=, bin_min=, bin_max=, num_clusters_stop=, num_edges_stop=, threshold=).optimize(graph, edge_indicators)` |
1433+
| `gaspClusterPolicy(...)` (signed weights + linkage) | `GaspClusterPolicy(num_clusters_stop=, linkage=).optimize(graph, edge_weights, edge_sizes=, is_mergeable=)` |
1434+
1435+
`GaspClusterPolicy` linkage strings map to the rules in Bailoni et al.'s
1436+
GASP framework: `"sum"`, `"mean"`, `"max"`, `"min"`, `"abs_max"`,
1437+
`"mutex_watershed"`. The `mutex_watershed` linkage treats a negative
1438+
heap-top weight as a cannot-link constraint; the others apply the chosen
1439+
linkage update without imposing hard constraints from signs. The
1440+
optional `is_mergeable` mask marks edges that should be used only to
1441+
install cluster-level cannot-link constraints.
1442+
1443+
Differences from nifty:
1444+
1445+
- `optimize` returns dense `uint64` node labels directly. Nifty exposes a
1446+
separate driver (`agglomerativeClustering(policy).run().result()`); the
1447+
underlying loop is the same.
1448+
- Both `float32` and `float64` inputs are accepted; computation runs in
1449+
`float64` internally.
1450+
- Tie-breaks follow the deterministic order of edge ids returned by
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.
1463+
13901464
### Projecting RAG Node Labels to Pixels
13911465

13921466
Nifty projects scalar node data back to pixels with

development/graph/agglomeration/PERFORMANCE_NOTES.md

Lines changed: 307 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Benchmark scaffolding for comparing bioimage-cpp agglomeration policies
2+
against the corresponding ``nifty.graph.agglo`` implementations.
3+
4+
Loads the external multicut problem (a generic edge list + costs) and
5+
reinterprets the costs as boundary indicators (after a sigmoid) so the
6+
policies have something realistic to chew on. Reports median runtime over
7+
``--repeats`` invocations and partition agreement (variation of information
8+
and adjusted Rand index) between the two implementations.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
from statistics import median
15+
from time import perf_counter
16+
from typing import Callable
17+
18+
import numpy as np
19+
20+
21+
def parser(description: str) -> argparse.ArgumentParser:
22+
arg_parser = argparse.ArgumentParser(description=description)
23+
arg_parser.add_argument(
24+
"--sample",
25+
default="A",
26+
choices=["A", "B", "C"],
27+
help="Multicut problem sample to load.",
28+
)
29+
arg_parser.add_argument(
30+
"--size",
31+
default="small",
32+
choices=["small", "medium"],
33+
help="Multicut problem size to load (small ~ 60k nodes, medium ~ 700k).",
34+
)
35+
arg_parser.add_argument(
36+
"--repeats",
37+
type=int,
38+
default=3,
39+
help="Number of timed repeats per implementation.",
40+
)
41+
arg_parser.add_argument(
42+
"--num-clusters-stop",
43+
type=int,
44+
default=200,
45+
help="Stop when this many clusters remain (must be > 1 to keep both "
46+
"implementations from collapsing the whole graph).",
47+
)
48+
arg_parser.add_argument(
49+
"--size-regularizer",
50+
type=float,
51+
default=0.5,
52+
help="Size regulariser exponent for the edge-weighted policies.",
53+
)
54+
arg_parser.add_argument(
55+
"--threshold",
56+
type=float,
57+
default=0.5,
58+
help="Threshold for the MALA policy.",
59+
)
60+
arg_parser.add_argument(
61+
"--timeout",
62+
type=float,
63+
default=120.0,
64+
help="Download timeout in seconds if the external problem is not cached.",
65+
)
66+
return arg_parser
67+
68+
69+
def load_problem(sample: str = "A", size: str = "small", *, timeout: float = 120.0):
70+
"""Load a multicut problem and derive indicator / weight arrays.
71+
72+
Returns ``(bic_graph, nifty_graph, indicators, signed_weights, uv_ids)``
73+
where ``indicators`` are in ``[0, 1]`` (boundary strength) and
74+
``signed_weights`` keeps the original multicut sign (positive = attract).
75+
"""
76+
import bioimage_cpp as bic
77+
import nifty.graph as ng
78+
79+
uv_ids, costs = bic.graph.multicut.load_multicut_problem_data(
80+
sample, size, timeout=timeout
81+
)
82+
n_nodes = int(uv_ids.max()) + 1
83+
84+
bic_graph = bic.graph.UndirectedGraph.from_edges(n_nodes, uv_ids)
85+
nifty_graph = ng.undirectedGraph(n_nodes)
86+
nifty_graph.insertEdges(uv_ids)
87+
88+
# Multicut costs are signed log-odds (positive = attractive, large
89+
# magnitude = certain). Map to a boundary-strength indicator in [0, 1]
90+
# via a sigmoid of the negated cost so 'large positive cost' becomes
91+
# 'small indicator' (weak boundary), matching nifty's convention.
92+
indicators = 1.0 / (1.0 + np.exp(np.asarray(costs, dtype=np.float64)))
93+
indicators = np.ascontiguousarray(indicators.astype(np.float64))
94+
# Signed weights for GASP: keep the multicut sign directly.
95+
signed_weights = np.ascontiguousarray(np.asarray(costs, dtype=np.float64))
96+
return bic_graph, nifty_graph, indicators, signed_weights, uv_ids
97+
98+
99+
def time_call(function: Callable[[], np.ndarray], repeats: int):
100+
timings = []
101+
result = None
102+
for _ in range(repeats):
103+
start = perf_counter()
104+
result = function()
105+
timings.append(perf_counter() - start)
106+
assert result is not None
107+
return timings, result
108+
109+
110+
def variation_of_information(labels_a: np.ndarray, labels_b: np.ndarray) -> float:
111+
labels_a = np.asarray(labels_a).astype(np.int64)
112+
labels_b = np.asarray(labels_b).astype(np.int64)
113+
n = labels_a.size
114+
if n == 0:
115+
return 0.0
116+
_, a_inv, a_counts = np.unique(labels_a, return_inverse=True, return_counts=True)
117+
_, b_inv, b_counts = np.unique(labels_b, return_inverse=True, return_counts=True)
118+
pa = a_counts / n
119+
pb = b_counts / n
120+
contingency = np.zeros((a_counts.size, b_counts.size), dtype=np.float64)
121+
np.add.at(contingency, (a_inv, b_inv), 1.0)
122+
contingency /= n
123+
with np.errstate(divide="ignore", invalid="ignore"):
124+
ha = -np.sum(pa * np.log(pa, where=pa > 0))
125+
hb = -np.sum(pb * np.log(pb, where=pb > 0))
126+
joint = -np.sum(
127+
contingency * np.log(contingency, where=contingency > 0)
128+
)
129+
mutual_info = ha + hb - joint
130+
return float(2.0 * joint - ha - hb - 2.0 * mutual_info + ha + hb)
131+
132+
133+
def adjusted_rand(labels_a: np.ndarray, labels_b: np.ndarray) -> float:
134+
try:
135+
from sklearn.metrics import adjusted_rand_score
136+
except ImportError:
137+
return float("nan")
138+
return float(adjusted_rand_score(labels_a, labels_b))
139+
140+
141+
def report(
142+
name: str,
143+
bic_timings,
144+
nifty_timings,
145+
bic_labels,
146+
nifty_labels,
147+
n_nodes,
148+
n_edges,
149+
*,
150+
sample: str | None = None,
151+
size: str | None = None,
152+
):
153+
vi = variation_of_information(bic_labels, nifty_labels)
154+
ari = adjusted_rand(bic_labels, nifty_labels)
155+
bic_clusters = int(np.unique(bic_labels).size)
156+
nifty_clusters = int(np.unique(nifty_labels).size)
157+
bic_med = median(bic_timings)
158+
nifty_med = median(nifty_timings)
159+
speedup = nifty_med / bic_med if bic_med > 0 else float("nan")
160+
suffix = ""
161+
if sample is not None and size is not None:
162+
suffix = f" [sample {sample} / {size}]"
163+
print(f"policy: {name}{suffix}")
164+
print(f"nodes: {n_nodes}, edges: {n_edges}")
165+
print(f"bioimage_cpp clusters: {bic_clusters}")
166+
print(f"nifty clusters: {nifty_clusters}")
167+
print(f"bioimage_cpp median runtime [s]: {bic_med:.6f}")
168+
print(f"nifty median runtime [s]: {nifty_med:.6f}")
169+
print(f"speedup (nifty / bioimage_cpp): {speedup:.2f}x")
170+
print(f"variation of information: {vi:.6f}")
171+
print(f"adjusted Rand index: {ari:.6f}")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Compare bioimage-cpp and nifty edge-weighted agglomerative clustering."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
7+
import bioimage_cpp as bic
8+
9+
from _compatibility import load_problem, parser, report, time_call
10+
11+
12+
def main() -> None:
13+
args = parser(__doc__ or "").parse_args()
14+
bic_graph, nifty_graph, indicators, _, _ = load_problem(
15+
args.sample, args.size, timeout=args.timeout
16+
)
17+
n_edges = int(bic_graph.number_of_edges)
18+
n_nodes = int(bic_graph.number_of_nodes)
19+
edge_sizes = np.ones(n_edges, dtype=np.float64)
20+
node_sizes = np.ones(n_nodes, dtype=np.float64)
21+
22+
import nifty.graph.agglo as nagglo
23+
24+
def run_bic() -> np.ndarray:
25+
return bic.graph.agglomeration.EdgeWeightedClusterPolicy(
26+
num_clusters_stop=args.num_clusters_stop,
27+
size_regularizer=args.size_regularizer,
28+
).optimize(
29+
bic_graph,
30+
indicators,
31+
edge_sizes=edge_sizes,
32+
node_sizes=node_sizes,
33+
)
34+
35+
def run_nifty() -> np.ndarray:
36+
policy = nagglo.edgeWeightedClusterPolicy(
37+
graph=nifty_graph,
38+
edgeIndicators=indicators.astype(np.float32),
39+
edgeSizes=edge_sizes.astype(np.float32),
40+
nodeSizes=node_sizes.astype(np.float32),
41+
numberOfNodesStop=args.num_clusters_stop,
42+
sizeRegularizer=args.size_regularizer,
43+
)
44+
clustering = nagglo.agglomerativeClustering(policy)
45+
clustering.run()
46+
return np.asarray(clustering.result(), dtype=np.uint64)
47+
48+
bic_timings, bic_labels = time_call(run_bic, args.repeats)
49+
nifty_timings, nifty_labels = time_call(run_nifty, args.repeats)
50+
51+
report(
52+
"edge_weighted",
53+
bic_timings,
54+
nifty_timings,
55+
bic_labels,
56+
nifty_labels,
57+
n_nodes,
58+
n_edges,
59+
sample=args.sample,
60+
size=args.size,
61+
)
62+
63+
64+
if __name__ == "__main__":
65+
main()

0 commit comments

Comments
 (0)