Tokenwerk · Das LLM-Lehrbuch

Kapitel 34 · VII · Bau dein LLM · 8 Minuten

Schritt 2: Die Daten vorbereiten

Grimms Märchen herunterladen, säubern, fair aufteilen und in lange Zahlenketten übersetzen – alles in einer Datei.

Was wir bauen

Jetzt füttern wir den Tokenizer mit echtem Text. Die Datei prepare.py macht in einem Rutsch alles, was Kapitel 21 empfohlen hat:

  1. Grimms Märchen herunterladen und säubern.
  2. Den Text in Absätze zerlegen – jeder Absatz ist ein Dokument.
  3. Erst die Dokumente in Training (90 %) und Validierung (10 %) aufteilen.
  4. Dann den Tokenizer nur auf den Trainingsdokumenten lernen.
  5. Beide Teile in Token-IDs übersetzen, jedes Dokument mit <bos> vorne und <eos> hinten.
  6. Alles als Dateien speichern, damit das Training später sofort loslegen kann.

Die Reihenfolge in Punkt 3 und 4 ist kein Zufall. Würde der Tokenizer auch die Validierungstexte sehen, hätte er beim späteren Prüfen einen kleinen unfairen Vorteil – Datenleckage, Kapitel 21.

Text laden und säubern

python
# Daten vorbereiten: Text laden, aufteilen, Tokenizer lernen, alles in IDs übersetzen (Kapitel 21)
import json
import random
import re
import urllib.request
from pathlib import Path

import numpy as np

from tokenizer import Tokenizer

URL = "https://www.gutenberg.org/cache/epub/77905/pg77905.txt"   # Grimms Märchen, gemeinfrei
DATA = Path("data")
VOCAB_SIZE = 1024

Oben stehen die Einstellungen: woher der Text kommt, wohin die Daten sollen und wie groß das Vokabular wird. Path("data") ist einfach der Ordner data neben deinen Dateien.

python
def load_documents():
    raw = DATA / "grimm.txt"
    if not raw.exists():
        DATA.mkdir(exist_ok=True)
        print("Lade Grimms Märchen herunter …")
        urllib.request.urlretrieve(URL, raw)
    text = raw.read_text(encoding="utf-8")
    text = text.split("*** START OF")[1].split("***", 1)[1].split("*** END OF")[0]
    text = re.sub(r"\[Illustration[^\]]*\]", "", text).replace("~", "")
    # Absätze sind durch Leerzeilen getrennt; Zeilenumbrüche im Absatz werden zu Leerzeichen
    paragraphs = [" ".join(p.split()) for p in re.split(r"\n\s*\n", text)]
    return [p for p in paragraphs if len(p) > 80 and not plattdeutsch(p)]   # Überschriften, Reste, Platt weg

Was hier passiert:

  • Beim ersten Start lädt urlretrieve den Text herunter und speichert ihn. Danach liegt er lokal, und das Programm lädt ihn nie wieder.
  • Project Gutenberg packt vor und hinter jedes Buch einen englischen Lizenztext. Den schneiden wir an den Markierungen *** START OF und *** END OF ab.
  • Die Ausgabe hat Bildhinweise wie [Illustration: Marienkind] und markiert gesperrten Text mit ~. Beides fliegt raus.
  • Absätze sind durch Leerzeilen getrennt. Innerhalb eines Absatzes machen wir aus allen Zeilenumbrüchen einfache Leerzeichen.
  • Absätze unter 80 Zeichen sind meist Überschriften. Die lassen wir weg – ebenso die plattdeutschen Märchen.
python
def plattdeutsch(paragraph):
    """Ein paar Märchen sind auf Plattdeutsch. Unser Modell soll Hochdeutsch lernen."""
    words = [" un ", " de ", " dat ", " ick ", " wat ", " nich ", " wull ", " hett "]
    return sum(paragraph.lower().count(w) for w in words) >= 3

