Startseite / Artikel

BPE-Tokenisierer von Grund auf: Kürzere Sequenzen für ein kleines Kontextfenster von GPT

Schreiben Sie einen Byte-Pair-Encoding-Tokenizer in Python, trainieren Sie ihn mit WikiText-2, speichern Sie die Ergebnisse als JSON und beobachten Sie, wie die Subwort-Tokenisierung das Kontextfenster eines GPT erweitert.

Verlag: REACTAPP.TOP26 Min. Lesezeit6015 Wörter

Quellen: Tutorial 16: Beyond Character Tokens, Building a BPE Tokenizer

Ein Zeichensebener Tokenisierer ist der einfachste Weg, um ein kleines GPT zum Laufen zu bringen: Das Vokabular ist winzig, es gibt nichts Unbekanntes, und jeder Schritt ist transparent. Der Nachteil ist die Länge der Sequenz, da jeder Buchstabe einen Platz im Kontextfenster kostet. Dieser Leitfaden ersetzt ihn durch Byte Pair Encoding (BPE), das von Grund auf in reinem Python implementiert wird. Sie werden es mit WikiText-2 trainieren, das gelernte Vokabular sowie die Merges als JSON speichern, es mit einem Fingerabdruck versehen, das Datensatz wieder kodieren und genau erkennen, was sich ändert, wenn das Modell mit Subwort-Token neu trainiert wird.

Der Code setzt voraus, dass bereits ein kleines PyTorch GPT sowie saubere WikiText-2-Dateien vorhanden sind. Falls das Training weiterhin Probleme macht, stabilisieren Sie es zunächst; siehe Tests, die jeden Fehler in einem kleinen PyTorch GPT isolieren.

Warum Zeichentoken nicht mehr ausreichen

Ein Zeichentokenisierer weist jedem Zeichen, einschließlich Leerzeichen, seinen eigenen Token zu. Nehmen wir diesen kurzen Satz:

artificial intelligence

Es wird zu einem Token pro Zeichen:

['a', 'r', 't', 'i', 'f', 'i', 'c', 'i', 'a', 'l',
 ' ', 'i', 'n', 't', 'e', 'l', 'l', 'i', 'g', 'e',
 'n', 'c', 'e']

Das sind 23 Token für zwei Wörter. Ein Subwort-Tokener könnte denselben Satz mit weitaus weniger Einheiten darstellen, zum Beispiel:

['art', 'ificial', ' intelligence']

Drei Token anstelle von 23. Kürzere Sequenzen bedeuten mehr Text pro Kontextfenster, weniger Positionen zum Trainieren sowie weniger DekodierungsSchritte bei der Erstellung. Das Modell erhält außerdem sinnvolle Abschnitte statt isolierter Buchstaben, häufige Fragmente werden zu wiederverwendbaren Einheiten, und seltene Wörter können weiterhin aus kleineren Teilen zusammengesetzt werden. Die schwierige Aufgabe besteht darin, automatisch gute Subwort-Einheiten zu finden – genau das ist die Aufgabe von BPE.

Zeichen, Wörter und Subwörter im Vergleich

Es gibt drei grundlegende Möglichkeiten, Text zu teilen, wobei jede unterschiedliche Kompromisse mit sich bringt.

Zeichentokenisierung

Jedes Zeichen ist ein Token:

"learning"
→ ["l", "e", "a", "r", "n", "i", "n", "g"]

Es bietet ein kleines Wortschatz, keine unbekannten Token sowie eine einfache Implementierung. Im Gegenzug erhält man lange Sequenzen, eine langsame Generierung und ein Modell, das Wörter aus einzelnen Buchstaben zusammensetzen muss.

Worttokenisierung

Jedes Wort ist ein Token:

"machine learning works"
→ ["machine", "learning", "works"]

Die Sequenzen sind kurz und leicht lesbar, doch das Vokabular wird enorm groß. Unbekannte Wörter erfordern einen unbekannten Token, und verwandte Formen wie "learn", "learning" und "learned" können zu drei unverbundenen IDs werden, die nichts miteinander teilen. Namen, Schreibfehler und seltene Wörter werden schlecht behandelt.

Subwort-Tokenisierung

Wörter werden in häufig vorkommende Fragmente aufgeteilt:

"unbelievable"
→ ["un", "believ", "able"]

Dies ergibt kürzere Sequenzen als Zeichen und ein kleineres Vokabular als Wörter. Häufige Wörter können weiterhin zu einzelnen Tokens werden, seltene Wörter zerfallen in bekannte Bestandteile, und Fragmente wie Präfixe und Suffixe werden in vielen Wörtern wiederverwendet. Die einzige wirkliche Herausforderung ist, dass das Trainieren und Implementieren des Tokenizers aufwändiger ist.

Man kann sich die drei Ansätze als Punkte auf einem Spektrum vorstellen:

Characters
← flexibility

Subwords
← balance

Words
← compactness

Wie BPE seine Fusionen lernt

BPE beginnt mit den kleinsten Einheiten, hier den einzelnen Zeichen, und verschmilzt wiederholt das häufigste Paar von Nachbarn zu einem neuen Token. Betrachten wir ein winziges Korpus mit drei Wörtern:

low
lower
lowest

Ursprünglich besteht jedes Wort nur aus seinen Zeichen:

l o w
l o w e r
l o w e s t

Führen Sie anschließend die Paare von benachbarten Symbolen auf, die vorkommen:

(l, o)
(o, w)
(w, e)
(e, r)
(e, s)
(s, t)

Ein Paar fällt besonders auf:

(l, o)

Es kommt in allen drei Wörtern vor, weshalb es zu einem einzigen Symbol zusammengefasst wird:

(l, o) → lo

Nachdem es überall ersetzt wurde, sieht das Korpus so aus:

lo w
lo w e r
lo w e s t

Wenn man erneut zählt, ist das häufigste Paar jetzt:

(lo, w)

Es erscheint auch dreimal, daher wird es ebenfalls zusammengeführt:

(lo, w) → low

Und das Korpus wird:

low
low e r
low e s t

Nach ausreichend vielen Runden werden die häufigsten Sequenzen zu eigenen Tokenen.

Was der Trainingszyklus hervorbringt

Das Verfahren, das Sie nun umsetzen werden, folgt diesen Schritten:

Training text
→ initialize character tokens
→ count adjacent token pairs
→ select most frequent pair
→ merge the selected pair
→ add merged token to vocabulary
→ repeat until target vocabulary size

Es erzeugt zwei Artefakte, und Sie benötigen beide, um den Tokenisierer später verwenden zu können.

Das Vokabular

Das Vokabular ordnet jeder Token-String eine Ganzzahl-ID zu, zum Beispiel:

"a"       → 0
"b"       → 1
"th"      → 98
"ing"     → 127
" the"    → 204

Die Zusammenführungsregeln

Die Zusammenführungsregeln sind die geordnete Liste der Paare, die während des Trainings zusammengeführt wurden:

("t", "h") → "th"
("i", "n") → "in"
("in", "g") → "ing"
(" ", "the") → " the"

Die Reihenfolge ist entscheidend. Eine spätere Regel wie ("in", "g") kann nur dann ausgelöst werden, wenn die frühere Regel, die "in" erzeugt hat, bereits angewendet wurde. Die Anwendung derselben Regeln in einer anderen Reihenfolge führt zu einer anderen Segmentierung.

Erste Implementierung auf einem kleinen Korpus

Bevor Sie WikiText-2 verwenden, üben Sie den Algorithmus an einem String, den Sie visuell überprüfen können:

