Ch. 00

Math & Python Prereqs

Linear algebra, calculus, probability, NumPy. The vocabulary every later chapter assumes.

linear-algebracalculusprobabilitynumpy

FIG 00 · Explainer video


The whole curriculum runs on five mathematical objects and one programming environment. The objects are vectors, matrices, derivatives, probabilities, and entropies. The environment is Python 3.10+ with NumPy, PyTorch, pandas, and a Jupyter kernel. If you can multiply two matrices in your head for shape correctness, write the chain rule for f(g(x))f(g(x)) without looking it up, compute E[X]E[X] for a coin flip, and import numpy as np from muscle memory, this chapter is a 90-minute skim. If those words feel slippery, this is the chapter where they stop being slippery. The math is not deep. It is the same six tools used over and over for the next 26 chapters, and the goal here is to make those tools so cheap to reach for that you stop noticing them.


FIG 00.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Multiply two matrices in NumPy and predict the output shape before running the code.
  • Take the gradient of $f(\mathbf{w}) = \frac{1}{2}\|X\mathbf{w} - \mathbf{y}\|^2$ on paper and verify it matches torch.autograd.grad.
  • Compute the entropy of a discrete distribution, and explain why cross-entropy is the loss function for almost every classifier you will train.
  • Write a vectorized NumPy function that beats its Python-for-loop equivalent by tens to hundreds of times on a length-10000 array (exact multiple varies by machine; the assessment only requires clearing a comfortable floor).
  • Set up a reproducible Python environment with venv, pip, torch, and a Jupyter kernel on either Colab or your own machine.
  • Read a corpus reference like 08-geron-notebooks/math_linear_algebra §determinant and know whianother chapter words inside that file are the load-bearing ones.

FIG 00.2 · What you need first

  • High-school algebra (you remember what $\sum$ and $\prod$ mean).
  • Basic Python. You have written for i in range(10): print(i) at some point. No more is assumed.
  • A Google account, or a laptop with 8GB+ RAM. The chapter's code runs free on Colab.

This chapter has no inbound prereqs from inside obvix-learn. It is the root of the DAG. If you have already taken a linear algebra course and used NumPy for a week, skim sections 2–7 for vocabulary, then go to section 9 for the practical setup.


FIG 00.3.1

Why this chapter exists

The math you need for the first 14 chapters of obvix-learn is the math you need for one specific operation. That operation is on a parameterized function evaluated against data. Every classical ML algorithm, every neural network, every transformer, every diffusion model, every RLHF policy, every mech-interp probe, is some variant of:

  1. Define a function fθ:XYf_\theta: \mathcal{X} \to \mathcal{Y} parameterized by θ\theta.
  2. Define a loss L(θ;D)L(\theta; \mathcal{D}) that scores fθf_\theta on data D\mathcal{D}.
  3. Compute θL\nabla_\theta L.
  4. Update θθηθL\theta \leftarrow \theta - \eta \nabla_\theta L.

That's it. The reason you need linear algebra is that θ\theta is almost always a collection of matrices, and θL\nabla_\theta L is a matrix-shaped object you compute by chain rule. The reason you need is that the loss LL usually has the form logpθ(yx)-\log p_\theta(y \mid x), a negative log-likelihood. The reason you need information theory is to know why is the right loss to minimize when you don't know the true data distribution.

Everything in this chapter exists to make those four bullet points readable.

FIG 00.3.2

Python setup: the only environment you'll need

You have two reasonable options for running every code block in this curriculum: Google Colab (zero install, free GPU access, browser-based) or local Python (more control, faster iteration, no quota limits). Pick Colab for the first ten chapters. Pick local for the rest.

Colab path. Go to colab.research.google.com. Create a new notebook. Type the following in the first cell and run it:

Python
import sys, torch, numpy as np, pandas as pd, sklearn
print(f"Python {sys.version.split()[0]}")
print(f"PyTorch {torch.__version__}, CUDA available: {torch.cuda.is_available()}")
print(f"NumPy {np.__version__}, pandas {pd.__version__}, sklearn {sklearn.__version__}")

You should see Python 3.10+, PyToranother chapter.x, True if you selected the T4 runtime. Done.

Local path (Linux/macOS; Windows users add wsl or use Anaconda):

Shell
# 1. Create a project venv (use Python 3.10 or newer)
python3 -m venv ~/.venvs/obvix
source ~/.venvs/obvix/bin/activate

# 2. Upgrade pip
pip install --upgrade pip

# 3. Install the core stack
pip install numpy pandas matplotlib scikit-learn jupyter

# 4. PyTorch — pick the right index URL for your CUDA version
#    For CPU only:
pip install torch --index-url https://download.pytorch.org/whl/cpu
#    For CUDA 12.1:
# pip install torch --index-url https://download.pytorch.org/whl/cu121

# 5. Test
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

If you want notebooks locally: jupyter lab. If you want a tighter editor: VS Code with the Python extension opens .ipynb files natively. If you want package isolation per project: uv from Astral is the fastest tool and the one I use now.

FIG 00.3.3

NumPy in 30 lines

NumPy is the array library every other Python ML library is built on. The only object you need to know is np.ndarray. It is a multi-dimensional, homogeneous, typed buffer with a shape and a .

Python
import numpy as np

# Construction
a = np.array([1, 2, 3, 4])              # shape (4,), dtype int64
b = np.zeros((3, 4))                    # shape (3, 4), dtype float64
c = np.ones((2, 3), dtype=np.float32)   # shape (2, 3), dtype float32
d = np.arange(0, 10, 2)                 # [0, 2, 4, 6, 8]
e = np.linspace(0, 1, 5)                # [0., 0.25, 0.5, 0.75, 1.]
r = np.random.randn(3, 4)               # standard normal, shape (3, 4)

# Shape and dtype
r.shape       # (3, 4)
r.dtype       # float64
r.ndim        # 2
r.size        # 12

# Indexing and slicing
r[0]          # first row, shape (4,)
r[:, 0]       # first column, shape (3,)
r[1:3, :2]    # rows 1-2, cols 0-1, shape (2, 2)
r[r > 0]      # boolean mask, returns 1D array of all positives

# Reshape (no copy, just stride change)
r.reshape(4, 3)
r.reshape(-1)         # flatten to 1D
r.T                   # transpose, shape (4, 3)

# Arithmetic: element-wise by default
a + 1                 # broadcast scalar
r + r                 # element-wise
r * 2                 # element-wise
r ** 2                # element-wise

# Matrix multiplication: explicit
A = np.random.randn(3, 4)
B = np.random.randn(4, 5)
C = A @ B             # shape (3, 5)
# Equivalent: np.matmul(A, B), np.dot(A, B) for 2D

# Reductions
r.sum()               # scalar
r.sum(axis=0)         # shape (4,) — sum each column
r.sum(axis=1)         # shape (3,) — sum each row
r.mean(), r.std(), r.max(), r.argmax()

The two ideas that trip up beginners: broadcasting and the axis .

Broadcasting. When you add a (3, 4) array to a (4,) array, NumPy stretches the second to (3, 4) by repeating along axis 0. The full rule: dimensions of size 1 (or absent) are stretched to match. (3, 1) + (1, 4) = (3, 4). This is the operation that makes vectorized code short. It is also the operation that produces silent shape bugs when you forget that (3,) and (3, 1) are different.

Axis. r.sum(axis=0) means "collapse axis 0", returning an array with one fewer dim along that axis. Get this wrong and your loss curve looks fine but the is for the wrong quantity. The fastest debugging trick in machine learning: print(x.shape) everywhere.

FIG 00.3.4

Linear algebra: the four operations you actually use

The 800-page linear algebra textbook covers eight semesters of material. Machine learning uses about four operations from it. Here they are.

One piece of notation first, since it runs through everything below: Rn\mathbb{R}^n means a vector of nn real numbers (a list of length nn), and Rm×n\mathbb{R}^{m \times n} means a matrix with mm rows and nn columns. The shape is the whole point — reading "ARm×nA \in \mathbb{R}^{m \times n}" as "AA is mm-by-nn" is most of what you need.

Vector addition and scalar multiplication

For x,yRn\mathbf{x}, \mathbf{y} \in \mathbb{R}^n and αR\alpha \in \mathbb{R}:

x+yRn,αxRn\mathbf{x} + \mathbf{y} \in \mathbb{R}^n, \quad \alpha \mathbf{x} \in \mathbb{R}^n

. This is 's update step: wt+1=wtηgt\mathbf{w}_{t+1} = \mathbf{w}_t - \eta \mathbf{g}_t.

Inner (dot) product

For x,yRn\mathbf{x}, \mathbf{y} \in \mathbb{R}^n:

xy=i=1nxiyiR\mathbf{x}^\top \mathbf{y} = \sum_{i=1}^n x_i y_i \in \mathbb{R}

The most-used operation in ML. A neuron's pre-activation is wx+b\mathbf{w}^\top \mathbf{x} + b. An score is qk\mathbf{q}^\top \mathbf{k}. is xyxy\frac{\mathbf{x}^\top \mathbf{y}}{\|\mathbf{x}\|\|\mathbf{y}\|}.

Python
x = np.array([1., 2., 3.])
y = np.array([4., 5., 6.])
x @ y          # 32.0
np.dot(x, y)   # same
(x * y).sum()  # same, slower path

Matrix-vector and matrix-matrix product

For ARm×nA \in \mathbb{R}^{m \times n}, xRn\mathbf{x} \in \mathbb{R}^n:

(Ax)i=j=1nAijxj    AxRm(A\mathbf{x})_i = \sum_{j=1}^n A_{ij} x_j \quad \implies \quad A\mathbf{x} \in \mathbb{R}^m

For ARm×kA \in \mathbb{R}^{m \times k}, BRk×nB \in \mathbb{R}^{k \times n}:

(AB)ij=p=1kAipBpj    ABRm×n(AB)_{ij} = \sum_{p=1}^k A_{ip} B_{pj} \quad \implies \quad AB \in \mathbb{R}^{m \times n}

The thing to remember: inner dimensions must match, outer dimensions become the output shape. (3, 4) @ (4, 5) = (3, 5). (3, 4) @ (5, 4) is an error.

When two shapes almost line up but the inner dimensions are swapped, the fix is the transpose: AA^\top (written A.T in NumPy) flips a matrix's rows and columns, so an m×nm \times n matrix becomes n×mn \times m with Aij=AjiA^\top_{ij} = A_{ji}. It is the standard way to rotate one operand so the inner dimensions meet.

A fully-connected layer is one matrix-matrix product plus a broadcasted vector add:

Y=XW+b\mathbf{Y} = X W^\top + \mathbf{b}

where XRB×dinX \in \mathbb{R}^{B \times d_{\text{in}}}, WRdout×dinW \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}}, bRdout\mathbf{b} \in \mathbb{R}^{d_{\text{out}}}, YRB×dout\mathbf{Y} \in \mathbb{R}^{B \times d_{\text{out}}}.

Python
B, d_in, d_out = 32, 100, 50
X = np.random.randn(B, d_in)
W = np.random.randn(d_out, d_in)
b = np.random.randn(d_out)

Y = X @ W.T + b      # (32, 100) @ (100, 50) + (50,) -> (32, 50)
assert Y.shape == (B, d_out)

Norms

The L2L^2 (Euclidean) norm of a vector:

x2=ixi2=xx\|\mathbf{x}\|_2 = \sqrt{\sum_i x_i^2} = \sqrt{\mathbf{x}^\top \mathbf{x}}

The L1L^1 norm (Manhattan):

x1=ixi\|\mathbf{x}\|_1 = \sum_i |x_i|

The LpL^p norm in general:

xp=(ixip)1/p\|\mathbf{x}\|_p = \left(\sum_i |x_i|^p\right)^{1/p}

You use L2L^2 for distances and energies, L1L^1 for sparsity penalties, L=maxixiL^\infty = \max_i |x_i| for worst-case bounds. The Frobenius norm of a matrix is AF=ijAij2\|A\|_F = \sqrt{\sum_{ij} A_{ij}^2}. It is the L2L^2 norm of the flattened matrix.

Python
np.linalg.norm(x)           # L2
np.linalg.norm(x, ord=1)    # L1
np.linalg.norm(x, ord=np.inf)  # Linf
np.linalg.norm(A)           # Frobenius for 2D

Two operations to know about but not memorize.

Inverse and pseudo-inverse. For square non-singular AA, the inverse A1A^{-1} satisfies AA1=IAA^{-1} = I. You almost never compute it explicitly in ML; you solve Ax=bA\mathbf{x} = \mathbf{b} via np.linalg.solve(A, b) instead. For non-square AA, the Moore-Penrose pseudo-inverse A+A^+ generalizes this. It shows up in the closed-form solution to least squares: w^=(XX)1Xy\hat{\mathbf{w}} = (X^\top X)^{-1} X^\top \mathbf{y}, which you basically never compute that way in practice (you use SGD or sklearn.linear_model.LinearRegression).

Eigendecomposition and SVD. A=QΛQ1A = Q \Lambda Q^{-1} when AA is symmetric. A=UΣVA = U \Sigma V^\top for any matrix. These power dimensionality reduction (PCA), and they show up in transformer training as the singular value spectrum of matrices. another chapter is the chapter where they earn their keep.

FIG 00.3.5

Calculus: derivatives that scale to vectors

ML uses three calculus tools: scalar derivatives, the chain rule, and vector gradients. Everything else (Taylor series, integration by parts, the implicit function theorem) lives outside the curriculum.

Scalar derivatives

dfdx=limh0f(x+h)f(x)h\frac{df}{dx} = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}

The rules you need:

FunctionDerivative
cc (constant)00
xnx^nnxn1n x^{n-1}
exe^xexe^x
lnx\ln x1/x1/x
sinx,cosx\sin x, \cos xcosx,sinx\cos x, -\sin x
σ(x)=11+ex\sigma(x) = \frac{1}{1 + e^{-x}}σ(x)(1σ(x))\sigma(x)(1 - \sigma(x))

The σ(x)=11+ex\sigma(x) = \frac{1}{1 + e^{-x}} is the squashing function that maps any real number into (0,1)(0, 1), which is why it turns a score into a for binary classification. Its derivative shows up so often it's worth memorizing in that factored form — and note that the factor σ(x)(1σ(x))\sigma(x)(1 - \sigma(x)) goes to zero as the output saturates toward 0 or 1, the fact that section 7 leans on to explain why squared error stalls there but does not.

Chain rule

If y=f(u)y = f(u) and u=g(x)u = g(x), then

dydx=dydududx\frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx}

This is the rule. is the chain rule applied carefully. The careful part is that you have to remember what activations you saw on the way in.

Example. y=(3x+2)4y = (3x + 2)^4. Let u=3x+2u = 3x + 2. Then dydu=4u3=4(3x+2)3\frac{dy}{du} = 4u^3 = 4(3x+2)^3 and dudx=3\frac{du}{dx} = 3, so dydx=12(3x+2)3\frac{dy}{dx} = 12(3x+2)^3.

Partial derivatives and gradients

If f:RnRf: \mathbb{R}^n \to \mathbb{R}, the partial derivative with respect to xix_i holds all other coordinates fixed:

fxi\frac{\partial f}{\partial x_i}

The is the vector of all partials:

f(x)=(fx1,,fxn)Rn\nabla f(\mathbf{x}) = \left(\frac{\partial f}{\partial x_1}, \ldots, \frac{\partial f}{\partial x_n}\right)^\top \in \mathbb{R}^n

Three facts about the gradient that you need:

  1. Direction of steepest ascent. f\nabla f points in the direction ff increases fastest, locally.
  2. Zero at minima. If x\mathbf{x}^* is a local minimum and ff is smooth, f(x)=0\nabla f(\mathbf{x}^*) = \mathbf{0}.
  3. Linear in ff. (f+g)=f+g\nabla (f + g) = \nabla f + \nabla g. (αf)=αf\nabla (\alpha f) = \alpha \nabla f.

Common gradients in ML

Each identity below is derived the same mechanical way: write the scalar function out as a sum over coordinates, take the ordinary partial /wi\partial / \partial w_i holding the rest fixed, then stack those nn partials back into a vector — that stacked vector is the gradient. You never need a new rule; it is the scalar chain rule applied one coordinate at a time. The four results worth memorizing, so you don't redo the bookkeeping each time:

For wRn\mathbf{w} \in \mathbb{R}^n, aRn\mathbf{a} \in \mathbb{R}^n, ARn×nA \in \mathbb{R}^{n \times n}:

w(aw)=a\nabla_{\mathbf{w}} (\mathbf{a}^\top \mathbf{w}) = \mathbf{a} w(ww)=2w\nabla_{\mathbf{w}} (\mathbf{w}^\top \mathbf{w}) = 2\mathbf{w} w(wAw)=(A+A)w\nabla_{\mathbf{w}} (\mathbf{w}^\top A \mathbf{w}) = (A + A^\top)\mathbf{w} wXwy2=2X(Xwy)\nabla_{\mathbf{w}} \|X\mathbf{w} - \mathbf{y}\|^2 = 2 X^\top (X\mathbf{w} - \mathbf{y})

The last one is the gradient of the least-squares loss. Setting it to zero gives the normal equations XXw=XyX^\top X \mathbf{w} = X^\top \mathbf{y}.

Verifying gradients with autograd

When in doubt, check your hand-derived gradient against PyTorch.

Python
import torch

# Define a simple quadratic
def f(w, X, y):
    return ((X @ w - y) ** 2).sum() / 2

# Random data
torch.manual_seed(0)
X = torch.randn(10, 3)
y = torch.randn(10)
w = torch.randn(3, requires_grad=True)

# Forward + backward
loss = f(w, X, y)
loss.backward()

# Hand-derived gradient: X^T (Xw - y)
manual = X.T @ (X @ w - y)
print("autograd:", w.grad)
print("manual:  ", manual)
assert torch.allclose(w.grad, manual, atol=1e-5)

If assert fails, your math is wrong. If it passes, you can trust the autograd.

FIG 00.3.6

Probability: distributions, expectations, conditioning

shows up in three places: defining loss functions, modeling stochastic optimizers, and reasoning about sampling.

Random variables and distributions

A random variable XX is a function from a sample space Ω\Omega to a value space. We almost never write the sample space. What we work with is the distribution p(X)p(X).

Discrete: p(X=x)0p(X = x) \geq 0 and xp(X=x)=1\sum_x p(X = x) = 1.

Continuous: p(x)0p(x) \geq 0 (a density, can be >1>1) and p(x)dx=1\int p(x)\, dx = 1. Probabilities are integrals over intervals.

The distributions you'll meet in the first ten chapters:

NameSupportParametersWhy it shows up
Bernoulli{0,1}\{0, 1\}ppBinary classification target; mask (each unit kept/dropped by its own coin flip)
Categorical{1,,K}\{1, \ldots, K\}π\boldsymbol{\pi}Multi-class target
GaussianR\mathbb{R}μ,σ2\mu, \sigma^2, noise models
Uniform[a,b][a, b]a,ba, bNo-opinion default; random init (no pull toward any value)

Expectation

The expected value of XX under pp:

  • Discrete: E[X]=xxp(x)E[X] = \sum_x x \cdot p(x)
  • Continuous: E[X]=xp(x)dxE[X] = \int x \cdot p(x)\, dx

Linearity. E[X+Y]=E[X]+E[Y]E[X + Y] = E[X] + E[Y] always. E[αX]=αE[X]E[\alpha X] = \alpha E[X]. This is the one rule you'll use ten times per derivation.

Variance. Var(X)=E[(XE[X])2]=E[X2]E[X]2\text{Var}(X) = E[(X - E[X])^2] = E[X^2] - E[X]^2.

Joint, conditional, marginal

Given a joint p(X,Y)p(X, Y):

  • Marginal: p(X)=yp(X,Y=y)p(X) = \sum_y p(X, Y = y) (or integrate for continuous)
  • Conditional: p(YX)=p(X,Y)p(X)p(Y \mid X) = \frac{p(X, Y)}{p(X)}
  • Independence: XYX \perp Y iff p(X,Y)=p(X)p(Y)p(X, Y) = p(X) p(Y)

Bayes' rule

p(YX)=p(XY)p(Y)p(X)p(Y \mid X) = \frac{p(X \mid Y)\, p(Y)}{p(X)}

Reading: posterior = likelihood × prior / evidence. The evidence p(X)=yp(XY=y)p(Y=y)p(X) = \sum_y p(X \mid Y = y) p(Y = y) is just the normalizer.

Bayes' rule is the basis of every probabilistic classifier you'll meet: naive Bayes, Gaussian discriminant analysis, logistic regression's probabilistic interpretation, Bayesian neural networks. It also justifies why we train classifiers to output p(YX)p(Y \mid X) and pick argmaxyp(Y=yX)\arg\max_y p(Y = y \mid X) as the prediction.

Maximum likelihood: the principle behind every loss

Given data D={(xi,yi)}i=1N\mathcal{D} = \{(x_i, y_i)\}_{i=1}^N and a parameterized model pθ(yx)p_\theta(y \mid x), the maximum likelihood estimate is:

θ^MLE=argmaxθi=1Npθ(yixi)\hat{\theta}_{\text{MLE}} = \arg\max_\theta \prod_{i=1}^N p_\theta(y_i \mid x_i)

Products of probabilities . We take logs:

θ^MLE=argmaxθi=1Nlogpθ(yixi)=argminθ[i=1Nlogpθ(yixi)]\hat{\theta}_{\text{MLE}} = \arg\max_\theta \sum_{i=1}^N \log p_\theta(y_i \mid x_i) = \arg\min_\theta \left[ -\sum_{i=1}^N \log p_\theta(y_i \mid x_i) \right]

That last expression is negative log-likelihood, the most common in deep learning. For Gaussian pθp_\theta it becomes mean squared error. For categorical pθp_\theta it becomes . Same idea, different distributional assumption.

FIG 00.3.7

Information theory: entropy and cross-entropy

You need three quantities. All are measured in nats (natural log) or bits (log base 2). Use nats; PyTorch does.

Entropy

The entropy of a distribution pp:

H(p)=xp(x)logp(x)H(p) = -\sum_x p(x) \log p(x)

Entropy measures uncertainty. A deterministic distribution (pp is a one-hot) has H=0H = 0. A uniform over KK outcomes has H=logKH = \log K. Intuitively: how surprised you'd be, on average, by a sample.

Cross-entropy

The between pp (truth) and qq (your model):

H(p,q)=xp(x)logq(x)H(p, q) = -\sum_x p(x) \log q(x)

This is what you minimize when you train a classifier. If pp is the empirical distribution (a one-hot for each training example) and q=qθ(yx)q = q_\theta(y \mid x) is your model's predictive distribution, then cross-entropy is the same loss as negative log-likelihood:

L(θ)=1Ni=1Nlogqθ(yixi)L(\theta) = -\frac{1}{N} \sum_{i=1}^N \log q_\theta(y_i \mid x_i)

Read that again. Cross-entropy and NLL are two names for the same thing when the target distribution is a one-hot.

KL divergence

The Kullback-Leibler divergence from qq to pp:

DKL(pq)=xp(x)logp(x)q(x)=H(p,q)H(p)D_{KL}(p \| q) = \sum_x p(x) \log \frac{p(x)}{q(x)} = H(p, q) - H(p)

KL is non-negative, zero iff p=qp = q, not symmetric (DKL(pq)DKL(qp)D_{KL}(p \| q) \neq D_{KL}(q \| p)). It appears as the "distance" between distributions in VAEs, in RLHF, in mech-interp -difference analyses.

Why cross-entropy works for classification

Two reasons.

First, it has the right . For output qθ(y=kx)=ezkjezjq_\theta(y = k \mid x) = \frac{e^{z_k}}{\sum_j e^{z_j}} and one-hot target y=ky = k^*, the gradient of logqθ(kx)-\log q_\theta(k^* \mid x) with respect to the logits zz — the raw, un-normalized scores the model emits just before the softmax turns them into probabilities — is exactly qyq - y. That is the simplest possible gradient: model minus target. It makes training stable.

Second, it correctly penalizes overconfident wrong predictions. Mean squared error on probabilities saturates the gradient when the prediction is near 0 or 1. Cross-entropy does not. A confident wrong prediction (say q=0.001q = 0.001 when the truth is y=1y = 1) produces a large loss and a large gradient.

Python
import torch
import torch.nn.functional as F

# logits: (batch, n_classes), targets: (batch,) integer labels
logits = torch.randn(4, 3)
targets = torch.tensor([0, 2, 1, 1])
loss = F.cross_entropy(logits, targets)
print(loss.item())   # one scalar

# What it does under the hood: log_softmax + nll_loss
log_probs = F.log_softmax(logits, dim=-1)
manual = -log_probs[torch.arange(4), targets].mean()
assert torch.allclose(loss, manual)

FIG 00.3.8

Putting it together: linear regression in 30 lines

Every concept from sections 4–7 shows up in one place: deriving and implementing linear regression. This is the smallest end-to-end ML algorithm and it earns its place as the first thing you implement.

Problem. Given {(xi,yi)}i=1N\{(x_i, y_i)\}_{i=1}^N with xiRdx_i \in \mathbb{R}^d and yiRy_i \in \mathbb{R}, find wRd\mathbf{w} \in \mathbb{R}^d, bRb \in \mathbb{R} that minimize:

L(w,b)=12Ni=1N(yiwxib)2L(\mathbf{w}, b) = \frac{1}{2N} \sum_{i=1}^N (y_i - \mathbf{w}^\top x_i - b)^2

Probabilistic interpretation. Assume yi=wxi+b+ϵiy_i = \mathbf{w}^\top x_i + b + \epsilon_i with ϵiN(0,σ2)\epsilon_i \sim \mathcal{N}(0, \sigma^2). The MLE of w,b\mathbf{w}, b under this model is exactly the minimizer of the squared loss. That is why squared loss is the canonical regression loss: it is NLL under Gaussian noise.

. Stack the data as XRN×dX \in \mathbb{R}^{N \times d}, yRN\mathbf{y} \in \mathbb{R}^N. Absorb bb into w\mathbf{w} by adding a column of ones to XX. Then L=12NXwy2L = \frac{1}{2N} \|X\mathbf{w} - \mathbf{y}\|^2 and from section 4:

wL=1NX(Xwy)\nabla_{\mathbf{w}} L = \frac{1}{N} X^\top (X\mathbf{w} - \mathbf{y})

The same fit done two ways against the library. First, the closed-form solver: sklearn's LinearRegression solves the normal equations directly, where the from-scratch path descends to the same minimum by SGD.

sklearn LinearRegression (closed form) vs. gradient descent from scratch

Classical ML
LIBRARY
model = LinearRegression()
model.fit(X, y)
print(model.coef_, model.intercept_)
FROM SCRATCH
def gradient_descent(X, y, lr=0.1, n_steps=500):
    N, d = X.shape
    w = np.zeros(d)
    b = 0.0
    losses = []
    for step in range(n_steps):
        losses.append(loss(w, b, X, y))
        residual = X @ w + b - y
        dw = X.T @ residual / N
        db = float(residual.mean())
        w = w - lr * dw
        b = b - lr * db
    return w, b, losses

from scratch: lab/solution.py: gradient_descent

  1. 1model.fit(X, y) the entire for-loop that iteratively updates w and b for n_steps
  2. 2model.coef_ the returned w (weight vector)
  3. 3model.intercept_ the returned b, learned via the db = residual.mean() gradient
  4. 4implicit objective: minimize ||Xw + b - y||^2 loss = 0.5 * (residual ** 2).mean(), the same least-squares bowl
  5. 5fit_intercept=True (default) centers X and y to recover b b is a separate parameter updated by its own gradient db
What the one call hides
  • Solution mechanism: sklearn does NOT do gradient descent — it solves the normal equations via an SVD-based least-squares solver (scipy.linalg.lstsq), reaching the exact minimizer in one shot rather than iterating.
  • No learning rate, no step count, no convergence loop: the closed form has no hyperparameters, so the iterative dynamics the scratch loop teaches are absent.
  • Intercept handling: fit_intercept=True centers the data internally and recovers the bias after fitting, not as a gradient parameter.
  • No regularization by default (plain OLS); for L2 you switch to Ridge, a different class.
  • Numerical conditioning: the SVD solver is robust to near-singular X^T X, whereas a naive (X^T X)^-1 X^T y would blow up.
  • Gotcha: fit() reaches the global optimum exactly and instantly, so it will NOT match a scratch run that used too few steps or too small a learning rate — equal answers only in the converged limit.
  • Gotcha: model.intercept_ is the scalar bias and model.coef_ is the weight array; beginners mix up which is w and which is b.
  • Gotcha: It silently fits unregularized OLS, so on collinear or wide data the coefficients can explode without warning.

Prefer sklearn (or the normal equations) for a plain linear model in production — it's exact and faster; the scratch gradient-descent version is to internalize that .fit() lands at the bottom of the same convex bowl, and that GD is the fallback when the data doesn't fit in memory or the loss has no closed form.

On the job: At work you call .fit(); the only hand-written code is feature prep and choosing OLS vs Ridge — you reach for GD by hand only when the problem outgrows the closed form (out-of-core, non-convex, custom loss).

And the PyTorch version of the same descent — nn.Linear trained with torch.optim.SGD — which mirrors the from-scratch loop step for step, just with autograd doing the gradient bookkeeping.

nn.Linear + SGD vs. gradient descent from scratch

DL primitive
LIBRARY
model = nn.Linear(in_features=1, out_features=1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
loss_fn = nn.MSELoss()
for step in range(500):
    optimizer.zero_grad()
    loss = loss_fn(model(X_t).squeeze(), y_t)
    loss.backward()
    optimizer.step()
FROM SCRATCH
def gradient(w, b, X, y):
    N = X.shape[0]
    residual = X @ w + b - y
    dw = X.T @ residual / N
    db = float(residual.mean())
    return dw, db

def gradient_descent(X, y, lr=0.1, n_steps=500):
    N, d = X.shape
    w = np.zeros(d)
    b = 0.0
    losses = []
    for step in range(n_steps):
        losses.append(loss(w, b, X, y))
        dw, db = gradient(w, b, X, y)
        w = w - lr * dw
        b = b - lr * db
    return w, b, losses

from scratch: lab/solution.py: gradient_descent

  1. 1nn.Linear(1, 1) holds .weight and .bias as the trainable parameters w = np.zeros(d) and b = 0.0
  2. 2model(X_t) forward pass X @ w + b inside loss/gradient computes the prediction
  3. 3nn.MSELoss()(pred, y) = mean(residual**2) 0.5 * (residual ** 2).mean() — same objective up to the 0.5 factor, which halves the effective gradient (hence lr/2 to match)
  4. 4loss.backward() builds .weight.grad and .bias.grad via reverse-mode autograd gradient() hand-derives dw = X.T @ residual / N and db = residual.mean()
  5. 5optimizer.step() with torch.optim.SGD the loop body w = w - lr*dw; b = b - lr*db
  6. 6optimizer.zero_grad() no analog — the scratch loop recomputes the gradient fresh each step, so there is nothing to clear
What the one call hides
  • Autograd: backward() derives the gradient by reverse-mode chain rule, so you never write X.T @ residual yourself and never see whether your hand-math is right.
  • Parameter init: nn.Linear initializes weight/bias from Kaiming-uniform U(-1/sqrt(fan_in), 1/sqrt(fan_in)), NOT zeros like the scratch version — so without tying the weights the two start from different points (both still converge on this convex problem).
  • Reduction: MSELoss defaults to 'mean' (divides by N and by output dim) and has no 0.5 factor, so the same lr is effectively 2x the scratch version's.
  • Gradient accumulation: .grad accumulates across backward() calls, which is why zero_grad() is mandatory — a footgun the scratch loop sidesteps entirely.
  • Shape handling: nn.Linear emits (N, 1); without .squeeze() it broadcasts against a (N,) target and silently computes the wrong loss.
  • Gotcha: Forgetting optimizer.zero_grad() makes gradients accumulate across steps, so the effective learning rate grows each iteration and training diverges.
  • Gotcha: MSELoss has no 0.5 factor, so a learning rate copied verbatim from the scratch loop is effectively doubled.
  • Gotcha: nn.Linear default init is random, not zero, so 'reproduce my scratch run exactly' fails unless you also seed and overwrite the weights.

In most application code, avoid hand-rolling a linear model's training loop — you use nn.Linear+SGD (or sklearn); the from-scratch version exists so you can see that .backward()+.step() is literally w = w - lr * (X.T @ residual / N).

On the job: What you actually write by hand is the training loop glue around these primitives (zero_grad/backward/step ordering, the .squeeze() shape fix, the lr) — not the gradient math, which autograd owns.

Three implementations, three different APIs, same math, same answer. You now have all the pieces of every classical ML algorithm in this curriculum.

FIG 00.3.9

Vectorization: why loops are slow

NumPy and PyTorch operations dispatch to vectorized C and kernels. Python for loops do not. The performance gap is two to three orders of magnitude.

Python
import numpy as np
import time

N = 1_000_000
x = np.random.randn(N)

# Slow: Python loop
t0 = time.time()
total_loop = 0.0
for xi in x:
    total_loop += xi ** 2
t_loop = time.time() - t0

# Fast: NumPy vectorized
t0 = time.time()
total_np = (x ** 2).sum()
t_np = time.time() - t0

print(f"Python loop: {t_loop:.3f}s")
print(f"NumPy:       {t_np:.6f}s")
print(f"Speedup:     {t_loop / t_np:.0f}x")

On a typical laptop, the speedup ranges from tens to a few hundred times, depending on machine and array size — it is not a fixed number. On GPU with PyTorch, the speedup over CPU NumPy is another 10–100x for large enough arrays.

The two habits this teaches:

  1. Never write a for loop over dimensions in your inner training loop. If you find yourself iterating over batches inside a batch, your code is wrong. Reshape, broadcast, einsum.
  2. np.einsum and torch.einsum are your friends. When you can't see how to broadcast, write the index notation explicitly and let the library figure it out. The string 'bnk,bkm->bnm' just names each axis with a letter: any letter that appears on the left but not after the -> is summed over (here k, exactly the inner dimension of a matmul), and a shared leading letter like b is carried along untouched — so this does one (n,k) @ (k,m) matmul for each of the b batch elements. You do not need to be fluent in this yet; it gets a proper treatment once batched tensors become routine. Reach for it only when broadcasting gets hard to read.
Python
# Batched matmul without einsum: messy
A = np.random.randn(32, 10, 20)   # (batch, n, k)
B = np.random.randn(32, 20, 15)   # (batch, k, m)
C = A @ B                          # works, but you have to know matmul broadcasts the leading dims

# With einsum: explicit
C = np.einsum('bnk,bkm->bnm', A, B)

FIG 00.3.10

pandas: data wrangling without tears

pandas is the library for reading CSVs, joining tables, filtering rows, and slicing columns. You use it before NumPy in every project, because real data lives in CSVs and TSVs and SQLite files, not in np.random.randn calls.

Python
import pandas as pd

# Load
df = pd.read_csv("housing.csv")

# Inspect
df.head()             # first 5 rows
df.shape              # (n_rows, n_cols)
df.dtypes             # column types
df.describe()         # summary statistics per numeric column
df.info()             # null counts and types

# Select
df["price"]                          # one column, returns Series
df[["price", "area"]]                # multiple columns, returns DataFrame
df.iloc[0:5, :]                      # positional indexing
df.loc[df["price"] > 1e6, "area"]    # boolean mask + column select

# Mutate
df["price_per_sqft"] = df["price"] / df["area"]

# Aggregate
df.groupby("ocean_proximity")["price"].mean()

# Missing data
df.isna().sum()        # how many NaNs per column
df.dropna()            # drop rows with any NaN
df.fillna(df.median(numeric_only=True))   # fill with median

# To NumPy
X = df.drop(columns=["target"]).values
y = df["target"].values

That covers ≈80% of what you'll do with pandas in this curriculum. The other 20% is pd.merge (SQL-style joins), pd.pivot_table (Excel-style pivots), and pd.to_datetime (time series parsing).

FIG 00.3.11

matplotlib: plots that don't fight you

Plot once. Look at the data. Adjust the model. Look at the data again. Matplotlib is the library for the looking-at part.

Python
import matplotlib.pyplot as plt
import numpy as np

# Single plot
x = np.linspace(0, 10, 100)
y = np.sin(x)
plt.figure(figsize=(6, 4))
plt.plot(x, y, label="sin(x)")
plt.xlabel("x")
plt.ylabel("y")
plt.title("A sine wave")
plt.legend()
plt.grid(True)
plt.show()

# Multiple subplots
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].scatter(np.random.randn(100), np.random.randn(100))
axes[0].set_title("Scatter")
axes[1].hist(np.random.randn(1000), bins=30)
axes[1].set_title("Histogram")
plt.tight_layout()
plt.show()

# Common ML plots
# Training curve
plt.plot(losses, label="train")
plt.plot(val_losses, label="val")
plt.xlabel("step")
plt.ylabel("loss")
plt.yscale("log")    # log scale for loss curves
plt.legend()

# Confusion matrix as a heatmap
import sklearn.metrics
cm = sklearn.metrics.confusion_matrix(y_true, y_pred)
plt.imshow(cm, cmap="Blues")
plt.colorbar()
plt.xlabel("predicted")
plt.ylabel("true")

FIG 00.3.12

The single Colab cell that sets up every chapter

For convenience, here's the boilerplate I paste into the first cell of every notebook in this curriculum.

Python
# Common imports
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import sklearn

# Reproducibility
np.random.seed(42)
torch.manual_seed(42)

# Better plot defaults
plt.rcParams['figure.figsize'] = (8, 5)
plt.rcParams['figure.dpi'] = 100
plt.rcParams['axes.grid'] = True
plt.rcParams['grid.alpha'] = 0.3

# Device selection
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using device: {device}")

Three habits this builds:

  1. RNGs when matters. For learning and debugging, deterministic behavior helps you tell whether a code change caused a result. Production systems may also need reproducible tests and incident replay, while intentionally stochastic behavior should use an explicit, controlled source of randomness.
  2. Always check your . Putting tensors on the wrong device produces a runtime error. Putting them on CPU when you meant GPU produces a 50x slowdown silently.
  3. Always set plot defaults once. Tiny plots are unreadable. The default 6x4 dpi 72 plot from matplotlib is too small. Bump it.

FIG 00.4 · Safety lens · this chapter

The math itself is value-neutral, but the practice of math-in-ML has three failure modes that anyone serious about AI safety should internalize early. Two of them are about reasoning errors. One is about hygiene.

Numerical instability hides bugs that look like . If you exponentiate large logits without subtracting the max first, your returns nan. If your variance estimate is computed as E[X^2] - E[X]^2 on near-equal scales, catastrophic cancellation makes it negative and your loss curve mysteriously diverges. Both produce model behavior that looks like "the training failed" when really the math was wrong. AI safety researchers spend a non-trivial fraction of their time on numerical stability, because evaluation pipelines that silently produce nan log a "model failed safely" when the truth is "we never tested anything". See 08-geron-notebooks/extra_autodiff §numerical-stability and 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__information-theory §numerical-stability for the standard fixes: log-sum-exp tricks for softmax, two-pass variance estimators for statistics, and using PyTorch's F.cross_entropy instead of writing softmax-then-NLL by hand.

Reproducibility is a safety property. When you train a model and report a result, another researcher needs to be able to reproduce it. If your only documentation is "I ran the notebook", you have shipped a non-replicable artifact. The fixes are mechanical: every RNG (np.random.seed, torch.manual_seed, torch.cuda.manual_seed_all, and random.seed), pin every package version (pip freeze > requirements.txt), and log the git hash of your code with every run. The reason this matters for safety specifically: alignment research is empirical research about claims like "method X reduces harmful behavior by Y%". Unreproducible claims are unfalsifiable claims. Nanda's mech-interp methodology essays make the same point about interp findings: unreproducible circuit claims are circuit claims about a particular initialization, not about the architecture. Anthropic's Core Views on AI Safety (25-alignment-canon/www-anthropic-com-news-core-views-on-ai-safety §empirical-research) is explicit that empirical alignment research depends on this discipline. See also 06-practice/madewithml-mlops-eda §reproducibility and the Yan et al. ML-systems posts in 06-practice/huyenchip-index.

The mental habit you want to build, starting now: when you derive a , verify it with autograd. When you compute a , check that it lies in [0, 1] and sums to 1. When you compute a loss, log the histogram of intermediate values once before up. The cost is ten minutes. The savings are sometimes weeks of debugging. The deeper safety habit underneath: believe nothing until it has been measured. That habit is the precondition for every other safety practice in this curriculum.


FIG 00.5 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • A finite-difference derivative checker that disagrees with the analytic gradient until you fix the step size, the way Karpathy opens micrograd.
  • A fully-connected layer's forward pass Y = X @ W.T + b from raw shapes, plus the broadcast rules that make it one line.
  • The least-squares gradient X.T @ (X w - y) derived on paper, then confirmed against torch.autograd to five decimals.
  • softmax, cross_entropy, and entropy from scratch, checked against torch.nn.functional, including the overflow bug that makes a naive softmax return nan.
  • A from-scratch gradient descent that recovers y = 3x + 5 from noisy data, the smallest end-to-end ML algorithm.

~1 min on CPU · 108 cells · 13 checked exercises · runs in Colab


FIG 00.6 · Going further

  • 02-code-refs/learnx-python

    the language reference. Skim it once if Python is new.

  • 08-geron-notebooks/math_linear_algebra

    Géron's standalone linear algebra notebook. Worked examples, runnable in Colab.

  • 08-geron-notebooks/math_differential_calculus

    the calculus companion. Same approach.

  • 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__*

    D2L's appendix is the densest reference for the math you'll meet in deep learning specifically.

  • 01-explorables/seeingtheory-basic-probability

    the best interactive intro to probability on the web. 20 minutes well spent.

  • 02-code-refs/amidi-cs229-algebra-calculus

    Stanford CS229's two-page cheat sheet. Print it. Tape it next to your monitor.

  • 23-textbooks/math4ml

    if you want a full textbook treatment, this is the one.

  • 23-textbooks/mackay-itila §1-2

    the entropy intuitions chapter. Less computational, more philosophical. Worth it.


FIG 00.7 · What this enables

Chapters you can now read, with the connecting idea written out.


FIG 00.8 · 40 sources
  1. 02-code-refs/amidi-cs229-algebra-calculus
  2. 02-code-refs/amidi-cs229-prob-stats
  3. 02-code-refs/amidi-cs229-ml-tips
  4. 02-code-refs/devhints-numpy
  5. 02-code-refs/devhints-pandas
  6. 02-code-refs/learnx-python
  7. 02-code-refs/ml-glossary-calculus
  8. 02-code-refs/ml-glossary-linear-algebra
  9. 02-code-refs/ml-glossary-loss-functions
  10. 02-code-refs/ml-glossary-math-notation
  11. 02-code-refs/ml-glossary-probability
  12. 02-code-refs/quickref-numpy
  13. 02-code-refs/quickref-python
  14. 02-code-refs/quickref-pytorch
  15. 03-curricula/google-mlcc-linear-regression
  16. 04-stanford/cs229-main-notes-pdf
  17. 06-practice/geron-install
  18. 06-practice/huyenchip-index
  19. 06-practice/kaggle-data-viz
  20. 06-practice/kaggle-pandas
  21. 06-practice/kaggle-python
  22. 06-practice/madewithml-mlops-eda
  23. 08-geron-notebooks/04_training_linear_models
  24. 08-geron-notebooks/extra_autodiff
  25. 08-geron-notebooks/extra_gradient_descent_comparison
  26. 08-geron-notebooks/math_differential_calculus
  27. 08-geron-notebooks/math_linear_algebra
  28. 08-geron-notebooks/tools_matplotlib
  29. 08-geron-notebooks/tools_numpy
  30. 08-geron-notebooks/tools_pandas
  31. 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__geometry-linear-algebraic-ops
  32. 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__information-theory
  33. 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__maximum-likelihood
  34. 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__multivariable-calculus
  35. 16-d2l-sections/chapter_appendix-mathematics-for-deep-learning__random-variables
  36. 16-d2l-sections/chapter_appendix-tools-for-deep-learning__colab
  37. 16-d2l-sections/chapter_appendix-tools-for-deep-learning__jupyter
  38. 01-explorables/distill-momentum
  39. 01-explorables/seeingtheory-basic-probability
  40. 01-explorables/seeingtheory-bayesian-inference