Limites et points de vigilance

Fragilités connues du pipeline, et ce qu’elles impliquent pour l’interprétation des résultats

Cette page recense les limites méthodologiques et techniques identifiées dans le pipeline, pour qu’elles restent visibles plutôt que d’être redécouvertes à chaque campagne. Elle distingue ce qui a été corrigé de ce qui reste ouvert.

AstuceComment lire cette page

Les points « ouverts » ne sont pas des bugs bloquants : le pipeline produit des résultats exploitables. Ce sont des écarts entre ce que le pipeline garantit et ce qu’on pourrait croire qu’il garantit — donc autant de précautions à prendre quand on interprète une accuracy ou un fichier livré.

Corrigé en juillet 2026

Élagage incomplet des hiérarchies linéaires

L’implémentation ne repliait un parent sur son enfant unique que si cet enfant avait lui-même au plus un enfant. Conséquence : 7 codes strictement redondants (02.3.0, 05.4.0, 10.1.0, 10.5.0, 11.2.0, 13.3.0, 13.9.0) coexistaient avec leur parent synonyme dans la nomenclature prunée, et étaient absents de la table de mapping — donc impossibles à normaliser en aval. Une prédiction 11.2.0 face à une annotation 11.2 était comptée comme une erreur, et deux documents quasi identiques se retrouvaient dans Qdrant.

Les postes concernés n’étaient pas anecdotiques en BdF : tabac (02.3) et services d’hébergement (11.2). Voir Prune pour l’algorithme corrigé et prune-codes/tests/test_pruning.py pour les tests de non-régression.

Normalisation asymétrique dans reconcile-llm

Trois oublis dans la normalisation (troncature niveau 4 + élagage) :

  • ragann_code n’était pas normalisé, alors que le RAG annotations ne garantit le pruning que par une consigne de prompt (contrairement au RAG notices, qui re-prune sa sortie dans le code). Un code non pruné pouvait donc entrer dans le vote de consensus, comparé par égalité de chaînes.
  • La vérité terrain n’était jamais prunée : le rapport comparait donc des prédictions prunées à une vérité au niveau 5. Une prédiction canonique parfaite (01.3 face à 01.3.0.0.x) était comptée fausse aux niveaux 3 et 4 — un biais touchant ~24 % des Postes de la nomenclature, et non uniformément réparti entre divisions. La table intermédiaire porte désormais les deux colonnes (code brut et code_lvl4 canonique) et le rapport score sur la seconde.
  • llm_code n’était normalisé que dans export-results, pas dans la sortie de reconcile-llm — or c’est cette sortie que le rapport score.

Fuite de la vérité terrain dans le prompt du juge

Le prompt de reconcile-llm contenait une ligne Code de référence : {code}, valant N/A en production mais exposant le code annoté en évaluation. Double effet : métriques du juge invalides (leakage), et incitation à répondre au niveau 5. La ligne a été retirée.

Ouvert — nomenclatures et codes

Le juge LLM ne voit pas la nomenclature prunée

reconcile-llm soumet au LLM un fichier statique du dépôt (reconcile-llm/data/coicop_et_codes_techniques.csv, ~700 codes) et non la nomenclature_pruned.parquet du run. Ce fichier contient 171 codes de niveau 5 et tous les codes en .0 que l’élagage vient d’éliminer. On demande donc au juge de choisir « le niveau le plus précis justifié » dans une liste qui contient des codes que le pipeline s’interdit par ailleurs. La normalisation aval rattrape le tir, mais :

  • le juge peut hésiter entre deux codes que le pipeline considère comme un seul ;
  • le fichier peut se désynchroniser de la nomenclature RMES à tout moment (il existe déjà deux versions divergentes dans le dépôt : celle de reconcile-llm/data/ et celle de classify-ttc/data/).

Piste : passer --nomenclature depuis Argo en dérivant un CSV de nomenclature_pruned.parquet.

Le code technique 99.6 manque au référentiel du juge

