Modul 08 · Lektion 3

Backpropagation von Hand

Eine Klasse in ein paar Dutzend Zeilen schreiben, in der sich jede Zahl merkt, wie sie berechnet wurde; dann lassen sich die Gradienten vom Verlust aus mit der Kettenregel bis zu jedem Parameter zurückreichen. Mit numerischer Ableitung gegenprüfen und damit ein kleines Netz trainieren, das XOR lernt.

  • Etwa 60 Minuten
  • Niveau: Fortgeschritten
  • Getestet: 2026-09-14 reines Python, fester Zufallsstartwert

Code und Programmausgaben stehen genau so da, wie sie gelaufen sind – Kommentare und Ausgaben sind daher auf Chinesisch.

Die Gradienten der vorigen Lektion wurden an einer konkreten Formel von Hand hergeleitet. Ein neuronales Netz aber ist eine komplexe, Schicht um Schicht verschachtelte Funktion mit Hunderten oder Tausenden Parametern; jede Ableitung von Hand herzuleiten ist unmöglich.

Diese Lektion schreibt ein kleines Werkzeug, mit dem der Computer die Gradienten aller Parameter automatisch berechnet. Es heißt Backpropagation (backpropagation). Das ist die Kernfunktion von PyTorch; diese Lektion schreibt in reinem Python eine Fassung von nur ein paar Dutzend Zeilen, und danach weißt du, was hinter der Zeile loss.backward() passiert.

Die Kettenregel

Zuerst ein einfaches Beispiel. Angenommen y = 3x und z = y². Frage: Ändert sich x ein kleines bisschen, wie stark ändert sich z?

In zwei Schritten gedacht: Ändert sich x ein bisschen, ändert sich y dreimal so stark (die Ableitung von y nach x ist 3); ändert sich y ein bisschen, ändert sich z 2y-mal so stark (die Ableitung von z nach y ist 2y). Ändert sich x ein bisschen, ändert sich z also 3 × 2y-mal so stark.

z 对 x 的导数 = (z 对 y 的导数) × (y 对 x 的导数)

Das ist die Kettenregel: Die Ableitung einer Kette von Operationen ist das Produkt der Ableitungen jedes Schritts. Die Ableitung jedes Schritts hängt nur von diesem Schritt selbst ab und heißt lokale Ableitung.

Ein neuronales Netz ist eine lange Kette von Operationen: multiplizieren, addieren, dann durch eine nichtlineare Funktion, Schicht für Schicht, bis am Ende der Verlust berechnet ist. Kennt man die lokale Ableitung jedes Schritts, kann man mit der Kettenregel vom Verlust aus Schritt für Schritt zurückmultiplizieren und erhält die Ableitung des Verlusts nach jedem Parameter.

Jede Zahl führt Buch

Der Ansatz: Bei jeder Operation die „lokale Ableitung des Ergebnisses nach jeder Eingabe“ festhalten. Wir schreiben eine Klasse Num, die neben dem Wert eine Liste speichert: [(Eingabezahl, lokale Ableitung), ...].

class Num:
    """一个会记账的数:记下自己的值、梯度,以及"我对每个上游数的局部导数"。"""

    def __init__(self, value, parents=()):
        self.value = value
        self.grad = 0.0
        self.parents = parents  # [(上游的 Num, 局部导数), ...]

    def __add__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a + b 对 a 的导数是 1,对 b 的导数也是 1
        return Num(self.value + other.value, [(self, 1.0), (other, 1.0)])

    def __mul__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a × b 对 a 的导数是 b,对 b 的导数是 a
        return Num(self.value * other.value, [(self, other.value), (other, self.value)])

    def __pow__(self, n):
        # x 的 n 次方,导数是 n × x 的 (n-1) 次方
        return Num(self.value ** n, [(self, n * self.value ** (n - 1))])

    def tanh(self):
        t = math.tanh(self.value)
        # tanh 的导数是 1 - tanh²
        return Num(t, [(self, 1 - t * t)])

