Introduction to Machine Learning

Backpropagation, Hands On

A deeper dive into the inner working of deep learning: the chain rule by hand, a network from scratch, the same network in PyTorch, and what to do when training goes wrong.

Five questions for today

  1. 1Why backpropagationHow do you get thousands of derivatives at once?
  2. 2Computation graphs by handCan you backpropagate through a graph with a pencil?
  3. 3From scratchCan you train a network with nothing but numpy?
  4. 4PyTorchWhat does a library do for you, and what does it not?
  5. 5When training goes wrongWhat do you check when the loss will not go down?

The mathematics is in the neural networks deck, Part 6. Today we run it: by hand, in numpy, in PyTorch, on the same 16 by 16 digits.

Where we left offBackpropagation in three lines

Forward: compute and store

\( \mathbf{z}^{[l]} = \mathbf{W}^{[l]} \mathbf{v}^{[l-1]} + \mathbf{b}^{[l]}, \quad \mathbf{v}^{[l]} = \sigma(\mathbf{z}^{[l]}) \)

Backward: reuse

\( \boldsymbol{\delta}^{[L]} = \hat{\mathbf{y}} - \mathbf{y}, \qquad \boldsymbol{\delta}^{[l]} = \big(\mathbf{W}^{[l+1]\top} \boldsymbol{\delta}^{[l+1]}\big) \odot \sigma'(\mathbf{z}^{[l]}) \)

\( \partial \ell / \partial \mathbf{W}^{[l]} = \boldsymbol{\delta}^{[l]}\, \mathbf{v}^{[l-1]\top}, \qquad \partial \ell / \partial \mathbf{b}^{[l]} = \boldsymbol{\delta}^{[l]} \)

The claim was that this gives every gradient for the price of about one forward pass. Is that true, and does it matter?

Two ways to get the gradientThe digits network, 8,554 weights, a batch of 32 real digits

Finite differences

Nudge one weight up and down by \(h = 10^{-5}\), recompute the loss, divide: \( \dfrac{\partial \mathcal{L}}{\partial w} \approx \dfrac{\mathcal{L}(w + h) - \mathcal{L}(w - h)}{2h} \). Two forward passes per weight.

Backpropagation

One forward pass, one backward pass, every derivative.

Press Compute: both methods run here, in your browser, on the same weights and digits.

Part 2 of 5

Computation graphs by hand

Can you backpropagate through a graph with a pencil?

One neuron, forward then backward\( L = \big(\sigma(w x + b) - y\big)^2 \) as a graph. Step through it, then move \(w\) and \(b\).

Three patterns in the backward flowWhat each kind of node does to the gradient that arrives from above

NodeForwardBackward: each input receivesSo it acts as
add\( z = x + y \)\( \bar z \) and \( \bar z \)a distributor: the same gradient to every input
multiply\( z = x y \)\( \bar z\, y \) and \( \bar z\, x \)a swap: each input gets the other one
max, ReLU\( z = \max(x, y) \)\( \bar z \) to the larger, 0 to the othera router: only the winner learns
matrix product\( \mathbf{z} = \mathbf{W} \mathbf{v} \)\( \bar{\mathbf{z}}\, \mathbf{v}^\top \) and \( \mathbf{W}^\top \bar{\mathbf{z}} \)the multiply rule, with transposes that make the shapes fit

Here \(\bar z = \partial L / \partial z\), the gradient arriving from above. The last row is the whole of backpropagation for a layer.

A debugging ruleA gradient always has the shape of the thing it is the gradient of: \( \partial L / \partial \mathbf{W} \) is \(m \times n\) like \(\mathbf{W}\). If your shapes do not match, the transpose is in the wrong place.

Part 3 of 5

From scratch

Can you train a network with nothing but numpy?

XOR, trained from scratchTwo inputs, a few sigmoid hidden units, one output, gradient descent on the four points

Exercise: make the network symmetricStart every weight at the same value. What happens, and why?

Pick "all weights 0.5" on the last slide and train. The hidden units start identical, compute the same value, receive the same \(\boldsymbol{\delta}\), and take the same step. They stay identical forever: a network of many units that behaves like one.

This is why weights start random: randomness breaks the symmetry, so each unit can become a different feature.

And random is not enough With 2 hidden units, some random starts end in a valley that does not solve XOR (press "New random start" a few times). In a Python check, 16 of 20 random starts solve it with 2 units and 20 of 20 with 4. Extra units make the landscape easier, not just the model bigger.

Backpropagation from scratch, on real digitsThe three lines, in numpy. Press Run.


import numpy as np, pandas as pd

d = pd.read_csv('mnist_16x16.csv')
X, y = d.drop(columns='label').values / 255, d.label.values
test = np.arange(len(y)) % 5 == 0
Y = np.eye(10)[y]                               # one-hot labels

rng = np.random.default_rng(0)
W1, b1 = rng.normal(0, np.sqrt(2 / 256), (32, 256)), np.zeros(32)
W2, b2 = rng.normal(0, np.sqrt(2 / 32), (10, 32)), np.zeros(10)

for epoch in range(10):
    for i in rng.permutation(np.where(~test)[0].reshape(-1, 32)):   # batches of 32
        z1 = X[i] @ W1.T + b1;  v1 = np.maximum(0, z1)                # forward, and store
        s = v1 @ W2.T + b2;  p = np.exp(s - s.max(1, keepdims=True)); p /= p.sum(1, keepdims=True)
        d2 = (p - Y[i]) / len(i)                                      # backward: delta at the output
        d1 = (d2 @ W2) * (z1 > 0)                                     # delta one layer down
        W2 -= 0.5 * d2.T @ v1;  b2 -= 0.5 * d2.sum(0)
        W1 -= 0.5 * d1.T @ X[i];  b1 -= 0.5 * d1.sum(0)
    p = np.maximum(0, X[test] @ W1.T + b1) @ W2.T + b2
    print('epoch', epoch + 1, ' held-out accuracy', round(np.mean(p.argmax(1) == y[test]), 3))
    

You should see the held-out accuracy reach about 0.90 within a few epochs, bouncing by a point or two: the same network as the digits slides of the last deck, now with every derivative written by hand. Try a learning rate of 5 instead of 0.5.

Gradient checkingTrust your backpropagation only after finite differences agree with it


import numpy as np, pandas as pd

d = pd.read_csv('mnist_16x16.csv')
X, y = d.drop(columns='label').values[:20] / 255, d.label.values[:20]   # 20 digits are enough
rng = np.random.default_rng(1)
W1, W2 = rng.normal(0, 0.1, (8, 256)), rng.normal(0, 0.1, (10, 8))

def loss(W1, W2):                                 # softmax cross-entropy, sigmoid hidden layer
    v = 1 / (1 + np.exp(-X @ W1.T));  s = v @ W2.T
    return np.mean(np.log(np.exp(s).sum(1)) - s[np.arange(20), y])

def backprop(W1, W2):
    v = 1 / (1 + np.exp(-X @ W1.T));  s = v @ W2.T
    p = np.exp(s) / np.exp(s).sum(1, keepdims=True);  p[np.arange(20), y] -= 1
    d2 = p / 20;  d1 = (d2 @ W2) * v * (1 - v)
    return d1.T @ X, d2.T @ v

g1, g2 = backprop(W1, W2)
h, worst = 1e-5, 0
for _ in range(10):                               # ten random weights of W1, checked numerically
    i, j = rng.integers(8), rng.integers(256)
    E = np.zeros_like(W1);  E[i, j] = h
    numeric = (loss(W1 + E, W2) - loss(W1 - E, W2)) / (2 * h)
    worst = max(worst, abs(numeric - g1[i, j]) / max(abs(numeric) + abs(g1[i, j]), 1e-12))
print('largest relative error:', f'{worst:.1e}')
    

Correct code gives a relative error around \(10^{-8}\). Now break it: delete * v * (1 - v) in backprop and run again. The error jumps to about 0.6. Finite differences are far too slow to train with, and exactly right for checking.

Part 4 of 5

PyTorch

What does a library do for you, and what does it not?

Autograd: the graph is built as you computePyTorch records every operation, then walks the record backwards


import torch

x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x[0]**2 + x[1]**2          # forward: builds the graph
y.backward()                   # backward: fills x.grad
print(x.grad)                  # tensor([4., 6.])
        

\(y = x_0^2 + x_1^2\), so \(\nabla y = (2x_0, 2x_1) = (4, 6)\) at \((2, 3)\). You never wrote the derivative.

Each tensor with requires_grad=True remembers the operation that made it. backward() is the backward sweep of the one-neuron graph, for any graph you build.

It is the micrograd of the Math and Python deck, for tensors, on a GPU.

The anatomy of a training loopFrom the MNIST notebook for today


model = SimpleNN()                                  # 784, 128 hidden, 10
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)

