Skip to Content
Start Free

How to Use Transfer Learning in PyTorch With a Pretrained ResNet Model

Transfer Learning in PyTorch
Transfer Learning in PyTorch

Two models, same architecture, same data, same settings, trained for the same three epochs. One reached 97.78% accuracy. The other reached 33.33%, which on a three way choice is precisely what you would get by flipping a coin with three sides. The only difference between them was where their starting numbers came from. That gap is the single most useful thing a beginner can learn about deep learning, because it means the hardest part of training a good model has usually already been done by somebody else, on hardware you do not have, using data you could never collect. This tutorial shows you how to pick that work up and use it.

Before you begin

This is part five of a five part series on PyTorch foundations. Everything so far trained models from scratch. This part shows you the shortcut that practitioners actually reach for, which is starting from a model somebody else already trained.

The comparison at the centre of this article was run twice under identical conditions, once starting from pretrained weights and once from random ones. Both sets of numbers came from PyTorch 2.13.0 on CPU.

Highlights

  • A network trained on a large image collection learns a stack of separate skills, and only the last of them is specific to the categories it was originally asked about.
  • Edges and textures are properties of images rather than of any particular dataset, which is why a model trained on everyday photographs transfers to medical scans and factory defects.
  • Freezing the pretrained layers leaves 1,539 of 11,178,051 parameters learning, and that 0.01% proved enough to reach 97.78% accuracy from thirty training images.
  • The identical architecture trained from random weights reached 33.33%, which on a three way choice is exactly chance, so the starting point mattered far more than the model.
  • Transfer learning is faster per epoch as well as more accurate, because gradients are computed for a tiny fraction of the network rather than all of it.
  • Normalisation statistics are part of a pretrained model's contract, and using the wrong ones lowers accuracy silently with no error message anywhere to warn you.
  • Freeze order matters, since replacing the final layer before freezing leaves nothing trainable at all and the optimizer refuses with an empty parameter list error.

What Transfer Learning Actually Transfers

A network trained on a large image collection does not learn one skill. It learns a stack of them, and they are not equally specific to the task it was trained on.

The earliest layers become detectors for edges, corners, and colour transitions. Slightly deeper layers combine those into textures and simple repeating patterns. Deeper still, they respond to shapes and object parts. Only the final layers become genuinely specific to the exact categories the network was asked about.

Edges and textures are not properties of any particular dataset. They are properties of images. A network that detects them well is useful for medical scans, manufacturing defects, and satellite tiles, none of which resembled its training data.

The short version

Transfer learning keeps the general visual machinery a network already learned and replaces only the part that names categories. You are not teaching a model to see, you are teaching a model that already sees to answer a different question.

Why This Matters More Than Any Architecture Choice

Beginners tend to assume that better results come from picking a better model. Far more often they come from starting a model in a better place.

The comparison run for this article makes the point bluntly. Two identical ResNet18 networks, the same 30 training images, the same optimizer, the same three epochs. The one starting from pretrained weights reached 97.78%. The one starting from random weights reached 33.33%, which on a three way choice is exactly chance. The architecture was not the variable. The starting point was.

What you changeTypical effect on a small datasetEffort involved
Start from pretrained weightsVery large, often the difference between working and not workingOne argument when you load the model
Collect more labelled dataLarge, and reliably soSlow and often expensive
Add data augmentationModerate, and it stacks with the aboveA few lines in your transform pipeline
Swap to a deeper architectureSmall, and can make overfitting worse when data is scarceLow effort, high compute cost
Tune the learning rateSmall once you are within a reasonable rangeMany training runs

Feature Extraction Compared With Fine Tuning

There are two ways to build on a pretrained model, and choosing between them is mostly a question of how much data you have.

Feature extraction freezes the entire pretrained network and trains only a new final layer. The frozen part becomes a fixed function that converts an image into a list of numbers describing what is in it, and the new layer learns to map those numbers onto your categories. This is what the tutorial below does.

Fine tuning additionally unfreezes some of the pretrained layers so they can adapt. It reaches higher accuracy when you have enough data, and it requires a much smaller learning rate on the unfrozen layers, because large updates will destroy the features you were trying to build on.

Did you know

Start with everything frozen every single time, even when you suspect you have enough data to fine tune. It trains in seconds and gives you a number to beat. Unfreezing without a baseline means you have no way to tell whether it helped.

