Home Chinland InfoTech Academy
Ready
All Courses

Math for Machine Learning — Handbook

A complete, derivation-first tour of the mathematics and statistics behind machine learning: probability, linear algebra, optimization, inference, and the algorithms built on them — each idea explained plainly, derived, illustrated, and coded.
95 topics · 23 chapters · companion to the Math for ML Concept Lab
Each topic is laid out the same way: The idea (the plain-language concept), Picture it (what the interactive in the lab shows), The math (the key equations), Derivation (where they come from), Code (a short runnable illustration), and In practice (how it is used). Use the search box on the left to jump to any topic.
Probability foundations
01

The Gaussian

mean and variance

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

p(x) = (1/σ√2π)·exp(−(x−μ)² / 2σ²). μ = E[X] (centre), σ² = Var[X] (spread). Area = 1.

Derivation

Standardize z = (x−μ)/σ ⇒ any normal becomes the standard N(0,1). The 1/σ√2π factor is exactly what makes ∫ p dx = 1.

Code

python
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.5

In practice

The default noise/error model: least squares is its MLE. It sets weight-initialization scales, appears in Gaussian mixtures, Bayesian priors, and the reparameterization of VAEs. ±2σ ≈ 95% gives confidence intervals.
02

Bayes’ theorem

flip the conditional

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

P(D|+) = P(+|D)P(D) / P(+), P(+) = P(+|D)P(D) + P(+|¬D)P(¬D) = sens·prev + (1−spec)(1−prev).

Derivation

Posterior = (likelihood × prior) / evidence. The evidence sums both ways to be positive; rare priors make the (1−spec)(1−prev) term dominate.

Code

python
# 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" test

In practice

Naive Bayes classifiers, posterior inference, and calibration all rest on this. With imbalanced classes the base rate dominates — high accuracy can hide a useless positive predictive value.
03

Expectation & variance

mean and spread of a distribution

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

E[X] = Σ x·p(x) (the mean). Var[X] = E[(X−μ)²] = E[X²] − E[X]². SD = √Var.

Derivation

Expand E[(X−μ)²] = E[X²] − 2μE[X] + μ² = E[X²] − μ² — the handy "mean of square minus square of mean".

Code

python
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

Training minimizes expected loss (risk). The bias–variance decomposition is built from these moments, and averaging gradients over a minibatch is an expectation estimate.
04

Central limit theorem

why averages go normal

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

For iid Xᵢ with mean μ, variance σ², the sample mean X̄ₙ satisfies X̄ₙ → N(μ, σ²/n). Standard error = σ/√n.

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

python
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

A minibatch gradient is a sample mean: its noise shrinks like 1/√n as batch size grows. Also underlies bootstrap confidence intervals and why ensembling averages away variance.
05

Conditional probability

narrowing the sample space

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

P(A|B) = P(A∩B) / P(B). Chain rule: P(A∩B) = P(A|B)P(B). Independent ⇔ P(A|B) = P(A).

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

python
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

Probabilistic graphical models encode conditional independence; naive Bayes assumes features independent given the class; autoregressive models factor P(x) = ∏ P(xₜ | x_{
Linear algebra essentials
06

Vectors, span & linear combinations

linear combinations and what they reach

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

w = a·v₁ + b·v₂ span{v₁,v₂} = { a·v₁ + b·v₂ : a,b ∈ ℝ } Independent ⇔ det[v₁ v₂] ≠ 0 ⇔ span is all of ℝ².

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

python
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 linear layer outputs Wx — each output is a linear combination of input features. The reachable outputs form a span; independent features give a full-rank design matrix, so the normal equations have a unique solution.
07

A matrix is a transformation

Ax warps the whole space

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

Ax = [a b; c d][x₁; x₂] = x₁·col₁ + x₂·col₂ col₁ = A·e₁ = (a,c), col₂ = A·e₂ = (b,d). det A = ad − bc = area-scaling factor.

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

python
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 factor

In practice

A dense layer is x ↦ Wx + b: W is a learned transformation of feature space, stacked layers compose them. Singular values of W say how much it stretches each direction (Jacobians, spectral norm, normalizing flows).
08

Eigenvectors & eigenvalues

directions a matrix only scales

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

A v = λ v. Nontrivial v exists ⇔ det(A − λI) = 0 ⇒ λ² − tr(A)λ + det(A) = 0. Symmetric A ⇒ real λ, orthogonal eigenvectors.

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

python
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 1

In practice

PCA uses eigenvectors of the covariance matrix — directions of maximum variance, with λ = variance captured. Eigenvalues of the loss Hessian are curvatures; their ratio is the condition number that governs gradient-descent speed. Also spectral clustering and PageRank.
09

Quadratic forms & definiteness

curvature in every direction

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

f(x) = xᵀA x = a·x₁² + 2b·x₁x₂ + d·x₂². In eigen-coordinates: f = λ₁y₁² + λ₂y₂². Positive-definite ⇔ λ₁,λ₂ > 0 ⇔ f > 0 for all x ≠ 0.

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

python
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

The loss Hessian is a quadratic form: positive-definite ⇒ a convex bowl with a unique minimum; eigenvalue ratio = condition number (ill-conditioned → slow zig-zagging GD). Covariance matrices are PSD; Mahalanobis distance is xᵀΣ⁻¹x.
10

Projection & least squares

the closest point, and a perpendicular residual

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

Project b onto span(a): t* = aᵀb / aᵀa, p = t*·a. Residual r = b − p satisfies aᵀr = 0 (⊥).

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

python
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 X

In practice

Ordinary least squares projects y onto the column space of X: the normal equations XᵀXβ = Xᵀy give β = (XᵀX)⁻¹Xᵀy, and the residual is orthogonal to every feature. This is linear regression, PCA reconstruction, and Gram–Schmidt.
Optimization
11

Convexity

why some losses are easy

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

Convex: f(λa+(1−λ)b) ≤ λf(a)+(1−λ)f(b), ∀λ∈[0,1]. Twice-differentiable: convex ⇔ f″(x) ≥ 0 everywhere.

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

python
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

Linear/ridge regression, logistic regression, and SVMs have convex losses → a unique optimum, solvable reliably. Deep nets are non-convex — many minima and saddles — but over-parameterization and SGD still find good ones.
12

Gradient descent & conditioning

why elongated bowls are slow

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

f(x,y)=½(a·x² + c·y²), ∇f=(a·x, c·y). Update: (x,y) ← (x,y) − η·∇f. Condition number κ = max(a,c)/min(a,c).

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

python
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-zags

In practice

Un-scaled features give an ill-conditioned loss — the reason we standardize inputs and use batch/layer norm. Second-order methods (Newton) and Adam rescale by curvature to fight exactly this.
13

L1 vs L2 regularization

shrinkage vs sparsity

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

min loss(w) + λ‖w‖ ⇔ min loss(w) s.t. ‖w‖ ≤ t. L2: ‖w‖₂ ≤ t (a disc) · L1: ‖w‖₁ ≤ t (a diamond).

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

python
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 weights

In practice

L2 = ridge / weight decay (smooth shrinkage, the default in deep nets). L1 = lasso (feature selection via sparsity). L2 corresponds to a Gaussian prior on weights, L1 to a Laplace prior (MAP estimation).
14

Learning rate & convergence

glide, oscillate, or diverge

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

f(x)=½k·x², gradient k·x. Update: x ← x − η·k·x = (1 − ηk)·x.

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

python
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 explodes

In practice

The safe ceiling is 2/(largest curvature) = 2/λ_max of the Hessian. Warmup, decay schedules, and adaptive optimizers all manage η against changing curvature during training.
Statistical inference
15

Estimators & the sampling distribution

a guess that is itself a random variable

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

μ̂ = X̄ = (1/n)Σ Xᵢ. E[X̄] = μ (unbiased), Var[X̄] = σ²/n. Standard error SE = σ/√n.

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

python
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

Every metric you report (validation accuracy, mean loss) is an estimate with a sampling distribution — hence error bars. The same logic gives the variance of a minibatch gradient and underlies the bias–variance view of models.
16

The bias–variance tradeoff

underfit, just right, overfit

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

E[(y − f̂(x))²] = Bias[f̂]² + Var[f̂] + σ². Bias ↓ and Variance ↑ as flexibility grows; σ² is irreducible.

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

python
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

The master curve behind model selection, regularization, early stopping, and ensembling (bagging cuts variance, boosting cuts bias). Modern over-parameterized nets revisit this with the “double descent” phenomenon.
17

Confidence intervals

what “95% confident” really means

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

CI = X̄ ± z·σ/√n. z = 1.645 (90%), 1.96 (95%), 2.576 (99%).

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

python
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 truth

In practice

Report metrics with CIs, not point numbers, before claiming model A beats B. Bootstrapping builds CIs without distributional assumptions; A/B tests and eval harnesses live on this idea.
18

Hypothesis testing & p-values

how surprised should the null be

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

z = X̄ / (σ/√n). p (two-sided) = 2·(1 − Φ(|z|)). Reject H₀ if p < α, i.e. |z| > z_{α/2}.

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

python
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

A/B tests and “is model A really better?” comparisons. Bigger n shrinks SE and raises power — but with enough data even trivial effects become “significant,” so report effect sizes, and beware multiple-comparison inflation.
19

MLE vs MAP & the prior

data versus belief, reconciled

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

MLE: θ̂ = argmax p(D|θ) = X̄. MAP: θ̂ = argmax p(D|θ)p(θ). Gaussian prior N(θ₀,τ²): θ_MAP = (nσ⁻²·X̄ + τ⁻²·θ₀)/(nσ⁻² + τ⁻²).

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

python
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

Regularization = a prior. L2 ⇔ Gaussian prior, L1 ⇔ Laplace prior. As data grows the prior’s pull fades (consistency). Bayesian deep learning keeps the whole posterior instead of just its peak.
Classic algorithms
20

Linear regression

the closed-form best line

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

ŷ = wx + b; minimize J = Σ(yᵢ − wxᵢ − b)². w = Cov(x,y)/Var(x), b = ȳ − w·x̄.

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

python
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

The canonical supervised baseline and the template for GLMs. Ridge adds λ‖β‖² (a Gaussian prior) for stability when XᵀX is near-singular; the same projection idea powers feature regression everywhere.
21

Logistic regression

learning a linear boundary

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

p = σ(w·x + b), loss = −Σ[y log p + (1−y)log(1−p)]. ∇_w = Σ(pᵢ − yᵢ)xᵢ, ∇_b = Σ(pᵢ − yᵢ).

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

python
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

The default linear classifier and the output layer of most neural nets. Softmax regression generalizes it to many classes; the (p−y) gradient is exactly what backprop feeds into the rest of the network.
22

k-means clustering

assign, recentre, repeat

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

Minimize J = Σᵢ ‖xᵢ − μ_{c(i)}‖². Assign: c(i) = argmin_c ‖xᵢ − μ_c‖. Update: μ_c = mean of its points.

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

python
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

Fast clustering, vector quantization, and image color compression. It is hard EM for a Gaussian mixture with equal spherical covariances; k-means++ seeding avoids bad local minima.
23

Principal component analysis

the directions of greatest variance

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

Covariance Σ = (1/n) XᵀX (centered). Σ = VΛVᵀ; columns of V = principal components, Λ = variances. Project: Z = XV (keep top components).

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

python
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 component

In practice

Dimensionality reduction, denoising, whitening, and visualization (project to 2-D). Closely tied to the SVD of the data matrix; truncating components gives the best low-rank approximation (Eckart–Young).
Kernels & SVM
24

Maximum-margin classifier

the widest street between classes

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

Boundary: w·x + b = 0. Distance of x = |w·x+b|/‖w‖. Margin = 2/‖w‖. Maximize it ⇔ minimize ‖w‖ s.t. yᵢ(w·xᵢ+b) ≥ 1.

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

python
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 distances

In practice

Only the support vectors matter, so SVMs are memory-efficient and robust. The large margin is a capacity-control prior that often generalizes well; the dual form opens the door to kernels.
25

Hinge loss & soft margin

paying for margin violations

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

Hinge: L = max(0, 1 − m). Soft-margin SVM: min ½‖w‖² + C·Σ max(0, 1 − yᵢ f(xᵢ)).

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

python
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

C trades margin width against violations (small C = wider, more tolerant). Hinge gives sparse support vectors (only margin violators contribute); cross-entropy keeps all points active — the two main linear-classifier losses.
26

The kernel trick

linear in disguise

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

Lift: φ(x) = (x₁, x₂, ‖x‖²). Linear rule w·φ(x)+b > 0 with w=(0,0,1) becomes ‖x‖² > t² — a circle. Kernel: K(x,x′) = φ(x)·φ(x′).

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

python
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)^2

In practice

Kernel SVMs, kernel ridge regression, and Gaussian processes all exploit this. The representer theorem says the solution is a weighted sum of kernels on the training points.
27

The RBF kernel

similarity as a Gaussian bump

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

K(x, x′) = exp(−γ‖x − x′‖²), γ = 1/(2σ²). f(x) = Σᵢ αᵢ yᵢ K(x, xᵢ) + b; predict sign(f).

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

python
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

The default SVM kernel and the heart of Gaussian processes and kernel regression. γ (and C) set the bias–variance tradeoff; tuning them is the main RBF-SVM hyperparameter search.
Trees & ensembles
28

Splitting: entropy & Gini

choosing the purest cut

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

Entropy H = −Σ pₖ log₂ pₖ. Gini = 1 − Σ pₖ². Gain = H(parent) − Σ (n_child/n)·H(child).

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

python
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.5

In practice

CART uses Gini, ID3/C4.5 use entropy/gain ratio — they pick nearly the same splits. Recursively repeating this builds the tree; stopping rules (depth, min-samples) control overfitting.
29

A tree partitions space

recursive axis-aligned boxes

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

Each node: split (feature, threshold) minimizing child impurity. Recurse until max depth / pure. Leaf predicts the majority class.

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

python
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 knob

In practice

Single trees are interpretable but unstable and overfit-prone — motivating ensembles (forests, boosting). Depth, min-samples-leaf, and pruning are the main regularizers.
30

Bagging & random forests

averaging away the wobble

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

Bagged prediction = (1/B)Σ_b T_b(x), each T_b trained on a bootstrap sample. Var of average ≈ ρσ² + (1−ρ)σ²/B.

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

python
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 tree

In practice

Random forests: a robust, low-tuning default for tabular data. Bagging cuts variance, not bias, so the base trees are grown deep (low bias) and de-correlated by bootstrap + feature subsampling.
31

Boosting

fit the residual, repeat

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

F₀ = mean(y). For m = 1..M: rᵢ = yᵢ − F_{m−1}(xᵢ) (residual = neg. gradient of MSE) F_m = F_{m−1} + ν·h_m, h_m fits r.

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

python
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

XGBoost / LightGBM / CatBoost dominate tabular competitions. Boosting reduces bias (opposite of bagging’s variance focus) but can overfit — controlled by ν, tree depth, and number of rounds (early stopping).
Probabilistic models
32

Naïve Bayes

Bayes with an independence shortcut

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

P(y|x) ∝ P(y)·∏ᵢ P(xᵢ|y). Gaussian features: log P(y|x) = log P(y) − ½Σᵢ [(xᵢ−μ_{y,i})²/σ²_{y,i} + log σ²_{y,i}] + c.

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

python
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

A fast, strong baseline for text (bag-of-words → multinomial NB) and spam filtering. The independence assumption is usually false yet often harmless for the argmax; needs little data and trains instantly.
33

Gaussian mixtures & EM

soft clustering by 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

p(x) = Σₖ πₖ·N(x | μₖ, Σₖ). E: γ_{ik} = πₖNₖ(xᵢ) / Σⱼ πⱼNⱼ(xᵢ). M: μₖ = Σᵢγxᵢ / Σᵢγ, similarly Σₖ, πₖ.

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

python
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

Density estimation and soft clustering; k-means is the hard-assignment, equal-spherical-covariance limit. The same EM machinery handles missing data and latent-variable models broadly.
34

Markov chains

memoryless hops to a steady state

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

v_{t+1} = v_t·P, Σⱼ P_{ij} = 1. Stationary π solves π = πP (left eigenvector, eigenvalue 1).

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

python
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

n-gram language models, PageRank (the web’s stationary distribution), MCMC samplers, and the transition model in MDPs / reinforcement learning. Mixing time governs how fast samples become useful.
35

Conjugate Bayesian updating

belief in, evidence, belief out

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

Prior θ ~ Beta(α, β). Data: H heads, T tails. Posterior θ | data ~ Beta(α+H, β+T). mean = (α+H)/(α+β+H+T).

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

python
# 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 update

In practice

Online/streaming Bayesian inference, Thompson sampling for bandits, Bayesian A/B testing, and Dirichlet–multinomial topic models. The posterior is a full distribution, not just a point estimate — uncertainty included.
Dimensionality reduction
36

SVD & low-rank approximation

the best rank-k summary

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

A = UΣVᵀ = Σᵢ σᵢ uᵢ vᵢᵀ. Rank-k: Aₖ = Σ_{i≤k} σᵢ uᵢ vᵢᵀ (top k terms).

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

python
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

PCA is the SVD of centered data; latent semantic analysis, recommender systems (low-rank matrix completion), embedding compression, and spectral methods all lean on it. Energy = Σσᵢ² captured.
37

Whitening

center, rotate, rescale

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

Center: x̃ = x − μ. Cov Σ = VΛVᵀ. Whiten: z = Λ^{−1/2} Vᵀ x̃ ⇒ Cov(z) = I.

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

python
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))                  # ~identity

