Transfer learning et fine-tuning en PyTorch : Réutiliser des modèles pré-entraînés

tl;dr: Transfer learning = réutiliser modèles pré-entraînés (ImageNet). 2 approches : (1) Feature extraction : geler poids (requires_grad=False), remplacer dernière couche. (2) Fine-tuning : dégeler progressif, LR différenciés (faible pré-entraînées, élevé nouvelles). Modèles : ResNet, EfficientNet, ViT, MobileNet. 100-1K images = résultats 100K from scratch.

Entraîner un réseau de neurones from scratch nécessite des dizaines de milliers d’images et plusieurs jours sur GPU. Et si vous pouviez obtenir d’excellents résultats avec seulement 100-1000 images et quelques heures ? C’est la promesse du transfer learning : réutiliser des modèles déjà entraînés et les adapter à votre tâche spécifique.

Pourquoi transfer learning ?

Comparez ces deux approches :

Entraînement from scratch

❌ 10,000-100,000 images nécessaires
❌ 2-7 jours d'entraînement sur GPU
❌ Risque d'overfitting si peu de données
❌ Nécessite expertise en hyperparamètres

Transfer learning

✅ 100-1,000 images suffisent
✅ Quelques heures d'entraînement
✅ Meilleure généralisation
✅ Convergence plus rapide et stable

Comparaison entraînement from scratch vs transfer learning

💡 Principe du transfer learning : Un modèle entraîné sur ImageNet (1.2M images, 1000 classes) a appris des features génériques (edges, textures, formes). Ces features sont utiles pour la plupart des tâches de vision. On réutilise ces connaissances !

Les deux approches

Feature Extraction (extraction de features)

On gèle tous les poids du modèle pré-entraîné et entraîne seulement une nouvelle dernière couche :

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

# 1. Charger modèle pré-entraîné
model = models.resnet50(pretrained=True)

# 2. Geler TOUS les paramètres
for param in model.parameters():
    param.requires_grad = False

# 3. Remplacer la dernière couche
num_features = model.fc.in_features  # 2048 pour ResNet50
model.fc = nn.Linear(num_features, num_classes)  # num_classes = vos classes

# 4. Optimizer seulement sur la nouvelle couche
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

Quand utiliser :

  • ✅ Très peu de données (<1000 images)
  • ✅ Votre tâche est similaire à ImageNet
  • ✅ Vous voulez un entraînement ultra-rapide
  • ❌ Votre domaine est très différent (imagerie médicale, satellites)

Fine-tuning complet

On dégèle progressivement des couches et les entraîne avec des learning rates différenciés :

# 1. Charger modèle pré-entraîné
model = models.resnet50(pretrained=True)

# 2. Remplacer dernière couche
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, num_classes)

# 3. Learning rates différenciés
optimizer = torch.optim.Adam([
    {'params': model.layer4.parameters(), 'lr': 1e-5},  # Dernière conv block
    {'params': model.fc.parameters(), 'lr': 1e-3}       # Nouvelle tête
])

# Note : les autres couches restent gelées
for name, param in model.named_parameters():
    if 'layer4' not in name and 'fc' not in name:
        param.requires_grad = False

Quand utiliser :

  • ✅ Dataset de taille moyenne (1000-10,000 images)
  • ✅ Votre domaine diffère d’ImageNet
  • ✅ Vous cherchez la meilleure accuracy possible
  • ❌ Très peu de données (risque d’overfitting)

Modèles pré-entraînés disponibles

PyTorch fournit des dizaines de modèles pré-entraînés via torchvision.models.

ResNet (Residual Networks)

Très populaire, excellent compromis performance/vitesse :

from torchvision import models

# Différentes tailles
resnet18 = models.resnet18(pretrained=True)   # 11M params, rapide
resnet34 = models.resnet34(pretrained=True)   # 21M params
resnet50 = models.resnet50(pretrained=True)   # 25M params, bon défaut
resnet101 = models.resnet101(pretrained=True) # 44M params
resnet152 = models.resnet152(pretrained=True) # 60M params, très précis

# Voir l'architecture
print(resnet50)

EfficientNet (efficace en paramètres)

State-of-the-art en 2020, excellent compromis taille/performance :

# EfficientNet B0 à B7 (du plus petit au plus grand)
efficientnet_b0 = models.efficientnet_b0(pretrained=True)  # 5M params
efficientnet_b1 = models.efficientnet_b1(pretrained=True)  # 7M params
efficientnet_b4 = models.efficientnet_b4(pretrained=True)  # 19M params
efficientnet_b7 = models.efficientnet_b7(pretrained=True)  # 66M params

