Was wir bauen
Ein Sprachmodell rechnet nur mit Zahlen. Der erste Baustein übersetzt deshalb Text in Token-IDs und wieder zurück: der Tokenizer. In Kapitel 12 hast du die Idee kennengelernt. Hier ist sie in drei Sätzen:
- Wir starten mit den 256 möglichen Bytes. Damit lässt sich jeder Text der Welt darstellen.
- Beim Training suchen wir das häufigste benachbarte Paar und verschmelzen es zu einem neuen Token. Das wiederholen wir, bis das Vokabular groß genug ist – bei uns 1024 Tokens.
- Beim Kodieren wenden wir diese Merge-Regeln in derselben Reihenfolge auf neuen Text an.
Am Ende dieses Schritts hast du die Datei tokenizer.py mit einer Klasse Tokenizer, die das alles kann.
Ein Trick vorab: erst in Wörter zerlegen
Würden wir die Paare im ganzen Text auf einmal zählen, wäre das bei 140.000 Tokens und hunderten Merge-Runden ziemlich langsam. Deshalb zerlegen wir den Text vorab in Wörter, Satzzeichen und Leerraum – und zählen dann jedes Wort nur einmal, mit seiner Häufigkeit. Das Wort „ und“ kommt tausende Male vor, muss aber nur einmal verarbeitet werden.
Nebeneffekt: Merges passieren nur innerhalb eines Wortes. Ein Token kann also nie halb aus „Katze“ und halb aus dem nächsten Wort bestehen. Genau so machen es auch GPT-2 und viele andere Modelle.
# Byte-BPE-Tokenizer: Text -> Token-IDs -> Text (Kapitel 12)
import json
import re
from collections import Counter
# Text vorab in Wörter, Satzzeichen und Leerraum zerlegen. Merges passieren nur innerhalb dieser Stücke.
PATTERN = re.compile(r" ?\w+| ?[^\w\s]+|\s+")
SPECIALS = ["<pad>", "<bos>", "<eos>", "<user>", "<assistant>"]Der reguläre Ausdruck PATTERN beschreibt, wie zerlegt wird: ein optionales Leerzeichen plus Buchstaben ( ?\w+), ein optionales Leerzeichen plus Satzzeichen ( ?[^\w\s]+) oder Leerraum (\s+). Aus „Da sprach er:“ wird so ["Da", " sprach", " er", ":"]. Das Leerzeichen bleibt beim folgenden Wort – deshalb sieht man in Token-Listen oft Tokens wie „ sprach“ mit Leerzeichen vorne.
Die fünf Sondertokens kennst du aus Kapitel 12: <bos> und <eos> markieren Anfang und Ende, <pad> füllt auf, <user> und <assistant> brauchen wir später für den Chat.
Ein Paar verschmelzen
Die wichtigste Hilfsfunktion: Sie ersetzt in einer Liste von IDs jedes Vorkommen eines Paares durch eine neue ID. Von links nach rechts und ohne Überlappung – aus [a, a, a] mit dem Paar (a, a) wird [aa, a].
def merge(ids, pair, new_id):
"""Ersetzt jedes Vorkommen von pair in ids durch new_id (von links, ohne Überlappung)."""
out, i = [], 0
while i < len(ids):
if i + 1 < len(ids) and (ids[i], ids[i + 1]) == pair:
out.append(new_id)
i += 2
else:
out.append(ids[i])
i += 1
return outDie Klasse: Vokabular aufbauen
Ein Tokenizer besteht eigentlich nur aus seiner Liste von Merge-Regeln. Alles andere lässt sich daraus berechnen:
class Tokenizer:
def __init__(self, merges=()):
self.merges = [tuple(pair) for pair in merges]
self.pieces = [bytes([b]) for b in range(256)] # IDs 0-255: die Bytes selbst
self.rank = {}
for pair in self.merges: # jede Merge-Regel bekommt die nächste ID
self.rank[pair] = len(self.pieces)
self.pieces.append(self.pieces[pair[0]] + self.pieces[pair[1]])
self.special = {name: len(self.pieces) + i for i, name in enumerate(SPECIALS)}
self.cache = {}
@propertypiecesist das Vokabular als Bytes: ID 0 bis 255 sind die einzelnen Bytes, danach kommt für jede Merge-Regel das zusammengesetzte Stück. Steht in Regel 1 „d“ + „ie“, dann ist das neue Stück einfachb"d" + b"ie".rankmerkt sich für jedes Paar, welche ID es bekommen hat. Die Reihenfolge ist wichtig: Wer früher gelernt wurde, wird später auch zuerst angewendet.- Die Sondertokens hängen wir hinten an. So stören sie die Byte-IDs nicht.
@propertymacht ausvocab_sizeeine Eigenschaft: Du schreibsttok.vocab_sizeohne Klammern.
Training: das häufigste Paar finden
class Tokenizer:
@classmethodSchritt für Schritt:
Counter(PATTERN.findall(text))zerlegt den Text und zählt, wie oft jedes Wort vorkommt.seqshält für jedes Wort seine aktuelle Zerlegung, am Anfang Byte für Byte.- In jeder Runde zählen wir alle benachbarten Paare – gewichtet mit der Worthäufigkeit.
- Das häufigste Paar gewinnt. Bei Gleichstand entscheidet die kleinere ID, damit das Ergebnis immer gleich ist.
- Kommt das beste Paar nur einmal vor, hören wir auf: Dafür lohnt sich kein eigenes Token.
- Sonst merken wir uns die Regel und wenden sie auf alle Wörter an.
Die Bedingung in while sorgt dafür, dass am Ende genau vocab_size Tokens herauskommen: 256 Bytes plus die Merges plus die 5 Sondertokens.
Kodieren und Dekodieren
class Tokenizer:
def encode_word(self, word):
if word in self.cache:
return self.cache[word]
ids = list(word.encode("utf-8"))
while len(ids) > 1:
# die Regel mit dem kleinsten Rang zuerst – genau die Reihenfolge aus dem Training
pair = min(zip(ids, ids[1:]), key=lambda p: self.rank.get(p, float("inf")))
if pair not in self.rank:
break
ids = merge(ids, pair, self.rank[pair])
self.cache[word] = ids
return ids
def encode(self, text):
ids = []
for word in PATTERN.findall(text):
ids.extend(self.encode_word(word))
return ids
def decode(self, ids):
data = b"".join(self.pieces[i] for i in ids if i < len(self.pieces)) # Sondertokens überspringen
return data.decode("utf-8", errors="replace")encode_word wendet die gelernten Regeln auf ein Wort an: Unter allen Paaren im Wort sucht es das mit dem kleinsten Rang – also die Regel, die im Training zuerst gelernt wurde – und verschmilzt es. Das wiederholt sich, bis keine Regel mehr passt. Weil dieselben Wörter ständig wiederkommen, speichern wir das Ergebnis im cache. Das macht das Kodieren um ein Vielfaches schneller.
decode geht den umgekehrten Weg: Bytes der Tokens aneinanderhängen und als UTF-8 lesen. Sondertokens werden übersprungen. errors="replace" fängt kaputte Bytefolgen ab, falls ein Modell später mal ein unvollständiges Zeichen erzeugt (Kapitel 12).
Speichern und Laden
class Tokenizer:
def save(self, path):
with open(path, "w", encoding="utf-8") as f:
json.dump({"merges": self.merges}, f)
@classmethodGespeichert werden nur die Merge-Regeln als JSON. Mehr braucht es nicht – den Rest berechnet __init__ beim Laden neu. @classmethod heißt: Die Methode gehört zur Klasse, nicht zu einem einzelnen Objekt. Deshalb schreibst du später Tokenizer.load("…") statt erst ein leeres Objekt zu bauen.
Die ganze Datei
# Byte-BPE-Tokenizer: Text -> Token-IDs -> Text (Kapitel 12)
import json
import re
from collections import Counter
# Text vorab in Wörter, Satzzeichen und Leerraum zerlegen. Merges passieren nur innerhalb dieser Stücke.
PATTERN = re.compile(r" ?\w+| ?[^\w\s]+|\s+")
SPECIALS = ["<pad>", "<bos>", "<eos>", "<user>", "<assistant>"]
def merge(ids, pair, new_id):
"""Ersetzt jedes Vorkommen von pair in ids durch new_id (von links, ohne Überlappung)."""
out, i = [], 0
while i < len(ids):
if i + 1 < len(ids) and (ids[i], ids[i + 1]) == pair:
out.append(new_id)
i += 2
else:
out.append(ids[i])
i += 1
return out
class Tokenizer:
def __init__(self, merges=()):
self.merges = [tuple(pair) for pair in merges]
self.pieces = [bytes([b]) for b in range(256)] # IDs 0-255: die Bytes selbst
self.rank = {}
for pair in self.merges: # jede Merge-Regel bekommt die nächste ID
self.rank[pair] = len(self.pieces)
self.pieces.append(self.pieces[pair[0]] + self.pieces[pair[1]])
self.special = {name: len(self.pieces) + i for i, name in enumerate(SPECIALS)}
self.cache = {}
@property
def vocab_size(self):
return len(self.pieces) + len(SPECIALS)
@classmethod
def train(cls, text, vocab_size):
words = Counter(PATTERN.findall(text)) # jedes Wort nur einmal, mit Häufigkeit
seqs = {w: list(w.encode("utf-8")) for w in words}
merges = []
while 256 + len(merges) + len(SPECIALS) < vocab_size:
pairs = Counter()
for w, count in words.items():
s = seqs[w]
for pair in zip(s, s[1:]):
pairs[pair] += count
if not pairs:
break
pair, count = max(pairs.items(), key=lambda item: (item[1], -item[0][0], -item[0][1]))
if count < 2:
break
new_id = 256 + len(merges)
merges.append(pair)
for w in words:
seqs[w] = merge(seqs[w], pair, new_id)
return cls(merges)
def encode_word(self, word):
if word in self.cache:
return self.cache[word]
ids = list(word.encode("utf-8"))
while len(ids) > 1:
# die Regel mit dem kleinsten Rang zuerst – genau die Reihenfolge aus dem Training
pair = min(zip(ids, ids[1:]), key=lambda p: self.rank.get(p, float("inf")))
if pair not in self.rank:
break
ids = merge(ids, pair, self.rank[pair])
self.cache[word] = ids
return ids
def encode(self, text):
ids = []
for word in PATTERN.findall(text):
ids.extend(self.encode_word(word))
return ids
def decode(self, ids):
data = b"".join(self.pieces[i] for i in ids if i < len(self.pieces)) # Sondertokens überspringen
return data.decode("utf-8", errors="replace")
def save(self, path):
with open(path, "w", encoding="utf-8") as f:
json.dump({"merges": self.merges}, f)
@classmethod
def load(cls, path):
with open(path, encoding="utf-8") as f:
return cls(json.load(f)["merges"])Ausprobieren
Starte im Ordner mein-llm eine Python-Konsole mit python und probier den Tokenizer an einem winzigen Text aus:
from tokenizer import Tokenizer
tok = Tokenizer.train("die katze und die kanne und die katze", 270)
print(tok.vocab_size)
ids = tok.encode("die katze")
print(ids)
print([tok.decode([i]) for i in ids])
print(tok.decode(ids) == "die katze")Ausgabe:
270
[259, 264, 101]
['die', ' katz', 'e']
True270 Tokens heißt: 256 Bytes, 5 Sondertokens – bleiben 9 Merges. Die haben gereicht, um aus „die“ und „ katz“ ganze Tokens zu machen. Für „ katz“ + „e“ war keine Regel mehr übrig. Probier 272 statt 270: Dann wird auch „ katze“ zu einem einzigen Token. Und das Wichtigste: Der Rückweg ergibt exakt den Originaltext.
Wenn etwas schiefgeht
ModuleNotFoundError: No module named 'tokenizer'– Die Python-Konsole läuft nicht im Ordnermein-llm. Mitcd mein-llmhineinwechseln.IndentationError– Beim Abtippen ist die Einrückung verrutscht. Python ist da streng: Methoden einer Klasse sind mit genau 4 Leerzeichen eingerückt.- Andere IDs als im Buch – Kein Problem, solange
Trueam Ende steht. Die genauen Nummern hängen vom Trainingstext ab.
Kurz gemerkt
- Der Tokenizer lernt Merge-Regeln: Das häufigste Paar wird zu einem neuen Token, wieder und wieder.
- Vorab in Wörter zerlegen macht das Training schnell und verhindert Tokens über Wortgrenzen.
- Gespeichert werden nur die Regeln. Kodieren wendet sie in derselben Reihenfolge an, Dekodieren hängt Bytes aneinander.