Math for Machine Learning — Handbook
The Gaussian
The idea
The normal distribution is the default model for noise and error in machine learning. Two numbers fix it: the mean μ sets its centre, the standard deviation σ sets its width. Drag the sliders and watch the bell move and breathe.
Picture it
In the companion lab you can set the mean and spread.
The math
Derivation
Standardize z = (x−μ)/σ ⇒ any normal becomes the standard N(0,1). The 1/σ√2π factor is exactly what makes ∫ p dx = 1.
Code
import numpy as np
def gaussian(x, mu, sigma):
z = (x - mu) / sigma
return np.exp(-0.5 * z**2) / (sigma * np.sqrt(2*np.pi))
x = np.linspace(-5, 5, 200)
pdf = gaussian(x, mu=0.0, sigma=1.0)
# fit mu, sigma from data by maximum likelihood = sample mean & std
data = np.random.normal(2.0, 1.5, size=10_000)
mu_hat, sigma_hat = data.mean(), data.std(ddof=0)
print(mu_hat, sigma_hat) # ~2.0, ~1.5In practice
Bayes’ theorem
The idea
Bayes’ theorem flips a conditional: from how often a test is positive given the disease, it tells you the chance of disease given a positive test. The surprise is how much the base rate matters — a great test can still mean a coin-flip when the disease is rare. Tune all three.
Picture it
In the companion lab you can prevalence = 1.0%sensitivity = 90%specificity = 90%
The math
Derivation
Posterior = (likelihood × prior) / evidence. The evidence sums both ways to be positive; rare priors make the (1−spec)(1−prev) term dominate.
Code
# Bayes' rule for a medical test: P(disease | positive)
p_d = 0.01 # prior prevalence
sens = 0.99 # P(+ | disease)
spec = 0.95 # P(- | no disease)
p_pos = sens*p_d + (1-spec)*(1-p_d) # total prob of a positive
post = sens*p_d / p_pos # posterior
print(round(post, 3)) # ~0.167 -- low despite a "99% accurate" testIn practice
Expectation & variance
The idea
The expectation E[X] is the probability-weighted average outcome — the balance point of the distribution. The variance measures how far outcomes spread around it. Reshape the distribution by dragging the bars and watch both numbers respond.
Picture it
In the companion lab you can drag the bars up or down to change the probability of each outcome (1–6). They renormalize to sum to 1.
The math
Derivation
Expand E[(X−μ)²] = E[X²] − 2μE[X] + μ² = E[X²] − μ² — the handy "mean of square minus square of mean".
Code
import numpy as np
# Expectation and variance as probability-weighted sums
x = np.array([1, 2, 3, 4, 5, 6]) # a die
p = np.full(6, 1/6)
E = (x * p).sum() # mean = 3.5
Var = ((x - E)**2 * p).sum() # 2.9167
# Monte-Carlo check
rolls = np.random.randint(1, 7, size=1_000_000)
print(E, rolls.mean(), Var, rolls.var())In practice
Central limit theorem
The idea
Averages behave better than the things they average. Take any distribution, draw n samples, average them, and repeat: the distribution of those averages drifts toward a bell and narrows as n grows — even when the source is lopsided. Raise n and watch it happen.
Picture it
In the companion lab you can increase the sample size and resample.
The math
Derivation
E[X̄ₙ] = μ and Var[X̄ₙ] = σ²/n (variances add, then divide by n²). The CLT adds that the shape tends to normal regardless of the source.
Code
import numpy as np
# CLT: means of samples from ANY distribution become Gaussian
pop = np.random.exponential(scale=1.0, size=1_000_000) # skewed
means = [np.random.choice(pop, size=n).mean()
for _ in range(5000)]
import numpy as np; means = np.array(means)
# standard error shrinks like 1/sqrt(n)
for n in (1, 4, 16, 64):
se = pop.std() / np.sqrt(n)
print(n, round(se, 3))In practice
Conditional probability
The idea
Conditioning means narrowing the world to the cases where B happened, then asking how often A also holds there. P(A|B) can be wildly different from P(A). Drag the three sliders — the sizes of A, B, and their overlap — and read the conditionals off the Venn diagram.
Picture it
In the companion lab you can p(A) = 0.50P(B) = 0.40overlap P(A∩B) = 0.20
The math
Derivation
Conditioning restricts the sample space to B and renormalizes by 1/P(B). Combined with symmetry P(A∩B)=P(B|A)P(A) this gives Bayes’ theorem.
Code
import numpy as np
# Conditional probability from a joint table P(X, Y)
joint = np.array([[0.10, 0.20],
[0.30, 0.40]]) # rows X, cols Y
p_x = joint.sum(axis=1, keepdims=True) # marginal P(X)
p_y_given_x = joint / p_x # rows sum to 1
print(p_y_given_x)
# independence test: joint == outer(P(X), P(Y)) ?
indep = np.allclose(joint, p_x * joint.sum(0))
print(indep)In practice
Vectors, span & linear combinations
The idea
Everything in ML lives in vector spaces: a data point, a weight row, a gradient are all vectors. A linear combination a·v₁ + b·v₂ scales and adds them; the set of all reachable combinations is their span. Drag the vectors and dial the coefficients to sweep out that span.
Picture it
In the companion lab you can drag the tips of v₁, v₂; set the coefficients.
The math
Derivation
If v₂ = k·v₁ (dependent), then a·v₁ + b·v₂ = (a + bk)·v₁. Every combination is a multiple of v₁ — the span collapses from a plane to a line.
Code
import numpy as np
a = np.array([2.0, 1.0]); b = np.array([1.0, 3.0])
dot = a @ b # 5.0
cos = dot / (np.linalg.norm(a)*np.linalg.norm(b))
angle = np.degrees(np.arccos(cos))
proj = (dot / (b @ b)) * b # projection of a onto b
print(dot, round(cos,3), round(angle,1))In practice
A matrix is a transformation
The idea
A matrix doesn’t just store numbers — it acts on space. Multiplying A by x maps the vector to a new place, and the whole grid warps with it. The columns of A are exactly where the basis vectors land. Tune the four entries and drag x to feel the map.
Picture it
In the companion lab you can set A’s entries; drag the input x.
The math
Derivation
Write x = x₁e₁ + x₂e₂. By linearity Ax = x₁(Ae₁) + x₂(Ae₂) = x₁·col₁ + x₂·col₂. So knowing where the basis lands determines the whole map.
Code
import numpy as np
# A matrix is a linear map: it sends the unit square to a parallelogram
A = np.array([[2.0, 1.0],
[0.0, 1.5]])
basis = np.eye(2) # columns are i-hat, j-hat
print(A @ basis) # where the basis vectors land
print("area scale =", np.linalg.det(A)) # determinant = area factorIn practice
Eigenvectors & eigenvalues
The idea
Most vectors get knocked off their line when a matrix acts on them. A precious few — the eigenvectors — keep their direction and merely scale by a factor λ, the eigenvalue. Drag the input around the circle; when Av lines up with v, you’ve found one.
Picture it
In the companion lab you can drag the input vector around the circle. Watch for where Av stays parallel to v — those are eigenvectors.
The math
Derivation
For [a b; b d]: tr = a+d, det = ad−b², λ = (tr/2) ± √((tr/2)² − det). Each λ gives a direction fixed by A up to scaling.
Code
import numpy as np
A = np.array([[2.0, 1.0],
[1.0, 2.0]])
vals, vecs = np.linalg.eig(A) # A v = lambda v
# verify
for i in range(2):
print(np.allclose(A @ vecs[:, i], vals[i] * vecs[:, i]))
print("eigenvalues:", vals) # 3 and 1In practice
Quadratic forms & definiteness
The idea
A quadratic form f(x) = xᵀAx turns a symmetric matrix into a bowl, saddle, or dome. Its level sets are ellipses whose axes are A’s eigenvectors and whose stretch is set by the eigenvalues. Drag x across the contours and reshape A to flip its character.
Picture it
In the companion lab you can drag x; reshape the symmetric matrix.
The math
Derivation
Symmetric A = QΛQᵀ (orthogonal Q). Substituting y = Qᵀx gives f = yᵀΛy = Σᵢ λᵢ yᵢ² — a sum of squared axes, so the signs of the λ decide the shape.
Code
import numpy as np
# Quadratic form x^T A x; positive-definite A => bowl with a unique min
A = np.array([[3.0, 1.0],
[1.0, 2.0]])
def q(x): return x @ A @ x
print(np.all(np.linalg.eigvalsh(A) > 0)) # positive definite?
# gradient is 2 A x -> minimized at x = 0
x = np.array([1.0, -1.0]); print(q(x), 2*A@x)In practice
Projection & least squares
The idea
To approximate a vector b using only directions you have, drop a perpendicular: the projection is the closest reachable point, and the leftover residual is orthogonal to everything you used. That orthogonality is the whole secret of least-squares regression. Drag b and watch.
Picture it
In the companion lab you can drag b. Its projection onto the line span(a) is the nearest point; the dashed residual meets the line at a right angle.
The math
Derivation
Minimize ‖b − t·a‖² over t: d/dt = −2 aᵀ(b − t a) = 0 ⇒ t* = aᵀb / aᵀa. Setting the gradient to zero is the orthogonality condition.
Code
import numpy as np
# Least-squares = orthogonal projection of y onto column space of X
X = np.array([[1, 0.], [1, 1.], [1, 2.], [1, 3.]])
y = np.array([1., 3., 4., 6.])
beta = np.linalg.solve(X.T @ X, X.T @ y) # normal equations
yhat = X @ beta # projection
resid = y - yhat
print(beta, "resid . X =", resid @ X) # residual is orthogonal to XIn practice
Convexity
The idea
A function is convex if the straight chord between any two points sits on or above the curve. Convexity is the property that makes optimization easy: every local minimum is the global minimum. Drag the two points and toggle to a non-convex function to watch the test fail.
Picture it
In the companion lab you can drag the two points; compare the chord to the curve between them.
The math
Derivation
The chord at the midpoint has height ½(f(a)+f(b)). If this is ≥ f(midpoint) for all a,b, no “dip” can hide a second valley — so any local min is global (a descent step always reaches it).
Code
import numpy as np
# A function is convex if the chord lies above the curve everywhere
f = lambda x: x**2 + 3
def convex_check(f, a, b, t=0.5):
mid = t*a + (1-t)*b
return f(mid) <= t*f(a) + (1-t)*f(b)
print(all(convex_check(f, *np.random.randn(2)) for _ in range(1000)))In practice
Gradient descent & conditioning
The idea
On a stretched bowl, gradient descent zig-zags: it keeps pointing across the valley instead of down it. The culprit is the condition number — the ratio of the steepest to the flattest curvature. Stretch the bowl and watch convergence crawl; round it out and it shoots to the bottom.
Picture it
In the companion lab you can click to drop the ball; step. Stretch the bowl with the curvature sliders.
The math
Derivation
Axes decouple: x ← (1−ηa)x, y ← (1−ηc)y. Stability needs η < 2/max(a,c); but then the flat axis (small c) moves by only ηc — tiny. Rate ≈ (κ−1)/(κ+1).
Code
import numpy as np
# Gradient descent on a 2-D bowl f(x) = x^T A x
A = np.array([[3., 0.], [0., 1.]])
grad = lambda x: 2 * A @ x
x, lr = np.array([4.0, 4.0]), 0.1
for _ in range(50):
x = x - lr * grad(x)
print(x) # -> [0, 0]; ill-conditioned A zig-zagsIn practice
L1 vs L2 regularization
The idea
Regularization adds a penalty on the weights, equivalent to constraining them inside a ball. The ball’s shape decides the outcome: the round L2 ball shrinks weights smoothly, while the pointed L1 diamond pulls solutions onto the axes — making them sparse. Drag the target and squeeze the budget.
Picture it
In the companion lab you can drag the unconstrained optimum w*; shrink the budget; switch penalty.
The math
Derivation
The solution is where the loss contour first touches the ball. L2 shrinks: w ← w*·t/‖w*‖. L1’s corners lie on the axes, so the touch point often has a coordinate exactly 0 — a feature dropped (soft-thresholding).
Code
import numpy as np
# L2 regularization shrinks weights toward zero (ridge)
X = np.random.randn(50, 5); w_true = np.array([3,0,0,1,0.])
y = X @ w_true + 0.5*np.random.randn(50)
for lam in (0, 1, 10):
w = np.linalg.solve(X.T@X + lam*np.eye(5), X.T@y)
print(lam, np.round(w, 2)) # larger lam -> smaller weightsIn practice
Learning rate & convergence
The idea
The learning rate is the single most important knob. On a simple quadratic there are three regimes: small steps glide in monotonically, medium steps overshoot and oscillate while still converging, and large steps blow up. The boundary is set entirely by the curvature.
Picture it
In the companion lab you can set the curvature and learning rate, then run.
The math
Derivation
After n steps xₙ = (1−ηk)ⁿ·x₀. Converges ⇔ |1−ηk| < 1 ⇔ 0 < η < 2/k. Monotone if η < 1/k, oscillating if 1/k < η < 2/k, divergent if η > 2/k.
Code
import numpy as np
# The learning rate: too small crawls, too large diverges
grad = lambda x: 2*x
for lr in (0.01, 0.5, 1.1):
x, hist = 5.0, []
for _ in range(20):
x -= lr*grad(x); hist.append(x)
print(f"lr={lr}: x->{x:.2e}") # 1.1 explodesIn practice
Estimators & the sampling distribution
The idea
An estimator is a recipe that turns a sample into a guess about the population (the sample mean estimates the true mean). Because the sample is random, the estimate is random too — it has its own sampling distribution. Draw samples repeatedly to build that distribution and watch it cluster around the truth.
Picture it
In the companion lab you can draw samples of size n; each one gives a sample mean. The histogram is the sampling distribution.
The math
Derivation
E[X̄] = (1/n)Σ E[Xᵢ] = μ. Var[X̄] = (1/n²)Σ Var[Xᵢ] = σ²/n. So the estimator centers on the truth and tightens like 1/√n as n grows.
Code
import numpy as np
# An estimator is a function of data; study its sampling distribution
true_mu = 5.0
ests = [np.random.normal(true_mu, 2, size=30).mean() for _ in range(10000)]
ests = np.array(ests)
print("bias =", ests.mean() - true_mu) # ~0 (unbiased)
print("std error =", ests.std()) # ~2/sqrt(30)In practice
The bias–variance tradeoff
The idea
A too-simple model misses the pattern (high bias, underfitting); a too-flexible one chases the noise (high variance, overfitting). Test error is smallest in between. Raise the polynomial degree and watch the fit go from stiff to wild while train error keeps dropping but test error turns back up.
Picture it
In the companion lab you can change model flexibility and noise; regenerate the data.
The math
Derivation
Split the expected test error by adding and subtracting E[f̂(x)]: the systematic miss is bias², the wobble across datasets is variance, and the label noise σ² can never be removed. Minimizing their sum picks the sweet-spot complexity.
Code
import numpy as np
# Sample variance: divide by n is biased; n-1 (Bessel) is unbiased
true_var = 4.0
biased, unbiased = [], []
for _ in range(20000):
x = np.random.normal(0, 2, size=5)
biased.append(x.var(ddof=0)); unbiased.append(x.var(ddof=1))
print(np.mean(biased), np.mean(unbiased), true_var)In practice
Confidence intervals
The idea
A 95% confidence interval doesn’t mean “95% chance the truth is in this interval.” It means the procedure traps the true value 95% of the time across repeated samples. Draw many intervals and watch about that fraction cross the true mean — some always miss.
Picture it
In the companion lab you can draw sample intervals; count how many cover the true μ.
The math
Derivation
Since X̄ ≈ N(μ, σ²/n), the event |X̄ − μ| ≤ z·SE has probability = level. Rearranging puts μ inside [X̄ − z·SE, X̄ + z·SE] with that same probability — a statement about the random interval, not μ.
Code
import numpy as np
# 95% confidence interval for the mean (known idea: estimate +/- 1.96 SE)
x = np.random.normal(10, 3, size=40)
m, se = x.mean(), x.std(ddof=1)/np.sqrt(len(x))
lo, hi = m - 1.96*se, m + 1.96*se
print(round(lo,2), round(hi,2)) # ~95% of such intervals cover the truthIn practice
Hypothesis testing & p-values
The idea
A test asks: if nothing were going on (the null), how surprising is what we saw? The p-value is the tail probability of a result at least this extreme under the null. Small p → reject. Increase the true effect or the sample size and watch the test gain power to detect it.
Picture it
In the companion lab you can set the true effect and sample size; pick α.
The math
Derivation
Under H₀ the statistic is N(0,1); the critical value z_{α/2} fixes the Type-I rate at α. Power = P(reject | effect real) = Φ(effect·√n − z_{α/2}); a Type-II error (miss) is 1 − power.
Code
import numpy as np
from scipy import stats
# Two-sample test: is the mean different between groups?
a = np.random.normal(0.0, 1, 50)
b = np.random.normal(0.4, 1, 50)
t, p = stats.ttest_ind(a, b)
print(f"t={t:.2f}, p={p:.3f}", "reject H0" if p < 0.05 else "keep H0")In practice
MLE vs MAP & the prior
The idea
Maximum likelihood trusts only the data; MAP adds a prior belief and lets the two combine. With little data the prior dominates and pulls the estimate toward itself; with lots of data the likelihood wins and MAP → MLE. A Gaussian prior is exactly L2 regularization in disguise. Move the prior and the data.
Picture it
In the companion lab you can set the prior (mean and strength) and the data (mean and amount).
The math
Derivation
Maximizing log p(D|θ)+log p(θ) for Gaussians gives a precision-weighted average of data and prior. The prior term −(θ−θ₀)²/2τ² is a quadratic penalty — i.e. L2 / weight decay centered at θ₀.
Code
import numpy as np
# MLE vs MAP for a coin: MAP adds a Beta(a,b) prior (pseudo-counts)
heads, n = 7, 10
mle = heads / n # 0.70
a, b = 2, 2 # weak prior toward 0.5
mAP = (heads + a - 1) / (n + a + b - 2) # 0.643
print(mle, round(mAP, 3))In practice
Linear regression
The idea
The simplest learner: fit a straight line that minimizes the total squared vertical gap to the data. There’s a closed-form answer — no iteration needed — because setting the derivative of the squared error to zero gives two linear equations. Drag the points and watch the best-fit line and its residuals respond instantly.
Picture it
In the companion lab you can drag a point to move it, or click empty space to add one.
The math
Derivation
Set ∂J/∂w = 0 and ∂J/∂b = 0 → the two normal equations. In matrix form XᵀXβ = Xᵀy, so β = (XᵀX)⁻¹Xᵀy — exactly projecting y onto the column space of X.
Code
import numpy as np
# Closed-form linear regression via the normal equations
X = np.c_[np.ones(100), np.random.randn(100, 2)]
beta_true = np.array([1.0, 2.0, -1.0])
y = X @ beta_true + 0.3*np.random.randn(100)
beta = np.linalg.lstsq(X, y, rcond=None)[0]
print(np.round(beta, 2))In practice
Logistic regression
The idea
Now the labels are classes, not numbers. Logistic regression learns a linear boundary by gradient descent on the cross-entropy loss; the gradient is the same clean (prediction − label)·x. Step the training and watch the boundary swing into place between the two clouds while the loss drops.
Picture it
In the companion lab you can step gradient descent and watch the boundary move.
The math
Derivation
Using σ′ = σ(1−σ), the per-example gradient of the cross-entropy collapses to (p−y)·x. No closed form (the loss is convex but transcendental), so we descend — but convexity guarantees the global optimum.
Code
import numpy as np
# Logistic regression by gradient descent on the log-loss
def sigmoid(z): return 1/(1+np.exp(-z))
X = np.c_[np.ones(200), np.random.randn(200, 2)]
y = (X[:,1] + X[:,2] > 0).astype(float)
w = np.zeros(3)
for _ in range(500):
p = sigmoid(X @ w)
w -= 0.1 * X.T @ (p - y) / len(y) # gradient of log-loss
print(np.round(w, 2))In practice
k-means clustering
The idea
Unsupervised: group points so each sits near its cluster’s centre. k-means alternates two cheap steps — assign every point to the nearest centroid, then move each centroid to the mean of its members — and repeats. Each step can only lower the total within-cluster distance, so it converges (to a local optimum). Step it and watch it settle.
Picture it
In the companion lab you can step Lloyd’s algorithm; change k; re-seed the centroids.
The math
Derivation
It’s coordinate descent on J: fixing centroids, nearest-assignment minimizes J; fixing assignments, the mean minimizes Σ‖x−μ‖² (set gradient to 0). Both steps are non-increasing ⇒ convergence; the result depends on initialization.
Code
import numpy as np
# Lloyd's algorithm: assign to nearest centroid, then recompute centroids
X = np.vstack([np.random.randn(100,2),
np.random.randn(100,2)+5])
k = 2; C = X[np.random.choice(len(X), k, replace=False)]
for _ in range(10):
d = ((X[:,None,:]-C[None])**2).sum(-1) # distances to centroids
lab = d.argmin(1)
C = np.array([X[lab==j].mean(0) for j in range(k)])
print(np.round(C, 2))In practice
Principal component analysis
The idea
PCA finds the directions along which the data varies most. The first principal component is the axis capturing maximum variance; the next is orthogonal to it, and so on. They are exactly the eigenvectors of the covariance matrix, with eigenvalues equal to the variance each captures. Reshape the cloud and watch the axes track it.
Picture it
In the companion lab you can stretch and rotate the data cloud; the principal axes follow.
The math
Derivation
Maximize the projected variance wᵀΣw subject to ‖w‖ = 1. The Lagrangian gives Σw = λw — so the maximizer is the top eigenvector, and λ is the variance along it.
Code
import numpy as np
# PCA = eigenvectors of the covariance (directions of max variance)
X = np.random.randn(500, 2) @ np.array([[3,1],[0,1.]])
Xc = X - X.mean(0)
cov = np.cov(Xc.T)
vals, vecs = np.linalg.eigh(cov)
order = vals.argsort()[::-1]
print("explained var ratio:", np.round(vals[order]/vals.sum(), 3))
Z = Xc @ vecs[:, order][:, :1] # project to top componentIn practice
Maximum-margin classifier
The idea
A support vector machine doesn’t just separate the classes — it finds the widest street between them. The boundary is pinned by the few closest points (the support vectors); everything else is irrelevant. Rotate and slide the boundary to widen the margin while keeping the classes apart.
Picture it
In the companion lab you can rotate and shift the boundary to maximize the margin (street width).
The math
Derivation
Scale w,b so the nearest points satisfy |w·x+b| = 1. Then each side’s half-width is 1/‖w‖, giving a total street of 2/‖w‖. Widening the street shrinks ‖w‖ — a convex quadratic program.
Code
import numpy as np
# Margin = distance from the separating line to the nearest points
w, b = np.array([1.0, -1.0]), 0.0
margin = lambda x: (w @ x + b) / np.linalg.norm(w)
pts = np.array([[2, 0.], [0, 2.], [3, 1.]])
print(np.round([margin(p) for p in pts], 3)) # signed distancesIn practice
Hinge loss & soft margin
The idea
Real data isn’t perfectly separable, so the SVM allows violations but charges for them with the hinge loss: zero once a point is correctly classified beyond the margin, then rising linearly. Slide a point’s margin and compare hinge to the logistic loss and the un-trainable 0–1 loss.
Picture it
In the companion lab you can slide the (signed) margin m = y·(w·x+b) and read each loss.
The math
Derivation
The slack ξᵢ = max(0, 1 − yᵢfᵢ) measures how far inside the margin a point falls. Hinge is the tightest convex upper bound on the 0–1 loss, so minimizing it is a tractable proxy; its subgradient is −y·x when m < 1, else 0.
Code
import numpy as np
# Hinge loss: zero once a point is correct AND past the margin
def hinge(y, score): return np.maximum(0, 1 - y*score)
y = np.array([1, 1, -1, -1.])
score = np.array([2.0, 0.3, -1.5, 0.2])
print(hinge(y, score)) # [0, 0.7, 0, 1.2]In practice
The kernel trick
The idea
These two classes — a core and a surrounding ring — cannot be split by any straight line. But add one feature, the squared radius, and they separate trivially: a flat threshold in the lifted space is a circle back in the original space. Slide the threshold and watch a linear cut become a curved boundary.
Picture it
In the companion lab you can move the threshold on the lifted feature ‖x‖; it becomes a circular boundary on the left.
The math
Derivation
A separator that is linear in φ maps to a nonlinear surface in x. The trick: algorithms need only inner products φ(x)·φ(x′), which a kernel K computes directly — so you never form the (possibly infinite-dim) φ.
Code
import numpy as np
# The kernel trick: inner products in feature space without the map
def phi(x): return np.array([x[0]**2, np.sqrt(2)*x[0]*x[1], x[1]**2])
a, b = np.array([1.,2.]), np.array([3.,1.])
print(phi(a) @ phi(b)) # explicit feature dot product
print((a @ b)**2) # = polynomial kernel k(a,b)=(a.b)^2In practice
The RBF kernel
The idea
The radial-basis-function kernel measures similarity as a Gaussian bump: nearby points are similar, far ones aren’t. A classifier sums these bumps over labeled landmarks, carving a smooth nonlinear boundary. The width γ is the key dial — turn it up for wiggly, overfit boundaries; down for smooth ones.
Picture it
In the companion lab you can change the kernel width γ and watch the boundary tighten or smooth.
The math
Derivation
RBF corresponds to an infinite-dimensional feature map, yet K is a single exponential. Large γ → narrow bumps → each point influences only its neighborhood (high variance); small γ → wide, smooth (high bias).
Code
import numpy as np
# RBF (Gaussian) kernel: similarity decays with distance
def rbf(A, B, gamma=0.5):
d2 = ((A[:,None,:]-B[None])**2).sum(-1)
return np.exp(-gamma * d2)
X = np.random.randn(5, 2)
K = rbf(X, X) # 5x5 Gram matrix, 1 on the diagonal
print(np.round(K, 2))In practice
Splitting: entropy & Gini
The idea
A decision tree grows by asking yes/no questions that make each side purer. Impurity — entropy or Gini — measures how mixed a group’s labels are; a good split drops the weighted impurity the most. Slide the threshold to find the cut that maximizes information gain.
Picture it
In the companion lab you can move the threshold; watch impurity and information gain change.
The math
Derivation
Each candidate split partitions the node; the children’s impurities are weighted by their sizes. The tree greedily picks the feature+threshold with the largest gain — the cut that buys the most purity per point.
Code
import numpy as np
# Best split = the threshold that most reduces impurity (Gini)
def gini(y):
p = np.bincount(y, minlength=2)/len(y); return 1 - (p**2).sum()
x = np.array([1,2,3,4,5,6.]); y = np.array([0,0,0,1,1,1])
best = min(((x[:-1]+x[1:])/2),
key=lambda t: (gini(y[x<=t])*np.mean(x<=t) +
gini(y[x>t])*np.mean(x>t)))
print("best threshold:", best) # ~3.5In practice
A tree partitions space
The idea
Apply splitting recursively and the feature space carves into axis-aligned boxes, each predicting one class. Shallow trees underfit; deep trees can isolate every point — memorizing noise. Raise the depth and watch the rectangles multiply until the boundary turns jagged.
Picture it
In the companion lab you can increase max depth and watch regions split and accuracy climb (toward overfit).
The math
Derivation
Greedy recursion: the best single split is chosen at each node independently. The result is a partition into hyper-rectangles — expressive but high-variance, since small data changes can flip a split and reshape whole regions.
Code
from sklearn.tree import DecisionTreeClassifier
import numpy as np
X = np.random.randn(300, 2); y = (X[:,0]*X[:,1] > 0).astype(int)
tree = DecisionTreeClassifier(max_depth=3).fit(X, y)
print("train acc:", tree.score(X, y))
# deeper trees fit more -- and overfit; depth is the complexity knobIn practice
Bagging & random forests
The idea
One deep tree is jumpy — retrain it on a slightly different sample and the prediction lurches. Bagging trains many trees on bootstrap resamples and averages them; the wobble cancels out and the curve smooths. Random forests go further by decorrelating the trees. Add trees and watch the variance shrink.
Picture it
In the companion lab you can increase the number of bootstrapped trees; the average (green) smooths out.
The math
Derivation
Averaging B identically-distributed predictors keeps the bias but divides the independent part of the variance by B. Trees are correlated (ρ>0), so a floor ρσ² remains — random forests lower ρ by sampling features at each split.
Code
import numpy as np
# Bagging: average many trees trained on bootstrap samples -> less variance
from sklearn.tree import DecisionTreeRegressor
X = np.sort(np.random.rand(80,1),0); y = np.sin(6*X).ravel()+0.1*np.random.randn(80)
preds = []
for _ in range(50):
idx = np.random.randint(0, len(X), len(X)) # bootstrap
preds.append(DecisionTreeRegressor(max_depth=4).fit(X[idx], y[idx]).predict(X))
ensemble = np.mean(preds, 0) # smoother than any one treeIn practice
Boosting
The idea
Boosting builds the model one small step at a time: each new weak learner fits the residual the current ensemble still gets wrong, and is added with a small learning rate. Bias falls round by round. Increase the rounds and watch a sum of tiny stumps converge onto the curve.
Picture it
In the companion lab you can add boosting rounds; the additive model (amber) hugs the data more tightly.
The math
Derivation
Gradient boosting does stagewise gradient descent in function space: the residual is −∂L/∂F for squared loss, so each weak learner h_m points downhill. The shrinkage ν keeps steps small to avoid overfitting.
Code
import numpy as np
# Gradient boosting: each tree fits the residual of the last
from sklearn.tree import DecisionTreeRegressor
X = np.sort(np.random.rand(120,1),0); y = np.sin(6*X).ravel()
pred = np.zeros_like(y); lr = 0.3
for _ in range(30):
resid = y - pred
pred += lr * DecisionTreeRegressor(max_depth=2).fit(X, resid).predict(X)
print("MSE:", np.mean((y-pred)**2))In practice
Naïve Bayes
The idea
Naïve Bayes classifies by Bayes’ rule plus one bold shortcut: assume the features are independent given the class. Each class becomes a product of simple per-feature densities (here axis-aligned Gaussians). Drag the test point across the boundary and watch the posterior flip.
Picture it
In the companion lab you can drag the test point; the bars show the posterior over the two classes.
The math
Derivation
Bayes gives P(y|x) = P(x|y)P(y)/P(x). The “naïve” assumption factors the likelihood P(x|y)=∏ᵢP(xᵢ|y), turning a hard joint density into easy 1-D fits — hence the axis-aligned ellipses.
Code
import numpy as np
# Naive Bayes: assume features independent given the class
from sklearn.naive_bayes import GaussianNB
X = np.vstack([np.random.randn(100,2), np.random.randn(100,2)+3])
y = np.r_[np.zeros(100), np.ones(100)]
print("acc:", GaussianNB().fit(X, y).score(X, y))
# posterior P(class|x) ~ P(class) * prod_i P(x_i|class)In practice
Gaussian mixtures & EM
The idea
A Gaussian mixture models data as a blend of bell-shaped clusters — but unlike k-means it assigns points softly, by probability. Expectation–Maximization alternates: estimate each point’s cluster responsibilities (E), then re-fit the Gaussians to those weighted points (M). Step it and watch the ellipses lock on.
Picture it
In the companion lab you can step EM (E then M); change the number of components; re-seed.
The math
Derivation
EM maximizes a lower bound (the same ELBO idea as VAEs): the E-step fills in soft cluster memberships, the M-step does weighted maximum-likelihood. Each round never decreases the data log-likelihood.
Code
import numpy as np
from sklearn.mixture import GaussianMixture
X = np.vstack([np.random.randn(200,2), np.random.randn(200,2)+4])
gmm = GaussianMixture(n_components=2).fit(X) # fit by EM
print("means:\n", np.round(gmm.means_, 2))
resp = gmm.predict_proba(X[:3]) # soft cluster memberships
print(np.round(resp, 2))In practice
Markov chains
The idea
A Markov chain hops between states with fixed transition probabilities, remembering only where it is now. Multiply the current distribution by the transition matrix repeatedly and it converges to a unique stationary distribution — no matter where you start. Step the distribution and tune how “sticky” the states are.
Picture it
In the companion lab you can step the distribution forward; adjust how often a state stays put.
The math
Derivation
Repeated multiplication is power iteration: components along sub-dominant eigenvectors shrink, leaving the eigenvector with eigenvalue 1. For an irreducible, aperiodic chain that limit π is unique and start-independent.
Code
import numpy as np
# Markov chain: next state depends only on the current state
P = np.array([[0.9, 0.1],
[0.5, 0.5]]) # transition matrix
# stationary distribution = left eigenvector with eigenvalue 1
vals, vecs = np.linalg.eig(P.T)
pi = np.real(vecs[:, np.isclose(vals, 1)].ravel()); pi /= pi.sum()
print(np.round(pi, 3))In practice
Conjugate Bayesian updating
The idea
Bayesian learning updates a belief as evidence arrives. For a coin, a Beta prior over the bias meets Bernoulli data and — because they’re conjugate — the posterior is just another Beta with the counts added in. Flip some heads and tails and watch the belief sharpen around the truth.
Picture it
In the companion lab you can set the prior, then add observations.
The math
Derivation
Likelihood θ^H(1−θ)^T times prior θ^{α−1}(1−θ)^{β−1} ∝ θ^{α+H−1}(1−θ)^{β+T−1} — a Beta kernel. Conjugacy means the posterior stays in the same family, so updating is just adding counts.
Code
# Beta-Binomial conjugacy: posterior stays Beta, just add counts
a, b = 2, 2 # prior Beta(2,2)
heads, tails = 8, 2
a_post, b_post = a + heads, b + tails
mean = a_post / (a_post + b_post)
print(a_post, b_post, round(mean, 3)) # closed-form Bayesian updateIn practice
SVD & low-rank approximation
The idea
Any matrix factors into rotation·stretch·rotation — the singular value decomposition. Keep only the largest few singular values and you get the best possible low-rank approximation, which is how images and embeddings compress. Slide the rank k and watch a shape rebuild from a handful of components.
Picture it
In the companion lab you can raise the rank k; the reconstruction sharpens as more components are kept.
The math
Derivation
σᵢ are √ of the eigenvalues of AᵀA; the uᵢ, vᵢ are its eigenvectors. Eckart–Young: truncating to the top k singular values is the closest rank-k matrix in Frobenius (and spectral) norm — no better low-rank fit exists.
Code
import numpy as np
# SVD factorizes ANY matrix: A = U S V^T; truncate for low-rank approx
A = np.random.randn(20, 10) @ np.random.randn(10, 8) # rank <= 8
U, s, Vt = np.linalg.svd(A, full_matrices=False)
k = 3
A_k = U[:,:k] * s[:k] @ Vt[:k] # best rank-3 approximation
print("rel error:", np.linalg.norm(A-A_k)/np.linalg.norm(A))In practice
Whitening
The idea
Whitening turns a tilted, stretched cloud into a clean isotropic blob with identity covariance. It runs in three moves: center the data, rotate onto the principal axes, then rescale each axis to unit variance. Step through the stages and watch the covariance ellipse become a circle.
Picture it
In the companion lab you can step through the whitening stages.
The math
Derivation
Vᵀ rotates onto the eigen-axes (decorrelating); dividing each by √λ (its std) equalizes the variances. The result has zero mean and identity covariance — every direction now equally scaled.
Code
import numpy as np
# Whitening: rotate + scale so features have unit variance, no correlation
X = np.random.randn(1000,2) @ np.array([[2,1.],[0,1]])
Xc = X - X.mean(0)
cov = np.cov(Xc.T); vals, vecs = np.linalg.eigh(cov)
W = vecs @ np.diag(1/np.sqrt(vals)) @ vecs.T # whitening matrix
Xw = Xc @ W
print(np.round(np.cov(Xw.T), 2)) # ~identityIn practice
Neighbor embeddings (t-SNE / UMAP)
The idea
t-SNE and UMAP don’t try to preserve every distance — just who’s near whom. They model neighbors with a similarity kernel and pack the map so local neighborhoods survive. Drag the query and shrink the bandwidth to see how few points really count as “neighbors.”
Picture it
In the companion lab you can drag the query point; change the neighborhood bandwidth (perplexity).
The math
Derivation
A Gaussian falls off fast, so only nearby points get real weight — that’s the local neighborhood. The heavy-tailed Student-t in the map gives distant points room (fixing the “crowding problem”), and minimizing KL aligns the two.
Code
import numpy as np
from sklearn.manifold import TSNE
# t-SNE: preserve local neighborhoods for 2-D visualization
X = np.vstack([np.random.randn(100,10),
np.random.randn(100,10)+5])
emb = TSNE(n_components=2, perplexity=30, init='pca').fit_transform(X)
print(emb.shape) # (200, 2) -- two visible clustersIn practice
Random projection (Johnson–Lindenstrauss)
The idea
You can crush high-dimensional data into far fewer dimensions with a random matrix and still keep all the pairwise distances almost intact — no training, no data peeking. The Johnson–Lindenstrauss lemma says the needed dimension depends only on the number of points and the tolerance. Raise the target dimension and watch distances snap to the diagonal.
Picture it
In the companion lab you can increase the projection dimension k; points hug the diagonal (distances preserved).
The math
Derivation
Each random coordinate of z is a Gaussian whose variance equals the squared distance; averaging k of them concentrates around the true value with error ∼ 1/√k. JL: k ≈ 8·ln(N)/ε² suffices — independent of the original D.
Code
import numpy as np
# Random projection: a random matrix nearly preserves distances (JL lemma)
X = np.random.randn(500, 1000)
R = np.random.randn(1000, 50) / np.sqrt(50)
Z = X @ R # 1000-D -> 50-D
# pairwise distances are roughly preserved
d_hi = np.linalg.norm(X[0]-X[1]); d_lo = np.linalg.norm(Z[0]-Z[1])
print(round(d_hi,1), round(d_lo,1))In practice
Confusion matrix & metrics
The idea
A classifier’s score becomes a decision only after you pick a threshold — and that choice trades false positives against false negatives. The confusion matrix tallies the four outcomes; precision, recall, and F1 summarize them. Slide the threshold and watch the trade-off move.
Picture it
In the companion lab you can move the decision threshold; the counts and metrics rebalance.
The math
Derivation
Raising the threshold demands more confidence: FP fall (precision ↑) but FN rise (recall ↓). F1 is the harmonic mean — high only when both are high — making it the go-to single number when classes matter asymmetrically.
Code
import numpy as np
from sklearn.metrics import confusion_matrix
y_true = np.array([1,1,0,0,1,0,1,0])
y_pred = np.array([1,0,0,0,1,1,1,0])
tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
precision = tp/(tp+fp); recall = tp/(tp+fn)
print(f"P={precision:.2f} R={recall:.2f} F1={2*precision*recall/(precision+recall):.2f}")In practice
ROC & AUC
The idea
Sweep the threshold across every value and plot true-positive rate against false-positive rate — that’s the ROC curve. The area under it (AUC) is the probability the model ranks a random positive above a random negative: a single, threshold-free score of separability. Pull the classes apart and watch AUC climb toward 1.
Picture it
In the companion lab you can change how separable the classes are, and slide the operating threshold.
The math
Derivation
Each threshold gives one (FPR, TPR) point; sweeping traces the curve. The area equals the chance a random positive outranks a random negative — so AUC is invariant to the threshold and to class balance, unlike accuracy.
Code
import numpy as np
from sklearn.metrics import roc_auc_score, roc_curve
y = np.r_[np.zeros(100), np.ones(100)]
scores = np.r_[np.random.rand(100), np.random.rand(100)+0.5]
fpr, tpr, thr = roc_curve(y, scores)
print("AUC =", round(roc_auc_score(y, scores), 3)) # ranking qualityIn practice
Cross-validation
The idea
A single train/test split is a noisy estimate of how a model generalizes. k-fold cross-validation rotates the held-out fold k times and averages, using all the data for both training and testing. More folds means more training data per round (less bias) but noisier, costlier estimates. Change k and watch the spread.
Picture it
In the companion lab you can change k; each row holds out one fold as the test set. Watch the estimate’s spread.
The math
Derivation
Averaging k estimates cuts the variance of the generalization estimate. Larger k → each training set is bigger (lower bias) but folds overlap heavily and tests shrink (higher variance, more compute). k = 5 or 10 is the usual sweet spot.
Code
import numpy as np
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
X = np.random.randn(200, 4); y = (X[:,0] > 0).astype(int)
scores = cross_val_score(LogisticRegression(), X, y, cv=5)
print("fold scores:", np.round(scores, 3), "mean:", round(scores.mean(),3))In practice
Calibration & reliability
The idea
A model can rank perfectly yet lie about its confidence — saying “90% sure” when it’s right 70% of the time. A reliability diagram plots predicted probability against observed frequency; the diagonal is perfect calibration. Temperature scaling divides the logits by T to fix overconfidence. Tune T to straighten the curve.
Picture it
In the companion lab you can adjust the temperature T to calibrate the (overconfident) model toward the diagonal.
The math
Derivation
Modern nets are overconfident (logits too large), so points bow below the diagonal. Dividing logits by T > 1 softens them toward the true frequencies without changing the ranking (AUC unchanged) — a one-parameter post-hoc fix.
Code
import numpy as np
# Calibration: among samples predicted p=0.7, ~70% should be positive
p = np.random.rand(10000)
y = (np.random.rand(10000) < p).astype(int) # perfectly calibrated
bins = np.linspace(0,1,11)
for lo, hi in zip(bins[:-1], bins[1:]):
m = (p>=lo)&(p<hi)
if m.sum(): print(f"pred~{(lo+hi)/2:.1f} actual={y[m].mean():.2f}")In practice
Collaborative filtering
The idea
Collaborative filtering fills in missing ratings using the crowd: to guess how a user would rate an item, look at similar items they have rated and take a weighted average. Similarity comes from the rating patterns themselves — no content needed. Change how many neighbors you trust and watch the prediction shift.
Picture it
In the companion lab you can predict the highlighted missing cell from its k most similar (item-item) neighbors.
The math
Derivation
Item columns that move together across users are “similar.” A user’s known ratings on those neighbors, weighted by similarity, estimate the unknown one. User-user CF is the transpose; item-item is usually more stable (items have more ratings).
Code
import numpy as np
# User-based collaborative filtering via cosine similarity
R = np.array([[5,4,0,0],[5,5,0,0],[0,0,4,5],[0,0,5,4.]]) # users x items
def cos(u, v):
m = (u>0)&(v>0)
return 0 if m.sum()==0 else u[m]@v[m]/(np.linalg.norm(u[m])*np.linalg.norm(v[m]))
sim = np.array([[cos(R[i],R[j]) for j in range(4)] for i in range(4)])
print(np.round(sim, 2))In practice
Matrix factorization
The idea
Matrix factorization explains the whole rating matrix with a few hidden factors: each user and each item gets a short latent vector, and a rating is their dot product. Fitting these vectors to the observed ratings fills in every blank at once. Train it and watch the reconstruction sharpen and the error fall.
Picture it
In the companion lab you can train the factors by SGD; adjust the latent dimension and regularization.
The math
Derivation
SGD on each observed rating: e = r − p_u·q_i, then p_u += η(e·q_i − λp_u), q_i += η(e·p_u − λq_i). The low rank d forces shared structure, so the model generalizes to unobserved cells instead of memorizing.
Code
import numpy as np
# Matrix factorization: R ~ U V^T learned by gradient descent
R = np.array([[5,3,0,1],[4,0,0,1],[1,1,0,5],[0,0,5,4.]])
mask = R>0; U = np.random.rand(4,2); V = np.random.rand(4,2)
for _ in range(2000):
E = mask*(R - U@V.T)
U += 0.01*(E@V); V += 0.01*(E.T@U)
print(np.round(U@V.T, 1)) # fills in the blanksIn practice
Implicit feedback & ranking
The idea
Most real signals aren’t star ratings — they’re clicks, plays, and purchases: positive when present, but silence doesn’t mean dislike. So instead of predicting a number, we learn to rank seen items above unseen ones. Train the pairwise objective and watch clicked items rise to the top.
Picture it
In the companion lab you can train so clicked items (green) outrank the rest for this user.
The math
Derivation
Absolute values are unknown, but order is informative: a click means i > j for some unseen j. Optimizing many such pairs (BPR / WARP) shapes scores so positives float up — evaluated by ranking metrics, not RMSE.
Code
import numpy as np
# Implicit feedback: clicks/plays as confidence, not ratings
plays = np.array([[0,3,0,12],[5,0,0,0],[0,0,8,1.]])
conf = 1 + 40*plays # more plays -> more confident "like"
pref = (plays > 0).astype(float) # binary preference target
print(conf) # weighting used by ALS for implicit dataIn practice
The cold-start problem
The idea
A brand-new user or item has no interactions, so pure collaborative filtering has nothing to go on — it can’t place them in the latent space. The fix is to fall back on content: position newcomers by their features (genre, profile) until behavior accumulates, or just recommend what’s popular. Toggle content features to rescue the cold item.
Picture it
In the companion lab you can a new item has no ratings. Turn on content features to place it near similar items.
The math
Derivation
Matrix factorization learns q_i only from observed ratings; with none, the vector is unconstrained. Mapping features → latent space (a learned regressor) gives an initial position, blending content and collaborative signal.
Code
import numpy as np
# Cold start: no history -> fall back to content features / popularity
item_feats = np.random.randn(5, 8) # new items have features
user_profile = np.random.randn(8) # from items they liked
scores = item_feats @ user_profile # content-based ranking
print("recommend item:", scores.argmax())In practice
Standardization & scaling
The idea
Many models judge distance or take gradient steps, and both break when features live on wildly different scales — a feature measured in thousands drowns out one in fractions. Standardizing (subtract mean, divide by std) puts every feature on equal footing. Toggle scaling and watch which points count as “nearest.”
Picture it
In the companion lab you can toggle standardization; the 3 nearest neighbors of the query change once features are comparable.
The math
Derivation
Euclidean distance sums squared differences, so a large-range feature dominates the sum and the others barely register. Equalizing variance makes each feature contribute comparably — the same reason it rounds the loss surface for gradient descent.
Code
import numpy as np
# Standardize: subtract mean, divide by std (fit on TRAIN only)
Xtr = np.random.randn(100, 3)*[10,1,0.1] + [5,0,-2]
mu, sd = Xtr.mean(0), Xtr.std(0)
Xtr_s = (Xtr - mu)/sd
Xte_s = (np.random.randn(20,3)*[10,1,0.1] + [5,0,-2] - mu)/sd # reuse mu,sd
print(np.round(Xtr_s.mean(0),2), np.round(Xtr_s.std(0),2))In practice
Categorical encoding
The idea
Models eat numbers, not words — so categories must be encoded. Mapping them to 1, 2, 3 sneaks in a false order and false distances (is “cat 3” really three times “cat 1”?). One-hot encoding instead gives each category its own axis, so all of them sit equidistant. Toggle between the two.
Picture it
In the companion lab you can compare label encoding (implied order) with one-hot (equidistant).
The math
Derivation
Label encoding embeds categories on a line, so |3−1| = 2 > |2−1| = 1 — a metric the data never implied. One-hot vectors are orthogonal, so every pair is equally (dis)similar, which is the honest default for nominal categories.
Code
import numpy as np, pandas as pd
# One-hot encode a categorical column
df = pd.DataFrame({'color': ['red','blue','red','green']})
oh = pd.get_dummies(df['color'])
print(oh.values) # each category -> its own 0/1 columnIn practice
Polynomial & interaction features
The idea
A linear model is only linear in its features — so if you hand it x, x², x³… it can bend into curves while the fitting stays linear and convex. Interaction terms like x₁x₂ let it carve regions a plain linear model can’t. Raise the degree and watch a straight line become a curve (then overfit).
Picture it
In the companion lab you can increase the polynomial degree; the linear model fits curves — then starts to overfit.
The math
Derivation
Mapping x to a higher-dimensional feature vector makes a nonlinear target linear in those features, so ordinary least squares still applies. This is the explicit version of the kernel trick — same idea, computed directly.
Code
import numpy as np
from sklearn.preprocessing import PolynomialFeatures
# Polynomial features let a linear model fit curves
X = np.linspace(-2,2,5).reshape(-1,1)
Xp = PolynomialFeatures(degree=3, include_bias=False).fit_transform(X)
print(np.round(Xp, 2)) # columns: x, x^2, x^3In practice
Binning & discretization
The idea
Binning chops a continuous feature into intervals and treats each as a category. A linear model then fits a separate level per bin — a step function that captures nonlinearity and shrugs off outliers, at the cost of throwing away within-bin detail. Change the bin count to trade resolution against smoothing.
Picture it
In the companion lab you can set the number of equal-width bins; the per-bin mean forms a step-function predictor.
The math
Derivation
One indicator per bin lets a linear model assign an independent level to each interval — a piecewise-constant fit. Equal-width bins are simplest; quantile bins equalize counts. More bins → finer detail but noisier, fewer points each.
Code
import numpy as np, pandas as pd
# Binning turns a continuous feature into ordinal buckets
age = np.array([5, 17, 22, 40, 67, 90])
bins = [0, 18, 35, 65, 120]
labels = ['child','young','adult','senior']
print(pd.cut(age, bins=bins, labels=labels).tolist())In practice
Stationarity & autocorrelation
The idea
Time-series methods assume the statistics don’t drift — stationarity. The autocorrelation function (ACF) measures how a series correlates with its own past at each lag. A stationary series’ ACF decays; a trending one stays stubbornly high. Raise the persistence and add a trend to see the ACF refuse to die.
Picture it
In the companion lab you can set the AR(1) persistence φ; add a trend to break stationarity (ACF stops decaying).
The math
Derivation
For AR(1), ACF(k) = φᵏ — a clean geometric decay, faster for small φ. A trend injects a slowly varying mean, so distant points stay correlated and the sample ACF decays barely at all: the tell-tale of non-stationarity.
Code
import numpy as np
# Autocorrelation: correlation of a series with a lagged copy of itself
x = np.sin(np.linspace(0, 20, 200)) + 0.3*np.random.randn(200)
def acf(x, lag):
x = x - x.mean()
return (x[:-lag] @ x[lag:]) / (x @ x)
print([round(acf(x, k), 2) for k in (1, 5, 10)])In practice
Autoregressive (AR) models
The idea
An autoregressive model predicts the next value as a weighted sum of recent values plus noise. AR(1) gives smooth persistence or mean-reversion; AR(2) can oscillate. The coefficients decide whether the series settles, cycles, or blows up — stay inside the stationarity region. Tune φ₁ and φ₂.
Picture it
In the companion lab you can set the AR(2) coefficients and watch the behavior — decay, oscillation, or explosion.
The math
Derivation
The series is a linear recurrence driven by noise; its long-run behavior is set by the characteristic roots. Complex roots → damped oscillations (pseudo-cycles); real roots in range → smooth decay; outside the region → the variance diverges.
Code
import numpy as np
# AR(1): x_t = phi * x_{t-1} + noise
phi, n = 0.8, 300
x = np.zeros(n)
for t in range(1, n):
x[t] = phi*x[t-1] + np.random.randn()
# estimate phi back by regressing x_t on x_{t-1}
phi_hat = (x[:-1] @ x[1:]) / (x[:-1] @ x[:-1])
print(round(phi_hat, 2))In practice
Trend + seasonality decomposition
The idea
Many series are a sum of a slow trend, a repeating seasonal pattern, and leftover noise. Classical decomposition pulls them apart: a moving average estimates the trend, averaging by phase gives the season, and what remains is the residual. Adjust the trend and seasonal strength and watch the parts separate.
Picture it
In the companion lab you can set the trend slope and seasonal amplitude; the panels show the recovered components.
The math
Derivation
A centered moving average over one season cancels the seasonal swing, leaving the trend. Subtract it and average the leftovers within each phase to get the repeating season; whatever is still unexplained is residual (ideally white noise).
Code
import numpy as np
# Decompose into trend + seasonal + residual
t = np.arange(120)
trend = 0.05*t; season = 2*np.sin(2*np.pi*t/12)
y = trend + season + 0.3*np.random.randn(120)
trend_hat = np.convolve(y, np.ones(12)/12, mode='same') # moving average
season_hat = (y - trend_hat)
print(np.round(trend_hat[:3], 2))In practice
Forecasting & uncertainty
The idea
Forecasting projects the pattern forward — and honest forecasts come with widening uncertainty, since errors compound the further out you go. Exponential smoothing tracks the level with a memory knob α, then extrapolates. Tune α and the horizon to see the fit and the fanning prediction interval.
Picture it
In the companion lab you can set the smoothing α (recency weight) and the forecast horizon; the band widens with distance.
The math
Derivation
Exponential smoothing is a weighted average with geometrically decaying weights — small α is smooth/sluggish, large α is reactive. Multi-step errors accumulate roughly like a random walk, so the interval grows with √h.
Code
import numpy as np
# Exponential smoothing forecast: weight recent points more
y = np.cumsum(np.random.randn(50)) + 10
alpha, level = 0.3, y[0]
for v in y:
level = alpha*v + (1-alpha)*level # update
forecast = level # next-step prediction
print(round(forecast, 2))In practice
Confounding & Simpson’s paradox
The idea
Correlation can point the wrong way. A lurking confounder that drives both variables can make an overall trend slope opposite to the trend within every subgroup — Simpson’s paradox. The fix is to condition on the confounder. Toggle the adjustment and watch the misleading overall line flip.
Picture it
In the companion lab you can toggle adjusting for the confounder (group). The honest within-group trend is the opposite sign.
The math
Derivation
If Z raises both X and Y, points from high-Z groups sit up-and-right, faking a positive pooled slope even when X lowers Y inside each group. Stratifying by Z removes the between-group shift, exposing the true relationship.
Code
import numpy as np
# A confounder U drives both X and Y -> naive correlation is misleading
n = 5000
U = np.random.randn(n)
X = U + 0.5*np.random.randn(n) # no real X->Y effect
Y = 2*U + 0.5*np.random.randn(n) # Y depends on U, not X
naive = np.polyfit(X, Y, 1)[0] # ~1, spurious
adjusted = np.polyfit(np.c_[X, U], Y, 1) # control for U -> X coef ~0
print(round(naive, 2))In practice
Randomization (RCTs)
The idea
The cleanest way to a causal effect is to randomize who gets the treatment. Randomization makes the treated and control groups statistically identical in everything — measured or not — so any outcome difference is the effect. Without it, sicker patients self-select and bias the naive comparison. Toggle randomization.
Picture it
In the companion lab you can compare observational assignment (confounded) with randomized assignment (balanced).
The math
Derivation
If treatment is assigned by coin flip, the confounder Z has the same distribution in both arms, so the naive mean difference is unbiased for the causal effect. In observational data Z differs across arms, and that imbalance contaminates the estimate.
Code
import numpy as np
# Randomization breaks confounding: treatment is independent of U
n = 10000; U = np.random.randn(n)
T = (np.random.rand(n) < 0.5).astype(float) # randomly assigned
Y = 3*T + 2*U + np.random.randn(n) # true effect of T is 3
ate = Y[T==1].mean() - Y[T==0].mean() # difference in means
print(round(ate, 2)) # ~3, unbiased thanks to randomizationIn practice
Confounding & the backdoor
The idea
Causal graphs make the logic explicit. A confounder Z opens a “backdoor” path X ← Z → Y that leaks non-causal association into the X–Y link. The do-operator asks the interventional question, and adjusting for Z blocks the backdoor so the remaining association is the true effect. Toggle the adjustment on the DAG.
Picture it
In the companion lab you can adjust for Z to block the backdoor path and recover the causal effect.
The math
Derivation
do(X) erases the arrows into X, deleting the Z→X edge and with it the backdoor. Conditioning on Z reproduces that severed graph from observational data, so the conditional association equals the causal effect.
Code
import numpy as np
# Backdoor adjustment: condition on confounders to block spurious paths
import pandas as pd
n=8000; Z=np.random.binomial(1,0.5,n) # confounder
T=np.random.binomial(1,0.3+0.4*Z,n) # Z->T
Y=1.5*T+2*Z+np.random.randn(n) # Z->Y, true T effect=1.5
df=pd.DataFrame({'Y':Y,'T':T,'Z':Z})
eff=sum(df[df.Z==z].groupby('T').Y.mean().diff().iloc[-1]*np.mean(Z==z)
for z in (0,1)) # adjust within strata of Z
print(round(eff,2))In practice
Instrumental variables
The idea
Sometimes the confounder can’t be measured — so adjustment is impossible. An instrument Z is a variable that nudges the treatment X but affects the outcome Y only through X. It carves out variation in X that’s unrelated to the hidden confounder, letting you recover the true effect. Tune the instrument’s strength.
Picture it
In the companion lab you can increase how strongly the instrument moves X. Strong → IV recovers the true slope; weak → noisy.
The math
Derivation
Plain OLS of Y on X is biased because the hidden U moves both. Z is independent of U, so projecting Y and X onto Z strips out the confounded part; their ratio isolates the causal slope X→Y.
Code
import numpy as np
# Instrumental variable: Z affects Y only through X
n=10000; Z=np.random.randn(n); U=np.random.randn(n)
X=Z+U+0.3*np.random.randn(n) # Z->X (and confounder U)
Y=2*X+3*U+np.random.randn(n) # true X effect=2
iv = np.cov(Z,Y)[0,1] / np.cov(Z,X)[0,1] # Cov(Z,Y)/Cov(Z,X)
print(round(iv, 2)) # ~2, unlike biased OLSIn practice
Bayesian inference & the posterior
The idea
Bayesian inference treats the unknown as a distribution, not a point. Start with a prior belief, multiply by the likelihood of the data, and you get the posterior — a sharper belief. Here we infer an unknown mean: add data and watch the posterior pull away from the prior toward the sample and tighten.
Picture it
In the companion lab you can add observations and watch the posterior narrow and shift; loosen the prior to let data dominate sooner.
The math
Derivation
Multiplying two Gaussians gives a Gaussian whose precision is the sum of precisions — so each data point adds 1/σ² of certainty. The posterior mean is a precision-weighted blend of the prior mean and the sample mean.
Code
import numpy as np
# Posterior over a coin's bias on a grid (no conjugacy needed)
theta = np.linspace(0,1,201)
prior = np.ones_like(theta) # uniform prior
heads, tails = 8, 2
like = theta**heads * (1-theta)**tails
post = prior*like; post /= np.trapz(post, theta)
print("posterior mean:", round(np.trapz(theta*post, theta), 3))In practice
MCMC: Metropolis sampling
The idea
When the posterior has no closed form, we sample it. The Metropolis algorithm wanders parameter space: propose a nearby step, and accept it with a probability set by the density ratio — always uphill, sometimes downhill. The collected samples trace the target distribution. Step the chain and tune the proposal width.
Picture it
In the companion lab you can run the chain; tune the proposal width (too small mixes slowly, too big gets rejected).
The math
Derivation
The accept rule satisfies detailed balance, so the chain’s long-run distribution is exactly p — even though p is known only up to a constant (the ratio cancels it). Step size trades acceptance against exploration; ~25–50% acceptance is healthy.
Code
import numpy as np
# Metropolis-Hastings samples from an unnormalized density
def logp(x): return -0.5*x**2 # target ~ N(0,1)
x, samples = 0.0, []
for _ in range(20000):
xp = x + np.random.normal(0, 1) # proposal
if np.log(np.random.rand()) < logp(xp)-logp(x):
x = xp # accept
samples.append(x)
samples = np.array(samples[2000:])
print(round(samples.mean(),2), round(samples.std(),2))In practice
Gibbs sampling
The idea
Gibbs sampling is MCMC for multivariate targets: instead of moving in all directions at once, it updates one variable at a time from its conditional distribution given the rest. The chain takes axis-aligned steps that, together, fill out the joint. Step it on a correlated 2-D Gaussian and watch the staircase trace the ellipse.
Picture it
In the companion lab you can step Gibbs updates (x|y then y|x); the staircase walk fills the correlated target.
The math
Derivation
Each conditional is the exact stationary update for that coordinate, so every move is always accepted (acceptance = 1). The sweep over all coordinates leaves the joint invariant — a special case of Metropolis–Hastings with conditional proposals.
Code
import numpy as np
# Gibbs sampler for a correlated 2-D Gaussian (sample each coord in turn)
rho = 0.8; x = y = 0.0; S = []
for _ in range(20000):
x = np.random.normal(rho*y, np.sqrt(1-rho**2))
y = np.random.normal(rho*x, np.sqrt(1-rho**2))
S.append((x, y))
S = np.array(S[2000:])
print("corr:", round(np.corrcoef(S.T)[0,1], 2)) # ~0.8In practice
Posterior predictive & uncertainty
The idea
Bayesian regression doesn’t fit one line — it keeps a whole distribution over lines consistent with the data. The posterior predictive averages over them, so its uncertainty band is tight where data is dense and fans out where you extrapolate. Add points and watch the plausible lines converge near the data and spread beyond it.
Picture it
In the companion lab you can add data points; the band of plausible lines tightens near data and widens where you extrapolate.
The math
Derivation
With a Gaussian prior and likelihood the weight posterior is Gaussian; each prediction integrates over it, adding the parameter uncertainty φᵀΣφ to the noise 1/β. Far from data φᵀΣφ grows — hence the widening fan.
Code
import numpy as np
# Posterior predictive: average predictions over the posterior, not a point
a_post, b_post = 10, 4 # Beta posterior on bias
draws = np.random.beta(a_post, b_post, 10000) # sample parameters
pred = np.random.binomial(1, draws) # then sample data
print("P(next flip = heads) =", round(pred.mean(), 3))In practice
Group fairness metrics
The idea
A classifier can look accurate overall yet treat groups differently. Fairness is measured by comparing rates across groups: demographic parity wants equal selection rates, equalized odds wants equal true- and false-positive rates. Move the threshold and watch the gaps between two groups (with different base rates) open and close.
Picture it
In the companion lab you can move the shared threshold; compare selection rate and TPR across groups A and B.
The math
Derivation
When base rates differ between groups, a single threshold gives different selection rates and error rates — so the criteria disagree. Each encodes a different value judgment about what “fair” means; there is no universally correct one.
Code
import numpy as np
# Group fairness metrics from predictions and a protected attribute
y = np.array([1,0,1,1,0,1,0,0.])
pred = np.array([1,0,1,0,0,1,1,0.])
grp = np.array([0,0,0,0,1,1,1,1]) # protected group
for g in (0,1):
m = grp==g
print(f"group {g}: selection rate = {pred[m].mean():.2f}") # demographic parityIn practice
The fairness impossibility
The idea
A sharp, sobering result: when two groups have different base rates, a classifier cannot be calibrated and have equal false-positive and false-negative rates at the same time — except in trivial cases. Improving one fairness metric necessarily worsens another. Widen the base-rate gap and watch the unavoidable trade-off grow.
Picture it
In the companion lab you can increase the difference in base rates between groups; the conflicting gaps cannot both be zero.
The math
Derivation
Calibration ties a score to a true probability; with unequal prevalences the same score implies different precision/error balances per group. Forcing equal error rates then breaks calibration — the constraints are jointly satisfiable only when the base rates match.
Code
import numpy as np
# Impossibility: you generally can't satisfy all fairness criteria at once
# unless base rates are equal. Show base rates differ -> tension.
base = {'A': 0.30, 'B': 0.60} # P(y=1) per group
# Equalizing FPR and calibration simultaneously is impossible if base differs
print("base rates differ ->", base['A'] != base['B'],
"=> calibration & equalized odds conflict")In practice
Where bias comes from
The idea
A fair-looking algorithm can still produce unfair outcomes if the data is biased. Under-sampling a group or systematically mislabeling it skews the learned boundary against that group — garbage in, discrimination out. Cycle through bias sources and watch the decision boundary tilt away from the truth.
Picture it
In the companion lab you can switch the bias source; the learned boundary (solid) drifts from the true one (dashed).
The math
Derivation
Under-representation lets the majority group dominate the loss, pulling the boundary toward majority-optimal. Label bias (historical prejudice in y) directly teaches the model the prejudice. Neither is fixed by a better optimizer.
Code
import numpy as np
# Sampling bias: training group proportions != population -> skewed model
pop = np.r_[np.zeros(900), np.ones(100)] # 10% group B in reality
sample = np.r_[np.zeros(500), np.ones(500)] # 50% in (biased) sample
print("pop rate:", pop.mean(), "sample rate:", sample.mean())
# a model trained on `sample` will misjudge the populationIn practice
Mitigation strategies
The idea
Bias can be addressed at three stages: pre-processing (reweight or repair the data), in-processing (add a fairness constraint to training), and post-processing (set group-specific thresholds). Post-processing is simplest: pick a separate cutoff per group so their true-positive rates match. Cycle the method and watch the gap close.
Picture it
In the companion lab you can apply a mitigation; group-specific thresholds equalize the true-positive rates.
The math
Derivation
Because each group has its own score distribution, one threshold yields unequal rates; allowing group-specific cutoffs restores equality on the chosen metric. The cost is usually a small accuracy drop and the legal/ethical question of explicit group-aware thresholds.
Code
import numpy as np
# Post-processing: pick per-group thresholds to equalize a chosen metric
scores = np.random.rand(1000); grp = np.random.randint(0,2,1000)
y = (scores + 0.2*grp + 0.2*np.random.randn(1000) > 0.6).astype(int)
thr = {g: np.quantile(scores[grp==g], 0.7) for g in (0,1)} # group-specific
pred = np.array([scores[i] > thr[grp[i]] for i in range(1000)])
print({g: round(pred[grp==g].mean(),2) for g in (0,1)})In practice
Kernel density estimation
The idea
To estimate a distribution without assuming its shape, drop a small bump (kernel) on each data point and add them up — that’s a kernel density estimate. The bandwidth sets the bump width: too narrow and you get spiky noise, too wide and you wash out real structure. Slide the bandwidth to find the sweet spot.
Picture it
In the companion lab you can adjust the bandwidth; watch the estimate swing between spiky (small h) and over-smoothed (large h).
The math
Derivation
Each point contributes a unit of probability mass spread by the kernel; summing gives a smooth estimate. Bandwidth is the bias–variance knob: small h → low bias, high variance (wiggly); large h → smooth but biased. Rules like Silverman’s pick a default.
Code
import numpy as np
# Kernel density estimate: sum of little Gaussians, one per data point
data = np.r_[np.random.randn(100), np.random.randn(50)+4]
def kde(x, data, h=0.4):
return np.mean(np.exp(-0.5*((x-data)/h)**2)/(h*np.sqrt(2*np.pi)))
grid = np.linspace(-4, 8, 100)
dens = np.array([kde(x, data) for x in grid])
print("peak near:", round(grid[dens.argmax()], 1))In practice
Density-based anomalies
The idea
An anomaly is a point in a sparse neighborhood — far from its neighbors. Scoring each point by its distance to its k-th nearest neighbor flags the loners. You set the contamination rate: what fraction of the data to treat as anomalous. Slide it and watch the flagged outliers grow inward from the fringe.
Picture it
In the companion lab you can set the contamination rate; the highest k-NN-distance points get flagged as anomalies.
The math
Derivation
In dense regions neighbors are close, so the k-NN distance is small; isolated points have large distances. Local Outlier Factor refines this by comparing a point’s density to its neighbors’, catching anomalies relative to varying local density.
Code
import numpy as np
# Robust z-score with the median absolute deviation (MAD)
x = np.r_[np.random.randn(100), [12, -9]] # two outliers
med = np.median(x); mad = np.median(np.abs(x-med))
z = 0.6745*(x-med)/mad # robust score
print("flagged:", np.where(np.abs(z) > 3.5)[0])In practice
One-class boundary
The idea
When you only have “normal” examples, learn a boundary that wraps around them; anything outside is anomalous. A one-class SVM (or density level-set) draws that envelope, and a parameter ν controls how tightly — effectively, what fraction of training points to leave outside. Tighten ν and watch the envelope shrink and reject more points.
Picture it
In the companion lab you can tighten ν; the boundary contracts and flags more fringe points as anomalies.
The math
Derivation
Choosing a density threshold so that a ν-fraction of points fall below it carves a level-set — the support of the normal data. With an RBF kernel the boundary hugs the data’s shape; ν trades coverage against tightness.
Code
import numpy as np
from sklearn.svm import OneClassSVM
# Learn a boundary around "normal" data; score new points as in/out
X = np.random.randn(200, 2)
oc = OneClassSVM(nu=0.05, gamma='scale').fit(X)
test = np.array([[0,0.],[6,6.]])
print(oc.predict(test)) # +1 = inlier, -1 = outlierIn practice
Isolation forest
The idea
A clever flip: instead of modeling normal data, isolate anomalies. Split the space with random cuts; outliers, sitting alone, get separated in just a few cuts, while inliers need many. The average number of cuts to isolate a point becomes its anomaly score. Regenerate the random trees and watch the loners light up.
Picture it
In the companion lab you can rebuild the random trees; points isolated in few splits (short path) score as anomalies.
The math
Derivation
Random partitioning separates sparse, far-flung points quickly (short paths) and dense-cluster points slowly (long paths). Averaging path length over many random trees gives a stable score — no distance or density model needed, and it scales to large data.
Code
import numpy as np
from sklearn.ensemble import IsolationForest
# Isolation Forest: anomalies are isolated in fewer random splits
X = np.r_[np.random.randn(200,2), [[8,8]]]
iso = IsolationForest(contamination=0.01).fit(X)
print("anomaly?", iso.predict([[8,8],[0,0]])) # -1 for the outlierIn practice
Online / streaming learning
The idea
When data arrives one example at a time — too much to store — you learn on the fly, updating the model per sample and discarding it. The perceptron is the classic: see a point, and only nudge the weights if you got it wrong. Stream points in and watch the boundary snap toward the right answer as mistakes accumulate then stop.
Picture it
In the companion lab you can stream points one batch at a time; the boundary updates only on misclassified points.
The math
Derivation
Each mistake rotates w toward the misclassified point’s correct side. For separable data the perceptron makes at most (R/γ)² mistakes (margin γ, radius R) — a bound independent of how many points stream by.
Code
import numpy as np
# Online gradient descent: update on one example at a time
w = np.zeros(3)
for _ in range(5000):
x = np.random.randn(3); y = (x @ [1.,-2,0.5] > 0)
p = 1/(1+np.exp(-(w@x)))
w -= 0.1 * (p - y) * x # single-sample SGD step
print(np.round(w, 2))In practice
Active learning
The idea
Labels are expensive; not every example is equally worth labeling. Active learning lets the model choose what to ask about — typically the points it’s most uncertain about, sitting right on the decision boundary. Those carry the most information, so accuracy climbs faster per label. Switch strategy and watch uncertainty beat random.
Picture it
In the companion lab you can set the label budget and compare uncertainty sampling vs random selection.
The math
Derivation
Points near the boundary are where the model is unsure, so their labels most reduce uncertainty about where the boundary lies. Random labeling wastes budget on already-confident points; uncertainty sampling concentrates it where it matters.
Code
import numpy as np
# Active learning: query the labels the model is most uncertain about
scores = np.random.rand(1000) # model P(y=1) on a pool
uncertainty = np.abs(scores - 0.5) # near 0.5 = uncertain
to_label = uncertainty.argsort()[:10] # cheapest informative 10
print("query indices:", to_label[:5])In practice
Concept drift
The idea
In the real world the thing you’re predicting changes — spam tactics evolve, tastes shift. A model trained once and frozen slowly goes stale as the true boundary moves out from under it. An adaptive model that keeps learning (sliding window or decay) tracks the change. Advance time and watch the static model decay while the adaptive one holds.
Picture it
In the companion lab you can advance time; the true boundary rotates. Compare the frozen model with one that keeps adapting.
The math
Derivation
A frozen decision rule is optimal only for the distribution it was trained on; as P_t drifts, its error grows. Re-estimating on recent data (or down-weighting old data) keeps the rule aligned with the current distribution.
Code
import numpy as np
# Detect distribution drift by watching a rolling error rate
err = np.r_[np.random.rand(200) < 0.1, # stable period
np.random.rand(200) < 0.4] # drift: errors jump
roll = np.convolve(err, np.ones(30)/30, 'valid')
alarm = np.argmax(roll > 0.25) # first crossing
print("drift detected near step", alarm)In practice
Regret & online optimization
The idea
How do you measure an online learner with no fixed dataset? By regret: your total loss minus that of the best single action you could have committed to in hindsight. A good algorithm is “no-regret” — its regret grows slower than time, so average regret vanishes. Tune the step size and watch the cumulative regret bend sublinearly.
Picture it
In the companion lab you can set the learning rate; cumulative regret should grow like √T (sublinear) — average regret → 0.
The math
Derivation
With convex losses and step size η ∝ 1/√T, the per-step suboptimality telescopes to a √T bound. “No-regret” means that in the long run you do as well as the best fixed decision — even against an adversarial sequence.
Code
import numpy as np
# Multi-armed bandit: epsilon-greedy, track regret vs the best arm
true = np.array([0.2, 0.5, 0.7]); Q = np.zeros(3); N = np.zeros(3)
regret = 0
for t in range(2000):
a = np.random.randint(3) if np.random.rand()<0.1 else Q.argmax()
r = np.random.rand() < true[a]
N[a]+=1; Q[a]+=(r-Q[a])/N[a]
regret += true.max() - true[a]
print("total regret:", round(regret, 1))In practice
Gaussian process priors
The idea
A Gaussian process is a distribution over functions: pick any set of inputs and the function values are jointly Gaussian, with covariance set by a kernel. Before seeing data, sampling the GP gives random smooth curves whose wiggliness the kernel’s length-scale controls. Resample and stretch the length-scale to feel the prior.
Picture it
In the companion lab you can set the length-scale and resample; small ℓ → wiggly draws, large ℓ → smooth, slow draws.
The math
Derivation
Build the covariance matrix K over a grid, Cholesky-factor it (K = LLᵀ), and f = L·z with z ~ N(0,I) is a sample. The kernel’s length-scale sets how fast correlation decays with distance — hence the smoothness.
Code
import numpy as np
# Sample functions from a GP prior (smooth random curves via the kernel)
X = np.linspace(0, 1, 100)
K = np.exp(-0.5*(X[:,None]-X[None])**2/0.05**2) + 1e-9*np.eye(100)
L = np.linalg.cholesky(K)
f = L @ np.random.randn(100) # one prior sample
print(f.shape) # draw several to see the prior over functionsIn practice
GP regression: the posterior
The idea
Condition the GP on observations and you get a posterior: a mean curve that passes near the data and an uncertainty band that pinches to zero at the points and balloons between and beyond them. That calibrated “I don’t know here” is the GP’s superpower. Add points and watch the band collapse around them.
Picture it
In the companion lab you can add observations; the posterior mean threads them and the 95% band tightens at each point.
The math
Derivation
Joint Gaussianity of training and test values means conditioning is a closed-form Gaussian. The variance drops to (near) zero where data is observed and recovers the prior variance far away — hence the fanning band.
Code
import numpy as np
# GP regression: condition the prior on observed points -> mean + variance
def k(a,b,l=0.2): return np.exp(-0.5*(a[:,None]-b[None])**2/l**2)
Xtr=np.array([0.1,0.5,0.9]); ytr=np.sin(6*Xtr); Xte=np.linspace(0,1,50)
Ktt=k(Xtr,Xtr)+1e-6*np.eye(3); Kst=k(Xte,Xtr)
mu = Kst @ np.linalg.solve(Ktt, ytr) # posterior mean
var = 1 - np.einsum('ij,jk,ik->i', Kst, np.linalg.inv(Ktt), Kst)
print(np.round(mu[:3], 2))In practice
Kernels encode assumptions
The idea
The kernel is where you tell the GP what kind of function to expect. An RBF kernel says “smooth”; a periodic kernel says “repeating”; a linear kernel says “straight lines.” Same machinery, totally different priors. Switch kernels and watch the sampled functions change character.
Picture it
In the companion lab you can switch the kernel; the prior samples take on its character (smooth / periodic / linear).
The math
Derivation
A valid kernel is any symmetric positive-semidefinite function; it defines the inner product (similarity) in feature space. Its shape dictates which functions get high prior probability — the inductive bias of the GP.
Code
import numpy as np
# The kernel encodes assumptions: length-scale controls wiggliness
def rbf(X, l): return np.exp(-0.5*(X[:,None]-X[None])**2/l**2)
X = np.linspace(0,1,5)
print("short l (wiggly):\n", np.round(rbf(X,0.05),2))
print("long l (smooth):\n", np.round(rbf(X,0.5),2))In practice
Bayesian optimization
The idea
To optimize an expensive black-box function with as few evaluations as possible, fit a GP surrogate and let an acquisition function decide where to sample next — balancing exploiting the current best against exploring uncertain regions. Step it and watch the GP home in on the optimum in just a handful of evaluations.
Picture it
In the companion lab you can each step evaluates the point the acquisition function (UCB) proposes; watch it converge to the peak.
The math
Derivation
μ(x) drives exploitation, σ(x) drives exploration; κ sets the balance. Sampling where a(x) is largest gathers the most useful information, so the optimum is found in far fewer evaluations than grid or random search.
Code
import numpy as np
# Bayesian optimization: pick the next point by an acquisition function
# (here: upper confidence bound = mean + kappa * std from a GP posterior)
mu = np.array([0.2, 0.5, 0.4, 0.55]) # GP posterior mean on candidates
std = np.array([0.3, 0.1, 0.05, 0.2]) # GP posterior std
ucb = mu + 2.0*std
print("evaluate candidate:", ucb.argmax()) # balances explore/exploitIn practice
Entropy & surprise
The idea
Entropy measures average surprise: a rare event carries −log p bits of information, and entropy is the expected value over the distribution. It peaks when outcomes are equally likely (maximum uncertainty) and vanishes when one outcome is certain. Slide a coin’s bias and watch its entropy rise to one bit at 50/50, then fall.
Picture it
In the companion lab you can change the probability of heads; entropy is maximal (1 bit) at p = 0.5 and zero at the extremes.
The math
Derivation
Information should add for independent events and grow as probability shrinks — forcing a logarithm. Averaging −log p over the distribution gives entropy, the minimum bits per symbol needed to encode it (Shannon).
Code
import numpy as np
# Entropy in bits: expected surprise of a distribution
def entropy(p):
p = p[p > 0]
return -(p * np.log2(p)).sum()
print(entropy(np.array([0.5, 0.5]))) # 1.0 bit (max for 2 outcomes)
print(entropy(np.array([0.9, 0.1]))) # 0.469 bits (more predictable)In practice
KL divergence
The idea
KL divergence measures how many extra bits you pay by coding data from p using a code optimized for q — the cost of believing the wrong distribution. It’s zero only when q = p, always non-negative, and asymmetric: KL(p∥q) ≠ KL(q∥p). Slide q toward p and watch both divergences fall to zero (at different rates).
Picture it
In the companion lab you can move q’s mean and width toward p; KL drops to 0 only when they match — note the asymmetry.
The math
Derivation
Coding p-data with a q-optimal code costs cross-entropy H(p,q) bits; the unavoidable minimum is H(p). The excess H(p,q) − H(p) = KL(p∥q) ≥ 0 by Gibbs’ inequality, with equality iff q = p.
Code
import numpy as np
# KL divergence: extra bits from using q to code data drawn from p
def kl(p, q):
p, q = np.asarray(p), np.asarray(q)
return np.sum(p * np.log2(p/q))
p = np.array([0.7, 0.3]); q = np.array([0.5, 0.5])
print(round(kl(p,q),3), round(kl(q,p),3)) # asymmetric!In practice
Mutual information
The idea
Mutual information is how much knowing one variable reduces uncertainty about another — the shared information between X and Y. It’s zero exactly when they’re independent and grows as they become more dependent, capturing nonlinear relationships that correlation can miss. Crank the dependence and watch the cloud tighten and MI rise.
Picture it
In the companion lab you can increase the dependence; the joint cloud tightens along a line and mutual information climbs from 0.
The math
Derivation
If X and Y are independent the joint equals the product of marginals, so the KL (and MI) is 0. Any dependence makes the joint differ, and MI measures it. For a bivariate Gaussian, I = −½ log(1 − ρ²).
Code
import numpy as np
# Mutual information from a joint distribution table
joint = np.array([[0.4, 0.1],
[0.1, 0.4]]) # correlated X,Y
px = joint.sum(1); py = joint.sum(0)
mi = sum(joint[i,j]*np.log2(joint[i,j]/(px[i]*py[j]))
for i in range(2) for j in range(2) if joint[i,j]>0)
print("I(X;Y) =", round(mi, 3), "bits")In practice
Coding, compression & MDL
The idea
Shannon’s source-coding theorem says entropy is the floor on bits per symbol: give frequent symbols short codes and rare ones long codes, and the best average length approaches the entropy. The same idea — the minimum description length — frames learning as compressing data with the model. Skew the frequencies and watch the optimal code lengths shift.
Picture it
In the companion lab you can skew the symbol frequencies; optimal code lengths track −log₂ p and the average approaches entropy.
The math
Derivation
Assigning −log₂ p(x) bits to each symbol makes the expected length equal to entropy; no prefix code can beat it (Kraft inequality). MDL turns this into model selection — the best model compresses the data most, balancing fit and complexity.
Code
import numpy as np
# Optimal code length per symbol is about -log2 p; average >= entropy
p = np.array([0.5, 0.25, 0.125, 0.125])
code_len = -np.log2(p) # ideal bits per symbol
avg = (p * code_len).sum()
print("lengths:", code_len, "avg = entropy =", avg)In practice
Grid vs random search
The idea
When only a few hyperparameters actually matter, random search beats grid search at the same budget: a grid wastes trials on a coarse lattice, repeating the same values of the important knob, while random sampling probes many distinct values of it. Slide the budget and watch random cover the important axis more densely.
Picture it
In the companion lab you can set the trial budget; compare how many distinct values of the important axis each strategy explores.
The math
Derivation
If performance depends mainly on one of D dimensions, a grid of n = kᴰ points still tries only k values of it, while random search tries ≈ n. With effective dimensionality low, random search’s coverage of the relevant axis is far higher per trial.
Code
import numpy as np
# Random search explores more distinct values of the important knob
def objective(lr, _unused): return -(np.log10(lr)+3)**2 # peak near lr=1e-3
best = max(((10**np.random.uniform(-5,0), np.random.rand()) for _ in range(40)),
key=lambda c: objective(*c))
print("best lr found:", f"{best[0]:.1e}")In practice
Successive halving & Hyperband
The idea
Most hyperparameter configs are bad — so don’t train them all to the end. Successive halving gives every config a little budget, keeps the top half, doubles their budget, and repeats. Promising configs get the compute; losers die early. Step the rounds and watch the field thin while survivors sharpen.
Picture it
In the companion lab you can advance rounds; each round keeps the top half and gives survivors more budget (cleaner estimates).
The math
Derivation
Spreading a fixed budget thinly first, then concentrating it on survivors, evaluates far more configs than full training would — at the cost of noisy early estimates. Hyperband hedges the unknown n-vs-b trade-off by running several brackets.
Code
import numpy as np
# Successive halving: many configs, little budget; keep the top half, repeat
configs = np.random.rand(16) # "true" quality of each config
budget = 1
while len(configs) > 1:
keep = np.argsort(configs)[::-1][:max(1,len(configs)//2)]
configs = configs[keep]; budget *= 2
print("survivor quality:", round(configs[0], 2), "final budget x", budget)In practice
Neural architecture search
The idea
Why hand-design architectures? NAS searches the space of architectures automatically — a controller proposes designs, each is scored, and the search climbs toward high-performing regions. Step the search and watch it explore a depth×width landscape and converge on a strong architecture.
Picture it
In the companion lab you can run the search; sampled architectures concentrate around the high-performance region.
The math
Derivation
The search space (operations, connections, depth/width) is huge and discrete, so NAS uses proxies — weight sharing, low-fidelity training, or gradient relaxation — to score candidates cheaply and steer sampling toward better designs.
Code
import numpy as np
# Toy architecture search: hill-climb over (depth, width)
score = lambda d,w: -((d-5)**2 + (w-8)**2) # unknown landscape
best = (np.random.randint(1,10), np.random.randint(1,16))
for _ in range(200):
cand = (np.clip(best[0]+np.random.randint(-1,2),1,12),
np.clip(best[1]+np.random.randint(-2,3),1,20))
if score(*cand) > score(*best): best = cand
print("best architecture:", best)In practice
Model selection & validation
The idea
Tuning is itself a fitting procedure — and you can overfit the validation set. As model complexity rises, training error keeps falling but validation error traces a U: too simple underfits, too complex overfits. Pick the bottom of the U, but report on a held-out test set, since the validation minimum is optimistic. Slide complexity to find it.
Picture it
In the companion lab you can change model complexity; find the validation minimum — note training error keeps dropping past it.
The math
Derivation
Validation error ≈ bias² + variance: bias falls and variance rises with complexity, producing the U. Selecting on validation and then reporting that same score is optimistically biased — nested CV or a final test set corrects it.
Code
import numpy as np
from sklearn.model_selection import validation_curve
from sklearn.tree import DecisionTreeClassifier
X = np.random.randn(300,5); y = (X[:,0]>0).astype(int)
tr, va = validation_curve(DecisionTreeClassifier(), X, y,
param_name='max_depth', param_range=range(1,12), cv=5)
print("best depth:", list(range(1,12))[va.mean(1).argmax()])In practice
Censoring & time-to-event
The idea
Survival analysis models the time until an event — failure, churn, death — and its defining challenge is censoring: for some subjects the study ends (or they drop out) before the event happens, so you only know their time exceeds some value. Throwing them away biases everything. Toggle censoring to see the information you’d lose.
Picture it
In the companion lab you can toggle censoring on; open ticks are subjects still event-free at last contact — partial, not missing, information.
The math
Derivation
Dropping censored subjects, or treating censoring as the event, both bias estimates. Survival methods use the partial information correctly: a censored subject contributes “survived at least this long” to the likelihood.
Code
import numpy as np
# Right-censored data: (time, event) where event=0 means "still alive at t"
time = np.array([3.2, 5.1, 2.4, 6.8, 4.0])
event = np.array([1, 0, 1, 1, 0 ]) # 0 = censored
print("observed events:", event.sum(), "censored:", (event==0).sum())
# censored rows mean "true time > observed" -- never discard themIn practice
Kaplan–Meier estimator
The idea
The Kaplan–Meier curve estimates the survival function — the probability of surviving past each time — directly from censored data. At every observed event it steps down by the fraction who failed among those still at risk; censored subjects simply leave the risk set without causing a drop. Slide the time cursor along the staircase.
Picture it
In the companion lab you can move the time cursor; read off the estimated probability of surviving beyond that time.
The math
Derivation
Surviving past t means surviving each event time up to t; multiplying the conditional survival (1 − d/n) at each gives the product-limit estimator. Censored subjects stay in nᵢ until their censoring time, then exit — using their partial info without biasing the curve.
Code
import numpy as np
# Kaplan-Meier estimator of the survival curve S(t)
time = np.array([1,1,2,3,4,4,5]); event = np.array([1,1,1,0,1,1,1])
order = np.argsort(time); t, e = time[order], event[order]
S, surv, n = 1.0, [], len(t)
for i, (ti, ei) in enumerate(zip(t, e)):
if ei == 1: S *= 1 - 1/(n-i) # drop at each event
surv.append((ti, round(S,3)))
print(surv)In practice
Hazard & survival functions
The idea
The hazard is the instantaneous risk of the event right now, given survival so far. It and the survival function are two views of the same distribution: integrate the hazard and exponentiate the negative to get survival. A rising hazard (wear-out) bends survival down faster over time. Shape the hazard and watch survival respond.
Picture it
In the companion lab you can change the hazard shape; a rising hazard (aging) makes the survival curve fall ever faster.
The math
Derivation
The cumulative hazard H(t) = ∫ h accumulates risk; survival is exp(−H). Constant hazard (k = 1) gives exponential survival (memoryless); k > 1 means risk grows with age, steepening the decline.
Code
import numpy as np
# Hazard -> survival: S(t) = exp(-cumulative hazard)
t = np.linspace(0, 5, 100)
hazard = 0.2 + 0.1*t # rising (wear-out) hazard
cum_haz = np.cumsum(hazard)*(t[1]-t[0])
S = np.exp(-cum_haz)
print("S(2.5) =", round(np.interp(2.5, t, S), 3))In practice
Cox proportional hazards
The idea
The Cox model relates covariates to survival without committing to a hazard shape: each covariate multiplies a shared baseline hazard by exp(βx). The key assumption is proportionality — the hazard ratio between groups is constant over time, so their survival curves never cross. Slide a covariate and watch the curve shift, keeping its shape.
Picture it
In the companion lab you can change the covariate; a higher value raises the hazard ratio and pulls survival down (proportionally).
The math
Derivation
Because the baseline h₀(t) factors out, β is estimated from the order of events (partial likelihood) without modeling h₀. The covariate just rescales the cumulative hazard, so survival curves are powers of the baseline — and stay ordered.
Code
import numpy as np
# Cox model: covariate scales a shared baseline hazard by exp(beta*x)
def survival(t, S0, beta, x): return S0(t) ** np.exp(beta*x)
S0 = lambda t: np.exp(-(t/3)**1.5) # baseline survival
beta = 0.6
for x in (-1, 0, 1):
print(f"x={x:+d}: S(2)={survival(2,S0,beta,x):.2f} HR={np.exp(beta*x):.2f}")In practice
Self-training & pseudo-labels
The idea
With a handful of labels and a flood of unlabeled data, self-training bootstraps: fit a model on the labels, let it label the unlabeled points it’s most confident about, add those pseudo-labels, and refit. Done carefully it sharpens the boundary using free data; done carelessly it amplifies its own mistakes. Step the rounds and watch confident points get adopted.
Picture it
In the companion lab you can run self-training rounds; the most confident unlabeled points become pseudo-labeled and refine the boundary.
The math
Derivation
Confident predictions on unlabeled data are likely correct, so adding them effectively grows the labeled set and pulls the boundary into low-density regions. The risk: confirmation bias — early errors get reinforced, so confidence thresholds and re-checking matter.
Code
import numpy as np
from sklearn.linear_model import LogisticRegression
# Self-training: label confident unlabeled points, add them, refit
Xl=np.array([[-2,0.],[2,0.]]); yl=np.array([0,1])
Xu=np.random.randn(200,2)+np.random.choice([-2,2],(200,1))
clf=LogisticRegression().fit(Xl,yl)
for _ in range(3):
p=clf.predict_proba(Xu).max(1); conf=p>0.95
Xl=np.vstack([Xl,Xu[conf]]); yl=np.r_[yl,clf.predict(Xu)[conf]]
Xu=Xu[~conf]; clf.fit(Xl,yl)
print("labeled set grew to", len(yl))In practice
Label propagation
The idea
If similar points should share labels, build a graph connecting nearby points and let the few known labels diffuse along the edges — like color spreading through a network. Labels flow easily within a dense cluster but barely cross the sparse gap between clusters. Step the propagation and watch the labels flood outward.
Picture it
In the companion lab you can step the diffusion; known labels propagate to neighbors through the similarity graph.
The math
Derivation
Each node’s label becomes a weighted average of its neighbors’, so labels diffuse fastest where edges are strong (dense regions) and stall across low-density gaps — encoding the cluster/manifold assumption directly in the graph.
Code
import numpy as np
# Label propagation: diffuse labels through a similarity graph
X = np.r_[np.random.randn(20,2)-2, np.random.randn(20,2)+2]
f = np.zeros(40); f[0], f[20] = -1, 1 # two seed labels
W = np.exp(-0.5*((X[:,None]-X[None])**2).sum(-1))
for _ in range(50):
f = W @ f / W.sum(1)
f[0], f[20] = -1, 1 # clamp seeds
print("cluster A mean label:", round(f[:20].mean(), 2))In practice
Consistency regularization
The idea
A model’s prediction shouldn’t flip just because you nudged the input — a slightly augmented image is still the same class. Consistency training enforces that on unlabeled data: penalize differences between predictions on two perturbed copies. This pushes the decision boundary out of dense regions into the gaps. Raise the perturbation and watch the boundary settle into the low-density valley.
Picture it
In the companion lab you can increase perturbation strength; the boundary is pushed away from dense data into the low-density gap.
The math
Derivation
Requiring invariance to perturbations forbids the boundary from cutting through clusters (where small nudges would flip labels), so it relaxes into low-density regions — the same low-density-separation principle, enforced softly.
Code
import numpy as np
# Consistency loss: prediction should be stable under input perturbation
def model(x, w): return 1/(1+np.exp(-(x@w)))
x = np.random.randn(5, 3); w = np.random.randn(3)
p1 = model(x, w)
p2 = model(x + 0.05*np.random.randn(*x.shape), w) # perturbed
print("consistency penalty:", round(np.mean((p1-p2)**2), 5))In practice
Low-density separation
The idea
The principle tying these methods together: a good boundary should pass through regions where data is sparse, not slice through a dense cluster. Unlabeled data reveals where those gaps are. Compare a purely supervised boundary (fit to two labels) against one that uses the unlabeled density to find the true valley.
Picture it
In the companion lab you can toggle the use of unlabeled data; the boundary moves to the genuine low-density gap between clusters.
The math
Derivation
Few labels under-determine the boundary; many placements fit them equally. The cluster assumption breaks the tie by favoring the placement that avoids dense regions — which unlabeled data lets you locate, even though it carries no labels itself.
Code
import numpy as np
# Low-density separation: put the boundary in the gap between clusters
data = np.r_[np.random.randn(100)-2, np.random.randn(100)+2]
grid = np.linspace(-4, 4, 200)
dens = np.array([np.exp(-0.5*((g-data)/0.4)**2).mean() for g in grid])
boundary = grid[dens.argmin()] # sparsest point
print("decision boundary near:", round(boundary, 2))