Skip to content

Commit 4f58649

Browse files
Update KL implementation and add multicut to migration guide
1 parent a223867 commit 4f58649

2 files changed

Lines changed: 219 additions & 34 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,116 @@ Notes:
221221
- `number_of_threads=0` uses the library default; pass a positive integer for a
222222
fixed thread count.
223223

224+
## Multicut
225+
226+
Nifty exposes multicut through an objective + factory-style solver hierarchy.
227+
`bioimage-cpp` uses an explicit `MulticutObjective` and a `MulticutSolver` class
228+
hierarchy with a single `optimize(objective)` entry point.
229+
230+
Nifty:
231+
232+
```python
233+
import nifty.graph.opt.multicut as nmc
234+
235+
objective = nmc.multicutObjective(graph, edge_costs)
236+
solver = objective.greedyAdditiveFactory().create(objective)
237+
labels = solver.optimize()
238+
energy = objective.evalNodeLabels(labels)
239+
```
240+
241+
bioimage-cpp:
242+
243+
```python
244+
import bioimage_cpp as bic
245+
246+
objective = bic.graph.MulticutObjective(graph, edge_costs)
247+
labels = bic.graph.GreedyAdditiveMulticut().optimize(objective)
248+
energy = objective.energy(labels)
249+
```
250+
251+
`MulticutObjective` accepts an `UndirectedGraph` or a `RegionAdjacencyGraph` and
252+
a 1D `edge_costs` array of length `graph.number_of_edges`. The objective owns
253+
the current best `labels`; `optimize` updates them in place and also returns
254+
the new array.
255+
256+
Available solvers:
257+
258+
| nifty factory | bioimage-cpp solver |
259+
| --- | --- |
260+
| `greedyAdditiveFactory()` | `GreedyAdditiveMulticut()` |
261+
| `greedyFixationFactory()` | `GreedyFixationMulticut()` |
262+
| `kernighanLinFactory(...)` | `KernighanLinMulticut(...)` |
263+
| `chainedSolversFactory([...])` | `ChainedMulticutSolvers([...])` |
264+
| `multicutDecomposer(submodelFactory=...)` | `MulticutDecomposer(sub_solver=...)` |
265+
266+
Constructor argument mapping:
267+
268+
| nifty argument | bioimage-cpp argument |
269+
| --- | --- |
270+
| `weightStop` | `weight_stop` |
271+
| `nodeNumStop` | `node_num_stop` |
272+
| `addNoise` | `add_noise` |
273+
| `numberOfOuterIterations` | `number_of_outer_iterations` |
274+
| `numberOfInnerIterations` | `number_of_inner_iterations` |
275+
| `epsilon` | `epsilon` |
276+
| `submodelFactory` | `sub_solver` |
277+
| `fallthroughFactory` | `fallthrough_solver` |
278+
| `numberOfThreads` | `number_of_threads` |
279+
280+
Kernighan-Lin example:
281+
282+
```python
283+
solver = bic.graph.KernighanLinMulticut(number_of_outer_iterations=5)
284+
labels = solver.optimize(objective)
285+
```
286+
287+
If the objective's labels are left at the default (one cluster per node),
288+
`KernighanLinMulticut` warm-starts from a greedy-additive solution
289+
internally, matching `kernighanLinFactory(warmStartGreedy=True)`. To skip the
290+
warm-start, set `objective.set_labels(...)` to a non-trivial labeling first.
291+
292+
Chaining solvers:
293+
294+
```python
295+
solver = bic.graph.ChainedMulticutSolvers([
296+
bic.graph.GreedyAdditiveMulticut(),
297+
bic.graph.KernighanLinMulticut(number_of_outer_iterations=5),
298+
])
299+
labels = solver.optimize(objective)
300+
```
301+
302+
Decomposing a problem into positive-cost connected components and solving each
303+
sub-problem with a cheaper solver:
304+
305+
```python
306+
solver = bic.graph.MulticutDecomposer(
307+
sub_solver=bic.graph.KernighanLinMulticut(number_of_outer_iterations=5),
308+
fallthrough_solver=bic.graph.GreedyAdditiveMulticut(),
309+
number_of_threads=0,
310+
)
311+
labels = solver.optimize(objective)
312+
```
313+
314+
Notes:
315+
316+
- `edge_costs` must be `float64` and 1D with length `graph.number_of_edges`.
317+
- Output labels are dense `uint64` ids in `0 .. number_of_clusters - 1`.
318+
- `MulticutObjective.energy(labels)` is the multicut energy used internally; it
319+
matches `nmc.multicutObjective(...).evalNodeLabels(labels)`.
320+
- `objective.reset_labels()` restores the per-node initial labeling, useful when
321+
re-running solvers from a clean state.
322+
323+
Intentional differences vs. nifty:
324+
325+
- Solvers are plain Python classes — no `factory().create(objective)` step.
326+
- Solver arguments use snake_case and are keyword-only where appropriate.
327+
- `KernighanLinMulticut` runs a border-restricted move chain plus an explicit
328+
cluster-split phase, matching nifty's local optima on the standard multicut
329+
benchmark while being noticeably faster.
330+
- `MulticutDecomposer` short-circuits the trivial case where the sub-solver is
331+
`GreedyAdditiveMulticut` and no fallthrough is given — the greedy solver
332+
already operates on each connected component internally.
333+
224334
## Segmentation Overlaps
225335

