|
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 | +""" |
2 | 13 |
|
3 | 14 | from __future__ import annotations |
4 | 15 |
|
|
52 | 63 | cfg = st.session_state["problem_config"] |
53 | 64 | render_config_chips(cfg) |
54 | 65 |
|
| 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 | + |
55 | 81 | # Hyper-parameter presets — each tuple is |
56 | 82 | # (sol_size, epochs, learning_rate, temp, min_bg, max_bg, curve_rate, |
57 | 83 | # div_param, update_every). |
@@ -81,7 +107,12 @@ def _apply_preset(name: str) -> None: |
81 | 107 |
|
82 | 108 |
|
83 | 109 | 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 | + ) |
85 | 116 | preset_name = st.radio( |
86 | 117 | "Preset", |
87 | 118 | list(_PRESETS), |
@@ -208,6 +239,74 @@ def _apply_preset(name: str) -> None: |
208 | 239 | help="Lower = smoother animation but slower wall-clock; higher = faster.", |
209 | 240 | ) |
210 | 241 |
|
| 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 | + |
211 | 310 |
|
212 | 311 | class StreamlitCallback(Callback): |
213 | 312 | """Stream QQA progress + a live parallel-population panel to Streamlit.""" |
@@ -447,20 +546,34 @@ def on_epoch_end(self, state: CallbackState) -> None: |
447 | 546 |
|
448 | 547 | # Compact "active hyper-params" chip row, so the user can see what is |
449 | 548 | # 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 | + ) |
461 | 573 |
|
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": |
464 | 577 | try: |
465 | 578 | problem = build_problem(cfg) |
466 | 579 | except Exception as e: |
@@ -579,4 +692,167 @@ def on_epoch_end(self, state: CallbackState) -> None: |
579 | 692 | st.info("Open **Visualize** for deeper inspection of this run (history, PCA, ridgeline).") |
580 | 693 |
|
581 | 694 |
|
| 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 | + |
582 | 858 | paper_link_footer() |
0 commit comments