In practice

Whitening / standardization speeds up optimization (rounds the loss bowl), and PCA-whitening feeds ZCA, decorrelated features, and classical preprocessing. BatchNorm is a learned, per-batch cousin.
38

Neighbor embeddings (t-SNE / UMAP)

keep neighbors, not distances

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

High-D: p_{j|i} ∝ exp(−‖xᵢ−xⱼ‖² / 2σ²). Low-D (t-SNE): q_{ij} ∝ (1 + ‖yᵢ−yⱼ‖²)⁻¹ (Student-t). Minimize KL(p ∥ q).

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

python
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 clusters

In practice

The go-to tools for visualizing high-dim embeddings (word/image vectors, single-cell data). Caveat: cluster sizes and between-cluster distances aren’t meaningful — only local grouping is.
39

Random projection (Johnson–Lindenstrauss)

distances survive a random squeeze

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

z = (1/√k)·R·x, R_{ij} ~ N(0,1), R ∈ ℝ^{k×D}. (1−ε)‖x−x′‖ ≤ ‖z−z′‖ ≤ (1+ε)‖x−x′‖.

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

python
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

Fast dimensionality reduction for huge sparse data, approximate nearest neighbors (LSH), compressed sensing, and feature hashing. Cheaper than PCA when you only need distances, not the principal axes.
Model evaluation
40

