Skip to content

Commit 3a49e72

Browse files
committed
feat: Population Annealing baseline + rebrand to PQQA
- Add qqa.population_annealing (Hukushima-Iba/Machta), with systematic and multinomial resampling, log-stable Boltzmann reweighting, ESS tracking, and the same SAResult-shaped contract as qqa.simulated_annealing. - Refactor qqa.sa to share single-sweep MCMC primitives (QUBO Glauber and generic single-spin MH) with the new PA backend, eliminating the risk of ΔE-formula drift between SA and PA. - Compare page: extend the shootout into a 3-way race PQQA vs SA vs PA; speedup vs the stronger of the two baselines. - README: rename top-line title to "Parallel Quasi-Quantum Annealer (PQQA)" and reposition the project as an OSS toolkit unifying continuous-relaxation CO methods (PQQA / CRA-PI-GNN / CPRA) plus matched MCMC baselines. - Tests: 8 new pytest cases (tests/test_pa.py), updated GUI AppTest for the renamed shootout mode, public-API regression covers PA exports. - Verified: ruff clean, 206 passed / 2 skipped, verify_pa.py 7/7, verify_sa_compare.py 7/7.
1 parent 053becf commit 3a49e72

8 files changed

Lines changed: 718 additions & 134 deletions

File tree

README.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
# QQA4CO — Parallel Quasi-Quantum Annealing for Combinatorial Optimisation
1+
# Parallel Quasi-Quantum Annealer (PQQA)
22

3-
A research-grade PyTorch toolkit organised around **Parallel Quasi-Quantum
4-
Annealing (PQQA)** — a gradient-based, GPU-friendly framework for
5-
combinatorial optimisation (CO) that unifies several recent
6-
unsupervised-learning solvers under a single API. One installation gives you
7-
the PQQA solver, an optional GNN backend that plugs in CRA-PI-GNN-style
8-
methods, a 20+ class problem catalogue, a Streamlit dashboard and a CLI.
3+
A research-grade PyTorch toolkit for **continuous-relaxation combinatorial
4+
optimisation**. PQQA unifies the recent line of work that *lifts* a
5+
discrete CO problem into a continuous space and then solves it with
6+
gradient descent — **PQQA** (ICLR 2025), the **CRA-PI-GNN** baseline
7+
(NeurIPS 2024) and the **CPRA** diverse-solution framework (TMLR 2025) —
8+
behind a single, GPU-friendly API. One installation gives you the PQQA
9+
solver, an optional GNN backend that plugs in CRA-PI-GNN-style methods,
10+
GPU-parallel **SA** and **Population Annealing** baselines for honest
11+
comparisons, a 20+ class problem catalogue, a Streamlit dashboard and a CLI.
12+
13+
The package is published on PyPI as **`qqa`** for backwards compatibility
14+
with earlier QQA4CO releases (``import qqa``).
915

1016
<p align="center">
1117
<a href="https://pypi.org/project/qqa/"><img src="https://img.shields.io/pypi/v/qqa.svg?logo=pypi&logoColor=white&label=PyPI" alt="PyPI version"></a>
@@ -51,7 +57,7 @@ parallel population in 3D solution-space PCA / loss-spectrogram views.
5157
| **Home** | Pick from 19 problems (MIS, Max-Cut, MaxClique, Vertex Cover, GraphBisection, MinDominatingSet, Coloring, BalancedGraphPartition, TSP, QAP, NQueens, Knapsack, NumberPartitioning, MaxSAT3, 1D Ising, Edwards–Anderson, SK, p-spin, RFIM, BinaryPerceptron, HopfieldMemory). Auxiliary sliders are problem-aware. |
5258
| **Solve** | Run PQQA / CRA-PI-GNN / CPRA with live progress, polish (1-flip local search) and warm-start toggles, and a per-problem solution viewer (TSP tour, NQueens board, highlighted IS, colouring, …). |
5359
| **Visualize** | 10 tabs of post-hoc plots — **solution-space PCA** of the final parallel population (3D, replicas coloured by loss, global best highlighted), **loss spectrogram** over time, diversity, replica fate, schedule, ridgeline. |
54-
| **Compare** | Hyper-parameter grid sweep with parallel-coordinates view, **and** a head-to-head **PQQA vs. SA shootout** that reports the wall-clock speed-up at matched compute budget. |
60+
| **Compare** | Hyper-parameter grid sweep with parallel-coordinates view, **and** a head-to-head **PQQA vs. SA vs. Population-Annealing shootout** that reports the wall-clock speed-up at matched compute budget. |
5561