Vision Transformers (ViT)

Architecture Transformer appliquée à la vision, SOTA 2024 :

# Vision Transformer
vit_b_16 = models.vit_b_16(pretrained=True)  # Base, patch 16x16
vit_b_32 = models.vit_b_32(pretrained=True)  # Base, patch 32x32
vit_l_16 = models.vit_l_16(pretrained=True)  # Large, très précis

# Swin Transformer (variante performante)
swin_t = models.swin_t(pretrained=True)      # Tiny
swin_s = models.swin_s(pretrained=True)      # Small
swin_b = models.swin_b(pretrained=True)      # Base

MobileNet (pour mobile/edge)

Optimisé pour mobile et edge devices :

mobilenet_v2 = models.mobilenet_v2(pretrained=True)  # 3.5M params
mobilenet_v3_small = models.mobilenet_v3_small(pretrained=True)
mobilenet_v3_large = models.mobilenet_v3_large(pretrained=True)

Tableau comparatif

ModèleParamsTop-1 AccVitesseUse Case
ResNet1811M69.8%⚡⚡⚡⚡⚡Prototypage rapide
ResNet5025M76.1%⚡⚡⚡⚡Bon défaut
EfficientNet-B05M77.7%⚡⚡⚡⚡Efficacité
EfficientNet-B419M83.0%⚡⚡⚡Haute précision
ViT-B/1686M81.1%⚡⚡Transformers, gros datasets
Swin-T28M81.3%⚡⚡⚡SOTA efficient
MobileNet-V23.5M71.9%⚡⚡⚡⚡⚡Mobile, edge
🔎 Tip
Quel modèle choisir ? ResNet50 : Excellent point de départ, rapide et performant. EfficientNet-B4 : Meilleur accuracy avec peu de paramètres. ViT : Si vous avez beaucoup de données (>10K images). MobileNet : Pour déploiement mobile/edge.

Feature extraction : étape par étape

Implémentons une feature extraction complète sur un dataset custom.

Charger et préparer le modèle

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

# Charger ResNet50 pré-entraîné
model = models.resnet50(pretrained=True)

# Geler tous les paramètres
for param in model.parameters():
    param.requires_grad = False

# Remplacer la dernière couche (fc) pour votre nombre de classes
num_classes = 10  # Ex: 10 classes custom
num_features = model.fc.in_features  # 2048 pour ResNet50
model.fc = nn.Linear(num_features, num_classes)

# Vérifier ce qui est trainable
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)

print(f"Total params: {total_params:,}")
print(f"Trainable params: {trainable_params:,}")  # Seulement la nouvelle fc layer

Préparer les données

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

# Transformations (ImageNet normalization)
transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.485, 0.456, 0.406],  # ImageNet mean
        std=[0.229, 0.224, 0.225]     # ImageNet std
    )
])

# Charger dataset
train_dataset = datasets.ImageFolder('data/train/', transform=transform)
val_dataset = datasets.ImageFolder('data/val/', transform=transform)

# DataLoaders
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)
⚠️ Warning
Important : Utilisez toujours la normalisation ImageNet (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) pour les modèles pré-entraînés sur ImageNet. Sinon, les features extraites seront incorrectes !

Entraîner

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

# Optimizer seulement sur la nouvelle layer
optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

# Training loop
num_epochs = 10

for epoch in range(num_epochs):
    # Training
    model.train()
    train_loss = 0.0

    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)

        outputs = model(images)
        loss = criterion(outputs, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        train_loss += loss.item()

    # Validation
    model.eval()
    val_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)

            val_loss += loss.item()

            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)

    train_loss /= len(train_loader)
    val_loss /= len(val_loader)
    val_acc = 100.0 * correct / total

    print(f"Epoch {epoch+1}/{num_epochs}")
    print(f"  Train Loss: {train_loss:.4f}")
    print(f"  Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}%")

Fine-tuning : Dégel progressif

Pour obtenir de meilleures performances, dégelez progressivement les couches.

Dégel en deux phases

# Phase 1 : Feature extraction (5 epochs)
model = models.resnet50(pretrained=True)

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

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

optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)

# Entraîner 5 epochs...
# train(model, train_loader, val_loader, epochs=5)

# Phase 2 : Fine-tuning (10 epochs)
# Dégeler layer4 (dernière conv block)
for param in model.layer4.parameters():
    param.requires_grad = True

