primer.ml.neural_net

Neural networks: neurons, activations and backprop by hand

Run: python -m primer.ml.neural_net

New to the notation (slopes, Σ, matrices)? primer.notation builds every symbol used here from zero. This lesson builds on the pipeline picture in primer.ml.big_picture.

Level 1: The practitioner's guide

In one sentence. A neural network is stacked layers of weighted sums with a nonlinear rule after each, and training is one loop, forward pass, loss, backpropagation, optimizer step, that nudges every weight in the direction that makes the loss smaller.

When you need it. You need this the first time you run or read a training job rather than call a finished model: a fine-tuning API asks you for epochs and a batch size, a training script runs out of memory that inference never needed, a loss curve refuses to move, or a custom layer needs a gradient you must trust. The tell: a setting in a training config (num_train_epochs, per_device_train_batch_size, hidden_act) that you copied from an example without knowing which box of the loop it touches. You don't need it to prompt a hosted model, and you don't need it to pick one from a leaderboard; the forward pass is the only part that runs at inference and a vendor has already tuned it. One number from this lesson says why depth is worth anything: on the two-moons data, a single linear layer scores 88% because it can only draw a straight line, and one hidden layer with a nonlinearity scores 99.75% (one point of 400 wrong).

Your options. How much of the training loop you own, from the least to the most:

Option What it does What it guarantees What it costs Where it lives
Call a trained model Runs the forward pass only No gradients, no activations kept: inference memory, and nothing you can break Per-token pricing, and a model whose weights you cannot move The API or the model server
Hosted fine-tuning The vendor runs forward, loss, backward and step on your examples; you set epochs and batch size No training infrastructure; the loop's four boxes are the vendor's problem Curated examples, a job that takes hours, a higher per-token price at some vendors The vendor's fine-tuning API
Framework autograd on your own hardware loss.backward() in PyTorch or jax.grad records the forward pass and replays the chain rule backwards over every weight The gradient is right, for any layer you compose from the framework's pieces GPU memory several times the inference footprint, because every layer's activations are cached for the backward pass; the loop is yours to debug Your training script
Backprop by hand, checked numerically You write the backward pass of a custom layer and compare it with nudging each weight by ±ε A gradient you can prove: this lesson's check agrees to a relative error of 4.5 × 10⁻⁹ Time, and a test per layer A custom layer's backward method

How to choose. Start from what you are changing.

  • Behaviour that prompting cannot fix, on a model you cannot host: hosted fine-tuning. Keep the epochs low; fine-tuning runs several passes over a small dataset, and that is how it overfits.
  • A model you host and a dataset of your own: the framework, with the activation the model was built with. Read it from the config (hidden_act in a Hugging Face Llama config is silu; GPT-2 uses GELU; the transformer paper's feed-forward uses ReLU) and leave it alone, because the trained weights assume it.
  • A new layer, a new loss, a new attention variant: write its backward pass and gradient-check it before training anything on it. A wrong gradient trains quietly and badly.
  • A training run that misbehaves: the fault is in one of the four boxes (data, forward, loss, backward-and-step). Plot the loss per epoch first; its shape (slow start, steep fall, flat tail) is the first diagnostic in this lesson.
  • Whatever you pick, hold out an evaluation set. The loss you train on is the loss the loop minimises, not the score you care about.

What it costs. Memory first: the backward pass reuses every layer's activations from the forward pass, so training keeps what inference throws away and needs several times the memory (this lesson's two-layer trace shows the three cached values it depends on). Compute is one matrix multiply per layer per direction, and in a transformer most parameters sit in the feed-forward layers, which are exactly the two-layer network built here. Steps are counted in batches: 400 examples in batches of 32 is 13 steps per epoch, and two epochs is 26 updates; Hugging Face's TrainingArguments defaults to 3 epochs, a batch of 8 per device and a learning rate of 5 × 10⁻⁵. Pretraining a language model is roughly one epoch over trillions of tokens; fine-tuning is several epochs over a small set. Quality is paid for in gradient signal: a sigmoid's slope is at most 0.25, so ten sigmoid layers can shrink the gradient by 0.25¹⁰ ≈ 10⁻⁶ before it reaches the first weights, while ReLU passes exactly 1 for positive inputs.

What breaks.

  • The loss doesn't move. The learning rate is too small (the one-knob example halves the error every step at 0.25 and would crawl at 0.001), or the gradient is dying on the way back: saturated sigmoids pass 0.02 of the signal at |z| = 4, and a ReLU whose input stays negative passes nothing, forever.
  • The loss explodes or turns NaN. The step is too big; primer.ml.optimizers is the fix.
  • Out of memory in training, fine in inference. Cached activations. Halve the batch size before anything else.
  • Great training loss, poor real results. Several epochs over a small dataset memorised it; measure on held-out data and stop earlier.
  • A custom gradient that is wrong. The network still trains, just not towards anything. Nudge each weight by ±ε and compare: anything below about 10⁻⁶ relative error is right, this lesson's check reports 4.5 × 10⁻⁹.
  • Depth without nonlinearity. Five stacked linear layers equal one matrix to 2.2 × 10⁻¹⁶; more parameters, no more power. Every layer needs its activation.

In the wild. PyTorch's autograd does this lesson's backward pass for billions of weights when you call loss.backward(); JAX does it as a function transformation, jax.grad and jax.value_and_grad. PyTorch's nn.Linear initialises its weights from a uniform range set by the fan-in, the rule primer.ml.deep_nets explains. Activations in the field: GELU in BERT and GPT-2 (Hendrycks and Gimpel, 2016), SwiGLU in Llama 2 (its paper), and hidden_act is the field a Hugging Face config uses to name it. Hugging Face's Trainer runs the four-box loop with the defaults above, and any hosted fine-tuning API runs the same loop behind a form that asks for epochs and a batch size. The idea is Rumelhart, Hinton and Williams (1986); Karpathy's micrograd is the whole of autograd in about a hundred lines. The papers are linked at the end of the lesson.

Go deeper. Level 2 trains one knob by hand, builds a neuron and a layer, passes blame backwards through the chain rule with every number shown, proves why stacked linear layers collapse, compares the activations and their slopes, and runs backprop through two layers against a numerical check. If you only needed to read a training config, you are done.

Level 2: How it works, from scratch.

Level 2: How it works, from scratch

A neuron is six lines of arithmetic, a layer is a matrix multiply, and training is a loop of four boxes. This level builds each one with numbers small enough to check by hand, then verifies the hand-written gradients against a numerical check.

The idea: learning is adjusting knobs to be less wrong

Picture learning to throw darts blindfolded, with a friend calling out "a bit high, a bit left". You throw, hear how far off you were, and adjust your arm a little. After enough throws your arm "knows" where the bullseye is. A neural network learns the same way. Its "arm" is millions of numbers called weights, and the friend is a formula (the loss) that says how far off each guess was and which way to adjust.

Two words carry this whole lesson. The derivative (or slope) of the loss with respect to a weight answers "if I nudge this weight up a tiny bit, does the loss go up or down, and how fast?" The gradient is simply the list of those slopes, one per weight. Stepping against the gradient is walking downhill. (New to the notation? primer.notation builds every symbol used here from zero.)

Worked example with a single knob: a model with one weight w predicts y = w·x and should learn y = 3x from the example x = 1, y = 3. The loss is (w·1 − 3)². Its slope with respect to w is 2(w − 3). Start at w = 0 and step against the slope with step size 0.25:

step w slope 2(w − 3) new w = w − 0.25·slope
0 0 −6 1.5
1 1.5 −3 2.25
2 2.25 −1.5 2.625

Each step halves the distance to 3. That's all training is.

flowchart LR D[Batch of examples] --> F[Forward pass<br/>make predictions] F --> L[Loss<br/>how wrong were we?] L --> B[Backpropagation<br/>gradient per weight] B --> O[Optimizer step<br/>adjust weights] O -->|next batch| D

Reading it: follow the arrows clockwise. A batch of examples goes in; the forward pass turns them into predictions; the loss scores how wrong those predictions were as a single number; backpropagation works out, for every weight, which direction would have made that number smaller; the optimizer nudges each weight a small step that way. Then the next batch arrives. The single-knob table above is one trip around this loop, three times.

The update rule, for every weight at once, with learning rate η:

Level 3: the formula and its symbols

$$ w \leftarrow w - \eta \, \frac{\partial \mathcal{L}}{\partial w} $$

Symbols

Symbol Meaning here In the example
$w$ one weight (a knob the model can turn) 0 at the start
$\leftarrow$ "replace the old value with" (an update, not an equation)
$\eta$ "eta", the learning rate: how big a step to take 0.25
$\mathcal{L}$ the loss: one number saying how wrong the model is $(0 - 3)^2 = 9$
$\partial \mathcal{L} / \partial w$ the slope of the loss with respect to $w$ (the "partial derivative": how the loss changes when only $w$ moves) $2(0 - 3) = -6$

In words: "the new weight is the old weight minus the learning rate times the slope of the loss at the old weight."

With the numbers: $w \leftarrow 0 - 0.25 \times (-6) = 1.5$, the first row of the table.

Level 3: in Python

In Python:

w, eta, target = 0.0, 0.25, 3.0
for step in range(3):
    # ∂L/∂w for the loss (w - 3)²
    slope = 2 * (w - target)
    # w ← w - η ∂L/∂w
    w = w - eta * slope
    print(w)  # → 1.5 2.25 2.625

learn_one_weight runs the table above; train runs the loop for a real network.

Why it matters: this loop is identical for a spam filter and for a frontier language model. Only the size of the network, the data, and the loss change. When a training run misbehaves, the fault is in one of these four boxes.

A neuron: a weighted vote followed by a decision

Think of a judge at a cooking contest. They score taste, presentation and originality, but care about taste most, so they weight it more. They add a baseline ("everyone starts at half a point"), then apply a rule to turn the total into a verdict ("negative totals count as zero"). That is a neuron: weights are how much each input matters, the bias is the baseline, and the activation function is the rule.

Worked example: inputs (2, 3), weights (0.5, 1.0), bias 0.5. Weighted sum: 2 × 0.5 + 3 × 1.0 + 0.5 = 4.5. The ReLU rule keeps positive values, so the output is 4.5.

flowchart LR X1((x1 = 2)) -- "× w1 = 0.5" --> S["Sum + bias<br/>1.0 + 3.0 + 0.5 = 4.5"] X2((x2 = 3)) -- "× w2 = 1.0" --> S B((bias 0.5)) --> S S --> A["Activation φ<br/>ReLU(4.5) = 4.5"] A --> Y((y = 4.5))

Reading it: each input travels along an edge that multiplies it by that edge's weight; the middle node adds the products and the bias; the activation reshapes the sum into the output. A layer is this picture repeated side by side, every input wired to every neuron, which is exactly a matrix multiply. A network is layers stacked.

Level 3: the formula and its symbols

$$ y = \phi\left(\sum_i w_i x_i + b\right) = \phi(w \cdot x + b), \qquad \text{a layer: } Y = \phi(XW + b) $$

Symbols

Symbol Meaning here In the example
$x_i$ the $i$-th input $x_1 = 2$, $x_2 = 3$
$w_i$ the weight on the $i$-th input $w_1 = 0.5$, $w_2 = 1.0$
$i$ a counter over the inputs 1, 2
$\sum_i$ "add up the following for every $i$" $w_1x_1 + w_2x_2$
$b$ the bias (the baseline added to every sum) 0.5
$w \cdot x$ the dot product: multiply matching entries of two lists, then add $0.5·2 + 1.0·3 = 4.0$
$\phi$ "phi", the activation function ReLU
$y$ the neuron's output 4.5
$X$ a batch of inputs, one example per row shape (batch, inputs)
$W$ a layer's weights, one column per neuron shape (inputs, neurons)
$XW$ matrix multiply: every row of $X$ dotted with every column of $W$ shape (batch, neurons)
$Y$ every neuron's output for every example shape (batch, neurons)

In words: "multiply each input by its weight, add them up, add the bias, and pass the total through the activation function."

With the numbers: $y = \text{ReLU}(0.5 \cdot 2 + 1.0 \cdot 3 + 0.5) = \text{ReLU}(4.5) = 4.5$.

Level 3: in Python

In Python:

x = [2, 3]
w = [0.5, 1.0]
b = 0.5
# ReLU: keep positives, zero the rest
def phi(z): return max(0.0, z)
# w · x = Σ_i w_i x_i
sum(w_i * x_i for w_i, x_i in zip(w, x))  # → 4.0
# y = φ(w · x + b)
phi(sum(w_i * x_i for w_i, x_i in zip(w, x)) + b)  # → 4.5

Why it matters: every dense layer in every model is this, including the feed-forward half of each transformer block, where most of a language model's parameters live. MLP.forward is two of these layers in six lines.

Backpropagation: passing the blame backwards

Picture an assembly line that produced a faulty product. The inspector at the end measures how bad it is and passes the complaint back. Each station works out how much of the fault was its own doing, based on what it received and what it did to it, and passes the rest further back. Every station ends up knowing exactly how to adjust its own machine. Backpropagation is that blame-passing, done with derivatives.

Worked example, continuing the neuron: the target was 5, so the loss is (5 − 4.5)² = 0.25.

link local derivative running blame
loss → y 2(y − 5) = −1 −1
y → z (ReLU, z > 0) 1 −1
z → w1 x1 = 2 −2
z → w2 x2 = 3 −3
z → b 1 −1

One step with learning rate 0.01 moves weights to (0.52, 1.03) and bias to 0.51. The new output is 4.64 and the loss drops from 0.25 to 0.1296. The weight whose input was bigger (3) received the bigger share of blame and moved more.

flowchart RL L["Loss (5 − y)² = 0.25"] -- "dL/dy = −1" --> Y[y = ReLU z] Y -- "dy/dz = 1" --> Z[z = w·x + b] Z -- "× x1 = 2 → dL/dw1 = −2" --> W1[w1] Z -- "× x2 = 3 → dL/dw2 = −3" --> W2[w2] Z -- "× 1 → dL/db = −1" --> Bb[b]

Reading it: this is the neuron read right to left. Start at the loss and multiply the local derivatives along each path; that product is the chain rule. All paths share the first two links (−1 × 1), then split. Each weight's gradient is the blame arriving at the sum times the input that weight multiplied. Negative gradient means "increase me to reduce the loss".

Level 3: the formula and its symbols

$$ \frac{\partial \mathcal{L}}{\partial w_i} = \frac{\partial \mathcal{L}}{\partial y}\cdot\frac{\partial y}{\partial z}\cdot\frac{\partial z}{\partial w_i} = 2(y - t)\cdot\phi'(z)\cdot x_i $$

This is the chain rule: when a change passes through several steps, the overall rate of change is the product of each step's rate of change. If turning a knob moves a gear 3× as fast, and that gear moves a needle 2× as fast, the knob moves the needle 6× as fast.

Symbols

Symbol Meaning here In the example
$\mathcal{L}$ the loss $(t - y)^2$ 0.25
$t$ the target (the right answer) 5
$y$ the neuron's output 4.5
$z$ the weighted sum before the activation 4.5
$\partial \mathcal{L}/\partial y$ how the loss changes as the output changes $2(4.5 - 5) = -1$
$\partial y/\partial z$ how the output changes as the sum changes: the activation's slope $\phi'(z)$ ReLU slope at 4.5 = 1
$\partial z/\partial w_i$ how the sum changes as weight $i$ changes: just its input $x_i$ $x_2 = 3$
$\phi'$ the activation's derivative (slope) 1 for ReLU when $z > 0$

In words: "the blame on a weight is how much the loss cares about the output, times how much the output cares about the sum, times how much the sum cares about that weight."

With the numbers: for $w_2$: $(-1) \times 1 \times 3 = -3$; for $w_1$: $(-1) \times 1 \times 2 = -2$.

Level 3: in Python

In Python:

x, t, z = [2, 3], 5, 4.5
# ReLU(4.5)
y = max(0.0, z)
# ∂L/∂y
dL_dy = 2 * (y - t)
# φ'(z): ReLU's slope
dy_dz = 1 if z > 0 else 0
# × ∂z/∂w_i = x_i, for w_1 and w_2
[dL_dy * dy_dz * x_i for x_i in x]  # → [-2.0, -3.0]
# one step, learning rate 0.01
w = [0.5 - 0.01 * -2.0, 1.0 - 0.01 * -3.0]
b = 0.5 - 0.01 * -1.0
y_new = w[0] * x[0] + w[1] * x[1] + b
# the new output and the smaller loss
round(y_new, 2), round((t - y_new) ** 2, 4)  # → (4.64, 0.1296)

one_neuron_worked_example computes every row of the table.

Why it matters: PyTorch's loss.backward() does exactly this, for billions of weights, by recording the forward computation and replaying it backwards. Knowing it is just the chain rule is what lets you reason about vanishing gradients, memory use, and why some architectures train and others don't.

Why the nonlinearity is essential

Stack three sheets of tinted glass and you get one darker sheet of glass: no arrangement of flat panes can bend light around a corner. Linear layers are flat panes. However many you stack, the result is one linear layer, which can only separate classes with a straight line. The activation function is the bend.

Worked example in one dimension: a layer that multiplies by 3, followed by a layer that multiplies by 2, is the same as one layer that multiplies by 6. With a ReLU between them, inputs −1 and 1 give 0 and 6. No single multiplication does that.

flowchart LR subgraph NoAct["Without activation"] a1[X] --> a2[·W1] --> a3[·W2] --> a4[·W3] --> a5["= X·(W1W2W3)<br/>one matrix"] end subgraph WithAct["With activation"] b1[X] --> b2[·W1] --> b3[φ] --> b4[·W2] --> b5[φ] --> b6[·W3] --> b7[curved<br/>decision boundary] end

Reading it: in the top row the multiplications are chained with nothing in between, and associativity lets you multiply the weights together first: three layers equal one. In the bottom row a nonlinearity φ sits between the multiplies, the product can no longer be pre-computed, and the network can bend its decision boundary.

Level 3: the formula and its symbols

$$ (XW_1)W_2 = X(W_1W_2) \quad\text{but}\quad \phi(XW_1)W_2 \neq X W' \text{ for any } W' $$

Symbols

Symbol Meaning here In the example
$X$ the inputs $x = -1$ or $x = 1$
$W_1, W_2$ two layers' weights 3 and 2
$W_1W_2$ the two layers multiplied into one 6
$\phi$ an activation between the layers ReLU
$W'$ any single weight you might try instead none works
$\neq$ "is not equal to"

In words: "two linear layers in a row are the same as one linear layer, but put an activation between them and no single layer can copy them."

With the numbers: without ReLU, $-1 \to -3 \to -6$ and $1 \to 3 \to 6$, exactly $6x$. With ReLU, $-1 \to -3 \to 0 \to 0$ and $1 \to 3 \to 3 \to 6$. A single weight $W'$ would need $-W' = 0$ and $W' = 6$ at once: impossible.

Level 3: in Python

In Python:

W_1, W_2 = 3, 2
# ReLU
def phi(z): return max(0, z)
# (X W_1) W_2 ...
[(x * W_1) * W_2 for x in [-1, 1]]  # → [-6, 6]
# ... equals X (W_1 W_2): one layer of 6
[x * (W_1 * W_2) for x in [-1, 1]]  # → [-6, 6]
# φ(X W_1) W_2: no single W' gives 0 and 6
[phi(x * W_1) * W_2 for x in [-1, 1]]  # → [0, 6]

Two-moons decision boundaries: logistic regression's straight line misclassifies the moon tips (88%), while one hidden tanh layer bends around the gap (99.75%)

Reading it: both panels show the same two interleaving half-moons, coloured by class; the shaded background is what each model predicts at every point. On the left, a single linear layer can only split the plane with a straight line, so the tips of both moons land on the wrong side (about 88% accuracy). On the right, one hidden tanh layer bends the boundary to follow the gap between the moons (99.75%, one point of 400 wrong). Same data, same optimizer: the only difference is a nonlinearity between two layers.

In code: make_moons builds the two interleaving half-moons (and make_xor the four-point XOR puzzle); train_logistic_regression fits the straight-line baseline in the left panel.

Why it matters: "depth" is only worth anything because of the activations between layers. linear_stack_collapses shows five stacked linear layers matching their single-matrix product to 1e-16.

Activation functions: the rule after the sum

Think of different kinds of switch. ReLU is a one-way valve: water flows freely forward, not at all backward. Sigmoid is a dimmer that squeezes any input into 0–1 but barely moves at the extremes. Tanh is the same dimmer centred on zero. GELU is a valve that leaks a little when nearly closed.

Worked example, values you can check on a calculator (the ′ mark, as in sigmoid′, means "the slope of"):

z ReLU sigmoid sigmoid' tanh' GELU
−1 0 0.269 0.197 0.420 −0.159
0 0 0.5 0.25 (its maximum) 1 (its maximum) 0
1 1 0.731 0.197 0.420 0.841

Activations and their slopes: sigmoid's slope peaks at 0.25 and sigmoid and tanh slopes vanish past |z| = 3, while ReLU's slope is a clean step from 0 to 1

Reading it: the left panel shows each activation's output, the right its derivative, which is how much gradient passes back through it. Look at the tails of the right panel first: sigmoid and tanh derivatives fall to almost zero once |z| passes 3, so a saturated neuron barely learns. Sigmoid's derivative never exceeds 0.25 even at its peak; stack ten such layers and the gradient can shrink by 0.25¹⁰ ≈ 1e-6. ReLU's derivative is a step: exactly 1 for positive inputs (no shrinking) and exactly 0 for negatives. GELU's is a smooth version of that step.

Function Formula Range Where it's used
ReLU max(0, z) [0, ∞) Hidden layers of CNNs and MLPs. Fast. A neuron stuck negative gets zero gradient forever ("dying ReLU").
GELU z·Φ(z), Φ = normal CDF ≈[−0.17, ∞) Transformers (BERT, GPT-2 and most since).
Sigmoid 1 / (1 + e^−z) (0, 1) Binary outputs, and the gates inside LSTMs.
Tanh (e^z − e^−z)/(e^z + e^−z) (−1, 1) RNN hidden states; zero-centred.
Softmax e^{z_i} / Σ e^{z_j} probabilities The output layer over classes or tokens, and inside attention.

Reading the formulas: e is Euler's number, about 2.718, and e^z means "2.718 raised to the power z", which is always positive and grows fast. Φ(z) is the fraction of a bell curve (the standard normal distribution) that lies below z: Φ(0) = 0.5, Φ(1) = 0.841. Softmax turns a list of scores into shares that are all positive and add up to 1: raise e to each score, then divide each by the total (Σ means "add them all up"). See primer.notation for each symbol, and primer.ml.attention for softmax worked through by hand.

In code: relu, sigmoid, tanh and gelu compute each rule, and relu_grad, sigmoid_grad, tanh_grad and gelu_grad compute its slope; gelu_tanh is the cheaper approximation GPT-2 uses, and softmax subtracts the largest score first so nothing overflows.

Why it matters: activation choice decides whether gradients survive a deep stack. Sigmoid everywhere is why deep networks were hard to train before 2010; ReLU and residual connections are why they aren't now (see primer.ml.deep_nets).

Backprop through two layers

Now the assembly line has two stations. The blame from the end first reaches the output weights, then travels back through the hidden layer to the first weights. On the way it passes through the hidden activation, which can shrink it, just as a station that barely changed the product can take only a little of the blame.

Worked example, one input, one hidden unit, one output: x = 1, w1 = 0.5, tanh, w2 = 2, sigmoid, target 1.

step value
z1 = x·w1 0.5
h = tanh(0.5) 0.4621
z2 = h·w2 0.9242
ŷ = σ(0.9242) 0.7159
dL/dz2 = ŷ − y −0.2841
dL/dw2 = h · dL/dz2 −0.1313
dL/dh = dL/dz2 · w2 −0.5682
dL/dz1 = dL/dh · (1 − h²) −0.5682 × 0.7864 = −0.4469
dL/dw1 = x · dL/dz1 −0.4469
flowchart LR subgraph Forward X[X] --> Z1["z1 = X·W1 + b1"] --> H["h = tanh z1"] --> Z2["z2 = h·W2 + b2"] --> P["ŷ = σ z2"] --> L["L = BCE ŷ, y"] end L -. "ŷ − y" .-> dZ2[dL/dz2] dZ2 -. "hᵀ · dz2" .-> dW2[dL/dW2] dZ2 -. "dz2 · W2ᵀ" .-> dH[dL/dh] dH -. "⊙ 1 − h²" .-> dZ1[dL/dz1] dZ1 -. "Xᵀ · dz1" .-> dW1[dL/dW1] H -. cached .-> dW2 H -. cached .-> dZ1 X -. cached .-> dW1

Reading it: the top row is the forward pass, left to right; the dotted arrows are the backward pass, starting at the loss and walking back one box at a time. Each label is one line of MLP.backward, and the worked table above is that same path with numbers. Notice the three "cached" arrows: the backward pass reuses h and X from the forward pass. That dependency is why training must keep every layer's activations in memory, and inference does not.

For a batch, with binary cross-entropy (the loss for yes/no predictions, $-[y\ln\hat{y} + (1-y)\ln(1-\hat{y})]$; see primer.ml.losses), which fuses with sigmoid into the simple error ŷ − y:

Level 3: the formula and its symbols

$$ \frac{\partial L}{\partial z_2} = \hat{y} - y,\; \frac{\partial L}{\partial W_2} = h^\top \frac{\partial L}{\partial z_2},\; \frac{\partial L}{\partial h} = \frac{\partial L}{\partial z_2} W_2^\top,\; \frac{\partial L}{\partial z_1} = \frac{\partial L}{\partial h} \odot (1 - h^2),\; \frac{\partial L}{\partial W_1} = X^\top \frac{\partial L}{\partial z_1} $$

Symbols

Symbol Meaning here In the example (one example, one unit)
$X$ inputs, one row per example $x = 1$
$W_1, W_2$ first and second layer weights 0.5, 2
$z_1, z_2$ each layer's weighted sum, before its activation 0.5, 0.9242
$h$ hidden activations, $\tanh(z_1)$ 0.4621
$\hat{y}$ "y-hat", the prediction $\sigma(z_2)$ 0.7159
$y$ the target 1
$\sigma$ sigmoid, squashes any number into 0 to 1 $\sigma(0.9242) = 0.7159$
$\tanh$ hyperbolic tangent, squashes into −1 to 1 $\tanh(0.5) = 0.4621$
$^\top$ "transpose": flip a matrix so rows become columns, so the shapes line up for the multiply a scalar is its own transpose
$\odot$ multiply element by element (not a matrix multiply) $-0.5682 \times 0.7864$
$1 - h^2$ the slope of tanh at $z_1$ 0.7864

In words: "the error at the output is prediction minus target; each layer's weight gradient is that layer's input times the error arriving at it; to send the error one layer further back, multiply by the weights it came through and by the activation's slope."

With the numbers: $\partial L/\partial z_2 = 0.7159 - 1 = -0.2841$; $\partial L/\partial W_2 = 0.4621 \times -0.2841 = -0.1313$; $\partial L/\partial h = -0.2841 \times 2 = -0.5682$; $\partial L/\partial z_1 = -0.5682 \times 0.7864 = -0.4469$; $\partial L/\partial W_1 = 1 \times -0.4469 = -0.4469$.

Level 3: in Python

In Python:

import math
X, W_1, W_2, y = 1, 0.5, 2, 1
z_1 = X * W_1
h = math.tanh(z_1)
z_2 = h * W_2
# σ(z_2)
y_hat = 1 / (1 + math.exp(-z_2))
# ŷ - y
dL_dz2 = y_hat - y
# hᵀ ∂L/∂z_2
dL_dW2 = h * dL_dz2
# ∂L/∂z_2 W_2ᵀ
dL_dh = dL_dz2 * W_2
# ⊙ (1 - h²), tanh's slope
dL_dz1 = dL_dh * (1 - h ** 2)
# Xᵀ ∂L/∂z_1
dL_dW1 = X * dL_dz1
[round(v, 4) for v in (dL_dz2, dL_dW2, dL_dh, dL_dz1, dL_dW1)]  # → [-0.2841, -0.1313, -0.5682, -0.4469, -0.4469]

The hand-written gradients are checked two ways: a numerical gradient check (nudge each weight by ±ε and measure the loss change; see gradient_check) and, in the tests, PyTorch autograd.

In code: tiny_two_layer_example computes every row of the worked table; MLP holds both layers' weights and biases, MLP.loss scores a batch with binary_cross_entropy, and numerical_gradient is the slow nudge-every-weight answer that gradient_check compares against.

Why it matters: the factor (1 − h²) is where gradients shrink. Chain fifty of them and the first layers hear almost nothing: the vanishing gradient problem. The cached activations are why training a model needs several times the memory of serving it.

Batch, step, epoch

Imagine studying a deck of 400 flashcards. You work through a pile of 32, then pause to update your notes; that pause is a step. The pile is a batch. Going through the whole deck once is an epoch; then you shuffle and start again.

Worked example: 400 examples in batches of 32 is 12 full batches plus one of 16, so ⌈400/32⌉ = 13 steps per epoch. Two epochs: 26 steps.

flowchart LR E[Epoch: one pass over all 400 examples] --> SH[Shuffle] SH --> B1[Batch 1<br/>32 examples] --> S1[Step 1<br/>update weights] S1 --> B2[Batch 2] --> S2[Step 2] S2 --> D[...] --> B13[Batch 13<br/>last 16 examples] --> S13[Step 13] S13 -->|next epoch| E

Reading it: an epoch reshuffles the data and slices it into batches; every batch produces exactly one weight update. Shuffling each epoch means batches differ every time, so the gradient noise doesn't repeat.

Training loss per epoch on a log axis: slow at first, a steep fall once hidden units find features, then a flat tail near zero

Reading it: the horizontal axis counts epochs on a log scale; the vertical axis is the loss on the whole training set after each one. Loss drops slowly at first while the weights are near their random start, falls steeply once the hidden units find useful features, then flattens as the remaining errors get harder. Plotting this curve is the first thing to do when training anything.

Level 3: the formula and its symbols

$$ \text{steps per epoch} = \left\lceil \frac{N}{B} \right\rceil, \qquad \text{total steps} = \text{epochs} \times \left\lceil \frac{N}{B} \right\rceil $$

Symbols

Symbol Meaning here In the example
$N$ number of training examples 400
$B$ batch size 32
$\lceil \cdot \rceil$ "ceiling": round up to the next whole number (the last, smaller batch still counts as a step) $\lceil 12.5 \rceil = 13$

In words: "an epoch takes as many steps as it takes batches of size B to cover N examples, rounding up."

With the numbers: $\lceil 400 / 32 \rceil = \lceil 12.5 \rceil = 13$ steps per epoch; 2 epochs = 26 steps.

Level 3: in Python

In Python:

import math
N, B, epochs = 400, 32, 2
N / B  # → 12.5
# ⌈N / B⌉: the last, smaller batch still counts
math.ceil(N / B)  # → 13
epochs * math.ceil(N / B)  # → 26

In code: train reshuffles the data every epoch, takes one plain gradient step per batch and records the loss after each epoch; MLP.accuracy reports the fraction of examples classified correctly.

Why it matters: larger batches give smoother gradient estimates and keep GPUs busy, but need more memory. Pretraining a language model runs roughly one epoch over trillions of tokens (it rarely sees the same text twice); fine-tuning runs several epochs over a small dataset, which is why fine-tunes can overfit.

In 20 seconds

  • A neuron is a weighted sum plus bias through a nonlinearity; a layer is a matrix multiply; a network is stacked layers.
  • Without nonlinearity, depth is pointless: stacked linear layers equal one.
  • Training loop: forward, loss, backprop (the chain rule gives a gradient per weight), optimizer step. Repeat.
  • Backprop reuses forward activations, so training needs far more memory than inference.

Self-test questions

Why can't you just stack linear layers? Their composition is a single linear map (W1·W2 is just another matrix), so extra layers add parameters but no expressive power.

What does backprop actually compute? The gradient: for every weight, how much a tiny change would change the loss. It applies the chain rule from the output backward, reusing values cached in the forward pass.

Why is ReLU preferred over sigmoid in hidden layers? Sigmoid's derivative is at most 0.25 and near 0 when saturated, so gradients shrink multiplicatively with depth. ReLU's derivative is exactly 1 for positive inputs. The cost: neurons whose input stays negative get zero gradient and "die".

Why does training need more memory than inference? The backward pass needs every layer's activations from the forward pass, so they must be kept until the gradients are computed. Inference can discard each activation as soon as the next layer has used it.

What's the gradient of sigmoid + binary cross-entropy with respect to the logit? ŷ − y: prediction minus target. Clean, bounded and cheap, which is why the two are always fused.

Batch vs. step vs. epoch? A batch is the examples per update, a step is one update, an epoch is one full pass over the data.

The papers behind this lesson

  • Rumelhart, Hinton & Williams, Learning representations by back-propagating errors (Nature, 1986): https://www.nature.com/articles/323533a0 Showed that the chain rule, run backwards through a multi-layer network, trains hidden units to discover useful internal features.
  • Hendrycks & Gimpel, Gaussian Error Linear Units (GELUs) (2016): https://arxiv.org/abs/1606.08415 Introduced GELU, the smooth ReLU that became the default activation in transformers.

Further reading

on GitHub
   1r"""
   2# Neural networks: neurons, activations and backprop by hand
   3
   4Run: `python -m primer.ml.neural_net`
   5
   6New to the notation (slopes, Σ, matrices)? `primer.notation` builds every
   7symbol used here from zero. This lesson builds on the pipeline picture in
   8`primer.ml.big_picture`.
   9
  10## Level 1: The practitioner's guide
  11
  12**In one sentence.** A neural network is stacked layers of weighted sums
  13with a nonlinear rule after each, and training is one loop, forward pass,
  14loss, backpropagation, optimizer step, that nudges every weight in the
  15direction that makes the loss smaller.
  16
  17**When you need it.** You need this the first time you run or read a
  18training job rather than call a finished model: a fine-tuning API asks you
  19for epochs and a batch size, a training script runs out of memory that
  20inference never needed, a loss curve refuses to move, or a custom layer
  21needs a gradient you must trust. The tell: a setting in a training config
  22(`num_train_epochs`, `per_device_train_batch_size`, `hidden_act`) that you
  23copied from an example without knowing which box of the loop it touches.
  24You don't need it to prompt a hosted model, and you don't need it to pick
  25one from a leaderboard; the forward pass is the only part that runs at
  26inference and a vendor has already tuned it. One number from this lesson
  27says why depth is worth anything: on the two-moons data, a single linear
  28layer scores 88% because it can only draw a straight line, and one hidden
  29layer with a nonlinearity scores 99.75% (one point of 400 wrong).
  30
  31**Your options.** How much of the training loop you own, from the least to
  32the most:
  33
  34| Option | What it does | What it guarantees | What it costs | Where it lives |
  35|---|---|---|---|---|
  36| Call a trained model | Runs the forward pass only | No gradients, no activations kept: inference memory, and nothing you can break | Per-token pricing, and a model whose weights you cannot move | The API or the model server |
  37| Hosted fine-tuning | The vendor runs forward, loss, backward and step on your examples; you set epochs and batch size | No training infrastructure; the loop's four boxes are the vendor's problem | Curated examples, a job that takes hours, a higher per-token price at some vendors | The vendor's fine-tuning API |
  38| Framework autograd on your own hardware | `loss.backward()` in PyTorch or `jax.grad` records the forward pass and replays the chain rule backwards over every weight | The gradient is right, for any layer you compose from the framework's pieces | GPU memory several times the inference footprint, because every layer's activations are cached for the backward pass; the loop is yours to debug | Your training script |
  39| Backprop by hand, checked numerically | You write the backward pass of a custom layer and compare it with nudging each weight by ±ε | A gradient you can prove: this lesson's check agrees to a relative error of 4.5 × 10⁻⁹ | Time, and a test per layer | A custom layer's backward method |
  40
  41**How to choose.** Start from what you are changing.
  42
  43- Behaviour that prompting cannot fix, on a model you cannot host: hosted
  44  fine-tuning. Keep the epochs low; fine-tuning runs several passes over a
  45  small dataset, and that is how it overfits.
  46- A model you host and a dataset of your own: the framework, with the
  47  activation the model was built with. Read it from the config
  48  (`hidden_act` in a Hugging Face Llama config is `silu`; GPT-2 uses GELU;
  49  the transformer paper's feed-forward uses ReLU) and leave it alone,
  50  because the trained weights assume it.
  51- A new layer, a new loss, a new attention variant: write its backward pass
  52  and gradient-check it before training anything on it. A wrong gradient
  53  trains quietly and badly.
  54- A training run that misbehaves: the fault is in one of the four boxes
  55  (data, forward, loss, backward-and-step). Plot the loss per epoch first;
  56  its shape (slow start, steep fall, flat tail) is the first diagnostic in
  57  this lesson.
  58- Whatever you pick, hold out an evaluation set. The loss you train on is
  59  the loss the loop minimises, not the score you care about.
  60
  61**What it costs.** Memory first: the backward pass reuses every layer's
  62activations from the forward pass, so training keeps what inference throws
  63away and needs several times the memory (this lesson's two-layer trace
  64shows the three cached values it depends on). Compute is one matrix
  65multiply per layer per direction, and in a transformer most parameters sit
  66in the feed-forward layers, which are exactly the two-layer network built
  67here. Steps are counted in batches: 400 examples in batches of 32 is 13
  68steps per epoch, and two epochs is 26 updates; Hugging Face's
  69`TrainingArguments` defaults to 3 epochs, a batch of 8 per device and a
  70learning rate of 5 × 10⁻⁵. Pretraining a language model is roughly one
  71epoch over trillions of tokens; fine-tuning is several epochs over a small
  72set. Quality is paid for in gradient signal: a sigmoid's slope is at most
  730.25, so ten sigmoid layers can shrink the gradient by 0.25¹⁰ ≈ 10⁻⁶ before
  74it reaches the first weights, while ReLU passes exactly 1 for positive
  75inputs.
  76
  77**What breaks.**
  78
  79- **The loss doesn't move.** The learning rate is too small (the one-knob
  80  example halves the error every step at 0.25 and would crawl at 0.001), or
  81  the gradient is dying on the way back: saturated sigmoids pass 0.02 of the
  82  signal at |z| = 4, and a ReLU whose input stays negative passes nothing,
  83  forever.
  84- **The loss explodes or turns NaN.** The step is too big; `primer.ml.optimizers`
  85  is the fix.
  86- **Out of memory in training, fine in inference.** Cached activations.
  87  Halve the batch size before anything else.
  88- **Great training loss, poor real results.** Several epochs over a small
  89  dataset memorised it; measure on held-out data and stop earlier.
  90- **A custom gradient that is wrong.** The network still trains, just not
  91  towards anything. Nudge each weight by ±ε and compare: anything below
  92  about 10⁻⁶ relative error is right, this lesson's check reports 4.5 × 10⁻⁹.
  93- **Depth without nonlinearity.** Five stacked linear layers equal one
  94  matrix to 2.2 × 10⁻¹⁶; more parameters, no more power. Every layer needs
  95  its activation.
  96
  97**In the wild.** PyTorch's autograd does this lesson's backward pass for
  98billions of weights when you call `loss.backward()`; JAX does it as a
  99function transformation, `jax.grad` and `jax.value_and_grad`. PyTorch's
 100`nn.Linear` initialises its weights from a uniform range set by the
 101fan-in, the rule `primer.ml.deep_nets` explains. Activations in the field:
 102GELU in BERT and GPT-2 (Hendrycks and Gimpel, 2016), SwiGLU in Llama 2
 103(its paper), and `hidden_act` is the field a Hugging Face config uses to
 104name it. Hugging Face's `Trainer` runs the four-box loop with the defaults
 105above, and any hosted fine-tuning API runs the same loop behind a form that
 106asks for epochs and a batch size. The idea is Rumelhart, Hinton and
 107Williams (1986); Karpathy's micrograd is the whole of autograd in about a
 108hundred lines. The papers are linked at the end of the lesson.
 109
 110**Go deeper.** Level 2 trains one knob by hand, builds a neuron and a
 111layer, passes blame backwards through the chain rule with every number
 112shown, proves why stacked linear layers collapse, compares the activations
 113and their slopes, and runs backprop through two layers against a numerical
 114check. If you only needed to read a training config, you are done.
 115
 116## Level 2: How it works, from scratch
 117
 118A neuron is six lines of arithmetic, a layer is a matrix multiply, and
 119training is a loop of four boxes. This level builds each one with numbers
 120small enough to check by hand, then verifies the hand-written gradients
 121against a numerical check.
 122
 123## The idea: learning is adjusting knobs to be less wrong
 124
 125Picture learning to throw darts blindfolded, with a friend calling out "a bit
 126high, a bit left". You throw, hear how far off you were, and adjust your arm
 127a little. After enough throws your arm "knows" where the bullseye is. A
 128neural network learns the same way. Its "arm" is millions of numbers called
 129**weights**, and the friend is a formula (the **loss**) that says how far
 130off each guess was and which way to adjust.
 131
 132Two words carry this whole lesson. The **derivative** (or **slope**) of the
 133loss with respect to a weight answers "if I nudge this weight up a tiny bit,
 134does the loss go up or down, and how fast?" The **gradient** is simply the
 135list of those slopes, one per weight. Stepping *against* the gradient is
 136walking downhill. (New to the notation? `primer.notation` builds every symbol
 137used here from zero.)
 138
 139Worked example with a single knob: a model with one weight *w* predicts
 140y = w·x and should learn y = 3x from the example x = 1, y = 3. The loss is
 141(w·1 − 3)². Its slope with respect to w is 2(w − 3). Start at w = 0 and step
 142against the slope with step size 0.25:
 143
 144| step | w | slope 2(w − 3) | new w = w − 0.25·slope |
 145|---|---|---|---|
 146| 0 | 0 | −6 | 1.5 |
 147| 1 | 1.5 | −3 | 2.25 |
 148| 2 | 2.25 | −1.5 | 2.625 |
 149
 150Each step halves the distance to 3. That's all training is.
 151
 152```mermaid
 153flowchart LR
 154  D[Batch of examples] --> F[Forward pass<br/>make predictions]
 155  F --> L[Loss<br/>how wrong were we?]
 156  L --> B[Backpropagation<br/>gradient per weight]
 157  B --> O[Optimizer step<br/>adjust weights]
 158  O -->|next batch| D
 159```
 160
 161**Reading it:** follow the arrows clockwise. A batch of examples goes in; the
 162forward pass turns them into predictions; the loss scores how wrong those
 163predictions were as a single number; backpropagation works out, for every
 164weight, which direction would have made that number smaller; the optimizer
 165nudges each weight a small step that way. Then the next batch arrives. The
 166single-knob table above is one trip around this loop, three times.
 167
 168The update rule, for every weight at once, with learning rate η:
 169
 170$$
 171w \leftarrow w - \eta \, \frac{\partial \mathcal{L}}{\partial w}
 172$$
 173
 174**Symbols**
 175
 176| Symbol | Meaning here | In the example |
 177|---|---|---|
 178| $w$ | one weight (a knob the model can turn) | 0 at the start |
 179| $\leftarrow$ | "replace the old value with" (an update, not an equation) | |
 180| $\eta$ | "eta", the learning rate: how big a step to take | 0.25 |
 181| $\mathcal{L}$ | the loss: one number saying how wrong the model is | $(0 - 3)^2 = 9$ |
 182| $\partial \mathcal{L} / \partial w$ | the slope of the loss with respect to $w$ (the "partial derivative": how the loss changes when only $w$ moves) | $2(0 - 3) = -6$ |
 183
 184**In words:** "the new weight is the old weight minus the learning rate times
 185the slope of the loss at the old weight."
 186
 187**With the numbers:** $w \leftarrow 0 - 0.25 \times (-6) = 1.5$, the first row
 188of the table.
 189
 190**In Python:**
 191
 192```python
 193w, eta, target = 0.0, 0.25, 3.0
 194for step in range(3):
 195    # ∂L/∂w for the loss (w - 3)²
 196    slope = 2 * (w - target)
 197    # w ← w - η ∂L/∂w
 198    w = w - eta * slope
 199    print(w)  # → 1.5 2.25 2.625
 200```
 201
 202`learn_one_weight` runs the table above; `train` runs the loop for a real
 203network.
 204
 205**Why it matters:** this loop is identical for a spam filter and for a
 206frontier language model. Only the size of the network, the data, and the
 207loss change. When a training run misbehaves, the fault is in one of these
 208four boxes.
 209
 210## A neuron: a weighted vote followed by a decision
 211
 212Think of a judge at a cooking contest. They score taste, presentation and
 213originality, but care about taste most, so they weight it more. They add a
 214baseline ("everyone starts at half a point"), then apply a rule to turn the
 215total into a verdict ("negative totals count as zero"). That is a neuron:
 216**weights** are how much each input matters, the **bias** is the baseline,
 217and the **activation function** is the rule.
 218
 219Worked example: inputs (2, 3), weights (0.5, 1.0), bias 0.5.
 220Weighted sum: 2 × 0.5 + 3 × 1.0 + 0.5 = 4.5. The ReLU rule keeps positive
 221values, so the output is 4.5.
 222
 223```mermaid
 224flowchart LR
 225  X1((x1 = 2)) -- "× w1 = 0.5" --> S["Sum + bias<br/>1.0 + 3.0 + 0.5 = 4.5"]
 226  X2((x2 = 3)) -- "× w2 = 1.0" --> S
 227  B((bias 0.5)) --> S
 228  S --> A["Activation φ<br/>ReLU(4.5) = 4.5"]
 229  A --> Y((y = 4.5))
 230```
 231
 232**Reading it:** each input travels along an edge that multiplies it by that
 233edge's weight; the middle node adds the products and the bias; the
 234activation reshapes the sum into the output. A **layer** is this picture
 235repeated side by side, every input wired to every neuron, which is exactly a
 236matrix multiply. A **network** is layers stacked.
 237
 238$$
 239y = \phi\left(\sum_i w_i x_i + b\right) = \phi(w \cdot x + b), \qquad
 240\text{a layer: } Y = \phi(XW + b)
 241$$
 242
 243**Symbols**
 244
 245| Symbol | Meaning here | In the example |
 246|---|---|---|
 247| $x_i$ | the $i$-th input | $x_1 = 2$, $x_2 = 3$ |
 248| $w_i$ | the weight on the $i$-th input | $w_1 = 0.5$, $w_2 = 1.0$ |
 249| $i$ | a counter over the inputs | 1, 2 |
 250| $\sum_i$ | "add up the following for every $i$" | $w_1x_1 + w_2x_2$ |
 251| $b$ | the bias (the baseline added to every sum) | 0.5 |
 252| $w \cdot x$ | the **dot product**: multiply matching entries of two lists, then add | $0.5·2 + 1.0·3 = 4.0$ |
 253| $\phi$ | "phi", the activation function | ReLU |
 254| $y$ | the neuron's output | 4.5 |
 255| $X$ | a batch of inputs, one example per row | shape (batch, inputs) |
 256| $W$ | a layer's weights, one column per neuron | shape (inputs, neurons) |
 257| $XW$ | **matrix multiply**: every row of $X$ dotted with every column of $W$ | shape (batch, neurons) |
 258| $Y$ | every neuron's output for every example | shape (batch, neurons) |
 259
 260**In words:** "multiply each input by its weight, add them up, add the
 261bias, and pass the total through the activation function."
 262
 263**With the numbers:** $y = \text{ReLU}(0.5 \cdot 2 + 1.0 \cdot 3 + 0.5) =
 264\text{ReLU}(4.5) = 4.5$.
 265
 266**In Python:**
 267
 268```python
 269x = [2, 3]
 270w = [0.5, 1.0]
 271b = 0.5
 272# ReLU: keep positives, zero the rest
 273def phi(z): return max(0.0, z)
 274# w · x = Σ_i w_i x_i
 275sum(w_i * x_i for w_i, x_i in zip(w, x))  # → 4.0
 276# y = φ(w · x + b)
 277phi(sum(w_i * x_i for w_i, x_i in zip(w, x)) + b)  # → 4.5
 278```
 279
 280**Why it matters:** every dense layer in every model is this, including the
 281feed-forward half of each transformer block, where most of a language
 282model's parameters live. `MLP.forward` is two of these layers in six lines.
 283
 284## Backpropagation: passing the blame backwards
 285
 286Picture an assembly line that produced a faulty product. The inspector at the
 287end measures how bad it is and passes the complaint back. Each station
 288works out how much of the fault was its own doing, based on what it received
 289and what it did to it, and passes the rest further back. Every station ends
 290up knowing exactly how to adjust its own machine. Backpropagation is that
 291blame-passing, done with derivatives.
 292
 293Worked example, continuing the neuron: the target was 5, so the loss is
 294(5 − 4.5)² = 0.25.
 295
 296| link | local derivative | running blame |
 297|---|---|---|
 298| loss → y | 2(y − 5) = −1 | −1 |
 299| y → z (ReLU, z > 0) | 1 | −1 |
 300| z → w1 | x1 = 2 | **−2** |
 301| z → w2 | x2 = 3 | **−3** |
 302| z → b | 1 | **−1** |
 303
 304One step with learning rate 0.01 moves weights to (0.52, 1.03) and bias to
 3050.51. The new output is 4.64 and the loss drops from 0.25 to 0.1296. The
 306weight whose input was bigger (3) received the bigger share of blame and
 307moved more.
 308
 309```mermaid
 310flowchart RL
 311  L["Loss (5 − y)² = 0.25"] -- "dL/dy = −1" --> Y[y = ReLU z]
 312  Y -- "dy/dz = 1" --> Z[z = w·x + b]
 313  Z -- "× x1 = 2 → dL/dw1 = −2" --> W1[w1]
 314  Z -- "× x2 = 3 → dL/dw2 = −3" --> W2[w2]
 315  Z -- "× 1 → dL/db = −1" --> Bb[b]
 316```
 317
 318**Reading it:** this is the neuron read right to left. Start at the loss and
 319multiply the local derivatives along each path; that product is the chain
 320rule. All paths share the first two links (−1 × 1), then split. Each weight's
 321gradient is the blame arriving at the sum times the input that weight
 322multiplied. Negative gradient means "increase me to reduce the loss".
 323
 324$$
 325\frac{\partial \mathcal{L}}{\partial w_i} =
 326\frac{\partial \mathcal{L}}{\partial y}\cdot\frac{\partial y}{\partial z}\cdot\frac{\partial z}{\partial w_i}
 327= 2(y - t)\cdot\phi'(z)\cdot x_i
 328$$
 329
 330This is the **chain rule**: when a change passes through several steps, the
 331overall rate of change is the product of each step's rate of change. If
 332turning a knob moves a gear 3× as fast, and that gear moves a needle 2× as
 333fast, the knob moves the needle 6× as fast.
 334
 335**Symbols**
 336
 337| Symbol | Meaning here | In the example |
 338|---|---|---|
 339| $\mathcal{L}$ | the loss $(t - y)^2$ | 0.25 |
 340| $t$ | the target (the right answer) | 5 |
 341| $y$ | the neuron's output | 4.5 |
 342| $z$ | the weighted sum before the activation | 4.5 |
 343| $\partial \mathcal{L}/\partial y$ | how the loss changes as the output changes | $2(4.5 - 5) = -1$ |
 344| $\partial y/\partial z$ | how the output changes as the sum changes: the activation's slope $\phi'(z)$ | ReLU slope at 4.5 = 1 |
 345| $\partial z/\partial w_i$ | how the sum changes as weight $i$ changes: just its input $x_i$ | $x_2 = 3$ |
 346| $\phi'$ | the activation's derivative (slope) | 1 for ReLU when $z > 0$ |
 347
 348**In words:** "the blame on a weight is how much the loss cares about the
 349output, times how much the output cares about the sum, times how much the
 350sum cares about that weight."
 351
 352**With the numbers:** for $w_2$: $(-1) \times 1 \times 3 = -3$; for $w_1$:
 353$(-1) \times 1 \times 2 = -2$.
 354
 355**In Python:**
 356
 357```python
 358x, t, z = [2, 3], 5, 4.5
 359# ReLU(4.5)
 360y = max(0.0, z)
 361# ∂L/∂y
 362dL_dy = 2 * (y - t)
 363# φ'(z): ReLU's slope
 364dy_dz = 1 if z > 0 else 0
 365# × ∂z/∂w_i = x_i, for w_1 and w_2
 366[dL_dy * dy_dz * x_i for x_i in x]  # → [-2.0, -3.0]
 367# one step, learning rate 0.01
 368w = [0.5 - 0.01 * -2.0, 1.0 - 0.01 * -3.0]
 369b = 0.5 - 0.01 * -1.0
 370y_new = w[0] * x[0] + w[1] * x[1] + b
 371# the new output and the smaller loss
 372round(y_new, 2), round((t - y_new) ** 2, 4)  # → (4.64, 0.1296)
 373```
 374
 375`one_neuron_worked_example` computes every row of the table.
 376
 377**Why it matters:** PyTorch's `loss.backward()` does exactly this, for
 378billions of weights, by recording the forward computation and replaying it
 379backwards. Knowing it is just the chain rule is what lets you reason about
 380vanishing gradients, memory use, and why some architectures train and others
 381don't.
 382
 383## Why the nonlinearity is essential
 384
 385Stack three sheets of tinted glass and you get one darker sheet of glass: no
 386arrangement of flat panes can bend light around a corner. Linear layers are
 387flat panes. However many you stack, the result is one linear layer, which can
 388only separate classes with a straight line. The activation function is the
 389bend.
 390
 391Worked example in one dimension: a layer that multiplies by 3, followed by a
 392layer that multiplies by 2, is the same as one layer that multiplies by 6.
 393With a ReLU between them, inputs −1 and 1 give 0 and 6. No single
 394multiplication does that.
 395
 396```mermaid
 397flowchart LR
 398  subgraph NoAct["Without activation"]
 399    a1[X] --> a2[·W1] --> a3[·W2] --> a4[·W3] --> a5["= X·(W1W2W3)<br/>one matrix"]
 400  end
 401  subgraph WithAct["With activation"]
 402    b1[X] --> b2[·W1] --> b3[φ] --> b4[·W2] --> b5[φ] --> b6[·W3] --> b7[curved<br/>decision boundary]
 403  end
 404```
 405
 406**Reading it:** in the top row the multiplications are chained with nothing
 407in between, and associativity lets you multiply the weights together first:
 408three layers equal one. In the bottom row a nonlinearity φ sits between the
 409multiplies, the product can no longer be pre-computed, and the network can
 410bend its decision boundary.
 411
 412$$
 413(XW_1)W_2 = X(W_1W_2) \quad\text{but}\quad \phi(XW_1)W_2 \neq X W' \text{ for any } W'
 414$$
 415
 416**Symbols**
 417
 418| Symbol | Meaning here | In the example |
 419|---|---|---|
 420| $X$ | the inputs | $x = -1$ or $x = 1$ |
 421| $W_1, W_2$ | two layers' weights | 3 and 2 |
 422| $W_1W_2$ | the two layers multiplied into one | 6 |
 423| $\phi$ | an activation between the layers | ReLU |
 424| $W'$ | any single weight you might try instead | none works |
 425| $\neq$ | "is not equal to" | |
 426
 427**In words:** "two linear layers in a row are the same as one linear layer,
 428but put an activation between them and no single layer can copy them."
 429
 430**With the numbers:** without ReLU, $-1 \to -3 \to -6$ and $1 \to 3 \to 6$,
 431exactly $6x$. With ReLU, $-1 \to -3 \to 0 \to 0$ and $1 \to 3 \to 3 \to 6$.
 432A single weight $W'$ would need $-W' = 0$ and $W' = 6$ at once: impossible.
 433
 434**In Python:**
 435
 436```python
 437W_1, W_2 = 3, 2
 438# ReLU
 439def phi(z): return max(0, z)
 440# (X W_1) W_2 ...
 441[(x * W_1) * W_2 for x in [-1, 1]]  # → [-6, 6]
 442# ... equals X (W_1 W_2): one layer of 6
 443[x * (W_1 * W_2) for x in [-1, 1]]  # → [-6, 6]
 444# φ(X W_1) W_2: no single W' gives 0 and 6
 445[phi(x * W_1) * W_2 for x in [-1, 1]]  # → [0, 6]
 446```
 447
 448![Two-moons decision boundaries: logistic regression's straight line misclassifies the moon tips (88%), while one hidden tanh layer bends around the gap (99.75%)](figures/primer.ml.neural_net.decision_boundaries.svg)
 449
 450**Reading it:** both panels show the same two interleaving half-moons,
 451coloured by class; the shaded background is what each model predicts at
 452every point. On the left, a single linear layer can only split the plane with
 453a straight line, so the tips of both moons land on the wrong side (about 88%
 454accuracy). On the right, one hidden tanh layer bends the boundary to follow
 455the gap between the moons (99.75%, one point of 400 wrong). Same data, same optimizer: the only
 456difference is a nonlinearity between two layers.
 457
 458**In code:** `make_moons` builds the two interleaving half-moons (and `make_xor` the four-point XOR puzzle); `train_logistic_regression` fits the straight-line baseline in the left panel.
 459
 460**Why it matters:** "depth" is only worth anything because of the
 461activations between layers. `linear_stack_collapses` shows five stacked
 462linear layers matching their single-matrix product to 1e-16.
 463
 464## Activation functions: the rule after the sum
 465
 466Think of different kinds of switch. **ReLU** is a one-way valve: water flows
 467freely forward, not at all backward. **Sigmoid** is a dimmer that squeezes
 468any input into 0–1 but barely moves at the extremes. **Tanh** is the same
 469dimmer centred on zero. **GELU** is a valve that leaks a little when nearly
 470closed.
 471
 472Worked example, values you can check on a calculator (the ′ mark, as in
 473sigmoid′, means "the slope of"):
 474
 475| z | ReLU | sigmoid | sigmoid' | tanh' | GELU |
 476|---|---|---|---|---|---|
 477| −1 | 0 | 0.269 | 0.197 | 0.420 | −0.159 |
 478| 0 | 0 | 0.5 | **0.25** (its maximum) | **1** (its maximum) | 0 |
 479| 1 | 1 | 0.731 | 0.197 | 0.420 | 0.841 |
 480
 481![Activations and their slopes: sigmoid's slope peaks at 0.25 and sigmoid and tanh slopes vanish past |z| = 3, while ReLU's slope is a clean step from 0 to 1](figures/primer.ml.neural_net.activations.svg)
 482
 483**Reading it:** the left panel shows each activation's output, the right its
 484derivative, which is how much gradient passes back through it. Look at the
 485tails of the right panel first: sigmoid and tanh derivatives fall to almost
 486zero once |z| passes 3, so a *saturated* neuron barely learns. Sigmoid's
 487derivative never exceeds 0.25 even at its peak; stack ten such layers and the
 488gradient can shrink by 0.25¹⁰ ≈ 1e-6. ReLU's derivative is a step: exactly 1
 489for positive inputs (no shrinking) and exactly 0 for negatives. GELU's is a
 490smooth version of that step.
 491
 492| Function | Formula | Range | Where it's used |
 493|---|---|---|---|
 494| ReLU | max(0, z) | [0, ∞) | Hidden layers of CNNs and MLPs. Fast. A neuron stuck negative gets zero gradient forever ("dying ReLU"). |
 495| GELU | z·Φ(z), Φ = normal CDF | ≈[−0.17, ∞) | Transformers (BERT, GPT-2 and most since). |
 496| Sigmoid | 1 / (1 + e^−z) | (0, 1) | Binary outputs, and the gates inside LSTMs. |
 497| Tanh | (e^z − e^−z)/(e^z + e^−z) | (−1, 1) | RNN hidden states; zero-centred. |
 498| Softmax | e^{z_i} / Σ e^{z_j} | probabilities | The output layer over classes or tokens, and inside attention. |
 499
 500Reading the formulas: *e* is Euler's number, about 2.718, and e^z means
 501"2.718 raised to the power z", which is always positive and grows fast.
 502Φ(z) is the fraction of a bell curve (the standard normal distribution) that
 503lies below z: Φ(0) = 0.5, Φ(1) = 0.841. **Softmax** turns a list of scores
 504into shares that are all positive and add up to 1: raise *e* to each score,
 505then divide each by the total (Σ means "add them all up"). See
 506`primer.notation` for each symbol, and `primer.ml.attention` for softmax
 507worked through by hand.
 508
 509**In code:** `relu`, `sigmoid`, `tanh` and `gelu` compute each rule, and `relu_grad`, `sigmoid_grad`, `tanh_grad` and `gelu_grad` compute its slope; `gelu_tanh` is the cheaper approximation GPT-2 uses, and `softmax` subtracts the largest score first so nothing overflows.
 510
 511**Why it matters:** activation choice decides whether gradients survive a
 512deep stack. Sigmoid everywhere is why deep networks were hard to train
 513before 2010; ReLU and residual connections are why they aren't now (see
 514`primer.ml.deep_nets`).
 515
 516## Backprop through two layers
 517
 518Now the assembly line has two stations. The blame from the end first reaches
 519the output weights, then travels back through the hidden layer to the first
 520weights. On the way it passes through the hidden activation, which can
 521shrink it, just as a station that barely changed the product can take only a
 522little of the blame.
 523
 524Worked example, one input, one hidden unit, one output: x = 1, w1 = 0.5,
 525tanh, w2 = 2, sigmoid, target 1.
 526
 527| step | value |
 528|---|---|
 529| z1 = x·w1 | 0.5 |
 530| h = tanh(0.5) | 0.4621 |
 531| z2 = h·w2 | 0.9242 |
 532| ŷ = σ(0.9242) | 0.7159 |
 533| dL/dz2 = ŷ − y | −0.2841 |
 534| dL/dw2 = h · dL/dz2 | −0.1313 |
 535| dL/dh = dL/dz2 · w2 | −0.5682 |
 536| dL/dz1 = dL/dh · (1 − h²) | −0.5682 × 0.7864 = −0.4469 |
 537| dL/dw1 = x · dL/dz1 | −0.4469 |
 538
 539```mermaid
 540flowchart LR
 541  subgraph Forward
 542    X[X] --> Z1["z1 = X·W1 + b1"] --> H["h = tanh z1"] --> Z2["z2 = h·W2 + b2"] --> P["ŷ = σ z2"] --> L["L = BCE ŷ, y"]
 543  end
 544  L -. "ŷ − y" .-> dZ2[dL/dz2]
 545  dZ2 -. "hᵀ · dz2" .-> dW2[dL/dW2]
 546  dZ2 -. "dz2 · W2ᵀ" .-> dH[dL/dh]
 547  dH -. "⊙ 1 − h²" .-> dZ1[dL/dz1]
 548  dZ1 -. "Xᵀ · dz1" .-> dW1[dL/dW1]
 549  H -. cached .-> dW2
 550  H -. cached .-> dZ1
 551  X -. cached .-> dW1
 552```
 553
 554**Reading it:** the top row is the forward pass, left to right; the dotted
 555arrows are the backward pass, starting at the loss and walking back one box
 556at a time. Each label is one line of `MLP.backward`, and the worked table
 557above is that same path with numbers. Notice the three "cached" arrows: the
 558backward pass reuses h and X from the forward pass. That dependency is why
 559training must keep every layer's activations in memory, and inference does
 560not.
 561
 562For a batch, with binary cross-entropy (the loss for yes/no predictions,
 563$-[y\ln\hat{y} + (1-y)\ln(1-\hat{y})]$; see `primer.ml.losses`), which
 564fuses with sigmoid into the simple error ŷ − y:
 565
 566$$
 567\frac{\partial L}{\partial z_2} = \hat{y} - y,\;
 568\frac{\partial L}{\partial W_2} = h^\top \frac{\partial L}{\partial z_2},\;
 569\frac{\partial L}{\partial h} = \frac{\partial L}{\partial z_2} W_2^\top,\;
 570\frac{\partial L}{\partial z_1} = \frac{\partial L}{\partial h} \odot (1 - h^2),\;
 571\frac{\partial L}{\partial W_1} = X^\top \frac{\partial L}{\partial z_1}
 572$$
 573
 574**Symbols**
 575
 576| Symbol | Meaning here | In the example (one example, one unit) |
 577|---|---|---|
 578| $X$ | inputs, one row per example | $x = 1$ |
 579| $W_1, W_2$ | first and second layer weights | 0.5, 2 |
 580| $z_1, z_2$ | each layer's weighted sum, before its activation | 0.5, 0.9242 |
 581| $h$ | hidden activations, $\tanh(z_1)$ | 0.4621 |
 582| $\hat{y}$ | "y-hat", the prediction $\sigma(z_2)$ | 0.7159 |
 583| $y$ | the target | 1 |
 584| $\sigma$ | sigmoid, squashes any number into 0 to 1 | $\sigma(0.9242) = 0.7159$ |
 585| $\tanh$ | hyperbolic tangent, squashes into −1 to 1 | $\tanh(0.5) = 0.4621$ |
 586| $^\top$ | "transpose": flip a matrix so rows become columns, so the shapes line up for the multiply | a scalar is its own transpose |
 587| $\odot$ | multiply element by element (not a matrix multiply) | $-0.5682 \times 0.7864$ |
 588| $1 - h^2$ | the slope of tanh at $z_1$ | 0.7864 |
 589
 590**In words:** "the error at the output is prediction minus target; each
 591layer's weight gradient is that layer's input times the error arriving at it;
 592to send the error one layer further back, multiply by the weights it came
 593through and by the activation's slope."
 594
 595**With the numbers:** $\partial L/\partial z_2 = 0.7159 - 1 = -0.2841$;
 596$\partial L/\partial W_2 = 0.4621 \times -0.2841 = -0.1313$;
 597$\partial L/\partial h = -0.2841 \times 2 = -0.5682$;
 598$\partial L/\partial z_1 = -0.5682 \times 0.7864 = -0.4469$;
 599$\partial L/\partial W_1 = 1 \times -0.4469 = -0.4469$.
 600
 601**In Python:**
 602
 603```python
 604import math
 605X, W_1, W_2, y = 1, 0.5, 2, 1
 606z_1 = X * W_1
 607h = math.tanh(z_1)
 608z_2 = h * W_2
 609# σ(z_2)
 610y_hat = 1 / (1 + math.exp(-z_2))
 611# ŷ - y
 612dL_dz2 = y_hat - y
 613# hᵀ ∂L/∂z_2
 614dL_dW2 = h * dL_dz2
 615# ∂L/∂z_2 W_2ᵀ
 616dL_dh = dL_dz2 * W_2
 617# ⊙ (1 - h²), tanh's slope
 618dL_dz1 = dL_dh * (1 - h ** 2)
 619# Xᵀ ∂L/∂z_1
 620dL_dW1 = X * dL_dz1
 621[round(v, 4) for v in (dL_dz2, dL_dW2, dL_dh, dL_dz1, dL_dW1)]  # → [-0.2841, -0.1313, -0.5682, -0.4469, -0.4469]
 622```
 623
 624The hand-written gradients are checked two ways: a **numerical gradient
 625check** (nudge each weight by ±ε and measure the loss change; see
 626`gradient_check`) and, in the tests, PyTorch autograd.
 627
 628**In code:** `tiny_two_layer_example` computes every row of the worked table; `MLP` holds both layers' weights and biases, `MLP.loss` scores a batch with `binary_cross_entropy`, and `numerical_gradient` is the slow nudge-every-weight answer that `gradient_check` compares against.
 629
 630**Why it matters:** the factor (1 − h²) is where gradients shrink. Chain
 631fifty of them and the first layers hear almost nothing: the vanishing
 632gradient problem. The cached activations are why training a model needs
 633several times the memory of serving it.
 634
 635## Batch, step, epoch
 636
 637Imagine studying a deck of 400 flashcards. You work through a pile of 32,
 638then pause to update your notes; that pause is a **step**. The pile is a
 639**batch**. Going through the whole deck once is an **epoch**; then you
 640shuffle and start again.
 641
 642Worked example: 400 examples in batches of 32 is 12 full batches plus one of
 64316, so ⌈400/32⌉ = 13 steps per epoch. Two epochs: 26 steps.
 644
 645```mermaid
 646flowchart LR
 647  E[Epoch: one pass over all 400 examples] --> SH[Shuffle]
 648  SH --> B1[Batch 1<br/>32 examples] --> S1[Step 1<br/>update weights]
 649  S1 --> B2[Batch 2] --> S2[Step 2]
 650  S2 --> D[...] --> B13[Batch 13<br/>last 16 examples] --> S13[Step 13]
 651  S13 -->|next epoch| E
 652```
 653
 654**Reading it:** an epoch reshuffles the data and slices it into batches;
 655every batch produces exactly one weight update. Shuffling each epoch means
 656batches differ every time, so the gradient noise doesn't repeat.
 657
 658![Training loss per epoch on a log axis: slow at first, a steep fall once hidden units find features, then a flat tail near zero](figures/primer.ml.neural_net.training_curve.svg)
 659
 660**Reading it:** the horizontal axis counts epochs on a log scale; the vertical
 661axis is the loss on the whole training set after each one. Loss drops slowly
 662at first while the weights are near their random start, falls steeply once
 663the hidden units find useful features, then flattens as the remaining errors
 664get harder. Plotting this curve is the first thing to do when training
 665anything.
 666
 667$$
 668\text{steps per epoch} = \left\lceil \frac{N}{B} \right\rceil, \qquad
 669\text{total steps} = \text{epochs} \times \left\lceil \frac{N}{B} \right\rceil
 670$$
 671
 672**Symbols**
 673
 674| Symbol | Meaning here | In the example |
 675|---|---|---|
 676| $N$ | number of training examples | 400 |
 677| $B$ | batch size | 32 |
 678| $\lceil \cdot \rceil$ | "ceiling": round up to the next whole number (the last, smaller batch still counts as a step) | $\lceil 12.5 \rceil = 13$ |
 679
 680**In words:** "an epoch takes as many steps as it takes batches of size B to
 681cover N examples, rounding up."
 682
 683**With the numbers:** $\lceil 400 / 32 \rceil = \lceil 12.5 \rceil = 13$ steps
 684per epoch; 2 epochs = 26 steps.
 685
 686**In Python:**
 687
 688```python
 689import math
 690N, B, epochs = 400, 32, 2
 691N / B  # → 12.5
 692# ⌈N / B⌉: the last, smaller batch still counts
 693math.ceil(N / B)  # → 13
 694epochs * math.ceil(N / B)  # → 26
 695```
 696
 697**In code:** `train` reshuffles the data every epoch, takes one plain gradient step per batch and records the loss after each epoch; `MLP.accuracy` reports the fraction of examples classified correctly.
 698
 699**Why it matters:** larger batches give smoother gradient estimates and keep
 700GPUs busy, but need more memory. Pretraining a language model runs roughly
 701one epoch over trillions of tokens (it rarely sees the same text twice);
 702fine-tuning runs several epochs over a small dataset, which is why
 703fine-tunes can overfit.
 704
 705## In 20 seconds
 706- A neuron is a weighted sum plus bias through a nonlinearity; a layer is a
 707  matrix multiply; a network is stacked layers.
 708- Without nonlinearity, depth is pointless: stacked linear layers equal one.
 709- Training loop: forward, loss, backprop (the chain rule gives a gradient per
 710  weight), optimizer step. Repeat.
 711- Backprop reuses forward activations, so training needs far more memory
 712  than inference.
 713
 714## Self-test questions
 715
 716**Why can't you just stack linear layers?**
 717Their composition is a single linear map (W1·W2 is just another matrix), so
 718extra layers add parameters but no expressive power.
 719
 720**What does backprop actually compute?**
 721The gradient: for every weight, how much a tiny change would change the loss.
 722It applies the chain rule from the output backward, reusing values cached in
 723the forward pass.
 724
 725**Why is ReLU preferred over sigmoid in hidden layers?**
 726Sigmoid's derivative is at most 0.25 and near 0 when saturated, so gradients
 727shrink multiplicatively with depth. ReLU's derivative is exactly 1 for
 728positive inputs. The cost: neurons whose input stays negative get zero
 729gradient and "die".
 730
 731**Why does training need more memory than inference?**
 732The backward pass needs every layer's activations from the forward pass, so
 733they must be kept until the gradients are computed. Inference can discard
 734each activation as soon as the next layer has used it.
 735
 736**What's the gradient of sigmoid + binary cross-entropy with respect to the logit?**
 737ŷ − y: prediction minus target. Clean, bounded and cheap, which is why the
 738two are always fused.
 739
 740**Batch vs. step vs. epoch?**
 741A batch is the examples per update, a step is one update, an epoch is one
 742full pass over the data.
 743
 744## The papers behind this lesson
 745
 746- Rumelhart, Hinton & Williams, *Learning representations by back-propagating errors* (Nature, 1986): https://www.nature.com/articles/323533a0
 747  Showed that the chain rule, run backwards through a multi-layer network, trains hidden units to discover useful internal features.
 748- Hendrycks & Gimpel, *Gaussian Error Linear Units (GELUs)* (2016): https://arxiv.org/abs/1606.08415
 749  Introduced GELU, the smooth ReLU that became the default activation in transformers.
 750
 751## Further reading
 752- CS231n notes, *Backpropagation, intuitions*: https://cs231n.github.io/optimization-2/
 753- CS231n notes, *Neural Networks Part 1*: https://cs231n.github.io/neural-networks-1/
 754- Andrej Karpathy, *micrograd* (autograd in about 100 lines): https://github.com/karpathy/micrograd
 755- Andrej Karpathy, *The spelled-out intro to neural networks and backpropagation*: https://www.youtube.com/watch?v=VMj-3S1tku0
 756- 3Blue1Brown, *Neural networks* series: https://www.3blue1brown.com/topics/neural-networks
 757- Michael Nielsen, *Neural Networks and Deep Learning*, ch. 2: http://neuralnetworksanddeeplearning.com/chap2.html
 758- Hendrycks & Gimpel, *GELU* (2016): https://arxiv.org/abs/1606.08415
 759- PyTorch autograd tutorial: https://pytorch.org/tutorials/beginner/blitz/autograd_tutorial.html
 760"""
 761
 762from __future__ import annotations
 763
 764import math
 765from dataclasses import dataclass, field
 766
 767import numpy as np
 768
 769from primer._show import banner, say, table, takeaway
 770
 771# ---------------------------------------------------------------------------
 772# 0. One knob: the whole training idea with a single weight
 773# ---------------------------------------------------------------------------
 774
 775
 776def learn_one_weight(steps: int = 3, lr: float = 0.25) -> list[float]:
 777    """Gradient descent on loss (w·1 − 3)², starting from w = 0. Returns w after each step.
 778
 779    The slope is 2(w − 3); with lr 0.25 each step moves w halfway to 3.
 780    """
 781    w, history = 0.0, [0.0]
 782    for _ in range(steps):
 783        slope = 2 * (w * 1.0 - 3.0)
 784        w -= lr * slope
 785        history.append(w)
 786    return history
 787
 788
 789# ---------------------------------------------------------------------------
 790# 1. One neuron, by hand
 791# ---------------------------------------------------------------------------
 792
 793
 794def one_neuron_worked_example(lr: float = 0.01) -> dict:
 795    """Forward pass, squared-error loss, gradients, and one SGD step for one ReLU neuron.
 796
 797    Inputs (2, 3), weights (0.5, 1.0), bias 0.5, target 5.
 798    """
 799    x = np.array([2.0, 3.0])
 800    w = np.array([0.5, 1.0])
 801    b = 0.5
 802    target = 5.0
 803
 804    # Forward.
 805    z = w @ x + b  # weighted sum: 2*0.5 + 3*1.0 + 0.5 = 4.5
 806    y = relu(z)  # positive, so unchanged: 4.5
 807    loss = (target - y) ** 2  # 0.25
 808
 809    # Backward, one chain-rule link at a time.
 810    dL_dy = 2 * (y - target)  # d/dy (t - y)^2 = -2 (t - y) = -1.0
 811    dy_dz = relu_grad(z)  # 1 because z > 0
 812    dL_dz = dL_dy * dy_dz
 813    dL_dw = dL_dz * x  # dz/dw_i = x_i, so the weight with the bigger input gets the bigger gradient
 814    dL_db = dL_dz  # dz/db = 1
 815
 816    # SGD: step *against* the gradient.
 817    w_new = w - lr * dL_dw
 818    b_new = b - lr * dL_db
 819    y_new = relu(w_new @ x + b_new)
 820    return dict(
 821        z=float(z),
 822        y=float(y),
 823        loss=float(loss),
 824        dL_dw=dL_dw,
 825        dL_db=float(dL_db),
 826        w_new=w_new,
 827        b_new=float(b_new),
 828        y_new=float(y_new),
 829        loss_new=float((target - y_new) ** 2),
 830    )
 831
 832
 833# ---------------------------------------------------------------------------
 834# 2. Activation functions and their derivatives
 835# ---------------------------------------------------------------------------
 836
 837
 838def relu(z):
 839    return np.maximum(0.0, z)
 840
 841
 842def relu_grad(z):
 843    # The derivative at exactly 0 is undefined; frameworks use 0. It never matters in practice.
 844    return (np.asarray(z) > 0).astype(float)
 845
 846
 847def sigmoid(z):
 848    # Split by sign so exp never overflows: for z << 0 use e^z / (1 + e^z).
 849    z = np.asarray(z, dtype=float)
 850    out = np.empty_like(z)
 851    pos = z >= 0
 852    out[pos] = 1 / (1 + np.exp(-z[pos]))
 853    ez = np.exp(z[~pos])
 854    out[~pos] = ez / (1 + ez)
 855    return out
 856
 857
 858def sigmoid_grad(z):
 859    s = sigmoid(z)
 860    return s * (1 - s)  # peaks at 0.25 when z = 0
 861
 862
 863def tanh(z):
 864    return np.tanh(z)
 865
 866
 867def tanh_grad(z):
 868    return 1 - np.tanh(z) ** 2  # peaks at 1 when z = 0
 869
 870
 871_erf = np.vectorize(math.erf)
 872
 873
 874def gelu(z):
 875    """Exact GELU: z * Φ(z), where Φ is the standard normal CDF.
 876
 877    Intuition: scale each input by the probability that a standard normal is
 878    below it. Large positives pass through, large negatives go to 0, and
 879    small negatives leak a little (unlike ReLU's hard cutoff).
 880    """
 881    z = np.asarray(z, dtype=float)
 882    return 0.5 * z * (1 + _erf(z / math.sqrt(2)))
 883
 884
 885def gelu_tanh(z):
 886    """The tanh approximation used by GPT-2 and many kernels."""
 887    z = np.asarray(z, dtype=float)
 888    return 0.5 * z * (1 + np.tanh(math.sqrt(2 / math.pi) * (z + 0.044715 * z**3)))
 889
 890
 891def gelu_grad(z):
 892    z = np.asarray(z, dtype=float)
 893    cdf = 0.5 * (1 + _erf(z / math.sqrt(2)))
 894    pdf = np.exp(-0.5 * z**2) / math.sqrt(2 * math.pi)
 895    return cdf + z * pdf
 896
 897
 898def softmax(z, axis: int = -1):
 899    """Stable softmax (see primer.ml.attention.softmax for the full story)."""
 900    z = np.asarray(z, dtype=float)
 901    e = np.exp(z - z.max(axis=axis, keepdims=True))
 902    return e / e.sum(axis=axis, keepdims=True)
 903
 904
 905ACTIVATIONS = {
 906    "relu": (relu, relu_grad),
 907    "gelu": (gelu, gelu_grad),
 908    "sigmoid": (sigmoid, sigmoid_grad),
 909    "tanh": (tanh, tanh_grad),
 910}
 911
 912
 913def tiny_two_layer_example() -> dict:
 914    """Backprop through x → (w1, tanh) → (w2, sigmoid) → BCE with scalars you can check by hand.
 915
 916    x = 1, w1 = 0.5, w2 = 2, target 1, no biases.
 917    """
 918    x, w1, w2, y = 1.0, 0.5, 2.0, 1.0
 919    z1 = x * w1
 920    h = float(np.tanh(z1))
 921    z2 = h * w2
 922    y_hat = float(sigmoid(np.array([z2]))[0])
 923    dL_dz2 = y_hat - y  # sigmoid + BCE fuse to prediction − target
 924    dL_dw2 = h * dL_dz2  # the output weight's input was h
 925    dL_dh = dL_dz2 * w2  # blame sent back through w2
 926    dL_dz1 = dL_dh * (1 - h**2)  # shrunk by tanh's slope at z1
 927    dL_dw1 = x * dL_dz1
 928    return dict(z1=z1, h=h, z2=z2, y_hat=y_hat, dL_dz2=dL_dz2, dL_dw2=dL_dw2, dL_dh=dL_dh, dL_dz1=dL_dz1, dL_dw1=dL_dw1)
 929
 930
 931# ---------------------------------------------------------------------------
 932# 3. Why nonlinearity matters
 933# ---------------------------------------------------------------------------
 934
 935
 936def linear_stack_collapses(n_layers: int = 5, d: int = 4, seed: int = 0) -> float:
 937    """Return max |difference| between a deep linear stack and its single-matrix equivalent.
 938
 939    It's ~1e-15 (floating-point noise): the deep stack *is* one linear layer.
 940    """
 941    rng = np.random.default_rng(seed)
 942    Ws = [rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(n_layers)]
 943    X = rng.standard_normal((10, d))
 944    deep = X
 945    for W in Ws:
 946        deep = deep @ W
 947    collapsed = np.linalg.multi_dot(Ws)
 948    return float(np.abs(deep - X @ collapsed).max())
 949
 950
 951# ---------------------------------------------------------------------------
 952# 4. Toy dataset: two interleaving moons (not linearly separable)
 953# ---------------------------------------------------------------------------
 954
 955
 956def make_moons(n: int = 400, noise: float = 0.15, seed: int = 0) -> tuple[np.ndarray, np.ndarray]:
 957    """Two interleaving half-circles. A straight line can't separate them."""
 958    rng = np.random.default_rng(seed)
 959    n1 = n // 2
 960    t1 = rng.uniform(0, np.pi, n1)
 961    t2 = rng.uniform(0, np.pi, n - n1)
 962    a = np.c_[np.cos(t1), np.sin(t1)]
 963    b = np.c_[1 - np.cos(t2), 0.5 - np.sin(t2)]
 964    X = np.vstack([a, b]) + rng.normal(0, noise, (n, 2))
 965    y = np.r_[np.zeros(n1), np.ones(n - n1)]
 966    return X, y
 967
 968
 969def make_xor() -> tuple[np.ndarray, np.ndarray]:
 970    X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
 971    y = np.array([0, 1, 1, 0], dtype=float)
 972    return X, y
 973
 974
 975# ---------------------------------------------------------------------------
 976# 5. A two-layer MLP with hand-written backprop
 977# ---------------------------------------------------------------------------
 978
 979
 980def binary_cross_entropy(p: np.ndarray, y: np.ndarray, eps: float = 1e-12) -> float:
 981    """Mean BCE: -[y log p + (1-y) log(1-p)]. Clip so log(0) can't happen."""
 982    p = np.clip(p, eps, 1 - eps)
 983    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
 984
 985
 986@dataclass
 987class MLP:
 988    """x -> tanh(x W1 + b1) -> sigmoid(h W2 + b2). Binary classifier.
 989
 990    Every parameter lives in `params`; `forward` caches what `backward` needs.
 991    """
 992
 993    n_in: int
 994    n_hidden: int
 995    seed: int = 0
 996    params: dict[str, np.ndarray] = field(init=False)
 997
 998    def __post_init__(self):
 999        rng = np.random.default_rng(self.seed)
1000        # Xavier-style init for tanh: variance 1/fan_in keeps activations out
1001        # of the flat, saturated regions at the start (see primer.ml.deep_nets).
1002        self.params = {
1003            "W1": rng.normal(0, 1 / np.sqrt(self.n_in), (self.n_in, self.n_hidden)),
1004            "b1": np.zeros(self.n_hidden),
1005            "W2": rng.normal(0, 1 / np.sqrt(self.n_hidden), (self.n_hidden, 1)),
1006            "b2": np.zeros(1),
1007        }
1008
1009    def forward(self, X: np.ndarray) -> tuple[np.ndarray, dict]:
1010        p = self.params
1011        z1 = X @ p["W1"] + p["b1"]  # (n, hidden)
1012        h = np.tanh(z1)  # nonlinearity: without it this whole net is logistic regression
1013        z2 = h @ p["W2"] + p["b2"]  # (n, 1)
1014        yhat = sigmoid(z2)[:, 0]
1015        # Cache the intermediates: the backward pass needs X, h and yhat.
1016        return yhat, {"X": X, "h": h, "yhat": yhat}
1017
1018    def loss(self, X: np.ndarray, y: np.ndarray) -> float:
1019        return binary_cross_entropy(self.forward(X)[0], y)
1020
1021    def backward(self, cache: dict, y: np.ndarray) -> dict[str, np.ndarray]:
1022        """Chain rule, from the loss back to every parameter. Returns grads with the same keys as params."""
1023        X, h, yhat = cache["X"], cache["h"], cache["yhat"]
1024        n = X.shape[0]
1025        # Sigmoid + BCE fuse to a beautifully simple gradient: prediction minus target.
1026        # (Divide by n because the loss is a mean over the batch.)
1027        dz2 = ((yhat - y) / n)[:, None]  # (n, 1)
1028        dW2 = h.T @ dz2  # (hidden, 1): each weight's gradient = its input times the upstream error
1029        db2 = dz2.sum(axis=0)
1030        dh = dz2 @ self.params["W2"].T  # (n, hidden): send the error back through W2
1031        dz1 = dh * (1 - h**2)  # tanh'(z1) = 1 - tanh(z1)^2, reusing the cached h
1032        dW1 = X.T @ dz1
1033        db1 = dz1.sum(axis=0)
1034        return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}
1035
1036    def accuracy(self, X: np.ndarray, y: np.ndarray) -> float:
1037        return float(np.mean((self.forward(X)[0] > 0.5) == y))
1038
1039
1040def numerical_gradient(model: MLP, X: np.ndarray, y: np.ndarray, eps: float = 1e-5) -> dict[str, np.ndarray]:
1041    """Central-difference gradient: (L(w+ε) - L(w-ε)) / 2ε for every weight.
1042
1043    Slow (two forward passes per parameter) but obviously correct, which is
1044    what makes it the standard way to test a hand-written backward pass.
1045    """
1046    grads = {}
1047    for name, P in model.params.items():
1048        g = np.zeros_like(P)
1049        for idx in np.ndindex(P.shape):
1050            old = P[idx]
1051            P[idx] = old + eps
1052            lp = model.loss(X, y)
1053            P[idx] = old - eps
1054            lm = model.loss(X, y)
1055            P[idx] = old
1056            g[idx] = (lp - lm) / (2 * eps)
1057        grads[name] = g
1058    return grads
1059
1060
1061def gradient_check(model: MLP, X: np.ndarray, y: np.ndarray) -> float:
1062    """Max relative error between analytic and numerical gradients (expect < 1e-6)."""
1063    _, cache = model.forward(X)
1064    analytic = model.backward(cache, y)
1065    numeric = numerical_gradient(model, X, y)
1066    worst = 0.0
1067    for k in analytic:
1068        a, n = analytic[k], numeric[k]
1069        rel = np.abs(a - n) / np.maximum(1e-8, np.abs(a) + np.abs(n))
1070        worst = max(worst, float(rel.max()))
1071    return worst
1072
1073
1074# ---------------------------------------------------------------------------
1075# 6. The training loop: batches, steps, epochs
1076# ---------------------------------------------------------------------------
1077
1078
1079def train(
1080    model: MLP,
1081    X: np.ndarray,
1082    y: np.ndarray,
1083    epochs: int = 200,
1084    batch_size: int = 32,
1085    lr: float = 0.5,
1086    seed: int = 0,
1087) -> dict:
1088    """Mini-batch SGD. Returns the loss history and step/epoch counts."""
1089    rng = np.random.default_rng(seed)
1090    n = len(X)
1091    steps = 0
1092    history = []
1093    for _epoch in range(epochs):  # one epoch = one full pass over the data
1094        order = rng.permutation(n)  # reshuffle each epoch so batches differ
1095        for start in range(0, n, batch_size):  # one batch -> one step
1096            idx = order[start : start + batch_size]
1097            _, cache = model.forward(X[idx])  # 1. forward
1098            grads = model.backward(cache, y[idx])  # 2-3. loss gradient via backprop
1099            for k in model.params:  # 4. optimizer step (plain SGD)
1100                model.params[k] -= lr * grads[k]
1101            steps += 1
1102        history.append(model.loss(X, y))
1103    return dict(history=history, steps=steps, steps_per_epoch=math.ceil(n / batch_size))
1104
1105
1106def train_logistic_regression(X: np.ndarray, y: np.ndarray, epochs: int = 300, lr: float = 0.5) -> float:
1107    """A single linear layer + sigmoid: the no-hidden-layer baseline. Returns accuracy."""
1108    w, b = np.zeros(X.shape[1]), 0.0
1109    for _ in range(epochs):
1110        p = sigmoid(X @ w + b)
1111        g = (p - y) / len(y)
1112        w -= lr * X.T @ g
1113        b -= lr * g.sum()
1114    return float(np.mean((sigmoid(X @ w + b) > 0.5) == y))
1115
1116
1117# ---------------------------------------------------------------------------
1118# 7. Figures (rendered by `make figures`)
1119# ---------------------------------------------------------------------------
1120
1121
1122def figures() -> dict:
1123    """Plots computed from this module's own functions."""
1124    import matplotlib
1125
1126    matplotlib.use("Agg")
1127    import matplotlib.pyplot as plt
1128
1129    figs = {}
1130
1131    z = np.linspace(-5, 5, 400)
1132    fig, (a1, a2) = plt.subplots(1, 2, figsize=(10, 3.6))
1133    for name, (f, g) in ACTIVATIONS.items():
1134        a1.plot(z, f(z), label=name)
1135        a2.plot(z, g(z), label=name)
1136    a1.set(xlabel="input z", ylabel="output φ(z)", title="Activation functions", ylim=(-1.5, 3))
1137    a2.set(xlabel="input z", ylabel="derivative φ'(z)", title="Their derivatives (gradient that flows back)")
1138    for a in (a1, a2):
1139        a.axhline(0, color="gray", lw=0.5)
1140        a.grid(alpha=0.3)
1141        a.legend(fontsize=8)
1142    fig.tight_layout()
1143    figs["activations"] = fig
1144
1145    X, y = make_moons()
1146    # Logistic regression, re-fit here so we can evaluate it on a grid.
1147    w, b = np.zeros(2), 0.0
1148    for _ in range(300):
1149        p = sigmoid(X @ w + b)
1150        g = (p - y) / len(y)
1151        w -= 0.5 * X.T @ g
1152        b -= 0.5 * g.sum()
1153    mlp = MLP(2, 16)
1154    train(mlp, X, y, epochs=300)
1155    xx, yy = np.meshgrid(np.linspace(-1.5, 2.5, 200), np.linspace(-1.0, 1.5, 150))
1156    grid = np.c_[xx.ravel(), yy.ravel()]
1157    fig, axes = plt.subplots(1, 2, figsize=(10, 3.8), sharey=True)
1158    for ax, pred, title in (
1159        (axes[0], sigmoid(grid @ w + b), f"Linear (logistic regression): {train_logistic_regression(X, y):.0%}"),
1160        (axes[1], mlp.forward(grid)[0], f"MLP, 16 tanh hidden units: {mlp.accuracy(X, y):.2%}"),
1161    ):
1162        ax.contourf(xx, yy, pred.reshape(xx.shape), levels=[0, 0.5, 1], colors=["#cfe0f3", "#f6d5c8"])
1163        ax.scatter(X[y == 0, 0], X[y == 0, 1], s=8, color="C0", label="class 0")
1164        ax.scatter(X[y == 1, 0], X[y == 1, 1], s=8, color="C3", label="class 1")
1165        ax.set(title=title, xlabel="x1")
1166    axes[0].set_ylabel("x2")
1167    axes[0].legend(fontsize=8, loc="lower left")
1168    fig.tight_layout()
1169    figs["decision_boundaries"] = fig
1170
1171    hist = train(MLP(2, 16), X, y, epochs=300)["history"]
1172    fig, ax = plt.subplots(figsize=(6, 3.4))
1173    ax.plot(np.arange(1, len(hist) + 1), hist)
1174    ax.set(xscale="log", xlabel="epoch (log scale)", ylabel="training loss (BCE)", title="Training curve on two moons")
1175    ax.grid(alpha=0.3)
1176    fig.tight_layout()
1177    figs["training_curve"] = fig
1178    return figs
1179
1180
1181# ---------------------------------------------------------------------------
1182# 8. Walkthrough
1183# ---------------------------------------------------------------------------
1184
1185
1186def demo() -> None:
1187    banner("0. One knob: gradient descent on a single weight")
1188    ws = learn_one_weight(steps=5)
1189    table(["step", "w", "slope 2(w − 3)"], [(i, w, 2 * (w - 3)) for i, w in enumerate(ws)], floatfmt=".4f")
1190    say("Each step moves w halfway to 3, because the slope shrinks as the error shrinks.")
1191    takeaway("Training is: measure how wrong you are, find which way is downhill, take a small step. Repeat.")
1192
1193    banner("1. One neuron, by hand")
1194    r = one_neuron_worked_example()
1195    say(
1196        f"""
1197        Inputs (2, 3), weights (0.5, 1.0), bias 0.5. Weighted sum
1198        2×0.5 + 3×1.0 + 0.5 = {r['z']}. ReLU keeps it: output {r['y']}.
1199        Target 5, so squared error = (5 − 4.5)² = {r['loss']}.
1200        """
1201    )
1202    table(
1203        ["quantity", "value", "why"],
1204        [
1205            ("dL/dy", 2 * (r["y"] - 5), "2(y − target): negative, so output is too low"),
1206            ("dL/dw1", r["dL_dw"][0], "error × input 2"),
1207            ("dL/dw2", r["dL_dw"][1], "error × input 3: the most influential weight"),
1208            ("dL/db", r["dL_db"], "error × 1"),
1209        ],
1210        floatfmt=".3f",
1211    )
1212    say(
1213        f"""
1214        One SGD step with learning rate 0.01 moves against the gradient:
1215        weights become ({r['w_new'][0]:.2f}, {r['w_new'][1]:.2f}), bias {r['b_new']:.2f}.
1216        New output {r['y_new']:.2f}, new loss {r['loss_new']:.4f} (was 0.25).
1217        """
1218    )
1219    takeaway("That's all training is, repeated across billions of weights and examples.")
1220
1221    banner("2. Activation functions and their gradients")
1222    zs = np.array([-4.0, -1.0, 0.0, 1.0, 4.0])
1223    rows = []
1224    for name, (f, g) in ACTIVATIONS.items():
1225        rows.append((name, *[f"{v:+.2f}/{d:.2f}" for v, d in zip(f(zs), g(zs))]))
1226    table(["act (value/grad)"] + [f"z={z:+.0f}" for z in zs], rows)
1227    say(
1228        """
1229        Sigmoid's gradient never exceeds 0.25 and is ~0.02 at |z|=4:
1230        saturated. Tanh peaks at 1 but also flattens. ReLU's gradient is
1231        exactly 1 for positives and exactly 0 for negatives: a neuron pushed
1232        permanently negative gets no gradient and "dies". GELU is a smooth
1233        ReLU that leaks a little for small negatives.
1234        """
1235    )
1236
1237    banner("3. Why nonlinearity is essential")
1238    err = linear_stack_collapses()
1239    say(f"Five stacked linear layers vs. their product as one matrix: max difference {err:.1e}.")
1240    X, y = make_moons()
1241    lin_acc = train_logistic_regression(X, y)
1242    model = MLP(2, 16)
1243    out = train(model, X, y, epochs=300)
1244    say(
1245        f"""
1246        On the two-moons data, a single linear layer (logistic regression)
1247        gets {lin_acc:.0%}: it can only draw a straight line. A 16-unit tanh
1248        hidden layer gets {model.accuracy(X, y):.2%}.
1249        """
1250    )
1251    takeaway("A chain of linear functions is one linear function. Nonlinearity is what makes depth useful.")
1252
1253    banner("4. Backprop, verified")
1254    small = MLP(2, 5, seed=1)
1255    rel = gradient_check(small, X[:20], y[:20])
1256    say(
1257        f"""
1258        Hand-written chain-rule gradients vs. finite differences (nudge each
1259        weight ±1e-5 and re-measure the loss): max relative error {rel:.1e}.
1260        Anything below ~1e-6 means the backward pass is right.
1261        """
1262    )
1263
1264    banner("5. Backprop through two layers, with numbers")
1265    t = tiny_two_layer_example()
1266    table(
1267        ["quantity", "value"],
1268        [("z1 = x·w1", t["z1"]), ("h = tanh(z1)", t["h"]), ("z2 = h·w2", t["z2"]), ("ŷ = σ(z2)", t["y_hat"]),
1269         ("dL/dz2 = ŷ − y", t["dL_dz2"]), ("dL/dw2 = h·dL/dz2", t["dL_dw2"]), ("dL/dh = dL/dz2·w2", t["dL_dh"]),
1270         ("dL/dz1 = dL/dh·(1 − h²)", t["dL_dz1"]), ("dL/dw1 = x·dL/dz1", t["dL_dw1"])],
1271    )
1272    say("The tanh slope (0.786) shrinks the blame on its way back. Fifty such layers and early weights hear almost nothing.")
1273
1274    banner("6. Batch, step, epoch")
1275    say(
1276        f"""
1277        400 examples, batch size 32: {out['steps_per_epoch']} steps per epoch.
1278        300 epochs = {out['steps']} optimizer steps. Loss fell from
1279        {out['history'][0]:.3f} after epoch 1 to {out['history'][-1]:.3f}.
1280        """
1281    )
1282    table(["epoch", "train loss"], [(e + 1, out["history"][e]) for e in (0, 9, 49, 99, 299)])
1283
1284
1285if __name__ == "__main__":
1286    demo()
Level 3: the code, function by function.
def learn_one_weight(steps: int = 3, lr: float = 0.25) -> list[float]: on GitHub
777def learn_one_weight(steps: int = 3, lr: float = 0.25) -> list[float]:
778    """Gradient descent on loss (w·1 − 3)², starting from w = 0. Returns w after each step.
779
780    The slope is 2(w − 3); with lr 0.25 each step moves w halfway to 3.
781    """
782    w, history = 0.0, [0.0]
783    for _ in range(steps):
784        slope = 2 * (w * 1.0 - 3.0)
785        w -= lr * slope
786        history.append(w)
787    return history

Gradient descent on loss (w·1 − 3)², starting from w = 0. Returns w after each step.

The slope is 2(w − 3); with lr 0.25 each step moves w halfway to 3.

def one_neuron_worked_example(lr: float = 0.01) -> dict: on GitHub
795def one_neuron_worked_example(lr: float = 0.01) -> dict:
796    """Forward pass, squared-error loss, gradients, and one SGD step for one ReLU neuron.
797
798    Inputs (2, 3), weights (0.5, 1.0), bias 0.5, target 5.
799    """
800    x = np.array([2.0, 3.0])
801    w = np.array([0.5, 1.0])
802    b = 0.5
803    target = 5.0
804
805    # Forward.
806    z = w @ x + b  # weighted sum: 2*0.5 + 3*1.0 + 0.5 = 4.5
807    y = relu(z)  # positive, so unchanged: 4.5
808    loss = (target - y) ** 2  # 0.25
809
810    # Backward, one chain-rule link at a time.
811    dL_dy = 2 * (y - target)  # d/dy (t - y)^2 = -2 (t - y) = -1.0
812    dy_dz = relu_grad(z)  # 1 because z > 0
813    dL_dz = dL_dy * dy_dz
814    dL_dw = dL_dz * x  # dz/dw_i = x_i, so the weight with the bigger input gets the bigger gradient
815    dL_db = dL_dz  # dz/db = 1
816
817    # SGD: step *against* the gradient.
818    w_new = w - lr * dL_dw
819    b_new = b - lr * dL_db
820    y_new = relu(w_new @ x + b_new)
821    return dict(
822        z=float(z),
823        y=float(y),
824        loss=float(loss),
825        dL_dw=dL_dw,
826        dL_db=float(dL_db),
827        w_new=w_new,
828        b_new=float(b_new),
829        y_new=float(y_new),
830        loss_new=float((target - y_new) ** 2),
831    )

Forward pass, squared-error loss, gradients, and one SGD step for one ReLU neuron.

Inputs (2, 3), weights (0.5, 1.0), bias 0.5, target 5.

def relu(z): on GitHub
839def relu(z):
840    return np.maximum(0.0, z)
def relu_grad(z): on GitHub
843def relu_grad(z):
844    # The derivative at exactly 0 is undefined; frameworks use 0. It never matters in practice.
845    return (np.asarray(z) > 0).astype(float)
def sigmoid(z): on GitHub
848def sigmoid(z):
849    # Split by sign so exp never overflows: for z << 0 use e^z / (1 + e^z).
850    z = np.asarray(z, dtype=float)
851    out = np.empty_like(z)
852    pos = z >= 0
853    out[pos] = 1 / (1 + np.exp(-z[pos]))
854    ez = np.exp(z[~pos])
855    out[~pos] = ez / (1 + ez)
856    return out
def sigmoid_grad(z): on GitHub
859def sigmoid_grad(z):
860    s = sigmoid(z)
861    return s * (1 - s)  # peaks at 0.25 when z = 0
def tanh(z): on GitHub
864def tanh(z):
865    return np.tanh(z)
def tanh_grad(z): on GitHub
868def tanh_grad(z):
869    return 1 - np.tanh(z) ** 2  # peaks at 1 when z = 0
def gelu(z): on GitHub
875def gelu(z):
876    """Exact GELU: z * Φ(z), where Φ is the standard normal CDF.
877
878    Intuition: scale each input by the probability that a standard normal is
879    below it. Large positives pass through, large negatives go to 0, and
880    small negatives leak a little (unlike ReLU's hard cutoff).
881    """
882    z = np.asarray(z, dtype=float)
883    return 0.5 * z * (1 + _erf(z / math.sqrt(2)))

Exact GELU: z * Φ(z), where Φ is the standard normal CDF.

Intuition: scale each input by the probability that a standard normal is below it. Large positives pass through, large negatives go to 0, and small negatives leak a little (unlike ReLU's hard cutoff).

def gelu_tanh(z): on GitHub
886def gelu_tanh(z):
887    """The tanh approximation used by GPT-2 and many kernels."""
888    z = np.asarray(z, dtype=float)
889    return 0.5 * z * (1 + np.tanh(math.sqrt(2 / math.pi) * (z + 0.044715 * z**3)))

The tanh approximation used by GPT-2 and many kernels.

def gelu_grad(z): on GitHub
892def gelu_grad(z):
893    z = np.asarray(z, dtype=float)
894    cdf = 0.5 * (1 + _erf(z / math.sqrt(2)))
895    pdf = np.exp(-0.5 * z**2) / math.sqrt(2 * math.pi)
896    return cdf + z * pdf
def softmax(z, axis: int = -1): on GitHub
899def softmax(z, axis: int = -1):
900    """Stable softmax (see primer.ml.attention.softmax for the full story)."""
901    z = np.asarray(z, dtype=float)
902    e = np.exp(z - z.max(axis=axis, keepdims=True))
903    return e / e.sum(axis=axis, keepdims=True)

Stable softmax (see primer.ml.attention.softmax for the full story).

ACTIVATIONS = {'relu': (<function relu>, <function relu_grad>), 'gelu': (<function gelu>, <function gelu_grad>), 'sigmoid': (<function sigmoid>, <function sigmoid_grad>), 'tanh': (<function tanh>, <function tanh_grad>)}
def tiny_two_layer_example() -> dict: on GitHub
914def tiny_two_layer_example() -> dict:
915    """Backprop through x → (w1, tanh) → (w2, sigmoid) → BCE with scalars you can check by hand.
916
917    x = 1, w1 = 0.5, w2 = 2, target 1, no biases.
918    """
919    x, w1, w2, y = 1.0, 0.5, 2.0, 1.0
920    z1 = x * w1
921    h = float(np.tanh(z1))
922    z2 = h * w2
923    y_hat = float(sigmoid(np.array([z2]))[0])
924    dL_dz2 = y_hat - y  # sigmoid + BCE fuse to prediction − target
925    dL_dw2 = h * dL_dz2  # the output weight's input was h
926    dL_dh = dL_dz2 * w2  # blame sent back through w2
927    dL_dz1 = dL_dh * (1 - h**2)  # shrunk by tanh's slope at z1
928    dL_dw1 = x * dL_dz1
929    return dict(z1=z1, h=h, z2=z2, y_hat=y_hat, dL_dz2=dL_dz2, dL_dw2=dL_dw2, dL_dh=dL_dh, dL_dz1=dL_dz1, dL_dw1=dL_dw1)

Backprop through x → (w1, tanh) → (w2, sigmoid) → BCE with scalars you can check by hand.

x = 1, w1 = 0.5, w2 = 2, target 1, no biases.

def linear_stack_collapses(n_layers: int = 5, d: int = 4, seed: int = 0) -> float: on GitHub
937def linear_stack_collapses(n_layers: int = 5, d: int = 4, seed: int = 0) -> float:
938    """Return max |difference| between a deep linear stack and its single-matrix equivalent.
939
940    It's ~1e-15 (floating-point noise): the deep stack *is* one linear layer.
941    """
942    rng = np.random.default_rng(seed)
943    Ws = [rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(n_layers)]
944    X = rng.standard_normal((10, d))
945    deep = X
946    for W in Ws:
947        deep = deep @ W
948    collapsed = np.linalg.multi_dot(Ws)
949    return float(np.abs(deep - X @ collapsed).max())

Return max |difference| between a deep linear stack and its single-matrix equivalent.

It's ~1e-15 (floating-point noise): the deep stack is one linear layer.

def make_moons( n: int = 400, noise: float = 0.15, seed: int = 0) -> tuple[numpy.ndarray, numpy.ndarray]: on GitHub
957def make_moons(n: int = 400, noise: float = 0.15, seed: int = 0) -> tuple[np.ndarray, np.ndarray]:
958    """Two interleaving half-circles. A straight line can't separate them."""
959    rng = np.random.default_rng(seed)
960    n1 = n // 2
961    t1 = rng.uniform(0, np.pi, n1)
962    t2 = rng.uniform(0, np.pi, n - n1)
963    a = np.c_[np.cos(t1), np.sin(t1)]
964    b = np.c_[1 - np.cos(t2), 0.5 - np.sin(t2)]
965    X = np.vstack([a, b]) + rng.normal(0, noise, (n, 2))
966    y = np.r_[np.zeros(n1), np.ones(n - n1)]
967    return X, y

Two interleaving half-circles. A straight line can't separate them.

def make_xor() -> tuple[numpy.ndarray, numpy.ndarray]: on GitHub
970def make_xor() -> tuple[np.ndarray, np.ndarray]:
971    X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
972    y = np.array([0, 1, 1, 0], dtype=float)
973    return X, y
def binary_cross_entropy(p: numpy.ndarray, y: numpy.ndarray, eps: float = 1e-12) -> float: on GitHub
981def binary_cross_entropy(p: np.ndarray, y: np.ndarray, eps: float = 1e-12) -> float:
982    """Mean BCE: -[y log p + (1-y) log(1-p)]. Clip so log(0) can't happen."""
983    p = np.clip(p, eps, 1 - eps)
984    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))

Mean BCE: -[y log p + (1-y) log(1-p)]. Clip so log(0) can't happen.

@dataclass
class MLP: on GitHub
 987@dataclass
 988class MLP:
 989    """x -> tanh(x W1 + b1) -> sigmoid(h W2 + b2). Binary classifier.
 990
 991    Every parameter lives in `params`; `forward` caches what `backward` needs.
 992    """
 993
 994    n_in: int
 995    n_hidden: int
 996    seed: int = 0
 997    params: dict[str, np.ndarray] = field(init=False)
 998
 999    def __post_init__(self):
1000        rng = np.random.default_rng(self.seed)
1001        # Xavier-style init for tanh: variance 1/fan_in keeps activations out
1002        # of the flat, saturated regions at the start (see primer.ml.deep_nets).
1003        self.params = {
1004            "W1": rng.normal(0, 1 / np.sqrt(self.n_in), (self.n_in, self.n_hidden)),
1005            "b1": np.zeros(self.n_hidden),
1006            "W2": rng.normal(0, 1 / np.sqrt(self.n_hidden), (self.n_hidden, 1)),
1007            "b2": np.zeros(1),
1008        }
1009
1010    def forward(self, X: np.ndarray) -> tuple[np.ndarray, dict]:
1011        p = self.params
1012        z1 = X @ p["W1"] + p["b1"]  # (n, hidden)
1013        h = np.tanh(z1)  # nonlinearity: without it this whole net is logistic regression
1014        z2 = h @ p["W2"] + p["b2"]  # (n, 1)
1015        yhat = sigmoid(z2)[:, 0]
1016        # Cache the intermediates: the backward pass needs X, h and yhat.
1017        return yhat, {"X": X, "h": h, "yhat": yhat}
1018
1019    def loss(self, X: np.ndarray, y: np.ndarray) -> float:
1020        return binary_cross_entropy(self.forward(X)[0], y)
1021
1022    def backward(self, cache: dict, y: np.ndarray) -> dict[str, np.ndarray]:
1023        """Chain rule, from the loss back to every parameter. Returns grads with the same keys as params."""
1024        X, h, yhat = cache["X"], cache["h"], cache["yhat"]
1025        n = X.shape[0]
1026        # Sigmoid + BCE fuse to a beautifully simple gradient: prediction minus target.
1027        # (Divide by n because the loss is a mean over the batch.)
1028        dz2 = ((yhat - y) / n)[:, None]  # (n, 1)
1029        dW2 = h.T @ dz2  # (hidden, 1): each weight's gradient = its input times the upstream error
1030        db2 = dz2.sum(axis=0)
1031        dh = dz2 @ self.params["W2"].T  # (n, hidden): send the error back through W2
1032        dz1 = dh * (1 - h**2)  # tanh'(z1) = 1 - tanh(z1)^2, reusing the cached h
1033        dW1 = X.T @ dz1
1034        db1 = dz1.sum(axis=0)
1035        return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}
1036
1037    def accuracy(self, X: np.ndarray, y: np.ndarray) -> float:
1038        return float(np.mean((self.forward(X)[0] > 0.5) == y))

x -> tanh(x W1 + b1) -> sigmoid(h W2 + b2). Binary classifier.

Every parameter lives in params; forward caches what backward needs.

MLP(n_in: int, n_hidden: int, seed: int = 0)
n_in: int
n_hidden: int
seed: int = 0
params: dict[str, numpy.ndarray]
def forward(self, X: numpy.ndarray) -> tuple[numpy.ndarray, dict]: on GitHub
1010    def forward(self, X: np.ndarray) -> tuple[np.ndarray, dict]:
1011        p = self.params
1012        z1 = X @ p["W1"] + p["b1"]  # (n, hidden)
1013        h = np.tanh(z1)  # nonlinearity: without it this whole net is logistic regression
1014        z2 = h @ p["W2"] + p["b2"]  # (n, 1)
1015        yhat = sigmoid(z2)[:, 0]
1016        # Cache the intermediates: the backward pass needs X, h and yhat.
1017        return yhat, {"X": X, "h": h, "yhat": yhat}
def loss(self, X: numpy.ndarray, y: numpy.ndarray) -> float: on GitHub
1019    def loss(self, X: np.ndarray, y: np.ndarray) -> float:
1020        return binary_cross_entropy(self.forward(X)[0], y)
def backward(self, cache: dict, y: numpy.ndarray) -> dict[str, numpy.ndarray]: on GitHub
1022    def backward(self, cache: dict, y: np.ndarray) -> dict[str, np.ndarray]:
1023        """Chain rule, from the loss back to every parameter. Returns grads with the same keys as params."""
1024        X, h, yhat = cache["X"], cache["h"], cache["yhat"]
1025        n = X.shape[0]
1026        # Sigmoid + BCE fuse to a beautifully simple gradient: prediction minus target.
1027        # (Divide by n because the loss is a mean over the batch.)
1028        dz2 = ((yhat - y) / n)[:, None]  # (n, 1)
1029        dW2 = h.T @ dz2  # (hidden, 1): each weight's gradient = its input times the upstream error
1030        db2 = dz2.sum(axis=0)
1031        dh = dz2 @ self.params["W2"].T  # (n, hidden): send the error back through W2
1032        dz1 = dh * (1 - h**2)  # tanh'(z1) = 1 - tanh(z1)^2, reusing the cached h
1033        dW1 = X.T @ dz1
1034        db1 = dz1.sum(axis=0)
1035        return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}

Chain rule, from the loss back to every parameter. Returns grads with the same keys as params.

def accuracy(self, X: numpy.ndarray, y: numpy.ndarray) -> float: on GitHub
1037    def accuracy(self, X: np.ndarray, y: np.ndarray) -> float:
1038        return float(np.mean((self.forward(X)[0] > 0.5) == y))
def numerical_gradient( model: MLP, X: numpy.ndarray, y: numpy.ndarray, eps: float = 1e-05) -> dict[str, numpy.ndarray]: on GitHub
1041def numerical_gradient(model: MLP, X: np.ndarray, y: np.ndarray, eps: float = 1e-5) -> dict[str, np.ndarray]:
1042    """Central-difference gradient: (L(w+ε) - L(w-ε)) / 2ε for every weight.
1043
1044    Slow (two forward passes per parameter) but obviously correct, which is
1045    what makes it the standard way to test a hand-written backward pass.
1046    """
1047    grads = {}
1048    for name, P in model.params.items():
1049        g = np.zeros_like(P)
1050        for idx in np.ndindex(P.shape):
1051            old = P[idx]
1052            P[idx] = old + eps
1053            lp = model.loss(X, y)
1054            P[idx] = old - eps
1055            lm = model.loss(X, y)
1056            P[idx] = old
1057            g[idx] = (lp - lm) / (2 * eps)
1058        grads[name] = g
1059    return grads

Central-difference gradient: (L(w+ε) - L(w-ε)) / 2ε for every weight.

Slow (two forward passes per parameter) but obviously correct, which is what makes it the standard way to test a hand-written backward pass.

def gradient_check( model: MLP, X: numpy.ndarray, y: numpy.ndarray) -> float: on GitHub
1062def gradient_check(model: MLP, X: np.ndarray, y: np.ndarray) -> float:
1063    """Max relative error between analytic and numerical gradients (expect < 1e-6)."""
1064    _, cache = model.forward(X)
1065    analytic = model.backward(cache, y)
1066    numeric = numerical_gradient(model, X, y)
1067    worst = 0.0
1068    for k in analytic:
1069        a, n = analytic[k], numeric[k]
1070        rel = np.abs(a - n) / np.maximum(1e-8, np.abs(a) + np.abs(n))
1071        worst = max(worst, float(rel.max()))
1072    return worst

Max relative error between analytic and numerical gradients (expect < 1e-6).

def train( model: MLP, X: numpy.ndarray, y: numpy.ndarray, epochs: int = 200, batch_size: int = 32, lr: float = 0.5, seed: int = 0) -> dict: on GitHub
1080def train(
1081    model: MLP,
1082    X: np.ndarray,
1083    y: np.ndarray,
1084    epochs: int = 200,
1085    batch_size: int = 32,
1086    lr: float = 0.5,
1087    seed: int = 0,
1088) -> dict:
1089    """Mini-batch SGD. Returns the loss history and step/epoch counts."""
1090    rng = np.random.default_rng(seed)
1091    n = len(X)
1092    steps = 0
1093    history = []
1094    for _epoch in range(epochs):  # one epoch = one full pass over the data
1095        order = rng.permutation(n)  # reshuffle each epoch so batches differ
1096        for start in range(0, n, batch_size):  # one batch -> one step
1097            idx = order[start : start + batch_size]
1098            _, cache = model.forward(X[idx])  # 1. forward
1099            grads = model.backward(cache, y[idx])  # 2-3. loss gradient via backprop
1100            for k in model.params:  # 4. optimizer step (plain SGD)
1101                model.params[k] -= lr * grads[k]
1102            steps += 1
1103        history.append(model.loss(X, y))
1104    return dict(history=history, steps=steps, steps_per_epoch=math.ceil(n / batch_size))

Mini-batch SGD. Returns the loss history and step/epoch counts.

def train_logistic_regression( X: numpy.ndarray, y: numpy.ndarray, epochs: int = 300, lr: float = 0.5) -> float: on GitHub
1107def train_logistic_regression(X: np.ndarray, y: np.ndarray, epochs: int = 300, lr: float = 0.5) -> float:
1108    """A single linear layer + sigmoid: the no-hidden-layer baseline. Returns accuracy."""
1109    w, b = np.zeros(X.shape[1]), 0.0
1110    for _ in range(epochs):
1111        p = sigmoid(X @ w + b)
1112        g = (p - y) / len(y)
1113        w -= lr * X.T @ g
1114        b -= lr * g.sum()
1115    return float(np.mean((sigmoid(X @ w + b) > 0.5) == y))

A single linear layer + sigmoid: the no-hidden-layer baseline. Returns accuracy.

def figures() -> dict: on GitHub
1123def figures() -> dict:
1124    """Plots computed from this module's own functions."""
1125    import matplotlib
1126
1127    matplotlib.use("Agg")
1128    import matplotlib.pyplot as plt
1129
1130    figs = {}
1131
1132    z = np.linspace(-5, 5, 400)
1133    fig, (a1, a2) = plt.subplots(1, 2, figsize=(10, 3.6))
1134    for name, (f, g) in ACTIVATIONS.items():
1135        a1.plot(z, f(z), label=name)
1136        a2.plot(z, g(z), label=name)
1137    a1.set(xlabel="input z", ylabel="output φ(z)", title="Activation functions", ylim=(-1.5, 3))
1138    a2.set(xlabel="input z", ylabel="derivative φ'(z)", title="Their derivatives (gradient that flows back)")
1139    for a in (a1, a2):
1140        a.axhline(0, color="gray", lw=0.5)
1141        a.grid(alpha=0.3)
1142        a.legend(fontsize=8)
1143    fig.tight_layout()
1144    figs["activations"] = fig
1145
1146    X, y = make_moons()
1147    # Logistic regression, re-fit here so we can evaluate it on a grid.
1148    w, b = np.zeros(2), 0.0
1149    for _ in range(300):
1150        p = sigmoid(X @ w + b)
1151        g = (p - y) / len(y)
1152        w -= 0.5 * X.T @ g
1153        b -= 0.5 * g.sum()
1154    mlp = MLP(2, 16)
1155    train(mlp, X, y, epochs=300)
1156    xx, yy = np.meshgrid(np.linspace(-1.5, 2.5, 200), np.linspace(-1.0, 1.5, 150))
1157    grid = np.c_[xx.ravel(), yy.ravel()]
1158    fig, axes = plt.subplots(1, 2, figsize=(10, 3.8), sharey=True)
1159    for ax, pred, title in (
1160        (axes[0], sigmoid(grid @ w + b), f"Linear (logistic regression): {train_logistic_regression(X, y):.0%}"),
1161        (axes[1], mlp.forward(grid)[0], f"MLP, 16 tanh hidden units: {mlp.accuracy(X, y):.2%}"),
1162    ):
1163        ax.contourf(xx, yy, pred.reshape(xx.shape), levels=[0, 0.5, 1], colors=["#cfe0f3", "#f6d5c8"])
1164        ax.scatter(X[y == 0, 0], X[y == 0, 1], s=8, color="C0", label="class 0")
1165        ax.scatter(X[y == 1, 0], X[y == 1, 1], s=8, color="C3", label="class 1")
1166        ax.set(title=title, xlabel="x1")
1167    axes[0].set_ylabel("x2")
1168    axes[0].legend(fontsize=8, loc="lower left")
1169    fig.tight_layout()
1170    figs["decision_boundaries"] = fig
1171
1172    hist = train(MLP(2, 16), X, y, epochs=300)["history"]
1173    fig, ax = plt.subplots(figsize=(6, 3.4))
1174    ax.plot(np.arange(1, len(hist) + 1), hist)
1175    ax.set(xscale="log", xlabel="epoch (log scale)", ylabel="training loss (BCE)", title="Training curve on two moons")
1176    ax.grid(alpha=0.3)
1177    fig.tight_layout()
1178    figs["training_curve"] = fig
1179    return figs

Plots computed from this module's own functions.

def demo() -> None: on GitHub
1187def demo() -> None:
1188    banner("0. One knob: gradient descent on a single weight")
1189    ws = learn_one_weight(steps=5)
1190    table(["step", "w", "slope 2(w − 3)"], [(i, w, 2 * (w - 3)) for i, w in enumerate(ws)], floatfmt=".4f")
1191    say("Each step moves w halfway to 3, because the slope shrinks as the error shrinks.")
1192    takeaway("Training is: measure how wrong you are, find which way is downhill, take a small step. Repeat.")
1193
1194    banner("1. One neuron, by hand")
1195    r = one_neuron_worked_example()
1196    say(
1197        f"""
1198        Inputs (2, 3), weights (0.5, 1.0), bias 0.5. Weighted sum
1199        2×0.5 + 3×1.0 + 0.5 = {r['z']}. ReLU keeps it: output {r['y']}.
1200        Target 5, so squared error = (5 − 4.5)² = {r['loss']}.
1201        """
1202    )
1203    table(
1204        ["quantity", "value", "why"],
1205        [
1206            ("dL/dy", 2 * (r["y"] - 5), "2(y − target): negative, so output is too low"),
1207            ("dL/dw1", r["dL_dw"][0], "error × input 2"),
1208            ("dL/dw2", r["dL_dw"][1], "error × input 3: the most influential weight"),
1209            ("dL/db", r["dL_db"], "error × 1"),
1210        ],
1211        floatfmt=".3f",
1212    )
1213    say(
1214        f"""
1215        One SGD step with learning rate 0.01 moves against the gradient:
1216        weights become ({r['w_new'][0]:.2f}, {r['w_new'][1]:.2f}), bias {r['b_new']:.2f}.
1217        New output {r['y_new']:.2f}, new loss {r['loss_new']:.4f} (was 0.25).
1218        """
1219    )
1220    takeaway("That's all training is, repeated across billions of weights and examples.")
1221
1222    banner("2. Activation functions and their gradients")
1223    zs = np.array([-4.0, -1.0, 0.0, 1.0, 4.0])
1224    rows = []
1225    for name, (f, g) in ACTIVATIONS.items():
1226        rows.append((name, *[f"{v:+.2f}/{d:.2f}" for v, d in zip(f(zs), g(zs))]))
1227    table(["act (value/grad)"] + [f"z={z:+.0f}" for z in zs], rows)
1228    say(
1229        """
1230        Sigmoid's gradient never exceeds 0.25 and is ~0.02 at |z|=4:
1231        saturated. Tanh peaks at 1 but also flattens. ReLU's gradient is
1232        exactly 1 for positives and exactly 0 for negatives: a neuron pushed
1233        permanently negative gets no gradient and "dies". GELU is a smooth
1234        ReLU that leaks a little for small negatives.
1235        """
1236    )
1237
1238    banner("3. Why nonlinearity is essential")
1239    err = linear_stack_collapses()
1240    say(f"Five stacked linear layers vs. their product as one matrix: max difference {err:.1e}.")
1241    X, y = make_moons()
1242    lin_acc = train_logistic_regression(X, y)
1243    model = MLP(2, 16)
1244    out = train(model, X, y, epochs=300)
1245    say(
1246        f"""
1247        On the two-moons data, a single linear layer (logistic regression)
1248        gets {lin_acc:.0%}: it can only draw a straight line. A 16-unit tanh
1249        hidden layer gets {model.accuracy(X, y):.2%}.
1250        """
1251    )
1252    takeaway("A chain of linear functions is one linear function. Nonlinearity is what makes depth useful.")
1253
1254    banner("4. Backprop, verified")
1255    small = MLP(2, 5, seed=1)
1256    rel = gradient_check(small, X[:20], y[:20])
1257    say(
1258        f"""
1259        Hand-written chain-rule gradients vs. finite differences (nudge each
1260        weight ±1e-5 and re-measure the loss): max relative error {rel:.1e}.
1261        Anything below ~1e-6 means the backward pass is right.
1262        """
1263    )
1264
1265    banner("5. Backprop through two layers, with numbers")
1266    t = tiny_two_layer_example()
1267    table(
1268        ["quantity", "value"],
1269        [("z1 = x·w1", t["z1"]), ("h = tanh(z1)", t["h"]), ("z2 = h·w2", t["z2"]), ("ŷ = σ(z2)", t["y_hat"]),
1270         ("dL/dz2 = ŷ − y", t["dL_dz2"]), ("dL/dw2 = h·dL/dz2", t["dL_dw2"]), ("dL/dh = dL/dz2·w2", t["dL_dh"]),
1271         ("dL/dz1 = dL/dh·(1 − h²)", t["dL_dz1"]), ("dL/dw1 = x·dL/dz1", t["dL_dw1"])],
1272    )
1273    say("The tanh slope (0.786) shrinks the blame on its way back. Fifty such layers and early weights hear almost nothing.")
1274
1275    banner("6. Batch, step, epoch")
1276    say(
1277        f"""
1278        400 examples, batch size 32: {out['steps_per_epoch']} steps per epoch.
1279        300 epochs = {out['steps']} optimizer steps. Loss fell from
1280        {out['history'][0]:.3f} after epoch 1 to {out['history'][-1]:.3f}.
1281        """
1282    )
1283    table(["epoch", "train loss"], [(e + 1, out["history"][e]) for e in (0, 9, 49, 99, 299)])