Distributions, Moments, Transformations, and Limit Theorems
// ---------------------------------------------------------------------------
// ldk — a tiny, dependency-free plotting + statistics library for these decks.
//
// Everything runs in the browser as plain JavaScript building SVG nodes, so a
// slider move costs a fraction of a millisecond instead of a round trip
// through a WebAssembly R runtime.
// ---------------------------------------------------------------------------
ldk = {
const NS = "http://www.w3.org/2000/svg";
let uid = 0;
// Deck palette, matching ldkslides.css
const C = {
green: "#74b83d",
blue: "#4072c2",
pink: "#ED017D",
orange: "#d18b2a",
grey: "#6E6E6E",
ink: "#222222",
axis: "#9aa0a6",
faint: "#efefef"
};
function el(tag, attrs, text) {
const e = document.createElementNS(NS, tag);
if (attrs) for (const k in attrs) if (attrs[k] != null) e.setAttribute(k, attrs[k]);
if (text != null)
// SVG collapses runs of whitespace, which would eat the wide gaps that
// separate the readout columns; non-breaking spaces survive. (xml:space
// is a namespaced attribute and does not survive setAttribute here.)
e.textContent = tag === "text"
? String(text).replace(/ {2,}/g, m => " ".repeat(m.length))
: text;
return e;
}
function alpha(hex, a) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
// ------------------------------------------------------------------ numerics
const LGC = [676.5203681218851, -1259.1392167224028, 771.32342877765313,
-176.61502916214059, 12.507343278686905, -0.13857109526572012,
9.9843695780195716e-6, 1.5056327351493116e-7];
function lgamma(z) {
if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - lgamma(1 - z);
z -= 1;
let x = 0.99999999999980993;
for (let i = 0; i < 8; i++) x += LGC[i] / (z + i + 1);
const t = z + 7.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
const lchoose = (n, k) => lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1);
// Chebyshev erfc (Numerical Recipes); plenty of accuracy for plotting.
const ERFC_COF = [
-1.3026537197817094, 6.4196979235649026e-1, 1.9476473204185836e-2,
-9.561514786808631e-3, -9.46595344482036e-4, 3.66839497852761e-4,
4.2523324806907e-5, -2.0278578112534e-5, -1.624290004647e-6,
1.303655835580e-6, 1.5626441722e-8, -8.5238095915e-8,
6.529054439e-9, 5.059343495e-9, -9.91364156e-10, -2.27365122e-10,
9.6467911e-11, 2.394038e-12, -6.886027e-12, 8.94487e-13,
3.13092e-13, -1.12708e-13, 3.81e-16, 7.106e-15];
function erfc(x) {
const z = Math.abs(x), t = 2 / (2 + z), ty = 4 * t - 2;
let d = 0, dd = 0;
for (let j = ERFC_COF.length - 1; j > 0; j--) {
const tmp = d;
d = ty * d - dd + ERFC_COF[j];
dd = tmp;
}
const ans = t * Math.exp(-z * z + 0.5 * (ERFC_COF[0] + ty * d) - dd);
return x >= 0 ? ans : 2 - ans;
}
const dnorm = (x, m = 0, s = 1) => {
const z = (x - m) / s;
return Math.exp(-0.5 * z * z) / (s * Math.sqrt(2 * Math.PI));
};
const pnorm = (x, m = 0, s = 1) => 0.5 * erfc(-(x - m) / (s * Math.SQRT2));
// Acklam's inverse normal CDF (relative error < 1.2e-9)
const QN_A = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
const QN_B = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
6.680131188771972e+01, -1.328068155288572e+01];
const QN_C = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
const QN_D = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
3.754408661907416e+00];
function qnorm(p, m = 0, s = 1) {
if (p <= 0) return -Infinity;
if (p >= 1) return Infinity;
const lo = 0.02425, hi = 1 - lo;
let x, q, r;
if (p < lo) {
q = Math.sqrt(-2 * Math.log(p));
x = (((((QN_C[0] * q + QN_C[1]) * q + QN_C[2]) * q + QN_C[3]) * q + QN_C[4]) * q + QN_C[5]) /
((((QN_D[0] * q + QN_D[1]) * q + QN_D[2]) * q + QN_D[3]) * q + 1);
} else if (p <= hi) {
q = p - 0.5; r = q * q;
x = (((((QN_A[0] * r + QN_A[1]) * r + QN_A[2]) * r + QN_A[3]) * r + QN_A[4]) * r + QN_A[5]) * q /
(((((QN_B[0] * r + QN_B[1]) * r + QN_B[2]) * r + QN_B[3]) * r + QN_B[4]) * r + 1);
} else {
q = Math.sqrt(-2 * Math.log(1 - p));
x = -(((((QN_C[0] * q + QN_C[1]) * q + QN_C[2]) * q + QN_C[3]) * q + QN_C[4]) * q + QN_C[5]) /
((((QN_D[0] * q + QN_D[1]) * q + QN_D[2]) * q + QN_D[3]) * q + 1);
}
return m + s * x;
}
function dbinom(k, n, p) {
if (k < 0 || k > n) return 0;
if (p <= 0) return k === 0 ? 1 : 0;
if (p >= 1) return k === n ? 1 : 0;
return Math.exp(lchoose(n, k) + k * Math.log(p) + (n - k) * Math.log1p(-p));
}
const dpois = (k, lam) => k < 0 ? 0 : Math.exp(-lam + k * Math.log(lam) - lgamma(k + 1));
const dexp = (x, rate) => x < 0 ? 0 : rate * Math.exp(-rate * x);
const pexp = (x, rate) => x <= 0 ? 0 : 1 - Math.exp(-rate * x);
const dunif = (x, a, b) => (x >= a && x <= b) ? 1 / (b - a) : 0;
const punif = (x, a, b) => x <= a ? 0 : x >= b ? 1 : (x - a) / (b - a);
const dlnorm = (x, ml = 0, sl = 1) => x <= 0 ? 0 : dnorm(Math.log(x), ml, sl) / x;
const dchisq1 = (x) => x <= 0 ? 0 : dnorm(Math.sqrt(x)) / Math.sqrt(x);
// ---- regularized lower incomplete gamma P(a,x): series + continued fraction
function gammaP(a, x) {
if (!(x >= 0) || !(a > 0)) return NaN;
if (x === 0) return 0;
const pref = Math.exp(-x + a * Math.log(x) - lgamma(a));
if (x < a + 1) { // series
let ap = a, sum = 1 / a, del = sum;
for (let i = 0; i < 500; i++) {
ap++; del *= x / ap; sum += del;
if (Math.abs(del) < Math.abs(sum) * 1e-16) break;
}
return sum * pref;
}
const TINY = 1e-300; // continued fraction for Q = 1 - P
let b = x + 1 - a, c = 1 / TINY, d = 1 / b, h = d;
for (let i = 1; i <= 500; i++) {
const an = -i * (i - a);
b += 2;
d = an * d + b; if (Math.abs(d) < TINY) d = TINY;
c = b + an / c; if (Math.abs(c) < TINY) c = TINY;
d = 1 / d;
const del = d * c;
h *= del;
if (Math.abs(del - 1) < 1e-16) break;
}
return 1 - pref * h;
}
// ---- regularized incomplete beta I_x(a,b)
function betacf(a, b, x) {
const TINY = 1e-300, qab = a + b, qap = a + 1, qam = a - 1;
let c = 1, d = 1 - qab * x / qap;
if (Math.abs(d) < TINY) d = TINY;
d = 1 / d;
let h = d;
for (let m = 1; m <= 400; m++) {
const m2 = 2 * m;
let aa = m * (b - m) * x / ((qam + m2) * (a + m2));
d = 1 + aa * d; if (Math.abs(d) < TINY) d = TINY;
c = 1 + aa / c; if (Math.abs(c) < TINY) c = TINY;
d = 1 / d; h *= d * c;
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
d = 1 + aa * d; if (Math.abs(d) < TINY) d = TINY;
c = 1 + aa / c; if (Math.abs(c) < TINY) c = TINY;
d = 1 / d;
const del = d * c;
h *= del;
if (Math.abs(del - 1) < 3e-16) break;
}
return h;
}
function betai(a, b, x) {
if (!(x > 0)) return 0;
if (x >= 1) return 1;
const bt = Math.exp(lgamma(a + b) - lgamma(a) - lgamma(b) +
a * Math.log(x) + b * Math.log1p(-x));
return x < (a + 1) / (a + b + 2)
? bt * betacf(a, b, x) / a
: 1 - bt * betacf(b, a, 1 - x) / b;
}
// ---- chi-squared and Student t
const dchisq = (x, k) => x <= 0 ? 0 :
Math.exp((k / 2 - 1) * Math.log(x) - x / 2 - lgamma(k / 2) - (k / 2) * Math.LN2);
const pchisq = (x, k) => x <= 0 ? 0 : gammaP(k / 2, x / 2);
const dt = (x, nu) => Math.exp(lgamma((nu + 1) / 2) - lgamma(nu / 2)
- 0.5 * Math.log(nu * Math.PI) - (nu + 1) / 2 * Math.log1p(x * x / nu));
const pt = (x, nu) => {
const half = 0.5 * betai(nu / 2, 0.5, nu / (nu + x * x));
return x > 0 ? 1 - half : half;
};
// quantiles by bisection — a handful of calls per redraw, so speed is moot
function invert(cdf, p, lo, hi) {
for (let i = 0; i < 90; i++) {
const mid = (lo + hi) / 2;
if (cdf(mid) < p) lo = mid; else hi = mid;
}
return (lo + hi) / 2;
}
const qchisq = (p, k) => invert(x => pchisq(x, k), p, 0, Math.max(60, k * 25));
const qt = (p, nu) => invert(x => pt(x, nu), p, -1e4, 1e4);
// Cumulative sums of a pmf enumerated over 0..n — cheaper and more accurate
// here than a general incomplete-beta / incomplete-gamma implementation.
function cdfFromPmf(pmf) {
const out = new Array(pmf.length);
let s = 0;
for (let i = 0; i < pmf.length; i++) { s += pmf[i]; out[i] = Math.min(s, 1); }
return out;
}
// ----------------------------------------------------------------- sequences
function seq(a, b, n) {
const out = new Array(n);
for (let i = 0; i < n; i++) out[i] = a + (b - a) * i / (n - 1);
return out;
}
const cumsum = (v) => { let s = 0; return v.map(x => (s += x)); };
const sum = (v) => v.reduce((a, b) => a + b, 0);
const mean = (v) => sum(v) / v.length;
function sd(v) {
const m = mean(v);
return Math.sqrt(v.reduce((a, x) => a + (x - m) * (x - m), 0) / (v.length - 1));
}
function quantileSorted(s, p) {
const h = (s.length - 1) * p, i = Math.floor(h);
const j = Math.min(i + 1, s.length - 1);
return s[i] + (h - i) * (s[j] - s[i]);
}
function hist(v, lo, hi, nb) {
const cnt = new Float64Array(nb), w = (hi - lo) / nb;
for (let i = 0; i < v.length; i++) {
const k = Math.floor((v[i] - lo) / w);
if (k >= 0 && k < nb) cnt[k]++;
else if (v[i] === hi) cnt[nb - 1]++;
}
const dens = new Array(nb);
for (let i = 0; i < nb; i++) dens[i] = cnt[i] / (v.length * w);
return { lo, hi, w, nb, dens, count: cnt };
}
// --------------------------------------------------------------------- rngs
// mulberry32: small, fast, seedable — the demos stay reproducible.
function rng(seed) {
let a = seed >>> 0;
const u = () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
let spare = null;
u.normal = () => { // Marsaglia polar
if (spare !== null) { const v = spare; spare = null; return v; }
let x, y, s;
do { x = 2 * u() - 1; y = 2 * u() - 1; s = x * x + y * y; } while (s >= 1 || s === 0);
const f = Math.sqrt(-2 * Math.log(s) / s);
spare = y * f;
return x * f;
};
u.exp = () => -Math.log(1 - u());
u.cauchy = () => Math.tan(Math.PI * (u() - 0.5));
return u;
}
// Weighted sampling without replacement (Efraimidis–Spirakis exponential race)
function sampleWeighted(n, w, u) {
const N = w.length;
const keys = new Array(N);
for (let i = 0; i < N; i++) keys[i] = w[i] > 0 ? -Math.log(1 - u()) / w[i] : Infinity;
const idx = Array.from({ length: N }, (_, i) => i);
idx.sort((a, b) => keys[a] - keys[b]);
return idx.slice(0, n);
}
// ---------------------------------------------------------------- formatting
const fmt = (v, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : "—");
const signed = (v, d = 2) => (Number.isFinite(v) ? (v >= 0 ? "+" : "") + v.toFixed(d) : "—");
function smart(v) {
if (!Number.isFinite(v)) return "—";
const a = Math.abs(v);
if (a >= 1e5 || (a > 0 && a < 1e-3)) return v.toExponential(1);
return v.toFixed(a >= 100 ? 0 : a >= 10 ? 1 : 2);
}
function nice(lo, hi, n) {
const span = (hi - lo) || Math.abs(hi) || 1;
const raw = span / n;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = mag * (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10);
const out = [];
for (let v = Math.ceil(lo / step - 1e-9) * step; v <= hi + 1e-9 * span; v += step)
out.push(Math.abs(v) < step * 1e-9 ? 0 : v);
return { ticks: out, step };
}
function logTicks(lo, hi) {
const decades = Math.log10(hi / lo);
const mults = decades > 2.2 ? [1] : decades > 1.1 ? [1, 3] : [1, 2, 5];
const out = [];
for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++)
for (const k of mults) {
const v = k * Math.pow(10, e);
if (v >= lo * 0.9999 && v <= hi * 1.0001) out.push(v);
}
return out;
}
const decimalsFor = (step) =>
Math.max(0, Math.min(6, -Math.floor(Math.log10(Math.abs(step)) + 1e-9)));
// -------------------------------------------------------------------- charts
function chart(W, H, extra) {
const svg = el("svg", Object.assign({
viewBox: `0 0 ${W} ${H}`,
style: `width:100%;height:auto;display:block;font-family:inherit;color:${C.ink}`
}, extra || {}));
svg.__W = W;
svg.__H = H;
return svg;
}
function panel(svg, o) {
const L = o.left ?? 0, T = o.top ?? 0;
const W = o.w ?? svg.__W, H = o.h ?? svg.__H;
const m = Object.assign({ l: 74, r: 22, t: 52, b: 54 }, o.margin || {});
const fs = o.fontSize ?? 15;
const iw = W - m.l - m.r, ih = H - m.t - m.b;
const px0 = L + m.l, py0 = T + m.t;
let [xa, xb] = o.xdom, [ya, yb] = o.ydom;
if (!(xb > xa)) xb = xa + 1;
if (!(yb > ya)) yb = ya + 1;
const lg = o.xlog ? Math.log : (v => v);
const xA = lg(xa), xB = lg(xb);
const x = v => px0 + (lg(v) - xA) / (xB - xA) * iw;
const y = v => py0 + ih - (v - ya) / (yb - ya) * ih;
const gAxis = el("g");
const id = `ldkclip${++uid}`;
const defs = el("defs");
const cp = el("clipPath", { id });
cp.append(el("rect", { x: px0 - 3, y: py0 - 8, width: iw + 6, height: ih + 11 }));
defs.append(cp);
const gc = el("g", { "clip-path": `url(#${id})` });
const gt = el("g");
svg.append(defs, gAxis, gc, gt);
// ticks
let xt, xfmt, yt, yfmt;
if (o.xticks) {
xt = o.xticks;
xfmt = o.xfmt || (v => String(v));
} else if (o.xlog) {
xt = logTicks(xa, xb);
xfmt = o.xfmt || (v => v >= 1 ? String(Math.round(v)) : String(v));
} else {
const n = nice(xa, xb, o.nx ?? 6);
xt = n.ticks;
xfmt = o.xfmt || (v => v.toFixed(decimalsFor(n.step)));
}
if (o.yticks) {
yt = o.yticks;
yfmt = o.yfmt || (v => String(v));
} else {
const n = nice(ya, yb, o.ny ?? 5);
yt = n.ticks;
yfmt = o.yfmt || (v => v.toFixed(decimalsFor(n.step)));
}
if (o.axes === false) { xt = []; yt = []; } // bare drawing surface
if (o.axes !== false) {
gAxis.append(el("line", { x1: px0, y1: py0 + ih, x2: px0 + iw, y2: py0 + ih,
stroke: C.axis, "stroke-width": 1.3 }));
gAxis.append(el("line", { x1: px0, y1: py0, x2: px0, y2: py0 + ih,
stroke: C.axis, "stroke-width": 1.3 }));
}
for (const t of xt) {
const px = x(t);
if (px < px0 - 0.5 || px > px0 + iw + 0.5) continue;
gAxis.append(el("line", { x1: px, y1: py0 + ih, x2: px, y2: py0 + ih + 5,
stroke: C.axis, "stroke-width": 1.3 }));
gAxis.append(el("text", { x: px, y: py0 + ih + 6 + fs, "text-anchor": "middle",
"font-size": fs, fill: C.grey }, xfmt(t)));
}
for (const t of yt) {
const py = y(t);
if (py < py0 - 0.5 || py > py0 + ih + 0.5) continue;
gAxis.append(el("line", { x1: px0 - 5, y1: py, x2: px0, y2: py,
stroke: C.axis, "stroke-width": 1.3 }));
gAxis.append(el("text", { x: px0 - 10, y: py + fs * 0.35, "text-anchor": "end",
"font-size": fs, fill: C.grey }, yfmt(t)));
}
if (o.xlab)
gAxis.append(el("text", { x: px0 + iw / 2, y: T + H - 8, "text-anchor": "middle",
"font-size": fs + 2, fill: C.ink }, o.xlab));
if (o.ylab) {
const ty = py0 + ih / 2, tx = L + 20;
gAxis.append(el("text", { x: tx, y: ty, "text-anchor": "middle", "font-size": fs + 2,
fill: C.ink, transform: `rotate(-90 ${tx} ${ty})` }, o.ylab));
}
const P = {
x, y, svg, gAxis, gc, gt, fs,
xdom: [xa, xb], ydom: [ya, yb],
area: { x0: px0, y0: py0, w: iw, h: ih },
add(node, top) { (top ? gt : gc).append(node); return node; },
path(pts, a) {
let d = "";
for (let i = 0; i < pts.length; i++)
d += (i ? "L" : "M") + x(pts[i][0]).toFixed(2) + "," + y(pts[i][1]).toFixed(2);
return P.add(el("path", Object.assign(
{ d, fill: "none", "stroke-linejoin": "round", "stroke-linecap": "round" }, a)));
},
fill(pts, base, a) {
let d = "";
for (let i = 0; i < pts.length; i++)
d += (i ? "L" : "M") + x(pts[i][0]).toFixed(2) + "," + y(pts[i][1]).toFixed(2);
d += "L" + x(pts[pts.length - 1][0]).toFixed(2) + "," + y(base).toFixed(2);
d += "L" + x(pts[0][0]).toFixed(2) + "," + y(base).toFixed(2) + "Z";
return P.add(el("path", Object.assign({ d, stroke: "none" }, a)));
},
seg(x1, y1, x2, y2, a) {
return P.add(el("line", Object.assign(
{ x1: x(x1), y1: y(y1), x2: x(x2), y2: y(y2) }, a)));
},
vline(v, a) { return P.seg(v, ya, v, yb, a); },
hline(v, a) { return P.seg(xa, v, xb, v, a); },
bar(x1, x2, y1, y2, a) {
const X1 = x(x1), X2 = x(x2), Y1 = y(y1), Y2 = y(y2);
return P.add(el("rect", Object.assign({
x: Math.min(X1, X2), y: Math.min(Y1, Y2),
width: Math.max(Math.abs(X2 - X1), 0.5),
height: Math.max(Math.abs(Y2 - Y1), 0)
}, a)));
},
dot(vx, vy, r, a) {
return P.add(el("circle", Object.assign({ cx: x(vx), cy: y(vy), r }, a)));
},
// mtext-style annotation above the plot region
header(s, a = {}) {
const adj = a.adj ?? 0;
const py = py0 - 14 - (a.line ?? 0) * (fs + 7);
return P.add(el("text", {
x: adj === 1 ? px0 + iw : adj === 0.5 ? px0 + iw / 2 : px0,
y: py,
"text-anchor": adj === 1 ? "end" : adj === 0.5 ? "middle" : "start",
"font-size": a.size ?? fs + 2,
"font-weight": a.weight ?? "bold",
fill: a.fill ?? C.ink
}, s), true);
},
legend(items, opt = {}) {
const size = opt.size ?? fs;
const pad = opt.pad ?? 8;
const rowH = size + 8, swW = 30;
const g = el("g");
let maxw = 0;
items.forEach((it, i) => {
const ty = i * rowH + size;
if (it.swatch)
g.append(el("rect", { x: 0, y: ty - size * 0.82, width: swW,
height: size * 0.85, fill: it.swatch, stroke: "none" }));
else
g.append(el("line", { x1: 0, y1: ty - size * 0.35, x2: swW, y2: ty - size * 0.35,
stroke: it.color, "stroke-width": it.lw ?? 3.5,
"stroke-dasharray": it.dash ?? null,
"stroke-linecap": "round" }));
g.append(el("text", { x: swW + 9, y: ty, "font-size": size,
fill: opt.fill ?? C.ink }, it.label));
maxw = Math.max(maxw, swW + 9 + it.label.length * size * 0.54);
});
// opt-in backing panel, for legends that must sit over the data
if (opt.bg) {
const q = 8;
g.insertBefore(el("rect", {
x: -q, y: -q + 3, width: maxw + 2 * q,
height: items.length * rowH + 2 * q - 6,
fill: "white", "fill-opacity": opt.bg === true ? 0.85 : opt.bg, rx: 5
}), g.firstChild);
}
const corner = opt.corner ?? "topright";
const gx = corner.includes("right") ? px0 + iw - maxw - pad : px0 + pad;
const gy = corner.includes("top") ? py0 + pad
: py0 + ih - items.length * rowH - pad;
g.setAttribute("transform", `translate(${gx},${gy})`);
gt.append(g);
return g;
}
};
return P;
}
// Convenience: one chart with exactly one panel filling it.
function single(W, H, o) {
const svg = chart(W, H);
return { svg, p: panel(svg, o) };
}
return {
C, el, alpha, chart, panel, single,
lgamma, lchoose, erfc, dnorm, pnorm, qnorm, dbinom, dpois, dexp, pexp,
dunif, punif, dlnorm, dchisq1, cdfFromPmf,
gammaP, betai, dchisq, pchisq, qchisq, dt, pt, qt, invert,
seq, cumsum, sum, mean, sd, quantileSorted, hist, rng, sampleWeighted,
fmt, signed, smart, nice, logTicks
};
}Definition — Probability Space
A probability space is a triple \((\Omega, \mathcal{F}, P)\) where
Definition — Random Variable
A random variable \(X\) is a function that maps outcomes from a sample space \(\Omega\) to real numbers: \[X: \Omega \rightarrow \mathbb{R}\]
Key Insight
Random variables let us work with numerical quantities in probability theory — arithmetic, averages and calculus become available.
Example: tossing two coins.
Definition — CDF
The cumulative distribution function of a random variable \(X\) is \[F_X(x) = P(X \leq x) \quad \text{for } x \in \mathbb{R}\]
Properties of the CDF:
Why the CDF matters
It is the one object that exists for every random variable — discrete, continuous, or a mixture of both. PMFs and PDFs are what the CDF looks like in two special cases.
Definition — Discrete Random Variable
A random variable \(X\) is discrete if it takes values in a countable set \(\{x_1, x_2, x_3, \ldots\}\).
Probability Mass Function (PMF): \[p_X(x) = P(X = x)\]
Properties:
Definition — Continuous Random Variable
A random variable \(X\) is continuous if there exists a non-negative function \(f_X\) such that \[P(a \leq X \leq b) = \int_a^b f_X(x)\, dx\]
Probability Density Function (PDF): \[f_X(x)=\frac{d}{dx}P(X\leq x)\]
Properties:
Discrete case: \[F_X(x) = \sum_{t \leq x} p_X(t)\] \[p_X(x) = F_X(x) - F_X(x^-)\]
The CDF is a staircase; the jump heights are the probabilities.
Continuous case: \[F_X(x) = \int_{-\infty}^x f_X(t)\, dt\] \[f_X(x) = \frac{d}{dx} F_X(x)\]
The CDF is smooth; the density is its slope.
Definition — Expected Value
The expected value (or mean) of a random variable \(X\) is
Discrete: \(\displaystyle E[X] = \sum_{x} x \cdot p_X(x)\) Continuous: \(\displaystyle E[X] = \int_{-\infty}^{\infty} x \cdot f_X(x)\, dx\)
Properties:
Intuition
\(E[X]\) is the balance point of the distribution: put the density on a seesaw, and this is where it tips level.
Definition — Variance
\[\text{Var}(X) = E[(X - E[X])^2] = E[X^2] - (E[X])^2\]
Definition — Standard Deviation
\[\sigma_X = \sqrt{\text{Var}(X)}\]
Properties:
Definition — Raw and Central Moments
The \(k\)-th raw moment is \(\mu_k' = E[X^k]\).
The \(k\)-th central moment is \(\mu_k = E[(X - E[X])^k]\).
Important special cases:
Two knobs, four numbers. Watch which moments react to which knob.
\(X = \sinh\!\big((\sinh^{-1} Z + \varepsilon)/\delta\big)\) with \(Z \sim N(0,1)\), standardised to mean 0, variance 1. At \(\varepsilon = 0, \delta = 1\) it is the normal.
{
const C = ldk.C;
const eps = mo_skew, del = mo_tail;
// X = sinh((asinh Z + eps)/del). Both the moments and the density are
// available in closed form, so no simulation is needed.
const S = z => Math.sinh((Math.asinh(z) + eps) / del);
const NQ = 6001, zlo = -9, zhi = 9, hq = (zhi - zlo) / (NQ - 1);
let m1 = 0, m2 = 0, m3 = 0, m4 = 0;
for (let i = 0; i < NQ; i++) {
const z = zlo + i * hq;
const w = (i === 0 || i === NQ - 1 ? 0.5 : 1) * hq * ldk.dnorm(z);
const s = S(z), s2 = s * s;
m1 += w * s; m2 += w * s2; m3 += w * s2 * s; m4 += w * s2 * s2;
}
const va = m2 - m1 * m1, sdv = Math.sqrt(va);
const skew = (m3 - 3 * m1 * m2 + 2 * m1 ** 3) / (sdv * va);
const kurt = (m4 - 4 * m1 * m3 + 6 * m1 * m1 * m2 - 3 * m1 ** 4) / (va * va) - 3;
// density of the standardised variable W = (X - E X) / sd(X)
const fW = w => {
const x = m1 + sdv * w;
const t = del * Math.asinh(x) - eps;
return sdv * ldk.dnorm(Math.sinh(t)) * del * Math.cosh(t) / Math.sqrt(1 + x * x);
};
const grid = ldk.seq(-4.5, 4.5, 700);
const curve = grid.map(w => [w, fW(w)]);
const ymax = Math.max(0.42, ...curve.map(d => d[1]));
const { svg, p } = ldk.single(880, 520, {
xdom: [-4.5, 4.5], ydom: [0, ymax * 1.12],
xlab: "x", ylab: "density", margin: { t: 54 }
});
p.fill(curve, 0, { fill: ldk.alpha(C.blue, 0.15) });
p.path(curve, { stroke: C.blue, "stroke-width": 3.5 });
const legend = [{ label: "your distribution", color: C.blue, lw: 3.5 }];
if (mo_ref) {
p.path(grid.map(w => [w, ldk.dnorm(w)]),
{ stroke: C.grey, "stroke-width": 2.5, "stroke-dasharray": "9,6" });
legend.push({ label: "N(0,1)", color: C.grey, lw: 2.5, dash: "9,6" });
}
p.vline(0, { stroke: C.green, "stroke-width": 2.5 });
p.legend(legend, { size: 16 });
p.header(`mean = 0.00 sd = 1.00 skewness = ${ldk.signed(skew)}` +
` excess kurtosis = ${ldk.signed(kurt)}`,
{ fill: C.pink, size: 18 });
return svg;
}Takeaway
Higher moments are how we describe the part of a distribution that \(\mu\) and \(\sigma^2\) cannot see.
Definition: models a single trial with two outcomes.
PMF: \[p_X(x) = \begin{cases} p & \text{if } x = 1 \\ 1-p & \text{if } x = 0 \\ 0 & \text{otherwise} \end{cases}\]
Parameters: \(p \in [0,1]\) (probability of success)
Moments: \(E[X] = p\), \(\text{Var}(X) = p(1-p)\)
Notation: \(X \sim \text{Bernoulli}(p)\)
Definition: number of successes in \(n\) independent Bernoulli trials.
PMF: \[p_X(k) = \binom{n}{k} p^k (1-p)^{n-k}, \quad k = 0, 1, \ldots, n\]
Parameters:
Moments:
Notation: \(X \sim \text{Binomial}(n, p)\)
Definition: models rare events or counts in a fixed interval.
PMF: \[p_X(k) = \frac{\lambda^k e^{-\lambda}}{k!}, \quad k = 0, 1, 2, \ldots\]
Parameter: \(\lambda > 0\) (rate)
Moments: \(E[X] = \lambda\), \(\text{Var}(X) = \lambda\)
Key Property
Poisson approximates the Binomial when \(n\) is large, \(p\) is small, and \(np = \lambda\) is moderate.
Notation: \(X \sim \text{Poisson}(\lambda)\)
Definition: equal likelihood over an interval \([a, b]\).
PDF: \[f_X(x) = \begin{cases} \frac{1}{b-a} & \text{if } a \leq x \leq b \\ 0 & \text{otherwise} \end{cases}\]
Moments: \(E[X] = \frac{a+b}{2}\), \(\text{Var}(X) = \frac{(b-a)^2}{12}\)
CDF: \[F_X(x) = \begin{cases} 0 & \text{if } x < a \\ \frac{x-a}{b-a} & \text{if } a \leq x \leq b \\ 1 & \text{if } x > b \end{cases}\]
Notation: \(X \sim \text{Uniform}(a, b)\)
Definition: models waiting times between events.
PDF: \[f_X(x) = \lambda e^{-\lambda x}, \quad x \geq 0\]
CDF: \[F_X(x) = 1 - e^{-\lambda x}, \quad x \geq 0\]
Parameter: \(\lambda > 0\) (rate)
Moments: \(E[X] = \frac{1}{\lambda}\), \(\text{Var}(X) = \frac{1}{\lambda^2}\)
Memoryless Property
\[P(X > s + t \mid X > s) = P(X > t)\] A used component is as good as new — the only continuous distribution with this property.
Notation: \(X \sim \text{Exp}(\lambda)\)
Definition: the most important continuous distribution.
PDF: \[f_X(x) = \frac{1}{\sigma\sqrt{2\pi}} \exp\left(-\frac{(x-\mu)^2}{2\sigma^2}\right)\]
Parameters:
Moments: \(E[X] = \mu\), \(\text{Var}(X) = \sigma^2\)
Standard Normal: \(Z \sim N(0,1)\), CDF written \(\Phi(z)\)
Notation: \(X \sim N(\mu, \sigma^2)\)
Move the cut-off \(x_0\) and watch the shaded area on top become the height of the CDF below.
viewof zoo_dist = Inputs.select(
["Bernoulli", "Binomial", "Poisson", "Uniform", "Exponential", "Normal"],
{label: "Distribution", value: "Binomial"}
)
viewof zoo_form = {
const forms = {
Bernoulli: () => Inputs.form({
a: Inputs.range([0, 1], {step: 0.01, value: 0.4, label: "p"})}),
Binomial: () => Inputs.form({
a: Inputs.range([1, 60], {step: 1, value: 20, label: "n"}),
b: Inputs.range([0, 1], {step: 0.01, value: 0.4, label: "p"})}),
Poisson: () => Inputs.form({
a: Inputs.range([0.2, 25], {step: 0.2, value: 4, label: "λ"})}),
Uniform: () => Inputs.form({
a: Inputs.range([-10, 4], {step: 0.5, value: 0, label: "a"}),
b: Inputs.range([-4, 10], {step: 0.5, value: 5, label: "b"})}),
Exponential: () => Inputs.form({
a: Inputs.range([0.1, 4], {step: 0.1, value: 1, label: "λ (rate)"})}),
Normal: () => Inputs.form({
a: Inputs.range([-10, 10], {step: 0.5, value: 0, label: "μ"}),
b: Inputs.range([0.2, 6], {step: 0.1, value: 1, label: "σ"})})
};
return forms[zoo_dist]();
}
zoo_a = zoo_form.a
zoo_b = zoo_form.b ?? 0
viewof zoo_q = Inputs.range([0, 1], {step: 0.01, value: 0.5, label: "cut-off x₀"}){
const C = ldk.C;
const a = zoo_a, b0 = zoo_b;
let spec;
if (zoo_dist === "Bernoulli") {
const f = [1 - a, a];
spec = { disc: true, x: [0, 1], f, mean: a, var: a * (1 - a),
lab: `Bernoulli(${ldk.fmt(a, 2)})` };
} else if (zoo_dist === "Binomial") {
const n = Math.round(a), x = ldk.seq(0, n, n + 1);
spec = { disc: true, x, f: x.map(k => ldk.dbinom(k, n, b0)),
mean: n * b0, var: n * b0 * (1 - b0),
lab: `Binomial(${n}, ${ldk.fmt(b0, 2)})` };
} else if (zoo_dist === "Poisson") {
const hi = Math.max(8, Math.ceil(a + 5 * Math.sqrt(a)));
const x = ldk.seq(0, hi, hi + 1);
spec = { disc: true, x, f: x.map(k => ldk.dpois(k, a)), mean: a, var: a,
lab: `Poisson(${ldk.fmt(a, 1)})` };
} else if (zoo_dist === "Uniform") {
const b = b0 <= a ? a + 0.5 : b0;
const g = ldk.seq(a - (b - a) * 0.25, b + (b - a) * 0.25, 600);
spec = { disc: false, x: g, f: g.map(v => ldk.dunif(v, a, b)),
cdf: v => ldk.punif(v, a, b), mean: (a + b) / 2, var: (b - a) ** 2 / 12,
lab: `Uniform(${ldk.fmt(a, 1)}, ${ldk.fmt(b, 1)})` };
} else if (zoo_dist === "Exponential") {
const g = ldk.seq(0, -Math.log(1 - 0.999) / a, 600);
spec = { disc: false, x: g, f: g.map(v => ldk.dexp(v, a)),
cdf: v => ldk.pexp(v, a), mean: 1 / a, var: 1 / (a * a),
lab: `Exp(${ldk.fmt(a, 1)})` };
} else {
const g = ldk.seq(a - 4 * b0, a + 4 * b0, 600);
spec = { disc: false, x: g, f: g.map(v => ldk.dnorm(v, a, b0)),
cdf: v => ldk.pnorm(v, a, b0), mean: a, var: b0 * b0,
lab: `N(${ldk.fmt(a, 1)}, ${ldk.fmt(b0 * b0, 2)})` };
}
const lo = spec.x[0], hi = spec.x[spec.x.length - 1];
let x0 = lo + zoo_q * (hi - lo);
if (spec.disc) x0 = Math.round(x0);
const below = spec.x.map(v => v <= x0 + 1e-9);
const Fv = spec.disc ? ldk.cumsum(spec.f) : null;
const prob = spec.disc
? spec.f.reduce((s, v, i) => s + (below[i] ? v : 0), 0)
: spec.cdf(x0);
const fmax = Math.max(...spec.f);
const xdom = spec.disc ? [lo - 0.5, hi + 1] : [lo, hi];
const svg = ldk.chart(880, 566);
const shared = { xdom, fontSize: 15 };
// ---- top: PMF / PDF ----------------------------------------------------
const pt = ldk.panel(svg, Object.assign({
top: 0, h: 272, ydom: [0, fmax * 1.1],
ylab: spec.disc ? "PMF p(x)" : "PDF f(x)",
margin: { t: 46, b: 32 }
}, shared));
if (spec.disc) {
const lw = spec.x.length > 40 ? 5 : 8;
spec.x.forEach((v, i) => {
const col = below[i] ? C.pink : ldk.alpha(C.blue, 0.75);
pt.seg(v, 0, v, spec.f[i], { stroke: col, "stroke-width": lw });
pt.dot(v, spec.f[i], 3, { fill: below[i] ? C.pink : C.blue });
});
} else {
const pts = spec.x.map((v, i) => [v, spec.f[i]]);
const inside = pts.filter((_, i) => below[i]);
if (inside.length > 1) pt.fill(inside, 0, { fill: ldk.alpha(C.pink, 0.35) });
pt.path(pts, { stroke: C.blue, "stroke-width": 3.5 });
}
pt.vline(spec.mean, { stroke: C.green, "stroke-width": 2.5, "stroke-dasharray": "8,6" });
pt.header(spec.lab, { size: 19 });
pt.header(`E[X] = ${ldk.fmt(spec.mean, 3)} Var(X) = ${ldk.fmt(spec.var, 3)}`,
{ adj: 1, fill: C.green, size: 17 });
// ---- bottom: CDF -------------------------------------------------------
const pb = ldk.panel(svg, Object.assign({
top: 272, h: 294, ydom: [0, 1], xlab: "x", ylab: "F(x)",
margin: { t: 46, b: 52 }
}, shared));
if (spec.disc) {
pb.seg(xdom[0], 0, spec.x[0], 0, { stroke: C.blue, "stroke-width": 3 });
spec.x.forEach((v, i) => {
pb.seg(v, Fv[i], v + 1, Fv[i], { stroke: C.blue, "stroke-width": 3 });
pb.dot(v, Fv[i], 3.5, { fill: C.blue });
});
} else {
pb.path(spec.x.map(v => [v, spec.cdf(v)]), { stroke: C.blue, "stroke-width": 3.5 });
}
pb.seg(x0, 0, x0, prob, { stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "3,5" });
pb.seg(xdom[0], prob, x0, prob, { stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "3,5" });
pb.dot(x0, prob, 7, { fill: C.pink });
pb.vline(spec.mean, { stroke: C.green, "stroke-width": 2.5, "stroke-dasharray": "8,6" });
pb.header(`shaded area above = F(${ldk.fmt(x0, 2)}) = P(X ≤ ${ldk.fmt(x0, 2)})` +
` = ${ldk.fmt(prob, 3)}`, { fill: C.pink, size: 18 });
return svg;
}Hold \(\lambda = np\) fixed and let \(n\) grow: the binomial mass collapses onto the Poisson.
Watch the largest gap in the readout — it shrinks roughly like \(\lambda^2/n\).
{
const C = ldk.C;
const lam = bp_lambda;
const n = Math.max(bp_n, Math.ceil(lam)); // p = lambda/n must be a probability
const p = lam / n;
const hi = Math.max(6, Math.ceil(lam + 4 * Math.sqrt(lam)));
const k = ldk.seq(0, hi, hi + 1);
const pb = k.map(v => ldk.dbinom(v, n, p));
const pp = k.map(v => ldk.dpois(v, lam));
const gap = Math.max(...k.map(i => Math.abs(pb[i] - pp[i])));
const { svg, p: pl } = ldk.single(880, 520, {
xdom: [-0.5, hi + 0.5], ydom: [0, Math.max(...pb, ...pp) * 1.15],
xlab: "k", ylab: "probability", margin: { t: 54 }
});
for (const i of k) {
pl.bar(i - 0.34, i - 0.02, 0, pb[i], { fill: ldk.alpha(C.blue, 0.75) });
pl.bar(i + 0.02, i + 0.34, 0, pp[i], { fill: ldk.alpha(C.orange, 0.85) });
}
pl.legend([
{ label: `Binomial(${n}, ${ldk.fmt(p, 4)})`, swatch: ldk.alpha(C.blue, 0.75) },
{ label: `Poisson(${ldk.fmt(lam, 1)})`, swatch: ldk.alpha(C.orange, 0.85) }
], { size: 17 });
pl.header(`largest gap max |p_bin − p_pois| = ${ldk.fmt(gap, 4)}`,
{ fill: C.pink, size: 19 });
return svg;
}Question: if \(Y = g(X)\), how do we find the distribution of \(Y\)?
Discrete case: \[p_Y(y) = \sum_{x:\, g(x) = y} p_X(x)\]
Continuous case (monotonic \(g\)): if \(g\) is strictly monotonic with inverse \(g^{-1}\), \[f_Y(y) = f_X(g^{-1}(y)) \left|\frac{d}{dy}g^{-1}(y)\right|\]
Where the Jacobian comes from
Densities are probability per unit length. Stretching the axis by \(g\) dilutes the density; the derivative term is the bookkeeping that keeps the total area at 1.
If \(Y = aX + b\):
Expected value: \[E[Y] = aE[X] + b\]
Variance: \[\text{Var}(Y) = a^2\text{Var}(X)\]
Special Case — Standardisation
\[Z = \frac{X - E[X]}{\sqrt{\text{Var}(X)}}\] Then \(E[Z] = 0\) and \(\text{Var}(Z) = 1\).
Note what standardisation does not do: it moves and rescales, but it never changes the shape. A skewed \(X\) gives a skewed \(Z\).
Let \(X \sim N(0,1)\) and \(Y = X^2\). Find the distribution of \(Y\).
Solution. For \(y > 0\): \[\begin{aligned} F_Y(y) &= P(Y \leq y) = P(X^2 \leq y) \\ &= P(-\sqrt{y} \leq X \leq \sqrt{y}) \\ &= \Phi(\sqrt{y}) - \Phi(-\sqrt{y}) \end{aligned}\]
Taking the derivative: \[f_Y(y) = \frac{1}{\sqrt{2\pi y}} e^{-y/2}, \quad y > 0\]
Recognise it
This is the Chi-squared distribution with 1 degree of freedom — and note \(g\) was not monotonic, which is why two branches had to be added up.
Left: the distribution of \(X\). Right: what \(g\) does to it.
viewof tr_x = Inputs.select(
new Map([["N(0,1)", "norm"], ["Uniform(0,1)", "unif"], ["Exp(1)", "exp"]]),
{label: "Distribution of X", value: "norm"}
)
viewof tr_g = Inputs.select(
new Map([["aX + b", "lin"], ["X²", "sq"], ["exp(X)", "expg"],
["|X|", "abs"], ["1/X", "inv"]]),
{label: "Transformation g", value: "sq"}
)
viewof tr_a = Inputs.range([-3, 3], {step: 0.1, value: 2, label: "a (linear only)"})
viewof tr_b = Inputs.range([-5, 5], {step: 0.5, value: 1, label: "b (linear only)"}){
const C = ldk.C;
// X: quantile function, density, support, and the probability at which g may
// stop being monotone (X = 0 for the two-branch transformations).
const DIST = {
norm: { q: p => ldk.qnorm(p), f: v => ldk.dnorm(v), xdom: [-3.6, 3.6], split: 0.5,
span: [[-9, 9], [-12, 12]] },
unif: { q: p => p, f: v => (v >= 0 && v <= 1 ? 1 : 0), xdom: [-0.05, 1.05],
split: null, span: [[0, 1], [0, 1]] },
exp: { q: p => -Math.log(1 - p), f: v => (v >= 0 ? Math.exp(-v) : 0),
xdom: [0, 7], split: null, span: [[0, 40], [0, 80]] }
}[tr_x];
const g = { lin: v => tr_a * v + tr_b, sq: v => v * v, expg: v => Math.exp(v),
abs: v => Math.abs(v), inv: v => 1 / v }[tr_g];
const twoBranch = DIST.split != null && ["sq", "abs", "inv"].includes(tr_g);
// ---- push the law of X through g on a grid that is uniform in probability.
// Equal mass per cell means the resolution automatically follows the mass,
// and heavy tails need no special casing.
const N = 1400;
const cuts = twoBranch ? [[1e-5, DIST.split], [DIST.split, 1 - 1e-5]]
: [[1e-5, 1 - 1e-5]];
const cells = [];
const branches = [];
for (const [ua, ub] of cuts) {
const pad = (ub - ua) * 1e-6;
const seg = [];
let up = null, yp = null;
for (let i = 0; i <= N; i++) {
const u = ua + pad + (ub - ua - 2 * pad) * i / N;
const y = g(DIST.q(u));
if (up !== null && Number.isFinite(y) && Number.isFinite(yp) && y !== yp) {
const c = { y: (y + yp) / 2, d: (u - up) / Math.abs(y - yp), m: u - up };
cells.push(c);
seg.push(c);
}
up = u; yp = y;
}
seg.sort((p, q) => p.y - q.y);
if (seg.length > 1) branches.push(seg);
}
const degenerate = cells.length === 0;
// display window: the central 99% of the mass, as the R version clipped to
const sorted = cells.slice().sort((p, q) => p.y - q.y);
const tot = ldk.sum(sorted.map(c => c.m));
const at = (frac) => {
if (!sorted.length) return tr_b; // a = 0: Y is the constant b
let acc = 0;
for (const c of sorted) { acc += c.m; if (acc >= frac * tot) return c.y; }
return sorted[sorted.length - 1].y;
};
let ylo = degenerate ? tr_b - 1 : at(0.005);
let yhi = degenerate ? tr_b + 1 : at(0.995);
if (!(yhi > ylo)) { ylo -= 0.5; yhi += 0.5; }
// Branches must be ADDED, not drawn on top of each other: X^2 and |X| fold
// the two halves of a normal onto the same y, and the density there is the
// sum of both contributions.
const NY = 700;
const ygrid = ldk.seq(ylo, yhi, NY);
const dens = new Float64Array(NY);
for (const seg of branches) {
const y0 = seg[0].y, y1 = seg[seg.length - 1].y;
let j = 0;
for (let i = 0; i < NY; i++) {
const t = ygrid[i];
if (t < y0 || t > y1) continue;
while (j < seg.length - 2 && seg[j + 1].y < t) j++;
const A = seg[j], B = seg[j + 1];
dens[i] += B.y > A.y ? A.d + (B.d - A.d) * (t - A.y) / (B.y - A.y) : A.d;
}
}
const curve = ygrid.map((t, i) => [t, dens[i]]);
// A lone spike (chi-squared at 0, say) must not flatten everything else, so
// anchor the y-axis on the density at the lower quartile and let poles clip.
const dmax = Math.max(...dens);
const iq = Math.round((at(0.25) - ylo) / (yhi - ylo) * (NY - 1));
const anchor = dens[Math.max(0, Math.min(NY - 1, iq))];
const cap = Math.min(dmax, anchor > 0 ? 2.1 * anchor : dmax) || 1;
// ---- E[Y] and sd(Y) by midpoint quadrature of g against the density of X.
// Running it twice — finer grid and wider window — is also the finiteness
// test: a divergent integral keeps growing, a convergent one settles.
// (1/X diverges at an interior point, so refining the grid is what catches
// it; exp(X) for X ~ Exp(1) diverges in the tail, which widening catches.)
const quad = (M, [xa, xb]) => {
const h = (xb - xa) / M;
let s1 = 0, s2 = 0, w = 0;
for (let i = 0; i < M; i++) {
const x = xa + (i + 0.5) * h;
const y = g(x);
if (!Number.isFinite(y)) continue;
const wt = DIST.f(x) * h;
s1 += wt * y; s2 += wt * y * y; w += wt;
}
s1 /= w; s2 /= w;
return { m: s1, s: Math.sqrt(Math.max(s2 - s1 * s1, 0)) };
};
const width = ([a, b]) => b - a;
const A = quad(20001, DIST.span[0]);
// B must have half A's step size, not merely more points over a wider window
const B = quad(Math.round(40002 * width(DIST.span[1]) / width(DIST.span[0])),
DIST.span[1]);
const finite = Math.abs(A.m - B.m) < 0.02 * (Math.abs(B.m) + 1) &&
Math.abs(A.s - B.s) < 0.05 * (B.s + 1);
// ---- the closed forms derived on the previous slides
let th = null;
if (tr_x === "norm" && tr_g === "sq") th = { f: ldk.dchisq1, lab: "chi-squared(1)" };
if (tr_x === "norm" && tr_g === "lin" && Math.abs(tr_a) > 1e-8)
th = { f: v => ldk.dnorm(v, tr_b, Math.abs(tr_a)),
lab: `N(${ldk.fmt(tr_b, 1)}, ${ldk.fmt(tr_a * tr_a, 2)})` };
if (tr_x === "norm" && tr_g === "expg") th = { f: v => ldk.dlnorm(v, 0, 1), lab: "lognormal" };
if (tr_x === "norm" && tr_g === "abs")
th = { f: v => (v >= 0 ? 2 * ldk.dnorm(v) : 0), lab: "half-normal" };
if (tr_x === "exp" && tr_g === "lin" && Math.abs(tr_a) > 1e-8)
th = { f: v => { const z = (v - tr_b) / tr_a; return z >= 0 ? Math.exp(-z) / Math.abs(tr_a) : 0; },
lab: "shifted/scaled Exp" };
// ---- draw ---------------------------------------------------------------
const svg = ldk.chart(920, 500);
const gx = ldk.seq(DIST.xdom[0], DIST.xdom[1], 500);
const fx = gx.map(v => [v, DIST.f(v)]);
const px = ldk.panel(svg, {
left: 0, w: 460, ydom: [0, Math.max(...fx.map(d => d[1])) * 1.15],
xdom: DIST.xdom, xlab: "x", ylab: "density",
margin: { l: 66, r: 14, t: 50, b: 52 }, nx: 5
});
px.fill(fx, 0, { fill: ldk.alpha(C.green, 0.18) });
px.path(fx, { stroke: C.green, "stroke-width": 3.5 });
px.header("X", { fill: C.green, size: 22 });
const py = ldk.panel(svg, {
left: 460, w: 460, xdom: [ylo, yhi], ydom: [0, cap * 1.2],
xlab: "y", ylab: "density",
margin: { l: 66, r: 14, t: 50, b: 52 }, nx: 5
});
if (degenerate) {
py.seg(tr_b, 0, tr_b, cap * 1.1, { stroke: C.blue, "stroke-width": 4 });
py.header("a = 0: Y is the constant b", { adj: 1, fill: C.grey, size: 15 });
} else {
py.fill(curve, 0, { fill: ldk.alpha(C.blue, 0.18) });
py.path(curve, { stroke: C.blue, "stroke-width": 3.5 });
if (th) {
py.path(ldk.seq(ylo, yhi, 600).map(v => [v, Math.min(th.f(v), cap * 1.2)]),
{ stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "9,6" });
py.legend([
{ label: "exact push-forward", color: C.blue, lw: 3.5 },
{ label: `theory: ${th.lab}`, color: C.pink, lw: 2.5, dash: "9,6" }
], { size: 14 });
}
py.header(finite
? `E[Y] = ${ldk.smart(B.m)} sd(Y) = ${ldk.smart(B.s)}`
: "E[Y], sd(Y) are not finite", { adj: 1, fill: C.grey, size: 15 });
}
py.header("Y = g(X)", { fill: C.blue, size: 22 });
return svg;
}Theorem — Law of Large Numbers
Let \(X_1, X_2, \ldots\) be i.i.d. with \(E[|X_i|] < \infty\) and \(E[X_i] = \mu\). Then
Key differences:
Each line is one sequence of draws, plotted as the running average \(\bar{X}_n\).
viewof ll_dist = Inputs.select(
new Map([["Bernoulli(0.3)", "bern"], ["Uniform(0,1)", "unif"],
["Exp(1)", "exp"], ["Cauchy(0,1) — no mean!", "cauchy"]]),
{label: "Population", value: "exp"}
)
viewof ll_paths = Inputs.range([1, 20], {step: 1, value: 8, label: "Paths"})
viewof ll_n = Inputs.range([1, 10000], {step: 1, value: 1, label: "Draws per path"})
viewof ll_go = Inputs.button("New simulation"){
const C = ldk.C;
const n = ll_n, m = ll_paths;
const u = ldk.rng(31 + ll_go);
// sd is the population sigma, which sets the 1/sqrt(n) envelope below.
// Cauchy has neither a mean nor a variance, so it gets no envelope.
const spec = {
bern: { r: () => (u() < 0.3 ? 1 : 0), mu: 0.3, sd: Math.sqrt(0.3 * 0.7),
lab: "Bernoulli(0.3)" },
unif: { r: () => u(), mu: 0.5, sd: Math.sqrt(1 / 12),
lab: "Uniform(0,1)" },
exp: { r: () => u.exp(), mu: 1, sd: 1, lab: "Exp(1)" },
cauchy: { r: () => u.cauchy(), mu: null, sd: null, lab: "Cauchy(0,1)" }
}[ll_dist];
// Always simulate the full slider range and draw only the first n. The frame
// then never moves: raising "draws per path" extends the very same lines to
// the right instead of rescaling the axes underneath them.
const NMAX = 10000;
const paths = [];
for (let j = 0; j < m; j++) {
const p = new Float64Array(NMAX);
let s = 0;
for (let i = 0; i < NMAX; i++) { s += spec.r(); p[i] = s / (i + 1); }
paths.push(p);
}
const kEarly = Math.max(1, Math.floor(NMAX / 20));
const early = paths.map(p => p[kEarly - 1]);
const final = paths.map(p => p[n - 1]);
const spread = m > 1 ? ldk.sd(early) : Math.abs(early[0] - spec.mu);
// ydom is built from the full-length paths too, so it is fixed in n as well
let ydom;
if (spec.mu === null) {
const flat = [];
const stride = Math.ceil(NMAX * m / 20000) || 1;
for (const p of paths) for (let i = 0; i < NMAX; i += stride) flat.push(p[i]);
flat.sort((a, b) => a - b);
const lim = Math.max(Math.abs(ldk.quantileSorted(flat, 0.02)),
Math.abs(ldk.quantileSorted(flat, 0.98))) * 1.2;
ydom = [-lim, lim];
} else {
const h = Math.max(0.35, 2.2 * spread);
ydom = [spec.mu - h, spec.mu + h];
}
// Log-spaced subsample: keeps the log-x picture at a fraction of the ink
const idxAll = [...new Set(ldk.seq(0, Math.log(NMAX), 700)
.map(v => Math.round(Math.exp(v))))]
.filter(v => v >= 1 && v <= NMAX);
const idx = idxAll.filter(v => v <= n); // only the drawn prefix
const { svg, p: pl } = ldk.single(880, 520, {
xdom: [1, NMAX], ydom, xlog: true,
xlab: "n (log scale)", ylab: "running average", margin: { t: 54 }
});
// The 1/sqrt(n) envelope, drawn first so the paths sit on top of it.
// sd(X-bar_n) = sigma/sqrt(n), so +/- 2 sigma/sqrt(n) is roughly where 95%
// of the paths should live. It spans the whole frame, not just the drawn
// prefix, so it stays a fixed reference as n grows.
if (spec.sd !== null) {
const K = 2;
const env = i => K * spec.sd / Math.sqrt(i);
const ring = [...idxAll.map(i => [i, spec.mu + env(i)]),
...idxAll.slice().reverse().map(i => [i, spec.mu - env(i)])];
let d = "";
for (let i = 0; i < ring.length; i++)
d += (i ? "L" : "M") + pl.x(ring[i][0]).toFixed(2) + "," + pl.y(ring[i][1]).toFixed(2);
pl.add(ldk.el("path", { d: d + "Z", fill: ldk.alpha(C.pink, 0.06), stroke: "none" }));
for (const sgn of [1, -1])
pl.path(idxAll.map(i => [i, spec.mu + sgn * env(i)]),
{ stroke: ldk.alpha(C.pink, 0.55), "stroke-width": 2,
"stroke-dasharray": "8,6" });
}
// blue -> green -> orange ramp across the paths
const stops = [C.blue, C.green, C.orange].map(h => {
const v = parseInt(h.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
});
const ramp = t => {
const s = Math.min(t * 2, 1.999), i = Math.floor(s), f = s - i;
const [a, b] = [stops[i], stops[i + 1]];
return `rgba(${a.map((c, k) => Math.round(c + (b[k] - c) * f)).join(",")},0.75)`;
};
paths.forEach((p, j) => {
pl.path(idx.map(i => [i, p[i - 1]]),
{ stroke: ramp(m > 1 ? j / (m - 1) : 0), "stroke-width": 1.8 });
});
if (spec.mu !== null) {
pl.hline(spec.mu, { stroke: C.pink, "stroke-width": 3.5 });
// in the right margin, clear of the paths crowding the line
pl.add(ldk.el("text", { x: pl.area.x0 + pl.area.w + 7, y: pl.y(spec.mu) + 6,
"font-size": 19, "font-weight": "bold", fill: C.pink }, "μ"), true);
pl.legend([{ label: "μ ± 2σ/√n", color: ldk.alpha(C.pink, 0.55), lw: 2, dash: "8,6" }],
{ size: 16 });
pl.header(m > 1
? `${spec.lab}: μ = ${ldk.fmt(spec.mu)} spread across paths at n = ${n}: ` +
`${ldk.fmt(ldk.sd(final), 3)} (σ/√n = ${ldk.fmt(spec.sd / Math.sqrt(n), 3)})`
: `${spec.lab}: μ = ${ldk.fmt(spec.mu)} average at n = ${n} is ` +
`${ldk.fmt(final[0], 3)}`,
{ fill: C.grey, size: 17 });
} else {
pl.header("Cauchy: E|X| is infinite, so the LLN does not apply — " +
"the averages never settle", { fill: C.pink, size: 17 });
}
return svg;
}Read the assumptions
“Averages converge” is not a law of nature. It is a theorem, and it has conditions.
Theorem — Central Limit Theorem
Let \(X_1, X_2, \ldots\) be i.i.d. with \(E[X_i] = \mu\) and \(\text{Var}(X_i) = \sigma^2 < \infty\). Then \[\frac{\bar{X}_n - \mu}{\sigma/\sqrt{n}} \xrightarrow{\ d\ } N(0,1)\] as \(n \to \infty\).
Practical interpretation: for large \(n\), \(\bar{X}_n\) is approximately normal, \[\bar{X}_n \approx N\left(\mu, \frac{\sigma^2}{n}\right)\]
Rule of thumb: \(n \geq 30\) is often sufficient — but see the next two slides for how badly that can fail.
Add up \(n\) independent \(\text{Uniform}(0,1)\) draws. No simulation: each extra term is one more convolution, so this is the exact density.
viewof cv_n = Inputs.range([1, 25], {step: 1, value: 1, label: "Terms n"})
viewof cv_c = Inputs.select(
new Map([["0 (none)", "none"], ["log n", "log"], ["√n", "sqrt"],
["n/2 = nμ", "mean"], ["n", "n"]]),
{label: "Centre: subtract aₙ", value: "none"}
)
viewof cv_s = Inputs.select(
new Map([["1 (none)", "none"], ["log n", "log"], ["√n", "sqrt"],
["√(n/12) = σ√n", "sigsqrt"], ["n", "n"], ["n²", "nsq"]]),
{label: "Scale: divide by bₙ", value: "none"}
)\(Z = \dfrac{S_n - a_n}{b_n}\), where \(S_n = X_1 + \cdots + X_n\).
Only \(a_n = n\mu\) together with \(b_n = \sigma\sqrt{n}\) gives the standard normal. The frame never moves: a wrong \(a_n\) walks the density off the edge, too small a \(b_n\) flattens it, too large a \(b_n\) collapses it to a spike.
cv_sum = {
const M = 240; // grid points per unit
const h = 1 / M;
let f = new Float64Array(M + 1).fill(1); // Uniform(0,1)
for (let k = 1; k < cv_n; k++) {
const L = f.length;
const cum = new Float64Array(L);
for (let i = 1; i < L; i++) cum[i] = cum[i - 1] + (f[i - 1] + f[i]) * 0.5 * h;
const total = cum[L - 1];
const at = j => j < 0 ? 0 : j >= L ? total : cum[j];
const out = new Float64Array(L + M);
for (let i = 0; i < L + M; i++) out[i] = at(i) - at(i - M);
f = out;
}
// guard against drift over 24 convolutions
let mass = 0;
for (let i = 1; i < f.length; i++) mass += (f[i - 1] + f[i]) * 0.5 * h;
for (let i = 0; i < f.length; i++) f[i] /= mass;
return { f, h, n: cv_n };
}
{
const C = ldk.C;
const { f, h } = cv_sum;
const n = cv_n;
const sigma = Math.sqrt(n / 12);
// The candidate centring and scaling sequences. Only nμ and sigma*sqrt(n)
// are the "right" ones; the rest are here to be seen failing.
const CENTRE = {
none: { f: () => 0, lab: "0" },
log: { f: k => Math.log(k), lab: "log n" },
sqrt: { f: k => Math.sqrt(k), lab: "√n" },
mean: { f: k => k / 2, lab: "n/2 = nμ" },
n: { f: k => k, lab: "n" }
}[cv_c];
const SCALE = {
none: { f: () => 1, lab: "1" },
log: { f: k => Math.log(k), lab: "log n" },
sqrt: { f: k => Math.sqrt(k), lab: "√n" },
sigsqrt: { f: k => Math.sqrt(k / 12), lab: "σ√n = √(n/12)" },
n: { f: k => k, lab: "n" },
nsq: { f: k => k * k, lab: "n²" }
}[cv_s];
const a = CENTRE.f(n);
const bRaw = SCALE.f(n);
const degenerate = !(bRaw > 1e-9); // log n is 0 at n = 1
const b = degenerate ? 1 : bRaw;
// f_Z(z) = b * f_S(a + b z), read off the S grid by linear interpolation
const fS = x => {
const t = x / h;
if (t < 0 || t > f.length - 1) return 0;
const i = Math.floor(t), w = t - i;
return i >= f.length - 1 ? f[f.length - 1] : f[i] + (f[i + 1] - f[i]) * w;
};
const fZ = z => b * fS(a + b * z);
const meanAt = k => (k / 2 - CENTRE.f(k)) / SCALE.f(k);
const sdAt = k => Math.sqrt(k / 12) / SCALE.f(k);
const mean = (n / 2 - a) / b, sd = Math.sqrt(n / 12) / b;
// Which of the three failure modes is this? The verdict is a claim about the
// limit, so compare the closed-form mean and sd at n against a far-away 64n;
// a short lookahead cannot tell sqrt(n)/log n from a constant.
const k2 = Math.max(n * 64, 64);
const m2 = meanAt(k2), s2 = sdAt(k2);
const ratio = s2 / sd;
let verdict, vcol;
if (degenerate) {
verdict = "b₁ = log 1 = 0 — not a valid scaling at n = 1"; vcol = C.grey;
} else if (Math.abs(m2) > Math.abs(mean) + 0.5) {
verdict = "not centred — the density walks off as n grows"; vcol = C.orange;
} else if (ratio > 1.3) {
verdict = "under-scaled — the spread keeps growing, no limit"; vcol = C.orange;
} else if (ratio < 0.75) {
verdict = "over-scaled — it collapses to a point mass at 0"; vcol = C.orange;
} else if (Math.abs(mean) < 0.05 && Math.abs(sd - 1) < 0.05) {
verdict = "standardised: this is exactly the CLT"; vcol = C.green;
} else {
verdict = `a proper limit, but N(0, ${ldk.fmt(sd * sd, 2)}), not N(0,1)`;
vcol = C.blue;
}
// Fixed frame. Everything above is what moves.
const XLO = -5, XHI = 5;
const grid = ldk.seq(XLO, XHI, 900);
const curve = grid.map(z => [z, fZ(z)]);
const peak = Math.max(...curve.map(d => d[1]));
const ymax = Math.min(Math.max(peak * 1.35, 0.62), 2.0);
const { svg, p } = ldk.single(880, 520, {
xdom: [XLO, XHI], ydom: [0, ymax],
xlab: "z", ylab: "density", margin: { t: 74 }
});
if (!degenerate) {
p.fill(curve, 0, { fill: ldk.alpha(C.blue, 0.18) });
p.path(curve, { stroke: C.blue, "stroke-width": 3.5 });
}
p.path(grid.map(z => [z, ldk.dnorm(z)]),
{ stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "9,6" });
p.vline(0, { stroke: C.green, "stroke-width": 2 });
// If the density has left the window, say which way it went
if (!degenerate && (mean > XHI || mean < XLO)) {
const right = mean > XHI;
const px = right ? p.area.x0 + p.area.w - 8 : p.area.x0 + 8;
p.add(ldk.el("text", { x: px, y: p.area.y0 + p.area.h * 0.42,
"text-anchor": right ? "end" : "start",
"font-size": 17, "font-weight": "bold", fill: C.orange },
`${right ? "→" : "←"} centred at ${ldk.smart(mean)}`), true);
}
p.legend([
{ label: `exact density of Z (n = ${n})`, color: C.blue, lw: 3.5 },
{ label: "target N(0,1)", color: C.pink, lw: 2.5, dash: "9,6" }
], { size: 15 });
p.header(`aₙ = ${CENTRE.lab} = ${ldk.smart(a)}` +
` bₙ = ${SCALE.lab} = ${ldk.smart(bRaw)}`, { line: 1, size: 17 });
p.header(degenerate
? verdict
: `E[Z] = ${ldk.smart(mean)} sd(Z) = ${ldk.smart(sd)} — ${verdict}`,
{ fill: vcol, size: 17 });
return svg;
}The population on the left can be as ugly as you like. Watch the right-hand panel anyway.
viewof cl_dist = Inputs.select(
new Map([["Exp(1) — skewed", "exp"],
["Bernoulli(0.08) — rare events", "bern"],
["Uniform(0,1)", "unif"],
["Two spikes", "bimodal"],
["Lognormal — very skewed", "lnorm"]]),
{label: "Population", value: "exp"}
)
viewof cl_n = Inputs.range([1, 100], {step: 1, value: 5, label: "Sample size n"})
viewof cl_reps = Inputs.range([500, 5000], {step: 500, value: 2000, label: "Number of samples"}){
const C = ldk.C;
const n = cl_n, R = cl_reps;
const u = ldk.rng(99);
// The population panel is drawn from the exact density; only the sampling
// distribution on the right is simulated, because that is the actual point.
const spec = {
exp: { r: () => u.exp(), mu: 1, sd: 1, disc: false,
dom: [0, 6], f: v => ldk.dexp(v, 1) },
bern: { r: () => (u() < 0.08 ? 1 : 0), mu: 0.08, sd: Math.sqrt(0.08 * 0.92),
disc: true, pmf: [[0, 0.92], [1, 0.08]] },
unif: { r: () => u(), mu: 0.5, sd: Math.sqrt(1 / 12), disc: false,
dom: [-0.05, 1.05], f: v => ldk.dunif(v, 0, 1) },
bimodal: { r: () => (u() < 0.5 ? -2 + 0.35 * u.normal() : 2 + 0.35 * u.normal()),
mu: 0, sd: Math.sqrt(4 + 0.35 ** 2), disc: false, dom: [-3.5, 3.5],
f: v => 0.5 * ldk.dnorm(v, -2, 0.35) + 0.5 * ldk.dnorm(v, 2, 0.35) },
lnorm: { r: () => Math.exp(1.4 * u.normal()), mu: Math.exp(1.4 ** 2 / 2),
sd: Math.sqrt((Math.exp(1.4 ** 2) - 1) * Math.exp(1.4 ** 2)),
disc: false, dom: [0, Math.exp(1.4 * ldk.qnorm(0.995))],
f: v => ldk.dlnorm(v, 0, 1.4) }
}[cl_dist];
const z = new Float64Array(R);
const scale = spec.sd / Math.sqrt(n);
for (let r = 0; r < R; r++) {
let s = 0;
for (let i = 0; i < n; i++) s += spec.r();
z[r] = (s / n - spec.mu) / scale;
}
const zm = ldk.mean(z), zs = ldk.sd(z);
let m3 = 0;
for (let i = 0; i < R; i++) m3 += (z[i] - zm) ** 3;
const skew = m3 / R / zs ** 3;
const svg = ldk.chart(920, 500);
// ---- left: the population ----------------------------------------------
const pp = ldk.panel(svg, {
left: 0, w: 460, xdom: spec.disc ? [-0.6, 1.6] : spec.dom,
ydom: spec.disc ? [0, 1] : [0, Math.max(...ldk.seq(spec.dom[0], spec.dom[1], 400)
.map(spec.f)) * 1.1],
xlab: "x", ylab: spec.disc ? "probability" : "density",
margin: { l: 66, r: 14, t: 50, b: 52 }, nx: 5
});
if (spec.disc) {
for (const [v, p] of spec.pmf)
pp.seg(v, 0, v, p, { stroke: ldk.alpha(C.green, 0.75), "stroke-width": 22 });
} else {
const pts = ldk.seq(spec.dom[0], spec.dom[1], 500).map(v => [v, spec.f(v)]);
pp.fill(pts, 0, { fill: ldk.alpha(C.green, 0.45) });
pp.path(pts, { stroke: C.green, "stroke-width": 3 });
}
pp.header("Population", { size: 20 });
// ---- right: standardised sample means -----------------------------------
// A lattice-valued mean needs bins as wide as the lattice, or the bars are
// spikes whose height has nothing to do with the density.
const uniq = [...new Set(Array.from(z, v => Math.round(v * 1e8) / 1e8))]
.sort((a, b) => a - b);
let hlo, hhi, nb;
if (uniq.length > 1 && uniq.length <= 60) {
let w = Infinity;
for (let i = 1; i < uniq.length; i++) w = Math.min(w, uniq[i] - uniq[i - 1]);
hlo = uniq[0] - w / 2;
hhi = uniq[uniq.length - 1] + w / 2;
nb = Math.min(400, Math.max(1, Math.round((hhi - hlo) / w)));
} else {
hlo = Math.min(...z); hhi = Math.max(...z); nb = 60;
}
const h = ldk.hist(z, hlo, hhi, nb);
const dmax = Math.max(0.42, ...h.dens.filter((_, i) => {
const c = hlo + (i + 0.5) * h.w;
return c >= -4.5 && c <= 4.5;
}));
const pz = ldk.panel(svg, {
left: 460, w: 460, xdom: [-4.5, 4.5], ydom: [0, dmax * 1.12],
xlab: "standardised sample mean", ylab: "density",
margin: { l: 66, r: 14, t: 72, b: 52 }, nx: 5
});
for (let i = 0; i < h.nb; i++) {
if (h.dens[i] === 0) continue;
pz.bar(hlo + i * h.w, hlo + (i + 1) * h.w, 0, h.dens[i],
{ fill: ldk.alpha(C.blue, 0.45), stroke: "white", "stroke-width": 0.8 });
}
pz.path(ldk.seq(-4.5, 4.5, 400).map(v => [v, ldk.dnorm(v)]),
{ stroke: C.pink, "stroke-width": 3.5 });
// two lines: the title and the readout will not fit side by side here
pz.header("Standardised sample means", { line: 1, size: 20 });
pz.header(`n = ${n} skew = ${ldk.signed(skew)}`, { adj: 1, fill: C.pink, size: 16 });
return svg;
}Takeaway
The CLT is a statement about the limit. How large \(n\) must be before the approximation is usable depends on the population’s shape, not on a magic number.
Two or more at a time
And then