Index Ai Academy
Phase 4 · Deep learning · Neural networks

Build an autograd engine

Write backprop yourself in ~100 lines (micrograd).

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

In the last lesson you wrote the backward pass by hand. That works, but it doesn't scale. Every new layer means new working-out and new chances for bugs. Automatic differentiation, or autograd, does that work for you. It records each operation as your code runs. Then it replays each step's local derivative backward, from the loss to every weight. PyTorch's autograd is the same idea, applied to whole arrays of numbers. A small version takes about 100 lines of Python. Andrej Karpathy's "micrograd" is the famous one. Building your own removes the last bit of mystery from how networks learn.

What you'll be able to do

  • Build a Value class that records how each number was computed
  • Give each operation a small backward function that applies its local derivative
  • Run backward() over the recorded graph in reverse topological order
  • Train a tiny neural network using nothing but your own engine

What it covers

  1. A number that remembers its history
  2. Idea 1: each operation knows its own slope
  3. Idea 2: gradients add up
  4. Idea 3: work backward, in the right order
  5. Enough operations to train a network
  6. Worked example: the engine's bookkeeping, by hand
  7. From scalars to tensors

A number that remembers its history

Think of a cell in a spreadsheet. Click it and you see its formula: which other cells it was built from. A plain Python number can't do that. Once you write y = w * x + b, y is 7 and nothing more. It has forgotten where it came from.

So you wrap each number in a small object called a Value. A Value keeps four things:

  • data, the number itself.
  • grad, its gradient. This says how much the final answer moves when this number moves. It starts at 0.
  • _prev, the values it was made from.
  • _backward, a small function that pushes gradient back to those values.

Two pieces of Python make this work. First, Python runs a method called __add__ when you write a + b with a Value on the left. It runs __mul__ when you write a * b. So the class gets to decide what + and * do. Here, each one makes a new Value and records its inputs.

Second, each of those methods builds a tiny function, _backward, inside itself. A function made inside another function remembers the variables around it. This is called a closure. So _backward still knows self, other and out long after __add__ has finished.

The line other = other if isinstance(other, Value) else Value(other) handles plain numbers. If other isn't a Value yet, it gets wrapped in one. And lambda: None is a function that does nothing. A number you type in yourself has no inputs, so it has nothing to push back to.

class Value:
    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._prev = _children
        self._op = _op
        self._backward = lambda: None

    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          # local derivative of + is 1 for both inputs
            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     # each input's slope is the other input
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

    def backward(self):
        order, seen = [], set()
        def visit(v):                      # topological order: inputs before outputs
            if v not in seen:
                seen.add(v)
                for child in v._prev:
                    visit(child)
                order.append(v)
        visit(self)
        self.grad = 1.0
        for v in reversed(order):          # outputs first: each pushes gradient to its inputs
            v._backward()

    def __repr__(self):
        return f"Value(data={self.data}, grad={self.grad})"

w, x, b = Value(2.0), Value(3.0), Value(1.0)
y = w * x + b
y.backward()
print(y)
print("dy/dw =", w.grad, "  dy/dx =", x.grad, "  dy/db =", b.grad)
It runs right here in your browser, with nothing to install.

It prints Value(data=7.0, grad=1.0), then the three gradients: 3.0, 2.0 and 1.0. (__repr__ sets how a Value looks when you print it.)

Check them by hand. Forward, y=2×3+1=7y = 2 \times 3 + 1 = 7. Raise ww by 1 and yy goes up by xx, which is 3. Raise xx by 1 and yy goes up by ww, which is 2. Raise bb by 1 and yy goes up by 1. The engine found all three without you writing a single derivative.

Three ideas carry the whole engine. The next three sections take them one at a time.

Quick check
Using the Value class above, what does this print?
w, x, b = Value(4.0), Value(5.0), Value(2.0)
y = w * x + b
y.backward()
print(w.grad)
  1. 1.0
  2. 5.0
  3. 22.0
  4. 4.0
Show the answer

