Deep learning and neural networksLoading progress…

A small autograd engine

Apply the chain rule through a graph and implement the core of a scalar reverse-mode autodiff engine.

A whole autograd engine in a few lines

Everything so far fits in one small class. Each node stores its value, a gradient that starts at 0, its parents, and a function that knows how to send its gradient to those parents. Calling backward() on the output orders the graph, sets the output gradient to 1, then runs each node's rule in reverse order.

class Value:
    def __init__(self, data, parents=()):
        self.data, self.grad = data, 0.0
        self._parents = parents
        self._backward = lambda: None

    def __add__(self, 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):
        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 backward(self):
        order, seen = [], set()
        def visit(v):
            if v not in seen:
                seen.add(v)
                for p in v._parents:
                    visit(p)
                order.append(v)
        visit(self)
        self.grad = 1.0
        for v in reversed(order):
            v._backward()

Notice the +=. Every rule adds to the gradient of its inputs, which is exactly how values used twice collect contributions. The same += has a side effect: calling backward() again keeps adding.

call∂L/∂a∂L/∂b∂L/∂c
1722
22166
3421212

Without clearing, every stored gradient keeps its old value and the new contributions are added on top, including the gradients of the in-between nodes p and q. So the totals grow faster than once per call (7, 21, 42 for a). This is why a training loop needs zero_grad before each backward().

Three calls to backward() on the same graph, with and without clearing between calls.

The training loop around it

A training step needs four things in the right order: compute fresh values (forward), clear old gradients (zero_grad), compute new gradients (backward), then move the weights (step). Skip the clearing and each update includes the stale gradients of earlier passes.

loop bodyw after 3 iterationsloss
correct: forward, zero_grad, backward, step1.9840.001
forgot zero_grad: forward, backward, step4.22419.785
zero_grad after backward: forward, backward, zero_grad, step016
The same tiny problem trained for 3 iterations with three different loop bodies.

Predict first. What happens if old gradients are unintentionally retained between updates?

Chapter context

Backpropagation organizes repeated applications of the chain rule. The graph stores how values depend on earlier values. Reverse traversal accumulates how the final loss changes with each intermediate quantity.

Chapter reference: math, worked example & code

This section belongs to the whole chapter, so it is the same on every lesson in it and may cover a different lesson's topic. The code for the experiment you just ran is inside the experiment itself.

Math and notation

If u=ab+cu=ab+c and L=u2L=u^2, then

∂L∂a=2ub,∂L∂b=2ua,∂L∂c=2u.\frac{\partial L}{\partial a}=2ub,\quad \frac{\partial L}{\partial b}=2ua,\quad \frac{\partial L}{\partial c}=2u.

At a shared node x, add all downstream contributions: ∂L/∂x=∑j(∂L/∂uj)(∂uj/∂x)\partial L/\partial x=\sum_j(\partial L/\partial u_j)(\partial u_j/\partial x).

Worked example

Take a=2,b=3,c=1. Then u=7 and L=49. The upstream gradient into u is 14, giving dL/da=14×3=42, dL/db=14×2=28 and dL/dc=14. These are sensitivities, not new parameter values; an optimizer still needs a step size.

Reference code

The following example uses the Python standard library and does not call a remote service.

from math import isclose
a,b,c = 2.,3.,1.
u = a*b+c
assert u*u == 49
assert (2*u*b,2*u*a,2*u) == (42,28,14)
h = 1e-5
f = lambda x: (x*b+c)**2
assert isclose((f(a+h)-f(a-h))/(2*h), 42, rel_tol=1e-7)

Python examples are provided for study and local execution; this page does not execute Python.

Common mistakes

Forward values and gradients are different objects. Shared branches require addition of gradient contributions. Backpropagation computes derivatives; the optimizer changes parameters.