11. Evaluate
Rôle de l’étape
evaluate mesure la qualité d’un run. C’est la dernière étape du DAG, et elle est facultative : elle ne tourne que si le fichier d’entrée du run portait une colonne d’étiquettes.
Code :
evaluate/— entréeevaluate/main.py, gabaritevaluate/evaluation_report.qmdSortie :
…/{run}/evaluate/evaluation_report.html, et les métriques dans MLflowCondition Argo :
when: '"{{workflow.parameters.skip-eval}}" != "true" && "{{workflow.parameters.label-column}}" != ""'
Sans label-column, la tâche apparaît Skipped dans Argo et le reste du run est inchangé. C’est le cas nominal de production.
Pourquoi une étape séparée
Le pipeline a été construit pour être évalué, et la mesure s’était installée un peu partout : un mode production/évaluation testé à treize endroits, et trois étapes de classification calculant leurs propres métriques. L’une d’elles, rag-notices, les calculait même en production — la garde qui devait ignorer les lignes sans vérité terrain était commentée — et loguait donc une accuracy ≈ 0 dans MLflow, sans que rien n’échoue.
Rassembler la mesure en une étape finale rend le reste du pipeline monomode, et rend l’évaluation explicite : on la demande, elle ne se déclenche pas toute seule.
Déclenchement
| Paramètre | Défaut | Rôle |
|---|---|---|
label-column |
(vide) | Nom de la colonne d’étiquettes dans le fichier d’entrée. C’est le déclencheur. build-datasets la recopie dans code, qui la porte jusqu’au bout de la chaîne |
eval-source-column |
(vide) | Nom de la colonne de provenance du produit. Ajoute la ventilation par source. Ne restreint jamais le périmètre codé |
skip-eval |
false |
Échappatoire : saute l’étape même si label-column est renseignée |
eval-experiment |
codif-coicop-eval |
Expérience MLflow |
argo submit argo/codif-pipeline.yaml --parameter-file argo/params.yaml \
-p label-column=code -p eval-source-column=sourceLes entrées : le parquet de conciliation ne suffit pas
Il porte les quatre classifieurs et la décision finale, mais trois choses lui manquent : les drapeaux parsed / codable et les codes récupérés par les RAG, tous retirés à la fusion, et les lignes captées par la regex, qui n’entrent jamais dans la chaîne d’arbitrage.
| Entrée | Ce qu’elle apporte |
|---|---|
…/{run}/reconcile-{llm,sirus}/predictions.parquet |
les 4 classifieurs, leurs confiances, et la conciliation |
…/{run}/classify-rag-notices/predictions.parquet |
parsed, codable — sans eux, pas de régimes de réponse |
…/{run}/classify-rag-notices/retrieved_codes.parquet et …/{run}/classify-rag-annotations/predictions.parquet |
les codes récupérés : recall du retriever et accuracy de génération conditionnelle |
le livrable d’export-results, plus build-datasets/observations.parquet et prune-codes/mapping_lvl4.parquet |
l’accuracy de bout en bout, regex comprise — le chiffre métier |
…/{run}/build-datasets/input_counts.parquet |
le nombre de lignes du fichier d’entrée et les deux retraits qui l’en séparent. Facultatif : un run antérieur ne l’a pas, l’entonnoir démarre alors plus bas au lieu d’échouer |
…/{run}/classify-regex/REGEX_pred.parquet et …/{run}/classify-regex/raw_test_without_regex.parquet |
les deux moitiés du partage opéré par la regex : ce qu’elle a tranché, et ce qui est entré dans la chaîne. Seules les lignes sont comptées |
…/{run}/prune-codes/nomenclature_pruned.parquet |
les libellés des divisions COICOP (01 → « Produits alimentaires… »), lus sur les codes à un seul segment. La nomenclature du run, pas le CSV source : les libellés affichés sont ceux contre lesquels ce run a été codé. Absente, les tableaux affichent le code seul |
Les artefacts de retrieval étaient écrits à chaque run et jamais relus.
export-results retire la vérité terrain du livrable : code et code_lvl4 sont dans son PIPELINE_COLS. Elle est donc rejointe depuis observations.parquet sur id, puis rendue canonique par mapping_lvl4 — sans cette dernière étape, une prédiction canonique juste serait comptée fausse.
Contenu du rapport
- Métadonnées du run : l’identité du run — fichier d’entrée, parquet de conciliation lu, colonne de vérité terrain effectivement scorée, conciliation retenue ;
- Ce que devient le fichier d’entrée : l’entonnoir des volumes, du CSV brut jusqu’aux observations mesurables. Il sépare les trois populations que le pipeline traite différemment mais que rien ne montrait ensemble — les lignes écartées d’emblée (produit non codable, libellé vide après nettoyage), celles tranchées par la regex, qui sortent du circuit avant tout classifieur, et celles qui entrent dans la chaîne de codification, seule population sur laquelle portent les accuracies qui suivent. Un run échantillonné y voit apparaître la ligne correspondante, sans quoi l’entonnoir ne s’additionnerait pas ;
- Accuracy globale par niveau COICOP pour LCS, RAG notices, RAG annotations, TTC et la conciliation (tableau + graphique), et la profondeur de la vérité terrain, qui dit où une prédiction plus fine que l’annotation est comptée fausse ;
- Accuracy du fichier livré, regex comprise — le chiffre métier, ventilé par source de décision et assorti d’une ligne « hors regex » ;
- Accuracy par groupe :
shop,shop_type_name, décile debudget, niveau COICOP 2, et surtout la ventilation par division COICOP prédite — le regroupement se fait sur ce que la chaîne annonce, pas sur la vérité. C’est la lecture opérationnelle : « quand elle annonce de l’alimentaire, à quelle fréquence a-t-elle raison ? », la seule question qui se pose devant un fichier livré, quand la vérité n’est pas connue. Deux taux par division, la bonne division puis le code complet — le second ne peut pas dépasser le premier. La ventilation symétrique, par division vraie, répondrait à l’autre question (« parmi les vrais produits alimentaires, combien retrouve-t-on ? ») et n’est plus affichée. Un second tableau pose la même question à chaque classifieur séparément : une division où toutes les sources s’effondrent est un problème de nomenclature, une division où une seule décroche est un problème de brique. Chaque colonne y est groupée par sa propre prédiction, d’où lendans chaque cellule ; - Accuracy par provenance du produit (ticket de caisse, carnet papier, saisie assistée) : volume et part de chaque source, couverture, accuracy aux niveaux 1, 2 et 4, et écart en points contre l’ensemble. Le découpage par niveau est le plus parlant — une saisie pauvre laisse souvent le niveau 1 intact et s’effondre au niveau 4 : on sait que c’est de l’alimentaire, pas quel poste. Un second tableau ventile par classifieur, pour distinguer une source qui dégrade toute la chaîne d’une source qui met une brique en défaut. Exige deux paramètres, tous deux vides par défaut :
source_column(la colonne du fichier d’entrée qui porte la provenance, lue parbuild-datasets) eteval-source-column: "source"(la dimension d’analyse du rapport) ; - Distorsion de distribution au niveau 1 : la répartition des produits entre divisions COICOP ressemble-t-elle à la vraie ? Question distincte de l’accuracy, et qui n’en découle pas — des erreurs qui se compensent laissent la répartition intacte. Le chapitre donne le solde par division (sur-codée / sous-codée) et trois chiffres : les erreurs brutes, la distance en variation totale — la part des produits qu’il faudrait déplacer pour retrouver la vraie répartition —, et le taux de compensation, la part des erreurs qui s’annulent entre elles. La divergence de Kullback-Leibler est donnée en complément, à comparer entre runs et non dans l’absolu ;
- Accuracy par régime de décision : une part des lignes est décidée sans que la conciliation choisisse quoi que ce soit — court-circuit consensus pour le juge LLM, candidat unique pour SIRUS (quand les classifieurs s’accordent, il ne reste qu’un candidat et l’argmax ne peut que le retenir). Seule la colonne « arbitré » / « choix réel » mesure la conciliation. Un tableau complémentaire l’encadre entre un plancher (tirer un candidat au hasard) et un plafond (au moins un candidat était juste), et donne la part du réalisable effectivement captée ;
- Analyse d’erreurs : matrice de confusion (top 20 paires au niveau 4), calibration (accuracy par tranche de confiance, AUROC) ;
- Les règles du modèle SIRUS, en clair, quand c’est la conciliation du run ;
- Calibration du score
llm_confiance— section propre au juge LLM, absente du rapport quand le run a été concilié par SIRUS (voir plus bas) ; - Traçabilité : URL du run MLflow de chaque brique et template de prompt Langfuse de chaque RAG ;
- Décomposition interne de chaque brique — voir la section suivante ;
- Accord entre les classifieurs et valeur ajoutée de l’arbitrage ;
- Décisions de conciliation hors des prédictions des classifieurs ;
- Récapitulatif.
Le rapport ne parle que de la conciliation qui a tranché
Les deux conciliations sont exclusives, donc un run porte llm_code ou sirus_code, jamais les deux. Le rapport le déduit de ses colonnes — pas du --reconciliation qu’on lui a passé : il décrit le parquet qu’il lit, ce qui le rend juste même sur un relancement manuel mal paramétré.
Conséquence : sur un run SIRUS, le juge LLM n’est pas mentionné. La section qui lui est propre — la calibration du score entier 1 à 5 (llm_confiance) — disparaît, titre compris, au lieu d’afficher un « sans objet » ; la ligne de métadonnées llm_error et l’étape reconcile-llm du tableau de traçabilité disparaissent aussi. Symétriquement, un run LLM ne parle pas de SIRUS.
Le découpage par régime de décision fait exception : il ne disparaît pas, parce qu’il a un équivalent SIRUS. Les deux conciliations décident une part de leurs lignes sans rien choisir, mais ne le marquent pas dans la même colonne — llm_model d’un côté, sirus_n_candidats de l’autre — et la section s’adapte.
Ce qui reste dans les deux cas : tout le reste. Le LLM classifieur (les deux RAG) n’est pas concerné — c’est une autre brique, elle tourne quelle que soit la conciliation.
Sur un run LLM dont le parquet ne porte pas llm_confiance, la section ne se tait pas : elle le dit. Le silence ferait passer une anomalie pour une conciliation SIRUS.
Sur quelle vérité terrain le rapport score-t-il ?
Sur code_lvl4, la version canonique de l’annotation produite par reconcile-llm — pas sur code brut. Les prédictions vivant dans l’espace de codes pruné, la vérité doit y vivre aussi : sinon une prédiction canonique exacte (01.3 face à une annotation 01.3.0.0.x) est comptée fausse, un biais qui touche ~24 % des Postes de la nomenclature. Les runs antérieurs à l’introduction de code_lvl4 retombent automatiquement sur code ; la colonne réellement utilisée est affichée dans les métadonnées du rapport.
code_lvl4 est absent
Elle ne se rabat pas sur code avec un avertissement, comme le faisait l’ancien rapport. code_lvl4 naît en un seul endroit du pipeline — reconcile-llm, et seulement si --mapping-file lui est passé. L’oublier ne cassait rien : tout le rapport sortait avec une accuracy sous-estimée sur près d’un quart des Postes. Un repli acceptable dans un rapport qui ne mesure rien ; pas quand quelqu’un a explicitement demandé une mesure.
Le message d’échec liste les colonnes présentes et nomme la cause probable.
Une seule règle d’accuracy : troncature et égalité
Au niveau k, on tronque la vérité et la prédiction à leurs k premiers segments, et on teste l’égalité. C’est toute la règle.
| Règle unique | |
|---|---|
| Dénominateur | toutes les observations portant une vérité, le même à tous les niveaux |
Vérité moins profonde que k |
tronquée à elle-même et mesurée : elle est juste si la prédiction s’arrête au même endroit |
| Prédiction plus courte que la vérité | erreur au niveau où elles divergent |
| Prédiction plus profonde que la vérité | erreur sous la profondeur de la vérité — 01.4 contre 01.4.3.1 : juste au niveau 2, faux au niveau 4 |
| Vérité absente | seul cas sorti du calcul : il n’y a rien à juger |
| Question | sur l’ensemble des observations étiquetées, quelle part porte le bon code à la profondeur k ? |
| Comparaison entre niveaux | possible : même population partout |
Le rapport a successivement publié une convention inclusive, puis une convention stricte qui écartait du dénominateur les vérités moins profondes que k. Les deux ont été retirées au profit de la règle ci-dessus, qui ne demande au lecteur de retenir qu’une phrase — et qui rend enfin mesurable le cas le plus fréquent des Postes peu profonds : une vérité 01.4 correctement codée 01.4 est juste, là où la convention stricte se contentait de ne pas la compter.
Une prédiction plus fine que la vérité n’est plus créditée au-delà de la profondeur de celle-ci. Comme 24 % des Postes ont un code canonique moins profond que le niveau 4 (voir prune-codes), un classifieur qui descend systématiquement à 4 segments est désormais compté faux sur cette part. Le tableau « Où une prédiction trop fine sera comptée fausse » donne, pour chaque k, la part concernée : c’est lui qui permet de distinguer une codification qui se dégrade en profondeur d’une annotation qui s’arrête avant.
Le niveau 5 n’est jamais affiché avec une vérité canonique : les codes prunés ont au plus 4 segments, donc tronquer à 5 ne tronque rien et la colonne dupliquerait le niveau 4. Elle en différerait seulement face à une prédiction à 5 segments — qu’elle compterait fausse, mesurant alors la profondeur de la prédiction et non son exactitude.
annexes/benchmarking/bilan-codification.qmd garde un scoreur local. Il ne mesure pas une accuracy contre une vérité terrain mais un taux d’accord avec une codification antérieure : un code_previous valant 11.2 est une réponse complète de l’annotateur précédent, pas une troncature. Son écart avec la règle ci-dessus s’est réduit au point de ne plus porter que sur ce point de sens — le scoreur local y est désormais largement redondant, et sa suppression est à instruire.
La décomposition interne des classifieurs
L’accuracy globale dit combien une brique se trompe, pas où. Un RAG à 70 % peut être un retriever qui ne ramène jamais la bonne réponse, ou un générateur qui la voit et choisit autre chose : les corrections sont opposées — réindexer d’un côté, retoucher le prompt de l’autre — et le chiffre agrégé ne permet pas de choisir.
Ces indicateurs existaient déjà : chaque étape de classification loguait les siens, dans sa propre expérience MLflow, sur son propre périmètre et avec sa propre convention. Ils étaient donc incomparables entre briques, et absents du rapport. evaluate/internals.py les rapatrie, calculés sur les mêmes lignes et la même vérité canonique que tout le reste.
| Indicateur | Ce qu’il répond |
|---|---|
| Recall du retriever, par niveau | quelle part des bonnes réponses figure dans la liste récupérée — la matière dont dispose le générateur |
| Accuracy si récupéré | sur ces lignes-là, à quelle fréquence le modèle choisit effectivement la bonne : la qualité de la génération, isolée des échecs du retriever |
| Régimes de réponse | accuracy et effectif sur all_raw / all_parsed / codable_only / parsed_and_codable / seuil de confiance — les quatre façons distinctes d’échouer que l’accuracy globale additionne |
| AUROC de chaque confiance | la confiance sépare-t-elle le juste du faux ? ≈ 0,5 : elle n’apprend rien et ne doit fonder aucun seuil |
| Balayage de seuils | pour chaque seuil, ce qu’il reste de volume et ce qu’on gagne en exactitude. C’est cette table qui instruit une décision de relecture, pas l’AUROC |
Fiabilité du drapeau codable |
le refus déclaré retient-il des lignes plus souvent justes que l’ensemble (lift) ? |
| Distorsion de distribution (TV, KL) | la brique déforme-t-elle la répartition des postes, à accuracy égale ? Invisible dans l’accuracy, sensible pour l’usage statistique aval |
internals.py est importé par le rapport et par main.py : un seul calcul, deux sorties, donc les tableaux rendus et les scalaires MLflow ne peuvent pas diverger.
La consigne faite au modèle de reprendre un candidat de la liste à l’identique est incitative, pas structurelle. Une accuracy supérieure au recall est donc possible, et l’écart mesure alors la part de codes justes produits hors de la liste récupérée. Ne pas lire le recall comme une borne.
Rendre le rapport à la main
Le gabarit lit ses entrées via des variables d’environnement plutôt que des paramètres Quarto : EVAL_DECIDE_PATH, EVAL_RAGNOTICES_PATH, EVAL_RETRIEVED_PATH, EVAL_RAGANN_PATH, EVAL_DELIVERABLE_PATH, EVAL_OBSERVATIONS_PATH, EVAL_MAPPING_PATH, EVAL_RUN_ID, EVAL_RUN_DATE, EVAL_SOURCE_COLUMN. C’est evaluate/main.py qui les positionne avant d’appeler quarto render ; pour itérer sur le gabarit en local, il suffit de les exporter soi-même puis de rendre le .qmd directement.
Traçabilité MLflow
L’étape loggue dans son expérience propre (eval-experiment, codif-coicop-eval par défaut), distincte de celle du rapport de production : les deux ne mesurent pas la même chose et n’ont pas le même schéma de métriques.
Sont enregistrés :
- Paramètres :
run_id,run_date, la conciliation retenue, la colonne de vérité terrain scorée, la colonne de provenance ; - Métriques : pour chaque méthode (LCS / RAG notices / RAG annotations / TTC / conciliation) et chaque niveau,
accuracy_<méthode>_niv<k>. Son dénominateur n’est plus logué par méthode et par niveau : il ne dépend ni de l’une ni de l’autre et vaut exactementn_scorable. Plustruth_shallower_than_niv<k>_count— inchangé, c’est une propriété de la vérité seule, mais il se lit désormais comme la part où une prédiction trop fine est comptée fausse —,llm_prompt_tokens_total,llm_latency_s_mean; - L’accuracy par régime de décision,
accuracy_<méthode>_niv4_<régime>etn_<régime>. Les régimes sontconsensus/arbitrateden conciliation LLM etno_candidate/single_candidate/arbitrateden SIRUS : le suffixearbitratedest partagé exprès, c’est la même notion — la conciliation a effectivement choisi — et c’est la série qu’il faut comparer d’un run à l’autre ; - La décomposition interne, préfixée par famille pour que l’UI MLflow les regroupe et qu’une comparaison entre deux runs porte sur des séries alignées :
retrieval/<brique>/recall_niv<k>et…/generation_when_retrieved_niv<k>,regime/<brique>/<régime>/accuracy_niv4et…/n,confidence/<brique>/auroc,…/accuracy_at_<seuil>,…/coverage_at_<seuil>,codable/<brique>/lift,distortion/<brique>/level_<k>/{tv_distance,kl_divergence}; - Volumétrie du fichier d’entrée :
n_input_rows,n_dropped_empty_label,n_dropped_uncodable,n_observations. Sansn_input_rows, comparer deux runs ne permet pas de distinguer un fichier plus petit d’un filtrage plus lourd ; - Artefacts :
evaluation_report.htmlettruth_depth_distribution.json.
Deux ruptures, traitées différemment.
Par renommage, lors du passage à la convention stricte : accuracy_all_<méthode>_niv<k> (convention inclusive) n’est plus émise, et coverage_<méthode> / abstention_<méthode>_count sont devenues coverage_niv4_<méthode> / abstention_niv4_<méthode>_count. Comparer un run d’avant et un run d’après montre un trou dans l’UI MLflow.
Sans renommage, lors du passage à la règle unique : accuracy_*, coverage_niv4_* et accuracy_answered_* gardent leur nom alors que leur définition a changé, et n_evaluable_<méthode>_niv<k> a disparu. C’est délibéré — la baisse mécanique au niveau 4 est l’information la plus utile du changement, et la cacher derrière une clé neuve la rendrait invisible. La rupture est portée par le tag accuracy_convention (troncature_egalite), qui permet de filtrer les runs d’une même convention dans l’UI.
Sortie
| Fichier | Contenu |
|---|---|
…/{run}/evaluate/evaluation_report.html |
rapport d’évaluation HTML auto-contenu |
Retour à l’accueil
Comment le rapport retrouve les runs des autres étapes
Aucune étape ne persistait son identifiant de run MLflow : ni en colonne, ni en artefact S3, ni en paramètre de sortie Argo, et les
run_namedes deux étapes RAG ne portent qu’un horodatage. Le raccordement se fait par deux tags posés au moment du run :pipeline.run_idindex.run_id, déjà présent, qui désigne le run d’indexation de la vector DBpipeline.steppipeline.run_id, une recherche sur ce seul tag renverrait celle qui a fini en derniercodif_common.trackingfait la recherche et construit l’URL. Trois cas se distinguent dans le rapport, et il ne laisse jamais une case vide :build-datasets,classify-regex,classify-lcs,prune-codes,reconcile-llm,export-resultsn’y touchent jamais ;classify-ttcetreconcile-sirusn’ouvrent aucun run pendant le pipeline : ils ne font qu’inférer. Leur run d’entraînement est retrouvé autrement, à partir de l’URI de leur modèle (mlflow-artifacts:/{experiment}/{run}/artifacts/model), qui porte les deux identifiants — celle de SIRUS est déjà une colonne du parquet de conciliation.Toute cette section est sous
try/except: un tableau de liens ne doit jamais faire échouer une évaluation.