5.0

For a multiply, each input's gradient is the other input. So w.grad is x, which is 5.0. The add node passes 1 through, so nothing else changes it. 22.0 is y's data (4 × 5 + 2), not a gradient.

Idea 1: each operation knows its own slope

Every operation builds its output's _backward. That function knows one thing: the operation's local derivative. This is how much the operation's output moves when one input moves by 1. You met local derivatives in the computation-graph lesson. Every _backward follows the same rule:

input.grad+=local derivative×out.grad\text{input.grad} \mathrel{+}= \text{local derivative} \times \text{out.grad}

In plain words: out.grad says how much the final answer cares about this output. The local derivative says how much the output moves when the input moves. Multiply them, and you get how much the final answer cares about the input. Add that to the input's running total.

This is the chain rule from Phase 2, one link at a time. Here's the rule for ww in y=w⋅x+by = w \cdot x + b, with w=2w = 2 and x=3x = 3:

  1. The add node passes gradient through unchanged. So the multiply node's out.grad is 1.
  2. For a multiply, the local derivative for ww is the other input, x=3x = 3.
  3. So ww's gradient goes up by 3×1=33 \times 1 = 3.
x = 3.0          # local derivative of w * x with respect to w
out_grad = 1.0   # how much y moves when w * x moves
w_grad = 0.0
w_grad += x * out_grad
print(w_grad)

It prints 3.0, the same as the engine. Here are the local derivatives the engine uses. In the table, aa is an operation's first input. bb is its second input. The number kk is a fixed power, like 2 or 3. And tt is the output of tanh, the S-shaped activation from earlier.

OperationOutputLocal derivative for each input
adda+ba + b1 and 1
multiplya⋅ba \cdot bbb and aa (the other input)
poweraka^kk⋅ak−1k \cdot a^{k-1}
tanht=tanh⁡(a)t = \tanh(a)1−t21 - t^2

In plain words: a power node's slope is the power kk, times the input raised to the power k−1k - 1. For a2a^2 at a=3a = 3, that's 2×3=62 \times 3 = 6. Tanh's slope is 1 when its output is 0. It shrinks toward 0 as the output nears +1 or −1.

Notice what autograd does not do. It never writes out a formula for the whole calculation, the way you might in algebra. It only works with numbers: each step's local rule, applied to the values you actually used.

Quick check
A tanh node gave the output t = 0.5. The gradient arriving at its output is 2.0. Using the rule, what does this print?
t = 0.5
out_grad = 2.0
print((1 - t * t) * out_grad)
  1. 1.5
  2. 1.0
  3. 2.0
  4. 0.75
Show the answer

1.5

The local derivative is 1 − 0.5² = 0.75. Multiply by the arriving gradient: 0.75 × 2.0 = 1.5. That's what gets added to the input's grad. 0.75 forgets to multiply by out.grad.

Idea 2: gradients add up

Every _backward uses +=, never =. Here's why.

Picture a café where milk goes into both the latte and the cappuccino. If milk gets pricier, your costs rise through both drinks. The total change is the two changes added together. A Value used in several places is the same. It affects the answer along several paths, so its gradient must collect every one.

With =, each new path would overwrite the last one. You'd keep only one contribution and lose the rest. With +=, they all add up. This is the rule from the computation-graph lesson, now built into the code.

Quick check
Why does the engine use self.grad += ... rather than self.grad = ... in each _backward?
  1. Python requires it inside closures
  2. To make the gradients larger
  3. A value used in several places must collect the gradient from every use
  4. It's faster
Show the answer

A value used in several places must collect the gradient from every use

With =, the last use would overwrite the others. With +=, all the contributions add up.

Idea 3: work backward, in the right order

A node must collect all of its gradient before it passes any of it on. Think of a team captain reporting votes. The captain must hear from every team member before sending the total up the line. Report too early, and the late votes are lost.

