Build an autograd engine
Write backprop yourself in ~100 lines (micrograd).
Create a free account Try part of it here
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
- A number that remembers its history
- Idea 1: each operation knows its own slope
- Idea 2: gradients add up
- Idea 3: work backward, in the right order
- Enough operations to train a network
- Worked example: the engine's bookkeeping, by hand
- 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 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, . Raise by 1 and goes up by , which is 3. Raise by 1 and goes up by , which is 2. Raise by 1 and 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.
w, x, b = Value(4.0), Value(5.0), Value(2.0) y = w * x + b y.backward() print(w.grad)
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:
In plain words:
out.gradsays 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 in , with and :
- The add node passes gradient through unchanged. So the multiply node's
out.gradis 1. - For a multiply, the local derivative for is the other input, .
- So 's gradient goes up by .
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, is an operation's first input. is its second input. The number is a fixed power, like 2 or 3. And is the output of tanh, the S-shaped activation from earlier.
| Operation | Output | Local derivative for each input |
|---|---|---|
| add | 1 and 1 | |
| multiply | and (the other input) | |
| power | ||
| tanh |
In plain words: a power node's slope is the power , times the input raised to the power . For at , that's . 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.
t = 0.5 out_grad = 2.0 print((1 - t * t) * out_grad)
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.
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:
- 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.
- 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.
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. is , and is .
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: . The second has 4 neurons with 4 inputs: . The output neuron has 4 inputs: . That's .
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 callszero_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.
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: the engine's bookkeeping, by hand
Take , with and . Notice that 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)
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
Try a quiz question
In what order does backward() call the _backward functions?