For Sai · companion to gradient/descent

closed/form

Classical machine learning, rebuilt by hand. Fifteen chapters from raw data to decision boundaries — every model motivated, derived, written from scratch in NumPy, then translated into the PyTorch you already speak.

Why the name? Classical ML is the land of closed-form solutions, convex losses, and models you can hold entirely in your head. Deep learning gives all that up in exchange for expressiveness — which is exactly why learning this first makes DL make sense.
Read in orderEach chapter builds on the last. Linear regression is the load-bearing wall — everything after is a variation on its themes.
Two tabs, one ideaThe NumPy tab shows the raw mechanics. The PyTorch tab maps each model onto the DL muscle memory you're building.
Track progressTap the circle next to a chapter to mark it done. Progress persists when this runs inside Claude; elsewhere it lasts for the session.

Chapter 01 · Part I — Foundations

What Is Machine Learning?

One sentence, one equation, and one trade-off that governs every model in this book.

The one-sentence version

Machine learning is function search: you pick a family of functions \(\mathcal{F}\), define a loss that scores how badly a function fits your data, then search \(\mathcal{F}\) for the function with the lowest loss. Every model in this book — and every neural net you've touched — is just a different choice of family and a different search strategy.

That framing is called empirical risk minimization (ERM), and it's worth memorizing:

$$\hat{f} \;=\; \arg\min_{f \in \mathcal{F}} \;\underbrace{\frac{1}{n}\sum_{i=1}^{n} L\big(f(x_i),\, y_i\big)}_{\text{average loss on training data}}$$

The three flavors you'll meet, in the order this book covers them:

ParadigmYou haveYou wantChapters
Supervisedinputs \(x\) + labels \(y\)predict \(y\) for new \(x\)4–11
Unsupervisedinputs \(x\) onlyfind structure (groups, axes)12–14
Reinforcementan environment + rewardsa policy of actionsSutton & Barto — later!

The trade-off that rules everything

Why not always pick the most flexible family? Because expected error decomposes into three parts, and flexibility only fixes one of them:

$$\mathbb{E}\big[(y - \hat{f}(x))^2\big] \;=\; \underbrace{\text{Bias}^2}_{\text{too simple}} \;+\; \underbrace{\text{Variance}}_{\text{too sensitive to the sample}} \;+\; \underbrace{\sigma^2}_{\text{irreducible noise}}$$
  • High bias = underfitting: the family can't represent the truth (a line through a spiral).
  • High variance = overfitting: retrain on a slightly different sample and you get a wildly different model. It memorized noise.
  • Noise you can never beat. If \(y\) has randomness, perfect prediction is off the table.

Almost every technique in this book — regularization, pruning, bagging, cross-validation — is a lever on this trade-off. When a chapter introduces a hyperparameter, ask yourself: does turning it up raise variance or bias?

The smallest possible model, two ways

Here's the theme of the whole book in ten lines. Task: predict a number with a single constant \(c\), minimizing MSE. Classical ML solves it on paper (set the derivative to zero — the answer is the mean). Deep learning walks downhill to the same place.

import numpy as np

y = np.array([2.0, 3.5, 4.0, 5.5])   # targets

# Model: predict one constant c for every input.
# Loss:  MSE(c) = mean((y - c)^2)
# Set the derivative to zero:
#   d/dc MSE = -2 * mean(y - c) = 0   =>   c = mean(y)

c = y.mean()          # the closed-form answer
print(c)              # 3.75 -- no training loop needed
import torch

y = torch.tensor([2.0, 3.5, 4.0, 5.5])
c = torch.zeros(1, requires_grad=True)      # start from a guess
opt = torch.optim.SGD([c], lr=0.1)

for _ in range(200):                        # walk downhill instead
    loss = ((y - c) ** 2).mean()
    opt.zero_grad(); loss.backward(); opt.step()

print(c.item())       # ~3.75 -- converges to the mean
Keep this pattern in your pocket. "Closed form when the math allows, gradient descent when it doesn't" is the single thread connecting Chapter 4 (both work) to Chapter 15 (only descent survives).
Practice & go deeper
Exercise: minimize mean absolute error with a constant. What's the closed form?

The median. MSE's derivative balances distances (giving the mean); MAE's subgradient balances counts of points above vs. below, which the median equalizes. This is why MAE-trained models are robust to outliers.

Chapter 02 · Part I — Foundations

Preprocessing & Feature Engineering

Models are only as good as the tensors you feed them. This is 80% of real-world ML work.

Why models care about your units

Suppose you predict house prices from sqft (≈ 1000s) and bedrooms (≈ 1–5). Any model that uses distances (KNN, K-Means, SVM) or gradient descent (nearly everything else) will be dominated by sqft purely because its numbers are bigger. Scaling puts features on equal footing:

$$\text{standardize: } z = \frac{x - \mu}{\sigma} \qquad\quad \text{min-max: } x' = \frac{x - x_{\min}}{x_{\max} - x_{\min}}$$

The core toolkit, in the order you'd apply it:

  • Impute missing values (median for skewed numerics, mode for categoricals) — or add an "is-missing" flag; missingness is often signal.
  • Encode categoricals: one-hot for nominal ("city"), integer/ordinal only when order is real ("S < M < L").
  • Scale numerics: standardize by default; min-max when you need a bounded range.
  • Transform skew: log1p tames long-tailed features like income or price.

The cardinal sin: data leakage

Every statistic you fit — means, medians, scalers, encoders — must be computed on the training set only, then applied to validation/test. If your scaler has seen the test set's mean, your test score is quietly lying to you. This is the most common silent bug in all of ML.

The code, two ways

import numpy as np

X_train = np.array([[1200., 3], [1500., 4], [np.nan, 2], [900., 2]])
X_test  = np.array([[1100., 3]])

# 1) Impute with the TRAIN median
med = np.nanmedian(X_train[:, 0])
X_train[np.isnan(X_train[:, 0]), 0] = med

# 2) Standardize -- fit on TRAIN ONLY, apply everywhere
mu, sigma = X_train.mean(axis=0), X_train.std(axis=0)
X_train_z = (X_train - mu) / sigma
X_test_z  = (X_test  - mu) / sigma      # reuse train stats. No leakage.

# 3) One-hot encode a categorical column
city   = np.array([0, 2, 1, 0])         # ids for ["SEA", "NYC", "LA"]
onehot = np.eye(3)[city]                 # rows of the identity matrix

# 4) Log-transform a skewed feature
price     = np.array([300_000., 5_000_000., 450_000.])
price_log = np.log1p(price)              # log(1 + x): safe at zero
import torch
import torch.nn.functional as F

X_train = torch.tensor([[1200., 3], [1500., 4], [float('nan'), 2], [900., 2]])
X_test  = torch.tensor([[1100., 3]])

# 1) Impute with the TRAIN median
med = X_train[:, 0].nanmedian()
X_train[:, 0] = torch.nan_to_num(X_train[:, 0], nan=med.item())

# 2) Standardize with TRAIN stats
mu, sigma = X_train.mean(0), X_train.std(0)
X_train_z = (X_train - mu) / sigma
X_test_z  = (X_test  - mu) / sigma       # same rule: train stats only

# 3) One-hot -- this exact op feeds embedding layers later in DL
city   = torch.tensor([0, 2, 1, 0])
onehot = F.one_hot(city, num_classes=3).float()

# 4) Tame skew
price_log = torch.log1p(torch.tensor([3e5, 5e6, 4.5e5]))

# DL note: BatchNorm/LayerNorm are this chapter's idea,
# applied continuously INSIDE the network.
Gotcha. Tree-based models (Chapters 9–10) are the exception: they split on thresholds, so they're immune to monotonic scaling. Everything distance- or gradient-based needs it. When in doubt, standardize — it never hurts.
Practice & go deeper
Exercise: why is one-hot encoding "high cardinality" columns (e.g. zip code, 40k values) a problem, and what would you do instead?

40k nearly-empty columns explode memory and variance (each category has few samples). Alternatives: target encoding (replace category with train-fold mean of \(y\), carefully cross-validated), frequency encoding, hashing — or in DL, an embedding layer, which is a learned dense encoding.

Chapter 03 · Part I — Foundations

Evaluation & Validation