The backward() method makes sure this happens. It works in two steps:

  1. It lists every node in topological order. That's an order where each node comes after the nodes it was made from. You met it in the computation-graph lesson.
  2. It reverses that list, so the final answer comes first. Then it calls each node's _backward.

Before the sweep, it sets the output's own gradient to 1. Why 1? If the answer moves by 1, then the answer has moved by 1. So its gradient with respect to itself is always 1.

In reverse order, every node that uses a value runs before that value does. So each value's gradient is complete by the time it passes gradient further back.

Go deeper How visit() builds the order

visit is a function that calls itself. When it reaches a node, it first visits each of the node's inputs. Only after all of them are done does it add the node to the list. So every node lands in the list after its inputs.

The seen set stops a node from being added twice. That matters when one value is used in several places. For y = w * x + b, the list ends up as w, x, the product, b, then y. Reversed, y runs first, then b, then the product, which finally passes gradient to w and x.

Try it now

This is all of backpropagation: one small rule per operation, and the right order.

Enough operations to train a network

Add a few more operations and you can build a whole network out of Values:

  • power (__pow__), so you can square an error;
  • tanh, the activation;
  • negation and subtraction, built from what's already there. −a-a is a×−1a \times -1, and a−ba - b is a+(−b)a + (-b).

Next come two small classes you'll recognize. A Neuron holds one weight per input and a bias. It adds up weight times input, adds the bias, then applies tanh. An MLP stacks layers of neurons. The __call__ method lets you use one like a function: model(x).

MLP([3, 4, 4, 1]) means 3 inputs, two hidden layers of 4 neurons, and 1 output. The network learns to output +1 or −1 for four small examples. The loss adds up the squared misses. Each step then does four things: work out the loss, reset every gradient to 0, run backward(), and move each parameter a little downhill. The step size, 0.05, is the learning rate.

import math
import random

class Value:
    def __init__(self, data, _children=()):
        self.data, self.grad, self._prev, self._backward = data, 0.0, _children, lambda: None

    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 __pow__(self, k):
        out = Value(self.data ** k, (self,))
        def _backward():
            self.grad += k * self.data ** (k - 1) * out.grad
        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,))
        def _backward():
            self.grad += (1 - t * t) * out.grad
        out._backward = _backward
        return out

    __radd__ = __add__
    __rmul__ = __mul__
    def __neg__(self): return self * -1
    def __sub__(self, other): return self + (-other)

    def backward(self):
        order, seen = [], set()
        def visit(v):
            if v not in seen:
                seen.add(v)
                for c in v._prev:
                    visit(c)
                order.append(v)
        visit(self)
        self.grad = 1.0
        for v in reversed(order):
            v._backward()

class Neuron:
    def __init__(self, n_in):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(n_in)]
        self.b = Value(0.0)
    def __call__(self, x):
        return sum((wi * xi for wi, xi in zip(self.w, x)), self.b).tanh()
    def parameters(self):
        return self.w + [self.b]

class MLP:
    def __init__(self, sizes):
        self.layers = [[Neuron(a) for _ in range(b)] for a, b in zip(sizes, sizes[1:])]
    def __call__(self, x):
        for layer in self.layers:
            x = [n(x) for n in layer]
        return x[0] if len(x) == 1 else x
    def parameters(self):
        return [p for layer in self.layers for n in layer for p in n.parameters()]

random.seed(1)
model = MLP([3, 4, 4, 1])
xs = [[2.0, 3.0, -1.0], [3.0, -1.0, 0.5], [0.5, 1.0, 1.0], [1.0, 1.0, -1.0]]
ys = [1.0, -1.0, -1.0, 1.0]
print("parameters:", len(model.parameters()))

for step in range(40):
    preds = [model(x) for x in xs]
    loss = sum((p - y) ** 2 for p, y in zip(preds, ys))
    for p in model.parameters():
        p.grad = 0.0                         # gradients accumulate, so reset them first
    loss.backward()
    for p in model.parameters():
        p.data -= 0.05 * p.grad              # step downhill
    if step % 10 == 0 or step == 39:
        print(f"step {step:>2}  loss {loss.data:.4f}  predictions {[round(p.data, 2) for p in preds]}")

