đ§ L'architecture est un seuil, pas un levier â ce que j'ai appris en optimisant un LLM français de 15M de paramĂštres đ«đ·
đ€ Contexte â oĂč on en Ă©tait
Si tu as lu le premier article : LLM français de 15M de paramÚtres, construit from scratch, en solo, sur une GTX 1080 Ti. Architecture style LLaMA, BPE 32k custom, Wikipedia français réécrit par IA. Coupure de courant à l'epoch 10/18.
Le modĂšle avait appris la forme, pas le fond. Il Ă©crivait un français parfait, du markdown structurĂ©, une grammaire fluide â mais hallucine les faits et dĂ©rive hors sujet aprĂšs ~200 tokens.
Cet article est le suivi honnĂȘte : une semaine entiĂšre de mesure â correction â mesure, sans nouvelle architecture, sans GPU plus puissant. Juste extraire systĂ©matiquement tout ce qu'il reste dans le modĂšle 15M. La conclusion dĂšs maintenant : l'architecture n'a jamais Ă©tĂ© le goulot d'Ă©tranglement. C'Ă©tait les donnĂ©es et le dĂ©codage.
đȘ Le modĂšle mental â l'architecture est un seuil, pas un levier
Le plus grand changement de perspective de la semaine.
Tout le monde s'obsĂšde sur l'architecture parce qu'elle est visible et copiable â RoPE, SwiGLU, GQA, papers, courbes. Les donnĂ©es et l'objectif d'entraĂźnement sont sales, ennuyeux, invisibles. Alors on optimise ce qu'on voit â comme chercher ses clĂ©s sous le lampadaire parce que c'est lĂ qu'il y a de la lumiĂšre.
Mais voilà la vérité à 15M de paramÚtres :
L'architecture est un seuil, pas un levier. En dessous d'un certain niveau elle te bloque. Au-dessus â et un transformer pre-norm propre avec weight tying est au-dessus â elle ne bouge presque plus le curseur. Le goulot migre vers les donnĂ©es et l'objectif.
J'ai donc construit une échelle de priorité, du ROI le plus élevé au plus faible :
| Niveau | Levier | Verdict |
|---|---|---|
| 1 | Code / mesure | le multiplicateur de tout le reste |
| 2 | Efficacité des données | le plus sous-estimé |
| 3 | Réglage de l'entraßnement | incrémental mais sûr |
| 4 | Vitesse / VRAM | itérer plus vite |
| 5 | Algo / innovation | le fun â mais en dernier |
L'insight clé : l'innovation (niveau 5) est inutile sans mesure (niveau 1). Une innovation qu'on ne peut pas quantifier, c'est de la foi, pas de la recherche. Donc on construit l'instrument d'abord.
đïž Niveau 0 â corriger les bugs d'architecture d'abord
Avant d'optimiser, j'ai auditĂ© gpt2.py. Plusieurs vrais bugs cachĂ©s en pleine vue â et un seul avait vraiment de l'importance.
Celui qui changeait rĂ©ellement les sorties : repetition_penalty Ă©tait Ă l'envers. Il divisait tous les logits par la pĂ©nalitĂ©. Pour un logit nĂ©gatif, diviser le rend plus probable â exactement l'inverse de l'intention. Donc la feature censĂ©e rĂ©duire la rĂ©pĂ©tition l'encourageait en partie. SĂ©mantique HF : logit > 0 ? /penalty : *penalty. C'est le seul qui a visiblement amĂ©liorĂ© la gĂ©nĂ©ration ; les autres sont des corrections de correction/stabilitĂ©.
Les suivants :
- đ Pas de scaled init GPT-2 â les projections rĂ©siduelles n'Ă©taient pas scalĂ©es par
1/â(2·n_layer), ce qui maintient la variance du residual stream bornĂ©e avec la profondeur. Affecte la stabilitĂ© de l'entraĂźnement, seulement sur un run from scratch. - đ KV-cache n'utilisait jamais SDPA â la gĂ©nĂ©ration (
use_cache=True) tombait sur l'attention manuelle lente. Correction de vitesse, pas de qualitĂ©. - đ IncompatibilitĂ©
torch 2.3.1âadd_safe_globalsn'existe pas avant la 2.4 ;from_pretrainedcrashait au chargement. Correction de crash.
Validé avec la suite de tests d'architecture (tests/test_architecture.py) : 10/10, dont "Flash vs Manual equivalence" et "KV-cache vs no-cache match". Le refactoring a changé comment les choses se calculent, pas ce qu'elles produisent.
đ§č Niveaux 1+2 â les donnĂ©es me mentaient
La réécriture du document packing
Le prepare_data.py original découpait chaque document en tranches de 450 tokens à des offsets arbitraires, paddait à 512 (~12% de padding gaspillé), sans marqueurs <bos>/<eos>.
La conséquence c'était la dérive thématique. Le modÚle avait vu des millions de fragments coupés en pleine phrase, jamais un document entier, propre, délimité. Il n'avait donc jamais appris qu'un texte a un début, un développement et une fin.
Correction : document packing (style GPT-3 / LLaMA). Envelopper chaque doc dans <bos> ⊠<eos>, tout concaténer en un flux continu de tokens, découper en blocs pleins de 768. Zéro padding, zéro coupure en pleine phrase, frontiÚres de documents explicites.
Avant (chunking) : 354 148 blocs de 512, ~12% padding, pas de frontiĂšres
AprĂšs (packing) : 349 490 blocs de 768, 0% padding, <bos>/<eos> partout
Tokens perdus : 527 (0,000%)
Le corpus était plein d'emojis
En lisant les fichiers rĂ©els, c'est apparu : ~30% du corpus Ă©tait polluĂ© par des emojis dĂ©coratifs (un article de thĂ©ologie avait un đ ou âš aprĂšs presque chaque proposition). Ajoutez des espaces insĂ©cables Ă©troites (U+202F) partout et des guillemets droits avec un espacement bizarre.
scripts/clean_corpus.py : supprimer les emojis, normaliser les espaces spĂ©ciaux, convertir les guillemets droits " en guillemets français « » en les appairisant â tout en protĂ©geant les blocs de code (tout ce qui est dans des fences ``` ou des backticks inline reste intact, donc le HTML class="x" survit).
Ăa a rĂ©cupĂ©rĂ© ~3,5M tokens de bruit pur : 271,9M â 268,4M tokens de français rĂ©el.
La vérité dure sur les données
Puis j'ai lu assez de fichiers pour voir le vrai problĂšme, celui qu'aucun nettoyage ne peut corriger :
Le corpus est la longue traĂźne de Wikipedia. Un fichier = un sujet, vu exactement une fois. Petits villages (BaranĂłw, Oderaue), plantes obscures (Aruncus dioicus), inconnus. La qualitĂ© du texte est excellente â mais chaque fait est un hapax.
C'est pourquoi le modĂšle hallucine. Il voit Synlycostrobus une fois â il ne peut pas ancrer le fait â il invente. Structure cohĂ©rente (152k exemples de "comment Ă©crire un article") mais faits ratĂ©s (chaque fait vu une seule fois). Pas un bug. La nature du corpus + le plafond Ă 15M.
J'ai 100k articles nettoyĂ©s supplĂ©mentaires prĂȘts. Je ne les utilise pas Ă 15M â ils sont toujours en format un-sujet-par-fichier. On l'a dĂ©jĂ prouvĂ© : passer de 20% â 60% des donnĂ©es a amĂ©liorĂ© le style/PPL (30 â 17) mais pas la factualitĂ©. Plus d'hapax = plus de style, mĂȘme hallucination. Mesurer avant d'agir.
đ Niveau 1 â le harnais de mesure
Le multiplicateur. scripts/evaluate.py : donne-lui un checkpoint, il retourne des chiffres comparables :
val_pplâ perplexitĂ© sur l'ensemble de validationrepetition_3gramâ % de trigrammes rĂ©pĂ©tĂ©s (plus bas = mieux)distinct_2â bigrammes uniques / total (diversitĂ©, plus haut = mieux)coherence_lenâ tokens gĂ©nĂ©rĂ©s avant la dĂ©rive (un heading#ou une boucle de rĂ©pĂ©tition). Ăa quantifie enfin la dĂ©rive que j'estimais Ă l'Ćil.
Un piĂšge mĂ©thodologique dans lequel je suis tombĂ© : le sampling sans seed fixe donnait des mĂ©triques bruitĂ©es (le mĂȘme modĂšle oscillait entre cohĂ©rence 30 et 37). Inutilisable pour comparer deux modĂšles. Seed fixe â reproductible et rĂ©aliste.
L'avant/aprĂšs sur les runs :
| Run | config | val PPL | coherence_len |
|---|---|---|---|
| A | 20% Ă 1 epoch (corpus sale) | 77 | â |
| B | 20% à 3 (nettoyé) | 30,6 | 38 |
| C | 60% à 6 (nettoyé + packed) | 17,8 | 48 |
đ Le bug de dĂ©rive â deux tokens spĂ©ciaux, des heures de confusion
La meilleure histoire de débogage de la semaine.
Le modĂšle abandonnait le sujet du prompt en dĂ©marrant un nouveau document : prompt "âŠdans le dĂ©partement du Lot" â "# La commune d'Ăvreux, nichĂ©e dans les Alpes françaisesâŠ". Prose fluide et cohĂ©rente â sur un endroit complĂštement diffĂ©rent.
J'ai d'abord incriminé le token #. Faux. AprÚs beaucoup de tests, deux causes cumulatives, toutes deux liées au document packing :
encode(add_special_tokens=True)ajoutait un<eos>Ă la FIN du prompt. Le modĂšle, entraĂźnĂ© surâŠ<eos><bos># Titre, lisait ça comme "ce document est terminĂ©" â en dĂ©marrait un nouveau. C'Ă©tait le dĂ©clencheur principal.- Le modĂšle pouvait Ă©mettre
<bos>en cours de génération.
Le <bos> Ă©tait invisible dans la sortie (dĂ©codĂ© avec skip_special_tokens=True) mais dĂ©clenchait quand mĂȘme le comportement "nouveau document" â ce qui m'a fait chercher le # pendant des heures.
Correction : supprimer le <eos> final du prompt, et interdire <bos> pendant le décodage (no_new_doc, activé par défaut). Résultat : le modÚle reste sur Saint-Céré et conserve ses sections ## internes.
La leçon, maintenant bug #6 dans mon README : les tokens spĂ©ciaux de l'entraĂźnement (<bos> texte <eos>) ne sont PAS ce qu'on veut Ă l'infĂ©rence (<bos> texteâŠ). Un classique, mais brutal Ă trouver dans un codebase from scratch sans framework pour prĂ©venir.
đ° Niveau 3 â rĂ©gler le sampling (le plus gros gain gratuit)
scripts/tune_sampling.py balaie (temperature, top_k, repetition_penalty) et tout mesure. Le résultat était spectaculaire :
| config | rep3â | distinct2â | cohĂ©renceâ |
|---|---|---|---|
| baseline (t0.8 k50 r1.2) | ~0,07 | ~0,86 | ~38 |
| optimal (t0.8 k40 r1.3) | ~0,00 | ~0,99 | ~76 |
repetition_penalty est LE levier. Passer de 1,2 â 1,3 a Ă peu prĂšs doublĂ© la cohĂ©rence (38 â 76) et tuĂ© la rĂ©pĂ©tition â et surtout, le texte est restĂ© naturel (vĂ©rifiĂ© Ă l'Ćil, pas que par les mĂ©triques). ZĂ©ro rĂ©entraĂźnement. Le plus grand saut de qualitĂ© de toute la semaine est venu d'une seule valeur de config.
â ïž Note : en dĂ©codage greedy le mĂȘme modĂšle rĂ©pĂšte massivement (rep 0,50), mais en sampling c'est propre (rep 0,07). Les petits modĂšles ont besoin du sampling â greedy les piĂšge en boucles.
đ§Ș Niveau 5 â la campagne innovation (tout contraster)
C'est là que je suis passé en mode recherche. L'idée : le contrastive decoding pour pousser la qualité encore plus loin. J'ai mené une campagne complÚte.
Contrastive Decoding (Li et al. 2022)
logits = (1+λ)·expert â λ·amateur, avec la contrainte de plausibilitĂ© (α-masking). Trois variantes de contraste testĂ©es :
| Contraste | résultat | pourquoi |
|---|---|---|
| Entre checkpoints (15M vs 15M) | â â4 coh | distributions trop proches â on soustrait du bruit |
| DoLa (couche finale vs prĂ©coce) | â â3 coh | 8 couches trop proches |
| Classique (15M vs amateur tiny 4.5M) | â +1,9 coh | vrai Ă©cart de capacitĂ© |
Le pattern : Ă 15M, le contraste n'aide que s'il y a un vrai Ă©cart de capacitĂ©. Trop proche (checkpoints, couches) = rien Ă soustraire. Trop loin (un amateur qui bafouille) = on soustrait du bruit. Il y a une fenĂȘtre Ă©troite.
Le bug qui a failli me donner un faux négatif
Le moment honnĂȘte. Mon premier run CD disait "Ă©chec total, tout dĂ©grade." Un rĂ©flexe de relecteur m'a fait vĂ©rifier le code plutĂŽt que de croire le rĂ©sultat.
Le script CD n'appliquait pas le sampling optimal (rep_pen 1.3) â il comparait un CD non rĂ©glĂ© contre une baseline non rĂ©glĂ©e, les deux en boucle. L'"Ă©chec" Ă©tait un bug mĂ©thodologique, pas un rĂ©sultat. Avec le mĂȘme sampling des deux cĂŽtĂ©s, le CD classique aide lĂ©gĂšrement.
Leçon : un résultat négatif d'un harnais buggé ne vaut rien. Toujours vérifier le code avant de publier la conclusion.
Ăval qualitative â ce que les mĂ©triques ne voyaient pas
Les chiffres disaient "+1,9 cohérence". J'ai lu les vraies générations, baseline vs CD, sur des prompts factuels :
- FactualitĂ© : â aucune amĂ©lioration â les deux inventent autant (capitale de la France â Paris dans les deux cas).
- PropretĂ© : â le CD supprime les artefacts. Sur "La Seconde Guerre mondiale a durĂ© de", la baseline produisait des emojis-chiffres parasites ("3âŁ3âŁ7⣠ans") ; le CD non.
- FluiditĂ© : â le CD se lit lĂ©gĂšrement mieux, des templates d'articles plus nets.
Le CD nettoie les tics de gĂ©nĂ©ration (l'amateur en a aussi â ils sont soustraits) mais ne peut pas crĂ©er de savoir que l'expert n'a pas. Exactement ce que la thĂ©orie prĂ©dit.
â Ce qui a marchĂ©
- đ§č Document packing â a tuĂ© la dĂ©rive structurelle, la plus grande correction de donnĂ©es
- đ°
repetition_penalty1.3 â cohĂ©rence doublĂ©e, gratuit, une valeur de config - đ Correction des tokens spĂ©ciaux â le modĂšle reste sur son sujet
- đ Un harnais de mesure reproductible â chaque affirmation est falsifiable
- đ§Ș CD classique â gain qualitatif petit mais rĂ©el
â Ce qui n'a pas marchĂ© (et c'est normal â c'est de la recherche)
- đ CD entre checkpoints â distributions trop corrĂ©lĂ©es
- đ DoLa â 8 couches c'est trop peu pour le contraste par couche
- đŻ FactualitĂ© â irrĂ©ductible Ă 15M sur un corpus longue traĂźne. Aucune astuce de dĂ©codage n'invente des faits.
- đ +100k articles â mĂȘme format un-sujet-par-fichier, ne changera pas la factualitĂ© Ă cette taille
â ïž Limites
- 15M paramĂštres reste un modĂšle de style, pas une base de connaissance
- Le corpus longue traßne plafonne la factualité quel que soit le décodage
- Les vrais gains factuels nĂ©cessitent soit du RAG soit un scale-up Ă 25-30M (avec les donnĂ©es supplĂ©mentaires) â un prochain chapitre
đ RĂ©fĂ©rence rapide â Ă©tat final
| ParamĂštre | Valeur |
|---|---|
| Params | ~15M (n_embd 256 / n_layer 8 / n_head 4) |
| Données | nettoyées + document-packed, 268M tokens, blocs de 768 |
| Meilleur run | 60% Ă 6 epochs, val PPL 17,8 |
| Sampling | temp 0.8 / top_k 40 / repetition_penalty 1.3 |
| coherence_len | 38 â 76 aprĂšs rĂ©glage du sampling |
| Corrections décodage | supprimer le <eos> final, interdire <bos> |
| Meilleur gain algo | CD classique (15M vs amateur 4.5M), +1,9 coh |
| Harnais d'éval | rep3 / distinct2 / coherence_len, seed fixe |
đ Conclusion
Une semaine de travail et pas une ligne de nouvelle architecture. Les gains sont venus d'endroits ennuyeux : comment les donnĂ©es sont dĂ©coupĂ©es, deux tokens spĂ©ciaux, un hyperparamĂštre de dĂ©codage. La recherche flashy (contrastive decoding, DoLa) a donnĂ© les plus petits gains â et l'un d'eux Ă©tait presque un faux nĂ©gatif causĂ© par mon propre harnais buggĂ©.
La vraie leçon ne concerne pas les LLM français. C'est que une fois que l'architecture passe le seuil, le goulot d'étranglement est partout sauf dans l'architecture. Mesurer d'abord. Lire ses propres données. Vérifier son code avant de croire ses conclusions. Le modÚle n'a jamais été le problÚme.
Prochaine Ă©tape : le seul levier restant au niveau des donnĂ©es â scale-up Ă 25-30M avec le corpus Ă©largi, sur du vrai cloud compute. Mais ça, c'est le prochain article. đ
â Q&R
â Tu as passĂ© une semaine et la PPL est passĂ©e de 17,8 à ⊠17,8 ?
Exact â la PPL a plafonnĂ©, parce que le run 60% Ă 6 avait dĂ©jĂ atteint le plafond donnĂ©es du modĂšle. Les gains de la semaine sont dans la qualitĂ© de gĂ©nĂ©ration (cohĂ©rence doublĂ©e, dĂ©rive corrigĂ©e, artefacts supprimĂ©s), que la PPL ne capture pas. C'est exactement pourquoi j'ai construit un harnais multi-mĂ©triques plutĂŽt que de faire confiance Ă la perplexitĂ© seule.
â Le contrastive decoding valait l'effort pour +1,9 de cohĂ©rence ?
En tant que feature produit, Ă peine. En tant que recherche, oui : ça produit un rĂ©sultat propre et falsifiable sur quand le contraste aide Ă petite Ă©chelle (un Ă©cart de capacitĂ© est nĂ©cessaire), et l'histoire du dĂ©bogage du faux nĂ©gatif est plus instructive que le gain lui-mĂȘme.
â Pourquoi ne pas simplement accepter le modĂšle et passer Ă autre chose ?
Parce que le but n'a jamais Ă©tĂ© le modĂšle â c'Ă©tait comprendre chaque levier. Maintenant je sais, chiffres Ă l'appui, quels leviers bougent quoi. C'est transfĂ©rable au run scale-up d'une façon que "ça a l'air mieux" ne permettrait jamais.
đĄ Le saviez-vous ?
Un rĂ©sultat nĂ©gatif n'est fiable qu'Ă hauteur du harnais qui l'a produit. Mon premier run de contrastive decoding rapportait "Ă©chec total" â et c'Ă©tait faux, parce que le code d'Ă©valuation n'appliquait pas le mĂȘme sampling des deux cĂŽtĂ©s. Un bug de mesure ne te donne pas une petite erreur ; il peut inverser entiĂšrement ta conclusion.
Théo CHARLET
DiplĂŽmĂ© du TSSR (Technicien en systĂšmes et rĂ©seaux informatiques) â SpĂ©cialisation IA/ML
CrĂ©ateur dâAG-BPE (Attention-Guided Byte-Pair Encoding)
đ LinkedIn : https://www.linkedin.com/in/thĂ©o-charlet
đ RDTvlokip Search (mon moteur de recherche) : https://search.rdtvlokip.fr
đ Ă la recherche d'opportunitĂ©s de stage
đ Site web : https://rdtvlokip.fr
đ GitHub du projet : https://github.com/RDTvlokip/GPT-2-French-From-Scratch