💼 « Ce texte est-il une offre, une liste d'offres, ou autre chose ? »

Chapitre 7 — la première tâche appliquée du projet. Jusqu'ici les modèles résolvaient des problèmes de maths ou dessinaient des chats ; celui-ci répond à un vrai besoin : parcourir des milliers de pages web et reconnaître celles qui proposent un poste, pour trouver automatiquement les pages carrière et les sites emploi. Contrainte de la commande : le texte seul — pas d'URL, pas de nom de domaine — sur la page entière, dans toutes les langues.

🔬 À vous : collez une page, ou donnez une adresse

Les trois détecteurs tournent en direct sur ce serveur, sur 4 cœurs partagés — ce sont exactement ceux mesurés au run-058. La page est téléchargée, passée dans l'extracteur de texte du projet, puis jugée trois fois. Trois réponses possibles : une offre, une liste d'offres, autre chose.

…ou coller directement du texte (ou du HTML)

Rien n'est enregistré : la page est téléchargée, jugée, et le texte est jeté. Les adresses privées (127.0.0.1, réseaux internes, métadonnées cloud) sont refusées — un visiteur ne doit jamais pouvoir se servir de ce formulaire pour viser l'intérieur de la machine.

Les trois concurrents (et pourquoi le troisième est là pour faire honte aux deux autres)

1 · TF-IDF + régression logistique — le candidat retenu

Trente lignes de scikit-learn : on compte les mots et les paires de mots, une régression tranche. Aucune compréhension, aucun GPU. ~1 000 pages par seconde sur deux cœurs — c'est ce qui rend la tâche faisable en masse.

2 · Les mots-clés — le témoin bête

Une expression régulière multilingue (cdi, we are hiring, wir suchen, buscamos, vaga…) : deux occurrences suffisent à dire « offre ». Il est là pour empêcher de se féliciter trop vite — si le modèle ne fait pas mieux que lui, il ne sert à rien. Il n'a que deux réponses : ses mots-clés sont ceux d'une offre, et une page qui liste des offres les contient tous. Il ne peut pas voir la différence — c'est exactement le défaut qu'un lecteur a trouvé, et je le laisse visible ci-dessus.

3 · La forme seule — le contrôle aveugle

Une régression qui ne voit aucun mot. Seulement cinq nombres : longueur du texte, nombre de retours à la ligne, densité de chiffres, de ponctuation, de majuscules. Elle ne peut rien savoir d'une offre d'emploi. Elle est là pour une seule raison : tout ce qu'elle réussit à deviner est une fuite de mon corpus, pas de l'intelligence. Et elle en a trouvé une (voir plus bas).

Le chemin, en trois runs

run-055 — l'annexe : juger sur l'adresse

Premier essai, sur ce qui existait déjà : 118 509 domaines déjà jugés, avec pour seul signal le nom de domaine et quelques URLs — 78 caractères en moyenne. Ce n'était pas la commande (qui porte sur le texte), mais le résultat mérite d'être gardé :

examenzéro-shot1,5 B entraînéTF-IDF + régression
aléatoire37,381,478,7
plateforme jamais vue36,974,471,1
pays jamais vu43,874,169,9

F1 en %. Le modèle gagne les trois — et son avance grandit quand l'examen devient honnête (+2,7 · +3,3 · +4,2). La régression mémorise des bouts d'adresse (geebo, jobs.) qui ne voyagent pas d'un pays à l'autre.

run-056 — la vraie commande… et 98 % que je n'ai pas signés

Sur le texte, le classement s'écrase : TF-IDF 98,4 de F1, le 1,5 B entraîné 98,7. +0,3 point pour environ mille fois le coût. Sauf que le run dénonçait lui-même trois défauts :

run-057 — le procès en appel, avec deux contrôles à charge contre mon propre corpus

Corpus réparé (1 800 pages-liste au lieu de 77, 242 articles au lieu de 0), et surtout deux mesures qui ne pouvaient rien m'apporter de bon.

Contrôle nº 1 — le classifieur qui ne lit aucun mot

familles vues à l'entraînementfamilles jamais vues
forme seule (aucun mot)F1 83,348,5
mots-clés71,264,1
TF-IDF + régression98,292,7
1,5 B entraîné98,597,5

