NLP boursier, utiliser l’embedding pour nettoyer le périmètre avant d’entraîner un transformer
Série Market-RSS-Sentiment — Épisode 3
Dans l’épisode 2, j’avais noté 1 281 couples (article, entreprise) à l’aide de LLM et annoté 101 articles à la main. Il me restait à entraîner un modèle transformer local — gratuit, instantané, qui n’aurait plus besoin d’appeler la moindre API pour noter un article.
Ce billet raconte la suite de cette aventure.
Chapitre 1 : Le périmètre change de nature : faire de l’embedding plutôt que compter
Avant de parler de transformer, un chantier de fond me restait ouvert depuis l’épisode 2 : décider quels couples (article, entreprise) méritent d’être notés.
Jusque-là, ma décision reposait sur un compteur : le nombre de fois où le nom de l’entreprise apparaît dans le texte (nbocc). Un compteur répond à la question « de quoi parle-t-on beaucoup ? ». Or la vraie question est ailleurs : de quel type d’article s’agit-il ?
Un exemple suffit à le voir. « Le CAC 40 finit en hausse, porté par Schneider Electric et Legrand » cite deux entreprises, les commente, leur donne même un pourcentage. Noter le sentiment de Schneider dans cet article n’a pourtant aucun sens : l’article ne dit rien de Schneider, il raconte la séance boursière. À l’inverse, un portrait de quinze paragraphes sur la stratégie d’Airbus peut ne nommer « Airbus » que trois fois, en s’appuyant ensuite sur « l’avionneur » ou « le groupe ». Ce n’est pas une question de fréquence, c’est une question de genre journalistique : chronique de marché, palmarès de séance, note d’analyste, article dédié à une entreprise. Ces genres ont une signature de surface importante — vocabulaire, structure, listes de pourcentages — que les embeddings captent très bien, et qu’un compteur d’occurrences, par construction, ignore.
Fabriquer les genres : embeddings, puis clustering
Ma première étape a consisté à vectoriser le corpus. Le modèle que j’ai retenu est intfloat/multilingual-e5-base (768 dimensions) : multilingue, donc à l’aise en français, et assez léger pour tourner sur ma RTX 3060.
Chaque article devient "passage: " + titre + contenu[:1500], tronqué à 512 tokens, vecteur normalisé L2. Le préfixe passage: est fixé : la famille e5 est entraînée de façon asymétrique. Elle distingue les recherches (qui commencent par query:) des documents à stocker (qui commencent par passage:). Comme je cherche ici à constituer ma base de documents et non à poser une question, j’applique passage: à tous mes articles. L’omettre décalerait les vecteurs par rapport à ce que le modèle a appris.
Résultat : 5 980 articles vectorisés en 108 secondes, pour une table de 25 Mo dans PostgreSQL via pgvector.
J’ai conçu le calcul de façon incrémentale : une seconde exécution du script (un second passage) ne recalcule pas tout, elle ne traite que ce qui a changé depuis la précédente. Concrètement, chaque vecteur est stocké avec une empreinte (un hash) du texte qui l’a produit. Au lancement suivant, mon script recalcule l’empreinte de chaque article et la compare à celle en base : identiques, il passe son chemin ; différentes ou absentes, il recalcule. Sur un corpus qui grossit de quelques dizaines d’articles par jour, la différence est brutale — quelques secondes au lieu de deux minutes, et surtout la certitude qu’un article corrigé en base (un corps re-scrapé, par exemple) verra bien son vecteur refait.
Un signe encourageant est apparu avant même le clustering, en regardant simplement les plus proches voisins d’un article : les voisins d’une analyse technique sur Michelin sont également d’autres analyses techniques (sur ArcelorMittal, Veolia, Schneider). Des secteurs différents, mais le même genre ! C’est exactement ce que j’espérais, et ce que je redoutais de ne pas obtenir : ma crainte était que l’espace se structure par thématique sectorielle (le luxe d’un côté, l’énergie de l’autre), auquel cas les clusters n’auraient rien dit du type d’article.
Pour la classification, j’ai utilisé l’algorithme HDBSCAN : je le laisse découvrir les paquets réellement présents, sans rien lui imposer.
HDBSCAN tourne sur une réduction PCA à 50 dimensions des embeddings — 768 dimensions, c’est trop pour un algorithme de densité, qui se dilue quand l’espace est trop vaste :
reduits = PCA(n_components=50, random_state=0).fit_transform(vecteurs)
clusters = HDBSCAN(min_cluster_size=30, min_samples=5).fit_predict(reduits)PythonLe paramètre min_samples a été décisif, et il est souvent laissé à son défaut (où il vaut min_cluster_size). Avec cette valeur par défaut, HDBSCAN classait 61 à 82 % de mon corpus en bruit. Il exigeait une densité si forte pour qu’un point soit considéré comme « au cœur » d’un cluster que presque rien ne passait. Découplé à 5, le bruit retombe à 49 %. Résultat : 15 clusters parfaitement exploitables.
Le clustering regroupe, l’humain qualifie
L’algorithme produit cluster_0, cluster_1, cluster_13 et du bruit. Aucun paquet ne porte de nom. J’ai donc dû lire quelques dizaines de titres par cluster et écrire les étiquettes à la main. Ce travail ne s’automatise pas, et c’est là que les surprises sont apparues :
- Les clusters 11 et 12 regroupaient par courtier (Oddo BHF d’un côté, Jefferies de l’autre) — deux paquets pour un seul genre : la note d’analyste. Je les ai fusionnés au nommage ;
- Le cluster 10 regroupait LVMH, Hermès et Kering : un secteur, pas un genre. Le risque redouté existait donc bien, mais il ne touchait que 32 articles. Pas de genre à part : ses articles rejoignent les « articles dédiés » et passent par les mêmes règles que les autres ;
- Les clusters 1, 3 et 4 formaient le même genre — l’indice étranger quotidien — découpé par pays ;
- Le cluster 0, étiqueté « actualité industrielle » à la première lecture des titres, s’est révélé plus tard être tout autre chose : ses 135 articles avaient un contenu strictement égal à leur titre. Ce sont des articles sans corps, un défaut de scraping, pas un genre. D’où une règle dédiée dans mon filtre final.
Voici les étiquettes obtenues et leur poids dans le corpus : article dédié (49,2 %), chronique de marché (30,9 %), résultats d’entreprise (4,5 %), palmarès (4,0 %), indice étranger (3,4 %), actualité industrielle (2,3 %), note d’analyste (1,5 %), liste de recommandations (1,4 %), crypto (1,1 %), analyse technique (1,0 %), franchissement de seuil (0,8 %).
Comme les numéros de clusters changent à chaque exécution de HDBSCAN, mon script bloque l’écriture en base de données dès que la taille des groupes varie :
TAILLES_ATTENDUES = {13: 1850, 9: 269, 2: 185, 0: 135, 7: 81, ...}
def verifier_correspondance(etiquettes) -> None:
"""Refuse d'écrire si les clusters ne sont plus ceux qui ont été nommés."""PythonPuisque le corpus d’articles grandit chaque jour, ces effectifs vont naturellement évoluer. C’est tout à fait normal : ce script n’a pas vocation à tourner en continu. Il me sert uniquement à figer un instantané de référence, sur lequel je suis venu lire les titres et attribuer 15 étiquettes métiers. Les nouveaux articles quotidiens ne sont pas re-clusterisés, mais simplement rattachés au centre de genre le plus proche, sans modifier la structure existante.
Ce garde-fou m’évite une erreur très piège : relancer un clustering à l’aveugle (après l’arrivée de nouveaux articles ou un changement de paramètre) et réécraser la base de données. HDBSCAN attribuant des numéros de manière aléatoire d’un run à l’autre, le « cluster 13 » d’aujourd’hui ne correspondrait pas forcément aux « chroniques de marché » de demain. Sans cette sécurité, le script permuterait toutes mes étiquettes en arrière-plan sans lever la moindre erreur. Je ne m’en rendrais compte que des semaines plus tard en constatant des incohérences majeures.
En stoppant le traitement dès qu’une différence de taille est détectée, le script lance une erreur explicite. La seule action correcte est de relire un échantillon de titres et valider le nommage à la main.
Je ne relance donc jamais le clustering pour classer les nouveaux articles : ils sont rattachés chaque jour au centre de genre le plus proche (seuil de similarité à 0,92), sans toucher au run de référence.
Auditer les genres avant de s’en servir
Une étiquette produite par un algorithme non supervisé ne se croit pas sur parole. Pour mon contrôle, j’ai utilisé quelques expressions régulières, par exemple REGEX_CHRONIQUE sur les titres (^(March|CAC ?40|Bourse|Wall Street|Palmar|Indices)), qui identifie 761 chroniques avec certitude mais en rate beaucoup.
Cet audit m’a apporté deux enseignements :
- Le cluster « chronique » attrape 655 articles que ma regex ratait. C’est précisément ce que j’attendais du clustering. Nuance honnête : 414 d’entre eux n’ont aucun couple en base — ce sont des dépêches sur des valeurs américaines non suivies (Amazon, Apple, Nvidia), que le vocabulaire « Wall Street / Nasdaq / records » rapproche des chroniques. Gain net réellement utile : 420 couples ;
- 75 chroniques s’échappent dans le bruit et se retrouvent étiquetées « article dédié » (« CAC40 : Chute vers 7840Pts »), soit 113 couples sur 3 092 (3,7 %). Plutôt que de relancer un clustering pour ces cas, je les rattrape avec la regex, via un opérateur OU.
C’est ce qui explique la forme finale de mon filtre, perimetre.py. Il ne compte plus rien ; il applique des règles ordonnées, dont la première interroge le genre :
def raison_exclusion(genre: str, titre: str, contenu: str, motif_entreprise) -> str:
"""Pourquoi ce couple sort du périmètre ; chaîne vide s'il est admis."""
titre, contenu = titre or "", contenu or ""
if genre in GENRES_EXCLUS:
return "genre exclu"
if REGEX_CHRONIQUE.match(titre):
return "titre de chronique"
if REGEX_TITRE_LISTE.match(titre):
return "titre de liste"
if contenu.strip() == titre.strip():
return "sans corps"
if not citee_dans(motif_entreprise, titre):
return "nom absent du titre"
return ""PythonGENRES_EXCLUS couvre la chronique de marché, le palmarès, l’indice étranger, la crypto, les listes de recommandations et l’actualité industrielle. Les deux règles suivantes rattrapent les chroniques et les listes que le clustering a laissées dans le bruit. La dernière est la plus radicale : le nom de l’entreprise doit figurer dans le titre, sous l’une de ses graphies. Sans elle, il me restait trop de mentions incidentes — une banque citée parce qu’elle conseille une OPA, une société rattachée à tort à deux cents articles qui ne la citent jamais vraiment.
Relue à la main sur deux échantillons — 30 couples qui entrent dans le périmètre, 30 qui en sortent —, cette règle prend la bonne décision dans environ 93 % des cas. Le périmètre qui en résulte compte 663 couples — loin des 1 281 de l’épisode précédent, mais chacun mérite vraiment d’être noté. nbocc reste calculé et stocké comme information de diagnostic ; il ne décide plus rien.
Chapitre 2 : Test de la référence : 191 annotations, et une repondération qui change tout
Le socle de mon consensus reste l’accord entre Gemini et Claude Haiku sur le prompt v7 : 79,9 % d’accord, kappa de Cohen à 0,64. J’ai annoté à la main 131 des 133 cas de désaccord, via un petit outil HTML fait maison (annotation au clavier, avancement conservé dans le navigateur), plus un échantillon de contrôle de 60 cas où les deux modèles s’accordaient. Sans cet échantillon de contrôle, je ne saurais rien de la fiabilité des ~530 couples où ils sont d’accord, et les désaccords seuls me donneraient une image faussement sombre du système. Au total : 191 notes humaines, la seule vérité du projet.
Verdict sur ce contrôle : quand Gemini et Haiku s’accordent, ils ont raison 86,7 % du temps (52 cas sur 60).
Le point de méthode crucial ici, c’est que le taux brut sur les 191 annotées (56 à 63 % selon le modèle) est trompeur, parce que les désaccords y sont surreprésentés par construction. Repondérer par la vraie composition du périmètre (530 accords, 133 désaccords) donne une image très différente :
| Modèle | Exactitude estimée sur les 663 couples du périmètre |
|---|---|
| Claude Haiku | 78,8 % |
| Gemini 2.5 Flash | 77,9 % |
| Llama 3.1 (8B) | 71,6 % |
| Qwen 2.5 (7B) | 71,0 % |
| Mistral Nemo (12B) | 50,9 % |
Ces taux portent sur les 663 couples du périmètre, alors que seuls 191 sont annotés. Ils ne sont donc pas comptés, ils sont estimés — et la façon de les estimer mérite un détour, parce que c’est elle qui change le classement.
Le périmètre se découpe en deux populations bien distinctes, selon que Gemini et Haiku sont tombés d’accord ou non :
| Strate | Couples dans le périmètre | Couples annotés |
|---|---|---|
| Les deux modèles sont d’accord | 530 (80 %) | 60, tirés au sort |
| Les deux modèles divergent | 133 (20 %) | 131, presque tous |
J’ai volontairement fait porter l’annotation sur presque tous les désaccords — ce sont les cas instructifs — et sur un simple échantillon des accords. Mes 191 annotations ne sont donc pas un miroir du corpus : les cas difficiles y pèsent 69 %, contre 20 % dans la réalité. Calculer un taux directement sur ces 191 reviendrait à noter un élève en ne comptant que les questions les plus dures de l’examen.
La correction consiste à mesurer la justesse séparément dans chaque strate, puis à recomposer la moyenne avec les poids réels. Pour Haiku :
- Sur les 131 désaccords annotés, il tombe juste 47,3 % du temps ;
- Sur les 60 accords annotés, 86,7 %.
Ces deux taux sont des mesures directes. Je les recombine ensuite au prorata du périmètre :
Une conséquence à garder en tête : ce mode de calcul pardonne peu les erreurs sur les cas faciles, puisqu’ils pèsent quatre fois plus lourd. C’est voulu. Un modèle brillant sur les cas ambigus mais négligent sur la routine serait, en production, un mauvais modèle — la routine est ce qu’il rencontrera huit fois sur dix.
J’ai noté trois familles d’erreurs récurrentes, indépendantes du prompt :
- Le ton confondu avec l’effet sur l’entreprise visée — « négatif pour Airbus car l’article parle de bonnes nouvelles pour Boeing » : un fait favorable à un concurrent est défavorable ici, mais le modèle note l’humeur générale du texte ;
- L’entreprise source prise pour le sujet — « ce n’est pas un article SUR BNP Paribas mais DE BNP Paribas » : la banque signe l’analyse, elle n’en est pas le sujet ;
- La matérialité ignorée — un fait concret mais de pure routine (livraisons mensuelles conformes à la tendance, succès d’un plan d’actionnariat salarié) reçoit une note positive ou négative, alors qu’il ne change rien à ce qu’un investisseur peut attendre. C’est le biais dominant : sur les 8 cas où les deux modèles cloud se trompent ensemble, 4 sont des faits anodins notés positifs ou négatifs.
Ces trois types d’erreurs réapparaîtront au chapitre suivant sous la forme de règles de prompt censées les corriger.
Chapitre 3 : Amélioration par les prompts
La première tentation est de corriger les erreurs en améliorant les prompts.
Un mot d’abord sur la structure du prompt v7 (llm_common.py), qui sert de socle. Il impose trois étapes : isoler les phrases qui concernent l’entreprise, évaluer le sentiment de ces phrases-là selon des règles strictes (un ton positif sans fait concret ne suffit pas), puis relever quatre éléments factuels — variation du cours, présence d’un fait concret autre que le cours, sens et niveau d’une éventuelle recommandation d’analyste. La sortie est un JSON à six champs, validé par Pydantic.
En v8, j’ai glissé une étape 2 bis entre l’évaluation et le relevé factuel, avec trois règles qui l’emportent sur tout le reste :
RÈGLE DE L’ENTREPRISE VISÉE : la note mesure l’effet sur {entreprise}, PAS le ton général de l’article. Un fait favorable à un concurrent, à un client ou à un fournisseur se note selon ce qu’il change POUR {entreprise} — et s’inverse donc souvent.
RÈGLE DE L’ENTREPRISE SOURCE : si {entreprise} n’est que l’AUTEUR de l’information (une banque qui publie une analyse, un cabinet qui signe une étude), l’article ne dit rien de son activité : note NEUTRAL (1).
RÈGLE DE MATÉRIALITÉ : un fait concret ne suffit pas s’il est sans effet attendu sur le cours. Sont NEUTRES : les chiffres d’activité mensuels conformes à la tendance, un plan d’actionnariat salarié, une recommandation confirmée avec un objectif de cours inchangé, une nomination sans changement de cap. Restent POSITIFS ou NÉGATIFS : les résultats, les avertissements, les contrats significatifs, les changements de recommandation.
Chaque règle était accompagnée d’un exemple tiré du corpus réel avec sa note humaine :
- un article sur un record de commandes chez Boeing, entreprise évaluée Airbus, noté 0 ;
- « BNP Paribas Exane abaisse son objectif de cours sur Kering », entreprise évaluée BNP Paribas, noté 1 ;
- « Airbus a livré 57 appareils en août et reçu 67 commandes brutes », noté 1.
Le résultat pour la v8 est décevant : Haiku gagne un peu (79,6 % contre 78,8), Gemini en perd (75,5 % contre 77,9). Les modèles locaux, eux, divergent dans des sens opposés : Mistral gagne 5,8 points, Qwen 2,9, Llama en perd 5,0. Pire, Gemini part carrément dans le sens inverse de l’objectif : ses notes neutres tombent de 61 à 41, ses notes négatives montent de 35 à 49 — il devient plus sévère, pas plus juste !
L’explication la plus probable de cette dérive tient à une interaction entre les règles ajoutées. « Ce qui profite à un concurrent dessert l’entreprise » pousse le modèle à chercher partout des effets négatifs indirects, et cette consigne l’emporte sur celle de matérialité, qui devait au contraire ramener vers le neutre. Deux règles écrites ensemble, dont l’une annule l’autre.
D’où la v9. Si v8 a échoué parce que ses règles se neutralisaient, il faut les tester une par une : changer une seule chose à la fois, pour savoir à quoi attribuer le résultat. v9 reprend donc v7 et n’y ajoute qu’une règle, celle de matérialité, parce qu’elle vise l’erreur la plus fréquente. La règle de l’entreprise visée, celle qui poussait Gemini vers le négatif, disparaît.
Nouvel échec, mais pas pour la même raison. Haiku descend à 75,5 % (−3,3 points). Cette fois, le modèle ne penche plus vers le négatif mais vers le neutre : il en met 103 sur 191, alors que les annotateurs humains n’en ont mis que 74. La règle a bien fait ce qu’on lui demandait, trop bien : elle classe comme anodins des faits qui ne l’étaient pas.
Le tableau est pourtant moins net qu’il n’y paraît. Sur les 191 articles annotés, Gemini v9 donne dix bonnes réponses de plus que v7 : il progresse nettement là où Gemini et Haiku v7 étaient en désaccord (68 bonnes réponses sur 131 contre 56), et ne recule presque pas ailleurs (50 sur 60 contre 52). La repondération efface ce gain, parce que les cas de désaccord ne pèsent qu’un cinquième du périmètre. Mais ce sont les cas difficiles. Faut-il alors garder v9 pour Gemini et v7 pour Haiku ?
Avant de conclure, il faut se poser la bonne question : ces écarts sont-ils réels, ou simplement dus au hasard d’un petit échantillon de 191 articles ?
J’ai utilisé pour cela le test de McNemar, un outil statistique fait spécifiquement pour comparer deux versions d’un même modèle sur exactement les mêmes données.
Son principe est le suivant : on ignore tous les articles où la v7 et la v9 donnent la même réponse (qu’ils aient tous deux raison ou tous deux tort, cela ne nous aide pas à les départager). On ne garde que les cas de divergence, c’est-à-dire les fois où l’une des versions a bon et l’autre a faux.
Ensuite, on pose ce que les statisticiens appellent l’hypothèse nulle (H0). C’est la posture du sceptique. Ici, mon hypothèse nulle affirme que : « la v9 ne vaut ni mieux ni moins bien que la v7, elles sont fondamentalement identiques et toute variation n’est que du bruit ». Si cette hypothèse nulle est vraie, les cas de divergence devraient logiquement se répartir à peu près à 50/50, comme à pile ou face.
Regardons les chiffres :
- Pour Haiku : sur 22 cas de divergence, la v9 a raison 8 fois et la v7 a raison 14 fois.
- Pour Gemini : sur 46 cas de divergence, la v9 a raison 28 fois et la v7 a raison 18 fois. (Gemini v9 semble donc avoir un avantage net).
C’est ici qu’entre en scène la fameuse p-value (ou valeur p). Elle traduit ce déséquilibre en probabilité. La p-value répond à une question très précise : si mon hypothèse nulle est vraie (si v9 et v7 se valent réellement), quelle est la probabilité d’obtenir un déséquilibre au moins aussi fort par le simple hasard du tirage ?
Le test me donne sa réponse :
- Pour Haiku, le test donne p=0,29. Il y a donc 29 % de chances d’observer cet écart par pur hasard.
- Pour Gemini, le test donne p=0,18. Il y a 18 % de chances de voir la v9 battre la v7 de cette façon par pure chance.
Par convention scientifique, on ne considère un effet comme « statistiquement significatif » (et on ne rejette l’hypothèse nulle) que si cette probabilité tombe sous la barre des 5 % (p<0,05). Nous en sommes très loin dans les deux cas.
Attention à ce que cela veut dire : le test ne prouve pas formellement que v9 et v7 sont strictement identiques. Il me dit simplement qu’avec mes 191 articles, il est impossible de distinguer l’effet de mon nouveau prompt d’une simple fluctuation statistique. Le signal est noyé dans le bruit. La même règle semble vaguement aider un modèle et nuire à l’autre, sans que rien ne se démarque du hasard. La v9 ne bat pas la v7.
J’arrête donc d’itérer sur le prompt, et la v7 reste ma référence.
Chapitre 4 : La combinaison des notes
La piste d’amélioration suivante est la combinaison de modèles. La médiane de trois notes sur une échelle ordinale (0 négatif, 1 neutre, 2 positif) est triviale à calculer et toujours valide :
def mediane(notes: list[int]) -> int:
"""Médiane d'un nombre impair de notes ordinales."""
return sorted(notes)[len(notes) // 2]PythonLe nombre de modèles doit être impair — sans quoi la médiane d’un vote pair peut tomber entre deux classes, ce qui n’existe pas ici. Sur les 26 combinaisons que j’ai testées, la meilleure est la médiane de Gemini, Haiku et Llama, tous en v7 : 81,1 %, contre 79,6 % pour le meilleur modèle seul (Haiku v8). Le gain est net et surtout gratuit car Llama tourne en local chez moi.
J’ai écarté Mistral. Son problème n’est pas le nombre de notes neutres qu’il produit, mais leur pouvoir discriminant : il répond « neutre » 84 % du temps quand l’humain dit effectivement neutre, mais aussi 72 % du temps quand ce n’est pas le cas. Llama, en comparaison, fait 51 % contre 25 % — un écart bien plus net entre les deux situations.
Étude du consensus
Une note de référence juste à 81 % pose un problème pratique immédiat : 19 % des notes sont fausses, et rien ne dit lesquelles. Tant que je ne sais pas les repérer, je dois traiter les 1 406 couples avec la même méfiance.
Ce qu’il me fallait, c’était un indicateur de confiance : une information qui, couple par couple, me dit celle là je peux la prendre ou celle-ci je doit la vérifier. Deux usages immédiats en dépendent — ne garder que les étiquettes sûres pour entraîner un modèle, et faire relire en priorité les douteuses.
Or la médiane de trois notes se calcule en effet dans deux situations très différentes :
- Les trois modèles disent la même chose. Gemini, Haiku et Llama répondent tous « positif ». La médiane vaut positif — mais surtout, trois systèmes conçus séparément, dont un tourne en local, ont lu le même article et conclu pareil ;
- Au moins un modèle diverge. Deux disent neutre, un dit négatif. La médiane vaut neutre, et cette note-là recouvre un désaccord.
La colonne consensus_unanime note simplement laquelle des deux situations s’est produite. Aucun appel de LLM supplémentaire, aucun modèle à entraîner : l’information tombe du calcul déjà fait.
Reste à vérifier qu’elle prédit vraiment quelque chose. Pour cela, je reprends mes 191 couples annotés à la main, et je les redécoupe — cette fois selon l’unanimité des trois modèles :
| Situation | Cas annotés | Consensus juste | Consensus faux | Justesse |
|---|---|---|---|---|
| Les trois modèles sont d’accord | 46 | 42 | 4 | 91,3 % |
| Au moins un modèle diverge | 145 | 87 | 58 | 60,0 % |
Trente et un points d’écart. Quand les trois modèles s’accordent, ma note de référence est pratiquement sûre ; quand l’un d’eux diverge, elle n’est juste que trois fois sur cinq. Et ce n’est pas un cas marginal : sur l’ensemble du corpus noté à ce stade, 981 couples sur 1 606 — 61 % — sont unanimes.
Un écart mesuré sur 46 cas d’un côté et 145 de l’autre mérite tout de même une vérification : pourrait-il n’être qu’un hasard de tirage ? C’est la question que tranche le test exact de Fisher. Il calcule la probabilité de voir un écart au moins aussi net si l’unanimité n’avait aucun effet réel.
Le résultat : , moins de cinq chances sur cent mille. L’écart est bien réel.
Je décide donc de ne retenir que les couples unanimes (91 % d’étiquettes justes) pour constituer un jeu d’entraînement et de mettre en tête de file les non-unanimes pour une campagne de relecture humaine, puisqu’ils concentrent l’essentiel des erreurs.
Retour sur la note TF-IDF
Ce rôle d’indicateur de confiance avait déjà été attribué dans les épisodes précédents : le TF-IDF de classifier.py, le tout premier prototype du projet. Le raisonnement était le même — si une méthode indépendante confirme la note du consensus, on est en terrain sûr ; si elle la contredit, méfiance.
L’idée était bonne, mais mesurée sur les mêmes 191 annotations, voici ce que donne le comparatif :
| Signal utilisé | Exactitude en cas d’accord | Exactitude en cas de désaccord | Écart |
|---|---|---|---|
| Unanimité des 3 LLM | 91,3 % (42/46) | 60,0 % (87/145) | 31,3 pts |
| Accord du TF-IDF | 74,6 % (85/114) | 57,1 % (44/77) | 17,4 pts |
Le TF-IDF est un indicateur de confiance nettement moins efficace : il peine à isoler les cas vraiment sûrs (74,6 % de justesse seulement quand il confirme, contre 91,3 % pour l’unanimité des LLM).
Chapitre 5 : Le vrai goulot d’étranglement : le volume, pas le modèle
C’est là que mon projet a changé de nature. Mon jeu d’entraînement le plus propre — les couples unanimes, dans le périmètre, non annotés — ne comptait que 373 couples. Trop peu pour entraîner un transformer sur trois classes avec une chance raisonnable de généraliser.
La cause n’était pas la qualité des étiquettes, mais l’univers d’entreprises suivies : seulement 34 sociétés avaient des couples en base. Conséquence directe : 2 975 articles dédiés, pourvus d’un corps, genre correctement identifié, n’avaient aucun couple — ils parlaient d’entreprises qui n’étaient tout simplement pas dans ma table companies. Les noms qui revenaient le plus dans ces articles orphelins étaient sans surprise des poids lourds du CAC 40 : LVMH, Stellantis, Kering, Renault, Vinci, Hermès, ArcelorMittal…
Le correctif ne demandait aucun scraping supplémentaire — les articles étaient déjà en base, seule l’entreprise manquait à l’appel.
J’ai écrit societes_suivies.py pour ajouter les sociétés manquantes (nom canonique + alias, l’alias comptant souvent plus que le nom : c’est lui qui fait reconnaître « Hermès » dans un titre qui parle d’Hermès International) :
SOCIETES = [
("LVMH", ["LVMH Moët Hennessy Louis Vuitton", "Moët Hennessy", "Louis Vuitton"], 11),
("Stellantis", [], 11),
("Vinci", ["Vinci SA"], 2),
("Hermès International", ["Hermès"], 11),
# ... 34 sociétés au totalPythonPuis creer_couples.py rattache chaque société aux articles qui la citent, dans le titre ou le corps :
for company_id, nom in societes:
motif = variantes_entreprise(company_id, nom)
for a in articles:
if not (citee_dans(motif, titre) or citee_dans(motif, contenu)):
continue
couples.append((a["id"], company_id, compter_citations(motif, f"{titre} {contenu}")))PythonRésultat : 68 sociétés suivies, 5 877 nouveaux couples créés, dont 743 dans le périmètre — mon total passe de 663 à 1 406 couples étiquetés. Mes cinq modèles ont noté ces 743 nouveaux couples, et le jeu d’entraînement disponible passe de 373 à 830.
Chapitre 6 : Entraîner le transformer
Avec un jeu d’entraînement plus que doublé, le terrain semblait enfin prêt.
Ce qu’on donne à lire au modèle
CamemBERT ne voit que 512 tokens, or 57 % de mes articles dépassent cette fenêtre, et dans 7 % des cas l’argument décisif se trouve au-delà des 1 500 premiers caractères. Tronquer le début, la solution par défaut, reviendrait donc à jeter l’information recherchée.
La solution dormait dans mon ancien script classifier.py : ne garder que les phrases qui parlent de l’entreprise, où qu’elles soient dans l’article, avec leur voisine de gauche et de droite pour le contexte :
def extraire_contexte_cible(texte: str, motif) -> str | None:
"""Phrases citant l'entreprise, avec la précédente et la suivante."""
phrases = nltk.sent_tokenize(texte, language="french")
index_trouves = [i for i, p in enumerate(phrases) if citee_dans(motif, p)]
if not index_trouves:
return None
garder = set()
for idx in index_trouves:
garder.update({max(0, idx - 1), idx, min(len(phrases) - 1, idx + 1)})
return " ".join(phrases[i] for i in sorted(garder))PythonTout se joue sur le fait de découper en phrases complètes (via NLTK, qui gère correctement les abréviations comme « M. » ou « 2,5 % »). garder récupère les phrases voisines, et le set élimine les doublons.
Grâce à ce traitement, la part d’entrées dépassant 512 tokens tombe de 57 % à 16 %.
L’entrée du modèle : un seul bloc de texte
Le transformer ne voit ni colonne « entreprise », ni métadonnées. Il reçoit une chaîne de caractères et rien d’autre.
Or un même article produit souvent plusieurs couples aux notes opposées (un contrat gagné par Airbus face à Boeing vaut 2 pour l’un et 0 pour l’autre). Si l’entrée ne contenait que le texte, le modèle verrait deux fois la même chaîne avec des étiquettes contradictoires. Le nom de l’entreprise évaluée doit donc figurer en tête de l’entrée, séparé du texte par </s> (le token de séparation natif de CamemBERT) :
LVMH </s> LVMH : Nomination de Maria Grazia Chiuri au poste de Chief Creative Officer chez F…PythonLa boucle d’entraînement
Je ne suis pas parti de zéro, mais de camembert-base, déjà entraîné sur des milliards de mots de français : il « sait » la langue, mais il ne sait rien de ma tâche. Ce que je fais ici s’appelle un affinage (fine-tuning) : je reprends ce modèle et je lui apprends une compétence supplémentaire à partir de 740 exemples, en en gardant 90 autres pour la validation.
Une ligne suffit pour ajouter une tête de classification à 3 sorties :
modele = AutoModelForSequenceClassification.from_pretrained(args.modele, num_labels=3).to(appareil)PythonFace au déséquilibre des classes (172 négatifs, 118 neutres, 450 positifs), j’ai pondéré la perte par l’inverse de la fréquence :
effectifs = np.array([sum(1 for e in jeux["entrainement"] if e["label"] == c) for c in (0, 1, 2)])
poids = torch.tensor(effectifs.sum() / (3 * effectifs), dtype=torch.float32, device=appareil)
perte = torch.nn.CrossEntropyLoss(weight=poids)
optimiseur = torch.optim.AdamW(modele.parameters(), lr=2e-5)PythonLa boucle est classique — six époques en lots de 16 avec un taux d’apprentissage à 2e-5 — sauf sur un point : ce qu’on retient à la fin. On ne garde pas le modèle de la dernière époque, ni celui qui maximise la justesse en validation, mais celui qui maximise le kappa de Cohen. Sur des classes aussi déséquilibrées, la justesse récompense un modèle qui se contente de la classe majoritaire ; le kappa, qui retire la part d’accord obtenue par hasard, ne le récompense pas.
for epoque in range(1, args.epochs + 1):
modele.train()
for lot, _, cibles in chargeurs["entrainement"]:
lot = {k: v.to(appareil) for k, v in lot.items()}
optimiseur.zero_grad()
sortie = perte(modele(**lot).logits, cibles.to(appareil))
sortie.backward()
optimiseur.step()
pred, vrai = evaluer(modele, chargeurs["validation"], appareil)
kappa = cohen_kappa_score(pred, vrai)
if kappa > meilleur: # kappa, pas justesse : les classes sont déséquilibrées
meilleur = kappa
meilleur_etat = {k: v.detach().cpu().clone() for k, v in modele.state_dict().items()}PythonVoici la trace réelle du premier entraînement :
époque 1/6 | perte 1.066 | validation : justesse 50.0%, kappa 0.28 ← meilleur
époque 2/6 | perte 0.863 | validation : justesse 81.1%, kappa 0.71 ← meilleur
époque 3/6 | perte 0.569 | validation : justesse 87.8%, kappa 0.81 ← meilleur
époque 4/6 | perte 0.380 | validation : justesse 83.3%, kappa 0.73
époque 5/6 | perte 0.285 | validation : justesse 85.6%, kappa 0.77
époque 6/6 | perte 0.252 | validation : justesse 85.6%, kappa 0.77BashLa perte d’entraînement continue de descendre jusqu’au bout — de 1,066 à 0,252 — pendant que la validation, elle, plafonne à la troisième époque puis recule. Le modèle apprend son jeu d’entraînement par cœur, pas la tâche. C’est le modèle de l’époque 3 qui est conservé.
Les 3 hypothèses au banc d’essai
Le test reste les 191 annotations humaines, jamais utilisées pour entraîner. J’y ai mesuré trois variantes, chacune correspondant à une hypothèse raisonnable.
Hypothèse 1 : n’entraîner que sur les étiquettes unanimes (740 exemples pour l’entraînement, 90 pour la validation). Résultat : 87,8 % en validation, mais 54,5 % sur le test humain. Le modèle apprend par cœur le style du consensus au lieu de la tâche.
Hypothèse 2 : élargir à 1 086 exemples, en acceptant les couples non unanimes. Chaque couple reçoit comme étiquette la note du consensus, c’est-à-dire la médiane de Gemini, Haiku et Llama en v7. Pour les 740 unanimes, ce sont exactement les mêmes exemples que dans l’hypothèse 1. Les 346 autres portent la médiane d’un vote partagé, juste seulement trois fois sur cinq.Ces couples-là contiennent justement les neutres et les cas ambigus, sous-représentés chez les unanimes. Résultat : 47,1 % sur le test. Pire, pas mieux : ajouter des étiquettes moins fiables dégrade le modèle.
Hypothèse 3 : remplacer l’étiquette dure par une étiquette douce. Au lieu de la médiane, une classe unique, je donne au modèle la distribution des votes : deux modèles sur trois disent neutre, un dit positif, la cible devient [0, 0.67, 0.33]. Le modèle apprend l’ambiguïté au lieu qu’on la lui masque. Résultat : 50,3 % sur le test. Le rappel sur la classe neutre double, mais sans compenser le bruit qui l’accompagne.
À titre de comparaison, sur ce même test de 191 cas : le consensus atteint 67,5 %, Llama seul 63,4 %. (Ce 67,5 % n’est pas en contradiction avec les 81,1 % du chapitre 4 : ici je compte brut sur les 191 annotées, où les cas litigieux sont surreprésentés ; là, la repondération redonnait à chaque strate son poids réel pour estimer la justesse sur tout le périmètre.)
| Approche | Test humain | Kappa |
|---|---|---|
| Consensus (3 LLM, référence) | 67,5 % | 0,46 |
| Llama 3.1 seul | 63,4 % | 0,39 |
| Transformer, étiquettes unanimes (740) | 54,5 % | 0,24 |
| Transformer, étiquettes douces (1086) | 50,3 % | 0,19 |
| Transformer, toutes étiquettes (1086) | 47,1 % | 0,16 |
Les kappas disent la même chose que les pourcentages, en plus sévère : à 0,24, le meilleur des trois reste dans la zone d’accord faible, quand le consensus atteint 0,46 sur le même test. Ce 0,46 n’est lui-même qu’un accord modéré : ce jeu de test est difficile pour tout le monde, le transformer y perd simplement deux fois plus de terrain.
Le diagnostic le plus parlant vient d’un découpage du test lui-même, en cas « faciles » (les 46 où les trois modèles du consensus étaient déjà unanimes) et « litigieux » (les 145 restants) :
- 82,6 % de justesse sur les 46 cas faciles ;
- 45,5 % de justesse sur les 145 cas litigieux.
Le transformer imite le consensus sur les cas faciles, mais s’effondre sur les cas difficiles.
Pour en avoir le cœur net, j’ai tracé une courbe d’apprentissage (sur 25 %, 50 %, 75 % et 100 % des données unanimes, avec 3 graines aléatoires) :
| Exemples d’entraînement | Justesse (moyenne ± écart-type) | Kappa |
|---|---|---|
| 185 (25 %) | 44,8 % ± 4,0 | 0,10 |
| 370 (50 %) | 51,0 % ± 2,0 | 0,21 |
| 555 (75 %) | 51,0 % ± 4,1 | 0,22 |
| 740 (100 %) | 53,2 % ± 2,6 | 0,25 |
La courbe s’aplatit très vite : doubler le volume de 370 à 740 exemples ne rapporte que 2 petits points, moins que l’écart entre deux graines sur les mêmes données. Au rythme d’environ 2 points par doublement, rejoindre les 67,5 % du consensus demanderait des dizaines de milliers d’exemples. L’extrapolation est grossière, mais l’ordre de grandeur suffit : le volume aide, il ne comblera pas l’écart à lui seul.
Ce que je retiens
Ce billet devait raconter l’entraînement réussi d’un transformer. Il raconte surtout pourquoi ce n’était pas encore le bon moment de l’entraîner.
Chaque étape de ce projet a réfuté une idée qui semblait pourtant séduisante au départ. Ma décision est donc simple : je ne réentraînerai pas ce transformer tel quel.
Pour progresser, il ne me faut pas seulement plus d’exemples unanimes (cas faciles), mais surtout des cas difficiles correctement annotés par un humain. En attendant, ma note de référence reste le consensus des 3 LLM (81,1 % de justesse estimée). Le transformer attendra sagement sur l’étagère !
Repères de lecture, pour les termes qui reviennent souvent dans cette série
- Kappa de Cohen : mesure l’accord entre deux juges en retirant la part qui tombe par hasard. En dessous de 0,40, l’accord est faible ; entre 0,40 et 0,60, modéré ; au-delà de 0,60, substantiel.
- Test de McNemar : compare deux versions d’un même classifieur sur le même échantillon, en ne regardant que les cas où elles divergent. Utile pour juger si un changement de prompt a un effet réel ou pas.
- Test exact de Fisher : version du test du chi² adaptée aux petits effectifs, utilisée ici pour valider que l’écart de fiabilité entre couples unanimes et non unanimes n’est pas un hasard d’échantillonnage.