Aller au contenu

Training SLMs (4) - Nouveau jeu de test et Hyperparamètres.

Hyper, Hyper!
Jean-Claude Trottinette, MLOps Engineer(ou Scooter, je ne sais plus)

Jusqu’ici, tout va bien. En quelques heures sur mon Potato PC, mes modèles sont passés de zéro message exploitable à un format parfait et à quatre types de commit sur huit maîtrisés. Le pari initial, qui consistait à montrer qu’un Small Language Model peut apprendre une tâche étroite sur du matériel lambda, est tenu.

Reste le morceau qui résiste, et l’article précédent l’a mis en avant : tout ce qui peut encore être gagné est dans feat et refactor. L’étape logique suivante est le réglage des hyperparamètres, et c’est le sujet de cet article. Je vous ai déjà spoilé la fin : ça ne marche pas. Mais avant de rater quoi que ce soit, il faut être capable de s’en rendre compte, et c’est là que ça a failli déraper.

D’abord, un nouveau jeu de test

A ce stade, je m’apprête à comparer des configurations d’entraînement dont j’espère qu’elles vont me rapporter un ou deux cas de mieux. Et mon instrument de mesure, c’est un jeu de test de 30 commits, dont j’ai établi dans l’article précédent que relancer deux fois exactement la même configuration en fait bouger le score d’environ un cas, à cause de l’ordre non déterministe des noyaux CUDA.

Un cas sur 30, ça fait 3,3 points. J’allais donc chercher un effet de 2 points avec une règle graduée tous les 3,3 points. Autrement dit : j’allais passer une soirée à tirer à pile ou face en appelant ça une campagne d’optimisation. Il y a des façons plus rapides pour perdre son temps, mais peu qui donnent autant l’impression de faire de la science.

Le problème est en réalité pire que ça quand on regarde là où je veux mesurer. Mes 30 cas contiennent 6 feat et 3 refactor, c’est-à-dire précisément les deux catégories où tout le gisement se trouve. Sur trois refactor, un cas vaut 33 points. Aucune conclusion n’est possible à cette résolution, quel que soit le soin apporté au reste.

Donc avant de toucher au moindre hyperparamètre, il faut agrandir le jeu de test. Ce n’est pas la partie glamour du projet, mais c’est celle sans laquelle tout le reste ne vaut rien.

Comment on agrandit un jeu de test sans casser l’historique

La contrainte principale n’est pas la taille, c’est la continuité. J’ai déjà douze adaptateurs entraînés et notés sur les 30 cas d’origine, et je n’ai aucune envie de jeter deux articles de résultats à la poubelle. La solution : le nouveau jeu est un sur-ensemble strict de l’ancien. Les 30 commits d’origine sont conservés tels quels, et 38 s’ajoutent à côté.

Le tirage réutilise le script de sélection de l’article 2, avec une option d’exclusion pour ne pas retirer ce qui est déjà pris, et surtout la même stratification : un plancher par type pour que les types rares ne disparaissent pas, une proportion choisie de gros diffs pour ne pas mesurer principalement la résistance à la troncature, et un quota par dépôt. Changer les règles du tirage en même temps que la taille aurait rendu les deux jeux incomparables, ce qui aurait annulé l’intérêt du sur-ensemble.

Les 40 commits tirés passent ensuite par le script de curation, qui signale les cas suspects pour relecture humaine. Deux sont écartés :

  • feat(a11y): a11y enhacements : recouvrement nul entre le message et son diff. Le message ne dit rien que le modèle puisse lire dans ce qu’on lui montre, et sa faute de frappe ne l’aide pas. C’est le même filtre que celui appliqué aux données d’entraînement dans l’article 2, appliqué cette fois au jeu de test.
  • refactor(sync): improve labels in settings : des modifications de libellés d’interface, typées refactor. Défendable dans mon dialecte, mais impossible à retrouver depuis le diff seul.

Un mot sur ce deuxième cas : écarter un cas parce que les modèles le ratent serait de la triche pure et simple, et le score en sortirait gonflé. Ici le tri est fait avant toute évaluation, sur un critère qui ne regarde que la donnée : le diff ne contient pas de quoi répondre. La règle que je m’impose est simple, mais elle est plus facile à énoncer qu’à respecter : on écarte un cas pour ce qu’il est, jamais pour la note qu’il coûte.

Le nouveau jeu

Il reste donc 68 commits, jamais vus par aucun modèle à l’entraînement.

Type 30 cas 68 cas
chore 7 17
fix 7 16
feat 6 13
refactor 3 6
test 2 5
perf 2 5
docs 2 5
style 1 1

