Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 120m read

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.

🧠SoftwareM02 · Neural nets from scratch in numpy· Session 012 of 130 140 min

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

You will be able to
  • 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

Every number is a knot in a fishing net
🌍 Real world
💻 Code world

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 _backward function 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 out

Study 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 out

Notice 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 out

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

Now 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 out

tanh 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 out

3 · 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:

  1. Build a topological order of all Values reachable from L.
  2. Set L.grad = 1.0 (the seed).
  3. 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.8

Output:

forward L = 0.16
dw = -0.8
db = -0.8

Verify 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

model = MLP(3, [4, 4, 1]) # 3 inputs hidden 4 hidden 4 1 outputXs = [ [2.0, 3.0, -1.0], [3.0, -1.0, 0.5], [0.5, 1.0, 1.0], [1.0, 1.0, -

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

Loss 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.99

Nailed it.


6 · How does this differ from PyTorch?

Micrograd vs PyTorch — the three big differences
  • 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

Try it
  1. Implement Value.relu(self) — one-liner activation with a mask-style _backward.
  2. Implement softmax_ce(logits: list[Value], target: int) -> Value that returns the negative log-probability of the target class. (Hint: use .exp() and sum.)
  3. Verify gradients numerically on a 3-element example.
Try it

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:

  1. The Value / Tensor class — data + grad + _prev (parent pointers).
  2. Op registration — each op creates a new node and attaches a backward closure / function.
  3. backward() driver — topological sort + reversed walk from the loss.
  4. 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

SystemLine count (core)Data typeBackward styleCompile stepNotable users
micrograd~100 Pythonscalarclosure, dynamic graphnoneeducational
PyTorch~1M C++/Pythonndarray tensorclosure, dynamic (eager)torch.compile opt~everyone
JAX~500K Python/C++ndarray tensortracing, static graphjax.jit (XLA)Google, DeepMind
tinygrad~5K Pythonndarray tensorclosure, lazyjit optionalGeorge Hotz's group
LLM.c~2K Cfp32/bf16 bufferhand-written per-op backwardnone (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


9 · War stories

War story I used `=` instead of `+=` and my gradients were wrong for reused variables

In my first version of Value.__add__, I wrote:

def _backward():
    self.grad  = 1.0 * out.grad
    other.grad = 1.0 * out.grad

Note the = instead of +=. When a Value was reused (e.g., y = x*xx 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.

War story I forgot to zero grads and my loss decreased suspiciously fast

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.

War story I mixed Python floats and Values, and my gradient wasn't propagating

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.

Common misconception
✗ What most people think

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

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 call
From first principles
Start with the question

Why must backward traverse the graph in reverse topological order, and why is a plain depth-first walk from the loss actually wrong?

  1. 1
    A 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
  2. 2
    So 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. 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
  4. 4
    A 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
  5. 5
    Therefore 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

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.

Mental modelA tape of closures

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 and losses.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.
🔔 Fires when you see

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.

The tradeoff

You are designing an autodiff system. Build the graph dynamically as ops execute, or define a static graph up front and then execute it?

Dynamic (define-by-run)
+ you gain ordinary Python control flow just works — loops, recursion, data-dependent branches, and early exits need no special constructs; debugging uses the normal stack trace and pdb, because the error happens where the op ran; and the mental model matches the code exactly
− you pay the framework sees one op at a time, so it cannot fuse kernels, reuse buffers across ops, or plan memory globally; and Python interpreter overhead sits on the critical path of every single op, which dominates for small tensors
pick when you are researching, debugging, or the model has genuinely data-dependent structure — and, practically, whenever developer iteration speed costs more than GPU time
Static (define-then-run)
+ you gain the whole graph is visible before execution, enabling operator fusion, constant folding, memory planning, and ahead-of-time compilation for a target device; it also serialises cleanly, so the artefact can run without Python at all
− you pay control flow needs framework-specific primitives instead of Python; errors surface at execution time far from where you wrote the bug; and the debugging story is genuinely worse — this is the reason the industry moved away from it
pick when you are exporting to a device with no Python runtime, or serving at a scale where a fusion-driven throughput win pays for the loss of flexibility
Dynamic with tracing / compilation
+ you gain write and debug eagerly, then capture the traced graph and hand it to a compiler for fusion and memory planning — most of the optimisation with most of the ergonomics; this is what torch.compile and its predecessors do
− you pay the trace is only valid for the control-flow path and shapes it saw, so shape changes or data-dependent branches force recompilation (or silently bake in the wrong path); compile time itself is real; and the failure modes are subtle rather than loud
pick when the model shape is stable across steps and you have a measured throughput problem — not before, because the compile step costs time you will not recover on a short run
What a senior engineer actually does

Develop 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

Quick recall · click to reveal
★ = stretch question

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 →