If you can't measure it honestly, you can't improve it. Learn this before training a single model.

Three sets, three jobs

  • Train — the model learns from this.
  • Validation — you pick hyperparameters and compare models on this. It slowly "wears out" as you tune against it.
  • Test — touched once, at the very end. It's your only unbiased estimate of real-world performance.

With small datasets you can't afford a big validation set, so use k-fold cross-validation: split the training data into \(k\) folds, train \(k\) times each holding out one fold, and average:

$$\text{CV}_{(k)} = \frac{1}{k}\sum_{j=1}^{k} \text{Err}_j \qquad (k=5 \text{ or } 10 \text{ in practice})$$

Metrics: pick the one that matches the cost of being wrong

For regression: MSE punishes large errors quadratically; MAE is robust to outliers; \(R^2\) reads as "fraction of variance explained":

$$R^2 = 1 - \frac{\sum_i (y_i - \hat{y}_i)^2}{\sum_i (y_i - \bar{y})^2}$$

For classification, accuracy collapses under class imbalance (99% "no fraud" data → a useless model scores 99%). The confusion matrix is the source of truth:

Predicted +Predicted −
Actual +TPFN (missed!)
Actual −FP (false alarm)TN
$$\text{Precision} = \frac{TP}{TP+FP} \qquad \text{Recall} = \frac{TP}{TP+FN} \qquad F_1 = \frac{2 \cdot P \cdot R}{P + R}$$
  • Precision: of my positive calls, how many were right? (Optimize when false alarms are costly — spam filters.)
  • Recall: of the real positives, how many did I catch? (Optimize when misses are costly — cancer screening.)
  • ROC-AUC: probability the model ranks a random positive above a random negative — threshold-free ranking quality.

The code, two ways

import numpy as np

def train_test_split(X, y, test_frac=0.2, seed=0):
    idx = np.random.default_rng(seed).permutation(len(y))
    cut = int(len(y) * (1 - test_frac))
    tr, te = idx[:cut], idx[cut:]
    return X[tr], X[te], y[tr], y[te]

def k_fold_indices(n, k=5, seed=0):
    idx = np.random.default_rng(seed).permutation(n)
    return np.array_split(idx, k)        # k disjoint validation folds

def accuracy(y, p):
    return (y == p).mean()

def precision_recall_f1(y, p):           # binary: 1 = positive
    tp = ((p == 1) & (y == 1)).sum()
    fp = ((p == 1) & (y == 0)).sum()
    fn = ((p == 0) & (y == 1)).sum()
    prec = tp / (tp + fp + 1e-12)
    rec  = tp / (tp + fn + 1e-12)
    f1   = 2 * prec * rec / (prec + rec + 1e-12)
    return prec, rec, f1

def r2_score(y, yhat):
    ss_res = ((y - yhat) ** 2).sum()
    ss_tot = ((y - y.mean()) ** 2).sum()
    return 1 - ss_res / ss_tot
import torch

def accuracy(y, p):
    return (y == p).float().mean()

def precision_recall_f1(y, p):
    tp = ((p == 1) & (y == 1)).sum().float()
    fp = ((p == 1) & (y == 0)).sum().float()
    fn = ((p == 0) & (y == 1)).sum().float()
    prec = tp / (tp + fp + 1e-12)
    rec  = tp / (tp + fn + 1e-12)
    f1   = 2 * prec * rec / (prec + rec + 1e-12)
    return prec, rec, f1

# In real projects: the `torchmetrics` package implements all of
# these (plus ROC-AUC) with GPU + batching support:
#
#   from torchmetrics.classification import BinaryF1Score
#   f1 = BinaryF1Score(); f1(preds, target)
#
# But you should be able to write each one from memory --
# interviewers love asking, and bugs here poison every experiment.
Gotcha. Do preprocessing inside each CV fold (fit scaler on the fold's train split), never before splitting — otherwise Chapter 2's leakage sneaks back in through the folds. For time-series, never shuffle: split by time.
Practice & go deeper
Exercise: a disease affects 1 in 1000 people. Your test has 99% accuracy. Should you panic on a positive result?

No — apply Bayes (preview of Chapter 8). With ~1% false positive rate, among 1000 people you'd get ≈10 false positives and ≈1 true positive, so P(disease | positive) ≈ 9%. Accuracy hides base rates; precision/recall don't.

Chapter 04 · Part II — Linear Models

Linear Regression

The load-bearing wall. Master this chapter deeply — every later model is a remix.

The model and its loss

Predict a number as a weighted sum of features plus a bias. Stack all \(n\) samples into a matrix \(X \in \mathbb{R}^{n \times d}\) and it's one matrix–vector product:

$$\hat{y} = Xw + b \qquad\quad J(w) = \frac{1}{n}\lVert Xw - y \rVert^2 \;\;\text{(MSE)}$$

MSE isn't arbitrary: it's the negative log-likelihood if you assume Gaussian noise. And crucially, \(J\) is a convex bowl in \(w\) — one global minimum, no bad local optima. That's what makes two very different solution routes land in the same place.

Route A — solve it on paper (the normal equation)

At the bottom of a bowl the gradient is zero. Take \(\nabla_w J = 0\) and solve:

$$\nabla_w J = \frac{2}{n} X^\top (Xw - y) = 0 \;\;\Longrightarrow\;\; w^* = (X^\top X)^{-1} X^\top y$$

One line of algebra, zero iterations. The catch: inverting \(X^\top X\) costs \(O(d^3)\) and is numerically fragile — which is why in code we call a solver (lstsq) instead of literally inverting.

Route B — walk downhill (gradient descent)

Repeatedly step opposite the gradient with learning rate \(\alpha\):

$$w \leftarrow w - \alpha \cdot \frac{2}{n} X^\top (Xw - y)$$

Read the gradient out loud: "errors, projected back through the inputs." That exact sentence is what backprop generalizes. Try it below — watch the line swing into place and the loss fall:

epoch 0 · w=0.00 b=0.00 · loss —

The code, two ways

import numpy as np

rng = np.random.default_rng(0)
X = rng.uniform(0, 10, size=(100, 1))
y = 3.0 * X[:, 0] + 4.0 + rng.normal(0, 1.5, 100)   # truth: w=3, b=4

Xb = np.c_[X, np.ones(len(X))]        # append a bias column of 1s

# ---- Route A: closed form ----
w = np.linalg.lstsq(Xb, y, rcond=None)[0]   # solves min ||Xb w - y||^2
print(w)                                    # ~[3.0, 4.0], instantly

# ---- Route B: gradient descent on the same bowl ----
w, lr = np.zeros(2), 0.01
for _ in range(2000):
    err  = Xb @ w - y                  # (n,)  residuals
    grad = 2 * Xb.T @ err / len(y)     # "errors, pushed back through X"
    w   -= lr * grad
print(w)                               # same answer, iteratively
import torch, torch.nn as nn

X = torch.rand(100, 1) * 10
y = 3.0 * X + 4.0 + torch.randn(100, 1) * 1.5

model   = nn.Linear(1, 1)              # w and b live inside the module
loss_fn = nn.MSELoss()
opt     = torch.optim.SGD(model.parameters(), lr=0.01)

for _ in range(2000):
    loss = loss_fn(model(X), y)        # forward
    opt.zero_grad()
    loss.backward()                    # autograd computes X^T(err) for you
    opt.step()

print(model.weight.item(), model.bias.item())   # ~3.0, ~4.0

# Realize what this means: nn.Linear + MSELoss + SGD
# *is* linear regression. Your first "neural network"
# was this chapter all along.
Gotchas. (1) Forget the bias column and your line is forced through the origin. (2) Unscaled features make the loss bowl an elongated valley — gradient descent zigzags and crawls; standardize first (Chapter 2). (3) If the loss grows, your learning rate is too high — you're overshooting the bowl.
Practice & go deeper
Exercise: when is \(X^\top X\) not invertible, and what does it mean?

When columns of \(X\) are linearly dependent (e.g. temp_C and temp_F both present, or more features than samples, \(d > n\)). Infinitely many \(w\) fit equally well. The fix is Chapter 5: ridge adds \(\lambda I\), making \(X^\top X + \lambda I\) always invertible.

Chapter 05 · Part II — Linear Models

Regularization: Ridge, Lasso & Elastic Net

Deliberately fit the training data a little worse, so you generalize a lot better.

The idea: tax large weights

Overfit models tend to have huge, delicately-balanced weights (one feature times +812, a correlated one times −807). Add a penalty on weight size and the optimizer must justify every unit of magnitude with real error reduction:

$$\text{Ridge (L2): } J = \tfrac{1}{n}\lVert Xw - y\rVert^2 + \lambda \lVert w \rVert_2^2 \qquad \text{Lasso (L1): } J = \tfrac{1}{n}\lVert Xw - y\rVert^2 + \lambda \lVert w \rVert_1$$

\(\lambda\) is your bias–variance dial from Chapter 1: \(\lambda = 0\) is plain regression (max variance); \(\lambda \to \infty\) shrinks all weights to zero (max bias). Choose it by cross-validation (Chapter 3).

  • Ridge shrinks weights smoothly toward zero, never exactly to zero. Great default; handles correlated features by splitting credit.
  • Lasso's corner-shaped penalty drives some weights exactly to zero — free feature selection. The geometric picture: the L1 "diamond" constraint has corners on the axes, and the loss contours usually touch a corner first.
  • Elastic net = a blend of both (\(\lambda_1\lVert w\rVert_1 + \lambda_2\lVert w\rVert_2^2\)) — sparsity and stability with correlated features.

Ridge still has a closed form — the \(\lambda I\) term also fixes Chapter 4's invertibility problem:

$$w^*_{\text{ridge}} = (X^\top X + \lambda I)^{-1} X^\top y$$

Lasso doesn't (the L1 corner isn't differentiable at 0), so we solve it iteratively — a preview of life after closed forms.

