Backpropagation, fully derived
The chain rule on a graph, organized so every gradient costs one backward pass
Backpropagation is the algorithm that makes deep learning possible: it computes the gradient of a scalar loss with respect to millions of parameters for roughly twice the cost of one forward pass. Deriving it once by hand, for a real softmax classifier, removes the magic from autograd, lets you debug shapes and NaNs with confidence (4.8 Training dynamics and debugging), and is the basis of every cost estimate in the field — the famous "6N FLOPs per token" of 7.9 Scaling laws and compute-optimal training is a statement about backprop.
You'll be able to
- Build a computational graph for an expression and apply the chain rule node by node, forward and backward
- Derive the gradients of a 2-layer network with softmax + cross-entropy with respect to every weight and bias, with shapes checked
- Explain vector–Jacobian products and why reverse-mode differentiation is the right choice for scalar losses
- Estimate the compute (≈ 2× forward) and memory (stored activations) cost of backprop
- Implement a micrograd-style scalar autograd engine and verify it with gradient checking
Key ideas
- Backprop computes ∇L for all parameters in about 2× the cost of a forward pass, independent of the number of parameters.
- On a computational graph, each node multiplies the upstream gradient by its local derivative; gradients arriving along different paths are summed.
- For softmax + cross-entropy the output error is δ⁽ᴸ⁾ = p − y; for each layer δ⁽ˡ⁾ = (W⁽ˡ⁺¹⁾ᵀ δ⁽ˡ⁺¹⁾) ⊙ φ'(z⁽ˡ⁾).
Show 4 more key ideas
- Weight gradients are outer products δ⁽ˡ⁾ h⁽ˡ⁻¹⁾ᵀ (batched: Δᵀ H / B); bias gradients are δ⁽ˡ⁾ summed over the batch.
- Reverse mode chains vector–Jacobian products from the loss backward, never forming Jacobians; it is optimal for scalar losses.
- Backward needs the stored forward activations, so memory scales with batch × depth; checkpointing recomputes them to save memory.
- Always verify a hand-written backward with centered finite differences in float64 (relative error < 1e-7).
Suggested path through this lesson
The problem
Training means minimizing over millions (or trillions) of parameters by gradient descent (1.10 Optimization: from gradient descent to Adam), so we need at every step. Finite differences — perturb one parameter, re-run the forward pass, repeat — would cost one forward pass per parameter: a 124M-parameter model would need 124 million forward passes per update. Backpropagation (Rumelhart, Hinton & Williams, 1986) computes the entire gradient for about twice the cost of one forward pass, regardless of the number of parameters. It is nothing more than the multivariable chain rule (1.4 Derivatives, gradients and the chain rule), applied in the right order and with intermediate results cached.
Computational graphs and the chain rule
Write the computation as a directed graph: nodes are intermediate values, edges say which values feed which operation. Each node knows its local derivative with respect to its inputs. The chain rule then says: the derivative of the output with respect to any node is the sum, over all paths from that node to the output, of the product of local derivatives along the path. Organizing this sum efficiently is the whole trick.
Take the one-neuron loss with .
Forward (left to right, caching every value): ; ; .
Backward (right to left, each node receives and multiplies by its local derivative):
Two things to notice. The backward pass reuses the forward values (, , ) — that is why they must be stored. And the "gradient so far" flows backward through the graph, being multiplied by one local derivative per node; nodes with several outputs sum the gradients arriving from each. The backprop-graph demo below lets you step through exactly this.
Deriving backprop for a 2-layer softmax classifier
Single example , label , one-hot ; hidden width :
Shapes: , . We go backward one layer at a time, naming (the "error signal" at layer ).
Step 1 — the output logits. Using ,
This is why softmax and cross-entropy are always used together: separately, the softmax Jacobian and the from the log are messy; combined, they cancel into predicted minus actual (4.3 Loss functions catalog).
Step 2 — output weights and biases. Since , we have and , so
An outer product: entry is the error at output times the activation of hidden unit — a weight changes in proportion to how active its input was and how wrong its output was (Hebbian learning with a sign).
Step 3 — back into the hidden activations. Each feeds every output, so the gradients sum: , i.e.
The forward pass multiplied by ; the backward pass multiplies by its transpose. That is always true for linear layers.
Step 4 — through the nonlinearity. acts element-wise, so its Jacobian is diagonal and the chain rule is an element-wise product:
For ReLU, is a 0/1 mask: the error only flows through units that were active in the forward pass.
Step 5 — first-layer weights and biases, by the same argument as Step 2:
The general recursion. For any depth,
Unrolling the recursion gives exactly the product of factors from 4.2 Activation functions — the vanishing-gradient argument is this formula read aloud.
Batched form. With examples as rows, and , the weight gradient for the mean loss is (shape ✓) and the bias gradient is the column mean of . Every per-example outer product collapses into one matrix multiply, which is why the backward pass is as GPU-friendly as the forward pass.
Vector–Jacobian products: why reverse mode
Think of each layer as a function with Jacobian . The full chain rule is . Multiplying from the left, starting with the row vector , each step is a vector–Jacobian product (VJP) — a vector in, a vector out, and the Jacobian itself is never materialized. (A Linear layer's VJP is ; ReLU's is a mask. That is all Steps 3–4 were.) This is reverse-mode automatic differentiation. Multiplying from the right (forward mode, Jacobian–vector products) would require one pass per input dimension. For a scalar loss and millions of inputs, reverse mode wins by a factor of millions — and forward mode wins when there are few inputs and many outputs, which is why it appears in some physics simulations but not in training.
What backprop costs
Compute. Each Linear layer does one matmul in the forward pass () and two in the backward pass: one for the gradient with respect to the input () and one for the gradient with respect to the weights (). So backward ≈ 2× forward, and a full training step ≈ 3× a forward pass. Since a forward pass costs about FLOPs per token for a model with parameters (4.12 Compute, hardware and scale basics), training costs about per token — the constant in the scaling-law formula (7.9 Scaling laws and compute-optimal training).
Memory. Step 2 needed , Step 4 needed , Step 5 needed : every layer's input must be kept alive from the forward pass until its backward step. Activation memory therefore grows with batch size × sequence length × depth × width, and it — not the parameters — is what overflows your GPU first. Activation checkpointing stores only some layers' activations and recomputes the rest during the backward pass, trading ~30% more compute for a large memory saving (4.12 Compute, hardware and scale basics).
Gradient checking
Backprop is easy to get subtly wrong when you implement layers yourself. Verify against the centered finite difference
in float64, and compare with the relative error : below is right, above is a bug. Check a handful of random parameters, not all of them.
How autograd implements it
PyTorch builds the graph dynamically as your code runs: every tensor operation records its inputs and a function that computes its VJP. Calling loss.backward() sorts the graph topologically and walks it in reverse, accumulating each tensor's .grad (which is why you call optimizer.zero_grad() each step). Karpathy's micrograd is the whole mechanism in 100 lines:
class Value:
def __init__(self, data, children=(), op=""):
self.data, self.grad = data, 0.0
self._backward, self._prev = lambda: None, set(children)
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), "*")
def _backward(): # local derivative × upstream gradient
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def backward(self): # reverse topological order
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v); [build(c) for c in v._prev]; topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo): v._backward()
The += is the "sum over paths" rule; the reversed topological order guarantees every node's upstream gradient is complete before it propagates further. Tensor autograd is the same idea with matrices and a library of hand-written VJPs.
Where this shows up later
- The backward recursion through many layers is the vanishing/exploding-gradient mechanism — 4.2 Activation functions, 4.6 Initialization and normalization; optimizers consume the gradients it produces — 4.5 Optimizers and learning-rate schedules.
- Backprop through time unrolls the same recursion across time steps — 6.4 Recurrent neural networks and BPTT; attention's backward pass and FlashAttention's recomputation trick — 7.2 Scaled dot-product and multi-head attention, fully derived, 7.10 Efficient inference: KV cache, GQA, FlashAttention, speculative decoding, quantization.
- The 6N FLOPs-per-token cost model — 4.12 Compute, hardware and scale basics, 7.9 Scaling laws and compute-optimal training; activation memory and checkpointing — 4.12 Compute, hardware and scale basics; LoRA cuts optimizer memory but not activation memory — 8.2 Fine-tuning and PEFT.
- Debugging with gradient norms and gradient checks — 4.8 Training dynamics and debugging.
Common pitfalls (5)
- Forgetting to zero gradients between steps: autograd accumulates .grad by design (the sum-over-paths rule).
- Multiplying by W instead of Wᵀ in the backward pass of a linear layer — shape errors or silently wrong gradients.
- Applying φ' to the activation h instead of the pre-activation z (for ReLU the mask must come from z > 0).
- Doing gradient checks in float32 with ε = 1e-8: rounding error swamps the signal; use float64 and ε ≈ 1e-5.
- Computing a loss on the whole dataset in one graph and running out of memory — activations for every example must be stored until backward.
Recap: all 7 key points
- Backprop computes ∇L for all parameters in about 2× the cost of a forward pass, independent of the number of parameters.
- On a computational graph, each node multiplies the upstream gradient by its local derivative; gradients arriving along different paths are summed.
- For softmax + cross-entropy the output error is δ⁽ᴸ⁾ = p − y; for each layer δ⁽ˡ⁾ = (W⁽ˡ⁺¹⁾ᵀ δ⁽ˡ⁺¹⁾) ⊙ φ'(z⁽ˡ⁾).
- Weight gradients are outer products δ⁽ˡ⁾ h⁽ˡ⁻¹⁾ᵀ (batched: Δᵀ H / B); bias gradients are δ⁽ˡ⁾ summed over the batch.
- Reverse mode chains vector–Jacobian products from the loss backward, never forming Jacobians; it is optimal for scalar losses.
- Backward needs the stored forward activations, so memory scales with batch × depth; checkpointing recomputes them to save memory.
- Always verify a hand-written backward with centered finite differences in float64 (relative error < 1e-7).
Watch
What the gradient 'wants' each weight to do; watch first.
Read
Try it live
Interactive demo
runs in your browserExercises
- By hand: the one-neuron example with new numbers
Redo the forward and backward pass of with . Then replace the sigmoid with ReLU and repeat. Expected: with ReLU and every gradient except is exactly zero — a dead unit in miniature.
- NumPy: 2-layer softmax classifier from scratch
Implement forward and backward for the network derived above (ReLU hidden layer, batched), train it on MNIST or on scikit-learn's digits dataset with plain SGD, and gradient-check three random weights in float64 before training. Expected: relative errors below 1e-7; test accuracy above 95% on digits after a few hundred steps.
- Build micrograd
Following Karpathy's video, implement the
Valueclass with add, mul, pow, exp, tanh and backward, then train a tiny MLP on the four-point dataset from the video. Expected: loss falls below 0.01; compare one of your gradients with PyTorch's on the same scalar graph. - Experiment: activation memory
In PyTorch, run a forward pass of a 10-layer MLP (width 4096) at batch sizes 64, 256, 1024 and record
torch.cuda.max_memory_allocated()(or process memory on CPU) with and withouttorch.no_grad(). Expected: with gradients enabled, memory grows linearly with batch size; under no_grad it barely changes — the stored activations are the difference.
Quiz
Flashcards
These cards are now in your spaced-repetition queue. Review all due cards →