Le pipeline codif-coicop-bdf

Fonctionnement, sorties et pistes de consolidation — pour les mainteneurs

2026-06-10

Le problème en bref

L’enquête Budget de Famille collecte les tickets de caisse des ménages ; chaque ligne est un libellé court et bruité à rattacher à la nomenclature COICOP :

Bagu. Tradition U Blé Bretagne · Nescafé DLC Gust ESP 30 cap 165g · Ticket CB · Illisible

  • Codes hiérarchiques 01 → 01.1.1.1 (le pipeline s’arrête au niveau 4)
  • Codes techniques 98.x / 99.x pour les lignes non-produit
  • Codage manuel coûteux → le pipeline automatise et trace chaque décision

Architecture

flowchart LR
  input[/"CSV d'entrée"/] --> PRE[build-datasets]
  PRE -- observations --> REG[classify-regex]
  PRE -.->|"suggester.parquet"| LCS
  REG -- raw_test_without_regex --> LCS[classify-lcs]
  REG -- raw_test_without_regex --> PRU[prune-codes]
  REG -- raw_test_without_regex --> TTC[classify-ttc]
  PRU -- annotations_test_pruned --> RAG[classify-rag-notices]
  PRU -- annotations_test_pruned --> RAGA[classify-rag-annotations]
  VDB[/"collection Qdrant notices"/] -.-> RAG
  VDBA[/"collection Qdrant annotations"/] -.-> RAGA
  LCS -- raw_test_LCS --> DEC[reconcile-llm / reconcile-sirus]
  RAG -- predictions --> DEC
  RAGA -- predictions --> DEC
  TTC -- predictions --> DEC
  DEC -- predictions --> FIN[export-results]
  REG -- REGEX_pred --> FIN
  DEC --> REP[report]
  FIN --> out[/"CSV livré"/]
  REP --> html[/report.html/]
  style VDB stroke-dasharray: 5 5
  style VDBA stroke-dasharray: 5 5

  • Argo Workflows ; chaque étape = un conteneur qui git clone (paramètre git-branch, défaut main) + uv sync --locked
  • Un dossier S3 par run : …/workflow_runs/{run_date}/{run_id}/<étape>/ ; run_id/run_date passés en CLI partout
  • Trois workflows : codif-pipeline.yaml (codification ; un seul mode, input_file obligatoire), index-notices-pipeline.yaml et index-annotations-pipeline.yaml (construction des collections Qdrant)
  • Les nœuds en pointillé ne sont pas des étapes : ce sont des collections bâties à l’avance, désignées par les paramètres obligatoires classify-rag-*-collection

Pourquoi l’indexation est sortie du pipeline

Avant : index-notices / index-annotations étaient deux étapes du DAG, sautables par skip-index=true, écrivant dans des collections au nom fixe (coicop_lineage, coicop_annotations_without_copain_2017).

Deux problèmes :

  • coût — l’embedding de toute la nomenclature (et de toute la base d’exemples) repayé à chaque codification, d’où le skip-index : un contournement, pas une solution ;
  • corruption silencieuse — une réindexation détruisait la base que lisait un run concurrent, et rien n’indiquait quelle version était embarquée.

Après : deux workflows autonomes, une collection au nom unique par indexation ({base}__{run_date}__{run_id}[__sampleN]), un manifeste JSON à côté (modèle d’embedding, dimension, stratégie, points, SHA git) que classify-rag-* relit et valide au démarrage. skip-index n’existe plus : il n’y a plus rien à sauter.

C’est le patron déjà en place pour classify-ttc-model-uri et reconcile-sirus-model-uri : artefact coûteux produit hors du pipeline, identifiant recopié dans argo/params.yaml.

La logique de conception

Aucune méthode ne couvre tout — le pipeline en croise quatre, puis arbitre :

Brique Forte sur… Aveugle sur…
Règles regex libellés standardisés tout le reste (voulu)
LCS quasi-doublons du stock manuel produits jamais vus
RAG sens, libellés inédits coût LLM, retrieval raté
TTC volume grande distribution hors supermarché

L’arbitre LLM-as-judge ne travaille que sur les désaccords : le consensus court-circuite l’appel LLM. C’est le compromis coût / qualité central du pipeline.

