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:

  1. GPT ist ein Fortsetzungsmodell.
    Es liest Text und sagt: „Was kommt als Nächstes?“ – Token für Token.

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

  3. Aufmerksamkeit (Attention) ist der Kontext-Mechanismus.
    Bei jedem neuen Token fragt das Modell: „Welche bisherigen Stellen sind gerade wichtig?“ und mischt den Kontext entsprechend.

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

  5. 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-Schichten

  • block_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_val ist numerische Stabilisierung: verhindert, dass exp() ü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 x werden Abfrage (q), Schlüssel (k), Werte (v) berechnet.

  • keys und values werden 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_h sind 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:

  • logits sind 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]

  • n begrenzt die Länge auf block_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 BOS

  • immer wieder: nächstes Token ausgeben

  • per Zufall ziehen, aber gewichtet nach Wahrscheinlichkeit

  • Stop, wenn BOS kommt

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:

  1. GPT ist Next-Token-Vorhersage.
    Es ist kein Wahrheitsautomat. Es ist ein Fortsetzungsmodell, das sehr gut darin ist, plausible nächste Tokens zu produzieren.

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

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

Weiter
Weiter

Wer ist hier eigentlich Chef? Wenn KI hilfreich wirkt, dich aber entmündigt