Répartition par dépôt : Fulgur 25, Fulgurant 23, zeptodo 20. Vingt-deux des 68 dépassent délibérément le budget de 3000 caractères de diff. La classe majoritaire est chore à 17/68, soit 25%, donc le score de la stratégie idiote de référence, celle qui répond chore à tout sans rien lire, reste au même niveau qu’avant. C’était le but : je change la précision de la règle, pas la difficulté de l’épreuve.

Ce que ça rapporte concrètement :

  • un cas vaut désormais 1,5 point au lieu de 3,3, donc le plancher de bruit passe sous la barre de l’effet que je cherche à mesurer,
  • feat passe de 6 à 13 cas et refactor de 3 à 6, ce qui est le vrai objectif de l’opération. Les catégories qui portent tout le reste à gagner sont celles qui doublent.

Le cas style, celui qui est perdu d’avance depuis l’article 2 faute d’exemples d’entraînement, reste seul de son espèce. Agrandir le jeu de test n’a donc rien changé à mon incapacité à le traiter, ça a juste divisé par deux ce qu’il me coûte. C’est toujours ça de pris.

La facture

Ce genre de décision n’est jamais gratuit, et celle-ci se paye comptant : aucun des douze adaptateurs déjà entraînés ne peut être noté sur ce nouveau jeu. Tous ont vu les 38 nouveaux cas passer dans leurs données d’entraînement. Les noter dessus reviendrait à faire passer un examen à un élève qui a lu le corrigé, ce qui est très exactement le principe que le jeu de test existe pour empêcher.

Les deux corpus sont donc reconstruits pour exclure la totalité des SHAs mis de côté, ce qui fait tomber le corpus combiné de 855 à 824 paires d’entraînement, et le corpus humain de 467 à 437. La règle pour la suite du projet tient en deux lignes : tout ce qui est entraîné à partir de maintenant est noté sur les 68 cas, et toute comparaison avec les résultats des articles précédents se fait sur le sous-ensemble de 30. Perdre trente paires d’entraînement pour gagner un instrument de mesure plus utilisable est un bon plan, d’autant que l’article précédent a établi que le volume de données n’est plus le facteur limitant pour trois de mes quatre modèles.

Voilà. Un détour entier, zéro amélioration du modèle, trente paires d’entraînement en moins, et un instrument de mesure qui permet enfin de savoir si ce que je vais faire ensuite sert à quelque chose. On peut passer aux hyperparamètres.

Les hyperparamètres

Les hyperparamètres sont les réglages de l’entraînement lui-même : on les fixe avant de lancer le run, ils décident de la façon dont l’apprentissage du modèle se déroule, et si on veut en changer il faut réentraîner. C’est ce qui les distingue des poids, qui sont le résultat de l’entraînement. Le guide d’Unsloth en fait un tour complet et lisible, y compris pour ceux qui comme moi n’utilisent pas Unsloth, puisque ces réglages ne dépendent pas de l’outil.

J’en fais varier deux, et il faut noter qu’ils ne sont pas de même nature :

  • Le nombre d’époques est le nombre de passages complets sur le jeu d’entraînement. Une époque, c’est le modèle qui a vu chacun de mes commits exactement une fois. Trop peu, il n’a pas fini d’apprendre, c’est le sous-apprentissage. Trop, il commence à apprendre mes exemples par cœur au lieu d’apprendre ma règle, c’est le surapprentissage. Ce réglage existe dans n’importe quel entraînement, avec ou sans LoRA.
  • Le rang, lui, est spécifiquement LoRA. C’est la largeur du goulot d’étranglement des deux matrices greffées à côté de chaque couche : la première comprime vers r dimensions, la seconde décomprime. Ce r fixe donc le nombre de paramètres réellement entraînables, et donc la quantité de choses que l’adaptateur est capable de retenir. Rang élevé, plus de capacité, plus de mémoire consommée, et plus de risque d’apprendre le bruit avec le signal.

Cinq configurations, toutes sur SmolLM2-135M, toutes entraînées sur le corpus combiné reconstruit, celui qui vient de tomber à 824 paires, et toutes notées sur les 68 cas. Un script enchaîne les entraînements et les évaluations, ce qui évite la principale source d’erreur de ce genre de campagne : une configuration qui change discrètement entre deux runs qu’on croit comparables. Pourquoi le 135M et pas le meilleur modèle du lot ? Parce que c’est celui dont l’article précédent a montré qu’il était de loin le meilleur rapport qualité/taille, parce qu’il s’entraîne en une poignée de minutes, et parce que faire cinq runs sur Qwen aurait coûté une journée pour répondre à une question qui, on va le voir, ne méritait pas une journée.

