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 . Donnez-la à une autre, , et remplacez :
La composition est avec et . 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 :
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]))-4.612963371048 -4.612963371048 0.00e+00Pas 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 dépend de et que dépend de , alors . 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 influence via et aussi via , les contributions s’additionnent :
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 et :
Remarquez que apparaît trois fois : dans , dans , et directement dans . Faites le passage arrière sur papier, de droite à gauche, en partant de :
Via l’addition
Lien vers la section : Via l’addition, donc et le chemin direct contribue . L’addition distribue le gradient entrant inchangé vers les deux entrées.
Via tanh
Lien vers la section : Via tanhavec , donc .
Via la multiplication
Lien vers la section : Via la multiplication, donc et . La multiplication échange : le gradient de chaque entrée est mis à l’échelle par la valeur de l’autre entrée.
Regrouper les trois chemins dans x
Lien vers la section : Regrouper les trois chemins dans xVia : . Via : . Directement : .
Gardez ce nombre en tête. Dans quelques pages, un programme va le produire sans qu’on lui ait expliqué tout cela.
Construire le moteur
Lien vers la section : Construire le moteurL’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.
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 = _opQuatre champs. data est la valeur. grad accumule . _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.
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 outLisez les quatre corps _backward comme un tableau, et les motifs de flux de la dérivation papier sont juste là :
| opération | ce 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 |
relu | route — le laisse passer ou le bloque entièrement |
tanh | atténue — le met à l’échelle par , 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 :
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.
Est-ce que cela concorde avec le papier ?
Lien vers la section : Est-ce que cela concorde avec le papier ?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)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.40331.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 estime la dérivée sans aucun calcul différentiel :
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Face à PyTorch, qui possède un moteur autodiff industriel écrit par des personnes dont c’est le métier :
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Concordance à , 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é.
Saturation, mesurée
Lien vers la section : Saturation, mesuréeLe même circuit, avec d’autres entrées. Fixez et , ce qui donne :
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.9999Le gradient traversant le nœud 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 porte encore du signal.
C’est le problème du gradient évanescent, dans un seul nœud. Empilez quarante couches de 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 cacheChaque _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 :
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.gradC’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 rate | seed | avec remise à zéro | sans remise à zéro |
|---|---|---|---|
| 0.05 | 1337 | perte 3.255088, 3/4 | perte 0.000000, 4/4 |
| 0.05 | 7 | perte 2.144820, 2/4 | perte 0.000000, 4/4 |
| 0.05 | 42 | perte 2.126074, 2/4 | perte 0.000000, 4/4 |
| 0.1 | 1337 | perte 0.038597, 4/4 | perte 0.000000, 4/4 |
| 0.1 | 7 | perte 2.055048, 2/4 | perte 0.000000, 4/4 |
| 0.1 | 42 | perte 2.049876, 2/4 | perte 0.000073, 4/4 |
| 0.3 | 1337 | perte 4.512310, 2/4 | perte 8.000000, 2/4 |
| 0.3 | 7 | perte 0.015247, 4/4 | perte 4.000000, 3/4 |
| 0.3 | 42 | perte 0.005478, 4/4 | perte 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 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. À , là où l’algorithme correct rampe, la taille de pas qui s’emballe ressemble exactement à une correction.
Regardez ensuite les trois lignes du bas. À , 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 — 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.
Le réseau, et enfin XOR
Lien vers la section : Le réseau, et enfin XORUne 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.
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 :
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) okQuatre sur quatre. La fonction qu’aucun perceptron ne peut calculer — prouvée au chapitre 1 par quatre inégalités qui exigeaient que soit à la fois positif et négatif — est calculée par neuf nombres trouvés automatiquement.
Ce que la couche cachée a fait
Lien vers la section : Ce que la couche cachée a faitLa 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ée | sortie de la couche cachée | score de sortie | étiquette |
|---|---|---|---|
Regardez les première et quatrième lignes. Les entrées et 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 et . 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 , , donc sa frontière de décision est
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 pasIl 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ées | initialisations atteignant 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 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 est
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.
Où cela nous mène ensuite
Lien vers la section : Où cela nous mène ensuiteVous 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.
Sources et méthode
Lien vers la section : Sources et méthodeLa 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.
Références
Lien vers la section : Références-
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. ↩
-
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. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
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. ↩