226336
Nifty:

include/bioimage_cpp/graph/multicut/kernighan_lin.hxx

Lines changed: 109 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,35 @@ inline std::vector<std::vector<std::uint64_t>> build_cluster_to_nodes(
6666
return result;
6767
}
6868

69-
// Scratch flag used only during the initial gain scan, so the chain can tell
70-
// whether each neighbor belongs to the current bipartition. After init the
71-
// addressable heap is the source of truth (heap.contains(u) replaces the
72-
// stale-tracking arrays the old hand-rolled heap required).
69+
// Per-node scratch reused across chains.
70+
//
71+
// - `in_pair` : 1 while the node sits in (A ∪ B) for the current chain.
72+
// - `moved` : 1 once the node has been popped (tentatively moved this
73+
// chain). Pairs with `in_pair` to distinguish "moved" from
74+
// "non-bordered, never pushed".
75+
// - `cross_count` : number of cross-side bipartition neighbors. Matches
76+
// nifty's `referenced_by`: a node is only allowed in the
77+
// heap once this is positive, which restricts the chain to
78+
// border moves and avoids committing "orphan" single-node
79+
// migrations into an arbitrary neighbor cluster. Without
80+
// this restriction the chain commits negative-internal-weight
81+
// nodes into B even when their true best home is a new
82+
// cluster, locking them out of the later split phase.
83+
// - `stash_gain` : the node's current gain estimate, kept in sync regardless
84+
// of whether the node is currently in the heap. Used to seed
85+
// `heap.push` when a non-bordered node first joins the
86+
// border.
7387
struct ChainBuffers {
7488
std::vector<char> in_pair;
75-
76-
explicit ChainBuffers(const std::size_t n_nodes) : in_pair(n_nodes, 0) {}
89+
std::vector<char> moved;
90+
std::vector<std::uint32_t> cross_count;
91+
std::vector<double> stash_gain;
92+
93+
explicit ChainBuffers(const std::size_t n_nodes)
94+
: in_pair(n_nodes, 0),
95+
moved(n_nodes, 0),
96+
cross_count(n_nodes, 0),
97+
stash_gain(n_nodes, 0.0) {}
7798
};
7899

