All Modules Module 8 Open in Colab

Module 8 — Lab 3: **Fine-tuning** a pretrained network

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

Feature extraction (Lab 2) froze the backbone. Fine-tuning goes further: we let the pretrained weights keep learning — but gently, with a small learning rate — so the features themselves adapt to our task.

Same *small, public* dataset as Lab 2: Hymenoptera (ants vs. bees). This lets you compare the two strategies directly.

Feature extraction vs. fine-tuning

Backbone weightsLearning rateWhen to prefer
Feature extractionfrozennormalvery little data / very different-but-generic task
Fine-tuningtrainablesmall (e.g. 1e-4)enough data / task close to the source domain

A common recipe: warm up the head first (like Lab 2), *then* unfreeze and fine-tune everything at a low LR.

1
# !pip install torch torchvision matplotlib --quietUncomment to install the libraries on a fresh environment.
import os, zipfile, urllib.requestStandard library: paths, unzip, download.
import torchCore PyTorch.
import torch.nn as nnLayers and losses.
from torchvision import datasets, transforms, modelsDatasets, transforms, and pretrained models.
from torch.utils.data import DataLoaderBatches the dataset.
import matplotlib.pyplot as pltPlotting.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')GPU if available, otherwise CPU.
print('Using device:', device)Show the device.
Output
Using device: cpu

1. Data (same as Lab 2)

2
URL = 'https://download.pytorch.org/tutorial/hymenoptera_data.zip'URL of the small ants/bees dataset.
if not os.path.isdir('hymenoptera_data'):Download only if it isn't already present.
urllib.request.urlretrieve(URL, 'hymenoptera_data.zip')Download the zip.
with zipfile.ZipFile('hymenoptera_data.zip') as z:Open it.
z.extractall('.')Extract into the current folder.
mean, std = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]ImageNet normalisation statistics.
data_tf = {A dict of transform pipelines per split.
'train': transforms.Compose([transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(),Train: random resized crop to 224 and a horizontal flip…
transforms.ToTensor(), transforms.Normalize(mean, std)]),…then convert to a tensor and ImageNet-normalise.
'val': transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224),Val: resize to 256 and centre-crop to 224…
transforms.ToTensor(), transforms.Normalize(mean, std)]),…to tensor and normalise (no augmentation).
}End the transforms dict.
root = 'hymenoptera_data'Dataset root folder.
image_sets = {s: datasets.ImageFolder(os.path.join(root, s), data_tf[s]) for s in ['train','val']}Build the train/val datasets from the class subfolders.
loaders = {s: DataLoader(image_sets[s], batch_size=32, shuffle=(s=='train'), num_workers=0) for s in ['train','val']}Batch each split; shuffle training only. num_workers=0 for limited CPUs.
class_names = image_sets['train'].classesClass names: ['ants','bees'].
print('classes:', class_names, '|', {s: len(image_sets[s]) for s in image_sets})Show the classes and per-split image counts.
Output
classes: ['ants', 'bees'] | {'train': 244, 'val': 153}

2. Load the model — leave the backbone trainable

This time we do not freeze the parameters. Every weight can update during training.

3
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)Load the ImageNet-pretrained ResNet-18.
model.fc = nn.Linear(model.fc.in_features, len(class_names))Swap in a fresh 2-class head.
model = model.to(device)Move the model to the device.
print('Trainable tensors:', sum(p.requires_grad for p in model.parameters()),Count trainable tensors — note NOTHING is frozen this time…
'/ total:', len(list(model.parameters())))…so every parameter (backbone + head) will be fine-tuned.
Output
Trainable tensors: 62 / total: 62

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. Fine-tune with a small learning rate

A key trick is discriminative learning rates: give the freshly-initialised head a larger LR than the pretrained backbone, which only needs gentle nudging. We also add a step scheduler.

