Learn machine learning from scratch

Start at chapter 1 and build up: what a model is, how it measures its own mistakes, and how it learns. Everything is worked out in plain Python, with the maths shown rather than assumed. Two shorter tracks hold reference notes on the maths and on modern architectures.

Track Progress: 0 Completed
0%
Core CourseLEVEL 2 · INTERMEDIATEChapter 5

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.

45 min Prerequisites: Chapter 4 (derivatives and the chain rule); Python classes at a 'what is self' level
Inspect Architecture: Linear regression

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

Computation graph for one ride, 5 km in 21 minutes, at w = 3 and b = 5. w (3) and the constant x (5) feed a multiply box, w·x = 15. That and b (5) feed an add box, pred = 20. That and the constant y (21) feed a subtract box, e = −1, which feeds a square box, L = 1. Every box except the constants also shows its gradient: L 1, e −2, pred −2, w·x −2, b −2 and w −10.Computation graph for one ride, 5 km in 21 minutes, at w = 3 and b = 5. w (3) and the constant x (5) feed a multiply box, w·x = 15. That and b (5) feed an add box, pred = 20. That and the constant y (21) feed a subtract box, e = −1, which feeds a square box, L = 1. Every box except the constants also shows its gradient: L 1, e −2, pred −2, w·x −2, b −2 and w −10.
One ride as a graph of Values. The forward pass fills in data from left to right; backward() fills in grad from right to left, and w's grad of −10 is what Chapter 4's formula 2·e·x gives.

2. The math