text = "low lower lowest"

Die Aufteilung in Zeichen ergibt die Liste der Start-Tokens:

tokens = list(text)
print(tokens)

Das Ausdrucken zeigt jeden Zeichen an, einschließlich der Leerzeichen:

['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r',
 ' ', 'l', 'o', 'w', 'e', 's', 't']

Um Kandidaten für das Zusammenführen zu finden, zählt man jedes Paar von Nachbarn. Das Verschachteln der Liste mit sich selbst, verschoben um eine Position, ergibt jedes benachbarte Paar, und Counter zählt sie auf:

from collections import Counter


def count_pairs(tokens):
    return Counter(
        zip(tokens[:-1], tokens[1:])
    )

Überprüfen Sie die häufigsten Paare:

pair_counts = count_pairs(tokens)
print(pair_counts.most_common(10))

Die Ausgabe wird ungefähr so aussehen:

[(('l', 'o'), 3),
 (('o', 'w'), 3),
 (('w', 'e'), 2),
 ...]

Beachten Sie, dass (' ', 'l') ebenfalls in den Zählungen erscheint. Da Leerzeichen in diesem Schema gewöhnliche Zeichen sind, kann BPE Tokens lernen, die einen führenden Leerzeichen enthalten, wodurch später Tokens wie " the" entstehen.

Fusion einer einzigen Paarung

Die Kernoperation durchläuft die Tokenliste von links nach rechts. Immer wenn das aktuelle Token und das nächste Token zur ausgewählten Paarung passen, gibt sie das gefusionierte Token aus und springt zwei Positionen vorwärts; ansonsten kopiert sie das aktuelle Token und geht einen Schritt weiter. Überschneidungen werden von links nach rechts gelöst: In a a a mit der Paarung (a, a) fusionieren nur die ersten beiden.

def merge_pair(
    tokens,
    pair,
    merged_token,
):
    output = []
    index = 0

    while index < len(tokens):
        if (
            index < len(tokens) - 1
            and tokens[index] == pair[0]
            and tokens[index + 1] == pair[1]
        ):
            output.append(merged_token)
            index += 2
        else:
            output.append(tokens[index])
            index += 1

    return output

Wenden Sie es an, um l und o zu verschmelzen:

tokens = merge_pair(
    tokens=tokens,
    pair=("l", "o"),
    merged_token="lo",
)

print(tokens)

Das Ergebnis ist kürzer als der Eingabetext:

['lo', 'w', ' ', 'lo', 'w', 'e', 'r',
 ' ', 'lo', 'w', 'e', 's', 't']

Jede Verschmelzung entfernt ein Token für jede Vorkunft des Paares – genau daraus entsteht die Kompression.

Wiederholen, bis das Vokabular voll ist

Ein minimaler Trainer läuft einfach im Kreis: Er zählt die Paare, wählt das häufigste aus, verschmilzt es überall, speichert das neue Token sowie die Regel und hört auf, sobald das Vokabular die gewünschte Größe erreicht hat. Er stoppt außerdem frühzeitig, wenn keine Paare mehr vorhanden sind oder wenn das beste Paar seltener als zweimal vorkommt, denn in diesem Fall erzeugt die Verschmelzung nichts mehr, das wiederverwendet werden kann.

def train_bpe_on_tokens(
    tokens,
    target_vocab_size,
):
    vocabulary = set(tokens)
    merges = []

    while len(vocabulary) < target_vocab_size:
        pair_counts = count_pairs(tokens)

        if not pair_counts:
            break

        best_pair, frequency = (
            pair_counts.most_common(1)[0]
        )

        if frequency < 2:
            break

        merged_token = "".join(best_pair)

        tokens = merge_pair(
            tokens=tokens,
            pair=best_pair,
            merged_token=merged_token,
        )

        vocabulary.add(merged_token)
        merges.append(best_pair)

        print(
            f"Merged {best_pair} "
            f"into {merged_token!r}, "
            f"frequency={frequency}"
        )

    return tokens, vocabulary, merges

Durch Ausführen auf der Testzeile mit einem kleinen Ziel ist es ausreichend, um zu beobachten, wie die Verschmelzungen nacheinander stattfinden:

final_tokens, vocabulary, merges = (
    train_bpe_on_tokens(
        tokens=list(text),
        target_vocab_size=20,
    )
)

Diese Version eignet sich gut, um ein Gefühl dafür zu entwickeln, doch bei jeder Iteration wird die gesamte Python-Liste erneut durchsucht. Im vollständigen WikiText-Korpus wird das sehr langsam, weshalb die praktische Version unten stattdessen auf einem konfigurierbaren Teil von WikiText-2 trainiert.

Entwurf eines wiederverwendbaren Tokenisierers

Die Tokenisiererklasse sollte aus Rohtexten ausgehend von bestimmten Zeichen trainieren, kodieren und decodieren, Zeichen außerhalb ihres Wortschatzes ablehnen, Daten in JSON speichern und daraus laden sowie einen Fingerabdruck berechnen. Um diesen herum gibt es ein Skript, das jede Aufteilung von WikiText-2 erneut kodiert und angibt, um wie viel kürzer die BPE-Sequenzen dadurch werden.

Eine Gestaltungsentscheidung sollte klar benannt werden. Dieser Tokenisierer arbeitet mit Unicode-Zeichen und nicht mit rohen Bytes. Die in großen Modellen verwendeten Produktions-Tokenisierer sind in der Regel auf Byte-Ebene ausgelegt, was bedeutet, dass ihr Grundwortschatz aus den 256 möglichen Byte-Werten besteht und keine Eingabe jemals undarstellbar ist. Die hier verwendete zeichensebene Version folgt demselben Algorithmus und ist leichter zu lesen, stellt aber keinen direkten Ersatz für diese Tokenisierer dar.

Das Klassenskelett

Der Konstruktor nimmt die Grundzeichen sowie die geordnete Mergeliste entgegen. Er erstellt die vollständige Tokenliste, indem er mit den Grundzeichen beginnt und das Ergebnis jeder Mergung hinzufügt, wobei Duplikate übersprungen werden. Aus dieser Liste leitet er die Suchfunktionen stoi (String zu ID) und itos (ID zu String) ab und speichert die Größe des Wortschatzes. Da die IDs aus den Listenpositionen stammen, ergeben immer dieselben Grund-Token und Mergen dieselben IDs.

class BPETokenizer:
    def __init__(
        self,
        base_tokens,
        merges,
    ):
        self.base_tokens = list(
            base_tokens
        )

        self.merges = [
            tuple(pair)
            for pair in merges
        ]

        self.tokens = list(
            self.base_tokens
        )

        for left, right in self.merges:
            merged = left + right

            if merged not in self.tokens:
                self.tokens.append(merged)

        self.stoi = {
            token: index
            for index, token in enumerate(
                self.tokens
            )
        }

        self.itos = {
            index: token
            for token, index
            in self.stoi.items()
        }

        self.vocab_size = len(
            self.tokens
        )

Das resultierende Wortschatz setzt sich daher aus zwei Teilen zusammen:

Base character tokens
+
learned merged tokens

Training mit echtem Text