Jede Operation muss nur ihre eigene lokale Ableitung kennen:

  • Addition a + b: Ändert sich a ein bisschen, ändert sich das Ergebnis genauso; die lokalen Ableitungen nach a und nach b sind beide 1.
  • Multiplikation a × b: Ändert sich a ein bisschen, ändert sich das Ergebnis b-fach; die lokale Ableitung nach a ist b, nach b ist sie a.
  • Potenz xⁿ: Die lokale Ableitung ist n × xⁿ⁻¹, Schulmathematik.
  • tanh: eine Funktion, die jede Zahl in den Bereich -1 bis 1 drückt, in neuronalen Netzen verbreitet; ihre Ableitung ist 1 - tanh².

Mit Pythons Operatorüberladung (__add__, __mul__ usw.) erzeugt eine gewöhnliche Schreibweise wie a * b + c automatisch Num-Objekte und protokolliert unauffällig den gesamten Rechenweg.

(Der vollständige Code hat noch ein paar Zeilen für Subtraktion und dafür, dass auch gewöhnliche Zahlen mitrechnen können; siehe code/08-neural-nets/backprop.py.)

Backpropagation

Nach der Berechnung weiß jede Num, aus welchen Zahlen sie berechnet wurde; der gesamte Rechenweg bildet einen Graphen. Backpropagation heißt, vom Endergebnis (dem Verlust) aus diesen Graphen rückwärts zu durchlaufen:

    def backward(self):
        """从这个数(通常是损失)出发,把梯度传给所有上游的数。"""
        order, seen = [], set()

        def visit(node):  # 先访问完所有上游,再把自己放进列表:得到一个"从上游到下游"的顺序
            if id(node) not in seen:
                seen.add(id(node))
                for parent, _ in node.parents:
                    visit(parent)
                order.append(node)

        visit(self)
        self.grad = 1.0  # 损失对自己的导数是 1
        for node in reversed(order):  # 从下游往上游,链式法则:上游梯度 += 下游梯度 × 局部导数
            for parent, local in node.parents:
                parent.grad += node.grad * local

Zwei Schritte:

  1. Reihenfolge festlegen. visit sorgt dafür, dass eine Zahl immer nach allen Zahlen steht, aus denen sie berechnet wurde. Rückwärts durchlaufen heißt: vom Verlust zurückgehen, und wenn eine Zahl an der Reihe ist, sind alle von ihr abhängigen Zahlen schon verarbeitet, ihr Gradient ist vollständig aufsummiert.
  2. Gradienten weitergeben. Die Ableitung des Verlusts nach sich selbst ist 1. Bei jedem Schritt zurück gilt die Kettenregel: Der vorgelagerte Gradient erhöht sich um „nachgelagerter Gradient × lokale Ableitung“.

Warum „erhöhen“ (+=) statt direkt zuweisen? Weil eine Zahl mehrfach verwendet werden kann. Bei y = x * x etwa ist x beide Eingaben der Multiplikation, und die Gradienten beider Wege müssen addiert werden.

Prüfen: Vergleich mit numerischer Ableitung

Zuerst testen. Mit einem kleinen Ausdruck f = (a × b + c)², a=2, b=-3, c=10:

a, b, c = Num(2.0), Num(-3.0), Num(10.0)
f = (a * b + c) ** 2
f.backward()
print(f"  f = {f.value}")
print(f"  自动算出的梯度:df/da={a.grad}, df/db={b.grad}, df/dc={c.grad}")

Dann mit der numerischen Methode der vorigen Lektion gegenprüfen (diesmal die genauere Variante „ein Stück nach links und rechts“):

def numeric(fn, x, h=1e-6):
    return (fn(x + h) - fn(x - h)) / (2 * h)
