Skip to content

Commit 473e265

Browse files
author
Yuma Ichikawa
committed
feat(isco): add paper-faithful iSCO sampler (Sun et al., ICML 2023)
Adds qqa.discrete_langevin (alias qqa.isco_anneal) — a faithful, GPU-parallel implementation of Algorithm 1 + Appendix C (PAS-MH-Step) of Sun, Goshvadi, Nova, Schuurmans, Dai, "Revisiting Sampling for Combinatorial Optimization", ICML 2023 (pmlr-v202-sun23c). Each MH step samples L ~ Poisson(μ) truncated at L≥1, picks L sites without replacement via Gumbel-top-L with logits −Δ_j/(2τ), applies the path-auxiliary MH correction over the ordered permutation σ (Eq. 30), and adapts μ toward the paper's 0.574 acceptance target (Eq. 31). Works on single-instance (Q_mat) and batched-instance (Q_tensor) QUBOs; spin / categorical / structured-shape relaxations are rejected at the API boundary with an actionable NotImplementedError. Returns an ISCOResult that mirrors SAResult / PAResult plus iSCO-specific diagnostics (accept_rate, mu_final, mean_path_length, t_max_used). Cross-checked against the DISCS reference (samplers/path_auxiliary.py) and the Zhang et al. discrete-langevin reference. Detailed-balance is empirically verified by a permanent CI test (test_isco_detailed_balance_on_tiny_qubo) that enumerates a 2^4-state QUBO and asserts TV(empirical, exact Boltzmann) < 0.02 after 4000 inner steps × 200 chains. Offline sweep across N ∈ {3,4,5} × seed ∈ {0,7,42} × μ ∈ {1,2,3} × {fp32, fp64} shows TV ≤ 0.0064 in every cell. Notable bug surfaced and fixed during verification: _plackett_luce_logprob's diff.clamp(max=-1e-12) round-tripped to 0 in float32 (machine ε ≈ 1.19e-7), sending the recursion into log(0) = -inf whenever sigma had repeated indices (i.e. every reverse path with L_per_chain < L_max because _reverse_path clamps the masked tail to sigma[0]). Subsequent summation via * mask.to(dtype) then produced (-inf) * 0 = NaN, making log(u) < log_alpha evaluate to False everywhere and silently rejecting every multi-flip proposal in the affected chain. Two surgical fixes: (a) dtype-aware clamp (-1e-6 for fp32, -1e-12 for fp64); (b) torch.where(mask, value, 0) instead of * mask, so masked positions can never contaminate the sum via inf * 0 = NaN. TV on the 4-bit enumerable QUBO drops 0.51 → 0.001-0.002. Regression test test_plackett_luce_logprob_handles_repeated_indices_in_float32 guards both float32 and float64 against this exact failure mode. Also adds: - README "iSCO baseline" section with Algorithm 1 / Appendix C derivation, Quickstart, batched-instance example, "when to use which baseline" table, and SA compute-budget parity rule. - BibTeX entries sun2023revisiting + goshvadi2023discs in the Reference papers section. - CHANGELOG [Unreleased] block documenting the new sampler, the detailed-balance test and the Plackett-Luce NaN fix.
1 parent 591493a commit 473e265

5 files changed

