dy/dx = dy/du · du/dx One scalar loss flows backward through the graph, picking up a local rate at every node, until all million weights know which way to move. That flow is backpropagation.
A dollar becomes euros at one rate, euros become rupees at another, and the two rates multiply. A composite function is the same chain: dy/dx = f′(g(x)) · g′(x), where each factor is one link's local slope evaluated at the point the chain passes through.
The forward pass computes values and caches them. The backward pass starts at the loss with gradient 1 and multiplies by each node's local rate on the way back, so the value at every point is charged exactly what it contributed.
grad += local_rate × upstream_grad03 / ONE PASS, EVERY GRADIENT
Millions of inputs, one output.
A network has millions of weights as inputs and a single loss as output. Reverse mode seeds that one loss with 1 and reaches every weight in one backward pass — roughly the cost of a second forward pass. Forward mode would need a pass per weight.
1 backward pass → all N gradients
MENTAL MODEL IN ONE SENTENCE
Never differentiate the whole composition at once: at each node, compute one local rate, multiply it by the gradient arriving from above, and pass the results on. Seed the loss with 1, walk the graph in reverse, and every weight gets its answer in a single pass.
By the end you will be able to read a computational graph, run a backward pass by hand, explain why PyTorch accumulates with +=, pick a sane step size for gradient checking, and read the 60-line autograd engine like code you wrote yourself.
01
MULTIPLY THE RATES
A chain of rates multiplies.
If y depends on u and u depends on x, then a nudge to x moves u, and that movement in u moves y. The two effects multiply — that is the whole chain rule.
Write the composition as y = f(g(x)). The inner function g turns x into an intermediate value u. The outer function f turns u into y. Each link has its own rate: g′(x) is how much u changes per unit of x, and f′(u) is how much y changes per unit of u.
Plain English: change x by a little; that change is scaled by g′ on the way to u; then it is scaled again by f′ on the way to y. Multiply the two scale factors. Deeper chains simply multiply more factors — a ten-layer network is a ten-link chain.
y = f(g(x)) x --g--> u --f--> y
dy/dx = dy/du · du/dx
= f′(u) · g′(x) evaluated at u = g(x)
deeper: y = f(g(h(x)))
dy/dx = f′(g(h(x))) · g′(h(x)) · h′(x)
Two rates, one product
Drag the point and watch the inner slope, the outer slope and the composite slope move together. The composite always equals their product.
The product is exact; the numeric line is a finite-difference estimate of the same number. Agreement means the two rates really do multiply.
Derivation: why the rates multiply
Let δx be a tiny change in x. The inner function turns it into a tiny change in u, scaled by its local rate:
δu ≈ g′(x) · δx
then the outer function turns that into a change in y:
δy ≈ f′(u) · δu ≈ f′(u) · g′(x) · δx
Divide both sides by δx and let it shrink toward zero. The approximations become exact and the product survives as the derivative. (This is the informal teaching version; a real analysis course makes the limit argument carry the error terms along.)
Notice that the outer rate is evaluated at u, not at x. Getting that point wrong is the classic chain-rule mistake: you need f′(g(x)), the outer slope where the chain actually is.
02
COMPUTATIONAL GRAPHS
Write the expression as a graph.
A computational graph turns a formula into a little factory: raw inputs on the left, one operation per station, one output on the right. Building it is what makes the backward pass mechanical.
Every operation becomes a node; every value handed from one operation to the next travels along an edge. The rule is only that the graph flows one way: no cycles, ever. Data moves forward, left to right, and the values computed along the way are kept.
Take the smallest honest neural-network expression: y = relu(x₁·x₂ + b). With x₁ = 2, x₂ = 3 and b = 1, the forward pass is three steps. Multiply first, then add, then bend:
Backpropagation, one node at a time
The graph for y = relu(x₁·x₂ + b). Change the inputs, then press Step backward repeatedly: forward values in ink, gradients in orange, arrows lighting up right to left.
forward: a = 2.0·3.0 = 6.0
c = 6.0 + 1.0 = 7.0
y = relu(7.0) = 7.0
backward: step 0 of 4. Forward pass done. The values are cached — the backward pass needs them.
Set b to −10 so the pre-activation goes negative: ReLU outputs 0, its local slope becomes 0, and every gradient upstream dies. That is a dead ReLU.
The forward pass, written out
a = x₁ · x₂ = 2 · 3 = 6
c = a + b = 6 + 1 = 7
y = relu(c) = max(0, 7) = 7
what gets cached for later:
a = 6, c = 7, and the fact that c > 0
(relu needs to know which side of zero it was on)
x₁ = 2, x₂ = 3 — the multiply needs the other factor
That caching is not an implementation detail; it is the reason the backward pass can be cheap. Every local derivative is evaluated at values the forward pass already computed, so no function is ever re-evaluated. One forward pass, one backward pass, all the gradients.
03
THE BACKWARD PASS
One local rate at every node.
Start at the output with a gradient of 1, then walk the graph right to left. At each node: multiply the gradient arriving from above by that node’s own local derivative. That is the entire algorithm.
The seed is dy/dy = 1: a tiny nudge to y changes y by exactly that much. From there the rule never changes. An add node hands the gradient unchanged to both of its inputs. A multiply node hands each input the other input’s value, because the other factor is the local rate. A relu node either passes the gradient through (input positive) or kills it (input non-positive).
Gradients only flow to a node once every consumer of that node has pushed its contribution — the dependency order. A depth-first topological sort produces exactly that order, so walking the sorted list in reverse guarantees each node’s gradient is complete before it is read.
Operation
Local derivative
Why
out = a + b
∂out/∂a = 1, ∂out/∂b = 1
raise an input by δ and the sum rises by δ
out = a · b
∂out/∂a = b, ∂out/∂b = a
the other factor is the exchange rate
out = aⁿ
n · aⁿ⁻¹
power rule (Lesson 4)
out = 1 / a
−1 / a²
power rule with n = −1; how division gets a gradient
out = eᵃ
eᵃ
the defining property of e; reuse the forward value
out = ln a
1 / a
the inverse of exp (Lesson 4)
out = relu(a)
1 if a > 0, else 0
a line of slope 1, or flat
out = tanh(a)
1 − tanh²a = 1 − out²
quotient rule, or trust the source; reuse out
Worked backward pass, every number written out
forward: a = x₁·x₂ = 2·3 = 6 c = a + b = 6 + 1 = 7 y = relu(7) = 7
backward (start at the output, seed dy/dy = 1):
dy/dc = dy/dy · d relu(c)/dc = 1 · 1 = 1 (c = 7 > 0, so relu's slope is 1)
dy/da = dy/dc · dc/da = 1 · 1 = 1 (c = a + b, slope w.r.t. a is 1)
dy/db = dy/dc · dc/db = 1 · 1 = 1
dy/dx₁ = dy/da · da/dx₁ = 1 · x₂ = 3 (a = x₁·x₂, slope w.r.t. x₁ is x₂)
dy/dx₂ = dy/da · da/dx₂ = 1 · x₁ = 2
meaning: nudge x₁ up by 0.01 and y rises by about 0.03; nudge b and y rises by 0.01.
numeric check: x₁ = 2.01 → a = 6.03 → c = 7.03 → y = 7.03
Δy/Δx₁ = 0.03 / 0.01 = 3 ✓
Every line has the same shape: (gradient from above) × (local derivative). Nothing else ever happens in backpropagation. With millions of nodes the bookkeeping grows, but the arithmetic does not.
Gradient accumulation and dependency order are the two details that make a toy autograd correct on graphs with shared inputs — which is every real network.
The autodiff console
One fixed expression, f = tanh(a·b + c), and the engine built from two operations. Edit the leaves, then walk the backward pass one node at a time and watch every gradient assemble.
a2.0000leafgrad .
×
b-3.0000leafgrad .
=
p = a · b-6.0000multiplygrad .
+
c10.0000leafgrad .
=
q = p + c4.0000addgrad .
→
f = tanh(q)0.9993activationgrad .
The add node hands the same gradient to both of its inputs; the multiply node hands each input the other input’s value. Nothing else happens, ever.
f = tanh(a·b + c) with a=2.00, b=-3.00, c=10.00
forward: p = a·b = -6.000
q = p + c = 4.000
f = tanh(q) = 0.999329
backward step 0/4:
df/df = 1
df/dq = …
df/dp, df/dc = …
Forward pass: p = a·b, q = p + c, f = tanh(q). Values cached.
04
FORWARD VS REVERSE
Two directions for the same chain rule.
You can carry derivatives forward from the inputs or pull gradients backward from the output. Neither is wrong; one is dramatically cheaper for the shape of problem training actually is.
Forward mode seeds one input with derivative 1 and carries derivatives through the graph alongside the values. One pass gives you the derivative of every output with respect to that single input. Reverse mode seeds the output with 1 and pulls gradients back. One pass gives you the derivative of that single output with respect to every input.
Mode
Seed
Direction
One pass gives
Best when
Forward mode
dxᵢ/dxᵢ = 1 for one input
input → output
every output's derivative with respect to one input
few inputs, many outputs
Reverse mode
dy/dy = 1 at the output
output → input
one output's derivative with respect to every input
many inputs, few outputs — neural networks
With many inputs and one output, reverse mode wins by a factor equal to the number of parameters. PyTorch, TensorFlow and JAX all default to it for training.
Why reverse mode costs about one forward pass
Each node’s backward step does a fixed small amount of work — one multiply-add per input. The backward pass visits every node exactly once. So its total cost is proportional to the number of operations in the forward pass, no matter how many inputs there are.
one forward + one backward → all N gradients, ≈ 2–3× forward cost
finite differences: 2N forward passes (perturb each weight, twice)
forward-mode autodiff: N passes (seed each weight)
reverse-mode autodiff: 1 backward pass (seed the loss once)
For N in the millions or billions, that factor is the difference between training being possible and not. Forward mode is not useless — it is the better tool when a function has few inputs and many outputs, and it is elegant to implement with dual numbers — but that is not the shape of a training loss.
05
ROUTES ADD UP
A gradient is a sum over every route.
Real graphs are not chains — they are webs. One value often feeds several operations, so its gradient arrives along several paths. The total is their sum.
If x is used in two places, a nudge to x changes the loss through both of them. Each route contributes its own product of local rates, and the multivariable chain rule says the total is the sum of the contributions. In the little graph below, L = x·y + x·z, so dL/dx = y + z.
This is not an edge case. Weight tying uses the same matrix twice; recurrent networks reuse a weight at every time step; attention reuses projections. Anywhere a value has more than one consumer, the gradient is a sum.
A junction adds every incoming gradient
x feeds two operations, so gradient reaches it along two routes. Step backward and watch the total assemble with += — then simulate the = bug.
forward: p = x·y = 2.00·3.00 = 6.00
q = x·z = 2.00·5.00 = 10.00
L = p + q = 16.00
backward step 0/4:
numeric check: slope of L as x moves = 8.0000
PyTorch’s .grad accumulates the same way, which is why optimizers call zero_grad() before each backward pass — otherwise yesterday’s routes add to today’s.
Derivation: the multivariable chain rule is a sum of route products
L = x·y + x·z with x = 2, y = 3, z = 5
route 1, through p = x·y:
dL/dp = 1 and ∂p/∂x = y = 3 → contribution 1 · 3 = 3
route 2, through q = x·z:
dL/dq = 1 and ∂q/∂x = z = 5 → contribution 1 · 5 = 5
dL/dx = 3 + 5 = 8
numeric check:
L(2.001) − L(1.999) = 16.008 − 15.992 = 0.016
slope = 0.016 / 0.002 = 8.000 ✓
Each route ends with its own += into x’s gradient. Run the routes in any order; the sum assembles itself. That is why the order in which backward closures fire is not delicate — only the rule that every consumer fires before the shared value is read (topological order) matters.
06
HOW YOU KNOW IT'S RIGHT
Trust, but check numerically.
Autodiff is exact arithmetic, but your backward rule can still be wrong. The central difference from Lesson 4 is the referee: an independent estimate that does not use your backward code at all.
The central difference estimates a derivative by evaluating the function on both sides of the point and measuring the slope of the secant: (f(x+h) − f(x−h)) / (2h). It is slower and only approximate, but it knows nothing about your graph, your closures or your topological sort — which is exactly what makes it a good test.
Run both, compare, and treat agreement to roughly 1e−6 or better as a pass. Do it when you add a new operation to an autograd engine, and first when a training loop refuses to converge. Never in production: it costs two extra forward passes per parameter.
Central differences: pick h well
Autodiff gives the exact slope; the numerical estimate is a secant. Slide h and watch the error curve: too big is truncation, too small is cancellation.
autodiff: 0.15252426
central diff: 0.15252426
|difference|: 2.80e-11
h = 1.0e-5
Balanced: truncation and roundoff are both small. This h is trustworthy.
Use this check when you add a new operation to your engine or when a training loop will not converge. Never in production: it costs two extra forward passes per parameter.
Where the error comes from, and how to pick h
Write out the two evaluations with Taylor series — the function plus its derivatives at x:
f(x+h) = f(x) + f′(x)h + f″(x)h²/2 + f‴(x)h³/6 + …
f(x−h) = f(x) − f′(x)h + f″(x)h²/2 − f‴(x)h³/6 + …
subtract: f(x+h) − f(x−h) = 2f′(x)h + f‴(x)h³/3 + …
divide 2h: = f′(x) + f‴(x)h²/6 + …
truncation error ≈ f‴(x) · h² / 6 shrinks as h shrinks
roundoff error ≈ ε · |f(x)| / h grows as h shrinks (ε ≈ 2.2e−16)
balance the two: h ≈ ε^(1/3) ≈ 6e−6
in practice: h = 1e−5 to 1e−7
The even powers cancel when you subtract — that is why the central difference beats the one-sided (f(x+h) − f(x))/h, whose error shrinks only like h. But no choice of h removes roundoff entirely: at h = 1e−12 the values f(x+h) and f(x−h) differ in their last couple of digits, and the subtraction leaves mostly noise.
Gradient checking, exactly as the engine tests itselfpython
Numbers verified for the expression in the lab: autodiff 0.15252426, central difference 0.15252426, agreement to 2.8e−11.
07
BUILD THE ENGINE
Sixty lines that learn.
Three ingredients: wrap every number, record every operation, walk the recorded graph in reverse. That is a complete autodiff engine — the same design PyTorch uses, scaled up.
Wrap. A Value stores its number, its gradient (start at 0), a pointer to its child nodes, and a _backward closure. Record. Every arithmetic operation creates a new Value and fills in a closure that knows that operation’s local derivative. Walk.backward() topologically sorts the graph, seeds the output with 1, and calls each closure in reverse order.
The whole engine — Pythonpython
class Value:
def __init__(self, data, children=(), op=""):
self.data = float(data)
self.grad = 0.0# dL/d(this), filled by backward()
self._backward = lambda: None# how to push my grad to my children
self._prev = set(children)
self._op = op
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 # d(a+b)/da = 1
other.grad += out.grad # d(a+b)/db = 1
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 # d(ab)/da = b
other.grad += self.data * out.grad # d(ab)/db = a
out._backward = _backward
return out
def relu(self):
out = Value(max(0, self.data), (self,), "relu")
def _backward():
self.grad += (1.0if out.data > 0else0.0) * out.grad
out._backward = _backward
return out
def backward(self):
topo, seen = [], set()
def build(v):
if v notin seen:
seen.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0# seed: dL/dL = 1for v in reversed(topo): # reverse topological order
v._backward()
x1, x2 = Value(2.0), Value(3.0)
y = (x1 * x2 + 1.0).relu()
y.backward()
print(x1.grad, x2.grad) # 3.0 2.0 — matches PyTorch
add, multiply, relu, backward: every line of the chain rule in one place. The += is the accumulation from chapter 05.
One neuron as a graph. Every leaf is a Value with its own .grad; one call to loss.backward() fills all of them, and the update rule nudges each leaf against its gradient.
The four beats of trainingpython
model = MLP([2, 4, 1])
xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1] # XOR, in tanh's rangefor step in range(100):
loss = sum((model(x) - y) ** 2for x, y in zip(xs, ys))
for p in model.parameters():
p.grad = 0.0# zero_grad, or yesterday adds in
loss.backward() # one call fills every gradientfor p in model.parameters():
p.data -= 0.05 * p.grad # nudge every weight downhill
Forward, loss, zero the gradients, backward, nudge. A [2, 4, 1] MLP trained on XOR with nothing but Values and the chain rule.
This is micrograd: a complete neural network training loop in pure Python. Every commercial deep learning framework does the same thing at massive scale — tensors instead of scalars, compiled kernels instead of closures, millions of nodes instead of dozens.
08
CHECK YOURSELF
Five questions. Then the terms worth keeping.
Answer before you look. The reverse-mode and accumulation questions are the ones that explain why real training code is written the way it is.
0 / 5 answered · 0 correct
01What does the chain rule state?
02What is a computational graph?
03Why does PyTorch use reverse-mode autodiff instead of forward mode?
04Why does the backward function use '+=' instead of '='?
05What is gradient checking and when should you use it?
Key terms, demystified
Click a card to swap the lazy description for what it actually means.
Exercises from the lesson
Four short problems. Try first; a worked answer is one click away.
Add __pow__ to the Value class so you can compute x ** n. Verify that d(x³)/dx at x = 2 equals 12.Show one worked answer
The op creates out = Value(self.data ** n, (self,), '**n'), and its backward closure is self.grad += n * self.data ** (n - 1) * out.grad. At x = 2 with n = 3: 3 · 2² = 12. Numeric check with central differences: ((2.001)³ − (1.999)³) / (2·0.001) = (8.012006001 − 7.988005999) / 0.002 = 12.000001. The power rule and the engine agree.
Add tanh as an activation function. Verify that tanh'(0) = 1 and tanh'(2) ≈ 0.0707.Show one worked answer
Forward: t = math.tanh(self.data); out = Value(t, (self,), 'tanh'). Backward: self.grad += (1 - t ** 2) * out.grad — reuse the forward output t, no second tanh call. At 0: tanh(0) = 0 so the slope is 1 − 0² = 1. At 2: tanh(2) ≈ 0.96403, so the slope is 1 − 0.92935 ≈ 0.07065, the stated 0.0707. A quick central difference on tanh at 2 also gives ≈ 0.07065.
Build a computation graph for a single neuron y = relu(w₁x₁ + w₂x₂ + b). Compute all five gradients and verify against PyTorch.Show one worked answer
Pick w₁ = 1, w₂ = 2, x₁ = 0.5, x₂ = −1, b = 3. Forward: z = 0.5 − 2 + 3 = 1.5, and since z > 0, y = relu(1.5) = 1.5 with relu' = 1. Backward: dy/dw₁ = x₁ = 0.5, dy/dw₂ = x₂ = −1, dy/db = 1, dy/dx₁ = w₁ = 1, dy/dx₂ = w₂ = 2. Check by nudging w₂ up 0.001: z = 1.499, y = 1.499, so Δy/Δw₂ = −0.001/0.001 = −1 ✓. In PyTorch the same five numbers land in w1.grad, w2.grad, b.grad, x1.grad, x2.grad after y.backward().
Implement forward-mode autodiff using dual numbers. Create a Dual class and verify it gives the same derivatives as your reverse-mode engine.Show one worked answer
A Dual is a pair (value, derivative) with rules (a, a') + (b, b') = (a + b, a' + b'), (a, a') · (b, b') = (a·b, a'·b + a·b'), and tanh(a, a') = (tanh(a), (1 − tanh²a)·a'). Seed the input as (x, 1) and read the derivative off the output. For f(x) = tanh(x³ + 2x + 1) at x = 0.5: x³ = (0.125, 0.75), 2x = (1, 2), the sum is (2.125, 2.75), and tanh gives (0.97187, (1 − 0.97187²)·2.75) = (0.97187, 0.15252). Reverse mode gives the same 0.15252 — one pass per input here, versus one pass for all inputs there.
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
derivative — The slope of a function at a point: how much the output changes per tiny change of the input. (Lesson 4)
gradient — The list of derivatives of the loss with respect to every parameter. Training steps opposite to it. (Lesson 4)
loss — One number measuring how wrong the model's predictions are. The single output reverse mode starts from. (Lesson 4)
tensor — An n-dimensional array of numbers: a vector is 1-D, a matrix 2-D, a batch of images 4-D. PyTorch's basic object. (Lesson 12)
PyTorch — A deep learning framework: arrays with automatic differentiation built in. Its autograd is the industrial version of the engine built here. (outside these lessons)
GPU — Graphics Processing Unit — hardware built for massively parallel arithmetic, which is what backprop is. (outside these lessons)
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text adapted from AI Engineering from Scratch (Phase 01, Lesson 05) and the Math Foundations Notebook reference build. Interactive figures, the animated backward-pass hero, worked exercise answers and the autodiff console are original to this page. Every lab runs in your browser.