Claude commited on
Commit
75e6d94
·
unverified ·
1 Parent(s): ec6632e

sprint39: A.II.1.b Calibration — couche de calcul (ECE, MCE, reliability)

Browse files

Deuxième brique des trois métriques prioritaires de l'Étape 2 du plan
d'évolution (axe A — fiabilité). Stratégie identique aux Sprints 35-38 :
couche de calcul d'abord, exposition des token_confidences sur les
EngineResult et câblage runner+narratif+HTML aux sprints suivants.

Pour un workflow patrimonial qui doit vérifier humainement un corpus de
50 000 pages, la différence entre vérifier 100 % vs 15 % du volume est
l'effet de la calibration. Un moteur surconfiant (ECE élevé) annonce
toujours "95 % de confiance" et a tort une fois sur deux — vérification
systématique inévitable. Un moteur calibré (ECE bas) permet de cibler
la vérification sur les passages à faible confiance.

Nouveau picarones/core/calibration.py
- Dataclass CalibrationBin avec propriété gap (None pour bin vide).
- reliability_diagram : binning équidistant avec calcul de la confiance
moyenne, précision moyenne et compte par bin.
- expected_calibration_error (ECE) : moyenne pondérée par bin de
|conf - accuracy|, ∈ [0, 1].
- maximum_calibration_error (MCE) : pire écart sur les bins non vides.
- compute_calibration_metrics : vue agrégée avec ECE, MCE, n_bins,
n_predictions, overall_accuracy, overall_confidence, bins.
- Calcul d'index par multiplication int(c * n_bins) plutôt que
division pour éviter le piège IEEE 754 (0.6 / 0.1 = 5.999... met
0.6 dans le mauvais bin).

Aucune dépendance externe : les listes confidences ∈ [0, 1] et
is_correct ∈ {0, 1} sont fournies en entrée. L'extraction depuis les
engines (Tesseract tsv, Pero PageLayout, Mistral confidence, Google
Vision Word.confidence) est reportée à un sprint dédié.

Tests : +32 dans test_sprint39_calibration.py couvrant calibration
parfaite (ECE = 0), cas extrêmes (sur/sous-confiance → ECE = 0,5),
biais constant (ECE = |c-a|), binning correct y compris pour 0.6 (le
piège classique), bins vides (gap = None), listes vides, garde-fous
(longueurs incompatibles, conf hors [0,1], n_bins ≤ 0), n_bins
paramétrable + monotonie ECE.
Suite complète : 1649 → 1681 passed, 2 skipped, 0 failed.

CHANGELOG.md CHANGED
@@ -16,6 +16,40 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 38 — A.II.1.a NER : couche de calcul.** Première brique
20
  des trois métriques prioritaires de l'Étape 2 du plan d'évolution
21
  (axe A — utilité aval). Stratégie de découpage analogue à la
@@ -186,11 +220,12 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
186
 
187
  ### Tests
188
 
189
- - 1478 → 1649 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
190
- +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38). Aucune
191
- régression. **Phase 0 close ; Étape 2 du plan d'évolution : inter-moteurs
192
- livrés bout-en-bout (Sprints 35-37) ; NER (axe A.II.1.a) couche de
193
- calcul livrée (Sprint 38).**
 
194
 
195
  ---
196
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 39 — A.II.1.b Calibration des moteurs : couche de calcul.**
20
+ Deuxième brique des trois métriques prioritaires de l'Étape 2 (axe A —
21
+ fiabilité). Stratégie identique aux Sprints 35-38 : couche de calcul
22
+ pure, exposition des `token_confidences` sur les `EngineResult` et
23
+ câblage runner+narratif+HTML aux sprints suivants.
24
+ - Nouveau module `picarones/core/calibration.py` :
25
+ - dataclass `CalibrationBin(bin_low, bin_high, avg_confidence,
26
+ accuracy, count)` avec propriété `gap` (renvoie `None` si bin vide)
27
+ - `reliability_diagram(confidences, is_correct, n_bins=10)` : binning
28
+ équidistant de la confiance, calcul de la précision moyenne et de
29
+ la confiance moyenne par bin
30
+ - `expected_calibration_error` (ECE) : moyenne pondérée par bin de
31
+ `|conf - accuracy|`, ∈ [0, 1], 0 = calibration parfaite
32
+ - `maximum_calibration_error` (MCE) : pire écart sur tous les bins
33
+ non vides
34
+ - `compute_calibration_metrics` : vue agrégée
35
+ - **Calcul d'index de bin par multiplication** (`int(c * n_bins)`)
36
+ plutôt que division, pour éviter les pièges IEEE 754 (`0.6 / 0.1 =
37
+ 5.999…` en flottant). Cas testé.
38
+ - Aucune dépendance externe ; les listes `confidences` et `is_correct`
39
+ sont fournies en entrée. L'extraction depuis les engines existants
40
+ (Tesseract `tsv`, Pero `PageLayout`, Mistral `confidence`, Google
41
+ Vision `Word.confidence`) est explicitement reportée à un sprint
42
+ dédié.
43
+ - +32 tests dans `test_sprint39_calibration.py` couvrant la
44
+ calibration parfaite (ECE = 0), les cas extrêmes (sur-confiance et
45
+ sous-confiance → ECE = 0,5), le biais constant (ECE = `|conf - acc|`),
46
+ le binning correct (bornes équidistantes, c=1.0 dans le dernier bin,
47
+ affectation correcte y compris pour 0.6), les bins vides
48
+ (avg/accuracy/gap = `None`), les listes vides, les garde-fous
49
+ (longueurs incompatibles, conf hors [0, 1], n_bins ≤ 0), `n_bins`
50
+ paramétrable + monotonie « ECE ne décroît pas avec un binning plus
51
+ fin ».
52
+
53
  - **Sprint 38 — A.II.1.a NER : couche de calcul.** Première brique
