La rétropropagation à la main
Écrire une classe de quelques dizaines de lignes où chaque nombre se souvient de la façon dont il a été calculé ; on peut alors, en partant de la perte, faire remonter les gradients jusqu'à chaque paramètre grâce à la règle de dérivation en chaîne. La vérifier par dérivation numérique, puis s'en servir pour entraîner un petit réseau à apprendre le XOR.
- Environ 60 minutes
- Niveau : Intermédiaire
- Testé : 2026-09-14 Python pur, graine aléatoire fixée
Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.
Les gradients de la leçon précédente ont été établis à la main à partir d'une formule précise. Mais un réseau de neurones est une fonction complexe faite de couches imbriquées, avec des centaines ou des milliers de paramètres ; établir à la main la dérivée de chacun est impossible.
Cette leçon écrit un petit outil qui fait calculer automatiquement à l'ordinateur les gradients de tous les paramètres. Il s'appelle la rétropropagation (backpropagation). C'est la fonction centrale de PyTorch ; cette leçon en écrit en Python pur une version de quelques dizaines de lignes, et ensuite vous saurez ce qui se cache derrière la ligne loss.backward().
La règle de dérivation en chaîne
D'abord un exemple simple. Supposons y = 3x et z = y². Question : si x change un tout petit peu, de combien z change-t-il ?
En deux temps : si x change un peu, y change 3 fois plus (la dérivée de y par rapport à x vaut 3) ; si y change un peu, z change 2y fois plus (la dérivée de z par rapport à y vaut 2y). Donc si x change un peu, z change 3 × 2y fois plus.
z 对 x 的导数 = (z 对 y 的导数) × (y 对 x 的导数)
C'est la règle de dérivation en chaîne : la dérivée d'une suite d'opérations est le produit des dérivées de chaque étape. La dérivée de chaque étape ne dépend que de cette étape elle-même ; on l'appelle dérivée locale.
Un réseau de neurones est une longue suite d'opérations : multiplier, additionner, passer par une fonction non linéaire, couche après couche, jusqu'au calcul de la perte. Si l'on connaît la dérivée locale de chaque étape, on peut, avec la règle en chaîne, partir de la perte et remonter étape par étape en multipliant, pour obtenir la dérivée de la perte par rapport à chaque paramètre.
Chaque nombre tient ses comptes
La méthode : à chaque opération, noter « la dérivée locale du résultat par rapport à chaque entrée ». On écrit une classe Num qui, en plus de la valeur, conserve une liste : [(nombre d'entrée, dérivée locale), ...].
class Num:
"""一个会记账的数:记下自己的值、梯度,以及"我对每个上游数的局部导数"。"""
def __init__(self, value, parents=()):
self.value = value
self.grad = 0.0
self.parents = parents # [(上游的 Num, 局部导数), ...]
def __add__(self, other):
other = other if isinstance(other, Num) else Num(other)
# a + b 对 a 的导数是 1,对 b 的导数也是 1
return Num(self.value + other.value, [(self, 1.0), (other, 1.0)])
def __mul__(self, other):
other = other if isinstance(other, Num) else Num(other)
# a × b 对 a 的导数是 b,对 b 的导数是 a
return Num(self.value * other.value, [(self, other.value), (other, self.value)])
def __pow__(self, n):
# x 的 n 次方,导数是 n × x 的 (n-1) 次方
return Num(self.value ** n, [(self, n * self.value ** (n - 1))])
def tanh(self):
t = math.tanh(self.value)
# tanh 的导数是 1 - tanh²
return Num(t, [(self, 1 - t * t)])
Chaque opération n'a besoin de connaître que sa propre dérivée locale :
- Addition
a + b: siachange un peu, le résultat change d'autant ; les dérivées locales par rapport àaet àbvalent toutes deux 1. - Multiplication
a × b: siachange un peu, le résultat changebfois plus ; la dérivée locale par rapport àavautb, par rapport àbelle vauta. - Puissance
xⁿ: la dérivée locale vautn × xⁿ⁻¹, vu au lycée. tanh: une fonction qui écrase n'importe quel nombre entre -1 et 1, courante dans les réseaux de neurones ; sa dérivée vaut1 - tanh².
Grâce à la surcharge d'opérateurs de Python (__add__, __mul__, etc.), une écriture ordinaire comme a * b + c produit automatiquement des objets Num, et enregistre discrètement tout le calcul.
(Le code complet contient aussi la soustraction et quelques lignes pour faire participer les nombres ordinaires aux calculs ; voir code/08-neural-nets/backprop.py.)
La rétropropagation
Une fois le calcul terminé, chaque Num sait à partir de quels nombres il a été calculé : tout le calcul forme un graphe. La rétropropagation consiste à partir du résultat final (la perte) et à remonter ce graphe :
def backward(self):
"""从这个数(通常是损失)出发,把梯度传给所有上游的数。"""
order, seen = [], set()
def visit(node): # 先访问完所有上游,再把自己放进列表:得到一个"从上游到下游"的顺序
if id(node) not in seen:
seen.add(id(node))
for parent, _ in node.parents:
visit(parent)
order.append(node)
visit(self)
self.grad = 1.0 # 损失对自己的导数是 1
for node in reversed(order): # 从下游往上游,链式法则:上游梯度 += 下游梯度 × 局部导数
for parent, local in node.parents:
parent.grad += node.grad * local
En deux temps :
- Établir l'ordre.
visitgarantit qu'un nombre vient toujours après tous les nombres qui ont servi à le calculer. En parcourant à l'envers, on remonte depuis la perte, et quand on traite un nombre, tous ceux qui en dépendent ont déjà été traités : son gradient est entièrement accumulé. - Transmettre les gradients. La dérivée de la perte par rapport à elle-même vaut 1. À chaque pas en arrière, on applique la règle en chaîne : le gradient en amont s'augmente de « gradient en aval × dérivée locale ».
Pourquoi « s'augmenter » (+=) plutôt qu'une affectation directe ? Parce qu'un nombre peut être utilisé plusieurs fois. Dans y = x * x, par exemple, x est les deux entrées de la multiplication, et les gradients revenant par les deux chemins doivent s'additionner.
Vérification : comparer avec la dérivation numérique
On teste d'abord ce qu'on vient d'écrire. Avec une petite expression f = (a × b + c)², a=2, b=-3, c=10 :
a, b, c = Num(2.0), Num(-3.0), Num(10.0)
f = (a * b + c) ** 2
f.backward()
print(f" f = {f.value}")
print(f" 自动算出的梯度:df/da={a.grad}, df/db={b.grad}, df/dc={c.grad}")
Puis on vérifie avec la méthode numérique de la leçon précédente (cette fois la variante plus précise, « décaler un peu à gauche et à droite ») :
def numeric(fn, x, h=1e-6):
return (fn(x + h) - fn(x - h)) / (2 * h)
== 1. f = (a × b + c)²,a=2, b=-3, c=10
f = 16.0
自动算出的梯度:df/da=-24.0, df/db=16.0, df/dc=8.0
数值求导核对: df/da≈-24.0000, df/db≈16.0000, df/dc≈8.0000
Parfaitement identique. On peut aussi vérifier à la main : a × b + c = 4, f = 4² = 16 ; la dérivée de f par rapport à (a×b+c) vaut 2 × 4 = 8, donc df/dc = 8, df/da = 8 × b = -24, df/db = 8 × a = 16.
En faire un réseau de neurones
Avec la dérivation automatique, on peut construire des réseaux de neurones.
Un neurone fait quelque chose de très simple : multiplier chaque entrée par un poids, additionner, ajouter un biais, et passer le tout dans tanh :
class Neuron:
def __init__(self, n_inputs):
self.w = [Num(random.uniform(-1, 1)) for _ in range(n_inputs)]
self.b = Num(0.0)
def __call__(self, xs):
total = self.b
for w, x in zip(self.w, xs):
total = total + w * x
return total.tanh()
Sans le tanh final, c'est la droite de la leçon 1, avec plusieurs entrées au lieu d'une. Une fonction non linéaire comme tanh (appelée aussi fonction d'activation) est indispensable : sans elle, de nombreuses couches de droites empilées donnent toujours une droite, incapable d'apprendre la moindre régularité complexe.
Une rangée de neurones forme une couche, et deux couches empilées font un petit réseau : 2 entrées → 4 neurones cachés → 1 sortie. Comptons les paramètres : la couche cachée a 4 neurones de 2 poids plus 1 biais chacun, soit 12 ; la couche de sortie 1 neurone de 4 poids plus 1 biais, soit 5. Au total, 17 paramètres.
Apprendre le XOR
Le XOR (ou exclusif) : si les deux entrées sont égales, la sortie est -1 ; si elles diffèrent, 1 (comme la sortie de tanh est entre -1 et 1, on utilise -1 et 1 au lieu des 0 et 1 habituels).
C'est un exemple classique, parce qu'une droite n'y arrive pas : si l'on dessine ces quatre points dans un plan, aucune droite ne sépare (0,1) et (1,0) de (0,0) et (1,1). Il faut une couche cachée et une fonction non linéaire.
La boucle d'entraînement est exactement la descente de gradient de la leçon précédente, sauf que l'étape de calcul du gradient est remplacée par loss.backward() :
data = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
lr = 0.1
for epoch in range(1, 301):
loss = Num(0.0)
for xs, y in data:
pred = output(hidden(xs))[0]
loss = loss + (pred - y) ** 2
for p in params:
p.grad = 0.0 # 每一轮都要清零,否则梯度会一直累加
loss.backward()
for p in params:
p.value -= lr * p.grad
Remarquez qu'il faut remettre les gradients à zéro avant chaque tour. Comme backward utilise +=, sans remise à zéro, les gradients de ce tour s'ajouteraient à ceux du tour précédent. C'est une erreur très classique, y compris avec PyTorch (le optimizer.zero_grad() de la leçon suivante).
== 2. 网络:2 个输入 → 4 个隐藏神经元 → 1 个输出,共 17 个参数
第 1 轮 损失 4.1022
第 10 轮 损失 3.7902
第 50 轮 损失 0.1093
第 100 轮 损失 0.0346
第 200 轮 损失 0.0134
第 300 轮 损失 0.0081
训练后的预测:
输入 [0, 0] → -0.965(目标 -1)
输入 [0, 1] → +0.952(目标 +1)
输入 [1, 0] → +0.953(目标 +1)
输入 [1, 1] → -0.951(目标 -1)
Au départ, la perte vaut 4,1, et les quatre prédictions sont presque toutes fausses. Les 10 premiers tours avancent très lentement, puis la perte chute soudain et atteint 0,1 en 50 tours. Après 300 tours, les quatre prédictions sont toutes très proches de la cible.
Une régularité qu'une droite ne peut pas apprendre, le petit réseau de 17 paramètres l'a apprise. Personne ne lui a jamais dit « ce qu'est le XOR » ; il a seulement calculé encore et encore la perte, les gradients, et ajusté les paramètres dans la direction opposée au gradient.
Ce que nous avons écrit
Avec du recul, les quelques dizaines de lignes de cette leçon contiennent déjà l'essentiel d'un framework d'apprentissage profond :
- La dérivation automatique : chaque opération note sa dérivée locale, et la rétropropagation fait remonter les gradients par la règle en chaîne.
- Les neurones et les couches : somme pondérée, biais, fonction d'activation.
- La boucle d'entraînement : calcul de la perte en avant, remise à zéro des gradients, rétropropagation, mise à jour des paramètres.
C'est évidemment très lent : chaque nombre est un objet Python, et un réseau un peu plus grand en compterait des millions. La leçon suivante passe à PyTorch, qui fait exactement la même chose, mais traite d'un coup tout un lot de nombres (des tenseurs), avec un code bas niveau optimisé, des milliers de fois plus rapide.
Exercices
- Ajoutez à
Numune méthoderelu: si l'entrée est positive, elle est renvoyée telle quelle, sinon 0. Quelle est sa dérivée locale ? Une fois écrite, vérifiez-la par dérivation numérique. - Supprimez les deux lignes de remise à zéro des gradients dans la boucle d'entraînement, relancez, et voyez ce qui se passe.
- Passez la couche cachée de 4 neurones à 1 puis 2. Le réseau apprend-il encore le XOR ? Essayez aussi quelques autres graines aléatoires.
Auto-test
1. Que dit la règle de dérivation en chaîne ? Quel est son lien avec la rétropropagation ?
La règle en chaîne : la dérivée d'une suite d'opérations est le produit des dérivées locales de chaque étape. La rétropropagation applique systématiquement cette règle : partir de la perte, remonter le calcul, multiplier à chaque étape par sa dérivée locale, pour obtenir au final la dérivée de la perte par rapport à chaque paramètre.
2. Pourquoi, en rétropropagation, accumule-t-on les gradients avec += plutôt qu'avec une affectation directe ?
Un nombre peut être utilisé plusieurs fois dans le calcul ; dans y = x × x, par exemple, x apparaît deux fois. Chaque utilisation fait revenir un gradient par un chemin, et ces gradients doivent s'additionner pour donner la dérivée complète.
3. Pourquoi une droite ne peut-elle pas apprendre le XOR, alors qu'un réseau avec une couche cachée et tanh le peut ?
Les quatre points du XOR ne peuvent pas être séparés par une droite. Des couches de droites empilées donnent toujours une droite ; la clé est donc la fonction d'activation non linéaire tanh, qui permet au réseau de composer des frontières courbes capables de séparer ces quatre points.
Questions et discussion
Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.
Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.
Chargement de la discussion…