12 min

Der Loss (MSE)

Lernen heißt, eine einzige Zahl kleiner zu machen.

Meilenstein Der Loss ist ein Skalar, backward läuft durch.

Bis hierhin kann dein Netz rechnen, aber es hat keine Meinung darüber, ob das Ergebnis gut war. Diese Meinung ist der Loss: eine einzige Zahl, die misst, wie weit die Vorhersage danebenlag. Klein ist gut, null ist perfekt.

Dass es genau eine Zahl sein muss, ist keine Konvention, sondern eine Notwendigkeit. Der Rückwärtslauf braucht einen Startwert, und der ist nur dann eindeutig, wenn oben ein Skalar steht. Bei einem Vektor mit zehn Ausgaben gäbe es zehn Startpunkte und keine Antwort auf die Frage, welcher gilt.

Das Ziel

Lernen heißt, eine einzige Zahl kleiner zu machen. Das ist die ganze Zielsetzung eines Trainings, und sie ist erstaunlich reduktionistisch: Ob ein Modell Katzen erkennt oder Sätze übersetzt, entscheidet sich daran, welche Zahl man definiert und in welche Richtung man sie drückt.

Der mittlere quadratische Fehler ist die einfachste dieser Zahlen:

  1. Differenz zwischen Vorhersage und Zielwert bilden.
  2. Quadrieren - dadurch zählen Abweichungen nach oben und unten gleich, und große Fehler wiegen überproportional schwer.
  3. Über alle Elemente mitteln.

Drei Schritte, und alle drei bestehen aus Operationen, die dein Tensor schon beherrscht. Neu ist nur eine: das Zusammenfassen vieler Zahlen zu einer, also sum. Auch sie braucht einen Rückwärtsschritt, und er ist der einfachste im ganzen Kurs - jedes Element der Eingabe hat zur Summe genau eins beigetragen.

Zero Magic in diesem Schritt

NumPy ist erlaubt, torch, jax, tinygrad und autograd sind verboten. Auch np.mean auf dem rohen Array wäre hier ein Fehler - nicht weil NumPy verboten wäre, sondern weil eine Rechnung außerhalb der Tensor-Klasse aus dem Graphen herausfällt. Was nicht mitgeschrieben wird, kann nicht rückwärts laufen, und der Loss wäre stumm.

Die Aufgabe

Zuerst eine neue Methode in meintorch/tensor.py:

    def sum(self):
        out = Tensor(self.data.sum(), (self,), "sum")

        def _backward():
            # TODO(du): Jedes Element hat zur Summe gleich viel beigetragen.
            raise NotImplementedError

        out._backward = _backward
        return out

Dann meintorch/loss.py:

from .tensor import as_tensor


def mse_loss(pred, target):
    """Mittlerer quadratischer Fehler als skalarer Tensor."""
    target = as_tensor(target)
    # TODO(du): Differenz, quadrieren, summieren, durch die Anzahl teilen.
    # Alles über Tensor-Operationen, damit der Graph erhalten bleibt.
    raise NotImplementedError

Hinweis 1 - die Richtung

Für sum(): Wenn die Summe sich um eins ändert, hat jedes einzelne Summenglied daran denselben Anteil. Der eingehende Gradient ist ein Skalar und muss auf die Form der Eingabe verteilt werden.

Für mse_loss: Das Quadrieren braucht keinen **-Operator. Du hast bereits etwas, das zwei Tensoren elementweise multipliziert - und zwei gleiche Tensoren sind auch zwei Tensoren. Das Teilen durch die Anzahl ist eine Multiplikation mit dem Kehrwert, und den kennt dein __mul__ als Skalar bereits.

Hinweis 2 - konkreter

  • sum()-Rückwärtsschritt: self.grad += np.ones_like(self.data) * out.grad.
  • mse_loss: diff = pred - target (das __sub__ von Kapitel 02), n = diff.data.size, danach (diff * diff).sum() * (1.0 / n).
  • Die Anzahl n darfst du direkt am NumPy-Array ablesen. Sie ist eine Konstante, kein Zwischenergebnis, deshalb muss sie nicht in den Graphen.
Lösung anzeigen
    # in Tensor.sum
        def _backward():
            self.grad += np.ones_like(self.data) * out.grad


# in loss.py
def mse_loss(pred, target):
    target = as_tensor(target)
    diff = pred - target
    n = diff.data.size
    return (diff * diff).sum() * (1.0 / n)

Im Goal-Modus

Der autonome Durchlauf prüft nicht nur, dass etwas herauskommt, sondern dass die richtige Zahl herauskommt - der Gradient des MSE ist von Hand nachrechenbar und damit ein harter Prüfstein:

Bau-Prompt
Erweitere `meintorch/tensor.py` um eine Methode `sum()`, die alle Elemente zu
einem Skalar zusammenfasst und ihren Rückwärtsschritt mitbringt. Lege
`meintorch/loss.py` mit `mse_loss(pred, target)` an: mittlerer quadratischer
Fehler, ausschließlich über Tensor-Operationen, damit der Graph erhalten bleibt.
Zwingend: NumPy erlaubt, torch/jax/tinygrad/autograd verboten. Keine Rechnung
auf rohen Arrays außerhalb der Tensor-Klasse.
Schreibe `tests/test_04_loss.py`: Der Loss muss ein Skalar sein (shape ()), und
`backward()` muss von ihm aus durch ein Netz aus zwei Linear-Schichten mit ReLU
laufen. Prüfe zusätzlich den Gradienten am Vorhersage-Tensor gegen die
analytische Formel 2*(pred-target)/n.
Führe `python -m pytest` aus, bis alles grün ist.

Der Meilenstein-Test

tests/test_04_loss.py. Zwei Dinge müssen stimmen: Der Loss ist ein Skalar, und backward() läuft von ihm aus bis in jedes Gewicht durch.

import numpy as np

from meintorch.loss import mse_loss
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor


def test_loss_ist_ein_skalar():
    loss = mse_loss(Tensor([[1.0], [2.0]]), Tensor([[0.0], [0.0]]))
    assert loss.shape == ()
    assert np.isclose(loss.data, 2.5)


def test_perfekte_vorhersage_hat_loss_null():
    t = Tensor([[3.0, -1.0]])
    assert np.isclose(mse_loss(t, Tensor([[3.0, -1.0]])).data, 0.0)


def test_gradient_am_vorhersage_tensor():
    pred = Tensor([[1.0], [2.0]])
    loss = mse_loss(pred, Tensor([[0.0], [0.0]]))
    loss.backward()
    # 2 * (pred - target) / n, hier also 2 * [1, 2] / 2
    assert np.allclose(pred.grad, [[1.0], [2.0]])


def test_backward_laeuft_durch_das_ganze_netz():
    net = Sequential(Linear(3, 4, seed=0), ReLU(), Linear(4, 1, seed=1))
    x = Tensor(np.random.default_rng(7).normal(size=(5, 3)))
    y = Tensor(np.random.default_rng(8).normal(size=(5, 1)))

    loss = mse_loss(net(x), y)
    loss.backward()

    assert loss.shape == ()
    for p in net.parameters():
        assert p.grad.shape == p.data.shape