A Tiny Autograd Engine, Explained
Introduction
engine.py is a complete reverse-mode automatic differentiation engine in about 80 lines (heres the repo). It has a single class, Value, that wraps one scalar and remembers how that scalar was computed, so that calling .backward() fills in the derivative of the final value with respect to every value that fed into it.
It’s the same core idea as PyTorch’s autograd, just small enough to read in one sitting. The code is also strongly influenced by Karpathy’s micrograd repo.
What is the purpose of an Autograd Engine
Consider a case when we have three x-parameters: $x_1$, $x_2$, $x_3$. In this example, these x-parameters have the following known values: $x_i = [2, 3, 4]$.
Let’s study a problem with these equations: $$f_1(x_i) = x_1 + x_2$$ $$f_2(x_i) = x_3 \cdot f_1(x_i)$$ $$\mathcal{L}(x_i) = \sin(f_2(x_i))$$
where $\mathcal{L}$ is the loss function. If we plug in the known values for $x_i$ then we get
$$f_1 = 5$$ $$f_2 = 20$$ $$\mathcal{L} = \sin(20) \approx 0.91294$$
These calculation are obviously super quick and easy to perform, but what if we want to calculate the gradient of the loss function with respect to the variables $x_i$? The equations for the gradient terms [$\partial{\mathcal{L}}/\partial{x_1}$, $\partial{\mathcal{L}}/\partial{x_1}$, $\partial{\mathcal{L}}/\partial{x_1}$] can be derived in closed-form and then quickly calculated. But what if the loss function $\mathcal{L}(x_i)$ depends on a million (or more) variables $x_i$. In that case it would be convenient if we could automatically obtain the gradient terms at the same time as the get the loss function value. After all, we need the gradient terms in order to perform gradient descent to minimize the loss.
It turns out there is a clever way to calculate the gradients at the same time as the loss function is calculated. This approach is discussed in this article, and the approach to solve it is to create a custom Python class that keeps track of not only numerical values, but also the local gradients for each calculation step.
Main Python class
Every node stores the local derivatives of its output with respect to each of its inputs — and it computes them during the forward pass, while the numbers are right there.
class Value:
def __init__(self, data, _children=(), _op='', _local_grads=()):
self.data = data
self.grad = 0
self._prev = list(_children) # inputs that produced this node
self._op = _op # kept for repr/debugging only
self._local_grads = _local_grads # ∂out/∂child_i per child_prev holds the children (the inputs), and _local_grads holds one number per child: the partial derivative of this node’s output with respect to that child. The two are positionally aligned.
Because the local derivatives are stored as plain data, the backward pass never has to know what operation created a node. It just multiplies and accumulates.
Building the graph in the forward pass
Each operator computes its output and its local derivatives at the same time. For addition and multiplication that’s trivial:
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, (self, other), '+', (1.0, 1.0))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), '*', (other.data, self.data))For a + b, both partials are 1. For a * b, the partial with respect to a is b.data, and with respect to b is a.data. Powers, ReLU, and sine follow the same pattern:
def __pow__(self, other):
return Value(self.data**other, (self,), f'**{other}',
(other * self.data**(other - 1),))
def relu(self):
return Value(0 if self.data < 0 else self.data, (self,), 'ReLU',
(1.0 if self.data > 0 else 0.0,))
def sin(self):
return Value(math.sin(self.data), (self,), 'sin', (math.cos(self.data),))So after a forward expression like g = f / 2.0 + 10.0 / f, the graph is built and every edge is already labelled with its local derivative.
The backward pass is generic
Backpropagation is the chain rule applied over the graph, plus the fact that a node used more than once gets its gradients summed from each use. That means we need to process nodes in an order where every node’s own gradient is complete before we push it to its children — i.e. parents before children, or reverse topological order.
def backward(self):
# children before parents in topological order
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
# the entire backward pass, generic over all ops
self.grad = 1.0
for v in reversed(topo):
for child, local in zip(v._prev, v._local_grads):
child.grad += local * v.gradThat inner loop is the whole algorithm:
for every node, send
local_derivative × node.gradto each child, accumulating.
self.grad = 1.0 seeds the derivative of the output with respect to itself (if the output is a loss, this is dL/dL = 1). The += is what makes fan-out correct — if a value is used twice, its contributions add up. And leaf nodes simply have _prev == [] and _local_grads == (), so the zip is empty and they’re skipped with no special case.
Adding a new operation
Because of that, extending the engine is a one-liner. Want exp? Supply its local derivative and you’re done — backward already handles it:
def exp(self):
return Value(math.exp(self.data), (self,), 'exp', (math.exp(self.data),))A worked check
The classic test, matching the original micrograd README:
from engine import Value
x1 = Value(2.0, (), 'x1')
x2 = Value(3.0, (), 'x2')
x3 = Value(4.0, (), 'x3')
f1 = x1 + x2
f2 = x3 * f1
Loss = f2.sin()
Loss.backward()
print(f"x1 = {x1}")
print(f"x2 = {x2}")
print(f"x3 = {x3}")
print(f"f1 = {f1}")
print(f"f2 = {f2}")
print(f"Loss = {Loss}")The output from the code is:
x1 = Value(data=2.0, grad=1.6323282472535678, op=x1)
x2 = Value(data=3.0, grad=1.6323282472535678, op=x2)
x3 = Value(data=4.0, grad=2.0404103090669596, op=x3)
f1 = Value(data=5.0, grad=1.6323282472535678, op=+)
f2 = Value(data=20.0, grad=0.40808206181339196, op=*)
Loss = Value(data=0.9129452507276277, grad=1.0, op=sin)Two things to keep in mind
- Gradients accumulate.
backward()seeds the output’s grad and adds into everything else, so calling it twice without resetting will double-count. Zero the gradients between steps — that’s exactly whatzero_grad()does in the neural-net code built on top of this. - The graph is only as correct as the local derivatives. If
_local_gradsand_prevever get out of sync in length,zipsilently stops at the shorter one. Keeping them constructed together, as every op here does, avoids that entirely.
That’s the whole engine: label every edge with its local slope while going forward, then walk the graph backward multiplying and accumulating. Everything else is just efficiency.
Jorgen Bergstrom