Skip to content
The loss curve

Chapitre 3 · 16 min

Entraîne tes propres tokens

Entraîne un petit tokenizer BPE dans le navigateur, puis sauvegarde le code du tokenizer et ses merges dans ton projet LLM local.

Jusqu’ici, nous découpions le texte sur les espaces. Chaque mot devenait un . C’est ainsi que le du chapitre 1 définissait son , et ça semblait innocent.

Ça ne l’est pas. C’est mauvais.

Un par espaces traite running, runs et run comme trois mots sans rapport. Pareil pour cat, cats et cat's. Pire : dès que le modèle rencontre un mot jamais vu, comme lowest alors que l’ ne contenait que low et lower, il n’a aucune information. Le est fermé.

Découper en caractères ne règle pas tout. Le devient minuscule et ouvert, mais running devient sept : r u n n i n g. Les séquences explosent.

Il existe un milieu presque embarrassant de simplicité : (). À l’origine, c’est un algorithme de compression de 1994, devenu très utile pour le langage. Tu vas l’inspecter depuis zéro : quatre fonctions, puis la même idée sauvegardée dans llm/tokenizer.py.

1. Initialiser : chaque mot comme liste de caractères

L’état de départ est le plus simple possible : chaque mot est découpé en caractères. Les espaces séparent les mots, mais à l’intérieur tout est lettre par lettre.

Code · JavaScript

C’est l’unité de départ de . Chaque étape choisit la paire adjacente la plus fréquente et la fusionne en un nouveau . Répète assez longtemps, et tu récupères quelque chose qui ressemble à des mots.

2. Trouver la paire la plus commune

Le doit demander : quelle paire de adjacents apparaît le plus souvent ? Parcours le , compte chaque (a, b) côte à côte, retourne le gagnant.

Code · JavaScript

Top adjacent pairs in the corpus

e+ss+tw+el+oo+wn+ee+ww+i

Dans notre , la paire gagnante sera une séquence de lettres banale comme e + s ou n + e. C’est toute l’histoire : redécouvre de la morphologie en poursuivant ce qui apparaît souvent ensemble.

3. Appliquer un merge

Une fois la paire gagnante connue, on parcourt le et on remplace chaque occurrence de (a, b) par un nouveau ab. Le est juste la concaténation. Rien de magique.

Code · JavaScript

Compare l’avant et l’après. Partout où a + b apparaissait, tu vois maintenant une seule puce ab. Le nombre total de baisse ; le augmente d’une entrée.

4. Entraîner et encoder

On assemble : “trouver la paire la plus fréquente + appliquer le merge”, N fois, en accumulant les merges dans l’ordre. Puis on applique ces merges à un nouveau texte, lowest newer.

Le script répète explicitement les quatre briques : bpeInit, countPairs, applyMerge et encode.

Code · JavaScript

Regarde le texte encodé. Le mot lowest n’est jamais apparu dans le d’. Pourtant il se décompose en morceaux du style low + est, parce que ces ont été appris ailleurs. Voilà le résultat important : généralise aux mots jamais vus en composant des .

Ce que tu perds

est glouton et irréversible. Les premiers merges influencent tous les suivants. Si ton est petit ou biaisé, les découverts le seront aussi. Un entraîné sur de l’anglais mal le français, et inversement.

Les merges sont aussi aveugles à la position : est peut servir dans newest et dans Eastern alors que ces mots n’ont rien à voir. Le ne sait pas. Il ne voit que des paires fréquentes.

5. Sauvegarder ton tokenizer localement

Crée llm/tokenizer.py :

"""A tiny BPE-style tokenizer for the teaching project."""
from __future__ import annotations
 
import json
from collections import Counter
from pathlib import Path
 
 
# [1]
def words(text: str) -> list[list[str]]:
    return [list(word) for word in text.lower().split()]
 
 
# [2]
def count_pairs(corpus: list[list[str]]) -> Counter[tuple[str, str]]:
    pairs: Counter[tuple[str, str]] = Counter()
    for word in corpus:
        for left, right in zip(word, word[1:]):
            pairs[(left, right)] += 1
    return pairs
 
 
# [3]
def apply_merge(corpus: list[list[str]], pair: tuple[str, str]) -> list[list[str]]:
    merged = "".join(pair)
    out: list[list[str]] = []
    for word in corpus:
        next_word: list[str] = []
        i = 0
        while i < len(word):
            if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
                next_word.append(merged)
                # [4]
                i += 2
            else:
                next_word.append(word[i])
                i += 1
        out.append(next_word)
    return out
 
 
# [5]
def train_bpe(text: str, steps: int) -> list[tuple[str, str]]:
    corpus = words(text)
    merges: list[tuple[str, str]] = []
    for _ in range(steps):
        pairs = count_pairs(corpus)
        if not pairs:
            break
        pair, _ = pairs.most_common(1)[0]
        merges.append(pair)
        corpus = apply_merge(corpus, pair)
    return merges
 
 
# [6]
def save_merges(merges: list[tuple[str, str]], path: str = "data/merges.json") -> None:
    Path(path).parent.mkdir(parents=True, exist_ok=True)
    Path(path).write_text(json.dumps(merges, indent=2), encoding="utf-8")

Lis ce comme une boucle de compression :

  • [1] words part du niveau caractère.
  • [2] count_pairs compte les symboles adjacents.
  • [3] apply_merge remplace la paire gagnante par un unique.
  • [4] saute de 2 après un merge pour ne pas réutiliser le même caractère.
  • [5] train_bpe répète “compte, choisit, fusionne”.
  • [6] save_merges écrit la recette sur disque. L’ordre des merges est important.

Crée scripts/train_tokenizer.py :

from llm.tokenizer import save_merges, train_bpe
 
TEXT = "low lower newest widest low lower newer"
 
# [1]
merges = train_bpe(TEXT, steps=20)
# [2]
save_merges(merges)
 
# [3]
print(f"saved {len(merges)} merges to data/merges.json")
print(merges[:10])

Lance-le :

python -m scripts.train_tokenizer
python -m scripts.train_tokenizer
python -m scripts.train_tokenizer

Le reste minuscule, mais il existe maintenant comme pièce réutilisable de ton modèle local. Au chapitre 11, on remplacera ce jouet par un de production ; la forme restera la même.

Recap

  • La par espaces est trop grossière : fermé, pas de lien entre mots apparentés. - La caractère est trop fine : minuscule, séquences trop longues. - commence avec les caractères et fusionne avidement la paire adjacente la plus fréquente. - Les merges découvrent de la morphologie sans qu’on la donne au modèle. - généralise aux mots jamais vus en composant des appris. - Ton projet local a maintenant llm/tokenizer.py et data/merges.json.

Pour aller plus loin

Prochaine étape : donner du sens aux mots — un n’est qu’un nombre, mais on peut lui donner un vecteur.