54
  des trois métriques prioritaires de l'Étape 2 du plan d'évolution
55
  (axe A — utilité aval). Stratégie de découpage analogue à la
 
220
 
221
  ### Tests
222
 
223
+ - 1478 → 1681 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
224
+ +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
225
+ +32 Sprint 39). Aucune régression. **Phase 0 close ; Étape 2 du plan
226
+ d'évolution : inter-moteurs livrés bout-en-bout (Sprints 35-37) ;
227
+ NER (A.II.1.a) et calibration (A.II.1.b) couches de calcul livrées
228
+ (Sprints 38-39).**
229
 
230
  ---
231
 
CLAUDE.md CHANGED
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
  | 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
209
  | 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
 
210
  | 38 | **Sprint 7 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : NER (couche de calcul)**. Nouveau module `picarones/core/ner.py` : dataclass `Entity(label, start, end, text)` (validation de span), fonction `compute_ner_metrics(reference, hypothesis, iou_threshold=0.5)` qui aligne par chevauchement IoU (greedy, IoU décroissant, chaque entité matchée au plus une fois) et retourne precision/recall/F1 globaux + par catégorie + listes `hallucinated_entities` / `missed_entities`. Format dict compatible `EntitiesGT` du Sprint 32. Métrique `ner_f1` enregistrée dans le registre typé Sprint 34 pour la jonction `(ENTITIES, ENTITIES)`. Aucune dépendance externe : les listes d'entités sont fournies en entrée — le backend extracteur (spaCy/Stanza/HIPE) suivra dans un sprint dédié. +19 tests dans `test_sprint38_ner_metrics.py` (cas standards, label case-insensitive, IoU sous/sur seuil, multi-catégorie, alignement greedy, cas dégénérés, validation Entity, intégration registre). **Verrou levé** : un benchmark dont le corpus a une GT entités peut maintenant mesurer l'utilité aval pour l'indexation prosopographique — métrique critique pour les bibliothèques numériques. |
211
  | 37 | **Sprint 6 du plan d'évolution 2026 — Étape 2 / axe A : section inter-moteurs dans le rapport HTML**. Nouveau module `picarones/report/inter_engine_render.py` qui produit deux blocs HTML serveur-side (pas de JS) : `build_divergence_matrix_html` rend une table heatmap CSS inline (gradient blanc → rouge sur le max hors-diagonale, diagonale étiquetée, paire la plus divergente annoncée en sous-titre) ; `build_oracle_gap_html` rend l'encart factuel best engine / recall / oracle / gap absolu+relatif / doc count. Le `ReportGenerator` les calcule et les passe au template `view_analyses.html` qui les affiche dans une `chart-card` à largeur pleine **uniquement si présents** — principe du rapport adaptatif (< 2 moteurs ou pas de taxonomie → section omise). +14 clés i18n FR/EN (`h_inter_engine`, `inter_engine_note`, `divergence_*`, `oracle_*`). Anti-injection HTML via `html.escape`. +42 tests dans `test_sprint37_inter_engine_html.py` couvrant le rendu (valeurs, paire max), le masquage adaptatif sur 4 cas dégénérés, l'anti-injection (engine name `<script>` correctement échappé), l'intégration rapport FR + EN, la complétude i18n sur les 14 clés × 2 langues. **Verrou levé** : ce que le moteur narratif annonce dans la synthèse (« tess et pero ont des profils divergents… ») est maintenant aussi visible dans la vue analyses sous forme de matrice et d'encart factuel — le lecteur peut vérifier visuellement le chiffre. |
212
  | 36 | **Sprint 5 du plan d'évolution 2026 — Étape 2 / axe A : câblage inter-moteurs au runner et au moteur narratif**. Suite du Sprint 35 : `inter_engine.py` gagne `compute_inter_engine_analysis` (agrégation corpus-wide doc par doc, structure stable consommable par les détecteurs et le rapport HTML — oracle global, recall par moteur, per_doc top 50 trié par gap, matrice de divergence, paire la plus divergente). `BenchmarkResult` expose un nouveau champ optionnel `inter_engine_analysis` ; le runner (`run_benchmark`) collecte les hypothèses brutes par moteur avant `compact()` et calcule l'analyse si ≥ 2 moteurs (sinon `None`). Nouveau `FactType.ENSEMBLE_OPPORTUNITY` (priority 130, importance MEDIUM, HIGH si `relative_gap` ≥ 50 %) avec détecteur `detect_ensemble_opportunity` qui fallback sur `per_engine_recall` quand la divergence taxonomique est absente. Templates FR/EN ajoutés à `narrative/templates/{fr,en}.yaml`. `report_data["inter_engine_analysis"]` exposé pour la consommation par le rapport HTML (matrice de divergence Sprint 37 à venir). +22 tests dans `test_sprint36_ensemble_narrative.py` couvrant l'agrégation, l'exposition `BenchmarkResult.as_dict`, les seuils du détecteur, le fallback paire sans taxonomie, l'intégration `build_synthesis` FR + EN, la traçabilité anti-hallucination (chaque nombre rendu est dans le payload, template sans chiffres en dur). |
@@ -256,7 +257,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
256
  ## Contexte développement
257
 
