Under the Hood
Book ↗
Projects/Foundations
Project 01

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 Value class that remembers its ancestors (the computational graph)
  • Local backward rules for each operation: + passes gradient straight through; * scales by the partner; tanh scales by 1 - tanh²
  • A topological sort of the graph so we can walk it backward in dependency order
  • Neuron, Layer, MLP built on top of Value so 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 files

Each 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.py

We 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

break_it_log.txt
# 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.
loss_curve.png
loss_curve.png
run_log.txt
# 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.0316

Read 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 →