8. Decide — arbitrage LLM-as-judge
Rôle de l’étape
decide-coicop est l’arbitre final. Pour chaque produit non codé par regex, elle réunit les prédictions candidates — LCS, RAG, TTC et, optionnellement, RAG-Annotations — puis choisit le code COICOP final, soit automatiquement (consensus), soit en interrogeant un LLM qui pèse les indices.
- Code :
decide-coicop/src/decide_coicop.py(module autonomedecide-coicop/) - Commande : sous-commande
decide-coicopdedecide-coicop/main.py
Entrées
| Source | Chemin S3 | Argument |
|---|---|---|
| LCS | …/{run}/codif-lcs/raw_test_LCS.parquet |
--lcs-file |
| RAG | …/{run}/run-rag/predictions.parquet |
--rag-file |
| TTC | …/{run}/run-ttc/predictions.parquet |
--ttc-file |
| RAG-Annotations (optionnel) | …/{run}/rag-annotation/predictions.parquet |
--rag-annotations-file |
| Table de mapping prune (optionnel) | …/{run}/prune/mapping_lvl4.parquet |
--mapping-file |
| Nomenclature | CSV des codes COICOP + codes techniques (Libelle;Code) — défaut : decide-coicop/data/coicop_et_codes_techniques.csv, fichier statique du dépôt (~700 codes, tous niveaux) |
--nomenclature |
| Colonnes utilisateur | …/{run}/preprocessing/observations.parquet (prod) ou raw_test.parquet (éval) |
--extra-columns-file |
Le pipeline Argo passe systématiquement --mapping-file et --rag-annotations-file (la step dépend de prune et de run-rag-annotations).
Les prédictions sont fusionnées sur la colonne id (load_all_observations()). Le bloc RAG-Annotations (colonnes ragann_code, ragann_confidence, ragann_codable) n’est joint que si --rag-annotations-file est fourni : le 4ᵉ classifieur est donc optionnel au niveau du module/CLI (default=None), et tout le traitement aval s’adapte au nombre de modèles réellement disponibles. En revanche, le pipeline Argo le passe systématiquement (la step decide-coicop dépend de run-rag-annotations et fournit toujours --rag-annotations-file) : les 4 classifieurs sont donc la norme en production, l’optionalité servant surtout aux lancements manuels ou au débogage.
Traitement
0. Normalisation des codes (troncature + élagage)
Avant toute décision, quand --mapping-file est fourni, les codes entrants sont ramenés dans l’espace de codes pruné défini par l’étape prune (troncature niveau 4 + repli des hiérarchies linéaires, via trunc_and_prune_lvl4) :
lcs_code(le suggester LCS contient des codes de niveau 5) ;ttc_code_1..3(le TTC est entraîné sur des codes tronqués mais non élagués) ;ragann_code(le RAG annotations ne garantit le pruning que par son prompt — seule la branche RAG notices re-prune structurellement ses prédictions) ;- et, en fin de batch, la décision
llm_codeelle-même.
Consensus, arbitrage et scoring raisonnent ainsi sur des codes homogènes : 11.2 et 11.2.0 ne sont plus comptés comme un désaccord.
La vérité terrain suit un traitement différent : elle n’est pas écrasée. La sortie porte les deux versions, et c’est la version canonique que le report score.
| Colonne | Contenu | Usage |
|---|---|---|
code |
code annoté brut, tel que saisi (souvent niveau 5) | traçabilité, inspection d’un cas, détection du mode par report |
code_lvl4 |
sa forme canonique : tronquée niveau 4 puis élaguée | cible du scoring |
Conserver code brut évite un effet de bord : la détection automatique du mode par report repose sur le test « tous les code sont nuls », qui reste donc valable. Et un contrôle méthodologique peut comparer les deux colonnes pour mesurer combien d’annotations ont effectivement été ramenées à un code plus agrégé.
1. Court-circuit consensus (sans LLM)
Pour économiser temps et tokens sur les cas faciles, try_consensus_decision() tranche sans appeler le LLM quand :
- la confiance TTC top-1 est suffisante (
ttc_conf_1 ≥ 0,90), et - tous les autres codes disponibles (
lcs_code,rag_code,ragann_code) sont égaux àttc_code_1.
if float(ttc_conf) < threshold: # threshold = 0.90
return None
...
# codes non nuls parmi lcs_code, rag_code, ragann_code
if not all(c == ttc_code for c in other_codes):
return None
# → décision par consensus, confiance = 5, llm_model = "consensus"Seuls les codes réellement présents (non NA) participent au vote : si RAG-Annotations n’a pas été fourni, le consensus se joue sur les modèles restants. La décision porte alors la mention « Consensus automatique : les N modèles s’accordent… » (où N est le nombre de codes concordants).
2. Arbitrage par LLM
Sinon, le LLM est sollicité :
Filtrage de la nomenclature (
filter_nomenclature()) : au lieu d’envoyer les ~700 codes COICOP, on ne soumet au LLM que les codes « autour » des prédictions des modèles (voir le détail ci-dessous) — réduction typique de ~700 à ~50-150 lignes (gain ×4 à ×10 sur les tokens). L’option--full-nomenclaturedésactive ce filtrage et envoie la nomenclature complète.Construction du prompt (
build_prompt()) : un prompt structuré présentant le produit, les prédictions des modèles, la nomenclature filtrée et les consignes :═══ PRODUIT ═══ Libellé brut : Max garden flowers balle surprise Libellé normalisé : max garden flowers balle surprise Enseigne : Action Type de magasin : … Montant (€) : 3.99 ═══ PRÉDICTIONS DES MODÈLES ═══ [LCS] Code prédit : … (sous-chaîne …, proportion …, distance …) [RAG] Code prédit : … (confiance …, codable …) [RAG-Annotations] Code prédit : … (confiance …, codable …) ← seulement si fourni [TTC] Top 1 : … (confiance …) / Top 2 : … / Top 3 : … ═══ NOMENCLATURE COICOP (codes valides) ═══ … ═══ INSTRUCTIONS ═══ 1. Choisis UN code COICOP parmi ceux listés… 4. Attribue un score de confiance de 1 (très faible) à 5 (très élevé).Le bloc
[RAG-Annotations]n’apparaît dans le prompt que si une prédictionragann_codenon nulle existe pour l’observation.
Exemple complet prompt → réponse
Max garden flowers balle surprise
Le message system impose le format de sortie :
Tu es un expert COICOP. Réponds UNIQUEMENT avec un objet JSON valide, sans texte
avant ni après, respectant exactement ce schéma :
{"coicop_code": "<code exact de la nomenclature>",
"libelle": "<libellé copié de la nomenclature>",
"explication": "<1 phrase max>", "confiance": <entier 1-5>}
Le message user est produit par build_prompt() (valeurs des modèles illustratives) :
Tu es un expert en comptabilité nationale et en statistiques de consommation.
Ta mission est de déterminer le code COICOP le plus approprié pour un produit acheté,
en t'appuyant sur le contexte d'achat et les prédictions de plusieurs modèles automatiques.
═══════════════════════════════════════
PRODUIT
═══════════════════════════════════════
Libellé brut : Max garden flowers balle surprise
Libellé normalisé : max garden flowers balle surprise
Enseigne : action
Type de magasin : magasin discount
Montant (€) : 3.99
═══════════════════════════════════════
PRÉDICTIONS DES MODÈLES
═══════════════════════════════════════
[LCS — Longest Common Substring]
Code prédit : 09.3.1
Sous-chaîne : balle (proportion dans référence : 36%)
Distance : 0.853
[RAG — Retrieval-Augmented Generation]
Code prédit : 09.3.1.1
Confiance : 60%
Codable : True
[RAG-Annotations — RAG sur exemples déjà codifiés] ← présent seulement si --rag-annotations-file fourni
Code prédit : 09.3.1
Confiance : 72%
Codable : True
[TTC — Classificateur par apprentissage profond]
Top 1 : 09.3.1 (confiance 41%)
Top 2 : 05.1.1 (confiance 22%)
Top 3 : 09.5.4 (confiance 8%)
═══════════════════════════════════════
NOMENCLATURE COICOP (codes valides)
═══════════════════════════════════════
05 — Meubles, articles de ménage et entretien courant du foyer
05.1 — Meubles, articles d'ameublement…
… (nomenclature filtrée aux sections 05 et 09 : ~60 lignes au lieu de ~700)
09.3.1 — Jeux, jouets et passe-temps
…
═══════════════════════════════════════
INSTRUCTIONS
═══════════════════════════════════════
1. Choisis UN code COICOP parmi ceux listés dans la nomenclature ci-dessus.
2. Le code doit correspondre au niveau le plus précis qui te semble justifié.
3. Explique en 1 phrase maximum comment tu as pesé les prédictions des différents modèles
et tout autre indice (enseigne, type de magasin, montant).
4. Attribue un score de confiance de 1 (très faible) à 5 (très élevé).
Réponse du LLM (validée par le schéma Pydantic DecisionCoicop) :
{
"coicop_code": "09.3.1",
"libelle": "Jeux, jouets et passe-temps",
"explication": "Deux modèles sur trois convergent vers les jouets ; le libellé (balle surprise), l'enseigne discount et le faible montant confirment un jouet plutôt qu'un article de jardin.",
"confiance": 4
}On peut reproduire ce prompt pour n’importe quelle ligne avec le mode mono-observation : uv run main.py decide-coicop --id <uuid> --output json … (voir Lancer une étape).
En mode évaluation, le code annoté (code) n’est pas injecté dans le prompt : le juge décide à l’aveugle, comme en production (pas de fuite de la vérité terrain dans les métriques).
Reprise et coût
- Checkpoints : la sortie est sauvegardée toutes les 50 observations (
CHECKPOINT_EVERY). Si--output-fileexiste déjà, l’étape reprend automatiquement les observations non traitées — relancer la même commande (mêmerun_id/run_date) suffit après une interruption ou un quota épuisé. - Coût LLM maîtrisé : seules les lignes hors consensus déclenchent un appel ; le filtrage de nomenclature divise le prompt par 4 à 10 ; les compteurs de tokens (
llm_prompt_tokens,llm_completion_tokens) et la latence (llm_latency_s) sont conservés dans la sortie pour suivre la consommation réelle d’un run.
Exemple sur le fil rouge
| Produit | Consensus ? | Décision |
|---|---|---|
Bagu. Tradition U Blé Bretagne |
si tous les codes disponibles = ttc_code_1 et ttc_conf_1 ≥ 0,90 → oui |
code pain repris sans LLM, confiance = 5, llm_model = "consensus" |
Max garden flowers balle surprise |
les modèles divergent (jouet vs jardin) → non | le LLM arbitre à partir de l’enseigne (Action), du prix et des candidats |
Que peut renvoyer la décision ?
La sortie de l’étape n’est pas garantie d’être un « beau » code de niveau 4. Les cas possibles :
Cas nominal — un code repris de la nomenclature, à un niveau quelconque (de
01à01.1.1.1; l’instruction demande « le niveau le plus précis justifié »), avec un scorellm_confiancede 1 (très faible) à 5 (très élevé). Ce code est ensuite tronqué au niveau 4 et élagué (normalisation--mapping-file), même si le LLM a répondu au niveau 5 (le fichier--nomenclaturequi lui est soumis contient des codes de niveau 5, voir Limites et points de vigilance).Décision par consensus —
llm_code = ttc_code_1,llm_model = "consensus",llm_confiance = 5,llm_error = None(aucun appel LLM).Aucun code (
llm_codeàNA) — quand l’appel LLM échoue. La raison est alors consignée dans la colonnellm_error. Causes capturées parcall_llm_async:Cause llm_error(extrait)Quota dépassé RateLimitError: …Erreur HTTP de l’API APIStatusError(<code>): …Connexion impossible APIConnectionError: …Réponse coupée (limite de tokens) Response truncated before JSON was complete…JSON illisible JSON parsing failed: …Toute autre erreur (auth, etc.) <TypeErreur>: …(catch-all)
La réponse du LLM est validée uniquement sur sa forme par le schéma Pydantic DecisionCoicop (coicop_code est une chaîne, confiance ∈ 1–5, longueurs de libelle / explication bornées). Le code n’est pas vérifié comme appartenant réellement à la nomenclature. Les garde-fous sont uniquement incitatifs : le prompt impose de choisir parmi la liste de candidats filtrée (filter_nomenclature) et de recopier le code exactement, et la sortie est en JSON structuré. Un code inexistant (hallucination) reste donc possible en principe — la normalisation finale le tronque et l’élague, mais ne vérifie pas son appartenance à la nomenclature. C’est rare, mais à garder en tête lors des contrôles qualité.
Sorties
| Fichier | Colonnes clés |
|---|---|
…/{run}/decide-coicop/predictions.parquet |
id, les codes normalisés des 4 classifieurs (lcs_code, rag_code, ragann_code, ttc_code_1..3 + confiances), code (vérité terrain brute) et code_lvl4 (vérité canonique, éval), llm_code (normalisé), llm_libelle, llm_explication, llm_confiance, llm_model ("consensus" si court-circuit), llm_error (raison si llm_code est NA) |
➡️ Étape suivante : 9. Final output
Comment est construite la liste de codes soumise au LLM ?
filter_nomenclature()part de tous les codes prédits pour l’observation (lcs_code,rag_code,ragann_code,ttc_code_1,ttc_code_2,ttc_code_3, après suppression desNA) et en extrait deux ensembles de préfixes :"01.1.2.3"→"01";"01.1.2.3"→"01.1".Puis, pour chaque code de la nomenclature complète, la décision de le garder dépend de son propre niveau de profondeur :
0101.1,01.201.1.2,01.1.2.3L’asymétrie est volontaire :
01.1,01.2,01.3…), même celles dont aucun code fin ne sera listé. Cela donne au LLM une vue d’ensemble de la section pour réorienter si les modèles se sont trompés de sous-section.Exemple — si les modèles prédisent
09.3.1et05.1.1, alorsL1 = {09, 05}etL2 = {09.3, 05.1}. La liste soumise contient : les en-têtes05et09; toutes les sous-sections de05et09(05.1,05.2, …,09.1, …,09.3, …) ; mais, au niveau fin, uniquement les codes sous05.1.*et09.3.*. Le LLM reste donc libre de choisir un code précis dans ces deux sous-sections, de basculer vers une autre sous-section des sections 05/09, ou de remonter à un niveau plus agrégé.Cas limites : si aucune prédiction n’est disponible (tous les codes
NA), ou sifull=True(--full-nomenclature), la nomenclature complète est renvoyée.Sortie structurée : le LLM répond en JSON validé par le modèle Pydantic
DecisionCoicop:Le LLM par défaut est
gemma4-26b-moe(decide-model), avecdecide-concurrency=5appels parallèles dans le pipeline (20 par défaut en CLI). Chaque appel est borné (temperature=0,max_tokens=512, réponse forcée en JSON).