Aller au contenu principal

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
La normalisation attendue par le modèle

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 fc apprend, ce qui prend quelques minutes.
  • Affinage (fine-tuning) : on entraîne aussi les dernières couches du réseau préentraîné, avec un lr trè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.

Ordre d'appel

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.
  • BatchNorm gelé ne l'est pas si l'on n'appelle pas .eval() explicitement sur ces couches : leurs moyennes continuent à dériver pendant modele.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.