Skip to content

Commit f7264d5

Browse files
More functionality for lifted multicut (#18)
* Implement lifted MC fusion moves * Update performance notes * Lifted Multicut Solver Optimizations * Update performance note
1 parent 1d445c9 commit f7264d5

12 files changed

Lines changed: 1500 additions & 128 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,14 +588,45 @@ energy = objective.energy(labels)
588588
- `overwrite_existing` — when `True`, lifted entries that coincide with an
589589
existing edge replace its weight; the default accumulates.
590590

591-
Available solvers (no fusion-move / ILP solvers yet):
591+
Available solvers (no ILP solvers yet):
592592

593593
| nifty factory | bioimage-cpp solver |
594594
| --- | --- |
595595
| `liftedMulticutGreedyAdditiveFactory()` | `LiftedGreedyAdditiveMulticut()` |
596596
| `liftedMulticutKernighanLinFactory(...)` | `LiftedKernighanLinMulticut(...)` |
597+
| `fusionMoveBasedFactory(...)` | `FusionMoveLiftedMulticut(...)` |
597598
| `chainedSolversFactory([...])` | `LiftedChainedSolvers([...])` |
598599

600+
`FusionMoveLiftedMulticut` mirrors `FusionMoveMulticut` (same proposal-generator
601+
plumbing, same threading + multi-proposal joint-fuse semantics, same best-of
602+
safety net). The differences are:
603+
604+
- Proposal generators operate on the *base* graph and base edge costs (only
605+
base-graph edges are candidate cut edges; lifted edges contribute to energy
606+
but cannot be contracted directly). The driver extracts the base costs from
607+
`objective.weights[:objective.number_of_base_edges]` automatically.
608+
- Each fuse contracts the base graph by agreement, aggregates *both* base and
609+
lifted weights onto the contracted lifted-multicut subproblem (lifted edges
610+
whose endpoints land on already-existing contracted base edges fold into
611+
them; the rest become new contracted lifted edges), and solves the
612+
subproblem with a `LiftedMulticutSolver`.
613+
- The default sub-solver and warm-start are `LiftedGreedyAdditiveMulticut`.
614+
Both `LiftedGreedyAdditiveMulticut` and `LiftedKernighanLinMulticut` are
615+
pluggable via `sub_solver=`.
616+
617+
```python
618+
solver = bic.graph.FusionMoveLiftedMulticut(
619+
proposal_generator=bic.graph.WatershedProposalGenerator(
620+
sigma=1.0, n_seeds_fraction=0.1, seed=0,
621+
),
622+
sub_solver=bic.graph.LiftedKernighanLinMulticut(number_of_outer_iterations=3),
623+
number_of_iterations=10,
624+
stop_if_no_improvement=4,
625+
number_of_threads=4,
626+
)
627+
labels = solver.optimize(objective)
628+
```
629+
599630
A typical warm-started solve combines greedy and KL:
600631

601632
```python

development/graph/lifted_multicut/PERFORMANCE_NOTES.md

Lines changed: 312 additions & 119 deletions
Large diffs are not rendered by default.

development/graph/lifted_multicut/_compatibility.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ def parser(description: str) -> argparse.ArgumentParser:
2828
default=60.0,
2929
help="Download timeout in seconds if the lifted problem is not cached.",
3030
)
31+
arg_parser.add_argument(
32+
"--threads",
33+
type=int,
34+
default=1,
35+
help="Number of threads for solvers that support it.",
36+
)
3137
arg_parser.add_argument(
3238
"--energy-bound",
3339
type=float,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import bioimage_cpp as bic
4+
5+
from _compatibility import parser, run_comparison
6+
7+
8+
def main() -> None:
9+
args = parser(
10+
"Compare bioimage-cpp and nifty fusion-move lifted multicut at matched "
11+
"settings."
12+
).parse_args()
13+
# Match settings on both sides:
14+
# - threads / parallel-proposals = `--threads N` (default 1). We pin
15+
# P = T explicitly on the bic side so the comparison is
16+
# apples-to-apples regardless of either library's default for P.
17+
# - greedy-additive warm-start: we do it automatically when the
18+
# objective's labels are the trivial singleton labeling. nifty's
19+
# `fusionMoveBasedFactory` has no warm-start parameter, so we make it
20+
# explicit on the nifty side by chaining greedy-additive in front.
21+
# - greedy-additive sub-solver (the implicit default on both sides).
22+
# - watershed proposal generator seeded from local edges. The bic
23+
# proposal generator only sees the base graph, so seeding from
24+
# "local" mirrors that exactly; the nifty default
25+
# `SEED_FROM_LIFTED` would put seeds where bic cannot.
26+
threads = int(args.threads)
27+
# nifty's lifted-multicut fusion-move backend only implements single-
28+
# threaded execution; passing numberOfThreads > 1 raises at solve time.
29+
# Cap the nifty side at 1 so multi-threaded bic benchmarks still run, and
30+
# surface the asymmetry in the printed output.
31+
nifty_threads = 1
32+
if threads != nifty_threads:
33+
print(
34+
f"note: nifty lifted fusion-move runs single-threaded; comparing "
35+
f"bic threads={threads} vs nifty threads={nifty_threads}."
36+
)
37+
run_comparison(
38+
"lifted_fusion_move",
39+
lambda: bic.graph.FusionMoveLiftedMulticut(
40+
proposal_generator=bic.graph.WatershedProposalGenerator(),
41+
number_of_iterations=10,
42+
stop_if_no_improvement=4,
43+
number_of_threads=threads,
44+
number_of_parallel_proposals=threads,
45+
),
46+
lambda objective: objective.chainedSolversFactory(
47+
[
48+
objective.liftedMulticutGreedyAdditiveFactory(),
49+
objective.fusionMoveBasedFactory(
50+
proposalGenerator=objective.watershedProposalGenerator(
51+
seedingStrategy="SEED_FROM_LOCAL",
52+
),
53+
numberOfIterations=10,
54+
stopIfNoImprovement=4,
55+
numberOfThreads=nifty_threads,
56+
),
57+
]
58+
),
59+
args,
60+
)
61+
62+
63+
if __name__ == "__main__":
64+
main()

development/graph/lifted_multicut/evaluate_solvers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ class SolverConfig:
2121
def solver_configs():
2222
import bioimage_cpp as bic
2323

24+
# Fair single-threaded comparison: nifty's lifted fusion-move backend is
25+
# single-threaded, so we pin the bic side to threads=1 and one parallel
26+
# proposal per iteration (matching nifty's "generate one, fuse" loop) and
27+
# chain greedy-additive in front of nifty to mirror bic's auto warm-start.
28+
# Watershed seeding strategy is forced to SEED_FROM_LOCAL on the nifty
29+
# side because the bic proposal generator only sees the base graph.
2430
return {
2531
"lifted_greedy_additive": SolverConfig(
2632
make_bic_solver=lambda: bic.graph.LiftedGreedyAdditiveMulticut(),
@@ -39,6 +45,28 @@ def solver_configs():
3945
]
4046
),
4147
),
48+
"lifted_fusion_move": SolverConfig(
49+
make_bic_solver=lambda: bic.graph.FusionMoveLiftedMulticut(
50+
proposal_generator=bic.graph.WatershedProposalGenerator(),
51+
number_of_iterations=10,
52+
stop_if_no_improvement=4,
53+
number_of_threads=1,
54+
number_of_parallel_proposals=1,
55+
),
56+
make_nifty_factory=lambda objective: objective.chainedSolversFactory(
57+
[
58+
objective.liftedMulticutGreedyAdditiveFactory(),
59+
objective.fusionMoveBasedFactory(
60+
proposalGenerator=objective.watershedProposalGenerator(
61+
seedingStrategy="SEED_FROM_LOCAL",
62+
),
63+
numberOfIterations=10,
64+
stopIfNoImprovement=4,
65+
numberOfThreads=1,
66+
),
67+
]
68+
),
69+
),
4270
}
4371

4472

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma once
22

3+
#include "bioimage_cpp/graph/lifted_multicut/fusion_move.hxx"
34
#include "bioimage_cpp/graph/lifted_multicut/greedy_additive.hxx"
45
#include "bioimage_cpp/graph/lifted_multicut/kernighan_lin.hxx"
56
#include "bioimage_cpp/graph/lifted_multicut/objective.hxx"

0 commit comments

Comments
 (0)