Deux règles regex (\bremboursement\b et ^retrait\b) produisent le code 99.6. Ce code est bien un code technique utilisé en pratique — il figure dans le suggester avec le libellé « Remboursement prêt » — mais il est absent du référentiel soumis au juge LLM (reconcile-llm/data/coicop_et_codes_techniques.csv, qui contient 20 codes techniques : 98, 98.1–98.8, 99, 99.1–99.5, 99.9). Conséquences :

  • le juge ne peut jamais choisir 99.6, même quand c’est la bonne réponse ;
  • une ligne codée 99.6 par regex traverse quand même tout le pipeline jusqu’au livrable, sans jamais avoir été validée contre un référentiel.

Second problème sur la même règle : le référentiel du juge attribue les retraits à 99.2 (« Opération bancaire (retrait, virement, prélèvement) »), alors que la règle ^retrait\b code en 99.6. Les deux conventions coexistent dans le pipeline.

Piste : réconcilier le référentiel des codes techniques avec les règles regex et les libellés du suggester, et en faire une source unique.

Les codes techniques 98.x / 99.x ne sont jamais élagués

Ils ne figurent pas dans la nomenclature source, donc ni le pruning ni la resynchronisation de libellé ne s’y appliquent. Or 98.1.1 a un enfant unique 98.1.1.1 dans le référentiel du juge : conceptuellement redondants (comme les codes en .0 que l’élagage réconcilie), et les règles regex émettent les deux (^fruits?$ → 98.1.1.1, ^courses alimentaires$ → 98.1.1). Deux codes pour un même concept, sans mapping pour les réconcilier — exactement le problème que l’élagage résout pour les codes COICOP.

Autre effet de bord : un même code technique porte deux libellés différents selon la source. Par exemple 99.4 = « Impôts et taxes » dans le suggester vs « Impôts, taxes et cotisations syndicales ou politiques » dans le référentiel du juge ; et 99.2 = « Épargne » dans le suggester vs « Opération bancaire » côté juge — deux notions qui ne se recouvrent pas.

Aucune validation d’appartenance à la nomenclature

À aucune étape le pipeline ne vérifie qu’un code prédit existe réellement. La normalisation tronque et remappe, mais un code halluciné bien formé (07.4.2.9) passe tel quel jusqu’au livrable. Un contrôle qualité aval (jointure sur la nomenclature prunée) reste nécessaire.

Des codes plus profonds que la nomenclature, et aucun contrôle de version

Le suggester contient des codes sur 6 niveaux (168 codes distincts, ex. 01.1.1.3.9.1 « Pâtisserie ») alors que la COICOP 2018 s’arrête au niveau 5. Il s’agit d’extensions locales de la nomenclature, pas d’un autre millésime : vérification faite, leur troncature au niveau 4 donne un code valide dans 168 cas sur 168. Ce point est donc sain aujourd’hui — mais rien ne le contrôle : aucune assertion ne vérifie que la troncature d’un code d’entrée retombe sur un code existant.

Le risque théorique reste entier pour un changement de millésime : toutes les sources sont supposées en ECOICOP v2 (COICOP 2018), y compris les annotations BdF 2017 (converties en amont). Un code ECOICOP v1 qui fuiterait serait tronqué en un code v2 faux dans ~76 % des cas, les deux arborescences ne coïncidant pas. Une table de passage v1→v2 a existé dans le dépôt (classify-ttc/data/table_passage_coicop.csv) sans être utilisée par le moindre code ; elle a été supprimée le 2026-09-02 et reste récupérable dans l’historique git. Rien ne détecte un code v1 en entrée.

Piste : logguer (ou faire échouer) les codes dont la troncature niveau 4 n’existe pas dans nomenclature_pruned, hors codes techniques 98.x/99.x.

Ouvert — fragilités techniques

Quatre copies de truncate_code

La même fonction est dupliquée dans quatre modules : prune-codes/src/prune/utils.py, rag-notices/src/rag_notices/utils.py, rag-notices/src/rag_notices/eval/metrics.py, rag-annotations/src/rag_annotations/utils.py. Les implémentations sont identiques aujourd’hui — rien ne le garantit demain. Seuls reconcile-llm et export-results consomment le module prune-codes comme dépendance et partagent donc réellement la logique.

