Search…

Your First Image Classifier (PyTorch + Transfer Learning)

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

Everything so far has been features you designed by hand. This post hands that job to the model. You will train a working classifier on 1,200 photos — a dataset far too small to train a network from scratch — by reusing one that has already learned to see.

Prerequisites: The CV project workflow, evaluation metrics, and ideally neural networks.

The problem: sorting recycling photos

A recycling facility wants a phone app that tells people which bin an item goes in. Four classes: cardboard, plastic, glass, metal. A summer intern photographed items over three weeks and collected 1,200 images.

# Class Images target Share Photographed by
1 cardboard 520 43.3% intern A, weeks 1–2
2 plastic 340 28.3% intern A, week 3
3 glass 200 16.7% intern B, week 3
4 metal 140 11.7% intern B, week 3
5 **Total** **1200** **100%**

The dataset. Two problems are already visible: a 3.7x imbalance, and classes correlated with who took the photos.

Two problems, both visible before writing a line of model code. Cardboard has 3.7 times more examples than metal. And glass and metal were both shot by intern B in week 3, so anything specific to intern B’s phone or lighting is a shortcut the model can learn instead of learning what glass looks like.

Why 1,200 images is enough

ResNet-18 has 11.7 million parameters. With 1,200 images that is roughly 10,000 parameters per training example. A model that flexible will memorise the training set perfectly and learn nothing that generalises.

transfer learning Starting from a model already trained on a large dataset, and adapting only part of it to your task. The early layers already know edges, textures and shapes, which are the same for any photograph.

The trick is that most of those 11.7 million parameters do not need to change. A network trained on ImageNet’s 1.2 million photos already knows edges, textures, and object parts. Cardboard corrugation is a texture; a bottle is a shape. Those detectors are already there.

Step: 0 / 12
Phase 1 – Feature Extraction: Fresh classification head attached. Backbone stays frozen while the head learns the new labels. Trainable params: 65,922 / 1,116,546. Accuracy: 50.2%.

Step through the visualization above and watch which layers update. In phase one only the final layer moves. In phase two the later blocks join in, gently.

# Strategy Trainable parameters Images needed Expected accuracy here target
1 From scratch 11,689,512 ~50,000+ ~54%
2 Freeze all, train head only 2,052 ~500+ ~86%
3 Freeze early, fine-tune last block 8,395,780 ~1,000+ ~92%
4 Fine-tune everything, low LR 11,689,512 ~2,000+ ~93%

Four strategies on the same 1,200 images. The head-only version fits 2,052 parameters and gets within 7 points of the best.

That 2,052 comes straight from the architecture. ResNet-18 outputs a 512-dimensional feature vector, and a 4-class head is a linear layer:

512×4+4=2048+4=2052 parameters512 \times 4 + 4 = 2048 + 4 = 2052 \text{ parameters}

Fitting 2,052 parameters to 1,200 images is a reasonable ratio. Fitting 11.7 million to 1,200 is not.

Splitting the data properly

Before anything else. Random splitting here would put photos of the same physical bottle into both train and test, and the model would score well by recognising that bottle rather than by recognising plastic.

# Split Rule used Images target Purpose
1 Train weeks 1–2 sessions 840 fit the model
2 Validation week 3, sessions 1–4 180 choose epochs and hyperparameters
3 Test week 3, sessions 5–8 180 measure once, at the very end

Split by photo session so no physical object appears in two splits

The data pipeline

import torch, torchvision
from torch import nn
from torchvision import transforms, datasets
from torch.utils.data import DataLoader

IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD  = [0.229, 0.224, 0.225]

train_tf = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2),
    transforms.RandomRotation(15),
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

eval_tf = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

train_ds = datasets.ImageFolder("data/train", train_tf)
val_ds   = datasets.ImageFolder("data/val",   eval_tf)
test_ds  = datasets.ImageFolder("data/test",  eval_tf)

train_dl = DataLoader(train_ds, batch_size=32, shuffle=True,  num_workers=4)
val_dl   = DataLoader(val_ds,   batch_size=64, shuffle=False, num_workers=4)

