Accueil / Articles

Tokeniseur BPE de zéro : séquences plus courtes pour une fenêtre de contexte GPT réduite

Écrivez un tokeniseur d’encodage par paires de bytes en Python, entraînez-le sur WikiText-2, enregistrez les résultats sous forme de JSON, et observez comment la tokenisation submots élargit la fenêtre de contexte d’un GPT.

Éditeur : REACTAPP.TOP26 min de lecture6015 mots

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

Un tokeniseur au niveau des caractères est le moyen le plus simple pour faire fonctionner un petit GPT : le vocabulaire est très restreint, rien n’est inconnu, et chaque étape est transparente. Le prix à payer est la longueur de la séquence, car chaque lettre occupe une position dans la fenêtre de contexte. Ce guide le remplace par l’encodage par paires de bytes (BPE), développé de zéro en Python pur. Vous allez l’entraîner sur WikiText-2, conserver le vocabulaire et les fusions appris sous forme de JSON, en générer une empreinte digitale, réencoder l’ensemble des données, et voir précisément quels changements surviennent lorsque vous entraînez à nouveau le modèle avec des tokens sous-mots.

Le code suppose l’existence d’un petit GPT PyTorch déjà prêt ainsi que de fichiers WikiText-2 nettoyés. Si l’entraînement continue de poser des problèmes, stabilisez-le d’abord ; consultez des tests permettant d’isoler chaque problème dans un petit GPT PyTorch.

Pourquoi les tokens de caractères ne suffisent plus

Un tokeniseur de caractères attribue à chaque caractère, y compris les espaces, son propre token. Prenons cette courte phrase :

artificial intelligence

Cela se transforme en un token par caractère :

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

Cela correspond à 23 tokens pour deux mots. Un tokeniseur de sous-mots pourrait représenter la même phrase avec bien moins d’éléments, par exemple :

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

Trois tokens au lieu de 23. Des séquences plus courtes permettent d’afficher plus de texte par fenêtre de contexte, de disposer de moins de positions pour l’entraînement et de réduire le nombre d’étapes de décodage lors de la génération. Le modèle reçoit également des blocs significatifs plutôt que des lettres isolées, les fragments fréquents deviennent des unités réutilisables, et les mots rares peuvent toujours être décomposés à partir de parties plus petites. La partie difficile consiste à découvrir automatiquement de bonnes unités sous-mots, tâche que remplit précisément le BPE.

Caractères, mots et sous-mots comparés

Il existe trois façons principales de diviser le texte, chacune impliquant des compromis différents.

Tokenisation par caractères

Chaque caractère est un token :

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

Il propose un vocabulaire restreint, sans tokens inconnus et une implémentation simple. En échange, on obtient des séquences longues, une génération lente, ainsi qu’un modèle qui doit assembler des mots à partir de lettres individuelles.

Tokenisation des mots

Chaque mot est un token :

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

Les séquences sont courtes et faciles à lire, mais le vocabulaire devient énorme ; les mots inconnus nécessitent un token inédit, et des formes apparentées telles que "learn", "learning" et "learned" peuvent se transformer en trois identifiants sans rapport entre eux, ne partageant rien. Les noms, les fautes d’orthographe et les mots rares sont mal gérés.

Tokenisation sous-mot

Les mots sont divisés en fragments fréquents :

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

Cela produit des séquences plus courtes que les caractères et un vocabulaire plus restreint que celui des mots. Les mots fréquents peuvent tout de même devenir des tokens uniques, les mots rares se décomposent en éléments connus, et des fragments tels que les préfixes et les suffixes sont réutilisés dans de nombreux mots. Le seul véritable inconvénient est que l’entraînement et la mise en œuvre du tokenizer sont plus complexes.

Vous pouvez considérer ces trois approches comme des points sur un spectre :

Characters
← flexibility

Subwords
← balance

Words
← compactness

Comment BPE apprend ses fusions

BPE commence par les plus petites unités, ici les caractères individuels, et fusionne répétitivement le couple de voisins le plus courant pour former un nouveau token. Prenons un très petit corpus de trois mots :

low
lower
lowest

Initialement, chaque mot n’est que ses caractères :

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

Ensuite, énumérez les paires de symboles adjacents qui apparaissent :

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

Une paire se distingue :

(l, o)

Il apparaît dans les trois mots, c’est pourquoi il est fusionné en un seul symbole :

(l, o) → lo

Après l’avoir remplacé partout, le corpus ressemble à ceci :

lo w
lo w e r
lo w e s t

En comptant à nouveau, la paire la plus fréquente est maintenant :

(lo, w)

Il apparaît également trois fois, il est donc également fusionné :

(lo, w) → low

Et le corpus devient :

low
low e r
low e s t

Après un nombre suffisant de tours, les séquences les plus fréquentes deviennent des tokens à part entière.

Ce que produit le cycle d’entraînement

La procédure que vous êtes sur le point de mettre en œuvre suit ces étapes :

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

Il génère deux artefacts, et vous en avez besoin des deux pour utiliser le tokeniseur ultérieurement.

Le vocabulaire

Le vocabulaire associe chaque chaîne de token à un ID entier, par exemple :

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

Les règles de fusion

Les règles de fusion sont la liste ordonnée des paires qui ont été fusionnées pendant l’entraînement :

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

Le ordre est essentiel. Une règle ultérieure telle que ("in", "g") ne peut s’appliquer que si la règle antérieure qui a créé "in" a déjà été appliquée. L’application des mêmes règles dans un ordre différent produit une segmentation différente.

Une première mise en œuvre sur un corpus de test

Avant de travailler avec WikiText-2, appliquez l’algorithme sur une chaîne de caractères que vous pouvez examiner visuellement :

text = "low lower lowest"

En le divisant en caractères, on obtient la liste des tokens de départ :

tokens = list(text)
print(tokens)

En l’imprimant, tous les caractères apparaissent, y compris les espaces :

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

Pour trouver des candidats à fusionner, comptez chaque paire de voisins. En superposant la liste à elle-même décalée d’une position, on obtient chaque paire adjacente, que Counter compte :

from collections import Counter


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

Inspectez les paires les plus courantes :

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

Le résultat ressemblera à peu près à ceci :

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

Notez que (' ', 'l') apparaît également dans les comptages. Comme les espaces sont des caractères ordinaires dans ce schéma, le BPE peut apprendre des tokens contenant un espace en tête, ce qui explique l’apparition ultérieure de tokens tels que " the".

Fusionner une paire unique

L’opération principale parcourt la liste des tokens de gauche à droite. Chaque fois que le token actuel et le suivant correspondent à la paire choisie, elle émet le token fusionné et saute de deux positions ; sinon, elle copie le token actuel et avance d’une position. Les chevauchements sont résolus de gauche à droite : dans a a a avec la paire (a, a), seuls les deux premiers se fusionnent.

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

Appliquez-le pour fusionner l et o :

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

print(tokens)

Le résultat est plus court que l’entrée :

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

Chaque fusion supprime un token pour chaque occurrence du couple, et c’est précisément de là que provient la compression.

Repetition jusqu’à remplir le vocabulaire

Un entraîneur minimal fonctionne simplement en boucle : il compte les couples, sélectionne celui qui est le plus fréquent, le fusionne partout, enregistre le nouveau token ainsi que la règle correspondante, et s’arrête une fois que le vocabulaire a atteint la taille cible. Il s’arrête également prématurément s’il ne reste plus de couples, ou si le couple le plus fréquent n’apparaît que moins de deux fois, car dans ce cas la fusion ne crée plus rien d’utilisable.

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

Le fait de l’exécuter sur la chaîne de test avec une cible petite suffit pour observer les fusionnements se produire un par un :

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

Cette version est utile pour développer une intuition, mais chaque itération rescanne toute la liste Python. Sur le corpus complet WikiText, cela devient très lent, c’est pourquoi la version pratique ci-dessous s’entraîne plutôt sur une tranche configurable de WikiText-2.