Das Training wird zu einer Klassenmethode, die einen sofort einsetzbaren Tokenisierer zurückgibt. Sie sortiert die einzigartigen Zeichen des Textes, um ein Grundwörterbuch zu erstellen, lehnt eine Zielgröße ab, die kleiner ist als diese Grundmenge, und führt anschließend denselben Zyklus aus Zählen, Auswählen und Zusammenführen wie zuvor aus. Das Protokollieren wird begrenzt: Es werden die ersten 20 Zusammenführungen ausgegeben, danach jede hundertste zusammen mit der Häufigkeitszahl des Paares und der aktuellen Sequenzlänge, sodass man den Komprimierungsfortschritt verfolgen kann, ohne von zu vielen Ausgaben überwältigt zu werden.

@classmethod
def train(
    cls,
    text,
    target_vocab_size,
    minimum_pair_frequency=2,
    verbose=True,
):
    base_tokens = sorted(
        set(text)
    )

    if target_vocab_size < len(base_tokens):
        raise ValueError(
            "target_vocab_size cannot be "
            "smaller than the number of "
            "unique characters"
        )

    working_tokens = list(text)
    vocabulary = set(base_tokens)
    merges = []

    while len(vocabulary) < target_vocab_size:
        pair_counts = Counter(
            zip(
                working_tokens[:-1],
                working_tokens[1:],
            )
        )

        if not pair_counts:
            break

        best_pair, frequency = (
            pair_counts.most_common(1)[0]
        )

        if frequency < minimum_pair_frequency:
            break

        merged_token = "".join(
            best_pair
        )

        working_tokens = merge_pair(
            tokens=working_tokens,
            pair=best_pair,
            merged_token=merged_token,
        )

        merges.append(best_pair)
        vocabulary.add(merged_token)

        if (
            verbose
            and (
                len(merges) <= 20
                or len(merges) % 100 == 0
            )
        ):
            print(
                f"Merge {len(merges):4d}: "
                f"{best_pair!r} "
                f"→ {merged_token!r}, "
                f"frequency={frequency:,}, "
                f"tokens={len(working_tokens):,}"
            )

    return cls(
        base_tokens=base_tokens,
        merges=merges,
    )

Warum es eine Mindestpaarfrequenz gibt

Falls das häufigste verbleibende Paar nur einmal vorkommt, führt seine Zusammenführung zu einem Token, das niemals wieder verwendet wird und somit lediglich einen Wortschatzplatz verschwendet. Der Schleifenlauf stoppt daher unter dieser Bedingung:

frequency < minimum_pair_frequency

Für Experimente wie dieses reicht ein Schwellenwert von zwei aus:

minimum_pair_frequency = 2

Bei einem größeren Korpus möchten Sie möglicherweise einen höheren Schwellenwert verwenden, damit das Vokabular nur für Muster verwendet wird, die häufig genug auftreten, damit das Modell gute Embeddings für sie erlernen kann.

Encodierung und Dekodierung mit dem Tokenisierer

Die Encodierung folgt drei Schritten:

  1. Teilen Sie den Text in Basiszeichen auf.
  2. Wiederholen Sie jede Verschmelzungsregel in der Reihenfolge, in der sie gelernt wurde.
  3. Suchen Sie die ID jedes entstehenden Tokens nach.

Die erste Hälfte dieser Arbeit findet in encode_tokens statt. Zunächst werden alle Zeichen gesammelt, die im Vokabular fehlen, und es wird ein klarer Fehler ausgelöst, falls solche vorhanden sind – was weitaus einfacher zu debuggen ist als ein KeyError mitten in einem Datensatz. Anschließend werden die Verschmelzungen wiederholt.

def encode_tokens(self, text):
    unknown_characters = sorted({
        char
        for char in text
        if char not in self.stoi
    })

    if unknown_characters:
        raise ValueError(
            "Text contains characters missing "
            "from the base vocabulary: "
            f"{unknown_characters}"
        )

    tokens = list(text)

    for pair in self.merges:
        merged_token = (
            pair[0] + pair[1]
        )

        tokens = merge_pair(
            tokens=tokens,
            pair=pair,
            merged_token=merged_token,
        )

    return tokens

Die Umwandlung der resultierenden Token-Strings in IDs ist eine einfache Abfrage:

def encode(self, text):
    tokens = self.encode_tokens(text)

    return [
        self.stoi[token]
        for token in tokens
    ]

Die Dekodierung ist noch einfacher. Jeder Token, egal ob zusammengeführt oder nicht, speichert den genauen Text, den er umfasst, sodass die Dekodierung lediglich eine Kombination darstellt:

def decode(self, token_ids):
    return "".join(
        self.itos[int(token_id)]
        for token_id in token_ids
    )

Das verleiht BPE eine Eigenschaft, die ausdrücklich getestet werden sollte: verlustfreies Hin- und Rückwechseln.

decode(encode(text)) = text

Eine schnelle Überprüfung mit einem Beispiel Satz:

sample = "The history of science."
encoded = tokenizer.encode(sample)
decoded = tokenizer.decode(encoded)
assert decoded == sample

Beibehalten und Neu laden des Tokenisierers

Es ist nicht notwendig, das Vokabular selbst zu speichern, da es deterministisch aus den Basis-Tokenen und der Mergeliste wiederaufgebaut werden kann. Die Save-Methode schreibt diese beiden Komponenten zusammen mit einer Typbezeichnung sowie der Größe des Vokabulars zur besseren Übersicht in JSON. Durch die Einstellung von ensure_ascii=False bleiben nicht-ASCII-Zeichen im Dateiinhalt lesbar, und das übergeordnete Verzeichnis wird erstellt, falls es noch nicht existiert.

def save(self, path):
    data = {
        "type": "character_bpe",
        "base_tokens": self.base_tokens,
        "merges": [
            [left, right]
            for left, right in self.merges
        ],
        "vocab_size": self.vocab_size,
    }

    path = Path(path)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    with open(
        path,
        "w",
        encoding="utf-8",
    ) as file:
        json.dump(
            data,
            file,
            ensure_ascii=False,
            indent=2,
        )

Das Ergebnis ist eine einzige Datei:

tokenizer/
└── bpe_tokenizer.json

Beim Laden wird der Prozess umgekehrt, indem das JSON gelesen und beide Listen an den Konstruktor übergeben werden:

@classmethod
def from_file(cls, path):
    with open(
        path,
        "r",
        encoding="utf-8",
    ) as file:
        data = json.load(file)

    return cls(
        base_tokens=data[
            "base_tokens"
        ],
        merges=data["merges"],
    )

Bestätigen Sie anschließend, dass der neu geladene Tokenisierer den Beispieltext weiterhin hin- und herbefördert:

loaded_tokenizer = (
    BPETokenizer.from_file(
        "tokenizer/bpe_tokenizer.json"
    )
)

assert (
    loaded_tokenizer.decode(
        loaded_tokenizer.encode(sample)
    )
    == sample
)

Training mit einem Teil von WikiText-2

Weil der einfache Trainer bei jeder Fusion die gesamte Arbeitssequenz erneut durchscannt, kann das Training mit dem gesamten WikiText-2 sowie einem umfangreichen Wortschatz viel Zeit in Anspruch nehmen. Beginnen Sie mit einem konfigurierbaren Präfix des Trainingstextes:

training_characters = 500_000

training_text = train_text[
    :training_characters
]

Eine angemessene Anfangsgröße des Wortschatzes beträgt:

target_vocab_size = 500

Anders ausgedrückt:

Initial character vocabulary
+
learned merged tokens
=
approximately 500 tokens

Wenn alles funktioniert, versuchen Sie größere Ziele:

target_vocab_size = 1_000
target_vocab_size = 2_000

Erwarten Sie, dass die Trainingszeit mit der Größe der Stichprobe sowie der Anzahl der Verschmelzungen zunimmt.

