Knowledge BaseFundamentals

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.

intermediate#autograd#chain-rule

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.

One number in, millions of numbers out

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.

Think of it like a factory tracing back a defective product:

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.

How to read this page

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.

Backprop is not the optimizer

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 .

Think of it like a recipe written as a flowchart:

"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:

d=a+b,e=d×cd = a + b, \qquad e = d \times c
The forward pass, by hand

Set a=2a = 2, b=5b = 5, c=3c = -3. Now walk left to right, doing one step at a time and writing down what each step produced:

  1. The add node. d=a+b=2+5=7d = a + b = 2 + 5 = 7.
  2. The multiply node. e=d×c=7×(3)=21e = d \times c = 7 \times (-3) = -21.

That's the whole forward pass: e=21e = -21. Notice we wrote down the intermediate value d=7d = 7 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.

The computational graph for e = (a + b) x c— interactive, drag & zoom
Loading chart…
Forward pass, left to right (black): each node computes its value from the values wired into it. Backward pass, right to left (orange): each node receives a gradient and hands one back to each of its inputs. Same wires, opposite directions. Drag to pan, scroll to zoom.
Try to recall

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 .

Tiny, ignorant, and that's the point

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.

Think of it like a chain of currency booths:

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 — 0.9×160=1440.9 \times 160 = 144 — appears on its own, just by multiplying the local rates. Backprop is that walk, done backwards.

The local gradients of our two nodes

The add node, d=a+bd = a + b, with a=2a = 2, b=5b = 5:

  • Push aa up by 0.0010.001: dd goes from 77 to 7.0017.001. It moved by exactly the same amount, so the local gradient is 11.
  • Same for bb: local gradient 11.
  • So an add node's local gradients are 11 and 11always, regardless of the input values.

The multiply node, e=d×ce = d \times c, with d=7d = 7, c=3c = -3:

  • Push dd up by 0.0010.001: ee goes from 21-21 to 7.001×(3)=21.0037.001 \times (-3) = -21.003. It moved 0.003-0.003, which is 3-3 times the nudge. Local gradient: 3-3 — which is exactly cc, the other input.
  • Push cc up by 0.0010.001: ee goes to 7×(2.999)=20.9937 \times (-2.999) = -20.993, a move of +0.007+0.007. Local gradient: 77 — which is dd, again the other input.
  • So a multiply node's local gradients are the values it was multiplied by. It swaps them.
Three gates, three personalities (worth memorizing)

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 11.
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

Activation Functions— interactive, try itOpen in lab →
f(x) — GELUf′(x) — derivative
Switch between activations and watch the derivative curve underneath — that curve IS the local gradient this node will hand backward. Notice how sigmoid and tanh flatten to nearly zero at the edges: a node sitting out there multiplies the incoming gradient by almost nothing and effectively cuts the wire. ReLU's derivative is a clean 1 or a hard 0 — pass or block.
nodes in a real network work the same way, just with a curvier local gradient.

Try to recall

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 ee to ee? Change ee by one and ee changes by one, so we seed the whole process with e/e=1\partial e/\partial e = 1.

The backward pass, by hand

Working right to left through our graph, with the local gradients we just computed:

  1. Seed the output. ee=1\dfrac{\partial e}{\partial e} = 1.
  2. Through the multiply node. Incoming gradient is 11.
    • To dd: 1×(local c)=1×(3)=31 \times (\text{local } c) = 1 \times (-3) = \mathbf{-3}.
    • To cc: 1×(local d)=1×7=71 \times (\text{local } d) = 1 \times 7 = \mathbf{7}.
  3. Through the add node. Incoming gradient is the 3-3 that just arrived at dd.
    • To aa: 3×1=3-3 \times 1 = \mathbf{-3}.
    • To bb: 3×1=3-3 \times 1 = \mathbf{-3}.

Done. Every input now has its gradient: e/a=3\partial e/\partial a = -3, e/b=3\partial e/\partial b = -3, e/c=7\partial e/\partial c = 7. Those are the orange numbers in the graph above.

Check one by brute force. Nudge aa from 22 to 2.0012.001. Then d=7.001d = 7.001 and e=7.001×(3)=21.003e = 7.001 \times (-3) = -21.003. The output fell by 0.0030.003 for a nudge of 0.0010.001 — a rate of 3-3. 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.

Read the sign, then the size

e/a=3\partial e/\partial a = -3 says two things. The minus says increasing aa decreases the output — so if you wanted the output up, you'd move aa down. The 3 says the effect is three times as strong as the nudge. In training, the loss plays the role of ee: 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 aa, bb, or cc and watch how the gradients move — the multiply node's gradients depend on the values, so they change every time:

Python · runs in your browser
What this does: Runs the forward pass through e = (a+b)*c storing each intermediate value, then runs the backward pass right to left, multiplying each incoming gradient by that node's local gradient. It finishes by re-deriving each gradient with brute-force numerical nudging, so you can see the two agree to several decimal places.

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.

z=wx+b,y^=σ(z),L=(y^y)2z = wx + b, \qquad \hat{y} = \sigma(z), \qquad L = (\hat{y} - y)^2

In words, left to right: multiply the input by the weight and add the bias; squash the result into the range 00 to 11 with the ; then score how far the prediction landed from the truth by squaring the miss.

One neuron, forward then backward

Take w=0.5w = 0.5, x=2x = 2, b=1b = 1, and a target of y=0y = 0.

Forward:

  1. z=0.5×2+1=2.0z = 0.5 \times 2 + 1 = 2.0
  2. y^=σ(2.0)0.8808\hat{y} = \sigma(2.0) \approx 0.8808
  3. L=(0.88080)20.7758L = (0.8808 - 0)^2 \approx 0.7758

Badly wrong — we wanted 00 and predicted 0.880.88.