Those normalization numbers are not arbitrary. They are the channel means and standard deviations of ImageNet, and the pretrained weights were learned with inputs scaled that way. Feed raw 0–1 pixels instead and every frozen filter sees inputs outside the range it expects.

Trace one pixel. A red channel value of 200 becomes 200/255=0.784200/255 = 0.784 after ToTensor, then:

0.7840.4850.229=0.2990.229=1.306\frac{0.784 - 0.485}{0.229} = \frac{0.299}{0.229} = 1.306

Augmentation goes on train only. Validation and test use a fixed resize and centre crop, because you need the same images evaluated the same way every epoch — otherwise a metric change could be augmentation randomness rather than model improvement.

Handling class imbalance

Metal has 140 images against cardboard’s 520. Left alone, a model that predicts cardboard for everything already gets 43% accuracy, and gradient descent finds that shortcut early.

Class weights make each class contribute equally to the loss:

wc=Nkncw_c = \frac{N}{k \cdot n_c}

where NN is the total, kk is the number of classes, and ncn_c is the count for class cc. With N=1200N = 1200 and k=4k = 4:

# Class ncn_c Calculation Weight wcw_c target
1 cardboard 520 1200 / (4 × 520) 0.577
2 plastic 340 1200 / (4 × 340) 0.882
3 glass 200 1200 / (4 × 200) 1.500
4 metal 140 1200 / (4 × 140) 2.143

Class weights. A metal mistake now costs 3.7x what a cardboard mistake costs — exactly the imbalance ratio.

The ratio between the extremes is 2.143/0.577=3.712.143 / 0.577 = 3.71, which is exactly 520/140520/140. That is the point: the weight cancels the count.

counts = torch.tensor([520., 340., 200., 140.])
weights = counts.sum() / (len(counts) * counts)
criterion = nn.CrossEntropyLoss(weight=weights.to(device), label_smoothing=0.05)

Building the model

from torchvision.models import resnet18, ResNet18_Weights

model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)

for p in model.parameters():          # freeze everything
    p.requires_grad = False

for p in model.layer4.parameters():   # unfreeze the last block
    p.requires_grad = True

model.fc = nn.Linear(model.fc.in_features, 4)   # new head, trainable by default
model = model.to(device)

n_train = sum(p.numel() for p in model.parameters() if p.requires_grad)
n_total = sum(p.numel() for p in model.parameters())
print(f"training {n_train:,} of {n_total:,} parameters "
      f"({100*n_train/n_total:.1f}%)")

Replacing model.fc creates a fresh layer with requires_grad=True, so it trains even though everything else was frozen first. Order matters — freeze, then replace.

Two learning rates

The new head starts from random weights and must move a long way. The pretrained layer4 is already close to useful and should only be nudged. Give them different learning rates:

optimizer = torch.optim.AdamW([
    {"params": model.fc.parameters(),     "lr": 1e-3},
    {"params": model.layer4.parameters(), "lr": 1e-4},
], weight_decay=1e-4)

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=25)
# Part of the model Learning rate Reason
1 New classification head 1e-3 Random init — needs to move far
2 layer4 (last block) 1e-4 Pretrained and nearly right — nudge only
3 layer1–layer3 frozen Generic edges and textures, already correct

Discriminative learning rates. A single high LR would destroy the pretrained features.

The training loop

def run_epoch(loader, train):
    model.train() if train else model.eval()
    total_loss, correct, n = 0.0, 0, 0
    with torch.set_grad_enabled(train):
        for x, y in loader:
            x, y = x.to(device), y.to(device)
            out = model(x)
            loss = criterion(out, y)
            if train:
                optimizer.zero_grad()
                loss.backward()
                optimizer.step()
            total_loss += loss.item() * y.size(0)
            correct    += (out.argmax(1) == y).sum().item()
            n          += y.size(0)
    return total_loss / n, correct / n