build-datasets + classify-regex

build-datasets (build-datasets/main.py) :

  • mappage de colonnes configurable (NAT_DEP → raw_product…) ; id UUID par ligne
  • deux normalisations : l_pr_product (légère → RAG/TTC) et s_pr_product (forte → regex/LCS)
  • enseigne → shop_type_name (jointure liste_magasins.csv) ; retrait des non-codables
  • écrit observations.parquet (à coder) + annotations_full.parquet (la KB) + le pool suggester.parquet

classify-regex (classify-regex/, 43 règles dans config/rules.yaml) :

  • s’applique sur s_pr_product ; règles ancrées (^…$), périmètre volontairement étroit
  • match → REGEX_pred.parquet, la ligne sort du circuit ; sinon → raw_test_without_regex.parquet
  • 2 règles renvoient le littéral Reprise manuelle — il traverse tout le pipeline jusqu’au livrable

classify-lcs (R + C++)

Plus longue sous-chaîne commune entre le libellé et chaque entrée du pool suggester :

\[\text{distance} = 1 - \frac{maxlen}{\max(n_1, n_2)}\]

  • égalités strictes appariées d’abord (distance 0) ; ensuite distance minimale, départage par proportion couverte
  • les paires distance > 0,8 sont écartées dès le noyau C++ (classify-lcs/C/distance_gcd_batch_cpp.cpp) — jamais candidates
  • produit cartésien jeu × pool : calcul par lots parallélisés (furrr + Rcpp)
  • transmet à l’arbitre : lcs_code, lcs_distance, lcs_substring, lcs_prop

⚠ La couverture dépend du pool : un libellé loin de tout COPAIN n’a pas de bon candidat.

Branches RAG

prune-codes (module dédié, dans les trois workflows, drapeau --only {all,nomenclature,kb}) — supprime le niveau 5, replie les hiérarchies linéaires sur un code canonique (+ table de mapping) ; produit tous les artefacts prunés, l’aval ne fait que lire.

Indexation (hors pipeline de codification) — qwen3-embedding-8b (dim 4096) → Qdrant, cosinus, collection au nom unique + manifeste JSON :

  • index-notices-pipeline.yaml → notices COICOP prunées (coicop_notices__…)
  • index-annotations-pipeline.yaml → produits déjà annotés + suggester (coicop_annotations__…) ; aucun input_file : la KB, ce sont les produits annotés, pas les produits à classer — d’où l’absence de classify-regex dans la chaîne

Codification — classify-rag-notices / classify-rag-annotations : embedding de l_pr_product → top-10 Qdrant → prompt (enseigne + prix + candidats) → gemma4-26b-moe, T=0.1, 32 appels parallèles

  • ⚠ Le gabarit de prompt n’est pas dans le dépôt : géré dans Langfuse (prompt-multi-level v12)
  • retrieved_codes.parquet permet d’auditer le retrieval indépendamment de la génération

classify-ttc

Classifieur neuronal plat (coicop-bdf-classifier/, package torchTextClassifiers) :

  • tokenisation n-grammes de caractères (type fastText) — robuste aux abréviations
  • entraîné sur des données de caisses (grande distribution), fine-tuné sur le test BDF 2024 — sous-commandes train-basic / fine-tune-basic, lancées à la main, hors Argo (⚠ argo/ttc-pipeline.yaml est un workflow de prédiction, et il ne passe pas argo lint)
  • prédiction sur l_pr_product, renvoie ttc_code_1..10 + ttc_conf_1..10
  • modèle chargé depuis MLflow : paramètre de workflow classify-ttc-model-uri (URI d’artefact, propagé au rapport pour traçabilité)

⚠ Biais connu : très fiable en supermarché, moins ailleurs — compensé par l’arbitrage.

reconcile-llm — 1. le consensus

try_consensus_decision() (reconcile-llm/src/reconcile_llm.py) tranche sans appel LLM quand :

  • ttc_conf_1 ≥ 0,90 (seuil threshold codé en dur), et
  • tous les codes disponibles (lcs_code, rag_code) égalent ttc_code_1

→ llm_model = "consensus", llm_confiance = 5, zéro token.

