Project 12 · Python · from scratch

A reverse-mode autodiff engine, written twice — once for clarity, once for speed

Two hand-written automatic differentiation engines that share one design: a ~170-line scalar core built for reading, and a numpy-backed tensor engine with every backward pass — including the broadcasting adjoints — derived and coded by hand. Plus second-order derivatives, which micrograd-style engines cannot do at all, and 210 verification checks pinned to external truths.

This page is a static report, not a live demo. The engine is Python; nothing on this page runs in your browser. Every figure and number below was produced by python walkthrough.py and python tests/run_all.py on a real run, and written to results/measurements.json.
210
verification checks, all passing
(180 without PyTorch)
~5,000×
tensor engine speedup
over the scalar engine
97.56%
digits accuracy on a
held-out test split
0.00e+00
error vs hand-derived
analytic Hessians

What makes this more than a micrograd clone

A scalar autodiff engine is a well-worn exercise. Three things here are not:

Two engines, one design

Value (scalar)Tensor (n-d)
Unit of workone Python floatone numpy array
Size~170 lines~450 lines incl. comments
Graph nodes per forward pass129,93823
Seconds per epoch~3–6 s~0.0006 s
Purposeshow what backprop ismake it usable

Identical workload: MLP(2,[16,16,1]), 337 parameters, 200 spiral points, hinge loss, full-batch gradient descent. Node counts obtained by walking the actual graph, not estimated. The ~5,000× speedup is almost exactly the 5,649× reduction in node count — which is the point: the scalar engine is slow for one explainable reason, namely that it allocates a Python object, a closure and a set per arithmetic operation.

The scalar engine was deliberately kept unchanged. Its minimalism is the pedagogical asset; the tensor engine exists so the project can actually do something.

The broadcasting adjoint

This is the genuinely subtle part of tensor autodiff. Broadcasting is a linear map: evaluating x + b with x.shape == (64, 10) and b.shape == (10,) first applies a map that copies b down all 64 rows. Reverse-mode autodiff propagates gradients by applying the adjoint (the transpose) of each forward linear map — and the transpose of a 0/1 replication matrix is a 0/1 summation matrix.

The adjoint of “copy one value into 64 slots” is “sum those 64 slots back into one value.” So when a forward op broadcasts, the backward pass must sum the gradient back down over exactly the broadcast axes, restoring the parent's shape.

Getting this wrong is nasty precisely because it still trains. A bias gradient that is 64× too small, or that comes back with the output's shape and silently re-broadcasts on the next update, still produces a loss curve that goes down. Only an independent check catches it — which is why every broadcast pattern in the test suite asserts both the gradient's values against finite differences and its shape.

Second order: double backpropagation

In a micrograd-style engine the backward pass writes raw floats (self.grad += other.data * out.grad). Those are values, not graph nodes — nothing records how a gradient was computed, so there is no graph to differentiate a second time. That is the ceiling micrograd hits.

The fix is to express each operation's vector-Jacobian product in the same algebra as the forward pass, so that _vjp = lambda g: (g * other, g * self) builds nodes rather than multiplying floats. The backward pass stops being a numeric sweep and becomes a graph transformation, whose output can be fed straight back in. Nothing is order-specific, so n-th derivatives fall out of the same code.

f(x, y) = x²y + sin(x)y³
  ∂f/∂x   = 2xy + cos(x)y³
  ∂²f/∂x² = 2y − sin(x)y³      ← what grad(grad(f)) returns, to 0.00e+00

This is the machinery behind gradient penalties (WGAN-GP), MAML-style meta-learning, and physics-informed losses — anything that optimizes through a derivative.

Verification — 210 checks all passing

Every check compares an engine-computed quantity against something computed independently of the engine. Nothing here is of the form “the loss went down” or “the output matches last time.”

SuiteChecksPinned to
scalar gradcheck (original, kept)17 Central finite differences per operator, plus gradient accumulation and a full MLP
tensor gradcheck79 Finite differences over every element of every input array; broadcast cases also assert gradient shape
higher-order40 Hand-derived analytic Hessians; finite differences of the analytic first derivative; xᵀAx whose Hessian is exactly A + Aᵀ; closed-form n-th derivatives
softmax / cross-entropy26 The closed-form identity ∂L/∂z = p − y; the softmax Jacobian diag(p) − ppᵀ; overflow behaviour at logits of ±800
training-loop pins18 The closed-form least-squares solution β = (XᵀX)⁻¹Xᵀy via numpy.linalg.solve; the descent lemma
PyTorch oracle optional30 PyTorch's autograd in float64, including create_graph=True Hessians
Total210180 without PyTorch installed

The three that matter most

The (p − y) identity. Cross-entropy is deliberately composed from primitive ops rather than fused with a hand-written backward, so the gradient that emerges is the product of six or seven separate local derivatives chained together. That it collapses to exactly p − y — measured at 0.00e+00 — is a real algebraic identity the engine was never told.

The training loop, not just the gradients. Linear regression is trained by gradient descent through this engine and asserted to converge to the exact normal-equation solution, reached to 1.4e-16 relative error. Three further consequences are pinned: the final loss equals the closed-form residual; the engine's gradient at β* is zero (the normal equation restated); and the loss is monotonically non-increasing at lr = 1/L with L the largest Hessian eigenvalue from numpy.linalg.eigvalsh — a theorem, so any rise is a real defect.

The PyTorch oracle actually ran. PyTorch 2.13.0+cpu was installed and all 30 oracle checks pass, many at exactly 0.00e+00, including the Hessian comparison against torch.autograd.grad(..., create_graph=True). When torch is absent they skip cleanly with an explanatory message and the suite reports 180/180.

torch is a test-only oracle, never a dependency. The shipped library has zero ML-framework dependencies, and that is worth protecting. It lives in requirements-dev.txt; the default pip install torch pulls CUDA wheels at roughly 2 GB (the CPU-only build used here is ~200 MB). The correctness argument does not depend on torch, because finite differences and closed-form algebra already cover the same ground from a different direction.
     17 checks  scalar engine gradcheck (finite differences)   [OK]
     79 checks  tensor engine gradcheck + broadcasting adjoints   [OK]
     40 checks  higher-order derivatives (analytic Hessians)   [OK]
     26 checks  softmax / cross-entropy closed-form gradient   [OK]
     18 checks  training-loop pins (closed-form least squares)   [OK]
     30 checks  PyTorch oracle [OPTIONAL]   [OK]
------------------------------------------------------------------------
  TOTAL: 210 checks, 210 passed, 0 failed

Results

Two-arm spiral

500 points, 350 train / 150 held-out test, MLP(2 → 64 → 64 → 2) with 4,482 parameters, 900 epochs of Adam with an annealed learning rate.

Spiral decision boundary before and after training
Signed model confidence as a diverging field, with the learned decision boundary in black. Left: random initialization (56.0% test). Right: after training (100% train, 90.0% test).

The dataset was deliberately made harder than it needed to be. At the gentler settings this project originally used, the same network scores 100% on both splits and the figure teaches nothing. At 2.5 revolutions with angular noise 0.5 the arms genuinely overlap, and the result is a network that memorizes the training set (100%) while reaching 90.0% on data it has never seen. That 10-point gap is the honest part:

Spiral training and test loss curves on a log scale
Training loss falls ~140× while held-out loss flattens at epoch ~270 and never improves again. This is a picture of overfitting, shipped rather than tuned away.

Handwritten digits

sklearn's bundled 8×8 load_digits: 1,797 samples, 1,347 train / 450 held-out test (stratified), MLP(64 → 64 → 32 → 10) with 6,570 parameters, 60 epochs, minibatch 64, Adam. sklearn supplies the array and the split only — the model, gradients, softmax, cross-entropy, optimizer, training loop and even the confusion matrix are this project's own code.

Test-set accuracy: 97.56% (439 / 450 correct, 11 wrong). Training accuracy was 100.00%, quoted here only so the gap is visible.
Confusion matrix on the held-out digits test set
Colour encodes row-normalised recall, not raw counts — the diagonal is ~40× every off-diagonal cell, so a raw-count ramp would render every actual mistake as indistinguishable from zero. Labels carry the raw counts. Worst class is 8 at 93.0% recall.
Every misclassified test digit
Every single mistake the model makes on the test set. Most are genuinely ambiguous at 8×8 resolution — a 9 whose loop closes into a 3, an 8 whose waist collapses to a 1.
Digits training loss and held-out test accuracy
Loss and accuracy as two panels rather than two y-axes on one plot. Held-out test loss bottoms out around epoch 30 and drifts up slightly while accuracy holds — mild overfitting that does not cost accuracy.

Optimizer comparison

Same problem, same seed, the same initial weights restored before every run, full-batch so there is no sampling noise, and no schedule. Comparing at one shared learning rate would be rigged, so each optimizer gets its own sweep and is plotted at its own best setting; each grid brackets its optimum on both sides.

Loss curves for SGD, SGD with momentum, and Adam
Training loss on a log scale; the dot marks where each optimizer first crosses 0.05.
OptimizerBest lrFinal train lossEpochs to loss < 0.05
SGD1.00.5999never (400 budget)
SGD + momentum 0.90.30.2452never (400 budget)
Adam0.030.0075279

Adam wins decisively: it is the only one of the three to reach the threshold at all within 400 epochs, and its final loss is 33× lower than the runner-up. This is one small full-batch problem, not a general claim about optimizers.

The computational graph

Computational graph of L = tanh(x*y + x)
x is used twice, so two edges leave it and x.grad is the sum of what arrives along each — (1 − L²)(y + 1) = −0.180707, matching the engine exactly. This is why every backward closure accumulates with +=.
Computational graph of one tensor layer
The same renderer on a tensor layer. Note b is (4,) while the node it is added to is (6,4) — and b's gradient comes back as (4,), summed over the batch axis. That is the broadcasting adjoint, visible.

The renderer degrades gracefully: Graphviz when the native dot binary is present, otherwise a built-in matplotlib layered renderer. Both figures above were produced by the fallback, because dot is not installed on the machine that built this page. DOT source is written next to every PNG regardless.

Honest limitations

Measured, not hedged.

The scalar engine is thousands of times too slow to be useful. ~3–6 seconds per epoch for a 337-parameter network on 200 points, because it allocates 129,938 graph nodes per forward pass. This is a property of the design, not a bug — but it makes the scalar engine a teaching artifact, not a tool.
WorkloadThis enginePyTorch 2.13 (CPU, float64)
200×2 → 16 → 16 → 1~0.0005–0.0008 s/epoch ~0.0003–0.0018 s/epochcomparable, within run-to-run noise
4096×64 → 256 → 256 → 10 (85k params) ~100–230 ms/epoch~68–214 ms/epoch PyTorch ~1.5–2× faster

At tiny sizes the two are indistinguishable, because per-op dispatch is the entire cost and a thin numpy wrapper is in the same class as PyTorch's dispatcher. At realistic sizes PyTorch wins. The gap is only ~2× rather than ~100× because both bottom out in the same float64 BLAS — PyTorch's real advantages (float32 kernels, operator fusion, GPUs) are things this engine does not have at all, rather than does slowly.

A flattering number that turned out to be wrong. An earlier version of the benchmark reported this engine as 3.7× faster than PyTorch at the small size. That was an artifact of not warming PyTorch up, and it evaporated the moment a warm-up pass was added. It is documented in experiments/benchmark.py rather than quietly deleted, because it is exactly the kind of measurement a portfolio is tempted to keep.

Adam does not converge to machine precision at a fixed learning rate. On a convex problem with a known exact answer, Adam at lr = 0.05 stalls ~4.6e-5 from the true optimum after 6,000 steps and is still at ~2.1e-5 after 20,000 — it has stopped converging, not slowed down. Its update stays O(lr) as the gradient shrinks, so it orbits the optimum in a ball of radius ~O(lr). Annealing closes it to 1.4e-16. This is asserted in the test suite, not merely narrated.

What this engine does not have: no GPU support, no operator fusion, no kernel specialization, no graph optimization, no float32 path, no in-place operations, no gradient checkpointing, no sparse tensors, no distributed anything, and no convolution — the digits model is an MLP over raw pixels, not a CNN, which is a large part of why it stops at 97.56% rather than the ~99% a small conv net reaches on the same data.

Scope of the results. The spiral and digits numbers are single runs at one seed, not means over repeated seeds with error bars. The optimizer comparison is one problem. Timing figures come from a busy Windows desktop and carry visible run-to-run spread, which is why ranges are quoted rather than false precision.

Running it

pip install -r requirements.txt

python walkthrough.py          # regenerates every figure (~2.5 min)
python walkthrough.py --quick  # skips the slow scalar benchmark

python tests/run_all.py        # all checks, with the exact count
pytest tests/ -q               # same suites under pytest

walkthrough.py writes every quoted number to results/measurements.json, so the README and this page can be checked against what the code actually produced.