Écrire un tokenizer BPE à la main
Le module 01 a dit que le modèle voit des tokens. Cette leçon écrit de vos mains un tokenizer BPE au niveau des octets, l'entraîne sur des poèmes Tang, montre comment il reconstitue pas à pas, à partir d'octets, des caractères chinois et des mots courants, et fait voir ses défauts sur de petites données.
- Environ 45 minutes
- Niveau : Intermédiaire
- Testé : 2026-09-15 Python pur, données de l'« Anthologie complète des poèmes Tang »
Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.
La leçon 1 du module 01 l'a montré : le modèle ne connaît pas l'écriture, il voit une suite de numéros de tokens. Ce module construit un GPT de zéro, et la première étape consiste à décider comment découper le texte en tokens.
Cette leçon écrit à la main la méthode qu'utilisent la série GPT, DeepSeek et Qwen (通义千问) : le BPE (Byte Pair Encoding, codage par paires d'octets). Ensuite, vous comprendrez deux choses qu'il fallait se contenter de retenir au module 01 : pourquoi un même texte n'a pas le même nombre de tokens selon les modèles, et pourquoi un caractère rare est découpé en plusieurs tokens.
Préparer les données
Tout le module utilise des poèmes Tang comme données. Ils viennent du projet open source chinese-poetry (licence MIT), qui contient plus de cinquante mille poèmes de l'« Anthologie complète des poèmes Tang » (全唐诗).
uv add torch opencc
python prepare_poems.py
Les données brutes sont en caractères traditionnels ; le script les convertit en caractères simplifiés avec OpenCC. OpenCC convertit par groupes de mots, ce qui est plus précis qu'un remplacement caractère par caractère. On ne garde ensuite que les poèmes à forme régulière : cinq ou sept caractères par vers, quatre vers (jueju) ou huit vers (lüshi), avec seulement des caractères chinois, des virgules et des points.
《全唐诗》共 57607 首,留下格律整齐的 35135 首,共 1557776 个字符,6288 个不同的字符
五言绝句:3656 首
七言绝句:10087 首
五言律诗:13865 首
七言律诗:7527 首
前三首:
闲却白云居,行踪出去初。窗中聊取笔,架上独留书。日背林光冷,潭澄岳影虚。长闻得药力,此说复何如。
秋溪南岸菊霏霏,急管烦弦对落晖。红叶树深山径断,碧云江静浦帆稀。不堪孙盛嘲时笑,愿送王弘醉夜归。流落正怜芳意在,砧声徒促授寒衣。
东城晓出静尘埃,紫画神旗向日开。锦袖半攘争捧辔,银鞍不下小传杯。马盘草上朱弓满,鴈落云中白羽回。晚向三通残皷尽,北原千骑卷行来。
Un poème par ligne, 35 000 poèmes et 1,56 million de caractères au total. En regardant bien le troisième poème, « 鴈 » et « 皷 » n'ont pas été convertis en « 雁 » et « 鼓 » : ce sont des variantes graphiques, et non des paires traditionnel-simplifié, que la conversion d'OpenCC ne traite pas. Le nettoyage des données est rarement propre à cent pour cent ; cela n'a guère d'effet sur l'entraînement qui suit, on les a donc laissés.
Partir des octets
Au plus bas niveau, le texte dans un ordinateur, ce sont des octets. En UTF-8, une lettre latine fait un octet, un caractère chinois trois :
一个汉字在 UTF-8 里是 3 个字节:'月' → e6 9c 88
Le BPE part des octets, si bien que son vocabulaire initial n'a que 256 éléments (les valeurs 0 à 255 qu'un octet peut représenter). L'avantage : n'importe quel texte peut être représenté, sans jamais tomber sur « ce caractère n'est pas dans le vocabulaire » ; au pire, on le découpe en octets.
On répète ensuite une seule chose : trouver dans les données d'entraînement la paire de tokens voisins la plus fréquente, et la fusionner en un nouveau token.
def train(text, n_merges):
ids = list(text.encode("utf-8")) # 从字节开始:词表一开始就是 0~255 这 256 个字节
merges = {} # (a, b) -> 新编号
vocab = {i: bytes([i]) for i in range(256)} # 编号 -> 它代表的字节串
for k in range(n_merges):
pairs = count_pairs(ids)
pair, count = pairs.most_common(1)[0] # 出现最多的一对相邻的词元
new_id = 256 + k
ids = merge(ids, pair, new_id)
merges[pair] = new_id
vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]
count_pairs compte avec Counter toutes les paires voisines, merge remplace chaque occurrence de la paire par le nouveau numéro, quelques lignes chacune (voir code/09-transformer/bpe.py).
Chaque fusion ajoute un token au vocabulaire et raccourcit un peu les données d'entraînement. Le nombre de fusions détermine la taille finale du vocabulaire : c'est un nombre qu'on choisit soi-même.
Ce qu'il a appris
Entraînement de 1500 fusions sur 2000 poèmes (le Python pur est très lent ; avec plus de données, il faudrait attendre longtemps) :
训练数据:2000 首诗,89863 个字符,265591 个字节
第 1 次合并: [80] + [82] → [80 82] (出现 6396 次),训练数据变成 259195 个词元
第 2 次合并: [ef] + [bc] → [ef bc] (出现 6394 次),训练数据变成 252801 个词元
第 3 次合并: [ef bc] + [8c] → , (出现 6394 次),训练数据变成 246407 个词元
第 4 次合并: [e3] + [80 82] → 。 (出现 6394 次),训练数据变成 240013 个词元
第 5 次合并: [e4] + [b8] → [e4 b8] (出现 3702 次),训练数据变成 236311 个词元
第 6 次合并: 。 + ↵ → 。↵ (出现 1999 次),训练数据变成 234312 个词元
第 7 次合并: [e4] + [ba] → [e4 ba] (出现 1819 次),训练数据变成 232493 个词元
第 8 次合并: [e5] + [a4] → [e5 a4] (出现 1749 次),训练数据变成 230744 个词元
……
第 1001 次合并:[ef bc 8c e8 a1] + [8c] → ,行 (出现 26 次),训练数据变成 106108 个词元
第 1500 次合并: [e7 a5] + [96] → 祖 (出现 15 次),训练数据变成 96124 个词元
训练 1500 次合并,用了 27 秒,词表大小 1756
Entre crochets, les octets qui ne forment pas encore un caractère complet. Les premières fusions sont intéressantes :
- Les 4 premières reconstituent la virgule et le point. Chaque poème en contient, ce sont les plus fréquents. La 1re et la 4e vont ensemble : on assemble d'abord les deux derniers octets du point,
80 82, puis on y ajoute lee3initial. - La 6e fusionne « point + retour à la ligne » en un token, car chaque poème se termine par un point suivi d'un retour à la ligne.
- Les 5e, 7e et 8e fusionnent les deux premiers octets du codage UTF-8 de caractères chinois. Beaucoup de caractères courants partagent leurs deux premiers octets ; commencent par
e4 b8, par exemple, « 不 », « 与 », « 世 », « 东 » et d'autres. Ces « demi-caractères » sont donc plus fréquents que n'importe quel caractère complet.
Après 1500 fusions, les données d'entraînement passent de 266 000 octets à 96 000 tokens, un peu plus que leurs 89 900 caractères.
1500 个新词元里:883 个正好是一个完整的字符,172 个是两个字符以上,其余 445 个是半个汉字、或者跨了字的边界
两个字符以上的,最早学到的 30 个:
。↵ ,不 ,一 。不 ,何 ,山 人。↵ ,风 万里 ,春 ,应 ,天 ,江 。何 ,无 千里 何处 ,清 ,白 。自 。莫 人间 ,秋 ,寒 ,月 。↵一 ,相 。一 ,云 ,日
Il a appris quelques vrais mots : « 万里 » (dix mille lieues), « 千里 » (mille lieues), « 何处 » (où), « 人间 » (le monde des hommes). Mais on trouve surtout « virgule + un caractère », comme « ,不 », « ,何 », « ,春 ». C'est que, dans un poème Tang, le premier caractère de chaque vers est toujours précédé d'un signe de ponctuation, et cette combinaison revient très souvent. Le BPE ne regarde que les fréquences, il ne comprend pas la langue : il ne sait pas que la ponctuation n'a rien à voir avec le caractère qui suit.
Les tokenizers utilisés en pratique commencent, avant l'entraînement, par un découpage grossier du texte avec des règles, par exemple ponctuation, espaces et chiffres séparés chacun, et le BPE ne fusionne qu'à l'intérieur des morceaux obtenus. GPT-2 utilise pour cela une expression régulière. On évite ainsi d'apprendre des tokens comme « ,不 » qui chevauchent une ponctuation.
Encoder et décoder
L'entraînement produit une table de règles de fusion. Pour encoder un nouveau texte, on le convertit d'abord en octets, puis on fusionne dans l'ordre où les règles ont été apprises :
def encode(text, merges):
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
# 在所有相邻的对里,找最早学到的那个合并规则先用上:和训练时的顺序一致
pair = min(set(zip(ids, ids[1:])), key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break
ids = merge(ids, pair, merges[pair])
return ids
def decode(ids, vocab):
return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")
Pourquoi dans cet ordre ? Parce que les règles tardives reposent sur les précédentes. Le token « ,行 », par exemple, est formé de « les deux premiers octets de la virgule plus les deux premiers octets de “行” », plus 8c ; si les fusions précédentes ne sont pas faites d'abord, les suivantes n'ont rien à fusionner.
Le décodage est bien plus simple : concaténer les suites d'octets de chaque token, puis décoder en UTF-8.
Sur des poèmes jamais vus
Test sur 500 poèmes absents de l'entraînement :
在训练时没见过的 500 首诗上:
68179 个字节,23059 个字符,BPE 分成 25078 个词元,平均每个词元 0.92 个字符
训练用的 2000 首诗里有 3655 个不同的字符;测试诗里的字符,1.0% 在训练数据里一次都没出现过
例子:自君入城市,北邙无新坟。始信壶中药,不落白杨根。如何忽告归,蕣华还笑人。玉笙无遗音,怅望缑岭云。
切成:自 | 君 | 入 | 城 | [e5 b8] | [82] | [ef bc 8c e5] | [8c] | [97] | [e9 82] | [99] | 无 | 新 | [e5 9d] | [9f] | [e3 80 82 e5] | [a7 8b] | 信 | [e5 a3] | [b6] | 中 | 药 | ,不 | 落 | 白 | 杨 | 根 | 。如 | 何 | 忽 | [e5 91] | [8a] | 归 | [ef bc 8c e8] | [95] | [a3] | 华 | 还 | 笑 | 人 | 。玉 | [e7 ac] | [99] | 无 | 遗 | 音 | [ef bc 8c e6 80] | [85] | 望 | [e7 bc] | [91] | 岭 | 云 | 。
解码回去和原文完全一样
Le résultat n'est pas beau : plus de tokens que de caractères, en moyenne seulement 0,92 caractère par token. Des caractères comme « 市 », « 北 », « 邙 », « 坟 », « 始 » sont tous découpés en deux ou trois fragments.
La cause : trop peu de données. Les 2000 poèmes ne contiennent que 3655 caractères différents, et 1500 fusions n'en ont reconstitué que 883 en caractères complets. Un caractère qui n'apparaît que quelques fois n'arrive jamais en tête de la file des fusions. « 市 » (marché) est un caractère courant, mais pas assez fréquent dans ces 2000 poèmes : il reste donc découpé.
Il y a toutefois une chose qu'il fait très bien : le texte décodé est parfaitement identique à l'original. Si fin que soit le découpage, aucune information n'est perdue. Il sait aussi traiter l'anglais, en revenant simplement à un token par octet :
一句含英文的:月 | 落 | 乌 | 啼 | 霜 | 满 | 天 | 。 | H | e | l | l | o
Les vrais tokenizers
Les tokenizers des vrais grands modèles sont entraînés exactement ainsi, avec des données plus volumineuses de plusieurs ordres de grandeur : des dizaines ou des centaines de Go de texte en toutes sortes de langues, et plus de cent mille fusions. En septembre 2026, les vocabulaires des principaux modèles comptent pour la plupart entre cent mille et un peu plus de deux cent mille tokens. Avec assez de données, les caractères chinois courants sont tous fusionnés en tokens complets, et les mots courants deviennent aussi un seul token.
Cela explique aussi ce que nous avons vu au module 01 :
- Les tokenizers des différents modèles sont entraînés sur des données différentes, d'où des nombres de tokens différents pour un même texte. Un modèle entraîné sur beaucoup de chinois découpe le chinois plus économiquement.
- Les caractères rares apparaissent peu dans les données d'entraînement, ne sont pas fusionnés, et sont donc découpés en plusieurs tokens au niveau des octets.
La tokenisation de ce module
Notre GPT n'utilise pas ce BPE, mais la tokenisation la plus simple, au niveau des caractères : un caractère égale un token.
class CharTokenizer:
"""字符级分词器:一个字符就是一个词元。换行符表示一首诗结束。"""
def __init__(self, text):
self.chars = sorted(set(text))
self.index = {c: i for i, c in enumerate(self.chars)}
def encode(self, text):
return [self.index[c] for c in text]
def decode(self, ids):
return "".join(self.chars[i] for i in ids)
La raison, nous l'avons vue plus haut : sur si peu de données, le BPE découpe même plus finement que les caractères. Et l'unité de langue des poèmes Tang est avant tout le caractère ; un token par caractère est tout naturel. Les 35 000 poèmes réunis comptent 6288 caractères différents : le vocabulaire est petit, et chaque caractère apparaît plusieurs fois pendant l'entraînement.
Le prix de la tokenisation par caractère : elle ne sait que faire d'un caractère hors vocabulaire. Cela n'affecte pas notre expérience, car le vocabulaire est construit à partir de tous les poèmes. Mais elle ne peut pas traiter un texte quelconque hors des données d'entraînement, et c'est pourquoi les vrais grands modèles utilisent tous un BPE au niveau des octets.
Exercices
- Passez les données d'entraînement de 2000 à 5000 poèmes (un peu plus lent). Combien de caractères par token en moyenne sur le jeu de test ?
- Avant l'entraînement, isolez d'abord virgules, points et retours à la ligne, et ne comptez et fusionnez qu'à l'intérieur de chaque vers. Quels tokens de plus de deux caractères sont désormais appris ?
- Le vocabulaire initial d'un BPE au niveau des octets compte 256 octets. Quels avantages et inconvénients y aurait-il à partir plutôt des caractères (vocabulaire initial = tous les caractères différents des données d'entraînement) ?
Auto-test
1. Que fait le BPE à chaque étape de l'entraînement ? Quand s'arrête-t-il ?
Il compte combien de fois chaque paire de tokens voisins apparaît dans les données d'entraînement, fusionne la paire la plus fréquente en un nouveau token, l'ajoute au vocabulaire et la remplace dans les données. Il répète ce processus jusqu'à atteindre le nombre de fusions fixé d'avance, c'est-à-dire la taille de vocabulaire voulue.
2. Pourquoi faut-il, pour encoder, fusionner dans l'ordre où les règles ont été apprises ?
Les règles tardives ont été apprises sur le résultat des fusions précédentes ; ce qu'elles fusionnent peut justement être un nouveau token issu d'une fusion antérieure. Sans respecter l'ordre d'origine, les règles tardives ne trouvent rien à fusionner, et le découpage obtenu ne correspond plus à celui de l'entraînement.
3. Pourquoi le BPE de cette leçon produit-il sur le jeu de test plus de tokens que de caractères ? Pourquoi les vrais tokenizers n'ont-ils pas ce problème ?
Les données d'entraînement ne sont que 2000 poèmes ; beaucoup de caractères chinois n'y apparaissent pas assez souvent pour être fusionnés en caractères complets, et restent découpés en deux ou trois fragments au niveau des octets. Les vrais tokenizers sont entraînés sur des masses de données, avec plus de cent mille fusions, et les caractères courants comme les mots usuels deviennent des tokens complets.
Questions et discussion
Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.
Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.
Chargement de la discussion…