Spaces:
Running
sprint42: A.II.1.b Calibration — token_confidences + câblage runner
Browse filesSuite directe du Sprint 39 (couche de calcul pure). Le runner peut
maintenant calculer ECE/MCE/reliability dès qu'un moteur expose des
confidences au niveau token sur l'EngineResult.
EngineResult.token_confidences (Optional[list[dict[str, Any]]])
- None par défaut → rétrocompat stricte pour TOUS les adapters
historiques (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI).
- Format attendu : [{"token": str, "confidence": float}, …] avec
confidence ∈ [0, 1] ou ∈ [0, 100] (normalisé par le runner).
Modèles étendus
- DocumentResult.calibration_metrics: Optional[dict] (sérialisé dans
as_dict() quand renseigné, libéré par compact()).
- EngineReport.aggregated_calibration: Optional[dict].
Câblage runner
- _calibration_from_engine_result(ground_truth, token_confidences) :
aligne par bag-of-words avec multiplicité (proxy oracle, comme
oracle_token_recall du Sprint 35), normalise les confidences en
pourcentage à [0, 1], ignore les négatives (Tesseract met -1 pour
les non-mots).
- Appelé dans _compute_document_result quand token_confidences est
non-vide ; sinon calibration_metrics reste None.
- _aggregate_calibration combine les bins de tous les docs en somme
pondérée par count, recalcule ECE/MCE micro sur l'ensemble.
L'adaptation de chaque adapter (Tesseract via image_to_data,
Pero via PageLayout, Mistral via confidence, Google Vision via
Word.confidence, Azure DI) à exposer ses confidences natives est
reportée à des sprints dédiés (un par adapter, plus testable
individuellement). Ce sprint pose l'infrastructure complète et la
rend testable de bout-en-bout via mock.
Tests : +17 dans test_sprint42_calibration_runner.py couvrant le
nouveau champ EngineResult, la sérialisation et compact des nouveaux
champs DR/ER, l'helper d'alignement (calibration parfaite quand
conf=accuracy, normalisation %, skip négatifs, bag-of-words avec
multiplicité, skip entrées invalides), l'agrégateur (combinaison de
bins multi-docs avec recalcul ECE/MCE micro), et la rétrocompat
(pas de calcul sans token_confidences).
Suite complète : 1735 → 1752 passed, 2 skipped, 0 failed.
État A.II.1.b (Calibration) : couche de calcul (Sprint 39) + câblage
runner (Sprint 42) livrés. Reste la vue HTML reliability diagram
(Sprint 43 à venir) et l'adaptation effective des engines pour
exposer leurs confidences natives.
- CHANGELOG.md +42 -6
- CLAUDE.md +2 -1
- picarones/core/results.py +20 -0
- picarones/core/runner.py +172 -0
- picarones/engines/base.py +7 -0
- tests/test_sprint42_calibration_runner.py +284 -0
|
@@ -16,6 +16,40 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 41 — A.II.1.a NER : vue HTML dédiée (clôture A.II.1.a).**
|
| 20 |
Suite directe des Sprints 38-40. Le moteur narratif et le runner ont
|
| 21 |
déjà tout ce qu'il faut ; ce sprint rend les chiffres visibles et
|
|
@@ -299,13 +333,15 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 299 |
|
| 300 |
### Tests
|
| 301 |
|
| 302 |
-
- 1478 →
|
| 303 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 304 |
-
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41
|
| 305 |
-
**Phase 0 close ; Étape 2 du plan d'évolution :
|
| 306 |
-
(A.II.1.c) et NER (A.II.1.a) livrés bout-en-bout
|
| 307 |
-
→ narratif → HTML ; calibration (A.II.1.b) couche
|
| 308 |
-
|
|
|
|
|
|
|
| 309 |
|
| 310 |
---
|
| 311 |
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 42 — A.II.1.b Calibration : exposition `token_confidences` +
|
| 20 |
+
câblage runner.** Suite directe du Sprint 39 (couche de calcul). Le
|
| 21 |
+
runner peut maintenant calculer ECE/MCE/reliability dès qu'un moteur
|
| 22 |
+
expose des confidences au niveau token.
|
| 23 |
+
- `EngineResult.token_confidences: Optional[list[dict[str, Any]]]`
|
| 24 |
+
ajouté. Format attendu : `[{"token": str, "confidence": float}, …]`,
|
| 25 |
+
confidence ∈ [0, 1] ou ∈ [0, 100] (normalisé par le runner).
|
| 26 |
+
`None` par défaut → comportement strictement rétrocompat pour tous
|
| 27 |
+
les adapters historiques (Tesseract, Pero, Mistral OCR, Google
|
| 28 |
+
Vision, Azure DI). L'adaptation de chaque adapter à exposer ses
|
| 29 |
+
confidences natives est reportée à des sprints dédiés (un par
|
| 30 |
+
adapter).
|
| 31 |
+
- `DocumentResult.calibration_metrics: Optional[dict]` ajouté
|
| 32 |
+
(sérialisé dans `as_dict` quand renseigné, libéré par `compact()`).
|
| 33 |
+
- `EngineReport.aggregated_calibration: Optional[dict]` ajouté.
|
| 34 |
+
- Helper `_calibration_from_engine_result(ground_truth, token_confidences)` :
|
| 35 |
+
aligne par bag-of-words avec multiplicité (proxy oracle, comme
|
| 36 |
+
`oracle_token_recall` du Sprint 35), normalise les confidences en
|
| 37 |
+
pourcentage à `[0, 1]`, ignore les confidences négatives
|
| 38 |
+
(Tesseract met -1 pour les non-mots), retourne `None` sur entrée
|
| 39 |
+
vide. Appelé dans `_compute_document_result` quand
|
| 40 |
+
`EngineResult.token_confidences` est non-vide.
|
| 41 |
+
- Helper `_aggregate_calibration(doc_results)` : combine les bins de
|
| 42 |
+
tous les docs en somme pondérée par count, recalcule ECE/MCE micro
|
| 43 |
+
sur l'ensemble. Renvoie `None` si aucun doc n'a de
|
| 44 |
+
`calibration_metrics`.
|
| 45 |
+
- +17 tests dans `test_sprint42_calibration_runner.py` couvrant le
|
| 46 |
+
nouveau champ EngineResult, la sérialisation et compact des
|
| 47 |
+
nouveaux champs DR/ER, l'helper d'alignement (calibration parfaite,
|
| 48 |
+
normalisation %, skip négatifs, bag-of-words avec multiplicité,
|
| 49 |
+
skip entrées invalides), l'agrégateur (combinaison de bins
|
| 50 |
+
multi-docs, recalcul ECE/MCE micro), et la rétrocompat
|
| 51 |
+
(pas de calcul sans token_confidences).
|
| 52 |
+
|
| 53 |
- **Sprint 41 — A.II.1.a NER : vue HTML dédiée (clôture A.II.1.a).**
|
| 54 |
Suite directe des Sprints 38-40. Le moteur narratif et le runner ont
|
| 55 |
déjà tout ce qu'il faut ; ce sprint rend les chiffres visibles et
|
|
|
|
| 333 |
|
| 334 |
### Tests
|
| 335 |
|
| 336 |
+
- 1478 → 1752 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
|
| 337 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 338 |
+
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42).
|
| 339 |
+
Aucune régression. **Phase 0 close ; Étape 2 du plan d'évolution :
|
| 340 |
+
inter-moteurs (A.II.1.c) et NER (A.II.1.a) livrés bout-en-bout
|
| 341 |
+
calcul → runner → narratif → HTML ; calibration (A.II.1.b) couche
|
| 342 |
+
de calcul + câblage runner livrés (Sprints 39+42), il manque la vue
|
| 343 |
+
HTML reliability diagram et l'adaptation des engines pour exposer
|
| 344 |
+
leurs confidences natives.**
|
| 345 |
|
| 346 |
---
|
| 347 |
|
|
@@ -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 |
| 41 | **Sprint 10 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : vue HTML NER (clôture A.II.1.a)**. Nouveau module `picarones/report/ner_render.py` : `build_ner_summary_html` rend un tableau résumé (F1 global, P, R, docs évalués, hallucinations, missed) avec cellule F1 colorée par gradient rouge → jaune → vert ; `build_ner_per_category_html` rend la heatmap moteur × catégorie d'entité (PER, LOC, ORG, DATE, MISC…) avec tooltip `support=N`, cellule vide marquée `—` pour les catégories non observées. Rendu server-side, pas de JS, déterministe. Anti-injection HTML via `html.escape`. `_build_report_data` expose `aggregated_ner` par moteur. `ReportGenerator.generate` calcule les deux blocs et les passe au template `view_analyses.html` qui les affiche dans une `chart-card` à largeur pleine **uniquement si ≥ 1 moteur a un `aggregated_ner`**. +12 clés i18n FR/EN. +38 tests dans `test_sprint41_ner_html.py` (rendu, masquage adaptatif, anti-injection, intégration FR + EN, complétude i18n). **Verrou levé** : A.II.1.a (NER) est désormais livré bout-en-bout — couche de calcul (Sprint 38) + backend + câblage runner (Sprint 40) + vue HTML (Sprint 41). Reste la calibration A.II.1.b à finir bout-en-bout (extraction des token_confidences depuis les engines + vue HTML reliability diagram). |
|
| 211 |
| 40 | **Sprint 9 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : NER backend + câblage runner**. Suite du Sprint 38 (couche de calcul). Nouveau module `picarones/core/ner_backends.py` : `EntityExtractor` (Protocol, tout callable `(text) → list[dict]` est valide), `SpacyEntityExtractor` (lazy-import spaCy, charge le modèle au premier appel, fallback gracieux silencieux + warning explicite si spaCy/modèle absent, mapping par défaut spaCy → conventions HIPE : PERSON→PER, GPE→LOC, etc.), `SPACY_PROFILES` (6 profils nommés), `get_extractor(profile)`, `is_spacy_available()`. `DocumentResult.ner_metrics: Optional[dict]` et `EngineReport.aggregated_ner` ajoutés (sérialisés dans `as_dict` quand renseignés, libérés par `compact()`). `runner.run_benchmark` accepte un nouveau paramètre optionnel `entity_extractor` ; si fourni, helpers `_attach_ner_metrics` et `_aggregate_ner` calculent les métriques en post-process (main process pour éviter de pickler spaCy dans les sous-processus). Rétrocompat stricte : sans `entity_extractor`, aucun calcul ni champ ajouté. Nouveau extra `[ner]` dans `pyproject.toml` (spacy>=3.7.0). +16 tests dans `test_sprint40_ner_runner.py` (fallback sans spaCy + warning, idempotence load, profils + factory, sérialisation nouveaux champs, câblage runner avec mock injecté, agrégation micro-F1, rétrocompat sans extracteur, robustesse à un extracteur qui lève). **Verrou levé** : un benchmark dont le corpus a une GT entités produit maintenant des métriques NER bout-en-bout — il manque uniquement la vue HTML dédiée (Sprint 41 à venir). |
|
| 212 |
| 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. |
|
|
@@ -259,7 +260,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 259 |
## Contexte développement
|
| 260 |
|
| 261 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 262 |
-
- **Tests** :
|
| 263 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 264 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 265 |
- **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 |
+
| 42 | **Sprint 11 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : exposition `token_confidences` + câblage runner**. Suite du Sprint 39 (couche de calcul). `EngineResult` gagne un champ optionnel `token_confidences: Optional[list[dict[str, Any]]]` (`None` par défaut → rétrocompat stricte). `DocumentResult.calibration_metrics` et `EngineReport.aggregated_calibration` ajoutés (sérialisation dans `as_dict` conditionnelle, libérés par `compact()`). Nouveau helper `_calibration_from_engine_result` qui aligne par bag-of-words avec multiplicité (proxy oracle, comme `oracle_token_recall`), normalise les confidences en pourcentage à `[0, 1]`, ignore les confidences négatives (Tesseract met -1 pour les non-mots) ; appelé dans `_compute_document_result` quand `token_confidences` est non-vide. Helper `_aggregate_calibration` combine les bins de tous les docs en somme pondérée par count, recalcule ECE/MCE micro. **L'adaptation de chaque adapter (Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI) à exposer ses confidences natives est reportée à des sprints dédiés** : ce sprint pose l'infrastructure complète et la teste avec un mock. +17 tests dans `test_sprint42_calibration_runner.py` (champ EngineResult, sérialisation/compact, helper d'alignement avec calibration parfaite + normalisation % + skip négatifs + bag-of-words multiplicité, agrégation multi-docs, rétrocompat sans confidences). **Verrou levé** : un moteur qui expose ses confidences (cas réel à venir) verra automatiquement ses métriques de calibration calculées et agrégées par le runner — il manque uniquement la vue HTML reliability et l'adaptation des engines un par un. |
|
| 211 |
| 41 | **Sprint 10 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : vue HTML NER (clôture A.II.1.a)**. Nouveau module `picarones/report/ner_render.py` : `build_ner_summary_html` rend un tableau résumé (F1 global, P, R, docs évalués, hallucinations, missed) avec cellule F1 colorée par gradient rouge → jaune → vert ; `build_ner_per_category_html` rend la heatmap moteur × catégorie d'entité (PER, LOC, ORG, DATE, MISC…) avec tooltip `support=N`, cellule vide marquée `—` pour les catégories non observées. Rendu server-side, pas de JS, déterministe. Anti-injection HTML via `html.escape`. `_build_report_data` expose `aggregated_ner` par moteur. `ReportGenerator.generate` calcule les deux blocs et les passe au template `view_analyses.html` qui les affiche dans une `chart-card` à largeur pleine **uniquement si ≥ 1 moteur a un `aggregated_ner`**. +12 clés i18n FR/EN. +38 tests dans `test_sprint41_ner_html.py` (rendu, masquage adaptatif, anti-injection, intégration FR + EN, complétude i18n). **Verrou levé** : A.II.1.a (NER) est désormais livré bout-en-bout — couche de calcul (Sprint 38) + backend + câblage runner (Sprint 40) + vue HTML (Sprint 41). Reste la calibration A.II.1.b à finir bout-en-bout (extraction des token_confidences depuis les engines + vue HTML reliability diagram). |
|
| 212 |
| 40 | **Sprint 9 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : NER backend + câblage runner**. Suite du Sprint 38 (couche de calcul). Nouveau module `picarones/core/ner_backends.py` : `EntityExtractor` (Protocol, tout callable `(text) → list[dict]` est valide), `SpacyEntityExtractor` (lazy-import spaCy, charge le modèle au premier appel, fallback gracieux silencieux + warning explicite si spaCy/modèle absent, mapping par défaut spaCy → conventions HIPE : PERSON→PER, GPE→LOC, etc.), `SPACY_PROFILES` (6 profils nommés), `get_extractor(profile)`, `is_spacy_available()`. `DocumentResult.ner_metrics: Optional[dict]` et `EngineReport.aggregated_ner` ajoutés (sérialisés dans `as_dict` quand renseignés, libérés par `compact()`). `runner.run_benchmark` accepte un nouveau paramètre optionnel `entity_extractor` ; si fourni, helpers `_attach_ner_metrics` et `_aggregate_ner` calculent les métriques en post-process (main process pour éviter de pickler spaCy dans les sous-processus). Rétrocompat stricte : sans `entity_extractor`, aucun calcul ni champ ajouté. Nouveau extra `[ner]` dans `pyproject.toml` (spacy>=3.7.0). +16 tests dans `test_sprint40_ner_runner.py` (fallback sans spaCy + warning, idempotence load, profils + factory, sérialisation nouveaux champs, câblage runner avec mock injecté, agrégation micro-F1, rétrocompat sans extracteur, robustesse à un extracteur qui lève). **Verrou levé** : un benchmark dont le corpus a une GT entités produit maintenant des métriques NER bout-en-bout — il manque uniquement la vue HTML dédiée (Sprint 41 à venir). |
|
| 213 |
| 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. |
|
|
|
|
| 260 |
## Contexte développement
|
| 261 |
|
| 262 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 263 |
+
- **Tests** : 1752 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout calcul → runner → HTML ; Sprints 39+42 = calibration couche de calcul + câblage runner, vue HTML reliability + adaptation engines à venir)
|
| 264 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 265 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 266 |
- **Transcript de la conversation de développement** :
|
|
@@ -61,6 +61,15 @@ class DocumentResult:
|
|
| 61 |
le document a un niveau de GT ``ENTITIES`` ET que le runner a reçu
|
| 62 |
un ``EntityExtractor``.
|
| 63 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
def as_dict(self) -> dict:
|
| 66 |
d = {
|
|
@@ -92,6 +101,8 @@ class DocumentResult:
|
|
| 92 |
d["hallucination_metrics"] = self.hallucination_metrics
|
| 93 |
if self.ner_metrics is not None:
|
| 94 |
d["ner_metrics"] = self.ner_metrics
|
|
|
|
|
|
|
| 95 |
return d
|
| 96 |
|
| 97 |
def compact(self) -> None:
|
|
@@ -118,6 +129,7 @@ class DocumentResult:
|
|
| 118 |
self.line_metrics = None
|
| 119 |
self.hallucination_metrics = None
|
| 120 |
self.ner_metrics = None
|
|
|
|
| 121 |
|
| 122 |
|
| 123 |
@dataclass
|
|
@@ -155,6 +167,12 @@ class EngineReport:
|
|
| 155 |
"""Métriques NER agrégées sur le corpus : F1 micro/macro globaux et
|
| 156 |
par catégorie, total hallucinations/missed. ``None`` si aucun
|
| 157 |
document n'a porté de calcul NER."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
def __post_init__(self) -> None:
|
| 160 |
if not self.aggregated_metrics and self.document_results:
|
|
@@ -217,6 +235,8 @@ class EngineReport:
|
|
| 217 |
d["aggregated_hallucination"] = self.aggregated_hallucination
|
| 218 |
if self.aggregated_ner is not None:
|
| 219 |
d["aggregated_ner"] = self.aggregated_ner
|
|
|
|
|
|
|
| 220 |
return d
|
| 221 |
|
| 222 |
|
|
|
|
| 61 |
le document a un niveau de GT ``ENTITIES`` ET que le runner a reçu
|
| 62 |
un ``EntityExtractor``.
|
| 63 |
"""
|
| 64 |
+
# Sprint 42 — calibration des confidences moteur (ECE, MCE, bins)
|
| 65 |
+
calibration_metrics: Optional[dict] = None
|
| 66 |
+
"""Métriques de calibration (Sprint 39+42).
|
| 67 |
+
|
| 68 |
+
Format : retour de ``compute_calibration_metrics`` (ece, mce,
|
| 69 |
+
n_bins, n_predictions, overall_accuracy, overall_confidence, bins).
|
| 70 |
+
Présent uniquement si le moteur a fourni des ``token_confidences``
|
| 71 |
+
sur l'``EngineResult``.
|
| 72 |
+
"""
|
| 73 |
|
| 74 |
def as_dict(self) -> dict:
|
| 75 |
d = {
|
|
|
|
| 101 |
d["hallucination_metrics"] = self.hallucination_metrics
|
| 102 |
if self.ner_metrics is not None:
|
| 103 |
d["ner_metrics"] = self.ner_metrics
|
| 104 |
+
if self.calibration_metrics is not None:
|
| 105 |
+
d["calibration_metrics"] = self.calibration_metrics
|
| 106 |
return d
|
| 107 |
|
| 108 |
def compact(self) -> None:
|
|
|
|
| 129 |
self.line_metrics = None
|
| 130 |
self.hallucination_metrics = None
|
| 131 |
self.ner_metrics = None
|
| 132 |
+
self.calibration_metrics = None
|
| 133 |
|
| 134 |
|
| 135 |
@dataclass
|
|
|
|
| 167 |
"""Métriques NER agrégées sur le corpus : F1 micro/macro globaux et
|
| 168 |
par catégorie, total hallucinations/missed. ``None`` si aucun
|
| 169 |
document n'a porté de calcul NER."""
|
| 170 |
+
# Sprint 42
|
| 171 |
+
aggregated_calibration: Optional[dict] = None
|
| 172 |
+
"""Calibration agrégée sur le corpus : ECE, MCE, reliability diagram
|
| 173 |
+
micro recalculé à partir des sommes par bin. ``None`` si aucun
|
| 174 |
+
document n'avait de ``calibration_metrics`` (cas par défaut tant que
|
| 175 |
+
les engines n'exposent pas ``token_confidences``)."""
|
| 176 |
|
| 177 |
def __post_init__(self) -> None:
|
| 178 |
if not self.aggregated_metrics and self.document_results:
|
|
|
|
| 235 |
d["aggregated_hallucination"] = self.aggregated_hallucination
|
| 236 |
if self.aggregated_ner is not None:
|
| 237 |
d["aggregated_ner"] = self.aggregated_ner
|
| 238 |
+
if self.aggregated_calibration is not None:
|
| 239 |
+
d["aggregated_calibration"] = self.aggregated_calibration
|
| 240 |
return d
|
| 241 |
|
| 242 |
|
|
@@ -101,6 +101,67 @@ def _io_doc_worker(
|
|
| 101 |
# Calcul documentaire centralisé
|
| 102 |
# ---------------------------------------------------------------------------
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def _compute_document_result(
|
| 105 |
doc_id: str,
|
| 106 |
image_path: str,
|
|
@@ -204,6 +265,18 @@ def _compute_document_result(
|
|
| 204 |
except Exception as e:
|
| 205 |
_logger.warning("[hallucination] fonctionnalité dégradée : %s", e)
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
try:
|
| 208 |
from picarones.core.image_quality import analyze_image_quality
|
| 209 |
iq = analyze_image_quality(image_path)
|
|
@@ -229,6 +302,7 @@ def _compute_document_result(
|
|
| 229 |
image_quality=image_quality_data,
|
| 230 |
line_metrics=line_metrics_data,
|
| 231 |
hallucination_metrics=hallucination_data,
|
|
|
|
| 232 |
)
|
| 233 |
|
| 234 |
|
|
@@ -636,6 +710,7 @@ def run_benchmark(
|
|
| 636 |
agg_image_quality = _aggregate_image_quality(document_results)
|
| 637 |
agg_line_metrics = _aggregate_line_metrics(document_results)
|
| 638 |
agg_hallucination = _aggregate_hallucination(document_results)
|
|
|
|
| 639 |
|
| 640 |
report = EngineReport(
|
| 641 |
engine_name=engine.name,
|
|
@@ -650,6 +725,7 @@ def run_benchmark(
|
|
| 650 |
aggregated_image_quality=agg_image_quality,
|
| 651 |
aggregated_line_metrics=agg_line_metrics,
|
| 652 |
aggregated_hallucination=agg_hallucination,
|
|
|
|
| 653 |
)
|
| 654 |
engine_reports.append(report)
|
| 655 |
logger.info(
|
|
@@ -957,6 +1033,102 @@ def _attach_ner_metrics(
|
|
| 957 |
logger.info("[ner] %d documents évalués pour NER.", n_done)
|
| 958 |
|
| 959 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
def _aggregate_ner(doc_results: list) -> Optional[dict]:
|
| 961 |
"""Agrège les métriques NER au niveau du moteur.
|
| 962 |
|
|
|
|
| 101 |
# Calcul documentaire centralisé
|
| 102 |
# ---------------------------------------------------------------------------
|
| 103 |
|
| 104 |
+
|
| 105 |
+
def _calibration_from_engine_result(
|
| 106 |
+
ground_truth: str,
|
| 107 |
+
token_confidences: list,
|
| 108 |
+
) -> Optional[dict]:
|
| 109 |
+
"""Aligne les ``token_confidences`` du moteur sur la GT (bag-of-words)
|
| 110 |
+
pour produire les listes parallèles ``confidences`` / ``is_correct``,
|
| 111 |
+
puis appelle ``compute_calibration_metrics`` (Sprint 39).
|
| 112 |
+
|
| 113 |
+
Convention d'alignement (proxy bag-of-words avec multiplicité, comme
|
| 114 |
+
``oracle_token_recall`` du Sprint 35) : un token de l'hypothèse est
|
| 115 |
+
"correct" si la GT contient encore une occurrence de ce token.
|
| 116 |
+
Ce n'est pas un alignement séquentiel ; c'est volontaire pour rester
|
| 117 |
+
simple et robuste aux décalages d'OCR.
|
| 118 |
+
|
| 119 |
+
Les confidences ``> 1.0`` sont supposées en pourcentage et
|
| 120 |
+
normalisées à ``[0, 1]``. Les confidences négatives (Tesseract met
|
| 121 |
+
-1 pour les non-mots) sont ignorées.
|
| 122 |
+
"""
|
| 123 |
+
from collections import Counter
|
| 124 |
+
|
| 125 |
+
from picarones.core.calibration import compute_calibration_metrics
|
| 126 |
+
|
| 127 |
+
if not token_confidences:
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
gt_counter = Counter((ground_truth or "").split())
|
| 131 |
+
confidences: list[float] = []
|
| 132 |
+
is_correct: list[int] = []
|
| 133 |
+
|
| 134 |
+
for tc in token_confidences:
|
| 135 |
+
if not isinstance(tc, dict):
|
| 136 |
+
continue
|
| 137 |
+
token = str(tc.get("token", ""))
|
| 138 |
+
if not token:
|
| 139 |
+
continue
|
| 140 |
+
try:
|
| 141 |
+
conf = float(tc.get("confidence"))
|
| 142 |
+
except (TypeError, ValueError):
|
| 143 |
+
continue
|
| 144 |
+
if conf < 0:
|
| 145 |
+
# -1 = non-mot dans le format Tesseract image_to_data
|
| 146 |
+
continue
|
| 147 |
+
if conf > 1.0:
|
| 148 |
+
conf = conf / 100.0
|
| 149 |
+
if not 0.0 <= conf <= 1.0:
|
| 150 |
+
continue
|
| 151 |
+
if gt_counter[token] > 0:
|
| 152 |
+
is_correct.append(1)
|
| 153 |
+
gt_counter[token] -= 1
|
| 154 |
+
else:
|
| 155 |
+
is_correct.append(0)
|
| 156 |
+
confidences.append(conf)
|
| 157 |
+
|
| 158 |
+
if not confidences:
|
| 159 |
+
return None
|
| 160 |
+
return compute_calibration_metrics(confidences, is_correct)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
|
| 165 |
def _compute_document_result(
|
| 166 |
doc_id: str,
|
| 167 |
image_path: str,
|
|
|
|
| 265 |
except Exception as e:
|
| 266 |
_logger.warning("[hallucination] fonctionnalité dégradée : %s", e)
|
| 267 |
|
| 268 |
+
# Sprint 42 — calibration des confidences moteur (en dehors du
|
| 269 |
+
# ``if ocr_result.success`` puisqu'on peut avoir des confidences même
|
| 270 |
+
# sur un succès partiel).
|
| 271 |
+
calibration_data: Optional[dict] = None
|
| 272 |
+
if ocr_result.token_confidences:
|
| 273 |
+
try:
|
| 274 |
+
calibration_data = _calibration_from_engine_result(
|
| 275 |
+
ground_truth, ocr_result.token_confidences,
|
| 276 |
+
)
|
| 277 |
+
except Exception as e:
|
| 278 |
+
_logger.warning("[calibration] fonctionnalité dégradée : %s", e)
|
| 279 |
+
|
| 280 |
try:
|
| 281 |
from picarones.core.image_quality import analyze_image_quality
|
| 282 |
iq = analyze_image_quality(image_path)
|
|
|
|
| 302 |
image_quality=image_quality_data,
|
| 303 |
line_metrics=line_metrics_data,
|
| 304 |
hallucination_metrics=hallucination_data,
|
| 305 |
+
calibration_metrics=calibration_data,
|
| 306 |
)
|
| 307 |
|
| 308 |
|
|
|
|
| 710 |
agg_image_quality = _aggregate_image_quality(document_results)
|
| 711 |
agg_line_metrics = _aggregate_line_metrics(document_results)
|
| 712 |
agg_hallucination = _aggregate_hallucination(document_results)
|
| 713 |
+
agg_calibration = _aggregate_calibration(document_results)
|
| 714 |
|
| 715 |
report = EngineReport(
|
| 716 |
engine_name=engine.name,
|
|
|
|
| 725 |
aggregated_image_quality=agg_image_quality,
|
| 726 |
aggregated_line_metrics=agg_line_metrics,
|
| 727 |
aggregated_hallucination=agg_hallucination,
|
| 728 |
+
aggregated_calibration=agg_calibration,
|
| 729 |
)
|
| 730 |
engine_reports.append(report)
|
| 731 |
logger.info(
|
|
|
|
| 1033 |
logger.info("[ner] %d documents évalués pour NER.", n_done)
|
| 1034 |
|
| 1035 |
|
| 1036 |
+
def _aggregate_calibration(doc_results: list) -> Optional[dict]:
|
| 1037 |
+
"""Agrège la calibration micro sur tous les docs.
|
| 1038 |
+
|
| 1039 |
+
Recalcule ECE/MCE à partir de la **somme des bins** de chaque
|
| 1040 |
+
document : pour chaque bin, on additionne ``count``, on agrège la
|
| 1041 |
+
confiance moyenne pondérée par count, et on agrège l'accuracy
|
| 1042 |
+
pondérée par count. L'ECE micro est ensuite la moyenne pondérée
|
| 1043 |
+
par bin de ``|conf - acc|``.
|
| 1044 |
+
"""
|
| 1045 |
+
relevant = [
|
| 1046 |
+
dr for dr in doc_results
|
| 1047 |
+
if dr.calibration_metrics is not None
|
| 1048 |
+
and (dr.calibration_metrics.get("bins") or [])
|
| 1049 |
+
]
|
| 1050 |
+
if not relevant:
|
| 1051 |
+
return None
|
| 1052 |
+
|
| 1053 |
+
# Aligne tous les docs sur le même nombre de bins (par sécurité, on
|
| 1054 |
+
# vérifie qu'ils sont cohérents — sinon on prend le 1er en
|
| 1055 |
+
# référence et on saute les incohérents avec un warning).
|
| 1056 |
+
n_bins = relevant[0].calibration_metrics.get("n_bins", 10)
|
| 1057 |
+
sum_conf: list[float] = [0.0] * n_bins
|
| 1058 |
+
sum_acc: list[float] = [0.0] * n_bins
|
| 1059 |
+
counts: list[int] = [0] * n_bins
|
| 1060 |
+
bin_lows: list[float] = [
|
| 1061 |
+
b["bin_low"] for b in relevant[0].calibration_metrics["bins"]
|
| 1062 |
+
]
|
| 1063 |
+
bin_highs: list[float] = [
|
| 1064 |
+
b["bin_high"] for b in relevant[0].calibration_metrics["bins"]
|
| 1065 |
+
]
|
| 1066 |
+
|
| 1067 |
+
for dr in relevant:
|
| 1068 |
+
m = dr.calibration_metrics
|
| 1069 |
+
if m.get("n_bins") != n_bins:
|
| 1070 |
+
logger.warning(
|
| 1071 |
+
"[aggregate_calibration] %s : n_bins=%s ≠ %s — ignoré",
|
| 1072 |
+
dr.doc_id, m.get("n_bins"), n_bins,
|
| 1073 |
+
)
|
| 1074 |
+
continue
|
| 1075 |
+
for k, b in enumerate(m["bins"]):
|
| 1076 |
+
n = int(b.get("count") or 0)
|
| 1077 |
+
if n == 0:
|
| 1078 |
+
continue
|
| 1079 |
+
counts[k] += n
|
| 1080 |
+
sum_conf[k] += float(b.get("avg_confidence") or 0.0) * n
|
| 1081 |
+
sum_acc[k] += float(b.get("accuracy") or 0.0) * n
|
| 1082 |
+
|
| 1083 |
+
total = sum(counts)
|
| 1084 |
+
if total == 0:
|
| 1085 |
+
return None
|
| 1086 |
+
|
| 1087 |
+
bins: list[dict] = []
|
| 1088 |
+
ece = 0.0
|
| 1089 |
+
mce = 0.0
|
| 1090 |
+
for k in range(n_bins):
|
| 1091 |
+
n = counts[k]
|
| 1092 |
+
if n == 0:
|
| 1093 |
+
bins.append({
|
| 1094 |
+
"bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
|
| 1095 |
+
"bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
|
| 1096 |
+
"avg_confidence": None,
|
| 1097 |
+
"accuracy": None,
|
| 1098 |
+
"count": 0,
|
| 1099 |
+
"gap": None,
|
| 1100 |
+
})
|
| 1101 |
+
continue
|
| 1102 |
+
avg_conf = sum_conf[k] / n
|
| 1103 |
+
accuracy = sum_acc[k] / n
|
| 1104 |
+
gap = abs(avg_conf - accuracy)
|
| 1105 |
+
bins.append({
|
| 1106 |
+
"bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
|
| 1107 |
+
"bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
|
| 1108 |
+
"avg_confidence": avg_conf,
|
| 1109 |
+
"accuracy": accuracy,
|
| 1110 |
+
"count": n,
|
| 1111 |
+
"gap": gap,
|
| 1112 |
+
})
|
| 1113 |
+
ece += (n / total) * gap
|
| 1114 |
+
if gap > mce:
|
| 1115 |
+
mce = gap
|
| 1116 |
+
|
| 1117 |
+
overall_acc = sum(sum_acc) / total
|
| 1118 |
+
overall_conf = sum(sum_conf) / total
|
| 1119 |
+
|
| 1120 |
+
return {
|
| 1121 |
+
"ece": ece,
|
| 1122 |
+
"mce": mce,
|
| 1123 |
+
"n_bins": n_bins,
|
| 1124 |
+
"n_predictions": total,
|
| 1125 |
+
"overall_accuracy": overall_acc,
|
| 1126 |
+
"overall_confidence": overall_conf,
|
| 1127 |
+
"bins": bins,
|
| 1128 |
+
"doc_count": len(relevant),
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
|
| 1132 |
def _aggregate_ner(doc_results: list) -> Optional[dict]:
|
| 1133 |
"""Agrège les métriques NER au niveau du moteur.
|
| 1134 |
|
|
@@ -22,6 +22,13 @@ class EngineResult:
|
|
| 22 |
duration_seconds: float
|
| 23 |
error: Optional[str] = None
|
| 24 |
metadata: dict = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
@property
|
| 27 |
def success(self) -> bool:
|
|
|
|
| 22 |
duration_seconds: float
|
| 23 |
error: Optional[str] = None
|
| 24 |
metadata: dict = field(default_factory=dict)
|
| 25 |
+
# Sprint 42 — confidences au niveau token (optionnel).
|
| 26 |
+
# Format attendu : liste de dicts ``{"token": str, "confidence": float}``
|
| 27 |
+
# avec ``confidence`` ∈ [0, 1] (ou ∈ [0, 100], normalisé par le runner).
|
| 28 |
+
# ``None`` si le moteur ne fournit pas ce signal — comportement par
|
| 29 |
+
# défaut pour tous les adapters historiques. Quand renseigné,
|
| 30 |
+
# le runner alimente ``DocumentResult.calibration_metrics``.
|
| 31 |
+
token_confidences: Optional[list[dict[str, Any]]] = None
|
| 32 |
|
| 33 |
@property
|
| 34 |
def success(self) -> bool:
|
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 42 — exposition des token_confidences + câblage runner.
|
| 2 |
+
|
| 3 |
+
Le runner peut maintenant calculer des métriques de calibration
|
| 4 |
+
(ECE / MCE / reliability) dès qu'un moteur expose des
|
| 5 |
+
``token_confidences`` sur l'``EngineResult``.
|
| 6 |
+
|
| 7 |
+
Couvre :
|
| 8 |
+
|
| 9 |
+
1. ``EngineResult.token_confidences`` accepte ``None`` (rétrocompat
|
| 10 |
+
stricte) ou une liste de dicts.
|
| 11 |
+
2. ``DocumentResult.calibration_metrics`` est sérialisé via ``as_dict``
|
| 12 |
+
uniquement quand renseigné, libéré par ``compact()``.
|
| 13 |
+
3. ``EngineReport.aggregated_calibration`` apparaît dans ``as_dict``
|
| 14 |
+
quand renseigné.
|
| 15 |
+
4. ``_calibration_from_engine_result`` :
|
| 16 |
+
- Aligne en bag-of-words avec multiplicité (proxy oracle)
|
| 17 |
+
- Normalise les confidences en pourcentage (>1) à [0, 1]
|
| 18 |
+
- Ignore les confidences négatives (Tesseract -1 pour non-mots)
|
| 19 |
+
- Retourne ``None`` sur entrée vide / ``None``
|
| 20 |
+
5. ``_aggregate_calibration`` :
|
| 21 |
+
- Combine les bins de plusieurs documents en somme pondérée
|
| 22 |
+
- Recalcule ECE/MCE micro à partir des sommes
|
| 23 |
+
- Retourne ``None`` si aucun doc n'a de calibration
|
| 24 |
+
6. Rétrocompat : sans token_confidences sur l'EngineResult, aucun
|
| 25 |
+
calcul calibration ; ``aggregated_calibration = None``.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import pytest
|
| 31 |
+
|
| 32 |
+
from picarones.core.runner import (
|
| 33 |
+
_aggregate_calibration,
|
| 34 |
+
_calibration_from_engine_result,
|
| 35 |
+
)
|
| 36 |
+
from picarones.core.results import DocumentResult, EngineReport
|
| 37 |
+
from picarones.engines.base import EngineResult
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
+
# 1. EngineResult.token_confidences
|
| 42 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TestEngineResultExtension:
|
| 46 |
+
def test_default_is_none(self) -> None:
|
| 47 |
+
r = EngineResult("e", "/tmp/x.png", "hello", 1.0)
|
| 48 |
+
assert r.token_confidences is None
|
| 49 |
+
|
| 50 |
+
def test_accepts_list_of_dicts(self) -> None:
|
| 51 |
+
confs = [{"token": "hello", "confidence": 0.95}]
|
| 52 |
+
r = EngineResult("e", "/tmp/x.png", "hello", 1.0, token_confidences=confs)
|
| 53 |
+
assert r.token_confidences == confs
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 57 |
+
# 2-3. Modèles : sérialisation et compact
|
| 58 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _make_dr(calibration_metrics: dict | None = None) -> DocumentResult:
|
| 62 |
+
from picarones.core.metrics import MetricsResult
|
| 63 |
+
|
| 64 |
+
return DocumentResult(
|
| 65 |
+
doc_id="d1", image_path="/tmp/x.png",
|
| 66 |
+
ground_truth="a b c", hypothesis="a b c",
|
| 67 |
+
metrics=MetricsResult(
|
| 68 |
+
cer=0.0, cer_nfc=0.0, cer_caseless=0.0,
|
| 69 |
+
wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
|
| 70 |
+
reference_length=5, hypothesis_length=5,
|
| 71 |
+
),
|
| 72 |
+
duration_seconds=0.1,
|
| 73 |
+
calibration_metrics=calibration_metrics,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class TestModelsSerialization:
|
| 78 |
+
def test_calibration_metrics_omitted_when_none(self) -> None:
|
| 79 |
+
d = _make_dr(None).as_dict()
|
| 80 |
+
assert "calibration_metrics" not in d
|
| 81 |
+
|
| 82 |
+
def test_calibration_metrics_present_when_set(self) -> None:
|
| 83 |
+
d = _make_dr({"ece": 0.05, "mce": 0.1}).as_dict()
|
| 84 |
+
assert d["calibration_metrics"] == {"ece": 0.05, "mce": 0.1}
|
| 85 |
+
|
| 86 |
+
def test_compact_clears_calibration(self) -> None:
|
| 87 |
+
dr = _make_dr({"ece": 0.05})
|
| 88 |
+
dr.compact()
|
| 89 |
+
assert dr.calibration_metrics is None
|
| 90 |
+
|
| 91 |
+
def test_engine_report_aggregated_calibration_omitted_when_none(self) -> None:
|
| 92 |
+
rep = EngineReport(
|
| 93 |
+
engine_name="t", engine_version="1", engine_config={},
|
| 94 |
+
document_results=[_make_dr()],
|
| 95 |
+
)
|
| 96 |
+
assert "aggregated_calibration" not in rep.as_dict()
|
| 97 |
+
|
| 98 |
+
def test_engine_report_aggregated_calibration_included_when_set(self) -> None:
|
| 99 |
+
rep = EngineReport(
|
| 100 |
+
engine_name="t", engine_version="1", engine_config={},
|
| 101 |
+
document_results=[_make_dr()],
|
| 102 |
+
aggregated_calibration={"ece": 0.05, "n_predictions": 100},
|
| 103 |
+
)
|
| 104 |
+
assert rep.as_dict()["aggregated_calibration"] == {
|
| 105 |
+
"ece": 0.05, "n_predictions": 100,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 110 |
+
# 4. Helper d'alignement
|
| 111 |
+
# ───────────────────────────���──────────────────────────────────────────────
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class TestCalibrationFromEngineResult:
|
| 115 |
+
def test_returns_none_for_empty_inputs(self) -> None:
|
| 116 |
+
assert _calibration_from_engine_result("text", None) is None
|
| 117 |
+
assert _calibration_from_engine_result("text", []) is None
|
| 118 |
+
|
| 119 |
+
def test_perfect_calibration_when_conf_matches_accuracy(self) -> None:
|
| 120 |
+
gt = "a b c d e f g h i j"
|
| 121 |
+
# 7 tokens dans la GT à conf=0.7, 3 hors de la GT à conf=0.7 → ECE = 0
|
| 122 |
+
tcs = (
|
| 123 |
+
[{"token": c, "confidence": 0.7} for c in "abcdefg"]
|
| 124 |
+
+ [{"token": c, "confidence": 0.7} for c in ["X", "Y", "Z"]]
|
| 125 |
+
)
|
| 126 |
+
m = _calibration_from_engine_result(gt, tcs)
|
| 127 |
+
assert m is not None
|
| 128 |
+
assert m["ece"] == pytest.approx(0.0, abs=1e-9)
|
| 129 |
+
assert m["overall_accuracy"] == pytest.approx(0.7)
|
| 130 |
+
assert m["n_predictions"] == 10
|
| 131 |
+
|
| 132 |
+
def test_normalizes_percentage_confidences(self) -> None:
|
| 133 |
+
"""Conf > 1 est interprétée en pourcentage et divisée par 100."""
|
| 134 |
+
m = _calibration_from_engine_result(
|
| 135 |
+
"hello", [{"token": "hello", "confidence": 95.0}],
|
| 136 |
+
)
|
| 137 |
+
assert m is not None
|
| 138 |
+
# 95/100 = 0.95
|
| 139 |
+
assert m["overall_confidence"] == 0.95
|
| 140 |
+
|
| 141 |
+
def test_skips_negative_confidences(self) -> None:
|
| 142 |
+
"""Tesseract met -1 pour les non-mots ; on les ignore."""
|
| 143 |
+
m = _calibration_from_engine_result(
|
| 144 |
+
"hello", [
|
| 145 |
+
{"token": "hello", "confidence": 0.9},
|
| 146 |
+
{"token": ".", "confidence": -1.0},
|
| 147 |
+
],
|
| 148 |
+
)
|
| 149 |
+
assert m is not None
|
| 150 |
+
assert m["n_predictions"] == 1
|
| 151 |
+
|
| 152 |
+
def test_bag_of_words_with_multiplicity(self) -> None:
|
| 153 |
+
# GT contient deux 'le'. L'hypothèse en a trois → 2 corrects, 1 incorrect.
|
| 154 |
+
gt = "le chat le chien"
|
| 155 |
+
tcs = [
|
| 156 |
+
{"token": "le", "confidence": 0.9},
|
| 157 |
+
{"token": "le", "confidence": 0.9},
|
| 158 |
+
{"token": "le", "confidence": 0.9}, # 3e 'le' : pas dans la GT
|
| 159 |
+
{"token": "chat", "confidence": 0.9},
|
| 160 |
+
{"token": "chien", "confidence": 0.9},
|
| 161 |
+
]
|
| 162 |
+
m = _calibration_from_engine_result(gt, tcs)
|
| 163 |
+
# 4 corrects sur 5
|
| 164 |
+
assert m["overall_accuracy"] == 0.8
|
| 165 |
+
assert m["n_predictions"] == 5
|
| 166 |
+
|
| 167 |
+
def test_skips_invalid_entries(self) -> None:
|
| 168 |
+
m = _calibration_from_engine_result(
|
| 169 |
+
"hello", [
|
| 170 |
+
"not a dict",
|
| 171 |
+
{"no_token": True, "confidence": 0.5},
|
| 172 |
+
{"token": "hello"}, # pas de confidence
|
| 173 |
+
{"token": "hello", "confidence": "abc"}, # conf non numérique
|
| 174 |
+
{"token": "hello", "confidence": 0.9}, # valide
|
| 175 |
+
],
|
| 176 |
+
)
|
| 177 |
+
assert m is not None
|
| 178 |
+
assert m["n_predictions"] == 1
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 182 |
+
# 5. Agrégateur
|
| 183 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class TestAggregateCalibration:
|
| 187 |
+
def test_returns_none_when_no_doc_has_calibration(self) -> None:
|
| 188 |
+
drs = [_make_dr(None), _make_dr(None)]
|
| 189 |
+
assert _aggregate_calibration(drs) is None
|
| 190 |
+
|
| 191 |
+
def test_combines_bins_across_docs(self) -> None:
|
| 192 |
+
# Doc 1 : bin [0.5, 0.6) avec 10 prédictions, conf=0.55, acc=0.5
|
| 193 |
+
# Doc 2 : bin [0.5, 0.6) avec 20 prédictions, conf=0.55, acc=0.7
|
| 194 |
+
# Agrégat attendu : 30 prédictions dans ce bin, conf moy = 0.55,
|
| 195 |
+
# acc moy pondérée = (10*0.5 + 20*0.7) / 30 = 19/30 ≈ 0.633
|
| 196 |
+
empty_bin = lambda lo, hi: { # noqa: E731
|
| 197 |
+
"bin_low": lo, "bin_high": hi,
|
| 198 |
+
"avg_confidence": None, "accuracy": None,
|
| 199 |
+
"count": 0, "gap": None,
|
| 200 |
+
}
|
| 201 |
+
bins1 = [empty_bin(k / 10, (k + 1) / 10) for k in range(10)]
|
| 202 |
+
bins1[5] = {
|
| 203 |
+
"bin_low": 0.5, "bin_high": 0.6,
|
| 204 |
+
"avg_confidence": 0.55, "accuracy": 0.5,
|
| 205 |
+
"count": 10, "gap": 0.05,
|
| 206 |
+
}
|
| 207 |
+
m1 = {
|
| 208 |
+
"ece": 0.05, "mce": 0.05, "n_bins": 10, "n_predictions": 10,
|
| 209 |
+
"overall_accuracy": 0.5, "overall_confidence": 0.55, "bins": bins1,
|
| 210 |
+
}
|
| 211 |
+
bins2 = [empty_bin(k / 10, (k + 1) / 10) for k in range(10)]
|
| 212 |
+
bins2[5] = {
|
| 213 |
+
"bin_low": 0.5, "bin_high": 0.6,
|
| 214 |
+
"avg_confidence": 0.55, "accuracy": 0.7,
|
| 215 |
+
"count": 20, "gap": 0.15,
|
| 216 |
+
}
|
| 217 |
+
m2 = {
|
| 218 |
+
"ece": 0.15, "mce": 0.15, "n_bins": 10, "n_predictions": 20,
|
| 219 |
+
"overall_accuracy": 0.7, "overall_confidence": 0.55, "bins": bins2,
|
| 220 |
+
}
|
| 221 |
+
drs = [_make_dr(m1), _make_dr(m2)]
|
| 222 |
+
agg = _aggregate_calibration(drs)
|
| 223 |
+
assert agg is not None
|
| 224 |
+
assert agg["n_predictions"] == 30
|
| 225 |
+
assert agg["doc_count"] == 2
|
| 226 |
+
# Accuracy combinée = (10*0.5 + 20*0.7) / 30
|
| 227 |
+
assert agg["overall_accuracy"] == (10 * 0.5 + 20 * 0.7) / 30
|
| 228 |
+
# Confidence combinée = 0.55 (constante)
|
| 229 |
+
assert abs(agg["overall_confidence"] - 0.55) < 1e-9
|
| 230 |
+
# ECE micro : seul bin non vide (bin 5), avec count=30,
|
| 231 |
+
# avg_conf=0.55, accuracy=19/30 ≈ 0.633, gap = |0.55 - 0.633|
|
| 232 |
+
expected_ece = abs(0.55 - 19 / 30)
|
| 233 |
+
assert abs(agg["ece"] - expected_ece) < 1e-9
|
| 234 |
+
assert agg["mce"] == agg["ece"] # un seul bin non vide → MCE = ECE
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 238 |
+
# 6. Rétrocompat : sans token_confidences, rien ne change
|
| 239 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class TestBackwardCompat:
|
| 243 |
+
def test_engine_result_default_no_calibration(self) -> None:
|
| 244 |
+
# Un EngineResult sans token_confidences → calibration_metrics
|
| 245 |
+
# ne doit pas être calculée.
|
| 246 |
+
from picarones.core.runner import _compute_document_result
|
| 247 |
+
ocr = EngineResult(
|
| 248 |
+
engine_name="e",
|
| 249 |
+
image_path="/tmp/x.png",
|
| 250 |
+
text="a b c",
|
| 251 |
+
duration_seconds=0.1,
|
| 252 |
+
token_confidences=None,
|
| 253 |
+
)
|
| 254 |
+
dr = _compute_document_result(
|
| 255 |
+
doc_id="d1", image_path="/tmp/x.png",
|
| 256 |
+
ground_truth="a b c",
|
| 257 |
+
ocr_result=ocr,
|
| 258 |
+
char_exclude=None,
|
| 259 |
+
)
|
| 260 |
+
assert dr.calibration_metrics is None
|
| 261 |
+
|
| 262 |
+
def test_engine_result_with_confs_triggers_calibration(self) -> None:
|
| 263 |
+
from picarones.core.runner import _compute_document_result
|
| 264 |
+
ocr = EngineResult(
|
| 265 |
+
engine_name="e",
|
| 266 |
+
image_path="/tmp/x.png",
|
| 267 |
+
text="a b c",
|
| 268 |
+
duration_seconds=0.1,
|
| 269 |
+
token_confidences=[
|
| 270 |
+
{"token": "a", "confidence": 0.9},
|
| 271 |
+
{"token": "b", "confidence": 0.9},
|
| 272 |
+
{"token": "c", "confidence": 0.9},
|
| 273 |
+
],
|
| 274 |
+
)
|
| 275 |
+
dr = _compute_document_result(
|
| 276 |
+
doc_id="d1", image_path="/tmp/x.png",
|
| 277 |
+
ground_truth="a b c",
|
| 278 |
+
ocr_result=ocr,
|
| 279 |
+
char_exclude=None,
|
| 280 |
+
)
|
| 281 |
+
assert dr.calibration_metrics is not None
|
| 282 |
+
# 3 tokens, tous corrects, conf 0.9 → accuracy = 1, conf = 0.9
|
| 283 |
+
assert dr.calibration_metrics["overall_accuracy"] == 1.0
|
| 284 |
+
assert dr.calibration_metrics["overall_confidence"] == 0.9
|