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:
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.