Aller au contenu
5/30Chapitre 5 sur 30

Backpropagation de zéro : le moteur, puis le réseau

Écrivez un moteur autodiff Python pur en 120 lignes, comparez-le à PyTorch à 16 décimales et découvrez zero_grad en le supprimant.

Dans cet article

Quatre chapitres plus loin, il y a un trou au milieu du cours.

Le chapitre 3 nous a donné gradient descent : pour améliorer un paramètre, trouver la pente de la perte par rapport à lui et descendre la pente. Le chapitre 4 nous a donné une perte qui mérite d’être descendue. Mais dans les deux cas, la dérivée était calculée à la main — un modèle, un paramètre, une ligne de calcul différentiel, et tout tenait sur une page.

Empilez maintenant deux couches. La sortie de la première alimente la seconde, donc chaque poids de la première affecte la perte à travers chaque neurone de la seconde. Un réseau avec deux couches cachées de cent unités chacune compte environ vingt mille paramètres, et chacun a besoin de sa propre dérivée partielle de la même perte. Le faire à la main n’est pas fastidieux ; c’est impossible, et cela reste impossible pour chaque architecture du reste de ce cours.

La sortie n’est pas une meilleure notation. C’est la prise de conscience que la dérivée d’une composition peut être calculée mécaniquement, par un programme, à partir de la structure du calcul lui-même — et que si vous le faites dans le bon sens, vous obtenez les vingt mille dérivées pour un coût proche de celui du calcul de la perte une seule fois.

Ce mécanisme est la différentiation automatique en mode inverse. Appliqué à un réseau de neurones, il s’appelle backpropagation, et à la fin de ce chapitre vous en aurez écrit un en environ 120 lignes de Python sans bibliothèques, vous l’aurez comparé à PyTorch et vous l’aurez utilisé pour résoudre le problème XOR qui avait tué le perceptron au chapitre 1.

D’abord : pourquoi il faut absolument une non-linéarité

Lien vers la section : D’abord : pourquoi il faut absolument une non-linéarité

Avant de construire la machine, il faut trancher une question, parce que si la réponse allait dans l’autre sens, il n’y aurait rien à construire.

Le perceptron a échoué sur XOR parce qu’une droite ne peut pas séparer les quatre points. La correction évidente consiste à empiler : faire passer l’entrée dans une couche linéaire, puis dans une autre. Est-ce que cela aide ?

Non, et la preuve tient en deux lignes. Une couche linéaire est h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Donnez-la à une autre, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, et remplacez :

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

La composition est Wx+bW\mathbf{x} + \mathbf{b} avec W=W2W1W = W_2W_1 et b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. Un empilement de couches linéaires est une seule couche linéaire. Dix couches, mille couches : toujours une seule droite, toujours incapable de faire XOR.

Il vaut mieux le voir se produire que le croire :

linear_is_linear.pyPYTHON
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)

two_layers = W2 @ (W1 @ x + b1) + b2
one_layer  = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

Pas approximativement égales. Identiques bit à bit, parce que c’est la même arithmétique réarrangée.

La profondeur n’apporte donc rien à elle seule. Ce qui apporte quelque chose, c’est de placer une fonction non linéaire entre les couches — et c’est toute la raison d’être des fonctions d’activation. Elles ne sont ni une coquetterie biologique ni une astuce de normalisation. Sans elles, la seconde couche est décorative.

La règle de la chaîne, sur papier, avec un nœud partagé

Lien vers la section : La règle de la chaîne, sur papier, avec un nœud partagé

Passons maintenant aux mathématiques, et il s’agit d’une règle que vous connaissez déjà, appliquée à un endroit un peu moins familier.

La règle de la chaîne à une variable dit que si LL dépend de cc et que cc dépend de xx, alors dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. Les dérivées se multiplient le long d’une chaîne.

Ce qui compte ici, c’est ce qui se passe quand une variable alimente plus d’un chemin en aval. Si xx influence LL via aa et aussi via bb, les contributions s’additionnent :

dLdx=Laax+Lbbx\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

Multiplier le long d’un chemin, sommer entre les chemins. Voilà toute la backpropagation, et chaque détail d’implémentation dans le reste de ce chapitre — y compris le += dans le code et l’appel zero_grad() qui piège toutes les personnes écrivant leur première boucle d’entraînement — est une conséquence directe de ce deuxième mot.

Prenons un circuit concret de cinq opérations, avec x=0.5x = 0.5 et y=1.4y = 1.4 :

a=xy,b=x+y,c=ab,d=tanh(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

Remarquez que xx apparaît trois fois : dans aa, dans bb, et directement dans LL. Faites le passage arrière sur papier, de droite à gauche, en partant de dLdL=1\frac{dL}{dL} = 1 :

L=d+xL = d + x, donc Ld=1\frac{\partial L}{\partial d} = 1 et le chemin direct contribue Lx=1\frac{\partial L}{\partial x} = 1. L’addition distribue le gradient entrant inchangé vers les deux entrées.

d=tanh(c)d = \tanh(c) avec c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33, donc dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444.

c=abc = ab, donc dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 et dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. La multiplication échange : le gradient de chaque entrée est mis à l’échelle par la valeur de l’autre entrée.

Via aa : dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501. Via bb : dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711. Directement : 11.

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

Gardez ce nombre en tête. Dans quelques pages, un programme va le produire sans qu’on lui ait expliqué tout cela.

L’idée qui rend cela programmable : chacune de ces étapes était locale. Pour pousser un gradient à travers le nœud de multiplication, il vous fallait le gradient entrant et les deux valeurs d’entrée stockées — rien sur le reste du circuit. Chaque opération sait comment se différencier elle-même.

Faites donc un nombre qui se souvient de ce qui l’a produit.

value.pyPYTHON
class Value:
    """A number that remembers where it came from."""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)      
        self._op = _op

Quatre champs. data est la valeur. grad accumule Lself\frac{\partial L}{\partial \text{self}}. _prev est l’ensemble des Value à partir desquels celui-ci a été calculé — les arêtes du graphe. Et _backward est une closure que chaque opération installe : elle sait comment pousser le gradient de ce nœud d’un pas en arrière vers ses entrées.

Chaque opérateur suit la même forme : calculer la sortie, enregistrer les parents, installer la règle locale.

value.py (continued)PYTHON
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            self.grad += out.grad       
            other.grad += out.grad      

        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), "*")

        def _backward():
            self.grad += other.data * out.grad   
            other.grad += self.data * out.grad   

        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

        def _backward():
            self.grad += (1 - t * t) * out.grad

        out._backward = _backward
        return out

    def relu(self):
        out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")

        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad

        out._backward = _backward
        return out

Lisez les quatre corps _backward comme un tableau, et les motifs de flux de la dérivation papier sont juste là :

opérationce qu’elle fait au gradient
+distribue — le même gradient vers chaque entrée
*échange — chaque entrée est mise à l’échelle par la valeur de l’autre
reluroute — le laisse passer ou le bloque entièrement
tanhatténue — le met à l’échelle par 1t21 - t^2, qui vaut au plus 1 et généralement moins

Toutes utilisent += et jamais =. C’est la règle « sommer entre les chemins », encodée. Un nœud qui alimente deux consommateurs est appelé deux fois, et les deux contributions s’additionnent d’elles-mêmes.

Puis le pilote, qui est la seule partie ayant une connaissance globale :

value.py (continued)PYTHON
    def backward(self):
        order, seen = [], set()

        def build(v):
            if v in seen:
                return
            seen.add(v)
            for child in v._prev:
                build(child)
            order.append(v)

        build(self)
        self.grad = 1.0
        for v in reversed(order):       
            v._backward()               

build produit un ordre topologique du graphe : chaque nœud apparaît après toutes ses entrées. Parcourir cette liste à l’envers garantit que lorsque vous appelez le _backward d’un nœud, son propre gradient est déjà complet — chaque consommateur en aval a déjà contribué. Trompez-vous d’ordre et vous poussez un gradient à moitié terminé vers l’arrière, ce qui produit une mauvaise réponse sans message d’erreur.

