Aller au contenu principal

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 :

  1. Le zero_grad efface le gradient accumulé du lot précédent (module 2). L'oublier fait diverger l'entraînement.
  2. La passe avant construit un nouveau graphe de calcul entre les entrées et la sortie du modèle.
  3. La perte, scalaire, sert de racine à la rétropropagation.
  4. backward remonte le graphe et remplit .grad sur chaque paramètre.
  5. step met à 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 silencieux

Sans 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 : lr trop élevé, données non normalisées, ou zero_grad oublié.

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.
  • CrossEntropyLoss prend des logits bruts : ajouter un Softmax en sortie double la fonction et fausse la perte sans erreur.
  • model.train() et model.eval() sont obligatoires avant chaque boucle ; oublier eval() 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.