Let the Computer Do the Calculus: A Tiny Autograd Engine
Chapter 4 found gradients with pen and paper. Here we teach every number to remember how it was made, so one call to backward() runs the chain rule for us, on any formula we write.
Inspect Architecture: Linear regression1. The idea
In Chapter 4 we derived ∂L/∂w = (2/N) Σ eᵢ·xᵢ for the ride model by hand. That took a page of careful algebra for a model with two knobs and one formula. Change the model, say by adding a sigmoid or a second layer, and you'd have to start the derivation again. Nobody trains real networks that way. PyTorch, JAX and every other framework find gradients automatically, and the idea behind them fits in about 60 lines of Python. We'll write those lines in this chapter.
The trick is to make numbers remember their history. Instead of a plain float, every quantity becomes a Value: an object holding its number (data), a link to the Values it was computed from, and a short function that knows its own local slope. Multiply w by 5 and the result remembers 'I am w times 5, and my slope with respect to w is 5'. Do that for every operation and the formula quietly records itself as a graph, from the knobs at one end to the loss at the other.
The chain rule then becomes a walk through that graph, backwards. Start at the loss, whose slope with respect to itself is 1. Visit each Value in reverse order, and let it pass its slope down to its parents, each one multiplied by the local slope. When the walk reaches the knobs, each knob holds ∂L/∂knob. For the 5 km ride at w = 3, b = 5, the engine reports ∂L/∂w = −10 and ∂L/∂b = −2. For all four rides it reports −4.5 and −0.5, the numbers Chapter 4 derived by hand, to the last digit.
This is backpropagation, and it's also called reverse-mode automatic differentiation. It's not an approximation like Chapter 4's nudging: the answers are exact, and one backward walk gives the slope for every knob at once, whether there are 2 of them or 2 billion. The engine follows the design of Andrej Karpathy's micrograd (MIT licence), rewritten for this course and run on our own examples.
2. The math
v.data = the number itself (computed going forward)
v.grad = ∂L/∂v (computed going backward, starts at 0)out = op(a, b)
a.grad += out.grad · ∂out/∂a b.grad += out.grad · ∂out/∂ba + b: ∂/∂a = 1, ∂/∂b = 1
a · b: ∂/∂a = b, ∂/∂b = a
aⁿ: n·aⁿ⁻¹ eᵃ: eᵃ (= out.data) ln a: 1/aa − b = a + (−1)·b a / b = a · b⁻¹ σ(z) = 1 / (1 + e^(−z))
check at z = 0.8: the engine's ∂σ/∂z = 0.2139 = σ(1 − σ), Chapter 4's formulaforward: w·x = 3·5 = 15 pred = 15 + 5 = 20 e = 20 − 21 = −1 L = e² = 1
backward: L.grad = 1 e.grad = 2e · 1 = −2 pred.grad = −2 (w·x).grad = −2 b.grad = −2
w.grad = x · (w·x).grad = 5 · (−2) = −10if a feeds several children c₁, c₂, …: ∂L/∂a = Σₖ (∂L/∂cₖ) · (∂cₖ/∂a)
f = a · a at a = 3: both inputs of × are a, each path gives 3·1 = 3, total 6 = 2atopological order: every Value appears after all the Values it was computed from
backward pass: walk that list in reverse, calling each Value's _backward() onceL = (1/4) Σᵢ (w·xᵢ + b − yᵢ)² at (3, 5): L = 0.75
backward(): ∂L/∂w = −4.5 ∂L/∂b = −0.5 central differences: −4.5, −0.5z = 2.0·0.7 + (−3.0)·0.2 + (−1.0)·0.5 + 0.5 = 0.8 p = σ(z) = 0.690
y = 1 ℓ = −ln p = 0.371
backward(): ∂ℓ/∂z = −0.310 = p − y
∂ℓ/∂wᵢ = (p − y)·xᵢ = [−0.217, −0.062, −0.155] ∂ℓ/∂b = −0.310central differences: 2 loss evaluations per knob → 2·P evaluations for P knobs
backward pass: 1 forward + 1 backward, each touching every Value once ≈ 2–3 × one evaluation
P = 1,000,000: 2,000,000 evaluations vs about 3• Suppose a feeds two children, c₁ = g₁(a) and c₂ = g₂(a), and the loss depends on both: L = F(c₁, c₂).
• Nudge a by a small h. Each child moves by about its local slope times h: c₁ → c₁ + g₁′(a)·h and c₂ → c₂ + g₂′(a)·h.
• The loss responds to each child's move separately, to first order: ΔL ≈ (∂L/∂c₁)·g₁′(a)·h + (∂L/∂c₂)·g₂′(a)·h. Cross terms involve h², which vanishes much faster than h.
• Divide by h and let it shrink: ∂L/∂a = (∂L/∂c₁)·(∂c₁/∂a) + (∂L/∂c₂)·(∂c₂/∂a). With more children the sum just gets longer.
• Each term is exactly what one child's _backward() adds to a.grad. Starting a.grad at 0 and letting each child do +=, the sum builds up term by term. ∎ For f = a·a both 'children' are the same × node, and its two contributions 3 + 3 give the 2a = 6 we expect.
• Claim: when the walk reaches a Value v, v.grad already equals ∂L/∂v. Then v's _backward() passes correct contributions to its parents.
• The first Value visited is L itself. Its grad is set to 1, and ∂L/∂L = 1, so the claim holds.
• Take any later v. Every child of v (a Value computed from v) comes after v in topological order, so it comes before v in the reversed walk. Each child has therefore already run its _backward(), adding (∂L/∂child)·(∂child/∂v) to v.grad, and by the claim its own grad was correct when it did.
• No other code touches v.grad, so it now holds the sum over all children, which is ∂L/∂v by the first derivation. The claim holds for v too, and by induction for every Value. ∎
• The cost follows from the same picture: each Value runs _backward() exactly once, and each call does a fixed amount of work per parent. The backward pass therefore costs about as much as the forward pass, however many knobs ask for a slope.
3. How it works
4. The code (python)
# Chapter 5: a tiny autograd engine. Every number remembers how it was made,
# so the chain rule can be run backwards automatically.
# Same design as Andrej Karpathy's micrograd (MIT licence), written fresh for this course.
import math
class Value:
"""One number in a calculation, plus the slope of the final loss with respect to it."""
def __init__(self, data, parents=(), op=""):
self.data = float(data)
self.grad = 0.0 # dL/d(this value), filled in by backward()
self._parents = parents # the Values this one was computed from
self._op = op # for printing only
self._backward = lambda: None # pushes self.grad down to the parents
def __repr__(self):
return f"Value(data={self.data:.4g}, grad={self.grad:.4g})"
# --- each operation computes its result AND records its local slopes -------
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward(): # d(a + b)/da = 1, d(a + b)/db = 1
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(): # d(a·b)/da = b, d(a·b)/db = a
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def __pow__(self, n): # n is a plain number, not a Value
out = Value(self.data ** n, (self,), f"**{n}")
def _backward(): # d(aⁿ)/da = n·aⁿ⁻¹
self.grad += n * self.data ** (n - 1) * out.grad
out._backward = _backward
return out
def exp(self):
out = Value(math.exp(self.data), (self,), "exp")
def _backward(): # d(eᵃ)/da = eᵃ, which is out.data
self.grad += out.data * out.grad
out._backward = _backward
return out
def log(self):
out = Value(math.log(self.data), (self,), "log")
def _backward(): # d(ln a)/da = 1/a
self.grad += out.grad / self.data
out._backward = _backward
return out
# --- everything else is built from the operations above --------------------
def __neg__(self): return self * -1
def __sub__(self, other): return self + (-other)
def __truediv__(self, other): return self * other ** -1
def __radd__(self, other): return self + other # lets you write 2 + v
def __rmul__(self, other): return self * other # and 2 * v
def __rsub__(self, other): return Value(other) - self
def __rtruediv__(self, other): return Value(other) / self
def sigmoid(self): return 1 / (1 + (-self).exp())
# --- the backward pass -------------------------------------------------------
def backward(self):
"""Fill in .grad for every Value this one depends on."""
order, seen = [], set()
def visit(v): # depth-first: parents before children
if v not in seen:
seen.add(v)
for p in v._parents:
visit(p)
order.append(v)
visit(self)
self.grad = 1.0 # dL/dL = 1
for v in reversed(order): # children before parents
v._backward()
# === 1. One ride, by hand in Chapter 4, now automatic ===========================
w, b = Value(3.0), Value(5.0)
x, y = 5, 21 # the 5 km ride that took 21 minutes
pred = w * x + b
e = pred - y
L = e ** 2
L.backward()
print("one ride: pred", pred.data, " error", e.data, " loss", L.data)
print(" dL/dw =", w.grad, " dL/db =", b.grad)
# === 2. All four rides: the same numbers Chapter 4 derived ======================
rides_km = [2, 5, 8, 12]
rides_min = [11, 21, 28, 42]
w, b = Value(3.0), Value(5.0)
L = sum((w * xi + b - yi) ** 2 for xi, yi in zip(rides_km, rides_min)) / len(rides_km)
L.backward()
print("\nfour rides: loss", L.data, " dL/dw =", w.grad, " dL/db =", b.grad)
# === 3. Gradient check against central differences ==============================
def mse(wv, bv):
return sum((wv * xi + bv - yi) ** 2 for xi, yi in zip(rides_km, rides_min)) / 4
h = 1e-5
num_w = (mse(3 + h, 5) - mse(3 - h, 5)) / (2 * h)
num_b = (mse(3, 5 + h) - mse(3, 5 - h)) / (2 * h)
print("numeric: dL/dw =", round(num_w, 6), " dL/db =", round(num_b, 6))
# === 4. Why the gradients use += : a value used twice ===========================
a = Value(3.0)
f = a * a # f = a², so df/da should be 2a = 6
f.backward()
print("\na*a at a=3: grad", a.grad, "(a node with two paths to the output adds both)")
# === 5. The picnic neuron from Chapters 2 and 3, with cross-entropy =============
xs = [0.7, 0.2, 0.5]
ws = [Value(2.0), Value(-3.0), Value(-1.0)]
bias = Value(0.5)
z = sum((wi * xi for wi, xi in zip(ws, xs)), bias)
p = z.sigmoid()
label = 1 # the picnic went well
loss = -(label * p.log() + (1 - label) * (1 - p).log())
loss.backward()
print("\npicnic: z", round(z.data, 4), " p", round(p.data, 4), " loss", round(loss.data, 4))
print(" dloss/dz =", round(z.grad, 4), " (p - y =", round(p.data - label, 4), ")")
print(" dloss/dw =", [round(wi.grad, 4) for wi in ws], " dloss/db =", round(bias.grad, 4))
# === 6. How big is the graph? ===================================================
def count(v, seen=None):
seen = set() if seen is None else seen
if v not in seen:
seen.add(v)
for q in v._parents:
count(q, seen)
return len(seen)
print("\nnodes in the picnic graph:", count(loss), " in the four-ride graph:", count(L))
5. Practice
Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.
6. Go further
- Add tanh as a sixth primitive with its own _backward (its local slope is 1 − tanh²). Then build tanh a second way from the existing operations, (e²ᶻ − 1) / (e²ᶻ + 1), and check that both give the same grad at z = 0.5. Which one builds the smaller graph?
- Call L.backward() twice on the 8 km ride from the practice problems, without resetting anything, and print w.grad. You might expect 32 (double 16). What do you get, and why? Then write a zero_grad() that walks the graph and sets every grad to 0.
- Use the engine to train: wrap w and b, then repeat 'compute the four-ride MSE, zero the grads, backward(), step against the gradient with η = 0.01' for 2,000 steps. Compare where you end up with Chapter 4's third exercise. You have now built, by yourself, the inner loop of every deep learning framework.
7. Check yourself
In the catalog
Finished this chapter?