3. Prune — troncature & élagage

Rôle de l’étape

L’étape prune-codes (module dédié prune-codes/, entrée prune-codes/scripts/main.py) définit l’espace de codes commun à tout le pipeline : elle tronque la nomenclature au niveau 4 et replie les hiérarchies linéaires sur un code canonique, puis produit les artefacts prunés que l’aval se contente de lire. Un seul algorithme, un seul point de vérité.

C’est la seule étape présente dans les trois workflows Argo. Elle y tourne à chaque fois, avec un périmètre différent — voir le drapeau --only ci-dessous.

Note

Dans le pipeline de codification, cette étape s’exécute après classify-regex et n’est jamais sautée : elle produit à chaque run le jeu à coder pruné et la table de mapping dont dépendent classify-rag-notices, classify-rag-annotations, reconcile-llm et export-results.

La nomenclature source

Fichier brut : s3://projet-budget-famille/data/coicop-2018_envoi_rmes_20251022.csv (COICOP 2018 / ECOICOP v2, export RMES, 949 codes) :

Niveau Type Nombre
1 Division 13
2 Groupe 52
3 Classe 146
4 Sous-classe 298
5 Poste 440

Les codes techniques BdF (98.x, 99.x) ne font pas partie de ce fichier : ils traversent le pruning inchangés (voir Limites et points de vigilance).

L’algorithme

1. Troncature — suppression du niveau 5

Les 440 lignes de type Poste sont retirées avant l’élagage (949 → 509 codes) : le niveau 5 est jugé trop granulaire pour la codification automatique, et la nomenclature présente des incohérences au-delà du niveau 4. Tout code plus profond rencontré ensuite dans le pipeline est tronqué à 4 segments (truncate_code(code, level=4) dans prune-codes/src/prune/utils.py).

2. Élagage — repli des hiérarchies linéaires

Un parent qui n’a qu’un seul enfant lui est sémantiquement équivalent : 100 % de son contenu est dans cet enfant. La chaîne d’équivalences est alors repliée sur le code le plus agrégé, dit code canonique (code_parent_equivalent). Deux cas de figure :

  • chaîne strictement linéaire : 01.3 → 01.3.0 → 01.3.0.0 (un seul enfant à chaque niveau) : les trois codes sont équivalents, seul 01.3 est conservé ;
  • parent à enfant unique dont l’enfant se ramifie : 11.2 n’a qu’un enfant 11.2.0, qui a lui-même 4 sous-classes. 11.2 ≡ 11.2.0 (le repli s’arrête là : les 4 sous-classes, elles, ne sont pas équivalentes entre elles). 11.2.0 est replié sur 11.2, ses enfants survivent et leur parent est remappé sur 11.2.

Les codes concernés sont essentiellement les codes en .0 (« non subdivisé ») : 01.2.1.0 → 01.2.1, 02.2.0.0 → 02.2, 10.4.0.0 → 10.4, 11.2.0 → 11.2, etc.

AvertissementCorrectif de juillet 2026

Jusqu’en juillet 2026, l’implémentation ne retenait une relation parent→enfant que si l’enfant avait lui-même au plus un enfant — le second cas de figure ci-dessus était donc oublié. Sept codes 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 : une prédiction 11.2.0 et une annotation 11.2 étaient comptées comme un désaccord. C’est corrigé (avec tests unitaires, prune-codes/tests/test_pruning.py).

Chiffres clés (nomenclature du 2025-10-22)

  • nomenclature prunée : 420 codes (13 divisions, 52 groupes, 131 classes, 224 sous-classes) ;
  • 89 codes repliés sur un code canonique ; aucun repli ne remonte au-delà du niveau 2 ;
  • la table mapping_lvl4.parquet contient les 89 replis plus les lignes identité des codes canoniques retenus ;
  • conséquence méthodologique importante : 107 des 440 Postes (24 %) ont un code canonique moins profond que le niveau 4 (95 au niveau 3, 12 au niveau 2). Un code final de niveau 2 ou 3 peut donc être la réponse exacte, pas une sous-codification. C’est aussi la part de la population sur laquelle le rapport d’évaluation compte fausse au niveau 4 une prédiction descendue à 4 segments — le rapport l’affiche niveau par niveau.

Application aux annotations et au suggester

prune_annotation_lvl4 (prune-codes/src/prune/pruning.py) applique aux codes annotés exactement la même normalisation que la nomenclature :

  1. troncature au niveau 4 ;
  2. remplacement par le code canonique via mapping_lvl4 (les codes hors mapping restent inchangés) ;
  3. resynchronisation du libellé coicop depuis la nomenclature brute (les codes spéciaux 98.x/99.x, absents des notices, gardent leur libellé d’origine).

