25 min

Der Beweis

Es läuft, und zwar ohne torch.

Meilenstein Der selbst erzeugte Monde-Datensatz wird zu über 90 Prozent richtig getrennt.

Sieben Kapitel lang hast du Bauteile geschrieben und jedes einzeln grün getestet. Jetzt kommt der Test, der zählt: eine Aufgabe, die keine gerade Linie lösen kann, gerechnet von Anfang bis Ende mit deinem eigenen Framework.

Die Aufgabe sind zwei ineinandergreifende Halbmonde. Sie ist das Standardbeispiel für „nichtlinear trennbar“: Keine Gerade schneidet die beiden Klassen auseinander, ein Netz mit einer ReLU-Schicht in der Mitte dagegen schafft es mühelos. Wenn dein Framework das kann, kann es das, wofür Frameworks gebaut werden.

Das Ziel

Es läuft, und zwar ohne torch. Dieser Satz ist die ganze Pointe des Kurses, und er ist überprüfbar: Der Meilenstein-Test dieses Kapitels durchsucht deine eigenen Dateien nach import torch und schaut nach, ob das Modul irgendwo im Prozess geladen wurde. Beides muss leer bleiben.

Der Datensatz kommt aus demselben Grund nicht aus einer Bibliothek. Der naheliegende Griff wäre sklearn.datasets.make_moons - eine Zeile, fertig. Aber dann stünde am Ende dieses Kurses ein pip install scikit-learn, und die Halbmonde wären das einzige Stück der Strecke, das dir jemand anderes gemacht hat. Zwei verschobene Halbkreise plus Rauschen sind zehn Zeilen NumPy. Zero Magic gilt auch für die Daten.

Zero Magic in diesem Schritt

NumPy ist erlaubt, torch, jax, tinygrad und autograd sind verboten - und in diesem Kapitel zusätzlich scikit-learn und jede andere Bibliothek, die dir Datensätze, Aufteilungen oder Metriken abnimmt. Datensatz, Split und Genauigkeit schreibst du selbst; zusammen sind es keine zwanzig Zeilen.

Die Aufgabe

Zwei Dateien. Zuerst der Datensatz, meintorch/datasets.py:

import numpy as np


def make_moons(n=400, noise=0.15, seed=0):
    """Zwei ineinandergreifende Halbmonde. Zehn Zeilen NumPy, keine Bibliothek."""
    rng = np.random.default_rng(seed)
    n_oben = n // 2
    n_unten = n - n_oben
    # TODO(du): Der obere Mond ist der Einheits-Halbkreis (cos, sin) für
    # Winkel von 0 bis pi. Der untere ist derselbe Halbkreis, punkt-
    # gespiegelt und nach rechts unten verschoben, sodass beide ineinander
    # greifen. Beide Klassen stapeln, Labels 0 und 1 als Spalte anhängen,
    # zum Schluss Rauschen mit `rng.normal` daraufaddieren.
    raise NotImplementedError

Dann das Beweis-Skript, beweis.py, im Wurzelverzeichnis neben meintorch/:

import numpy as np

from meintorch.data import DataLoader
from meintorch.datasets import make_moons
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor
from meintorch.train import fit


def genauigkeit(model, x, y):
    """Anteil richtig geratener Klassen. Schwelle 0,5."""
    # TODO(du): vorhersagen, an 0,5 schwellen, mit y vergleichen, mitteln.
    raise NotImplementedError


def main():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    # TODO(du): 80/20 in Training und Test teilen - einmal permutieren,
    # dann schneiden. Ein Netz 2 -> 16 -> 16 -> 1 mit ReLU dazwischen bauen,
    # einen DataLoader anlegen, mit `fit` trainieren und am Ende beide
    # Genauigkeiten ausgeben.
    raise NotImplementedError


if __name__ == "__main__":
    main()

Hinweis 1 - die Richtung

Für die Monde: Ein Halbkreis ist (cos t, sin t) für t von 0 bis pi. Der zweite Mond ist derselbe Bogen, andersherum und versetzt - probier (1 - cos t, 0.5 - sin t). Wenn du unsicher bist, ob es passt: Die beiden Wolken sollen einander umgreifen wie zwei Klammern, nicht nebeneinanderliegen. Ein Streudiagramm sagt in fünf Sekunden, ob die Form stimmt.