check.pyPYTHON
x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)
TEXT
forward:  a=0.7000  b=1.9000  c=1.3300  d=0.8692  L=1.3692
backward: dL/dd=1.0000  dL/dc=0.2444  dL/da=0.4644  dL/db=0.1711
          dL/dx=1.8212   dL/dy=0.4033

1.8212. Le même nombre, issu d’un programme auquel on a donné la règle de +, la règle de *, la règle de tanh, et rien sur ce circuit.

Deux vérifications indépendantes, parce que « cela correspond à ce que j’ai dérivé » est un test faible quand la même personne a fait les deux.

Différentiation numérique. Perturbez légèrement l’entrée et mesurez. La différence centrée L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} estime la dérivée sans aucun calcul différentiel :

TEXT
dL/dx:  analytic=1.821202805  numeric=1.821202805  |diff|=1.80e-10
dL/dy:  analytic=0.403269235  numeric=0.403269235  |diff|=7.64e-12

Face à PyTorch, qui possède un moteur autodiff industriel écrit par des personnes dont c’est le métier :

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

Concordance à 2×10162 \times 10^{-16}, soit l’epsilon machine pour un flottant 64 bits : les deux moteurs effectuent une arithmétique identique. Gardez la vérification numérique sous la main — c’est l’outil pour déboguer le passage arrière d’une nouvelle couche, et la raison pour laquelle un gradient faux peut être trouvé.

Le même circuit, avec d’autres entrées. Fixez x=2x = 2 et y=3y = -3, ce qui donne c=6c = 6 :

TEXT
x=0.5, y=1.4:  dL/dc = 0.244400     three paths into x:  0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025     three paths into x:  0.0001 + -0.0001 + 1.0000 = 0.9999

Le gradient traversant le nœud tanh\tanh a chuté d’un facteur de 9 945. Tout ce qui se trouve en amont — dans un vrai réseau, chaque couche avant lui — ne reçoit pratiquement rien. Les deux chemins à travers le circuit se sont tus ; seule la connexion directe qui contourne tanh\tanh porte encore du signal.

C’est le problème du gradient évanescent, dans un seul nœud. Empilez quarante couches de tanh\tanh et multipliez quarante facteurs de ce type, et les premières couches cessent totalement d’apprendre. C’est aussi, au passage, un argument en faveur des connexions de saut que l’on peut voir ici en miniature : le chemin qui contournait la non-linéarité est le seul à avoir survécu.

Ce que fait vraiment zero_grad, et pourquoi le bug se cache

Lien vers la section : Ce que fait vraiment zero_grad, et pourquoi le bug se cache

Chaque _backward utilise +=. C’est correct — c’est ainsi que les chemins se somment. Mais cela a une conséquence qui piège tout le monde : les gradients s’accumulent aussi entre les appels à backward(). Le moteur n’a aucune idée que votre second appel est une nouvelle étape d’entraînement plutôt qu’un autre chemin dans le même graphe.

Une boucle d’entraînement doit donc les effacer :

train.pyPYTHON
for step in range(steps):
    ys = [model(x) for x, _ in DATA]
    loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))

    for p in model.parameters():   
        p.grad = 0.0

    loss.backward()
    for p in model.parameters():
        p.data -= lr * p.grad

C’est optimizer.zero_grad() dans PyTorch, et le conseil habituel est que l’oublier casse l’entraînement. Supprimons donc ces deux lignes et voyons à quel point c’est cassé. Mêmes seeds, mêmes conditions, 200 étapes de XOR :

learning rateseedavec remise à zérosans remise à zéro
0.051337perte 3.255088, 3/4perte 0.000000, 4/4
0.057perte 2.144820, 2/4perte 0.000000, 4/4
0.0542perte 2.126074, 2/4perte 0.000000, 4/4
0.11337perte 0.038597, 4/4perte 0.000000, 4/4
0.17perte 2.055048, 2/4perte 0.000000, 4/4
0.142perte 2.049876, 2/4perte 0.000073, 4/4
0.31337perte 4.512310, 2/4perte 8.000000, 2/4
0.37perte 0.015247, 4/4perte 4.000000, 3/4
0.342perte 0.005478, 4/4perte 4.000000, 3/4

