Aller au contenu

Training SLMs (7) - Le dialecte ne s'exporte pas.

Donnez à un petit garçon un SLM, et il découvrira que tout ce qu’il rencontre a besoin d’être essayé sur son SLM.
Abraham Kaplan(ou Pierre qui teste les limites de son idée, je ne sais plus)

Il y a une question que je repousse depuis le log #3 que j’avais explicitement mise de côté en promettant qu’elle valait un article à elle seule. La voici.

Tout ce que j’ai mesuré jusqu’ici l’a été sur mes dépôts, mes habitudes, et sur du Rust uniquement. J’ai donc entraîné des modèles à imiter une seule personne sur un seul langage, et j’ai passé six articles à raffiner cette mesure. La question évidente, celle que se pose finalement à n’importe qui s’intéressant à ce projet après 6 articles : est-ce que ça sert à quelque chose ailleurs, ton truc ?

La réponse est nettement plus intéressante qu’un oui ou un non. Une moitié de ce que mes modèles ont appris traverse gratuitement. L’autre moitié traverse aussi, et c’est bien le problème : de l’autre côté, elle ne fait pas mieux qu’une règle qui ne lit rien.

Un jeu de test payé au prix fort

Pour répondre proprement, il faut des commits que le modèle n’a jamais vus, dans un langage qu’il n’a jamais vu, écrits par la même personne pour que le style reste comparable. Ça tombe bien, j’ai écrit en juillet une application SvelteKit de gestion de demandes d’accréditations pour France Racing, 43 commits non-merge dont 42 respectent la convention. Du TypeScript et du Svelte, écrits par moi.

Ce dépôt aurait pu fournir environ 35 paires d’entraînement supplémentaires. Il n’en a fourni aucune, et c’est une décision prise au tout début du projet : il est exclu de l’entraînement, entièrement, définitivement. Une seule paire TypeScript qui fuite dans le corpus et la seule mesure inter-langages non biaisée du projet part à la poubelle.

C’est le genre d’arbitrage qu’on n’aime pas faire quand on a un petit corpus et qu’on compte chaque exemple. Trente-cinq paires sur un corpus de 824, c’est 4% de données en plus, gratuites, déjà minées, dans le bon format. Les garder pour la mesure plutôt que pour l’entraînement était le bon choix, et l’article que vous lisez n’existerait pas autrement.

Une fois passé par les mêmes filtres que le reste du projet, il en reste exactement 30 commits utilisables, et je les prends tous. Ce n’est pas un échantillon, c’est le recensement complet : il n’y a pas de trente-et-unième cas à aller chercher, et pas de tirage dont la composition pourrait fausser le résultat.

Petit bonus méthodologique : les fichiers .svelte sont structurellement différents de tout ce que le modèle a vu. Un seul fichier y mélange le balisage, le script et le style, là où un .rs est homogène. C’est un test de généralisation plus dur que du .ts pur, et donc plus informatif.

Comment lire ce jeu, avant de regarder le moindre chiffre

Ce jeu contient 21 cas feat sur 30, parce que c’est une application jeune et qu’on y ajoute surtout des fonctionnalités. Donc, exactement comme dans l’article 2 : répondre feat à tout, sans rien lire, rapporte 21 sur 30, soit 70%.

Gardez ce nombre sous les yeux pour toute la suite. Sur ce jeu, le score de type global est presque inexploitable : il récompense d’abord le modèle qui répond le plus souvent feat. Les seuls cas qui disent vraiment quelque chose sont les 9 où feat est la mauvaise réponse. Je le dis avant les chiffres plutôt qu’après, parce que dans l’autre ordre ça ressemble à une excuse.

La bonne nouvelle : le format traverse, et gratuitement

Commençons par la moitié qui marche, et elle marche parfaitement.

Les cinq modèles fine-tunés écrivent 30 sujets valides sur 30, dans un langage sur lequel aucun n’a été entraîné, sur des fichiers .svelte dont ils n’ont jamais vu la structure. Avant fine-tuning, les SmolLM2 et Gemma n’en écrivaient pas un seul. C’est logique quand on y réfléchit. Conventional Commits est une convention publique, identique partout, indépendante du langage. Un modèle qui a appris à produire type: résumé impératif court a appris quelque chose d’universel. Il n’y a rien à réapprendre en TypeScript.

