Llama à partir de zéro : comment implémenter un article sans pleurer
(blog.briankitano.com)- Brian Kitano a construit lui-même une version réduite de Llama avec TinyShakespeare, et conclut que, pour implémenter un article en toute sécurité, il faut partir d’un petit modèle, remplacer les composants un par un, puis entraîner et évaluer à chaque étape.
- Il commence par mettre en place des fonctions auxiliaires de vérification comme la séparation des données, la génération de batches, l’évaluation de la perte et une fonction de génération, puis vérifie qu’un modèle simple compile et peut s’entraîner avant d’ajouter les composants de Llama.
- En ajoutant successivement RMSNorm, RoPE et SwiGLU, il vérifie que chaque couche fonctionne comme attendu au moyen des shapes de tenseurs, des propriétés mathématiques et des cartes d’attention.
- Dans l’attention RoPE, retirer le causal mask fait descendre la perte de validation jusqu’à 0,16, mais dégrade la qualité de génération ; la cause était une fuite d’information due à l’accès aux tokens futurs.
- Le Llama réduit final comporte 4 blocs et environ 2,37 millions de paramètres, abaisse la perte de validation à environ 1,0, et nécessite aussi de vérifier le flux des gradients ainsi que le planning de taux d’apprentissage.
Commencer petit et construire progressivement sa confiance
- Pour implémenter un article, l’essentiel est de commencer avec un petit modèle, de remplacer les composants un par un, puis de répéter entraînement et évaluation à chaque changement.
- Il faut d’abord préparer des fonctions auxiliaires permettant de vérifier le modèle quantitativement :
- séparation des données
- boucle d’entraînement
- visualisation de la perte
- évaluation de la perte de validation
- Plutôt que de porter tous les composants de l’article en une seule fois, il prépare aussi une fonction d’évaluation qualitative pour observer les sorties générées par un modèle simple, rapide et déjà familier.
- Les couches de tenseurs sont vérifiées avec
.shape,assertetplt.imshow; au lieu de commencer par optimiser les multiplications matricielles, il vérifie d’abord les résultats attendus à la main, puis les rend efficaces avec des fonctionstorch. - Il faut tester en faisant varier la taille de batch, la longueur de séquence et la dimension d’embedding ; un code qui ne fonctionne que pour une seule taille peut casser au moment de l’inférence.
Jeu de données et configuration de base
- La cible de l’implémentation est une version fortement réduite de Llama de Meta AI, et les données d’entraînement sont TinyShakespeare.
- Llama est entraîné sur 1,4 T de tokens, mais cette implémentation utilise TinyShakespeare, d’environ 1,11 million de caractères.
- Le Llama original utilise un tokenizer byte-pair encoding SentencePiece, mais cette implémentation utilise un simple tokenizer au niveau caractère.
- la taille du vocabulaire est de 65
- le jeu de données étant petit, le mode de stockage en mémoire n’est pas optimisé séparément
- Un dictionnaire
MASTER_CONFIGgère les réglages du modèle commevocab_size,batch_size,context_windowetd_model.- L’objectif est de réduire les constantes et magic numbers, et de rendre le code plus lisible.
- La fonction
get_batchesdécoupe les données en train 80 %, val 10 % et test 10 %, puis génère, depuis un point de départ aléatoire, une entréexet un labelydécalé d’un caractère.
Vérifier la compilation et l’entraînement avec un modèle de base
- Le premier modèle est
SimpleBrokenModel, composé d’un embedding et d’un réseau feed-forward simple :nn.EmbeddingLinearReLULinear
- Dans l’implémentation d’un article, dire qu’un modèle « fonctionne » signifie qu’il satisfait deux conditions :
- compilation : les shapes des tenseurs correspondent entre les couches
- entraînement : la perte baisse effectivement
- La fonction
evaluate_losséchantillonne 10 batches sur les splits train et val, puis calcule la perte moyenne. - Après 1000 epochs d’entraînement,
SimpleBrokenModelobtenait une perte de validation d’environ 3,94, quasiment inchangée par rapport à la cross-entropy initiale de 4,17. - La cause était le passage à
F.cross_entropyde valeurs déjà passées par softmax.F.cross_entropyde PyTorch reçoit directement des logits non normalisés.SimpleModel, après retrait du softmax, fait descendre la perte de validation à environ 2,51.
- Il ajoute ensuite une fonction
generatepour inspecter directement les caractères produits par le modèle ; le modèle de base reste imparfait, mais la perte de validation baisse.
Composant Llama 1 : RMSNorm
- Par rapport au Transformer original, Llama utilise trois modifications architecturales principales :
- RMSNorm en pre-normalization
- rotary embeddings
- fonction d’activation SwiGLU
- Le Transformer original utilise BatchNormalization, tandis que Llama utilise RMSNorm, qui met le vecteur à l’échelle par la variance sans le centrer.
- Alors que le Transformer original applique la normalisation à la sortie de la couche d’attention, en post-normalization, Llama utilise une pre-normalization appliquée d’abord à l’entrée.
- Le
RMSNormimplémenté suppose une entrée de shape(batch, seq_len, d_model). - Le résultat de RMSNorm est testé grâce à la propriété selon laquelle la norme de couche devient la racine carrée du nombre d’éléments de la couche :
assert- comparaison ligne par ligne
torch.allclose
SimpleModel_RMS, qui ajoute RMSNorm au modèle de base, réduit légèrement la perte de validation à environ 2,5015.
Composant Llama 2 : RoPE et causal mask
- RoPE est une méthode d’encodage positionnel pour Transformers, qui représente la position des tokens par une rotation de l’embedding.
get_rotary_matrixgénère, pour chaque position, une matrice de rotation à partir de la context window et de la dimension d’embedding.- L’implémentation de RoPE est testée avec la propriété suivante :
- le produit scalaire de deux vecteurs tournés aux positions
metndoit correspondre à une rotation de position relativen-m.
- le produit scalaire de deux vecteurs tournés aux positions
RoPEAttentionHeadcréew_q,w_ketw_v, applique la rotation RoPE aux query et key, puis utiliseF.scaled_dot_product_attention.- Il faut faire attention à la différence de shape des tenseurs entre entraînement et inférence :
- à l’entraînement, on est souvent aligné sur la configuration, par exemple
(config['batch_size'], config['context_window'], config['d_model']) - à l’inférence, il peut falloir traiter un exemple unique comme
(1, 1, config['d_model']) - dans
forward, l’indexation doit se baser sur la shape obtenue depuis l’entrée, et non sur les valeurs de configuration du modèle
- à l’entraînement, on est souvent aligné sur la configuration, par exemple
- Le modèle auquel on ajoute une attention multi-head RoPE sans causal mask voit sa perte de validation chuter brutalement à 0,1623, mais les sorties générées sont mauvaises, du type
OOOO...ouIIII.... - L’inspection de la carte d’attention montre que toutes les positions consultent toutes les positions, provoquant une fuite d’information : le modèle regarde les tokens futurs lors de la prédiction du token suivant.
- En passant à
RoPEMaskedAttentionHead, qui appliqueis_causal=TrueàF.scaled_dot_product_attention, l’attention upper triangular correspondant au futur devient presque nulle. - Après application du causal mask, la perte de validation est de 2,0815, puis descend à 1,8985 avec un entraînement plus long.
Composant Llama 3 : SwiGLU et empilement de blocs
- Llama remplace la non-linéarité ReLU par la fonction d’activation SwiGLU.
- Le
SwiGLUimplémenté est une Swish-gated linear unit, avec deux transformations linéaires et un paramètrebetaapprenable. - Le RopeModel qui ajoute SwiGLU dans la partie feed-forward compte 592 706 paramètres, avec une perte de validation d’environ 1,8963.
- Il crée ensuite
LlamaBlock, qui regroupe la configuration suivante dans un seul bloc :- RMSNorm en pre-normalization
- attention multi-head RoPE masquée
- connexion résiduelle
- RMSNorm en pre-normalization
- feed-forward SwiGLU
- connexion résiduelle
- Le modèle
Llamafinal est configuré avecn_layers=4et empile 4LlamaBlockvia unnn.Sequentialbasé surOrderedDict. - Le modèle final compte 2 370 246 paramètres, et les résultats d’entraînement sont les suivants :
- après le premier entraînement du modèle à 4 couches, perte de validation de 1,5532
- après entraînement supplémentaire jusqu’à 10 000 epochs, perte de validation de 1,1479
- après entraînement additionnel, perte de validation de 0,9997
- la perte sur un batch du split test est de 1,2358
Résultats de génération et points de débogage
- Le modèle final produit des noms, retours à la ligne et fragments de mots proches du format de Shakespeare, mais la qualité réelle des phrases reste limitée.
- La perte cross-entropy peut être interprétée intuitivement du point de vue du choix de tokens :
- la perte initiale de 4,17 est proche d’un choix aléatoire dans un vocabulaire de taille 65
- une perte de 1,08 s’interprète comme un choix aléatoire parmi environ 2,9 tokens
- Le flux des gradients est vérifié avec la fonction
show_grads.- Elle calcule la proportion de gradients de faible valeur absolue pour chaque paramètre.
- Si les gradients de la plupart des paramètres ne sont pas proches de 0, le flux est correct.
- Le Llama original utilise un planning de learning rate Cosine Annealing, mais dans cette implémentation les résultats expérimentaux étaient moins bons.
- Dans l’expérience Cosine Annealing, même avec une tolerance très faible, l’attention bias recevait très peu de signal ; la raison n’est pas certaine, il est donc plus sûr de commencer simplement dans une implémentation réelle.
1 commentaires
Avis de Hacker News
Il semble y avoir un bug dans l’implémentation de SwiGLU : dans l’article de référence, le bêta du feed-forward network est une constante, pas une valeur apprenable, et il est défini comme
FFnSwiGLU = Swish1...D’après l’équation 6 de https://arxiv.org/pdf/2002.05202.pdf
Dans l’implémentation officielle de llama, le bêta constant est également supprimé : https://github.com/facebookresearch/llama/blob/main/llama/mo...
Si l’on regarde les lignes
"feedforward.1.beta', 0.0"dans les logs du blog, le bêta a dégénéré à 0 pendant l’entraînement, alors qu’il devrait normalement être une constante égale à 1Il arrive aussi souvent que le réseau s’adapte aux changements, qu’ils soient intentionnels ou non, et qu’après entraînement plusieurs variantes d’architecture se comportent de façon similaire, si bien qu’il peut être ambigu de savoir s’il faut absolument correspondre à l’original
Une façon de trouver ce type d’erreur consiste à faire correspondre exactement les sorties avec celles d’une implémentation de référence. Comme avec les modèles tiny-random de HuggingFace, même avec des poids aléatoires, la sortie doit être exactement identique ; sinon, c’est le signe d’un bug
Cette méthode fonctionne toutefois surtout pour les bugs qui apparaissent pendant l’inférence ; les problèmes liés au traitement des données, à l’optimiseur ou qui ne surviennent que pendant l’entraînement sont plus difficiles à détecter
Personnellement, je pense que c’est dû à leur caractère autorégressif et à des propriétés proches des ODE, mais je n’en suis pas certain
Le travail est excellent, mais les premiers
SimpleBrokenModeletSimpleModelcomportent pas mal d’opérations gaspillées. L’ordre estembedding 65 -> 128,linear 128 -> 128,ReLU,linear 128 -> 65, mais il n’y a pas de non-linéarité entre les deux premières couches, et comme elles sont toutes deux linéaires, la deuxième couche linéaire ne sert en fait à rienAu final, ce modèle est équivalent à un MLP classique à une seule couche cachée et, en FLOPS, il gaspille
128*128=16kopérations sur un total de128*128+65*128=24kLa couche d’embedding est une structure spéciale qui transforme les indices de tokens en vecteurs d’embedding, donc je ne pense pas qu’on puisse la supprimer
Dans l’ensemble, cela illustre bien les principes de base. J’aime particulièrement la phrase « utilisez
.shapereligieusement.assertetplt.imshowsont vos amis », et il faut toujours vérifier par assert les préconditions et postconditions sur les shapesJe me demande aussi si
bearoutypeguardprennent en charge ce type de vérification via des décorateursEn revanche, la partie « choisissez un modèle petit, simple et rapide, et créez un helper pour l’évaluer qualitativement » veut sans doute dire évaluation quantitative. C’est ce qui permet d’avoir une baseline chiffrée pour comparer avec des techniques plus avancées
Le conseil d’implémenter les composants de l’article un par un devrait aussi être plus précis. Les articles essaient généralement plusieurs changements en même temps, puis montrent la contribution de chaque élément via des ablations ; il me semble donc préférable de commencer par les changements architecturaux essentiels, puis, dans l’ordre des effets les plus importants observés lors des ablations, d’évaluer chaque changement atomique en respectant les dépendances
bearoutypeguard, grâce à https://peps.python.org/pep-0646/, on peut en partie intégrer cela directement dans les annotations de type PythonPar exemple, on peut représenter la shape par axe dans le type, comme
ndarray[float, Dim1, *Shape], et surcharger la shape de retour selon la valeur deaxisbear/typeguardCela dit, Python aura probablement du mal à être aussi bon que Julia. Le système de types de Julia permet de garantir beaucoup plus facilement que les dimensions des matrices correspondent
Je me demande quel est le principe qui pousse à utiliser SwiGLU plutôt que ReLU. Je ne sais pas si les auteurs ont simplement essayé toutes les fonctions non linéaires possibles, ou s’il y a une raison plus profonde
Comme bearblog subit une attaque DDoS, je laisse le dépôt : https://github.com/bkitano/llama-from-scratch
Du point de vue de quelqu’un qui apprend l’IA, j’ai essayé de résumer simplement les termes cités dans l’article. Un token est un identifiant entier représentant un fragment de texte, et dans les LLM on regroupe des fragments de caractères fréquents dans un vocabulaire de taille limitée.
La fonction de perte est une valeur qui mesure l’écart entre la prédiction et la réponse attendue ; plus elle est basse, mieux c’est. PyTorch est une bibliothèque pour manipuler des tenseurs et des réseaux de neurones, et un tenseur est un tableau multidimensionnel de nombres incluant scalaires, vecteurs et matrices.
Un réseau de neurones est une structure de connexions entre neurones avec des poids et des biais, et une couche linéaire est une structure simple où toutes les entrées et sorties sont connectées. ReLU est une fonction d’activation comme
Math.max(0, x); si l’on empile uniquement des couches linéaires, cela revient finalement à une seule fonction linéaire, donc on ajoute de la non-linéarité pour augmenter la capacité d’apprentissage.Un gradient est une quantité de variation numérique calculée pendant l’apprentissage pour rendre le modèle plus précis, et la normalisation par lot est une méthode qui aide l’apprentissage en ajustant les nombres qui circulent. L’encodage positionnel indique sous forme de vecteurs les positions relatives des tokens.
En Python, l’opérateur
@est un alias de__matmul__et sert à la multiplication matricielle. Une époque correspond à un apprentissage sur l’ensemble complet du jeu de données, et un batch est le nombre de données injectées en une fois avant la mise à jour des paramètres.L’attention est le mécanisme clé qui fait fonctionner les LLM : elle traite les tokens d’entrée en parallèle pour produire des tenseurs intermédiaires, puis les utilise pour générer les tokens de sortie.
Par exemple,
writ, commun àwriting,writtenetwriter, peut devenir un token, etwriterpeut être tokenisé enwriteter.L’embedding est l’étape qui transforme ces tokens en représentations numériques uniques.
S’il existe une implémentation de référence du modèle et des checkpoints, la manière la plus efficace de vérifier que sa propre implémentation est correcte est de charger ces checkpoints et de comparer les sorties.
Si les sorties ne correspondent pas, c’est généralement qu’un détail de l’implémentation est incorrect, et on peut alors suivre chaque couche de façon systématique pour trouver la différence réelle. On peut même, au passage, découvrir des bizarreries dans l’implémentation existante.
Cela concerne le modèle lui-même ; l’entraînement est un axe séparé. Cela dit, si les hyperparamètres sont réglés de façon à peu près similaire, quand l’implémentation du modèle est correcte, les choses se passent généralement bien.
Les conseils sur la lecture d’articles et le contenu de cet article sont tous deux bons, et je recommande aussi la série Makemore de Karpathy.
Les conseils de synthèse sont excellents, et je pense que le conseil d’assert les shapes des tenseurs s’applique à n’importe quelle bibliothèque d’algèbre linéaire généraliste. Quand on écrit du code d’algèbre linéaire complexe, il est très important d’avancer par petites étapes et de coder défensivement.
Programmer de l’algèbre linéaire dans les langages grand public est pénible parce qu’il n’y a pas de vérification des shapes à la compilation. La shape d’un tenseur devrait faire partie de son type, et essayer de multiplier
3x4par3x4sans transposition devrait tout simplement empêcher la compilation.Faire tourner un long calcul pour échouer ensuite sur une opération avec dimensions incompatibles, c’est vraiment le pire.
Je pense aussi que les tenseurs PyTorch devraient avoir un typage statique de leur périphérique. Aujourd’hui, si l’on essaie de multiplier un tenseur en mémoire CPU par un tenseur en mémoire GPU, on obtient une erreur à l’exécution.