Aux petits learning rates, la version boguée gagne à chaque ligne. Elle converge quand la version correcte bloque.

Ce n’est pas un accident, et cela mérite d’être compris, parce que cela explique pourquoi ce bug est si difficile à détecter. Si vous n’effacez jamais le gradient, alors à l’étape kk le paramètre est mis à jour par la somme de tous les gradients calculés jusque-là. Sur une perte qui continue de pointer à peu près dans la même direction, cette somme augmente régulièrement, et l’effet est un learning rate qui augmente tout seul. À η=0.05\eta = 0.05, là où l’algorithme correct rampe, la taille de pas qui s’emballe ressemble exactement à une correction.

Regardez ensuite les trois lignes du bas. À η=0.3\eta = 0.3, le même mécanisme fait exploser le modèle — une perte de 8.0 est le score d’un modèle effondré en une constante ±1\pm 1 — la moitié des 16 que coûteraient quatre réponses maximalement fausses — tandis que la version correcte converge maintenant proprement.

La formulation honnête n’est donc pas « appelez toujours zero_grad sinon votre modèle ne s’entraînera pas ». C’est : sans lui, vous ne faites plus gradient descent. Vous exécutez quelque chose dont la taille de pas dérive vers le haut à une vitesse que personne n’a choisie, et cela semblera fonctionner, parfois mieux que la vraie méthode, jusqu’au moment où ce ne sera plus le cas — et vous accuserez alors le learning rate, l’initialisation ou les données. C’est la forme des pires bugs en machine learning : ils ne plantent pas, ils changent l’algorithme en un autre algorithme qui obtient parfois de meilleurs scores.

Une fois le moteur terminé, un réseau de neurones tient en très peu de code. Un neurone est un produit scalaire, un biais et une activation ; une couche est une liste de neurones ; un réseau est une liste de couches.

nn.pyPYTHON
class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    def parameters(self):
        return self.w + [self.b]


class Layer:
    def __init__(self, nin, nout):
        self.neurons = [Neuron(nin) for _ in range(nout)]

    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

Il n’y a aucun passage arrière dans tout cela. Pas une ligne. La classe Value sait déjà différencier tout ce que ces classes peuvent construire, et c’est tout l’intérêt de l’avoir écrite d’abord : un moteur autodiff ne sait pas qu’il est utilisé pour un réseau de neurones.

Maintenant le problème du chapitre 1. Deux entrées, deux unités cachées, une sortie, neuf paramètres :

TEXT
step   1: loss 4.156690
step  10: loss 4.005572
step  50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597

[0, 0] -> -0.9081  (target -1)  ok
[0, 1] -> +0.8934  (target +1)  ok
[1, 0] -> +0.8906  (target +1)  ok
[1, 1] -> -0.9207  (target -1)  ok

Quatre sur quatre. La fonction qu’aucun perceptron ne peut calculer — prouvée au chapitre 1 par quatre inégalités qui exigeaient que bb soit à la fois positif et négatif — est calculée par neuf nombres trouvés automatiquement.

La partie satisfaisante n’est pas que cela fonctionne. C’est de pouvoir voir comment, parce qu’avec deux unités cachées la représentation intermédiaire est un point dans un plan et vous pouvez simplement l’afficher.

Entraîné jusqu’à une perte de 0.001241, voici où chaque entrée atterrit après la couche cachée, et ce que le neurone de sortie en fait :

entréesortie de la couche cachéescore de sortieétiquette
(0,0)(0, 0)(+0.8206, 0.8474)(+0.8206,\ -0.8474)2.4045-2.40451-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(0.8401, 0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, 0.8550)(+0.8368,\ -0.8550)2.4786-2.47861-1

