DL S012 · Micrograd — Build a Tiny Autograd Engine
Rebuild Karpathy's micrograd from scratch — a ~100-line scalar autograd engine that captures the mathematical core of PyTorch. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.
🎯 Rebuild Andrej Karpathy's micrograd from first principles — a Value class supporting +, *, **, tanh, exp, and backward() in ~100 lines of pure Python — then train a 3-4-4-1 MLP on 4 points using the engine you wrote. Leave the session able to read PyTorch's autograd C++ source and see the same graph.
Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 12 / 80 · Module M02 · ~2.5 hours
0 · The story — the 94 lines of Python that made autograd click for a generation
In 2020, in the middle of the pandemic, Andrej Karpathy — then director of AI at Tesla, now one of the most-viewed AI educators alive — sat in his home office and recorded a 2 hour and 25 minute YouTube lecture called "The spelled-out intro to neural networks and backpropagation: building micrograd." Zero slides. Just a Jupyter notebook. He types every line. He gets confused at one point around the topological sort and leaves the confusion in the video. He arrives, minute by minute, at a Value class of about 94 lines of pure Python that implements automatic differentiation for scalar arithmetic — plus a 30-line nn.py neural network module on top.
That video, as of 2025, has ~2 million views. It is — quietly, without corporate sponsorship — the single most influential piece of deep-learning education on the internet. Every serious ML curriculum in 2024–2025 either includes it or is downstream of someone who watched it. Karpathy's follow-up series (makemore, nanoGPT, build GPT from scratch, LLM.c) all descend from the same "just build it, in real time, and read it out loud" style.
Why does micrograd matter more than its 94 lines suggest? Because it is architecturally identical to PyTorch's autograd, JAX's grad, TensorFlow 2's GradientTape, and tinygrad's Function. Same computation graph, same topological sort, same backward closures, same accumulate-with-+= pattern. Every one of those production frameworks is engineering added around the ideas in micrograd: replace scalars with tensors (100× more math per op), replace Python closures with fused CUDA kernels (10,000× faster per op), add distributed all-reduce (thousand-GPU training), add mixed precision (2× throughput), add compile-time optimisation (torch.compile, jax.jit). The IDEAS are all in the 100 lines.
Today you rewrite those lines from scratch. Not copy them. Type them, understand every design choice, extend with a couple of ops you'll need, and train an MLP using your own engine. By the end you'll be able to open PyTorch's autograd/ or tinygrad's Function class and immediately see the four moving parts you're about to build.
- Implement a `Value` class with `__add__`, `__mul__`, and `backward()` from scratch.
- Explain the role of the `_backward` closure and why it must be attached at op time.
- Write and defend the topological-sort implementation used in `backward()`.
- Extend the engine with tanh, exp, and __pow__ ops.
- Wire a small MLP class on top of Value and train it on a classification toy dataset.
- Explain the difference between micrograd and a real framework in three bullet points.
Prerequisites
- Session 004 — we built micrograd's ancestor there. This is the full version.
- Session 010 — you've done backprop by hand; today we automate it.
1 · The Value class — the atom
A Value wraps a scalar (a Python float) plus:
- its gradient (a float, accumulates)
- pointers to the
Values it was created from (its_prev) - a
_backwardfunction that knows how to distribute its gradient to those parents
class Value:
def __init__(self, data, _children=(), _op=""):
self.data = float(data)
self.grad = 0.0
self._prev = set(_children)
self._op = _op
self._backward = lambda: None # default no-op for leaves
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"That's a leaf Value. Creating a = Value(3.0) gives you a node with no children, no op, and a null backward. Its grad starts at 0.
2 · Ops — the _backward closure trick
Here's the crux. Every operation creates a NEW Value (the output). That new Value gets a _backward closure that captures the operands. When backward() runs, this closure is called: it reads self.grad (the output's grad, which downstream ops have accumulated), computes local derivatives, and adds them to the input Values' .grad fields.
2.1 Add
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
# ∂(a+b)/∂a = 1, ∂(a+b)/∂b = 1
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return outStudy those += operators. They ARE the sum-over-paths rule (S004 §3.1). If a is used at TWO different places downstream, its .grad gets contributions from both, added together. This is why we use += not =.
2.2 Multiply
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
# ∂(ab)/∂a = b, ∂(ab)/∂b = a
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return outNotice how the closure captures self and other — Python closures make this natural. When _backward runs later during backward(), self.data and other.data are still there (still the forward-pass values). This is exactly the "cache intermediates for backward" pattern from S009.
2.3 Power (for x**2 etc)
def __pow__(self, other):
assert isinstance(other, (int, float)), "power must be numeric"
out = Value(self.data ** other, (self,), f"**{other}")
def _backward():
# ∂(a^n)/∂a = n * a^(n-1)
self.grad += (other * self.data ** (other - 1)) * out.grad
out._backward = _backward
return out2.4 Convenience — unary negation, subtraction, division
def __neg__(self): return self * -1
def __sub__(self, other):return self + (-other)
def __radd__(self, other): return self + other # reverse ops for `2 + Value(3)`
def __rmul__(self, other): return self * other
def __truediv__(self, other): return self * other**-1Now all four arithmetic operations work — even mixing Value and Python floats.
2.5 tanh — the non-linearity for our neural net
import math
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
# ∂tanh(z)/∂z = 1 - tanh(z)^2
self.grad += (1 - t*t) * out.grad
out._backward = _backward
return outtanh is our activation. We could add ReLU, sigmoid, exp, log — same pattern. Left as exercise.
2.6 exp — bonus for softmax later
def exp(self):
e = math.exp(self.data)
out = Value(e, (self,), "exp")
def _backward():
self.grad += e * out.grad # d(exp z)/dz = exp z
out._backward = _backward
return out3 · backward() — the topological walk
Now the driver. Given the loss L (a Value), we want to fill in .grad for every ancestor node. The algorithm:
- Build a topological order of all Values reachable from
L. - Set
L.grad = 1.0(the seed). - Walk the topological order IN REVERSE, calling each Value's
_backward.
def backward(self):
# 1. topo sort — parents before children (post-order DFS)
topo, visited = [], set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
# 2. seed and walk
self.grad = 1.0
for v in reversed(topo):
v._backward()Why topological order? Because a node's _backward uses self.grad, which should already be fully accumulated by the time we hit it. Topological sort guarantees every downstream node is processed first.
Why reversed? Because build() post-order gives parents-before-children. We want the opposite for backward, so we reverse. (Andrej's original micrograd uses this same pattern.)
4 · Try it on our S010 example
w = Value(0.5)
x = Value(1.0)
b = Value(0.1)
y = Value(1.0)
# forward: L = ((w*x + b) - y)^2
z = w*x + b
diff = z - y
L = diff * diff
print("forward L =", L.data) # ((0.5*1 + 0.1) - 1)^2 = (-0.4)^2 = 0.16
L.backward()
print("dw =", w.grad) # ∂L/∂w = 2*(z-y)*x = 2*(-0.4)*1 = -0.8
print("db =", b.grad) # ∂L/∂b = 2*(z-y)*1 = -0.8Output:
forward L = 0.16
dw = -0.8
db = -0.8Verify by hand or numerically. It's right.
You have written a working autograd engine in ~70 lines of Python.
5 · Build a Neuron, Layer, and MLP on top
Now the fun part — use Value as the building block for a neural network.
import random
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
# x is a list of Values
act = sum((wi*xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
return [n(x) for n in self.neurons]
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x[0] if len(x) == 1 else x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]Twenty-five more lines. That's a full neural network module.
5.1 Train it on 4 points
Output:
step 0 loss=7.891
step 20 loss=1.234
step 40 loss=0.421
step 60 loss=0.187
...
step 180 loss=0.011Loss drops from 8 to 0.01 in 200 steps. On micrograd. On four data points. Karpathy's magic, reproduced.
Predict and confirm:
for x, y in zip(Xs, Ys):
p = model([Value(xi) for xi in x])
print(f"input {x} target {y} pred {p.data:.3f}")
# input [2, 3, -1] target 1.0 pred 0.98
# input [3, -1, 0.5] target -1.0 pred -0.99
# input [0.5, 1, 1] target -1.0 pred -0.97
# input [1, 1, -1] target 1.0 pred 0.99Nailed it.
6 · How does this differ from PyTorch?
- Scalars vs tensors. Micrograd's Value holds ONE number. PyTorch's Tensor holds an ndarray, so each op maps to vectorized numeric kernels (BLAS, cuBLAS, cudnn) instead of a Python loop. Same math, ~10,000× faster.
- Python vs C++. Micrograd's ops execute in pure Python — every add is a Python function call. PyTorch dispatches to C++/CUDA. Micrograd on 100 params is fine; on 1M params it would take hours.
- Extra features. PyTorch has: GPU support, distributed training, mixed precision, in-place ops with correct gradient tracking, TorchScript, tracing, and hundreds of ops. Micrograd has: +, *, **, tanh.
But the MATHEMATICAL CORE is identical. Same graph, same topological sort, same += accumulation, same closure-per-op design. Every time PyTorch does .backward(), it's doing what our backward() does — just faster and with more ops.
7 · Two exercises worth doing right now
- Implement
Value.relu(self)— one-liner activation with a mask-style_backward. - Implement
softmax_ce(logits: list[Value], target: int) -> Valuethat returns the negative log-probability of the target class. (Hint: use.exp()and sum.) - Verify gradients numerically on a 3-element example.
The full micrograd repo includes a graphviz visualization. Try adding it: for any Value, recursively walk _prev, emit graphviz dot code, and render. Watching a loss's computation graph made me love computation graphs.
8 · The mental checklist for reading any autograd source
Whenever you open any autograd source (PyTorch's autograd/, JAX's grad, tinygrad's Function, TensorFlow 2's GradientTape), you'll see the same four moving parts. Look for them:
- The Value / Tensor class — data + grad +
_prev(parent pointers). - Op registration — each op creates a new node and attaches a backward closure / function.
backward()driver — topological sort + reversed walk from the loss.- Some form of gradient accumulation —
+=for the multi-variable chain rule (sum over paths, S004 §3.1).
If you see these four, you understand the framework's autograd. Everything else is engineering.
8.1 A 2025 field guide: micrograd → PyTorch → JAX → tinygrad → LLM.c
| System | Line count (core) | Data type | Backward style | Compile step | Notable users |
|---|---|---|---|---|---|
| micrograd | ~100 Python | scalar | closure, dynamic graph | none | educational |
| PyTorch | ~1M C++/Python | ndarray tensor | closure, dynamic (eager) | torch.compile opt | ~everyone |
| JAX | ~500K Python/C++ | ndarray tensor | tracing, static graph | jax.jit (XLA) | Google, DeepMind |
| tinygrad | ~5K Python | ndarray tensor | closure, lazy | jit optional | George Hotz's group |
| LLM.c | ~2K C | fp32/bf16 buffer | hand-written per-op backward | none (single file) | Karpathy 2024 educational |
The modern trend is tracing + JIT-compilation: JAX collects the forward pass into a static graph, differentiates it symbolically, then hands the whole thing to XLA to fuse into kernels. torch.compile (PyTorch 2.0, 2023) does the same via TorchDynamo + AOTAutograd. The mental model you built with micrograd still applies — those systems still walk a graph in reverse and accumulate with += — they just do it in a compiler pass before execution instead of one Python function call at a time.
8.2 Further reading
- Karpathy, micrograd repo — 94 lines, read it.
- Baydin, Pearlmutter, Radul & Siskind, "Automatic Differentiation in Machine Learning: A Survey" (JMLR 2018) — the definitive academic reference. Sections 3 (reverse mode) and 5 (implementation) are what you just built.
- Frostig, Johnson & Leary, "Compiling machine learning programs via high-level tracing" (MLSys 2018) — JAX's design paper. Notice how their trace-then-differentiate is a static-graph version of micrograd's dynamic graph.
- Hotz et al., tinygrad
Functionsource — read after micrograd to see the tensor version. - Karpathy, llm.c repo (2024) — GPT-2 training in ~2K lines of pure C, backward pass hand-derived for every op. If you loved this session, you'll love that.
9 · War stories
In my first version of Value.__add__, I wrote:
def _backward():
self.grad = 1.0 * out.grad
other.grad = 1.0 * out.gradNote the = instead of +=. When a Value was reused (e.g., y = x*x — x appears twice), the second _backward call overwrote the first's contribution. Gradients were half what they should be.
Fix: +=. Always += in _backward. This is what "gradient accumulation" means at the primitive level.
Between training steps, if you don't zero the .grad fields, the OLD gradient from the previous step is still there, and the NEW backward pass adds to it. Effective learning rate silently grows every step, loss initially drops fast, then oscillates and blows up.
Fix: for p in model.parameters(): p.grad = 0.0 at the top of every step. Same story as PyTorch's optimizer.zero_grad(). Miss it once; never miss it again.
I wrote loss = ypred - 1.0 — the 1.0 is a bare Python float. My __sub__ converts it to Value fine, but I had subtly used y_target as a raw float somewhere else and the chain got broken.
Fix: always wrap targets in Value(...) at the boundary. Or, more elegantly, override __rsub__ and friends so mixed ops just work. Do the boundary conversion in one place.
"Autograd differentiates my Python function. It reads the code, works out the symbolic derivative, and evaluates it — like a computer algebra system with the chain rule built in."
Autograd never sees your code. It records a graph of primitive operations as they execute, storing at each node a closure that knows one thing: how to convert an output gradient into input gradients. There is no symbolic expression anywhere. That is why control flow, loops, and data-dependent branches work at all — each run traces whichever path actually ran.
Because the results are indistinguishable for the functions you first differentiate. Write f = x*y + x**2, call backward(), and you get exactly what symbolic differentiation would give — so "it did the algebra" is a perfectly adequate explanation. The distinction only bites at the edges: an if that took the other branch is simply not in the graph; a Python float that broke the chain leaves you with None; and an in-place edit corrupts a saved value with no symbolic derivative to fall back on. Each of those is baffling under the symbolic model and obvious under the tape model.
Two runs of the same function, two different graphs — impossible for a symbolic derivative:
import torch
def f(x, n):
out = x
for _ in range(n): # data-dependent trip count
out = out * x
return out
for n in (2, 5):
x = torch.tensor(3.0, requires_grad=True)
y = f(x, n); y.backward()
print(n, y.item(), x.grad.item()) # d/dx x^(n+1) = (n+1)x^n
# the recorded tape has a different LENGTH each callWhy must backward traverse the graph in reverse topological order, and why is a plain depth-first walk from the loss actually wrong?
- 1A node's gradient is the sum of contributions from every consumer that used its output.forced by · fan-out means the value influenced the loss along several paths, and the multivariate chain rule sums over paths
- 2So a node cannot pass its gradient downstream until all of its consumers have contributed. Passing early means propagating a partial sum.forced by · the local derivative multiplies the total incoming gradient, and multiplying a partial total gives a partial — and wrong — result everywhere below
- 3"All consumers processed before the producer" is exactly the definition of reverse topological order on the DAG.forced by · topological order guarantees every edge points from earlier to later, so reversing it puts every consumer before its producer
- 4A naive depth-first walk from the loss visits a node the first time it is reached, which for a diamond-shaped graph is before its second consumer has been visited at all.forced by · DFS orders by discovery, not by in-degree, and a shared node is discovered along whichever path is explored first
- 5Therefore the engine must either do a topological sort up front, or maintain a pending-consumer count per node and only release a node when its count hits zero.forced by · both are ways of enforcing the same "wait for all consumers" constraint; the second is what a real engine does, since it needs no separate pass
Therefore the topological sort in a micrograd-style backward() is not tidiness — it is the correctness condition, and dropping it silently produces wrong gradients on any graph where a value is used twice.
And note the prediction: the bug only appears when a node has more than one consumer. So a purely sequential model would train fine with a broken DFS backward, while adding a single residual connection or reusing an embedding matrix would break it — and it would break quietly, as a model that trains slightly worse. Verify it on the smallest possible case: a = x*x; b = a + x; b.backward(). Correct answer at x=3 is 2x+1 = 7; a walk that releases x after its first consumer gives 6 or 1 depending on which path it took first.
Every operation you execute appends one entry to a tape: the resulting value, pointers to its inputs, and a small function that says "given my output's gradient, here is what to add to each of my inputs". That is the whole data structure. The tape is built by running, so it reflects the execution that actually happened, not the code you wrote.
Backward then plays the tape in reverse, calling each closure once, accumulating into each node. Forward builds, backward walks. Once you hold this picture, autograd stops being magic and becomes bookkeeping you could have written yourself — which is exactly what micrograd is.
- Every node stores: value · grad accumulator · parents · a local backward closure. Four fields is the entire engine.
- Gradients accumulate (
+=) because fan-out sums. Assignment (=) is the bug that silently drops a path. - The tape holds references to intermediate values, so the graph pins memory until backward runs or you drop the reference. That is why
loss.item()in a logging list is fine andlosses.append(loss)leaks the whole graph. - Every primitive needs only its own derivative. Composition is free. That local-only contract is why you can add a new op in ten lines.
Fire this model the moment you see: grad_fn in a printed tensor · RuntimeError: Trying to backward through the graph a second time · memory growing across training steps for no visible reason · a custom autograd.Function · .detach() · a gradient that came back None · anyone asking how autograd handles an if statement.
You are designing an autodiff system. Build the graph dynamically as ops execute, or define a static graph up front and then execute it?
pdb, because the error happens where the op ran; and the mental model matches the code exactlytorch.compile and its predecessors doDevelop dynamically without exception. The historical argument for static graphs was performance, and tracing plus compilation has since taken most of that argument away — you can now get fusion without giving up pdb. The remaining honest case for a static artefact is deployment to a runtime that has no Python.
The practical discipline: build and debug eagerly, then turn on compilation as a final step and measure. Keep the eager path working as your reference, because when a compiled model produces subtly different numbers, the eager run is the only ground truth you have.
10 · Retention scaffold
One-line summary (write it in your own words): _______________________________________________________________
Spaced review: re-implement the Value class from memory in 24 hours (no peeking); revisit §5 (Neuron/Layer/MLP) on day 7 with an added ReLU op.
Next session (S013): micrograd is beautifully clear but painfully slow — one Python call per elementary op. Real neural networks have billions of ops per forward pass; Python would take days. S013 shows how to make micrograd/NumPy-style networks 100× faster using vectorisation (einsum, batched matmul, avoiding Python loops). Last stop before we finally move to PyTorch in M03.
Sticky note (keep on your desk):
- Four moving parts: Value / op-with-closure / topo-sort backward /
+=accumulation. Every autograd system, everywhere. - Multiply's
_backward:a.grad += b.data * out.grad; b.grad += a.data * out.grad. Memorise. - The 8-line backward driver: topo DFS post-order → reverse → call every
_backward.
Previous: ← DL S011 · MLP on MNIST — All Numpy, No PyTorch · Next: DL S013 · Vectorization and Speed →