Confusion matrix & metrics

one threshold, four outcomes

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

Precision = TP/(TP+FP) Recall = TP/(TP+FN) F1 = 2·P·R/(P+R) Accuracy = (TP+TN)/all.

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

python
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

The starting point for any classifier report. Pick the threshold for your cost trade-off (medical screening favors recall; spam favors precision); F1 / Fβ weight them, and the matrix generalizes to multi-class.
41

ROC & AUC

ranking quality at every threshold

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

TPR = TP/(TP+FN), FPR = FP/(FP+TN). AUC = ∫ TPR d(FPR) = P(score₊ > score₋).

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

python
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 quality

In practice

AUC compares rankers independent of any cutoff; 0.5 is random, 1.0 perfect. Under heavy class imbalance, precision–recall curves are more informative than ROC, since FPR hides a flood of true negatives.
42

Cross-validation

every point tested once

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

CV score = (1/k)Σ score(fold_i). Each fold: train on k−1 parts, test on the held-out part.

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

python
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

The standard for honest model selection and hyperparameter tuning. Stratify folds for imbalance; use grouped/time-series splits to avoid leakage. LOOCV (k = n) is nearly unbiased but high-variance and expensive.
43

Calibration & reliability

do the probabilities mean what they say

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

Calibrated if P(y=1 | p̂ = p) = p for all p. Temperature scaling: p̂_T = σ(logit / T). ECE = Σ_b (n_b/n)·|conf_b − acc_b|.

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

python
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

Calibration matters wherever probabilities drive decisions (risk, cost-sensitive choices, downstream Bayes). Temperature scaling, Platt scaling, and isotonic regression are the standard fixes; ECE is the headline metric.
Recommender systems
44

Collaborative filtering

people who liked X also liked…

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

sim(i,j) = cos(col_i, col_j) over shared users. r̂(u,i) = Σ_{j∈N} sim(i,j)·r(u,j) / Σ_{j∈N} |sim(i,j)|.

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

python
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

