Aller au contenu principal

Module 6 — Optimiseurs et planificateurs de taux d'apprentissage

La boucle du module 5 utilise Adam(lr=1e-3) sans réfléchir. Ce choix suffit à obtenir un premier résultat, il ne permet pas d'aller plus loin. Ce module fait deux choses : comparer les optimiseurs disponibles dans torch.optim, et montrer comment faire varier le taux d'apprentissage au fil des époques pour gagner plusieurs points d'exactitude sans changer le modèle.

Qu'est-ce qu'un optimiseur, précisément

Un optimiseur PyTorch est un objet qui détient les paramètres à mettre à jour et applique une règle à partir des gradients calculés par backward(). Trois éléments définissent la règle : le taux d'apprentissage lr, l'inertie accumulée sur les gradients passés, et la régularisation appliquée aux poids.

import torch
from torch import optim

optimiseur = optim.SGD(modele.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4)

optim.SGD(momentum=0) est la descente pure : θt+1=θtηL\theta_{t+1} = \theta_t - \eta \nabla L Avec momentum, une moyenne mobile du gradient est maintenue, ce qui amortit les oscillations dans les vallées étroites.

SGD avec momentum : simple, robuste, encore compétitif

Contrairement à ce que suggère la mode, SGD avec momentum reste l'optimiseur de référence pour la vision par ordinateur classique. Correctement réglé, il donne souvent la meilleure généralisation.

optimiseur = optim.SGD(
modele.parameters(),
lr=0.1,
momentum=0.9,
weight_decay=5e-4,
nesterov=True,
)

Le taux d'apprentissage de 0.1 semble énorme comparé à Adam ; il l'est, et c'est précisément ce qui permet à SGD d'échapper à des minima étroits. La difficulté est d'accepter la sensibilité au réglage : un lr mal choisi et l'entraînement diverge.

Adam et AdamW : l'adaptatif qui pardonne beaucoup

Adam maintient une moyenne mobile du gradient et de son carré, et divise la mise à jour par la racine du second. Chaque paramètre a donc son propre taux effectif, ce qui rend le réglage bien plus tolérant.

optimiseur = optim.Adam(modele.parameters(), lr=1e-3)

Le piège d'Adam concerne la régularisation. Dans optim.Adam, le weight_decay est en réalité ajouté au gradient avant la normalisation, ce qui couple la régularisation au taux d'apprentissage adaptatif — et l'affaiblit sur les paramètres à gradient variable. AdamW corrige ce défaut en appliquant la diminution des poids séparément, comme une vraie régularisation.

optimiseur = optim.AdamW(modele.parameters(), lr=1e-3, weight_decay=1e-2)

Règle pratique : commencer avec AdamW sur un modèle inconnu, et n'utiliser SGD que quand on sait ce qu'on fait.

Un tableau pour choisir
SituationOptimiseurlr de départ
Prototype rapide, modèle nouveauAdamW1e-3
Vision, ResNet ou similaire, budget d'explorationSGD momentum 0.90.1
Transformer, NLP, embeddingsAdamW3e-4
Peu de données, régularisation critiqueAdamW avec weight_decay=1e-25e-4

Le taux d'apprentissage : ce que fait vraiment un planificateur

Un lr fixe est un compromis unique entre convergence rapide au début et convergence fine à la fin. Un planificateur — appelé « scheduler » — change lr au fil des époques ou des pas pour obtenir les deux.

Le principe est toujours le même : après l'étape de l'optimiseur, on appelle planificateur.step(). Certains planificateurs se pilotent par époque, d'autres par pas ; c'est écrit dans leur documentation.

Les quatre planificateurs à connaître

StepLR divise lr par un facteur toutes les step_size époques. Le plus simple, adapté à un entraînement dont on connaît approximativement la courbe.

planificateur = optim.lr_scheduler.StepLR(optimiseur, step_size=10, gamma=0.1)

CosineAnnealingLR fait décroître lr selon une demi-cosinusoïde, de sa valeur initiale à eta_min sur T_max époques. Zéro hyperparamètre à part la durée. C'est un excellent défaut moderne.

planificateur = optim.lr_scheduler.CosineAnnealingLR(optimiseur, T_max=30)