# Learning rates différenciés
optimizer = optim.Adam([
    {'params': model.layer4.parameters(), 'lr': 1e-5},  # LR faible
    {'params': model.fc.parameters(), 'lr': 1e-4}       # LR moyen
])

# Entraîner 10 epochs supplémentaires...
# train(model, train_loader, val_loader, epochs=10)

Learning rates discriminatifs

Différents LR pour chaque groupe de couches :

def get_parameter_groups(model, base_lr=1e-5):
    """
    Crée des groupes de paramètres avec LR décroissants
    """
    # ResNet structure : conv1 → layer1 → layer2 → layer3 → layer4 → fc
    param_groups = [
        {'params': model.conv1.parameters(), 'lr': base_lr * 0.1},
        {'params': model.layer1.parameters(), 'lr': base_lr * 0.2},
        {'params': model.layer2.parameters(), 'lr': base_lr * 0.4},
        {'params': model.layer3.parameters(), 'lr': base_lr * 0.6},
        {'params': model.layer4.parameters(), 'lr': base_lr * 0.8},
        {'params': model.fc.parameters(), 'lr': base_lr * 10}  # Nouvelle layer
    ]
    return param_groups

# Utilisation
model = models.resnet50(pretrained=True)
model.fc = nn.Linear(model.fc.in_features, num_classes)

param_groups = get_parameter_groups(model, base_lr=1e-5)
optimizer = optim.Adam(param_groups)

Dégel graduel (layer by layer)

def unfreeze_layer(model, layer_name):
    """
    Dégèle une couche spécifique
    """
    for name, param in model.named_parameters():
        if layer_name in name:
            param.requires_grad = True

# Entraînement progressif
model = models.resnet50(pretrained=True)
model.fc = nn.Linear(model.fc.in_features, num_classes)

# Tout geler sauf fc
for param in model.parameters():
    param.requires_grad = False
for param in model.fc.parameters():
    param.requires_grad = True

# Epoch 1-5 : fc seulement
# train(model, epochs=5)

# Epoch 6-10 : fc + layer4
unfreeze_layer(model, 'layer4')
optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-5)
# train(model, epochs=5)

# Epoch 11-15 : fc + layer4 + layer3
unfreeze_layer(model, 'layer3')
optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-5)
# train(model, epochs=5)

Adapter à différentes architectures

Chaque architecture a sa propre structure pour la dernière couche.

ResNet

model = models.resnet50(pretrained=True)
# Dernière couche : model.fc (Linear)
num_features = model.fc.in_features  # 2048
model.fc = nn.Linear(num_features, num_classes)

EfficientNet

model = models.efficientnet_b0(pretrained=True)
# Dernière couche : model.classifier (Sequential)
num_features = model.classifier[1].in_features  # 1280
model.classifier = nn.Sequential(
    nn.Dropout(p=0.2),
    nn.Linear(num_features, num_classes)
)

Vision Transformer (ViT)

model = models.vit_b_16(pretrained=True)
# Dernière couche : model.heads.head (Linear)
num_features = model.heads.head.in_features  # 768
model.heads.head = nn.Linear(num_features, num_classes)

MobileNet

model = models.mobilenet_v2(pretrained=True)
# Dernière couche : model.classifier (Sequential)
num_features = model.classifier[1].in_features  # 1280
model.classifier = nn.Sequential(
    nn.Dropout(p=0.2),
    nn.Linear(num_features, num_classes)
)

Data Augmentation pour Transfer Learning

L’augmentation est encore plus importante avec peu de données :

from torchvision import transforms

# Augmentation agressive pour training
train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomVerticalFlip(p=0.2),
    transforms.RandomRotation(degrees=15),
    transforms.ColorJitter(
        brightness=0.3,
        contrast=0.3,
        saturation=0.3,
        hue=0.1
    ),
    transforms.RandomAffine(
        degrees=0,
        translate=(0.1, 0.1),
        scale=(0.9, 1.1)
    ),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# Pas d'augmentation pour validation
val_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

Exemple complet : Transfer learning sur dataset custom

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

# Configuration
num_classes = 10
batch_size = 32
num_epochs_phase1 = 5
num_epochs_phase2 = 10
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Transformations
train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.2, contrast=0.2),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

val_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# Datasets
train_dataset = datasets.ImageFolder('data/train/', transform=train_transform)
val_dataset = datasets.ImageFolder('data/val/', transform=val_transform)

train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=4)