Eine einfache Faustregel: Kommen in einem Absatz typische plattdeutsche Wörter wie „un“, „dat“ oder „ick“ mindestens dreimal vor, ist er raus. Nicht perfekt, aber gut genug – und ein schönes Beispiel dafür, dass Datenqualität Handarbeit ist.

Aufteilen, Tokenizer lernen, übersetzen

python
def main():
    docs = load_documents()
    random.seed(42)
    random.shuffle(docs)
    n_valid = len(docs) // 10
    valid, train = docs[:n_valid], docs[n_valid:]                # erst ganze Dokumente trennen ...
    print(f"{len(train)} Trainings- und {len(valid)} Validierungsabsätze")
    (DATA / "train_docs.json").write_text(json.dumps(train, ensure_ascii=False), encoding="utf-8")

    tok = Tokenizer.train("\n".join(train), VOCAB_SIZE)          # ... dann den Tokenizer NUR auf Training lernen
    tok.save(DATA / "tokenizer.json")
    print("Vokabular:", tok.vocab_size, "Tokens")

    bos, eos = tok.special["<bos>"], tok.special["<eos>"]
    for name, split in [("train", train), ("valid", valid)]:
        ids = []
        for doc in split:
            ids += [bos] + tok.encode(doc) + [eos]               # jedes Dokument mit Start und Ende
        np.save(DATA / f"{name}.npy", np.array(ids, dtype=np.int32))
        print(f"{name}: {len(ids):,} Tokens")

    sample = tok.encode(train[0][:60])
    print("Beispiel:", [tok.decode([i]) for i in sample])
  • random.seed(42) sorgt dafür, dass die „zufällige“ Aufteilung bei jedem Start gleich ist (Kapitel 21, Seed).
  • train_docs.json merkt sich die Trainingsabsätze. Die brauchen wir in Schritt 7 noch einmal für den Chat.
  • [bos] + tok.encode(doc) + [eos] rahmt jedes Dokument ein. So lernt das Modell später, wo ein Absatz anfängt und wann es aufhören soll.
  • np.save speichert die langen ID-Listen als NumPy-Dateien. int32 reicht völlig, denn unsere größte ID ist 1023.

Die ganze Datei

python
# Daten vorbereiten: Text laden, aufteilen, Tokenizer lernen, alles in IDs übersetzen (Kapitel 21)
import json
import random
import re
import urllib.request
from pathlib import Path

import numpy as np

from tokenizer import Tokenizer

URL = "https://www.gutenberg.org/cache/epub/77905/pg77905.txt"   # Grimms Märchen, gemeinfrei
DATA = Path("data")
VOCAB_SIZE = 1024


def load_documents():
    raw = DATA / "grimm.txt"
    if not raw.exists():
        DATA.mkdir(exist_ok=True)
        print("Lade Grimms Märchen herunter …")
        urllib.request.urlretrieve(URL, raw)
    text = raw.read_text(encoding="utf-8")
    text = text.split("*** START OF")[1].split("***", 1)[1].split("*** END OF")[0]
    text = re.sub(r"\[Illustration[^\]]*\]", "", text).replace("~", "")
    # Absätze sind durch Leerzeilen getrennt; Zeilenumbrüche im Absatz werden zu Leerzeichen
    paragraphs = [" ".join(p.split()) for p in re.split(r"\n\s*\n", text)]
    return [p for p in paragraphs if len(p) > 80 and not plattdeutsch(p)]   # Überschriften, Reste, Platt weg


def plattdeutsch(paragraph):
    """Ein paar Märchen sind auf Plattdeutsch. Unser Modell soll Hochdeutsch lernen."""
    words = [" un ", " de ", " dat ", " ick ", " wat ", " nich ", " wull ", " hett "]
    return sum(paragraph.lower().count(w) for w in words) >= 3


