Aller au contenu principal

Module 9 — Apprentissage par transfert et affinage progressif

Le jeu de déchets à trier compte environ 2 500 images en 6 classes. Entraîner un ResNet50 depuis zéro sur ce volume donne au mieux 55 % d'exactitude — le réseau surajuste avant d'avoir appris à voir. Prendre le même ResNet50 préentraîné sur ImageNet et l'affiner sur ce jeu monte à 92 %, en trente minutes de calcul sur un GPU d'entrée de gamme. Ce module explique pourquoi, montre le geste en deux phases, et détaille les deux erreurs qui rendent cette technique inefficace la moitié du temps : la normalisation par lots gelée à moitié, et le taux d'apprentissage appliqué à l'aveugle.

Pourquoi les poids d'ImageNet marchent sur des déchets

On a vu au module 8 du cours 08 (référence FR) que les premières couches d'un CNN apprennent des primitives universelles — contours, gradients, textures. Sur ImageNet, les 25 dernières couches d'un ResNet50 se spécialisent aux 1 000 classes d'origine (chiens, voitures, oiseaux). Les 25 premières restent utiles à peu près partout.

Le jeu de déchets a des textures très marquées — plastique brillant, papier mat, carton ondulé — que les premières couches d'ImageNet détectent déjà. Le transfert consiste à garder ces couches, remplacer la tête de classification, et rajuster progressivement en profondeur.

Phase 1 : extraction de caractéristiques

On charge ResNet50 sans sa tête d'origine, on gèle toute la base, et on entraîne seulement une petite tête de classification adaptée aux 6 classes.

from tensorflow import keras
from tensorflow.keras import layers

base = keras.applications.ResNet50(
weights="imagenet",
include_top=False,
input_shape=(224, 224, 3),
)
base.trainable = False

entrees = keras.Input(shape=(224, 224, 3))
x = keras.applications.resnet50.preprocess_input(entrees)
x = base(x, training=False) # crucial, voir ci-dessous
x = layers.GlobalAveragePooling2D()(x)
x = layers.Dropout(0.3)(x)
sortie = layers.Dense(6, activation="softmax")(x)