Run Type feat refactor fix Loss d’entraînement
e3r16 (config par défaut) 39/68 5/13 1/6 11/16 2,886
e6r16 41/68 5/13 1/6 12/16 2,426
e10r16 41/68 5/13 0/6 14/16 1,891
e6r32 38/68 5/13 1/6 10/16 2,139
e6r64 40/68 6/13 1/6 11/16 1,814

Le format est à 68/68 partout, et il ne sera plus jamais question de lui dans ce projet.

L’échec, c’est aussi un résultat

Les deux colonnes du milieu, celles pour lesquelles toute cette opération a été montée, disent tout. De 3 à 10 époques, du rang 16 au rang 64, feat ne quitte jamais 5/13 et refactor ne quitte jamais 1/6. Une seule case bouge d’un cas sur l’ensemble du tableau, et c’est le genre de mouvement qu’un simple relancement produit gratuitement.

J’ai donc fait varier mes deux leviers sur un facteur trois et un facteur quatre, en visant explicitement deux catégories identifiées à l’avance, et je n’ai pas déplacé une virgule. C’est un échec, il est net, et c’est précisément pour ça qu’il est utile : un échec flou aurait laissé la porte ouverte à un sixième run, puis un septième. Celui-ci ferme la question.

Trois choses valent d’être notées :

La meilleure configuration est e6r16, et je n’y crois qu’à moitié. Passer de 3 à 6 époques rapporte 2 cas, soit 3 points. Sauf que j’ai bâti tout le début de cet article sur le fait qu’il faut comparer un écart à son plancher de bruit avant de l’appeler un résultat, et je ne vais pas m’en dispenser pour le seul chiffre qui m’arrange. Un cas vaut 1,5 point sur ce jeu, donc 2 cas sont tout juste au-dessus de la résolution de l’instrument, et pas au-dessus de tout soupçon. Je retiens cette configuration pour la suite du projet parce qu’elle ne coûte rien et qu’il faut bien en choisir une, pas parce que j’ai mesuré une nette amélioration. Et je le note ici pour une raison précise : je mesurerai plus tard, dans des conditions qui n’avaient rien à voir, ce que deux runs strictement identiques donnent sur ce jeu de 68 cas, et la réponse rendra ce paragraphe très pertinent.

Plus de capacité dégrade le résultat. Le rang 32 obtient le plus mauvais score du tableau, 38/68, tout en collant mieux aux données d’entraînement que le rang 16. Ce n’est pas une anomalie, c’est une réponse : le modèle n’est pas limité par sa capacité. Ce que j’essayais de lui faire apprendre n’était pas trop gros pour l’adaptateur que je lui donnais, donc lui en donner un plus large n’ajoute que de la place pour mémoriser. C’est aussi une petite leçon d’économie : le réflexe d’augmenter les compteurs quand ça ne marche pas est souvent le plus cher et pas forcément le plus informatif.

La loss et le score pointent dans des directions opposées. Je l’avais teasé à la fin de l’article précédent, voilà la démonstration. La meilleure configuration termine à une loss de 2,426, tandis que les deux qui généralisent le plus mal terminent à 1,814 et 2,139, c’est-à-dire nettement plus bas. Le run le plus doué pour prédire les commits qu’il a déjà vus n’est pas le plus doué sur ceux qu’il n’a jamais vus, et c’est la définition même du surapprentissage.

Le cas e10r16 mérite qu’on ouvre le capot, parce qu’il ressemble à un progrès et n’en est pas un. Passer de 3 à 10 époques fait grimper fix de 11 à 14 sur 16, ce qui est le plus gros gain visible de tout le tableau. Sauf qu’en regardant la répartition des prédictions, le modèle est simplement passé de 25 à 28 réponses fix. Il n’a pas appris à reconnaître un correctif, il a appris que répondre fix paye, ce qui est vrai sur un jeu qui en contient 16 sur 68. Et il le paye ailleurs, immédiatement : refactor tombe de 1 à 0, test de 4 à 3. C’est le même effondrement sur une étiquette unique que le feat compulsif de l’article 2 et le fix de Gemma dans l’article 3, en version discrète et donc plus dangereuse, parce que cette fois le score agrégé monte au lieu de descendre. Un gain qui ne fait que déplacer l’a priori du modèle n’est pas un gain, et c’est très exactement pour attraper ce genre de mirage que je traîne la colonne de répartition des prédictions depuis le début.

Ce que j’ai le droit de conclure, et ce que je n’ai pas le droit de conclure

Soyons précis sur la portée, parce que la tentation est grande de transformer cinq runs en loi générale. Ce que je peux affirmer : sur ce corpus et pour ce modèle, ni le nombre d’époques ni le rang ne déplacent feat et refactor. Ce que je ne peux pas affirmer, c’est que “les hyperparamètres ne servent à rien”.

