Backpropagation From Scratch: The Engine, Then the Network
Write a 120-line autodiff engine in pure Python, check it against PyTorch to sixteen decimals, and learn what zero_grad does by deleting it.
On this page
Four chapters in, there is a hole in the middle of the course.
Chapter 3 gave us gradient descent: to improve a parameter, find the slope of the loss with respect to it and step downhill. Chapter 4 gave us a loss worth descending. But in both, the derivative was computed by hand — one model, one parameter, one line of calculus, and it fit on a page.
Now stack two layers. The output of the first feeds the second, so every weight in the first affects the loss through every neuron in the second. A network with two hidden layers of a hundred units each has about twenty thousand parameters, and each one needs its own partial derivative of the same loss. Doing that by hand is not tedious; it is impossible, and it stays impossible for every architecture in the rest of this course.
The way out is not a better notation. It is the realisation that the derivative of a composition can be computed mechanically, by a program, from the structure of the computation itself — and that if you do it in the right direction, you get all twenty thousand derivatives for roughly the cost of computing the loss once.
That mechanism is reverse-mode automatic differentiation. Applied to a neural network it is called backpropagation, and by the end of this chapter you will have written one in about 120 lines of Python with no libraries, checked it against PyTorch, and used it to solve the XOR problem that killed the perceptron in Chapter 1.
First: why there has to be a nonlinearity at all
Link to the section: First: why there has to be a nonlinearity at allBefore building the machine, one question has to be settled, because if the answer went the other way there would be nothing to build.
The perceptron failed on XOR because one line cannot separate the four points. The obvious fix is to stack: run the input through one linear layer, then another. Does that help?
No, and the proof is two lines. A linear layer is . Feed it to another, , and substitute:
The composition is with and . A stack of linear layers is a single linear layer. Ten of them, a thousand of them: still one line, still unable to do XOR.
It is worth watching that happen rather than believing it:
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)
two_layers = W2 @ (W1 @ x + b1) + b2
one_layer = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))-4.612963371048 -4.612963371048 0.00e+00Not approximately equal. Bit-for-bit identical, because it is the same arithmetic rearranged.
So depth buys nothing on its own. What buys something is putting a nonlinear function between the layers — and that is the entire reason activation functions exist. They are not a biological flourish or a normalisation trick. Without one, the second layer is decoration.
The chain rule, on paper, with a shared node
Link to the section: The chain rule, on paper, with a shared nodeNow the mathematics, and it is one rule you already know applied somewhere slightly unfamiliar.
The single-variable chain rule says that if depends on and depends on , then . Derivatives multiply along a chain.
The part that matters here is what happens when a variable feeds more than one downstream path. If influences through and also through , the contributions add:
Multiply along a path, sum across paths. That is the whole of backpropagation, and every implementation detail in the rest of this chapter — including the += in the code and the zero_grad() call that trips up everyone who writes their first training loop — is a direct consequence of that second word.
Take a concrete circuit of five operations, with and :
Note that appears three times: in , in , and directly in . Do the backward pass on paper, right to left, starting from :
Through the addition
Link to the section: Through the addition, so and the direct path contributes . Addition distributes the incoming gradient unchanged to both inputs.
Through the tanh
Link to the section: Through the tanhwith , so .
Through the multiplication
Link to the section: Through the multiplication, so and . Multiplication swaps: each input's gradient is scaled by the other input.
Collect the three paths into x
Link to the section: Collect the three paths into xThrough : . Through : . Directly: .
Hold on to that number. In a few pages a program is going to produce it without being told any of this.
Building the engine
Link to the section: Building the engineThe insight that makes it programmable: every one of those steps was local. To push a gradient through the multiplication node, you needed the incoming gradient and the two stored input values — nothing about the rest of the circuit. Each operation knows how to differentiate itself.
So make a number that remembers what produced it.
class Value:
"""A number that remembers where it came from."""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _opFour fields. data is the value. grad accumulates . _prev is the set of Values this one was computed from — the edges of the graph. And _backward is a closure that each operation installs: it knows how to push this node's gradient one step back to its inputs.
Every operator follows the same shape: compute the output, record the parents, install the local rule.
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return outRead the four _backward bodies as a table and the flow patterns from the paper derivation are sitting right there:
| operation | what it does to the gradient |
|---|---|
+ | distributes — the same gradient to every input |
* | swaps — each input scaled by the other's value |
relu | routes — passes it through or blocks it entirely |
tanh | attenuates — scales by , which is at most 1 and usually less |
Every single one uses += and never =. That is the "sum across paths" rule, encoded. A node that feeds two consumers gets called twice, and the two contributions add up on their own.
Then the driver, which is the only part with any global knowledge:
def backward(self):
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order):
v._backward() build produces a topological ordering of the graph: every node appears after all of its inputs. Walking that list in reverse guarantees that when you call a node's _backward, its own gradient is already complete — every consumer downstream of it has already contributed. Get the order wrong and you push a half-finished gradient backwards, which produces a wrong answer with no error message.
Does it agree with the paper?
Link to the section: Does it agree with the paper?x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)forward: a=0.7000 b=1.9000 c=1.3300 d=0.8692 L=1.3692
backward: dL/dd=1.0000 dL/dc=0.2444 dL/da=0.4644 dL/db=0.1711
dL/dx=1.8212 dL/dy=0.40331.8212. The same number, from a program that was told the rule for +, the rule for *, the rule for tanh, and nothing about this circuit.
Two independent checks, because "it matches what I derived" is a weak test when the same person did both.
Numerical differentiation. Nudge the input and measure. The centred difference estimates the derivative without any calculus at all:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Against PyTorch, which has an industrial autodiff engine written by people who do this for a living:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Agreement at , which is machine epsilon for a 64-bit float: the two engines are performing identical arithmetic. Keep the numerical check in your pocket — it is the tool for debugging a new layer's backward pass, and it is the reason a wrong gradient is findable at all.
Saturation, measured
Link to the section: Saturation, measuredThe same circuit, different inputs. Set and , which makes :
x=0.5, y=1.4: dL/dc = 0.244400 three paths into x: 0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025 three paths into x: 0.0001 + -0.0001 + 1.0000 = 0.9999The gradient crossing the node fell by a factor of 9,945. Everything upstream of it — in a real network, every layer before it — receives essentially nothing. The two paths through the circuit have gone silent; only the direct connection that skips the still carries signal.
That is the vanishing gradient problem, in one node. Stack forty layers of and multiply forty such factors together, and the early layers stop learning entirely. It is also, incidentally, an argument for skip connections that you can see here in miniature: the path that bypassed the nonlinearity is the only one that survived.
What zero_grad actually does, and why the bug hides
Link to the section: What zero_grad actually does, and why the bug hidesEvery _backward uses +=. That is correct — it is how paths sum. But it has a consequence that catches everybody: gradients accumulate across calls to backward() too. The engine has no idea that your second call is a new training step rather than another path in the same graph.
So a training loop has to clear them:
for step in range(steps):
ys = [model(x) for x, _ in DATA]
loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.gradThis is optimizer.zero_grad() in PyTorch, and the usual advice is that forgetting it breaks training. So let us delete those two lines and see how broken it is. Same seeds, same everything, 200 steps of XOR:
| learning rate | seed | with reset | without reset |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
At the small learning rates, the buggy version wins every single row. It converges when the correct version stalls.
That is not a fluke and it is worth understanding, because it explains why this bug is so hard to catch. If you never clear the gradient, then at step the parameter is updated by the sum of every gradient computed so far. On a loss that keeps pointing roughly the same way, that sum grows steadily, and the effect is a learning rate that increases on its own. At , where the correct algorithm is crawling, the runaway step size looks exactly like a fix.
Then look at the bottom three rows. At the same mechanism blows the model apart — loss 8.0 is what a model collapsed to a constant scores — half of the 16 that four maximally wrong answers would cost — — while the correct version now converges cleanly.
So the honest statement is not "always call zero_grad or your model will not train". It is: without it you are not running gradient descent any more. You are running something whose step size drifts upward at a rate nobody chose, and it will appear to work, sometimes better than the real thing, right up until it does not — at which point you will blame the learning rate, the initialisation, or the data. This is the shape of the worst bugs in machine learning: they do not crash, they change the algorithm into a different algorithm that occasionally scores better.
The network, and XOR at last
Link to the section: The network, and XOR at lastWith the engine done, a neural network is barely any code. A neuron is a dot product, a bias and an activation; a layer is a list of neurons; a network is a list of layers.
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]There is no backward pass in any of that. Not one line. The Value class already knows how to differentiate whatever these classes happen to build, which is the point of having written it first: an autodiff engine does not know it is being used for a neural network.
Now the problem from Chapter 1. Two inputs, two hidden units, one output, nine parameters:
step 1: loss 4.156690
step 10: loss 4.005572
step 50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597
[0, 0] -> -0.9081 (target -1) ok
[0, 1] -> +0.8934 (target +1) ok
[1, 0] -> +0.8906 (target +1) ok
[1, 1] -> -0.9207 (target -1) okFour out of four. The function that no perceptron can compute — proved in Chapter 1 by four inequalities that demanded be both positive and negative — is computed by nine numbers found automatically.
What the hidden layer did
Link to the section: What the hidden layer didThe satisfying part is not that it works. It is being able to see how, because with two hidden units the intermediate representation is a point in a plane and you can just print it.
Trained to a loss of 0.001241, here is where each input lands after the hidden layer, and what the output neuron does with it:
| input | hidden layer output | output score | label |
|---|---|---|---|
Look at the first and fourth rows. The inputs and are diagonally opposite corners of the square — as far apart as two points in this problem can be — and the hidden layer maps them to and . Almost the same point. The layer has folded the plane so that the two rejected corners land on top of each other, and once they are in the same place, one line separates them from the other two.
And the output neuron is exactly that line. Its learned parameters are , , so its decision boundary is
which is a straight line — a perceptron, the same object from Chapter 1, unchanged. It could not solve XOR then and it cannot now. What changed is that it is no longer looking at the input; it is looking at a space the first layer built for it, in which the problem is linearly separable.
That is what a learned representation is, and it is worth being precise because the phrase gets used loosely for the rest of this course, and for the rest of the field. It is not a compression, a summary, or an embedding in any mystical sense. It is a change of coordinates, learned rather than designed, whose only job is to make the next layer's job easy.
The universal approximation theorem, and what it does not say
Link to the section: The universal approximation theorem, and what it does not sayThere is a theorem here, and it is usually quoted badly.
Cybenko in 1989 and Hornik in 1991 proved that a feedforward network with a single hidden layer and a suitable activation function can approximate any continuous function on a compact set, to any accuracy you like, given enough hidden units.34 It is a genuine and important result: it says the architecture is not the limitation.
Now read what it omits. It does not say how many units — the bound can be astronomically large. It does not say the weights can be found; it asserts existence, and gradient descent from a random start is not an oracle. And it says nothing about behaviour on data you have not seen, which is the second half of Chapter 6.
The gap between "exists" and "findable" is not academic. Here is the same XOR problem, 50 random initialisations each, 1000 steps, only the hidden layer size changed:
| hidden units | initialisations reaching 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
With the minimum viable architecture, one run in four never gets there — it settles into a configuration it cannot descend out of, exactly the local minimum that Chapter 3 showed on a one-dimensional surface. Add one unit and the failures nearly vanish, not because the network became more expressive (two units already suffice — 38 runs prove it) but because extra dimensions give the descent more directions to escape through.
And then eight units does slightly worse than four. At a fixed learning rate and step budget, more capacity is not monotonically better. Anyone who tells you the fix for a stuck network is always a bigger network is extrapolating from the middle of that table.
This is the same lesson as the convergence theorem in Chapter 1, and it will be the same lesson in Chapter 10 about scaling laws, in the form that chapter gives it: a prediction of the loss is not a prediction of the capability you are paying for, and the distance between the two is where the engineering lives.
Show details
Optional: the matrix form, and why the code above does not use it.
Everything here has been written one scalar at a time, which is the clearest way to see the mechanism and the slowest way to execute it. In practice a layer is a matrix multiply, and the backward pass of is
The transposes are not a trick to remember; they are what the sum-over-paths rule looks like when the paths are indexed by matrix entries. The general object is the Jacobian, the matrix of all partial derivatives of all outputs with respect to all inputs, and reverse mode is precisely the computation of a vector-Jacobian product without ever forming the Jacobian — which matters, because for a layer with 4096 inputs and 4096 outputs that matrix has sixteen million entries and is never worth building.
You do not need any of this to follow the next chapters; the scalar version does everything the matrix version does, more slowly. It becomes necessary in Chapter 9, where the shapes stop being obvious.
Where this goes next
Link to the section: Where this goes nextYou now have a network that trains. That is a smaller achievement than it feels, because the network you have trains on four examples and is measured on the same four.
Run the same code on a real dataset and a new set of problems appears, none of which is about gradients. The loss goes down for a while and then stops. Or it goes down on the training data and up on everything else. Or it does not move at all from the first step, and the cause turns out to be the range of the initial random weights. Or one unit's input drifted negative on every example in epoch three and it has been dead ever since, silently, taking a chunk of the model's capacity with it.
These are not exotic failures; they are the normal condition of a network that has just been written, and none of them announces itself. The gradient is correct — you checked it against PyTorch to sixteen decimal places — and the model still does not learn.
Chapter 6 is about that: initialisation, normalisation, overfitting and regularisation, and the diagnostic habit of asking which of those is happening before changing anything. It is the difference between a network that runs and a network that works.
Sources and method
Link to the section: Sources and methodThe Value class in this chapter descends directly from Andrej Karpathy's micrograd, and his video The spelled-out intro to neural networks and backpropagation: building micrograd is the best three hours you can spend on this material if you want it explained a second way by someone else. His 2016 post Yes you should understand backprop argues the case for writing one yourself and is assigned reading in Stanford's CS224n. The CS231n notes on backpropagation (cs231n.github.io/optimization-2) are the canonical treatment of the flow patterns tabulated above. For the mathematics as calculus on a graph rather than as neural-network folklore, chapter 5.6 of Mathematics for Machine Learning by Deisenroth, Faisal and Ong is unusually clear; and Baydin, Pearlmutter, Radul and Siskind's survey Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) is the reference for the field as a whole, including the forward/reverse trade-off discussed above.
References
Link to the section: References-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Master's thesis, University of Helsinki (1970). Reverse-mode accumulation, sixteen years before it reached this field and under a completely different motivation. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). The paper that made the method known, and the source of the reading of hidden units as learned representations that this chapter's What the hidden layer did section spends its measurements on. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Generalises Cybenko: the result depends on the activation being non-polynomial, not on it being sigmoidal. ↩