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.
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=Truesera dérivée parbackward: 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 enFalse. x.detach()renvoie un tenseur qui partage les données dexmais 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 backwardUne 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 — surtoutBatchNormetDropout— en mode évaluation. Sans cela,Dropoutcontinue à éteindre des neurones aléatoirement à la prédiction, et les statistiques deBatchNormcontinuent à 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.
| Aspect | Graphe dynamique (PyTorch) | Graphe statique (TF @tf.function) |
|---|---|---|
| Débogage | naturel, print à volonté | traçage à comprendre |
| Souplesse | contrôle de flux Python direct | branchements symboliques |
| Vitesse | légèrement moindre par défaut | optimisée après traçage |
| Export | via 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é ;
backwardle remonte puis le libère. - Les gradients s'accumulent dans
.gradd'un appel à l'autre :zero_grad()avant chaquebackwardest 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()ettorch.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.