Der Optimizer (SGD)
Der Lernschritt ist eine Zeile.
Meilenstein Ein einzelner Schritt senkt den Loss messbar.
Du hast eine Zahl, die den Fehler misst, und Gradienten, die von ihr aus in jedes Gewicht zeigen. Was fehlt, ist der Schritt, der die Gewichte anfasst. Er ist so kurz, dass man beim ersten Lesen fragt, ob das alles war: jedes Gewicht ein Stückchen entgegen seinem Gradienten schieben.
Entgegen, nicht mit ihm. Der Gradient zeigt in die Richtung, in der der Loss größer wird - er ist die Richtung des steilsten Anstiegs. Wir wollen bergab, also gehen wir rückwärts. Das Minuszeichen ist die ganze Idee des Verfahrens, und sein Name sagt es auch: Gradientenabstieg.
Das Ziel
Der Lernschritt ist eine Zeile. p.data -= lr * p.grad, für jeden
Parameter. Der Rest des Optimizers ist Verwaltung: eine Liste von Parametern
und eine Schrittweite.
Die Schrittweite lr (learning rate) ist der einzige Regler, und sie hat zwei
Fehlstellungen. Zu klein: das Training kriecht und braucht tausende Epochen. Zu
groß: der Schritt schießt über das Tal hinaus, landet auf der anderen Seite
höher als vorher, und der Loss wächst statt zu fallen. Zwischen beidem liegt
eine erstaunlich breite Zone, in der es einfach funktioniert - deshalb kommt man
mit Raten wie 0,01 oder 0,05 überraschend weit.
Zero Magic in diesem Schritt
NumPy ist erlaubt, torch, jax, tinygrad und autograd sind verboten.
Besonders torch.optim.SGD - eine Klasse, die in PyTorch keine dreißig Zeilen
hat und deren Kern du gleich in einer schreibst.
Die Aufgabe
Eine neue Datei, meintorch/optim.py:
class SGD:
"""Stochastischer Gradientenabstieg: der einfachste Optimizer, den es gibt."""
def __init__(self, parameters, lr=0.01):
self.parameters = list(parameters)
self.lr = lr
def step(self):
# TODO(du): jeden Parameter ein Stück entgegen seinem Gradienten
# schieben. Am rohen Array, nicht über Tensor-Operationen.
raise NotImplementedError
def zero_grad(self):
# TODO(du): alle Gradienten auf null zurücksetzen. Ohne das würde
# sich der nächste Rückwärtslauf auf den alten draufsetzen.
raise NotImplementedError
Hinweis 1 - die Richtung
step() ist eine Schleife über self.parameters mit genau einer Zeile darin.
Das Vorzeichen ist die einzige Entscheidung: Der Loss soll kleiner werden, der
Gradient zeigt dorthin, wo er größer wird.
zero_grad() ist ebenfalls eine Schleife über dieselbe Liste. Dein Tensor kann
schon, was darin passieren muss - du hast die Methode in Kapitel 02 geschrieben.
Warum zero_grad() überhaupt sein muss: Gradienten werden akkumuliert (+=,
siehe Kapitel 02). Ohne Aufräumen enthielte der Gradient in Epoche 10 die Summe
aller zehn Rückwärtsläufe, der Schritt wäre zehnmal zu groß, und das Training
entgleist. Das ist der mit Abstand häufigste Fehler in selbst gebauten
Trainingsschleifen - und übrigens auch in PyTorch-Code.
Hinweis 2 - konkreter
step():for p in self.parameters: p.data -= self.lr * p.grad.zero_grad():for p in self.parameters: p.zero_grad().list(parameters)im Konstruktor ist Absicht:model.parameters()gibt eine Liste zurück, aber ein Generator wäre nach dem ersten Durchlauf leer.
Lösung anzeigen
class SGD:
def __init__(self, parameters, lr=0.01):
self.parameters = list(parameters)
self.lr = lr
def step(self):
for p in self.parameters:
p.data -= self.lr * p.grad
def zero_grad(self):
for p in self.parameters:
p.zero_grad()
Im Goal-Modus
Der autonome Durchlauf prüft nicht nur, dass der Loss fällt, sondern auch, dass der Schritt exakt die richtige Größe hat und den Graphen in Ruhe lässt:
Lege `meintorch/optim.py` an mit einer Klasse `SGD(parameters, lr=0.01)`. `step()` schiebt jeden Parameter um `lr * grad` entgegen seinem Gradienten, und zwar in-place am NumPy-Array, damit kein neuer Graphknoten entsteht. `zero_grad()` setzt alle Gradienten zurück. Zwingend: NumPy erlaubt, torch/jax/tinygrad/autograd verboten. Kein Momentum, kein Weight Decay - der nackte Gradientenabstieg. Schreibe `tests/test_05_optimizer.py`: Ein Schritt auf einem Parameter mit bekanntem Gradienten muss exakt `lr * grad` betragen, ein Schritt nach einem Rückwärtslauf muss den Loss eines kleinen Netzes messbar senken, `zero_grad()` muss alle Gradienten leeren, und nach `step()` darf kein Parameter Kinder im Graphen haben. Führe `python -m pytest` aus, bis alles grün ist.
Der Meilenstein-Test
tests/test_05_optimizer.py. Der zweite Test ist der Meilenstein: ein einziger
Schritt, und der Loss ist kleiner als vorher. Das ist der Moment, in dem aus
Rechnerei Lernen wird.
import numpy as np
from meintorch.loss import mse_loss
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.optim import SGD
from meintorch.tensor import Tensor
def netz_und_daten():
netz = Sequential(Linear(3, 4, seed=0), ReLU(), Linear(4, 1, seed=1))
x = Tensor(np.random.default_rng(7).normal(size=(8, 3)))
y = Tensor(np.random.default_rng(8).normal(size=(8, 1)))
return netz, x, y
def test_der_schritt_ist_genau_lr_mal_gradient():
p = Tensor([[1.0, -2.0]])
p.grad = np.array([[2.0, 4.0]])
SGD([p], lr=0.1).step()
assert np.allclose(p.data, [[0.8, -2.4]])
def test_ein_schritt_senkt_den_loss():
netz, x, y = netz_und_daten()
opt = SGD(netz.parameters(), lr=0.05)
vorher = float(mse_loss(netz(x), y).data)
loss = mse_loss(netz(x), y)
opt.zero_grad()
loss.backward()
opt.step()
assert float(mse_loss(netz(x), y).data) < vorher
def test_zero_grad_raeumt_vor_dem_naechsten_schritt_auf():
netz, x, y = netz_und_daten()
opt = SGD(netz.parameters(), lr=0.05)
mse_loss(netz(x), y).backward()
opt.zero_grad()
for p in netz.parameters():
assert np.allclose(p.grad, 0.0)
def test_der_schritt_haengt_nichts_an_den_graphen():
netz, x, y = netz_und_daten()
opt = SGD(netz.parameters(), lr=0.05)
mse_loss(netz(x), y).backward()
opt.step()
for p in netz.parameters():
assert p._children == ()