Regardez les première et quatrième lignes. Les entrées (0,0)(0,0) et (1,1)(1,1) sont des coins diagonalement opposés du carré — aussi éloignés que deux points peuvent l’être dans ce problème — et la couche cachée les projette vers (0.82,0.85)(0.82, -0.85) et (0.84,0.86)(0.84, -0.86). Presque le même point. La couche a plié le plan de sorte que les deux coins rejetés se retrouvent l’un sur l’autre, et une fois qu’ils sont au même endroit, une seule droite les sépare des deux autres.

Et le neurone de sortie est exactement cette droite. Ses paramètres appris sont w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, donc sa frontière de décision est

3.1153h1+3.0893h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

qui est une droite — un perceptron, le même objet qu’au chapitre 1, inchangé. Il ne pouvait pas résoudre XOR alors et il ne le peut pas maintenant. Ce qui a changé, c’est qu’il ne regarde plus l’entrée ; il regarde un espace que la première couche a construit pour lui, dans lequel le problème est linéairement séparable.

Voilà ce qu’est une représentation apprise, et il vaut la peine d’être précis parce que l’expression sera utilisée de façon lâche dans le reste de ce cours, et dans le reste du domaine. Ce n’est pas une compression, un résumé, ni un embedding au sens mystique. C’est un changement de coordonnées, appris plutôt que conçu, dont le seul rôle est de rendre le travail de la couche suivante facile.

Le théorème d’approximation universelle, et ce qu’il ne dit pas

Lien vers la section : Le théorème d’approximation universelle, et ce qu’il ne dit pas

Il y a ici un théorème, et il est généralement mal cité.

Cybenko en 1989 et Hornik en 1991 ont prouvé qu’un réseau feedforward avec une seule couche cachée et une fonction d’activation adaptée peut approximer n’importe quelle fonction continue sur un ensemble compact, avec la précision voulue, pourvu qu’il y ait assez d’unités cachées.34 C’est un résultat réel et important : il dit que l’architecture n’est pas la limitation.

Lisez maintenant ce qu’il omet. Il ne dit pas combien d’unités — la borne peut être astronomiquement grande. Il ne dit pas que les poids peuvent être trouvés ; il affirme l’existence, et gradient descent depuis un départ aléatoire n’est pas un oracle. Et il ne dit rien sur le comportement avec des données que vous n’avez pas vues, ce qui constitue la seconde moitié du chapitre 6.

L’écart entre « existe » et « trouvable » n’a rien d’académique. Voici le même problème XOR, 50 initialisations aléatoires chacune, 1000 étapes, seule la taille de la couche cachée change :

unités cachéesinitialisations atteignant 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

Avec l’architecture minimale viable, une exécution sur quatre n’y arrive jamais — elle se stabilise dans une configuration dont elle ne peut pas descendre, exactement le minimum local que le chapitre 3 montrait sur une surface unidimensionnelle. Ajoutez une unité et les échecs disparaissent presque, non pas parce que le réseau est devenu plus expressif (deux unités suffisent déjà — 38 exécutions le prouvent), mais parce que des dimensions supplémentaires donnent à la descente plus de directions par lesquelles s’échapper.

Et puis huit unités fait légèrement moins bien que quatre. À learning rate et budget d’étapes fixes, plus de capacité n’est pas monotoniquement meilleur. Quiconque vous dit que la solution à un réseau bloqué est toujours un réseau plus grand extrapole à partir du milieu de ce tableau.

C’est la même leçon que le théorème de convergence du chapitre 1, et ce sera la même leçon au chapitre 10 sur les lois de passage à l’échelle, sous la forme que ce chapitre lui donne : une prédiction de la perte n’est pas une prédiction de la capacité pour laquelle vous payez, et l’écart entre les deux est là où se trouve l’ingénierie.

Afficher les détails

Facultatif : la forme matricielle, et pourquoi le code ci-dessus ne l’utilise pas.

Tout ici a été écrit scalaire par scalaire, ce qui est la manière la plus claire de voir le mécanisme et la plus lente de l’exécuter. En pratique, une couche est une multiplication matricielle, et le passage arrière de y=Wx\mathbf{y} = W\mathbf{x} est

