[JV]

Part 1: scalars to tensors, backprop by hand

July 7, 2026·[ autodiff, machine learning, python ]
Summary

Built a reverse-mode autodiff engine from scratch: a scalar Value engine, an MLP trained on top of it, then a NumPy-backed Tensor class with broadcasting, batched matmul, and softmax.

Key takeaways
  • Backprop is the chain rule applied to a graph. Traversal order and gradient accumulation (+=, not =) are the only non-obvious parts
  • Broadcasting on the forward pass must be explicitly undone (summed) on the backward pass
  • Composing correct primitives is a debugging strategy: ops built from verified pieces inherit their correctness
  • A failing test is not proof the math is wrong. Check precision mismatches (float32 vs float64) first
What's next

Building a character-level transformer on top of this engine: self-attention, layer norm, feedforward block, cross-entropy loss, and Adam. Attention is free (just matmul + softmax); the missing pieces are embedding-lookup indexing, layer norm, and a log primitive for cross-entropy.

This project implements a reverse-mode automatic differentiation engine from scratch in Python and NumPy to understand the mechanics behind gradient computation.

It covers a scalar engine, an MLP trained on top of it, and a generalization to NumPy-backed tensors. PyTorch is used only as a reference in the test suite: every gradient the engine computes is checked against what PyTorch produces for the same expression.

The resulting engine serves as the foundation for implementing a transformer from first principles.

The idea underneath .backward()

Any arithmetic expression can be represented as a graph:

L=tanh(ab+c)L = \tanh(a \cdot b + c)

This builds a directed acyclic graph. a and b feed into a multiply node, that combines with c at an add node, and the result goes through tanh to produce L. Training means computing L/p\partial L/\partial p for every parameter pp: how much does changing this weight change the loss.

abc×+tanha·ba·b + cL = tanh(a·b + c)
the graph for L = tanh(a·b + c)

There are two ways to get every one of those partial derivatives:

A neural network has one output loss and potentially millions of parameters, making reverse-mode the only practical choice.

The chain rule, mechanically

Reverse-mode autodiff is a graph-wide application of the chain rule. If L depends on x only through some intermediate y = f(x), then:

Lx=Lyyx\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial x}

The two factors on the right play different roles:

Each operator only needs to know its own local gradient rule; the backward traversal composes these into upstream gradients. Each Value node stores its forward value (data), its accumulated gradient (grad, starting at 0.0), the parent nodes it came from (_prev), and a _backward closure that pushes the upstream gradient into those parents.

If a node has multiple consumers (its value gets reused more than once downstream), the total upstream gradient is the sum of contributions from each consumer, an application of the multivariable chain rule. Every _backward closure uses += instead of = for this reason; using = produces an incorrect gradient with no error raised.

From there, each operator's backward rule is straightforward:

opforwardlocal gradientbackward rule
adda+ba+b1,11, 1a.grad += out.grad; b.grad += out.grad
mulaba \cdot bb,ab, aa.grad += b.data*out.grad; b.grad += a.data*out.grad
powaka^kkak1k a^{k-1}a.grad += k*a.data**(k-1)*out.grad
expexe^xexe^xx.grad += out.data*out.grad
tanhtanh(x)\tanh(x)1tanh(x)21-\tanh(x)^2x.grad += (1 - out.data**2)*out.grad
relumax(0,x)\max(0,x)1[x>0]\mathbf{1}[x > 0]x.grad += (out.data > 0)*out.grad

I didn't implement sub, neg, or truediv as their own primitives. a - b is a + (-b), -b is b * -1, and a / b is a * b**-1, so their gradients come from the already-implemented add, mul, and pow rules.

Getting the traversal order right

.backward() needs one guarantee: by the time a node v runs its _backward(), every node that uses v's output has already added its contribution to v.grad; otherwise _backward() runs on an incomplete gradient. A post-order depth-first traversal gives this ordering:

def build_topo(v):
    if v not in visited:
        visited.add(v)
        for child in v._prev:
            build_topo(child)
        topo.append(v)   # v is appended only after all its children are

A node lands in topo only after every node it depends on has been visited. Reversing the list produces an order where every node receives all upstream gradients before executing its backward rule. Set the output's gradient to 1.0 (since L/L=1\partial L/\partial L = 1), walk the list, and call each _backward() in turn.

Trusting the derivations: PyTorch as an oracle

The test suite builds the same expression twice: once with my Value class, once with torch.tensor(..., requires_grad=True). It runs .backward() on both and checks every leaf's .grad matches to within 1e-6.

The mismatch came from PyTorch defaulting to float32 while my engine used float64. Matching the dtypes eliminated the discrepancy.

Turning it into a neural net

A single neuron is just:

z=iwixi+b,a=relu(z)z = \sum_i w_i x_i + b, \qquad a = \mathrm{relu}(z)

Layers and the MLP are composed directly from these primitives, so no additional backward implementation is required.

For the loss I used max-margin (hinge) loss. It composes directly from ReLU:

Li=relu(1yisi)L_i = \mathrm{relu}(1 - y_i s_i)

If the score has the right sign and is confident enough (yisi1y_i s_i \geq 1), the loss is zero. Otherwise it grows with how wrong the prediction is. Add L2 regularization and average over the batch.

Training uses vanilla SGD:

for p in parameters:
    p.data -= lr * p.grad

Gradients must be reset before each backward pass since accumulation uses +=.

I trained an MLP(2, [16, 16, 1]) on the classic two-interleaved-crescents make_moons dataset:

step 0:  loss 1.4665, accuracy 26.0%
step 20: loss 0.0984, accuracy 96.0%
step 60: loss 0.0182, accuracy 100.0%
Decision boundary before training, an arbitrary split unrelated to the two moons
before
Decision boundary after training, cleanly separating the two moons
after

Scaling up: one Value per number doesn't scale

The scalar engine builds a new graph for every training step, one node per number, so a 100-example batch through a [2,16,16,1] network allocates thousands of Python objects. Tensor is the same structure, except data is a NumPy array instead of a float. One node represents a whole matrix. A single matmul replaces hundreds of scalar ops.

Most of Tensor is a direct generalization of Value. Two parts weren't: broadcasting and softmax.

Broadcasting has to be undone on the way back

NumPy broadcasts silently: adding a (4,) vector to a (3,4) matrix just works. Broadcasting creates multiple consumers of the same value, so the backward pass must sum the gradient across the broadcast dimensions to undo it.

Concretely, if b has shape (4,) broadcast up to (3,4) inside a + b, then for each j:

Lbj=iLouti,j\frac{\partial L}{\partial b_j} = \sum_i \frac{\partial L}{\partial \text{out}_{i,j}}

One generic helper handles it rather than special-casing every op:

def _unbroadcast(grad, shape):
    while grad.ndim > len(shape):          # broadcast added new leading axes
        grad = grad.sum(axis=0)
    for i, dim in enumerate(shape):        # broadcast stretched a size-1 axis
        if dim == 1 and grad.shape[i] != 1:
            grad = grad.sum(axis=i, keepdims=True)
    return grad

Every binary op runs its gradient through this before accumulating.

For matmul, C=ABC = AB, the gradients are:

LA=LCB,LB=ALC\frac{\partial L}{\partial A} = \frac{\partial L}{\partial C}\,B^\top, \qquad \frac{\partial L}{\partial B} = A^\top \frac{\partial L}{\partial C}

np.swapaxes(-1, -2) instead of a plain transpose handles batched matmuls by only swapping the last two axes. A batched input against an unbatched weight matrix goes through the same _unbroadcast helper.

Softmax: the one op where the "local gradient" isn't a number

Every other op has a scalar or elementwise local gradient. Softmax doesn't, because every output depends on every input. Its local gradient is a full Jacobian:

si=exijexj,sixj=si(δijsj)s_i = \frac{e^{x_i}}{\sum_j e^{x_j}}, \qquad \frac{\partial s_i}{\partial x_j} = s_i(\delta_{ij} - s_j)

Given an upstream gradient gi=L/sig_i = \partial L/\partial s_i, the chain rule sums over ii:

Lxj=igisi(δijsj)=sj(gjigisi)\frac{\partial L}{\partial x_j} = \sum_i g_i\, s_i(\delta_{ij}-s_j) = s_j\left(g_j - \sum_i g_i s_i\right)

That collapses into a single elementwise formula:

dot = np.sum(out.grad * out.data, axis=axis, keepdims=True)   # Σᵢ gᵢsᵢ
self.grad += out.data * (out.grad - dot)                       # s · (g − Σgs)

The full Jacobian never has to be formed explicitly: the sum over i reduces to a single dot product between out.grad and out.data, after which the remaining computation is elementwise. The forward pass also subtracts max(x)\max(x) before exponentiating. This is exact, not an approximation: exc/exjc=ex/exje^{x-c}/\sum e^{x_j - c} = e^x/\sum e^{x_j} for any constant cc. It keeps exp() from overflowing on large inputs.

The Tensor implementation produces the same decision boundary while replacing thousands of scalar graph nodes with vectorized NumPy operations.

Decision boundary before training, batched Tensor version
before (batched)
Decision boundary after training, batched Tensor version, cleanly separating the two moons
after (batched)

What I learned

What's next

The next step is implementing a character-level transformer using this engine. Remaining work includes embedding lookup with scatter-add gradients, layer normalization, and a logarithm primitive for cross-entropy loss. Each requires implementing additional local gradient rules while reusing the existing reverse-mode engine.