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.
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.
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.
Conv → ReLU → Pool blocks followed by a classifier.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.Using device: cpu
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.
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.1437 train / 360 test images | image shape (1, 8, 8)
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.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\).
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.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,618The diagram below shows the whole network and how the tensor shape changes at each stage.
Cross-entropy loss + Adam. The dataset is tiny, so a handful of epochs reaches high accuracy in seconds.
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.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%
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.model. *(Hint: \((C_{in}\,k\,k+1)\,F\).)*BatchNorm2d layers — how do the loss curve and final accuracy change?MaxPool2d with a stride-2 convolution. Does accuracy improve?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.
# All of these come pre-installed on Google Colab.