Conception d’un tokeniseur réutilisable

La classe tokeniseur doit s’entraîner à partir de texte brut en partant des caractères, encoder et décoder, rejeter les caractères hors de son vocabulaire, enregistrer et charger des données depuis JSON, ainsi que calculer une empreinte digitale. Autour d’elle, un script réencode chaque partition de WikiText-2 et indique de combien les séquences BPE sont plus courtes.

Une décision de conception mérite d’être clairement indiquée. Ce tokeniseur fonctionne sur des caractères Unicode, et non sur des octets bruts. Les tokeniseurs utilisés en production par les grands modèles sont généralement au niveau des octets, ce qui signifie que leur vocabulaire de base comprend les 256 valeurs d’octet possibles, de sorte qu’aucune entrée ne peut être irreprésentable. La version basée sur les caractères ici suit le même algorithme et est plus facile à lire, mais elle n’est pas un équivalent direct de ces tokeniseurs.

Le squelette de la classe

Le constructeur prend en paramètre les caractères de base ainsi que la liste d’fusion ordonnée. Il construit la liste complète de tokens en partant des caractères de base et en ajoutant le résultat de chaque fusion, en sautant les doublons. À partir de cette liste, il dérive les fonctions de recherche stoi (chaîne en ID) et itos (ID en chaîne) et enregistre la taille du vocabulaire. Comme les IDs proviennent des positions dans la liste, les mêmes tokens de base et mêmes fusions produisent toujours les mêmes 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
        )

Le vocabulaire résultant est donc composé de deux parties :

Base character tokens
+
learned merged tokens

Entraînement sur du texte réel

L’entraînement devient une méthode de classe qui retourne un tokeniseur prêt à l’emploi. Elle classe les caractères uniques du texte pour former le vocabulaire de base, refuse une taille cible inférieure à cet ensemble de base, puis exécute la même boucle de comptage, de sélection et de fusion que précédemment. L’enregistrement des données est limité : elle affiche les 20 premières fusions, puis une toutes les cent suivantes, accompagnées de la fréquence des paires et de la longueur actuelle de la séquence, afin de permettre de suivre l’avancement de la compression sans être submergé par les résultats.

@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,
    )

Pourquoi il existe une fréquence minimale de paire

Si la paire la plus fréquente restante n’apparaît qu’une seule fois, sa fusion crée un token qui ne sera jamais réutilisé, ce qui représente simplement une perte d’un emplacement dans le vocabulaire. Le boucle s’arrête donc dans cette condition :

frequency < minimum_pair_frequency

Pour des expériences comme celle-ci, un seuil de deux est suffisant :

minimum_pair_frequency = 2

Sur un corpus plus grand, vous pourriez vouloir un seuil plus élevé, afin que le vocabulaire ne soit utilisé que pour les motifs qui apparaissent suffisamment souvent pour que le modèle puisse en apprendre de bons embeddings.

Encodage et décodage avec le tokenizer

L’encodage suit trois étapes :

  1. Décomposer le texte en caractères de base.
  2. Rejouer chaque règle de fusion dans l’ordre où elle a été apprise.
  3. Rechercher l’ID de chaque token résultant.

La première moitié de ce processus se trouve dans la fonction encode_tokens. Elle commence par collecter tous les caractères absents du vocabulaire et génère une erreur claire s’il y en a, ce qui est bien plus facile à déboguer qu’une erreur KeyError au milieu d’un ensemble de données. Ensuite, elle rejoue les opérations de fusion.

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

Transformer les chaînes de tokens résultantes en IDs consiste en une recherche simple :

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

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

La décodage est encore plus simple. Chaque token, qu’il soit fusionné ou non, stocke le texte exact qu’il couvre, de sorte que le décodage se résume simplement à une concaténation :

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

Cela confère au BPE une propriété qui mérite d’être testée explicitement : une transmission bidirectionnelle sans perte.