The code, two ways

import numpy as np

# ---- Ridge: closed form survives; just add lambda*I ----
def ridge_fit(X, y, lam=1.0):
    d = X.shape[1]
    return np.linalg.solve(X.T @ X + lam * np.eye(d), X.T @ y)

# ---- Lasso: coordinate descent + soft-thresholding ----
def soft(z, t):                        # shrink toward 0, clamp at 0
    return np.sign(z) * np.maximum(np.abs(z) - t, 0.0)

def lasso_fit(X, y, lam=0.1, iters=300):
    n, d = X.shape
    w = np.zeros(d)
    col_sq = (X ** 2).sum(axis=0)
    for _ in range(iters):
        for j in range(d):             # optimize one weight at a time
            r_j  = y - X @ w + X[:, j] * w[j]   # residual w/o feature j
            rho  = X[:, j] @ r_j
            w[j] = soft(rho, lam * n / 2) / col_sq[j]
    return w

# The soft() clamp is what produces EXACT zeros:
# if a feature's correlation with the residual is below the
# threshold, its weight snaps to 0 -- feature deleted.
import torch, torch.nn as nn

d = 10
model = nn.Linear(d, 1)
mse   = nn.MSELoss()

# ---- Ridge is hiding in an argument you've already used ----
opt = torch.optim.SGD(model.parameters(), lr=0.01,
                      weight_decay=1e-3)   # weight_decay == L2 penalty

# ---- L1 has no flag; add it to the loss yourself ----
lam = 1e-3
for _ in range(2000):
    loss = mse(model(X), y) + lam * model.weight.abs().sum()
    opt.zero_grad(); loss.backward(); opt.step()

# DL bridge: dropout, early stopping, and data augmentation are
# all this chapter's idea -- "make fitting the training set harder" --
# expressed without a penalty term. (Caveat for later: with Adam,
# true weight decay and L2 diverge; that's why AdamW exists.)
Gotcha. Regularization is unit-sensitive: the penalty treats a weight on "meters" and a weight on "millimeters" identically, but they're on wildly different scales. Always standardize features before regularizing, and conventionally don't penalize the bias term.
Practice & go deeper
Exercise: two identical copies of a feature enter a ridge model vs. a lasso model. What happens to their weights?

Ridge splits the weight evenly between the twins (the L2 penalty of \((a,a)\) beats \((2a,0)\)). Lasso is indifferent between them and typically zeroes one arbitrarily — one reason lasso's selection is unstable, and elastic net exists.

Chapter 06 · Part II — Linear Models

Logistic Regression

Linear regression walks into a classification problem. A squashing function saves the day.

From scores to probabilities

To classify (spam / not spam), we still compute a linear score \(z = w^\top x + b\) — but squash it into a probability with the sigmoid:

$$\sigma(z) = \frac{1}{1 + e^{-z}} \qquad\quad P(y{=}1 \mid x) = \sigma(w^\top x + b)$$

Large positive score → probability near 1; large negative → near 0; \(z = 0\) → exactly 0.5. The set of points where \(z=0\) is the decision boundary, and since \(z\) is linear, that boundary is a straight line (hyperplane). Logistic regression is a linear classifier — only the output interpretation changed.

Why not MSE? Enter cross-entropy

MSE on top of a sigmoid gives a lumpy, non-convex loss with flat regions where gradients die. Instead, maximize the likelihood of the labels — equivalently minimize binary cross-entropy:

$$J(w) = -\frac{1}{n}\sum_{i=1}^n \Big[\, y_i \log p_i + (1-y_i)\log(1-p_i) \,\Big], \qquad p_i = \sigma(w^\top x_i + b)$$

It's convex, and its gradient collapses into something you already know:

$$\nabla_w J = \frac{1}{n} X^\top (p - y)$$

Compare with Chapter 4's \(\frac{2}{n}X^\top(\hat{y} - y)\). Identical shape: errors, pushed back through the inputs. This "prediction minus target, times input" pattern recurs in softmax regression and in the output layer of every classifier network you'll ever train. There's no closed form this time, though — \(w\) is trapped inside \(\sigma\) — so gradient descent is now mandatory, not optional.

The code, two ways

import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def logreg_fit(X, y, lr=0.1, iters=3000):
    Xb = np.c_[X, np.ones(len(X))]      # bias trick again
    w  = np.zeros(Xb.shape[1])
    for _ in range(iters):
        p    = sigmoid(Xb @ w)          # predicted P(y=1), shape (n,)
        grad = Xb.T @ (p - y) / len(y)  # the tidy BCE gradient
        w   -= lr * grad
    return w

def predict_proba(X, w):
    return sigmoid(np.c_[X, np.ones(len(X))] @ w)

def predict(X, w, thresh=0.5):          # thresh is a business decision!
    return (predict_proba(X, w) >= thresh).astype(int)

# Multiclass? Replace sigmoid with softmax and BCE with
# cross-entropy; the gradient keeps the exact same X^T(p - y) shape.
import torch, torch.nn as nn

X = torch.randn(200, 2)
y = (X[:, 0] + X[:, 1] > 0).float().unsqueeze(1)   # a linear truth

model   = nn.Linear(2, 1)
loss_fn = nn.BCEWithLogitsLoss()   # sigmoid + BCE fused: numerically
opt     = torch.optim.SGD(model.parameters(), lr=0.1)   # stable in one op

for _ in range(3000):
    logits = model(X)               # NOTE: no sigmoid in the forward pass
    loss   = loss_fn(logits, y)
    opt.zero_grad(); loss.backward(); opt.step()

probs = torch.sigmoid(model(X))     # sigmoid only at inference time

# Multiclass version -- the pattern you already know from DL:
#   model = nn.Linear(d, num_classes)
#   loss  = nn.CrossEntropyLoss()   (takes raw logits, fuses softmax)
# Softmax regression IS the final layer of every classifier net.
Gotchas. (1) np.exp(-z) overflows for very negative \(z\) — real implementations use log-sum-exp tricks; in PyTorch that's exactly why BCEWithLogitsLoss exists and why you never apply sigmoid before it. (2) On imbalanced data, don't worship the 0.5 threshold — sweep it and read precision/recall (Chapter 3). (3) Perfectly separable data sends \(\lVert w\rVert \to \infty\); a little L2 (Chapter 5) fixes it.
Practice & go deeper
Exercise: why does the weight vector \(w\) point perpendicular to the decision boundary?

The boundary is \(\{x : w^\top x + b = 0\}\), a level set of the linear score. Gradients are always perpendicular to level sets, and \(\nabla_x (w^\top x + b) = w\). So \(w\) literally points in the direction of fastest-increasing "class-1-ness".

Chapter 07 · Part III — Nonlinear Models

k-Nearest Neighbors

The laziest model in ML: no training at all. Just ask the neighbors.

Learning by lookup

To classify a new point: find the \(k\) closest training points, let them vote. To regress: average their targets. There is no training step — the dataset is the model. All the intelligence lives in one question: what does "close" mean?

$$\text{Euclidean: } d(a,b) = \sqrt{\textstyle\sum_j (a_j - b_j)^2} \qquad \text{Manhattan: } d(a,b) = \textstyle\sum_j |a_j - b_j|$$

\(k\) is a pure bias–variance dial: \(k=1\) memorizes the training set (jagged boundary, high variance); \(k=n\) predicts the global majority (flat, high bias). Use odd \(k\) for binary problems to dodge ties, and pick it by cross-validation.

The curse of dimensionality

KNN's kryptonite. In high dimensions, volume concentrates in corners and everything becomes far from everything — the ratio between the nearest and farthest neighbor distance approaches 1, so "nearest" stops meaning anything. A rule of feel: KNN shines in ≲20 well-chosen dimensions and degrades badly beyond that. (This is one reason Chapter 14's dimensionality reduction exists — and why DL learns embeddings where distance does mean something.)

The code, two ways

import numpy as np

def knn_predict(X_train, y_train, X_new, k=5):
    # pairwise squared distances via broadcasting:
    # (m,1,d) - (1,n,d) -> (m,n,d) -> sum over d -> (m,n)
    d2 = ((X_new[:, None, :] - X_train[None, :, :]) ** 2).sum(-1)

    nn_idx = np.argsort(d2, axis=1)[:, :k]   # k closest per query row
    votes  = y_train[nn_idx]                 # (m, k) neighbor labels

    # majority vote per row
    return np.array([np.bincount(v).argmax() for v in votes])

# Regression variant: return y_train[nn_idx].mean(axis=1)
# Weighted variant: weight votes by 1/d -- closer neighbors count more.

# That (m,n) distance matrix is O(m*n*d) time and O(m*n) memory:
# fine for thousands of points, brutal for millions. Real systems
# use KD-trees / ball trees, or approximate NN (FAISS, HNSW) --
# the same tech behind vector databases and RAG retrieval.
import torch

def knn_predict(X_train, y_train, X_new, k=5):
    d   = torch.cdist(X_new, X_train)         # (m, n) pairwise distances
    idx = d.topk(k, largest=False).indices    # k nearest, no full sort
    votes = y_train[idx]                       # (m, k)
    return votes.mode(dim=1).values            # majority label per row

# Three habits worth stealing here:
#  * torch.cdist -- optimized pairwise distances, GPU-ready
#  * topk instead of full argsort -- O(n log k), not O(n log n)
#  * this exact pattern (embed -> cdist -> topk) is literally
#    how retrieval / kNN-augmented models work in modern DL.
Gotcha. KNN is the model most brutally punished by unscaled features — a feature measured in thousands owns the distance metric outright. Standardize (Chapter 2), always. And note prediction cost: KNN is free to "train" but pays at inference — the mirror image of every other model here.
Practice & go deeper
  • Do: classify Iris with your implementation; sweep \(k\) from 1–30 and plot CV accuracy.
  • Read: CS231n's KNN notes — you've seen these; reread knowing the curse of dimensionality is the punchline.
Exercise: your KNN gets 100% training accuracy with \(k=1\). Is something wrong?

No — it's guaranteed. Every training point's nearest neighbor is itself (distance 0). This is why training accuracy is meaningless for KNN and validation accuracy is everything.

Chapter 08 · Part III — Nonlinear Models

Naive Bayes

A "wrong" independence assumption that works embarrassingly well — and trains in one pass.

Flip the question with Bayes' rule

Instead of modeling \(P(y \mid x)\) directly (like logistic regression — a discriminative model), model how each class generates data, then invert:

$$P(y = c \mid x) \;\propto\; \underbrace{P(y = c)}_{\text{prior}} \; \underbrace{P(x \mid y = c)}_{\text{likelihood}}$$

Estimating the full joint \(P(x \mid y)\) is hopeless (exponentially many feature combinations). The naive move: pretend features are independent given the class, so the likelihood factorizes into per-feature pieces you can estimate from simple counts and averages:

$$P(x \mid y = c) \;\approx\; \prod_{j=1}^{d} P(x_j \mid y = c) \qquad\Longrightarrow\qquad \hat{y} = \arg\max_c \Big[ \log P(c) + \sum_j \log P(x_j \mid c) \Big]$$

For continuous features, model each \(P(x_j \mid c)\) as a Gaussian with that class's per-feature mean and variance (Gaussian NB). For word counts, use multinomial NB — the classic spam filter. Training = compute means/variances/counts per class. One pass. No optimizer.

Notice we work in log space: a product of 10,000 small probabilities underflows to exactly 0.0 in float64; a sum of 10,000 logs is perfectly happy.

The code, two ways

import numpy as np

class GaussianNB:
    def fit(self, X, y):
        self.classes = np.unique(y)
        self.mu  = np.array([X[y == c].mean(0) for c in self.classes])
        self.var = np.array([X[y == c].var(0)  for c in self.classes])
        self.var += 1e-9                        # never divide by zero
        self.log_prior = np.log(np.bincount(y) / len(y))
        return self                             # "training" is done.

    def predict(self, X):
        # log N(x | mu, var) per class/sample/feature, then the naive
        # step: SUM over features (a product, in log space)
        ll = -0.5 * (np.log(2 * np.pi * self.var[:, None, :])
             + (X[None] - self.mu[:, None]) ** 2 / self.var[:, None, :])
        scores = ll.sum(-1) + self.log_prior[:, None]   # (C, n)
        return self.classes[scores.argmax(0)]

# Multinomial NB (spam): P(word|class) = word counts per class,
# plus Laplace smoothing (add 1) so unseen words don't zero out
# the whole product.
import torch
from torch.distributions import Normal

def gnb_fit(X, y, C):
    mu = torch.stack([X[y == c].mean(0) for c in range(C)])
    sd = torch.stack([X[y == c].std(0) + 1e-6 for c in range(C)])
    log_prior = torch.log(torch.bincount(y).float() / len(y))
    return mu, sd, log_prior

def gnb_predict(X, mu, sd, log_prior):
    # Normal(...).log_prob broadcasts to (C, n, d);
    # summing over features is the "naive" independence step
    ll = Normal(mu[:, None], sd[:, None]).log_prob(X[None]).sum(-1)
    return (ll + log_prior[:, None]).argmax(0)

# torch.distributions is a hidden gem: log_prob, sampling, KL --
# the same API powers VAEs and policy-gradient RL later.
# Also note: log-space arithmetic is the same trick behind
# log_softmax + NLLLoss in deep learning.
When to reach for it. Naive Bayes is a phenomenal baseline: trains instantly, handles many features, works with tiny datasets, updates online. Its class rankings are often right even when independence is badly violated — but its probabilities are overconfident for the same reason. Calibrate before trusting them.
Practice & go deeper
Exercise: why does NB need Laplace smoothing, concretely?

If the word "viagra" never appeared in training ham, then \(P(\text{viagra} \mid \text{ham}) = 0\), and one occurrence multiplies the entire ham probability to zero — no other evidence can recover it. Adding 1 to every count keeps every probability strictly positive.

Chapter 09 · Part III — Nonlinear Models

Decision Trees

Twenty questions, played greedily against your data. The building block of the strongest tabular models alive.

How a tree thinks

A tree carves feature space with axis-aligned questions: is sqft ≤ 1400? is age ≤ 35? Each question splits the data; leaves predict the majority class (or mean value). To choose questions, we need a score for "how mixed is this group?" — impurity:

$$\text{Gini}(S) = 1 - \sum_c p_c^2 \qquad\quad \text{Entropy}(S) = -\sum_c p_c \log_2 p_c$$

Both are 0 for a pure group and maximal for a 50/50 mix. A split's quality is the impurity drop it buys (information gain), weighting each side by its size:

$$\text{Gain} = \text{Imp}(S) \;-\; \frac{|S_L|}{|S|}\,\text{Imp}(S_L) \;-\; \frac{|S_R|}{|S|}\,\text{Imp}(S_R)$$

The CART algorithm is then just: try every (feature, threshold), keep the best split, recurse on both sides, stop at a depth/size limit. It's greedy — locally optimal splits, no lookahead — because finding the globally optimal tree is NP-hard.

Left unchecked, a tree grows until every leaf is pure — memorizing the training set, the definition of high variance. Control it with max_depth, min_samples_leaf, or post-pruning. (Or embrace the variance and average many trees — that's Chapter 10.)

The code, two ways

import numpy as np

def gini(y):
    p = np.bincount(y) / len(y)
    return 1 - (p ** 2).sum()

def best_split(X, y):
    best = (None, None, np.inf)             # (feature, threshold, score)
    for j in range(X.shape[1]):
        for t in np.unique(X[:, j]):
            L = X[:, j] <= t
            if L.all() or not L.any():
                continue
            score = (L.mean() * gini(y[L])          # weighted child
                     + (~L).mean() * gini(y[~L]))   # impurity
            if score < best[2]:
                best = (j, t, score)
    return best

def grow(X, y, depth=0, max_depth=4, min_leaf=3):
    if depth == max_depth or len(y) < 2 * min_leaf or gini(y) == 0:
        return {"leaf": np.bincount(y).argmax()}
    j, t, _ = best_split(X, y)
    if j is None:
        return {"leaf": np.bincount(y).argmax()}
    L = X[:, j] <= t
    return {"feat": j, "thresh": t,
            "left":  grow(X[L],  y[L],  depth+1, max_depth, min_leaf),
            "right": grow(X[~L], y[~L], depth+1, max_depth, min_leaf)}

def predict_one(node, x):
    while "leaf" not in node:
        go_left = x[node["feat"]] <= node["thresh"]
        node = node["left"] if go_left else node["right"]
    return node["leaf"]
# An honest note instead of a fake idiom:
#
# Trees do NOT train by gradient descent. Choosing a split is a
# discrete, non-differentiable search -- there's no gradient to
# follow, so there is no natural "PyTorch tree". In practice you
# reach for scikit-learn (single trees, forests) or
# XGBoost / LightGBM (boosted trees, Chapter 10).
#
# What DOES translate is the impurity math, on tensors:

import torch

def gini(y, num_classes):
    p = torch.bincount(y, minlength=num_classes).float() / len(y)
    return 1 - (p ** 2).sum()

def entropy(y, num_classes):
    p = torch.bincount(y, minlength=num_classes).float() / len(y)
    p = p[p > 0]                       # 0*log(0) := 0
    return -(p * p.log2()).sum()

# Deeper connection: entropy here IS the entropy in cross-entropy
# loss. Information gain = mutual information between a split
# and the label -- the same quantity, one field over.
Gotchas. (1) Single trees are unstable: change a few rows and the root split can flip, restructuring everything below — high variance in its purest form. (2) Axis-aligned splits struggle with diagonal boundaries (they build staircases). (3) The naive split search above is \(O(d \cdot n^2)\); real CART sorts each feature once and sweeps thresholds in \(O(d \cdot n \log n)\).
Practice & go deeper
  • Interactive: A Visual Introduction to ML (R2D3) — the most beautiful decision-tree explainer on the internet.
  • Do: train your CART on Titanic and print the tree — inspecting learned rules ("sex, then age, then class") is a rare treat: a model you can literally read.
Exercise: why don't trees care about feature scaling?

A split \(x_j \le t\) only depends on the ordering of values, and monotonic transforms (standardize, log) preserve order — the tree just learns a transformed threshold. Same tree, same predictions.

Chapter 10 · Part III — Nonlinear Models

Ensembles: Bagging, Random Forests & Boosting

Weak models, combined cleverly, beat strong models. Two opposite strategies, both built on Chapter 9's trees.

Strategy 1 — Bagging: average away the variance

Chapter 9's problem: one deep tree = low bias, huge variance. But averaging \(m\) independent noisy estimates divides variance by \(m\):

$$\text{Var}\Big(\tfrac{1}{m}\textstyle\sum_i \hat{f}_i\Big) = \frac{\sigma^2}{m} \quad \text{(if independent)} \qquad \rho\sigma^2 + \frac{1-\rho}{m}\sigma^2 \quad \text{(if correlated by } \rho)$$

We only have one dataset, so we manufacture "independent" ones by bootstrap sampling (draw \(n\) rows with replacement), train a deep tree on each, and average/vote. That's bagging. The correlated-case formula exposes the remaining weakness: if all trees agree because they all lean on the same dominant feature, \(\rho\) is high and averaging stops helping.

Random forests attack \(\rho\) directly: at every split, each tree may only consider a random subset of features (typically \(\sqrt{d}\)). Forced diversity → decorrelated trees → the averaging actually works. Bonus: rows left out of each bootstrap (~37%) give a free validation signal (out-of-bag error).

Strategy 2 — Boosting: chip away at the bias

The opposite philosophy: start with a terrible model, and sequentially add small trees, each one fixing what the current ensemble still gets wrong:

$$F_m(x) = F_{m-1}(x) + \nu \cdot h_m(x), \qquad h_m \text{ fit to } \;-\frac{\partial L(y, F_{m-1})}{\partial F_{m-1}}$$

For MSE, that negative gradient is literally the residual \(y - F_{m-1}(x)\): "fit a small tree to the errors, add it in, repeat." The learning rate \(\nu\) (≈0.1) keeps each step humble. This is gradient descent in function space — instead of nudging weights, each step adds a whole function pointing downhill. XGBoost and LightGBM are industrial versions of exactly this loop, and they remain the strongest models for tabular data — routinely beating neural nets there.

The code, two ways

import numpy as np
# uses grow() / predict_one() from Chapter 9

# ---- Bagging: many deep trees on bootstrap samples ----
def bagging_fit(X, y, n_trees=25, seed=0):
    rng = np.random.default_rng(seed)
    forest = []
    for _ in range(n_trees):
        idx = rng.integers(0, len(y), size=len(y))  # with replacement
        forest.append(grow(X[idx], y[idx], max_depth=8))
    return forest

def bagging_predict(forest, x):
    votes = np.array([predict_one(t, x) for t in forest])
    return np.bincount(votes).argmax()
# Random forest = the same loop + random feature subsets in best_split.

# ---- Gradient boosting (regression) with stumps ----
def fit_stump(X, r):                    # depth-1 regression tree
    best, best_err = None, np.inf
    for j in range(X.shape[1]):
        for t in np.unique(X[:, j]):
            L = X[:, j] <= t
            if L.all() or not L.any():
                continue
            lv, rv = r[L].mean(), r[~L].mean()
            err = ((r[L]-lv)**2).sum() + ((r[~L]-rv)**2).sum()
            if err < best_err:
                best_err, best = err, (j, t, lv, rv)
    return best

def stump_predict(s, X):
    j, t, lv, rv = s
    return np.where(X[:, j] <= t, lv, rv)

def boost_fit(X, y, rounds=100, lr=0.1):
    pred, stumps = np.full(len(y), y.mean()), []
    for _ in range(rounds):
        residual = y - pred              # what we still get wrong
        s = fit_stump(X, residual)       # small tree on the errors
        pred += lr * stump_predict(s, X) # humble corrective step
        stumps.append(s)
    return y.mean(), stumps
# The deep-learning bridge, made concrete: a "residual" IS the
# negative gradient of MSE with respect to current predictions.
import torch

y    = torch.tensor([3.0, 5.0, 8.0])
pred = torch.full((3,), y.mean().item(), requires_grad=True)

loss = 0.5 * ((y - pred) ** 2).sum()
loss.backward()

print(-pred.grad)     # == y - pred: the residuals, via autograd
print(y - pred)       # identical.

# So gradient boosting = gradient descent in FUNCTION space:
#   step direction: a tree fit to -dL/dF   (not -dL/dw!)
#   step size:      the learning rate nu
# Swap in BCE's gradient and the same loop does classification.
# This viewpoint is why it's called *gradient* boosting.

# Ensembling shows up in DL too, same variance logic:
#   * deep ensembles (train k nets, average) -- SOTA uncertainty
#   * dropout ~= an implicit ensemble of subnetworks
#   * model souping / EMA weights ~= averaging in weight space
Choosing between them. Forests: nearly tune-free, parallel, hard to overfit — a superb default. Boosting: higher ceiling, but sequential and tunable (depth, \(\nu\), rounds — use early stopping on a validation set). Real-world tabular problems: start with a forest baseline, then reach for LightGBM/XGBoost.
Practice & go deeper
Exercise: why deep trees for bagging but shallow trees for boosting?

Bagging reduces variance, so feed it low-bias/high-variance learners (deep trees) and let averaging clean up. Boosting reduces bias, so feed it high-bias/low-variance learners (stumps, depth 3–6) and let the sequence build complexity. Each method supplies exactly what its base learner lacks.

Chapter 11 · Part III — Nonlinear Models

Support Vector Machines

Not just any separating line — the one with maximum breathing room. Plus the most elegant trick in classical ML.

The widest street

Logistic regression accepts any boundary that separates the classes. SVMs demand the one that maximizes the margin — the empty street between the classes. With labels \(y_i \in \{-1, +1\}\), the street's width is \(2/\lVert w \rVert\), so maximizing margin means minimizing \(\lVert w \rVert\):

$$\min_{w,b} \; \tfrac{1}{2}\lVert w \rVert^2 \quad \text{s.t.} \quad y_i (w^\top x_i + b) \ge 1 \;\; \forall i$$

Only the points touching the street — the support vectors — determine the answer; delete everything else and the boundary doesn't move. Real data isn't separable, so the soft margin lets points violate the street for a price, which turns out to be exactly the hinge loss:

$$\min_{w,b} \;\; \tfrac{1}{2}\lVert w \rVert^2 \;+\; C \sum_i \max\big(0,\; 1 - y_i(w^\top x_i + b)\big)$$

Read the hinge: a point beyond the margin (\(y \cdot z \ge 1\)) costs nothing — unlike BCE, which keeps squeezing tiny gradients out of already-correct points. \(C\) is the bias–variance dial: large \(C\) = narrow street, punish every violation (variance); small \(C\) = wide tolerant street (bias).

The kernel trick

The dual form of this optimization touches data only through inner products \(x_i^\top x_j\). So replace that inner product with a kernel \(K(x_i, x_j)\) that equals an inner product in some huge implicit feature space — without ever visiting it:

$$K_{\text{RBF}}(a, b) = \exp\big(-\gamma \lVert a - b \rVert^2\big) \quad \text{(an infinite-dimensional feature space!)}$$

Result: linear machinery, wildly nonlinear boundaries. The RBF kernel behaves like similarity-weighted voting — close points influence you, far ones don't (\(\gamma\) sets "close"). Before deep learning took over, kernel-SVMs were the state of the art; the kernel was your hand-crafted representation. DL's pitch is: learn that representation instead.

The code, two ways

import numpy as np

def svm_fit(X, y, C=1.0, lr=0.01, epochs=1000):
    """Linear soft-margin SVM by subgradient descent. y in {-1,+1}."""
    n, d = X.shape
    w, b = np.zeros(d), 0.0
    for _ in range(epochs):
        margins = y * (X @ w + b)
        viol = margins < 1            # inside the street (or wrong side)
        # subgradient of  0.5||w||^2 + C * sum(hinge)
        grad_w = w - C * (y[viol, None] * X[viol]).sum(0)
        grad_b =    - C * y[viol].sum()
        w -= lr * grad_w / n
        b -= lr * grad_b / n
    return w, b

def rbf_kernel(A, B, gamma=0.5):
    d2 = ((A[:, None] - B[None]) ** 2).sum(-1)
    return np.exp(-gamma * d2)

# Kernel SVMs are solved in the dual (SMO algorithm) -- that's what
# libsvm / sklearn.svm.SVC do. The prediction becomes a weighted
# vote over support vectors:
#   f(x) = sum_i alpha_i * y_i * K(x_i, x) + b
import torch

d = X.shape[1]
w = torch.zeros(d, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
opt = torch.optim.SGD([w, b], lr=0.01)

lam = 1e-3                              # plays the role of 1/C
for _ in range(1000):
    margins = y * (X @ w + b)           # y in {-1, +1}
    hinge = torch.clamp(1 - margins, min=0).mean()
    loss  = lam * 0.5 * (w ** 2).sum() + hinge
    opt.zero_grad(); loss.backward(); opt.step()

# Notice what this is: a linear layer + a different loss function.
# swap hinge -> BCE and you have logistic regression;
# swap hinge -> MSE and you have linear regression.
# "Model architecture" and "loss" are independent dials.

# The kernel trick echoes in DL as: fixed kernel similarity vs.
# learned embeddings + dot products (attention!). Attention scores
# q . k are inner products in a *learned* feature space.
Gotchas. (1) Scale your features — margins are distances (Chapter 2 strikes again). (2) RBF-SVM has two coupled knobs, \(C\) and \(\gamma\): grid-search them jointly on a log scale. (3) Kernel SVMs scale poorly past ~50k samples (the kernel matrix is \(n \times n\)); linear SVMs scale fine. (4) SVM scores aren't probabilities — calibrate if you need them.
Practice & go deeper
Exercise: hinge loss vs BCE — both train linear classifiers. Name one practical behavioral difference.

Hinge goes exactly to zero beyond the margin, so confidently-correct points stop contributing — sparse solutions defined only by support vectors. BCE never reaches zero, so every point keeps pulling on \(w\) forever (and produces calibrated probabilities, which hinge doesn't).

Chapter 12 · Part IV — Unsupervised

K-Means & Gaussian Mixtures

No labels, no problem: find the groups the data was hiding all along.

K-Means: the two-step dance

Pick \(k\) centers; assign each point to its nearest center; move each center to the mean of its points; repeat. Lloyd's algorithm is coordinate descent on one objective — total within-cluster squared distance:

$$J = \sum_{j=1}^{k} \sum_{x_i \in C_j} \lVert x_i - \mu_j \rVert^2$$
  • Assign step minimizes \(J\) holding centers fixed (each point picks its closest \(\mu\)).
  • Update step minimizes \(J\) holding assignments fixed (the mean minimizes squared distance — Chapter 1!).

Each step can only lower \(J\), so it always converges — but only to a local minimum that depends on initialization. Fixes: run several times and keep the best \(J\), or seed smartly with k-means++ (spread initial centers out proportionally to squared distance). Choosing \(k\): plot \(J\) vs. \(k\) and look for the "elbow", or use silhouette scores — honest answer: it's part science, part judgment.

GMMs: K-Means grows up

K-Means makes hard, spherical assignments. A Gaussian mixture model says: data was generated from \(k\) Gaussians (each with its own mean and covariance), and each point has a soft responsibility — the probability it came from each component. The EM algorithm alternates:

$$\text{E-step: } r_{ij} = \frac{\pi_j \,\mathcal{N}(x_i \mid \mu_j, \Sigma_j)}{\sum_{l} \pi_l \,\mathcal{N}(x_i \mid \mu_l, \Sigma_l)} \qquad \text{M-step: refit each } (\pi_j, \mu_j, \Sigma_j) \text{ weighted by } r_{ij}$$

Squint and it's the same dance: E-step ≈ assign (softly), M-step ≈ update. K-Means is exactly the limit of EM as covariances shrink to zero — hard assignments fall out of infinitely confident Gaussians. EM itself is a general tool for "fit a model with hidden variables" and returns in VAEs and HMMs.

The code, two ways

import numpy as np

def kmeans(X, k=3, iters=100, seed=0):
    rng = np.random.default_rng(seed)
    centers = X[rng.choice(len(X), k, replace=False)]
    for _ in range(iters):
        # assign: (n,1,d)-(1,k,d) -> (n,k) squared distances
        d2 = ((X[:, None] - centers[None]) ** 2).sum(-1)
        labels = d2.argmin(1)
        # update: each center -> mean of its members
        new = np.array([X[labels == j].mean(0) if (labels == j).any()
                        else centers[j]              # empty cluster guard
                        for j in range(k)])
        if np.allclose(new, centers):
            break                                    # converged
        centers = new
    return centers, labels

def inertia(X, centers, labels):
    return ((X - centers[labels]) ** 2).sum()        # J, for the elbow plot

# k-means++ seeding: pick the first center at random, then pick
# each next center with probability proportional to its squared
# distance from the nearest already-chosen center.
import torch

def kmeans(X, k=3, iters=100, seed=0):
    g = torch.Generator().manual_seed(seed)
    centers = X[torch.randperm(len(X), generator=g)[:k]]
    for _ in range(iters):
        labels = torch.cdist(X, centers).argmin(1)      # assign
        new = torch.stack([
            X[labels == j].mean(0) if (labels == j).any() else centers[j]
            for j in range(k)
        ])
        if torch.allclose(new, centers):
            break
        centers = new                                    # update
    return centers, labels

# Why bother with a torch version? Scale. cdist + argmin on GPU
# clusters millions of points fast -- this exact loop is used to
# build vector-DB indexes (IVF) and to quantize embeddings.
#
# GMMs in practice: sklearn.mixture.GaussianMixture. In torch,
# EM's E-step is Normal(...).log_prob + softmax over components --
# the same pieces you met in Chapter 8.
Gotchas. (1) K-Means assumes round, similar-sized clusters — it will happily bisect a crescent (that's Chapter 13's job). (2) Scaling matters: it's distance-based (Chapter 2, again). (3) Empty clusters happen; guard for them. (4) The clusters it finds are whatever minimizes \(J\) — not necessarily the categories you had in mind. Unsupervised results always need a sanity check by a human.
Practice & go deeper
  • Do: implement k-means++ seeding on top of your kmeans, and compare inertia across 10 random runs vs. 10 seeded runs.
  • Do: cluster the RGB pixels of a photo with \(k{=}16\) to posterize it — image compression via K-Means, deeply satisfying and graphics-adjacent.
  • Watch: StatQuest — K-means clustering.
Exercise: why does the M-step move centers to the mean, specifically?

Because the objective is squared distance, and Chapter 1 proved the mean is the constant minimizing squared error. Swap the objective to absolute distance and centers become medians — that's k-medians, more robust to outliers.

Chapter 13 · Part IV — Unsupervised

Hierarchical Clustering & DBSCAN

Two answers to K-Means' blind spots: what if you don't know k, and what if clusters aren't round?

Hierarchical: don't choose k, build a family tree

Agglomerative clustering starts with every point as its own cluster and repeatedly merges the two closest clusters until one remains. The merge history forms a dendrogram — cut it at any height to get any number of clusters, chosen after seeing the structure. The whole personality of the method lives in how you define distance between clusters (the linkage):

$$\text{single: } \min_{a \in A, b \in B} d(a,b) \qquad \text{complete: } \max_{a \in A, b \in B} d(a,b) \qquad \text{average / Ward: in between}$$
  • Single linkage chains through narrow bridges — finds elongated shapes, but one noisy path can weld two real clusters together.
  • Complete/Ward produce compact, round-ish clusters — Ward (minimize within-cluster variance growth) is the sane default.

DBSCAN: clusters are dense regions, full stop

DBSCAN drops centroids entirely. Two knobs: a radius \(\varepsilon\) and a count min_pts. A point with ≥ min_pts neighbors within \(\varepsilon\) is a core point; clusters are flood-fills of core points reaching each other; anything reachable from a core joins as a border point; everything else is labeled noise (−1). Consequences: finds crescents, rings, and blobs of any shape; discovers \(k\) on its own; has a built-in outlier detector. The price: struggles when clusters have very different densities, and \(\varepsilon\) needs care (the k-distance elbow plot helps).

The code, two ways

import numpy as np

def dbscan(X, eps=0.5, min_pts=5):
    """Teaching version: O(n^2) memory, honest logic."""
    n = len(X)
    d = np.sqrt(((X[:, None] - X[None]) ** 2).sum(-1))
    neighbors = [np.flatnonzero(d[i] <= eps) for i in range(n)]
    labels, cluster = np.full(n, -1), 0        # -1 = noise / unvisited

    for i in range(n):
        if labels[i] != -1 or len(neighbors[i]) < min_pts:
            continue                            # claimed, or not core
        labels[i] = cluster                     # new cluster seed
        queue = list(neighbors[i])              # flood-fill (BFS)
        while queue:
            j = queue.pop()
            if labels[j] == -1:
                labels[j] = cluster             # border or core joins
                if len(neighbors[j]) >= min_pts:
                    queue.extend(neighbors[j])  # core: keep expanding
        cluster += 1
    return labels                               # -1s that remain = noise

def single_linkage(X, k=2):
    """Naive agglomerative clustering down to k clusters."""
    clusters = [[i] for i in range(len(X))]
    d = np.sqrt(((X[:, None] - X[None]) ** 2).sum(-1))
    while len(clusters) > k:
        best, pair = np.inf, None
        for a in range(len(clusters)):
            for b in range(a + 1, len(clusters)):
                dist = min(d[i, j] for i in clusters[a]
                                   for j in clusters[b])
                if dist < best:
                    best, pair = dist, (a, b)
        a, b = pair
        clusters[a] += clusters.pop(b)          # merge closest pair
    return clusters
# Production: scipy.cluster.hierarchy.linkage + dendrogram plots.
import torch

# Both algorithms are built on one object you already know how
# to make fast: the pairwise distance matrix.
D = torch.cdist(X, X)                       # (n, n), GPU-ready

# DBSCAN's expensive part, vectorized:
eps, min_pts = 0.5, 5
neighbor_mask  = D <= eps                   # (n, n) boolean
neighbor_count = neighbor_mask.sum(1)       # per-point density
is_core        = neighbor_count >= min_pts  # core points in one line

# The flood-fill itself stays plain Python (it's inherently
# sequential), but with the mask precomputed on GPU it's cheap.

# Practical stack for clustering at scale:
#   sklearn.cluster.DBSCAN / AgglomerativeClustering  (CPU, solid)
#   HDBSCAN -- density clustering w/ varying densities; the modern
#              default for clustering *embeddings* from deep models
# Typical modern pipeline: encoder -> embeddings -> UMAP -> HDBSCAN.
# Classical clustering didn't die; it moved into latent space.
Which one, when? Round blobs + known rough \(k\) → K-Means. Need the full structure tree or small data → hierarchical (Ward). Weird shapes, unknown \(k\), outliers present → DBSCAN/HDBSCAN. All of them: scale features first.
Practice & go deeper
  • Do: generate two interleaved crescents (sklearn.datasets.make_moons) and watch K-Means fail where your DBSCAN succeeds — the single most instructive plot in clustering.
  • Read: scikit-learn clustering guide — the comparison chart of every algorithm on every dataset shape is a keeper.
Exercise: DBSCAN labels a point noise, but it sits within \(\varepsilon\) of a cluster member. How?

Its neighbor is a border point, not a core point. Border points belong to clusters but don't recruit — expansion only continues through cores. Density has to be locally high to propagate membership.

Chapter 14 · Part IV — Unsupervised

PCA & Dimensionality Reduction

Find the few directions your data actually varies in, and throw the rest away — with a receipt.

Directions of maximum variance

High-dimensional data usually lives near a much lower-dimensional sheet: pixel intensities co-vary, survey answers correlate, sensors are redundant. PCA asks: along which unit direction \(w\) does the (centered) data vary most?

$$\max_{\lVert w \rVert = 1} \; w^\top \Sigma \, w \qquad \text{where } \Sigma = \tfrac{1}{n-1} X_c^\top X_c \;\;\text{(covariance matrix)}$$

Lagrange multipliers turn this into \(\Sigma w = \lambda w\) — the answer is an eigenvector of the covariance matrix, and its eigenvalue \(\lambda\) is exactly the variance captured. Eigenvectors sorted by eigenvalue give principal component 1, 2, 3, … — orthogonal axes, each capturing what's left. Keep the top \(m\), project, and you've compressed \(d\) dimensions to \(m\) with a receipt: the explained variance ratio tells you exactly what fraction of the spread you kept.

In practice nobody eigendecomposes \(\Sigma\) — the SVD of centered \(X\) gives the same components more stably: \(X_c = U S V^\top\), where rows of \(V^\top\) are the principal directions and \(S^2/(n{-}1)\) are the variances.

What it's for — and its famous cousins

  • Compression & denoising: small directions are often noise; dropping them can help downstream models.
  • Rescuing distance-based models: PCA before KNN/K-Means blunts the curse of dimensionality (Chapter 7).
  • Visualization: project to 2D and look. For nonlinear structure, t-SNE/UMAP preserve neighborhoods instead of variance — gorgeous cluster maps, but distances between far-apart blobs are not meaningful; never feed their output to downstream models casually.
  • Whitening: divide projections by \(\sqrt{\lambda}\) to equalize variance — a spiritual ancestor of BatchNorm.

The code, two ways

import numpy as np

def pca(X, n_components=2):
    Xc = X - X.mean(0)                    # center. ALWAYS center.
    U, S, Vt = np.linalg.svd(Xc, full_matrices=False)

    components = Vt[:n_components]         # principal directions (m, d)
    var  = S ** 2 / (len(X) - 1)           # variance along each PC
    ratio = var / var.sum()                # the "receipt"

    Z = Xc @ components.T                  # projected coords (n, m)
    return Z, components, ratio[:n_components]

def reconstruct(Z, components, mean):
    return Z @ components + mean           # back to d dims (lossy)

# Choosing m: cumulative ratio -- keep enough PCs to explain
# e.g. 95% of variance:
#   m = np.searchsorted(np.cumsum(ratio), 0.95) + 1
import torch

Xc = X - X.mean(0)

# Route 1: exact SVD (same math as the NumPy tab)
U, S, Vh = torch.linalg.svd(Xc, full_matrices=False)
Z = Xc @ Vh[:2].T

# Route 2: randomized -- fast for big matrices
U, S, V = torch.pca_lowrank(Xc, q=2)
Z = Xc @ V[:, :2]

# The DL bridge is exact, not an analogy:
# a LINEAR autoencoder with tied weights,
#     encode: z = W x      decode: x_hat = W^T z
# trained on reconstruction MSE, learns the PCA subspace.
# Nonlinear autoencoders (add ReLUs) generalize PCA to curved
# manifolds -- and embeddings from deep encoders are the modern
# answer to "reduce my dimensions". PCA is their linear ancestor,
# and still the first thing to try.
Gotchas. (1) Forgetting to center makes PC1 point at the data's mean, not its spread — the classic bug. (2) Standardize first when features have different units, or big-variance features own the components. (3) PCA maximizes variance, not class separation — the discriminative direction can hide in a small PC (LDA is the supervised alternative). (4) Fit PCA on train only — it's a transform like any scaler (Chapter 2's leakage rule).
Practice & go deeper
  • Do: PCA on MNIST pixels — plot digits in 2D, then reconstruct images from 30 PCs. Watching digits blur back is the best PCA intuition there is.
  • Read: "How to Use t-SNE Effectively" (Distill) — required reading before trusting any embedding plot.
  • Reference: MML Book, Chapter 10 — the full PCA derivation with your math foundations.
Exercise: PCA to 50 dims then KNN often beats KNN on 784 raw MNIST pixels. Two reasons why?

(1) The dropped components are mostly pixel noise, so distances become cleaner — mild denoising. (2) The curse of dimensionality relaxes: in 50 dims, "nearest" is meaningful again. Bonus: 15× less compute per distance.

Chapter 15 · Part V — The Bridge

From Classical ML to Deep Learning

You've been doing deep learning all along — you just didn't have hidden layers yet.

The lineage, in one paragraph

The perceptron (1958) is a linear classifier trained by nudging weights toward misclassified points. Give it a probabilistic output and a proper loss and it becomes logistic regression (Chapter 6). Stack two of them with a nonlinearity between and it becomes an MLP — and the "errors pushed back through inputs" gradient you derived in Chapters 4 and 6 becomes, applied recursively through layers via the chain rule, backpropagation. Nothing was thrown away; layers were added.

$$\text{perceptron update: } w \leftarrow w + y_i x_i \;\text{ (on mistakes)} \qquad\quad \text{MLP: } \hat{y} = W_2\, \phi(W_1 x + b_1) + b_2$$

The deep shift is where features come from. Classical ML: you engineer features (Chapter 2), pick kernels (Chapter 11), reduce dimensions (Chapter 14), and a simple model does the last step. Deep learning: the hidden layers learn the representation, and the final layer is — literally — logistic/softmax regression on learned features. Every classifier net you've trained is Chapter 6 sitting on top of a learned Chapter 2.

Your classical toolkit, relabeled

Classical conceptWhere it lives in DL
Feature scaling (Ch 2)Input normalization, BatchNorm / LayerNorm
One-hot encoding (Ch 2)Embedding layers (learned dense encodings)
Train/val/test, overfitting (Ch 3)Unchanged. Verbatim. Forever.
Gradient descent (Ch 4)SGD → momentum → Adam/AdamW
L2 regularization (Ch 5)Weight decay; siblings: dropout, early stopping, augmentation
Softmax regression (Ch 6)The output layer of every classifier
KNN retrieval (Ch 7)Vector databases, RAG, contrastive embeddings
Log-space probability (Ch 8)log_softmax + NLL, numerical stability everywhere
Boosting = descent in function space (Ch 10)The same gradient, one abstraction up
Kernels as similarity (Ch 11)Attention: dot products in learned feature spaces
PCA (Ch 14)Linear autoencoders; latent spaces in general

The code, two ways

import numpy as np

def perceptron_fit(X, y, epochs=20):
    """Rosenblatt, 1958. y in {-1, +1}."""
    w, b = np.zeros(X.shape[1]), 0.0
    for _ in range(epochs):
        for xi, yi in zip(X, y):
            if yi * (xi @ w + b) <= 0:      # misclassified
                w += yi * xi                 # nudge toward correct side
                b += yi
    return w, b

# Guaranteed to converge IF the data is linearly separable.
# If not, it thrashes forever -- and that limitation (XOR!)
# froze neural-net research for a decade. The fix was depth:
# hidden layers make XOR trivially separable in learned space.

# You've already written the rest of the bridge yourself:
# Karpathy's micrograd (Zero to Hero, episode 1) is exactly
# "Chapter 4's gradient, applied recursively via the chain rule."
import torch.nn as nn

# Logistic regression IS a one-layer neural network:
clf = nn.Sequential(
    nn.Linear(d, 1),                # Chapter 6, verbatim
)                                    # + BCEWithLogitsLoss

# Add ONE hidden layer, and it stops being "classical":
mlp = nn.Sequential(
    nn.Linear(d, 32), nn.ReLU(),    # learned feature engineering
    nn.Linear(32, 1),               # ...then Chapter 6 again
)

# That's the whole difference. The first Linear+ReLU is doing the
# job you did by hand in Chapter 2 and via kernels in Chapter 11.

# When classical still wins (knowing this is a superpower):
#  * tabular data -- boosted trees beat nets shockingly often
#  * small data   -- nets overfit; ridge/NB/forests shine
#  * interpretability, latency, or a baseline in an afternoon
# Rule of thumb: always build the classical baseline FIRST.
# If your net can't beat ridge regression, the net is lying to you.
Where to next. You're now standing exactly where your gradient/descent roadmap picks up: MLPs → backprop from scratch (micrograd) → CNNs (CS231n) → sequence models and Transformers (CS224n). Everything there will rhyme with something in this book — that's the point of having read it.
Practice & go deeper
  • Do: implement the perceptron, show it fails on XOR, then solve XOR with the 2-layer MLP — the single most historically meaningful exercise in ML.
  • Watch: Karpathy — micrograd (Zero to Hero #1) — rewatch it after this book; it will feel different.
  • Compete: one full Kaggle tabular competition where you pit your classical stack against a small net. Let the leaderboard teach you when each wins.
Exercise: why can't a perceptron learn XOR, in one geometric sentence?

XOR's positive points sit on opposite corners of a square, so no single line puts both positives on one side and both negatives on the other — and a perceptron can only draw one line.

closed/form · 15 chapters · built for Sai

When every tick in the sidebar is green, open gradient/descent and keep going. ∎