|
59 | 59 | tab_sched, |
60 | 60 | tab_pop, |
61 | 61 | tab_pca, |
| 62 | + tab_div, |
| 63 | + tab_spec, |
62 | 64 | tab_ridge, |
63 | 65 | tab_fate, |
64 | 66 | ) = st.tabs( |
|
68 | 70 | "Best trajectory", |
69 | 71 | "Schedule", |
70 | 72 | "Parallel population", |
71 | | - "PCA trajectory", |
| 73 | + "3D PCA flow", |
| 74 | + "Diversity", |
| 75 | + "Loss spectrogram", |
72 | 76 | "Ridgeline", |
73 | 77 | "Replica fate", |
74 | 78 | ] |
@@ -145,15 +149,250 @@ def _retheme(fig): |
145 | 149 | st.info("No x-snapshots recorded (population_tracker requires record_x=True).") |
146 | 150 | else: |
147 | 151 | 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") |
150 | 254 | 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." |
153 | 260 | ) |
| 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") |
154 | 264 | except Exception as e: |
155 | 265 | st.info(f"PCA could not be computed: {e}") |
156 | 266 |
|
| 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 | + |
157 | 396 | with tab_ridge: |
158 | 397 | if pop_tracker is None or not pop_tracker.loss: |
159 | 398 | st.info("No population snapshots recorded for this run.") |
|
0 commit comments