A Value carries two numbers
v.data = the number itself (computed going forward) v.grad = ∂L/∂v (computed going backward, starts at 0)
L is the final output, usually the loss. A Value's grad answers Chapter 4's question for that one number: nudge it by h and L moves by about grad × h.
The one rule every operation follows
out = op(a, b) a.grad += out.grad · ∂out/∂a b.grad += out.grad · ∂out/∂b
out.grad is the slope arriving from above (∂L/∂out). The operation multiplies it by its own local slope and passes it to its parents. This is the chain rule, applied one link at a time.
Local slopes of the five primitive operations
a + b: ∂/∂a = 1, ∂/∂b = 1 a · b: ∂/∂a = b, ∂/∂b = a aⁿ: n·aⁿ⁻¹ eᵃ: eᵃ (= out.data) ln a: 1/a
Addition passes the incoming slope through unchanged. Multiplication hands each side the other side's value. These five, plus the chain rule, cover every loss in this course.
Everything else is built from the primitives, with no new calculus
a − 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 formula
Write subtraction, division and the sigmoid in terms of +, ×, power and exp, and their gradients come out right automatically. Fewer hand-written slopes means fewer places for bugs.
Worked example: one ride, backwards
forward: 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) = −10
Each step uses only the slope arriving from above and the box's own local slope. The result matches Chapter 4's 2·e·x = 2·(−1)·5 = −10 for this single ride.
Shared values add their gradients
if 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 = 2a
That's why the code writes grad += and never grad =. With =, the second path would overwrite the first and the engine would report 3. The first derivation shows why the paths add.
Order matters: children before parents
topological order: every Value appears after all the Values it was computed from backward pass: walk that list in reverse, calling each Value's _backward() once
A Value may only push its grad to its parents once its own grad is complete, meaning every child has already reported in. The reversed topological order guarantees it (second derivation).
All four rides: the engine agrees with Chapter 4
L = (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.5
The same Values w and b appear in all four ride terms, so their grads collect four contributions each, the Σ in Chapter 4's formula, added up by the += rule.
The picnic neuron: p − y, without deriving it
z = 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.310
Chapter 4 needed a derivation to show the sigmoid and cross-entropy collapse to p − y. The engine finds the same number by multiplying local slopes, with no algebra and no special case.
Why this scales: one backward pass for all the knobs
central 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
The cost of the backward pass grows with the size of the graph, not with the number of knobs you want slopes for. That's what makes training large networks possible at all.

• 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

1
Wrap the knobs
Turn every parameter into a Value. Plain numbers like the data can stay plain: the operations wrap them automatically when they meet a Value.
2
Write the formula normally
Compute the prediction and the loss with ordinary + − × / and **. Behind the scenes, every operation builds a new Value that remembers its parents and its local slope.
3
Call backward() on the loss
It sorts the graph so children come before parents, sets the loss's grad to 1 and walks the list, each Value pushing its grad down with +=.
4
Read the gradients
Every knob's .grad now holds ∂L/∂knob. Check them once against central differences, as in Chapter 4, whenever you add a new operation.
5
Reset before the next round
Grads accumulate, so before computing the next loss, set every knob's grad back to 0, or build fresh Values. Forgetting this is one of the most common training bugs.

4. The code (python)

core_ch5.py
# 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.

P1 Run one ride through the engine: x = 8 km, y = 28 minutes, at w = 3, b = 5. What is w.grad?
Forward: pred = 29, e = 1, L = 1. Backward: e.grad = 2e, and w.grad = x · e.grad.
Solution. e.grad = 2·1 = 2, pred.grad = 2, (w·x).grad = 2, and w.grad = 8 · 2 = 16. The ride took less time than predicted, so the slope is positive: lowering w would help this ride.
P2 A multiply node computes out = a · b with a = 4 and b = −2, and out.grad = 3 arrives from above. What does it add to a.grad?
∂(a·b)/∂a = b.
Solution. a.grad += b · out.grad = (−2) · 3 = −6. Multiplication hands each input the other input's value.
P3 f = a · a + a with a = 3. What does backward() leave in a.grad?
a reaches f along three paths: two through the multiply node and one straight into the add node.
Solution. The × node gives 3 + 3 = 6, and the + node passes 1 through, so 6 + 1 = 7. That matches f′(a) = 2a + 1 = 7.
P4 Someone writes self.grad = other.data * out.grad (instead of +=) in __mul__, and likewise for other. For f = a · a at a = 3, what would a.grad be after backward()?
Both lines write to the same object, a. The second assignment overwrites the first.
Solution. Both assignments set a.grad to 3 · 1 = 3, and the second overwrites the first, so a.grad = 3 instead of 6. A gradient check would show a relative error of 0.33.
P5 A log node computes out = ln a with a = 4, and out.grad = 2. What does it add to a.grad?
d(ln a)/da = 1/a.
Solution. 2 · (1/4) = 0.5.
P6 Division is built as a · b⁻¹. For d = a / b with a = 6, b = 2, what is b.grad after d.backward()?
The power node gives b⁻¹ the local slope −1·b⁻², and the multiply node hands b⁻¹ the value of a.
Solution. (b⁻¹).grad = a · 1 = 6, then b.grad = 6 · (−1 · 2⁻²) = 6 · (−0.25) = −1.5. That's −a/b², the quotient rule, without ever writing it down.
P7 For the picnic neuron (x = [0.7, 0.2, 0.5], p = 0.690, y = 1), what is ∂ℓ/∂w₁, the grad of the first weight? (3 decimal places.)
z.grad = p − y, and a weight's grad is z.grad times its input.
Solution. z.grad = 0.690 − 1 = −0.310, and w₁.grad = 0.7 · (−0.310) = −0.217.
P8 A model has 1,000,000 knobs. How many loss evaluations does a central-difference gradient need?
Two per knob: one nudged up, one nudged down.
Solution. 2 × 1,000,000 = 2,000,000. One forward pass plus one backward pass costs about the same as 2 or 3 of those evaluations.

6. Go further

  1. 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?
  2. 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.
  3. 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

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 What does a Value's .grad hold after loss.backward()?
Q2/5 A + node receives out.grad = −2. What does it pass to each of its two inputs?
Q3/5 Why does the code use grad += instead of grad = ?
Q4/5 Why must backward() visit Values in reversed topological order?
Q5/5 For a model with a million knobs, how does backpropagation compare with central differences?

Finished this chapter?