Grundzeichen aus jeder Aufteilung verwenden

Falls das Grundwortschatz nur aus der Trainingsstichprobe erstellt wird, können der Validierungs- oder Testtext Zeichen enthalten, die darin nie vorkamen, wodurch die Kodierung dieser Aufteilungen fehlschlagen wird. Die Lösung besteht darin, den Grundzeichensatz aus allen drei Aufteilungen zu sammeln, während die Statistiken zur Verschmelzung weiterhin ausschließlich aus den Trainingsdaten ermittelt werden:

all_text = (
    train_text
    + val_text
    + test_text
)

base_tokens = sorted(
    set(all_text)
)

Dies lässt keine Validierungs-Sprachmuster in die Merges einfließen. Es garantiert lediglich, dass jedes Zeichen im Datensatz eine ID hat. Zur Unterstützung erhält der Trainer ein optionales Argument base_tokens, das bei Bereitstellung anstelle der Zeichen des Trainingstextes verwendet wird:

@classmethod
def train(
    cls,
    text,
    target_vocab_size,
    minimum_pair_frequency=2,
    base_tokens=None,
    verbose=True,
):
    if base_tokens is None:
        base_tokens = sorted(
            set(text)
        )
    else:
        base_tokens = sorted(
            set(base_tokens)
        )

Die erzielte Kompression messen

Um zu erkennen, was BPE Ihnen bringt, vergleichen Sie die Token-Zahlen im selben Textabschnitt. Nehmen Sie ein Beispiel:

sample_text = train_text[:10_000]

Die Anzahl der Zeichentoken beträgt einfach seine Länge:

character_count = len(sample_text)

Die Anzahl der BPE-Token entspricht der Länge der kodierten ID-Liste:

bpe_ids = tokenizer.encode(
    sample_text
)

bpe_count = len(bpe_ids)

Das Verhältnis zwischen ihnen ist der Kompressionsfaktor:

compression_factor = (
    character_count / bpe_count
)

Ein Ergebnis könnte so aussehen:

Character tokens: 10,000
BPE tokens: 4,250
Compression: 2.35×

Mit einem Kompressionsfaktor von 2,35 umfasst ein Block aus 128 BPE-Token in etwa so viel Text wie 301 Zeichentoken. Die Kompression ist jedoch nicht gleichmäßig: Prosa, die aus häufigen Wörtern besteht, lässt sich gut komprimieren, während Zahlen, seltene Namen und ungewöhnliche Satzzeichen nur schlecht komprimiert werden.

Was der Tokenisierer gelernt hat

Die gelernten Token sind alles im Wortschatz, was über die Basiszeichen hinausgeht. Wenn man sie nach Länge sortiert und die längsten zusammen mit ihren IDs anzeigt, erhält man einen schnellen Eindruck davon, was der Tokenisierer als häufig ansieht:

learned_tokens = (
    tokenizer.tokens[
        len(tokenizer.base_tokens):
    ]
)

longest_tokens = sorted(
    learned_tokens,
    key=len,
    reverse=True,
)

for token in longest_tokens[:50]:
    print(
        repr(token),
        tokenizer.stoi[token],
    )

Typischerweise werden Sie Tokens in dieser Form sehen:

" the"
"ing"
"tion"
" of"
"ed"
" and"

Die genaue Liste hängt vom Trainingsbeispiel, der Vorkonvertierung, der Größe des Zielwortschatzes, der Mindesthäufigkeit sowie den Regeln zur Entscheidung bei Gleichheit ab. Bei der Entscheidung bei Gleichheit gibt Counter.most_common die gleich häufig vorkommenden Paare in der Reihenfolge zurück, in der sie erstmals gezählt wurden; daher sind die Ergebnisse für ein gegebenes Eingabedatum deterministisch. Eine Änderung des Beispiels kann jedoch bestimmen, welches von mehreren gleich häufigen Paaren gewinnt.

Neue Kodierung des Datensatzes

Mit einem trainierten Tokenisierer sollten alle drei Datensätze kodiert werden:

train_bpe_ids = tokenizer.encode(
    train_text
)

val_bpe_ids = tokenizer.encode(
    val_text
)

test_bpe_ids = tokenizer.encode(
    test_text
)

Übersetzen Sie die ID-Listen in long-Tensoren um, da dies der Typ ist, den Embedding-Schichten für Indizes erwarten:

train_tensor = torch.tensor(
    train_bpe_ids,
    dtype=torch.long,
)

val_tensor = torch.tensor(
    val_bpe_ids,
    dtype=torch.long,
)

test_tensor = torch.tensor(
    test_bpe_ids,
    dtype=torch.long,
)

Speichern Sie sie in ihrem eigenen Verzeichnis:

data/
└── encoded_bpe/
    ├── train_ids.pt
    ├── val_ids.pt
    └── test_ids.pt

Lassen Sie die zeichenseitig kodierten Dateien unberührt; die Beibehaltung beider erleichtert spätere Vergleiche und verhindert, dass einem Modell die IDs des anderen übergeben werden.

Überprüfung der kodierten Aufteilungen

Zunächst muss jede ID im Bereich von null bis, aber ohne die Größe des Wortschatzes, liegen:

for split_name, token_ids in [
    ("train", train_tensor),
    ("validation", val_tensor),
    ("test", test_tensor),
]:
    assert token_ids.min().item() >= 0

    assert (
        token_ids.max().item()
        < tokenizer.vocab_size
    )

    print(
        split_name,
        token_ids.shape,
        token_ids.min().item(),
        token_ids.max().item(),
    )

Zweitens sollte ein Beispiel aus jeder Aufteilung wieder zum ursprünglichen Text zurückgeführt werden:

for text in [
    train_text[:1_000],
    val_text[:1_000],
    test_text[:1_000],
]:
    encoded = tokenizer.encode(text)
    decoded = tokenizer.decode(encoded)

    assert decoded == text

Einfügen von BPE in das Modell

Die Modellarchitektur erfordert keine strukturellen Änderungen. Nur die Größe des Wortschatzes in der Konfiguration ändert sich:

config = MiniGPTConfig(
    vocab_size=bpe_tokenizer.vocab_size,
    block_size=128,
    embedding_dim=128,
    num_heads=4,
    num_layers=4,
    expansion_factor=4,
    dropout=0.1,
)

Stellen Sie die Datenladung auf die neuen Dateien ein:

train_ids = torch.load(
    "data/encoded_bpe/train_ids.pt"
)

Von nun an prognostiziert das Modell BPE-Token anstelle von Zeichen.

Alte Checkpoints werden nicht geladen

Ein Zeichenumodell hat in der Regel ein Wörterbuch in diesem Bereich:

100 to 300 character tokens

Ein BPE-Modell wird in der Regel etwas wie folgt haben:

500 to 2,000 subword tokens

Sowohl die Token-Embedding-Struktur als auch der Ausgabekopf hängen von der Größe des Wortschatzes ab, wodurch sich ihre Formen ändern. Ein Zeichencheckpoint weist Folgendes auf:

token embedding: [character_vocab_size, C]

Während das BPE-Modell erwartet:

token embedding: [bpe_vocab_size, C]

Hier ist C die Embedding-Dimension. Die Formen stimmen nicht überein, daher können die alten Gewichte nicht direkt geladen werden. Und selbst wenn das möglich wäre, bedeuten die IDs unter jedem Tokenisierer unterschiedliche Dinge. Sie müssen ein neues Modell trainieren, und es hilft, die Checkpoints getrennt zu halten:

checkpoints_char/
checkpoints_bpe/

Oder, äquivalent dazu:

checkpoints/char/
checkpoints/bpe/

Fingerabdruck des Tokenisierers

Ein Formunterschied ist auffällig; das Koppeln eines Checkpoints mit einem anderen BPE-Tokenisierer derselben Größe bleibt hingegen unbemerkt, da jede ID einfach auf den falschen Text verweist. Der Fingerabdruck hashiert eine kompakte JSON-Form der Basis-Token und verschmilzt sie mit SHA-256, sodass jede Änderung einen neuen Wert erzeugt.

import hashlib


def tokenizer_fingerprint(tokenizer):
    payload = json.dumps(
        {
            "base_tokens": (
                tokenizer.base_tokens
            ),
            "merges": [
                list(pair)
                for pair in tokenizer.merges
            ],
        },
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")

    return hashlib.sha256(
        payload
    ).hexdigest()

Speichern Sie die Fingerabdruckdaten innerhalb jedes Checkpoints:

checkpoint[
    "tokenizer_fingerprint"
] = tokenizer_fingerprint(
    tokenizer
)

Zur Ladezeit wird der Fingerabdruck des aktiven Tokenisierers berechnet, und es wird abgelehnt, fortzufahren, wenn er nicht mit demjenigen übereinstimmt, der zusammen mit dem Checkpoint gespeichert wurde.

Die Leistungsbeschränkung, die Sie kennen sollten

Der Encoder wiedergibt jede Verschmelzungsregel in der gesamten Tokenfolge ab:

for pair in self.merges:
    tokens = merge_pair(...)

Das bedeutet eine vollständige Durchlaufung des Textes bei jeder Fusion, wodurch große Texte mit Tausenden von Fusionen nur langsam kodiert werden. Die Implementierung bevorzugt bewusst Klarheit, Korrektheit und Überprüfbarkeit gegenüber Geschwindigkeit. Produktive Tokenisierer teilen den Text bereits im Voraus in Wörter auf, speichern wiederholte Segmentierungen im Cache und wenden Fusionen nach Rangfolge an; zum Lernen ist die transparente Version vorteilhafter.

Das vollständige Programm

Bringen Sie alles in einem einzigen Skript zusammen:

build_bpe_tokenizer.py

Das untenstehende vollständige Programm kombiniert die Hilfsfunktionen für Paare, die Tokenisierungsklasse, Validierungs-Hilfsfunktionen sowie einen Eingangspunkt für die Kommandozeile. Zwei Aspekte unterscheiden sich von den Beispielen: Beim Speichern wird nun der Fingerabdruck gespeichert und über eine temporäre Datei geschrieben, die anschließend durch die Zieldatei ersetzt wird, sodass ein unterbrochener Ablauf niemals eine unvollständig geschriebene Datei hinterlässt. Zudem überprüft das Training, ob das Grundvokabular den Trainingstext abdeckt. Die Hauptfunktion trainiert mit dem konfigurierten Präfix, speichert und lädt die Tokenisierungsklasse neu, prüft die Fingerabdrücke, führt Round-Trip-Tests durch und speichert jede Aufteilung, validiert die ID-Bereiche sowie gibt Statistiken aus.

In der Schleife „längste gelernten Tokens“ scheint eine Zeile beschädigt worden zu sein: f"{loaded_tokenizer.stoi5d} " löst einen AttributeError aus, da stoi5d nicht existiert. Der beabsichtigte Ausdruck ist mit ziemlicher Sicherheit f"{loaded_tokenizer.stoi[token]:5d} ", der die ID jedes Tokens mit fünf Zeichen aufgefüllt ausgibt. Beheben Sie diese Zeile, bevor Sie das Skript ausführen.

import argparse
import hashlib
import json
from collections import Counter
from pathlib import Path

import torch


# ==================================================
# Pair Operations
# ==================================================

def count_pairs(tokens):
    return Counter(
        zip(tokens[:-1], tokens[1:])
    )


def merge_pair(
    tokens,
    pair,
    merged_token,
):
    output = []
    index = 0

    while index < len(tokens):
        if (
            index < len(tokens) - 1
            and tokens[index] == pair[0]
            and tokens[index + 1] == pair[1]
        ):
            output.append(merged_token)
            index += 2
        else:
            output.append(tokens[index])
            index += 1

    return output


# ==================================================
# BPE Tokenizer
# ==================================================

class BPETokenizer:
    def __init__(
        self,
        base_tokens,
        merges,
    ):
        self.base_tokens = list(
            base_tokens
        )

        self.merges = [
            tuple(pair)
            for pair in merges
        ]

        self.tokens = list(
            self.base_tokens
        )

        for left, right in self.merges:
            merged_token = left + right

            if merged_token not in self.tokens:
                self.tokens.append(
                    merged_token
                )

        self.stoi = {
            token: index
            for index, token
            in enumerate(self.tokens)
        }

        self.itos = {
            index: token
            for token, index
            in self.stoi.items()
        }

        self.vocab_size = len(
            self.tokens
        )

    @classmethod
    def train(
        cls,
        text,
        target_vocab_size,
        minimum_pair_frequency=2,
        base_tokens=None,
        verbose=True,
    ):
        if not text:
            raise ValueError(
                "Training text cannot be empty"
            )

        if base_tokens is None:
            base_tokens = sorted(
                set(text)
            )
        else:
            base_tokens = sorted(
                set(base_tokens)
            )

        missing_characters = (
            set(text) - set(base_tokens)
        )

        if missing_characters:
            raise ValueError(
                "Base vocabulary is missing "
                "training characters"
            )

        if (
            target_vocab_size
            < len(base_tokens)
        ):
            raise ValueError(
                "target_vocab_size cannot be "
                "smaller than the number of "
                "base tokens"
            )

        working_tokens = list(text)
        vocabulary = set(base_tokens)
        merges = []

        print(
            "Initial characters:",
            len(working_tokens),
        )

        print(
            "Base vocabulary size:",
            len(base_tokens),
        )

        while (
            len(vocabulary)
            < target_vocab_size
        ):
            pair_counts = count_pairs(
                working_tokens
            )

            if not pair_counts:
                print(
                    "No adjacent pairs remain."
                )
                break

            best_pair, frequency = (
                pair_counts.most_common(1)[0]
            )

            if (
                frequency
                < minimum_pair_frequency
            ):
                print(
                    "Stopping because the most "
                    "frequent pair occurs only "
                    f"{frequency} time(s)."
                )
                break

            merged_token = "".join(
                best_pair
            )

            working_tokens = merge_pair(
                tokens=working_tokens,
                pair=best_pair,
                merged_token=merged_token,
            )

            merges.append(best_pair)
            vocabulary.add(merged_token)

            merge_number = len(merges)

            if (
                verbose
                and (
                    merge_number <= 20
                    or merge_number % 100 == 0
                )
            ):
                print(
                    f"Merge {merge_number:4d}: "
                    f"{best_pair!r} "
                    f"→ {merged_token!r}, "
                    f"frequency={frequency:,}, "
                    f"sequence length="
                    f"{len(working_tokens):,}, "
                    f"vocabulary="
                    f"{len(vocabulary):,}"
                )

        tokenizer = cls(
            base_tokens=base_tokens,
            merges=merges,
        )

        print(
            "Final vocabulary size:",
            tokenizer.vocab_size,
        )

        print(
            "Final training sequence length:",
            len(working_tokens),
        )

        return tokenizer

    @classmethod
    def from_file(cls, path):
        with open(
            path,
            "r",
            encoding="utf-8",
        ) as file:
            data = json.load(file)

        return cls(
            base_tokens=data[
                "base_tokens"
            ],
            merges=data["merges"],
        )

    def encode_tokens(self, text):
        unknown_characters = sorted({
            char
            for char in text
            if char not in self.stoi
        })

        if unknown_characters:
            raise ValueError(
                "Text contains characters "
                "missing from the base vocabulary: "
                f"{unknown_characters}"
            )

        tokens = list(text)

        for left, right in self.merges:
            tokens = merge_pair(
                tokens=tokens,
                pair=(left, right),
                merged_token=left + right,
            )

        return tokens

    def encode(self, text):
        return [
            self.stoi[token]
            for token in self.encode_tokens(
                text
            )
        ]

    def decode(self, token_ids):
        return "".join(
            self.itos[int(token_id)]
            for token_id in token_ids
        )

    def save(self, path):
        path = Path(path)

        path.parent.mkdir(
            parents=True,
            exist_ok=True,
        )

        data = {
            "type": "character_bpe",
            "base_tokens": (
                self.base_tokens
            ),
            "merges": [
                [left, right]
                for left, right
                in self.merges
            ],
            "vocab_size": (
                self.vocab_size
            ),
            "fingerprint": (
                self.fingerprint()
            ),
        }

        temporary_path = path.with_suffix(
            ".tmp"
        )

        with open(
            temporary_path,
            "w",
            encoding="utf-8",
        ) as file:
            json.dump(
                data,
                file,
                ensure_ascii=False,
                indent=2,
            )

        temporary_path.replace(path)

    def fingerprint(self):
        payload = json.dumps(
            {
                "base_tokens": (
                    self.base_tokens
                ),
                "merges": [
                    list(pair)
                    for pair in self.merges
                ],
            },
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")

        return hashlib.sha256(
            payload
        ).hexdigest()


# ==================================================
# Validation
# ==================================================

def validate_round_trip(
    tokenizer,
    text,
    split_name,
):
    encoded = tokenizer.encode(text)
    decoded = tokenizer.decode(encoded)

    if decoded != text:
        raise AssertionError(
            f"Round-trip failed for "
            f"{split_name}"
        )

    print(
        f"{split_name} round-trip passed: "
        f"{len(text):,} characters "
        f"→ {len(encoded):,} tokens"
    )

    return encoded


def save_token_tensor(
    token_ids,
    path,
):
    path = Path(path)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    tensor = torch.tensor(
        token_ids,
        dtype=torch.long,
    )

    temporary_path = path.with_suffix(
        ".tmp"
    )

    torch.save(
        tensor,
        temporary_path,
    )

    temporary_path.replace(path)

    return tensor


# ==================================================
# Arguments
# ==================================================

def parse_args():
    parser = argparse.ArgumentParser(
        description=(
            "Train a simple educational BPE "
            "tokenizer on WikiText-2"
        )
    )

    parser.add_argument(
        "--processed-dir",
        type=str,
        default="data/processed",
    )

    parser.add_argument(
        "--tokenizer-output",
        type=str,
        default=(
            "tokenizer/bpe_tokenizer.json"
        ),
    )

    parser.add_argument(
        "--encoded-output-dir",
        type=str,
        default="data/encoded_bpe",
    )

    parser.add_argument(
        "--target-vocab-size",
        type=int,
        default=500,
    )

    parser.add_argument(
        "--training-characters",
        type=int,
        default=500_000,
    )

    parser.add_argument(
        "--minimum-pair-frequency",
        type=int,
        default=2,
    )

    return parser.parse_args()


# ==================================================
# Main Program
# ==================================================

def main():
    args = parse_args()

    processed_dir = Path(
        args.processed_dir
    )

    train_path = (
        processed_dir
        / "wikitext2_train_clean.txt"
    )

    val_path = (
        processed_dir
        / "wikitext2_val_clean.txt"
    )

    test_path = (
        processed_dir
        / "wikitext2_test_clean.txt"
    )

    for path in [
        train_path,
        val_path,
        test_path,
    ]:
        if not path.exists():
            raise FileNotFoundError(
                f"Required file not found: "
                f"{path}"
            )

    train_text = train_path.read_text(
        encoding="utf-8"
    )

    val_text = val_path.read_text(
        encoding="utf-8"
    )

    test_text = test_path.read_text(
        encoding="utf-8"
    )

    print(
        "Training characters:",
        len(train_text),
    )

    print(
        "Validation characters:",
        len(val_text),
    )

    print(
        "Test characters:",
        len(test_text),
    )

    all_characters = sorted(
        set(
            train_text
            + val_text
            + test_text
        )
    )

    training_character_count = min(
        args.training_characters,
        len(train_text),
    )

    bpe_training_text = train_text[
        :training_character_count
    ]

    print(
        "\nBPE training characters:",
        len(bpe_training_text),
    )

    tokenizer = BPETokenizer.train(
        text=bpe_training_text,
        target_vocab_size=(
            args.target_vocab_size
        ),
        minimum_pair_frequency=(
            args.minimum_pair_frequency
        ),
        base_tokens=all_characters,
        verbose=True,
    )

    tokenizer.save(
        args.tokenizer_output
    )

    print(
        "\nTokenizer saved to:",
        args.tokenizer_output,
    )

    print(
        "Tokenizer fingerprint:",
        tokenizer.fingerprint(),
    )

    loaded_tokenizer = (
        BPETokenizer.from_file(
            args.tokenizer_output
        )
    )

    assert (
        loaded_tokenizer.fingerprint()
        == tokenizer.fingerprint()
    )

    print(
        "Tokenizer reload test passed."
    )

    print(
        "\nEncoding dataset splits..."
    )

    train_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=train_text,
        split_name="Training",
    )

    val_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=val_text,
        split_name="Validation",
    )

    test_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=test_text,
        split_name="Test",
    )

    output_dir = Path(
        args.encoded_output_dir
    )

    train_tensor = save_token_tensor(
        train_ids,
        output_dir / "train_ids.pt",
    )

    val_tensor = save_token_tensor(
        val_ids,
        output_dir / "val_ids.pt",
    )

    test_tensor = save_token_tensor(
        test_ids,
        output_dir / "test_ids.pt",
    )

    for split_name, tensor in [
        ("Training", train_tensor),
        ("Validation", val_tensor),
        ("Test", test_tensor),
    ]:
        assert tensor.min().item() >= 0

        assert (
            tensor.max().item()
            < loaded_tokenizer.vocab_size
        )

        print(
            f"{split_name} tensor: "
            f"shape={tuple(tensor.shape)}, "
            f"min={tensor.min().item()}, "
            f"max={tensor.max().item()}"
        )

    character_tokens = len(
        train_text
    )

    bpe_tokens = len(
        train_ids
    )

    compression_factor = (
        character_tokens / bpe_tokens
    )

    reduction_percentage = (
        1.0
        - bpe_tokens / character_tokens
    ) * 100.0

    print(
        "\nCharacter token count:",
        f"{character_tokens:,}",
    )

    print(
        "BPE token count:",
        f"{bpe_tokens:,}",
    )

    print(
        "Compression factor:",
        f"{compression_factor:.2f}x",
    )

    print(
        "Token-count reduction:",
        f"{reduction_percentage:.2f}%",
    )

    learned_tokens = (
        loaded_tokenizer.tokens[
            len(
                loaded_tokenizer.base_tokens
            ):
        ]
    )

    longest_tokens = sorted(
        learned_tokens,
        key=len,
        reverse=True,
    )

    print("\nLongest learned tokens:")

    for token in longest_tokens[:30]:
        print(
            f"{loaded_tokenizer.stoi5d} "
            f"{token!r}"
        )

    sample = (
        "Artificial intelligence "
        "and machine learning"
    )

    unknown = [
        char
        for char in sample
        if char not in (
            loaded_tokenizer.stoi
        )
    ]

    if not unknown:
        sample_tokens = (
            loaded_tokenizer.encode_tokens(
                sample
            )
        )

        sample_ids = (
            loaded_tokenizer.encode(
                sample
            )
        )

        print("\nSample text:")
        print(sample)

        print("\nSample BPE tokens:")
        print(sample_tokens)

        print("\nSample token IDs:")
        print(sample_ids)

        print("\nDecoded sample:")
        print(
            loaded_tokenizer.decode(
                sample_ids
            )
        )

    print(
        "\nBPE tokenizer pipeline "
        "completed successfully."
    )