decode(encode(text)) = text

Vérification rapide avec une phrase d’exemple :

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

Conservation et rechargement du tokeniseur

Il n’est pas nécessaire de stocker le vocabulaire lui-même, car il peut être reconstruit de manière déterministe à partir des tokens de base et de la liste de fusion. La méthode save enregistre ces deux éléments en JSON, ainsi qu’une étiquette de type et la taille du vocabulaire pour plus de commodité. En définissant ensure_ascii=False, les caractères non ASCII restent lisibles dans le fichier, et le répertoire parent est créé s’il n’existe pas encore.

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,
        )

Le résultat est un seul fichier :

tokenizer/
└── bpe_tokenizer.json

Le chargement inverse le processus en lisant le JSON et en transmettant les deux listes au constructeur :

@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"],
    )

Vérifiez ensuite qu’un tokeniseur rechargé effectue toujours des allers-retours sur le texte d’exemple :

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

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

Entraînement sur une partie de WikiText-2

Comme l’entraîneur simple rescanne toute la séquence de travail à chaque fusion, l’entraînement sur l’ensemble de WikiText-2 avec un vocabulaire étendu peut prendre beaucoup de temps. Commencez par un préfixe configurable du texte d’entraînement :

training_characters = 500_000

training_text = train_text[
    :training_characters
]

Une taille de vocabulaire de base raisonnable est :

target_vocab_size = 500

En d’autres termes :

Initial character vocabulary
+
learned merged tokens
=
approximately 500 tokens

Lorsque tout fonctionne correctement, essayez des cibles plus grandes :

target_vocab_size = 1_000
target_vocab_size = 2_000

On peut s’attendre à ce que le temps d’entraînement augmente à la fois avec la taille de l’échantillon et le nombre de fusions.

Prise en compte des caractères de base de chaque partition

Si le vocabulaire de base est construit uniquement à partir de l’échantillon d’entraînement, le texte de validation ou de test peut contenir des caractères qui n’y sont jamais apparus, ce qui entraînera une erreur lors de l’encodage de ces partitions. La solution consiste à recueillir l’ensemble de caractères de base à partir des trois partitions, tout en continuant à calculer les statistiques de fusion uniquement à partir des données d’entraînement :

all_text = (
    train_text
    + val_text
    + test_text
)

base_tokens = sorted(
    set(all_text)
)

Cela n’injecte aucun schéma de langage de validation dans les fusionnements. Il ne garantit que chaque caractère dans l’ensemble de données dispose d’un ID. Pour le prendre en charge, l’entraîneur dispose d’un argument optionnel base_tokens qui est utilisé à la place des caractères du texte d’entraînement lorsqu’il est fourni :

@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)
        )

Mesurer la compression obtenue

Pour savoir ce que BPE vous apporte, comparez le nombre de tokens dans le même passage. Prenons un échantillon :

sample_text = train_text[:10_000]

Le nombre de tokens de son caractère correspond simplement à sa longueur :

character_count = len(sample_text)

Le nombre de tokens BPE correspond à la longueur de la liste d’ID codée :

bpe_ids = tokenizer.encode(
    sample_text
)

bpe_count = len(bpe_ids)

Le rapport entre eux est le facteur de compression :

compression_factor = (
    character_count / bpe_count
)

Un résultat pourrait ressembler à ceci :

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

Avec un facteur de compression de 2,35, un bloc de 128 tokens BPE couvre à peu près autant de texte qu’un ensemble de 301 tokens de caractères. Cependant, la compression n’est pas uniforme : le texte en prose, riche en mots courants, se compresse bien, tandis que les nombres, les noms rares et la ponctuation inhabituelle se compresse mal.

Examen de ce que le tokeniseur a appris

Les tokens appris correspondent à tous les éléments du vocabulaire après les caractères de base. En les triant par longueur et en affichant les plus longs, accompagnés de leurs IDs, on peut rapidement se faire une idée de ce que le tokeniseur considère comme fréquent :

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],
    )