best_val, patience, bad = 0.0, 5, 0
for epoch in range(25):
    tr_loss, tr_acc = run_epoch(train_dl, True)
    va_loss, va_acc = run_epoch(val_dl,  False)
    scheduler.step()
    print(f"epoch {epoch:02d}  train {tr_loss:.3f}/{tr_acc:.3f}  "
          f"val {va_loss:.3f}/{va_acc:.3f}")

    if va_acc > best_val:
        best_val, bad = va_acc, 0
        torch.save(model.state_dict(), "best.pt")
    else:
        bad += 1
        if bad >= patience:
            print(f"early stop at epoch {epoch}")
            break

Reading the loss curves

Epoch 0 / 40
Diagnosis: healthy optimization steadily lowers both training and validation loss before validation eventually levels off.

Switch between the scenarios in the control above. Each has a distinct signature and a different fix.

# Train loss Validation loss Diagnosis target What to do
1 falling falling Healthy Keep going
2 falling flat then rising Overfitting More augmentation, more data, freeze more, stop earlier
3 flat and high flat and high Underfitting Unfreeze more layers, raise LR, train longer
4 jumping wildly jumping wildly LR too high Cut LR by 10x
5 falling very slowly falling very slowly LR too low Raise LR by 10x
6 falling far lower than train Data leak, or augmentation only on train Usually fine — check the split anyway

Loss curve shapes and their fixes

Here is what a real run on this dataset looks like:

The two curves separate at epoch 10. Before that, both fall together and the model is genuinely learning. After that the training loss keeps dropping while validation rises — the model is memorising the 840 training images. The gap between the curves is the amount of memorisation.

The confusion matrix, and what to do with it

model.load_state_dict(torch.load("best.pt"))
model.eval()

from sklearn.metrics import confusion_matrix, classification_report
preds, trues = [], []
with torch.no_grad():
    for x, y in test_dl:
        preds += model(x.to(device)).argmax(1).cpu().tolist()
        trues += y.tolist()

print(confusion_matrix(trues, preds))
print(classification_report(trues, preds, target_names=train_ds.classes))
# True \\ Predicted target cardboard plastic glass metal Recall
1 cardboard 74 3 1 0 0.949
2 plastic 4 42 8 1 0.764
3 glass 1 11 17 1 0.567
4 metal 0 2 3 12 0.706

Test set confusion matrix, 180 images. Overall accuracy is 80.6%.

Overall accuracy is 80.6%, which tells you nothing you can act on. The matrix does. Nineteen of the 35 errors are the glass–plastic pair: 11 glass called plastic, 8 plastic called glass. That single pair is 54% of all mistakes.

Now look at the images. Clear plastic bottles and clear glass bottles look nearly identical in a photo — the real distinguishing cues are weight and the sound they make, neither of which is in the image. What is in the image: glass has sharper specular highlights and slightly different edge refraction.

# Action Effort Expected gain target Do it?
1 Collect 300 more glass images at varied angles 3 days +7 to 9 points Yes, first
2 Unfreeze layer3 as well 20 min +1 to 2 points Yes, quick
3 Switch to ResNet-50 1 hour +1 to 2 points Maybe
4 Collect 300 more cardboard images 3 days +0.3 points No — cardboard is already at 0.949
5 Add a weight sensor to the app not possible +8 points No

Ranked by gain per day of effort. The confusion matrix, not intuition, produced this ranking.

This is the workflow from error analysis applied to a real model. The instinct is to reach for a bigger architecture; the matrix says collect glass photos instead, for four times the gain.

ModelResNet-18ResNet-50EfficientNet-B0ViT-B/16
Parameters11.7M25.6M5.3M86M
ImageNet top-169.8%76.1%77.7%81.1%
Inference, 1 image CPU~35 ms~95 ms~45 ms~180 ms
Fine-tunes on 1k imagesvery wellwellwellneeds more data
Good default?yes, start herewhen accuracy matterswhen size matterswith 10k+ images
Backbone choice. Start with ResNet-18 — it is the fastest to iterate with.

Practice task

