Math for Deep Learning — Handbook
The derivative as slope
The idea
The derivative of a function at a point is the slope of its tangent there — the limit of the secant through two points as they collapse together. Drag the point along the curve and shrink h to watch the secant swing into the tangent.
Picture it
In the companion lab you can drag the point along the curve; shrink h to make the secant approach the tangent.
The math
Derivation
(½(x+h)² − ½x²)/h = (½(2xh+h²))/h = x + h/2 → x as h→0. The secant slope limits to the tangent slope.
Code
import numpy as np
# Numerical derivative (finite difference) vs the analytic one
f = lambda x: x**3
df = lambda x: 3*x**2
x, h = 2.0, 1e-5
num = (f(x+h) - f(x-h)) / (2*h) # central difference
print(num, df(x)) # ~12.0, 12.0In practice
Gradient descent
The idea
Training minimizes a loss by repeatedly stepping downhill: subtract a small multiple of the gradient from the current point. The learning rate sets the step size — too small crawls, too large overshoots or diverges. Drop a ball anywhere and step it down.
Picture it
In the companion lab you can click the curve to place the ball, then step. Adjust the learning rate.
The math
Derivation
First-order Taylor: f(x−ηf′) ≈ f(x) − η·f′(x)². For small η the change is ≤ 0, so loss decreases — until η is too big and the approximation breaks.
Code
import numpy as np
# Gradient = vector of partial derivatives; points uphill
f = lambda w: w[0]**2 + 3*w[1]**2
def grad(w, h=1e-5):
g = np.zeros_like(w)
for i in range(len(w)):
e = np.zeros_like(w); e[i] = h
g[i] = (f(w+e) - f(w-e)) / (2*h)
return g
print(grad(np.array([1.0, 1.0]))) # [2, 6] -> analytic [2w0, 6w1]In practice
The chain rule
The idea
Composing functions multiplies their slopes. If y = f(g(x)), then dy/dx = f′(g(x)) · g′(x): the local sensitivity of the outer function times that of the inner. This single rule is what makes backpropagation possible. Slide x and watch the factors multiply.
Picture it
In the companion lab you can slide x through y = sin(x)² = f(g(x)) with g=sin, f=square.
The math
Derivation
Δy/Δx = (Δy/Δu)(Δu/Δx) with u=g(x); taking limits gives the product of derivatives. For sin²x: 2·sin x·cos x.
Code
import numpy as np
# Chain rule: dz/dx = dz/dy * dy/dx, composed through a pipeline
x = 1.5
y = np.sin(x); dy_dx = np.cos(x)
z = y**2; dz_dy = 2*y
print("dz/dx =", dz_dy * dy_dx) # = 2 sin(x) cos(x)In practice
Backprop on a tiny graph
The idea
A neuron computes pred = w·x + b, and squared-error loss compares it to a target. The forward pass fills in values left to right; the backward pass sends gradients right to left by the chain rule. Move w and b, watch the loss and gradients, and take a step.
Picture it
In the companion lab you can set the weight and bias; step to descend the loss.
The math
Derivation
Let e = pred−y. dL/dpred = 2e (square). Then chain back: dpred/dw = x, dpred/db = 1 ⇒ dL/dw = 2e·x, dL/db = 2e.
Code
import numpy as np
# Backprop through a tiny net: forward, then reverse-mode gradients
x, w1, w2 = 1.0, 0.8, -0.5
h = np.tanh(w1*x) # forward
y = w2*h
L = 0.5*(y - 1.0)**2
dy = (y - 1.0) # backward
dw2 = dy*h
dh = dy*w2
dw1 = dh*(1 - h**2)*x # tanh' = 1 - tanh^2
print(round(dw1,4), round(dw2,4))In practice
Activations & their derivatives
The idea
Nonlinear activations are what let networks bend. Each has a shape and a derivative — and that derivative is what backprop multiplies by. Flat regions (where the derivative is near zero) starve learning: the vanishing-gradient problem. Switch activations and slide the input.
Picture it
In the companion lab you can pick an activation and slide the input marker.
The math
Derivation
σ′: differentiate (1+e⁻ˣ)⁻¹ → e⁻ˣ/(1+e⁻ˣ)² = σ(1−σ), peaking at ¼. Saturated regions (|x| large) have derivative ≈ 0.
Code
import numpy as np
# Activations and their derivatives (the gradient signal that flows back)
relu = lambda z: np.maximum(0, z); d_relu = lambda z: (z > 0).astype(float)
sig = lambda z: 1/(1+np.exp(-z)); d_sig = lambda z: sig(z)*(1-sig(z))
z = np.array([-2., 0., 2.])
print("relu'", d_relu(z), " sigmoid'", np.round(d_sig(z), 3))In practice
Softmax & cross-entropy
The idea
A classifier outputs raw scores (logits); softmax turns them into a probability distribution, and cross-entropy scores how surprised the model is by the true label. Their famous pairing exists because the gradient collapses to something beautifully simple. Tune the logits and the true class.
Picture it
In the companion lab you can set the logits; pick the true class.
The math
Derivation
∂pⱼ/∂zᵢ = pᵢ(δᵢⱼ − pⱼ). With CE = −log pₜ, ∂CE/∂zᵢ = −(δᵢₜ − pᵢ) = pᵢ − yᵢ. The exp in softmax cancels the log in CE — hence the clean result.
Code
import numpy as np
# Softmax: logits -> probability distribution (stable version)
def softmax(z):
z = z - z.max() # subtract max for stability
e = np.exp(z)
return e / e.sum()
print(np.round(softmax(np.array([2.0, 1.0, 0.1])), 3)) # sums to 1In practice
Maximum likelihood → your loss
The idea
Where do loss functions come from? Maximum likelihood: pick the parameters that make the observed data most probable. Maximizing the product of probabilities is the same as minimizing the summed negative log — and for Gaussian noise that is exactly squared error. Slide μ to find the peak.
Picture it
In the companion lab you can slide the model mean μ to fit the data points (σ fixed). The negative log-likelihood is minimized at the sample mean.
The math
Derivation
Set dℓ/dμ = (1/σ²)Σ(xᵢ−μ) = 0 ⇒ μ* = (1/n)Σ xᵢ = sample mean. Gaussian likelihood → squared-error loss falls right out.
Code
import numpy as np
# Max likelihood = minimize negative log-likelihood; for a Gaussian -> MSE
data = np.random.normal(3.0, 1.0, 1000)
# NLL of mu given unit variance is sum (x-mu)^2 / 2 -> minimized at the mean
mu_hat = data.mean()
print("MLE mean:", round(mu_hat, 3))In practice
Logistic regression & BCE
The idea
For a yes/no label, squash a linear score through the sigmoid to get a probability, then score it with binary cross-entropy. Remarkably the gradient is again predicted − target. Move the weight and bias to slide and steepen the decision boundary between the two classes.
Picture it
In the companion lab you can set weight and bias; the boundary sits where σ = 0.5.
The math
Derivation
σ′(z) = σ(z)(1−σ(z)). Differentiating BCE and using this identity, the (1−p) and p factors cancel, leaving ∂BCE/∂z = p − y — the same clean signal as softmax.
Code
import numpy as np
# Sigmoid maps a logit to P(y=1); its inverse is the log-odds (logit)
sigmoid = lambda z: 1/(1+np.exp(-z))
p = sigmoid(np.array([-2., 0., 2.]))
logit = np.log(p/(1-p)) # recovers the input
print(np.round(p,3), np.round(logit,3))In practice
Entropy, cross-entropy & KL
The idea
Three quantities, one identity. Entropy is the irreducible uncertainty in the true distribution p; cross-entropy is the cost of coding p’s outcomes with model q; and their gap is the KL divergence. Since CE = H(p) + KL(p∥q), minimizing cross-entropy is minimizing KL to the data. Drag q toward p.
Picture it
In the companion lab you can adjust the model q over 3 outcomes (true p is fixed). KL hits 0 when q = p.
The math
Derivation
CE − H = −Σ p log q + Σ p log p = Σ p log(p/q) = KL ≥ 0 (Gibbs), with equality iff q = p. So CE = H(p) + KL(p∥q), and H(p) is fixed w.r.t. q.
Code
import numpy as np
# Cross-entropy = entropy(p) + KL(p||q); training minimizes it
def cross_entropy(p, q): return -(p*np.log(q)).sum()
def kl(p, q): return (p*np.log(p/q)).sum()
p = np.array([1.,0.,0.]) # one-hot label
q = np.array([0.7,0.2,0.1]) # prediction
print("CE =", round(cross_entropy(p, np.clip(q,1e-9,1)), 3))In practice
MSE, MAE, Huber — gradients
The idea
The loss you pick decides how the model reacts to errors, because training only ever sees the gradient. Squared error’s gradient grows with the error (outliers dominate); absolute error’s is constant (robust but kinked); Huber blends both. Drag the error and compare the slopes.
Picture it
In the companion lab you can drag the residual e = prediction − target; set Huber’s δ.
The math
Derivation
MSE’s gradient 2e is unbounded — a single outlier sends a huge update. MAE’s is bounded at ±1 — robust, but nonsmooth at 0. Huber is quadratic (smooth) near 0 and linear (bounded) far out: best of both.
Code
import numpy as np
# Picking a loss = picking a noise model. MSE<->Gaussian, CE<->categorical
y, yhat = np.array([1.,0.,1.]), np.array([0.9,0.2,0.6])
mse = np.mean((y - yhat)**2)
bce = -np.mean(y*np.log(yhat) + (1-y)*np.log(1-yhat))
print(f"MSE={mse:.3f} BCE={bce:.3f}")In practice
Weight initialization
The idea
Before any learning happens, the scale of the random weights decides whether a signal survives a deep stack of layers. Too large and activations explode; too small and they vanish to zero. The fix is to set the weight variance from the fan-in. Add layers and dial the gain to find the stable knife-edge.
Picture it
In the companion lab you can add layers, change the gain, switch activation. Watch the per-layer activation scale.
The math
Derivation
Stable layers need g²·c_act = 1. ReLU zeroes half the inputs (c_act = ½) ⇒ g = √2 (He). Linear/tanh (c_act ≈ 1) ⇒ g = 1 (Xavier/Glorot).
Code
import numpy as np
# Initialization sets the scale of activations; Xavier/He keep variance ~1
fan_in, fan_out = 256, 256
xavier = np.random.randn(fan_in, fan_out) * np.sqrt(1/fan_in)
he = np.random.randn(fan_in, fan_out) * np.sqrt(2/fan_in) # for ReLU
x = np.random.randn(1000, fan_in)
print("act std (He):", round((x @ he).std(), 2)) # ~1, not explodingIn practice
Vanishing & exploding gradients
The idea
Backprop sends the error backward by multiplying through every layer. Multiply many numbers below one and the gradient decays to nothing in early layers; many above one and it blows up. This is why sigmoid-deep nets barely trained — and why ReLU and residual connections changed everything. Deepen the net and switch the activation.
Picture it
In the companion lab you can set depth and weight scale; switch activation to compare gradient flow.
The math
Derivation
Each backward hop multiplies by the layer Jacobian. Sigmoid has f′ ≤ ¼, so even with w=1 the factor ≤ 0.25 — raised to a high power → 0. ReLU has f′ = 1 on active units, so the factor stays near |w|.
Code
import numpy as np
# Vanishing gradients: stacking saturating activations shrinks the signal
g = 1.0
for layer in range(20):
s = 1/(1+np.exp(-np.random.randn())) # a sigmoid output ~0.5
g *= s*(1-s) # multiply by sigmoid' (<=0.25)
print("gradient after 20 layers:", f"{g:.1e}") # tiny -> vanishedIn practice
Normalization (BatchNorm / LayerNorm)
The idea
Normalization grabs a batch of activations, recenters them to mean 0 and variance 1, then lets the network rescale with two learned parameters γ and β. This keeps every layer’s inputs in a sane range no matter what the previous layers do. Shift and spread the raw activations and watch them snap back.
Picture it
In the companion lab you can mess up the raw activations; normalization fixes the spread, then γ,β reshape it on purpose.
The math
Derivation
By construction x̂ has mean 0, variance 1. Then y has mean β and variance γ² — so the net can recover any scale/shift it wants (even undo the normalization), but starts from a stable point.
Code
import numpy as np
# Batch/Layer norm: standardize activations to mean 0, var 1, then scale+shift
def layernorm(x, gamma=1.0, beta=0.0, eps=1e-5):
mu = x.mean(-1, keepdims=True); var = x.var(-1, keepdims=True)
return gamma*(x - mu)/np.sqrt(var + eps) + beta
h = np.random.randn(4, 8)*5 + 3
print("after norm:", np.round(layernorm(h).mean(), 3), np.round(layernorm(h).std(), 3))In practice
SGD vs Momentum vs Adam
The idea
On a stretched ravine, plain SGD bounces across the walls and inches along the floor. Momentum builds velocity down the long axis; Adam rescales each coordinate by its own gradient history so both axes move at a sensible pace. Run each optimizer from the same start and compare the paths.
Picture it
In the companion lab you can run each optimizer (same start, ill-conditioned bowl) and compare.
The math
Derivation
Momentum accumulates consistent gradients (the floor direction) while cancelling the oscillating ones — an exponential average of past gradients. Adam divides by √v̂, so a steep coordinate gets a small step and a flat one a large step: per-parameter learning rates.
Code
import numpy as np
# Adam: per-parameter adaptive step from 1st & 2nd moment estimates
w = np.array([5.0]); m = v = 0; b1,b2,lr,eps = 0.9,0.999,0.1,1e-8
grad = lambda w: 2*w
for t in range(1, 51):
g = grad(w)
m = b1*m + (1-b1)*g; v = b2*v + (1-b2)*g**2
mh = m/(1-b1**t); vh = v/(1-b2**t) # bias correction
w -= lr*mh/(np.sqrt(vh)+eps)
print(np.round(w, 3)) # -> 0In practice
A layer as a matrix multiply
The idea
A dense layer is just h = Wx + b. Read it row by row: each output is the dot product of one weight row with the input — a little feature detector that fires when the input matches its row. Change the input and pick a row to see exactly which numbers get multiplied and summed.
Picture it
In the companion lab you can set the input; highlight a row to see its dot product.
The math
Derivation
Matrix–vector product stacks dot products: the i-th entry pairs row i of W with x. Equivalently h = x₁·col₁ + x₂·col₂ + b — a linear combination of W’s columns.
Code
import numpy as np
# A dense layer is just y = x W + b (a batched matrix multiply)
x = np.random.randn(32, 128) # batch of 32 vectors
W = np.random.randn(128, 64); b = np.random.randn(64)
y = x @ W + b
print(y.shape) # (32, 64)In practice
The Jacobian & backward pass
The idea
Forward, a linear layer multiplies by W. Backward, the gradient multiplies by Wᵀ. That transpose is the whole mechanism of backprop: the layer’s Jacobian sends gradients from outputs back to inputs. Drag the incoming gradient and watch the outgoing gradient it produces.
Picture it
In the companion lab you can drag the upstream gradient g_y; the downstream gradient is g_x = Wᵀ g_y.
The math
Derivation
By the chain rule ∂L/∂x_j = Σ_i (∂L/∂y_i)(∂y_i/∂x_j) = Σ_i g_{y,i} W_{ij} = (Wᵀ g_y)_j. Reverse-mode never builds J — it computes this vector–Jacobian product directly.
Code
import numpy as np
# The Jacobian of a layer: how each output changes with each input
W = np.array([[2., 1.], [0., 3.]])
f = lambda x: W @ x # linear layer -> Jacobian is W
x = np.array([1., 1.]); h = 1e-5
J = np.array([[ (f(x+np.eye(2)[j]*h)[i]-f(x-np.eye(2)[j]*h)[i])/(2*h)
for j in range(2)] for i in range(2)])
print(np.round(J, 2)) # == WIn practice
Why nonlinearity?
The idea
Stack two linear layers and you get… one linear layer: W₂(W₁x) = (W₂W₁)x. No amount of depth helps until you put a nonlinearity between them. Toggle the activation to watch a flat grid either stay a boring parallelogram or fold into something a linear map could never make.
Picture it
In the companion lab you can toggle the activation between the two layers; randomize the weights.
The math
Derivation
Matrix products are associative, so any chain of linear maps equals a single matrix W_eff. The activation φ breaks associativity — it acts per-coordinate between the matmuls — so the composite becomes genuinely nonlinear.
Code
import numpy as np
# Stacking linear layers without nonlinearity collapses to one matrix
W1 = np.random.randn(4, 4); W2 = np.random.randn(4, 4)
x = np.random.randn(4)
print(np.allclose(W2 @ (W1 @ x), (W2 @ W1) @ x)) # True -> need nonlinearityIn practice
Forward + backward: a live MLP
The idea
Here is a complete tiny network learning by backprop. Two inputs feed a hidden ReLU layer, then a linear output, scored against a target. Step runs one forward pass (values flow right) and one backward pass (gradients flow left), then nudges every weight downhill. Watch the loss fall as it learns.
Picture it
In the companion lab you can step the network and watch loss drop. Change the target or learning rate.
The math
Code
import numpy as np
# Full forward pass of a 2-layer MLP
def mlp(x, W1, b1, W2, b2):
h = np.maximum(0, x @ W1 + b1) # ReLU hidden layer
return h @ W2 + b2 # linear output
x = np.random.randn(8, 16)
W1, b1 = np.random.randn(16,32), np.zeros(32)
W2, b2 = np.random.randn(32,3), np.zeros(3)
print(mlp(x, W1, b1, W2, b2).shape) # (8, 3)In practice
Attention weights
The idea
Attention lets a query pull information from whichever items resemble it most. Score the query against each key by dot product, soften the scores into weights with softmax, then blend the values by those weights. Drag the query toward a key and watch nearly all the weight rush to it.
Picture it
In the companion lab you can drag the query. Each key’s weight is how much the output leans toward it.
The math
Derivation
The dot product measures alignment; softmax turns the scores into positive weights summing to 1, so the output is a convex combination of the values — a differentiable, content-addressed average that the network can learn to steer.
Code
import numpy as np
# Attention scores = how much each query attends to each key
Q = np.random.randn(3, 8) # 3 queries, dim 8
K = np.random.randn(5, 8) # 5 keys
scores = Q @ K.T # (3, 5) raw compatibility
print(scores.shape)In practice
Self-attention: Q, K, V
The idea
In self-attention every token plays three roles: it emits a query (what am I looking for), a key (what do I offer), and a value (what I’ll pass on). The full attention matrix scores every query against every key; each row, softmaxed, says how one token distributes its attention across all tokens. Pick a token to see its row.
Picture it
In the companion lab you can select a query token to highlight its attention row; reshape the learned projections.
The math
Derivation
Entry A_{ij} = softmax over j of (q_i·k_j)/√d: token i’s affinity for token j. Multiplying by V replaces each token with a weighted mixture of all tokens’ values — contextualization in one matmul.
Code
import numpy as np
def softmax(z): e=np.exp(z-z.max(-1,keepdims=True)); return e/e.sum(-1,keepdims=True)
# Self-attention: each token mixes in the values of relevant tokens
X = np.random.randn(4, 8) # 4 tokens
Wq,Wk,Wv = [np.random.randn(8,8) for _ in range(3)]
Q,K,V = X@Wq, X@Wk, X@Wv
A = softmax(Q @ K.T / np.sqrt(8)) # attention weights (rows sum to 1)
out = A @ V
print(out.shape, np.round(A.sum(1), 2))In practice
Why divide by √d
The idea
Dot products of high-dimensional vectors grow with the dimension, so raw attention scores get huge — softmax then collapses to a near one-hot spike and its gradient vanishes. Dividing by √d rescales the scores back to unit variance, keeping attention soft and trainable. Crank up d and compare the two rows.
Picture it
In the companion lab you can increase the key/query dimension d and compare unscaled vs scaled weights.
The math
Derivation
q·k = Σᵢ qᵢkᵢ is a sum of d zero-mean unit-variance terms, so its variance is d and its std is √d. Scaling by 1/√d standardizes it, so softmax sees comparable inputs at any depth.
Code
import numpy as np
# Why divide by sqrt(d): without it, dot products grow and softmax saturates
d = 64
q = np.random.randn(d); k = np.random.randn(d)
print("var of q.k :", round((q@k).var() if False else float(np.var([np.random.randn(d)@np.random.randn(d) for _ in range(1000)])),1))
# ~d, so scores ~sqrt(d); dividing by sqrt(d) keeps softmax in a useful rangeIn practice
Positional encoding
The idea
Attention is permutation-invariant — shuffle the tokens and it can’t tell, because it has no built-in notion of order. Positional encodings fix this by adding a unique, smoothly varying signature to each position. The classic choice is a bank of sines and cosines at geometrically spaced frequencies. Slide the position to see its fingerprint.
Picture it
In the companion lab you can move the highlighted position and change the model width.
The math
Derivation
Low dimensions oscillate fast, high dimensions slowly — like binary place-values in continuous form, giving every position a distinct vector. Because shifts act as rotations, PE(pos+k) is a linear function of PE(pos), so the model can learn relative offsets.
Code
import numpy as np
# Sinusoidal positional encodings give the model a sense of order
def pos_enc(seq_len, d):
pos = np.arange(seq_len)[:, None]; i = np.arange(d)[None, :]
ang = pos / np.power(10000, (2*(i//2))/d)
pe = np.where(i % 2 == 0, np.sin(ang), np.cos(ang))
return pe
print(pos_enc(10, 16).shape) # (10, 16), added to token embeddingsIn practice
Convolution
The idea
A convolution slides a small kernel across an image; at each spot it multiplies the overlapping pixels by the kernel and sums them into one output value. A 3×3 kernel becomes an edge detector, a blur, or a sharpener depending on its nine numbers. Drag the window over the image and switch kernels to watch the feature map change.
Picture it
In the companion lab you can drag the 3×3 window over the image; switch the kernel.
The math
Derivation
Each output pixel is a dot product of the kernel with the local patch — a tiny linear filter. The same kernel is reused at every location, so a feature (an edge, a corner) is detected wherever it appears.
Code
import numpy as np
# 2-D convolution: slide a kernel, take dot products (here: edge detector)
img = np.eye(5)
kernel = np.array([[-1,-1,-1],[0,0,0],[1,1,1]]) # horizontal edges
out = np.zeros((3,3))
for i in range(3):
for j in range(3):
out[i,j] = (img[i:i+3, j:j+3] * kernel).sum()
print(out)In practice
Stride, padding & output size
The idea
Three knobs decide how big a conv layer’s output is and how fast it shrinks: the kernel size, the stride (how far the window jumps), and the padding (zeros added around the border). Dial them and watch the window placements and the resulting output length update against the formula.
Picture it
In the companion lab you can adjust the four knobs; the windows and output length follow the formula.
The math
Derivation
The first window starts at 0 and the last fits when its right edge ≤ N+2p−1; stepping by s gives (N+2p−k)/s + 1 start positions, floored. Larger stride downsamples; padding restores the size the border would otherwise lose.
Code
import numpy as np
# Output size = (W - K + 2P)/S + 1; stride/padding control downsampling
def out_size(W, K, P, S): return (W - K + 2*P)//S + 1
print(out_size(32, 3, 1, 1)) # 32 (same)
print(out_size(32, 3, 0, 2)) # 15 (downsampled)In practice
Pooling
The idea
Pooling shrinks a feature map by summarizing each little region with a single number — the maximum (keep the strongest response) or the average. It has no learnable weights; it just downsamples and grants a bit of translation invariance. Toggle between max and average and see which input each output came from.
Picture it
In the companion lab you can switch between max and average pooling (2×2, stride 2).
The math
Derivation
Pooling is a fixed (non-learned) reduction. Max-pool’s gradient flows only to the winning input; avg-pool spreads it evenly. Either way it discards precise location, buying small-shift invariance.
Code
import numpy as np
# Max pooling: downsample by keeping the strongest activation per window
x = np.arange(16).reshape(4,4).astype(float)
pooled = x.reshape(2,2,2,2).max(axis=(1,3)) # 2x2 max pool
print(pooled)In practice
Parameter sharing & feature maps
The idea
Why convolutions instead of dense layers on images? A dense layer wires every pixel to every output — the parameter count explodes. A conv layer reuses one tiny kernel across the whole image, so its size depends only on the kernel and channel counts, not the image size. Compare the two and watch the gap.
Picture it
In the companion lab you can grow the image and the channels; compare conv vs dense parameters.
The math
Derivation
The conv count has no H,W term — the same k×k weights are shared across all H·W positions. The dense count multiplies the full flattened sizes, so it grows like the fourth power of the image side.
Code
# Parameter count of a conv layer vs a dense layer on the same input
# Conv: K*K*C_in*C_out (shared across all spatial positions)
K, Cin, Cout = 3, 64, 128
conv = K*K*Cin*Cout
dense = (32*32*Cin)*(32*32*Cout) # if you used a dense layer
print(f"conv params={conv:,} dense params={dense:,}") # weight sharing winsIn practice
Autoencoder
The idea
An autoencoder squeezes input through a narrow bottleneck and reconstructs it. The squeeze forces it to learn the data’s low-dimensional structure — the manifold the points actually live on. Add noise and watch the 1-D bottleneck project messy points back onto the clean curve (denoising).
Picture it
In the companion lab you can add noise; the bottleneck reconstruction snaps points back to the learned curve.
The math
Derivation
With a bottleneck the best reconstruction lands on a learned dim(z)-manifold; a linear AE recovers PCA, a nonlinear one a curved manifold. Off-manifold noise is discarded — so the AE denoises by projection.
Code
import numpy as np
# Autoencoder: compress to a bottleneck z, then reconstruct
def encode(x, We): return np.maximum(0, x @ We)
def decode(z, Wd): return z @ Wd
x = np.random.randn(10, 64)
We = np.random.randn(64, 8); Wd = np.random.randn(8, 64) # bottleneck=8
recon = decode(encode(x, We), Wd)
print("reconstruction MSE:", round(np.mean((x-recon)**2), 3))In practice
Variational autoencoder
The idea
A VAE makes the latent space probabilistic: the encoder outputs a Gaussian q(z|x), and a KL term pulls every such Gaussian toward the standard-normal prior. That keeps the latent space smooth and samplable, so you can draw new z from the prior and decode novel data. Tune q and watch the KL.
Picture it
In the companion lab you can set the encoder’s q(z|x). KL → 0 as q matches the prior N(0,1).
The math
Derivation
Maximizing the data likelihood is intractable, so we maximize its ELBO lower bound. The reparameterization trick writes z as a deterministic function of (μ,σ,ε), letting gradients flow through the sampling step.
Code
import numpy as np
# VAE: encoder outputs mu, logvar; sample z with the reparam trick
mu, logvar = np.zeros(8), np.zeros(8)
eps = np.random.randn(8)
z = mu + np.exp(0.5*logvar) * eps # differentiable sampling
# KL term pulls q(z|x) toward N(0,I)
kl = -0.5 * np.sum(1 + logvar - mu**2 - np.exp(logvar))
print("z:", np.round(z[:3],2), " KL:", round(kl,3))In practice
GAN: generator vs discriminator
The idea
A GAN pits two networks against each other: the generator invents fake samples, the discriminator tries to tell real from fake. They reach equilibrium only when the fakes are indistinguishable — at which point the discriminator is reduced to a coin flip everywhere. Move the generator toward the real data and watch D collapse to 0.5.
Picture it
In the companion lab you can steer the generator toward the real distribution; the optimal D flattens to 0.5.
The math
Derivation
For fixed G the inner max is solved pointwise by D*. Substituting it back leaves the Jensen–Shannon divergence between p_data and p_g — minimized (to 0) exactly when p_g = p_data, where D* = ½ everywhere.
Code
import numpy as np
# GAN objective: generator fools a discriminator (minimax game)
def D(x, w): return 1/(1+np.exp(-(x@w))) # discriminator
real = np.random.randn(100, 4) + 3
fake = np.random.randn(100, 4) # from generator
w = np.random.randn(4)
# D wants D(real)->1, D(fake)->0; G wants D(fake)->1
loss_D = -np.mean(np.log(D(real,w)) + np.log(1-D(fake,w)+1e-9))
print("discriminator loss:", round(loss_D, 3))In practice
Diffusion: noise & denoise
The idea
Diffusion models learn to reverse a gradual noising process. Forward, you blend a clean sample with more and more Gaussian noise until it’s pure static; a network then learns to undo one step at a time, so you can start from noise and generate. Slide the noise level to watch a shape dissolve and the signal schedule decay.
Picture it
In the companion lab you can increase the timestep t — the ring dissolves into Gaussian noise as the signal √ā fades.
The math
Derivation
Composing many small Gaussian steps stays Gaussian, giving the closed form above. Training fits ε_θ(x_t, t) to predict the added noise; sampling subtracts the predicted noise step by step from x_T ~ N(0,I).
Code
import numpy as np
# Forward diffusion: gradually add Gaussian noise over T steps
x0 = np.array([1.0, 0.5])
betas = np.linspace(1e-4, 0.2, 50)
alpha_bar = np.cumprod(1 - betas)
t = 30
xt = np.sqrt(alpha_bar[t])*x0 + np.sqrt(1-alpha_bar[t])*np.random.randn(2)
print("noised sample at t=30:", np.round(xt, 2)) # model learns to reverse thisIn practice
Overfitting & early stopping
The idea
Train long enough and the training loss keeps falling while the validation loss bottoms out and then climbs — the model is starting to memorize noise. The gap between the two is the generalization error. Slide the stop epoch to the validation minimum: that’s early stopping, the cheapest regularizer there is.
Picture it
In the companion lab you can choose where to stop training; watch the train–validation gap.
The math
Derivation
Each gradient step lets weights grow and fit finer detail — signal first, then noise. Halting early bounds the effective parameter movement, so it acts like an implicit norm penalty (closely related to L2 for linear models).
Code
import numpy as np
# Early stopping: halt when validation loss stops improving (patience)
val = [1.0, 0.7, 0.55, 0.5, 0.52, 0.53, 0.55]
best, wait, patience, stop = np.inf, 0, 2, None
for epoch, v in enumerate(val):
if v < best: best, wait = v, 0
else:
wait += 1
if wait >= patience: stop = epoch; break
print("stopped at epoch", stop) # before it overfitsIn practice
Dropout
The idea
Dropout randomly switches off a fraction of units on every forward pass, so the network can’t lean on any single neuron — it must spread the work. Each pass trains a different thinned sub-network; at test time you use them all, scaled. Resample the mask and change the drop rate.
Picture it
In the companion lab you can set the drop rate and resample the mask — each draw is a different sub-network.
The math
Derivation
Zeroing units samples one of 2ⁿ thinned networks; training averages over them, and the test-time full network approximates that ensemble’s average. The 1/(1−p) rescale keeps E[h̃] = h.
Code
import numpy as np
# Dropout: randomly zero activations at train time; scale to keep the mean
def dropout(h, p=0.5, train=True):
if not train: return h
mask = (np.random.rand(*h.shape) > p) / (1-p) # inverted dropout
return h * mask
h = np.ones((2, 6))
print(dropout(h, p=0.5)) # ~half zeroed, rest scaled by 2In practice
Weight decay (L2)
The idea
Weight decay adds a penalty on the size of the weights, nudging them toward zero each step. Big weights mean a wiggly, high-variance fit; shrinking them smooths the function. Turn the penalty λ up and watch an overfit curve relax toward a gentle trend.
Picture it
In the companion lab you can increase the L2 penalty λ and watch the fit smooth and the weights shrink.
The math
Derivation
The extra λ‖w‖² adds 2λw to the gradient, so every step first multiplies w by (1−ηλ) — a constant pull toward 0. It’s the MAP estimate under a Gaussian prior on the weights (= ridge regression).
Code
import numpy as np
# Weight decay = L2 penalty; the gradient pulls weights toward 0 each step
w, lr, lam = np.array([2.0, -3.0]), 0.1, 0.05
grad = np.array([0.0, 0.0]) # suppose data gradient is 0
for _ in range(50):
w -= lr*(grad + lam*w) # decay term shrinks w
print(np.round(w, 3)) # decays toward 0In practice
Label smoothing
The idea
Hard one-hot targets push the network to make a class infinitely more likely than the rest — driving logits to extremes and breeding overconfidence. Label smoothing replaces the 1 with a slightly-less-than-1 and spreads the rest evenly, giving the loss a finite optimum and better-calibrated probabilities. Slide ε.
Picture it
In the companion lab you can increase ε to soften the target; the optimal logit gap becomes finite.
The math
Derivation
With a hard target the CE keeps falling as the true logit → ∞ (never satisfied). A soft target is matched at a finite logit where p_true = 1−ε+ε/K, so the minimizer stops at sensible confidence rather than diverging.
Code
import numpy as np
# Label smoothing: soften one-hot targets to curb overconfidence
def smooth(onehot, eps=0.1):
K = len(onehot)
return (1-eps)*onehot + eps/K
print(np.round(smooth(np.array([0,1,0,0.])), 3)) # [.025 .925 .025 .025]In practice
Recurrent neural networks
The idea
An RNN reads a sequence one step at a time, carrying a hidden state that summarizes everything seen so far. The same weights apply at every step, so it handles any length. Feed a short input pulse and tune the recurrence: a strong self-weight makes the memory linger long after the input is gone.
Picture it
In the companion lab you can a pulse enters at the start; the recurrent weight sets how long the memory persists.
The math
Derivation
Unrolling the recurrence in time turns the RNN into a (very deep) feed-forward net with shared weights. The hidden state is a learned running summary; w_h near 1 lets information persist across many steps.
Code
import numpy as np
# Vanilla RNN: carry a hidden state across time steps
def rnn_step(x, h, Wx, Wh, b): return np.tanh(x@Wx + h@Wh + b)
Wx,Wh,b = np.random.randn(4,8), np.random.randn(8,8), np.zeros(8)
h = np.zeros(8)
for x in np.random.randn(6, 4): # sequence of length 6
h = rnn_step(x, h, Wx, Wh, b)
print(h.shape) # final hidden summaryIn practice
Backprop through time
The idea
Training an RNN means backpropagating the error through every timestep — and the gradient to an early step is a product of one factor per step in between. If that factor is below one the gradient vanishes over time (the net forgets long-range dependencies); above one it explodes. Set the recurrent weight and sequence length to see the decay.
Picture it
In the companion lab you can see how the gradient to step t shrinks with distance from the output.
The math
Derivation
The chain rule multiplies one Jacobian factor per timestep. A product of k factors each <1 decays geometrically to 0 (vanishing); each >1 blows up (exploding) — the same depth problem, now stretched along time.
Code
import numpy as np
# Backprop through time: gradient is a product of Jacobians -> can explode/vanish
Wh = np.array([[1.1, 0.],[0., 0.9]]) # recurrent weight
g = np.eye(2)
for t in range(20):
g = g @ Wh # repeated multiplication
print("grad norms after 20 steps:", np.round(np.diag(g), 2)) # 1.1^20 vs 0.9^20In practice
LSTM gates
The idea
The LSTM fixes vanishing gradients with a protected cell state updated by addition, not repeated multiplication. A forget gate decides how much of the old memory to keep and an input gate how much new to write. With the forget gate near 1 a value rides the cell state for many steps untouched — a gradient highway. Tune the gates.
Picture it
In the companion lab you can a value is written early; the forget gate controls how long the cell state remembers it.
The math
Derivation
∂c_t/∂c_{t−1} = f. With f ≈ 1 the gradient passes through nearly unchanged — the “constant error carousel” — so error signals survive across long gaps instead of decaying like w_h^k.
Code
import numpy as np
# LSTM cell: gates decide what to forget, write, and read from the cell state
sig = lambda z: 1/(1+np.exp(-z))
def lstm(x, h, c, W):
z = np.concatenate([x, h]) @ W # combined gate pre-activations
f,i,o,g = np.split(z, 4)
c = sig(f)*c + sig(i)*np.tanh(g) # forget + input gates
h = sig(o)*np.tanh(c) # output gate
return h, c
print("gates route gradients past the vanishing problem")In practice
Seq2seq & the need for attention
The idea
A basic encoder–decoder crams an entire input sequence into one fixed-size context vector, then decodes from it. For long inputs that bottleneck loses information — each token gets a smaller share. Attention removes the bottleneck by letting the decoder look back at every encoder state. Grow the sequence and toggle attention.
Picture it
In the companion lab you can lengthen the input and toggle attention to remove the single-vector bottleneck.
The math
Derivation
A fixed vector has constant capacity, so information per input token ∼ 1/length — it degrades as inputs grow. Attention gives the decoder a content-weighted view of all encoder states, so capacity scales with length.
Code
import numpy as np
# Encoder-decoder: encode a sequence to a context, decode a new sequence
def encode(seq, h, step):
for x in seq: h = step(x, h)
return h # context vector
# decoder then generates tokens conditioned on the context (+ attention)
print("context summarizes the input for the decoder")In practice
Residual stream & LayerNorm
The idea
Transformers are deep because of a simple trick: every sublayer adds its output back to a running “residual stream” instead of replacing it. That additive highway keeps signal and gradient stable through dozens of layers. LayerNorm standardizes each token before the sublayer reads it. Toggle the skip connection to see depth blow up without it.
Picture it
In the companion lab you can stack more layers; toggle the residual skip to see the activation scale with and without it.
The math
Derivation
∂(x+F(x))/∂x = I + ∂F/∂x, so the gradient always has a clean +1 path — it can’t vanish through the skip. Without it, signal scales like (gain)^depth and explodes or dies, exactly the depth problem.
Code
import numpy as np
# Residual connection: y = x + f(x); gradients flow through the identity path
def block(x, f): return x + f(x)
x = np.random.randn(4, 8)
y = block(x, lambda z: 0.01*z) # even if f is tiny, x passes through
print(np.allclose(y, x, atol=0.1)) # signal preserved across depthIn practice
Position-wise feed-forward
The idea
Between attention blocks sits a small MLP applied to each token independently: project up to a wider hidden size, apply a nonlinearity (GELU), project back down. Attention mixes information across tokens; the FFN does the per-token “thinking” and holds most of the parameters. Resize it and watch the cost.
Picture it
In the companion lab you can set the model width and expansion factor; see the up-then-down projection and parameter count.
The math
Derivation
The same two matrices act on every position (a 1×1 “conv” over the sequence) — hence “position-wise.” The widening to r·d gives capacity for nonlinear feature mixing before compressing back to the residual width.
Code
import numpy as np
# Position-wise feed-forward: expand, nonlinearity, project back (per token)
def ffn(x, W1, W2):
return np.maximum(0, x @ W1) @ W2 # d -> 4d -> d
x = np.random.randn(10, 64)
W1, W2 = np.random.randn(64,256), np.random.randn(256,64)
print(ffn(x, W1, W2).shape) # (10, 64)In practice
Multi-head attention
The idea
Rather than one big attention, a Transformer splits the model width into several heads, each attending in its own smaller subspace, then concatenates and projects the results. Different heads can specialize — one tracks the previous token, another long-range agreement. Change the head count and watch the width partition.
Picture it
In the companion lab you can change the number of heads; the model width d splits into h subspaces of size d/h.
The math
Derivation
Splitting d into h subspaces costs the same total compute as one head of size d, but lets the model attend to several patterns at once. Each head’s d/h dimensions keep its dot products well-scaled (the √(d/h) factor).
Code
import numpy as np
def softmax(z): e=np.exp(z-z.max(-1,keepdims=True)); return e/e.sum(-1,keepdims=True)
# Multi-head attention: run several attentions in parallel, concatenate
def head(X, Wq, Wk, Wv, dk):
Q,K,V = X@Wq, X@Wk, X@Wv
return softmax(Q@K.T/np.sqrt(dk)) @ V
X = np.random.randn(5, 64); H, dk = 8, 8
outs = [head(X, *[np.random.randn(64,dk) for _ in range(3)], dk) for _ in range(H)]
print(np.concatenate(outs, -1).shape) # (5, 64)In practice
A Transformer block, assembled
The idea
Put it together: each block routes the residual stream through two sublayers — multi-head attention then the feed-forward network — each wrapped in LayerNorm and a skip connection. Stack N of these and you have GPT or BERT. Add a causal mask and it can only look left, which is what makes a decoder generate left-to-right.
Picture it
In the companion lab you can stack more blocks; toggle the causal mask (decoder) vs full attention (encoder).
The math
Derivation
Two residual sublayers per block: attention mixes across tokens, FFN transforms each token. A causal mask sets attention scores to −∞ above the diagonal, so position t sees only ≤ t — enabling autoregressive next-token prediction.
Code
import numpy as np
# A transformer block = (attention + residual + norm) then (FFN + residual + norm)
def ln(x): return (x-x.mean(-1,keepdims=True))/(x.std(-1,keepdims=True)+1e-5)
def block(x, attn, ffn):
x = ln(x + attn(x)) # pre/post-norm variants exist
x = ln(x + ffn(x))
return x
print("stack N of these to build the encoder/decoder")In practice
Self-supervised pretext tasks
The idea
Labels are scarce, but raw data is everywhere — so self-supervision invents its own labels by hiding part of the input and asking the model to predict it. Masked language modeling hides a token; the model must use the surrounding context to fill it in, learning rich representations for free. Slide the mask along the sentence.
Picture it
In the companion lab you can move the mask; the model predicts the hidden token from both-side context.
The math
Derivation
Predicting a hidden piece forces the model to capture how parts of the data relate. Pretraining on this objective over huge corpora yields features that transfer; a small labeled set then fine-tunes for the real task.
Code
import numpy as np
# Pretext task: hide part of the input, predict it (no human labels)
sentence = list("the cat sat on the mat")
mask_at = 4
target = sentence[mask_at]; sentence[mask_at] = "_"
print("".join(sentence), "-> predict:", repr(target)) # masked modelingIn practice
Contrastive learning (InfoNCE)
The idea
Contrastive learning shapes an embedding space by example: pull an anchor toward its positive (two views of the same thing) and push it away from a batch of negatives. The InfoNCE loss is a softmax over similarities — essentially “classify which item is the true match.” Drag the anchor and set the temperature.
Picture it
In the companion lab you can drag the anchor; the loss drops as it nears its positive and leaves the negatives.
The math
Derivation
It’s cross-entropy for a classifier whose “classes” are the candidates: the positive should win the softmax. Low τ sharpens focus on the hardest negatives; the gradient pulls a toward p and away from whichever negatives are closest.
Code
import numpy as np
# Contrastive (InfoNCE): pull two views of the same item together, push others apart
def info_nce(z, zp, temp=0.1):
z = z/np.linalg.norm(z,axis=1,keepdims=True)
zp = zp/np.linalg.norm(zp,axis=1,keepdims=True)
logits = z @ zp.T / temp # similarity matrix
labels = np.arange(len(z)) # positives on the diagonal
e = np.exp(logits - logits.max(1,keepdims=True))
p = e[labels, labels]/e.sum(1)
return -np.log(p).mean()
print(round(info_nce(np.random.randn(8,16), np.random.randn(8,16)), 2))In practice
Augmentation & invariance
The idea
Where do positive pairs come from when there are no labels? From the same example, augmented two different ways — crop, flip, recolor. The model is trained to map both views to the same embedding, learning to ignore the nuisances augmentation changes while keeping what matters. Crank the augmentation strength.
Picture it
In the companion lab you can increase augmentation strength; the two views differ more, yet should embed to the same point.
The math
Derivation
Treating two augmentations of one image as a positive pair bakes in invariance to those transforms. Choosing augmentations is choosing which factors to discard — the inductive bias that makes self-supervised features useful.
Code
import numpy as np
# Augmentations create two "views" the model must agree on
def augment(x):
x = x + 0.1*np.random.randn(*x.shape) # jitter
if np.random.rand() < 0.5: x = x[..., ::-1] # flip
return x
img = np.random.rand(8, 8)
v1, v2 = augment(img), augment(img) # positive pair
print(v1.shape, v2.shape)In practice
Representation collapse
The idea
There’s a cheap way to make all positive pairs match: map everything to the same point. That’s collapse — a useless constant encoder. Negatives prevent it by demanding that different inputs stay apart (uniformity). Toggle the negatives off and watch the embeddings implode to a dot.
Picture it
In the companion lab you can turn negatives off and the embeddings collapse to one point; on, they spread out.
The math
Derivation
With only an “attract positives” term, the global optimum is a constant function. Negatives (or alternatives below) add a repulsive term, forcing the encoder to spread inputs out — retaining information.
Code
import numpy as np
# Collapse: without negatives/stop-grad, all embeddings become identical
emb = np.random.randn(100, 16)
for _ in range(50):
emb = 0.9*emb.mean(0) + 0.1*emb # naive "pull together" only
print("embedding variance:", round(emb.var(), 4)) # -> ~0, collapsedIn practice
Markov decision process & value
The idea
Reinforcement learning casts a problem as states, actions, rewards, and transitions — a Markov decision process. The value of a state is the total (discounted) future reward you expect from it under good play. High value flows outward from the goal, and the greedy arrows trace the optimal path. Tune the discount.
Picture it
In the companion lab you can change the discount γ (how much the future counts). Values and the greedy policy update.
The math
Derivation
A reward d steps away is worth γᵈ now, so γ<1 makes the sum finite and prefers sooner rewards. The optimal value satisfies a recursive consistency (the Bellman equation), and acting greedily w.r.t. V* is optimal.
Code
import numpy as np
# An MDP: states, actions, transition probs, rewards, discount gamma
states = [0, 1]; gamma = 0.9
P = {0:{ 'a':[(0.8,0,1.0),(0.2,1,0.0)] }, 1:{ 'a':[(1.0,1,0.0)] }} # (prob, s', r)
# expected immediate reward of taking 'a' in state 0
exp_r = sum(p*r for p,_,r in P[0]['a'])
print("E[r | s=0, a] =", exp_r)In practice
The Bellman equation
The idea
The Bellman equation is the recursion at the heart of RL: a state’s value equals the immediate reward plus the discounted value of the best next state. Value iteration just applies it as an update, sweep after sweep, and the values ripple outward from the goal until they stop changing. Step it and watch the wavefront spread.
Picture it
In the companion lab you can apply the Bellman backup one sweep at a time; values propagate from the goal.
The math
Derivation
The Bellman optimality operator is a γ-contraction in max-norm, so repeated application has a unique fixed point V* and converges geometrically. Each sweep lets information travel one more step back from the goal.
Code
import numpy as np
# Value iteration solves the Bellman optimality equation
R = np.array([0., 1.]); gamma = 0.9
P = np.array([[0.5,0.5],[0.,1.]]) # transition under the (only) policy
V = np.zeros(2)
for _ in range(100):
V = R + gamma * P @ V # Bellman backup
print(np.round(V, 2))In practice
Policy gradient
The idea
Instead of valuing states, policy-gradient methods optimize the action policy directly. Sample actions, see which earned high reward, and nudge the policy to make those more likely — gradient ascent on expected return. Step it and watch a Gaussian policy drift onto the reward peak.
Picture it
In the companion lab you can run policy-gradient steps; the policy mean climbs toward the high-reward region.
The math
Derivation
The log-derivative trick rewrites the gradient of an expectation as an expectation of ∇logπ·R, estimable from samples. Subtracting a baseline (e.g. mean return) cuts variance without bias — the actor-critic idea.
Code
import numpy as np
# REINFORCE: push up log-prob of actions weighted by the return
def softmax(z): e=np.exp(z-z.max()); return e/e.sum()
theta = np.zeros(2); lr = 0.1
for ep in range(2000):
p = softmax(theta); a = np.random.choice(2, p=p)
R = 1.0 if a == 1 else 0.0 # reward action 1
grad = -p; grad[a] += 1 # d log pi / d theta
theta += lr * R * grad
print(np.round(softmax(theta), 2)) # -> favors action 1In practice
Exploration vs exploitation
The idea
An agent that always picks its current best guess may never discover a better option; one that always explores never cashes in. The multi-armed bandit is the cleanest version of this dilemma. ε-greedy explores at random a fraction ε of the time. Pull the arms and watch the estimates sharpen and regret accumulate.
Picture it
In the companion lab you can set ε and pull; estimates converge to the true arm values, regret grows slower with good ε.
The math
Derivation
The running mean is an unbiased value estimate; a little forced exploration guarantees every arm is sampled enough to be ranked correctly. UCB/Thompson sampling explore smarter — by optimism or posterior sampling — for lower regret.
Code
import numpy as np
# Exploration vs exploitation: epsilon-greedy over action values
Q = np.array([0.1, 0.9, 0.3]); eps = 0.1
def act(Q, eps):
return np.random.randint(len(Q)) if np.random.rand() < eps else Q.argmax()
choices = [act(Q, eps) for _ in range(1000)]
print("explored non-greedy", round(np.mean(np.array(choices)!=1), 2), "of the time")In practice
Graphs & the adjacency matrix
The idea
A graph is nodes joined by edges — social networks, molecules, road maps. Its structure is captured by the adjacency matrix A, where A_{ij}=1 if i and j are linked. A node’s row lists its neighbors, and the degree is the row sum. Pick a node to light up its neighborhood and the matching matrix row.
Picture it
In the companion lab you can select a node to highlight its neighbors and its row/column in the adjacency matrix.
The math
Derivation
The adjacency matrix turns graph structure into linear algebra: A·x spreads a signal x to neighbors, A² reaches two hops, and the eigenvectors of A (or the Laplacian D−A) reveal communities and the smooth modes GNNs operate on.
Code
import numpy as np
# A graph: node features X and an adjacency matrix A
A = np.array([[0,1,1,0],[1,0,1,0],[1,1,0,1],[0,0,1,0.]]) # 4 nodes
X = np.random.randn(4, 8) # node features
deg = A.sum(1) # node degrees
print("degrees:", deg)In practice
Message passing
The idea
A graph neural net updates each node by gathering messages from its neighbors, aggregating them (sum or mean), and combining with its own state. One round mixes information one hop; repeat and a signal diffuses across the graph. Inject a signal at one node and step the rounds to watch it spread.
Picture it
In the companion lab you can node 0 starts with a signal; each round averages neighbors so it diffuses outward.
The math
Derivation
Stacking k message-passing layers gives each node a receptive field of its k-hop neighborhood — the graph analogue of a CNN’s growing field. Permutation-invariant aggregators (sum/mean) make the output independent of neighbor ordering.
Code
import numpy as np
# Message passing: each node aggregates features from its neighbors
A = np.array([[0,1,1,0],[1,0,1,0],[1,1,0,1],[0,0,1,0.]])
X = np.random.randn(4, 8)
msg = A @ X # sum of neighbor features
agg = msg / A.sum(1, keepdims=True).clip(1) # mean aggregation
print(agg.shape) # (4, 8) updated per nodeIn practice
Graph convolution (GCN)
The idea
The graph convolutional layer is message passing with a normalized adjacency and a learnable weight: aggregate neighbors (scaled by degree so high-degree nodes don’t dominate), multiply by W, apply a nonlinearity. Stacking L layers grows each node’s receptive field to L hops. Slide the depth to see how far a node “sees.”
Picture it
In the companion lab you can set the number of GCN layers; node 0’s receptive field grows one hop per layer.
The math
Derivation
Adding self-loops (A+I) keeps a node’s own features; the D̃^{−1/2}·Ã·D̃^{−1/2} scaling is a first-order approximation of spectral graph convolution, keeping eigenvalues bounded so deep stacks stay stable. W is the only learned part.
Code
import numpy as np
# GCN layer: symmetric-normalized propagation H = D^-.5 A~ D^-.5 X W
A = np.array([[0,1,1,0],[1,0,1,0],[1,1,0,1],[0,0,1,0.]])
A_tilde = A + np.eye(4) # add self-loops
d = A_tilde.sum(1); Dinv = np.diag(1/np.sqrt(d))
Ahat = Dinv @ A_tilde @ Dinv
X, W = np.random.randn(4,8), np.random.randn(8,16)
H = np.maximum(0, Ahat @ X @ W)
print(H.shape) # (4, 16)In practice
Over-smoothing
The idea
GNNs have a depth paradox: more layers reach farther, but each layer also averages neighbors — and too much averaging makes every node’s features converge to the same value. Discriminative signal washes out, so very deep GNNs often do worse. Increase the layers and watch the colors collapse toward uniform.
Picture it
In the companion lab you can add layers; repeated neighbor-averaging drives all node features toward the same value.
The math
Derivation
The normalized propagation is a smoothing (low-pass) operator; iterating it is power iteration toward its top eigenvector, so differences between nodes shrink geometrically. Beyond a few layers, embeddings become nearly indistinguishable.
Code
import numpy as np
# Oversmoothing: too many GCN layers make all node features converge
A = np.array([[0,1,1,0],[1,0,1,0],[1,1,0,1],[0,0,1,0.]]) + np.eye(4)
d = A.sum(1); Ahat = np.diag(1/d) @ A
X = np.random.randn(4, 8)
for _ in range(50): X = Ahat @ X # repeated smoothing
print("feature variance across nodes:", round(X.var(0).mean(), 5)) # ->0In practice
Quantization
The idea
Weights trained in 32-bit float can usually be stored in far fewer bits. Quantization snaps each value to the nearest level on a coarse grid — INT8 or even INT4 — shrinking the model 4–8× and speeding up integer math, at the cost of small rounding error. Drop the bit-width and watch the grid coarsen and error rise.
Picture it
In the companion lab you can reduce the bit-width; weights snap to a coarser grid — smaller model, larger rounding error.
The math
Derivation
b bits give 2ᵇ levels spaced by step s, so each weight is off by at most s/2 (uniform error, std s/√12). Halving bits doubles the step — error grows geometrically, which is why 8-bit is usually safe and 4-bit needs care.
Code
import numpy as np
# Quantize float32 weights to int8 (scale + round); ~4x smaller
w = np.random.randn(1000).astype(np.float32)
scale = np.abs(w).max() / 127
q = np.round(w / scale).astype(np.int8) # store int8 + one scale
w_recon = q.astype(np.float32) * scale
print("max error:", round(np.abs(w - w_recon).max(), 4))In practice
Pruning & sparsity
The idea
Most weights in a trained network are near zero and contribute little. Pruning sets the smallest-magnitude ones to zero, producing a sparse model that stores and computes less. Accuracy holds up to surprisingly high sparsity, then falls off a cliff. Slide the pruning fraction to sparsify the matrix and trace the trade-off.
Picture it
In the companion lab you can prune the smallest-magnitude weights; accuracy holds, then drops past a point.
The math
Derivation
Magnitude is a cheap proxy for importance: tiny weights change the output little, so zeroing them barely moves the loss — until you cut into load-bearing weights and accuracy collapses. Fine-tuning after pruning recovers much of the loss.
Code
import numpy as np
# Magnitude pruning: zero out the smallest-magnitude weights
w = np.random.randn(10, 10)
k = 0.5 # prune 50%
thresh = np.quantile(np.abs(w), k)
w_pruned = w * (np.abs(w) >= thresh)
print("sparsity:", round((w_pruned == 0).mean(), 2))In practice
Knowledge distillation
The idea
A small “student” can be trained to mimic a big “teacher.” The trick is to learn from the teacher’s soft probabilities, not just the hard label: the relative scores across wrong classes (“dark knowledge”) reveal which classes look similar, a much richer signal. Raise the temperature to soften the targets.
Picture it
In the companion lab you can raise the softmax temperature T; the teacher’s targets soften, exposing inter-class similarity.
The math
Derivation
Higher T flattens the softmax, amplifying the small logits that encode “class 2 looks a bit like class 4.” Matching those soft targets transfers more information per example than a one-hot label; the T² factor keeps gradient scale constant.
Code
import numpy as np
# Knowledge distillation: student matches teacher's soft probabilities
def softmax_T(z, T): e=np.exp(z/T - (z/T).max()); return e/e.sum()
teacher_logits = np.array([4., 2., 1.])
soft = softmax_T(teacher_logits, T=2.0) # softened targets carry "dark knowledge"
print(np.round(soft, 3)) # student trains on theseIn practice
Mixture of experts
The idea
A mixture-of-experts layer holds many expert sub-networks but routes each token to only a few via a learned gate. Parameters scale with the number of experts, while compute scales only with how many you activate — so you grow capacity almost for free. Set the expert count and the top-k routing.
Picture it
In the companion lab you can set the number of experts and how many each token uses (top-k). Params grow; compute stays low.
The math
Derivation
Only k of N experts fire per token, so FLOPs depend on k while the parameter count depends on N — decoupling capacity from cost. A load-balancing loss keeps the gate from collapsing onto a few experts.
Code
import numpy as np
def softmax(z): e=np.exp(z-z.max()); return e/e.sum()
# Mixture of Experts: a router sends each token to its top-k experts
x = np.random.randn(8) # a token
gate = softmax(np.random.randn(4)) # scores over 4 experts
topk = gate.argsort()[::-1][:2] # use only 2 (sparse)
print("routed to experts:", topk, " (rest skipped -> cheap)")In practice
Saliency: input gradients
The idea
The simplest explanation of a prediction: which input pixels would change it most? That’s the gradient of the output with respect to the input — a saliency map. Raw gradients are noisy, so SmoothGrad averages the map over many slightly-noised copies of the input. Slide the smoothing and watch the edges sharpen out of the noise.
Picture it
In the companion lab you can increase SmoothGrad samples; averaging cancels gradient noise and the salient edges emerge.
The math
Derivation
The gradient says how a tiny pixel change moves the score — a first-order local explanation. Because that gradient is jagged, adding noise and averaging (SmoothGrad) keeps the consistent signal (edges) while the random part shrinks like 1/√n.
Code
import numpy as np
# Saliency: gradient of the output w.r.t. each input pixel (sensitivity)
def model(x, w): return 1/(1+np.exp(-(x@w)))
x, w = np.random.randn(16), np.random.randn(16)
p = model(x, w)
saliency = (p*(1-p))*w # d sigmoid / d x
print("most influential input:", np.abs(saliency).argmax())In practice
Integrated gradients
The idea
Plain gradients can be zero exactly where a feature already saturated the output — missing its importance. Integrated gradients fix this by accumulating gradients along a straight path from a neutral baseline to the actual input. The attributions then satisfy completeness: they sum to the change in output. Add path steps and watch the sum converge.
Picture it
In the companion lab you can increase the number of path steps; the Riemann sum converges and completeness holds.
The math
Derivation
By the fundamental theorem of calculus, integrating the gradient along the baseline→input path recovers the total output change, split cleanly across features. A Riemann sum over k steps approximates the integral, converging as k grows.
Code
import numpy as np
# Integrated gradients: average the gradient along a path from a baseline
def grad(x, w): p=1/(1+np.exp(-(x@w))); return p*(1-p)*w
x, base, w = np.random.randn(8), np.zeros(8), np.random.randn(8)
steps = 50
ig = (x - base) * np.mean([grad(base + (k/steps)*(x-base), w) for k in range(steps)], 0)
print("attributions sum:", round(ig.sum(), 3)) # satisfies completenessIn practice
Probing & concept directions
The idea
Does a network actually encode a concept — say, sentiment or part-of-speech? Train a simple linear probe on its hidden activations: if a linear boundary separates the concept, the representation encodes it linearly. Probe accuracy tends to rise with depth as features grow more abstract. Slide the layer and watch the classes pull apart.
Picture it
In the companion lab you can move to deeper layers; the two concept classes become linearly separable and probe accuracy climbs.
The math
Derivation
A linear probe has tiny capacity, so it can only succeed if the concept already lies along a direction in activation space. Comparing probes across layers traces where and how a concept becomes explicit — the basis of the “linear representation” view.
Code
import numpy as np
from sklearn.linear_model import LogisticRegression
# Probing: train a simple classifier on frozen activations to test what they encode
acts = np.random.randn(500, 64) # hidden representations
concept = (acts[:, 0] > 0).astype(int) # some property
acc = LogisticRegression().fit(acts, concept).score(acts, concept)
print("probe accuracy:", round(acc, 2)) # high -> info is linearly presentIn practice
Feature visualization
The idea
Another way in: find the input that maximally activates a given neuron, by optimizing the image rather than the weights. The results reveal a hierarchy — early units fire for oriented edges, middle ones for textures and curves, deep ones for object-like motifs. Move through the layers and switch neurons to see the preferred stimulus grow in complexity.
Picture it
In the companion lab you can move through layers (edges → textures → motifs) and switch neurons to see different preferred patterns.
The math
Derivation
Ascending the neuron’s activation w.r.t. the input synthesizes its ideal stimulus. Regularizers (frequency penalties, jitter, transformations) keep the result interpretable rather than adversarial noise — revealing the feature the unit detects.
Code
import numpy as np
# Feature visualization: optimize an input to maximally activate a neuron
def activation(x, w): return x @ w
w = np.random.randn(16)
x = np.random.randn(16)
for _ in range(200):
x += 0.1 * w # ascend the activation
x = np.clip(x, -3, 3)
print("found input that maximizes the neuron:", np.round(x[:4], 1))In practice
Tokenization (BPE)
The idea
Language models don’t read characters or whole words — they read subword tokens. Byte-pair encoding starts from characters and repeatedly merges the most frequent adjacent pair into a new token, so common chunks become single units while rare words still decompose. Slide the merge count and watch words fuse from characters into subwords.
Picture it
In the companion lab you can increase BPE merges; frequent character pairs fuse into longer tokens (fewer, bigger pieces).
The math
Derivation
Greedy frequency-based merging balances vocabulary size against sequence length: frequent words become single tokens (short sequences), rare/novel words fall back to pieces (no out-of-vocab). Byte-level BPE guarantees any string is representable.
Code
# Byte-pair encoding idea: merge the most frequent adjacent pair, repeat
from collections import Counter
tokens = list("low lower lowest")
for _ in range(3):
pairs = Counter(zip(tokens[:-1], tokens[1:]))
best = max(pairs, key=pairs.get)
merged, i = [], 0
while i < len(tokens):
if i < len(tokens)-1 and (tokens[i],tokens[i+1])==best:
merged.append(tokens[i]+tokens[i+1]); i+=2
else: merged.append(tokens[i]); i+=1
tokens = merged
print(tokens)In practice
Decoding: temperature & top-p
The idea
A language model outputs a probability over the next token; how you sample from it shapes the text. Temperature flattens or sharpens the distribution, and nucleus (top-p) sampling keeps only the smallest set of tokens whose probability sums to p. Tune both and watch the candidate set and shape change.
Picture it
In the companion lab you can raise temperature to flatten, lower top-p to truncate to the nucleus (kept = blue).
The math
Derivation
Dividing logits by T > 1 shrinks differences (more random, more diverse); T → 0 is greedy argmax. Nucleus sampling adapts the cutoff to the distribution’s shape — keeping few tokens when the model is confident, many when it’s unsure — unlike fixed top-k.
Code
import numpy as np
# Temperature + top-p sampling shape the next-token distribution
def sample(logits, T=1.0, p=0.9):
z = logits / T; e = np.exp(z - z.max()); probs = e/e.sum()
idx = np.argsort(probs)[::-1]
cum = np.cumsum(probs[idx]); keep = idx[:np.searchsorted(cum, p)+1]
pk = probs[keep]/probs[keep].sum()
return np.random.choice(keep, p=pk)
print(sample(np.array([3.,2.,1.,0.5]), T=0.7))In practice
Scaling laws
The idea
Across orders of magnitude, model loss falls as a clean power law in parameters, data, and compute — a straight line on a log-log plot, bottoming out at an irreducible floor. These laws let you predict a big model’s loss from small runs and budget compute optimally. Slide the compute and watch the loss slide down the line.
Picture it
In the companion lab you can increase the compute budget; loss follows a power law toward the irreducible floor (diminishing returns).
The math
Derivation
Empirically loss is linear in log-log over many decades, so doubling compute cuts the reducible loss by a fixed fraction — until it hits the data/architecture floor E. Given a budget C ≈ 6·N·D, the optimum balances model size and tokens rather than over-growing one.
Code
import numpy as np
# Scaling laws: loss falls as a power law in params/data/compute
def loss(N, alpha=0.076, Nc=8.8e13): return (Nc/N)**alpha
for N in (1e8, 1e9, 1e10, 1e11):
print(f"N={N:.0e} loss~{loss(N):.3f}") # diminishing but predictableIn practice
Alignment: RLHF
The idea
A pretrained model predicts text; it isn’t trained to be helpful or safe. RLHF fixes that: collect human preferences, fit a reward model, then optimize the policy to earn reward — while a KL penalty tethers it to the original model so it doesn’t drift or exploit quirks. Lower the leash and watch the policy chase a spurious reward spike (reward hacking).
Picture it
In the companion lab you can change the KL penalty. Too weak → the policy over-optimizes a fake reward spike; too strong → it barely moves.
The math
Derivation
The reward model is an imperfect proxy for human preference, so pure reward-maximization finds its exploits (reward hacking). The KL term penalizes drift from the trusted base policy, trading reward against staying in-distribution — β sets that balance.
Code
import numpy as np
# RLHF reward modeling: learn from human preferences (Bradley-Terry)
def pref_prob(r_win, r_lose): return 1/(1+np.exp(-(r_win - r_lose)))
# train reward so preferred response scores higher; then RL-optimize the policy
print("P(human prefers A) =", round(pref_prob(2.0, 1.0), 3))In practice
Joint embedding space
The idea
Multimodal models map different kinds of data — images and text — into one shared vector space, so a picture of a dog and the words “a dog” land near each other. Distance then means semantic similarity across modalities, enabling search by either. Pick a caption and watch it retrieve its nearest image.
Picture it
In the companion lab you can select a text query; the nearest image in the shared space is retrieved (its true match).
The math
Derivation
Two separate encoders are trained so paired (image, text) embeddings align. Once the space is shared, cross-modal retrieval is just nearest-neighbor search — no task-specific head — and arithmetic/clustering work across modalities.
Code
import numpy as np
# Joint embedding: map images and text into one shared vector space
img_emb = np.random.randn(10, 64)
text_emb = np.random.randn(10, 64)
def norm(x): return x/np.linalg.norm(x,axis=1,keepdims=True)
sim = norm(img_emb) @ norm(text_emb).T # cross-modal similarity
print(sim.shape) # (10, 10)In practice
Contrastive image–text (CLIP)
The idea
CLIP learns the shared space from a batch of image–text pairs. Compute every image-vs-every-text similarity to form an N×N matrix; the correct pairs lie on the diagonal. Train to push the diagonal up and everything off-diagonal down — a symmetric contrastive loss. Train it and watch the diagonal light up.
Picture it
In the companion lab you can train the contrastive objective; the matching pairs (diagonal) brighten, mismatches darken.
The math
Derivation
Each row is a classification: which of the N captions matches this image? The label is the diagonal. Minimizing cross-entropy both ways aligns paired embeddings and repels the N−1 negatives per example — contrastive learning at batch scale.
Code
import numpy as np
# CLIP: contrastive loss aligns matching image-text pairs on the diagonal
def clip_loss(I, T, temp=0.07):
I = I/np.linalg.norm(I,axis=1,keepdims=True)
T = T/np.linalg.norm(T,axis=1,keepdims=True)
logits = I @ T.T / temp
e = np.exp(logits - logits.max(1,keepdims=True))
p = np.diag(e)/e.sum(1)
return -np.log(p).mean()
print(round(clip_loss(np.random.randn(16,64), np.random.randn(16,64)), 2))In practice
Zero-shot classification
The idea
Because images and label-words share a space, you can classify without training on the classes: embed the image, embed each candidate label as a prompt like “a photo of a {class},” and pick the closest. New classes need only new text — no labeled images, no fine-tuning. Pick an image and read off the similarities.
Picture it
In the companion lab you can choose an image; its embedding is compared to class prompts — the closest wins (no training).
The math
Derivation
The contrastive pretraining already placed images near their describing text, so an unseen class is recognizable as long as you can name it. Prompt wording matters (“prompt engineering”); ensembling templates improves accuracy.
Code
import numpy as np
# Zero-shot classification: compare an image to text prompts of each class
def norm(x): return x/np.linalg.norm(x)
img = np.random.randn(64)
classes = {c: np.random.randn(64) for c in ['cat','dog','car']}
scores = {c: norm(img) @ norm(e) for c, e in classes.items()}
print("prediction:", max(scores, key=scores.get))In practice
The modality gap
The idea
A surprising wrinkle: even after contrastive training, image embeddings and text embeddings sit in two separate regions of the shared space — a persistent “modality gap.” Pairs are still nearest within their offset, so retrieval works, but the two clouds don’t overlap. Toggle the gap to see why it doesn’t break matching.
Picture it
In the companion lab you can toggle the modality gap. Even offset, each text’s nearest text-neighbor structure mirrors the images’.
The math
Derivation
Initialization places each encoder’s outputs in a narrow cone, and the contrastive loss with finite temperature preserves a gap rather than fully merging the clouds. Relative geometry within each modality still matches, so cross-modal similarity ranking is unaffected.
Code
import numpy as np
# Modality gap: image and text embeddings cluster in separate cones
img = np.random.randn(100, 64) + 5 # shifted clusters
text = np.random.randn(100, 64) - 5
gap = np.linalg.norm(img.mean(0) - text.mean(0))
print("distance between modality centroids:", round(gap, 1))In practice
The quadratic cost of attention
The idea
Self-attention compares every token with every other, so an n-token sequence builds an n×n score matrix — compute and memory grow like n². Double the context and the cost quadruples. That wall is why long-context is hard and why cheaper alternatives exist. Slide the sequence length and watch the matrix (and the cost) balloon.
Picture it
In the companion lab you can increase the sequence length; the attention matrix and its cost grow quadratically.
The math
Derivation
Forming all pairwise dot products is n² entries, each O(d) — and storing the matrix is n² memory. For sequences of thousands of tokens this dominates, capping context length and inflating training/inference cost.
Code
# Attention cost grows quadratically with sequence length n
for n in (1_000, 4_000, 16_000, 64_000):
ops = n*n # n^2 attention scores
print(f"n={n:>6}: {ops:.1e} score computations")In practice
The KV cache
The idea
When a model generates text token by token, it would be wasteful to recompute attention over the whole prefix each step. Instead it caches the keys and values of every past token and reuses them. That makes each new token cheap — but the cache grows linearly with context, and for long sequences it becomes the memory bottleneck. Slide the position and watch it fill.
Picture it
In the companion lab you can advance the decoding position; the K/V cache grows with every generated token.
The math
Derivation
Past keys/values don’t change as you append tokens, so caching them turns an O(n²) re-encode into O(n) per step. The price is memory that scales with context length × layers × heads — often gigabytes for long prompts.
Code
# KV cache: store past keys/values so each new token is O(n), not O(n^2)
seq, d = 0, 64
cache_k, cache_v = [], []
def step(k, v):
cache_k.append(k); cache_v.append(v) # append once
return len(cache_k) # attend over all cached
for t in range(5): step([0]*d, [0]*d)
print("cached tokens:", len(cache_k), "(memory grows linearly)")In practice
Linear attention
The idea
The quadratic cost comes from forming QKᵀ first. Replace softmax with a kernel feature map and you can reassociate the product: compute KᵀV once (a small d×d summary) and reuse it for every query — turning O(n²) into O(n). It’s an approximation, but it scales. Slide n and watch linear overtake quadratic.
Picture it
In the companion lab you can grow the sequence; the linear path (green) beats quadratic (pink) as n increases.
The math
Derivation
With a feature map φ replacing exp, attention becomes a sum of outer products φ(k)vᵀ — a d×d running state. Each query reads that state in O(d²), independent of n, and the state updates incrementally (a linear RNN).
Code
import numpy as np
# Linear attention: reorder (Q K^T) V as Q (K^T V) -> O(n) in sequence length
Q = np.random.randn(100, 16); K = np.random.randn(100, 16); V = np.random.randn(100, 16)
KV = K.T @ V # (16,16) once
out = Q @ KV # cheap, no n x n matrix
print(out.shape) # (100, 16)In practice
State-space models
The idea
State-space models process a sequence as a linear recurrence: a hidden state carries information forward, decayed and updated at each step. That’s O(n) and streamable like an RNN — yet it can be unrolled into a parallel scan for fast training. A decay parameter sets the memory horizon; selective SSMs (Mamba) make it input-dependent. Tune the decay.
Picture it
In the companion lab you can set the state decay; closer to 1 means the input pulse is remembered far longer.
The math
Derivation
Unrolling gives y_t = Σ_k C Aᵏ B x_{t−k} — a convolution with an exponentially decaying kernel; |A|→ 1 lengthens memory (horizon ≈ 1/(1−a)). Linearity lets an associative scan compute all states in parallel, unlike a nonlinear RNN.
Code
import numpy as np
# State-space model: a linear recurrence h_t = A h_{t-1} + B x_t
A, B, C = 0.9, 1.0, 1.0
h, ys = 0.0, []
for x in np.random.randn(20):
h = A*h + B*x; ys.append(C*h) # scan; O(n), long memory via A
print("output length:", len(ys))In practice
Residual blocks as ODE steps
The idea
A residual block computes x + f(x) — which is exactly one Euler step of an ODE, dx/dt = f(x), with step size 1. Stacking blocks integrates that ODE forward, and a point’s journey through the network is a trajectory in a vector field. More blocks = finer integration. Add layers and watch the path smooth onto the true flow.
Picture it
In the companion lab you can add residual blocks; each is an Euler step, so more blocks trace the ODE trajectory more finely.
The math
Derivation
Rewriting the residual update as (x_{l+1}−x_l)/h = f(x_l) is the forward-Euler discretization of an ODE. As the number of blocks grows (step → 0), the discrete network limits to a continuous-depth model.
Code
import numpy as np
# A residual net is Euler's method: x_{t+1} = x_t + f(x_t) (step size 1)
def f(x): return -0.5*x
x = np.array([2.0])
for _ in range(5): x = x + f(x) # discrete residual steps
print(np.round(x, 3))In practice
Neural ODEs: adaptive solving
The idea
A Neural ODE replaces discrete layers with a learned vector field f(h,t) and hands the forward pass to an ODE solver. “Depth” becomes integration time, and an adaptive solver spends function evaluations where the dynamics are stiff. Coarse solving is inaccurate; refine it and the trajectory error collapses toward the true solution.
Picture it
In the companion lab you can increase solver steps (function evaluations); the trajectory error vs the true solution drops.
The math
Derivation
The model output is the ODE solution at time T. More function evaluations shrink the discretization error (Euler is O(h); Runge–Kutta higher order). Continuous depth means you can query any t and adjust accuracy at inference.
Code
import numpy as np
# Neural ODE: take the limit of infinitely many tiny residual steps
def f(x, t): return -0.5*x
x, t, dt = np.array([2.0]), 0.0, 0.01
for _ in range(500):
x = x + dt*f(x, t); t += dt # ODE solver (Euler)
print(np.round(x, 3)) # ~ 2 e^{-0.5 t}In practice
The adjoint method
The idea
Backprop normally stores every intermediate activation — memory that grows with depth. Neural ODEs instead solve a second ODE backward in time (the adjoint) to get gradients, reconstructing states on the fly. Memory becomes constant, independent of how many solver steps the forward pass took. Slide the depth and compare.
Picture it
In the companion lab you can increase integration depth; standard backprop memory grows, the adjoint stays flat.
The math
Derivation
The adjoint ODE propagates the loss sensitivity backward; integrating it (alongside the re-derived state) yields parameter gradients without storing the forward trajectory — trading recomputation for memory.
Code
# Adjoint method: get gradients by solving a second ODE backward in time
# -> constant memory regardless of the number of solver steps
# d a/dt = -a^T df/dx ; integrate backward to accumulate parameter grads
print("memory cost: O(1) in solver steps, vs O(steps) for plain backprop")In practice
Continuous normalizing flows
The idea
A continuous normalizing flow turns a simple density into a complex one by flowing samples along an ODE. Track how the log-density changes — it falls by the divergence of the velocity field — and you get exact likelihoods of a flexible distribution. Slide time and watch a Gaussian blob morph into a structured target.
Picture it
In the companion lab you can advance time t; samples flow from the base Gaussian to the target density.
The math
Derivation
The continuous change-of-variables formula replaces a flow’s log-det-Jacobian with a time integral of the trace (divergence) — cheap to estimate. Integrating it alongside z gives exact sample log-likelihoods.
Code
import numpy as np
# Continuous normalizing flow: track how log-density changes along the flow
# d log p / dt = -trace(df/dx)
def trace_jac(x): return -0.5*len(x) # for f(x) = -0.5 x
logp = 0.0
for _ in range(100): logp += -trace_jac(np.zeros(2))*0.01
print("accumulated log-density change:", round(logp, 3))In practice
Score functions
The idea
Score-based models learn the gradient of the log-density, ∇ log p(x) — the score — a vector field that everywhere points toward higher data density. You never need the normalizing constant, and following the field walks you onto the data manifold. Drag the point and watch its score arrow swing toward the nearest mode.
Picture it
In the companion lab you can drag the point; its score vector ∇log p always points toward higher density (the modes).
The math
Derivation
Taking the gradient of log p cancels the intractable partition function, leaving only the shape of the density. Training a network to denoise data at many noise levels provably estimates the score (Vincent’s identity).
Code
import numpy as np
# Score = gradient of log-density; points toward higher probability
mu, sigma = 0.0, 1.0
score = lambda x: -(x - mu)/sigma**2 # for a Gaussian
print([round(score(x), 2) for x in (-2, 0, 2)]) # pulls toward the meanIn practice
Langevin sampling
The idea
Given the score, you can sample without ever writing down p: repeatedly step a little way along the score and add a dash of noise. This Langevin dynamics settles into the data distribution — the noise keeps it from collapsing to a single mode. Step it and watch random dust drift into the density’s basins.
Picture it
In the companion lab you can run Langevin steps; samples climb the score and the noise spreads them across both modes.
The math
Derivation
The drift ε·score pulls samples toward high density; the √(2ε) noise injects exploration so the chain matches p rather than just its peak. It’s gradient ascent on log p with calibrated noise — the sampling analogue of SGD.
Code
import numpy as np
# Langevin sampling: follow the score, add noise -> samples from p
score = lambda x: -x # target N(0,1)
x, eps, samples = 5.0, 0.1, []
for _ in range(5000):
x = x + eps*score(x) + np.sqrt(2*eps)*np.random.randn()
samples.append(x)
s = np.array(samples[500:])
print(round(s.mean(), 2), round(s.std(), 2)) # ~0, ~1In practice
Classifier-free guidance
The idea
To make a conditional generator follow its prompt more strongly, mix two scores: the conditional and the unconditional. Push past the conditional (guidance scale w > 1) and samples lock onto the prompt — sharper and more on-target, but less diverse. Slide w and watch mass concentrate on the conditioned mode as variety shrinks.
Picture it
In the companion lab you can raise the guidance scale; the effective density sharpens onto the conditioned mode (fidelity up, diversity down).
The math
Derivation
The guided score extrapolates from the unconditional toward the conditional; raising w sharpens the implied p(c|x)^w, concentrating probability where the condition is most satisfied. No separate classifier is needed — hence “classifier-free.”
Code
import numpy as np
# Classifier-free guidance: extrapolate from uncond toward cond score
def guided(eps_uncond, eps_cond, w):
return eps_uncond + w*(eps_cond - eps_uncond)
print(guided(0.2, 0.8, w=3.0)) # higher w -> stronger prompt adherenceIn practice
Schedules & samplers
The idea
Diffusion generates by reversing noise over many steps — but how many? More denoising steps mean higher quality and higher cost. Deterministic samplers (DDIM) get usable results in a few steps; stochastic ones (DDPM) need more but can be crisper. Slide the step budget and watch the reconstruction error fall.
Picture it
In the companion lab you can change the number of denoising steps; few steps are coarse, more steps refine the sample toward the target.
The math
Derivation
Each step integrates a slice of the reverse SDE/ODE; finer discretization lowers truncation error, like an ODE solver. DDIM’s deterministic update lets large steps stay accurate, so far fewer evaluations suffice.
Code
import numpy as np
# Fewer denoising steps = faster but coarser; error ~ 1/steps
for steps in (5, 20, 100):
err = 1/np.sqrt(steps)
print(f"{steps:>3} steps -> approx error {err:.3f}")In practice
Reasoning + acting (ReAct)
The idea
An agent isn’t just a one-shot answer — it loops: think about what to do, take an action, observe the result, and repeat until it can answer. Interleaving reasoning with acting lets the model gather facts it doesn’t know and correct course. Step the loop and watch a question resolve through thought–action–observation.
Picture it
In the companion lab you can step the agent loop; each cycle adds a thought, an action, and an observation until it answers.
The math
Derivation
Reasoning traces make the next action better; actions ground the reasoning in real observations, reducing hallucination. The growing transcript is the agent’s working memory — each step conditions on the whole history.
Code
# ReAct loop: interleave reasoning (thought) with acting (tool calls)
trace = []
def step(thought, action, observation):
trace.append(("thought", thought)); trace.append(("action", action))
trace.append(("observation", observation))
step("need the capital", 'search("capital of France")', "Paris")
print(trace[-1]) # the transcript is fed back as context each turnIn practice
Tool calling
The idea
Language models are bad at arithmetic, fresh facts, and precise lookups — so let them call tools. The model emits a structured call (function name + arguments), an external system runs it, and the result is fed back to continue generation. Toggle the tool and watch a calculation flip from a confident guess to the correct answer.
Picture it
In the companion lab you can toggle tool access for a hard arithmetic query; the tool returns the exact result the model would otherwise approximate.
The math
Derivation
The model learns when and how to call a tool, not to compute the answer itself. This grounds outputs in reliable systems (calculators, search, code, databases) and extends capability beyond what weights memorized.
Code
# Tool calling: model emits a structured call; system runs it; result returns
import json
def calculator(expr): return eval(expr) # the external tool
call = {"name": "calculator", "args": {"expr": "47291 * 8633"}}
result = calculator(call["args"]["expr"])
print(json.dumps({"tool_result": result})) # fed back to the modelIn practice
Planning & search
The idea
For multi-step problems, a single greedy chain of thought can wander off. Tree-of-thoughts lets the agent branch — explore several reasoning paths, score partial states, and pursue the most promising while pruning dead ends. Expand the tree and watch the search find a high-value path.
Picture it
In the companion lab you can expand the search tree; nodes are scored and the best path (green) is pursued while weak branches are pruned.
The math
Derivation
Branching converts one fragile chain into a search problem where a value/verifier guides exploration. Pruning low-value branches focuses compute, trading more inference for higher reliability on hard, multi-step tasks.
Code
import numpy as np
# Tree-of-thoughts: branch, score partial states, expand the best
frontier = [("start", 0.5)]
for depth in range(3):
node, score = max(frontier, key=lambda x: x[1]) # best-first
frontier = [(node+f"->{i}", score + np.random.rand()*0.3) for i in range(2)]
print("best path:", max(frontier, key=lambda x: x[1])[0])In practice
Memory & retrieval
The idea
Context windows are finite, so agents can’t keep everything in view. Instead they store information as embeddings and retrieve the few most relevant items for the current query — retrieval-augmented memory. Slide the query and watch the top-k relevant memories light up while the rest stay dormant.
Picture it
In the companion lab you can move the query; the k nearest memories (by embedding similarity) are retrieved into context.
The math
Derivation
Embedding memories into a vector space turns recall into nearest-neighbor search, so relevant context is injected on demand instead of held in the window. This scales memory beyond the context limit and keeps prompts focused.
Code
import numpy as np
# Retrieval-augmented memory: fetch the top-k relevant items by similarity
mem = np.random.randn(100, 64) # stored memories
query = np.random.randn(64)
sims = mem @ query / (np.linalg.norm(mem,axis=1)*np.linalg.norm(query))
topk = sims.argsort()[::-1][:3]
print("retrieved memory ids:", topk) # injected into contextIn practice
Learned dynamics models
The idea
A world model learns the environment’s dynamics: given the current state and an action, predict the next state. Chain those predictions and you can roll the future forward in your head — imagined trajectories, no real interaction needed. Slide the horizon and watch a rollout trace the predicted path.
Picture it
In the companion lab you can extend the rollout horizon; the model chains one-step predictions into an imagined trajectory.
The math
Derivation
Fitting f to (state, action → next state) tuples gives a simulator. Multi-step rollouts compose single-step predictions, enabling planning and data-efficient RL — but errors accumulate (a later card).
Code
import numpy as np
# Learned dynamics: predict next state from state+action, then roll forward
def f(s, a): return np.array([s[0] + 0.1*s[1], s[1] - 0.1*s[0] + a])
s = np.array([1.0, 0.0]); traj = [s]
for t in range(20): s = f(s, a=0.0); traj.append(s) # imagined rollout
print("rollout length:", len(traj))In practice
Latent world models
The idea
Pixels are high-dimensional and noisy; predicting them step by step is wasteful. Latent world models encode observations into a compact latent state, predict there, and decode only when needed. Planning happens in the small latent space. Step the latent rollout and watch the decoded prediction stay coherent.
Picture it
In the companion lab you can step the latent rollout; dynamics are predicted in the compact code, then decoded back to observations.
The math
Derivation
A low-dimensional latent captures the task-relevant structure, so rollouts are cheap and stable; decoding is only needed for visualization or reward. This is the recipe behind PlaNet/Dreamer’s recurrent state-space model.
Code
import numpy as np
# Latent world model: encode obs -> z, predict in latent, decode only to view
encode = lambda o: o @ np.random.randn(64, 8) # to compact latent
predict = lambda z: z @ np.random.randn(8, 8) # cheap latent dynamics
z = encode(np.random.randn(64))
for _ in range(10): z = predict(z) # plan in latent space
print("latent dim:", z.shape)In practice
Planning in imagination
The idea
With a world model in hand, an agent can plan by simulation: sample many candidate action sequences, roll each forward in the model, score the imagined outcomes, and execute the best plan’s first action — then replan. Step it and watch many imagined futures get scored and the best one chosen.
Picture it
In the companion lab you can sample candidate plans; each is rolled out in imagination and scored — the best (green) reaches the goal.
The math
Derivation
The model turns control into search over imagined trajectories — no real-world trials needed. Re-planning each step (receding horizon) corrects for model error and changing conditions.
Code
import numpy as np
# Plan by imagination: sample action sequences, score rollouts, pick the best
def rollout_return(actions): return -np.sum((np.cumsum(actions) - 5)**2)
candidates = [np.random.randn(10) for _ in range(50)]
best = max(candidates, key=rollout_return)
print("execute first action of best plan:", round(best[0], 2))In practice
Compounding model error
The idea
The catch with imagination: tiny one-step prediction errors compound. Each rollout step feeds the model its own slightly-wrong output, so the imagined trajectory drifts from reality — exponentially in the worst case. That caps how far ahead you can plan. Raise the per-step error and watch the prediction peel away from the truth.
Picture it
In the companion lab you can increase the per-step error; the predicted rollout diverges from the true trajectory as the horizon grows.
The math
Derivation
Feeding predictions back as inputs means errors propagate and amplify through the dynamics. Even a near-perfect one-step model can produce useless long rollouts — the core difficulty of model-based RL.
Code
import numpy as np
# Compounding error: per-step error grows over the rollout horizon
eps, L = 0.02, 1.1 # step error, expansion rate
for H in (5, 10, 20, 40):
err = eps*(L**H - 1)/(L - 1)
print(f"horizon {H:>2}: drift ~ {err:.2f}") # grows fastIn practice
Early exit / adaptive depth
The idea
Not every input needs the full network. With early-exit heads, an easy example can be classified after a few layers and skip the rest, while hard ones run deep. The model spends compute in proportion to difficulty. Slide the difficulty and watch the exit layer — and the FLOPs — move.
Picture it
In the companion lab you can change input difficulty; easy inputs exit at a shallow layer (less compute), hard ones run to the end.
The math
Derivation
Adding classifier heads at intermediate layers lets the model halt once it’s confident. Average compute drops because most inputs are easy — the long tail of hard cases still gets full depth.
Code
import numpy as np
# Early exit: stop once an intermediate head is confident enough
def confidence(layer, difficulty): return min(1.0, 0.3 + 0.1*layer - difficulty)
for difficulty in (0.1, 0.6):
layer = next(l for l in range(1, 13) if confidence(l, difficulty) > 0.7)
print(f"difficulty {difficulty}: exits at layer {layer}")In practice
Mixture-of-depths routing
The idea
Mixture-of-depths gives each transformer block a token budget: a router picks which tokens actually pass through the heavy computation, and the rest take a cheap residual shortcut. Total compute is capped regardless of sequence length. Slide the capacity and watch tokens get routed in or skipped.
Picture it
In the companion lab you can set the per-block token capacity; only the top-scoring tokens are processed, the others skip the block.
The math
Derivation
A fixed capacity makes per-block cost independent of how many tokens are “interesting,” giving a static compute graph (good for hardware) while still letting the model allocate depth to the tokens the router deems important.
Code
import numpy as np
# Mixture-of-depths: a router sends only top-k tokens through the heavy block
scores = np.random.rand(24) # router score per token
capacity = 0.5
k = int(capacity*len(scores))
process = set(scores.argsort()[::-1][:k]) # rest skip the block
print(f"{k}/{len(scores)} tokens processed -> {capacity*100:.0f}% FLOPs")In practice
Adaptive computation time
The idea
Some inputs need more thinking steps than others. A halting mechanism lets a network decide, per input, how many recurrent steps to take: it accumulates a halting probability and stops once it crosses a threshold, paying a small “ponder” penalty for extra steps. Slide difficulty and watch the step count adapt.
Picture it
In the companion lab you can change difficulty; the halting unit accumulates probability and stops later for harder inputs.
The math
Derivation
Making the step count data-dependent lets the model allocate computation to difficulty, while the ponder penalty discourages thinking forever. It’s a differentiable approximation to “keep computing until confident.”
Code
import numpy as np
# Adaptive computation time: keep stepping until halting prob crosses 1
def steps_for(difficulty):
cum, t = 0.0, 0
while cum < 1.0 and t < 12:
cum += max(0.05, 0.5 - difficulty); t += 1
return t
print("easy:", steps_for(0.1), " hard:", steps_for(0.45))In practice
Speculative decoding
The idea
Big models decode slowly, one token at a time. Speculative decoding uses a small, fast draft model to guess several tokens ahead, then the big model verifies them all in a single parallel pass — accepting the correct prefix and correcting the first mistake. Slide the acceptance rate and watch the speedup grow.
Picture it
In the companion lab you can change how often the draft’s guesses are accepted; higher acceptance → more tokens per verification → faster.
The math
Derivation
Verification is parallel (one forward pass over k tokens), and a rejection-sampling correction guarantees the same distribution as the target model. So speed improves with no quality loss — the gain scales with draft accuracy.
Code
import numpy as np
# Speculative decoding: a draft model proposes k tokens, target verifies in 1 pass
k, accept_rate = 5, 0.7
accepted = np.random.binomial(k, accept_rate) # longest correct prefix
tokens_per_pass = accepted + 1 # +1 correction
print(f"{tokens_per_pass} tokens/verification (~{tokens_per_pass:.1f}x speedup, lossless)")In practice
Locating facts (causal tracing)
The idea
Where does a model “store” that Paris is in France? Causal tracing finds out: corrupt the input, then restore each hidden state one at a time and see which restoration recovers the correct answer. The layers that matter light up — usually mid-layer MLPs at the subject token. Step the trace and watch the responsible site emerge.
Picture it
In the companion lab you can step the trace; restoring each layer’s activation reveals which sites carry the fact (brighter = more causal effect).
The math
Derivation
Corrupting the subject embedding breaks the prediction; restoring a single internal state and measuring recovery isolates which states cause the correct output — a causal, not merely correlational, localization of the fact.
Code
import numpy as np
# Causal tracing: restore each hidden state, see which recovers the answer
effect = np.zeros((10, 6)) # layers x tokens
effect[5, 2] = 0.9 # a causal mid-layer site
l, t = np.unravel_index(effect.argmax(), effect.shape)
print(f"fact localized at layer {l}, token {t}")In practice
Editing a fact (ROME)
The idea
Once you know where a fact lives, you can rewrite it with a targeted weight update — no retraining. ROME treats an MLP layer as a key–value store and applies a rank-one edit so the subject’s key now maps to a new value (“the Eiffel Tower is in Rome”). Toggle the edit and watch the target output flip while neighbors stay put.
Picture it
In the companion lab you can apply the rank-one edit; the target association changes while an unrelated fact is left intact.
The math
Derivation
A rank-one update Δ = (v* − Wk*)·(C⁻¹k*)ᵀ / (k*ᵀC⁻¹k*) sets the new association at key k* while least-squares-minimizing disruption to other keys (C is the covariance of keys). Surgical, closed-form, fast.
Code
import numpy as np
# ROME: rank-one update so a key maps to a new value, minimal collateral
W = np.random.randn(64, 64) # an MLP weight (key->value)
k_star = np.random.randn(64); v_star = np.random.randn(64)
delta = np.outer(v_star - W @ k_star, k_star) / (k_star @ k_star)
W_new = W + delta # now W_new @ k_star == v_star
print("edit applied, error:", round(np.linalg.norm(W_new @ k_star - v_star), 6))In practice
Ripple effects & specificity
The idea
A good edit must generalize to paraphrases of the same fact yet not leak into unrelated facts — and it should keep logically-entailed consequences consistent. Real edits are imperfect: they can miss paraphrases (poor generalization) or bleed into neighbors (poor specificity). Toggle edit quality and watch the ripple spread.
Picture it
In the companion lab you can switch between a clean and a leaky edit; see which related/unrelated facts change.
The math
Derivation
Because facts share representations, a weight edit can ripple to entangled associations. Quality means hitting the target and its paraphrases while leaving everything else — a tension that grows as you edit many facts at once.
Code
import numpy as np
# A good edit generalizes to paraphrases but not to unrelated facts
edited_fact = "Eiffel Tower -> Rome"
checks = {"paraphrase": True, "entailed": True, "unrelated_1": False, "unrelated_2": False}
# specificity = fraction of unrelated facts left unchanged
specificity = np.mean([not v for k, v in checks.items() if "unrelated" in k])
print("specificity:", specificity) # want 1.0In practice
Forgetting & continual learning
The idea
Train a network on a new task and it tends to overwrite the old one — catastrophic forgetting. Because the same weights serve all tasks, gradient steps for task B trample task A. Countermeasures (rehearsal, regularizing important weights) trade a little new-task speed for retained old-task accuracy. Toggle mitigation and watch the old task survive.
Picture it
In the companion lab you can toggle mitigation; without it, learning task B collapses task-A accuracy — with it, both are retained.
The math
Derivation
Unconstrained SGD on task B moves θ freely, destroying task-A solutions. Penalizing changes to weights with high Fisher information (those that mattered for A), or replaying A’s data, preserves old performance while still fitting B.
Code
import numpy as np
# Catastrophic forgetting: EWC penalizes changing weights important for old tasks
theta_old = np.array([1.0, 2.0]) # old-task solution
fisher = np.array([10.0, 0.1]) # importance per weight
def ewc_penalty(theta, lam=1.0):
return lam * np.sum(fisher * (theta - theta_old)**2)
print("penalty for moving important weight:", round(ewc_penalty(theta_old + 0.5), 2))