Backpropagation — wie ein Modell lernt

Backpropagation — wie ein Modell lernt

Artikel 4 von 8 · Serie: Wie LLMs funktionieren

Am Ende von Artikel 3 hatten wir ein vollständiges neuronales Netz — Embedding rein, Logits raus, alles Schritt für Schritt nachgebaut. Nur hat das Netz nichts gekonnt. Die Ausgabe-Wahrscheinlichkeiten waren fast gleichverteilt, weil die Gewichte zufällig initialisiert waren. Das Netz hatte nie etwas gesehen, nie etwas gelernt.

In diesem Artikel schließen wir die Lücke. Wir schauen uns an wie ein Modell lernt; und zwar nicht metaphorisch, sondern mechanisch. Backpropagation ist der Algorithmus der aus einem zufällig initialisierten Netz ein trainiertes macht. Die Idee ist überraschend einfach. Die Umsetzung braucht ein bisschen Differentialrechnung, aber die Intuition lässt sich auch ohne Mathematik-Studium begreifen.

Was heißt „lernen" eigentlich?

Ein Modell hat Gewichte. Diese Gewichte bestimmen was es ausgibt. Lernen bedeutet: die Gewichte so verändern dass die Ausgaben besser werden.

„Besser" müssen wir präzise fassen. Wenn wir einem Modell zeigen dass auf „Die Hauptstadt von Frankreich ist" das Token „ Paris" folgen soll, und das Modell sagt „ Berlin", dann ist die Ausgabe falsch. Wie falsch genau? Das misst der Loss — eine Zahl die angibt wie weit die Vorhersage von der Wahrheit entfernt ist. Je kleiner der Loss, desto besser das Modell.

Lernen ist damit ein Optimierungsproblem: Finde die Gewichte, die den Loss minimieren. Bei einem Mini-MLP mit 19.210 Parametern ist das schon ein 19.210-dimensionales Problem. Bei Llama 3.1 mit 405 Milliarden Parametern ein 405-milliarden-dimensionales. Brute Force — alle Kombinationen durchprobieren — fällt sofort aus. Wir brauchen einen klugen Weg.

Der kluge Weg heißt Gradient Descent.

Der Loss: ein Maß für Unrichtigkeit

Bevor wir optimieren können, brauchen wir die Zielfunktion. Für Sprachmodelle ist das in der Regel der Cross-Entropy Loss — er misst wie weit eine Wahrscheinlichkeitsverteilung von einer anderen entfernt ist.

Kurzer Umweg: One-Hot-Encoding

Bevor wir zum Loss kommen, brauchen wir eine Art die „richtige Antwort“ in einer Form darzustellen die das Modell verarbeiten kann. Das Modell gibt keine Wörter aus, sondern eine Wahrscheinlichkeitsverteilung über alle Tokens im Vokabular. Um seine Ausgabe mit der richtigen Antwort vergleichen zu können, muss auch die richtige Antwort als Wahrscheinlichkeitsverteilung vorliegen.

One-Hot-Encoding ist die simpelste Möglichkeit. Für ein Vokabular mit 4 Tokens schreiben wir jedes Token als Vektor mit vier Einträgen: an der Position des Tokens steht eine 1, an allen anderen eine 0.

Vokabular: [" Paris", " Berlin", " London", " Madrid"]
            Index 0     Index 1     Index 2     Index 3

" Paris"  → [1, 0, 0, 0]
" Berlin" → [0, 1, 0, 0]
" London" → [0, 0, 1, 0]
" Madrid" → [0, 0, 0, 1]

Der Vektor heißt „one-hot“ weil genau eine Position „heiß“ ist (den Wert 1 hat), der Rest ist „kalt“ (0). Als Wahrscheinlichkeitsverteilung gelesen bedeutet [1, 0, 0, 0]: „100% Paris, 0% alles andere“ — eine Verteilung die absolut sicher ist dass Paris die richtige Antwort ist.

Das ist genau das was wir für den Vergleich brauchen: das Modell gibt eine Verteilung aus (z.B. [0.1, 0.7, 0.15, 0.05]), wir haben die wahre Antwort als Verteilung ([1, 0, 0, 0]), und der Loss misst den Unterschied zwischen beiden.

Cross-Entropy am Beispiel

Stellen wir uns vor, das Modell soll das nächste Token vorhersagen. Die wahre Antwort ist „ Paris“, also als One-Hot:

y_wahr = [1, 0, 0, 0]   # 100% Paris, 0% alles andere

Das Modell sagt aber:

y_modell = [0.1, 0.7, 0.15, 0.05]   # 70% Berlin, nur 10% Paris

Der Cross-Entropy Loss für diese Vorhersage ist -log(0.1) ≈ 2.30. Hätte das Modell „ Paris" mit 90% vorhergesagt, wäre der Loss -log(0.9) ≈ 0.11. Bei 100% wäre er -log(1.0) = 0. Je sicherer das Modell bei der richtigen Antwort ist, desto kleiner der Loss.

import numpy as np

def cross_entropy(y_pred, y_true_index):
    """Cross-Entropy für eine einzelne Vorhersage.
    y_pred: Wahrscheinlichkeiten vom Modell, z.B. [0.1, 0.7, 0.15, 0.05]
    y_true_index: Index des richtigen Tokens, z.B. 0 für " Paris"
    """
    return -np.log(y_pred[y_true_index] + 1e-12)   # epsilon gegen log(0)

# Beispiel: Modell sagt stark falsches Token
probs = np.array([0.1, 0.7, 0.15, 0.05])
print(f"Loss bei falscher Vorhersage:  {cross_entropy(probs, 0):.4f}")

# Beispiel: Modell ist unsicher
probs = np.array([0.3, 0.3, 0.2, 0.2])
print(f"Loss bei unsicherem Modell:    {cross_entropy(probs, 0):.4f}")