The classic Amazon/Netflix baseline. Strengths: no features required. Weaknesses: sparsity, popularity bias, and cold start (no ratings → no neighbors) — which matrix factorization and hybrids address.
45

Matrix factorization

users and items in a shared latent space

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

R ≈ P Qᵀ, r̂(u,i) = p_u · q_i. min Σ_{obs} (r − p_u·q_i)² + λ(‖p_u‖² + ‖q_i‖²).

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

python
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 blanks

In practice

The heart of the Netflix-Prize approach (Funk SVD). Add bias terms and you get the standard model; ALS parallelizes it; regularization λ is essential on sparse data. Generalizes to embeddings everywhere.
46

Implicit feedback & ranking

learn an order, not a score

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

BPR: maximize Σ ln σ(x̂_{ui} − x̂_{uj}) for observed i, unobserved j. Pairwise: rank a clicked item above a non-clicked one.

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

python
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 data

In practice

Implicit feedback dominates production recsys (YouTube, Spotify). Treat unobserved as weak negatives (with confidence weights, as in ALS-implicit), and evaluate with Recall@k / NDCG / MAP rather than rating error.
47

The cold-start problem

recommending with no history

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

No interactions → q_new undefined from CF alone. Hybrid: q_new = f(content features), or back off to a popularity prior.

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

python
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

Hybrid recommenders, content-based fallbacks, and popularity baselines handle cold start; two-tower models learn item embeddings directly from features. Exploration (bandits) gathers the first interactions efficiently.
Feature engineering
48

Standardization & scaling

put every feature on equal footing

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

z-score: x′ = (x − μ)/σ. min-max: x′ = (x − min)/(max − min). After z-scoring: each feature has mean 0, variance 1.

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

python
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

Essential for k-NN, k-means, SVMs, PCA, and neural nets; fit the scaler on train only (avoid leakage). Tree models are scale-invariant and don’t need it. Standardize vs min-max depends on outliers and bounded ranges.
49

Categorical encoding

numbers without a fake order

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

Label: catₖ → k (one number, ordered). One-hot: catₖ → eₖ (unit basis vector). All pairwise distances = √2.

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

python
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 column

In practice

One-hot is standard for linear/NN models (watch the dummy-variable trap and high cardinality). Ordinal encoding is fine when order is real; target/frequency encoding and learned embeddings handle many categories compactly.
50

Polynomial & interaction features

bend a linear model by expanding the basis

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

φ(x) = [1, x, x², …, xᵈ]; model = wᵀφ(x). Interactions (2-D): add x₁x₂, x₁², x₂², …

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

python
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^3

In practice

Cheap nonlinearity for linear/logistic models; interaction terms encode “A matters only when B.” Degree is a capacity knob — pair it with regularization, since high degrees overfit and explode at the edges.
51

Binning & discretization

continuous → buckets

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

Edges split the range; bin(x) = which interval x falls in. Prediction per bin = mean of y for points in that bin.

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

python
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

Robust to outliers and monotone transforms, and it linearizes nonlinear effects for GLMs (credit scorecards love it). Trade-off: information loss and edge artifacts. Gradient-boosted trees bin internally for speed (histogram methods).
Time series
52

Stationarity & autocorrelation

how a series echoes its own past

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

ACF(k) = Σ_t (x_t − x̄)(x_{t−k} − x̄) / Σ_t (x_t − x̄)². Stationary: mean, variance, ACF constant over time.

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

python
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

Check stationarity before ARIMA-type models; difference or detrend to achieve it. The ACF/PACF shapes diagnose model order; spurious regression between two trending series is a classic non-stationarity trap.
53

Autoregressive (AR) models

the past predicts the next step

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

AR(p): x_t = φ₁x_{t−1} + … + φ_p x_{t−p} + ε_t. Stationary if the roots of 1 − φ₁z − φ₂z² lie outside the unit circle.

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

python
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

The “AR” in ARIMA; fit by least squares or Yule–Walker. PACF cuts off at lag p, revealing the order. The same autoregressive principle underlies sequence models that predict the next token from previous ones.
54

Trend + seasonality decomposition

pull a series into its parts

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

Additive: x_t = T_t + S_t + R_t. T_t = moving average; S_t = mean of (x−T) by phase; R_t = remainder.

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

python
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

STL/seasonal decomposition for EDA, deseasonalizing before modeling, and anomaly detection on the residual. Multiplicative variants suit series whose seasonal swing scales with level (e.g., sales).
55

Forecasting & uncertainty

project forward, widen the cone

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

Level: ℓ_t = α·x_t + (1−α)·ℓ_{t−1}. Forecast x̂_{t+h} = ℓ_t; interval ∝ σ·√h.

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

python
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

Exponential smoothing / Holt–Winters and ARIMA are strong, cheap baselines that often rival fancy models. Always report intervals, validate with rolling/backtesting splits, and beware leakage from using the future.
Causal inference
56

Confounding & Simpson’s paradox

the trend that reverses when you look closer

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

Pooled slope mixes within- and between-group variation. Adjusted: estimate the X→Y slope within each level of the confounder Z.

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

python
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

Why “control for covariates” matters; naive correlations in observational data mislead (hospital data, A/B analysis with mix shifts). The right adjustment set comes from causal reasoning, not just throwing in every variable.
57

Randomization (RCTs)

break the link to confounders

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

