All Modules Module 8 Open in Colab

Module 8 — Lab 2: **Transfer learning** (feature extraction)

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

Training a good vision model from scratch needs a lot of data. Transfer learning sidesteps this: take a network already trained on ImageNet (1.2M images), freeze it, and train only a new classifier on top. The frozen network acts as a fixed *feature extractor*.

Dataset: Hymenoptera (ants vs. bees) — the classic *small, public* PyTorch dataset, ≈245 training and ≈153 validation images. It downloads as a zip from download.pytorch.org.

What you'll do

  1. Download the ants/bees dataset and build ImageFolder loaders.
  2. Load a pretrained ResNet-18 and freeze its weights.
  3. Replace the final layer with a fresh 2-class head and train only that head.
  4. Evaluate and visualise predictions.
1
# !pip install torch torchvision matplotlib --quietUncomment on a fresh environment to install the libraries.
import os, zipfile, urllib.requestStandard library: file paths, unzipping, and downloading.
import torchCore PyTorch.
import torch.nn as nnNeural-network layers and losses.
import torchvisionVision utilities.
from torchvision import datasets, transforms, modelsDataset loaders, transforms, and pretrained model architectures.
from torch.utils.data import DataLoaderBatches the dataset for training.
import matplotlib.pyplot as pltPlotting for image previews.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')GPU if available, otherwise CPU.
print('Using device:', device)Show the chosen device.
Output
Using device: cpu

1. Data

Download once and unzip. The archive contains hymenoptera_data/train and .../val, each with ants/ and bees/ subfolders — exactly the layout torchvision.datasets.ImageFolder expects.

2
URL = 'https://download.pytorch.org/tutorial/hymenoptera_data.zip'Location of the small ants/bees dataset on PyTorch's server.
if not os.path.isdir('hymenoptera_data'):Only download if the folder isn't already present.
print('Downloading...')Status message.
urllib.request.urlretrieve(URL, 'hymenoptera_data.zip')Download the zip archive.
with zipfile.ZipFile('hymenoptera_data.zip') as z:Open the downloaded zip.
z.extractall('.')Extract it into the current folder.
print('Done.')Status message.
else:Otherwise…
print('Already present.')…skip the download.
Output
Downloading...
Done.
3
# ImageNet stats — required because the pretrained backbone expects them
mean, std = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]The exact mean/std ImageNet was trained on — inputs must match them.
data_tf = {A dict holding one transform pipeline per split.
'train': transforms.Compose([Training pipeline (with augmentation).
transforms.RandomResizedCrop(224),Randomly crop and resize to 224×224 (ResNet's expected input size).
transforms.RandomHorizontalFlip(),Random left–right mirror.
transforms.ToTensor(),Convert to a 0–1 tensor.
transforms.Normalize(mean, std),Standardise with the ImageNet stats.
]),End the training pipeline.
'val': transforms.Compose([Validation pipeline (deterministic — no augmentation).
transforms.Resize(256),Resize the shorter side to 256.
transforms.CenterCrop(224),Centre-crop to 224×224.
transforms.ToTensor(),Convert to a tensor.
transforms.Normalize(mean, std),Same ImageNet normalisation.
]),End the validation pipeline.
}End the transforms dict.
root = 'hymenoptera_data'Folder that contains train/ and val/ subfolders.
image_sets = {s: datasets.ImageFolder(os.path.join(root, s), data_tf[s]) for s in ['train','val']}ImageFolder reads each class from its own subfolder; build one dataset per split.
loaders = {s: DataLoader(image_sets[s], batch_size=32, shuffle=(s=='train'), num_workers=0) for s in ['train','val']}Batch each split; shuffle only the training data. num_workers=0 for limited CPUs.
class_names = image_sets['train'].classesClass names inferred from the folder names: ['ants','bees'].
print('classes:', class_names)Show the classes.
print({s: len(image_sets[s]) for s in image_sets})Show how many images are in each split.
Output
classes: ['ants', 'bees']
{'train': 244, 'val': 153}

Peek at a batch

4
import numpy as npNumPy, used when converting tensors for plotting.
def denorm(x):Reverse the ImageNet normalisation for display.
m = torch.tensor(mean).view(3,1,1); s = torch.tensor(std).view(3,1,1)Mean/std reshaped to (C,1,1) so they broadcast over the image.
return (x*s + m).clamp(0,1)Undo normalisation and clip to 0–1.
imgs, labels = next(iter(loaders['train']))Grab one training batch (augmented images).
fig, axes = plt.subplots(1, 6, figsize=(14, 3))A row of 6 sub-plots.
for i, ax in enumerate(axes):Loop over the 6 images.
ax.imshow(denorm(imgs[i]).permute(1,2,0).numpy())Draw de-normalised image i (reordered to H×W×C).
ax.set_title(class_names[labels[i]]); ax.axis('off')Title with the class name; hide the axis ticks.
plt.tight_layout(); plt.show()Render the previews.
Output

2. Freeze the backbone

Load ResNet-18 with pretrained weights, turn off gradients for every parameter, then swap the final fc layer for a new one with 2 outputs. Only the new layer has requires_grad=True, so only it is trained.

5
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)Load ResNet-18 with weights pretrained on ImageNet.
for p in model.parameters(): # freeze everythingLoop over every parameter to freeze it.
p.requires_grad = FalseTurn off gradients so the backbone will not update during training.
num_features = model.fc.in_featuresThe number of features feeding the final layer (512 for ResNet-18).
model.fc = nn.Linear(num_features, len(class_names)) # new head, trainable by defaultReplace the 1000-class head with a fresh 2-class layer (its parameters train by default).
model = model.to(device)Move the model to the device.
trainable = [n for n, p in model.named_parameters() if p.requires_grad]Collect the names of parameters that still require gradients…
print('Trainable parameters:', trainable)…which should be only the new fc (head) layer.
Output
Trainable parameters: ['fc.weight', 'fc.bias']

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 only the head