# Beispiel: Modell sagt richtig mit hoher Sicherheit
probs = np.array([0.9, 0.05, 0.03, 0.02])
print(f"Loss bei korrekter Vorhersage: {cross_entropy(probs, 0):.4f}")
Loss bei falscher Vorhersage:  2.3026
Loss bei unsicherem Modell:    1.2040
Loss bei korrekter Vorhersage: 0.1054

Der Loss ist eine einzelne Zahl. Das ist der entscheidende Trick. Egal wie komplex das Modell ist und wie viele Gewichte es hat — am Ende des Forward Pass kommt genau eine Zahl heraus die sagt „so falsch war’s gerade". Diese eine Zahl ist es, die wir minimieren wollen.

Warum -log und nicht einfach 1 minus Wahrscheinlichkeit?

Cross-Entropy ist nicht willkürlich gewählt. Sie stammt aus der Informationstheorie: -log(p) misst die „Überraschung" wenn ein Ereignis mit Wahrscheinlichkeit p eintritt. Je unwahrscheinlicher ein Ereignis das Modell für die richtige Antwort hält, desto überraschender ist es dass diese Antwort richtig war, desto größer der Loss.

Formal ist der Cross-Entropy Loss zwischen wahrer Verteilung y und vorhergesagter Verteilung ŷ:

L = -Σ yᵢ · log(ŷᵢ)

Bei One-Hot-Encoding ist nur ein yᵢ gleich 1, alle anderen 0. Die Summe reduziert sich damit auf den einzigen Term wo yᵢ=1, also -log(ŷ_korrekt).

Warum ist das besser als 1 - ŷ_korrekt? Zwei Gründe:

  1. Gradient-Verhalten: Die Ableitung von -log(p) ist -1/p. Wenn das Modell total falsch liegt (p klein), ist der Gradient riesig und zieht stark in die richtige Richtung. 1-p hätte überall denselben Gradienten, unabhängig von wie falsch das Modell ist.

  2. Kombiniert sich elegant mit Softmax: Die Ableitung von Softmax-gefolgt-von-Cross-Entropy ist einfach ŷ - y — eine der einfachsten Ableitungen überhaupt. Das ist kein Zufall, sondern der Grund warum diese Kombination in praktisch allen Klassifikations-Netzen verwendet wird.

Die Intuition hinter Gradienten

Wir haben eine Loss-Funktion. Wir haben viele Gewichte. Wir wollen die Gewichte so verändern dass der Loss kleiner wird. Wie?

Vergessen wir für einen Moment neuronale Netze und stellen uns ein einziges Gewicht vor — nennen wir es w. Der Loss hängt von w ab: L(w). Wenn wir w verändern, ändert sich L. Wenn wir das als Kurve auftragen, mit w auf der x-Achse und L auf der y-Achse, hat diese Kurve irgendwo einen Tiefpunkt. Dort ist der Loss minimal — das ist das Gewicht, das wir finden wollen.

Aber wir sehen die ganze Kurve nicht. Wir stehen an einem Punkt und wissen nur: der Loss an dieser Stelle ist so-und-so. Wie kommen wir zum Tiefpunkt?

Die Antwort ist die Ableitung. Die Ableitung von L nach w sagt uns: wenn ich w ein bisschen erhöhe, wie ändert sich L? Ist die Ableitung positiv, dann wird L größer — wir müssen w verkleinern um den Loss zu reduzieren. Ist die Ableitung negativ, müssen wir w vergrößern. Ist sie null, sind wir am Tiefpunkt.

Bei vielen Gewichten sprechen wir vom Gradienten — einem Vektor der für jedes Gewicht einzeln sagt in welche Richtung es verändert werden muss. Der Gradient zeigt in Richtung steilstem Anstieg des Loss. Wir bewegen uns in die entgegengesetzte Richtung, weil wir den Loss verkleinern wollen. Daher „Gradient Descent" — Abstieg entlang des Gradienten.

Gradient Descent — interaktiv

-4-202404812L(w)w
w
2.00
Loss
4.00
Schritte
0
Startpunkt 2.0
Learning Rate 0.30

Ein entscheidender Parameter ist die Learning Rate (Lernrate). Sie bestimmt wie groß der Schritt ist den wir in die Abstiegs-Richtung machen. Zu klein: wir kommen nie an, das Training dauert ewig. Zu groß: wir springen über das Minimum hinweg und fangen an zu oszillieren oder zu divergieren. Das Ausbalancieren der Learning Rate ist eine der zentralen Fragen beim Training neuronaler Netze.

Gradient Descent: der Algorithmus

In Code ist Gradient Descent drei Zeilen:

gradient = berechne_gradient(w, daten)   # Wie steil und in welche Richtung?
w = w - learning_rate * gradient         # Einen Schritt bergab

Wiederholt man das oft genug, landet man in einem Minimum. Das ist im Kern alles was Training ist.

Das Problem ist nur: wie berechnet man berechne_gradient(w, daten) bei einem Netz mit Tausenden von Schichten und Milliarden von Gewichten? Man kann nicht alle Gewichte einzeln numerisch testen — das wäre absurd teuer. Man braucht einen Weg, den Gradienten analytisch auszurechnen.

Die Antwort ist die Chain Rule.

Die Chain Rule: Gradienten durch viele Layer

Ein neuronales Netz ist eine Kette von Operationen: Embedding → Layer 1 → Aktivierung → Layer 2 → Softmax → Loss. Jede dieser Operationen ist eine Funktion. Das ganze Netz ist also eine verschachtelte Funktion, so etwas wie L = f(g(h(embedding))).

Die Chain Rule aus der Differentialrechnung sagt: wenn ich wissen will wie stark der Loss von einem tief im Netz versteckten Gewicht abhängt, multipliziere ich die lokalen Gradienten entlang des Weges.

Lokal heißt: für jede einzelne Operation kann man leicht ausrechnen wie sich ihr Output ändert wenn man ihren Input ändert. Die Chain Rule sagt: diese lokalen Steigungen einfach aufeinander multiplizieren, und man bekommt die Gesamt-Steigung durch die ganze Kette hindurch.

Chain Rule — interaktiv