ATE = E[Y | do(T=1)] − E[Y | do(T=0)]. Randomize ⇒ T ⊥ confounders ⇒ ATE = E[Y|T=1] − E[Y|T=0].

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

python
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 randomization

In practice

A/B tests are RCTs — the gold standard for causal product decisions. When randomization is impossible, lean on matching, propensity weighting, or natural experiments — all trying to mimic balance.
58

Confounding & the backdoor

block the backdoor, read the effect

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

Backdoor adjustment: P(Y | do(X)) = Σ_z P(Y | X, Z=z)·P(Z=z). Valid if Z blocks all backdoor paths and isn’t a descendant of X.

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

python
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

The backdoor criterion tells you exactly which variables to control for — and warns against adjusting for colliders or mediators, which can create bias. Foundational for causal ML, uplift modeling, and policy evaluation.
59

Instrumental variables

leverage from an outside nudge

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

β_IV = Cov(Z, Y) / Cov(Z, X). Assumes Z→X (relevance) and Z affects Y only via X (exclusion).

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

python
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 OLS

In practice

Used where randomization fails: economics (price/demand), Mendelian randomization (genes as instruments), encouragement designs. Beware weak instruments (tiny Cov(Z,X) → exploding variance) and exclusion violations.
Bayesian methods & MCMC
60

Bayesian inference & the posterior

prior × likelihood → 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

p(μ | x) ∝ p(x | μ)·p(μ). Conjugate normal: posterior precision = 1/τ² + n/σ²; mean = weighted avg of prior & data.

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

python
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

The engine of Bayesian modeling: uncertainty-aware estimates, sequential updating, and regularization (MAP = posterior mode). Conjugate priors give closed forms; otherwise we sample (next cards) or use variational approximations.
61

MCMC: Metropolis sampling

a random walk that mimics a distribution

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

Propose x′ = x + ε. Accept with prob min(1, p(x′)/p(x)). Stationary distribution of the chain = target p.

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

python
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

The workhorse for Bayesian posteriors without conjugacy. Variants (Hamiltonian Monte Carlo, NUTS in Stan/PyMC) use gradients to move farther per step. Watch for burn-in, autocorrelation, and multimodality (chains can get stuck).
62

Gibbs sampling

sample one coordinate at a time

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

Draw x | y ~ p(x | y), then y | x ~ p(y | x), repeat. For bivariate normal: x | y ~ N(ρy, 1−ρ²).

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

python
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.8

In practice

Natural when conditionals are easy but the joint isn’t — LDA topic models, hierarchical Bayesian models, Boltzmann machines. High correlation slows mixing (narrow staircase); blocked or collapsed Gibbs helps.
63

Posterior predictive & uncertainty

a distribution over fits, not one fit

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

Posterior over weights: Σ = (αI + βΦᵀΦ)⁻¹, m = βΣΦᵀy. Predictive variance = 1/β + φ(x)ᵀ Σ φ(x).

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

python
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

Honest uncertainty for decision-making and active learning; Gaussian processes are the kernelized limit. Deep ensembles and MC-dropout approximate this predictive spread for neural nets.
Fairness & bias
64

Group fairness metrics

equal how, exactly?

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

Demographic parity: P(ŷ=1|A) = P(ŷ=1|B). Equalized odds: equal TPR and FPR across groups. Predictive parity: equal precision.

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

python
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 parity

In practice

Choose the criterion from context (lending, hiring, risk). Report multiple metrics and disaggregate by group; aggregate accuracy hides disparities. The next cards show why you can’t satisfy all criteria at once.
65

The fairness impossibility

you can’t have it all

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

If base rates p_A ≠ p_B, you cannot have calibration AND equal FPR AND equal FNR simultaneously (Kleinberg et al.; Chouldechova).

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

python
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

This forced the field to pick which fairness notion matters per application, and to be explicit about the cost. It also explains heated debates (e.g., COMPAS): two sides each invoked a different, individually reasonable, criterion.
66

Where bias comes from

biased data → biased model

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

The model fits P(y|x) in the training distribution — if that distribution is skewed (sampling / label bias), so is the boundary, even with a “neutral” learner.

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

python
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 population

In practice

Bias enters via historical labels, sampling, measurement, and proxy features (a redundant encoding of a protected attribute). Auditing data and provenance matters as much as the model; “fairness through unawareness” (just dropping the attribute) usually fails.
67

Mitigation strategies

pre-, in-, and post-processing

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

Post-processing: choose t_A, t_B so TPR_A(t_A) = TPR_B(t_B). In-processing: minimize loss s.t. fairness constraint. Pre: reweight samples.

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

python
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

Tools: reweighing, adversarial debiasing, constrained optimization (Fairlearn), and threshold optimization. Pick the stage by access and constraints; always re-measure — mitigation on one metric can worsen another (per the impossibility result).
Anomaly & density estimation
68

Kernel density estimation

a smooth density from raw points

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

p̂(x) = (1/nh) Σᵢ K((x − xᵢ)/h). K = Gaussian kernel; h = bandwidth.

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

python
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

Nonparametric density for visualization, generative sampling, and anomaly scoring (low density = unusual). Curse of dimensionality limits it in high dimensions; it’s the kernel cousin of histograms and the basis of mean-shift.
69

Density-based anomalies

unusual = far from the crowd

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

