Aller au contenu principal

Module 2 — Autograd : graphe dynamique et calcul du gradient

Un réseau de neurones apprend en descendant le long du gradient de sa fonction de perte. Écrire ce gradient à la main pour un modèle réaliste est irréaliste. Autograd est le mécanisme de PyTorch qui l'obtient automatiquement à partir du code Python que vous avez écrit, sans que vous ayez à formuler la dérivée. Comprendre exactement ce qu'il fait évite les trois quarts des bogues silencieux de l'entraînement.

Un graphe qui se construit à la volée

À chaque opération sur un tenseur dont requires_grad=True, PyTorch enregistre l'opération dans un graphe orienté acyclique. Ce graphe n'est pas défini d'avance ; il est reconstruit à chaque passe avant, à partir du code Python effectivement exécuté. C'est la différence majeure avec un cadre à graphe statique où l'on définit la topologie séparément avant l'exécution.

import torch

x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x ** 2 # nouveau nœud dans le graphe
perte = y.sum() # racine du graphe

perte.backward() # remonte le graphe, remplit x.grad
print(x.grad) # tensor([4., 6.]) = d(perte)/dx = 2*x

L'appel à backward() déclenche la rétropropagation : PyTorch remonte le graphe depuis la racine (la perte), applique la règle de la chaîne à chaque nœud, et dépose le gradient dans l'attribut .grad des feuilles qui l'ont demandé. Une fois backward terminé, le graphe est libéré par défaut ; refaire un backward sur la même perte lève une exception.

Ce que change le graphe dynamique

Une boucle for Python ou un if sur la valeur d'un tenseur produit un graphe différent à chaque itération, sans effort particulier. C'est ce qui rend PyTorch particulièrement adapté aux réseaux à taille variable (séquences, arbres) ou aux architectures qu'on prototype.

Trois règles pour requires_grad

L'attribut requires_grad propage la surveillance dans le graphe.

  • Une feuille avec requires_grad=True sera dérivée par backward : on la met sur les paramètres à entraîner (poids, biais).
  • Un tenseur produit par une opération sur au moins un tenseur suivi hérite de requires_grad=True. Les données d'entrée d'un modèle, elles, restent en False.
  • x.detach() renvoie un tenseur qui partage les données de x mais qui est retiré du graphe. Utile pour manipuler une valeur sans qu'elle ne remonte lors de la rétropropagation.
poids = torch.randn(784, 10, requires_grad=True)   # à entraîner
image = torch.randn(1, 784) # donnée d'entrée
logits = image @ poids # requires_grad=True hérité
prediction = logits.argmax(dim=1).detach() # entier, hors graphe

Le piège numéro un : les gradients s'accumulent

Contrairement à d'autres cadres, PyTorch n'écrase pas .grad d'un appel à backward à l'autre : il l'accumule. C'est un choix délibéré, utile pour construire un gradient en plusieurs morceaux, mais c'est la cause la plus fréquente d'un modèle qui n'apprend pas comme prévu.

w = torch.tensor(1.0, requires_grad=True)

for _ in range(3):
perte = w ** 2
perte.backward()
print(w.grad) # 2.0, puis 4.0, puis 6.0 — accumulation !

La correction est mécanique : appeler w.grad.zero_() avant chaque backward, ou plus généralement optimiseur.zero_grad() sur l'optimiseur qui rassemble tous les paramètres, comme on le fera au module 5. Oublier ce zero_grad produit un entraînement qui semble progresser au début puis diverge sans raison apparente.

zero_grad() avant chaque backward

Une boucle d'entraînement PyTorch a trois lignes obligatoires dans cet ordre : optimiseur.zero_grad(), perte.backward(), optimiseur.step(). Inverser les deux premières laisse un gradient résiduel du lot précédent qui contamine la mise à jour courante.

Désactiver le suivi : torch.no_grad et inference_mode