Das Rauschen kommt zuletzt, auf beide Monde zusammen. Ohne Rauschen lägen alle Punkte exakt auf zwei Kurven, und die Aufgabe wäre langweilig statt leicht.

Für den Split: np.random.default_rng(1).permutation(len(x)) gibt dir eine gemischte Indexliste. Die ersten 80 Prozent sind Training, der Rest ist Test. Der Test darf beim Training nirgends vorkommen - sonst misst du auswendig gelernt statt verstanden.

Für die Genauigkeit: pred > 0.5 und y > 0.5 sind beides Boolean-Arrays. Ihr elementweiser Vergleich, gemittelt, ist die Trefferquote.

Hinweis 2 - konkreter

  • Die Monde:

        winkel_oben = np.linspace(0.0, np.pi, n_oben)
        winkel_unten = np.linspace(0.0, np.pi, n_unten)
        oben = np.stack([np.cos(winkel_oben), np.sin(winkel_oben)], axis=1)
        unten = np.stack(
            [1.0 - np.cos(winkel_unten), 0.5 - np.sin(winkel_unten)], axis=1
        )
        x = np.concatenate([oben, unten])
        y = np.concatenate([np.zeros(n_oben), np.ones(n_unten)]).reshape(-1, 1)
        x += rng.normal(scale=noise, size=x.shape)
        return x, y
    
  • Das Netz: Sequential(Linear(2, 16, seed=0), ReLU(), Linear(16, 16, seed=1), ReLU(), Linear(16, 1, seed=2)). Eine Schicht weniger tut es auch, zwei ReLU-Schichten haben mehr Luft.

  • Training: fit(netz, DataLoader(x_train, y_train, batch_size=32, seed=2), epochs=60, lr=0.1). Mit diesen Werten landet die Testgenauigkeit typisch jenseits von 97 Prozent - die geforderten 90 sind mit Absicht großzügig.

  • Genauigkeit: float(((model(Tensor(x)).data > 0.5) == (y > 0.5)).mean()).

Lösung anzeigen
# in datasets.py
def make_moons(n=400, noise=0.15, seed=0):
    rng = np.random.default_rng(seed)
    n_oben = n // 2
    n_unten = n - n_oben
    winkel_oben = np.linspace(0.0, np.pi, n_oben)
    winkel_unten = np.linspace(0.0, np.pi, n_unten)
    oben = np.stack([np.cos(winkel_oben), np.sin(winkel_oben)], axis=1)
    unten = np.stack(
        [1.0 - np.cos(winkel_unten), 0.5 - np.sin(winkel_unten)], axis=1
    )
    x = np.concatenate([oben, unten])
    y = np.concatenate([np.zeros(n_oben), np.ones(n_unten)]).reshape(-1, 1)
    x += rng.normal(scale=noise, size=x.shape)
    return x, y


# in beweis.py
def genauigkeit(model, x, y):
    pred = model(Tensor(x)).data
    return float(((pred > 0.5) == (y > 0.5)).mean())


def main():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    idx = np.random.default_rng(1).permutation(len(x))
    schnitt = int(0.8 * len(x))
    train_idx, test_idx = idx[:schnitt], idx[schnitt:]

    netz = Sequential(
        Linear(2, 16, seed=0),
        ReLU(),
        Linear(16, 16, seed=1),
        ReLU(),
        Linear(16, 1, seed=2),
    )
    loader = DataLoader(x[train_idx], y[train_idx], batch_size=32, seed=2)
    verlauf = fit(netz, loader, epochs=60, lr=0.1)

    print("Loss zuerst %.4f, zuletzt %.4f" % (verlauf[0], verlauf[-1]))
    print("Training %.3f" % genauigkeit(netz, x[train_idx], y[train_idx]))
    print("Test     %.3f" % genauigkeit(netz, x[test_idx], y[test_idx]))

Im Goal-Modus

