Skip to content

Commit fcca220

Browse files
committed
feat(pa): equilibrium samples + free energy + Muller-plot family tree, UI backend selector
- pa.py: return final_x/final_loss + Hukushima-Iba ln Z + genealogy - Solve UI: PQQA/PA radio with PA-specific knobs and live ESS panel - Visualize UI: PA tabs with Muller plot, sorted ancestry matrix, survivor curve - notebooks/colab_pqqa_sa_pa.ipynb: executed end-to-end demo - benchmark notebook re-executed with outputs
1 parent d5cde76 commit fcca220

7 files changed

Lines changed: 2124 additions & 355 deletions

File tree

app/pages/1_Solve.py

Lines changed: 291 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
"""Solve page — run QQA with live progress, metrics, and a parallel-search view."""
1+
"""Solve page — run PQQA or Population Annealing with live progress.
2+
3+
Two backends are exposed via a sidebar radio:
4+
5+
* **PQQA** (default) — Parallel Quasi-Quantum Annealing, the gradient-based
6+
CRA-PI-GNN family that ships with the library.
7+
* **PA** — Hukushima–Iba Population Annealing with resampling, equilibrium
8+
sample dump, free-energy estimation, and genealogy recording. The
9+
recorded genealogy + ``final_x`` populate the new "PA: …" tabs on the
10+
Visualize page so you can inspect resampling collapse and the family
11+
tree of the final population.
12+
"""
213

314
from __future__ import annotations
415

@@ -52,6 +63,21 @@
5263
cfg = st.session_state["problem_config"]
5364
render_config_chips(cfg)
5465

66+
with st.sidebar:
67+
st.header("2 · Backend")
68+
backend = st.radio(
69+
"Solver backend",
70+
["PQQA", "PA (Population Annealing)"],
71+
index=0,
72+
help=(
73+
"PQQA = Parallel Quasi-Quantum Annealing (gradient-based, the "
74+
"library's headline solver). PA = Hukushima–Iba Population "
75+
"Annealing with resampling — also produces equilibrium samples "
76+
"and a free-energy estimate."
77+
),
78+
key="solve_backend",
79+
)
80+
5581
# Hyper-parameter presets — each tuple is
5682
# (sol_size, epochs, learning_rate, temp, min_bg, max_bg, curve_rate,
5783
# div_param, update_every).
@@ -81,7 +107,12 @@ def _apply_preset(name: str) -> None:
81107

82108

83109
with st.sidebar:
84-
st.header("2 · QQA hyper-parameters")
110+
st.header("3 · QQA hyper-parameters")
111+
if backend != "PQQA":
112+
st.caption(
113+
":material/info: PQQA controls below are inactive — PA "
114+
"uses its own knobs further down the sidebar."
115+
)
85116
preset_name = st.radio(
86117
"Preset",
87118
list(_PRESETS),
@@ -208,6 +239,74 @@ def _apply_preset(name: str) -> None:
208239
help="Lower = smoother animation but slower wall-clock; higher = faster.",
209240
)
210241

242+
if backend != "PQQA":
243+
st.divider()
244+
st.header("3′ · PA hyper-parameters")
245+
st.caption(
246+
"Population Annealing with resampling. PA records its full "
247+
"genealogy and an equilibrium population at β_end so the "
248+
"Visualize page can show a resampling family tree and a "
249+
"free-energy density curve."
250+
)
251+
with st.expander("Population & schedule", expanded=True):
252+
pa_sol_size = st.slider(
253+
"PA population (sol_size)",
254+
4,
255+
512,
256+
st.session_state.get("pa_sol_size", 128),
257+
key="pa_sol_size",
258+
help="Number of replicas carried through resampling.",
259+
)
260+
pa_num_temps = st.slider(
261+
"num_temps",
262+
10,
263+
500,
264+
st.session_state.get("pa_num_temps", 100),
265+
step=10,
266+
key="pa_num_temps",
267+
help="Number of inverse-temperature steps in the schedule.",
268+
)
269+
pa_sweeps_per_temp = st.slider(
270+
"sweeps_per_temp",
271+
1,
272+
50,
273+
st.session_state.get("pa_sweeps_per_temp", 10),
274+
key="pa_sweeps_per_temp",
275+
help="MCMC sweeps after each resampling step (K in the textbook).",
276+
)
277+
pa_beta_start = st.slider(
278+
"β_start",
279+
0.01,
280+
2.0,
281+
st.session_state.get("pa_beta_start", 0.1),
282+
step=0.01,
283+
key="pa_beta_start",
284+
)
285+
pa_beta_end = st.slider(
286+
"β_end",
287+
0.5,
288+
100.0,
289+
st.session_state.get("pa_beta_end", 10.0),
290+
step=0.5,
291+
key="pa_beta_end",
292+
)
293+
pa_beta_schedule = st.selectbox(
294+
"β schedule",
295+
["geometric", "linear"],
296+
index=0,
297+
key="pa_beta_schedule",
298+
)
299+
pa_resample = st.selectbox(
300+
"Resampling rule",
301+
["systematic", "multinomial"],
302+
index=0,
303+
key="pa_resample",
304+
help=(
305+
"Systematic = low-variance (Doucet & Johansen, "
306+
"2008). Multinomial = standard SMC baseline."
307+
),
308+
)
309+
211310

