Spaces:
Running
sprint35: métriques inter-moteurs (couche de calcul)
Browse filesPremier sprint de l'Étape 2 du plan d'évolution 2026 (axe A —
enrichissement métrique). Pose les fonctions pures qui répondent à deux
questions complémentaires que le rapport ne sait pas répondre
aujourd'hui :
(a) à quel point les moteurs font-ils des erreurs de natures
différentes ? → divergence taxonomique
(b) quel CER serait atteignable si on combinait les moteurs ?
→ complémentarité (oracle token recall)
Nouveau module picarones/core/inter_engine.py :
- Divergence : kl_divergence, jensen_shannon_divergence (symétrique,
bornée [0, 1]), taxonomy_divergence_matrix (triangulaire, JS ou KL).
Lissage epsilon des zéros pour éviter log(0).
- Complémentarité : oracle_token_recall (proxy bag-of-words,
documenté comme borne supérieure optimiste — la vraie borne
séquentielle reste à faire), complementarity_gap qui retourne aussi
best_single_recall, best_engine, absolute_gap, relative_gap (fraction
des erreurs du meilleur moteur récupérable par ensemble),
pairwise_disagreement_rate.
Fonctions pures, sans I/O ni intégration runner. Le câblage narratif
(détecteur ENSEMBLE_OPPORTUNITY) et la matrice de divergence dans le
rapport HTML suivent au Sprint 36 — ce sprint livre la couche de calcul
indépendamment, prête à être consommée.
Tests : +27 dans test_sprint35_inter_engine.py 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 ressortent comme candidats à un ensemble, complémentarité
parfaite atteint oracle = 1), et les garde-fous (référence vide,
hypothèses vides, métrique inconnue).
Suite complète : 1539 → 1566 passed, 2 skipped, 0 failed.
- CHANGELOG.md +28 -2
- CLAUDE.md +2 -1
- picarones/core/inter_engine.py +316 -0
- tests/test_sprint35_inter_engine.py +268 -0
|
@@ -16,6 +16,31 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 34 — Phase 0.3 : registre typé de métriques (clôture Phase 0).**
|
| 20 |
Nouveaux modules `picarones/core/metric_registry.py` et
|
| 21 |
`picarones/core/builtin_metrics.py` :
|
|
@@ -83,8 +108,9 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 83 |
|
| 84 |
### Tests
|
| 85 |
|
| 86 |
-
- 1478 →
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
---
|
| 90 |
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 35 — Étape 2 du plan d'évolution : métriques inter-moteurs
|
| 20 |
+
(couche de calcul).** Nouveau module `picarones/core/inter_engine.py`
|
| 21 |
+
qui expose deux familles de mesures qui ne dépendent que des données
|
| 22 |
+
déjà produites par le runner :
|
| 23 |
+
- **Divergence taxonomique** : `kl_divergence`,
|
| 24 |
+
`jensen_shannon_divergence` (symétrique, bornée dans `[0, 1]`),
|
| 25 |
+
`taxonomy_divergence_matrix` qui construit la matrice triangulaire
|
| 26 |
+
inter-moteurs sur les distributions de classes d'erreur (issues de
|
| 27 |
+
`taxonomy.py`). Lissage epsilon des zéros pour éviter `log(0)`.
|
| 28 |
+
- **Complémentarité** : `oracle_token_recall` (borne supérieure
|
| 29 |
+
bag-of-words du recall atteignable par voting), `complementarity_gap`
|
| 30 |
+
qui retourne aussi `best_single_recall` / `absolute_gap` /
|
| 31 |
+
`relative_gap` / `best_engine`, `pairwise_disagreement_rate` pour
|
| 32 |
+
quantifier le potentiel d'ensemble entre deux moteurs spécifiques.
|
| 33 |
+
- Fonctions pures, sans I/O ni intégration runner — la couche de calcul
|
| 34 |
+
est livrable indépendamment ; le câblage au moteur narratif
|
| 35 |
+
(`ENSEMBLE_OPPORTUNITY`) et au rapport HTML (matrice de divergence,
|
| 36 |
+
badge oracle gap) suit au Sprint 36.
|
| 37 |
+
- +27 tests dans `tests/test_sprint35_inter_engine.py` couvrant les
|
| 38 |
+
invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et
|
| 39 |
+
bornée, oracle ≥ best_single), les cas concrets (moteurs
|
| 40 |
+
spécialisés ressortent comme candidats à un ensemble, complémentarité
|
| 41 |
+
parfaite atteint oracle = 1), les garde-fous (référence vide,
|
| 42 |
+
hypothèses vides, métrique inconnue).
|
| 43 |
+
|
| 44 |
- **Sprint 34 — Phase 0.3 : registre typé de métriques (clôture Phase 0).**
|
| 45 |
Nouveaux modules `picarones/core/metric_registry.py` et
|
| 46 |
`picarones/core/builtin_metrics.py` :
|
|
|
|
| 108 |
|
| 109 |
### Tests
|
| 110 |
|
| 111 |
+
- 1478 → 1566 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
|
| 112 |
+
+27 Sprint 35). Aucune régression. **Phase 0 close ; Étape 2 démarrée
|
| 113 |
+
(couche de calcul des métriques inter-moteurs).**
|
| 114 |
|
| 115 |
---
|
| 116 |
|
|
@@ -206,6 +206,7 @@ AZURE_DOC_INTEL_KEY=...
|
|
| 206 |
| 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
|
| 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 |
|
| 210 |
---
|
| 211 |
|
|
@@ -252,7 +253,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 252 |
## Contexte développement
|
| 253 |
|
| 254 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 255 |
-
- **Tests** :
|
| 256 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 257 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 258 |
- **Transcript de la conversation de développement** :
|
|
|
|
| 206 |
| 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
|
| 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 |
|
| 211 |
---
|
| 212 |
|
|
|
|
| 253 |
## Contexte développement
|
| 254 |
|
| 255 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 256 |
+
- **Tests** : 1566 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprint 35 = couche de calcul inter-moteurs)
|
| 257 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 258 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 259 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution).
|
| 2 |
+
|
| 3 |
+
Deux familles de mesures qui répondent à des questions différentes mais
|
| 4 |
+
liées :
|
| 5 |
+
|
| 6 |
+
1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`,
|
| 7 |
+
`taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des
|
| 8 |
+
erreurs de natures différentes ?* Une divergence élevée signale des
|
| 9 |
+
moteurs spécialisés sur des classes d'erreurs distinctes (visual vs
|
| 10 |
+
abréviation vs casse) et donc des candidats pour un voting ensemble.
|
| 11 |
+
|
| 12 |
+
2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`,
|
| 13 |
+
`pairwise_disagreement_rate`) — *quel CER serait atteignable si on
|
| 14 |
+
combinait les moteurs ?* La borne inférieure du CER atteignable par
|
| 15 |
+
un voting majoritaire token-level est ``1 - oracle_token_recall``.
|
| 16 |
+
Si elle est très inférieure au CER du meilleur moteur seul, l'effort
|
| 17 |
+
d'un pipeline d'ensemble se justifie. Sinon non.
|
| 18 |
+
|
| 19 |
+
Convention de typage
|
| 20 |
+
--------------------
|
| 21 |
+
Toutes les fonctions sont enregistrables dans le registre Sprint 34 si
|
| 22 |
+
on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour
|
| 23 |
+
limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont
|
| 24 |
+
des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne
|
| 25 |
+
correspondent pas au modèle « une jonction = une métrique » du runner.
|
| 26 |
+
Elles sont consommées par les détecteurs narratifs et le rapport HTML.
|
| 27 |
+
|
| 28 |
+
Note sur l'oracle
|
| 29 |
+
-----------------
|
| 30 |
+
La métrique ``oracle_token_recall`` retournée ici utilise un alignement
|
| 31 |
+
bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie
|
| 32 |
+
borne atteignable par voting majoritaire séquentiel — c'est une borne
|
| 33 |
+
supérieure (proxy optimiste). La vraie borne demanderait un
|
| 34 |
+
alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour
|
| 35 |
+
le diagnostic « ensemble vaut-il le coup ? », le proxy suffit
|
| 36 |
+
largement ; on documente clairement la limite dans le glossaire et le
|
| 37 |
+
rapport.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import logging
|
| 43 |
+
import math
|
| 44 |
+
from collections import Counter
|
| 45 |
+
|
| 46 |
+
logger = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 50 |
+
# Divergence taxonomique (KL / Jensen-Shannon)
|
| 51 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _smoothed_distribution(
|
| 55 |
+
distribution: dict[str, float],
|
| 56 |
+
keys: list[str],
|
| 57 |
+
epsilon: float = 1e-12,
|
| 58 |
+
) -> list[float]:
|
| 59 |
+
"""Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros.
|
| 60 |
+
|
| 61 |
+
Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement
|
| 62 |
+
minuscule pour ne pas modifier le résultat de manière sensible.
|
| 63 |
+
"""
|
| 64 |
+
smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys]
|
| 65 |
+
total = sum(smoothed)
|
| 66 |
+
return [v / total for v in smoothed]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float:
|
| 70 |
+
"""KL-divergence ``D(P||Q)`` en bits, sur l'union des clés.
|
| 71 |
+
|
| 72 |
+
Les distributions n'ont pas besoin de partager exactement les mêmes
|
| 73 |
+
clés ; les clés manquantes sont lissées à ``epsilon`` puis
|
| 74 |
+
renormalisées.
|
| 75 |
+
|
| 76 |
+
Returns
|
| 77 |
+
-------
|
| 78 |
+
float
|
| 79 |
+
``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas
|
| 80 |
+
symétrique : ``kl(p, q) != kl(q, p)`` en général.
|
| 81 |
+
"""
|
| 82 |
+
keys = sorted(set(p.keys()) | set(q.keys()))
|
| 83 |
+
if not keys:
|
| 84 |
+
return 0.0
|
| 85 |
+
p_vec = _smoothed_distribution(p, keys)
|
| 86 |
+
q_vec = _smoothed_distribution(q, keys)
|
| 87 |
+
return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def jensen_shannon_divergence(
|
| 91 |
+
p: dict[str, float],
|
| 92 |
+
q: dict[str, float],
|
| 93 |
+
) -> float:
|
| 94 |
+
"""JS-divergence symétrique en bits, bornée dans ``[0, 1]``.
|
| 95 |
+
|
| 96 |
+
``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``.
|
| 97 |
+
Symétrique et bornée — préférable à la KL pour construire une
|
| 98 |
+
matrice triangulaire de divergences entre moteurs.
|
| 99 |
+
"""
|
| 100 |
+
keys = sorted(set(p.keys()) | set(q.keys()))
|
| 101 |
+
if not keys:
|
| 102 |
+
return 0.0
|
| 103 |
+
p_vec = _smoothed_distribution(p, keys)
|
| 104 |
+
q_vec = _smoothed_distribution(q, keys)
|
| 105 |
+
m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)]
|
| 106 |
+
|
| 107 |
+
def _kl(a: list[float], b: list[float]) -> float:
|
| 108 |
+
return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0)
|
| 109 |
+
|
| 110 |
+
js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec)
|
| 111 |
+
# Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les
|
| 112 |
+
# erreurs d'arrondi flottant.
|
| 113 |
+
return max(0.0, min(1.0, js))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def taxonomy_divergence_matrix(
|
| 117 |
+
distributions: dict[str, dict[str, float]],
|
| 118 |
+
metric: str = "js",
|
| 119 |
+
) -> dict[str, dict[str, float]]:
|
| 120 |
+
"""Construit la matrice de divergence triangulaire entre moteurs.
|
| 121 |
+
|
| 122 |
+
Parameters
|
| 123 |
+
----------
|
| 124 |
+
distributions:
|
| 125 |
+
``{engine_name: {error_class: probability}}``. Chaque
|
| 126 |
+
distribution doit sommer à environ 1 (pas de validation stricte
|
| 127 |
+
— les distributions taxonomiques de Picarones sont déjà
|
| 128 |
+
normalisées par ``aggregate_taxonomy``).
|
| 129 |
+
metric:
|
| 130 |
+
``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique).
|
| 131 |
+
|
| 132 |
+
Returns
|
| 133 |
+
-------
|
| 134 |
+
dict[str, dict[str, float]]
|
| 135 |
+
Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour
|
| 136 |
+
``js``, asymétrique pour ``kl``. La diagonale vaut 0.
|
| 137 |
+
"""
|
| 138 |
+
if metric not in ("js", "kl"):
|
| 139 |
+
raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}")
|
| 140 |
+
fn = jensen_shannon_divergence if metric == "js" else kl_divergence
|
| 141 |
+
|
| 142 |
+
engines = sorted(distributions.keys())
|
| 143 |
+
matrix: dict[str, dict[str, float]] = {a: {} for a in engines}
|
| 144 |
+
for a in engines:
|
| 145 |
+
for b in engines:
|
| 146 |
+
if a == b:
|
| 147 |
+
matrix[a][b] = 0.0
|
| 148 |
+
elif metric == "js" and b in matrix and a in matrix[b]:
|
| 149 |
+
# Symétrique : recopie pour éviter de recalculer
|
| 150 |
+
matrix[a][b] = matrix[b][a]
|
| 151 |
+
else:
|
| 152 |
+
matrix[a][b] = fn(distributions[a], distributions[b])
|
| 153 |
+
return matrix
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 157 |
+
# Complémentarité (oracle token recall)
|
| 158 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _word_multiset(text: str) -> Counter[str]:
|
| 162 |
+
"""Décomposition en multiset de tokens (séparateur whitespace)."""
|
| 163 |
+
return Counter(tok for tok in text.split() if tok)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def oracle_token_recall(
|
| 167 |
+
reference: str,
|
| 168 |
+
hypotheses: dict[str, str],
|
| 169 |
+
) -> float:
|
| 170 |
+
"""Borne supérieure (proxy bag-of-words) du token-recall atteignable
|
| 171 |
+
par un voting majoritaire entre tous les moteurs fournis.
|
| 172 |
+
|
| 173 |
+
Pour chaque token de la référence (avec sa multiplicité), on
|
| 174 |
+
considère qu'il est "préservé" par l'ensemble si au moins un moteur
|
| 175 |
+
en produit une occurrence non encore comptée. Le score est le ratio
|
| 176 |
+
d'occurrences GT préservées sur le total.
|
| 177 |
+
|
| 178 |
+
Parameters
|
| 179 |
+
----------
|
| 180 |
+
reference:
|
| 181 |
+
Texte GT.
|
| 182 |
+
hypotheses:
|
| 183 |
+
``{engine_name: hypothesis_text}``.
|
| 184 |
+
|
| 185 |
+
Returns
|
| 186 |
+
-------
|
| 187 |
+
float
|
| 188 |
+
Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent
|
| 189 |
+
dans au moins une hypothèse à hauteur de sa multiplicité.
|
| 190 |
+
|
| 191 |
+
Note
|
| 192 |
+
----
|
| 193 |
+
Cette borne est **optimiste** (supérieure à la vraie borne par
|
| 194 |
+
voting séquentiel) car elle ignore l'ordre d'apparition. Pour le
|
| 195 |
+
diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour
|
| 196 |
+
une vraie borne il faudrait un alignement séquentiel.
|
| 197 |
+
"""
|
| 198 |
+
ref_counter = _word_multiset(reference)
|
| 199 |
+
if not ref_counter or not hypotheses:
|
| 200 |
+
return 1.0 if not ref_counter else 0.0
|
| 201 |
+
|
| 202 |
+
hyp_counters = [_word_multiset(h) for h in hypotheses.values()]
|
| 203 |
+
total_ref = sum(ref_counter.values())
|
| 204 |
+
preserved = 0
|
| 205 |
+
for token, gt_count in ref_counter.items():
|
| 206 |
+
# Pour chaque moteur, le nombre d'occurrences disponibles, plafonné
|
| 207 |
+
# à la multiplicité GT. L'oracle prend le max sur les moteurs.
|
| 208 |
+
best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0)
|
| 209 |
+
preserved += best
|
| 210 |
+
return preserved / total_ref
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def complementarity_gap(
|
| 214 |
+
reference: str,
|
| 215 |
+
hypotheses: dict[str, str],
|
| 216 |
+
) -> dict[str, float]:
|
| 217 |
+
"""Compare l'oracle au meilleur moteur seul.
|
| 218 |
+
|
| 219 |
+
Returns
|
| 220 |
+
-------
|
| 221 |
+
dict
|
| 222 |
+
``{
|
| 223 |
+
"oracle_recall": float, # bag-of-words recall de l'oracle
|
| 224 |
+
"best_single_recall": float, # meilleur recall token d'un moteur seul
|
| 225 |
+
"best_engine": str, # nom du moteur correspondant
|
| 226 |
+
"absolute_gap": float, # oracle - best_single (toujours ≥ 0)
|
| 227 |
+
"relative_gap": float, # absolute_gap / (1 - best_single + ε)
|
| 228 |
+
# = fraction des erreurs encore évitables
|
| 229 |
+
# par un ensemble
|
| 230 |
+
}``
|
| 231 |
+
"""
|
| 232 |
+
ref_counter = _word_multiset(reference)
|
| 233 |
+
total = sum(ref_counter.values())
|
| 234 |
+
if not total:
|
| 235 |
+
return {
|
| 236 |
+
"oracle_recall": 1.0,
|
| 237 |
+
"best_single_recall": 1.0,
|
| 238 |
+
"best_engine": "",
|
| 239 |
+
"absolute_gap": 0.0,
|
| 240 |
+
"relative_gap": 0.0,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
def _single_recall(hyp_text: str) -> float:
|
| 244 |
+
hc = _word_multiset(hyp_text)
|
| 245 |
+
preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items())
|
| 246 |
+
return preserved / total
|
| 247 |
+
|
| 248 |
+
if not hypotheses:
|
| 249 |
+
return {
|
| 250 |
+
"oracle_recall": 0.0,
|
| 251 |
+
"best_single_recall": 0.0,
|
| 252 |
+
"best_engine": "",
|
| 253 |
+
"absolute_gap": 0.0,
|
| 254 |
+
"relative_gap": 0.0,
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
per_engine = {name: _single_recall(h) for name, h in hypotheses.items()}
|
| 258 |
+
best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1])
|
| 259 |
+
oracle = oracle_token_recall(reference, hypotheses)
|
| 260 |
+
|
| 261 |
+
absolute_gap = max(0.0, oracle - best_recall)
|
| 262 |
+
# relative_gap : fraction des erreurs du meilleur moteur que l'ensemble
|
| 263 |
+
# serait théoriquement capable de récupérer (∈ [0, 1])
|
| 264 |
+
headroom = max(1.0 - best_recall, 1e-12)
|
| 265 |
+
relative_gap = min(1.0, absolute_gap / headroom)
|
| 266 |
+
|
| 267 |
+
return {
|
| 268 |
+
"oracle_recall": oracle,
|
| 269 |
+
"best_single_recall": best_recall,
|
| 270 |
+
"best_engine": best_engine,
|
| 271 |
+
"absolute_gap": absolute_gap,
|
| 272 |
+
"relative_gap": relative_gap,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def pairwise_disagreement_rate(
|
| 277 |
+
reference: str,
|
| 278 |
+
hyp_a: str,
|
| 279 |
+
hyp_b: str,
|
| 280 |
+
) -> float:
|
| 281 |
+
"""Fraction de tokens GT pour lesquels A et B sont en désaccord.
|
| 282 |
+
|
| 283 |
+
Un désaccord = (l'un préserve le token, l'autre non) OU
|
| 284 |
+
(les deux le ratent mais avec des substitutions différentes — non
|
| 285 |
+
capturé ici, on reste sur la version simple présence/absence).
|
| 286 |
+
|
| 287 |
+
Returns
|
| 288 |
+
-------
|
| 289 |
+
float
|
| 290 |
+
Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix
|
| 291 |
+
(pas de gain d'ensemble). ``1`` = A et B sont toujours en
|
| 292 |
+
désaccord (gain d'ensemble maximal).
|
| 293 |
+
"""
|
| 294 |
+
ref_counter = _word_multiset(reference)
|
| 295 |
+
if not ref_counter:
|
| 296 |
+
return 0.0
|
| 297 |
+
a = _word_multiset(hyp_a)
|
| 298 |
+
b = _word_multiset(hyp_b)
|
| 299 |
+
total = sum(ref_counter.values())
|
| 300 |
+
disagree = 0
|
| 301 |
+
for tok, gt_count in ref_counter.items():
|
| 302 |
+
a_pres = min(gt_count, a.get(tok, 0))
|
| 303 |
+
b_pres = min(gt_count, b.get(tok, 0))
|
| 304 |
+
# Compte les positions où A et B donnent une réponse différente
|
| 305 |
+
disagree += abs(a_pres - b_pres)
|
| 306 |
+
return disagree / total
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
__all__ = [
|
| 310 |
+
"kl_divergence",
|
| 311 |
+
"jensen_shannon_divergence",
|
| 312 |
+
"taxonomy_divergence_matrix",
|
| 313 |
+
"oracle_token_recall",
|
| 314 |
+
"complementarity_gap",
|
| 315 |
+
"pairwise_disagreement_rate",
|
| 316 |
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 35 — métriques inter-moteurs (Étape 2 du plan).
|
| 2 |
+
|
| 3 |
+
Couvre les deux familles de mesures du module ``picarones.core.inter_engine`` :
|
| 4 |
+
|
| 5 |
+
1. **Divergence taxonomique** : KL et JS-divergence sur les
|
| 6 |
+
distributions de classes d'erreur, plus la matrice triangulaire
|
| 7 |
+
inter-moteurs. Tests : invariants mathématiques (positivité, JS
|
| 8 |
+
symétrique et bornée, KL(p,p)=0), comportement sur clés disjointes.
|
| 9 |
+
|
| 10 |
+
2. **Complémentarité** : oracle token recall, gap absolu/relatif vs
|
| 11 |
+
meilleur moteur seul, taux de désaccord par paire. Tests : cas
|
| 12 |
+
parfait (oracle = best = 1), cas où un ensemble apporte un vrai gain,
|
| 13 |
+
cas d'égalité parfaite (gap = 0), garde-fous (référence vide,
|
| 14 |
+
hypothèses vides).
|
| 15 |
+
|
| 16 |
+
Les fonctions sont pures ; pas besoin de fixtures d'I/O ni de moteurs
|
| 17 |
+
réels.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import math
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
from picarones.core.inter_engine import (
|
| 27 |
+
complementarity_gap,
|
| 28 |
+
jensen_shannon_divergence,
|
| 29 |
+
kl_divergence,
|
| 30 |
+
oracle_token_recall,
|
| 31 |
+
pairwise_disagreement_rate,
|
| 32 |
+
taxonomy_divergence_matrix,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 37 |
+
# 1. KL-divergence
|
| 38 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestKLDivergence:
|
| 42 |
+
def test_self_divergence_is_zero(self) -> None:
|
| 43 |
+
p = {"a": 0.4, "b": 0.3, "c": 0.3}
|
| 44 |
+
assert kl_divergence(p, p) == pytest.approx(0.0, abs=1e-9)
|
| 45 |
+
|
| 46 |
+
def test_kl_is_non_negative(self) -> None:
|
| 47 |
+
p = {"a": 0.7, "b": 0.2, "c": 0.1}
|
| 48 |
+
q = {"a": 0.1, "b": 0.4, "c": 0.5}
|
| 49 |
+
assert kl_divergence(p, q) > 0
|
| 50 |
+
assert kl_divergence(q, p) > 0
|
| 51 |
+
|
| 52 |
+
def test_kl_is_asymmetric_in_general(self) -> None:
|
| 53 |
+
# Choix asymétrique non symétrique par permutation
|
| 54 |
+
p = {"a": 0.9, "b": 0.05, "c": 0.05}
|
| 55 |
+
q = {"a": 0.4, "b": 0.4, "c": 0.2}
|
| 56 |
+
assert kl_divergence(p, q) != pytest.approx(kl_divergence(q, p), abs=1e-3)
|
| 57 |
+
|
| 58 |
+
def test_disjoint_keys_handled(self) -> None:
|
| 59 |
+
# Pas de clé en commun : doit retourner une valeur finie grâce
|
| 60 |
+
# au lissage epsilon.
|
| 61 |
+
p = {"a": 1.0}
|
| 62 |
+
q = {"b": 1.0}
|
| 63 |
+
kl = kl_divergence(p, q)
|
| 64 |
+
assert math.isfinite(kl)
|
| 65 |
+
assert kl > 0
|
| 66 |
+
|
| 67 |
+
def test_empty_distributions_return_zero(self) -> None:
|
| 68 |
+
assert kl_divergence({}, {}) == 0.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 72 |
+
# 2. Jensen-Shannon divergence
|
| 73 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class TestJensenShannonDivergence:
|
| 77 |
+
def test_self_divergence_is_zero(self) -> None:
|
| 78 |
+
p = {"a": 0.4, "b": 0.3, "c": 0.3}
|
| 79 |
+
assert jensen_shannon_divergence(p, p) == pytest.approx(0.0, abs=1e-9)
|
| 80 |
+
|
| 81 |
+
def test_symmetric(self) -> None:
|
| 82 |
+
p = {"a": 0.7, "b": 0.2, "c": 0.1}
|
| 83 |
+
q = {"a": 0.1, "b": 0.4, "c": 0.5}
|
| 84 |
+
assert jensen_shannon_divergence(p, q) == pytest.approx(
|
| 85 |
+
jensen_shannon_divergence(q, p), abs=1e-9
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def test_bounded_in_unit_interval(self) -> None:
|
| 89 |
+
# JS en bits ∈ [0, 1]. Distributions extrêmes : disjointes.
|
| 90 |
+
p = {"a": 1.0}
|
| 91 |
+
q = {"b": 1.0}
|
| 92 |
+
js = jensen_shannon_divergence(p, q)
|
| 93 |
+
assert 0.0 <= js <= 1.0
|
| 94 |
+
# Les distributions disjointes donnent une JS proche de 1 (la
|
| 95 |
+
# borne est atteinte asymptotiquement).
|
| 96 |
+
assert js > 0.5
|
| 97 |
+
|
| 98 |
+
def test_close_distributions_have_small_js(self) -> None:
|
| 99 |
+
p = {"a": 0.5, "b": 0.5}
|
| 100 |
+
q = {"a": 0.51, "b": 0.49}
|
| 101 |
+
assert jensen_shannon_divergence(p, q) < 0.01
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 105 |
+
# 3. Matrice de divergence inter-moteurs
|
| 106 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class TestDivergenceMatrix:
|
| 110 |
+
@pytest.fixture
|
| 111 |
+
def engines(self) -> dict[str, dict[str, float]]:
|
| 112 |
+
return {
|
| 113 |
+
"tesseract": {"visual": 0.5, "casse": 0.3, "abbrev": 0.2},
|
| 114 |
+
"pero": {"visual": 0.2, "casse": 0.3, "abbrev": 0.5},
|
| 115 |
+
"mistral": {"visual": 0.4, "casse": 0.4, "abbrev": 0.2},
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def test_diagonal_is_zero(
|
| 119 |
+
self, engines: dict[str, dict[str, float]]
|
| 120 |
+
) -> None:
|
| 121 |
+
mat = taxonomy_divergence_matrix(engines)
|
| 122 |
+
for name in engines:
|
| 123 |
+
assert mat[name][name] == pytest.approx(0.0, abs=1e-9)
|
| 124 |
+
|
| 125 |
+
def test_js_matrix_is_symmetric(
|
| 126 |
+
self, engines: dict[str, dict[str, float]]
|
| 127 |
+
) -> None:
|
| 128 |
+
mat = taxonomy_divergence_matrix(engines, metric="js")
|
| 129 |
+
for a in engines:
|
| 130 |
+
for b in engines:
|
| 131 |
+
assert mat[a][b] == pytest.approx(mat[b][a], abs=1e-9)
|
| 132 |
+
|
| 133 |
+
def test_kl_matrix_is_asymmetric(
|
| 134 |
+
self, engines: dict[str, dict[str, float]]
|
| 135 |
+
) -> None:
|
| 136 |
+
mat = taxonomy_divergence_matrix(engines, metric="kl")
|
| 137 |
+
# Au moins une paire doit être asymétrique
|
| 138 |
+
asymmetric_found = any(
|
| 139 |
+
abs(mat[a][b] - mat[b][a]) > 1e-6
|
| 140 |
+
for a in engines for b in engines if a != b
|
| 141 |
+
)
|
| 142 |
+
assert asymmetric_found
|
| 143 |
+
|
| 144 |
+
def test_unknown_metric_raises(
|
| 145 |
+
self, engines: dict[str, dict[str, float]]
|
| 146 |
+
) -> None:
|
| 147 |
+
with pytest.raises(ValueError, match="metric"):
|
| 148 |
+
taxonomy_divergence_matrix(engines, metric="hellinger")
|
| 149 |
+
|
| 150 |
+
def test_distinguishes_specialized_engines(self) -> None:
|
| 151 |
+
"""Deux moteurs avec profils opposés doivent ressortir comme
|
| 152 |
+
candidats à un ensemble (JS élevée)."""
|
| 153 |
+
engines = {
|
| 154 |
+
"visual_specialist": {"visual": 0.9, "casse": 0.05, "abbrev": 0.05},
|
| 155 |
+
"abbrev_specialist": {"visual": 0.05, "casse": 0.05, "abbrev": 0.9},
|
| 156 |
+
"balanced": {"visual": 0.33, "casse": 0.33, "abbrev": 0.34},
|
| 157 |
+
}
|
| 158 |
+
mat = taxonomy_divergence_matrix(engines, metric="js")
|
| 159 |
+
# Les deux spécialistes doivent diverger plus l'un de l'autre que
|
| 160 |
+
# n'importe lequel d'eux du moteur balanced.
|
| 161 |
+
assert mat["visual_specialist"]["abbrev_specialist"] > mat["visual_specialist"]["balanced"]
|
| 162 |
+
assert mat["visual_specialist"]["abbrev_specialist"] > mat["abbrev_specialist"]["balanced"]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
+
# 4. Oracle token recall
|
| 167 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class TestOracleTokenRecall:
|
| 171 |
+
def test_perfect_engine_oracle_is_one(self) -> None:
|
| 172 |
+
ref = "le manuscrit est ancien"
|
| 173 |
+
hyps = {"perfect": ref}
|
| 174 |
+
assert oracle_token_recall(ref, hyps) == pytest.approx(1.0)
|
| 175 |
+
|
| 176 |
+
def test_no_engine_recovers_anything(self) -> None:
|
| 177 |
+
ref = "alpha beta gamma"
|
| 178 |
+
hyps = {"a": "x y z", "b": "x y z"}
|
| 179 |
+
assert oracle_token_recall(ref, hyps) == pytest.approx(0.0)
|
| 180 |
+
|
| 181 |
+
def test_complementarity_pays_off(self) -> None:
|
| 182 |
+
"""A et B se complètent : aucun ne fait tout, ensemble ils font tout."""
|
| 183 |
+
ref = "alpha beta gamma delta"
|
| 184 |
+
hyps = {
|
| 185 |
+
"a": "alpha beta x y", # alpha + beta seulement
|
| 186 |
+
"b": "x y gamma delta", # gamma + delta seulement
|
| 187 |
+
}
|
| 188 |
+
assert oracle_token_recall(ref, hyps) == pytest.approx(1.0)
|
| 189 |
+
# Et chacun seul ne fait que la moitié
|
| 190 |
+
from picarones.core.inter_engine import complementarity_gap
|
| 191 |
+
gap = complementarity_gap(ref, hyps)
|
| 192 |
+
assert gap["best_single_recall"] == pytest.approx(0.5)
|
| 193 |
+
assert gap["oracle_recall"] == pytest.approx(1.0)
|
| 194 |
+
assert gap["absolute_gap"] == pytest.approx(0.5)
|
| 195 |
+
# Tout l'écart restant est récupérable → relative_gap = 1
|
| 196 |
+
assert gap["relative_gap"] == pytest.approx(1.0)
|
| 197 |
+
|
| 198 |
+
def test_multiplicity_is_respected(self) -> None:
|
| 199 |
+
"""Si la GT a deux 'le' et le moteur n'en produit qu'un, recall = 0.5
|
| 200 |
+
sur ce token."""
|
| 201 |
+
ref = "le chat le chien" # 2× 'le', 1× 'chat', 1× 'chien'
|
| 202 |
+
hyps = {"a": "le chat le chien"} # parfait
|
| 203 |
+
assert oracle_token_recall(ref, hyps) == pytest.approx(1.0)
|
| 204 |
+
hyps2 = {"a": "le chat chien"} # un seul 'le'
|
| 205 |
+
assert oracle_token_recall(ref, hyps2) == pytest.approx(3 / 4)
|
| 206 |
+
|
| 207 |
+
def test_empty_reference_returns_one(self) -> None:
|
| 208 |
+
assert oracle_token_recall("", {"a": "anything"}) == pytest.approx(1.0)
|
| 209 |
+
|
| 210 |
+
def test_no_hypotheses_returns_zero(self) -> None:
|
| 211 |
+
assert oracle_token_recall("alpha", {}) == pytest.approx(0.0)
|
| 212 |
+
|
| 213 |
+
def test_oracle_is_at_least_best_single(self) -> None:
|
| 214 |
+
"""Invariant : l'oracle est toujours ≥ au meilleur moteur seul."""
|
| 215 |
+
ref = "alpha beta gamma delta epsilon"
|
| 216 |
+
hyps = {
|
| 217 |
+
"a": "alpha beta gamma x y",
|
| 218 |
+
"b": "alpha x gamma delta z",
|
| 219 |
+
"c": "x y z delta epsilon",
|
| 220 |
+
}
|
| 221 |
+
gap = complementarity_gap(ref, hyps)
|
| 222 |
+
assert gap["oracle_recall"] >= gap["best_single_recall"]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ──────────────��───────────────────────────────────────────────────────────
|
| 226 |
+
# 5. Gap et désaccord par paire
|
| 227 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class TestComplementarityGap:
|
| 231 |
+
def test_no_gap_when_engines_are_redundant(self) -> None:
|
| 232 |
+
ref = "alpha beta gamma"
|
| 233 |
+
hyps = {"a": "alpha beta x", "b": "alpha beta x"} # redondants
|
| 234 |
+
gap = complementarity_gap(ref, hyps)
|
| 235 |
+
# Les deux ratent le même token → oracle = best_single
|
| 236 |
+
assert gap["absolute_gap"] == pytest.approx(0.0)
|
| 237 |
+
assert gap["relative_gap"] == pytest.approx(0.0)
|
| 238 |
+
|
| 239 |
+
def test_best_engine_named(self) -> None:
|
| 240 |
+
ref = "alpha beta gamma"
|
| 241 |
+
hyps = {
|
| 242 |
+
"tesseract": "alpha x x", # 1/3
|
| 243 |
+
"pero": "alpha beta x", # 2/3
|
| 244 |
+
}
|
| 245 |
+
gap = complementarity_gap(ref, hyps)
|
| 246 |
+
assert gap["best_engine"] == "pero"
|
| 247 |
+
|
| 248 |
+
def test_empty_reference(self) -> None:
|
| 249 |
+
gap = complementarity_gap("", {"a": "anything"})
|
| 250 |
+
assert gap["oracle_recall"] == 1.0
|
| 251 |
+
assert gap["best_single_recall"] == 1.0
|
| 252 |
+
assert gap["absolute_gap"] == 0.0
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class TestPairwiseDisagreement:
|
| 256 |
+
def test_identical_hypotheses_zero_disagreement(self) -> None:
|
| 257 |
+
ref = "alpha beta gamma"
|
| 258 |
+
h = "alpha beta x"
|
| 259 |
+
assert pairwise_disagreement_rate(ref, h, h) == pytest.approx(0.0)
|
| 260 |
+
|
| 261 |
+
def test_complete_disagreement_when_complementary(self) -> None:
|
| 262 |
+
ref = "alpha beta"
|
| 263 |
+
# A préserve alpha, B préserve beta — désaccord sur les deux
|
| 264 |
+
rate = pairwise_disagreement_rate(ref, "alpha x", "x beta")
|
| 265 |
+
assert rate == pytest.approx(1.0)
|
| 266 |
+
|
| 267 |
+
def test_empty_reference_returns_zero(self) -> None:
|
| 268 |
+
assert pairwise_disagreement_rate("", "x", "y") == 0.0
|