4
criterion = nn.CrossEntropyLoss()Classification loss.
backbone_params = [p for n, p in model.named_parameters() if not n.startswith('fc.')]All pretrained parameters except the new head.
head_params = [p for n, p in model.named_parameters() if n.startswith('fc.')]Just the new head's parameters.
optimizer = torch.optim.SGD([SGD with two parameter groups — this is the discriminative-learning-rate trick.
{'params': backbone_params, 'lr': 1e-4}, # gentle on pretrained featuresBackbone: a tiny LR (1e-4) so the pretrained features barely move.
{'params': head_params, 'lr': 1e-3}, # faster on the new headHead: a larger LR (1e-3) since it starts from random.
], momentum=0.9)Momentum 0.9 smooths the updates.
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)Every 5 epochs, drop the learning rate by 10×.
EPOCHS = 6 # fine-tuning is heavier; keep epochs low on CPUNumber of epochs — kept low because fine-tuning is heavier on CPU.
5
def run_epoch(phase):One pass over 'train' or 'val'.
model.train() if phase == 'train' else model.eval()Set the matching mode (train vs eval).
total_loss = correct = total = 0Running totals.
for x, y in loaders[phase]:Iterate the split's batches.
x, y = x.to(device), y.to(device)Move to the device.
with torch.set_grad_enabled(phase == 'train'):Gradients on only for training.
out = model(x)Forward pass.
loss = criterion(out, y)Compute the loss.
if phase == 'train':Update weights only while training.
optimizer.zero_grad(); loss.backward(); optimizer.step()Zero gradients, backpropagate, step the optimiser.
total_loss += loss.item() * x.size(0)Accumulate 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.
best_val = 0.0Track the best validation accuracy seen so far.
for epoch in range(1, EPOCHS+1):Epoch loop.
tl, ta = run_epoch('train')Train one epoch.
vl, va = run_epoch('val')Validate.
scheduler.step()Advance the learning-rate schedule.
best_val = max(best_val, va)Update the best validation accuracy.
print(f'Epoch {epoch:2d} | train {ta*100:5.1f}% | val {va*100:5.1f}% | lr {scheduler.get_last_lr()[0]:.1e}')Log accuracy and the current learning rate.
print(f'Best val accuracy: {best_val*100:.1f}%')Report the best result at the end.
Output
Epoch  1 | train  50.4% | val  64.1% | lr 1.0e-04
Epoch  2 | train  66.8% | val  85.6% | lr 1.0e-04
Epoch  3 | train  79.5% | val  91.5% | lr 1.0e-04
Epoch  4 | train  83.2% | val  93.5% | lr 1.0e-04
Epoch  5 | train  86.9% | val  94.1% | lr 1.0e-05
Epoch  6 | train  93.0% | val  94.8% | lr 1.0e-05
Best val accuracy: 94.8%

4. Inspect predictions

6
def denorm(x):Reverse normalisation for display.
m = torch.tensor(mean).view(3,1,1); s = torch.tensor(std).view(3,1,1)Reshape mean/std to broadcast over the image.
return (x*s + m).clamp(0,1)Undo normalisation and clip to 0–1.
imgs, labels = next(iter(loaders['val']))One validation batch.
model.eval()Eval mode.
with torch.no_grad():No gradients for inference.
preds = model(imgs.to(device)).argmax(1).cpu()Predict 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 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')); ax.axis('off')Predicted label, green if right/red if wrong; hide ticks.
plt.tight_layout(); plt.show()Render the predictions.
Output

Exercises

  1. Compare the best val accuracy here with Lab 2 (feature extraction). Which wins, and by how much?
  2. Freeze the early layers (conv1, bn1, layer1) but fine-tune the rest. Does it help or hurt?
  3. Raise the backbone LR to 1e-2. What happens to val accuracy, and why? *(catastrophic forgetting)*
  4. Try models.resnet18(weights=None) (no pretraining) with the same recipe. How much does ImageNet pretraining buy you on this tiny dataset?

Recap

Fine-tuning adapts the pretrained features to your task with a small learning rate and (optionally) discriminative LRs. On small datasets close to the source domain it usually edges out pure feature extraction — at the cost of more compute and a higher risk of overfitting.

Module 8 — Lab 3: **Fine-tuning** a pretrained network

Overview Feature extraction vs. fine-tuning 1. Data (same as Lab 2) 2. Load the model — leave the backbone trainable Architecture 3. Fine-tune with a small learning rate 4. Inspect predictions Exercises