Le suggester (codes au niveau 5, non prunés à la source) reçoit le même traitement.

Le drapeau --only

Les trois workflows n’ont pas besoin des mêmes artefacts, et surtout n’ont pas les mêmes entrées disponibles. Le drapeau --only {all,nomenclature,kb} (défaut all, comportement historique inchangé) découpe l’étape en conséquence :

Valeur Workflow Ce qui est produit Pourquoi s’arrêter là
all codif-pipeline.yaml tout le pipeline de codification a besoin du jeu à coder pruné et du mapping
nomenclature index-notices-pipeline.yaml nomenclature prunée + mapping les étapes suivantes liraient des sorties de classify-regex, qui n’existent pas dans un run d’indexation autonome
kb index-annotations-pipeline.yaml nomenclature + mapping + KB + suggester un run d’indexation construit une base d’exemples, pas une codification : le « jeu à coder » n’y a pas de sens

Les trois variantes exécutent toujours l’étape 1 : la nomenclature brute et la table de mapping qu’elle calcule en mémoire sont consommées par les étapes suivantes, qui ne peuvent donc pas s’en passer.

Trois options complètent le drapeau, utilisées par le pipeline d’indexation des annotations :

Option Défaut Rôle
--annotations-train classify-regex/raw_train_without_regex.parquet source de la KB. L’indexation y met build-datasets/annotations_full.parquet : la KB, ce sont les produits déjà annotés, et elle n’a pas à être filtrée par les regex
--suggester-path le CSV brut COPAIN source du suggester. L’indexation y met build-datasets/suggester.parquet, déjà préprocessé
--suggester-product-col suggester.source_product_col colonne texte de cette source (l_pr_product pour le suggester préprocessé)

Sorties

Par run, sous …/{run}/prune-codes/ :

Fichier Contenu Produit avec Consommé par
nomenclature_pruned.parquet nomenclature prunée (niveaux 1 à 4, 420 codes) all, nomenclature, kb index-notices
mapping_lvl4.parquet table code → code_parent_equivalent all, nomenclature, kb classify-rag-notices (re-pruning), reconcile-llm (normalisation), export-results (garde-fou)
annotations_train_pruned.parquet KB d’annotations prunées all, kb index-annotations
annotations_test_pruned.parquet jeu à coder pruné all classify-rag-notices et classify-rag-annotations
suggester_pruned.parquet suggester pruné all, kb index-annotations
Note

Dans le pipeline de codification, l’étape tourne en --only all : elle y écrit donc aussi annotations_train_pruned.parquet et suggester_pruned.parquet, que plus aucune étape de ce workflow ne lit depuis la sortie de l’indexation. Ces deux fichiers ne coûtent presque rien et gardent le comportement par défaut de l’étape identique à ce qu’il était.

Réapplications en aval

L’espace de codes pruné est ré-imposé à trois endroits, pour rattraper tout code non canonique produit par un modèle (un LLM peut halluciner un code de niveau 5) :

Où Sur quoi Code
classify-rag-notices code_predict et les codes retrouvés rag-notices/scripts/2_run_rag.py (étape 5)
reconcile-llm lcs_code, ttc_code_1..3, ragann_code et la décision llm_code (sur place) ; la vérité terrain dans une colonne dédiée code_lvl4 (code reste brut) reconcile-llm/src/reconcile_llm.py (--mapping-file)
export-results predicted_code (garde-fou final, idempotent) export-results/main.py

Le module prune-codes est consommé comme dépendance Python locale par reconcile-llm et export-results ([tool.uv.sources] prune-codes = { workspace = true }) : la logique n’est jamais dupliquée.

Exemple sur le fil rouge

Bagu. Tradition U Blé Bretagne relève d’un Poste (niveau 5) de la sous-classe 01.1.1.3 (Pain et produits de boulangerie). Après troncature niveau 4, la cible devient 01.1.1.3 ; ce code n’appartient à aucune hiérarchie linéaire, il reste tel quel. À l’inverse, un paquet de cigarettes annoté 02.3.0.1.1 (niveau 5) est tronqué en 02.3.0.1 (Cigarettes) — et si un modèle prédisait la classe 02.3.0 (Tabac, non subdivisé), celle-ci serait repliée sur son groupe équivalent 02.3, seul survivant des deux dans la nomenclature prunée.

➡️ Étape suivante : 4. Codif LCS