79100
struct ChainScratch {
@@ -90,6 +111,10 @@ struct ChainScratch {
90111
// cluster c during this outer iteration"; the filter on `labels[v] == c`
91112
// removes stale entries on the fly. Returns the committed cumulative gain
92113
// (>= 0).
114+
//
115+
// Also handles single-cluster splits: pass `cluster_b` as a fresh label
116+
// (no live members) and the chain will try to peel off a subset of `cluster_a`
117+
// into the new label.
93118
inline double run_chain(
94119
const UndirectedGraph &graph,
95120
const std::vector<double> &costs,
@@ -130,33 +155,48 @@ inline double run_chain(
130155
++live_b;
131156
}
132157
}
133-
// No two-side swap is possible if either side is empty or both sides have
134-
// a single node (the only non-trivial outcome is a join, handled by
135-
// apply_joins).
136-
if (live_a == 0 || live_b == 0 || (live_a == 1 && live_b == 1)) {
158+
// Skip if no non-trivial move exists: the cluster_b == fresh-label split
159+
// case is allowed when cluster_a has at least two live nodes.
160+
if (live_a + live_b < 2 || (live_a == 1 && live_b == 1)) {
137161
for (const auto v : queue_nodes) {
138162
bufs.in_pair[static_cast<std::size_t>(v)] = 0;
139163
}
140164
return 0.0;
141165
}
142166

167+
// For pair-chains (both sides non-empty) the chain is restricted to nodes
168+
// with at least one cross-side neighbor. For splits (B empty) every live
169+
// A node is eligible because there is no border yet — the first move has
170+
// to peel off the weakest-attached interior node.
171+
const bool is_split = (live_b == 0);
143172
for (const auto v : queue_nodes) {
144173
double w_to_a = 0.0;
145174
double w_to_b = 0.0;
175+
std::uint32_t cross = 0;
176+
const auto v_label = labels[static_cast<std::size_t>(v)];
146177
for (const auto adj : graph.node_adjacency(v)) {
147-
if (!bufs.in_pair[static_cast<std::size_t>(adj.node)]) {
178+
const auto u_key = static_cast<std::size_t>(adj.node);
179+
if (!bufs.in_pair[u_key]) {
148180
continue;
149181
}
150182
const auto c = costs[static_cast<std::size_t>(adj.edge)];
151-
if (labels[static_cast<std::size_t>(adj.node)] == cluster_a) {
183+
const auto u_label = labels[u_key];
184+
if (u_label == cluster_a) {
152185
w_to_a += c;
153186
} else {
154187
w_to_b += c;
155188
}
189+
if (u_label != v_label) {
190+
++cross;
191+
}
156192
}
157-
const auto v_label = labels[static_cast<std::size_t>(v)];
158193
const double gain_v = (v_label == cluster_a) ? (w_to_b - w_to_a) : (w_to_a - w_to_b);
159-
heap.push(static_cast<std::size_t>(v), gain_v);
194+
const auto v_key = static_cast<std::size_t>(v);
195+
bufs.stash_gain[v_key] = gain_v;
196+
bufs.cross_count[v_key] = cross;
197+
if (is_split || cross > 0) {
198+
heap.push(v_key, gain_v);
199+
}
160200
}
161201

162202
struct Move {
@@ -169,51 +209,62 @@ inline double run_chain(
169209
double cumulative = 0.0;
170210
double best_cumulative = 0.0;
171211
std::size_t best_prefix = 0;
172-
// The chain keeps running through negative moves because a later prefix
173-
// can still recover. In practice deep negative runs almost never improve
174-
// best_cumulative, so cap the lookahead at a small constant after each new
175-
// best.
176-
constexpr std::size_t max_steps_without_improvement = 32;
177-
std::size_t steps_since_best = 0;
178212

179213
while (!heap.empty()) {
180214
const auto top = heap.pop();
181215
const auto v = static_cast<std::uint64_t>(top.key);
182216
const auto gain_v = top.priority;
183-
const auto old_label = labels[static_cast<std::size_t>(v)];
217+
const auto v_key = static_cast<std::size_t>(v);
218+
const auto old_label = labels[v_key];
184219
const auto new_label = (old_label == cluster_a) ? cluster_b : cluster_a;
185220

221+
bufs.moved[v_key] = 1;
186222
cumulative += gain_v;
187223
chain.push_back({v, new_label});
188224

189225
if (cumulative > best_cumulative + epsilon) {
190226
best_cumulative = cumulative;
191227
best_prefix = chain.size();
192-
steps_since_best = 0;
193-
} else {
194-
++steps_since_best;
195-
if (steps_since_best > max_steps_without_improvement) {
196-
break;
197-
}
198228
}
199229

200230
for (const auto adj : graph.node_adjacency(v)) {
201231
const auto u_key = static_cast<std::size_t>(adj.node);
202-
if (!heap.contains(u_key)) {
232+
if (!bufs.in_pair[u_key] || bufs.moved[u_key]) {
203233
continue;
204234
}
205-
// u is in the heap, so it's an unmoved member of the current
206-
// bipartition. labels[u] is therefore u's actual side (chain
207-
// moves are tentative — they only mutate `labels` on commit).
208235
const auto c = costs[static_cast<std::size_t>(adj.edge)];
209-
const auto u_label = labels[static_cast<std::size_t>(adj.node)];
236+
const auto u_label = labels[u_key];
210237
const double delta = (u_label == old_label) ? 2.0 * c : -2.0 * c;
211-
heap.change(u_key, heap.priority_of(u_key) + delta);
238+
bufs.stash_gain[u_key] += delta;
239+
if (heap.contains(u_key)) {
240+
heap.change(u_key, bufs.stash_gain[u_key]);
241+
}
242+
// Border maintenance. For pair-chains, only nodes that are
243+
// currently bordered may be popped. A node becomes bordered when
244+
// it gains its first cross-side neighbor (cross_count 0 -> 1) and
245+
// un-borders when it loses its last (cross_count -> 0).
246+
if (u_label == old_label) {
247+
++bufs.cross_count[u_key];
248+
if (!is_split && !heap.contains(u_key)) {
249+
heap.push(u_key, bufs.stash_gain[u_key]);
250+
}
251+
} else {
252+
if (bufs.cross_count[u_key] > 0) {
253+
--bufs.cross_count[u_key];
254+
}
255+
if (!is_split && bufs.cross_count[u_key] == 0
256+
&& heap.contains(u_key)) {
257+
heap.erase(u_key);
258+
}
259+
}
212260
}
213261
}
214262

215263
for (const auto v : queue_nodes) {
216-
bufs.in_pair[static_cast<std::size_t>(v)] = 0;
264+
const auto v_key = static_cast<std::size_t>(v);
265+
bufs.in_pair[v_key] = 0;
266+
bufs.moved[v_key] = 0;
267+
bufs.cross_count[v_key] = 0;
217268
}
218269

219270
if (best_cumulative > epsilon) {
@@ -349,6 +400,30 @@ inline std::vector<std::uint64_t> kernighan_lin(
349400
}
350401
}
351402

403+
// Try splitting each existing cluster off a fresh label. Pair-chains
404+
// can only swap members between existing clusters, so without this
405+
// pass the algorithm can never *increase* the partition count — any
406+
// local minimum that requires breaking up a cluster is unreachable.
407+
// Whether a given problem actually benefits depends on whether the
408+
// pair-chain phase leaves any cluster with internally-negative-weight
409+
// nodes.
410+
std::uint64_t next_label = number_of_clusters;
411+
for (std::uint64_t cluster = 0; cluster < number_of_clusters; ++cluster) {
412+
while (true) {
413+
if (next_label >= cluster_to_nodes.size()) {
414+
cluster_to_nodes.resize(static_cast<std::size_t>(next_label) + 1);
415+
}
416+
const auto delta = detail_kl::run_chain(
417+
graph, costs, labels, cluster_to_nodes, bufs, scratch, cluster, next_label, epsilon
418+
);
419+
if (delta <= epsilon) {
420+
break;
421+
}
422+
improved = true;
423+
++next_label;
424+
}
425+
}
426+
352427
const auto pairs_for_join = detail_kl::compute_cluster_pairs(graph, costs, labels);
353428
const auto current_number_of_clusters = labels.empty()
354429
? std::uint64_t{0}

0 commit comments

Comments
 (0)