Everything a neural net does is a derivative away.
Nine short chapters covering the math that powers every architecture — LeNet, AlexNet, transformers, all of them. Each concept comes with a worked example small enough to compute on paper, a from-scratch implementation, the PyTorch one-liner it corresponds to, and exercises with hidden solutions.
How to use this workbook
The learning loop, and the one rule that makes it work.
The chapters are ordered so that each one uses only what came before it. The dependency chain is:
derivatives → partials/gradients → chain rule → linear layers → losses → gradient descent → backprop → activations → training loop
Backpropagation (Ch. 07) is the payoff: it is nothing but chapters 1–6 glued together. If backprop ever feels like magic, the fix is always to revisit the chain rule.
The rule
For each concept you'll see
- What / Why / Where — definition, purpose, and where it appears in a real network
- A worked example on graph paper — every number shown
- Code twice — from scratch (plain Python/tensors) first, then the PyTorch API that hides it
- Exercises — with solutions collapsed until you want them
Derivatives
Goal: given a function, compute how fast its output changes when the input nudges.
- What
- The derivative \(f'(x)\) is the slope of \(f\) at the point \(x\): how much the output changes per tiny change of input.
- Why
- Training asks one question millions of times: “if I nudge this weight, does the loss go up or down, and how steeply?” That question is a derivative.
- Where
- Inside
loss.backward()— it computes the derivative of the loss with respect to every parameter.
Definition, in numbers first
Slope = rise over run, measured over a shrinking run \(h\):
$$ f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} $$
Take \(f(x) = x^2\) at \(x = 3\). Use a small run, \(h = 0.001\):
$$ \frac{f(3.001) - f(3)}{0.001} = \frac{9.006001 - 9}{0.001} = 6.001 $$
The rules below say \(f'(x) = 2x\), so \(f'(3) = 6\). The numeric estimate \(6.001\) agrees — the leftover \(0.001\) shrinks as \(h\) shrinks. This numeric check works for any derivative and is how you should verify every exercise in this book.
The five rules you actually need
Deep learning uses a tiny subset of calculus. These cover ~95% of everything:
| Rule | Function | Derivative |
|---|---|---|
| Power | \(x^n\) | \(n\,x^{n-1}\) |
| Constant multiple | \(c \cdot f(x)\) | \(c \cdot f'(x)\) |
| Sum | \(f(x) + g(x)\) | \(f'(x) + g'(x)\) |
| Exponential | \(e^{x}\) | \(e^{x}\) |
| Log | \(\ln x\) | \(1/x\) |
(The sixth, the chain rule, is so important it gets its own chapter.)
from scratchdef numeric_derivative(f, x, h=1e-5):
"""Estimate f'(x) with a tiny rise-over-run. Your universal answer-checker."""
return (f(x + h) - f(x)) / h
f = lambda x: x**2
print(numeric_derivative(f, 3.0)) # ~6.00001
pytorch
import torch
x = torch.tensor(3.0, requires_grad=True) # track operations on x
y = x**2
y.backward() # compute dy/dx and store it in x.grad
print(x.grad) # tensor(6.)
Compute \(f'(2)\) for \(f(x) = 3x^2 + 2x\) using the rules table. Then verify with numeric_derivative.
Solution
Sum rule splits it; power rule handles each part: \(f'(x) = 3 \cdot 2x + 2 = 6x + 2\).
At \(x = 2\): \(f'(2) = 12 + 2 = \mathbf{14}\).
Check: \(\dfrac{f(2.001) - f(2)}{0.001} = \dfrac{16.014003 - 16}{0.001} \approx 14.003\) ✓
Compute \(f'(2)\) for \(f(x) = \dfrac{1}{x}\). Hint: rewrite as a power, \(x^{-1}\).
Solution
Power rule with \(n = -1\): \(f'(x) = -1 \cdot x^{-2} = -\dfrac{1}{x^2}\).
At \(x = 2\): \(f'(2) = -\dfrac{1}{4} = \mathbf{-0.25}\). Negative slope: increasing \(x\) decreases \(1/x\), which matches intuition.
Partial derivatives & gradients
Goal: handle functions of many inputs — because a loss depends on many weights.
- What
- A partial derivative \(\frac{\partial f}{\partial w_1}\) is an ordinary derivative taken while pretending every other variable is a constant. The gradient \(\nabla f\) is the vector collecting all the partials.
- Why
- A loss \(L(w_1, w_2, \dots, w_{61706})\) depends on every weight in LeNet. The gradient answers “which direction in weight space increases \(L\) fastest?” — so we step the opposite way.
- Where
param.gradafterloss.backward()— one partial derivative per entry of every weight tensor.
Let \(f(w_1, w_2) = w_1^2 + 3 w_1 w_2\). Two partials:
$$ \frac{\partial f}{\partial w_1} = 2w_1 + 3w_2 \qquad\text{(treat } w_2 \text{ as a constant)} $$
$$ \frac{\partial f}{\partial w_2} = 3w_1 \qquad\text{(treat } w_1 \text{ as a constant; } w_1^2 \text{ vanishes)} $$
At the point \((w_1, w_2) = (2, 1)\):
$$ \nabla f(2,1) = \begin{bmatrix} 2(2) + 3(1) \\ 3(2) \end{bmatrix} = \begin{bmatrix} 7 \\ 6 \end{bmatrix} $$
Reading: near this point, nudging \(w_1\) up by a tiny \(\epsilon\) raises \(f\) by about \(7\epsilon\); nudging \(w_2\) raises it by about \(6\epsilon\). So \(w_1\) currently matters slightly more.
def numeric_partial(f, point, i, h=1e-5):
"""Partial derivative of f w.r.t. variable i: nudge only that one."""
nudged = list(point)
nudged[i] += h
return (f(*nudged) - f(*point)) / h
f = lambda w1, w2: w1**2 + 3*w1*w2
print(numeric_partial(f, (2.0, 1.0), 0)) # ~7.0 (d f / d w1)
print(numeric_partial(f, (2.0, 1.0), 1)) # ~6.0 (d f / d w2)
pytorch
w = torch.tensor([2.0, 1.0], requires_grad=True)
f = w[0]**2 + 3*w[0]*w[1]
f.backward()
print(w.grad) # tensor([7., 6.]) — the gradient vector, all partials at once
For \(f(w_1, w_2) = w_1^2 w_2 + w_2^2\), compute \(\nabla f\) at \((1, 2)\).
Solution
\(\dfrac{\partial f}{\partial w_1} = 2 w_1 w_2 = 2(1)(2) = \mathbf{4}\)
\(\dfrac{\partial f}{\partial w_2} = w_1^2 + 2 w_2 = 1 + 4 = \mathbf{5}\)
So \(\nabla f(1,2) = [4, 5]\).
A loss is \(L(w, b) = (w \cdot 2 + b - 6)^2\) — a model \(y = wx + b\) with input \(x=2\) and target \(6\). Expand it, then compute \(\nabla L\) at \((w, b) = (1, 1)\). (If you know the chain rule already, that's faster — otherwise expand the square.)
Solution
Let \(e = 2w + b - 6\). Expanding: \(L = 4w^2 + b^2 + 36 + 4wb - 24w - 12b\).
\(\dfrac{\partial L}{\partial w} = 8w + 4b - 24\). At \((1,1)\): \(8 + 4 - 24 = \mathbf{-12}\)
\(\dfrac{\partial L}{\partial b} = 2b + 4w - 12\). At \((1,1)\): \(2 + 4 - 12 = \mathbf{-6}\)
Sanity check: at \((1,1)\) the prediction is \(2 + 1 = 3\), which is below the target 6. Both partials are negative — “increase \(w\) and \(b\) to reduce loss” — exactly right. Note both equal \(2e \cdot x\) and \(2e \cdot 1\) with \(e = -3\): the chain-rule shortcut of the next chapter.
The chain rule
Goal: differentiate compositions — because a network is a composition of layers.
- What
- If \(y = f(g(x))\), then \(\dfrac{dy}{dx} = f'(g(x)) \cdot g'(x)\): the derivative of the outer function times the derivative of the inner one.
- Why
- A network is \( \text{loss}(\text{layer}_3(\text{layer}_2(\text{layer}_1(x)))) \). To learn how a weight buried in layer 1 affects the loss, you multiply the local slopes along the path connecting them. That multiplication is backpropagation.
- Where
- Every step of
loss.backward(). Also explains vanishing gradients: multiply many slopes \(< 1\) and the product dies.
\(y = (2x + 1)^2\) at \(x = 1\). Name the inner piece: \(u = 2x + 1\), so \(y = u^2\).
Two local slopes:
$$ \frac{dy}{du} = 2u \qquad \frac{du}{dx} = 2 $$
Chain them (at \(x=1\), \(u = 3\)):
$$ \frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx} = 2(3) \cdot 2 = \mathbf{12} $$
Numeric check: \(\dfrac{(2 \cdot 1.001 + 1)^2 - 9}{0.001} = \dfrac{9.012004 - 9}{0.001} \approx 12.004\) ✓
Intuition: a nudge to \(x\) gets amplified ×2 passing through \(u\), then ×6 passing through the square. Total amplification: 12. Signals flowing backward through a network are amplified/shrunk the same way, layer by layer.
Longer chains: just keep multiplying
Three functions deep: \(y = f(g(h(x)))\)
$$ \frac{dy}{dx} = \frac{dy}{dg} \cdot \frac{dg}{dh} \cdot \frac{dh}{dx} $$
Each factor is a local derivative that one layer can compute knowing nothing about the rest. That locality is why backprop is mechanical enough for a library to automate.
from scratch vs pytorchimport torch
# By hand: y = (2x+1)^2, dy/dx = 2*(2x+1) * 2
x_val = 1.0
manual = 2 * (2*x_val + 1) * 2 # 12.0
# Autograd does the same multiplication internally
x = torch.tensor(x_val, requires_grad=True)
y = (2*x + 1)**2
y.backward()
print(manual, x.grad) # 12.0 tensor(12.)
The single most important derivative in ML: \(L = (wx - t)^2\) — squared error of a linear model. With \(x = 2\), \(t = 6\), \(w = 1\), compute \(\dfrac{dL}{dw}\) using the chain rule (inner: \(e = wx - t\); outer: \(L = e^2\)).
Solution
Local slopes: \(\dfrac{dL}{de} = 2e\) and \(\dfrac{de}{dw} = x\).
At the point: \(e = 1(2) - 6 = -4\).
$$ \frac{dL}{dw} = 2e \cdot x = 2(-4)(2) = \mathbf{-16} $$
Negative gradient → increase \(w\) to reduce the loss. Correct: prediction 2 is far below target 6.
The sigmoid \(\sigma(z) = \dfrac{1}{1+e^{-z}}\) has derivative \(\sigma'(z) = \sigma(z)(1 - \sigma(z))\). For \(y = \sigma(3x)\), compute \(\dfrac{dy}{dx}\) at \(x = 0\).
Solution
Inner: \(u = 3x\), \(\dfrac{du}{dx} = 3\). At \(x=0\): \(u = 0\), \(\sigma(0) = 0.5\).
$$ \frac{dy}{dx} = \sigma'(0) \cdot 3 = (0.5)(0.5) \cdot 3 = \mathbf{0.75} $$
Note \(\sigma'\) can never exceed \(0.25\) — chain several sigmoids and the product shrinks fast. You've just derived vanishing gradients.
Linear layers
Goal: read \(y = Wx + b\) as arithmetic you can do by hand, and count its parameters.
- What
- A linear layer computes weighted sums: each output is a dot product of the input with that output's own weight row, plus a bias.
- Why
- It is the only learnable computation in most networks. Convolutions, attention, fully-connected layers — all are structured variants of “multiply by weights and sum.”
- Where
nn.Linear,nn.Conv2d(a linear layer with weight sharing), theX @ W + bin your softmax recipe.
Dot product first
$$ a \cdot b = \sum_i a_i b_i \qquad e.g.\;\; [1, 2, 3] \cdot [4, 0, 2] = 4 + 0 + 6 = 10 $$
One number that measures “how strongly does the input match this weight pattern?” — the edge-detector calculation from convolution is exactly this.
A layer with 3 inputs and 2 outputs. Weights \(W\) (one row per output), bias \(b\), input \(x\):
$$ W = \begin{bmatrix} 1 & 0 & 2 \\ -1 & 1 & 0 \end{bmatrix} \quad b = \begin{bmatrix} 1 \\ 0 \end{bmatrix} \quad x = \begin{bmatrix} 2 \\ 3 \\ 1 \end{bmatrix} $$
Each output = (its weight row) · (input) + (its bias):
$$ y_1 = (1)(2) + (0)(3) + (2)(1) + 1 = \mathbf{5} $$
$$ y_2 = (-1)(2) + (1)(3) + (0)(1) + 0 = \mathbf{1} $$
So \(y = Wx + b = [5, 1]\). That's all matrix multiplication is: stacked dot products.
Parameter count: \(W\) has \(2 \times 3 = 6\) weights, \(b\) has \(2\) → \(8\) parameters. In general: \(n_{out} \times n_{in} + n_{out}\).
X @ W with \(W\) shaped \((n_{in} \times n_{out})\), like your recipe does.def linear(W, b, x):
"""y_i = dot(W[i], x) + b[i], written as explicit loops."""
n_out, n_in = len(W), len(x)
y = [0.0] * n_out
for i in range(n_out): # one output at a time
for j in range(n_in): # accumulate the dot product
y[i] += W[i][j] * x[j]
y[i] += b[i]
return y
W = [[1, 0, 2], [-1, 1, 0]]
b = [1, 0]
x = [2, 3, 1]
print(linear(W, b, x)) # [5.0, 1.0]
pytorch
import torch
from torch import nn
layer = nn.Linear(3, 2) # allocates W (2x3) and b (2), random init
with torch.no_grad(): # setting weights manually, skip autograd
layer.weight[:] = torch.tensor([[1., 0., 2.], [-1., 1., 0.]])
layer.bias[:] = torch.tensor([1., 0.])
x = torch.tensor([2., 3., 1.])
print(layer(x)) # tensor([5., 1.])
With \(W = \begin{bmatrix} 2 & -1 \\ 0 & 3 \end{bmatrix}\), \(b = \begin{bmatrix} 1 \\ -2 \end{bmatrix}\), \(x = \begin{bmatrix} 4 \\ 2 \end{bmatrix}\), compute \(y = Wx + b\) by hand.
Solution
\(y_1 = 2(4) + (-1)(2) + 1 = 8 - 2 + 1 = \mathbf{7}\)
\(y_2 = 0(4) + 3(2) - 2 = 6 - 2 = \mathbf{4}\)
\(y = [7, 4]\)
How many parameters in the MLP 784 → 256 → 10 (two linear layers, biases included)? Then: LeNet's conv1 has 6 kernels of shape \((1, 5, 5)\) plus one bias each — how many parameters, and why is it so much cheaper than a dense layer over the same image?
Solution
Layer 1: \(256 \times 784 + 256 = 200{,}960\). Layer 2: \(10 \times 256 + 10 = 2{,}570\). Total \(\mathbf{203{,}530}\).
Conv1: \(6 \times (1 \cdot 5 \cdot 5) + 6 = \mathbf{156}\). It's cheap because the same 25 weights slide across all \(28 \times 28\) positions (weight sharing) instead of every pixel getting its own weight.
Softmax & loss functions
Goal: turn raw scores into probabilities, then into a single number measuring “how wrong.”
- What
- Softmax converts a vector of raw scores (logits) into probabilities that sum to 1. Cross-entropy then scores them: the negative log of the probability assigned to the true class. For regression, MSE \((\hat{y} - t)^2\) plays the same role.
- Why
- Gradient descent needs one scalar to minimize. The loss compresses “how good are all these predictions” into that scalar — and its shape determines what gradients flow back.
- Where
- Your recipe's
softmax()+cross_entropy(); PyTorch'snn.CrossEntropyLossfuses both (it takes raw logits).
Logits for 3 classes: \(o = [2, 0, 1]\).
$$ \text{softmax}(o_i) = \frac{e^{o_i}}{\sum_j e^{o_j}} $$
Step 1 — exponentiate: \(e^2 \approx 7.39,\;\; e^0 = 1,\;\; e^1 \approx 2.72\)
Step 2 — sum: \(7.39 + 1 + 2.72 = 11.11\)
Step 3 — normalize: $$ \hat{y} = \left[ \tfrac{7.39}{11.11}, \tfrac{1}{11.11}, \tfrac{2.72}{11.11} \right] = [0.665,\; 0.090,\; 0.245] $$
Checks: all positive, sum = 1, and the biggest logit got the biggest probability (order preserved — that's why argmax on logits equals argmax on probabilities).
Suppose the true class is class 0. Cross-entropy only looks at the probability given to the truth:
$$ L = -\ln(\hat{y}_{\text{true}}) = -\ln(0.665) \approx \mathbf{0.408} $$
Why \(-\ln\)? It converts “confidence in the truth” into “pain,” with the right shape:
| \(\hat{y}_{\text{true}}\) | 1.0 | 0.9 | 0.5 | 0.1 | 0.01 |
|---|---|---|---|---|---|
| \(L = -\ln(\cdot)\) | 0 | 0.105 | 0.693 | 2.303 | 4.605 |
Being confidently wrong (0.01) hurts ~44× more than being mildly unsure (0.9). That asymmetry produces large gradients exactly where the model most needs correcting.
import torch
def softmax(O):
O_max = O.max(dim=1, keepdim=True).values # subtract max: avoids exp() overflow,
exp_O = torch.exp(O - O_max) # doesn't change the result (why? ex 5.2)
return exp_O / exp_O.sum(dim=1, keepdim=True)
def cross_entropy(y_hat, y):
picked = y_hat[range(len(y_hat)), y] # prob assigned to each true class
return (-torch.log(picked)).mean()
O = torch.tensor([[2.0, 0.0, 1.0]])
y = torch.tensor([0])
print(softmax(O)) # [[0.665, 0.090, 0.245]]
print(cross_entropy(softmax(O), y)) # 0.408
pytorch
loss_fn = torch.nn.CrossEntropyLoss() # = softmax + cross-entropy fused
print(loss_fn(O, y)) # 0.408 — note: takes RAW logits, not probabilities
Logits \(o = [1, 1]\) for 2 classes, true class = 1. Compute the softmax and the cross-entropy loss by hand. (Useful constant: \(\ln 2 \approx 0.693\).)
Solution
Equal logits → equal probabilities: \(\hat{y} = \left[\tfrac{e}{2e}, \tfrac{e}{2e}\right] = [0.5, 0.5]\).
\(L = -\ln(0.5) = \ln 2 \approx \mathbf{0.693}\).
This is the loss of pure guessing between 2 classes. For 10 classes it's \(\ln 10 \approx 2.30\) — which is why an untrained FashionMNIST model starts near loss 2.3. If your loss starts much higher, your init is broken.
Show with \(o = [2, 0, 1]\) that subtracting the max logit from every logit (i.e. using \([0, -2, -1]\)) gives the same softmax. Why does this matter for \(o = [1000, 999]\)?
Solution
\(e^0 = 1,\; e^{-2} \approx 0.135,\; e^{-1} \approx 0.368\); sum \(\approx 1.503\).
\(\hat{y} = [0.665, 0.090, 0.245]\) — identical, because \(\dfrac{e^{o_i - m}}{\sum_j e^{o_j - m}} = \dfrac{e^{o_i} e^{-m}}{e^{-m} \sum_j e^{o_j}}\): the \(e^{-m}\) cancels.
For \([1000, 999]\): \(e^{1000}\) overflows to inf in float32. After subtracting the max: \(e^0, e^{-1}\) — perfectly safe, same answer. This is exactly the O_max line in your recipe.
Gradient descent
Goal: use the gradient to actually improve the weights — and see convergence and divergence happen by hand.
- What
- Repeated updates \(w \leftarrow w - \eta \, \dfrac{\partial L}{\partial w}\): step every weight a little bit against its gradient. \(\eta\) is the learning rate.
- Why
- The gradient points uphill on the loss surface; stepping downhill reduces the loss. Small enough steps, repeated, find a valley.
- Where
- Your recipe's
sgd():param -= lr * param.grad. Same update insidetorch.optim.SGD; Adam and friends are variations on the step size.
Loss \(L(w) = (2w - 6)^2\). Chain rule: \(\dfrac{dL}{dw} = 2(2w-6)\cdot 2 = 8w - 24\). Minimum at \(w = 3\) where the gradient is 0. Start at \(w_0 = 0\), learning rate \(\eta = 0.1\):
| step | \(w\) | grad \(= 8w - 24\) | update \(w - 0.1 \cdot \text{grad}\) | \(L\) |
|---|---|---|---|---|
| 0 | 0 | −24 | \(0 + 2.4 = 2.4\) | 36 |
| 1 | 2.4 | −4.8 | \(2.4 + 0.48 = 2.88\) | 1.44 |
| 2 | 2.88 | −0.96 | \(2.88 + 0.096 = 2.976\) | 0.058 |
| 3 | 2.976 | −0.192 | \(2.9952\) | 0.002 |
Each step covers 80% of the remaining distance to \(w=3\). Steps shrink automatically because the gradient itself shrinks near the minimum — no schedule needed for this simple bowl.
Same problem, learning rate \(\eta = 0.3\), start \(w_0 = 0\):
| step | \(w\) | grad | next \(w\) |
|---|---|---|---|
| 0 | 0 | −24 | \(0 + 7.2 = 7.2\) |
| 1 | 7.2 | 33.6 | \(7.2 - 10.08 = -2.88\) |
| 2 | −2.88 | −47.04 | \(11.23\) |
Each step overshoots the minimum and lands farther away, on the opposite wall of the bowl. The loss explodes. When training loss goes to NaN, this is usually what happened.
w, lr = 0.0, 0.1
for step in range(6):
grad = 8*w - 24 # dL/dw, derived by hand above
w = w - lr * grad # THE update. All of deep learning training is this line.
print(f"step {step}: w={w:.4f} loss={(2*w - 6)**2:.5f}")
# w -> 3.0, loss -> 0. Try lr = 0.3 to reproduce the divergence table.
Loss \(L(w) = w^2 - 4w\) (minimum at \(w = 2\)). Starting from \(w_0 = 5\) with \(\eta = 0.25\), compute two update steps by hand.
Solution
\(\dfrac{dL}{dw} = 2w - 4\).
Step 1: grad \(= 2(5) - 4 = 6\); \(\;w_1 = 5 - 0.25(6) = \mathbf{3.5}\)
Step 2: grad \(= 2(3.5) - 4 = 3\); \(\;w_2 = 3.5 - 0.25(3) = \mathbf{2.75}\)
Halving the distance to 2 each step — converging.
For \(L(w) = (2w-6)^2\), the update is \(w \leftarrow w - \eta(8w - 24)\), which rearranges to \(w \leftarrow (1 - 8\eta)w + 24\eta\). For what values of \(\eta\) does the distance to the optimum shrink each step? (Hint: look at \(|1 - 8\eta|\).)
Solution
The error from the optimum multiplies by \((1 - 8\eta)\) every step: substituting \(w = 3 + \epsilon\) gives next error \((1-8\eta)\epsilon\). Convergence needs \(|1 - 8\eta| < 1\), i.e. \(\mathbf{0 < \eta < 0.25}\).
Check against the examples: \(\eta = 0.1\) → factor \(0.2\) (fast convergence ✓); \(\eta = 0.3\) → factor \(-1.4\) (oscillating divergence ✓). The steeper the loss (bigger curvature), the smaller the safe learning rate — which is why deeper/steeper networks need careful lr tuning.
Backpropagation
Goal: the payoff chapter — compute every gradient in a real (tiny) network entirely by hand, then watch PyTorch agree.
- What
- An algorithm for applying the chain rule to a whole network efficiently: run forward saving intermediate values, then sweep backward multiplying local derivatives, reusing shared sub-products.
- Why
- Naively computing each weight's derivative separately re-walks the whole network per weight. Backprop computes all of them in one backward pass — the reason training 61k-parameter (or 175B-parameter) models is feasible.
- Where
loss.backward(). Everything in this chapter is what that one call does.
The network
The smallest network that has everything — a hidden layer, an activation, a loss:
$$ x \;\xrightarrow{\;\times w_1\;}\; z \;\xrightarrow{\;\text{ReLU}\;}\; h \;\xrightarrow{\;\times w_2\;}\; \hat{y} \;\xrightarrow{\;(\hat{y}-t)^2\;}\; L $$
Numbers: \(x = 2\), target \(t = 3\), weights \(w_1 = 0.5\), \(w_2 = 1.5\). (Biases omitted to keep the paper math short — they backprop the same way, see Ex. 7.2.)
$$ z = w_1 x = 0.5 \times 2 = 1 $$
$$ h = \text{ReLU}(z) = \max(0, 1) = 1 $$
$$ \hat{y} = w_2 h = 1.5 \times 1 = 1.5 $$
$$ L = (\hat{y} - t)^2 = (1.5 - 3)^2 = \mathbf{2.25} $$
We keep \(z, h, \hat{y}\) around — the backward pass needs them. (This is why training uses more memory than inference.)
1. Loss w.r.t. prediction (outermost function first):
$$ \frac{\partial L}{\partial \hat{y}} = 2(\hat{y} - t) = 2(1.5 - 3) = -3 $$
2. Gradient for \(w_2\) — since \(\hat{y} = w_2 h\), locally \(\frac{\partial \hat{y}}{\partial w_2} = h\):
$$ \frac{\partial L}{\partial w_2} = \frac{\partial L}{\partial \hat{y}} \cdot h = (-3)(1) = \mathbf{-3} $$
3. Keep flowing to \(h\) — locally \(\frac{\partial \hat{y}}{\partial h} = w_2\):
$$ \frac{\partial L}{\partial h} = (-3)(1.5) = -4.5 $$
4. Through the ReLU — \(z = 1 > 0\), so its local slope is 1 (it would be 0 for negative \(z\), killing the signal):
$$ \frac{\partial L}{\partial z} = (-4.5)(1) = -4.5 $$
5. Gradient for \(w_1\) — since \(z = w_1 x\), locally \(\frac{\partial z}{\partial w_1} = x\):
$$ \frac{\partial L}{\partial w_1} = (-4.5)(2) = \mathbf{-9} $$
Both gradients negative → increase both weights → prediction rises toward the target. Notice step 3's value \(-4.5\) was reused in steps 4 and 5: that reuse of shared sub-products is the entire efficiency trick of backprop.
With \(\eta = 0.05\):
$$ w_1 \leftarrow 0.5 - 0.05(-9) = 0.95 \qquad w_2 \leftarrow 1.5 - 0.05(-3) = 1.65 $$
New forward: \(z = 1.9\), \(h = 1.9\), \(\hat{y} = 1.9 \times 1.65 = 3.135\), \(L = 0.018\).
Loss fell from \(2.25\) to \(\mathbf{0.018}\) in one step. Training is nothing more than this, repeated.
x, t = 2.0, 3.0
w1, w2 = 0.5, 1.5
# ---- forward (save intermediates) ----
z = w1 * x
h = max(0.0, z) # ReLU
y_hat = w2 * h
L = (y_hat - t)**2
# ---- backward (chain rule, right to left) ----
dL_dyhat = 2 * (y_hat - t) # step 1: -3
dL_dw2 = dL_dyhat * h # step 2: -3
dL_dh = dL_dyhat * w2 # step 3: -4.5
dL_dz = dL_dh * (1.0 if z > 0 else 0.0) # step 4: ReLU gate
dL_dw1 = dL_dz * x # step 5: -9
print(L, dL_dw1, dL_dw2) # 2.25 -9.0 -3.0
pytorch — the same five steps, automated
import torch
w1 = torch.tensor(0.5, requires_grad=True)
w2 = torch.tensor(1.5, requires_grad=True)
x, t = torch.tensor(2.0), torch.tensor(3.0)
y_hat = w2 * torch.relu(w1 * x) # forward builds the computation graph
L = (y_hat - t)**2
L.backward() # backward walks it right-to-left, exactly as above
print(w1.grad, w2.grad) # tensor(-9.) tensor(-3.) — matches the paper ✓
Same network, but now \(w_1 = -0.5\) (everything else unchanged: \(x=2, t=3, w_2=1.5\)). Run the forward and backward passes by hand. Something interesting happens — what, and why is it a real problem?
Solution
Forward: \(z = -1\), \(h = \text{ReLU}(-1) = 0\), \(\hat{y} = 0\), \(L = 9\).
Backward: \(\frac{\partial L}{\partial \hat{y}} = -6\); \(\;\frac{\partial L}{\partial w_2} = (-6)(0) = \mathbf{0}\); \(\;\frac{\partial L}{\partial h} = -9\); ReLU's local slope at \(z = -1\) is 0, so \(\frac{\partial L}{\partial z} = 0\) and \(\frac{\partial L}{\partial w_1} = \mathbf{0}\).
Both gradients are zero while the loss is 9: a dead ReLU. The neuron is off, so no gradient flows, so it can never turn back on (for this input). This is the classic ReLU failure mode — mitigated in practice by good initialization, other inputs activating the neuron, or LeakyReLU.
Add a bias: \(\hat{y} = w_2 h + b\), with \(b = 0.5\) and the original weights (\(w_1 = 0.5, w_2 = 1.5, x = 2, t = 3\)). Compute \(L\) and all three gradients \(\frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \frac{\partial L}{\partial b}\).
Solution
Forward: \(z = 1, h = 1\), \(\hat{y} = 1.5 + 0.5 = 2\), \(L = (2-3)^2 = \mathbf{1}\).
\(\frac{\partial L}{\partial \hat{y}} = 2(2-3) = -2\). Since \(\frac{\partial \hat{y}}{\partial b} = 1\):
\(\frac{\partial L}{\partial b} = (-2)(1) = \mathbf{-2}\) — a bias just passes the incoming gradient through.
\(\frac{\partial L}{\partial w_2} = (-2)(h) = \mathbf{-2}\)
\(\frac{\partial L}{\partial w_1} = (-2)(w_2)(\text{ReLU}'{=}1)(x) = (-2)(1.5)(1)(2) = \mathbf{-6}\)
Activations & nonlinearity
Goal: prove that layers without activations collapse, and see what the activation buys.
- What
- An elementwise nonlinear function \(\phi\) applied after each linear step: \(h = \phi(Wx + b)\). ReLU \(= \max(0, x)\), sigmoid \(= 1/(1+e^{-x})\), tanh, GELU…
- Why
- Without \(\phi\), any stack of linear layers is algebraically equal to one linear layer — depth adds nothing. The activation is what makes depth meaningful.
- Where
- Between every pair of layers. Last layer usually has none (logits go straight into the loss).
Two layers, no activation: \(h = 2x + 1\), then \(y = 3h + 2\). Substitute:
$$ y = 3(2x + 1) + 2 = 6x + 5 $$
Two layers = one layer with \(w = 6, b = 5\). In matrix form, \(W_2(W_1 x + b_1) + b_2 = (W_2 W_1)x + (W_2 b_1 + b_2)\) — always mergeable, at any depth. A 100-layer activation-free network has exactly the power of softmax regression.
Insert ReLU: \(h = \text{ReLU}(2x + 1)\), \(y = 3h + 2\). Evaluate at three inputs:
| \(x\) | \(2x+1\) | \(h = \text{ReLU}\) | \(y = 3h + 2\) |
|---|---|---|---|
| 1 | 3 | 3 | 11 |
| −0.5 | 0 | 0 | 2 |
| −2 | −3 | 0 | 2 |
For all \(x \le -0.5\) the output is stuck at 2; above, it climbs with slope 6. That's a bend — a shape no single linear layer can make. Each ReLU neuron contributes one bend; a wide network combines thousands of bends into arbitrary curves and decision boundaries.
import torch
from torch import nn
x = torch.linspace(-3, 3, 7).reshape(-1, 1) # 7 test inputs
no_act = nn.Sequential(nn.Linear(1, 4), nn.Linear(4, 4), nn.Linear(4, 1))
with_act = nn.Sequential(nn.Linear(1, 4), nn.ReLU(),
nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 1))
# no_act's output is ALWAYS a perfect straight line in x — check the
# differences between consecutive outputs (constant slope => all equal):
y = no_act(x).detach().squeeze() # detach: just numbers, no autograd
print(torch.diff(y)) # all identical values
print(torch.diff(with_act(x).detach().squeeze())) # varies — the line bends
Collapse this activation-free stack into a single layer \(y = wx + b\): layer 1 is \(h = -x + 4\); layer 2 is \(y = 5h - 3\). Then confirm your \((w, b)\) reproduces the stack's output at \(x = 2\).
Solution
\(y = 5(-x + 4) - 3 = -5x + 17\), so \(\mathbf{w = -5,\; b = 17}\).
Check at \(x=2\): stack gives \(h = 2\), \(y = 7\); formula gives \(-10 + 17 = 7\) ✓
Two neurons: \(h_1 = \text{ReLU}(x_1 - x_2)\), \(h_2 = \text{ReLU}(x_2 - x_1)\), output \(y = h_1 + h_2\). Evaluate on inputs \([9,0]\), \([0,9]\), \([5,5]\). What familiar function of \(x_1, x_2\) does this network compute, and why can no linear model do it?
Solution
\([9,0]\): \(h_1 = 9, h_2 = 0, y = 9\). \([0,9]\): \(h_1 = 0, h_2 = 9, y = 9\). \([5,5]\): \(y = 0\).
It computes \(\mathbf{|x_1 - x_2|}\) — an “edge detector” that fires for a difference in either direction. A linear model \(y = w_1 x_1 + w_2 x_2 + b\) can't: making \([9,0]\) and \([0,9]\) both output 9 forces \(9w_1 + b = 9w_2 + b = 9\), so \(w_1 = w_2\), which then gives \([5,5] \to 10 w_1 + b \neq 0\) whenever the first equations hold. The ReLU's trick is deleting negative evidence instead of letting it cancel positive evidence.
The full training loop
Goal: assemble chapters 1–8 into the complete algorithm — and map every from-scratch piece to its PyTorch name.
Every training run — LeNet, GPT, anything — is this loop:
- Forward (Ch. 04, 08): push a batch through linear layers + activations to get predictions
- Loss (Ch. 05): compress predictions vs. targets into one scalar
- Backward (Ch. 03, 07): chain rule computes every parameter's gradient
- Update (Ch. 06): step each parameter against its gradient
- Repeat
import torch
# Tiny 2-layer MLP on fake data, pure tensors — no nn.Module anywhere.
torch.manual_seed(0)
X = torch.randn(100, 4) # 100 samples, 4 features
true_W = torch.tensor([[1.], [-2.], [3.], [0.5]])
y = X @ true_W + 0.1*torch.randn(100, 1) # targets from a secret linear rule
W1 = (torch.randn(4, 8) * 0.1).requires_grad_(True) # layer 1 weights
b1 = torch.zeros(8, requires_grad=True)
W2 = (torch.randn(8, 1) * 0.1).requires_grad_(True) # layer 2 weights
b2 = torch.zeros(1, requires_grad=True)
lr = 0.05
for step in range(200):
# 1. forward (Ch. 04 + 08)
h = torch.relu(X @ W1 + b1)
y_hat = h @ W2 + b2
# 2. loss (Ch. 05 — MSE here)
loss = ((y_hat - y)**2).mean()
# 3. backward (Ch. 03 + 07)
loss.backward()
# 4. update (Ch. 06)
with torch.no_grad(): # raw arithmetic, don't record for autograd
for p in (W1, b1, W2, b2):
p -= lr * p.grad # the gradient descent step
p.grad.zero_() # clear: .backward() ACCUMULATES by default
if step % 50 == 0:
print(f"step {step}: loss {loss.item():.4f}")
The translation table
Every wrapper you'll meet (in d2l, PyTorch, Lightning) is one of these rows:
| You wrote (from scratch) | PyTorch API | Chapter |
|---|---|---|
X @ W + b | nn.Linear | 04 |
torch.relu(...) / manual max | nn.ReLU | 08 |
softmax() + cross_entropy() | nn.CrossEntropyLoss | 05 |
| manual chain rule | loss.backward() | 03, 07 |
p -= lr * p.grad | optimizer.step() | 06 |
p.grad.zero_() | optimizer.zero_grad() | 06 |
| the whole loop | d2l.Trainer.fit() | 09 |
In the from-scratch loop, predict what happens if you delete the p.grad.zero_() line — then run it and confirm.
Solution
.backward() adds new gradients onto .grad rather than replacing them. Without zeroing, step \(n\) uses the sum of all gradients from steps \(1..n\) — effectively an ever-growing step size. Loss drops at first, then oscillates or explodes, like the divergence table in Ch. 06.
Rewrite the loop using nn.Sequential, nn.MSELoss, and torch.optim.SGD, keeping behavior identical. Then verify both versions reach a similar loss.
Solution
from torch import nn
net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
loss_fn = nn.MSELoss()
opt = torch.optim.SGD(net.parameters(), lr=0.05)
for step in range(200):
loss = loss_fn(net(X), y) # forward + loss
opt.zero_grad() # p.grad.zero_() for every param
loss.backward() # chain rule
opt.step() # p -= lr * p.grad for every param
Same four phases, same math — the API just names them. If you can write both versions and explain each line's chapter number, you own the foundations.
Resources
Curated for the paper-first style of this workbook.
- 3Blue1Brown — Neural Networks seriesThe best visual intuition for gradient descent and backprop; watch after Ch. 06–07.
- Karpathy — “The spelled-out intro to neural networks and backpropagation”Builds autograd from scratch in Python (micrograd). The perfect follow-up to Ch. 07.
- TensorFlow PlaygroundSet activation to “Linear” and watch a deep net fail; switch to ReLU and watch it bend. Ch. 08, live.
- CNN ExplainerInteractive convolution/pooling on a real image — bridges this workbook back to LeNet.
- Dive into Deep LearningYour main text. Chapters 2.4–2.5 (calculus, autograd) and 5 (MLPs) map directly onto this workbook.
- The Matrix Calculus You Need For Deep Learning (Parr & Howard)When you're ready to redo Ch. 07 with full matrices instead of scalars.