212311
class StreamlitCallback(Callback):
213312
"""Stream QQA progress + a live parallel-population panel to Streamlit."""
@@ -447,20 +546,34 @@ def on_epoch_end(self, state: CallbackState) -> None:
447546

448547
# Compact "active hyper-params" chip row, so the user can see what is
449548
# about to run without scrolling the sidebar.
450-
render_config_chips(
451-
cfg,
452-
extras={
453-
"sol_size": sol_size,
454-
"epochs": epochs,
455-
"lr": f"{learning_rate:.2g}",
456-
"T": f"{temp:.2g}",
457-
"polish": "on" if polish else "off",
458-
"warm-start": "on" if warm_start else "off",
459-
},
460-
)
549+
if backend == "PQQA":
550+
render_config_chips(
551+
cfg,
552+
extras={
553+
"sol_size": sol_size,
554+
"epochs": epochs,
555+
"lr": f"{learning_rate:.2g}",
556+
"T": f"{temp:.2g}",
557+
"polish": "on" if polish else "off",
558+
"warm-start": "on" if warm_start else "off",
559+
},
560+
)
561+
else:
562+
render_config_chips(
563+
cfg,
564+
extras={
565+
"backend": "PA",
566+
"R": pa_sol_size,
567+
"T": pa_num_temps,
568+
"K": pa_sweeps_per_temp,
569+
"β": f"{pa_beta_start:.2g}{pa_beta_end:.2g}",
570+
"resample": pa_resample,
571+
},
572+
)
461573

462-
run = st.button("▶ Run QQA", type="primary", width="stretch")
463-
if run:
574+
run_label = "▶ Run QQA" if backend == "PQQA" else "▶ Run Population Annealing"
575+
run = st.button(run_label, type="primary", width="stretch")
576+
if run and backend == "PQQA":
464577
try:
465578
problem = build_problem(cfg)
466579
except Exception as e:
@@ -579,4 +692,167 @@ def on_epoch_end(self, state: CallbackState) -> None:
579692
st.info("Open **Visualize** for deeper inspection of this run (history, PCA, ridgeline).")
580693

581694