L'évaluation d'un modèle et l'inférence en production n'ont pas besoin de gradients. Les calculer coûte du temps et de la mémoire pour rien. Deux gestionnaires de contexte les désactivent :

modele.eval()

with torch.no_grad():
for images, cibles in loader_validation:
logits = modele(images)
predictions = logits.argmax(dim=1)

torch.no_grad() empêche la construction du graphe : les opérations à l'intérieur ne sont pas enregistrées, .grad_fn reste None sur leurs résultats. torch.inference_mode(), ajouté plus récemment, va plus loin en désactivant aussi le suivi de version des tenseurs, ce qui accélère encore l'inférence pure.

torch.no_grad est un contexte, pas un état permanent : à la sortie du bloc, l'autograd reprend son comportement normal. C'est pourquoi on l'ouvre spécifiquement autour d'une boucle de validation.

model.eval() n'est pas torch.no_grad

Les débutants confondent souvent les deux commutateurs. Ils sont complémentaires et indépendants, et l'oubli de l'un ou de l'autre donne des symptômes différents.

  • model.eval() bascule certaines couches — surtout BatchNorm et Dropout — en mode évaluation. Sans cela, Dropout continue à éteindre des neurones aléatoirement à la prédiction, et les statistiques de BatchNorm continuent à se mettre à jour.
  • torch.no_grad() empêche le suivi pour économiser mémoire et temps, sans changer le comportement des couches.

En validation, on applique les deux ensemble. On y reviendra dans la boucle du module 5, où oublier eval() reste l'erreur numéro un.

Comparaison rapide avec le graphe statique

Le cours 08 sur TensorFlow a montré tf.function, qui trace un graphe une fois puis ne réexécute plus le code Python. PyTorch est historiquement à l'inverse : chaque itération réexécute le Python et reconstruit le graphe. Cette différence a des conséquences concrètes.

AspectGraphe dynamique (PyTorch)Graphe statique (TF @tf.function)
Débogagenaturel, print à volontétraçage à comprendre
Souplessecontrôle de flux Python directbranchements symboliques
Vitesselégèrement moindre par défautoptimisée après traçage
Exportvia TorchScript ou ONNX (module 10)natif

L'écart de vitesse s'est réduit ; torch.compile, introduit au module 7, apporte à PyTorch une compilation à la volée sans changer la façon d'écrire le code.

Retour à Fashion-MNIST : un pas de gradient à la main

Pour ancrer les mécanismes, effectuons un pas de descente sur un modèle linéaire minimal appliqué à une image aplatie.

import torch

x = torch.randn(1, 784) # une image, aplatie
y = torch.tensor([3]) # étiquette de classe

W = torch.zeros(784, 10, requires_grad=True)
b = torch.zeros(10, requires_grad=True)

logits = x @ W + b
perte = torch.nn.functional.cross_entropy(logits, y)

perte.backward() # remplit W.grad et b.grad
with torch.no_grad(): # mise à jour hors graphe
W -= 0.1 * W.grad
b -= 0.1 * b.grad
W.grad.zero_()
b.grad.zero_()

Les modules suivants remplaceront chacune de ces étapes par un objet mieux nommé — nn.Module pour W et b, optim.SGD pour la mise à jour, DataLoader pour la boucle sur les images. Le mécanisme sous le capot restera exactement celui montré ici.

En résumé

  • Autograd construit un graphe dynamique à chaque passe avant, à partir du code Python effectivement exécuté ; backward le remonte puis le libère.
  • Les gradients s'accumulent dans .grad d'un appel à l'autre : zero_grad() avant chaque backward est obligatoire.
  • torch.no_grad() désactive le suivi pour la validation et l'inférence ; c'est un contexte, pas un état global.
  • model.eval() et torch.no_grad() sont complémentaires : le premier change le comportement des couches (BatchNorm, Dropout), le second économise mémoire et temps.

Le module suivant montre comment nn.Module remplace la manipulation directe de W, b, et de leurs gradients par une structure de couches réutilisables.