258
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
259
- - **Tests** : 1649 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprint 38 = NER couche de calcul)
260
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
261
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
262
  - **Transcript de la conversation de développement** :
 
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
  | 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
209
  | 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
210
+ | 39 | **Sprint 8 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : Calibration (couche de calcul)**. Nouveau module `picarones/core/calibration.py` avec dataclass `CalibrationBin` (`bin_low/high`, `avg_confidence`, `accuracy`, `count`, propriété `gap`), `reliability_diagram`, `expected_calibration_error` (ECE — moyenne pondérée par bin de `\|conf - accuracy\|`, ∈ [0, 1]), `maximum_calibration_error` (MCE — pire écart sur les bins non vides), `compute_calibration_metrics` (vue agrégée). Calcul d'index de bin par multiplication `int(c * n_bins)` plutôt que division pour éviter le piège IEEE 754 (`0.6 / 0.1 = 5.999…`). Aucune dépendance externe — les listes `confidences` ∈ [0, 1] et `is_correct` ∈ {0,1} sont fournies en entrée ; l'extraction depuis les engines existants est reportée à un sprint dédié. +32 tests couvrant calibration parfaite (ECE = 0), cas extrêmes (sur/sous-confiance → ECE = 0,5), biais constant (ECE = `\|c-a\|`), binning correct (0.6 placé dans le bon bin), bins vides (`gap = None`), garde-fous, monotonie `n_bins` plus fins → ECE ne décroît pas. **Verrou levé** : un workflow patrimonial peut maintenant répondre à *« quand le moteur dit qu'il est sûr, est-il vraiment sûr ? »* — différence entre vérification humaine systématique (100 %) et ciblée (15 %) sur les passages à faible confiance. |
211
  | 38 | **Sprint 7 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : NER (couche de calcul)**. Nouveau module `picarones/core/ner.py` : dataclass `Entity(label, start, end, text)` (validation de span), fonction `compute_ner_metrics(reference, hypothesis, iou_threshold=0.5)` qui aligne par chevauchement IoU (greedy, IoU décroissant, chaque entité matchée au plus une fois) et retourne precision/recall/F1 globaux + par catégorie + listes `hallucinated_entities` / `missed_entities`. Format dict compatible `EntitiesGT` du Sprint 32. Métrique `ner_f1` enregistrée dans le registre typé Sprint 34 pour la jonction `(ENTITIES, ENTITIES)`. Aucune dépendance externe : les listes d'entités sont fournies en entrée — le backend extracteur (spaCy/Stanza/HIPE) suivra dans un sprint dédié. +19 tests dans `test_sprint38_ner_metrics.py` (cas standards, label case-insensitive, IoU sous/sur seuil, multi-catégorie, alignement greedy, cas dégénérés, validation Entity, intégration registre). **Verrou levé** : un benchmark dont le corpus a une GT entités peut maintenant mesurer l'utilité aval pour l'indexation prosopographique — métrique critique pour les bibliothèques numériques. |
212
  | 37 | **Sprint 6 du plan d'évolution 2026 — Étape 2 / axe A : section inter-moteurs dans le rapport HTML**. Nouveau module `picarones/report/inter_engine_render.py` qui produit deux blocs HTML serveur-side (pas de JS) : `build_divergence_matrix_html` rend une table heatmap CSS inline (gradient blanc → rouge sur le max hors-diagonale, diagonale étiquetée, paire la plus divergente annoncée en sous-titre) ; `build_oracle_gap_html` rend l'encart factuel best engine / recall / oracle / gap absolu+relatif / doc count. Le `ReportGenerator` les calcule et les passe au template `view_analyses.html` qui les affiche dans une `chart-card` à largeur pleine **uniquement si présents** — principe du rapport adaptatif (< 2 moteurs ou pas de taxonomie → section omise). +14 clés i18n FR/EN (`h_inter_engine`, `inter_engine_note`, `divergence_*`, `oracle_*`). Anti-injection HTML via `html.escape`. +42 tests dans `test_sprint37_inter_engine_html.py` couvrant le rendu (valeurs, paire max), le masquage adaptatif sur 4 cas dégénérés, l'anti-injection (engine name `<script>` correctement échappé), l'intégration rapport FR + EN, la complétude i18n sur les 14 clés × 2 langues. **Verrou levé** : ce que le moteur narratif annonce dans la synthèse (« tess et pero ont des profils divergents… ») est maintenant aussi visible dans la vue analyses sous forme de matrice et d'encart factuel — le lecteur peut vérifier visuellement le chiffre. |
213
  | 36 | **Sprint 5 du plan d'évolution 2026 — Étape 2 / axe A : câblage inter-moteurs au runner et au moteur narratif**. Suite du Sprint 35 : `inter_engine.py` gagne `compute_inter_engine_analysis` (agrégation corpus-wide doc par doc, structure stable consommable par les détecteurs et le rapport HTML — oracle global, recall par moteur, per_doc top 50 trié par gap, matrice de divergence, paire la plus divergente). `BenchmarkResult` expose un nouveau champ optionnel `inter_engine_analysis` ; le runner (`run_benchmark`) collecte les hypothèses brutes par moteur avant `compact()` et calcule l'analyse si ≥ 2 moteurs (sinon `None`). Nouveau `FactType.ENSEMBLE_OPPORTUNITY` (priority 130, importance MEDIUM, HIGH si `relative_gap` ≥ 50 %) avec détecteur `detect_ensemble_opportunity` qui fallback sur `per_engine_recall` quand la divergence taxonomique est absente. Templates FR/EN ajoutés à `narrative/templates/{fr,en}.yaml`. `report_data["inter_engine_analysis"]` exposé pour la consommation par le rapport HTML (matrice de divergence Sprint 37 à venir). +22 tests dans `test_sprint36_ensemble_narrative.py` couvrant l'agrégation, l'exposition `BenchmarkResult.as_dict`, les seuils du détecteur, le fallback paire sans taxonomie, l'intégration `build_synthesis` FR + EN, la traçabilité anti-hallucination (chaque nombre rendu est dans le payload, template sans chiffres en dur). |
 