if __name__ == "__main__":
    main()

Ausführen

Die Standardkonfiguration ist ein kleiner, lehrreicher Ausführungsvorgang:

python build_bpe_tokenizer.py

Um ein größeres Wortschatz zu erlernen:

python build_bpe_tokenizer.py \
  --target-vocab-size 1000

Um auch mit mehr Text zu trainieren:

python build_bpe_tokenizer.py \
  --training-characters 1000000 \
  --target-vocab-size 1000

Für einen schnellen Smoke-Test, der lediglich bestätigt, dass der Pipeline funktioniert:

python build_bpe_tokenizer.py \
  --training-characters 100000 \
  --target-vocab-size 300

Falls Ihre Shell keine Fortsetzung von Zeilen mit Backslash unterstützt, geben Sie jeden Befehl in einer einzigen Zeile ein.

Wie das Ausgabeergebnis aussieht

Ein Ausführungsvorgang könnte etwas wie Folgendes ausgeben:

Training characters: 10,000,000
Validation characters: 1,100,000
Test characters: 1,200,000

BPE training characters: 500,000
Initial characters: 500,000
Base vocabulary size: 200

Merge    1: (' ', 't') → ' t', frequency=19,842
Merge    2: ('h', 'e') → 'he', frequency=17,415
Merge    3: (' t', 'he') → ' the', frequency=14,108
...
Final vocabulary size: 500

