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:
- Differenz zwischen Vorhersage und Zielwert bilden.
- Quadrieren - dadurch zählen Abweichungen nach oben und unten gleich, und große Fehler wiegen überproportional schwer.
- Ü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
ndarfst 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:
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