12 min

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:

Bau-Prompt
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 == ()