for epoch in range(20):
    model.train()
    for images, labels in train_loader:
        optimizer.zero_grad()                       # 1. clear old gradients
        loss = criterion(model(images), labels)     # 2. forward
        loss.backward()                             # 3. backward
        optimizer.step()                            # 4. update
    model.eval()
    with torch.no_grad():                           # no graph needed to evaluate
        dev_accuracy = evaluate_model(dev_loader)
        

Forget step 1 and gradients add up across batches: PyTorch accumulates on purpose.

Train and eval modes matter once layers behave differently while training (dropout).

The dev set is checked every epoch; the test set once, at the end.

Regularization is one argument: optim.Adam(..., weight_decay=1e-3).

The lab: four notebooks

  • Neural network from scratch: forward pass, backpropagation and the XOR training loop in numpy. Exercise: switch from one example at a time to full-batch gradient descent, and plot which converges faster.
  • PyTorch basics: tensors, autograd, nn.Module, optimizers.
  • Make the network symmetric: the basic PyTorch example on a curve. Start all weights equal and see what the fit looks like; then add a validation split and weight decay.
  • Deep learning for MNIST: the full 28 by 28 digits with a DataLoader, a dev set, and a test set.

All on intro2ml.com, on the schedule for today.

Part 5 of 5

When training goes wrong

