Backpropagation von Grund auf: erst die Engine, dann das Netzwerk
Schreibe eine 120-Zeilen-Autodiff-Engine in reinem Python, prüfe sie gegen PyTorch und lerne, was zero_grad wirklich tut.
Auf dieser Seite
Vier Kapitel sind geschafft, und mitten im Kurs klafft eine Lücke.
Kapitel 3 gab uns gradient descent: Um einen Parameter zu verbessern, finde die Steigung des Loss in Bezug auf ihn und gehe bergab. Kapitel 4 gab uns einen Loss, bei dem sich das Absteigen lohnt. Aber in beiden wurde die Ableitung von Hand berechnet — ein Modell, ein Parameter, eine Zeile Analysis, und alles passte auf eine Seite.
Jetzt stapel zwei Schichten. Die Ausgabe der ersten speist die zweite, also beeinflusst jedes Gewicht in der ersten den Loss über jedes Neuron in der zweiten. Ein Netzwerk mit zwei verborgenen Schichten zu je hundert Einheiten hat ungefähr zwanzigtausend Parameter, und jeder einzelne braucht seine eigene partielle Ableitung desselben Loss. Das von Hand zu machen ist nicht mühsam; es ist unmöglich, und es bleibt für jede Architektur im Rest dieses Kurses unmöglich.
Der Ausweg ist keine bessere Notation. Er ist die Einsicht, dass die Ableitung einer Komposition mechanisch von einem Programm aus der Struktur der Berechnung selbst berechnet werden kann — und dass du, wenn du es in der richtigen Richtung tust, alle zwanzigtausend Ableitungen für ungefähr die Kosten bekommst, den Loss einmal zu berechnen.
Dieser Mechanismus ist reverse-mode automatic differentiation. Auf ein neuronales Netzwerk angewandt heißt er backpropagation, und am Ende dieses Kapitels wirst du ihn in etwa 120 Zeilen Python ohne Bibliotheken geschrieben, gegen PyTorch geprüft und benutzt haben, um das XOR-Problem zu lösen, an dem das Perzeptron in Kapitel 1 scheiterte.
Zuerst: warum es überhaupt eine Nichtlinearität geben muss
Link zum Abschnitt: Zuerst: warum es überhaupt eine Nichtlinearität geben mussBevor wir die Maschine bauen, muss eine Frage geklärt werden, denn wenn die Antwort anders ausfiele, gäbe es nichts zu bauen.
Das Perzeptron scheiterte an XOR, weil eine Linie die vier Punkte nicht trennen kann. Die offensichtliche Reparatur ist Stapeln: Schicke die Eingabe durch eine lineare Schicht, dann durch eine weitere. Hilft das?
Nein, und der Beweis hat zwei Zeilen. Eine lineare Schicht ist . Gib sie an eine weitere, , und setze ein:
Die Komposition ist mit und . Ein Stapel linearer Schichten ist eine einzige lineare Schicht. Zehn davon, tausend davon: immer noch eine Linie, immer noch unfähig, XOR zu lösen.
Es lohnt sich, das anzusehen, statt es nur zu glauben:
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+00Nicht ungefähr gleich. Bit für Bit identisch, weil es dieselbe Arithmetik in anderer Reihenfolge ist.
Tiefe allein bringt also nichts. Was etwas bringt, ist eine nichtlineare Funktion zwischen die Schichten zu setzen — und genau deshalb gibt es Aktivierungsfunktionen. Sie sind keine biologische Verzierung und kein Normalisierungstrick. Ohne eine ist die zweite Schicht Dekoration.
Die Kettenregel, auf Papier, mit einem geteilten Knoten
Link zum Abschnitt: Die Kettenregel, auf Papier, mit einem geteilten KnotenJetzt die Mathematik, und es ist eine Regel, die du schon kennst, nur an einer etwas ungewohnten Stelle angewandt.
Die eindimensionale Kettenregel sagt: Wenn von abhängt und von abhängt, dann gilt . Ableitungen multiplizieren sich entlang einer Kette.
Der Teil, der hier zählt, ist das, was passiert, wenn eine Variable mehr als einen nachgelagerten Pfad speist. Wenn über beeinflusst und außerdem über , dann addieren sich die Beiträge:
Entlang eines Pfads multiplizieren, über Pfade summieren. Das ist die ganze backpropagation, und jedes Implementierungsdetail im Rest dieses Kapitels — einschließlich des += im Code und des zero_grad()-Aufrufs, über den alle stolpern, die ihre erste Trainingsschleife schreiben — ist eine direkte Folge dieses zweiten Wortes.
Nimm eine konkrete Schaltung aus fünf Operationen, mit und :
Beachte, dass dreimal erscheint: in , in und direkt in . Mache den Backward-Pass auf Papier, von rechts nach links, beginnend bei :
Durch die Addition
Link zum Abschnitt: Durch die Addition, also , und der direkte Pfad trägt bei. Addition verteilt den eingehenden Gradient unverändert auf beide Eingaben.
Durch das tanh
Link zum Abschnitt: Durch das tanhmit , also .
Durch die Multiplikation
Link zum Abschnitt: Durch die Multiplikation, also und . Multiplikation tauscht: Der Gradient jeder Eingabe wird mit der anderen Eingabe skaliert.
Die drei Pfade nach x sammeln
Link zum Abschnitt: Die drei Pfade nach x sammelnDurch : . Durch : . Direkt: .
Merk dir diese Zahl. In ein paar Seiten wird ein Programm sie erzeugen, ohne dass man ihm irgendetwas davon gesagt hat.
Die Engine bauen
Link zum Abschnitt: Die Engine bauenDie Einsicht, die das programmierbar macht: Jeder dieser Schritte war lokal. Um einen Gradient durch den Multiplikationsknoten zu schieben, brauchtest du den eingehenden Gradient und die beiden gespeicherten Eingabewerte — nichts über den Rest der Schaltung. Jede Operation weiß, wie sie sich selbst differenziert.
Also bauen wir eine Zahl, die sich merkt, was sie erzeugt hat.
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 = _opVier Felder. data ist der Wert. grad akkumuliert . _prev ist die Menge der Values, aus denen diese Zahl berechnet wurde — die Kanten des Graphen. Und _backward ist eine Closure, die jede Operation installiert: Sie weiß, wie sie den Gradient dieses Knotens einen Schritt zurück zu seinen Eingaben schiebt.
Jeder Operator folgt derselben Form: Ausgabe berechnen, Eltern speichern, lokale Regel installieren.
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 outLies die vier _backward-Rümpfe wie eine Tabelle, und die Flussmuster aus der Papierableitung stehen direkt vor dir:
| Operation | was sie mit dem Gradient macht |
|---|---|
+ | verteilt — derselbe Gradient an jede Eingabe |
* | tauscht — jede Eingabe wird mit dem Wert der anderen skaliert |
relu | routet — lässt ihn durch oder blockiert ihn vollständig |
tanh | dämpft — skaliert mit , was höchstens 1 und meist kleiner ist |
Jede einzelne verwendet += und niemals =. Das ist die Regel „über Pfade summieren“, codiert. Ein Knoten, der zwei Verbraucher speist, wird zweimal aufgerufen, und die beiden Beiträge addieren sich von selbst.
Dann der Treiber, der einzige Teil mit globalem Wissen:
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 erzeugt eine topologische Ordnung des Graphen: Jeder Knoten erscheint nach allen seinen Eingaben. Diese Liste rückwärts zu durchlaufen garantiert, dass der eigene Gradient eines Knotens bereits vollständig ist, wenn du sein _backward aufrufst — jeder nachgelagerte Verbraucher hat bereits beigetragen. Bring die Reihenfolge durcheinander, und du schiebst einen halb fertigen Gradient rückwärts, was eine falsche Antwort ohne Fehlermeldung erzeugt.
Stimmt sie mit dem Papier überein?
Link zum Abschnitt: Stimmt sie mit dem Papier überein?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. Dieselbe Zahl, aus einem Programm, dem die Regel für +, die Regel für *, die Regel für tanh gesagt wurde — und nichts über diese Schaltung.
Zwei unabhängige Prüfungen, denn „es stimmt mit meiner Herleitung überein“ ist ein schwacher Test, wenn dieselbe Person beides gemacht hat.
Numerische Differentiation. Stoße die Eingabe leicht an und miss. Die zentrierte Differenz schätzt die Ableitung ganz ohne Analysis:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Gegen PyTorch, das eine industrielle Autodiff-Engine hat, geschrieben von Leuten, die das beruflich tun:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Übereinstimmung bei , also machine epsilon für einen 64-Bit-Float: Die beiden Engines führen identische Arithmetik aus. Behalte die numerische Prüfung griffbereit — sie ist das Werkzeug, um den Backward-Pass einer neuen Schicht zu debuggen, und der Grund, warum ein falscher Gradient überhaupt auffindbar ist.
Sättigung, gemessen
Link zum Abschnitt: Sättigung, gemessenDieselbe Schaltung, andere Eingaben. Setze und , was ergibt:
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.9999Der Gradient, der den -Knoten durchquert, ist um den Faktor 9.945 gefallen. Alles davor — in einem echten Netzwerk jede Schicht davor — erhält praktisch nichts. Die beiden Pfade durch die Schaltung sind verstummt; nur die direkte Verbindung, die überspringt, trägt noch Signal.
Das ist das Vanishing-Gradient-Problem in einem einzigen Knoten. Staple vierzig Schichten von und multipliziere vierzig solcher Faktoren miteinander, und die frühen Schichten hören vollständig auf zu lernen. Nebenbei ist das auch ein Argument für Skip-Verbindungen, das du hier im Kleinen sehen kannst: Der Pfad, der die Nichtlinearität umging, ist der einzige, der überlebt hat.
Was zero_grad wirklich tut und warum der Bug sich versteckt
Link zum Abschnitt: Was zero_grad wirklich tut und warum der Bug sich verstecktJedes _backward verwendet +=. Das ist korrekt — so summieren sich Pfade. Aber es hat eine Konsequenz, die alle erwischt: Gradienten akkumulieren auch über Aufrufe von backward() hinweg. Die Engine hat keine Ahnung, dass dein zweiter Aufruf ein neuer Trainingsschritt ist und nicht ein weiterer Pfad im selben Graphen.
Eine Trainingsschleife muss sie also löschen:
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.gradDas ist optimizer.zero_grad() in PyTorch, und der übliche Rat lautet, dass Training kaputtgeht, wenn man es vergisst. Also löschen wir diese zwei Zeilen und schauen, wie kaputt es ist. Dieselben Seeds, alles gleich, 200 Schritte XOR:
| learning rate | seed | mit Reset | ohne Reset |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
Bei den kleinen learning rates gewinnt die fehlerhafte Version in jeder einzelnen Zeile. Sie konvergiert, während die korrekte Version stecken bleibt.
Das ist kein Zufall, und es lohnt sich, es zu verstehen, weil es erklärt, warum dieser Bug so schwer zu finden ist. Wenn du den Gradient nie löschst, wird der Parameter in Schritt mit der Summe aller bisher berechneten Gradienten aktualisiert. Bei einem Loss, der grob weiter in dieselbe Richtung zeigt, wächst diese Summe stetig, und der Effekt ist eine learning rate, die von selbst steigt. Bei , wo der korrekte Algorithmus kriecht, sieht die davonlaufende Schrittweite genau wie eine Lösung aus.
Dann sieh dir die unteren drei Zeilen an. Bei sprengt derselbe Mechanismus das Modell — Loss 8.0 ist das Ergebnis eines Modells, das zu einer konstanten kollabiert ist, die Hälfte der 16, die vier maximal falsche Antworten kosten würden — — während die korrekte Version jetzt sauber konvergiert.
Die ehrliche Aussage ist also nicht: „Rufe immer zero_grad auf, sonst trainiert dein Modell nicht.“ Sie lautet: Ohne das führst du kein gradient descent mehr aus. Du führst etwas aus, dessen Schrittweite mit einer Rate nach oben driftet, die niemand gewählt hat, und es wird funktionieren wirken, manchmal sogar besser als das echte Verfahren, bis es das nicht mehr tut — und dann wirst du die learning rate, die Initialisierung oder die Daten verantwortlich machen. So sehen die schlimmsten Bugs im Machine Learning aus: Sie crashen nicht, sie verwandeln den Algorithmus in einen anderen Algorithmus, der gelegentlich besser punktet.
Das Netzwerk und endlich XOR
Link zum Abschnitt: Das Netzwerk und endlich XORWenn die Engine fertig ist, ist ein neuronales Netzwerk kaum noch Code. Ein Neuron ist ein Skalarprodukt, ein Bias und eine Aktivierung; eine Schicht ist eine Liste von Neuronen; ein Netzwerk ist eine Liste von Schichten.
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()]Darin gibt es keinen Backward-Pass. Keine einzige Zeile. Die Klasse Value weiß bereits, wie sie differenziert, was auch immer diese Klassen zufällig bauen. Genau darum haben wir sie zuerst geschrieben: Eine Autodiff-Engine weiß nicht, dass sie für ein neuronales Netzwerk verwendet wird.
Jetzt das Problem aus Kapitel 1. Zwei Eingaben, zwei verborgene Einheiten, eine Ausgabe, neun Parameter:
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) okVier von vier. Die Funktion, die kein Perzeptron berechnen kann — in Kapitel 1 durch vier Ungleichungen bewiesen, die verlangten, dass gleichzeitig positiv und negativ ist — wird von neun automatisch gefundenen Zahlen berechnet.
Was die verborgene Schicht getan hat
Link zum Abschnitt: Was die verborgene Schicht getan hatDer befriedigende Teil ist nicht, dass es funktioniert. Es ist, sehen zu können, wie es funktioniert, denn mit zwei verborgenen Einheiten ist die Zwischenrepräsentation ein Punkt in einer Ebene, und du kannst sie einfach ausgeben.
Trainiert auf einen Loss von 0.001241: Hier landet jede Eingabe nach der verborgenen Schicht, und das macht das Ausgabeneuron damit:
| Eingabe | Ausgabe der verborgenen Schicht | Output-Score | Label |
|---|---|---|---|
Sieh dir die erste und vierte Zeile an. Die Eingaben und sind diagonal gegenüberliegende Ecken des Quadrats — so weit voneinander entfernt, wie zwei Punkte in diesem Problem sein können — und die verborgene Schicht bildet sie auf und ab. Fast derselbe Punkt. Die Schicht hat die Ebene gefaltet, sodass die beiden abgelehnten Ecken aufeinander landen, und sobald sie am selben Ort sind, trennt eine Linie sie von den beiden anderen.
Und das Ausgabeneuron ist genau diese Linie. Seine gelernten Parameter sind , , also ist seine Entscheidungsgrenze
eine gerade Linie — ein Perzeptron, dasselbe Objekt aus Kapitel 1, unverändert. Damals konnte es XOR nicht lösen, und jetzt kann es das auch nicht. Was sich geändert hat: Es schaut nicht mehr auf die Eingabe; es schaut auf einen Raum, den die erste Schicht für es gebaut hat, in dem das Problem linear trennbar ist.
Das ist eine gelernte Repräsentation, und es lohnt sich, genau zu sein, weil der Ausdruck im Rest dieses Kurses und im Rest des Feldes locker verwendet wird. Es ist keine Kompression, keine Zusammenfassung und kein embedding in irgendeinem mystischen Sinn. Es ist ein Koordinatenwechsel, gelernt statt entworfen, dessen einzige Aufgabe darin besteht, der nächsten Schicht die Arbeit leicht zu machen.
Das universelle Approximationstheorem und was es nicht sagt
Link zum Abschnitt: Das universelle Approximationstheorem und was es nicht sagtHier gibt es ein Theorem, und es wird meistens schlecht zitiert.
Cybenko 1989 und Hornik 1991 bewiesen, dass ein Feedforward-Netzwerk mit einer einzigen verborgenen Schicht und einer geeigneten Aktivierungsfunktion jede stetige Funktion auf einer kompakten Menge mit beliebiger Genauigkeit approximieren kann, wenn es genug verborgene Einheiten gibt.34 Das ist ein echtes und wichtiges Ergebnis: Es sagt, dass die Architektur nicht die Begrenzung ist.
Lies jetzt, was es auslässt. Es sagt nicht, wie viele Einheiten — die Schranke kann astronomisch groß sein. Es sagt nicht, dass die Gewichte gefunden werden können; es behauptet Existenz, und gradient descent von einem zufälligen Startpunkt ist kein Orakel. Und es sagt nichts über das Verhalten auf Daten, die du nicht gesehen hast; das ist die zweite Hälfte von Kapitel 6.
Die Lücke zwischen „existiert“ und „auffindbar“ ist nicht akademisch. Hier ist dasselbe XOR-Problem, jeweils 50 zufällige Initialisierungen, 1000 Schritte, nur die Größe der verborgenen Schicht geändert:
| verborgene Einheiten | Initialisierungen, die 4/4 erreichen |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
Mit der minimal lebensfähigen Architektur kommt ein Lauf von vier nie dort an — er landet in einer Konfiguration, aus der er nicht heraus absteigen kann, genau das lokale Minimum, das Kapitel 3 auf einer eindimensionalen Fläche zeigte. Füge eine Einheit hinzu, und die Fehlschläge verschwinden fast, nicht weil das Netzwerk ausdrucksstärker wurde (zwei Einheiten reichen bereits — 38 Läufe beweisen es), sondern weil zusätzliche Dimensionen dem Abstieg mehr Richtungen geben, durch die er entkommen kann.
Und dann schneiden acht Einheiten etwas schlechter ab als vier. Bei fester learning rate und festem Schrittbudget ist mehr Kapazität nicht monoton besser. Wer dir sagt, die Lösung für ein feststeckendes Netzwerk sei immer ein größeres Netzwerk, extrapoliert aus der Mitte dieser Tabelle.
Das ist dieselbe Lektion wie das Konvergenztheorem in Kapitel 1, und es wird dieselbe Lektion in Kapitel 10 über Scaling Laws sein, in der Form, die dieses Kapitel ihr gibt: Eine Vorhersage des Loss ist keine Vorhersage der Fähigkeit, für die du bezahlst, und der Abstand zwischen beiden ist der Ort, an dem Engineering stattfindet.
Details anzeigen
Optional: die Matrixform und warum der Code oben sie nicht verwendet.
Alles hier wurde Skalar für Skalar geschrieben, was die klarste Art ist, den Mechanismus zu sehen, und die langsamste, ihn auszuführen. In der Praxis ist eine Schicht eine Matrixmultiplikation, und der Backward-Pass von ist
Die Transponierten sind kein Merk-Trick; sie sind die Form, die die Summe-über-Pfade-Regel annimmt, wenn die Pfade durch Matrixeinträge indiziert sind. Das allgemeine Objekt ist die Jacobian, die Matrix aller partiellen Ableitungen aller Ausgaben in Bezug auf alle Eingaben, und reverse mode ist genau die Berechnung eines Vektor-Jacobian-Produkts, ohne die Jacobian je zu bilden — was wichtig ist, denn für eine Schicht mit 4096 Eingaben und 4096 Ausgaben hat diese Matrix sechzehn Millionen Einträge und ist es nie wert, gebaut zu werden.
Du brauchst nichts davon, um den nächsten Kapiteln zu folgen; die skalare Version tut alles, was die Matrixversion tut, nur langsamer. Es wird in Kapitel 9 notwendig, wo die Shapes nicht mehr offensichtlich sind.
Wohin es als Nächstes geht
Link zum Abschnitt: Wohin es als Nächstes gehtDu hast jetzt ein Netzwerk, das trainiert. Das ist ein kleinerer Erfolg, als es sich anfühlt, denn dein Netzwerk trainiert auf vier Beispielen und wird an denselben vier gemessen.
Führe denselben Code auf einem echten Datensatz aus, und eine neue Reihe von Problemen erscheint, von denen keines mit Gradienten zu tun hat. Der Loss sinkt eine Weile und stoppt dann. Oder er sinkt auf den Trainingsdaten und steigt auf allem anderen. Oder er bewegt sich vom ersten Schritt an gar nicht, und die Ursache stellt sich als Wertebereich der anfänglichen Zufallsgewichte heraus. Oder die Eingabe einer Einheit ist in Epoche drei bei jedem Beispiel ins Negative gedriftet, und sie ist seitdem still tot und nimmt einen Teil der Modellkapazität mit sich.
Das sind keine exotischen Fehler; sie sind der Normalzustand eines Netzwerks, das gerade erst geschrieben wurde, und keiner von ihnen meldet sich. Der Gradient ist korrekt — du hast ihn bis auf sechzehn Dezimalstellen gegen PyTorch geprüft — und das Modell lernt trotzdem nicht.
Kapitel 6 handelt davon: Initialisierung, Normalisierung, Overfitting und Regularisierung, und die diagnostische Gewohnheit zu fragen, welches davon passiert, bevor man irgendetwas ändert. Es ist der Unterschied zwischen einem Netzwerk, das läuft, und einem Netzwerk, das funktioniert.
Quellen und Methode
Link zum Abschnitt: Quellen und MethodeDie Klasse Value in diesem Kapitel stammt direkt von Andrej Karpathys micrograd ab, und sein Video The spelled-out intro to neural networks and backpropagation: building micrograd sind die besten drei Stunden, die du mit diesem Material verbringen kannst, wenn du es dir auf eine zweite Art von jemand anderem erklären lassen willst. Sein Post von 2016 Yes you should understand backprop liefert das Argument dafür, selbst eine zu schreiben, und ist Pflichtlektüre in Stanfords CS224n. Die CS231n-Notizen zu backpropagation (cs231n.github.io/optimization-2) sind die kanonische Behandlung der oben tabellierten Flussmuster. Für die Mathematik als Analysis auf einem Graphen statt als Neural-Network-Folklore ist Kapitel 5.6 von Mathematics for Machine Learning von Deisenroth, Faisal und Ong ungewöhnlich klar; und Baydin, Pearlmutter, Radul und Siskinds Survey Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) ist die Referenz für das gesamte Feld, einschließlich des oben diskutierten Forward/Reverse-Trade-offs.
Referenzen
Link zum Abschnitt: Referenzen-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Masterarbeit, University of Helsinki (1970). Reverse-mode-Akkumulation, sechzehn Jahre bevor sie dieses Feld erreichte, und mit einer völlig anderen Motivation. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, S. 533–536 (1986). Das Paper, das die Methode bekannt machte, und die Quelle der Lesart verborgener Einheiten als gelernte Repräsentationen, auf die der Abschnitt Was die verborgene Schicht getan hat in diesem Kapitel seine Messungen verwendet. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, S. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), S. 251–257 (1991). Verallgemeinert Cybenko: Das Ergebnis hängt davon ab, dass die Aktivierung nicht-polynomiell ist, nicht davon, dass sie sigmoidal ist. ↩