Inhalt
Die kurze Antwort
Ein Sprachmodell liest keinen Text. Es verarbeitet Zahlen (Token-IDs).
Bevor nlm auch nur einen einzigen Buchstaben "versteht", muss jeder Satz erst in eine Folge von Token-IDs übersetzt werden — und am Ende der Generierung wieder exakt zurück in lesbaren Text. Diese Übersetzung übernimmt ein selbst gebauter Byte-Level-BPE-Tokenizer, ohne externe Tokenizer-Bibliotheken. In diesem Beitrag zeige ich an einem echten Satz, wie das konkret funktioniert.
Schritt 1: Warum nicht einfach ein Zeichen pro Zahl?
Frage: Reicht es nicht aus, jedem Buchstaben eine feste Zahl zuzuweisen?
Hypothese: Eine feste Zeichen-zu-Zahl-Zuordnung müsste theoretisch funktionieren, wäre aber ineffizient und ein wortbasiertes Vokabular hätte das gegenteilige Problem: Sobald ein unbekanntes Wort auftaucht, fehlt eine Zahl dafür.
Experiment: Ein klassischer wortbasierter Tokenizer stößt zum Beispiel bei einem seltenen Wort wie "Skurrilität" an seine Grenze — ist es nicht im Vokabular, braucht er ein <unk>-Token, und der Originaltext lässt sich dann nicht mehr exakt rekonstruieren.
Ergebnis: Byte-Level BPE löst beide Probleme gleichzeitig. Es startet mit allen 256 möglichen UTF-8-Bytes als Basis-Vokabular:
0 -> b'\x00'
65 -> b'A'
97 -> b'a'
255 -> b'\xff'
Dadurch kann grundsätzlich JEDER UTF-8-kodierbare Text dargestellt werden — auch Zeichen, die im Trainingskorpus nie vorkamen wie bspw. ein Emoji:
"😀".encode("utf-8")
# [240, 159, 152, 128]
Entscheidung: Ich starte mit den 256 Basis-Bytes und nicht mit vorgefertigten Wörtern. Das macht ein <unk>-Token überflüssig: Ein unbekanntes Zeichen wird im Zweifel einfach in seine Roh-Bytes zerlegt.
Schritt 2: Wie wird aus 256 Bytes ein Vokabular mit 4.096 Einträgen?
Frage: 256 Bytes sind extrem ineffizient — fast jeder Buchstabe wäre ein eigenes Token. Wie kommt man von dort zu einem brauchbaren Vokabular?
Hypothese: Häufige Buchstabenkombinationen (he, in, ing, the) sollten zu größeren, gelernten Tokens zusammengeführt werden können — das ist das eigentliche "BPE" in Byte-Pair-Encoding.
Experiment: Das Training läuft in vier Phasen:
1. Pretokenisierung
"Hallo Welt!" -> ["Hallo", " ", "Welt", "!"]
2. Wort-Frequenz-Zählung
{"Hallo": 1, " ": 1, "Welt": 1, "!": 1}
3. BPE-Training
Häufige Byte-Paare werden zu neuen Tokens zusammengeführt.
4. Encoding / Decoding
Neuer Text wird mit denselben Regeln umgewandelt.
Die entscheidende Design-Entscheidung liegt in Phase 2: Der Tokenizer trainiert nicht auf dem rohen Byte-Strom des gesamten Korpus, sondern auf einem Wort-Frequenz-Wörterbuch. Kommt das Wort "the" bspw. 50.000-mal im Korpus vor, muss BPE es nicht 50.000-mal neu analysieren — es reicht, "the" einmal zu speichern und seine Häufigkeit 50000 als Gewicht zu verwenden. Das ändert die Laufzeit von O(Merges × Korpuslänge) auf O(Merges × eindeutige Wörter).
Ein gelernter Merge wird dabei so gespeichert:
(104, 101) -> 256 # 'h' + 'e' -> neues Token 256 ('he')
Ergebnis: nlms tatsächliches, trainiertes Vokabular hat genau 4.096 Einträge — 256 Basis-Bytes plus 3.840 gelernte Merges.
Entscheidung: Vokabulargröße 4.096 als bewusster Kompromiss für ein kleines, CPU-trainiertes Modell: groß genug für häufige englische Wortteile, klein genug, um die Embedding-Matrix nicht unnötig aufzublasen.
Schritt 3: Warum darf BPE nicht über Wortgrenzen hinweg mergen?
Frage: Wenn BPE einfach die häufigsten Byte-Paare im gesamten Text sucht — warum entsteht dann nicht irgendwann ein Merge wie " W" (Leerzeichen + Buchstabe)?
Hypothese: Ohne eine zusätzliche Regel müsste genau das passieren, sobald diese Kombination häufig genug vorkommt.
Experiment: Vor dem eigentlichen BPE-Training zerlegt eine Regex den Text in Pretokens — kleine Grundeinheiten, innerhalb derer BPE mergen darf, aber niemals über ihre Grenzen hinweg:
PRETOKEN_PATTERN = re.compile(r"\s+|\w+|[^\w\s]")
Diese Regel trennt Whitespace (\s+), Wortzeichen (\w+) und einzelne Sonderzeichen ([^\w\s]) grundsätzlich in eigene Pretokens:
"Hallo Welt!" -> ["Hallo", " ", "Welt", "!"]
Ergebnis: Das Leerzeichen und der folgende Buchstabe können also NIE ein gemeinsames Token werden, egal wie häufig sie nebeneinander vorkommen.
Entscheidung: Pretokenisierung als feste, nicht verhandelbare Grenze vor jedem BPE-Training — bewusster GPT-2-Stil: BPE darf innerhalb von Textsegmenten mergen, nie über sie hinweg.
Schritt 4: Ein echter Satz, Schritt für Schritt
Frage: Funktioniert das am Ende tatsächlich verlustfrei — auch mit einem echten Satz aus dem Trainingskorpus?
Hypothese: Der trainierte Tokenizer sollte jeden Satz in eine kürzere Token-Folge übersetzen und beim Decodieren exakt den Originaltext zurückliefern.
Experiment: Ein TinyStories-artiger Satz, codiert mit dem tatsächlich trainierten Tokenizer:
satz = "Once upon a time, there was a little rabbit named Freddy."
ids = tok.encode(satz)
Ergebnis: Der Satz hat 57 Zeichen und wird zu genau 24 Tokens — inklusive der 10 einzelnen Leerzeichen-Tokens, die zwischen den 14 sichtbaren Wort-/Satzzeichen-Tokens liegen:
Once | " " | upon | " " | a | " " | time | , | " " | there | " " | was | " " | a | " " | little | " " | rabbit | " " | named | " " | Fred | dy | .
Zwei Details daran sind lehrreich. Erstens: Jedes Leerzeichen bleibt ein eigenes Token (" ") — exakt die in Schritt 3 erzwungene Pretoken-Grenze. Der Satz besteht aus 11 durch Leerzeichen getrennten Wortgruppen, also 10 Lücken dazwischen — macht zusammen mit den 14 Wort-/Satzzeichen-Tokens genau die 24. Zweitens: Freddy wird NICHT als ein einziges Token codiert, sondern als zwei: Fred + dy. Der Name war im Trainingskorpus offenbar nicht häufig genug, um ein eigenes, vollständiges Merge-Token zu bekommen — aber häufig genug, dass sich Fred als Teilstück gelohnt hat.
tok.decode(ids) == satz # True
Der decodierte Text ist exakt identisch mit dem Original — kein einziges Zeichen geht verloren. Ein zweiter Test mit bewusst "unfairen" Zeichen (Emoji, Umlaut, Gedankenstrich) bestätigt dieselbe Verlustfreiheit:
test = "Freddy said: 😀 — grüezi!"
tok.decode(tok.encode(test)) == "Freddy said: 😀 — grüezi!" # True
Entscheidung: Die Hypothese bestätigt sich vollständig — auch für Zeichen, die im englischen TinyStories-Korpus praktisch nie vorkommen. Das ist die eigentliche Garantie von Byte-Level BPE: Nicht "funktioniert meistens", sondern "funktioniert strukturell immer", weil im Zweifel auf die 256 Basis-Bytes zurückgegriffen wird.
Was das für nlm bedeutet
Der Tokenizer ist der erste von mehreren Bausteinen, die zusammen nlm ausmachen — und der einzige, der komplett unabhängig vom eigentlichen Sprachmodell trainiert und getestet werden kann. Bevor ein einziger Trainingsschritt des Modells stattfindet, muss diese Übersetzung bereits verlustfrei und deterministisch funktionieren: dieselbe Eingabe muss immer dieselbe Token-Folge ergeben, und jede Token-Folge muss sich exakt zurückübersetzen lassen.
Nächster Beitrag
Als Nächstes gehe ich einen Schritt weiter: Eine Token-ID ist nur eine Zahl — wie wird daraus ein Vektor, der tatsächlich etwas über das Wort "weiß"?
Dies ist der zweite Beitrag aus der Serie über den Aufbau von nlm: einem kleinen Sprachmodell, das von Grund auf gebaut, trainiert und kritisch bewertet wird.