Ce point est plus important qu’il n’en a l’air pour qui voudrait refaire ce projet : la partie la moins chère du fine-tuning est aussi la seule qui s’exporte. Quelques dizaines d’exemples suffisent à l’installer, on l’a vu dès l’article 3, et elle vous suit partout.

La mauvaise : le type ne traverse pas

Modèle (100% des données) Format Type en domaine Type en transfert
SmolLM2 135M 30/30 41/68 (60%) 15/30
Gemma 270M 30/30 38/68 (56%) 20/30
SmolLM2 360M 30/30 35/68 (51%) 13/30
Qwen 0.8B 30/30 51/68 (75%) 16/30
SmolLM2 1.7B 30/30 44/68 (65%) 16/30
Répondre feat à tout - 17/68 (25%) 21/30

Relisez la dernière ligne. Aucun de mes modèles entraînés ne bat la stratégie qui consiste à répondre feat sans rien lire. Pas un. Y compris le Qwen 0.8B qui, deux articles plus tôt, battait Claude Opus 5 sur mon corpus. Et pas seulement à 100% des données : sur les treize combinaisons de modèle et de palier d’entraînement, aucune n’atteint 21.

Sur ce dépôt, six articles de travail produisent donc au mieux un modèle à un cas d’une règle tenant en une ligne : écrire feat partout, sans ouvrir un seul diff. Et ce meilleur-là n’est pas celui qu’on croit.

Le meilleur score est un trompe-l’œil

Gemma 270M, avant-dernier sur mon corpus, arrive premier ici avec 20/30. Regardons comment : il répond feat 22 fois sur 30. Il ramasse ainsi 17 des 21 feat, et sur les 9 cas où cette réponse est fausse, il en trouve 3, exactement comme à 25% et à 50% des données. Toute sa progression d’un palier à l’autre, c’est son a priori qui se resserre sur la classe majoritaire du jeu. Il ne bat pas la règle triviale, il est en train de la devenir.

Modèle (100% des données) feat (21 cas) fix (5 cas) Hors feat (9 cas) Quand il dit feat, il a raison
SmolLM2 135M 12 2 3 12/15
Gemma 270M 17 3 3 17/22
SmolLM2 360M 11 1 2 11/15
Qwen 0.8B 12 4 4 12/14
SmolLM2 1.7B 11 3 5 11/12

Qwen est le meilleur sur les fix : 4 sur 5, à chaque palier. Le 1.7B a la meilleure précision du lot : quand il répond feat, il a raison 11 fois sur 12. Ce sont eux qui lisent le diff, et leur score global est plus bas précisément parce qu’ils ne s’effondrent pas sur la réponse majoritaire. Sur les 9 cas informatifs, on retrouve à peu de chose près l’ordre de tous les articles précédents : Qwen et le 1.7B en tête, Gemma et le 360M en queue.

Neuf cas, donc une direction et rien de plus : même le plus grand écart, Qwen à 25% des données (6/9) contre Gemma (3/9), n’est pas significatif (p ≈ 0,35).

L’explication tient dans une distribution, pas dans une compétence

Regardons ce que le Qwen entraîné à 100% répond réellement sur ces trente cas TypeScript. Il répond fix douze fois, sur un jeu qui n’en contient que cinq.

Tout est là. Mes quatre dépôts sources sont des projets Rust en maintenance : on y corrige beaucoup plus qu’on n’y ajoute, et mon corpus est donc lourdement chargé en fix. Le modèle a parfaitement appris cet a priori, et il l’exporte consciencieusement vers une application jeune où la distribution est inversée.

Le modèle n’a pas mal compris les diffs TypeScript. Il a très bien appris ma distribution et l’a appliquée à quelqu’un d’autre. Le score global en transfert ne mesure pas une compétence, il mesure surtout la distance entre deux a priori : celui que le modèle applique, et celui du dépôt d’arrivée. C’est exactement pour ça que Gemma gagne au classement global et perd sur les cas qui comptent.

Et plus de données, ça aide ?

L’intuition dit que non : si le modèle exporte mon a priori, plus de données devraient le rendre plus net, et donc plus faux ici. Les paliers d’entraînement à 25%, 50% et 100% du corpus ne confirment pas cette intuition :

Modèle 25% 50% 100%
SmolLM2 135M 6/30 8/30 15/30
Gemma 270M 15/30 16/30 20/30
SmolLM2 360M 16/30 14/30 13/30
Qwen 0.8B 19/30 16/30 16/30

Deux modèles montent, deux descendent, et les deux SmolLM2, qui sortent pourtant de la même famille, ne vont même pas dans le même sens. Deux directions sur quatre modèles, ce n’est pas une direction : rien dans ce dépôt ne permet de dire que plus de données en domaine aident ou nuisent ailleurs.

Ce qui sépare les deux groupes, c’est encore l’a priori. Ceux qui montent répondent feat de plus en plus souvent à mesure que les données augmentent : Gemma 16, puis 18, puis 22 fois. Ceux qui descendent le répondent moins souvent à 100% qu’à 25%, et Qwen glisse dans l’autre sens, vers fix : 8, puis 11, puis 12 fois. Même corpus, mêmes paliers, dérives opposées. Pourquoi un modèle dérive d’un côté plutôt que de l’autre, je n’en sais rien, et je préfère le dire que d’inventer une explication.

La passe de typage, une dette à moitié remboursée

J’avais laissé une deuxième dette dans le log #3. La passe de typage, celle où j’ai fait étiqueter 428 vieux commits antérieurs à l’adoption de Conventional Commits, n’avait strictement rien rapporté sur mon jeu de test. J’avais mentionné en une phrase qu’elle avait en revanche nettement amélioré les résultats en TypeScript, et j’avais remis l’explication à plus tard.

Le mécanisme est plausible. Ces vieux commits sont les débuts de mes projets, l’époque où l’on construit au lieu de réparer, et ils sont plus riches en feat que le reste du corpus (31% contre 24%). Les ajouter n’a rien appris à mes modèles sur la lecture d’un diff, le jeu en domaine l’a bien montré en ne bougeant pas d’un pouce, mais ça a pu rééquilibrer un a priori trop chargé en fix.

Seulement, “nettement”, c’était aller trop vite. Les écarts observés à l’époque tenaient à un ou deux commits, et je n’ai pas réévalué sur ce jeu complet les modèles entraînés sans la passe de typage. C’est une hypothèse cohérente avec tout le reste, pas un résultat.

Ce que ça dit vraiment du dialecte

La formule des articles précédents disait : le fine-tuning achète le dialecte, un meilleur pré-entraînement achète le jugement. Ce jeu de trente commits lui ajoute une frontière, et c’est la contribution de cet article :

Le dialecte est une propriété de mon corpus, pas une propriété des messages de commit en général.

Il se décompose en deux morceaux qui n’ont pas du tout le même statut :

  • Le format est la moitié universelle. Conventional Commits est identique chez tout le monde. Cette moitié s’apprend en quelques dizaines d’exemples et vous suit dans n’importe quel dépôt, n’importe quel langage. C’est un actif.
  • Le type est la moitié locale. Qu’un commit soit typé chore plutôt que style, qu’un projet corrige plus qu’il n’ajoute, ce sont des faits sur moi et sur l’âge de mes dépôts. Transporter ça ailleurs n’est pas neutre, c’est un passif.

Et la seconde moitié de la formule tient aussi de l’autre côté : quand le dialecte ne sert plus à rien, ce sont les deux meilleurs modèles de mon corpus, Qwen et le 1.7B, qui lisent encore le diff. Avec toutes les réserves que méritent neuf cas.

Ce qui donne une règle pratique pour quiconque voudrait utiliser ce genre d’outil : un modèle entraîné sur vos commits est utilisable sur vos dépôts. Pas sur ceux de votre collègue, et pas non plus sur votre propre nouveau projet tant qu’il n’est pas dans la même phase de développement. Ce n’est pas une limite du fine-tuning, c’est sa définition. Un modèle qui apprend un dialecte apprend forcément un dialecte particulier, sinon ce ne serait pas un dialecte.

Les limites de tout ce qui précède

