JA EN
LearnNumerical Computing
·★ MEMBER·12 min read

Build Your Own Autograd — A Mini PyTorch in 100 Lines

Start from a single Value class, add operator overloading, topological ordering, and gradient accumulation, then put a neural network on top and train it. Once you have seen the reasons behind each design choice, zero_grad() and retain_graph stop being trivia to memorize.

ModalitytextTaskalgorithm

What reading gives you, and what building gives you

loss.backward() is nothing more than the chain rule applied mechanically — that argument is laid out in How Automatic Differentiation Works. But there is a gap between nodding along to an explanation and watching a class you wrote drop the right number into w.grad.

Here we build a working automatic differentiation engine that handles a single scalar at a time, stopping at each design decision to ask why it has to be that way. The finished thing is a little over 100 lines. Then we stack a small neural network on it and train until the loss comes down. Karpathy's micrograd is the best-known implementation in this style, and the skeleton below is from that lineage.

What building it actually answers are the questions that nagged you as a user. Why does zero_grad() have to be called every step? Why do gradients accumulate instead of being assigned? Why does calling backward() twice on the same graph raise an error? From the inside, none of these are arbitrary rules. They are the only way the thing could have been built.

A node is a small delivery worker

Every value that appears in the computation becomes a Value object. 2.0 is a Value. The result of w * x is a Value. So is the final loss.

Each Value carries three things: its own number, the gradient that has arrived at it, and a procedure for passing that gradient on to its parents — the inputs it was built from.

The third one is the whole trick. An object knows only how it was made; it knows nothing about the graph as a whole. When a gradient arrives from upstream, it multiplies by its local derivative and hands the result to its parents. That is all it does. Wake these workers up one at a time starting from the output, and the gradient reaches every leaf. Nobody holds the big picture, yet the whole thing works — that is the pleasure of the autodiff design.

The skeleton: a Value that can add and multiply

class Value:
    def __init__(self, data, _children=(), _op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None   # how to pass gradients on (default: do nothing)
        self._prev = set(_children)     # the inputs this was built from
        self._op = _op                  # a label, for display only

    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       # d(a+b)/da = 1
            other.grad += out.grad      # d(a+b)/db = 1
        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   # d(ab)/da = b
            other.grad += self.data * out.grad   # d(ab)/db = a
        out._backward = _backward
        return out

__add__ and __mul__ are Python's operator overloading hooks. With them in place you write a + b like normal, and nodes quietly accumulate behind your back. This is why you never have to "declare" the computation graph: run the forward code and the graph falls out as a byproduct.

The other if isinstance(other, Value) else Value(other) at the top of each method is what lets raw numbers mix in, so x * 2 works. The constant becomes a node too and dutifully gets a gradient that nobody ever reads, which is harmless. Normalize the types at the door and every branch downstream disappears — that is how the implementation stays short.

Why store a function instead of a number

Storing local derivatives as plain numbers looks sufficient. For multiplication you could just attach the tuple (other.data, self.data) and be done. For simple operations that works.

It falls apart the moment you meet an operation whose local derivative comes from the output rather than the input. Add tanh\tanh and you see it.

ddxtanh(x)=1tanh(x)2\frac{d}{dx}\tanh(x) = 1 - \tanh(x)^2
(1)

Read it as: the slope of tanh is its own output, squared and subtracted from one. The point is that xx does not appear on the right-hand side. To know the slope you do not need the input — the output you already computed is enough. The exponential is the same story: ddxex=ex\frac{d}{dx}e^x = e^x, so the output is the slope.

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

Notice that _backward reaches both t and out. That is a Python closure: the function remembers the variables that existed when it was defined. With this shape, operations whose derivative depends on the input and operations whose derivative depends on the output both fit the same slot. PyTorch can ship hundreds of operations precisely because each one registers as a pair: how to compute forward, and how to hand gradients back.

Turn that around and adding a new operation is always the identical chore. Compute the forward value, build out, write a _backward that distributes the local derivative, attach it. ReLU, exp, log — the recipe never changes. Because the cost of adding one operation is constant, the library can keep growing its op set indefinitely. The _op label, incidentally, plays no part in the math; it exists so you can draw the graph when debugging.

Drag the point on the curve below and you can confirm the claim directly: for tanh, the slope at a point is readable from its height alone.

FIG 1The slope of the curve is the local derivative itself. For tanh, squaring the output and subtracting from one gives the slope — so backward needs the output, not the input

Get the order wrong and it breaks silently

The order in which you wake the delivery workers is not arbitrary. A node's `_backward` may only run once every operation that consumed that node has finished handing back its gradient.

What's behind this

§

Members-only from here

371 walkthroughs, 26 textbook chapters, 48 student units and 6 close readings — all included for $4.99/mo, with three new explainers every day. Cancel any time; access runs to the end of the period.

Already a member? Sign in to keep reading

Comments

Sign in to comment