Skip to content

Commit f587584

Browse files
committed
ui(viz): 3D PCA flow + diversity + loss-spectrogram tabs
Replace the flat 2D PCA tab with a Plotly Scatter3d "PCA flow" that overlays per-replica trajectories, epoch-coloured snapshot scatter and a final-loss-coloured terminal cloud. The 2D fallback stays available inside an expander. Add two adjacent tabs that visualise the same population dynamics from complementary angles: - "Diversity": loss-σ across replicas + median−min loss + (when ``x`` is recorded) genotypic variance ⟨Var_b x⟩ on a secondary axis. - "Loss spectrogram": row-normalised histogram heatmap of replica losses per epoch, exposing convergence as a sharpening horizontal stripe. Wire a GUI smoke test that exercises all three new tabs through a real PopulationTracker run. Made-with: Cursor
1 parent d69f1ba commit f587584

2 files changed

Lines changed: 280 additions & 5 deletions

File tree

app/pages/2_Visualize.py

Lines changed: 244 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
tab_sched,
6060
tab_pop,
6161
tab_pca,
62+
tab_div,
63+
tab_spec,
6264
tab_ridge,
6365
tab_fate,
6466
) = st.tabs(
@@ -68,7 +70,9 @@
6870
"Best trajectory",
6971
"Schedule",
7072
"Parallel population",
71-
"PCA trajectory",
73+
"3D PCA flow",
74+
"Diversity",
75+
"Loss spectrogram",
7276
"Ridgeline",
7377
"Replica fate",
7478
]
@@ -145,15 +149,250 @@ def _retheme(fig):
145149
st.info("No x-snapshots recorded (population_tracker requires record_x=True).")
146150
else:
147151
try:
148-
fig = viz.plot_population_embedding(pop_tracker, backend="plotly", show=False)
149-
st.plotly_chart(_retheme(fig), width="stretch")
152+
import plotly.graph_objects as go
153+
154+
xs = pop_tracker.x # list[T] of (B, ...) ndarrays
155+
epochs = np.asarray(pop_tracker.epochs)
156+
X = np.stack([np.asarray(snap).reshape(snap.shape[0], -1) for snap in xs], axis=0)
157+
# X: (T, B, D). Stack into a flat (T*B, D) cloud and run a
158+
# truncated SVD so PCA-3D scales to N≈10⁴ variables comfortably.
159+
T_, B, D = X.shape
160+
flat = X.reshape(T_ * B, D)
161+
mean = flat.mean(axis=0, keepdims=True)
162+
flat0 = flat - mean
163+
ncomp = min(3, flat0.shape[1], flat0.shape[0])
164+
try:
165+
# Faster than np.linalg.svd for tall matrices.
166+
_, _, vt = np.linalg.svd(flat0, full_matrices=False)
167+
except np.linalg.LinAlgError:
168+
vt = np.eye(D)[:ncomp]
169+
comps = vt[:ncomp].T # (D, ncomp)
170+
proj = (flat0 @ comps).reshape(T_, B, ncomp) # (T, B, 3)
171+
if ncomp < 3:
172+
pad = np.zeros((T_, B, 3 - ncomp))
173+
proj = np.concatenate([proj, pad], axis=-1)
174+
175+
final_loss = np.asarray(pop_tracker.loss[-1])
176+
cmin = float(final_loss.min())
177+
cmax = float(final_loss.max())
178+
p = palette()
179+
fig = go.Figure()
180+
# One faint trajectory per replica.
181+
stride = max(1, B // 64) # cap visible lines for readability
182+
for i in range(0, B, stride):
183+
fig.add_trace(
184+
go.Scatter3d(
185+
x=proj[:, i, 0],
186+
y=proj[:, i, 1],
187+
z=proj[:, i, 2],
188+
mode="lines",
189+
line={"color": p["muted"], "width": 1.5},
190+
opacity=0.18,
191+
showlegend=False,
192+
hoverinfo="skip",
193+
)
194+
)
195+
# Snapshot scatter coloured by epoch — gives the "flow" feel.
196+
for k in range(T_):
197+
fig.add_trace(
198+
go.Scatter3d(
199+
x=proj[k, :, 0],
200+
y=proj[k, :, 1],
201+
z=proj[k, :, 2],
202+
mode="markers",
203+
marker={
204+
"size": 2.5,
205+
"color": [int(epochs[k])] * B,
206+
"colorscale": "Viridis",
207+
"cmin": int(epochs.min()),
208+
"cmax": int(epochs.max()),
209+
"showscale": k == T_ - 1,
210+
"colorbar": {"title": "epoch"} if k == T_ - 1 else None,
211+
"opacity": 0.55,
212+
},
213+
showlegend=False,
214+
name=f"epoch {int(epochs[k])}",
215+
hovertemplate=f"epoch {int(epochs[k])}<br>PC1=%{{x:.3f}}, PC2=%{{y:.3f}}, PC3=%{{z:.3f}}<extra></extra>",
216+
)
217+
)
218+
# Overlay final population coloured by per-replica final loss.
219+
fig.add_trace(
220+
go.Scatter3d(
221+
x=proj[-1, :, 0],
222+
y=proj[-1, :, 1],
223+
z=proj[-1, :, 2],
224+
mode="markers",
225+
marker={
226+
"size": 4.2,
227+
"color": final_loss,
228+
"colorscale": "Plasma",
229+
"cmin": cmin,
230+
"cmax": cmax,
231+
"showscale": True,
232+
"colorbar": {"title": "final loss", "x": 1.12},
233+
"line": {"color": "white", "width": 0.6},
234+
},
235+
name="final",
236+
showlegend=False,
237+
hovertemplate="final<br>loss=%{marker.color:.4f}<extra></extra>",
238+
)
239+
)
240+
fig.update_layout(
241+
**plotly_layout(
242+
title={"text": "3D PCA flow of the parallel population"},
243+
height=620,
244+
showlegend=False,
245+
)
246+
)
247+
fig.update_scenes(
248+
xaxis_title="PC1",
249+
yaxis_title="PC2",
250+
zaxis_title="PC3",
251+
bgcolor=palette()["surface"],
252+
)
253+
st.plotly_chart(fig, width="stretch")
150254
st.caption(
151-
"2D PCA projection of the entire continuous-variable population over time. "
152-
"Each faint grey line is one replica's trajectory; markers are coloured by epoch."
255+
"Truncated-SVD PCA (3 components) of the continuous-variable "
256+
"population. Each grey thread is one replica's trajectory; "
257+
"snapshot dots are coloured by epoch (Viridis); the final "
258+
"population is overlaid in Plasma by per-replica final loss. "
259+
"Drag to rotate."
153260
)
261+
with st.expander("Show 2D projection", expanded=False):
262+
fig2 = viz.plot_population_embedding(pop_tracker, backend="plotly", show=False)
263+
st.plotly_chart(_retheme(fig2), width="stretch")
154264
except Exception as e:
155265
st.info(f"PCA could not be computed: {e}")
156266

267+
with tab_div:
268+
if pop_tracker is None or not pop_tracker.loss:
269+
st.info("No population snapshots recorded for this run.")
270+
else:
271+
import plotly.graph_objects as go
272+
273+
p = palette()
274+
epochs = np.asarray(pop_tracker.epochs)
275+
loss_mat = np.stack(pop_tracker.loss, axis=0) # (T, B)
276+
# Loss spread = std over replicas — collapses to ~0 once the
277+
# population converges.
278+
loss_std = loss_mat.std(axis=1)
279+
loss_min = loss_mat.min(axis=1)
280+
loss_med = np.median(loss_mat, axis=1)
281+
282+
# Genotypic diversity: mean pairwise variance of the projected
283+
# bits. Cheap proxy for Hamming distance and works for
284+
# categorical / spin variables alike. Falls back gracefully
285+
# when ``record_x`` was off.
286+
geno_div: np.ndarray | None = None
287+
if pop_tracker.x:
288+
try:
289+
xs = [np.asarray(s).reshape(s.shape[0], -1) for s in pop_tracker.x]
290+
geno_div = np.array([s.var(axis=0).mean() for s in xs])
291+
except Exception:
292+
geno_div = None
293+
294+
fig = go.Figure()
295+
fig.add_trace(
296+
go.Scatter(
297+
x=epochs,
298+
y=loss_std,
299+
mode="lines",
300+
line={"color": p["palette"][0], "width": 2.4},
301+
name="loss σ across replicas",
302+
)
303+
)
304+
fig.add_trace(
305+
go.Scatter(
306+
x=epochs,
307+
y=loss_med - loss_min,
308+
mode="lines",
309+
line={"color": p["palette"][1], "width": 2.0, "dash": "dot"},
310+
name="median − min loss",
311+
)
312+
)
313+
if geno_div is not None and geno_div.size == epochs.size:
314+
fig.add_trace(
315+
go.Scatter(
316+
x=epochs,
317+
y=geno_div,
318+
mode="lines",
319+
line={
320+
"color": p["palette"][2 % len(p["palette"])],
321+
"width": 2.0,
322+
},
323+
name="genotypic variance ⟨Var_b x⟩",
324+
yaxis="y2",
325+
)
326+
)
327+
layout_kwargs = plotly_layout(
328+
title={"text": "Population diversity over time"},
329+
xaxis_title="Epoch",
330+
yaxis_title="Loss spread",
331+
height=420,
332+
)
333+
if geno_div is not None and geno_div.size == epochs.size:
334+
layout_kwargs["yaxis2"] = {
335+
"overlaying": "y",
336+
"side": "right",
337+
"title": "⟨Var_b x⟩",
338+
"showgrid": False,
339+
}
340+
fig.update_layout(**layout_kwargs)
341+
st.plotly_chart(fig, width="stretch")
342+
st.caption(
343+
"Tracks how quickly the parallel population collapses. "
344+
"Healthy CRA / repulsion runs keep the genotypic variance "
345+
"above zero for longer."
346+
)
347+
348+
with tab_spec:
349+
if pop_tracker is None or not pop_tracker.loss:
350+
st.info("No population snapshots recorded for this run.")
351+
else:
352+
import plotly.graph_objects as go
353+
354+
epochs = np.asarray(pop_tracker.epochs)
355+
loss_mat = np.stack(pop_tracker.loss, axis=0) # (T, B)
356+
# Per-row histogram → (T, n_bins) heatmap.
357+
n_bins = 64
358+
lo = float(loss_mat.min())
359+
hi = float(loss_mat.max())
360+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
361+
hi = lo + 1.0
362+
edges = np.linspace(lo, hi, n_bins + 1)
363+
spec = np.zeros((len(epochs), n_bins), dtype=float)
364+
for t in range(len(epochs)):
365+
h, _ = np.histogram(loss_mat[t], bins=edges)
366+
spec[t] = h
367+
# Row-normalise to keep the colour scale stable when sol_size is large.
368+
row_max = spec.max(axis=1, keepdims=True)
369+
row_max[row_max == 0] = 1.0
370+
spec = spec / row_max
371+
centres = 0.5 * (edges[:-1] + edges[1:])
372+
fig = go.Figure(
373+
go.Heatmap(
374+
z=spec.T,
375+
x=epochs,
376+
y=centres,
377+
colorscale="Magma",
378+
colorbar={"title": "density"},
379+
hovertemplate="epoch %{x}<br>loss %{y:.4f}<br>p=%{z:.2f}<extra></extra>",
380+
)
381+
)
382+
fig.update_layout(
383+
**plotly_layout(
384+
title={"text": "Loss-distribution spectrogram"},
385+
xaxis_title="Epoch",
386+
yaxis_title="Loss",
387+
height=460,
388+
)
389+
)
390+
st.plotly_chart(fig, width="stretch")
391+
st.caption(
392+
"Row-normalised histogram of replica losses at each recorded "
393+
"epoch. A sharp horizontal stripe at the bottom = converged."
394+
)
395+
157396
with tab_ridge:
158397
if pop_tracker is None or not pop_tracker.loss:
159398
st.info("No population snapshots recorded for this run.")

tests/test_gui_apptest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,42 @@ def test_visualize_tab_order_solution_first(tmp_path):
256256
assert tab_labels[0] == "Solution", f"Solution must be the first tab (got order: {tab_labels})"
257257

258258

259+
def test_visualize_renders_3d_pca_and_diversity_with_population(tmp_path):
260+
"""The new "3D PCA flow", "Diversity" and "Loss spectrogram" tabs must
261+
render without exception when a PopulationTracker is attached."""
262+
import sys
263+
264+
sys.path.insert(0, str(APP.parent))
265+
from _common import build_problem as _build # noqa: F811
266+
267+
import qqa # noqa: F811
268+
from qqa.callbacks import PopulationTracker # noqa: F811
269+
270+
cfg = {"kind": "mis", "size": 16, "seed": 0, "device": "cpu", "extra": {}}
271+
problem = _build(cfg)
272+
tracker = PopulationTracker(stride=2, record_x=True)
273+
result = qqa.anneal(
274+
problem,
275+
sol_size=8,
276+
num_epochs=12,
277+
learning_rate=0.1,
278+
device="cpu",
279+
verbose=False,
280+
callbacks=[tracker],
281+
)
282+
283+
at = AppTest.from_file(str(PAGE_DIR / "2_Visualize.py"), default_timeout=60)
284+
at.session_state["last_result"] = result
285+
at.session_state["last_problem"] = problem
286+
at.session_state["last_pop_tracker"] = tracker
287+
at.session_state["problem_config"] = cfg
288+
at.run()
289+
assert not at.exception, at.exception
290+
labels = [t.label for t in at.tabs]
291+
for required in ("3D PCA flow", "Diversity", "Loss spectrogram"):
292+
assert required in labels, f"missing tab {required!r} (got {labels})"
293+
294+
259295
def test_solve_dynamics_separates_discrete_and_relaxed_best():
260296
"""Regression: the per-replica chart used to plot the running discrete
261297
best (``state.best_obj``) on the same y-axis as the relaxed mean. For

0 commit comments

Comments
 (0)