Die ersten Zusammenführungen entsprechen genau dem, was man von Englisch erwarten würde: ein Leerzeichen gefolgt von t, dann he, anschließend werden diese beiden zu " the" zusammengefügt. Nach der Kodierung sieht die Zusammenfassung ungefähr so aus:

Character token count: 10,000,000
BPE token count: 4,500,000
Compression factor: 2.22x
Token-count reduction: 55.00%

Ihre Zahlen werden variieren. Beachten Sie, dass das Durchlaufen des gesamten Trainingsdatensatzes mit diesem Encoder bei größeren Wörterbüchern den Laufzeitbedarf stark erhöhen kann.

Was ändert sich am Modell und seinem Kontextfenster

Token-Zahlen in einem echten Satz

Nehmen wir diesen Satz:

The history of artificial intelligence

Ein Zeichentokenisierer erzeugt ungefähr diese Anzahl an Tokenen:

38 tokens

Ein BPE-Tokenizer könnte Tokens für ganze Wörter wie diese erzeugen:

["The", " history", " of", " artificial", " intelligence"]

Oder es könnte kleinere Fragmente erzeugen, je nachdem, welche Fusionen es gelernt hat. Auf jeden Fall liegt die Anzahl in einem Bereich wie:

5 to 15 tokens

Die Segmentierung wird nicht manuell erstellt; sie ergibt sich aus den Statistiken des Trainingskorpus.

Mehr Text pro Kontextfenster

Nehmen wir an, das Modell verwendet diese Blockgröße:

block_size = 128

Mit Zeichen decken 128 Token etwa 128 Zeichen ab. Mit BPE können dieselben 128 Token mehrere hundert Zeichen abdecken. Das Modell erhält mehr sprachlichen Kontext, ohne dass die Rechenlast für die Selbstaufmerksamkeit zunimmt. Das ist wichtig, denn die Kosten für die Selbstaufmerksamkeit steigen in etwa mit:

T²

Hier ist T die Länge der Sequenz; dadurch verdoppelt sich die Aufmerksamkeitskosten im Großen und Ganzen, wenn T verdoppelt wird, während eine bessere Tokenisierung mehr Text in dieselbe T-Länge packt.

Der Kompromiss bei der Größe des Wortschatzes

Ein größerer BPE-Wortschatz bedeutet in der Regel kürzere Sequenzen, führt aber auch dazu, dass bestimmte Matrizen größer werden. Die Token-Embedding-Tabelle enthält so viele Parameter:

vocab_size × embedding_dim

Durch das Verknüpfen der Gewichte wendet der Ausgabekopf dieselbe Matrix erneut an, anstatt eine zweite hinzuzufügen. Vergleichen Sie ein kleines Wörterbuch:

Vocabulary:          500
Embedding dimension: 128
Embedding parameters: 64,000

Mit einem größeren:

Vocabulary: 8,000
Embedding dimension: 128
Embedding parameters: 1,024,000

Ein größeres Vokabular verkürzt zwar die Sequenzen, erhöht aber die Embedding-Parameter, die Kosten für die Ausgabelogits, den Speicherverbrauch sowie die Trainingszeit des Tokenizers. Jedes zusätzliche Token benötigt außerdem ausreichend Vorkommen, um ein gutes Embedding zu erlernen; daher führt ein riesiges Vokabular in einem Korpus wie WikiText-2 zu vielen kaum trainierten Tokenen. Die richtige Größe hängt vom Datensatz und vom Modell ab.

Neuentrainieren des Modells mit BPE-Tokenen

Erweitern Sie das Trainingsskript, um die Tokenizer-Datei zu laden:

tokenizer/bpe_tokenizer.json

Und die kodierten Aufteilungen:

data/encoded_bpe/train_ids.pt
data/encoded_bpe/val_ids.pt
data/encoded_bpe/test_ids.pt

Dann erstellen Sie die Konfiguration anhand der Wortvorratsgröße des Tokenisierers:

config = MiniGPTConfig(
    vocab_size=bpe_tokenizer.vocab_size,
    block_size=128,
    embedding_dim=128,
    num_heads=4,
    num_layers=4,
    expansion_factor=4,
    dropout=0.1,
)

Und schreiben Sie Kontrollpunkte in ihre eigene Verzeichnisstruktur:

checkpoints/bpe/

Ausgenommen diese Änderungen kann der Trainingszyklus im Wesentlichen unverändert bleiben.

Ein fairer Vergleich der beiden Modelle

Um einen sinnvollen Vergleich zu gewährleisten, sollten Sie die Architektur (Breite, Tiefe, Heads), die Daten, den Optimierer, den Zeitplan, das Trainingsbudget sowie die Bewertungsmethode dort, wo möglich, unverändert lassen. Selbst dann ist ein Vergleich bei gleicher Anzahl an Trainingsschritten irreführend, da eine Batch von BPE-Tokens deutlich mehr Text enthält als eine Batch von Zeichen. Bessere Vergleichsgrößen sind:

Total characters processed
Total bytes processed
Training time
Compute budget