5662
### Or run it locally — same UI, your hardware
5763

@@ -92,12 +98,20 @@ CUDA is picked up automatically when available.
9298
diverse-solution framework (TMLR 2025) all share `AnnealResult`,
9399
`score_summary`, the same problem builders and the same CLI flags — A/B
94100
comparing methods is one `--backend` switch away.
95-
4. **A polished Streamlit dashboard** (light / dark, live progress, parallel
101+
4. **GPU-parallel MCMC baselines** for honest head-to-head comparisons —
102+
classical **Simulated Annealing** (`qqa.simulated_annealing`) with a
103+
QUBO Glauber fast path, and **Population Annealing**
104+
(`qqa.population_annealing`, Hukushima-Iba / Machta) with systematic or
105+
multinomial resampling between temperature steps. Both expose the same
106+
`best_sol` / `best_obj` / `history` surface as `qqa.anneal`, so the
107+
Streamlit *Compare* page can race PQQA against either at a matched
108+
compute budget.
109+
5. **A polished Streamlit dashboard** (light / dark, live progress, parallel
96110
population view, per-problem solution viz, hyper-parameter sweeps) and a
97111
`qqa` **CLI** (`solve / bench / gui / version`) for reproducible
98112
experiments. A hosted instance lives at
99113
<https://parallelquasiquantum4co.streamlit.app/>.
100-
5. **MkDocs + Material reference docs** with auto-generated API pages, a
114+
6. **MkDocs + Material reference docs** with auto-generated API pages, a
101115
runnable `examples/` notebook gallery (Open-in-Colab badges) and a
102116
`scripts/verify_all_problems.py` correctness sweep that benchmarks every
103117
problem against ground truth or a strong baseline (29 / 29 instances pass

app/pages/3_Compare.py

Lines changed: 92 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,12 @@ def _scalar_best(obj) -> float:
6969
with st.sidebar:
7070
mode = st.radio(
7171
"Compare mode",
72-
["QQA hyper-parameter sweep", "PQQA vs SA shootout"],
72+
["QQA hyper-parameter sweep", "PQQA vs SA vs PA shootout"],
7373
index=0,
7474
help=(
7575
"Sweep: grid-search QQA hyper-parameters on the current problem. "
76-
"Shootout: race PQQA against the SA baseline at a matched compute "
77-
"budget — useful for showing how much the relaxation buys you."
76+
"Shootout: race PQQA against the SA and Population-Annealing "
77+
"baselines at a matched compute budget."
7878
),
7979
)
8080

@@ -151,25 +151,27 @@ def _scalar_best(obj) -> float:
151151

152152

153153
# =============================================================================
154-
# Mode 2 — PQQA vs SA shootout (new)
154+
# Mode 2 — PQQA vs SA vs PA shootout
155155
# =============================================================================
156-
elif mode == "PQQA vs SA shootout":
157-
# SA only supports binary / spin relaxations; refuse upfront on Categorical.
156+
elif mode == "PQQA vs SA vs PA shootout":
157+
# SA / PA only support binary / spin relaxations; refuse on Categorical.
158158
probe_problem = build_problem(cfg)
159-
sa_supported = not isinstance(getattr(probe_problem, "relaxation", None), CategoricalRelaxation)
159+
chain_supported = not isinstance(
160+
getattr(probe_problem, "relaxation", None), CategoricalRelaxation
161+
)
160162

161163
st.markdown(
162-
"Race **Parallel QQA** against a textbook **Simulated Annealing** "
163-
"baseline on the same problem. Both run on the device you picked on "
164-
"Home, with a comparable compute budget — the convergence plot below "
165-
"exposes the wall-clock gap directly."
164+
"Race **Parallel QQA** against two textbook MCMC baselines — "
165+
"**Simulated Annealing (SA)** and **Population Annealing (PA, with "
166+
"resampling)** — on the same problem. Total compute is matched: "
167+
"PA's ``num_temps × sweeps_per_temp`` ≈ SA's ``num_sweeps``."
166168
)
167169

168-
if not sa_supported:
170+
if not chain_supported:
169171
st.warning(
170-
"SA is not supported for this problem (categorical relaxation). "
171-
"Switch the problem on Home to a QUBO / Ising / spin family "
172-
"(e.g. MIS, Max-Cut, SK) to use the shootout."
172+
"SA / PA do not support categorical-relaxation problems. "
173+
"Pick a QUBO / Ising / spin family (e.g. MIS, Max-Cut, SK) on "
174+
"**Home** to use the shootout."
173175
)
174176

175177
with st.sidebar:
@@ -187,9 +189,17 @@ def _scalar_best(obj) -> float:
187189
sa_beta_end = st.slider("SA β_end", 1.0, 100.0, 10.0, step=1.0, key="sa_b1")
188190
sa_schedule = st.selectbox("SA schedule", ["geometric", "linear"], key="sa_sched")
189191

190-
run_shootout = st.button("▶ Run shootout", type="primary", disabled=not sa_supported)
192+
st.caption("Population Annealing baseline")
193+
pa_temps = st.slider("PA num_temps", 10, 500, 100, step=10, key="pa_temps")
194+
pa_sweeps = st.slider("PA sweeps_per_temp", 1, 50, 10, key="pa_sweeps")
195+
pa_chains = st.slider("PA population (sol_size)", 4, 256, 64, key="pa_chains")
196+
pa_beta_start = st.slider("PA β_start", 0.01, 1.0, 0.1, step=0.01, key="pa_b0")
197+
pa_beta_end = st.slider("PA β_end", 1.0, 100.0, 10.0, step=1.0, key="pa_b1")
198+
pa_resample = st.selectbox("PA resample", ["systematic", "multinomial"], key="pa_resample")
199+
200+
run_shootout = st.button("▶ Run shootout", type="primary", disabled=not chain_supported)
191201

192-
if sa_supported and run_shootout:
202+
if chain_supported and run_shootout:
193203
with st.spinner("Running PQQA…"):
194204
problem_pqqa = build_problem(cfg)
195205
res_pqqa = qqa.anneal(
@@ -215,11 +225,25 @@ def _scalar_best(obj) -> float:
215225
verbose=False,
216226
)
217227

228+
with st.spinner("Running Population Annealing baseline…"):
229+
problem_pa = build_problem(cfg)
230+
res_pa = qqa.population_annealing(
231+
problem_pa,
232+
sol_size=int(pa_chains),
233+
num_temps=int(pa_temps),
234+
sweeps_per_temp=int(pa_sweeps),
235+
beta_start=float(pa_beta_start),
236+
beta_end=float(pa_beta_end),
237+
resample=pa_resample,
238+
device=cfg["device"],
239+
verbose=False,
240+
)
241+
218242
pqqa_best = _scalar_best(res_pqqa.best_obj)
219243
sa_best = _scalar_best(res_sa.best_obj)
244+
pa_best = _scalar_best(res_pa.best_obj)
220245

221-
# Side-by-side summary cards.
222-
col_p, col_s = st.columns(2)
246+
col_p, col_s, col_pa = st.columns(3)
223247
col_p.metric(
224248
"PQQA best_obj",
225249
f"{pqqa_best:.4f}",
@@ -232,60 +256,66 @@ def _scalar_best(obj) -> float:
232256
delta=f"runtime {res_sa.runtime:.2f} s",
233257
delta_color="off",
234258
)
259+
col_pa.metric(
260+
"PA best_obj",
261+
f"{pa_best:.4f}",
262+
delta=f"runtime {res_pa.runtime:.2f} s",
263+
delta_color="off",
264+
)
235265

236-
# "How much faster did PQQA reach SA's best?" by walking the SA history.
237-
sa_history = res_sa.history.get("best_obj", []) or [sa_best]
238-
# Time per SA sweep (rough), so we can map sweep index -> wall-clock.
239-
sa_dt = float(res_sa.runtime) / max(1, len(sa_history))
240-
# Find the first sweep at which SA reached the PQQA best.
241-
sa_reach_idx = next(
242-
(i for i, v in enumerate(sa_history) if float(v) <= pqqa_best),
266+
# "How much faster did PQQA reach the best baseline's best?"
267+
baseline_best = min(sa_best, pa_best)
268+
baseline_label = "SA" if sa_best <= pa_best else "PA"
269+
baseline_res = res_sa if sa_best <= pa_best else res_pa
270+
baseline_history = baseline_res.history.get("best_obj", []) or [baseline_best]
271+
baseline_dt = float(baseline_res.runtime) / max(1, len(baseline_history))
272+
baseline_reach = next(
273+
(i for i, v in enumerate(baseline_history) if float(v) <= pqqa_best),
243274
None,
244275
)
245276

246-
if sa_reach_idx is None:
277+
if baseline_reach is None:
247278
st.warning(
248-
f"SA never matched PQQA's best ({pqqa_best:.4f}). "
249-
f"SA stalled at {sa_best:.4f} after {res_sa.runtime:.2f}s. "
250-
"Increase `SA num_sweeps` or `β_end` to give SA more room."
279+
f"Neither SA nor PA matched PQQA's best ({pqqa_best:.4f}). "
280+
f"Best baseline = {baseline_label} at {baseline_best:.4f} "
281+
"after the chosen budget. Increase the baseline's compute or "
282+
"tune β to give it more room."
251283
)
252284
else:
253-
sa_time_to_pqqa = sa_dt * (sa_reach_idx + 1)
254-
speedup = sa_time_to_pqqa / max(res_pqqa.runtime, 1e-6)
285+
baseline_t = baseline_dt * (baseline_reach + 1)
286+
speedup = baseline_t / max(res_pqqa.runtime, 1e-6)
255287
st.success(
256288
f"PQQA reached `best_obj = {pqqa_best:.4f}` in "
257-
f"**{res_pqqa.runtime:.2f}s**, while SA needed "
258-
f"~**{sa_time_to_pqqa:.2f}s** to match it "
259-
f"(speedup ≈ **{speedup:.1f}×**)."
289+
f"**{res_pqqa.runtime:.2f}s**, while {baseline_label} "
290+
f"(the stronger baseline) needed ~**{baseline_t:.2f}s** to "
291+
f"match it (speedup ≈ **{speedup:.1f}×**)."
260292
)
261293

262-
# Convergence overlay: x = epoch / sweep index, y = best_obj.
263294
try:
264295
import plotly.graph_objects as go
265296

266297
fig = go.Figure()
267-
pqqa_hist = res_pqqa.history.get("best_obj", []) or [pqqa_best]
268-
fig.add_trace(
269-
go.Scatter(
270-
x=list(range(len(pqqa_hist))),
271-
y=[float(v) for v in pqqa_hist],
272-
name="PQQA",
273-
mode="lines",
274-
line={"width": 3},
275-
)
276-
)
277-
fig.add_trace(
278-
go.Scatter(
279-
x=list(range(len(sa_history))),
280-
y=[float(v) for v in sa_history],
281-
name="SA",
282-
mode="lines",
283-
line={"width": 3, "dash": "dash"},
298+
for name, hist, fallback, dash in (
299+
("PQQA", res_pqqa.history.get("best_obj", []), pqqa_best, None),
300+
("SA", res_sa.history.get("best_obj", []), sa_best, "dash"),
301+
("PA", res_pa.history.get("best_obj", []), pa_best, "dot"),
302+
):
303+
hist = hist or [fallback]
304+
line = {"width": 3}
305+
if dash:
306+
line["dash"] = dash
307+
fig.add_trace(
308+
go.Scatter(
309+
x=list(range(len(hist))),
310+
y=[float(v) for v in hist],
311+
name=name,
312+
mode="lines",
313+
line=line,
314+
)
284315
)
285-
)
286316
fig.update_layout(
287317
title="Best-objective trajectory",
288-
xaxis_title="iteration (PQQA epoch / SA sweep)",
318+
xaxis_title="iteration (PQQA epoch / SA sweep / PA temperature step)",
289319
yaxis_title="best_obj (lower is better)",
290320
**plotly_layout(),
291321
)
@@ -294,7 +324,6 @@ def _scalar_best(obj) -> float:
294324
except Exception as e:
295325
st.info(f"Plot unavailable: {e}")
296326

297-
# Final results table for export.
298327
st.subheader("Summary table")
299328
st.dataframe(
300329
[
@@ -312,6 +341,13 @@ def _scalar_best(obj) -> float:
312341
"iterations": int(sa_sweeps),
313342
"sol_size": int(sa_chains),
314343
},
344+
{
345+
"backend": "PA",
346+
"best_obj": pa_best,
347+
"runtime_s": float(res_pa.runtime),
348+
"iterations": int(pa_temps) * int(pa_sweeps),
349+
"sol_size": int(pa_chains),
350+
},
315351
],
316352
width="stretch",
317353
)

src/qqa/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
PopulationTracker,
3939
TrajectoryTracker,
4040
)
41+
from qqa.pa import PAResult, population_annealing
4142
from qqa.problems import (
4243
QAP,
4344
TSP,
@@ -122,6 +123,7 @@
122123
"MinimumDominatingSet",
123124
"NQueens",
124125
"NumberPartitioning",
126+
"PAResult",
125127
"PSpinGlass",
126128
"PopulationTracker",
127129
"QUBOProblem",
@@ -141,6 +143,7 @@
141143
"generate_graph",
142144
"load_problem_from_file",
143145
"polish",
146+
"population_annealing",
144147
"simulated_annealing",
145148
"user_problem_from_source",
146149
"warmstart",

0 commit comments

Comments
 (0)