The Learning Machine
scalar autograd, neurons, MLP, training loop.
Build a scalar autograd engine yourself, train a tiny MLP on a 2D toy dataset, then deliberately break the gradient flow and watch learning stop cold.
The Concept
A neural network learns in three moves: it makes a guess, you measure how wrong the guess is, and then you figure out which internal numbers caused the error and by how much. That third step is the whole story — automatic differentiation, reverse-mode autodiff, backpropagation — all names for "walk the recipe backward and ask each ingredient how much of this mess was your fault."
We build:
- A
Valueclass that remembers its ancestors (the computational graph) - Local backward rules for each operation:
+passes gradient straight through;*scales by the partner;tanhscales by1 - tanh² - A topological sort of the graph so we can walk it backward in dependency order
Neuron,Layer,MLPbuilt on top ofValueso the whole thing learns end to end
Why It Matters
Without .backward(), training is a mystery. With it built from scratch, every odd debugging symptom — a parameter whose grad is always zero, a loss that flatlines, a weight that explodes — has a mechanical explanation you can trace.
The Build
build.py$python projects/01_the-learning-machine/build.py --tiny
"""
Project 1: The Learning Machine — complete working build.
A scalar autograd engine plus a tiny MLP trained on a 2D toy dataset.
This is the assembled, runnable form of the code presented step-by-step
in Chapter 1 of *Under the Hood*.
The step_*.py files in this folder show each piece in isolation, the way
the book introduces them. This file is what they assemble into.
Run:
python build.py --tiny # 100 epochs, CPU, <5s
python build.py --full # 1000 epochs, more thorough
python build.py --output-dir out # write loss curve + final state to out/
"""
from __future__ import annotations
import argparse
import math
import random
from pathlib import Path
class Value:
"""A scalar that remembers how it was produced, so we can walk gradients back."""
def __init__(self, data: float, _children: tuple = (), _op: str = "") -> None:
self.data = float(data)
self.grad = 0.0
self._prev = set(_children)
self._op = _op
self._backward = lambda: None
def __repr__(self) -> str:
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
def __add__(self, other: "Value | float") -> "Value":
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward() -> None:
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other: "Value | float") -> "Value":
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward() -> None:
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def __pow__(self, other: float) -> "Value":
assert isinstance(other, (int, float)), "only int/float exponents supported"
out = Value(self.data**other, (self,), f"**{other}")
def _backward() -> None:
self.grad += (other * self.data ** (other - 1)) * out.grad
out._backward = _backward
return out
def __neg__(self) -> "Value":
return self * -1
def __sub__(self, other: "Value | float") -> "Value":
return self + (-other if isinstance(other, Value) else -float(other))
def __truediv__(self, other: "Value | float") -> "Value":
if isinstance(other, Value):
return self * (other ** -1)
return self * (1.0 / float(other))
def __radd__(self, other: float) -> "Value":
return self + other
def __rmul__(self, other: float) -> "Value":
return self * other
def __rsub__(self, other: float) -> "Value":
return Value(other) - self
def tanh(self) -> "Value":
x = self.data
t = (math.exp(2 * x) - 1) / (math.exp(2 * x) + 1)
out = Value(t, (self,), "tanh")
def _backward() -> None:
self.grad += (1 - out.data**2) * out.grad
out._backward = _backward
return out
def exp(self) -> "Value":
out = Value(math.exp(self.data), (self,), "exp")
def _backward() -> None:
self.grad += out.data * out.grad
out._backward = _backward
return out
def relu(self) -> "Value":
out = Value(0.0 if self.data < 0 else self.data, (self,), "ReLU")
def _backward() -> None:
self.grad += (out.data > 0) * out.grad
out._backward = _backward
return out
def backward(self) -> None:
"""Topologically order the graph, then run each node's local _backward in reverse."""
topo: list[Value] = []
visited: set[Value] = set()
def build_topo(v: Value) -> None:
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for node in reversed(topo):
node._backward()
class Neuron:
def __init__(self, nin: int) -> None:
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x: list[Value | float]) -> Value:
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self) -> list[Value]:
return self.w + [self.b]
class Layer:
def __init__(self, nin: int, nout: int) -> None:
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x: list[Value | float]) -> Value | list[Value]:
outs = [n(x) for n in self.neurons]
return outs[0] if len(outs) == 1 else outs
def parameters(self) -> list[Value]:
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin: int, nouts: list[int]) -> None:
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x: list[Value | float]) -> Value | list[Value]:
out: Value | list[Value | float] = list(x)
for layer in self.layers:
out = layer(out if isinstance(out, list) else [out])
return out
def parameters(self) -> list[Value]:
return [p for layer in self.layers for p in layer.parameters()]
def toy_dataset() -> tuple[list[list[float]], list[float]]:
"""Two clusters in 2D — top-right is +1, bottom-left is -1."""
xs = [
[-1.2, -0.8],
[-1.0, -1.1],
[-0.8, -1.3],
[1.0, 0.9],
[1.2, 1.1],
[0.8, 1.3],
]
ys = [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0]
return xs, ys
def squared_loss(model: MLP, xs: list[list[float]], ys: list[float]) -> Value:
preds = [model(x) for x in xs]
# preds is list[Value] because the final layer has 1 neuron.
loss = sum(((p - y) ** 2 for p, y in zip(preds, ys)), Value(0.0))
return loss
def train(
model: MLP,
xs: list[list[float]],
ys: list[float],
epochs: int,
lr: float,
) -> list[float]:
history: list[float] = []
for epoch in range(epochs):
loss = squared_loss(model, xs, ys)
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.grad
history.append(loss.data)
return history
def write_loss_curve_png(history: list[float], path: Path) -> None:
"""Write the training loss curve as a PNG. Skips silently if matplotlib unavailable."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(history)
ax.set_xlabel("epoch")
ax.set_ylabel("squared loss")
ax.set_title("Project 1: training loss")
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(path, dpi=120)
plt.close(fig)
def write_run_log(
history: list[float], preds_final: list[Value], ys: list[float], path: Path
) -> None:
lines = [
f"# Project 1 run log",
f"final loss : {history[-1]:.6f}",
f"epoch 0 loss : {history[0]:.6f}",
"",
"# predictions vs targets",
]
for p, y in zip(preds_final, ys):
lines.append(f" pred={p.data:+.4f} target={y:+.1f} |diff|={abs(p.data - y):.4f}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tiny", action="store_true", help="Proxy run (100 epochs).")
parser.add_argument("--full", action="store_true", help="Full run (1000 epochs).")
parser.add_argument("--epochs", type=int, default=None, help="Override epoch count.")
parser.add_argument("--lr", type=float, default=0.05, help="Learning rate.")
parser.add_argument("--seed", type=int, default=0, help="Random seed.")
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).parent / "outputs",
help="Where to write loss curve + run log.",
)
args = parser.parse_args()
if args.epochs is None:
args.epochs = 1000 if args.full else 100
random.seed(args.seed)
args.output_dir.mkdir(parents=True, exist_ok=True)
xs, ys = toy_dataset()
model = MLP(nin=2, nouts=[3, 1])
history = train(model, xs, ys, epochs=args.epochs, lr=args.lr)
preds_final = [model(x) for x in xs]
print(f"Project 1: trained {args.epochs} epochs (seed {args.seed}, lr {args.lr})")
print(f" initial loss: {history[0]:.6f}")
print(f" final loss : {history[-1]:.6f}")
print(f" predictions:")
for p, y in zip(preds_final, ys):
sign = "OK" if (p.data > 0) == (y > 0) else "WRONG"
print(f" pred={p.data:+.4f} target={y:+.1f} {sign}")
write_loss_curve_png(history, args.output_dir / "loss_curve.png")
write_run_log(history, preds_final, ys, args.output_dir / "run_log.txt")
print(f"\nOutputs written to {args.output_dir.resolve()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The Steps
13 reference filesEach step is the code for one piece, in isolation — read them as the book introduces them. They assemble into build.py above.
Step 01Build A Number That Remembers Where It Came Fromstep_01_build-a-number-that-remembers-where-it-came-from.py
"""
Project 1: Step 1 — Build a Number That Remembers Where It Came From
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
x = 2.0
y = 3.0
z = x * y
print(z) # 6.0
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data
self.grad = 0.0
self._prev = set(_children)
self._op = _op
self._backward = lambda: None
Step 02Teach The Number How To Addstep_02_teach-the-number-how-to-add.py
"""
Project 1: Step 2 — Teach the Number How to Add
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
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
other.grad += out.grad
out._backward = _backward
return out
Step 03Teach The Number How To Multiplystep_03_teach-the-number-how-to-multiply.py
"""
Project 1: Step 3 — Teach the Number How to Multiply
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
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
other.grad += self.data * out.grad
out._backward = _backward
return out
Step 04Add A Nonlinearitystep_04_add-a-nonlinearity.py
"""
Project 1: Step 4 — Add a Nonlinearity
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
import math
def tanh(self) -> 'Value':
x = self.data
t = (math.exp(2 * x) - 1) / (math.exp(2 * x) + 1)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - out.data**2) * out.grad
out._backward = _backward
return out
Step 05Build The Backward Passstep_05_build-the-backward-pass.py
"""
Project 1: Step 5 — Build the Backward Pass
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
def backward(self):
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)
self.grad = 1.0
for node in reversed(topo):
node._backward()
Step 06Add The Rest Of The Small Operationsstep_06_add-the-rest-of-the-small-operations.py
"""
Project 1: Step 6 — Add the Rest of the Small Operations
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __pow__(self, other):
assert isinstance(other, (int, float))
out = Value(self.data**other, (self,), f'**{other}')
def _backward():
self.grad += (other * self.data**(other - 1)) * out.grad
out._backward = _backward
return out
def __truediv__(self, other):
return self * (other ** -1)
def exp(self):
x = self.data
out = Value(math.exp(x), (self,), 'exp')
def _backward():
self.grad += out.data * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(0 if self.data < 0 else self.data, (self,), 'ReLU')
def _backward():
self.grad += (out.data > 0) * out.grad
out._backward = _backward
return out
Step 07Verify The Gradients Numericallystep_07_verify-the-gradients-numerically.py
"""
Project 1: Step 7 — Verify the Gradients Numerically
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
def finite_diff(f: callable, x: float, h: float = 1e-6) -> float:
return (f(x + h) - f(x - h)) / (2 * h)
x = Value(0.7)
y = x.tanh()
y.backward()
print("autograd:", x.grad)
import math
fd = (math.tanh(0.7 + 1e-6) - math.tanh(0.7 - 1e-6)) / (2e-6)
print("finite diff:", fd)
Step 08Build A Neuronstep_08_build-a-neuron.py
"""
Project 1: Step 8 — Build a Neuron
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
import random
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
out = act.tanh()
return out
def parameters(self):
return self.w + [self.b]
Step 09Build A Layer And An Mlpstep_09_build-a-layer-and-an-mlp.py
"""
Project 1: Step 9 — Build a Layer and an MLP
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
outs = [n(x) for n in self.neurons]
return outs[0] if len(outs) == 1 else outs
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sz = [nin] + nouts
self.layers = [Layer(sz[i], sz[i+1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x) if isinstance(x, list) else layer([x])
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
Step 10Create A Toy Datasetstep_10_create-a-toy-dataset.py
"""
Project 1: Step 10 — Create a Toy Dataset
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
xs = [
[-1.2, -0.8],
[-1.0, -1.1],
[-0.8, -1.3],
[1.0, 0.9],
[1.2, 1.1],
[0.8, 1.3],
]
ys = [-1, -1, -1, 1, 1, 1]
Step 11Define The Lossstep_11_define-the-loss.py
"""
Project 1: Step 11 — Define the Loss
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
def loss_fn(model: 'MLP', xs: list, ys: list) -> tuple:
preds = [model(x) for x in xs]
loss = sum((pred - y)**2 for pred, y in zip(preds, ys))
return loss, preds
Step 12Run The Training Loopstep_12_run-the-training-loop.py
"""
Project 1: Step 12 — Run the Training Loop
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
for epoch in range(100):
loss, preds = loss_fn(model, xs, ys)
for p in model.parameters():
p.grad = 0.0
loss.backward()
lr = 0.05
for p in model.parameters():
p.data -= lr * p.grad
print(epoch, loss.data)
Step 13Plot The Decision Boundary Over 100 Epochsstep_13_plot-the-decision-boundary-over-100-epochs.py
"""
Project 1: Step 13 — Plot the Decision Boundary Over 100 Epochs
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
import numpy as np
import matplotlib.pyplot as plt
def plot_boundary(model, xs, ys, epoch):
x_min, x_max = -2, 2
y_min, y_max = -2, 2
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200),
np.linspace(y_min, y_max, 200))
zz = np.zeros_like(xx)
for i in range(xx.shape[0]):
for j in range(xx.shape[1]):
zz[i, j] = model([float(xx[i, j]), float(yy[i, j])]).data
plt.figure(figsize=(5, 5))
plt.contourf(xx, yy, zz, levels=20, cmap='RdBu', alpha=0.6)
plt.contour(xx, yy, zz, levels=[0], colors='black')
for x, y in zip(xs, ys):
color = 'red' if y == 1 else 'blue'
plt.scatter(x[0], x[1], c=color, edgecolors='k')
plt.title(f"epoch {epoch}")
plt.xlim(x_min, x_max)
plt.ylim(y_min, y_max)
plt.show()
Break It
break_it.pyWe replace Value.__mul__'s backward function with a no-op. Forward pass still works (predictions look normal at epoch 0), but no blame ever flows back through any multiplication. Since every weight is multiplied by an input somewhere, the gradients never reach the parameters.
Result of python break_it.py --tiny:
baseline initial=0.880232 final=0.006837
broken __mul__ initial=0.880232 final=0.880203
The broken version's loss moves by 0.00003 over 100 epochs. The baseline's loss moves by 0.87. The forward pass is fine; the backward pass is the entire story.
Lesson: every weight in a neural network is multiplied by something. If __mul__ cannot propagate gradients, nothing learns. This is why a single broken _backward in a custom op is a silent killer — your loss plateau is not "the model converged" or "the learning rate is wrong"; it is "no gradients are flowing."
$python projects/01_the-learning-machine/break_it.py --tiny
"""
Project 1: BREAK IT — kill the gradient flow through multiplication.
We replace `Value.__mul__`'s backward pass with a no-op. The forward pass
still works (predictions look normal at epoch 0), but no blame ever flows
back through any multiplication. Since every weight is multiplied by an
input somewhere, the gradients never reach the parameters — and the loss
refuses to fall.
Run:
python break_it.py --tiny
Expected output: final loss is roughly the same as initial loss
(no training happened), and many predictions are WRONG.
"""
from __future__ import annotations
import argparse
import random
from pathlib import Path
from build import MLP, Value, squared_loss, toy_dataset
def install_broken_mul() -> None:
"""Sabotage: __mul__ forward is unchanged but _backward propagates nothing."""
def __mul__(self: Value, other: Value | float) -> Value:
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward() -> None:
# SABOTAGE: gradient flow through multiplication is removed.
# Forward still works; backward is a no-op.
pass
out._backward = _backward
return out
Value.__mul__ = __mul__ # type: ignore[method-assign]
def train_and_report(
model: MLP,
xs: list[list[float]],
ys: list[float],
epochs: int,
lr: float,
label: str,
) -> tuple[float, float]:
initial_loss = squared_loss(model, xs, ys).data
for _ in range(epochs):
loss = squared_loss(model, xs, ys)
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.grad
final_loss = squared_loss(model, xs, ys).data
print(f"{label:20s} initial={initial_loss:.6f} final={final_loss:.6f}")
return initial_loss, final_loss
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tiny", action="store_true", help="Proxy run (100 epochs).")
parser.add_argument("--full", action="store_true", help="Full run (1000 epochs).")
parser.add_argument("--epochs", type=int, default=None)
parser.add_argument("--lr", type=float, default=0.05)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).parent / "outputs",
)
args = parser.parse_args()
if args.epochs is None:
args.epochs = 1000 if args.full else 100
xs, ys = toy_dataset()
# First: working baseline.
random.seed(args.seed)
baseline = MLP(nin=2, nouts=[3, 1])
base_initial, base_final = train_and_report(baseline, xs, ys, args.epochs, args.lr, "baseline")
# Now: sabotage and re-run.
install_broken_mul()
random.seed(args.seed)
broken = MLP(nin=2, nouts=[3, 1])
broken_initial, broken_final = train_and_report(broken, xs, ys, args.epochs, args.lr, "broken __mul__")
args.output_dir.mkdir(parents=True, exist_ok=True)
log = args.output_dir / "break_it_log.txt"
log.write_text(
f"# Project 1 BREAK IT — kill gradient flow through __mul__\n"
f"baseline: initial={base_initial:.6f} final={base_final:.6f}\n"
f"broken __mul__: initial={broken_initial:.6f} final={broken_final:.6f}\n"
f"\n"
f"Lesson: without gradient flow through multiplication, every weight is\n"
f"effectively untrained. The forward pass produces output, but no learning\n"
f"happens. The final loss is roughly the initial loss.\n",
encoding="utf-8",
)
print(f"\nLog written to {log.resolve()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Outputs
# Project 1 BREAK IT — kill gradient flow through __mul__
baseline: initial=0.880232 final=0.006837
broken __mul__: initial=0.880232 final=0.880203
Lesson: without gradient flow through multiplication, every weight is
effectively untrained. The forward pass produces output, but no learning
happens. The final loss is roughly the initial loss.
# Project 1 run log
final loss : 0.006911
epoch 0 loss : 0.880232
# predictions vs targets
pred=-0.9628 target=-1.0 |diff|=0.0372
pred=-0.9675 target=-1.0 |diff|=0.0325
pred=-0.9687 target=-1.0 |diff|=0.0313
pred=+0.9607 target=+1.0 |diff|=0.0393
pred=+0.9704 target=+1.0 |diff|=0.0296
pred=+0.9684 target=+1.0 |diff|=0.0316Read it in the book
This page ships the code. The full derivation — why the chain rule is mechanical not magical, what the computational graph buys you, the long-form reasoning — lives in Chapter 1 of Under the Hood.
Get the book →