Zum Inhalt springen
5/30Kapitel 5 von 30

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 muss

Bevor 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 h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Gib sie an eine weitere, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, und setze ein:

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)

Die Komposition ist Wx+bW\mathbf{x} + \mathbf{b} mit W=W2W1W = W_2W_1 und b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. 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:

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

Nicht 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 Knoten

Jetzt die Mathematik, und es ist eine Regel, die du schon kennst, nur an einer etwas ungewohnten Stelle angewandt.

Die eindimensionale Kettenregel sagt: Wenn LL von cc abhängt und cc von xx abhängt, dann gilt dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. 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 xx LL über aa beeinflusst und außerdem über bb, dann addieren sich die Beiträge:

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}

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 x=0.5x = 0.5 und 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

Beachte, dass xx dreimal erscheint: in aa, in bb und direkt in LL. Mache den Backward-Pass auf Papier, von rechts nach links, beginnend bei dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, also Ld=1\frac{\partial L}{\partial d} = 1, und der direkte Pfad trägt Lx=1\frac{\partial L}{\partial x} = 1 bei. Addition verteilt den eingehenden Gradient unverändert auf beide Eingaben.

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

c=abc = ab, also dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 und dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. Multiplikation tauscht: Der Gradient jeder Eingabe wird mit der anderen Eingabe skaliert.

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

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

Merk dir diese Zahl. In ein paar Seiten wird ein Programm sie erzeugen, ohne dass man ihm irgendetwas davon gesagt hat.

Die 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.

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

Vier Felder. data ist der Wert. grad akkumuliert Lself\frac{\partial L}{\partial \text{self}}. _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.

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

Lies die vier _backward-Rümpfe wie eine Tabelle, und die Flussmuster aus der Papierableitung stehen direkt vor dir:

Operationwas sie mit dem Gradient macht
+verteilt — derselbe Gradient an jede Eingabe
*tauscht — jede Eingabe wird mit dem Wert der anderen skaliert
reluroutet — lässt ihn durch oder blockiert ihn vollständig
tanhdämpft — skaliert mit 1t21 - t^2, 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:

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 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.

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. 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 L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} schätzt die Ableitung ganz ohne Analysis:

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

Gegen PyTorch, das eine industrielle Autodiff-Engine hat, geschrieben von Leuten, die das beruflich tun:

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

Übereinstimmung bei 2×10162 \times 10^{-16}, 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.

Dieselbe Schaltung, andere Eingaben. Setze x=2x = 2 und y=3y = -3, was c=6c = 6 ergibt:

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

Der Gradient, der den tanh\tanh-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 tanh\tanh überspringt, trägt noch Signal.

Das ist das Vanishing-Gradient-Problem in einem einzigen Knoten. Staple vierzig Schichten von tanh\tanh 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 versteckt

Jedes _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:

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

Das 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 rateseedmit Resetohne Reset
0.051337loss 3.255088, 3/4loss 0.000000, 4/4
0.057loss 2.144820, 2/4loss 0.000000, 4/4
0.0542loss 2.126074, 2/4loss 0.000000, 4/4
0.11337loss 0.038597, 4/4loss 0.000000, 4/4
0.17loss 2.055048, 2/4loss 0.000000, 4/4
0.142loss 2.049876, 2/4loss 0.000073, 4/4
0.31337loss 4.512310, 2/4loss 8.000000, 2/4
0.37loss 0.015247, 4/4loss 4.000000, 3/4
0.342loss 0.005478, 4/4loss 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 kk 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 η=0.05\eta = 0.05, wo der korrekte Algorithmus kriecht, sieht die davonlaufende Schrittweite genau wie eine Lösung aus.

Dann sieh dir die unteren drei Zeilen an. Bei η=0.3\eta = 0.3 sprengt derselbe Mechanismus das Modell — Loss 8.0 ist das Ergebnis eines Modells, das zu einer konstanten ±1\pm 1 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.

Wenn 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.

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()]

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:

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

Vier von vier. Die Funktion, die kein Perzeptron berechnen kann — in Kapitel 1 durch vier Ungleichungen bewiesen, die verlangten, dass bb gleichzeitig positiv und negativ ist — wird von neun automatisch gefundenen Zahlen berechnet.

Der 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:

EingabeAusgabe der verborgenen SchichtOutput-ScoreLabel
(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

Sieh dir die erste und vierte Zeile an. Die Eingaben (0,0)(0,0) und (1,1)(1,1) 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 (0.82,0.85)(0.82, -0.85) und (0.84,0.86)(0.84, -0.86) 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 w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, also ist seine Entscheidungsgrenze

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

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 sagt

Hier 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 EinheitenInitialisierungen, die 4/4 erreichen
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 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 y=Wx\mathbf{y} = W\mathbf{x} ist

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}}

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.

Du 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.


Die 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.

  1. 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.

  2. 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.

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

  4. 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.

Bereit, LIA die Wahl zu überlassen?

Bau mit jedem KI-Modell an einem Ort — starte heute kostenlos.