695+
# =============================================================================
696+
# Population Annealing (PA) backend
697+
# =============================================================================
698+
if run and backend != "PQQA":
699+
try:
700+
problem = build_problem(cfg)
701+
except Exception as e:
702+
st.error(f"Could not build problem: {e}")
703+
st.stop()
704+
705+
progress = st.progress(0.0)
706+
metrics = st.empty()
707+
score_holder = st.empty()
708+
chart = st.empty()
709+
free_energy_holder = st.empty()
710+
711+
pa_loss_mean: list[float] = []
712+
pa_best: list[float] = []
713+
pa_ess: list[float] = []
714+
pa_steps: list[int] = []
715+
716+
pa_total_steps = int(pa_num_temps)
717+
pa_update_every = max(1, pa_total_steps // 50)
718+
pa_t0 = time.time()
719+
720+
def _pa_callback(step: int, mean_loss: float, best_obj: float, ess: float) -> None:
721+
pa_steps.append(step)
722+
pa_loss_mean.append(float(mean_loss))
723+
pa_best.append(float(best_obj))
724+
pa_ess.append(float(ess))
725+
if step % pa_update_every != 0 and step != pa_total_steps - 1:
726+
return
727+
progress.progress(min(1.0, (step + 1) / pa_total_steps))
728+
elapsed = time.time() - pa_t0
729+
with metrics.container():
730+
r1, r2, r3, r4 = st.columns(4)
731+
r1.metric("step", f"{step + 1} / {pa_total_steps}")
732+
r2.metric("best", f"{best_obj:.4g}")
733+
r3.metric("mean", f"{mean_loss:.4g}")
734+
r4.metric("ESS", f"{ess:.1f} / {int(pa_sol_size)}")
735+
r5, r6, r7, r8 = st.columns(4)
736+
r5.metric("elapsed", f"{elapsed:.1f}s")
737+
r6.metric("R", f"{int(pa_sol_size)}")
738+
r7.metric("K", f"{int(pa_sweeps_per_temp)}")
739+
r8.metric("backend", "PA")
740+
# Live convergence plot
741+
p = palette()
742+
fig = go.Figure()
743+
fig.add_trace(
744+
go.Scatter(
745+
x=pa_steps,
746+
y=pa_loss_mean,
747+
mode="lines",
748+
name="mean / replica",
749+
line={"color": p["palette"][0], "width": 2},
750+
)
751+
)
752+
fig.add_trace(
753+
go.Scatter(
754+
x=pa_steps,
755+
y=pa_best,
756+
mode="lines",
757+
name="best so far",
758+
line={"color": p["palette"][1], "width": 2.4},
759+
)
760+
)
761+
fig.add_trace(
762+
go.Scatter(
763+
x=pa_steps,
764+
y=pa_ess,
765+
mode="lines",
766+
yaxis="y2",
767+
name="ESS",
768+
line={"color": p["palette"][2], "width": 1.5, "dash": "dot"},
769+
)
770+
)
771+
fig.update_layout(
772+
**plotly_layout(
773+
height=360,
774+
title={"text": "PA dynamics (loss + ESS)"},
775+
xaxis_title="Temperature step",
776+
yaxis_title="Loss",
777+
yaxis2={"overlaying": "y", "side": "right", "title": "ESS"},
778+
legend={"x": 0.01, "y": 0.99},
779+
)
780+
)
781+
chart.plotly_chart(fig, width="stretch", theme=None, config={"displayModeBar": False})
782+
783+
try:
784+
pa_result = qqa.population_annealing(
785+
problem,
786+
sol_size=int(pa_sol_size),
787+
num_temps=int(pa_num_temps),
788+
sweeps_per_temp=int(pa_sweeps_per_temp),
789+
beta_schedule=pa_beta_schedule,
790+
beta_start=float(pa_beta_start),
791+
beta_end=float(pa_beta_end),
792+
resample=pa_resample,
793+
device=cfg["device"],
794+
record_genealogy=True,
795+
verbose=False,
796+
callback=_pa_callback,
797+
)
798+
except Exception as e:
799+
st.error(f"PA run failed: {e}")
800+
st.stop()
801+
802+
progress.empty()
803+
raw = float(pa_result.best_obj)
804+
with score_holder.container():
805+
render_score_card(pa_result.score, raw_loss=raw)
806+
807+
# Free-energy density curve
808+
if pa_result.history.get("beta") and pa_result.history.get("free_energy_density"):
809+
p = palette()
810+
fig_f = go.Figure()
811+
fig_f.add_trace(
812+
go.Scatter(
813+
x=pa_result.history["beta"],
814+
y=pa_result.history["free_energy_density"],
815+
mode="lines+markers",
816+
line={"color": p["palette"][3 % len(p["palette"])], "width": 2.4},
817+
name="F(β)/N",
818+
)
819+
)
820+
fig_f.update_layout(
821+
**plotly_layout(
822+
height=320,
823+
title={"text": "Free-energy density estimate (Hukushima–Iba)"},
824+
xaxis_title="β (inverse temperature)",
825+
yaxis_title="F(β) / N",
826+
xaxis={"type": "log"} if pa_beta_schedule == "geometric" else None,
827+
)
828+
)
829+
free_energy_holder.plotly_chart(
830+
fig_f, width="stretch", theme=None, config={"displayModeBar": False}
831+
)
832+
st.caption(
833+
f"PA estimate at β={float(pa_beta_end):.3g}: "
834+
f"**F/N = {pa_result.free_energy_density:.4f}**, "
835+
f"ln Z = {pa_result.log_z:.3f}. The estimator uses the "
836+
"average unnormalised reweighting factor at every annealing "
837+
"step, so the resampling correction is implicit."
838+
)
839+
840+
st.session_state["last_result"] = pa_result
841+
st.session_state["last_problem"] = problem
842+
st.session_state["last_pop_tracker"] = None # PA stores its own population
843+
st.session_state["last_pa_result"] = pa_result
844+
845+
st.markdown("### Solution")
846+
st.caption(
847+
"Problem-aware view of the best configuration PA found. The "
848+
"**Visualize** page shows the full equilibrium population, ESS "
849+
"history, free-energy curve, and a resampling family tree."
850+
)
851+
render_solution_view(problem, pa_result, cfg)
852+
st.info(
853+
"Open **Visualize** to see the equilibrium population, ESS over β, "
854+
"the free-energy density curve, and the resampling family tree."
855+
)
856+
857+
582858
paper_link_footer()

0 commit comments

Comments
 (0)