Lines changed: 1715 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,63 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **First-class iSCO sampler support** via `qqa.discrete_langevin`
12+
(paper-faithful alias `qqa.isco_anneal`). Faithful, GPU-parallel
13+
implementation of **Algorithm 1 + Appendix C (PAS-MH-Step)** of
14+
Sun, Goshvadi, Nova, Schuurmans, Dai, *Revisiting Sampling for
15+
Combinatorial Optimization*, ICML 2023 (pmlr-v202-sun23c). Every
16+
MH step samples a Poisson-length path `L ~ Poisson(μ)` truncated
17+
at `L ≥ 1`, picks `L` sites without replacement via Gumbel-top-`L`
18+
with logits `−Δ_j/(2τ)`, applies the **path-auxiliary MH
19+
correction** over the ordered permutation σ (Eq. 30), and adapts μ
20+
toward the paper's 0.574 acceptance target (Eq. 31). Works on
21+
single-instance (`Q_mat`) and batched-instance (`Q_tensor`) QUBOs;
22+
spin / categorical / structured-shape relaxations are rejected at
23+
the API boundary with an actionable `NotImplementedError`. Returns
24+
an `ISCOResult` that mirrors `SAResult` / `PAResult`
25+
(`best_sol` / `best_obj` / `runtime` / `history` / `score` /
26+
`polished_sol`) plus iSCO-specific diagnostics
27+
(`accept_rate`, `mu_final`, `mean_path_length`, `t_max_used`).
28+
Cross-checked against the DISCS reference implementation
29+
(`samplers/path_auxiliary.py`) and the Zhang et al.
30+
`discrete-langevin` reference. See the new
31+
*iSCO baseline (Sun et al., ICML 2023)* section in the README and
32+
citations `sun2023revisiting` + `goshvadi2023discs`.
33+
- **Empirical detailed-balance test for iSCO**
34+
(`tests/test_isco.py::test_isco_detailed_balance_on_tiny_qubo`)
35+
enumerates a 2^4-state QUBO, runs the full PAS-MH kernel for 4000
36+
inner steps × 200 chains at fixed temperature, and asserts
37+
TV(empirical, exact Boltzmann) < 0.02. Ships as a permanent
38+
guard against silent MH-correction regressions; offline sweep
39+
across `N ∈ {3, 4, 5} × seed ∈ {0, 7, 42} × μ ∈ {1, 2, 3} ×
40+
{float32, float64}` shows the post-fix sampler converges to
41+
TV ≤ 0.0064 in every cell.
42+
43+
### Fixed
44+
45+
- **iSCO `_plackett_luce_logprob` NaN bug (silent detailed-balance
46+
violation).** The Plackett-Luce log-prob recursion used
47+
`diff.clamp(max=-1e-12)` to keep `log1p(-exp(diff))` finite, but
48+
`-1e-12` round-trips to `0.0` in float32 (machine ε ≈ 1.19e-7),
49+
sending the recursion into `log(0) = -inf` whenever `sigma`
50+
contained the repeated indices that `_reverse_path` writes into
51+
the masked tail (i.e. **every chain with `L_per_chain < L_max`,
52+
which is every short chain in any batch with variable Poisson
53+
path length**). Subsequent summation via `* mask.to(dtype)` then
54+
produced `(-inf) * 0 = NaN`, making `log(u) < log_alpha` evaluate
55+
to `False` everywhere and silently rejecting every multi-flip
56+
proposal in the affected chain. Empirical TV(empirical, Boltzmann)
57+
on a 4-bit enumerable QUBO was ~0.51; after the fix it is
58+
~0.001-0.002. Two surgical changes: (a) dtype-aware clamp
59+
(`eps_clamp = -1e-6` for float32, `-1e-12` for float64); (b)
60+
`torch.where(mask, value, 0)` instead of `* mask` so masked
61+
positions can never contaminate the sum via `inf * 0`. Regression
62+
tests `test_plackett_luce_logprob_handles_repeated_indices_in_float32`
63+
and `test_isco_detailed_balance_on_tiny_qubo` ensure this stays
64+
fixed. Lessons L48-L50 in `tasks/lessons.md`.
65+
966
## [0.6.0] - 2026-04-20
1067

1168
### Added

