🧪 Le harnais — tout ce qui n'est pas le modèle

Le modèle du chapitre 7 tient en trente lignes de scikit-learn. Le reste — la machine qui fabrique les exemples, qui décide de la vérité, qui découpe l'examen, qui mesure et qui sert — c'est le harnais. Cette page le décrit étape par étape, avec le fichier de chaque étape et le mode de panne qu'elle empêche.

La seule idée à retenir : un chiffre ne mesure jamais le modèle seul. Il mesure le couple modèle + harnais. Tant qu'on ne sait pas ce que fait le harnais, on ne sait pas ce que veut dire le score — et sur ce projet, quatre fois de suite, le chiffre publiable mesurait autre chose que ce que je croyais.

Quatre fois où le harnais mesurait le harnais

Un champ qui ment

98,4

de F1 au run-056. L'étiquette venait d'un champ de la base qui, sur certains sites, pointait l'offre elle-même : j'étiquetais des offres comme « pas une offre ».

La mise en page

83,3

de F1 obtenus par une régression qui ne voit aucun mot — juste cinq nombres. Tout ce qu'elle réussit est une fuite de mise en page, pas de la compréhension.

Le chemin de collecte

−24

points de rappel. Les offres passaient par un robot qui exécute le JavaScript, les non-offres par un simple téléchargement. Le modèle avait appris comment on collecte.

Deux populations

75,4

de F1 annoncés pour la régression contre 78,6 au 1,5 B — sauf que le premier était mesuré sur 5 247 pages et le second sur 1 500. Trouvé par un audit du harnais, pas par un contrôle du run.

Une famille absente

16 361

pages carrières d'entreprise dormaient dans la base sans jamais servir. Un lecteur a envoyé une URL, et le détecteur à 92,7 de F1 s'est trompé.

Les trois premiers ont été trouvés par des contrôles écrits exprès pour me donner tort. Le quatrième a été trouvé par un lecteur — c'est-à-dire par le seul examinateur que le harnais ne contient pas encore.

La chaîne, de l'adresse au verdict

Onze étapes. Chacune transforme la donnée, et chacune peut fabriquer un faux résultat si on ne la surveille pas.

Tirer les candidates

base jobiglo (lecture seule) → 33 000 adresses

Un échantillonnage aléatoire par famille de domaines : job boards, racines carrières, sections carrières, sites ordinaires, blogs emploi, agences. La famille est l'unité qui compte : c'est elle qui sera, ou non, montrée à l'entraînement.

training/local/emploi_urls3_export.py
Garde contre : un corpus qui ne contient qu'un seul genre de page. Le run-057 avait 92,7 de F1 en ignorant totalement une famille de 16 361 domaines.

Sortir le calcul du serveur

14 Mo d'adresses → jeu de données Kaggle → moisson par le kernel

Le serveur héberge aussi la préprod du partenaire : il ne télécharge pas 33 000 pages. Seules les adresses partent, et le kernel Kaggle moissonne lui-même en 32 fils. Le jeu de données est supprimé de Kaggle une fois le run terminé.

training/kaggle/run-058-offre3-15b/train.py:147
Garde contre : saturer une machine partagée — et laisser traîner des données du partenaire chez un tiers.

Télécharger, une fois, de la même manière pour tous

URL → HTML brut

Le point le plus dangereux de toute la chaîne. Si les exemples positifs et négatifs n'arrivent pas par le même chemin, le modèle apprend le chemin.

training/kaggle/run-058-offre3-15b/train.py:107
Déjà attrapé : au run-057, re-télécharger les mêmes 439 offres avec le téléchargeur des négatifs a fait tomber le rappel de 97,7 % à 73,3 %. Longueur médiane du texte : 2 569 caractères contre 4 903. Environ 24 points venaient du chemin de collecte, pas du modèle.

Extraire le texte — le même code partout

HTML → texte, coupé à 6 000 caractères

html_vers_texte() retire scripts et balises, puis tronque à 6 000 caractères. retirer_adresses() efface ensuite toute URL et tout nom de domaine, parce que la commande est explicite : juger sur le texte seul.