modele = keras.Model(entrees, sortie)
modele.compile(
optimizer=keras.optimizers.Adam(1e-3),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
modele.fit(train_ds, validation_data=val_ds, epochs=10)

Trois points à ne pas rater :

  • preprocess_input de la famille du modèle. ResNet50 attend des pixels centrés sur les moyennes d'ImageNet dans l'ordre BGR. Si on lui donne du RGB dans [0,1][0, 1], la première convolution reçoit des valeurs de la mauvaise plage et les activations tombent hors de la zone où les poids ont été appris.
  • base(x, training=False). C'est le piège central du transfert. On y revient dans une seconde.
  • GlobalAveragePooling2D avant la tête, pas d'aplatissement. Cela réduit une carte 7x7x2048 en un vecteur de 2048, ce qui rend la tête légère et applicable à toute taille d'image plus grande que 32.

Après 10 époques, on atteint autour de 87 % de précision de validation avec la base gelée. C'est déjà bien, mais on peut faire mieux en dégelant.

Le piège de BatchNormalization

Voici l'erreur qui coûte 5 à 8 points aux transferts naïfs. Elle est presque invisible dans le code, jamais signalée par une exception.

Une couche BatchNormalization contient deux sortes de valeurs : les poids appris (échelle et décalage) et les statistiques courantes — moyenne et variance accumulées pendant l'entraînement d'origine. base.trainable = False gèle les poids, mais ne suffit pas à figer les statistiques. Si la couche est appelée en mode entraînement, elle continue de les mettre à jour à partir de vos lots.

Sur un jeu petit et distribué différemment d'ImageNet, ces statistiques dérivent en quelques centaines d'itérations, s'écartent de celles pour lesquelles les poids gelés ont été optimisés, et détruisent la représentation qu'on cherchait à préserver. Le symptôme : la précision de validation stagne à un niveau bas alors que tout paraît correct.

La parade tient dans le training=False de l'appel base(x, training=False) ci-dessus. Il force le mode inférence pour toute la base, statistiques comprises.

Deux mécanismes distincts, souvent confondus

base.trainable = False décide quels poids reçoivent un gradient. L'argument training=False décide comment les couches se comportent à chaque appel. Les deux sont nécessaires et ne se remplacent pas. Sur une base qui contient de la normalisation par lots — c'est le cas de presque toutes les architectures modernes, y compris ResNet50 —, omettre training=False annule l'essentiel du bénéfice de trainable = False.

Phase 2 : affinage progressif

Après la phase 1, la tête est stable. On peut alors dégeler une portion de la base et poursuivre l'entraînement, à un taux beaucoup plus faible pour ne pas effacer les poids ImageNet.

base.trainable = True
for couche in base.layers[:-20]: # ne degele que les 20 dernieres
couche.trainable = False

# Recompilation obligatoire apres modification de trainable
modele.compile(
optimizer=keras.optimizers.Adam(1e-5), # cent fois plus faible
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
modele.fit(train_ds, validation_data=val_ds, epochs=10)

Deux points essentiels :

  • Recompiler après avoir changé trainable. Sans cela, la liste des variables entraînables reste celle de la compilation précédente, et le dégel n'a aucun effet — le modèle passe 10 époques à tourner sans que rien de la base ne bouge.
  • Taux d'apprentissage cent fois plus faible. À taux normal, les gradients qui remontent de la tête neuve écraseraient en quelques dizaines d'itérations les poids affinés sur des millions d'images. On revient sur ce point.

Le progressif consiste à dégeler par étages : d'abord les 20 dernières couches, puis les 50, puis les 100, en réduisant encore le taux à chaque étage. La convergence est plus lente mais plus stable, et l'écart avec un dégel brutal peut atteindre 3 points.

Taux d'apprentissage différenciés

Un raffinement supplémentaire — indispensable sur les jeux petits — consiste à appliquer un taux différent par groupe de couches : très faible pour les premières (qu'on veut à peine perturber), plus fort pour les dernières (qu'on veut réellement adapter), et taux plein pour la tête neuve.

Keras ne l'expose pas directement, mais on peut construire un optimiseur qui multiplie les gradients par un facteur par couche, ou utiliser plusieurs optimiseurs sur des sous-groupes de variables. En PyTorch, c'est plus naturel : on passe une liste [{'params': base.parameters(), 'lr': 1e-5}, {'params': tete.parameters(), 'lr': 1e-3}] à l'optimiseur.

Sur le jeu de déchets, un taux différencié en trois paliers (1e-5, 1e-4, 1e-3) donne un gain d'environ 1,5 point par rapport au taux unique.

Quand le transfert échoue

Le transfert n'est pas un remède universel. Trois situations où il donne peu ou pas :

  • Domaine très éloigné d'ImageNet. Images satellite hyperspectrales à 12 canaux, tomographies médicales, spectrogrammes audio : les premières couches d'ImageNet ne captent pas les bonnes primitives. On peut malgré tout gagner un peu en gardant seulement les toutes premières couches, mais on peut aussi entraîner depuis zéro avec un budget suffisant.
  • Résolution très différente. ImageNet est en 224x224. Sur des images 32x32 (CIFAR) ou 4096x4096 (astronomie), le champ récepteur de ResNet50 est mal calibré. On adapte l'architecture (module 6) ou on redimensionne, ce qui perd de l'information.
  • Distribution de classes fortement déséquilibrée. Le transfert donne le point de départ, il ne corrige pas le déséquilibre. Une pondération par classe dans la perte, ou un rééchantillonnage, reste nécessaire.
Toujours faire la phase 1 avant la phase 2

Passer directement au dégel avec un taux même faible sans la phase 1 donne systématiquement de moins bons résultats. La tête neuve, initialisée au hasard, produit au départ des gradients énormes qui déstabilisent la base. La phase 1 amène la tête à un état raisonnable avant qu'on ne l'autorise à toucher au reste. L'ordre n'est pas négociable.

Sur le fil rouge

L'affinage complet sur le jeu de déchets à trier — deux phases, dégel des 20 dernières couches en phase 2, taux 1e-3 puis 1e-5, augmentation modérée du module 8 — atteint 92 % de précision de validation. Sans le training=False, ce chiffre tombe à 84 %. Sans la phase 1, il tombe à 78 %. C'est la combinaison des trois qui produit le résultat.

En résumé

  • Le transfert fonctionne parce que les premières couches sont universelles ; sur un jeu de quelques milliers d'images, un ResNet50 préentraîné bat un modèle entraîné depuis zéro d'au moins 30 points.
  • Le geste standard est en deux phases : extraction de caractéristiques base gelée, puis dégel progressif avec taux cent fois plus faible.
  • Le piège central est training=False à l'appel de la base : sans lui, la normalisation par lots dérive et efface la représentation.
  • Le transfert échoue quand le domaine est trop éloigné d'ImageNet, quand la résolution est très différente, ou quand la distribution des classes est fortement déséquilibrée sans correction.

Module suivant : Grad-CAM et cartes de saillance, pour comprendre où le modèle regarde vraiment et détecter s'il a appris le mauvais indice.