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.
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:
The three flavors you'll meet, in the order this book covers them:
| Paradigm | You have | You want | Chapters |
|---|---|---|---|
| Supervised | inputs \(x\) + labels \(y\) | predict \(y\) for new \(x\) | 4–11 |
| Unsupervised | inputs \(x\) only | find structure (groups, axes) | 12–14 |
| Reinforcement | an environment + rewards | a policy of actions | Sutton & 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:
- 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
Practice & go deeper
- Watch: StatQuest — Machine Learning playlist (the bias/variance video especially).
- Read: ISL, Chapter 2 — the best 40 pages of intro-ML ever written, free PDF.
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:
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:
log1ptames 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.
Practice & go deeper
- Do: Kaggle Titanic — 90% of the work is exactly this chapter.
- Read: scikit-learn preprocessing guide — see how the pros API-ify fit/transform.
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:
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":
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 + | TP | FN (missed!) |
| Actual − | FP (false alarm) | TN |
- 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.
Practice & go deeper
- Read: scikit-learn cross-validation guide.
- Interactive: MLU-Explain — beautiful visual essays on train/test splits, precision/recall, ROC.
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:
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:
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\):
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:
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.
Practice & go deeper
- Do: Kaggle House Prices — the canonical regression playground.
- Read: CS229 notes, §1 — Andrew Ng's derivation, including the probabilistic view.
- Read: ISL Chapter 3 — inference-side details (p-values, confidence intervals).
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:
\(\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:
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.)
Practice & go deeper
- Watch: StatQuest — Ridge Regression and the Lasso follow-up.
- Read: ISL §6.2 — the diamond-vs-circle constraint picture, properly drawn.
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:
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:
It's convex, and its gradient collapses into something you already know:
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.
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
- Do: build a spam classifier on UCI Spambase with your NumPy implementation.
- Read: CS229 notes, §2 — logistic regression as a GLM.
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?
\(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.
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:
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:
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.
Practice & go deeper
- Do: build a multinomial NB spam filter on SMS Spam Collection — counts, smoothing, logs. Deeply satisfying.
- Watch: StatQuest — Naive Bayes.
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:
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:
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.
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\):
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:
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
Practice & go deeper
- Do: any active Kaggle tabular competition — read winning solutions; they're ~all gradient boosting + feature engineering.
- Watch: StatQuest — Gradient Boost series.
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\):
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:
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:
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.
Practice & go deeper
- Read: CS229 notes, kernel methods chapter — the full dual derivation is worth one careful pass.
- Watch: MIT 6.034 — Patrick Winston on SVMs — a legendary lecture.
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:
- 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:
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.
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):
- 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.
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?
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.
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.
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 concept | Where 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.
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.