Transfer Learning
Transfer learning takes a model pretrained on a large dataset (ImageNet, large text corpora) and adapts it to your smaller task. You get 80–90% of the performance for 1% of the data and compute. Done right, it’s the single highest-leverage technique in applied deep learning.
Freeze, fine-tune, layer-wise LR, LoRA
EXAMPLE
import torch
import torch.nn as nn
import torchvision
from torchvision import models, transforms, datasets
from torch.utils.data import DataLoader
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingLR
# 1) Load a pretrained model
weights = models.ResNet50_Weights.IMAGENET1K_V2
base = models.resnet50(weights=weights)
print(base) # final layer is Linear(2048, 1000)
# Use the recommended preprocessing
preprocess = weights.transforms()
# 2) Swap the head for your task
NUM_CLASSES = 10
base.fc = nn.Linear(base.fc.in_features, NUM_CLASSES) # 1000 -> NUM_CLASSES
# 3) Strategy A: feature extraction (freeze everything but the head)
for p in base.parameters():
p.requires_grad = False
for p in base.fc.parameters():
p.requires_grad = True
# Quick to train, modest accuracy. Best when your dataset is tiny + similar to pretraining domain.
# 4) Strategy B: full fine-tune (every layer trains)
for p in base.parameters():
p.requires_grad = True
# Best accuracy when you have ~ 5k+ labelled samples. Needs lower LR than from-scratch training.
# 5) Strategy C: layer-wise LR — gold standard
# Higher LR for the new head, lower for early layers.
param_groups = [
{'params': list(base.fc.parameters()), 'lr': 1e-3},
{'params': list(base.layer4.parameters()), 'lr': 1e-4},
{'params': list(base.layer3.parameters()), 'lr': 5e-5},
{'params': list(base.layer1.parameters()) + list(base.layer2.parameters()), 'lr': 1e-5},
{'params': list(base.conv1.parameters()) + list(base.bn1.parameters()), 'lr': 1e-5},
]
opt = AdamW(param_groups, weight_decay=1e-4)
# Or — discriminative learning rates per parameter group (fastai-style).
# 6) Training loop
import os, time
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
base = base.to(device)
train_ds = datasets.ImageFolder('data/train', transform=preprocess)
val_ds = datasets.ImageFolder('data/val', transform=preprocess)
train_dl = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4, pin_memory=True)
val_dl = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)
criterion = nn.CrossEntropyLoss()
scheduler = CosineAnnealingLR(opt, T_max=10)
for epoch in range(10):
base.train()
for x, y in train_dl:
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
opt.zero_grad(set_to_none=True)
loss = criterion(base(x), y)
loss.backward()
opt.step()
scheduler.step()
base.eval()
correct = 0; total = 0
with torch.no_grad():
for x, y in val_dl:
preds = base(x.to(device)).argmax(dim=1).cpu()
correct += (preds == y).sum().item(); total += y.size(0)
print(f'epoch {epoch+1} val acc {correct/total:.3f}')
# 7) Mixed precision — 2-3x faster, lower memory
scaler = torch.cuda.amp.GradScaler()
for x, y in train_dl:
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
opt.zero_grad(set_to_none=True)
with torch.cuda.amp.autocast(dtype=torch.float16):
loss = criterion(base(x), y)
scaler.scale(loss).backward()
scaler.step(opt)
scaler.update()
# 8) Gradual unfreezing (Howard & Ruder, ULMFiT)
# Phase 1: train only the head (5 epochs)
# Phase 2: unfreeze last block, continue at lower LR (3 epochs)
# Phase 3: unfreeze everything, even lower LR (3-5 epochs)
# Often produces the best generalisation, especially with little data.
# 9) Other vision backbones
models.efficientnet_v2_s(weights=models.EfficientNet_V2_S_Weights.IMAGENET1K_V1)
models.convnext_tiny(weights=models.ConvNeXt_Tiny_Weights.IMAGENET1K_V1)
models.vit_b_16(weights=models.ViT_B_16_Weights.IMAGENET1K_SWAG_E2E_V1)
models.swin_v2_t(weights=models.Swin_V2_T_Weights.IMAGENET1K_V1)
# Pick by speed/memory budget vs accuracy on your benchmark.
# 10) Hugging Face — NLP transfer learning
from transformers import AutoModelForSequenceClassification, AutoTokenizer, TrainingArguments, Trainer
model = AutoModelForSequenceClassification.from_pretrained('roberta-base', num_labels=4)
tok = AutoTokenizer.from_pretrained('roberta-base')
args = TrainingArguments(
output_dir='out',
num_train_epochs=3,
per_device_train_batch_size=16,
learning_rate=2e-5,
weight_decay=0.01,
warmup_ratio=0.06,
eval_strategy='epoch',
save_strategy='epoch',
load_best_model_at_end=True,
fp16=True,
)
trainer = Trainer(model=model, args=args, train_dataset=train_hf, eval_dataset=val_hf)
trainer.train()
# 11) PEFT — Parameter-Efficient Fine-Tuning (LoRA, adapters)
# When the model is huge (Llama, Mistral), fine-tuning all weights is expensive.
# LoRA trains tiny low-rank adapter matrices, leaves base weights frozen.
from peft import LoraConfig, get_peft_model, TaskType
config = LoraConfig(
task_type=TaskType.SEQ_CLS,
r=8, lora_alpha=16, lora_dropout=0.05,
target_modules=['query', 'value'],
)
model = get_peft_model(model, config)
model.print_trainable_parameters() # 0.1-1% of full model parameters
# Trains 100x faster, fits a 7B model on a single 24GB GPU.
# 12) Data efficiency — augmentation makes transfer go further
train_transform = transforms.Compose([
transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ColorJitter(0.2, 0.2, 0.2),
transforms.RandAugment(num_ops=2, magnitude=9),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
# Use MixUp / CutMix for an extra 1-2 points on tough datasets:
from torchvision.transforms import v2
cutmix_or_mixup = v2.RandomChoice([v2.CutMix(num_classes=NUM_CLASSES), v2.MixUp(num_classes=NUM_CLASSES)])
# 13) When transfer doesn't help
# • Domain gap is huge (ImageNet → medical X-ray): freezing all but the head underperforms a smaller bespoke model
# • Your dataset is millions of labels: a from-scratch model can beat a frozen pretrained one
# • You need very small + fast inference: distil a smaller model from your fine-tuned one
# • The pretrained model was trained on data you legally / ethically can't build on
# 14) Save + load checkpoints (state_dict is the recommended format)
torch.save(base.state_dict(), 'fine_tuned.pt')
base.load_state_dict(torch.load('fine_tuned.pt', map_location='cpu'))
# 15) Quantisation post-fine-tune
import torch.ao.quantization as q
base.eval()
base_q = q.quantize_dynamic(base, {nn.Linear}, dtype=torch.qint8)
torch.save(base_q.state_dict(), 'fine_tuned_qint8.pt')
# 4x smaller; same accuracy on most tasks; faster CPU inference.
# 16) Common bugs
# • Forgot to swap the head — model still outputs 1000 classes
# • Used the SAME LR for head and backbone — backbone overfits to small data
# • Wrong preprocessing pipeline — mean/std mismatch hurts accuracy 5-10%
# • Did not call model.eval() at inference — BatchNorm/Dropout active, wrong predictions
# • Trained too long with a tiny dataset — overfits; use early stopping, dropout, weight decay
# • Loaded weights without strict=False after swapping head — KeyError on fc / classifier
# • Mixed-precision with optimizer.zero_grad() but forgot set_to_none=True — memory grows
# • Forgot to freeze BatchNorm running stats during full fine-tune — set to .eval() per layer to keep stats from drifting on small batches
Why it matters
Start with a pretrained model, swap the head, and use layer-wise learning rates (large for the new head, small for early layers). Reach for LoRA when fine-tuning huge models on consumer GPUs — 1% of the parameters update, 100× faster, almost the same downstream quality. Match the original preprocessing exactly; mismatched normalisation costs more accuracy than you’d guess.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import torchvision.models as M from torch import nn base = M.resnet50(weights=M.ResNet50_Weights.IMAGENET1K_V2) for p in base.parameters(): p.requires_grad = False base.fc = nn.Linear(base.fc.in_features, num_classes)Try it Yourself »
Discussion
Loading…