Module 9 — Apprentissage par transfert avec torchvision
Les huit modules précédents ont conduit à un classificateur maison de Fashion-MNIST à environ 88-90 % d'exactitude. Ce module remplace ce réseau par un ResNet18 déjà entraîné sur ImageNet et atteint 92 à 93 % en quelques minutes d'affinage. C'est le levier le plus rentable du cours : plus de qualité, moins de calcul, moins d'exemples.
Ce que « préentraîné » signifie vraiment
torchvision.models fournit des architectures — ResNet, EfficientNet,
ViT, DenseNet — accompagnées de poids appris sur ImageNet-1k
(1,3 million d'images de 1 000 catégories). Ces poids encodent des
représentations visuelles utiles bien au-delà d'ImageNet : bords,
textures, formes, motifs. Réutiliser ces représentations sur un
nouveau jeu — les vêtements de Fashion-MNIST — est la définition même
de l'apprentissage par transfert.
from torchvision.models import resnet18, ResNet18_Weights
poids = ResNet18_Weights.IMAGENET1K_V1
modele = resnet18(weights=poids)
print(modele.fc) # Linear(in_features=512, out_features=1000, bias=True)
La dernière couche fc fait 512 → 1000 : mille classes ImageNet. Notre
problème en a dix ; il faut donc remplacer la tête sans toucher au
reste.
Étape 1 : remplacer la tête
C'est une réaffectation d'attribut, rien de plus. Le nouveau nn.Linear
est initialisé aléatoirement et donc entraînable par défaut.
import torch
from torch import nn
modele.fc = nn.Linear(in_features=512, out_features=10)
Deux vérifications à ne pas sauter. D'abord, modele.fc.weight.requires_grad
doit valoir True (par défaut c'est le cas). Ensuite, une passe avant
factice pour valider les formes.
lot_factice = torch.randn(2, 3, 224, 224)
sortie = modele(lot_factice)
print(sortie.shape) # torch.Size([2, 10])
Étape 2 : la normalisation attendue par le modèle
Un modèle préentraîné a appris avec une préparation d'entrée
précise. La lui présenter différemment dégrade tout, silencieusement.
Pour ImageNet, c'est : conversion en tenseur, redimensionnement à 224
par 224 pixels, canaux RGB dans cet ordre, et normalisation par les
moyennes et écarts-types du jeu ImageNet.
Fashion-MNIST est en niveaux de gris 28 par 28. Il faut donc convertir les images avant tout entraînement.
from torchvision import transforms
transform_transfert = transforms.Compose([
transforms.Grayscale(num_output_channels=3), # 1 canal -> 3 canaux répétés
transforms.Resize(224), # côté court à 224
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], # moyenne ImageNet
std=[0.229, 0.224, 0.225]), # écart-type ImageNet
])
Les valeurs [0.485, 0.456, 0.406] ne sont pas arbitraires. Elles sont
celles utilisées pendant le préentraînement du modèle sur ImageNet.
Une bonne pratique consiste à récupérer ces statistiques directement
depuis l'objet des poids :
poids = ResNet18_Weights.IMAGENET1K_V1
transform_officiel = poids.transforms() # renvoie la transformation exacte
Beaucoup d'échecs mystérieux de transfert viennent d'une normalisation
oubliée ou incorrecte. Si votre modèle fait 88 % sans préentraînement
et 45 % avec, la première chose à vérifier est la transformation
d'entrée. Utiliser weights.transforms() évite l'erreur.
Étape 3 : geler ou non les couches ?
Deux stratégies canoniques.
- Extracteur de caractéristiques (le plus rapide) : on fige
tout sauf la nouvelle tête. Seule
fcapprend, ce qui prend quelques minutes. - Affinage (fine-tuning) : on entraîne aussi les dernières couches
du réseau préentraîné, avec un
lrtrès petit, pour spécialiser les représentations à la nouvelle tâche.
def geler_sauf_tete(modele):
for param in modele.parameters():
param.requires_grad = False
for param in modele.fc.parameters():
param.requires_grad = True
geler_sauf_tete(modele)
Après cet appel, optimiseur = optim.AdamW(modele.fc.parameters(), lr=1e-3)
ne mettra à jour que la tête. Sur Fashion-MNIST, cette stratégie atteint
déjà ~91 % en 3 époques.
Étape 4 : affinage progressif par taux différenciés
Pour dépasser les 92 %, on dégèle les derniers blocs après
quelques époques d'entraînement de la tête, avec un lr beaucoup plus
petit sur les blocs préentraînés que sur la tête.
def preparer_affinage(modele):
for param in modele.parameters(): # tout dégeler
param.requires_grad = True
optimiseur = torch.optim.AdamW([
{"params": modele.layer1.parameters(), "lr": 1e-5},
{"params": modele.layer2.parameters(), "lr": 1e-5},
{"params": modele.layer3.parameters(), "lr": 5e-5},
{"params": modele.layer4.parameters(), "lr": 1e-4},
{"params": modele.fc.parameters(), "lr": 1e-3},
], weight_decay=1e-4)
return optimiseur
La logique : plus une couche est proche de l'entrée, plus elle
apprend des caractéristiques génériques (bords, textures) qui doivent
rester quasi intactes. Plus elle est proche de la sortie, plus elle
est spécifique à la tâche originale et doit être révisée avec le
nouveau jeu. La tête, elle, part de zéro et peut apprendre avec un
lr normal.
Une procédure en deux phases, prête à copier
def transfert_deux_phases(train_loader, val_loader, nb_epoques_tete=3, nb_epoques_affinage=5):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Phase 1 : entraîner uniquement la tête
modele = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1).to(device)
modele.fc = nn.Linear(512, 10).to(device)
geler_sauf_tete(modele)
criterion = nn.CrossEntropyLoss()
optim1 = torch.optim.AdamW(modele.fc.parameters(), lr=1e-3, weight_decay=1e-4)
for epoque in range(nb_epoques_tete):
entrainer_une_epoque(modele, train_loader, criterion, optim1)
_, exact = evaluer(modele, val_loader, criterion)
print(f"phase 1 - époque {epoque} - exact. {exact:.3f}")
# Phase 2 : affinage progressif
optim2 = preparer_affinage(modele)
for epoque in range(nb_epoques_affinage):
entrainer_une_epoque(modele, train_loader, criterion, optim2)
_, exact = evaluer(modele, val_loader, criterion)
print(f"phase 2 - époque {epoque} - exact. {exact:.3f}")
return modele
Le piège de BatchNorm gelé
ResNet18 contient beaucoup de couches BatchNorm. Quand on met
param.requires_grad = False sur elles, on gèle leurs paramètres
appris (gamma, beta), mais on ne fige pas les moyennes courantes,
qui continuent à se mettre à jour pendant modele.train(). Sur un
petit jeu comme Fashion-MNIST, ces moyennes dérivent lentement, ce
qui peut dégrader une couche « censée » être figée.
Pour figer complètement BatchNorm, une astuce :
def figer_batchnorm(modele):
for m in modele.modules():
if isinstance(m, nn.BatchNorm2d):
m.eval() # fige les statistiques
for p in m.parameters():
p.requires_grad = False
À appeler avant chaque modele.train() — l'appel train() remet
tous les sous-modules en mode entraînement, y compris les BatchNorm
qu'on vient de figer.
Dans la boucle d'entraînement d'un modèle avec BatchNorm gelée :
modele.train() puis figer_batchnorm(modele). L'inverse défait
le travail immédiatement.
Combien d'exemples faut-il ?
L'un des grands intérêts du transfert est de fonctionner sur des jeux petits. Sur Fashion-MNIST, on peut se contenter de 5 000 images d'entraînement au lieu des 60 000 et perdre à peine deux points d'exactitude. Sur un jeu vraiment petit — quelques centaines d'exemples par classe — le transfert reste souvent la seule voie viable, quand un modèle from scratch ne dépasse pas le hasard.
En résumé
- Un modèle préentraîné apporte des représentations visuelles utiles bien au-delà de sa tâche d'origine ; la tête se remplace par une nouvelle couche linéaire.
- La normalisation d'entrée doit imiter celle du préentraînement :
weights.transforms()renvoie la transformation exacte, à préférer aux copies à la main. - Deux stratégies : extracteur de caractéristiques (tout figé sauf la tête), ou affinage progressif avec taux différenciés par bloc.
BatchNormgelé ne l'est pas si l'on n'appelle pas.eval()explicitement sur ces couches : leurs moyennes continuent à dériver pendantmodele.train().
Le module suivant clôt le cours en exportant ce modèle affiné vers TorchScript puis ONNX pour le servir en production sans dépendance à l'interpréteur Python.