== 1. f = (a × b + c)²,a=2, b=-3, c=10
  f = 16.0
  自动算出的梯度:df/da=-24.0, df/db=16.0, df/dc=8.0
  数值求导核对: df/da≈-24.0000, df/db≈16.0000, df/dc≈8.0000

Völlig übereinstimmend. Von Hand lässt es sich auch prüfen: a × b + c = 4, f = 4² = 16; die Ableitung von f nach (a×b+c) ist 2 × 4 = 8, also df/dc = 8, df/da = 8 × b = -24, df/db = 8 × a = 16.

Ein neuronales Netz daraus bauen

Mit automatischer Ableitung kann man neuronale Netze bauen.

Ein Neuron tut etwas sehr Einfaches: jede Eingabe mit einem Gewicht multiplizieren, aufsummieren, einen Bias addieren und das Ganze durch tanh schicken:

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

    def __call__(self, xs):
        total = self.b
        for w, x in zip(self.w, xs):
            total = total + w * x
        return total.tanh()

Ohne das abschließende tanh ist es die Gerade aus Lektion 1, nur mit mehreren Eingaben statt einer. Eine nichtlineare Funktion wie tanh (auch Aktivierungsfunktion genannt) ist unverzichtbar: Ohne sie ergeben viele übereinandergelegte Geradenschichten immer noch eine Gerade, und komplexe Muster lassen sich nicht lernen.

Eine Reihe von Neuronen bildet eine Schicht; zwei Schichten übereinander ergeben ein kleines Netz: 2 Eingaben → 4 verborgene Neuronen → 1 Ausgabe. Die Parameter gezählt: Die verborgene Schicht hat 4 Neuronen mit je 2 Gewichten und 1 Bias, zusammen 12; die Ausgabeschicht 1 Neuron mit 4 Gewichten und 1 Bias, zusammen 5. Insgesamt 17 Parameter.

XOR lernen

XOR (exklusives Oder): Sind beide Eingaben gleich, ist die Ausgabe -1, sind sie verschieden, 1 (weil die Ausgabe von tanh zwischen -1 und 1 liegt, stehen -1 und 1 für das übliche 0 und 1).

Es ist ein klassisches Beispiel, weil eine Gerade es nicht schafft: Zeichnet man die vier Punkte in eine Ebene, kann man (0,1), (1,0) nicht mit einer Geraden von (0,0), (1,1) trennen. Es braucht eine verborgene Schicht und eine nichtlineare Funktion.

Die Trainingsschleife ist genau der Gradientenabstieg der vorigen Lektion, nur wird der Schritt der Gradientenberechnung durch loss.backward() ersetzt:

data = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
lr = 0.1
for epoch in range(1, 301):
    loss = Num(0.0)
    for xs, y in data:
        pred = output(hidden(xs))[0]
        loss = loss + (pred - y) ** 2
    for p in params:
        p.grad = 0.0  # 每一轮都要清零,否则梯度会一直累加
    loss.backward()
    for p in params:
        p.value -= lr * p.grad

Beachte, dass die Gradienten vor jeder Runde auf null gesetzt werden müssen. Weil backward += verwendet, würden sich sonst die Gradienten dieser Runde zu denen der vorigen addieren. Das ist ein sehr klassischer Fehler, auch in PyTorch (optimizer.zero_grad() in der nächsten Lektion).

== 2. 网络:2 个输入 → 4 个隐藏神经元 → 1 个输出,共 17 个参数
  第   1 轮  损失 4.1022
  第  10 轮  损失 3.7902
  第  50 轮  损失 0.1093
  第 100 轮  损失 0.0346
  第 200 轮  损失 0.0134
  第 300 轮  损失 0.0081
  训练后的预测:
    输入 [0, 0] → -0.965(目标 -1)
    输入 [0, 1] → +0.952(目标 +1)
    输入 [1, 0] → +0.953(目标 +1)
    输入 [1, 1] → -0.951(目标 -1)