Cette fonction est par ailleurs peu défensive :

Entrée Sortie Problème
13 (entier) None tout code non-str est silencieusement perdu, sans log
' 01.1.2.3.4 ' ' 01.1.2.3' pas de strip() → code invalide qui ne matche jamais le mapping
'1.1.2.3.4' '1.1.2.3' pas de normalisation du zéro non significatif (01.1.2.3 attendu)
'Reprise manuelle' inchangé aucune validation de forme (voulu ici, mais non typé)

Index Qdrant épinglé vs mapping recalculé

Le risque a changé de nature depuis que l’indexation est sortie du pipeline de classification.

Ce qui est réglé. Les collections ne portent plus de nom fixe partagé. Auparavant, coicop_lineage et coicop_annotations_without_copain_2017 étaient réutilisées par tous les runs et détruites puis recréées à chaque indexation : réindexer cassait la base que lisait un run concurrent, et le défaut skip-index=true faisait coder contre une vector DB dont personne ne connaissait la provenance. Chaque build crée désormais une collection au nom unique, désignée explicitement au lancement.

Ce qui subsiste. L’index est bâti une fois et réutilisé, alors que mapping_lvl4.parquet est recalculé à chaque run de classification à partir du CSV de nomenclature. Si ce CSV est modifié en place, l’index épinglé décrit l’ancien espace de codes et le mapping le nouveau, sans que rien ne le signale.

Le manifeste de chaque collection enregistre pour cela le chemin de la nomenclature source ; le rapprocher du CSV courant permet de détecter l’écart après coup.

Règle pratique : toute évolution de la nomenclature impose de relancer argo/index-notices-pipeline.yaml et de reporter le nouveau nom de collection dans argo/params.yaml. Publier une nomenclature révisée sous un nouveau nom de fichier plutôt que d’écraser l’ancien rend l’écart visible au lieu de silencieux.

Fuite possible entre la KB d’annotations et le jeu évalué

La KB indexée contient tous les produits annotés. Si le fichier évalué provient du même lot, le RAG y retrouve ses propres réponses et l’accuracy est gonflée — sans qu’aucune étape n’échoue.

Rien ne le vérifie, et c’est assumé : depuis que les nouveaux produits annotés fournissent un jeu indépendant, évaluer sur un lot frais est le cas normal. La garde revient à celui qui choisit le fichier à passer en label-column.

Il y avait ici une option kb-scope=train, qui réservait la moitié de la base pour permettre une évaluation honnête sur l’ancien lot. Elle a été supprimée avec le split.

Accumulation des collections Qdrant

Les noms étant uniques, rien ne supprime les anciennes collections : chaque indexation en laisse une de plus, de quelques centaines de points pour les notices à plusieurs dizaines de milliers pour les annotations. Le nettoyage est manuel et doit vérifier qu’aucun run ne référence encore la collection visée — supprimer celle qu’utilise un run de production le casse.

filter_nomenclature sans repli quand toutes les prédictions sont au niveau 1

Le filtre de nomenclature soumis au juge ne garde, au-delà du niveau 2, que les descendants des préfixes L2 effectivement prédits. Si toutes les prédictions sont au niveau 1 (pas de point dans le code), l’ensemble L2 est vide et le juge ne voit que des en-têtes de niveau ≤ 2 : il ne peut alors répondre qu’à un niveau agrégé. De même, si le bon code est dans un autre groupe de la même division, il n’est pas dans la liste.

Les métriques MLflow des branches RAG ne sont pas celles du rapport

Résolu. Il y avait trois séries d’accuracy non alignées : les deux conventions du rapport, et les métriques que chaque branche RAG loguait pour son propre compte, sur son propre périmètre et avec sa propre convention. Pour un même modèle et un même jeu, leurs chiffres différaient de ceux du rapport, sans que rien ne le signale.

Ces calculs ont disparu des étapes de classification : seule l’étape evaluate mesure, et elle n’a plus qu’une règle — tronquer la vérité et la prédiction au niveau k, comparer — voir la page evaluate.