Before you start

  1. PyTorch and torchvision installed. All results below come from PyTorch 2.13.0 and torchvision 0.28.0 on CPU.
  2. A labelled image folder. Part four of this series generates the exact dataset used here in about twenty lines.
  3. Familiarity with a training loop, covered in part one.
  4. About 25 minutes, including a one time 45 MB download of the pretrained weights.

Run it in your browser

You do not have to install anything on your own machine. The JupyterLab playground gives you a browser based notebook environment running on a Linux host, so you can open a terminal inside it, install PyTorch with a single pip command, and work through every step of this tutorial in notebook cells. Sessions last one hour and extend by 15 minutes, which is comfortably longer than this tutorial needs. Install with %pip install --upgrade pip first, then %pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu, which also brings Pillow and NumPy. The percent sign matters because a notebook cell runs Python rather than shell, and the playground image ships a pip old enough to fail without the upgrade. Allow for a one time 45 MB download of the pretrained weights on first run.

Step 1: Prepare Data the Way the Model Expects

This step is where silent accuracy loss happens. A pretrained model expects input formatted exactly as it was during its original training.

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

torch.manual_seed(42)

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

tfm = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

full_train = datasets.ImageFolder("shapes/train", transform=tfm)
val_set    = datasets.ImageFolder("shapes/val",   transform=tfm)

Two constraints are non negotiable.

Images must be 224 by 224. That is the resolution ResNet was trained on. Other sizes technically pass through because of adaptive pooling, but the learned features are tuned to this scale and accuracy suffers at very different resolutions.

Normalisation must use ImageNet statistics. Those six numbers are the per channel mean and standard deviation of the ImageNet training set. The pretrained weights expect input centred that way. Substituting your own dataset statistics, or skipping normalisation entirely, produces no error and quietly costs you accuracy.

Now restrict training to 10 images per class, which is what makes the comparison meaningful:

idx, per_class = [], {0: 0, 1: 0, 2: 0}
for i, (_, lbl) in enumerate(full_train.samples):
    if per_class[lbl] < 10:
        idx.append(i)
        per_class[lbl] += 1
train_set = Subset(full_train, idx)

print("Classes           :", full_train.classes)
print("Training images   :", len(train_set), "(10 per class)")
print("Validation images :", len(val_set))
Classes           : ['circle', 'square', 'triangle']
Training images   : 30 (10 per class)
Validation images : 90

What this code does

IMAGENET_MEAN and IMAGENET_STD

The per channel average and spread of the original ImageNet photographs. The pretrained weights expect input centred exactly this way, so these six numbers are not optional.

Resize((224, 224))

ResNet was trained at this resolution and its learned features are tuned to that scale. Other sizes technically pass through but accuracy suffers.

ImageFolder

Reads a folder whose subfolder names are the class labels, which saves writing a custom Dataset when your data already has that shape.

Subset with 10 per class

Deliberately starves the model of data. Thirty training images is punishing, and it is also realistic, because scarce labelled data is the normal situation outside of benchmarks.

Thirty training images is a deliberately punishing amount. It is also realistic, because scarce labelled data is the normal situation outside of benchmarks and the exact circumstance transfer learning exists to address.

Step 2: Load the Pretrained Model and Freeze It

Three operations: load with weights, freeze everything, replace the head.

model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)

for param in model.parameters():
    param.requires_grad = False

model.fc = nn.Linear(model.fc.in_features, 3)

print("Original fc layer:", models.resnet18().fc)
print("Replaced fc layer:", model.fc)

total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total parameters    : {total:,}")
print(f"Trainable parameters: {trainable:,}  ({100*trainable/total:.2f}%)")
Original fc layer: Linear(in_features=512, out_features=1000, bias=True)
Replaced fc layer: Linear(in_features=512, out_features=3, bias=True)
Total parameters    : 11,178,051
Trainable parameters: 1,539  (0.01%)

What this code does

weights=ResNet18_Weights.IMAGENET1K_V1

Downloads and loads the numbers this model learned from 1.2 million ImageNet photographs. Passing None instead gives you the same architecture with random values.

requires_grad = False

Freezes a parameter so training leaves it untouched. Looping over every parameter freezes the entire network.

model.fc = nn.Linear(...)

Replaces the final layer. The original answered 1,000 ImageNet categories, and yours needs to answer 3.

Why the order matters

Freeze first, then replace. Layers created after the freeze loop are trainable by default, which is exactly what you want for the new head. Reverse the order and you freeze everything, leaving nothing to train.

Where 1,539 comes from

512 incoming features multiplied by 3 output classes gives 1,536 weights, plus 3 bias values. You can verify the number by hand, which is worth doing once.