Zu Beginn ist der Verlust 4,1, und die vier Vorhersagen sind fast alle falsch. Die ersten 10 Runden kommen sehr langsam voran, dann fällt der Verlust plötzlich und ist nach 50 Runden bei 0,1. Nach 300 Runden liegen alle vier Vorhersagen sehr nah am Ziel.

Ein Muster, das eine Gerade nicht lernen kann, hat das kleine Netz mit 17 Parametern gelernt. Niemand hat ihm dabei gesagt, „was XOR ist“; es hat nur immer wieder den Verlust berechnet, die Gradienten berechnet und die Parameter in Gegenrichtung der Gradienten angepasst.

Was wir geschrieben haben

Rückblickend enthalten die paar Dutzend Zeilen dieser Lektion bereits das Wichtigste eines Deep-Learning-Frameworks:

  • Automatische Ableitung: Jede Operation hält ihre lokale Ableitung fest, die Backpropagation reicht die Gradienten per Kettenregel zurück.
  • Neuronen und Schichten: gewichtete Summe, Bias, Aktivierungsfunktion.
  • Trainingsschleife: vorwärts den Verlust berechnen, Gradienten nullen, Backpropagation, Parameter aktualisieren.

Natürlich ist es sehr langsam: Jede Zahl ist ein Python-Objekt, und ein etwas größeres Netz hätte Millionen solcher Objekte. Die nächste Lektion wechselt zu PyTorch, das genau dasselbe tut, nur verarbeitet es auf einmal einen ganzen Stapel Zahlen (Tensoren) und rechnet mit optimiertem Low-Level-Code, tausendfach schneller.

Übungen

  1. Füg Num eine Methode relu hinzu: Ist die Eingabe größer als 0, wird sie unverändert ausgegeben, sonst 0. Wie lautet ihre lokale Ableitung? Prüfe sie danach mit numerischer Ableitung.
  2. Lösch die beiden Zeilen in der Trainingsschleife, die die Gradienten nullen, führ es erneut aus und sieh, was passiert.
  3. Ändere die Zahl der verborgenen Neuronen von 4 auf 1 oder 2. Lernt es XOR noch? Probier auch einige andere Zufallsstartwerte aus.

Selbsttest

1. Was besagt die Kettenregel, und wie hängt sie mit der Backpropagation zusammen?

Die Kettenregel: Die Ableitung einer Kette von Operationen ist das Produkt der lokalen Ableitungen jedes Schritts. Backpropagation wendet die Kettenregel systematisch an: vom Verlust aus den Rechenweg rückwärts durchlaufen, bei jedem Schritt mit dessen lokaler Ableitung multiplizieren, und am Ende hat man die Ableitung des Verlusts nach jedem Parameter.

2. Warum werden Gradienten bei der Backpropagation mit += aufsummiert statt direkt zugewiesen?

Eine Zahl kann in der Berechnung mehrfach verwendet werden, etwa kommt x in y = x × x zweimal vor. Über jede Verwendung fließt ein Gradient zurück, und erst deren Summe ist die vollständige Ableitung.

3. Warum lernt eine Gerade XOR nicht, ein Netz mit verborgener Schicht und tanh aber schon?

Die vier XOR-Punkte lassen sich nicht mit einer Geraden trennen. Übereinandergelegte Geradenschichten ergeben immer noch eine Gerade; entscheidend ist also die nichtlineare Aktivierungsfunktion tanh: Sie ermöglicht dem Netz, gekrümmte Grenzen zusammenzusetzen, die diese vier Punkte trennen.

Fragen und Diskussion

Hängst du in dieser Lektion fest? Frag hier. Und wenn du die Frage von jemandem beantworten kannst, tu es gern.

Eine Frage bringt 3 Punkte, eine Antwort 6. Beiträge erscheinen nach der Prüfung.

Diskussion wird geladen…