Vous verrez généralement des jetons de ce type :

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

La liste exacte dépend de l’échantillon d’entraînement, du prétraitement, de la taille du vocabulaire cible, de la fréquence minimale et des règles de résolution des égalités. Pour la résolution des égalités : Counter.most_common renvoie les paires ayant la même fréquence dans l’ordre où elles ont été comptées pour la première fois, de sorte que les résultats sont déterministes pour une donnée donnée, mais le changement de l’échantillon peut réorganiser l’ordre selon lequel les différentes paires égales remportent la victoire.

Re-codage du jeu de données

Avec un tokeniseur entraîné, encodez les trois sous-ensembles :

train_bpe_ids = tokenizer.encode(
    train_text
)

val_bpe_ids = tokenizer.encode(
    val_text
)

test_bpe_ids = tokenizer.encode(
    test_text
)

Convertissez les listes d’ID en tenseurs long, qui est le type attendu par les couches d’incorporation pour les indices :

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,
)

Enregistrez-les dans leur propre répertoire :

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

Laissez les fichiers codés en caractères inchangés ; conserver les deux facilite les comparaisons ultérieures et évite de fournir aux modèles les identifiants de l’autre.

Vérification des divisions codées

Tout d’abord, chaque identifiant doit se situer dans la plage allant de zéro jusqu’à, mais sans inclure, la taille du vocabulaire :

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(),
    )

Deuxièmement, un échantillon de chaque partie divisée doit pouvoir être ramené au texte original :

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

Intégration de BPE dans le modèle

L’architecture du modèle ne nécessite aucune modification structurelle. Seule la taille du vocabulaire dans la configuration change :

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,
)

Dirigez le chargement des données vers les nouveaux fichiers :

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

Désormais, le modèle prédit des tokens BPE plutôt que des caractères.

Les anciens points de vérification ne se chargeront pas

Un modèle de caractères dispose généralement d’un vocabulaire dans cette fourchette :

100 to 300 character tokens

Un modèle BPE aura généralement quelque chose comme ceci :

500 to 2,000 subword tokens

Tant l’incorporation des tokens que la tête de sortie dépendent de la taille du vocabulaire, ce qui fait varier leur forme. Un point de contrôle par caractère comporte :

token embedding: [character_vocab_size, C]

Tandis que le modèle BPE s’attend à :

token embedding: [bpe_vocab_size, C]

Ici, C représente la dimension d’incorporation. Les formes ne correspondent pas, donc les anciens poids ne peuvent pas être chargés directement. Et même s’ils le pouvaient, les IDs signifient des choses différentes selon chaque tokenizer. Il vous faut entraîner un modèle nouveau, et il est utile de conserver les points de contrôle séparément :

checkpoints_char/
checkpoints_bpe/

Ou, de manière équivalente :

checkpoints/char/
checkpoints/bpe/

Reconnaissance des empreintes digitales du tokeniseur

Une incohérence de forme se manifeste de manière évidente ; en revanche, associer un point de vérification à un tokeniseur BPE différent de la même taille reste silencieux, car chaque identifiant se réfère simplement au mauvais texte. L’empreinte digitale hache une version JSON compacte des tokens de base et la fusionne avec SHA-256, de sorte que toute modification génère une nouvelle valeur.

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()

Stockez l’empreinte digitale à l’intérieur de chaque point de contrôle :

checkpoint[
    "tokenizer_fingerprint"
] = tokenizer_fingerprint(
    tokenizer
)

Lors du chargement, calculez l’empreinte digitale du tokeniseur actif et refusez de continuer s’elle ne correspond pas à celle enregistrée avec le point de contrôle.

La limite de performance que vous devez connaître

L’encodeur rejoue chaque règle de fusion sur l’ensemble de la séquence de tokens :

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

