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.
A point estimate is one draw from that distribution. An interval reports its width.
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 question returns with efficiency, later in the deck.
{
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;
}Every estimator answers the question which value fits the data best? A loss function is how you say what “best” means.
Loss function
\(L(y, c)\) is the cost of reporting \(c\) when the outcome is \(y\). How it charges for a miss is your choice, not a fact about the data.
The recipe
Report the value with the smallest average loss in your sample: \[\hat\theta \;=\; \arg\min_c\ \frac{1}{n}\sum_{i=1}^n L(y_i, c)\]
The sample average stands in for the population average \(\operatorname{E}[L(Y, c)]\). The law of large numbers from the last section is why that substitution works as \(n\) grows.
| Loss \(L(y, c)\) | How a miss is charged | Best single value \(c\) | With regressors |
|---|---|---|---|
| Squared \(\ (y-c)^2\) | quadratically — big misses dominate | the mean | least squares (conditional mean) |
| Absolute \(\ \lvert y-c\rvert\) | linearly — every unit the same | the median | median (LAD) regression |
| Asymmetric | \(\tau\) per unit too low, \(1-\tau\) per unit too high | the \(\tau\)-quantile | quantile regression |
| 0–1 \(\ \mathbf{1}\{y \neq c\}\) | every miss the same, however large | the mode | classification |
| Neg. log-likelihood \(\ -\ln f(y;\theta)\) | by how surprising \(y\) is under a model | the MLE | maximum likelihood |
Choose the loss, and you have chosen the estimator
The mean and the median answer different questions: the average income, or the typical one? The last row is the bridge to maximum likelihood.
viewof lm_loss = Inputs.select(new Map([["Squared", "sq"], ["Absolute", "abs"],
["Asymmetric, τ = 0.9", "q90"]]),
{label: "Loss", value: "sq"})
viewof lm_c = Inputs.range([0, 120], {step: 0.5, value: 50, label: "candidate c"})
viewof lm_out = Inputs.toggle({label: "Add a top earner", value: false})
viewof lm_draw = Inputs.button("New sample")Top: 13 incomes and your candidate \(c\); the grey lines are the misses. Bottom: the average loss of reporting \(c\), for every \(c\). The green line is where it bottoms out.
Add the top earner: under squared loss the minimum jumps, under absolute loss it barely moves. Squared loss gives a far-away point a say proportional to its squared distance.
{
const C = ldk.C, tau = 0.9;
const u = ldk.rng(404 + 977 * lm_draw);
const y = Array.from({ length: 13 }, () => Math.exp(3.1 + 0.62 * u.normal()));
if (lm_out) y.push(120);
const n = y.length, s = Float64Array.from(y).sort();
const loss = {
sq: (v, c) => (v - c) * (v - c),
abs: (v, c) => Math.abs(v - c),
q90: (v, c) => (v >= c ? tau : 1 - tau) * Math.abs(v - c)
}[lm_loss];
const avg = c => { let t = 0; for (const v of y) t += loss(v, c); return t / n; };
// closed-form minimisers: with n = 13 or 14 and tau = 0.9 the quantile is
// unique, and the even-n median is the midpoint of the flat stretch
const mn = ldk.mean(y), md = ldk.quantileSorted(s, 0.5), q9 = s[Math.ceil(n * tau) - 1];
const best = { sq: mn, abs: md, q90: q9 }[lm_loss];
const name = { sq: "the sample mean", abs: "the sample median",
q90: "the sample 90th percentile" }[lm_loss];
const col = { sq: C.blue, abs: C.pink, q90: C.orange }[lm_loss];
const xhi = Math.max(125, s[n - 1] * 1.05);
const svg = ldk.chart(880, 520);
// ---- top: the sample, the candidate, and every miss
const pt = ldk.panel(svg, {
top: 0, h: 180, xdom: [0, xhi], ydom: [0, 1], yticks: [],
margin: { l: 110, r: 26, t: 66, b: 34 }, nx: 6
});
const jit = ldk.rng(5);
y.forEach((v, i) => {
const h = 0.15 + 0.7 * jit();
pt.seg(v, h, lm_c, h, { stroke: ldk.alpha(C.grey, 0.4), "stroke-width": 1.4 });
pt.dot(v, h, 5.5, { fill: lm_out && i === n - 1 ? C.pink : ldk.alpha(C.ink, 0.7) });
});
pt.vline(best, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "8,5" });
pt.vline(lm_c, { stroke: col, "stroke-width": 3 });
pt.header(`mean ${ldk.fmt(mn, 1)} median ${ldk.fmt(md, 1)} 90th percentile ${ldk.fmt(q9, 1)}`,
{ size: 16, fill: C.grey });
pt.header(`the sample: ${n} incomes, in € 1000s`, { line: 1, size: 17 });
// ---- bottom: the average loss of every candidate
const cs = ldk.seq(0, xhi, 500), L = cs.map(avg);
const pb = ldk.panel(svg, {
top: 180, h: 340, xdom: [0, xhi], ydom: [0, Math.max(...L) * 1.05],
xlab: "candidate c", ylab: "average loss",
margin: { l: 110, r: 26, t: 66, b: 60 }, nx: 6, ny: 4
});
pb.path(cs.map((c, i) => [c, L[i]]), { stroke: col, "stroke-width": 3.5 });
pb.vline(best, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "8,5" });
pb.vline(lm_c, { stroke: ldk.alpha(col, 0.5), "stroke-width": 1.6, "stroke-dasharray": "4,4" });
pb.dot(best, avg(best), 6.5, { fill: C.green, stroke: "white", "stroke-width": 2 });
pb.dot(lm_c, avg(lm_c), 6.5, { fill: col, stroke: "white", "stroke-width": 2 });
pb.header(`minimised at c = ${ldk.fmt(best, 1)}: ${name}`, { line: 1, size: 18, fill: C.green });
pb.header(`average loss at c = ${ldk.fmt(lm_c, 1)}: ${ldk.smart(avg(lm_c))}` +
` at the minimum: ${ldk.smart(avg(best))}`, { size: 16, fill: col });
return svg;
}The same recipe with regressors
Replace the constant \(c\) by a prediction \(f(x_i;\theta)\) and minimize the same average loss over \(\theta\). Squared loss then fits the conditional mean \(\operatorname{E}[Y \mid X]\) — that is OLS. Absolute loss fits the conditional median.
A loss needs no story about where the data came from. Maximum likelihood starts from one.
Maximum likelihood estimator
\[\hat\theta_{ML} \;=\; \arg\max_\theta \ \prod_{i=1}^n f(y_i;\theta) \;=\; \arg\max_\theta \ \sum_{i=1}^n \ln f(y_i;\theta)\] Taking logs turns a product of tiny numbers into a sum; the maximizer does not move.
We model \(n\) workers’ log hourly wages as \(N(\mu, \sigma^2)\). Which \(\mu\) and \(\sigma\) make the wages we actually observed most probable?
viewof lk_mu = Inputs.range([1.5, 4.5], {step: 0.01, value: 2.6, label: "candidate μ"})
viewof lk_sd = Inputs.range([0.15, 1.2], {step: 0.01, value: 0.8, label: "candidate σ"})
viewof lk_n = Inputs.range([5, 40], {step: 1, value: 8, label: "observations n"})
viewof lk_draw = Inputs.button("New sample")Each observation contributes the height of the density at its value — the stems. The likelihood multiplies them, so one point the curve barely reaches (pink) drags the whole product down.
Move \(\mu\) and \(\sigma\) until the top picture matches the bottom one: that is maximum likelihood. Too narrow a density misses the outer points; too wide a one spreads itself thin over all of them.
{
const C = ldk.C, n = lk_n;
const u = ldk.rng(606 + 977 * lk_draw);
const y = Array.from({ length: n }, () => 3.0 + 0.45 * u.normal());
const muHat = ldk.mean(y);
const sdHat = Math.sqrt(y.reduce((a, v) => a + (v - muHat) ** 2, 0) / n); // MLE divides by n
const loglik = (m, s) => y.reduce((a, v) => a + Math.log(ldk.dnorm(v, m, s)), 0);
// likelihoods are tiny: print them as 1.1 × 10⁻³ rather than 0.00, working from
// the log-likelihood so that 10⁻⁸⁰⁰ does not underflow to 0
const sup = t => t.replace(/[-0-9]/g, c => "⁻⁰¹²³⁴⁵⁶⁷⁸⁹"["-0123456789".indexOf(c)]);
const sci = ll => {
if (ll >= Math.log(0.01)) return String(Number(Math.exp(ll).toPrecision(3)));
const l10 = ll / Math.LN10, e = Math.floor(l10);
return `${Math.pow(10, l10 - e).toFixed(1)} × 10${sup(String(e))}`;
};
const neg = v => ldk.fmt(v, 1).replace("-", "−");
const llC = loglik(lk_mu, lk_sd), llH = loglik(muHat, sdHat);
const xs = ldk.seq(1, 5, 400);
const ymax = 1.12 * Math.max(ldk.dnorm(0, 0, lk_sd), ldk.dnorm(0, 0, sdHat));
const svg = ldk.chart(880, 520);
// one panel per density: the stems are each observation's likelihood contribution
const fit = (top, m, s, col, last, markMin) => {
const p = ldk.panel(svg, {
top, h: 260, xdom: [1, 5], ydom: [0, ymax],
xlab: last ? "log hourly wage" : null, ylab: "density",
margin: { l: 80, r: 26, t: 62, b: last ? 62 : 30 }, nx: 8, ny: 3
});
const h = y.map(v => ldk.dnorm(v, m, s));
const iMin = h.indexOf(Math.min(...h));
p.fill(xs.map(x => [x, ldk.dnorm(x, m, s)]), 0, { fill: ldk.alpha(col, 0.1) });
p.path(xs.map(x => [x, ldk.dnorm(x, m, s)]), { stroke: col, "stroke-width": 3.2 });
y.forEach((v, i) => {
const c = markMin && i === iMin ? C.pink : col;
p.seg(v, 0, v, h[i], { stroke: c, "stroke-width": 2.4 });
p.dot(v, h[i], 4, { fill: c });
p.dot(v, 0, 4.5, { fill: C.ink });
});
return p;
};
const pc = fit(0, lk_mu, lk_sd, C.orange, false, true);
pc.header(`your candidate: μ = ${ldk.fmt(lk_mu)}, σ = ${ldk.fmt(lk_sd)}`,
{ line: 1, size: 18, fill: C.orange });
pc.header(`likelihood = product of the ${n} heights = ${sci(llC)}` +
` log-likelihood ${neg(llC)}`, { size: 16, fill: C.grey });
const ph = fit(260, muHat, sdHat, C.green, true, false);
ph.header(`best fit (MLE): μ = ȳ = ${ldk.fmt(muHat)}, σ = ${ldk.fmt(sdHat)}`,
{ line: 1, size: 18, fill: C.green });
ph.header(`likelihood = ${sci(llH)} log-likelihood ${neg(llH)}` +
" — no other μ, σ does better", { size: 16, fill: C.grey });
return svg;
}| Data | Model | MLE | Remark |
|---|---|---|---|
| yes / no answers | Bernoulli\((p)\) | \(\hat p = k/n\) | the share of yes answers |
| counts (patents, doctor visits) | Poisson\((\lambda)\) | \(\hat\lambda = \bar y\) | the sample mean |
| durations (unemployment spells) | Exponential\((\lambda)\) | \(\hat\lambda = 1/\bar y\) | a rate: one over the mean |
| continuous outcomes (log wages) | Normal\((\mu, \sigma^2)\) | \(\hat\mu = \bar y\), \(\ \hat\sigma^2 = \frac1n\sum(y_i-\bar y)^2\) | \(\hat\sigma^2\) is the biased “\(\div n\)” estimator from the first section — yet consistent |
Not exotic — but no longer a guess
For simple models, MLE returns the formulas you would have guessed, together with a reason for them. For models where there is nothing obvious to guess — logit and probit, duration and count regressions — it is the default recipe.
What you must assume
In return, as n grows
And if the model is wrong?
The MLE still converges — to the best-fitting wrong model. Sometimes harmless (the normal MLE of a mean is \(\bar y\), whatever the true distribution); sometimes exactly the number you did not want.
Big picture: a loss and a likelihood both answer 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 maximum likelihood — and inherits every guarantee on the MLE’s list, efficiency included.
Maximizing \(\sum \ln f\) is minimizing \(\sum -\ln f\). So every error distribution you assume hands you a loss:
| If you assume the error is… | then \(-\ln f\) is, up to constants… | a loss called | and the MLE is |
|---|---|---|---|
| Normal | \((y - f(x;\theta))^2 / 2\sigma^2\) | squared | least squares |
| Laplace (fatter tails) | \(\lvert y - f(x;\theta)\rvert \,/\, b\) | absolute | median (LAD) regression |
| Bernoulli, \(P(y = 1) = p(x;\theta)\) | \(-y\ln p - (1-y)\ln(1-p)\) | log loss | logit / probit |
Read it left to right
Assuming a distribution chooses your loss: MLE is loss minimization with a loss derived from a model.
Read it right to left
Choosing a loss quietly assumes a distribution. Squared loss suits roughly normal errors — and pays for it when tails are fat.
A third road. Instead of a loss or a likelihood, write down facts that should hold on average at the true parameter, like \(\operatorname{E}[Y] = \mu\). Classical method of moments: one such equation per parameter, solved 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.
Minimize a loss or maximize a likelihood, and the optimum sits where the derivative is zero. Averaged over the sample, that first-order condition is a moment condition.
| Estimator | Built from | Its moment condition |
|---|---|---|
| Mean | squared loss | \(\operatorname{E}[Y - \mu] = 0\) |
| Median | absolute loss | \(P(Y \le m) - \tfrac12 = 0\) |
| OLS | squared loss, with regressors | \(\operatorname{E}[X(Y - X'\beta)] = 0\) |
| MLE | a full distribution | \(\operatorname{E}\big[\tfrac{\partial}{\partial\theta}\ln f(Y;\theta)\big] = 0\) |
| IV | nothing but the moment itself | \(\operatorname{E}[Z(Y - X'\beta)] = 0\) |
So GMM is the umbrella
Every estimator in this deck is GMM with a particular choice of moments. What differs is how much you have to believe: MLE needs the whole distribution, GMM only the moments you write down. The last row has no loss or likelihood behind it at all — that is where GMM earns its keep.
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 variance among all choices of \(W\) — an efficiency result, the theme of the next section.
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.
Tests get their own lecture next. The end of this deck shows why every test is also an interval.
{
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…
Every sampling distribution in this deck had a width. For the sample mean it is \(\operatorname{Var}(\bar X) = \sigma^2/n\), and 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 rest of this deck is about the second: how small it can get, and how to report it.
For the sample mean, \(\operatorname{SE} = \sigma/\sqrt n\). The same three levers set the standard error of every estimator in this deck:
And the shape is (almost) always the same
In large samples the mean, least squares, MLE and GMM are all approximately normal around \(\theta\) — under their assumptions — with that SE as their spread. Every interval at the end of this deck is built on this one fact.
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 — how fast the log-likelihood in Likelihood, Live falls as you move away from the best fit. More information means the data pin 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. The MLE reaches it as \(n\) grows.
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 median is unbiased here too — the normal is symmetric — but it ignores how far each point lies from the centre. In large samples its variance is \(\pi/2 \approx 1.57\) times the floor: it needs 57% more data for the same precision.
{
const C = ldk.C;
const s2 = 4, waste = Math.PI / 2, n = n_eff; // large-n variance of the median
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: "median ≈ (π/2)·σ²/n — ignores distances", color: C.orange, lw: 3 }],
{ size: 15, bg: true });
p.header(`n = ${n}: Var(X̄) = ${ldk.fmt(crlb(n), 3)} vs Var(median) ≈ ${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;
}Each dot is one estimate from a fresh sample; the truth is 0. Most points come from \(N(0,1)\), a share of “wild” ones from a distribution ten times as wide. Both are centred on 0, so both estimators stay unbiased.
On clean data the mean wins, by roughly the \(\pi/2\) of the last slide. A few percent of wild points flips the ranking: the mean inherits their variance, the median barely notices. Efficiency is a property of an estimator and the data it meets.
{
const C = ldk.C, n = me_n, eps = me_eps, R = 2000, show = 250;
const u = ldk.rng(77 + 977 * me_draw);
// both estimators see the same samples; eps only rescales the draws, so
// moving the slider changes the data, not the random numbers behind them
const means = new Array(R), meds = new Array(R);
const x = new Float64Array(n);
for (let r = 0; r < R; r++) {
for (let i = 0; i < n; i++) x[i] = u.normal() * (u() < eps ? 10 : 1);
means[r] = ldk.mean(x);
x.sort();
meds[r] = ldk.quantileSorted(x, 0.5);
}
const sm = ldk.sd(means), sd = ldk.sd(meds), ratio = (sd * sd) / (sm * sm);
const svg = ldk.chart(880, 520);
const jit = ldk.rng(5);
const strip = (top, v, s, label, col, last) => {
const p = ldk.panel(svg, {
top, w: 880, h: 260, xdom: [-2, 2], ydom: [0, 1], yticks: [],
xlab: last ? "estimate (truth = 0)" : null,
margin: { l: 132, r: 26, t: 62, b: last ? 62 : 26 }, nx: 5
});
p.vline(0, { stroke: C.green, "stroke-width": 3, "stroke-dasharray": "8,5" });
for (let i = 0; i < show; i++)
p.dot(v[i], 0.24 + 0.68 * jit(), 3.4, { fill: ldk.alpha(col, 0.4) });
p.seg(-s, 0.09, s, 0.09, { stroke: col, "stroke-width": 6, "stroke-linecap": "round" });
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(`SD ${ldk.fmt(s, 3)} (bar: ± 1 SD)`, { size: 17, fill: col });
return p;
};
const pm = strip(0, means, sm, "mean", C.blue, false);
const pd = strip(260, meds, sd, "median", C.pink, true);
pm.header(eps === 0 ? `clean N(0, 1) data, n = ${n}`
: `${Math.round(100 * eps)}% of points from N(0, 10²), n = ${n}`,
{ line: 1, size: 16, fill: C.grey });
pd.header(`Var(median) ÷ Var(mean) = ${ldk.fmt(ratio, 2)} → the ` +
(ratio > 1 ? "mean" : "median") + " is more efficient",
{ line: 1, size: 18, fill: ratio > 1 ? C.blue : C.pink });
return svg;
}The trade-off behind every choice in this deck
The more you assume, the more precise you can be — if you are right. Assume less, and you trade precision for robustness.
What is the variance for?
A variance nobody reports is useless. Turning the standard error into a statement on a page is the job of the confidence interval.
A point estimate says what is our best guess, never how good the guess is — the question a policy note has to survive.
Confidence interval
An interval estimator \([\hat\theta_L, \hat\theta_U]\) has confidence level \(1-\alpha\) if its coverage probability \(P_\theta\big(\hat\theta_L \le \theta \le \hat\theta_U\big)\) is \(1-\alpha\), whatever the true \(\theta\).
The workhorse
The estimator is approximately normal, so \(\hat\theta \pm 1.96 \times \operatorname{SE}(\hat\theta)\) covers \(\theta\) in about 95% of samples. A small-sample mean with \(\sigma\) estimated by \(s\) uses \(t_{n-1}\) instead of 1.96.
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.
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.
Turn the question around. Instead of which values of \(\theta\) are plausible?, ask about one value \(\theta_0\) — say “the reform had no effect”, \(\theta_0 = 0\).
p-value, in words
If \(\theta\) really were \(\theta_0\), how often would a fresh sample give an estimate at least as far from \(\theta_0\) as the one we got? \[p(\theta_0) \;=\; P_{\theta_0}\big(\,\lvert\hat\theta - \theta_0\rvert \;\ge\; \lvert\hat\theta_{\text{obs}} - \theta_0\rvert\,\big)\] The sampling distribution again — this time centred at \(\theta_0\).
Small p
The data would be surprising if \(\theta\) were \(\theta_0\): evidence against it. Below a threshold \(\alpha\), often 5%, we reject \(\theta_0\).
Not
The probability that \(\theta_0\) is true — \(\theta_0\) is either true or not. The next lecture treats tests and p-values in full.
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 formula for the standard error; 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.
| Least squares | Median / LAD | MLE | GMM | |
|---|---|---|---|---|
| Built from | squared loss | absolute loss | a full distribution | moment conditions |
| Needs a full distribution | ❌ | ❌ | ✅ | ❌ |
| Robust to outliers | ❌ | ✅ | depends on model | depends on moments |
| Efficient when… | errors normal | errors Laplace | the model is right | best weights, given its moments |
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
Finite-sample optimality
Sufficiency · Rao-Blackwell · the UMVUE, the best unbiased estimator
Robust and modern estimation
M-estimators, the general form of the loss section · LASSO and Ridge · machine learning and causal inference
And the interval kind
Pivots and exact intervals — \(\chi^2\) for a variance, Wilson for a proportion · sample-size planning · the delta method · cluster bootstraps