Spaces:
Running
sprint39: A.II.1.b Calibration — couche de calcul (ECE, MCE, reliability)
Browse filesDeuxiè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 +40 -5
- CLAUDE.md +2 -1
- picarones/core/calibration.py +323 -0
- tests/test_sprint39_calibration.py +310 -0
|
@@ -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 →
|
| 190 |
-
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38
|
| 191 |
-
régression. **Phase 0 close ; Étape 2 du plan
|
| 192 |
-
livrés bout-en-bout (Sprints 35-37) ;
|
| 193 |
-
|
|
|
|
| 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 |
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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 |
+
]
|
|
@@ -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)
|