AC/DC search (Python)
A Python translation of the MATLAB reference implementation of AC/DC search — the Alternating Continuous and Discrete Combinatorial optimization behind the winning solution to the FlyWire Ventral Nerve Cord Matching Challenge.
D. D. Lee, A. Matsliah & L. K. Saul, "AC/DC search: behind the winning solution to the FlyWire graph-matching challenge", Transactions on Machine Learning Research (01/2026). OpenReview
What it does
Given two weighted directed graphs A and B (same number of nodes), AC/DC
searches for the permutation P (a one-to-one node correspondence) that
maximizes the overlapping edge weight
J(P) = sum_ij min( A_ij , B_{p(i), p(j)} )
where p(i) is the node of B matched to node i of A. It alternates:
- a continuous phase — Frank–Wolfe optimization over the doubly stochastic relaxation (Birkhoff polytope), with an exact closed-form line search and a linear-assignment subproblem at each step; and
- a discrete phase — greedy pairwise-swap search over permutations, applying the exact-gain swaps that improve the score.
Each phase warm-starts the other.
Install
pip install acdc-search
The distribution is named acdc-search (the names acdc and acdc-py are
taken on PyPI by unrelated projects); the import name is acdc. It is pure
Python and needs only NumPy and SciPy.
For a development install from a checkout:
pip install -e ".[test]" # numpy, scipy + pytest
Usage
import numpy as np
import scipy.sparse as sp
from acdc import acdc_match, score, matrix_to_perm
# --- build two graphs A, B as scipy.sparse n x n matrices ---
rng = np.random.default_rng(0)
A = sp.random(200, 200, density=0.05, random_state=0).tocsr()
# (toy example) B is A relabeled by a hidden permutation we will recover
perm = rng.permutation(200)
P = sp.csc_matrix((np.ones(200), (np.arange(200), perm)), shape=(200, 200))
B = (P.T @ A @ P).tocsr()
# --- run the full AC/DC algorithm ---
M = acdc_match(A, B, max_iter=5, num_frank_wolfe=10)
print("score:", score(M, A, B), " (optimum:", A.sum(), ")")
matching = matrix_to_perm(M) # 0-based: node i of A -> matching[i] of B
High-level functions
| Function | MATLAB equivalent | Description |
|---|---|---|
acdc_match(A, B, P0=None, max_iter=5, num_frank_wolfe=10, max_swap=inf, solver='dense') |
main_acdc.m |
Full alternating algorithm. |
frank_wolfe_search(A, B, P0=None, num_updates=40, solver='dense') |
main_continuous.m |
Continuous (Frank–Wolfe) phase only. |
greedy_match(A, B, P0=None, max_swap=inf) |
main_discrete.m |
Discrete (greedy-swap) phase only. |
All three return a sparse permutation matrix.
Progress output. The phases emit their MATLAB-style tables through the
acdc logger at INFO. verbose=True (the default) attaches a stdout handler
for the duration of the call, so they just appear; verbose=False leaves your
logging configuration alone, which means an application that has configured
logging at INFO still receives them and one that has not stays silent:
import logging
logging.basicConfig(level=logging.INFO) # or logging.getLogger("acdc")
M = acdc_match(A, B, verbose=False) # tables go to your handlers
Inputs. A and B may be a SciPy sparse matrix, a dense numpy.ndarray,
or an edge-list tuple (rows, cols, weights) / (rows, cols, weights, n) with
0-based node indices. P0 may be a permutation vector, a sparse permutation
matrix, or None (identity start).
Public API
acdc exports exactly six names: the three entry points above, plus score
to evaluate a matching and perm_to_matrix / matrix_to_perm to convert
between a permutation matrix and a 0-based permutation vector.
The individual algorithm phases live in the submodules that mirror the MATLAB
files — acdc.objective (compute_gradient), acdc.frank_wolfe
(do_frank_wolfe), acdc.swaps (evaluate_swaps, make_swaps,
greedy_search), acdc.matching (permutation_match, as_graph, …) — and
can be imported from there. They are internal: their signatures follow the
call graph rather than any user-facing contract and may change without a major
version bump.
File map (MATLAB → Python)
MATLAB (../src/) |
Python (acdc/) |
|---|---|
compute_gradient.m + score expression |
objective.py |
permutation_match.m |
matching.py |
do_frank_wolfe.m |
frank_wolfe.py |
evaluate_swaps.m, make_swaps.m, greedy_search.m |
swaps.py |
main_acdc.m, main_continuous.m, main_discrete.m |
core.py |
Notes on fidelity & performance
solver='dense'(default) uses SciPy's exact Hungarian/LAPJV solver (linear_sum_assignment) — the faithful analogue of the MATLABperfectMatching.solver='sparse'usesmin_weight_full_bipartite_matching, which is much faster and lighter at scale but only considers stored edges (a perfect matching must exist on the sparsity pattern).- The MATLAB warm-start preconditioner for the assignment solver is omitted on purpose: its transform only adds per-row/column constants and does not change the optimal matching — it existed solely to speed MATLAB's sparse solver.
- Like the MATLAB original, the gradient and swap-gain matrices are dense
n × n, so memory isO(n²). At challenge scale (n = 18524, see below) that needs ~16 GB+ of RAM and is best run withsolver='dense'on a machine with enough memory; small and medium graphs run comfortably anywhere. - The quadratic part of the swap-gain matrix has two implementations. Six of
its eight terms are
min(x, y)with one argument taken fromAorP B P', so for nonnegative edge weights they vanish off those supports and can be scattered onto the union of the supports instead of swept densely. On sparse graphs that holds 2 densen × narrays instead of 5 (≈5.5 GB rather than ≈13.7 GB at challenge scale). Scattered writes are slower per element, so dense or negatively-weighted graphs use the literal dense expression; the two paths agree bit for bit and the choice is automatic. - The greedy phase recomputes the full score after each trial swap (faithful to the MATLAB original); incremental scoring is a possible future optimization.
- Both loops stop early at a fixed point: a Frank–Wolfe update that leaves the
iterate unchanged, or an AC/DC alternation that leaves the matching
unchanged, makes every remaining iteration a deterministic replay.
max_iterandnum_frank_wolfeare therefore upper bounds, not exact counts. The result is unchanged — only the wasted work is skipped.
TODO
Pinned (fixed) matches
Neither the MATLAB original nor this translation supports pinning a subset of
matches so they are never swapped out. Every pair is free to move at every stage:
the discrete phase picks its swap from a max over the whole gain matrix
(make_swaps.m → swaps.py), and each Frank–Wolfe step solves an
unconstrained linear assignment problem over the full gradient
(permutation_match.m → matching.py) — the P0 argument there is only a
warm-start accelerator and does not restrict the solution. This would be useful
whenever part of the correspondence is known a priori, e.g. neurons matched with
high confidence by cell type.
Adding it means two hooks, and would surface as a pinned= argument on the three
entry points:
- Discrete phase — mask the swap-gain matrix
D: zero the row and column of each pinned index whereDis assembled inevaluate_swaps, so thatdMaxalso respects the pins and thewhile dMax > 0loop still terminates. - Continuous phase — constrain the assignment problem. Prefer reducing it
(drop the pinned rows/columns, solve on the free submatrix, reinsert the pinned
pairs) over penalising forbidden entries with a large negative cost: the
reduced LAP is strictly smaller and avoids
-infhandling inlinear_sum_assignmententirely. Only the twopermutation_matchcalls need constraining — the interpolationP + step*(Q - P)withstepin(0, 1]preserves any entry that is1in bothPandQ, so pinned entries carry through the continuous iterate on their own.
Benchmark
benchmark.py runs AC/DC on synthetic planted instances of
increasing size to give a feel for scaling. Each instance relabels a random
graph by a hidden permutation, corrupts that alignment to make a warm start
(40 % of nodes scrambled by default), then measures runtime and how much of the
planted optimum is recovered.
python benchmark.py # default sweep, ~1 min
python benchmark.py --sizes 100 200 400 800 # quick
python benchmark.py --sizes 1000 2000 --density 0.02 --solver sparse
Example run (laptop, solver='dense', max_iter=3, num_frank_wolfe=10):
n edges mem grad eval acdc cont disc start% final% nodes%
-------------------------------------------------------------------------------------------------
100 481 78.1KB 0.001 0.002 0.014 0.010 0.004 38.9 100.0 100.0
200 1935 312.5KB 0.003 0.003 0.021 0.013 0.008 36.0 100.0 100.0
400 7771 1.2MB 0.007 0.009 0.060 0.038 0.022 39.3 100.0 100.0
800 31147 4.9MB 0.028 0.035 0.219 0.139 0.080 38.1 100.0 100.0
2000 194913 30.5MB 0.522 0.572 3.483 2.255 1.227 38.2 100.0 100.0
5000 1219064 190.7MB 8.149 8.837 50.284 32.975 17.308 38.2 100.0 100.0
- - - - - - - - - - - - - - - - - - - - - - - -
extrapolated from the n=800..5000 rows (acdc ~ n^2.97, R2=1.000 in log-log) -- NOT measured
~10000 4.88e+06 762.9MB 71.6 72.2 399 266 134 - - -
~50000 1.22e+08 18.6GB 1.04e+04 9.29e+03 4.73e+04 3.25e+04 1.52e+04 - - -
~100000 4.88e+08 74.5GB 8.89e+04 7.52e+04 3.7e+05 2.57e+05 1.16e+05 - - -
extrapolated n |
full acdc_match |
one dense n × n array |
|---|---|---|
| 10 000 | ~6.7 min | 762.9 MB |
| 50 000 | ~13 h | 18.6 GB |
| 100 000 | ~4.3 d | 74.5 GB |
grad/evalare per-call times (in seconds) forcompute_gradient/evaluate_swaps(best of as many repeats as fit in a short budget, so small sizes are not dominated by noise).acdcis the full run, split into the continuous (cont) and discrete (disc) phases — roughly a 2:1 split throughout.memis the size of one densen × nfloat64 matrix; then = 5000row peaks at about 1.1 GB resident.- Rows prefixed
~are extrapolations, not measurements. Each timing column is fitted as a power law over the asymptotic tail of the measured rows (n ≥ 800) and evaluated at the larger size. Fitting the small rows instead gives an exponent near 1.3 and under-predictsn = 5000by ~25×, because those sizes are dominated by fixed overhead — the benchmark warns when the fitted exponent lands below 2.5. Held out against a real measurement, a fit overn = 400..2000predicts 33 s atn = 5000against 42 s measured. - Scaling is
O(n³)at fixed density, notO(n²): the gradient loops over thenmatched nodes and does work quadratic in the degree, which at fixed density is itself proportional ton. The measured exponent is 2.97 withR² = 1.000. At fixed average degree — the realistic regime for a connectome — the same argument givesO(n²), dominated by the dense matrices. Which of the two applies to your graph is the single biggest factor in what it will cost. - The extrapolated rows hold density fixed at 0.05, so they are an upper
bound for a graph of that size rather than a prediction for a real one. The
challenge connectomes are
n = 18524at density 0.012 (male) and 0.006 (female) — 4–8× sparser than this sweep. At these sizes memory binds well before time does: 74.5 GB for a single array atn = 100 000, and the greedy phase holds two to three of them. - From a corrupted-but-informative warm start the planted alignment is
recovered essentially perfectly (
final% ≈ 100). Quality from a cold (identity) start is much lower — AC/DC is a local search and, like the paper, relies on a reasonable warm start.
The challenge data
The challenge was to align the connectomes of the ventral nerve cords (VNCs)
of a male and a female fruit fly — not the FlyWire brain connectome, despite
the challenge carrying the FlyWire name. Per the paper, each VNC connectome is
a directed weighted graph with n = 18524 nodes (neurons) and millions of
edges (synapse counts); the organizers also supplied a cell-type-derived
baseline match, scoring 5 154 247, which teams could use as a warm start.
The two graphs are that size in the data shipped here as well: the male edge
list spans node ids m1..m18524 and the female f1..f18524 (the female list
has 18 523 nodes with at least one edge — f9574 is isolated, which is why
_coerce_pair pads to a common n). Their densities are 0.012 and 0.006
respectively, so both are far sparser than the benchmark's default 0.05.
CSV / challenge I/O
This package translates the core algorithm only. The challenge-specific CSV
readers/writers (read_connectome.m, read_solution.m, save_solution.m) and
the figure scripts are intentionally out of scope; build A, B as sparse
matrices (or pass edge lists) as shown above.
Tests
pytest -q
Covers: a finite-difference gradient check, the score identity, self-match optimality, recovery of a known permutation, exactness of the swap-gain matrix, dense/sparse backend agreement, and end-to-end smoke tests.
Attribution & licence
This package is a translation of the MATLAB reference implementation that Lee, Matsliah & Saul published as supplementary material to their TMLR paper:
D. D. Lee, A. Matsliah & L. K. Saul, "AC/DC search: behind the winning solution to the FlyWire graph-matching challenge", Transactions on Machine Learning Research (01/2026). https://openreview.net/forum?id=8MjCOMyaDf
That original MATLAB code is MIT-licensed, Copyright (c) 2025 Daniel Lee and Lawrence Saul. This Python translation is therefore also offered under the MIT licence; LICENSE carries both notices, with the upstream licence reproduced in full as it requires.
NOTICE summarises how the translation departs from the original —
0-based indexing, SciPy in place of MATLAB's internal perfectMatching, the
omitted assignment preconditioner, the untranslated challenge I/O, and the added
tests and benchmark. The "Notes on fidelity & performance" section above covers
the same ground in more detail.
If you use this package, please cite the paper above — the algorithm is the authors' work; this is only a translation of it.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file acdc_search-0.1.0.tar.gz.
File metadata
- Download URL: acdc_search-0.1.0.tar.gz
- Upload date:
- Size: 26.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e37fc841b399405a0da92b83cb9f1d8e43e88c2dad081f4b83a139e98eb528c7
|
|
| MD5 |
a8997615bce1a319708f6ab2805b0adf
|
|
| BLAKE2b-256 |
eb7929b23a6e1b9dc71e1e0d6dcd05b6a50293069bc239d15a87e05491946b20
|
Provenance
The following attestation bundles were made for acdc_search-0.1.0.tar.gz:
Publisher:
ci.yml on flyconnectome/acdc_py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
acdc_search-0.1.0.tar.gz -
Subject digest:
e37fc841b399405a0da92b83cb9f1d8e43e88c2dad081f4b83a139e98eb528c7 - Sigstore transparency entry: 2273612021
- Sigstore integration time:
-
Permalink:
flyconnectome/acdc_py@8cbf28f8284874208ce4287aa5e469cdfa5e251f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/flyconnectome
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@8cbf28f8284874208ce4287aa5e469cdfa5e251f -
Trigger Event:
push
-
Statement type:
File details
Details for the file acdc_search-0.1.0-py3-none-any.whl.
File metadata
- Download URL: acdc_search-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2fb3f537cb3f673c7f094bc7dba5ed5733d943072ae7e8b6193b005b375dd8e
|
|
| MD5 |
aebfc9bc6d9b6f002413ea5e49a44d15
|
|
| BLAKE2b-256 |
8000a550571b424d09e567f58e4b194a895351d6bb4e4e1d8bff0ba2fd138377
|
Provenance
The following attestation bundles were made for acdc_search-0.1.0-py3-none-any.whl:
Publisher:
ci.yml on flyconnectome/acdc_py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
acdc_search-0.1.0-py3-none-any.whl -
Subject digest:
f2fb3f537cb3f673c7f094bc7dba5ed5733d943072ae7e8b6193b005b375dd8e - Sigstore transparency entry: 2273612480
- Sigstore integration time:
-
Permalink:
flyconnectome/acdc_py@8cbf28f8284874208ce4287aa5e469cdfa5e251f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/flyconnectome
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@8cbf28f8284874208ce4287aa5e469cdfa5e251f -
Trigger Event:
push
-
Statement type: