Chain Rule by Hand: Six Numbers, One Forward Pass, Exact Gradients
Backpropagation is a bookkeeping algorithm for the chain rule. Nothing statistical, nothing approximate: given a composition of differentiable functions, it computes the exact derivative of the loss with respect to every parameter in one backward sweep. Work the smallest net that still has two layers, and the pattern generalizes to any depth.
x = 1.0 (input), t = 0.0 (target)
layer 1: z1 = w1·x + b1, a1 = σ(z1)
layer 2: z2 = w2·a1 + b2, ŷ = σ(z2)
loss: L = (ŷ − t)^2
start: w1 = 0.6, b1 = 0.1, w2 = −0.4, b2 = 0.2
σ is the sigmoid, σ(z) = 1 / (1 + e^(−z)), and it has the famous derivative σ′(z) = σ(z)·(1 − σ(z)) — the slope comes from the output itself, which keeps the arithmetic short.
Forward pass first, because every gradient below names one of these values:
z1 = 0.6·1.0 + 0.1 = 0.7
a1 = σ(0.7) = 0.6682
z2 = −0.4·0.6682 + 0.2 = −0.0673
ŷ = σ(−0.0673) = 0.4832
L = (0.4832 − 0)^2 = 0.2335
Now backward, chain rule one link at a time. Each gradient is the upstream gradient multiplied by the local derivative:
dL/dŷ = 2(ŷ − t) = 0.9664
dŷ/dz2 = σ′(z2) = ŷ(1−ŷ) = 0.2497
δ2 = dL/dz2 = 0.9664 · 0.2497 = 0.2413
dL/dw2 = δ2 · a1 = 0.1612
dL/db2 = δ2 = 0.2413
dL/da1 = δ2 · w2 = −0.0965
δ1 = dL/dz1 = −0.0965 · σ′(0.7) = −0.0214
dL/dw1 = δ1 · x = −0.0214
dL/db1 = δ1 = −0.0214
Two structural facts to take away. Every layer’s gradient follows the same recipe: δ at that layer = upstream gradient × local derivative, and a weight’s gradient is that δ times the weight’s input. And look at the magnitudes: |δ1| = 0.0214 is roughly eleven times smaller than |δ2| = 0.2413, because backprop multiplied one more sigmoid slope (0.2217 at z1) on the way down. Two layers cost a factor of eleven. Chapter three is about what fifty layers cost.
One bookkeeping rule completes the method: when a tensor feeds several downstream paths, the chain rule sums the contributions — dL/dz = (dL/dy1)·(dy1/dz) + (dL/dy2)·(dy2/dz). Autograd does that addition for you, and in a hand derivation it is the step people forget. Every gradient above is a product along one path; deeper nets are the same products summed over many paths.
PyTorch computes exactly these numbers from the recorded computation graph:
import torch
w1 = torch.tensor(0.6, requires_grad=True)
b1 = torch.tensor(0.1, requires_grad=True)
w2 = torch.tensor(-0.4, requires_grad=True)
b2 = torch.tensor(0.2, requires_grad=True)
x, t = 1.0, 0.0
a1 = torch.sigmoid(w1 * x + b1)
y = torch.sigmoid(w2 * a1 + b2)
loss = (y - t) ** 2
loss.backward()
print(w1.grad, w2.grad, b1.grad, b2.grad)
# tensor(-0.0214) tensor(0.1612) tensor(-0.0214) tensor(0.2413)
The forward pass logged every operation on an autograd graph; backward() walks that graph in reverse, multiplying local derivatives, and deposits each result in the corresponding tensor’s .grad buffer. This is reverse-mode autodiff, and its cost property is why deep learning is affordable at all: one forward pass plus one backward pass yields the gradient of one scalar loss with respect to millions of parameters — a small constant multiple of the forward cost, not one derivative per parameter. Ask a symbolic system for dL/dw1 of a 100-million-parameter model and it computes dL/dw100,000,000 too, for the same price.
zero_grad, backward, step: The Four Lines Every Training Loop Shares
Every PyTorch training loop, from a course homework to a fine-tune of a large model, is the same four lines inside the same two loops:
model = MLP(n_in=20, n_hidden=64, n_out=3)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
loss_fn = torch.nn.CrossEntropyLoss()
for epoch in range(30): # one epoch = one full pass over the data
for xb, yb in train_dl: # one batch, e.g. xb: (32, 20), yb: (32,)
logits = model(xb) # forward: compute predictions, record the graph
loss = loss_fn(logits, yb) # scalar objective — the pairing from Output Layers and Loss Pairings
optimizer.zero_grad() # clear last batch gradients
loss.backward() # fill every parameter .grad
optimizer.step() # apply gradients to the weights
The order is not stylistic. zero_grad must precede backward because .grad is an accumulating buffer: each backward() does grad += contribution, it does not overwrite. That design is deliberate — it enables gradient accumulation, where you split a batch too large for memory into micro-batches and step once at the end:
for i, (xb, yb) in enumerate(train_dl):
loss = loss_fn(model(xb), yb) / 4 # scale each micro-batch before accumulating
loss.backward()
if (i + 1) % 4 == 0: # effective batch = 4 × micro-batch
optimizer.step()
optimizer.zero_grad()
The same accumulation explains the classic beginner bug. Forget zero_grad() in the plain loop and every update uses the running sum of all gradients since the process started: early batches keep influencing step 500, the sums grow without bound, and the loss looks reasonable for a few dozen batches before rocketing to NaN. Newer PyTorch defaults zero_grad(set_to_none=True), which sets buffers to None instead of zeros — same training semantics, less memory, and a layer that never received a gradient now fails loudly instead of silently skipping updates.
What step() does is per-parameter arithmetic. SGD applies w ← w − lr·g to every weight, plus momentum bookkeeping if configured. Using the numbers from the first chapter: w1 = 0.6 with gradient −0.0214 and lr = 0.1 becomes 0.6 − 0.1·(−0.0214) = 0.6021. Swap SGD for Adam and the call site is identical — only the update rule behind step() changes, from a fixed learning rate to per-parameter adaptive scaling from running moment estimates.
Epochs and batches set the pace of that arithmetic. A 10,000-sample dataset at batch_size 32 delivers ceil(10000/32) = 313 updates per epoch; 30 epochs is 9,390 optimizer steps. Batch size trades noise against throughput: smaller batches give noisier gradient estimates, more steps per epoch, and a mild regularizing effect; larger batches give smoother estimates, fewer steps, and more memory per update. When comparing two runs, compare optimizer steps and wall-clock, not epochs — “it trained for 30 epochs” means nothing without the batch size attached. Log the running loss with loss.item() rather than the tensor itself, and evaluate a held-out loader under torch.no_grad() so the curve you tune against is out-of-sample.
Two more ordering traps worth memorizing: calling optimizer.step() before loss.backward() applies gradients from the previous batch (and errors outright once gradients are None), and calling backward() twice without zeroing accumulates two batches’ gradients — a bug in a plain loop, a feature in the accumulation pattern above. Each iteration’s loss comes from the pairings chosen in Output Layers and Loss Pairings; a mismatched pairing still produces gradients, they just descend the wrong objective. The scaffolding around this loop — datasets, modules, checkpoints — is Building a Network in PyTorch.
Layer One Barely Moves: Reading Vanishing and Exploding Gradients Off the Gradient Norms
The scenario interviewers love to hand you: a 6-layer MLP with sigmoid hidden units trains for 30 epochs, the loss drops from 0.91 to 0.51 in the first few, then crawls. You print the weight updates and find layer 6 moving by 1e-2 per step while layer 1 moves by 1e-6. Raising the learning rate destabilizes layer 6 without waking layer 1. This is not a tuning problem; it is a gradient-flow problem, and the first chapter already contained its cause.
Backprop multiplies one local derivative per layer on the way down. The sigmoid derivative never exceeds 0.25, and tanh’s never exceeds 1, so a stack of squashing activations attenuates the signal multiplicatively:
gradient at layer k = dL/d(output) × Π (local derivatives from layer L down to k)
10 sigmoid layers: ≤ 0.25^10 ≈ 1e-6 of the loss signal reaches layer 1
20 sigmoid layers: ≤ 0.25^20 ≈ 1e-12 — the early layer is effectively frozen
Nothing malfunctioned. You asked for a derivative that is a product of twenty numbers smaller than one, and got one. The mirror failure is exploding gradients: a recurrent network unrolled over 50 time steps multiplies by a weight matrix with effective gain greater than 1 at each step, the norms double until they exceed 1e20, and the next optimizer step writes NaN into every parameter. An oversized learning rate produces the same runaway in plain feed-forward nets.
Diagnose with numbers, not vibes. Log the gradient norm per parameter group every few hundred steps:
for name, p in model.named_parameters():
if p.grad is not None:
print(f'{name:24s} grad_norm={p.grad.norm():.3e}')
Healthy training shows gradient norms within roughly one order of magnitude across layers. Vanishing shows head layers two or three orders of magnitude below the tail — the exact 11× gap from chapter one, compounded. Exploding shows any norm past 1e3, or NaN appearing after it once overflowed.
Each remedy fixes a specific mechanism, so name the mechanism when you name the fix:
| Remedy | Mechanism |
|---|---|
| ReLU-family activations | derivative is exactly 1 on the active side — no multiplicative shrink (cost: dead units on the inactive side) |
| He init for ReLU nets, Xavier for tanh/sigmoid | keeps activation and gradient variance near 1 per layer at the start of training |
| Residual connections (y = F(x) + x) | gives gradients an identity path: dL/dx = dL/dy + dL/dF·dF/dx, so signal skips layers entirely |
| BatchNorm / LayerNorm | renormalize pre-activations so derivatives stay off the saturation rails |
| Gradient clipping (clip_grad_norm_, max_norm=1.0) | rescales any gradient vector whose norm exceeds the cap — containment, not a cure |
| Warmup then decay on the learning rate | keeps early large-gradient steps small while scales settle |
Note what clipping is and is not: it stops a bad step from destroying the weights, it does not fix the layer or the initialization that produced the explosion. Print the norms again after clipping; if the head layers still sit at 1e-7, the architecture needs the fix, not the loop.
This is the question behind most “my network does not learn” reports, so practice the full answer out loud: print per-layer gradient norms, compare them, attribute the spread to multiplicative chain-rule attenuation (sigmoid stacks) or gain above one (recurrent unrolls, oversized lr), then prescribe from the table — activations and residual paths for vanishing, clipping and learning-rate surgery for exploding. The same norms live inside the loop you built in Building a Network in PyTorch, and the loss being differentiated came from the pairings in Output Layers and Loss Pairings.
Quiz
1. You delete optimizer.zero_grad() from a standard training loop. What happens next?
2. Which sequence correctly performs one training iteration?
3. A 12-layer sigmoid MLP plateaus at loss 0.51. Gradient norms print 2e-7 for layer 1 and 3e-2 for layer 12. Most likely explanation?
4. A 10,000-sample dataset is trained with batch_size 64, drop_last=True, for 20 epochs. How many optimizer steps occur?
Flashcards
Question
What does loss.backward() compute, and where do the results land?
Click to reveal answer
Answer
It runs reverse-mode autodiff over the graph recorded during the forward pass, applying the chain rule from the scalar loss back through every operation, and accumulates each derivative into the corresponding parameter’s .grad buffer. One forward plus one backward yields gradients for all parameters at once.
Question
Why must optimizer.zero_grad() run between batches?
Click to reveal answer
Answer
grad buffers accumulate with += across backward() calls, and optimizer.step() consumes but does not clear them. Skipping zero_grad() trains on the running sum of every gradient since the process started, which typically drives the loss to NaN within tens of steps. set_to_none=True makes the miss fail loudly.
Question
Define epoch, batch, and iteration; how many updates does a 10k-sample dataset at batch size 50 get per epoch?
Click to reveal answer
Answer
An epoch is one full pass over the dataset; a batch is the subset whose gradients are averaged for one update; an iteration (step) is one optimizer.step(). 10,000/50 = 200 updates per epoch, so the epoch count alone never tells you how much training happened.
Question
Why do stacks of sigmoid units cause vanishing gradients — quantify it.
Click to reveal answer
Answer
The sigmoid derivative is at most 0.25, and backprop multiplies one such factor per layer on the way down: 10 squashing layers deliver at most 0.25^10 ≈ 1e-6 of the loss signal to the earliest weights. Remedies: ReLU-family activations (derivative 1 on the active side), He/Xavier init, residual connections, normalization.
Question
What causes exploding gradients, and what is the quickest containment measure?
Click to reveal answer
Answer
Repeated multiplicative feedback with gain above 1 — deep recurrent unrolls, oversized learning rates, bad init — inflates gradients until updates overflow to NaN. Immediate containment: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) rescales any gradient vector whose norm exceeds the cap; the underlying init or lr still needs fixing.
Revision Notes
Key Takeaways
- 1. Backprop is the chain rule run in reverse over the recorded graph: each δ = upstream gradient × local derivative, each weight gradient = δ × that weight’s input
- 2. One forward plus one backward yields gradients for every parameter — reverse-mode autodiff makes the cost of training nearly independent of parameter count
- 3. grad buffers accumulate across backward() calls, so zero_grad() between batches is mandatory; forgetting it trains on the sum of all history and diverges
- 4. Epochs are passes, steps are updates: 10,000 samples at batch 32 means 313 updates per epoch, and comparisons across runs must count steps, not epochs
- 5. Vanishing gradients are multiplicative attenuation (sigmoid’s 0.25 slope per layer); exploding gradients are gain above one — diagnose with per-layer grad norms before touching hyperparameters
Interview Tips
- • "Derive the gradient for this two-neuron network" — go forward one line per node, backward one line per node, and state the recipe (upstream × local); interviewers are watching the order, not the arithmetic
- • "Your loss stopped improving — what do you check first?" — print per-layer gradient norms, compare spreads, then attribute: attenuation (vanishing) or gain > 1 (exploding); always name the mechanism behind the remedy
- • "What does optimizer.zero_grad() do and why?" — explain grad accumulation semantics, then show you know the legitimate use: gradient accumulation for batches larger than GPU memory
- • "How many updates does my epoch contain?" — derive floor(N/batch) × epochs and flag drop_last; this reveals whether you have actually trained models or only read about them
Cheat Sheet
Backpropagation and the Training Loop
The four-line training step
logits = model(xb) # forward: record graph
loss = loss_fn(logits, yb) # scalar objective
optimizer.zero_grad() # clear .grad buffers
loss.backward() # chain rule: fill .grad
optimizer.step() # w ← w − lr·g (per optimizer rule)
Gradient recipe (two-layer sigmoid net, worked)
forward: a1 = σ(w1·x + b1), ŷ = σ(w2·a1 + b2), L = (ŷ − t)²
backward: δ2 = dL/dŷ·σ′(z2); dL/dw2 = δ2·a1; δ1 = δ2·w2·σ′(z1); dL/dw1 = δ1·x
rule: δ at layer k = upstream gradient × local derivative; weight grad = δ × input
Semantics that bite
| Fact | Consequence |
|---|---|
| backward() does grad += | zero_grad() required each batch; also enables gradient accumulation |
| step() does not clear grads | skipping zero_grad() sums all history → NaN |
| epoch = full pass; step = one batch | 10k samples, batch 50 ⇒ 200 updates/epoch |
| 0.25^depth attenuation | sigmoid stacks freeze early layers (vanishing gradients) |
Gradient flow toolkit
- Diagnose: print p.grad.norm() per named_parameter; healthy spread within ~1 order of magnitude
- Vanishing: ReLU family, He/Xavier init, residual skips, normalization
- Exploding: clip_grad_norm_(max_norm=1.0), lr warmup/decay — containment, not a cure