Autograd from Scratch
tf.GradientTape() looks like magic: you write arithmetic, ask for a gradient, and get
one. It is not magic — it is one small idea (record every operation, then walk the
recording backwards) plus one derivative rule per operation. This page builds the whole
thing, then checks it against TensorFlow on the same graph.
What you’ll learn
Section titled “What you’ll learn”- Why a computation graph is the data structure differentiation needs.
- Reverse-mode autodiff in 60 lines, one
_backwardclosure per operation. - Why gradients must accumulate with
+=, verified ona + aanda * a. - A gradient agreeing with
tf.GradientTapeto 0.00e+00 on the same expression. - Why reverse mode, not forward mode: one backward pass for a million parameters.
- A 9-parameter network learning XOR with no framework at all — loss 1.766 → 0.000541.
The idea: record, then replay backwards
Section titled “The idea: record, then replay backwards”The chain rule says that if depends on which depends on :
To use it you need two things at every step: the local derivative of the operation, and the derivative that has already arrived from above. So each value must remember which operation produced it and from which inputs. That is a graph:
flowchart LR
W1["w1 = 0.5"] --> M1(("x"))
X1["x1 = 1.0"] --> M1
W2["w2 = -0.3"] --> M2(("x"))
X2["x2 = 2.0"] --> M2
M1 --> A1(("+"))
M2 --> A1
B["b = 0.1"] --> A1
A1 --> Z["z = 0.0"]
Z --> T["tanh"]
T --> P["prediction = 0.0"]
P --> S(("- then squared"))
Y["target = 1.0"] --> S
S --> L["loss = 1.0"]
L -.->|"dL/dL = 1"| S
S -.->|"dL/dp = -2.0"| T
T -.->|"dL/dz = -2.0"| A1
A1 -.->|"dL/dw1 = -2.0"| M1
A1 -.->|"dL/dw2 = -4.0"| M2
Solid arrows are the forward pass, dotted arrows the backward pass. Every dotted number is the one this page computes, and every one is checkable by hand.
The engine
Section titled “The engine”Each Value holds its number, a gradient slot, its parents, and a closure that knows
how to push the gradient one step further back.
import math
class Value:
"""A scalar that remembers how it was computed, so it can be differentiated."""
def __init__(self, data, parents=(), op=""):
self.data = float(data)
self.grad = 0.0 # filled in by backward()
self._backward = lambda: None # how to pass my gradient to my parents
self._parents = tuple(parents)
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 __pow__(self, exponent):
out = Value(self.data ** exponent, (self,), f"**{exponent}")
def _backward():
self.grad += exponent * self.data ** (exponent - 1) * 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 # tanh'(z) = 1 - tanh(z)^2
out._backward = _backward
return out
def relu(self):
out = Value(max(0.0, self.data), (self,), "relu")
def _backward():
self.grad += (out.data > 0) * out.grad
out._backward = _backward
return out
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-(other if isinstance(other, Value) else Value(other)))
def __truediv__(self, other):
other = other if isinstance(other, Value) else Value(other)
return self * other ** -1
__radd__ = __add__
__rmul__ = __mul__
def backward(self):
"""Topologically sort the graph, then apply the chain rule once per node."""
order, seen = [], set()
def build(node):
if id(node) in seen:
return
seen.add(id(node))
for parent in node._parents:
build(parent)
order.append(node) # parents land before children
build(self)
self.grad = 1.0 # dL/dL = 1: the base case
for node in reversed(order): # children before parents
node._backward()
return orderThree details carry all the weight.
The topological sort. A node’s gradient is only complete once every child has contributed. Sorting the graph so parents appear before children, then walking it in reverse, guarantees that. Without the sort you get partially-summed gradients that look plausible and are wrong.
self.grad = 1.0 before the loop. . Every other
gradient in the graph is derived from that one line.
+=, never =. A value used twice receives a gradient along each path.
The smallest possible check
Section titled “The smallest possible check”at , . By hand: and .
a, b = Value(3.0), Value(4.0)
f = a * b + a
f.backward()
print(f.data, a.grad, b.grad) # 15.0 5.0 3.0Four nodes in the graph, both gradients exact. Note that appears twice and its gradient is the sum of the two paths — from the multiply, from the add.
A longer chain against calculus
Section titled “A longer chain against calculus”at . The analytic derivative is
| Value | |
|---|---|
| inner | 1.625 |
| 0.925346 | |
| autograd | 0.5390038624 |
| analytic | 0.5390038624 |
| difference | 0.00e+00 |
(Evaluated at deliberately — at the tanh saturates, both derivatives are 0.0000000000, and the “check” would be vacuous.)
One expression proves nothing about the other eight rules, so every one of them gets the same treatment:
One neuron, one step, by hand
Section titled “One neuron, one step, by hand”, , , inputs , target 1, loss :
Working backwards, , and , so . Then each weight’s gradient is that number times its input:
| Parameter | Gradient | After one step at |
|---|---|---|
| 0.5 → 0.700000 | ||
| −0.3 → 0.100000 | ||
| 0.1 → 0.300000 |
gets twice the gradient of because its input is twice as large. That is the whole reason feature scaling matters, visible in three numbers.
The same graph in TensorFlow
Section titled “The same graph in TensorFlow”import tensorflow as tf
tw1, tw2, tb = tf.Variable(0.5), tf.Variable(-0.3), tf.Variable(0.1)
with tf.GradientTape() as tape:
tz = tw1 * 1.0 + tw2 * 2.0 + tb
tloss = (tf.tanh(tz) - 1.0) ** 2
print(tape.gradient(tloss, [tw1, tw2, tb]))| Parameter | Ours | tf.GradientTape | Difference |
|---|---|---|---|
| −2.00000000 | −2.00000000 | 0.00e+00 | |
| −4.00000000 | −4.00000000 | 0.00e+00 | |
| −2.00000000 | −2.00000000 | 0.00e+00 |
Identical to every printed digit. GradientTape is doing exactly what the 60 lines
above do; it just does it on tensors, with fused kernels, and without Python’s overhead
per operation.
Why reverse mode
Section titled “Why reverse mode”There are two ways to apply the chain rule. Forward mode propagates derivatives from inputs toward the output and costs one pass per input. Reverse mode starts at the output and costs one pass per output:
| Parameters | Forward mode | Reverse mode |
|---|---|---|
| 2 | 2 passes | 1 pass |
| 100 | 100 passes | 1 pass |
| 10,000 | 10,000 passes | 1 pass |
| 1,000,000 | 1,000,000 passes | 1 pass |
Neural networks have millions of parameters and exactly one scalar loss. That asymmetry is why every framework implements reverse mode — and why the backward pass costs roughly the same as the forward pass regardless of how many parameters you have.
The table above is arithmetic. Here is the same claim measured, with both modes
implemented in the same style so the comparison is like for like — forward mode as dual
numbers, reverse mode as the Value graph above:
Gradients accumulate
Section titled “Gradients accumulate”a = Value(3.0); b = a + a; b.backward() # db/da = 2.0
a = Value(3.0); c = a * a; c.backward() # dc/da = 6.0Both are correct only because _backward uses +=. With = the second contribution
would overwrite the first and you would get 1.0 and 3.0 — wrong by exactly a factor of
two, which is the kind of bug that trains almost fine and wastes a week.
This is also why real frameworks make you zero the gradients: PyTorch’s
optimizer.zero_grad() exists because the accumulation is deliberate. Keras hides it
inside apply_gradients, and GradientTape gives you a fresh tape per context.
Train a network with nothing but this
Section titled “Train a network with nothing but this”Nine parameters, a 2-2-1 tanh network, plain gradient descent at , learning XOR — the function a single perceptron provably cannot represent:
| Epoch | Loss (sum over the 4 rows) |
|---|---|
| 1 | 1.766174 |
| 100 | 0.938014 |
| 500 | 0.729389 |
| 1000 | 0.461485 |
| 2000 | 0.001065 |
| 3000 | 0.000541 |
| Input | Prediction | Target |
|---|---|---|
| (0, 0) | +0.0003 | 0 |
| (0, 1) | +0.9824 | 1 |
| (1, 0) | +0.9848 | 1 |
| (1, 1) | +0.0001 | 0 |
Read the middle of that table rather than the ends. Nothing much happens for the first 500 epochs, the loss then falls by three orders of magnitude between epoch 1000 and epoch 2000, and it is essentially done by 2500. Nine parameters and plain gradient descent produce a plateau followed by a collapse, not a smooth curve — and the descent is not even monotonic: the loss rose on 27 of 2,999 epoch-to-epoch transitions, the largest single rise being 0.010974.
See it move
Section titled “See it move”Pitfalls
Section titled “Pitfalls”Using = instead of += in a backward closure. A value used twice then reports
only its last path’s gradient. a + a returns 1.0 instead of 2.0 — a silent factor-of-N
error wherever a tensor is reused.
Skipping the topological sort. Applying _backward in creation order propagates
gradients that are not finished yet. The result is wrong but stable, which makes it hard
to spot.
Forgetting to zero gradients between steps. Because accumulation is the point,
gradients from the previous batch survive unless you clear them. In this engine that is
for p in params: p.grad = 0.0; in PyTorch it is optimizer.zero_grad().
Assuming autodiff is numerical differentiation. It is not. There is no step size and no truncation error — each local derivative is the exact symbolic rule, evaluated at a point. That is why the checks here come out to 0.00e+00 rather than 1e-7.
Differentiating through a Python if and expecting the other branch to matter. The
graph records the path actually taken. relu’s gradient is 0 for negative inputs
because that is the branch that ran, and no gradient flows down a road not travelled.
Building this for production. Scalar Value objects allocate one Python object per
operation. That is perfect for understanding and hopeless for speed — the reason
frameworks work on whole tensors with fused kernels.
- Autodiff needs a graph: each value remembers its operation and its parents.
- The engine is one
_backwardclosure per operation, a topological sort, and . - Gradients accumulate:
a + agives 2.0,a * agives 6.0, and both require+=. - Checked against calculus (0.5390038624 both ways) and against
tf.GradientTape(0.00e+00 difference on all three parameters). - received twice ‘s gradient because its input was twice as large — the argument for feature scaling, in three numbers.
- Reverse mode costs one backward pass whether you have 2 parameters or 1,000,000 — measured at 174× cheaper than forward mode at 800 parameters, on identical machinery, with the two gradients agreeing to 1.11e-16.
- All nine derivative rules agree with a central difference to 5.96e-10 or better.
- Nine parameters and this engine learned XOR: loss 1.766174 → 0.000541, after a plateau lasting 1,000 epochs and with the loss rising on 27 of 2,999 transitions — a path a final-number-only report would have hidden.
-
Why must a backward closure use `self.grad += ...` rather than `self.grad = ...`?
This is also why frameworks make you zero gradients between steps: the accumulation is deliberate, so last batch's gradients survive unless cleared.
pch.quizShowAnswer
B — Because a value used more than once receives a gradient along each path, and they must sum — `a + a` gives 2.0 with += and an incorrect 1.0 with = — This is also why frameworks make you zero gradients between steps: the accumulation is deliberate, so last batch's gradients survive unless cleared.
-
What does the topological sort in backward() guarantee?
Without it you propagate partially-summed gradients. The result looks plausible and is wrong, which is the worst kind of bug.
pch.quizShowAnswer
B — That every node's gradient is complete before it is used — all of a node's children contribute before the node passes anything to its parents — Without it you propagate partially-summed gradients. The result looks plausible and is wrong, which is the worst kind of bug.
-
Your network has 1,000,000 parameters and one scalar loss. How many passes does reverse-mode autodiff need for all the gradients?
Forward mode would need one pass per input, which is why nobody uses it for training. The asymmetry between many parameters and one loss is exactly what reverse mode exploits.
pch.quizShowAnswer
B — One backward pass, because reverse mode costs one pass per output and there is a single loss — Forward mode would need one pass per input, which is why nobody uses it for training. The asymmetry between many parameters and one loss is exactly what reverse mode exploits.
-
In the worked neuron, w2's gradient was -4.0 while w1's was -2.0. Why?
This is the mechanism behind feature scaling: an input measured in thousands produces gradients a thousand times larger than one measured in units, so a single learning rate cannot suit both.
pch.quizShowAnswer
B — Because dL/dw = dL/dz times the input, and x2 = 2.0 was twice x1 = 1.0 — the input scale multiplies the gradient directly — This is the mechanism behind feature scaling: an input measured in thousands produces gradients a thousand times larger than one measured in units, so a single learning rate cannot suit both.
-
How does autodiff differ from numerical differentiation with a small step h?
Numerical differentiation trades truncation error against floating-point cancellation and needs one evaluation per parameter. Autodiff has neither problem, which is why gradient checks use finite differences only as an independent sanity test.
pch.quizShowAnswer
B — There is no step size — each local derivative is the exact symbolic rule evaluated at a point, which is why the checks here differ by 0.00e+00 rather than about 1e-7 — Numerical differentiation trades truncation error against floating-point cancellation and needs one evaluation per parameter. Autodiff has neither problem, which is why gradient checks use finite differences only as an independent sanity test.
You have now written the accumulation rule that PyTorch exposes as
optimiser.zero_grad(). The next page builds the same network in both frameworks and
measures how far apart their gradients actually land:
The Same Network in PyTorch.
🧪 Try It Yourself
Section titled “🧪 Try It Yourself”Exercise 1 – Write the multiply rule
Section titled “Exercise 1 – Write the multiply rule”Exercise 2 – Check a long chain against calculus
Section titled “Exercise 2 – Check a long chain against calculus”Exercise 3 – Watch gradients accumulate
Section titled “Exercise 3 – Watch gradients accumulate”Exercise 4 – Take one gradient step by hand
Section titled “Exercise 4 – Take one gradient step by hand”Exercise 5 – Check yourself against tf.GradientTape
Section titled “Exercise 5 – Check yourself against tf.GradientTape”Exercise 6 – Count the passes reverse mode saves
Section titled “Exercise 6 – Count the passes reverse mode saves”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading