All Modules Module 8 Open in Colab

Module 8 — Lab 1: A Convolutional Network **from scratch**

Module 8 · Hands-on Lab · Dr. Abdulkarim Albanna

Every line of code is explained. Follow the steps in order, or open the whole notebook in Colab.

Run this lab in Google Colab

How to use this lab

Each numbered step is one runnable code cell, shown line by line with an explanation under every line, followed by the output you should expect (sample images, the drawn architecture, training logs and predictions). The labs are built to run on Google Colab — click the button above to open the full notebook, or copy each cell with the button and run the steps in order.

Overview

Deep Learning course · Dr. Abdulkarim Albanna · albanna-tutorials.com

In this notebook you build a small CNN by hand and train it to recognise handwritten-digit glyphs. We use the digits dataset that ships *inside* scikit-learn: 1,797 tiny 8×8 grayscale images of the digits 0–9.

Nothing is downloaded — the data is bundled with scikit-learn (already installed on Colab). The whole set is a few hundred kilobytes, so it trains in *seconds* on any CPU.

What you'll do

  1. Load the built-in digit glyphs (no download).
  2. Define a CNN: Conv → ReLU → Pool blocks followed by a classifier.
  3. Train with cross-entropy + Adam and track accuracy.
  4. Evaluate on a held-out test split and inspect predictions.
1
# All of these come pre-installed on Google Colab.
import torchImports PyTorch — tensors and autograd.
import torch.nn as nnNeural-network layers, containers and losses.
from torch.utils.data import TensorDataset, DataLoaderWrap in-memory tensors as a dataset and load them in batches.
from sklearn.datasets import load_digitsThe built-in handwritten-digit glyph dataset (ships with scikit-learn — no download).
from sklearn.model_selection import train_test_splitHelper to split data into train and test sets.
import matplotlib.pyplot as pltPlotting, used to preview the glyphs.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')Use the GPU if one is available, otherwise the CPU.
print('Using device:', device)Confirms whether you're running on GPU or CPU.
Output
Using device: cpu

1. Data

load_digits() returns 1,797 images of shape 8×8 with pixel values 0–16. We scale them to 0–1, add a channel dimension (1×8×8), and split into train/test — all in memory, no files.

2
digits = load_digits()Load the 1,797 built-in 8×8 digit images (as NumPy arrays).
X = digits.images.astype('float32') / 16.0 # (N,8,8) pixels scaled to 0..1Scale the 0–16 pixel values to 0–1 (shape N×8×8).
y = digits.targetThe integer labels 0–9.
classes = [str(i) for i in range(10)]Human-readable class names '0'…'9'.
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=0, stratify=y)Split 80/20 into train/test, keeping the class balance (stratify).
def to_dataset(imgs, labels):A small helper that turns arrays into a PyTorch dataset.
t = torch.tensor(imgs, dtype=torch.float32).unsqueeze(1) # add a channel dim -> (N,1,8,8)To a float tensor with an added channel dim → (N,1,8,8).
return TensorDataset(t, torch.tensor(labels, dtype=torch.long))Pair the image tensor with its label tensor as a TensorDataset.
train_loader = DataLoader(to_dataset(Xtr, ytr), batch_size=64, shuffle=True)Shuffled training mini-batches of 64.
test_loader = DataLoader(to_dataset(Xte, yte), batch_size=128, shuffle=False)Test batches of 128, no shuffling needed for evaluation.
print(len(Xtr), 'train /', len(Xte), 'test images | image shape (1, 8, 8)')Report the split sizes and the image shape.
Output
1437 train / 360 test images  |  image shape (1, 8, 8)

Peek at a few glyphs

3
imgs, labels = next(iter(train_loader))Pull one batch of glyphs and their labels.
fig, axes = plt.subplots(1, 8, figsize=(12, 2))Create a row of 8 sub-plots.
for i, ax in enumerate(axes):Loop over the 8 axes with their index.
ax.imshow(imgs[i].squeeze().numpy(), cmap='gray')Draw glyph i in grayscale (drop the channel dim).
ax.set_title(classes[labels[i]], fontsize=10)Title each glyph with its digit label.
ax.axis('off')Hide the axis ticks for a clean thumbnail.
plt.tight_layout(); plt.show()Lay out and render the figure.
Output

2. The model

Two conv blocks grow the channels (16 → 32 → 64) while pooling shrinks the 8×8 map to 2×2. A small fully-connected head maps the 64×2×2 features to 10 class scores.

Output-size rule for a conv/pool layer: \(O = \lfloor (I - k + 2P)/S \rfloor + 1\).

4
class SimpleCNN(nn.Module):Define the network as a subclass of nn.Module (the base class for all models).
def __init__(self, num_classes=10):Constructor; defaults to 10 output classes (digits 0–9).
super().__init__()Initialise the nn.Module machinery — must be called first.
self.features = nn.Sequential(The convolutional feature extractor, run as an ordered stack of layers.
nn.Conv2d(1, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(),Block 1: 1→16 channels, 3×3 filters (padding keeps size), BatchNorm, ReLU.
nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),Widen to 32 channels with another 3×3 conv, BatchNorm + ReLU.
nn.MaxPool2d(2), # 8x8 -> 4x42×2 max-pool halves the spatial size: 8×8 → 4×4.
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),Widen to 64 channels with a third conv, BatchNorm + ReLU.
nn.MaxPool2d(2), # 4x4 -> 2x2Max-pool halves 4×4 → 2×2.
)End of the feature extractor.
self.classifier = nn.Sequential(The fully-connected head that turns features into class scores.
nn.Flatten(),Flatten the 64×2×2 feature map into a length-256 vector.
nn.Dropout(0.25),Randomly zero 25% of activations to reduce overfitting.
nn.Linear(64*2*2, 64), nn.ReLU(),Dense 256→64 layer followed by ReLU.
nn.Linear(64, num_classes),Final dense layer → 10 class logits.
)End of the classifier.
def forward(self, x):Defines the forward pass through the network.
x = self.features(x)Run the input through all conv/pool layers.
return self.classifier(x)Flatten + classify into logits and return them.
model = SimpleCNN().to(device)Instantiate the model and move it to the GPU/CPU.
n_params = sum(p.numel() for p in model.parameters())Count all learnable parameters.
print(model)Print the layer structure.
print(f'Trainable parameters: {n_params:,}')Report the total parameter count.
Output
SimpleCNN(
  (features): Sequential(
    (0): Conv2d(1, 16, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (2): ReLU()
    (3): Conv2d(16, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (4): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (5): ReLU()
    (6): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (7): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (8): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (9): ReLU()
    (10): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  )
  (classifier): Sequential(
    (0): Flatten(start_dim=1, end_dim=-1)
    (1): Dropout(p=0.25, inplace=False)
    (2): Linear(in_features=256, out_features=64, bias=True)
    (3): ReLU()
    (4): Linear(in_features=64, out_features=10, bias=True)
  )
)
Trainable parameters: 40,618

Architecture

The diagram below shows the whole network and how the tensor shape changes at each stage.

The full network and how the tensor shape changes at each stage.

3. Train

Cross-entropy loss + Adam. The dataset is tiny, so a handful of epochs reaches high accuracy in seconds.

5
criterion = nn.CrossEntropyLoss()Cross-entropy loss for multi-class classification (log-softmax + NLL combined).
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)Adam optimiser updates all weights; learning rate 0.001.
EPOCHS = 8Number of passes over the training set — the data is tiny so this is fast.
def evaluate(loader):Compute accuracy on a given data loader.
model.eval()Eval mode: disables dropout and uses running BatchNorm stats.
correct = total = 0Counters for correct predictions and total samples.
with torch.no_grad():Disable gradient tracking — faster and lighter, no training here.
for x, y in loader:Iterate over the evaluation batches.
x, y = x.to(device), y.to(device)Move the batch to the model's device.
pred = model(x).argmax(1)Forward pass; take the highest-scoring class per image.
correct += (pred == y).sum().item()Add the number of correct predictions in this batch.
total += y.size(0)Add this batch's size to the running total.
return correct / totalReturn accuracy = correct / total.
for epoch in range(1, EPOCHS+1):Main training loop over epochs.
model.train()Training mode: enables dropout and updates BatchNorm stats.
running = 0.0Accumulator for the epoch's average loss.
for x, y in train_loader:Iterate over the training mini-batches.
x, y = x.to(device), y.to(device)Move inputs and labels to the device.
optimizer.zero_grad()Clear gradients left from the previous step.
loss = criterion(model(x), y)Forward pass and compute the batch loss.
loss.backward()Backpropagate to compute gradients.
optimizer.step()Update the weights using those gradients.
running += loss.item() * x.size(0)Accumulate total loss (scaled by batch size).
train_loss = running / len(Xtr)Average training loss over the epoch.
acc = evaluate(test_loader)Measure test accuracy after the epoch.
print(f'Epoch {epoch:2d} | train loss {train_loss:.3f} | test acc {acc*100:.2f}%')Log loss and accuracy for this epoch.
Output
Epoch  1 | train loss 1.675 | test acc 74.44%
Epoch  2 | train loss 0.512 | test acc 96.94%
Epoch  3 | train loss 0.169 | test acc 97.50%
Epoch  4 | train loss 0.085 | test acc 98.33%
Epoch  5 | train loss 0.058 | test acc 98.06%
Epoch  6 | train loss 0.034 | test acc 98.33%
Epoch  7 | train loss 0.024 | test acc 98.33%
Epoch  8 | train loss 0.019 | test acc 98.33%

4. Inspect predictions

6
imgs, labels = next(iter(test_loader))Take one batch from the test set.
model.eval()Eval mode for inference.
with torch.no_grad():No gradients needed for prediction.
preds = model(imgs.to(device)).argmax(1).cpu()Predict the digit of each glyph and bring results back to the CPU.
fig, axes = plt.subplots(1, 8, figsize=(12, 2))A row of 8 sub-plots.
for i, ax in enumerate(axes):Loop over the 8 glyphs.
ax.imshow(imgs[i].squeeze().numpy(), cmap='gray')Show glyph i in grayscale.
ok = preds[i] == labels[i]True if the prediction matches the actual label.
ax.set_title(classes[preds[i]], color=('green' if ok else 'red'), fontsize=10)Title = predicted digit, green if correct, red if wrong.
ax.axis('off')Hide axis ticks.
plt.tight_layout(); plt.show()Render the grid of predictions.
Output

Exercises

  1. Count the parameters of the first conv layer by hand and confirm against model. *(Hint: \((C_{in}\,k\,k+1)\,F\).)*
  2. Remove all BatchNorm2d layers — how do the loss curve and final accuracy change?
  3. Replace MaxPool2d with a stride-2 convolution. Does accuracy improve?
  4. This dataset is small enough to overfit — add more epochs and watch train vs test accuracy diverge.

Recap

You built a CNN from primitive layers and trained it end-to-end on a tiny, built-in glyph dataset — no downloads, no pretrained weights. Next you'll reuse a network someone else trained on millions of images.

Module 8 — Lab 1: A Convolutional Network **from scratch**

Overview What you'll do 1. Data 2. The model Architecture 3. Train 4. Inspect predictions Exercises