Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import type { ParameterDef, ThemeColors } from "../core/types";
3
4const BINS = 64;
5/** Seeded source draws so every reset replays the same stream. */
6type Source = (rng: () => number) => number;
7
8const 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 */
20export 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.