Training SLMs (2) - Dataset et baseline.
Données, données, données, Données, mes données à moi, Données, données, données, Pour du training IA.
L’étape critique de l’entraînement d’un petit modèle de langage, c’est la préparation des données. C’est celle qui décide de tout le reste. Ceux qui suivent de près les progrès des LLMs ont vu apparaître ces modèles compacts capables d’excellents résultats, non pas parce qu’on leur a fait avaler plus de données, mais parce qu’on leur en a fait avaler de meilleures. L’objectif de cet article est donc double : construire un dataset qualitatif à défaut d’être quantitatif, puis mesurer une baseline avant tout entraînement, histoire de savoir d’où l’on part.
Sélection des données
Étant donné l’objectif de réaliser un agent d’écriture de messages de commit d’une part, et un agent de validation de messages de commit d’autre part, il est primordial de trouver une source de couples (git diff, message de commit) qui correspondent à nos besoins. Je vais pour cela miner les commits de plusieurs de mes projets dont 3 open source :
- Fulgur, un éditeur de texte multiplateforme avec accélération GPU et synchronisation de fichiers,
- Fulgurant, le serveur de synchronisation de fichiers entre les éditeurs Fulgur,
- Zeptodo, une todo list ultra minimaliste hébergeable avec accès via API,
- Une petite application SvelteKit de gestion d’accréditations pour FranceRacing.
Les trois premiers sont écrits en Rust, mon principal langage de programmation. Nous allons donc pouvoir constituer un dataset spécialisé sur mon style d’écriture de commits et sur les technos que j’utilise. Cette hyperspécialisation est le gros point fort des SLMs : là où un LLM généraliste connaît la convention, un petit modèle bien nourri peut connaître vos habitudes.
Le quatrième fait exception, et volontairement : ses 42 commits ne serviront jamais à l’entraînement. Je les mets de côté pour poser plus tard une question qui vaut son propre chapitre, celle de savoir si un modèle entraîné exclusivement sur du Rust sait dire quoi que ce soit d’utile face à un diff TypeScript qu’il n’a jamais vu. Une seule paire qui fuirait dans l’entraînement suffirait à ruiner la mesure.
Préparation des données
La préparation des données s’effectue en plusieurs étapes : l’extraction des données des sources choisies, leur nettoyage et filtrage pour ne garder que le meilleure et enfin la sélection en données d’entrainement et jeux de test.
Extraction des données
Un script Python extrait les couples diff et message de ces projets, en prenant soin d’éviter les merges, et les sauvegarde dans un fichier JSONL, à raison d’un objet JSON par ligne. Il est exécuté deux fois :
- une première fois pour extraire les commits déjà au format Conventional Commits, soit 690 messages,
- une seconde fois pour extraire ceux qui ne suivent pas cette convention, soit 554 messages.
Ce second groupe n’est pas de la mauvaise donnée, c’est de la donnée ancienne : Fulgur et Fulgurant n’ont adopté les Conventional Commits qu’en mars 2026, et tout ce qui précède est de l’anglais libre. Les messages sont bons, il leur manque juste une étiquette. Après un peu de préparation, et avec l’aide de Claude, j’ai pu les pré-typer sur les cas évidents et n’avoir plus qu’à faire une revue du résultat en supprimant les cas trop ambigus et en consolidant toutes ces corrections. Restent ainsi 497 messages nouvellement typés.
Un point sur lequel je n’ai pas transigé : les labellers ont attribué un type, et rien d’autre. Pas un message n’a été réécrit. Sinon l’exercice devient circulaire, puisque écrire un message de commit à partir d’un diff est très exactement ce qu’on cherche à apprendre au modèle.
J’ai par ailleurs écarté le scope, optionnel dans la convention, au profit d’un plus simple type: message. Ce n’est pas seulement une simplification pour le confort du lecteur, c’est une décision mesurée. Sur 639 commits, on trouve 137 scopes distincts, dont 66 n’apparaissent qu’une seule fois. Tenter de le deviner à partir des fichiers modifiés plafonne à 21% de réussite, tout simplement parce que src/main.rs a été touché sous des dizaines de scopes différents. Et surtout, un scope faux est pire qu’un scope absent : si le modèle me propose feat(auth): sur un commit qui ne touche que la todo list, l’erreur est affirmée avec assurance et ne se voit pas, il faut la repérer avant même de pouvoir la corriger. Un feat: tout court, lui, est visiblement incomplet : le trou est sous mes yeux, et le combler prend trois secondes… si besoin est, étant optionnel par convention. Le scope reste malgré tout stocké dans les données brutes et n’est retiré qu’au moment de générer le jeu d’entraînement, histoire de pouvoir revenir dessus un jour sans tout re-miner.
Cet exercice a d’ailleurs été très intéressant d’un point de vue personnel pour revoir ma manière d’écrire des messages de commit !
Nettoyage des données
Un second script nettoie ces 690 + 497 messages en supprimant les commits sans valeur ajoutée : work in progress, bumps de version, mises à jour de dépendances, messages très courts ou très longs, quasi-doublons. Il produit un jeu d’entraînement de 855 messages et un jeu de validation de 96 messages.
Le filtre le plus utile n’est pas celui auquel on pense. Écarter les wip et les bumps de version, c’est évident. Celui qui a réellement changé quelque chose vérifie que le diff soutient le message : les mots porteurs de sens du message doivent apparaître quelque part dans le diff que le modèle voit. docs(readme): wrong image path n’a strictement aucun recouvrement avec le sien. Entraîner un modèle sur ce genre de paire, c’est lui apprendre à inventer un résumé plausible à partir de rien, ce qui est à peu près la définition d’une hallucination. 29 exemples y sont passés.
À l’inverse, un filtre que j’avais prévu s’est révélé nuisible. J’écartais au départ les commits de plus de 400 lignes modifiées. Mauvaise idée : il coûtait 91 exemples pour rien, puisque le diff est de toute façon déjà tronqué à 3000 caractères répartis entre les fichiers modifiés, et il biaisait l’entraînement vers les petits commits alors qu’un bon tiers de mon jeu de test est délibérément constitué de gros diffs. Un filtre qui crée un écart entre ce que le modèle voit à l’entraînement et ce qu’il verra à l’évaluation est un filtre qui fabrique un mauvais résultat.
Ce que contient vraiment le dataset
Nous sommes en théorie dans les limites basses quantitatives pour entraîner une IA dans de bonnes conditions, mais je fais confiance à la qualité globale des données pour compenser. Ceci dit, elles ne sont pas parfaites, et leur répartition par type est franchement déséquilibrée :
| type | part du corpus |
|---|---|
| feat | 28% |
| fix | 23% |
| chore | 19% |
| refactor | 13% |
| test | 8% |
| perf | 7% |
| docs | 3% |
| style | 0,1% |
Nous risquons donc d’obtenir des modèles qui proposent trop volontiers les quatre premiers types. Une meilleure répartition aiderait à limiter cet effet, mais on ne réécrit pas son historique git pour faire plaisir à un modèle.
Le cas style est plus amusant : il compte exactement un exemple d’entraînement. Il ne sera donc jamais appris, et comme le jeu de test contient justement un cas style, c’est une erreur garantie sur les 30, soit un peu plus de 3% du score maximum, que rien ne pourra jamais récupérer. Autant le savoir maintenant plutôt que de le diagnostiquer comme une défaillance du modèle dans trois articles. Autre limite assumée : 93% du corpus est du Rust.
Reste la question de fond, celle du volume. Mon plan initial visait 1000 à 2000 paires nettoyées, chiffre écrit avant d’avoir compté quoi que ce soit. Le corpus en fournit 951. La tentation est de remplacer l’objectif par le constat, mais ça ne nous apprend rien. Deux quantités étaient confondues : combien de paires existent, ce qui est mesuré et définitif, et combien de paires sont nécessaires, ce que personne ne sait à l’avance et qui dépend du modèle. Plutôt que de fixer une cible au doigt mouillé, j’entraînerai donc chaque modèle sur 25%, 50% et 100% du dataset. Si le score grimpe encore à 100%, les données sont le facteur limitant et il faut aller en chercher. S’il a plafonné avant, courir après plus de commits est du temps perdu. Réponse dans deux articles.
Le jeu de test
Un point de vocabulaire qui compte plus qu’il n’en a l’air : le fichier de validation de 96 messages ne sert qu’à surveiller l’entraînement. Il ne sert pas à noter le modèle. Pour ça, 30 commits supplémentaires ont été mis de côté avant tout le reste, et aucun d’eux n’apparaît nulle part dans l’entraînement, ce qu’un test automatique vérifie à chaque reconstruction du dataset. Un modèle noté sur des données qu’il a déjà vues, c’est un élève qui a lu le corrigé.
Ces 30 commits ne sont pas tirés au hasard, et les deux premières tentatives ont été instructives. Un tirage proportionnel à mon historique donne environ un seul cas docs : un modèle qui répondrait feat à tout obtiendrait alors presque le même score qu’un modèle qui a réellement compris la différence entre les types. Un tirage naïf, lui, se retrouve dominé par les gros diffs, au point que 23 des 30 premiers cas dépassaient le budget de 3000 caractères. On aurait alors surtout mesuré la façon dont un modèle se dégrade quand on lui coupe son entrée, ce qui est une question parfaitement légitime mais pas celle que je pose. Le tirage final impose donc un plancher par type et une proportion choisie de gros diffs.
Mesure d’une baseline
Avant tout entraînement, il faut savoir d’où on part. Chaque modèle est donc évalué sur ces 30 commits, avec le prompt suivant :
"You write Conventional Commit subject lines. Given a git diff, reply with exactly one line in the form type(scope): summary, where type is one of feat, fix, docs, refactor, perf, test, chore. No code, no explanation, no markdown fences. One line only."
et avec les paramètres think: false, temperature: 0.0, num_predict: 64 et num_ctx: 8192, pour limiter la créativité du modèle et figer les conditions de mesure. Vous noterez que ce prompt réclame encore un scope alors que je viens de l’écarter : c’est le prompt tel qu’il a réellement servi, et le scope n’y est simplement pas noté, le harnais ne le compare que si on le lui demande explicitement.
Le premier de ces paramètres mérite une parenthèse. Laissé à sa valeur par défaut, Qwen 3.5 0.8B a passé 59,6 secondes et l’intégralité de son budget de tokens à rédiger 10 122 caractères de réflexion intérieure, pour finalement renvoyer une réponse vide. Le même modèle, le même diff, avec think: false : 0,6 seconde et 13 tokens. C’est le genre de détail qui sépare ce modèle est inutilisable de ce modèle marche très bien. Le dernier paramètre relève de la même famille : laissé au défaut du serveur, il tronque silencieusement le début des plus gros prompts, et le modèle est alors noté sur une entrée différente de celle qu’on croit lui avoir envoyée.
| Gemma 3 270M | SmolLM2 360M | Qwen 3.5 0.8B | toujours chore |
|
|---|---|---|---|---|
| Message valide | 0/30 | 0/30 | 23/30 | - |
| Type correct | 0/30 | 0/30 | 4/30 | 7/30 |
| Latence médiane | 0.6s | 0.5s | 0.6s | - |
C’est la douche froide pour les deux plus petits. Zéro message valide sur 30, et pas au sens de “presque bon” : SmolLM2 360M explique parfois ce qu’est la commande git diff, ou invente un bloc #[tokio::test] qui n’apparaît nulle part dans l’entrée. Gemma 3 270M, lui, part sur du Python ou du C++ dans des blocs de code, en brodant sur les identifiants qu’il a croisés. Ils ne font pas mal la tâche, ils ne font pas la tâche.
Qwen s’en sort nettement mieux sur la forme, avec 23 messages valides qui décrivent réellement le contenu du diff, même si le résumé reste souvent peu précis. La plupart de ses échecs de format ont d’ailleurs la même tête : feat (desktop template added), feat (fix), une espace avant la parenthèse et de la prose là où devrait aller le scope. Ce n’est pas de l’incompréhension, c’est de la ponctuation, et c’est typiquement ce qu’un fine-tuning doit effacer. Si ça ne part pas, ce sera le signe que le problème est dans mon setup d’entraînement et pas dans le modèle.
Et puis il y a la dernière colonne, qui est la vraie leçon de ce tableau. 4 bons types sur 30 pour Qwen, ça ressemble à une compétence partielle et un peu poussive. Sauf que répondre chore à absolument tout, sans lire quoi que ce soit, en donne 7. Notre meilleur modèle fait donc moins bien qu’une horloge cassée. L’explication est limpide : Qwen répond feat 21 fois sur 30, alors que le jeu de test ne contient que 6 vrais feat. Ce n’est pas un problème de lecture du diff, c’est un a priori effondré sur un seul token. C’est aussi, très probablement, la chose la plus facile à corriger de tout le projet. Morale à retenir pour toute évaluation : un score ne veut rien dire tant qu’on ne l’a pas comparé à ce que rapporte la stratégie la plus bête possible.
Vous noterez enfin que trois des concurrents annoncés dans le premier article manquent à l’appel. SmolLM2-135M, SmolLM2-1.7B et Qwen 3.5-4B ont rejoint le projet plus tard, et leur baseline a été mesurée avec un autre outillage. Sans surprise, le 135M ne relève pas le niveau : il ne produit lui non plus pas un seul message valide.
Et maintenant ?
Ce n’est donc pas franchement encourageant, et c’est pourtant exactement la situation où un fine-tuning a le plus à apporter : tout ce qui est à gagner ici relève du format et d’un a priori mal calibré, deux choses qui s’apprennent par l’exemple. Le prochain article attaque donc LoRA et le premier entraînement. La forme s’achète presque gratuitement, et le meilleur rapport qualité/taille du lot est tenu par un modèle que je n’attendais pas à cette place.
TL;DR
- 951 paires (diff, message) minées sur quatre de mes dépôts, réparties en 855 pour l’entraînement et 96 pour la validation, plus 30 commits mis de côté pour la notation.
- Le filtre le plus utile ne vérifie pas la taille du diff mais qu’il soutient réellement le message, sans quoi on apprend au modèle à halluciner un résumé plausible.
- Le scope est écarté sur mesure et non par confort : 137 scopes distincts dont 66 uniques, et un scope faux est bien pire qu’un scope absent.
- Avant tout entraînement, les modèles sous 0.5B ne produisent pas un seul message valide sur 30, là où Qwen 3.5 0.8B en produit 23.
- Mais Qwen ne trouve que 4 bons types sur 30, quand répondre
choreà tout en donne 7 : un score ne vaut rien tant qu’on ne l’a pas comparé à la stratégie la plus bête possible.
Photo originale par Djoubled, CC BY-SA 4.0.