83 points de F1 sans un seul mot de vocabulaire. La colonne « familles vues » ne mesurait donc pas la compréhension, elle mesurait la mise en page. Sur les familles jamais vues, le contrôle s'effondre à 48,5 — seule cette colonne-là est lisible, et c'est celle où le modèle prend enfin le large : 97,5 contre 92,7.

Contrôle nº 2 — les mêmes offres, l'autre chemin de collecte

Le seul moyen de trancher le soupçon : re-télécharger exactement les mêmes offres, par leur adresse, avec le téléchargeur simple qui avait servi aux non-offres. 439 pages récupérées.

rappel (offres correctement reconnues)texte stocké par le robotmême page, re-téléchargée
TF-IDF + régression97,773,3
1,5 B entraîné98,075,9

Environ 24 points de rappel venaient du chemin de collecte — ou de la péremption des offres ; le témoin ne sépare pas les deux, et dans les deux cas c'est le chiffre qui compte en production. Longueur médiane du texte : 2 569 caractères stockés contre 4 903 re-téléchargés. J'ai vérifié que ma propre troncature d'export n'y était pour rien : 0 document sur 200 ne l'atteint.

Vous pouvez le voir vous-même : le bouton « une offre en JavaScript » ci-dessus pointe une vraie offre dont la page, téléchargée simplement, ne contient presque pas de texte — tout est construit par le navigateur. Le robot qui exécute le JavaScript la voit ; le téléchargeur simple, non.

Une autre coupe, celle-là assumée : l'extracteur ne garde que les 6 000 premiers caractères de texte d'une page — alors qu'une page web brute en fait couramment 55 000 de HTML. 24 % des offres du corpus (816 sur 3 407) touchent ce plafond, médiane 3 347. Le modèle n'a donc 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, offres comme non-offres — mais c'est une limite : si un signal décisif ne vivait qu'après 6 000 caractères, aucun des trois détecteurs ne pourrait le voir.

run-058 — un lecteur casse le détecteur, et le corpus était en cause

Un lecteur a envoyé une adresse : la page « offres d'emploi » du Crédit Agricole du Maroc. Ce n'est pas une offre, c'est une liste de postes. Le run-057 a répondu « offre, 0,501 ». Le bouton ci-dessus rejoue le cas.

Le défaut n'était pas dans le modèle. Il était dans mon corpus : il n'avait jamais vu une seule page carrières d'entreprise. Mes « listes » négatives étaient des racines de sites emploi — la page d'accueil d'Indeed, c'est-à-dire un moteur de recherche, pas une liste. Le négatif le plus difficile, celui qui a le vocabulaire d'une offre sans en être une, n'était pas dans le jeu. Et il y en avait 16 361 disponibles dans la base, jamais utilisés.

Le choix : une troisième classe, pas un seuil

On pouvait monter le seuil à 0,7 et faire disparaître le problème de l'écran. J'ai fait autre chose, parce que « liste » n'est pas un refus, c'est une destination : en production une offre part au parseur, et une liste part au crawler qui en suivra les liens pour trouver d'autres offres. Un détecteur qui dit seulement « non » jette le meilleur point d'entrée du site.

Étiqueter une liste sans se croire sur parole

Au run-056 j'avais fait confiance à un champ de la base pour dire ce qu'était une page : il mentait, et j'ai étiqueté des offres comme « pas une offre ». Cette fois, aucune page n'entre dans la classe « liste » sans preuve : elle doit pointer au moins 5 liens distincts vers des chemins plus profonds du même hôte ressemblant à des postes, comptés sur le HTML brut. Sévère : 33 000 candidates tirées, 3 111 retenues. Les 12 000 pages à 1 à 4 liens sont jetées — c'est là que vit le doute, et un exemple absent coûte moins cher qu'une étiquette fausse.

Le sous-produit vaut l'exercice : les 2 100 pages carrières qui ne pointaient aucun poste sont devenues les négatifs durs de la 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. Ma première tentative les avait supprimées par prudence — et elle a échoué : 9,0 % de faux positifs contre 8,8 % au run-057, aucun progrès. Les récupérer est ce qui a fait la différence.

Le chiffre, sur exactement la même population

371 pages carrières d'entreprise, famille jamais vue à l'entraînement. Les deux modèles jugent les mêmes textes :

sur ces 371 pages (qui sont toutes des listes)run-057 (2 classes)run-058 (3 classes)
prises pour une offre18,6 %2,7 %
reconnues listeimpossible91,1 %
rappel sur les offres tenues de côté98,6 %98,6 %

