Schichten und ReLU
Ein Netz ist eine Kette simpler Bausteine.
Meilenstein Forward und Backward laufen durch zwei Schichten.
Ein neuronales Netz sieht in Diagrammen aus wie ein Nervensystem. Im Code ist es eine Liste. Du rufst ein Objekt nach dem anderen auf, gibst jedem das Ergebnis des vorigen und bekommst am Ende eine Vorhersage. Das ist der ganze Zauber.
Zwei Bausteine reichen für ein Netz, das mehr kann als eine Gerade: eine lineare Schicht, die mit einer Gewichtsmatrix multipliziert und einen Bias addiert, und eine Aktivierung, die etwas Nichtlineares tut. Ohne die zweite wäre ein Stapel linearer Schichten mathematisch nur eine einzige - drei Matrizen hintereinander sind wieder eine Matrix.
Das Ziel
Ein Netz ist eine Kette simpler Bausteine. Jeder Baustein kann genau zwei Dinge, und du hast beide schon gebaut, ohne es zu merken:
Linear.forward ist x @ W + b. Das sind die zwei Operatoren aus Kapitel 01.
Die Rückwärtsrichtung musst du nirgends programmieren - sie fällt aus dem
Autograd von Kapitel 02 heraus, weil @ und + sie mitbringen. Genau dafür
stand Autograd so früh im Fahrplan.
ReLU ist die einfachste brauchbare Nichtlinearität: alles unter null wird null, der Rest bleibt. Sie ist die eine Stelle in diesem Kapitel, für die dein Tensor eine neue Operation lernen muss - und ihre Ableitung ist ein Schalter: eins, wo die Eingabe positiv war, sonst null.
Zero Magic in diesem Schritt
NumPy ist erlaubt, torch, jax, tinygrad und autograd sind verboten.
Die Versuchung ist in diesem Kapitel besonders greifbar, weil nn.Linear und
nn.ReLU in PyTorch fertig herumliegen. Aber genau diese zwei Klassen sind
zusammen keine dreißig Zeilen - und wer sie einmal getippt hat, weiß für immer,
was model.parameters() eigentlich zurückgibt: eine flache Liste der Tensoren,
an denen der Optimizer später schrauben darf.
Die Aufgabe
Zwei Dateien. Zuerst eine neue Methode in meintorch/tensor.py:
def relu(self):
out = Tensor(np.maximum(self.data, 0.0), (self,), "relu")
def _backward():
# TODO(du): Der Gradient fließt nur dort weiter, wo die Eingabe
# positiv war. Sonst wird er blockiert.
raise NotImplementedError
out._backward = _backward
return out
Dann meintorch/nn.py:
import numpy as np
from .tensor import Tensor
class Module:
"""Gemeinsame Basis: aufrufbar sein und seine Parameter herausgeben."""
def __call__(self, x):
return self.forward(x)
def forward(self, x):
raise NotImplementedError
def parameters(self):
return []
class Linear(Module):
def __init__(self, in_features, out_features, seed=None):
# Gleichverteilt um null, skaliert mit der Eingangsbreite. Geschenkt -
# Initialisierung ist ein eigenes Thema und nicht dieses Kapitel.
rng = np.random.default_rng(seed)
limit = 1.0 / np.sqrt(in_features)
self.W = Tensor(rng.uniform(-limit, limit, (in_features, out_features)))
self.b = Tensor(np.zeros(out_features))
def forward(self, x):
# TODO(du): eine Zeile - Matrixprodukt mit den Gewichten, Bias dazu.
raise NotImplementedError
def parameters(self):
return [self.W, self.b]
class ReLU(Module):
def forward(self, x):
# TODO(du): auch eine Zeile - der Tensor kann das jetzt selbst.
raise NotImplementedError
class Sequential(Module):
def __init__(self, *layers):
self.layers = list(layers)
def forward(self, x):
# TODO(du): jede Schicht der Reihe nach, das Ergebnis wandert weiter.
raise NotImplementedError
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
Hinweis 1 - die Richtung
Alle drei forward-Methoden sind ein bis drei Zeilen. Du schreibst in diesem
Kapitel keine einzige Ableitung außer der von ReLU - alles andere erledigt der
Graph, den du schon hast. Wenn du merkst, dass du eine Kettenregel von Hand
anwendest, bist du auf dem falschen Weg.
Für Sequential.forward: Es ist eine Schleife, die eine Variable immer wieder
überschreibt.
Hinweis 2 - konkreter
- ReLU-Rückwärtsschritt:
(self.data > 0)ist eine Maske ausTrueundFalse. Multipliziert mitout.gradwird daraus genau das, was du brauchst. Nicht vergessen zu akkumulieren. Linear.forward:x @ self.W + self.b. Mehr nicht.ReLU.forward:x.relu().Sequential.forward:for layer in self.layers: x = layer(x)und am Endereturn x.
Lösung anzeigen
# in Tensor.relu
def _backward():
self.grad += (self.data > 0) * out.grad
# in nn.py
class Linear(Module):
def forward(self, x):
return x @ self.W + self.b
class ReLU(Module):
def forward(self, x):
return x.relu()
class Sequential(Module):
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
Im Goal-Modus
Der autonome Durchlauf baut beide Dateien und beweist die Korrektheit numerisch, statt sich auf die Form der Ausgabe zu verlassen:
Erweitere `meintorch/tensor.py` um eine Methode `relu()` mit passendem Rückwärtsschritt und lege `meintorch/nn.py` an mit den Klassen `Module`, `Linear`, `ReLU` und `Sequential`. `Linear` initialisiert W gleichverteilt mit ±1/sqrt(in_features), b mit Nullen, und gibt beide über `parameters()` heraus. `Sequential` reicht die Eingabe durch alle Schichten und sammelt deren Parameter ein. Zwingend: NumPy erlaubt, torch/jax/tinygrad/autograd verboten. Es darf keine Ableitung außerhalb von `tensor.py` von Hand geschrieben werden. Schreibe `tests/test_03_schichten.py`: Forward und Backward müssen durch ein Netz aus zwei Linear-Schichten mit ReLU dazwischen laufen, jeder Parameter danach einen Gradienten in seiner eigenen Form tragen. Prüfe mindestens ein Gewicht gegen eine zentrale numerische Differenz. Führe `python -m pytest` aus, bis alles grün ist.
Der Meilenstein-Test
tests/test_03_schichten.py. Der letzte Test ist der interessanteste: Er
vergleicht den Gradienten, den dein Autograd ausrechnet, mit der numerischen
Ableitung derselben Stelle. Stimmen die beiden überein, ist die ganze Kette
korrekt - vom Matrixprodukt über ReLU bis zurück in die Gewichte.
import numpy as np
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor
def netz_und_eingabe():
net = Sequential(Linear(3, 4, seed=0), ReLU(), Linear(4, 1, seed=1))
x = Tensor(np.random.default_rng(7).normal(size=(5, 3)))
return net, x
def test_forward_liefert_die_richtige_form():
net, x = netz_und_eingabe()
assert net(x).shape == (5, 1)
def test_relu_schneidet_negative_werte_ab():
out = ReLU()(Tensor([[-2.0, 0.0, 3.0]]))
assert np.allclose(out.data, [[0.0, 0.0, 3.0]])
def test_backward_erreicht_jeden_parameter():
net, x = netz_und_eingabe()
net(x).backward()
for p in net.parameters():
assert p.grad.shape == p.data.shape
assert np.any(net.layers[2].W.grad != 0.0)
def test_gradient_stimmt_mit_der_numerischen_ableitung():
net, x = netz_und_eingabe()
net(x).backward()
W = net.layers[2].W
analytisch = W.grad[0, 0]
eps = 1e-6
W.data[0, 0] += eps
plus = net(x).data.sum()
W.data[0, 0] -= 2 * eps
minus = net(x).data.sum()
W.data[0, 0] += eps
assert abs(analytisch - (plus - minus) / (2 * eps)) < 1e-4