257
  ## Contexte développement
258
 
259
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
260
+ - **Tests** : 1681 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38-39 = NER + calibration couches de calcul)
261
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
262
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
263
  - **Transcript de la conversation de développement** :
picarones/core/calibration.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibration des moteurs : ECE, MCE, reliability diagram.
2
+
3
+ Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Tous les moteurs OCR cibles fournissent une confidence par token ou par
8
+ ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``,
9
+ Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``).
10
+ La question naturelle pour un workflow patrimonial est : *« quand le
11
+ moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe
12
+ qui doit vérifier humainement un corpus de 50 000 pages, la différence
13
+ entre vérifier 100 % vs 15 % du volume est l'effet de la calibration.
14
+
15
+ Ce module fournit les trois mesures classiques :
16
+
17
+ - **Expected Calibration Error (ECE)** — moyenne pondérée par bin de
18
+ l'écart absolu entre confiance moyenne et précision moyenne.
19
+ ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart
20
+ systématique entre confiance affichée et fiabilité réelle.
21
+ - **Maximum Calibration Error (MCE)** — max de cet écart sur les bins.
22
+ Utile pour repérer le pire mensonge du moteur (ex. il dit toujours
23
+ 95 % de confiance et il a tort une fois sur deux).
24
+ - **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
25
+ accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
26
+ Chart.js côté navigateur dans un sprint suivant.
27
+
28
+ Stratégie de découpage
29
+ ----------------------
30
+ Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37),
31
+ on découpe :
32
+
33
+ - **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes
34
+ parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1).
35
+ Aucune dépendance externe.
36
+ - **Sprint à venir** — exposition de ``token_confidences`` sur
37
+ ``EngineResult``, alignement caractère/token avec la GT pour produire
38
+ ``is_correct``, intégration dans le runner et vue HTML reliability.
39
+
40
+ Ce qui est explicitement hors scope
41
+ -----------------------------------
42
+ Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est
43
+ extraite ; on calcule uniquement à partir de séquences de prédictions
44
+ fournies en entrée. C'est ce qui permet de tester rigoureusement les
45
+ invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias
46
+ constant, etc.) sans dépendre d'un backend.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ from dataclasses import dataclass
53
+ from typing import Iterable
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # ──────────────────────────────────────────────────────────────────────────
59
+ # Modèle de données
60
+ # ──────────────────────────────────────────────────────────────────────────
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CalibrationBin:
65
+ """Un bin du reliability diagram.
66
+
67
+ Attributs
68
+ ---------
69
+ bin_low, bin_high:
70
+ Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` —
71
+ sauf le dernier bin qui inclut ``1.0``).
72
+ avg_confidence:
73
+ Moyenne des confidences des prédictions tombées dans le bin.
74
+ ``None`` si le bin est vide.
75
+ accuracy:
76
+ Fraction de prédictions correctes dans le bin (``∈ [0, 1]``).
77
+ ``None`` si le bin est vide.
78
+ count:
79
+ Nombre de prédictions dans le bin.
80
+ """
81
+
82
+ bin_low: float
83
+ bin_high: float
84
+ avg_confidence: float | None
85
+ accuracy: float | None
86
+ count: int
87
+
88
+ @property
89
+ def gap(self) -> float | None:
90
+ """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide."""
91
+ if self.avg_confidence is None or self.accuracy is None:
92
+ return None
93
+ return abs(self.avg_confidence - self.accuracy)
94
+
95
+
96
+ # ──────────────────────────────────────────────────────────────────────────
97
+ # Validation
98
+ # ──────────────────────────────────────────────────────────────────────────
99
+
100
+
101
+ def _validate_inputs(
102
+ confidences: list[float],
103
+ is_correct: list[bool | int],
104
+ ) -> None:
105
+ if len(confidences) != len(is_correct):
106
+ raise ValueError(
107
+ f"Longueurs incompatibles : confidences={len(confidences)} "
108
+ f"vs is_correct={len(is_correct)}"
109
+ )
110
+ for i, c in enumerate(confidences):
111
+ if not (0.0 <= float(c) <= 1.0):
112
+ raise ValueError(
113
+ f"Confiance hors [0, 1] à l'index {i} : {c!r}"
114
+ )
115
+
116
+
117
+ # ──────────────────────────────────────────���───────────────────────────────
118
+ # Reliability diagram (binning)
119
+ # ──────────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def reliability_diagram(
123
+ confidences: Iterable[float],
124
+ is_correct: Iterable[bool | int],
125
+ n_bins: int = 10,
126
+ ) -> list[CalibrationBin]:
127
+ """Découpe les prédictions en ``n_bins`` bins équidistants par confiance
128
+ et calcule pour chacun la confiance moyenne, la précision et le compte.
129
+
130
+ Parameters
131
+ ----------
132
+ confidences:
133
+ Confidences des prédictions, ``∈ [0, 1]``.
134
+ is_correct:
135
+ Indicateur booléen (1 = prédiction correcte, 0 = incorrecte).
136
+ n_bins:
137
+ Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)``
138
+ sauf le dernier bin qui inclut ``1.0``.
139
+
140
+ Returns
141
+ -------
142
+ list[CalibrationBin]
143
+ Liste de ``n_bins`` bins, dans l'ordre croissant des confidences.
144
+ """
145
+ if n_bins < 1:
146
+ raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}")
147
+
148
+ confs = [float(c) for c in confidences]
149
+ correct = [int(bool(x)) for x in is_correct]
150
+ _validate_inputs(confs, correct)
151
+
152
+ bin_width = 1.0 / n_bins
153
+ sums: list[float] = [0.0] * n_bins
154
+ correct_counts: list[int] = [0] * n_bins
155
+ counts: list[int] = [0] * n_bins
156
+
157
+ for c, ok in zip(confs, correct):
158
+ # Calcul du bin index par multiplication ``c * n_bins`` plutôt que
159
+ # division ``c / bin_width`` pour éviter les pièges de
160
+ # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754
161
+ # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)).
162
+ if c >= 1.0:
163
+ idx = n_bins - 1
164
+ else:
165
+ idx = int(c * n_bins)
166
+ # Garde-fou en cas d'arrondi flottant
167
+ if idx >= n_bins:
168
+ idx = n_bins - 1
169
+ elif idx < 0:
170
+ idx = 0
171
+ sums[idx] += c
172
+ correct_counts[idx] += ok
173
+ counts[idx] += 1
174
+
175
+ bins: list[CalibrationBin] = []
176
+ for k in range(n_bins):
177
+ low = k * bin_width
178
+ high = (k + 1) * bin_width
179
+ n = counts[k]
180
+ if n == 0:
181
+ bins.append(CalibrationBin(low, high, None, None, 0))
182
+ else:
183
+ bins.append(CalibrationBin(
184
+ bin_low=low,
185
+ bin_high=high,
186
+ avg_confidence=sums[k] / n,
187
+ accuracy=correct_counts[k] / n,
188
+ count=n,
189
+ ))
190
+ return bins
191
+
192
+
193
+ # ──────────────────────────────────────────────────────────────────────────
194
+ # ECE et MCE
195
+ # ──────────────────────────────────────────────────────────────────────────
196
+
197
+
198
+ def expected_calibration_error(
199
+ confidences: Iterable[float],
200
+ is_correct: Iterable[bool | int],
201
+ n_bins: int = 10,
202
+ ) -> float:
203
+ """Expected Calibration Error : moyenne pondérée par bin de l'écart
204
+ absolu confiance ↔ précision.
205
+
206
+ ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|``
207
+
208
+ où la somme porte sur les bins non vides.
209
+
210
+ Returns
211
+ -------
212
+ float
213
+ ``∈ [0, 1]``. ``0`` ↔ calibration parfaite.
214
+ """
215
+ bins = reliability_diagram(confidences, is_correct, n_bins=n_bins)
216
+ total = sum(b.count for b in bins)
217
+ if total == 0:
218
+ return 0.0
219
+ ece = 0.0
220
+ for b in bins:
221
+ if b.count == 0 or b.gap is None:
222
+ continue
223
+ ece += (b.count / total) * b.gap
224
+ return ece
225
+
226
+
227
+ def maximum_calibration_error(
228
+ confidences: Iterable[float],
229
+ is_correct: Iterable[bool | int],
230
+ n_bins: int = 10,
231
+ ) -> float:
232
+ """Maximum Calibration Error : pire écart confiance ↔ précision sur
233
+ tous les bins non vides.
234
+
235
+ Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 %
236
+ de confiance et il a tort une fois sur deux dans ce bin).
237
+
238
+ Returns
239
+ -------
240
+ float
241
+ ``∈ [0, 1]``. ``0`` ↔ calibration parfaite.
242
+ """
243
+ bins = reliability_diagram(confidences, is_correct, n_bins=n_bins)
244
+ gaps = [b.gap for b in bins if b.gap is not None]
245
+ return max(gaps) if gaps else 0.0
246
+
247
+
248
+ # ──────────────────────────────────────────────────────────────────────────
249
+ # Vue agrégée
250
+ # ──────────────────────────────────────────────────────────────────────��───
251
+
252
+
253
+ def compute_calibration_metrics(
254
+ confidences: Iterable[float],
255
+ is_correct: Iterable[bool | int],
256
+ n_bins: int = 10,
257
+ ) -> dict:
258
+ """Calcule l'ensemble des métriques de calibration en un appel.
259
+
260
+ Returns
261
+ -------
262
+ dict
263
+ ``{
264
+ "ece": float,
265
+ "mce": float,
266
+ "n_bins": int,
267
+ "n_predictions": int,
268
+ "overall_accuracy": float,
269
+ "overall_confidence": float,
270
+ "bins": [
271
+ {"bin_low", "bin_high", "avg_confidence",
272
+ "accuracy", "count", "gap"},
273
+ ...
274
+ ],
275
+ }``
276
+ """
277
+ confs = list(confidences)
278
+ correct = list(is_correct)
279
+ bins = reliability_diagram(confs, correct, n_bins=n_bins)
280
+ total = sum(b.count for b in bins)
281
+ overall_acc = (
282
+ sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0
283
+ )
284
+ overall_conf = (
285
+ sum(float(c) for c in confs) / total if total > 0 else 0.0
286
+ )
287
+
288
+ ece = 0.0
289
+ if total > 0:
290
+ for b in bins:
291
+ if b.gap is None:
292
+ continue
293
+ ece += (b.count / total) * b.gap
294
+ mce = max((b.gap for b in bins if b.gap is not None), default=0.0)
295
+
296
+ return {
297
+ "ece": ece,
298
+ "mce": mce,
299
+ "n_bins": n_bins,
300
+ "n_predictions": total,
301
+ "overall_accuracy": overall_acc,
302
+ "overall_confidence": overall_conf,
303
+ "bins": [
304
+ {
305
+ "bin_low": b.bin_low,
306
+ "bin_high": b.bin_high,
307
+ "avg_confidence": b.avg_confidence,
308
+ "accuracy": b.accuracy,
309
+ "count": b.count,
310
+ "gap": b.gap,
311
+ }
312
+ for b in bins
313
+ ],
314
+ }
315
+
316
+
317
+ __all__ = [
318
+ "CalibrationBin",
319
+ "reliability_diagram",
320
+ "expected_calibration_error",
321
+ "maximum_calibration_error",
322
+ "compute_calibration_metrics",
323
+ ]
tests/test_sprint39_calibration.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 39 — métriques de calibration (ECE, MCE, reliability).
2
+
3
+ Le module ``picarones.core.calibration`` expose :
4
+
5
+ - ``CalibrationBin`` : un bin du reliability diagram
6
+ - ``reliability_diagram(confidences, is_correct, n_bins=10)``
7
+ - ``expected_calibration_error`` (ECE)
8
+ - ``maximum_calibration_error`` (MCE)
9
+ - ``compute_calibration_metrics`` : vue agrégée
10
+
11
+ Les tests vérifient :
12
+
13
+ 1. **Calibration parfaite** : confidences uniformes égales à la précision
14
+ du bin → ECE = MCE = 0.
15
+ 2. **Sur-confiance extrême** : confidence = 1.0 mais 50 % correct →
16
+ ECE = 0.5 et MCE = 0.5.
17
+ 3. **Sous-confiance extrême** : confidence = 0.5 mais 100 % correct →
18
+ ECE = 0.5.
19
+ 4. **Calibration constante** : confidence = c, accuracy = a → ECE = |c-a|.
20
+ 5. **Reliability diagram** : binning correct, bornes correctes,
21
+ bin 1.0 inclus dans le dernier bin.
22
+ 6. **Bins vides** correctement gérés (avg_confidence/accuracy = None,
23
+ count = 0, gap = None).
24
+ 7. **Listes vides** → ECE = 0, MCE = 0.
25
+ 8. **Garde-fous** : longueurs incompatibles → ValueError ;
26
+ confidence hors [0, 1] → ValueError ; n_bins < 1 → ValueError.
27
+ 9. **n_bins paramétrable** : 5 bins vs 20 bins, bornes adaptées.
28
+ 10. **compute_calibration_metrics** : structure de retour complète et
29
+ cohérente avec les fonctions individuelles.
30
+ 11. **CalibrationBin.gap** : comportement attendu (None pour bin vide).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import pytest
36
+
37
+ from picarones.core.calibration import (
38
+ CalibrationBin,
39
+ compute_calibration_metrics,
40
+ expected_calibration_error,
41
+ maximum_calibration_error,
42
+ reliability_diagram,
43
+ )
44
+
45
+
46
+ # ──────────────────────────────────────────────────────────────────────────
47
+ # 1. Calibration parfaite
48
+ # ──────────────────────────────────────────────────────────────────────────
49
+
50
+
51
+ class TestPerfectCalibration:
52
+ def test_uniform_confidence_matching_accuracy_per_bin(self) -> None:
53
+ """Toutes les prédictions à confidence 0.75, 75 % correctes.
54
+ Le seul bin non vide est [0.7, 0.8) avec gap = 0.
55
+ """
56
+ confs = [0.75] * 100
57
+ correct = [1] * 75 + [0] * 25
58
+ assert expected_calibration_error(confs, correct) == pytest.approx(0.0, abs=1e-9)
59
+ assert maximum_calibration_error(confs, correct) == pytest.approx(0.0, abs=1e-9)
60
+
61
+ def test_two_bins_each_perfectly_calibrated(self) -> None:
62
+ # Bin [0.2, 0.3) : 25 % correct, 25 % conf
63
+ # Bin [0.8, 0.9) : 85 % correct, 85 % conf
64
+ confs = [0.25] * 100 + [0.85] * 100
65
+ correct = [1] * 25 + [0] * 75 + [1] * 85 + [0] * 15
66
+ assert expected_calibration_error(confs, correct) == pytest.approx(0.0, abs=1e-9)
67
+
68
+
69
+ # ──────────────────────────────────────────────────────────────────────────
70
+ # 2-3. Cas extrêmes
71
+ # ──────────────────────────────────────────────────────────────────────────
72
+
73
+
74
+ class TestExtremeCases:
75
+ def test_extreme_overconfidence(self) -> None:
76
+ # Le moteur dit "100 % sûr" mais a tort une fois sur deux
77
+ confs = [1.0] * 10
78
+ correct = [1] * 5 + [0] * 5
79
+ assert expected_calibration_error(confs, correct) == pytest.approx(0.5)
80
+ assert maximum_calibration_error(confs, correct) == pytest.approx(0.5)
81
+
82
+ def test_extreme_underconfidence(self) -> None:
83
+ # Le moteur dit "50 % sûr" mais a toujours raison
84
+ confs = [0.5] * 10
85
+ correct = [1] * 10
86
+ assert expected_calibration_error(confs, correct) == pytest.approx(0.5)
87
+ assert maximum_calibration_error(confs, correct) == pytest.approx(0.5)
88
+
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────
91
+ # 4. Calibration constante (gap = |c - a|)
92
+ # ──────────────────────────────────────────────────────────────────────────
93
+
94
+
95
+ class TestConstantBias:
96
+ @pytest.mark.parametrize("conf,acc", [(0.6, 0.4), (0.3, 0.7), (0.95, 0.85)])
97
+ def test_constant_bias_is_absolute_gap(
98
+ self, conf: float, acc: float
99
+ ) -> None:
100
+ """Avec un seul bin non vide, ECE = |conf - acc|."""
101
+ n = 100
102
+ confs = [conf] * n
103
+ n_correct = int(round(acc * n))
104
+ correct = [1] * n_correct + [0] * (n - n_correct)
105
+ ece = expected_calibration_error(confs, correct)
106
+ # acc effective = n_correct/n (peut différer légèrement de acc cible
107
+ # par arrondi entier)
108
+ actual_acc = n_correct / n
109
+ assert ece == pytest.approx(abs(conf - actual_acc), abs=1e-9)
110
+
111
+
112
+ # ──────────────────────────────────────────────────────────────────────────
113
+ # 5. Reliability diagram — binning
114
+ # ──────────────────────────────────────────────────────────────────────────
115
+
116
+
117
+ class TestReliabilityDiagramBinning:
118
+ def test_default_returns_10_bins(self) -> None:
119
+ bins = reliability_diagram([0.5], [1])
120
+ assert len(bins) == 10
121
+
122
+ def test_bin_bounds_are_equidistant(self) -> None:
123
+ bins = reliability_diagram([], [], n_bins=5)
124
+ widths = [b.bin_high - b.bin_low for b in bins]
125
+ for w in widths:
126
+ assert w == pytest.approx(0.2, abs=1e-9)
127
+ assert bins[0].bin_low == pytest.approx(0.0)
128
+ assert bins[-1].bin_high == pytest.approx(1.0)
129
+
130
+ def test_confidence_1_falls_in_last_bin(self) -> None:
131
+ bins = reliability_diagram([1.0, 1.0, 1.0], [1, 0, 1], n_bins=10)
132
+ # Toutes les prédictions doivent être dans le dernier bin
133
+ assert bins[-1].count == 3
134
+ assert sum(b.count for b in bins[:-1]) == 0
135
+
136
+ def test_predictions_assigned_to_correct_bin(self) -> None:
137
+ bins = reliability_diagram(
138
+ [0.05, 0.15, 0.55, 0.95],
139
+ [0, 1, 1, 0],
140
+ n_bins=10,
141
+ )
142
+ # bin [0.0, 0.1) → 1 prédiction
143
+ assert bins[0].count == 1
144
+ # bin [0.1, 0.2) → 1
145
+ assert bins[1].count == 1
146
+ # bin [0.5, 0.6) → 1
147
+ assert bins[5].count == 1
148
+ # bin [0.9, 1.0] → 1
149
+ assert bins[9].count == 1
150
+
151
+ def test_avg_confidence_and_accuracy_per_bin(self) -> None:
152
+ # Bin [0.6, 0.7) : confidences 0.6, 0.65 ; correct 1, 0
153
+ bins = reliability_diagram([0.6, 0.65], [1, 0], n_bins=10)
154
+ b6 = bins[6]
155
+ assert b6.count == 2
156
+ assert b6.avg_confidence == pytest.approx((0.6 + 0.65) / 2)
157
+ assert b6.accuracy == pytest.approx(0.5)
158
+
159
+
160
+ # ──────────────────────────────────────────────────────────────────────────
161
+ # 6. Bins vides
162
+ # ──────────────────────────────────────────────────────────────────────────
163
+
164
+
165
+ class TestEmptyBins:
166
+ def test_empty_bin_has_none_avg_and_accuracy(self) -> None:
167
+ bins = reliability_diagram([0.95], [1], n_bins=10)
168
+ # Tous les bins sauf le dernier sont vides
169
+ for b in bins[:-1]:
170
+ assert b.count == 0
171
+ assert b.avg_confidence is None
172
+ assert b.accuracy is None
173
+ assert b.gap is None
174
+
175
+ def test_ece_skips_empty_bins(self) -> None:
176
+ # Avec un seul bin non vide à gap 0, ECE doit être 0
177
+ bins = reliability_diagram([0.55] * 10, [1] * 6 + [0] * 4)
178
+ assert expected_calibration_error([0.55] * 10, [1] * 6 + [0] * 4) == \
179
+ pytest.approx(0.05)
180
+ # Confirmer que beaucoup de bins sont vides
181
+ empty = [b for b in bins if b.count == 0]
182
+ assert len(empty) == 9
183
+
184
+
185
+ # ──────────────────────────────────────────────────────────────────────────
186
+ # 7. Listes vides
187
+ # ──────────────────────────────────────────────────────────────────────────
188
+
189
+
190
+ class TestEmptyInputs:
191
+ def test_empty_lists_return_zero(self) -> None:
192
+ assert expected_calibration_error([], []) == 0.0
193
+ assert maximum_calibration_error([], []) == 0.0
194
+
195
+ def test_empty_reliability_diagram(self) -> None:
196
+ bins = reliability_diagram([], [], n_bins=10)
197
+ assert len(bins) == 10
198
+ assert all(b.count == 0 for b in bins)
199
+
200
+
201
+ # ──────────────────────────────────────────────────────────────────────────
202
+ # 8. Garde-fous
203
+ # ──────────────────────────────────────────────────────────────────────────
204
+
205
+
206
+ class TestGuards:
207
+ def test_length_mismatch_raises(self) -> None:
208
+ with pytest.raises(ValueError, match="Longueurs"):
209
+ expected_calibration_error([0.5, 0.5], [1])
210
+
211
+ def test_confidence_above_one_raises(self) -> None:
212
+ with pytest.raises(ValueError, match="hors"):
213
+ expected_calibration_error([1.5], [1])
214
+
215
+ def test_negative_confidence_raises(self) -> None:
216
+ with pytest.raises(ValueError, match="hors"):
217
+ expected_calibration_error([-0.1], [1])
218
+
219
+ def test_invalid_n_bins_raises(self) -> None:
220
+ with pytest.raises(ValueError, match="n_bins"):
221
+ reliability_diagram([0.5], [1], n_bins=0)
222
+
223
+ def test_n_bins_negative_raises(self) -> None:
224
+ with pytest.raises(ValueError, match="n_bins"):
225
+ reliability_diagram([0.5], [1], n_bins=-3)
226
+
227
+
228
+ # ──────────────────────────────────────────────────────────────────────────
229
+ # 9. n_bins paramétrable
230
+ # ──────────────────────────────────────────────────────────────────────────
231
+
232
+
233
+ class TestVariableNBins:
234
+ @pytest.mark.parametrize("n_bins,expected_width", [
235
+ (5, 0.2), (10, 0.1), (20, 0.05), (1, 1.0),
236
+ ])
237
+ def test_bin_width_scales_with_n_bins(
238
+ self, n_bins: int, expected_width: float
239
+ ) -> None:
240
+ bins = reliability_diagram([], [], n_bins=n_bins)
241
+ assert len(bins) == n_bins
242
+ for b in bins:
243
+ assert (b.bin_high - b.bin_low) == pytest.approx(expected_width)
244
+
245
+ def test_finer_bins_can_only_increase_or_keep_ece(self) -> None:
246
+ """À distribution donnée, n_bins plus grand révèle des écarts
247
+ masqués par un binning grossier — ECE ne décroît pas."""
248
+ confs = [0.6, 0.65, 0.7, 0.95, 0.95]
249
+ correct = [1, 0, 1, 1, 0]
250
+ ece_5 = expected_calibration_error(confs, correct, n_bins=5)
251
+ ece_20 = expected_calibration_error(confs, correct, n_bins=20)
252
+ assert ece_20 >= ece_5 - 1e-9
253
+
254
+
255
+ # ──────────────────────────────────────────────────────────────────────────
256
+ # 10. compute_calibration_metrics
257
+ # ──────────────────────────────────────────────────────────────────────────
258
+
259
+
260
+ class TestComputeCalibrationMetrics:
261
+ def test_returns_full_structure(self) -> None:
262
+ confs = [0.6, 0.7, 0.95, 0.95]
263
+ correct = [1, 0, 1, 1]
264
+ out = compute_calibration_metrics(confs, correct, n_bins=10)
265
+ assert set(out.keys()) >= {
266
+ "ece", "mce", "n_bins", "n_predictions",
267
+ "overall_accuracy", "overall_confidence", "bins",
268
+ }
269
+ assert out["n_predictions"] == 4
270
+ assert out["overall_accuracy"] == pytest.approx(3 / 4)
271
+ assert out["overall_confidence"] == pytest.approx((0.6 + 0.7 + 0.95 + 0.95) / 4)
272
+ assert len(out["bins"]) == 10
273
+
274
+ def test_ece_matches_function(self) -> None:
275
+ confs = [0.55, 0.65, 0.75, 0.85, 0.95]
276
+ correct = [1, 0, 1, 0, 1]
277
+ out = compute_calibration_metrics(confs, correct)
278
+ assert out["ece"] == pytest.approx(
279
+ expected_calibration_error(confs, correct), abs=1e-9
280
+ )
281
+ assert out["mce"] == pytest.approx(
282
+ maximum_calibration_error(confs, correct), abs=1e-9
283
+ )
284
+
285
+ def test_bin_dicts_contain_gap(self) -> None:
286
+ out = compute_calibration_metrics([0.55] * 4, [1, 1, 0, 1])
287
+ # Bin [0.5, 0.6) : avg_conf = 0.55, accuracy = 0.75, gap = 0.20
288
+ b5 = out["bins"][5]
289
+ assert b5["count"] == 4
290
+ assert b5["gap"] == pytest.approx(0.20, abs=1e-9)
291
+
292
+
293
+ # ──────────────────────────────────────────────────────────────────────────
294
+ # 11. CalibrationBin.gap
295
+ # ──────────────────────────────────────────────────────────────────────────
296
+
297
+
298
+ class TestCalibrationBinGap:
299
+ def test_gap_for_empty_bin_is_none(self) -> None:
300
+ b = CalibrationBin(0.0, 0.1, None, None, 0)
301
+ assert b.gap is None
302
+
303
+ def test_gap_is_absolute_difference(self) -> None:
304
+ b = CalibrationBin(0.5, 0.6, 0.55, 0.30, 10)
305
+ assert b.gap == pytest.approx(0.25)
306
+
307
+ def test_gap_symmetric(self) -> None:
308
+ b1 = CalibrationBin(0.5, 0.6, 0.55, 0.30, 10)
309
+ b2 = CalibrationBin(0.5, 0.6, 0.30, 0.55, 10)
310
+ assert b1.gap == pytest.approx(b2.gap)