That final line is the whole technique in one number. You are training 1,539 parameters out of 11.2 million.

Did you know

Order matters here. Freeze first, then replace model.fc. Layers created after the freeze loop have requires_grad=True by default, which is exactly what you want for the new head. Replace the head first and you will freeze it along with everything else, leaving nothing trainable at all.

The count of 1,539 is arithmetic you can verify: 512 input features times 3 output classes gives 1,536 weights, plus 3 bias terms.

Step 3: Train Only the Head

Pass the optimizer just the parameters that require gradients.

train_loader = DataLoader(train_set, batch_size=10, shuffle=True)
val_loader   = DataLoader(val_set,   batch_size=30)

params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.Adam(params, lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

def accuracy(model, loader):
    model.eval()
    correct = total = 0
    with torch.no_grad():
        for X, y in loader:
            correct += (model(X).argmax(1) == y).sum().item()
            total += y.size(0)
    return 100 * correct / total

print(f"Accuracy before training: {accuracy(model, val_loader):.2f}%")

for epoch in range(1, 4):
    model.train()
    running = 0.0
    for X, y in train_loader:
        loss = loss_fn(model(X), y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        running += loss.item()
    print(f"epoch {epoch} | loss {running/len(train_loader):.4f} | "
          f"val accuracy {accuracy(model, val_loader):6.2f}%")
Accuracy before training: 16.67%
epoch 1 | loss 1.3806 | val accuracy  65.56%
epoch 2 | loss 0.9656 | val accuracy  88.89%
epoch 3 | loss 0.7533 | val accuracy  97.78%

What this code does

The parameter filter

Passing only the trainable parameters keeps the optimizer from allocating memory to track 11.2 million weights that will never change.

Accuracy before training is 16.67%

The new final layer starts with random values, so its answers are meaningless until it learns. Everything behind it, however, already knows how to see.

Why it climbs so fast

The frozen layers already detect edges, textures, and shapes. The only thing left to learn is which combinations of those mean circle, square, or triangle, and that is a small job.

What the loss value is doing

It keeps falling while accuracy jumps, which is normal. Loss measures confidence across every example while accuracy only counts whether the top guess was right.

From 16.67% to 97.78% on 30 training images.

Filtering the parameter list is a small but real optimisation. Adam stores two additional tensors of optimizer state per parameter it manages, so handing it all 11.2 million would allocate memory for state on weights that never change.

Step 4: Run the Same Model From Scratch

The comparison is what makes the result credible. Identical architecture, identical data, identical hyperparameters, random initial weights.

scratch = models.resnet18(weights=None)
scratch.fc = nn.Linear(scratch.fc.in_features, 3)
optimizer = torch.optim.Adam(scratch.parameters(), lr=1e-3)
Trainable parameters: 11,178,051
Accuracy before training: 33.33%
epoch 1 | loss 1.5395 | val accuracy  31.11%
epoch 2 | loss 1.1269 | val accuracy  37.78%
epoch 3 | loss 0.8267 | val accuracy  33.33%

What this code does

weights=None

Same architecture, random starting numbers. Nothing about this model has ever seen an image before.

Why loss still falls

It drops from 1.5395 to 0.8267, so the model is genuinely learning. What it learned was those 30 specific training images, not any general property of shapes.

Why accuracy stays at chance

33.33% on three classes is exactly random. The model memorised its training set and has nothing useful to say about images it has not seen, which is textbook overfitting.

The real lesson

11.2 million parameters and 30 examples is a hopeless ratio. Transfer learning works because it changes that ratio to 1,539 parameters and 30 examples.

Loss falls from 1.5395 to 0.8267, so the model is genuinely learning something. Validation accuracy stays at chance level, which means what it learned was the 30 specific training images rather than any property of circles, squares, and triangles.

MeasurementPretrained, frozenFrom scratch
Trainable parameters1,53911,178,051
Accuracy after epoch 165.56%31.11%
Accuracy after epoch 397.78%33.33%
Relative time per epoch1.0xabout 1.5x slower
Training images required30Thousands, realistically

The speed difference deserves a note, because it is the opposite of what people expect. Both models run the same forward pass, but the frozen model computes gradients for 1,539 parameters instead of 11.2 million, so the backward pass is dramatically cheaper. Absolute timings depend on your machine, which is why the table gives a ratio. Across two runs here the from scratch model took about one and a half times as long per epoch.

Step 5: Fine Tune by Unfreezing the Last Block

What you just did is feature extraction. Fine tuning unfreezes part of the backbone as well.

model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
for param in model.parameters():
    param.requires_grad = False

# unfreeze the final convolutional block
for param in model.layer4.parameters():
    param.requires_grad = True

model.fc = nn.Linear(model.fc.in_features, 3)

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

The two learning rates are the important detail. The new head starts from random weights and needs to move quickly. The unfrozen convolutional block already holds useful features, so it needs small adjustments rather than large ones. A single learning rate applied to both would destroy what layer4 already knows.

Your situationApproachWhy
Under a few hundred images, similar to natural photosFreeze all, train the headToo little data to update millions of weights without overfitting
A few thousand images, similar domainUnfreeze the last blockEnough data to adapt high level features without losing the basics
Thousands of images, very different domain such as scans or radarUnfreeze most layers, low learning rateMid level ImageNet features transfer poorly, early edge detectors still help
Hundreds of thousands of imagesTrain from scratch, or fine tune everythingEnough data to learn features suited to your domain directly

Start at the top row regardless of which one describes you. It trains in seconds and gives you a baseline, and you only need the rows below it if that baseline is not good enough.

Break It On Purpose

Break it on purpose, experiment one

Swap the order of the freeze loop and the head replacement, so model.fc = nn.Linear(...) runs first and the freeze loop runs second.

Trainable parameters: 0
ValueError: optimizer got an empty parameter list

Why it happens: the freeze loop walks every parameter currently in the model, and by then that includes your new head. Nothing is left trainable. This is one of the more merciful failures in the tutorial because it raises immediately, unlike the next one.

Break it on purpose, experiment two

Delete the transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD) line and retrain.

No error appears. Training completes. Accuracy is simply worse, and on a harder dataset the drop is large enough to make you doubt the whole approach.

Why it happens: the pretrained weights were learned on inputs centred with those specific statistics. Feeding raw 0 to 1 values shifts the entire input distribution, so every activation through the frozen backbone lands somewhere the network was not tuned for. This is the single most common transfer learning mistake precisely because nothing warns you about it.

Error Decoder

What PyTorch printsWhat it means and how to fix it
ValueError: optimizer got an empty parameter listEverything is frozen, including the new head. Freeze the backbone first, then replace model.fc.
RuntimeError: mat1 and mat2 shapes cannot be multiplied (30x512 and 1000x3)The replacement head has wrong input features. Always use model.fc.in_features rather than a hard coded number.
RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[10, 4, 224, 224] to have 3 channelsYour images carry an alpha channel. Add .convert("RGB") when loading, or use ImageFolder which handles it.
UserWarning: Arguments other than a weight enum or None for 'weights' are deprecatedYou used the old pretrained=True argument. Use weights=models.ResNet18_Weights.IMAGENET1K_V1 instead.
Accuracy is mediocre but no error appearsNormalisation is missing or uses the wrong statistics. Confirm you apply the ImageNet mean and standard deviation.
Training accuracy climbs while validation accuracy stalls or fallsOverfitting. Freeze more layers, add augmentation to the training pipeline, or stop training earlier.
Accuracy collapses after unfreezing backbone layersLearning rate on the unfrozen layers is too high and is erasing pretrained features. Drop it to 1e-4 or lower.

Choosing a Backbone

ResNet18 is a sensible default. It is small, fast, and well understood. Alternatives are worth knowing about.

ResNet50 has roughly twice the depth and usually a few points more accuracy, at proportionally higher compute cost. Use it when ResNet18 is close but not quite good enough.

EfficientNet models generally achieve better accuracy per parameter, which matters when deploying to constrained hardware.

Vision Transformers frequently outperform convolutional networks given large datasets, but they typically need more data to fine tune well, so they are a weaker choice for the small dataset case this article addresses.

Whichever you pick, the pattern is identical: load with weights, freeze, replace the final layer, train. Only the attribute name changes, since some architectures call the final layer classifier rather than fc. Print the model to check.

Course

PyTorch

Covers transfer learning alongside additional training methods, then moves into deployment and inference, which is the natural continuation once a model performs well on your own data.

Transfer learningPretrained modelsDeployment
Explore the course β†’

Conclusion

Transfer learning is the highest return technique available to anyone working with limited labelled data, and the measurements in this article show why. A frozen ResNet18 training 1,539 parameters reached 97.78% on 30 images. The identical architecture trained from scratch reached 33.33%, which is chance.

The recipe is four lines and rarely changes. Load the model with its pretrained weights, freeze the parameters, replace the final layer with one matching your class count, and train only what remains unfrozen. Normalise with the statistics the model was pretrained on, or you lose accuracy for no visible reason.

Start with everything frozen every time. It trains in seconds, needs almost no data, and gives you a number to beat. Unfreeze more layers only when that baseline falls short and you have the data to support it. Most projects never need to go further than the frozen version, which is a considerably better position than the one you are in when starting from random weights.

FAQS

Q1: How much data do I need for transfer learning to work?

Far less than training from scratch, and the tutorial above worked with 10 images per class. As rough guidance, 20 to 50 images per class is often enough for feature extraction with a frozen backbone when your images resemble natural photographs, while a few hundred per class opens up fine tuning the later layers, and thousands per class lets you fine tune most of the network. The strength of the result depends heavily on how similar your images are to ImageNet, so photographs of everyday objects transfer extremely well while medical scans, radar returns, or microscopy images transfer less well and benefit from more data and more unfrozen layers. The practical approach is to try the frozen version first because it costs almost nothing, then decide whether the result justifies collecting more data.

Q2: What is the difference between feature extraction and fine tuning?

Feature extraction freezes the entire pretrained network and trains only a new final layer, so the backbone acts as a fixed function that converts images into feature vectors. Fine tuning additionally unfreezes some pretrained layers so they can adapt to your data, usually the later ones since those hold the most task specific features. Feature extraction is faster, needs much less data, and cannot overfit badly because so few parameters are learning. Fine tuning can reach higher accuracy when you have enough data, but it requires a lower learning rate on the unfrozen layers, typically around 1e-4, because large updates will destroy the pretrained features you are trying to build on. Start with feature extraction and only move to fine tuning when its result is genuinely insufficient.

Q3: Why do I have to use those specific normalisation numbers?

The values [0.485, 0.456, 0.406] and [0.229, 0.224, 0.225] are the per channel mean and standard deviation of the ImageNet training set, and every torchvision model pretrained on ImageNet learned its weights on inputs preprocessed with them. The network's internal activations are tuned to expect data centred that way, so feeding differently scaled input shifts every activation through the frozen backbone into ranges the model was not trained on. What makes this dangerous is that nothing fails. There is no error and no warning, just accuracy that is quietly lower than it should be, which leads people to conclude transfer learning does not work for their problem. If you ever train a backbone from scratch on your own data then you should compute and use your own dataset statistics, but with pretrained weights you must match the original.

Q4: Can I use transfer learning for tasks other than image classification?

Yes, and the same principle applies across every domain. In natural language processing, pretrained language models are adapted to classification, extraction, and question answering tasks by adding a small task specific head. In audio, models pretrained on large speech corpora transfer to speaker identification and sound classification. Within computer vision, backbones pretrained for classification are routinely reused for object detection and segmentation by attaching a different head. The mechanics change, since a segmentation head is more involved than a single linear layer, but the reasoning is identical: general representations learned on abundant data get reused, and only the task specific portion is trained on your scarce data. This reuse is now the default approach across most of applied machine learning rather than a special technique.

Q5: How do I decide which pretrained model to start from?

Choose based on your compute budget and your accuracy requirement, in that order. ResNet18 is the right default for learning and for most first attempts, since it is small enough to train quickly on CPU and well understood enough that problems are easy to diagnose. Move to ResNet50 when ResNet18 is close but insufficient and you can afford roughly double the compute. Consider EfficientNet when deploying to constrained hardware, since it delivers better accuracy per parameter. Vision Transformers can outperform convolutional networks but generally need more data to fine tune successfully, which makes them a poor fit for the small dataset scenario where transfer learning matters most. Whatever you choose, the code pattern stays the same, though you should print the model first to confirm whether its final layer is called fc or classifier.

Q6: My model works well in testing but poorly on real data. What went wrong?

The most likely cause is a mismatch between how images are preprocessed during training and during inference. Your training pipeline resizes to 224 by 224 and normalises with ImageNet statistics, and your inference code must do exactly the same, in the same order, including the same interpolation method. The second most common cause is forgetting model.eval() before inference, which leaves dropout active and batch normalisation using batch statistics rather than the running averages it accumulated. The third is genuine distribution shift, where real world images differ from your validation set in lighting, angle, resolution, or background. Preventing these systematically is what MLOps practice addresses, and the beginner's guide to MLOps covers deployment and monitoring, while the Fundamentals of MLOps course works through model serving and drift detection with MLflow and BentoML.

Pramodh Kumar M Pramodh Kumar M

Subscribe to Newsletter

Join me on this exciting journey as we explore the boundless world of web design together.