Die Trainingsschleife
Jetzt lernt es wirklich.
Meilenstein Der Loss fällt über die Epochen.
Jetzt lernt es wirklich. Alle Bauteile liegen bereit, und was sie zusammenhält, ist eine Schleife aus fünf Zeilen: vorhersagen, Fehler messen, Gradienten löschen, rückwärts laufen, Schritt machen. Danach von vorn.
Das ist kein vereinfachtes Lehrbeispiel. Genau diese fünf Zeilen stehen in jedem PyTorch-Skript der Welt, nur mit anderen Namen davor. Wer schon einmal fremden Trainingscode gelesen hat, erkennt sie sofort - und wer sie einmal selbst getippt hat, liest fremden Trainingscode ab sofort anders.
Das Ziel
Jetzt lernt es wirklich. Und die Einsicht dabei ist, wie wenig dafür nötig ist: Der Fortschritt steckt nicht in einem cleveren Schritt, sondern in der Wiederholung eines dummen Schritts. Zweihundert Mal ein kleines Stück bergab, und ein Netz, das anfangs geraten hat, trifft.
Die Reihenfolge in der Schleife ist nicht beliebig:
- Vorhersage rechnen - baut den Graphen dieser Runde auf.
- Loss rechnen - die eine Zahl an der Spitze des Graphen.
zero_grad()- die Gradienten der letzten Runde wegräumen.backward()- Gradienten von der Spitze in jedes Gewicht verteilen.step()- jedes Gewicht ein Stück bergab schieben.
Schritt 3 muss zwischen 2 und 4 stehen (oder direkt nach 5, das ist dieselbe
Stelle in der nächsten Runde). Steht er hinter backward(), löscht er genau
die Gradienten, die er gerade errechnet hat, und das Netz bewegt sich nie.
Fehlt er ganz, summieren sich die Gradienten aller bisherigen Runden auf und
das Training entgleist nach wenigen Epochen.
Die Brücke: Trainingsschleife und Agenten-Schleife
Dieser Kurs steht auf einer Plattform über Agenten, und dieses Kapitel ist der Grund dafür. Stell die beiden Schleifen nebeneinander:
Training: Vorhersage rechnen → Fehler messen → Gewichte anpassen → von vorn.
Agent: Modell fragen → Werkzeug aufrufen → Ergebnis zurückgeben → von vorn.
Beide Male ist der Kern eine Wiederholung, kein Geistesblitz. Beide Male wird
ein Zustand von Runde zu Runde weitergereicht - dort die Gewichte, hier die
Nachrichtenliste. Beide Male steckt die eigentliche Arbeit nicht im klugen Teil,
sondern in der Klempnerei drumherum: Daten hinein, Zustand weiterreichen,
Abbruchbedingung. Und beide Male sieht das, was von außen wie eine Blackbox
aussieht, von innen aus wie ein for-Block.
Der Baustein Tools und Loop beschreibt die zweite Schleife im Detail. Wenn du die erste einmal selbst geschrieben hast, liest er sich wie ein alter Bekannter - das ist dasselbe „Verstehen durch Bauen“, mit dem hier alles erklärt wird, nur auf ein Nachbarthema angewandt.
Zero Magic in diesem Schritt
NumPy ist erlaubt, torch, jax, tinygrad und autograd sind verboten.
In diesem Kapitel importierst du ohnehin nichts Fremdes mehr: Alles, was die
Schleife braucht, hast du selbst gebaut. Das ist der eigentliche Test des
Kapitels.
Die Aufgabe
Eine neue Datei, meintorch/train.py:
from .loss import mse_loss
from .optim import SGD
def train(model, x, y, epochs=100, lr=0.05, loss_fn=mse_loss):
"""Trainiert das Modell auf dem ganzen Datensatz und gibt den Loss je Epoche zurück."""
opt = SGD(model.parameters(), lr=lr)
history = []
for _ in range(epochs):
# TODO(du): fünf Zeilen - vorhersagen, Fehler messen, Gradienten
# löschen, rückwärts laufen, Schritt machen. Danach den Loss als
# gewöhnliche Zahl an `history` anhängen.
raise NotImplementedError
return history
Hinweis 1 - die Richtung
Die fünf Zeilen sind Aufrufe von Dingen, die du bereits hast: das Modell selbst,
loss_fn, opt.zero_grad(), loss.backward(), opt.step().
Achte auf die Reihenfolge von zero_grad() und backward(). Wenn dein Loss
sich überhaupt nicht bewegt, stehen die beiden vermutlich falsch herum.
Für history: loss.data ist ein NumPy-Skalar. Er hält den ganzen Graphen der
Runde am Leben, solange du ihn aufhebst - float(...) löst diese Bindung und
kostet nichts.
Hinweis 2 - konkreter
loss = loss_fn(model(x), y)
opt.zero_grad()
loss.backward()
opt.step()
history.append(float(loss.data))
Wenn der Loss fällt und dann in nan kippt, ist lr zu groß. Halbiere sie.
Lösung anzeigen
def train(model, x, y, epochs=100, lr=0.05, loss_fn=mse_loss):
opt = SGD(model.parameters(), lr=lr)
history = []
for _ in range(epochs):
loss = loss_fn(model(x), y)
opt.zero_grad()
loss.backward()
opt.step()
history.append(float(loss.data))
return history
Im Goal-Modus
Der autonome Durchlauf muss beweisen, dass wirklich gelernt wird - nicht, dass die Funktion durchläuft:
Lege `meintorch/train.py` an mit `train(model, x, y, epochs=100, lr=0.05, loss_fn=mse_loss)`. Die Funktion legt einen SGD über `model.parameters()` an und wiederholt je Epoche: Vorhersage, Loss, `zero_grad()`, `backward()`, `step()`. Sie gibt die Loss-Werte als Liste gewöhnlicher floats zurück. Zwingend: NumPy erlaubt, torch/jax/tinygrad/autograd verboten. `zero_grad()` steht vor `backward()`, damit sich Gradienten nicht über Epochen aufsummieren. Schreibe `tests/test_06_trainingsschleife.py`: Auf einer Regressionsaufgabe muss der Loss über 200 Epochen um mindestens den Faktor 10 fallen, alle Werte müssen endlich sein, ein zweiter Aufruf muss weiterlernen statt zu entgleisen, und `epochs=0` darf keinen Parameter verändern. Führe `python -m pytest` aus, bis alles grün ist.
Der Meilenstein-Test
tests/test_06_trainingsschleife.py. Der Meilenstein ist der erste Test: Der
Loss am Ende ist mindestens zehnmal kleiner als am Anfang. Der dritte Test ist
der, der ein vergessenes zero_grad() auffliegen lässt - ohne Aufräumen wird
ein zweiter Lauf nicht besser, sondern schlechter.
import numpy as np
from meintorch.loss import mse_loss
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor
from meintorch.train import train
def aufgabe():
# y = 2*x0 - x1 + 0.5, eine Aufgabe, die ein kleines Netz sicher lernt
rng = np.random.default_rng(0)
x = rng.normal(size=(64, 2))
y = 2.0 * x[:, :1] - x[:, 1:] + 0.5
netz = Sequential(Linear(2, 8, seed=0), ReLU(), Linear(8, 1, seed=1))
return netz, Tensor(x), Tensor(y)
def test_der_loss_faellt_ueber_die_epochen():
netz, x, y = aufgabe()
verlauf = train(netz, x, y, epochs=200, lr=0.05)
assert len(verlauf) == 200
assert all(np.isfinite(v) for v in verlauf)
assert verlauf[-1] < verlauf[0] / 10
def test_das_netz_sagt_am_ende_brauchbar_vorher():
netz, x, y = aufgabe()
train(netz, x, y, epochs=400, lr=0.05)
assert float(mse_loss(netz(x), y).data) < 0.05
def test_ein_zweiter_lauf_lernt_weiter_statt_zu_entgleisen():
netz, x, y = aufgabe()
erst = train(netz, x, y, epochs=50, lr=0.05)
dann = train(netz, x, y, epochs=50, lr=0.05)
assert dann[-1] < erst[-1]
def test_null_epochen_veraendern_nichts():
netz, x, y = aufgabe()
vorher = netz.layers[0].W.data.copy()
assert train(netz, x, y, epochs=0) == []
assert np.allclose(netz.layers[0].W.data, vorher)