Sept fois moins de faux positifs, et pas un point de rappel perdu. Sur la page du lecteur : offre 0,501 devient liste 0,692.

Le contrôle aveugle, encore lui — et il fallait le craindre ici

Une liste est longue et hachée, une offre est un bloc de prose : la nouvelle classe avait tout pour s'apprendre à la mise en page plutôt qu'au sens. La forme seule obtient 35,3 de F1 macro sur les familles inconnues, contre 75,4 à la régression. Elle prend 1 956 pages ordinaires sur 4 048 pour des offres. La classe « liste » ne s'attrape pas à la forme.

Ce qui reste faux, et que je ne cache pas

La précision de la classe « liste » face aux familles inconnues n'est que de 30,5 % : 761 pages ordinaires sur 4 048 se font appeler « liste ». Beaucoup sont des cas limites honnêtes — un site d'agence de recrutement qui affiche « Available Positions », un job board associatif — mais pas toutes. En production, un faux « liste » coûte une visite de crawler inutile ; un faux « offre » coûte un quota de parsing. C'est le moins cher des deux, et c'est pour ça que je sers ce modèle-là. Ce n'est pas une excuse : c'est le prochain chantier.

Le 1,5 B entraîné fait mieux en moyenne — 78,6 de F1 macro contre 74,7 à la régression sur exactement les mêmes 1 500 pages (le run l'avait d'abord noté 75,4, mais sur 5 247 pages : deux populations, donc pas une comparaison ; corrigé après coup, la conclusion ne change pas) et se trompe deux fois moins en appelant « liste » (précision 43,9 %) — mais il ne reconnaît que 57,5 % des vraies listes, contre 91,1 % à la régression, et il rate 38,8 % des pages du lecteur. Sur cette tâche-ci, à un millième du coût, la régression reste le bon choix. Le zéro-shot, lui, s'effondre à 32,3.

Ce qu'on en retient

1 · Pour l'usage réel — des milliers de pages sur 4 cœurs partagés — la régression gagne. Elle fait 92,7 contre 97,5 au modèle sur les familles inconnues, à un millième du coût. La forme utile est une cascade : la régression d'abord, le modèle en arbitre sur les cas douteux seulement.

2 · L'avance du modèle n'apparaît que quand l'examen devient dur. +1,6 sur le corpus facile du run-056, +4,8 sur le corpus réparé du run-057 — même signature qu'au run-055. Les cas qu'il rattrape sont parlants : un blog de diplômes marocain, un site d'agence de recrutement italien, un blog vietnamien. La régression y voit du vocabulaire d'emploi et dit « offre » ; le modèle dit non.

3 · Un contrôle qui ne peut que vous donner tort vaut mieux qu'un score. Les deux contrôles de ce chapitre tiennent en vingt lignes chacun, et ils ont retiré 23 points au résultat que j'allais publier.

4 · Le défaut trouvé par un lecteur était un trou dans le corpus, pas un réglage du modèle. Le run-057 avait 92,7 de F1 et se trompait quand même sur une page carrières — parce qu'il n'en avait jamais vu une. Aucun réglage n'aurait rattrapé ça ; il fallait aller chercher la famille manquante. Et une première tentative, celle qui jetait les négatifs difficiles par prudence, n'a rien gagné du tout : 9,0 % contre 8,8 %. Ce sont les exemples durs qui apprennent, pas les exemples propres.

Ce qui n'est pas jouable ici, et pourquoi je le dis

Le 1,5 B entraîné n'est pas servi sur cette page. Il existe sous forme d'adaptateur LoRA sur GPU ; le mettre en ligne demanderait de le fusionner, de le convertir, et d'ajouter un conteneur de plus sur une machine déjà partagée avec d'autres services. Ses chiffres sont dans les tableaux ci-dessus — ceux du run-058 sur les 1 500 pages qu'il a réellement jugées — mais vous ne pouvez pas les rejouer d'un clic — et une page qui laisserait croire le contraire mentirait. Ce qui tourne ci-dessus est ce qui tourne vraiment en production.

Vérification faite au passage : les deux détecteurs servis ici ont été ré-entraînés sur ce serveur, avec un corpus de non-offres re-téléchargé — donc jamais le même que celui de Kaggle. Ils retombent à un point près sur les mêmes scores (forme 82,4 / 48,8 ; TF-IDF 98,1 / 92,1). Le résultat n'était pas un coup de chance de tirage.