Eingabe x 1.5
Steigung a von f 2.0
x1.50f(·)f(x) = a · xf(x)3.00g(·)g(y) = y²g(f(x))9.00Output9.00Lokaler Gradientdf/dx = a= 2.00Lokaler Gradientdg/df = 2·f(x)= 2·3.00 = 6.00Vorwärts (Werte) oben · Lokale Gradienten unten
Lokaler Gradient df/dx: 2.00
Lokaler Gradient dg/df: 6.00
Gesamt-Gradient dg/dx: 2.00 × 6.00 = 12.00

Was genau ist der Gesamt-Gradient eigentlich?

Wenn wir mit dem Widget herumspielen, fällt auf: der Output und der Gesamt-Gradient sind zwei verschiedene Zahlen, die nichts offensichtlich miteinander zu tun haben. Bei x = 1.3 und a = 2.0 kommt als Output 6.76 heraus, der Gesamt-Gradient ist aber 10.4. Was bedeutet diese 10.4?

Der Gesamt-Gradient ist nicht der Output. Er beantwortet eine ganz andere Frage: „Wenn ich x um einen winzigen Betrag ändere, wie stark ändert sich dadurch der Output?“ Die Antwort bei x = 1.3 lautet: der Output ändert sich 10.4-mal so stark wie die Änderung von x.

Konkret nachrechnen:

xf(x) = 2·xg(f(x)) = f(x)²
1.302.606.760
1.312.626.864

Änderung von x: +0.01. Änderung des Outputs: +0.104. Verhältnis: 0.104 / 0.01 = 10.4 — genau der Gesamt-Gradient.

Der Gradient sagt uns also nicht den Output, sondern die Empfindlichkeit des Outputs auf x. Genau das brauchen wir beim Training: „in welche Richtung und wie stark muss ich ein Gewicht schieben, damit der Loss kleiner wird?“ Die Antwort steht im Gradienten.

Vorsicht: der Gradient gilt nur lokal

Ein Stolperstein: man könnte auf die Idee kommen, den Gradienten einmal auszurechnen und dann jeden beliebigen Output vorherzusagen, einfach Output + gradient · Δx. Das funktioniert aber nur für winzige Änderungen.

Bei unserem Beispiel mit x = 1.3, Output 6.76, Gradient 10.4:

  • Winziger Sprung: x = 1.31 → Vorhersage 6.76 + 0.01·10.4 = 6.864 → tatsächlich 6.8644 ✓ passt fast exakt.
  • Mittlerer Sprung: x = 1.5 → Vorhersage 6.76 + 0.2·10.4 = 8.84 → tatsächlich 9.00 — schon leicht daneben.
  • Großer Sprung: x = 3.0 → Vorhersage 6.76 + 1.7·10.4 = 24.44 → tatsächlich 36.00 — deutlich daneben.

Warum? Die Funktion ist gekrümmt. An jedem Punkt ist die Steigung anders. Der Gradient bei x = 1.3 ist eine Momentaufnahme genau dort, er sagt nichts über die Krümmung an anderen Stellen aus. Bei x = 3.0 wäre der tatsächliche Gradient 2·2·(2·3) = 24, nicht mehr 10.4.

Die Analogie: Wir stehen auf einem Hügel und messen die Steigung unter unseren Füßen. Das sagt uns in welche Richtung es gerade bergauf oder bergab geht, aber nicht wo das Tal ist. Ein kleiner Schritt in die Abstiegs-Richtung bringt uns näher, ein großer Sprung aber landet irgendwo in einer anderen Landschaft wo die Steigung ganz anders ist.

Genau dafür ist Gradient Descent iterativ: lokaler Gradient, kleiner Schritt, neuer Gradient messen, nächster Schritt. Tausende Mal. Das ist auch der Grund warum die Learning Rate so heikel ist — ist der Schritt zu groß, verlassen wir die Umgebung in der der Gradient noch eine gute Approximation war (im Gradient-Descent-Widget oben sieht man genau das: bei Learning Rate > 1 fliegt die Kugel über das Minimum hinweg).

Chain Rule als Zahnräder

Zahnrad 1×1Zahnrad 2×3Zahnrad 3×2OutputGesamt-Übersetzung:1 × 3 × 2 = 6

Jedes Zahnrad hat seine eigene lokale Übersetzung. Die Gesamt-Übersetzung durch die Kette ist einfach das Produkt. Genau das ist die Chain Rule.

Anschaulicher Vergleich: Stellen wir uns eine Kette von Zahnrädern vor. Das erste Zahnrad dreht sich einmal, das zweite dreht sich 3× so schnell, das dritte 2× so schnell wie das zweite. Wie schnell dreht sich das dritte wenn das erste sich einmal dreht? 1 × 3 × 2 = 6. Das ist die Chain Rule.

In einem neuronalen Netz passiert genau das, nur mit vielen Zahnrädern und in vielen Dimensionen gleichzeitig.

Die Chain Rule formal

Die Notation aus der Differentialrechnung sieht komplizierter aus als sie ist. dL/dx bedeutet: „wie stark ändert sich L, wenn ich x ein ganz kleines bisschen ändere?“ Es ist einfach eine Zahl, die Steigung der Funktion L an der Stelle x.

Für eine verschachtelte Funktion L = f(g(h(x))) gilt:

dL/dx = (dL/df) · (df/dg) · (dg/dh) · (dh/dx)

In Worten: Die Gesamt-Steigung von L nach x ist das Produkt aus vier lokalen Steigungen:

  • dh/dx: wie stark ändert sich h, wenn x sich ändert?
  • dg/dh: wie stark ändert sich g, wenn sein Input h sich ändert?
  • df/dg: wie stark ändert sich f, wenn sein Input g sich ändert?
  • dL/df: wie stark ändert sich L, wenn f sich ändert?

Genau das Zahnrad-Bild: jedes Zahnrad hat einen lokalen Übersetzungsfaktor, und die Gesamt-Übersetzung ist das Produkt. Jeder Faktor ist ein lokaler Gradient, eine Größe die man leicht einzeln ausrechnen kann. Um dL/dx zu bekommen, multipliziert man diese lokalen Gradienten entlang des Pfades von L zurück zu x.