def main():
    docs = load_documents()
    random.seed(42)
    random.shuffle(docs)
    n_valid = len(docs) // 10
    valid, train = docs[:n_valid], docs[n_valid:]                # erst ganze Dokumente trennen ...
    print(f"{len(train)} Trainings- und {len(valid)} Validierungsabsätze")
    (DATA / "train_docs.json").write_text(json.dumps(train, ensure_ascii=False), encoding="utf-8")

    tok = Tokenizer.train("\n".join(train), VOCAB_SIZE)          # ... dann den Tokenizer NUR auf Training lernen
    tok.save(DATA / "tokenizer.json")
    print("Vokabular:", tok.vocab_size, "Tokens")

    bos, eos = tok.special["<bos>"], tok.special["<eos>"]
    for name, split in [("train", train), ("valid", valid)]:
        ids = []
        for doc in split:
            ids += [bos] + tok.encode(doc) + [eos]               # jedes Dokument mit Start und Ende
        np.save(DATA / f"{name}.npy", np.array(ids, dtype=np.int32))
        print(f"{name}: {len(ids):,} Tokens")

    sample = tok.encode(train[0][:60])
    print("Beispiel:", [tok.decode([i]) for i in sample])


if __name__ == "__main__":
    main()

Ausführen und prüfen

bash
python prepare.py

Das dauert beim ersten Mal ein paar Sekunden für den Download und etwa 10 Sekunden für den Tokenizer. Du solltest ungefähr das hier sehen:

text
Lade Grimms Märchen herunter …
358 Trainings- und 39 Validierungsabsätze
Vokabular: 1024 Tokens
train: 139,988 Tokens
valid: 11,964 Tokens
Beispiel: ['»', 'So', ' komm', ' nach', ' Haus', ',«', ' sprach', ' der', ' J', 'unge', ...]

Schau dir das Beispiel genau an: Häufige Wörter wie „ sprach“ oder „ der“ sind ein einziges Token, seltenere wie „Junge“ werden zerlegt. Genau so soll BPE arbeiten.

Jetzt ein Blick in die gespeicherten Daten. Starte python und tippe:

python
import numpy as np
from tokenizer import Tokenizer
tok = Tokenizer.load("data/tokenizer.json")
train = np.load("data/train.npy")
print(len(train))
print(train[:10])
print(tok.decode(train[:30]))
text
139988
[1020  279  944  830  493  600  363  375  285  432]
»So komm nach Haus,« sprach der Junge, faßte sie am Strickchen, führte sie in den Stall

Die erste Zahl, 1020, ist <bos>: 256 Bytes plus 763 Merges ergeben 1019 normale Tokens (IDs 0 bis 1018), danach kommen <pad> (1019) und <bos> (1020). Der Rest sind die Wörter des ersten Absatzes. So sieht dein Modell die Welt: eine lange Kette von Zahlen.

Wenn etwas schiefgeht

  • URLError oder Zeitüberschreitung beim Download – Keine Internetverbindung oder Gutenberg ist kurz nicht erreichbar. Alternativ die Datei im Browser von gutenberg.org herunterladen (eBook #77905, „Plain Text UTF-8“) und als data/grimm.txt speichern.
  • IndexError bei split("*** START OF") – Die heruntergeladene Datei ist keine Gutenberg-Textdatei, zum Beispiel eine Fehlerseite. data/grimm.txt löschen und neu starten.
  • Deutlich andere Zahlen – Leicht andere Werte sind normal, falls Gutenberg den Text überarbeitet hat. Hunderte Absätze und über 100.000 Tokens sollten es aber sein.

Kurz gemerkt

  • prepare.py lädt den Text, säubert ihn, zerlegt ihn in Absätze und teilt zuerst in Training und Validierung.
  • Der Tokenizer lernt nur auf den Trainingsdaten. Jedes Dokument wird mit <bos> und <eos> eingerahmt.
  • Heraus kommen zwei lange Zahlenketten in data/: rund 140.000 Tokens zum Lernen und 12.000 zum Prüfen.