Cela signifie qu’une passe complète sur le texte a lieu à chaque fusion, ce qui ralentit l’encodage des textes volumineux comportant des milliers de fusions. La mise en œuvre privilégie délibérément la clarté, la correction et la facilité d’inspection par rapport à la vitesse. Les tokeniseurs utilisés en production divisent à l’avance le texte en mots, mettent en cache les segmentations répétées et appliquent les fusions selon un ordre de priorité ; pour l’apprentissage, la version transparente est préférable.

Le programme complet

Assemblez tout dans un seul script :

build_bpe_tokenizer.py

Le programme complet ci-dessous combine les outils d’aide pour les paires, la classe de tokenisation, les outils de validation ainsi qu’un point d’entrée en ligne de commande. Deux détails diffèrent des extraits : l’enregistrement stocke désormais l’empreinte digitale et écrit via un fichier temporaire qui est renommé pour remplacer le fichier cible, de sorte qu’une exécution interrompue ne laisse jamais de fichier inachevé ; de plus, l’entraînement vérifie que le vocabulaire de base couvre le texte d’entraînement. La fonction principale entraîne sur le préfixe configuré, enregistre et recharge le tokeniseur, vérifie les empreintes digitales, effectue des allers-retours et enregistre chaque partition, valide les plages d’ID, et affiche des statistiques.

Une ligne dans la boucle des « tokens appris les plus longs » semble avoir été endommagée : f"{loaded_tokenizer.stoi5d} " provoquera une AttributeError, car stoi5d n’existe pas. L’expression visée est presque certainement f"{loaded_tokenizer.stoi[token]:5d} ", qui affiche l’ID de chaque token complété à cinq caractères. Corrigez cette ligne avant d’exécuter le script.

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()

Le lancement

La configuration par défaut correspond à un lancement court à des fins pédagogiques :

python build_bpe_tokenizer.py

Pour acquérir un vocabulaire plus étendu :

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

Pour s’entraîner sur davantage de texte également :

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

Pour un test rapide qui ne vise qu’à confirmer que le pipeline fonctionne :

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

Si votre interpréteur de commandes ne prend pas en charge les continuations de ligne par barre oblique, écrivez chaque commande sur une seule ligne.

À quoi ressemble la sortie

Une exécution peut afficher quelque chose comme ceci :

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

Les premières fusionnations correspondent exactement à ce à quoi on s’attend pour l’anglais : un espace suivi de t, puis de he, puis ces deux lettres combinées en " the". Après encodage, le résumé ressemble à ceci :

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

Vos chiffres varieront. Notez que l’exécution complète de la phase d’entraînement avec cet encodeur peut dominer le temps d’exécution pour des vocabulaires plus importants.

Quelles sont les modifications pour le modèle et sa fenêtre de contexte ?

Comptage des tokens dans une phrase réelle

Prenons cette phrase :

The history of artificial intelligence

Un tokeniseur de caractères produit à peu près autant de tokens :

38 tokens

Un tokeniseur BPE pourrait générer des tokens de mots entiers tels que ceux-ci :

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

Il peut également produire des fragments plus petits, en fonction des fusions qu’il a apprises. Dans tous les cas, le nombre se situe dans une fourchette approximative comme suit :

5 to 15 tokens

La segmentation n’a pas été conçue manuellement ; elle résulte des statistiques du corpus d’entraînement.

Plus de texte par fenêtre de contexte

Supposons que le modèle utilise cette taille de bloc :

block_size = 128

Avec les caractères, 128 tokens couvrent environ 128 caractères. Avec BPE, ces mêmes 128 tokens peuvent couvrir plusieurs centaines de caractères. Le modèle prend en compte davantage de contexte linguistique sans que le calcul de l’attention ne devienne plus volumineux. Cela est important car le coût de l’auto-attention augmente approximativement en fonction de :

T²

Ici, T représente la longueur de la séquence ; donc, doubler T fait approximativement quadrupler le coût lié à l’attention, tandis qu’une meilleure tokenisation permet d’insérer plus de texte dans la même valeur de T.

Le compromis lié à la taille du vocabulaire