OneCycleLR implémente la méthode « super-convergence » de Leslie Smith : lr augmente d'abord jusqu'à un pic, puis décroît en descente cosinusoïdale. Piloté au pas (pas à l'époque), il permet souvent d'atteindre la meilleure exactitude en beaucoup moins d'époques.

planificateur = optim.lr_scheduler.OneCycleLR(
optimiseur,
max_lr=0.1,
total_steps=nb_epoques * len(train_loader),
)

ReduceLROnPlateau réagit à une métrique qui stagne. On l'appelle avec la valeur observée (planificateur.step(perte_val)) et il baisse lr quand la métrique n'a pas progressé depuis patience époques.

planificateur = optim.lr_scheduler.ReduceLROnPlateau(
optimiseur, mode="min", factor=0.5, patience=3
)

Intégrer le planificateur à la boucle

modele = ReseauFashionMNIST()
optimiseur = optim.AdamW(modele.parameters(), lr=1e-3, weight_decay=1e-4)
planificateur = optim.lr_scheduler.CosineAnnealingLR(optimiseur, T_max=15)

for epoque in range(15):
entrainer_une_epoque(modele, train_loader, criterion, optimiseur)
perte_val, exactitude = evaluer(modele, val_loader, criterion)
planificateur.step() # après l'époque
lr_actuel = optimiseur.param_groups[0]["lr"]
print(f"époque {epoque:2d} | val {perte_val:.4f} | exact {exactitude:.3f} | lr {lr_actuel:.2e}")

Pour OneCycleLR, le step() se met à l'intérieur de la boucle sur les lots, immédiatement après optimiseur.step(). Se tromper de niveau transforme un cycle en une constante ; les motifs de perte deviennent alors plats et incompréhensibles.

planificateur.step() après optimiseur.step()

Depuis PyTorch 1.1, l'ordre attendu est : optimiseur.step() puis planificateur.step(). L'inverser provoque un avertissement et saute le premier taux prévu. OneCycleLR est pilotée au pas, StepLR, CosineAnnealingLR et ReduceLROnPlateau à l'époque : lire la documentation avant.

Trouver un taux d'apprentissage sans deviner

La méthode du balayage de lr proposée par Smith consiste à augmenter lr de façon exponentielle sur quelques centaines de pas et à tracer la perte. Le meilleur lr est le point où la courbe descend le plus fortement, pas le minimum de la courbe.

import math

def balayer_lr(modele, loader, criterion, lr_min=1e-6, lr_max=1.0, nb_pas=100):
optimiseur = optim.SGD(modele.parameters(), lr=lr_min)
facteur = (lr_max / lr_min) ** (1 / nb_pas)
pertes, taux = [], []
for i, (x, y) in enumerate(loader):
if i >= nb_pas:
break
lr = lr_min * (facteur ** i)
for g in optimiseur.param_groups:
g["lr"] = lr
optimiseur.zero_grad()
perte = criterion(modele(x), y)
perte.backward()
optimiseur.step()
pertes.append(perte.item())
taux.append(lr)
return taux, pertes

On lance cette fonction sur un modèle fraîchement initialisé, on regarde la courbe, et on choisit le lr immédiatement avant que la perte ne remonte violemment. Cet exercice remplace trois heures d'essais au hasard.

Groupes de paramètres : traiter différemment plusieurs blocs

Un optimiseur peut recevoir plusieurs groupes de paramètres avec des lr distincts, indispensable pour l'affinage progressif du module 9.

optimiseur = optim.AdamW([
{"params": modele.reseau[:4].parameters(), "lr": 1e-5}, # blocs figés/affinés
{"params": modele.reseau[4:].parameters(), "lr": 1e-3}, # tête
], weight_decay=1e-4)

Le premier groupe apprend cent fois plus lentement que le second. On retrouve exactement ce mécanisme au module 9 pour affiner le préentraîné en préservant les premières couches.

En résumé

  • AdamW est un défaut moderne solide ; SGD avec momentum reste gagnant en vision quand on accepte de régler lr finement.
  • weight_decay de Adam est trompeur : sur un modèle sérieux, préférer AdamW qui applique la vraie régularisation par diminution des poids.
  • Un planificateur fait plus qu'un réglage de lr : CosineAnnealingLR et OneCycleLR gagnent souvent plusieurs points sans autre changement, à condition d'appeler step() au bon niveau.
  • Le balayage de lr trouve un ordre de grandeur solide en quelques minutes ; le point choisi est là où la perte descend le plus, pas là où elle est la plus basse.

Le module suivant déplace l'entraînement sur GPU et introduit la précision mixte, deux leviers qui accélèrent l'entraînement d'un facteur deux à cinq sans dégrader la qualité.