NAF2025 : cohérence, chevauchement et confusion entre notices et libellés
Authors
Affiliation
Meilame Tayebjee
Insee, SSP Lab
Théo Ferry
Insee, SSP Lab
Published
16 September 2026
Contexte
Cette page évalue la qualité de la recherche par embeddings dans la NACE sur un échantillon annoté et évalue si l’espace d’embedding reflète la structure hiérarchique de la nomenclature NAF2025 (section → division → groupe → classe → sous-classe).
Note
Les résultats établis ci-après le sont sous l’hypothèse que la ground truth est correcte. Or nous souhaitons par la suite étudier la qualité de cette ground truth et nous réeffectuerons l’analyse ci-après.
Le code source complet est dans evaluate_embeddings.py ; cette page réutilise directement ses fonctions plutôt que de dupliquer la logique.
Données et diagnostics
Configuration & chargement des données
import osimport sys# evaluate_embeddings.py assumes it's run from the repo root (relative data paths, module# imports) — Quarto executes this chunk with cwd set to presentation/, so replicate that.os.chdir(os.path.abspath(".."))sys.path.insert(0, os.getcwd())import pandas as pdfrom evaluate_embeddings import ( CANDIDATE_MODELS, K_NN, LEVEL_NAMES, NAF2025_NOTICES_PATH, EVAL_SAMPLE_PATH, build_comparison_figure, build_confusion_heatmap, build_hierarchical_accuracy_figure, build_level_cohesion_figure, build_similarity_distribution_figure, build_similarity_heatmap, compute_model_diagnostics, load_eval_sample, load_leaf_notices, REDUCTION_METHODS,)MODEL_NAME = CANDIDATE_MODELS[0]EVAL_SET_FULL_PATH ="data/eval/eval_set.parquet"# eval_df (30 libellés) is used only for the 2D plot, to keep it readable; eval_df_full is# used for every numeric metric below (accuracy/recall/confusion/gap) — all 30 of eval_df's# libellés are a subset of eval_df_full's.eval_df = load_eval_sample(EVAL_SAMPLE_PATH)eval_df_full = load_eval_sample(EVAL_SET_FULL_PATH)notices_df = load_leaf_notices(NAF2025_NOTICES_PATH)notices_df_excludes = load_leaf_notices(NAF2025_NOTICES_PATH, include_excludes=True)print(f"{len(eval_df)} libellés (plot) / {len(eval_df_full)} libellés (métriques), "f"{len(notices_df)} notices terminales (NAF2025)")
Toutes les métriques de cette page (accuracy@1, recall@k, confusion en récupération, écart requête/passage) sont calculées sur l’échantillon annoté complet (data/eval/eval_set.parquet). La projection 2D est réalisé sur un échantillon de 30 libellés pour rester lisible ; la cohésion et la confusion inter-groupes ne dépendent, elles, que des notices, donc pas de la taille de l’échantillon de libellés.
Calcul des diagnostics (baseline)
# diag: petit échantillon (30), utilisé uniquement pour la projection 2D (lisibilité).diag = compute_model_diagnostics(MODEL_NAME, eval_df, notices_df, run_label=f"{MODEL_NAME}_baseline")# diag_metrics: échantillon complet, réutilise les embeddings de notices déjà calculés —# seuls les libellés sont ré-embeddés. Source de toutes les métriques affichées plus bas.diag_metrics = compute_model_diagnostics( MODEL_NAME, eval_df_full, notices_df, run_label=f"{MODEL_NAME}_baseline_full", notice_embeddings=diag["notice_embeddings"],)metrics = diag_metrics["metrics"]
Calculé sur les 5181 libellés de l’échantillon complet.
Précision par niveau hiérarchique
L’accuracy@1 ci-dessus ne compte qu’une correspondance exacte de code (sous-classe). Un top-1 faux à ce niveau mais correct en section/division/groupe/classe est une erreur bien moins grave qu’un top-1 dans une branche complètement différente. Pour chaque niveau \(\ell\) (du code exact jusqu’à la section) :
Le top-1 récupéré est le même à chaque niveau ; seule la granularité de comparaison change. Une correspondance à un niveau fin implique donc automatiquement la correspondance à tous les niveaux plus larges.
hacc = diag_metrics["hierarchical_accuracy"]hacc_sentence = ("Les erreurs du top-1 se rapprochent donc majoritairement de la bonne réponse au fil de ""la hiérarchie, plutôt que de partir dans une branche totalement différente — c'est ""l'écart entre deux niveaux successifs qui est informatif, pas le sens de variation ""lui-même (garanti croissant, cf. ci-dessus).")
Distribution des similarités correct / incorrect
mean_cosine_sim_correct/incorrect ne donnent que deux moyennes. La distribution complète des similarités des arêtes \(k\)-NN, séparée par exactitude, montre si les deux populations se recouvrent beaucoup (aucun seuil de confiance exploitable) ou sont assez bien séparées (un seuil de similarité pourrait signaler les cas à faible confiance à renvoyer vers la navigation LLM plutôt que faire confiance au top-1).
import numpy as npfrom scipy.stats import mannwhitneyucorrect_sims = np.array([1- dist for _, _, dist, is_correct in diag_metrics["edges"] if is_correct])incorrect_sims = np.array([1- dist for _, _, dist, is_correct in diag_metrics["edges"] ifnot is_correct])q1_correct, q3_correct = np.percentile(correct_sims, [25, 75])q1_incorrect, q3_incorrect = np.percentile(incorrect_sims, [25, 75])# P(similarité correcte tirée au hasard > similarité incorrecte tirée au hasard) — équivalent de# l'AUC d'un classifieur qui trancherait correct/incorrect sur la seule similarité, sans boucle# O(n×m) sur toutes les paires grâce à la formule de Mann-Whitney (rang-based).auc_sim_as_classifier = mannwhitneyu(correct_sims, incorrect_sims, alternative="greater").statistic / (len(correct_sims) *len(incorrect_sims))# Seuil concret : si on ne voulait perdre que 25% des bonnes récupérations (couper au 1er quartile# des correctes), quelle fraction des incorrectes passerait quand même au-dessus de ce seuil ?leak_at_q1_correct =float((incorrect_sims >= q1_correct).mean())dist_sentence = (f"Médiane à {np.median(correct_sims):.2f} pour les récupérations correctes contre "f"{np.median(incorrect_sims):.2f} pour les incorrectes, mais les deux boîtes à moustaches se "f"chevauchent nettement (IQR correct [{q1_correct:.2f}, {q3_correct:.2f}], IQR incorrect "f"[{q1_incorrect:.2f}, {q3_incorrect:.2f}]) : la similarité correcte n'est supérieure à l'incorrecte "f"que dans {auc_sim_as_classifier:.0%} des tirages aléatoires (ce serait 100% pour une séparation ""parfaite, 50% pour deux populations indiscernables). Concrètement, un seuil fixé pour ne perdre "f"que 25% des bonnes récupérations (au 1er quartile correct, {q1_correct:.2f}) laisserait encore "f"passer {leak_at_q1_correct:.0%} des récupérations incorrectes au-dessus de ce même seuil — la ""similarité brute n'est donc pas, seule, un filtre de confiance fiable pour décider quand ""renvoyer un cas vers la navigation LLM plutôt que de faire confiance au top-1.")
Médiane à 0.70 pour les récupérations correctes contre 0.64 pour les incorrectes, mais les deux boîtes à moustaches se chevauchent nettement (IQR correct [0.65, 0.76], IQR incorrect [0.60, 0.69]) : la similarité correcte n’est supérieure à l’incorrecte que dans 72% des tirages aléatoires (ce serait 100% pour une séparation parfaite, 50% pour deux populations indiscernables). Concrètement, un seuil fixé pour ne perdre que 25% des bonnes récupérations (au 1er quartile correct, 0.65) laisserait encore passer 47% des récupérations incorrectes au-dessus de ce même seuil — la similarité brute n’est donc pas, seule, un filtre de confiance fiable pour décider quand renvoyer un cas vers la navigation LLM plutôt que de faire confiance au top-1.
Cas les plus problématiques
Pour chaque libellé, on calcule sa similarité à sa propre notice cible (pas à ce qui a été récupéré) puis on trie par ordre croissant. Les valeurs les plus basses sont les libellés où même la bonne réponse ne ressemble pas du tout à la requête — un signal plus fort d’un vrai problème (embedding ou étiquetage) qu’un simple “top-1 faux”, qui peut n’être qu’un concurrent proche ayant devancé de peu la bonne notice.
Les 20 libellés dont la similarité à leur propre notice cible est la plus faible (échantillon complet) :
UMAP et PaCMAP préservent mieux la structure locale (cohésion de voisinage) que t-SNE, qui déforme les distances relatives et la taille apparente des clusters, ou que PCA, une projection linéaire peu apte à révéler une séparation non-linéaire des groupes — privilégier les panneaux UMAP/PaCMAP pour juger de la cohésion réelle des clusters. Les libellés (étoiles) peuvent visuellement sembler éloignés de leur notice correcte : voir la section Écart requête / passage avant d’interpréter cela comme un défaut.
# diag["edges"] contient les k=5 plus proches voisins de chaque libellé (pas seulement le# top-1) : un trait vert signifie donc "la notice cible est l'un des k plus proches voisins"# (recall@k, un par libellé au plus), pas "le top-1 est correct" (accuracy@1, une notion plus# stricte calculée séparément) — chaque étoile porte aussi jusqu'à k-1 traits bleus vers ses# autres voisins, corrects ou non.n_labels_plot =len(eval_df)n_recall_hits_plot =sum(1for*_, is_correct in diag["edges"] if is_correct)proj_sentence = (f"Sur ces {n_labels_plot} libellés, {n_recall_hits_plot} ont leur notice cible parmi leurs "f"{K_NN} plus proches voisins (un trait vert plein, un seul par libellé) et "f"{n_labels_plot - n_recall_hits_plot} ne l'ont dans aucun de leurs {K_NN} voisins (aucun trait "f"vert, seulement des traits bleus pointillés) — cohérent avec le recall@{K_NN} de ce "f"sous-échantillon ({diag['metrics'][f'recall_at_{K_NN}']:.1%}, propre aux 30 libellés de ce ""graphique, à ne pas confondre avec celui de l'échantillon complet plus haut, ni avec ""l'accuracy@1 qui, elle, n'accepterait que le tout premier voisin). Sur les quatre méthodes, les ""étoiles se regroupent surtout entre elles plutôt que de se disperser au milieu des points de ""leur propre couleur/section : c'est la manifestation visuelle directe de l'écart requête/passage ""détaillé plus bas, pas un artefact de la projection en elle-même.")
Sur ces 30 libellés, 20 ont leur notice cible parmi leurs 5 plus proches voisins (un trait vert plein, un seul par libellé) et 10 ne l’ont dans aucun de leurs 5 voisins (aucun trait vert, seulement des traits bleus pointillés) — cohérent avec le recall@5 de ce sous-échantillon (66.7%, propre aux 30 libellés de ce graphique, à ne pas confondre avec celui de l’échantillon complet plus haut, ni avec l’accuracy@1 qui, elle, n’accepterait que le tout premier voisin). Sur les quatre méthodes, les étoiles se regroupent surtout entre elles plutôt que de se disperser au milieu des points de leur propre couleur/section : c’est la manifestation visuelle directe de l’écart requête/passage détaillé plus bas, pas un artefact de la projection en elle-même.
Cohésion intra-groupe
Pour un niveau de la taxonomie (section, division, groupe, classe), on regroupe les notices par leur ancêtre commun à ce niveau, puis on compare la similarité moyenne à l’intérieur de chaque groupe à la similarité moyenne globale (toutes paires de notices confondues) :
où \(g\) parcourt les groupes ayant au moins 2 membres et \(\text{sim}(e_i, e_j) = 1 - d_{\cos}(e_i, e_j)\). Un ratio \(> 1\) signifie que les notices d’un même groupe (sections, divisions…) sont plus proches entre elles que deux notices prises au hasard — la hiérarchie NAF est donc reflétée dans l’espace d’embedding. Un ratio \(\le 1\) signifierait qu’elle ne l’est pas.
cohesion_levels = ["section", "division", "group", "class"]cohesion_values = {lvl: diag["cohesion_by_level"][lvl]["cohesion_ratio"] for lvl in cohesion_levels}cohesion_is_increasing =all( cohesion_values[cohesion_levels[i]] <= cohesion_values[cohesion_levels[i +1]]for i inrange(len(cohesion_levels) -1))cohesion_trend_sentence = ("C'est la progression attendue : plus le regroupement est fin, plus ses membres se ""ressemblent, et l'embedding respecte donc la hiérarchie NAF à toutes les granularités ""testées (sous-classe n'a volontairement pas de barre, cf. ci-dessus)."if cohesion_is_increasingelse"Cette progression n'est pas strictement croissante à chaque niveau : l'embedding ne ""respecte donc pas la hiérarchie de façon uniforme à toutes les granularités.")
Le ratio de cohésion passe de 1.19 au niveau section à 1.46 en division, 1.62 en groupe, puis 1.79 en classe. C’est la progression attendue : plus le regroupement est fin, plus ses membres se ressemblent, et l’embedding respecte donc la hiérarchie NAF à toutes les granularités testées (sous-classe n’a volontairement pas de barre, cf. ci-dessus).
Similarité entre notices
Similarité cosinus calculée dans l’espace haute dimension (non projeté), notices triées par chemin taxonomique (section → division → groupe → classe → code) — une structure en blocs emboîtés est la preuve visuelle directe que la hiérarchie est reflétée dans l’embedding.
sec_cohesion = diag["cohesion_by_level"]["section"]notice_sim_sentence = (f"À l'échelle section, la similarité moyenne *intra*-groupe "f"({sec_cohesion['mean_intra_group_cosine_sim']:.2f}) dépasse la similarité moyenne globale toutes "f"paires confondues ({sec_cohesion['mean_overall_cosine_sim']:.2f}) — le ratio de cohésion de "f"{sec_cohesion['cohesion_ratio']:.2f} déjà vu plus haut, mais visible ici directement comme des ""blocs diagonaux plus lumineux que le fond. Tous les blocs ne sont pas aussi compacts : un carré ""diagonal uniformément clair signale une section homogène, un carré plus terne ou irrégulier une ""section elle-même hétérogène — un contraste que le ratio moyen, en écrasant toutes les sections ""en un seul nombre, ne montre pas.")
À l’échelle section, la similarité moyenne *intra*-groupe (0.50) dépasse la similarité moyenne globale toutes paires confondues (0.42) — le ratio de cohésion de 1.19 déjà vu plus haut, mais visible ici directement comme des blocs diagonaux plus lumineux que le fond. Tous les blocs ne sont pas aussi compacts : un carré diagonal uniformément clair signale une section homogène, un carré plus terne ou irrégulier une section elle-même hétérogène — un contraste que le ratio moyen, en écrasant toutes les sections en un seul nombre, ne montre pas.
Confusion entre groupes (chevauchement)
La cohésion ne regarde que l’intérieur de chaque groupe ; elle ne dit rien sur les paires de groupes différents qui se chevauchent. On calcule donc, pour chaque paire de groupes \((g_i, g_j)\), la similarité croisée moyenne, et on la compare à la cohésion propre de chacun des deux groupes :
Un indice proche de ou supérieur à 1 signifie que deux catégories différentes sont, en moyenne, aussi proches l’une de l’autre qu’elles ne le sont chacune en interne — un vrai chevauchement, que l’embedding ne sépare pas nettement (à distinguer d’une ambiguïté propre à la nomenclature elle-même). La carte ci-dessous affiche directement cet indice (pas la similarité croisée brute) : une similarité brute élevée entre deux sections peut n’être que le reflet d’une cohésion globalement forte des deux côtés (déjà visible sur les figures de cohésion plus haut), sans rien dire du chevauchement propre à la paire — coder la couleur par indice normalisé évite qu’une section simplement très homogène n’écrase visuellement les vraies confusions.
Figure : confusion inter-sections
section_names =dict(zip(notices_df["SECTION"], notices_df["SECTION_NAME"]))build_confusion_heatmap( diag["section_matrix"], diag["section_labels"],"Confusion inter-sections (confusabilité : similarité croisée / cohésion la plus faible)", group_names=section_names,)
Interprétation de la carte de confusion inter-sections
top_pair = diag["top_confusable_sections"][0]top_pair_name_a = notices_df.loc[notices_df["SECTION"] == top_pair["group_a"], "SECTION_NAME"].iloc[0]top_pair_name_b = notices_df.loc[notices_df["SECTION"] == top_pair["group_b"], "SECTION_NAME"].iloc[0]confusion_sentence = (f"La paire la plus confondue est {top_pair['group_a']} ({top_pair_name_a}) / "f"{top_pair['group_b']} ({top_pair_name_b}) : leur similarité croisée ({top_pair['cross_sim']:.2f}) "f"atteint {top_pair['confusability']:.0%} de la plus faible de leurs deux cohésions internes — sur ""la carte ci-dessus, directement la cellule hors-diagonale la plus claire (l'indice affiché est ""déjà normalisé par la cohésion de chaque section, il se lit donc sans comparaison mentale ""supplémentaire). Le détail des paires suivantes (sections puis divisions) est dans les tableaux ""ci-dessous.")
La paire la plus confondue est M (ACTIVITÉS IMMOBILIÈRES) / O (ACTIVITÉS DE SERVICE ADMINISTRATIF ET DE SOUTIEN) : leur similarité croisée (0.50) atteint 100% de la plus faible de leurs deux cohésions internes — sur la carte ci-dessus, directement la cellule hors-diagonale la plus claire (l’indice affiché est déjà normalisé par la cohésion de chaque section, il se lit donc sans comparaison mentale supplémentaire). Le détail des paires suivantes (sections puis divisions) est dans les tableaux ci-dessous.
Sections à un seul membre (cellules NaN)
section_counts = notices_df["SECTION"].value_counts()singleton_sections = section_counts[section_counts <2]singleton_sentence = ("Aucune section n'a un seul membre : l'indice de confusabilité est calculable pour toutes les paires."if singleton_sections.emptyelse"Section"+ ("s "iflen(singleton_sections) >1else" ")+", ".join(f"{code} ({notices_df.loc[notices_df['SECTION'] == code, 'SECTION_NAME'].iloc[0]}, "f"{count} notice)"for code, count in singleton_sections.items() )+" : une cohésion *intra*-groupe suppose au moins 2 membres pour former une paire, donc sans ""cohésion de référence propre l'indice de confusabilité (qui divise par elle) n'est calculable ""pour aucune paire impliquant cette section — toute sa ligne et sa colonne restent vides (NaN) ""sur la carte ci-dessus, pas seulement sa cellule diagonale.")
Section V (ACTIVITÉS DES ORGANISATIONS ET ORGANISMES EXTRATERRITORIAUX, 1 notice) : une cohésion *intra*-groupe suppose au moins 2 membres pour former une paire, donc sans cohésion de référence propre l’indice de confusabilité (qui divise par elle) n’est calculable pour aucune paire impliquant cette section — toute sa ligne et sa colonne restent vides (NaN) sur la carte ci-dessus, pas seulement sa cellule diagonale.
À partir des mêmes voisins \(k\)-NN utilisés pour l’accuracy/recall, on ne garde que les arêtes incorrectes (notice récupérée ≠ notice cible), et on compte les paires (groupe réel → groupe récupéré à tort), en excluant les cas où les deux notices partagent le même groupe (erreur fine, pas une confusion inter-catégories).
Tableau : confusions de récupération
retrieval_confusion_df = pd.DataFrame(diag_metrics["retrieval_confusion"])retrieval_confusion_df.insert(1, "nom_réel", retrieval_confusion_df["true_group"].map(section_names))retrieval_confusion_df.insert(3, "nom_récupéré", retrieval_confusion_df["retrieved_group"].map(section_names))print(f"{len(retrieval_confusion_df)} paires de sections différentes confondues au moins une fois — 20 premières :")retrieval_confusion_df.head(20)
305 paires de sections différentes confondues au moins une fois — 20 premières :
true_group
nom_réel
retrieved_group
nom_récupéré
count
0
C
INDUSTRIE MANUFACTURIÈRE
G
COMMERCE
1106
1
C
INDUSTRIE MANUFACTURIÈRE
F
CONSTRUCTION
324
2
G
COMMERCE
C
INDUSTRIE MANUFACTURIÈRE
284
3
C
INDUSTRIE MANUFACTURIÈRE
T
AUTRES ACTIVITÉS DE SERVICES
261
4
T
AUTRES ACTIVITÉS DE SERVICES
G
COMMERCE
209
5
F
CONSTRUCTION
C
INDUSTRIE MANUFACTURIÈRE
208
6
O
ACTIVITÉS DE SERVICE ADMINISTRATIF ET DE SOUTIEN
H
TRANSPORTS ET ENTREPOSAGE
192
7
O
ACTIVITÉS DE SERVICE ADMINISTRATIF ET DE SOUTIEN
I
HÉBERGEMENT ET RESTAURATION
142
8
C
INDUSTRIE MANUFACTURIÈRE
I
HÉBERGEMENT ET RESTAURATION
134
9
A
AGRICULTURE, SYLVICULTURE ET PÊCHE
G
COMMERCE
129
10
H
TRANSPORTS ET ENTREPOSAGE
O
ACTIVITÉS DE SERVICE ADMINISTRATIF ET DE SOUTIEN
126
11
G
COMMERCE
M
ACTIVITÉS IMMOBILIÈRES
125
12
T
AUTRES ACTIVITÉS DE SERVICES
C
INDUSTRIE MANUFACTURIÈRE
122
13
O
ACTIVITÉS DE SERVICE ADMINISTRATIF ET DE SOUTIEN
T
AUTRES ACTIVITÉS DE SERVICES
122
14
S
ARTS, SPORTS ET ACTIVITÉS RÉCRÉATIVES
Q
ENSEIGNEMENT
106
15
A
AGRICULTURE, SYLVICULTURE ET PÊCHE
C
INDUSTRIE MANUFACTURIÈRE
103
16
F
CONSTRUCTION
G
COMMERCE
100
17
T
AUTRES ACTIVITÉS DE SERVICES
I
HÉBERGEMENT ET RESTAURATION
96
18
O
ACTIVITÉS DE SERVICE ADMINISTRATIF ET DE SOUTIEN
M
ACTIVITÉS IMMOBILIÈRES
93
19
N
ACTIVITÉS SPÉCIALISÉES, SCIENTIFIQUES ET TECHN...
M
ACTIVITÉS IMMOBILIÈRES
87
Écart requête / passage
De nombreux modèles d’embedding de recherche (bi-encodeurs) sont entraînés pour classer le bon passage devant les autres (ranking relatif), pas pour placer requêtes et passages au même endroit dans l’espace vectoriel (position absolue). On quantifie cet écart avec quatre nombres : la similarité intra-notices, la similarité intra-libellés, la similarité croisée libellé → notice (toutes paires), et la similarité des centroïdes :
Si la similarité intra-libellés dépasse nettement la similarité croisée libellé → notice, les libellés se regroupent davantage entre eux qu’avec leur notice correspondante — un écart réel, pas un simple artefact de projection 2D.
gap_exists = gap["within_label_sim"] > gap["label_to_notice_sim"]gap_sentence = (f"La similarité intra-libellés ({gap['within_label_sim']:.2f}) dépasse la similarité "f"libellé → notice toutes paires confondues ({gap['label_to_notice_sim']:.2f}) : les ""libellés se regroupent donc bien un peu plus entre eux qu'avec une notice prise au ""hasard — l'écart requête/passage est réel, pas seulement un artefact de projection 2D."if gap_existselsef"La similarité intra-libellés ({gap['within_label_sim']:.2f}) ne dépasse pas la "f"similarité libellé → notice toutes paires confondues ({gap['label_to_notice_sim']:.2f}) : ""pas de signe net d'écart requête/passage sur cet échantillon.")correct_vs_within_label = (f"Ce qui compte pour la récupération, c'est que la similarité libellé → notice *correcte* "f"({metrics['mean_cosine_sim_correct']:.2f}) reste nettement supérieure à la similarité "f"intra-libellés ({gap['within_label_sim']:.2f}) : le classement relatif entre une notice ""correcte et les autres n'est donc pas remis en cause par cet écart."if metrics["mean_cosine_sim_correct"] > gap["within_label_sim"]elsef"Signal à surveiller : la similarité libellé → notice *correcte* "f"({metrics['mean_cosine_sim_correct']:.2f}) ne dépasse même pas la similarité "f"intra-libellés ({gap['within_label_sim']:.2f}), ce qui peut dégrader le classement relatif ""dont dépend la récupération.")
La similarité intra-libellés (0.48) dépasse la similarité libellé → notice toutes paires confondues (0.39) : les libellés se regroupent donc bien un peu plus entre eux qu’avec une notice prise au hasard — l’écart requête/passage est réel, pas seulement un artefact de projection 2D. Ce qui compte pour la récupération, c’est que la similarité libellé → notice *correcte* (0.70) reste nettement supérieure à la similarité intra-libellés (0.48) : le classement relatif entre une notice correcte et les autres n’est donc pas remis en cause par cet écart.
Expérience : le champ “Excludes”
text_to_embed (production comme ce diagnostic) construit le texte à partir de NAME + Implementation_rule + Includes + IncludesAlso, sans jamais utiliser Excludes — le texte rédigé par l’Insee pour dire explicitement “ressemble à ça mais n’en fait pas partie”. C’est exactement le signal discriminant qui pourrait aider à séparer des catégories confondues. On teste ici l’effet de son ajout, sans toucher à la pipeline de production.
Calcul des diagnostics (avec Excludes)
diag_excludes = compute_model_diagnostics( MODEL_NAME, eval_df, notices_df_excludes, run_label=f"{MODEL_NAME}_with_excludes")# Même logique que pour la baseline : réutilise les embeddings de notices déjà calculés# (avec Excludes) et ré-embedde seulement les libellés, sur l'échantillon complet.diag_excludes_metrics = compute_model_diagnostics( MODEL_NAME, eval_df_full, notices_df_excludes, run_label=f"{MODEL_NAME}_with_excludes_full", notice_embeddings=diag_excludes["notice_embeddings"],)metrics_excludes = diag_excludes_metrics["metrics"]
Comparaison calculée sur les 5181 libellés de l’échantillon complet (même échantillon que dans le reste de cette page).
Tableau comparatif baseline vs. avec Excludes
rows = [ {"métrique": "accuracy@1","baseline": f"{metrics['accuracy_at_1']:.1%}","avec Excludes": f"{metrics_excludes['accuracy_at_1']:.1%}", }, {"métrique": f"recall@{K_NN}","baseline": f"{metrics[f'recall_at_{K_NN}']:.1%}","avec Excludes": f"{metrics_excludes[f'recall_at_{K_NN}']:.1%}", },]for level in LEVEL_NAMES.values(): level = level.lower() b = metrics["sibling_cohesion"][level]["cohesion_ratio"] w = metrics_excludes["sibling_cohesion"][level]["cohesion_ratio"] rows.append( {"métrique": f"cohesion_ratio ({level})","baseline": f"{b:.3f}"if b isnotNoneelse"n/a","avec Excludes": f"{w:.3f}"if w isnotNoneelse"n/a", } )pd.DataFrame(rows)
métrique
baseline
avec Excludes
0
accuracy@1
48.1%
50.7%
1
recall@5
77.5%
80.4%
2
cohesion_ratio (section)
1.195
1.191
3
cohesion_ratio (division)
1.455
1.434
4
cohesion_ratio (group)
1.618
1.595
5
cohesion_ratio (class)
1.794
1.782
6
cohesion_ratio (subclass)
n/a
n/a
Conclusion et implications pratiques pour le RAG
Cette page évalue le retrieval seul, indépendamment de la navigation LLM qui le suit (cf. Contexte) — les points ci-dessous se lisent donc comme des implications pour le pipeline agentic_rag.py / get_closest_codes dans son ensemble, pas comme un jugement final sur l’accuracy du classifieur.
À faire :
Tester Excludes en production. Ajouter ce champ à text_to_embed améliore la précision de récupération réelle (accuracy@1 et recall@5), même si le cohesion_ratio baisse très légèrement à chaque niveau — la baseline globale de similarité augmente elle aussi (vocabulaire partagé entre notices voisines introduit par les notes d’exclusion). C’est le changement le plus direct à essayer, au prix d’un ré-embedding des notices dans Neo4j.
Regarder les paires de sections/divisions les plus confondues (tableaux de la section Confusion entre groupes) au cas par cas : certaines sont un vrai défaut d’embedding, d’autres une ambiguïté propre à la nomenclature NAF — la carte ne dit pas laquelle, mais elle dit où regarder en priorité plutôt que de traiter la nomenclature comme uniformément difficile.
Auditer ponctuellement les “cas les plus problématiques” (section ci-dessus, désormais avec les noms de code) : une similarité très faible entre un libellé et sa propre notice cible peut signaler un libellé mal annoté plutôt qu’un défaut d’embedding — quelques minutes de vérification manuelle peuvent révéler des erreurs d’étiquetage qui plafonnent artificiellement l’accuracy mesurée ici.
À ne pas faire :
Ne pas remplacer la navigation LLM par un seuil de similarité cosinus. La section Distribution des similarités montre que les similarités correct/incorrect se chevauchent trop pour qu’un seuil de confiance sur le score du top-1 permette de sauter la navigation LLM en toute sécurité sur les cas “évidents” : même un seuil conservateur (ne perdre que 25% des bonnes récupérations) laisse passer une fraction substantielle de récupérations incorrectes. La vérification LLM systématique reste justifiée par les données, pas seulement par prudence.
Ne pas s’inquiéter de l’écart requête / passage dans l’état actuel. Il est réel et mesurable, mais la similarité libellé → notice correcte reste nettement supérieure à la similarité intra-libellés : le classement relatif dont dépend la récupération n’est pas remis en cause. Pas d’action nécessaire tant que le modèle d’embedding ne change pas — juste un point à re-tester si MODEL_NAME change.
Pourquoi l’architecture actuelle (retrieval + navigation LLM) reste justifiée : l’accuracy@1 brute (~50%) serait insuffisante utilisée seule, mais le recall@5 nettement plus haut et la précision croissante par niveau hiérarchique montrent que la bonne réponse est presque toujours proche du top-1 récupéré (même section/division) plutôt que dans une branche totalement différente — exactement le type d’écart qu’une navigation LLM locale autour du point de départ peut corriger. Le retrieval n’a donc pas besoin d’être parfait pour bien remplir son rôle de point de départ ; il doit surtout éviter de partir dans la mauvaise branche, ce qui est déjà le cas la plupart du temps.