1 The project & the dataset
Imagine you have a giant shoebox full of 70,000 little photos, and on each photo someone drew a number from 0 to 9 with a pencil. We want to teach a computer to look at any one of those scribbles and shout out the right number. To do that we show it tens of thousands of examples โ "this squiggle is a 7, this one is a 2" โ until it gets the hang of it. Then we test it on photos it has never seen to check it actually learned, instead of just memorising.
Our goal for this whole session is one concrete project: train a fully-connected neural network to recognise handwritten digits. The dataset we'll use is the famous MNIST dataset โ 70,000 grayscale images of handwritten digits (0โ9), each one a tiny 28ร28 pixel square. It is the "hello world" of machine learning: small enough to train on a laptop, but real enough to teach you everything.
What one example looks like
Each image is 28ร28 = 784 pixels. Every pixel is a single brightness number
from 0 (black) to 255 (white). The label is the correct answer โ the
digit the image actually shows. So one training example is a pair:
(image of 784 numbers, correct digit).
A fully-connected network (also called a multilayer perceptron, or MLP) is the kind we've studied all course: every neuron in one layer connects to every neuron in the next. We flatten the 28ร28 image into one long row of 784 numbers and feed it in. (In Session 9 we'll meet convolutions, which are smarter about images โ but the humble MLP already gets us well over 97% accuracy here.)
The end-to-end machine-learning workflow
Every supervised ML project โ not just this one โ follows the same five-stage pipeline. Burn this into your memory; it's the map for the rest of the session.
Why we split the data into three piles
The single most important rule in machine learning: you must test on data the model never trained on. Otherwise the model can just memorise the answers and look brilliant while being useless on anything new (we'll call this overfitting). So we split our examples into three groups:
| Split | Job | Typical size |
|---|---|---|
| Training set | The model learns from these โ this is what gradient descent looks at. | ~60,000 (85%) |
| Validation set | Used during development to tune choices (layer sizes, learning rate). The model never trains on it. | ~10,000 |
| Test set | Touched once, at the very end, to report honest final accuracy. | 10,000 |
Here's the very first code you'd run. It loads MNIST, normalizes the pixels, and prints the shapes so you understand exactly what you're holding:
import numpy as np from sklearn.datasets import fetch_openml # Download MNIST (70,000 images of 784 pixels each) mnist = fetch_openml('mnist_784', version=1, as_frame=False) X = mnist.data.astype(np.float32) # shape (70000, 784), values 0..255 y = mnist.target.astype(np.int64) # shape (70000,), values 0..9 # Normalize pixels to the 0..1 range (Session 7: scaled inputs train better) X = X / 255.0 # Split: 60k train, 10k test (MNIST's standard split is the last 10k) X_train, X_test = X[:60000], X[60000:] y_train, y_test = y[:60000], y[60000:] print(X_train.shape, y_train.shape) # (60000, 784) (60000,) print(X_train.min(), X_train.max()) # 0.0 1.0 print("first label:", y_train[0]) # e.g. 5
Notice the normalization step โ dividing by 255. Recall from Session 7 that neural nets train far more reliably when inputs are on a small, consistent scale. Feeding raw 0โ255 values would make the early gradients wildly large.
One more data trick: one-hot labels
Our network will output ten numbers โ one "score" per digit. To compare those scores against
the true answer, it helps to turn each label into a one-hot vector:
a length-10 list that is all zeros except a single 1 at the correct digit's position. The label
3 becomes [0,0,0,1,0,0,0,0,0,0].
The project: classify 28ร28 handwritten digits with a fully-connected net. The workflow never changes โ data โ model โ train โ evaluate โ improve โ and the golden rule is to always judge the model on data it has never seen.
2 A neural net from scratch in NumPy
Building a net in pure NumPy is like baking bread from raw flour instead of buying it. It's more work, but you see and touch every ingredient. We'll do four things over and over: (1) push the picture through the net to get a guess (forward), (2) measure how wrong the guess was (loss), (3) figure out which knobs to turn and by how much (backprop), and (4) nudge every knob a tiny bit in the right direction (update). Do that thousands of times and the scribbles start getting recognised.
We're going to tie together everything from Sessions 3โ5 in one file. Our architecture is a simple two-layer MLP:
- Input: 784 numbers (the flattened image).
- Hidden layer: 128 neurons with a ReLU activation (recall from Session 4: ReLU just sets negatives to zero).
- Output layer: 10 neurons, one per digit, turned into probabilities with softmax.
The four moving parts
Part A โ the forward pass
The forward pass is just two matrix multiplications with an
activation in between. For a batch of images X:
| Step | Math | Meaning |
|---|---|---|
| 1 | z1 = XยทW1 + b1 | Weighted sums into the hidden layer |
| 2 | a1 = ReLU(z1) | Apply the activation |
| 3 | z2 = a1ยทW2 + b2 | Weighted sums into the output layer (the logits) |
| 4 | a2 = softmax(z2) | Turn the 10 scores into probabilities that sum to 1 |
Part B โ the loss
We use cross-entropy loss, the standard for classification. It is
small when the probability assigned to the correct digit is high, and large when the
model is confidently wrong. For one example it's simply -log(probability of
the true class).
Part C โ backpropagation
Backpropagation (Session 5) is just the chain rule applied
backwards through the net to find how the loss changes with respect to every weight โ the
gradients. There's a famous and beautiful simplification: when you
pair softmax with cross-entropy, the gradient at the output collapses to the tidy expression
(a2 โ y_onehot) โ predicted minus actual. That one fact does most of
the work.
Part D โ the gradient-descent update
Finally we nudge every weight a small step against its gradient (Session 3):
W = W โ learning_rate ร gradient. Repeat the whole loop and the loss
steadily falls.
This is a full, runnable program. It assumes X_train, y_train
from Topic 1. Read the comments โ every line maps to something we've studied.
import numpy as np np.random.seed(42) # ---------- helpers ---------- def one_hot(y, num_classes=10): out = np.zeros((y.size, num_classes)) out[np.arange(y.size), y] = 1 # put a 1 at the correct digit return out def relu(z): return np.maximum(0, z) # negatives become 0 def softmax(z): # subtract row max for numerical stability, then normalize z = z - z.max(axis=1, keepdims=True) e = np.exp(z) return e / e.sum(axis=1, keepdims=True) # ---------- initialize weights (Session 7: He init for ReLU) ---------- n_in, n_hidden, n_out = 784, 128, 10 W1 = np.random.randn(n_in, n_hidden) * np.sqrt(2.0 / n_in) b1 = np.zeros(n_hidden) W2 = np.random.randn(n_hidden, n_out) * np.sqrt(2.0 / n_hidden) b2 = np.zeros(n_out) # ---------- hyperparameters ---------- lr = 0.1 # learning rate (Session 3) epochs = 20 # full passes over the training data batch = 128 # mini-batch size (Session 3: SGD) n = X_train.shape[0] Y_train = one_hot(y_train) # ---------- the training loop ---------- for epoch in range(epochs): # shuffle each epoch so batches differ perm = np.random.permutation(n) Xs, Ys = X_train[perm], Y_train[perm] for i in range(0, n, batch): xb = Xs[i:i+batch] # (B, 784) yb = Ys[i:i+batch] # (B, 10) one-hot B = xb.shape[0] # ----- FORWARD PASS ----- z1 = xb @ W1 + b1 # (B, 128) a1 = relu(z1) # (B, 128) z2 = a1 @ W2 + b2 # (B, 10) logits a2 = softmax(z2) # (B, 10) probabilities # ----- LOSS (cross-entropy, averaged over the batch) ----- loss = -np.sum(yb * np.log(a2 + 1e-9)) / B # ----- BACKPROP (chain rule, Session 5) ----- dz2 = (a2 - yb) / B # softmax+CE shortcut: pred - actual dW2 = a1.T @ dz2 # (128, 10) db2 = dz2.sum(axis=0) # (10,) da1 = dz2 @ W2.T # (B, 128) dz1 = da1 * (z1 > 0) # ReLU gradient: 1 where z1>0 else 0 dW1 = xb.T @ dz1 # (784, 128) db1 = dz1.sum(axis=0) # (128,) # ----- GRADIENT-DESCENT UPDATE (Session 3) ----- W2 -= lr * dW2; b2 -= lr * db2 W1 -= lr * dW1; b1 -= lr * db1 print(f"epoch {epoch+1:2d} loss {loss:.4f}") # ---------- evaluate on the held-out test set ---------- def predict(X): a1 = relu(X @ W1 + b1) return softmax(a1 @ W2 + b2).argmax(axis=1) acc = (predict(X_test) == y_test).mean() print(f"test accuracy: {acc*100:.2f}%") # ~97% after 20 epochs
That is a complete neural network โ forward, loss, backprop, update โ in about 50 lines, with no framework. Run it and you'll watch the loss fall and finish around 97% test accuracy.
(1) Shape mismatches. 90% of "from scratch" pain is matrices not lining
up. Print .shape at every step. (2) Forgetting the
1/B. If you don't average the gradient over the batch, your
effective learning rate scales with batch size and training explodes. Note how
dz2 is divided by B above.
np.exp of a large logit overflows to infinity. Subtracting the
row's maximum before exponentiating gives the exact same probabilities (it cancels
out) but keeps the numbers safe. This is a standard, important trick.
pred โ actual), and the
gradient-descent update. He initialization and input normalization make it
train smoothly โ that's why we studied Session 7.
3 The same net in PyTorch
If NumPy was baking bread from raw flour, PyTorch is a bread machine. You still decide what kind of bread you want and the ingredients, but you press one button and it does the kneading for you. The magic button is called autograd: PyTorch quietly remembers every calculation, so when you say "how wrong were we?", it figures out all the gradients by itself. No hand-written backprop โ that whole scary middle section of our NumPy code disappears.
We'll rebuild the exact same two-layer MLP, but the idiomatic PyTorch way. The framework gives us three big conveniences:
| We hand-wrote | PyTorch gives us |
|---|---|
| Weight matrices & the forward formula | nn.Module with nn.Linear layers (init handled for us) |
| The entire backprop block | loss.backward() โ automatic differentiation |
The manual W -= lr * dW updates | An optimizer (e.g. optim.SGD or Adam) |
A full, runnable training script. Compare it line-by-line with the NumPy version โ the structure is identical, but backprop is now a single call.
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset from torchvision import datasets, transforms device = "cuda" if torch.cuda.is_available() else "cpu" # ---------- 1. DATA ---------- tfm = transforms.Compose([ transforms.ToTensor(), # to tensor, scales pixels to 0..1 transforms.Normalize((0.1307,), (0.3081,)), # MNIST mean/std ]) train_ds = datasets.MNIST("./data", train=True, download=True, transform=tfm) test_ds = datasets.MNIST("./data", train=False, download=True, transform=tfm) train_loader = DataLoader(train_ds, batch_size=128, shuffle=True) test_loader = DataLoader(test_ds, batch_size=256) # ---------- 2. MODEL ---------- class MLP(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Flatten(), # (B,1,28,28) -> (B,784) nn.Linear(784, 128), # hidden layer nn.ReLU(), # activation (Session 4) nn.Linear(128, 10), # output layer (logits) ) def forward(self, x): return self.net(x) model = MLP().to(device) # ---------- 3. LOSS + OPTIMIZER ---------- # CrossEntropyLoss applies softmax internally, so the model outputs raw logits loss_fn = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1) # ---------- 4. TRAIN ---------- for epoch in range(20): model.train() for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) logits = model(xb) # FORWARD loss = loss_fn(logits, yb) # LOSS optimizer.zero_grad() # clear last step's gradients (!) loss.backward() # BACKPROP โ autograd does it all optimizer.step() # UPDATE every weight print(f"epoch {epoch+1:2d} loss {loss.item():.4f}") # ---------- 5. EVALUATE ---------- model.eval() correct = 0 with torch.no_grad(): # no gradients needed at test time for xb, yb in test_loader: xb, yb = xb.to(device), yb.to(device) preds = model(xb).argmax(dim=1) correct += (preds == yb).sum().item() print(f"test accuracy: {100*correct/len(test_ds):.2f}%") # ~97-98%
Same architecture, same result โ but the hand-written gradients are gone. The three lines
zero_grad() โ backward() โ
step() are the heart of every PyTorch training loop you will
ever write.
loss.backward() is literally doing the chain-rule arithmetic you
coded by hand in Topic 2 โ PyTorch just recorded the operations as you ran the forward pass
and replays them in reverse. Knowing what's under the hood is exactly why we built it from
scratch first. The framework saves typing, not understanding.
Forgetting optimizer.zero_grad(). PyTorch accumulates
gradients by default, so if you don't clear them each step they pile up across batches and
training goes haywire. Always zero, backward, step โ in that order.
CrossEntropyLoss takes raw logits
In NumPy we applied softmax then cross-entropy as two steps. PyTorch's
nn.CrossEntropyLoss fuses them into one numerically-stable operation,
so your model's last layer should output raw logits โ do not add a
softmax yourself. Doing both is a common, silent mistake that hurts accuracy.
nn.Module + nn.Linear
layers, a loss function, and an optimizer. The manual backprop block becomes a single
loss.backward() thanks to autograd, and the weight updates become
optimizer.step(). Same maths, far less code.
4 Training, evaluating & improving
Teaching the net is like coaching a student for an exam. You give them practice questions (training data) and watch two scores: how well they do on the practice sheet, and how well they do on a fresh mock exam (validation data). If they ace the practice sheet but flop the mock, they've just memorised the answers instead of learning โ that's overfitting. The fixes are the same as for a real student: give them more variety, don't let them cram pointlessly, and stop the moment the mock score stops improving.
Splitting off a validation set
In Topic 1 we set aside a test set. But to tune the model during development we also carve a validation set out of the training data โ typically the last chunk. We watch its loss every epoch:
from torch.utils.data import random_split # carve 10,000 examples out of the 60,000 training images for validation train_ds, val_ds = random_split(train_ds, [50000, 10000]) val_loader = DataLoader(val_ds, batch_size=256)
Monitoring train vs validation loss
The single most useful habit in training: plot both curves together. The gap between them tells you almost everything.
| What you see | Diagnosis | What to do |
|---|---|---|
| Both losses high, both falling slowly | Underfitting โ model too weak or not trained enough | Bigger/more layers, train longer, higher learning rate |
| Train loss โ but val loss flattens then rises | Overfitting โ memorising the training set | Regularize (below), more data, stop earlier |
| Both low and close together | Healthy โ good fit ๐ | You're done; report test accuracy |
| Loss is NaN or explodes | Learning rate too high / bad init | Lower lr, check normalization & init (Session 7) |
Here's the practical loop you'd actually use, computing validation loss each epoch:
def evaluate(loader): model.eval() total, correct, loss_sum = 0, 0, 0.0 with torch.no_grad(): for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) logits = model(xb) loss_sum += loss_fn(logits, yb).item() * xb.size(0) correct += (logits.argmax(1) == yb).sum().item() total += xb.size(0) return loss_sum / total, correct / total best_val, patience, waited = 1e9, 3, 0 for epoch in range(50): model.train() for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) loss = loss_fn(model(xb), yb) optimizer.zero_grad(); loss.backward(); optimizer.step() val_loss, val_acc = evaluate(val_loader) print(f"epoch {epoch+1} val_loss {val_loss:.4f} val_acc {val_acc*100:.2f}%") # EARLY STOPPING: stop if val loss hasn't improved for `patience` epochs if val_loss < best_val: best_val, waited = val_loss, 0 torch.save(model.state_dict(), "best.pt") # keep the best model else: waited += 1 if waited >= patience: print("early stopping"); break
The early stopping logic โ save the model whenever validation loss hits a new low, and quit if it hasn't improved in a few epochs โ is the simplest, most effective regularizer there is.
Applying regularization (Session 6)
When you spot overfitting, reach for the tools from Session 6. In PyTorch they're nearly free to add:
- Dropout โ randomly zero some hidden activations during training so the
net can't rely on any one neuron. Add
nn.Dropout(0.2)after the ReLU. - Weight decay (L2) โ gently shrink weights toward zero. Just pass
weight_decay=1e-4to the optimizer. - More data / data augmentation โ small random shifts and rotations of the images give the net more variety to learn from.
# model with dropout self.net = nn.Sequential( nn.Flatten(), nn.Linear(784, 128), nn.ReLU(), nn.Dropout(0.2), # Session 6: regularization nn.Linear(128, 10), ) # optimizer with L2 weight decay + Adam (often trains faster than plain SGD) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
nn.Linear already uses a sensible default init (Kaiming/He-style),
which is why our PyTorch net "just works." In the NumPy version we did it by hand with
np.sqrt(2/n_in). Bad init is a top cause of a loss that refuses to
fall or that NaNs out โ if training won't start, suspect init or learning rate first.
Reading the results โ accuracy isn't everything
Once you have a trained model, look beyond the single accuracy number. A confusion matrix shows which digits get mixed up (4s and 9s are classic confusions). Look at the actual misclassified images โ often they're genuinely ambiguous scribbles, which tells you the model is doing fine and the data is just hard.
Data leakage: never let test data touch training, and compute
normalization stats from the training set only.
Forgetting model.eval(): dropout and batch-norm
behave differently at test time โ switch to eval mode or your accuracy will look wrong.
Learning rate too high/low: the #1 reason training fails. Try 0.1 for SGD,
1e-3 for Adam, then adjust.
Tuning on the test set: if you keep peeking at test accuracy and tweaking,
you've secretly turned it into a validation set โ keep it sacred for the final report.
โ Putting it all together
This session was the payoff for everything in Sessions 1โ7. Here's the one-paragraph story that ties the whole project together:
We took a real dataset (MNIST), normalized it and split it into
train / validation / test, then built a fully-connected net twice.
In NumPy we hand-coded the forward pass (matmuls + ReLU +
softmax), the cross-entropy loss, backprop (the chain rule,
with the softmax+CE gradient collapsing to pred โ actual), and the
gradient-descent update โ using He initialization from
Session 7 so it trained smoothly. Then in PyTorch we rebuilt the exact same
model with nn.Module and let autograd do the
backprop and an optimizer do the updates. Finally we watched
train vs validation loss to spot overfitting, fought it with
dropout and weight decay (Session 6) and early stopping, and
reported honest accuracy on the untouched test set โ around 97โ98%. You now know how every
neural network on Earth is actually trained.
Quick self-check
Why do we split data into train, validation, and test sets?
Train teaches the model; validation lets us tune choices without cheating; test gives one honest, final accuracy number on data the model has never seen. Without this split we can't tell learning from memorising (overfitting).
In the NumPy net, why is the output-layer gradient just a2 โ y_onehot?
Because softmax paired with cross-entropy simplifies algebraically: the messy derivatives cancel and the gradient of the loss w.r.t. the logits collapses to predicted-probabilities minus the one-hot truth. It's the neatest result in all of backprop.
What does loss.backward() replace from our NumPy code?
The entire hand-written backprop block (computing dz2, dW2, da1, dz1, dW1โฆ). PyTorch's autograd records the forward operations and automatically computes every gradient via the chain rule โ the same maths, done for you.
Training loss keeps falling but validation loss starts rising. What's happening and what do you do?
That's overfitting โ the model is memorising the training set. Fix it with regularization (dropout, weight decay), more/augmented data, or early stopping (stop when validation loss stops improving).
Why must the PyTorch model output raw logits instead of softmax probabilities?
Because nn.CrossEntropyLoss applies softmax internally
(in a numerically stable, fused way). Adding your own softmax first double-applies it and hurts
accuracy.
You forgot optimizer.zero_grad(). What goes wrong?
PyTorch accumulates gradients by default, so without zeroing them each step they pile up across batches, producing huge, wrong updates and unstable training.
๐ References & Further Reading
Class material
- Your course handout: "SST Deep Learning handout (Session 8)" โ the accompanying notes for this hands-on project session, available at priyanshsaxena.com/sst-deep-learning-nn.
Papers, docs & deep dives
- PyTorch โ Deep Learning with PyTorch: A 60 Minute Blitz โ the official quickstart that mirrors exactly what we built in Topic 3 (tensors, autograd, nn.Module, training loop).
- Andrej Karpathy โ Neural Networks: Zero to Hero โ a superb video course that builds backprop and nets from scratch, the perfect companion to our NumPy implementation.
- The MNIST database of handwritten digits โ the original dataset home page, with benchmark accuracies for many model types.
- PyTorch docs โ Autograd mechanics โ how
backward()actually computes gradients, if you want to peek behind the curtain. - Stanford CS231n โ Backpropagation, intuitions โ the clearest written explanation of the chain rule and gradient flow we coded by hand.