C’est le premier levier de coût : seules les lignes hors consensus paient un appel LLM.

reconcile-llm — 2. l’arbitrage

Pour chaque ligne en désaccord :

  • filter_nomenclature() : seules les sections L1/L2 des prédictions partent dans le prompt — ×4 à ×10 de tokens en moins (~700 → 50–150 codes)
  • prompt structuré : produit + contexte d’achat + les 3 avis + nomenclature filtrée
  • réponse JSON forcé, validée par le schéma Pydantic DecisionCoicop (coicop_code, libelle, explication, confiance 1–5) ; T=0, max_tokens=512
  • erreurs capturées, jamais bloquantes : RateLimitError, APIStatusError, troncature, JSON invalide → llm_code = NA + raison dans llm_error

Exploitation :

  • checkpoint toutes les 50 obs ; si --output-file existe → reprise automatique (relancer la même commande après un quota épuisé)
  • debug d’une ligne : uv run main.py reconcile-llm --id <uuid> --output json …
  • tokens et latence conservés par ligne (llm_prompt_tokens, llm_latency_s)

Les sorties d’un run

s3://projet-budget-famille/data/workflow_runs/{run_date}/{run_id}/
├── build-datasets/            observations.parquet · annotations_full.parquet · suggester.parquet
├── classify-regex/            REGEX_pred.parquet · raw_test_without_regex.parquet
├── classify-lcs/              raw_test_LCS.parquet     (lcs_code, distance, substring, prop)
├── prune-codes/               nomenclature_pruned · mapping_lvl4 ·
│                              annotations_{train,test}_pruned · suggester_pruned
├── classify-rag-notices/      predictions.parquet      (rag_code, confidence, codable)
│                              retrieved_codes.parquet  (audit du retrieval)
├── classify-rag-annotations/  predictions.parquet      (code_predict, confidence, codable)
├── classify-ttc/              predictions.parquet      (ttc_code_1..10, ttc_conf_1..10)
├── reconcile-llm/             predictions.parquet      (llm_code, llm_confiance 1–5,
│                                                        llm_model, llm_error, tokens, latence)
├── export-results/            {nom_du_fichier_d_entrée}  ← le livrable
└── report/                    report.html

s3://projet-budget-famille/data/vector_db_manifests/{collection_name}.json
                                                   ← un manifeste par collection Qdrant

Tout est inspectable en place (DuckDB read_parquet('s3://…')) — premier réflexe de debug.

Le livrable et le rapport

export-results : fichier d’entrée d’origine + 3 colonnes — la décision LLM prime, sinon le code regex, sinon vide.

prediction_source Lecture
consensus les 3 modèles d’accord — le plus sûr
regex règle déterministe (inclut Reprise manuelle)
llm arbitrage — croiser avec llm_confiance et llm_comment
(vide) non codé : échec LLM, raison dans llm_error (fichier reconcile-llm)

report (rapport de production, sans vérité terrain) : distribution par niveau, sources, meilleur prédicteur par code, désaccords totaux.

evaluate (facultative, déclenchée par label-column) : accuracy par niveau/groupe/source, confusion, calibration, recall de retrieval, coût de l’arbitrage.

Traçabilité : MLflow (paramètres dont ttc_model_uri, métriques, HTML en artefact) + traces Langfuse (classify-rag-notices).

Opérations courantes

# 1. Construire les collections Qdrant (une fois, ou quand les sources changent).
#    Chacune imprime la ligne à recopier dans argo/params.yaml.
argo submit argo/index-notices-pipeline.yaml
argo submit argo/index-annotations-pipeline.yaml       # la KB = toute la base annotée

# 2. Pipeline complet (test rapide : -p sample-observations=100)
argo submit argo/codif-pipeline.yaml --parameter-file argo/params.yaml

# Relancer UNE étape : --entrypoint + run_id/run_date du run d'origine (sinon dossier S3 vide !)
argo submit argo/codif-pipeline.yaml --entrypoint reconcile-llm \
  -p run_id=codif-abc12 -p run_date=2026-03-20 \
  -p reconcile-llm-model=gemma4-26b-moe -p reconcile-llm-concurrency=5