Im neuronalen Netz entspricht das einer Rückwärts-Traversierung: vom Loss aus zurück durch jede Schicht, wobei jede Schicht ihren eigenen lokalen Gradienten beisteuert. Genau deshalb heißt der Algorithmus Backpropagation, er propagiert den Gradienten rückwärts durch das Netz.

Für mehrdimensionale Funktionen (was Netze praktisch immer sind) werden aus den Ableitungen Jacobi-Matrizen, aber das Prinzip bleibt: lokale Gradienten multiplizieren. In der Implementierung sind das Matrix-Multiplikationen statt Zahlen-Multiplikationen.

Backpropagation, von Hand implementiert

Genug Theorie. Wir implementieren Backpropagation für ein Mini-Netz das XOR lernt — das Problem aus Artikel 3. Vier Datenpunkte, zwei Inputs, ein Output. Kleiner geht’s kaum, und es demonstriert alles was auch in Llama 3.1 passiert.

Kurz zur Aktivierungsfunktion

In Artikel 3 hatten wir ReLU und GELU. Für dieses Beispiel nehmen wir eine dritte: Sigmoid. Sigmoid quetscht jeden Eingabewert in einen Ausgabebereich zwischen 0 und 1; große positive Zahlen werden zu fast 1, große negative zu fast 0, und 0 landet bei 0.5. Es ist eine historisch wichtige Aktivierungsfunktion (vor ReLU war sie Standard) und hat zwei praktische Vorteile für unser XOR-Beispiel: Ihr Output eignet sich direkt als Binär-Klassifikation (über 0.5 = Klasse 1, unter 0.5 = Klasse 0), und ihre Ableitung lässt sich besonders einfach aus dem eigenen Output berechnen. Was das bedeutet, sehen wir gleich im Code.

Sigmoid-Funktion

-6-303610.50xσ(x) = 1 / (1 + e⁻ˣ)große Negative → 00 → 0.5große Positive → 1

Sigmoid quetscht jeden Input in den Bereich (0, 1). An den Enden ist die Kurve fast flach — Gradienten nahe null.

Gewichts-Initialisierung

Bevor das Training startet, müssen die Gewichte auf irgendwelche Werte gesetzt werden. Eine naheliegende Idee wäre: alle auf Null. Das funktioniert nicht wenn alle Gewichte identisch sind, bekommen alle Neuronen in einer Schicht exakt denselben Gradienten und lernen dasselbe. Die Neuronen werden nie unterscheidbar und das Netz verhält sich wie ein einzelnes Neuron.

Deshalb initialisiert man mit Zufallszahlen. Jedes Gewicht bekommt einen kleinen zufälligen Wert. Dadurch startet jedes Neuron an einer leicht anderen Stelle, und Backpropagation kann sie unterschiedlich modifizieren. Der Faktor * 0.5 im Code unten skaliert die Zufallswerte auf einen kleinen Bereich, typisch sind Werte zwischen -0.5 und +0.5. Zu große Startwerte führen dazu dass Sigmoid in Bereiche gerät wo ihre Ableitung fast 0 ist (an den äußeren Enden), und das Training stockt.

Gewichte und Biases — zwei Arten von Parametern

Bisher haben wir nur von Gewichten gesprochen. Neuronale Netze haben aber eine zweite Art von Parametern, die genauso wichtig sind: Biases. Beides wird beim Training gelernt, beides bekommt eigene Gradienten im Backward Pass.

Die Rechnung für eine Schicht ist z = x @ W + b. Der Gewichts-Anteil x @ W verbindet die Neuronen, für jede Verbindung zwischen einem Input-Neuron und einem Neuron der nächsten Schicht gibt es ein Gewicht. Der Bias b ist eine Verschiebung pro Neuron, unabhängig vom Input. Jedes Neuron in der empfangenden Schicht hat einen eigenen Bias.

Warum braucht man Biases? Ohne Bias müsste jede Schicht durch den Ursprung gehen, bei Input 0 wäre der Output vor der Aktivierung auch immer 0. Der Bias erlaubt dem Neuron zu sagen: „ich aktiviere mich erst wenn die Summe größer als X ist“. Das macht das Netz flexibler.

Für unser XOR-Netz zählen wir die Parameter zusammen:

  • W1 (2 Inputs × 4 Hidden): 8 Gewichte
  • b1 (ein Bias pro Hidden-Neuron): 4 Biases
  • W2 (4 Hidden × 1 Output): 4 Gewichte
  • b2 (ein Bias für das Output-Neuron): 1 Bias

Summe: 8 + 4 + 4 + 1 = 17 Parameter. Alle 17 werden beim Training gelernt.

Im Code sehen wir das gleich als vier separate Arrays: W1, b1, W2, b2. Die Biases werden mit np.zeros(...) auf 0 initialisiert (das ist unkritisch, da die Gewichte schon zufällig sind starten alle Neuronen sowieso unterschiedlich).

Netz-Architektur: 2 → 4 → 1

W1(2×4)W2(4×1)x₁x₂h₁h₂h₃h₄ŷInputHidden (Sigmoid)Output (Sigmoid)× W1 + b1, dann Sigmoid× W2 + b2, dann SigmoidParameter178 + 4+ 4 + 1

2 Inputs, 4 Hidden-Neuronen, 1 Output. W1 (2×4 = 8 Gewichte) und W2 (4×1 = 4 Gewichte), plus 4+1 = 5 Biases — insgesamt 17 Parameter.

import numpy as np

def sigmoid(x):
    # Clip verhindert overflow bei sehr großen negativen Werten
    return 1 / (1 + np.exp(-np.clip(x, -500, 500)))

def sigmoid_deriv(a):
    # Der Clou bei Sigmoid: die Ableitung lässt sich aus dem Output a selbst berechnen.
    # Wenn a = sigmoid(z), dann ist sigmoid'(z) = a * (1 - a).
    # Das spart uns den Umweg über z beim Backward Pass.
    return a * (1 - a)