README.md

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,17 @@ CUDA is picked up automatically when available.
110110
comparing methods is one `--backend` switch away.
111111
4. **GPU-parallel MCMC baselines** for honest head-to-head comparisons —
112112
classical **Simulated Annealing** (`qqa.simulated_annealing`) with a
113-
QUBO Glauber fast path, and **Population Annealing**
113+
QUBO Glauber fast path, **Population Annealing**
114114
(`qqa.population_annealing`, Hukushima-Iba / Machta) with systematic or
115-
multinomial resampling between temperature steps. Both expose the same
116-
`best_sol` / `best_obj` / `history` surface as `qqa.anneal`, so the
117-
Streamlit *Compare* page can race PQQA against either at a matched
118-
compute budget.
115+
multinomial resampling between temperature steps, and the
116+
**iSCO sampler** (`qqa.discrete_langevin`, a.k.a. `qqa.isco_anneal`)
117+
of Sun et al., *Revisiting Sampling for Combinatorial Optimization*
118+
(ICML 2023), a full implementation of the paper's Algorithm 1 +
119+
Appendix C (path-auxiliary multi-flip MH with Poisson-length paths
120+
and adaptive μ targeting 0.574 acceptance). All three expose the
121+
same `best_sol` / `best_obj` / `history` / `polished_sol` surface as
122+
`qqa.anneal`, so the Streamlit *Compare* page can race PQQA against
123+
any of them at a matched compute budget.
119124
5. **A polished Streamlit dashboard** (light / dark, live progress, parallel
120125
population view, per-problem solution viz, hyper-parameter sweeps) and a
121126
`qqa` **CLI** (`solve / bench / gui / version`) for reproducible
@@ -376,6 +381,127 @@ hyperparameter choice across all three rows.
376381
but see the original [CRA4CO repository](https://github.com/Yuma-Ichikawa/CRA4CO)
377382
for the canonical DGL implementation.
378383

384+
## iSCO baseline (Sun et al., ICML 2023)
385+
386+
Alongside `qqa.simulated_annealing` and `qqa.population_annealing`,
387+
QQA4CO ships a **faithful, GPU-parallel implementation of iSCO** as
388+
`qqa.discrete_langevin` (paper-faithful alias: `qqa.isco_anneal`),
389+
following the paper's Algorithm 1 and Appendix C (*Path-Auxiliary
390+
Sampler + Annealing*) in full. Specifically, every MH step
391+
392+
1. samples a path length `L ~ Poisson(μ)` truncated to `L ≥ 1`
393+
(Eq. 31);
394+
2. picks `L` sites **without replacement** from the locally-balanced
395+
weights $w_j = \exp\bigl(-\Delta_j(x)/(2\tau)\bigr)$ via the Gumbel
396+
top-`L` trick (Eq. 28), where the exact one-flip QUBO delta is
397+
398+
$$
399+
\Delta_i(x) \;=\; (1 - 2 x_i)\bigl(Q_{ii} + 2\,((Q x)_i - Q_{ii} x_i)\bigr);
400+
$$
401+
402+
3. flips those `L` bits simultaneously to obtain the candidate `y`;
403+
4. accepts via the **path-auxiliary MH correction** over the ordered
404+
permutation `σ` (Eq. 30):
405+
406+
$$
407+
A = \min\!\Bigl\{1,\;\frac{\pi(y)\,q_y(\sigma_r)}{\pi(x)\,q_x(\sigma)}\Bigr\}
408+
\qquad\text{with}\qquad
409+
q_x(\sigma) \;=\; \prod_{k=1}^{L} \frac{w_{\sigma_k}}{W_x - \sum_{m<k} w_{\sigma_m}};
410+
$$
411+
412+
5. adapts `μ` to track the paper's 0.574 acceptance target:
413+
`μ ← clip(μ + 0.001·(Ā − 0.574), 1, N)`.
414+
415+
The outer loop performs `num_steps` temperature updates (`m` in
416+
Algorithm 1); `num_inner` MH steps are run at each temperature (`n` in
417+
Algorithm 1). The default exponential decay schedule reproduces §5 of
418+
the paper; `schedule="lin"` reproduces the literal linear form from
419+
Algorithm 1 line 8. The implementation was cross-checked against
420+
[`google-research/discs`](https://github.com/google-research/discs) and
421+
[`ruqizhang/discrete-langevin`](https://github.com/ruqizhang/discrete-langevin).
422+
423+
**Empirically verified detailed balance.** The PAS-MH kernel is
424+
covered by an enumerable-state Boltzmann test
425+
(`tests/test_isco.py::test_isco_detailed_balance_on_tiny_qubo`)
426+
that asserts `TV(empirical, exact Boltzmann) < 0.02` after 4000
427+
inner steps × 200 chains at fixed temperature on a 2^4-state
428+
QUBO. An offline sweep across
429+
`N ∈ {3, 4, 5} × seed ∈ {0, 7, 42} × μ ∈ {1, 2, 3} ×
430+
{float32, float64}` shows TV ≤ 0.0064 in every cell, so the
431+
sampler is verified to converge to the target distribution under
432+
realistic operating conditions. (See the
433+
[`Unreleased` entry in `CHANGELOG.md`](CHANGELOG.md) for the
434+
silent NaN bug in the Plackett-Luce log-prob recursion that this
435+
test was added to guard against.)
436+
437+
The backend uses the same `sol_size` / `initial_state` / `device` /
438+
`polish=True` contract as every other QQA4CO solver, and returns an
439+
`ISCOResult` with the standard `best_sol` / `best_obj` / `runtime` /
440+
`history` / `score` / `polished_sol` fields plus iSCO-specific
441+
diagnostics (`accept_rate`, `mu_final`, `mean_path_length`,
442+
`t_max_used`).
443+
444+
### Quickstart
445+
446+
```python
447+
import networkx as nx
448+
import qqa
449+
450+
qqa.fix_seed(0)
451+
g = nx.random_regular_graph(d=3, n=200, seed=0)
452+
problem = qqa.MaximumIndependentSet(g, penalty=2)
453+
454+
result = qqa.discrete_langevin(
455+
problem,
456+
sol_size=256, # number of parallel chains (num_chains in iSCO paper)
457+
num_steps=500, # outer annealing steps (m)
458+
num_inner=4, # inner MH steps per temperature (n)
459+
t_max=None, # auto-calibrate from |Δ| quantile (DISCS adaptive-step recipe)
460+
t_min=0.01,
461+
schedule="exp", # paper §5 default (exponential decay). "lin" = Alg 1 literal form
462+
mu0=1.0, # initial Poisson mean for path length
463+
device="cuda",
464+
seed=0,
465+
)
466+
print(f"MIS size: {-int(result.best_obj)} acc={result.accept_rate:.2f} "
467+
f"μ_final={result.mu_final:.2f} mean_L={result.mean_path_length:.2f} "
468+
f"runtime={result.runtime:.2f}s")
469+
```
470+
471+
### Batched instances (same API)
472+
473+
```python
474+
result = qqa.discrete_langevin(
475+
batched_problem, # e.g. MaximumIndependentSetInstance(graphs, ...)
476+
sol_size=128, num_steps=500, num_inner=4, device="cuda",
477+
)
478+
# result.best_sol: (I, N); result.best_obj: numpy array of shape (I,)
479+
```
480+
481+
### When to use which baseline
482+
483+
| Use case | Recommended |
484+
| ------------------------------------------------------------------ | ----------------------------- |
485+
| PQQA headline method (gradient-based, continuous relaxation) | `qqa.anneal` |
486+
| Classical single-spin SA reference (Glauber / Metropolis) | `qqa.simulated_annealing` |
487+
| Free-energy estimation + spin-glass ground states (PA) | `qqa.population_annealing` |
488+
| iSCO paper reproduction; multi-flip path-auxiliary MH on QUBO | `qqa.discrete_langevin` |
489+
| Spin glasses / permutation / categorical problems | `qqa.anneal` (iSCO is QUBO-only) |
490+
491+
iSCO is only defined for binary QUBO (both single-instance `Q_mat` and
492+
batched-instance `Q_tensor` are supported); spin, categorical and
493+
structured-shape relaxations are rejected at the API boundary with an
494+
actionable `NotImplementedError` pointing at `qqa.anneal`.
495+
496+
### Compute-budget parity with SA
497+
498+
One iSCO outer step with `num_inner=n` performs `n` multi-flip MH
499+
moves per chain, each flipping ~`μ` sites. One SA sweep performs one
500+
flip per bit per chain. So the compute of
501+
`qqa.discrete_langevin(..., num_steps=m, num_inner=n)` is comparable
502+
to `qqa.simulated_annealing(..., num_sweeps=m*n*μ/N)` — a useful rule
503+
of thumb when designing head-to-head benchmarks at matched budget.
504+
379505
## Problem catalog
380506

381507
| Category | Classes |
@@ -887,6 +1013,41 @@ The unified ``data/discs/`` layout (one ``.gpickle`` per instance plus a
8871013
``manifest.jsonl`` sidecar) is QQA4CO-specific and described in
8881014
[`data/discs/README.md`](data/discs/README.md).
8891015

1016+
If you use the **iSCO baseline** (`qqa.discrete_langevin`, a.k.a.
1017+
`qqa.isco_anneal`), please cite the iSCO paper (whose Algorithm 1 and
1018+
Appendix C this backend implements) and — for the DISCS reference
1019+
implementation used for cross-checking — the DISCS benchmark paper:
1020+
1021+
```bibtex
1022+
@inproceedings{sun2023revisiting,
1023+
title = {Revisiting Sampling for Combinatorial Optimization},
1024+
author = {Sun, Haoran and Goshvadi, Katayoon and Nova, Azade and
1025+
Schuurmans, Dale and Dai, Hanjun},
1026+
booktitle = {Proceedings of the 40th International Conference on
1027+
Machine Learning (ICML)},
1028+
series = {Proceedings of Machine Learning Research},
1029+
volume = {202},
1030+
pages = {32859--32874},
1031+
year = {2023},
1032+
url = {https://proceedings.mlr.press/v202/sun23c.html}
1033+
}
1034+
1035+
@inproceedings{goshvadi2023discs,
1036+
title = {{DISCS}: A Benchmark for Discrete Sampling},
1037+
author = {Goshvadi, Katayoon and Sun, Haoran and Liu, Xingchao
1038+
and Nova, Azade and Zhang, Ruqi and Grathwohl, Will
1039+
and Schuurmans, Dale and Dai, Hanjun},
1040+
booktitle = {Advances in Neural Information Processing Systems
1041+
(NeurIPS Datasets and Benchmarks Track)},
1042+
year = {2023},
1043+
url = {https://openreview.net/forum?id=oi1MUMk5NF}
1044+
}
1045+
```
1046+
1047+
Reference implementations consulted during the port:
1048+
<https://github.com/google-research/discs> (`samplers/path_auxiliary.py`)
1049+
and <https://github.com/ruqizhang/discrete-langevin> (`samplers.py`).
1050+
8901051
If you use the **planted-solution factorization Ising benchmark**
8911052
(`qqa.IntegerFactorizationIsing`, `scripts/bench_factorization.py`,
8921053
`data/factorization/`), please cite the paper that introduced the

src/qqa/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
PopulationTracker,
3939
TrajectoryTracker,
4040
)
41+
from qqa.isco import ISCOResult, discrete_langevin, isco_anneal
4142
from qqa.pa import PAResult, population_annealing
4243
from qqa.problems import (
4344
QAP,
@@ -116,6 +117,7 @@
116117
"GraphBisection",
117118
"HistoryRecorder",
118119
"HopfieldMemory",
120+
"ISCOResult",
119121
"IntegerFactorizationIsing",
120122
"Ising1D",
121123
"Knapsack",
@@ -147,9 +149,11 @@
147149
"VertexCover",
148150
"__version__",
149151
"anneal",
152+
"discrete_langevin",
150153
"enable_tf32",
151154
"fix_seed",
152155
"generate_graph",
156+
"isco_anneal",
153157
"load_problem_from_file",
154158
"polish",
155159
"population_annealing",

0 commit comments

Comments
 (0)