Neuronale Netze von Grund auf
Artikel 3 von 8 · Serie: Wie LLMs funktionieren
Wir haben in Artikel 2 ein Problem aufgedeckt das eleganter klingt als es ist.
Embeddings sind statisch. „Bank" hat einen Vektor, egal ob Sitzgelegenheit oder Geldinstitut. Das Modell sieht diesen Vektor und muss irgendwie, irgendwo, aus dem Kontext herausfinden was gemeint ist. Wie macht es das? Was passiert zwischen dem Moment wo ein Embedding-Vektor ins Modell eintritt und dem Moment wo Logits herauskommen?
Die Antwort sind neuronale Netze. Nicht als Gedankenkonzept adaptiert von der Natur, sondern als das was sie mathematisch sind: Matrizenmultiplikationen, Addition, eine nichtlineare Funktion, wiederholt angewendet. Wir bauen das in diesem Artikel von Grund auf, wieder ohne PyTorch oder Framework, nur numpy, damit klar wird was hinter den Kulissen passiert. Spätestens jetzt kommen wir an etwas Mathematik nicht vorbei.
Das Problem mit linearen Transformationen
Bevor wir ein Neuron bauen, sollten wir verstehen warum wir eines brauchen.
Ein Embedding-Vektor ist eine Liste von Zahlen. Man könnte versuchen, ihn direkt mit einer Matrix zu multiplizieren um Logits zu erzeugen. Das wäre eine lineare Transformation: logits = W · embedding + b. Einfach, schnell, differenzierbar.
Das Problem: Beliebig viele lineare Transformationen hintereinander sind immer noch eine einzige lineare Transformation. W₂ · (W₁ · x + b₁) + b₂ lässt sich zu (W₂W₁) · x + (W₂b₁ + b₂) zusammenfassen — eine Matrix und ein Bias. Egal wie viele Layer wir stapeln, die Ausdruckskraft bleibt dieselbe wie bei einem einzigen Layer.
Ein lineares Modell kann nur lineare Beziehungen lernen. Sprache ist aber eben nicht linear. Die Bedeutung von „nicht" hängt von dem ab was danach kommt. „Er hat nicht gewonnen" und „Er hat nicht verloren" — das Wort „nicht" kippt die Bedeutung, aber wie es das tut ist kontextabhängig.
Was wir brauchen, ist Nichtlinearität — eine Funktion die das Modell in die Lage versetzt, komplexere Zusammenhänge zu lernen. Das ist die Aufgabe der Aktivierungsfunktion.
Warum Nichtlinearität?
Die Entscheidungen, die ein Sprachmodell treffen muss, folgen selten einer simplen Summe. Stellen wir uns vor, eine Maschine soll Hund und Katze auf Fotos unterscheiden. Kein einzelnes Merkmal reicht dafür aus — weder Ohrform noch Größe noch Beinlänge. Die Entscheidung ergibt sich aus der Kombination mehrerer Merkmale, und zwar auf eine Art die sich nicht aufsummieren lässt: Spitze Ohren plus klein, plus lange Beine deutet auf Katze, spitze Ohren plus groß auf Schäferhund, Schlappohren plus klein auf Dackel.
Eine lineare Funktion kann das prinzipiell nicht lösen. Sie ist buchstäblich nur eine gewichtete Summe. Und wenn wir mehrere lineare Schichten hintereinander stapeln, lässt sich das Ganze zu einer einzigen Summe zusammenrechnen — mathematisch äquivalent zu einem einzigen Layer. Egal wie tief wir bauen: Das Ergebnis ist immer eine gerade Trennlinie, eine Ebene, eine Hyperebene.
Das XOR-Problem ist der einfachste Beweis dafür. Vier Punkte: (0,0)→0, (0,1)→1, (1,0)→1, (1,1)→0. Die Einsen liegen diagonal, die Nullen auch. Es gibt keine gerade Linie, die sie sauber trennt. Jeder lineare Klassifikator scheitert an mindestens einem Punkt — wir sehen das später konkret.
ReLU — die einfachste denkbare Nichtlinearität, nur ein Knick bei 0 — reicht aus um das zu ändern. Der Knick erlaubt dem Netz, die Eingabe in zwei unterschiedlich behandelte Bereiche aufzuteilen und in jedem Bereich anders weiterzurechnen. Dadurch können mehrere Layer wirklich aufeinander aufbauen, statt zu einem einzigen zu kollabieren.
Ein einzelnes Neuron
Ein Neuron ist die kleinste Einheit. Es nimmt mehrere Inputs, gewichtet sie, addiert einen Bias, und wendet eine Aktivierungsfunktion an.
import numpy as np
def relu(x):
return np.maximum(0, x)
# Ein Neuron: 3 Inputs, 1 Output
np.random.seed(42)
inputs = np.array([0.5, -0.3, 0.8]) # Embedding-Werte (3 Dimensionen)
weights = np.array([0.4, -0.7, 0.2]) # Lernbare Gewichte
bias = 0.1 # Lernbarer Bias
# Forward Pass
z = np.dot(inputs, weights) + bias # Lineare Kombination
a = relu(z) # Aktivierungsfunktion
print(f"Inputs: {inputs}")
print(f"Weights: {weights}")
print(f"Bias: {bias}")
print(f"z = w·x + b = {z:.4f}")
print(f"a = ReLU(z) = {a:.4f}")
Inputs: [ 0.5 -0.3 0.8]
Weights: [ 0.4 -0.7 0.2]
Bias: 0.1
z = w·x + b = 0.6700
a = ReLU(z) = 0.6700
z ist die gewichtete Summe — die lineare Kombination der Inputs. a ist die Ausgabe nach der Aktivierungsfunktion. ReLU ist dabei radikal einfach: alles unter 0 wird zu 0, alles über 0 bleibt unverändert. max(0, x).
Der Bias ist ein zusätzlicher Parameter der unabhängig von den Inputs addiert wird. Man kann ihn sich als Schwellwert-Verschiebung vorstellen: Ohne Bias würde das Neuron immer durch den Ursprung feuern — bei Input [0, 0, 0] wäre die Ausgabe zwingend 0. Der Bias erlaubt dem Neuron, auch ohne Input eine Grundaktivierung zu haben, oder den Punkt zu verschieben ab dem ReLU greift. Ohne Bias wären die Ausdrucksmöglichkeiten des Netzes deutlich eingeschränkt.
Die Gewichte und der Bias sind die lernbaren Parameter dieses Neurons. Beim Training werden sie so angepasst dass das Modell bessere Vorhersagen macht — wie das funktioniert, schauen wir uns in Artikel 4 an.
Ein vollständiger Layer
Ein einzelnes Neuron ist nutzlos. Nützlich wird es wenn viele Neuronen parallel operieren und ihre Outputs kombiniert werden — das ist ein Layer.
import numpy as np
def relu(x):
return np.maximum(0, x)
np.random.seed(42)
# Layer mit 3 Inputs und 4 Neuronen
inputs = np.array([0.5, -0.3, 0.8]) # shape: (3,)
weights = np.random.randn(3, 4) * 0.5 # shape: (3, 4) — eine Spalte pro Neuron
biases = np.zeros(4) # shape: (4,)
# Forward Pass für alle 4 Neuronen gleichzeitig
z = inputs @ weights + biases # shape: (4,)
a = relu(z) # shape: (4,)
print(f"Input shape: {inputs.shape}")
print(f"Weights shape: {weights.shape}")
print(f"Output shape: {a.shape}")
print(f"\nVor ReLU (z): {z.round(4)}")
print(f"Nach ReLU (a): {a.round(4)}")
Input shape: (3,)
Weights shape: (3, 4)
Output shape: (4,)
Vor ReLU (z): [-0.0285 0.2176 -0.2603 0.0794]
Nach ReLU (a): [0. 0.2176 0. 0.0794]
Die Gewichtsmatrix hat Shape (3, 4): 3 Inputs, 4 Neuronen. Die Matrizenmultiplikation inputs @ weights berechnet alle 4 Neuronen gleichzeitig — das ist der Grund warum neuronale Netze auf GPUs so effizient laufen. GPUs sind im Kern Maschinen die Matrizenmultiplikationen parallelisieren.
Zwei der vier Neuronen geben 0 aus — ihre lineare Kombination war negativ und ReLU hat sie abgeschnitten. Das ist nicht schlimm, es ist das Prinzip: Neuronen „feuern" nur wenn ihr Input einen Schwellwert überschreitet. Die Sparsität ist eine nützliche Eigenschaft.
Aktivierungsfunktionen: ReLU und GELU
ReLU ist historisch wichtig und immer noch weit verbreitet. Transformer — das Architektur-Fundament hinter GPT, Claude und Gemini — verwenden typischerweise eine Variante namens GELU (Gaussian Error Linear Unit).
import numpy as np
def relu(x):
return np.maximum(0, x)
def gelu(x):
# Approximation die in der Praxis verwendet wird
return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))
# Vergleich für einige Werte
print(f"{'x':6s} {'ReLU(x)':10s} {'GELU(x)':10s}")
print("-" * 30)
for x in [-2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0]:
print(f"{x:6.1f} {relu(x):10.4f} {gelu(x):10.4f}")
x ReLU(x) GELU(x)
------------------------------
-2.0 0.0000 -0.0455
-1.0 0.0000 -0.1588
-0.5 0.0000 -0.1543
0.0 0.0000 0.0000
0.5 0.5000 0.3457
1.0 1.0000 0.8412
2.0 2.0000 1.9545
Der Unterschied ist subtil aber wichtig. ReLU schneidet bei 0 hart ab — links davon ist die Ableitung exakt 0, rechts davon exakt 1. Das führt zum sogenannten „dying ReLU"-Problem: Neuronen die einmal stark negativ aktiviert werden, lernen danach nichts mehr weil ihr Gradient 0 ist.
GELU verhält sich weicher. Leicht negative Werte werden nicht komplett auf 0 gesetzt, sondern nur gedämpft. Der Übergang bei 0 ist glatt und differenzierbar. In der Praxis trainieren Transformer mit GELU oft besser und stabiler als mit ReLU — deshalb ist GELU der Standard in modernen Architektur-Implementierungen.
Das MLP: mehrere Layer hintereinander
Ein einzelner Layer ist noch kein Modell. Was er lernen kann, ist begrenzt: Er nimmt eine gewichtete Summe seiner Inputs, wendet einmal eine Aktivierungsfunktion an — das war’s. Für einfache Muster reicht das, aber nicht für Sprache.
Ein MLP (Multilayer Perceptron) stapelt mehrere Layer aufeinander, mit einer Aktivierungsfunktion dazwischen. Das ist der entscheidende Punkt: Ohne Aktivierung dazwischen würden die Layer sich zu einem einzigen Layer zusammenfassen lassen — wir hätten nichts gewonnen. Die Aktivierungsfunktion zwischen den Layern bricht diese Zusammenfassbarkeit auf und erlaubt dem Netz, wirklich komplexe Muster zu lernen.
Die Transformer-Konvention
In einem Transformer hat das MLP-Modul eine sehr spezifische, einheitliche Struktur:
- Ein Hidden Layer, der die Embedding-Dimension auf das Vierfache aufweitet (daher „wide hidden layer“). Hat das Embedding 64 Dimensionen, bekommt der Hidden Layer 256. Hat es 4096 Dimensionen, bekommt der Hidden Layer 16384.
- Eine Aktivierungsfunktion (GELU) auf diesen breiten Zwischenraum.
- Ein Output Layer, der die Dimension wieder auf das ursprüngliche Embedding-Format zurückprojiziert — oder bei der Token-Vorhersage direkt auf die Vokabular-Größe.
Das ‚Aufweiten und wieder zusammenführen‘ ist wichtig: Der breite Zwischenraum gibt dem Netz temporär viel Platz um Zwischenrepräsentationen zu bauen — eine Art großes Arbeitsgedächtnis. Der Output Layer verdichtet das Ergebnis wieder zurück auf die kompakte Darstellung die der Rest des Netzes erwartet.
Der folgende Code baut das vollständig nach. Wir führen zwei Helfer ein: eine Linear-Klasse für einen einzelnen vollständigen Layer, und die softmax-Funktion für den allerletzten Schritt.
import numpy as np
def gelu(x):
return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))
def softmax(x):
x = x - np.max(x)
e = np.exp(x)
return e / e.sum()
class Linear:
def __init__(self, n_in, n_out, seed=None):
rng = np.random.default_rng(seed)
self.W = rng.normal(0, 0.1, (n_in, n_out))
self.b = np.zeros(n_out)
def forward(self, x):
return x @ self.W + self.b
class MLP:
"""Einfaches 2-Layer MLP wie es in einem Transformer-Block vorkommt."""
def __init__(self, d_model, d_ff, d_out, seed=0):
self.layer1 = Linear(d_model, d_ff, seed=seed)
self.layer2 = Linear(d_ff, d_out, seed=seed+1)
def forward(self, x):
x = gelu(self.layer1.forward(x)) # Hidden Layer mit GELU
x = self.layer2.forward(x) # Output Layer (keine Aktivierung)
return x
# Typische Dimensionen für einen kleinen Transformer:
# d_model = 64 (Embedding-Dimension)
# d_ff = 256 (4× d_model, klassische Transformer-Konvention)
# d_vocab = 10 (vereinfachtes Mini-Vokabular)
np.random.seed(0)
d_model, d_ff, d_vocab = 64, 256, 10
mlp = MLP(d_model=d_model, d_ff=d_ff, d_out=d_vocab, seed=0)
# Ein Embedding-Vektor (z.B. für Token " ist")
embedding = np.random.randn(d_model) * 0.1
# Forward Pass
logits = mlp.forward(embedding)
probs = softmax(logits)
print(f"Input (Embedding): shape={embedding.shape}")
print(f"Hidden Layer: shape=({d_ff},) [nach GELU]")
print(f"Output (Logits): shape={logits.shape}")
print(f"\nLogits: {logits.round(3)}")
print(f"Probs: {probs.round(3)}")
print(f"Summe: {probs.sum():.6f}")
print(f"\nParameter gesamt: {d_model*d_ff + d_ff + d_ff*d_vocab + d_vocab:,}")
Input (Embedding): shape=(64,)
Hidden Layer: shape=(256,) [nach GELU]
Output (Logits): shape=(10,)
Logits: [ 0.048 0.111 -0.026 0.01 -0.063 0.035 -0.038 -0.012 0.047 0.066]
Probs: [0.103 0.11 0.096 0.099 0.092 0.102 0.094 0.097 0.103 0.105]
Summe: 1.000000
Parameter gesamt: 19,210
Was hier Schritt für Schritt passiert
Der Code sieht dicht aus, tut aber nur vier Dinge, und jedes davon haben wir vorher schon gebaut:
1. Die Linear-Klasse bündelt was wir in „Ein vollständiger Layer“ von Hand gemacht haben: sie hält eine Gewichtsmatrix W und einen Bias-Vektor b, und ihre forward-Methode berechnet x @ W + b — die gewichtete Summe für alle Neuronen gleichzeitig. Keine Magie, nur Buchhaltung.
2. Die MLP-Klasse kombiniert zwei solche Linear-Layer:
layer1: projiziert vond_model=64aufd_ff=256Neuronen (der breite Hidden Layer)layer2: projiziert vond_ff=256zurück aufd_out=10(in diesem Beispiel die Vokabular-Größe)
Im forward wird GELU nur zwischen den beiden Layern angewandt — genau das ist die Nichtlinearität die Schicht 2 von Schicht 1 trennt. Nach Schicht 2 kommt keine Aktivierung mehr, weil der Output rohe Logits sein sollen.
3. Der Forward Pass nimmt einen einzelnen Embedding-Vektor mit 64 Zahlen, bläst ihn auf 256 Zahlen auf, schickt ihn durch GELU, und verdichtet ihn wieder auf 10 Zahlen. Diese 10 Zahlen sind die Logits — für jedes der 10 möglichen Tokens im Vokabular genau ein Score.
4. softmax wandelt diese 10 Scores in 10 Wahrscheinlichkeiten um, die sich zu 1.0 addieren. Das ist die gleiche Funktion die wir schon in Artikel 1 beim Token-Sampling gesehen haben.
Das Ergebnis lesen
Im Output sehen wir dass die Wahrscheinlichkeiten alle bei ungefähr 10% liegen — fast gleichverteilt. Das ist kein Bug, sondern Absicht: Das Netz wurde mit zufälligen Gewichten initialisiert. Es hat nie Text gesehen, nichts gelernt. Alles was wir messen ist das Eigenrauschen der Architektur.
Dass trotzdem ein vollständiger Forward Pass funktioniert — von Embedding zu Wahrscheinlichkeitsverteilung — ist der Punkt. Die Mechanik ist komplett. Was fehlt, ist das Training: der Prozess der die Gewichte so anpasst dass die Ausgabe-Wahrscheinlichkeiten sinnvolle Vorhersagen werden. Das ist Artikel 4.
Noch eine Zahl die man sich merken sollte: 19.210 Parameter für dieses Mini-MLP. Das sind alle Gewichte plus Biases beider Layer zusammen. Klingt nach viel für ein Netz das nichts kann — und ist gleichzeitig lächerlich wenig verglichen mit echten Modellen, wie wir gleich sehen werden.
Warum Tiefe funktioniert
Ein häufiges Missverständnis: Mehr Layer bedeutet nicht einfach „mehr Rechenleistung". Tiefe ermöglicht hierarchische Repräsentationen.
Das klassische Beispiel ist das XOR-Problem — es lässt sich mit einem linearen Modell nicht lösen, aber mit zwei Layern trivial:
import numpy as np
def relu(x):
return np.maximum(0, x)
# XOR: [0,0]→0, [0,1]→1, [1,0]→1, [1,1]→0
# Kein linearer Klassifikator kann das lösen — die Klassen sind nicht linear trennbar.
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([0, 1, 1, 0])
# Handgesetztes Netz das XOR löst
# Layer 1 lernt: "mindestens einer ist 1" (Neuron 0) und "beide sind 1" (Neuron 1)
W1 = np.array([[1, 1], [1, 1]], dtype=float)
b1 = np.array([0, -1], dtype=float)
# Layer 2 kombiniert: "mindestens einer" MINUS 2 × "beide" = XOR
W2 = np.array([[1], [-2]], dtype=float)
b2 = np.array([0], dtype=float)
print("XOR mit 2-Layer MLP:")
print(f"{'Input':12s} {'Erwartet':10s} {'Ausgabe':10s}")
print("-" * 36)
for x, target in zip(X, y):
h = relu(x @ W1 + b1)
out = h @ W2 + b2
pred = 1 if out[0] > 0.5 else 0
ok = "✓" if pred == target else "✗"
print(f"{str(x):12s} {target:10d} {pred:10d} {ok}")
XOR mit 2-Layer MLP:
Input Erwartet Ausgabe
------------------------------------
[0 0] 0 0 ✓
[0 1] 1 1 ✓
[1 0] 1 1 ✓
[1 1] 0 0 ✓
Der erste Layer lernt zwei Teilkonzepte: „mindestens einer der Inputs ist 1" und „beide Inputs sind 1". Der zweite Layer kombiniert diese Zwischenergebnisse zur finalen Antwort. Kein einzelner Layer könnte das — die Nichtlinearität (ReLU) zwischen den Layern macht es möglich.
In einem echten Sprachmodell passiert dasselbe auf einer anderen Skala. Die ersten Layer lernen einfache Muster — Morphologie, häufige Wortpaare. Mittlere Layer lernen syntaktische Strukturen. Tiefe Layer lernen semantische Zusammenhänge und Weltwissen. Diese Hierarchie entsteht nicht durch explizites Design, sondern aus der Struktur des Problems.
Wie groß ist ein echtes Modell?
Unser Mini-MLP hatte 19.210 Parameter. Zum Vergleich: Llama 3.1 405B — eines der größten öffentlich dokumentierten Open-Source-Modelle — hat 405 Milliarden Parameter, verteilt auf 126 Transformer-Layer mit einer Embedding-Dimension von 16.384 und einem MLP-Hidden-Layer von 53.248. Allein das MLP eines einzigen Layers hat damit über 1,7 Milliarden Parameter — und das 126-mal gestapelt ergibt den Großteil der 405 Milliarden.
Proprietäre Flagship-Modelle wie GPT-5 oder Claude Opus sind vermutlich ähnlich groß oder größer, ihre genauen Architektur-Details sind aber nicht öffentlich. Klar ist: Modellgröße skaliert mit Fähigkeit, zumindest bis zu einem Punkt. Mehr Parameter bedeutet mehr Kapazität um Muster zu speichern.
Interessant dabei: Der Forward Pass durch alle diese Layer folgt exakt demselben Prinzip das wir hier aufgebaut haben. Matrizenmultiplikation, Aktivierungsfunktion, wieder Matrizenmultiplikation. Die Architektur ist einfach — die Skala ist es nicht.
Was noch fehlt
Wir können jetzt einen Embedding-Vektor durch ein MLP schicken und Logits bekommen. Was wir nicht können: Das Netz lernen lassen.
Unsere Gewichte sind zufällig initialisiert. Die Logits die herauskommen, spiegeln nichts Sinnvolles wider, gleichmäßig verteilte Wahrscheinlichkeiten über das Vokabular, wie wir oben gesehen haben. Um nützliche Vorhersagen zu machen, müssen die Gewichte angepasst werden.
Das geschieht durch Backpropagation — der Algorithmus der den Fehler des Modells durch alle Layer zurückpropagiert und für jedes Gewicht berechnet, in welche Richtung es verändert werden muss um die Vorhersagen zu verbessern. Die mathematische Grundlage ist die Chain Rule aus der Differentialrechnung — und das ist das Thema von Artikel 4.
Alle Artikel der Serie
- Das nächste Wort — wie Sprachmodelle funktionieren
- Wörter als Punkte im Raum — was Embeddings wirklich sind
- Neuronale Netze von Grund auf ← dieser Artikel
- Backpropagation — wie ein Modell lernt (erscheint demnächst)
- Kontext und RNNs — warum Reihenfolge zählt (erscheint demnächst)
- Attention — der Mechanismus der alles veränderte (erscheint demnächst)
- Der Transformer — die vollständige Architektur (erscheint demnächst)
- Fine-Tuning — vom Basismodell zum Assistenten (erscheint demnächst)
Serie: Wie LLMs funktionieren · rotecodefraktion.de