# XOR-Datensatz: die vier möglichen Binär-Inputs und ihre Ziel-Outputs
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([[0], [1], [1], [0]], dtype=float)

# Netz-Architektur: 2 Inputs -> 4 Hidden Neuronen -> 1 Output
# Wir setzen einen festen Seed damit das Experiment reproduzierbar ist.
np.random.seed(42)

# W1 verbindet die 2 Inputs mit 4 Hidden Neuronen -> Matrix mit Shape (2, 4).
# randn() liefert Zufallszahlen aus einer Normalverteilung um 0.
# Der Faktor 0.5 skaliert sie auf einen kleinen Bereich, damit Sigmoid
# nicht sofort in die flachen Enden gerät.
W1 = np.random.randn(2, 4) * 0.5
b1 = np.zeros((1, 4))   # Bias pro Neuron, startet bei 0

# W2 verbindet die 4 Hidden Neuronen mit dem einen Output -> Shape (4, 1).
W2 = np.random.randn(4, 1) * 0.5
b2 = np.zeros((1, 1))

# Learning Rate: wie groß sind die Schritte beim Gewichts-Update?
learning_rate = 0.5
losses = []   # Wir sammeln den Loss pro Epoch für spätere Auswertung

for epoch in range(5000):
    # ───── Forward Pass: Input durch das Netz schicken ─────
    z1 = X @ W1 + b1        # Lineare Kombination im Hidden Layer
    a1 = sigmoid(z1)         # Aktivierung: a1 ist der Output des Hidden Layers
    z2 = a1 @ W2 + b2        # Lineare Kombination im Output Layer
    a2 = sigmoid(z2)         # Aktivierung: a2 ist die finale Vorhersage des Netzes

    # Loss: wie weit ist die Vorhersage a2 vom Ziel y entfernt?
    # Mean Squared Error (MSE) ist für Regressions-artige Probleme einfach:
    # mittlere quadrierte Abweichung über alle 4 Trainingsbeispiele.
    loss = np.mean((a2 - y) ** 2)
    losses.append(loss)

    # ───── Backward Pass: Chain Rule rückwärts durch's Netz ─────
    # Jede Zeile hier entspricht einem Schritt der Chain Rule.
    # Wir starten hinten (beim Loss) und arbeiten uns nach vorne.

    # Gradient des Loss nach dem Output a2.
    # MSE-Ableitung: d/da2 von (a2-y)² ist 2*(a2-y). Durch len(X) teilen wegen des Mittelwerts.
    d_a2 = 2 * (a2 - y) / len(X)

    # Gradient vor der Sigmoid im Output Layer.
    # Chain Rule: dL/dz2 = dL/da2 * da2/dz2. Letzteres ist sigmoid_deriv(a2).
    d_z2 = d_a2 * sigmoid_deriv(a2)

    # Gradient der Gewichte W2 (für das Update).
    # Weil z2 = a1 @ W2 + b2 gilt dz2/dW2 = a1.
    # In Matrix-Form: a1.T @ d_z2.
    d_W2 = a1.T @ d_z2
    d_b2 = d_z2.sum(axis=0, keepdims=True)  # Bias-Gradient: einfach aufsummieren

    # Gradient durch W2 zurück in den Hidden Layer.
    # Chain Rule: dL/da1 = dL/dz2 * dz2/da1 = d_z2 @ W2.T
    d_a1 = d_z2 @ W2.T

    # Gradient vor der Sigmoid im Hidden Layer - selber Trick wie bei Layer 2.
    d_z1 = d_a1 * sigmoid_deriv(a1)

    # Gradient der Gewichte W1 und des Bias b1.
    d_W1 = X.T @ d_z1
    d_b1 = d_z1.sum(axis=0, keepdims=True)

    # ───── Update: Gewichte in die Richtung des steilsten Abstiegs schieben ─────
    # Das ist Gradient Descent: w = w - lr * gradient
    W1 -= learning_rate * d_W1
    b1 -= learning_rate * d_b1
    W2 -= learning_rate * d_W2
    b2 -= learning_rate * d_b2

    # Zwischenstand ausgeben
    if epoch % 1000 == 0:
        preds = (a2 > 0.5).astype(int).flatten()
        correct = (preds == y.flatten()).sum()
        print(f"Epoch {epoch:4d}  Loss = {loss:.4f}  Korrekt = {correct}/4")

print(f"\nFinale Vorhersagen:")
for inp, target, pred in zip(X, y.flatten(), a2.flatten()):
    print(f"  {inp}{pred:.3f}  (Soll: {int(target)})")
Epoch    0  Loss = 0.2611  Korrekt = 2/4
Epoch 1000  Loss = 0.1891  Korrekt = 2/4
Epoch 2000  Loss = 0.0432  Korrekt = 4/4
Epoch 3000  Loss = 0.0084  Korrekt = 4/4
Epoch 4000  Loss = 0.0041  Korrekt = 4/4

Finale Vorhersagen:
  [0. 0.] → 0.061  (Soll: 0)
  [0. 1.] → 0.940  (Soll: 1)
  [1. 0.] → 0.942  (Soll: 1)
  [1. 1.] → 0.063  (Soll: 0)

Das Netz hat XOR gelernt. Es startet bei Loss 0.26, verharrt etwa 1500 Epochen in einer Art Plateau (es weiß nicht wohin), und kippt dann plötzlich in Richtung Lösung. Nach 2000 Epochen klassifiziert es alle vier Punkte korrekt, nach 5000 sind die Vorhersagen sauber bei fast 0 bzw. fast 1.

Schauen wir uns den Backward Pass genau an. Das ist der ganze Backpropagation-Algorithmus:

  1. Wir fangen hinten am Loss an und rechnen den Gradienten nach dem Output aus.
  2. Wir gehen rückwärts durch jede Schicht. An jeder Schicht: wir nutzen den Gradienten der nächsten Schicht und den lokalen Gradienten der aktuellen Schicht, um den Gradienten für die Gewichte zu berechnen.
  3. Wir updaten alle Gewichte mit Gradient Descent.

