PyTorch Fine-Tuning

Fine-tuning updates the weights of a pretrained model for your specific task. Unlike feature extraction — which freezes the backbone completely — fine-tuning lets the pretrained layers adapt to your data. Done carefully, it produces higher accuracy than feature extraction, especially when your task differs from the original pretraining task.

Feature Extraction vs Fine-Tuning

Feature Extraction:
  Freeze backbone → train only new head
  Fast, few trainable params
  Best for: small dataset, similar task to pretrained task

Fine-Tuning:
  Start from pretrained weights → unfreeze some or all layers
  Train at a LOW learning rate to avoid destroying learned features
  Best for: medium/large dataset, or when task differs from pretrained task

Step 1: Load Pretrained Model and Replace Head

import torch
import torch.nn as nn
import torchvision.models as models
from torchvision.models import ResNet18_Weights

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

# Replace the final layer for your task (e.g. 3 classes)
model.fc = nn.Linear(model.fc.in_features, 3)

Step 2: Freeze All Layers, Unfreeze Gradually

A common fine-tuning strategy freezes everything first, trains the head for a few epochs, then unfreezes later layers progressively. This protects learned features during the early unstable phase of head training.

# Phase 1: Freeze backbone, train head only
for name, param in model.named_parameters():
    if 'fc' not in name:
        param.requires_grad = False

optimizer_phase1 = torch.optim.Adam(
    filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3
)

# Train for ~5 epochs with just the head
# Phase 2: Unfreeze entire model, train at much lower lr
for param in model.parameters():
    param.requires_grad = True

optimizer_phase2 = torch.optim.Adam([
    {'params': model.layer4.parameters(), 'lr': 1e-4},
    {'params': model.fc.parameters(),     'lr': 1e-3},
], lr=1e-5)   # default lr for all other layers

# Train for ~10–20 more epochs

Discriminative Learning Rates

Early layers contain more general, universal features — they need less updating. Later layers and the new head need more updating. Assigning different learning rates to different parts of the model — lower rates for early layers, higher for later ones — is called discriminative learning rates.

model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(512, 3)

optimizer = torch.optim.Adam([
    {'params': model.layer1.parameters(), 'lr': 1e-5},   # early, general
    {'params': model.layer2.parameters(), 'lr': 2e-5},
    {'params': model.layer3.parameters(), 'lr': 5e-5},
    {'params': model.layer4.parameters(), 'lr': 1e-4},
    {'params': model.fc.parameters(),     'lr': 1e-3},   # new head, fastest
])

Diagram: Discriminative Learning Rates on ResNet18

Layer         lr       Role
────────────────────────────────────────────────
Conv1, BN1    1e-5    Basic edge/color detectors  ← barely change
Layer1        1e-5    Simple patterns
Layer2        2e-5    Shapes and textures
Layer3        5e-5    Object parts
Layer4        1e-4    High-level semantic features
FC (new)      1e-3    Task-specific classification ← change most

Full Fine-Tuning Training Loop

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

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# Data
transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406],
                         [0.229, 0.224, 0.225])
])

train_data   = datasets.ImageFolder('data/train', transform=transform)
val_data     = datasets.ImageFolder('data/val',   transform=transform)
train_loader = DataLoader(train_data, batch_size=32, shuffle=True)
val_loader   = DataLoader(val_data,   batch_size=32)

# Model
model    = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(512, len(train_data.classes))
model    = model.to(device)

# All layers trainable at discriminative rates
optimizer = torch.optim.Adam([
    {'params': list(model.parameters())[:-2], 'lr': 1e-5},
    {'params': model.fc.parameters(),         'lr': 1e-3}
])
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)
loss_fn   = nn.CrossEntropyLoss()

best_acc = 0.0
for epoch in range(20):
    model.train()
    for imgs, labels in train_loader:
        imgs, labels = imgs.to(device), labels.to(device)
        optimizer.zero_grad()
        loss = loss_fn(model(imgs), labels)
        loss.backward()
        optimizer.step()

    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for imgs, labels in val_loader:
            imgs, labels = imgs.to(device), labels.to(device)
            preds = model(imgs).argmax(1)
            correct += (preds == labels).sum().item()
            total   += labels.size(0)
    acc = correct / total

    scheduler.step()
    if acc > best_acc:
        best_acc = acc
        torch.save(model.state_dict(), 'best_finetuned.pth')

    print(f"Epoch {epoch+1:2d}  Val Acc: {acc:.4f}  Best: {best_acc:.4f}")

Common Fine-Tuning Mistakes

Mistake 1: Using the same large lr for all layers
  → Destroys pretrained features in early layers
  Fix: Use discriminative learning rates

Mistake 2: Forgetting to unfreeze layers
  → Model trains only the head; misses the benefit of fine-tuning
  Fix: Explicitly unfreeze layers after Phase 1

Mistake 3: Skipping normalization
  → Model receives input in wrong range, accuracy plummets
  Fix: Always use ImageNet mean/std normalization

Mistake 4: Forgetting model.eval() at validation
  → BatchNorm and Dropout behave incorrectly
  Fix: Always switch modes explicitly

Summary

Fine-tuning updates pretrained weights for a new task rather than freezing them. Start by training only the new classification head (Phase 1), then unfreeze more layers and train everything at lower learning rates (Phase 2). Use discriminative learning rates — small rates for early layers, larger for later layers and the new head. Always use the correct input normalization for your pretrained model. Fine-tuning consistently outperforms feature extraction when you have sufficient data (roughly 1,000+ samples per class). It is the dominant approach in modern computer vision and NLP workflows.

Leave a Comment

Your email address will not be published. Required fields are marked *