Module 5 — La boucle d'entraînement : forward, backward, step
C'est le module pivot du cours. Tout ce qui a été introduit — tenseurs,
autograd, nn.Module, DataLoader — s'assemble ici en une première
version complète du classificateur Fashion-MNIST. Contrairement à Keras
où model.fit cache la boucle, PyTorch la laisse à votre charge. C'est
plus de code, et surtout plus de contrôle.
L'anatomie d'un pas d'entraînement
Un pas d'entraînement PyTorch comporte cinq lignes dans un ordre strict.
for images, cibles in train_loader:
optimiseur.zero_grad() # 1. remettre les gradients à zéro
logits = modele(images) # 2. passe avant
perte = criterion(logits, cibles) # 3. calcul de la perte
perte.backward() # 4. rétropropagation
optimiseur.step() # 5. mise à jour des paramètres
Cette séquence est la définition opérationnelle de la descente de gradient stochastique. Chaque ligne a un rôle précis :
- Le
zero_gradefface le gradient accumulé du lot précédent (module 2). L'oublier fait diverger l'entraînement. - La passe avant construit un nouveau graphe de calcul entre les entrées et la sortie du modèle.
- La perte, scalaire, sert de racine à la rétropropagation.
backwardremonte le graphe et remplit.gradsur chaque paramètre.stepmet à jour les paramètres selon la règle de l'optimiseur choisi.
Un classificateur Fashion-MNIST complet
Assemblons tout, du chargement à la métrique.
import torch
from torch import nn, optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# 1. Données
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
train_ds = datasets.FashionMNIST("./donnees", train=True, download=True, transform=transform)
val_ds = datasets.FashionMNIST("./donnees", train=False, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=2)
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=2)
# 2. Modèle
class ReseauFashionMNIST(nn.Module):
def __init__(self):
super().__init__()
self.reseau = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 256),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
def forward(self, x):
return self.reseau(x)
modele = ReseauFashionMNIST()
# 3. Perte et optimiseur
criterion = nn.CrossEntropyLoss()
optimiseur = optim.Adam(modele.parameters(), lr=1e-3)
CrossEntropyLoss mérite d'être décryptée : elle combine
log_softmax et la perte de log-vraisemblance. La conséquence pratique
est qu'on lui passe les logits bruts — pas une distribution de
probabilités. Ajouter un nn.Softmax en dernière couche du modèle
double l'application de la fonction et casse silencieusement
l'entraînement.
La fonction d'entraînement pour une époque
def entrainer_une_epoque(modele, loader, criterion, optimiseur):
modele.train() # active Dropout, BatchNorm en mode entraînement
perte_cumulee, exemples_vus = 0.0, 0
for images, cibles in loader:
optimiseur.zero_grad()
logits = modele(images)
perte = criterion(logits, cibles)
perte.backward()
optimiseur.step()
perte_cumulee += perte.item() * images.size(0)
exemples_vus += images.size(0)
return perte_cumulee / exemples_vus
Trois précisions. perte.item() extrait la valeur Python — sans quoi
on garderait un tenseur qui retient le graphe et gonfle la mémoire au
fil des itérations. La perte est pondérée par la taille du lot :
sur un dernier lot plus petit, la moyenne simple des pertes de lot est
biaisée. Et modele.train() est appelé au début : c'est le point
d'entrée qui active Dropout et met BatchNorm en mode entraînement.
La fonction d'évaluation
@torch.no_grad()
def evaluer(modele, loader, criterion):
modele.eval()
perte_cumulee, correctes, exemples_vus = 0.0, 0, 0
for images, cibles in loader:
logits = modele(images)
perte = criterion(logits, cibles)
perte_cumulee += perte.item() * images.size(0)
correctes += (logits.argmax(dim=1) == cibles).sum().item()
exemples_vus += images.size(0)
return perte_cumulee / exemples_vus, correctes / exemples_vus
Le décorateur @torch.no_grad() désactive la construction du graphe
pour toute la fonction, ce qui divise la mémoire par deux ou plus.
modele.eval() bascule les couches en mode évaluation. Oublier l'un ou
l'autre est le bogue le plus fréquent de PyTorch, et il est
silencieux.
model.eval() : le bogue silencieuxSans modele.eval(), Dropout reste actif à la validation : des
neurones sont éteints aléatoirement, la précision affichée dégringole,
mais aucune exception n'est levée. Symétriquement, BatchNorm continue
à mettre à jour ses moyennes courantes sur le jeu de validation, ce
qui les contamine. Toujours appeler model.eval() avant une
évaluation et model.train() avant l'entraînement.
Assembler la boucle sur plusieurs époques
def entrainer(modele, train_loader, val_loader, nb_epoques=5):
criterion = nn.CrossEntropyLoss()
optimiseur = optim.Adam(modele.parameters(), lr=1e-3)
historique = []
for epoque in range(1, nb_epoques + 1):
perte_train = entrainer_une_epoque(modele, train_loader, criterion, optimiseur)
perte_val, exactitude = evaluer(modele, val_loader, criterion)
historique.append((epoque, perte_train, perte_val, exactitude))
print(f"époque {epoque:2d} | train {perte_train:.4f} | val {perte_val:.4f} | exact. {exactitude:.3f}")
return historique
if __name__ == "__main__":
modele = ReseauFashionMNIST()
entrainer(modele, train_loader, val_loader, nb_epoques=5)
Sur portable, cinq époques prennent quelques minutes et atteignent autour de 88 % d'exactitude sur Fashion-MNIST. C'est loin de l'état de l'art, mais c'est un point de départ solide : chaque module suivant va faire progresser cette valeur ou raccourcir le temps pour l'obtenir.
Détecter le surapprentissage à l'œil nu
Trois motifs typiques dans l'historique.
- Perte d'entraînement qui baisse, perte de validation qui remonte :
le modèle a mémorisé le jeu d'entraînement. Réponse : augmenter
Dropout, réduire la capacité, arrêter plus tôt, régulariser (module 6). - Les deux pertes stagnent haut : le modèle est trop petit ou le
taux d'apprentissage inadapté. Réponse : augmenter la capacité,
essayer un autre
lr, vérifier la préparation des données. - Perte qui explose ou devient NaN après quelques pas :
lrtrop élevé, données non normalisées, ouzero_gradoublié.
Une règle d'or : ne jamais toucher au modèle sans avoir affiché les deux pertes et l'exactitude au moins par époque. Sans ce retour, on avance à l'aveugle.
Reproductibilité en une graine
import random
import numpy as np
def fixer_graine(graine=42):
random.seed(graine)
np.random.seed(graine)
torch.manual_seed(graine)
torch.cuda.manual_seed_all(graine)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
cudnn.deterministic=True sacrifie un peu de vitesse pour une
reproductibilité stricte, indispensable lors du débogage. En production,
on repasse à benchmark=True pour laisser cuDNN choisir les
algorithmes les plus rapides.
En résumé
- Cinq lignes dans un ordre strict :
zero_grad, passe avant, perte,backward,step. Inverser deux d'entre elles casse l'entraînement silencieusement. CrossEntropyLossprend des logits bruts : ajouter unSoftmaxen sortie double la fonction et fausse la perte sans erreur.model.train()etmodel.eval()sont obligatoires avant chaque boucle ; oubliereval()en validation dégrade la métrique sans message d'erreur.- La perte se pondère par la taille du lot et se lit systématiquement à côté de la perte de validation pour détecter surapprentissage et divergence.
Le module suivant remplace Adam(lr=1e-3) par un choix éclairé
d'optimiseur et un planificateur de taux d'apprentissage qui
change la donne pour atteindre les 91 % d'exactitude.