Un vocabulaire BPE plus grand signifie généralement des séquences plus courtes, mais il rend également certaines matrices plus volumineuses. Le tableau d’incorporation des tokens contient autant de paramètres :

vocab_size × embedding_dim

Avec le liage des poids, la tête de sortie réutilise la même matrice au lieu d’en ajouter une seconde. Comparez un petit vocabulaire :

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

Avec un plus grand :

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

Ainsi, un vocabulaire plus grand raccourcit les séquences mais augmente les paramètres d’incorporation, le coût des logits de sortie, l’utilisation de la mémoire et le temps d’entraînement du tokeniseur. Chaque token supplémentaire nécessite également suffisamment d’occurrences pour apprendre un bon embedding ; par conséquent, sur un corpus de la taille de WikiText-2, un vocabulaire très vaste génère de nombreux tokens à peine entraînés. La taille idéale dépend du dataset et du modèle.

Reformater le modèle avec des tokens BPE

Mettre à jour le script d’entraînement pour charger le fichier du tokeniseur :

tokenizer/bpe_tokenizer.json

Et les divisions codées :

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

Ensuite, construisez la configuration en fonction de la taille du vocabulaire du tokeniseur :

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,
)

Et écrivez les points de contrôle dans leur propre répertoire :

checkpoints/bpe/

À part ces changements, le cycle d’entraînement peut rester essentiellement tel qu’il était.

Comparer équitablement les deux modèles

Pour une comparaison significative, maintenez inchangées autant que possible l’architecture (largeur, profondeur, nombre de couches), les données, l’optimiseur, le calendrier d’entraînement, le budget d’entraînement et la méthode d’évaluation. Même ainsi, comparer à des étapes d’entraînement égales est trompeur, car un lot de tokens BPE contient considérablement plus de texte qu’un lot de caractères. De meilleurs critères de comparaison sont :

Total characters processed
Total bytes processed
Training time
Compute budget

La perplexité est calculée par unité de prédiction, ce qui empêche de comparer directement les perplexités par caractère et celles basées sur le BPE. Pour obtenir une valeur comparable, convertissez les deux en bits par caractère ; sinon, évaluez chaque modèle en fonction de la qualité de génération, du coût et des métriques adaptées au tokeniseur.

Erreurs courantes

Création d’un tokeniseur distinct pour chaque partition

C’est incorrect :

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

Les données d’entraînement, de validation et de test doivent toutes être encodées par le même tokeniseur ; sinon, le même ID signifie des choses différentes dans les différentes partitions.

Application des fusionnements hors ordre

Les fusionnements s’articulent les uns sur les autres, il faut donc les exécuter exactement dans l’ordre où ils ont été appris :

for pair in self.merges:
    apply(pair)

Le tri des règles, par exemple de manière alphabétique pour une meilleure lisibilité, modifie la façon dont le texte est segmenté.

Écrasement des fichiers codés en caractères

Conservez les IDs BPE dans leur propre répertoire :

data/encoded_bpe/

Et laissez les IDs des caractères où ils se trouvent :

data/encoded/

Apprentissage des fusions à partir du texte de validation

Les statistiques de fusion doivent provenir uniquement du texte d’entraînement. Il est acceptable d’utiliser l’ensemble de caractères de base de chaque partition pour assurer une couverture complète, mais les fréquences de fusion doivent refléter uniquement le corpus d’entraînement, sinon les chiffres de validation seront trop optimistes.

Attente que les tokens appris soient des mots entiers

BPE apprend volontiers des fragments tels que :

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

Les tokens sous-mots suivent la fréquence, et non les limites des mots perçues par l’être humain, ce qui est un comportement attendu.

Présumer qu’un vocabulaire plus grand est toujours meilleur

Un vocabulaire plus vaste entraîne des coûts supplémentaires et génère des tokens rares et mal formés. Essayez plusieurs tailles et mesurez les résultats. De même, ne comparez jamais la perplexité brute entre différents tokeniseurs.

