Frontend Labs
⋯

57. Autograd from scratch: the engine inside PyTorch in 120 lines

PyTorch's central trick is automatic differentiation: you write the forward computation, and it computes every gradient for you. Building a tiny version yourself removes the mystery. This chapter follows the design of Andrej Karpathy's micrograd, and is the code version of AI Ch 15 (backpropagation) and the chain rule in the Maths guide.

# labs/tinygrad.py: scalar reverse-mode autodiff + a small neural network
import math
import random

class Value:
    """A number that remembers how it was computed, so gradients can flow back."""

    __slots__ = ("data", "grad", "_backward", "_prev", "op")

    def __init__(self, data: float, _prev: tuple["Value", ...] = (), op: str = ""):
        self.data = data
        self.grad = 0.0                   # d(output)/d(self), filled in by backward()
        self._backward = lambda: None     # how to push my grad to my inputs
        self._prev = _prev
        self.op = op

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

    def __pow__(self, k: float):
        out = Value(self.data ** k, (self,), f"**{k}")
        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,), "tanh")
        def _backward():
            self.grad += (1 - t * t) * out.grad   # d tanh(x)/dx = 1 - tanh(x)^2
        out._backward = _backward
        return out

    def backward(self) -> None:
        order, seen = [], set()
        def visit(v):                     # topological sort: children before parents
            if v not in seen:
                seen.add(v)
                for p in v._prev:
                    visit(p)
                order.append(v)
        visit(self)
        self.grad = 1.0                   # d(out)/d(out)
        for v in reversed(order):         # chain rule, from the output back to the inputs
            v._backward()

    __radd__ = __add__
    __rmul__ = __mul__
    def __neg__(self): return self * -1
    def __sub__(self, other): return self + (-other)
    def __truediv__(self, other): return self * other ** -1
    def __repr__(self): return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

class Neuron:
    def __init__(self, n_in: int):
        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, n_in: int, sizes: list[int]):
        dims = [n_in] + sizes
        self.layers = [[Neuron(dims[i]) for _ in range(dims[i + 1])] for i in range(len(sizes))]
    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()]

if __name__ == "__main__":
    # 1. Check one gradient by hand: f = a*b + c  ->  df/da = b = -3
    a, b, c = Value(2.0), Value(-3.0), Value(10.0)
    f = a * b + c
    f.backward()
    print(f, a.grad, b.grad, c.grad)                 # data=4, grads -3, 2, 1

    # 2. Train a 2-8-8-1 network to separate two classes of points.
    random.seed(1)
    X = [[random.uniform(-1, 1), random.uniform(-1, 1)] for _ in range(60)]
    Y = [1.0 if x0 * x0 + x1 * x1 < 0.5 else -1.0 for x0, x1 in X]   # inside / outside a circle
    model = MLP(2, [8, 8, 1])
    for step in range(200):
        preds = [model(x) for x in X]
        loss = sum((p - y) ** 2 for p, y in zip(preds, Y)) / len(X)   # mean squared error
        for p in model.parameters():
            p.grad = 0.0                                              # zero the old gradients
        loss.backward()                                               # backprop through everything
        lr = 0.1 if step < 150 else 0.03
        for p in model.parameters():
            p.data -= lr * p.grad                                     # gradient descent step
        if step % 50 == 0 or step == 199:
            acc = sum((p.data > 0) == (y > 0) for p, y in zip(preds, Y)) / len(X)
            print(f"step {step:3}  loss {loss.data:.4f}  accuracy {acc:.0%}")
    print(len(model.parameters()), "parameters")
Value(data=4.0000, grad=1.0000) -3.0 2.0 1.0
step   0  loss 1.0...  accuracy ...
...
step 199  loss 0.0...  accuracy 9x%
113 parameters

What you just built, and how it maps to PyTorch:

tinygrad PyTorch
Value (one number) torch.Tensor (an array of numbers, on CPU or GPU)
each op records _prev and a _backward closure each op records a node in the autograd graph (tensor.grad_fn)
loss.backward() topologically sorts and applies the chain rule loss.backward(), the same algorithm, in C++
p.grad = 0.0 optimizer.zero_grad()
p.data -= lr * p.grad optimizer.step() (SGD; AdamW adds momentum and scaling, AI Ch 71)
Neuron, MLP with parameters() nn.Linear, nn.Module with .parameters()

The Python features from earlier Parts are doing the work: operator overloading (Ch 16) makes a * b + c build a graph, closures (Ch 5.4) store each local derivative, and __slots__ (Ch 26) keeps the thousands of Value objects small. It is also painfully slow: one Python object per number. PyTorch's answer is the same engine over tensors, so one autograd node covers a million multiply-adds running in C++ or on the GPU.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36