training/local/extraction.py:32 · :58
Garde contre : deux extracteurs différents des deux côtés — ce qui fabriquerait une signature de mise en page au lieu d'une notion d'offre. Le fichier voyage dans le jeu de données et son SHA-256 est imprimé des deux côtés.
Limite assumée : 24 % des offres du corpus (816 sur 3 407) touchent le plafond de 6 000. Le modèle n'a jamais lu le bas d'une page longue. Ce n'est pas une fuite — le plafond s'applique au caractère près des deux côtés — mais si un signal décisif vivait après 6 000 caractères, aucun détecteur ne pourrait le voir.

Décider de la vérité — par preuve, jamais par confiance

page + ses liens bruts → étiquette, ou rien

C'est l'étape la plus sous-estimée d'un projet d'apprentissage : d'où vient l'étiquette ? Une page n'entre dans la classe « liste » que si elle pointe au moins 5 liens distincts vers des chemins plus profonds du même hôte ressemblant à des postes, comptés sur le HTML brut avant tout nettoyage.

training/kaggle/run-058-offre3-15b/train.py:122 · :156
Garde contre : croire un champ de base de données. Au run-056 j'ai fait exactement ça, et j'ai étiqueté des offres comme « pas une offre ».
Prix payé, volontairement : 33 000 candidates tirées, 3 111 retenues, et les 12 190 pages à 1-4 liens jetées. C'est là que vit le doute — et un exemple absent coûte moins cher qu'une étiquette fausse.

Recycler les refus en négatifs difficiles

2 100 pages carrières à zéro lien de poste → classe « autre »

Une page d'entreprise bourrée de vocabulaire RH sans un seul poste : exactement la forme de ce qui se faisait prendre pour une offre. Zéro lien, donc aucun doute sur l'étiquette — elle rejoint « autre ». Les pages à 1-4 liens, elles, restent dehors.

training/kaggle/run-058-offre3-15b/train.py:159
Déjà attrapé : ma première tentative avait supprimé ces pages par prudence. Résultat : 9,0 % de faux positifs contre 8,8 % au modèle précédent — aucun progrès. Les récupérer est ce qui a fait passer le chiffre à 2,7 %.

Découper l'examen par famille, pas au hasard

13 759 documents → 7 005 entraînement · 2 335 familles vues · 5 247 familles inconnues

Une découpe aléatoire classique met des pages du même site des deux côtés : le modèle reconnaît le site, pas la notion. Ici certaines familles entières — sections carrières, blogs emploi, agences, accueils d'entreprise, articles — ne servent qu'à l'examen.

training/kaggle/run-058-offre3-15b/train.py:228
Garde contre : un score qui mesure la mémorisation. L'écart le dit tout seul : la régression fait 91,5 sur les familles vues et 75,4 sur les inconnues. Seule la seconde colonne est lisible.

Le contrôle aveugle — celui qui ne peut que me donner tort

texte → 5 nombres, aucun mot → une classe

Une régression qui ne voit que la longueur, le nombre de retours à la ligne et les densités de chiffres, de ponctuation et de majuscules. Elle ne peut rien savoir d'une offre d'emploi. Tout ce qu'elle réussit est une fuite de mise en page.

training/local/offre3_servir.py:51 · servi en direct sur emploi.html
Déjà attrapé : au run-057 elle a fait 83,3 de F1 sur les familles vues — cette colonne mesurait la mise en page. Au run-058, la classe « liste » était le suspect idéal (longue et hachée face à un bloc de prose) : elle tombe à 35,3 de F1 macro sur les familles inconnues. La classe ne s'attrape pas à la forme.

Le témoin bête — le plancher à battre

texte → expression régulière multilingue → offre / autre

cdi, we are hiring, wir suchen, buscamos, vaga… Deux occurrences suffisent. Si le modèle ne fait pas mieux qu'une expression régulière, il ne sert à rien.

api/app.py — MOTS_OFFRE
Garde contre : se féliciter d'un score absolu. Il n'a que deux réponses — une liste d'offres contient tous les mots-clés d'une offre — et cet aveuglement est laissé visible sur la page publique, parce que c'est précisément le défaut qu'un lecteur a trouvé.

Comparer sur la même population, ou ne pas comparer

ancien modèle et nouveau, mêmes 371 textes

La faute la plus facile du métier : annoncer « 8,8 % → 2,7 % » alors que les deux chiffres viennent de deux jeux de pages différents. Chaque comparaison publiée ici rejoue les deux modèles sur les mêmes octets.

training/local/offre3_servir.py:107
Déjà attrapé : j'ai failli publier une amélioration de « 6,7 % contre 8,8 % » mesurée sur 120 pages d'un côté et 502 de l'autre. Sur la même population, la vraie valeur du premier essai était 9,0 % — c'est-à-dire pire.
Et la règle s'est fait prendre en défaut par son propre run : le run-058 note les détecteurs pas chers sur l'examen entier (5 247 pages) et le 1,5 B sur les 1 500 premiers. J'ai donc publié « 75,4 contre 78,6 » en comparant deux populations. Recalculé sur les mêmes 1 500 : 74,7 contre 78,6. La conclusion tient — l'écart se creuse même — mais le chiffre publié était faux, et aucun contrôle du run ne l'a vu : il a fallu un audit du harnais lui-même.

Servir avec exactement le même prétraitement

URL du visiteur → mêmes fonctions d'extraction → verdict en ~20 ms

Le conteneur monte extraction.py au lieu de le recopier : une copie finirait par diverger, et le détecteur jugerait alors un texte extrait autrement que ce qu'il a appris. Le plafond de 6 000 est lu chez l'extracteur lui-même pour que les deux ne dérivent jamais.

docker-compose.yml:31 · api/app.py
Garde contre : le décalage entraînement / production — la panne silencieuse par excellence. Et contre le visiteur malveillant : toute adresse non publique est refusée à chaque saut de redirection, sinon une redirection suffirait à viser l'intérieur de la machine.

Trois règles que le harnais impose au reste du projet

1 · Un contrôle qui ne peut que vous donner tort vaut mieux qu'un score. Les deux contrôles du chapitre 7 tiennent en vingt lignes chacun et ils ont retiré 23 points au résultat que j'allais publier. Un score ne se conteste pas tout seul ; un contrôle, si.

2 · L'étiquette est une hypothèse, pas une donnée. Chaque fois que j'ai fait confiance à un champ, à un nom de fichier ou à une intuition sur ce qu'une page « devait » être, ça s'est payé. La règle des 5 liens coûte 12 190 exemples jetés : c'est le prix d'une étiquette dont on peut répondre.

3 · Les chiffres se publient avec leurs défauts. La précision de la classe « liste » est de 30,5 % sur les familles inconnues, et c'est écrit sur la page publique à côté du chiffre qui fait plaisir. Un harnais qui ne sert qu'à produire de bonnes nouvelles n'est pas un harnais, c'est une vitrine.

Ce que le harnais ne fait pas encore

Il n'a pas de témoin de production permanent. Le contrôle de collecte du run-057 a été fait une fois, à la main, sur 439 offres. Rien ne le rejoue à chaque run — donc rien ne m'avertira si l'écart se creuse à nouveau.

Il n'a pas de jeu de non-régression figé. Les pages qui ont mis un modèle en défaut — celle du Crédit Agricole en tête — devraient former un examen permanent que chaque nouveau run doit repasser. Aujourd'hui elles ne vivent que dans le journal.

Il ne garde pas les prédictions brutes. Les runs 057 et 058 enregistrent leurs scores mais pas les réponses détecteur par détecteur, ni les indices des jeux d'examen — alors que le run-055 le faisait. C'est exactement pour ça que l'erreur « deux populations » ci-dessus a dû être recalculée au lieu d'être simplement re-découpée : pour la régression c'est une minute de processeur, pour le 1,5 B il faudrait rallumer un GPU. La règle avait été écrite après le run-050 ; elle a été désappliquée trois runs plus tard.

Il n'oblige à rien pour les traces. Le jeu de données du run-058 a été supprimé de Kaggle, mais rien dans le dépôt ne le prouve : la sortie de commande n'a pas été consignée, contrairement au run précédent. Une garde qui repose sur ma bonne foi n'est pas une garde.

Il ne surveillait pas les secrets. L'audit a trouvé le mot de passe MongoDB de la préproduction du partenaire en clair dans trois scripts d'export, dont deux que je venais d'ajouter au dépôt. Le dépôt n'a aucun remote — rien n'est sorti de la machine — et le mot de passe vit désormais hors du dépôt. Mais c'est une garde qui manquait, et ce n'est pas le harnais qui l'a vue.

Il n'a pas de lecteur. Les trois premières erreurs de cette page ont été trouvées par du code que j'ai écrit contre moi-même. La quatrième a été trouvée par quelqu'un qui a simplement collé une URL. C'est le contrôle le moins cher et le plus efficace du projet, et il n'est pas automatisable — d'où le banc d'essai public sur la page du détecteur.