Das war’s, keine Magie, nur Matrix-Multiplikationen und Ableitungen die wir aus der Kette zusammensetzen.

Woher kommen die Ableitungen im Code?

Jede Zeile im Backward Pass entspricht genau einem Chain-Rule-Schritt. Gehen wir sie durch:

d_a2 = 2 * (a2 - y) / len(X) Das ist dL/da2. Der Mean Squared Error ist L = Σ(a2-y)²/n. Ableitung nach a2: 2(a2-y)/n.

d_z2 = d_a2 * sigmoid_deriv(a2) Das ist dL/dz2 = dL/da2 · da2/dz2. Weil a2 = sigmoid(z2), ist da2/dz2 = sigmoid'(z2) = a2·(1-a2). Chain Rule.

d_W2 = a1.T @ d_z2 Das ist dL/dW2. Weil z2 = a1 · W2 + b2, ist die Ableitung nach W2 einfach a1. In Matrix-Form: a1.T @ d_z2.

d_a1 = d_z2 @ W2.T Gradient geht rückwärts durch die lineare Schicht. Weil z2 = a1 · W2, ist dz2/da1 = W2. Also dL/da1 = dL/dz2 · W2.T.

d_z1 = d_a1 * sigmoid_deriv(a1) Wieder Chain Rule durch die Sigmoid-Aktivierung, genau wie bei Layer 2.

d_W1 = X.T @ d_z1 Gradient der Gewichte in Layer 1 — dieselbe Logik wie bei d_W2, nur mit X statt a1 als Input.

Jede Zeile ist eine lokale Ableitung, mal den Gradienten der schon aus den späteren Schichten zurückgeflossen ist. Genau das ist die Chain Rule.

XOR-Training — live

Entscheidungsregion
0101
Loss-Kurve
00.150.300Epoche
Epoche
0
Loss
0.2600
Korrekt
0/4

Vom XOR zur Token-Vorhersage

XOR ist eine saubere Demo, aber dennoch künstlich. Ein Sprachmodell lernt nicht XOR, es lernt Token-Vorhersagen. Das Prinzip ist identisch, nur größer und mit Cross-Entropy statt MSE.

Wir trainieren ein winziges MLP darauf, nach einem Eingabe-Token das richtige Ausgabe-Token zu produzieren. Unser Vokabular besteht aus fünf Tokens: ["Die", "Hauptstadt", "von", "Frankreich", "Paris"]. Dem Modell zeigen wir vier Beispielpaare:

"Die"         → "Hauptstadt"
"Hauptstadt"  → "von"
"von"         → "Frankreich"
"Frankreich"  → "Paris"

Das Modell soll diese Assoziationen lernen. Der Aufbau ist derselbe wie in Artikel 3, nur jetzt mit Backpropagation dran. Ein paar Details sind anders und deshalb kommentiert:

  • One-Hot-Encoding statt Embeddings: wir stellen jedes Token als Vektor dar der an genau einer Stelle 1 ist, sonst 0. Das ist der simpelste denkbare Input und funktioniert für unsere 5 Tokens. In echten Modellen werden hier Embedding-Vektoren verwendet, das ändert aber nichts am Training-Prinzip.
  • Softmax + Cross-Entropy statt Sigmoid + MSE: für Mehr-Klassen-Klassifikation ist das der Standard. Die Ableitung dieser Kombination ist übrigens besonders elegant, wie wir im Code sehen.
import numpy as np

# Unser Mini-Vokabular und seine Größe V.
# V = 5 bedeutet: das Modell wird am Ende für jeden Input eine
# Wahrscheinlichkeitsverteilung über diese 5 Tokens ausgeben.
VOCAB = ["Die", "Hauptstadt", "von", "Frankreich", "Paris"]
V = len(VOCAB)

# Trainingsdaten als Token-IDs (die Position im Vokabular).
# X_ids sind die Inputs, y_ids die zugehörigen Ziel-Outputs.
# Beispiel: (0, 1) bedeutet "Modell soll aus Token 0 ('Die') das
# nächste Token 1 ('Hauptstadt') vorhersagen".
X_ids = np.array([0, 1, 2, 3])   # "Die", "Hauptstadt", "von", "Frankreich"
y_ids = np.array([1, 2, 3, 4])   # "Hauptstadt", "von", "Frankreich", "Paris"

# One-Hot-Encoding der Inputs. Aus einer Token-ID wird ein Vektor der Länge V,
# der an der Position der ID eine 1 hat, sonst 0.
# Trick: np.eye(V) ist die V×V-Einheitsmatrix (Diagonale = 1, Rest = 0).
# Ihre i-te Zeile ist bereits der One-Hot-Vektor für Token i.
# [X_ids] wählt die Zeilen in der Reihenfolge unserer Token-IDs aus.
# Ergebnis X hat Shape (4, 5) - 4 Trainingsbeispiele, jedes ein 5-dim-Vektor.
X = np.eye(V)[X_ids]

def softmax(z):
    # Softmax verwandelt beliebige Zahlen (die "Logits") in eine
    # Wahrscheinlichkeitsverteilung - alle Werte zwischen 0 und 1,
    # und die Summe über jede Zeile ist genau 1.
    #
    # Stabilitäts-Trick: Wenn z sehr große Werte enthält (z.B. 1000),
    # dann würde np.exp(1000) überlaufen und Infinity liefern.
    # Ziehen wir vorher das Maximum ab, bleibt das Ergebnis mathematisch
    # identisch (kürzt sich raus), vermeidet aber den Overflow.
    z = z - np.max(z, axis=1, keepdims=True)
    e = np.exp(z)
    # Division durch die Summe pro Zeile normalisiert die Werte zu Wahrscheinlichkeiten.
    return e / e.sum(axis=1, keepdims=True)