Exercices

  1. Comparez trois tailles de vocabulaire. Entraînez avec les tailles indiquées ci-dessous et comparez le temps d’entraînement, la longueur des séquences, le niveau de compression, la longueur des tokens les plus longs et la taille des embeddings.
300 tokens
500 tokens
1,000 tokens
  1. Étudiez les premières fusionnations. Imprimez les 50 premières règles de fusion. Y a-t-il des motifs de espaces blancs ? Des suffixes courants ? Des combinaisons de ponctuation ? Quelles fusionnations vous surprennent ?
  2. Tokenisez une phrase des deux manières. Encodez la phrase suivante à l’aide à la fois du tokeniseur de caractères et du tokeniseur BPE, puis comparez les tokens, les IDs, le nombre de tokens et le résultat décodé.
Artificial intelligence is transforming technology.
  1. Testez des mots rares. Encodez les mots suivants et observez comment le BPE assemble des mots inconnus à partir de fragments connus.
electromagnetism
neurocomputational
uncharacteristically
  1. Évaluer la couverture du contexte. Décoder les 128 premiers tokens de chaque tokenizer pour voir quelle partie du texte chaque fenêtre couvre.
  2. Entraîner un modèle BPE. Entraîner sur le répertoire indiqué ci-dessous et comparer ses échantillons au modèle de caractères avec un effort de calcul équivalent.
data/encoded_bpe/
  1. Imposer l’utilisation des empreintes digitales. Stocker l’empreinte digitale du tokeniseur dans chaque point de contrôle et refuser d’exécuter les inférences lorsque le point de contrôle et le tokeniseur actif ne sont pas en accord.
  2. Accélérer l’encodage. Remplacer l’encodeur qui effectue une passe par fusion par un système plus rapide, en s’appuyant sur des rangs de fusion, des piles, des listes de tokens liées, des caches par mot ou des tentatives.

Règles de tokenisation à conserver

  • Les sous-mots se situent entre des caractères flexibles mais longs et des mots compacts mais fragiles.
  • Un tokeniseur BPE est entièrement défini par ses tokens de base ainsi que par ses fusions ordonnées ; il convient de les enregistrer et de leur créer une empreinte digitale avec précision.
  • L’encodage reconstitue les fusions dans l’ordre, le décodage les concatène, et les allers-retours sont sans perte.
  • Un seul tokeniseur sert à chaque segmentation, et les fusions sont apprises uniquement à partir des données d’entraînement.
  • Un nouveau tokeniseur implique de nouveaux fichiers de tokens, une taille de vocabulaire différente et un modèle réentraîné.
  • La taille du vocabulaire permet des séquences plus courtes en échange de couches d’incorporation et de sortie plus volumineuses, et la perplexité ne se transmet pas d’un tokeniseur à un autre.
  • Le pipeline complet, du texte brut au modèle réentraîné, se présente comme suit :

    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
    

    Le point essentiel est que la tokenisation n’est pas une simple étape de prétraitement. Elle détermine quelles unités le modèle apprend, combien de texte peut entrer dans sa fenêtre de contexte et quelle est la taille du problème de prédiction de ses sorties. Une fois qu’un petit GPT fonctionne avec des tokens BPE, la prochaine étape logique est d’examiner comment ce même mécanisme de génération de tokens évolue pour former des systèmes d’IA générative modernes :

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

    Lectures complémentaires

  • De GPT-1 aux modèles de raisonnement : ce que chaque génération a changé pour les développeurs : Suivez l’évolution de la famille GPT, du pré-apprentissage de 2018 jusqu’aux modèles de raisonnement, découvrez les idées ajoutées par chaque génération, et découvrez la vision GPT-4o ainsi que les sorties structurées depuis Python.
  • Entraîner un petit LLM de zéro sur un laptop avec 8 GB de RAM : Comment mettre en place un pipeline de données propre pour entraîner un modèle transformateur sur du matériel limité : tokeniseur BPE, TinyStories, tokens stockés sur disque et regroupement des tokens suivants.