Je n’ai touché que deux réglages sur la petite dizaine qui existent. Tout le reste est resté fixe d’un run à l’autre, et autant donner les valeurs puisque c’est ce qui rend l’expérience reproductible : taux d’apprentissage à 2e-4, longueur maximale d’exemple à 2048 tokens, graine fixée à 20260804, et le dropout comme le choix des couches ciblées laissés à leurs valeurs par défaut. Le run de référence tourne en 303 pas d’optimisation pour 0,93 Go de VRAM au pic, ce qui explique accessoirement pourquoi cinq configurations tiennent dans une soirée.

Un sweep exhaustif reste donc possible. Il n’est simplement pas raisonnable, et pour une raison qui n’a rien à voir avec le temps de calcul.

Faisons le compte des leviers, dans l’ordre recommandé et que j’ai suivi :

  • les données, réglées dans l’article 3 : trois modèles sur quatre plafonnent, et doubler le corpus par une passe de typage n’a rien rapporté sur le jeu de test,
  • les hyperparamètres, réglés ici : rien ne bouge sur les deux catégories visées,
  • la taille du modèle, réglée sans même avoir eu à faire l’expérience : le 135M fait aussi bien que le 360M et mieux que le 270M, donc l’échelle ne se comporte pas comme le levier qu’on attend.

Les trois leviers habituels sont épuisés, et aucun n’a bougé les mêmes neuf ou dix cas. Quand tous les réglages échouent au même endroit, ce n’est plus un problème de réglage. C’est un problème de question posée.

Ce qui reste, c’est l’entrée. Je demande à un modèle de deviner, depuis un diff, si son auteur considérait son changement comme une nouveauté ou comme une réparation. Sur les cas où ces deux lectures se défendent également, aucune époque supplémentaire ne peut récupérer une information qui n’est pas dans le fichier. La suite logique serait donc de changer ce que le modèle a sous les yeux plutôt que la façon dont il l’apprend : lui donner la liste des fichiers et le nombre de lignes ajoutées et supprimées comme des champs explicites, au lieu d’un patch brut où il doit tout reconstruire. Ça demande de reminer le corpus et de refaire toutes les mesures de référence, donc c’est une expérience à part entière et elle attendra.

Et maintenant ?

Bilan de cet article : un jeu de test deux fois plus grand, trente paires d’entraînement en moins, cinq runs, et zéro cas gagné là où je visais. Sur le papier, une soirée perdue.

Sauf que je sais maintenant quelque chose que je ne savais pas en commençant, et c’est ce que valent les expériences négatives quand on prend la peine de les mener proprement : ce qui bloque n’est pas dans la façon dont j’entraîne. Sans ces cinq runs, j’aurais passé les articles suivants à tripoter des taux d’apprentissage avec l’espoir tenace que la bonne combinaison existe quelque part.

Toute la conclusion ci-dessus repose sur une phrase que je répète depuis deux articles : l’intention de l’auteur n’est pas dans le diff, donc le plafond est atteint. C’est commode, ça absout élégamment mes modèles, et je l’ai déduite de trois exemples bien choisis sans jamais la vérifier. Dans le prochain article, je la mesure. Elle ne survit pas. C’est ballot, non ?

TL;DR

  • Avant d’optimiser quoi que ce soit, il faut un instrument capable de voir l’effet cherché. Mon jeu de test de 30 commits avait un plancher de bruit de 3,3 points, pour une amélioration espérée de 2 points : j’allais tirer à pile ou face en appelant ça de la science.
  • Le nouveau jeu de test fait 68 commits et il est un sur-ensemble strict de l’ancien, ce qui garde tous les résultats déjà publiés comparables. Un cas vaut 1,5 point, et feat comme refactor doublent en nombre de cas. Prix à payer : aucun adaptateur déjà entraîné ne peut être noté dessus, et le corpus d’entraînement perd trente paires.
  • On écarte un cas du jeu de test pour ce qu’il est, jamais pour la note qu’il coûte.
  • Cinq configurations, de 3 à 10 époques et du rang 16 au rang 64 : feat reste à 5/13 et refactor à 1/6 partout. Les deux leviers ne touchent pas les deux catégories qui portent tout le reste à gagner.
  • Augmenter le rang dégrade le score tout en améliorant la loss : le modèle n’est pas limité par sa capacité, et lui donner plus de place ne lui sert qu’à mémoriser.
  • Un gain qui ne fait que déplacer l’a priori du modèle n’est pas un gain. Dix époques achètent 3 cas de fix en répondant fix plus souvent, et les repayent aussitôt en refactor et en test.
  • Données, hyperparamètres et taille de modèle échouent tous les trois au même endroit. Le problème n’est donc plus dans l’entraînement, il est dans ce qu’on donne à lire au modèle.