Die Perplexität wird pro Vorhersageeinheit berechnet, weshalb die Perplexitäten für Zeichen und BPE nicht direkt verglichen werden können. Um einen vergleichbaren Wert zu erhalten, müssen beide in Bits pro Zeichen umgerechnet werden; ansonsten sollte jedes Modell anhand der Generierungsqualität, der berechneten Kosten sowie metriken, die zum Tokenisierer passen, bewertet werden.

Häufige Fehler

Erstellung eines separaten Tokenisierers für jede Aufteilung

Das ist falsch:

Train tokenizer from training text
Validation tokenizer from validation text
Test tokenizer from test text

Das Trainings-, Validierungs- und Testdatenmaterial muss vollständig mit demselben Tokenizer kodiert werden; andernfalls bedeutet derselbe ID in verschiedenen Datensätzen unterschiedliche Dinge.

Anwendung von Merges in umgekehrter Reihenfolge

Merges bauen aufeinander auf, daher müssen sie genau in der Reihenfolge ausgeführt werden, in der sie erlernt wurden:

for pair in self.merges:
    apply(pair)

Das Sortieren der Regeln, beispielsweise alphabetisch zur besseren Lesbarkeit, verändert die Art und Weise, wie der Text segmentiert wird.

Überschreiben der zeichencodebasierten Dateien

Halten Sie die BPE-IDs in ihrem eigenen Verzeichnis:

data/encoded_bpe/

Und lassen Sie die Charakter-ID‑Werte dort, wo sie sind:

data/encoded/

Lernen aus Validierungstext

Die Zusammenführungsstatistiken sollten ausschließlich aus dem Trainingstext stammen. Es ist in Ordnung, das Grundzeichensatz aus jeder Aufteilung zu verwenden, um Abdeckung zu gewährleisten, doch die Zusammenführungshäufigkeiten müssen ausschließlich das Trainingskorpus widerspiegeln – andernfalls sind Ihre Validierungszahlen zu optimistisch.

Erwartung, dass gelernte Token vollständige Wörter sind

BPE lernt gerne Fragmente wie diese:

"ing"
"tion"
" th"
" arti"

Subwort-Token folgen der Häufigkeit und nicht menschlichen Wortgrenzen – das ist ein erwartetes Verhalten.

Die Annahme, dass ein größeres Vokabular immer besser ist

Größere Vokabulare erhöhen die Kosten und erzeugen seltene, schlecht trainierte Token. Probieren Sie verschiedene Größen aus und messen Sie die Ergebnisse. Vergleichen Sie außerdem niemals die Roh-Perplexität verschiedener Tokenisierer.

Übungen

  1. Vergleichen Sie drei Vokabulargrößen. Trainieren Sie mit den unten angegebenen Größen und vergleichen Sie die Trainingszeit, die Sequenzlänge, die Kompression, die längsten Token sowie die Größe der Embeddings.
300 tokens
500 tokens
1,000 tokens
  1. Studieren Sie die frühen Fusionen. Drucken Sie die ersten 50 Fusionenregeln aus. Treten Leerzeichenmuster auf? Häufige Suffixe? Satzzeichenkombinationen? Welche Fusionen überraschen Sie?
  2. Tokenisieren Sie einen Satz in beiden Richtungen. Kodieren Sie den folgenden Satz sowohl mit dem Zeichentokenisator als auch mit dem BPE-Tokenisator und vergleichen Sie die Tokens, IDs, die Anzahl der Tokens sowie das decodierte Ergebnis.
Artificial intelligence is transforming technology.
  1. Probieren Sie seltene Wörter aus. Kodieren Sie die folgenden Wörter und beobachten Sie, wie BPE unbekannte Wörter aus bekannten Teilen zusammensetzt.
electromagnetism
neurocomputational
uncharacteristically
  1. Messen Sie die Kontextabdeckung. Dekodieren Sie die ersten 128 Tokens unter jedem Tokenisierer und prüfen Sie, wie viel Text jede Fenstergröße umfasst.
  2. Trainieren Sie ein BPE-Modell. Trainieren Sie es mit den Dateien im untenstehenden Verzeichnis und vergleichen Sie die Ergebnisse mit dem Zeichenmodell unter gleichen Recheneinsatzbedingungen.
data/encoded_bpe/
  1. Fingerabdrücke durchsetzen. Speichern Sie den Fingerabdruck des Tokenisierers in jedem Checkpoint und weigern Sie sich, Inferenz durchzuführen, wenn der Checkpoint und der aktuelle Tokenisierer nicht übereinstimmen.
  2. Die Kodierung beschleunigen. Ersetzen Sie den Encoder, der bei jeder Verschmelzung einen Durchlauf benötigt, durch eine schnellere Variante, die Merge-Ranks, Heaps, vernetzte Tokenlisten, Wortspeicher oder Try-Strukturen nutzt.

Regeln zur Tokenisierung, die beibehalten werden sollten

  • Subwörter befinden sich zwischen flexiblen, aber langen Zeichen und kompakten, aber brüchigen Wörtern.
  • Ein BPE-Tokenisierer wird vollständig durch seine Basis-Token sowie seine geordneten Verschmelzungen definiert; speichern und erfassen Sie genau diese Elemente.
  • Bei der Kodierung werden die Verschmelzungen in der richtigen Reihenfolge wiederholt, bei der Dekodierung werden sie gekoppelt, und Round-Trips sind verlustfrei.
  • Ein einziger Tokenisierer dient für jede Aufteilung, und Verschmelzungen werden ausschließlich aus Trainingsdaten gelernt.
  • Ein neuer Tokenisierer bedeutet neue Token-Dateien, eine größere Vokabellänge sowie ein neu trainiertes Modell.
  • Die Vokabellänge ermöglicht kürzere Sequenzen, erfordert aber größere Embedding- und Ausgabeschichten, und die Perplexität überträgt sich nicht zwischen verschiedenen Tokenisierern.
  • Der gesamte Prozessablauf, von Rohtext bis zum neu trainierten Modell, sieht wie folgt aus:

    WikiText training text
    → character vocabulary
    → pair frequencies
    → repeated merge operations
    → BPE vocabulary and merge rules
    → encode all splits
    → save token tensors
    → train a new Mini-GPT
    

    Im Grunde genommen ist Tokenisierung kein Detail der Vorausverarbeitung. Sie bestimmt, welche Einheiten das Modell lernt, wie viel Text in sein Kontextfenster passt und wie groß das Problem der Ausgabevorhersage ist. Sobald ein kleines GPT mit BPE-Tokenen arbeitet, ist der natürliche nächste Schritt zu untersuchen, wie dieselbe Mechanik zur Erzeugung von Tokens zu modernen generativen KI-Systemen wird:

    Next-token predictor
    → pretrained language model
    → instruction-following model
    → retrieval-augmented application
    → tool-using GenAI system
    

    Verwandte Artikel

  • Von GPT-1 zu Reasoning-Modellen: Was jede Generation für Entwickler verändert hat: Verfolgen Sie die Entwicklung der GPT-Familie vom Vor-Training im Jahr 2018 bis zu den Reasoning-Modellen, erkennen Sie, welche Ideen jede Generation hinzugefügt hat, und nutzen Sie GPT-4o Vision sowie strukturierte Ausgaben aus Python.
  • Training eines kleinen LLM von Grund auf auf einem Laptop mit 8 GB RAM: So richten Sie einen sauberen Datenfluss ein, um einen Transformer auf begrenzten Hardware-Ressourcen zu trainieren: BPE-Tokenizer, TinyStories, tokens auf Festplatte gespeichert sowie Batchen der nächsten Token.