Subsiste, et c’est la contrepartie du choix : une prédiction plus fine que la vérité est comptée fausse sous la profondeur de celle-ci. Au niveau 4, cela concerne les observations dont la vérité s’arrête avant — près d’un quart de la population. Or une vérité peu profonde peut être terminale (la réponse exacte, et la prédiction plus fine est bien une erreur) ou incomplète (l’annotateur s’est arrêté, et la prédiction peut être juste) : rien ne distingue aujourd’hui les deux cas, et ils sont comptés pareil. Le rapport affiche donc, en regard de chaque tableau d’accuracy, la part concernée à chaque niveau. Les séparer demanderait de marquer les feuilles de la nomenclature prunée — voir le point suivant.

Suggester : des codes agrégés indexés comme candidats

Le suggester contient 27 codes de niveau 2 et 56 de niveau 3 avant pruning ; après troncature et élagage, 59 de ses codes sont au niveau 2 et 1 107 au niveau 3 (sur 6 611 produits distincts). Tous sont indexés comme candidats légitimes dans la base vectorielle des RAG annotations, et le prompt impose au LLM de choisir parmi ces candidats. Le RAG annotations peut donc légitimement proposer un code très agrégé, sans que rien ne distingue une réponse volontairement agrégée d’une sous-codification.

Notons aussi que la déduplication du suggester est appliquée avant la troncature : après repli sur les codes canoniques, des doublons (produit, code) réapparaissent (2 mesurés) et n_obs n’est pas réagrégé — d’où des points quasi identiques dans Qdrant.

Feuilles et codes intermédiaires ne sont pas distingués

nomenclature_pruned.parquet contient 122 codes non terminaux (divisions, groupes, classes), tous indexés dans Qdrant comme candidats. Aucun drapeau is_leaf, aucun niveau minimal. Or 8 codes sont des feuilles dès le niveau 2 (01.3, 02.2, 02.4, 08.2, 09.8, 10.2, 10.3, 10.4) : un code de niveau 2 en sortie est donc parfois la réponse exacte, parfois une sous-codification, et rien ne permet de distinguer les deux automatiquement.

Piste : ajouter une colonne is_leaf à nomenclature_pruned.parquet (un code sans enfant dans la nomenclature prunée). Elle lèverait l’ambiguïté que la règle d’accuracy tranche aujourd’hui dans un seul sens — une vérité peu profonde et terminale rend bien fausse une prédiction plus fine, tandis qu’une vérité peu profonde non terminale est une annotation incomplète et la pénalité est alors imméritée — et permettrait de signaler une sous-codification dans le fichier livré.

Résolus par la sortie de l’évaluation

  • Détection du mode du rapport : report/main.py testait bool_and(code IS NULL) ; un seul code non nul dans un fichier de production faisait basculer tout le rapport en mode évaluation. La détection a disparu avec la dualité.
  • Accuracy regex trompeuse : classify-regex calculait une accuracy par égalité stricte entre la vérité (niveau 5) et son code prédit (niveau ≤ 4) — proche de 0 par construction, et jamais documentée. Le bloc a été supprimé.
  • rag-notices loguait une accuracy ≈ 0 en production : ce module n’a jamais connu la dualité, il évaluait donc sans condition, et la garde qui devait ignorer les lignes sans vérité terrain était commentée. Le bloc a été supprimé.

Ouvert — détails d’exploitation

  • Script d’évaluation obsolète : rag-notices/scripts/eval.py lit des colonnes (code_tprune, code_predict_tprune) que 2_run_rag.py ne produit plus, et contient des chemins S3 datés en dur. Il n’est pas appelé par Argo.
  • Config résiduelle : rag-notices/config/config.yaml déclare encore un path_raw (nomenclature brute) qu’aucun script ne lit — seule la nomenclature prunée est utilisée.
  • Pas de verrouillage des dépendances R : classify-lcs installe ses paquets au runtime via install.packages, sans renv ni lockfile : les résultats de classify-lcs ne sont pas strictement reproductibles dans le temps.

Retour à l’accueil · voir aussi Prune et Decide.