What do you check when the loss will not go down?

The learning rate, on real digitsFive runs of plain SGD, same network, same 2,400 digits, only \(\alpha\) differs

Reading the curves Too small: correct, and hopelessly slow. Too large: every step overshoots, the network lands where it predicts the same thing for every digit, and the loss sits at \(\ln 10 \approx 2.30\), the loss of a uniform guess over ten classes.

Vanishing gradientsHow much gradient reaches each layer of an 8-layer network, before training

Backward, each layer multiplies \(\boldsymbol{\delta}\) by \(\sigma'(\mathbf{z})\), and a sigmoid's derivative is at most \(1/4\). Eight layers: eight shrinkings. ReLU's derivative is 1 wherever the unit is on, which is a large part of why deep networks became trainable.

A checklist for when the loss will not go down

  1. Check the loss before training. With ten classes it should be near \(\ln 10 \approx 2.30\). Much larger means the initial weights are too big.
  2. Overfit ten examples. A healthy network gets to zero training loss on ten digits in a few hundred steps. If it cannot, the bug is in the code, not the data.
  3. Check the gradient. Finite differences on a few weights, as in the notebook.
  4. Sweep the learning rate by factors of ten before touching anything else.
  5. Watch dead units. A ReLU whose input is always negative has zero gradient and never recovers; a too-large step can kill many at once.
  6. Plot training and validation loss together. Both high: underfitting. Training low, validation climbing: overfitting (the last slide of the neural networks deck).

Summary

The algorithm

Forward stores every value; backward multiplies local derivatives from the loss down, reusing them. Hundreds of times faster than finite differences on the digits network, a gap that grows with every weight you add, and exactly as accurate.

The practice

Break symmetry with random weights, check gradients numerically, sweep the learning rate, prefer ReLU in deep networks, and watch both loss curves.

What to take away

  1. Backpropagation gets all 8,554 gradients of the digits network in milliseconds; finite differences need 17,108 forward passes and seconds, and give the same numbers. The more weights, the bigger the gap.
  2. Every node passes gradients by a local rule: addition distributes, multiplication swaps, max routes. A layer is the multiply rule with transposes.
  3. Identical starting weights stay identical: random initialization is what lets hidden units become different features.
  4. Too large a learning rate lands on the loss of guessing, \(\ln 10\); too small barely moves. Sweep it by factors of ten.
  5. Sigmoids shrink the backward signal at every layer; in an 8-layer network the first layer gets tens of thousands of times less. ReLU does not.