def gelu(x):
    # GELU - die Aktivierungsfunktion aus Artikel 3, Standard in modernen Transformern.
    # Wir nehmen sie hier um zu demonstrieren dass Backpropagation mit beliebigen
    # differenzierbaren Aktivierungen funktioniert, nicht nur mit Sigmoid.
    # Die Formel ist eine glatte Approximation - man muss sie nicht verstehen,
    # nur wissen: GELU verhält sich ähnlich wie ReLU, aber ohne scharfen Knick bei 0.
    return 0.5 * x * (1 + np.tanh(np.sqrt(2/np.pi) * (x + 0.044715 * x**3)))

def gelu_deriv(x):
    # Für die Chain Rule im Backward Pass brauchen wir die Ableitung von GELU.
    # Bei Sigmoid war die analytisch einfach (a·(1-a)). Bei GELU ist sie es nicht.
    # Wir behelfen uns daher mit einer *numerischen* Ableitung: wir rechnen einfach
    #     (f(x+h) - f(x-h)) / (2h)
    # für ein sehr kleines h. Das ist die Definition der Ableitung als Differenzenquotient.
    # Für Lehrzwecke reicht das; in Produktions-Code nimmt man die analytische Form
    # (oder lässt Frameworks wie PyTorch das automatisch machen).
    h = 1e-4
    return (gelu(x + h) - gelu(x - h)) / (2 * h)

# Netz-Architektur: V Inputs (=5) -> 16 Hidden Neuronen -> V Outputs (=5).
# Dass Input- und Output-Dimension gleich sind, liegt an unserer Aufgabe:
# wir nehmen einen One-Hot-Vektor rein und wollen eine Wahrscheinlichkeitsverteilung
# über dasselbe Vokabular raus.
# 16 Hidden Neuronen ist willkürlich gewählt - genug Kapazität für 4 Trainingsbeispiele.
np.random.seed(0)
W1 = np.random.randn(V, 16) * 0.3   # Gewichte klein halten, sonst startet GELU in Sättigung
b1 = np.zeros((1, 16))
W2 = np.random.randn(16, V) * 0.3
b2 = np.zeros((1, V))

learning_rate = 0.3

for epoch in range(500):
    # ───── Forward Pass ─────
    z1 = X @ W1 + b1           # Hidden Layer: lineare Kombination, Shape (4, 16)
    a1 = gelu(z1)              # Hidden Layer Aktivierung, Shape (4, 16)
    z2 = a1 @ W2 + b2          # Output Layer: lineare Kombination, Shape (4, 5)
                               # Diese rohen Zahlen heißen "Logits" - sie können
                               # beliebige Werte annehmen, auch negative.
    probs = softmax(z2)        # Softmax macht aus Logits Wahrscheinlichkeiten, Shape (4, 5)

    # Cross-Entropy Loss: -log(Wahrscheinlichkeit die das Modell dem *richtigen* Token gibt).
    # Wir brauchen also für jede der 4 Zeilen in probs genau den Wert an der Spalte y_ids[i].
    #
    # probs[np.arange(len(y_ids)), y_ids] ist NumPy "Fancy Indexing":
    #   - np.arange(4) = [0, 1, 2, 3] wählt alle 4 Zeilen
    #   - y_ids        = [1, 2, 3, 4] wählt für jede Zeile die passende Spalte
    # Ergebnis: ein Vektor mit 4 Wahrscheinlichkeiten - für jedes Beispiel die Wahr-
    # scheinlichkeit die das Modell dem *richtigen* Zieltoken zugewiesen hat.
    #
    # Das + 1e-12 vermeidet log(0) falls das Modell eine Wahrscheinlichkeit von exakt 0 vergibt.
    # .mean() bildet den Mittelwert über die 4 Trainingsbeispiele.
    loss = -np.log(probs[np.arange(len(y_ids)), y_ids] + 1e-12).mean()

    # ───── Backward Pass ─────
    # Der Clou: die Ableitung von Softmax + Cross-Entropy ist extrem einfach:
    # einfach (probs - y_onehot). Damit sparen wir uns alle Zwischenschritte
    # die eigentlich nötig wären (die Softmax-Ableitung ist an sich gar nicht so nett).
    # Das ist kein Zufall, sondern der Grund warum Softmax + Cross-Entropy überall
    # verwendet wird wo Klassifikation gemacht wird.
    y_onehot = np.eye(V)[y_ids]          # y_ids als One-Hot-Matrix, Shape (4, 5)
    d_z2 = (probs - y_onehot) / len(X)   # Gradient am Output Layer, Shape (4, 5)

    # Ab hier identische Logik wie beim XOR-Beispiel:
    d_W2 = a1.T @ d_z2                   # Gradient der Gewichte W2
    d_b2 = d_z2.sum(axis=0, keepdims=True)

    # Gradient rückwärts durch W2 in den Hidden Layer
    d_a1 = d_z2 @ W2.T                   # Chain Rule
    d_z1 = d_a1 * gelu_deriv(z1)         # Wichtig: GELU-Ableitung bekommt z1 als Input
                                          # (nicht a1 - GELU ist nicht wie Sigmoid mit 'aus dem
                                          #  Output berechenbar')
    d_W1 = X.T @ d_z1                    # Gradient der Gewichte W1
    d_b1 = d_z1.sum(axis=0, keepdims=True)

    # Update aller Parameter - Schritt in die Abstiegs-Richtung des Loss
    W1 -= learning_rate * d_W1
    b1 -= learning_rate * d_b1
    W2 -= learning_rate * d_W2
    b2 -= learning_rate * d_b2

    if epoch % 100 == 0:
        preds = probs.argmax(axis=1)     # argmax: welche Spalte hat die höchste Wahrscheinlichkeit?
        correct = (preds == y_ids).sum()
        print(f"Epoch {epoch:3d}  Loss = {loss:.4f}  Korrekt = {correct}/4")

# Teste das trainierte Modell mit jedem der ersten 4 Tokens als Input.
print("\nVorhersagen nach Training:")
for i, token in enumerate(VOCAB[:-1]):
    # np.eye(V)[i:i+1] - mit Slicing [i:i+1] behalten wir die Zeilen-Dimension (Shape (1,5)),
    # während np.eye(V)[i] einen 1D-Vektor (Shape (5,)) liefern würde.
    # Die Matrix-Multiplikation braucht aber 2D-Shape als Input.
    x = np.eye(V)[i:i+1]
    # Forward Pass manuell (keine Backprop mehr nötig, nur Vorhersage):
    p = softmax(gelu(x @ W1 + b1) @ W2 + b2)[0]
    top = p.argmax()                      # Index des wahrscheinlichsten Tokens
    print(f"  '{token}' → '{VOCAB[top]}'  ({p[top]*100:.1f}%)")
Epoch   0  Loss = 1.6196  Korrekt = 1/4
Epoch 100  Loss = 0.0158  Korrekt = 4/4
Epoch 200  Loss = 0.0064  Korrekt = 4/4
Epoch 300  Loss = 0.0039  Korrekt = 4/4
Epoch 400  Loss = 0.0028  Korrekt = 4/4

Vorhersagen nach Training:
  'Die' → 'Hauptstadt'  (99.7%)
  'Hauptstadt' → 'von'  (99.6%)
  'von' → 'Frankreich'  (99.4%)
  'Frankreich' → 'Paris'  (99.4%)

Das ist im Kleinen exakt was ein Sprachmodell macht. Wir nehmen einen Input-Token, schicken ihn durch ein Netz, produzieren eine Wahrscheinlichkeitsverteilung über alle möglichen nächsten Tokens, vergleichen mit dem tatsächlichen nächsten Token aus dem Trainingstext, berechnen den Loss, propagieren rückwärts, updaten die Gewichte und wiederholen das Milliarden Mal.

Die Ähnlichkeit der beiden Beispiele ist kein Zufall. Der Backward Pass sieht fast identisch aus, nur die Ableitung am Ende unterscheidet sich (MSE bei XOR, Cross-Entropy bei Token-Vorhersage). Das ist einer der Gründe warum neuronale Netze so mächtig sind: dasselbe Grundgerüst funktioniert für komplett unterschiedliche Probleme. Man ändert Input-Daten, Output-Dimension und Loss-Funktion, die Maschinerie darunter bleibt aber identisch.

Was in der Praxis noch dazukommt

Was wir gebaut haben ist vanilla Gradient Descent — die Grundversion. In echten Trainings-Setups kommen mehrere Verfeinerungen dazu, die alle dasselbe Ziel haben: schneller und stabiler ans Minimum zu kommen.

Mini-Batches. Wir haben oben alle vier Datenpunkte zusammen benutzt um einen Gradient zu berechnen. Bei echten Datensätzen mit Milliarden Tokens geht das nicht, ein Batch wäre zu groß für den Speicher. Stattdessen rechnet man den Gradienten auf einer kleinen Teilmenge (z.B. 64 oder 256 Beispielen) aus, macht einen Update, nimmt die nächste Teilmenge. Das ist Stochastic Gradient Descent (SGD) bzw. Mini-Batch-SGD.

Momentum. Statt nur den aktuellen Gradienten zu benutzen, behält man einen gleitenden Durchschnitt der letzten Gradienten. Das glättet das Training und hilft über schmale Täler hinweg.

Adam. Der heute weitverbreitete Optimierer. Kombiniert Momentum mit einer pro-Parameter adaptiven Learning Rate. Parameter die häufig große Gradienten sehen, bekommen kleinere Schritte; Parameter mit selten großen Gradienten bekommen größere. Das macht Training robust auch bei sehr unterschiedlich skalierten Parametern.

Learning Rate Schedules. Die Learning Rate wird nicht konstant gehalten, sondern nach einem Plan verändert, typischerweise zu Beginn hoch, dann allmählich abfallen. Am Anfang will man schnell vorankommen, am Ende feinjustieren.

Nichts davon ändert das Grundprinzip. Der Kern bleibt: Forward Pass rechnen, Loss bestimmen, Backward Pass für alle Gradienten, Gewichte updaten. Was dazukommt sind Tricks um diesen Kern effizienter zu machen.

Was noch fehlt

Wir haben jetzt alles um ein Netz zu trainieren. Embedding, Forward Pass, Loss, Backpropagation, Update, der komplette Kreislauf ist da. Mit diesem Werkzeugkasten könnten wir im Prinzip ein Sprachmodell bauen.

Nur hat das Modell das wir bisher gebaut haben ein fundamentales Problem: Es sieht immer nur ein Token. Input ist ein Embedding-Vektor, Output ist eine Vorhersage für das nächste Token. Der Kontext, all die Tokens die vorher kamen, ist nicht Teil der Eingabe.

Sprache funktioniert aber genau durch Kontext. „Ich ging zur Bank" und „die Bank am See" bedeuten verschiedenes, und der Unterschied ergibt sich ausschließlich aus den umgebenden Tokens. Ein Modell das nur ein Token zur Zeit sieht, kann solche Kontextabhängigkeiten nicht abbilden.

Die nächste Frage ist also: wie packt man Kontext in das Modell? Die erste Antwort die die Deep-Learning-Community gefunden hat, waren Rekurrente Neuronale Netze (RNNs), Netze die einen internen Zustand haben der sich mit jedem Token aktualisiert. Damit beginnt Artikel 5.

Alle Artikel der Serie

  1. Das nächste Wort — wie Sprachmodelle funktionieren
  2. Wörter als Punkte im Raum — was Embeddings wirklich sind
  3. Neuronale Netze von Grund auf
  4. Backpropagation — wie ein Modell lernt ← dieser Artikel
  5. Kontext und RNNs — warum Reihenfolge zählt (erscheint demnächst)
  6. Attention — der Mechanismus der alles veränderte (erscheint demnächst)
  7. Der Transformer — die vollständige Architektur (erscheint demnächst)
  8. Fine-Tuning — vom Basismodell zum Assistenten (erscheint demnächst)

Serie: Wie LLMs wirklich funktionieren · rotecodefraktion.de