Trente cas, et pas plus : c’est le dépôt entier. Plus de cas n’y changerait rien de toute façon, parce que le problème n’est pas la taille du jeu mais sa composition. Avec 70% de feat, le score global est presque muet, et la partie informative tient en 9 cas, où un cas vaut 11 points. Après avoir consacré la moitié de l’article 4 à expliquer qu’un jeu de 30 cas était trop grossier pour conclure, je ne vais pas prétendre l’inverse avec neuf.

Ce qui porte le résultat n’est donc aucun chiffre pris isolément, mais deux constats qui tiennent sur le dépôt entier :

  • le format passe partout, à 29 ou 30 sur 30 pour chaque modèle fine-tuné et chaque palier,
  • aucune des treize combinaisons de modèle et de palier ne bat la règle triviale, dans trois familles indépendantes. La meilleure n’en est qu’à un cas, mais elle y arrive en l’imitant, ce qui revient au même.

Mesurer proprement le transfert du type demanderait un dépôt TypeScript avec un vrai mélange de types, et je n’en ai pas. Des dépôts publics qui suivent strictement Conventional Commits, ça existe, mais ils ne sont pas écrits par moi, et c’est précisément mon dialecte que je cherche à mesurer. Il me reste donc deux options imparfaites : lire neuf cas, ou mesurer autre chose.

Et maintenant ?

Sept articles, et le tableau est complet : le format est acquis et portable, le type est excellent chez moi et ne bat pas une règle triviale ailleurs, la taille du modèle ne change rien à l’affaire, et le meilleur modèle du lot tient dans 800 millions de paramètres.

Autrement dit, j’ai un outil. Il est étroit, ses limites sont mesurées et documentées, et c’est déjà à peu près ce que le cahier des charges demandait.

Sauf qu’il reste ces deux catégories, feat et refactor, sur lesquelles j’ai désormais tout essayé. Le prochain article ne va donc pas essayer une neuvième chose : il change de question. Depuis sept articles je demande à un modèle d’écrire mes messages de commit. Il existe une autre façon de se servir exactement des mêmes poids, sans réentraîner quoi que ce soit : lui montrer le message que je viens d’écrire, avec son diff, et lui demander si les deux se correspondent.

Le changement n’est pas cosmétique. Un générateur doit répondre à tous les cas, y compris les 19% que l’article 5 a montrés réellement indécidables, et il mange l’erreur sur chacun d’eux. Un correcteur, lui, a le droit de se taire. Et il se trouve qu’à poids rigoureusement identiques, mon modèle est bien meilleur juge qu’il n’est auteur.

TL;DR

  • Les 30 commits TypeScript et Svelte de mon dépôt SvelteKit, soit le dépôt entier, volontairement exclus de l’entraînement depuis le premier jour pour préserver la seule mesure inter-langages non biaisée du projet. Coût : 35 paires d’entraînement sacrifiées.
  • Le format traverse parfaitement. Les cinq modèles fine-tunés écrivent 30 sujets valides sur 30 dans un langage jamais vu. Conventional Commits est universel, donc rien à réapprendre.
  • Le type ne traverse pas. Répondre feat à tout sans rien lire rapporte 21/30, et aucune des treize combinaisons de modèle et de palier ne l’atteint. La meilleure, Gemma à 20/30, y arrive en répondant feat 22 fois.
  • Sur les 9 cas où feat est faux, le classement s’inverse : Qwen trouve 4 fix sur 5 et le 1.7B a raison 11 fois sur 12 quand il dit feat, pendant que Gemma plafonne à 3 sur 9.
  • L’explication n’est pas une incompréhension du code mais une distribution : Qwen répond fix 12 fois sur un jeu qui en contient 5, parce que mes dépôts Rust sont en maintenance et celui-ci est un projet jeune. Le score global en transfert mesure la distance entre deux a priori.
  • Plus de données ne tranche rien. Selon le modèle, le score en transfert monte ou descend avec le volume, au gré de la dérive de son a priori vers feat ou vers fix.
  • La passe de typage aurait amélioré le transfert en rééquilibrant l’a priori vers feat, mais sur des écarts d’un ou deux commits, jamais remesurés sur le jeu complet. Une hypothèse, pas un résultat.
  • Le dialecte est une propriété de mon corpus, pas des messages de commit en général. Le format en est la moitié universelle et c’est un actif ; le type en est la moitié locale et c’est un passif dès qu’on change de dépôt.