DL · Chapitre 4
Deep learning 4 — Améliorer un CNN : pipeline de données, régularisation et transfer learning
Le chapitre précédent nous a donné un objet fonctionnel : un petit réseau convolutif capable de classifier des chiffres MNIST avec une précision honorable, en empilant deux ou trois blocs Conv2d — ReLU — MaxPool2d suivis d'un classifieur linéaire. Sur MNIST, cette recette suffit. Sur des problèmes à peine plus exigeants — Fashion-MNIST, et surtout CIFAR-10 — elle commence à s'essouffler. Trois symptômes apparaissent rapidement, qui sont les véritables sujets de ce chapitre.
Le premier est un problème de plomberie : sur GPU, le réseau attend les données. La lecture des images PNG, leur décodage, leurs transformations, puis leur transfert vers la mémoire GPU prennent plus de temps que la passe avant et la passe arrière elles-mêmes. Le second est un problème de stabilité de l'entraînement : à mesure que le réseau s'approfondit, les distributions d'activations dérivent, le taux d'apprentissage devient capricieux, et la convergence ralentit. Le troisième est un problème de généralisation : le réseau apprend par cœur les détails du jeu d'entraînement et perd en performance sur le test. À cela s'ajoute, en arrière-plan, un constat plus fondamental : sur CIFAR-10, partir d'un réseau initialisé aléatoirement et l'entraîner from scratch est une stratégie inefficace dès lors qu'on dispose, gratuitement, de modèles pré-entraînés sur des dizaines de millions d'images.
Ce chapitre introduit donc, dans cet ordre : la mécanique du DataLoader PyTorch et les leviers d'accélération du pipeline de données, la Batch Normalization comme stabilisateur d'entraînement, le Dropout comme régularisateur, la data augmentation comme régularisateur structurel, le learning rate scheduling, et enfin le transfer learning — comment recycler un ResNet ou un VGG entraîné sur ImageNet pour un problème de classification à dix classes. À la fin du chapitre, vous saurez assembler un CNN qui dépasse 80 % d'accuracy sur CIFAR-10 sans efforts héroïques.
Le pipeline de données : Dataset, DataLoader, et goulots d'étranglement
Quand les données ne tiennent plus en mémoire, ou qu'elles arrivent sous forme de fichiers PNG sur disque, on ne peut plus charger un tenseur global avant la boucle d'entraînement. Il faut un mécanisme qui lise chaque image à la demande, applique les pré-traitements et regroupe les exemples en mini-batchs. PyTorch sépare cette mécanique en deux objets complémentaires.
Dataset et ImageFolder
Un Dataset PyTorch est essentiellement un objet avec deux méthodes : __len__() (combien d'exemples ?) et __getitem__(i) (que renvoyer pour l'exemple ?). Pour les images, torchvision fournit une implémentation prête à l'emploi : ImageFolder. Elle suppose une organisation en sous-dossiers, un par classe :
dataset/
├── train/
│ ├── 0/ # toutes les images de la classe 0
│ ├── 1/ # toutes les images de la classe 1
│ └── ...
└── test/
├── 0/
├── 1/
└── ...
Le nom de chaque sous-dossier devient le label associé. ImageFolder parcourt l'arborescence à l'instanciation, indexe les fichiers, et retourne à la demande un couple (image_PIL, label_int). Mais à ce stade les images sont en format PIL, en valeurs entières, et pas nécessairement à la taille attendue par le réseau. Il faut donc passer par une pipeline de transformations.
from torchvision import datasets, transforms from torch.utils.data import DataLoader transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((28, 28)), transforms.ToTensor(), ]) train_dataset = datasets.ImageFolder(root="dataset/train", transform=transform) test_dataset = datasets.ImageFolder(root="dataset/test", transform=transform)
ToTensor() accomplit deux gestes essentiels : il convertit l'image PIL (H, W, C) en tenseur PyTorch (C, H, W), et il divise par 255 pour ramener les valeurs dans . Si l'image est déjà en niveaux de gris, le canal est ajouté automatiquement, et la sortie est de forme (1, H, W).
DataLoader et mini-batchs
Un Dataset ne sait pas regrouper les exemples en batchs. C'est le rôle du DataLoader, qui prend un Dataset et orchestre :
- la création de mini-batchs de taille fixe ;
- le mélange des exemples (
shuffle=Trueen entraînement) ; - la parallélisation du chargement (workers en processus séparés) ;
- la stratégie de copie vers le GPU (mémoire pinnée, transfert asynchrone).
train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False) for Xb, yb in train_loader: # Xb : (B, C, H, W) # yb : (B,) ...
Au premier ordre, c'est tout ce dont on a besoin pour entraîner un CNN. Au second ordre, lorsqu'on observe que le GPU est sous-utilisé alors que le CPU sature, il faut sortir les leviers d'optimisation.
Les quatre leviers d'accélération
Quand un entraînement « semble lent » sur GPU, la cause la plus fréquente n'est pas le modèle mais le pipeline de données. Quatre options du DataLoader permettent de réduire les goulots d'étranglement.
Le premier, et le plus important, est num_workers. Par défaut, num_workers=0 : tout le chargement se fait dans le processus principal, en série avec la passe avant. En passant à num_workers=4, on lance quatre processus parallèles dédiés à la lecture des fichiers, au décodage, à l'application des transformations et à l'assemblage des batchs. Le GPU peut alors recevoir le batch suivant pendant qu'il traite le batch courant.
train_loader = DataLoader( train_dataset, batch_size=256, shuffle=True, num_workers=4, )
Le bon nombre de workers dépend du CPU et du coût des transformations. Sur Kaggle, 2 à 8 fonctionnent bien. Au-delà, l'overhead de communication entre processus dégrade les performances.
Le deuxième levier, persistent_workers=True, évite de recréer les processus à chaque epoch. Utile quand les epochs sont courtes et le coût de démarrage des workers significatif.
Le troisième, pin_memory=True, demande au DataLoader d'allouer les batchs dans une zone de mémoire pinnée (page-locked) côté CPU. Cette zone permet à CUDA de copier les données vers le GPU par DMA, sans passer par un buffer intermédiaire. Le gain n'est sensible qu'avec un GPU.
Le quatrième est l'option non_blocking=True lors du transfert vers le GPU, à utiliser conjointement avec pin_memory :
for Xb, yb in train_loader: Xb = Xb.to(device, non_blocking=True) yb = yb.to(device, non_blocking=True) ...
L'idée : le CPU n'attend pas la fin de la copie pour passer à l'instruction suivante. Si la passe avant peut commencer dès que les données sont arrivées, on obtient un recouvrement entre transfert et calcul.
Diagnostic. Si le GPU est peu utilisé (faible utilization dans
nvidia-smi) alors que le CPU sature, le problème est dans le pipeline. Augmenteznum_workers, vérifiezpin_memory=True, et simplifiez les transformations (unResizeinutile sur des images déjà à la bonne taille coûte cher à grande échelle). Si à l'inverse la RAM explose, c'est quenum_workersouprefetch_factorest trop grand.
Voici la configuration « rapide » canonique, sur images PNG et GPU :
train_loader = DataLoader( train_dataset, batch_size=256, shuffle=True, num_workers=4, pin_memory=True, persistent_workers=True, )
Batch Normalization : stabiliser l'entraînement
Le réseau du chapitre précédent fonctionne sur MNIST, mais commence à donner des courbes de perte erratiques dès qu'on l'approfondit ou qu'on l'entraîne sur Fashion-MNIST. La cause profonde est ce que Sergey Ioffe et Christian Szegedy ont nommé en 2015 le internal covariate shift : à chaque mise à jour des poids des couches précédentes, la distribution des activations qui arrivent à la couche change. La couche doit alors constamment se réajuster à un terrain mouvant, ce qui ralentit la convergence et rend le réseau sensible à l'initialisation et au taux d'apprentissage.
Principe
La Batch Normalization (BN) attaque ce problème en normalisant explicitement les activations à la sortie de chaque couche. Pour un mini-batch de exemples et un canal donné, la BN calcule la moyenne empirique et la variance empirique des activations sur le batch, puis applique :
Les activations centrées et réduites passent ensuite dans une transformation affine apprise :
Les paramètres et sont des poids comme les autres, optimisés par descente de gradient. Ils permettent au réseau de récupérer une distribution non standard si elle est utile à la tâche — la BN n'impose pas une normalité figée, elle offre une normalisation conditionnelle, désactivable par apprentissage si et .
Dans un CNN, la normalisation se fait par canal (par feature map). Pour une couche convolutive de sortie (B, 64, 16, 16), la BN calcule 64 paires — une par canal — sur les activations correspondantes.
Où placer la BatchNorm
Le placement standard est :
Conv -> BatchNorm -> ReLU
C'est-à-dire après la convolution, avant la fonction d'activation. L'argument est simple : la BN normalise une combinaison linéaire (la sortie de Conv), ce qui est l'objet pour lequel la statistique a un sens. Appliquer la BN après la ReLU normaliserait des valeurs déjà tronquées à , ce qui biaise la moyenne. C'est l'ordre proposé dans le papier original, et c'est celui qu'on retient en pratique.
Train vs eval. La BN se comporte différemment dans les deux modes du modèle. Pendant
model.train(), elle utilise les statistiques du batch courant () pour normaliser, et met à jour des moyennes courantes (running statistics) en arrière-plan. Pendantmodel.eval(), elle utilise ces moyennes courantes — calculées sur l'ensemble de l'entraînement — pour normaliser. C'est essentiel : oubliermodel.eval()à l'inférence donne des résultats absurdes, parce que la BN normalise alors avec les statistiques d'un batch potentiellement réduit ou homogène. Inversement, oubliermodel.train()au début de chaque epoch fige les statistiques courantes et le réseau cesse d'apprendre correctement.
Implémentation PyTorch
Pour une couche convolutive de sortie 64 canaux :
import torch.nn as nn class Block(nn.Module): def __init__(self): super().__init__() self.conv = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn = nn.BatchNorm2d(64) self.relu = nn.ReLU() def forward(self, x): x = self.conv(x) x = self.bn(x) x = self.relu(x) return x
BatchNorm2d(64) instancie 64 couples et 64 couples . Pour une couche entièrement connectée, on utiliserait BatchNorm1d. Pour les images 3D (vidéo, scans médicaux), BatchNorm3d.
L'effet pratique sur l'entraînement est spectaculaire : la perte décroît plus rapidement, on peut utiliser des taux d'apprentissage plus élevés sans diverger, et l'initialisation des poids devient moins critique.
Dropout : régulariser le classifieur
Là où la BN s'attaque à la stabilité, le Dropout s'attaque au sur-apprentissage. C'est une idée sobre, due à Srivastava et al. (2014) : pendant l'entraînement, désactiver aléatoirement une fraction des neurones à chaque passage avant.
Principe
Pour chaque neurone, à chaque itération, on tire indépendamment une variable de Bernoulli de paramètre . Si elle vaut 1, le neurone est conservé ; si elle vaut 0, sa sortie est mise à zéro pour ce passage. Les neurones « survivants » sont rescalés par pour conserver l'espérance des activations.
L'effet est double. D'une part, le réseau ne peut pas concentrer toute son information sur un petit nombre de chemins privilégiés, puisque ces chemins disparaissent aléatoirement. Il doit donc construire des représentations redondantes, plus robustes. D'autre part, on peut interpréter le Dropout comme l'entraînement implicite d'une moyenne d'un grand nombre de sous-réseaux partageant les mêmes poids — une forme de bagging implicite.
À l'évaluation, le Dropout est désactivé : tous les neurones sont actifs, sans rescaling, et le réseau utilise tous ses chemins. Comme pour la BN, ce comportement dépend du mode train()/eval().
Où le placer
Le Dropout se place essentiellement dans le classifieur — c'est-à-dire les couches Linear finales — et plus rarement après des blocs convolutifs peu profonds. Schéma typique :
Linear -> ReLU -> Dropout -> Linear
self.fc1 = nn.Linear(64 * 8 * 8, 256) self.dropout = nn.Dropout(p=0.5) self.fc2 = nn.Linear(256, 10) def forward(self, x): x = self.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x
Avant ou après la ReLU ? En théorie, pour ReLU et beaucoup d'autres activations, mettre Dropout avant ou après la non-linéarité revient au même : zéro reste zéro. En pratique, on le met après la ReLU, ce qui correspond à l'écriture la plus courante. La probabilité usuelle est 0,5 pour les couches denses, plus faible (0,1 à 0,3) pour les Dropout convolutifs (
Dropout2d).
BN et Dropout dans la même couche : à éviter. Combiner BN et Dropout dans la même position est une mauvaise idée. La BN normalise sur la base de statistiques du batch ; le Dropout ajoute du bruit qui décale ces statistiques. La conjonction donne souvent des dégradations sensibles. Dans les architectures modernes (ResNet par exemple), le Dropout est souvent absent et la régularisation est apportée par la BN seule, plus la weight decay et la data augmentation.
Un CNN amélioré pour CIFAR-10
Avec ces deux outils, on peut écrire un CNN substantiellement plus solide pour CIFAR-10 (images RGB 32×32, 10 classes). On garde l'ossature en trois blocs convolutifs, on ajoute la BN après chaque convolution, on insère un Dropout dans le classifieur :
import torch.nn as nn class CNN_BN_Dropout(nn.Module): def __init__(self, num_classes=10, dropout_p=0.5): super().__init__() self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(16) self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(32) self.conv3 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(64) self.relu = nn.ReLU() self.pool = nn.MaxPool2d(2) self.flatten = nn.Flatten() self.fc1 = nn.Linear(64 * 8 * 8, 256) self.dropout = nn.Dropout(p=dropout_p) self.fc2 = nn.Linear(256, num_classes) def forward(self, x): x = self.pool(self.relu(self.bn1(self.conv1(x)))) # 32 -> 16 x = self.relu(self.bn2(self.conv2(x))) # 16 x = self.pool(self.relu(self.bn3(self.conv3(x)))) # 16 -> 8 x = self.flatten(x) x = self.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x
Et dans la boucle d'entraînement, on n'oublie pas les bascules train()/eval() :
for epoch in range(epochs): model.train() for Xb, yb in train_loader: ... model.eval() with torch.no_grad(): for Xb, yb in test_loader: ...
À configuration égale, la version BN + Dropout converge plus vite et atteint une accuracy de test typiquement 5 à 10 points au-dessus de la version sans, sur CIFAR-10.
Data augmentation : régulariser par les données
La régularisation peut venir des poids (BN, Dropout, weight decay) ou des données elles-mêmes. La data augmentation consiste à appliquer, en entraînement, des transformations aléatoires aux images d'entrée : retournement horizontal, recadrage aléatoire, rotation, variation de luminosité, etc. Le réseau voit ainsi, à chaque epoch, des versions légèrement différentes de chaque image. Cela accroît artificiellement la taille du jeu d'entraînement et force le réseau à apprendre des représentations invariantes aux transformations choisies.
Implémentation
Tout passe par torchvision.transforms. Pour CIFAR-10, une recette classique combine random crop avec padding et random horizontal flip :
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize( mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616), ), ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616), ), ])
Augmentation : entraînement seulement. Les transformations stochastiques (RandomCrop, RandomFlip, ColorJitter) ne s'appliquent qu'au jeu d'entraînement. Au test, on évalue sur les images originales — sinon les résultats deviennent bruités et peu reproductibles, et la métrique perd son sens. On définit donc deux pipelines :
train_transformaugmenté,test_transformminimal (ToTensor+Normalize).
Le rôle de Normalize
Le transforms.Normalize retire la moyenne par canal et divise par l'écart-type, sur la base des statistiques du dataset. Pour CIFAR-10, les valeurs (0.4914, 0.4822, 0.4465) et (0.2470, 0.2435, 0.2616) sont calculées une fois pour toutes sur le jeu d'entraînement. C'est le pendant image-domain du StandardScaler que nous utilisions sur des données tabulaires. Pour le transfer learning à partir d'un modèle ImageNet, il est important d'utiliser les statistiques d'ImageNet plutôt que celles du dataset cible — nous y revenons plus loin.
Augmentations courantes
Au-delà de RandomCrop et RandomFlip, plusieurs augmentations sont utiles selon les domaines :
RandomRotation(degrees=10): rotation aléatoire ±10°. Utile en imagerie médicale, en classification d'objets pas alignés, beaucoup moins sur des chiffres écrits où l'orientation porte du sens.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1): variations photométriques.RandomAffine,RandomPerspective: transformations géométriques plus générales.RandomErasing(s'applique sur tenseur, donc aprèsToTensor) : efface un rectangle aléatoire de l'image, force le réseau à ne pas dépendre d'une région unique.
Le bon dosage demande un peu d'expérimentation : trop d'augmentation et le réseau n'apprend plus, pas assez et il sur-apprend.
Learning rate scheduling
Un dernier outil avant le transfer learning. Pendant l'entraînement, garder un taux d'apprentissage constant est rarement optimal : on aimerait un grand au début, pour explorer rapidement, et un petit vers la fin, pour affiner. C'est l'objet des learning rate schedulers.
PyTorch fournit plusieurs stratégies dans torch.optim.lr_scheduler. Trois en particulier sont utiles à connaître.
from torch.optim.lr_scheduler import StepLR, CosineAnnealingLR, ReduceLROnPlateau # Diviser le LR par 10 toutes les 30 epochs scheduler = StepLR(optimizer, step_size=30, gamma=0.1) # Décroissance en cosinus de lr_init à 0 sur T_max epochs scheduler = CosineAnnealingLR(optimizer, T_max=epochs) # Réduit le LR si la métrique surveillée stagne scheduler = ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)
L'usage typique est d'appeler scheduler.step() à la fin de chaque epoch (et non à chaque batch — sauf pour certains schedulers cycliques). Pour ReduceLROnPlateau, on passe la métrique surveillée :
for epoch in range(epochs): train_loss = train_one_epoch(...) val_loss = validate(...) scheduler.step(val_loss) # pour ReduceLROnPlateau # scheduler.step() # pour StepLR / CosineAnnealingLR
Sur CIFAR-10, un planning cosine annealing sur 50 à 200 epochs avec un taux initial de et l'optimiseur Adam (ou SGD avec momentum 0,9 et weight decay ) constitue une recette robuste.
Transfer learning : recycler un modèle pré-entraîné
Voici le levier le plus puissant de tout ce chapitre. Plutôt que de partir d'un réseau initialisé aléatoirement, on prend un modèle déjà entraîné sur un grand corpus d'images — typiquement ImageNet, 1,3 million d'images réparties en 1000 classes — et on l'adapte à notre problème. Les premières couches d'un CNN entraîné sur ImageNet ont appris des détecteurs de bords, de couleurs, de textures, qui sont génériques : les mêmes filtres servent pour reconnaître un chien, une voiture ou un avion. Les recycler évite de les réapprendre.
torchvision.models fournit une dizaine de familles d'architectures pré-entraînées : ResNet (18, 34, 50, 101, 152), VGG, DenseNet, MobileNet, EfficientNet, ConvNeXt, Vision Transformers. Le choix usuel pour démarrer : ResNet-18 (léger, rapide) ou ResNet-50 (un peu plus précis).
Stratégie 1 : feature extraction (couches gelées)
L'idée la plus simple : on récupère le réseau pré-entraîné, on gèle tous ses poids (gradient désactivé), on remplace uniquement la dernière couche linéaire par une nouvelle couche adaptée au nombre de classes du problème, et on entraîne uniquement cette dernière couche.
import torch.nn as nn from torchvision import models # Modèle pré-entraîné ImageNet model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # Geler tous les paramètres for param in model.parameters(): param.requires_grad = False # Remplacer la dernière couche (classifieur ImageNet 1000 classes -> notre 10 classes) in_features = model.fc.in_features model.fc = nn.Linear(in_features, 10) # Les paramètres de model.fc ont par défaut requires_grad=True model = model.to(device) optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)
Quelques points méritent attention. Le model.fc d'un ResNet est la couche linéaire finale ; pour un VGG ou un DenseNet, c'est model.classifier qui joue ce rôle, et il faut adapter en conséquence (un VGG a un classifier qui est lui-même un Sequential de plusieurs couches). On passe à l'optimiseur uniquement les paramètres de la nouvelle couche : c'est plus propre, et plus rapide.
Geler les couches : les deux gestes nécessaires.
requires_grad = Falsesuffit à empêcher la mise à jour des poids, mais il faut aussi que les couches BatchNorm soient en modeeval()pour qu'elles n'actualisent pas leurs statistiques courantes pendant l'entraînement. La pratique usuelle, lorsque le backbone est gelé, est d'appelermodel.eval()une fois pour figer la BN, puis de mettre uniquement la nouvelle couche en modetrain()— ou plus simplement de surveiller quemodel.train()ne soit appelé que sur les couches dégelées.
Stratégie 2 : fine-tuning
La stratégie de feature extraction est efficace quand le nouveau problème ressemble à ImageNet (objets naturels). Quand il s'en éloigne (imagerie médicale, satellites, images industrielles), on fait du fine-tuning : on dégèle tout ou partie du réseau et on poursuit l'entraînement sur le nouveau dataset, avec un taux d'apprentissage faible.
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) in_features = model.fc.in_features model.fc = nn.Linear(in_features, 10) model = model.to(device) # On entraîne TOUS les paramètres, mais avec un LR petit optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
Une variante plus fine consiste à utiliser un taux d'apprentissage différentiel : LR très faible pour les premières couches (qu'on touche peu), LR plus élevé pour les dernières couches (qu'on adapte plus fort), LR encore plus élevé pour la nouvelle tête. PyTorch le permet en passant à l'optimiseur une liste de groupes :
optimizer = torch.optim.Adam([ {"params": model.layer1.parameters(), "lr": 1e-5}, {"params": model.layer2.parameters(), "lr": 1e-5}, {"params": model.layer3.parameters(), "lr": 1e-4}, {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ])
Pré-traitement et taille d'entrée
Un dernier point pratique. Les modèles torchvision pré-entraînés sur ImageNet attendent des entrées spécifiques :
- images RGB (3 canaux),
- taille minimale 224×224 (les ResNet acceptent en pratique des entrées plus petites grâce au pooling adaptatif, mais les performances sont meilleures à 224×224),
- normalisées avec les statistiques d'ImageNet :
mean=(0.485, 0.456, 0.406),std=(0.229, 0.224, 0.225).
Pour CIFAR-10 (32×32), on redimensionne à 224×224 :
imagenet_mean = (0.485, 0.456, 0.406) imagenet_std = (0.229, 0.224, 0.225) train_transform = transforms.Compose([ transforms.Resize(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(imagenet_mean, imagenet_std), ])
L'enchaînement « ResNet-18 pré-entraîné, dernière couche remplacée, fine-tuning avec LR et augmentation modérée » dépasse confortablement les 90 % d'accuracy sur CIFAR-10 en quelques epochs, là où un CNN from scratch peinerait à atteindre 80 % en plusieurs dizaines d'epochs.
Récapitulatif : composer un entraînement solide
Les six leviers introduits dans ce chapitre fonctionnent ensemble. Voici l'ordre de priorité que l'on recommande pour un nouveau problème de classification d'images :
- Pipeline de données propre :
ImageFolder+DataLoaderavecnum_workers > 0,pin_memory=True,non_blocking=True. Avant tout le reste : si le GPU attend, rien d'autre ne servira. - Normalize sur les bonnes statistiques (du dataset si entraînement from scratch, d'ImageNet si transfer learning).
- Architecture avec BatchNorm pour stabiliser. C'est gratuit et le gain est systématique.
- Data augmentation modérée sur le train, pas sur le test. Commencer par RandomCrop + HorizontalFlip, voir si l'écart train/test diminue.
- Learning rate scheduling : un cosinus ou un StepLR pour gagner les derniers points.
- Dropout dans le classifieur si le sur-apprentissage persiste après les étapes précédentes — souvent inutile en présence de BN + augmentation.
- Transfer learning dès que la performance plafonne ou que le dataset est petit. Sur n'importe quelle tâche d'images naturelles, partir d'un modèle pré-entraîné est presque toujours plus efficace que partir de zéro.
Exercices
Exercice 1 — Mesurer l'impact du pipeline
Sur Fashion-MNIST PNG, mesurer le temps total d'une epoch dans quatre configurations :
num_workers=0;num_workers=2;num_workers=4,pin_memory=True;- configuration 3 +
non_blocking=Truedans la boucle.
Mesurer avec time.perf_counter() autour de la boucle d'entraînement complète. Tracer un bar plot des temps. Conclure : où est le goulot d'étranglement, et quel gain peut-on en attendre sur cette tâche précise ?
Exercice 2 — BN + Dropout sur CIFAR-10
Reprendre le SimpleCNN du chapitre précédent adapté à CIFAR-10 (3 canaux d'entrée, 10 classes). Construire trois variantes :
- A : sans BN, sans Dropout ;
- B : avec BatchNorm après chaque convolution ;
- C : avec BatchNorm + Dropout
p=0.5dans le classifieur.
Entraîner chaque variante 10 epochs avec Adam, LR , batch size 256. Comparer les courbes de perte et l'accuracy de test. Commenter : quel levier apporte le gain le plus net ? Y a-t-il du sur-apprentissage dans la version A ?
Exercice 3 — Data augmentation
À partir de la variante C de l'exercice 2, ajouter une pipeline d'augmentation au jeu d'entraînement (RandomCrop avec padding 4, RandomHorizontalFlip). Le jeu de test reste inchangé (uniquement ToTensor + Normalize). Réentraîner et comparer l'accuracy de test à la version sans augmentation. Tracer côte à côte les deux courbes train/test pour visualiser la réduction de l'écart de généralisation.
Exercice 4 — Learning rate scheduling
Reprendre le modèle de l'exercice 3 et entraîner sur 30 epochs dans deux conditions :
- LR constant à ;
CosineAnnealingLR(optimizer, T_max=30)initialisé à .
Tracer la courbe de LR et celle de la perte de validation. À quelle epoch le scheduler donne-t-il le meilleur gain ?
Exercice 5 — Transfer learning sur CIFAR-10
Charger un resnet18 pré-entraîné depuis torchvision.models. Geler tous les paramètres, remplacer model.fc par un nn.Linear(512, 10), et entraîner uniquement la nouvelle couche (sur des images redimensionnées à 224×224, normalisées avec les statistiques d'ImageNet). 5 epochs avec Adam, LR , batch size 64. Mesurer l'accuracy de test et la comparer à votre meilleur CNN from scratch du TP. Combien d'epochs from scratch faudrait-il pour atteindre la même performance ?
Exercice 6 — Fine-tuning
Reprendre l'exercice 5, dégeler maintenant tout le réseau, et continuer l'entraînement avec Adam et LR pendant 5 epochs supplémentaires. Quel gain marginal ce fine-tuning apporte-t-il ? Tester ensuite avec un LR différentiel (LR pour layer1 et layer2, pour layer3 et layer4, pour fc). Le gain est-il significatif ?
Exercice 7 — Pièges de mode
Pour chacun des cas suivants, indiquer ce qui se passe et pourquoi :
- On oublie
model.eval()avant l'évaluation, et le batch de test ne contient que des images d'une seule classe. - Pendant le fine-tuning, on a fait
model.train()mais oublié que les couches BN gelées du backbone continuent de mettre à jour leurs statistiques courantes. - On applique
RandomHorizontalFlipà la fois sur train et sur test pour « être cohérent ». - On utilise
transforms.Normalizeavec les statistiques d'ImageNet sur un modèle entraîné from scratch sur CIFAR-10.
Pour aller plus loin
- Documentation
torchvision.models: la page pytorch.org/vision/stable/models.html liste toutes les architectures disponibles, leurs poids pré-entraînés et leurs performances ImageNet (top-1 / top-5). Indispensable pour choisir un backbone. - Documentation
torchvision.transforms: pytorch.org/vision/stable/transforms.html. Inclut depuis PyTorch 2.0 la nouvelle APItransforms.v2, plus rapide et compatible avec d'autres types (boîtes englobantes, masques de segmentation). - Sergey Ioffe, Christian Szegedy, Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift, ICML 2015 — l'article fondateur, accessible.
- Nitish Srivastava et al., Dropout: A Simple Way to Prevent Neural Networks from Overfitting, JMLR 2014 — le papier original, à lire pour la motivation et les expériences.
- Kaiming He et al., Deep Residual Learning for Image Recognition, CVPR 2016 — l'article de ResNet, qui combine BN, connexions résiduelles et entraînement profond. La famille de modèles dont on tire la plupart des ResNet pré-entraînés.
- Andrei Karpathy, A Recipe for Training Neural Networks (karpathy.github.io/2019/04/25/recipe) — un guide pragmatique, très lu, sur les pièges et les bonnes pratiques d'un entraînement de CNN.
- Leslie N. Smith, Cyclical Learning Rates for Training Neural Networks, WACV 2017 — introduction des LR schedulers cycliques (
CyclicLR,OneCycleLR), encore utilisés dans certains pipelines de fastai. - Aurélien Géron, Hands-On Machine Learning with Scikit-Learn, Keras and TensorFlow, chapitre 14 — couvre data augmentation, transfer learning et fine-tuning du point de vue pratique.
- Jeremy Howard, Sylvain Gugger, Deep Learning for Coders with fastai and PyTorch — tout le livre est construit autour du transfer learning comme stratégie par défaut, avec une emphase sur les LR schedulers cycliques et la data augmentation forte.
Au prochain chapitre, nous resterons sur les images mais quitterons le terrain de la classification généraliste pour deux problèmes plus structurés : la détection d'objets (où sont les objets dans l'image, et de quelle classe ?) et la segmentation sémantique (à quelle classe appartient chaque pixel ?). Les blocs convolutifs et les techniques de régularisation que nous venons de poser y resteront centraux ; ce qui changera, c'est la tête du réseau et la fonction de coût.