score(x) = distance to k-th nearest neighbor (or 1/local density). Flag the top “contamination” fraction by score.

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

python
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

Fraud, intrusion, defect, and health monitoring. Unsupervised, so set the threshold from a contamination prior or a validation set. Distance methods struggle in high dimensions (everything is far) — reduce dimensionality first.
70

One-class boundary

wrap a fence around the normal data

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

One-class SVM: separate data from the origin in feature space with max margin; ν bounds the outlier fraction. Equivalent to a density level-set: keep p(x) > threshold.

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

python
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 = outlier

In practice

Novelty detection when only normal data is available (machine health, clean-vs-attack). RBF one-class SVM, SVDD, and deep-SVDD; pick ν from tolerated false-alarm rate. Sensitive to feature scaling and kernel width.
71

Isolation forest

anomalies are easy to isolate

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

Build random trees with random axis + split. Path length h(x) = depth to isolate x. score(x) = 2^{− E[h(x)] / c(n)} (near 1 → anomaly).

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

python
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 outlier

In practice

Isolation Forest is a fast, popular unsupervised detector (linear time, low memory), strong in higher dimensions where distance methods fail. Extended Isolation Forest fixes axis-aligned bias with oblique cuts.
Online & active learning
72

Online / streaming learning

update per example, never look back

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

Predict ŷ = sign(w·x). On a mistake: w ← w + y·x. Online GD: w ← w − η∇ℓ_t(w) per example.

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

python
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

Streaming/online SGD scales to data that won’t fit in memory and adapts to fresh data; the basis of large-scale and real-time learners. One pass, constant memory — trades some statistical efficiency for huge practical scale.
73

Active learning

label the points that teach the most

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

Uncertainty sampling: query x* = argmin |score(x)| (closest to the boundary), or argmax entropy of p(y|x).

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

python
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

Cuts labeling cost in annotation-heavy settings (medical, NLP). Strategies: uncertainty, query-by-committee, expected model change. Caveats: sampling bias and batch diversity — pure uncertainty can pick redundant near-duplicates.
74

Concept drift

when the target moves

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

Distribution P_t(x,y) changes with t. Static model fixed at t=0; adaptive refits on a recent window / with forgetting.

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

python
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

Critical in production: monitor for drift, retrain on rolling windows, use forgetting factors, and detect change points (ADWIN, DDM). Trade-off: adapt too fast and you chase noise; too slow and you lag real change.
75

Regret & online optimization

compete with the best fixed choice

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

Regret_T = Σ_t ℓ_t(x_t) − min_x Σ_t ℓ_t(x). Online GD achieves Regret_T = O(√T) ⇒ Regret_T/T → 0.

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

python
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

The theoretical backbone of online learning, bandits, boosting, and game-playing (no-regret dynamics converge to equilibria). Adagrad/Adam descend from online convex optimization with adaptive step sizes.
Gaussian processes
76

Gaussian process priors

a distribution over smooth functions

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

f ~ GP(m(·), k(·,·)). RBF kernel: k(x,x′) = exp(−(x−x′)² / 2ℓ²). Any finite set of f-values is multivariate Gaussian.

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

python
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 functions

In practice

GPs give flexible nonparametric models with built-in uncertainty. The kernel encodes prior beliefs (smoothness, periodicity); its hyperparameters are tuned by maximizing the marginal likelihood. Cost is O(n³) — the main scaling limit.
77

GP regression: the posterior

certainty at the data, doubt away from it

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

mean(x*) = K*ᵀ(K+σ²I)⁻¹ y. var(x*) = k(x*,x*) − K*ᵀ(K+σ²I)⁻¹ K*.

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

python
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

Exact, calibrated regression with uncertainty — ideal for small data, sensor fusion, and as the surrogate in Bayesian optimization. Scaling to big n needs sparse/inducing-point approximations (SVGP).
78

Kernels encode assumptions

choosing the prior’s shape

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

RBF: exp(−d²/2ℓ²). Periodic: exp(−2 sin²(πd/p)/ℓ²). Linear: x·x′. Sums/products of kernels combine behaviors.

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

python
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

Kernel design is GP modeling: combine RBF + periodic + linear to capture trend, seasonality, and noise (as in Mauna Loa CO₂). The same kernels power SVMs and kernel ridge regression.
79

Bayesian optimization

optimize expensive functions, frugally

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

Fit GP to evaluated points. Acquisition (UCB): a(x) = μ(x) + κ·σ(x). Next point = argmax a(x); evaluate, repeat.

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

python
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/exploit

In practice

The standard for hyperparameter tuning and experiment design (expensive objectives). Expected Improvement is a common alternative acquisition; Thompson sampling is the Bayesian-bandit cousin.
Information theory
80

Entropy & surprise

the average information of an outcome

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

surprise(x) = −log₂ p(x) bits. H(p) = − Σ p(x) log₂ p(x) = E[surprise].

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

python
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

Entropy underlies cross-entropy loss, decision-tree splits (information gain), and exploration bonuses. Maximum-entropy modeling picks the least-committal distribution consistent with constraints.
81

KL divergence

the cost of the wrong distribution

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

KL(p∥q) = Σ p(x) log( p(x)/q(x) ) ≥ 0. = cross-entropy(p,q) − entropy(p).

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

python
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