Der letzte autonome Auftrag ist der einzige, der die ganze Strecke prüft:

Bau-Prompt
Lege `meintorch/datasets.py` an mit `make_moons(n=400, noise=0.15, seed=0)`:
zwei ineinandergreifende Halbmonde aus reinem NumPy, Rückgabe (x mit Form
(n, 2), y mit Form (n, 1) und Labels 0/1), Klassen gleich groß. Schreibe
`beweis.py`, das damit einen 80/20-Split bildet, ein Netz 2-16-16-1 mit ReLU
über `fit` und einen DataLoader trainiert und Trainings- wie Testgenauigkeit
ausgibt.
Zwingend: NumPy erlaubt, torch/jax/tinygrad/autograd verboten, zusätzlich kein
scikit-learn - Datensatz, Split und Metrik selbst schreiben.
Schreibe `tests/test_08_beweis.py`: Die Monde müssen zwei gleich große Klassen
in der richtigen Form ergeben, keine Datei unter `meintorch/` darf `import
torch` enthalten und `torch` nicht in `sys.modules` auftauchen, und das
trainierte Netz muss auf dem Testteil über 90 Prozent Genauigkeit erreichen.
Führe `python -m pytest` aus, bis alles grün ist.

Der Meilenstein-Test

tests/test_08_beweis.py, aus dem Wurzelverzeichnis heraus ausgeführt. Der zweite Test ist der Titel des Kurses als Zusicherung, der dritte ist der Meilenstein: über 90 Prozent auf Daten, die das Netz nie gesehen hat.

import pathlib
import sys

import numpy as np

from meintorch.data import DataLoader
from meintorch.datasets import make_moons
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor
from meintorch.train import fit


def test_die_monde_sind_zwei_gleich_grosse_klassen():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    assert x.shape == (600, 2)
    assert y.shape == (600, 1)
    assert int(y.sum()) == 300


def test_kein_torch_im_spiel():
    assert "torch" not in sys.modules
    for datei in pathlib.Path("meintorch").glob("*.py"):
        assert "import torch" not in datei.read_text()


def test_das_netz_trennt_die_monde_zu_ueber_90_prozent():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    idx = np.random.default_rng(1).permutation(len(x))
    schnitt = int(0.8 * len(x))
    train_idx, test_idx = idx[:schnitt], idx[schnitt:]

    netz = Sequential(
        Linear(2, 16, seed=0),
        ReLU(),
        Linear(16, 16, seed=1),
        ReLU(),
        Linear(16, 1, seed=2),
    )
    loader = DataLoader(x[train_idx], y[train_idx], batch_size=32, seed=2)
    verlauf = fit(netz, loader, epochs=60, lr=0.1)
    assert verlauf[-1] < verlauf[0] / 5

    pred = netz(Tensor(x[test_idx])).data
    genauigkeit = float(((pred > 0.5) == (y[test_idx] > 0.5)).mean())
    assert genauigkeit > 0.9

Was du jetzt hast

Ein Verzeichnis mit sieben Dateien und keiner Abhängigkeit außer NumPy. Darin: ein Tensor, der rechnet und sich merkt, woraus er entstanden ist; ein Autograd, das die Kettenregel als Buchhaltung erledigt; Schichten, ein Loss, ein Optimizer; eine Schleife, die daraus Training macht, und ein DataLoader, der sie füttert.

Der Unterschied zu vorher ist nicht, dass du jetzt Netze trainieren kannst - das konntest du mit drei Zeilen PyTorch schon vorher. Der Unterschied ist, dass loss.backward() für dich keine Blackbox mehr ist. Wenn ein Training nicht konvergiert, hast du ab jetzt Kandidaten statt Ratlosigkeit: vergessenes zero_grad(), zu große Schrittweite, ein Gradient, der irgendwo aus dem Graphen fällt, weil jemand auf einem rohen Array gerechnet hat.

Und der Weg dahin war derselbe wie bei den Agenten nebenan: einmal selbst bauen. Die fünf Zeilen aus Kapitel 06 sind dieselbe Figur wie die Schleife im Baustein Tools und Loop - erst wiederholen, dann wird es klug.