Construire sa Vérité Terrain : Quel LLM Local pour Noter l’Actualité Boursière ?
Série Market-RSS-Sentiment — Épisode 2
Dans l’épisode 1, nous avons réglé le problème des coréférences journalistiques en générant un dictionnaire d’alias par LLM. Aujourd’hui, nous attaquons une étape charnière de tout projet NLP : la constitution d’une vérité terrain (ground truth). L’objectif : noter le sentiment boursier de chaque article, et trouver une alternative locale, gratuite, à une API propriétaire.
Si vous avez déjà monté un benchmark pour départager plusieurs LLM, vous connaissez la sensation : les chiffres tombent, ils sont plausibles, vous les mettez dans un tableau et vous concluez. Le problème, c’est que rien ne vous dit qu’ils mesurent quoi que ce soit.
Mon objectif de départ était simple. J’ai un pipeline qui collecte des articles RSS financiers et les rattache aux entreprises du CAC 40. Je voulais savoir si des modèles tournant sur ma machine — Llama 3.1 8B, Mistral Nemo 12B, Qwen 2.5 7B via Ollama — pouvaient remplacer des API payantes pour noter le sentiment de chaque article : négatif (0), neutre (1), positif (2). Corpus : 1 281 couples (article, entreprise).
Le premier tableau de résultats m’a donné un modèle gagnant et un classement net. Il était faux. Pas approximatif : faux, pour une raison qui n’avait rien à voir avec les modèles.
Cet article raconte ce qu’il a fallu corriger avant que le benchmark commence à mesurer quelque chose — et pourquoi la partie la plus déterminante du travail a fini par être 101 articles annotés à la main dans un tableur.
1. Le mur invisible
Premier run, premier tableau. Gemini attribuait la note 0 — NEGATIVE — à 70 % du corpus, quand les autres modèles se répartissaient normalement entre les trois classes.
Un modèle qui juge sept articles financiers sur dix comme négatifs, ce n’est pas absurde en soi. C’est même le genre de résultat qu’on peut raconter : « Gemini est plus sévère que les autres ». J’ai failli l’écrire.
Ce qui m’a retenu, c’est la proportion. Sept sur dix, sur un flux RSS où l’essentiel des articles sont des brèves de marché, ça ne collait pas avec ce que je voyais en lisant les articles moi-même. J’avais un soupçon, mais aucun moyen de le vérifier : mon pipeline ne stockait que la note. Une réponse réduite à un entier ne se conteste pas.
Demander au modèle de se justifier
J’ai donc modifié le format de réponse pour exiger un second champ :
{"note_llm": 0, "justification": "..."}PythonL’intention était modeste : pouvoir relire quelques cas négatifs et juger si le raisonnement tenait. Une colonne justification_gemini en base, un champ de plus dans le prompt.
Le résultat a été bien plus tranchant que prévu. Sur les 1 421 lignes du run, 994 avaient une note 0 et une justification vide. Les 427 autres avaient les deux.
Une note sans justification, c’est une réponse qui n’a jamais existé.
Le vrai coupable
note_gemini integer DEFAULT 0 NOT NULLPythonToute ligne créée naissait avec la note 0. C’est-à-dire NEGATIVE. Un article jamais évalué était rigoureusement indiscernable d’un article jugé négativement.
Le correctif tient en deux instructions :
ALTER TABLE article_companies ALTER COLUMN note_gemini DROP NOT NULL;
ALTER TABLE article_companies ALTER COLUMN note_gemini DROP DEFAULT;SQLMais retirer la contrainte ne suffisait pas : il fallait pouvoir dire pourquoi une note est absente. J’ai ajouté une colonne de statut par modèle, avec quatre valeurs :
| Statut | Signification |
|---|---|
not_evaluated | jamais soumis (filtré en amont) |
submitted | soumis en batch, résultat pas encore collecté |
ok | note fiable |
failed | appel tenté, jamais abouti après retries |
À partir de là, note = NULL + statut = 'failed' veut dire quelque chose de précis : on a essayé, ça n’a pas abouti, il n’y a pas de note. Et surtout, l’analyse en aval peut exclure ces lignes au lieu de les compter comme des jugements négatifs.
Deux enseignements
Le premier tient au schéma : une valeur par défaut sur une colonne de résultat transforme une absence en information. La base ne se contente pas de stocker vos mesures, elle en fabrique. Et comme rien ne plante, rien ne vous prévient.
Le second est celui qui a débloqué la situation. Ce n’est pas une métrique qui a révélé le bug, c’est un champ ajouté à la réponse du modèle. Une note seule est opaque : elle ne permet ni de repérer une anomalie, ni d’en identifier la cause. Dès qu’on demande au modèle d’expliciter ce sur quoi il s’appuie, la réponse devient auditable.
Ce principe — enrichir la réponse pour la rendre vérifiable — revient à trois reprises dans la suite de ce projet, et c’est probablement la décision de conception la plus rentable que j’aie prise.
2. Sept prompts, une courbe non monotone
Une fois la mesure fiabilisée, je pouvais m’attaquer à la vraie question : quel prompt donne les meilleurs résultats ?
Le prompt initial (v1) tenait en huit lignes : trois règles de notation, une consigne de format JSON. Les versions suivantes ont ajouté des précisions, chacune en réponse à un cas d’erreur réel :
| Version | Ajout | Taille |
|---|---|---|
| v1 | règles courtes | ~190 tokens |
| v2 | isolation des phrases pertinentes, few-shot, règle anti-repli-neutre | ~540 tokens |
| v3 | règles sur les objectifs de cours et recommandations de brokers | ~979 tokens |
| v4 | grille complète 3×4 des cas broker | ~1 589 tokens |
| v5 | règle sur les mouvements de cours intraday | ~1 022 tokens |
| v6 | extraction structurée : le LLM constate, le code décide | ~1 034 tokens |
| v7 | correction de la définition d’un champ d’extraction | ~1 233 tokens |
L’intuition naturelle, c’est que la courbe monte. Plus le prompt est précis, mieux le modèle devrait s’en sortir. Ce n’est pas ce qui s’est passé.
Le cas v5 : une règle qui fait exactement l’inverse
Le mode d’erreur dominant de Llama 3.1 en v2 était identifiable et concentré : sur ses 320 erreurs, 164 étaient du type NEU→POS — un article que la référence juge neutre, Llama le juge positif. Soit 51 % de ses erreurs, un seul motif : des mouvements de cours sans cause annoncée.
« La Bourse de Paris gagne 0,3 % ce matin, soutenue par Legrand (+1,9 %) et Safran (+1,5 %). »
Pour Llama, « +1,9 % » est un fait concret positif, donc note 2. Pour un analyste, c’est une variation de séance sans information : note 1.
J’ai écrit la règle correspondante en v5
RÈGLE DU MOUVEMENT DE COURS : la variation du cours de l'action n'est PAS en elle-même un fait concret. IGNORE cette variation et juge l'article sur les AUTRES faits présents. S'il n'y a aucun autre fait concret, réponds note_llm=1.
Résultat sur les 38 cas de mouvement intraday que j’avais annotés à la main :
| Modèle | v3 (sans la règle) | v5 (avec la règle) |
|---|---|---|
| Claude Haiku | 82 % | 84 % |
| Gemini 2.5 Flash | 16 % | 82 % |
| Llama 3.1 (8B) | 68 % | 39 % |
| Qwen 2.5 (7B) | 79 % | 58 % |
La règle a spectaculairement corrigé Gemini — de 16 % à 82 %, c’était son point faible majeur. Et elle a cassé Llama et Qwen.
En v3, Llama répondait 1 sur 28 des 38 cas. En v5, plus que sur 15, et il répondait 2 dans 21 cas. La règle censée le pousser vers le neutre l’a poussé vers le positif.
Mon hypothèse, en lisant ses justifications : la consigne demande deux opérations enchaînées — écarter une information, puis chercher ailleurs. Un modèle 8B semble n’en retenir que la première moitié. Il enregistre « la variation ne compte pas comme neutre » et bascule sur le positif par défaut.
Haiku et Gemini, eux, exécutent l’instruction telle qu’elle est écrite.
Enrichir la réponse plutôt que la consigne
Après cet échec, j’ai changé d’approche. Puisque ajouter des règles de décision dégradait les petits modèles, autant leur retirer la décision.
C’est le même geste qu’en section 1, poussé plus loin. Là, j’avais ajouté un champ justification pour rendre une note auditable. Cette fois, j’ai demandé quatre champs supplémentaires — des faits, pas des jugements :
{
"note_llm": 1,
"justification": "...",
"variation_cours_pct": -1.3,
"autre_fait_concret": false,
"reco_sens": "aucune",
"reco_niveau": null
}PythonLe raisonnement : « extraire le nombre -1,3 » est une tâche de lecture, à la portée d’un 7B. « Appliquer une règle en tenant compte du niveau et du sens de la recommandation » est une tâche de raisonnement, où ces mêmes modèles s’effondrent — je venais de le mesurer cinq fois. Autant confier la première au modèle et écrire la seconde en Python :
if not autre_fait_concret and reco_sens == "aucune":
if variation is None or abs(variation) < seuil:
return 1 # mouvement de séance seul
return 2 if variation > 0 else 0
return note_llm # sinon, on garde le jugement
PythonTrois bénéfices, dont deux que je n’avais pas anticipés.
Les règles deviennent exactes. Ce que le code applique, il l’applique toujours — contrairement à une consigne en langage naturel, dont on a vu qu’elle pouvait produire l’effet inverse.
Les seuils deviennent modifiables sans réévaluer. Passer de 5 % à 10 % est un UPDATE recalculé depuis les faits stockés, pas 1 281 appels API. J’ai pu balayer six seuils en quelques secondes.
L’audit devient trivial. Une justification en prose se lit et s’interprète ; variation = -1.3, autre_fait = false se vérifie.
C’est d’ailleurs comme ça que j’ai diagnostiqué l’échec suivant.
Car il y a eu un échec. Sur les cas de mouvement intraday, Llama remplissait autre_fait_concret = true dans 22 cas sur 38 — il comptait la variation de cours elle-même comme un « autre fait », alors que le champ excluait explicitement cette possibilité.
Ses justifications le disent sans ambiguïté : « Saint-Gobain a connu une hausse de 2,8 % », « Capgemini complète le podium du jour avec 4,2 % ».
La v7 a reformulé le champ avec un test mécanique — « supprime tous les pourcentages, reste-t-il une information ? » — et les quatre cas d’échec réels en contre-exemples.
Llama est passé de 34 % à 42 % de justesse sur ce champ. Mieux, mais loin du compte.
Chez les autres modèles, en revanche, ça fonctionne : Mistral atteint 100 % sur ce champ, Qwen 92 %, Haiku et Gemini 79 %.
La conclusion est donc nuancée. Externaliser la décision vers le code fonctionne à condition que ce qu’on demande au modèle soit réellement une extraction. variation_cours_pct en est une : le nombre est littéralement dans le texte, et tous les modèles l’extraient correctement. autre_fait_concret n’en est pas une : décider si « Safran (+2,3 %) soutient l’indice » constitue une information sur l’entreprise demande exactement le discernement qui fait défaut à Llama. J’avais déplacé la difficulté d’un champ à l’autre en croyant l’avoir supprimée.
La leçon
Le coût de la complexité d’une consigne n’est pas uniforme : il croît quand la capacité du modèle décroît. Une règle qui améliore un modèle API peut dégrader un modèle local — et pas d’un peu, en inversant son comportement.
Ça a une conséquence pratique directe : si vous servez plusieurs modèles derrière la même interface, rien ne justifie de leur imposer le même prompt. Dans mon pipeline, la version de prompt est déjà une colonne par modèle. Ce qui était une commodité de traçabilité s’est révélé être un levier.
3. L’indicateur d’alerte
De quoi il s’agit exactement ?
C’est le taux d’accord entre mes deux modèles de référence — Claude Haiku et Gemini 2.5 Flash — calculé sur l’ensemble du corpus, à chaque version de prompt. Une ligne de code :
accuracy_score(df['note_haiku'], df['note_gemini'])
cohen_kappa_score(df['note_haiku'], df['note_gemini'])PythonIl faut bien voir ce qu’elle ne mesure pas. Elle ne mesure aucun des modèles évalués : Llama, Mistral et Qwen n’entrent pas dans le calcul. Elle ne mesure pas non plus la justesse de Haiku ou de Gemini pris séparément — seulement leur degré d’accord mutuel.
Sa propriété intéressante est là : les modèles et le corpus restant identiques d’une version à l’autre, elle ne varie que si le prompt varie. C’est donc une mesure du prompt, pas des candidats.
Ce qu’il a montré :
Ce découpage sert d’abord à autre chose : il sépare le corpus en deux zones — la zone de consensus, où les deux références s’accordent et où les métriques sont fiables, et la zone grise, où elles divergent et où un « accord avec Haiku » ne veut plus rien dire.
Mais en traçant simplement le taux d’accord au fil des versions :
| Version | Accord Haiku ↔ Gemini | Kappa | Zone grise |
|---|---|---|---|
| v1 | 82,1 % | 0,711 | 217 cas |
| v2 | 76,8 % | 0,622 | 280 cas |
| v3 | 76,7 % | 0,638 | 283 cas |
| v4 | 72,7 % | 0,572 | 347 cas |
| v5 | 81,7 % | 0,705 | 234 cas |
| v7 | 83,6 % | 0,734 | 207 cas |
Pourquoi le kappa à côté de l'accuracy ?
Parce qu'une partie des accords tombe par hasard.
Deux annotateurs qui répondent souvent « neutre » coïncideront régulièrement sans que ça traduise la moindre compétence commune.
Le kappa de Cohen retire cette part fortuite :
il rapporte l'accord observé à la marge d'amélioration réellement disponible au-dessus du hasard. L'écart est parlant sur un cas de mon benchmark — Mistral Nemo affichait 64 % d'accuracy pour un kappa de 0,39, parce qu'il attribuait la note neutre à 74 % des articles et récoltait ainsi beaucoup d'accords gratuits.
L'accuracy récompense le biais de prudence, le kappa non.
Repère de lecture usuel : en dessous de 0,40 l'accord est faible, entre 0,40 et 0,60 modéré, au-delà de 0,60 substantiel.
La chute de v1 à v4 est nette et régulière. Chaque version censée lever de l’ambiguïté faisait diverger davantage les deux modèles les plus capables de mon panel. C’est le contraire de l’effet recherché : des règles plus détaillées devraient rapprocher deux juges compétents, pas les séparer.
Le mécanisme est visible dans les distributions. Entre v1 et v4, le taux de notes NEUTRE de Haiku passe de 30,7 % à 44,1 %, celui de Gemini de 19,9 % à 28,3 %. Les deux dérivent vers le neutre — mes règles ajoutaient surtout des chemins menant à la note 1 — mais à des vitesses différentes. D’où l’écart croissant.
Ma règle empirique, désormais : si l’accord entre références baisse après un changement de prompt, ne pas regarder plus loin. C’est le changement qui est en cause, quels que soient les scores obtenus par les modèles évalués.
Puis v5 et v7 remontent, et c’est cohérent : ce sont les versions où j’ai cessé d’empiler des règles de décision pour traiter un cas d’erreur précisément identifié.
4. 140 annotations, et le classement s’inverse deux fois
Jusqu’ici, tous mes classements reposaient sur une hypothèse jamais vérifiée : que Claude Haiku, ma référence principale, ait raison. « Qwen obtient 74,9 % d’accord avec Haiku » est une mesure d’accord, pas de justesse. Tant que la référence n’est pas validée, un modèle bien classé peut simplement partager les biais du juge.
Il n’y a pas de raccourci : il faut ouvrir les articles et trancher à la main.
Premier échantillon : la zone grise
J’ai exporté 101 cas où Haiku et Gemini se contredisaient — la zone la plus difficile par construction — avec leurs justifications respectives côte à côte, et une colonne à remplir. Une heure de travail.
Résultat : Haiku a raison dans 76 cas, Gemini dans 22.
Le rapport de 3 pour 1 valide le choix de Haiku comme référence principale. Mais 76 %, ce n’est pas 100 % : sur les cas ambigus, ma référence se trompe une fois sur quatre. Toute mesure « vs Haiku » porte donc cette marge d’erreur.
L’annotation a aussi révélé une famille d’erreurs que je n’avais pas isolée. Sur les 101 cas, 38 étaient de simples mouvements de cours de séance, et je les ai notés neutres dans 36 cas. Gemini n’en réussissait que 16 % — il lisait « -1,3 % » comme un fait concret négatif. C’est ce diagnostic qui a produit les versions v5 à v7.
Et un cas particulier m’a fait revenir sur une décision antérieure. Sur l’article Exosens :
« Deutsche Bank relève sa cible à 49 euros contre 45 euros et reste à "conserver".
Haiku a justifié sa note par : « Deutsche Bank abaisse son objectif de cours de 45 euros à 49 euros » — un contresens factuel — puis s’est contredit deux lignes plus loin (« Bien que l’objectif soit relevé ») avant de noter NEGATIVE. Il avait réécrit le fait pour le faire coller à une règle de mon prompt v3, dont il citait la formulation mot pour mot. Une consigne trop directive ne se contente pas d’orienter le jugement : elle peut faire réécrire la lecture des faits.
Le renversement
Avec cette vérité terrain, le classement change de visage. Voici les modèles locaux mesurés d’abord contre Haiku sur l’ensemble du corpus, puis contre mon annotation sur ces 101 cas :
| Modèle | Kappa vs Haiku | Justesse vs annotation |
|---|---|---|
| Mistral Nemo (12B) | 0,278 (dernier) | 79 % (premier) |
| Qwen 2.5 (7B) | 0,589 | 71 % |
| Llama 3.1 (8B) | 0,431 | 63 % |
Mistral, bon dernier sur la métrique statistique, arrive premier face à l’humain — devant Haiku (69 %) et Gemini (59 %). Un modèle 12B tournant sur une RTX 3060 battant deux API commerciales : le résultat était trop beau pour ne pas être vérifié.
Deuxième échantillon : le contrôle
Un chiffre me gênait. Mistral attribue la note NEUTRE à 82 % du corpus, contre 39,8 % pour Haiku. Or mes 101 cas venaient de la zone grise, où les mouvements intraday — donc les vrais neutres — représentaient 38 % des cas, très au-dessus de leur poids réel.
Autrement dit : la performance de Mistral pouvait n’être qu’une constante qui coïncidait avec la composition de l’échantillon.
J’ai donc tiré 40 nouveaux cas dans la zone de consensus — là où Haiku et Gemini s’accordent, soit 84 % du corpus. Échantillon stratifié, pas uniforme : 24 cas où Mistral contredit les deux références, 8 où il les confirme, 8 témoins. Un tirage au hasard aurait dilué le signal.
Verdict sur les 24 cas litigieux : l’humain donne raison aux références dans 19 cas, à Mistral dans 5.
Le tableau complet :
| Modèle | Zone grise (101 cas) | Zone de consensus (40 cas) |
|---|---|---|
| Mistral Nemo | 79 % | 32 % |
| Qwen 2.5 | 71 % | 72 % |
| Llama 3.1 | 63 % | 68 % |
| Accord Haiku–Gemini | — | 88 % |
Mistral passe de premier à dernier. Sur les 8 cas témoins où il diverge sans que la règle automatique intervienne, il se trompe 8 fois sur 8.
Ce n’était donc pas de la compétence. Sa note quasi systématique tombait juste dans un échantillon riche en articles neutres, et rate tout le reste. Son score parfait sur l’extraction disait la même chose autrement : il répond presque toujours « pas de fait concret », donc il ne se trompe jamais quand c’est effectivement le cas.
Ce qui reste debout
Trois résultats survivent à ces deux inversions.
L’accord de deux références vaut mieux que la meilleure des deux. Haiku seul est juste à 69 % sur les cas ambigus ; l’accord Haiku–Gemini est juste à 88 %. Utiliser deux juges ne sert pas seulement à repérer les cas litigieux : le sous-ensemble où ils convergent constitue une référence sensiblement plus fiable.
Qwen 2.5 (7B) est le modèle local le plus robuste : 71 % et 72 % sur deux échantillons de composition très différente. C’est le seul dont la performance ne dépend pas de l’endroit où on la mesure — et pour un usage en production, cette stabilité vaut mieux qu’un pic contextuel.
Un écart de performance qui bouge selon l’échantillon n’est pas une performance. Mistral et Qwen étaient séparés de 8 points dans un cas, de 40 dans l’autre. Sans le second échantillon, j’aurais publié le premier.
Ce que je retiens
Ce projet devait comparer cinq modèles. Il a surtout mesuré la fragilité de mes propres instruments.
Les bugs de la section 2 produisaient tous des chiffres présentables. Les sept versions de prompt ont donné une courbe non monotone, avec une règle qui a inversé le comportement qu’elle devait corriger. Et les deux classements successifs se sont inversés à chaque fois que j’ajoutais une vérification.
Si je devais n’en garder qu’une chose : avant de comparer des modèles, il faut savoir combien vaut la référence. Cette question ne se règle ni par une métrique, ni par un modèle plus gros, ni par un prompt plus long. Elle se règle en ouvrant les données et en tranchant à la main — 140 cas ont suffi ici, deux heures de travail, et ce sont ces deux heures qui ont déterminé toutes mes conclusions.
Le reste — l’infrastructure de reprise, les colonnes de statut, le versionnement des prompts — n’a pas produit un seul chiffre. Mais sans lui, je n’aurais pas pu savoir lesquels étaient faux.
La suite : étalonner un transformer
Ce benchmark n’était pas une fin en soi. Son objectif réel était de produire un jeu de données étiqueté pour entraîner un classifieur supervisé — un transformer français, CamemBERT — qui tournerait ensuite sans dépendre d’aucune API.
C’est là que tout ce qui précède prend son sens. Un classifieur supervisé n’est jamais meilleur que ses étiquettes. Si j’avais entraîné CamemBERT sur mes premières notes, je lui aurais appris que 70 % de l’actualité financière est négative. Il aurait parfaitement appris une erreur, et aucune métrique d’entraînement ne me l’aurait signalé : les courbes de perte auraient convergé normalement.
J’ai commencé à préparer ce jeu d’entraînement, et les mêmes questions que dans ce premier volet se reposent immédiatement — cette fois avec des réponses chiffrées.
Quelle étiquette pour chaque exemple ? J’ai trois candidats de qualité inégale : la note d’un modèle unique, l’accord Haiku–Gemini, et mes 140 annotations manuelles. Le sous-ensemble le plus fiable est aussi le plus petit — 140 exemples ne suffisent pas à entraîner un transformer sur trois classes. J’ai donc mesuré la fiabilité de l’accord Haiku–Gemini directement, en croisant les 94 cas annotés qui tombent dans cette zone : 82 % de justesse, et non 88 % comme le suggérait mon premier échantillon de contrôle, plus étroit. La ventilation par classe compte plus que la moyenne :
| Étiquette (accord Haiku–Gemini) | Justesse | n |
|---|---|---|
| NEUTRE | 96 % | 47 |
| NÉGATIF | 82 % | 11 |
| POSITIF | 64 % | 36 |
Un tiers des étiquettes POSITIVE sont fausses, et pas au hasard : dans 33 % des cas, la vérité humaine est NEUTRE. C’est le même biais qui traverse tout cet article — une hausse de cours lue comme une bonne nouvelle. Un jeu d’entraînement construit sur ces étiquettes apprendrait ce biais aussi fidèlement que le reste.
Comment mesurer sans se tromper une seconde fois ? En gardant deux jeux de test séparés plutôt qu’un seul, de composition volontairement différente : les 44 cas annotés dans la zone grise, et les 94 cas annotés dans la zone de consensus. La raison tient en une phrase, et c’est la leçon de la section 4 appliquée à l’étape suivante : un modèle bon sur le premier jeu et moins bon sur le second est exploitable — on route les cas difficiles vers une API — alors qu’un modèle bon sur le second et moins bon sur le premier ne fait probablement que reproduire la distribution de ses étiquettes, comme Mistral Nemo avant que je ne m’en aperçoive.
Plan d’attaque pour la suite
1. Construire la table d’entraînement
D’abord matérialiser « la zone de consensus, moins les cas annotés » :
import pandas as pd
import psycopg2
conn = psycopg2.connect(...) # tes identifiants habituels
df = pd.read_sql("""
SELECT ac.article_id, ac.company_id, a.contenu,
ac.note_haiku, ac.note_gemini,
ac.extraction_haiku
FROM article_companies ac
JOIN articles_rss a ON a.id = ac.article_id
WHERE ac.statut_haiku = 'ok' AND ac.statut_gemini = 'ok'
AND ac.prompt_version_haiku = 'v7'
AND ac.note_haiku = ac.note_gemini -- zone de consensus uniquement
""", conn)
# Écarter les 94 + 44 cas déjà annotés à la main : ils vont en TEST, jamais en TRAIN
annotes = pd.concat([
pd.read_excel("zone_grise_a_annoter.xlsx"),
pd.read_excel("controle_consensus_a_annoter.xlsx"),
])[["article_id", "company_id"]].drop_duplicates()
cle = ["article_id", "company_id"]
train_df = df.merge(annotes, on=cle, how="left", indicator=True)
train_df = train_df[train_df["_merge"] == "left_only"].drop(columns="_merge")
train_df["label"] = train_df["note_haiku"] # = note_gemini, par construction iciPythonC’est la ligne WHERE ac.note_haiku = ac.note_gemini qui traduit littéralement « la zone de consensus » — sans elle on rentraînerait sur des étiquettes moitié fiables.
2. Séparer les deux jeux de test dès le départ
test_consensus = pd.read_excel("controle_consensus_a_annoter.xlsx") # 40 -> effectivement 94 après jointure
test_grise = pd.read_excel("zone_grise_a_annoter.xlsx") # 101 -> 44 dans la zone grise stricte
test_consensus["label"] = test_consensus["note_humaine"]
test_grise["label"] = test_grise["note_humaine"]PythonFaut-il croire toutes les étiquettes à parts égales ? Non, et j’ai deux leviers pour ça, tous deux fondés sur des écarts mesurés plutôt que sur des choix arbitraires.
3. Le premier levier : pondérer par l’origine de la note
Le premier levier vient d’une observation faite en construisant l’extraction structurée : sur les cas où mes prompts imposent une note par une règle déterministe plutôt que par un jugement du modèle, l’étiquette est juste à 93 % — contre 71 % quand c’est le modèle qui tranche seul. Vingt-deux points d’écart, sur une information déjà disponible dans mes données puisque je trace l’origine de chaque note. Il suffit de surpondérer les exemples « règle » à l’entraînement.
=== Justesse par origine de la note === (cf calcul_fiabilite_regle_vs_modele.py)
Note imposée par la RÈGLE : 93% (n=45)
Note issue du JUGEMENT du modèle : 71% (n=49)
L’écart 93 %/71 % entre notes « imposées par une règle » et notes « jugées par le modèle » vient de la colonne extraction_haiku. On la relit pour construire un poids par exemple :
FIABILITE_REGLE = 0.93 # mesuré : justesse quand la note vient d'une règle
FIABILITE_MODELE = 0.71 # mesuré : justesse quand la note vient du jugement du modèle
def poids_origine(extraction_json):
if pd.isna(extraction_json):
return FIABILITE_MODELE # v1-v6 : pas d'extraction, traité comme "jugement"
f = json.loads(extraction_json)
regle = (f.get("autre_fait_concret") is False) and (f.get("reco_sens") in (None, "aucune"))
return FIABILITE_REGLE if regle else FIABILITE_MODELE
train_df["poids_origine"] = train_df["extraction_haiku"].apply(poids_origine) Python4. Le second levier : la cible souple par classe
Le second levier traite directement l’écart par classe. Une pondération de la perte par la fiabilité mesurée — 0,82 / 0,96 / 0,64 pour NEG / NEU / POS — réduit mécaniquement le poids des exemples POSITIVE dans le gradient, sans les exclure. Plus fin encore : puisque je sais que les POS fautifs pointent vers NEUTRE dans un tiers des cas, je peux remplacer l’étiquette dure [0, 0, 1] par une cible souple [0.03, 0.33, 0.64] directement tirée de la matrice de confusion. Le modèle apprend alors « c’est probablement positif, mais il arrive que ce soit neutre » plutôt qu’une certitude que je sais fausse un tiers du temps.
Matrice de confusion (étiquette ‘accord Haiku-Gemini’ -> vérité humaine), proportions par ligne (cf tableau_qualite_etiquette.py) :
| note_humaine | 0 | 1 | 2 |
| note_haiku | |||
| 0 | 0.82 | 0.09 | 0.09 |
| 1 | 0.00 | 0.96 | 0.04 |
| 2 | 0.03 | 0.33 | 0.64 |
import torch
import torch.nn.functional as F
from transformers import Trainer
# La matrice de confusion mesurée dans l'article, ligne = étiquette, colonnes = vérité
CIBLES_SOUPLES = {
0: torch.tensor([0.82, 0.09, 0.09]), # étiquette NEG
1: torch.tensor([0.00, 0.96, 0.04]), # étiquette NEU
2: torch.tensor([0.03, 0.33, 0.64]), # étiquette POS
}
class TrainerPondere(Trainer):
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
labels = inputs.pop("labels")
poids = inputs.pop("poids_origine")
outputs = model(**inputs)
logits = outputs.logits
cibles = torch.stack([CIBLES_SOUPLES[int(l)] for l in labels]).to(logits.device)
log_probs = F.log_softmax(logits, dim=-1)
perte_par_exemple = -(cibles * log_probs).sum(dim=-1) # cross-entropy douce
perte = (perte_par_exemple * poids).mean() # pondération par l'origine
return (perte, outputs) if return_outputs else pertePythonChaque ligne du dictionnaire CIBLES_SOUPLES est copiée directement du tableau de l’article. Un exemple étiqueté POSITIF ne dit plus au modèle « c’est la classe 2, point final » mais « 64 % de chances que ce soit 2, 33 % que ce soit 1 ».
En résumé
Ce deuxième épisode devait comparer cinq modèles de langage. Il a surtout révélé que la moitié du travail consistait à vérifier mes propres outils de mesure avant de faire confiance à leurs résultats — un DEFAULT 0 qui maquillait des échecs en jugements négatifs, une règle de prompt qui inversait le comportement qu’elle devait corriger, un classement qui s’est retourné deux fois à mesure que j’ajoutais des vérifications.
La table d’entraînement est prête, avec ses poids et ses cibles souples calibrés sur des écarts mesurés plutôt que sur des choix arbitraires. Reste à savoir si CamemBERT, entraîné dessus, tient la comparaison — et surtout si sa confiance en sortie est fiable, ce qui permettrait enfin l’escalade sélective évoquée plus haut : ne router vers une API que les cas que le modèle sait reconnaître comme difficiles.
Ce sera l’objet du prochain épisode.
Le code
L’intégralité du pipeline — collecte RSS, extraction d’alias, providers LLM (Ollama/Gemini/Haiku), gestion des runs batch, scripts d’analyse — est sur GitHub :