๐Ÿ“š Study Notes / Home / Neural Nets / Session 8
Session 08 ยท Hands-on Project โ€” ANN

Let's actually build a neural network โ€” twice

Up to now we've been learning the pieces: neurons, activations, gradient descent, backprop, regularization, initialization. Today we put them all together and build a working digit-recognising neural network โ€” first by hand in pure NumPy so you can see every gear turn, then the idiomatic way in PyTorch so you see how a framework does the heavy lifting for you. Everything here is real, runnable code. Grab a coffee, open a notebook, and let's build.

โฑ 20 min read๐Ÿ“– 4 topics

1 The project & the dataset


Explain like I'm 5

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" net โ€” quick reminder

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.

๐Ÿ“ฆ
1. Data
Load, normalize, split into train / val / test
โ†’
๐Ÿงฑ
2. Model
Choose layers, sizes, activations
โ†’
๐Ÿ‹๏ธ
3. Train
Forward โ†’ loss โ†’ backprop โ†’ update, repeat
โ†’
๐Ÿ“Š
4. Evaluate
Measure accuracy on unseen data
โ†’
๐Ÿ”ง
5. Improve
Tune, regularize, repeat

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:

SplitJobTypical size
Training setThe model learns from these โ€” this is what gradient descent looks at.~60,000 (85%)
Validation setUsed during development to tune choices (layer sizes, learning rate). The model never trains on it.~10,000
Test setTouched once, at the very end, to report honest final accuracy.10,000
Worked example โ€” loading and inspecting MNIST

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].

Key takeaway

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.

Recap MNIST = 70,000 handwritten digits, each a 784-pixel image with a 0โ€“9 label. We normalize pixels to 0โ€“1, split into train / validation / test so we can measure honest performance, and one-hot-encode labels so the net's ten outputs can be scored against the truth.

2 A neural net from scratch in NumPy


Explain like I'm 5

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:

StepMathMeaning
1z1 = XยทW1 + b1Weighted sums into the hidden layer
2a1 = ReLU(z1)Apply the activation
3z2 = a1ยทW2 + b2Weighted sums into the output layer (the logits)
4a2 = 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.

Worked example โ€” the complete NumPy network

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.

Watch out โ€” the two classic backprop bugs

(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.

Why softmax subtracts the max

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.

Recap A from-scratch net is a loop of four steps: forward (two matmuls + ReLU + softmax), cross-entropy loss, backprop (chain rule, with the softmax+CE gradient collapsing to 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


Explain like I'm 5

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-wrotePyTorch gives us
Weight matrices & the forward formulann.Module with nn.Linear layers (init handled for us)
The entire backprop blockloss.backward() โ€” automatic differentiation
The manual W -= lr * dW updatesAn optimizer (e.g. optim.SGD or Adam)
Worked example โ€” the complete PyTorch network

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.

Key takeaway โ€” the framework hides backprop, not the ideas

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.

The #1 PyTorch beginner bug

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.

Why 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.

Recap PyTorch rebuilds the same net with 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


Explain like I'm 5

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 seeDiagnosisWhat to do
Both losses high, both falling slowlyUnderfitting โ€” model too weak or not trained enoughBigger/more layers, train longer, higher learning rate
Train loss โ†“ but val loss flattens then risesOverfitting โ€” memorising the training setRegularize (below), more data, stop earlier
Both low and close togetherHealthy โ€” good fit ๐ŸŽ‰You're done; report test accuracy
Loss is NaN or explodesLearning rate too high / bad initLower lr, check normalization & init (Session 7)
Worked example โ€” a training loop that tracks validation loss

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-4 to 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)
Good initialization, revisited (Session 7)

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.

Common pitfalls โ€” a checklist

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.

Recap Split off a validation set, watch train vs val loss every epoch, and read the gap: underfitting โ†’ make the model stronger; overfitting โ†’ regularize with dropout / weight decay / more data and use early stopping. Good init and a sane learning rate keep training healthy. Judge with a confusion matrix and misclassified examples โ€” and keep the test set untouched until the very end.

โ˜… 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:

The big idea

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

Papers, docs & deep dives