Articles · Smallem : un LLM en Rust et CUDA, à partir de zéro
Des données et un premier modèle
Troisième article de la série Smallem. Précédent : Un premier kernel sur le GPU.
Cet article construit un modèle de langage complet sans réseau de neurones et sans GPU. Il est très mauvais, mais il permet de mettre en place tout ce qui entoure un modèle : le corpus, les tokens, les lots, la mesure de qualité et la génération. Tout cela resservira tel quel jusqu'à la fin de la série.
Le corpus
Le texte vient de FineWeb-Edu, un ensemble de pages web éducatives en anglais, triées par
un classifieur de qualité. On utilise son échantillon sample-10BT : 14 fichiers au format
Parquet, d'environ 2,15 Go chacun. Le premier contient à lui seul 726 000 documents.
Un fichier Parquet range les données par colonnes, chacune compressée séparément. Celui-ci
en a dix : le texte, et neuf colonnes de métadonnées (identifiant, adresse de la page,
langue, score de qualité, etc.). Seule la colonne text nous intéresse.
Ce format a deux propriétés utiles, et le programme de préparation exploite les deux.
- On peut ne lire qu'une colonne. Le lecteur ne décompresse alors pas les neuf autres.
- Le fichier est découpé en groupes de lignes, ici 726 groupes de 1 000 documents. On les lit un par un, sans jamais charger le fichier entier en mémoire.
self.projection est un schéma réduit à la seule colonne text : c'est lui qui dit au
lecteur d'ignorer le reste.
Pour les neuf premiers articles, on ne garde que les 100 premiers mégaoctets de texte. C'est assez pour mettre au point tous les modèles, et assez petit pour que chaque essai soit rapide.
Du texte aux nombres
Un modèle ne manipule pas des lettres mais des nombres. Il faut donc une règle qui transforme un texte en suite d'entiers, et la règle inverse. C'est le rôle du tokenizer.
La première idée serait de numéroter les caractères. Elle ne tient pas sur du texte venu du web, qui contient tout Unicode : des lettres accentuées, du japonais, des émojis, soit plus de cent mille caractères possibles, dont la plupart n'apparaîtraient presque jamais.
On descend donc d'un niveau. Dans un fichier, un texte est une suite d'octets, et la convention qui associe des octets à des caractères s'appelle UTF-8. Un caractère y occupe de un à quatre octets :
| Caractère | Octets en UTF-8 |
|---|---|
a | 61 |
é | C3 A9 |
日 | E6 97 A5 |
🦀 | F0 9F A6 80 |
Un octet ne peut prendre que 256 valeurs. En prenant l'octet comme token, on obtient un vocabulaire de 256 tokens qui couvre n'importe quel texte, sans cas particulier.
On ajoute un 257ᵉ token, le numéro 256, qui marque la fin d'un document. Sans lui, le modèle verrait la fin d'un article sur la photosynthèse enchaîner directement sur le début d'un article sur le droit romain, et apprendrait que c'est une suite normale.
Pourquoi des u16, des entiers sur 16 bits, alors que les octets tiennent sur 8 ? À cause
de ce 257ᵉ token, qui ne tient pas sur 8 bits, et parce qu'à l'article 10 le vocabulaire
passera à 8 192 tokens. Un u16 peut numéroter jusqu'à 65 536 tokens : le format de
fichier ne changera plus.
Le décodage fait le chemin inverse, avec deux précautions :
Le filter_map écarte les tokens qui ne sont pas des octets, donc la fin de document. Et
from_utf8_lossy tolère les suites d'octets invalides. Un modèle peu entraîné produira
souvent la première moitié d'un é sans la seconde : plutôt que d'échouer, on affiche le
caractère de remplacement �.
Entraînement et validation
Si on mesure un modèle sur le texte qui a servi à l'entraîner, on ne sait pas s'il a compris quelque chose ou s'il a appris ce texte par coeur. On met donc de côté une partie du corpus qu'il ne verra jamais : la validation.
Le découpage se fait par document entier, jamais au milieu d'un texte : un document sur vingt part en validation.
La règle ne dépend que du rang du document. Elle donnera donc toujours le même découpage, quel que soit le programme ou le tokenizer, ce qui permettra de comparer tous les modèles de la série.
Le programme prepare écrit deux fichiers, train.bin et val.bin. Leur format est le
plus simple possible : les tokens, bout à bout, deux octets chacun, sans en-tête.
train.bin : 21 413 documents, 104 889 840 tokens
val.bin : 1 127 documents, 5 259 050 tokens
Tirer un lot
Un modèle ne s'entraîne pas sur tout le corpus d'un coup, mais sur de petits échantillons,
les lots. Un lot contient B fenêtres de texte prises à des endroits tirés au hasard.
Chaque fenêtre fait T + 1 tokens, et sert à fabriquer une entrée et une cible, toutes deux
de longueur T :
fenêtre : T h e ␣ c a t
entrée : T h e ␣ c a
cible : h e ␣ c a t
La cible est l'entrée décalée d'un cran. À chaque position, la cible est le token qui suit dans le texte : c'est ce que le modèle doit apprendre à deviner.
for _ in 0..batch_size
Le bigramme
Le bigramme est notre tout premier modèle. Son fonctionnement est très simple : le token suivant ne dépend que du token courant. Il oublie tout ce qui précède. Il sera donc très mauvais, mais parfait pour illustrer le processus global.
Pour l'obtenir, on parcourt le texte d'entraînement, et pour chaque paire de tokens voisins on ajoute 1 dans une table de 257 lignes et 257 colonnes : la ligne est le token courant, la colonne le token suivant.
let mut counts = vec!;
for pair in tokens.windows
Puis on divise chaque ligne par son total. La ligne du token devient une distribution
de probabilités : elle dit, par exemple, que q est suivi de u dans presque tous les
cas.
Remarque : la table démarre à 1 et non à 0. C'est le lissage. Sans lui, une paire jamais vue à l'entraînement aurait une probabilité nulle, et on va voir que cela rendrait la mesure de qualité infinie.
Au passage, la table à deux dimensions est rangée dans un seul vecteur, ligne après ligne :
la case (a, b) est à l'indice a × 257 + b. C'est exactement la façon dont on rangera
tous les tenseurs à partir de l'article suivant.
Mesurer : l'entropie croisée
Comment dire qu'un modèle est meilleur qu'un autre ? On regarde quelle probabilité il donnait à ce qui s'est réellement passé.
Prenons un texte de validation. À chaque position, le modèle propose une distribution, et
on connaît le token qui venait vraiment. Si le modèle lui donnait une probabilité p, on
compte une perte de -ln p :
| Probabilité donnée au bon token | Perte -ln p |
|---|---|
| 1 (certitude, et il avait raison) | 0 |
| 0,5 | 0,69 |
| 0,1 | 2,30 |
| 0,01 | 4,61 |
| 0 | infinie |
La perte est nulle quand le modèle était sûr et avait raison, et elle grandit à mesure qu'il est surpris. La dernière ligne explique le lissage : une seule probabilité nulle suffirait à rendre la moyenne infinie.
La perte d'un modèle est la moyenne de -ln p sur toutes les positions. On l'appelle
entropie croisée (cross entropy).
L'unité : des nats par octet
Avec le logarithme naturel ln, la perte se mesure en nats. Avec le logarithme en
base 2, ce seraient des bits : un nat vaut environ 1,44 bit. Et comme un token est ici un
octet, on parle de nats par octet.
Cette perte a un point de repère naturel. Un modèle qui ne sait rien donne la même
probabilité aux 257 tokens, soit 1/257 à chacun, et sa perte vaut ln 257 ≈ 5,55. C'est le
point de départ de tout modèle non entraîné, et on vérifiera ce nombre à chaque nouveau
modèle de la série : s'il ne sort pas, quelque chose est cassé.
L'exponentielle de la perte a aussi un sens. On l'appelle la perplexité : c'est le nombre de choix équiprobables entre lesquels le modèle hésite, en moyenne. Une perte de 5,55 correspond à 257 choix ; une perte de 2,53, à environ 12,6.
Résultat
perte uniforme : 5.5491
perte train : 2.5298
perte validation : 2.5299
Le bigramme divise la perte par plus de deux. Au lieu d'hésiter entre 257 octets, il hésite entre une douzaine. Les pertes d'entraînement et de validation sont identiques : avec 66 000 nombres à apprendre pour 100 millions d'exemples, il n'a aucun moyen d'apprendre par coeur.
Générer
Pour produire du texte, on part du token de fin de document, qui précède le début de chaque texte. On tire un token selon sa ligne dans la table, puis un autre selon la ligne de celui-ci, et ainsi de suite.
Le tirage lui-même tient en quelques lignes. On tire un nombre entre 0 et 1, et on avance dans les probabilités jusqu'à l'avoir dépassé :
Un token de probabilité 0,5 occupe la moitié de l'intervalle : il est tiré une fois sur deux. Voici ce que cela donne :
Nend fouc und if iboune d tangestrifin fure Yonuseron, e arevedace micheseeds is cheon
ert this atrd bye Thandes leres t wt wofe os edie In bol asalindon o ociaiound pe ve "?
C'est du charabia, mais un charabia instructif. Les mots ont des longueurs plausibles, les
voyelles et les consonnes alternent, et quelques vrais mots apparaissent (if, is,
this, In). Le modèle a appris quelles lettres se suivent en anglais. Il ne peut rien
apprendre de plus : il ne voit qu'une lettre à la fois.
Ce qu'il faut retenir
- Un token est ici un octet, plus un token de fin de document : 257 en tout.
- L'entraînement et la validation sont des ensembles de documents distincts.
- La perte est la moyenne de
-ln p, oùpest la probabilité donnée au bon token. Un modèle ignorant part deln 257 ≈ 5,55. - Le bigramme atteint 2,53. C'est la barre que tous les modèles suivants devront franchir.
Pour faire mieux, il faut un modèle qui regarde plus loin en arrière, donc un réseau de neurones, donc des tenseurs. C'est l'objet de l'article suivant : Un tenseur sur CPU.