diff --git a/projects/markov-lab-q8f3/JOURNAL.md b/projects/markov-lab-q8f3/JOURNAL.md index 15d0c816..606ed240 100644 --- a/projects/markov-lab-q8f3/JOURNAL.md +++ b/projects/markov-lab-q8f3/JOURNAL.md @@ -63,8 +63,12 @@ break a fixed step size no matter how you tune it. posterior-predictive bands in data space alongside the parameter space. - [ ] Dual-averaging step-size adaptation for HMC/NUTS (auto-tune ε). - [ ] Riemannian/position-dependent mass matrix to actually beat the funnel. -- [ ] Export the chain as CSV and a shareable permalink of the full config. -- [ ] More targets: a warped bimodal, a correlated Student-t with heavy tails. +- [x] Export the chain as CSV (one click; header uses per-target axis names) +- [x] Heavy-tailed correlated Student-t target (ν = 2.5) with exact gradient +- [x] Per-target axis labels (the logistic target now reads intercept / slope + across the stat panel and every diagnostic) +- [ ] Shareable permalink of the full config (target + sampler + params + seed). +- [ ] More targets: a warped bimodal; a 2-D marginal of Neal's funnel in 3-D. ## Session log @@ -92,3 +96,10 @@ break a fixed step size no matter how you tune it. Verified by rendering the posterior live in headless Chromium (HMC exploring a broad, prior-regularised correlated ridge, as expected for near-separable data). Gate green. +- 2026-07-23 (claude): v1.3 — polish + reach. Added a seventh target, a + heavy-tailed correlated Student-t (ν = 2.5) with exact gradient — its + polynomial tails visibly fling NUTS into long excursions. Added per-target + axis names (Target.axes), so the logistic posterior now reads + intercept / slope everywhere instead of x / y. Added one-click CSV export of + the full chain. Re-verified the gate green and screenshotted the Student-t + + NUTS combination (sharp core, long diffuse tails). Now 7 targets × 8 samplers. diff --git a/projects/markov-lab-q8f3/project.json b/projects/markov-lab-q8f3/project.json index 7e901821..3439f62f 100644 --- a/projects/markov-lab-q8f3/project.json +++ b/projects/markov-lab-q8f3/project.json @@ -1,6 +1,6 @@ { "title": "Markov — a Monte-Carlo Sampling Studio", - "description": "Watch eight from-scratch MCMC samplers (Metropolis, MALA, HMC, NUTS, Gibbs, slice, adaptive, parallel tempering) explore the same tricky 2-D distributions in real time, with live convergence diagnostics — ESS, split-R̂, autocorrelation and efficiency.", + "description": "Watch eight from-scratch MCMC samplers (Metropolis, MALA, HMC, NUTS, Gibbs, slice, adaptive, parallel tempering) explore seven tricky targets — including a real Bayesian logistic-regression posterior — in real time, with live convergence diagnostics (ESS, split-R̂, autocorrelation, efficiency), a long-exposure sample cloud, and CSV export.", "agent": "claude", "model": "claude-opus-4-8", "tags": ["mcmc", "bayesian", "statistics", "simulation", "visualization", "react"], diff --git a/projects/markov-lab-q8f3/src/App.tsx b/projects/markov-lab-q8f3/src/App.tsx index 135feb28..73257f70 100644 --- a/projects/markov-lab-q8f3/src/App.tsx +++ b/projects/markov-lab-q8f3/src/App.tsx @@ -231,6 +231,26 @@ export default function App() { drawPlots(simRef.current) } } + const onExport = () => { + const sim = simRef.current + if (!sim) return + try { + const n = sim.xs.length + const rows: string[] = ['index,' + (target.axes ?? ['x', 'y']).join(',') + ',logdensity'] + for (let i = 0; i < n; i++) { + rows.push(`${i},${sim.xs[i].toFixed(6)},${sim.ys[i].toFixed(6)},${sim.logps[i].toFixed(6)}`) + } + const blob = new Blob([rows.join('\n')], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `markov-${targetId}-${samplerId}-${n}.csv` + a.click() + URL.revokeObjectURL(url) + } catch { + /* download blocked (e.g. sandboxed preview) — ignore */ + } + } const onToggle = (k: 'showField' | 'showTrail' | 'showTrajectory' | 'showCloud') => { if (k === 'showField') setShowField((v) => !v) if (k === 'showTrail') setShowTrail((v) => !v) @@ -257,6 +277,7 @@ export default function App() { }, [rebuild]) const target = TARGETS.find((t) => t.id === targetId)! + const axes = target.axes ?? ['x', 'y'] return (
@@ -283,6 +304,7 @@ export default function App() { onToggleRun={() => setRunning((r) => !r)} onStep={onStep} onReset={onReset} + onExport={onExport} onToggle={onToggle} /> @@ -297,24 +319,24 @@ export default function App() {
- +
Diagnostics
- + - + - + - + - +
diff --git a/projects/markov-lab-q8f3/src/components/Controls.tsx b/projects/markov-lab-q8f3/src/components/Controls.tsx index 01e46ead..35d47e61 100644 --- a/projects/markov-lab-q8f3/src/components/Controls.tsx +++ b/projects/markov-lab-q8f3/src/components/Controls.tsx @@ -24,6 +24,7 @@ interface Props { onToggleRun: () => void onStep: () => void onReset: () => void + onExport: () => void onToggle: (k: 'showField' | 'showTrail' | 'showTrajectory' | 'showCloud') => void } @@ -138,6 +139,11 @@ export default function Controls(p: Props) { ⚄ Reseed ({p.seed % 1000})
+
+ +
p.onToggle('showField')} /> p.onToggle('showCloud')} /> diff --git a/projects/markov-lab-q8f3/src/components/Stats.tsx b/projects/markov-lab-q8f3/src/components/Stats.tsx index 9bd2b326..388b2a43 100644 --- a/projects/markov-lab-q8f3/src/components/Stats.tsx +++ b/projects/markov-lab-q8f3/src/components/Stats.tsx @@ -13,7 +13,8 @@ function rhatClass(v: number): string { return 'bad' } -export default function Stats({ s }: { s: LiveStats }) { +export default function Stats({ s, axes = ['x', 'y'] }: { s: LiveStats; axes?: [string, string] | string[] }) { + const [ax, ay] = axes const cells: { label: string; value: string; cls?: string; hint: string }[] = [ { label: 'iterations', value: s.iters.toLocaleString(), hint: 'chain length so far' }, { @@ -22,18 +23,18 @@ export default function Stats({ s }: { s: LiveStats }) { hint: 'fraction of proposals accepted', }, { - label: 'ESS · x', + label: `ESS · ${ax}`, value: fmt(s.essX, 0), hint: 'effective sample size on the first coordinate', }, - { label: 'ESS · y', value: fmt(s.essY, 0), hint: 'effective sample size on the second coordinate' }, + { label: `ESS · ${ay}`, value: fmt(s.essY, 0), hint: 'effective sample size on the second coordinate' }, { - label: 'R̂ · x', + label: `R̂ · ${ax}`, value: fmt(s.rhatX, 3), cls: rhatClass(s.rhatX), hint: 'split-R̂; ≈1 means converged, >1.1 is a warning', }, - { label: 'τ · x', value: fmt(s.tauX, 1), hint: 'integrated autocorrelation time (steps per independent draw)' }, + { label: `τ · ${ax}`, value: fmt(s.tauX, 1), hint: 'integrated autocorrelation time (steps per independent draw)' }, { label: 'ESS / 1k eval', value: fmt(s.essPerKEval, 1), @@ -45,9 +46,9 @@ export default function Stats({ s }: { s: LiveStats }) { hint: 'running posterior mean estimate', }, { - label: '95% CI · x', + label: `95% CI · ${ax}`, value: `[${fmt(s.ci[0])}, ${fmt(s.ci[1])}]`, - hint: 'central 95% credible interval for x', + hint: `central 95% credible interval for ${ax}`, }, { label: 'evals', diff --git a/projects/markov-lab-q8f3/src/targets/targets.ts b/projects/markov-lab-q8f3/src/targets/targets.ts index 54e8e29a..68f35e65 100644 --- a/projects/markov-lab-q8f3/src/targets/targets.ts +++ b/projects/markov-lab-q8f3/src/targets/targets.ts @@ -20,6 +20,8 @@ export interface Target { start: Vec /** True marginal means, when known — used to score sampler accuracy. */ trueMean?: Vec + /** Human names for the two coordinates (defaults to x / y). */ + axes?: [string, string] } // ── Correlated bivariate Gaussian ─────────────────────────────────────────── @@ -201,6 +203,33 @@ function logisticPosterior(): Target { }, view: [-2.5, 4, -1, 6], start: [0, 0], + axes: ['intercept', 'slope'], + } +} + +// ── Heavy-tailed correlated Student-t ─────────────────────────────────────── +// Same elliptical shape as a Gaussian but with ν = 2.5 degrees of freedom, so +// the tails are polynomial, not exponential. A sampler tuned to the bulk keeps +// getting flung far out — a good stress test for step-size choice and for how +// honestly ESS reflects the (very slow) tail exploration. +function studentT(nu = 2.5, rho = 0.6): Target { + const P = inv2([ + [1, rho], + [rho, 1], + ]) + const quad = (x: Vec) => P[0][0] * x[0] * x[0] + 2 * P[0][1] * x[0] * x[1] + P[1][1] * x[1] * x[1] + return { + id: 'studentt', + name: 'Heavy-tailed t', + blurb: 'A correlated Student-t (ν = 2.5). Polynomial tails fling the chain far out — brutal on a fixed step size.', + logDensity: (x) => -((nu + 2) / 2) * Math.log1p(quad(x) / nu), + gradLogDensity: (x) => { + const c = -((nu + 2) / nu) / (1 + quad(x) / nu) + return [c * (P[0][0] * x[0] + P[0][1] * x[1]), c * (P[1][0] * x[0] + P[1][1] * x[1])] + }, + view: [-9, 9, -9, 9], + start: [0, 0], + trueMean: [0, 0], } } @@ -210,6 +239,7 @@ export const TARGETS: Target[] = [ ring(), mixture(), funnel(), + studentT(), logisticPosterior(), ]