Use any four-class image dataset, or photograph 300 items around your home.

  1. Split by session or object, never at random. Write down the rule you used.
  2. Train with the whole backbone frozen, head only. Record test accuracy.
  3. Unfreeze layer4 at 1e-4 and retrain. Record the change.
  4. Train from scratch with weights=None for the same number of epochs. Record it.
  5. Plot train and validation loss for all three runs on one chart.
  6. Print the confusion matrix for your best model and identify the dominant error pair.
  7. Look at 20 images from that pair. Write one sentence on why the model confuses them.
  8. Now redo step 1 with a random split and retrain. Compare test accuracy.

Step 8 is deliberately the last one. The random-split model will score several points higher on a test set it has effectively already seen. Watching that inflation happen on your own data is the most reliable way to never make the mistake again.

Summary

Transfer learning works because early network layers learn edges and textures that are the same for every photograph. Training only a new head means fitting 512×4+4=2052512 \times 4 + 4 = 2052 parameters instead of 11.7 million, which is a sane ratio for 1,200 images. Unfreezing the last block adds a few points; training from scratch on this data loses about 40.

Split by session so no physical object appears twice. Normalise with ImageNet statistics, so a red value of 200 becomes 1.306. Augment train only. Weight the loss by N/(knc)N/(k \cdot n_c), which gave 0.577 for cardboard and 2.143 for metal — a 3.71× ratio exactly cancelling the 520:140 imbalance.

Watch the two loss curves. They separated at epoch 10 in the run above, and everything after that was memorisation. Save on every validation improvement so you keep the best model rather than the last one.

Finally, read the confusion matrix rather than the accuracy. 80.6% overall was not actionable; “19 of 35 errors are glass versus plastic” was, and it pointed at collecting glass photos rather than at a bigger architecture.

What comes next

Overfitting appeared at epoch 10, and the standard first response is better data handling. Vision data pipelines and augmentation covers which augmentations actually help, which quietly destroy your labels, and how to build an input pipeline that does not starve the GPU. After that, CNN architectures from LeNet to ResNet explains what is inside the backbone you have been using as a black box.

Test your understanding
You fine-tune ResNet-18 on 800 images and get 97% test accuracy — far better than expected. Validation accuracy is also 97%. What should you check first?
Test your understanding
Your training loss drops steadily to 0.08 while validation loss bottoms out at 0.35 around epoch 10 and then climbs to 0.58. What is the most useful response?

Frequently asked questions

How many images do I need per class?
With a frozen backbone, 100 to 200 per class often gives something usable, and 500 or more per class produces a solid model. Variety matters more than raw count: 200 photos of one object from many angles teach far less than 200 photos of 200 different objects. If a class is stuck below 50 images, either merge it into another class or use classical features until you can collect more.
Should I freeze the backbone or fine-tune it?
Start frozen. It trains in minutes, cannot damage the pretrained features, and gives you a baseline within an hour. Then unfreeze the last block at a learning rate roughly ten times lower than the head and see whether validation improves. Unfreeze more only if it keeps helping. With under a thousand images, full fine-tuning usually overfits before it beats the partial version.
Why do I have to use the ImageNet normalization values?
The pretrained weights were learned with inputs standardised using ImageNet's channel means and standard deviations. Every frozen filter expects that input distribution. Feeding raw 0-1 values shifts and rescales everything the early layers see, so their responses land outside the range the later layers were tuned for. The model still trains, but noticeably worse, and the cause is easy to miss.
What batch size should I use?
Use the largest that fits in memory, typically 32 or 64 for 224x224 images on a consumer GPU. Larger batches give smoother gradient estimates and better hardware utilisation. If you are forced below about 8 by memory limits, replace any BatchNorm layers with GroupNorm or use gradient accumulation, because BatchNorm statistics become unreliable on very small batches.
My accuracy is 80%. Should I try a bigger model?
Read the confusion matrix first. In this post, 80.6% accuracy hid the fact that one class pair caused 54% of all errors, and targeted data for that pair was worth seven to nine points against one or two for a larger backbone. Architecture changes are the easiest thing to try and almost always the lowest-value. Spend the time on the specific failure the matrix identifies.
Start typing to search across all content
navigate Enter open Esc close