Backpropagation
Reverse-mode autodiff — how gradients flow through a network. Taught from zero, starting with a graph of five little arithmetic steps, then built up to the algorithm that assigns blame to every weight in a billion-parameter model.
Start here — the question backprop answers
You already know from Optimization that training means turning knobs to make the loss smaller, and that to turn a knob you need to know which way and how much. That "which way and how much" number is the gradient.
Here's the problem nobody warns you about. A modern network has millions of knobs, buried dozens of layers deep, and the loss is one single number computed at the very end. So the real question is:
The final answer was wrong by this much. How much of that was each individual knob's fault?
That question has a name — — and is the algorithm that answers it. Efficiently. For every knob at once. It is the idea that made neural networks with hidden layers trainable in the first place, and the version everyone uses today was popularized in a four-page 1986 paper.
Backprop takes one number — the loss — and hands back one gradient per parameter, telling each weight exactly how the loss would change if that weight were nudged up a hair. The astonishing part is the price: getting all million answers costs roughly the same as computing the loss once. That single fact is why deep learning is possible at all.
A product comes off the end of a long assembly line, and it's 3 millimetres too wide. You walk backwards down the line asking each station: "given that your output was off by this much, how far off was your input, and how much did your own setting contribute?" Each station only needs to understand its own little job. Chain those local answers together, station by station, and by the time you reach the front of the line every worker knows their exact share of the blame — without anyone having to understand the whole factory.
It starts from first principles with five numbers you can check by hand. Flip the Depth switch at the top for the formal notation, matrix forms, and derivations — and they open automatically once you've finished the prerequisites (Neural Networks and Calculus). Nothing is hidden for good.
This trips up almost everyone. Backpropagation computes the gradients; it does not change a single weight. Gradient descent, SGD, or Adam take those gradients and do the actual updating. In PyTorch the split is literal: loss.backward() is backprop, opt.step() is the optimizer. Two different jobs, two different lines of code.
The computational graph — a network is just small steps wired together
Before we can push blame backwards, we need to be precise about what "the network" even is. And the answer is nicer than you'd expect: a network is a pile of tiny arithmetic operations wired together. Add. Multiply. Take a max. Apply a squashing function. Nothing in there is harder than middle-school arithmetic — there's just a lot of it.
Drawing those steps as boxes with wires between them gives a .
"Cream the butter and sugar" → "beat in the eggs" → "fold in the flour" → "bake." Each arrow hands the result of one step to the next. A network is the same flowchart, just with multiply-and-add instead of creaming and folding — and dozens of layers instead of four steps.
Let's take the smallest example that shows every idea we need. Three inputs, two operations:
Set , , . Now walk left to right, doing one step at a time and writing down what each step produced:
- The add node. .
- The multiply node. .
That's the whole forward pass: . Notice we wrote down the intermediate value rather than throwing it away — in a moment you'll see that the backward pass is helpless without it.
That left-to-right sweep is the . Here is the same computation as a picture — the black labels are what the forward pass computes, and the orange labels are what the backward pass will compute in a moment. Don't worry about the orange numbers yet; just note that every wire ends up carrying two things: a value forward, and a gradient backward.
Why does the forward pass bother storing the intermediate value d, instead of just returning the final answer?
Hint: Look at what the multiply node would need in order to answer questions about its own inputs.
Local gradients — every node only needs to know its own job
Now the key move, and honestly the whole idea of backpropagation.
No node in the graph knows anything about the network. The add node has never heard of the loss. It doesn't know it's inside a neural network, doesn't know what layer it's in, doesn't know what happens after it. It knows exactly one thing: if my inputs wiggle, how does my output wiggle? That's its .
Each node is a specialist with a one-line job description. "I add." "I multiply." Ask an adder how its output responds to its first input and it says "one-for-one" — and it can answer that without knowing a single thing about the other three million nodes. Global understanding never has to exist anywhere. It emerges from chaining local answers.
Booth 1 changes dollars to euros at 0.9. Booth 2 changes euros to yen at 160. Neither clerk knows about the other, and neither needs to. Each knows only their own rate. Walk your money through both and the end-to-end rate — — appears on its own, just by multiplying the local rates. Backprop is that walk, done backwards.
The add node, , with , :
- Push up by : goes from to . It moved by exactly the same amount, so the local gradient is .
- Same for : local gradient .
- So an add node's local gradients are and — always, regardless of the input values.
The multiply node, , with , :
- Push up by : goes from to . It moved , which is times the nudge. Local gradient: — which is exactly , the other input.
- Push up by : goes to , a move of . Local gradient: — which is , again the other input.
- So a multiply node's local gradients are the values it was multiplied by. It swaps them.
These three shapes cover most of what you'll ever hand-derive, and reading a backward pass gets much faster once you know them by feel:
• Add is a distributor. It copies the incoming gradient, unchanged, to every input. Local gradients are all .
• Multiply is a swapper. Each input's gradient is the incoming gradient times the other input. A tiny input therefore hands its partner a tiny gradient — which is precisely why badly scaled inputs stall training.
• Max is a router. It sends the whole gradient to whichever input won, and exactly zero to the losers. This is why a dead ReLU stays dead: it lost, so it receives nothing, so it never gets a chance to change.
The
A multiply node computes y = u x v with u = 0.001 and v = 500. Which input will receive a large gradient, and which a tiny one?
Hint: A multiply node's local gradient for one input is the value of the other input.
The backward pass — start at 1 and multiply your way home
We have local gradients everywhere. Now we chain them. The rule is short enough to fit on a sticky note:
Each node takes the gradient handed to it from above, multiplies by its own local gradient, and hands the result down to each of its inputs.
Where does the very first gradient come from? From the output itself. How sensitive is to ? Change by one and changes by one, so we seed the whole process with .
Working right to left through our graph, with the local gradients we just computed:
- Seed the output. .
- Through the multiply node. Incoming gradient is .
- To : .
- To : .
- Through the add node. Incoming gradient is the that just arrived at .
- To : .
- To : .
Done. Every input now has its gradient: , , . Those are the orange numbers in the graph above.
Check one by brute force. Nudge from to . Then and . The output fell by for a nudge of — a rate of . The backward pass was right, and it got all three answers in a single sweep, while brute force needs one full re-run of the network per input.
says two things. The minus says increasing decreases the output — so if you wanted the output up, you'd move down. The 3 says the effect is three times as strong as the nudge. In training, the loss plays the role of : sign tells you which way to turn the knob, size tells you how much that knob matters.
Run it and see the two sweeps side by side. Change , , or and watch how the gradients move — the multiply node's gradients depend on the values, so they change every time:
In a computational graph, a node's gradient with respect to one of its inputs equals:
A real neuron, end to end
Our toy graph had no weights. Let's do the smallest thing that actually learns: one neuron with a weight, a bias, a squashing function, and a loss.
In words, left to right: multiply the input by the weight and add the bias; squash the result into the range to with the ; then score how far the prediction landed from the truth by squaring the miss.
Take , , , and a target of .
Forward:
Badly wrong — we wanted and predicted .
Backward, seeding with and walking back through each node:
- Through the squared loss. Local gradient of with respect to is . So .
- Through the sigmoid. Its local gradient is the tidy . Multiply: .
- Through the multiply-and-add. The bias is added, so it distributes: . The weight is multiplied by , so it swaps: .
Both gradients are positive, meaning raising or would raise the loss — so gradient descent will lower both, pushing the prediction down toward the target of . Exactly right.
Look at step 2. The sigmoid multiplied the gradient by roughly one tenth on its way through — and isn't even far out on the curve. At that factor drops below . Stack ten such layers and the gradient reaching the first one has been multiplied by something like : the early layers receive essentially nothing and stop learning. That is the vanishing-gradient problem, and it is the single biggest reason ReLU replaced sigmoid in hidden layers.
Run the neuron and watch each stage's gradient appear. Then try the experiment that makes the point stick: change b to 6.0. The forward pass barely notices — the prediction just saturates from up to , which is more wrong, not less. But every gradient collapses by roughly a hundredfold. The neuron is now maximally wrong and almost completely unable to learn.
dL/dz is the gradient after passing through the sigmoid. Compare it to dL/dyhat, the gradient before. The ratio between them is the sigmoid's local gradient — and it is never greater than , no matter what.
The sigmoid's local gradient is yhat(1 - yhat). What is the largest value that can ever take, and where?
Hint: Try a few values of yhat between 0 and 1.
Why is reverse-mode (backprop) the right choice for training neural networks, rather than forward mode?
Vanishing and exploding gradients — the price of multiplying
The chain rule multiplies one factor per layer. That is elegant, and it is also a trap, because repeated multiplication is unstable. Multiply many numbers slightly below and the product races to zero; multiply many slightly above and it races to infinity. There is no gentle middle.
A 5% annual gain over 30 years multiplies your money by 4.3. A 5% annual loss over 30 years leaves you with 21% of it. Same modest per-year factor, wildly different 30-year outcome — because the effect compounds. Gradient flow through 30 layers compounds in exactly the same way, except the "years" are layers and the compounding happens on every single training step.
Ask why any of these exist and the answer is "to hold that per-layer factor near 1.0":
• ReLU instead of sigmoid — local gradient of exactly where active, instead of at most .
• Careful initialization (Xavier, He) — weight scales chosen so each layer's factor starts near .
• Normalization layers — re-centre activations each layer so nodes don't drift into saturated regions.
• Residual connections — add a shortcut path whose local gradient is exactly , giving the gradient a clean route home no matter how deep the network is.
• Gradient clipping — a blunt safety net: if the gradient explodes anyway, cap its size before the optimizer acts on it.
Autograd — what a framework does so you don't have to
You have now hand-derived what PyTorch does automatically. Worth being precise about what it automates:
- It records the graph as you run. Every operation on a tensor with
requires_grad=Trueappends a node to a tape, capturing the operation and the values it needs for its local gradient. That's the forward pass. loss.backward()walks the tape in reverse. Each recorded node applies exactly the rule from the sticky note — upstream gradient times local gradient — accumulating results into each tensor's.grad.opt.step()is a separate matter entirely. Backprop filled in.grad; the optimizer decides what to do with it.
is neither guessing nor doing algebra on your formula. It is exactly the bookkeeping you just did by hand, done at scale and without arithmetic slips.
import torch
# Exactly our toy graph, with PyTorch tracking the gradients
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(5.0, requires_grad=True)
c = torch.tensor(-3.0, requires_grad=True)
d = a + b # the add node
e = d * c # the multiply node
e.backward() # seeds de/de = 1 and walks the tape backwards
print(a.grad, b.grad, c.grad) # tensor(-3.) tensor(-3.) tensor(7.)
# The same thing inside a real training loop
for x, y in loader:
opt.zero_grad() # clear .grad — it ACCUMULATES, remember
loss = criterion(model(x), y)
loss.backward() # <- backpropagation: fills every .grad
opt.step() # <- the optimizer: uses them- Forgetting
opt.zero_grad(). Gradients accumulate by design (that's the sum-over-paths rule). Skip the reset and you're stepping on the sum of every batch so far. - Calling
.backward()twice withoutretain_graph=True. The tape is freed after the first pass — the error message is confusing, the cause is not. - Accumulating loss for logging with
total += lossinstead ofloss.item(). That keeps the whole graph alive for every batch and quietly eats all your memory. - In-place ops (
x += 1,relu_()) on a tensor the backward pass still needs, which corrupts a stored local gradient. PyTorch usually catches this and complains. - Silently broken gradient flow — a
.detach(), atorch.no_grad(), or a non-differentiable step in the middle. The classic tell is.gradthat isNoneor all zeros for a parameter you expected to be learning.
Your model trains, the loss goes down, but one particular layer's weights never change at all. What would you check first?
Hint: Think about what has to reach a weight before the optimizer can move it.
Now build a whole network with no framework at all — forward pass, backward pass, and training loop written by hand. If you can read this cell, you understand backpropagation:
Any hand-derived gradient can be verified in three lines: nudge one parameter by a tiny (around ), re-run the forward pass, and compare against what your backward pass claimed. If they agree to several decimals, your derivation is correct. This is slow — one forward pass per parameter — so it is a debugging tool, never a training method. But when a from-scratch model won't learn, it is the fastest way to find out whether the bug is in your gradients or somewhere else entirely.
You write a custom layer's backward pass by hand, and gradient checking shows your analytic gradient is exactly half the numerical one for a particular input. What is the most likely cause?
Explain backpropagation to a friend using the factory-assembly-line picture and no symbols. Cover three things: what a local gradient is, where the very first gradient comes from, and why the answers get multiplied together rather than added. If you stall on any one of those, that is exactly the section to reread.
- Backprop answers one question: how much of the final error is each parameter's fault? It computes gradients; the optimizer is what actually changes the weights.
- A network is a computational graph of tiny operations. The forward pass computes values left to right and stores the intermediates — which is why training costs so much memory.
- Every node knows only its local gradient: how its own output responds to its own input. No node knows about the network.
- The backward pass seeds with at the loss and applies one rule at every node: upstream gradient x local gradient, handed down to each input.
- Add nodes distribute gradient, multiply nodes swap their inputs, max nodes route it all to the winner. When a value forks forward, its gradients add backward — hence
+=andopt.zero_grad(). - Backprop is reverse-mode autodiff: cost scales with outputs, and a loss is one number — so all million gradients cost about one extra forward pass.
- Multiplying one factor per layer makes deep gradients vanish or explode. ReLU, good initialization, normalization, and residual connections all exist to keep that per-layer factor near .
Practice — and how to make it stick
• Retrieval practice: before scrolling back up, try to state the backward rule for a multiply node from memory, then check. Pulling it out of your head beats rereading it, even when the recall feels uncomfortable.
• Spaced repetition: mark this topic complete to add it to your Review queue, where it resurfaces right before you would have forgotten it.
• Interleaving: mix these exercises with Calculus chain-rule problems and Optimization update-rule problems rather than grinding one type. Messier practice, sturdier memory.
- By hand, no code: for at , , draw the graph and compute and . Watch carefully what the max node does with the gradient — and note that forks into two paths, so its two contributions must be summed.
- Gradient checking: take the XOR network above and verify one entry of
dL_dW1numerically with the two-sided formula. Do the numbers match to five decimals? - Break it on purpose: in the XOR cell, change the initialization to
rng.normal(0, 20.0, ...). Training should stall completely. Explain why using the sigmoid's local gradient — then fix it by switching the hidden activation to ReLU (np.maximum(0, z), with local gradient(z > 0)). - Build micrograd: write a tiny
Valueclass that overloads+and*, records its children, and implements.backward()by walking the graph in reverse topological order. It is about 100 lines, and it is genuinely the same algorithm PyTorch runs. Karpathy's Zero to Hero walks through it step by step.
Try it right here — edit and run the code, and if you get stuck or hit an error, ask Ada on the right: she can see your code and your terminal output.
Next: stop writing backward passes by hand and let a framework do it, in PyTorch — then see what the gradients get used for in Regularization and Normalization and Initialization.