Backward, seeding with L/L=1\partial L/\partial L = 1 and walking back through each node:

  1. Through the squared loss. Local gradient of (y^y)2(\hat{y}-y)^2 with respect to y^\hat{y} is 2(y^y)=2×0.88081.76162(\hat{y}-y) = 2 \times 0.8808 \approx 1.7616. So L/y^1.7616\partial L/\partial \hat{y} \approx 1.7616.
  2. Through the sigmoid. Its local gradient is the tidy y^(1y^)=0.8808×0.11920.1050\hat{y}(1-\hat{y}) = 0.8808 \times 0.1192 \approx 0.1050. Multiply: L/z1.7616×0.10500.1850\partial L/\partial z \approx 1.7616 \times 0.1050 \approx 0.1850.
  3. Through the multiply-and-add. The bias is added, so it distributes: L/b0.1850\partial L/\partial b \approx 0.1850. The weight is multiplied by xx, so it swaps: L/w0.1850×2=0.3699\partial L/\partial w \approx 0.1850 \times 2 = 0.3699.

Both gradients are positive, meaning raising ww or bb would raise the loss — so gradient descent will lower both, pushing the prediction down toward the target of 00. Exactly right.

Spot the villain: 0.1050

Look at step 2. The sigmoid multiplied the gradient by roughly one tenth on its way through — and z=2z = 2 isn't even far out on the curve. At z=6z = 6 that factor drops below 0.00250.0025. Stack ten such layers and the gradient reaching the first one has been multiplied by something like 101010^{-10}: 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 0.880.88 up to 0.9990.999, 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.

What to look for

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 0.250.25, no matter what.

Python · runs in your browser
What this does: Runs a single sigmoid neuron forward on w=0.5, x=2, b=1 with target 0, then walks the gradient back through the loss, the sigmoid, and the linear step, printing the gradient at every stage. Change b to 6.0 and rerun: the prediction gets WORSE (0.88 to 0.999) while every gradient shrinks about a hundredfold — that is saturation, and it is what stops deep sigmoid networks from training.
Try to recall

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.

Passes needed to get every gradient: forward mode vs reverse mode— interactive, drag & zoom
Loading chart…
Forward-mode autodiff needs one sweep per parameter, so its cost tracks model size. Reverse mode (backpropagation) needs one sweep per output — and a loss is a single number, so it is always exactly one, at any scale. Note the log axis: the gap at a billion parameters is a factor of a billion.

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 11 and the product races to zero; multiply many slightly above 11 and it races to infinity. There is no gentle middle.

Think of it like compound interest, running in reverse:

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.

Gradient magnitude reaching layer 1, as depth grows— interactive, drag & zoom
Loading chart…
Each curve assumes every layer multiplies the gradient by a constant factor. At 0.6 per layer the gradient vanishes into numerical noise by layer 25; at 1.5 it explodes by four orders of magnitude; only a factor near 1.0 survives the trip. Keeping that per-layer factor near 1 is the entire job of careful initialization, normalization layers, and residual connections. Log scale on the vertical axis.
Everything in the modern training toolkit is about this one plot

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 11 where active, instead of at most 0.250.25.
Careful initialization (Xavier, He) — weight scales chosen so each layer's factor starts near 11.
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 11, 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:

  1. It records the graph as you run. Every operation on a tensor with requires_grad=True appends a node to a tape, capturing the operation and the values it needs for its local gradient. That's the forward pass.
  2. 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.
  3. 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.

Python · needs a GPU — run on Colab
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
The five autograd bugs you will actually hit
  • 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 without retain_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 += loss instead of loss.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(), a torch.no_grad(), or a non-differentiable step in the middle. The classic tell is .grad that is None or all zeros for a parameter you expected to be learning.
Try to recall

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:

Python · runs in your browser
What this does: Trains a 2-layer neural network on the XOR problem using nothing but NumPy — no PyTorch, no autograd. The backward pass is written out by hand using exactly the rules from this lesson: the loss node, the sigmoid's local gradient, and the matrix forms for a linear layer. It prints the loss falling and the final predictions landing near the correct 0/1 targets, then plots the loss curve.
Gradient checking — how to know your backward pass is right

Any hand-derived gradient can be verified in three lines: nudge one parameter by a tiny hh (around 10510^{-5}), re-run the forward pass, and compare (L(w+h)L(wh))/2h(L(w+h) - L(w-h)) / 2h 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 it yourself

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.

Recap — the key ideas
  • 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 11 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 += and opt.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 11.

Practice — and how to make it stick

Learn it the way that actually works


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.

  1. By hand, no code: for f(x,y)=(x+y)×max(x,y)f(x, y) = (x + y) \times \max(x, y) at x=3x = 3, y=1y = 1, draw the graph and compute f/x\partial f/\partial x and f/y\partial f/\partial y. Watch carefully what the max node does with the gradient — and note that xx forks into two paths, so its two contributions must be summed.
  2. Gradient checking: take the XOR network above and verify one entry of dL_dW1 numerically with the two-sided formula. Do the numbers match to five decimals?
  3. 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)).
  4. Build micrograd: write a tiny Value class 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.

Practice lab
Your task: Finish the backward pass for f(x, y) = (x + y) * max(x, y). The forward pass and the numerical check are already written. Fill in the four TODO lines using the rules from this lesson — add distributes, multiply swaps, max routes everything to the winner and nothing to the loser — and remember that x feeds BOTH nodes, so its two gradient contributions must be added together. Your printed gradients should match the measured ones. Bonus: swap to x = 1.0, y = 3.0 and confirm the max node now sends its gradient the other way.
editor
terminal
Press Run (⌘/Ctrl+Enter) to execute.
Ask Ada — she can read your terminal

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.

Key papers