Part II · ML foundations · Chapter 15

Project: train your own neural network

Write a neural network with nothing but NumPy: forward pass, cross-entropy, backpropagation, mini-batches and a validation split, all in about 200 lines you can read in one sitting. It trains in under a second on a laptop.

Why do this

Libraries hide the backward pass. Writing it once yourself, and checking it against nudged losses, turns chapters 5 to 13 from things you have read into things you have done. Every later idea, from attention to LoRA, is a variation on this loop.

What you need

  • Python 3.10 or newer and NumPy. The project's requirements.txt lists only numpy>=1.26.
  • Any laptop. No GPU, no downloads: the data is generated by the script.
  • The files: labs/numpy-neural-net/train.py and its requirements.txt, in this book's repository. Everything below runs from the repository root.
# from the repository root, in a fresh virtual environment
python3 -m venv .venv && . .venv/bin/activate
pip install -r labs/numpy-neural-net/requirements.txt
python labs/numpy-neural-net/train.py --gradcheck
python labs/numpy-neural-net/train.py --plot
Run status

Run here with Python 3.11 and NumPy 2.4.6 on a laptop CPU: the gradient check and a 300-epoch training run each finished in well under a second. The output below is from that run.

A tour of train.py

PieceWhat it doesChapter
make_spiralTwo interleaved spiral arms, 200 points each, with a little noise. No straight line separates them.14
splitShuffles, then holds back 20% for validation.11
standardising in mainSubtracts the training mean and divides by the training standard deviation, then applies the same numbers to validation.8
TinyNet.__init__Xavier initialisation for a 2 → hidden → 1 network.13
TinyNet.forwardtanh(x @ W1 + b1), then a sigmoid output: two matrix multiplies for a whole batch at once.4, 12
TinyNet.lossMean binary cross-entropy.9
TinyNet.gradientsBackpropagation, eight lines.12
gradient_checkCompares backprop with (loss(w + ε) − loss(w − ε)) / 2ε for random weights.12
the loop in mainMini-batch gradient descent with optional weight decay, printing train and validation loss every 25 epochs.6

The backward pass

This is the heart of the project. Compare each line with the hand calculation in chapter 12; the only difference is that every quantity is a matrix with one row per example.

def gradients(self, x, y):
    n = len(x)
    h, p = self.forward(x)
    d_out = ((p - y) / n)[:, None]   # sigmoid + cross-entropy: p - y, averaged
    g_W2 = h.T @ d_out                 # delta times the input each weight multiplied
    g_b2 = d_out.sum(axis=0)
    d_h = d_out @ self.W2.T            # back through the output weights
    d_z1 = d_h * (1.0 - h**2)          # back through tanh (slope 1 - tanh squared)
    g_W1 = x.T @ d_z1
    g_b1 = d_z1.sum(axis=0)
    return self.loss(p, y), [g_W1, g_b1, g_W2, g_b2]

What you should see

The gradient check prints backprop and numerical slopes side by side, then the worst relative gap. Anything below about 10−5 means the backward pass is right; the run here gave 1.8 × 10−10.

$ python labs/numpy-neural-net/train.py
data: 320 training and 80 validation points; network 2 -> 16 -> 1 (65 parameters)
epoch    1  train loss 0.6660  val loss 0.6666  train acc 0.613  val acc 0.600
epoch   50  train loss 0.5395  val loss 0.5671  train acc 0.694  val acc 0.700
epoch  100  train loss 0.3840  val loss 0.4766  train acc 0.850  val acc 0.762
epoch  150  train loss 0.1540  val loss 0.1671  train acc 0.975  val acc 0.975
epoch  200  train loss 0.0845  val loss 0.0996  train acc 0.994  val acc 0.988
epoch  300  train loss 0.0306  val loss 0.0396  train acc 1.000  val acc 1.000
lowest validation loss 0.0396 at epoch 300

(Some lines are left out.) Notice the plateau for the first 75 or so epochs, where the loss barely moves, then a sudden drop as the hidden neurons find useful directions. Plateaus like this are normal (chapter 6). With --plot the script draws the decision regions as text, with o and x for the two arms and . and : for the regions the network assigns to them; you can see the boundary winding between the arms.

Experiments

  1. Skip standardisation. Run python labs/numpy-neural-net/train.py --raw. In the run here, accuracy stayed near 60% after 300 epochs, against 100% with standardised inputs. The raw coordinates are small, so the tanh neurons start nearly linear and the gradients for the first layer are tiny. One line of preprocessing made the difference.
  2. Width. Try --hidden 2, 4 and 64. Where does the spiral become learnable? Does 64 learn faster or slower per epoch?
  3. Learning rate. Try --lr 0.05 and --lr 5. Find the largest rate that still trains smoothly.
  4. Overfitting. Make the data noisier by editing noise=0.04 in main to 0.15 and train for 2,000 epochs with --hidden 64. Watch training accuracy rise while validation accuracy stalls. Then add --l2 0.001.
  5. ReLU. Replace np.tanh with np.maximum(0, ...) in forward, and fix the slope in gradients to (h > 0). Run --gradcheck first; if you forget the slope fix, it will tell you.
  6. A second hidden layer. Add W3 and b3, extend forward and gradients, and keep the gradient check passing. This is the exercise that proves you understand backpropagation.

Where this leads

A deep-learning library does exactly this, with automatic gradients, GPU kernels and better optimisers. In PyTorch the whole gradients method becomes one call, loss.backward(). Chapter 20 uses PyTorch for a tiny language model, and you will recognise every part of its training loop.

Self-check

Why are the standardisation numbers computed on the training set only?

Computing them on all the data would let the validation set influence training, a small leak that makes validation scores less honest.

The gradient check reports a relative gap of 0.3. What do you do?

Treat the backward pass as wrong. Check each line against the forward pass: a missing activation slope, a wrong transpose or a forgotten 1/n are the usual causes.

Why does the loss sit still for many epochs before dropping?

Early on the hidden neurons point in unhelpful directions and the gradient is small: a plateau. Once a few neurons line up with the spiral's structure, progress speeds up.

Further reading