Because the optimiser is given only model.fc.parameters(), the frozen backbone never updates.

6
criterion = nn.CrossEntropyLoss()Classification loss.
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)Optimise ONLY the new head — the frozen backbone is deliberately excluded.
EPOCHS = 5 # few epochs — fine on CPU (dataset is tiny)Train for a few epochs — fast because the dataset is tiny.
def run_epoch(phase):Run one pass over either the 'train' or 'val' split.
model.train() if phase == 'train' else model.eval()Training mode for 'train', eval mode otherwise.
total_loss = correct = total = 0Running totals for loss, correct predictions, and samples.
for x, y in loaders[phase]:Iterate that split's batches.
x, y = x.to(device), y.to(device)Move the batch to the device.
with torch.set_grad_enabled(phase == 'train'):Track gradients only during training.
out = model(x)Forward pass.
loss = criterion(out, y)Compute the loss.
if phase == 'train':Only update weights while training.
optimizer.zero_grad(); loss.backward(); optimizer.step()Zero gradients, backpropagate, and step the optimiser.
total_loss += loss.item() * x.size(0)Accumulate the loss.
correct += (out.argmax(1) == y).sum().item()Count correct predictions.
total += y.size(0)Count samples.
return total_loss/total, correct/totalReturn average loss and accuracy.
for epoch in range(1, EPOCHS+1):Loop over epochs.
tl, ta = run_epoch('train')Train for one epoch.
vl, va = run_epoch('val')Evaluate on the validation split.
print(f'Epoch {epoch:2d} | train {ta*100:5.1f}% | val {va*100:5.1f}%')Log train and validation accuracy.
Output
Epoch  1 | train  64.3% | val  83.7%
Epoch  2 | train  76.2% | val  87.6%
Epoch  3 | train  87.3% | val  91.5%
Epoch  4 | train  87.7% | val  92.2%
Epoch  5 | train  91.4% | val  94.1%

4. Inspect predictions

7
imgs, labels = next(iter(loaders['val']))Take one batch from the validation set.
model.eval()Eval mode.
with torch.no_grad():No gradients for inference.
preds = model(imgs.to(device)).argmax(1).cpu()Predict classes and move results to the CPU.
fig, axes = plt.subplots(1, 6, figsize=(14, 3))A row of 6 sub-plots.
for i, ax in enumerate(axes):Loop over the 6 images.
ax.imshow(denorm(imgs[i]).permute(1,2,0).numpy())Show de-normalised image i.
ok = preds[i] == labels[i]True if the prediction is correct.
ax.set_title(class_names[preds[i]], color=('green' if ok else 'red'))Predicted label, green if right, red if wrong.
ax.axis('off')Hide axis ticks.
plt.tight_layout(); plt.show()Render the predictions.
Output

Exercises

  1. How many parameters are actually trained here vs. the full ResNet-18? *(Hint: compare fc to sum(p.numel() ...).)*
  2. Swap resnet18 for mobilenet_v2 or resnet50. Does val accuracy change? (Watch the head attribute name.)
  3. Remove the ImageNet normalisation. Why does accuracy collapse?
  4. Train for only 2 epochs — feature extraction usually reaches high accuracy very fast. Why?

Recap

A frozen ImageNet backbone + a tiny trained head reaches strong accuracy on a few hundred images. In the next lab you'll unfreeze the backbone and *fine-tune* it for a further boost.

Module 8 — Lab 2: **Transfer learning** (feature extraction)

Overview What you'll do 1. Data 2. Freeze the backbone Architecture 3. Train only the head 4. Inspect predictions Exercises