Minimizing cross-entropy = minimizing KL to the data distribution. KL appears in the VAE ELBO, variational inference, policy regularization (RLHF), and distillation. Forward vs reverse KL gives mean-seeking vs mode-seeking fits.
82

Mutual information

how much Y tells you about X

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

I(X;Y) = H(X) − H(X|Y) = Σ p(x,y) log( p(x,y)/(p(x)p(y)) ). = KL( joint ∥ product of marginals ).

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

python
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

Feature selection (keep high-MI features), the information bottleneck, representation learning (InfoNCE maximizes a MI bound), and independence testing. Unlike correlation, MI catches nonlinear dependence.
83

Coding, compression & MDL

entropy is the compression limit

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

Optimal length(x) ≈ −log₂ p(x). Average length ≥ H(p) (source-coding theorem). MDL: minimize description(model) + description(data | model).

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

python
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

Arithmetic/Huffman coding, the MDL principle for model selection (a formal Occam’s razor), and the deep tie between compression and prediction (a good language model is a good compressor).
Hyperparameter & model search
85

Successive halving & Hyperband

kill the losers early

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

Start with n configs, budget b. Each round: keep top ½, multiply budget ×2. Total cost ≈ n·b·log n.

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

python
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

The standard for tuning deep nets where each run is expensive; implemented in Optuna, Ray Tune, Keras Tuner. Pairs naturally with random/Bayesian config sampling.
86

Neural architecture search

let the search design the network

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

max over architectures a: validation accuracy(a). Methods: RL controller, evolution, or differentiable search (DARTS) over a supernet.

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

python
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

Produced EfficientNet, NASNet, and hardware-aware mobile models. Cost was once enormous; weight-sharing (ENAS) and differentiable NAS (DARTS) made it practical.
87

Model selection & validation

the U-shaped validation curve

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

Choose hyperparameter λ = argmin validation error(λ). Nested CV: inner loop selects λ, outer loop gives an unbiased estimate.

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

python
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

Underlies all hyperparameter tuning and early stopping. Beware leakage and selection bias when the validation set is small or reused many times across experiments.
Survival analysis
88

Censoring & time-to-event

the data you only half-observe

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

Observe (Tᵢ, δᵢ): time Tᵢ and indicator δᵢ (1 = event, 0 = censored). Censored ⇒ true event time > Tᵢ.

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

python
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 them

In practice

Customer churn, hardware reliability, clinical trials, credit default — anywhere outcomes unfold over time and follow-up is incomplete. The basis for the Kaplan–Meier and Cox methods that follow.
89

Kaplan–Meier estimator

the survival curve, censoring and all

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

Ŝ(t) = ∏_{tᵢ ≤ t} (1 − dᵢ / nᵢ), dᵢ = events at tᵢ, nᵢ = at risk just before tᵢ.

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

python
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

The standard nonparametric survival estimate; log-rank tests compare two KM curves. A natural target for survival-aware models (random survival forests, DeepSurv).
90

Hazard & survival functions

instantaneous risk ↔ survival

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

h(t) = f(t)/S(t); S(t) = exp(−∫₀ᵗ h(u) du). Weibull: h(t) = (k/λ)(t/λ)^{k−1}.

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

python
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

Parametric survival models choose a hazard family (exponential, Weibull, log-normal); discrete-time models predict per-interval hazards with a neural net. The hazard view also links to point processes.
91

Cox proportional hazards

covariates scale the hazard

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

h(t | x) = h₀(t)·exp(βᵀx). Hazard ratio = exp(βΔx). S(t | x) = S₀(t)^{exp(βx)}.

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

python
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

The workhorse of clinical and reliability modeling; coefficients read as log-hazard-ratios. Check the proportional-hazards assumption (crossing curves break it); DeepSurv learns βᵀx as a neural net.
Semi-supervised learning
92

Self-training & pseudo-labels

the model teaches itself

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

1) fit on labeled. 2) pseudo-label x where confidence > τ. 3) add to training set, refit. Repeat.

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

python
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

Pseudo-labeling and “noisy student” training are simple, strong semi-supervised baselines, especially in vision. FixMatch combines pseudo-labels with consistency (next cards).
93

Label propagation

labels spread along the graph

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

Build affinity Wᵢⱼ = exp(−∥xᵢ−xⱼ∥²/σ²). Iterate f ← D⁻¹W f, clamping labeled nodes.

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

python
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

Graph-based SSL (label propagation/spreading) is transductive and effective when data forms clear manifolds; it underlies modern graph-SSL and some GNN training. Sensitive to the graph and bandwidth σ.
94

Consistency regularization

predict the same under perturbation

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

Loss += ∥ f(x+ε₁) − f(x+ε₂) ∥² on unlabeled x. Encourages locally smooth, confident predictions.

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

python
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

The engine of modern semi-supervised vision: Π-model, Mean Teacher, VAT, FixMatch, MixMatch. Strong augmentation + consistency + pseudo-labels routinely match supervised accuracy with a fraction of labels.
95

Low-density separation

cut through the gaps, not the crowds

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

Prefer boundaries where p(x) is low (the margin lies in a density valley) — the assumption shared by S3VM, entropy minimization, and consistency methods.

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

python
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))

In practice

The conceptual core of semi-supervised learning: unlabeled data helps only when its structure (clusters, manifolds) aligns with the labels. When it doesn’t, SSL can hurt — so validate the assumption.