# Modèle
model = models.resnet50(pretrained=True)
for param in model.parameters():
    param.requires_grad = False

num_features = model.fc.in_features
model.fc = nn.Linear(num_features, num_classes)
model = model.to(device)

# Phase 1 : Feature Extraction
print("=== Phase 1: Feature Extraction ===")
optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)

best_acc = 0.0

for epoch in range(num_epochs_phase1):
    # Training
    model.train()
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)

        outputs = model(images)
        loss = criterion(outputs, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    scheduler.step()

    # Validation
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)

    acc = 100.0 * correct / total
    print(f"Epoch {epoch+1}: Val Acc = {acc:.2f}%")

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

# Phase 2 : Fine-Tuning
print("\n=== Phase 2: Fine-Tuning ===")
for param in model.layer4.parameters():
    param.requires_grad = True

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

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs_phase2)

for epoch in range(num_epochs_phase2):
    # Training
    model.train()
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)

        outputs = model(images)
        loss = criterion(outputs, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    scheduler.step()

    # Validation
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)

    acc = 100.0 * correct / total
    print(f"Epoch {epoch+1}: Val Acc = {acc:.2f}%")

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

print(f"\nBest Validation Accuracy: {best_acc:.2f}%")

Conseils et best practices

Choisir le bon learning rate

# Learning rates recommandés
lr_feature_extraction = 1e-3   # Nouvelle couche
lr_fine_tuning_high = 1e-4     # Nouvelle couche en fine-tuning
lr_fine_tuning_low = 1e-5      # Couches pré-entraînées

Éviter l’overfitting

# Techniques anti-overfitting
model = models.resnet50(pretrained=True)
model.fc = nn.Sequential(
    nn.Dropout(0.5),           # Dropout avant dernière layer
    nn.Linear(2048, num_classes)
)

# Data augmentation agressive
# Early stopping
# Régularisation L2 (weight_decay)
optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)

Surveiller la convergence

# Signes de bonne convergence :
# - Val acc augmente régulièrement
# - Gap train/val < 5-10%
# - Loss diminue progressivement

# Signes de problèmes :
# - Val acc stagne dès epoch 1 → LR trop faible ou modèle mal initialisé
# - Val acc diminue après quelques epochs → Overfitting, ajouter régularisation
# - Loss explose → LR trop élevé

Exercices pratiques

Comparer modèles

Comparez ResNet50, EfficientNet-B0 et MobileNet-V2 sur le même dataset :

models_to_test = {
    'resnet50': models.resnet50(pretrained=True),
    'efficientnet_b0': models.efficientnet_b0(pretrained=True),
    'mobilenet_v2': models.mobilenet_v2(pretrained=True)
}

for name, model in models_to_test.items():
    # Adapter dernière couche
    # Entraîner
    # Mesurer accuracy et temps
    pass

Impact du dégel progressif

Mesurez l’impact du fine-tuning vs feature extraction pure :

# Scénario 1 : Feature extraction seulement
# Scénario 2 : Feature extraction + fine-tuning layer4
# Scénario 3 : Feature extraction + fine-tuning complet
# Comparer les accuracy finales

Optimal learning rate

Trouvez le meilleur learning rate avec un learning rate finder :

def find_lr(model, train_loader, start_lr=1e-7, end_lr=1, num_iter=100):
    lrs = []
    losses = []
    # ... implémenter LR finder
    return lrs, losses

Conclusion

Dans cet article, vous avez maîtrisé le transfer learning avec PyTorch :

  • ✅ Principe du transfer learning (réutiliser modèles pré-entraînés)
  • ✅ Feature extraction (geler tout sauf dernière couche)
  • ✅ Fine-tuning complet (dégel progressif)
  • ✅ Learning rates différenciés par couche
  • ✅ Modèles disponibles (ResNet, EfficientNet, ViT)
  • ✅ Adaptation à différentes architectures
  • ✅ Data augmentation spécifique
  • ✅ Stratégies d’entraînement en 2 phases

Prochaine étape : Dans le prochain article, nous allons plonger dans les CNNs et Computer Vision. Vous apprendrez les convolutions en détail, les architectures classiques, et des tâches avancées comme la détection d’objets.

Pour aller plus loin :

🔎 Tip
Projet pratique : Collectez 100-500 images par classe pour un problème de classification (ex: chiens vs chats, fleurs, produits). Entraînez ResNet50 avec transfer learning. Comparez feature extraction vs fine-tuning. Objectif : >90% accuracy avec <1000 images totales !