From a Point Estimate to an Interval
// ---------------------------------------------------------------------------
// 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);
}
// Sampling *with* replacement — what the bootstrap needs, and what
// sampleWeighted deliberately is not.
function sampleReplace(n, v, u) {
const out = new Array(n);
for (let i = 0; i < n; i++) out[i] = v[Math.floor(u() * v.length)];
return out;
}
// Beta density — the conjugate posterior for a proportion.
const dbeta = (x, a, b) => (x <= 0 || x >= 1) ? 0 :
Math.exp(lgamma(a + b) - lgamma(a) - lgamma(b) +
(a - 1) * Math.log(x) + (b - 1) * Math.log(1 - x));
// Beta quantile, by inverting the regularized incomplete beta above. Used for
// the Clopper-Pearson interval, whose endpoints are exactly beta quantiles.
const qbeta = (p, a, b) => invert(x => betai(a, b, x), p, 0, 1);
// Gini coefficient of a sorted, non-negative sample: twice the area between
// the Lorenz curve and the diagonal. No closed-form standard error exists,
// which is exactly why the bootstrap earns its place.
function gini(sorted) {
const n = sorted.length;
let num = 0, tot = 0;
for (let i = 0; i < n; i++) { num += (i + 1) * sorted[i]; tot += sorted[i]; }
return tot > 0 ? (2 * num) / (n * tot) - (n + 1) / n : 0;
}
// ------------------------------------------------------- regression + series
// Simple OLS of y on x, with both the classical and the heteroskedasticity-
// robust (HC1) standard error. Deck 08 leans on the pair: the estimate and its
// standard error fail independently, and the standard error fails first.
function ols(x, y) {
const n = x.length;
const mx = mean(x), my = mean(y);
let sxx = 0, sxy = 0;
for (let i = 0; i < n; i++) { const dx = x[i] - mx; sxx += dx * dx; sxy += dx * (y[i] - my); }
const b = sxx > 0 ? sxy / sxx : 0;
const a = my - b * mx;
let rss = 0, meat = 0, tss = 0;
for (let i = 0; i < n; i++) {
const e = y[i] - a - b * x[i], dx = x[i] - mx;
rss += e * e; meat += dx * dx * e * e; tss += (y[i] - my) * (y[i] - my);
}
const se = sxx > 0 && n > 2 ? Math.sqrt(rss / (n - 2) / sxx) : NaN;
const seR = sxx > 0 && n > 2
? Math.sqrt(meat * n / (n - 2)) / sxx // HC1
: NaN;
return { a, b, se, seR, t: b / se, tR: b / seR,
r2: tss > 0 ? 1 - rss / tss : 0, n };
}
const corr = (x, y) => {
const mx = mean(x), my = mean(y);
let sxy = 0, sxx = 0, syy = 0;
for (let i = 0; i < x.length; i++) {
const dx = x[i] - mx, dy = y[i] - my;
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
}
return sxx > 0 && syy > 0 ? sxy / Math.sqrt(sxx * syy) : 0;
};
// Stationary AR(1), started from its stationary distribution so the first
// observations are not systematically closer to zero than the rest.
function ar1(n, rho, u, sd = 1) {
const out = new Float64Array(n);
let v = u.normal() * sd / Math.sqrt(Math.max(1 - rho * rho, 1e-9));
for (let i = 0; i < n; i++) { v = rho * v + sd * u.normal(); out[i] = v; }
return out;
}
const randomWalk = (n, u, sd = 1) => {
const out = new Float64Array(n);
let v = 0;
for (let i = 0; i < n; i++) { v += sd * u.normal(); out[i] = v; }
return out;
};
// G clusters of m units with intra-cluster correlation rho: a shared group
// draw plus an idiosyncratic one, weighted so the total variance stays 1.
function clustered(G, m, rho, u) {
const out = new Float64Array(G * m);
const sg = Math.sqrt(Math.max(rho, 0)), si = Math.sqrt(Math.max(1 - rho, 0));
for (let g = 0; g < G; g++) {
const shared = sg * u.normal();
for (let i = 0; i < m; i++) out[g * m + i] = shared + si * u.normal();
}
return out;
}
// ---------------------------------------------------------------- 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, dbeta, qbeta, dchisq, pchisq, qchisq, dt, pt, qt, invert,
gini,
seq, cumsum, sum, mean, sd, quantileSorted, hist, rng, sampleWeighted,
sampleReplace, ols, corr, ar1, randomWalk, clustered,
fmt, signed, smart, nice, logTicks
};
}You compute \(\hat\theta\) from the one dataset you have. A colleague with a different sample from the same population gets a different number. Neither of you is wrong.
The object this whole deck is about
The sampling distribution of \(\hat\theta\): the distribution of the numbers you would get by repeating the study. Every idea that follows is a statement about one of its features.
Where it sits
Bias, and what it costs — the first half of this deck
How wide it is
Variance, its floor, and the interval that reports it — the second half
A point estimate is one draw from that distribution. An interval reports its width.
Big picture: least squares and maximum likelihood both provide answers to the same question — which parameter values fit the data best? They just define “best” differently.
Key Insight
Under the right assumptions, these two different ideas lead to the exact same estimator. The next slide shows why.
Suppose \(Y = f(X;\theta) + \epsilon\) with \(\epsilon \sim N(0, \sigma^2)\).
The likelihood of the data is a product of normal densities: \[\text{MLE} = \arg\max_\theta \prod_{i=1}^n \frac{1}{\sqrt{2\pi\sigma^2}} e^{-\frac{(y_i - f(x_i;\theta))^2}{2\sigma^2}}\]
Taking logs and dropping the parts that don’t involve \(\theta\)… \[\text{MLE} = \arg\min_\theta \sum_{i=1}^n (y_i - f(x_i; \theta))^2 = \textbf{Least Squares}\]
Takeaway
If the errors are normal, least squares is equivalent to maximum likelihood.
Intuition: imagine repeating your study many times, each time computing \(\hat\theta\) from a fresh sample.
Formal Definition
\(\hat\theta\) is unbiased for \(\theta\) if \[\operatorname{E}[\hat\theta] = \theta \quad \text{for all } \theta \in \Theta\] Its bias is \(\operatorname{Bias}(\hat\theta) = \operatorname{E}[\hat\theta] - \theta\).
Unbiased
Biased
\(\operatorname{E}[\hat\sigma^2] = \frac{n-1}{n}\sigma^2\) — the bias vanishes as \(n\) grows, which is the first hint that unbiased and consistent are different promises.
{
const C = ldk.C;
const svg = ldk.chart(640, 430);
const p = ldk.panel(svg, {
xdom: [2, 100], ydom: [0.35, 1.1],
xlab: "sample size n", ylab: "expectation ÷ σ²",
margin: { l: 78, r: 20, t: 74, b: 54 }, nx: 5, ny: 4
});
const ns = ldk.seq(2, 100, 300);
p.path(ns.map(v => [v, 1]), { stroke: C.green, "stroke-width": 3.5 });
p.path(ns.map(v => [v, (v - 1) / v]), { stroke: C.pink, "stroke-width": 3.5 });
const n = ub_n, e = (n - 1) / n;
p.vline(n, { stroke: C.grey, "stroke-width": 1.6, "stroke-dasharray": "5,4" });
p.seg(n, e, n, 1, { stroke: C.orange, "stroke-width": 4 });
p.dot(n, e, 5, { fill: C.pink });
p.dot(n, 1, 5, { fill: C.green });
p.legend([{ label: "S² (unbiased)", color: C.green, lw: 3.5 },
{ label: "σ̂² (biased)", color: C.pink, lw: 3.5 }],
{ size: 15, bg: true, corner: "bottomright" });
p.header(`n = ${n}: bias = −σ²/n = ${ldk.fmt(-1 / n, 3)}·σ²`,
{ size: 17, fill: C.orange });
p.header("dividing by n instead of n − 1 shrinks the estimate",
{ line: 1, size: 15, fill: C.grey });
return svg;
}Every sample is drawn honestly from the true distribution — nothing is shifted. Both rows are handed the same data and differ only in the divisor.
The dotted rule is where each estimator is expected to land. Add repetitions and each solid average walks onto its own dotted rule — one of which is not \(\sigma^2\).
Pull the repetitions down and the averages become unreliable: unbiasedness is a statement about the long run, not about one study.
{
const C = ldk.C;
const sig2 = 1, N = 300, k = k_bias, n = n_bias;
const u = ldk.rng(11 + 977 * bias_draw);
// one honest sample per repetition; both estimators are handed the SAME data,
// so sigma-hat² is literally S² x (n-1)/n — the divisor is the only difference
const s2u = new Array(N), s2b = new Array(N);
for (let r = 0; r < N; r++) {
const x = new Array(n);
for (let i = 0; i < n; i++) x[i] = 3 + u.normal(); // mean 3, variance 1
const m = ldk.mean(x);
let ss = 0;
for (let i = 0; i < n; i++) ss += (x[i] - m) * (x[i] - m);
s2u[r] = ss / (n - 1);
s2b[r] = ss / n;
}
const xhi = Math.min(7, sig2 * (1 + 4.2 * Math.sqrt(2 / Math.max(1, n - 1))));
const svg = ldk.chart(880, 520);
const jit = ldk.rng(5);
const strip = (top, v, label, note, col, target, last) => {
const p = ldk.panel(svg, {
top, w: 880, h: 260, xdom: [0, xhi], ydom: [0, 1], yticks: [],
xlab: last ? "estimate of σ² from one sample" : null,
margin: { l: 132, r: 26, t: 62, b: last ? 62 : 26 }, nx: 5
});
p.vline(sig2, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "8,5" });
p.vline(target, { stroke: ldk.alpha(col, 0.8), "stroke-width": 2,
"stroke-dasharray": "2,4" });
for (let i = 0; i < k; i++)
p.dot(Math.min(v[i], xhi), 0.12 + 0.76 * jit(), 3.6, { fill: ldk.alpha(col, 0.4) });
const avg = ldk.mean(v.slice(0, k));
p.vline(avg, { stroke: col, "stroke-width": 3.5 });
p.add(ldk.el("text", {
x: p.area.x0 - 16, y: p.area.y0 + p.area.h / 2 + 8, "text-anchor": "end",
"font-size": 23, "font-weight": "bold", fill: col
}, label), true);
p.header(`average of ${k} = ${ldk.fmt(avg, 3)} expected ${ldk.fmt(target, 3)}`,
{ size: 17, fill: col });
p.header(note, { line: 1, size: 15, fill: C.grey });
return avg;
};
strip(0, s2u, "÷ (n−1)", "S² — unbiased", C.blue, sig2, false);
strip(260, s2b, "÷ n", "σ̂² — biased low by σ²/n", C.pink,
sig2 * (n - 1) / n, true);
return svg;
}Think of an estimator as arrows thrown at a target, where the bullseye is the true \(\theta\):
Rows differ in bias — whether the cloud is centred on the bullseye. Columns differ in variance — how tightly it is packed.
At one or two shots the four targets are indistinguishable. The pattern is a property of the distribution, not of any single estimate you will ever compute.
Keep in mind: each dot is an estimate, not a data point!
{
const C = ldk.C;
const cfgs = [
{ label: "low bias, low variance", bx: 0, by: 0, sd: 0.10, col: C.green, seed: 1 },
{ label: "low bias, high variance", bx: 0, by: 0, sd: 0.42, col: C.blue, seed: 2 },
{ label: "high bias, low variance", bx: 0.55, by: 0.4, sd: 0.10, col: C.pink, seed: 3 },
{ label: "high bias, high variance", bx: 0.55, by: 0.4, sd: 0.42, col: C.orange, seed: 4 }
];
const svg = ldk.chart(880, 520);
cfgs.forEach((cfg, i) => {
const p = ldk.panel(svg, {
left: (i % 2) * 440, top: Math.floor(i / 2) * 260, w: 440, h: 260,
xdom: [-1, 1], ydom: [-1, 1], axes: false,
margin: { l: 30, r: 30, t: 48, b: 14 }
});
const a = p.area;
const cx = a.x0 + a.w / 2, cy = a.y0 + a.h / 2, R = Math.min(a.w, a.h) / 2;
[1, 0.66, 0.33].forEach((f, j) =>
p.add(ldk.el("circle", { cx, cy, r: R * f,
fill: j % 2 === 0 ? "#f4f4f4" : "#e2e2e2", stroke: C.axis, "stroke-width": 1 })));
p.add(ldk.el("circle", { cx, cy, r: 3, fill: C.ink }));
const u = ldk.rng(cfg.seed);
for (let s = 0; s < shots_bv; s++)
p.add(ldk.el("circle", {
cx: cx + (cfg.bx + u.normal() * cfg.sd) * R,
cy: cy + (cfg.by + u.normal() * cfg.sd) * R,
r: 4, fill: ldk.alpha(cfg.col, 0.75)
}), true);
p.header(cfg.label, { size: 17, fill: cfg.col, adj: 0.5 });
});
return svg;
}Definition and Decomposition
\[\operatorname{MSE}(\hat{\theta}) = \underbrace{\operatorname{Var}(\hat{\theta})}_{\text{Variance}} + \underbrace{[\operatorname{Bias}(\hat{\theta})]^2}_{\text{Bias}^2}\]
We don’t only look for unbiased estimators: a slightly biased estimator with much lower variance can have lower MSE overall. Drag the slider to read the trade-off at each point.
{
const C = ldk.C;
const b2 = x => 2 * Math.exp(-0.3 * x) + 0.2;
const vr = x => 0.1 * x + 0.1;
const ms = x => b2(x) + vr(x);
const svg = ldk.chart(660, 470);
const p = ldk.panel(svg, {
xdom: [0, 10], ydom: [0, 2.6], xlab: "model complexity", ylab: "error",
margin: { l: 70, r: 20, t: 76, b: 54 }, nx: 5, ny: 5
});
const xs = ldk.seq(0, 10, 220);
p.path(xs.map(x => [x, b2(x)]), { stroke: C.blue, "stroke-width": 3 });
p.path(xs.map(x => [x, vr(x)]), { stroke: C.orange, "stroke-width": 3 });
p.path(xs.map(x => [x, ms(x)]), { stroke: C.pink, "stroke-width": 4 });
const xStar = xs.reduce((a, x) => ms(x) < ms(a) ? x : a, xs[0]);
p.vline(xStar, { stroke: C.green, "stroke-width": 2, "stroke-dasharray": "7,5" });
const x = complexity;
p.vline(x, { stroke: C.grey, "stroke-width": 1.6, "stroke-dasharray": "4,4" });
[[b2(x), C.blue], [vr(x), C.orange], [ms(x), C.pink]].forEach(([v, c]) =>
p.dot(x, v, 5.5, { fill: c }));
p.legend([{ label: "bias²", color: C.blue, lw: 3 },
{ label: "variance", color: C.orange, lw: 3 },
{ label: "MSE", color: C.pink, lw: 4 },
{ label: "MSE-minimising", color: C.green, lw: 2, dash: "7,5" }],
{ size: 14, bg: true });
p.header(`bias² ${ldk.fmt(b2(x))} + variance ${ldk.fmt(vr(x))} = MSE ${ldk.fmt(ms(x))}`,
{ size: 16, fill: C.pink });
p.header("the minimum sits where neither term is smallest",
{ line: 1, size: 14, fill: C.grey });
return svg;
}For \(X_1, \ldots, X_n \sim N(\mu, 1)\), compare two estimators of \(\mu\):
Their mean squared errors are: \[\operatorname{MSE}(\hat{\mu}_1) = \frac{1}{n} \qquad\qquad \operatorname{MSE}(\hat{\mu}_2) = \frac{c^2}{n} + (1-c)^2\mu^2\]
Takeaway
When \(\mu\) is close to 0, the biased \(\hat\mu_2\) can have smaller MSE than the unbiased \(\bar{X}\). This is the idea behind shrinkage estimators like Ridge regression.
Shaded: where the shrunk estimator has the lower MSE.
Shrinking hard (\(c\) small) wins big near \(\mu = 0\) and loses badly far from it. Raising \(n\) narrows the shaded band — with enough data there is little left to gain by biasing the estimate.
{
const C = ldk.C;
const n = n_shrink, c = c_shrink;
const m1 = () => 1 / n;
const m2 = mu => (c * c) / n + (1 - c) * (1 - c) * mu * mu;
const muMax = 2, yMax = Math.max(0.6, m2(muMax)) * 1.08;
const muStar = Math.sqrt((1 - c * c) / (n * (1 - c) * (1 - c)));
const svg = ldk.chart(880, 520);
const p = ldk.panel(svg, {
xdom: [-muMax, muMax], ydom: [0, yMax], xlab: "μ (true mean)", ylab: "MSE",
margin: { l: 92, r: 26, t: 84, b: 62 }, nx: 5, ny: 5
});
const lo = Math.max(-muMax, -muStar), hi = Math.min(muMax, muStar);
if (hi > lo) p.bar(lo, hi, 0, yMax, { fill: ldk.alpha(C.green, 0.13), stroke: "none" });
const mus = ldk.seq(-muMax, muMax, 260);
p.path(mus.map(m => [m, m1()]), { stroke: C.blue, "stroke-width": 3 });
p.path(mus.map(m => [m, m2(m)]), { stroke: C.pink, "stroke-width": 3 });
p.legend([{ label: "MSE(X̄) = 1/n (unbiased)", color: C.blue, lw: 3 },
{ label: "MSE(cX̄) = c²/n + (1−c)²μ²", color: C.pink, lw: 3 }],
{ size: 15, bg: true });
p.header(`shrinkage wins for |μ| < ${ldk.fmt(muStar, 2)}`, { size: 19, fill: C.green });
p.header("and loses everywhere else — the gain is bought, not free",
{ line: 1, size: 15, fill: C.grey });
return svg;
}Every property so far was stated for a fixed \(n\). Now let \(n\) grow: the running average settles down near the true \(\mu\).
The dashed funnel is \(\mu \pm \sigma/\sqrt n\) — the path is squeezed by arithmetic, not by luck. Hold on to that shape: by the end of this deck it will be an interval, drawn the other way round.
{
const C = ldk.C;
const mu = 2, sigma = 1.5, Nmax = 500, n = n_lln;
const u = ldk.rng(7 + 977 * lln_draw);
const run = [];
let s = 0;
for (let i = 0; i < Nmax; i++) { s += mu + u.normal() * sigma; run.push(s / (i + 1)); }
const svg = ldk.chart(880, 520);
const p = ldk.panel(svg, {
xdom: [1, Nmax], ydom: [mu - 2.6, mu + 2.6],
xlab: "n (draws so far)", ylab: "running average X̄ₙ",
margin: { l: 100, r: 26, t: 84, b: 62 }, nx: 5, ny: 5
});
const env = ldk.seq(1, Nmax, 260);
p.path(env.map(v => [v, mu + sigma / Math.sqrt(v)]),
{ stroke: ldk.alpha(C.grey, 0.55), "stroke-width": 1.8, "stroke-dasharray": "5,4" });
p.path(env.map(v => [v, mu - sigma / Math.sqrt(v)]),
{ stroke: ldk.alpha(C.grey, 0.55), "stroke-width": 1.8, "stroke-dasharray": "5,4" });
p.hline(mu, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "9,6" });
p.path(run.slice(0, n).map((v, i) => [i + 1, v]), { stroke: C.blue, "stroke-width": 2.8 });
p.dot(n, run[n - 1], 5.5, { fill: C.blue });
p.legend([{ label: "true μ", color: C.green, lw: 3, dash: "9,6" },
{ label: "μ ± σ/√n", color: C.grey, lw: 1.8, dash: "5,4" }],
{ size: 15, bg: true });
p.header(`n = ${n}: X̄ₙ = ${ldk.fmt(run[n - 1], 3)} (error ${ldk.signed(run[n - 1] - mu, 3)})`,
{ size: 19, fill: C.blue });
return svg;
}Unbiasedness is about being right on average for a given \(n\). Consistency instead asks: does the estimator get closer and closer to the truth as we collect more data?
Definition
\(\hat{\theta}_n\) is consistent if \(\hat{\theta}_n \xrightarrow{p} \theta\) as \(n \to \infty\), i.e. for every \(\epsilon > 0\): \[\lim_{n \to \infty} P\left(|\hat{\theta}_n - \theta| > \epsilon\right) = 0\]
An Easy Way to Check It
If both of these hold, \(\hat\theta_n\) is automatically consistent: \[\lim_{n \to \infty} \operatorname{Bias}(\hat{\theta}_n) = 0 \qquad \text{and} \qquad \lim_{n \to \infty} \operatorname{Var}(\hat{\theta}_n) = 0\]
The whole sampling distribution collapses onto \(\theta\); the faint curves are the smaller sample sizes this one grew out of.
Consistency is the shaded percentage going to 100 for every \(\epsilon\), however small. It says nothing about how fast — that is the next section.
{
const C = ldk.C;
const theta = 2, sigma = 1, n = n_consist, eps = eps_consist;
const sd = sigma / Math.sqrt(n);
const yMax = ldk.dnorm(0, 0, sigma / Math.sqrt(200)) * 1.06;
const svg = ldk.chart(880, 520);
const p = ldk.panel(svg, {
xdom: [theta - 3, theta + 3], ydom: [0, yMax], xlab: "θ̂ₙ", ylab: "density",
margin: { l: 92, r: 26, t: 84, b: 62 }, nx: 5, ny: 4
});
[2, 5, 20, 80].filter(v => v < n).forEach(v => {
const s = sigma / Math.sqrt(v);
p.path(ldk.seq(theta - 3, theta + 3, 200).map(x => [x, ldk.dnorm(x, theta, s)]),
{ stroke: ldk.alpha(C.blue, 0.22), "stroke-width": 1.8 });
});
const xs = ldk.seq(theta - eps, theta + eps, 120);
p.fill(xs.map(x => [x, ldk.dnorm(x, theta, sd)]), 0,
{ fill: ldk.alpha(C.green, 0.22), stroke: "none" });
p.path(ldk.seq(theta - 3, theta + 3, 320).map(x => [x, ldk.dnorm(x, theta, sd)]),
{ stroke: C.blue, "stroke-width": 3.4 });
p.vline(theta, { stroke: C.grey, "stroke-width": 1.8, "stroke-dasharray": "5,4" });
p.vline(theta - eps, { stroke: C.green, "stroke-width": 2.2 });
p.vline(theta + eps, { stroke: C.green, "stroke-width": 2.2 });
const prob = 2 * ldk.pnorm(eps / sd) - 1;
p.header(`n = ${n}: P(|θ̂ₙ − θ| ≤ ε) = ${ldk.fmt(100 * prob, 1)}%`, { size: 19, fill: C.blue });
p.header(`SE = σ/√n = ${ldk.fmt(sd, 3)}`, { line: 1, size: 16, fill: C.grey });
return svg;
}Among all unbiased estimators, some are more precise than others. Efficiency asks: is \(\hat\theta\) the most precise unbiased estimator possible given some sample size?
It turns out there is a hard floor on how small the variance of an unbiased estimator can ever be — no amount of cleverness can beat it.
That floor is the Cramér-Rao Lower Bound.
Fisher Information
\[I(\theta) = -\operatorname{E}\left[\frac{\partial^2 \ln f(X;\theta)}{\partial \theta^2}\right]\] Roughly: how sharply peaked the likelihood is. More information means the data pins down \(\theta\) more precisely.
Cramér-Rao Lower Bound (CRLB)
For any unbiased estimator \(\hat\theta\) based on \(n\) observations: \[\operatorname{Var}(\hat{\theta}) \geq \frac{1}{nI(\theta)}\] An unbiased estimator that achieves this bound is called efficient.
For \(X_i \sim N(\mu, \sigma^2)\), estimating \(\mu\):
Conclusion
\(\operatorname{Var}(\bar{X})\) exactly meets the CRLB, so \(\bar{X}\) is an efficient estimator of \(\mu\) — no unbiased estimator can do better.
\(\operatorname{Var}(\bar X)\) sits exactly on the CRLB floor; the shaded region below it is empty for every unbiased estimator, at every \(n\).
The wasteful estimator is unbiased too — it simply throws away information. It never crosses the floor, it just needs more data to reach the same precision.
{
const C = ldk.C;
const s2 = 4, waste = 1.6, n = n_eff;
const crlb = v => s2 / v, ineff = v => waste * s2 / v;
const svg = ldk.chart(880, 520);
const p = ldk.panel(svg, {
xdom: [2, 60], ydom: [0, ineff(2) * 1.04], xlab: "n", ylab: "Var(θ̂)",
margin: { l: 96, r: 26, t: 84, b: 62 }, nx: 5, ny: 4
});
const ns = ldk.seq(2, 60, 240);
p.fill(ns.map(v => [v, crlb(v)]), 0, { fill: ldk.alpha(C.pink, 0.08), stroke: "none" });
p.path(ns.map(v => [v, ineff(v)]), { stroke: C.orange, "stroke-width": 3 });
p.path(ns.map(v => [v, crlb(v)]), { stroke: C.pink, "stroke-width": 5, "stroke-dasharray": "9,5" });
p.path(ns.map(v => [v, crlb(v)]), { stroke: C.blue, "stroke-width": 2 });
p.vline(n, { stroke: C.grey, "stroke-width": 1.8, "stroke-dasharray": "4,4" });
p.dot(n, crlb(n), 6, { fill: C.blue });
p.dot(n, ineff(n), 6, { fill: C.orange });
p.legend([{ label: "CRLB = σ²/n — nothing lives below", color: C.pink, lw: 5, dash: "9,5" },
{ label: "Var(X̄) — efficient, on the floor", color: C.blue, lw: 2 },
{ label: "unbiased, but wastes data", color: C.orange, lw: 3 }],
{ size: 15, bg: true });
p.header(`n = ${n}: Var(X̄) = ${ldk.fmt(crlb(n), 3)} vs ${ldk.fmt(ineff(n), 3)}`,
{ size: 19, fill: C.blue });
p.header(`${ldk.fmt(100 * (ineff(n) / crlb(n) - 1), 0)}% more variance from the same data`,
{ line: 1, size: 16, fill: C.orange });
return svg;
}The CRLB is the last statement this deck makes about \(\operatorname{Var}(\hat\theta)\) in the abstract. It splits the rest of the course in two.
Can the floor be reached?
If a floor exists, is there a recipe that lands on it? That is sufficiency, Rao-Blackwell and the UMVUE — picked up again near the end of this deck.
What is the floor for?
A variance nobody reports is useless. Turning \(\operatorname{Var}(\hat\theta)\) into a number on a page is interval estimation — everything from here to the bootstrap.
We take the second road first, because it is the one that reaches a published table.
Every slide of the last section computed \(\operatorname{Var}(\bar X) = \sigma^2/n\). Its square root is the number you report.
Standard deviation
\[\operatorname{SD}(X_i) = \sqrt{\operatorname{Var}(X_i)}\]
A property of the population. It does not shrink with \(n\); a larger sample only estimates it better.
Standard error
\[\operatorname{SE}(T) = \sqrt{\operatorname{\widehat{Var}}(T)}\]
A property of the estimator \(T = T(X_1,\dots,X_n)\) — the width of the sampling distribution we have been drawing all along.
Every interval from here is built from the second one
\(\bar x \pm 1.96\,s\) says where the data lie; \(\bar x \pm 1.96\,s/\sqrt{n}\) says where the mean lies. The Cramér-Rao bound is a floor on the second, and so on every interval below.
A point estimate answers what is our best guess. It never answers how good is the guess — and the second question is the one a policy note has to survive.
The object of the next four sections
An interval estimator is a pair of statistics \(\hat\theta_L(X) \le \hat\theta_U(X)\), and its coverage probability is \[P_\theta\big(\hat\theta_L \le \theta \le \hat\theta_U\big).\] If this equals \(1-\alpha\) for every \(\theta\), the interval has confidence level \(1-\alpha\).
Read the probability carefully
\(\hat\theta_L\) and \(\hat\theta_U\) are the random variables. \(\theta\) is a fixed unknown constant. The probability is over intervals, not over \(\theta\).
Each horizontal line is one 95% interval from one sample — the same repeated sampling as the dot strips earlier, with a bar instead of a dot. The vertical rule is the true \(\mu\), which in real life you never see.
viewof ci_n = Inputs.range([5, 200], {step: 1, value: 20, label: "Sample size n"})
viewof ci_lev = Inputs.select(new Map([["80%", 0.80], ["90%", 0.90],
["95%", 0.95], ["99%", 0.99]]),
{label: "Level", value: 0.95})
viewof ci_pop = Inputs.select(new Map([["Normal", "norm"],
["Skewed (income-like)", "skew"],
["Bernoulli, p = 0.05", "bern"]]),
{label: "Population", value: "norm"})
viewof ci_meth = Inputs.select(new Map([["t interval (correct)", "t"],
["z interval, s for σ", "z"]]),
{label: "Method", value: "t"})
viewof ci_draw = Inputs.button("Draw 40 more")95% is a promise about the procedure, not about the interval on your screen. Break an assumption — a skewed population at small \(n\), a rare binary event, or \(z\) where \(t\) belongs — and the promise quietly stops being kept.
{
const C = ldk.C;
const n = ci_n, lev = ci_lev, batches = ci_draw + 1;
const u = ldk.rng(31415);
const mu = ci_pop === "norm" ? 100 : ci_pop === "skew" ? Math.exp(3.1 + 0.62 * 0.62 / 2)
: 0.05;
const drawSample = () => {
const v = new Array(n);
for (let i = 0; i < n; i++)
v[i] = ci_pop === "norm" ? 100 + 15 * u.normal()
: ci_pop === "skew" ? Math.exp(3.1 + 0.62 * u.normal())
: (u() < 0.05 ? 1 : 0);
return v;
};
const crit = ci_meth === "t" ? ldk.qt(1 - (1 - lev) / 2, n - 1)
: ldk.qnorm(1 - (1 - lev) / 2);
const shown = [];
let cov = 0, tot = 0;
const trace = [];
for (let b = 0; b < batches; b++)
for (let k = 0; k < 40; k++) {
const v = drawSample(), m = ldk.mean(v), se = ldk.sd(v) / Math.sqrt(n);
const lo = m - crit * se, hi = m + crit * se;
const ok = lo <= mu && mu <= hi;
if (ok) cov++;
tot++;
if (b === batches - 1) shown.push({ lo, hi, m, ok });
if (tot % 4 === 0) trace.push([tot, cov / tot]);
}
const svg = ldk.chart(880, 520);
const half = Math.max(...shown.map(s => Math.abs(s.m - mu) + (s.hi - s.lo) / 2));
const pl = ldk.panel(svg, {
left: 0, w: 470, xdom: [mu - half * 1.05, mu + half * 1.05], ydom: [0, 41],
xlab: "value", ylab: "sample", margin: { l: 60, r: 14, t: 66, b: 52 },
nx: 4, ny: 4
});
shown.forEach((s, i) => {
const col = s.ok ? ldk.alpha(C.blue, 0.75) : C.pink;
pl.seg(s.lo, i + 0.5, s.hi, i + 0.5, { stroke: col, "stroke-width": 2.4 });
pl.dot(s.m, i + 0.5, 2.2, { fill: col });
});
pl.vline(mu, { stroke: C.green, "stroke-width": 3.5 });
pl.header(`last 40 intervals — ${shown.filter(s => !s.ok).length} miss`,
{ size: 17 });
const pr = ldk.panel(svg, {
left: 470, w: 410, xdom: [0, tot], ydom: [Math.min(0.55, lev - 0.35), 1.0],
xlab: "intervals drawn", ylab: "running coverage",
margin: { l: 66, r: 16, t: 66, b: 52 }, nx: 4
});
pr.hline(lev, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "9,6" });
pr.path(trace, { stroke: C.pink, "stroke-width": 3 });
pr.legend([{ label: `nominal ${(100 * lev).toFixed(0)}%`, color: C.green, lw: 3, dash: "9,6" },
{ label: "actual", color: C.pink, lw: 3 }],
{ size: 14, bg: true, corner: "bottomright" });
pl.header(`nominal ${(100 * lev).toFixed(0)}% → actual ` +
`${ldk.fmt(100 * cov / tot, 1)}% (${cov} of ${tot})`,
{ line: 1, size: 19, fill: C.pink });
return svg;
}The point
Nominal coverage is a property of the procedure under its assumptions. The label keeps saying 95% also if the assumptions are violated.
The scatter of the dots in Unbiasedness, Visualized and the length of these bars are the same quantity: one standard error, times a critical value.
Correct interpretation
A confidence interval is NOT
Once you have seen the data, the interval either contains \(\theta\) or it does not. Nothing random is left to attach a probability to.
Definition — Pivotal Quantity
A pivot is a function \(Q(X, \theta)\) of the data and the parameter whose distribution does not depend on \(\theta\).
Find a pivot, look up its quantiles, and invert:
\[P\big(q_{\alpha/2} \le Q(X,\theta) \le q_{1-\alpha/2}\big) = 1-\alpha \quad\Longrightarrow\quad \text{solve for } \theta\]
Known variance
\[Q = \frac{\bar X - \mu}{\sigma/\sqrt{n}} \sim N(0,1)\] \[\bar x \pm z_{1-\alpha/2}\,\frac{\sigma}{\sqrt n}\]
Unknown variance
\[Q = \frac{\bar X - \mu}{S/\sqrt{n}} \sim t_{n-1}\] \[\bar x \pm t_{n-1,\,1-\alpha/2}\,\frac{s}{\sqrt n}\]
The \(\pm\) form is not the definition of an interval — it is what a symmetric pivot gives you.
Pivot for a normal variance
\[Q = \frac{(n-1)S^2}{\sigma^2} \sim \chi^2_{n-1}\]
Inverting \(P\big(\chi^2_{n-1,\alpha/2} \le Q \le \chi^2_{n-1,1-\alpha/2}\big) = 1-\alpha\) gives
\[\left[\ \frac{(n-1)s^2}{\chi^2_{n-1,\,1-\alpha/2}}\ ,\ \frac{(n-1)s^2}{\chi^2_{n-1,\,\alpha/2}}\ \right]\]
Note what changed
The \(\chi^2\) distribution is skewed, so the two critical values are not symmetric about \(n-1\) — and \(s^2\) is not at the centre of its own interval.
The interval in every textbook, \(\hat p \pm z\sqrt{\hat p(1-\hat p)/n}\), applied to unemployment rates, vote shares and take-up rates.
viewof w_n = Inputs.range([10, 200], {step: 1, value: 40, label: "n"})
viewof w_meth = Inputs.select(new Map([["All three", "all"], ["Wald only", "wald"],
["Wilson only", "wilson"],
["Clopper–Pearson only", "cp"]]),
{label: "Interval", value: "all"})
viewof w_lev = Inputs.select(new Map([["90%", 0.90], ["95%", 0.95], ["99%", 0.99]]),
{label: "Level", value: 0.95})Coverage here is exact, not simulated: for each \(p\) we sum \(\binom{n}{k}p^k(1-p)^{n-k}\) over every \(k\) whose interval covers \(p\).
Raising \(n\) does not smooth the sawtooth away. It moves it.
{
const C = ldk.C, n = w_n, lev = w_lev;
const z = ldk.qnorm(1 - (1 - lev) / 2), a = 1 - lev;
const wald = k => { const p = k / n, s = Math.sqrt(p * (1 - p) / n);
return [p - z * s, p + z * s]; };
const wilson = k => {
const p = k / n, d = 1 + z * z / n;
const c = (p + z * z / (2 * n)) / d;
const h = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d;
return [c - h, c + h];
};
const cp = k => [k === 0 ? 0 : ldk.qbeta(a / 2, k, n - k + 1),
k === n ? 1 : ldk.qbeta(1 - a / 2, k + 1, n - k)];
const methods = [["Wald", wald, C.pink], ["Wilson", wilson, C.blue],
["Clopper–Pearson", cp, C.orange]]
.filter(m => w_meth === "all" ||
(w_meth === "wald" && m[0] === "Wald") ||
(w_meth === "wilson" && m[0] === "Wilson") ||
(w_meth === "cp" && m[0].startsWith("Clopper")));
const ps = ldk.seq(0.005, 0.5, 340);
const cover = (fn) => {
const ivs = []; for (let k = 0; k <= n; k++) ivs.push(fn(k));
return ps.map(p => {
let c = 0;
for (let k = 0; k <= n; k++)
if (ivs[k][0] <= p && p <= ivs[k][1]) c += ldk.dbinom(k, n, p);
return [p, c];
});
};
const { svg, p } = ldk.single(880, 520, {
xdom: [0, 0.5], ydom: [Math.min(0.6, lev - 0.3), 1.01],
xlab: "true proportion p", ylab: "actual coverage",
margin: { t: 74 }, nx: 6
});
p.hline(lev, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "9,6" });
let worst = 1, worstAt = 0;
for (const [name, fn, col] of methods) {
const cv = cover(fn);
p.path(cv, { stroke: col, "stroke-width": 2.6 });
if (name === "Wald" || methods.length === 1)
for (const [pp, c] of cv) if (c < worst) { worst = c; worstAt = pp; }
}
p.legend([{ label: `nominal ${(100 * lev).toFixed(0)}%`, color: C.green, lw: 3, dash: "9,6" },
...methods.map(([nm, , col]) => ({ label: nm, color: col, lw: 2.6 }))],
{ size: 14, bg: true, corner: "bottomright" });
p.header(`worst coverage ${ldk.fmt(100 * worst, 1)}% at p = ${ldk.fmt(worstAt, 3)}`,
{ line: 1, size: 19, fill: C.pink });
p.header(`n = ${n}`, { size: 16, fill: C.grey });
return svg;
}Conservative is not free
“Guaranteed at least 95%” and “exactly 95%” are different promises. The first costs precision on every single application.
For the mean, the half-width of a \(1-\alpha\) interval is
\[w \;=\; z_{1-\alpha/2}\,\frac{\sigma}{\sqrt n} \qquad\Longleftrightarrow\qquad n \;=\; \left(\frac{z_{1-\alpha/2}\,\sigma}{w}\right)^{\!2}\]
Precision is bought at a quadratic price
Halving the width costs four times the sample. Quartering it costs sixteen times.
Read forwards, this is a diagnosis: here is how precise we were.
Read backwards, it is a design tool: here is the sample we must buy.
This is the Cramér-Rao bound, in the currency of a budget
The floor on \(\operatorname{SE}(\hat\theta)\) is a floor on \(w\), so the CRLB sets the cheapest possible study that can reach a stated precision. An efficient estimator is one that does not make you buy observations twice.
viewof pp_n = Inputs.range([2, 4], {step: 0.01, value: 3.08, label: "log₁₀ n"})
viewof pp_sd = Inputs.range([0.1, 1], {step: 0.01, value: 0.5, label: "σ"})
viewof pp_lev = Inputs.select(new Map([["90%", 0.90], ["95%", 0.95], ["99%", 0.99]]),
{label: "Level", value: 0.95})
viewof pp_cost = Inputs.range([5, 100], {step: 1, value: 30, label: "€ per observation"})The curve is \(w = z\sigma/\sqrt n\). The marked point is your current design; the dashed line is what it would take to halve the width.
The same algebra answers the power question of the next lecture, which is why design and precision are one calculation.
{
const C = ldk.C;
const n = Math.round(Math.pow(10, pp_n));
const z = ldk.qnorm(1 - (1 - pp_lev) / 2);
const w = z * pp_sd / Math.sqrt(n);
const nHalf = Math.ceil(Math.pow(z * pp_sd / (w / 2), 2));
const mde = 2.8025 * pp_sd * Math.sqrt(2 / n);
const { svg, p } = ldk.single(880, 520, {
xdom: [100, 10000], ydom: [0, z * pp_sd / Math.sqrt(100) * 1.05], xlog: true,
xlab: "sample size n", ylab: "half-width of the interval",
margin: { t: 74 }, nx: 5
});
const grid = [];
for (let e = 2; e <= 4.001; e += 0.01) grid.push(Math.pow(10, e));
p.path(grid.map(g => [g, z * pp_sd / Math.sqrt(g)]),
{ stroke: C.blue, "stroke-width": 3.5 });
p.hline(w, { stroke: ldk.alpha(C.grey, 0.6), "stroke-width": 1.6,
"stroke-dasharray": "4,5" });
p.hline(w / 2, { stroke: C.pink, "stroke-width": 2, "stroke-dasharray": "8,5" });
p.dot(n, w, 8, { fill: C.orange, stroke: "white", "stroke-width": 2.5 });
if (nHalf <= 10000)
p.dot(nHalf, w / 2, 8, { fill: C.pink, stroke: "white", "stroke-width": 2.5 });
p.legend([{ label: `current: n = ${n.toLocaleString("en-US")}`, color: C.orange, lw: 4 },
{ label: `half the width: n = ${nHalf.toLocaleString("en-US")}`,
color: C.pink, lw: 4, dash: "8,5" }],
{ size: 14, bg: true });
p.header(`half-width ± ${ldk.fmt(w, 3)}` +
` cost € ${(n * pp_cost).toLocaleString("en-US")}` +
` → € ${(nHalf * pp_cost).toLocaleString("en-US")}`,
{ line: 1, size: 18, fill: C.pink });
p.header(`minimum detectable effect at 80% power: ${ldk.fmt(mde, 3)}`,
{ size: 16, fill: C.grey });
return svg;
}Two intervals that look identical on a chart and answer different questions.
Confidence interval — for the mean
\[\bar x \pm z\,\frac{\sigma}{\sqrt n} \;\xrightarrow[n\to\infty]{}\; \{\mu\}\]
Width \(\to 0\). With enough data you know \(\mu\) exactly.
Prediction interval — for the next draw
\[\bar x \pm z\,\sigma\sqrt{1 + \tfrac1n} \;\xrightarrow[n\to\infty]{}\; \mu \pm z\sigma\]
Width \(\to 2z\sigma\). It stops.
The distinction economists get wrong
Forecast fan charts are prediction intervals. Reading one as a confidence interval understates forecast uncertainty by a factor that grows with \(\sqrt n\). The left-hand limit is the consistency picture from earlier; the right-hand one cannot collapse, because the next observation is not an estimator of anything.
Hypothesis testing, the next lecture, builds rejection regions; this deck builds intervals. They are the same object, seen from two sides.
Duality
\[\text{CI}_{1-\alpha}(x) \;=\; \big\{\,\theta_0 \;:\; \text{the level-}\alpha\text{ test of } H_0{:}\ \theta = \theta_0 \text{ does not reject}\,\big\}\]
Equivalently, writing \(p(\theta_0)\) for the p-value of the test of \(H_0: \theta = \theta_0\),
\[\text{CI}_{1-\alpha}(x) \;=\; \{\,\theta_0 : p(\theta_0) > \alpha\,\}\]
Read that as a picture
The confidence interval is the horizontal slice of the p-value curve at height \(\alpha\).
Drag \(\theta_0\) and watch the test on the left agree with the interval on the right — every time.
viewof pv_th0 = Inputs.range([-1.5, 3.5], {step: 0.01, value: 0.4, label: "θ₀"})
viewof pv_alpha = Inputs.select(new Map([["0.01", 0.01], ["0.05", 0.05], ["0.10", 0.10]]),
{label: "α", value: 0.05})
viewof pv_n = Inputs.range([5, 120], {step: 1, value: 25, label: "n"})
viewof pv_draw = Inputs.button("New sample")Left: the null distribution centred at the current \(\theta_0\), with the observed \(\bar x\) and its tail area.
Right: \(p(\theta_0)\) for every \(\theta_0\). Where the curve crosses \(\alpha\) are exactly the interval endpoints.
{
const C = ldk.C, n = pv_n, a = pv_alpha;
const u = ldk.rng(2718 + pv_draw * 101);
const truth = 1.0;
const x = Array.from({ length: n }, () => truth + u.normal());
const m = ldk.mean(x), se = ldk.sd(x) / Math.sqrt(n);
const tcrit = ldk.qt(1 - a / 2, n - 1);
const lo = m - tcrit * se, hi = m + tcrit * se;
const pOf = th => 2 * (1 - ldk.pt(Math.abs((m - th) / se), n - 1));
const pNow = pOf(pv_th0);
const reject = pNow < a;
const svg = ldk.chart(880, 520);
const dom = [-1.5, 3.5];
// ---- left: the test at the current theta0
const pl = ldk.panel(svg, {
left: 0, w: 430, xdom: dom, ydom: [0, ldk.dnorm(0, 0, se) * 1.12],
xlab: "x̄", ylab: "density under H₀",
margin: { l: 58, r: 12, t: 70, b: 52 }, nx: 5
});
const g = ldk.seq(dom[0], dom[1], 500);
const curve = g.map(v => [v, ldk.dnorm(v, pv_th0, se)]);
pl.fill(curve, 0, { fill: ldk.alpha(C.green, 0.12) });
const c = tcrit * se;
for (const [a0, b0] of [[dom[0], pv_th0 - c], [pv_th0 + c, dom[1]]]) {
const seg = g.filter(v => v >= a0 && v <= b0).map(v => [v, ldk.dnorm(v, pv_th0, se)]);
if (seg.length > 1) pl.fill(seg, 0, { fill: ldk.alpha(C.pink, 0.5) });
}
pl.path(curve, { stroke: C.blue, "stroke-width": 3 });
pl.vline(m, { stroke: C.orange, "stroke-width": 3.5 });
pl.vline(pv_th0, { stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "5,5" });
pl.header(reject ? `reject H₀ (p = ${ldk.fmt(pNow, 4)})`
: `do not reject (p = ${ldk.fmt(pNow, 4)})`,
{ size: 18, fill: reject ? C.pink : C.green });
// ---- right: the whole p-value curve, and the interval it defines
const pr = ldk.panel(svg, {
left: 430, w: 450, xdom: dom, ydom: [0, 1.04],
xlab: "θ₀", ylab: "p-value", margin: { l: 62, r: 16, t: 70, b: 52 }, nx: 5
});
pr.path(g.map(v => [v, pOf(v)]), { stroke: C.blue, "stroke-width": 3 });
pr.hline(a, { stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "8,5" });
pr.seg(lo, 0, lo, a, { stroke: C.green, "stroke-width": 2 });
pr.seg(hi, 0, hi, a, { stroke: C.green, "stroke-width": 2 });
pr.seg(lo, 0.035, hi, 0.035, { stroke: C.green, "stroke-width": 5 });
pr.dot(pv_th0, pNow, 6, { fill: reject ? C.pink : C.green,
stroke: "white", "stroke-width": 2 });
pr.legend([{ label: `α = ${a}`, color: C.pink, lw: 2.5, dash: "8,5" },
{ label: "confidence interval", color: C.green, lw: 5 }],
{ size: 14, bg: true });
pr.header(`${(100 * (1 - a)).toFixed(0)}% CI = [${ldk.fmt(lo, 3)}, ${ldk.fmt(hi, 3)}]`,
{ size: 18, fill: C.green });
return svg;
}Why this matters for reporting
“Not significant” collapses an interval to one bit. An interval of \([-0.01,\ 0.42]\) and an interval of \([-0.20,\ 0.21]\) tell very different stories, and the p-value is nearly the same for both.
Every simulation so far drew fresh samples from a known truth — a luxury you have exactly once, in a lecture.
You cannot do that with real data
One sample, no population to redraw from. The way out so far was a pivot with a known distribution; these have none:
The bootstrap principle
The sample is to the population as a resample is to the sample. Draw \(B\) samples of size \(n\) with replacement from the data, recompute \(\hat\theta^*\) on each, and use the spread of \(\hat\theta^*\) as the sampling distribution of \(\hat\theta\).
viewof bs_stat = Inputs.select(new Map([["Mean", "mean"], ["Median", "median"],
["Gini coefficient", "gini"],
["Maximum", "max"]]),
{label: "Statistic", value: "gini"})
viewof bs_n = Inputs.range([20, 300], {step: 10, value: 80, label: "Sample size n"})
viewof bs_B = Inputs.range([50, 2000], {step: 50, value: 600, label: "Replicates B"})
viewof bs_draw = Inputs.button("New sample")Left: the sample. Dot size is how often each observation was drawn in one replicate; greyed-out points were not drawn at all.
Choose Maximum to watch the method fail: a resample misses the largest observation with probability \((1-1/n)^n \to e^{-1}\).
{
const C = ldk.C, n = bs_n, B = bs_B;
const u = ldk.rng(1729 + bs_draw * 37);
const x = Array.from({ length: n }, () => Math.exp(3.1 + 0.62 * u.normal()));
const stat = v => {
if (bs_stat === "mean") return ldk.mean(v);
const s = Float64Array.from(v).sort();
if (bs_stat === "median") return ldk.quantileSorted(s, 0.5);
if (bs_stat === "max") return s[s.length - 1];
return ldk.gini(s);
};
const obs = stat(x);
const boot = new Array(B);
const counts = new Int32Array(n);
for (let b = 0; b < B; b++) {
const idx = new Array(n);
for (let i = 0; i < n; i++) idx[i] = Math.floor(u() * n);
if (b === 0) for (const i of idx) counts[i]++;
boot[b] = stat(idx.map(i => x[i]));
}
const sorted = Float64Array.from(boot).sort();
const ci = [ldk.quantileSorted(sorted, 0.025), ldk.quantileSorted(sorted, 0.975)];
const bootSE = ldk.sd(boot);
const atoms = new Set(boot.map(v => Math.round(v * 1e6))).size;
const svg = ldk.chart(880, 520);
// ---- left: the sample, with resample multiplicities
const xs = Float64Array.from(x).sort();
const pl = ldk.panel(svg, {
left: 0, w: 400, xdom: [0, xs[n - 1] * 1.05], ydom: [-0.5, 1.5],
xlab: "observation", margin: { l: 26, r: 14, t: 70, b: 52 }, nx: 4,
yticks: []
});
const jit = ldk.rng(5);
for (let i = 0; i < n; i++) {
const c = counts[i];
pl.dot(x[i], jit(), c === 0 ? 2.5 : 3 + 2.1 * Math.sqrt(c),
{ fill: c === 0 ? ldk.alpha(C.grey, 0.2) : ldk.alpha(C.blue, 0.5),
stroke: c === 0 ? "none" : C.blue, "stroke-width": 1.1 });
}
pl.header(`one replicate: ${counts.filter(c => c === 0).length} of ${n} never drawn`,
{ size: 15, fill: C.grey });
pl.header("the sample", { line: 1, size: 17 });
// ---- right: the bootstrap distribution
const blo = sorted[0], bhi = sorted[B - 1];
const pad = (bhi - blo) * 0.08 + 1e-9;
const h = ldk.hist(boot, blo - pad, bhi + pad, Math.min(40, Math.max(8, atoms)));
const pr = ldk.panel(svg, {
left: 400, w: 480, xdom: [blo - pad, bhi + pad],
ydom: [0, Math.max(...h.dens) * 1.15],
xlab: `bootstrap ${bs_stat}`, ylab: "density",
margin: { l: 62, r: 18, t: 70, b: 52 }, nx: 5
});
for (let i = 0; i < h.nb; i++)
pr.bar(blo - pad + i * h.w, blo - pad + (i + 1) * h.w, 0, h.dens[i],
{ fill: ldk.alpha(C.blue, 0.32), stroke: "white", "stroke-width": 1 });
pr.vline(obs, { stroke: C.orange, "stroke-width": 3.5 });
pr.vline(ci[0], { stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "7,5" });
pr.vline(ci[1], { stroke: C.pink, "stroke-width": 2.5, "stroke-dasharray": "7,5" });
pr.legend([{ label: `observed = ${ldk.fmt(obs, 3)}`, color: C.orange, lw: 3.5 },
{ label: "95% percentile CI", color: C.pink, lw: 2.5, dash: "7,5" }],
{ size: 14, bg: true });
const note = bs_stat === "mean"
? `bootstrap SE ${ldk.fmt(bootSE, 2)} vs textbook s/√n ${ldk.fmt(ldk.sd(x) / Math.sqrt(n), 2)}`
: bs_stat === "max"
? `only ${atoms} distinct values among ${B} replicates`
: `bootstrap SE ${ldk.fmt(bootSE, 4)} — no textbook formula exists`;
pr.header(`95% CI [${ldk.fmt(ci[0], 3)}, ${ldk.fmt(ci[1], 3)}]`,
{ line: 1, size: 18, fill: C.pink });
pr.header(note, { size: 15, fill: bs_stat === "max" ? C.pink : C.grey });
return svg;
}\(B\) is not \(n\)
Raising \(B\) shrinks Monte-Carlo noise in the endpoints. It does not shrink the interval, and it adds no information.
It cannot repair the sample
Resampling a biased sample gives biased resamples. And i.i.d. resampling destroys dependence — clustered or serial data need a block bootstrap.
The CRLB said a floor exists. It did not say anything reaches it. The rest of this deck is the machinery that does — and one method for when even the likelihood is more than you are willing to assume.
Sufficiency → Rao-Blackwell → UMVUE
A recipe that provably cannot be beaten among unbiased estimators.
GMM
What to do when you can write down moments but not a density.
Do we really need to keep the entire dataset to estimate \(\theta\), or does a short summary contain all the useful information?
Definition
A statistic \(T(X)\) is sufficient for \(\theta\) if, once you know \(T(X)\), the rest of the data tells you nothing more about \(\theta\).
Example: to estimate the probability of heads \(p\) from \(n\) coin flips, the order of heads and tails is irrelevant — only the total count of heads matters. That count is a sufficient statistic.
Factorization Theorem
\(T(X)\) is sufficient for \(\theta\) if and only if the likelihood splits as \[f(x;\theta) = g(T(x), \theta) \cdot h(x)\] i.e. \(\theta\) only ever enters through \(T(x)\).
Example: Bernoulli trials
For \(X_1, \ldots, X_n \sim \text{Ber}(p)\), the likelihood is \[L(p) = p^{\sum x_i}(1-p)^{n-\sum x_i}\] Since \(p\) only appears through \(\sum x_i\), the statistic \(T(X) = \sum_{i=1}^n X_i\) is sufficient for \(p\).
All sequences with the same count \(T = \sum x_i\) are interchangeable for learning \(p\). The bar is how many raw sequences collapse into that one value.
\(2^n\) possible datasets, \(n+1\) possible values of \(T\) — and nothing about \(p\) is lost in the collapse.
{
const C = ldk.C;
const n = n_suff, k = Math.min(k_suff, n);
const cnt = Array.from({ length: n + 1 }, (_, j) => Math.round(Math.exp(ldk.lchoose(n, j))));
const total = Math.pow(2, n);
const svg = ldk.chart(880, 520);
const p = ldk.panel(svg, {
xdom: [-0.7, n + 0.7], ydom: [0, Math.max(...cnt) * 1.08],
xlab: "T = number of heads", ylab: "sequences with that T",
margin: { l: 116, r: 26, t: 84, b: 62 }, nx: Math.min(n, 6), ny: 4
});
cnt.forEach((c, j) => p.bar(j - 0.42, j + 0.42, 0, c, {
fill: j === k ? C.pink : ldk.alpha(C.blue, 0.42), stroke: "white", "stroke-width": 1
}));
p.header(`T = ${k}: ${cnt[k].toLocaleString()} of ${total.toLocaleString()} sequences ` +
`(${ldk.fmt(100 * cnt[k] / total, 1)}%)`, { size: 19, fill: C.pink });
p.header(`n = ${n} flips → 2ⁿ = ${total.toLocaleString()} raw outcomes, ` +
`but only ${n + 1} values of T`, { line: 1, size: 16, fill: C.grey });
return svg;
}Idea: if you have an unbiased estimator that doesn’t yet use a sufficient statistic, you can always improve it (or at worst leave it unchanged) by conditioning on that sufficient statistic.
Theorem (Rao-Blackwell)
Let \(\hat{\theta}\) be an unbiased estimator of \(\theta\), and let \(T\) be sufficient for \(\theta\). Then \[\hat{\theta}^* = \operatorname{E}[\hat{\theta} \mid T]\] is also unbiased, and \(\operatorname{Var}(\hat{\theta}^*) \leq \operatorname{Var}(\hat{\theta})\).
For \(X_1, \ldots, X_n \sim \text{Ber}(p)\):
Result
\(\hat{p}^{\,*} = \bar{X}\): the sample mean, which uses all the data and has smaller variance than \(\hat{p} = X_1\).
Each dot is one experiment of \(n = 8\) coin flips with true \(p = 0.4\).
Both rows are centred on \(p\) — both estimators are unbiased. Only the spread differs, and that spread is the standard error. Theory says it should fall by \(\sqrt{n} = \sqrt{8} \approx 2.83\): conditioning on \(T\) discarded noise, not information.
{
const C = ldk.C;
const p0 = 0.4, nFlips = 8, N = 150, k = k_rb;
const u = ldk.rng(21);
const exp = Array.from({ length: N }, () => {
const f = Array.from({ length: nFlips }, () => (u() < p0 ? 1 : 0));
return { crude: f[0], rb: f.reduce((a, b) => a + b, 0) / nFlips };
});
const svg = ldk.chart(880, 520);
const jit = ldk.rng(5);
const strip = (top, key, label, col, last) => {
const v = exp.slice(0, k).map(e => e[key]);
const pl = ldk.panel(svg, {
top, w: 880, h: 260, xdom: [-0.12, 1.12], ydom: [0, 1], yticks: [],
xlab: last ? "estimate of p" : null,
margin: { l: 210, r: 26, t: 58, b: last ? 62 : 26 }
});
pl.vline(p0, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "8,5" });
v.forEach(x => pl.dot(x, 0.12 + 0.76 * jit(), 4, { fill: ldk.alpha(col, 0.5) }));
pl.vline(ldk.mean(v), { stroke: col, "stroke-width": 3.5 });
pl.add(ldk.el("text", {
x: pl.area.x0 - 16, y: pl.area.y0 + pl.area.h / 2 + 6, "text-anchor": "end",
"font-size": 17, "font-weight": "bold", fill: col
}, label), true);
pl.header(`mean ${ldk.fmt(ldk.mean(v), 3)} SD ${ldk.fmt(ldk.sd(v), 3)}`,
{ size: 17, fill: col });
return ldk.sd(v);
};
const s1 = strip(0, "crude", "crude: p̂ = X₁", C.orange, false);
const s2 = strip(260, "rb", "Rao-Blackwellized: p̂* = X̄", C.blue, true);
return svg;
}Rao-Blackwell tells us that conditioning on a sufficient statistic never hurts. UMVUE takes this to its logical conclusion: is there an unbiased estimator that beats every other unbiased estimator, for every value of \(\theta\)?
Definition
\(\hat{\theta}^*\) is the Uniformly Minimum Variance Unbiased Estimator (UMVUE) if it is unbiased and \[\operatorname{Var}(\hat{\theta}^*) \leq \operatorname{Var}(\hat{\theta})\] for every other unbiased \(\hat\theta\), and every \(\theta\).
Theorem (Lehmann-Scheffé)
If \(T\) is a complete sufficient statistic and \(\hat\theta = g(T)\) is unbiased, then \(\hat\theta\) is automatically the UMVUE.
What does “complete” mean?
Loosely: \(T\) has no “leftover” unbiased noise in it. Formally, \(T\) is complete if \[\operatorname{E}[g(T)] = 0 \; \forall \theta \quad \implies \quad g(T) = 0 \text{ almost surely}\]
In practice: once you’ve found a complete sufficient statistic, any unbiased function of it is automatically the best possible unbiased estimator.
Good to Know
And it need not sit on the floor
The CRLB is a bound the UMVUE may or may not attain — and the shrinkage slide showed a biased estimator beating it on MSE. “Best unbiased” is not “best”.
Classical method of moments: write down as many equations (moment conditions) as you have parameters, then solve them exactly.
But what if you have more valid equations than parameters? You can’t satisfy all of them exactly at once, so instead you get as close as possible to satisfying all of them simultaneously.
This is the idea behind GMM
Suppose economic theory implies a set of conditions that should hold on average at the true parameter value:
\[\operatorname{E}[g(X_i, \theta)] = 0, \qquad g: \mathbb{R}^d \times \mathbb{R}^p \to \mathbb{R}^q\]
Their sample counterparts will rarely hit zero exactly, so we make them as close to zero as possible:
\[\bar{g}_n(\theta) = \frac{1}{n}\sum_{i=1}^n g(X_i, \theta)\]
GMM Estimator
\[\hat{\theta}_{GMM} = \arg\min_\theta \; \bar{g}_n(\theta)'W_n\bar{g}_n(\theta)\] where \(W_n\) is a \(q \times q\) weight matrix that decides how much each condition counts.
Not all moment conditions are equally reliable — noisier ones should count for less.
Two-Step Procedure
With the optimal weight matrix, GMM achieves the smallest possible asymptotic variance among all choices of \(W\) — the same idea as the Cramér-Rao bound, transplanted from the likelihood to the moment conditions.
Model: \(y = X\beta + \epsilon\), but \(\operatorname{E}[X'\epsilon] \neq 0\) — \(X\) is endogenous, so OLS is biased.
Fix: find instruments \(Z\) that are correlated with \(X\) but uncorrelated with \(\epsilon\): \[\operatorname{E}[Z'\epsilon] = 0 \quad \Longrightarrow \quad g(y,X,Z;\beta) = Z'(y - X\beta)\]
GMM / IV Estimator
\[\hat{\beta}_{GMM} = (X'ZW_nZ'X)^{-1}X'ZW_nZ'y\]
A live simulation with \(x = 0.9z + u\) and \(y = 2x + u + \varepsilon\): the confounder \(u\) sits in both, so \(x\) is endogenous.
Both lines are noisy at small \(n\) and both settle down — but they settle on different numbers. More data does not cure a biased estimator; it only pins down the wrong answer more precisely.
{
const C = ldk.C;
const beta = 2, gz = 0.9, Nmax = 500, n = n_iv;
const u = ldk.rng(31);
// running OLS and IV estimates, recomputed as each observation arrives
let sx = 0, sy = 0, sz = 0, sxx = 0, sxy = 0, szx = 0, szy = 0;
const ols = [], iv = [];
for (let i = 1; i <= Nmax; i++) {
const z = u.normal(), c = u.normal(), e = u.normal();
const x = gz * z + c; // endogenous regressor
const y = beta * x + c + 0.6 * e; // the confounder c is in the error too
sx += x; sy += y; sz += z; sxx += x * x; sxy += x * y; szx += z * x; szy += z * y;
const cxx = sxx - sx * sx / i, cxy = sxy - sx * sy / i;
const czx = szx - sz * sx / i, czy = szy - sz * sy / i;
ols.push(cxx > 0 ? cxy / cxx : beta);
iv.push(Math.abs(czx) > 1e-9 ? czy / czx : beta);
}
// plim of OLS: beta + Cov(x, error)/Var(x) = beta + Var(c)/(gz² + 1)
const plimOLS = beta + 1 / (gz * gz + 1);
const svg = ldk.chart(880, 520);
const p = ldk.panel(svg, {
xdom: [10, Nmax], ydom: [beta - 1.2, beta + 1.8],
xlab: "n (observations so far)", ylab: "β̂ₙ",
margin: { l: 92, r: 26, t: 84, b: 62 }, nx: 5, ny: 5
});
p.hline(plimOLS, { stroke: ldk.alpha(C.orange, 0.6), "stroke-width": 2, "stroke-dasharray": "5,4" });
p.hline(beta, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "9,6" });
p.path(ols.slice(9, n).map((v, i) => [i + 10, v]), { stroke: C.orange, "stroke-width": 2.8 });
p.path(iv.slice(9, n).map((v, i) => [i + 10, v]), { stroke: C.blue, "stroke-width": 2.8 });
p.dot(n, ols[n - 1], 5.5, { fill: C.orange });
p.dot(n, iv[n - 1], 5.5, { fill: C.blue });
p.legend([{ label: `true β = ${beta}`, color: C.green, lw: 3, dash: "9,6" },
{ label: `OLS → ${ldk.fmt(ols[n - 1])} (plim ${ldk.fmt(plimOLS)})`, color: C.orange, lw: 2.8 },
{ label: `IV / GMM → ${ldk.fmt(iv[n - 1])}`, color: C.blue, lw: 2.8 }],
{ size: 15, bg: true });
p.header("OLS converges — to the wrong number", { size: 19, fill: C.orange });
return svg;
}J-Test for Over-Identification
When \(q > p\), the extra moment conditions can be used to test the model itself: \[J = n \cdot \bar{g}_n(\hat{\theta})'W\bar{g}_n(\hat{\theta}) \xrightarrow{d} \chi^2_{q-p}\] A large \(J\) suggests some moment condition — and so the model — may be wrong.
And a test is an interval seen from the other side: the values of \(\theta\) the \(J\)-test does not reject are a confidence region.
{
const C = ldk.C;
const svg = ldk.chart(460, 360);
const cx = 230, cy = 190;
svg.append(ldk.el("ellipse", {
cx, cy, rx: 205, ry: 132, fill: ldk.alpha(C.blue, 0.10),
stroke: C.blue, "stroke-width": 2
}));
svg.append(ldk.el("text", {
x: cx, y: 44, "text-anchor": "middle", "font-size": 24,
"font-weight": "bold", fill: C.blue
}, "GMM"));
[["MM", -110, -34, C.green], ["MLE", 110, -34, C.pink],
["IV", 0, 46, C.orange], ["OLS", -118, 62, C.green],
["2SLS", 118, 62, C.orange]].forEach(([label, dx, dy, col]) => {
svg.append(ldk.el("circle", {
cx: cx + dx, cy: cy + dy, r: 42,
fill: ldk.alpha(col, 0.22), stroke: col, "stroke-width": 2
}));
svg.append(ldk.el("text", {
x: cx + dx, y: cy + dy + 6, "text-anchor": "middle",
"font-size": 17, "font-weight": "bold", fill: C.ink
}, label));
});
svg.append(ldk.el("text", {
x: cx, y: 348, "text-anchor": "middle", "font-size": 14, fill: C.grey
}, "each is GMM with particular moments and weights"));
return svg;
}Good fit when…
Watch out for…
| Property | GMM | LS | MLE | UMVUE |
|---|---|---|---|---|
| Always exists | ✅ | ✅ | ✅ | ❌ |
| Unbiased | ❌ | ✅¹ | ❌ | ✅ |
| Consistent | ✅ | ✅ | ✅ | ✅ |
| Asymptotically efficient | ❌ | ✅² | ✅ | ✅ |
| Needs a distribution | ❌ | ❌ | ✅ | ✅ |
¹ Linear models ² Normal errors
Rules of Thumb
The through-line
Every one of these is a statement about the same distribution — the one you never observe, because you only ever draw one sample from it.
Choosing an interval
Report the interval
It carries the effect size, the precision, and every value the data cannot rule out. A p-value carries one bit of that.
Bayesian methods
Priors and posteriors · credible intervals, the probability statement a CI is not · hierarchical models
Robust estimation
M-, L- and R-estimators · influence functions
Modern extensions
High-dimensional estimation · shrinkage with LASSO and Ridge · machine learning and causal inference
And the interval kind
The delta method for functions of estimates · simultaneous and uniform bands · block and cluster bootstraps