LW=Lyx,Lx=WLy\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

Les transposées ne sont pas une astuce à mémoriser ; elles sont ce à quoi ressemble la règle de la somme sur les chemins quand les chemins sont indexés par les entrées de la matrice. L’objet général est le jacobien, la matrice de toutes les dérivées partielles de toutes les sorties par rapport à toutes les entrées, et le mode inverse est précisément le calcul d’un produit vecteur-jacobien sans jamais former le jacobien — ce qui compte, parce que pour une couche avec 4096 entrées et 4096 sorties, cette matrice a seize millions d’entrées et ne vaut jamais la peine d’être construite.

Vous n’avez besoin de rien de tout cela pour suivre les chapitres suivants ; la version scalaire fait tout ce que fait la version matricielle, plus lentement. Cela devient nécessaire au chapitre 9, lorsque les formes cessent d’être évidentes.

Vous avez maintenant un réseau qui s’entraîne. C’est un accomplissement plus petit qu’il n’y paraît, parce que le réseau que vous avez s’entraîne sur quatre exemples et est mesuré sur les mêmes quatre.

Exécutez le même code sur un vrai jeu de données et un nouvel ensemble de problèmes apparaît, dont aucun ne concerne les gradients. La perte diminue pendant un moment puis s’arrête. Ou elle diminue sur les données d’entraînement et augmente sur tout le reste. Ou elle ne bouge pas du tout dès la première étape, et la cause se révèle être la plage des poids aléatoires initiaux. Ou l’entrée d’une unité est devenue négative sur chaque exemple à l’époque trois et elle est morte depuis, silencieusement, emportant avec elle un morceau de la capacité du modèle.

Ce ne sont pas des échecs exotiques ; c’est l’état normal d’un réseau qui vient d’être écrit, et aucun ne s’annonce. Le gradient est correct — vous l’avez comparé à PyTorch à seize décimales près — et le modèle n’apprend toujours pas.

Le chapitre 6 traite de cela : initialisation, normalisation, surapprentissage et régularisation, et l’habitude diagnostique qui consiste à demander lequel de ces phénomènes est en train de se produire avant de changer quoi que ce soit. C’est la différence entre un réseau qui tourne et un réseau qui fonctionne.


La classe Value de ce chapitre descend directement de micrograd d’Andrej Karpathy, et sa vidéo The spelled-out intro to neural networks and backpropagation: building micrograd est les trois meilleures heures que vous puissiez passer sur ce sujet si vous voulez qu’il soit expliqué une deuxième fois par quelqu’un d’autre. Son billet de 2016 Yes you should understand backprop défend l’idée d’en écrire un soi-même et fait partie des lectures obligatoires du cours CS224n de Stanford. Les notes CS231n sur la backpropagation (cs231n.github.io/optimization-2) sont le traitement canonique des motifs de flux répertoriés ci-dessus. Pour les mathématiques vues comme du calcul différentiel sur un graphe plutôt que comme du folklore de réseaux de neurones, le chapitre 5.6 de Mathematics for Machine Learning de Deisenroth, Faisal et Ong est exceptionnellement clair ; et l’étude de Baydin, Pearlmutter, Radul et Siskind, Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767), est la référence pour l’ensemble du domaine, y compris le compromis avant/inverse discuté plus haut.

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Mémoire de master, University of Helsinki (1970). Accumulation en mode inverse, seize ans avant son arrivée dans ce domaine et avec une motivation complètement différente.

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). L’article qui a fait connaître la méthode, et la source de la lecture des unités cachées comme représentations apprises que la section Ce que la couche cachée a fait de ce chapitre mesure en détail.

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989).

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Généralise Cybenko : le résultat dépend du fait que l’activation soit non polynomiale, pas du fait qu’elle soit sigmoïdale.

Prêt à laisser LIA choisir à votre place ?

Créez avec tous les modèles d'IA au même endroit — commencez gratuitement dès aujourd'hui.