Vom Text zum Token: Wie nlm Sprache in Zahlen übersetzt

Ein Sprachmodell liest keinen Text, sondern Zahlen. In diesem Beitrag zeige ich, wie der selbst gebaute Byte-Level-BPE-Tokenizer von nlm einen echten Satz Schritt für Schritt in Token-IDs übersetzt — und wieder verlustfrei zurück.

Inhalt
  1. Die kurze Antwort
  2. Schritt 1: Warum nicht einfach ein Zeichen pro Zahl?
  3. Schritt 2: Wie wird aus 256 Bytes ein Vokabular mit 4.096 Einträgen?
  4. Schritt 3: Warum darf BPE nicht über Wortgrenzen hinweg mergen?
  5. Schritt 4: Ein echter Satz, Schritt für Schritt
  6. Was das für nlm bedeutet
  7. Nächster Beitrag

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.