GPT endlich verstehen - indem wir es auf eine einzige Python-Datei schrumpfen (microGPT)
Andrej Karpathy hat vor Kurzen GPT so weit heruntergedampft, dass der Kern in eine einzige Python-Datei passt. Ohne PyTorch, ohne NumPy, ohne „ML-Magie“. Nur Standardbibliothek. Er nennt es microGPT - „the complete algorithm“. Hier geht es direkt zum Github Repo.
Das ist kein „besseres ChatGPT“. Das ist ein Röntgenbild: Wenn du diese Datei einmal verstanden hast, wirkt GPT weniger wie Zauberei – und mehr wie das, was es ist: Next-Token-Vorhersage + Optimierung + viel Daten.
Ich gehe den Code entlang (in sinnvollen Blöcken, nicht pingelig Zeile für Zeile) und versuche abschnittsweise zu erläutern. An einigen Stellen habe ich noch weiterführende Links herausgesucht. Doch zunächst eine kurze Fassung ohne Code, für alle, die nicht selber coden wollen.
Kurzfassung (ohne Code)
Wenn du den Code-Teil überspringen willst, reicht diese mentale Landkarte:
GPT ist ein Fortsetzungsmodell.
Es liest Text und sagt: „Was kommt als Nächstes?“ – Token für Token.Token sind nur nummerierte Symbole.
Ein GPT rechnet nicht mit Wörtern, sondern mit Zahlen (Token-IDs). Ein Token kann ein Zeichen sein (wie hier), oft aber Wortteile.Aufmerksamkeit (Attention) ist der Kontext-Mechanismus.
Bei jedem neuen Token fragt das Modell: „Welche bisherigen Stellen sind gerade wichtig?“ und mischt den Kontext entsprechend.Training ist Fehler minimieren, nicht „Wissen einfüllen“.
Das Modell rät das nächste Token → bekommt Feedback („war richtig/falsch“) → passt seine Gewichte an.Inferenz (Generieren) ist Sampling aus Wahrscheinlichkeiten.
Aus den Wahrscheinlichkeiten wird ein nächstes Token gezogen. Mit Temperatur kann man steuern, wie „risikofreudig“ es sampling betreibt.
Wenn du genau diese fünf Punkte sauber im Kopf hast, ist GPT kein mystischer Kasten mehr. Der Rest ist Skalierung, Effizienz und Produktdesign.
Weiterführende Visualisierungen (wirklich gut):
1) Setup
import os # os.path.exists import math # math.log, math.exp import random # random.seed, random.choices, random.gauss, random.shuffle random.seed(42) # Let there be order among chaos
Kurze Erläuterungen
microGPT nutzt nur Python-Standardbibliothek: keine externen Abhängigkeiten.
random.seed(42) sorgt dafür, dass das Training reproduzierbar wird: gleiche Startwerte → ähnliche Lernkurve.
2) Daten: Ein kleiner Textkorpus als Trainingsmaterial
# Let there be an input dataset `docs`: list[str] of documents (e.g. a dataset of names)
if not os.path.exists('input.txt'):
import urllib.request
names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
urllib.request.urlretrieve(names_url, 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] of documents
random.shuffle(docs)
print(f"num docs: {len(docs)}")
Kurze Erläuterungen
Hier wird ein winziger Datensatz geladen: Namen (ein Name pro Zeile). Falls die Datei “inputs.txt” bei euch nicht im Ordner legt, wird sie über den Link heruntergeladen. Es handelt sich um einen Datensatz mit 32.033 Namen. Natürlich könnt ihr auch andere Wörter oder Texte in inputs.txt ablegen. Das wirkt simpel. Ist aber perfekt, weil es den Kern zeigt:
GPT wird trainiert, indem es Fortsetzungen lernt.
Ob das Fortsetzungen von Namen sind oder von Blogartikeln/Chats: mechanisch ist es dasselbe.
random.shuffle(docs)mischt die Reihenfolge, damit das Modell nicht immer gleiche Muster in gleicher Abfolge sieht.
3) Tokenisierung: Text → Symbole (Zahlen) → Text
# Let there be a Tokenizer to translate strings to discrete symbols and back
uchars = sorted(set(''.join(docs))) # unique characters in the dataset become token ids 0..n-1
BOS = len(uchars) # token id for the special Beginning of Sequence (BOS) token
vocab_size = len(uchars) + 1 # total number of unique tokens, +1 is for BOS
print(f"vocab size: {vocab_size}")
Kurze Erläuterungen:
Neuronale Netze rechnen nicht mit Buchstaben, sondern mit Zahlen. Deshalb baut microGPT einen kleinen „Tokenizer“:
uchars: alle einzigartigen Zeichen (z. B. a–z)jedes Zeichen bekommt eine Token-ID
zusätzlich gibt es
BOS(Beginning of Sequence) als Spezialzeichen
Wichtig: Dieses BOS wird später auch als Stop-Signal genutzt: Wenn das Modell wieder BOS ausgibt, ist der Name zu Ende.
4) Autograd: Der Mechanismus, der Lernen möglich macht
# Let there be Autograd, to recursively apply the chain rule through a computation graph
class Value:
__slots__ = ('data', 'grad', '_children', '_local_grads') # Python optimization for memory usage
def __init__(self, data, children=(), local_grads=()):
self.data = data # scalar value of this node calculated during forward pass
self.grad = 0 # derivative of the loss w.r.t. this node, calculated in backward pass
self._children = children # children of this node in the computation graph
self._local_grads = local_grads # local derivative of this node w.r.t. its children
Kurze Erläuterungen:
Das ist eine Mini-Version von „automatischer Differenzierung“ (Autograd). Hier steckt die große Idee:
Beim Vorwärtsrechnen entsteht ein Rechengraph (wer hängt von wem ab?).
Beim Rückwärtsrechnen verteilt man Ableitungen über die Kettenregel.
Ergebnis: jedes Gewicht bekommt einen „Schuldwert“ dafür, wie stark es den Fehler beeinflusst.
Hier eine gute Visualisierung der Backpropagation
4.1 Rechenoperationen bauen den Graphen auf
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), (other.data, self.data))
def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
def __neg__(self): return self * -1
def __radd__(self, other): return self + other
def __sub__(self, other): return self + (-other)
def __rsub__(self, other): return other + (-self)
def __rmul__(self, other): return self * other
def __truediv__(self, other): return self * other**-1
def __rtruediv__(self, other): return other * self**-1
Kurze Erläuterungen
Jede Operation erzeugt ein neues Value-Objekt und speichert:
welche Kinderwerte eingegangen sind (
_children)welche lokale Ableitung gilt (
_local_grads)
Damit kann backward() später systematisch alle Gradienten berechnen.
4.2 Der Rückwärtsdurchlauf (Backpropagation)
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1
for v in reversed(topo):
for child, local_grad in zip(v._children, v._local_grads):
child.grad += local_grad * v.grad
Kurze Erläuterungen
Zuerst: topologische Sortierung des Graphen (damit Kinder vor Eltern kommen).
Dann: der Fehler (
loss) bekommt Startgradient 1.Dann läuft man rückwärts und sammelt Gradienten ein.
Das ist der Moment, an dem „das Modell lernt“ möglich wird.
5) Parameter: Wo das Modell „Wissen“ speichert
# Initialize the parameters, to store the knowledge of the model. n_embd = 16 # embedding dimension n_head = 4 # number of attention heads n_layer = 1 # number of layers block_size = 16 # maximum sequence length head_dim = n_embd // n_head # dimension of each head matrix = lambda nout, nin, std=0.08: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
Kurze Erläuterungen
Diese Werte sind die „Größenregler“ des Modells:
n_embd: Länge der Einbettungsvektoren (mehr = mehr Kapazität)n_head: Anzahl der Aufmerksamkeitsköpfe („mehrere Blickwinkel“)n_layer: Anzahl der Transformer-Schichtenblock_size: maximale Kontextlänge (wie weit zurückgeschaut werden kann)
matrix(...) erzeugt Gewichtsmatrizen aus Zufallswerten – der Startpunkt, den Training später formt.
5.1 Embeddings, Attention
state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
for i in range(n_layer):
state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
params = [p for mat in state_dict.values() for row in mat for p in row] # flatten params into a single list[Value]
print(f"num params: {len(params)}")
Kurze Erläuterungen:
wte: Token-Einbettung (Token-ID → Vektor)wpe: Positions-Einbettung (Position → Vektor)lm_head: Projektion zurück aufs Vokabular (damit wir „nächstes Token“ bewerten können)Attention-Matrizen: Query/Key/Value/Output
MLP: zwei lineare Schichten (Breite 4× hoch, dann zurück)
Und am Ende werden die Parameter gezählt mit len(params).
6) Architektur: GPT als Funktion „Token rein → Wahrscheinlichkeiten raus“
# Define the model architecture: a stateless function mapping token sequence and parameters to logits over what comes next.
# Follow GPT-2, blessed among the GPTs, with minor differences: layernorm -> rmsnorm, no biases, GeLU -> ReLU
def linear(x, w):
return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
Kurze Erläuterungen:
linear ist die Basisoperation überall im Modell: Matrix mal Vektor (nur eben mit Python-Listen).
6.1) Softmax: aus Scores werden Wahrscheinlichkeiten
def softmax(logits):
max_val = max(val.data for val in logits)
exps = [(val - max_val).exp() for val in logits]
total = sum(exps)
return [e / total for e in exps]
Kurze Erläuterungen:
GPT gibt zunächst Logits aus (Scores ohne feste Skala).
Softmax macht daraus eine Wahrscheinlichkeitsverteilung (Summe = 1).
max_valist numerische Stabilisierung: verhindert, dassexp()überläuft.
6.2) RMSNorm: Stabilität statt „explodierende Werte“
def rmsnorm(x):
ms = sum(xi * xi for xi in x) / len(x)
scale = (ms + 1e-5) ** -0.5
return [xi * scale for xi in x]
Das ist eine Normalisierung, die Aktivierungen im „gut lernbaren“ Bereich hält. Ohne so etwas werden tiefe Netze schnell instabil.
7) Der GPT-Kern: Ein Schritt (bzw. ein Token) vorwärts
def gpt(token_id, pos_id, keys, values):
tok_emb = state_dict['wte'][token_id] # token embedding
pos_emb = state_dict['wpe'][pos_id] # position embedding
x = [t + p for t, p in zip(tok_emb, pos_emb)] # joint token and position embedding
x = rmsnorm(x)
Kurze Erläuterung:
Hier passiert der klassische GPT-Start:
Token-ID → Token-Einbettung
Position → Positions-Einbettung
beides addieren → „Token an Position“
normalisieren
Damit weiß das Modell nicht nur was da steht, sondern auch wo es steht.
8) Aufmerksamkeitsblock: Kontext „intelligent mischen“
for li in range(n_layer):
# 1) Multi-head attention block
x_residual = x
x = rmsnorm(x)
q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])
keys[li].append(k)
values[li].append(v)
Kurze Erläuterungen:
Das ist das Herz des Transformers:
Aus dem aktuellen Zustand
xwerden Abfrage (q), Schlüssel (k), Werte (v) berechnet.keysundvalueswerden gespeichert: das ist der KV-Zwischenspeicher (Gedächtnis im Kontext).
Damit kann das Modell beim nächsten Token auf alles vorherige zurückgreifen, ohne es komplett neu zu berechnen.
Hier könnt ihr mehr über Attention / Aufmerksamkeit erfahren.
8.1) Multi-Head: mehrere Blickwinkel parallel
x_attn = []
for h in range(n_head):
hs = h * head_dim
q_h = q[hs:hs+head_dim]
k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
v_h = [vi[hs:hs+head_dim] for vi in values[li]]
attn_logits = [sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5 for t in range(len(k_h))]
attn_weights = softmax(attn_logits)
head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h))) for j in range(head_dim)]
x_attn.extend(head_out)
Kurze Erläuterung:
Ein Head („Kopf“) ist eine Teilansicht des Einbettungsvektors:
q_h,k_h,v_hsind Ausschnitte (Slices) pro Kopf.attn_logits: Ähnlichkeit zwischen aktueller Abfrage und allen bisherigen Schlüsseln.softmax: macht daraus Aufmerksamkeitsgewichte.head_out: gewichtete Mischung der Werte – das ist der Kontext, den der Kopf „herausliest“.
Das Ganze passiert mehrfach (n_head), weil verschiedene Köpfe unterschiedliche Muster lernen können.
8.2) Output-Projektion + Residual-Verbindung
x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)]
Kurze Erläuterung:
Die Ausgaben aller Köpfe werden zusammengeführt (attn_wo). Dann wird der ursprüngliche Zustand wieder addiert (Residual)
Residual-Verbindungen sind ein Stabilitäts-Trick: Das Netz kann „kleine Korrekturen“ lernen, statt jedes Mal alles neu zu erfinden.
9) MLP-Block: Transformation statt Kontext
# 2) MLP block
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
x = [xi.relu() for xi in x]
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
x = [a + b for a, b in zip(x, x_residual)]
Kurze Erläuterungen:
Hier passiert das zweite Standardstück jeder Transformer-Schicht:
erst breiter rechnen (
fc1: 4× so breit)Nichtlinearität (ReLU)
wieder zurück (
fc2)Residual addieren
Wenn Aufmerksamkeit „Kontext holt“, dann ist das MLP eher „Verarbeitung/Umformung“.
10) Logits: Was kommt als Nächstes?
logits = linear(x, state_dict['lm_head'])
return logits
Erläuterung:
logitssind Roh-Scores für jedes mögliche nächste Token.Erst
softmax(logits)macht daraus Wahrscheinlichkeiten.
11) “Adam”-Optimierer: Gewichte sinnvoll nachführen
# Let there be Adam, the blessed optimizer and its buffers learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8 m = [0.0] * len(params) # first moment buffer v = [0.0] * len(params) # second moment buffer
Kurze Erläuterung:
Adam (ja, so benannt) ist eine verbreitete Methode, um Gewichte zu aktualisieren:
m: gleitender Mittelwert der Gradienten (Momentum)v: gleitender Mittelwert der quadrierten Gradienten (Skalierung)
Kurz: nicht nur „bergab“, sondern „bergab, aber stabil“.
12) Training: Dokument für Dokument, Token für Token
# Repeat in sequence
num_steps = 1000 # number of training steps
for step in range(num_steps):
# Take single document, tokenize it, surround it with BOS special token on both sides
doc = docs[step % len(docs)]
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
n = min(block_size, len(tokens) - 1)
Kurze Erläuterung:
Pro Schritt wird ein Name genommen.
Tokenisiert:
[BOS] + buchstaben + [BOS]nbegrenzt die Länge aufblock_size(Kontextfenster).
Damit lernt das Modell, aus „Anfang + bisherige Buchstaben“ den nächsten Buchstaben zu erraten.
12.1) Vorwärtslauf: Verlust (Loss) pro Position
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
losses = []
for pos_id in range(n):
token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
logits = gpt(token_id, pos_id, keys, values)
probs = softmax(logits)
loss_t = -probs[target_id].log()
losses.append(loss_t)
loss = (1 / n) * sum(losses)
Kurze Erläuterungen:
Das ist „Next-Token-Vorhersage“ in Reinform:
Eingabe: aktuelles Token (
token_id)Ziel: nächstes Token (
target_id)Modell gibt Wahrscheinlichkeiten aus (
probs)„Strafe“:
-log(p(richtig))→ wenn richtiges Token unwahrscheinlich, wird Loss groß.
Das ist genau die Grundaufgabe, die (mit viel mehr Daten) auch große GPTs trainiert.
12.2) Rückwärtslauf: Gradienten berechnen
loss.backward()
Erläuterung: Jetzt wird über den Rechengraph rückwärts gerechnet, und jedes Gewicht bekommt seinen Gradient: „Wenn ich dieses Gewicht leicht ändere, wie ändert sich der Fehler?“
12.3) Adam-Update: Gewichte anpassen, Gradienten zurücksetzen
lr_t = learning_rate * (1 - step / num_steps) # linear learning rate decay
for i, p in enumerate(params):
m[i] = beta1 * m[i] + (1 - beta1) * p.grad
v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2
m_hat = m[i] / (1 - beta1 ** (step + 1))
v_hat = v[i] / (1 - beta2 ** (step + 1))
p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)
p.grad = 0
print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}")
Kurze Erläuterungen:
lr_t: Lernrate wird linear kleiner (am Anfang große Schritte, am Ende fein).Adam kombiniert aktuelle Gradienten mit Gedächtnis (
m,v).Danach:
p.grad = 0, sonst würden sich Gradienten aus alten Schritten aufsummieren.
13) Inferenz: „Lass das Modell babbeln“
# Inference: may the model babble back to us
temperature = 0.5 # in (0, 1], control the "creativity" of generated text, low to high
print("\n--- inference (new, hallucinated names) ---")
for sample_idx in range(20):
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
token_id = BOS
sample = []
for pos_id in range(block_size):
logits = gpt(token_id, pos_id, keys, values)
probs = softmax([l / temperature for l in logits])
token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
if token_id == BOS:
break
sample.append(uchars[token_id])
print(f"sample {sample_idx+1:2d}: {''.join(sample)}")
Kurze Erläuterungen:
Das ist der Teil, den wir als „GPT antwortet“ kennen – nur ohne Chat-Oberfläche:
Start mit
BOSimmer wieder: nächstes Token ausgeben
per Zufall ziehen, aber gewichtet nach Wahrscheinlichkeit
Stop, wenn
BOSkommt
temperature ist dabei der „Kreativitätsregler“:
kleiner → vorsichtiger (mehr „Standardnamen“)
größer → wilder (mehr ungewöhnliche Kombinationen)
Und genau hier sieht man auch die Grenze: Das Modell garantiert keine Wahrheit. Es erzeugt plausible Fortsetzungen.
Beispiel: microGPT auf deutsche Brauereien trainiert
Ich habe input.txt mal durch eine Liste von ca. 1.700 deutschen Brauereien ersetzt (je Zeile ein Name). Das ist ein ziemlich dankbares Dataset, weil es viele wiederkehrende Muster hat: „Brauerei“, „Brauhaus“, „Privatbrauerei“, „Bräu…“. Genau solche Wiederholungen sind für microGPT Gold, weil das Modell klein ist und schnell „Stil“ statt „Bedeutung“ lernt.
Nach dem Training kam diese Samples raus:
sample 1: Arauer Bräueis L sample 2: Brauerei Wer Bra sample 3: Krauhaus Kerger sample 4: Brauerei Sterer sample 5: Brauerei Gergerb sample 6: Brauerei Hasthau sample 7: Brauerei Bräuera sample 8: Madher Braust Br sample 9: Brauerei Gol Bra sample 10: Privatbrauerei L sample 11: Al sample 12: Brauhaus Ger sample 13: Könder Bräus Gol sample 14: Brauerei Wi Bras sample 15: Brauerei Rönbr G sample 16: Brauerei Brauere sample 17: Brauerei Bräu Br sample 18: Brauerei Jasthof sample 19: Brauerei We Bräu sample 20: Brauerei Gausche
Was man daran schön sieht: microGPT hat sehr klar die Schablone gelernt („Brauerei + irgendwas“). Es produziert Namen, die sich auf den ersten Blick plausibel anfühlen – und stolpert gleichzeitig über Details: abgebrochene Wörter, Dopplungen, komische Übergänge. Das ist kein Bug, sondern genau das, was ein GPT im Kern macht: es erzeugt plausible Fortsetzungen, basierend auf Mustern im Trainingsmaterial. „Wissen“ entsteht erst durch Größe, Datenmenge, Training und (bei Chatbots) zusätzliche Nachbearbeitung.
Fazit
microGPT ist keine Abkürzung zu „ich baue mein eigenes ChatGPT“. Es ist eine Abkürzung zu „ich verstehe endlich, was GPT ist“.
Wenn du nach diesen ~200 Zeilen nur drei Dinge behältst, nimm diese:
GPT ist Next-Token-Vorhersage.
Es ist kein Wahrheitsautomat. Es ist ein Fortsetzungsmodell, das sehr gut darin ist, plausible nächste Tokens zu produzieren.Aufmerksamkeit ist der Kontext-Hebel.
GPT wirkt „intelligent“, weil es in jedem Schritt entscheiden kann, welche Teile des bisherigen Textes wichtig sind – und diese Information gezielt reinmischt.Training ist Optimierung, nicht Magie.
Vorwärts: vorhersagen. Rückwärts: Gradienten. Update: Gewichte anpassen. Wiederholen. Das ist das ganze Spiel.
Und vielleicht der wichtigste praktische Punkt: Alles, was moderne Modelle zusätzlich tun, ist „dieser Kern – nur in groß“: mehr Daten, mehr Parameter, mehr Schichten, bessere Tokenisierung, effizientere Umsetzung, plus Produkt- und Sicherheitslayer.