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, alsoself.grad += unbroadcast(out.grad, self.data.shape). Fürotheranalog.*:selfbekommtother.data * out.grad,otherbekommtself.data * out.grad. Beides durchunbroadcastschicken.@:self.grad += out.grad @ other.data.Tundother.grad += self.data.T @ out.grad. Wenn du unsicher bist, welche Reihenfolge stimmt: Es gibt nur eine, bei der die Formen aufgehen.backward(): Listetopoplussetgegen doppelte Besuche, rekursiv erst die Kinder, dann sich selbst anhängen. Danachself.grad = np.ones_like(self.data)als Saat undfor 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:
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)