25 min

Autograd, das Herzstück

Ableiten ist Buchhaltung, kein Zauber.

Meilenstein Die Ableitung von x²+x an der Stelle 3 ergibt automatisch 7.

Dieses Kapitel steht an zweiter Stelle, obwohl du das Gebaute erst ab Schritt 05 wirklich benutzt. Der Grund ist einfach: Schichten, Loss, Optimizer und Trainingsschleife sind allesamt nur dann etwas wert, wenn Gradienten durch sie hindurchlaufen. Autograd ist nicht ein Bauteil unter acht, sondern das Fundament, auf dem die anderen sieben stehen. Wer es überspringt, baut den Rest auf Sand.

Und es ist kürzer, als sein Ruf vermuten lässt. Was du gleich schreibst, sind etwa vierzig Zeilen.

Das Ziel

Ableiten ist Buchhaltung, kein Zauber. Der Trick besteht aus zwei Teilen, und beide sind unspektakulär:

Erstens: mitschreiben. Jedes Rechenergebnis merkt sich, aus welchen Eingaben es entstanden ist und mit welcher Operation. Aus einer Rechnung wird so ganz nebenbei ein Graph - bei uns hängt er einfach als _children an jedem Tensor.

Zweitens: rückwärts laufen. Jede Operation weiß, wie sie einen eingehenden Gradienten auf ihre Eingaben verteilt. Bei + geht er unverändert an beide Seiten. Bei * bekommt jede Seite ihn mit dem Wert der anderen multipliziert (die Produktregel). Bei @ sind es zwei Matrixprodukte, jeweils mit einer Transponierten.

Mehr ist die Kettenregel nicht: erst die Reihenfolge umdrehen, dann jedes Glied seinen Anteil weitergeben lassen. Wer das einmal getippt hat, sieht bei loss.backward() nie wieder eine Blackbox.

Zero Magic in diesem Schritt

Hier gilt die Regel des Kurses am schärfsten: NumPy ist erlaubt, aber torch, jax, tinygrad und vor allem autograd sind verboten. Die letzte Bibliothek heißt nicht zufällig so wie dieses Kapitel - sie würde dir genau die eine Einsicht abnehmen, für die der ganze Kurs existiert. NumPy darf multiplizieren und transponieren; die Ableitungen schreibst du.

Die Aufgabe

meintorch/tensor.py wird erweitert. Neu sind grad, _children, _backward und die Methode backward(). Die drei Operatoren behalten ihre Vorwärtsrechnung aus Kapitel 01 und bekommen je einen Rückwärtsschritt dazu.

import numpy as np


def as_tensor(x):
    return x if isinstance(x, Tensor) else Tensor(x)


def unbroadcast(grad, shape):
    """Rechnet einen Gradienten auf die Form zurück, aus der er gebroadcastet wurde.

    Reine Klempnerei, deshalb geschenkt: Wenn NumPy beim Vorwärtsrechnen eine
    Zeile über einen ganzen Stapel kopiert hat, müssen die Gradienten des
    Stapels beim Rückwärtsrechnen wieder aufsummiert werden.
    """
    while grad.ndim > len(shape):
        grad = grad.sum(axis=0)
    for axis, size in enumerate(shape):
        if size == 1 and grad.shape[axis] != 1:
            grad = grad.sum(axis=axis, keepdims=True)
    return grad


class Tensor:
    def __init__(self, data, _children=(), _op=""):
        self.data = np.asarray(data, dtype=np.float64)
        self.grad = np.zeros_like(self.data)
        self._children = tuple(_children)
        self._op = _op
        self._backward = lambda: None

    @property
    def shape(self):
        return self.data.shape

    def __repr__(self):
        return f"Tensor({self.data})"

    def zero_grad(self):
        self.grad = np.zeros_like(self.data)

    def __add__(self, other):
        other = as_tensor(other)
        out = Tensor(self.data + other.data, (self, other), "+")

        def _backward():
            # TODO(du): Wie viel von out.grad geht an self, wie viel an other?
            raise NotImplementedError

        out._backward = _backward
        return out

    def __mul__(self, other):
        other = as_tensor(other)
        out = Tensor(self.data * other.data, (self, other), "*")

        def _backward():
            # TODO(du): Produktregel - der Partner ist der lokale Faktor.
            raise NotImplementedError

        out._backward = _backward
        return out

    def __matmul__(self, other):
        other = as_tensor(other)
        out = Tensor(self.data @ other.data, (self, other), "@")

        def _backward():
            # TODO(du): zwei Matrixprodukte, jeweils mit einer Transponierten.
            raise NotImplementedError

        out._backward = _backward
        return out

    # Bequemlichkeit, geschenkt: alles bloße Ableitungen von + und *
    def __neg__(self):
        return self * -1.0

    def __sub__(self, other):
        return self + (-as_tensor(other))

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def backward(self):
        # TODO(du): topologische Reihenfolge aufbauen, den Saatgradienten
        # setzen, rückwärts durchlaufen und jedes _backward() aufrufen.
        raise NotImplementedError

Hinweis 1 - die Richtung

Für die drei _backward-Funktionen: out.grad ist die Frage „wie stark verändert sich das Endergebnis, wenn sich dieser Wert ändert?“. Deine Aufgabe ist, diese Frage an self und other weiterzureichen - jeweils multipliziert mit dem, was die Operation lokal tut.

Für backward(): Du darfst ein _backward erst aufrufen, wenn out.grad vollständig ist. Das heißt, jeder Knoten muss nach allen kommen, die von ihm abhängen. Eine Tiefensuche über _children liefert genau die umgekehrte Reihenfolge - also einmal umdrehen.

Hinweis 2 - konkreter

  • +: der Gradient geht unverändert an beide Seiten, also self.grad += unbroadcast(out.grad, self.data.shape). Für other analog.
  • *: self bekommt other.data * out.grad, other bekommt self.data * out.grad. Beides durch unbroadcast schicken.
  • @: self.grad += out.grad @ other.data.T und other.grad += self.data.T @ out.grad. Wenn du unsicher bist, welche Reihenfolge stimmt: Es gibt nur eine, bei der die Formen aufgehen.
  • backward(): Liste topo plus set gegen doppelte Besuche, rekursiv erst die Kinder, dann sich selbst anhängen. Danach self.grad = np.ones_like(self.data) als Saat und for t in reversed(topo): t._backward().
Lösung anzeigen
    # in __add__
        def _backward():
            self.grad += unbroadcast(out.grad, self.data.shape)
            other.grad += unbroadcast(out.grad, other.data.shape)

    # in __mul__
        def _backward():
            self.grad += unbroadcast(other.data * out.grad, self.data.shape)
            other.grad += unbroadcast(self.data * out.grad, other.data.shape)

    # in __matmul__
        def _backward():
            self.grad += out.grad @ other.data.T
            other.grad += self.data.T @ out.grad

    def backward(self):
        topo, visited = [], set()

        def visit(t):
            if t in visited:
                return
            visited.add(t)
            for child in t._children:
                visit(child)
            topo.append(t)

        visit(self)
        self.grad = np.ones_like(self.data)
        for t in reversed(topo):
            t._backward()

Im Goal-Modus

Der autonome Durchlauf baut denselben Graphen und prüft ihn härter, als ein einzelner Meilenstein-Test es täte: Er vergleicht die analytischen Gradienten gegen eine numerische Ableitung. Der Auftrag:

Bau-Prompt
Erweitere `meintorch/tensor.py` um Reverse-Mode-Autograd, ohne externe
Bibliotheken (NumPy erlaubt, torch/jax/tinygrad/autograd verboten).
Jeder Tensor bekommt `grad`, seine Kinder und eine `_backward`-Closure;
`backward()` baut die topologische Reihenfolge, setzt den Saatgradienten auf
Eins und läuft rückwärts. Gradienten werden akkumuliert (+=), nie überschrieben.
Decke Addition, elementweise Multiplikation und Matrixprodukt ab, inklusive
Broadcasting.
Schreibe `tests/test_02_autograd.py`: die Ableitung von x²+x an der Stelle 3
muss automatisch 7 ergeben. Prüfe zusätzlich alle Gradienten gegen eine
zentrale numerische Differenz (eps 1e-6, Toleranz 1e-5).
Führe `python -m pytest` aus, bis alles grün ist.

Der Meilenstein-Test

tests/test_02_autograd.py. Der erste Test ist der Meilenstein des Kapitels: Die Ableitung von x² + x ist 2x + 1, an der Stelle 3 also 7 - und niemand hat das irgendwo hingeschrieben. Es fällt aus der Buchhaltung heraus.

import numpy as np

from meintorch.tensor import Tensor


def test_ableitung_von_x_quadrat_plus_x():
    x = Tensor(3.0)
    y = x * x + x
    y.backward()
    assert np.allclose(x.grad, 7.0)


def test_gradienten_sammeln_sich_statt_zu_ueberschreiben():
    x = Tensor(2.0)
    y = x + x
    y.backward()
    assert np.allclose(x.grad, 2.0)


def test_matmul_gradienten_haben_die_form_ihrer_eingaben():
    a = Tensor([[1.0, 2.0], [3.0, 4.0]])
    b = Tensor([[5.0], [6.0]])
    out = a @ b
    out.backward()
    assert a.grad.shape == a.shape
    assert b.grad.shape == b.shape
    assert np.allclose(b.grad, [[4.0], [6.0]])


def test_zero_grad_raeumt_auf():
    x = Tensor(3.0)
    (x * x).backward()
    x.zero_grad()
    assert np.allclose(x.grad, 0.0)