Introduction to Machine Learning · Neural networks lab
Can you build a network that still reads a digit after it moves?
A hackathon in three tracks, then six problems on everything since the first neuron, all scored on one live board.
About 10 minutes for the challenge, 40 for the lab, 5 to compare results, 20 for the problems. The boards keep the results of all of it.
The same ink, the same shape, and the network is just as sure of the wrong answer. This is not one unlucky digit: on the 600 held-out digits, the network reads 91% as written and 24% once each is moved two pixels up, down, left or right. That is the failure the lecture ended on. Today you fix it.
Loading the board…
Loading the board…
Part 2 of 6
Where do you start, and what can you change?
scikit-learn's network, run on the next two slides. Change the width, the depth, the penalty on large weights, and whether the network also sees shifted copies of its training digits.
No installation. Each run takes seconds to half a minute.
Replace the fully connected layer with convolutions and pooling, then train it on shifted batches. Count what each change costs in weights.
In numpy on the slides, or PyTorch in Colab. Seconds on a CPU.
A recurrent network forecasts tomorrow's maximum temperature in Beirut from the last 14 days. Can it beat "tomorrow is like today", and a straight line?
For teams that finish track 2, or want time series.
from sklearn.neural_network import MLPClassifier
net = MLPClassifier(hidden_layer_sizes=(32,), max_iter=500, random_state=0)
net.fit(X_train, y_train) # the 1,900 training digits, 256 pixels each
score(net, 'the lecture network') # both tests, and the lower of the two
You should see accuracy 1.000 on the training digits, and 0.888 as written, 0.262 moved on the validation digits: it has learned its 1,900 digits, not to read moved ones. A hidden first cell loads the digits and builds the three sets (X_train, X_val, X_val_shift, and the hidden test digits), shift(X, dx, dy) and score(net).
from sklearn.neural_network import MLPClassifier
HIDDEN = (32,) # hidden layer widths: try (128,), or two layers (64, 32)
ALPHA = 0.0001 # the penalty on large weights (weight decay): try 0.001 or 0.01
SHIFTS = [] # moves (dx, dy) to copy every training digit with, e.g.
# [(2, 0), (-2, 0), (0, 2), (0, -2), (1, 0), (-1, 0), (0, 1), (0, -1)]
UPDATES = 5000 # the most weight updates training may make, each on a batch of 200 digits
X_more = np.vstack([X_train] + [shift(X_train, dx, dy) for dx, dy in SHIFTS])
y_more = np.tile(y_train, len(SHIFTS) + 1)
passes = max(1, UPDATES * 200 // len(y_more)) # passes over the data that make that many updates:
# 526 over the 1,900 digits, 58 over them and 8 copies
# (it stops sooner once the loss stops improving)
net = MLPClassifier(hidden_layer_sizes=HIDDEN, alpha=ALPHA, max_iter=passes, random_state=0)
net.fit(X_more, y_more)
score(net, f'{HIDDEN}, alpha {ALPHA}, {len(SHIFTS)} shifted copies')
Which helps the moved accuracy more: a wider network, or the shifted copies? The copies are moved training digits, never validation or test digits. With copies a run takes up to a minute here.
def make_cnn():
return nn.Sequential(
nn.Conv2d(1, 8, kernel_size=3, padding=1), # 8 maps, 16 by 16
nn.ReLU(), nn.MaxPool2d(2), # 8 by 8
nn.Conv2d(8, 16, kernel_size=3, padding=1), # 16 maps
nn.ReLU(), nn.MaxPool2d(2), # 4 by 4
nn.Flatten(), nn.Linear(16 * 4 * 4, 10))
cnn = train(make_cnn()) # 30 epochs, Adam
score(cnn, 'CNN')
cnn_aug = train(make_cnn(), augment=True) # every batch moved
score(cnn_aug, 'CNN on shifted batches')
The notebook builds the same split, moved digits and score as the slides, trains the lecture network in PyTorch, then this network, then lets you change it.
Colab needs a free Google account; the notebook fetches the digits from this site, and a CPU is enough: about ten seconds per network. The next slide runs a numpy version right here.
Before you believe a difference, run a second seed.
FILTERS = 8 # kernels in the first convolution: try 4 or 16
EPOCHS = 4 # passes over the 1,900 training digits
LR = 0.01 # Adam's step size
AUGMENT = False # True: every batch is moved by up to 2 pixels before the network sees it
net = [Conv(1, FILTERS, 3), ReLU(), MaxPool(), # FILTERS maps of 16 by 16, pooled to 8 by 8
Conv(FILTERS, 16, 3), ReLU(), MaxPool(), # 16 maps, pooled to 4 by 4
Flatten(), Dense(16 * 4 * 4, 10)] # one score per digit
# one convolution only: [Conv(1, FILTERS, 3), ReLU(), MaxPool(), Flatten(), Dense(FILTERS * 8 * 8, 10)]
history = train(net, EPOCHS, LR, augment=AUGMENT)
score_cnn(net, f'CNN, {FILTERS} kernels, {EPOCHS} epochs' + (', moved batches' if AUGMENT else ''))
show(net, history)
Press Run: about 5 seconds. You should see validation accuracy 0.944 as written and 0.604 moved, with 3,818 weights, against 0.262 moved for the lecture network; with AUGMENT = True, 0.922 and 0.886. The layers are written in numpy in a hidden first cell, the same convolution, pooling and backward pass as the slides.
A different question, so a different score: the error of the forecast in degrees, on 1,096 test days the model never saw. Lower is better.
Or the next slide: a recurrent network in numpy, in this page.
Then add the night minimum, the rain, the season. Does the network still win when both read the same inputs?
class Forecaster(nn.Module):
def __init__(self, n_in, hidden=16, kind='LSTM'):
super().__init__()
self.rnn = getattr(nn, kind)(n_in, hidden,
batch_first=True)
self.out = nn.Linear(hidden, 1)
def forward(self, x): # x: (days, 14, inputs)
h, _ = self.rnn(x) # the state after each day
return self.out(h[:, -1]).squeeze(-1)
Data: ERA5 reanalysis for Beirut via the Open-Meteo archive API, CC-BY 4.0.
INPUTS = ['tmax'] # what the network reads each day: try ['tmax', 'tmin', 'rain', 'season']
HIDDEN = 16 # numbers in the hidden state
EPOCHS = 30 # passes over the 2,908 training days
LR = 0.003 # Adam's step size: 0.01 trains faster and noisier
SEED = 0 # the starting weights and the order of the days: try 1
win, Xn = make_windows(INPUTS) # every 14-day window, standardized
model = RNN(Xn.shape[2], HIDDEN, seed=SEED)
history = fit(model, Xn, EPOCHS, LR, seed=SEED)
report(model, Xn, win, history, f'RNN {HIDDEN}, {"+".join(INPUTS)}, {EPOCHS} epochs')
Press Run: about 2 seconds. It trains on 2015 to 2021 and reports the error on the validation year, 2022: 2.25 °C for tomorrow = today, 2.13 for the linear model and 2.14 for the recurrent network; with all four inputs, 1.95 for both. SEED = 1 gives 2.12: a difference of 0.02 is noise. 2023 to 2025 is the test, opened at the end.
Part 3 of 6
What do you check when the numbers look wrong?
| What you see | The usual cause |
|---|---|
| The loss sits near 2.30 and accuracy near 10%: one digit for every image | The learning rate is too large (3.0 with plain SGD does it), or inputs and labels are out of step |
| Accuracy 39% where the same network gets 91% | opt.zero_grad() is missing, so gradients pile up across batches |
Expected 3D (unbatched) or 4D (batched) input to conv2d, but got input of size: [32, 256] | A convolution wants images: xb.view(-1, 1, 16, 16), batch, channels, height, width |
| 100% on the training digits, far less on held-out ones | Overfitting: fewer epochs, a penalty on weights, or more (or shifted) data |
| A test accuracy of 1.000 on the moved digits | The test digits reached training. Part 4 shows how easily |
Part 4 of 6
How do you use one without fooling yourself?
Explain an error message, line by line.
Propose one change, and say why it should help the shifted score.
Write the code for one idea you already have: a third convolution, a shift per digit.
Explain every line it gave you.
Running it, every time.
Printing shapes and a few predictions.
Comparing with the numbers you know: 0.888 and 0.262 on validation for the baseline.
Asking it, and yourself, how the number could be wrong.
Paste code you have not run and read.
Let it near the held-out digits.
Report a number you cannot reproduce, or explain.
from sklearn.neural_network import MLPClassifier
# The sets, as the hidden first cell builds them for everyone:
test = np.arange(len(y)) % 5 == 0 # every fifth digit: the 600 test digits
X_test, y_test = X[test], y[test]
X_shift = moved(X_test, 0) # the same 600 test digits, each moved two pixels
# (and from the other 2,400: X_train, 1,900 to train on; X_val and X_val_shift, 500 to choose with)
# The assistant: "Train on shifted examples so the network learns that position does not matter:"
X_more = np.vstack([X_train, X_shift])
y_more = np.concatenate([y_train, y_test])
net = MLPClassifier(hidden_layer_sizes=(32,), max_iter=500, random_state=0)
net.fit(X_more, y_more)
score(net, "the assistant's network", show_test=True) # this once, the test accuracy too
Validation says 0.898 and 0.650: better on moved digits than the baseline's 0.262, nothing strange. Test says 1.000 on the moved digits. The advice sounds right, and the code runs. Read where X_shift comes from: which line is the problem, what does 1.000 measure, and why did validation not see it?
Part 5 of 6
Which change bought the most?
Width alone bought nothing. Shifted copies did most of the work, for every architecture: they show the network what to ignore. The convolution did the rest, with a ninth of the weights of the wide network. And the moved 2 from the start? The lecture network reads a 6; with shifted copies, or as a CNN, a 2.
The LSTM beat the straight line by a tenth of a degree on maxima alone, and a straight line that reads the night minimum, the rain and the season does as well as it. Most of what the past two weeks can say about tomorrow, a linear model already hears. The rest of the error is weather that is not in the file: a front arriving from the sea, a dust storm from the east.
Loading the runs…
A team that won by training longer has not found a better network: run the others for as long before you compare. Then reveal the test set, and see who kept their place.
Loading the runs…
Part 6 of 6
Can you do by hand what the network does?
0.25, 25%, 1/4 or 1,184.Problems 3 and 4 read Figures A and B; every figure sits on its problem's slide.
How PyTorch counts. Conv2d(c_in, c_out, k, padding=p) has c_out filters, each a c_in by k by k block of weights, plus one bias per filter; padding=p adds p rows and columns of zeros on every side. Linear(n_in, n_out) has n_out by n_in weights plus one bias per output. Count weights and biases: by this rule the lecture network, 256 to 32 to 10, has 8,554.
Conv2d(1, 8, 3, padding=1)Conv2d(8, 16, 3, padding=1)Linear(16*4*4, 10)padding=0 in the first convolution instead: how many rows does each map have after it and the first pool? (The maps are square.)\(z = w_1 x\), \(h = \mathrm{ReLU}(z)\), \(\hat y = w_2 h\), \(L = (\hat y - y)^2\), on the example \(x = 2\), \(y = 1\).
Figure A. The network reads all 100 of its training digits by the end.
Figure B. The thin grey line, marked uniform guess, is the loss of a network that cannot tell the digits apart: it gives all ten the same probability.
Rows and columns of the output count from 1, top left.
How PyTorch counts. nn.RNN(n_in, H) reads n_in numbers each day into a hidden state of H numbers, and computes \(h_t = \tanh(W x_t + b + U h_{t-1} + b')\): \(W\) is \(H \times n_{in}\), \(U\) is \(H \times H\), and there are two bias vectors \(b, b'\) of \(H\) each. nn.LSTM(n_in, H) has four such blocks, one per gate (input, forget, cell, output), each with its own \(W\), \(U\), \(b\) and \(b'\). Count weights and biases.
nn.RNN(1, 16): a recurrent cell that reads 1 number per day (that day's maximum) into a hidden state of 16 numbersnn.LSTM(1, 16): an LSTM cell with the same input and hidden state sizesLinear(16, 1), which turns the last hidden state into tomorrow's forecastLoading the board…