random.seed(1) fixes the starting weights, so you get the same run every time.

First it prints parameters: 41. Count them layer by layer. Each neuron has one weight per input, plus a bias. The first layer has 4 neurons with 3 inputs each: 4×(3+1)=164 \times (3 + 1) = 16. The second has 4 neurons with 4 inputs: 4×(4+1)=204 \times (4 + 1) = 20. The output neuron has 4 inputs: 1×(4+1)=51 \times (4 + 1) = 5. That's 16+20+5=4116 + 20 + 5 = 41.

Then watch the loss. It starts at 4.5167 and falls to 0.0202 by step 39. The predictions start near random, at 0.03, −0.71, 0.41 and −0.23. They end at 0.94, −0.92, −0.93 and 0.93, close to the targets of +1, −1, −1 and +1. Every gradient came from your own engine.

Watch out: the reset to zero before each backward() matters. Gradients add up with +=. Skip the reset, and this step's gradients pile on top of the last step's. PyTorch behaves the same way. That's why every PyTorch training loop calls zero_grad().

Go deeper Why the class needs __radd__ and __rmul__

Python's sum() starts from 0 unless you give it a start value. So the loss line works out 0 + Value(...) first. The number is on the left, so Python can't use Value.__add__. It falls back to the right-hand object's __radd__. Setting __radd__ = __add__ tells Python that adding works the same in either order. __rmul__ does the same for a line like 2 * v.

Quick check
How many parameters does MLP([2, 3, 1]) have? (Each neuron has one weight per input, plus a bias.)
  1. 6
  2. 13
  3. 9
  4. 11
Show the answer

13

The first layer has 3 neurons with 2 inputs each: 3 × (2 + 1) = 9. The output neuron has 3 inputs: 1 × (3 + 1) = 4. Total 9 + 4 = 13. 9 counts only the first layer.

Worked example

Worked example: the engine's bookkeeping, by hand

Take f=a⋅b+af = a \cdot b + a, with a=3a = 3 and b=−2b = -2. Notice that aa is used twice. Do what the engine does, one step at a time. Go forward first, then backward from the output, using += whenever a value gets a second share.

a, b = 3.0, -2.0

# forward
p = a * b        # multiply: -6.0
f = p + a        # add: -3.0

# backward, output first
f_grad = 1.0
p_grad = f_grad * 1          # add passes gradient straight through
a_grad = p_grad * b          # multiply: the other input
b_grad = p_grad * a
a_grad += f_grad * 1         # a is used again in the add, so add its share
print(f, a_grad, b_grad)
In the lesson you can change this and run it.

It prints -3.0 -1.0 3.0. Forward: p = 3 × (−2) = −6, then f = −6 + 3 = −3. Backward: the output starts with gradient 1. The add node passes 1 to both p and a. The multiply node sends p's gradient to a, scaled by the other input: 1 × (−2) = −2. It sends 1 × 3 = 3 to b. Then a collects its second share, 1, from the add: −2 + 1 = −1. Calculus agrees. The slope of ab + a with respect to a is b + 1 = −1, and with respect to b it's a = 3. The engine does this same bookkeeping for you, for any calculation.

From scalars to tensors

A scalar is a single number. This engine handles one scalar per Value. A network with millions of weights would build millions of tiny nodes, which is far too slow.

PyTorch works with tensors, its name for whole arrays of numbers. It makes one node per array operation, such as a whole matrix multiply. The backward rules from the backpropagation lesson are written once, in fast code. Everything else is the same as your engine: record the graph as the code runs, give each operation a local backward function, add gradients with +=, and sweep from the output back.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

In what order does backward() call the _backward functions?

  1. Reverse topological order: from the output back toward the inputs
  2. The order the values were created: from inputs to the output
  3. Alphabetical order, by each value's label in the graph
  4. Random order, which is reshuffled on each backward pass

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson