Central Limit Theorem
Means forget where they came from.
Beginner / probability / statistics / convergence
Draw n values from a uniform, triangular, or bimodal source, record their mean, and repeat. However lopsided the source, the histogram of means tightens onto a Gaussian whose spread shrinks like sigma over root n - and the empirical sigma on the metrics strip checks the prediction live.
Tagged: CLT, Gaussian, sampling, convergence
Live simulation
Raise n and the histogram snaps onto the predicted Gaussian faster. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Raise n and the histogram snaps onto the predicted Gaussian faster.
Mathematics
Take n independent draws from any distribution with finite variance and average them. The CLT says the distribution of that average approaches a Gaussian with the source's mean and variance divided by n - regardless of the source's shape.
X̄ₙ → Normal(μ, σ²/n) as n → ∞
predicted spread: σ_X̄ = σ / √n
Welford: s² = M2 / (N − 1), M2 += δ (x − μ_new)
The simulation measures rather than asserts: the source's variance is characterized empirically at reset, the running spread of the means is computed online by Welford's algorithm, and the two numbers sit side by side in the metrics. Switch to the bimodal source with n = 1 and you are sampling the raw distribution; push n up and watch the bimodality vanish within a few hundred bins.
How it works
Each tick draws many n-sample means from the seeded source stream into a 64-bin histogram over [0, 1]. The Gaussian overlay is the CLT prediction scaled to the histogram's own scale; the source outline is a cached empirical probe for contrast.
Reset replays the identical stream for the same seed - convergence is deterministic here, which makes the empirical-versus-predicted comparison meaningful.
Implementation
src/engine/experiments/central-limit.ts · 187 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | const BINS = 64; |
| 5 | /** Seeded source draws so every reset replays the same stream. */ |
| 6 | type Source = (rng: () => number) => number; |
| 7 | |
| 8 | const SOURCES: Record<string, Source> = { |
| 9 | uniform: (rng) => rng(), |
| 10 | triangular: (rng) => (rng() + rng()) / 2, |
| 11 | bimodal: (rng) => (rng() < 0.5 ? 0.05 + rng() * 0.25 : 0.7 + rng() * 0.25), |
| 12 | }; |
| 13 | |
| 14 | /** |
| 15 | * Central Limit Theorem, observed rather than asserted: draw n values from a |
| 16 | * (non-normal) source, record their mean, repeat. The running histogram of |
| 17 | * means converges to the Gaussian the theorem predicts, with the empirical |
| 18 | * sigma checked against sigma/sqrt(n) live. |
| 19 | */ |
| 20 | export class CentralLimitExperiment extends BaseExperiment { |
| 21 | readonly id = "central-limit-theorem"; |
| 22 | |
| 23 | private bins = new Float64Array(BINS); |
| 24 | private meansCount = 0; |
| 25 | private samplesCount = 0; |
| 26 | private meanOfMeans = 0; |
| 27 | private m2 = 0; // Welford |
| 28 | private sourceMean = 0; |
| 29 | private sourceVar = 0; |
| 30 | private pointer = { x: 0, y: 0, down: false, inside: false }; |
| 31 | |
| 32 | protected params(): ParameterDef[] { |
| 33 | return [ |
| 34 | { key: "n", label: "Sample size n", min: 1, max: 60, step: 1, defaultValue: 5 }, |
| 35 | { key: "rate", label: "Means per tick", min: 10, max: 2000, step: 10, defaultValue: 200 }, |
| 36 | { |
| 37 | key: "source", |
| 38 | label: "Source distribution", |
| 39 | options: [ |
| 40 | { value: "uniform", label: "Uniform(0, 1)" }, |
| 41 | { value: "triangular", label: "Triangular" }, |
| 42 | { value: "bimodal", label: "Bimodal" }, |
| 43 | ], |
| 44 | defaultValue: "bimodal", |
| 45 | }, |
| 46 | ]; |
| 47 | } |
| 48 | |
| 49 | protected onReset(): void { |
| 50 | this.bins.fill(0); |
| 51 | this.meansCount = 0; |
| 52 | this.samplesCount = 0; |
| 53 | this.meanOfMeans = 0; |
| 54 | this.m2 = 0; |
| 55 | // Characterize the source empirically (100k draws) instead of hardcoding |
| 56 | // moments per distribution. |
| 57 | const source = SOURCES[this.str("source")]; |
| 58 | let sum = 0; |
| 59 | let sum2 = 0; |
| 60 | const draws = 100_000; |
| 61 | for (let i = 0; i < draws; i++) { |
| 62 | const v = source(this.rng); |
| 63 | sum += v; |
| 64 | sum2 += v * v; |
| 65 | } |
| 66 | this.sourceMean = sum / draws; |
| 67 | this.sourceVar = sum2 / draws - this.sourceMean * this.sourceMean; |
| 68 | } |
| 69 | |
| 70 | protected onParameterChange(key: string): void { |
| 71 | if (key !== "rate") this.reset(); |
| 72 | } |
| 73 | |
| 74 | private drawSource(): number { |
| 75 | return SOURCES[this.str("source")](this.rng); |
| 76 | } |
| 77 | |
| 78 | protected onUpdate(): void { |
| 79 | const n = this.num("n"); |
| 80 | const rate = this.num("rate"); |
| 81 | for (let s = 0; s < rate; s++) { |
| 82 | let sum = 0; |
| 83 | for (let i = 0; i < n; i++) sum += this.drawSource(); |
| 84 | const mean = sum / n; |
| 85 | const bin = Math.min(BINS - 1, Math.max(0, Math.floor(mean * BINS))); |
| 86 | this.bins[bin] += 1; |
| 87 | this.meansCount += 1; |
| 88 | this.samplesCount += n; |
| 89 | // Welford's online variance for the observed means. |
| 90 | const delta = mean - this.meanOfMeans; |
| 91 | this.meanOfMeans += delta / this.meansCount; |
| 92 | this.m2 += delta * (mean - this.meanOfMeans); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 97 | ctx.fillStyle = theme.bg; |
| 98 | ctx.fillRect(0, 0, this.width, this.height); |
| 99 | let maxCount = 1; |
| 100 | for (let i = 0; i < BINS; i++) if (this.bins[i] > maxCount) maxCount = this.bins[i]; |
| 101 | const binWidth = this.width / BINS; |
| 102 | const baseY = this.height - 18; |
| 103 | const chartH = this.height - 42; |
| 104 | ctx.fillStyle = theme.fgSecondary; |
| 105 | for (let i = 0; i < BINS; i++) { |
| 106 | const h = (this.bins[i] / maxCount) * chartH; |
| 107 | ctx.fillRect(i * binWidth + 0.5, baseY - h, binWidth - 1, h); |
| 108 | } |
| 109 | // CLT prediction: Gaussian with source sigma / sqrt(n). Expected count in |
| 110 | // a bin = meansCount * binWidth * pdf(x); drawn against the same scale. |
| 111 | const n = this.num("n"); |
| 112 | const sigmaMean = Math.sqrt(this.sourceVar / n); |
| 113 | const mu = this.sourceMean; |
| 114 | const countsToPixels = chartH / Math.max(1, maxCount); |
| 115 | const expected = (x: number) => |
| 116 | (Math.exp(-((x - mu) ** 2) / (2 * sigmaMean * sigmaMean)) / |
| 117 | (sigmaMean * Math.sqrt(2 * Math.PI))) * |
| 118 | (this.meansCount / BINS); |
| 119 | ctx.strokeStyle = theme.accent; |
| 120 | ctx.lineWidth = 2; |
| 121 | ctx.beginPath(); |
| 122 | for (let px = 0; px <= this.width; px += 2) { |
| 123 | const y = baseY - expected(px / this.width) * countsToPixels; |
| 124 | if (px === 0) ctx.moveTo(px, y); |
| 125 | else ctx.lineTo(px, y); |
| 126 | } |
| 127 | ctx.stroke(); |
| 128 | // Source distribution outline for reference |
| 129 | ctx.strokeStyle = theme.viz[1]; |
| 130 | ctx.lineWidth = 1; |
| 131 | ctx.globalAlpha = 0.5; |
| 132 | ctx.beginPath(); |
| 133 | for (let px = 0; px <= this.width; px += 2) { |
| 134 | const x = px / this.width; |
| 135 | // Empirical source pdf from a light probe pass (cheap: closed forms avoided on purpose) |
| 136 | const pdf = this.probeSourcePdf(x); |
| 137 | const y = baseY - pdf * chartH * 0.55; |
| 138 | if (px === 0) ctx.moveTo(px, y); |
| 139 | else ctx.lineTo(px, y); |
| 140 | } |
| 141 | ctx.stroke(); |
| 142 | ctx.globalAlpha = 1; |
| 143 | ctx.fillStyle = theme.lineStrong; |
| 144 | ctx.fillRect(0, baseY, this.width, 1); |
| 145 | } |
| 146 | |
| 147 | /** Small cached probe of the source pdf shape (200 draws per cell). */ |
| 148 | private pdfProbe: Float64Array | null = null; |
| 149 | private probeSourcePdf(x: number): number { |
| 150 | if (!this.pdfProbe) { |
| 151 | const probe = new Float64Array(64); |
| 152 | const source = SOURCES[this.str("source")]; |
| 153 | for (let i = 0; i < 20_000; i++) { |
| 154 | probe[Math.min(63, Math.max(0, Math.floor(source(this.rng) * 64)))] += 1; |
| 155 | } |
| 156 | let max = 1; |
| 157 | for (let i = 0; i < 64; i++) max = Math.max(max, probe[i]); |
| 158 | for (let i = 0; i < 64; i++) probe[i] /= max; |
| 159 | this.pdfProbe = probe; |
| 160 | } |
| 161 | return this.pdfProbe[Math.min(63, Math.max(0, Math.floor(x * 64)))]; |
| 162 | } |
| 163 | |
| 164 | getMetrics() { |
| 165 | const n = this.num("n"); |
| 166 | const variance = this.meansCount > 1 ? this.m2 / (this.meansCount - 1) : 0; |
| 167 | const predictedSigma = Math.sqrt(this.sourceVar / n); |
| 168 | return { |
| 169 | means: this.meansCount.toLocaleString("en-US"), |
| 170 | samples: this.samplesCount.toLocaleString("en-US"), |
| 171 | empiricalSigma: Math.sqrt(variance).toFixed(4), |
| 172 | predictedSigma: predictedSigma.toFixed(4), |
| 173 | meanOfMeans: this.meanOfMeans.toFixed(4), |
| 174 | }; |
| 175 | } |
| 176 | |
| 177 | describe(): string { |
| 178 | const n = this.num("n"); |
| 179 | const variance = this.meansCount > 1 ? this.m2 / (this.meansCount - 1) : 0; |
| 180 | return `${this.meansCount.toLocaleString("en-US")} sample means of n=${n} ${this.str("source")} draws; observed sigma ${Math.sqrt(variance).toFixed(3)}, CLT predicts ${Math.sqrt(this.sourceVar / n).toFixed(3)}.`; |
| 181 | } |
| 182 | |
| 183 | entities(): number { |
| 184 | return this.meansCount; |
| 185 | } |
| 186 | } |
| 187 | |
Performance
- Time per tick
- O(rate · n) draws per tick
- Space
- One 64-bin histogram plus running moments
- Notes
- The predicted sigma uses the measured source variance, not a textbook constant, so the comparison stays honest for every source.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.