# Reprendre le DAG là où il a échoué
argo retry codif-abc12

Pièges connus :

  • classify-rag-*-collection sont obligatoires (défaut vide) : oubliés, l’étape échoue en quelques secondes — voulu, plutôt qu’un repli silencieux sur l’index d’un autre run
  • en --entrypoint, l’input du template RAG s’appelle collection-name ; report réclame en plus step-timings — valider avec --dry-run
  • -p skip-index=... et -p sample-annotations=... n’existent plus. Argo n’en dit rien : un -p inconnu est accepté et ignoré. Le run part donc sans échantillonnage, ou en réindexant… rien. Utiliser sample-observations et les workflows d’indexation
  • chaque étape est aussi exécutable en local (uv / Rscript) avec les bonnes variables d’env

→ guide complet : « Lancer une étape individuellement »

Points de vigilance et pistes

Points de vigilance actuels

Constats vérifiés dans le code, à garder en tête :

  • llm_code n’est jamais validé contre la nomenclature : la validation Pydantic est purement formelle → hallucination (code inexistant) possible dans le livrable
  • Pas de retry/backoff sur les erreurs LLM transitoires : un RateLimitError laisse la ligne en échec ; la « relance manuelle + reprise checkpoint » fait office de retry
  • Prompt RAG hors dépôt (Langfuse v12) : une modif de prompt n’est ni versionnée ni revue avec le code
  • MLflow éclaté : classify-rag-notices loggue dans l’expérience test, classify-rag-annotations dans rag-annotation, report dans codif-coicop-eval
  • argo/ttc-pipeline.yaml invalide et mal nommé : workflow de prédiction, échoue à argo lint

Réglés depuis : collection Qdrant unique écrasée à chaque run (→ noms uniques + manifestes) ; branches git incohérentes (→ paramètre git-branch partout) ; vector_index.py dupliqué (→ module common/) ; la dualité prod/éval — un mode dérivé du seul fait qu’input_file soit vide ou non, testé à treize endroits, avec des métriques dans trois étapes de classification (→ un seul mode + l’étape facultative evaluate).

Pistes — fiabilité

  1. Valider predicted_code contre la nomenclature en sortie de reconcile-llm : flag code_invalide (a minima) ou re-prompt automatique du LLM avec l’erreur
  2. Retry avec backoff exponentiel sur RateLimitError / APIConnectionError dans call_llm_async — le checkpoint existant rend ça peu risqué
  3. Boucle vertueuse : réinjecter les codifications validées par les gestionnaires dans le pool suggester (LCS s’améliore) et dans le fine-tuning TTC périodique
  4. Suivi des non-codés : seuil d’alerte sur la part de llm_error / lignes vides par run (métrique MLflow déjà disponible)

Pistes — industrialisation

  1. CI GitHub Actions : smoke tests uv run … --help par module, argo lint, rendu du site docs (et publication GitHub Pages)
  2. Factoriser les manifests Argo : envFrom: secretRef au lieu de ~20 secretKeyRef copiés-collés par étape ; ou un WorkflowTemplate partagé
  3. MLflow Model Registry pour le TTC : alias (@champion) au lieu de l’URI d’artefact brut mlflow-artifacts:/10/cacf26… codée dans le YAML
  4. Unifier les branches clonées (tout sur main, ou tag/commit épinglé par release) et mettre le README à jour (ou pointer vers ce site)
  5. Harmoniser MLflow : une seule expérience (ou convention claire classify-rag-notices vs report)

Pistes — méthode

  1. Jeu d’évaluation permanent : un échantillon annoté stable + un run de codif-pipeline.yaml avec -p label-column=code à chaque changement de modèle, prompt ou règle — comparaison directe dans MLflow (l’infra existe déjà)
  2. Vendoriser une copie du prompt Langfuse dans le dépôt (fallback + revue de code)
  3. Remplacer le littéral Reprise manuelle par un code technique dédié (ex. 97.x) pour garder predicted_code typé « code »
  4. Purge / cycle de vie des dossiers workflow_runs/ — et des collections Qdrant, dont le nombre croît maintenant d’une par indexation (les manifestes sur S3 donnent l’inventaire)

Pour contribuer

Merci — questions ?