Mon modèle fine-tuné a fait 100%. Le benchmark mentait.
J'ai fine-tuné Mistral 7B avec LoRA sur un MacBook pour détecter des données personnelles. Sur mon propre jeu de test : 100%, et le prompting suivait de près. Sur des données réelles, le fine-tune gagne de 29 points. Le dataset choisissait ma conclusion.
J’ai fine-tuné Mistral 7B sur mon ordinateur portable pour détecter des données personnelles dans des lignes de logs et des messages de support. Sur mon premier jeu de test, il a fait 100%. Parfait. Chaque ligne classée correctement.
Je n’ai pas publié ce chiffre, parce que le même jeu de test donnait 94% au prompting few-shot. Six points d’écart face à un prompt qu’on écrit en cinq minutes, ce n’est pas une raison de fine-tuner quoi que ce soit. La conclusion honnête ressemblait à « j’ai perdu mon après-midi ».
Puis j’ai jeté mon jeu de test et je l’ai reconstruit à partir de données publiques réelles. Le fine-tune est tombé à 95%. Le prompting s’est effondré à 66%.
Même modèle, même code, même recette d’entraînement. Un écart de 6 points est devenu un écart de 29 points, et la conclusion s’est inversée. Mon benchmark choisissait la réponse à ma place, et il avait choisi la mauvaise.
Cet article, c’est le run complet : ce que fait réellement LoRA, comment construire un dataset qui ne vous ment pas, les commandes exactes et les résultats mesurés. Tout est reproductible depuis le dépôt sur n’importe quel Mac Apple Silicon.
LoRA, est-ce du vrai fine-tuning ?
La question mérite d’être réglée en premier, parce que « LoRA ce n’est pas vraiment du fine-tuning » revient sans arrêt.
Si, c’en est. LoRA entraîne le modèle par descente de gradient sur vos données, exactement comme un fine-tuning complet. La différence porte sur les poids qui bougent. Au lieu de mettre à jour les 7 milliards de paramètres, il les gèle et apprend deux petites matrices de rang faible par couche ciblée. Leur produit approxime la mise à jour qu’aurait faite un fine-tuning complet, et il peut être fusionné dans les poids de base ensuite, ce qui donne un modèle réellement différent.
Dans mon run, cela signifie que 0,145% des paramètres étaient entraînables : 10,5 millions sur 7,25 milliards. C’est toute la raison pour laquelle ça tient sur un portable, et pourquoi le résultat est un adaptateur de 42 Mo au lieu d’un nouveau modèle de 4 Go.
Ce que ce n’est pas, c’est du prompting ou de la recherche documentaire. Les poids changent vraiment. La nuance honnête : un fine-tuning complet peut aller un peu plus loin sur les tâches difficiles, pour dix à cent fois plus de mémoire. Pour apprendre un format, une taxonomie ou un comportement à un modèle, LoRA est ce que les praticiens livrent réellement, et c’est ce que font tourner les API de fine-tuning des grands fournisseurs sous le capot.
La tâche et le matériel
Le modèle reçoit une ligne de texte, en anglais ou en français, et doit répondre en JSON strict :
{"pii": true, "types": ["email", "name"]}
Six types : email, phone, name, iban, address, dob. Liste vide quand il n’y a rien.
C’est un vrai problème, pas un jouet. Les données personnelles fuient dans les logs, les dumps de staging et les exports, bien au-delà de votre table utilisateurs, comme je l’écrivais dans PII et masquage de données, et savoir où elles se trouvent est la condition préalable à une rétention et une suppression qui fonctionnent vraiment.
Matériel : un MacBook avec une puce Apple M5 et 16 Go de mémoire unifiée. Modèle de base mlx-community/Mistral-7B-Instruct-v0.3-4bit, exécuté avec MLX, le framework d’Apple. Pas de cloud, pas de clé d’API, coût total 0 EUR.
Ce dont personne ne parle : le dataset
Tous les tutoriels vous montrent mlx_lm.lora --train. Cette commande, c’est quatre minutes de travail. Le dataset, ce sont les trois heures restantes, et c’est là que le résultat se décide vraiment.
Première tentative, et pourquoi elle mentait
J’ai généré 800 exemples depuis mes propres templates : des lignes de logs avec de faux e-mails, des messages de support avec de faux IBAN, plus des négatifs difficiles remplis d’UUID et de numéros de facture pour que le modèle ne puisse pas simplement signaler tout ce qui ressemble à un identifiant.
Résultat : score parfait pour le fine-tune. La raison saute aux yeux après coup : le jeu de test venait des mêmes templates que le jeu d’entraînement. Le modèle avait déjà vu chaque forme de phrase. Je mesurais la mémorisation de ma propre imagination.
Si votre fine-tune fait 100%, votre jeu de test est trop facile. Ce n’est pas un bon problème à avoir, c’est une mesure cassée.
Deuxième tentative : données réelles, et un piège de licence
Je suis donc parti chercher des corpus publics, et je suis tombé immédiatement sur deux problèmes qui valent plus que le reste de cet article.
Le choix évident est ai4privacy/pii-masking-200k, que tout le monde cite. Sa licence est double : gratuite pour les particuliers, les associations et les entreprises de trois personnes ou moins, payante sinon. Un blog qui promeut une activité de conseil, c’est un usage commercial. Selon votre situation vous êtes peut-être couvert, mais « tout le monde l’utilise » n’est pas une licence.
Pour les négatifs, j’ai récupéré LogHub, 32 000 lignes de logs de production réels issus de 16 systèmes. Deux problèmes. Sa licence ne couvre que « la recherche ou le travail académique ». Et, bien plus grave pour un article sur la détection de données personnelles, plusieurs de ces systèmes contiennent de vraies données personnelles :
- BGL expose de vrais noms de comptes de chercheurs dans des chemins
/home/de laboratoires nationaux - Les logs Mac contiennent une vraie adresse e-mail et un vrai répertoire personnel
- Linux et OpenSSH transportent de vrais noms d’utilisateurs issus de trafic d’attaque réel
Ma regex de filtrage a attrapé quatre adresses e-mail et est passée à côté de tout le reste, parce qu’un nom d’utilisateur dans un chemin de fichier ne ressemble pas à une donnée de contact. J’avais construit un jeu d’entraînement qui apprend à un modèle que les noms de vraies personnes ne sont pas des données personnelles. C’est pire que pas de modèle du tout.
Les deux sources ont été écartées. Le pipeline final utilise deux corpus Apache-2.0 : kiji pour les positifs, qui couvre les six types dont l’IBAN et la date de naissance en anglais et en français et fournit son propre découpage train/test, et witfoo syslog pour les négatifs, 155 000 lignes de logs pare-feu et système réels.
Le raccourci qui aurait faussé le score une deuxième fois
Voici le piège qui aurait discrètement tout ruiné. Les positifs sont de la prose professionnelle. Les négatifs sont du syslog brut. Un modèle peut séparer ces deux catégories par le style d’écriture et obtenir un excellent score sans jamais apprendre ce qu’est une donnée personnelle.
Il faut donc que les quatre quadrants existent :
| Positif (contient des PII) | Négatif (aucune PII) | |
|---|---|---|
| Prose | phrases kiji | mêmes phrases, PII remplacées par des mots génériques |
| Log | syslog réel avec de vraies PII injectées | syslog réel, intact |
Les négatifs en prose sont construits en réécrivant chaque span annoté en un mot générique : « contactez Alice Dupont à alice@example.fr » devient « contactez le client à l’adresse du support ». Même structure de phrase, même vocabulaire, même langue, aucune donnée personnelle.
Puis j’ai trouvé la fuite à l’intérieur de mon propre correctif. Les expressions de remplissage comme « le client » n’apparaissaient que dans les négatifs, elles devenaient donc un indice parfait. Le modèle pouvait apprendre mon vocabulaire de remplissage au lieu de la tâche. 10% des lignes sont donc hybrides : mots génériques partout, sauf une vraie valeur laissée en place. Le remplissage porte désormais les deux étiquettes et ne signale plus rien.
Quatre défauts dans les données publiques
Les datasets publics ne sont pas propres. Le filtrage du mien a fait remonter :
Le filtrage aveugle coûte les trois quarts du corpus. La plupart des lignes kiji mentionnent quelque part un passeport ou un numéro de sécurité sociale, des types que je ne modélise pas. Supprimer ces lignes laissait 4 180 lignes utilisables. Remplacer uniquement ces spans par des mots génériques en laissait 15 810.
Les annotations sont incomplètes. Kiji a un champ coreferences, et il est vide sur absolument toutes les lignes. Une mention ultérieure survit donc : « Alice Dubois » est annotée, mais « Dubois a signé » trois phrases plus loin ne l’est pas, et ce vrai nom de famille atterrit dans un exemple « aucune donnée personnelle ». Correctif : filtrer chaque négatif contre un vocabulaire de 1 519 tokens de noms, extrait du corpus lui-même.
4,7% des lignes françaises sont corrompues à l’encodage. Les caractères accentués arrivent sous forme d’octets NUL, étude est donc stocké \x00tude. Entraînez là-dessus et vous apprenez du français en mojibake à votre modèle. 406 lignes supprimées.
4% du syslog transporte des noms de comptes en prose, du type Accepted password for johndoe. Trop peu pour valoir un parsing, donc supprimées en bloc plutôt que mal étiquetées comme propres.
Dataset final : 8 000 lignes d’entraînement, 448 de validation, 800 de test. 4 572 positifs contre 4 676 négatifs. 2 544 lignes en français. Zéro recouvrement entre les splits, vérifié.
Le piège Mistral qui coûte un après-midi
Mon premier test a planté :
jinja2.exceptions.TemplateError: Conversation roles must alternate user/assistant/user/assistant/...
Le template de chat de Mistral refuse un rôle system isolé. Contrairement à Llama ou Qwen, il veut des tours user et assistant strictement alternés. Les instructions doivent donc voyager dans le premier tour user :
{
"messages": [
{"role": "user", "content": f"{SYSTEM_PROMPT}\n\nLine: {text}"},
{"role": "assistant", "content": '{"pii":true,"types":["email"]}'}
]
}
Le danger n’est pas le plantage. C’est que si vous corrigez cela dans votre script d’évaluation en oubliant votre générateur de dataset, l’entraînement et l’inférence utilisent des formats de prompt différents, et votre adaptateur semble cassé sans raison visible. Construisez les deux depuis la même fonction.
L’entraînement
python -m mlx_lm lora \
--model mlx-community/Mistral-7B-Instruct-v0.3-4bit \
--train --data ./data \
--fine-tune-type lora \
--batch-size 4 --num-layers 16 --iters 500 \
--learning-rate 1e-5 --max-seq-length 512 \
--mask-prompt --grad-checkpoint \
--steps-per-report 50 --steps-per-eval 250 --save-every 500 \
--val-batches 25 --seed 42 \
--adapter-path ./adapters 2>&1 | tee training.log
Pourquoi ces valeurs :
--mask-prompt est celle qu’il ne faut pas sauter. Elle calcule la perte uniquement sur la réponse, pas sur la ligne d’entrée. Sans elle, la plupart des tokens que le modèle apprend à prédire sont la ligne de log elle-même, ce qui n’est pas la tâche.
--batch-size 4 --num-layers 16 viennent d’une mesure, pas d’une intuition. Un premier run en batch 1 avec 8 couches a culminé à 4,8 Go sur une machine de 16 Go, il restait donc de la place pour doubler les deux.
--learning-rate 1e-5 fait consensus pour les petits datasets. 1e-4 oscille, 1e-6 ne bouge presque pas.
--grad-checkpoint échange du calcul contre de la mémoire, et --save-every compte parce que vous voulez le meilleur checkpoint, pas le dernier.
La courbe de perte est la partie intéressante :
| Itération | Perte de validation |
|---|---|
| 1 | 3,537 |
| 250 | 0,508 |
| 500 | 0,491 |
Presque tout se joue dans les 250 premières itérations. Les 250 suivantes ont apporté 3% d’amélioration, je me suis donc arrêté là au lieu de faire les 2 000 prévues. S’il faut retenir une leçon opérationnelle : surveillez la perte de validation et arrêtez quand elle s’aplatit, parce que « entraîner plus longtemps » est surtout une façon de dépenser de l’électricité.
Coût final : 45 minutes, 6,0 Go de mémoire au pic, un adaptateur de 42 Mo, 0 EUR.
Résultats
Chaque mode utilise les mêmes prompts, le même jeu de test et une température de 0. « Few-shot » signifie six exemples résolus dans le prompt, ce qu’un ingénieur sensé essaie avant de sortir l’entraînement.
400 lignes de test tenues à l’écart
| Métrique | Zero-shot | Few-shot (6) | LoRA |
|---|---|---|---|
| Exactitude | 66% | 66% | 95% |
| Précision | 0,639 | 0,610 | 0,926 |
| Rappel | 0,686 | 0,840 | 0,974 |
| F1 | 0,662 | 0,707 | 0,950 |
| Faux positifs | 75 | 104 | 15 |
| PII manquées | 61 | 31 | 5 |
| JSON valide | 100% | 100% | 100% |
| Secondes par ligne | 0,83 | 1,67 | 0,91 |
Regardez la précision du few-shot : ajouter six exemples l’a rendu pire que le zero-shot, 104 faux positifs contre 75. Il trouve plus de données personnelles et crie beaucoup plus souvent au loup.
Le détail par type explique pourquoi :
| Type | Zero-shot | Few-shot | LoRA |
|---|---|---|---|
| 0,745 | 0,782 | 1,000 | |
| phone | 0,628 | 0,575 | 0,983 |
| name | 0,531 | 0,663 | 0,855 |
| iban | 0,358 | 0,194 | 0,950 |
| address | 0,500 | 0,597 | 0,914 |
| dob | 0,383 | 0,366 | 0,875 |
L’IBAN est révélateur. Six exemples ne peuvent pas apprendre à un modèle la frontière entre un IBAN, une référence de facture et un secret de webhook whsec_ dans deux langues. Le few-shot fait 0,194, pire que de ne rien dire. Le fine-tune atteint 0,950, parce qu’il faut 4 000 exemples de cette frontière.
30 lignes écrites à la main que le modèle n’a jamais vues
J’ai aussi écrit 30 lignes à la main, dans des formulations que les corpus n’ont jamais produites, comme contrôle d’honnêteté final :
| Métrique | Zero-shot | Few-shot | LoRA |
|---|---|---|---|
| Exactitude | 80% | 90% | 100% |
| Faux positifs | 2 | 1 | 0 |
| PII manquées | 4 | 2 | 0 |
Le fine-tune a eu les 30. Échantillon réduit, je ne mettrais donc pas « 100% » sur une slide, mais il ne s’est pas effondré hors distribution, et c’était la vraie question.
Et il coûte moins cher à faire tourner
Le fine-tune est 1,8 fois plus rapide par ligne que le few-shot, 0,91 seconde contre 1,67. Les six exemples ont disparu du prompt, chaque inférence est donc plus courte, pour toujours. Meilleur et moins cher, c’est une combinaison rare.
Quand il ne faut pas le faire
Le fine-tuning apprend un comportement, un format, une taxonomie. Il n’apprend pas des faits.
Si votre problème est « le modèle ne connaît pas notre documentation interne », le fine-tuning est le mauvais outil et la recherche documentaire est le bon. Les faits changent, les poids non. Vous ré-entraînerez indéfiniment et vous obtiendrez quand même des réponses fausses assénées avec assurance.
Et le prompting mérite un essai loyal d’abord. Sur mon dataset synthétique, il faisait réellement jeu égal avec le fine-tune. Il ne s’est effondré que lorsque la tâche est devenue assez difficile pour que six exemples ne suffisent plus à exprimer les règles. Ce seuil est le vrai point de décision, et vous ne le trouverez pas en lisant des articles de blog, y compris celui-ci. Vous le trouvez en mesurant les deux, ce qui coûte un après-midi.
La leçon qui se généralise
Le travail technique était facile. MLX est excellent, la commande tient sur une ligne, et ça a tourné sur un portable pendant que je faisais autre chose.
La partie difficile, celle qui a décidé du résultat, c’était les données : la licence qui exclut discrètement l’usage commercial, le corpus de logs « propre » rempli de vrais noms d’utilisateurs, le champ de coréférences vide, le français corrompu par des octets NUL, et le raccourci de style qui m’aurait offert un joli chiffre dénué de sens.
J’ai obtenu deux réponses complètement différentes à la même question, sur le même modèle, le même jour. La seule chose qui a changé, c’est la qualité de ce contre quoi je mesurais. Avant de faire confiance à un résultat de fine-tuning, y compris ceux de cet article, demandez de quoi le jeu de test est fait.
Reproduire l’expérience
Tout est public, y compris le journal d’entraînement et les prédictions brutes :
git clone https://github.com/jguillaumesio/lora-pii-detection-mlx
cd lora-pii-detection-mlx
python3 -m venv .venv && .venv/bin/pip install mlx-lm datasets
.venv/bin/python build_dataset.py
Le dataset n’est pas versionné. Il se reconstruit depuis les deux corpus Apache-2.0 avec cette seule commande, ainsi rien n’est redistribué qui ne devrait pas l’être. Les 30 lignes de test écrites à la main, les résultats et le journal d’entraînement sont tous dans le dépôt.