diff --git a/docs/architecture-cercles.md b/docs/architecture-cercles.md index 7e462ab27b12337cf64377e499cc7b5b5bda4819..4f0a224e462e3aaa1f1d8f5647c204321b064778 100644 --- a/docs/architecture-cercles.md +++ b/docs/architecture-cercles.md @@ -156,17 +156,40 @@ elles-mêmes dans `report/views/`, donc Cercle 2). ## Distinguer un module Cercle 1 vs Cercle 2 -Test concret : si on supprime ce module, est-ce que la phrase -*« Picarones est un banc d'essai pour pipelines OCR/HTR/VLM »* reste -vraie ? - -- **Oui** → Cercle 2 (le produit existe sans ce module). -- **Non** → Cercle 1 (le module participe à la définition même). +Critère **corrigé** (alignement architecture hexagonale / DDD) : + +> **Cercle 1 = abstractions et logique métier du domaine, +> indépendantes de l'interface utilisateur. Stables entre versions +> mineures.** +> +> **Cercle 2 = adapters concrets (engines, LLM, modules de référence), +> couches d'interface (report, cli, web), et mesures au-delà du noyau +> (measurements). Maintenus mais peuvent évoluer.** + +Le critère « si on supprime ce module, le produit reste viable » +mélange deux questions distinctes (« est-ce indispensable ? » et +« est-ce une abstraction stable ? »). On préfère le critère DDD : + +- **Cercle 1** : abstractions et orchestration qui définissent ce + que Picarones *est* logiquement (corpus, BaseModule, registres, + runner). Indépendant de l'interface utilisateur. +- **Cercle 2** : ce qui rend le domaine utilisable concrètement + (adapters, mesures, présentation HTML, CLI). Exemple : -- Sans `corpus.py` : impossible de charger un corpus → Cercle 1. -- Sans `confusion.py` : on a toujours un bench fonctionnel sans - matrice de confusion → Cercle 2. +- `corpus.py` → Cercle 1 (abstraction du domaine). +- `runner.py` → Cercle 1 (orchestration du domaine). +- `confusion.py` → Cercle 2 (mesure au-delà du noyau, dans + ``measurements/``). +- `report/generator.py` → Cercle 2 (couche de présentation, même si + essentielle à l'usage pratique). +- `engines/tesseract.py` → Cercle 2 (adapter concret). + +> Note : la convention « `base.py` dans le dossier du concept » +> (`engines/base.py`, `llm/base.py`) reste dans son dossier d'origine. +> Ces contrats sont logiquement Cercle 1 (API publique stable) mais +> physiquement co-localisés avec leurs implémentations, comme dans +> Django, SQLAlchemy, FastAPI. Convention universelle Python. - Sans `taxonomy_intra_doc.py` : on a toujours un bench complet et utile → Cercle 3. diff --git a/picarones/core/baseline_comparison.py b/picarones/core/baseline_comparison.py index 22f021aaceb4952d7f96271e325b6864b50a9258..25de1acf7ab62e1b774ea64f0c3334e25d1bca3b 100644 --- a/picarones/core/baseline_comparison.py +++ b/picarones/core/baseline_comparison.py @@ -1,229 +1,19 @@ -"""Comparaison à la baseline historique — Sprint 73 (A.I.3). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.baseline_comparison`. -Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.baseline_comparison import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -L'historique SQLite (``picarones/core/history.py``, Sprint 8) -existe mais aucun détecteur narratif ne le lit. Ce module fournit -la couche de calcul qui répond à *« comment ce moteur se -comporte-t-il sur ce corpus, **par rapport à ses runs précédents -de mon institution** ? »*. - -Sortie typique --------------- -Un dict par moteur : - -.. code-block:: python - - { - "engine_name": "tesseract", - "cer_current": 0.052, - "cer_historical_mean": 0.041, - "cer_historical_median": 0.040, - "n_runs": 12, - "absolute_delta": 0.011, - "relative_delta": 0.268, # +26,8 % vs moyenne - "off_baseline": True, - } - -Le détecteur narratif ``engine_off_baseline`` (Sprint 73) -consomme cette structure pour émettre des Facts. - -Garde-fous ----------- -- ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus - contient moins de runs, on retourne ``None`` plutôt que de - comparer à un échantillon trop petit. -- ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du - même corpus** (sinon on compare des pommes et des oranges : - registres paroissiaux vs imprimés modernes). -- Le run courant lui-même n'est pas inclus dans la baseline (on - passe le ``current_run_id`` à exclure). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import statistics -from typing import Optional - -logger = logging.getLogger(__name__) - - -def compute_engine_baseline( - history, - engine_name: str, - corpus_name: str, - current_cer: float, - *, - current_run_id: Optional[str] = None, - min_runs: int = 5, - relative_delta_threshold: float = 0.20, -) -> Optional[dict]: - """Compare le CER courant d'un moteur à sa moyenne historique - sur le **même corpus**. - - Parameters - ---------- - history: - Instance de ``BenchmarkHistory`` (ou compatible : doit - exposer une méthode ``query(engine, corpus, limit)`` - retournant une liste d'``HistoryEntry`` avec attribut - ``cer_mean`` et ``run_id``). - engine_name: - Nom du moteur dont on calcule la baseline. - corpus_name: - Nom du corpus — limite la comparaison aux runs antérieurs - sur ce même corpus. - current_cer: - CER moyen observé dans le run courant. - current_run_id: - Si fourni, le run portant cet identifiant est exclu de la - baseline (utile quand le run courant est déjà enregistré - dans l'historique avant d'appeler ce calcul). - min_runs: - Nombre minimum de runs historiques pour que la - comparaison soit considérée fiable. Sous ce seuil, on - retourne ``None``. - relative_delta_threshold: - Seuil au-delà duquel ``off_baseline`` vaut ``True`` - (défaut : 0,20 = 20 % d'écart relatif). - - Returns - ------- - Optional[dict] - ``None`` si : - - moins de ``min_runs`` runs historiques disponibles - - ``current_cer`` est ``None`` ou négatif - - tous les CER historiques sont ``None`` - - Sinon, dict avec les champs documentés dans le module. - """ - if current_cer is None or current_cer < 0: - return None - try: - entries = history.query( - engine=engine_name, corpus=corpus_name, limit=1000, - ) - except Exception as exc: # pragma: no cover — défense - logger.warning( - "[baseline_comparison] query history a levé : %s", exc, - ) - return None - - historical_cers: list[float] = [] - for entry in entries: - if current_run_id is not None and entry.run_id == current_run_id: - continue - cer = entry.cer_mean - if cer is None or cer < 0: - continue - historical_cers.append(float(cer)) - - if len(historical_cers) < min_runs: - return None - - mean = statistics.fmean(historical_cers) - median = statistics.median(historical_cers) - absolute_delta = current_cer - mean - if mean > 0: - relative_delta = absolute_delta / mean - elif current_cer == 0: - relative_delta = 0.0 - else: - # Baseline à 0 mais CER courant > 0 : écart infini — - # convention : on signale comme off_baseline avec - # relative_delta = None. - relative_delta = None - - off_baseline = ( - relative_delta is not None - and abs(relative_delta) > relative_delta_threshold - ) - - return { - "engine_name": engine_name, - "corpus_name": corpus_name, - "cer_current": float(current_cer), - "cer_historical_mean": mean, - "cer_historical_median": median, - "n_runs": len(historical_cers), - "absolute_delta": absolute_delta, - "relative_delta": relative_delta, - "off_baseline": off_baseline, - } - - -def compute_corpus_difficulty_percentile( - history, - current_difficulty: float, - *, - min_runs: int = 5, -) -> Optional[dict]: - """Place la difficulté du corpus courant dans la distribution - des difficultés historiques. - - Lit les difficultés stockées dans ``HistoryEntry.metadata`` - sous la clé ``difficulty`` (convention de - ``picarones/core/difficulty.py``). - - Returns - ------- - Optional[dict] - ``{ - "current_difficulty": float, - "percentile": float, # 0..100 - "n_runs": int, - "median_historical": float, - "harder_than_usual": bool, # percentile > 75 - "easier_than_usual": bool, # percentile < 25 - }`` - ou ``None`` si moins de ``min_runs`` runs historiques ont - une difficulté enregistrée. - """ - if current_difficulty is None: - return None - try: - entries = history.query(limit=1000) - except Exception as exc: # pragma: no cover - logger.warning( - "[baseline_comparison] query history a levé : %s", exc, - ) - return None - - historical_difficulties: list[float] = [] - for entry in entries: - diff = entry.metadata.get("difficulty") if entry.metadata else None - if diff is None: - continue - try: - historical_difficulties.append(float(diff)) - except (TypeError, ValueError): - continue - - if len(historical_difficulties) < min_runs: - return None - - sorted_diff = sorted(historical_difficulties) - n = len(sorted_diff) - # Percentile = % de corpus historiques de difficulté ≤ - # current_difficulty. Convention courante (P_i = i/n × 100). - n_below = sum(1 for d in sorted_diff if d <= current_difficulty) - percentile = (n_below / n) * 100.0 - median = statistics.median(sorted_diff) - - return { - "current_difficulty": float(current_difficulty), - "percentile": percentile, - "n_runs": n, - "median_historical": median, - "harder_than_usual": percentile > 75.0, - "easier_than_usual": percentile < 25.0, - } - +from picarones.measurements.baseline_comparison import * # noqa: F401, F403 -__all__ = [ - "compute_engine_baseline", - "compute_corpus_difficulty_percentile", -] +import picarones.measurements.baseline_comparison as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/builtin_hooks.py b/picarones/core/builtin_hooks.py index ebb3481d460e89241bf2b02dc77930c7dc1f7743..f76ce39b1da0a70b0574fa0e29f28b3e256c3239 100644 --- a/picarones/core/builtin_hooks.py +++ b/picarones/core/builtin_hooks.py @@ -1,582 +1,19 @@ -"""Enregistrement des hooks de métriques natifs de Picarones. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.builtin_hooks`. -Chantier 2 du plan d'évolution post-Sprint 97. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.builtin_hooks import ...``) de continuer +à fonctionner sans modification. -Ce module **migre** les 12 hooks document-level et 12 agrégateurs -corpus-level qui étaient codés en dur dans -``picarones.core.runner._compute_document_result`` et autour de la -boucle d'agrégation (lignes 794-827 du runner pré-chantier-2). - -Approche additive — rétrocompat stricte ---------------------------------------- -Tous les hooks sont enregistrés sur les profils ``standard``, -``philological``, ``diagnostics`` et ``full`` (i.e. activés par -défaut quand le runner est appelé sans paramètre ``profile``). Le -profil ``minimal`` n'active aucun hook (pour bench massif où seul -CER/WER comptent). Les profils ``economics`` et ``pipeline`` sont -réservés pour des hooks futurs. - -L'import de ce module **suffit** à peupler les registres : -:mod:`picarones.core.metric_hooks` se contente d'exposer les -décorateurs ; le runner ne dépend que d'une seule fonction — -``select_document_hooks(profile)`` — pour découvrir les hooks actifs. - -Liste complète des hooks (Sprint d'origine) -------------------------------------------- -**Document-level** (12) : - -- ``confusion`` (Sprint 5) — ``confusion_matrix`` -- ``char_scores`` (Sprint 5) — ``char_scores`` -- ``taxonomy`` (Sprint 5) — ``taxonomy`` -- ``structure`` (Sprint 5) — ``structure`` -- ``image_quality`` (Sprint 5) — ``image_quality`` -- ``line_metrics`` (Sprint 10) — ``line_metrics`` -- ``hallucination`` (Sprint 10) — ``hallucination_metrics`` -- ``calibration`` (Sprint 42) — ``calibration_metrics`` -- ``philological`` (Sprint 61) — ``philological_metrics`` -- ``searchability`` (Sprint 86) — ``searchability_metrics`` -- ``numerical_sequences`` (Sprint 86) — ``numerical_sequence_metrics`` -- ``readability`` (Sprint 87) — ``readability_metrics`` - -**Corpus-level** (12) : un agrégateur par hook documentaire, -remplissant le champ ``aggregated_*`` correspondant du -``EngineReport``. - -Le hook ``ner`` (Sprint 40) reste hors de ce mécanisme : il dépend -d'un ``EntityExtractor`` injecté à la main par l'utilisateur, ce -qui n'entre pas dans la sémantique des profils. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from collections import Counter -from typing import Any, Optional - -from picarones.core.metric_hooks import ( - PROFILE_DIAGNOSTICS, - PROFILE_FULL, - PROFILE_PHILOLOGICAL, - PROFILE_STANDARD, - register_corpus_aggregator, - register_document_metric, -) - -logger = logging.getLogger(__name__) - - -# Profils dans lesquels les 12 hooks "standard" s'activent. Égalent -# par construction le comportement runner pré-chantier-2 ; le profil -# ``minimal`` est volontairement absent. -_STANDARD_PROFILES = ( - PROFILE_STANDARD, - PROFILE_PHILOLOGICAL, - PROFILE_DIAGNOSTICS, - PROFILE_FULL, -) - - -# ────────────────────────────────────────────────────────────────────────── -# Helper de calibration (déplacé depuis runner.py — chantier 2) -# ────────────────────────────────────────────────────────────────────────── - - -def calibration_from_engine_result( - ground_truth: str, - token_confidences: list, -) -> Optional[dict]: - """Aligne les ``token_confidences`` du moteur sur la GT (bag-of-words) - pour produire les listes parallèles ``confidences`` / ``is_correct``, - puis appelle ``compute_calibration_metrics`` (Sprint 39). - - Convention d'alignement (proxy bag-of-words avec multiplicité, comme - ``oracle_token_recall`` du Sprint 35) : un token de l'hypothèse est - "correct" si la GT contient encore une occurrence de ce token. - - Les confidences ``> 1.0`` sont supposées en pourcentage et - normalisées à ``[0, 1]``. Les confidences négatives (Tesseract met - -1 pour les non-mots) sont ignorées. - """ - from picarones.core.calibration import compute_calibration_metrics - - if not token_confidences: - return None - - gt_counter = Counter((ground_truth or "").split()) - confidences: list[float] = [] - is_correct: list[int] = [] - - for tc in token_confidences: - if not isinstance(tc, dict): - continue - token = str(tc.get("token", "")) - if not token: - continue - try: - conf = float(tc.get("confidence")) - except (TypeError, ValueError): - continue - if conf < 0: - continue - if conf > 1.0: - conf = conf / 100.0 - if not 0.0 <= conf <= 1.0: - continue - if gt_counter[token] > 0: - is_correct.append(1) - gt_counter[token] -= 1 - else: - is_correct.append(0) - confidences.append(conf) - - if not confidences: - return None - return compute_calibration_metrics(confidences, is_correct) - - -# ────────────────────────────────────────────────────────────────────────── -# Document-level hooks (12) -# ────────────────────────────────────────────────────────────────────────── - - -@register_document_metric( - name="confusion", - attribute="confusion_matrix", - profiles=_STANDARD_PROFILES, - requires_success=True, -) -def _confusion_hook(*, ground_truth, hypothesis, **_): - from picarones.core.confusion import build_confusion_matrix - return build_confusion_matrix(ground_truth, hypothesis).as_dict() - - -@register_document_metric( - name="char_scores", - attribute="char_scores", - profiles=_STANDARD_PROFILES, - requires_success=True, -) -def _char_scores_hook(*, ground_truth, hypothesis, **_): - from picarones.core.char_scores import ( - compute_diacritic_score, - compute_ligature_score, - ) - lig = compute_ligature_score(ground_truth, hypothesis) - diac = compute_diacritic_score(ground_truth, hypothesis) - return {"ligature": lig.as_dict(), "diacritic": diac.as_dict()} - - -@register_document_metric( - name="taxonomy", - attribute="taxonomy", - profiles=_STANDARD_PROFILES, - requires_success=True, -) -def _taxonomy_hook(*, ground_truth, hypothesis, **_): - from picarones.core.taxonomy import classify_errors - return classify_errors(ground_truth, hypothesis).as_dict() - - -@register_document_metric( - name="structure", - attribute="structure", - profiles=_STANDARD_PROFILES, - requires_success=True, -) -def _structure_hook(*, ground_truth, hypothesis, **_): - from picarones.core.structure import analyze_structure - return analyze_structure(ground_truth, hypothesis).as_dict() - - -@register_document_metric( - name="line_metrics", - attribute="line_metrics", - profiles=_STANDARD_PROFILES, - requires_success=True, -) -def _line_metrics_hook(*, ground_truth, hypothesis, **_): - from picarones.core.line_metrics import compute_line_metrics - return compute_line_metrics(ground_truth, hypothesis).as_dict() - - -@register_document_metric( - name="hallucination", - attribute="hallucination_metrics", - profiles=_STANDARD_PROFILES, - requires_success=True, -) -def _hallucination_hook(*, ground_truth, hypothesis, **_): - from picarones.core.hallucination import compute_hallucination_metrics - return compute_hallucination_metrics(ground_truth, hypothesis).as_dict() - - -@register_document_metric( - name="calibration", - attribute="calibration_metrics", - profiles=_STANDARD_PROFILES, - requires_token_confidences=True, -) -def _calibration_hook(*, ground_truth, ocr_result, **_): - return calibration_from_engine_result( - ground_truth, ocr_result.token_confidences, - ) - - -@register_document_metric( - name="image_quality", - attribute="image_quality", - profiles=_STANDARD_PROFILES, - # Pas de requires_success : on analyse l'image quel que soit le - # résultat OCR (pour comparer un échec OCR à la qualité image). -) -def _image_quality_hook(*, image_path, **_): - from picarones.core.image_quality import analyze_image_quality - iq = analyze_image_quality(image_path) - if iq.error is not None: - return None - return iq.as_dict() - - -@register_document_metric( - name="philological", - attribute="philological_metrics", - profiles=_STANDARD_PROFILES, - # Pas de requires_success : le runner pré-chantier-2 calculait - # même sur échec OCR (avec hyp=""). Les modules philologiques - # retournent ``None`` quand la GT n'a pas de signal exploitable - # — comportement adaptive intact. -) -def _philological_hook(*, ground_truth, hypothesis, **_): - from picarones.core.philological_runner import compute_philological_metrics - return compute_philological_metrics(ground_truth, hypothesis) - - -@register_document_metric( - name="searchability", - attribute="searchability_metrics", - profiles=_STANDARD_PROFILES, -) -def _searchability_hook(*, ground_truth, hypothesis, **_): - from picarones.core.searchability_runner import compute_searchability_metrics - return compute_searchability_metrics(ground_truth, hypothesis) - - -@register_document_metric( - name="numerical_sequences", - attribute="numerical_sequence_metrics", - profiles=_STANDARD_PROFILES, -) -def _numerical_sequences_hook(*, ground_truth, hypothesis, **_): - from picarones.core.numerical_sequences_runner import ( - compute_numerical_sequence_metrics_adaptive, - ) - return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis) - - -@register_document_metric( - name="readability", - attribute="readability_metrics", - profiles=_STANDARD_PROFILES, -) -def _readability_hook(*, ground_truth, hypothesis, corpus_lang, **_): - from picarones.core.readability_runner import compute_readability_metrics - return compute_readability_metrics(ground_truth, hypothesis, lang=corpus_lang) - - -# ────────────────────────────────────────────────────────────────────────── -# Corpus-level aggregators (12) -# ────────────────────────────────────────────────────────────────────────── - - -@register_corpus_aggregator( - name="confusion", - attribute="aggregated_confusion", - profiles=_STANDARD_PROFILES, -) -def _aggregate_confusion(doc_results: list) -> Optional[dict]: - from picarones.core.confusion import ( - ConfusionMatrix, aggregate_confusion_matrices, - ) - matrices = [ - ConfusionMatrix(**dr.confusion_matrix) - for dr in doc_results - if dr.confusion_matrix is not None - ] - if not matrices: - return None - return aggregate_confusion_matrices(matrices).as_compact_dict(min_count=2) - - -@register_corpus_aggregator( - name="char_scores", - attribute="aggregated_char_scores", - profiles=_STANDARD_PROFILES, -) -def _aggregate_char_scores(doc_results: list) -> Optional[dict]: - from picarones.core.char_scores import ( - DiacriticScore, - LigatureScore, - aggregate_diacritic_scores, - aggregate_ligature_scores, - ) - lig_scores = [ - LigatureScore(**dr.char_scores["ligature"]) - for dr in doc_results - if dr.char_scores is not None - ] - diac_scores = [ - DiacriticScore(**dr.char_scores["diacritic"]) - for dr in doc_results - if dr.char_scores is not None - ] - if not lig_scores: - return None - return { - "ligature": aggregate_ligature_scores(lig_scores), - "diacritic": aggregate_diacritic_scores(diac_scores), - } - - -@register_corpus_aggregator( - name="taxonomy", - attribute="aggregated_taxonomy", - profiles=_STANDARD_PROFILES, -) -def _aggregate_taxonomy(doc_results: list) -> Optional[dict]: - from picarones.core.taxonomy import TaxonomyResult, aggregate_taxonomy - results = [ - TaxonomyResult.from_dict(dr.taxonomy) - for dr in doc_results - if dr.taxonomy is not None - ] - if not results: - return None - return aggregate_taxonomy(results) - - -@register_corpus_aggregator( - name="structure", - attribute="aggregated_structure", - profiles=_STANDARD_PROFILES, -) -def _aggregate_structure(doc_results: list) -> Optional[dict]: - from picarones.core.structure import StructureResult, aggregate_structure - results = [ - StructureResult.from_dict(dr.structure) - for dr in doc_results - if dr.structure is not None - ] - if not results: - return None - return aggregate_structure(results) - - -@register_corpus_aggregator( - name="image_quality", - attribute="aggregated_image_quality", - profiles=_STANDARD_PROFILES, -) -def _aggregate_image_quality(doc_results: list) -> Optional[dict]: - from picarones.core.image_quality import ( - ImageQualityResult, aggregate_image_quality, - ) - results = [ - ImageQualityResult.from_dict(dr.image_quality) - for dr in doc_results - if dr.image_quality is not None - ] - if not results: - return None - return aggregate_image_quality(results) - - -@register_corpus_aggregator( - name="line_metrics", - attribute="aggregated_line_metrics", - profiles=_STANDARD_PROFILES, -) -def _aggregate_line_metrics(doc_results: list) -> Optional[dict]: - from picarones.core.line_metrics import ( - LineMetrics, aggregate_line_metrics, - ) - results = [ - LineMetrics.from_dict(dr.line_metrics) - for dr in doc_results - if dr.line_metrics is not None - ] - if not results: - return None - return aggregate_line_metrics(results) - - -@register_corpus_aggregator( - name="hallucination", - attribute="aggregated_hallucination", - profiles=_STANDARD_PROFILES, -) -def _aggregate_hallucination(doc_results: list) -> Optional[dict]: - from picarones.core.hallucination import ( - HallucinationMetrics, aggregate_hallucination_metrics, - ) - results = [ - HallucinationMetrics.from_dict(dr.hallucination_metrics) - for dr in doc_results - if dr.hallucination_metrics is not None - ] - if not results: - return None - return aggregate_hallucination_metrics(results) - - -@register_corpus_aggregator( - name="calibration", - attribute="aggregated_calibration", - profiles=_STANDARD_PROFILES, -) -def _aggregate_calibration(doc_results: list) -> Optional[dict]: - """Agrège la calibration micro sur tous les docs. - - Recalcule ECE/MCE à partir de la **somme des bins** de chaque - document : pour chaque bin, on additionne ``count``, on agrège la - confiance moyenne pondérée par count, et on agrège l'accuracy - pondérée par count. L'ECE micro est ensuite la moyenne pondérée - par bin de ``|conf - acc|``. - - Comportement déplacé verbatim depuis ``runner._aggregate_calibration`` - (chantier 2 — rétrocompat octet par octet du sérialisé). - """ - relevant = [ - dr for dr in doc_results - if dr.calibration_metrics is not None - and (dr.calibration_metrics.get("bins") or []) - ] - if not relevant: - return None - - n_bins = relevant[0].calibration_metrics.get("n_bins", 10) - sum_conf: list[float] = [0.0] * n_bins - sum_acc: list[float] = [0.0] * n_bins - counts: list[int] = [0] * n_bins - bin_lows: list[float] = [ - b["bin_low"] for b in relevant[0].calibration_metrics["bins"] - ] - bin_highs: list[float] = [ - b["bin_high"] for b in relevant[0].calibration_metrics["bins"] - ] - - for dr in relevant: - m = dr.calibration_metrics - if m.get("n_bins") != n_bins: - logger.warning( - "[aggregate_calibration] %s : n_bins=%s ≠ %s — ignoré", - dr.doc_id, m.get("n_bins"), n_bins, - ) - continue - for k, b in enumerate(m["bins"]): - n = int(b.get("count") or 0) - if n == 0: - continue - counts[k] += n - sum_conf[k] += float(b.get("avg_confidence") or 0.0) * n - sum_acc[k] += float(b.get("accuracy") or 0.0) * n - - total = sum(counts) - if total == 0: - return None - - bins: list[dict] = [] - ece = 0.0 - mce = 0.0 - for k in range(n_bins): - n = counts[k] - if n == 0: - bins.append({ - "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins, - "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins, - "avg_confidence": None, - "accuracy": None, - "count": 0, - "gap": None, - }) - continue - avg_conf = sum_conf[k] / n - accuracy = sum_acc[k] / n - gap = abs(avg_conf - accuracy) - bins.append({ - "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins, - "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins, - "avg_confidence": avg_conf, - "accuracy": accuracy, - "count": n, - "gap": gap, - }) - ece += (n / total) * gap - if gap > mce: - mce = gap - - overall_acc = sum(sum_acc) / total - overall_conf = sum(sum_conf) / total - - return { - "ece": ece, - "mce": mce, - "n_bins": n_bins, - "n_predictions": total, - "overall_accuracy": overall_acc, - "overall_confidence": overall_conf, - "bins": bins, - "doc_count": len(relevant), - } - - -@register_corpus_aggregator( - name="philological", - attribute="aggregated_philological", - profiles=_STANDARD_PROFILES, -) -def _aggregate_philological(doc_results: list) -> Optional[dict]: - from picarones.core.philological_runner import aggregate_philological_metrics - return aggregate_philological_metrics( - [dr.philological_metrics for dr in doc_results], - ) - - -@register_corpus_aggregator( - name="searchability", - attribute="aggregated_searchability", - profiles=_STANDARD_PROFILES, -) -def _aggregate_searchability(doc_results: list) -> Optional[dict]: - from picarones.core.searchability_runner import aggregate_searchability_metrics - return aggregate_searchability_metrics( - [dr.searchability_metrics for dr in doc_results], - ) - - -@register_corpus_aggregator( - name="numerical_sequences", - attribute="aggregated_numerical_sequences", - profiles=_STANDARD_PROFILES, -) -def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]: - from picarones.core.numerical_sequences_runner import ( - aggregate_numerical_sequence_metrics, - ) - return aggregate_numerical_sequence_metrics( - [dr.numerical_sequence_metrics for dr in doc_results], - ) - - -@register_corpus_aggregator( - name="readability", - attribute="aggregated_readability", - profiles=_STANDARD_PROFILES, -) -def _aggregate_readability(doc_results: list) -> Optional[dict]: - from picarones.core.readability_runner import aggregate_readability_metrics - return aggregate_readability_metrics( - [dr.readability_metrics for dr in doc_results], - ) - +from picarones.measurements.builtin_hooks import * # noqa: F401, F403 -__all__ = ["calibration_from_engine_result"] +import picarones.measurements.builtin_hooks as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/calibration.py b/picarones/core/calibration.py index 35819b20332e0b915b4cb13a5b9c55555f50c392..030a9d90f78b6fbc26ab7a399347efa01123e560 100644 --- a/picarones/core/calibration.py +++ b/picarones/core/calibration.py @@ -1,323 +1,19 @@ -"""Calibration des moteurs : ECE, MCE, reliability diagram. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.calibration`. -Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.calibration import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Tous les moteurs OCR cibles fournissent une confidence par token ou par -ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``, -Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``). -La question naturelle pour un workflow patrimonial est : *« quand le -moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe -qui doit vérifier humainement un corpus de 50 000 pages, la différence -entre vérifier 100 % vs 15 % du volume est l'effet de la calibration. - -Ce module fournit les trois mesures classiques : - -- **Expected Calibration Error (ECE)** — moyenne pondérée par bin de - l'écart absolu entre confiance moyenne et précision moyenne. - ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart - systématique entre confiance affichée et fiabilité réelle. -- **Maximum Calibration Error (MCE)** — max de cet écart sur les bins. - Utile pour repérer le pire mensonge du moteur (ex. il dit toujours - 95 % de confiance et il a tort une fois sur deux). -- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf, - accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en - Chart.js côté navigateur dans un sprint suivant. - -Stratégie de découpage ----------------------- -Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37), -on découpe : - -- **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes - parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1). - Aucune dépendance externe. -- **Sprint à venir** — exposition de ``token_confidences`` sur - ``EngineResult``, alignement caractère/token avec la GT pour produire - ``is_correct``, intégration dans le runner et vue HTML reliability. - -Ce qui est explicitement hors scope ------------------------------------ -Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est -extraite ; on calcule uniquement à partir de séquences de prédictions -fournies en entrée. C'est ce qui permet de tester rigoureusement les -invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias -constant, etc.) sans dépendre d'un backend. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Iterable - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle de données -# ────────────────────────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class CalibrationBin: - """Un bin du reliability diagram. - - Attributs - --------- - bin_low, bin_high: - Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` — - sauf le dernier bin qui inclut ``1.0``). - avg_confidence: - Moyenne des confidences des prédictions tombées dans le bin. - ``None`` si le bin est vide. - accuracy: - Fraction de prédictions correctes dans le bin (``∈ [0, 1]``). - ``None`` si le bin est vide. - count: - Nombre de prédictions dans le bin. - """ - - bin_low: float - bin_high: float - avg_confidence: float | None - accuracy: float | None - count: int - - @property - def gap(self) -> float | None: - """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide.""" - if self.avg_confidence is None or self.accuracy is None: - return None - return abs(self.avg_confidence - self.accuracy) - - -# ────────────────────────────────────────────────────────────────────────── -# Validation -# ────────────────────────────────────────────────────────────────────────── - - -def _validate_inputs( - confidences: list[float], - is_correct: list[bool | int], -) -> None: - if len(confidences) != len(is_correct): - raise ValueError( - f"Longueurs incompatibles : confidences={len(confidences)} " - f"vs is_correct={len(is_correct)}" - ) - for i, c in enumerate(confidences): - if not (0.0 <= float(c) <= 1.0): - raise ValueError( - f"Confiance hors [0, 1] à l'index {i} : {c!r}" - ) - - -# ────────────────────────────────────────────────────────────────────────── -# Reliability diagram (binning) -# ────────────────────────────────────────────────────────────────────────── - - -def reliability_diagram( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> list[CalibrationBin]: - """Découpe les prédictions en ``n_bins`` bins équidistants par confiance - et calcule pour chacun la confiance moyenne, la précision et le compte. - - Parameters - ---------- - confidences: - Confidences des prédictions, ``∈ [0, 1]``. - is_correct: - Indicateur booléen (1 = prédiction correcte, 0 = incorrecte). - n_bins: - Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)`` - sauf le dernier bin qui inclut ``1.0``. - - Returns - ------- - list[CalibrationBin] - Liste de ``n_bins`` bins, dans l'ordre croissant des confidences. - """ - if n_bins < 1: - raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}") - - confs = [float(c) for c in confidences] - correct = [int(bool(x)) for x in is_correct] - _validate_inputs(confs, correct) - - bin_width = 1.0 / n_bins - sums: list[float] = [0.0] * n_bins - correct_counts: list[int] = [0] * n_bins - counts: list[int] = [0] * n_bins - - for c, ok in zip(confs, correct): - # Calcul du bin index par multiplication ``c * n_bins`` plutôt que - # division ``c / bin_width`` pour éviter les pièges de - # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754 - # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)). - if c >= 1.0: - idx = n_bins - 1 - else: - idx = int(c * n_bins) - # Garde-fou en cas d'arrondi flottant - if idx >= n_bins: - idx = n_bins - 1 - elif idx < 0: - idx = 0 - sums[idx] += c - correct_counts[idx] += ok - counts[idx] += 1 - - bins: list[CalibrationBin] = [] - for k in range(n_bins): - low = k * bin_width - high = (k + 1) * bin_width - n = counts[k] - if n == 0: - bins.append(CalibrationBin(low, high, None, None, 0)) - else: - bins.append(CalibrationBin( - bin_low=low, - bin_high=high, - avg_confidence=sums[k] / n, - accuracy=correct_counts[k] / n, - count=n, - )) - return bins - - -# ────────────────────────────────────────────────────────────────────────── -# ECE et MCE -# ────────────────────────────────────────────────────────────────────────── - - -def expected_calibration_error( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> float: - """Expected Calibration Error : moyenne pondérée par bin de l'écart - absolu confiance ↔ précision. - - ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|`` - - où la somme porte sur les bins non vides. - - Returns - ------- - float - ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. - """ - bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) - total = sum(b.count for b in bins) - if total == 0: - return 0.0 - ece = 0.0 - for b in bins: - if b.count == 0 or b.gap is None: - continue - ece += (b.count / total) * b.gap - return ece - - -def maximum_calibration_error( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> float: - """Maximum Calibration Error : pire écart confiance ↔ précision sur - tous les bins non vides. - - Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 % - de confiance et il a tort une fois sur deux dans ce bin). - - Returns - ------- - float - ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. - """ - bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) - gaps = [b.gap for b in bins if b.gap is not None] - return max(gaps) if gaps else 0.0 - - -# ────────────────────────────────────────────────────────────────────────── -# Vue agrégée -# ────────────────────────────────────────────────────────────────────────── - - -def compute_calibration_metrics( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> dict: - """Calcule l'ensemble des métriques de calibration en un appel. - - Returns - ------- - dict - ``{ - "ece": float, - "mce": float, - "n_bins": int, - "n_predictions": int, - "overall_accuracy": float, - "overall_confidence": float, - "bins": [ - {"bin_low", "bin_high", "avg_confidence", - "accuracy", "count", "gap"}, - ... - ], - }`` - """ - confs = list(confidences) - correct = list(is_correct) - bins = reliability_diagram(confs, correct, n_bins=n_bins) - total = sum(b.count for b in bins) - overall_acc = ( - sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0 - ) - overall_conf = ( - sum(float(c) for c in confs) / total if total > 0 else 0.0 - ) - - ece = 0.0 - if total > 0: - for b in bins: - if b.gap is None: - continue - ece += (b.count / total) * b.gap - mce = max((b.gap for b in bins if b.gap is not None), default=0.0) - - return { - "ece": ece, - "mce": mce, - "n_bins": n_bins, - "n_predictions": total, - "overall_accuracy": overall_acc, - "overall_confidence": overall_conf, - "bins": [ - { - "bin_low": b.bin_low, - "bin_high": b.bin_high, - "avg_confidence": b.avg_confidence, - "accuracy": b.accuracy, - "count": b.count, - "gap": b.gap, - } - for b in bins - ], - } - +from picarones.measurements.calibration import * # noqa: F401, F403 -__all__ = [ - "CalibrationBin", - "reliability_diagram", - "expected_calibration_error", - "maximum_calibration_error", - "compute_calibration_metrics", -] +import picarones.measurements.calibration as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/char_scores.py b/picarones/core/char_scores.py index e390462b14d027212f05f765d6691b8fe8542aa3..582a90fcfdc73194ba47417c062b5abfc8c61902 100644 --- a/picarones/core/char_scores.py +++ b/picarones/core/char_scores.py @@ -1,370 +1,19 @@ -"""Scores de reconnaissance des ligatures et des diacritiques. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.char_scores`. -Ces métriques sont spécifiques aux documents patrimoniaux (manuscrits, imprimés -anciens) où ligatures et diacritiques jouent un rôle paléographique essentiel. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.char_scores import ...``) de continuer +à fonctionner sans modification. -Ligatures ---------- -Caractères encodés comme une séquence unique dans Unicode mais représentant -deux ou plusieurs glyphes fusionnés : fi (fi), fl (fl), œ, æ, etc. - -Pour chaque ligature présente dans le GT, on vérifie si l'OCR a produit -soit le caractère Unicode équivalent, soit la séquence décomposée équivalente. - -Diacritiques ------------ -Accents, cédilles, trémas et autres signes diacritiques. Pour chaque caractère -accentué dans le GT, on vérifie si l'OCR a conservé le diacritique ou l'a -remplacé par la lettre de base. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Optional - -import unicodedata - - -# --------------------------------------------------------------------------- -# Tables de ligatures (char ligature → séquences équivalentes acceptées) -# --------------------------------------------------------------------------- - -#: Table principale des ligatures et leurs équivalents acceptés. -#: Clé = caractère ligature Unicode ; valeur = liste de séquences équivalentes. -LIGATURE_TABLE: dict[str, list[str]] = { - # Ligatures typographiques latines (Unicode Letterlike Symbols / Alphabetic Presentation Forms) - "\uFB00": ["ff"], # ff ff - "\uFB01": ["fi"], # fi fi - "\uFB02": ["fl"], # fl fl - "\uFB03": ["ffi"], # ffi ffi - "\uFB04": ["ffl"], # ffl ffl - "\uFB05": ["st", "\u017Ft"], # ſt st / ſt - "\uFB06": ["st"], # st st (variante) - # Ligatures latines patrimoniales (Unicode Latin Extended Additional) - "\u0153": ["oe"], # œ oe - "\u00E6": ["ae"], # æ ae - "\u0152": ["OE"], # Œ OE - "\u00C6": ["AE"], # Æ AE - # Abréviations latines / médiévales - "\uA751": ["per", "p\u0332"], # ꝑ per / p̲ - "\uA753": ["pro"], # ꝓ pro - "\uA757": ["que"], # ꝗ que - # Ligatures germaniques - "\u00DF": ["ss"], # ß ss - "\u1E9E": ["SS"], # ẞ SS -} - -# Ensemble de toutes les ligatures pour recherche rapide -_ALL_LIGATURES: frozenset[str] = frozenset(LIGATURE_TABLE) - -# Mapping inverse : séquence → ligature -_SEQ_TO_LIGATURE: dict[str, str] = {} -for _lig, _seqs in LIGATURE_TABLE.items(): - for _seq in _seqs: - _SEQ_TO_LIGATURE[_seq] = _lig - - -# --------------------------------------------------------------------------- -# Table des caractères diacritiques -# --------------------------------------------------------------------------- - -def _build_diacritic_map() -> dict[str, str]: - """Construit automatiquement la table diacritique depuis l'Unicode.""" - table: dict[str, str] = {} - for codepoint in range(0x00C0, 0x0250): # Latin Étendu A + B - ch = chr(codepoint) - nfd = unicodedata.normalize("NFD", ch) - if len(nfd) > 1: # le caractère est décomposable - base = nfd[0] # lettre de base - if base.isalpha() and base != ch: - table[ch] = base - # Compléments manuels - table.update({ - "\u0107": "c", # ć - "\u0119": "e", # ę - "\u0142": "l", # ł - "\u0144": "n", # ń - "\u015B": "s", # ś - "\u017A": "z", # ź - "\u017C": "z", # ż - }) - return table - - -DIACRITIC_MAP: dict[str, str] = _build_diacritic_map() -_ALL_DIACRITICS: frozenset[str] = frozenset(DIACRITIC_MAP) - -# Ligatures qui NE sont PAS des diacritiques (pour éviter les doublons) -_LIGATURE_SET: frozenset[str] = frozenset(LIGATURE_TABLE) - - -# --------------------------------------------------------------------------- -# Résultats structurés -# --------------------------------------------------------------------------- - -@dataclass -class LigatureScore: - """Score de reconnaissance des ligatures pour une paire (GT, OCR).""" - - total_in_gt: int = 0 - """Nombre de ligatures présentes dans le GT.""" - correctly_recognized: int = 0 - """Nombre de ligatures correctement transcrites (unicode ou équivalent).""" - score: float = 0.0 - """Taux de reconnaissance = correctly_recognized / total_in_gt. 1.0 si total=0.""" - per_ligature: dict[str, dict] = field(default_factory=dict) - """Détail par ligature : {'fi': {'gt_count': 5, 'ocr_correct': 3, 'score': 0.6}}""" - - def as_dict(self) -> dict: - return { - "total_in_gt": self.total_in_gt, - "correctly_recognized": self.correctly_recognized, - "score": round(self.score, 4), - "per_ligature": { - k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} - for k, v in self.per_ligature.items() - }, - } - - -@dataclass -class DiacriticScore: - """Score de conservation des diacritiques pour une paire (GT, OCR).""" - - total_in_gt: int = 0 - """Nombre de caractères accentués dans le GT.""" - correctly_recognized: int = 0 - """Nombre de diacritiques correctement conservés.""" - score: float = 0.0 - """Taux de conservation = correctly_recognized / total_in_gt. 1.0 si total=0.""" - per_diacritic: dict[str, dict] = field(default_factory=dict) - """Détail par caractère diacritique.""" - - def as_dict(self) -> dict: - return { - "total_in_gt": self.total_in_gt, - "correctly_recognized": self.correctly_recognized, - "score": round(self.score, 4), - "per_diacritic": { - k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} - for k, v in self.per_diacritic.items() - }, - } - - -# --------------------------------------------------------------------------- -# Calcul des scores -# --------------------------------------------------------------------------- - -def compute_ligature_score(ground_truth: str, hypothesis: str) -> LigatureScore: - """Calcule le score de reconnaissance des ligatures. - - Pour chaque ligature dans le GT, on vérifie si l'OCR a produit : - - Exactement le même caractère ligature Unicode (ex. fi → fi) - - Ou la séquence de lettres équivalente (ex. fi → fi) - - Les deux sont considérés comme corrects — ce qui correspond à la pratique - éditoriale patrimoniaux (certains éditeurs développent les ligatures). - - Parameters - ---------- - ground_truth: - Texte de référence. - hypothesis: - Texte produit par l'OCR. - - Returns - ------- - LigatureScore - """ - if not ground_truth: - return LigatureScore(score=1.0) - - # Construire un index de position dans l'hypothèse pour recherche rapide - hyp_norm = unicodedata.normalize("NFC", hypothesis) - gt_norm = unicodedata.normalize("NFC", ground_truth) - - per_lig: dict[str, dict] = {} - total = 0 - correct = 0 - - # Trouver toutes les ligatures dans le GT - i = 0 - while i < len(gt_norm): - ch = gt_norm[i] - if ch in _ALL_LIGATURES: - total += 1 - equivalents = [ch] + LIGATURE_TABLE[ch] # unicode direct ou séquences équivalentes - - # Vérifier si la position correspondante dans l'OCR contient l'équivalent - is_correct = _check_char_at_context(gt_norm, hyp_norm, i, ch, equivalents) - if is_correct: - correct += 1 - - if ch not in per_lig: - per_lig[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0} - per_lig[ch]["gt_count"] += 1 - if is_correct: - per_lig[ch]["ocr_correct"] += 1 - i += 1 - - # Calculer les scores individuels - for lig_data in per_lig.values(): - lig_data["score"] = ( - lig_data["ocr_correct"] / lig_data["gt_count"] - if lig_data["gt_count"] > 0 - else 1.0 - ) - - score = correct / total if total > 0 else 1.0 - return LigatureScore( - total_in_gt=total, - correctly_recognized=correct, - score=score, - per_ligature=per_lig, - ) - - -def compute_diacritic_score(ground_truth: str, hypothesis: str) -> DiacriticScore: - """Calcule le score de conservation des diacritiques. - - Pour chaque caractère accentué dans le GT, on vérifie si l'OCR a produit - le même caractère (conservation) ou a substitué la lettre de base (perte). - On accepte aussi les formes NFD équivalentes. - - Parameters - ---------- - ground_truth: - Texte de référence. - hypothesis: - Texte produit par l'OCR. - - Returns - ------- - DiacriticScore - """ - if not ground_truth: - return DiacriticScore(score=1.0) - - gt_norm = unicodedata.normalize("NFC", ground_truth) - hyp_norm = unicodedata.normalize("NFC", hypothesis) - - per_diac: dict[str, dict] = {} - total = 0 - correct = 0 - - # Utiliser difflib pour l'alignement - import difflib - matcher = difflib.SequenceMatcher(None, gt_norm, hyp_norm, autojunk=False) - gt_to_hyp: dict[int, Optional[int]] = {} - - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - for k in range(i2 - i1): - gt_to_hyp[i1 + k] = j1 + k - elif tag == "replace" and (i2 - i1) == (j2 - j1): - for k in range(i2 - i1): - gt_to_hyp[i1 + k] = j1 + k - else: - # delete ou replace de longueurs différentes - for k in range(i1, i2): - gt_to_hyp[k] = None - - for i, ch in enumerate(gt_norm): - if ch in _ALL_DIACRITICS and ch not in _LIGATURE_SET: - total += 1 - hyp_pos = gt_to_hyp.get(i) - is_correct = False - if hyp_pos is not None and hyp_pos < len(hyp_norm): - hyp_ch = hyp_norm[hyp_pos] - is_correct = (hyp_ch == ch) - if is_correct: - correct += 1 - - if ch not in per_diac: - per_diac[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0} - per_diac[ch]["gt_count"] += 1 - if is_correct: - per_diac[ch]["ocr_correct"] += 1 - - for diac_data in per_diac.values(): - diac_data["score"] = ( - diac_data["ocr_correct"] / diac_data["gt_count"] - if diac_data["gt_count"] > 0 - else 1.0 - ) - - score = correct / total if total > 0 else 1.0 - return DiacriticScore( - total_in_gt=total, - correctly_recognized=correct, - score=score, - per_diacritic=per_diac, - ) - - -def _check_char_at_context( - gt: str, - hyp: str, - gt_pos: int, - gt_char: str, - equivalents: list[str], -) -> bool: - """Vérifie si la position correspondante dans l'hypothèse contient un équivalent. - - Cherche dans une fenêtre de ±5 caractères autour de la position estimée - pour tolérer les décalages d'alignement OCR. - """ - # Position estimée dans l'hypothèse (ratio proportionnel) - if len(gt) == 0: - return False - est_pos = int(gt_pos * len(hyp) / len(gt)) if len(gt) > 0 else 0 - window = 5 - start = max(0, est_pos - window) - end = min(len(hyp), est_pos + window + len(gt_char)) - context = hyp[start:end] - for equiv in equivalents: - if equiv in context: - return True - return False - - -def aggregate_ligature_scores(scores: list[LigatureScore]) -> dict: - """Agrège les scores de ligatures sur un corpus.""" - total_gt = sum(s.total_in_gt for s in scores) - total_correct = sum(s.correctly_recognized for s in scores) - score = total_correct / total_gt if total_gt > 0 else 1.0 - - # Agrégation par ligature - per_lig: dict[str, dict] = {} - for s in scores: - for lig, data in s.per_ligature.items(): - if lig not in per_lig: - per_lig[lig] = {"gt_count": 0, "ocr_correct": 0} - per_lig[lig]["gt_count"] += data["gt_count"] - per_lig[lig]["ocr_correct"] += data["ocr_correct"] - for lig_data in per_lig.values(): - lig_data["score"] = ( - lig_data["ocr_correct"] / lig_data["gt_count"] - if lig_data["gt_count"] > 0 else 1.0 - ) - - return { - "score": round(score, 4), - "total_in_gt": total_gt, - "correctly_recognized": total_correct, - "per_ligature": per_lig, - } - +from picarones.measurements.char_scores import * # noqa: F401, F403 -def aggregate_diacritic_scores(scores: list[DiacriticScore]) -> dict: - """Agrège les scores diacritiques sur un corpus.""" - total_gt = sum(s.total_in_gt for s in scores) - total_correct = sum(s.correctly_recognized for s in scores) - score = total_correct / total_gt if total_gt > 0 else 1.0 - return { - "score": round(score, 4), - "total_in_gt": total_gt, - "correctly_recognized": total_correct, - } +import picarones.measurements.char_scores as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/confusion.py b/picarones/core/confusion.py index a90d9ebb9b3eb6a5585e4f172a0a6bbf4be79689..4c59906188a415a8097ba7378dd0422084543ac5 100644 --- a/picarones/core/confusion.py +++ b/picarones/core/confusion.py @@ -1,268 +1,19 @@ -"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.confusion`. -Pour chaque moteur, on calcule quels caractères du GT sont transcrits par -quels caractères OCR (substitutions). Cette "empreinte d'erreur" est -caractéristique de chaque moteur ou pipeline. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.confusion import ...``) de continuer +à fonctionner sans modification. -Méthode -------- -L'alignement caractère par caractère utilise les opérations d'édition -de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet -d'identifier les substitutions, insertions et suppressions. - -La matrice est stockée comme un dict de dict : - ``{gt_char: {ocr_char: count}}`` - -La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide : -- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR -- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT) +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import difflib -from collections import defaultdict -from dataclasses import dataclass, field - -# Symbole représentant un caractère absent (insertion / suppression) -EMPTY_CHAR = "∅" - -# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne) -_WHITESPACE = set(" \t\n\r") - - -@dataclass -class ConfusionMatrix: - """Matrice de confusion unicode pour une paire (GT, OCR).""" - - matrix: dict[str, dict[str, int]] = field(default_factory=dict) - """Clé externe = char GT ; clé interne = char OCR ; valeur = count.""" - - total_substitutions: int = 0 - total_insertions: int = 0 - total_deletions: int = 0 - - @property - def total_errors(self) -> int: - return self.total_substitutions + self.total_insertions + self.total_deletions - - def top_confusions(self, n: int = 20) -> list[dict]: - """Retourne les n confusions les plus fréquentes (substitutions uniquement).""" - pairs: list[tuple[str, str, int]] = [] - for gt_char, ocr_counts in self.matrix.items(): - if gt_char == EMPTY_CHAR: - continue # insertions - for ocr_char, count in ocr_counts.items(): - if ocr_char == EMPTY_CHAR: - continue # suppressions - if gt_char != ocr_char: - pairs.append((gt_char, ocr_char, count)) - pairs.sort(key=lambda x: -x[2]) - return [ - {"gt": gt, "ocr": ocr, "count": cnt} - for gt, ocr, cnt in pairs[:n] - ] - - def as_compact_dict(self, min_count: int = 1) -> dict: - """Sérialise la matrice en éliminant les entrées rares.""" - compact: dict[str, dict[str, int]] = {} - for gt_char, ocr_counts in self.matrix.items(): - filtered = { - oc: cnt for oc, cnt in ocr_counts.items() - if cnt >= min_count - } - if filtered: - compact[gt_char] = filtered - return { - "matrix": compact, - "total_substitutions": self.total_substitutions, - "total_insertions": self.total_insertions, - "total_deletions": self.total_deletions, - } - - def as_dict(self) -> dict: - return self.as_compact_dict(min_count=1) - - -def build_confusion_matrix( - ground_truth: str, - hypothesis: str, - ignore_whitespace: bool = True, - ignore_correct: bool = True, -) -> ConfusionMatrix: - """Construit la matrice de confusion unicode pour une paire GT/OCR. - - Parameters - ---------- - ground_truth: - Texte de référence (vérité terrain). - hypothesis: - Texte produit par l'OCR. - ignore_whitespace: - Si True, ignore les espaces, tabulations et sauts de ligne. - ignore_correct: - Si True, n'enregistre pas les paires identiques (gt_char == ocr_char). - Par défaut True pour réduire la taille de la matrice. - - Returns - ------- - ConfusionMatrix - """ - matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - n_subs = n_ins = n_dels = 0 - - if not ground_truth and not hypothesis: - return ConfusionMatrix(dict(matrix), 0, 0, 0) - - # SequenceMatcher sur listes de chars pour un alignement précis - matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False) - - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - if not ignore_correct: - for ch in ground_truth[i1:i2]: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[ch][ch] += 1 - elif tag == "replace": - # Aligner char par char les séquences de longueurs différentes - gt_seg = ground_truth[i1:i2] - oc_seg = hypothesis[j1:j2] - _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace) - # Substitutions = longueur commune, surplus = insertions ou suppressions - n_subs += min(len(gt_seg), len(oc_seg)) - surplus = abs(len(gt_seg) - len(oc_seg)) - if len(gt_seg) > len(oc_seg): - n_dels += surplus - else: - n_ins += surplus - elif tag == "delete": - for ch in ground_truth[i1:i2]: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[ch][EMPTY_CHAR] += 1 - n_dels += 1 - elif tag == "insert": - for ch in hypothesis[j1:j2]: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[EMPTY_CHAR][ch] += 1 - n_ins += 1 - - # Convertir defaultdict en dict normal - result_matrix: dict[str, dict[str, int]] = { - k: dict(v) for k, v in matrix.items() - } - - return ConfusionMatrix( - matrix=result_matrix, - total_substitutions=n_subs, - total_insertions=n_ins, - total_deletions=n_dels, - ) - - -def _align_segments( - gt_seg: str, - oc_seg: str, - matrix: dict, - ignore_whitespace: bool, -) -> None: - """Aligne deux segments de longueurs potentiellement différentes.""" - if not gt_seg: - for ch in oc_seg: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[EMPTY_CHAR][ch] += 1 - return - if not oc_seg: - for ch in gt_seg: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[ch][EMPTY_CHAR] += 1 - return - - if len(gt_seg) == len(oc_seg): - # Substitutions 1-pour-1 - for g, o in zip(gt_seg, oc_seg): - if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): - continue - matrix[g][o] += 1 - else: - # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts - sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False) - for tag2, i1, i2, j1, j2 in sub.get_opcodes(): - if tag2 == "equal": - pass - elif tag2 == "replace": - # Régression simple : aligner par troncature - for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]): - if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): - continue - matrix[g][o] += 1 - elif tag2 == "delete": - for g in gt_seg[i1:i2]: - if ignore_whitespace and g in _WHITESPACE: - continue - matrix[g][EMPTY_CHAR] += 1 - elif tag2 == "insert": - for o in oc_seg[j1:j2]: - if ignore_whitespace and o in _WHITESPACE: - continue - matrix[EMPTY_CHAR][o] += 1 - - -def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix: - """Agrège plusieurs matrices de confusion en une seule. - - Utile pour obtenir la matrice agrégée sur l'ensemble du corpus. - """ - combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - total_subs = total_ins = total_dels = 0 - - for cm in matrices: - for gt_char, ocr_counts in cm.matrix.items(): - for ocr_char, count in ocr_counts.items(): - combined[gt_char][ocr_char] += count - total_subs += cm.total_substitutions - total_ins += cm.total_insertions - total_dels += cm.total_deletions - - return ConfusionMatrix( - matrix={k: dict(v) for k, v in combined.items()}, - total_substitutions=total_subs, - total_insertions=total_ins, - total_deletions=total_dels, - ) - - -def top_confused_chars( - matrix: ConfusionMatrix, - n: int = 15, - exclude_empty: bool = True, -) -> list[dict]: - """Retourne les caractères GT les plus souvent confondus. - - Retourne une liste triée par nombre total d'erreurs décroissant : - ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]`` - """ - char_stats: dict[str, dict] = {} - for gt_char, ocr_counts in matrix.matrix.items(): - if exclude_empty and gt_char == EMPTY_CHAR: - continue - error_count = sum( - cnt for oc, cnt in ocr_counts.items() - if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR) - ) - if error_count > 0: - top_subs = sorted( - [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char], - key=lambda x: -x["count"], - )[:5] - char_stats[gt_char] = { - "char": gt_char, - "total_errors": error_count, - "top_substitutes": top_subs, - } +from picarones.measurements.confusion import * # noqa: F401, F403 - return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n] +import picarones.measurements.confusion as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/cost_projection.py b/picarones/core/cost_projection.py index f9eab7a6d47731e7b6b0722cb86ad9b1aae0729b..6e76ffcac346ef2b2a97f31da58abc7a4ae6706b 100644 --- a/picarones/core/cost_projection.py +++ b/picarones/core/cost_projection.py @@ -1,169 +1,19 @@ -"""Projection de coût en volume cible — Sprint 79 (A.I.6). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.cost_projection`. -Sprint 79 — A.I.6 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.cost_projection import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par -unité (1 000 pages). Pour décider business-side, il faut projeter -ce coût sur le **volume cible** que l'utilisateur prévoit de -traiter — payer 50 € de plus sur 50 pages est trivial, sur -5 millions ça change tout. - -Sortie typique --------------- -*« Pour vos 80 000 pages BMS — Tesseract = 3 €, Pero = 0 € (local -amorti), Mistral OCR = 280 €, GPT-4o post-correction = 600 €. »* - -Aucun seuil arbitraire imposé : le module fournit les chiffres, -le chercheur arbitre selon son budget. - -Dépendance ----------- -S'appuie sur ``picarones.core.pricing`` (Sprint 20) qui expose -``EngineCost.cost_per_1k_pages_eur`` et -``co2_per_1k_pages_g``. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Optional - -from picarones.core.pricing import EngineCost - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class ProjectedCost: - """Coût total projeté d'un moteur pour un volume cible.""" - engine_key: str - target_pages: int - cost_total_eur: Optional[float] - co2_total_g: Optional[float] - cost_per_1k_pages_eur: Optional[float] - co2_per_1k_pages_g: Optional[float] - type: str # "local" / "cloud_api" / "unknown" - - def as_dict(self) -> dict: - return { - "engine_key": self.engine_key, - "target_pages": self.target_pages, - "cost_total_eur": self.cost_total_eur, - "co2_total_g": self.co2_total_g, - "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, - "co2_per_1k_pages_g": self.co2_per_1k_pages_g, - "type": self.type, - } - - -def project_cost_total( - engine_cost: EngineCost, target_pages: int, -) -> Optional[float]: - """Coût total projeté en euros pour ``target_pages`` pages. - - Retourne ``None`` si ``cost_per_1k_pages_eur`` est ``None`` - (données insuffisantes) ou si ``target_pages`` est négatif. - """ - if target_pages < 0: - return None - if engine_cost.cost_per_1k_pages_eur is None: - return None - return engine_cost.cost_per_1k_pages_eur * target_pages / 1000.0 - - -def project_co2_total( - engine_cost: EngineCost, target_pages: int, -) -> Optional[float]: - """Empreinte CO₂ totale en grammes pour ``target_pages`` pages.""" - if target_pages < 0: - return None - if engine_cost.co2_per_1k_pages_g is None: - return None - return engine_cost.co2_per_1k_pages_g * target_pages / 1000.0 - - -def project_engine( - engine_cost: EngineCost, target_pages: int, -) -> ProjectedCost: - """Retourne le ``ProjectedCost`` complet pour un moteur.""" - return ProjectedCost( - engine_key=engine_cost.engine_key, - target_pages=int(target_pages), - cost_total_eur=project_cost_total(engine_cost, target_pages), - co2_total_g=project_co2_total(engine_cost, target_pages), - cost_per_1k_pages_eur=engine_cost.cost_per_1k_pages_eur, - co2_per_1k_pages_g=engine_cost.co2_per_1k_pages_g, - type=engine_cost.type, - ) - - -def project_all_engines( - engine_costs: dict[str, EngineCost], - target_pages: int, -) -> dict[str, ProjectedCost]: - """Projette les coûts de plusieurs moteurs sur le volume cible. - - Retourne un dict ``{engine_name: ProjectedCost}`` avec entrée - pour chaque moteur, y compris ceux sans données de coût (où - ``cost_total_eur`` sera ``None``). - """ - if target_pages < 0: - raise ValueError("target_pages doit être ≥ 0") - return { - name: project_engine(cost, target_pages) - for name, cost in engine_costs.items() - } - - -def cost_gap_table( - projections: dict[str, ProjectedCost], - baseline_engine: str, -) -> dict[str, dict[str, Optional[float]]]: - """Pour chaque moteur, écart de coût total vs baseline. - - Retourne ``{engine: {"total": float, "delta_abs": float, - "delta_rel": float}}`` où : - - - ``delta_abs`` = ``cost - cost_baseline`` (None si l'un des - deux est None) - - ``delta_rel`` = ``delta_abs / cost_baseline`` (None si - baseline = 0 ou None) - - Lève ``KeyError`` si la baseline est inconnue. - """ - if baseline_engine not in projections: - raise KeyError( - f"baseline {baseline_engine!r} absente des projections", - ) - baseline_total = projections[baseline_engine].cost_total_eur - out: dict[str, dict[str, Optional[float]]] = {} - for name, proj in projections.items(): - total = proj.cost_total_eur - if total is None or baseline_total is None: - delta_abs: Optional[float] = None - delta_rel: Optional[float] = None - else: - delta_abs = total - baseline_total - if baseline_total != 0: - delta_rel = delta_abs / baseline_total - else: - delta_rel = None - out[name] = { - "total": total, - "delta_abs": delta_abs, - "delta_rel": delta_rel, - } - return out - +from picarones.measurements.cost_projection import * # noqa: F401, F403 -__all__ = [ - "ProjectedCost", - "project_cost_total", - "project_co2_total", - "project_engine", - "project_all_engines", - "cost_gap_table", -] +import picarones.measurements.cost_projection as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/difficulty.py b/picarones/core/difficulty.py index 7f037a48d4f67d06e7162473b901fc261681373a..f1153aebe7b3ecac19f86967a6140843a2e50b64 100644 --- a/picarones/core/difficulty.py +++ b/picarones/core/difficulty.py @@ -1,202 +1,19 @@ -"""Score de difficulté intrinsèque par document. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.difficulty`. -Le score est indépendant des moteurs OCR : il mesure la difficulté -*objective* d'un document, indépendamment de la qualité des transcriptions. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.difficulty import ...``) de continuer +à fonctionner sans modification. -Formule -------- - difficulty = w_variance * variance_norm - + w_quality * (1 - image_quality_score) - + w_density * special_char_density - -où : - - variance_norm : variance inter-moteurs du CER, normalisée [0, 1] - - image_quality : score de qualité image [0, 1] (netteté, contraste…) - - special_chars : densité de caractères spéciaux dans la GT [0, 1] - -Les poids sont configurables (défaut : 0.4 / 0.35 / 0.25). - -Score final : [0, 1] — 0 = document facile, 1 = très difficile. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import re -from dataclasses import dataclass -from typing import Optional - - -# Poids par défaut -_W_VARIANCE = 0.40 -_W_QUALITY = 0.35 -_W_DENSITY = 0.25 - -# Caractères spéciaux patrimoniaux (ligatures, abréviations, diacritiques rares) -_SPECIAL_CHARS_RE = re.compile( - r"[ſœæꝑꝓ&]" # ligatures / abréviations médiévales - r"|[ḁ-ỿ]" # Latin Étendu Additionnel (diacritiques rares) - r"|[\u0300-\u036f]" # Diacritiques combinants - r"|[\ufb00-\ufb06]" # Formes de présentation latines (fi, fl…) - r"|[IVXLCDM]{3,}" # Chiffres romains (3+ caractères) -) - - -@dataclass -class DifficultyScore: - """Score de difficulté intrinsèque d'un document.""" - doc_id: str - score: float - """Score global [0, 1] — plus élevé = plus difficile.""" - variance_component: float - """Composante variance inter-moteurs [0, 1].""" - quality_component: float - """Composante qualité image inversée [0, 1].""" - density_component: float - """Composante densité caractères spéciaux [0, 1].""" - cer_variance: float - """Variance brute du CER entre moteurs.""" - image_quality_score: float - """Score de qualité image (si disponible, sinon 0.5).""" - special_char_ratio: float - """Ratio caractères spéciaux / longueur GT.""" - - def as_dict(self) -> dict: - return { - "doc_id": self.doc_id, - "score": round(self.score, 4), - "variance_component": round(self.variance_component, 4), - "quality_component": round(self.quality_component, 4), - "density_component": round(self.density_component, 4), - "cer_variance": round(self.cer_variance, 6), - "image_quality_score": round(self.image_quality_score, 4), - "special_char_ratio": round(self.special_char_ratio, 4), - } - - -def _special_char_density(text: str) -> float: - """Ratio de caractères spéciaux patrimoniaux dans le texte.""" - if not text: - return 0.0 - matches = len(_SPECIAL_CHARS_RE.findall(text)) - return min(1.0, matches / len(text)) - - -def _variance(values: list[float]) -> float: - """Variance d'une liste de valeurs.""" - if len(values) < 2: - return 0.0 - mu = sum(values) / len(values) - return sum((v - mu) ** 2 for v in values) / len(values) - - -def compute_difficulty_score( - doc_id: str, - ground_truth: str, - cer_per_engine: list[float], - image_quality_score: Optional[float] = None, - weights: tuple[float, float, float] = (_W_VARIANCE, _W_QUALITY, _W_DENSITY), -) -> DifficultyScore: - """Calcule le score de difficulté intrinsèque pour un document. - - Parameters - ---------- - doc_id : identifiant du document - ground_truth : texte de référence - cer_per_engine : liste des CER (un par moteur concurrent) - image_quality_score: score de qualité image [0, 1] (None → 0.5 neutre) - weights : (w_variance, w_quality, w_density) - - Returns - ------- - DifficultyScore - """ - w_var, w_qual, w_den = weights - - # 1. Variance inter-moteurs (normalisée sur [0, 1] — variance max ≈ 0.25) - cer_var = _variance(cer_per_engine) - variance_norm = min(1.0, cer_var / 0.25) - - # 2. Qualité image inversée - iq = image_quality_score if image_quality_score is not None else 0.5 - iq = max(0.0, min(1.0, iq)) - quality_component = 1.0 - iq - - # 3. Densité de caractères spéciaux - density = _special_char_density(ground_truth) - # Amplifier légèrement (la densité brute est souvent faible) - density_component = min(1.0, density * 3.0) - - # Score combiné - score = ( - w_var * variance_norm - + w_qual * quality_component - + w_den * density_component - ) - score = max(0.0, min(1.0, score)) - - return DifficultyScore( - doc_id=doc_id, - score=score, - variance_component=variance_norm, - quality_component=quality_component, - density_component=density_component, - cer_variance=cer_var, - image_quality_score=iq, - special_char_ratio=density, - ) - - -def compute_all_difficulties( - doc_ids: list[str], - ground_truths: dict[str, str], - cer_map: dict[str, dict[str, float]], - image_quality_map: Optional[dict[str, float]] = None, -) -> dict[str, DifficultyScore]: - """Calcule les scores de difficulté pour tous les documents d'un corpus. - - Parameters - ---------- - doc_ids : liste des identifiants de documents - ground_truths : {doc_id → gt_text} - cer_map : {doc_id → {engine_name → cer}} - image_quality_map : {doc_id → quality_score} (facultatif) - - Returns - ------- - {doc_id → DifficultyScore} - """ - result = {} - for doc_id in doc_ids: - gt = ground_truths.get(doc_id, "") - engine_cers = list(cer_map.get(doc_id, {}).values()) - iq = (image_quality_map or {}).get(doc_id) - result[doc_id] = compute_difficulty_score( - doc_id=doc_id, - ground_truth=gt, - cer_per_engine=engine_cers, - image_quality_score=iq, - ) - return result - - -def difficulty_label(score: float) -> str: - """Retourne un label lisible pour un score de difficulté.""" - if score < 0.25: - return "Facile" - if score < 0.50: - return "Modéré" - if score < 0.75: - return "Difficile" - return "Très difficile" - +from picarones.measurements.difficulty import * # noqa: F401, F403 -def difficulty_color(score: float) -> str: - """Retourne une couleur CSS pour un score de difficulté.""" - from picarones.core.colors import COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED - if score < 0.25: - return COLOR_GREEN - if score < 0.50: - return COLOR_YELLOW - if score < 0.75: - return COLOR_ORANGE - return COLOR_RED +import picarones.measurements.difficulty as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/equivalence_profile.py b/picarones/core/equivalence_profile.py index e3c9adf1cb24c3e73567733ac16c883ba8396e81..07d7dc76786a04bb57daf9881cd90869321752c2 100644 --- a/picarones/core/equivalence_profile.py +++ b/picarones/core/equivalence_profile.py @@ -1,199 +1,19 @@ -"""Équivalences diplomatiques granulaires — Sprint 78 (A.I.5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.equivalence_profile`. -Sprint 78 — A.I.5 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.equivalence_profile import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Aujourd'hui les profils de ``picarones/core/normalization.py`` -(``medieval_french``, ``early_modern_french``, etc.) appliquent un -**bloc entier** de transformations. Mais un éditeur peut vouloir -nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par -exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont -distinctes mais où le s long doit être normalisé. - -Ce module **éclate** chaque profil en règles d'équivalence -**nommées et indépendantes** que l'utilisateur peut activer ou -désactiver une par une. La couche de calcul retourne le CER -recalculé avec un sous-ensemble personnalisé. - -Format ------- -Chaque règle a : - -- ``name`` : identifiant stable utilisé dans les URLs et l'UX - (ex. ``"longs_s"``, ``"u_eq_v"``) -- ``source`` : caractère ou séquence à remplacer -- ``target`` : caractère ou séquence cible -- ``description`` : phrase courte FR destinée à l'utilisateur -- ``profile_tag`` : nom du profil dont elle est issue (utile pour - grouper dans l'UX) - -Stratégie de découpage ----------------------- -Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau -avancé (cases à cocher + recalcul JS client + URL state) suivra -dans un sprint dédié — la couche calcul livrée ici est une -fondation suffisante pour qu'un développeur frontend câble la vue. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Iterable, Optional - -from picarones.core.normalization import ( - DIPLOMATIC_EN_EARLY_MODERN, - DIPLOMATIC_FR_EARLY_MODERN, - DIPLOMATIC_LATIN_MEDIEVAL, - DIPLOMATIC_MINIMAL, -) - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class EquivalenceRule: - """Une équivalence diplomatique nommée et indépendante.""" - name: str - source: str - target: str - description: str - profile_tag: str - - -# Catalogue : on dérive des profils existants en attribuant un nom -# stable à chaque transformation. Les doublons (ex. ``ſ → s`` -# présent dans plusieurs profils) sont fusionnés sous un nom unique -# (le premier rencontré). -def _build_catalog() -> dict[str, EquivalenceRule]: - catalog: dict[str, EquivalenceRule] = {} - - # Noms canoniques pour les transformations courantes - canonical_names: dict[tuple[str, str], tuple[str, str]] = { - ("ſ", "s"): ("longs_s", "s long ſ → s"), - ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"), - ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"), - ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"), - ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"), - ("æ", "ae"): ("ae_ligature", "æ → ae"), - ("œ", "oe"): ("oe_ligature", "œ → oe"), - ("þ", "th"): ("thorn_th", "þ (thorn) → th"), - ("ð", "th"): ("eth_th", "ð (eth) → th"), - ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"), - ("&", "et"): ("ampersand_et", "& → et (esperluette)"), - ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"), - ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"), - ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"), - ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"), - } - - sources = [ - ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL), - ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN), - ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN), - ("minimal", DIPLOMATIC_MINIMAL), - ] - - for profile_tag, profile_dict in sources: - for source, target in profile_dict.items(): - key = (source, target) - if key in canonical_names: - name, desc = canonical_names[key] - else: - # Fallback : générer un nom à partir des codepoints - name = f"{source}_to_{target}".replace(" ", "_") - desc = f"{source} → {target}" - if name in catalog: - # On garde le profile_tag du premier rencontré, mais - # on note que la règle est partagée. - continue - catalog[name] = EquivalenceRule( - name=name, - source=source, - target=target, - description=desc, - profile_tag=profile_tag, - ) - return catalog - - -BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog() - - -def list_equivalences_by_profile( - profile_name: Optional[str] = None, -) -> list[EquivalenceRule]: - """Liste les règles d'équivalence disponibles. - - Si ``profile_name`` est fourni, ne retourne que les règles dont - ``profile_tag == profile_name`` (ou les règles dérivées de - plusieurs profils dont au moins un est ``profile_name``). - """ - if profile_name is None: - return list(BUILTIN_EQUIVALENCES.values()) - return [ - rule for rule in BUILTIN_EQUIVALENCES.values() - if rule.profile_tag == profile_name - ] - - -def apply_selected_equivalences( - text: Optional[str], - selected_names: Iterable[str], -) -> str: - """Applique uniquement les règles dont le nom est dans - ``selected_names``. - - L'ordre d'application est l'ordre du catalogue interne — les - transformations sont appliquées séquentiellement sur le texte. - Les règles inconnues sont silencieusement ignorées (avec - warning). - """ - if not text: - return text or "" - selected_set = set(selected_names) - if not selected_set: - return text - out = text - for name, rule in BUILTIN_EQUIVALENCES.items(): - if name not in selected_set: - continue - out = out.replace(rule.source, rule.target) - # Détection des règles inconnues (pour logger explicite) - unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys()) - if unknown: - logger.warning( - "[equivalence_profile] règles inconnues ignorées : %s", - sorted(unknown), - ) - return out - - -def compute_cer_with_equivalences( - reference: Optional[str], - hypothesis: Optional[str], - selected_names: Iterable[str], -) -> float: - """Calcule le CER après application des équivalences sélectionnées - sur les **deux** côtés (GT et hypothèse). - - Utilise ``picarones.core.metrics.compute_metrics`` et extrait - le champ ``cer`` du résultat. - """ - from picarones.core.metrics import compute_metrics - - selected_list = list(selected_names) - ref = apply_selected_equivalences(reference or "", selected_list) - hyp = apply_selected_equivalences(hypothesis or "", selected_list) - result = compute_metrics(ref, hyp) - return result.cer - +from picarones.measurements.equivalence_profile import * # noqa: F401, F403 -__all__ = [ - "EquivalenceRule", - "BUILTIN_EQUIVALENCES", - "list_equivalences_by_profile", - "apply_selected_equivalences", - "compute_cer_with_equivalences", -] +import picarones.measurements.equivalence_profile as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/error_absorption.py b/picarones/core/error_absorption.py index ce1021d64b625397fd5c3dca1d15475d6d83477b..f06a093ecb07e5f9a3301bd3303e4262faa3bf16 100644 --- a/picarones/core/error_absorption.py +++ b/picarones/core/error_absorption.py @@ -1,276 +1,19 @@ -"""Métrique d'absorption d'erreur — Sprint 94 (B.3). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.error_absorption`. -Sprint 94 — B.3 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.error_absorption import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Quand un module de post-correction LLM aplatit les différences -entre OCR amont, ce n'est pas qu'il « améliore » tous les -moteurs — c'est qu'il introduit ses propres biais qui dominent -ceux de l'OCR. Mesurer la dégradation par étape ne suffit -pas : il faut **séparer** les deux flux. - -À chaque jonction où un module transforme un artefact, on -mesure : - -- **Taux de correction** : parmi les erreurs présentes en - entrée du module, combien sont corrigées en sortie ? -- **Taux d'introduction** : parmi les erreurs présentes en - sortie, combien sont **nouvelles** (absentes en entrée) ? - -C'est la généralisation du score de sur-normalisation -(chantier A.I.7) à toute jonction. La formule s'applique -uniformément à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper — -toute jonction qui transforme un artefact en un autre du même -type. - -Méthode (token-level) ---------------------- -On split en tokens whitespace ``reference``, ``before``, -``after``. On compare en **multiset** (un token GT consommé -au plus une fois) : - -- ``errors_before`` = tokens GT non retrouvés dans ``before`` -- ``errors_after`` = tokens GT non retrouvés dans ``after`` -- ``corrected`` = ``errors_before \\ errors_after`` - (présents avant, absents après → corrigés) -- ``introduced`` = ``errors_after \\ errors_before`` - (absents avant, présents après → introduits) - -Garde-fou : le module ne classe pas les erreurs (visuelles, -abréviations, etc.) — c'est une métrique d'**absorption de -volume**, pas de qualité éditoriale. L'intersection sémantique -avec ``taxonomy`` (Sprint 5) est documentée dans le glossaire. - -Sortie ------- -``compute_error_absorption(reference, before, after)`` retourne : - -.. code-block:: text - - { - "n_gt_tokens": int, - "n_errors_before": int, - "n_errors_after": int, - "n_corrected": int, - "n_introduced": int, - "n_kept_wrong": int, - "correction_rate": float | None, # n_corrected / n_errors_before - "introduction_rate": float | None, # n_introduced / n_errors_after - "net_improvement": int, # n_corrected - n_introduced - "corrected_tokens": list[str], - "introduced_tokens": list[str], - } - -``aggregate_error_absorption(per_doc_results)`` somme les -compteurs corpus-wide et recalcule les taux *micro*. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from collections import Counter -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -def _split_words(text: Optional[str]) -> list[str]: - if not text: - return [] - return text.split() - - -def _missing_tokens( - reference: list[str], hypothesis: list[str], -) -> Counter: - """Tokens GT manquants en hypothèse au sens multiset. - - Un token GT compte plusieurs fois s'il apparaît plusieurs - fois ; chaque occurrence en hypothèse en absorbe au plus - une. Retourne un Counter ``{token: nb_occurrences_manquees}``. - """ - ref_count = Counter(reference) - hyp_count = Counter(hypothesis) - missing: Counter = Counter() - for token, n_ref in ref_count.items(): - n_hyp = hyp_count.get(token, 0) - if n_hyp < n_ref: - missing[token] = n_ref - n_hyp - return missing - - -def compute_error_absorption( - reference: Optional[str], - before: Optional[str], - after: Optional[str], - *, - case_sensitive: bool = False, -) -> Optional[dict]: - """Mesure l'absorption d'erreur entre ``before`` et ``after``. - - Parameters - ---------- - reference: - GT (vérité terrain). - before: - Sortie de l'étape précédente (typiquement OCR amont). - after: - Sortie de l'étape courante (typiquement post-correction LLM). - case_sensitive: - Si False (défaut), match case-insensitive — la sortie - ``corrected_tokens``/``introduced_tokens`` reste en casse - GT originale. - - Returns - ------- - dict | None - ``None`` si la GT est vide ou ne contient aucun token. - """ - ref_tokens = _split_words(reference) - if not ref_tokens: - return None - before_tokens = _split_words(before) - after_tokens = _split_words(after) - - if case_sensitive: - ref_match = list(ref_tokens) - before_match = list(before_tokens) - after_match = list(after_tokens) - else: - ref_match = [t.lower() for t in ref_tokens] - before_match = [t.lower() for t in before_tokens] - after_match = [t.lower() for t in after_tokens] - - # Map case-insensitive token → liste de casses GT originales - ref_orig_by_match: dict[str, list[str]] = {} - for orig, m in zip(ref_tokens, ref_match): - ref_orig_by_match.setdefault(m, []).append(orig) - - missing_before = _missing_tokens(ref_match, before_match) - missing_after = _missing_tokens(ref_match, after_match) - - n_errors_before = sum(missing_before.values()) - n_errors_after = sum(missing_after.values()) - - # Calcul corrigé / introduit en multiset - corrected_counter: Counter = Counter() - introduced_counter: Counter = Counter() - kept_wrong_counter: Counter = Counter() - all_tokens = set(missing_before) | set(missing_after) - for tok in all_tokens: - nb = missing_before.get(tok, 0) - na = missing_after.get(tok, 0) - if nb > na: - corrected_counter[tok] = nb - na - kept_wrong_counter[tok] = na - elif na > nb: - introduced_counter[tok] = na - nb - kept_wrong_counter[tok] = nb - else: - kept_wrong_counter[tok] = nb - - n_corrected = sum(corrected_counter.values()) - n_introduced = sum(introduced_counter.values()) - n_kept_wrong = sum(kept_wrong_counter.values()) - - correction_rate = ( - n_corrected / n_errors_before - if n_errors_before > 0 else None - ) - introduction_rate = ( - n_introduced / n_errors_after - if n_errors_after > 0 else None - ) - - def _expand(counter: Counter) -> list[str]: - out: list[str] = [] - for tok, count in counter.items(): - origs = ref_orig_by_match.get(tok, [tok]) - # Ne renvoie que la casse représentative GT - display = origs[0] if origs else tok - out.extend([display] * count) - return out - - return { - "n_gt_tokens": len(ref_tokens), - "n_errors_before": n_errors_before, - "n_errors_after": n_errors_after, - "n_corrected": n_corrected, - "n_introduced": n_introduced, - "n_kept_wrong": n_kept_wrong, - "correction_rate": correction_rate, - "introduction_rate": introduction_rate, - "net_improvement": n_corrected - n_introduced, - "corrected_tokens": _expand(corrected_counter), - "introduced_tokens": _expand(introduced_counter), - } - - -def aggregate_error_absorption( - per_doc: Iterable[Optional[dict]], - *, - sample_tokens: int = 50, -) -> Optional[dict]: - """Agrège les compteurs corpus-wide et recalcule les taux - *micro*. - - Parameters - ---------- - per_doc: - Itérable de sorties de ``compute_error_absorption`` (ou - ``None`` pour les docs sans GT). - sample_tokens: - Nombre maximal de tokens corrigés/introduits gardés dans - l'échantillon (cap pour ne pas exploser le JSON). - - Returns - ------- - dict | None - ``None`` si aucune entry valide. - """ - docs = [d for d in per_doc if d] - if not docs: - return None - n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs) - n_errors_before = sum(int(d.get("n_errors_before") or 0) for d in docs) - n_errors_after = sum(int(d.get("n_errors_after") or 0) for d in docs) - n_corrected = sum(int(d.get("n_corrected") or 0) for d in docs) - n_introduced = sum(int(d.get("n_introduced") or 0) for d in docs) - n_kept_wrong = sum(int(d.get("n_kept_wrong") or 0) for d in docs) - correction_rate = ( - n_corrected / n_errors_before if n_errors_before > 0 else None - ) - introduction_rate = ( - n_introduced / n_errors_after if n_errors_after > 0 else None - ) - corrected_sample: list[str] = [] - introduced_sample: list[str] = [] - for d in docs: - corrected_sample.extend(d.get("corrected_tokens") or []) - introduced_sample.extend(d.get("introduced_tokens") or []) - if ( - len(corrected_sample) >= sample_tokens - and len(introduced_sample) >= sample_tokens - ): - break - return { - "n_docs": len(docs), - "n_gt_tokens": n_gt, - "n_errors_before": n_errors_before, - "n_errors_after": n_errors_after, - "n_corrected": n_corrected, - "n_introduced": n_introduced, - "n_kept_wrong": n_kept_wrong, - "correction_rate": correction_rate, - "introduction_rate": introduction_rate, - "net_improvement": n_corrected - n_introduced, - "corrected_tokens_sample": corrected_sample[:sample_tokens], - "introduced_tokens_sample": introduced_sample[:sample_tokens], - } - +from picarones.measurements.error_absorption import * # noqa: F401, F403 -__all__ = [ - "compute_error_absorption", - "aggregate_error_absorption", -] +import picarones.measurements.error_absorption as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/hallucination.py b/picarones/core/hallucination.py index 07eda573ca8d1b4e659600482d3af3e87f245c21..a84914afd096d6d73aeee9d9746061360bc87a39 100644 --- a/picarones/core/hallucination.py +++ b/picarones/core/hallucination.py @@ -1,331 +1,19 @@ -"""Détection des hallucinations VLM/LLM — Sprint 10. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.hallucination`. -Métriques calculées -------------------- -- Taux d'insertion net : mots/caractères ajoutés absents du GT, distinct du WIL existant -- Ratio de longueur : len(hyp) / len(gt) — ratio > 1.2 → hallucination potentielle -- Score d'ancrage : proportion des n-grammes (trigrammes) de la sortie présents dans le GT -- Blocs hallucinés : segments continus de la sortie sans correspondance GT au-delà d'un seuil -- Badge hallucination : True si ancrage faible ou ratio de longueur anormal -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass - - -# --------------------------------------------------------------------------- -# Helpers texte -# --------------------------------------------------------------------------- - -def _tokenize(text: str) -> list[str]: - """Découpe en mots (minuscules, sans ponctuation).""" - return re.findall(r"[^\s]+", text.lower()) - - -def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]: - """Génère les n-grammes d'une liste de tokens.""" - if len(tokens) < n: - return [tuple(tokens)] if tokens else [] - return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)] - - -# --------------------------------------------------------------------------- -# Blocs hallucinés (segments continus sans ancrage) -# --------------------------------------------------------------------------- - -@dataclass -class HallucinatedBlock: - """Segment continu de la sortie sans correspondance dans le GT.""" - start_token: int - end_token: int - text: str - length: int # nombre de tokens - - def as_dict(self) -> dict: - return { - "start_token": self.start_token, - "end_token": self.end_token, - "text": self.text, - "length": self.length, - } - - -def _detect_hallucinated_blocks( - hyp_tokens: list[str], - gt_token_set: set[str], - tolerance: int = 3, - min_block_length: int = 4, -) -> list[HallucinatedBlock]: - """Détecte les blocs de tokens hypothèse sans correspondance dans le GT. - - Un bloc est un segment contigu de tokens hypothèse dont aucun n'est présent - dans le vocabulaire GT. Une tolérance de ``tolerance`` tokens connus interrompus - est acceptée avant de clore un bloc. - - Parameters - ---------- - hyp_tokens: - Tokens de la sortie OCR/VLM. - gt_token_set: - Ensemble des tokens du GT (pour recherche O(1)). - tolerance: - Nombre de tokens connus consécutifs interrompant un bloc avant de le clore. - min_block_length: - Longueur minimale (tokens) pour qu'un bloc soit signalé. - - Returns - ------- - list[HallucinatedBlock] - """ - blocks: list[HallucinatedBlock] = [] - if not hyp_tokens: - return blocks - - in_block = False - block_start = 0 - consecutive_known = 0 - - for i, tok in enumerate(hyp_tokens): - is_unknown = tok not in gt_token_set - if is_unknown: - if not in_block: - in_block = True - block_start = i - consecutive_known = 0 - else: - consecutive_known = 0 - else: - if in_block: - consecutive_known += 1 - if consecutive_known >= tolerance: - # Clore le bloc - end = i - consecutive_known - length = end - block_start + 1 - if length >= min_block_length: - text = " ".join(hyp_tokens[block_start:end + 1]) - blocks.append(HallucinatedBlock( - start_token=block_start, - end_token=end, - text=text, - length=length, - )) - in_block = False - consecutive_known = 0 - - # Bloc non terminé - if in_block: - end = len(hyp_tokens) - 1 - length = end - block_start + 1 - if length >= min_block_length: - text = " ".join(hyp_tokens[block_start:end + 1]) - blocks.append(HallucinatedBlock( - start_token=block_start, - end_token=end, - text=text, - length=length, - )) - - return blocks - - -# --------------------------------------------------------------------------- -# Résultat structuré -# --------------------------------------------------------------------------- - -@dataclass -class HallucinationMetrics: - """Métriques de détection des hallucinations pour une paire (GT, hypothèse).""" - - net_insertion_rate: float - """Taux d'insertion nette : tokens hypothèse absents du GT / total tokens hypothèse.""" +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.hallucination import ...``) de continuer +à fonctionner sans modification. - length_ratio: float - """Ratio de longueur : len(hyp) / len(gt) en caractères. > 1.2 = signal d'hallucination.""" - - anchor_score: float - """Score d'ancrage : proportion des trigrammes hypothèse présents dans les trigrammes GT. - Score élevé → l'hypothèse s'ancre bien dans le GT. Score faible → hallucinations probables.""" - - hallucinated_blocks: list[HallucinatedBlock] - """Segments continus de la sortie sans correspondance GT (au-dessus du seuil de tolérance).""" - - is_hallucinating: bool - """True si anchor_score < anchor_threshold OU length_ratio > length_ratio_threshold.""" - - # Détails supplémentaires - gt_word_count: int = 0 - hyp_word_count: int = 0 - net_inserted_words: int = 0 - anchor_threshold_used: float = 0.5 - length_ratio_threshold_used: float = 1.2 - ngram_size_used: int = 3 - - def as_dict(self) -> dict: - return { - "net_insertion_rate": round(self.net_insertion_rate, 6), - "length_ratio": round(self.length_ratio, 6), - "anchor_score": round(self.anchor_score, 6), - "hallucinated_blocks": [b.as_dict() for b in self.hallucinated_blocks], - "is_hallucinating": self.is_hallucinating, - "gt_word_count": self.gt_word_count, - "hyp_word_count": self.hyp_word_count, - "net_inserted_words": self.net_inserted_words, - "anchor_threshold_used": self.anchor_threshold_used, - "length_ratio_threshold_used": self.length_ratio_threshold_used, - "ngram_size_used": self.ngram_size_used, - } - - @classmethod - def from_dict(cls, d: dict) -> "HallucinationMetrics": - blocks = [ - HallucinatedBlock(**b) for b in d.get("hallucinated_blocks", []) - ] - return cls( - net_insertion_rate=d.get("net_insertion_rate", 0.0), - length_ratio=d.get("length_ratio", 1.0), - anchor_score=d.get("anchor_score", 1.0), - hallucinated_blocks=blocks, - is_hallucinating=d.get("is_hallucinating", False), - gt_word_count=d.get("gt_word_count", 0), - hyp_word_count=d.get("hyp_word_count", 0), - net_inserted_words=d.get("net_inserted_words", 0), - anchor_threshold_used=d.get("anchor_threshold_used", 0.5), - length_ratio_threshold_used=d.get("length_ratio_threshold_used", 1.2), - ngram_size_used=d.get("ngram_size_used", 3), - ) - - -# --------------------------------------------------------------------------- -# Calcul principal -# --------------------------------------------------------------------------- - -def compute_hallucination_metrics( - reference: str, - hypothesis: str, - n: int = 3, - length_ratio_threshold: float = 1.2, - anchor_threshold: float = 0.5, - block_tolerance: int = 3, - min_block_length: int = 4, -) -> HallucinationMetrics: - """Calcule les métriques de détection des hallucinations VLM/LLM. - - Parameters - ---------- - reference: - Texte de vérité terrain (GT). - hypothesis: - Texte produit par le modèle. - n: - Taille des n-grammes pour le score d'ancrage (défaut : trigrammes). - length_ratio_threshold: - Seuil de ratio de longueur au-dessus duquel on signale une hallucination potentielle. - anchor_threshold: - Seuil de score d'ancrage en dessous duquel on signale une hallucination potentielle. - block_tolerance: - Nombre de tokens connus consécutifs acceptés dans un bloc halluciné. - min_block_length: - Longueur minimale (tokens) pour signaler un bloc halluciné. - - Returns - ------- - HallucinationMetrics - """ - gt_tokens = _tokenize(reference) - hyp_tokens = _tokenize(hypothesis) - - gt_len_chars = len(reference.strip()) - hyp_len_chars = len(hypothesis.strip()) - - # ── Ratio de longueur ──────────────────────────────────────────────── - if gt_len_chars == 0: - length_ratio = 1.0 if hyp_len_chars == 0 else float("inf") - else: - length_ratio = hyp_len_chars / gt_len_chars - - # ── Taux d'insertion nette ─────────────────────────────────────────── - gt_token_set = set(gt_tokens) - hyp_token_count = len(hyp_tokens) - - if hyp_token_count == 0: - net_insertion_rate = 0.0 - net_inserted_words = 0 - else: - net_inserted = [t for t in hyp_tokens if t not in gt_token_set] - net_inserted_words = len(net_inserted) - net_insertion_rate = net_inserted_words / hyp_token_count - - # ── Score d'ancrage (n-grammes) ────────────────────────────────────── - gt_ngrams = set(_ngrams(gt_tokens, n)) - hyp_ngrams = _ngrams(hyp_tokens, n) - - if not hyp_ngrams: - # Pas de n-grammes dans l'hypothèse → ancrage parfait (hypothèse vide ou trop courte) - anchor_score = 1.0 if not gt_ngrams else 0.0 - elif not gt_ngrams: - anchor_score = 0.0 - else: - anchored = sum(1 for ng in hyp_ngrams if ng in gt_ngrams) - anchor_score = anchored / len(hyp_ngrams) - - # ── Blocs hallucinés ───────────────────────────────────────────────── - blocks = _detect_hallucinated_blocks( - hyp_tokens=hyp_tokens, - gt_token_set=gt_token_set, - tolerance=block_tolerance, - min_block_length=min_block_length, - ) - - # ── Badge hallucination ────────────────────────────────────────────── - is_hallucinating = ( - anchor_score < anchor_threshold - or length_ratio > length_ratio_threshold - ) - - return HallucinationMetrics( - net_insertion_rate=net_insertion_rate, - length_ratio=min(length_ratio, 9.99), # plafonner pour la sérialisation - anchor_score=anchor_score, - hallucinated_blocks=blocks, - is_hallucinating=is_hallucinating, - gt_word_count=len(gt_tokens), - hyp_word_count=hyp_token_count, - net_inserted_words=net_inserted_words, - anchor_threshold_used=anchor_threshold, - length_ratio_threshold_used=length_ratio_threshold, - ngram_size_used=n, - ) - - -# --------------------------------------------------------------------------- -# Agrégation sur un corpus -# --------------------------------------------------------------------------- - -def aggregate_hallucination_metrics(results: list[HallucinationMetrics]) -> dict: - """Agrège les métriques d'hallucination sur un corpus. - - Returns - ------- - dict - Statistiques agrégées : anchor_score moyen, taux de documents hallucinés… - """ - if not results: - return {} +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). +""" - n = len(results) - anchor_values = [r.anchor_score for r in results] - ratio_values = [r.length_ratio for r in results] - insertion_values = [r.net_insertion_rate for r in results] - hallucinating_count = sum(1 for r in results if r.is_hallucinating) +from picarones.measurements.hallucination import * # noqa: F401, F403 - return { - "anchor_score_mean": round(sum(anchor_values) / n, 6), - "anchor_score_min": round(min(anchor_values), 6), - "length_ratio_mean": round(sum(ratio_values) / n, 6), - "net_insertion_rate_mean": round(sum(insertion_values) / n, 6), - "hallucinating_doc_count": hallucinating_count, - "hallucinating_doc_rate": round(hallucinating_count / n, 6), - "document_count": n, - } +import picarones.measurements.hallucination as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/history.py b/picarones/core/history.py index 9a360851ae767a99635a18eadae3fa0dcd9fb411..e0134ea445c4435553272c152362ff83c21bed02 100644 --- a/picarones/core/history.py +++ b/picarones/core/history.py @@ -1,615 +1,19 @@ -"""Suivi longitudinal des benchmarks — base SQLite optionnelle. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.history`. -Fonctionnement --------------- -- Chaque run de benchmark est enregistré dans une table SQLite avec horodatage, - corpus, moteurs, métriques agrégées. -- L'historique permet de tracer des courbes d'évolution du CER dans le temps. -- La détection de régression compare le dernier run à une baseline configurable. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.history import ...``) de continuer +à fonctionner sans modification. -Structure de la base --------------------- -Table ``runs`` : - run_id TEXT PRIMARY KEY — UUID ou hash du run - timestamp TEXT — ISO 8601 - corpus_name TEXT - engine_name TEXT - cer_mean REAL - wer_mean REAL - doc_count INTEGER - metadata TEXT — JSON - -Usage ------ ->>> from picarones.core.history import BenchmarkHistory ->>> history = BenchmarkHistory("~/.picarones/history.db") ->>> history.record(benchmark_result) ->>> df = history.query(engine="tesseract", corpus="chroniques") ->>> regression = history.detect_regression(engine="tesseract", threshold=0.02) +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import json -import logging -import sqlite3 -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from picarones.core.results import BenchmarkResult - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Structures de données -# --------------------------------------------------------------------------- - -@dataclass -class HistoryEntry: - """Un enregistrement dans l'historique des benchmarks.""" - run_id: str - timestamp: str - corpus_name: str - engine_name: str - cer_mean: Optional[float] - wer_mean: Optional[float] - doc_count: int - metadata: dict = field(default_factory=dict) - - @property - def cer_percent(self) -> Optional[float]: - return self.cer_mean * 100 if self.cer_mean is not None else None - - def as_dict(self) -> dict: - return { - "run_id": self.run_id, - "timestamp": self.timestamp, - "corpus_name": self.corpus_name, - "engine_name": self.engine_name, - "cer_mean": self.cer_mean, - "wer_mean": self.wer_mean, - "doc_count": self.doc_count, - "metadata": self.metadata, - } - - -@dataclass -class RegressionResult: - """Résultat d'une détection de régression.""" - engine_name: str - corpus_name: str - baseline_run_id: str - baseline_timestamp: str - baseline_cer: Optional[float] - current_run_id: str - current_timestamp: str - current_cer: Optional[float] - delta_cer: Optional[float] - """Delta CER (current - baseline). Positif = régression.""" - is_regression: bool - threshold: float - - def as_dict(self) -> dict: - return { - "engine_name": self.engine_name, - "corpus_name": self.corpus_name, - "baseline_run_id": self.baseline_run_id, - "baseline_timestamp": self.baseline_timestamp, - "baseline_cer": self.baseline_cer, - "current_run_id": self.current_run_id, - "current_timestamp": self.current_timestamp, - "current_cer": self.current_cer, - "delta_cer": self.delta_cer, - "is_regression": self.is_regression, - "threshold": self.threshold, - } - - -# --------------------------------------------------------------------------- -# BenchmarkHistory -# --------------------------------------------------------------------------- - -class BenchmarkHistory: - """Gestionnaire de l'historique des benchmarks dans SQLite. - - Parameters - ---------- - db_path: - Chemin vers le fichier SQLite. Utiliser ``":memory:"`` pour les tests. - - Examples - -------- - >>> history = BenchmarkHistory("~/.picarones/history.db") - >>> history.record(benchmark) - >>> entries = history.query(engine="tesseract") - >>> for e in entries: - ... print(e.timestamp, f"CER={e.cer_percent:.2f}%") - """ - - _CREATE_TABLE = """ - CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY, - timestamp TEXT NOT NULL, - corpus_name TEXT NOT NULL, - engine_name TEXT NOT NULL, - cer_mean REAL, - wer_mean REAL, - doc_count INTEGER, - metadata TEXT - ); - CREATE INDEX IF NOT EXISTS idx_engine ON runs (engine_name); - CREATE INDEX IF NOT EXISTS idx_corpus ON runs (corpus_name); - CREATE INDEX IF NOT EXISTS idx_timestamp ON runs (timestamp); - """ - - def __init__(self, db_path: str = "~/.picarones/history.db") -> None: - if db_path != ":memory:": - path = Path(db_path).expanduser() - path.parent.mkdir(parents=True, exist_ok=True) - self.db_path = str(path) - else: - self.db_path = ":memory:" - self._conn: Optional[sqlite3.Connection] = None - self._init_db() - - def _connect(self) -> sqlite3.Connection: - if self._conn is None: - self._conn = sqlite3.connect(self.db_path) - self._conn.row_factory = sqlite3.Row - return self._conn - - def _init_db(self) -> None: - conn = self._connect() - conn.executescript(self._CREATE_TABLE) - conn.commit() - - def close(self) -> None: - """Ferme la connexion SQLite.""" - if self._conn: - self._conn.close() - self._conn = None - - # ------------------------------------------------------------------ - # Enregistrement - # ------------------------------------------------------------------ - - def record( - self, - benchmark_result: "BenchmarkResult", - run_id: Optional[str] = None, - extra_metadata: Optional[dict] = None, - ) -> str: - """Enregistre les résultats d'un benchmark dans l'historique. - - Parameters - ---------- - benchmark_result: - Résultats à enregistrer (``BenchmarkResult``). - run_id: - Identifiant du run (auto-généré si None). - extra_metadata: - Métadonnées supplémentaires à stocker. - - Returns - ------- - str - L'identifiant du run enregistré. - """ - if run_id is None: - run_id = str(uuid.uuid4()) - - timestamp = datetime.now(timezone.utc).isoformat() - conn = self._connect() - - for report in benchmark_result.engine_reports: - ranking = benchmark_result.ranking() - engine_entry = next( - (r for r in ranking if r["engine"] == report.engine_name), - None, - ) - cer_mean = engine_entry["mean_cer"] if engine_entry else None - wer_mean = engine_entry["mean_wer"] if engine_entry else None - - meta = { - "engine_version": report.engine_version, - "engine_config": report.engine_config, - "picarones_version": benchmark_result.metadata.get("picarones_version", ""), - **(extra_metadata or {}), - } - - conn.execute( - """ - INSERT OR REPLACE INTO runs - (run_id, timestamp, corpus_name, engine_name, - cer_mean, wer_mean, doc_count, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - f"{run_id}_{report.engine_name}", - timestamp, - benchmark_result.corpus_name, - report.engine_name, - cer_mean, - wer_mean, - benchmark_result.document_count, - json.dumps(meta, ensure_ascii=False), - ), - ) - - conn.commit() - logger.info("Benchmark enregistré dans l'historique : run_id=%s", run_id) - return run_id - - def record_single( - self, - run_id: str, - corpus_name: str, - engine_name: str, - cer_mean: Optional[float], - wer_mean: Optional[float], - doc_count: int, - timestamp: Optional[str] = None, - metadata: Optional[dict] = None, - ) -> str: - """Enregistre manuellement une entrée dans l'historique. - - Utile pour les tests, les imports de données externes, ou pour - enregistrer des résultats calculés en dehors de Picarones. - - Returns - ------- - str - run_id enregistré. - """ - if timestamp is None: - timestamp = datetime.now(timezone.utc).isoformat() - - conn = self._connect() - conn.execute( - """ - INSERT OR REPLACE INTO runs - (run_id, timestamp, corpus_name, engine_name, - cer_mean, wer_mean, doc_count, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - run_id, - timestamp, - corpus_name, - engine_name, - cer_mean, - wer_mean, - doc_count, - json.dumps(metadata or {}, ensure_ascii=False), - ), - ) - conn.commit() - return run_id - - # ------------------------------------------------------------------ - # Requêtes - # ------------------------------------------------------------------ - - def query( - self, - engine: Optional[str] = None, - corpus: Optional[str] = None, - since: Optional[str] = None, - limit: int = 100, - ) -> list[HistoryEntry]: - """Retourne l'historique des runs, avec filtres optionnels. - - Parameters - ---------- - engine: - Filtre sur le nom du moteur. - corpus: - Filtre sur le nom du corpus. - since: - Date ISO 8601 minimale (``"2025-01-01"``). - limit: - Nombre maximum d'entrées retournées. - - Returns - ------- - list[HistoryEntry] - Entrées triées par timestamp croissant. - """ - clauses: list[str] = [] - params: list = [] - - if engine: - clauses.append("engine_name = ?") - params.append(engine) - if corpus: - clauses.append("corpus_name = ?") - params.append(corpus) - if since: - clauses.append("timestamp >= ?") - params.append(since) - - where = f"WHERE {' AND '.join(clauses)}" if clauses else "" - params.append(limit) - - conn = self._connect() - rows = conn.execute( - f"SELECT * FROM runs {where} ORDER BY timestamp ASC LIMIT ?", - params, - ).fetchall() - - return [ - HistoryEntry( - run_id=row["run_id"], - timestamp=row["timestamp"], - corpus_name=row["corpus_name"], - engine_name=row["engine_name"], - cer_mean=row["cer_mean"], - wer_mean=row["wer_mean"], - doc_count=row["doc_count"], - metadata=json.loads(row["metadata"] or "{}"), - ) - for row in rows - ] - - def list_engines(self) -> list[str]: - """Retourne la liste des moteurs présents dans l'historique.""" - conn = self._connect() - rows = conn.execute( - "SELECT DISTINCT engine_name FROM runs ORDER BY engine_name" - ).fetchall() - return [row[0] for row in rows] - - def list_corpora(self) -> list[str]: - """Retourne la liste des corpus présents dans l'historique.""" - conn = self._connect() - rows = conn.execute( - "SELECT DISTINCT corpus_name FROM runs ORDER BY corpus_name" - ).fetchall() - return [row[0] for row in rows] - - def count(self) -> int: - """Nombre total d'entrées dans l'historique.""" - conn = self._connect() - return conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0] - - # ------------------------------------------------------------------ - # Courbes d'évolution - # ------------------------------------------------------------------ - - def get_cer_curve( - self, - engine: str, - corpus: Optional[str] = None, - ) -> list[dict]: - """Retourne les données pour tracer la courbe d'évolution du CER. - - Parameters - ---------- - engine: - Nom du moteur. - corpus: - Corpus spécifique (None = tous les corpus pour ce moteur). - - Returns - ------- - list[dict] - Chaque dict contient ``{"timestamp": str, "cer": float, "run_id": str}``. - """ - entries = self.query(engine=engine, corpus=corpus, limit=1000) - return [ - { - "timestamp": e.timestamp, - "cer": e.cer_mean, - "cer_percent": e.cer_percent, - "run_id": e.run_id, - "corpus_name": e.corpus_name, - } - for e in entries - if e.cer_mean is not None - ] - - # ------------------------------------------------------------------ - # Détection de régression - # ------------------------------------------------------------------ - - def detect_regression( - self, - engine: str, - corpus: Optional[str] = None, - threshold: float = 0.01, - baseline_run_id: Optional[str] = None, - ) -> Optional[RegressionResult]: - """Détecte une régression du CER entre deux runs. - - Compare le run le plus récent à une baseline (le run précédent ou - un run spécifique). - - Parameters - ---------- - engine: - Nom du moteur à surveiller. - corpus: - Corpus spécifique (None = tous). - threshold: - Seuil de régression en points absolus de CER (ex : 0.01 = 1%). - Si delta_cer > threshold → régression détectée. - baseline_run_id: - run_id de référence. Si None, utilise l'avant-dernier run. - - Returns - ------- - RegressionResult | None - None si moins de 2 runs disponibles. - """ - entries = self.query(engine=engine, corpus=corpus, limit=1000) - if len(entries) < 2: - logger.info("Pas assez de runs pour détecter une régression (moteur=%s)", engine) - return None - - current = entries[-1] - - if baseline_run_id: - baseline_list = [e for e in entries[:-1] if e.run_id == baseline_run_id] - baseline = baseline_list[0] if baseline_list else entries[-2] - else: - baseline = entries[-2] - - delta = None - is_regression = False - if current.cer_mean is not None and baseline.cer_mean is not None: - delta = current.cer_mean - baseline.cer_mean - is_regression = delta > threshold - - return RegressionResult( - engine_name=engine, - corpus_name=corpus or "tous", - baseline_run_id=baseline.run_id, - baseline_timestamp=baseline.timestamp, - baseline_cer=baseline.cer_mean, - current_run_id=current.run_id, - current_timestamp=current.timestamp, - current_cer=current.cer_mean, - delta_cer=delta, - is_regression=is_regression, - threshold=threshold, - ) - - def detect_all_regressions( - self, - threshold: float = 0.01, - ) -> list[RegressionResult]: - """Détecte les régressions pour tous les moteurs et corpus connus. - - Parameters - ---------- - threshold: - Seuil de régression. - - Returns - ------- - list[RegressionResult] - Uniquement les moteurs où une régression est détectée. - """ - results: list[RegressionResult] = [] - engines = self.list_engines() - corpora = self.list_corpora() - - for engine in engines: - for corpus in corpora: - result = self.detect_regression(engine, corpus, threshold) - if result and result.is_regression: - results.append(result) - - return results - - # ------------------------------------------------------------------ - # Export - # ------------------------------------------------------------------ - - def export_json(self, output_path: str) -> Path: - """Exporte l'historique complet en JSON. - - Parameters - ---------- - output_path: - Chemin du fichier JSON de sortie. - - Returns - ------- - Path - Chemin vers le fichier créé. - """ - entries = self.query(limit=100_000) - path = Path(output_path) - data = { - "picarones_history": True, - "exported_at": datetime.now(timezone.utc).isoformat(), - "total_runs": len(entries), - "engines": self.list_engines(), - "corpora": self.list_corpora(), - "runs": [e.as_dict() for e in entries], - } - path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - return path - - def __repr__(self) -> str: - return f"BenchmarkHistory(db='{self.db_path}', runs={self.count()})" - - -# --------------------------------------------------------------------------- -# Données de démonstration longitudinale -# --------------------------------------------------------------------------- - -def generate_demo_history( - db: BenchmarkHistory, - n_runs: int = 8, - seed: int = 42, -) -> None: - """Insère des données fictives de suivi longitudinal pour la démo. - - Simule l'amélioration progressive d'un modèle tesseract sur 8 runs, - avec une légère régression au run 5. - - Parameters - ---------- - db: - Base d'historique à remplir. - n_runs: - Nombre de runs à générer. - seed: - Graine aléatoire. - """ - import random - rng = random.Random(seed) - - engines = ["tesseract", "pero_ocr", "ancien_moteur"] - corpus = "Chroniques médiévales" - - # Trajectoires de CER simulées (amélioration progressive + bruit) - base_cers = { - "tesseract": 0.15, - "pero_ocr": 0.09, - "ancien_moteur": 0.28, - } - improvements = { - "tesseract": -0.008, # améliore de ~0.8% par run - "pero_ocr": -0.005, # améliore de ~0.5% par run - "ancien_moteur": -0.003, - } - - from datetime import timedelta - base_date = datetime(2024, 9, 1, tzinfo=timezone.utc) - - for run_idx in range(n_runs): - run_date = base_date + timedelta(weeks=run_idx * 2) - run_id = f"demo_run_{run_idx + 1:02d}" - - for engine in engines: - cer = base_cers[engine] + improvements[engine] * run_idx - # Ajouter du bruit + régression au run 5 - noise = rng.gauss(0, 0.005) - if run_idx == 4 and engine == "tesseract": - noise += 0.02 # régression simulée - cer = max(0.01, min(0.5, cer + noise)) - - wer = cer * 1.8 + rng.gauss(0, 0.01) - wer = max(0.01, min(0.9, wer)) +from picarones.measurements.history import * # noqa: F401, F403 - db.record_single( - run_id=f"{run_id}_{engine}", - corpus_name=corpus, - engine_name=engine, - cer_mean=round(cer, 4), - wer_mean=round(wer, 4), - doc_count=12, - timestamp=run_date.isoformat(), - metadata={ - "note": f"Run de démonstration #{run_idx + 1}", - "engine_version": f"5.{run_idx}.0" if engine == "tesseract" else "0.7.2", - }, - ) +import picarones.measurements.history as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/image_quality.py b/picarones/core/image_quality.py index 929bf67f7a4c0a60d2f7029ebdba72a6d665e1fb..4e3a80d1acbcbf6bcea786f415cbdddc6a100abc 100644 --- a/picarones/core/image_quality.py +++ b/picarones/core/image_quality.py @@ -1,391 +1,19 @@ -"""Analyse automatique de la qualité des images de documents numérisés. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.image_quality`. -Métriques ---------- -- **Score de netteté** : variance du laplacien (plus élevé = plus net) -- **Niveau de bruit** : écart-type des résidus haute-fréquence -- **Angle de rotation résiduel** : estimé par projection horizontale -- **Score de contraste** : ratio Michelson entre zones sombres (encre) et claires (fond) -- **Score de qualité global** : combinaison normalisée des métriques ci-dessus +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.image_quality import ...``) de continuer +à fonctionner sans modification. -Ces calculs sont réalisés en pur Python + bibliothèques stdlib ou Pillow. -NumPy est utilisé si disponible (calculs plus rapides), mais les méthodes -de fallback n'en dépendent pas. - -Note ----- -Pour les images placeholder (fixtures), des valeurs fictives cohérentes -sont générées via `generate_mock_quality_scores()`. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import math -import statistics -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class ImageQualityResult: - """Métriques de qualité d'une image de document.""" - - sharpness_score: float = 0.0 - """Score de netteté [0, 1]. Basé sur la variance du laplacien normalisée.""" - - noise_level: float = 0.0 - """Niveau de bruit [0, 1]. 0 = pas de bruit, 1 = très bruité.""" - - rotation_degrees: float = 0.0 - """Angle de rotation résiduel estimé en degrés (positif = sens horaire).""" - - contrast_score: float = 0.0 - """Score de contraste [0, 1]. Ratio Michelson encre/fond.""" - - quality_score: float = 0.0 - """Score de qualité global [0, 1]. Combinaison pondérée des autres métriques.""" - - analysis_method: str = "none" - """Méthode d'analyse utilisée : 'pillow', 'numpy', 'mock'.""" - - error: Optional[str] = None - """Erreur si l'analyse a échoué.""" - - @property - def is_good_quality(self) -> bool: - """Vrai si le score de qualité global est ≥ 0.7.""" - return self.quality_score >= 0.7 - - @property - def quality_tier(self) -> str: - """Catégorie de qualité : 'good', 'medium', 'poor'.""" - if self.quality_score >= 0.7: - return "good" - elif self.quality_score >= 0.4: - return "medium" - return "poor" - - def as_dict(self) -> dict: - d = { - "sharpness_score": round(self.sharpness_score, 4), - "noise_level": round(self.noise_level, 4), - "rotation_degrees": round(self.rotation_degrees, 2), - "contrast_score": round(self.contrast_score, 4), - "quality_score": round(self.quality_score, 4), - "quality_tier": self.quality_tier, - "analysis_method": self.analysis_method, - } - if self.error: - d["error"] = self.error - return d - - @classmethod - def from_dict(cls, data: dict) -> "ImageQualityResult": - return cls( - sharpness_score=data.get("sharpness_score", 0.0), - noise_level=data.get("noise_level", 0.0), - rotation_degrees=data.get("rotation_degrees", 0.0), - contrast_score=data.get("contrast_score", 0.0), - quality_score=data.get("quality_score", 0.0), - analysis_method=data.get("analysis_method", "none"), - error=data.get("error"), - ) - - -def analyze_image_quality(image_path: str | Path) -> ImageQualityResult: - """Analyse la qualité d'une image de document numérisé. - - Essaie successivement : - 1. Pillow + NumPy (méthode complète) - 2. Pillow seul (méthode simplifiée) - 3. Fallback : retourne un résultat vide avec erreur - - Parameters - ---------- - image_path: - Chemin vers l'image (JPG, PNG, TIFF…). - - Returns - ------- - ImageQualityResult - """ - path = Path(image_path) - if not path.exists(): - return ImageQualityResult( - error=f"Fichier image introuvable : {image_path}", - analysis_method="none", - ) - - # Essai avec Pillow + NumPy - try: - import numpy as np - from PIL import Image - return _analyze_with_numpy(path, np, Image) - except ImportError: - pass - - # Essai avec Pillow seul - try: - from PIL import Image - return _analyze_with_pillow(path, Image) - except ImportError: - pass - - return ImageQualityResult( - error="Pillow non disponible (pip install Pillow)", - analysis_method="none", - quality_score=0.5, # valeur neutre - ) - - -def _analyze_with_numpy(path: Path, np, Image) -> ImageQualityResult: - """Analyse complète avec NumPy.""" - img = Image.open(path).convert("L") # niveaux de gris - arr = np.array(img, dtype=np.float32) - - # 1. Netteté : variance du laplacien - laplacian = _laplacian_variance_numpy(arr, np) - # Normalisation empirique : variance > 500 = très net, < 50 = flou - sharpness = min(1.0, laplacian / 500.0) - - # 2. Bruit : écart-type des résidus (différence image - image lissée) - noise = _noise_level_numpy(arr, np) - - # 3. Rotation : angle d'inclinaison estimé - rotation = _estimate_rotation_numpy(arr, np) - - # 4. Contraste : ratio Michelson - contrast = _contrast_score_numpy(arr, np) - - # 5. Score global pondéré - quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) - - return ImageQualityResult( - sharpness_score=float(sharpness), - noise_level=float(noise), - rotation_degrees=float(rotation), - contrast_score=float(contrast), - quality_score=float(quality), - analysis_method="numpy", - ) - - -def _analyze_with_pillow(path: Path, Image) -> ImageQualityResult: - """Analyse simplifiée avec Pillow seul (sans NumPy).""" - img = Image.open(path).convert("L") - pixels = list(img.tobytes()) # mode "L" = 1 byte/pixel - w, h = img.size - - if not pixels: - return ImageQualityResult(quality_score=0.5, analysis_method="pillow") - - # Contraste : étendue des valeurs - min_val = min(pixels) - max_val = max(pixels) - if max_val + min_val > 0: - contrast = (max_val - min_val) / (max_val + min_val) - else: - contrast = 0.0 - - # Netteté approximée : variance globale des pixels - try: - variance = statistics.variance(pixels) - except statistics.StatisticsError: - variance = 0.0 - sharpness = min(1.0, math.sqrt(variance) / 128.0) - - # Bruit : approximation grossière - noise = min(1.0, statistics.stdev(pixels[:min(1000, len(pixels))]) / 64.0) if len(pixels) > 1 else 0.0 - - quality = _global_quality_score(sharpness, noise, 0.0, contrast) - - return ImageQualityResult( - sharpness_score=sharpness, - noise_level=noise, - rotation_degrees=0.0, # non calculé sans NumPy - contrast_score=contrast, - quality_score=quality, - analysis_method="pillow", - ) - - -def _laplacian_variance_numpy(arr, np) -> float: - """Calcule la variance du laplacien (mesure de netteté).""" - # Convolution laplacien 3x3 via slicing (bordures ignorées) - h, w = arr.shape - if h < 3 or w < 3: - return float(np.var(arr)) - - # Utiliser une convolution rapide avec slicing - center = arr[1:-1, 1:-1] - top = arr[:-2, 1:-1] - bottom = arr[2:, 1:-1] - left = arr[1:-1, :-2] - right = arr[1:-1, 2:] - lap = top + bottom + left + right - 4 * center - - return float(np.var(lap)) - - -def _noise_level_numpy(arr, np) -> float: - """Estime le niveau de bruit par la MAD (Median Absolute Deviation) des gradients.""" - h, w = arr.shape - if h < 2 or w < 2: - return 0.0 - # Différences horizontales et verticales - diff_h = np.abs(arr[:, 1:] - arr[:, :-1]) - diff_v = np.abs(arr[1:, :] - arr[:-1, :]) - noise_std = float(np.median(np.concatenate([diff_h.ravel(), diff_v.ravel()]))) - # Normaliser : 0 = pas de bruit, 1 = très bruité (seuil à ~30) - return min(1.0, noise_std / 30.0) - - -def _estimate_rotation_numpy(arr, np) -> float: - """Estime l'angle de rotation par projection horizontale simplifiée. - - Retourne l'angle estimé en degrés [-45, 45]. - """ - # Méthode simplifiée : analyse de la variance des projections à différents angles - # Limiter à quelques angles pour la performance - h, w = arr.shape - if h < 20 or w < 20: - return 0.0 - - # Sous-échantillonnage pour la performance - step = max(1, h // 100) - sample = arr[::step, :] - - best_angle = 0.0 - best_var = -1.0 - - for angle_deg in range(-5, 6): # ±5 degrés, pas de 1° - angle_rad = math.radians(angle_deg) - # Projection horizontale après rotation approximative - # (approximation linéaire rapide) - offsets = np.round( - np.arange(sample.shape[0]) * math.tan(angle_rad) - ).astype(int) - offsets = np.clip(offsets, 0, w - 1) - - # Variance des sommes de lignes décalées - try: - row_sums = np.array([ - float(np.sum(sample[i, max(0, offsets[i]):min(w, offsets[i]+w)])) - for i in range(sample.shape[0]) - ]) - var = float(np.var(row_sums)) - if var > best_var: - best_var = var - best_angle = float(angle_deg) - except Exception as e: - logger.warning( - "[image_quality] projection à %d° indisponible : %s", - angle_deg, e, - ) - - return best_angle - - -def _contrast_score_numpy(arr, np) -> float: - """Score de contraste Michelson [0, 1].""" - p5 = float(np.percentile(arr, 5)) # fond clair - p95 = float(np.percentile(arr, 95)) # encre sombre - if p5 + p95 == 0: - return 0.0 - # Michelson : (Imax - Imin) / (Imax + Imin) - return float((p95 - p5) / (p95 + p5)) - - -def _global_quality_score( - sharpness: float, - noise: float, - rotation_abs: float, - contrast: float, -) -> float: - """Calcule le score de qualité global pondéré.""" - # Poids : netteté (40%), contraste (30%), bruit (20%), rotation (10%) - score = ( - 0.40 * sharpness - + 0.30 * contrast - + 0.20 * (1.0 - noise) # moins de bruit = mieux - + 0.10 * max(0.0, 1.0 - rotation_abs / 10.0) # ±10° max - ) - return round(min(1.0, max(0.0, score)), 4) - - -# --------------------------------------------------------------------------- -# Données fictives pour les fixtures de démo -# --------------------------------------------------------------------------- - -def generate_mock_quality_scores( - doc_id: str, - seed: Optional[int] = None, -) -> ImageQualityResult: - """Génère des métriques de qualité fictives mais cohérentes pour un document. - - Utilisé par les fixtures de démo pour simuler une diversité réaliste - de qualités d'image (bonne, moyenne, dégradée). - - Parameters - ---------- - doc_id: - Identifiant du document (utilisé pour la reproductibilité). - seed: - Graine aléatoire optionnelle. - """ - import random - rng = random.Random(seed or hash(doc_id) % 2**32) - - # Générer une qualité cohérente : certains docs sont plus difficiles - base_quality = 0.3 + rng.random() * 0.6 # 0.3 à 0.9 - - sharpness = max(0.1, min(1.0, base_quality + rng.gauss(0, 0.1))) - noise = max(0.0, min(1.0, (1.0 - base_quality) * 0.8 + rng.gauss(0, 0.05))) - rotation = rng.gauss(0, 1.5) # ±1.5° typique - contrast = max(0.2, min(1.0, base_quality + rng.gauss(0, 0.15))) - - quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) - - return ImageQualityResult( - sharpness_score=round(sharpness, 4), - noise_level=round(noise, 4), - rotation_degrees=round(rotation, 2), - contrast_score=round(contrast, 4), - quality_score=round(quality, 4), - analysis_method="mock", - ) - - -def aggregate_image_quality(results: list[ImageQualityResult]) -> dict: - """Agrège les métriques de qualité image sur un corpus.""" - if not results: - return {} - - valid = [r for r in results if r.error is None] - if not valid: - return {"error": "Aucune analyse réussie"} - - def _mean(vals: list[float]) -> float: - return round(statistics.mean(vals), 4) if vals else 0.0 - - quality_scores = [r.quality_score for r in valid] - sharpness_scores = [r.sharpness_score for r in valid] - noise_levels = [r.noise_level for r in valid] - - # Distribution par tier - tiers = {"good": 0, "medium": 0, "poor": 0} - for r in valid: - tiers[r.quality_tier] += 1 +from picarones.measurements.image_quality import * # noqa: F401, F403 - return { - "mean_quality_score": _mean(quality_scores), - "mean_sharpness": _mean(sharpness_scores), - "mean_noise_level": _mean(noise_levels), - "quality_distribution": tiers, - "document_count": len(valid), - "scores": [r.quality_score for r in valid], # pour scatter plot - } +import picarones.measurements.image_quality as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/incremental_comparison.py b/picarones/core/incremental_comparison.py index 03f3ea0ee8a3b3eb7ed90af59349f49cb95386e1..3b747cf5003096d3a64b816975e4897aafe79439 100644 --- a/picarones/core/incremental_comparison.py +++ b/picarones/core/incremental_comparison.py @@ -1,253 +1,19 @@ -"""Comparaison incrémentale de pipelines composées — Sprint 96 (B.5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.incremental_comparison`. -Sprint 96 — B.5 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.incremental_comparison import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Avec 5 OCR × 3 reconstructeurs × 4 post-correcteurs × 3 -mappeurs = 180 pipelines à comparer, le rapport noie -l'information. Il faut un mécanisme de **comparaison -contrôlée** type design d'expérience. - -Méthode -------- -Pour mesurer l'effet isolé d'un slot ``varying`` : - -1. Fixer les valeurs des autres slots (``fixed``). -2. Pour chaque combinaison des fixed, comparer les pipelines - qui ne diffèrent que sur le slot varying. -3. Agréger : pour chaque valeur du slot varying, calculer - sa moyenne, son écart-type, son rang moyen sur les groupes. - -C'est presque un Latin square automatisé. Sans ça, le -rapport sur 180 pipelines est inutilisable. - -Pas de tests statistiques scipy -------------------------------- -On ne reconstruit pas Friedman/Nemenyi (déjà dans Sprint 18) ; -on agrège ici les données nécessaires pour qu'un -tests statistique externe puisse les consommer. Le rapport -existant reste libre de brancher -``picarones.core.statistics.friedman_test`` sur la sortie de -ce module. - -Sortie ------- -``compare_isolated_effect(runs, varying_slot)`` retourne : - -.. code-block:: text - - { - "varying_slot": str, - "n_runs": int, - "n_groups": int, # combinaisons fixed distinctes - "values": list[str], # valeurs distinctes du slot - "per_value": {value: { - "n_observations": int, - "mean": float | None, - "stdev": float | None, - "min": float, "max": float, - "mean_rank": float | None, - }}, - "best_value": str | None, - "worst_value": str | None, - "groups": list[dict], # détail par groupe - } +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import statistics -from dataclasses import dataclass -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PipelineRun: - """Un run de pipeline composée pour la comparaison contrôlée. - - Attributes - ---------- - name: - Nom du run (libre — informatif uniquement). - slots: - Map ``{slot_name: module_name}`` décrivant la pipeline - (ex. ``{"ocr": "tess", "llm": "gpt-4o"}``). - score: - Métrique numérique à comparer (CER moyen typiquement). - Plus bas = meilleur par convention sauf si - ``higher_is_better=True`` est passé à - ``compare_isolated_effect``. - """ - - name: str - slots: dict[str, str] - score: float - - def as_dict(self) -> dict: - return { - "name": self.name, - "slots": dict(self.slots), - "score": self.score, - } - - -def _normalise_runs(runs) -> list[PipelineRun]: - """Accepte une liste de ``PipelineRun`` ou de dicts compatibles.""" - out: list[PipelineRun] = [] - for r in runs: - if isinstance(r, PipelineRun): - out.append(r) - continue - if not isinstance(r, dict): - continue - slots = r.get("slots") or {} - if not isinstance(slots, dict): - continue - try: - score = float(r.get("score")) - except (TypeError, ValueError): - continue - out.append(PipelineRun( - name=str(r.get("name") or ""), - slots={str(k): str(v) for k, v in slots.items()}, - score=score, - )) - return out - - -def compare_isolated_effect( - runs, - varying_slot: str, - *, - higher_is_better: bool = False, -) -> Optional[dict]: - """Mesure l'effet isolé du slot ``varying_slot``. - - Parameters - ---------- - runs: - Liste de ``PipelineRun`` (ou dicts compatibles). - varying_slot: - Nom du slot dont on veut isoler l'effet. Les autres - slots constituent les groupes de contrôle. - higher_is_better: - Si ``True``, on inverse la convention de classement - (rang 1 = score le plus haut). Défaut ``False`` = - rang 1 = score le plus bas (CER). - - Returns - ------- - dict | None - ``None`` si moins de 2 runs ou si ``varying_slot`` - n'est présent dans aucun run. - """ - runs_list = _normalise_runs(runs) - if len(runs_list) < 2: - return None - runs_list = [r for r in runs_list if varying_slot in r.slots] - if not runs_list: - return None - - # Constitue les groupes par valeurs des slots fixed - groups: dict[tuple, list[PipelineRun]] = {} - fixed_slot_names: list[str] = [] - for r in runs_list: - other_slots = sorted(k for k in r.slots if k != varying_slot) - if not fixed_slot_names: - fixed_slot_names = other_slots - # Skip runs avec un schéma de slots incompatible - if other_slots != fixed_slot_names: - continue - key = tuple((k, r.slots[k]) for k in other_slots) - groups.setdefault(key, []).append(r) - - if not groups: - return None - - # Pour chaque groupe : ranking des runs par score - per_value: dict[str, dict] = {} - group_details: list[dict] = [] - for key, members in groups.items(): - members_sorted = sorted( - members, key=lambda x: x.score, reverse=higher_is_better, - ) - # Rangs : runs ex aequo partagent la moyenne des rangs - ranks: dict[str, float] = {} - i = 0 - while i < len(members_sorted): - j = i - while ( - j + 1 < len(members_sorted) - and members_sorted[j + 1].score == members_sorted[i].score - ): - j += 1 - avg_rank = (i + 1 + j + 1) / 2 - for k in range(i, j + 1): - value = members_sorted[k].slots[varying_slot] - ranks[value] = avg_rank - i = j + 1 - - for r in members: - value = r.slots[varying_slot] - slot = per_value.setdefault(value, { - "scores": [], - "ranks": [], - }) - slot["scores"].append(r.score) - slot["ranks"].append(ranks[value]) - group_details.append({ - "fixed_slots": dict(key), - "n_members": len(members), - "values": [r.slots[varying_slot] for r in members_sorted], - "scores": [r.score for r in members_sorted], - }) - - # Calcul mean/stdev/min/max + rang moyen par valeur - summary: dict[str, dict] = {} - for value, slot in per_value.items(): - scores = slot["scores"] - ranks = slot["ranks"] - summary[value] = { - "n_observations": len(scores), - "mean": statistics.fmean(scores) if scores else None, - "stdev": ( - statistics.stdev(scores) if len(scores) >= 2 else None - ), - "min": min(scores), - "max": max(scores), - "mean_rank": ( - statistics.fmean(ranks) if ranks else None - ), - } - - # Best/worst : sur la mean (convention CER : plus bas = meilleur) - by_mean = sorted( - ((v, d["mean"]) for v, d in summary.items() - if d["mean"] is not None), - key=lambda kv: kv[1], - reverse=higher_is_better, - ) - best_value = by_mean[0][0] if by_mean else None - worst_value = by_mean[-1][0] if by_mean else None - - return { - "varying_slot": varying_slot, - "n_runs": len(runs_list), - "n_groups": len(groups), - "values": sorted(per_value.keys()), - "per_value": summary, - "best_value": best_value, - "worst_value": worst_value, - "groups": group_details, - "higher_is_better": higher_is_better, - } - +from picarones.measurements.incremental_comparison import * # noqa: F401, F403 -__all__ = [ - "PipelineRun", - "compare_isolated_effect", -] +import picarones.measurements.incremental_comparison as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/inter_engine.py b/picarones/core/inter_engine.py index 68576f0ef9792451092a94aadeafb2c9aea4cf97..27cc04dc89de133da77300b22d39b7c264fab96a 100644 --- a/picarones/core/inter_engine.py +++ b/picarones/core/inter_engine.py @@ -1,484 +1,19 @@ -"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.inter_engine`. -Deux familles de mesures qui répondent à des questions différentes mais -liées : +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.inter_engine import ...``) de continuer +à fonctionner sans modification. -1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`, - `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des - erreurs de natures différentes ?* Une divergence élevée signale des - moteurs spécialisés sur des classes d'erreurs distinctes (visual vs - abréviation vs casse) et donc des candidats pour un voting ensemble. - -2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`, - `pairwise_disagreement_rate`) — *quel CER serait atteignable si on - combinait les moteurs ?* La borne inférieure du CER atteignable par - un voting majoritaire token-level est ``1 - oracle_token_recall``. - Si elle est très inférieure au CER du meilleur moteur seul, l'effort - d'un pipeline d'ensemble se justifie. Sinon non. - -Convention de typage --------------------- -Toutes les fonctions sont enregistrables dans le registre Sprint 34 si -on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour -limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont -des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne -correspondent pas au modèle « une jonction = une métrique » du runner. -Elles sont consommées par les détecteurs narratifs et le rapport HTML. - -Note sur l'oracle ------------------ -La métrique ``oracle_token_recall`` retournée ici utilise un alignement -bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie -borne atteignable par voting majoritaire séquentiel — c'est une borne -supérieure (proxy optimiste). La vraie borne demanderait un -alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour -le diagnostic « ensemble vaut-il le coup ? », le proxy suffit -largement ; on documente clairement la limite dans le glossaire et le -rapport. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import math -from collections import Counter - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Divergence taxonomique (KL / Jensen-Shannon) -# ────────────────────────────────────────────────────────────────────────── - - -def _smoothed_distribution( - distribution: dict[str, float], - keys: list[str], - epsilon: float = 1e-12, -) -> list[float]: - """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros. - - Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement - minuscule pour ne pas modifier le résultat de manière sensible. - """ - smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys] - total = sum(smoothed) - return [v / total for v in smoothed] - - -def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float: - """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés. - - Les distributions n'ont pas besoin de partager exactement les mêmes - clés ; les clés manquantes sont lissées à ``epsilon`` puis - renormalisées. - - Returns - ------- - float - ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas - symétrique : ``kl(p, q) != kl(q, p)`` en général. - """ - keys = sorted(set(p.keys()) | set(q.keys())) - if not keys: - return 0.0 - p_vec = _smoothed_distribution(p, keys) - q_vec = _smoothed_distribution(q, keys) - return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec)) - - -def jensen_shannon_divergence( - p: dict[str, float], - q: dict[str, float], -) -> float: - """JS-divergence symétrique en bits, bornée dans ``[0, 1]``. - - ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``. - Symétrique et bornée — préférable à la KL pour construire une - matrice triangulaire de divergences entre moteurs. - """ - keys = sorted(set(p.keys()) | set(q.keys())) - if not keys: - return 0.0 - p_vec = _smoothed_distribution(p, keys) - q_vec = _smoothed_distribution(q, keys) - m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)] - - def _kl(a: list[float], b: list[float]) -> float: - return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0) - - js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec) - # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les - # erreurs d'arrondi flottant. - return max(0.0, min(1.0, js)) - - -def taxonomy_divergence_matrix( - distributions: dict[str, dict[str, float]], - metric: str = "js", -) -> dict[str, dict[str, float]]: - """Construit la matrice de divergence triangulaire entre moteurs. - - Parameters - ---------- - distributions: - ``{engine_name: {error_class: probability}}``. Chaque - distribution doit sommer à environ 1 (pas de validation stricte - — les distributions taxonomiques de Picarones sont déjà - normalisées par ``aggregate_taxonomy``). - metric: - ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique). - - Returns - ------- - dict[str, dict[str, float]] - Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour - ``js``, asymétrique pour ``kl``. La diagonale vaut 0. - """ - if metric not in ("js", "kl"): - raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}") - fn = jensen_shannon_divergence if metric == "js" else kl_divergence - - engines = sorted(distributions.keys()) - matrix: dict[str, dict[str, float]] = {a: {} for a in engines} - for a in engines: - for b in engines: - if a == b: - matrix[a][b] = 0.0 - elif metric == "js" and b in matrix and a in matrix[b]: - # Symétrique : recopie pour éviter de recalculer - matrix[a][b] = matrix[b][a] - else: - matrix[a][b] = fn(distributions[a], distributions[b]) - return matrix - - -# ────────────────────────────────────────────────────────────────────────── -# Complémentarité (oracle token recall) -# ────────────────────────────────────────────────────────────────────────── - - -def _word_multiset(text: str) -> Counter[str]: - """Décomposition en multiset de tokens (séparateur whitespace).""" - return Counter(tok for tok in text.split() if tok) - - -def oracle_token_recall( - reference: str, - hypotheses: dict[str, str], -) -> float: - """Borne supérieure (proxy bag-of-words) du token-recall atteignable - par un voting majoritaire entre tous les moteurs fournis. - - Pour chaque token de la référence (avec sa multiplicité), on - considère qu'il est "préservé" par l'ensemble si au moins un moteur - en produit une occurrence non encore comptée. Le score est le ratio - d'occurrences GT préservées sur le total. - - Parameters - ---------- - reference: - Texte GT. - hypotheses: - ``{engine_name: hypothesis_text}``. - - Returns - ------- - float - Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent - dans au moins une hypothèse à hauteur de sa multiplicité. - - Note - ---- - Cette borne est **optimiste** (supérieure à la vraie borne par - voting séquentiel) car elle ignore l'ordre d'apparition. Pour le - diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour - une vraie borne il faudrait un alignement séquentiel. - """ - ref_counter = _word_multiset(reference) - if not ref_counter or not hypotheses: - return 1.0 if not ref_counter else 0.0 - - hyp_counters = [_word_multiset(h) for h in hypotheses.values()] - total_ref = sum(ref_counter.values()) - preserved = 0 - for token, gt_count in ref_counter.items(): - # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné - # à la multiplicité GT. L'oracle prend le max sur les moteurs. - best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0) - preserved += best - return preserved / total_ref - - -def complementarity_gap( - reference: str, - hypotheses: dict[str, str], -) -> dict[str, float]: - """Compare l'oracle au meilleur moteur seul. - - Returns - ------- - dict - ``{ - "oracle_recall": float, # bag-of-words recall de l'oracle - "best_single_recall": float, # meilleur recall token d'un moteur seul - "best_engine": str, # nom du moteur correspondant - "absolute_gap": float, # oracle - best_single (toujours ≥ 0) - "relative_gap": float, # absolute_gap / (1 - best_single + ε) - # = fraction des erreurs encore évitables - # par un ensemble - }`` - """ - ref_counter = _word_multiset(reference) - total = sum(ref_counter.values()) - if not total: - return { - "oracle_recall": 1.0, - "best_single_recall": 1.0, - "best_engine": "", - "absolute_gap": 0.0, - "relative_gap": 0.0, - } - - def _single_recall(hyp_text: str) -> float: - hc = _word_multiset(hyp_text) - preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items()) - return preserved / total - - if not hypotheses: - return { - "oracle_recall": 0.0, - "best_single_recall": 0.0, - "best_engine": "", - "absolute_gap": 0.0, - "relative_gap": 0.0, - } - - per_engine = {name: _single_recall(h) for name, h in hypotheses.items()} - best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1]) - oracle = oracle_token_recall(reference, hypotheses) - - absolute_gap = max(0.0, oracle - best_recall) - # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble - # serait théoriquement capable de récupérer (∈ [0, 1]) - headroom = max(1.0 - best_recall, 1e-12) - relative_gap = min(1.0, absolute_gap / headroom) - - return { - "oracle_recall": oracle, - "best_single_recall": best_recall, - "best_engine": best_engine, - "absolute_gap": absolute_gap, - "relative_gap": relative_gap, - } - - -def pairwise_disagreement_rate( - reference: str, - hyp_a: str, - hyp_b: str, -) -> float: - """Fraction de tokens GT pour lesquels A et B sont en désaccord. - - Un désaccord = (l'un préserve le token, l'autre non) OU - (les deux le ratent mais avec des substitutions différentes — non - capturé ici, on reste sur la version simple présence/absence). - - Returns - ------- - float - Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix - (pas de gain d'ensemble). ``1`` = A et B sont toujours en - désaccord (gain d'ensemble maximal). - """ - ref_counter = _word_multiset(reference) - if not ref_counter: - return 0.0 - a = _word_multiset(hyp_a) - b = _word_multiset(hyp_b) - total = sum(ref_counter.values()) - disagree = 0 - for tok, gt_count in ref_counter.items(): - a_pres = min(gt_count, a.get(tok, 0)) - b_pres = min(gt_count, b.get(tok, 0)) - # Compte les positions où A et B donnent une réponse différente - disagree += abs(a_pres - b_pres) - return disagree / total - - -# ────────────────────────────────────────────────────────────────────────── -# Agrégation au niveau benchmark (Sprint 36) -# ────────────────────────────────────────────────────────────────────────── - - -def compute_inter_engine_analysis( - *, - per_engine_outputs: dict[str, dict[str, str]], - ground_truths: dict[str, str], - taxonomy_distributions: dict[str, dict[str, float]] | None = None, - divergence_metric: str = "js", -) -> dict: - """Agrège les métriques inter-moteurs sur l'ensemble du corpus. - - Parameters - ---------- - per_engine_outputs: - ``{engine_name: {doc_id: hypothesis_text}}``. Une entrée par - moteur, avec une hypothèse par document. Les documents absents - d'un moteur (échecs, timeouts) sont simplement ignorés pour ce - moteur — l'oracle est calculé sur les moteurs qui ont produit - une sortie pour le doc. - ground_truths: - ``{doc_id: ground_truth_text}``. La GT est la même pour tous - les moteurs ; on la passe une seule fois. - taxonomy_distributions: - ``{engine_name: {error_class: probability}}`` — typiquement - ``EngineReport.aggregated_taxonomy["class_distribution"]``. Si - ``None`` ou vide, la divergence taxonomique n'est pas calculée. - divergence_metric: - ``"js"`` (défaut, symétrique) ou ``"kl"``. - - Returns - ------- - dict - Structure stable consommable par les détecteurs narratifs et le - rapport HTML : - ``{ - "complementarity": { - "oracle_recall": float, - "best_single_recall": float, - "best_engine": str, - "absolute_gap": float, - "relative_gap": float, - "doc_count": int, - "per_doc": [{doc_id, oracle, best, gap}, ...] # max 50 docs - }, - "taxonomy_divergence": { - "metric": "js"|"kl", - "matrix": {engine_a: {engine_b: divergence}}, - "max_pair": [engine_a, engine_b, value] # paire la plus divergente - } | None, - "engines": [...], # liste des moteurs analysés (ordre stable) - }`` - """ - engines = sorted(per_engine_outputs.keys()) - result: dict = {"engines": engines} - - # ── Complémentarité agrégée doc par doc ────────────────────────────── - if not engines: - result["complementarity"] = None - else: - total_oracle_preserved = 0 - total_ref_tokens = 0 - per_engine_preserved: dict[str, int] = {name: 0 for name in engines} - per_doc_records: list[dict] = [] - - for doc_id, gt in ground_truths.items(): - ref_counter = _word_multiset(gt) - ref_total = sum(ref_counter.values()) - if not ref_total: - continue - total_ref_tokens += ref_total - - doc_hyps: dict[str, str] = {} - for name in engines: - hyp = per_engine_outputs.get(name, {}).get(doc_id) - if hyp is not None: - doc_hyps[name] = hyp - - if not doc_hyps: - continue - - hyp_counters = {n: _word_multiset(h) for n, h in doc_hyps.items()} - - doc_oracle = 0 - doc_best_per_engine: dict[str, int] = {n: 0 for n in doc_hyps} - for tok, gt_count in ref_counter.items(): - # Oracle : meilleur des moteurs sur ce token - best_for_token = 0 - for name, hc in hyp_counters.items(): - preserved = min(gt_count, hc.get(tok, 0)) - doc_best_per_engine[name] += preserved - if preserved > best_for_token: - best_for_token = preserved - doc_oracle += best_for_token - - total_oracle_preserved += doc_oracle - for name, count in doc_best_per_engine.items(): - per_engine_preserved[name] += count - - doc_best = max(doc_best_per_engine.values()) if doc_best_per_engine else 0 - per_doc_records.append({ - "doc_id": doc_id, - "oracle_recall": doc_oracle / ref_total, - "best_single_recall": doc_best / ref_total, - "absolute_gap": (doc_oracle - doc_best) / ref_total, - }) - - if total_ref_tokens == 0: - result["complementarity"] = None - else: - oracle_recall = total_oracle_preserved / total_ref_tokens - recalls = { - name: per_engine_preserved[name] / total_ref_tokens - for name in engines - } - best_engine, best_recall = max(recalls.items(), key=lambda kv: kv[1]) - absolute_gap = max(0.0, oracle_recall - best_recall) - headroom = max(1.0 - best_recall, 1e-12) - relative_gap = min(1.0, absolute_gap / headroom) - - # Garder les ``per_doc_records`` les plus instructifs : tri par - # gap absolu décroissant, top 50. Les détecteurs narratifs - # n'en consomment que quelques-uns. - per_doc_records.sort(key=lambda r: r["absolute_gap"], reverse=True) - per_doc_top = per_doc_records[:50] - - result["complementarity"] = { - "oracle_recall": oracle_recall, - "best_single_recall": best_recall, - "best_engine": best_engine, - "absolute_gap": absolute_gap, - "relative_gap": relative_gap, - "doc_count": len(per_doc_records), - "per_engine_recall": recalls, - "per_doc": per_doc_top, - } - - # ── Divergence taxonomique ───────────────────────────────────────── - if not taxonomy_distributions: - result["taxonomy_divergence"] = None - else: - matrix = taxonomy_divergence_matrix( - taxonomy_distributions, - metric=divergence_metric, - ) - # Cherche la paire la plus divergente (utile pour la synthèse - # narrative qui veut nommer les deux moteurs candidats à - # l'ensemble). - max_pair: tuple[str, str, float] = ("", "", 0.0) - names = sorted(matrix.keys()) - for i, a in enumerate(names): - for b in names[i + 1:]: - v = matrix[a][b] - if v > max_pair[2]: - max_pair = (a, b, v) - - result["taxonomy_divergence"] = { - "metric": divergence_metric, - "matrix": matrix, - "max_pair": list(max_pair) if max_pair[2] > 0 else None, - } - - return result - +from picarones.measurements.inter_engine import * # noqa: F401, F403 -__all__ = [ - "kl_divergence", - "jensen_shannon_divergence", - "taxonomy_divergence_matrix", - "oracle_token_recall", - "complementarity_gap", - "pairwise_disagreement_rate", - "compute_inter_engine_analysis", -] +import picarones.measurements.inter_engine as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/layout.py b/picarones/core/layout.py index 477d247e8b531c1aeafa97ee6b76ac064479904b..2b4a3a5c1924dfc38277de9d7699feaa92a53685 100644 --- a/picarones/core/layout.py +++ b/picarones/core/layout.py @@ -1,280 +1,19 @@ -"""Layout F1 par type de région — Sprint 54. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.layout`. -Sprint 54 — A.II.2.2 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.layout import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur -sépare-t-il bien le texte principal de la glose ? »*. Le score de -structure global de Picarones (Sprint 5) agrège fusion/fragmentation -de lignes en un seul nombre — utile mais non typé. Ce module -discrimine par **type de région** ALTO/PAGE (``TextRegion``, -``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en -appliquant le pattern ICDAR layout standard : - -- **TP** : région GT et région hypothèse de **même type** avec - chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant), -- **FN** : région GT non matchée, -- **FP** : région hypothèse non matchée, -- F1 calculé global et par type. - -Le pattern d'alignement est le même que pour le NER (Sprint 38) — on -réutilise une approche éprouvée plutôt que d'en inventer une nouvelle. - -Stratégie de découpage ----------------------- -Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1 -(Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit -deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un -parser amont — le parser ALTO/PAGE standard de Picarones suivra -dans un sprint dédié). Pas de câblage runner ni de vue HTML ici. - -Convention de coordonnées -------------------------- -Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine -en haut à gauche, axe y vers le bas — convention ALTO et PAGE -standard). L'IoU est calculée sur l'aire d'intersection / union des -rectangles. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Iterable - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle de données -# ────────────────────────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class Region: - """Une région ALTO/PAGE alignable sur sa GT. - - Attributs - --------- - id: - Identifiant unique au sein de la séquence (ex. ``"r_1"``, - ``"region_main"``). Informatif — l'alignement se fait par IoU, - pas par ID. - type: - Catégorie de la région (``"TextRegion"``, ``"MarginNote"``, - ``"Header"``, etc.). Comparaison **case-insensitive**. - bbox: - Rectangle ``(x, y, width, height)`` en pixels, origine en haut - à gauche. Doit avoir width > 0 et height > 0. - """ - - id: str - type: str - bbox: tuple[int, int, int, int] - - def __post_init__(self) -> None: - x, y, w, h = self.bbox - if w <= 0 or h <= 0: - raise ValueError( - f"Region {self.id!r} : bbox invalide (w={w}, h={h}). " - "width et height doivent être strictement positifs." - ) - - @property - def area(self) -> int: - _, _, w, h = self.bbox - return w * h - - -def _to_region(obj: Region | dict) -> Region: - """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``).""" - if isinstance(obj, Region): - return obj - return Region( - id=str(obj["id"]), - type=str(obj["type"]), - bbox=tuple(obj["bbox"]), # type: ignore[arg-type] - ) - - -# ────────────────────────────────────────────────────────────────────────── -# IoU + alignement greedy -# ────────────────────────────────────────────────────────────────────────── - - -def _iou_bbox(a: Region, b: Region) -> float: - """Intersection-over-Union de deux bboxes ``(x, y, w, h)``.""" - ax, ay, aw, ah = a.bbox - bx, by, bw, bh = b.bbox - inter_x = max(ax, bx) - inter_y = max(ay, by) - inter_x_end = min(ax + aw, bx + bw) - inter_y_end = min(ay + ah, by + bh) - inter_w = max(0, inter_x_end - inter_x) - inter_h = max(0, inter_y_end - inter_y) - inter = inter_w * inter_h - if inter == 0: - return 0.0 - union = a.area + b.area - inter - if union <= 0: - return 0.0 - return inter / union - - -def _align_regions( - references: list[Region], - hypotheses: list[Region], - iou_threshold: float, -) -> tuple[list[tuple[int, int, float]], set[int], set[int]]: - """Appareillage greedy par IoU décroissant ; same type requis. - - Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` — - ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``. - """ - candidates: list[tuple[float, int, int]] = [] - for i, r in enumerate(references): - for j, h in enumerate(hypotheses): - if r.type.casefold() != h.type.casefold(): - continue - iou = _iou_bbox(r, h) - if iou >= iou_threshold: - candidates.append((iou, i, j)) - - # Tri stable : IoU décroissant, puis indices croissants pour - # déterminisme sur égalités. - candidates.sort(key=lambda t: (-t[0], t[1], t[2])) - - matched_refs: set[int] = set() - matched_hyps: set[int] = set() - matches: list[tuple[int, int, float]] = [] - for iou, i, j in candidates: - if i in matched_refs or j in matched_hyps: - continue - matched_refs.add(i) - matched_hyps.add(j) - matches.append((i, j, iou)) - - unmatched_refs = set(range(len(references))) - matched_refs - unmatched_hyps = set(range(len(hypotheses))) - matched_hyps - return matches, unmatched_refs, unmatched_hyps - - -# ────────────────────────────────────────────────────────────────────────── -# Métrique principale -# ────────────────────────────────────────────────────────────────────────── - - -def _prf(tp: int, fp: int, fn: int) -> dict[str, float]: - p = tp / (tp + fp) if (tp + fp) > 0 else 0.0 - r = tp / (tp + fn) if (tp + fn) > 0 else 0.0 - f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0 - return {"precision": p, "recall": r, "f1": f1, "support": tp + fn} - - -def compute_layout_metrics( - reference_regions: Iterable[Region | dict] | None, - hypothesis_regions: Iterable[Region | dict] | None, - iou_threshold: float = 0.5, -) -> dict: - """Calcule precision/recall/F1 sur le layout par type de région. - - Parameters - ---------- - reference_regions: - Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``). - hypothesis_regions: - Liste de régions produites par le moteur OCR/HTR ou un - layout-detector. - iou_threshold: - Seuil de chevauchement minimal pour déclarer un appariement - (défaut : 0,5 — convention ICDAR). - - Returns - ------- - dict - ``{ - "global": {"precision", "recall", "f1", "support"}, - "per_type": {type_name: {"precision", ...}}, - "true_positives": int, - "false_positives": int, - "false_negatives": int, - "missed_regions": list[dict], # GT non matchées - "hallucinated_regions": list[dict], # hyp non matchées - "iou_threshold": float, - }`` - - Cas dégénérés - ------------- - - Deux listes vides → F1 = 0 et tous compteurs à 0. - - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP). - - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN). - """ - refs = [_to_region(r) for r in (reference_regions or [])] - hyps = [_to_region(h) for h in (hypothesis_regions or [])] - - matches, unmatched_refs, unmatched_hyps = _align_regions( - refs, hyps, iou_threshold, - ) - - tp = len(matches) - fn = len(unmatched_refs) - fp = len(unmatched_hyps) - - cat_tp: dict[str, int] = {} - cat_fn: dict[str, int] = {} - cat_fp: dict[str, int] = {} - for i, _j, _iou in matches: - cat = refs[i].type - cat_tp[cat] = cat_tp.get(cat, 0) + 1 - for i in unmatched_refs: - cat = refs[i].type - cat_fn[cat] = cat_fn.get(cat, 0) + 1 - for j in unmatched_hyps: - cat = hyps[j].type - cat_fp[cat] = cat_fp.get(cat, 0) + 1 - - all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp)) - per_type = { - cat: _prf( - cat_tp.get(cat, 0), - cat_fp.get(cat, 0), - cat_fn.get(cat, 0), - ) - for cat in all_categories - } - - return { - "global": _prf(tp, fp, fn), - "per_type": per_type, - "true_positives": tp, - "false_positives": fp, - "false_negatives": fn, - "missed_regions": [ - {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)} - for i in sorted(unmatched_refs) - ], - "hallucinated_regions": [ - {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)} - for j in sorted(unmatched_hyps) - ], - "iou_threshold": iou_threshold, - } - - -def layout_f1( - reference_regions: Iterable[Region | dict] | None, - hypothesis_regions: Iterable[Region | dict] | None, - iou_threshold: float = 0.5, -) -> float: - """Raccourci : F1 global du layout.""" - return compute_layout_metrics( - reference_regions, hypothesis_regions, iou_threshold, - )["global"]["f1"] - +from picarones.measurements.layout import * # noqa: F401, F403 -__all__ = [ - "Region", - "compute_layout_metrics", - "layout_f1", -] +import picarones.measurements.layout as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/levers.py b/picarones/core/levers.py index 47ba0ab9d665f6eb35d0572fdb4c07a2d7b4ea44..7706d6fd69631dd71d914e4ed4eab32ce347f239 100644 --- a/picarones/core/levers.py +++ b/picarones/core/levers.py @@ -1,561 +1,19 @@ -"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.levers`. -Sprint 82 — A.I.9 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.levers import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce -qui s'est passé** dans le benchmark : qui gagne, qui s'effondre, -qui est fragile. Ce sprint répond à une question -complémentaire : **sur quelle dimension le bénéfice attendu d'une -amélioration serait-il le plus visible ?** - -Pas de prescription -------------------- -Picarones est un **outil de recherche**, pas un atelier de -production. Le module ne dit jamais *« faites X »* ni -*« utilisez le moteur Y »* ; il agrège des **observations -factuelles** déjà calculées dans d'autres modules (Sprints 75-81) -et les présente comme un récapitulatif compact en bas du rapport. -Le chercheur lit, juge et arbitre. - -Exemples de leviers émis ------------------------- -- *« 65 % des erreurs de Tesseract sont de classe récupérable - (case_error, ligature_error, abbreviation_error) — un - post-processing trivial absorberait une partie. »* -- *« 12 % de vos documents concentrent 78 % du CER total - (Pareto-CER). »* -- *« Le déficit projeté du moteur le plus fragile sur le corpus - réel est de 4,2 points de CER (Sprint 81). »* -- *« Le top-3 des tokens GT systématiquement modernisés est - maistre, nostre, veoir (Sprint 80). »* - -Structure ---------- -Module parallèle au registre narratif Sprint 19 : `Lever` est la -dataclass équivalente à `Fact`, `LeverImportance` reprend la -sémantique de `FactImportance`, `@register_lever` indexe les -détecteurs. Garde-fou anti-hallucination identique : chaque -nombre rendu doit être présent dans le `payload` du `Lever`. - -Les détecteurs lisent **uniquement** des structures déjà -construites par le pipeline du benchmark — ils ne calculent rien -de nouveau, ils synthétisent. C'est pourquoi le module est -résolument optionnel : si un benchmark n'expose pas -`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`, -`lexical_modernization` ou `robustness_projection`, le détecteur -correspondant retourne tout simplement `[]`. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import threading -from dataclasses import dataclass -from enum import Enum -from typing import Callable - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle -# ────────────────────────────────────────────────────────────────────────── - - -class LeverType(str, Enum): - """Types de leviers détectés.""" - - DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class" - """Une part importante des erreurs d'un moteur est dans des classes - catégorisées « récupérables » (Sprint 77).""" - - PARETO_CONCENTRATION = "pareto_concentration" - """Une fraction minoritaire de documents concentre une fraction - majoritaire du CER total — l'inspection ciblée est rentable.""" - - COMPLEMENTARITY_OBSERVATION = "complementarity_observation" - """Le `complementarity_gap` (Sprint 35) entre l'oracle et le - meilleur moteur seul est non négligeable — observation factuelle, - aucune recommandation d'ensemble.""" - - LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation" - """Top-N des tokens GT systématiquement modernisés (Sprint 80).""" - - ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation" - """Déficit projeté global le plus important pour un moteur sur - le corpus réel (Sprint 81).""" - - -class LeverImportance(int, Enum): - """Importance éditoriale d'un levier.""" - - HIGH = 70 - MEDIUM = 40 - LOW = 10 - - -@dataclass -class Lever: - """Observation factuelle synthétisable en encart « Leviers ». - - Attributes - ---------- - type: - Le type de levier (voir `LeverType`). - importance: - Score qui décide l'ordre d'affichage. - payload: - Données brutes — **tout chiffre rendu dans le HTML doit - provenir d'ici**, jamais d'un calcul du renderer. - engines_involved: - Noms des moteurs concernés (peut être vide pour un levier - corpus-wide). - """ - - type: LeverType - importance: LeverImportance - payload: dict - engines_involved: tuple[str, ...] = () - - def as_dict(self) -> dict: - return { - "type": self.type.value, - "importance": int(self.importance), - "payload": self.payload, - "engines_involved": list(self.engines_involved), - } - - -# ────────────────────────────────────────────────────────────────────────── -# Registre -# ────────────────────────────────────────────────────────────────────────── - - -LeverDetectorFn = Callable[[dict], list[Lever]] - - -@dataclass(frozen=True) -class LeverDetectorEntry: - lever_type: LeverType - fn: LeverDetectorFn - priority: int - - -_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {} -_LEVER_REGISTRY_LOCK = threading.Lock() - - -def register_lever( - lever_type: LeverType, - *, - priority: int, -) -> Callable[[LeverDetectorFn], LeverDetectorFn]: - """Décorateur : enregistre un détecteur de levier. - - Une seule fonction par type — réenregistrer lève `ValueError`. - """ - def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn: - with _LEVER_REGISTRY_LOCK: - if lever_type in _LEVER_REGISTRY: - raise ValueError( - f"Détecteur déjà enregistré pour {lever_type.value!r} : " - f"{_LEVER_REGISTRY[lever_type].fn.__name__}." - ) - _LEVER_REGISTRY[lever_type] = LeverDetectorEntry( - lever_type=lever_type, fn=fn, priority=int(priority), - ) - return fn - return _decorator - - -def unregister_lever(lever_type: LeverType) -> None: - with _LEVER_REGISTRY_LOCK: - _LEVER_REGISTRY.pop(lever_type, None) - - -def iter_lever_detectors() -> list[LeverDetectorEntry]: - with _LEVER_REGISTRY_LOCK: - entries = list(_LEVER_REGISTRY.values()) - entries.sort(key=lambda e: e.priority) - return entries - - -def detect_levers(benchmark_data: dict) -> list[Lever]: - """Applique tous les détecteurs enregistrés et trie par importance - décroissante puis priorité d'enregistrement croissante.""" - levers: list[Lever] = [] - for entry in iter_lever_detectors(): - try: - result = entry.fn(benchmark_data) - except Exception as e: - logger.warning( - "[levers.detector.%s] fonctionnalité dégradée : %s", - entry.lever_type.value, e, - ) - continue - if result: - levers.extend(result) - # Tri stable : importance décroissante d'abord - levers.sort(key=lambda lv: -int(lv.importance)) - return levers - - -# ────────────────────────────────────────────────────────────────────────── -# Détecteurs -# ────────────────────────────────────────────────────────────────────────── - - -# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py). -# Volontairement dupliquée ici pour ne pas introduire d'import -# circulaire — la sémantique est gelée. -_RECOVERABILITY: dict[str, str] = { - "case_error": "recoverable", - "ligature_error": "recoverable", - "abbreviation_error": "recoverable", - "diacritic_error": "difficult", - "visual_confusion": "difficult", - "hapax": "difficult", - "lacuna": "irrecoverable", - "oov_character": "irrecoverable", - "segmentation_error": "irrecoverable", -} - - -@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10) -def detect_dominant_recoverable_class( - benchmark_data: dict, - *, - threshold: float = 0.30, -) -> list[Lever]: - """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont - classifiées récupérables (catégorisation Sprint 77). - - Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` — - structure produite par le runner historique. Si absent, retourne - []. - """ - engines = benchmark_data.get("engines") or [] - out: list[Lever] = [] - for engine in engines: - taxonomy = engine.get("aggregated_taxonomy") - if not taxonomy: - continue - # `taxonomy` peut être {class_name: int} ou un dict avec une - # sous-clé "counts" — on accepte les deux conventions. - counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy - if not isinstance(counts, dict) or not counts: - continue - try: - int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))} - except (TypeError, ValueError): - continue - total = sum(int_counts.values()) - if total <= 0: - continue - recoverable_total = sum( - v for k, v in int_counts.items() - if _RECOVERABILITY.get(k) == "recoverable" - ) - share = recoverable_total / total - if share < threshold: - continue - # Classes récupérables non vides triées par count décroissant - breakdown = sorted( - ( - (k, v) for k, v in int_counts.items() - if _RECOVERABILITY.get(k) == "recoverable" and v > 0 - ), - key=lambda kv: -kv[1], - ) - importance = ( - LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM - ) - out.append(Lever( - type=LeverType.DOMINANT_RECOVERABLE_CLASS, - importance=importance, - payload={ - "engine": engine.get("name") or "?", - "share_recoverable": share, - "share_recoverable_pct": round(share * 100, 1), - "n_recoverable": recoverable_total, - "n_total_errors": total, - "top_classes": [ - {"class": k, "count": v} for k, v in breakdown[:3] - ], - }, - engines_involved=(engine.get("name") or "?",), - )) - return out - - -@register_lever(LeverType.PARETO_CONCENTRATION, priority=20) -def detect_pareto_concentration( - benchmark_data: dict, - *, - top_share: float = 0.20, - cer_share_threshold: float = 0.50, -) -> list[Lever]: - """Émet un levier si une fraction minoritaire de documents - (`top_share`) concentre plus de `cer_share_threshold` du CER - total cumulé sur le moteur leader. - - Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de - reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`. - Si rien d'exploitable, retourne []. - """ - ranking = benchmark_data.get("ranking") or [] - if not ranking: - return [] - leader = ranking[0] - leader_name = leader.get("engine") - if not leader_name: - return [] - - per_doc_cer: list[float] = [] - # Voie 1 : structure plate "per_doc_cer" - flat = benchmark_data.get("per_doc_cer") or {} - if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list): - per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))] - else: - # Voie 2 : engine.per_doc liste de dicts {cer: float} - for engine in benchmark_data.get("engines") or []: - if engine.get("name") != leader_name: - continue - per_doc = engine.get("per_doc") or [] - for entry in per_doc: - if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)): - per_doc_cer.append(float(entry["cer"])) - break - - if not per_doc_cer: - return [] - total_cer = sum(per_doc_cer) - if total_cer <= 0: - return [] - - sorted_cer = sorted(per_doc_cer, reverse=True) - n = len(sorted_cer) - n_top = max(1, int(round(top_share * n))) - top_cer_sum = sum(sorted_cer[:n_top]) - share_of_total = top_cer_sum / total_cer - if share_of_total < cer_share_threshold: - return [] - importance = ( - LeverImportance.HIGH if share_of_total >= 0.75 - else LeverImportance.MEDIUM - ) - return [Lever( - type=LeverType.PARETO_CONCENTRATION, - importance=importance, - payload={ - "engine": leader_name, - "n_docs": n, - "n_docs_top": n_top, - "top_share_pct": round((n_top / n) * 100, 1), - "cer_share_of_total": share_of_total, - "cer_share_pct": round(share_of_total * 100, 1), - }, - engines_involved=(leader_name,), - )] - - -@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30) -def detect_complementarity_observation( - benchmark_data: dict, - *, - min_relative_gap: float = 0.20, -) -> list[Lever]: - """Reformule factuellement le `complementarity_gap` (Sprint 35). - - Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne - déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune - recommandation d'ensemble** — le levier dit factuellement - « X points séparent l'oracle du meilleur moteur », c'est tout. - """ - inter = benchmark_data.get("inter_engine_analysis") or {} - cgap = inter.get("complementarity_gap") or {} - relative_gap = cgap.get("relative_gap") - absolute_gap = cgap.get("absolute_gap") - if relative_gap is None or absolute_gap is None: - return [] - try: - rg = float(relative_gap) - ag = float(absolute_gap) - except (TypeError, ValueError): - return [] - if rg < min_relative_gap: - return [] - importance = ( - LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM - ) - payload: dict = { - "absolute_gap": ag, - "absolute_gap_pct": round(ag * 100, 1), - "relative_gap": rg, - "relative_gap_pct": round(rg * 100, 1), - } - best_engine = cgap.get("best_engine") or inter.get("best_engine") - best_recall = cgap.get("best_recall") or inter.get("best_engine_recall") - oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall") - engines_involved: tuple[str, ...] = () - if best_engine: - payload["best_engine"] = str(best_engine) - engines_involved = (str(best_engine),) - if isinstance(best_recall, (int, float)): - payload["best_recall"] = float(best_recall) - if isinstance(oracle_recall, (int, float)): - payload["oracle_recall"] = float(oracle_recall) - return [Lever( - type=LeverType.COMPLEMENTARITY_OBSERVATION, - importance=importance, - payload=payload, - engines_involved=engines_involved, - )] - - -@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40) -def detect_lexical_modernization_observation( - benchmark_data: dict, - *, - top_n: int = 3, - min_total: int = 3, - min_rate: float = 0.50, -) -> list[Lever]: - """Pour chaque moteur disposant de `lexical_modernization`, - émet un levier listant les `top_n` tokens GT les plus modernisés. - - Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui - suit la forme produite par `compute_lexical_modernization` du - Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`). - """ - out: list[Lever] = [] - for engine in benchmark_data.get("engines") or []: - data = engine.get("lexical_modernization") - if not isinstance(data, dict): - continue - tokens = data.get("tokens") or {} - if not isinstance(tokens, dict) or not tokens: - continue - candidates: list[tuple[str, dict]] = [] - for gt_token, slot in tokens.items(): - if not isinstance(slot, dict): - continue - n_total = slot.get("n_total") - rate = slot.get("rate_modernized") - if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)): - continue - if int(n_total) < min_total: - continue - if float(rate) < min_rate: - continue - candidates.append((gt_token, dict(slot))) - if not candidates: - continue - candidates.sort( - key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)), - -int(kv[1].get("n_total", 0)), - kv[0]), - ) - top = candidates[:top_n] - engine_name = engine.get("name") or "?" - max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top) - importance = ( - LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM - ) - out.append(Lever( - type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION, - importance=importance, - payload={ - "engine": engine_name, - "top_tokens": [ - { - "gt_token": gt, - "n_total": int(slot.get("n_total", 0)), - "rate_modernized": float(slot.get("rate_modernized", 0.0)), - "rate_modernized_pct": round( - float(slot.get("rate_modernized", 0.0)) * 100, 1, - ), - } - for gt, slot in top - ], - }, - engines_involved=(engine_name,), - )) - return out - - -@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50) -def detect_robustness_projection_observation( - benchmark_data: dict, - *, - min_total_deficit: float = 0.02, -) -> list[Lever]: - """Lit l'agrégation par moteur de la projection de robustesse - (Sprint 81). Émet le levier pour le moteur dont - `total_expected_deficit` est ≥ `min_total_deficit` (par défaut - 2 points de CER). - - Lit `benchmark_data["robustness_projection_aggregated"]` — - structure produite par `aggregate_projection_per_engine`. - """ - agg = benchmark_data.get("robustness_projection_aggregated") or {} - if not isinstance(agg, dict) or not agg: - return [] - out: list[Lever] = [] - for engine_name, info in agg.items(): - if not isinstance(info, dict): - continue - total_deficit = info.get("total_expected_deficit") - worst_type = info.get("worst_degradation_type") - worst_deficit = info.get("worst_degradation_deficit") - if not isinstance(total_deficit, (int, float)): - continue - if float(total_deficit) < min_total_deficit: - continue - importance = ( - LeverImportance.HIGH if float(total_deficit) >= 0.05 - else LeverImportance.MEDIUM - ) - payload: dict = { - "engine": engine_name, - "total_expected_deficit": float(total_deficit), - "total_expected_deficit_pct": round(float(total_deficit) * 100, 1), - "n_degradation_types": int(info.get("n_degradation_types") or 0), - } - if isinstance(worst_type, str): - payload["worst_degradation_type"] = worst_type - if isinstance(worst_deficit, (int, float)): - payload["worst_degradation_deficit"] = float(worst_deficit) - payload["worst_degradation_deficit_pct"] = round( - float(worst_deficit) * 100, 1, - ) - out.append(Lever( - type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, - importance=importance, - payload=payload, - engines_involved=(engine_name,), - )) - # Tri par déficit décroissant pour stabilité d'affichage. - out.sort( - key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0), - ) - return out - +from picarones.measurements.levers import * # noqa: F401, F403 -__all__ = [ - "Lever", - "LeverImportance", - "LeverType", - "LeverDetectorEntry", - "register_lever", - "unregister_lever", - "iter_lever_detectors", - "detect_levers", - "detect_dominant_recoverable_class", - "detect_pareto_concentration", - "detect_complementarity_observation", - "detect_lexical_modernization_observation", - "detect_robustness_projection_observation", -] +import picarones.measurements.levers as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/line_metrics.py b/picarones/core/line_metrics.py index 5204decce03afa16ce9d4fc93e8bbb973d77f475..4de862ecaaacf2d7dd1b41eeacb74a6183432b88 100644 --- a/picarones/core/line_metrics.py +++ b/picarones/core/line_metrics.py @@ -1,286 +1,19 @@ -"""Distribution des erreurs CER par ligne — Sprint 10. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.line_metrics`. -Métriques calculées -------------------- -- CER par ligne : distance d'édition caractère/longueur GT sur chaque paire de lignes -- Percentiles : p50, p75, p90, p95, p99 sur la distribution des CER ligne -- Taux catastrophiques : % de lignes dépassant des seuils configurables (30 %, 50 %, 100 %) -- Coefficient de Gini : concentration des erreurs (0 = uniformes, 1 = toutes concentrées) -- Carte thermique : CER moyen par tranche de position dans le document -""" - -from __future__ import annotations - -import unicodedata -from dataclasses import dataclass -from typing import Optional - - -# --------------------------------------------------------------------------- -# CER d'une paire de lignes (distance d'édition Levenshtein normalisée) -# --------------------------------------------------------------------------- - -def _edit_distance(a: str, b: str) -> int: - """Distance de Levenshtein entre deux chaînes.""" - if not a: - return len(b) - if not b: - return len(a) - prev = list(range(len(b) + 1)) - for i, ca in enumerate(a, 1): - curr = [i] - for j, cb in enumerate(b, 1): - cost = 0 if ca == cb else 1 - curr.append(min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)) - prev = curr - return prev[-1] - - -def _line_cer(ref_line: str, hyp_line: str) -> float: - """CER pour une paire de lignes. Retourne 1.0 si le GT est vide et que l'hyp ne l'est pas.""" - ref = unicodedata.normalize("NFC", ref_line.strip()) - hyp = unicodedata.normalize("NFC", hyp_line.strip()) - if not ref: - return 0.0 if not hyp else 1.0 - dist = _edit_distance(ref, hyp) - return dist / len(ref) - - -# --------------------------------------------------------------------------- -# Percentiles (implémentation pur-Python, sans numpy) -# --------------------------------------------------------------------------- - -def _percentile(sorted_values: list[float], p: float) -> float: - """Retourne le p-ième percentile (0 ≤ p ≤ 100) d'une liste triée.""" - if not sorted_values: - return 0.0 - n = len(sorted_values) - index = p / 100 * (n - 1) - lo = int(index) - hi = min(lo + 1, n - 1) - frac = index - lo - return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo]) - - -# --------------------------------------------------------------------------- -# Coefficient de Gini -# --------------------------------------------------------------------------- - -def _gini(values: list[float]) -> float: - """Coefficient de Gini des erreurs (0 = uniformes, 1 = toutes concentrées). - - Formule : G = (2 * Σ i*x_i) / (n * Σ x_i) - (n+1)/n - sur les valeurs triées par ordre croissant. - """ - if not values: - return 0.0 - xs = sorted(max(v, 0.0) for v in values) - n = len(xs) - total = sum(xs) - if total == 0.0: - return 0.0 - weighted_sum = sum((i + 1) * x for i, x in enumerate(xs)) - return (2.0 * weighted_sum) / (n * total) - (n + 1) / n - - -# --------------------------------------------------------------------------- -# Résultat structuré -# --------------------------------------------------------------------------- - -@dataclass -class LineMetrics: - """Distribution des erreurs CER par ligne pour une paire (GT, hypothèse).""" - - cer_per_line: list[float] - """CER de chaque ligne (longueur = nombre de lignes GT).""" - - percentiles: dict[str, float] - """Percentiles : p50, p75, p90, p95, p99.""" - - catastrophic_rate: dict[str, float] - """Taux de lignes catastrophiques pour chaque seuil (ex. {0.3: 0.12, 0.5: 0.07, 1.0: 0.02}).""" - - gini: float - """Coefficient de Gini des erreurs (0 → uniforme, 1 → concentrées).""" - - heatmap: list[float] - """CER moyen par tranche de position dans le document (longueur = heatmap_bins).""" - - line_count: int - """Nombre de lignes GT traitées.""" - - mean_cer: float - """CER moyen sur l'ensemble des lignes.""" +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.line_metrics import ...``) de continuer +à fonctionner sans modification. - def as_dict(self) -> dict: - return { - "cer_per_line": [round(v, 6) for v in self.cer_per_line], - "percentiles": {k: round(v, 6) for k, v in self.percentiles.items()}, - "catastrophic_rate": {str(k): round(v, 6) for k, v in self.catastrophic_rate.items()}, - "gini": round(self.gini, 6), - "heatmap": [round(v, 6) for v in self.heatmap], - "line_count": self.line_count, - "mean_cer": round(self.mean_cer, 6), - } - - @classmethod - def from_dict(cls, d: dict) -> "LineMetrics": - return cls( - cer_per_line=d.get("cer_per_line", []), - percentiles=d.get("percentiles", {}), - catastrophic_rate={float(k): v for k, v in d.get("catastrophic_rate", {}).items()}, - gini=d.get("gini", 0.0), - heatmap=d.get("heatmap", []), - line_count=d.get("line_count", 0), - mean_cer=d.get("mean_cer", 0.0), - ) - - -# --------------------------------------------------------------------------- -# Calcul principal -# --------------------------------------------------------------------------- - -def compute_line_metrics( - reference: str, - hypothesis: str, - thresholds: Optional[list[float]] = None, - heatmap_bins: int = 10, -) -> LineMetrics: - """Calcule la distribution des erreurs CER ligne par ligne. - - Parameters - ---------- - reference: - Texte de vérité terrain (GT) avec sauts de ligne. - hypothesis: - Texte produit par le moteur OCR. - thresholds: - Seuils CER pour le taux catastrophique. Défaut : [0.30, 0.50, 1.00]. - heatmap_bins: - Nombre de tranches de position pour la carte thermique. - - Returns - ------- - LineMetrics - """ - if thresholds is None: - thresholds = [0.30, 0.50, 1.00] - - ref_lines = reference.splitlines() - hyp_lines = hypothesis.splitlines() - - # Aligner les lignes GT / hypothèse — on prend au moins autant de lignes que le GT - n = len(ref_lines) - if n == 0: - # Pas de lignes : retourner des métriques neutres - return LineMetrics( - cer_per_line=[], - percentiles={f"p{p}": 0.0 for p in (50, 75, 90, 95, 99)}, - catastrophic_rate={t: 0.0 for t in thresholds}, - gini=0.0, - heatmap=[0.0] * heatmap_bins, - line_count=0, - mean_cer=0.0, - ) - - # Aligner en ignorant les lignes d'hypothèse supplémentaires - # Si l'hypothèse a moins de lignes, les lignes manquantes comptent comme supprimées (CER = 1.0) - cer_per_line: list[float] = [] - for i, ref_line in enumerate(ref_lines): - hyp_line = hyp_lines[i] if i < len(hyp_lines) else "" - cer_per_line.append(min(_line_cer(ref_line, hyp_line), 1.0)) - - sorted_cer = sorted(cer_per_line) - - # Percentiles - percentiles = { - f"p{p}": _percentile(sorted_cer, p) - for p in (50, 75, 90, 95, 99) - } - - # Taux catastrophiques - catastrophic_rate: dict[float, float] = {} - for t in thresholds: - count = sum(1 for v in cer_per_line if v > t) - catastrophic_rate[t] = count / n - - # Gini - gini = _gini(cer_per_line) - - # Carte thermique par tranche de position - bins = heatmap_bins - heatmap: list[float] = [] - for b in range(bins): - start = int(b * n / bins) - end = int((b + 1) * n / bins) - slice_ = cer_per_line[start:end] - heatmap.append(sum(slice_) / len(slice_) if slice_ else 0.0) - - mean_cer = sum(cer_per_line) / n - - return LineMetrics( - cer_per_line=cer_per_line, - percentiles=percentiles, - catastrophic_rate=catastrophic_rate, - gini=gini, - heatmap=heatmap, - line_count=n, - mean_cer=mean_cer, - ) - - -# --------------------------------------------------------------------------- -# Agrégation sur un corpus -# --------------------------------------------------------------------------- - -def aggregate_line_metrics(results: list[LineMetrics]) -> dict: - """Agrège les métriques de distribution par ligne sur un corpus. - - Returns - ------- - dict - Statistiques agrégées : Gini moyen, percentiles moyens, taux catastrophiques moyens. - """ - if not results: - return {} - - import statistics as _stats - - gini_values = [r.gini for r in results] - mean_cer_values = [r.mean_cer for r in results] - - # Percentiles moyens - pct_keys = ["p50", "p75", "p90", "p95", "p99"] - avg_percentiles = {} - for k in pct_keys: - vals = [r.percentiles.get(k, 0.0) for r in results] - avg_percentiles[k] = round(sum(vals) / len(vals), 6) if vals else 0.0 - - # Taux catastrophiques moyens (union des seuils) - all_thresholds: set[float] = set() - for r in results: - all_thresholds.update(r.catastrophic_rate.keys()) - avg_catastrophic: dict[str, float] = {} - for t in sorted(all_thresholds): - vals = [r.catastrophic_rate.get(t, 0.0) for r in results] - avg_catastrophic[str(t)] = round(sum(vals) / len(vals), 6) if vals else 0.0 +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). +""" - # Heatmap moyenne (longueur = max des longueurs) - if results and results[0].heatmap: - n_bins = len(results[0].heatmap) - heatmap_avg = [] - for b in range(n_bins): - vals = [r.heatmap[b] for r in results if b < len(r.heatmap)] - heatmap_avg.append(round(sum(vals) / len(vals), 6) if vals else 0.0) - else: - heatmap_avg = [] +from picarones.measurements.line_metrics import * # noqa: F401, F403 - return { - "gini_mean": round(sum(gini_values) / len(gini_values), 6), - "gini_stdev": round(_stats.stdev(gini_values), 6) if len(gini_values) > 1 else 0.0, - "mean_cer_mean": round(sum(mean_cer_values) / len(mean_cer_values), 6), - "percentiles": avg_percentiles, - "catastrophic_rate": avg_catastrophic, - "heatmap": heatmap_avg, - "document_count": len(results), - } +import picarones.measurements.line_metrics as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/longitudinal.py b/picarones/core/longitudinal.py index 26fe91c4530a99793c87e35fef81ffb5716df174..edf827cf317be40df36e072daf6952a9a12d97e2 100644 --- a/picarones/core/longitudinal.py +++ b/picarones/core/longitudinal.py @@ -1,373 +1,19 @@ -"""Métriques longitudinales — Sprint 92 (A.II.9). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.longitudinal`. -Sprint 92 — A.II.9 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.longitudinal import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -L'historique SQLite (`core/history.py`, Sprint 8) collecte les -résultats de chaque run de benchmark, mais aucune métrique -n'en sortait dans le rapport. Ce module exploite la série -temporelle des CER d'un moteur pour répondre à deux -questions : - -1. **Y a-t-il une tendance ?** Régression linéaire simple - (méthode des moindres carrés) sur ``(t, CER)`` — pente, - ordonnée à l'origine, R², n_runs. Une pente > 0 signale - une régression progressive ; une pente < 0 une amélioration. - -2. **Y a-t-il un point de rupture ?** Algorithme de - change-point pur Python (différence de moyennes maximale, - variante de Pettitt simplifiée). Identifie l'index où la - série se sépare en deux segments avec moyennes les plus - différentes — typiquement le run où un modèle a changé de - comportement. - -Pas de scipy ------------- -Pour rester sans dépendance lourde, on implémente : -- la régression linéaire en pur Python (closed-form OLS) ; -- le change-point par balayage exhaustif (O(N) pour de petits - N — l'historique d'une institution dépasse rarement quelques - centaines de runs). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import math -import statistics -from dataclasses import dataclass -from datetime import datetime -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class LinearTrend: - """Résultat d'une régression linéaire sur une série CER.""" - slope: float - """Pente (CER par jour). Positif = régression.""" - intercept: float - """Ordonnée à l'origine.""" - r_squared: float - """Qualité de l'ajustement, ∈ [0, 1].""" - n_runs: int - """Nombre de points utilisés.""" - - def as_dict(self) -> dict: - return { - "slope": self.slope, - "intercept": self.intercept, - "r_squared": self.r_squared, - "n_runs": self.n_runs, - } - - -@dataclass -class ChangePointResult: - """Résultat d'une détection de point de rupture.""" - index: int - """Index de la rupture (0-based, le segment 1 est [0:index], - le segment 2 est [index:N]).""" - timestamp: str - """Timestamp du run à la rupture.""" - mean_before: float - mean_after: float - delta: float - """``mean_after - mean_before``. Positif = régression.""" - n_before: int - n_after: int - - def as_dict(self) -> dict: - return { - "index": self.index, - "timestamp": self.timestamp, - "mean_before": self.mean_before, - "mean_after": self.mean_after, - "delta": self.delta, - "n_before": self.n_before, - "n_after": self.n_after, - } - - -def _parse_timestamp(ts: str) -> Optional[float]: - """Parse un ISO timestamp en jour ordinal float. - - Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne - ``None`` si non parsable. - """ - if not ts: - return None - formats = ( - "%Y-%m-%dT%H:%M:%S.%f", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%d", - ) - for fmt in formats: - try: - dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt) - return dt.toordinal() + ( - dt.hour * 3600 + dt.minute * 60 + dt.second - ) / 86400.0 - except ValueError: - continue - return None - - -def compute_linear_trend( - cer_series: Iterable[tuple[str, float]], -) -> Optional[LinearTrend]: - """Régression linéaire OLS sur une série temporelle de CER. - - Parameters - ---------- - cer_series: - Itérable de ``(timestamp_iso, cer)``. Au moins 2 points - valides requis. - - Returns - ------- - LinearTrend | None - ``None`` si moins de 2 points ou si tous les timestamps - sont identiques (variance nulle sur t). - """ - points: list[tuple[float, float]] = [] - for ts, cer in cer_series: - t = _parse_timestamp(ts) - if t is None or cer is None: - continue - try: - cer_f = float(cer) - except (TypeError, ValueError): - continue - points.append((t, cer_f)) - n = len(points) - if n < 2: - return None - xs = [p[0] for p in points] - ys = [p[1] for p in points] - x_mean = statistics.fmean(xs) - y_mean = statistics.fmean(ys) - sxx = sum((x - x_mean) ** 2 for x in xs) - sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) - if sxx == 0: - return None - slope = sxy / sxx - intercept = y_mean - slope * x_mean - syy = sum((y - y_mean) ** 2 for y in ys) - if syy == 0: - # Tous les CER sont égaux → R² mathématiquement indéfini ; - # on retourne 1.0 (parfaite "non-tendance"). - r_squared = 1.0 - else: - ss_res = sum( - (y - (slope * x + intercept)) ** 2 - for x, y in zip(xs, ys) - ) - r_squared = max(0.0, 1.0 - ss_res / syy) - return LinearTrend( - slope=slope, - intercept=intercept, - r_squared=r_squared, - n_runs=n, - ) - - -def detect_change_point( - cer_series: Iterable[tuple[str, float]], - min_segment_size: int = 3, -) -> Optional[ChangePointResult]: - """Détecte le point de rupture maximisant l'écart de moyennes. - - Algorithme : balayage des indices ``i`` où la série se - sépare en deux segments d'au moins ``min_segment_size`` - points chacun ; on retient l'index où ``|mean_after - - mean_before|`` est maximal. Variante simplifiée de Pettitt. - - Parameters - ---------- - cer_series: - Itérable de ``(timestamp_iso, cer)``. - min_segment_size: - Taille minimale des deux segments. Défaut 3. - - Returns - ------- - ChangePointResult | None - ``None`` si la série a moins de ``2 × min_segment_size`` - points valides. - """ - points: list[tuple[str, float, float]] = [] - for ts, cer in cer_series: - t = _parse_timestamp(ts) - if t is None or cer is None: - continue - try: - cer_f = float(cer) - except (TypeError, ValueError): - continue - points.append((ts, t, cer_f)) - if len(points) < 2 * min_segment_size: - return None - points.sort(key=lambda p: p[1]) - n = len(points) - best_index = -1 - best_abs_delta = -1.0 - best_delta = 0.0 - best_mean_before = 0.0 - best_mean_after = 0.0 - for i in range(min_segment_size, n - min_segment_size + 1): - before = [p[2] for p in points[:i]] - after = [p[2] for p in points[i:]] - mean_b = statistics.fmean(before) - mean_a = statistics.fmean(after) - delta = mean_a - mean_b - abs_delta = abs(delta) - if abs_delta > best_abs_delta: - best_abs_delta = abs_delta - best_index = i - best_delta = delta - best_mean_before = mean_b - best_mean_after = mean_a - if best_index < 0: - return None - return ChangePointResult( - index=best_index, - timestamp=points[best_index][0], - mean_before=best_mean_before, - mean_after=best_mean_after, - delta=best_delta, - n_before=best_index, - n_after=n - best_index, - ) - - -def compute_engine_longitudinal( - history_entries: Iterable, - engine_name: str, - corpus_name: Optional[str] = None, - *, - min_runs_for_trend: int = 3, - min_segment_size: int = 3, - change_point_threshold: float = 0.01, -) -> Optional[dict]: - """Calcule trend + change_point pour un moteur. - - Parameters - ---------- - history_entries: - Liste de ``HistoryEntry`` (ou dicts compatibles). - engine_name: - Filtre sur le nom du moteur. - corpus_name: - Filtre optionnel sur le corpus. ``None`` (défaut) : tous - les corpus. - min_runs_for_trend: - Minimum de runs pour calculer une tendance. - min_segment_size: - Taille minimale des segments pour le change-point. - change_point_threshold: - Magnitude absolue minimale du delta (en CER) pour - retenir le change-point. Défaut 0.01 (1 point de CER). - - Returns - ------- - dict | None - ``{ - "engine_name", "corpus_name", "n_runs", "trend", - "change_point", # ou None - "first_timestamp", "last_timestamp", - "first_cer", "last_cer", "absolute_delta_pct", - }`` ou ``None`` si moins de ``min_runs_for_trend`` runs. - """ - series: list[tuple[str, float]] = [] - for entry in history_entries: - if hasattr(entry, "as_dict"): - data = entry.as_dict() - else: - data = entry - if data.get("engine_name") != engine_name: - continue - if corpus_name is not None and data.get("corpus_name") != corpus_name: - continue - cer = data.get("cer_mean") - ts = data.get("timestamp") - if cer is None or ts is None: - continue - series.append((ts, float(cer))) - if len(series) < min_runs_for_trend: - return None - series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0) - trend = compute_linear_trend(series) - cp = detect_change_point(series, min_segment_size=min_segment_size) - if cp is not None and abs(cp.delta) < change_point_threshold: - cp = None - first_ts, first_cer = series[0] - last_ts, last_cer = series[-1] - return { - "engine_name": engine_name, - "corpus_name": corpus_name, - "n_runs": len(series), - "trend": trend.as_dict() if trend else None, - "change_point": cp.as_dict() if cp else None, - "first_timestamp": first_ts, - "last_timestamp": last_ts, - "first_cer": first_cer, - "last_cer": last_cer, - "absolute_delta": last_cer - first_cer, - "absolute_delta_pct": round((last_cer - first_cer) * 100, 2), - } - - -def compute_corpus_longitudinal( - history_entries: Iterable, - corpus_name: Optional[str] = None, - *, - min_runs_for_trend: int = 3, - min_segment_size: int = 3, - change_point_threshold: float = 0.01, -) -> list[dict]: - """Pour chaque moteur présent dans l'historique sur ``corpus_name``, - calcule trend + change_point. - - Returns - ------- - list[dict] - Une entrée par moteur (filtrée), liste vide si rien. - """ - entries = list(history_entries) - engines: set[str] = set() - for entry in entries: - data = entry.as_dict() if hasattr(entry, "as_dict") else entry - if corpus_name is not None and data.get("corpus_name") != corpus_name: - continue - name = data.get("engine_name") - if name: - engines.add(name) - out: list[dict] = [] - for engine in sorted(engines): - result = compute_engine_longitudinal( - entries, engine, corpus_name=corpus_name, - min_runs_for_trend=min_runs_for_trend, - min_segment_size=min_segment_size, - change_point_threshold=change_point_threshold, - ) - if result is not None: - out.append(result) - return out - - -__all__ = [ - "LinearTrend", - "ChangePointResult", - "compute_linear_trend", - "detect_change_point", - "compute_engine_longitudinal", - "compute_corpus_longitudinal", -] - +from picarones.measurements.longitudinal import * # noqa: F401, F403 -# Marqueur d'évitement d'import inutilisé (math) -_ = math +import picarones.measurements.longitudinal as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/marginal_cost.py b/picarones/core/marginal_cost.py index 4d1c59bf324ede3d6bf0e2fcf91c59d9dae9d0de..a02620857ff9f800f60811c67acc1064cc0fa5dc 100644 --- a/picarones/core/marginal_cost.py +++ b/picarones/core/marginal_cost.py @@ -1,142 +1,19 @@ -"""Coût marginal par erreur évitée — Sprint 91 (A.II.6 chantier 2). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.marginal_cost`. -Sprint 91 — A.II.6 chantier 2 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.marginal_cost import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -La vue Pareto (Sprint 20) trace CER vs coût mais n'arbitre pas -quel surcoût est *raisonnable* pour quelle réduction d'erreur. -Une institution avec un budget contraint a besoin d'une -réponse opérationnelle : - - *« Passer de Tesseract à Mistral OCR coûte 0,83 € par - erreur évitée — décider selon votre budget par millier - d'erreurs corrigées. »* - -Formule -------- -Pour deux moteurs A et B où B fait **moins** d'erreurs que A -(donc B est plus précis) : - -.. code:: - - coût_marginal = (coût_B − coût_A) / (errors_A − errors_B) - -- Si ``cost_B > cost_A`` et ``errors_B < errors_A`` : - ``cost_per_avoided_error > 0`` (cas standard, B coûte plus - pour moins d'erreurs). -- Si ``cost_B ≤ cost_A`` et ``errors_B < errors_A`` : - ``cost_per_avoided_error ≤ 0`` (cas idéal, B est strictement - meilleur). -- Si ``errors_B ≥ errors_A`` : non comparable dans ce sens - (B n'évite pas d'erreur), retourne ``None``. - -Sortie ------- -``compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)`` -retourne ``{cost_per_avoided_error, n_errors_avoided, -cost_delta, dominated}`` ou ``None`` si non comparable. - -``compute_marginal_cost_matrix(per_engine)`` retourne, pour -chaque paire ordonnée ``(A → B)`` où B est plus précis, le -coût marginal correspondant. Trié par coût marginal croissant -(meilleur ratio en tête). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -def compute_marginal_cost( - cost_a: float, - errors_a: float, - cost_b: float, - errors_b: float, -) -> Optional[dict]: - """Coût marginal du passage A → B (B plus précis). - - Retourne ``None`` si : - - ``errors_b >= errors_a`` (B n'évite pas d'erreur) ; - - les valeurs ne sont pas finies. - """ - try: - ca = float(cost_a) - cb = float(cost_b) - ea = float(errors_a) - eb = float(errors_b) - except (TypeError, ValueError): - return None - if ea <= eb: - # B ne fait pas mieux que A → pas de gain à mesurer. - return None - n_avoided = ea - eb - cost_delta = cb - ca - cost_per_avoided = cost_delta / n_avoided - dominated = cost_delta <= 0 # B aussi cher ou moins → cas idéal - return { - "cost_per_avoided_error": cost_per_avoided, - "n_errors_avoided": n_avoided, - "cost_delta": cost_delta, - "dominated": dominated, - } - - -def compute_marginal_cost_matrix( - per_engine: dict[str, dict], -) -> Optional[dict]: - """Pour chaque paire A → B où B fait moins d'erreurs, calcule - le coût marginal. - - Parameters - ---------- - per_engine: - Map ``{engine_name: {"cost": float, "errors": float}}``. - - Returns - ------- - dict | None - ``{ - "pairs": list[ - {"engine_a", "engine_b", "cost_per_avoided_error", - "n_errors_avoided", "cost_delta", "dominated"} - ], # triée par cost_per_avoided_error croissant - }`` - ou ``None`` si moins de 2 moteurs. - """ - if not per_engine or len(per_engine) < 2: - return None - engines = sorted(per_engine.keys()) - pairs: list[dict] = [] - for a in engines: - for b in engines: - if a == b: - continue - data_a = per_engine[a] - data_b = per_engine[b] - try: - ca = float(data_a.get("cost")) - ea = float(data_a.get("errors")) - cb = float(data_b.get("cost")) - eb = float(data_b.get("errors")) - except (TypeError, ValueError): - continue - result = compute_marginal_cost(ca, ea, cb, eb) - if result is None: - continue - entry = {"engine_a": a, "engine_b": b} - entry.update(result) - pairs.append(entry) - if not pairs: - return None - pairs.sort(key=lambda p: p["cost_per_avoided_error"]) - return {"pairs": pairs} - +from picarones.measurements.marginal_cost import * # noqa: F401, F403 -__all__ = [ - "compute_marginal_cost", - "compute_marginal_cost_matrix", -] +import picarones.measurements.marginal_cost as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/__init__.py b/picarones/core/narrative/__init__.py index 69fb6492d069f7fa24dc5fce781a3e1eddf42e9f..2b1de8881e36eb31acc32ea7c56de4e5ebaa62b0 100644 --- a/picarones/core/narrative/__init__.py +++ b/picarones/core/narrative/__init__.py @@ -1,78 +1,15 @@ -"""Moteur narratif factuel — génération de synthèse déterministe. +"""Alias rétrocompat — package déplacé dans :mod:`picarones.measurements.narrative`. -Extrait des faits saillants d'un ``BenchmarkResult`` et les rend en phrases -courtes via des templates externes YAML. Aucun LLM : chaque nombre ou nom -apparaissant dans la synthèse est traçable au JSON de résultats en entrée. - -API publique ------------- -- ``Fact``, ``FactType``, ``FactImportance`` : modèle de données -- ``DetectorRegistry`` : registre des détecteurs -- ``detect_all(data)`` : applique le registre par défaut -- ``select_facts(facts, max_facts=5)`` : arbitre de sélection -- ``render_synthesis(facts, lang="fr")`` : rend en liste de phrases -- ``build_synthesis(data, lang="fr")`` : pipeline complet (Sprint 4) +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2) vit désormais dans ``picarones.measurements.narrative``. +Cet alias maintient la rétrocompat des imports historiques : +``from picarones.core.narrative import build_synthesis``, +``from picarones.core.narrative.facts import Fact``, etc. """ -from picarones.core.narrative.facts import ( - Fact, - FactType, - FactImportance, - DetectorRegistry, - detect_all, - _DEFAULT_REGISTRY, -) -from picarones.core.narrative.arbiter import select_facts -from picarones.core.narrative.renderer import ( - render_fact, - render_synthesis, - extract_numbers, -) -from picarones.core.narrative.detectors import ( - register_default_detectors, - DETECTORS_BY_TYPE, -) - - -# Activer le registre par défaut — Sprint 4 -register_default_detectors(_DEFAULT_REGISTRY) - - -def build_synthesis( - benchmark_data: dict, - lang: str = "fr", - max_facts: int = 5, -) -> dict: - """Pipeline complet : détection → arbitre → rendu. - - Returns - ------- - dict avec : - - ``sentences`` : liste de phrases prêtes à l'affichage - - ``facts`` : liste de dicts ``Fact.as_dict()`` pour traçabilité - - ``lang`` : langue utilisée - """ - all_facts = detect_all(benchmark_data) - selected = select_facts(all_facts, max_facts=max_facts) - sentences = render_synthesis(selected, lang=lang) - return { - "sentences": sentences, - "facts": [f.as_dict() for f in selected], - "lang": lang, - } - +from picarones.measurements.narrative import * # noqa: F401, F403 -__all__ = [ - "Fact", - "FactType", - "FactImportance", - "DetectorRegistry", - "detect_all", - "select_facts", - "render_fact", - "render_synthesis", - "extract_numbers", - "build_synthesis", - "register_default_detectors", - "DETECTORS_BY_TYPE", -] +import picarones.measurements.narrative as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/arbiter.py b/picarones/core/narrative/arbiter.py index 16a0b43c59008ed595009fc4fdcc9df35a889f9c..c419584f8098d9f919ee62def6e0f95589f1b5ed 100644 --- a/picarones/core/narrative/arbiter.py +++ b/picarones/core/narrative/arbiter.py @@ -1,227 +1,13 @@ -"""Arbitre de sélection des faits narratifs. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.arbiter`. -L'arbitre transforme une liste potentiellement longue de ``Fact`` détectés -en une synthèse courte (3 à 5 phrases) adaptée à l'ouverture du rapport. - -Règles de sélection : - 1. Tri par importance décroissante, puis par type (ordre canonique). - 2. Non-redondance : un seul fait par moteur, sauf si les types sont - complémentaires (ex. ``GLOBAL_LEADER_CER`` + ``SIGNIFICANT_GAP`` - concernent le leader mais apportent une information différente). - 3. Limite : au maximum ``max_facts`` faits retenus (défaut 5). - 4. Déterminisme : tri stable sur (−importance, ordre canonique du type, - noms des moteurs) pour garantir une sortie bit-à-bit identique. - -Les détecteurs peuvent émettre plusieurs faits du même type (ex. plusieurs -``STATISTICAL_TIE`` si plusieurs groupes distincts). L'arbitre ne fusionne -pas mais peut limiter par type. +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -from typing import Iterable, Sequence - -from picarones.core.narrative.facts import Fact, FactImportance, FactType - - -# Ordre canonique des types pour départager les ex-aequo à l'importance égale. -# -# Politique éditoriale — exposée et documentée dans -# ``docs/developer/narrative-engine.md`` § Editorial policy. -# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont -# la même ``FactImportance``. Surchargeable via le paramètre ``type_order`` -# de ``select_facts`` sans patcher le code. -# -# Sprint 29 : la valeur n'est plus codée en dur ici — elle est dérivée du -# registre déclaratif (``@register_detector(..., priority=N)``). Ajouter -# un détecteur en bonne position se fait donc en éditant **un seul** -# fichier (``detectors.py``) au lieu de quatre comme avant. -def _compute_default_type_order() -> tuple[FactType, ...]: - # Import local pour éviter la dépendance circulaire au chargement. - from picarones.core.narrative.registry import default_type_order - order = default_type_order() - # Filet de sécurité : tant que les détecteurs n'ont pas été importés - # (cas des tests qui mockent le registre), on retombe sur un ordre - # canonique gravé pour ne pas planter ``select_facts``. - if not order: - return _FALLBACK_TYPE_ORDER - return order - - -# Ordre statique gardé en mémoire : utilisé si jamais le registre est vide -# au moment où ``arbiter`` est chargé (chargement partiel par les tests). -_FALLBACK_TYPE_ORDER: tuple[FactType, ...] = ( - FactType.GLOBAL_LEADER_CER, - FactType.STATISTICAL_TIE, - FactType.SIGNIFICANT_GAP, - FactType.STRATUM_WINNER, - # Sprint 46 — priority 45, juste après STRATUM_WINNER (40), - # avant STRATUM_COLLAPSE (50). La recommandation de stratification - # nuance directement les autres faits par strate. - FactType.STRATIFICATION_RECOMMENDED, - FactType.STRATUM_COLLAPSE, - FactType.ERROR_PROFILE_OUTLIER, - FactType.LLM_HALLUCINATION_FLAG, - FactType.ROBUSTNESS_FRAGILE, - FactType.PARETO_ALTERNATIVE, - FactType.SPEED_WINNER, - FactType.COST_OUTLIER, - FactType.CONFIDENCE_WARNING, - FactType.ENSEMBLE_OPPORTUNITY, - FactType.MEDIAN_MEAN_GAP_WARNING, - # Sprint 73 — priority 150, après MEDIAN_MEAN_GAP_WARNING (140). - # Le détecteur off-baseline donne le contexte historique, qui - # vient en fin de synthèse comme « note ». - FactType.ENGINE_OFF_BASELINE, - # Sprint 90 — priority 160, ferme la synthèse avec la mise en - # garde sur la reproductibilité. Une instabilité multi-runs - # discrédite toute autre conclusion sur ce moteur ; on la - # remonte en dernier pour ne pas l'enterrer. - FactType.ENGINE_UNSTABLE, - # Sprint 92 — priority 170, après ENGINE_UNSTABLE. La - # régression historique complète A.I.3 (off-baseline) en - # caractérisant la tendance : l'écart courant est-il une - # dégradation graduelle, une rupture brutale, ou un bruit ? - FactType.REGRESSION_IN_HISTORY, -) - - -# ``DEFAULT_TYPE_ORDER`` reste un attribut module accessible. On le calcule -# à l'import si possible, sinon on prend le fallback ; ``select_facts`` -# recalcule à chaque appel pour absorber les ajouts de détecteurs après -# l'import initial (extensions tierces). -DEFAULT_TYPE_ORDER: tuple[FactType, ...] = _compute_default_type_order() - -# Alias rétro-compatible. -_TYPE_ORDER = DEFAULT_TYPE_ORDER -_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)} - - -# Paires de types qui ne sont PAS considérées comme redondantes même quand -# elles concernent le même moteur. Tout autre couple → un seul fait retenu -# pour le moteur (le plus important). -_COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({ - frozenset({FactType.GLOBAL_LEADER_CER, FactType.SIGNIFICANT_GAP}), - frozenset({FactType.GLOBAL_LEADER_CER, FactType.SPEED_WINNER}), - frozenset({FactType.GLOBAL_LEADER_CER, FactType.CONFIDENCE_WARNING}), - frozenset({FactType.STATISTICAL_TIE, FactType.SPEED_WINNER}), - # Sprint 44 — l'avertissement d'asymétrie nuance le leader - # plutôt que de le doubler : on veut les deux phrases ensemble. - frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}), - # Sprint 46 — la recommandation de stratification est un méta-conseil - # qui s'ajoute au leader sans le contredire ; les deux peuvent - # cohabiter même quand ils concernent le même moteur. - frozenset({FactType.GLOBAL_LEADER_CER, FactType.STRATIFICATION_RECOMMENDED}), - # Sprint 90 — l'instabilité multi-runs nuance les conclusions - # sur le moteur leader sans les contredire : un moteur peut être - # leader **et** instable, et c'est précisément l'information - # critique pour la reproductibilité scientifique. - frozenset({FactType.GLOBAL_LEADER_CER, FactType.ENGINE_UNSTABLE}), - # Sprint 92 — la régression historique caractérise la tendance - # du leader : un leader peut être en régression progressive, - # info critique pour décider quand re-tester. - frozenset({FactType.GLOBAL_LEADER_CER, FactType.REGRESSION_IN_HISTORY}), - # Off-baseline (Sprint 73) dit "écart anormal sur ce corpus" ; - # regression-in-history (Sprint 92) dit "tendance dans le - # temps" — les deux se complètent sans se redonder. - frozenset({FactType.ENGINE_OFF_BASELINE, FactType.REGRESSION_IN_HISTORY}), -}) - - -def _sort_key(fact: Fact, type_index: dict[FactType, int]) -> tuple: - """Clé de tri stable : importance (desc), type canonique, moteurs.""" - return ( - -int(fact.importance), - type_index.get(fact.type, len(type_index)), - tuple(sorted(fact.engines_involved)), - fact.stratum or "", - ) - - -def _is_redundant(candidate: Fact, kept: Fact) -> bool: - """Vrai si ``candidate`` apporte trop peu par rapport à ``kept``. - - Deux faits sont redondants s'ils concernent exactement le même moteur, - ont le même type, et la même strate (s'il y en a une). Des types - différents sur le même moteur ne sont considérés redondants que s'ils - n'appartiennent pas aux paires complémentaires (ex : un leader peut - aussi être rapide ; c'est complémentaire). - """ - if candidate.type == kept.type and candidate.stratum == kept.stratum: - return set(candidate.engines_involved) == set(kept.engines_involved) - if set(candidate.engines_involved) == set(kept.engines_involved): - pair = frozenset({candidate.type, kept.type}) - return pair not in _COMPLEMENTARY_PAIRS - return False - - -def _remove_contradictions(facts: list[Fact]) -> list[Fact]: - """Supprime les faits incohérents sur le plan statistique. - - Règle centrale : si Nemenyi (post-hoc corrigé pour comparaisons multiples) - place deux moteurs dans le même groupe d'ex-aequo, alors un ``SIGNIFICANT_GAP`` - basé sur Wilcoxon non corrigé entre ces deux mêmes moteurs est trompeur - pour un lecteur non statisticien. Nemenyi l'emporte. - """ - tied_groups: list[set[str]] = [] - for f in facts: - if f.type == FactType.STATISTICAL_TIE: - tied_groups.append(set(f.engines_involved)) - - def _is_contradicted(fact: Fact) -> bool: - if fact.type != FactType.SIGNIFICANT_GAP: - return False - pair = set(fact.engines_involved) - return any(pair <= group for group in tied_groups) - - return [f for f in facts if not _is_contradicted(f)] - - -def select_facts( - facts: Iterable[Fact], - max_facts: int = 5, - min_importance: FactImportance = FactImportance.MEDIUM, - type_order: Sequence[FactType] | None = None, -) -> list[Fact]: - """Sélectionne la synthèse finale à partir d'une liste brute de faits. - - Parameters - ---------- - facts: - Liste de ``Fact`` brute issue de ``DetectorRegistry.run``. - max_facts: - Nombre maximal de faits retenus (défaut : 5). - min_importance: - Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut. - type_order: - Surcharge optionnelle de l'ordre canonique des types pour départager - les faits d'égale importance. ``None`` (défaut) utilise - ``DEFAULT_TYPE_ORDER``. Une institution peut passer son propre ordre - sans patcher le code — voir ``docs/developer/narrative-engine.md``. - - Returns - ------- - Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``. - """ - if type_order is None: - # Sprint 29 — recalcul à chaque appel pour absorber les détecteurs - # enregistrés après l'import d'arbiter (extensions tierces qui - # font ``@register_detector`` dans un module utilisateur). - from picarones.core.narrative.registry import default_type_order - live_order = default_type_order() or _FALLBACK_TYPE_ORDER - type_index = {t: i for i, t in enumerate(live_order)} - else: - type_index = {t: i for i, t in enumerate(type_order)} - - facts_list = [f for f in facts if int(f.importance) >= int(min_importance)] - facts_list = _remove_contradictions(facts_list) - ranked = sorted(facts_list, key=lambda f: _sort_key(f, type_index)) +from picarones.measurements.narrative.arbiter import * # noqa: F401, F403 - selected: list[Fact] = [] - for fact in ranked: - if any(_is_redundant(fact, kept) for kept in selected): - continue - selected.append(fact) - if len(selected) >= max_facts: - break - return selected +import picarones.measurements.narrative.arbiter as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/__init__.py b/picarones/core/narrative/detectors/__init__.py index eab4983817d14f4ca2a168166dc2f7503bb7b82b..9d677e61151ff05be9f2d9e5bd0942a2fc7d883e 100644 --- a/picarones/core/narrative/detectors/__init__.py +++ b/picarones/core/narrative/detectors/__init__.py @@ -1,129 +1,13 @@ -"""Détecteurs narratifs — package thématique (chantier 5 post-Sprint 97). +"""Alias rétrocompat — package déplacé dans :mod:`picarones.measurements.narrative.detectors`. -Avant le chantier 5, ce module était un fichier monolithe de 1229 lignes -(``narrative/detectors.py``) contenant 18 détecteurs. Pour aligner la -structure de code avec celle du registre déclaratif (Sprint 29), les -détecteurs ont été regroupés par **famille thématique** : - -- :mod:`ranking` — global leader, statistical tie, significant gap, - speed winner, median/mean gap warning (5 détecteurs) -- :mod:`pareto` — Pareto alternative, cost outlier (2 détecteurs) -- :mod:`stratum` — stratum winner / collapse, stratification - recommended (3 détecteurs) -- :mod:`quality` — error profile outlier, LLM hallucination flag, - robustness fragile, confidence warning (4 détecteurs) -- :mod:`history` — engine off baseline, engine unstable, regression - in history (3 détecteurs) -- :mod:`ensemble` — ensemble opportunity (1 détecteur) - -Total : 18 détecteurs (≠ "12" mentionné dans CLAUDE.md historique — -le chantier 5 corrige ce comptage). - -Rétrocompatibilité absolue --------------------------- -Tous les noms exportés par l'ancien fichier ``detectors.py`` -(``detect_*``, ``DETECTORS_BY_TYPE``, ``register_default_detectors``) -restent accessibles via ``from picarones.core.narrative.detectors -import ...``. Les tests Sprints 20, 23, 29, 36, 44, 46, 73 importent -directement ces noms et continuent à fonctionner sans modification. - -L'enregistrement automatique des détecteurs via ``@register_detector`` -se fait à l'import de ce package — chaque sous-module est importé ici -en cascade. +Phase E du chantier de refonte. Les 18 détecteurs en 6 familles +(ranking, pareto, stratum, quality, history, ensemble) vivent +désormais dans ``picarones.measurements.narrative.detectors/``. """ -from __future__ import annotations - -# Imports en cascade des 6 sous-modules : déclenche l'enregistrement -# automatique via les décorateurs ``@register_detector`` au chargement. -from picarones.core.narrative.detectors.ranking import ( - detect_global_leader_cer, - detect_median_mean_gap_warning, - detect_significant_gap, - detect_speed_winner, - detect_statistical_tie, -) -from picarones.core.narrative.detectors.pareto import ( - detect_cost_outlier, - detect_pareto_alternative, -) -from picarones.core.narrative.detectors.stratum import ( - detect_stratification_recommended, - detect_stratum_collapse, - detect_stratum_winner, -) -from picarones.core.narrative.detectors.quality import ( - detect_confidence_warning, - detect_error_profile_outlier, - detect_llm_hallucination_flag, - detect_robustness_fragile, -) -from picarones.core.narrative.detectors.history import ( - detect_engine_off_baseline, - detect_engine_unstable, - detect_regression_in_history, -) -from picarones.core.narrative.detectors.ensemble import ( - detect_ensemble_opportunity, -) - -# Snapshot du registre + helper d'enregistrement legacy — déplacés -# verbatim depuis l'ancien ``detectors.py`` (lignes 1193-1229). -from picarones.core.narrative.facts import DetectorFn, FactType -from picarones.core.narrative.registry import ( - iter_detectors as _iter_detectors, - populate_legacy_registry as _populate_legacy_registry, -) - - -def _build_detectors_by_type() -> dict[FactType, DetectorFn]: - """Snapshot du registre déclaratif vers un dict ``{type: fn}``.""" - return {entry.fact_type: entry.fn for entry in _iter_detectors()} - - -# Vue figée à l'import — utile pour les tests qui parcourent les types -# enregistrés sans instancier un ``DetectorRegistry``. -DETECTORS_BY_TYPE = _build_detectors_by_type() - - -def register_default_detectors(registry) -> None: - """Enregistre les détecteurs du registre déclaratif dans un - ``DetectorRegistry`` historique. - - Sprint 29 : la source de vérité est maintenant le décorateur - ``@register_detector`` ; cette fonction se contente de pousser - le contenu du registre vers l'objet ``DetectorRegistry`` que les - consommateurs externes (``DetectorRegistry.run``) instancient. - """ - _populate_legacy_registry(registry) - +from picarones.measurements.narrative.detectors import * # noqa: F401, F403 -__all__ = [ - # ranking - "detect_global_leader_cer", - "detect_median_mean_gap_warning", - "detect_significant_gap", - "detect_speed_winner", - "detect_statistical_tie", - # pareto - "detect_cost_outlier", - "detect_pareto_alternative", - # stratum - "detect_stratification_recommended", - "detect_stratum_collapse", - "detect_stratum_winner", - # quality - "detect_confidence_warning", - "detect_error_profile_outlier", - "detect_llm_hallucination_flag", - "detect_robustness_fragile", - # history - "detect_engine_off_baseline", - "detect_engine_unstable", - "detect_regression_in_history", - # ensemble - "detect_ensemble_opportunity", - # legacy - "DETECTORS_BY_TYPE", - "register_default_detectors", -] +import picarones.measurements.narrative.detectors as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/_helpers.py b/picarones/core/narrative/detectors/_helpers.py index 124324c361400d2f8a36a89bf03885f3af1d1579..0465d072fb081bc2307a71fbdb86ab236d4691fd 100644 --- a/picarones/core/narrative/detectors/_helpers.py +++ b/picarones/core/narrative/detectors/_helpers.py @@ -1,31 +1,13 @@ -"""Helpers internes partagés par les détecteurs narratifs. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors._helpers`. -Chantier 5 du plan d'évolution post-Sprint 97 — découpage de -``picarones/core/narrative/detectors.py`` (1229 lignes, 18 détecteurs) -en 6 sous-modules thématiques + ce module d'helpers communs. - -Ces fonctions étaient privées (préfixe ``_``) au module historique. -Elles sont conservées telles quelles ici ; les sous-modules les -importent. +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -from typing import Optional - - -def _engines_summary(data: dict) -> list[dict]: - """Accès normalisé à la liste des résumés moteur.""" - return data.get("engines", []) or [] - - -def _engine_by_name(data: dict, name: str) -> Optional[dict]: - for e in _engines_summary(data): - if e.get("name") == name: - return e - return None - +from picarones.measurements.narrative.detectors._helpers import * # noqa: F401, F403 -def _n_docs(data: dict) -> int: - meta = data.get("meta", {}) or {} - return int(meta.get("document_count") or 0) +import picarones.measurements.narrative.detectors._helpers as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/ensemble.py b/picarones/core/narrative/detectors/ensemble.py index 31ce6ec7d33899f6bb7261805180870751ecddc1..d736ba5bd3b1527f1e49deeeb4911edecdb91cfa 100644 --- a/picarones/core/narrative/detectors/ensemble.py +++ b/picarones/core/narrative/detectors/ensemble.py @@ -1,96 +1,13 @@ -"""Détecteurs narratifs liés à l'*opportunité d'ensemble inter-moteurs* (chantier 5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.ensemble`. -1 détecteur déplacé depuis ``narrative/detectors.py`` : - -- :func:`detect_ensemble_opportunity` (Sprint 36) +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import statistics as _stats -from typing import Optional - -from picarones.core.narrative.facts import Fact, FactImportance, FactType -from picarones.core.narrative.registry import register_detector - -from picarones.core.narrative.detectors._helpers import ( - _engine_by_name, - _engines_summary, - _n_docs, -) - - -@register_detector( - FactType.ENSEMBLE_OPPORTUNITY, - priority=130, - importance=FactImportance.MEDIUM, -) -def detect_ensemble_opportunity(benchmark_data: dict) -> list[Fact]: - """Deux moteurs très complémentaires : un voting majoritaire entre eux - pourrait améliorer significativement le CER token-level. - - Lit la structure ``inter_engine_analysis`` produite par le runner - (Sprint 35-36) et déclenche si la fraction d'erreurs du meilleur - moteur récupérable par un ensemble dépasse 25 %. - - L'importance monte à ``HIGH`` quand le gap relatif dépasse 50 % - (ensemble franchement profitable) — sinon reste à ``MEDIUM``. - """ - iea = benchmark_data.get("inter_engine_analysis") or {} - comp = iea.get("complementarity") or {} - if not comp: - return [] - - relative_gap = float(comp.get("relative_gap") or 0.0) - if relative_gap < 0.25: - # En deçà de 25 %, l'ensemble n'apporterait quasi rien — on ne - # remonte pas le fait pour ne pas bruiter la synthèse. - return [] - - best_engine = comp.get("best_engine") or "" - if not best_engine: - return [] - - payload: dict = { - "best_engine": best_engine, - "best_recall_pct": round(float(comp.get("best_single_recall") or 0.0) * 100, 2), - "oracle_recall_pct": round(float(comp.get("oracle_recall") or 0.0) * 100, 2), - "absolute_gap_pct": round(float(comp.get("absolute_gap") or 0.0) * 100, 2), - "relative_gap_pct": round(relative_gap * 100, 1), - "doc_count": int(comp.get("doc_count") or 0), - } - - # Paire la plus complémentaire — la divergence taxonomique, quand - # disponible, fournit deux moteurs « candidats naturels ». Sinon on - # tombe sur le best + le second-best en recall individuel. - div = iea.get("taxonomy_divergence") or {} - pair = div.get("max_pair") or [] - pair_a = "" - pair_b = "" - divergence_value: Optional[float] = None - if pair and len(pair) >= 3 and isinstance(pair[2], (int, float)) and pair[2] > 0: - pair_a, pair_b, divergence_value = str(pair[0]), str(pair[1]), float(pair[2]) - else: - # Fallback : best engine + second-best engine par recall individuel - per_engine = comp.get("per_engine_recall") or {} - if len(per_engine) >= 2: - ranked = sorted(per_engine.items(), key=lambda kv: kv[1], reverse=True) - pair_a, pair_b = ranked[0][0], ranked[1][0] - - payload["pair_a"] = pair_a - payload["pair_b"] = pair_b - payload["divergence"] = round(divergence_value, 3) if divergence_value is not None else 0.0 - payload["divergence_metric"] = (div.get("metric") or "js") +from picarones.measurements.narrative.detectors.ensemble import * # noqa: F401, F403 - importance = ( - FactImportance.HIGH if relative_gap >= 0.5 else FactImportance.MEDIUM - ) - engines_involved: tuple[str, ...] = ( - (pair_a, pair_b) if pair_a and pair_b else (best_engine,) - ) - return [Fact( - type=FactType.ENSEMBLE_OPPORTUNITY, - importance=importance, - payload=payload, - engines_involved=engines_involved, - )] +import picarones.measurements.narrative.detectors.ensemble as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/history.py b/picarones/core/narrative/detectors/history.py index de1fa94835ead21cb101107bddd2bf6921a660d6..13431b2f261aa4fda0ecbbf666fbe17e3b394dc7 100644 --- a/picarones/core/narrative/detectors/history.py +++ b/picarones/core/narrative/detectors/history.py @@ -1,280 +1,13 @@ -"""Détecteurs narratifs liés à *l'historique SQLite + multi-runs* (chantier 5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.history`. -3 détecteurs déplacés depuis ``narrative/detectors.py`` : - -- :func:`detect_engine_off_baseline` (Sprint 73) -- :func:`detect_engine_unstable` (Sprint 90) -- :func:`detect_regression_in_history` (Sprint 92) +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import statistics as _stats -from typing import Optional - -from picarones.core.narrative.facts import Fact, FactImportance, FactType -from picarones.core.narrative.registry import register_detector - -from picarones.core.narrative.detectors._helpers import ( - _engine_by_name, - _engines_summary, - _n_docs, -) - - -@register_detector( - FactType.ENGINE_OFF_BASELINE, - priority=150, - importance=FactImportance.MEDIUM, -) -def detect_engine_off_baseline(benchmark_data: dict) -> list[Fact]: - """Émet un Fact pour chaque moteur dont le CER courant s'écarte - significativement de sa moyenne historique sur le **même corpus**. - - Lit ``benchmark_data["baseline_comparisons"]`` (liste de dicts - produits par ``compute_engine_baseline`` du module - ``baseline_comparison`` Sprint 73). Si la clé est absente ou - vide, le détecteur reste silencieux — typiquement le cas quand - aucun historique SQLite n'a été chargé. - - Garde-fous : - - - Si ``n_runs < 5`` (déjà filtré par ``compute_engine_baseline`` - qui retourne ``None`` dans ce cas). - - Si ``relative_delta`` n'est pas calculable (baseline = 0). - - Importance ``HIGH`` si ``|relative_delta| ≥ 50 %``, sinon - ``MEDIUM``. - """ - comparisons = benchmark_data.get("baseline_comparisons") or [] - if not isinstance(comparisons, (list, tuple)): - return [] - facts: list[Fact] = [] - for comp in comparisons: - if not isinstance(comp, dict): - continue - if not comp.get("off_baseline"): - continue - rel = comp.get("relative_delta") - if rel is None: - continue - engine = comp.get("engine_name") - cer_current = comp.get("cer_current") - cer_hist_mean = comp.get("cer_historical_mean") - n_runs = comp.get("n_runs") - if engine is None or cer_current is None or cer_hist_mean is None: - continue - importance = ( - FactImportance.HIGH if abs(float(rel)) >= 0.50 - else FactImportance.MEDIUM - ) - facts.append(Fact( - type=FactType.ENGINE_OFF_BASELINE, - importance=importance, - payload={ - "engine": engine, - "cer_current_pct": round(float(cer_current) * 100, 2), - "cer_historical_mean_pct": round( - float(cer_hist_mean) * 100, 2, - ), - "n_runs": int(n_runs or 0), - "relative_delta_pct": round(float(rel) * 100, 1), - "direction": "higher" if float(rel) > 0 else "lower", - }, - engines_involved=(engine,), - )) - return facts - - -@register_detector( - FactType.ENGINE_UNSTABLE, - priority=160, - importance=FactImportance.HIGH, -) -def detect_engine_unstable(benchmark_data: dict) -> list[Fact]: - """Émet un Fact pour chaque moteur dont la stabilité multi-runs - est insuffisante (Sprint 83 + 90). - - Lit ``benchmark_data["multirun_stability"]`` : liste de dicts - avec ``engine_name`` + champs de ``compute_multirun_stability`` - (cer_cv, identical_run_rate, n_runs, etc.). Si la clé est - absente ou vide, le détecteur reste silencieux — typiquement - le cas quand l'utilisateur n'a pas exécuté `--repeats N`. - - Garde-fous : - - - ``n_runs ≥ 2`` (déjà filtré par - ``compute_multirun_stability`` qui retourne ``None``). - - Déclenche si ``cer_cv > 0.10`` (variance relative > 10 % du - CER moyen) **ou** ``identical_run_rate < 0.50`` (moins - d'une paire de runs sur deux est identique). - - Importance ``HIGH`` (l'instabilité discrédite les - conclusions). - """ - stabilities = benchmark_data.get("multirun_stability") or [] - if not isinstance(stabilities, (list, tuple)): - return [] - facts: list[Fact] = [] - for stab in stabilities: - if not isinstance(stab, dict): - continue - engine = stab.get("engine_name") or stab.get("engine") - if not engine: - continue - n_runs = stab.get("n_runs") - if not isinstance(n_runs, int) or n_runs < 2: - continue - cer_cv = stab.get("cer_cv") - identical_rate = stab.get("identical_run_rate") - # Critères de déclenchement - cv_high = ( - isinstance(cer_cv, (int, float)) and float(cer_cv) > 0.10 - ) - runs_diverge = ( - isinstance(identical_rate, (int, float)) - and float(identical_rate) < 0.50 - ) - if not (cv_high or runs_diverge): - continue - payload: dict = { - "engine": engine, - "n_runs": int(n_runs), - } - if isinstance(cer_cv, (int, float)): - payload["cer_cv"] = float(cer_cv) - payload["cer_cv_pct"] = round(float(cer_cv) * 100, 1) - if isinstance(identical_rate, (int, float)): - payload["identical_run_rate"] = float(identical_rate) - payload["identical_run_rate_pct"] = round( - float(identical_rate) * 100, 1, - ) - # Champs additionnels pour la phrase de synthèse - cer_mean = stab.get("cer_mean") - cer_stdev = stab.get("cer_stdev") - if isinstance(cer_mean, (int, float)): - payload["cer_mean_pct"] = round(float(cer_mean) * 100, 2) - if isinstance(cer_stdev, (int, float)): - payload["cer_stdev_pct"] = round(float(cer_stdev) * 100, 2) - n_distinct = stab.get("n_distinct_outputs") - if isinstance(n_distinct, int): - payload["n_distinct_outputs"] = int(n_distinct) - facts.append(Fact( - type=FactType.ENGINE_UNSTABLE, - importance=FactImportance.HIGH, - payload=payload, - engines_involved=(engine,), - )) - return facts - - -@register_detector( - FactType.REGRESSION_IN_HISTORY, - priority=170, - importance=FactImportance.MEDIUM, -) -def detect_regression_in_history(benchmark_data: dict) -> list[Fact]: - """Émet un Fact pour chaque moteur dont l'historique montre - une dégradation : pente positive significative ou rupture - brutale (Sprint 92). - - Lit ``benchmark_data["longitudinal_trends"]`` : liste de - dicts produits par ``compute_corpus_longitudinal`` du module - ``longitudinal``. Si la clé est absente ou vide, le - détecteur reste silencieux — typiquement le cas quand - aucun historique n'a été chargé ou que la série est trop - courte. - - Garde-fous : +from picarones.measurements.narrative.detectors.history import * # noqa: F401, F403 - - ``n_runs ≥ 3`` (déjà filtré par - ``compute_engine_longitudinal``). - - Déclenche si **soit** ``trend.slope`` traduit une - régression d'au moins ``slope_threshold`` (en CER/jour, - défaut équivalent à +1 point CER sur 365 jours), **soit** - ``change_point.delta > change_threshold`` (défaut - 0.01 = +1 point de CER d'un segment à l'autre). - - Importance ``HIGH`` si la dégradation cumulée - ``absolute_delta`` ≥ 5 points de CER. - """ - trends = benchmark_data.get("longitudinal_trends") or [] - if not isinstance(trends, (list, tuple)): - return [] - slope_threshold = ( - 0.01 / 365.0 # +1 point de CER sur 365 jours minimum - ) - change_threshold = 0.01 - facts: list[Fact] = [] - for entry in trends: - if not isinstance(entry, dict): - continue - engine = entry.get("engine_name") - if not engine: - continue - n_runs = entry.get("n_runs") - if not isinstance(n_runs, int) or n_runs < 3: - continue - trend = entry.get("trend") or {} - cp = entry.get("change_point") - slope = trend.get("slope") - slope_high = ( - isinstance(slope, (int, float)) - and float(slope) > slope_threshold - ) - cp_high = ( - isinstance(cp, dict) - and isinstance(cp.get("delta"), (int, float)) - and float(cp["delta"]) > change_threshold - ) - if not (slope_high or cp_high): - continue - absolute_delta = entry.get("absolute_delta") or 0.0 - importance = ( - FactImportance.HIGH - if isinstance(absolute_delta, (int, float)) - and abs(float(absolute_delta)) >= 0.05 - else FactImportance.MEDIUM - ) - payload: dict = { - "engine": engine, - "n_runs": int(n_runs), - "absolute_delta_pct": round( - float(absolute_delta) * 100, 2, - ) if isinstance(absolute_delta, (int, float)) else 0.0, - "first_cer_pct": round( - float(entry.get("first_cer") or 0.0) * 100, 2, - ), - "last_cer_pct": round( - float(entry.get("last_cer") or 0.0) * 100, 2, - ), - } - if slope_high: - payload["slope_per_year_pct"] = round( - float(slope) * 365 * 100, 2, - ) - payload["r_squared"] = round( - float(trend.get("r_squared") or 0.0), 3, - ) - payload["pattern"] = "trend" - if cp_high: - payload["change_point_timestamp"] = str( - cp.get("timestamp") or "?", - ) - payload["change_delta_pct"] = round( - float(cp["delta"]) * 100, 2, - ) - payload["mean_before_pct"] = round( - float(cp.get("mean_before") or 0.0) * 100, 2, - ) - payload["mean_after_pct"] = round( - float(cp.get("mean_after") or 0.0) * 100, 2, - ) - # Si on a aussi une rupture, le pattern domine - payload["pattern"] = ( - "trend_and_change_point" if slope_high else "change_point" - ) - facts.append(Fact( - type=FactType.REGRESSION_IN_HISTORY, - importance=importance, - payload=payload, - engines_involved=(engine,), - )) - return facts +import picarones.measurements.narrative.detectors.history as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/pareto.py b/picarones/core/narrative/detectors/pareto.py index fd246c7cf6536bc8351ff85d6351fc2ee51a8280..75075af1112b097bdfb2cdd1e3466efa2038f74b 100644 --- a/picarones/core/narrative/detectors/pareto.py +++ b/picarones/core/narrative/detectors/pareto.py @@ -1,136 +1,13 @@ -"""Détecteurs narratifs orientés *coût/performance Pareto* (chantier 5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.pareto`. -2 détecteurs déplacés depuis ``narrative/detectors.py`` : - -- :func:`detect_pareto_alternative` (Sprint 19) — alternative coût/qualité -- :func:`detect_cost_outlier` (Sprint 19) — moteur dont le coût est aberrant +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import statistics as _stats -from typing import Optional - -from picarones.core.narrative.facts import Fact, FactImportance, FactType -from picarones.core.narrative.registry import register_detector - -from picarones.core.narrative.detectors._helpers import ( - _engine_by_name, - _engines_summary, - _n_docs, -) - - -@register_detector( - FactType.PARETO_ALTERNATIVE, - priority=90, - importance=FactImportance.HIGH, -) -def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]: - """Moteur Pareto-dominant différent du leader CER. - - Lit ``benchmark_data["pareto"]["cost"]`` (Sprint 19) et émet un Fact si - la frontière contient un moteur autre que le leader CER, pour souligner - l'existence d'un compromis coût/qualité intéressant. - """ - pareto = (benchmark_data.get("pareto") or {}).get("cost") or {} - front = pareto.get("front") or [] - points = pareto.get("points") or [] - if len(front) < 2: - return [] - - ranking = benchmark_data.get("ranking") or [] - if not ranking: - return [] - leader = ranking[0].get("engine") - - # Le moteur le moins cher sur le front (hors leader) - alt: Optional[dict] = None - for p in points: - if p.get("engine") == leader: - continue - if p.get("engine") not in front: - continue - if alt is None or float(p.get("cost") or 0.0) < float(alt.get("cost") or 0.0): - alt = p - if alt is None: - return [] - - leader_point = next((p for p in points if p.get("engine") == leader), None) - if leader_point is None: - return [] - - alt_cer = float(alt.get("cer") or 0.0) - alt_cost = float(alt.get("cost") or 0.0) - leader_cer = float(leader_point.get("cer") or 0.0) - leader_cost = float(leader_point.get("cost") or 0.0) - if alt_cost >= leader_cost or alt_cost <= 0: - return [] # pas réellement moins cher — pas intéressant à remonter - - return [Fact( - type=FactType.PARETO_ALTERNATIVE, - importance=FactImportance.HIGH, - payload={ - "engine": alt["engine"], - "leader": leader, - "cer": round(alt_cer, 4), - "cer_pct": round(alt_cer * 100, 2), - "cost": round(alt_cost, 2), - "leader_cer": round(leader_cer, 4), - "leader_cer_pct": round(leader_cer * 100, 2), - "leader_cost": round(leader_cost, 2), - "cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None, - "delta_cer_pct": round((alt_cer - leader_cer) * 100, 2), - # Unité du coût — propagée pour traçabilité (le template ne - # hardcode plus "1000 pages"). - "cost_unit_pages": 1000, - }, - engines_involved=(alt["engine"],), - )] - - -@register_detector( - FactType.COST_OUTLIER, - priority=110, - importance=FactImportance.MEDIUM, -) -def detect_cost_outlier(benchmark_data: dict) -> list[Fact]: - """Moteur dont le coût est très disproportionné par rapport à son apport. - - Flag un moteur dont le coût ≥ 5× la médiane ET qui n'est pas sur le - front Pareto (donc dominé par moins cher OU meilleur CER). - """ - pareto = (benchmark_data.get("pareto") or {}).get("cost") or {} - points = pareto.get("points") or [] - front = set(pareto.get("front") or []) - if len(points) < 3: - return [] - - costs = [float(p["cost"]) for p in points if p.get("cost") is not None] - if not costs: - return [] - median_cost = _stats.median(costs) - if median_cost <= 0: - return [] +from picarones.measurements.narrative.detectors.pareto import * # noqa: F401, F403 - facts: list[Fact] = [] - for p in points: - c = float(p.get("cost") or 0.0) - if c < 5.0 * median_cost: - continue - if p["engine"] in front: - continue # sur le front → coût justifié par une qualité unique - facts.append(Fact( - type=FactType.COST_OUTLIER, - importance=FactImportance.MEDIUM, - payload={ - "engine": p["engine"], - "cost": round(c, 2), - "median_cost": round(median_cost, 2), - "ratio_to_median": round(c / median_cost, 1), - "cer_pct": round(float(p.get("cer") or 0.0) * 100, 2), - "cost_unit_pages": 1000, - }, - engines_involved=(p["engine"],), - )) - return facts +import picarones.measurements.narrative.detectors.pareto as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/quality.py b/picarones/core/narrative/detectors/quality.py index 70cd29f14f0f3feacf71c8c030e4c50ada9cde94..acdb59e182e1b84da6b3948788e293b1f82f7e7f 100644 --- a/picarones/core/narrative/detectors/quality.py +++ b/picarones/core/narrative/detectors/quality.py @@ -1,251 +1,13 @@ -"""Détecteurs narratifs liés à la *qualité texte / fiabilité* (chantier 5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.quality`. -4 détecteurs déplacés depuis ``narrative/detectors.py`` : - -- :func:`detect_error_profile_outlier` (Sprint 4) -- :func:`detect_llm_hallucination_flag` (Sprint 4) -- :func:`detect_robustness_fragile` (Sprint 4) -- :func:`detect_confidence_warning` (Sprint 4) +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import statistics as _stats -from typing import Optional - -from picarones.core.narrative.facts import Fact, FactImportance, FactType -from picarones.core.narrative.registry import register_detector - -from picarones.core.narrative.detectors._helpers import ( - _engine_by_name, - _engines_summary, - _n_docs, -) - - -@register_detector( - FactType.ERROR_PROFILE_OUTLIER, - priority=60, - importance=FactImportance.MEDIUM, -) -def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]: - """Moteur au profil taxonomique atypique. - - Émet un Fact si, pour un moteur et une classe d'erreur, la part relative - est au moins 2× plus élevée que la médiane des autres moteurs (et > 15 % - du total pour éviter les strates marginales). - """ - engines = _engines_summary(benchmark_data) - # {engine: {class_name: proportion}} - profiles: dict[str, dict[str, float]] = {} - for e in engines: - tax = e.get("aggregated_taxonomy") or {} - distribution = tax.get("distribution") or tax.get("proportions") or {} - if not distribution: - continue - profiles[e["name"]] = {k: float(v) for k, v in distribution.items()} - if len(profiles) < 2: - return [] - - # Collecter toutes les classes rencontrées - all_classes: set[str] = set() - for p in profiles.values(): - all_classes.update(p.keys()) - - facts: list[Fact] = [] - for cls in all_classes: - values = [(name, p.get(cls, 0.0)) for name, p in profiles.items()] - props = [v for _, v in values] - if not props: - continue - median_prop = _stats.median(props) - for name, v in values: - if v < 0.15: # trop marginal pour être notable - continue - if median_prop <= 0: - continue - if v >= 2.0 * median_prop: - facts.append(Fact( - type=FactType.ERROR_PROFILE_OUTLIER, - importance=FactImportance.HIGH, - payload={ - "engine": name, - "error_class": cls, - "proportion": round(v, 4), - "proportion_pct": round(v * 100, 1), - "median_proportion": round(median_prop, 4), - "median_proportion_pct": round(median_prop * 100, 1), - "ratio_to_median": round(v / median_prop, 2) if median_prop else None, - }, - engines_involved=(name,), - )) - return facts - - -@register_detector( - FactType.LLM_HALLUCINATION_FLAG, - priority=70, - importance=FactImportance.HIGH, -) -def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]: - """LLM/VLM au taux d'hallucination notablement élevé. - - Déclenché si ``hallucinating_doc_rate`` > 30 % OU ``anchor_score_mean`` < 0,6 - pour un moteur dont le champ ``is_pipeline`` ou ``is_vlm`` est ``True``. - """ - facts: list[Fact] = [] - for e in _engines_summary(benchmark_data): - agg = e.get("aggregated_hallucination") or {} - if not agg: - continue - rate = agg.get("hallucinating_doc_rate") - anchor = agg.get("anchor_score_mean") - length_ratio = agg.get("length_ratio_mean") - # Signal seulement si c'est un pipeline LLM ou un VLM - is_llm = bool(e.get("is_pipeline")) or bool(e.get("is_vlm")) - if not is_llm: - continue - - flagged = False - reasons = [] - if rate is not None and float(rate) > 0.30: - flagged = True - reasons.append("taux de documents hallucinés") - if anchor is not None and float(anchor) < 0.60: - flagged = True - reasons.append("ancrage faible") - if length_ratio is not None and float(length_ratio) > 1.30: - flagged = True - reasons.append("sortie anormalement longue") - if not flagged: - continue - - facts.append(Fact( - type=FactType.LLM_HALLUCINATION_FLAG, - importance=FactImportance.HIGH, - payload={ - "engine": e["name"], - "hallucinating_rate": round(float(rate or 0.0), 4), - "hallucinating_rate_pct": round(float(rate or 0.0) * 100, 1), - "anchor_score": round(float(anchor), 3) if anchor is not None else None, - "length_ratio": round(float(length_ratio), 3) if length_ratio is not None else None, - "reasons": reasons, - "reasons_list": ", ".join(reasons), - }, - engines_involved=(e["name"],), - )) - return facts - - -@register_detector( - FactType.ROBUSTNESS_FRAGILE, - priority=80, - importance=FactImportance.MEDIUM, -) -def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]: - """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou. - - Activé si les données de robustesse sont embarquées dans - ``benchmark_data["robustness"]`` (hors scope du benchmark classique, - produit par ``picarones robustness`` et injecté optionnellement). - """ - robustness = benchmark_data.get("robustness") - if not robustness: - return [] - - facts: list[Fact] = [] - curves = robustness.get("curves") or robustness.get("engines") or [] - # Structure attendue : [{engine, degradation_type, points: [{level, cer}]}] - # Flag : CER à niveau max > 3× CER au niveau min. - for entry in curves: - engine = entry.get("engine") - dtype = entry.get("degradation_type") - points = entry.get("points") or [] - if not engine or not points or len(points) < 2: - continue - try: - sorted_pts = sorted(points, key=lambda p: float(p["level"])) - except (KeyError, TypeError, ValueError): - continue - first, last = sorted_pts[0], sorted_pts[-1] - c0 = float(first.get("cer") or 0.0) - c1 = float(last.get("cer") or 0.0) - if c0 <= 0.01: # éviter division par quasi-zéro - continue - if c1 >= 3.0 * c0 and c1 > 0.15: - facts.append(Fact( - type=FactType.ROBUSTNESS_FRAGILE, - importance=FactImportance.HIGH, - payload={ - "engine": engine, - "degradation": dtype, - "cer_baseline": round(c0, 4), - "cer_baseline_pct": round(c0 * 100, 1), - "cer_degraded": round(c1, 4), - "cer_degraded_pct": round(c1 * 100, 1), - "ratio": round(c1 / c0, 1), - "level_max": float(last.get("level") or 0), - }, - engines_involved=(engine,), - )) - return facts - - -@register_detector( - FactType.CONFIDENCE_WARNING, - priority=120, - importance=FactImportance.MEDIUM, -) -def detect_confidence_warning(benchmark_data: dict) -> list[Fact]: - """Intervalle de confiance large → classement peu fiable. - - Déclenché si, pour le leader ou le runner-up, la largeur de l'IC 95 % - est plus du triple de l'écart |leader − runner-up| OU > 5 points de CER. - """ - stats = benchmark_data.get("statistics", {}) or {} - cis = stats.get("bootstrap_cis") or [] - if len(cis) < 2: - return [] - - ranking = benchmark_data.get("ranking") or [] - valid = [r for r in ranking if r.get("mean_cer") is not None] - if len(valid) < 2: - return [] - - by_name = {c["engine"]: c for c in cis if "engine" in c} - leader = valid[0]["engine"] - runner_up = valid[1]["engine"] - leader_ci = by_name.get(leader) - runner_ci = by_name.get(runner_up) - if not leader_ci or not runner_ci: - return [] +from picarones.measurements.narrative.detectors.quality import * # noqa: F401, F403 - gap = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"])) - facts: list[Fact] = [] - for engine_name, ci in ((leader, leader_ci), (runner_up, runner_ci)): - lo = float(ci.get("ci_lower") or 0.0) - hi = float(ci.get("ci_upper") or 0.0) - width = hi - lo - wide_vs_gap = gap > 0 and width > 3.0 * gap - wide_absolute = width > 0.05 - if wide_vs_gap or wide_absolute: - facts.append(Fact( - type=FactType.CONFIDENCE_WARNING, - importance=FactImportance.MEDIUM, - payload={ - "engine": engine_name, - "ci_lower": round(lo, 4), - "ci_upper": round(hi, 4), - "ci_width": round(width, 4), - "ci_width_pct": round(width * 100, 2), - "mean_cer": round(float(ci.get("mean") or 0.0), 4), - "mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2), - "gap_to_runner_up_pct": round(gap * 100, 2), - # Niveau de confiance des bornes — propagé pour traçabilité - # anti-hallucination (le template ne hardcode plus "95 %"). - "confidence_level": 95, - }, - engines_involved=(engine_name,), - )) - break # un seul avertissement suffit - return facts +import picarones.measurements.narrative.detectors.quality as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/ranking.py b/picarones/core/narrative/detectors/ranking.py index 987ac82a2f8c0dac55b81e029c1fa42bcd984b08..f152130934a793aec8c67de7acafd087de464674 100644 --- a/picarones/core/narrative/detectors/ranking.py +++ b/picarones/core/narrative/detectors/ranking.py @@ -1,279 +1,13 @@ -"""Détecteurs narratifs orientés *classement* (chantier 5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.ranking`. -5 détecteurs déplacés depuis ``narrative/detectors.py`` : - -- :func:`detect_global_leader_cer` (Sprint 4) -- :func:`detect_statistical_tie` (Sprint 18) -- :func:`detect_significant_gap` (Sprint 4) -- :func:`detect_speed_winner` (Sprint 4) -- :func:`detect_median_mean_gap_warning` (Sprint 44) - -Comportement et signature inchangés. Tous restent enregistrés -automatiquement via ``@register_detector`` à l'import. +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import statistics as _stats -from typing import Optional - -from picarones.core.narrative.facts import Fact, FactImportance, FactType -from picarones.core.narrative.registry import register_detector - -from picarones.core.narrative.detectors._helpers import ( - _engine_by_name, - _engines_summary, - _n_docs, -) - - -@register_detector( - FactType.GLOBAL_LEADER_CER, - priority=10, - importance=FactImportance.CRITICAL, -) -def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]: - """Moteur avec le CER moyen le plus bas sur l'ensemble du corpus. - - Émet un Fact CRITICAL si au moins 2 moteurs sont comparés, en attachant - aussi le 2ᵉ pour permettre à l'arbitre de fusionner avec ``significant_gap``. - """ - ranking = benchmark_data.get("ranking") or [] - # Éliminer les entrées sans CER calculé - valid = [r for r in ranking if r.get("mean_cer") is not None] - if len(valid) < 1: - return [] - - leader = valid[0] - runner_up = valid[1] if len(valid) >= 2 else None - - payload = { - "engine": leader["engine"], - "cer": float(leader["mean_cer"]), - "cer_pct": round(float(leader["mean_cer"]) * 100, 2), - "n_engines": len(valid), - "n_docs": _n_docs(benchmark_data), - } - if runner_up is not None: - payload["runner_up"] = runner_up["engine"] - payload["runner_up_cer"] = float(runner_up["mean_cer"]) - payload["runner_up_cer_pct"] = round(float(runner_up["mean_cer"]) * 100, 2) - - return [Fact( - type=FactType.GLOBAL_LEADER_CER, - importance=FactImportance.CRITICAL, - payload=payload, - engines_involved=(leader["engine"],), - )] - - -@register_detector( - FactType.STATISTICAL_TIE, - priority=20, - importance=FactImportance.CRITICAL, -) -def detect_statistical_tie(benchmark_data: dict) -> list[Fact]: - """Groupes de moteurs statistiquement indiscernables (Nemenyi).""" - nemenyi = benchmark_data.get("statistics", {}).get("nemenyi", {}) - if not nemenyi or nemenyi.get("error"): - return [] - - tied_groups = nemenyi.get("tied_groups", []) - mean_ranks = nemenyi.get("mean_ranks", {}) - cd = nemenyi.get("critical_distance", 0.0) - alpha = nemenyi.get("alpha", 0.05) - n_blocks = nemenyi.get("n_blocks", 0) - - facts: list[Fact] = [] - for group in tied_groups: - if len(group) < 2: - continue - is_leader_tie = min(mean_ranks.get(n, 999) for n in group) == min( - mean_ranks.values(), default=0 - ) - importance = FactImportance.CRITICAL if is_leader_tie else FactImportance.HIGH - - facts.append(Fact( - type=FactType.STATISTICAL_TIE, - importance=importance, - payload={ - "engines": list(group), - "engines_list": ", ".join(group), - "mean_ranks": {n: mean_ranks.get(n) for n in group}, - "critical_distance": round(cd, 3), - "alpha": alpha, - "n_blocks": n_blocks, - "includes_leader": is_leader_tie, - "n_tied": len(group), - }, - engines_involved=tuple(group), - )) - return facts - - -@register_detector( - FactType.SIGNIFICANT_GAP, - priority=30, - importance=FactImportance.HIGH, -) -def detect_significant_gap(benchmark_data: dict) -> list[Fact]: - """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement. - - Lit la matrice de Wilcoxon pairwise et vérifie si la paire (leader, - runner-up) y apparaît avec ``significant = True``. - """ - ranking = benchmark_data.get("ranking") or [] - valid = [r for r in ranking if r.get("mean_cer") is not None] - if len(valid) < 2: - return [] - - leader = valid[0]["engine"] - runner_up = valid[1]["engine"] - - pairwise = benchmark_data.get("statistics", {}).get("pairwise_wilcoxon") or [] - match = None - for p in pairwise: - names = {p.get("engine_a"), p.get("engine_b")} - if names == {leader, runner_up}: - match = p - break - if match is None: - return [] - - if not match.get("significant"): - return [] # pas d'écart significatif — rien à signaler ici - - delta_cer = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"])) - return [Fact( - type=FactType.SIGNIFICANT_GAP, - importance=FactImportance.CRITICAL, - payload={ - "leader": leader, - "runner_up": runner_up, - "p_value": float(match.get("p_value", 0.0)), - "delta_cer": round(delta_cer, 4), - "delta_cer_pct": round(delta_cer * 100, 2), - "n_pairs": int(match.get("n_pairs", 0)), - }, - engines_involved=(leader, runner_up), - )] - - -@register_detector( - FactType.SPEED_WINNER, - priority=100, - importance=FactImportance.MEDIUM, -) -def detect_speed_winner(benchmark_data: dict) -> list[Fact]: - """Moteur significativement plus rapide pour une qualité comparable. - - Déclenché si un moteur est au moins 3× plus rapide que la médiane ET que - son CER n'est pas significativement pire (dans le même groupe Nemenyi que - le leader OU CER ≤ 1,1 × CER du leader). - """ - durations = _mean_duration_per_engine(benchmark_data) - if len(durations) < 2: - return [] - - values = list(durations.values()) - median_dur = _stats.median(values) - if median_dur <= 0: - return [] - - ranking = benchmark_data.get("ranking") or [] - valid = [r for r in ranking if r.get("mean_cer") is not None] - if not valid: - return [] - leader_cer = float(valid[0]["mean_cer"]) - quality_ceiling = max(0.01, leader_cer * 1.10) - - tied_groups = benchmark_data.get("statistics", {}).get("nemenyi", {}).get("tied_groups") or [] - leader_group: set[str] = set() - for g in tied_groups: - if valid[0]["engine"] in g: - leader_group = set(g) - break - - facts: list[Fact] = [] - candidates = sorted(durations.items(), key=lambda kv: kv[1]) - for engine, dur in candidates: - if dur * 3.0 > median_dur: - break # les suivants sont encore plus lents - summary = _engine_by_name(benchmark_data, engine) or {} - engine_cer = summary.get("cer") - if engine_cer is None: - continue - acceptable_quality = ( - engine in leader_group or float(engine_cer) <= quality_ceiling - ) - if not acceptable_quality: - continue - facts.append(Fact( - type=FactType.SPEED_WINNER, - importance=FactImportance.MEDIUM, - payload={ - "engine": engine, - "mean_duration": round(dur, 3), - "median_duration": round(median_dur, 3), - "speedup": round(median_dur / dur, 1) if dur > 0 else None, - "cer": round(float(engine_cer), 4), - "cer_pct": round(float(engine_cer) * 100, 2), - }, - engines_involved=(engine,), - )) - return facts[:1] # seulement le plus rapide — éviter le bruit - - -@register_detector( - FactType.MEDIAN_MEAN_GAP_WARNING, - priority=140, - importance=FactImportance.MEDIUM, -) -def detect_median_mean_gap_warning(benchmark_data: dict) -> list[Fact]: - """Avertit quand le ratio ``|moyenne - médiane| / médiane`` du leader - dépasse 30 %, ce qui indique une distribution fortement asymétrique - où la moyenne masque les performances réelles. - - Sprint 44 — A.I.2 du plan d'évolution. Cohérent avec le passage du - tri par défaut sur la médiane : si la moyenne du leader diverge - fortement de la médiane, l'utilisateur doit le savoir pour - interpréter correctement les chiffres. - """ - ranking = benchmark_data.get("ranking") or [] - valid = [ - r for r in ranking - if r.get("median_cer") is not None - and r.get("mean_cer") is not None - ] - if not valid: - return [] - - leader = valid[0] - median_cer = float(leader["median_cer"]) - mean_cer = float(leader["mean_cer"]) - - if median_cer <= 0: - # Médiane nulle (corpus très facile pour ce moteur) — l'écart - # relatif n'est pas calculable de manière utile, on s'abstient. - return [] - - relative_gap = abs(mean_cer - median_cer) / median_cer - if relative_gap < 0.30: - return [] - - importance = ( - FactImportance.HIGH if relative_gap >= 1.0 else FactImportance.MEDIUM - ) +from picarones.measurements.narrative.detectors.ranking import * # noqa: F401, F403 - return [Fact( - type=FactType.MEDIAN_MEAN_GAP_WARNING, - importance=importance, - payload={ - "engine": leader["engine"], - "median_cer_pct": round(median_cer * 100, 2), - "mean_cer_pct": round(mean_cer * 100, 2), - "relative_gap_pct": round(relative_gap * 100, 1), - "n_docs": int(leader.get("documents") or 0), - }, - engines_involved=(leader["engine"],), - )] +import picarones.measurements.narrative.detectors.ranking as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/detectors/stratum.py b/picarones/core/narrative/detectors/stratum.py index 525aad71a12597d84ba2c05fcfd1895fe4460db9..f7dd96cd9cbbc3b4a83f5b74fa9213422d7f6ea2 100644 --- a/picarones/core/narrative/detectors/stratum.py +++ b/picarones/core/narrative/detectors/stratum.py @@ -1,203 +1,13 @@ -"""Détecteurs narratifs liés à la *stratification corpus* (chantier 5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.stratum`. -3 détecteurs + 1 helper (``_stratum_cer_by_engine``) déplacés depuis -``narrative/detectors.py`` : - -- :func:`detect_stratum_winner` (Sprint 4) -- :func:`detect_stratum_collapse` (Sprint 4) -- :func:`detect_stratification_recommended` (Sprint 45) +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import statistics as _stats -from typing import Optional - -from picarones.core.narrative.facts import Fact, FactImportance, FactType -from picarones.core.narrative.registry import register_detector - -from picarones.core.narrative.detectors._helpers import ( - _engine_by_name, - _engines_summary, - _n_docs, -) - - -def _stratum_cer_by_engine(benchmark_data: dict) -> dict[str, dict[str, list[float]]]: - """Agrège les CER par (moteur, strate). - - Strate = ``document["script_type"]`` si présent. Retourne ``{}`` si aucun - document n'expose de strate (pas d'émission possible). - """ - out: dict[str, dict[str, list[float]]] = {} - for doc in benchmark_data.get("documents") or []: - stratum = doc.get("script_type") - if not stratum: - continue - for er in doc.get("engine_results") or []: - if er.get("error"): - continue - cer = er.get("cer") - if cer is None: - continue - name = er.get("engine") - out.setdefault(name, {}).setdefault(stratum, []).append(float(cer)) - return out - - -@register_detector( - FactType.STRATUM_WINNER, - priority=40, - importance=FactImportance.MEDIUM, -) -def detect_stratum_winner(benchmark_data: dict) -> list[Fact]: - """Moteur qui domine nettement sur une strate (≥ 3 documents, CER - au moins 25 % plus bas que le second sur cette strate). - """ - agg = _stratum_cer_by_engine(benchmark_data) - if not agg: - return [] - - # Inverser : {stratum: {engine: mean_cer}} - by_stratum: dict[str, dict[str, float]] = {} - for engine, strata in agg.items(): - for stratum, vals in strata.items(): - if len(vals) < 3: - continue - by_stratum.setdefault(stratum, {})[engine] = sum(vals) / len(vals) - - facts: list[Fact] = [] - for stratum, engine_cer in by_stratum.items(): - if len(engine_cer) < 2: - continue - ordered = sorted(engine_cer.items(), key=lambda kv: kv[1]) - best_name, best_cer = ordered[0] - second_cer = ordered[1][1] - if second_cer == 0: - continue - if best_cer < second_cer * 0.75: # dominance ≥ 25 % - facts.append(Fact( - type=FactType.STRATUM_WINNER, - importance=FactImportance.HIGH, - payload={ - "engine": best_name, - "stratum": stratum, - "cer": round(best_cer, 4), - "cer_pct": round(best_cer * 100, 2), - "second_engine": ordered[1][0], - "second_cer": round(second_cer, 4), - "second_cer_pct": round(second_cer * 100, 2), - "n_docs_stratum": len(agg[best_name][stratum]), - }, - engines_involved=(best_name,), - stratum=stratum, - )) - return facts - - -@register_detector( - FactType.STRATUM_COLLAPSE, - priority=50, - importance=FactImportance.HIGH, -) -def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]: - """Moteur globalement compétitif qui s'effondre sur une strate. - - Déclenché si, pour un moteur, le CER moyen sur une strate ≥ 3 documents - est plus du double du CER global du même moteur. - """ - agg = _stratum_cer_by_engine(benchmark_data) - if not agg: - return [] - - facts: list[Fact] = [] - for engine_name, strata in agg.items(): - summary = _engine_by_name(benchmark_data, engine_name) or {} - global_cer = summary.get("cer") - if global_cer is None: - continue - global_cer = float(global_cer) - if global_cer <= 0: - continue - for stratum, vals in strata.items(): - if len(vals) < 3: - continue - local_cer = sum(vals) / len(vals) - if local_cer > 2.0 * global_cer and (local_cer - global_cer) > 0.05: - facts.append(Fact( - type=FactType.STRATUM_COLLAPSE, - importance=FactImportance.HIGH, - payload={ - "engine": engine_name, - "stratum": stratum, - "local_cer": round(local_cer, 4), - "local_cer_pct": round(local_cer * 100, 2), - "global_cer": round(global_cer, 4), - "global_cer_pct": round(global_cer * 100, 2), - "delta_cer_pct": round((local_cer - global_cer) * 100, 2), - "n_docs_stratum": len(vals), - }, - engines_involved=(engine_name,), - stratum=stratum, - )) - return facts - - -@register_detector( - FactType.STRATIFICATION_RECOMMENDED, - priority=45, # juste après STRATUM_WINNER (40), avant STRATUM_COLLAPSE (50) - importance=FactImportance.HIGH, -) -def detect_stratification_recommended(benchmark_data: dict) -> list[Fact]: - """Avertit quand le corpus est hétérogène et que la vue stratifiée - apporte un éclairage qualitativement différent du classement global. - - Critère : ``corpus_homogeneity.max_inter_strata_gap > 5 points`` de - CER médian sur le moteur leader. Au-delà de 10 points, importance - ``HIGH`` (situation très hétérogène où le seul classement global - serait trompeur). - - Lit ``benchmark_data["corpus_homogeneity"]`` exposé par - ``BenchmarkResult.as_dict()`` (Sprint 45). - """ - homog = benchmark_data.get("corpus_homogeneity") - if not homog: - return [] - - gap = homog.get("max_inter_strata_gap") - if gap is None: - return [] - - gap = float(gap) - if gap < 0.05: - return [] # 5 points de CER : seuil de pertinence éditoriale - - leader = str(homog.get("leader") or "") - n_strata = int(homog.get("n_strata") or 0) - pair = homog.get("leader_max_gap_strata") or ["", ""] - if len(pair) < 2: - return [] - min_strat, max_strat = str(pair[0]), str(pair[1]) - - leader_per_stratum = homog.get("leader_per_stratum_median") or {} - min_med = float(leader_per_stratum.get(min_strat, 0.0)) - max_med = float(leader_per_stratum.get(max_strat, 0.0)) - - importance = ( - FactImportance.HIGH if gap >= 0.10 else FactImportance.MEDIUM - ) +from picarones.measurements.narrative.detectors.stratum import * # noqa: F401, F403 - return [Fact( - type=FactType.STRATIFICATION_RECOMMENDED, - importance=importance, - payload={ - "leader": leader, - "n_strata": n_strata, - "gap_pct": round(gap * 100, 1), - "min_stratum": min_strat, - "max_stratum": max_strat, - "min_stratum_cer_pct": round(min_med * 100, 2), - "max_stratum_cer_pct": round(max_med * 100, 2), - }, - engines_involved=(leader,) if leader else (), - )] +import picarones.measurements.narrative.detectors.stratum as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/facts.py b/picarones/core/narrative/facts.py index f06c2289173a203616ea64b658a4ae21368b15af..3365bdf3ca3c66463e6b9a6ec4c4252ae3315e4a 100644 --- a/picarones/core/narrative/facts.py +++ b/picarones/core/narrative/facts.py @@ -1,212 +1,13 @@ -"""Modèle de données du moteur narratif. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.facts`. -Un ``Fact`` est une observation structurée extraite d'un ``BenchmarkResult``. -Chaque détecteur retourne zéro, un ou plusieurs ``Fact`` typés. L'arbitre -(Sprint 4) trie par ``importance`` et sélectionne les faits à afficher. - -Règle d'or (à vérifier par tests) : chaque valeur numérique ou nom d'entité -présent dans ``payload`` doit provenir directement du JSON d'entrée, jamais -d'une génération. C'est ce qui rend la synthèse reproductible bit-à-bit et -immune à l'hallucination par construction. +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Callable, Optional - - -class FactType(str, Enum): - """Types de faits détectables. - - L'ajout d'un nouveau type se fait ici + un détecteur dans ``detectors.py`` - + un template dans ``narrative/templates_{lang}.yaml`` (Sprint 4). - """ - - GLOBAL_LEADER_CER = "global_leader_cer" - """Moteur avec le CER médian le plus bas sur l'ensemble du corpus.""" - - STATISTICAL_TIE = "statistical_tie" - """Top-N moteurs statistiquement indiscernables (Nemenyi, Sprint 3).""" - - SIGNIFICANT_GAP = "significant_gap" - """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement.""" - - PARETO_ALTERNATIVE = "pareto_alternative" - """Moteur sur la frontière Pareto différent du leader CER pur (Sprint 5).""" - - STRATUM_WINNER = "stratum_winner" - """Moteur qui domine sur une strate spécifique (siècle, langue, type).""" - - STRATUM_COLLAPSE = "stratum_collapse" - """Moteur globalement bon qui s'effondre sur une strate spécifique.""" - - ERROR_PROFILE_OUTLIER = "error_profile_outlier" - """Moteur avec un profil taxonomique atypique (ex : 3× plus d'erreurs d'abréviation).""" - - LLM_HALLUCINATION_FLAG = "llm_hallucination_flag" - """LLM avec un taux d'hallucination notablement supérieur aux autres.""" - - ROBUSTNESS_FRAGILE = "robustness_fragile" - """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou.""" - - COST_OUTLIER = "cost_outlier" - """Moteur au ratio coût/qualité très défavorable (Sprint 5).""" - - SPEED_WINNER = "speed_winner" - """Moteur significativement plus rapide pour une qualité comparable.""" - - CONFIDENCE_WARNING = "confidence_warning" - """Intervalle de confiance très large : classement peu fiable.""" - - ENSEMBLE_OPPORTUNITY = "ensemble_opportunity" - """Deux moteurs sont fortement complémentaires : un voting majoritaire - pourrait améliorer significativement le CER (Sprint 36).""" - - MEDIAN_MEAN_GAP_WARNING = "median_mean_gap_warning" - """Distribution des CER fortement asymétrique sur le corpus — - la moyenne du leader est tirée par quelques documents catastrophiques - et masque les performances réelles. La médiane (utilisée pour le tri - par défaut depuis Sprint 44) est plus représentative.""" - - STRATIFICATION_RECOMMENDED = "stratification_recommended" - """Le corpus est hétérogène du point de vue script_type : le moteur - leader varie fortement selon la strate. Le lecteur doit consulter - la vue stratifiée plutôt que de se fier au seul classement global - (Sprint 46).""" - - ENGINE_OFF_BASELINE = "engine_off_baseline" - """Le CER courant d'un moteur s'écarte significativement de sa - moyenne historique sur le même corpus (lue depuis l'historique - SQLite, Sprint 8). Lit ``BenchmarkHistory`` via le module - ``baseline_comparison`` (Sprint 73). Garde-fous : ≥ 5 runs - historiques même corpus + |delta_relatif| > 20 %.""" - - ENGINE_UNSTABLE = "engine_unstable" - """Un moteur LLM/VLM exécuté plusieurs fois sur les mêmes - documents produit des sorties différentes au-delà d'un seuil - de variance (Sprint 90). Lit ``compute_multirun_stability`` - (Sprint 83). Garde-fous : ≥ 2 runs et seuil sur le coefficient - de variation du CER (>10 % par défaut) ou sur le rappel de - runs identiques (<50 %).""" - - REGRESSION_IN_HISTORY = "regression_in_history" - """Un moteur montre une tendance ou une rupture défavorable - sur l'historique SQLite : son CER moyen s'est dégradé sur - les N derniers runs (Sprint 92). Lit - ``compute_corpus_longitudinal`` du module ``longitudinal``. - Garde-fous : ≥ 3 runs historiques et soit pente > seuil - (régression progressive), soit change-point avec delta > - seuil (rupture brutale).""" - - -class FactImportance(int, Enum): - """Score d'importance d'un fait — décide l'ordre et la sélection.""" - - CRITICAL = 100 - """À remonter systématiquement en synthèse (ex : leader + écart significatif).""" - - HIGH = 70 - """À remonter sauf si déjà redondant avec un fait critique.""" - - MEDIUM = 40 - """À remonter si la synthèse a encore de la place.""" - - LOW = 10 - """Informatif, remonté uniquement en vue détaillée.""" - - -@dataclass -class Fact: - """Observation structurée extraite d'un benchmark. - - Attributes - ---------- - type: - Type de fait (voir ``FactType``). - importance: - Priorité de sélection (voir ``FactImportance``). - payload: - Dict de données brutes sérialisables. **Toutes les valeurs doivent - provenir du JSON d'entrée** — c'est le garde-fou anti-hallucination. - engines_involved: - Noms des moteurs concernés. Utilisé par l'arbitre pour détecter - les redondances (deux faits sur le même moteur = fusion ou sélection). - stratum: - Strate concernée (ex : "XVIIe siècle", "latin médiéval") ou None. - """ - - type: FactType - importance: FactImportance - payload: dict - engines_involved: tuple[str, ...] = () - stratum: Optional[str] = None - - def as_dict(self) -> dict: - return { - "type": self.type.value, - "importance": int(self.importance), - "payload": self.payload, - "engines_involved": list(self.engines_involved), - "stratum": self.stratum, - } - - -# --------------------------------------------------------------------------- -# Registre de détecteurs -# --------------------------------------------------------------------------- - -# Signature d'un détecteur : prend le dict JSON du benchmark, retourne une liste -# de Fact (potentiellement vide). Doit être pure et déterministe. -DetectorFn = Callable[[dict], list[Fact]] - - -@dataclass -class DetectorRegistry: - """Registre central des détecteurs de faits. - - Un détecteur est enregistré via ``register(fact_type, fn)``. ``detect_all`` - appelle tous les détecteurs enregistrés et renvoie la liste consolidée. - """ - - _detectors: dict[FactType, DetectorFn] = field(default_factory=dict) - - def register(self, fact_type: FactType, fn: DetectorFn) -> None: - self._detectors[fact_type] = fn - - def unregister(self, fact_type: FactType) -> None: - self._detectors.pop(fact_type, None) - - def registered_types(self) -> tuple[FactType, ...]: - return tuple(self._detectors.keys()) - - def run(self, benchmark_data: dict) -> list[Fact]: - facts: list[Fact] = [] - for fact_type, fn in self._detectors.items(): - try: - result = fn(benchmark_data) - except Exception as e: - import logging - logging.getLogger(__name__).warning( - "[narrative.detector.%s] fonctionnalité dégradée : %s", - fact_type.value, e, - ) - continue - if result: - facts.extend(result) - return facts - - -def detect_all(benchmark_data: dict, registry: Optional[DetectorRegistry] = None) -> list[Fact]: - """Applique tous les détecteurs enregistrés au benchmark donné. - - Point d'entrée du Sprint 4. Pour Sprint 1, le registre par défaut est vide : - les détecteurs concrets sont ajoutés sprint par sprint. - """ - if registry is None: - registry = _DEFAULT_REGISTRY - return registry.run(benchmark_data) - +from picarones.measurements.narrative.facts import * # noqa: F401, F403 -_DEFAULT_REGISTRY = DetectorRegistry() +import picarones.measurements.narrative.facts as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/registry.py b/picarones/core/narrative/registry.py index c38b566280eed5560628e8a93d9210f34cf1d838..d400fd4c614e0142162b00056a167cb9074ce940 100644 --- a/picarones/core/narrative/registry.py +++ b/picarones/core/narrative/registry.py @@ -1,217 +1,13 @@ -"""Registre déclaratif des détecteurs narratifs (Sprint 29). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.registry`. -Avant le Sprint 29, ajouter un nouveau type de fait imposait de toucher -**quatre** fichiers : - - 1. ``facts.py`` — ajouter une valeur à ``FactType`` ; - 2. ``detectors.py`` — écrire ``def detect_xxx(data) -> list[Fact]`` ; - 3. ``detectors.py`` — l'inscrire dans le dict ``DETECTORS_BY_TYPE`` ; - 4. ``arbiter.py`` — ajouter le type à la séquence ``DEFAULT_TYPE_ORDER`` - au bon endroit pour la priorité éditoriale. - -Sprint 29 ramène le nombre de modifications à **deux** : - - 1. ``facts.py`` — toujours nécessaire pour le type énuméré ; - 2. ``detectors.py`` — décorer la fonction avec ``@register_detector(...)``. - -Le décorateur : - - enregistre la fonction dans un registre global trié par ``priority`` ; - - vérifie qu'aucun détecteur ne se réenregistre sur le même ``FactType`` ; - - laisse la fonction utilisable telle quelle (rétrocompatibilité) ; - - alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER``. - -Conventions de priorité (« politique éditoriale » du rapport) -------------------------------------------------------------- -Plus la valeur est petite, plus le fait remonte tôt en synthèse à -importance égale. Pour conserver l'ordre historique du Sprint 23, on -utilise un pas de 10 pour laisser de la place à des insertions futures : - - 10 GLOBAL_LEADER_CER qui gagne globalement - 20 STATISTICAL_TIE y a-t-il un ex-aequo - 30 SIGNIFICANT_GAP à quel point l'écart est solide - 40 STRATUM_WINNER qui domine sur quel sous-corpus - 50 STRATUM_COLLAPSE qui s'effondre sur quoi - 60 ERROR_PROFILE_OUTLIER qui se trompe différemment - 70 LLM_HALLUCINATION_FLAG hallucinations VLM - 80 ROBUSTNESS_FRAGILE sensibilité aux dégradations - 90 PARETO_ALTERNATIVE compromis coût/qualité - 100 SPEED_WINNER vitesse - 110 COST_OUTLIER coût aberrant - 120 CONFIDENCE_WARNING mise en garde sur la fiabilité - -Le décorateur n'impose **pas** de pas — un détecteur tiers peut très -bien utiliser ``priority=42`` pour s'insérer entre STRATUM_WINNER et -STRATUM_COLLAPSE par exemple. +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations - -import logging -import threading -from dataclasses import dataclass -from typing import Callable, Optional - -from picarones.core.narrative.facts import ( - DetectorFn, - DetectorRegistry, - FactImportance, - FactType, -) - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Métadonnées d'un détecteur -# --------------------------------------------------------------------------- - -@dataclass(frozen=True) -class DetectorEntry: - """Métadonnées d'un détecteur enregistré.""" - fact_type: FactType - fn: DetectorFn - priority: int - importance: FactImportance - - -# --------------------------------------------------------------------------- -# Registre global -# --------------------------------------------------------------------------- - -_REGISTRY: dict[FactType, DetectorEntry] = {} -_REGISTRY_LOCK = threading.Lock() - - -def register_detector( - fact_type: FactType, - *, - priority: int, - importance: FactImportance = FactImportance.MEDIUM, -) -> Callable[[DetectorFn], DetectorFn]: - """Décorateur d'enregistrement. - - Usage:: - - @register_detector(FactType.GLOBAL_LEADER_CER, priority=10, - importance=FactImportance.CRITICAL) - def detect_global_leader_cer(data: dict) -> list[Fact]: - ... - - Le décorateur : - - vérifie qu'aucun autre détecteur n'est déjà enregistré sur - ``fact_type`` (sinon ``ValueError``) ; - - vérifie que ``priority`` est un entier ; - - retourne la fonction inchangée pour ne pas casser les imports - existants. - - L'``importance`` mémorisée ici sert de **métadonnée** au registre : - chaque détecteur reste libre d'émettre des ``Fact`` avec une - importance différente selon le contexte (ex. CRITICAL si l'écart - est gigantesque, HIGH sinon). - """ - def _decorator(fn: DetectorFn) -> DetectorFn: - with _REGISTRY_LOCK: - if fact_type in _REGISTRY: - raise ValueError( - f"Détecteur déjà enregistré pour {fact_type.value!r} : " - f"{_REGISTRY[fact_type].fn.__name__}. Désenregistrer " - "explicitement avant de réassigner." - ) - entry = DetectorEntry( - fact_type=fact_type, - fn=fn, - priority=int(priority), - importance=importance, - ) - _REGISTRY[fact_type] = entry - logger.debug( - "[narrative.registry] enregistré %s priority=%s importance=%s", - fact_type.value, priority, importance.name, - ) - return fn - - return _decorator - - -def unregister(fact_type: FactType) -> None: - """Retire un détecteur du registre — utilisé par les tests.""" - with _REGISTRY_LOCK: - _REGISTRY.pop(fact_type, None) - - -def iter_detectors() -> list[DetectorEntry]: - """Retourne tous les détecteurs enregistrés, triés par ``priority``. - - Le tri est stable : à ``priority`` égale, l'ordre d'enregistrement - est préservé (utile en présence d'extensions tierces). - """ - with _REGISTRY_LOCK: - entries = list(_REGISTRY.values()) - entries.sort(key=lambda e: e.priority) - return entries - - -def detector_for(fact_type: FactType) -> Optional[DetectorEntry]: - with _REGISTRY_LOCK: - return _REGISTRY.get(fact_type) - - -def clear_registry() -> None: - """Vide le registre — réservé aux tests d'isolation.""" - with _REGISTRY_LOCK: - _REGISTRY.clear() - - -def default_type_order() -> tuple[FactType, ...]: - """Calcule l'ordre canonique des types depuis le registre courant. - - Source de vérité de ``arbiter.DEFAULT_TYPE_ORDER`` depuis le Sprint 29. - """ - return tuple(e.fact_type for e in iter_detectors()) - - -# --------------------------------------------------------------------------- -# Pont avec ``DetectorRegistry`` historique -# --------------------------------------------------------------------------- - -def populate_legacy_registry(registry: DetectorRegistry) -> None: - """Synchronise le ``DetectorRegistry`` historique depuis le décorateur. - - L'objet ``DetectorRegistry`` reste l'API publique pour les - consommateurs externes (cf. ``DetectorRegistry.run``) ; cette - fonction l'alimente depuis le registre déclaratif courant. - """ - for entry in iter_detectors(): - registry.register(entry.fact_type, entry.fn) - - -__all__ = [ - "DetectorEntry", - "register_detector", - "unregister", - "iter_detectors", - "detector_for", - "clear_registry", - "default_type_order", - "populate_legacy_registry", -] - - -# --------------------------------------------------------------------------- -# Sentinel — sans usage direct ; vérifie au build qu'on n'introduit pas -# de valeur ``priority`` dupliquée par accident parmi les builtins. -# --------------------------------------------------------------------------- +from picarones.measurements.narrative.registry import * # noqa: F401, F403 -def _verify_unique_priorities() -> None: - seen: dict[int, FactType] = {} - for entry in iter_detectors(): - if entry.priority in seen: - logger.warning( - "[narrative.registry] priority %s dupliquée : " - "%s et %s — ordre indéterministe à priorité égale.", - entry.priority, - seen[entry.priority].value, - entry.fact_type.value, - ) - else: - seen[entry.priority] = entry.fact_type +import picarones.measurements.narrative.registry as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/narrative/renderer.py b/picarones/core/narrative/renderer.py index a201560c682f25a1611359477509c08e75f83e8b..15fa8454067ee95553e5243ec4d5932b6fce0367 100644 --- a/picarones/core/narrative/renderer.py +++ b/picarones/core/narrative/renderer.py @@ -1,105 +1,13 @@ -"""Rendu des faits narratifs en texte lisible. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.renderer`. -Les templates sont chargés depuis ``templates/{lang}.yaml`` au premier accès. -Le rendu utilise ``str.format_map`` sur le ``payload`` du ``Fact``. Aucun LLM, -aucune génération : la sortie est la concaténation de templates remplis avec -des valeurs venant strictement du JSON d'entrée. +Phase E du chantier de refonte en 3 cercles. Le moteur narratif +(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``. +Cet alias maintient la rétrocompat des imports historiques. """ -from __future__ import annotations +from picarones.measurements.narrative.renderer import * # noqa: F401, F403 -import logging -import re -from pathlib import Path -from typing import Iterable - -import yaml - -from picarones.core.narrative.facts import Fact - -logger = logging.getLogger(__name__) - -_TEMPLATES_DIR = Path(__file__).parent / "templates" -_TEMPLATES_CACHE: dict[str, dict[str, str]] = {} - - -def _load_templates(lang: str) -> dict[str, str]: - """Charge et met en cache les templates de la langue demandée. - - Fallback : si la langue n'existe pas, retourne les templates FR. Si FR - est également absent (incident d'installation), retourne un dict vide. - """ - if lang in _TEMPLATES_CACHE: - return _TEMPLATES_CACHE[lang] - - path = _TEMPLATES_DIR / f"{lang}.yaml" - if not path.exists(): - if lang != "fr": - return _load_templates("fr") - _TEMPLATES_CACHE[lang] = {} - return _TEMPLATES_CACHE[lang] - - try: - with path.open(encoding="utf-8") as fh: - data = yaml.safe_load(fh) or {} - if not isinstance(data, dict): - logger.warning("[narrative] %s n'est pas un dict YAML — ignoré", path) - _TEMPLATES_CACHE[lang] = {} - else: - _TEMPLATES_CACHE[lang] = {str(k): str(v).strip() for k, v in data.items()} - except yaml.YAMLError as e: - logger.warning("[narrative] échec parsing %s : %s", path, e) - _TEMPLATES_CACHE[lang] = {} - - return _TEMPLATES_CACHE[lang] - - -class _SafeFormatMap(dict): - """Dict qui retourne ``'?'`` pour les clés manquantes dans un template. - - Évite qu'un détecteur mal documenté fasse crasher le rendu. En pratique - les tests couvrent les clés attendues, mais la robustesse prévaut. - """ - - def __missing__(self, key: str) -> str: - logger.warning("[narrative] clé manquante dans payload : %r", key) - return "?" - - -def render_fact(fact: Fact, lang: str = "fr") -> str: - """Rend un Fact en une phrase selon la langue. - - Retourne ``""`` si le template est absent pour ce type. - """ - templates = _load_templates(lang) - tpl = templates.get(fact.type.value) - if not tpl: - return "" - - try: - return tpl.format_map(_SafeFormatMap(fact.payload)) - except (ValueError, KeyError) as e: - logger.warning( - "[narrative] rendu impossible pour %s : %s", fact.type.value, e, - ) - return "" - - -def render_synthesis(facts: Iterable[Fact], lang: str = "fr") -> list[str]: - """Rend une liste de Fact en liste de phrases (ordre préservé).""" - out: list[str] = [] - for fact in facts: - phrase = render_fact(fact, lang) - phrase = re.sub(r"\s+", " ", phrase).strip() - if phrase: - out.append(phrase) - return out - - -def extract_numbers(text: str) -> list[str]: - """Extrait les nombres (décimaux ou entiers) présents dans une phrase. - - Utilisé par le test de traçabilité : chaque nombre remonté en synthèse - doit être présent dans le JSON d'entrée. - """ - return re.findall(r"\d+(?:[.,]\d+)?", text) +import picarones.measurements.narrative.renderer as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/ner.py b/picarones/core/ner.py index 7d3bbe8892ede723311146db19ae43a47d83c771..717e6a08df3cf1f571a2835ae991659a92e3d829 100644 --- a/picarones/core/ner.py +++ b/picarones/core/ner.py @@ -1,309 +1,19 @@ -"""Calcul des métriques de précision sur entités nommées (NER). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.ner`. -Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.ner import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Pour un médiéviste, un archiviste ou un économiste-historien, -l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui -compte c'est de savoir si les **entités nommées** (personnes, lieux, -dates, organisations) ont survécu à la transcription. Un CER de 5 % -qui rate 80 % des noms propres est inutilisable pour l'indexation -prosopographique. - -Stratégie de découpage en sprints ---------------------------------- -Comme pour la divergence taxonomique (Sprints 35-37), on découpe : - -- **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre - deux listes d'entités, calcul de Precision/Recall/F1 par catégorie - et global, détection des hallucinations d'entité. Aucune dépendance - externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont - fournies en entrée. Un test de l'enregistrement dans le registre - typé Sprint 34 garantit l'intégration. -- **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et - câblage runner+narratif+HTML. - -Format des entités ------------------- -Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un -dictionnaire ``{"label": str, "start": int, "end": int, "text": str}`` -où ``start``/``end`` sont des offsets caractère. - -Convention d'alignement ------------------------ -Une entité hypothèse "matche" une entité de référence si : - -1. les **labels sont identiques** (case-insensitive), -2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans - caractère est ``≥ iou_threshold`` (défaut : 0,5). - -Une entité de référence non matchée → faux négatif (recall pénalisé). -Une entité hypothèse non matchée → faux positif (précision pénalisée). -Un faux positif est aussi compté comme **hallucination d'entité**, ce -qui est utile pour les VLM/LLM qui inventent. - -Limites -------- -- L'alignement bag-of-spans : une entité peut être matchée par au plus - une entité de l'autre côté (sinon double-comptage). -- Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique - mesure conjointement OCR + NER. Documenter explicitement. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Iterable - -from picarones.core.metric_registry import register_metric -from picarones.core.modules import ArtifactType - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle de données -# ────────────────────────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class Entity: - """Entité nommée alignée sur un texte. - - Attributs - --------- - label: - Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``). - La comparaison se fait en *case-insensitive*. - start, end: - Offsets caractère (inclus, exclu) sur le texte de référence. - text: - Forme de surface — informative, **non utilisée pour - l'alignement** (deux entités peuvent matcher même si leur - forme de surface diffère, du moment que leurs spans - chevauchent suffisamment). - """ - - label: str - start: int - end: int - text: str = "" - - def __post_init__(self) -> None: - if self.start > self.end: - raise ValueError( - f"Entity span invalide : start={self.start} > end={self.end}" - ) - - @property - def length(self) -> int: - return max(0, self.end - self.start) - - -def _to_entity(obj: Entity | dict) -> Entity: - """Coerce un dict (format EntitiesGT) en ``Entity``.""" - if isinstance(obj, Entity): - return obj - return Entity( - label=str(obj["label"]), - start=int(obj["start"]), - end=int(obj["end"]), - text=str(obj.get("text", "")), - ) - - -# ────────────────────────────────────────────────────────────────────────── -# Alignement par IoU -# ────────────────────────────────────────────────────────────────────────── - - -def _iou(a: Entity, b: Entity) -> float: - """Intersection-over-Union sur les spans caractère.""" - inter_start = max(a.start, b.start) - inter_end = min(a.end, b.end) - inter = max(0, inter_end - inter_start) - union = a.length + b.length - inter - if union <= 0: - return 0.0 - return inter / union - - -def _align( - references: list[Entity], - hypotheses: list[Entity], - iou_threshold: float, -) -> tuple[list[tuple[int, int, float]], set[int], set[int]]: - """Aligne deux listes d'entités par IoU décroissant (greedy). - - Returns - ------- - matches: - Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU - décroissant — chaque entité n'apparaît qu'une fois. - unmatched_refs: - Indices des entités GT non matchées (faux négatifs). - unmatched_hyps: - Indices des entités hypothèse non matchées (faux positifs). - """ - candidates: list[tuple[float, int, int]] = [] - for i, r in enumerate(references): - for j, h in enumerate(hypotheses): - if r.label.casefold() != h.label.casefold(): - continue - score = _iou(r, h) - if score >= iou_threshold: - candidates.append((score, i, j)) - - # Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires - # pour garantir un tri stable et déterministe. - candidates.sort(key=lambda t: (-t[0], t[1], t[2])) - - matched_refs: set[int] = set() - matched_hyps: set[int] = set() - matches: list[tuple[int, int, float]] = [] - for score, i, j in candidates: - if i in matched_refs or j in matched_hyps: - continue - matched_refs.add(i) - matched_hyps.add(j) - matches.append((i, j, score)) - - unmatched_refs = set(range(len(references))) - matched_refs - unmatched_hyps = set(range(len(hypotheses))) - matched_hyps - return matches, unmatched_refs, unmatched_hyps - - -# ────────────────────────────────────────────────────────────────────────── -# Calcul des métriques -# ────────────────────────────────────────────────────────────────────────── - - -def _prf(tp: int, fp: int, fn: int) -> dict[str, float]: - """Précision / rappel / F1 à partir des comptes.""" - precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 - recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 - f1 = ( - 2 * precision * recall / (precision + recall) - if (precision + recall) > 0 - else 0.0 - ) - return { - "precision": precision, - "recall": recall, - "f1": f1, - "support": tp + fn, - } - - -def compute_ner_metrics( - reference_entities: Iterable[Entity | dict], - hypothesis_entities: Iterable[Entity | dict], - iou_threshold: float = 0.5, -) -> dict: - """Calcule la précision/rappel/F1 sur entités nommées. - - Parameters - ---------- - reference_entities: - Liste d'entités GT (format ``Entity`` ou dict de - ``EntitiesGT``). - hypothesis_entities: - Liste d'entités produites par le NER sur la sortie OCR. - iou_threshold: - Seuil de chevauchement caractère pour qu'un appariement - soit valide (défaut : 0,5 — convention CoNLL/HIPE). - - Returns - ------- - dict - ``{ - "global": {"precision", "recall", "f1", "support"}, - "per_category": {label: {"precision", ...}}, - "true_positives": int, - "false_positives": int, - "false_negatives": int, - "hallucinated_entities": list[dict], # entités OCR sans GT - "missed_entities": list[dict], # entités GT non détectées - "iou_threshold": float, - }`` - """ - refs = [_to_entity(e) for e in reference_entities] - hyps = [_to_entity(e) for e in hypothesis_entities] - - matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold) - - tp = len(matches) - fn = len(unmatched_refs) - fp = len(unmatched_hyps) - - # Comptes par catégorie - cat_tp: dict[str, int] = {} - cat_fn: dict[str, int] = {} - cat_fp: dict[str, int] = {} - for i, _j, _score in matches: - cat = refs[i].label - cat_tp[cat] = cat_tp.get(cat, 0) + 1 - for i in unmatched_refs: - cat = refs[i].label - cat_fn[cat] = cat_fn.get(cat, 0) + 1 - for j in unmatched_hyps: - cat = hyps[j].label - cat_fp[cat] = cat_fp.get(cat, 0) + 1 - - all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp)) - per_category = { - cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0)) - for cat in all_categories - } - - return { - "global": _prf(tp, fp, fn), - "per_category": per_category, - "true_positives": tp, - "false_positives": fp, - "false_negatives": fn, - "hallucinated_entities": [ - {"label": hyps[j].label, "start": hyps[j].start, - "end": hyps[j].end, "text": hyps[j].text} - for j in sorted(unmatched_hyps) - ], - "missed_entities": [ - {"label": refs[i].label, "start": refs[i].start, - "end": refs[i].end, "text": refs[i].text} - for i in sorted(unmatched_refs) - ], - "iou_threshold": iou_threshold, - } - - -# ────────────────────────────────────────────────────────────────────────── -# Enregistrement dans le registre typé (Sprint 34) -# ────────────────────────────────────────────────────────────────────────── - - -@register_metric( - name="ner_f1", - input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES), - description=( - "F1 global sur les entités nommées (alignement IoU ≥ 0,5, " - "labels case-insensitive). Pour le détail par catégorie, " - "utiliser compute_ner_metrics directement." - ), - higher_is_better=True, - tags={"downstream", "ner", "structure"}, -) -def ner_f1( - reference_entities: Iterable[Entity | dict], - hypothesis_entities: Iterable[Entity | dict], -) -> float: - """F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``.""" - return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"] - +from picarones.measurements.ner import * # noqa: F401, F403 -__all__ = [ - "Entity", - "compute_ner_metrics", - "ner_f1", -] +import picarones.measurements.ner as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/ner_backends.py b/picarones/core/ner_backends.py index 4e944df927dec5919a3f89fc840d5e05b5af189e..5a65dfe1c5aa55670f21d31d268354d4c3ad4734 100644 --- a/picarones/core/ner_backends.py +++ b/picarones/core/ner_backends.py @@ -1,227 +1,19 @@ -"""Backends d'extraction d'entités nommées (Sprint 40). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.ner_backends`. -Suite directe du Sprint 38 : la couche de calcul (`compute_ner_metrics`) -prend deux listes d'entités, ce module fournit le moyen d'**obtenir** la -liste d'entités d'un côté à partir d'un texte (généralement la sortie -OCR du moteur). +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.ner_backends import ...``) de continuer +à fonctionner sans modification. -Architecture ------------- -- ``EntityExtractor`` : Protocol Python qui décrit l'interface ; tout - callable ``(text: str) -> list[dict]`` est un extracteur valide. Le - format de sortie est compatible ``EntitiesGT`` (Sprint 32) et - ``compute_ner_metrics`` (Sprint 38). -- ``SpacyEntityExtractor`` : implémentation par défaut, lazy-import de - spaCy. Si spaCy n'est pas installé OU si le modèle n'est pas - téléchargé, retourne ``[]`` avec un ``logger.warning`` explicite - (cf. règle CLAUDE.md : pas de ``except: pass``). -- ``SPACY_PROFILES`` : dict de profils nommés vers noms de modèles - spaCy (FR, EN, multilingue, HIPE pour les corpus historiques). -- ``get_extractor(profile)`` : factory qui retourne l'extracteur - correspondant au profil demandé. - -Découplage runner ↔ backend ---------------------------- -Le runner reçoit un ``EntityExtractor`` en paramètre — il n'importe -**jamais** spaCy directement. Cela permet : - -1. de **tester** sans dépendance externe (le test injecte un callable - qui simule l'extraction) ; -2. de **brancher** des backends alternatifs (Stanza, HIPE custom, - modèle fine-tuné maison) sans modifier le runner ; -3. de **désactiver** la métrique en passant ``None`` — comportement - par défaut, rétrocompat stricte. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Any, Protocol - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Interface -# ────────────────────────────────────────────────────────────────────────── - - -class EntityExtractor(Protocol): - """Tout callable ``(text) -> list[dict]`` est un extracteur valide. - - Format de sortie attendu : liste de dicts - ``{"label": str, "start": int, "end": int, "text": str}`` - compatibles avec ``compute_ner_metrics`` (Sprint 38) et - ``EntitiesGT`` (Sprint 32). - """ - - def __call__(self, text: str) -> list[dict[str, Any]]: ... - - -# ────────────────────────────────────────────────────────────────────────── -# Profils spaCy nommés -# ────────────────────────────────────────────────────────────────────────── - - -SPACY_PROFILES: dict[str, str] = { - "fr": "fr_core_news_sm", - "fr_lg": "fr_core_news_lg", - "en": "en_core_web_sm", - "en_lg": "en_core_web_lg", - "multilingual": "xx_ent_wiki_sm", - # HIPE 2022 — modèle historique multilingue (Hugging Face). Pas - # toujours disponible via ``spacy.load`` direct ; documenté pour - # mémoire, l'utilisateur peut le wrapper dans un EntityExtractor - # custom si besoin. - "hipe": "fr_core_news_lg", -} - - -# ────────────────────────────────────────────────────────────────────────── -# Backend spaCy -# ────────────────────────────────────────────────────────────────────────── - - -class SpacyEntityExtractor: - """Extracteur d'entités basé sur spaCy. - - Lazy-import : ``spacy`` n'est importé qu'au premier appel. Le - modèle est chargé une seule fois et mis en cache sur l'instance. - - Si spaCy n'est pas installé OU si le modèle demandé n'est pas - téléchargé, l'extracteur tombe en mode dégradé (retourne ``[]`` - pour chaque appel) et émet un ``logger.warning`` au premier - appel. - - Parameters - ---------- - model_name: - Nom du modèle spaCy à charger (ex. ``"fr_core_news_sm"``). - label_mapping: - Dict optionnel ``{spacy_label: target_label}`` pour - normaliser les labels (ex. spaCy utilise ``"PERSON"``, - on veut ``"PER"``). Si ``None``, garde les labels tels - quels. - - Examples - -------- - >>> extractor = SpacyEntityExtractor("fr_core_news_sm") - >>> entities = extractor("Marie de Bourgogne, en 1477.") - >>> # liste de dicts {label, start, end, text}, ou [] si spaCy absent - """ - - # Mapping par défaut spaCy → conventions HIPE/CoNLL courtes - DEFAULT_LABEL_MAPPING: dict[str, str] = { - "PERSON": "PER", - "PER": "PER", - "LOC": "LOC", - "GPE": "LOC", # Geo-Political Entity → LOC - "ORG": "ORG", - "DATE": "DATE", - "TIME": "DATE", - "MISC": "MISC", - } - - def __init__( - self, - model_name: str = "fr_core_news_sm", - label_mapping: dict[str, str] | None = None, - ) -> None: - self.model_name = model_name - self.label_mapping = ( - dict(label_mapping) - if label_mapping is not None - else dict(self.DEFAULT_LABEL_MAPPING) - ) - self._nlp: Any | None = None - self._loaded: bool = False - self._available: bool = False - - def _load(self) -> None: - """Charge spaCy + modèle au premier appel. Idempotent.""" - if self._loaded: - return - self._loaded = True - try: - import spacy # type: ignore[import-untyped] - except ImportError as exc: - logger.warning( - "[ner_backends] spaCy non installé (%s) — extraction NER " - "désactivée. Installer avec `pip install picarones[ner]`.", - exc, - ) - return - try: - self._nlp = spacy.load(self.model_name) - self._available = True - except OSError as exc: - logger.warning( - "[ner_backends] Modèle spaCy %r introuvable (%s) — extraction " - "NER désactivée. Télécharger avec `python -m spacy download %s`.", - self.model_name, exc, self.model_name, - ) - - @property - def available(self) -> bool: - """``True`` si spaCy + le modèle sont chargés et utilisables.""" - if not self._loaded: - self._load() - return self._available - - def __call__(self, text: str) -> list[dict[str, Any]]: - if not text: - return [] - if not self.available or self._nlp is None: - return [] - doc = self._nlp(text) - results: list[dict[str, Any]] = [] - for ent in doc.ents: - label = self.label_mapping.get(ent.label_, ent.label_) - results.append({ - "label": label, - "start": int(ent.start_char), - "end": int(ent.end_char), - "text": ent.text, - }) - return results - - -# ────────────────────────────────────────────────────────────────────────── -# Factory -# ────────────────────────────────────────────────────────────────────────── - - -def get_extractor(profile: str = "fr") -> SpacyEntityExtractor: - """Retourne un extracteur spaCy pour le profil demandé. - - Le profil peut être : - - - une clé de ``SPACY_PROFILES`` (ex. ``"fr"``, ``"en"``, - ``"multilingual"``) - - un nom de modèle spaCy direct (ex. ``"fr_core_news_lg"``) - - L'extracteur est instancié paresseusement (le modèle n'est chargé - qu'au premier appel). Si le modèle n'est pas disponible, - l'extracteur tombe en mode dégradé silencieux (retourne ``[]``). - """ - model_name = SPACY_PROFILES.get(profile, profile) - return SpacyEntityExtractor(model_name=model_name) - - -def is_spacy_available() -> bool: - """``True`` si la librairie ``spacy`` est importable, sans charger - de modèle.""" - try: - import spacy # noqa: F401 - except ImportError: - return False - return True - +from picarones.measurements.ner_backends import * # noqa: F401, F403 -__all__ = [ - "EntityExtractor", - "SpacyEntityExtractor", - "SPACY_PROFILES", - "get_extractor", - "is_spacy_available", -] +import picarones.measurements.ner_backends as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/normalization.py b/picarones/core/normalization.py index 6c33b33d4752d0c00715e8dfd6b068b75c773498..a215882abe759deeb0ba7a665f22237d7277117e 100644 --- a/picarones/core/normalization.py +++ b/picarones/core/normalization.py @@ -1,420 +1,19 @@ -"""Profils de normalisation unicode pour le calcul du CER diplomatique. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.normalization`. -La normalisation diplomatique permet de calculer un CER tenant compte des -équivalences graphiques propres aux documents historiques : ſ=s, u=v, i=j, etc. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.normalization import ...``) de continuer +à fonctionner sans modification. -En appliquant la même table aux deux textes (GT et OCR), on mesure les erreurs -"substantielles" (transcription erronée) en ignorant les variations graphiques -codifiées connues. - -Trois niveaux de normalisation sont disponibles : - -1. NFC : normalisation Unicode canonique (décomposition+recomposition) -2. caseless : NFC + pliage de casse (casefold) -3. diplomatic: NFC + table de correspondances historiques configurables - -Les profils préconfigurés couvrent les cas d'usage patrimoniaux courants. -Ils sont également chargeables depuis un fichier YAML. - -Exemple YAML ------------- -name: medieval_custom -caseless: false -diplomatic: - ſ: s - u: v - i: j - y: i - æ: ae - œ: oe +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import unicodedata -from dataclasses import dataclass, field -from pathlib import Path - - -# --------------------------------------------------------------------------- -# Tables de correspondances diplomatiques préconfigurées -# --------------------------------------------------------------------------- - -#: Français médiéval (XIIe–XVe siècle) -DIPLOMATIC_FR_MEDIEVAL: dict[str, str] = { - "ſ": "s", # s long → s - "u": "v", # u/v interchangeables en position initiale - "i": "j", # i/j interchangeables - "y": "i", # y vocalique → i - "æ": "ae", # ligature æ - "œ": "oe", # ligature œ - "ꝑ": "per", # abréviation per/par - "ꝓ": "pro", # abréviation pro - "\u0026": "et", # & → et -} - -#: Français moderne / imprimés anciens (XVIe–XVIIIe siècle) -DIPLOMATIC_FR_EARLY_MODERN: dict[str, str] = { - "ſ": "s", # s long - "æ": "ae", - "œ": "oe", - "\u0026": "et", - "ỹ": "yn", # y tilde -} - -#: Latin médiéval -DIPLOMATIC_LATIN_MEDIEVAL: dict[str, str] = { - "ſ": "s", - "u": "v", - "i": "j", - "y": "i", - "æ": "ae", - "œ": "oe", - "ꝑ": "per", - "ꝓ": "pro", - "ꝗ": "que", # q barré → que - "\u0026": "et", -} - -#: Profil minimal — uniquement NFC + s long -DIPLOMATIC_MINIMAL: dict[str, str] = { - "ſ": "s", -} - -#: Anglais moderne / imprimés anciens (XVIe–XVIIIe siècle) -#: Orthographe «early modern» : ſ=s, u/v, i/j, vv=w, þ=th, ð=th, ȝ=y -DIPLOMATIC_EN_EARLY_MODERN: dict[str, str] = { - "ſ": "s", # s long → s - "u": "v", # u/v interchangeables (vpon → upon) - "i": "j", # i/j interchangeables (ioy → joy) - "vv": "w", # vv → w (vvhich → which) - "þ": "th", # thorn → th - "ð": "th", # eth → th - "ȝ": "y", # yogh → y - "æ": "ae", # ligature æ - "œ": "oe", # ligature œ - "\u0026": "and", # & → and -} - -#: Anglais médiéval (XIIe–XVe siècle) — abréviations manuscrites incluses -DIPLOMATIC_EN_MEDIEVAL: dict[str, str] = { - "ſ": "s", - "u": "v", - "i": "j", - "vv": "w", - "þ": "th", - "ð": "th", - "ȝ": "y", - "æ": "ae", - "œ": "oe", - "\u0026": "and", - # Abréviations courantes dans les manuscrits anglais médiévaux - "ꝑ": "per", # p barré → per/par - "ꝓ": "pro", # p crocheté → pro - "ꝗ": "que", # q barré → que - "\ua75b": "r", # lettre r rotunda → r -} - -#: Écriture secrétaire (XVIe–XVIIe siècle) — secretary hand -#: Confusions visuelles propres à l'écriture cursive anglaise -DIPLOMATIC_EN_SECRETARY: dict[str, str] = { - "ſ": "s", - "u": "v", - "i": "j", - "vv": "w", - "þ": "th", - "ð": "th", - "ȝ": "y", - "\u0026": "and", - # Confusions visuelles typiques : e/c, n/u, m/w en secrétaire - # Note : ne pas normaliser e/c automatiquement (trop agressif) ; - # on se limite aux substituts graphiques historiquement documentés -} - - -# --------------------------------------------------------------------------- -# Profil de normalisation -# --------------------------------------------------------------------------- - -@dataclass -class NormalizationProfile: - """Décrit une stratégie de normalisation pour le calcul du CER diplomatique. - - Parameters - ---------- - name: - Identifiant lisible du profil (ex : ``"medieval_french"``). - nfc: - Applique la normalisation Unicode NFC (recommandé, activé par défaut). - caseless: - Pliage de casse (casefold) après NFC. - diplomatic_table: - Table de correspondances graphiques historiques appliquée caractère - par caractère sur les deux textes avant calcul du CER. - exclude_chars: - Ensemble de caractères supprimés des deux textes (GT et OCR) avant - tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique). - Utile pour ignorer la ponctuation ou les apostrophes. - description: - Description courte du profil (affichée dans le rapport HTML). - """ - - name: str - nfc: bool = True - caseless: bool = False - diplomatic_table: dict[str, str] = field(default_factory=dict) - exclude_chars: frozenset = field(default_factory=frozenset) - description: str = "" - - def normalize(self, text: str) -> str: - """Applique le profil de normalisation à un texte.""" - if self.exclude_chars: - text = "".join(c for c in text if c not in self.exclude_chars) - if self.nfc: - text = unicodedata.normalize("NFC", text) - if self.caseless: - text = text.casefold() - if self.diplomatic_table: - text = _apply_diplomatic_table(text, self.diplomatic_table) - return text - - def as_dict(self) -> dict: - return { - "name": self.name, - "nfc": self.nfc, - "caseless": self.caseless, - "diplomatic_table": self.diplomatic_table, - "exclude_chars": sorted(self.exclude_chars), - "description": self.description, - } - - @classmethod - def from_yaml(cls, path: str | Path) -> "NormalizationProfile": - """Charge un profil depuis un fichier YAML. - - Le fichier YAML doit contenir les clés ``name``, optionnellement - ``caseless``, ``description``, ``diplomatic`` (dict str→str) et - ``exclude_chars`` (liste ou chaîne de caractères à ignorer). - - Example - ------- - .. code-block:: yaml - - name: medieval_custom - caseless: false - description: Français médiéval personnalisé - exclude_chars: ".,;:!?" - diplomatic: - ſ: s - u: v - """ - try: - import yaml - except ImportError as exc: - raise RuntimeError( - "Le package 'pyyaml' est requis pour charger les profils YAML. " - "Installez-le avec : pip install pyyaml" - ) from exc - - data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) - return cls( - name=data.get("name", Path(path).stem), - nfc=bool(data.get("nfc", True)), - caseless=bool(data.get("caseless", False)), - diplomatic_table=data.get("diplomatic", {}), - exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), - description=data.get("description", ""), - ) - - @classmethod - def from_dict(cls, data: dict) -> "NormalizationProfile": - """Charge un profil depuis un dictionnaire (ex : section YAML inline).""" - return cls( - name=data.get("name", "custom"), - nfc=bool(data.get("nfc", True)), - caseless=bool(data.get("caseless", False)), - diplomatic_table=data.get("diplomatic", {}), - exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), - description=data.get("description", ""), - ) - - -# --------------------------------------------------------------------------- -# Profils préconfigurés -# --------------------------------------------------------------------------- - -NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = { - "nfc": NormalizationProfile( - name="nfc", - nfc=True, - caseless=False, - diplomatic_table={}, - description="Normalisation NFC uniquement", - ), - "caseless": NormalizationProfile( - name="caseless", - nfc=True, - caseless=True, - diplomatic_table={}, - description="NFC + insensible à la casse", - ), - "minimal": NormalizationProfile( - name="minimal", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_MINIMAL, - description="Minimal : NFC + s long seulement", - ), - "medieval_french": NormalizationProfile( - name="medieval_french", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_FR_MEDIEVAL, - description="Français médiéval (XIIe–XVe) : ſ=s, u=v, i=j, æ=ae, œ=oe", - ), - "early_modern_french": NormalizationProfile( - name="early_modern_french", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_FR_EARLY_MODERN, - description="Imprimés anciens (XVIe–XVIIIe) : ſ=s, æ=ae, œ=oe", - ), - "medieval_latin": NormalizationProfile( - name="medieval_latin", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_LATIN_MEDIEVAL, - description="Latin médiéval : ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro", - ), - "early_modern_english": NormalizationProfile( - name="early_modern_english", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_EN_EARLY_MODERN, - description="Early Modern English (XVIth–XVIIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", - ), - "medieval_english": NormalizationProfile( - name="medieval_english", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_EN_MEDIEVAL, - description="Medieval English (XIIth–XVth c.): ſ=s, u=v, i=j, þ=th, ȝ=y, ꝑ=per, ꝓ=pro", - ), - "secretary_hand": NormalizationProfile( - name="secretary_hand", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_EN_SECRETARY, - description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", - ), - # ── Profils d'exclusion de caractères ──────────────────────────────── - "sans_ponctuation": NormalizationProfile( - name="sans_ponctuation", - nfc=True, - caseless=False, - diplomatic_table={}, - exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()), - description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]", - ), - "sans_apostrophes": NormalizationProfile( - name="sans_apostrophes", - nfc=True, - caseless=False, - diplomatic_table={}, - exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique - description="NFC + suppression des apostrophes droite (') et typographique (\u2019)", - ), -} - - -def get_builtin_profile(name: str) -> NormalizationProfile: - """Retourne un profil préconfigurée par son identifiant. - - Identifiants disponibles - ------------------------ - - ``"medieval_french"`` : français médiéval XIIe–XVe (ſ=s, u=v, i=j, æ=ae, œ=oe…) - - ``"early_modern_french"`` : imprimés anciens XVIe–XVIIIe (ſ=s, œ=oe, æ=ae…) - - ``"medieval_latin"`` : latin médiéval (ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro…) - - ``"early_modern_english"`` : anglais imprimé XVIe–XVIIIe (ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y) - - ``"medieval_english"`` : anglais manuscrit XIIe–XVe (+ abréviations ꝑ, ꝓ…) - - ``"secretary_hand"`` : écriture secrétaire anglaise XVIe–XVIIe (cursive administrative) - - ``"minimal"`` : uniquement NFC + s long - - ``"nfc"`` : NFC seul (sans table diplomatique) - - ``"caseless"`` : NFC + pliage de casse - - Raises - ------ - KeyError - Si le nom n'est pas reconnu. - """ - if name not in NORMALIZATION_PROFILES: - raise KeyError( - f"Profil de normalisation inconnu : '{name}'. " - f"Disponibles : {', '.join(NORMALIZATION_PROFILES)}" - ) - return NORMALIZATION_PROFILES[name] - - -# --------------------------------------------------------------------------- -# Fonctions utilitaires -# --------------------------------------------------------------------------- - -def _parse_exclude_chars(value: "str | list | None") -> frozenset: - """Convertit une liste de caractères (str ou list) en frozenset. - - Accepte : - - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``) - ou simplement concaténés sans séparateur (ex. ``".,;:!?"``) - - Une liste Python/YAML de chaînes (chacune un caractère) - - None ou chaîne vide → frozenset vide - - Règle de désambiguïsation : si la chaîne contient la séquence ``", "`` - (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque - caractère Unicode est un item distinct. - """ - if not value: - return frozenset() - if isinstance(value, (list, tuple)): - return frozenset(str(c) for c in value if c) - raw = str(value) - # Désambiguïsation : séparer par ", " si présent (format lisible) - if ", " in raw: - return frozenset(c.strip() for c in raw.split(",") if c.strip()) - # Sinon, chaque caractère Unicode est un item distinct - return frozenset(raw) - - -def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str: - """Applique une table de correspondances diplomatiques en un seul pass. - - Les clés multi-caractères (ex : ``"ae"`` → ``"æ"``) sont gérées en priorité - sur les correspondances simples. Le remplacement est fait en un seul pass - via regex pour éviter les remplacements en cascade (ex : ``"ſ"→"s"`` puis - ``"s"→"z"`` donnerait ``"z"`` au lieu de ``"s"``). - """ - if not table: - return text - - import re - - # Séparer les clés simples (1 char) des clés multi-chars - multi_keys = sorted( - (k for k in table if len(k) > 1), key=len, reverse=True - ) - simple_table = {k: v for k, v in table.items() if len(k) == 1} - - if multi_keys: - # Single-pass : construire un pattern regex avec toutes les clés multi-chars - # triées par longueur décroissante pour matcher les plus longues d'abord - pattern = re.compile("|".join(re.escape(k) for k in multi_keys)) - text = pattern.sub(lambda m: table[m.group(0)], text) - - # Remplacements char par char (single-pass via itération) - if simple_table: - text = "".join(simple_table.get(c, c) for c in text) - - return text - +from picarones.measurements.normalization import * # noqa: F401, F403 -# Profil par défaut utilisé pour le CER diplomatique intégré -DEFAULT_DIPLOMATIC_PROFILE: NormalizationProfile = get_builtin_profile("medieval_french") +import picarones.measurements.normalization as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/numerical_sequences.py b/picarones/core/numerical_sequences.py index 5698b4017fa693cec17a6dd1671bfed7d1cab38c..cae7cb71c16619d10e1064c3b6aa5aa4e5a760b1 100644 --- a/picarones/core/numerical_sequences.py +++ b/picarones/core/numerical_sequences.py @@ -1,422 +1,19 @@ -"""Précision sur séquences numériques — Sprint 85 (A.II.5b). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.numerical_sequences`. -Sprint 85 — A.II.5b du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.numerical_sequences import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Pour un économiste-historien, un éditeur de chartes ou un -archiviste, la **fidélité aux séquences numériques** est un -proxy direct de la qualité éditoriale. Un OCR qui rate -*« 1789 »* dans une charte révolutionnaire ou *« f. 12v »* -dans une cote d'archives produit un corpus inutilisable pour la -recherche fine, même si le CER global est respectable. - -Catégories couvertes --------------------- -1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789`` - (le module détecte les **années** sur 4 chiffres dans la - plage [1000-2099]). -2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``. - Réutilise ``picarones.core.roman_numerals`` (Sprint 60). -3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``, - ``p. 5``, ``pp. 12-15``, ``n° 42``. -4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``, - ``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime - (``l.``, ``s.``, ``d.``). -5. **Années régnales** : ``an III``, ``l'an V``, ``an de - grâce 1450``, ``an de la République``. - -Méthode -------- -Pour chaque catégorie, on extrait les occurrences (regex -spécialisée) en GT et en hypothèse. On classe ensuite chaque -GT en **3 statuts** : - -- ``strict_preserved`` : forme exacte présente dans - l'hypothèse (sensible à la casse seulement pour la - foliotation, sinon la convention est documentée par - catégorie) ; -- ``value_preserved`` : la **valeur** apparaît même si la - forme diffère (ex. ``XIV`` GT et ``14`` hypothèse — - considéré comme valeur préservée mais forme non) ; -- ``lost`` : aucune trace exploitable. - -Sortie ------- -``compute_numerical_sequence_metrics(reference, hypothesis)`` -retourne : - -``` -{ - "global_strict_score": float, # ∈ [0, 1] - "global_value_score": float, # ∈ [0, 1] - "n_total": int, - "per_category": { - "year": {"n_total": int, "strict": int, "value": int, - "strict_score": float, "value_score": float, - "lost_items": list[str]}, - "roman": {...}, - "foliation": {...}, - "currency": {...}, - "regnal": {...}, - }, -} -``` - -Limites -------- -- Les regex sont **conservatrices** : on rate quelques - formes rares plutôt que de produire des faux positifs (par - exemple, ``mil cinq cens`` en français médiéval n'est pas - détecté comme année — la couche calcul s'en tient aux - formes les plus reconnaissables). Pour un corpus - spécifique, l'utilisateur peut composer ses propres - détecteurs et les passer via ``custom_detectors``. -- ``value_preserved`` exige une équivalence de **valeur - numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ; - ``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la - foliotation (recto/verso est une information distincte). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import re -from typing import Optional - -from picarones.core.metric_registry import register_metric -from picarones.core.modules import ArtifactType -from picarones.core.roman_numerals import ( - detect_roman_numerals, - roman_to_int, -) - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Constantes / catégories -# ────────────────────────────────────────────────────────────────────────── - - -CATEGORIES = ("year", "roman", "foliation", "currency", "regnal") - - -# Dates arabes — 4 chiffres dans la plage [1000-2099]. -# On exige une frontière de mot pour ne pas attraper -# « 12345 » (volume) ou « 0001 » (numéro de page). -_RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b") - - -# Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42 -# La capture conserve la forme intégrale (avec ponctuation et -# r/v) parce que recto/verso est une information distincte. -_RE_FOLIATION = re.compile( - r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n° - r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15) - r"\s*([rvRV])?", # suffixe optionnel r/v - re.UNICODE, -) - - -# Montants : nombre suivi d'une unité monétaire. -# On accepte espaces multiples mais pas de saut de ligne. -_RE_CURRENCY = re.compile( - r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal) - r"(livres?|sols?|deniers?|écus?|florins?|francs?|" - r"l\.|s\.|d\.|£|€|₣)" # unité - r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole - re.UNICODE | re.IGNORECASE, -) - - -# Années régnales : « an III », « an de grâce 1450 », -# « l'an V de la République ». -# Capture le numéral (romain ou arabe). -_RE_REGNAL = re.compile( - r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?" - r"([IVXLCDMivxlcdm]+|\d{1,4})\b", - re.UNICODE, -) - - -# ────────────────────────────────────────────────────────────────────────── -# Détection par catégorie -# ────────────────────────────────────────────────────────────────────────── - - -def _detect_years(text: str) -> list[tuple[str, int]]: - """Retourne [(forme, valeur)] pour chaque année 4 chiffres.""" - if not text: - return [] - return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)] - - -def _detect_romans_with_values(text: str) -> list[tuple[str, int]]: - """Numéraux romains accompagnés de leur valeur entière. - Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60), - qui retourne ``(start, form, value)``. - """ - if not text: - return [] - out: list[tuple[str, int]] = [] - for _start, form, value in detect_roman_numerals(text, min_length=2): - if value is not None: - out.append((form, value)) - return out - - -def _detect_foliations(text: str) -> list[tuple[str, str]]: - """Foliotation. Retourne [(forme_complète, clé_normalisée)] où la - clé inclut le suffixe r/v normalisé (recto/verso). - """ - if not text: - return [] - out: list[tuple[str, str]] = [] - for m in _RE_FOLIATION.finditer(text): - full = m.group(0).strip() - nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15" - suffix = (m.group(2) or "").lower() - key = f"{nums}{suffix}" - out.append((full, key)) - return out - - -def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]: - """Montants. Clé = (montant_normalisé, unité_canonique). - - L'unité canonique compresse les variantes (« livres » et - « livre » → « livre » ; « £ » reste « £ »). - """ - if not text: - return [] - canon = { - "livre": "livre", "livres": "livre", "l.": "livre", - "sol": "sol", "sols": "sol", "s.": "sol", - "denier": "denier", "deniers": "denier", "d.": "denier", - "écu": "écu", "écus": "écu", - "florin": "florin", "florins": "florin", - "franc": "franc", "francs": "franc", - "£": "£", "€": "€", "₣": "₣", - } - out: list[tuple[str, tuple[str, str]]] = [] - for m in _RE_CURRENCY.finditer(text): - amount = m.group(1).replace(",", ".") - unit_raw = m.group(2).lower() - unit = canon.get(unit_raw, unit_raw) - out.append((m.group(0), (amount, unit))) - return out - - -def _detect_regnal(text: str) -> list[tuple[str, int]]: - """Années régnales. Retourne [(forme, valeur_int)] avec la - valeur extraite (romain → int ou arabe → int). - """ - if not text: - return [] - out: list[tuple[str, int]] = [] - for m in _RE_REGNAL.finditer(text): - numeral = m.group(1) - value: Optional[int] - if numeral.isdigit(): - value = int(numeral) - else: - value = roman_to_int(numeral) - if value is not None: - out.append((m.group(0), value)) - return out - - -_DETECTORS = { - "year": _detect_years, - "roman": _detect_romans_with_values, - "foliation": _detect_foliations, - "currency": _detect_currencies, - "regnal": _detect_regnal, -} - - -# ────────────────────────────────────────────────────────────────────────── -# Calcul principal -# ────────────────────────────────────────────────────────────────────────── - - -def _classify_per_category( - gt_items: list, - hyp_items: list, - *, - form_extractor, - value_extractor, -) -> dict: - """Pour chaque item GT, le classe en strict_preserved / - value_preserved / lost. - - Multiplicité respectée : un item hypothèse ne peut servir - qu'à un seul match (forme prioritaire sur valeur). - """ - hyp_used = [False] * len(hyp_items) - n_strict = 0 - n_value = 0 - lost: list[str] = [] - # Première passe : matchs stricts (forme exacte) - matched: list[bool] = [False] * len(gt_items) - for gi, gt_item in enumerate(gt_items): - gt_form = form_extractor(gt_item) - for hi, hyp_item in enumerate(hyp_items): - if hyp_used[hi]: - continue - if form_extractor(hyp_item) == gt_form: - hyp_used[hi] = True - matched[gi] = True - n_strict += 1 - break - # Deuxième passe : matchs sur valeur (forme différente) - for gi, gt_item in enumerate(gt_items): - if matched[gi]: - n_value += 1 # strict implique value - continue - gt_val = value_extractor(gt_item) - for hi, hyp_item in enumerate(hyp_items): - if hyp_used[hi]: - continue - if value_extractor(hyp_item) == gt_val: - hyp_used[hi] = True - matched[gi] = True - n_value += 1 - break - if not matched[gi]: - lost.append(form_extractor(gt_item)) - n_total = len(gt_items) - return { - "n_total": n_total, - "strict": n_strict, - "value": n_value, - "strict_score": n_strict / n_total if n_total else 0.0, - "value_score": n_value / n_total if n_total else 0.0, - "lost_items": lost, - } - - -def compute_numerical_sequence_metrics( - reference: Optional[str], - hypothesis: Optional[str], -) -> dict: - """Calcule la précision sur séquences numériques. - - Returns - ------- - dict - Voir docstring du module. Si ``reference`` est vide - ou ne contient aucune séquence détectée, retourne - ``{n_total: 0, ...}`` avec scores à 0 (pas None). - """ - ref = reference or "" - hyp = hypothesis or "" - - # Spécifications par catégorie : (gt_items, hyp_items, - # extractor de forme, extractor de valeur). - specs: dict[str, dict] = {} - # year : (form="1789", value=1789) - specs["year"] = { - "gt": _detect_years(ref), - "hyp": _detect_years(hyp), - "form": lambda it: it[0], - "value": lambda it: it[1], - } - # roman : (form="MDCLXVIII", value=1668) - specs["roman"] = { - "gt": _detect_romans_with_values(ref), - "hyp": _detect_romans_with_values(hyp), - "form": lambda it: it[0], - "value": lambda it: it[1], - } - # foliation : (form="f. 12r", value="12r") - specs["foliation"] = { - "gt": _detect_foliations(ref), - "hyp": _detect_foliations(hyp), - "form": lambda it: it[0], - "value": lambda it: it[1], - } - # currency : (form="12 livres", value=("12", "livre")) - specs["currency"] = { - "gt": _detect_currencies(ref), - "hyp": _detect_currencies(hyp), - "form": lambda it: it[0], - "value": lambda it: it[1], - } - # regnal : (form="an III", value=3) - specs["regnal"] = { - "gt": _detect_regnal(ref), - "hyp": _detect_regnal(hyp), - "form": lambda it: it[0], - "value": lambda it: it[1], - } - - per_category: dict[str, dict] = {} - total = 0 - total_strict = 0 - total_value = 0 - for cat, spec in specs.items(): - breakdown = _classify_per_category( - spec["gt"], spec["hyp"], - form_extractor=spec["form"], - value_extractor=spec["value"], - ) - per_category[cat] = breakdown - total += breakdown["n_total"] - total_strict += breakdown["strict"] - total_value += breakdown["value"] - - return { - "n_total": total, - "global_strict_score": ( - total_strict / total if total else 0.0 - ), - "global_value_score": ( - total_value / total if total else 0.0 - ), - "per_category": per_category, - } - - -# ────────────────────────────────────────────────────────────────────────── -# Enregistrement registre typé -# ────────────────────────────────────────────────────────────────────────── - - -@register_metric( - name="numerical_sequence_strict_score", - input_types=(ArtifactType.TEXT, ArtifactType.TEXT), - description=( - "Précision sur séquences numériques en mode strict (forme " - "préservée). Couvre années arabes, numéraux romains, " - "foliotation, montants Ancien Régime, années régnales." - ), -) -def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float: - return compute_numerical_sequence_metrics( - reference, hypothesis, - )["global_strict_score"] - - -@register_metric( - name="numerical_sequence_value_score", - input_types=(ArtifactType.TEXT, ArtifactType.TEXT), - description=( - "Précision sur séquences numériques en mode valeur " - "(la valeur est préservée même si la forme diffère, " - "ex. XIV → 14)." - ), -) -def numerical_sequence_value_score(reference: str, hypothesis: str) -> float: - return compute_numerical_sequence_metrics( - reference, hypothesis, - )["global_value_score"] - +from picarones.measurements.numerical_sequences import * # noqa: F401, F403 -__all__ = [ - "CATEGORIES", - "compute_numerical_sequence_metrics", - "numerical_sequence_strict_score", - "numerical_sequence_value_score", -] +import picarones.measurements.numerical_sequences as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/numerical_sequences_runner.py b/picarones/core/numerical_sequences_runner.py index c1d6d1f429e85c11efa6a2a5a6db632c0566c976..7ddedcda5b0940a30fe2457f8c7e064808a582d1 100644 --- a/picarones/core/numerical_sequences_runner.py +++ b/picarones/core/numerical_sequences_runner.py @@ -1,102 +1,19 @@ -"""Câblage runner des séquences numériques (Sprint 86). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.numerical_sequences_runner`. -Sprint 86 — A.II.5b (vue HTML + câblage runner). +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.numerical_sequences_runner import ...``) de continuer +à fonctionner sans modification. -Le module ``picarones/core/numerical_sequences.py`` (Sprint 85) -a livré la couche de calcul. Ce helper prépare la donnée -adaptative pour le runner et agrège les compteurs par moteur. - -Adaptive masking ----------------- -On ne stocke le résultat que si la GT contient au moins une -séquence numérique détectée — sinon le module n'apparaît pas -dans le rapport. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Iterable, Optional - -from picarones.core.numerical_sequences import ( - CATEGORIES, - compute_numerical_sequence_metrics, -) - -logger = logging.getLogger(__name__) - - -def compute_numerical_sequence_metrics_adaptive( - reference: Optional[str], - hypothesis: Optional[str], -) -> Optional[dict]: - """Calcule les métriques séquences numériques avec masquage - adaptatif : retourne ``None`` si la GT n'en contient - aucune.""" - if not reference: - return None - result = compute_numerical_sequence_metrics(reference, hypothesis or "") - if (result.get("n_total") or 0) == 0: - return None - return result - - -def aggregate_numerical_sequence_metrics( - per_doc: Iterable[Optional[dict]], -) -> Optional[dict]: - """Agrège par moteur : somme les compteurs par catégorie et - recalcule les scores globaux et per-category. - - Format de sortie identique à ``compute_numerical_sequence_metrics`` - pour faciliter le rendu HTML symétrique. - """ - docs = [d for d in per_doc if d] - if not docs: - return None - total_n = 0 - total_strict = 0 - total_value = 0 - per_cat: dict[str, dict] = {} - for cat in CATEGORIES: - per_cat[cat] = { - "n_total": 0, - "strict": 0, - "value": 0, - "lost_items": [], - } - for d in docs: - for cat in CATEGORIES: - cat_data = (d.get("per_category") or {}).get(cat) or {} - per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0) - per_cat[cat]["strict"] += int(cat_data.get("strict") or 0) - per_cat[cat]["value"] += int(cat_data.get("value") or 0) - per_cat[cat]["lost_items"].extend( - cat_data.get("lost_items") or [], - ) - total_n += int(d.get("n_total") or 0) - # Recalcul des scores - for cat, slot in per_cat.items(): - n = slot["n_total"] - slot["strict_score"] = slot["strict"] / n if n else 0.0 - slot["value_score"] = slot["value"] / n if n else 0.0 - # Cap des lost_items à 50 par catégorie - slot["lost_items"] = slot["lost_items"][:50] - total_strict += slot["strict"] - total_value += slot["value"] - return { - "n_docs": len(docs), - "n_total": total_n, - "global_strict_score": ( - total_strict / total_n if total_n else 0.0 - ), - "global_value_score": ( - total_value / total_n if total_n else 0.0 - ), - "per_category": per_cat, - } - +from picarones.measurements.numerical_sequences_runner import * # noqa: F401, F403 -__all__ = [ - "compute_numerical_sequence_metrics_adaptive", - "aggregate_numerical_sequence_metrics", -] +import picarones.measurements.numerical_sequences_runner as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/pricing.py b/picarones/core/pricing.py index 2ebf1bea27ba9b17d80ba2f6d3f2e1c84e7192d3..ad9e3a56f088d384f275ef647b6cba6fe7e3d751 100644 --- a/picarones/core/pricing.py +++ b/picarones/core/pricing.py @@ -1,309 +1,19 @@ -"""Modélisation des coûts — APIs cloud et temps d'inférence local. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.pricing`. -Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5). -Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml`` -pour les hypothèses, dates et URLs de référence. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.pricing import ...``) de continuer +à fonctionner sans modification. -Conventions ------------ -- Unité monétaire : EUR (conversion indicative depuis USD quand applicable). -- Coût exprimé par **1 000 pages** traitées. -- Coût local = temps moyen d'inférence × taux horaire (paramétrable). -- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau - d'exécution (mix France bas carbone par défaut pour le local, - moyenne cloud hyperscaler pour les APIs). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations +from picarones.measurements.pricing import * # noqa: F401, F403 -import logging -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - -import yaml - -logger = logging.getLogger(__name__) - -_DEFAULT_PRICING_PATH = Path(__file__).parent.parent / "data" / "pricing.yaml" - - -@dataclass(frozen=True) -class PricingDefaults: - """Valeurs par défaut du fichier de prix (section ``meta``).""" - - last_updated: Optional[str] = None - currency: str = "EUR" - hourly_rate_local_cpu_eur: float = 0.08 - hourly_rate_local_gpu_eur: float = 1.20 - grid_intensity_local: float = 58.0 - grid_intensity_cloud: float = 380.0 - - -@dataclass -class EngineCost: - """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses. - - La représentation est immuable après construction : une fois que l'utilisateur - a choisi un taux horaire local, toutes les instances partagent cette - hypothèse par injection explicite dans ``build_costs_for_benchmark``. - """ - - engine_key: str - """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``).""" - - type: str # "local" | "cloud_api" | "unknown" - - cost_per_1k_pages_eur: Optional[float] = None - """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes.""" - - currency: str = "EUR" - - # Source / date - pricing_source_url: Optional[str] = None - pricing_date: Optional[str] = None - - # Pour les APIs cloud : prix brut - api_price_per_1k_pages: Optional[float] = None - - # Pour le local : temps d'inférence et taux horaire utilisés - local_mean_seconds_per_page: Optional[float] = None - hourly_rate_eur: Optional[float] = None - - # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport) - kwh_per_1k_pages: Optional[float] = None - grid_intensity_g_co2_per_kwh: Optional[float] = None - co2_per_1k_pages_g: Optional[float] = None - - notes: Optional[str] = None - - assumptions: list[str] = field(default_factory=list) - """Liste d'hypothèses textuelles à afficher sous le graphique.""" - - def as_dict(self) -> dict: - return { - "engine_key": self.engine_key, - "type": self.type, - "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, - "currency": self.currency, - "pricing_source_url": self.pricing_source_url, - "pricing_date": self.pricing_date, - "api_price_per_1k_pages": self.api_price_per_1k_pages, - "local_mean_seconds_per_page": self.local_mean_seconds_per_page, - "hourly_rate_eur": self.hourly_rate_eur, - "kwh_per_1k_pages": self.kwh_per_1k_pages, - "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh, - "co2_per_1k_pages_g": self.co2_per_1k_pages_g, - "notes": self.notes, - "assumptions": list(self.assumptions), - } - - -def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]: - """Charge la table de prix YAML. - - Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict - ``{engine_key: raw_entry}``. - """ - path = Path(path) if path else _DEFAULT_PRICING_PATH - if not path.exists(): - logger.warning("[pricing] fichier %s introuvable", path) - return PricingDefaults(), {} - try: - with path.open(encoding="utf-8") as fh: - data = yaml.safe_load(fh) or {} - except yaml.YAMLError as e: - logger.warning("[pricing] échec parsing %s : %s", path, e) - return PricingDefaults(), {} - - meta = data.get("meta", {}) or {} - defaults = PricingDefaults( - last_updated=meta.get("last_updated"), - currency=meta.get("currency", "EUR"), - hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)), - hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)), - grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)), - grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)), - ) - engines_table = data.get("engines", {}) or {} - return defaults, engines_table - - -def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]: - """Cherche la meilleure clé pour ce moteur dans la table. - - Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le - nom OCR, puis un match partiel (substring) comme filet de sécurité. - """ - candidates = [llm_model, engine_name] - for c in candidates: - if c and c in table: - return c - # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision" - for c in candidates: - if not c: - continue - for key in table: - if key in c: - return key - return None - - -def estimate_cost( - engine_name: str, - *, - llm_model: Optional[str] = None, - is_pipeline: bool = False, - measured_seconds_per_page: Optional[float] = None, - table: Optional[dict] = None, - defaults: Optional[PricingDefaults] = None, - hourly_rate_override_eur: Optional[float] = None, -) -> EngineCost: - """Calcule le ``EngineCost`` pour un moteur donné. - - Parameters - ---------- - engine_name: - Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``). - llm_model: - Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la - lookup car c'est lui qui domine le coût. - is_pipeline: - Indique un pipeline OCR+LLM (change la sémantique de lookup). - measured_seconds_per_page: - Temps moyen observé sur le benchmark courant. Remplace la valeur - indicative de la table si fournie (plus fiable). - table, defaults: - Overrides pour tests ou usage institutionnel. - hourly_rate_override_eur: - Taux horaire à utiliser pour le calcul local (sinon valeur table - ou défaut). - """ - if table is None or defaults is None: - _defaults, _table = load_pricing_database() - defaults = defaults or _defaults - table = table or _table - - key = _match_key(engine_name, llm_model if is_pipeline else None, table) - if key is None: - return EngineCost( - engine_key=engine_name, - type="unknown", - assumptions=["Aucune entrée dans la table de prix pour ce moteur."], - ) - - entry = table[key] - etype = str(entry.get("type", "unknown")) - notes = entry.get("notes") - assumptions: list[str] = [] - currency = defaults.currency - - cost_eur: Optional[float] = None - api_price: Optional[float] = None - local_seconds = measured_seconds_per_page - hourly_rate = None - - if etype == "cloud_api": - api_price = entry.get("api_price_per_1k_pages") - if api_price is not None: - cost_eur = float(api_price) - assumptions.append( - f"Prix API indicatif : {cost_eur:.2f} €/1000 pages " - f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})." - ) - elif etype == "local": - indicative_seconds = entry.get("local_mean_seconds_per_page") - if local_seconds is None and indicative_seconds is not None: - local_seconds = float(indicative_seconds) - assumptions.append( - f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)." - ) - elif local_seconds is not None: - assumptions.append( - f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)." - ) - - hourly_rate = ( - hourly_rate_override_eur - if hourly_rate_override_eur is not None - else entry.get("hourly_rate_override_eur") - ) - if hourly_rate is None: - # Heuristique : si l'entrée précise un override GPU, sinon CPU - hourly_rate = ( - defaults.hourly_rate_local_gpu_eur - if "gpu" in str(notes or "").lower() - else defaults.hourly_rate_local_cpu_eur - ) - hourly_rate = float(hourly_rate) - - if local_seconds is not None and hourly_rate is not None: - cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0 - assumptions.append( - f"Taux horaire appliqué : {hourly_rate:.2f} €/h " - f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})." - ) - - # Empreinte carbone optionnelle - kwh_1k = entry.get("kwh_per_1k_pages") - grid = ( - entry.get("grid_intensity_g_co2_per_kwh") - or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local) - ) - co2_g = None - if kwh_1k is not None and grid is not None: - co2_g = float(kwh_1k) * float(grid) - - return EngineCost( - engine_key=key, - type=etype, - cost_per_1k_pages_eur=cost_eur, - currency=currency, - pricing_source_url=entry.get("pricing_source_url"), - pricing_date=entry.get("pricing_date"), - api_price_per_1k_pages=api_price, - local_mean_seconds_per_page=local_seconds, - hourly_rate_eur=hourly_rate, - kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None, - grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None, - co2_per_1k_pages_g=co2_g, - notes=notes, - assumptions=assumptions, - ) - - -def build_costs_for_benchmark( - engines_summary: list[dict], - durations_by_engine: dict[str, float], - *, - hourly_rate_local_eur: Optional[float] = None, - pricing_path: Optional[Path] = None, -) -> dict[str, dict]: - """Calcule le coût de chaque moteur d'un benchmark. - - Returns - ------- - dict ``{engine_name: EngineCost.as_dict()}``. - """ - defaults, table = load_pricing_database(pricing_path) - out: dict[str, dict] = {} - for e in engines_summary: - name = e.get("name") - if not name: - continue - measured = durations_by_engine.get(name) - llm_model = None - pipeline_info = e.get("pipeline_info") or {} - if pipeline_info: - llm_model = pipeline_info.get("llm_model") - cost = estimate_cost( - engine_name=name, - llm_model=llm_model, - is_pipeline=bool(e.get("is_pipeline")), - measured_seconds_per_page=measured, - table=table, - defaults=defaults, - hourly_rate_override_eur=hourly_rate_local_eur, - ) - out[name] = cost.as_dict() - return out +import picarones.measurements.pricing as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/rare_tokens.py b/picarones/core/rare_tokens.py index 69f320e2c1b1922285c16f708f74240b51713709..4d57c79e5a5a3ddd931846103042365fb241a9f5 100644 --- a/picarones/core/rare_tokens.py +++ b/picarones/core/rare_tokens.py @@ -1,254 +1,19 @@ -"""Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.rare_tokens`. -Pourquoi ce module ------------------- -Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en -masquant des **erreurs systématiques sur les tokens rares** : noms -propres, toponymes peu fréquents, mots techniques, formules latines -récurrentes mais pas dominantes. Pour un usage prosopographique -(indexation de noms, recherche généalogique), ce sont précisément -ces tokens-là qui comptent. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.rare_tokens import ...``) de continuer +à fonctionner sans modification. -Ce module mesure le **rappel sur les tokens rares** d'un corpus — -défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax + -dis legomena, terminologie de lexicométrie classique). - -Hypothèse à valider expérimentalement -------------------------------------- -La conjecture du plan A.I.1 : *« cette métrique discrimine plus -les moteurs que le CER global »*. Si confirmée sur un corpus -patrimonial réel, elle gagne sa place dans le tableau de -classement principal — décision laissée au chercheur après -observation. - -Stratégie de découpage ----------------------- -Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche -de calcul pure d'abord, sans intégration runner. La vue HTML -« worst lines / rare tokens manqués » suit dans un sprint dédié. - -Pas d'enregistrement dans le registre typé Sprint 34 ----------------------------------------------------- -La métrique exige **trois entrées** (reference, hypothesis, set -des tokens rares) et le set des rares est calculé corpus-wide -(donc connu seulement après itération sur tout le corpus). La -signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur -appelle explicitement ``compute_rare_token_recall`` avec le set -qu'il a calculé. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import re -from collections import Counter -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Tokenisation Unicode-aware -# ────────────────────────────────────────────────────────────────────────── - -# Token = séquence maximale de caractères de mot Unicode (\w en -# Python 3 utilise déjà la table Unicode), incluant l'apostrophe -# typographique '’' à l'intérieur (« l'an », « d’une ») et les -# tirets internes (« peut-être »). La ponctuation isolée et les -# espaces sont des séparateurs. - -_TOKEN_RE = re.compile( - r"\w+(?:[’'\-]\w+)*", - flags=re.UNICODE, -) - - -def tokenize(text: Optional[str]) -> list[str]: - """Tokenisation Unicode-aware. - - Conserve les contractions (``l'an``, ``d’une``) et les mots - composés (``peut-être``, ``c'est-à-dire``) comme un seul token. - Casse préservée — l'utilisateur normalise lui-même via - ``case_sensitive=False`` dans les fonctions aval s'il le veut. - """ - if not text: - return [] - return _TOKEN_RE.findall(text) - - -# ────────────────────────────────────────────────────────────────────────── -# Distribution de fréquence corpus-wide -# ────────────────────────────────────────────────────────────────────────── - - -def frequency_distribution( - documents: Iterable[str], - *, - case_sensitive: bool = False, -) -> Counter[str]: - """Calcule ``{token: count}`` sur l'ensemble du corpus. - - Parameters - ---------- - documents: - Itérable de textes (typiquement les ``ground_truth`` des - documents du corpus). - case_sensitive: - Si ``False`` (défaut), tous les tokens sont mis en - minuscule avant comptage. - """ - counter: Counter[str] = Counter() - for doc in documents: - tokens = tokenize(doc) - if not case_sensitive: - tokens = [t.lower() for t in tokens] - counter.update(tokens) - return counter - - -def extract_rare_tokens( - documents: Iterable[str], - *, - max_freq: int = 2, - case_sensitive: bool = False, -) -> frozenset[str]: - """Retourne l'ensemble des tokens dont la fréquence - corpus-wide est ``≤ max_freq``. - - Convention de lexicométrie : ``max_freq=1`` retourne uniquement - les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne - hapax + dis legomena (≤ 2 occurrences) — défaut. - - Les tokens qui n'apparaissent **jamais** dans le corpus ne sont - évidemment pas inclus (le ``Counter`` ne les liste pas). - """ - if max_freq < 1: - raise ValueError("max_freq doit être ≥ 1") - counter = frequency_distribution( - documents, case_sensitive=case_sensitive, - ) - return frozenset(t for t, c in counter.items() if c <= max_freq) - - -# ────────────────────────────────────────────────────────────────────────── -# Calcul du rappel par document -# ────────────────────────────────────────────────────────────────────────── - - -def compute_rare_token_recall( - reference: Optional[str], - hypothesis: Optional[str], - rare_tokens: Iterable[str], - *, - case_sensitive: bool = False, -) -> dict: - """Calcule le rappel sur les tokens rares présents dans la GT. - - Parameters - ---------- - reference: - Texte GT du document. - hypothesis: - Texte produit par l'OCR. - rare_tokens: - Itérable des tokens rares — typiquement le résultat de - ``extract_rare_tokens`` sur le corpus complet. - case_sensitive: - Si ``False`` (défaut), la comparaison se fait sur les - formes minuscules. - - Returns - ------- - dict - ``{ - "n_rare_tokens_in_reference": int, - # nombre d'**occurrences** de tokens rares dans la GT - # (multiplicité préservée — un token rare présent 2 - # fois compte 2) - "n_rare_tokens_recalled": int, - # nombre d'occurrences correctement présentes dans hyp - # (alignement bag-of-tokens : min(count_ref, count_hyp)) - "recall": float, - # ratio dans [0, 1], ou 0.0 si aucun rare en GT - "missed_tokens": list[str], - # liste des tokens rares **manqués** (avec multiplicité, - # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp → - # missed_tokens contient ["Dupont"] une fois) - }`` - - Cas dégénérés - ------------- - - GT vide ou aucun token rare présent → recall = 0.0, listes - vides (convention : on ne récompense pas l'absence de - tokens rares). - - Hyp vide avec rares en GT → tous manqués, recall = 0.0. - """ - ref = reference or "" - hyp = hypothesis or "" - - if case_sensitive: - rare_set = frozenset(rare_tokens) - ref_tokens = tokenize(ref) - hyp_tokens = tokenize(hyp) - else: - rare_set = frozenset(t.lower() for t in rare_tokens) - ref_tokens = [t.lower() for t in tokenize(ref)] - hyp_tokens = [t.lower() for t in tokenize(hyp)] - - # Multiplicité : on compte uniquement les rares présents dans la GT - ref_rare_counts: Counter[str] = Counter( - t for t in ref_tokens if t in rare_set - ) - n_rare_in_ref = sum(ref_rare_counts.values()) - if n_rare_in_ref == 0: - return { - "n_rare_tokens_in_reference": 0, - "n_rare_tokens_recalled": 0, - "recall": 0.0, - "missed_tokens": [], - } - - # Bag-of-tokens dans hyp pour les tokens rares uniquement - hyp_rare_counts: Counter[str] = Counter( - t for t in hyp_tokens if t in rare_set - ) - # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count) - n_recalled = 0 - missed: list[str] = [] - for token, ref_count in ref_rare_counts.items(): - hyp_count = hyp_rare_counts.get(token, 0) - recalled = min(ref_count, hyp_count) - n_recalled += recalled - missed_count = ref_count - recalled - if missed_count > 0: - missed.extend([token] * missed_count) - - return { - "n_rare_tokens_in_reference": n_rare_in_ref, - "n_rare_tokens_recalled": n_recalled, - "recall": n_recalled / n_rare_in_ref, - "missed_tokens": missed, - } - - -def rare_token_recall( - reference: Optional[str], - hypothesis: Optional[str], - rare_tokens: Iterable[str], - *, - case_sensitive: bool = False, -) -> float: - """Raccourci : retourne uniquement le rappel ∈ [0, 1].""" - return compute_rare_token_recall( - reference, hypothesis, rare_tokens, - case_sensitive=case_sensitive, - )["recall"] - +from picarones.measurements.rare_tokens import * # noqa: F401, F403 -__all__ = [ - "tokenize", - "frequency_distribution", - "extract_rare_tokens", - "compute_rare_token_recall", - "rare_token_recall", -] +import picarones.measurements.rare_tokens as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/readability.py b/picarones/core/readability.py index 941709c25b9f539366eb9c1a15a8625a97820d88..1f0b31c9639895ee78d773aa51ec6dd2ae8f5d17 100644 --- a/picarones/core/readability.py +++ b/picarones/core/readability.py @@ -1,252 +1,19 @@ -"""Métriques de lisibilité (Flesch) — Sprint 52. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.readability`. -Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure -de la métrique Flesch, indépendante de tout alignement OCR/GT. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.readability import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Les LLM produisent du texte plus « lisse » que les manuscrits -historiques. Cette tendance à la modernisation est mesurable par la -différence de score de lisibilité entre la GT et la sortie OCR/LLM — -**indépendamment des classes taxonomiques** et **sans alignement -caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne -même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte -moderne plausible mais déconnecté de la GT). - -Stratégie de découpage ----------------------- -Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on -découpe : - -- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et - ``flesch_delta``. Aucune dépendance externe ; les heuristiques de - comptage de syllabes sont en pur Python, déterministes, testées. -- **Sprints suivants** — câblage runner pour calculer - ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML. - -Formules --------- -- **Anglais** (Flesch original 1948) : - ``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)`` -- **Français** (Kandel-Moles 1958) : - ``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)`` - -Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire », -0 ↔ « très difficile ». Une **augmentation** du score quand on passe -de la GT à l'OCR signale une simplification (typique des LLM -modernisants). Une **chute** signale une dégradation OCR. - -Limites documentées -------------------- -- Le comptage de syllabes est heuristique. En français, des règles - comme « -ier non final = 2 syllabes » ne sont pas appliquées - finement. Acceptable pour une métrique de **comparaison relative** - (delta GT vs OCR), pas pour publier une absolue. -- Sur des textes très courts (< 20 mots), la formule perd en - fiabilité. Le seuil minimal est documenté. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import re -from typing import Literal - -from picarones.core.metric_registry import register_metric -from picarones.core.modules import ArtifactType - -logger = logging.getLogger(__name__) - - -Language = Literal["fr", "en"] - -# Coefficients de la formule Flesch selon la langue. -_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = { - "en": (206.835, 1.015, 84.6), # Flesch 1948 - "fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958 -} - -# Voyelles utilisées pour l'heuristique de comptage de syllabes. -# On utilise un set qui inclut les diacritiques courantes en FR/EN. -_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ") - -# Regex de découpage en phrases : ponctuation finale + espace ou fin. -# Tolère les multiples points (« ... ») et garde un découpage robuste. -_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)") - -# Regex de tokenisation simple (mots) : séquences de caractères "lettres". -_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE) - - -# ────────────────────────────────────────────────────────────────────────── -# Compteurs de base -# ────────────────────────────────────────────────────────────────────────── - - -def count_words(text: str) -> int: - """Nombre de mots (tokens alphanumériques) dans ``text``.""" - if not text: - return 0 - return len(_WORD_RE.findall(text)) - - -def count_sentences(text: str) -> int: - """Nombre de phrases dans ``text``. - - Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``). - Renvoie au minimum 1 si ``text`` contient au moins un mot, pour - éviter une division par zéro dans la formule de Flesch sur les - textes sans ponctuation finale. - """ - if not text: - return 0 - parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()] - n = len(parts) - if n == 0 and count_words(text) > 0: - return 1 - return n - - -def count_syllables_word(word: str) -> int: - """Heuristique de comptage de syllabes pour un mot isolé. - - Règle : on compte les **groupes de voyelles consécutives** (en - incluant ``y`` et les diacritiques courantes). C'est une - approximation grossière mais déterministe et testable. - - Cas limites : - - mot vide → 0 - - mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``) - - mot d'une seule voyelle isolée → 1 - """ - if not word: - return 0 - word = word.lower() - in_vowel_group = False - count = 0 - for ch in word: - if ch in _VOWELS: - if not in_vowel_group: - count += 1 - in_vowel_group = True - else: - in_vowel_group = False - return count or 1 - - -def count_syllables(text: str) -> int: - """Somme des syllabes de tous les mots de ``text``.""" - if not text: - return 0 - return sum(count_syllables_word(w) for w in _WORD_RE.findall(text)) - - -# ────────────────────────────────────────────────────────────────────────── -# Score Flesch -# ────────────────────────────────────────────────────────────────────────── - - -def flesch_score(text: str, lang: Language = "fr") -> float: - """Calcule le score de lisibilité Flesch pour ``text``. - - Parameters - ---------- - text: - Texte à évaluer. Peut contenir ponctuation, accents, etc. - lang: - ``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948). - - Returns - ------- - float - Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte - vide ou sans mot exploitable. - - Notes - ----- - Le score chute fortement avec : - - longues phrases (mots/phrases élevé) - - mots polysyllabiques (syllabes/mots élevé) - Une montée du score lors du passage GT → OCR signale qu'un LLM a - « lissé » la langue (phrases plus courtes, mots plus communs). - """ - if lang not in _FLESCH_COEFFS: - raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.") - - n_words = count_words(text) - if n_words == 0: - return 0.0 - n_sentences = max(1, count_sentences(text)) - n_syllables = count_syllables(text) - if n_syllables == 0: - return 0.0 - - base, k_words, k_syll = _FLESCH_COEFFS[lang] - raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words) - return max(0.0, min(100.0, raw)) - - -def flesch_delta( - reference: str, - hypothesis: str, - lang: Language = "fr", -) -> float: - """Différence ``flesch_score(hypothesis) - flesch_score(reference)``. - - Interprétation - -------------- - - **Positif** : l'hypothèse OCR est plus lisible que la GT — - signal d'**over-normalisation** (typique des LLM qui modernisent - des textes anciens). - - **Négatif** : l'OCR est moins lisible — signal de dégradation - (caractères mal reconnus brisent la fluidité). - - **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique. - - Borné dans ``[-100, +100]``. - """ - return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang) - - -# ────────────────────────────────────────────────────────────────────────── -# Enregistrement dans le registre typé (Sprint 34) -# ────────────────────────────────────────────────────────────────────────── - - -@register_metric( - name="flesch_delta_fr", - input_types=(ArtifactType.TEXT, ArtifactType.TEXT), - description=( - "Différence de score Flesch (Kandel-Moles, FR) entre la sortie " - "OCR et la GT. Positif = OCR plus lisible (signal " - "d'over-normalisation LLM). Aucun alignement requis." - ), - higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant - tags={"text", "readability", "over_normalization"}, -) -def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float: - return flesch_delta(reference, hypothesis, lang="fr") - - -@register_metric( - name="flesch_delta_en", - input_types=(ArtifactType.TEXT, ArtifactType.TEXT), - description=( - "Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. " - "Positive = OCR easier to read than GT (LLM smoothing signal). " - "No alignment required." - ), - higher_is_better=False, - tags={"text", "readability", "over_normalization"}, -) -def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float: - return flesch_delta(reference, hypothesis, lang="en") - +from picarones.measurements.readability import * # noqa: F401, F403 -__all__ = [ - "flesch_score", - "flesch_delta", - "count_words", - "count_sentences", - "count_syllables", - "count_syllables_word", -] +import picarones.measurements.readability as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/readability_runner.py b/picarones/core/readability_runner.py index 073b5f16fca2a59993e08453c191f915d8f75405..ed22538b086b1b02810856d5dd7aa253a22a739a 100644 --- a/picarones/core/readability_runner.py +++ b/picarones/core/readability_runner.py @@ -1,114 +1,19 @@ -"""Câblage runner du delta Flesch (Sprint 87 — A.II.2). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.readability_runner`. -Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch -livré par le Sprint 52). +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.readability_runner import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le ``flesch_delta`` mesure la différence de lisibilité entre la -GT et la sortie OCR. Un score positif signale une *over- -normalisation* typique des LLM/VLM qui modernisent un texte -ancien (le Flesch monte parce que les mots sont plus simples) ; -un score négatif signale une dégradation OCR brutale. - -Cette métrique est calculée **automatiquement** par le runner -sur chaque document, agrégée par moteur, et présentée dans le -rapport. - -Adaptive masking ----------------- -On ne calcule que si la GT contient ≥ 5 mots — en dessous, le -Flesch est trop instable pour être informatif. - -Langue ------- -Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour -les corpus mixtes, l'utilisateur peut passer une langue -explicite à l'orchestrateur. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import statistics -from typing import Iterable, Optional - -from picarones.core.readability import ( - Language, - count_words, - flesch_delta, - flesch_score, -) - -logger = logging.getLogger(__name__) - - -_MIN_WORDS_FOR_FLESCH = 5 - - -def compute_readability_metrics( - reference: Optional[str], - hypothesis: Optional[str], - *, - lang: Language = "fr", -) -> Optional[dict]: - """Calcule le delta Flesch d'un document avec adaptive masking. - - Retourne ``None`` si la GT contient moins de - ``_MIN_WORDS_FOR_FLESCH`` mots. - """ - ref = reference or "" - n_ref_words = count_words(ref) - if n_ref_words < _MIN_WORDS_FOR_FLESCH: - return None - hyp = hypothesis or "" - flesch_ref = flesch_score(ref, lang=lang) - flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None - delta = ( - flesch_delta(ref, hyp, lang=lang) if hyp else None - ) - return { - "lang": lang, - "flesch_reference": flesch_ref, - "flesch_hypothesis": flesch_hyp, - "flesch_delta": delta, - "n_words_reference": n_ref_words, - } - - -def aggregate_readability_metrics( - per_doc: Iterable[Optional[dict]], -) -> Optional[dict]: - """Agrège : moyenne/médiane des deltas + part de docs - « over-normalisés » (delta > +5 points). - """ - docs = [d for d in per_doc if d] - if not docs: - return None - deltas = [ - float(d["flesch_delta"]) for d in docs - if isinstance(d.get("flesch_delta"), (int, float)) - ] - if not deltas: - return None - over_norm = sum(1 for d in deltas if d > 5.0) - under_norm = sum(1 for d in deltas if d < -5.0) - lang = docs[0].get("lang") or "fr" - return { - "lang": lang, - "n_docs": len(docs), - "n_docs_with_delta": len(deltas), - "delta_mean": statistics.fmean(deltas), - "delta_median": statistics.median(deltas), - "delta_min": min(deltas), - "delta_max": max(deltas), - "n_over_normalized": over_norm, - "n_under_normalized": under_norm, - "over_normalized_rate": over_norm / len(deltas), - } - +from picarones.measurements.readability_runner import * # noqa: F401, F403 -__all__ = [ - "compute_readability_metrics", - "aggregate_readability_metrics", -] +import picarones.measurements.readability_runner as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/reading_order.py b/picarones/core/reading_order.py index 655557dcffc56ed40554a1ea2369542f635859a1..65cc1152ef25eabec802f71e4eedb0ca3fa309f7 100644 --- a/picarones/core/reading_order.py +++ b/picarones/core/reading_order.py @@ -1,196 +1,19 @@ -"""Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.reading_order`. -Sprint 53 — A.II.2.1 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.reading_order import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Sur un manuscrit glosé, un journal multi-colonnes ou un registre -paroissial complexe, le **classement des moteurs en CER** peut être -trompeur : un moteur peut avoir un excellent CER caractère et un -**ordre de lecture catastrophique**. Le résultat est inutilisable -pour la recherche plein texte (Elastic, Solr) ou pour reconstituer -une narration linéaire. - -La métrique standard est définie par Antonacopoulos et al. dans -ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions -ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède -``b`` dans la GT : - -- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse, -- **FN** si la paire est manquante (régions absentes ou ordre - inversé) côté hypothèse, -- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que - la GT n'a pas cet ordre (régions hallucinées ou inversion). - -Le F1 est la moyenne harmonique des deux. - -Stratégie de découpage ----------------------- -Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch -(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit -deux listes ordonnées d'IDs de régions (typiquement extraites de -ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML -suivent dans des sprints dédiés. - -Compatible directement avec ``ReadingOrderGT`` du Sprint 32 : -``ReadingOrderGT.region_order`` est exactement le format attendu. - -Convention sur les régions --------------------------- -- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.). -- Les **doublons** sont ignorés au calcul des paires ordonnées - (chaque ID compte une fois par séquence). -- Une région présente dans la GT mais absente de l'hypothèse - contribue aux paires FN. -- Une région présente dans l'hypothèse mais absente de la GT - contribue aux paires FP. -- Si une séquence a < 2 régions distinctes, aucune paire n'est - émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux - séquences soient identiques. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from itertools import combinations -from typing import Iterable - -from picarones.core.metric_registry import register_metric -from picarones.core.modules import ArtifactType - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────────────────── - - -def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]: - """Retourne l'ensemble des paires ``(a, b)`` telles que ``a`` - précède strictement ``b`` dans ``sequence``. - - Doublons : chaque ID est traité une seule fois (première occurrence - dans la séquence). Cohérent avec ICDAR 2015 où les régions ont - des IDs uniques. - """ - seen: list[str] = [] - seen_set: set[str] = set() - for r in sequence: - if r not in seen_set: - seen.append(r) - seen_set.add(r) - return set(combinations(seen, 2)) - - -def _normalize_input(value: Iterable[str] | None) -> list[str]: - """Coerce une entrée en list[str], en filtrant les valeurs vides.""" - if value is None: - return [] - return [str(v) for v in value if v is not None and str(v).strip()] - - -# ────────────────────────────────────────────────────────────────────────── -# Métrique principale -# ────────────────────────────────────────────────────────────────────────── - - -def compute_reading_order_metrics( - reference_order: Iterable[str] | None, - hypothesis_order: Iterable[str] | None, -) -> dict: - """Calcule precision / recall / F1 sur l'ordre relatif des régions. - - Parameters - ---------- - reference_order: - Séquence ordonnée d'IDs de régions issue de la GT (typiquement - ``ReadingOrderGT.region_order`` du Sprint 32). - hypothesis_order: - Séquence ordonnée d'IDs de régions produite par un moteur - OCR/HTR ou un reconstructeur ALTO. - - Returns - ------- - dict - ``{"precision", "recall", "f1", "true_positives", - "false_positives", "false_negatives", "n_ref_pairs", - "n_hyp_pairs", "common_regions", "ref_only_regions", - "hyp_only_regions"}``. - - Comportements aux bornes - ------------------------ - - Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0. - - Ordre strictement inversé → F1 = 0.0 (toutes les paires - relatives sont fausses). - - Une séquence vide vs une séquence non vide → F1 = 0.0. - - Deux séquences vides → F1 = 0.0 et tous les compteurs à 0 - (convention : on ne récompense pas l'absence). - """ - ref = _normalize_input(reference_order) - hyp = _normalize_input(hypothesis_order) - - ref_pairs = _ordered_pairs(ref) - hyp_pairs = _ordered_pairs(hyp) - - tp = len(ref_pairs & hyp_pairs) - fn = len(ref_pairs - hyp_pairs) - fp = len(hyp_pairs - ref_pairs) - - precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 - recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 - f1 = ( - 2 * precision * recall / (precision + recall) - if (precision + recall) > 0 - else 0.0 - ) - - ref_set = set(ref) - hyp_set = set(hyp) - return { - "precision": precision, - "recall": recall, - "f1": f1, - "true_positives": tp, - "false_positives": fp, - "false_negatives": fn, - "n_ref_pairs": len(ref_pairs), - "n_hyp_pairs": len(hyp_pairs), - "common_regions": sorted(ref_set & hyp_set), - "ref_only_regions": sorted(ref_set - hyp_set), - "hyp_only_regions": sorted(hyp_set - ref_set), - } - - -# ────────────────────────────────────────────────────────────────────────── -# Enregistrement dans le registre typé (Sprint 34) -# ────────────────────────────────────────────────────────────────────────── - - -@register_metric( - name="reading_order_f1", - input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER), - description=( - "F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, " - "Antonacopoulos). Pour chaque paire (a,b) où a précède b dans " - "la GT, vérifie que a précède aussi b dans l'hypothèse." - ), - higher_is_better=True, - tags={"structure", "icdar", "alto", "page"}, -) -def reading_order_f1( - reference: Iterable[str] | None, - hypothesis: Iterable[str] | None, -) -> float: - """Raccourci : retourne uniquement le F1 global. - - Pour les détails par paire (TP/FP/FN, régions communes, etc.), - appeler ``compute_reading_order_metrics`` directement. - """ - return compute_reading_order_metrics(reference, hypothesis)["f1"] - +from picarones.measurements.reading_order import * # noqa: F401, F403 -__all__ = [ - "compute_reading_order_metrics", - "reading_order_f1", -] +import picarones.measurements.reading_order as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/reliability.py b/picarones/core/reliability.py index 116bdc28d8312cc1a702d6d0caf156d2db78e0b9..ab0b5a9cc77ae691258e5efbc6ebdf43112c3352 100644 --- a/picarones/core/reliability.py +++ b/picarones/core/reliability.py @@ -1,360 +1,19 @@ -"""Métriques de fiabilité — Sprint 83 (A.II.4). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.reliability`. -Sprint 83 — A.II.4 du plan d'évolution 2026 (Étape 4). +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.reliability import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Une publication scientifique qui rapporte un CER LLM sans -stabilité est méthodologiquement faible. Et un benchmark qui -ignore le plafond humain (« deux paléographes ne sont pas même -d'accord ») crée des classements faussement optimistes. Ce -module livre deux familles complémentaires : - -1. **Inter-annotator agreement (IAA)** — quand un document a - plusieurs GT (deux paléographes, par ex.), Cohen κ et - Krippendorff α mesurent l'accord au niveau caractère. - Lecture : *« le CER de Pero (4,2 %) approche le plafond - humain (κ = 0,89). »* - -2. **Stabilité multi-runs** — quand on relance la même - pipeline LLM N fois sur les mêmes documents, on mesure : - variance du CER, taux de tokens divergents entre runs, - CER pairwise moyen. - -Périmètre Sprint 83 -------------------- -**Couche de calcul uniquement** — fonctions pures, pas -d'intégration runner ni de vue HTML. L'extension du loader -pour accepter ``doc_001.gt.A.txt`` / ``doc_001.gt.B.txt`` est -documentée comme dépendance future ; en attendant le sprint -dédié, on prend deux strings GT en entrée. - -Méthode -------- -*IAA caractère par caractère.* On aligne les deux GT par -``difflib.SequenceMatcher`` au niveau caractère et on construit -une table de contingence ``(annotator_a_char, annotator_b_char)`` -sur les positions ``equal`` ou ``replace``. Cohen κ utilise -cette table directement. Krippendorff α utilise la version -matricielle (différence binaire pour le mode nominal). - -*Stabilité multi-runs.* ``compute_multirun_stability(runs)`` -prend une liste de N transcriptions du **même** document et -renvoie variance/écart-type/coefficient de variation du CER si -référence fournie ; sinon, taux pairwise de divergence -(intersection-vs-union des tokens). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import statistics -from typing import Optional, Sequence - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Helpers d'alignement caractère par caractère -# ────────────────────────────────────────────────────────────────────────── - - -def _aligned_char_pairs( - text_a: str, text_b: str, -) -> list[tuple[str, str]]: - """Aligne ``text_a`` et ``text_b`` caractère par caractère. - - Retourne la liste des paires alignées sur les segments - ``equal`` et ``replace`` de ``SequenceMatcher`` (les ``insert`` - et ``delete`` sont ignorés — pas d'alignement valide). - """ - if not text_a and not text_b: - return [] - import difflib - matcher = difflib.SequenceMatcher(None, text_a, text_b, autojunk=False) - pairs: list[tuple[str, str]] = [] - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - for k in range(i2 - i1): - pairs.append((text_a[i1 + k], text_b[j1 + k])) - elif tag == "replace": - paired = min(i2 - i1, j2 - j1) - for k in range(paired): - pairs.append((text_a[i1 + k], text_b[j1 + k])) - # insert/delete : pas d'alignement bilatéral exploitable - return pairs - - -__all__: list[str] = [] - - -# ────────────────────────────────────────────────────────────────────────── -# 1. Cohen's kappa (deux annotateurs, accord nominal) -# ────────────────────────────────────────────────────────────────────────── - - -def cohen_kappa( - annotations_a: Sequence, - annotations_b: Sequence, -) -> Optional[float]: - """Cohen's κ entre deux annotateurs sur des observations - appariées. - - Définition : - - κ = (po - pe) / (1 - pe) - - où ``po`` est l'accord observé (proportion de paires égales) - et ``pe`` l'accord attendu par hasard (somme sur les classes - de p_a(c) × p_b(c)). - - Conventions : - - retourne ``None`` si les deux séquences sont vides ou de - tailles incompatibles ; - - κ = 1.0 quand l'accord est parfait, 0.0 quand il égale le - hasard, négatif si pire que le hasard ; - - quand ``pe == 1`` (un seul label dans les deux séquences), - retourne 1.0 si les séquences sont identiques, 0.0 sinon - (κ est mathématiquement indéfini, on choisit une - convention transparente documentée). - """ - if len(annotations_a) != len(annotations_b): - return None - n = len(annotations_a) - if n == 0: - return None - # Accord observé - agree = sum(1 for a, b in zip(annotations_a, annotations_b) if a == b) - p_o = agree / n - # Accord attendu par hasard - from collections import Counter - count_a = Counter(annotations_a) - count_b = Counter(annotations_b) - classes = set(count_a) | set(count_b) - p_e = sum( - (count_a.get(c, 0) / n) * (count_b.get(c, 0) / n) - for c in classes - ) - if p_e >= 1.0 - 1e-12: - # Indéfini ; convention : 1 si identité totale, 0 sinon - return 1.0 if p_o >= 1.0 - 1e-12 else 0.0 - return (p_o - p_e) / (1.0 - p_e) - - -__all__.append("cohen_kappa") - - -# ────────────────────────────────────────────────────────────────────────── -# 2. Krippendorff's alpha (généralisation à N annotateurs) -# ────────────────────────────────────────────────────────────────────────── - - -def krippendorff_alpha( - annotations_per_unit: Sequence[Sequence], -) -> Optional[float]: - """Krippendorff's α en mode nominal pour N annotateurs. - - Parameters - ---------- - annotations_per_unit: - Liste d'unités, chaque unité étant la liste des - annotations produites par les différents annotateurs sur - cette unité. ``None`` dans une cellule = annotation - manquante (autorisée). - - Définition (Krippendorff 1980, équation pour métrique - nominale) : - - α = 1 - D_o / D_e - - où ``D_o`` est le désaccord observé (paires en désaccord - intra-unité, normalisées) et ``D_e`` le désaccord attendu - par hasard. ``α = 1`` accord parfait, ``α = 0`` hasard, - négatif si pire. - - Conventions : - - unités avec moins de 2 annotations valides : ignorées - (Krippendorff convention) ; - - retourne ``None`` si moins d'une unité utilisable ou - ``D_e == 0`` (un seul label dans tout le corpus). - """ - from collections import Counter - # Valeurs observées au niveau corpus - value_counts: Counter = Counter() - pair_disagree = 0.0 - pair_total = 0.0 - for unit in annotations_per_unit: - valid = [v for v in unit if v is not None] - m = len(valid) - if m < 2: - continue - # paires intra-unité (sans repetition, ordonné) - for i in range(m): - for j in range(m): - if i == j: - continue - pair_total += 1.0 / (m - 1) - if valid[i] != valid[j]: - pair_disagree += 1.0 / (m - 1) - for v in valid: - value_counts[v] += 1 - if pair_total == 0: - return None - n_total = sum(value_counts.values()) - if n_total < 2: - return None - # Désaccord attendu (sur paires aléatoires sans remise) - expected_disagree = 0.0 - for v_a, c_a in value_counts.items(): - for v_b, c_b in value_counts.items(): - if v_a != v_b: - expected_disagree += c_a * c_b - expected_disagree /= n_total * (n_total - 1) - if expected_disagree <= 1e-12: - return None - d_o = pair_disagree / pair_total - return 1.0 - (d_o / expected_disagree) - - -__all__.append("krippendorff_alpha") - - -# ────────────────────────────────────────────────────────────────────────── -# 3. Helpers IAA caractère -# ────────────────────────────────────────────────────────────────────────── - - -def compute_iaa( - transcription_a: str, - transcription_b: str, -) -> Optional[dict]: - """Calcule κ et α au niveau caractère entre deux - transcriptions du même document. - - Aligne via ``_aligned_char_pairs`` puis : - - κ : sur la liste des paires alignées ; - - α : sur les unités à 2 annotations (équivalent à κ sur ce - cas, mais le cadre généralise à N annotateurs). - - Retourne ``None`` si pas d'alignement possible (transcriptions - vides ou totalement disjointes). - """ - pairs = _aligned_char_pairs(transcription_a, transcription_b) - if not pairs: - return None - kappa = cohen_kappa([a for a, _ in pairs], [b for _, b in pairs]) - alpha = krippendorff_alpha([[a, b] for a, b in pairs]) - return { - "n_aligned_chars": len(pairs), - "cohen_kappa": kappa, - "krippendorff_alpha": alpha, - "agreement_rate": ( - sum(1 for a, b in pairs if a == b) / len(pairs) - ), - } - - -__all__.append("compute_iaa") - - -# ────────────────────────────────────────────────────────────────────────── -# 4. Stabilité multi-runs (variance CER, divergence pairwise) -# ────────────────────────────────────────────────────────────────────────── - - -def _split_words(text: str) -> list[str]: - return text.split() if text else [] - - -def compute_multirun_stability( - runs: Sequence[str], - *, - reference: Optional[str] = None, -) -> Optional[dict]: - """Mesure la stabilité de N runs successifs d'une même - pipeline (typiquement LLM/VLM non déterministe) sur un - document. - - Parameters - ---------- - runs: - Liste des transcriptions produites à chaque run (≥ 2). - reference: - Transcription de référence (GT). Si fournie, on calcule - ``cer_per_run``, leur variance et leur coefficient de - variation. - - Returns - ------- - dict | None - ``{ - "n_runs": int, - "pairwise_disagreement_mean": float, # divergence moyenne - "pairwise_disagreement_max": float, - "identical_run_rate": float, # paires identiques / total - "cer_per_run": Optional[list[float]], - "cer_mean": Optional[float], - "cer_stdev": Optional[float], - "cer_cv": Optional[float], # cv = stdev / mean - "n_distinct_outputs": int, - }`` - ou ``None`` si moins de 2 runs. - """ - if len(runs) < 2: - return None - runs_list = list(runs) - # Divergence pairwise (token-level Jaccard distance) - n = len(runs_list) - n_pairs = 0 - sum_disagree = 0.0 - max_disagree = 0.0 - n_identical = 0 - for i in range(n): - for j in range(i + 1, n): - n_pairs += 1 - tokens_i = set(_split_words(runs_list[i])) - tokens_j = set(_split_words(runs_list[j])) - union = tokens_i | tokens_j - if not union: - disagree = 0.0 - else: - disagree = 1.0 - len(tokens_i & tokens_j) / len(union) - sum_disagree += disagree - if disagree > max_disagree: - max_disagree = disagree - if runs_list[i] == runs_list[j]: - n_identical += 1 - pairwise_mean = sum_disagree / n_pairs if n_pairs else 0.0 - identical_rate = n_identical / n_pairs if n_pairs else 0.0 - distinct = len(set(runs_list)) - - cer_per_run: Optional[list[float]] = None - cer_mean: Optional[float] = None - cer_stdev: Optional[float] = None - cer_cv: Optional[float] = None - if reference is not None: - from picarones.core.metrics import _cer_from_strings - cer_per_run = [_cer_from_strings(reference, r) for r in runs_list] - cer_per_run = [v for v in cer_per_run if v is not None] - if cer_per_run: - cer_mean = statistics.fmean(cer_per_run) - if len(cer_per_run) >= 2: - cer_stdev = statistics.stdev(cer_per_run) - cer_cv = ( - cer_stdev / cer_mean if cer_mean and cer_mean > 0 - else None - ) - return { - "n_runs": n, - "pairwise_disagreement_mean": pairwise_mean, - "pairwise_disagreement_max": max_disagree, - "identical_run_rate": identical_rate, - "n_distinct_outputs": distinct, - "cer_per_run": cer_per_run, - "cer_mean": cer_mean, - "cer_stdev": cer_stdev, - "cer_cv": cer_cv, - } - +from picarones.measurements.reliability import * # noqa: F401, F403 -__all__.append("compute_multirun_stability") +import picarones.measurements.reliability as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/robustness.py b/picarones/core/robustness.py index 27f407234709718dab9752114a00250c0fc2db60..44f9008f5b71b887a603199e9721914431b06d87 100644 --- a/picarones/core/robustness.py +++ b/picarones/core/robustness.py @@ -1,731 +1,19 @@ -"""Analyse de robustesse des moteurs OCR face aux dégradations d'image. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.robustness`. -Fonctionnement --------------- -1. Génération de versions dégradées des images du corpus à différents niveaux : - - Bruit gaussien (sigma croissant) - - Flou gaussien (kernel size croissant) - - Rotation (angle croissant) - - Réduction de résolution (facteur de downscaling) - - Binarisation (seuillage Otsu ou fixe) -2. Exécution du moteur OCR sur chaque version dégradée -3. Calcul du CER pour chaque niveau de dégradation -4. Génération de courbes de robustesse (CER en fonction du niveau) -5. Identification du seuil critique (niveau à partir duquel CER > seuil) +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.robustness import ...``) de continuer +à fonctionner sans modification. -Usage ------ ->>> from picarones.core.robustness import RobustnessAnalyzer ->>> analyzer = RobustnessAnalyzer(engine, degradation_types=["noise", "blur"]) ->>> report = analyzer.analyze(corpus) ->>> print(report.critical_thresholds) +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations +from picarones.measurements.robustness import * # noqa: F401, F403 -import logging -import math -import os -import tempfile -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from picarones.core.corpus import Corpus, Document - from picarones.engines.base import BaseOCREngine - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Paramètres de dégradation -# --------------------------------------------------------------------------- - -# Niveaux de dégradation pour chaque type -DEGRADATION_LEVELS: dict[str, list] = { - "noise": [0, 5, 15, 30, 50, 80], # sigma du bruit gaussien - "blur": [0, 1, 2, 3, 5, 8], # rayon du flou gaussien (pixels) - "rotation": [0, 1, 2, 5, 10, 20], # angle de rotation (degrés) - "resolution": [1.0, 0.75, 0.5, 0.33, 0.25, 0.1], # facteur de résolution - "binarization": [0, 64, 96, 128, 160, 192], # seuil de binarisation (0 = Otsu) -} - -DEGRADATION_LABELS: dict[str, list[str]] = { - "noise": ["original", "σ=5", "σ=15", "σ=30", "σ=50", "σ=80"], - "blur": ["original", "r=1", "r=2", "r=3", "r=5", "r=8"], - "rotation": ["0°", "1°", "2°", "5°", "10°", "20°"], - "resolution": ["100%", "75%", "50%", "33%", "25%", "10%"], - "binarization": ["original", "seuil=64", "seuil=96", "seuil=128", "seuil=160", "seuil=192"], -} - -ALL_DEGRADATION_TYPES = list(DEGRADATION_LEVELS.keys()) - - -# --------------------------------------------------------------------------- -# Dégradation d'image (pure Python + stdlib, optionnellement Pillow/NumPy) -# --------------------------------------------------------------------------- - -def _apply_gaussian_noise(pixels: list[list[list[int]]], sigma: float, rng_seed: int = 0) -> list[list[list[int]]]: - """Applique du bruit gaussien (pure Python).""" - import random - rng = random.Random(rng_seed) - h = len(pixels) - w = len(pixels[0]) if h > 0 else 0 - result = [] - for y in range(h): - row = [] - for x in range(w): - pixel = [] - for c in pixels[y][x]: - noise = rng.gauss(0, sigma) - val = int(c + noise) - pixel.append(max(0, min(255, val))) - row.append(pixel) - result.append(row) - return result - - -def _apply_box_blur(pixels: list[list[list[int]]], radius: int) -> list[list[list[int]]]: - """Applique un flou de boîte (approximation du flou gaussien, pure Python).""" - if radius <= 0: - return pixels - h = len(pixels) - w = len(pixels[0]) if h > 0 else 0 - channels = len(pixels[0][0]) if h > 0 and w > 0 else 3 - - def blur_pass(data: list[list[list[int]]]) -> list[list[list[int]]]: - out = [] - for y in range(h): - row = [] - for x in range(w): - totals = [0] * channels - count = 0 - for dy in range(-radius, radius + 1): - for dx in range(-radius, radius + 1): - ny, nx = y + dy, x + dx - if 0 <= ny < h and 0 <= nx < w: - for c in range(channels): - totals[c] += data[ny][nx][c] - count += 1 - row.append([t // count for t in totals]) - out.append(row) - return out - - return blur_pass(pixels) - - -def _apply_rotation_simple(pixels: list[list[list[int]]], angle_deg: float) -> list[list[list[int]]]: - """Rotation avec interpolation au plus proche voisin (pure Python). - - Pour des angles faibles, l'effet est réaliste. - """ - if angle_deg == 0: - return pixels - h = len(pixels) - w = len(pixels[0]) if h > 0 else 0 - channels = len(pixels[0][0]) if h > 0 and w > 0 else 3 - - angle_rad = math.radians(angle_deg) - cos_a = math.cos(angle_rad) - sin_a = math.sin(angle_rad) - cx, cy = w / 2, h / 2 - - result = [[[245, 240, 232][:channels] for _ in range(w)] for _ in range(h)] - for y in range(h): - for x in range(w): - # Coordonnées source - sx = cos_a * (x - cx) + sin_a * (y - cy) + cx - sy = -sin_a * (x - cx) + cos_a * (y - cy) + cy - ix, iy = int(round(sx)), int(round(sy)) - if 0 <= ix < w and 0 <= iy < h: - result[y][x] = list(pixels[iy][ix]) - return result - - -def _apply_resolution_reduction( - pixels: list[list[list[int]]], factor: float -) -> list[list[list[int]]]: - """Réduit la résolution puis remonte à la taille originale (pixelisation).""" - if factor >= 1.0: - return pixels - h = len(pixels) - w = len(pixels[0]) if h > 0 else 0 - new_h = max(1, int(h * factor)) - new_w = max(1, int(w * factor)) - - # Downscale - small = [] - for y in range(new_h): - row = [] - src_y = int(y / factor) - for x in range(new_w): - src_x = int(x / factor) - row.append(list(pixels[min(src_y, h - 1)][min(src_x, w - 1)])) - small.append(row) - - # Upscale (nearest-neighbor) - result = [] - for y in range(h): - row = [] - src_y = min(int(y * factor), new_h - 1) - for x in range(w): - src_x = min(int(x * factor), new_w - 1) - row.append(list(small[src_y][src_x])) - result.append(row) - return result - - -def _apply_binarization( - pixels: list[list[list[int]]], threshold: int -) -> list[list[list[int]]]: - """Binarise l'image (seuillage fixe sur luminosité).""" - h = len(pixels) - w = len(pixels[0]) if h > 0 else 0 - result = [] - - # Calculer le seuil Otsu si threshold == 0 - if threshold == 0: - histogram = [0] * 256 - total = h * w - for y in range(h): - for x in range(w): - p = pixels[y][x] - lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0] - histogram[lum] += 1 - # Otsu simplifié - best_thresh = 128 - best_var = -1.0 - total_sum = sum(i * histogram[i] for i in range(256)) - w0, w1, sum0 = 0, total, 0.0 - for t in range(256): - w0 += histogram[t] - if w0 == 0: - continue - w1 = total - w0 - if w1 == 0: - break - sum0 += t * histogram[t] - mean0 = sum0 / w0 - mean1 = (total_sum - sum0) / w1 - var = w0 * w1 * (mean0 - mean1) ** 2 - if var > best_var: - best_var = var - best_thresh = t - threshold = best_thresh - - for y in range(h): - row = [] - for x in range(w): - p = pixels[y][x] - lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0] - val = 255 if lum >= threshold else 0 - row.append([val] * len(p)) - result.append(row) - return result - - -def degrade_image_bytes( - png_bytes: bytes, - degradation_type: str, - level: float, -) -> bytes: - """Dégrade une image PNG et retourne les bytes PNG modifiés. - - Utilise Pillow si disponible, sinon utilise l'implémentation pure Python. - - Parameters - ---------- - png_bytes: - Bytes de l'image PNG source. - degradation_type: - Type de dégradation (``"noise"``, ``"blur"``, ``"rotation"``, - ``"resolution"``, ``"binarization"``). - level: - Niveau de dégradation (valeur numérique selon le type). - - Returns - ------- - bytes - Bytes de l'image PNG dégradée. - """ - try: - return _degrade_pillow(png_bytes, degradation_type, level) - except ImportError: - return _degrade_pure_python(png_bytes, degradation_type, level) - - -def _degrade_pillow(png_bytes: bytes, degradation_type: str, level: float) -> bytes: - """Dégradation avec Pillow (meilleure qualité).""" - import io - from PIL import Image, ImageFilter - - img = Image.open(io.BytesIO(png_bytes)).convert("RGB") - - if degradation_type == "noise": - if level > 0: - import random - # RGB : 3 octets par pixel, tobytes() reste stable Pillow 10 → 14+ - raw = img.tobytes() - rng = random.Random(0) - noisy = [] - for i in range(0, len(raw), 3): - r, g, b = raw[i], raw[i + 1], raw[i + 2] - noisy.append(( - max(0, min(255, int(r + rng.gauss(0, level)))), - max(0, min(255, int(g + rng.gauss(0, level)))), - max(0, min(255, int(b + rng.gauss(0, level)))), - )) - img.putdata(noisy) - - elif degradation_type == "blur": - if level > 0: - img = img.filter(ImageFilter.GaussianBlur(radius=level)) - - elif degradation_type == "rotation": - if level != 0: - img = img.rotate(-level, expand=False, fillcolor=(245, 240, 232)) - - elif degradation_type == "resolution": - if level < 1.0: - w, h = img.size - new_w, new_h = max(1, int(w * level)), max(1, int(h * level)) - img = img.resize((new_w, new_h), Image.NEAREST) - img = img.resize((w, h), Image.NEAREST) - - elif degradation_type == "binarization": - img = img.convert("L") # niveaux de gris - if level == 0: - # Seuillage Otsu : calcul du seuil optimal - histogram = img.histogram() - total = img.size[0] * img.size[1] - best_thresh, best_var = 128, -1.0 - total_sum = sum(i * histogram[i] for i in range(256)) - w0, sum0 = 0, 0.0 - for t in range(256): - w0 += histogram[t] - if w0 == 0: - continue - w1 = total - w0 - if w1 == 0: - break - sum0 += t * histogram[t] - var = w0 * w1 * (sum0 / w0 - (total_sum - sum0) / w1) ** 2 - if var > best_var: - best_var = var - best_thresh = t - threshold = best_thresh - else: - threshold = int(level) - img = img.point(lambda p: 255 if p >= threshold else 0, "1").convert("RGB") - - buf = io.BytesIO() - img.save(buf, format="PNG") - return buf.getvalue() - - -def _degrade_pure_python(png_bytes: bytes, degradation_type: str, level: float) -> bytes: - """Dégradation en pur Python (sans Pillow). - - Décode le PNG, applique la transformation, ré-encode en PNG. - Note : n'implémente pas le décodage PNG complet — utilise des stubs. - """ - # Pour l'implémentation pure Python, on applique des transformations - # minimales sur les bytes bruts en créant une image de test synthétique. - # En pratique, Pillow est presque toujours disponible dans l'environnement Picarones. - logger.warning( - "Pillow non disponible : dégradation '%s' appliquée en mode dégradé (stub)", - degradation_type, - ) - # Retourner l'image originale légèrement modifiée (simulation) - return png_bytes - - -# --------------------------------------------------------------------------- -# Structures de résultats -# --------------------------------------------------------------------------- - -@dataclass -class DegradationCurve: - """Courbe CER vs niveau de dégradation pour un moteur et un type de dégradation.""" - engine_name: str - degradation_type: str - levels: list[float] - labels: list[str] - cer_values: list[Optional[float]] - """CER moyen (0-1) à chaque niveau. None si calcul impossible.""" - critical_threshold_level: Optional[float] = None - """Niveau à partir duquel CER > cer_threshold.""" - cer_threshold: float = 0.20 - """Seuil de CER utilisé pour déterminer le niveau critique.""" - - def as_dict(self) -> dict: - return { - "engine_name": self.engine_name, - "degradation_type": self.degradation_type, - "levels": self.levels, - "labels": self.labels, - "cer_values": self.cer_values, - "critical_threshold_level": self.critical_threshold_level, - "cer_threshold": self.cer_threshold, - } - - -@dataclass -class RobustnessReport: - """Rapport complet d'analyse de robustesse pour un ou plusieurs moteurs.""" - engine_names: list[str] - corpus_name: str - degradation_types: list[str] - curves: list[DegradationCurve] - summary: dict = field(default_factory=dict) - """Résumé : moteur le plus robuste par type de dégradation, seuils critiques…""" - - def get_curves_for_engine(self, engine_name: str) -> list[DegradationCurve]: - return [c for c in self.curves if c.engine_name == engine_name] - - def get_curves_for_type(self, degradation_type: str) -> list[DegradationCurve]: - return [c for c in self.curves if c.degradation_type == degradation_type] - - def as_dict(self) -> dict: - return { - "engine_names": self.engine_names, - "corpus_name": self.corpus_name, - "degradation_types": self.degradation_types, - "curves": [c.as_dict() for c in self.curves], - "summary": self.summary, - } - - -# --------------------------------------------------------------------------- -# Analyseur de robustesse -# --------------------------------------------------------------------------- - -class RobustnessAnalyzer: - """Lance une analyse de robustesse sur un corpus. - - Parameters - ---------- - engines: - Un ou plusieurs moteurs OCR (``BaseOCREngine``). - degradation_types: - Liste des types de dégradation à tester. - Par défaut : tous (``"noise"``, ``"blur"``, ``"rotation"``, - ``"resolution"``, ``"binarization"``). - cer_threshold: - Seuil de CER pour définir le niveau critique (défaut : 0.20 = 20%). - custom_levels: - Niveaux personnalisés par type (remplace les valeurs par défaut). - - Examples - -------- - >>> from picarones.engines.tesseract import TesseractEngine - >>> from picarones.core.robustness import RobustnessAnalyzer - >>> engine = TesseractEngine(config={"lang": "fra"}) - >>> analyzer = RobustnessAnalyzer([engine], degradation_types=["noise", "blur"]) - >>> report = analyzer.analyze(corpus) - """ - - def __init__( - self, - engines: "list[BaseOCREngine]", - degradation_types: Optional[list[str]] = None, - cer_threshold: float = 0.20, - custom_levels: Optional[dict[str, list]] = None, - ) -> None: - if not isinstance(engines, list): - engines = [engines] - self.engines = engines - self.degradation_types = degradation_types or ALL_DEGRADATION_TYPES - self.cer_threshold = cer_threshold - self.levels = dict(DEGRADATION_LEVELS) - if custom_levels: - self.levels.update(custom_levels) - - def analyze( - self, - corpus: "Corpus", - show_progress: bool = True, - max_docs: int = 10, - ) -> RobustnessReport: - """Lance l'analyse de robustesse sur le corpus. - - Parameters - ---------- - corpus: - Corpus Picarones avec images et GT. - show_progress: - Affiche la progression. - max_docs: - Nombre maximum de documents à traiter (pour la rapidité). - - Returns - ------- - RobustnessReport - """ - from picarones.core.metrics import compute_metrics - - docs = corpus.documents[:max_docs] - curves: list[DegradationCurve] = [] - - for engine in self.engines: - for deg_type in self.degradation_types: - levels = self.levels[deg_type] - labels = DEGRADATION_LABELS.get(deg_type, [str(lv) for lv in levels]) - - cer_per_level: list[Optional[float]] = [] - - if show_progress: - try: - from tqdm import tqdm - level_iter = tqdm( - list(enumerate(levels)), - desc=f"{engine.name} / {deg_type}", - ) - except ImportError: - level_iter = enumerate(levels) - else: - level_iter = enumerate(levels) - - for lvl_idx, level in level_iter: - doc_cers: list[float] = [] - - for doc in docs: - gt = doc.ground_truth.strip() - if not gt: - continue - - # Obtenir l'image (fichier ou data URI) - degraded_bytes = self._get_degraded_image( - doc, deg_type, level - ) - if degraded_bytes is None: - continue - - # Sauvegarder temporairement et OCR - with tempfile.NamedTemporaryFile( - suffix=".png", delete=False - ) as tmp: - tmp.write(degraded_bytes) - tmp_path = tmp.name - - try: - ocr_result = engine.run(tmp_path) - hypothesis = ocr_result.text - metrics = compute_metrics(gt, hypothesis) - doc_cers.append(metrics.cer) - except Exception as exc: - logger.debug( - "Erreur OCR %s niveau %s=%s: %s", - engine.name, deg_type, level, exc - ) - finally: - try: - os.unlink(tmp_path) - except OSError: - pass - - if doc_cers: - cer_per_level.append(sum(doc_cers) / len(doc_cers)) - else: - cer_per_level.append(None) - - # Calculer le niveau critique - critical = self._find_critical_level( - levels, cer_per_level, self.cer_threshold - ) - - curves.append(DegradationCurve( - engine_name=engine.name, - degradation_type=deg_type, - levels=levels, - labels=labels[:len(levels)], - cer_values=cer_per_level, - critical_threshold_level=critical, - cer_threshold=self.cer_threshold, - )) - - summary = self._build_summary(curves) - - return RobustnessReport( - engine_names=[e.name for e in self.engines], - corpus_name=corpus.name, - degradation_types=self.degradation_types, - curves=curves, - summary=summary, - ) - - def _get_degraded_image( - self, - doc: "Document", - degradation_type: str, - level: float, - ) -> Optional[bytes]: - """Retourne les bytes PNG de l'image dégradée.""" - # Charger l'image originale - original_bytes = self._load_image(doc) - if original_bytes is None: - return None - - # Niveau 0 = image originale (sauf binarisation à 0 = Otsu) - if (degradation_type == "noise" and level == 0) or \ - (degradation_type == "blur" and level == 0) or \ - (degradation_type == "rotation" and level == 0) or \ - (degradation_type == "resolution" and level >= 1.0): - return original_bytes - - return degrade_image_bytes(original_bytes, degradation_type, level) - - def _load_image(self, doc: "Document") -> Optional[bytes]: - """Charge les bytes PNG de l'image d'un document.""" - img_path = doc.image_path - - # Data URI (base64) - if img_path.startswith("data:image/"): - import base64 - try: - _, b64 = img_path.split(",", 1) - return base64.b64decode(b64) - except Exception as exc: - logger.debug("Impossible de décoder data URI: %s", exc) - return None - - # Fichier local - path = Path(img_path) - if path.exists(): - return path.read_bytes() - - logger.debug("Image introuvable : %s", img_path) - return None - - @staticmethod - def _find_critical_level( - levels: list[float], - cer_values: list[Optional[float]], - threshold: float, - ) -> Optional[float]: - """Trouve le niveau à partir duquel CER dépasse le seuil.""" - for level, cer in zip(levels, cer_values): - if cer is not None and cer > threshold: - return level - return None - - @staticmethod - def _build_summary(curves: list[DegradationCurve]) -> dict: - """Construit le résumé de l'analyse.""" - summary: dict = {} - - # Par type de dégradation : moteur le plus robuste - by_type: dict[str, dict[str, list]] = {} - for curve in curves: - dt = curve.degradation_type - if dt not in by_type: - by_type[dt] = {} - valid_cers = [c for c in curve.cer_values if c is not None] - if valid_cers: - by_type[dt][curve.engine_name] = valid_cers - - for dt, engine_cers in by_type.items(): - if not engine_cers: - continue - # Robustesse = CER moyen sur tous les niveaux (plus bas = plus robuste) - best_engine = min(engine_cers, key=lambda e: sum(engine_cers[e]) / len(engine_cers[e])) - summary[f"most_robust_{dt}"] = best_engine - - # Seuils critiques par moteur - for curve in curves: - key = f"critical_{curve.engine_name}_{curve.degradation_type}" - summary[key] = curve.critical_threshold_level - - return summary - - -# --------------------------------------------------------------------------- -# Données de démonstration de robustesse -# --------------------------------------------------------------------------- - -def generate_demo_robustness_report( - engine_names: Optional[list[str]] = None, - seed: int = 42, -) -> RobustnessReport: - """Génère un rapport de robustesse fictif mais réaliste pour la démo. - - Parameters - ---------- - engine_names: - Noms des moteurs à simuler (défaut : tesseract, pero_ocr). - seed: - Graine aléatoire. - - Returns - ------- - RobustnessReport - """ - import random - rng = random.Random(seed) - - if engine_names is None: - engine_names = ["tesseract", "pero_ocr"] - - # CER de base par moteur - base_cer = { - "tesseract": 0.12, - "pero_ocr": 0.07, - "ancien_moteur": 0.25, - } - - # Sensibilité par type de dégradation (facteur multiplicatif par niveau) - sensitivity = { - "tesseract": { - "noise": 0.04, "blur": 0.05, "rotation": 0.06, - "resolution": 0.12, "binarization": 0.03, - }, - "pero_ocr": { - "noise": 0.02, "blur": 0.03, "rotation": 0.04, - "resolution": 0.08, "binarization": 0.02, - }, - "ancien_moteur": { - "noise": 0.06, "blur": 0.08, "rotation": 0.10, - "resolution": 0.15, "binarization": 0.05, - }, - } - - deg_types = ALL_DEGRADATION_TYPES - curves: list[DegradationCurve] = [] - - for engine_name in engine_names: - cer_base = base_cer.get(engine_name, 0.15) - sens = sensitivity.get(engine_name, {dt: 0.05 for dt in deg_types}) - - for deg_type in deg_types: - levels = DEGRADATION_LEVELS[deg_type] - labels = DEGRADATION_LABELS[deg_type] - s = sens.get(deg_type, 0.05) - - cer_values = [] - for i, level in enumerate(levels): - noise = rng.gauss(0, 0.005) - cer = min(1.0, cer_base + s * i + noise) - cer_values.append(round(max(0.0, cer), 4)) - - critical = RobustnessAnalyzer._find_critical_level(levels, cer_values, 0.20) - - curves.append(DegradationCurve( - engine_name=engine_name, - degradation_type=deg_type, - levels=list(levels), - labels=labels[:len(levels)], - cer_values=cer_values, - critical_threshold_level=critical, - cer_threshold=0.20, - )) - - summary = RobustnessAnalyzer._build_summary(curves) - - return RobustnessReport( - engine_names=engine_names, - corpus_name="Corpus de démonstration — Chroniques médiévales", - degradation_types=deg_types, - curves=curves, - summary=summary, - ) +import picarones.measurements.robustness as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/robustness_projection.py b/picarones/core/robustness_projection.py index dc6c66a0a62c62e6a70839288e08c85a415a7c0c..9e82ebdd4c41c5e3034e710173f551e9fd65146c 100644 --- a/picarones/core/robustness_projection.py +++ b/picarones/core/robustness_projection.py @@ -1,287 +1,19 @@ -"""Projection de robustesse synthétique sur le corpus réel — -Sprint 81 (A.I.8). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.robustness_projection`. -Sprint 81 — A.I.8 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.robustness_projection import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le module ``picarones/core/robustness.py`` (Sprint 8) génère des -courbes CER vs niveau de dégradation **synthétique** (bruit, flou, -rotation, résolution). ``picarones/core/image_quality.py`` mesure -le bruit/flou/contraste **réels** des images du corpus. Ce -sprint **projette** les caractéristiques réelles sur les courbes -synthétiques pour estimer le **déficit attendu de CER** sur le -corpus dans son état actuel. - -Lecture concrète ----------------- -*« 30 % de vos documents ont un bruit équivalent à σ=15 où -Tesseract perd 8 points de CER — soit un déficit attendu global -de 2,4 points (30 % × 8 points). »* - -Méthode -------- -1. Pour chaque document, on extrait la valeur de qualité réelle - (``noise_level``, ``blur_score``, ``contrast_score``…) depuis - ``ImageQualityResult``. -2. Pour chaque type de dégradation, on interpole linéairement la - ``DegradationCurve`` synthétique : CER attendu à ce niveau. -3. On agrège : CER moyen attendu, % docs au-dessus du seuil - critique de la courbe, déficit projeté = CER_attendu - - CER_baseline (niveau nul). - -Sortie ------- -``project_robustness_on_corpus(curves, image_qualities)`` retourne -``{engine_name: {degradation_type: {expected_cer_mean, -deficit_vs_baseline, n_docs_above_critical, n_docs}}}``. - -Limites -------- -- Mapping ``image_quality → degradation level`` : on suppose que - ``noise_level`` (ImageQualityResult) correspond à σ - (DegradationCurve), et idem pour ``blur_score`` ↔ rayon de - flou. Si un corpus expose ces valeurs avec une échelle - différente, le mapping est documenté et l'utilisateur peut - passer ``quality_to_level`` custom. -- Interpolation **linéaire** entre les points de la courbe. Au- - delà des bornes, on **clip** au point extrême (pas - d'extrapolation hasardeuse). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -import statistics -from typing import Callable, Iterable, Optional - -logger = logging.getLogger(__name__) - - -# Mapping par défaut entre attributs ImageQualityResult et types -# de dégradation synthétique. L'utilisateur peut passer un dict -# custom pour modifier ce mapping. -_DEFAULT_QUALITY_FIELD: dict[str, str] = { - "noise": "noise_level", # σ - "blur": "blur_score", # Variance laplacienne (inverse) - "contrast": "contrast_score", - "rotation": "rotation_angle", - "resolution": "resolution_score", # peut être absent -} - - -def _interpolate_cer( - levels: list[float], - cer_values: list[Optional[float]], - target_level: float, -) -> Optional[float]: - """Interpolation linéaire : retourne CER attendu à - ``target_level``. - - - Si ``target_level`` est en-dessous du minimum de levels, - retourne le CER au minimum (clip). - - Si au-dessus du maximum, retourne le CER au maximum. - - Sinon, interpolation linéaire entre les deux points - encadrants. - - Retourne ``None`` si aucun ``cer_value`` valide. - """ - if not levels: - return None - # Filtrer les paires (level, cer) où cer est None - pairs = [ - (lvl, cer) for lvl, cer in zip(levels, cer_values) - if cer is not None - ] - if not pairs: - return None - pairs.sort(key=lambda p: p[0]) - # Clip - if target_level <= pairs[0][0]: - return pairs[0][1] - if target_level >= pairs[-1][0]: - return pairs[-1][1] - # Interpolation - for i in range(len(pairs) - 1): - lo_lvl, lo_cer = pairs[i] - hi_lvl, hi_cer = pairs[i + 1] - if lo_lvl <= target_level <= hi_lvl: - if hi_lvl == lo_lvl: - return lo_cer - ratio = (target_level - lo_lvl) / (hi_lvl - lo_lvl) - return lo_cer + (hi_cer - lo_cer) * ratio - return None # ne devrait pas arriver - - -def _extract_quality_value( - quality: dict, degradation_type: str, - custom_mapping: Optional[dict[str, str]] = None, -) -> Optional[float]: - """Extrait la valeur de qualité pertinente pour un type de - dégradation depuis un ``ImageQualityResult.as_dict()``.""" - mapping = custom_mapping or _DEFAULT_QUALITY_FIELD - field = mapping.get(degradation_type) - if field is None: - return None - value = quality.get(field) - if value is None: - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - -def project_robustness_on_corpus( - curves: Iterable, - image_qualities: list[dict], - *, - quality_to_level: Optional[Callable[[dict, str], Optional[float]]] = None, - critical_threshold: Optional[float] = None, -) -> dict: - """Projette les courbes de robustesse sur les qualités réelles. - - Parameters - ---------- - curves: - Itérable de ``DegradationCurve`` (ou dicts compatibles - avec ``engine_name``, ``degradation_type``, ``levels``, - ``cer_values``, ``critical_threshold_level``). - image_qualities: - Liste de dicts ``ImageQualityResult.as_dict()`` (un par - document). Si vide, retourne une projection vide. - quality_to_level: - Fonction custom ``(quality_dict, degradation_type) → - Optional[float]`` pour adapter le mapping qualité→niveau. - Par défaut, utilise ``_DEFAULT_QUALITY_FIELD``. - critical_threshold: - Override pour le seuil critique de CER (défaut : utilise - ``DegradationCurve.cer_threshold``). - - Returns - ------- - dict - ``{ - engine_name: { - degradation_type: { - "n_docs": int, - "n_docs_with_data": int, # qualité disponible - "expected_cer_mean": float, # moyenne CER attendu - "expected_cer_median": float, - "baseline_cer": float, # CER à niveau min - "deficit_vs_baseline": float, - "n_docs_above_critical": int, - "critical_threshold_level": float | None, - "critical_threshold_cer": float, - }, - }, - }`` - """ - extractor = quality_to_level or ( - lambda q, dt: _extract_quality_value(q, dt) - ) - out: dict[str, dict] = {} - - for curve in curves: - # Accepter dict ou DegradationCurve - if hasattr(curve, "as_dict"): - data = curve.as_dict() - else: - data = curve - engine = data.get("engine_name") - deg_type = data.get("degradation_type") - levels = data.get("levels") or [] - cer_values = data.get("cer_values") or [] - crit_lvl = data.get("critical_threshold_level") - crit_cer = ( - critical_threshold - if critical_threshold is not None - else data.get("cer_threshold", 0.20) - ) - if not engine or not deg_type: - continue - - per_doc_cer: list[float] = [] - n_docs_with_data = 0 - n_above_critical = 0 - for quality in image_qualities: - level = extractor(quality, deg_type) - if level is None: - continue - n_docs_with_data += 1 - cer = _interpolate_cer(levels, cer_values, level) - if cer is None: - continue - per_doc_cer.append(cer) - if cer > crit_cer: - n_above_critical += 1 - - if not per_doc_cer: - continue - - # Baseline = CER au niveau minimum (sans dégradation) - baseline = _interpolate_cer( - levels, cer_values, - min(levels) if levels else 0.0, - ) - expected_mean = statistics.fmean(per_doc_cer) - expected_median = statistics.median(per_doc_cer) - deficit = ( - expected_mean - baseline - if baseline is not None else None - ) - - out.setdefault(engine, {})[deg_type] = { - "n_docs": len(image_qualities), - "n_docs_with_data": n_docs_with_data, - "expected_cer_mean": expected_mean, - "expected_cer_median": expected_median, - "baseline_cer": baseline, - "deficit_vs_baseline": deficit, - "n_docs_above_critical": n_above_critical, - "critical_threshold_level": crit_lvl, - "critical_threshold_cer": crit_cer, - } - return out - - -def aggregate_projection_per_engine(projection: dict) -> dict: - """Pour chaque moteur, agrège le déficit projeté en sommant - sur tous les types de dégradation. - - Lecture : *« déficit total attendu pour Tesseract = 5,2 points - de CER si on considère les 4 dégradations indépendamment »*. - - Note : la sommation **suppose l'indépendance** des - dégradations, ce qui n'est pas strictement vrai mais reste - une approximation utile pour le diagnostic. - """ - out: dict[str, dict] = {} - for engine, per_type in projection.items(): - total_deficit = 0.0 - n_types_with_data = 0 - max_deficit_type: Optional[tuple[str, float]] = None - for deg_type, stats in per_type.items(): - deficit = stats.get("deficit_vs_baseline") - if deficit is None: - continue - total_deficit += deficit - n_types_with_data += 1 - if max_deficit_type is None or deficit > max_deficit_type[1]: - max_deficit_type = (deg_type, deficit) - out[engine] = { - "total_expected_deficit": total_deficit, - "n_degradation_types": n_types_with_data, - "worst_degradation_type": ( - max_deficit_type[0] if max_deficit_type else None - ), - "worst_degradation_deficit": ( - max_deficit_type[1] if max_deficit_type else None - ), - } - return out - +from picarones.measurements.robustness_projection import * # noqa: F401, F403 -__all__ = [ - "project_robustness_on_corpus", - "aggregate_projection_per_engine", -] +import picarones.measurements.robustness_projection as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/searchability.py b/picarones/core/searchability.py index efe9209835a81a051db26fe478fe16db24a88e64..5d165c99bb493d5f5380de7db877ba6b9ac48380 100644 --- a/picarones/core/searchability.py +++ b/picarones/core/searchability.py @@ -1,225 +1,19 @@ -"""Recherchabilité fuzzy — Sprint 84 (A.II.5). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.searchability`. -Sprint 84 — A.II.5 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.searchability import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le CER mesure les erreurs caractère par caractère. Mais pour -un usage *recherche plein-texte* (ce que font Elastic, Solr en -mode fuzzy, ou la recherche full-text de Gallica), la question -réelle est : - - *« Combien de mots de ma GT sont retrouvables dans la - sortie OCR, à orthographe approchée près ? »* - -Un CER de 8 % peut donner 95 % de findability si les erreurs -sont concentrées sur des caractères non-significatifs ou sur -quelques mots aberrants ; à l'inverse, 4 % de CER mais -distribué sur tous les noms propres rend le corpus inutilisable -pour l'indexation prosopographique. - -Méthode -------- -Pour chaque token GT, on regarde s'il existe au moins un token -hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut -2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5 -caractères). Le **rappel** est la proportion de tokens GT -ainsi retrouvés. - -Multiplicité ------------- -Si la GT contient *« le »* deux fois et l'hypothèse une fois, -seul un token GT est compté comme retrouvé (alignement -multi-set, comme ``rare_token_recall`` Sprint 71). - -Sortie ------- -``compute_searchability(reference, hypothesis)`` retourne -``{n_gt_tokens, n_searchable, recall, missed_tokens}``. - -Limites documentées -------------------- -- Tokenisation par split sur whitespace (cohérent avec le reste - du codebase). Pas de stemming ni de lemmatisation. -- Levenshtein non pondéré — substitution = insertion = suppression - = 1. Pour un poids différent (par ex. faute classique - diacritique = 0,5), passer une fonction custom. -- Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la - similarité sémantique, voir des modules futurs (BERTScore). +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Optional - -from picarones.core.metric_registry import register_metric -from picarones.core.modules import ArtifactType - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Tokenisation et distance d'édition -# ────────────────────────────────────────────────────────────────────────── - - -def _split_words(text: Optional[str]) -> list[str]: - """Tokenisation par whitespace — cohérent avec - ``lexical_modernization.py``, ``rare_tokens.py``, etc.""" - if not text: - return [] - return text.split() - - -def levenshtein_distance(a: str, b: str) -> int: - """Distance de Levenshtein (substitution=insertion=suppression=1). - - Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)). - """ - if a == b: - return 0 - if len(a) < len(b): - a, b = b, a - # |a| ≥ |b| - if not b: - return len(a) - previous = list(range(len(b) + 1)) - for i, ca in enumerate(a, start=1): - current = [i] + [0] * len(b) - for j, cb in enumerate(b, start=1): - cost = 0 if ca == cb else 1 - current[j] = min( - current[j - 1] + 1, # insertion - previous[j] + 1, # suppression - previous[j - 1] + cost, # substitution - ) - previous = current - return previous[-1] - - -# ────────────────────────────────────────────────────────────────────────── -# Calcul principal -# ────────────────────────────────────────────────────────────────────────── - - -def compute_searchability( - reference: Optional[str], - hypothesis: Optional[str], - *, - max_distance: int = 2, - case_sensitive: bool = False, -) -> dict: - """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``. - - Parameters - ---------- - reference, hypothesis: - Transcriptions GT et OCR. - max_distance: - Seuil de distance de Levenshtein (≤ pour considérer un - token comme retrouvé). Défaut 2 — convention - ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères. - case_sensitive: - Si False (défaut), casse insensible côté match — la - sortie ``missed_tokens`` reste avec la casse GT - originale. - - Returns - ------- - dict - ``{ - "n_gt_tokens": int, - "n_searchable": int, - "recall": float | None, # None si n_gt_tokens == 0 - "missed_tokens": list[str], - "max_distance": int, - }`` - """ - if max_distance < 0: - raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}") - gt_tokens = _split_words(reference) - hyp_tokens = _split_words(hypothesis) - n_gt = len(gt_tokens) - if n_gt == 0: - return { - "n_gt_tokens": 0, - "n_searchable": 0, - "recall": None, - "missed_tokens": [], - "max_distance": max_distance, - } - # Multi-set : un token hypothèse ne peut servir qu'une fois. - # Tri par longueur croissante pour matcher d'abord les - # tokens GT les plus courts (où ε-fautes sont plus rares). - if case_sensitive: - gt_for_match = list(gt_tokens) - hyp_for_match = list(hyp_tokens) - else: - gt_for_match = [t.lower() for t in gt_tokens] - hyp_for_match = [t.lower() for t in hyp_tokens] - - hyp_used = [False] * len(hyp_for_match) - n_searchable = 0 - missed: list[str] = [] - for gi, gt_match in enumerate(gt_for_match): - # Court-circuit si match exact disponible - best_idx = -1 - best_dist = max_distance + 1 - for hi, used in enumerate(hyp_used): - if used: - continue - hyp_match = hyp_for_match[hi] - # Court-circuit longueur (Levenshtein ≥ |Δlen|) - if abs(len(hyp_match) - len(gt_match)) > max_distance: - continue - d = levenshtein_distance(gt_match, hyp_match) - if d < best_dist: - best_dist = d - best_idx = hi - if d == 0: - break # match exact, inutile de chercher mieux - if best_idx >= 0 and best_dist <= max_distance: - hyp_used[best_idx] = True - n_searchable += 1 - else: - missed.append(gt_tokens[gi]) - recall = n_searchable / n_gt - return { - "n_gt_tokens": n_gt, - "n_searchable": n_searchable, - "recall": recall, - "missed_tokens": missed, - "max_distance": max_distance, - } - - -# ────────────────────────────────────────────────────────────────────────── -# Enregistrement registre typé (Sprint 34) -# ────────────────────────────────────────────────────────────────────────── - - -@register_metric( - name="searchability_recall", - input_types=(ArtifactType.TEXT, ArtifactType.TEXT), - description=( - "Recherchabilité fuzzy : proportion de tokens GT retrouvés " - "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de " - "la qualité pour la recherche plein-texte (Elastic, Solr)." - ), -) -def searchability_recall_metric(reference: str, hypothesis: str) -> float: - """Variante scalaire pour le registre typé : retourne le - rappel en [0, 1], ou ``0.0`` si la GT est vide (convention - cohérente avec rare_token_recall Sprint 71). - """ - result = compute_searchability(reference, hypothesis) - recall = result.get("recall") - return 0.0 if recall is None else recall - +from picarones.measurements.searchability import * # noqa: F401, F403 -__all__ = [ - "levenshtein_distance", - "compute_searchability", - "searchability_recall_metric", -] +import picarones.measurements.searchability as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/searchability_runner.py b/picarones/core/searchability_runner.py index cf822338cd0fb86c4a58301f136568a77080a346..75474d5310a4355dc7020e7befb82a91ee035f59 100644 --- a/picarones/core/searchability_runner.py +++ b/picarones/core/searchability_runner.py @@ -1,81 +1,19 @@ -"""Câblage runner de la recherchabilité (Sprint 86). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.searchability_runner`. -Sprint 86 — A.II.5a (vue HTML + câblage runner). +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.searchability_runner import ...``) de continuer +à fonctionner sans modification. -Le module ``picarones/core/searchability.py`` (Sprint 84) a livré -la couche de calcul. Ce helper prépare la donnée pour le runner -historique et l'agrégation par moteur. - -Adaptive masking ----------------- -Comme pour les modules philologiques (Sprint 61), on ne calcule -le rappel que si la GT contient au moins un token — pas de -calcul vide qui produirait du bruit dans le rapport. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Iterable, Optional - -from picarones.core.searchability import ( - _split_words, - compute_searchability, -) - -logger = logging.getLogger(__name__) - - -def compute_searchability_metrics( - reference: Optional[str], - hypothesis: Optional[str], - *, - max_distance: int = 2, -) -> Optional[dict]: - """Recherchabilité d'un document (adaptive). - - Retourne ``None`` si la GT est vide ou ne contient aucun - token — ce qui déclenche l'adaptive masking côté HTML. - """ - if not reference or not _split_words(reference): - return None - return compute_searchability( - reference, hypothesis or "", max_distance=max_distance, - ) - - -def aggregate_searchability_metrics( - per_doc: Iterable[Optional[dict]], -) -> Optional[dict]: - """Agrège les métriques par-doc en un score corpus-wide. - - Convention : on somme les ``n_gt_tokens`` et ``n_searchable`` - et on recalcule un rappel **micro** (cohérent avec ECE/MCE - Sprint 39 et NER Sprint 38). - """ - docs = [d for d in per_doc if d] - if not docs: - return None - n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs) - n_search = sum(int(d.get("n_searchable") or 0) for d in docs) - if n_gt == 0: - return None - # On garde l'union des missed_tokens (capped pour ne pas - # exploser le JSON sur de gros corpus) - missed: list[str] = [] - for d in docs: - missed.extend(d.get("missed_tokens") or []) - return { - "n_docs": len(docs), - "n_gt_tokens": n_gt, - "n_searchable": n_search, - "recall": n_search / n_gt, - "missed_tokens_sample": missed[:50], - "max_distance": docs[0].get("max_distance", 2), - } - +from picarones.measurements.searchability_runner import * # noqa: F401, F403 -__all__ = [ - "compute_searchability_metrics", - "aggregate_searchability_metrics", -] +import picarones.measurements.searchability_runner as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/specialization.py b/picarones/core/specialization.py index a3f251c56c578701544d9b57aea1f9d21554f033..491e79921d6ddc534fe743b4948f27bec5e9e114 100644 --- a/picarones/core/specialization.py +++ b/picarones/core/specialization.py @@ -1,187 +1,19 @@ -"""Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.specialization`. -Sprint 89 — A.II.8b du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.specialization import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -La matrice de divergence taxonomique (Sprint 35 -``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel -point ces moteurs se trompent-ils différemment ? »*. Ce -sprint la transforme en un **score de spécialisation** lisible -et complète la lecture par : - -- une **classification** discrète (similar / distinct / - highly_specialized) que le chercheur peut consommer sans - avoir à interpréter une distance ; -- un **top-N des paires** les plus spécialisées, qui répond - directement à la question *« quels moteurs sont les meilleurs - candidats pour un voting ensemble ? »*. - -Ce module **ne recommande pas** de pipeline d'ensemble — il -fournit l'observation factuelle et laisse le chercheur arbitrer. - -Convention de score -------------------- -On utilise la **Jensen-Shannon divergence** déjà calculée par -``inter_engine.jensen_shannon_divergence`` : elle est -symétrique, bornée dans [0, 1], et son interprétation est -intuitive : - -- ≈ 0 → profils taxonomiques identiques -- 1 → distributions totalement disjointes - -Dépendances ------------ -S'appuie strictement sur ``picarones.core.inter_engine`` (Sprint -35) — pas de double calcul, pas de logique nouvelle de -divergence. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Optional - -from picarones.core.inter_engine import jensen_shannon_divergence - -logger = logging.getLogger(__name__) - - -# Seuils par convention éditoriale. La roadmap ne fixe rien : -# ces seuils sont des **guides de lecture**, pas des verdicts. -# Le chercheur peut les surcharger via ``classify_specialization``. -DEFAULT_THRESHOLDS = ( - ("similar", 0.10), - ("distinct", 0.30), - ("highly_specialized", 1.01), # tout score ≥ 0.30 -) - - -def compute_specialization_score( - taxonomy_a: dict[str, float], - taxonomy_b: dict[str, float], -) -> float: - """Score de spécialisation entre deux moteurs ∈ [0, 1]. - - 0 = mêmes erreurs, 1 = erreurs totalement disjointes. - Délègue à ``jensen_shannon_divergence`` (Sprint 35). - """ - return jensen_shannon_divergence(taxonomy_a, taxonomy_b) - - -def classify_specialization( - score: float, - thresholds: Optional[tuple[tuple[str, float], ...]] = None, -) -> str: - """Classe le score en catégorie discrète. - - Convention : - - score < 0.10 → ``similar`` - - 0.10 ≤ score < 0.30 → ``distinct`` - - score ≥ 0.30 → ``highly_specialized`` - - L'utilisateur peut passer ses propres ``thresholds`` (liste - triée par valeur croissante de tuples ``(label, max_score)``). - """ - rules = thresholds or DEFAULT_THRESHOLDS - for label, max_score in rules: - if score < max_score: - return label - # Garde-fou : si aucun seuil ne match, dernière catégorie - return rules[-1][0] - - -def compute_specialization_matrix( - taxonomies: dict[str, dict[str, float]], -) -> Optional[dict]: - """Matrice de spécialisation symétrique entre tous les moteurs. - - Parameters - ---------- - taxonomies: - Map ``{engine_name: {error_class: count_or_proportion}}``. - - Returns - ------- - dict | None - ``{ - "engines": list[str], - "matrix": list[list[float]], # carrée, symétrique - "n_pairs": int, # paires distinctes - "max_score": float, - "max_pair": (str, str) | None, - }`` ; ``None`` si moins de 2 moteurs. - """ - if not taxonomies or len(taxonomies) < 2: - return None - engines = sorted(taxonomies.keys()) - n = len(engines) - matrix = [[0.0] * n for _ in range(n)] - n_pairs = 0 - max_score = 0.0 - max_pair: Optional[tuple[str, str]] = None - for i in range(n): - for j in range(i + 1, n): - score = compute_specialization_score( - taxonomies[engines[i]], taxonomies[engines[j]], - ) - matrix[i][j] = score - matrix[j][i] = score - n_pairs += 1 - if score > max_score: - max_score = score - max_pair = (engines[i], engines[j]) - return { - "engines": engines, - "matrix": matrix, - "n_pairs": n_pairs, - "max_score": max_score, - "max_pair": max_pair, - } - - -def top_specialized_pairs( - matrix_data: Optional[dict], - n: int = 5, - *, - min_score: float = 0.0, -) -> list[dict]: - """Top-N paires de moteurs triées par score décroissant. - - Returns - ------- - list[dict] - Une liste de ``{ - "engine_a": str, "engine_b": str, - "score": float, "category": str, - }`` triée par score décroissant. Liste vide si - ``matrix_data`` est ``None`` ou que toutes les paires - sont sous ``min_score``. - """ - if not matrix_data: - return [] - engines = matrix_data["engines"] - matrix = matrix_data["matrix"] - pairs: list[dict] = [] - for i, engine_a in enumerate(engines): - for j in range(i + 1, len(engines)): - score = matrix[i][j] - if score < min_score: - continue - pairs.append({ - "engine_a": engine_a, - "engine_b": engines[j], - "score": score, - "category": classify_specialization(score), - }) - pairs.sort(key=lambda p: -p["score"]) - return pairs[:n] - +from picarones.measurements.specialization import * # noqa: F401, F403 -__all__ = [ - "DEFAULT_THRESHOLDS", - "compute_specialization_score", - "classify_specialization", - "compute_specialization_matrix", - "top_specialized_pairs", -] +import picarones.measurements.specialization as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/statistics.py b/picarones/core/statistics.py index e5a6a8d6ee204d2e06fc87ff67458a68d950785c..ea93762bb6e11b00726a627cb7b29db399186f45 100644 --- a/picarones/core/statistics.py +++ b/picarones/core/statistics.py @@ -1,1127 +1,19 @@ -"""Tests statistiques et clustering d'erreurs pour Picarones. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.statistics`. -Fonctions fournies ------------------- -- wilcoxon_test(a, b) : Wilcoxon signé-rangé (2 moteurs appariés) -- bootstrap_ci(values, ...) : intervalle de confiance à 95 % par bootstrap -- compute_pairwise_stats(...) : matrice de Wilcoxon entre toutes les paires -- friedman_test(engine_cer_map) : Friedman (k moteurs, n documents) [Sprint 17] -- nemenyi_posthoc(engine_cer_map) : post-hoc Nemenyi avec critical distance [Sprint 17] -- build_critical_difference_svg(...) : rendu SVG du CDD (Demšar 2006) [Sprint 17] -- compute_pareto_front(points, ...) : frontière de Pareto multi-objectifs [Sprint 19] -- cluster_errors(...) : regroupement des patterns d'erreurs -- compute_correlation_matrix(...) : matrice de corrélation des métriques -- compute_reliability_curve(...) : courbe CER vs. % docs les plus faciles -- compute_venn_data(...) : diagramme de Venn 2/3 moteurs -""" - -from __future__ import annotations - -import math -import random -import re -from collections import defaultdict -from dataclasses import dataclass -from typing import Optional - -# Import optionnel de scipy — utilisé pour le test de Wilcoxon si disponible -# (méthode exacte pour n ≤ 25, approximation normale pour n > 25). -# En son absence, l'implémentation native (approximation normale pour n ≥ 10) -# est utilisée automatiquement. -try: - from scipy.stats import wilcoxon as _scipy_wilcoxon # type: ignore[import-untyped] - _SCIPY_AVAILABLE = True -except ImportError: - _SCIPY_AVAILABLE = False - - -# --------------------------------------------------------------------------- -# Bootstrap CI -# --------------------------------------------------------------------------- - -def bootstrap_ci( - values: list[float], - n_iter: int = 1000, - ci: float = 0.95, - seed: int = 42, -) -> tuple[float, float]: - """Intervalle de confiance par bootstrap. - - Parameters - ---------- - values : liste des valeurs (ex. CER par document) - n_iter : nombre d'itérations bootstrap (défaut 1000) - ci : niveau de confiance (défaut 0.95 → 95 %) - seed : graine RNG pour reproductibilité - - Returns - ------- - (lower, upper) — les bornes de l'IC à ``ci`` % - """ - if not values: - return (0.0, 0.0) - rng = random.Random(seed) - n = len(values) - means = [] - for _ in range(n_iter): - sample = [values[rng.randint(0, n - 1)] for _ in range(n)] - means.append(sum(sample) / n) - means.sort() - alpha = (1.0 - ci) / 2.0 - lo_idx = max(0, int(alpha * n_iter)) - hi_idx = min(n_iter - 1, int((1.0 - alpha) * n_iter)) - return (means[lo_idx], means[hi_idx]) - - -# --------------------------------------------------------------------------- -# Test de Wilcoxon signé-rangé (implémentation pure Python) -# --------------------------------------------------------------------------- - -def wilcoxon_test( - a: list[float], - b: list[float], - zero_method: str = "wilcox", -) -> dict: - """Test de Wilcoxon signé-rangé entre deux séries de CER appariées. - - Retourne un dict avec : - - statistic : W = min(W⁺, W⁻) - - p_value : p-value bilatérale - - significant : bool (p < 0.05) - - interpretation : phrase lisible - - n_pairs : nombre de paires utilisées (après retrait des zéros) - - W_plus : somme des rangs des différences positives - - W_minus : somme des rangs des différences négatives - - Hypothèses et limites - --------------------- - * Les observations sont appariées (même corpus, deux moteurs différents). - * Le test est non-paramétrique : aucune hypothèse de normalité des CER. - * ``zero_method="wilcox"`` (défaut) : les paires sans différence (aᵢ = bᵢ) - sont simplement exclues. Les autres méthodes (``"pratt"``, ``"zsplit"``) - nécessitent scipy. - * **Approximation normale** (implémentation native, n ≥ 10) : - L'approximation est raisonnable pour n ≥ 10 et converge vers la - distribution exacte. Pour n < 10, une table critique simplifiée est - utilisée (p ∈ {0.04, 0.20}) — résultat **conservateur**. - * **scipy** (si installé) : ``scipy.stats.wilcoxon`` est utilisé à la place - de l'approximation native. scipy utilise la méthode exacte pour n ≤ 25 - et l'approximation normale pour n > 25, ce qui est plus précis. - * **Validité** : le test suppose la symétrie de la distribution des - différences. Avec de très petits n (< 5), les résultats sont peu fiables - quelle que soit la méthode. - - Parameters - ---------- - a, b : séries de CER (même longueur, même ordre de documents) - zero_method : gestion des paires nulles (défaut : ``"wilcox"``) - """ - if len(a) != len(b): - raise ValueError("Les deux listes doivent avoir la même longueur") - - diffs = [x - y for x, y in zip(a, b)] - - # Retirer les zéros (méthode "wilcox") - if zero_method == "wilcox": - diffs = [d for d in diffs if d != 0.0] - - n = len(diffs) - if n == 0: - return { - "statistic": 0.0, - "p_value": 1.0, - "significant": False, - "interpretation": "Aucune différence entre les deux concurrents.", - "n_pairs": 0, - } - - # Rangs des valeurs absolues - abs_diffs = [abs(d) for d in diffs] - indexed = sorted(enumerate(abs_diffs), key=lambda x: x[1]) - - # Gestion des ex-aequo : rang moyen - ranks = [0.0] * n - i = 0 - while i < n: - j = i - while j < n and abs_diffs[indexed[j][0]] == abs_diffs[indexed[i][0]]: - j += 1 - avg_rank = (i + j + 1) / 2.0 # rang moyen (1-based) - for k in range(i, j): - ranks[indexed[k][0]] = avg_rank - i = j - - W_plus = sum(ranks[k] for k in range(n) if diffs[k] > 0) - W_minus = sum(ranks[k] for k in range(n) if diffs[k] < 0) - W = min(W_plus, W_minus) - - # Calcul de la p-value : scipy si disponible, sinon approximation native - if _SCIPY_AVAILABLE: - try: - scipy_res = _scipy_wilcoxon(diffs, zero_method=zero_method) - p_value = float(scipy_res.pvalue) - except Exception: - # Repli sur l'implémentation native en cas d'erreur scipy - p_value = _native_p_value(n, W) - else: - p_value = _native_p_value(n, W) - - significant = p_value < 0.05 - - if significant: - better = "premier" if W_plus < W_minus else "second" - interpretation = ( - f"Différence statistiquement significative (p = {p_value:.4f} < 0.05). " - f"Le {better} concurrent obtient de meilleurs scores." - ) - else: - interpretation = ( - f"Différence non significative (p = {p_value:.4f} ≥ 0.05). " - "On ne peut pas conclure que l'un surpasse l'autre." - ) - - return { - "statistic": round(W, 4), - "p_value": round(p_value, 6), - "significant": significant, - "interpretation": interpretation, - "n_pairs": n, - "W_plus": round(W_plus, 4), - "W_minus": round(W_minus, 4), - } - - -def _normal_sf(z: float) -> float: - """Survival function de la loi normale standard (1 - CDF).""" - # Approximation Abramowitz & Stegun 26.2.17 - t = 1.0 / (1.0 + 0.2316419 * abs(z)) - poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 - + t * (-1.821255978 + t * 1.330274429)))) - phi_z = math.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi) - p = phi_z * poly - return p if z >= 0 else 1.0 - p - - -# Table des valeurs critiques de W pour α=0.05 bilatéral (test exact, source : tables de Wilcoxon) -_W_CRITICAL = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 2, 8: 3, 9: 5} - - -def _wilcoxon_exact_p(n: int, w: float) -> float: - """P-value approximée pour petits n (< 10) via table critique simplifiée. - - Note : résultat **conservateur** — seules deux valeurs sont retournées : - 0.04 (significatif à 5 %) ou 0.20 (non significatif). - Préférer scipy pour des p-values exactes. - """ - critical = _W_CRITICAL.get(n, 0) - if w <= critical: - return 0.04 # significatif à 5 % - return 0.20 # non significatif (approximation conservative) - - -def _native_p_value(n: int, W: float) -> float: - """Calcule la p-value via l'approximation normale (n ≥ 10) ou la table exacte (n < 10).""" - if n >= 10: - mu = n * (n + 1) / 4.0 - sigma2 = n * (n + 1) * (2 * n + 1) / 24.0 - if sigma2 <= 0: - return 1.0 - z = abs((W + 0.5) - mu) / math.sqrt(sigma2) # correction de continuité - return 2.0 * _normal_sf(z) # test bilatéral - return _wilcoxon_exact_p(n, W) - - -# --------------------------------------------------------------------------- -# Matrice des tests pairwise -# --------------------------------------------------------------------------- - -def compute_pairwise_stats( - engine_cer_map: dict[str, list[float]], -) -> list[dict]: - """Calcule les tests de Wilcoxon entre toutes les paires de concurrents. - - Parameters - ---------- - engine_cer_map : dict {engine_name → [cer_doc1, cer_doc2, ...]} - - Returns - ------- - Liste de dicts, un par paire : - - engine_a, engine_b, statistic, p_value, significant, interpretation - """ - names = list(engine_cer_map.keys()) - results = [] - for i in range(len(names)): - for j in range(i + 1, len(names)): - a_name, b_name = names[i], names[j] - a_vals = engine_cer_map[a_name] - b_vals = engine_cer_map[b_name] - # Aligner les longueurs - min_len = min(len(a_vals), len(b_vals)) - if min_len < 2: - continue - res = wilcoxon_test(a_vals[:min_len], b_vals[:min_len]) - results.append({ - "engine_a": a_name, - "engine_b": b_name, - **res, - }) - return results - - -# --------------------------------------------------------------------------- -# Test de Friedman + post-hoc Nemenyi (Sprint 17) -# --------------------------------------------------------------------------- -# -# Référence : Demšar, J. (2006), "Statistical Comparisons of Classifiers over -# Multiple Data Sets", Journal of Machine Learning Research 7:1-30. Standard -# de facto pour comparer plusieurs systèmes sur plusieurs datasets — ici : -# plusieurs moteurs OCR sur plusieurs documents. Le CDD (critical difference -# diagram) issu de Nemenyi est le rendu canonique. - -# Valeurs critiques de la distribution du Studentized Range divisées par √2, -# pour df = ∞ (approximation usuelle pour Nemenyi). Source : tables de Tukey. -# Clé : nombre de traitements k ; valeur : q_α pour α ∈ {0.05, 0.01}. -_NEMENYI_Q_TABLE = { - # k q_0.05 q_0.01 - 2: (1.960, 2.576), - 3: (2.343, 2.913), - 4: (2.569, 3.113), - 5: (2.728, 3.255), - 6: (2.850, 3.364), - 7: (2.949, 3.452), - 8: (3.031, 3.526), - 9: (3.102, 3.590), - 10: (3.164, 3.646), - 11: (3.219, 3.696), - 12: (3.268, 3.741), - 13: (3.313, 3.781), - 14: (3.354, 3.818), - 15: (3.391, 3.853), - 16: (3.426, 3.886), - 17: (3.458, 3.916), - 18: (3.489, 3.944), - 19: (3.517, 3.970), - 20: (3.544, 3.995), - 25: (3.658, 4.095), - 30: (3.739, 4.167), - 40: (3.858, 4.272), - 50: (3.945, 4.349), -} - - -def _chi_square_sf(x: float, df: int) -> float: - """Survival function de la loi chi², 1 - CDF(x). - - Utilise scipy si disponible (méthode exacte), sinon Wilson-Hilferty - (approximation normale précise dès df ≥ 3). - """ - if x <= 0 or df <= 0: - return 1.0 - try: - from scipy.stats import chi2 as _chi2 # type: ignore[import-untyped] - return float(_chi2.sf(x, df)) - except ImportError: - pass - # Wilson-Hilferty : transforme chi² en approximation normale - z = (((x / df) ** (1.0 / 3.0)) - (1.0 - 2.0 / (9.0 * df))) / math.sqrt(2.0 / (9.0 * df)) - return _normal_sf(z) - - -def _rank_row(values: list[float]) -> list[float]: - """Rangs d'une ligne — petit = rang 1. Ex-aequo : rangs moyens.""" - n = len(values) - indexed = sorted(range(n), key=lambda i: values[i]) - ranks = [0.0] * n - i = 0 - while i < n: - j = i - while j < n and values[indexed[j]] == values[indexed[i]]: - j += 1 - avg_rank = (i + j + 1) / 2.0 # 1-based - for k in range(i, j): - ranks[indexed[k]] = avg_rank - i = j - return ranks - - -def _aligned_cer_matrix( - engine_cer_map: dict[str, list[float]], -) -> tuple[list[str], list[list[float]]]: - """Construit la matrice (k moteurs × n documents) alignée sur la longueur - minimale. Retourne ``(noms, matrice_colonne_par_moteur)``. - - Friedman exige des blocs (documents) complets : si les moteurs n'ont pas - tous été exécutés sur les mêmes documents, on tronque à la longueur - minimale, documentée dans le résultat via ``n_blocks``. - """ - names = list(engine_cer_map.keys()) - if not names: - return [], [] - min_len = min(len(v) for v in engine_cer_map.values()) - if min_len == 0: - return names, [] - matrix = [engine_cer_map[n][:min_len] for n in names] - return names, matrix - - -def friedman_test(engine_cer_map: dict[str, list[float]]) -> dict: - """Test de Friedman — k moteurs sur n documents appariés. - - Test non-paramétrique équivalent à l'ANOVA à mesures répétées pour des - données ordinales. Hypothèse nulle : tous les moteurs ont la même - performance moyenne. Rejet → au moins un moteur diffère des autres. - - Parameters - ---------- - engine_cer_map: - Dict ``{engine_name → [cer_doc1, cer_doc2, ...]}``. Tous les moteurs - doivent avoir été évalués sur les mêmes documents (dans le même ordre). - - Returns - ------- - dict avec : - - ``statistic`` : Q corrigé pour les ex-aequo - - ``p_value`` : p-value (scipy si dispo, sinon Wilson-Hilferty) - - ``significant`` : bool, p < 0.05 - - ``df`` : degrés de liberté = k - 1 - - ``n_blocks`` : nombre de documents (blocs) utilisés - - ``n_engines`` : nombre de moteurs (k) - - ``mean_ranks`` : dict ``{engine: rang_moyen}`` - - ``interpretation``: phrase lisible - - ``error`` : message si le test n'est pas applicable - """ - names, matrix = _aligned_cer_matrix(engine_cer_map) - k = len(names) - n = len(matrix[0]) if matrix else 0 - - if k < 2: - return { - "statistic": 0.0, "p_value": 1.0, "significant": False, - "df": 0, "n_blocks": n, "n_engines": k, - "mean_ranks": {names[0]: 1.0} if k == 1 else {}, - "interpretation": "Test de Friedman non applicable : il faut au moins 2 moteurs.", - "error": "not_enough_engines", - } - if n < 2: - return { - "statistic": 0.0, "p_value": 1.0, "significant": False, - "df": k - 1, "n_blocks": n, "n_engines": k, - "mean_ranks": {name: 1.0 for name in names}, - "interpretation": "Test de Friedman non applicable : il faut au moins 2 documents communs.", - "error": "not_enough_blocks", - } - - # Rangs par bloc (document) : pour chaque doc, ranger les k moteurs - ranks_by_engine: list[list[float]] = [[] for _ in range(k)] - for j in range(n): - row = [matrix[i][j] for i in range(k)] - row_ranks = _rank_row(row) - for i in range(k): - ranks_by_engine[i].append(row_ranks[i]) - - rank_sums = [sum(r) for r in ranks_by_engine] - mean_ranks = {names[i]: rank_sums[i] / n for i in range(k)} - - # Statistique Q non-corrigée (sans ex-aequo) - # Q = 12 / (n·k·(k+1)) · Σ R_j² − 3·n·(k+1) - Q = (12.0 / (n * k * (k + 1))) * sum(rs ** 2 for rs in rank_sums) - 3.0 * n * (k + 1) - - # Correction pour les ex-aequo (ties factor) — ajuste si des rangs sont - # partagés dans certains blocs. Formule : Q_corr = Q / (1 - T/(n·(k³−k))) - # où T = Σ (tⱼ³ − tⱼ) sur tous les groupes d'ex-aequo. - tie_correction = 0.0 - for j in range(n): - row = [matrix[i][j] for i in range(k)] - sorted_row = sorted(row) - i = 0 - while i < len(sorted_row): - count = 1 - while i + count < len(sorted_row) and sorted_row[i + count] == sorted_row[i]: - count += 1 - if count > 1: - tie_correction += count ** 3 - count - i += count - denom = 1.0 - tie_correction / (n * (k ** 3 - k)) if k >= 2 else 1.0 - if denom > 0: - Q = Q / denom - - df = k - 1 - p_value = _chi_square_sf(Q, df) - significant = p_value < 0.05 - - if significant: - interpretation = ( - f"Test de Friedman significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). " - f"Au moins un moteur diffère des autres — utiliser le post-hoc Nemenyi " - f"pour identifier les paires distinguables." - ) - else: - interpretation = ( - f"Test de Friedman non significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). " - f"Aucune différence globale détectée entre les moteurs sur ce corpus." - ) - - return { - "statistic": round(Q, 4), - "p_value": round(p_value, 6), - "significant": significant, - "df": df, - "n_blocks": n, - "n_engines": k, - "mean_ranks": {k_: round(v, 4) for k_, v in mean_ranks.items()}, - "interpretation": interpretation, - } - - -def _nemenyi_critical_value(k: int, alpha: float = 0.05) -> Optional[float]: - """Valeur critique q_α pour k traitements, df = ∞. - - Retourne ``None`` si k est hors table (< 2 ou > 50). - """ - if k < 2: - return None - if k in _NEMENYI_Q_TABLE: - q05, q01 = _NEMENYI_Q_TABLE[k] - return q05 if alpha == 0.05 else q01 if alpha == 0.01 else q05 - # Au-delà de la table : borne supérieure (conservateur) - max_k = max(_NEMENYI_Q_TABLE.keys()) - if k > max_k: - q05, q01 = _NEMENYI_Q_TABLE[max_k] - return q05 if alpha == 0.05 else q01 - # Entre deux clés : interpolation linéaire - keys = sorted(_NEMENYI_Q_TABLE.keys()) - for i in range(len(keys) - 1): - if keys[i] < k < keys[i + 1]: - lo, hi = keys[i], keys[i + 1] - q_lo = _NEMENYI_Q_TABLE[lo][0 if alpha == 0.05 else 1] - q_hi = _NEMENYI_Q_TABLE[hi][0 if alpha == 0.05 else 1] - frac = (k - lo) / (hi - lo) - return q_lo + frac * (q_hi - q_lo) - return None - - -def nemenyi_posthoc( - engine_cer_map: dict[str, list[float]], - alpha: float = 0.05, -) -> dict: - """Post-hoc de Nemenyi — identifie les paires de moteurs statistiquement - indiscernables après un test de Friedman. - - Calcule la *critical distance* CD = q_α · √(k·(k+1) / (6·n)). Deux moteurs - dont les rangs moyens diffèrent de moins que CD ne sont **pas** - statistiquement distinguables au seuil α. - - Returns - ------- - dict avec : - - ``alpha`` : seuil utilisé - - ``critical_distance`` : CD calculée - - ``q_alpha`` : valeur critique q_α issue de la table - - ``n_blocks``, ``n_engines`` - - ``mean_ranks`` : rangs moyens par moteur (dict) - - ``engines_sorted`` : liste des moteurs triés par rang croissant - - ``significant_matrix`` : matrice bool (list[list[bool]]), - ``True`` = paire significativement différente - - ``tied_groups`` : liste de listes de moteurs indiscernables - (groupes maximaux d'ex-aequo pratiques) - - ``error`` : présent si le test n'est pas applicable - """ - names, matrix = _aligned_cer_matrix(engine_cer_map) - k = len(names) - n = len(matrix[0]) if matrix else 0 - - if k < 2 or n < 2: - return { - "alpha": alpha, - "critical_distance": 0.0, - "q_alpha": 0.0, - "n_blocks": n, - "n_engines": k, - "mean_ranks": {name: 1.0 for name in names}, - "engines_sorted": list(names), - "significant_matrix": [[False] * k for _ in range(k)], - "tied_groups": [list(names)] if names else [], - "error": "not_enough_data", - } - - # Friedman fournit les rangs moyens — on les recalcule ici pour rester - # autonome (sans forcer l'utilisateur à chaîner les deux appels). - ranks_by_engine: list[list[float]] = [[] for _ in range(k)] - for j in range(n): - row = [matrix[i][j] for i in range(k)] - row_ranks = _rank_row(row) - for i in range(k): - ranks_by_engine[i].append(row_ranks[i]) +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.statistics import ...``) de continuer +à fonctionner sans modification. - mean_ranks_list = [sum(r) / n for r in ranks_by_engine] - mean_ranks = {names[i]: round(mean_ranks_list[i], 4) for i in range(k)} - - q_alpha = _nemenyi_critical_value(k, alpha) or 0.0 - critical_distance = q_alpha * math.sqrt(k * (k + 1) / (6.0 * n)) - - # Matrice de significativité : paire (i,j) significative si |R_i - R_j| > CD - significant_matrix = [ - [ - (i != j) and (abs(mean_ranks_list[i] - mean_ranks_list[j]) > critical_distance) - for j in range(k) - ] - for i in range(k) - ] - - # Groupes d'ex-aequo pratiques : fenêtre glissante sur les rangs triés. - # Deux moteurs sont dans le même groupe si leur écart ≤ CD. - order = sorted(range(k), key=lambda i: mean_ranks_list[i]) - sorted_names = [names[i] for i in order] - sorted_ranks = [mean_ranks_list[i] for i in order] - - tied_groups: list[list[str]] = [] - i = 0 - while i < len(sorted_names): - # étendre le groupe tant que le moteur suivant est à ≤ CD du premier du groupe - j = i - while j + 1 < len(sorted_names) and (sorted_ranks[j + 1] - sorted_ranks[i]) <= critical_distance: - j += 1 - tied_groups.append(sorted_names[i:j + 1]) - i = j + 1 if j > i else i + 1 - - return { - "alpha": alpha, - "critical_distance": round(critical_distance, 4), - "q_alpha": round(q_alpha, 4), - "n_blocks": n, - "n_engines": k, - "mean_ranks": mean_ranks, - "engines_sorted": sorted_names, - "significant_matrix": significant_matrix, - "tied_groups": tied_groups, - } - - -# --------------------------------------------------------------------------- -# Critical Difference Diagram — rendu SVG (Sprint 17) -# --------------------------------------------------------------------------- - -def build_critical_difference_svg( - nemenyi_result: dict, - width: int = 780, - row_height: int = 22, -) -> str: - """Génère le SVG du Critical Difference Diagram (Demšar 2006). - - Le diagramme montre : - * un axe horizontal des rangs moyens (1 à k), - * chaque moteur positionné sur l'axe à son rang moyen, - * des barres horizontales épaisses reliant les moteurs statistiquement - indiscernables (distance ≤ CD), - * la longueur de CD affichée au-dessus de l'axe en référence. - - Parameters - ---------- - nemenyi_result: - Résultat de ``nemenyi_posthoc``. - width: - Largeur totale du SVG en pixels. - row_height: - Hauteur de chaque ligne d'étiquette moteur (auto-adaptatif). - - Returns - ------- - Chaîne contenant le SVG (balise racine ````). - """ - k = nemenyi_result.get("n_engines", 0) - if k < 2 or nemenyi_result.get("error"): - return ( - '' - '' - 'Critical Difference Diagram non calculable — données insuffisantes.' - '' - ) - - engines_sorted: list[str] = list(nemenyi_result.get("engines_sorted", [])) - mean_ranks: dict[str, float] = dict(nemenyi_result.get("mean_ranks", {})) - tied_groups: list[list[str]] = list(nemenyi_result.get("tied_groups", [])) - cd: float = float(nemenyi_result.get("critical_distance", 0.0)) - - # Dimensions - left_pad, right_pad = 40, 40 - top_pad = 50 # espace pour l'affichage CD - axis_y = top_pad + 10 - bars_start_y = axis_y + 20 # première barre d'ex-aequo sous l'axe - # Empiler une ligne par groupe + une ligne par étiquette - label_rows = k # chaque moteur a sa propre ligne de label - bars_count = len(tied_groups) - total_h = bars_start_y + bars_count * 10 + label_rows * row_height + 20 - - axis_x0, axis_x1 = left_pad, width - right_pad - axis_width = axis_x1 - axis_x0 - - def x_for_rank(r: float) -> float: - # Rang 1 à gauche, rang k à droite - if k <= 1: - return axis_x0 - return axis_x0 + (r - 1.0) / (k - 1.0) * axis_width - - parts: list[str] = [] - parts.append( - f'' - ) - parts.append('') - - # Barre CD de référence (en haut, à gauche de l'axe) - if cd > 0 and k >= 2: - cd_bar_x0 = axis_x0 - cd_bar_x1 = axis_x0 + (cd / max(1, k - 1)) * axis_width - cd_y = top_pad - 20 - parts.append(f'') - parts.append(f'') - parts.append(f'') - parts.append(f'CD = {cd:.3f}') - - # Axe principal - parts.append(f'') - # Ticks entiers - for r in range(1, k + 1): - xt = x_for_rank(r) - parts.append(f'') - parts.append(f'{r}') - - # Barres reliant les groupes indiscernables - for i, group in enumerate(tied_groups): - if len(group) < 2: - continue - rs = [mean_ranks[n] for n in group] - x0 = x_for_rank(min(rs)) - x1 = x_for_rank(max(rs)) - y_bar = bars_start_y + i * 10 - parts.append(f'') - - # Étiquettes des moteurs : la moitié la plus basse à gauche, l'autre à droite - labels_y_base = bars_start_y + bars_count * 10 + 15 - half = (len(engines_sorted) + 1) // 2 - left_engines = engines_sorted[:half] - right_engines = engines_sorted[half:] - - for idx, name in enumerate(left_engines): - r = mean_ranks[name] - x = x_for_rank(r) - y_label = labels_y_base + idx * row_height - # Ligne du moteur vers axe - parts.append(f'') - parts.append(f'') - parts.append(f'{_svg_escape(name)} ' - f'({r:.2f})') - - for idx, name in enumerate(right_engines): - r = mean_ranks[name] - x = x_for_rank(r) - y_label = labels_y_base + idx * row_height - parts.append(f'') - parts.append(f'') - parts.append(f'{_svg_escape(name)} ' - f'({r:.2f})') - - parts.append('') - return "".join(parts) - - -def _svg_escape(text: str) -> str: - """Échappe un texte pour inclusion sûre dans un nœud SVG/XML.""" - return (text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - .replace("'", "'")) - - -# --------------------------------------------------------------------------- -# Frontière de Pareto (Sprint 19) -# --------------------------------------------------------------------------- - -def compute_pareto_front( - points: list[dict], - objectives: tuple[str, ...] = ("cer", "cost"), - name_key: str = "engine", - minimize: Optional[tuple[bool, ...]] = None, -) -> list[str]: - """Calcule la frontière de Pareto sur ``len(objectives)`` dimensions. - - Un point ``p`` est Pareto-dominant si aucun autre point n'a, pour TOUS - les objectifs, une valeur au moins aussi bonne ET au moins une valeur - strictement meilleure. - - Parameters - ---------- - points: - Liste de dicts. Chaque dict doit contenir ``name_key`` et toutes les - clés de ``objectives``. Les points dont une valeur d'objectif est - ``None`` sont ignorés (pas de comparaison possible). - objectives: - Clés des objectifs à minimiser/maximiser. - name_key: - Clé identifiant le point (par défaut ``"engine"``). - minimize: - Pour chaque objectif, ``True`` = minimiser (ex. CER, coût), - ``False`` = maximiser (ex. ancrage). Doit avoir la même longueur - que ``objectives``. - - Returns - ------- - Liste des ``name`` des points sur le front Pareto, ordre stable depuis - ``points``. - """ - if minimize is None: - minimize = tuple(True for _ in objectives) - if len(minimize) != len(objectives): - raise ValueError("`minimize` doit avoir la même longueur que `objectives`") - - valid = [] - for p in points: - try: - vals = tuple(float(p[k]) for k in objectives) - except (KeyError, TypeError, ValueError): - continue - valid.append((p[name_key], vals)) - - front: list[str] = [] - for name_a, vals_a in valid: - dominated = False - for name_b, vals_b in valid: - if name_a == name_b: - continue - # B domine A si B est ≥ aussi bon partout ET strictement meilleur quelque part - better_or_equal_everywhere = True - strictly_better_somewhere = False - for va, vb, mini in zip(vals_a, vals_b, minimize): - if mini: - if vb > va: - better_or_equal_everywhere = False - break - if vb < va: - strictly_better_somewhere = True - else: # maximiser - if vb < va: - better_or_equal_everywhere = False - break - if vb > va: - strictly_better_somewhere = True - if better_or_equal_everywhere and strictly_better_somewhere: - dominated = True - break - if not dominated: - front.append(name_a) - return front - - -# --------------------------------------------------------------------------- -# Clustering des patterns d'erreurs -# --------------------------------------------------------------------------- - -# Patterns d'erreurs fréquentes (OCR + HTR documents patrimoniaux) -_ERROR_PATTERNS = [ - # (pattern_re, label) - (r"\brn\b.*\bm\b|\bm\b.*\brn\b|rn→m|m→rn", "confusion rn/m"), - (r"[lI]→1|1→[lI]|l→1|1→l|I→1|1→I", "confusion l/1/I"), - (r"u→n|n→u|v→u|u→v", "confusion u/n/v"), - (r"[oO]→0|0→[oO]", "confusion O/0"), - (r"ſ→[fs]|[fs]→ſ", "confusion ſ/f/s"), - (r"é→e|è→e|ê→e|e→[éèê]", "erreur diacritique é/e"), - (r"œ→oe|oe→œ|æ→ae|ae→æ", "ligature œ/æ"), - (r"[fF]i→fi|fi→[fF]i", "ligature fi"), - (r"[fF]l→fl|fl→[fF]l", "ligature fl"), - (r"\s+→''|''→\s+", "segmentation espace"), -] - -def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]: - """Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution.""" - from picarones.report.diff_utils import compute_word_diff - ops = compute_word_diff(gt, hyp) - pairs = [] - for op in ops: - if op["op"] == "replace": - pairs.append((op["old"], op["new"])) - elif op["op"] == "delete": - pairs.append((op["text"], "")) - elif op["op"] == "insert": - pairs.append(("", op["text"])) - return pairs - - -@dataclass -class ErrorCluster: - """Un cluster d'erreurs similaires.""" - cluster_id: int - label: str - """Description humaine du pattern (ex. 'confusion rn/m').""" - count: int - examples: list[dict] - """Liste de {engine, gt_fragment, ocr_fragment}.""" - - def as_dict(self) -> dict: - return { - "cluster_id": self.cluster_id, - "label": self.label, - "count": self.count, - "examples": self.examples[:5], # 5 exemples max - } - - -def cluster_errors( - error_data: list[dict], - max_clusters: int = 8, -) -> list[ErrorCluster]: - """Regroupe les erreurs en clusters avec labels lisibles. - - Parameters - ---------- - error_data : liste de dicts {engine, gt, hypothesis} - max_clusters : nombre max de clusters à retourner - - Returns - ------- - Liste de ErrorCluster triée par count décroissant. - """ - # Collecter tous les patterns d'erreur avec contexte - # Clé : catégorie d'erreur → liste d'exemples - bucket: dict[str, list[dict]] = defaultdict(list) - other_pairs: list[dict] = [] - - for item in error_data: - engine = item.get("engine", "") - gt = item.get("gt", "") - hyp = item.get("hypothesis", "") - pairs = _extract_error_pairs(gt, hyp) - - for old, new in pairs: - if not old and not new: - continue - matched = False - # Essayer de matcher un pattern connu - probe = f"{old}→{new}" - for _pat, label in _ERROR_PATTERNS: - try: - if re.search(_pat, probe, re.IGNORECASE): - bucket[label].append({ - "engine": engine, - "gt_fragment": old, - "ocr_fragment": new, - }) - matched = True - break - except re.error: - pass - - if not matched: - # Regrouper les substitutions restantes par paire de caractères - if len(old) <= 3 and len(new) <= 3: - key = f"{old}→{new}" if (old and new) else (f"—→{new}" if new else f"{old}→—") - bucket[key].append({ - "engine": engine, - "gt_fragment": old, - "ocr_fragment": new, - }) - else: - other_pairs.append({ - "engine": engine, - "gt_fragment": old, - "ocr_fragment": new, - }) - - # Construire les clusters triés par fréquence - clusters: list[ErrorCluster] = [] - cluster_id = 1 - sorted_buckets = sorted(bucket.items(), key=lambda x: -len(x[1])) - - for label, examples in sorted_buckets[:max_clusters - 1]: - clusters.append(ErrorCluster( - cluster_id=cluster_id, - label=label, - count=len(examples), - examples=examples, - )) - cluster_id += 1 - - # Cluster "autres" - if other_pairs: - clusters.append(ErrorCluster( - cluster_id=cluster_id, - label="autres substitutions", - count=len(other_pairs), - examples=other_pairs, - )) - - # Trier par count décroissant et limiter - clusters.sort(key=lambda c: -c.count) - return clusters[:max_clusters] - - -# --------------------------------------------------------------------------- -# Matrice de corrélation entre métriques -# --------------------------------------------------------------------------- - -def _pearson(x: list[float], y: list[float]) -> float: - """Coefficient de corrélation de Pearson.""" - n = len(x) - if n < 2: - return 0.0 - mx = sum(x) / n - my = sum(y) / n - num = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) - den = math.sqrt( - sum((xi - mx) ** 2 for xi in x) * sum((yi - my) ** 2 for yi in y) - ) - return num / den if den > 0 else 0.0 - - -def compute_correlation_matrix( - metrics_per_doc: list[dict], - metric_keys: Optional[list[str]] = None, -) -> dict: - """Calcule la matrice de corrélation entre toutes les métriques numériques. - - Parameters - ---------- - metrics_per_doc : liste de dicts, un par document, contenant les métriques - metric_keys : clés à inclure (None → toutes les clés numériques) - - Returns - ------- - { - "labels": [...], - "matrix": [[r_ij, ...], ...] // coefficients de Pearson - } - """ - if not metrics_per_doc: - return {"labels": [], "matrix": []} - - if metric_keys is None: - # Déduire les clés numériques - sample = metrics_per_doc[0] - metric_keys = [k for k, v in sample.items() if isinstance(v, (int, float))] - - # Construire les vecteurs - vectors: dict[str, list[float]] = {k: [] for k in metric_keys} - for doc in metrics_per_doc: - for k in metric_keys: - v = doc.get(k) - vectors[k].append(float(v) if v is not None else 0.0) - - # Calculer la matrice - labels = metric_keys - n = len(labels) - matrix = [] - for i in range(n): - row = [] - for j in range(n): - r = _pearson(vectors[labels[i]], vectors[labels[j]]) - row.append(round(r, 4)) - matrix.append(row) - - return {"labels": labels, "matrix": matrix} - - -# --------------------------------------------------------------------------- -# Courbe de fiabilité (reliability curve) -# --------------------------------------------------------------------------- - -def compute_reliability_curve( - cer_values: list[float], - steps: int = 20, -) -> list[dict]: - """Pour les X% documents les plus faciles, quel est le CER moyen ? - - Returns - ------- - Liste de {pct_docs: float, mean_cer: float} - """ - if not cer_values: - return [] - sorted_cer = sorted(cer_values) - n = len(sorted_cer) - points = [] - for step in range(1, steps + 1): - pct = step / steps - cutoff = max(1, int(pct * n)) - subset = sorted_cer[:cutoff] - mean_cer = sum(subset) / len(subset) - points.append({"pct_docs": round(pct * 100, 1), "mean_cer": round(mean_cer, 6)}) - return points - - -# --------------------------------------------------------------------------- -# Données pour le diagramme de Venn (erreurs communes / exclusives) -# --------------------------------------------------------------------------- - -def compute_venn_data( - engine_error_sets: dict[str, set[str]], -) -> dict: - """Calcule les cardinalités pour un diagramme de Venn entre 2 ou 3 concurrents. - - Parameters - ---------- - engine_error_sets : {engine_name → set of doc_id:error_token_pair strings} - - Returns - ------- - Pour 2 concurrents : - {only_a, only_b, both, label_a, label_b} - Pour 3 concurrents : - {only_a, only_b, only_c, ab, ac, bc, abc, label_a, label_b, label_c} - """ - names = list(engine_error_sets.keys())[:3] # max 3 pour Venn lisible - if len(names) < 2: - return {} +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). +""" - sets = {n: engine_error_sets[n] for n in names} +from picarones.measurements.statistics import * # noqa: F401, F403 - if len(names) == 2: - a, b = names - sa, sb = sets[a], sets[b] - return { - "type": "venn2", - "label_a": a, - "label_b": b, - "only_a": len(sa - sb), - "only_b": len(sb - sa), - "both": len(sa & sb), - } - else: - a, b, c = names - sa, sb, sc = sets[a], sets[b], sets[c] - return { - "type": "venn3", - "label_a": a, - "label_b": b, - "label_c": c, - "only_a": len(sa - sb - sc), - "only_b": len(sb - sa - sc), - "only_c": len(sc - sa - sb), - "ab": len((sa & sb) - sc), - "ac": len((sa & sc) - sb), - "bc": len((sb & sc) - sa), - "abc": len(sa & sb & sc), - } +import picarones.measurements.statistics as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/structure.py b/picarones/core/structure.py index 2724f4fc76f0a86b0b7c6e3df6fb287dcf56a45b..54a6e738a4c50b8c5b7ba0eff11343eda9e458bb 100644 --- a/picarones/core/structure.py +++ b/picarones/core/structure.py @@ -1,229 +1,19 @@ -"""Analyse structurelle des résultats OCR. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.structure`. -Mesures -------- -- **Taux de fusion de lignes** : l'OCR produit moins de lignes que le GT - (plusieurs lignes GT fusionnées en une seule). -- **Taux de fragmentation** : l'OCR produit plus de lignes que le GT - (une ligne GT découpée en plusieurs). -- **Score d'ordre de lecture** : corrélation entre l'ordre des mots GT et OCR, - approximé par la longueur de la sous-séquence commune la plus longue (LCS). -- **Taux de conservation des paragraphes** : respect des sauts de paragraphe. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.structure import ...``) de continuer +à fonctionner sans modification. -Ces métriques sont calculées indépendamment du contenu textuel — elles mesurent -la fidélité de la mise en page, pas la qualité des caractères. - -Note : sans bounding boxes disponibles, l'analyse se base uniquement sur les -sauts de ligne présents dans les textes GT et OCR. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import difflib -from dataclasses import dataclass - - -@dataclass -class StructureResult: - """Résultat de l'analyse structurelle pour un document.""" - - gt_line_count: int = 0 - """Nombre de lignes dans le GT.""" - ocr_line_count: int = 0 - """Nombre de lignes dans l'OCR.""" - - line_fusion_count: int = 0 - """Nombre de fusions de lignes (GT lignes absorbées).""" - line_fragmentation_count: int = 0 - """Nombre de fragmentations (GT lignes splittées).""" - - reading_order_score: float = 1.0 - """Score d'ordre de lecture [0, 1]. 1 = ordre parfait.""" - - paragraph_conservation_score: float = 1.0 - """Score de conservation des paragraphes [0, 1].""" - - @property - def line_fusion_rate(self) -> float: - """Taux de fusion = fusions / lignes GT.""" - return self.line_fusion_count / self.gt_line_count if self.gt_line_count > 0 else 0.0 - - @property - def line_fragmentation_rate(self) -> float: - """Taux de fragmentation = fragmentations / lignes GT.""" - return self.line_fragmentation_count / self.gt_line_count if self.gt_line_count > 0 else 0.0 - - @property - def line_accuracy(self) -> float: - """Exactitude du nombre de lignes : 1 - |delta| / max(gt, ocr).""" - if self.gt_line_count == 0 and self.ocr_line_count == 0: - return 1.0 - max_lines = max(self.gt_line_count, self.ocr_line_count) - delta = abs(self.gt_line_count - self.ocr_line_count) - return max(0.0, 1.0 - delta / max_lines) - - def as_dict(self) -> dict: - return { - "gt_line_count": self.gt_line_count, - "ocr_line_count": self.ocr_line_count, - "line_fusion_count": self.line_fusion_count, - "line_fragmentation_count": self.line_fragmentation_count, - "line_fusion_rate": round(self.line_fusion_rate, 4), - "line_fragmentation_rate": round(self.line_fragmentation_rate, 4), - "line_accuracy": round(self.line_accuracy, 4), - "reading_order_score": round(self.reading_order_score, 4), - "paragraph_conservation_score": round(self.paragraph_conservation_score, 4), - } - - @classmethod - def from_dict(cls, data: dict) -> "StructureResult": - return cls( - gt_line_count=data.get("gt_line_count", 0), - ocr_line_count=data.get("ocr_line_count", 0), - line_fusion_count=data.get("line_fusion_count", 0), - line_fragmentation_count=data.get("line_fragmentation_count", 0), - reading_order_score=data.get("reading_order_score", 1.0), - paragraph_conservation_score=data.get("paragraph_conservation_score", 1.0), - ) - - -def analyze_structure(ground_truth: str, hypothesis: str) -> StructureResult: - """Analyse la structure d'un document OCR comparée au GT. - - Parameters - ---------- - ground_truth: - Texte de référence (vérité terrain), avec sauts de ligne. - hypothesis: - Texte produit par l'OCR, avec sauts de ligne. - - Returns - ------- - StructureResult - """ - gt_lines = [ln for ln in ground_truth.splitlines() if ln.strip()] - ocr_lines = [ln for ln in hypothesis.splitlines() if ln.strip()] - - n_gt = len(gt_lines) - n_ocr = len(ocr_lines) - - # Fusions et fragmentations - fusion_count, frag_count = _count_line_changes(gt_lines, ocr_lines) - - # Score d'ordre de lecture via LCS sur les mots - reading_order = _reading_order_score(ground_truth, hypothesis) - - # Score de conservation des paragraphes (sauts de ligne vides = paragraphes) - para_score = _paragraph_conservation_score(ground_truth, hypothesis) - - return StructureResult( - gt_line_count=n_gt, - ocr_line_count=n_ocr, - line_fusion_count=fusion_count, - line_fragmentation_count=frag_count, - reading_order_score=reading_order, - paragraph_conservation_score=para_score, - ) - - -def _count_line_changes(gt_lines: list[str], ocr_lines: list[str]) -> tuple[int, int]: - """Compte les fusions et fragmentations de lignes via SequenceMatcher.""" - if not gt_lines or not ocr_lines: - return 0, 0 - - fusion_count = 0 - frag_count = 0 - - # Aligner les lignes par contenu - matcher = difflib.SequenceMatcher( - None, - [ln.strip()[:30] for ln in gt_lines], # fingerprint court pour la comparaison - [ln.strip()[:30] for ln in ocr_lines], - autojunk=False, - ) - - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "replace": - gt_len = i2 - i1 - ocr_len = j2 - j1 - if ocr_len < gt_len: - # Moins de lignes OCR → fusions - fusion_count += gt_len - ocr_len - elif ocr_len > gt_len: - # Plus de lignes OCR → fragmentations - frag_count += ocr_len - gt_len - elif tag == "delete": - # Lignes GT supprimées dans l'OCR → lacunes (pas fusion/frag) - pass - elif tag == "insert": - # Lignes insérées par l'OCR - frag_count += j2 - j1 - - return fusion_count, frag_count - - -def _reading_order_score(ground_truth: str, hypothesis: str) -> float: - """Score d'ordre de lecture [0, 1] basé sur la LCS des mots. - - On calcule la longueur de la sous-séquence commune la plus longue (LCS) - entre les listes de mots GT et OCR. Un score de 1 signifie que tous les - mots communs apparaissent dans le même ordre. - """ - gt_words = ground_truth.split() - hyp_words = hypothesis.split() - - if not gt_words or not hyp_words: - return 1.0 - - # Utiliser SequenceMatcher pour approximer la LCS - matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False) - # Ratio est 2 * nb_correspondances / (len_gt + len_ocr) - # C'est un proxy raisonnable de l'ordre de lecture - ratio = matcher.ratio() - return round(ratio, 4) - - -def _paragraph_conservation_score(ground_truth: str, hypothesis: str) -> float: - """Score de conservation des paragraphes [0, 1]. - - Compte les sauts de paragraphe (lignes vides) dans le GT et mesure - le taux de conservation dans l'OCR. - """ - # Un saut de paragraphe = deux sauts de ligne consécutifs - gt_paras = [p for p in ground_truth.split("\n\n") if p.strip()] - ocr_paras = [p for p in hypothesis.split("\n\n") if p.strip()] - - n_gt_paras = len(gt_paras) - if n_gt_paras <= 1: - return 1.0 # pas de paragraphe distinct → score parfait - - n_ocr_paras = len(ocr_paras) - delta = abs(n_gt_paras - n_ocr_paras) - score = max(0.0, 1.0 - delta / n_gt_paras) - return round(score, 4) - - -def aggregate_structure(results: list[StructureResult]) -> dict: - """Agrège les résultats structurels sur un corpus.""" - if not results: - return {} - - import statistics - - def _mean(values: list[float]) -> float: - return round(statistics.mean(values), 4) if values else 0.0 - - fusion_rates = [r.line_fusion_rate for r in results] - frag_rates = [r.line_fragmentation_rate for r in results] - reading_scores = [r.reading_order_score for r in results] - para_scores = [r.paragraph_conservation_score for r in results] - line_accuracies = [r.line_accuracy for r in results] +from picarones.measurements.structure import * # noqa: F401, F403 - return { - "mean_line_fusion_rate": _mean(fusion_rates), - "mean_line_fragmentation_rate": _mean(frag_rates), - "mean_reading_order_score": _mean(reading_scores), - "mean_paragraph_conservation": _mean(para_scores), - "mean_line_accuracy": _mean(line_accuracies), - "document_count": len(results), - } +import picarones.measurements.structure as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/taxonomy.py b/picarones/core/taxonomy.py index a8d36076528d81c781e1ccbf4dd18c9341032237..735c53e2738c63bebd577c6d1e8ccd6b0df98b4a 100644 --- a/picarones/core/taxonomy.py +++ b/picarones/core/taxonomy.py @@ -1,350 +1,19 @@ -"""Taxonomie des erreurs OCR — classification automatique (classes 1 à 9). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.taxonomy`. -Chaque erreur identifiée par l'alignement GT↔OCR est catégorisée selon -la taxonomie Picarones : +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.taxonomy import ...``) de continuer +à fonctionner sans modification. -| Classe | Nom | Description | -|--------|-------------------|----------------------------------------------------| -| 1 | visual_confusion | Confusion morphologique (rn/m, l/1, O/0, u/n…) | -| 2 | diacritic_error | Diacritique absent, incorrect ou ajouté | -| 3 | case_error | Erreur de casse uniquement (A/a) | -| 4 | ligature_error | Ligature non résolue ou mal résolue | -| 5 | abbreviation_error| Abréviation médiévale non développée | -| 6 | hapax | Mot introuvable dans tout lexique | -| 7 | segmentation_error| Fusion ou fragmentation de tokens (mots/lignes) | -| 8 | oov_character | Caractère hors-vocabulaire du moteur | -| 9 | lacuna | Texte présent dans le GT absent de l'OCR | -| 10 | over_normalization| Sur-normalisation LLM (voir pipelines/) | - -Note : la classe 10 est calculée par picarones/pipelines/over_normalization.py. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import difflib -import unicodedata -from dataclasses import dataclass, field - - -# --------------------------------------------------------------------------- -# Tables de référence pour la classification -# --------------------------------------------------------------------------- - -#: Confusions visuelles bien connues en OCR (caractères morphologiquement proches) -VISUAL_CONFUSIONS: dict[frozenset, str] = {} -_VISUAL_PAIRS: list[tuple[str, str]] = [ - # Minuscules - ("r", "n"), ("rn", "m"), ("l", "1"), ("l", "i"), ("l", "|"), - ("O", "0"), ("O", "o"), ("u", "n"), ("n", "u"), ("v", "u"), - ("c", "e"), ("e", "c"), ("a", "o"), ("o", "a"), - ("f", "ſ"), ("ſ", "f"), ("f", "t"), - ("h", "li"), ("h", "lı"), - ("m", "rn"), ("m", "in"), - ("d", "cl"), ("d", "a"), - ("q", "g"), ("p", "q"), - # Majuscules ↔ minuscules homographes (classe 1, pas classe 3) - ("I", "l"), ("I", "1"), - # Chiffres - ("1", "I"), ("1", "l"), ("0", "O"), - # Ponctuation - (".", ","), (",", "."), -] -for _a, _b in _VISUAL_PAIRS: - VISUAL_CONFUSIONS[frozenset({_a, _b})] = f"{_a}/{_b}" - -#: Couples de ligatures pour la détection des erreurs de ligatures -from picarones.core.char_scores import LIGATURE_TABLE, DIACRITIC_MAP # noqa: E402 - -# Caractères hors-ASCII présumés hors-vocabulaire (alphabet non latin de base) -_LATIN_BASIC = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - " \t\n.,;:!?-_'\"«»()[]{}/@#%&*+=/\\|<>~^") - - -# --------------------------------------------------------------------------- -# Résultat structuré -# --------------------------------------------------------------------------- - -@dataclass -class TaxonomyResult: - """Résultat de la classification taxonomique des erreurs pour un document.""" - - counts: dict[str, int] = field(default_factory=dict) - """Nombre d'erreurs par classe. Clés : 'visual_confusion', 'diacritic_error'…""" - - examples: dict[str, list[dict]] = field(default_factory=dict) - """Exemples d'erreurs par classe (max 5 par classe). - Format : [{'gt': 'chaîne', 'ocr': 'chaîne', 'position': int}] - """ - - total_errors: int = 0 - """Nombre total d'erreurs classifiées.""" - - @property - def class_distribution(self) -> dict[str, float]: - """Distribution relative (0–1) par classe.""" - if not self.total_errors: - return {} - return { - cls: round(cnt / self.total_errors, 4) - for cls, cnt in self.counts.items() - } - - def as_dict(self) -> dict: - return { - "counts": self.counts, - "total_errors": self.total_errors, - "class_distribution": self.class_distribution, - "examples": { - cls: exs[:3] for cls, exs in self.examples.items() - }, - } - - @classmethod - def from_dict(cls, data: dict) -> "TaxonomyResult": - return cls( - counts=data.get("counts", {}), - examples=data.get("examples", {}), - total_errors=data.get("total_errors", 0), - ) - - -# Noms des classes en ordre -ERROR_CLASSES = [ - "visual_confusion", - "diacritic_error", - "case_error", - "ligature_error", - "abbreviation_error", - "hapax", - "segmentation_error", - "oov_character", - "lacuna", -] - - -# --------------------------------------------------------------------------- -# Classification principale -# --------------------------------------------------------------------------- - -def classify_errors( - ground_truth: str, - hypothesis: str, - max_examples: int = 5, -) -> TaxonomyResult: - """Classifie automatiquement les erreurs OCR dans une paire GT/OCR. - - L'alignement utilise difflib.SequenceMatcher au niveau mot pour détecter - les erreurs de segmentation, puis au niveau caractère pour les autres classes. - - Parameters - ---------- - ground_truth: - Texte de référence (vérité terrain). - hypothesis: - Texte produit par l'OCR. - max_examples: - Nombre maximal d'exemples conservés par classe. - - Returns - ------- - TaxonomyResult - """ - counts: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES} - examples: dict[str, list[dict]] = {cls: [] for cls in ERROR_CLASSES} - total = 0 - - if not ground_truth and not hypothesis: - return TaxonomyResult(counts=counts, examples=examples, total_errors=0) - - # ----------------------------------------------------------------------- - # Niveau mot : détecter segmentation (classe 7) et lacunes (classe 9) - # ----------------------------------------------------------------------- - gt_words = ground_truth.split() - hyp_words = hypothesis.split() - - word_matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False) - for tag, i1, i2, j1, j2 in word_matcher.get_opcodes(): - if tag == "delete": - # Mots GT absents de l'OCR → lacune (classe 9) - for w in gt_words[i1:i2]: - counts["lacuna"] += 1 - total += 1 - if len(examples["lacuna"]) < max_examples: - examples["lacuna"].append({"gt": w, "ocr": "", "position": i1}) - - elif tag == "insert": - # Mots ajoutés par l'OCR → généralement classe 8 (hors-vocab) - for w in hyp_words[j1:j2]: - if _is_oov_word(w): - counts["oov_character"] += 1 - total += 1 - - elif tag == "replace": - gt_seg = gt_words[i1:i2] - hyp_seg = hyp_words[j1:j2] - # Segmentation : fusion de mots (moins de mots OCR) ou fragmentation - if len(hyp_seg) != len(gt_seg): - n_seg = abs(len(gt_seg) - len(hyp_seg)) - counts["segmentation_error"] += n_seg - total += n_seg - if len(examples["segmentation_error"]) < max_examples: - examples["segmentation_error"].append({ - "gt": " ".join(gt_seg), - "ocr": " ".join(hyp_seg), - "position": i1, - }) - else: - # Paires mot-à-mot - for gt_w, hyp_w in zip(gt_seg, hyp_seg): - if gt_w != hyp_w: - _classify_word_error( - gt_w, hyp_w, counts, examples, max_examples - ) - total += 1 - - return TaxonomyResult( - counts=counts, - examples=examples, - total_errors=total, - ) - - -def _classify_word_error( - gt_word: str, - hyp_word: str, - counts: dict[str, int], - examples: dict[str, list[dict]], - max_examples: int, -) -> None: - """Classifie l'erreur entre deux mots non-identiques.""" - # Classe 3 : erreur de casse seule - if gt_word.casefold() == hyp_word.casefold() and gt_word != hyp_word: - counts["case_error"] += 1 - if len(examples["case_error"]) < max_examples: - examples["case_error"].append({"gt": gt_word, "ocr": hyp_word}) - return - - # Classe 4 : erreur de ligature - gt_norm = unicodedata.normalize("NFC", gt_word) - hyp_norm = unicodedata.normalize("NFC", hyp_word) - if _is_ligature_error(gt_norm, hyp_norm): - counts["ligature_error"] += 1 - if len(examples["ligature_error"]) < max_examples: - examples["ligature_error"].append({"gt": gt_word, "ocr": hyp_word}) - return - - # Classe 5 : erreur d'abréviation (présence de ꝑ, ꝓ, ꝗ dans le GT) - if _is_abbreviation_error(gt_norm, hyp_norm): - counts["abbreviation_error"] += 1 - if len(examples["abbreviation_error"]) < max_examples: - examples["abbreviation_error"].append({"gt": gt_word, "ocr": hyp_word}) - return - - # Classe 2 : erreur diacritique - if _is_diacritic_error(gt_norm, hyp_norm): - counts["diacritic_error"] += 1 - if len(examples["diacritic_error"]) < max_examples: - examples["diacritic_error"].append({"gt": gt_word, "ocr": hyp_word}) - return - - # Classe 1 : confusion visuelle (comparaison char par char) - if _is_visual_confusion(gt_norm, hyp_norm): - counts["visual_confusion"] += 1 - if len(examples["visual_confusion"]) < max_examples: - examples["visual_confusion"].append({"gt": gt_word, "ocr": hyp_word}) - return - - # Classe 8 : caractère hors-vocabulaire - if _is_oov_word(hyp_word): - counts["oov_character"] += 1 - if len(examples["oov_character"]) < max_examples: - examples["oov_character"].append({"gt": gt_word, "ocr": hyp_word}) - return - - # Classe 6 : hapax (erreur résiduelle non classifiable) - counts["hapax"] += 1 - if len(examples["hapax"]) < max_examples: - examples["hapax"].append({"gt": gt_word, "ocr": hyp_word}) - - -def _is_ligature_error(gt: str, hyp: str) -> bool: - """Vrai si la différence implique une ligature Unicode.""" - # GT contient une ligature que l'OCR a décomposée, ou vice versa - for lig, seqs in LIGATURE_TABLE.items(): - if lig in gt: - for seq in seqs: - if seq in hyp and lig not in hyp: - return True - for seq in seqs: - if seq in gt and lig in hyp: - return True - return False - - -def _is_abbreviation_error(gt: str, hyp: str) -> bool: - """Vrai si le GT contient un caractère d'abréviation médiévale.""" - abbreviation_chars = "\uA751\uA753\uA757" # ꝑ ꝓ ꝗ - return any(c in gt for c in abbreviation_chars) - - -def _is_diacritic_error(gt: str, hyp: str) -> bool: - """Vrai si la différence est principalement due à des diacritiques.""" - # Comparer les formes sans diacritiques - def strip_diacritics(text: str) -> str: - nfd = unicodedata.normalize("NFD", text) - return "".join(c for c in nfd if unicodedata.category(c) != "Mn") - - gt_stripped = strip_diacritics(gt) - hyp_stripped = strip_diacritics(hyp) - # Si les mots sont identiques sans diacritiques → erreur diacritique - if gt_stripped.casefold() == hyp_stripped.casefold() and gt != hyp: - return True - # Si le GT contient des diacritiques que l'OCR a perdus et que les textes - # sans diacritiques sont identiques (même longueur requise) - gt_has_diac = any(c in DIACRITIC_MAP for c in gt) - return gt_has_diac and len(gt) == len(hyp) and gt_stripped.casefold() == hyp_stripped.casefold() - - -def _is_visual_confusion(gt: str, hyp: str) -> bool: - """Vrai si la différence implique des confusions visuelles connues.""" - if abs(len(gt) - len(hyp)) > 2: - return False - # Vérifier les paires de confusions connues - for pair in VISUAL_CONFUSIONS: - chars = list(pair) - if len(chars) == 2: - a, b = chars - if a in gt and b in hyp and a not in hyp: - return True - if b in gt and a in hyp and b not in hyp: - return True - return False - - -def _is_oov_word(word: str) -> bool: - """Vrai si le mot contient des caractères hors de l'alphabet latin de base.""" - return any(c not in _LATIN_BASIC and not c.isalpha() for c in word) - - -# --------------------------------------------------------------------------- -# Agrégation -# --------------------------------------------------------------------------- - -def aggregate_taxonomy(results: list[TaxonomyResult]) -> dict: - """Agrège les résultats taxonomiques sur un corpus.""" - combined: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES} - total = 0 - for r in results: - for cls, cnt in r.counts.items(): - combined[cls] = combined.get(cls, 0) + cnt - total += r.total_errors +from picarones.measurements.taxonomy import * # noqa: F401, F403 - distribution = { - cls: round(cnt / total, 4) if total > 0 else 0.0 - for cls, cnt in combined.items() - } - return { - "counts": combined, - "total_errors": total, - "class_distribution": distribution, - } +import picarones.measurements.taxonomy as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/taxonomy_comparison.py b/picarones/core/taxonomy_comparison.py index eb99d5ef20d8af1985c2dd42b777499c3d1b58f3..f8ece378cc73903201da37cd7ee9da9d8ab615b8 100644 --- a/picarones/core/taxonomy_comparison.py +++ b/picarones/core/taxonomy_comparison.py @@ -1,161 +1,19 @@ -"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.taxonomy_comparison`. -Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4). +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.taxonomy_comparison import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale -qu'un moteur a un profil taxonomique éloigné de ses concurrents, -mais le rapport n'expose pas cette différence visuellement. Ce -sprint répond à *« deux moteurs ont le même CER global, mais lequel -fait des erreurs plus récupérables ? »*. - -Lecture concrète ----------------- -- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables - par un post-processing trivial (récupérables). -- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) → - irrécupérables sans relire l'image. - -À CER égal, A est massivement préférable pour un workflow -d'édition critique. Cette vue rend la différence visible. - -Catégorisation des classes --------------------------- -On annote chaque classe d'erreur d'un degré de **récupérabilité** -(critère éditorial pragmatique, pas verdict imposé) : - -- ``recoverable`` : récupérable par post-processing trivial - (case_error, ligature_error, abbreviation_error) -- ``difficult`` : récupérable au prix d'un effort - (diacritic_error, visual_confusion, hapax) -- ``irrecoverable`` : impossible à corriger sans l'image - (lacuna, oov_character, segmentation_error) - -L'utilisateur consulte ces catégories comme un guide, pas un -verdict — c'est lui qui juge selon ses besoins éditoriaux. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -# Classification éditoriale. Documentée dans la docstring. -RECOVERABILITY: dict[str, str] = { - "case_error": "recoverable", - "ligature_error": "recoverable", - "abbreviation_error": "recoverable", - "diacritic_error": "difficult", - "visual_confusion": "difficult", - "hapax": "difficult", - "lacuna": "irrecoverable", - "oov_character": "irrecoverable", - "segmentation_error": "irrecoverable", -} - - -def _normalize_counts(counts: dict[str, int]) -> dict[str, float]: - """Convertit un dict de comptes en proportions [0, 1].""" - total = sum(counts.values()) - if total <= 0: - return {k: 0.0 for k in counts} - return {k: v / total for k, v in counts.items()} - - -def compare_taxonomies( - engine_a_name: str, - engine_a_counts: dict[str, int], - engine_b_name: str, - engine_b_counts: dict[str, int], -) -> Optional[dict]: - """Compare deux profils taxonomiques. - - Parameters - ---------- - engine_a_name, engine_b_name: - Noms d'identification des moteurs (utilisés dans le rendu). - engine_a_counts, engine_b_counts: - Maps ``{class_name: count}`` produites par - ``aggregate_taxonomy``. - - Returns - ------- - Optional[dict] - ``{ - "engine_a": str, "engine_b": str, - "total_a": int, "total_b": int, - "classes": list[str], # classes apparaissant chez A ou B - "proportions_a": dict[str, float], - "proportions_b": dict[str, float], - "deltas": dict[str, float], # prop_b - prop_a (signé) - "recoverability": dict[str, str], # mapping class → niveau - "totals_by_recoverability": { - "recoverable": {"a": float, "b": float}, - "difficult": {"a": float, "b": float}, - "irrecoverable": {"a": float, "b": float}, - }, - }`` - Ou ``None`` si les deux moteurs ont 0 erreur chacun. - """ - if engine_a_name == engine_b_name: - # On accepte des comparaisons même si les noms sont - # identiques (cas tests), mais on émet un warning. - logger.warning( - "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s", - engine_a_name, - ) - - total_a = sum(engine_a_counts.values()) if engine_a_counts else 0 - total_b = sum(engine_b_counts.values()) if engine_b_counts else 0 - if total_a == 0 and total_b == 0: - return None - - classes = sorted(set(engine_a_counts) | set(engine_b_counts)) - if not classes: - return None - - prop_a = _normalize_counts( - {c: engine_a_counts.get(c, 0) for c in classes}, - ) - prop_b = _normalize_counts( - {c: engine_b_counts.get(c, 0) for c in classes}, - ) - deltas = {c: prop_b[c] - prop_a[c] for c in classes} - - # Agrégat par récupérabilité (utile pour la lecture rapide) - totals_recov: dict[str, dict[str, float]] = { - "recoverable": {"a": 0.0, "b": 0.0}, - "difficult": {"a": 0.0, "b": 0.0}, - "irrecoverable": {"a": 0.0, "b": 0.0}, - } - for cls in classes: - level = RECOVERABILITY.get(cls, "difficult") - if level not in totals_recov: - level = "difficult" - totals_recov[level]["a"] += prop_a[cls] - totals_recov[level]["b"] += prop_b[cls] - - return { - "engine_a": engine_a_name, - "engine_b": engine_b_name, - "total_a": total_a, - "total_b": total_b, - "classes": classes, - "proportions_a": prop_a, - "proportions_b": prop_b, - "deltas": deltas, - "recoverability": { - cls: RECOVERABILITY.get(cls, "difficult") for cls in classes - }, - "totals_by_recoverability": totals_recov, - } - +from picarones.measurements.taxonomy_comparison import * # noqa: F401, F403 -__all__ = [ - "RECOVERABILITY", - "compare_taxonomies", -] +import picarones.measurements.taxonomy_comparison as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/throughput.py b/picarones/core/throughput.py index 47d0ed674492f221013aa8a53c3632db14cbe6b5..2564b62b0b192ff0a7dd38c15898843fdcb272f8 100644 --- a/picarones/core/throughput.py +++ b/picarones/core/throughput.py @@ -1,165 +1,19 @@ -"""Throughput effectif (Sprint 91 — A.II.6). +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.throughput`. -Sprint 91 — A.II.6 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.throughput import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le throughput brut (pages/heure d'OCR pur) ment quand un moteur -est rapide mais imprécis : la correction humaine *post hoc* -absorbe le gain. La **vraie** vitesse opérationnelle inclut -le temps de correction. Cette métrique discrimine fortement -entre un cloud rapide à 30 % de timeouts/erreurs et un local -lent à 100 % de fiabilité. - -Formule -------- -.. code:: - - pages_par_heure_utilisable = - pages_traitées / (durée_totale + temps_correction_humaine) - -Le temps de correction est estimé linéairement : -``temps_par_erreur × nombre_d_erreurs``. Le défaut -``time_per_error_seconds=5.0`` correspond aux études HTR-United -(saisie manuelle d'une correction de mot par un opérateur -formé : ≈ 5 s par erreur). L'utilisateur peut le surcharger -pour son institution. - -Sortie ------- -``compute_effective_throughput(n_pages, duration_seconds, -n_errors, time_per_error_seconds=5.0)`` retourne ``{n_pages, -duration_seconds, n_errors, time_per_error_seconds, -correction_time_seconds, total_seconds, pages_per_hour_raw, -pages_per_hour_effective, drag_ratio}``. - -``aggregate_effective_throughput(per_engine_data)`` agrège par -moteur sur l'ensemble du corpus. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -_DEFAULT_TIME_PER_ERROR_SECONDS = 5.0 - - -def compute_effective_throughput( - n_pages: int, - duration_seconds: float, - n_errors: int, - *, - time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, -) -> Optional[dict]: - """Throughput effectif (pages/heure utilisables). - - Parameters - ---------- - n_pages: - Nombre de pages traitées. - duration_seconds: - Durée totale de l'OCR (somme des durées par doc). - n_errors: - Nombre d'erreurs (au niveau mot, typiquement - ``WER × n_words_total``). - time_per_error_seconds: - Temps moyen de correction humaine par erreur. Défaut - 5 s (HTR-United). Doit être ≥ 0. - - Returns - ------- - dict | None - ``None`` si ``n_pages == 0`` ou ``total_seconds == 0`` - (pas de division par zéro). - """ - if n_pages <= 0: - return None - if duration_seconds < 0 or n_errors < 0 or time_per_error_seconds < 0: - raise ValueError( - "duration_seconds, n_errors et time_per_error_seconds " - "doivent être ≥ 0", - ) - correction_seconds = float(n_errors) * float(time_per_error_seconds) - total_seconds = float(duration_seconds) + correction_seconds - if total_seconds <= 0: - # Aucun temps écoulé : impossible de définir un throughput - return None - pages_per_hour_raw = ( - n_pages / duration_seconds * 3600.0 - if duration_seconds > 0 else None - ) - pages_per_hour_effective = n_pages / total_seconds * 3600.0 - drag_ratio = ( - correction_seconds / total_seconds if total_seconds > 0 else 0.0 - ) - return { - "n_pages": int(n_pages), - "duration_seconds": float(duration_seconds), - "n_errors": int(n_errors), - "time_per_error_seconds": float(time_per_error_seconds), - "correction_time_seconds": correction_seconds, - "total_seconds": total_seconds, - "pages_per_hour_raw": pages_per_hour_raw, - "pages_per_hour_effective": pages_per_hour_effective, - "drag_ratio": drag_ratio, - } - - -def aggregate_effective_throughput( - per_engine: Iterable[dict], - *, - time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, -) -> Optional[dict]: - """Agrège le throughput effectif par moteur. - - Parameters - ---------- - per_engine: - Itérable de dicts ``{engine_name, n_pages, - duration_seconds, n_errors}``. - - Returns - ------- - dict | None - ``{ - "engines": [ - {"engine_name", ..., compute_effective_throughput - fields}, - ... - ], - "time_per_error_seconds": float, - }`` ou ``None`` si aucun moteur exploitable. - """ - rows: list[dict] = [] - for entry in per_engine: - if not isinstance(entry, dict): - continue - name = entry.get("engine_name") or entry.get("engine") - if not name: - continue - result = compute_effective_throughput( - int(entry.get("n_pages") or 0), - float(entry.get("duration_seconds") or 0.0), - int(entry.get("n_errors") or 0), - time_per_error_seconds=time_per_error_seconds, - ) - if result is None: - continue - result["engine_name"] = str(name) - rows.append(result) - if not rows: - return None - return { - "engines": rows, - "time_per_error_seconds": float(time_per_error_seconds), - } - +from picarones.measurements.throughput import * # noqa: F401, F403 -__all__ = [ - "compute_effective_throughput", - "aggregate_effective_throughput", -] +import picarones.measurements.throughput as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/core/worst_lines.py b/picarones/core/worst_lines.py index dfece53263f29f83db9cb6dbaaf749d719b04857..ebf267317a1207c015d9247712ea09f7eb05ff44 100644 --- a/picarones/core/worst_lines.py +++ b/picarones/core/worst_lines.py @@ -1,199 +1,19 @@ -"""Extraction transversale des « Worst lines » du corpus — Sprint 72. +"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.worst_lines`. -Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026. +Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2) +n'est plus dans ``picarones.core/`` ; elle vit dans +``picarones.measurements/``. L'alias ici permet aux imports +historiques (``from picarones.core.worst_lines import ...``) de continuer +à fonctionner sans modification. -Pourquoi ce module ------------------- -Le percentile p95 du CER ligne (calculé par ``line_metrics.py``, -Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un -CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur -texte, leur diff, leur document parent, pour comprendre ce qui -casse. - -Ce module fournit la requête transversale qui collecte, depuis un -``BenchmarkResult``, les **N lignes les plus mal transcrites de -tout le corpus**, classées par CER ligne. Filtrable par moteur -et par strate. - -Limite documentée ------------------ -``DocumentResult.line_metrics`` ne stocke que les CER par ligne, -**pas le texte des lignes**. Pour récupérer les textes GT/hyp -on resplitte ``ground_truth`` et ``hypothesis`` du -``DocumentResult`` à l'index de la ligne. Cette logique -**suppose un BenchmarkResult non-compacté** — après ``compact()`` -les textes sont tronqués à 200 caractères et les lignes au-delà -de cette troncature ne sont plus accessibles. En pratique on -extrait les worst lines **avant** la sérialisation/compactage. +Voir :doc:`docs/architecture-cercles.md` pour la cartographie des +3 cercles. Le ``core/`` strict ne contient plus que les abstractions +du domaine et l'orchestration (Cercle 1). """ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class WorstLineEntry: - """Une ligne du corpus identifiée comme mal transcrite. - - Champs - ------ - rank: - Position dans le classement (1-based, 1 = pire CER). - cer: - CER de la ligne ∈ [0, 1]. - engine_name: - Nom du moteur ayant produit cette hypothèse. - doc_id: - Identifiant du document parent. - line_index: - Index 0-based de la ligne dans le document GT. - gt_line: - Texte de la ligne dans la GT. - hyp_line: - Texte correspondant dans l'hypothèse (peut être ``""`` - si l'OCR a sauté la ligne). - script_type: - Strate du document si disponible (``script_type`` - capturé par le runner pour la stratification A.III). - """ - - rank: int - cer: float - engine_name: str - doc_id: str - line_index: int - gt_line: str - hyp_line: str - script_type: Optional[str] = None - - -def _split_lines(text: Optional[str]) -> list[str]: - """Splitte un texte en lignes (cohérent avec ``line_metrics``). - - Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les - lignes vides sont préservées. Retourne une liste vide si le - texte est None ou vide. - """ - if not text: - return [] - # ``splitlines`` gère \r\n et \r correctement - return text.splitlines() - - -def _line_at(text: Optional[str], index: int) -> str: - """Retourne la ligne à l'index demandé, ou ``""`` si l'index - est hors borne (cas où l'OCR a moins de lignes que la GT).""" - lines = _split_lines(text) - if 0 <= index < len(lines): - return lines[index] - return "" - - -def extract_worst_lines( - benchmark, - *, - top_n: int = 20, - engine_filter: Optional[str] = None, - script_type_filter: Optional[str] = None, -) -> list[WorstLineEntry]: - """Extrait les ``top_n`` lignes les plus mal transcrites du - corpus, transversalement à tous les moteurs et documents. - - Parameters - ---------- - benchmark: - ``BenchmarkResult`` non-compacté (cf. limite ci-dessus). - L'objet doit exposer ``engine_reports`` (liste de - ``EngineReport``) et optionnellement ``doc_strata`` - (map ``{doc_id: script_type}``, Sprint 45). - top_n: - Nombre de lignes à retourner. Défaut : 20. - engine_filter: - Si fourni, n'inclut que les lignes produites par ce moteur - (match exact sur ``engine_name``). - script_type_filter: - Si fourni, n'inclut que les lignes des documents de cette - strate (nécessite ``benchmark.doc_strata``). - - Returns - ------- - list[WorstLineEntry] - Liste triée par CER décroissant (pire en premier), - rang 1-based attribué après tri. Vide si aucune ligne - exploitable. - """ - if top_n <= 0: - return [] - - doc_strata = getattr(benchmark, "doc_strata", None) or {} - candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = [] - - for engine_report in getattr(benchmark, "engine_reports", []): - engine_name = engine_report.engine_name - if engine_filter is not None and engine_name != engine_filter: - continue - for dr in engine_report.document_results: - line_metrics = getattr(dr, "line_metrics", None) - if not line_metrics: - continue - cer_per_line = line_metrics.get("cer_per_line") if isinstance( - line_metrics, dict, - ) else getattr(line_metrics, "cer_per_line", None) - if not cer_per_line: - continue - doc_id = dr.doc_id - doc_strata_value = doc_strata.get(doc_id) - if ( - script_type_filter is not None - and doc_strata_value != script_type_filter - ): - continue - for idx, cer in enumerate(cer_per_line): - if cer <= 0.0: - continue - gt_line = _line_at(dr.ground_truth, idx) - hyp_line = _line_at(dr.hypothesis, idx) - if not gt_line and not hyp_line: - continue - candidates.append(( - float(cer), engine_name, doc_id, idx, - gt_line, hyp_line, doc_strata_value, - )) - - if not candidates: - return [] - - # Tri par CER décroissant ; en cas d'égalité, ordre stable - # (engine, doc_id, line_index) pour reproductibilité. - candidates.sort( - key=lambda c: (-c[0], c[1], c[2], c[3]), - ) - selected = candidates[:top_n] - - return [ - WorstLineEntry( - rank=i + 1, - cer=cer, - engine_name=engine, - doc_id=doc_id, - line_index=line_index, - gt_line=gt_line, - hyp_line=hyp_line, - script_type=script_type, - ) - for i, ( - cer, engine, doc_id, line_index, - gt_line, hyp_line, script_type, - ) in enumerate(selected) - ] - +from picarones.measurements.worst_lines import * # noqa: F401, F403 -__all__ = [ - "WorstLineEntry", - "extract_worst_lines", -] +import picarones.measurements.worst_lines as _module +__all__ = getattr(_module, "__all__", [ + nm for nm in dir(_module) if not nm.startswith("_") +]) diff --git a/picarones/measurements/__init__.py b/picarones/measurements/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d5ae5f22b556d6c3680939ed9a69f42eeb65b202 --- /dev/null +++ b/picarones/measurements/__init__.py @@ -0,0 +1,87 @@ +"""Métriques officielles Picarones — Cercle 2. + +Phase E du chantier de refonte en 3 cercles. Ce package contient +l'ensemble des **mesures et analyses au-delà du noyau** : tout ce qui +calcule, agrège ou interprète des métriques sur un corpus, mais qui +n'est pas une abstraction du domaine (Cercle 1, ``core/``) ni un +plugin niche (Cercle 3, ``extras/``). + +Sous-modules +------------ +Métriques scalaires et structurelles : + +- :mod:`confusion` matrice de confusion Unicode +- :mod:`char_scores` scores ligatures/diacritiques +- :mod:`taxonomy` taxonomie 9 classes d'erreurs +- :mod:`taxonomy_comparison` comparaison taxonomique miroir +- :mod:`structure` analyse structurelle (lignes/blocs) +- :mod:`line_metrics` distribution CER par ligne (Gini, percentiles) +- :mod:`hallucination` détection hallucinations VLM +- :mod:`reading_order` F1 ordre de lecture (ICDAR 2015) +- :mod:`layout` F1 layout par type de région +- :mod:`error_absorption` correction vs introduction par jonction +- :mod:`searchability` recherchabilité fuzzy (Levenshtein) +- :mod:`numerical_sequences` préservation dates/cotes/numéraux +- :mod:`numerical_sequences_runner` +- :mod:`rare_tokens` rappel sur tokens rares +- :mod:`readability` Δ Flesch (sur-normalisation) +- :mod:`readability_runner` +- :mod:`searchability_runner` +- :mod:`specialization` spécialisation inter-moteurs +- :mod:`worst_lines` lignes pires globales +- :mod:`inter_engine` divergence taxonomique + oracle gap +- :mod:`incremental_comparison` ANOVA-like par slot +- :mod:`baseline_comparison` comparaison à l'historique +- :mod:`longitudinal` régression linéaire + change-point + +Fiabilité et calibration : + +- :mod:`calibration` ECE, MCE, reliability bins +- :mod:`reliability` IAA Cohen κ + multirun stability +- :mod:`robustness` courbes CER vs dégradation +- :mod:`robustness_projection` projection sur corpus réel + +NER : + +- :mod:`ner`, :mod:`ner_backends` + +Économie et opération : + +- :mod:`pricing` table tarifaire +- :mod:`throughput` pages/h effectif +- :mod:`cost_projection` projection à volume cible +- :mod:`marginal_cost` coût par erreur évitée + +Contexte corpus : + +- :mod:`history` historique SQLite +- :mod:`difficulty` score difficulté intrinsèque +- :mod:`image_quality` contraste, bruit, flou… +- :mod:`normalization` profils Unicode + +Statistiques : + +- :mod:`statistics` Wilcoxon, Friedman, Nemenyi, Pareto, CDD + +Aide à la décision : + +- :mod:`levers` leviers d'amélioration factuels +- :mod:`equivalence_profile` curseur fin équivalences diplomatiques + +Hooks et registres : + +- :mod:`builtin_hooks` 12 hooks doc + 12 agrégateurs natifs + +Moteur narratif : + +- :mod:`narrative` (sous-package) : facts, registry, arbiter, renderer, 18 détecteurs + +Rétrocompatibilité absolue +-------------------------- +Tous les modules historiquement dans ``picarones.core.X`` restent +accessibles via des fichiers-shims qui les redirigent vers le nouvel +emplacement. Aucun import existant ne casse. + +Voir :doc:`docs/architecture-cercles.md` et la phase E du plan de +refonte. +""" diff --git a/picarones/measurements/baseline_comparison.py b/picarones/measurements/baseline_comparison.py new file mode 100644 index 0000000000000000000000000000000000000000..22f021aaceb4952d7f96271e325b6864b50a9258 --- /dev/null +++ b/picarones/measurements/baseline_comparison.py @@ -0,0 +1,229 @@ +"""Comparaison à la baseline historique — Sprint 73 (A.I.3). + +Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +L'historique SQLite (``picarones/core/history.py``, Sprint 8) +existe mais aucun détecteur narratif ne le lit. Ce module fournit +la couche de calcul qui répond à *« comment ce moteur se +comporte-t-il sur ce corpus, **par rapport à ses runs précédents +de mon institution** ? »*. + +Sortie typique +-------------- +Un dict par moteur : + +.. code-block:: python + + { + "engine_name": "tesseract", + "cer_current": 0.052, + "cer_historical_mean": 0.041, + "cer_historical_median": 0.040, + "n_runs": 12, + "absolute_delta": 0.011, + "relative_delta": 0.268, # +26,8 % vs moyenne + "off_baseline": True, + } + +Le détecteur narratif ``engine_off_baseline`` (Sprint 73) +consomme cette structure pour émettre des Facts. + +Garde-fous +---------- +- ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus + contient moins de runs, on retourne ``None`` plutôt que de + comparer à un échantillon trop petit. +- ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du + même corpus** (sinon on compare des pommes et des oranges : + registres paroissiaux vs imprimés modernes). +- Le run courant lui-même n'est pas inclus dans la baseline (on + passe le ``current_run_id`` à exclure). +""" + +from __future__ import annotations + +import logging +import statistics +from typing import Optional + +logger = logging.getLogger(__name__) + + +def compute_engine_baseline( + history, + engine_name: str, + corpus_name: str, + current_cer: float, + *, + current_run_id: Optional[str] = None, + min_runs: int = 5, + relative_delta_threshold: float = 0.20, +) -> Optional[dict]: + """Compare le CER courant d'un moteur à sa moyenne historique + sur le **même corpus**. + + Parameters + ---------- + history: + Instance de ``BenchmarkHistory`` (ou compatible : doit + exposer une méthode ``query(engine, corpus, limit)`` + retournant une liste d'``HistoryEntry`` avec attribut + ``cer_mean`` et ``run_id``). + engine_name: + Nom du moteur dont on calcule la baseline. + corpus_name: + Nom du corpus — limite la comparaison aux runs antérieurs + sur ce même corpus. + current_cer: + CER moyen observé dans le run courant. + current_run_id: + Si fourni, le run portant cet identifiant est exclu de la + baseline (utile quand le run courant est déjà enregistré + dans l'historique avant d'appeler ce calcul). + min_runs: + Nombre minimum de runs historiques pour que la + comparaison soit considérée fiable. Sous ce seuil, on + retourne ``None``. + relative_delta_threshold: + Seuil au-delà duquel ``off_baseline`` vaut ``True`` + (défaut : 0,20 = 20 % d'écart relatif). + + Returns + ------- + Optional[dict] + ``None`` si : + - moins de ``min_runs`` runs historiques disponibles + - ``current_cer`` est ``None`` ou négatif + - tous les CER historiques sont ``None`` + + Sinon, dict avec les champs documentés dans le module. + """ + if current_cer is None or current_cer < 0: + return None + try: + entries = history.query( + engine=engine_name, corpus=corpus_name, limit=1000, + ) + except Exception as exc: # pragma: no cover — défense + logger.warning( + "[baseline_comparison] query history a levé : %s", exc, + ) + return None + + historical_cers: list[float] = [] + for entry in entries: + if current_run_id is not None and entry.run_id == current_run_id: + continue + cer = entry.cer_mean + if cer is None or cer < 0: + continue + historical_cers.append(float(cer)) + + if len(historical_cers) < min_runs: + return None + + mean = statistics.fmean(historical_cers) + median = statistics.median(historical_cers) + absolute_delta = current_cer - mean + if mean > 0: + relative_delta = absolute_delta / mean + elif current_cer == 0: + relative_delta = 0.0 + else: + # Baseline à 0 mais CER courant > 0 : écart infini — + # convention : on signale comme off_baseline avec + # relative_delta = None. + relative_delta = None + + off_baseline = ( + relative_delta is not None + and abs(relative_delta) > relative_delta_threshold + ) + + return { + "engine_name": engine_name, + "corpus_name": corpus_name, + "cer_current": float(current_cer), + "cer_historical_mean": mean, + "cer_historical_median": median, + "n_runs": len(historical_cers), + "absolute_delta": absolute_delta, + "relative_delta": relative_delta, + "off_baseline": off_baseline, + } + + +def compute_corpus_difficulty_percentile( + history, + current_difficulty: float, + *, + min_runs: int = 5, +) -> Optional[dict]: + """Place la difficulté du corpus courant dans la distribution + des difficultés historiques. + + Lit les difficultés stockées dans ``HistoryEntry.metadata`` + sous la clé ``difficulty`` (convention de + ``picarones/core/difficulty.py``). + + Returns + ------- + Optional[dict] + ``{ + "current_difficulty": float, + "percentile": float, # 0..100 + "n_runs": int, + "median_historical": float, + "harder_than_usual": bool, # percentile > 75 + "easier_than_usual": bool, # percentile < 25 + }`` + ou ``None`` si moins de ``min_runs`` runs historiques ont + une difficulté enregistrée. + """ + if current_difficulty is None: + return None + try: + entries = history.query(limit=1000) + except Exception as exc: # pragma: no cover + logger.warning( + "[baseline_comparison] query history a levé : %s", exc, + ) + return None + + historical_difficulties: list[float] = [] + for entry in entries: + diff = entry.metadata.get("difficulty") if entry.metadata else None + if diff is None: + continue + try: + historical_difficulties.append(float(diff)) + except (TypeError, ValueError): + continue + + if len(historical_difficulties) < min_runs: + return None + + sorted_diff = sorted(historical_difficulties) + n = len(sorted_diff) + # Percentile = % de corpus historiques de difficulté ≤ + # current_difficulty. Convention courante (P_i = i/n × 100). + n_below = sum(1 for d in sorted_diff if d <= current_difficulty) + percentile = (n_below / n) * 100.0 + median = statistics.median(sorted_diff) + + return { + "current_difficulty": float(current_difficulty), + "percentile": percentile, + "n_runs": n, + "median_historical": median, + "harder_than_usual": percentile > 75.0, + "easier_than_usual": percentile < 25.0, + } + + +__all__ = [ + "compute_engine_baseline", + "compute_corpus_difficulty_percentile", +] diff --git a/picarones/measurements/builtin_hooks.py b/picarones/measurements/builtin_hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..ebb3481d460e89241bf2b02dc77930c7dc1f7743 --- /dev/null +++ b/picarones/measurements/builtin_hooks.py @@ -0,0 +1,582 @@ +"""Enregistrement des hooks de métriques natifs de Picarones. + +Chantier 2 du plan d'évolution post-Sprint 97. + +Ce module **migre** les 12 hooks document-level et 12 agrégateurs +corpus-level qui étaient codés en dur dans +``picarones.core.runner._compute_document_result`` et autour de la +boucle d'agrégation (lignes 794-827 du runner pré-chantier-2). + +Approche additive — rétrocompat stricte +--------------------------------------- +Tous les hooks sont enregistrés sur les profils ``standard``, +``philological``, ``diagnostics`` et ``full`` (i.e. activés par +défaut quand le runner est appelé sans paramètre ``profile``). Le +profil ``minimal`` n'active aucun hook (pour bench massif où seul +CER/WER comptent). Les profils ``economics`` et ``pipeline`` sont +réservés pour des hooks futurs. + +L'import de ce module **suffit** à peupler les registres : +:mod:`picarones.core.metric_hooks` se contente d'exposer les +décorateurs ; le runner ne dépend que d'une seule fonction — +``select_document_hooks(profile)`` — pour découvrir les hooks actifs. + +Liste complète des hooks (Sprint d'origine) +------------------------------------------- +**Document-level** (12) : + +- ``confusion`` (Sprint 5) — ``confusion_matrix`` +- ``char_scores`` (Sprint 5) — ``char_scores`` +- ``taxonomy`` (Sprint 5) — ``taxonomy`` +- ``structure`` (Sprint 5) — ``structure`` +- ``image_quality`` (Sprint 5) — ``image_quality`` +- ``line_metrics`` (Sprint 10) — ``line_metrics`` +- ``hallucination`` (Sprint 10) — ``hallucination_metrics`` +- ``calibration`` (Sprint 42) — ``calibration_metrics`` +- ``philological`` (Sprint 61) — ``philological_metrics`` +- ``searchability`` (Sprint 86) — ``searchability_metrics`` +- ``numerical_sequences`` (Sprint 86) — ``numerical_sequence_metrics`` +- ``readability`` (Sprint 87) — ``readability_metrics`` + +**Corpus-level** (12) : un agrégateur par hook documentaire, +remplissant le champ ``aggregated_*`` correspondant du +``EngineReport``. + +Le hook ``ner`` (Sprint 40) reste hors de ce mécanisme : il dépend +d'un ``EntityExtractor`` injecté à la main par l'utilisateur, ce +qui n'entre pas dans la sémantique des profils. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from typing import Any, Optional + +from picarones.core.metric_hooks import ( + PROFILE_DIAGNOSTICS, + PROFILE_FULL, + PROFILE_PHILOLOGICAL, + PROFILE_STANDARD, + register_corpus_aggregator, + register_document_metric, +) + +logger = logging.getLogger(__name__) + + +# Profils dans lesquels les 12 hooks "standard" s'activent. Égalent +# par construction le comportement runner pré-chantier-2 ; le profil +# ``minimal`` est volontairement absent. +_STANDARD_PROFILES = ( + PROFILE_STANDARD, + PROFILE_PHILOLOGICAL, + PROFILE_DIAGNOSTICS, + PROFILE_FULL, +) + + +# ────────────────────────────────────────────────────────────────────────── +# Helper de calibration (déplacé depuis runner.py — chantier 2) +# ────────────────────────────────────────────────────────────────────────── + + +def calibration_from_engine_result( + ground_truth: str, + token_confidences: list, +) -> Optional[dict]: + """Aligne les ``token_confidences`` du moteur sur la GT (bag-of-words) + pour produire les listes parallèles ``confidences`` / ``is_correct``, + puis appelle ``compute_calibration_metrics`` (Sprint 39). + + Convention d'alignement (proxy bag-of-words avec multiplicité, comme + ``oracle_token_recall`` du Sprint 35) : un token de l'hypothèse est + "correct" si la GT contient encore une occurrence de ce token. + + Les confidences ``> 1.0`` sont supposées en pourcentage et + normalisées à ``[0, 1]``. Les confidences négatives (Tesseract met + -1 pour les non-mots) sont ignorées. + """ + from picarones.core.calibration import compute_calibration_metrics + + if not token_confidences: + return None + + gt_counter = Counter((ground_truth or "").split()) + confidences: list[float] = [] + is_correct: list[int] = [] + + for tc in token_confidences: + if not isinstance(tc, dict): + continue + token = str(tc.get("token", "")) + if not token: + continue + try: + conf = float(tc.get("confidence")) + except (TypeError, ValueError): + continue + if conf < 0: + continue + if conf > 1.0: + conf = conf / 100.0 + if not 0.0 <= conf <= 1.0: + continue + if gt_counter[token] > 0: + is_correct.append(1) + gt_counter[token] -= 1 + else: + is_correct.append(0) + confidences.append(conf) + + if not confidences: + return None + return compute_calibration_metrics(confidences, is_correct) + + +# ────────────────────────────────────────────────────────────────────────── +# Document-level hooks (12) +# ────────────────────────────────────────────────────────────────────────── + + +@register_document_metric( + name="confusion", + attribute="confusion_matrix", + profiles=_STANDARD_PROFILES, + requires_success=True, +) +def _confusion_hook(*, ground_truth, hypothesis, **_): + from picarones.core.confusion import build_confusion_matrix + return build_confusion_matrix(ground_truth, hypothesis).as_dict() + + +@register_document_metric( + name="char_scores", + attribute="char_scores", + profiles=_STANDARD_PROFILES, + requires_success=True, +) +def _char_scores_hook(*, ground_truth, hypothesis, **_): + from picarones.core.char_scores import ( + compute_diacritic_score, + compute_ligature_score, + ) + lig = compute_ligature_score(ground_truth, hypothesis) + diac = compute_diacritic_score(ground_truth, hypothesis) + return {"ligature": lig.as_dict(), "diacritic": diac.as_dict()} + + +@register_document_metric( + name="taxonomy", + attribute="taxonomy", + profiles=_STANDARD_PROFILES, + requires_success=True, +) +def _taxonomy_hook(*, ground_truth, hypothesis, **_): + from picarones.core.taxonomy import classify_errors + return classify_errors(ground_truth, hypothesis).as_dict() + + +@register_document_metric( + name="structure", + attribute="structure", + profiles=_STANDARD_PROFILES, + requires_success=True, +) +def _structure_hook(*, ground_truth, hypothesis, **_): + from picarones.core.structure import analyze_structure + return analyze_structure(ground_truth, hypothesis).as_dict() + + +@register_document_metric( + name="line_metrics", + attribute="line_metrics", + profiles=_STANDARD_PROFILES, + requires_success=True, +) +def _line_metrics_hook(*, ground_truth, hypothesis, **_): + from picarones.core.line_metrics import compute_line_metrics + return compute_line_metrics(ground_truth, hypothesis).as_dict() + + +@register_document_metric( + name="hallucination", + attribute="hallucination_metrics", + profiles=_STANDARD_PROFILES, + requires_success=True, +) +def _hallucination_hook(*, ground_truth, hypothesis, **_): + from picarones.core.hallucination import compute_hallucination_metrics + return compute_hallucination_metrics(ground_truth, hypothesis).as_dict() + + +@register_document_metric( + name="calibration", + attribute="calibration_metrics", + profiles=_STANDARD_PROFILES, + requires_token_confidences=True, +) +def _calibration_hook(*, ground_truth, ocr_result, **_): + return calibration_from_engine_result( + ground_truth, ocr_result.token_confidences, + ) + + +@register_document_metric( + name="image_quality", + attribute="image_quality", + profiles=_STANDARD_PROFILES, + # Pas de requires_success : on analyse l'image quel que soit le + # résultat OCR (pour comparer un échec OCR à la qualité image). +) +def _image_quality_hook(*, image_path, **_): + from picarones.core.image_quality import analyze_image_quality + iq = analyze_image_quality(image_path) + if iq.error is not None: + return None + return iq.as_dict() + + +@register_document_metric( + name="philological", + attribute="philological_metrics", + profiles=_STANDARD_PROFILES, + # Pas de requires_success : le runner pré-chantier-2 calculait + # même sur échec OCR (avec hyp=""). Les modules philologiques + # retournent ``None`` quand la GT n'a pas de signal exploitable + # — comportement adaptive intact. +) +def _philological_hook(*, ground_truth, hypothesis, **_): + from picarones.core.philological_runner import compute_philological_metrics + return compute_philological_metrics(ground_truth, hypothesis) + + +@register_document_metric( + name="searchability", + attribute="searchability_metrics", + profiles=_STANDARD_PROFILES, +) +def _searchability_hook(*, ground_truth, hypothesis, **_): + from picarones.core.searchability_runner import compute_searchability_metrics + return compute_searchability_metrics(ground_truth, hypothesis) + + +@register_document_metric( + name="numerical_sequences", + attribute="numerical_sequence_metrics", + profiles=_STANDARD_PROFILES, +) +def _numerical_sequences_hook(*, ground_truth, hypothesis, **_): + from picarones.core.numerical_sequences_runner import ( + compute_numerical_sequence_metrics_adaptive, + ) + return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis) + + +@register_document_metric( + name="readability", + attribute="readability_metrics", + profiles=_STANDARD_PROFILES, +) +def _readability_hook(*, ground_truth, hypothesis, corpus_lang, **_): + from picarones.core.readability_runner import compute_readability_metrics + return compute_readability_metrics(ground_truth, hypothesis, lang=corpus_lang) + + +# ────────────────────────────────────────────────────────────────────────── +# Corpus-level aggregators (12) +# ────────────────────────────────────────────────────────────────────────── + + +@register_corpus_aggregator( + name="confusion", + attribute="aggregated_confusion", + profiles=_STANDARD_PROFILES, +) +def _aggregate_confusion(doc_results: list) -> Optional[dict]: + from picarones.core.confusion import ( + ConfusionMatrix, aggregate_confusion_matrices, + ) + matrices = [ + ConfusionMatrix(**dr.confusion_matrix) + for dr in doc_results + if dr.confusion_matrix is not None + ] + if not matrices: + return None + return aggregate_confusion_matrices(matrices).as_compact_dict(min_count=2) + + +@register_corpus_aggregator( + name="char_scores", + attribute="aggregated_char_scores", + profiles=_STANDARD_PROFILES, +) +def _aggregate_char_scores(doc_results: list) -> Optional[dict]: + from picarones.core.char_scores import ( + DiacriticScore, + LigatureScore, + aggregate_diacritic_scores, + aggregate_ligature_scores, + ) + lig_scores = [ + LigatureScore(**dr.char_scores["ligature"]) + for dr in doc_results + if dr.char_scores is not None + ] + diac_scores = [ + DiacriticScore(**dr.char_scores["diacritic"]) + for dr in doc_results + if dr.char_scores is not None + ] + if not lig_scores: + return None + return { + "ligature": aggregate_ligature_scores(lig_scores), + "diacritic": aggregate_diacritic_scores(diac_scores), + } + + +@register_corpus_aggregator( + name="taxonomy", + attribute="aggregated_taxonomy", + profiles=_STANDARD_PROFILES, +) +def _aggregate_taxonomy(doc_results: list) -> Optional[dict]: + from picarones.core.taxonomy import TaxonomyResult, aggregate_taxonomy + results = [ + TaxonomyResult.from_dict(dr.taxonomy) + for dr in doc_results + if dr.taxonomy is not None + ] + if not results: + return None + return aggregate_taxonomy(results) + + +@register_corpus_aggregator( + name="structure", + attribute="aggregated_structure", + profiles=_STANDARD_PROFILES, +) +def _aggregate_structure(doc_results: list) -> Optional[dict]: + from picarones.core.structure import StructureResult, aggregate_structure + results = [ + StructureResult.from_dict(dr.structure) + for dr in doc_results + if dr.structure is not None + ] + if not results: + return None + return aggregate_structure(results) + + +@register_corpus_aggregator( + name="image_quality", + attribute="aggregated_image_quality", + profiles=_STANDARD_PROFILES, +) +def _aggregate_image_quality(doc_results: list) -> Optional[dict]: + from picarones.core.image_quality import ( + ImageQualityResult, aggregate_image_quality, + ) + results = [ + ImageQualityResult.from_dict(dr.image_quality) + for dr in doc_results + if dr.image_quality is not None + ] + if not results: + return None + return aggregate_image_quality(results) + + +@register_corpus_aggregator( + name="line_metrics", + attribute="aggregated_line_metrics", + profiles=_STANDARD_PROFILES, +) +def _aggregate_line_metrics(doc_results: list) -> Optional[dict]: + from picarones.core.line_metrics import ( + LineMetrics, aggregate_line_metrics, + ) + results = [ + LineMetrics.from_dict(dr.line_metrics) + for dr in doc_results + if dr.line_metrics is not None + ] + if not results: + return None + return aggregate_line_metrics(results) + + +@register_corpus_aggregator( + name="hallucination", + attribute="aggregated_hallucination", + profiles=_STANDARD_PROFILES, +) +def _aggregate_hallucination(doc_results: list) -> Optional[dict]: + from picarones.core.hallucination import ( + HallucinationMetrics, aggregate_hallucination_metrics, + ) + results = [ + HallucinationMetrics.from_dict(dr.hallucination_metrics) + for dr in doc_results + if dr.hallucination_metrics is not None + ] + if not results: + return None + return aggregate_hallucination_metrics(results) + + +@register_corpus_aggregator( + name="calibration", + attribute="aggregated_calibration", + profiles=_STANDARD_PROFILES, +) +def _aggregate_calibration(doc_results: list) -> Optional[dict]: + """Agrège la calibration micro sur tous les docs. + + Recalcule ECE/MCE à partir de la **somme des bins** de chaque + document : pour chaque bin, on additionne ``count``, on agrège la + confiance moyenne pondérée par count, et on agrège l'accuracy + pondérée par count. L'ECE micro est ensuite la moyenne pondérée + par bin de ``|conf - acc|``. + + Comportement déplacé verbatim depuis ``runner._aggregate_calibration`` + (chantier 2 — rétrocompat octet par octet du sérialisé). + """ + relevant = [ + dr for dr in doc_results + if dr.calibration_metrics is not None + and (dr.calibration_metrics.get("bins") or []) + ] + if not relevant: + return None + + n_bins = relevant[0].calibration_metrics.get("n_bins", 10) + sum_conf: list[float] = [0.0] * n_bins + sum_acc: list[float] = [0.0] * n_bins + counts: list[int] = [0] * n_bins + bin_lows: list[float] = [ + b["bin_low"] for b in relevant[0].calibration_metrics["bins"] + ] + bin_highs: list[float] = [ + b["bin_high"] for b in relevant[0].calibration_metrics["bins"] + ] + + for dr in relevant: + m = dr.calibration_metrics + if m.get("n_bins") != n_bins: + logger.warning( + "[aggregate_calibration] %s : n_bins=%s ≠ %s — ignoré", + dr.doc_id, m.get("n_bins"), n_bins, + ) + continue + for k, b in enumerate(m["bins"]): + n = int(b.get("count") or 0) + if n == 0: + continue + counts[k] += n + sum_conf[k] += float(b.get("avg_confidence") or 0.0) * n + sum_acc[k] += float(b.get("accuracy") or 0.0) * n + + total = sum(counts) + if total == 0: + return None + + bins: list[dict] = [] + ece = 0.0 + mce = 0.0 + for k in range(n_bins): + n = counts[k] + if n == 0: + bins.append({ + "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins, + "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins, + "avg_confidence": None, + "accuracy": None, + "count": 0, + "gap": None, + }) + continue + avg_conf = sum_conf[k] / n + accuracy = sum_acc[k] / n + gap = abs(avg_conf - accuracy) + bins.append({ + "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins, + "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins, + "avg_confidence": avg_conf, + "accuracy": accuracy, + "count": n, + "gap": gap, + }) + ece += (n / total) * gap + if gap > mce: + mce = gap + + overall_acc = sum(sum_acc) / total + overall_conf = sum(sum_conf) / total + + return { + "ece": ece, + "mce": mce, + "n_bins": n_bins, + "n_predictions": total, + "overall_accuracy": overall_acc, + "overall_confidence": overall_conf, + "bins": bins, + "doc_count": len(relevant), + } + + +@register_corpus_aggregator( + name="philological", + attribute="aggregated_philological", + profiles=_STANDARD_PROFILES, +) +def _aggregate_philological(doc_results: list) -> Optional[dict]: + from picarones.core.philological_runner import aggregate_philological_metrics + return aggregate_philological_metrics( + [dr.philological_metrics for dr in doc_results], + ) + + +@register_corpus_aggregator( + name="searchability", + attribute="aggregated_searchability", + profiles=_STANDARD_PROFILES, +) +def _aggregate_searchability(doc_results: list) -> Optional[dict]: + from picarones.core.searchability_runner import aggregate_searchability_metrics + return aggregate_searchability_metrics( + [dr.searchability_metrics for dr in doc_results], + ) + + +@register_corpus_aggregator( + name="numerical_sequences", + attribute="aggregated_numerical_sequences", + profiles=_STANDARD_PROFILES, +) +def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]: + from picarones.core.numerical_sequences_runner import ( + aggregate_numerical_sequence_metrics, + ) + return aggregate_numerical_sequence_metrics( + [dr.numerical_sequence_metrics for dr in doc_results], + ) + + +@register_corpus_aggregator( + name="readability", + attribute="aggregated_readability", + profiles=_STANDARD_PROFILES, +) +def _aggregate_readability(doc_results: list) -> Optional[dict]: + from picarones.core.readability_runner import aggregate_readability_metrics + return aggregate_readability_metrics( + [dr.readability_metrics for dr in doc_results], + ) + + +__all__ = ["calibration_from_engine_result"] diff --git a/picarones/measurements/calibration.py b/picarones/measurements/calibration.py new file mode 100644 index 0000000000000000000000000000000000000000..35819b20332e0b915b4cb13a5b9c55555f50c392 --- /dev/null +++ b/picarones/measurements/calibration.py @@ -0,0 +1,323 @@ +"""Calibration des moteurs : ECE, MCE, reliability diagram. + +Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure. + +Pourquoi ce module +------------------ +Tous les moteurs OCR cibles fournissent une confidence par token ou par +ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``, +Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``). +La question naturelle pour un workflow patrimonial est : *« quand le +moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe +qui doit vérifier humainement un corpus de 50 000 pages, la différence +entre vérifier 100 % vs 15 % du volume est l'effet de la calibration. + +Ce module fournit les trois mesures classiques : + +- **Expected Calibration Error (ECE)** — moyenne pondérée par bin de + l'écart absolu entre confiance moyenne et précision moyenne. + ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart + systématique entre confiance affichée et fiabilité réelle. +- **Maximum Calibration Error (MCE)** — max de cet écart sur les bins. + Utile pour repérer le pire mensonge du moteur (ex. il dit toujours + 95 % de confiance et il a tort une fois sur deux). +- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf, + accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en + Chart.js côté navigateur dans un sprint suivant. + +Stratégie de découpage +---------------------- +Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37), +on découpe : + +- **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes + parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1). + Aucune dépendance externe. +- **Sprint à venir** — exposition de ``token_confidences`` sur + ``EngineResult``, alignement caractère/token avec la GT pour produire + ``is_correct``, intégration dans le runner et vue HTML reliability. + +Ce qui est explicitement hors scope +----------------------------------- +Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est +extraite ; on calcule uniquement à partir de séquences de prédictions +fournies en entrée. C'est ce qui permet de tester rigoureusement les +invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias +constant, etc.) sans dépendre d'un backend. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle de données +# ────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class CalibrationBin: + """Un bin du reliability diagram. + + Attributs + --------- + bin_low, bin_high: + Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` — + sauf le dernier bin qui inclut ``1.0``). + avg_confidence: + Moyenne des confidences des prédictions tombées dans le bin. + ``None`` si le bin est vide. + accuracy: + Fraction de prédictions correctes dans le bin (``∈ [0, 1]``). + ``None`` si le bin est vide. + count: + Nombre de prédictions dans le bin. + """ + + bin_low: float + bin_high: float + avg_confidence: float | None + accuracy: float | None + count: int + + @property + def gap(self) -> float | None: + """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide.""" + if self.avg_confidence is None or self.accuracy is None: + return None + return abs(self.avg_confidence - self.accuracy) + + +# ────────────────────────────────────────────────────────────────────────── +# Validation +# ────────────────────────────────────────────────────────────────────────── + + +def _validate_inputs( + confidences: list[float], + is_correct: list[bool | int], +) -> None: + if len(confidences) != len(is_correct): + raise ValueError( + f"Longueurs incompatibles : confidences={len(confidences)} " + f"vs is_correct={len(is_correct)}" + ) + for i, c in enumerate(confidences): + if not (0.0 <= float(c) <= 1.0): + raise ValueError( + f"Confiance hors [0, 1] à l'index {i} : {c!r}" + ) + + +# ────────────────────────────────────────────────────────────────────────── +# Reliability diagram (binning) +# ────────────────────────────────────────────────────────────────────────── + + +def reliability_diagram( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> list[CalibrationBin]: + """Découpe les prédictions en ``n_bins`` bins équidistants par confiance + et calcule pour chacun la confiance moyenne, la précision et le compte. + + Parameters + ---------- + confidences: + Confidences des prédictions, ``∈ [0, 1]``. + is_correct: + Indicateur booléen (1 = prédiction correcte, 0 = incorrecte). + n_bins: + Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)`` + sauf le dernier bin qui inclut ``1.0``. + + Returns + ------- + list[CalibrationBin] + Liste de ``n_bins`` bins, dans l'ordre croissant des confidences. + """ + if n_bins < 1: + raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}") + + confs = [float(c) for c in confidences] + correct = [int(bool(x)) for x in is_correct] + _validate_inputs(confs, correct) + + bin_width = 1.0 / n_bins + sums: list[float] = [0.0] * n_bins + correct_counts: list[int] = [0] * n_bins + counts: list[int] = [0] * n_bins + + for c, ok in zip(confs, correct): + # Calcul du bin index par multiplication ``c * n_bins`` plutôt que + # division ``c / bin_width`` pour éviter les pièges de + # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754 + # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)). + if c >= 1.0: + idx = n_bins - 1 + else: + idx = int(c * n_bins) + # Garde-fou en cas d'arrondi flottant + if idx >= n_bins: + idx = n_bins - 1 + elif idx < 0: + idx = 0 + sums[idx] += c + correct_counts[idx] += ok + counts[idx] += 1 + + bins: list[CalibrationBin] = [] + for k in range(n_bins): + low = k * bin_width + high = (k + 1) * bin_width + n = counts[k] + if n == 0: + bins.append(CalibrationBin(low, high, None, None, 0)) + else: + bins.append(CalibrationBin( + bin_low=low, + bin_high=high, + avg_confidence=sums[k] / n, + accuracy=correct_counts[k] / n, + count=n, + )) + return bins + + +# ────────────────────────────────────────────────────────────────────────── +# ECE et MCE +# ────────────────────────────────────────────────────────────────────────── + + +def expected_calibration_error( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> float: + """Expected Calibration Error : moyenne pondérée par bin de l'écart + absolu confiance ↔ précision. + + ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|`` + + où la somme porte sur les bins non vides. + + Returns + ------- + float + ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. + """ + bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) + total = sum(b.count for b in bins) + if total == 0: + return 0.0 + ece = 0.0 + for b in bins: + if b.count == 0 or b.gap is None: + continue + ece += (b.count / total) * b.gap + return ece + + +def maximum_calibration_error( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> float: + """Maximum Calibration Error : pire écart confiance ↔ précision sur + tous les bins non vides. + + Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 % + de confiance et il a tort une fois sur deux dans ce bin). + + Returns + ------- + float + ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. + """ + bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) + gaps = [b.gap for b in bins if b.gap is not None] + return max(gaps) if gaps else 0.0 + + +# ────────────────────────────────────────────────────────────────────────── +# Vue agrégée +# ────────────────────────────────────────────────────────────────────────── + + +def compute_calibration_metrics( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> dict: + """Calcule l'ensemble des métriques de calibration en un appel. + + Returns + ------- + dict + ``{ + "ece": float, + "mce": float, + "n_bins": int, + "n_predictions": int, + "overall_accuracy": float, + "overall_confidence": float, + "bins": [ + {"bin_low", "bin_high", "avg_confidence", + "accuracy", "count", "gap"}, + ... + ], + }`` + """ + confs = list(confidences) + correct = list(is_correct) + bins = reliability_diagram(confs, correct, n_bins=n_bins) + total = sum(b.count for b in bins) + overall_acc = ( + sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0 + ) + overall_conf = ( + sum(float(c) for c in confs) / total if total > 0 else 0.0 + ) + + ece = 0.0 + if total > 0: + for b in bins: + if b.gap is None: + continue + ece += (b.count / total) * b.gap + mce = max((b.gap for b in bins if b.gap is not None), default=0.0) + + return { + "ece": ece, + "mce": mce, + "n_bins": n_bins, + "n_predictions": total, + "overall_accuracy": overall_acc, + "overall_confidence": overall_conf, + "bins": [ + { + "bin_low": b.bin_low, + "bin_high": b.bin_high, + "avg_confidence": b.avg_confidence, + "accuracy": b.accuracy, + "count": b.count, + "gap": b.gap, + } + for b in bins + ], + } + + +__all__ = [ + "CalibrationBin", + "reliability_diagram", + "expected_calibration_error", + "maximum_calibration_error", + "compute_calibration_metrics", +] diff --git a/picarones/measurements/char_scores.py b/picarones/measurements/char_scores.py new file mode 100644 index 0000000000000000000000000000000000000000..e390462b14d027212f05f765d6691b8fe8542aa3 --- /dev/null +++ b/picarones/measurements/char_scores.py @@ -0,0 +1,370 @@ +"""Scores de reconnaissance des ligatures et des diacritiques. + +Ces métriques sont spécifiques aux documents patrimoniaux (manuscrits, imprimés +anciens) où ligatures et diacritiques jouent un rôle paléographique essentiel. + +Ligatures +--------- +Caractères encodés comme une séquence unique dans Unicode mais représentant +deux ou plusieurs glyphes fusionnés : fi (fi), fl (fl), œ, æ, etc. + +Pour chaque ligature présente dans le GT, on vérifie si l'OCR a produit +soit le caractère Unicode équivalent, soit la séquence décomposée équivalente. + +Diacritiques +----------- +Accents, cédilles, trémas et autres signes diacritiques. Pour chaque caractère +accentué dans le GT, on vérifie si l'OCR a conservé le diacritique ou l'a +remplacé par la lettre de base. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +import unicodedata + + +# --------------------------------------------------------------------------- +# Tables de ligatures (char ligature → séquences équivalentes acceptées) +# --------------------------------------------------------------------------- + +#: Table principale des ligatures et leurs équivalents acceptés. +#: Clé = caractère ligature Unicode ; valeur = liste de séquences équivalentes. +LIGATURE_TABLE: dict[str, list[str]] = { + # Ligatures typographiques latines (Unicode Letterlike Symbols / Alphabetic Presentation Forms) + "\uFB00": ["ff"], # ff ff + "\uFB01": ["fi"], # fi fi + "\uFB02": ["fl"], # fl fl + "\uFB03": ["ffi"], # ffi ffi + "\uFB04": ["ffl"], # ffl ffl + "\uFB05": ["st", "\u017Ft"], # ſt st / ſt + "\uFB06": ["st"], # st st (variante) + # Ligatures latines patrimoniales (Unicode Latin Extended Additional) + "\u0153": ["oe"], # œ oe + "\u00E6": ["ae"], # æ ae + "\u0152": ["OE"], # Œ OE + "\u00C6": ["AE"], # Æ AE + # Abréviations latines / médiévales + "\uA751": ["per", "p\u0332"], # ꝑ per / p̲ + "\uA753": ["pro"], # ꝓ pro + "\uA757": ["que"], # ꝗ que + # Ligatures germaniques + "\u00DF": ["ss"], # ß ss + "\u1E9E": ["SS"], # ẞ SS +} + +# Ensemble de toutes les ligatures pour recherche rapide +_ALL_LIGATURES: frozenset[str] = frozenset(LIGATURE_TABLE) + +# Mapping inverse : séquence → ligature +_SEQ_TO_LIGATURE: dict[str, str] = {} +for _lig, _seqs in LIGATURE_TABLE.items(): + for _seq in _seqs: + _SEQ_TO_LIGATURE[_seq] = _lig + + +# --------------------------------------------------------------------------- +# Table des caractères diacritiques +# --------------------------------------------------------------------------- + +def _build_diacritic_map() -> dict[str, str]: + """Construit automatiquement la table diacritique depuis l'Unicode.""" + table: dict[str, str] = {} + for codepoint in range(0x00C0, 0x0250): # Latin Étendu A + B + ch = chr(codepoint) + nfd = unicodedata.normalize("NFD", ch) + if len(nfd) > 1: # le caractère est décomposable + base = nfd[0] # lettre de base + if base.isalpha() and base != ch: + table[ch] = base + # Compléments manuels + table.update({ + "\u0107": "c", # ć + "\u0119": "e", # ę + "\u0142": "l", # ł + "\u0144": "n", # ń + "\u015B": "s", # ś + "\u017A": "z", # ź + "\u017C": "z", # ż + }) + return table + + +DIACRITIC_MAP: dict[str, str] = _build_diacritic_map() +_ALL_DIACRITICS: frozenset[str] = frozenset(DIACRITIC_MAP) + +# Ligatures qui NE sont PAS des diacritiques (pour éviter les doublons) +_LIGATURE_SET: frozenset[str] = frozenset(LIGATURE_TABLE) + + +# --------------------------------------------------------------------------- +# Résultats structurés +# --------------------------------------------------------------------------- + +@dataclass +class LigatureScore: + """Score de reconnaissance des ligatures pour une paire (GT, OCR).""" + + total_in_gt: int = 0 + """Nombre de ligatures présentes dans le GT.""" + correctly_recognized: int = 0 + """Nombre de ligatures correctement transcrites (unicode ou équivalent).""" + score: float = 0.0 + """Taux de reconnaissance = correctly_recognized / total_in_gt. 1.0 si total=0.""" + per_ligature: dict[str, dict] = field(default_factory=dict) + """Détail par ligature : {'fi': {'gt_count': 5, 'ocr_correct': 3, 'score': 0.6}}""" + + def as_dict(self) -> dict: + return { + "total_in_gt": self.total_in_gt, + "correctly_recognized": self.correctly_recognized, + "score": round(self.score, 4), + "per_ligature": { + k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} + for k, v in self.per_ligature.items() + }, + } + + +@dataclass +class DiacriticScore: + """Score de conservation des diacritiques pour une paire (GT, OCR).""" + + total_in_gt: int = 0 + """Nombre de caractères accentués dans le GT.""" + correctly_recognized: int = 0 + """Nombre de diacritiques correctement conservés.""" + score: float = 0.0 + """Taux de conservation = correctly_recognized / total_in_gt. 1.0 si total=0.""" + per_diacritic: dict[str, dict] = field(default_factory=dict) + """Détail par caractère diacritique.""" + + def as_dict(self) -> dict: + return { + "total_in_gt": self.total_in_gt, + "correctly_recognized": self.correctly_recognized, + "score": round(self.score, 4), + "per_diacritic": { + k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} + for k, v in self.per_diacritic.items() + }, + } + + +# --------------------------------------------------------------------------- +# Calcul des scores +# --------------------------------------------------------------------------- + +def compute_ligature_score(ground_truth: str, hypothesis: str) -> LigatureScore: + """Calcule le score de reconnaissance des ligatures. + + Pour chaque ligature dans le GT, on vérifie si l'OCR a produit : + - Exactement le même caractère ligature Unicode (ex. fi → fi) + - Ou la séquence de lettres équivalente (ex. fi → fi) + + Les deux sont considérés comme corrects — ce qui correspond à la pratique + éditoriale patrimoniaux (certains éditeurs développent les ligatures). + + Parameters + ---------- + ground_truth: + Texte de référence. + hypothesis: + Texte produit par l'OCR. + + Returns + ------- + LigatureScore + """ + if not ground_truth: + return LigatureScore(score=1.0) + + # Construire un index de position dans l'hypothèse pour recherche rapide + hyp_norm = unicodedata.normalize("NFC", hypothesis) + gt_norm = unicodedata.normalize("NFC", ground_truth) + + per_lig: dict[str, dict] = {} + total = 0 + correct = 0 + + # Trouver toutes les ligatures dans le GT + i = 0 + while i < len(gt_norm): + ch = gt_norm[i] + if ch in _ALL_LIGATURES: + total += 1 + equivalents = [ch] + LIGATURE_TABLE[ch] # unicode direct ou séquences équivalentes + + # Vérifier si la position correspondante dans l'OCR contient l'équivalent + is_correct = _check_char_at_context(gt_norm, hyp_norm, i, ch, equivalents) + if is_correct: + correct += 1 + + if ch not in per_lig: + per_lig[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0} + per_lig[ch]["gt_count"] += 1 + if is_correct: + per_lig[ch]["ocr_correct"] += 1 + i += 1 + + # Calculer les scores individuels + for lig_data in per_lig.values(): + lig_data["score"] = ( + lig_data["ocr_correct"] / lig_data["gt_count"] + if lig_data["gt_count"] > 0 + else 1.0 + ) + + score = correct / total if total > 0 else 1.0 + return LigatureScore( + total_in_gt=total, + correctly_recognized=correct, + score=score, + per_ligature=per_lig, + ) + + +def compute_diacritic_score(ground_truth: str, hypothesis: str) -> DiacriticScore: + """Calcule le score de conservation des diacritiques. + + Pour chaque caractère accentué dans le GT, on vérifie si l'OCR a produit + le même caractère (conservation) ou a substitué la lettre de base (perte). + On accepte aussi les formes NFD équivalentes. + + Parameters + ---------- + ground_truth: + Texte de référence. + hypothesis: + Texte produit par l'OCR. + + Returns + ------- + DiacriticScore + """ + if not ground_truth: + return DiacriticScore(score=1.0) + + gt_norm = unicodedata.normalize("NFC", ground_truth) + hyp_norm = unicodedata.normalize("NFC", hypothesis) + + per_diac: dict[str, dict] = {} + total = 0 + correct = 0 + + # Utiliser difflib pour l'alignement + import difflib + matcher = difflib.SequenceMatcher(None, gt_norm, hyp_norm, autojunk=False) + gt_to_hyp: dict[int, Optional[int]] = {} + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + for k in range(i2 - i1): + gt_to_hyp[i1 + k] = j1 + k + elif tag == "replace" and (i2 - i1) == (j2 - j1): + for k in range(i2 - i1): + gt_to_hyp[i1 + k] = j1 + k + else: + # delete ou replace de longueurs différentes + for k in range(i1, i2): + gt_to_hyp[k] = None + + for i, ch in enumerate(gt_norm): + if ch in _ALL_DIACRITICS and ch not in _LIGATURE_SET: + total += 1 + hyp_pos = gt_to_hyp.get(i) + is_correct = False + if hyp_pos is not None and hyp_pos < len(hyp_norm): + hyp_ch = hyp_norm[hyp_pos] + is_correct = (hyp_ch == ch) + if is_correct: + correct += 1 + + if ch not in per_diac: + per_diac[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0} + per_diac[ch]["gt_count"] += 1 + if is_correct: + per_diac[ch]["ocr_correct"] += 1 + + for diac_data in per_diac.values(): + diac_data["score"] = ( + diac_data["ocr_correct"] / diac_data["gt_count"] + if diac_data["gt_count"] > 0 + else 1.0 + ) + + score = correct / total if total > 0 else 1.0 + return DiacriticScore( + total_in_gt=total, + correctly_recognized=correct, + score=score, + per_diacritic=per_diac, + ) + + +def _check_char_at_context( + gt: str, + hyp: str, + gt_pos: int, + gt_char: str, + equivalents: list[str], +) -> bool: + """Vérifie si la position correspondante dans l'hypothèse contient un équivalent. + + Cherche dans une fenêtre de ±5 caractères autour de la position estimée + pour tolérer les décalages d'alignement OCR. + """ + # Position estimée dans l'hypothèse (ratio proportionnel) + if len(gt) == 0: + return False + est_pos = int(gt_pos * len(hyp) / len(gt)) if len(gt) > 0 else 0 + window = 5 + start = max(0, est_pos - window) + end = min(len(hyp), est_pos + window + len(gt_char)) + context = hyp[start:end] + for equiv in equivalents: + if equiv in context: + return True + return False + + +def aggregate_ligature_scores(scores: list[LigatureScore]) -> dict: + """Agrège les scores de ligatures sur un corpus.""" + total_gt = sum(s.total_in_gt for s in scores) + total_correct = sum(s.correctly_recognized for s in scores) + score = total_correct / total_gt if total_gt > 0 else 1.0 + + # Agrégation par ligature + per_lig: dict[str, dict] = {} + for s in scores: + for lig, data in s.per_ligature.items(): + if lig not in per_lig: + per_lig[lig] = {"gt_count": 0, "ocr_correct": 0} + per_lig[lig]["gt_count"] += data["gt_count"] + per_lig[lig]["ocr_correct"] += data["ocr_correct"] + for lig_data in per_lig.values(): + lig_data["score"] = ( + lig_data["ocr_correct"] / lig_data["gt_count"] + if lig_data["gt_count"] > 0 else 1.0 + ) + + return { + "score": round(score, 4), + "total_in_gt": total_gt, + "correctly_recognized": total_correct, + "per_ligature": per_lig, + } + + +def aggregate_diacritic_scores(scores: list[DiacriticScore]) -> dict: + """Agrège les scores diacritiques sur un corpus.""" + total_gt = sum(s.total_in_gt for s in scores) + total_correct = sum(s.correctly_recognized for s in scores) + score = total_correct / total_gt if total_gt > 0 else 1.0 + return { + "score": round(score, 4), + "total_in_gt": total_gt, + "correctly_recognized": total_correct, + } diff --git a/picarones/measurements/confusion.py b/picarones/measurements/confusion.py new file mode 100644 index 0000000000000000000000000000000000000000..a90d9ebb9b3eb6a5585e4f172a0a6bbf4be79689 --- /dev/null +++ b/picarones/measurements/confusion.py @@ -0,0 +1,268 @@ +"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR. + +Pour chaque moteur, on calcule quels caractères du GT sont transcrits par +quels caractères OCR (substitutions). Cette "empreinte d'erreur" est +caractéristique de chaque moteur ou pipeline. + +Méthode +------- +L'alignement caractère par caractère utilise les opérations d'édition +de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet +d'identifier les substitutions, insertions et suppressions. + +La matrice est stockée comme un dict de dict : + ``{gt_char: {ocr_char: count}}`` + +La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide : +- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR +- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT) +""" + +from __future__ import annotations + +import difflib +from collections import defaultdict +from dataclasses import dataclass, field + +# Symbole représentant un caractère absent (insertion / suppression) +EMPTY_CHAR = "∅" + +# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne) +_WHITESPACE = set(" \t\n\r") + + +@dataclass +class ConfusionMatrix: + """Matrice de confusion unicode pour une paire (GT, OCR).""" + + matrix: dict[str, dict[str, int]] = field(default_factory=dict) + """Clé externe = char GT ; clé interne = char OCR ; valeur = count.""" + + total_substitutions: int = 0 + total_insertions: int = 0 + total_deletions: int = 0 + + @property + def total_errors(self) -> int: + return self.total_substitutions + self.total_insertions + self.total_deletions + + def top_confusions(self, n: int = 20) -> list[dict]: + """Retourne les n confusions les plus fréquentes (substitutions uniquement).""" + pairs: list[tuple[str, str, int]] = [] + for gt_char, ocr_counts in self.matrix.items(): + if gt_char == EMPTY_CHAR: + continue # insertions + for ocr_char, count in ocr_counts.items(): + if ocr_char == EMPTY_CHAR: + continue # suppressions + if gt_char != ocr_char: + pairs.append((gt_char, ocr_char, count)) + pairs.sort(key=lambda x: -x[2]) + return [ + {"gt": gt, "ocr": ocr, "count": cnt} + for gt, ocr, cnt in pairs[:n] + ] + + def as_compact_dict(self, min_count: int = 1) -> dict: + """Sérialise la matrice en éliminant les entrées rares.""" + compact: dict[str, dict[str, int]] = {} + for gt_char, ocr_counts in self.matrix.items(): + filtered = { + oc: cnt for oc, cnt in ocr_counts.items() + if cnt >= min_count + } + if filtered: + compact[gt_char] = filtered + return { + "matrix": compact, + "total_substitutions": self.total_substitutions, + "total_insertions": self.total_insertions, + "total_deletions": self.total_deletions, + } + + def as_dict(self) -> dict: + return self.as_compact_dict(min_count=1) + + +def build_confusion_matrix( + ground_truth: str, + hypothesis: str, + ignore_whitespace: bool = True, + ignore_correct: bool = True, +) -> ConfusionMatrix: + """Construit la matrice de confusion unicode pour une paire GT/OCR. + + Parameters + ---------- + ground_truth: + Texte de référence (vérité terrain). + hypothesis: + Texte produit par l'OCR. + ignore_whitespace: + Si True, ignore les espaces, tabulations et sauts de ligne. + ignore_correct: + Si True, n'enregistre pas les paires identiques (gt_char == ocr_char). + Par défaut True pour réduire la taille de la matrice. + + Returns + ------- + ConfusionMatrix + """ + matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + n_subs = n_ins = n_dels = 0 + + if not ground_truth and not hypothesis: + return ConfusionMatrix(dict(matrix), 0, 0, 0) + + # SequenceMatcher sur listes de chars pour un alignement précis + matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False) + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + if not ignore_correct: + for ch in ground_truth[i1:i2]: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[ch][ch] += 1 + elif tag == "replace": + # Aligner char par char les séquences de longueurs différentes + gt_seg = ground_truth[i1:i2] + oc_seg = hypothesis[j1:j2] + _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace) + # Substitutions = longueur commune, surplus = insertions ou suppressions + n_subs += min(len(gt_seg), len(oc_seg)) + surplus = abs(len(gt_seg) - len(oc_seg)) + if len(gt_seg) > len(oc_seg): + n_dels += surplus + else: + n_ins += surplus + elif tag == "delete": + for ch in ground_truth[i1:i2]: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[ch][EMPTY_CHAR] += 1 + n_dels += 1 + elif tag == "insert": + for ch in hypothesis[j1:j2]: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[EMPTY_CHAR][ch] += 1 + n_ins += 1 + + # Convertir defaultdict en dict normal + result_matrix: dict[str, dict[str, int]] = { + k: dict(v) for k, v in matrix.items() + } + + return ConfusionMatrix( + matrix=result_matrix, + total_substitutions=n_subs, + total_insertions=n_ins, + total_deletions=n_dels, + ) + + +def _align_segments( + gt_seg: str, + oc_seg: str, + matrix: dict, + ignore_whitespace: bool, +) -> None: + """Aligne deux segments de longueurs potentiellement différentes.""" + if not gt_seg: + for ch in oc_seg: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[EMPTY_CHAR][ch] += 1 + return + if not oc_seg: + for ch in gt_seg: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[ch][EMPTY_CHAR] += 1 + return + + if len(gt_seg) == len(oc_seg): + # Substitutions 1-pour-1 + for g, o in zip(gt_seg, oc_seg): + if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): + continue + matrix[g][o] += 1 + else: + # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts + sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False) + for tag2, i1, i2, j1, j2 in sub.get_opcodes(): + if tag2 == "equal": + pass + elif tag2 == "replace": + # Régression simple : aligner par troncature + for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]): + if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): + continue + matrix[g][o] += 1 + elif tag2 == "delete": + for g in gt_seg[i1:i2]: + if ignore_whitespace and g in _WHITESPACE: + continue + matrix[g][EMPTY_CHAR] += 1 + elif tag2 == "insert": + for o in oc_seg[j1:j2]: + if ignore_whitespace and o in _WHITESPACE: + continue + matrix[EMPTY_CHAR][o] += 1 + + +def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix: + """Agrège plusieurs matrices de confusion en une seule. + + Utile pour obtenir la matrice agrégée sur l'ensemble du corpus. + """ + combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + total_subs = total_ins = total_dels = 0 + + for cm in matrices: + for gt_char, ocr_counts in cm.matrix.items(): + for ocr_char, count in ocr_counts.items(): + combined[gt_char][ocr_char] += count + total_subs += cm.total_substitutions + total_ins += cm.total_insertions + total_dels += cm.total_deletions + + return ConfusionMatrix( + matrix={k: dict(v) for k, v in combined.items()}, + total_substitutions=total_subs, + total_insertions=total_ins, + total_deletions=total_dels, + ) + + +def top_confused_chars( + matrix: ConfusionMatrix, + n: int = 15, + exclude_empty: bool = True, +) -> list[dict]: + """Retourne les caractères GT les plus souvent confondus. + + Retourne une liste triée par nombre total d'erreurs décroissant : + ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]`` + """ + char_stats: dict[str, dict] = {} + for gt_char, ocr_counts in matrix.matrix.items(): + if exclude_empty and gt_char == EMPTY_CHAR: + continue + error_count = sum( + cnt for oc, cnt in ocr_counts.items() + if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR) + ) + if error_count > 0: + top_subs = sorted( + [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char], + key=lambda x: -x["count"], + )[:5] + char_stats[gt_char] = { + "char": gt_char, + "total_errors": error_count, + "top_substitutes": top_subs, + } + + return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n] diff --git a/picarones/measurements/cost_projection.py b/picarones/measurements/cost_projection.py new file mode 100644 index 0000000000000000000000000000000000000000..f9eab7a6d47731e7b6b0722cb86ad9b1aae0729b --- /dev/null +++ b/picarones/measurements/cost_projection.py @@ -0,0 +1,169 @@ +"""Projection de coût en volume cible — Sprint 79 (A.I.6). + +Sprint 79 — A.I.6 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par +unité (1 000 pages). Pour décider business-side, il faut projeter +ce coût sur le **volume cible** que l'utilisateur prévoit de +traiter — payer 50 € de plus sur 50 pages est trivial, sur +5 millions ça change tout. + +Sortie typique +-------------- +*« Pour vos 80 000 pages BMS — Tesseract = 3 €, Pero = 0 € (local +amorti), Mistral OCR = 280 €, GPT-4o post-correction = 600 €. »* + +Aucun seuil arbitraire imposé : le module fournit les chiffres, +le chercheur arbitre selon son budget. + +Dépendance +---------- +S'appuie sur ``picarones.core.pricing`` (Sprint 20) qui expose +``EngineCost.cost_per_1k_pages_eur`` et +``co2_per_1k_pages_g``. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from picarones.core.pricing import EngineCost + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ProjectedCost: + """Coût total projeté d'un moteur pour un volume cible.""" + engine_key: str + target_pages: int + cost_total_eur: Optional[float] + co2_total_g: Optional[float] + cost_per_1k_pages_eur: Optional[float] + co2_per_1k_pages_g: Optional[float] + type: str # "local" / "cloud_api" / "unknown" + + def as_dict(self) -> dict: + return { + "engine_key": self.engine_key, + "target_pages": self.target_pages, + "cost_total_eur": self.cost_total_eur, + "co2_total_g": self.co2_total_g, + "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, + "co2_per_1k_pages_g": self.co2_per_1k_pages_g, + "type": self.type, + } + + +def project_cost_total( + engine_cost: EngineCost, target_pages: int, +) -> Optional[float]: + """Coût total projeté en euros pour ``target_pages`` pages. + + Retourne ``None`` si ``cost_per_1k_pages_eur`` est ``None`` + (données insuffisantes) ou si ``target_pages`` est négatif. + """ + if target_pages < 0: + return None + if engine_cost.cost_per_1k_pages_eur is None: + return None + return engine_cost.cost_per_1k_pages_eur * target_pages / 1000.0 + + +def project_co2_total( + engine_cost: EngineCost, target_pages: int, +) -> Optional[float]: + """Empreinte CO₂ totale en grammes pour ``target_pages`` pages.""" + if target_pages < 0: + return None + if engine_cost.co2_per_1k_pages_g is None: + return None + return engine_cost.co2_per_1k_pages_g * target_pages / 1000.0 + + +def project_engine( + engine_cost: EngineCost, target_pages: int, +) -> ProjectedCost: + """Retourne le ``ProjectedCost`` complet pour un moteur.""" + return ProjectedCost( + engine_key=engine_cost.engine_key, + target_pages=int(target_pages), + cost_total_eur=project_cost_total(engine_cost, target_pages), + co2_total_g=project_co2_total(engine_cost, target_pages), + cost_per_1k_pages_eur=engine_cost.cost_per_1k_pages_eur, + co2_per_1k_pages_g=engine_cost.co2_per_1k_pages_g, + type=engine_cost.type, + ) + + +def project_all_engines( + engine_costs: dict[str, EngineCost], + target_pages: int, +) -> dict[str, ProjectedCost]: + """Projette les coûts de plusieurs moteurs sur le volume cible. + + Retourne un dict ``{engine_name: ProjectedCost}`` avec entrée + pour chaque moteur, y compris ceux sans données de coût (où + ``cost_total_eur`` sera ``None``). + """ + if target_pages < 0: + raise ValueError("target_pages doit être ≥ 0") + return { + name: project_engine(cost, target_pages) + for name, cost in engine_costs.items() + } + + +def cost_gap_table( + projections: dict[str, ProjectedCost], + baseline_engine: str, +) -> dict[str, dict[str, Optional[float]]]: + """Pour chaque moteur, écart de coût total vs baseline. + + Retourne ``{engine: {"total": float, "delta_abs": float, + "delta_rel": float}}`` où : + + - ``delta_abs`` = ``cost - cost_baseline`` (None si l'un des + deux est None) + - ``delta_rel`` = ``delta_abs / cost_baseline`` (None si + baseline = 0 ou None) + + Lève ``KeyError`` si la baseline est inconnue. + """ + if baseline_engine not in projections: + raise KeyError( + f"baseline {baseline_engine!r} absente des projections", + ) + baseline_total = projections[baseline_engine].cost_total_eur + out: dict[str, dict[str, Optional[float]]] = {} + for name, proj in projections.items(): + total = proj.cost_total_eur + if total is None or baseline_total is None: + delta_abs: Optional[float] = None + delta_rel: Optional[float] = None + else: + delta_abs = total - baseline_total + if baseline_total != 0: + delta_rel = delta_abs / baseline_total + else: + delta_rel = None + out[name] = { + "total": total, + "delta_abs": delta_abs, + "delta_rel": delta_rel, + } + return out + + +__all__ = [ + "ProjectedCost", + "project_cost_total", + "project_co2_total", + "project_engine", + "project_all_engines", + "cost_gap_table", +] diff --git a/picarones/measurements/difficulty.py b/picarones/measurements/difficulty.py new file mode 100644 index 0000000000000000000000000000000000000000..7f037a48d4f67d06e7162473b901fc261681373a --- /dev/null +++ b/picarones/measurements/difficulty.py @@ -0,0 +1,202 @@ +"""Score de difficulté intrinsèque par document. + +Le score est indépendant des moteurs OCR : il mesure la difficulté +*objective* d'un document, indépendamment de la qualité des transcriptions. + +Formule +------- + difficulty = w_variance * variance_norm + + w_quality * (1 - image_quality_score) + + w_density * special_char_density + +où : + - variance_norm : variance inter-moteurs du CER, normalisée [0, 1] + - image_quality : score de qualité image [0, 1] (netteté, contraste…) + - special_chars : densité de caractères spéciaux dans la GT [0, 1] + +Les poids sont configurables (défaut : 0.4 / 0.35 / 0.25). + +Score final : [0, 1] — 0 = document facile, 1 = très difficile. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Optional + + +# Poids par défaut +_W_VARIANCE = 0.40 +_W_QUALITY = 0.35 +_W_DENSITY = 0.25 + +# Caractères spéciaux patrimoniaux (ligatures, abréviations, diacritiques rares) +_SPECIAL_CHARS_RE = re.compile( + r"[ſœæꝑꝓ&]" # ligatures / abréviations médiévales + r"|[ḁ-ỿ]" # Latin Étendu Additionnel (diacritiques rares) + r"|[\u0300-\u036f]" # Diacritiques combinants + r"|[\ufb00-\ufb06]" # Formes de présentation latines (fi, fl…) + r"|[IVXLCDM]{3,}" # Chiffres romains (3+ caractères) +) + + +@dataclass +class DifficultyScore: + """Score de difficulté intrinsèque d'un document.""" + doc_id: str + score: float + """Score global [0, 1] — plus élevé = plus difficile.""" + variance_component: float + """Composante variance inter-moteurs [0, 1].""" + quality_component: float + """Composante qualité image inversée [0, 1].""" + density_component: float + """Composante densité caractères spéciaux [0, 1].""" + cer_variance: float + """Variance brute du CER entre moteurs.""" + image_quality_score: float + """Score de qualité image (si disponible, sinon 0.5).""" + special_char_ratio: float + """Ratio caractères spéciaux / longueur GT.""" + + def as_dict(self) -> dict: + return { + "doc_id": self.doc_id, + "score": round(self.score, 4), + "variance_component": round(self.variance_component, 4), + "quality_component": round(self.quality_component, 4), + "density_component": round(self.density_component, 4), + "cer_variance": round(self.cer_variance, 6), + "image_quality_score": round(self.image_quality_score, 4), + "special_char_ratio": round(self.special_char_ratio, 4), + } + + +def _special_char_density(text: str) -> float: + """Ratio de caractères spéciaux patrimoniaux dans le texte.""" + if not text: + return 0.0 + matches = len(_SPECIAL_CHARS_RE.findall(text)) + return min(1.0, matches / len(text)) + + +def _variance(values: list[float]) -> float: + """Variance d'une liste de valeurs.""" + if len(values) < 2: + return 0.0 + mu = sum(values) / len(values) + return sum((v - mu) ** 2 for v in values) / len(values) + + +def compute_difficulty_score( + doc_id: str, + ground_truth: str, + cer_per_engine: list[float], + image_quality_score: Optional[float] = None, + weights: tuple[float, float, float] = (_W_VARIANCE, _W_QUALITY, _W_DENSITY), +) -> DifficultyScore: + """Calcule le score de difficulté intrinsèque pour un document. + + Parameters + ---------- + doc_id : identifiant du document + ground_truth : texte de référence + cer_per_engine : liste des CER (un par moteur concurrent) + image_quality_score: score de qualité image [0, 1] (None → 0.5 neutre) + weights : (w_variance, w_quality, w_density) + + Returns + ------- + DifficultyScore + """ + w_var, w_qual, w_den = weights + + # 1. Variance inter-moteurs (normalisée sur [0, 1] — variance max ≈ 0.25) + cer_var = _variance(cer_per_engine) + variance_norm = min(1.0, cer_var / 0.25) + + # 2. Qualité image inversée + iq = image_quality_score if image_quality_score is not None else 0.5 + iq = max(0.0, min(1.0, iq)) + quality_component = 1.0 - iq + + # 3. Densité de caractères spéciaux + density = _special_char_density(ground_truth) + # Amplifier légèrement (la densité brute est souvent faible) + density_component = min(1.0, density * 3.0) + + # Score combiné + score = ( + w_var * variance_norm + + w_qual * quality_component + + w_den * density_component + ) + score = max(0.0, min(1.0, score)) + + return DifficultyScore( + doc_id=doc_id, + score=score, + variance_component=variance_norm, + quality_component=quality_component, + density_component=density_component, + cer_variance=cer_var, + image_quality_score=iq, + special_char_ratio=density, + ) + + +def compute_all_difficulties( + doc_ids: list[str], + ground_truths: dict[str, str], + cer_map: dict[str, dict[str, float]], + image_quality_map: Optional[dict[str, float]] = None, +) -> dict[str, DifficultyScore]: + """Calcule les scores de difficulté pour tous les documents d'un corpus. + + Parameters + ---------- + doc_ids : liste des identifiants de documents + ground_truths : {doc_id → gt_text} + cer_map : {doc_id → {engine_name → cer}} + image_quality_map : {doc_id → quality_score} (facultatif) + + Returns + ------- + {doc_id → DifficultyScore} + """ + result = {} + for doc_id in doc_ids: + gt = ground_truths.get(doc_id, "") + engine_cers = list(cer_map.get(doc_id, {}).values()) + iq = (image_quality_map or {}).get(doc_id) + result[doc_id] = compute_difficulty_score( + doc_id=doc_id, + ground_truth=gt, + cer_per_engine=engine_cers, + image_quality_score=iq, + ) + return result + + +def difficulty_label(score: float) -> str: + """Retourne un label lisible pour un score de difficulté.""" + if score < 0.25: + return "Facile" + if score < 0.50: + return "Modéré" + if score < 0.75: + return "Difficile" + return "Très difficile" + + +def difficulty_color(score: float) -> str: + """Retourne une couleur CSS pour un score de difficulté.""" + from picarones.core.colors import COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED + if score < 0.25: + return COLOR_GREEN + if score < 0.50: + return COLOR_YELLOW + if score < 0.75: + return COLOR_ORANGE + return COLOR_RED diff --git a/picarones/measurements/equivalence_profile.py b/picarones/measurements/equivalence_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..e3c9adf1cb24c3e73567733ac16c883ba8396e81 --- /dev/null +++ b/picarones/measurements/equivalence_profile.py @@ -0,0 +1,199 @@ +"""Équivalences diplomatiques granulaires — Sprint 78 (A.I.5). + +Sprint 78 — A.I.5 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Aujourd'hui les profils de ``picarones/core/normalization.py`` +(``medieval_french``, ``early_modern_french``, etc.) appliquent un +**bloc entier** de transformations. Mais un éditeur peut vouloir +nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par +exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont +distinctes mais où le s long doit être normalisé. + +Ce module **éclate** chaque profil en règles d'équivalence +**nommées et indépendantes** que l'utilisateur peut activer ou +désactiver une par une. La couche de calcul retourne le CER +recalculé avec un sous-ensemble personnalisé. + +Format +------ +Chaque règle a : + +- ``name`` : identifiant stable utilisé dans les URLs et l'UX + (ex. ``"longs_s"``, ``"u_eq_v"``) +- ``source`` : caractère ou séquence à remplacer +- ``target`` : caractère ou séquence cible +- ``description`` : phrase courte FR destinée à l'utilisateur +- ``profile_tag`` : nom du profil dont elle est issue (utile pour + grouper dans l'UX) + +Stratégie de découpage +---------------------- +Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau +avancé (cases à cocher + recalcul JS client + URL state) suivra +dans un sprint dédié — la couche calcul livrée ici est une +fondation suffisante pour qu'un développeur frontend câble la vue. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable, Optional + +from picarones.core.normalization import ( + DIPLOMATIC_EN_EARLY_MODERN, + DIPLOMATIC_FR_EARLY_MODERN, + DIPLOMATIC_LATIN_MEDIEVAL, + DIPLOMATIC_MINIMAL, +) + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EquivalenceRule: + """Une équivalence diplomatique nommée et indépendante.""" + name: str + source: str + target: str + description: str + profile_tag: str + + +# Catalogue : on dérive des profils existants en attribuant un nom +# stable à chaque transformation. Les doublons (ex. ``ſ → s`` +# présent dans plusieurs profils) sont fusionnés sous un nom unique +# (le premier rencontré). +def _build_catalog() -> dict[str, EquivalenceRule]: + catalog: dict[str, EquivalenceRule] = {} + + # Noms canoniques pour les transformations courantes + canonical_names: dict[tuple[str, str], tuple[str, str]] = { + ("ſ", "s"): ("longs_s", "s long ſ → s"), + ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"), + ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"), + ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"), + ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"), + ("æ", "ae"): ("ae_ligature", "æ → ae"), + ("œ", "oe"): ("oe_ligature", "œ → oe"), + ("þ", "th"): ("thorn_th", "þ (thorn) → th"), + ("ð", "th"): ("eth_th", "ð (eth) → th"), + ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"), + ("&", "et"): ("ampersand_et", "& → et (esperluette)"), + ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"), + ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"), + ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"), + ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"), + } + + sources = [ + ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL), + ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN), + ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN), + ("minimal", DIPLOMATIC_MINIMAL), + ] + + for profile_tag, profile_dict in sources: + for source, target in profile_dict.items(): + key = (source, target) + if key in canonical_names: + name, desc = canonical_names[key] + else: + # Fallback : générer un nom à partir des codepoints + name = f"{source}_to_{target}".replace(" ", "_") + desc = f"{source} → {target}" + if name in catalog: + # On garde le profile_tag du premier rencontré, mais + # on note que la règle est partagée. + continue + catalog[name] = EquivalenceRule( + name=name, + source=source, + target=target, + description=desc, + profile_tag=profile_tag, + ) + return catalog + + +BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog() + + +def list_equivalences_by_profile( + profile_name: Optional[str] = None, +) -> list[EquivalenceRule]: + """Liste les règles d'équivalence disponibles. + + Si ``profile_name`` est fourni, ne retourne que les règles dont + ``profile_tag == profile_name`` (ou les règles dérivées de + plusieurs profils dont au moins un est ``profile_name``). + """ + if profile_name is None: + return list(BUILTIN_EQUIVALENCES.values()) + return [ + rule for rule in BUILTIN_EQUIVALENCES.values() + if rule.profile_tag == profile_name + ] + + +def apply_selected_equivalences( + text: Optional[str], + selected_names: Iterable[str], +) -> str: + """Applique uniquement les règles dont le nom est dans + ``selected_names``. + + L'ordre d'application est l'ordre du catalogue interne — les + transformations sont appliquées séquentiellement sur le texte. + Les règles inconnues sont silencieusement ignorées (avec + warning). + """ + if not text: + return text or "" + selected_set = set(selected_names) + if not selected_set: + return text + out = text + for name, rule in BUILTIN_EQUIVALENCES.items(): + if name not in selected_set: + continue + out = out.replace(rule.source, rule.target) + # Détection des règles inconnues (pour logger explicite) + unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys()) + if unknown: + logger.warning( + "[equivalence_profile] règles inconnues ignorées : %s", + sorted(unknown), + ) + return out + + +def compute_cer_with_equivalences( + reference: Optional[str], + hypothesis: Optional[str], + selected_names: Iterable[str], +) -> float: + """Calcule le CER après application des équivalences sélectionnées + sur les **deux** côtés (GT et hypothèse). + + Utilise ``picarones.core.metrics.compute_metrics`` et extrait + le champ ``cer`` du résultat. + """ + from picarones.core.metrics import compute_metrics + + selected_list = list(selected_names) + ref = apply_selected_equivalences(reference or "", selected_list) + hyp = apply_selected_equivalences(hypothesis or "", selected_list) + result = compute_metrics(ref, hyp) + return result.cer + + +__all__ = [ + "EquivalenceRule", + "BUILTIN_EQUIVALENCES", + "list_equivalences_by_profile", + "apply_selected_equivalences", + "compute_cer_with_equivalences", +] diff --git a/picarones/measurements/error_absorption.py b/picarones/measurements/error_absorption.py new file mode 100644 index 0000000000000000000000000000000000000000..ce1021d64b625397fd5c3dca1d15475d6d83477b --- /dev/null +++ b/picarones/measurements/error_absorption.py @@ -0,0 +1,276 @@ +"""Métrique d'absorption d'erreur — Sprint 94 (B.3). + +Sprint 94 — B.3 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Quand un module de post-correction LLM aplatit les différences +entre OCR amont, ce n'est pas qu'il « améliore » tous les +moteurs — c'est qu'il introduit ses propres biais qui dominent +ceux de l'OCR. Mesurer la dégradation par étape ne suffit +pas : il faut **séparer** les deux flux. + +À chaque jonction où un module transforme un artefact, on +mesure : + +- **Taux de correction** : parmi les erreurs présentes en + entrée du module, combien sont corrigées en sortie ? +- **Taux d'introduction** : parmi les erreurs présentes en + sortie, combien sont **nouvelles** (absentes en entrée) ? + +C'est la généralisation du score de sur-normalisation +(chantier A.I.7) à toute jonction. La formule s'applique +uniformément à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper — +toute jonction qui transforme un artefact en un autre du même +type. + +Méthode (token-level) +--------------------- +On split en tokens whitespace ``reference``, ``before``, +``after``. On compare en **multiset** (un token GT consommé +au plus une fois) : + +- ``errors_before`` = tokens GT non retrouvés dans ``before`` +- ``errors_after`` = tokens GT non retrouvés dans ``after`` +- ``corrected`` = ``errors_before \\ errors_after`` + (présents avant, absents après → corrigés) +- ``introduced`` = ``errors_after \\ errors_before`` + (absents avant, présents après → introduits) + +Garde-fou : le module ne classe pas les erreurs (visuelles, +abréviations, etc.) — c'est une métrique d'**absorption de +volume**, pas de qualité éditoriale. L'intersection sémantique +avec ``taxonomy`` (Sprint 5) est documentée dans le glossaire. + +Sortie +------ +``compute_error_absorption(reference, before, after)`` retourne : + +.. code-block:: text + + { + "n_gt_tokens": int, + "n_errors_before": int, + "n_errors_after": int, + "n_corrected": int, + "n_introduced": int, + "n_kept_wrong": int, + "correction_rate": float | None, # n_corrected / n_errors_before + "introduction_rate": float | None, # n_introduced / n_errors_after + "net_improvement": int, # n_corrected - n_introduced + "corrected_tokens": list[str], + "introduced_tokens": list[str], + } + +``aggregate_error_absorption(per_doc_results)`` somme les +compteurs corpus-wide et recalcule les taux *micro*. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +def _split_words(text: Optional[str]) -> list[str]: + if not text: + return [] + return text.split() + + +def _missing_tokens( + reference: list[str], hypothesis: list[str], +) -> Counter: + """Tokens GT manquants en hypothèse au sens multiset. + + Un token GT compte plusieurs fois s'il apparaît plusieurs + fois ; chaque occurrence en hypothèse en absorbe au plus + une. Retourne un Counter ``{token: nb_occurrences_manquees}``. + """ + ref_count = Counter(reference) + hyp_count = Counter(hypothesis) + missing: Counter = Counter() + for token, n_ref in ref_count.items(): + n_hyp = hyp_count.get(token, 0) + if n_hyp < n_ref: + missing[token] = n_ref - n_hyp + return missing + + +def compute_error_absorption( + reference: Optional[str], + before: Optional[str], + after: Optional[str], + *, + case_sensitive: bool = False, +) -> Optional[dict]: + """Mesure l'absorption d'erreur entre ``before`` et ``after``. + + Parameters + ---------- + reference: + GT (vérité terrain). + before: + Sortie de l'étape précédente (typiquement OCR amont). + after: + Sortie de l'étape courante (typiquement post-correction LLM). + case_sensitive: + Si False (défaut), match case-insensitive — la sortie + ``corrected_tokens``/``introduced_tokens`` reste en casse + GT originale. + + Returns + ------- + dict | None + ``None`` si la GT est vide ou ne contient aucun token. + """ + ref_tokens = _split_words(reference) + if not ref_tokens: + return None + before_tokens = _split_words(before) + after_tokens = _split_words(after) + + if case_sensitive: + ref_match = list(ref_tokens) + before_match = list(before_tokens) + after_match = list(after_tokens) + else: + ref_match = [t.lower() for t in ref_tokens] + before_match = [t.lower() for t in before_tokens] + after_match = [t.lower() for t in after_tokens] + + # Map case-insensitive token → liste de casses GT originales + ref_orig_by_match: dict[str, list[str]] = {} + for orig, m in zip(ref_tokens, ref_match): + ref_orig_by_match.setdefault(m, []).append(orig) + + missing_before = _missing_tokens(ref_match, before_match) + missing_after = _missing_tokens(ref_match, after_match) + + n_errors_before = sum(missing_before.values()) + n_errors_after = sum(missing_after.values()) + + # Calcul corrigé / introduit en multiset + corrected_counter: Counter = Counter() + introduced_counter: Counter = Counter() + kept_wrong_counter: Counter = Counter() + all_tokens = set(missing_before) | set(missing_after) + for tok in all_tokens: + nb = missing_before.get(tok, 0) + na = missing_after.get(tok, 0) + if nb > na: + corrected_counter[tok] = nb - na + kept_wrong_counter[tok] = na + elif na > nb: + introduced_counter[tok] = na - nb + kept_wrong_counter[tok] = nb + else: + kept_wrong_counter[tok] = nb + + n_corrected = sum(corrected_counter.values()) + n_introduced = sum(introduced_counter.values()) + n_kept_wrong = sum(kept_wrong_counter.values()) + + correction_rate = ( + n_corrected / n_errors_before + if n_errors_before > 0 else None + ) + introduction_rate = ( + n_introduced / n_errors_after + if n_errors_after > 0 else None + ) + + def _expand(counter: Counter) -> list[str]: + out: list[str] = [] + for tok, count in counter.items(): + origs = ref_orig_by_match.get(tok, [tok]) + # Ne renvoie que la casse représentative GT + display = origs[0] if origs else tok + out.extend([display] * count) + return out + + return { + "n_gt_tokens": len(ref_tokens), + "n_errors_before": n_errors_before, + "n_errors_after": n_errors_after, + "n_corrected": n_corrected, + "n_introduced": n_introduced, + "n_kept_wrong": n_kept_wrong, + "correction_rate": correction_rate, + "introduction_rate": introduction_rate, + "net_improvement": n_corrected - n_introduced, + "corrected_tokens": _expand(corrected_counter), + "introduced_tokens": _expand(introduced_counter), + } + + +def aggregate_error_absorption( + per_doc: Iterable[Optional[dict]], + *, + sample_tokens: int = 50, +) -> Optional[dict]: + """Agrège les compteurs corpus-wide et recalcule les taux + *micro*. + + Parameters + ---------- + per_doc: + Itérable de sorties de ``compute_error_absorption`` (ou + ``None`` pour les docs sans GT). + sample_tokens: + Nombre maximal de tokens corrigés/introduits gardés dans + l'échantillon (cap pour ne pas exploser le JSON). + + Returns + ------- + dict | None + ``None`` si aucune entry valide. + """ + docs = [d for d in per_doc if d] + if not docs: + return None + n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs) + n_errors_before = sum(int(d.get("n_errors_before") or 0) for d in docs) + n_errors_after = sum(int(d.get("n_errors_after") or 0) for d in docs) + n_corrected = sum(int(d.get("n_corrected") or 0) for d in docs) + n_introduced = sum(int(d.get("n_introduced") or 0) for d in docs) + n_kept_wrong = sum(int(d.get("n_kept_wrong") or 0) for d in docs) + correction_rate = ( + n_corrected / n_errors_before if n_errors_before > 0 else None + ) + introduction_rate = ( + n_introduced / n_errors_after if n_errors_after > 0 else None + ) + corrected_sample: list[str] = [] + introduced_sample: list[str] = [] + for d in docs: + corrected_sample.extend(d.get("corrected_tokens") or []) + introduced_sample.extend(d.get("introduced_tokens") or []) + if ( + len(corrected_sample) >= sample_tokens + and len(introduced_sample) >= sample_tokens + ): + break + return { + "n_docs": len(docs), + "n_gt_tokens": n_gt, + "n_errors_before": n_errors_before, + "n_errors_after": n_errors_after, + "n_corrected": n_corrected, + "n_introduced": n_introduced, + "n_kept_wrong": n_kept_wrong, + "correction_rate": correction_rate, + "introduction_rate": introduction_rate, + "net_improvement": n_corrected - n_introduced, + "corrected_tokens_sample": corrected_sample[:sample_tokens], + "introduced_tokens_sample": introduced_sample[:sample_tokens], + } + + +__all__ = [ + "compute_error_absorption", + "aggregate_error_absorption", +] diff --git a/picarones/measurements/hallucination.py b/picarones/measurements/hallucination.py new file mode 100644 index 0000000000000000000000000000000000000000..07eda573ca8d1b4e659600482d3af3e87f245c21 --- /dev/null +++ b/picarones/measurements/hallucination.py @@ -0,0 +1,331 @@ +"""Détection des hallucinations VLM/LLM — Sprint 10. + +Métriques calculées +------------------- +- Taux d'insertion net : mots/caractères ajoutés absents du GT, distinct du WIL existant +- Ratio de longueur : len(hyp) / len(gt) — ratio > 1.2 → hallucination potentielle +- Score d'ancrage : proportion des n-grammes (trigrammes) de la sortie présents dans le GT +- Blocs hallucinés : segments continus de la sortie sans correspondance GT au-delà d'un seuil +- Badge hallucination : True si ancrage faible ou ratio de longueur anormal +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Helpers texte +# --------------------------------------------------------------------------- + +def _tokenize(text: str) -> list[str]: + """Découpe en mots (minuscules, sans ponctuation).""" + return re.findall(r"[^\s]+", text.lower()) + + +def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]: + """Génère les n-grammes d'une liste de tokens.""" + if len(tokens) < n: + return [tuple(tokens)] if tokens else [] + return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)] + + +# --------------------------------------------------------------------------- +# Blocs hallucinés (segments continus sans ancrage) +# --------------------------------------------------------------------------- + +@dataclass +class HallucinatedBlock: + """Segment continu de la sortie sans correspondance dans le GT.""" + start_token: int + end_token: int + text: str + length: int # nombre de tokens + + def as_dict(self) -> dict: + return { + "start_token": self.start_token, + "end_token": self.end_token, + "text": self.text, + "length": self.length, + } + + +def _detect_hallucinated_blocks( + hyp_tokens: list[str], + gt_token_set: set[str], + tolerance: int = 3, + min_block_length: int = 4, +) -> list[HallucinatedBlock]: + """Détecte les blocs de tokens hypothèse sans correspondance dans le GT. + + Un bloc est un segment contigu de tokens hypothèse dont aucun n'est présent + dans le vocabulaire GT. Une tolérance de ``tolerance`` tokens connus interrompus + est acceptée avant de clore un bloc. + + Parameters + ---------- + hyp_tokens: + Tokens de la sortie OCR/VLM. + gt_token_set: + Ensemble des tokens du GT (pour recherche O(1)). + tolerance: + Nombre de tokens connus consécutifs interrompant un bloc avant de le clore. + min_block_length: + Longueur minimale (tokens) pour qu'un bloc soit signalé. + + Returns + ------- + list[HallucinatedBlock] + """ + blocks: list[HallucinatedBlock] = [] + if not hyp_tokens: + return blocks + + in_block = False + block_start = 0 + consecutive_known = 0 + + for i, tok in enumerate(hyp_tokens): + is_unknown = tok not in gt_token_set + if is_unknown: + if not in_block: + in_block = True + block_start = i + consecutive_known = 0 + else: + consecutive_known = 0 + else: + if in_block: + consecutive_known += 1 + if consecutive_known >= tolerance: + # Clore le bloc + end = i - consecutive_known + length = end - block_start + 1 + if length >= min_block_length: + text = " ".join(hyp_tokens[block_start:end + 1]) + blocks.append(HallucinatedBlock( + start_token=block_start, + end_token=end, + text=text, + length=length, + )) + in_block = False + consecutive_known = 0 + + # Bloc non terminé + if in_block: + end = len(hyp_tokens) - 1 + length = end - block_start + 1 + if length >= min_block_length: + text = " ".join(hyp_tokens[block_start:end + 1]) + blocks.append(HallucinatedBlock( + start_token=block_start, + end_token=end, + text=text, + length=length, + )) + + return blocks + + +# --------------------------------------------------------------------------- +# Résultat structuré +# --------------------------------------------------------------------------- + +@dataclass +class HallucinationMetrics: + """Métriques de détection des hallucinations pour une paire (GT, hypothèse).""" + + net_insertion_rate: float + """Taux d'insertion nette : tokens hypothèse absents du GT / total tokens hypothèse.""" + + length_ratio: float + """Ratio de longueur : len(hyp) / len(gt) en caractères. > 1.2 = signal d'hallucination.""" + + anchor_score: float + """Score d'ancrage : proportion des trigrammes hypothèse présents dans les trigrammes GT. + Score élevé → l'hypothèse s'ancre bien dans le GT. Score faible → hallucinations probables.""" + + hallucinated_blocks: list[HallucinatedBlock] + """Segments continus de la sortie sans correspondance GT (au-dessus du seuil de tolérance).""" + + is_hallucinating: bool + """True si anchor_score < anchor_threshold OU length_ratio > length_ratio_threshold.""" + + # Détails supplémentaires + gt_word_count: int = 0 + hyp_word_count: int = 0 + net_inserted_words: int = 0 + anchor_threshold_used: float = 0.5 + length_ratio_threshold_used: float = 1.2 + ngram_size_used: int = 3 + + def as_dict(self) -> dict: + return { + "net_insertion_rate": round(self.net_insertion_rate, 6), + "length_ratio": round(self.length_ratio, 6), + "anchor_score": round(self.anchor_score, 6), + "hallucinated_blocks": [b.as_dict() for b in self.hallucinated_blocks], + "is_hallucinating": self.is_hallucinating, + "gt_word_count": self.gt_word_count, + "hyp_word_count": self.hyp_word_count, + "net_inserted_words": self.net_inserted_words, + "anchor_threshold_used": self.anchor_threshold_used, + "length_ratio_threshold_used": self.length_ratio_threshold_used, + "ngram_size_used": self.ngram_size_used, + } + + @classmethod + def from_dict(cls, d: dict) -> "HallucinationMetrics": + blocks = [ + HallucinatedBlock(**b) for b in d.get("hallucinated_blocks", []) + ] + return cls( + net_insertion_rate=d.get("net_insertion_rate", 0.0), + length_ratio=d.get("length_ratio", 1.0), + anchor_score=d.get("anchor_score", 1.0), + hallucinated_blocks=blocks, + is_hallucinating=d.get("is_hallucinating", False), + gt_word_count=d.get("gt_word_count", 0), + hyp_word_count=d.get("hyp_word_count", 0), + net_inserted_words=d.get("net_inserted_words", 0), + anchor_threshold_used=d.get("anchor_threshold_used", 0.5), + length_ratio_threshold_used=d.get("length_ratio_threshold_used", 1.2), + ngram_size_used=d.get("ngram_size_used", 3), + ) + + +# --------------------------------------------------------------------------- +# Calcul principal +# --------------------------------------------------------------------------- + +def compute_hallucination_metrics( + reference: str, + hypothesis: str, + n: int = 3, + length_ratio_threshold: float = 1.2, + anchor_threshold: float = 0.5, + block_tolerance: int = 3, + min_block_length: int = 4, +) -> HallucinationMetrics: + """Calcule les métriques de détection des hallucinations VLM/LLM. + + Parameters + ---------- + reference: + Texte de vérité terrain (GT). + hypothesis: + Texte produit par le modèle. + n: + Taille des n-grammes pour le score d'ancrage (défaut : trigrammes). + length_ratio_threshold: + Seuil de ratio de longueur au-dessus duquel on signale une hallucination potentielle. + anchor_threshold: + Seuil de score d'ancrage en dessous duquel on signale une hallucination potentielle. + block_tolerance: + Nombre de tokens connus consécutifs acceptés dans un bloc halluciné. + min_block_length: + Longueur minimale (tokens) pour signaler un bloc halluciné. + + Returns + ------- + HallucinationMetrics + """ + gt_tokens = _tokenize(reference) + hyp_tokens = _tokenize(hypothesis) + + gt_len_chars = len(reference.strip()) + hyp_len_chars = len(hypothesis.strip()) + + # ── Ratio de longueur ──────────────────────────────────────────────── + if gt_len_chars == 0: + length_ratio = 1.0 if hyp_len_chars == 0 else float("inf") + else: + length_ratio = hyp_len_chars / gt_len_chars + + # ── Taux d'insertion nette ─────────────────────────────────────────── + gt_token_set = set(gt_tokens) + hyp_token_count = len(hyp_tokens) + + if hyp_token_count == 0: + net_insertion_rate = 0.0 + net_inserted_words = 0 + else: + net_inserted = [t for t in hyp_tokens if t not in gt_token_set] + net_inserted_words = len(net_inserted) + net_insertion_rate = net_inserted_words / hyp_token_count + + # ── Score d'ancrage (n-grammes) ────────────────────────────────────── + gt_ngrams = set(_ngrams(gt_tokens, n)) + hyp_ngrams = _ngrams(hyp_tokens, n) + + if not hyp_ngrams: + # Pas de n-grammes dans l'hypothèse → ancrage parfait (hypothèse vide ou trop courte) + anchor_score = 1.0 if not gt_ngrams else 0.0 + elif not gt_ngrams: + anchor_score = 0.0 + else: + anchored = sum(1 for ng in hyp_ngrams if ng in gt_ngrams) + anchor_score = anchored / len(hyp_ngrams) + + # ── Blocs hallucinés ───────────────────────────────────────────────── + blocks = _detect_hallucinated_blocks( + hyp_tokens=hyp_tokens, + gt_token_set=gt_token_set, + tolerance=block_tolerance, + min_block_length=min_block_length, + ) + + # ── Badge hallucination ────────────────────────────────────────────── + is_hallucinating = ( + anchor_score < anchor_threshold + or length_ratio > length_ratio_threshold + ) + + return HallucinationMetrics( + net_insertion_rate=net_insertion_rate, + length_ratio=min(length_ratio, 9.99), # plafonner pour la sérialisation + anchor_score=anchor_score, + hallucinated_blocks=blocks, + is_hallucinating=is_hallucinating, + gt_word_count=len(gt_tokens), + hyp_word_count=hyp_token_count, + net_inserted_words=net_inserted_words, + anchor_threshold_used=anchor_threshold, + length_ratio_threshold_used=length_ratio_threshold, + ngram_size_used=n, + ) + + +# --------------------------------------------------------------------------- +# Agrégation sur un corpus +# --------------------------------------------------------------------------- + +def aggregate_hallucination_metrics(results: list[HallucinationMetrics]) -> dict: + """Agrège les métriques d'hallucination sur un corpus. + + Returns + ------- + dict + Statistiques agrégées : anchor_score moyen, taux de documents hallucinés… + """ + if not results: + return {} + + n = len(results) + anchor_values = [r.anchor_score for r in results] + ratio_values = [r.length_ratio for r in results] + insertion_values = [r.net_insertion_rate for r in results] + hallucinating_count = sum(1 for r in results if r.is_hallucinating) + + return { + "anchor_score_mean": round(sum(anchor_values) / n, 6), + "anchor_score_min": round(min(anchor_values), 6), + "length_ratio_mean": round(sum(ratio_values) / n, 6), + "net_insertion_rate_mean": round(sum(insertion_values) / n, 6), + "hallucinating_doc_count": hallucinating_count, + "hallucinating_doc_rate": round(hallucinating_count / n, 6), + "document_count": n, + } diff --git a/picarones/measurements/history.py b/picarones/measurements/history.py new file mode 100644 index 0000000000000000000000000000000000000000..9a360851ae767a99635a18eadae3fa0dcd9fb411 --- /dev/null +++ b/picarones/measurements/history.py @@ -0,0 +1,615 @@ +"""Suivi longitudinal des benchmarks — base SQLite optionnelle. + +Fonctionnement +-------------- +- Chaque run de benchmark est enregistré dans une table SQLite avec horodatage, + corpus, moteurs, métriques agrégées. +- L'historique permet de tracer des courbes d'évolution du CER dans le temps. +- La détection de régression compare le dernier run à une baseline configurable. + +Structure de la base +-------------------- +Table ``runs`` : + run_id TEXT PRIMARY KEY — UUID ou hash du run + timestamp TEXT — ISO 8601 + corpus_name TEXT + engine_name TEXT + cer_mean REAL + wer_mean REAL + doc_count INTEGER + metadata TEXT — JSON + +Usage +----- +>>> from picarones.core.history import BenchmarkHistory +>>> history = BenchmarkHistory("~/.picarones/history.db") +>>> history.record(benchmark_result) +>>> df = history.query(engine="tesseract", corpus="chroniques") +>>> regression = history.detect_regression(engine="tesseract", threshold=0.02) +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from picarones.core.results import BenchmarkResult + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Structures de données +# --------------------------------------------------------------------------- + +@dataclass +class HistoryEntry: + """Un enregistrement dans l'historique des benchmarks.""" + run_id: str + timestamp: str + corpus_name: str + engine_name: str + cer_mean: Optional[float] + wer_mean: Optional[float] + doc_count: int + metadata: dict = field(default_factory=dict) + + @property + def cer_percent(self) -> Optional[float]: + return self.cer_mean * 100 if self.cer_mean is not None else None + + def as_dict(self) -> dict: + return { + "run_id": self.run_id, + "timestamp": self.timestamp, + "corpus_name": self.corpus_name, + "engine_name": self.engine_name, + "cer_mean": self.cer_mean, + "wer_mean": self.wer_mean, + "doc_count": self.doc_count, + "metadata": self.metadata, + } + + +@dataclass +class RegressionResult: + """Résultat d'une détection de régression.""" + engine_name: str + corpus_name: str + baseline_run_id: str + baseline_timestamp: str + baseline_cer: Optional[float] + current_run_id: str + current_timestamp: str + current_cer: Optional[float] + delta_cer: Optional[float] + """Delta CER (current - baseline). Positif = régression.""" + is_regression: bool + threshold: float + + def as_dict(self) -> dict: + return { + "engine_name": self.engine_name, + "corpus_name": self.corpus_name, + "baseline_run_id": self.baseline_run_id, + "baseline_timestamp": self.baseline_timestamp, + "baseline_cer": self.baseline_cer, + "current_run_id": self.current_run_id, + "current_timestamp": self.current_timestamp, + "current_cer": self.current_cer, + "delta_cer": self.delta_cer, + "is_regression": self.is_regression, + "threshold": self.threshold, + } + + +# --------------------------------------------------------------------------- +# BenchmarkHistory +# --------------------------------------------------------------------------- + +class BenchmarkHistory: + """Gestionnaire de l'historique des benchmarks dans SQLite. + + Parameters + ---------- + db_path: + Chemin vers le fichier SQLite. Utiliser ``":memory:"`` pour les tests. + + Examples + -------- + >>> history = BenchmarkHistory("~/.picarones/history.db") + >>> history.record(benchmark) + >>> entries = history.query(engine="tesseract") + >>> for e in entries: + ... print(e.timestamp, f"CER={e.cer_percent:.2f}%") + """ + + _CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + corpus_name TEXT NOT NULL, + engine_name TEXT NOT NULL, + cer_mean REAL, + wer_mean REAL, + doc_count INTEGER, + metadata TEXT + ); + CREATE INDEX IF NOT EXISTS idx_engine ON runs (engine_name); + CREATE INDEX IF NOT EXISTS idx_corpus ON runs (corpus_name); + CREATE INDEX IF NOT EXISTS idx_timestamp ON runs (timestamp); + """ + + def __init__(self, db_path: str = "~/.picarones/history.db") -> None: + if db_path != ":memory:": + path = Path(db_path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + self.db_path = str(path) + else: + self.db_path = ":memory:" + self._conn: Optional[sqlite3.Connection] = None + self._init_db() + + def _connect(self) -> sqlite3.Connection: + if self._conn is None: + self._conn = sqlite3.connect(self.db_path) + self._conn.row_factory = sqlite3.Row + return self._conn + + def _init_db(self) -> None: + conn = self._connect() + conn.executescript(self._CREATE_TABLE) + conn.commit() + + def close(self) -> None: + """Ferme la connexion SQLite.""" + if self._conn: + self._conn.close() + self._conn = None + + # ------------------------------------------------------------------ + # Enregistrement + # ------------------------------------------------------------------ + + def record( + self, + benchmark_result: "BenchmarkResult", + run_id: Optional[str] = None, + extra_metadata: Optional[dict] = None, + ) -> str: + """Enregistre les résultats d'un benchmark dans l'historique. + + Parameters + ---------- + benchmark_result: + Résultats à enregistrer (``BenchmarkResult``). + run_id: + Identifiant du run (auto-généré si None). + extra_metadata: + Métadonnées supplémentaires à stocker. + + Returns + ------- + str + L'identifiant du run enregistré. + """ + if run_id is None: + run_id = str(uuid.uuid4()) + + timestamp = datetime.now(timezone.utc).isoformat() + conn = self._connect() + + for report in benchmark_result.engine_reports: + ranking = benchmark_result.ranking() + engine_entry = next( + (r for r in ranking if r["engine"] == report.engine_name), + None, + ) + cer_mean = engine_entry["mean_cer"] if engine_entry else None + wer_mean = engine_entry["mean_wer"] if engine_entry else None + + meta = { + "engine_version": report.engine_version, + "engine_config": report.engine_config, + "picarones_version": benchmark_result.metadata.get("picarones_version", ""), + **(extra_metadata or {}), + } + + conn.execute( + """ + INSERT OR REPLACE INTO runs + (run_id, timestamp, corpus_name, engine_name, + cer_mean, wer_mean, doc_count, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + f"{run_id}_{report.engine_name}", + timestamp, + benchmark_result.corpus_name, + report.engine_name, + cer_mean, + wer_mean, + benchmark_result.document_count, + json.dumps(meta, ensure_ascii=False), + ), + ) + + conn.commit() + logger.info("Benchmark enregistré dans l'historique : run_id=%s", run_id) + return run_id + + def record_single( + self, + run_id: str, + corpus_name: str, + engine_name: str, + cer_mean: Optional[float], + wer_mean: Optional[float], + doc_count: int, + timestamp: Optional[str] = None, + metadata: Optional[dict] = None, + ) -> str: + """Enregistre manuellement une entrée dans l'historique. + + Utile pour les tests, les imports de données externes, ou pour + enregistrer des résultats calculés en dehors de Picarones. + + Returns + ------- + str + run_id enregistré. + """ + if timestamp is None: + timestamp = datetime.now(timezone.utc).isoformat() + + conn = self._connect() + conn.execute( + """ + INSERT OR REPLACE INTO runs + (run_id, timestamp, corpus_name, engine_name, + cer_mean, wer_mean, doc_count, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run_id, + timestamp, + corpus_name, + engine_name, + cer_mean, + wer_mean, + doc_count, + json.dumps(metadata or {}, ensure_ascii=False), + ), + ) + conn.commit() + return run_id + + # ------------------------------------------------------------------ + # Requêtes + # ------------------------------------------------------------------ + + def query( + self, + engine: Optional[str] = None, + corpus: Optional[str] = None, + since: Optional[str] = None, + limit: int = 100, + ) -> list[HistoryEntry]: + """Retourne l'historique des runs, avec filtres optionnels. + + Parameters + ---------- + engine: + Filtre sur le nom du moteur. + corpus: + Filtre sur le nom du corpus. + since: + Date ISO 8601 minimale (``"2025-01-01"``). + limit: + Nombre maximum d'entrées retournées. + + Returns + ------- + list[HistoryEntry] + Entrées triées par timestamp croissant. + """ + clauses: list[str] = [] + params: list = [] + + if engine: + clauses.append("engine_name = ?") + params.append(engine) + if corpus: + clauses.append("corpus_name = ?") + params.append(corpus) + if since: + clauses.append("timestamp >= ?") + params.append(since) + + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.append(limit) + + conn = self._connect() + rows = conn.execute( + f"SELECT * FROM runs {where} ORDER BY timestamp ASC LIMIT ?", + params, + ).fetchall() + + return [ + HistoryEntry( + run_id=row["run_id"], + timestamp=row["timestamp"], + corpus_name=row["corpus_name"], + engine_name=row["engine_name"], + cer_mean=row["cer_mean"], + wer_mean=row["wer_mean"], + doc_count=row["doc_count"], + metadata=json.loads(row["metadata"] or "{}"), + ) + for row in rows + ] + + def list_engines(self) -> list[str]: + """Retourne la liste des moteurs présents dans l'historique.""" + conn = self._connect() + rows = conn.execute( + "SELECT DISTINCT engine_name FROM runs ORDER BY engine_name" + ).fetchall() + return [row[0] for row in rows] + + def list_corpora(self) -> list[str]: + """Retourne la liste des corpus présents dans l'historique.""" + conn = self._connect() + rows = conn.execute( + "SELECT DISTINCT corpus_name FROM runs ORDER BY corpus_name" + ).fetchall() + return [row[0] for row in rows] + + def count(self) -> int: + """Nombre total d'entrées dans l'historique.""" + conn = self._connect() + return conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0] + + # ------------------------------------------------------------------ + # Courbes d'évolution + # ------------------------------------------------------------------ + + def get_cer_curve( + self, + engine: str, + corpus: Optional[str] = None, + ) -> list[dict]: + """Retourne les données pour tracer la courbe d'évolution du CER. + + Parameters + ---------- + engine: + Nom du moteur. + corpus: + Corpus spécifique (None = tous les corpus pour ce moteur). + + Returns + ------- + list[dict] + Chaque dict contient ``{"timestamp": str, "cer": float, "run_id": str}``. + """ + entries = self.query(engine=engine, corpus=corpus, limit=1000) + return [ + { + "timestamp": e.timestamp, + "cer": e.cer_mean, + "cer_percent": e.cer_percent, + "run_id": e.run_id, + "corpus_name": e.corpus_name, + } + for e in entries + if e.cer_mean is not None + ] + + # ------------------------------------------------------------------ + # Détection de régression + # ------------------------------------------------------------------ + + def detect_regression( + self, + engine: str, + corpus: Optional[str] = None, + threshold: float = 0.01, + baseline_run_id: Optional[str] = None, + ) -> Optional[RegressionResult]: + """Détecte une régression du CER entre deux runs. + + Compare le run le plus récent à une baseline (le run précédent ou + un run spécifique). + + Parameters + ---------- + engine: + Nom du moteur à surveiller. + corpus: + Corpus spécifique (None = tous). + threshold: + Seuil de régression en points absolus de CER (ex : 0.01 = 1%). + Si delta_cer > threshold → régression détectée. + baseline_run_id: + run_id de référence. Si None, utilise l'avant-dernier run. + + Returns + ------- + RegressionResult | None + None si moins de 2 runs disponibles. + """ + entries = self.query(engine=engine, corpus=corpus, limit=1000) + if len(entries) < 2: + logger.info("Pas assez de runs pour détecter une régression (moteur=%s)", engine) + return None + + current = entries[-1] + + if baseline_run_id: + baseline_list = [e for e in entries[:-1] if e.run_id == baseline_run_id] + baseline = baseline_list[0] if baseline_list else entries[-2] + else: + baseline = entries[-2] + + delta = None + is_regression = False + if current.cer_mean is not None and baseline.cer_mean is not None: + delta = current.cer_mean - baseline.cer_mean + is_regression = delta > threshold + + return RegressionResult( + engine_name=engine, + corpus_name=corpus or "tous", + baseline_run_id=baseline.run_id, + baseline_timestamp=baseline.timestamp, + baseline_cer=baseline.cer_mean, + current_run_id=current.run_id, + current_timestamp=current.timestamp, + current_cer=current.cer_mean, + delta_cer=delta, + is_regression=is_regression, + threshold=threshold, + ) + + def detect_all_regressions( + self, + threshold: float = 0.01, + ) -> list[RegressionResult]: + """Détecte les régressions pour tous les moteurs et corpus connus. + + Parameters + ---------- + threshold: + Seuil de régression. + + Returns + ------- + list[RegressionResult] + Uniquement les moteurs où une régression est détectée. + """ + results: list[RegressionResult] = [] + engines = self.list_engines() + corpora = self.list_corpora() + + for engine in engines: + for corpus in corpora: + result = self.detect_regression(engine, corpus, threshold) + if result and result.is_regression: + results.append(result) + + return results + + # ------------------------------------------------------------------ + # Export + # ------------------------------------------------------------------ + + def export_json(self, output_path: str) -> Path: + """Exporte l'historique complet en JSON. + + Parameters + ---------- + output_path: + Chemin du fichier JSON de sortie. + + Returns + ------- + Path + Chemin vers le fichier créé. + """ + entries = self.query(limit=100_000) + path = Path(output_path) + data = { + "picarones_history": True, + "exported_at": datetime.now(timezone.utc).isoformat(), + "total_runs": len(entries), + "engines": self.list_engines(), + "corpora": self.list_corpora(), + "runs": [e.as_dict() for e in entries], + } + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + def __repr__(self) -> str: + return f"BenchmarkHistory(db='{self.db_path}', runs={self.count()})" + + +# --------------------------------------------------------------------------- +# Données de démonstration longitudinale +# --------------------------------------------------------------------------- + +def generate_demo_history( + db: BenchmarkHistory, + n_runs: int = 8, + seed: int = 42, +) -> None: + """Insère des données fictives de suivi longitudinal pour la démo. + + Simule l'amélioration progressive d'un modèle tesseract sur 8 runs, + avec une légère régression au run 5. + + Parameters + ---------- + db: + Base d'historique à remplir. + n_runs: + Nombre de runs à générer. + seed: + Graine aléatoire. + """ + import random + rng = random.Random(seed) + + engines = ["tesseract", "pero_ocr", "ancien_moteur"] + corpus = "Chroniques médiévales" + + # Trajectoires de CER simulées (amélioration progressive + bruit) + base_cers = { + "tesseract": 0.15, + "pero_ocr": 0.09, + "ancien_moteur": 0.28, + } + improvements = { + "tesseract": -0.008, # améliore de ~0.8% par run + "pero_ocr": -0.005, # améliore de ~0.5% par run + "ancien_moteur": -0.003, + } + + from datetime import timedelta + base_date = datetime(2024, 9, 1, tzinfo=timezone.utc) + + for run_idx in range(n_runs): + run_date = base_date + timedelta(weeks=run_idx * 2) + run_id = f"demo_run_{run_idx + 1:02d}" + + for engine in engines: + cer = base_cers[engine] + improvements[engine] * run_idx + # Ajouter du bruit + régression au run 5 + noise = rng.gauss(0, 0.005) + if run_idx == 4 and engine == "tesseract": + noise += 0.02 # régression simulée + cer = max(0.01, min(0.5, cer + noise)) + + wer = cer * 1.8 + rng.gauss(0, 0.01) + wer = max(0.01, min(0.9, wer)) + + db.record_single( + run_id=f"{run_id}_{engine}", + corpus_name=corpus, + engine_name=engine, + cer_mean=round(cer, 4), + wer_mean=round(wer, 4), + doc_count=12, + timestamp=run_date.isoformat(), + metadata={ + "note": f"Run de démonstration #{run_idx + 1}", + "engine_version": f"5.{run_idx}.0" if engine == "tesseract" else "0.7.2", + }, + ) diff --git a/picarones/measurements/image_quality.py b/picarones/measurements/image_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..929bf67f7a4c0a60d2f7029ebdba72a6d665e1fb --- /dev/null +++ b/picarones/measurements/image_quality.py @@ -0,0 +1,391 @@ +"""Analyse automatique de la qualité des images de documents numérisés. + +Métriques +--------- +- **Score de netteté** : variance du laplacien (plus élevé = plus net) +- **Niveau de bruit** : écart-type des résidus haute-fréquence +- **Angle de rotation résiduel** : estimé par projection horizontale +- **Score de contraste** : ratio Michelson entre zones sombres (encre) et claires (fond) +- **Score de qualité global** : combinaison normalisée des métriques ci-dessus + +Ces calculs sont réalisés en pur Python + bibliothèques stdlib ou Pillow. +NumPy est utilisé si disponible (calculs plus rapides), mais les méthodes +de fallback n'en dépendent pas. + +Note +---- +Pour les images placeholder (fixtures), des valeurs fictives cohérentes +sont générées via `generate_mock_quality_scores()`. +""" + +from __future__ import annotations + +import logging +import math +import statistics +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class ImageQualityResult: + """Métriques de qualité d'une image de document.""" + + sharpness_score: float = 0.0 + """Score de netteté [0, 1]. Basé sur la variance du laplacien normalisée.""" + + noise_level: float = 0.0 + """Niveau de bruit [0, 1]. 0 = pas de bruit, 1 = très bruité.""" + + rotation_degrees: float = 0.0 + """Angle de rotation résiduel estimé en degrés (positif = sens horaire).""" + + contrast_score: float = 0.0 + """Score de contraste [0, 1]. Ratio Michelson encre/fond.""" + + quality_score: float = 0.0 + """Score de qualité global [0, 1]. Combinaison pondérée des autres métriques.""" + + analysis_method: str = "none" + """Méthode d'analyse utilisée : 'pillow', 'numpy', 'mock'.""" + + error: Optional[str] = None + """Erreur si l'analyse a échoué.""" + + @property + def is_good_quality(self) -> bool: + """Vrai si le score de qualité global est ≥ 0.7.""" + return self.quality_score >= 0.7 + + @property + def quality_tier(self) -> str: + """Catégorie de qualité : 'good', 'medium', 'poor'.""" + if self.quality_score >= 0.7: + return "good" + elif self.quality_score >= 0.4: + return "medium" + return "poor" + + def as_dict(self) -> dict: + d = { + "sharpness_score": round(self.sharpness_score, 4), + "noise_level": round(self.noise_level, 4), + "rotation_degrees": round(self.rotation_degrees, 2), + "contrast_score": round(self.contrast_score, 4), + "quality_score": round(self.quality_score, 4), + "quality_tier": self.quality_tier, + "analysis_method": self.analysis_method, + } + if self.error: + d["error"] = self.error + return d + + @classmethod + def from_dict(cls, data: dict) -> "ImageQualityResult": + return cls( + sharpness_score=data.get("sharpness_score", 0.0), + noise_level=data.get("noise_level", 0.0), + rotation_degrees=data.get("rotation_degrees", 0.0), + contrast_score=data.get("contrast_score", 0.0), + quality_score=data.get("quality_score", 0.0), + analysis_method=data.get("analysis_method", "none"), + error=data.get("error"), + ) + + +def analyze_image_quality(image_path: str | Path) -> ImageQualityResult: + """Analyse la qualité d'une image de document numérisé. + + Essaie successivement : + 1. Pillow + NumPy (méthode complète) + 2. Pillow seul (méthode simplifiée) + 3. Fallback : retourne un résultat vide avec erreur + + Parameters + ---------- + image_path: + Chemin vers l'image (JPG, PNG, TIFF…). + + Returns + ------- + ImageQualityResult + """ + path = Path(image_path) + if not path.exists(): + return ImageQualityResult( + error=f"Fichier image introuvable : {image_path}", + analysis_method="none", + ) + + # Essai avec Pillow + NumPy + try: + import numpy as np + from PIL import Image + return _analyze_with_numpy(path, np, Image) + except ImportError: + pass + + # Essai avec Pillow seul + try: + from PIL import Image + return _analyze_with_pillow(path, Image) + except ImportError: + pass + + return ImageQualityResult( + error="Pillow non disponible (pip install Pillow)", + analysis_method="none", + quality_score=0.5, # valeur neutre + ) + + +def _analyze_with_numpy(path: Path, np, Image) -> ImageQualityResult: + """Analyse complète avec NumPy.""" + img = Image.open(path).convert("L") # niveaux de gris + arr = np.array(img, dtype=np.float32) + + # 1. Netteté : variance du laplacien + laplacian = _laplacian_variance_numpy(arr, np) + # Normalisation empirique : variance > 500 = très net, < 50 = flou + sharpness = min(1.0, laplacian / 500.0) + + # 2. Bruit : écart-type des résidus (différence image - image lissée) + noise = _noise_level_numpy(arr, np) + + # 3. Rotation : angle d'inclinaison estimé + rotation = _estimate_rotation_numpy(arr, np) + + # 4. Contraste : ratio Michelson + contrast = _contrast_score_numpy(arr, np) + + # 5. Score global pondéré + quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) + + return ImageQualityResult( + sharpness_score=float(sharpness), + noise_level=float(noise), + rotation_degrees=float(rotation), + contrast_score=float(contrast), + quality_score=float(quality), + analysis_method="numpy", + ) + + +def _analyze_with_pillow(path: Path, Image) -> ImageQualityResult: + """Analyse simplifiée avec Pillow seul (sans NumPy).""" + img = Image.open(path).convert("L") + pixels = list(img.tobytes()) # mode "L" = 1 byte/pixel + w, h = img.size + + if not pixels: + return ImageQualityResult(quality_score=0.5, analysis_method="pillow") + + # Contraste : étendue des valeurs + min_val = min(pixels) + max_val = max(pixels) + if max_val + min_val > 0: + contrast = (max_val - min_val) / (max_val + min_val) + else: + contrast = 0.0 + + # Netteté approximée : variance globale des pixels + try: + variance = statistics.variance(pixels) + except statistics.StatisticsError: + variance = 0.0 + sharpness = min(1.0, math.sqrt(variance) / 128.0) + + # Bruit : approximation grossière + noise = min(1.0, statistics.stdev(pixels[:min(1000, len(pixels))]) / 64.0) if len(pixels) > 1 else 0.0 + + quality = _global_quality_score(sharpness, noise, 0.0, contrast) + + return ImageQualityResult( + sharpness_score=sharpness, + noise_level=noise, + rotation_degrees=0.0, # non calculé sans NumPy + contrast_score=contrast, + quality_score=quality, + analysis_method="pillow", + ) + + +def _laplacian_variance_numpy(arr, np) -> float: + """Calcule la variance du laplacien (mesure de netteté).""" + # Convolution laplacien 3x3 via slicing (bordures ignorées) + h, w = arr.shape + if h < 3 or w < 3: + return float(np.var(arr)) + + # Utiliser une convolution rapide avec slicing + center = arr[1:-1, 1:-1] + top = arr[:-2, 1:-1] + bottom = arr[2:, 1:-1] + left = arr[1:-1, :-2] + right = arr[1:-1, 2:] + lap = top + bottom + left + right - 4 * center + + return float(np.var(lap)) + + +def _noise_level_numpy(arr, np) -> float: + """Estime le niveau de bruit par la MAD (Median Absolute Deviation) des gradients.""" + h, w = arr.shape + if h < 2 or w < 2: + return 0.0 + # Différences horizontales et verticales + diff_h = np.abs(arr[:, 1:] - arr[:, :-1]) + diff_v = np.abs(arr[1:, :] - arr[:-1, :]) + noise_std = float(np.median(np.concatenate([diff_h.ravel(), diff_v.ravel()]))) + # Normaliser : 0 = pas de bruit, 1 = très bruité (seuil à ~30) + return min(1.0, noise_std / 30.0) + + +def _estimate_rotation_numpy(arr, np) -> float: + """Estime l'angle de rotation par projection horizontale simplifiée. + + Retourne l'angle estimé en degrés [-45, 45]. + """ + # Méthode simplifiée : analyse de la variance des projections à différents angles + # Limiter à quelques angles pour la performance + h, w = arr.shape + if h < 20 or w < 20: + return 0.0 + + # Sous-échantillonnage pour la performance + step = max(1, h // 100) + sample = arr[::step, :] + + best_angle = 0.0 + best_var = -1.0 + + for angle_deg in range(-5, 6): # ±5 degrés, pas de 1° + angle_rad = math.radians(angle_deg) + # Projection horizontale après rotation approximative + # (approximation linéaire rapide) + offsets = np.round( + np.arange(sample.shape[0]) * math.tan(angle_rad) + ).astype(int) + offsets = np.clip(offsets, 0, w - 1) + + # Variance des sommes de lignes décalées + try: + row_sums = np.array([ + float(np.sum(sample[i, max(0, offsets[i]):min(w, offsets[i]+w)])) + for i in range(sample.shape[0]) + ]) + var = float(np.var(row_sums)) + if var > best_var: + best_var = var + best_angle = float(angle_deg) + except Exception as e: + logger.warning( + "[image_quality] projection à %d° indisponible : %s", + angle_deg, e, + ) + + return best_angle + + +def _contrast_score_numpy(arr, np) -> float: + """Score de contraste Michelson [0, 1].""" + p5 = float(np.percentile(arr, 5)) # fond clair + p95 = float(np.percentile(arr, 95)) # encre sombre + if p5 + p95 == 0: + return 0.0 + # Michelson : (Imax - Imin) / (Imax + Imin) + return float((p95 - p5) / (p95 + p5)) + + +def _global_quality_score( + sharpness: float, + noise: float, + rotation_abs: float, + contrast: float, +) -> float: + """Calcule le score de qualité global pondéré.""" + # Poids : netteté (40%), contraste (30%), bruit (20%), rotation (10%) + score = ( + 0.40 * sharpness + + 0.30 * contrast + + 0.20 * (1.0 - noise) # moins de bruit = mieux + + 0.10 * max(0.0, 1.0 - rotation_abs / 10.0) # ±10° max + ) + return round(min(1.0, max(0.0, score)), 4) + + +# --------------------------------------------------------------------------- +# Données fictives pour les fixtures de démo +# --------------------------------------------------------------------------- + +def generate_mock_quality_scores( + doc_id: str, + seed: Optional[int] = None, +) -> ImageQualityResult: + """Génère des métriques de qualité fictives mais cohérentes pour un document. + + Utilisé par les fixtures de démo pour simuler une diversité réaliste + de qualités d'image (bonne, moyenne, dégradée). + + Parameters + ---------- + doc_id: + Identifiant du document (utilisé pour la reproductibilité). + seed: + Graine aléatoire optionnelle. + """ + import random + rng = random.Random(seed or hash(doc_id) % 2**32) + + # Générer une qualité cohérente : certains docs sont plus difficiles + base_quality = 0.3 + rng.random() * 0.6 # 0.3 à 0.9 + + sharpness = max(0.1, min(1.0, base_quality + rng.gauss(0, 0.1))) + noise = max(0.0, min(1.0, (1.0 - base_quality) * 0.8 + rng.gauss(0, 0.05))) + rotation = rng.gauss(0, 1.5) # ±1.5° typique + contrast = max(0.2, min(1.0, base_quality + rng.gauss(0, 0.15))) + + quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) + + return ImageQualityResult( + sharpness_score=round(sharpness, 4), + noise_level=round(noise, 4), + rotation_degrees=round(rotation, 2), + contrast_score=round(contrast, 4), + quality_score=round(quality, 4), + analysis_method="mock", + ) + + +def aggregate_image_quality(results: list[ImageQualityResult]) -> dict: + """Agrège les métriques de qualité image sur un corpus.""" + if not results: + return {} + + valid = [r for r in results if r.error is None] + if not valid: + return {"error": "Aucune analyse réussie"} + + def _mean(vals: list[float]) -> float: + return round(statistics.mean(vals), 4) if vals else 0.0 + + quality_scores = [r.quality_score for r in valid] + sharpness_scores = [r.sharpness_score for r in valid] + noise_levels = [r.noise_level for r in valid] + + # Distribution par tier + tiers = {"good": 0, "medium": 0, "poor": 0} + for r in valid: + tiers[r.quality_tier] += 1 + + return { + "mean_quality_score": _mean(quality_scores), + "mean_sharpness": _mean(sharpness_scores), + "mean_noise_level": _mean(noise_levels), + "quality_distribution": tiers, + "document_count": len(valid), + "scores": [r.quality_score for r in valid], # pour scatter plot + } diff --git a/picarones/measurements/incremental_comparison.py b/picarones/measurements/incremental_comparison.py new file mode 100644 index 0000000000000000000000000000000000000000..03f3ea0ee8a3b3eb7ed90af59349f49cb95386e1 --- /dev/null +++ b/picarones/measurements/incremental_comparison.py @@ -0,0 +1,253 @@ +"""Comparaison incrémentale de pipelines composées — Sprint 96 (B.5). + +Sprint 96 — B.5 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Avec 5 OCR × 3 reconstructeurs × 4 post-correcteurs × 3 +mappeurs = 180 pipelines à comparer, le rapport noie +l'information. Il faut un mécanisme de **comparaison +contrôlée** type design d'expérience. + +Méthode +------- +Pour mesurer l'effet isolé d'un slot ``varying`` : + +1. Fixer les valeurs des autres slots (``fixed``). +2. Pour chaque combinaison des fixed, comparer les pipelines + qui ne diffèrent que sur le slot varying. +3. Agréger : pour chaque valeur du slot varying, calculer + sa moyenne, son écart-type, son rang moyen sur les groupes. + +C'est presque un Latin square automatisé. Sans ça, le +rapport sur 180 pipelines est inutilisable. + +Pas de tests statistiques scipy +------------------------------- +On ne reconstruit pas Friedman/Nemenyi (déjà dans Sprint 18) ; +on agrège ici les données nécessaires pour qu'un +tests statistique externe puisse les consommer. Le rapport +existant reste libre de brancher +``picarones.core.statistics.friedman_test`` sur la sortie de +ce module. + +Sortie +------ +``compare_isolated_effect(runs, varying_slot)`` retourne : + +.. code-block:: text + + { + "varying_slot": str, + "n_runs": int, + "n_groups": int, # combinaisons fixed distinctes + "values": list[str], # valeurs distinctes du slot + "per_value": {value: { + "n_observations": int, + "mean": float | None, + "stdev": float | None, + "min": float, "max": float, + "mean_rank": float | None, + }}, + "best_value": str | None, + "worst_value": str | None, + "groups": list[dict], # détail par groupe + } +""" + +from __future__ import annotations + +import logging +import statistics +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PipelineRun: + """Un run de pipeline composée pour la comparaison contrôlée. + + Attributes + ---------- + name: + Nom du run (libre — informatif uniquement). + slots: + Map ``{slot_name: module_name}`` décrivant la pipeline + (ex. ``{"ocr": "tess", "llm": "gpt-4o"}``). + score: + Métrique numérique à comparer (CER moyen typiquement). + Plus bas = meilleur par convention sauf si + ``higher_is_better=True`` est passé à + ``compare_isolated_effect``. + """ + + name: str + slots: dict[str, str] + score: float + + def as_dict(self) -> dict: + return { + "name": self.name, + "slots": dict(self.slots), + "score": self.score, + } + + +def _normalise_runs(runs) -> list[PipelineRun]: + """Accepte une liste de ``PipelineRun`` ou de dicts compatibles.""" + out: list[PipelineRun] = [] + for r in runs: + if isinstance(r, PipelineRun): + out.append(r) + continue + if not isinstance(r, dict): + continue + slots = r.get("slots") or {} + if not isinstance(slots, dict): + continue + try: + score = float(r.get("score")) + except (TypeError, ValueError): + continue + out.append(PipelineRun( + name=str(r.get("name") or ""), + slots={str(k): str(v) for k, v in slots.items()}, + score=score, + )) + return out + + +def compare_isolated_effect( + runs, + varying_slot: str, + *, + higher_is_better: bool = False, +) -> Optional[dict]: + """Mesure l'effet isolé du slot ``varying_slot``. + + Parameters + ---------- + runs: + Liste de ``PipelineRun`` (ou dicts compatibles). + varying_slot: + Nom du slot dont on veut isoler l'effet. Les autres + slots constituent les groupes de contrôle. + higher_is_better: + Si ``True``, on inverse la convention de classement + (rang 1 = score le plus haut). Défaut ``False`` = + rang 1 = score le plus bas (CER). + + Returns + ------- + dict | None + ``None`` si moins de 2 runs ou si ``varying_slot`` + n'est présent dans aucun run. + """ + runs_list = _normalise_runs(runs) + if len(runs_list) < 2: + return None + runs_list = [r for r in runs_list if varying_slot in r.slots] + if not runs_list: + return None + + # Constitue les groupes par valeurs des slots fixed + groups: dict[tuple, list[PipelineRun]] = {} + fixed_slot_names: list[str] = [] + for r in runs_list: + other_slots = sorted(k for k in r.slots if k != varying_slot) + if not fixed_slot_names: + fixed_slot_names = other_slots + # Skip runs avec un schéma de slots incompatible + if other_slots != fixed_slot_names: + continue + key = tuple((k, r.slots[k]) for k in other_slots) + groups.setdefault(key, []).append(r) + + if not groups: + return None + + # Pour chaque groupe : ranking des runs par score + per_value: dict[str, dict] = {} + group_details: list[dict] = [] + for key, members in groups.items(): + members_sorted = sorted( + members, key=lambda x: x.score, reverse=higher_is_better, + ) + # Rangs : runs ex aequo partagent la moyenne des rangs + ranks: dict[str, float] = {} + i = 0 + while i < len(members_sorted): + j = i + while ( + j + 1 < len(members_sorted) + and members_sorted[j + 1].score == members_sorted[i].score + ): + j += 1 + avg_rank = (i + 1 + j + 1) / 2 + for k in range(i, j + 1): + value = members_sorted[k].slots[varying_slot] + ranks[value] = avg_rank + i = j + 1 + + for r in members: + value = r.slots[varying_slot] + slot = per_value.setdefault(value, { + "scores": [], + "ranks": [], + }) + slot["scores"].append(r.score) + slot["ranks"].append(ranks[value]) + group_details.append({ + "fixed_slots": dict(key), + "n_members": len(members), + "values": [r.slots[varying_slot] for r in members_sorted], + "scores": [r.score for r in members_sorted], + }) + + # Calcul mean/stdev/min/max + rang moyen par valeur + summary: dict[str, dict] = {} + for value, slot in per_value.items(): + scores = slot["scores"] + ranks = slot["ranks"] + summary[value] = { + "n_observations": len(scores), + "mean": statistics.fmean(scores) if scores else None, + "stdev": ( + statistics.stdev(scores) if len(scores) >= 2 else None + ), + "min": min(scores), + "max": max(scores), + "mean_rank": ( + statistics.fmean(ranks) if ranks else None + ), + } + + # Best/worst : sur la mean (convention CER : plus bas = meilleur) + by_mean = sorted( + ((v, d["mean"]) for v, d in summary.items() + if d["mean"] is not None), + key=lambda kv: kv[1], + reverse=higher_is_better, + ) + best_value = by_mean[0][0] if by_mean else None + worst_value = by_mean[-1][0] if by_mean else None + + return { + "varying_slot": varying_slot, + "n_runs": len(runs_list), + "n_groups": len(groups), + "values": sorted(per_value.keys()), + "per_value": summary, + "best_value": best_value, + "worst_value": worst_value, + "groups": group_details, + "higher_is_better": higher_is_better, + } + + +__all__ = [ + "PipelineRun", + "compare_isolated_effect", +] diff --git a/picarones/measurements/inter_engine.py b/picarones/measurements/inter_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..68576f0ef9792451092a94aadeafb2c9aea4cf97 --- /dev/null +++ b/picarones/measurements/inter_engine.py @@ -0,0 +1,484 @@ +"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution). + +Deux familles de mesures qui répondent à des questions différentes mais +liées : + +1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`, + `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des + erreurs de natures différentes ?* Une divergence élevée signale des + moteurs spécialisés sur des classes d'erreurs distinctes (visual vs + abréviation vs casse) et donc des candidats pour un voting ensemble. + +2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`, + `pairwise_disagreement_rate`) — *quel CER serait atteignable si on + combinait les moteurs ?* La borne inférieure du CER atteignable par + un voting majoritaire token-level est ``1 - oracle_token_recall``. + Si elle est très inférieure au CER du meilleur moteur seul, l'effort + d'un pipeline d'ensemble se justifie. Sinon non. + +Convention de typage +-------------------- +Toutes les fonctions sont enregistrables dans le registre Sprint 34 si +on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour +limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont +des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne +correspondent pas au modèle « une jonction = une métrique » du runner. +Elles sont consommées par les détecteurs narratifs et le rapport HTML. + +Note sur l'oracle +----------------- +La métrique ``oracle_token_recall`` retournée ici utilise un alignement +bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie +borne atteignable par voting majoritaire séquentiel — c'est une borne +supérieure (proxy optimiste). La vraie borne demanderait un +alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour +le diagnostic « ensemble vaut-il le coup ? », le proxy suffit +largement ; on documente clairement la limite dans le glossaire et le +rapport. +""" + +from __future__ import annotations + +import logging +import math +from collections import Counter + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Divergence taxonomique (KL / Jensen-Shannon) +# ────────────────────────────────────────────────────────────────────────── + + +def _smoothed_distribution( + distribution: dict[str, float], + keys: list[str], + epsilon: float = 1e-12, +) -> list[float]: + """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros. + + Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement + minuscule pour ne pas modifier le résultat de manière sensible. + """ + smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys] + total = sum(smoothed) + return [v / total for v in smoothed] + + +def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float: + """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés. + + Les distributions n'ont pas besoin de partager exactement les mêmes + clés ; les clés manquantes sont lissées à ``epsilon`` puis + renormalisées. + + Returns + ------- + float + ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas + symétrique : ``kl(p, q) != kl(q, p)`` en général. + """ + keys = sorted(set(p.keys()) | set(q.keys())) + if not keys: + return 0.0 + p_vec = _smoothed_distribution(p, keys) + q_vec = _smoothed_distribution(q, keys) + return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec)) + + +def jensen_shannon_divergence( + p: dict[str, float], + q: dict[str, float], +) -> float: + """JS-divergence symétrique en bits, bornée dans ``[0, 1]``. + + ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``. + Symétrique et bornée — préférable à la KL pour construire une + matrice triangulaire de divergences entre moteurs. + """ + keys = sorted(set(p.keys()) | set(q.keys())) + if not keys: + return 0.0 + p_vec = _smoothed_distribution(p, keys) + q_vec = _smoothed_distribution(q, keys) + m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)] + + def _kl(a: list[float], b: list[float]) -> float: + return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0) + + js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec) + # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les + # erreurs d'arrondi flottant. + return max(0.0, min(1.0, js)) + + +def taxonomy_divergence_matrix( + distributions: dict[str, dict[str, float]], + metric: str = "js", +) -> dict[str, dict[str, float]]: + """Construit la matrice de divergence triangulaire entre moteurs. + + Parameters + ---------- + distributions: + ``{engine_name: {error_class: probability}}``. Chaque + distribution doit sommer à environ 1 (pas de validation stricte + — les distributions taxonomiques de Picarones sont déjà + normalisées par ``aggregate_taxonomy``). + metric: + ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique). + + Returns + ------- + dict[str, dict[str, float]] + Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour + ``js``, asymétrique pour ``kl``. La diagonale vaut 0. + """ + if metric not in ("js", "kl"): + raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}") + fn = jensen_shannon_divergence if metric == "js" else kl_divergence + + engines = sorted(distributions.keys()) + matrix: dict[str, dict[str, float]] = {a: {} for a in engines} + for a in engines: + for b in engines: + if a == b: + matrix[a][b] = 0.0 + elif metric == "js" and b in matrix and a in matrix[b]: + # Symétrique : recopie pour éviter de recalculer + matrix[a][b] = matrix[b][a] + else: + matrix[a][b] = fn(distributions[a], distributions[b]) + return matrix + + +# ────────────────────────────────────────────────────────────────────────── +# Complémentarité (oracle token recall) +# ────────────────────────────────────────────────────────────────────────── + + +def _word_multiset(text: str) -> Counter[str]: + """Décomposition en multiset de tokens (séparateur whitespace).""" + return Counter(tok for tok in text.split() if tok) + + +def oracle_token_recall( + reference: str, + hypotheses: dict[str, str], +) -> float: + """Borne supérieure (proxy bag-of-words) du token-recall atteignable + par un voting majoritaire entre tous les moteurs fournis. + + Pour chaque token de la référence (avec sa multiplicité), on + considère qu'il est "préservé" par l'ensemble si au moins un moteur + en produit une occurrence non encore comptée. Le score est le ratio + d'occurrences GT préservées sur le total. + + Parameters + ---------- + reference: + Texte GT. + hypotheses: + ``{engine_name: hypothesis_text}``. + + Returns + ------- + float + Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent + dans au moins une hypothèse à hauteur de sa multiplicité. + + Note + ---- + Cette borne est **optimiste** (supérieure à la vraie borne par + voting séquentiel) car elle ignore l'ordre d'apparition. Pour le + diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour + une vraie borne il faudrait un alignement séquentiel. + """ + ref_counter = _word_multiset(reference) + if not ref_counter or not hypotheses: + return 1.0 if not ref_counter else 0.0 + + hyp_counters = [_word_multiset(h) for h in hypotheses.values()] + total_ref = sum(ref_counter.values()) + preserved = 0 + for token, gt_count in ref_counter.items(): + # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné + # à la multiplicité GT. L'oracle prend le max sur les moteurs. + best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0) + preserved += best + return preserved / total_ref + + +def complementarity_gap( + reference: str, + hypotheses: dict[str, str], +) -> dict[str, float]: + """Compare l'oracle au meilleur moteur seul. + + Returns + ------- + dict + ``{ + "oracle_recall": float, # bag-of-words recall de l'oracle + "best_single_recall": float, # meilleur recall token d'un moteur seul + "best_engine": str, # nom du moteur correspondant + "absolute_gap": float, # oracle - best_single (toujours ≥ 0) + "relative_gap": float, # absolute_gap / (1 - best_single + ε) + # = fraction des erreurs encore évitables + # par un ensemble + }`` + """ + ref_counter = _word_multiset(reference) + total = sum(ref_counter.values()) + if not total: + return { + "oracle_recall": 1.0, + "best_single_recall": 1.0, + "best_engine": "", + "absolute_gap": 0.0, + "relative_gap": 0.0, + } + + def _single_recall(hyp_text: str) -> float: + hc = _word_multiset(hyp_text) + preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items()) + return preserved / total + + if not hypotheses: + return { + "oracle_recall": 0.0, + "best_single_recall": 0.0, + "best_engine": "", + "absolute_gap": 0.0, + "relative_gap": 0.0, + } + + per_engine = {name: _single_recall(h) for name, h in hypotheses.items()} + best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1]) + oracle = oracle_token_recall(reference, hypotheses) + + absolute_gap = max(0.0, oracle - best_recall) + # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble + # serait théoriquement capable de récupérer (∈ [0, 1]) + headroom = max(1.0 - best_recall, 1e-12) + relative_gap = min(1.0, absolute_gap / headroom) + + return { + "oracle_recall": oracle, + "best_single_recall": best_recall, + "best_engine": best_engine, + "absolute_gap": absolute_gap, + "relative_gap": relative_gap, + } + + +def pairwise_disagreement_rate( + reference: str, + hyp_a: str, + hyp_b: str, +) -> float: + """Fraction de tokens GT pour lesquels A et B sont en désaccord. + + Un désaccord = (l'un préserve le token, l'autre non) OU + (les deux le ratent mais avec des substitutions différentes — non + capturé ici, on reste sur la version simple présence/absence). + + Returns + ------- + float + Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix + (pas de gain d'ensemble). ``1`` = A et B sont toujours en + désaccord (gain d'ensemble maximal). + """ + ref_counter = _word_multiset(reference) + if not ref_counter: + return 0.0 + a = _word_multiset(hyp_a) + b = _word_multiset(hyp_b) + total = sum(ref_counter.values()) + disagree = 0 + for tok, gt_count in ref_counter.items(): + a_pres = min(gt_count, a.get(tok, 0)) + b_pres = min(gt_count, b.get(tok, 0)) + # Compte les positions où A et B donnent une réponse différente + disagree += abs(a_pres - b_pres) + return disagree / total + + +# ────────────────────────────────────────────────────────────────────────── +# Agrégation au niveau benchmark (Sprint 36) +# ────────────────────────────────────────────────────────────────────────── + + +def compute_inter_engine_analysis( + *, + per_engine_outputs: dict[str, dict[str, str]], + ground_truths: dict[str, str], + taxonomy_distributions: dict[str, dict[str, float]] | None = None, + divergence_metric: str = "js", +) -> dict: + """Agrège les métriques inter-moteurs sur l'ensemble du corpus. + + Parameters + ---------- + per_engine_outputs: + ``{engine_name: {doc_id: hypothesis_text}}``. Une entrée par + moteur, avec une hypothèse par document. Les documents absents + d'un moteur (échecs, timeouts) sont simplement ignorés pour ce + moteur — l'oracle est calculé sur les moteurs qui ont produit + une sortie pour le doc. + ground_truths: + ``{doc_id: ground_truth_text}``. La GT est la même pour tous + les moteurs ; on la passe une seule fois. + taxonomy_distributions: + ``{engine_name: {error_class: probability}}`` — typiquement + ``EngineReport.aggregated_taxonomy["class_distribution"]``. Si + ``None`` ou vide, la divergence taxonomique n'est pas calculée. + divergence_metric: + ``"js"`` (défaut, symétrique) ou ``"kl"``. + + Returns + ------- + dict + Structure stable consommable par les détecteurs narratifs et le + rapport HTML : + ``{ + "complementarity": { + "oracle_recall": float, + "best_single_recall": float, + "best_engine": str, + "absolute_gap": float, + "relative_gap": float, + "doc_count": int, + "per_doc": [{doc_id, oracle, best, gap}, ...] # max 50 docs + }, + "taxonomy_divergence": { + "metric": "js"|"kl", + "matrix": {engine_a: {engine_b: divergence}}, + "max_pair": [engine_a, engine_b, value] # paire la plus divergente + } | None, + "engines": [...], # liste des moteurs analysés (ordre stable) + }`` + """ + engines = sorted(per_engine_outputs.keys()) + result: dict = {"engines": engines} + + # ── Complémentarité agrégée doc par doc ────────────────────────────── + if not engines: + result["complementarity"] = None + else: + total_oracle_preserved = 0 + total_ref_tokens = 0 + per_engine_preserved: dict[str, int] = {name: 0 for name in engines} + per_doc_records: list[dict] = [] + + for doc_id, gt in ground_truths.items(): + ref_counter = _word_multiset(gt) + ref_total = sum(ref_counter.values()) + if not ref_total: + continue + total_ref_tokens += ref_total + + doc_hyps: dict[str, str] = {} + for name in engines: + hyp = per_engine_outputs.get(name, {}).get(doc_id) + if hyp is not None: + doc_hyps[name] = hyp + + if not doc_hyps: + continue + + hyp_counters = {n: _word_multiset(h) for n, h in doc_hyps.items()} + + doc_oracle = 0 + doc_best_per_engine: dict[str, int] = {n: 0 for n in doc_hyps} + for tok, gt_count in ref_counter.items(): + # Oracle : meilleur des moteurs sur ce token + best_for_token = 0 + for name, hc in hyp_counters.items(): + preserved = min(gt_count, hc.get(tok, 0)) + doc_best_per_engine[name] += preserved + if preserved > best_for_token: + best_for_token = preserved + doc_oracle += best_for_token + + total_oracle_preserved += doc_oracle + for name, count in doc_best_per_engine.items(): + per_engine_preserved[name] += count + + doc_best = max(doc_best_per_engine.values()) if doc_best_per_engine else 0 + per_doc_records.append({ + "doc_id": doc_id, + "oracle_recall": doc_oracle / ref_total, + "best_single_recall": doc_best / ref_total, + "absolute_gap": (doc_oracle - doc_best) / ref_total, + }) + + if total_ref_tokens == 0: + result["complementarity"] = None + else: + oracle_recall = total_oracle_preserved / total_ref_tokens + recalls = { + name: per_engine_preserved[name] / total_ref_tokens + for name in engines + } + best_engine, best_recall = max(recalls.items(), key=lambda kv: kv[1]) + absolute_gap = max(0.0, oracle_recall - best_recall) + headroom = max(1.0 - best_recall, 1e-12) + relative_gap = min(1.0, absolute_gap / headroom) + + # Garder les ``per_doc_records`` les plus instructifs : tri par + # gap absolu décroissant, top 50. Les détecteurs narratifs + # n'en consomment que quelques-uns. + per_doc_records.sort(key=lambda r: r["absolute_gap"], reverse=True) + per_doc_top = per_doc_records[:50] + + result["complementarity"] = { + "oracle_recall": oracle_recall, + "best_single_recall": best_recall, + "best_engine": best_engine, + "absolute_gap": absolute_gap, + "relative_gap": relative_gap, + "doc_count": len(per_doc_records), + "per_engine_recall": recalls, + "per_doc": per_doc_top, + } + + # ── Divergence taxonomique ───────────────────────────────────────── + if not taxonomy_distributions: + result["taxonomy_divergence"] = None + else: + matrix = taxonomy_divergence_matrix( + taxonomy_distributions, + metric=divergence_metric, + ) + # Cherche la paire la plus divergente (utile pour la synthèse + # narrative qui veut nommer les deux moteurs candidats à + # l'ensemble). + max_pair: tuple[str, str, float] = ("", "", 0.0) + names = sorted(matrix.keys()) + for i, a in enumerate(names): + for b in names[i + 1:]: + v = matrix[a][b] + if v > max_pair[2]: + max_pair = (a, b, v) + + result["taxonomy_divergence"] = { + "metric": divergence_metric, + "matrix": matrix, + "max_pair": list(max_pair) if max_pair[2] > 0 else None, + } + + return result + + +__all__ = [ + "kl_divergence", + "jensen_shannon_divergence", + "taxonomy_divergence_matrix", + "oracle_token_recall", + "complementarity_gap", + "pairwise_disagreement_rate", + "compute_inter_engine_analysis", +] diff --git a/picarones/measurements/layout.py b/picarones/measurements/layout.py new file mode 100644 index 0000000000000000000000000000000000000000..477d247e8b531c1aeafa97ee6b76ac064479904b --- /dev/null +++ b/picarones/measurements/layout.py @@ -0,0 +1,280 @@ +"""Layout F1 par type de région — Sprint 54. + +Sprint 54 — A.II.2.2 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur +sépare-t-il bien le texte principal de la glose ? »*. Le score de +structure global de Picarones (Sprint 5) agrège fusion/fragmentation +de lignes en un seul nombre — utile mais non typé. Ce module +discrimine par **type de région** ALTO/PAGE (``TextRegion``, +``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en +appliquant le pattern ICDAR layout standard : + +- **TP** : région GT et région hypothèse de **même type** avec + chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant), +- **FN** : région GT non matchée, +- **FP** : région hypothèse non matchée, +- F1 calculé global et par type. + +Le pattern d'alignement est le même que pour le NER (Sprint 38) — on +réutilise une approche éprouvée plutôt que d'en inventer une nouvelle. + +Stratégie de découpage +---------------------- +Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1 +(Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit +deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un +parser amont — le parser ALTO/PAGE standard de Picarones suivra +dans un sprint dédié). Pas de câblage runner ni de vue HTML ici. + +Convention de coordonnées +------------------------- +Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine +en haut à gauche, axe y vers le bas — convention ALTO et PAGE +standard). L'IoU est calculée sur l'aire d'intersection / union des +rectangles. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle de données +# ────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Region: + """Une région ALTO/PAGE alignable sur sa GT. + + Attributs + --------- + id: + Identifiant unique au sein de la séquence (ex. ``"r_1"``, + ``"region_main"``). Informatif — l'alignement se fait par IoU, + pas par ID. + type: + Catégorie de la région (``"TextRegion"``, ``"MarginNote"``, + ``"Header"``, etc.). Comparaison **case-insensitive**. + bbox: + Rectangle ``(x, y, width, height)`` en pixels, origine en haut + à gauche. Doit avoir width > 0 et height > 0. + """ + + id: str + type: str + bbox: tuple[int, int, int, int] + + def __post_init__(self) -> None: + x, y, w, h = self.bbox + if w <= 0 or h <= 0: + raise ValueError( + f"Region {self.id!r} : bbox invalide (w={w}, h={h}). " + "width et height doivent être strictement positifs." + ) + + @property + def area(self) -> int: + _, _, w, h = self.bbox + return w * h + + +def _to_region(obj: Region | dict) -> Region: + """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``).""" + if isinstance(obj, Region): + return obj + return Region( + id=str(obj["id"]), + type=str(obj["type"]), + bbox=tuple(obj["bbox"]), # type: ignore[arg-type] + ) + + +# ────────────────────────────────────────────────────────────────────────── +# IoU + alignement greedy +# ────────────────────────────────────────────────────────────────────────── + + +def _iou_bbox(a: Region, b: Region) -> float: + """Intersection-over-Union de deux bboxes ``(x, y, w, h)``.""" + ax, ay, aw, ah = a.bbox + bx, by, bw, bh = b.bbox + inter_x = max(ax, bx) + inter_y = max(ay, by) + inter_x_end = min(ax + aw, bx + bw) + inter_y_end = min(ay + ah, by + bh) + inter_w = max(0, inter_x_end - inter_x) + inter_h = max(0, inter_y_end - inter_y) + inter = inter_w * inter_h + if inter == 0: + return 0.0 + union = a.area + b.area - inter + if union <= 0: + return 0.0 + return inter / union + + +def _align_regions( + references: list[Region], + hypotheses: list[Region], + iou_threshold: float, +) -> tuple[list[tuple[int, int, float]], set[int], set[int]]: + """Appareillage greedy par IoU décroissant ; same type requis. + + Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` — + ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``. + """ + candidates: list[tuple[float, int, int]] = [] + for i, r in enumerate(references): + for j, h in enumerate(hypotheses): + if r.type.casefold() != h.type.casefold(): + continue + iou = _iou_bbox(r, h) + if iou >= iou_threshold: + candidates.append((iou, i, j)) + + # Tri stable : IoU décroissant, puis indices croissants pour + # déterminisme sur égalités. + candidates.sort(key=lambda t: (-t[0], t[1], t[2])) + + matched_refs: set[int] = set() + matched_hyps: set[int] = set() + matches: list[tuple[int, int, float]] = [] + for iou, i, j in candidates: + if i in matched_refs or j in matched_hyps: + continue + matched_refs.add(i) + matched_hyps.add(j) + matches.append((i, j, iou)) + + unmatched_refs = set(range(len(references))) - matched_refs + unmatched_hyps = set(range(len(hypotheses))) - matched_hyps + return matches, unmatched_refs, unmatched_hyps + + +# ────────────────────────────────────────────────────────────────────────── +# Métrique principale +# ────────────────────────────────────────────────────────────────────────── + + +def _prf(tp: int, fp: int, fn: int) -> dict[str, float]: + p = tp / (tp + fp) if (tp + fp) > 0 else 0.0 + r = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0 + return {"precision": p, "recall": r, "f1": f1, "support": tp + fn} + + +def compute_layout_metrics( + reference_regions: Iterable[Region | dict] | None, + hypothesis_regions: Iterable[Region | dict] | None, + iou_threshold: float = 0.5, +) -> dict: + """Calcule precision/recall/F1 sur le layout par type de région. + + Parameters + ---------- + reference_regions: + Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``). + hypothesis_regions: + Liste de régions produites par le moteur OCR/HTR ou un + layout-detector. + iou_threshold: + Seuil de chevauchement minimal pour déclarer un appariement + (défaut : 0,5 — convention ICDAR). + + Returns + ------- + dict + ``{ + "global": {"precision", "recall", "f1", "support"}, + "per_type": {type_name: {"precision", ...}}, + "true_positives": int, + "false_positives": int, + "false_negatives": int, + "missed_regions": list[dict], # GT non matchées + "hallucinated_regions": list[dict], # hyp non matchées + "iou_threshold": float, + }`` + + Cas dégénérés + ------------- + - Deux listes vides → F1 = 0 et tous compteurs à 0. + - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP). + - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN). + """ + refs = [_to_region(r) for r in (reference_regions or [])] + hyps = [_to_region(h) for h in (hypothesis_regions or [])] + + matches, unmatched_refs, unmatched_hyps = _align_regions( + refs, hyps, iou_threshold, + ) + + tp = len(matches) + fn = len(unmatched_refs) + fp = len(unmatched_hyps) + + cat_tp: dict[str, int] = {} + cat_fn: dict[str, int] = {} + cat_fp: dict[str, int] = {} + for i, _j, _iou in matches: + cat = refs[i].type + cat_tp[cat] = cat_tp.get(cat, 0) + 1 + for i in unmatched_refs: + cat = refs[i].type + cat_fn[cat] = cat_fn.get(cat, 0) + 1 + for j in unmatched_hyps: + cat = hyps[j].type + cat_fp[cat] = cat_fp.get(cat, 0) + 1 + + all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp)) + per_type = { + cat: _prf( + cat_tp.get(cat, 0), + cat_fp.get(cat, 0), + cat_fn.get(cat, 0), + ) + for cat in all_categories + } + + return { + "global": _prf(tp, fp, fn), + "per_type": per_type, + "true_positives": tp, + "false_positives": fp, + "false_negatives": fn, + "missed_regions": [ + {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)} + for i in sorted(unmatched_refs) + ], + "hallucinated_regions": [ + {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)} + for j in sorted(unmatched_hyps) + ], + "iou_threshold": iou_threshold, + } + + +def layout_f1( + reference_regions: Iterable[Region | dict] | None, + hypothesis_regions: Iterable[Region | dict] | None, + iou_threshold: float = 0.5, +) -> float: + """Raccourci : F1 global du layout.""" + return compute_layout_metrics( + reference_regions, hypothesis_regions, iou_threshold, + )["global"]["f1"] + + +__all__ = [ + "Region", + "compute_layout_metrics", + "layout_f1", +] diff --git a/picarones/measurements/levers.py b/picarones/measurements/levers.py new file mode 100644 index 0000000000000000000000000000000000000000..47ba0ab9d665f6eb35d0572fdb4c07a2d7b4ea44 --- /dev/null +++ b/picarones/measurements/levers.py @@ -0,0 +1,561 @@ +"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9). + +Sprint 82 — A.I.9 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce +qui s'est passé** dans le benchmark : qui gagne, qui s'effondre, +qui est fragile. Ce sprint répond à une question +complémentaire : **sur quelle dimension le bénéfice attendu d'une +amélioration serait-il le plus visible ?** + +Pas de prescription +------------------- +Picarones est un **outil de recherche**, pas un atelier de +production. Le module ne dit jamais *« faites X »* ni +*« utilisez le moteur Y »* ; il agrège des **observations +factuelles** déjà calculées dans d'autres modules (Sprints 75-81) +et les présente comme un récapitulatif compact en bas du rapport. +Le chercheur lit, juge et arbitre. + +Exemples de leviers émis +------------------------ +- *« 65 % des erreurs de Tesseract sont de classe récupérable + (case_error, ligature_error, abbreviation_error) — un + post-processing trivial absorberait une partie. »* +- *« 12 % de vos documents concentrent 78 % du CER total + (Pareto-CER). »* +- *« Le déficit projeté du moteur le plus fragile sur le corpus + réel est de 4,2 points de CER (Sprint 81). »* +- *« Le top-3 des tokens GT systématiquement modernisés est + maistre, nostre, veoir (Sprint 80). »* + +Structure +--------- +Module parallèle au registre narratif Sprint 19 : `Lever` est la +dataclass équivalente à `Fact`, `LeverImportance` reprend la +sémantique de `FactImportance`, `@register_lever` indexe les +détecteurs. Garde-fou anti-hallucination identique : chaque +nombre rendu doit être présent dans le `payload` du `Lever`. + +Les détecteurs lisent **uniquement** des structures déjà +construites par le pipeline du benchmark — ils ne calculent rien +de nouveau, ils synthétisent. C'est pourquoi le module est +résolument optionnel : si un benchmark n'expose pas +`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`, +`lexical_modernization` ou `robustness_projection`, le détecteur +correspondant retourne tout simplement `[]`. +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from enum import Enum +from typing import Callable + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle +# ────────────────────────────────────────────────────────────────────────── + + +class LeverType(str, Enum): + """Types de leviers détectés.""" + + DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class" + """Une part importante des erreurs d'un moteur est dans des classes + catégorisées « récupérables » (Sprint 77).""" + + PARETO_CONCENTRATION = "pareto_concentration" + """Une fraction minoritaire de documents concentre une fraction + majoritaire du CER total — l'inspection ciblée est rentable.""" + + COMPLEMENTARITY_OBSERVATION = "complementarity_observation" + """Le `complementarity_gap` (Sprint 35) entre l'oracle et le + meilleur moteur seul est non négligeable — observation factuelle, + aucune recommandation d'ensemble.""" + + LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation" + """Top-N des tokens GT systématiquement modernisés (Sprint 80).""" + + ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation" + """Déficit projeté global le plus important pour un moteur sur + le corpus réel (Sprint 81).""" + + +class LeverImportance(int, Enum): + """Importance éditoriale d'un levier.""" + + HIGH = 70 + MEDIUM = 40 + LOW = 10 + + +@dataclass +class Lever: + """Observation factuelle synthétisable en encart « Leviers ». + + Attributes + ---------- + type: + Le type de levier (voir `LeverType`). + importance: + Score qui décide l'ordre d'affichage. + payload: + Données brutes — **tout chiffre rendu dans le HTML doit + provenir d'ici**, jamais d'un calcul du renderer. + engines_involved: + Noms des moteurs concernés (peut être vide pour un levier + corpus-wide). + """ + + type: LeverType + importance: LeverImportance + payload: dict + engines_involved: tuple[str, ...] = () + + def as_dict(self) -> dict: + return { + "type": self.type.value, + "importance": int(self.importance), + "payload": self.payload, + "engines_involved": list(self.engines_involved), + } + + +# ────────────────────────────────────────────────────────────────────────── +# Registre +# ────────────────────────────────────────────────────────────────────────── + + +LeverDetectorFn = Callable[[dict], list[Lever]] + + +@dataclass(frozen=True) +class LeverDetectorEntry: + lever_type: LeverType + fn: LeverDetectorFn + priority: int + + +_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {} +_LEVER_REGISTRY_LOCK = threading.Lock() + + +def register_lever( + lever_type: LeverType, + *, + priority: int, +) -> Callable[[LeverDetectorFn], LeverDetectorFn]: + """Décorateur : enregistre un détecteur de levier. + + Une seule fonction par type — réenregistrer lève `ValueError`. + """ + def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn: + with _LEVER_REGISTRY_LOCK: + if lever_type in _LEVER_REGISTRY: + raise ValueError( + f"Détecteur déjà enregistré pour {lever_type.value!r} : " + f"{_LEVER_REGISTRY[lever_type].fn.__name__}." + ) + _LEVER_REGISTRY[lever_type] = LeverDetectorEntry( + lever_type=lever_type, fn=fn, priority=int(priority), + ) + return fn + return _decorator + + +def unregister_lever(lever_type: LeverType) -> None: + with _LEVER_REGISTRY_LOCK: + _LEVER_REGISTRY.pop(lever_type, None) + + +def iter_lever_detectors() -> list[LeverDetectorEntry]: + with _LEVER_REGISTRY_LOCK: + entries = list(_LEVER_REGISTRY.values()) + entries.sort(key=lambda e: e.priority) + return entries + + +def detect_levers(benchmark_data: dict) -> list[Lever]: + """Applique tous les détecteurs enregistrés et trie par importance + décroissante puis priorité d'enregistrement croissante.""" + levers: list[Lever] = [] + for entry in iter_lever_detectors(): + try: + result = entry.fn(benchmark_data) + except Exception as e: + logger.warning( + "[levers.detector.%s] fonctionnalité dégradée : %s", + entry.lever_type.value, e, + ) + continue + if result: + levers.extend(result) + # Tri stable : importance décroissante d'abord + levers.sort(key=lambda lv: -int(lv.importance)) + return levers + + +# ────────────────────────────────────────────────────────────────────────── +# Détecteurs +# ────────────────────────────────────────────────────────────────────────── + + +# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py). +# Volontairement dupliquée ici pour ne pas introduire d'import +# circulaire — la sémantique est gelée. +_RECOVERABILITY: dict[str, str] = { + "case_error": "recoverable", + "ligature_error": "recoverable", + "abbreviation_error": "recoverable", + "diacritic_error": "difficult", + "visual_confusion": "difficult", + "hapax": "difficult", + "lacuna": "irrecoverable", + "oov_character": "irrecoverable", + "segmentation_error": "irrecoverable", +} + + +@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10) +def detect_dominant_recoverable_class( + benchmark_data: dict, + *, + threshold: float = 0.30, +) -> list[Lever]: + """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont + classifiées récupérables (catégorisation Sprint 77). + + Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` — + structure produite par le runner historique. Si absent, retourne + []. + """ + engines = benchmark_data.get("engines") or [] + out: list[Lever] = [] + for engine in engines: + taxonomy = engine.get("aggregated_taxonomy") + if not taxonomy: + continue + # `taxonomy` peut être {class_name: int} ou un dict avec une + # sous-clé "counts" — on accepte les deux conventions. + counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy + if not isinstance(counts, dict) or not counts: + continue + try: + int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))} + except (TypeError, ValueError): + continue + total = sum(int_counts.values()) + if total <= 0: + continue + recoverable_total = sum( + v for k, v in int_counts.items() + if _RECOVERABILITY.get(k) == "recoverable" + ) + share = recoverable_total / total + if share < threshold: + continue + # Classes récupérables non vides triées par count décroissant + breakdown = sorted( + ( + (k, v) for k, v in int_counts.items() + if _RECOVERABILITY.get(k) == "recoverable" and v > 0 + ), + key=lambda kv: -kv[1], + ) + importance = ( + LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM + ) + out.append(Lever( + type=LeverType.DOMINANT_RECOVERABLE_CLASS, + importance=importance, + payload={ + "engine": engine.get("name") or "?", + "share_recoverable": share, + "share_recoverable_pct": round(share * 100, 1), + "n_recoverable": recoverable_total, + "n_total_errors": total, + "top_classes": [ + {"class": k, "count": v} for k, v in breakdown[:3] + ], + }, + engines_involved=(engine.get("name") or "?",), + )) + return out + + +@register_lever(LeverType.PARETO_CONCENTRATION, priority=20) +def detect_pareto_concentration( + benchmark_data: dict, + *, + top_share: float = 0.20, + cer_share_threshold: float = 0.50, +) -> list[Lever]: + """Émet un levier si une fraction minoritaire de documents + (`top_share`) concentre plus de `cer_share_threshold` du CER + total cumulé sur le moteur leader. + + Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de + reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`. + Si rien d'exploitable, retourne []. + """ + ranking = benchmark_data.get("ranking") or [] + if not ranking: + return [] + leader = ranking[0] + leader_name = leader.get("engine") + if not leader_name: + return [] + + per_doc_cer: list[float] = [] + # Voie 1 : structure plate "per_doc_cer" + flat = benchmark_data.get("per_doc_cer") or {} + if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list): + per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))] + else: + # Voie 2 : engine.per_doc liste de dicts {cer: float} + for engine in benchmark_data.get("engines") or []: + if engine.get("name") != leader_name: + continue + per_doc = engine.get("per_doc") or [] + for entry in per_doc: + if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)): + per_doc_cer.append(float(entry["cer"])) + break + + if not per_doc_cer: + return [] + total_cer = sum(per_doc_cer) + if total_cer <= 0: + return [] + + sorted_cer = sorted(per_doc_cer, reverse=True) + n = len(sorted_cer) + n_top = max(1, int(round(top_share * n))) + top_cer_sum = sum(sorted_cer[:n_top]) + share_of_total = top_cer_sum / total_cer + if share_of_total < cer_share_threshold: + return [] + importance = ( + LeverImportance.HIGH if share_of_total >= 0.75 + else LeverImportance.MEDIUM + ) + return [Lever( + type=LeverType.PARETO_CONCENTRATION, + importance=importance, + payload={ + "engine": leader_name, + "n_docs": n, + "n_docs_top": n_top, + "top_share_pct": round((n_top / n) * 100, 1), + "cer_share_of_total": share_of_total, + "cer_share_pct": round(share_of_total * 100, 1), + }, + engines_involved=(leader_name,), + )] + + +@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30) +def detect_complementarity_observation( + benchmark_data: dict, + *, + min_relative_gap: float = 0.20, +) -> list[Lever]: + """Reformule factuellement le `complementarity_gap` (Sprint 35). + + Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne + déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune + recommandation d'ensemble** — le levier dit factuellement + « X points séparent l'oracle du meilleur moteur », c'est tout. + """ + inter = benchmark_data.get("inter_engine_analysis") or {} + cgap = inter.get("complementarity_gap") or {} + relative_gap = cgap.get("relative_gap") + absolute_gap = cgap.get("absolute_gap") + if relative_gap is None or absolute_gap is None: + return [] + try: + rg = float(relative_gap) + ag = float(absolute_gap) + except (TypeError, ValueError): + return [] + if rg < min_relative_gap: + return [] + importance = ( + LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM + ) + payload: dict = { + "absolute_gap": ag, + "absolute_gap_pct": round(ag * 100, 1), + "relative_gap": rg, + "relative_gap_pct": round(rg * 100, 1), + } + best_engine = cgap.get("best_engine") or inter.get("best_engine") + best_recall = cgap.get("best_recall") or inter.get("best_engine_recall") + oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall") + engines_involved: tuple[str, ...] = () + if best_engine: + payload["best_engine"] = str(best_engine) + engines_involved = (str(best_engine),) + if isinstance(best_recall, (int, float)): + payload["best_recall"] = float(best_recall) + if isinstance(oracle_recall, (int, float)): + payload["oracle_recall"] = float(oracle_recall) + return [Lever( + type=LeverType.COMPLEMENTARITY_OBSERVATION, + importance=importance, + payload=payload, + engines_involved=engines_involved, + )] + + +@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40) +def detect_lexical_modernization_observation( + benchmark_data: dict, + *, + top_n: int = 3, + min_total: int = 3, + min_rate: float = 0.50, +) -> list[Lever]: + """Pour chaque moteur disposant de `lexical_modernization`, + émet un levier listant les `top_n` tokens GT les plus modernisés. + + Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui + suit la forme produite par `compute_lexical_modernization` du + Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`). + """ + out: list[Lever] = [] + for engine in benchmark_data.get("engines") or []: + data = engine.get("lexical_modernization") + if not isinstance(data, dict): + continue + tokens = data.get("tokens") or {} + if not isinstance(tokens, dict) or not tokens: + continue + candidates: list[tuple[str, dict]] = [] + for gt_token, slot in tokens.items(): + if not isinstance(slot, dict): + continue + n_total = slot.get("n_total") + rate = slot.get("rate_modernized") + if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)): + continue + if int(n_total) < min_total: + continue + if float(rate) < min_rate: + continue + candidates.append((gt_token, dict(slot))) + if not candidates: + continue + candidates.sort( + key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)), + -int(kv[1].get("n_total", 0)), + kv[0]), + ) + top = candidates[:top_n] + engine_name = engine.get("name") or "?" + max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top) + importance = ( + LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM + ) + out.append(Lever( + type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION, + importance=importance, + payload={ + "engine": engine_name, + "top_tokens": [ + { + "gt_token": gt, + "n_total": int(slot.get("n_total", 0)), + "rate_modernized": float(slot.get("rate_modernized", 0.0)), + "rate_modernized_pct": round( + float(slot.get("rate_modernized", 0.0)) * 100, 1, + ), + } + for gt, slot in top + ], + }, + engines_involved=(engine_name,), + )) + return out + + +@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50) +def detect_robustness_projection_observation( + benchmark_data: dict, + *, + min_total_deficit: float = 0.02, +) -> list[Lever]: + """Lit l'agrégation par moteur de la projection de robustesse + (Sprint 81). Émet le levier pour le moteur dont + `total_expected_deficit` est ≥ `min_total_deficit` (par défaut + 2 points de CER). + + Lit `benchmark_data["robustness_projection_aggregated"]` — + structure produite par `aggregate_projection_per_engine`. + """ + agg = benchmark_data.get("robustness_projection_aggregated") or {} + if not isinstance(agg, dict) or not agg: + return [] + out: list[Lever] = [] + for engine_name, info in agg.items(): + if not isinstance(info, dict): + continue + total_deficit = info.get("total_expected_deficit") + worst_type = info.get("worst_degradation_type") + worst_deficit = info.get("worst_degradation_deficit") + if not isinstance(total_deficit, (int, float)): + continue + if float(total_deficit) < min_total_deficit: + continue + importance = ( + LeverImportance.HIGH if float(total_deficit) >= 0.05 + else LeverImportance.MEDIUM + ) + payload: dict = { + "engine": engine_name, + "total_expected_deficit": float(total_deficit), + "total_expected_deficit_pct": round(float(total_deficit) * 100, 1), + "n_degradation_types": int(info.get("n_degradation_types") or 0), + } + if isinstance(worst_type, str): + payload["worst_degradation_type"] = worst_type + if isinstance(worst_deficit, (int, float)): + payload["worst_degradation_deficit"] = float(worst_deficit) + payload["worst_degradation_deficit_pct"] = round( + float(worst_deficit) * 100, 1, + ) + out.append(Lever( + type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, + importance=importance, + payload=payload, + engines_involved=(engine_name,), + )) + # Tri par déficit décroissant pour stabilité d'affichage. + out.sort( + key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0), + ) + return out + + +__all__ = [ + "Lever", + "LeverImportance", + "LeverType", + "LeverDetectorEntry", + "register_lever", + "unregister_lever", + "iter_lever_detectors", + "detect_levers", + "detect_dominant_recoverable_class", + "detect_pareto_concentration", + "detect_complementarity_observation", + "detect_lexical_modernization_observation", + "detect_robustness_projection_observation", +] diff --git a/picarones/measurements/line_metrics.py b/picarones/measurements/line_metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..5204decce03afa16ce9d4fc93e8bbb973d77f475 --- /dev/null +++ b/picarones/measurements/line_metrics.py @@ -0,0 +1,286 @@ +"""Distribution des erreurs CER par ligne — Sprint 10. + +Métriques calculées +------------------- +- CER par ligne : distance d'édition caractère/longueur GT sur chaque paire de lignes +- Percentiles : p50, p75, p90, p95, p99 sur la distribution des CER ligne +- Taux catastrophiques : % de lignes dépassant des seuils configurables (30 %, 50 %, 100 %) +- Coefficient de Gini : concentration des erreurs (0 = uniformes, 1 = toutes concentrées) +- Carte thermique : CER moyen par tranche de position dans le document +""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass +from typing import Optional + + +# --------------------------------------------------------------------------- +# CER d'une paire de lignes (distance d'édition Levenshtein normalisée) +# --------------------------------------------------------------------------- + +def _edit_distance(a: str, b: str) -> int: + """Distance de Levenshtein entre deux chaînes.""" + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + curr = [i] + for j, cb in enumerate(b, 1): + cost = 0 if ca == cb else 1 + curr.append(min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)) + prev = curr + return prev[-1] + + +def _line_cer(ref_line: str, hyp_line: str) -> float: + """CER pour une paire de lignes. Retourne 1.0 si le GT est vide et que l'hyp ne l'est pas.""" + ref = unicodedata.normalize("NFC", ref_line.strip()) + hyp = unicodedata.normalize("NFC", hyp_line.strip()) + if not ref: + return 0.0 if not hyp else 1.0 + dist = _edit_distance(ref, hyp) + return dist / len(ref) + + +# --------------------------------------------------------------------------- +# Percentiles (implémentation pur-Python, sans numpy) +# --------------------------------------------------------------------------- + +def _percentile(sorted_values: list[float], p: float) -> float: + """Retourne le p-ième percentile (0 ≤ p ≤ 100) d'une liste triée.""" + if not sorted_values: + return 0.0 + n = len(sorted_values) + index = p / 100 * (n - 1) + lo = int(index) + hi = min(lo + 1, n - 1) + frac = index - lo + return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo]) + + +# --------------------------------------------------------------------------- +# Coefficient de Gini +# --------------------------------------------------------------------------- + +def _gini(values: list[float]) -> float: + """Coefficient de Gini des erreurs (0 = uniformes, 1 = toutes concentrées). + + Formule : G = (2 * Σ i*x_i) / (n * Σ x_i) - (n+1)/n + sur les valeurs triées par ordre croissant. + """ + if not values: + return 0.0 + xs = sorted(max(v, 0.0) for v in values) + n = len(xs) + total = sum(xs) + if total == 0.0: + return 0.0 + weighted_sum = sum((i + 1) * x for i, x in enumerate(xs)) + return (2.0 * weighted_sum) / (n * total) - (n + 1) / n + + +# --------------------------------------------------------------------------- +# Résultat structuré +# --------------------------------------------------------------------------- + +@dataclass +class LineMetrics: + """Distribution des erreurs CER par ligne pour une paire (GT, hypothèse).""" + + cer_per_line: list[float] + """CER de chaque ligne (longueur = nombre de lignes GT).""" + + percentiles: dict[str, float] + """Percentiles : p50, p75, p90, p95, p99.""" + + catastrophic_rate: dict[str, float] + """Taux de lignes catastrophiques pour chaque seuil (ex. {0.3: 0.12, 0.5: 0.07, 1.0: 0.02}).""" + + gini: float + """Coefficient de Gini des erreurs (0 → uniforme, 1 → concentrées).""" + + heatmap: list[float] + """CER moyen par tranche de position dans le document (longueur = heatmap_bins).""" + + line_count: int + """Nombre de lignes GT traitées.""" + + mean_cer: float + """CER moyen sur l'ensemble des lignes.""" + + def as_dict(self) -> dict: + return { + "cer_per_line": [round(v, 6) for v in self.cer_per_line], + "percentiles": {k: round(v, 6) for k, v in self.percentiles.items()}, + "catastrophic_rate": {str(k): round(v, 6) for k, v in self.catastrophic_rate.items()}, + "gini": round(self.gini, 6), + "heatmap": [round(v, 6) for v in self.heatmap], + "line_count": self.line_count, + "mean_cer": round(self.mean_cer, 6), + } + + @classmethod + def from_dict(cls, d: dict) -> "LineMetrics": + return cls( + cer_per_line=d.get("cer_per_line", []), + percentiles=d.get("percentiles", {}), + catastrophic_rate={float(k): v for k, v in d.get("catastrophic_rate", {}).items()}, + gini=d.get("gini", 0.0), + heatmap=d.get("heatmap", []), + line_count=d.get("line_count", 0), + mean_cer=d.get("mean_cer", 0.0), + ) + + +# --------------------------------------------------------------------------- +# Calcul principal +# --------------------------------------------------------------------------- + +def compute_line_metrics( + reference: str, + hypothesis: str, + thresholds: Optional[list[float]] = None, + heatmap_bins: int = 10, +) -> LineMetrics: + """Calcule la distribution des erreurs CER ligne par ligne. + + Parameters + ---------- + reference: + Texte de vérité terrain (GT) avec sauts de ligne. + hypothesis: + Texte produit par le moteur OCR. + thresholds: + Seuils CER pour le taux catastrophique. Défaut : [0.30, 0.50, 1.00]. + heatmap_bins: + Nombre de tranches de position pour la carte thermique. + + Returns + ------- + LineMetrics + """ + if thresholds is None: + thresholds = [0.30, 0.50, 1.00] + + ref_lines = reference.splitlines() + hyp_lines = hypothesis.splitlines() + + # Aligner les lignes GT / hypothèse — on prend au moins autant de lignes que le GT + n = len(ref_lines) + if n == 0: + # Pas de lignes : retourner des métriques neutres + return LineMetrics( + cer_per_line=[], + percentiles={f"p{p}": 0.0 for p in (50, 75, 90, 95, 99)}, + catastrophic_rate={t: 0.0 for t in thresholds}, + gini=0.0, + heatmap=[0.0] * heatmap_bins, + line_count=0, + mean_cer=0.0, + ) + + # Aligner en ignorant les lignes d'hypothèse supplémentaires + # Si l'hypothèse a moins de lignes, les lignes manquantes comptent comme supprimées (CER = 1.0) + cer_per_line: list[float] = [] + for i, ref_line in enumerate(ref_lines): + hyp_line = hyp_lines[i] if i < len(hyp_lines) else "" + cer_per_line.append(min(_line_cer(ref_line, hyp_line), 1.0)) + + sorted_cer = sorted(cer_per_line) + + # Percentiles + percentiles = { + f"p{p}": _percentile(sorted_cer, p) + for p in (50, 75, 90, 95, 99) + } + + # Taux catastrophiques + catastrophic_rate: dict[float, float] = {} + for t in thresholds: + count = sum(1 for v in cer_per_line if v > t) + catastrophic_rate[t] = count / n + + # Gini + gini = _gini(cer_per_line) + + # Carte thermique par tranche de position + bins = heatmap_bins + heatmap: list[float] = [] + for b in range(bins): + start = int(b * n / bins) + end = int((b + 1) * n / bins) + slice_ = cer_per_line[start:end] + heatmap.append(sum(slice_) / len(slice_) if slice_ else 0.0) + + mean_cer = sum(cer_per_line) / n + + return LineMetrics( + cer_per_line=cer_per_line, + percentiles=percentiles, + catastrophic_rate=catastrophic_rate, + gini=gini, + heatmap=heatmap, + line_count=n, + mean_cer=mean_cer, + ) + + +# --------------------------------------------------------------------------- +# Agrégation sur un corpus +# --------------------------------------------------------------------------- + +def aggregate_line_metrics(results: list[LineMetrics]) -> dict: + """Agrège les métriques de distribution par ligne sur un corpus. + + Returns + ------- + dict + Statistiques agrégées : Gini moyen, percentiles moyens, taux catastrophiques moyens. + """ + if not results: + return {} + + import statistics as _stats + + gini_values = [r.gini for r in results] + mean_cer_values = [r.mean_cer for r in results] + + # Percentiles moyens + pct_keys = ["p50", "p75", "p90", "p95", "p99"] + avg_percentiles = {} + for k in pct_keys: + vals = [r.percentiles.get(k, 0.0) for r in results] + avg_percentiles[k] = round(sum(vals) / len(vals), 6) if vals else 0.0 + + # Taux catastrophiques moyens (union des seuils) + all_thresholds: set[float] = set() + for r in results: + all_thresholds.update(r.catastrophic_rate.keys()) + avg_catastrophic: dict[str, float] = {} + for t in sorted(all_thresholds): + vals = [r.catastrophic_rate.get(t, 0.0) for r in results] + avg_catastrophic[str(t)] = round(sum(vals) / len(vals), 6) if vals else 0.0 + + # Heatmap moyenne (longueur = max des longueurs) + if results and results[0].heatmap: + n_bins = len(results[0].heatmap) + heatmap_avg = [] + for b in range(n_bins): + vals = [r.heatmap[b] for r in results if b < len(r.heatmap)] + heatmap_avg.append(round(sum(vals) / len(vals), 6) if vals else 0.0) + else: + heatmap_avg = [] + + return { + "gini_mean": round(sum(gini_values) / len(gini_values), 6), + "gini_stdev": round(_stats.stdev(gini_values), 6) if len(gini_values) > 1 else 0.0, + "mean_cer_mean": round(sum(mean_cer_values) / len(mean_cer_values), 6), + "percentiles": avg_percentiles, + "catastrophic_rate": avg_catastrophic, + "heatmap": heatmap_avg, + "document_count": len(results), + } diff --git a/picarones/measurements/longitudinal.py b/picarones/measurements/longitudinal.py new file mode 100644 index 0000000000000000000000000000000000000000..26fe91c4530a99793c87e35fef81ffb5716df174 --- /dev/null +++ b/picarones/measurements/longitudinal.py @@ -0,0 +1,373 @@ +"""Métriques longitudinales — Sprint 92 (A.II.9). + +Sprint 92 — A.II.9 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +L'historique SQLite (`core/history.py`, Sprint 8) collecte les +résultats de chaque run de benchmark, mais aucune métrique +n'en sortait dans le rapport. Ce module exploite la série +temporelle des CER d'un moteur pour répondre à deux +questions : + +1. **Y a-t-il une tendance ?** Régression linéaire simple + (méthode des moindres carrés) sur ``(t, CER)`` — pente, + ordonnée à l'origine, R², n_runs. Une pente > 0 signale + une régression progressive ; une pente < 0 une amélioration. + +2. **Y a-t-il un point de rupture ?** Algorithme de + change-point pur Python (différence de moyennes maximale, + variante de Pettitt simplifiée). Identifie l'index où la + série se sépare en deux segments avec moyennes les plus + différentes — typiquement le run où un modèle a changé de + comportement. + +Pas de scipy +------------ +Pour rester sans dépendance lourde, on implémente : +- la régression linéaire en pur Python (closed-form OLS) ; +- le change-point par balayage exhaustif (O(N) pour de petits + N — l'historique d'une institution dépasse rarement quelques + centaines de runs). +""" + +from __future__ import annotations + +import logging +import math +import statistics +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class LinearTrend: + """Résultat d'une régression linéaire sur une série CER.""" + slope: float + """Pente (CER par jour). Positif = régression.""" + intercept: float + """Ordonnée à l'origine.""" + r_squared: float + """Qualité de l'ajustement, ∈ [0, 1].""" + n_runs: int + """Nombre de points utilisés.""" + + def as_dict(self) -> dict: + return { + "slope": self.slope, + "intercept": self.intercept, + "r_squared": self.r_squared, + "n_runs": self.n_runs, + } + + +@dataclass +class ChangePointResult: + """Résultat d'une détection de point de rupture.""" + index: int + """Index de la rupture (0-based, le segment 1 est [0:index], + le segment 2 est [index:N]).""" + timestamp: str + """Timestamp du run à la rupture.""" + mean_before: float + mean_after: float + delta: float + """``mean_after - mean_before``. Positif = régression.""" + n_before: int + n_after: int + + def as_dict(self) -> dict: + return { + "index": self.index, + "timestamp": self.timestamp, + "mean_before": self.mean_before, + "mean_after": self.mean_after, + "delta": self.delta, + "n_before": self.n_before, + "n_after": self.n_after, + } + + +def _parse_timestamp(ts: str) -> Optional[float]: + """Parse un ISO timestamp en jour ordinal float. + + Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne + ``None`` si non parsable. + """ + if not ts: + return None + formats = ( + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ) + for fmt in formats: + try: + dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt) + return dt.toordinal() + ( + dt.hour * 3600 + dt.minute * 60 + dt.second + ) / 86400.0 + except ValueError: + continue + return None + + +def compute_linear_trend( + cer_series: Iterable[tuple[str, float]], +) -> Optional[LinearTrend]: + """Régression linéaire OLS sur une série temporelle de CER. + + Parameters + ---------- + cer_series: + Itérable de ``(timestamp_iso, cer)``. Au moins 2 points + valides requis. + + Returns + ------- + LinearTrend | None + ``None`` si moins de 2 points ou si tous les timestamps + sont identiques (variance nulle sur t). + """ + points: list[tuple[float, float]] = [] + for ts, cer in cer_series: + t = _parse_timestamp(ts) + if t is None or cer is None: + continue + try: + cer_f = float(cer) + except (TypeError, ValueError): + continue + points.append((t, cer_f)) + n = len(points) + if n < 2: + return None + xs = [p[0] for p in points] + ys = [p[1] for p in points] + x_mean = statistics.fmean(xs) + y_mean = statistics.fmean(ys) + sxx = sum((x - x_mean) ** 2 for x in xs) + sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) + if sxx == 0: + return None + slope = sxy / sxx + intercept = y_mean - slope * x_mean + syy = sum((y - y_mean) ** 2 for y in ys) + if syy == 0: + # Tous les CER sont égaux → R² mathématiquement indéfini ; + # on retourne 1.0 (parfaite "non-tendance"). + r_squared = 1.0 + else: + ss_res = sum( + (y - (slope * x + intercept)) ** 2 + for x, y in zip(xs, ys) + ) + r_squared = max(0.0, 1.0 - ss_res / syy) + return LinearTrend( + slope=slope, + intercept=intercept, + r_squared=r_squared, + n_runs=n, + ) + + +def detect_change_point( + cer_series: Iterable[tuple[str, float]], + min_segment_size: int = 3, +) -> Optional[ChangePointResult]: + """Détecte le point de rupture maximisant l'écart de moyennes. + + Algorithme : balayage des indices ``i`` où la série se + sépare en deux segments d'au moins ``min_segment_size`` + points chacun ; on retient l'index où ``|mean_after - + mean_before|`` est maximal. Variante simplifiée de Pettitt. + + Parameters + ---------- + cer_series: + Itérable de ``(timestamp_iso, cer)``. + min_segment_size: + Taille minimale des deux segments. Défaut 3. + + Returns + ------- + ChangePointResult | None + ``None`` si la série a moins de ``2 × min_segment_size`` + points valides. + """ + points: list[tuple[str, float, float]] = [] + for ts, cer in cer_series: + t = _parse_timestamp(ts) + if t is None or cer is None: + continue + try: + cer_f = float(cer) + except (TypeError, ValueError): + continue + points.append((ts, t, cer_f)) + if len(points) < 2 * min_segment_size: + return None + points.sort(key=lambda p: p[1]) + n = len(points) + best_index = -1 + best_abs_delta = -1.0 + best_delta = 0.0 + best_mean_before = 0.0 + best_mean_after = 0.0 + for i in range(min_segment_size, n - min_segment_size + 1): + before = [p[2] for p in points[:i]] + after = [p[2] for p in points[i:]] + mean_b = statistics.fmean(before) + mean_a = statistics.fmean(after) + delta = mean_a - mean_b + abs_delta = abs(delta) + if abs_delta > best_abs_delta: + best_abs_delta = abs_delta + best_index = i + best_delta = delta + best_mean_before = mean_b + best_mean_after = mean_a + if best_index < 0: + return None + return ChangePointResult( + index=best_index, + timestamp=points[best_index][0], + mean_before=best_mean_before, + mean_after=best_mean_after, + delta=best_delta, + n_before=best_index, + n_after=n - best_index, + ) + + +def compute_engine_longitudinal( + history_entries: Iterable, + engine_name: str, + corpus_name: Optional[str] = None, + *, + min_runs_for_trend: int = 3, + min_segment_size: int = 3, + change_point_threshold: float = 0.01, +) -> Optional[dict]: + """Calcule trend + change_point pour un moteur. + + Parameters + ---------- + history_entries: + Liste de ``HistoryEntry`` (ou dicts compatibles). + engine_name: + Filtre sur le nom du moteur. + corpus_name: + Filtre optionnel sur le corpus. ``None`` (défaut) : tous + les corpus. + min_runs_for_trend: + Minimum de runs pour calculer une tendance. + min_segment_size: + Taille minimale des segments pour le change-point. + change_point_threshold: + Magnitude absolue minimale du delta (en CER) pour + retenir le change-point. Défaut 0.01 (1 point de CER). + + Returns + ------- + dict | None + ``{ + "engine_name", "corpus_name", "n_runs", "trend", + "change_point", # ou None + "first_timestamp", "last_timestamp", + "first_cer", "last_cer", "absolute_delta_pct", + }`` ou ``None`` si moins de ``min_runs_for_trend`` runs. + """ + series: list[tuple[str, float]] = [] + for entry in history_entries: + if hasattr(entry, "as_dict"): + data = entry.as_dict() + else: + data = entry + if data.get("engine_name") != engine_name: + continue + if corpus_name is not None and data.get("corpus_name") != corpus_name: + continue + cer = data.get("cer_mean") + ts = data.get("timestamp") + if cer is None or ts is None: + continue + series.append((ts, float(cer))) + if len(series) < min_runs_for_trend: + return None + series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0) + trend = compute_linear_trend(series) + cp = detect_change_point(series, min_segment_size=min_segment_size) + if cp is not None and abs(cp.delta) < change_point_threshold: + cp = None + first_ts, first_cer = series[0] + last_ts, last_cer = series[-1] + return { + "engine_name": engine_name, + "corpus_name": corpus_name, + "n_runs": len(series), + "trend": trend.as_dict() if trend else None, + "change_point": cp.as_dict() if cp else None, + "first_timestamp": first_ts, + "last_timestamp": last_ts, + "first_cer": first_cer, + "last_cer": last_cer, + "absolute_delta": last_cer - first_cer, + "absolute_delta_pct": round((last_cer - first_cer) * 100, 2), + } + + +def compute_corpus_longitudinal( + history_entries: Iterable, + corpus_name: Optional[str] = None, + *, + min_runs_for_trend: int = 3, + min_segment_size: int = 3, + change_point_threshold: float = 0.01, +) -> list[dict]: + """Pour chaque moteur présent dans l'historique sur ``corpus_name``, + calcule trend + change_point. + + Returns + ------- + list[dict] + Une entrée par moteur (filtrée), liste vide si rien. + """ + entries = list(history_entries) + engines: set[str] = set() + for entry in entries: + data = entry.as_dict() if hasattr(entry, "as_dict") else entry + if corpus_name is not None and data.get("corpus_name") != corpus_name: + continue + name = data.get("engine_name") + if name: + engines.add(name) + out: list[dict] = [] + for engine in sorted(engines): + result = compute_engine_longitudinal( + entries, engine, corpus_name=corpus_name, + min_runs_for_trend=min_runs_for_trend, + min_segment_size=min_segment_size, + change_point_threshold=change_point_threshold, + ) + if result is not None: + out.append(result) + return out + + +__all__ = [ + "LinearTrend", + "ChangePointResult", + "compute_linear_trend", + "detect_change_point", + "compute_engine_longitudinal", + "compute_corpus_longitudinal", +] + + +# Marqueur d'évitement d'import inutilisé (math) +_ = math diff --git a/picarones/measurements/marginal_cost.py b/picarones/measurements/marginal_cost.py new file mode 100644 index 0000000000000000000000000000000000000000..4d1c59bf324ede3d6bf0e2fcf91c59d9dae9d0de --- /dev/null +++ b/picarones/measurements/marginal_cost.py @@ -0,0 +1,142 @@ +"""Coût marginal par erreur évitée — Sprint 91 (A.II.6 chantier 2). + +Sprint 91 — A.II.6 chantier 2 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +La vue Pareto (Sprint 20) trace CER vs coût mais n'arbitre pas +quel surcoût est *raisonnable* pour quelle réduction d'erreur. +Une institution avec un budget contraint a besoin d'une +réponse opérationnelle : + + *« Passer de Tesseract à Mistral OCR coûte 0,83 € par + erreur évitée — décider selon votre budget par millier + d'erreurs corrigées. »* + +Formule +------- +Pour deux moteurs A et B où B fait **moins** d'erreurs que A +(donc B est plus précis) : + +.. code:: + + coût_marginal = (coût_B − coût_A) / (errors_A − errors_B) + +- Si ``cost_B > cost_A`` et ``errors_B < errors_A`` : + ``cost_per_avoided_error > 0`` (cas standard, B coûte plus + pour moins d'erreurs). +- Si ``cost_B ≤ cost_A`` et ``errors_B < errors_A`` : + ``cost_per_avoided_error ≤ 0`` (cas idéal, B est strictement + meilleur). +- Si ``errors_B ≥ errors_A`` : non comparable dans ce sens + (B n'évite pas d'erreur), retourne ``None``. + +Sortie +------ +``compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)`` +retourne ``{cost_per_avoided_error, n_errors_avoided, +cost_delta, dominated}`` ou ``None`` si non comparable. + +``compute_marginal_cost_matrix(per_engine)`` retourne, pour +chaque paire ordonnée ``(A → B)`` où B est plus précis, le +coût marginal correspondant. Trié par coût marginal croissant +(meilleur ratio en tête). +""" + +from __future__ import annotations + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def compute_marginal_cost( + cost_a: float, + errors_a: float, + cost_b: float, + errors_b: float, +) -> Optional[dict]: + """Coût marginal du passage A → B (B plus précis). + + Retourne ``None`` si : + - ``errors_b >= errors_a`` (B n'évite pas d'erreur) ; + - les valeurs ne sont pas finies. + """ + try: + ca = float(cost_a) + cb = float(cost_b) + ea = float(errors_a) + eb = float(errors_b) + except (TypeError, ValueError): + return None + if ea <= eb: + # B ne fait pas mieux que A → pas de gain à mesurer. + return None + n_avoided = ea - eb + cost_delta = cb - ca + cost_per_avoided = cost_delta / n_avoided + dominated = cost_delta <= 0 # B aussi cher ou moins → cas idéal + return { + "cost_per_avoided_error": cost_per_avoided, + "n_errors_avoided": n_avoided, + "cost_delta": cost_delta, + "dominated": dominated, + } + + +def compute_marginal_cost_matrix( + per_engine: dict[str, dict], +) -> Optional[dict]: + """Pour chaque paire A → B où B fait moins d'erreurs, calcule + le coût marginal. + + Parameters + ---------- + per_engine: + Map ``{engine_name: {"cost": float, "errors": float}}``. + + Returns + ------- + dict | None + ``{ + "pairs": list[ + {"engine_a", "engine_b", "cost_per_avoided_error", + "n_errors_avoided", "cost_delta", "dominated"} + ], # triée par cost_per_avoided_error croissant + }`` + ou ``None`` si moins de 2 moteurs. + """ + if not per_engine or len(per_engine) < 2: + return None + engines = sorted(per_engine.keys()) + pairs: list[dict] = [] + for a in engines: + for b in engines: + if a == b: + continue + data_a = per_engine[a] + data_b = per_engine[b] + try: + ca = float(data_a.get("cost")) + ea = float(data_a.get("errors")) + cb = float(data_b.get("cost")) + eb = float(data_b.get("errors")) + except (TypeError, ValueError): + continue + result = compute_marginal_cost(ca, ea, cb, eb) + if result is None: + continue + entry = {"engine_a": a, "engine_b": b} + entry.update(result) + pairs.append(entry) + if not pairs: + return None + pairs.sort(key=lambda p: p["cost_per_avoided_error"]) + return {"pairs": pairs} + + +__all__ = [ + "compute_marginal_cost", + "compute_marginal_cost_matrix", +] diff --git a/picarones/measurements/narrative/__init__.py b/picarones/measurements/narrative/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..12b7ce32122e4d51748a79c79e99220951cdcc81 --- /dev/null +++ b/picarones/measurements/narrative/__init__.py @@ -0,0 +1,78 @@ +"""Moteur narratif factuel — génération de synthèse déterministe. + +Extrait des faits saillants d'un ``BenchmarkResult`` et les rend en phrases +courtes via des templates externes YAML. Aucun LLM : chaque nombre ou nom +apparaissant dans la synthèse est traçable au JSON de résultats en entrée. + +API publique +------------ +- ``Fact``, ``FactType``, ``FactImportance`` : modèle de données +- ``DetectorRegistry`` : registre des détecteurs +- ``detect_all(data)`` : applique le registre par défaut +- ``select_facts(facts, max_facts=5)`` : arbitre de sélection +- ``render_synthesis(facts, lang="fr")`` : rend en liste de phrases +- ``build_synthesis(data, lang="fr")`` : pipeline complet (Sprint 4) +""" + +from picarones.measurements.narrative.facts import ( + Fact, + FactType, + FactImportance, + DetectorRegistry, + detect_all, + _DEFAULT_REGISTRY, +) +from picarones.measurements.narrative.arbiter import select_facts +from picarones.measurements.narrative.renderer import ( + render_fact, + render_synthesis, + extract_numbers, +) +from picarones.measurements.narrative.detectors import ( + register_default_detectors, + DETECTORS_BY_TYPE, +) + + +# Activer le registre par défaut — Sprint 4 +register_default_detectors(_DEFAULT_REGISTRY) + + +def build_synthesis( + benchmark_data: dict, + lang: str = "fr", + max_facts: int = 5, +) -> dict: + """Pipeline complet : détection → arbitre → rendu. + + Returns + ------- + dict avec : + - ``sentences`` : liste de phrases prêtes à l'affichage + - ``facts`` : liste de dicts ``Fact.as_dict()`` pour traçabilité + - ``lang`` : langue utilisée + """ + all_facts = detect_all(benchmark_data) + selected = select_facts(all_facts, max_facts=max_facts) + sentences = render_synthesis(selected, lang=lang) + return { + "sentences": sentences, + "facts": [f.as_dict() for f in selected], + "lang": lang, + } + + +__all__ = [ + "Fact", + "FactType", + "FactImportance", + "DetectorRegistry", + "detect_all", + "select_facts", + "render_fact", + "render_synthesis", + "extract_numbers", + "build_synthesis", + "register_default_detectors", + "DETECTORS_BY_TYPE", +] diff --git a/picarones/measurements/narrative/arbiter.py b/picarones/measurements/narrative/arbiter.py new file mode 100644 index 0000000000000000000000000000000000000000..1b0625d3afcf7e0ff670ea28667344b3d867060e --- /dev/null +++ b/picarones/measurements/narrative/arbiter.py @@ -0,0 +1,227 @@ +"""Arbitre de sélection des faits narratifs. + +L'arbitre transforme une liste potentiellement longue de ``Fact`` détectés +en une synthèse courte (3 à 5 phrases) adaptée à l'ouverture du rapport. + +Règles de sélection : + 1. Tri par importance décroissante, puis par type (ordre canonique). + 2. Non-redondance : un seul fait par moteur, sauf si les types sont + complémentaires (ex. ``GLOBAL_LEADER_CER`` + ``SIGNIFICANT_GAP`` + concernent le leader mais apportent une information différente). + 3. Limite : au maximum ``max_facts`` faits retenus (défaut 5). + 4. Déterminisme : tri stable sur (−importance, ordre canonique du type, + noms des moteurs) pour garantir une sortie bit-à-bit identique. + +Les détecteurs peuvent émettre plusieurs faits du même type (ex. plusieurs +``STATISTICAL_TIE`` si plusieurs groupes distincts). L'arbitre ne fusionne +pas mais peut limiter par type. +""" + +from __future__ import annotations + +from typing import Iterable, Sequence + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType + + +# Ordre canonique des types pour départager les ex-aequo à l'importance égale. +# +# Politique éditoriale — exposée et documentée dans +# ``docs/developer/narrative-engine.md`` § Editorial policy. +# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont +# la même ``FactImportance``. Surchargeable via le paramètre ``type_order`` +# de ``select_facts`` sans patcher le code. +# +# Sprint 29 : la valeur n'est plus codée en dur ici — elle est dérivée du +# registre déclaratif (``@register_detector(..., priority=N)``). Ajouter +# un détecteur en bonne position se fait donc en éditant **un seul** +# fichier (``detectors.py``) au lieu de quatre comme avant. +def _compute_default_type_order() -> tuple[FactType, ...]: + # Import local pour éviter la dépendance circulaire au chargement. + from picarones.measurements.narrative.registry import default_type_order + order = default_type_order() + # Filet de sécurité : tant que les détecteurs n'ont pas été importés + # (cas des tests qui mockent le registre), on retombe sur un ordre + # canonique gravé pour ne pas planter ``select_facts``. + if not order: + return _FALLBACK_TYPE_ORDER + return order + + +# Ordre statique gardé en mémoire : utilisé si jamais le registre est vide +# au moment où ``arbiter`` est chargé (chargement partiel par les tests). +_FALLBACK_TYPE_ORDER: tuple[FactType, ...] = ( + FactType.GLOBAL_LEADER_CER, + FactType.STATISTICAL_TIE, + FactType.SIGNIFICANT_GAP, + FactType.STRATUM_WINNER, + # Sprint 46 — priority 45, juste après STRATUM_WINNER (40), + # avant STRATUM_COLLAPSE (50). La recommandation de stratification + # nuance directement les autres faits par strate. + FactType.STRATIFICATION_RECOMMENDED, + FactType.STRATUM_COLLAPSE, + FactType.ERROR_PROFILE_OUTLIER, + FactType.LLM_HALLUCINATION_FLAG, + FactType.ROBUSTNESS_FRAGILE, + FactType.PARETO_ALTERNATIVE, + FactType.SPEED_WINNER, + FactType.COST_OUTLIER, + FactType.CONFIDENCE_WARNING, + FactType.ENSEMBLE_OPPORTUNITY, + FactType.MEDIAN_MEAN_GAP_WARNING, + # Sprint 73 — priority 150, après MEDIAN_MEAN_GAP_WARNING (140). + # Le détecteur off-baseline donne le contexte historique, qui + # vient en fin de synthèse comme « note ». + FactType.ENGINE_OFF_BASELINE, + # Sprint 90 — priority 160, ferme la synthèse avec la mise en + # garde sur la reproductibilité. Une instabilité multi-runs + # discrédite toute autre conclusion sur ce moteur ; on la + # remonte en dernier pour ne pas l'enterrer. + FactType.ENGINE_UNSTABLE, + # Sprint 92 — priority 170, après ENGINE_UNSTABLE. La + # régression historique complète A.I.3 (off-baseline) en + # caractérisant la tendance : l'écart courant est-il une + # dégradation graduelle, une rupture brutale, ou un bruit ? + FactType.REGRESSION_IN_HISTORY, +) + + +# ``DEFAULT_TYPE_ORDER`` reste un attribut module accessible. On le calcule +# à l'import si possible, sinon on prend le fallback ; ``select_facts`` +# recalcule à chaque appel pour absorber les ajouts de détecteurs après +# l'import initial (extensions tierces). +DEFAULT_TYPE_ORDER: tuple[FactType, ...] = _compute_default_type_order() + +# Alias rétro-compatible. +_TYPE_ORDER = DEFAULT_TYPE_ORDER +_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)} + + +# Paires de types qui ne sont PAS considérées comme redondantes même quand +# elles concernent le même moteur. Tout autre couple → un seul fait retenu +# pour le moteur (le plus important). +_COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({ + frozenset({FactType.GLOBAL_LEADER_CER, FactType.SIGNIFICANT_GAP}), + frozenset({FactType.GLOBAL_LEADER_CER, FactType.SPEED_WINNER}), + frozenset({FactType.GLOBAL_LEADER_CER, FactType.CONFIDENCE_WARNING}), + frozenset({FactType.STATISTICAL_TIE, FactType.SPEED_WINNER}), + # Sprint 44 — l'avertissement d'asymétrie nuance le leader + # plutôt que de le doubler : on veut les deux phrases ensemble. + frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}), + # Sprint 46 — la recommandation de stratification est un méta-conseil + # qui s'ajoute au leader sans le contredire ; les deux peuvent + # cohabiter même quand ils concernent le même moteur. + frozenset({FactType.GLOBAL_LEADER_CER, FactType.STRATIFICATION_RECOMMENDED}), + # Sprint 90 — l'instabilité multi-runs nuance les conclusions + # sur le moteur leader sans les contredire : un moteur peut être + # leader **et** instable, et c'est précisément l'information + # critique pour la reproductibilité scientifique. + frozenset({FactType.GLOBAL_LEADER_CER, FactType.ENGINE_UNSTABLE}), + # Sprint 92 — la régression historique caractérise la tendance + # du leader : un leader peut être en régression progressive, + # info critique pour décider quand re-tester. + frozenset({FactType.GLOBAL_LEADER_CER, FactType.REGRESSION_IN_HISTORY}), + # Off-baseline (Sprint 73) dit "écart anormal sur ce corpus" ; + # regression-in-history (Sprint 92) dit "tendance dans le + # temps" — les deux se complètent sans se redonder. + frozenset({FactType.ENGINE_OFF_BASELINE, FactType.REGRESSION_IN_HISTORY}), +}) + + +def _sort_key(fact: Fact, type_index: dict[FactType, int]) -> tuple: + """Clé de tri stable : importance (desc), type canonique, moteurs.""" + return ( + -int(fact.importance), + type_index.get(fact.type, len(type_index)), + tuple(sorted(fact.engines_involved)), + fact.stratum or "", + ) + + +def _is_redundant(candidate: Fact, kept: Fact) -> bool: + """Vrai si ``candidate`` apporte trop peu par rapport à ``kept``. + + Deux faits sont redondants s'ils concernent exactement le même moteur, + ont le même type, et la même strate (s'il y en a une). Des types + différents sur le même moteur ne sont considérés redondants que s'ils + n'appartiennent pas aux paires complémentaires (ex : un leader peut + aussi être rapide ; c'est complémentaire). + """ + if candidate.type == kept.type and candidate.stratum == kept.stratum: + return set(candidate.engines_involved) == set(kept.engines_involved) + if set(candidate.engines_involved) == set(kept.engines_involved): + pair = frozenset({candidate.type, kept.type}) + return pair not in _COMPLEMENTARY_PAIRS + return False + + +def _remove_contradictions(facts: list[Fact]) -> list[Fact]: + """Supprime les faits incohérents sur le plan statistique. + + Règle centrale : si Nemenyi (post-hoc corrigé pour comparaisons multiples) + place deux moteurs dans le même groupe d'ex-aequo, alors un ``SIGNIFICANT_GAP`` + basé sur Wilcoxon non corrigé entre ces deux mêmes moteurs est trompeur + pour un lecteur non statisticien. Nemenyi l'emporte. + """ + tied_groups: list[set[str]] = [] + for f in facts: + if f.type == FactType.STATISTICAL_TIE: + tied_groups.append(set(f.engines_involved)) + + def _is_contradicted(fact: Fact) -> bool: + if fact.type != FactType.SIGNIFICANT_GAP: + return False + pair = set(fact.engines_involved) + return any(pair <= group for group in tied_groups) + + return [f for f in facts if not _is_contradicted(f)] + + +def select_facts( + facts: Iterable[Fact], + max_facts: int = 5, + min_importance: FactImportance = FactImportance.MEDIUM, + type_order: Sequence[FactType] | None = None, +) -> list[Fact]: + """Sélectionne la synthèse finale à partir d'une liste brute de faits. + + Parameters + ---------- + facts: + Liste de ``Fact`` brute issue de ``DetectorRegistry.run``. + max_facts: + Nombre maximal de faits retenus (défaut : 5). + min_importance: + Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut. + type_order: + Surcharge optionnelle de l'ordre canonique des types pour départager + les faits d'égale importance. ``None`` (défaut) utilise + ``DEFAULT_TYPE_ORDER``. Une institution peut passer son propre ordre + sans patcher le code — voir ``docs/developer/narrative-engine.md``. + + Returns + ------- + Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``. + """ + if type_order is None: + # Sprint 29 — recalcul à chaque appel pour absorber les détecteurs + # enregistrés après l'import d'arbiter (extensions tierces qui + # font ``@register_detector`` dans un module utilisateur). + from picarones.measurements.narrative.registry import default_type_order + live_order = default_type_order() or _FALLBACK_TYPE_ORDER + type_index = {t: i for i, t in enumerate(live_order)} + else: + type_index = {t: i for i, t in enumerate(type_order)} + + facts_list = [f for f in facts if int(f.importance) >= int(min_importance)] + facts_list = _remove_contradictions(facts_list) + ranked = sorted(facts_list, key=lambda f: _sort_key(f, type_index)) + + selected: list[Fact] = [] + for fact in ranked: + if any(_is_redundant(fact, kept) for kept in selected): + continue + selected.append(fact) + if len(selected) >= max_facts: + break + return selected diff --git a/picarones/measurements/narrative/detectors/__init__.py b/picarones/measurements/narrative/detectors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cc71abb34614697b6e11e8af7eba2ecd1503b1c1 --- /dev/null +++ b/picarones/measurements/narrative/detectors/__init__.py @@ -0,0 +1,129 @@ +"""Détecteurs narratifs — package thématique (chantier 5 post-Sprint 97). + +Avant le chantier 5, ce module était un fichier monolithe de 1229 lignes +(``narrative/detectors.py``) contenant 18 détecteurs. Pour aligner la +structure de code avec celle du registre déclaratif (Sprint 29), les +détecteurs ont été regroupés par **famille thématique** : + +- :mod:`ranking` — global leader, statistical tie, significant gap, + speed winner, median/mean gap warning (5 détecteurs) +- :mod:`pareto` — Pareto alternative, cost outlier (2 détecteurs) +- :mod:`stratum` — stratum winner / collapse, stratification + recommended (3 détecteurs) +- :mod:`quality` — error profile outlier, LLM hallucination flag, + robustness fragile, confidence warning (4 détecteurs) +- :mod:`history` — engine off baseline, engine unstable, regression + in history (3 détecteurs) +- :mod:`ensemble` — ensemble opportunity (1 détecteur) + +Total : 18 détecteurs (≠ "12" mentionné dans CLAUDE.md historique — +le chantier 5 corrige ce comptage). + +Rétrocompatibilité absolue +-------------------------- +Tous les noms exportés par l'ancien fichier ``detectors.py`` +(``detect_*``, ``DETECTORS_BY_TYPE``, ``register_default_detectors``) +restent accessibles via ``from picarones.measurements.narrative.detectors +import ...``. Les tests Sprints 20, 23, 29, 36, 44, 46, 73 importent +directement ces noms et continuent à fonctionner sans modification. + +L'enregistrement automatique des détecteurs via ``@register_detector`` +se fait à l'import de ce package — chaque sous-module est importé ici +en cascade. +""" + +from __future__ import annotations + +# Imports en cascade des 6 sous-modules : déclenche l'enregistrement +# automatique via les décorateurs ``@register_detector`` au chargement. +from picarones.measurements.narrative.detectors.ranking import ( + detect_global_leader_cer, + detect_median_mean_gap_warning, + detect_significant_gap, + detect_speed_winner, + detect_statistical_tie, +) +from picarones.measurements.narrative.detectors.pareto import ( + detect_cost_outlier, + detect_pareto_alternative, +) +from picarones.measurements.narrative.detectors.stratum import ( + detect_stratification_recommended, + detect_stratum_collapse, + detect_stratum_winner, +) +from picarones.measurements.narrative.detectors.quality import ( + detect_confidence_warning, + detect_error_profile_outlier, + detect_llm_hallucination_flag, + detect_robustness_fragile, +) +from picarones.measurements.narrative.detectors.history import ( + detect_engine_off_baseline, + detect_engine_unstable, + detect_regression_in_history, +) +from picarones.measurements.narrative.detectors.ensemble import ( + detect_ensemble_opportunity, +) + +# Snapshot du registre + helper d'enregistrement legacy — déplacés +# verbatim depuis l'ancien ``detectors.py`` (lignes 1193-1229). +from picarones.measurements.narrative.facts import DetectorFn, FactType +from picarones.measurements.narrative.registry import ( + iter_detectors as _iter_detectors, + populate_legacy_registry as _populate_legacy_registry, +) + + +def _build_detectors_by_type() -> dict[FactType, DetectorFn]: + """Snapshot du registre déclaratif vers un dict ``{type: fn}``.""" + return {entry.fact_type: entry.fn for entry in _iter_detectors()} + + +# Vue figée à l'import — utile pour les tests qui parcourent les types +# enregistrés sans instancier un ``DetectorRegistry``. +DETECTORS_BY_TYPE = _build_detectors_by_type() + + +def register_default_detectors(registry) -> None: + """Enregistre les détecteurs du registre déclaratif dans un + ``DetectorRegistry`` historique. + + Sprint 29 : la source de vérité est maintenant le décorateur + ``@register_detector`` ; cette fonction se contente de pousser + le contenu du registre vers l'objet ``DetectorRegistry`` que les + consommateurs externes (``DetectorRegistry.run``) instancient. + """ + _populate_legacy_registry(registry) + + +__all__ = [ + # ranking + "detect_global_leader_cer", + "detect_median_mean_gap_warning", + "detect_significant_gap", + "detect_speed_winner", + "detect_statistical_tie", + # pareto + "detect_cost_outlier", + "detect_pareto_alternative", + # stratum + "detect_stratification_recommended", + "detect_stratum_collapse", + "detect_stratum_winner", + # quality + "detect_confidence_warning", + "detect_error_profile_outlier", + "detect_llm_hallucination_flag", + "detect_robustness_fragile", + # history + "detect_engine_off_baseline", + "detect_engine_unstable", + "detect_regression_in_history", + # ensemble + "detect_ensemble_opportunity", + # legacy + "DETECTORS_BY_TYPE", + "register_default_detectors", +] diff --git a/picarones/measurements/narrative/detectors/_helpers.py b/picarones/measurements/narrative/detectors/_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..ea669174f360b5295814d52e346b6a8491504606 --- /dev/null +++ b/picarones/measurements/narrative/detectors/_helpers.py @@ -0,0 +1,55 @@ +"""Helpers internes partagés par les détecteurs narratifs. + +Chantier 5 du plan d'évolution post-Sprint 97 — découpage de +``picarones/core/narrative/detectors.py`` (1229 lignes, 18 détecteurs) +en 6 sous-modules thématiques + ce module d'helpers communs. + +Ces fonctions étaient privées (préfixe ``_``) au module historique. +Elles sont conservées telles quelles ici ; les sous-modules les +importent. +""" + +from __future__ import annotations + +from typing import Optional + + +def _engines_summary(data: dict) -> list[dict]: + """Accès normalisé à la liste des résumés moteur.""" + return data.get("engines", []) or [] + + +def _engine_by_name(data: dict, name: str) -> Optional[dict]: + for e in _engines_summary(data): + if e.get("name") == name: + return e + return None + + +def _n_docs(data: dict) -> int: + meta = data.get("meta", {}) or {} + return int(meta.get("document_count") or 0) + + +def _mean_duration_per_engine(data: dict) -> dict[str, float]: + """Retourne ``{engine_name: mean_duration_seconds}`` quand disponible. + + Lit ``benchmark_data["engines"][i]["mean_duration"]`` (renseigné + par le runner depuis ``durations_by_engine`` Sprint 4). Filtre les + durées non-numériques. + """ + out: dict[str, float] = {} + for e in _engines_summary(data): + name = e.get("name") + if not name: + continue + dur = e.get("mean_duration") + if dur is None: + continue + try: + dur_f = float(dur) + except (TypeError, ValueError): + continue + if dur_f > 0: + out[name] = dur_f + return out diff --git a/picarones/measurements/narrative/detectors/ensemble.py b/picarones/measurements/narrative/detectors/ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..0a34f597d20c57ca0b79e481cd18832b982bc062 --- /dev/null +++ b/picarones/measurements/narrative/detectors/ensemble.py @@ -0,0 +1,96 @@ +"""Détecteurs narratifs liés à l'*opportunité d'ensemble inter-moteurs* (chantier 5). + +1 détecteur déplacé depuis ``narrative/detectors.py`` : + +- :func:`detect_ensemble_opportunity` (Sprint 36) +""" + +from __future__ import annotations + +import statistics as _stats +from typing import Optional + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType +from picarones.measurements.narrative.registry import register_detector + +from picarones.measurements.narrative.detectors._helpers import ( + _engine_by_name, + _engines_summary, + _n_docs, +) + + +@register_detector( + FactType.ENSEMBLE_OPPORTUNITY, + priority=130, + importance=FactImportance.MEDIUM, +) +def detect_ensemble_opportunity(benchmark_data: dict) -> list[Fact]: + """Deux moteurs très complémentaires : un voting majoritaire entre eux + pourrait améliorer significativement le CER token-level. + + Lit la structure ``inter_engine_analysis`` produite par le runner + (Sprint 35-36) et déclenche si la fraction d'erreurs du meilleur + moteur récupérable par un ensemble dépasse 25 %. + + L'importance monte à ``HIGH`` quand le gap relatif dépasse 50 % + (ensemble franchement profitable) — sinon reste à ``MEDIUM``. + """ + iea = benchmark_data.get("inter_engine_analysis") or {} + comp = iea.get("complementarity") or {} + if not comp: + return [] + + relative_gap = float(comp.get("relative_gap") or 0.0) + if relative_gap < 0.25: + # En deçà de 25 %, l'ensemble n'apporterait quasi rien — on ne + # remonte pas le fait pour ne pas bruiter la synthèse. + return [] + + best_engine = comp.get("best_engine") or "" + if not best_engine: + return [] + + payload: dict = { + "best_engine": best_engine, + "best_recall_pct": round(float(comp.get("best_single_recall") or 0.0) * 100, 2), + "oracle_recall_pct": round(float(comp.get("oracle_recall") or 0.0) * 100, 2), + "absolute_gap_pct": round(float(comp.get("absolute_gap") or 0.0) * 100, 2), + "relative_gap_pct": round(relative_gap * 100, 1), + "doc_count": int(comp.get("doc_count") or 0), + } + + # Paire la plus complémentaire — la divergence taxonomique, quand + # disponible, fournit deux moteurs « candidats naturels ». Sinon on + # tombe sur le best + le second-best en recall individuel. + div = iea.get("taxonomy_divergence") or {} + pair = div.get("max_pair") or [] + pair_a = "" + pair_b = "" + divergence_value: Optional[float] = None + if pair and len(pair) >= 3 and isinstance(pair[2], (int, float)) and pair[2] > 0: + pair_a, pair_b, divergence_value = str(pair[0]), str(pair[1]), float(pair[2]) + else: + # Fallback : best engine + second-best engine par recall individuel + per_engine = comp.get("per_engine_recall") or {} + if len(per_engine) >= 2: + ranked = sorted(per_engine.items(), key=lambda kv: kv[1], reverse=True) + pair_a, pair_b = ranked[0][0], ranked[1][0] + + payload["pair_a"] = pair_a + payload["pair_b"] = pair_b + payload["divergence"] = round(divergence_value, 3) if divergence_value is not None else 0.0 + payload["divergence_metric"] = (div.get("metric") or "js") + + importance = ( + FactImportance.HIGH if relative_gap >= 0.5 else FactImportance.MEDIUM + ) + engines_involved: tuple[str, ...] = ( + (pair_a, pair_b) if pair_a and pair_b else (best_engine,) + ) + return [Fact( + type=FactType.ENSEMBLE_OPPORTUNITY, + importance=importance, + payload=payload, + engines_involved=engines_involved, + )] diff --git a/picarones/measurements/narrative/detectors/history.py b/picarones/measurements/narrative/detectors/history.py new file mode 100644 index 0000000000000000000000000000000000000000..e9bf2a7912b176c6c1fe317223e5c87d0701ad19 --- /dev/null +++ b/picarones/measurements/narrative/detectors/history.py @@ -0,0 +1,280 @@ +"""Détecteurs narratifs liés à *l'historique SQLite + multi-runs* (chantier 5). + +3 détecteurs déplacés depuis ``narrative/detectors.py`` : + +- :func:`detect_engine_off_baseline` (Sprint 73) +- :func:`detect_engine_unstable` (Sprint 90) +- :func:`detect_regression_in_history` (Sprint 92) +""" + +from __future__ import annotations + +import statistics as _stats +from typing import Optional + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType +from picarones.measurements.narrative.registry import register_detector + +from picarones.measurements.narrative.detectors._helpers import ( + _engine_by_name, + _engines_summary, + _n_docs, +) + + +@register_detector( + FactType.ENGINE_OFF_BASELINE, + priority=150, + importance=FactImportance.MEDIUM, +) +def detect_engine_off_baseline(benchmark_data: dict) -> list[Fact]: + """Émet un Fact pour chaque moteur dont le CER courant s'écarte + significativement de sa moyenne historique sur le **même corpus**. + + Lit ``benchmark_data["baseline_comparisons"]`` (liste de dicts + produits par ``compute_engine_baseline`` du module + ``baseline_comparison`` Sprint 73). Si la clé est absente ou + vide, le détecteur reste silencieux — typiquement le cas quand + aucun historique SQLite n'a été chargé. + + Garde-fous : + + - Si ``n_runs < 5`` (déjà filtré par ``compute_engine_baseline`` + qui retourne ``None`` dans ce cas). + - Si ``relative_delta`` n'est pas calculable (baseline = 0). + - Importance ``HIGH`` si ``|relative_delta| ≥ 50 %``, sinon + ``MEDIUM``. + """ + comparisons = benchmark_data.get("baseline_comparisons") or [] + if not isinstance(comparisons, (list, tuple)): + return [] + facts: list[Fact] = [] + for comp in comparisons: + if not isinstance(comp, dict): + continue + if not comp.get("off_baseline"): + continue + rel = comp.get("relative_delta") + if rel is None: + continue + engine = comp.get("engine_name") + cer_current = comp.get("cer_current") + cer_hist_mean = comp.get("cer_historical_mean") + n_runs = comp.get("n_runs") + if engine is None or cer_current is None or cer_hist_mean is None: + continue + importance = ( + FactImportance.HIGH if abs(float(rel)) >= 0.50 + else FactImportance.MEDIUM + ) + facts.append(Fact( + type=FactType.ENGINE_OFF_BASELINE, + importance=importance, + payload={ + "engine": engine, + "cer_current_pct": round(float(cer_current) * 100, 2), + "cer_historical_mean_pct": round( + float(cer_hist_mean) * 100, 2, + ), + "n_runs": int(n_runs or 0), + "relative_delta_pct": round(float(rel) * 100, 1), + "direction": "higher" if float(rel) > 0 else "lower", + }, + engines_involved=(engine,), + )) + return facts + + +@register_detector( + FactType.ENGINE_UNSTABLE, + priority=160, + importance=FactImportance.HIGH, +) +def detect_engine_unstable(benchmark_data: dict) -> list[Fact]: + """Émet un Fact pour chaque moteur dont la stabilité multi-runs + est insuffisante (Sprint 83 + 90). + + Lit ``benchmark_data["multirun_stability"]`` : liste de dicts + avec ``engine_name`` + champs de ``compute_multirun_stability`` + (cer_cv, identical_run_rate, n_runs, etc.). Si la clé est + absente ou vide, le détecteur reste silencieux — typiquement + le cas quand l'utilisateur n'a pas exécuté `--repeats N`. + + Garde-fous : + + - ``n_runs ≥ 2`` (déjà filtré par + ``compute_multirun_stability`` qui retourne ``None``). + - Déclenche si ``cer_cv > 0.10`` (variance relative > 10 % du + CER moyen) **ou** ``identical_run_rate < 0.50`` (moins + d'une paire de runs sur deux est identique). + - Importance ``HIGH`` (l'instabilité discrédite les + conclusions). + """ + stabilities = benchmark_data.get("multirun_stability") or [] + if not isinstance(stabilities, (list, tuple)): + return [] + facts: list[Fact] = [] + for stab in stabilities: + if not isinstance(stab, dict): + continue + engine = stab.get("engine_name") or stab.get("engine") + if not engine: + continue + n_runs = stab.get("n_runs") + if not isinstance(n_runs, int) or n_runs < 2: + continue + cer_cv = stab.get("cer_cv") + identical_rate = stab.get("identical_run_rate") + # Critères de déclenchement + cv_high = ( + isinstance(cer_cv, (int, float)) and float(cer_cv) > 0.10 + ) + runs_diverge = ( + isinstance(identical_rate, (int, float)) + and float(identical_rate) < 0.50 + ) + if not (cv_high or runs_diverge): + continue + payload: dict = { + "engine": engine, + "n_runs": int(n_runs), + } + if isinstance(cer_cv, (int, float)): + payload["cer_cv"] = float(cer_cv) + payload["cer_cv_pct"] = round(float(cer_cv) * 100, 1) + if isinstance(identical_rate, (int, float)): + payload["identical_run_rate"] = float(identical_rate) + payload["identical_run_rate_pct"] = round( + float(identical_rate) * 100, 1, + ) + # Champs additionnels pour la phrase de synthèse + cer_mean = stab.get("cer_mean") + cer_stdev = stab.get("cer_stdev") + if isinstance(cer_mean, (int, float)): + payload["cer_mean_pct"] = round(float(cer_mean) * 100, 2) + if isinstance(cer_stdev, (int, float)): + payload["cer_stdev_pct"] = round(float(cer_stdev) * 100, 2) + n_distinct = stab.get("n_distinct_outputs") + if isinstance(n_distinct, int): + payload["n_distinct_outputs"] = int(n_distinct) + facts.append(Fact( + type=FactType.ENGINE_UNSTABLE, + importance=FactImportance.HIGH, + payload=payload, + engines_involved=(engine,), + )) + return facts + + +@register_detector( + FactType.REGRESSION_IN_HISTORY, + priority=170, + importance=FactImportance.MEDIUM, +) +def detect_regression_in_history(benchmark_data: dict) -> list[Fact]: + """Émet un Fact pour chaque moteur dont l'historique montre + une dégradation : pente positive significative ou rupture + brutale (Sprint 92). + + Lit ``benchmark_data["longitudinal_trends"]`` : liste de + dicts produits par ``compute_corpus_longitudinal`` du module + ``longitudinal``. Si la clé est absente ou vide, le + détecteur reste silencieux — typiquement le cas quand + aucun historique n'a été chargé ou que la série est trop + courte. + + Garde-fous : + + - ``n_runs ≥ 3`` (déjà filtré par + ``compute_engine_longitudinal``). + - Déclenche si **soit** ``trend.slope`` traduit une + régression d'au moins ``slope_threshold`` (en CER/jour, + défaut équivalent à +1 point CER sur 365 jours), **soit** + ``change_point.delta > change_threshold`` (défaut + 0.01 = +1 point de CER d'un segment à l'autre). + - Importance ``HIGH`` si la dégradation cumulée + ``absolute_delta`` ≥ 5 points de CER. + """ + trends = benchmark_data.get("longitudinal_trends") or [] + if not isinstance(trends, (list, tuple)): + return [] + slope_threshold = ( + 0.01 / 365.0 # +1 point de CER sur 365 jours minimum + ) + change_threshold = 0.01 + facts: list[Fact] = [] + for entry in trends: + if not isinstance(entry, dict): + continue + engine = entry.get("engine_name") + if not engine: + continue + n_runs = entry.get("n_runs") + if not isinstance(n_runs, int) or n_runs < 3: + continue + trend = entry.get("trend") or {} + cp = entry.get("change_point") + slope = trend.get("slope") + slope_high = ( + isinstance(slope, (int, float)) + and float(slope) > slope_threshold + ) + cp_high = ( + isinstance(cp, dict) + and isinstance(cp.get("delta"), (int, float)) + and float(cp["delta"]) > change_threshold + ) + if not (slope_high or cp_high): + continue + absolute_delta = entry.get("absolute_delta") or 0.0 + importance = ( + FactImportance.HIGH + if isinstance(absolute_delta, (int, float)) + and abs(float(absolute_delta)) >= 0.05 + else FactImportance.MEDIUM + ) + payload: dict = { + "engine": engine, + "n_runs": int(n_runs), + "absolute_delta_pct": round( + float(absolute_delta) * 100, 2, + ) if isinstance(absolute_delta, (int, float)) else 0.0, + "first_cer_pct": round( + float(entry.get("first_cer") or 0.0) * 100, 2, + ), + "last_cer_pct": round( + float(entry.get("last_cer") or 0.0) * 100, 2, + ), + } + if slope_high: + payload["slope_per_year_pct"] = round( + float(slope) * 365 * 100, 2, + ) + payload["r_squared"] = round( + float(trend.get("r_squared") or 0.0), 3, + ) + payload["pattern"] = "trend" + if cp_high: + payload["change_point_timestamp"] = str( + cp.get("timestamp") or "?", + ) + payload["change_delta_pct"] = round( + float(cp["delta"]) * 100, 2, + ) + payload["mean_before_pct"] = round( + float(cp.get("mean_before") or 0.0) * 100, 2, + ) + payload["mean_after_pct"] = round( + float(cp.get("mean_after") or 0.0) * 100, 2, + ) + # Si on a aussi une rupture, le pattern domine + payload["pattern"] = ( + "trend_and_change_point" if slope_high else "change_point" + ) + facts.append(Fact( + type=FactType.REGRESSION_IN_HISTORY, + importance=importance, + payload=payload, + engines_involved=(engine,), + )) + return facts diff --git a/picarones/measurements/narrative/detectors/pareto.py b/picarones/measurements/narrative/detectors/pareto.py new file mode 100644 index 0000000000000000000000000000000000000000..6f0a73db2eac796775159585d04931a710d29bc8 --- /dev/null +++ b/picarones/measurements/narrative/detectors/pareto.py @@ -0,0 +1,136 @@ +"""Détecteurs narratifs orientés *coût/performance Pareto* (chantier 5). + +2 détecteurs déplacés depuis ``narrative/detectors.py`` : + +- :func:`detect_pareto_alternative` (Sprint 19) — alternative coût/qualité +- :func:`detect_cost_outlier` (Sprint 19) — moteur dont le coût est aberrant +""" + +from __future__ import annotations + +import statistics as _stats +from typing import Optional + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType +from picarones.measurements.narrative.registry import register_detector + +from picarones.measurements.narrative.detectors._helpers import ( + _engine_by_name, + _engines_summary, + _n_docs, +) + + +@register_detector( + FactType.PARETO_ALTERNATIVE, + priority=90, + importance=FactImportance.HIGH, +) +def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]: + """Moteur Pareto-dominant différent du leader CER. + + Lit ``benchmark_data["pareto"]["cost"]`` (Sprint 19) et émet un Fact si + la frontière contient un moteur autre que le leader CER, pour souligner + l'existence d'un compromis coût/qualité intéressant. + """ + pareto = (benchmark_data.get("pareto") or {}).get("cost") or {} + front = pareto.get("front") or [] + points = pareto.get("points") or [] + if len(front) < 2: + return [] + + ranking = benchmark_data.get("ranking") or [] + if not ranking: + return [] + leader = ranking[0].get("engine") + + # Le moteur le moins cher sur le front (hors leader) + alt: Optional[dict] = None + for p in points: + if p.get("engine") == leader: + continue + if p.get("engine") not in front: + continue + if alt is None or float(p.get("cost") or 0.0) < float(alt.get("cost") or 0.0): + alt = p + if alt is None: + return [] + + leader_point = next((p for p in points if p.get("engine") == leader), None) + if leader_point is None: + return [] + + alt_cer = float(alt.get("cer") or 0.0) + alt_cost = float(alt.get("cost") or 0.0) + leader_cer = float(leader_point.get("cer") or 0.0) + leader_cost = float(leader_point.get("cost") or 0.0) + if alt_cost >= leader_cost or alt_cost <= 0: + return [] # pas réellement moins cher — pas intéressant à remonter + + return [Fact( + type=FactType.PARETO_ALTERNATIVE, + importance=FactImportance.HIGH, + payload={ + "engine": alt["engine"], + "leader": leader, + "cer": round(alt_cer, 4), + "cer_pct": round(alt_cer * 100, 2), + "cost": round(alt_cost, 2), + "leader_cer": round(leader_cer, 4), + "leader_cer_pct": round(leader_cer * 100, 2), + "leader_cost": round(leader_cost, 2), + "cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None, + "delta_cer_pct": round((alt_cer - leader_cer) * 100, 2), + # Unité du coût — propagée pour traçabilité (le template ne + # hardcode plus "1000 pages"). + "cost_unit_pages": 1000, + }, + engines_involved=(alt["engine"],), + )] + + +@register_detector( + FactType.COST_OUTLIER, + priority=110, + importance=FactImportance.MEDIUM, +) +def detect_cost_outlier(benchmark_data: dict) -> list[Fact]: + """Moteur dont le coût est très disproportionné par rapport à son apport. + + Flag un moteur dont le coût ≥ 5× la médiane ET qui n'est pas sur le + front Pareto (donc dominé par moins cher OU meilleur CER). + """ + pareto = (benchmark_data.get("pareto") or {}).get("cost") or {} + points = pareto.get("points") or [] + front = set(pareto.get("front") or []) + if len(points) < 3: + return [] + + costs = [float(p["cost"]) for p in points if p.get("cost") is not None] + if not costs: + return [] + median_cost = _stats.median(costs) + if median_cost <= 0: + return [] + + facts: list[Fact] = [] + for p in points: + c = float(p.get("cost") or 0.0) + if c < 5.0 * median_cost: + continue + if p["engine"] in front: + continue # sur le front → coût justifié par une qualité unique + facts.append(Fact( + type=FactType.COST_OUTLIER, + importance=FactImportance.MEDIUM, + payload={ + "engine": p["engine"], + "cost": round(c, 2), + "median_cost": round(median_cost, 2), + "ratio_to_median": round(c / median_cost, 1), + "cer_pct": round(float(p.get("cer") or 0.0) * 100, 2), + "cost_unit_pages": 1000, + }, + engines_involved=(p["engine"],), + )) + return facts diff --git a/picarones/measurements/narrative/detectors/quality.py b/picarones/measurements/narrative/detectors/quality.py new file mode 100644 index 0000000000000000000000000000000000000000..7c2e49dfee0d277a0fef4201757a11bf3cc53e86 --- /dev/null +++ b/picarones/measurements/narrative/detectors/quality.py @@ -0,0 +1,251 @@ +"""Détecteurs narratifs liés à la *qualité texte / fiabilité* (chantier 5). + +4 détecteurs déplacés depuis ``narrative/detectors.py`` : + +- :func:`detect_error_profile_outlier` (Sprint 4) +- :func:`detect_llm_hallucination_flag` (Sprint 4) +- :func:`detect_robustness_fragile` (Sprint 4) +- :func:`detect_confidence_warning` (Sprint 4) +""" + +from __future__ import annotations + +import statistics as _stats +from typing import Optional + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType +from picarones.measurements.narrative.registry import register_detector + +from picarones.measurements.narrative.detectors._helpers import ( + _engine_by_name, + _engines_summary, + _n_docs, +) + + +@register_detector( + FactType.ERROR_PROFILE_OUTLIER, + priority=60, + importance=FactImportance.MEDIUM, +) +def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]: + """Moteur au profil taxonomique atypique. + + Émet un Fact si, pour un moteur et une classe d'erreur, la part relative + est au moins 2× plus élevée que la médiane des autres moteurs (et > 15 % + du total pour éviter les strates marginales). + """ + engines = _engines_summary(benchmark_data) + # {engine: {class_name: proportion}} + profiles: dict[str, dict[str, float]] = {} + for e in engines: + tax = e.get("aggregated_taxonomy") or {} + distribution = tax.get("distribution") or tax.get("proportions") or {} + if not distribution: + continue + profiles[e["name"]] = {k: float(v) for k, v in distribution.items()} + if len(profiles) < 2: + return [] + + # Collecter toutes les classes rencontrées + all_classes: set[str] = set() + for p in profiles.values(): + all_classes.update(p.keys()) + + facts: list[Fact] = [] + for cls in all_classes: + values = [(name, p.get(cls, 0.0)) for name, p in profiles.items()] + props = [v for _, v in values] + if not props: + continue + median_prop = _stats.median(props) + for name, v in values: + if v < 0.15: # trop marginal pour être notable + continue + if median_prop <= 0: + continue + if v >= 2.0 * median_prop: + facts.append(Fact( + type=FactType.ERROR_PROFILE_OUTLIER, + importance=FactImportance.HIGH, + payload={ + "engine": name, + "error_class": cls, + "proportion": round(v, 4), + "proportion_pct": round(v * 100, 1), + "median_proportion": round(median_prop, 4), + "median_proportion_pct": round(median_prop * 100, 1), + "ratio_to_median": round(v / median_prop, 2) if median_prop else None, + }, + engines_involved=(name,), + )) + return facts + + +@register_detector( + FactType.LLM_HALLUCINATION_FLAG, + priority=70, + importance=FactImportance.HIGH, +) +def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]: + """LLM/VLM au taux d'hallucination notablement élevé. + + Déclenché si ``hallucinating_doc_rate`` > 30 % OU ``anchor_score_mean`` < 0,6 + pour un moteur dont le champ ``is_pipeline`` ou ``is_vlm`` est ``True``. + """ + facts: list[Fact] = [] + for e in _engines_summary(benchmark_data): + agg = e.get("aggregated_hallucination") or {} + if not agg: + continue + rate = agg.get("hallucinating_doc_rate") + anchor = agg.get("anchor_score_mean") + length_ratio = agg.get("length_ratio_mean") + # Signal seulement si c'est un pipeline LLM ou un VLM + is_llm = bool(e.get("is_pipeline")) or bool(e.get("is_vlm")) + if not is_llm: + continue + + flagged = False + reasons = [] + if rate is not None and float(rate) > 0.30: + flagged = True + reasons.append("taux de documents hallucinés") + if anchor is not None and float(anchor) < 0.60: + flagged = True + reasons.append("ancrage faible") + if length_ratio is not None and float(length_ratio) > 1.30: + flagged = True + reasons.append("sortie anormalement longue") + if not flagged: + continue + + facts.append(Fact( + type=FactType.LLM_HALLUCINATION_FLAG, + importance=FactImportance.HIGH, + payload={ + "engine": e["name"], + "hallucinating_rate": round(float(rate or 0.0), 4), + "hallucinating_rate_pct": round(float(rate or 0.0) * 100, 1), + "anchor_score": round(float(anchor), 3) if anchor is not None else None, + "length_ratio": round(float(length_ratio), 3) if length_ratio is not None else None, + "reasons": reasons, + "reasons_list": ", ".join(reasons), + }, + engines_involved=(e["name"],), + )) + return facts + + +@register_detector( + FactType.ROBUSTNESS_FRAGILE, + priority=80, + importance=FactImportance.MEDIUM, +) +def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]: + """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou. + + Activé si les données de robustesse sont embarquées dans + ``benchmark_data["robustness"]`` (hors scope du benchmark classique, + produit par ``picarones robustness`` et injecté optionnellement). + """ + robustness = benchmark_data.get("robustness") + if not robustness: + return [] + + facts: list[Fact] = [] + curves = robustness.get("curves") or robustness.get("engines") or [] + # Structure attendue : [{engine, degradation_type, points: [{level, cer}]}] + # Flag : CER à niveau max > 3× CER au niveau min. + for entry in curves: + engine = entry.get("engine") + dtype = entry.get("degradation_type") + points = entry.get("points") or [] + if not engine or not points or len(points) < 2: + continue + try: + sorted_pts = sorted(points, key=lambda p: float(p["level"])) + except (KeyError, TypeError, ValueError): + continue + first, last = sorted_pts[0], sorted_pts[-1] + c0 = float(first.get("cer") or 0.0) + c1 = float(last.get("cer") or 0.0) + if c0 <= 0.01: # éviter division par quasi-zéro + continue + if c1 >= 3.0 * c0 and c1 > 0.15: + facts.append(Fact( + type=FactType.ROBUSTNESS_FRAGILE, + importance=FactImportance.HIGH, + payload={ + "engine": engine, + "degradation": dtype, + "cer_baseline": round(c0, 4), + "cer_baseline_pct": round(c0 * 100, 1), + "cer_degraded": round(c1, 4), + "cer_degraded_pct": round(c1 * 100, 1), + "ratio": round(c1 / c0, 1), + "level_max": float(last.get("level") or 0), + }, + engines_involved=(engine,), + )) + return facts + + +@register_detector( + FactType.CONFIDENCE_WARNING, + priority=120, + importance=FactImportance.MEDIUM, +) +def detect_confidence_warning(benchmark_data: dict) -> list[Fact]: + """Intervalle de confiance large → classement peu fiable. + + Déclenché si, pour le leader ou le runner-up, la largeur de l'IC 95 % + est plus du triple de l'écart |leader − runner-up| OU > 5 points de CER. + """ + stats = benchmark_data.get("statistics", {}) or {} + cis = stats.get("bootstrap_cis") or [] + if len(cis) < 2: + return [] + + ranking = benchmark_data.get("ranking") or [] + valid = [r for r in ranking if r.get("mean_cer") is not None] + if len(valid) < 2: + return [] + + by_name = {c["engine"]: c for c in cis if "engine" in c} + leader = valid[0]["engine"] + runner_up = valid[1]["engine"] + leader_ci = by_name.get(leader) + runner_ci = by_name.get(runner_up) + if not leader_ci or not runner_ci: + return [] + + gap = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"])) + facts: list[Fact] = [] + for engine_name, ci in ((leader, leader_ci), (runner_up, runner_ci)): + lo = float(ci.get("ci_lower") or 0.0) + hi = float(ci.get("ci_upper") or 0.0) + width = hi - lo + wide_vs_gap = gap > 0 and width > 3.0 * gap + wide_absolute = width > 0.05 + if wide_vs_gap or wide_absolute: + facts.append(Fact( + type=FactType.CONFIDENCE_WARNING, + importance=FactImportance.MEDIUM, + payload={ + "engine": engine_name, + "ci_lower": round(lo, 4), + "ci_upper": round(hi, 4), + "ci_width": round(width, 4), + "ci_width_pct": round(width * 100, 2), + "mean_cer": round(float(ci.get("mean") or 0.0), 4), + "mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2), + "gap_to_runner_up_pct": round(gap * 100, 2), + # Niveau de confiance des bornes — propagé pour traçabilité + # anti-hallucination (le template ne hardcode plus "95 %"). + "confidence_level": 95, + }, + engines_involved=(engine_name,), + )) + break # un seul avertissement suffit + return facts diff --git a/picarones/measurements/narrative/detectors/ranking.py b/picarones/measurements/narrative/detectors/ranking.py new file mode 100644 index 0000000000000000000000000000000000000000..4910591229a0809128025610dead58ad305011b7 --- /dev/null +++ b/picarones/measurements/narrative/detectors/ranking.py @@ -0,0 +1,280 @@ +"""Détecteurs narratifs orientés *classement* (chantier 5). + +5 détecteurs déplacés depuis ``narrative/detectors.py`` : + +- :func:`detect_global_leader_cer` (Sprint 4) +- :func:`detect_statistical_tie` (Sprint 18) +- :func:`detect_significant_gap` (Sprint 4) +- :func:`detect_speed_winner` (Sprint 4) +- :func:`detect_median_mean_gap_warning` (Sprint 44) + +Comportement et signature inchangés. Tous restent enregistrés +automatiquement via ``@register_detector`` à l'import. +""" + +from __future__ import annotations + +import statistics as _stats +from typing import Optional + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType +from picarones.measurements.narrative.registry import register_detector + +from picarones.measurements.narrative.detectors._helpers import ( + _engine_by_name, + _engines_summary, + _mean_duration_per_engine, + _n_docs, +) + + +@register_detector( + FactType.GLOBAL_LEADER_CER, + priority=10, + importance=FactImportance.CRITICAL, +) +def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]: + """Moteur avec le CER moyen le plus bas sur l'ensemble du corpus. + + Émet un Fact CRITICAL si au moins 2 moteurs sont comparés, en attachant + aussi le 2ᵉ pour permettre à l'arbitre de fusionner avec ``significant_gap``. + """ + ranking = benchmark_data.get("ranking") or [] + # Éliminer les entrées sans CER calculé + valid = [r for r in ranking if r.get("mean_cer") is not None] + if len(valid) < 1: + return [] + + leader = valid[0] + runner_up = valid[1] if len(valid) >= 2 else None + + payload = { + "engine": leader["engine"], + "cer": float(leader["mean_cer"]), + "cer_pct": round(float(leader["mean_cer"]) * 100, 2), + "n_engines": len(valid), + "n_docs": _n_docs(benchmark_data), + } + if runner_up is not None: + payload["runner_up"] = runner_up["engine"] + payload["runner_up_cer"] = float(runner_up["mean_cer"]) + payload["runner_up_cer_pct"] = round(float(runner_up["mean_cer"]) * 100, 2) + + return [Fact( + type=FactType.GLOBAL_LEADER_CER, + importance=FactImportance.CRITICAL, + payload=payload, + engines_involved=(leader["engine"],), + )] + + +@register_detector( + FactType.STATISTICAL_TIE, + priority=20, + importance=FactImportance.CRITICAL, +) +def detect_statistical_tie(benchmark_data: dict) -> list[Fact]: + """Groupes de moteurs statistiquement indiscernables (Nemenyi).""" + nemenyi = benchmark_data.get("statistics", {}).get("nemenyi", {}) + if not nemenyi or nemenyi.get("error"): + return [] + + tied_groups = nemenyi.get("tied_groups", []) + mean_ranks = nemenyi.get("mean_ranks", {}) + cd = nemenyi.get("critical_distance", 0.0) + alpha = nemenyi.get("alpha", 0.05) + n_blocks = nemenyi.get("n_blocks", 0) + + facts: list[Fact] = [] + for group in tied_groups: + if len(group) < 2: + continue + is_leader_tie = min(mean_ranks.get(n, 999) for n in group) == min( + mean_ranks.values(), default=0 + ) + importance = FactImportance.CRITICAL if is_leader_tie else FactImportance.HIGH + + facts.append(Fact( + type=FactType.STATISTICAL_TIE, + importance=importance, + payload={ + "engines": list(group), + "engines_list": ", ".join(group), + "mean_ranks": {n: mean_ranks.get(n) for n in group}, + "critical_distance": round(cd, 3), + "alpha": alpha, + "n_blocks": n_blocks, + "includes_leader": is_leader_tie, + "n_tied": len(group), + }, + engines_involved=tuple(group), + )) + return facts + + +@register_detector( + FactType.SIGNIFICANT_GAP, + priority=30, + importance=FactImportance.HIGH, +) +def detect_significant_gap(benchmark_data: dict) -> list[Fact]: + """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement. + + Lit la matrice de Wilcoxon pairwise et vérifie si la paire (leader, + runner-up) y apparaît avec ``significant = True``. + """ + ranking = benchmark_data.get("ranking") or [] + valid = [r for r in ranking if r.get("mean_cer") is not None] + if len(valid) < 2: + return [] + + leader = valid[0]["engine"] + runner_up = valid[1]["engine"] + + pairwise = benchmark_data.get("statistics", {}).get("pairwise_wilcoxon") or [] + match = None + for p in pairwise: + names = {p.get("engine_a"), p.get("engine_b")} + if names == {leader, runner_up}: + match = p + break + if match is None: + return [] + + if not match.get("significant"): + return [] # pas d'écart significatif — rien à signaler ici + + delta_cer = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"])) + return [Fact( + type=FactType.SIGNIFICANT_GAP, + importance=FactImportance.CRITICAL, + payload={ + "leader": leader, + "runner_up": runner_up, + "p_value": float(match.get("p_value", 0.0)), + "delta_cer": round(delta_cer, 4), + "delta_cer_pct": round(delta_cer * 100, 2), + "n_pairs": int(match.get("n_pairs", 0)), + }, + engines_involved=(leader, runner_up), + )] + + +@register_detector( + FactType.SPEED_WINNER, + priority=100, + importance=FactImportance.MEDIUM, +) +def detect_speed_winner(benchmark_data: dict) -> list[Fact]: + """Moteur significativement plus rapide pour une qualité comparable. + + Déclenché si un moteur est au moins 3× plus rapide que la médiane ET que + son CER n'est pas significativement pire (dans le même groupe Nemenyi que + le leader OU CER ≤ 1,1 × CER du leader). + """ + durations = _mean_duration_per_engine(benchmark_data) + if len(durations) < 2: + return [] + + values = list(durations.values()) + median_dur = _stats.median(values) + if median_dur <= 0: + return [] + + ranking = benchmark_data.get("ranking") or [] + valid = [r for r in ranking if r.get("mean_cer") is not None] + if not valid: + return [] + leader_cer = float(valid[0]["mean_cer"]) + quality_ceiling = max(0.01, leader_cer * 1.10) + + tied_groups = benchmark_data.get("statistics", {}).get("nemenyi", {}).get("tied_groups") or [] + leader_group: set[str] = set() + for g in tied_groups: + if valid[0]["engine"] in g: + leader_group = set(g) + break + + facts: list[Fact] = [] + candidates = sorted(durations.items(), key=lambda kv: kv[1]) + for engine, dur in candidates: + if dur * 3.0 > median_dur: + break # les suivants sont encore plus lents + summary = _engine_by_name(benchmark_data, engine) or {} + engine_cer = summary.get("cer") + if engine_cer is None: + continue + acceptable_quality = ( + engine in leader_group or float(engine_cer) <= quality_ceiling + ) + if not acceptable_quality: + continue + facts.append(Fact( + type=FactType.SPEED_WINNER, + importance=FactImportance.MEDIUM, + payload={ + "engine": engine, + "mean_duration": round(dur, 3), + "median_duration": round(median_dur, 3), + "speedup": round(median_dur / dur, 1) if dur > 0 else None, + "cer": round(float(engine_cer), 4), + "cer_pct": round(float(engine_cer) * 100, 2), + }, + engines_involved=(engine,), + )) + return facts[:1] # seulement le plus rapide — éviter le bruit + + +@register_detector( + FactType.MEDIAN_MEAN_GAP_WARNING, + priority=140, + importance=FactImportance.MEDIUM, +) +def detect_median_mean_gap_warning(benchmark_data: dict) -> list[Fact]: + """Avertit quand le ratio ``|moyenne - médiane| / médiane`` du leader + dépasse 30 %, ce qui indique une distribution fortement asymétrique + où la moyenne masque les performances réelles. + + Sprint 44 — A.I.2 du plan d'évolution. Cohérent avec le passage du + tri par défaut sur la médiane : si la moyenne du leader diverge + fortement de la médiane, l'utilisateur doit le savoir pour + interpréter correctement les chiffres. + """ + ranking = benchmark_data.get("ranking") or [] + valid = [ + r for r in ranking + if r.get("median_cer") is not None + and r.get("mean_cer") is not None + ] + if not valid: + return [] + + leader = valid[0] + median_cer = float(leader["median_cer"]) + mean_cer = float(leader["mean_cer"]) + + if median_cer <= 0: + # Médiane nulle (corpus très facile pour ce moteur) — l'écart + # relatif n'est pas calculable de manière utile, on s'abstient. + return [] + + relative_gap = abs(mean_cer - median_cer) / median_cer + if relative_gap < 0.30: + return [] + + importance = ( + FactImportance.HIGH if relative_gap >= 1.0 else FactImportance.MEDIUM + ) + + return [Fact( + type=FactType.MEDIAN_MEAN_GAP_WARNING, + importance=importance, + payload={ + "engine": leader["engine"], + "median_cer_pct": round(median_cer * 100, 2), + "mean_cer_pct": round(mean_cer * 100, 2), + "relative_gap_pct": round(relative_gap * 100, 1), + "n_docs": int(leader.get("documents") or 0), + }, + engines_involved=(leader["engine"],), + )] diff --git a/picarones/measurements/narrative/detectors/stratum.py b/picarones/measurements/narrative/detectors/stratum.py new file mode 100644 index 0000000000000000000000000000000000000000..63d3952008a146e00bc0d3a6941ad92f647f5ff5 --- /dev/null +++ b/picarones/measurements/narrative/detectors/stratum.py @@ -0,0 +1,203 @@ +"""Détecteurs narratifs liés à la *stratification corpus* (chantier 5). + +3 détecteurs + 1 helper (``_stratum_cer_by_engine``) déplacés depuis +``narrative/detectors.py`` : + +- :func:`detect_stratum_winner` (Sprint 4) +- :func:`detect_stratum_collapse` (Sprint 4) +- :func:`detect_stratification_recommended` (Sprint 45) +""" + +from __future__ import annotations + +import statistics as _stats +from typing import Optional + +from picarones.measurements.narrative.facts import Fact, FactImportance, FactType +from picarones.measurements.narrative.registry import register_detector + +from picarones.measurements.narrative.detectors._helpers import ( + _engine_by_name, + _engines_summary, + _n_docs, +) + + +def _stratum_cer_by_engine(benchmark_data: dict) -> dict[str, dict[str, list[float]]]: + """Agrège les CER par (moteur, strate). + + Strate = ``document["script_type"]`` si présent. Retourne ``{}`` si aucun + document n'expose de strate (pas d'émission possible). + """ + out: dict[str, dict[str, list[float]]] = {} + for doc in benchmark_data.get("documents") or []: + stratum = doc.get("script_type") + if not stratum: + continue + for er in doc.get("engine_results") or []: + if er.get("error"): + continue + cer = er.get("cer") + if cer is None: + continue + name = er.get("engine") + out.setdefault(name, {}).setdefault(stratum, []).append(float(cer)) + return out + + +@register_detector( + FactType.STRATUM_WINNER, + priority=40, + importance=FactImportance.MEDIUM, +) +def detect_stratum_winner(benchmark_data: dict) -> list[Fact]: + """Moteur qui domine nettement sur une strate (≥ 3 documents, CER + au moins 25 % plus bas que le second sur cette strate). + """ + agg = _stratum_cer_by_engine(benchmark_data) + if not agg: + return [] + + # Inverser : {stratum: {engine: mean_cer}} + by_stratum: dict[str, dict[str, float]] = {} + for engine, strata in agg.items(): + for stratum, vals in strata.items(): + if len(vals) < 3: + continue + by_stratum.setdefault(stratum, {})[engine] = sum(vals) / len(vals) + + facts: list[Fact] = [] + for stratum, engine_cer in by_stratum.items(): + if len(engine_cer) < 2: + continue + ordered = sorted(engine_cer.items(), key=lambda kv: kv[1]) + best_name, best_cer = ordered[0] + second_cer = ordered[1][1] + if second_cer == 0: + continue + if best_cer < second_cer * 0.75: # dominance ≥ 25 % + facts.append(Fact( + type=FactType.STRATUM_WINNER, + importance=FactImportance.HIGH, + payload={ + "engine": best_name, + "stratum": stratum, + "cer": round(best_cer, 4), + "cer_pct": round(best_cer * 100, 2), + "second_engine": ordered[1][0], + "second_cer": round(second_cer, 4), + "second_cer_pct": round(second_cer * 100, 2), + "n_docs_stratum": len(agg[best_name][stratum]), + }, + engines_involved=(best_name,), + stratum=stratum, + )) + return facts + + +@register_detector( + FactType.STRATUM_COLLAPSE, + priority=50, + importance=FactImportance.HIGH, +) +def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]: + """Moteur globalement compétitif qui s'effondre sur une strate. + + Déclenché si, pour un moteur, le CER moyen sur une strate ≥ 3 documents + est plus du double du CER global du même moteur. + """ + agg = _stratum_cer_by_engine(benchmark_data) + if not agg: + return [] + + facts: list[Fact] = [] + for engine_name, strata in agg.items(): + summary = _engine_by_name(benchmark_data, engine_name) or {} + global_cer = summary.get("cer") + if global_cer is None: + continue + global_cer = float(global_cer) + if global_cer <= 0: + continue + for stratum, vals in strata.items(): + if len(vals) < 3: + continue + local_cer = sum(vals) / len(vals) + if local_cer > 2.0 * global_cer and (local_cer - global_cer) > 0.05: + facts.append(Fact( + type=FactType.STRATUM_COLLAPSE, + importance=FactImportance.HIGH, + payload={ + "engine": engine_name, + "stratum": stratum, + "local_cer": round(local_cer, 4), + "local_cer_pct": round(local_cer * 100, 2), + "global_cer": round(global_cer, 4), + "global_cer_pct": round(global_cer * 100, 2), + "delta_cer_pct": round((local_cer - global_cer) * 100, 2), + "n_docs_stratum": len(vals), + }, + engines_involved=(engine_name,), + stratum=stratum, + )) + return facts + + +@register_detector( + FactType.STRATIFICATION_RECOMMENDED, + priority=45, # juste après STRATUM_WINNER (40), avant STRATUM_COLLAPSE (50) + importance=FactImportance.HIGH, +) +def detect_stratification_recommended(benchmark_data: dict) -> list[Fact]: + """Avertit quand le corpus est hétérogène et que la vue stratifiée + apporte un éclairage qualitativement différent du classement global. + + Critère : ``corpus_homogeneity.max_inter_strata_gap > 5 points`` de + CER médian sur le moteur leader. Au-delà de 10 points, importance + ``HIGH`` (situation très hétérogène où le seul classement global + serait trompeur). + + Lit ``benchmark_data["corpus_homogeneity"]`` exposé par + ``BenchmarkResult.as_dict()`` (Sprint 45). + """ + homog = benchmark_data.get("corpus_homogeneity") + if not homog: + return [] + + gap = homog.get("max_inter_strata_gap") + if gap is None: + return [] + + gap = float(gap) + if gap < 0.05: + return [] # 5 points de CER : seuil de pertinence éditoriale + + leader = str(homog.get("leader") or "") + n_strata = int(homog.get("n_strata") or 0) + pair = homog.get("leader_max_gap_strata") or ["", ""] + if len(pair) < 2: + return [] + min_strat, max_strat = str(pair[0]), str(pair[1]) + + leader_per_stratum = homog.get("leader_per_stratum_median") or {} + min_med = float(leader_per_stratum.get(min_strat, 0.0)) + max_med = float(leader_per_stratum.get(max_strat, 0.0)) + + importance = ( + FactImportance.HIGH if gap >= 0.10 else FactImportance.MEDIUM + ) + + return [Fact( + type=FactType.STRATIFICATION_RECOMMENDED, + importance=importance, + payload={ + "leader": leader, + "n_strata": n_strata, + "gap_pct": round(gap * 100, 1), + "min_stratum": min_strat, + "max_stratum": max_strat, + "min_stratum_cer_pct": round(min_med * 100, 2), + "max_stratum_cer_pct": round(max_med * 100, 2), + }, + engines_involved=(leader,) if leader else (), + )] diff --git a/picarones/measurements/narrative/facts.py b/picarones/measurements/narrative/facts.py new file mode 100644 index 0000000000000000000000000000000000000000..f06c2289173a203616ea64b658a4ae21368b15af --- /dev/null +++ b/picarones/measurements/narrative/facts.py @@ -0,0 +1,212 @@ +"""Modèle de données du moteur narratif. + +Un ``Fact`` est une observation structurée extraite d'un ``BenchmarkResult``. +Chaque détecteur retourne zéro, un ou plusieurs ``Fact`` typés. L'arbitre +(Sprint 4) trie par ``importance`` et sélectionne les faits à afficher. + +Règle d'or (à vérifier par tests) : chaque valeur numérique ou nom d'entité +présent dans ``payload`` doit provenir directement du JSON d'entrée, jamais +d'une génération. C'est ce qui rend la synthèse reproductible bit-à-bit et +immune à l'hallucination par construction. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, Optional + + +class FactType(str, Enum): + """Types de faits détectables. + + L'ajout d'un nouveau type se fait ici + un détecteur dans ``detectors.py`` + + un template dans ``narrative/templates_{lang}.yaml`` (Sprint 4). + """ + + GLOBAL_LEADER_CER = "global_leader_cer" + """Moteur avec le CER médian le plus bas sur l'ensemble du corpus.""" + + STATISTICAL_TIE = "statistical_tie" + """Top-N moteurs statistiquement indiscernables (Nemenyi, Sprint 3).""" + + SIGNIFICANT_GAP = "significant_gap" + """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement.""" + + PARETO_ALTERNATIVE = "pareto_alternative" + """Moteur sur la frontière Pareto différent du leader CER pur (Sprint 5).""" + + STRATUM_WINNER = "stratum_winner" + """Moteur qui domine sur une strate spécifique (siècle, langue, type).""" + + STRATUM_COLLAPSE = "stratum_collapse" + """Moteur globalement bon qui s'effondre sur une strate spécifique.""" + + ERROR_PROFILE_OUTLIER = "error_profile_outlier" + """Moteur avec un profil taxonomique atypique (ex : 3× plus d'erreurs d'abréviation).""" + + LLM_HALLUCINATION_FLAG = "llm_hallucination_flag" + """LLM avec un taux d'hallucination notablement supérieur aux autres.""" + + ROBUSTNESS_FRAGILE = "robustness_fragile" + """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou.""" + + COST_OUTLIER = "cost_outlier" + """Moteur au ratio coût/qualité très défavorable (Sprint 5).""" + + SPEED_WINNER = "speed_winner" + """Moteur significativement plus rapide pour une qualité comparable.""" + + CONFIDENCE_WARNING = "confidence_warning" + """Intervalle de confiance très large : classement peu fiable.""" + + ENSEMBLE_OPPORTUNITY = "ensemble_opportunity" + """Deux moteurs sont fortement complémentaires : un voting majoritaire + pourrait améliorer significativement le CER (Sprint 36).""" + + MEDIAN_MEAN_GAP_WARNING = "median_mean_gap_warning" + """Distribution des CER fortement asymétrique sur le corpus — + la moyenne du leader est tirée par quelques documents catastrophiques + et masque les performances réelles. La médiane (utilisée pour le tri + par défaut depuis Sprint 44) est plus représentative.""" + + STRATIFICATION_RECOMMENDED = "stratification_recommended" + """Le corpus est hétérogène du point de vue script_type : le moteur + leader varie fortement selon la strate. Le lecteur doit consulter + la vue stratifiée plutôt que de se fier au seul classement global + (Sprint 46).""" + + ENGINE_OFF_BASELINE = "engine_off_baseline" + """Le CER courant d'un moteur s'écarte significativement de sa + moyenne historique sur le même corpus (lue depuis l'historique + SQLite, Sprint 8). Lit ``BenchmarkHistory`` via le module + ``baseline_comparison`` (Sprint 73). Garde-fous : ≥ 5 runs + historiques même corpus + |delta_relatif| > 20 %.""" + + ENGINE_UNSTABLE = "engine_unstable" + """Un moteur LLM/VLM exécuté plusieurs fois sur les mêmes + documents produit des sorties différentes au-delà d'un seuil + de variance (Sprint 90). Lit ``compute_multirun_stability`` + (Sprint 83). Garde-fous : ≥ 2 runs et seuil sur le coefficient + de variation du CER (>10 % par défaut) ou sur le rappel de + runs identiques (<50 %).""" + + REGRESSION_IN_HISTORY = "regression_in_history" + """Un moteur montre une tendance ou une rupture défavorable + sur l'historique SQLite : son CER moyen s'est dégradé sur + les N derniers runs (Sprint 92). Lit + ``compute_corpus_longitudinal`` du module ``longitudinal``. + Garde-fous : ≥ 3 runs historiques et soit pente > seuil + (régression progressive), soit change-point avec delta > + seuil (rupture brutale).""" + + +class FactImportance(int, Enum): + """Score d'importance d'un fait — décide l'ordre et la sélection.""" + + CRITICAL = 100 + """À remonter systématiquement en synthèse (ex : leader + écart significatif).""" + + HIGH = 70 + """À remonter sauf si déjà redondant avec un fait critique.""" + + MEDIUM = 40 + """À remonter si la synthèse a encore de la place.""" + + LOW = 10 + """Informatif, remonté uniquement en vue détaillée.""" + + +@dataclass +class Fact: + """Observation structurée extraite d'un benchmark. + + Attributes + ---------- + type: + Type de fait (voir ``FactType``). + importance: + Priorité de sélection (voir ``FactImportance``). + payload: + Dict de données brutes sérialisables. **Toutes les valeurs doivent + provenir du JSON d'entrée** — c'est le garde-fou anti-hallucination. + engines_involved: + Noms des moteurs concernés. Utilisé par l'arbitre pour détecter + les redondances (deux faits sur le même moteur = fusion ou sélection). + stratum: + Strate concernée (ex : "XVIIe siècle", "latin médiéval") ou None. + """ + + type: FactType + importance: FactImportance + payload: dict + engines_involved: tuple[str, ...] = () + stratum: Optional[str] = None + + def as_dict(self) -> dict: + return { + "type": self.type.value, + "importance": int(self.importance), + "payload": self.payload, + "engines_involved": list(self.engines_involved), + "stratum": self.stratum, + } + + +# --------------------------------------------------------------------------- +# Registre de détecteurs +# --------------------------------------------------------------------------- + +# Signature d'un détecteur : prend le dict JSON du benchmark, retourne une liste +# de Fact (potentiellement vide). Doit être pure et déterministe. +DetectorFn = Callable[[dict], list[Fact]] + + +@dataclass +class DetectorRegistry: + """Registre central des détecteurs de faits. + + Un détecteur est enregistré via ``register(fact_type, fn)``. ``detect_all`` + appelle tous les détecteurs enregistrés et renvoie la liste consolidée. + """ + + _detectors: dict[FactType, DetectorFn] = field(default_factory=dict) + + def register(self, fact_type: FactType, fn: DetectorFn) -> None: + self._detectors[fact_type] = fn + + def unregister(self, fact_type: FactType) -> None: + self._detectors.pop(fact_type, None) + + def registered_types(self) -> tuple[FactType, ...]: + return tuple(self._detectors.keys()) + + def run(self, benchmark_data: dict) -> list[Fact]: + facts: list[Fact] = [] + for fact_type, fn in self._detectors.items(): + try: + result = fn(benchmark_data) + except Exception as e: + import logging + logging.getLogger(__name__).warning( + "[narrative.detector.%s] fonctionnalité dégradée : %s", + fact_type.value, e, + ) + continue + if result: + facts.extend(result) + return facts + + +def detect_all(benchmark_data: dict, registry: Optional[DetectorRegistry] = None) -> list[Fact]: + """Applique tous les détecteurs enregistrés au benchmark donné. + + Point d'entrée du Sprint 4. Pour Sprint 1, le registre par défaut est vide : + les détecteurs concrets sont ajoutés sprint par sprint. + """ + if registry is None: + registry = _DEFAULT_REGISTRY + return registry.run(benchmark_data) + + +_DEFAULT_REGISTRY = DetectorRegistry() diff --git a/picarones/measurements/narrative/registry.py b/picarones/measurements/narrative/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..511ed2ed360a178b407183c05ab6ca7adf46dd8b --- /dev/null +++ b/picarones/measurements/narrative/registry.py @@ -0,0 +1,217 @@ +"""Registre déclaratif des détecteurs narratifs (Sprint 29). + +Avant le Sprint 29, ajouter un nouveau type de fait imposait de toucher +**quatre** fichiers : + + 1. ``facts.py`` — ajouter une valeur à ``FactType`` ; + 2. ``detectors.py`` — écrire ``def detect_xxx(data) -> list[Fact]`` ; + 3. ``detectors.py`` — l'inscrire dans le dict ``DETECTORS_BY_TYPE`` ; + 4. ``arbiter.py`` — ajouter le type à la séquence ``DEFAULT_TYPE_ORDER`` + au bon endroit pour la priorité éditoriale. + +Sprint 29 ramène le nombre de modifications à **deux** : + + 1. ``facts.py`` — toujours nécessaire pour le type énuméré ; + 2. ``detectors.py`` — décorer la fonction avec ``@register_detector(...)``. + +Le décorateur : + - enregistre la fonction dans un registre global trié par ``priority`` ; + - vérifie qu'aucun détecteur ne se réenregistre sur le même ``FactType`` ; + - laisse la fonction utilisable telle quelle (rétrocompatibilité) ; + - alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER``. + +Conventions de priorité (« politique éditoriale » du rapport) +------------------------------------------------------------- +Plus la valeur est petite, plus le fait remonte tôt en synthèse à +importance égale. Pour conserver l'ordre historique du Sprint 23, on +utilise un pas de 10 pour laisser de la place à des insertions futures : + + 10 GLOBAL_LEADER_CER qui gagne globalement + 20 STATISTICAL_TIE y a-t-il un ex-aequo + 30 SIGNIFICANT_GAP à quel point l'écart est solide + 40 STRATUM_WINNER qui domine sur quel sous-corpus + 50 STRATUM_COLLAPSE qui s'effondre sur quoi + 60 ERROR_PROFILE_OUTLIER qui se trompe différemment + 70 LLM_HALLUCINATION_FLAG hallucinations VLM + 80 ROBUSTNESS_FRAGILE sensibilité aux dégradations + 90 PARETO_ALTERNATIVE compromis coût/qualité + 100 SPEED_WINNER vitesse + 110 COST_OUTLIER coût aberrant + 120 CONFIDENCE_WARNING mise en garde sur la fiabilité + +Le décorateur n'impose **pas** de pas — un détecteur tiers peut très +bien utiliser ``priority=42`` pour s'insérer entre STRATUM_WINNER et +STRATUM_COLLAPSE par exemple. +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from typing import Callable, Optional + +from picarones.measurements.narrative.facts import ( + DetectorFn, + DetectorRegistry, + FactImportance, + FactType, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Métadonnées d'un détecteur +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class DetectorEntry: + """Métadonnées d'un détecteur enregistré.""" + fact_type: FactType + fn: DetectorFn + priority: int + importance: FactImportance + + +# --------------------------------------------------------------------------- +# Registre global +# --------------------------------------------------------------------------- + +_REGISTRY: dict[FactType, DetectorEntry] = {} +_REGISTRY_LOCK = threading.Lock() + + +def register_detector( + fact_type: FactType, + *, + priority: int, + importance: FactImportance = FactImportance.MEDIUM, +) -> Callable[[DetectorFn], DetectorFn]: + """Décorateur d'enregistrement. + + Usage:: + + @register_detector(FactType.GLOBAL_LEADER_CER, priority=10, + importance=FactImportance.CRITICAL) + def detect_global_leader_cer(data: dict) -> list[Fact]: + ... + + Le décorateur : + - vérifie qu'aucun autre détecteur n'est déjà enregistré sur + ``fact_type`` (sinon ``ValueError``) ; + - vérifie que ``priority`` est un entier ; + - retourne la fonction inchangée pour ne pas casser les imports + existants. + + L'``importance`` mémorisée ici sert de **métadonnée** au registre : + chaque détecteur reste libre d'émettre des ``Fact`` avec une + importance différente selon le contexte (ex. CRITICAL si l'écart + est gigantesque, HIGH sinon). + """ + def _decorator(fn: DetectorFn) -> DetectorFn: + with _REGISTRY_LOCK: + if fact_type in _REGISTRY: + raise ValueError( + f"Détecteur déjà enregistré pour {fact_type.value!r} : " + f"{_REGISTRY[fact_type].fn.__name__}. Désenregistrer " + "explicitement avant de réassigner." + ) + entry = DetectorEntry( + fact_type=fact_type, + fn=fn, + priority=int(priority), + importance=importance, + ) + _REGISTRY[fact_type] = entry + logger.debug( + "[narrative.registry] enregistré %s priority=%s importance=%s", + fact_type.value, priority, importance.name, + ) + return fn + + return _decorator + + +def unregister(fact_type: FactType) -> None: + """Retire un détecteur du registre — utilisé par les tests.""" + with _REGISTRY_LOCK: + _REGISTRY.pop(fact_type, None) + + +def iter_detectors() -> list[DetectorEntry]: + """Retourne tous les détecteurs enregistrés, triés par ``priority``. + + Le tri est stable : à ``priority`` égale, l'ordre d'enregistrement + est préservé (utile en présence d'extensions tierces). + """ + with _REGISTRY_LOCK: + entries = list(_REGISTRY.values()) + entries.sort(key=lambda e: e.priority) + return entries + + +def detector_for(fact_type: FactType) -> Optional[DetectorEntry]: + with _REGISTRY_LOCK: + return _REGISTRY.get(fact_type) + + +def clear_registry() -> None: + """Vide le registre — réservé aux tests d'isolation.""" + with _REGISTRY_LOCK: + _REGISTRY.clear() + + +def default_type_order() -> tuple[FactType, ...]: + """Calcule l'ordre canonique des types depuis le registre courant. + + Source de vérité de ``arbiter.DEFAULT_TYPE_ORDER`` depuis le Sprint 29. + """ + return tuple(e.fact_type for e in iter_detectors()) + + +# --------------------------------------------------------------------------- +# Pont avec ``DetectorRegistry`` historique +# --------------------------------------------------------------------------- + +def populate_legacy_registry(registry: DetectorRegistry) -> None: + """Synchronise le ``DetectorRegistry`` historique depuis le décorateur. + + L'objet ``DetectorRegistry`` reste l'API publique pour les + consommateurs externes (cf. ``DetectorRegistry.run``) ; cette + fonction l'alimente depuis le registre déclaratif courant. + """ + for entry in iter_detectors(): + registry.register(entry.fact_type, entry.fn) + + +__all__ = [ + "DetectorEntry", + "register_detector", + "unregister", + "iter_detectors", + "detector_for", + "clear_registry", + "default_type_order", + "populate_legacy_registry", +] + + +# --------------------------------------------------------------------------- +# Sentinel — sans usage direct ; vérifie au build qu'on n'introduit pas +# de valeur ``priority`` dupliquée par accident parmi les builtins. +# --------------------------------------------------------------------------- + +def _verify_unique_priorities() -> None: + seen: dict[int, FactType] = {} + for entry in iter_detectors(): + if entry.priority in seen: + logger.warning( + "[narrative.registry] priority %s dupliquée : " + "%s et %s — ordre indéterministe à priorité égale.", + entry.priority, + seen[entry.priority].value, + entry.fact_type.value, + ) + else: + seen[entry.priority] = entry.fact_type diff --git a/picarones/measurements/narrative/renderer.py b/picarones/measurements/narrative/renderer.py new file mode 100644 index 0000000000000000000000000000000000000000..cbb5d3da3f91db658339c78eb6be946177dfb0f9 --- /dev/null +++ b/picarones/measurements/narrative/renderer.py @@ -0,0 +1,105 @@ +"""Rendu des faits narratifs en texte lisible. + +Les templates sont chargés depuis ``templates/{lang}.yaml`` au premier accès. +Le rendu utilise ``str.format_map`` sur le ``payload`` du ``Fact``. Aucun LLM, +aucune génération : la sortie est la concaténation de templates remplis avec +des valeurs venant strictement du JSON d'entrée. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Iterable + +import yaml + +from picarones.measurements.narrative.facts import Fact + +logger = logging.getLogger(__name__) + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_TEMPLATES_CACHE: dict[str, dict[str, str]] = {} + + +def _load_templates(lang: str) -> dict[str, str]: + """Charge et met en cache les templates de la langue demandée. + + Fallback : si la langue n'existe pas, retourne les templates FR. Si FR + est également absent (incident d'installation), retourne un dict vide. + """ + if lang in _TEMPLATES_CACHE: + return _TEMPLATES_CACHE[lang] + + path = _TEMPLATES_DIR / f"{lang}.yaml" + if not path.exists(): + if lang != "fr": + return _load_templates("fr") + _TEMPLATES_CACHE[lang] = {} + return _TEMPLATES_CACHE[lang] + + try: + with path.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + if not isinstance(data, dict): + logger.warning("[narrative] %s n'est pas un dict YAML — ignoré", path) + _TEMPLATES_CACHE[lang] = {} + else: + _TEMPLATES_CACHE[lang] = {str(k): str(v).strip() for k, v in data.items()} + except yaml.YAMLError as e: + logger.warning("[narrative] échec parsing %s : %s", path, e) + _TEMPLATES_CACHE[lang] = {} + + return _TEMPLATES_CACHE[lang] + + +class _SafeFormatMap(dict): + """Dict qui retourne ``'?'`` pour les clés manquantes dans un template. + + Évite qu'un détecteur mal documenté fasse crasher le rendu. En pratique + les tests couvrent les clés attendues, mais la robustesse prévaut. + """ + + def __missing__(self, key: str) -> str: + logger.warning("[narrative] clé manquante dans payload : %r", key) + return "?" + + +def render_fact(fact: Fact, lang: str = "fr") -> str: + """Rend un Fact en une phrase selon la langue. + + Retourne ``""`` si le template est absent pour ce type. + """ + templates = _load_templates(lang) + tpl = templates.get(fact.type.value) + if not tpl: + return "" + + try: + return tpl.format_map(_SafeFormatMap(fact.payload)) + except (ValueError, KeyError) as e: + logger.warning( + "[narrative] rendu impossible pour %s : %s", fact.type.value, e, + ) + return "" + + +def render_synthesis(facts: Iterable[Fact], lang: str = "fr") -> list[str]: + """Rend une liste de Fact en liste de phrases (ordre préservé).""" + out: list[str] = [] + for fact in facts: + phrase = render_fact(fact, lang) + phrase = re.sub(r"\s+", " ", phrase).strip() + if phrase: + out.append(phrase) + return out + + +def extract_numbers(text: str) -> list[str]: + """Extrait les nombres (décimaux ou entiers) présents dans une phrase. + + Utilisé par le test de traçabilité : chaque nombre remonté en synthèse + doit être présent dans le JSON d'entrée. + """ + return re.findall(r"\d+(?:[.,]\d+)?", text) diff --git a/picarones/measurements/narrative/templates/en.yaml b/picarones/measurements/narrative/templates/en.yaml new file mode 100644 index 0000000000000000000000000000000000000000..40e2af6c48583a04243034dd742cc2568f2133e5 --- /dev/null +++ b/picarones/measurements/narrative/templates/en.yaml @@ -0,0 +1,96 @@ +# Narrative rendering templates — English. +# Anti-hallucination rule: never introduce a number or entity name that is not +# already in the Fact ``payload``. Tests verify traceability of every number +# appearing in the rendered synthesis. + +global_leader_cer: >- + On this corpus of {n_docs} documents, {engine} achieves the lowest mean CER + ({cer_pct} %). + +statistical_tie: >- + Engines {engines_list} are not statistically distinguishable + (Friedman-Nemenyi, α = {alpha}, n = {n_blocks} documents, CD = {critical_distance}). + +significant_gap: >- + The gap between {leader} and {runner_up} is statistically significant + (Wilcoxon, p = {p_value:.4f}, Δ CER = {delta_cer_pct} points over {n_pairs} pairs). + +stratum_winner: >- + On stratum "{stratum}" ({n_docs_stratum} documents), {engine} achieves + the lowest CER ({cer_pct} % vs. {second_cer_pct} % for {second_engine}). + +stratum_collapse: >- + {engine} is globally competitive ({global_cer_pct} %) but collapses on + stratum "{stratum}" ({local_cer_pct} % over {n_docs_stratum} documents, + i.e. {delta_cer_pct} points above its own average). + +error_profile_outlier: >- + {engine} has an atypical error profile: {proportion_pct} % of errors fall + into class "{error_class}", vs. a median of {median_proportion_pct} % across + other engines (×{ratio_to_median} the median). + +llm_hallucination_flag: >- + Hallucination signal on {engine} ({reasons_list}) — + {hallucinating_rate_pct} % of documents above alert thresholds. + +robustness_fragile: >- + {engine} is fragile under "{degradation}" degradation: its CER rises from + {cer_baseline_pct} % to {cer_degraded_pct} % at maximum level (×{ratio}). + +speed_winner: >- + {engine} is the fastest ({mean_duration} s/doc, ×{speedup} faster than the + median) for comparable quality (CER {cer_pct} %). + +confidence_warning: >- + High statistical uncertainty: the {confidence_level} % confidence interval of + {engine} spans {ci_width_pct} CER points, compared with a gap of + {gap_to_runner_up_pct} points to the runner-up. + +pareto_alternative: >- + At much lower cost, {engine} offers an interesting trade-off ({cer_pct} % + CER for {cost} €/{cost_unit_pages} pages, vs {leader_cer_pct} % / {leader_cost} € for + {leader}, i.e. ×{cost_saving_ratio} cheaper). + +cost_outlier: >- + Disproportionate cost for {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median} + the median) without a compensating quality advantage (CER {cer_pct} %). + +ensemble_opportunity: >- + Engines {pair_a} and {pair_b} have divergent error profiles + ({divergence_metric}={divergence}). On this corpus of {doc_count} documents, + {best_engine} preserves {best_recall_pct} % of tokens; a majority vote + among the engines would preserve {oracle_recall_pct} % — i.e. + {absolute_gap_pct} points recoverable ({relative_gap_pct} % of the best + engine's errors). + +median_mean_gap_warning: >- + Asymmetric distribution for {engine}: median CER {median_cer_pct} % + vs mean {mean_cer_pct} % across {n_docs} documents (relative gap + {relative_gap_pct} %). The mean is pulled by a few catastrophic + documents — the median (now used for default ranking) is more + representative. + +stratification_recommended: >- + Heterogeneous corpus ({n_strata} strata): {leader} performs very + differently depending on document type — median CER + {min_stratum_cer_pct} % on "{min_stratum}" vs + {max_stratum_cer_pct} % on "{max_stratum}", a gap of {gap_pct} + points. The global ranking hides this disparity; consult the + stratified view. + +engine_off_baseline: >- + {engine} achieved {cer_current_pct} % CER here, vs {cer_historical_mean_pct} % + on average over the last {n_runs} runs of your institution on this + same corpus (relative delta {relative_delta_pct} %). This corpus is + harder for it than usual. + +engine_unstable: >- + Over {n_runs} successive runs, {engine} produces variable outputs + (CER CV {cer_cv_pct} %, identical-run pair rate {identical_run_rate_pct} %). + Reproducibility is limited — interpret the average CER with caution. + +regression_in_history: >- + Over the {n_runs} historical runs for {engine}, the average CER + moved from {first_cer_pct} % to {last_cer_pct} % + (cumulative change {absolute_delta_pct} points). Investigate what + changed in the pipeline or the models. diff --git a/picarones/measurements/narrative/templates/fr.yaml b/picarones/measurements/narrative/templates/fr.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d3a858abfd5dcb5462007859458a8a59d43fafce --- /dev/null +++ b/picarones/measurements/narrative/templates/fr.yaml @@ -0,0 +1,101 @@ +# Templates de rendu narratif — français. +# +# Chaque clé correspond à une valeur de ``FactType``. La valeur est un template +# Python ``.format()`` qui consomme les champs du ``Fact.payload``. +# +# Règle anti-hallucination : n'introduire aucune valeur numérique ou nom +# d'entité qui ne soit pas dans le ``payload``. Les tests parsent la synthèse +# rendue et vérifient la traçabilité. + +global_leader_cer: >- + Sur ce corpus de {n_docs} documents, {engine} obtient le CER moyen le plus + bas ({cer_pct} %). + +statistical_tie: >- + Les moteurs {engines_list} ne sont pas statistiquement distinguables + (Friedman-Nemenyi, α = {alpha}, n = {n_blocks} documents, CD = {critical_distance}). + +significant_gap: >- + L'écart entre {leader} et {runner_up} est statistiquement significatif + (Wilcoxon, p = {p_value:.4f}, Δ CER = {delta_cer_pct} points sur {n_pairs} paires). + +stratum_winner: >- + Sur la strate « {stratum} » ({n_docs_stratum} documents), {engine} + obtient le CER le plus bas ({cer_pct} % contre {second_cer_pct} % + pour {second_engine}). + +stratum_collapse: >- + {engine} est globalement compétitif ({global_cer_pct} %) mais s'effondre sur + la strate « {stratum} » ({local_cer_pct} % sur {n_docs_stratum} documents, + soit {delta_cer_pct} points au-dessus de sa moyenne). + +error_profile_outlier: >- + Le profil d'erreurs de {engine} est atypique : {proportion_pct} % de la + classe « {error_class} », contre une médiane de {median_proportion_pct} % + sur les autres moteurs (ratio ×{ratio_to_median}). + +llm_hallucination_flag: >- + Signal d'hallucination sur {engine} ({reasons_list}) — + {hallucinating_rate_pct} % de documents au-dessus des seuils d'alerte. + +robustness_fragile: >- + {engine} est fragile à la dégradation « {degradation} » : son CER passe de + {cer_baseline_pct} % à {cer_degraded_pct} % au niveau maximal (ratio ×{ratio}). + +speed_winner: >- + {engine} est le plus rapide ({mean_duration} s / doc, ×{speedup} plus vite + que la médiane) pour un CER comparable ({cer_pct} %). + +confidence_warning: >- + Incertitude statistique élevée : l'intervalle de confiance à {confidence_level} % + de {engine} s'étend sur {ci_width_pct} points de CER, à comparer à l'écart de + {gap_to_runner_up_pct} points avec le second. + +pareto_alternative: >- + À coût sensiblement inférieur, {engine} offre un compromis intéressant + ({cer_pct} % de CER pour {cost} €/{cost_unit_pages} pages, contre {leader_cer_pct} % / + {leader_cost} € pour {leader}, soit ×{cost_saving_ratio} moins cher). + +cost_outlier: >- + Coût disproportionné pour {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median} + la médiane) sans avantage de qualité compensatoire (CER {cer_pct} %). + +ensemble_opportunity: >- + Les moteurs {pair_a} et {pair_b} ont des profils d'erreurs divergents + ({divergence_metric}={divergence}). Sur ce corpus de {doc_count} documents, + {best_engine} préserve {best_recall_pct} % des tokens ; un voting majoritaire + entre les moteurs en préserverait {oracle_recall_pct} %, soit + {absolute_gap_pct} points récupérables ({relative_gap_pct} % des erreurs + du meilleur moteur). + +median_mean_gap_warning: >- + Distribution asymétrique pour {engine} : médiane CER {median_cer_pct} % + vs moyenne {mean_cer_pct} % sur {n_docs} documents (écart relatif + {relative_gap_pct} %). La moyenne est tirée par quelques documents + catastrophiques — la médiane (utilisée pour le tri par défaut) est + plus représentative. + +stratification_recommended: >- + Corpus hétérogène ({n_strata} strates) : {leader} performe très + différemment selon le type de document — médiane CER + {min_stratum_cer_pct} % sur « {min_stratum} » contre + {max_stratum_cer_pct} % sur « {max_stratum} », soit {gap_pct} points + d'écart. Le classement global masque cette disparité ; consulter la + vue stratifiée. + +engine_off_baseline: >- + {engine} a obtenu {cer_current_pct} % CER ici, vs {cer_historical_mean_pct} % + en moyenne sur les {n_runs} runs précédents de votre institution sur + ce même corpus (écart relatif {relative_delta_pct} %). Ce corpus lui + est plus difficile que d'habitude. + +engine_unstable: >- + Sur {n_runs} runs successifs, {engine} produit des sorties variables + (CV CER {cer_cv_pct} %, paires de runs identiques {identical_run_rate_pct} %). + La reproductibilité est limitée — interpréter le CER moyen avec prudence. + +regression_in_history: >- + Sur les {n_runs} runs historiques pour {engine}, le CER moyen + est passé de {first_cer_pct} % à {last_cer_pct} % + (variation cumulée {absolute_delta_pct} points). Vérifier ce qui + a changé dans le pipeline ou les modèles. diff --git a/picarones/measurements/ner.py b/picarones/measurements/ner.py new file mode 100644 index 0000000000000000000000000000000000000000..7d3bbe8892ede723311146db19ae43a47d83c771 --- /dev/null +++ b/picarones/measurements/ner.py @@ -0,0 +1,309 @@ +"""Calcul des métriques de précision sur entités nommées (NER). + +Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure. + +Pourquoi ce module +------------------ +Pour un médiéviste, un archiviste ou un économiste-historien, +l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui +compte c'est de savoir si les **entités nommées** (personnes, lieux, +dates, organisations) ont survécu à la transcription. Un CER de 5 % +qui rate 80 % des noms propres est inutilisable pour l'indexation +prosopographique. + +Stratégie de découpage en sprints +--------------------------------- +Comme pour la divergence taxonomique (Sprints 35-37), on découpe : + +- **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre + deux listes d'entités, calcul de Precision/Recall/F1 par catégorie + et global, détection des hallucinations d'entité. Aucune dépendance + externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont + fournies en entrée. Un test de l'enregistrement dans le registre + typé Sprint 34 garantit l'intégration. +- **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et + câblage runner+narratif+HTML. + +Format des entités +------------------ +Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un +dictionnaire ``{"label": str, "start": int, "end": int, "text": str}`` +où ``start``/``end`` sont des offsets caractère. + +Convention d'alignement +----------------------- +Une entité hypothèse "matche" une entité de référence si : + +1. les **labels sont identiques** (case-insensitive), +2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans + caractère est ``≥ iou_threshold`` (défaut : 0,5). + +Une entité de référence non matchée → faux négatif (recall pénalisé). +Une entité hypothèse non matchée → faux positif (précision pénalisée). +Un faux positif est aussi compté comme **hallucination d'entité**, ce +qui est utile pour les VLM/LLM qui inventent. + +Limites +------- +- L'alignement bag-of-spans : une entité peut être matchée par au plus + une entité de l'autre côté (sinon double-comptage). +- Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique + mesure conjointement OCR + NER. Documenter explicitement. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable + +from picarones.core.metric_registry import register_metric +from picarones.core.modules import ArtifactType + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle de données +# ────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Entity: + """Entité nommée alignée sur un texte. + + Attributs + --------- + label: + Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``). + La comparaison se fait en *case-insensitive*. + start, end: + Offsets caractère (inclus, exclu) sur le texte de référence. + text: + Forme de surface — informative, **non utilisée pour + l'alignement** (deux entités peuvent matcher même si leur + forme de surface diffère, du moment que leurs spans + chevauchent suffisamment). + """ + + label: str + start: int + end: int + text: str = "" + + def __post_init__(self) -> None: + if self.start > self.end: + raise ValueError( + f"Entity span invalide : start={self.start} > end={self.end}" + ) + + @property + def length(self) -> int: + return max(0, self.end - self.start) + + +def _to_entity(obj: Entity | dict) -> Entity: + """Coerce un dict (format EntitiesGT) en ``Entity``.""" + if isinstance(obj, Entity): + return obj + return Entity( + label=str(obj["label"]), + start=int(obj["start"]), + end=int(obj["end"]), + text=str(obj.get("text", "")), + ) + + +# ────────────────────────────────────────────────────────────────────────── +# Alignement par IoU +# ────────────────────────────────────────────────────────────────────────── + + +def _iou(a: Entity, b: Entity) -> float: + """Intersection-over-Union sur les spans caractère.""" + inter_start = max(a.start, b.start) + inter_end = min(a.end, b.end) + inter = max(0, inter_end - inter_start) + union = a.length + b.length - inter + if union <= 0: + return 0.0 + return inter / union + + +def _align( + references: list[Entity], + hypotheses: list[Entity], + iou_threshold: float, +) -> tuple[list[tuple[int, int, float]], set[int], set[int]]: + """Aligne deux listes d'entités par IoU décroissant (greedy). + + Returns + ------- + matches: + Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU + décroissant — chaque entité n'apparaît qu'une fois. + unmatched_refs: + Indices des entités GT non matchées (faux négatifs). + unmatched_hyps: + Indices des entités hypothèse non matchées (faux positifs). + """ + candidates: list[tuple[float, int, int]] = [] + for i, r in enumerate(references): + for j, h in enumerate(hypotheses): + if r.label.casefold() != h.label.casefold(): + continue + score = _iou(r, h) + if score >= iou_threshold: + candidates.append((score, i, j)) + + # Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires + # pour garantir un tri stable et déterministe. + candidates.sort(key=lambda t: (-t[0], t[1], t[2])) + + matched_refs: set[int] = set() + matched_hyps: set[int] = set() + matches: list[tuple[int, int, float]] = [] + for score, i, j in candidates: + if i in matched_refs or j in matched_hyps: + continue + matched_refs.add(i) + matched_hyps.add(j) + matches.append((i, j, score)) + + unmatched_refs = set(range(len(references))) - matched_refs + unmatched_hyps = set(range(len(hypotheses))) - matched_hyps + return matches, unmatched_refs, unmatched_hyps + + +# ────────────────────────────────────────────────────────────────────────── +# Calcul des métriques +# ────────────────────────────────────────────────────────────────────────── + + +def _prf(tp: int, fp: int, fn: int) -> dict[str, float]: + """Précision / rappel / F1 à partir des comptes.""" + precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f1 = ( + 2 * precision * recall / (precision + recall) + if (precision + recall) > 0 + else 0.0 + ) + return { + "precision": precision, + "recall": recall, + "f1": f1, + "support": tp + fn, + } + + +def compute_ner_metrics( + reference_entities: Iterable[Entity | dict], + hypothesis_entities: Iterable[Entity | dict], + iou_threshold: float = 0.5, +) -> dict: + """Calcule la précision/rappel/F1 sur entités nommées. + + Parameters + ---------- + reference_entities: + Liste d'entités GT (format ``Entity`` ou dict de + ``EntitiesGT``). + hypothesis_entities: + Liste d'entités produites par le NER sur la sortie OCR. + iou_threshold: + Seuil de chevauchement caractère pour qu'un appariement + soit valide (défaut : 0,5 — convention CoNLL/HIPE). + + Returns + ------- + dict + ``{ + "global": {"precision", "recall", "f1", "support"}, + "per_category": {label: {"precision", ...}}, + "true_positives": int, + "false_positives": int, + "false_negatives": int, + "hallucinated_entities": list[dict], # entités OCR sans GT + "missed_entities": list[dict], # entités GT non détectées + "iou_threshold": float, + }`` + """ + refs = [_to_entity(e) for e in reference_entities] + hyps = [_to_entity(e) for e in hypothesis_entities] + + matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold) + + tp = len(matches) + fn = len(unmatched_refs) + fp = len(unmatched_hyps) + + # Comptes par catégorie + cat_tp: dict[str, int] = {} + cat_fn: dict[str, int] = {} + cat_fp: dict[str, int] = {} + for i, _j, _score in matches: + cat = refs[i].label + cat_tp[cat] = cat_tp.get(cat, 0) + 1 + for i in unmatched_refs: + cat = refs[i].label + cat_fn[cat] = cat_fn.get(cat, 0) + 1 + for j in unmatched_hyps: + cat = hyps[j].label + cat_fp[cat] = cat_fp.get(cat, 0) + 1 + + all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp)) + per_category = { + cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0)) + for cat in all_categories + } + + return { + "global": _prf(tp, fp, fn), + "per_category": per_category, + "true_positives": tp, + "false_positives": fp, + "false_negatives": fn, + "hallucinated_entities": [ + {"label": hyps[j].label, "start": hyps[j].start, + "end": hyps[j].end, "text": hyps[j].text} + for j in sorted(unmatched_hyps) + ], + "missed_entities": [ + {"label": refs[i].label, "start": refs[i].start, + "end": refs[i].end, "text": refs[i].text} + for i in sorted(unmatched_refs) + ], + "iou_threshold": iou_threshold, + } + + +# ────────────────────────────────────────────────────────────────────────── +# Enregistrement dans le registre typé (Sprint 34) +# ────────────────────────────────────────────────────────────────────────── + + +@register_metric( + name="ner_f1", + input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES), + description=( + "F1 global sur les entités nommées (alignement IoU ≥ 0,5, " + "labels case-insensitive). Pour le détail par catégorie, " + "utiliser compute_ner_metrics directement." + ), + higher_is_better=True, + tags={"downstream", "ner", "structure"}, +) +def ner_f1( + reference_entities: Iterable[Entity | dict], + hypothesis_entities: Iterable[Entity | dict], +) -> float: + """F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``.""" + return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"] + + +__all__ = [ + "Entity", + "compute_ner_metrics", + "ner_f1", +] diff --git a/picarones/measurements/ner_backends.py b/picarones/measurements/ner_backends.py new file mode 100644 index 0000000000000000000000000000000000000000..4e944df927dec5919a3f89fc840d5e05b5af189e --- /dev/null +++ b/picarones/measurements/ner_backends.py @@ -0,0 +1,227 @@ +"""Backends d'extraction d'entités nommées (Sprint 40). + +Suite directe du Sprint 38 : la couche de calcul (`compute_ner_metrics`) +prend deux listes d'entités, ce module fournit le moyen d'**obtenir** la +liste d'entités d'un côté à partir d'un texte (généralement la sortie +OCR du moteur). + +Architecture +------------ +- ``EntityExtractor`` : Protocol Python qui décrit l'interface ; tout + callable ``(text: str) -> list[dict]`` est un extracteur valide. Le + format de sortie est compatible ``EntitiesGT`` (Sprint 32) et + ``compute_ner_metrics`` (Sprint 38). +- ``SpacyEntityExtractor`` : implémentation par défaut, lazy-import de + spaCy. Si spaCy n'est pas installé OU si le modèle n'est pas + téléchargé, retourne ``[]`` avec un ``logger.warning`` explicite + (cf. règle CLAUDE.md : pas de ``except: pass``). +- ``SPACY_PROFILES`` : dict de profils nommés vers noms de modèles + spaCy (FR, EN, multilingue, HIPE pour les corpus historiques). +- ``get_extractor(profile)`` : factory qui retourne l'extracteur + correspondant au profil demandé. + +Découplage runner ↔ backend +--------------------------- +Le runner reçoit un ``EntityExtractor`` en paramètre — il n'importe +**jamais** spaCy directement. Cela permet : + +1. de **tester** sans dépendance externe (le test injecte un callable + qui simule l'extraction) ; +2. de **brancher** des backends alternatifs (Stanza, HIPE custom, + modèle fine-tuné maison) sans modifier le runner ; +3. de **désactiver** la métrique en passant ``None`` — comportement + par défaut, rétrocompat stricte. +""" + +from __future__ import annotations + +import logging +from typing import Any, Protocol + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Interface +# ────────────────────────────────────────────────────────────────────────── + + +class EntityExtractor(Protocol): + """Tout callable ``(text) -> list[dict]`` est un extracteur valide. + + Format de sortie attendu : liste de dicts + ``{"label": str, "start": int, "end": int, "text": str}`` + compatibles avec ``compute_ner_metrics`` (Sprint 38) et + ``EntitiesGT`` (Sprint 32). + """ + + def __call__(self, text: str) -> list[dict[str, Any]]: ... + + +# ────────────────────────────────────────────────────────────────────────── +# Profils spaCy nommés +# ────────────────────────────────────────────────────────────────────────── + + +SPACY_PROFILES: dict[str, str] = { + "fr": "fr_core_news_sm", + "fr_lg": "fr_core_news_lg", + "en": "en_core_web_sm", + "en_lg": "en_core_web_lg", + "multilingual": "xx_ent_wiki_sm", + # HIPE 2022 — modèle historique multilingue (Hugging Face). Pas + # toujours disponible via ``spacy.load`` direct ; documenté pour + # mémoire, l'utilisateur peut le wrapper dans un EntityExtractor + # custom si besoin. + "hipe": "fr_core_news_lg", +} + + +# ────────────────────────────────────────────────────────────────────────── +# Backend spaCy +# ────────────────────────────────────────────────────────────────────────── + + +class SpacyEntityExtractor: + """Extracteur d'entités basé sur spaCy. + + Lazy-import : ``spacy`` n'est importé qu'au premier appel. Le + modèle est chargé une seule fois et mis en cache sur l'instance. + + Si spaCy n'est pas installé OU si le modèle demandé n'est pas + téléchargé, l'extracteur tombe en mode dégradé (retourne ``[]`` + pour chaque appel) et émet un ``logger.warning`` au premier + appel. + + Parameters + ---------- + model_name: + Nom du modèle spaCy à charger (ex. ``"fr_core_news_sm"``). + label_mapping: + Dict optionnel ``{spacy_label: target_label}`` pour + normaliser les labels (ex. spaCy utilise ``"PERSON"``, + on veut ``"PER"``). Si ``None``, garde les labels tels + quels. + + Examples + -------- + >>> extractor = SpacyEntityExtractor("fr_core_news_sm") + >>> entities = extractor("Marie de Bourgogne, en 1477.") + >>> # liste de dicts {label, start, end, text}, ou [] si spaCy absent + """ + + # Mapping par défaut spaCy → conventions HIPE/CoNLL courtes + DEFAULT_LABEL_MAPPING: dict[str, str] = { + "PERSON": "PER", + "PER": "PER", + "LOC": "LOC", + "GPE": "LOC", # Geo-Political Entity → LOC + "ORG": "ORG", + "DATE": "DATE", + "TIME": "DATE", + "MISC": "MISC", + } + + def __init__( + self, + model_name: str = "fr_core_news_sm", + label_mapping: dict[str, str] | None = None, + ) -> None: + self.model_name = model_name + self.label_mapping = ( + dict(label_mapping) + if label_mapping is not None + else dict(self.DEFAULT_LABEL_MAPPING) + ) + self._nlp: Any | None = None + self._loaded: bool = False + self._available: bool = False + + def _load(self) -> None: + """Charge spaCy + modèle au premier appel. Idempotent.""" + if self._loaded: + return + self._loaded = True + try: + import spacy # type: ignore[import-untyped] + except ImportError as exc: + logger.warning( + "[ner_backends] spaCy non installé (%s) — extraction NER " + "désactivée. Installer avec `pip install picarones[ner]`.", + exc, + ) + return + try: + self._nlp = spacy.load(self.model_name) + self._available = True + except OSError as exc: + logger.warning( + "[ner_backends] Modèle spaCy %r introuvable (%s) — extraction " + "NER désactivée. Télécharger avec `python -m spacy download %s`.", + self.model_name, exc, self.model_name, + ) + + @property + def available(self) -> bool: + """``True`` si spaCy + le modèle sont chargés et utilisables.""" + if not self._loaded: + self._load() + return self._available + + def __call__(self, text: str) -> list[dict[str, Any]]: + if not text: + return [] + if not self.available or self._nlp is None: + return [] + doc = self._nlp(text) + results: list[dict[str, Any]] = [] + for ent in doc.ents: + label = self.label_mapping.get(ent.label_, ent.label_) + results.append({ + "label": label, + "start": int(ent.start_char), + "end": int(ent.end_char), + "text": ent.text, + }) + return results + + +# ────────────────────────────────────────────────────────────────────────── +# Factory +# ────────────────────────────────────────────────────────────────────────── + + +def get_extractor(profile: str = "fr") -> SpacyEntityExtractor: + """Retourne un extracteur spaCy pour le profil demandé. + + Le profil peut être : + + - une clé de ``SPACY_PROFILES`` (ex. ``"fr"``, ``"en"``, + ``"multilingual"``) + - un nom de modèle spaCy direct (ex. ``"fr_core_news_lg"``) + + L'extracteur est instancié paresseusement (le modèle n'est chargé + qu'au premier appel). Si le modèle n'est pas disponible, + l'extracteur tombe en mode dégradé silencieux (retourne ``[]``). + """ + model_name = SPACY_PROFILES.get(profile, profile) + return SpacyEntityExtractor(model_name=model_name) + + +def is_spacy_available() -> bool: + """``True`` si la librairie ``spacy`` est importable, sans charger + de modèle.""" + try: + import spacy # noqa: F401 + except ImportError: + return False + return True + + +__all__ = [ + "EntityExtractor", + "SpacyEntityExtractor", + "SPACY_PROFILES", + "get_extractor", + "is_spacy_available", +] diff --git a/picarones/measurements/normalization.py b/picarones/measurements/normalization.py new file mode 100644 index 0000000000000000000000000000000000000000..6c33b33d4752d0c00715e8dfd6b068b75c773498 --- /dev/null +++ b/picarones/measurements/normalization.py @@ -0,0 +1,420 @@ +"""Profils de normalisation unicode pour le calcul du CER diplomatique. + +La normalisation diplomatique permet de calculer un CER tenant compte des +équivalences graphiques propres aux documents historiques : ſ=s, u=v, i=j, etc. + +En appliquant la même table aux deux textes (GT et OCR), on mesure les erreurs +"substantielles" (transcription erronée) en ignorant les variations graphiques +codifiées connues. + +Trois niveaux de normalisation sont disponibles : + +1. NFC : normalisation Unicode canonique (décomposition+recomposition) +2. caseless : NFC + pliage de casse (casefold) +3. diplomatic: NFC + table de correspondances historiques configurables + +Les profils préconfigurés couvrent les cas d'usage patrimoniaux courants. +Ils sont également chargeables depuis un fichier YAML. + +Exemple YAML +------------ +name: medieval_custom +caseless: false +diplomatic: + ſ: s + u: v + i: j + y: i + æ: ae + œ: oe +""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Tables de correspondances diplomatiques préconfigurées +# --------------------------------------------------------------------------- + +#: Français médiéval (XIIe–XVe siècle) +DIPLOMATIC_FR_MEDIEVAL: dict[str, str] = { + "ſ": "s", # s long → s + "u": "v", # u/v interchangeables en position initiale + "i": "j", # i/j interchangeables + "y": "i", # y vocalique → i + "æ": "ae", # ligature æ + "œ": "oe", # ligature œ + "ꝑ": "per", # abréviation per/par + "ꝓ": "pro", # abréviation pro + "\u0026": "et", # & → et +} + +#: Français moderne / imprimés anciens (XVIe–XVIIIe siècle) +DIPLOMATIC_FR_EARLY_MODERN: dict[str, str] = { + "ſ": "s", # s long + "æ": "ae", + "œ": "oe", + "\u0026": "et", + "ỹ": "yn", # y tilde +} + +#: Latin médiéval +DIPLOMATIC_LATIN_MEDIEVAL: dict[str, str] = { + "ſ": "s", + "u": "v", + "i": "j", + "y": "i", + "æ": "ae", + "œ": "oe", + "ꝑ": "per", + "ꝓ": "pro", + "ꝗ": "que", # q barré → que + "\u0026": "et", +} + +#: Profil minimal — uniquement NFC + s long +DIPLOMATIC_MINIMAL: dict[str, str] = { + "ſ": "s", +} + +#: Anglais moderne / imprimés anciens (XVIe–XVIIIe siècle) +#: Orthographe «early modern» : ſ=s, u/v, i/j, vv=w, þ=th, ð=th, ȝ=y +DIPLOMATIC_EN_EARLY_MODERN: dict[str, str] = { + "ſ": "s", # s long → s + "u": "v", # u/v interchangeables (vpon → upon) + "i": "j", # i/j interchangeables (ioy → joy) + "vv": "w", # vv → w (vvhich → which) + "þ": "th", # thorn → th + "ð": "th", # eth → th + "ȝ": "y", # yogh → y + "æ": "ae", # ligature æ + "œ": "oe", # ligature œ + "\u0026": "and", # & → and +} + +#: Anglais médiéval (XIIe–XVe siècle) — abréviations manuscrites incluses +DIPLOMATIC_EN_MEDIEVAL: dict[str, str] = { + "ſ": "s", + "u": "v", + "i": "j", + "vv": "w", + "þ": "th", + "ð": "th", + "ȝ": "y", + "æ": "ae", + "œ": "oe", + "\u0026": "and", + # Abréviations courantes dans les manuscrits anglais médiévaux + "ꝑ": "per", # p barré → per/par + "ꝓ": "pro", # p crocheté → pro + "ꝗ": "que", # q barré → que + "\ua75b": "r", # lettre r rotunda → r +} + +#: Écriture secrétaire (XVIe–XVIIe siècle) — secretary hand +#: Confusions visuelles propres à l'écriture cursive anglaise +DIPLOMATIC_EN_SECRETARY: dict[str, str] = { + "ſ": "s", + "u": "v", + "i": "j", + "vv": "w", + "þ": "th", + "ð": "th", + "ȝ": "y", + "\u0026": "and", + # Confusions visuelles typiques : e/c, n/u, m/w en secrétaire + # Note : ne pas normaliser e/c automatiquement (trop agressif) ; + # on se limite aux substituts graphiques historiquement documentés +} + + +# --------------------------------------------------------------------------- +# Profil de normalisation +# --------------------------------------------------------------------------- + +@dataclass +class NormalizationProfile: + """Décrit une stratégie de normalisation pour le calcul du CER diplomatique. + + Parameters + ---------- + name: + Identifiant lisible du profil (ex : ``"medieval_french"``). + nfc: + Applique la normalisation Unicode NFC (recommandé, activé par défaut). + caseless: + Pliage de casse (casefold) après NFC. + diplomatic_table: + Table de correspondances graphiques historiques appliquée caractère + par caractère sur les deux textes avant calcul du CER. + exclude_chars: + Ensemble de caractères supprimés des deux textes (GT et OCR) avant + tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique). + Utile pour ignorer la ponctuation ou les apostrophes. + description: + Description courte du profil (affichée dans le rapport HTML). + """ + + name: str + nfc: bool = True + caseless: bool = False + diplomatic_table: dict[str, str] = field(default_factory=dict) + exclude_chars: frozenset = field(default_factory=frozenset) + description: str = "" + + def normalize(self, text: str) -> str: + """Applique le profil de normalisation à un texte.""" + if self.exclude_chars: + text = "".join(c for c in text if c not in self.exclude_chars) + if self.nfc: + text = unicodedata.normalize("NFC", text) + if self.caseless: + text = text.casefold() + if self.diplomatic_table: + text = _apply_diplomatic_table(text, self.diplomatic_table) + return text + + def as_dict(self) -> dict: + return { + "name": self.name, + "nfc": self.nfc, + "caseless": self.caseless, + "diplomatic_table": self.diplomatic_table, + "exclude_chars": sorted(self.exclude_chars), + "description": self.description, + } + + @classmethod + def from_yaml(cls, path: str | Path) -> "NormalizationProfile": + """Charge un profil depuis un fichier YAML. + + Le fichier YAML doit contenir les clés ``name``, optionnellement + ``caseless``, ``description``, ``diplomatic`` (dict str→str) et + ``exclude_chars`` (liste ou chaîne de caractères à ignorer). + + Example + ------- + .. code-block:: yaml + + name: medieval_custom + caseless: false + description: Français médiéval personnalisé + exclude_chars: ".,;:!?" + diplomatic: + ſ: s + u: v + """ + try: + import yaml + except ImportError as exc: + raise RuntimeError( + "Le package 'pyyaml' est requis pour charger les profils YAML. " + "Installez-le avec : pip install pyyaml" + ) from exc + + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + return cls( + name=data.get("name", Path(path).stem), + nfc=bool(data.get("nfc", True)), + caseless=bool(data.get("caseless", False)), + diplomatic_table=data.get("diplomatic", {}), + exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), + description=data.get("description", ""), + ) + + @classmethod + def from_dict(cls, data: dict) -> "NormalizationProfile": + """Charge un profil depuis un dictionnaire (ex : section YAML inline).""" + return cls( + name=data.get("name", "custom"), + nfc=bool(data.get("nfc", True)), + caseless=bool(data.get("caseless", False)), + diplomatic_table=data.get("diplomatic", {}), + exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), + description=data.get("description", ""), + ) + + +# --------------------------------------------------------------------------- +# Profils préconfigurés +# --------------------------------------------------------------------------- + +NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = { + "nfc": NormalizationProfile( + name="nfc", + nfc=True, + caseless=False, + diplomatic_table={}, + description="Normalisation NFC uniquement", + ), + "caseless": NormalizationProfile( + name="caseless", + nfc=True, + caseless=True, + diplomatic_table={}, + description="NFC + insensible à la casse", + ), + "minimal": NormalizationProfile( + name="minimal", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_MINIMAL, + description="Minimal : NFC + s long seulement", + ), + "medieval_french": NormalizationProfile( + name="medieval_french", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_FR_MEDIEVAL, + description="Français médiéval (XIIe–XVe) : ſ=s, u=v, i=j, æ=ae, œ=oe", + ), + "early_modern_french": NormalizationProfile( + name="early_modern_french", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_FR_EARLY_MODERN, + description="Imprimés anciens (XVIe–XVIIIe) : ſ=s, æ=ae, œ=oe", + ), + "medieval_latin": NormalizationProfile( + name="medieval_latin", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_LATIN_MEDIEVAL, + description="Latin médiéval : ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro", + ), + "early_modern_english": NormalizationProfile( + name="early_modern_english", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_EN_EARLY_MODERN, + description="Early Modern English (XVIth–XVIIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", + ), + "medieval_english": NormalizationProfile( + name="medieval_english", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_EN_MEDIEVAL, + description="Medieval English (XIIth–XVth c.): ſ=s, u=v, i=j, þ=th, ȝ=y, ꝑ=per, ꝓ=pro", + ), + "secretary_hand": NormalizationProfile( + name="secretary_hand", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_EN_SECRETARY, + description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", + ), + # ── Profils d'exclusion de caractères ──────────────────────────────── + "sans_ponctuation": NormalizationProfile( + name="sans_ponctuation", + nfc=True, + caseless=False, + diplomatic_table={}, + exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()), + description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]", + ), + "sans_apostrophes": NormalizationProfile( + name="sans_apostrophes", + nfc=True, + caseless=False, + diplomatic_table={}, + exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique + description="NFC + suppression des apostrophes droite (') et typographique (\u2019)", + ), +} + + +def get_builtin_profile(name: str) -> NormalizationProfile: + """Retourne un profil préconfigurée par son identifiant. + + Identifiants disponibles + ------------------------ + - ``"medieval_french"`` : français médiéval XIIe–XVe (ſ=s, u=v, i=j, æ=ae, œ=oe…) + - ``"early_modern_french"`` : imprimés anciens XVIe–XVIIIe (ſ=s, œ=oe, æ=ae…) + - ``"medieval_latin"`` : latin médiéval (ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro…) + - ``"early_modern_english"`` : anglais imprimé XVIe–XVIIIe (ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y) + - ``"medieval_english"`` : anglais manuscrit XIIe–XVe (+ abréviations ꝑ, ꝓ…) + - ``"secretary_hand"`` : écriture secrétaire anglaise XVIe–XVIIe (cursive administrative) + - ``"minimal"`` : uniquement NFC + s long + - ``"nfc"`` : NFC seul (sans table diplomatique) + - ``"caseless"`` : NFC + pliage de casse + + Raises + ------ + KeyError + Si le nom n'est pas reconnu. + """ + if name not in NORMALIZATION_PROFILES: + raise KeyError( + f"Profil de normalisation inconnu : '{name}'. " + f"Disponibles : {', '.join(NORMALIZATION_PROFILES)}" + ) + return NORMALIZATION_PROFILES[name] + + +# --------------------------------------------------------------------------- +# Fonctions utilitaires +# --------------------------------------------------------------------------- + +def _parse_exclude_chars(value: "str | list | None") -> frozenset: + """Convertit une liste de caractères (str ou list) en frozenset. + + Accepte : + - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``) + ou simplement concaténés sans séparateur (ex. ``".,;:!?"``) + - Une liste Python/YAML de chaînes (chacune un caractère) + - None ou chaîne vide → frozenset vide + + Règle de désambiguïsation : si la chaîne contient la séquence ``", "`` + (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque + caractère Unicode est un item distinct. + """ + if not value: + return frozenset() + if isinstance(value, (list, tuple)): + return frozenset(str(c) for c in value if c) + raw = str(value) + # Désambiguïsation : séparer par ", " si présent (format lisible) + if ", " in raw: + return frozenset(c.strip() for c in raw.split(",") if c.strip()) + # Sinon, chaque caractère Unicode est un item distinct + return frozenset(raw) + + +def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str: + """Applique une table de correspondances diplomatiques en un seul pass. + + Les clés multi-caractères (ex : ``"ae"`` → ``"æ"``) sont gérées en priorité + sur les correspondances simples. Le remplacement est fait en un seul pass + via regex pour éviter les remplacements en cascade (ex : ``"ſ"→"s"`` puis + ``"s"→"z"`` donnerait ``"z"`` au lieu de ``"s"``). + """ + if not table: + return text + + import re + + # Séparer les clés simples (1 char) des clés multi-chars + multi_keys = sorted( + (k for k in table if len(k) > 1), key=len, reverse=True + ) + simple_table = {k: v for k, v in table.items() if len(k) == 1} + + if multi_keys: + # Single-pass : construire un pattern regex avec toutes les clés multi-chars + # triées par longueur décroissante pour matcher les plus longues d'abord + pattern = re.compile("|".join(re.escape(k) for k in multi_keys)) + text = pattern.sub(lambda m: table[m.group(0)], text) + + # Remplacements char par char (single-pass via itération) + if simple_table: + text = "".join(simple_table.get(c, c) for c in text) + + return text + + +# Profil par défaut utilisé pour le CER diplomatique intégré +DEFAULT_DIPLOMATIC_PROFILE: NormalizationProfile = get_builtin_profile("medieval_french") diff --git a/picarones/measurements/numerical_sequences.py b/picarones/measurements/numerical_sequences.py new file mode 100644 index 0000000000000000000000000000000000000000..5698b4017fa693cec17a6dd1671bfed7d1cab38c --- /dev/null +++ b/picarones/measurements/numerical_sequences.py @@ -0,0 +1,422 @@ +"""Précision sur séquences numériques — Sprint 85 (A.II.5b). + +Sprint 85 — A.II.5b du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Pour un économiste-historien, un éditeur de chartes ou un +archiviste, la **fidélité aux séquences numériques** est un +proxy direct de la qualité éditoriale. Un OCR qui rate +*« 1789 »* dans une charte révolutionnaire ou *« f. 12v »* +dans une cote d'archives produit un corpus inutilisable pour la +recherche fine, même si le CER global est respectable. + +Catégories couvertes +-------------------- +1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789`` + (le module détecte les **années** sur 4 chiffres dans la + plage [1000-2099]). +2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``. + Réutilise ``picarones.core.roman_numerals`` (Sprint 60). +3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``, + ``p. 5``, ``pp. 12-15``, ``n° 42``. +4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``, + ``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime + (``l.``, ``s.``, ``d.``). +5. **Années régnales** : ``an III``, ``l'an V``, ``an de + grâce 1450``, ``an de la République``. + +Méthode +------- +Pour chaque catégorie, on extrait les occurrences (regex +spécialisée) en GT et en hypothèse. On classe ensuite chaque +GT en **3 statuts** : + +- ``strict_preserved`` : forme exacte présente dans + l'hypothèse (sensible à la casse seulement pour la + foliotation, sinon la convention est documentée par + catégorie) ; +- ``value_preserved`` : la **valeur** apparaît même si la + forme diffère (ex. ``XIV`` GT et ``14`` hypothèse — + considéré comme valeur préservée mais forme non) ; +- ``lost`` : aucune trace exploitable. + +Sortie +------ +``compute_numerical_sequence_metrics(reference, hypothesis)`` +retourne : + +``` +{ + "global_strict_score": float, # ∈ [0, 1] + "global_value_score": float, # ∈ [0, 1] + "n_total": int, + "per_category": { + "year": {"n_total": int, "strict": int, "value": int, + "strict_score": float, "value_score": float, + "lost_items": list[str]}, + "roman": {...}, + "foliation": {...}, + "currency": {...}, + "regnal": {...}, + }, +} +``` + +Limites +------- +- Les regex sont **conservatrices** : on rate quelques + formes rares plutôt que de produire des faux positifs (par + exemple, ``mil cinq cens`` en français médiéval n'est pas + détecté comme année — la couche calcul s'en tient aux + formes les plus reconnaissables). Pour un corpus + spécifique, l'utilisateur peut composer ses propres + détecteurs et les passer via ``custom_detectors``. +- ``value_preserved`` exige une équivalence de **valeur + numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ; + ``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la + foliotation (recto/verso est une information distincte). +""" + +from __future__ import annotations + +import logging +import re +from typing import Optional + +from picarones.core.metric_registry import register_metric +from picarones.core.modules import ArtifactType +from picarones.core.roman_numerals import ( + detect_roman_numerals, + roman_to_int, +) + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Constantes / catégories +# ────────────────────────────────────────────────────────────────────────── + + +CATEGORIES = ("year", "roman", "foliation", "currency", "regnal") + + +# Dates arabes — 4 chiffres dans la plage [1000-2099]. +# On exige une frontière de mot pour ne pas attraper +# « 12345 » (volume) ou « 0001 » (numéro de page). +_RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b") + + +# Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42 +# La capture conserve la forme intégrale (avec ponctuation et +# r/v) parce que recto/verso est une information distincte. +_RE_FOLIATION = re.compile( + r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n° + r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15) + r"\s*([rvRV])?", # suffixe optionnel r/v + re.UNICODE, +) + + +# Montants : nombre suivi d'une unité monétaire. +# On accepte espaces multiples mais pas de saut de ligne. +_RE_CURRENCY = re.compile( + r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal) + r"(livres?|sols?|deniers?|écus?|florins?|francs?|" + r"l\.|s\.|d\.|£|€|₣)" # unité + r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole + re.UNICODE | re.IGNORECASE, +) + + +# Années régnales : « an III », « an de grâce 1450 », +# « l'an V de la République ». +# Capture le numéral (romain ou arabe). +_RE_REGNAL = re.compile( + r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?" + r"([IVXLCDMivxlcdm]+|\d{1,4})\b", + re.UNICODE, +) + + +# ────────────────────────────────────────────────────────────────────────── +# Détection par catégorie +# ────────────────────────────────────────────────────────────────────────── + + +def _detect_years(text: str) -> list[tuple[str, int]]: + """Retourne [(forme, valeur)] pour chaque année 4 chiffres.""" + if not text: + return [] + return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)] + + +def _detect_romans_with_values(text: str) -> list[tuple[str, int]]: + """Numéraux romains accompagnés de leur valeur entière. + Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60), + qui retourne ``(start, form, value)``. + """ + if not text: + return [] + out: list[tuple[str, int]] = [] + for _start, form, value in detect_roman_numerals(text, min_length=2): + if value is not None: + out.append((form, value)) + return out + + +def _detect_foliations(text: str) -> list[tuple[str, str]]: + """Foliotation. Retourne [(forme_complète, clé_normalisée)] où la + clé inclut le suffixe r/v normalisé (recto/verso). + """ + if not text: + return [] + out: list[tuple[str, str]] = [] + for m in _RE_FOLIATION.finditer(text): + full = m.group(0).strip() + nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15" + suffix = (m.group(2) or "").lower() + key = f"{nums}{suffix}" + out.append((full, key)) + return out + + +def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]: + """Montants. Clé = (montant_normalisé, unité_canonique). + + L'unité canonique compresse les variantes (« livres » et + « livre » → « livre » ; « £ » reste « £ »). + """ + if not text: + return [] + canon = { + "livre": "livre", "livres": "livre", "l.": "livre", + "sol": "sol", "sols": "sol", "s.": "sol", + "denier": "denier", "deniers": "denier", "d.": "denier", + "écu": "écu", "écus": "écu", + "florin": "florin", "florins": "florin", + "franc": "franc", "francs": "franc", + "£": "£", "€": "€", "₣": "₣", + } + out: list[tuple[str, tuple[str, str]]] = [] + for m in _RE_CURRENCY.finditer(text): + amount = m.group(1).replace(",", ".") + unit_raw = m.group(2).lower() + unit = canon.get(unit_raw, unit_raw) + out.append((m.group(0), (amount, unit))) + return out + + +def _detect_regnal(text: str) -> list[tuple[str, int]]: + """Années régnales. Retourne [(forme, valeur_int)] avec la + valeur extraite (romain → int ou arabe → int). + """ + if not text: + return [] + out: list[tuple[str, int]] = [] + for m in _RE_REGNAL.finditer(text): + numeral = m.group(1) + value: Optional[int] + if numeral.isdigit(): + value = int(numeral) + else: + value = roman_to_int(numeral) + if value is not None: + out.append((m.group(0), value)) + return out + + +_DETECTORS = { + "year": _detect_years, + "roman": _detect_romans_with_values, + "foliation": _detect_foliations, + "currency": _detect_currencies, + "regnal": _detect_regnal, +} + + +# ────────────────────────────────────────────────────────────────────────── +# Calcul principal +# ────────────────────────────────────────────────────────────────────────── + + +def _classify_per_category( + gt_items: list, + hyp_items: list, + *, + form_extractor, + value_extractor, +) -> dict: + """Pour chaque item GT, le classe en strict_preserved / + value_preserved / lost. + + Multiplicité respectée : un item hypothèse ne peut servir + qu'à un seul match (forme prioritaire sur valeur). + """ + hyp_used = [False] * len(hyp_items) + n_strict = 0 + n_value = 0 + lost: list[str] = [] + # Première passe : matchs stricts (forme exacte) + matched: list[bool] = [False] * len(gt_items) + for gi, gt_item in enumerate(gt_items): + gt_form = form_extractor(gt_item) + for hi, hyp_item in enumerate(hyp_items): + if hyp_used[hi]: + continue + if form_extractor(hyp_item) == gt_form: + hyp_used[hi] = True + matched[gi] = True + n_strict += 1 + break + # Deuxième passe : matchs sur valeur (forme différente) + for gi, gt_item in enumerate(gt_items): + if matched[gi]: + n_value += 1 # strict implique value + continue + gt_val = value_extractor(gt_item) + for hi, hyp_item in enumerate(hyp_items): + if hyp_used[hi]: + continue + if value_extractor(hyp_item) == gt_val: + hyp_used[hi] = True + matched[gi] = True + n_value += 1 + break + if not matched[gi]: + lost.append(form_extractor(gt_item)) + n_total = len(gt_items) + return { + "n_total": n_total, + "strict": n_strict, + "value": n_value, + "strict_score": n_strict / n_total if n_total else 0.0, + "value_score": n_value / n_total if n_total else 0.0, + "lost_items": lost, + } + + +def compute_numerical_sequence_metrics( + reference: Optional[str], + hypothesis: Optional[str], +) -> dict: + """Calcule la précision sur séquences numériques. + + Returns + ------- + dict + Voir docstring du module. Si ``reference`` est vide + ou ne contient aucune séquence détectée, retourne + ``{n_total: 0, ...}`` avec scores à 0 (pas None). + """ + ref = reference or "" + hyp = hypothesis or "" + + # Spécifications par catégorie : (gt_items, hyp_items, + # extractor de forme, extractor de valeur). + specs: dict[str, dict] = {} + # year : (form="1789", value=1789) + specs["year"] = { + "gt": _detect_years(ref), + "hyp": _detect_years(hyp), + "form": lambda it: it[0], + "value": lambda it: it[1], + } + # roman : (form="MDCLXVIII", value=1668) + specs["roman"] = { + "gt": _detect_romans_with_values(ref), + "hyp": _detect_romans_with_values(hyp), + "form": lambda it: it[0], + "value": lambda it: it[1], + } + # foliation : (form="f. 12r", value="12r") + specs["foliation"] = { + "gt": _detect_foliations(ref), + "hyp": _detect_foliations(hyp), + "form": lambda it: it[0], + "value": lambda it: it[1], + } + # currency : (form="12 livres", value=("12", "livre")) + specs["currency"] = { + "gt": _detect_currencies(ref), + "hyp": _detect_currencies(hyp), + "form": lambda it: it[0], + "value": lambda it: it[1], + } + # regnal : (form="an III", value=3) + specs["regnal"] = { + "gt": _detect_regnal(ref), + "hyp": _detect_regnal(hyp), + "form": lambda it: it[0], + "value": lambda it: it[1], + } + + per_category: dict[str, dict] = {} + total = 0 + total_strict = 0 + total_value = 0 + for cat, spec in specs.items(): + breakdown = _classify_per_category( + spec["gt"], spec["hyp"], + form_extractor=spec["form"], + value_extractor=spec["value"], + ) + per_category[cat] = breakdown + total += breakdown["n_total"] + total_strict += breakdown["strict"] + total_value += breakdown["value"] + + return { + "n_total": total, + "global_strict_score": ( + total_strict / total if total else 0.0 + ), + "global_value_score": ( + total_value / total if total else 0.0 + ), + "per_category": per_category, + } + + +# ────────────────────────────────────────────────────────────────────────── +# Enregistrement registre typé +# ────────────────────────────────────────────────────────────────────────── + + +@register_metric( + name="numerical_sequence_strict_score", + input_types=(ArtifactType.TEXT, ArtifactType.TEXT), + description=( + "Précision sur séquences numériques en mode strict (forme " + "préservée). Couvre années arabes, numéraux romains, " + "foliotation, montants Ancien Régime, années régnales." + ), +) +def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float: + return compute_numerical_sequence_metrics( + reference, hypothesis, + )["global_strict_score"] + + +@register_metric( + name="numerical_sequence_value_score", + input_types=(ArtifactType.TEXT, ArtifactType.TEXT), + description=( + "Précision sur séquences numériques en mode valeur " + "(la valeur est préservée même si la forme diffère, " + "ex. XIV → 14)." + ), +) +def numerical_sequence_value_score(reference: str, hypothesis: str) -> float: + return compute_numerical_sequence_metrics( + reference, hypothesis, + )["global_value_score"] + + +__all__ = [ + "CATEGORIES", + "compute_numerical_sequence_metrics", + "numerical_sequence_strict_score", + "numerical_sequence_value_score", +] diff --git a/picarones/measurements/numerical_sequences_runner.py b/picarones/measurements/numerical_sequences_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..c1d6d1f429e85c11efa6a2a5a6db632c0566c976 --- /dev/null +++ b/picarones/measurements/numerical_sequences_runner.py @@ -0,0 +1,102 @@ +"""Câblage runner des séquences numériques (Sprint 86). + +Sprint 86 — A.II.5b (vue HTML + câblage runner). + +Le module ``picarones/core/numerical_sequences.py`` (Sprint 85) +a livré la couche de calcul. Ce helper prépare la donnée +adaptative pour le runner et agrège les compteurs par moteur. + +Adaptive masking +---------------- +On ne stocke le résultat que si la GT contient au moins une +séquence numérique détectée — sinon le module n'apparaît pas +dans le rapport. +""" + +from __future__ import annotations + +import logging +from typing import Iterable, Optional + +from picarones.core.numerical_sequences import ( + CATEGORIES, + compute_numerical_sequence_metrics, +) + +logger = logging.getLogger(__name__) + + +def compute_numerical_sequence_metrics_adaptive( + reference: Optional[str], + hypothesis: Optional[str], +) -> Optional[dict]: + """Calcule les métriques séquences numériques avec masquage + adaptatif : retourne ``None`` si la GT n'en contient + aucune.""" + if not reference: + return None + result = compute_numerical_sequence_metrics(reference, hypothesis or "") + if (result.get("n_total") or 0) == 0: + return None + return result + + +def aggregate_numerical_sequence_metrics( + per_doc: Iterable[Optional[dict]], +) -> Optional[dict]: + """Agrège par moteur : somme les compteurs par catégorie et + recalcule les scores globaux et per-category. + + Format de sortie identique à ``compute_numerical_sequence_metrics`` + pour faciliter le rendu HTML symétrique. + """ + docs = [d for d in per_doc if d] + if not docs: + return None + total_n = 0 + total_strict = 0 + total_value = 0 + per_cat: dict[str, dict] = {} + for cat in CATEGORIES: + per_cat[cat] = { + "n_total": 0, + "strict": 0, + "value": 0, + "lost_items": [], + } + for d in docs: + for cat in CATEGORIES: + cat_data = (d.get("per_category") or {}).get(cat) or {} + per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0) + per_cat[cat]["strict"] += int(cat_data.get("strict") or 0) + per_cat[cat]["value"] += int(cat_data.get("value") or 0) + per_cat[cat]["lost_items"].extend( + cat_data.get("lost_items") or [], + ) + total_n += int(d.get("n_total") or 0) + # Recalcul des scores + for cat, slot in per_cat.items(): + n = slot["n_total"] + slot["strict_score"] = slot["strict"] / n if n else 0.0 + slot["value_score"] = slot["value"] / n if n else 0.0 + # Cap des lost_items à 50 par catégorie + slot["lost_items"] = slot["lost_items"][:50] + total_strict += slot["strict"] + total_value += slot["value"] + return { + "n_docs": len(docs), + "n_total": total_n, + "global_strict_score": ( + total_strict / total_n if total_n else 0.0 + ), + "global_value_score": ( + total_value / total_n if total_n else 0.0 + ), + "per_category": per_cat, + } + + +__all__ = [ + "compute_numerical_sequence_metrics_adaptive", + "aggregate_numerical_sequence_metrics", +] diff --git a/picarones/measurements/pricing.py b/picarones/measurements/pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..2ebf1bea27ba9b17d80ba2f6d3f2e1c84e7192d3 --- /dev/null +++ b/picarones/measurements/pricing.py @@ -0,0 +1,309 @@ +"""Modélisation des coûts — APIs cloud et temps d'inférence local. + +Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5). +Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml`` +pour les hypothèses, dates et URLs de référence. + +Conventions +----------- +- Unité monétaire : EUR (conversion indicative depuis USD quand applicable). +- Coût exprimé par **1 000 pages** traitées. +- Coût local = temps moyen d'inférence × taux horaire (paramétrable). +- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau + d'exécution (mix France bas carbone par défaut pour le local, + moyenne cloud hyperscaler pour les APIs). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + +logger = logging.getLogger(__name__) + +_DEFAULT_PRICING_PATH = Path(__file__).parent.parent / "data" / "pricing.yaml" + + +@dataclass(frozen=True) +class PricingDefaults: + """Valeurs par défaut du fichier de prix (section ``meta``).""" + + last_updated: Optional[str] = None + currency: str = "EUR" + hourly_rate_local_cpu_eur: float = 0.08 + hourly_rate_local_gpu_eur: float = 1.20 + grid_intensity_local: float = 58.0 + grid_intensity_cloud: float = 380.0 + + +@dataclass +class EngineCost: + """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses. + + La représentation est immuable après construction : une fois que l'utilisateur + a choisi un taux horaire local, toutes les instances partagent cette + hypothèse par injection explicite dans ``build_costs_for_benchmark``. + """ + + engine_key: str + """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``).""" + + type: str # "local" | "cloud_api" | "unknown" + + cost_per_1k_pages_eur: Optional[float] = None + """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes.""" + + currency: str = "EUR" + + # Source / date + pricing_source_url: Optional[str] = None + pricing_date: Optional[str] = None + + # Pour les APIs cloud : prix brut + api_price_per_1k_pages: Optional[float] = None + + # Pour le local : temps d'inférence et taux horaire utilisés + local_mean_seconds_per_page: Optional[float] = None + hourly_rate_eur: Optional[float] = None + + # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport) + kwh_per_1k_pages: Optional[float] = None + grid_intensity_g_co2_per_kwh: Optional[float] = None + co2_per_1k_pages_g: Optional[float] = None + + notes: Optional[str] = None + + assumptions: list[str] = field(default_factory=list) + """Liste d'hypothèses textuelles à afficher sous le graphique.""" + + def as_dict(self) -> dict: + return { + "engine_key": self.engine_key, + "type": self.type, + "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, + "currency": self.currency, + "pricing_source_url": self.pricing_source_url, + "pricing_date": self.pricing_date, + "api_price_per_1k_pages": self.api_price_per_1k_pages, + "local_mean_seconds_per_page": self.local_mean_seconds_per_page, + "hourly_rate_eur": self.hourly_rate_eur, + "kwh_per_1k_pages": self.kwh_per_1k_pages, + "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh, + "co2_per_1k_pages_g": self.co2_per_1k_pages_g, + "notes": self.notes, + "assumptions": list(self.assumptions), + } + + +def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]: + """Charge la table de prix YAML. + + Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict + ``{engine_key: raw_entry}``. + """ + path = Path(path) if path else _DEFAULT_PRICING_PATH + if not path.exists(): + logger.warning("[pricing] fichier %s introuvable", path) + return PricingDefaults(), {} + try: + with path.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + except yaml.YAMLError as e: + logger.warning("[pricing] échec parsing %s : %s", path, e) + return PricingDefaults(), {} + + meta = data.get("meta", {}) or {} + defaults = PricingDefaults( + last_updated=meta.get("last_updated"), + currency=meta.get("currency", "EUR"), + hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)), + hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)), + grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)), + grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)), + ) + engines_table = data.get("engines", {}) or {} + return defaults, engines_table + + +def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]: + """Cherche la meilleure clé pour ce moteur dans la table. + + Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le + nom OCR, puis un match partiel (substring) comme filet de sécurité. + """ + candidates = [llm_model, engine_name] + for c in candidates: + if c and c in table: + return c + # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision" + for c in candidates: + if not c: + continue + for key in table: + if key in c: + return key + return None + + +def estimate_cost( + engine_name: str, + *, + llm_model: Optional[str] = None, + is_pipeline: bool = False, + measured_seconds_per_page: Optional[float] = None, + table: Optional[dict] = None, + defaults: Optional[PricingDefaults] = None, + hourly_rate_override_eur: Optional[float] = None, +) -> EngineCost: + """Calcule le ``EngineCost`` pour un moteur donné. + + Parameters + ---------- + engine_name: + Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``). + llm_model: + Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la + lookup car c'est lui qui domine le coût. + is_pipeline: + Indique un pipeline OCR+LLM (change la sémantique de lookup). + measured_seconds_per_page: + Temps moyen observé sur le benchmark courant. Remplace la valeur + indicative de la table si fournie (plus fiable). + table, defaults: + Overrides pour tests ou usage institutionnel. + hourly_rate_override_eur: + Taux horaire à utiliser pour le calcul local (sinon valeur table + ou défaut). + """ + if table is None or defaults is None: + _defaults, _table = load_pricing_database() + defaults = defaults or _defaults + table = table or _table + + key = _match_key(engine_name, llm_model if is_pipeline else None, table) + if key is None: + return EngineCost( + engine_key=engine_name, + type="unknown", + assumptions=["Aucune entrée dans la table de prix pour ce moteur."], + ) + + entry = table[key] + etype = str(entry.get("type", "unknown")) + notes = entry.get("notes") + assumptions: list[str] = [] + currency = defaults.currency + + cost_eur: Optional[float] = None + api_price: Optional[float] = None + local_seconds = measured_seconds_per_page + hourly_rate = None + + if etype == "cloud_api": + api_price = entry.get("api_price_per_1k_pages") + if api_price is not None: + cost_eur = float(api_price) + assumptions.append( + f"Prix API indicatif : {cost_eur:.2f} €/1000 pages " + f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})." + ) + elif etype == "local": + indicative_seconds = entry.get("local_mean_seconds_per_page") + if local_seconds is None and indicative_seconds is not None: + local_seconds = float(indicative_seconds) + assumptions.append( + f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)." + ) + elif local_seconds is not None: + assumptions.append( + f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)." + ) + + hourly_rate = ( + hourly_rate_override_eur + if hourly_rate_override_eur is not None + else entry.get("hourly_rate_override_eur") + ) + if hourly_rate is None: + # Heuristique : si l'entrée précise un override GPU, sinon CPU + hourly_rate = ( + defaults.hourly_rate_local_gpu_eur + if "gpu" in str(notes or "").lower() + else defaults.hourly_rate_local_cpu_eur + ) + hourly_rate = float(hourly_rate) + + if local_seconds is not None and hourly_rate is not None: + cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0 + assumptions.append( + f"Taux horaire appliqué : {hourly_rate:.2f} €/h " + f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})." + ) + + # Empreinte carbone optionnelle + kwh_1k = entry.get("kwh_per_1k_pages") + grid = ( + entry.get("grid_intensity_g_co2_per_kwh") + or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local) + ) + co2_g = None + if kwh_1k is not None and grid is not None: + co2_g = float(kwh_1k) * float(grid) + + return EngineCost( + engine_key=key, + type=etype, + cost_per_1k_pages_eur=cost_eur, + currency=currency, + pricing_source_url=entry.get("pricing_source_url"), + pricing_date=entry.get("pricing_date"), + api_price_per_1k_pages=api_price, + local_mean_seconds_per_page=local_seconds, + hourly_rate_eur=hourly_rate, + kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None, + grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None, + co2_per_1k_pages_g=co2_g, + notes=notes, + assumptions=assumptions, + ) + + +def build_costs_for_benchmark( + engines_summary: list[dict], + durations_by_engine: dict[str, float], + *, + hourly_rate_local_eur: Optional[float] = None, + pricing_path: Optional[Path] = None, +) -> dict[str, dict]: + """Calcule le coût de chaque moteur d'un benchmark. + + Returns + ------- + dict ``{engine_name: EngineCost.as_dict()}``. + """ + defaults, table = load_pricing_database(pricing_path) + out: dict[str, dict] = {} + for e in engines_summary: + name = e.get("name") + if not name: + continue + measured = durations_by_engine.get(name) + llm_model = None + pipeline_info = e.get("pipeline_info") or {} + if pipeline_info: + llm_model = pipeline_info.get("llm_model") + cost = estimate_cost( + engine_name=name, + llm_model=llm_model, + is_pipeline=bool(e.get("is_pipeline")), + measured_seconds_per_page=measured, + table=table, + defaults=defaults, + hourly_rate_override_eur=hourly_rate_local_eur, + ) + out[name] = cost.as_dict() + return out diff --git a/picarones/measurements/rare_tokens.py b/picarones/measurements/rare_tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..69f320e2c1b1922285c16f708f74240b51713709 --- /dev/null +++ b/picarones/measurements/rare_tokens.py @@ -0,0 +1,254 @@ +"""Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026). + +Pourquoi ce module +------------------ +Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en +masquant des **erreurs systématiques sur les tokens rares** : noms +propres, toponymes peu fréquents, mots techniques, formules latines +récurrentes mais pas dominantes. Pour un usage prosopographique +(indexation de noms, recherche généalogique), ce sont précisément +ces tokens-là qui comptent. + +Ce module mesure le **rappel sur les tokens rares** d'un corpus — +défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax + +dis legomena, terminologie de lexicométrie classique). + +Hypothèse à valider expérimentalement +------------------------------------- +La conjecture du plan A.I.1 : *« cette métrique discrimine plus +les moteurs que le CER global »*. Si confirmée sur un corpus +patrimonial réel, elle gagne sa place dans le tableau de +classement principal — décision laissée au chercheur après +observation. + +Stratégie de découpage +---------------------- +Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche +de calcul pure d'abord, sans intégration runner. La vue HTML +« worst lines / rare tokens manqués » suit dans un sprint dédié. + +Pas d'enregistrement dans le registre typé Sprint 34 +---------------------------------------------------- +La métrique exige **trois entrées** (reference, hypothesis, set +des tokens rares) et le set des rares est calculé corpus-wide +(donc connu seulement après itération sur tout le corpus). La +signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur +appelle explicitement ``compute_rare_token_recall`` avec le set +qu'il a calculé. +""" + +from __future__ import annotations + +import logging +import re +from collections import Counter +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Tokenisation Unicode-aware +# ────────────────────────────────────────────────────────────────────────── + +# Token = séquence maximale de caractères de mot Unicode (\w en +# Python 3 utilise déjà la table Unicode), incluant l'apostrophe +# typographique '’' à l'intérieur (« l'an », « d’une ») et les +# tirets internes (« peut-être »). La ponctuation isolée et les +# espaces sont des séparateurs. + +_TOKEN_RE = re.compile( + r"\w+(?:[’'\-]\w+)*", + flags=re.UNICODE, +) + + +def tokenize(text: Optional[str]) -> list[str]: + """Tokenisation Unicode-aware. + + Conserve les contractions (``l'an``, ``d’une``) et les mots + composés (``peut-être``, ``c'est-à-dire``) comme un seul token. + Casse préservée — l'utilisateur normalise lui-même via + ``case_sensitive=False`` dans les fonctions aval s'il le veut. + """ + if not text: + return [] + return _TOKEN_RE.findall(text) + + +# ────────────────────────────────────────────────────────────────────────── +# Distribution de fréquence corpus-wide +# ────────────────────────────────────────────────────────────────────────── + + +def frequency_distribution( + documents: Iterable[str], + *, + case_sensitive: bool = False, +) -> Counter[str]: + """Calcule ``{token: count}`` sur l'ensemble du corpus. + + Parameters + ---------- + documents: + Itérable de textes (typiquement les ``ground_truth`` des + documents du corpus). + case_sensitive: + Si ``False`` (défaut), tous les tokens sont mis en + minuscule avant comptage. + """ + counter: Counter[str] = Counter() + for doc in documents: + tokens = tokenize(doc) + if not case_sensitive: + tokens = [t.lower() for t in tokens] + counter.update(tokens) + return counter + + +def extract_rare_tokens( + documents: Iterable[str], + *, + max_freq: int = 2, + case_sensitive: bool = False, +) -> frozenset[str]: + """Retourne l'ensemble des tokens dont la fréquence + corpus-wide est ``≤ max_freq``. + + Convention de lexicométrie : ``max_freq=1`` retourne uniquement + les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne + hapax + dis legomena (≤ 2 occurrences) — défaut. + + Les tokens qui n'apparaissent **jamais** dans le corpus ne sont + évidemment pas inclus (le ``Counter`` ne les liste pas). + """ + if max_freq < 1: + raise ValueError("max_freq doit être ≥ 1") + counter = frequency_distribution( + documents, case_sensitive=case_sensitive, + ) + return frozenset(t for t, c in counter.items() if c <= max_freq) + + +# ────────────────────────────────────────────────────────────────────────── +# Calcul du rappel par document +# ────────────────────────────────────────────────────────────────────────── + + +def compute_rare_token_recall( + reference: Optional[str], + hypothesis: Optional[str], + rare_tokens: Iterable[str], + *, + case_sensitive: bool = False, +) -> dict: + """Calcule le rappel sur les tokens rares présents dans la GT. + + Parameters + ---------- + reference: + Texte GT du document. + hypothesis: + Texte produit par l'OCR. + rare_tokens: + Itérable des tokens rares — typiquement le résultat de + ``extract_rare_tokens`` sur le corpus complet. + case_sensitive: + Si ``False`` (défaut), la comparaison se fait sur les + formes minuscules. + + Returns + ------- + dict + ``{ + "n_rare_tokens_in_reference": int, + # nombre d'**occurrences** de tokens rares dans la GT + # (multiplicité préservée — un token rare présent 2 + # fois compte 2) + "n_rare_tokens_recalled": int, + # nombre d'occurrences correctement présentes dans hyp + # (alignement bag-of-tokens : min(count_ref, count_hyp)) + "recall": float, + # ratio dans [0, 1], ou 0.0 si aucun rare en GT + "missed_tokens": list[str], + # liste des tokens rares **manqués** (avec multiplicité, + # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp → + # missed_tokens contient ["Dupont"] une fois) + }`` + + Cas dégénérés + ------------- + - GT vide ou aucun token rare présent → recall = 0.0, listes + vides (convention : on ne récompense pas l'absence de + tokens rares). + - Hyp vide avec rares en GT → tous manqués, recall = 0.0. + """ + ref = reference or "" + hyp = hypothesis or "" + + if case_sensitive: + rare_set = frozenset(rare_tokens) + ref_tokens = tokenize(ref) + hyp_tokens = tokenize(hyp) + else: + rare_set = frozenset(t.lower() for t in rare_tokens) + ref_tokens = [t.lower() for t in tokenize(ref)] + hyp_tokens = [t.lower() for t in tokenize(hyp)] + + # Multiplicité : on compte uniquement les rares présents dans la GT + ref_rare_counts: Counter[str] = Counter( + t for t in ref_tokens if t in rare_set + ) + n_rare_in_ref = sum(ref_rare_counts.values()) + if n_rare_in_ref == 0: + return { + "n_rare_tokens_in_reference": 0, + "n_rare_tokens_recalled": 0, + "recall": 0.0, + "missed_tokens": [], + } + + # Bag-of-tokens dans hyp pour les tokens rares uniquement + hyp_rare_counts: Counter[str] = Counter( + t for t in hyp_tokens if t in rare_set + ) + # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count) + n_recalled = 0 + missed: list[str] = [] + for token, ref_count in ref_rare_counts.items(): + hyp_count = hyp_rare_counts.get(token, 0) + recalled = min(ref_count, hyp_count) + n_recalled += recalled + missed_count = ref_count - recalled + if missed_count > 0: + missed.extend([token] * missed_count) + + return { + "n_rare_tokens_in_reference": n_rare_in_ref, + "n_rare_tokens_recalled": n_recalled, + "recall": n_recalled / n_rare_in_ref, + "missed_tokens": missed, + } + + +def rare_token_recall( + reference: Optional[str], + hypothesis: Optional[str], + rare_tokens: Iterable[str], + *, + case_sensitive: bool = False, +) -> float: + """Raccourci : retourne uniquement le rappel ∈ [0, 1].""" + return compute_rare_token_recall( + reference, hypothesis, rare_tokens, + case_sensitive=case_sensitive, + )["recall"] + + +__all__ = [ + "tokenize", + "frequency_distribution", + "extract_rare_tokens", + "compute_rare_token_recall", + "rare_token_recall", +] diff --git a/picarones/measurements/readability.py b/picarones/measurements/readability.py new file mode 100644 index 0000000000000000000000000000000000000000..941709c25b9f539366eb9c1a15a8625a97820d88 --- /dev/null +++ b/picarones/measurements/readability.py @@ -0,0 +1,252 @@ +"""Métriques de lisibilité (Flesch) — Sprint 52. + +Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure +de la métrique Flesch, indépendante de tout alignement OCR/GT. + +Pourquoi ce module +------------------ +Les LLM produisent du texte plus « lisse » que les manuscrits +historiques. Cette tendance à la modernisation est mesurable par la +différence de score de lisibilité entre la GT et la sortie OCR/LLM — +**indépendamment des classes taxonomiques** et **sans alignement +caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne +même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte +moderne plausible mais déconnecté de la GT). + +Stratégie de découpage +---------------------- +Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on +découpe : + +- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et + ``flesch_delta``. Aucune dépendance externe ; les heuristiques de + comptage de syllabes sont en pur Python, déterministes, testées. +- **Sprints suivants** — câblage runner pour calculer + ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML. + +Formules +-------- +- **Anglais** (Flesch original 1948) : + ``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)`` +- **Français** (Kandel-Moles 1958) : + ``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)`` + +Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire », +0 ↔ « très difficile ». Une **augmentation** du score quand on passe +de la GT à l'OCR signale une simplification (typique des LLM +modernisants). Une **chute** signale une dégradation OCR. + +Limites documentées +------------------- +- Le comptage de syllabes est heuristique. En français, des règles + comme « -ier non final = 2 syllabes » ne sont pas appliquées + finement. Acceptable pour une métrique de **comparaison relative** + (delta GT vs OCR), pas pour publier une absolue. +- Sur des textes très courts (< 20 mots), la formule perd en + fiabilité. Le seuil minimal est documenté. +""" + +from __future__ import annotations + +import logging +import re +from typing import Literal + +from picarones.core.metric_registry import register_metric +from picarones.core.modules import ArtifactType + +logger = logging.getLogger(__name__) + + +Language = Literal["fr", "en"] + +# Coefficients de la formule Flesch selon la langue. +_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = { + "en": (206.835, 1.015, 84.6), # Flesch 1948 + "fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958 +} + +# Voyelles utilisées pour l'heuristique de comptage de syllabes. +# On utilise un set qui inclut les diacritiques courantes en FR/EN. +_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ") + +# Regex de découpage en phrases : ponctuation finale + espace ou fin. +# Tolère les multiples points (« ... ») et garde un découpage robuste. +_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)") + +# Regex de tokenisation simple (mots) : séquences de caractères "lettres". +_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE) + + +# ────────────────────────────────────────────────────────────────────────── +# Compteurs de base +# ────────────────────────────────────────────────────────────────────────── + + +def count_words(text: str) -> int: + """Nombre de mots (tokens alphanumériques) dans ``text``.""" + if not text: + return 0 + return len(_WORD_RE.findall(text)) + + +def count_sentences(text: str) -> int: + """Nombre de phrases dans ``text``. + + Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``). + Renvoie au minimum 1 si ``text`` contient au moins un mot, pour + éviter une division par zéro dans la formule de Flesch sur les + textes sans ponctuation finale. + """ + if not text: + return 0 + parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()] + n = len(parts) + if n == 0 and count_words(text) > 0: + return 1 + return n + + +def count_syllables_word(word: str) -> int: + """Heuristique de comptage de syllabes pour un mot isolé. + + Règle : on compte les **groupes de voyelles consécutives** (en + incluant ``y`` et les diacritiques courantes). C'est une + approximation grossière mais déterministe et testable. + + Cas limites : + - mot vide → 0 + - mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``) + - mot d'une seule voyelle isolée → 1 + """ + if not word: + return 0 + word = word.lower() + in_vowel_group = False + count = 0 + for ch in word: + if ch in _VOWELS: + if not in_vowel_group: + count += 1 + in_vowel_group = True + else: + in_vowel_group = False + return count or 1 + + +def count_syllables(text: str) -> int: + """Somme des syllabes de tous les mots de ``text``.""" + if not text: + return 0 + return sum(count_syllables_word(w) for w in _WORD_RE.findall(text)) + + +# ────────────────────────────────────────────────────────────────────────── +# Score Flesch +# ────────────────────────────────────────────────────────────────────────── + + +def flesch_score(text: str, lang: Language = "fr") -> float: + """Calcule le score de lisibilité Flesch pour ``text``. + + Parameters + ---------- + text: + Texte à évaluer. Peut contenir ponctuation, accents, etc. + lang: + ``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948). + + Returns + ------- + float + Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte + vide ou sans mot exploitable. + + Notes + ----- + Le score chute fortement avec : + - longues phrases (mots/phrases élevé) + - mots polysyllabiques (syllabes/mots élevé) + Une montée du score lors du passage GT → OCR signale qu'un LLM a + « lissé » la langue (phrases plus courtes, mots plus communs). + """ + if lang not in _FLESCH_COEFFS: + raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.") + + n_words = count_words(text) + if n_words == 0: + return 0.0 + n_sentences = max(1, count_sentences(text)) + n_syllables = count_syllables(text) + if n_syllables == 0: + return 0.0 + + base, k_words, k_syll = _FLESCH_COEFFS[lang] + raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words) + return max(0.0, min(100.0, raw)) + + +def flesch_delta( + reference: str, + hypothesis: str, + lang: Language = "fr", +) -> float: + """Différence ``flesch_score(hypothesis) - flesch_score(reference)``. + + Interprétation + -------------- + - **Positif** : l'hypothèse OCR est plus lisible que la GT — + signal d'**over-normalisation** (typique des LLM qui modernisent + des textes anciens). + - **Négatif** : l'OCR est moins lisible — signal de dégradation + (caractères mal reconnus brisent la fluidité). + - **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique. + + Borné dans ``[-100, +100]``. + """ + return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang) + + +# ────────────────────────────────────────────────────────────────────────── +# Enregistrement dans le registre typé (Sprint 34) +# ────────────────────────────────────────────────────────────────────────── + + +@register_metric( + name="flesch_delta_fr", + input_types=(ArtifactType.TEXT, ArtifactType.TEXT), + description=( + "Différence de score Flesch (Kandel-Moles, FR) entre la sortie " + "OCR et la GT. Positif = OCR plus lisible (signal " + "d'over-normalisation LLM). Aucun alignement requis." + ), + higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant + tags={"text", "readability", "over_normalization"}, +) +def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float: + return flesch_delta(reference, hypothesis, lang="fr") + + +@register_metric( + name="flesch_delta_en", + input_types=(ArtifactType.TEXT, ArtifactType.TEXT), + description=( + "Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. " + "Positive = OCR easier to read than GT (LLM smoothing signal). " + "No alignment required." + ), + higher_is_better=False, + tags={"text", "readability", "over_normalization"}, +) +def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float: + return flesch_delta(reference, hypothesis, lang="en") + + +__all__ = [ + "flesch_score", + "flesch_delta", + "count_words", + "count_sentences", + "count_syllables", + "count_syllables_word", +] diff --git a/picarones/measurements/readability_runner.py b/picarones/measurements/readability_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..073b5f16fca2a59993e08453c191f915d8f75405 --- /dev/null +++ b/picarones/measurements/readability_runner.py @@ -0,0 +1,114 @@ +"""Câblage runner du delta Flesch (Sprint 87 — A.II.2). + +Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch +livré par le Sprint 52). + +Pourquoi ce module +------------------ +Le ``flesch_delta`` mesure la différence de lisibilité entre la +GT et la sortie OCR. Un score positif signale une *over- +normalisation* typique des LLM/VLM qui modernisent un texte +ancien (le Flesch monte parce que les mots sont plus simples) ; +un score négatif signale une dégradation OCR brutale. + +Cette métrique est calculée **automatiquement** par le runner +sur chaque document, agrégée par moteur, et présentée dans le +rapport. + +Adaptive masking +---------------- +On ne calcule que si la GT contient ≥ 5 mots — en dessous, le +Flesch est trop instable pour être informatif. + +Langue +------ +Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour +les corpus mixtes, l'utilisateur peut passer une langue +explicite à l'orchestrateur. +""" + +from __future__ import annotations + +import logging +import statistics +from typing import Iterable, Optional + +from picarones.core.readability import ( + Language, + count_words, + flesch_delta, + flesch_score, +) + +logger = logging.getLogger(__name__) + + +_MIN_WORDS_FOR_FLESCH = 5 + + +def compute_readability_metrics( + reference: Optional[str], + hypothesis: Optional[str], + *, + lang: Language = "fr", +) -> Optional[dict]: + """Calcule le delta Flesch d'un document avec adaptive masking. + + Retourne ``None`` si la GT contient moins de + ``_MIN_WORDS_FOR_FLESCH`` mots. + """ + ref = reference or "" + n_ref_words = count_words(ref) + if n_ref_words < _MIN_WORDS_FOR_FLESCH: + return None + hyp = hypothesis or "" + flesch_ref = flesch_score(ref, lang=lang) + flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None + delta = ( + flesch_delta(ref, hyp, lang=lang) if hyp else None + ) + return { + "lang": lang, + "flesch_reference": flesch_ref, + "flesch_hypothesis": flesch_hyp, + "flesch_delta": delta, + "n_words_reference": n_ref_words, + } + + +def aggregate_readability_metrics( + per_doc: Iterable[Optional[dict]], +) -> Optional[dict]: + """Agrège : moyenne/médiane des deltas + part de docs + « over-normalisés » (delta > +5 points). + """ + docs = [d for d in per_doc if d] + if not docs: + return None + deltas = [ + float(d["flesch_delta"]) for d in docs + if isinstance(d.get("flesch_delta"), (int, float)) + ] + if not deltas: + return None + over_norm = sum(1 for d in deltas if d > 5.0) + under_norm = sum(1 for d in deltas if d < -5.0) + lang = docs[0].get("lang") or "fr" + return { + "lang": lang, + "n_docs": len(docs), + "n_docs_with_delta": len(deltas), + "delta_mean": statistics.fmean(deltas), + "delta_median": statistics.median(deltas), + "delta_min": min(deltas), + "delta_max": max(deltas), + "n_over_normalized": over_norm, + "n_under_normalized": under_norm, + "over_normalized_rate": over_norm / len(deltas), + } + + +__all__ = [ + "compute_readability_metrics", + "aggregate_readability_metrics", +] diff --git a/picarones/measurements/reading_order.py b/picarones/measurements/reading_order.py new file mode 100644 index 0000000000000000000000000000000000000000..655557dcffc56ed40554a1ea2369542f635859a1 --- /dev/null +++ b/picarones/measurements/reading_order.py @@ -0,0 +1,196 @@ +"""Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53. + +Sprint 53 — A.II.2.1 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Sur un manuscrit glosé, un journal multi-colonnes ou un registre +paroissial complexe, le **classement des moteurs en CER** peut être +trompeur : un moteur peut avoir un excellent CER caractère et un +**ordre de lecture catastrophique**. Le résultat est inutilisable +pour la recherche plein texte (Elastic, Solr) ou pour reconstituer +une narration linéaire. + +La métrique standard est définie par Antonacopoulos et al. dans +ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions +ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède +``b`` dans la GT : + +- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse, +- **FN** si la paire est manquante (régions absentes ou ordre + inversé) côté hypothèse, +- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que + la GT n'a pas cet ordre (régions hallucinées ou inversion). + +Le F1 est la moyenne harmonique des deux. + +Stratégie de découpage +---------------------- +Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch +(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit +deux listes ordonnées d'IDs de régions (typiquement extraites de +ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML +suivent dans des sprints dédiés. + +Compatible directement avec ``ReadingOrderGT`` du Sprint 32 : +``ReadingOrderGT.region_order`` est exactement le format attendu. + +Convention sur les régions +-------------------------- +- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.). +- Les **doublons** sont ignorés au calcul des paires ordonnées + (chaque ID compte une fois par séquence). +- Une région présente dans la GT mais absente de l'hypothèse + contribue aux paires FN. +- Une région présente dans l'hypothèse mais absente de la GT + contribue aux paires FP. +- Si une séquence a < 2 régions distinctes, aucune paire n'est + émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux + séquences soient identiques. +""" + +from __future__ import annotations + +import logging +from itertools import combinations +from typing import Iterable + +from picarones.core.metric_registry import register_metric +from picarones.core.modules import ArtifactType + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────── + + +def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]: + """Retourne l'ensemble des paires ``(a, b)`` telles que ``a`` + précède strictement ``b`` dans ``sequence``. + + Doublons : chaque ID est traité une seule fois (première occurrence + dans la séquence). Cohérent avec ICDAR 2015 où les régions ont + des IDs uniques. + """ + seen: list[str] = [] + seen_set: set[str] = set() + for r in sequence: + if r not in seen_set: + seen.append(r) + seen_set.add(r) + return set(combinations(seen, 2)) + + +def _normalize_input(value: Iterable[str] | None) -> list[str]: + """Coerce une entrée en list[str], en filtrant les valeurs vides.""" + if value is None: + return [] + return [str(v) for v in value if v is not None and str(v).strip()] + + +# ────────────────────────────────────────────────────────────────────────── +# Métrique principale +# ────────────────────────────────────────────────────────────────────────── + + +def compute_reading_order_metrics( + reference_order: Iterable[str] | None, + hypothesis_order: Iterable[str] | None, +) -> dict: + """Calcule precision / recall / F1 sur l'ordre relatif des régions. + + Parameters + ---------- + reference_order: + Séquence ordonnée d'IDs de régions issue de la GT (typiquement + ``ReadingOrderGT.region_order`` du Sprint 32). + hypothesis_order: + Séquence ordonnée d'IDs de régions produite par un moteur + OCR/HTR ou un reconstructeur ALTO. + + Returns + ------- + dict + ``{"precision", "recall", "f1", "true_positives", + "false_positives", "false_negatives", "n_ref_pairs", + "n_hyp_pairs", "common_regions", "ref_only_regions", + "hyp_only_regions"}``. + + Comportements aux bornes + ------------------------ + - Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0. + - Ordre strictement inversé → F1 = 0.0 (toutes les paires + relatives sont fausses). + - Une séquence vide vs une séquence non vide → F1 = 0.0. + - Deux séquences vides → F1 = 0.0 et tous les compteurs à 0 + (convention : on ne récompense pas l'absence). + """ + ref = _normalize_input(reference_order) + hyp = _normalize_input(hypothesis_order) + + ref_pairs = _ordered_pairs(ref) + hyp_pairs = _ordered_pairs(hyp) + + tp = len(ref_pairs & hyp_pairs) + fn = len(ref_pairs - hyp_pairs) + fp = len(hyp_pairs - ref_pairs) + + precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f1 = ( + 2 * precision * recall / (precision + recall) + if (precision + recall) > 0 + else 0.0 + ) + + ref_set = set(ref) + hyp_set = set(hyp) + return { + "precision": precision, + "recall": recall, + "f1": f1, + "true_positives": tp, + "false_positives": fp, + "false_negatives": fn, + "n_ref_pairs": len(ref_pairs), + "n_hyp_pairs": len(hyp_pairs), + "common_regions": sorted(ref_set & hyp_set), + "ref_only_regions": sorted(ref_set - hyp_set), + "hyp_only_regions": sorted(hyp_set - ref_set), + } + + +# ────────────────────────────────────────────────────────────────────────── +# Enregistrement dans le registre typé (Sprint 34) +# ────────────────────────────────────────────────────────────────────────── + + +@register_metric( + name="reading_order_f1", + input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER), + description=( + "F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, " + "Antonacopoulos). Pour chaque paire (a,b) où a précède b dans " + "la GT, vérifie que a précède aussi b dans l'hypothèse." + ), + higher_is_better=True, + tags={"structure", "icdar", "alto", "page"}, +) +def reading_order_f1( + reference: Iterable[str] | None, + hypothesis: Iterable[str] | None, +) -> float: + """Raccourci : retourne uniquement le F1 global. + + Pour les détails par paire (TP/FP/FN, régions communes, etc.), + appeler ``compute_reading_order_metrics`` directement. + """ + return compute_reading_order_metrics(reference, hypothesis)["f1"] + + +__all__ = [ + "compute_reading_order_metrics", + "reading_order_f1", +] diff --git a/picarones/measurements/reliability.py b/picarones/measurements/reliability.py new file mode 100644 index 0000000000000000000000000000000000000000..116bdc28d8312cc1a702d6d0caf156d2db78e0b9 --- /dev/null +++ b/picarones/measurements/reliability.py @@ -0,0 +1,360 @@ +"""Métriques de fiabilité — Sprint 83 (A.II.4). + +Sprint 83 — A.II.4 du plan d'évolution 2026 (Étape 4). + +Pourquoi ce module +------------------ +Une publication scientifique qui rapporte un CER LLM sans +stabilité est méthodologiquement faible. Et un benchmark qui +ignore le plafond humain (« deux paléographes ne sont pas même +d'accord ») crée des classements faussement optimistes. Ce +module livre deux familles complémentaires : + +1. **Inter-annotator agreement (IAA)** — quand un document a + plusieurs GT (deux paléographes, par ex.), Cohen κ et + Krippendorff α mesurent l'accord au niveau caractère. + Lecture : *« le CER de Pero (4,2 %) approche le plafond + humain (κ = 0,89). »* + +2. **Stabilité multi-runs** — quand on relance la même + pipeline LLM N fois sur les mêmes documents, on mesure : + variance du CER, taux de tokens divergents entre runs, + CER pairwise moyen. + +Périmètre Sprint 83 +------------------- +**Couche de calcul uniquement** — fonctions pures, pas +d'intégration runner ni de vue HTML. L'extension du loader +pour accepter ``doc_001.gt.A.txt`` / ``doc_001.gt.B.txt`` est +documentée comme dépendance future ; en attendant le sprint +dédié, on prend deux strings GT en entrée. + +Méthode +------- +*IAA caractère par caractère.* On aligne les deux GT par +``difflib.SequenceMatcher`` au niveau caractère et on construit +une table de contingence ``(annotator_a_char, annotator_b_char)`` +sur les positions ``equal`` ou ``replace``. Cohen κ utilise +cette table directement. Krippendorff α utilise la version +matricielle (différence binaire pour le mode nominal). + +*Stabilité multi-runs.* ``compute_multirun_stability(runs)`` +prend une liste de N transcriptions du **même** document et +renvoie variance/écart-type/coefficient de variation du CER si +référence fournie ; sinon, taux pairwise de divergence +(intersection-vs-union des tokens). +""" + +from __future__ import annotations + +import logging +import statistics +from typing import Optional, Sequence + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Helpers d'alignement caractère par caractère +# ────────────────────────────────────────────────────────────────────────── + + +def _aligned_char_pairs( + text_a: str, text_b: str, +) -> list[tuple[str, str]]: + """Aligne ``text_a`` et ``text_b`` caractère par caractère. + + Retourne la liste des paires alignées sur les segments + ``equal`` et ``replace`` de ``SequenceMatcher`` (les ``insert`` + et ``delete`` sont ignorés — pas d'alignement valide). + """ + if not text_a and not text_b: + return [] + import difflib + matcher = difflib.SequenceMatcher(None, text_a, text_b, autojunk=False) + pairs: list[tuple[str, str]] = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + for k in range(i2 - i1): + pairs.append((text_a[i1 + k], text_b[j1 + k])) + elif tag == "replace": + paired = min(i2 - i1, j2 - j1) + for k in range(paired): + pairs.append((text_a[i1 + k], text_b[j1 + k])) + # insert/delete : pas d'alignement bilatéral exploitable + return pairs + + +__all__: list[str] = [] + + +# ────────────────────────────────────────────────────────────────────────── +# 1. Cohen's kappa (deux annotateurs, accord nominal) +# ────────────────────────────────────────────────────────────────────────── + + +def cohen_kappa( + annotations_a: Sequence, + annotations_b: Sequence, +) -> Optional[float]: + """Cohen's κ entre deux annotateurs sur des observations + appariées. + + Définition : + + κ = (po - pe) / (1 - pe) + + où ``po`` est l'accord observé (proportion de paires égales) + et ``pe`` l'accord attendu par hasard (somme sur les classes + de p_a(c) × p_b(c)). + + Conventions : + - retourne ``None`` si les deux séquences sont vides ou de + tailles incompatibles ; + - κ = 1.0 quand l'accord est parfait, 0.0 quand il égale le + hasard, négatif si pire que le hasard ; + - quand ``pe == 1`` (un seul label dans les deux séquences), + retourne 1.0 si les séquences sont identiques, 0.0 sinon + (κ est mathématiquement indéfini, on choisit une + convention transparente documentée). + """ + if len(annotations_a) != len(annotations_b): + return None + n = len(annotations_a) + if n == 0: + return None + # Accord observé + agree = sum(1 for a, b in zip(annotations_a, annotations_b) if a == b) + p_o = agree / n + # Accord attendu par hasard + from collections import Counter + count_a = Counter(annotations_a) + count_b = Counter(annotations_b) + classes = set(count_a) | set(count_b) + p_e = sum( + (count_a.get(c, 0) / n) * (count_b.get(c, 0) / n) + for c in classes + ) + if p_e >= 1.0 - 1e-12: + # Indéfini ; convention : 1 si identité totale, 0 sinon + return 1.0 if p_o >= 1.0 - 1e-12 else 0.0 + return (p_o - p_e) / (1.0 - p_e) + + +__all__.append("cohen_kappa") + + +# ────────────────────────────────────────────────────────────────────────── +# 2. Krippendorff's alpha (généralisation à N annotateurs) +# ────────────────────────────────────────────────────────────────────────── + + +def krippendorff_alpha( + annotations_per_unit: Sequence[Sequence], +) -> Optional[float]: + """Krippendorff's α en mode nominal pour N annotateurs. + + Parameters + ---------- + annotations_per_unit: + Liste d'unités, chaque unité étant la liste des + annotations produites par les différents annotateurs sur + cette unité. ``None`` dans une cellule = annotation + manquante (autorisée). + + Définition (Krippendorff 1980, équation pour métrique + nominale) : + + α = 1 - D_o / D_e + + où ``D_o`` est le désaccord observé (paires en désaccord + intra-unité, normalisées) et ``D_e`` le désaccord attendu + par hasard. ``α = 1`` accord parfait, ``α = 0`` hasard, + négatif si pire. + + Conventions : + - unités avec moins de 2 annotations valides : ignorées + (Krippendorff convention) ; + - retourne ``None`` si moins d'une unité utilisable ou + ``D_e == 0`` (un seul label dans tout le corpus). + """ + from collections import Counter + # Valeurs observées au niveau corpus + value_counts: Counter = Counter() + pair_disagree = 0.0 + pair_total = 0.0 + for unit in annotations_per_unit: + valid = [v for v in unit if v is not None] + m = len(valid) + if m < 2: + continue + # paires intra-unité (sans repetition, ordonné) + for i in range(m): + for j in range(m): + if i == j: + continue + pair_total += 1.0 / (m - 1) + if valid[i] != valid[j]: + pair_disagree += 1.0 / (m - 1) + for v in valid: + value_counts[v] += 1 + if pair_total == 0: + return None + n_total = sum(value_counts.values()) + if n_total < 2: + return None + # Désaccord attendu (sur paires aléatoires sans remise) + expected_disagree = 0.0 + for v_a, c_a in value_counts.items(): + for v_b, c_b in value_counts.items(): + if v_a != v_b: + expected_disagree += c_a * c_b + expected_disagree /= n_total * (n_total - 1) + if expected_disagree <= 1e-12: + return None + d_o = pair_disagree / pair_total + return 1.0 - (d_o / expected_disagree) + + +__all__.append("krippendorff_alpha") + + +# ────────────────────────────────────────────────────────────────────────── +# 3. Helpers IAA caractère +# ────────────────────────────────────────────────────────────────────────── + + +def compute_iaa( + transcription_a: str, + transcription_b: str, +) -> Optional[dict]: + """Calcule κ et α au niveau caractère entre deux + transcriptions du même document. + + Aligne via ``_aligned_char_pairs`` puis : + - κ : sur la liste des paires alignées ; + - α : sur les unités à 2 annotations (équivalent à κ sur ce + cas, mais le cadre généralise à N annotateurs). + + Retourne ``None`` si pas d'alignement possible (transcriptions + vides ou totalement disjointes). + """ + pairs = _aligned_char_pairs(transcription_a, transcription_b) + if not pairs: + return None + kappa = cohen_kappa([a for a, _ in pairs], [b for _, b in pairs]) + alpha = krippendorff_alpha([[a, b] for a, b in pairs]) + return { + "n_aligned_chars": len(pairs), + "cohen_kappa": kappa, + "krippendorff_alpha": alpha, + "agreement_rate": ( + sum(1 for a, b in pairs if a == b) / len(pairs) + ), + } + + +__all__.append("compute_iaa") + + +# ────────────────────────────────────────────────────────────────────────── +# 4. Stabilité multi-runs (variance CER, divergence pairwise) +# ────────────────────────────────────────────────────────────────────────── + + +def _split_words(text: str) -> list[str]: + return text.split() if text else [] + + +def compute_multirun_stability( + runs: Sequence[str], + *, + reference: Optional[str] = None, +) -> Optional[dict]: + """Mesure la stabilité de N runs successifs d'une même + pipeline (typiquement LLM/VLM non déterministe) sur un + document. + + Parameters + ---------- + runs: + Liste des transcriptions produites à chaque run (≥ 2). + reference: + Transcription de référence (GT). Si fournie, on calcule + ``cer_per_run``, leur variance et leur coefficient de + variation. + + Returns + ------- + dict | None + ``{ + "n_runs": int, + "pairwise_disagreement_mean": float, # divergence moyenne + "pairwise_disagreement_max": float, + "identical_run_rate": float, # paires identiques / total + "cer_per_run": Optional[list[float]], + "cer_mean": Optional[float], + "cer_stdev": Optional[float], + "cer_cv": Optional[float], # cv = stdev / mean + "n_distinct_outputs": int, + }`` + ou ``None`` si moins de 2 runs. + """ + if len(runs) < 2: + return None + runs_list = list(runs) + # Divergence pairwise (token-level Jaccard distance) + n = len(runs_list) + n_pairs = 0 + sum_disagree = 0.0 + max_disagree = 0.0 + n_identical = 0 + for i in range(n): + for j in range(i + 1, n): + n_pairs += 1 + tokens_i = set(_split_words(runs_list[i])) + tokens_j = set(_split_words(runs_list[j])) + union = tokens_i | tokens_j + if not union: + disagree = 0.0 + else: + disagree = 1.0 - len(tokens_i & tokens_j) / len(union) + sum_disagree += disagree + if disagree > max_disagree: + max_disagree = disagree + if runs_list[i] == runs_list[j]: + n_identical += 1 + pairwise_mean = sum_disagree / n_pairs if n_pairs else 0.0 + identical_rate = n_identical / n_pairs if n_pairs else 0.0 + distinct = len(set(runs_list)) + + cer_per_run: Optional[list[float]] = None + cer_mean: Optional[float] = None + cer_stdev: Optional[float] = None + cer_cv: Optional[float] = None + if reference is not None: + from picarones.core.metrics import _cer_from_strings + cer_per_run = [_cer_from_strings(reference, r) for r in runs_list] + cer_per_run = [v for v in cer_per_run if v is not None] + if cer_per_run: + cer_mean = statistics.fmean(cer_per_run) + if len(cer_per_run) >= 2: + cer_stdev = statistics.stdev(cer_per_run) + cer_cv = ( + cer_stdev / cer_mean if cer_mean and cer_mean > 0 + else None + ) + return { + "n_runs": n, + "pairwise_disagreement_mean": pairwise_mean, + "pairwise_disagreement_max": max_disagree, + "identical_run_rate": identical_rate, + "n_distinct_outputs": distinct, + "cer_per_run": cer_per_run, + "cer_mean": cer_mean, + "cer_stdev": cer_stdev, + "cer_cv": cer_cv, + } + + +__all__.append("compute_multirun_stability") diff --git a/picarones/measurements/robustness.py b/picarones/measurements/robustness.py new file mode 100644 index 0000000000000000000000000000000000000000..27f407234709718dab9752114a00250c0fc2db60 --- /dev/null +++ b/picarones/measurements/robustness.py @@ -0,0 +1,731 @@ +"""Analyse de robustesse des moteurs OCR face aux dégradations d'image. + +Fonctionnement +-------------- +1. Génération de versions dégradées des images du corpus à différents niveaux : + - Bruit gaussien (sigma croissant) + - Flou gaussien (kernel size croissant) + - Rotation (angle croissant) + - Réduction de résolution (facteur de downscaling) + - Binarisation (seuillage Otsu ou fixe) +2. Exécution du moteur OCR sur chaque version dégradée +3. Calcul du CER pour chaque niveau de dégradation +4. Génération de courbes de robustesse (CER en fonction du niveau) +5. Identification du seuil critique (niveau à partir duquel CER > seuil) + +Usage +----- +>>> from picarones.core.robustness import RobustnessAnalyzer +>>> analyzer = RobustnessAnalyzer(engine, degradation_types=["noise", "blur"]) +>>> report = analyzer.analyze(corpus) +>>> print(report.critical_thresholds) +""" + +from __future__ import annotations + +import logging +import math +import os +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from picarones.core.corpus import Corpus, Document + from picarones.engines.base import BaseOCREngine + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Paramètres de dégradation +# --------------------------------------------------------------------------- + +# Niveaux de dégradation pour chaque type +DEGRADATION_LEVELS: dict[str, list] = { + "noise": [0, 5, 15, 30, 50, 80], # sigma du bruit gaussien + "blur": [0, 1, 2, 3, 5, 8], # rayon du flou gaussien (pixels) + "rotation": [0, 1, 2, 5, 10, 20], # angle de rotation (degrés) + "resolution": [1.0, 0.75, 0.5, 0.33, 0.25, 0.1], # facteur de résolution + "binarization": [0, 64, 96, 128, 160, 192], # seuil de binarisation (0 = Otsu) +} + +DEGRADATION_LABELS: dict[str, list[str]] = { + "noise": ["original", "σ=5", "σ=15", "σ=30", "σ=50", "σ=80"], + "blur": ["original", "r=1", "r=2", "r=3", "r=5", "r=8"], + "rotation": ["0°", "1°", "2°", "5°", "10°", "20°"], + "resolution": ["100%", "75%", "50%", "33%", "25%", "10%"], + "binarization": ["original", "seuil=64", "seuil=96", "seuil=128", "seuil=160", "seuil=192"], +} + +ALL_DEGRADATION_TYPES = list(DEGRADATION_LEVELS.keys()) + + +# --------------------------------------------------------------------------- +# Dégradation d'image (pure Python + stdlib, optionnellement Pillow/NumPy) +# --------------------------------------------------------------------------- + +def _apply_gaussian_noise(pixels: list[list[list[int]]], sigma: float, rng_seed: int = 0) -> list[list[list[int]]]: + """Applique du bruit gaussien (pure Python).""" + import random + rng = random.Random(rng_seed) + h = len(pixels) + w = len(pixels[0]) if h > 0 else 0 + result = [] + for y in range(h): + row = [] + for x in range(w): + pixel = [] + for c in pixels[y][x]: + noise = rng.gauss(0, sigma) + val = int(c + noise) + pixel.append(max(0, min(255, val))) + row.append(pixel) + result.append(row) + return result + + +def _apply_box_blur(pixels: list[list[list[int]]], radius: int) -> list[list[list[int]]]: + """Applique un flou de boîte (approximation du flou gaussien, pure Python).""" + if radius <= 0: + return pixels + h = len(pixels) + w = len(pixels[0]) if h > 0 else 0 + channels = len(pixels[0][0]) if h > 0 and w > 0 else 3 + + def blur_pass(data: list[list[list[int]]]) -> list[list[list[int]]]: + out = [] + for y in range(h): + row = [] + for x in range(w): + totals = [0] * channels + count = 0 + for dy in range(-radius, radius + 1): + for dx in range(-radius, radius + 1): + ny, nx = y + dy, x + dx + if 0 <= ny < h and 0 <= nx < w: + for c in range(channels): + totals[c] += data[ny][nx][c] + count += 1 + row.append([t // count for t in totals]) + out.append(row) + return out + + return blur_pass(pixels) + + +def _apply_rotation_simple(pixels: list[list[list[int]]], angle_deg: float) -> list[list[list[int]]]: + """Rotation avec interpolation au plus proche voisin (pure Python). + + Pour des angles faibles, l'effet est réaliste. + """ + if angle_deg == 0: + return pixels + h = len(pixels) + w = len(pixels[0]) if h > 0 else 0 + channels = len(pixels[0][0]) if h > 0 and w > 0 else 3 + + angle_rad = math.radians(angle_deg) + cos_a = math.cos(angle_rad) + sin_a = math.sin(angle_rad) + cx, cy = w / 2, h / 2 + + result = [[[245, 240, 232][:channels] for _ in range(w)] for _ in range(h)] + for y in range(h): + for x in range(w): + # Coordonnées source + sx = cos_a * (x - cx) + sin_a * (y - cy) + cx + sy = -sin_a * (x - cx) + cos_a * (y - cy) + cy + ix, iy = int(round(sx)), int(round(sy)) + if 0 <= ix < w and 0 <= iy < h: + result[y][x] = list(pixels[iy][ix]) + return result + + +def _apply_resolution_reduction( + pixels: list[list[list[int]]], factor: float +) -> list[list[list[int]]]: + """Réduit la résolution puis remonte à la taille originale (pixelisation).""" + if factor >= 1.0: + return pixels + h = len(pixels) + w = len(pixels[0]) if h > 0 else 0 + new_h = max(1, int(h * factor)) + new_w = max(1, int(w * factor)) + + # Downscale + small = [] + for y in range(new_h): + row = [] + src_y = int(y / factor) + for x in range(new_w): + src_x = int(x / factor) + row.append(list(pixels[min(src_y, h - 1)][min(src_x, w - 1)])) + small.append(row) + + # Upscale (nearest-neighbor) + result = [] + for y in range(h): + row = [] + src_y = min(int(y * factor), new_h - 1) + for x in range(w): + src_x = min(int(x * factor), new_w - 1) + row.append(list(small[src_y][src_x])) + result.append(row) + return result + + +def _apply_binarization( + pixels: list[list[list[int]]], threshold: int +) -> list[list[list[int]]]: + """Binarise l'image (seuillage fixe sur luminosité).""" + h = len(pixels) + w = len(pixels[0]) if h > 0 else 0 + result = [] + + # Calculer le seuil Otsu si threshold == 0 + if threshold == 0: + histogram = [0] * 256 + total = h * w + for y in range(h): + for x in range(w): + p = pixels[y][x] + lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0] + histogram[lum] += 1 + # Otsu simplifié + best_thresh = 128 + best_var = -1.0 + total_sum = sum(i * histogram[i] for i in range(256)) + w0, w1, sum0 = 0, total, 0.0 + for t in range(256): + w0 += histogram[t] + if w0 == 0: + continue + w1 = total - w0 + if w1 == 0: + break + sum0 += t * histogram[t] + mean0 = sum0 / w0 + mean1 = (total_sum - sum0) / w1 + var = w0 * w1 * (mean0 - mean1) ** 2 + if var > best_var: + best_var = var + best_thresh = t + threshold = best_thresh + + for y in range(h): + row = [] + for x in range(w): + p = pixels[y][x] + lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0] + val = 255 if lum >= threshold else 0 + row.append([val] * len(p)) + result.append(row) + return result + + +def degrade_image_bytes( + png_bytes: bytes, + degradation_type: str, + level: float, +) -> bytes: + """Dégrade une image PNG et retourne les bytes PNG modifiés. + + Utilise Pillow si disponible, sinon utilise l'implémentation pure Python. + + Parameters + ---------- + png_bytes: + Bytes de l'image PNG source. + degradation_type: + Type de dégradation (``"noise"``, ``"blur"``, ``"rotation"``, + ``"resolution"``, ``"binarization"``). + level: + Niveau de dégradation (valeur numérique selon le type). + + Returns + ------- + bytes + Bytes de l'image PNG dégradée. + """ + try: + return _degrade_pillow(png_bytes, degradation_type, level) + except ImportError: + return _degrade_pure_python(png_bytes, degradation_type, level) + + +def _degrade_pillow(png_bytes: bytes, degradation_type: str, level: float) -> bytes: + """Dégradation avec Pillow (meilleure qualité).""" + import io + from PIL import Image, ImageFilter + + img = Image.open(io.BytesIO(png_bytes)).convert("RGB") + + if degradation_type == "noise": + if level > 0: + import random + # RGB : 3 octets par pixel, tobytes() reste stable Pillow 10 → 14+ + raw = img.tobytes() + rng = random.Random(0) + noisy = [] + for i in range(0, len(raw), 3): + r, g, b = raw[i], raw[i + 1], raw[i + 2] + noisy.append(( + max(0, min(255, int(r + rng.gauss(0, level)))), + max(0, min(255, int(g + rng.gauss(0, level)))), + max(0, min(255, int(b + rng.gauss(0, level)))), + )) + img.putdata(noisy) + + elif degradation_type == "blur": + if level > 0: + img = img.filter(ImageFilter.GaussianBlur(radius=level)) + + elif degradation_type == "rotation": + if level != 0: + img = img.rotate(-level, expand=False, fillcolor=(245, 240, 232)) + + elif degradation_type == "resolution": + if level < 1.0: + w, h = img.size + new_w, new_h = max(1, int(w * level)), max(1, int(h * level)) + img = img.resize((new_w, new_h), Image.NEAREST) + img = img.resize((w, h), Image.NEAREST) + + elif degradation_type == "binarization": + img = img.convert("L") # niveaux de gris + if level == 0: + # Seuillage Otsu : calcul du seuil optimal + histogram = img.histogram() + total = img.size[0] * img.size[1] + best_thresh, best_var = 128, -1.0 + total_sum = sum(i * histogram[i] for i in range(256)) + w0, sum0 = 0, 0.0 + for t in range(256): + w0 += histogram[t] + if w0 == 0: + continue + w1 = total - w0 + if w1 == 0: + break + sum0 += t * histogram[t] + var = w0 * w1 * (sum0 / w0 - (total_sum - sum0) / w1) ** 2 + if var > best_var: + best_var = var + best_thresh = t + threshold = best_thresh + else: + threshold = int(level) + img = img.point(lambda p: 255 if p >= threshold else 0, "1").convert("RGB") + + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def _degrade_pure_python(png_bytes: bytes, degradation_type: str, level: float) -> bytes: + """Dégradation en pur Python (sans Pillow). + + Décode le PNG, applique la transformation, ré-encode en PNG. + Note : n'implémente pas le décodage PNG complet — utilise des stubs. + """ + # Pour l'implémentation pure Python, on applique des transformations + # minimales sur les bytes bruts en créant une image de test synthétique. + # En pratique, Pillow est presque toujours disponible dans l'environnement Picarones. + logger.warning( + "Pillow non disponible : dégradation '%s' appliquée en mode dégradé (stub)", + degradation_type, + ) + # Retourner l'image originale légèrement modifiée (simulation) + return png_bytes + + +# --------------------------------------------------------------------------- +# Structures de résultats +# --------------------------------------------------------------------------- + +@dataclass +class DegradationCurve: + """Courbe CER vs niveau de dégradation pour un moteur et un type de dégradation.""" + engine_name: str + degradation_type: str + levels: list[float] + labels: list[str] + cer_values: list[Optional[float]] + """CER moyen (0-1) à chaque niveau. None si calcul impossible.""" + critical_threshold_level: Optional[float] = None + """Niveau à partir duquel CER > cer_threshold.""" + cer_threshold: float = 0.20 + """Seuil de CER utilisé pour déterminer le niveau critique.""" + + def as_dict(self) -> dict: + return { + "engine_name": self.engine_name, + "degradation_type": self.degradation_type, + "levels": self.levels, + "labels": self.labels, + "cer_values": self.cer_values, + "critical_threshold_level": self.critical_threshold_level, + "cer_threshold": self.cer_threshold, + } + + +@dataclass +class RobustnessReport: + """Rapport complet d'analyse de robustesse pour un ou plusieurs moteurs.""" + engine_names: list[str] + corpus_name: str + degradation_types: list[str] + curves: list[DegradationCurve] + summary: dict = field(default_factory=dict) + """Résumé : moteur le plus robuste par type de dégradation, seuils critiques…""" + + def get_curves_for_engine(self, engine_name: str) -> list[DegradationCurve]: + return [c for c in self.curves if c.engine_name == engine_name] + + def get_curves_for_type(self, degradation_type: str) -> list[DegradationCurve]: + return [c for c in self.curves if c.degradation_type == degradation_type] + + def as_dict(self) -> dict: + return { + "engine_names": self.engine_names, + "corpus_name": self.corpus_name, + "degradation_types": self.degradation_types, + "curves": [c.as_dict() for c in self.curves], + "summary": self.summary, + } + + +# --------------------------------------------------------------------------- +# Analyseur de robustesse +# --------------------------------------------------------------------------- + +class RobustnessAnalyzer: + """Lance une analyse de robustesse sur un corpus. + + Parameters + ---------- + engines: + Un ou plusieurs moteurs OCR (``BaseOCREngine``). + degradation_types: + Liste des types de dégradation à tester. + Par défaut : tous (``"noise"``, ``"blur"``, ``"rotation"``, + ``"resolution"``, ``"binarization"``). + cer_threshold: + Seuil de CER pour définir le niveau critique (défaut : 0.20 = 20%). + custom_levels: + Niveaux personnalisés par type (remplace les valeurs par défaut). + + Examples + -------- + >>> from picarones.engines.tesseract import TesseractEngine + >>> from picarones.core.robustness import RobustnessAnalyzer + >>> engine = TesseractEngine(config={"lang": "fra"}) + >>> analyzer = RobustnessAnalyzer([engine], degradation_types=["noise", "blur"]) + >>> report = analyzer.analyze(corpus) + """ + + def __init__( + self, + engines: "list[BaseOCREngine]", + degradation_types: Optional[list[str]] = None, + cer_threshold: float = 0.20, + custom_levels: Optional[dict[str, list]] = None, + ) -> None: + if not isinstance(engines, list): + engines = [engines] + self.engines = engines + self.degradation_types = degradation_types or ALL_DEGRADATION_TYPES + self.cer_threshold = cer_threshold + self.levels = dict(DEGRADATION_LEVELS) + if custom_levels: + self.levels.update(custom_levels) + + def analyze( + self, + corpus: "Corpus", + show_progress: bool = True, + max_docs: int = 10, + ) -> RobustnessReport: + """Lance l'analyse de robustesse sur le corpus. + + Parameters + ---------- + corpus: + Corpus Picarones avec images et GT. + show_progress: + Affiche la progression. + max_docs: + Nombre maximum de documents à traiter (pour la rapidité). + + Returns + ------- + RobustnessReport + """ + from picarones.core.metrics import compute_metrics + + docs = corpus.documents[:max_docs] + curves: list[DegradationCurve] = [] + + for engine in self.engines: + for deg_type in self.degradation_types: + levels = self.levels[deg_type] + labels = DEGRADATION_LABELS.get(deg_type, [str(lv) for lv in levels]) + + cer_per_level: list[Optional[float]] = [] + + if show_progress: + try: + from tqdm import tqdm + level_iter = tqdm( + list(enumerate(levels)), + desc=f"{engine.name} / {deg_type}", + ) + except ImportError: + level_iter = enumerate(levels) + else: + level_iter = enumerate(levels) + + for lvl_idx, level in level_iter: + doc_cers: list[float] = [] + + for doc in docs: + gt = doc.ground_truth.strip() + if not gt: + continue + + # Obtenir l'image (fichier ou data URI) + degraded_bytes = self._get_degraded_image( + doc, deg_type, level + ) + if degraded_bytes is None: + continue + + # Sauvegarder temporairement et OCR + with tempfile.NamedTemporaryFile( + suffix=".png", delete=False + ) as tmp: + tmp.write(degraded_bytes) + tmp_path = tmp.name + + try: + ocr_result = engine.run(tmp_path) + hypothesis = ocr_result.text + metrics = compute_metrics(gt, hypothesis) + doc_cers.append(metrics.cer) + except Exception as exc: + logger.debug( + "Erreur OCR %s niveau %s=%s: %s", + engine.name, deg_type, level, exc + ) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + if doc_cers: + cer_per_level.append(sum(doc_cers) / len(doc_cers)) + else: + cer_per_level.append(None) + + # Calculer le niveau critique + critical = self._find_critical_level( + levels, cer_per_level, self.cer_threshold + ) + + curves.append(DegradationCurve( + engine_name=engine.name, + degradation_type=deg_type, + levels=levels, + labels=labels[:len(levels)], + cer_values=cer_per_level, + critical_threshold_level=critical, + cer_threshold=self.cer_threshold, + )) + + summary = self._build_summary(curves) + + return RobustnessReport( + engine_names=[e.name for e in self.engines], + corpus_name=corpus.name, + degradation_types=self.degradation_types, + curves=curves, + summary=summary, + ) + + def _get_degraded_image( + self, + doc: "Document", + degradation_type: str, + level: float, + ) -> Optional[bytes]: + """Retourne les bytes PNG de l'image dégradée.""" + # Charger l'image originale + original_bytes = self._load_image(doc) + if original_bytes is None: + return None + + # Niveau 0 = image originale (sauf binarisation à 0 = Otsu) + if (degradation_type == "noise" and level == 0) or \ + (degradation_type == "blur" and level == 0) or \ + (degradation_type == "rotation" and level == 0) or \ + (degradation_type == "resolution" and level >= 1.0): + return original_bytes + + return degrade_image_bytes(original_bytes, degradation_type, level) + + def _load_image(self, doc: "Document") -> Optional[bytes]: + """Charge les bytes PNG de l'image d'un document.""" + img_path = doc.image_path + + # Data URI (base64) + if img_path.startswith("data:image/"): + import base64 + try: + _, b64 = img_path.split(",", 1) + return base64.b64decode(b64) + except Exception as exc: + logger.debug("Impossible de décoder data URI: %s", exc) + return None + + # Fichier local + path = Path(img_path) + if path.exists(): + return path.read_bytes() + + logger.debug("Image introuvable : %s", img_path) + return None + + @staticmethod + def _find_critical_level( + levels: list[float], + cer_values: list[Optional[float]], + threshold: float, + ) -> Optional[float]: + """Trouve le niveau à partir duquel CER dépasse le seuil.""" + for level, cer in zip(levels, cer_values): + if cer is not None and cer > threshold: + return level + return None + + @staticmethod + def _build_summary(curves: list[DegradationCurve]) -> dict: + """Construit le résumé de l'analyse.""" + summary: dict = {} + + # Par type de dégradation : moteur le plus robuste + by_type: dict[str, dict[str, list]] = {} + for curve in curves: + dt = curve.degradation_type + if dt not in by_type: + by_type[dt] = {} + valid_cers = [c for c in curve.cer_values if c is not None] + if valid_cers: + by_type[dt][curve.engine_name] = valid_cers + + for dt, engine_cers in by_type.items(): + if not engine_cers: + continue + # Robustesse = CER moyen sur tous les niveaux (plus bas = plus robuste) + best_engine = min(engine_cers, key=lambda e: sum(engine_cers[e]) / len(engine_cers[e])) + summary[f"most_robust_{dt}"] = best_engine + + # Seuils critiques par moteur + for curve in curves: + key = f"critical_{curve.engine_name}_{curve.degradation_type}" + summary[key] = curve.critical_threshold_level + + return summary + + +# --------------------------------------------------------------------------- +# Données de démonstration de robustesse +# --------------------------------------------------------------------------- + +def generate_demo_robustness_report( + engine_names: Optional[list[str]] = None, + seed: int = 42, +) -> RobustnessReport: + """Génère un rapport de robustesse fictif mais réaliste pour la démo. + + Parameters + ---------- + engine_names: + Noms des moteurs à simuler (défaut : tesseract, pero_ocr). + seed: + Graine aléatoire. + + Returns + ------- + RobustnessReport + """ + import random + rng = random.Random(seed) + + if engine_names is None: + engine_names = ["tesseract", "pero_ocr"] + + # CER de base par moteur + base_cer = { + "tesseract": 0.12, + "pero_ocr": 0.07, + "ancien_moteur": 0.25, + } + + # Sensibilité par type de dégradation (facteur multiplicatif par niveau) + sensitivity = { + "tesseract": { + "noise": 0.04, "blur": 0.05, "rotation": 0.06, + "resolution": 0.12, "binarization": 0.03, + }, + "pero_ocr": { + "noise": 0.02, "blur": 0.03, "rotation": 0.04, + "resolution": 0.08, "binarization": 0.02, + }, + "ancien_moteur": { + "noise": 0.06, "blur": 0.08, "rotation": 0.10, + "resolution": 0.15, "binarization": 0.05, + }, + } + + deg_types = ALL_DEGRADATION_TYPES + curves: list[DegradationCurve] = [] + + for engine_name in engine_names: + cer_base = base_cer.get(engine_name, 0.15) + sens = sensitivity.get(engine_name, {dt: 0.05 for dt in deg_types}) + + for deg_type in deg_types: + levels = DEGRADATION_LEVELS[deg_type] + labels = DEGRADATION_LABELS[deg_type] + s = sens.get(deg_type, 0.05) + + cer_values = [] + for i, level in enumerate(levels): + noise = rng.gauss(0, 0.005) + cer = min(1.0, cer_base + s * i + noise) + cer_values.append(round(max(0.0, cer), 4)) + + critical = RobustnessAnalyzer._find_critical_level(levels, cer_values, 0.20) + + curves.append(DegradationCurve( + engine_name=engine_name, + degradation_type=deg_type, + levels=list(levels), + labels=labels[:len(levels)], + cer_values=cer_values, + critical_threshold_level=critical, + cer_threshold=0.20, + )) + + summary = RobustnessAnalyzer._build_summary(curves) + + return RobustnessReport( + engine_names=engine_names, + corpus_name="Corpus de démonstration — Chroniques médiévales", + degradation_types=deg_types, + curves=curves, + summary=summary, + ) diff --git a/picarones/measurements/robustness_projection.py b/picarones/measurements/robustness_projection.py new file mode 100644 index 0000000000000000000000000000000000000000..dc6c66a0a62c62e6a70839288e08c85a415a7c0c --- /dev/null +++ b/picarones/measurements/robustness_projection.py @@ -0,0 +1,287 @@ +"""Projection de robustesse synthétique sur le corpus réel — +Sprint 81 (A.I.8). + +Sprint 81 — A.I.8 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le module ``picarones/core/robustness.py`` (Sprint 8) génère des +courbes CER vs niveau de dégradation **synthétique** (bruit, flou, +rotation, résolution). ``picarones/core/image_quality.py`` mesure +le bruit/flou/contraste **réels** des images du corpus. Ce +sprint **projette** les caractéristiques réelles sur les courbes +synthétiques pour estimer le **déficit attendu de CER** sur le +corpus dans son état actuel. + +Lecture concrète +---------------- +*« 30 % de vos documents ont un bruit équivalent à σ=15 où +Tesseract perd 8 points de CER — soit un déficit attendu global +de 2,4 points (30 % × 8 points). »* + +Méthode +------- +1. Pour chaque document, on extrait la valeur de qualité réelle + (``noise_level``, ``blur_score``, ``contrast_score``…) depuis + ``ImageQualityResult``. +2. Pour chaque type de dégradation, on interpole linéairement la + ``DegradationCurve`` synthétique : CER attendu à ce niveau. +3. On agrège : CER moyen attendu, % docs au-dessus du seuil + critique de la courbe, déficit projeté = CER_attendu - + CER_baseline (niveau nul). + +Sortie +------ +``project_robustness_on_corpus(curves, image_qualities)`` retourne +``{engine_name: {degradation_type: {expected_cer_mean, +deficit_vs_baseline, n_docs_above_critical, n_docs}}}``. + +Limites +------- +- Mapping ``image_quality → degradation level`` : on suppose que + ``noise_level`` (ImageQualityResult) correspond à σ + (DegradationCurve), et idem pour ``blur_score`` ↔ rayon de + flou. Si un corpus expose ces valeurs avec une échelle + différente, le mapping est documenté et l'utilisateur peut + passer ``quality_to_level`` custom. +- Interpolation **linéaire** entre les points de la courbe. Au- + delà des bornes, on **clip** au point extrême (pas + d'extrapolation hasardeuse). +""" + +from __future__ import annotations + +import logging +import statistics +from typing import Callable, Iterable, Optional + +logger = logging.getLogger(__name__) + + +# Mapping par défaut entre attributs ImageQualityResult et types +# de dégradation synthétique. L'utilisateur peut passer un dict +# custom pour modifier ce mapping. +_DEFAULT_QUALITY_FIELD: dict[str, str] = { + "noise": "noise_level", # σ + "blur": "blur_score", # Variance laplacienne (inverse) + "contrast": "contrast_score", + "rotation": "rotation_angle", + "resolution": "resolution_score", # peut être absent +} + + +def _interpolate_cer( + levels: list[float], + cer_values: list[Optional[float]], + target_level: float, +) -> Optional[float]: + """Interpolation linéaire : retourne CER attendu à + ``target_level``. + + - Si ``target_level`` est en-dessous du minimum de levels, + retourne le CER au minimum (clip). + - Si au-dessus du maximum, retourne le CER au maximum. + - Sinon, interpolation linéaire entre les deux points + encadrants. + - Retourne ``None`` si aucun ``cer_value`` valide. + """ + if not levels: + return None + # Filtrer les paires (level, cer) où cer est None + pairs = [ + (lvl, cer) for lvl, cer in zip(levels, cer_values) + if cer is not None + ] + if not pairs: + return None + pairs.sort(key=lambda p: p[0]) + # Clip + if target_level <= pairs[0][0]: + return pairs[0][1] + if target_level >= pairs[-1][0]: + return pairs[-1][1] + # Interpolation + for i in range(len(pairs) - 1): + lo_lvl, lo_cer = pairs[i] + hi_lvl, hi_cer = pairs[i + 1] + if lo_lvl <= target_level <= hi_lvl: + if hi_lvl == lo_lvl: + return lo_cer + ratio = (target_level - lo_lvl) / (hi_lvl - lo_lvl) + return lo_cer + (hi_cer - lo_cer) * ratio + return None # ne devrait pas arriver + + +def _extract_quality_value( + quality: dict, degradation_type: str, + custom_mapping: Optional[dict[str, str]] = None, +) -> Optional[float]: + """Extrait la valeur de qualité pertinente pour un type de + dégradation depuis un ``ImageQualityResult.as_dict()``.""" + mapping = custom_mapping or _DEFAULT_QUALITY_FIELD + field = mapping.get(degradation_type) + if field is None: + return None + value = quality.get(field) + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def project_robustness_on_corpus( + curves: Iterable, + image_qualities: list[dict], + *, + quality_to_level: Optional[Callable[[dict, str], Optional[float]]] = None, + critical_threshold: Optional[float] = None, +) -> dict: + """Projette les courbes de robustesse sur les qualités réelles. + + Parameters + ---------- + curves: + Itérable de ``DegradationCurve`` (ou dicts compatibles + avec ``engine_name``, ``degradation_type``, ``levels``, + ``cer_values``, ``critical_threshold_level``). + image_qualities: + Liste de dicts ``ImageQualityResult.as_dict()`` (un par + document). Si vide, retourne une projection vide. + quality_to_level: + Fonction custom ``(quality_dict, degradation_type) → + Optional[float]`` pour adapter le mapping qualité→niveau. + Par défaut, utilise ``_DEFAULT_QUALITY_FIELD``. + critical_threshold: + Override pour le seuil critique de CER (défaut : utilise + ``DegradationCurve.cer_threshold``). + + Returns + ------- + dict + ``{ + engine_name: { + degradation_type: { + "n_docs": int, + "n_docs_with_data": int, # qualité disponible + "expected_cer_mean": float, # moyenne CER attendu + "expected_cer_median": float, + "baseline_cer": float, # CER à niveau min + "deficit_vs_baseline": float, + "n_docs_above_critical": int, + "critical_threshold_level": float | None, + "critical_threshold_cer": float, + }, + }, + }`` + """ + extractor = quality_to_level or ( + lambda q, dt: _extract_quality_value(q, dt) + ) + out: dict[str, dict] = {} + + for curve in curves: + # Accepter dict ou DegradationCurve + if hasattr(curve, "as_dict"): + data = curve.as_dict() + else: + data = curve + engine = data.get("engine_name") + deg_type = data.get("degradation_type") + levels = data.get("levels") or [] + cer_values = data.get("cer_values") or [] + crit_lvl = data.get("critical_threshold_level") + crit_cer = ( + critical_threshold + if critical_threshold is not None + else data.get("cer_threshold", 0.20) + ) + if not engine or not deg_type: + continue + + per_doc_cer: list[float] = [] + n_docs_with_data = 0 + n_above_critical = 0 + for quality in image_qualities: + level = extractor(quality, deg_type) + if level is None: + continue + n_docs_with_data += 1 + cer = _interpolate_cer(levels, cer_values, level) + if cer is None: + continue + per_doc_cer.append(cer) + if cer > crit_cer: + n_above_critical += 1 + + if not per_doc_cer: + continue + + # Baseline = CER au niveau minimum (sans dégradation) + baseline = _interpolate_cer( + levels, cer_values, + min(levels) if levels else 0.0, + ) + expected_mean = statistics.fmean(per_doc_cer) + expected_median = statistics.median(per_doc_cer) + deficit = ( + expected_mean - baseline + if baseline is not None else None + ) + + out.setdefault(engine, {})[deg_type] = { + "n_docs": len(image_qualities), + "n_docs_with_data": n_docs_with_data, + "expected_cer_mean": expected_mean, + "expected_cer_median": expected_median, + "baseline_cer": baseline, + "deficit_vs_baseline": deficit, + "n_docs_above_critical": n_above_critical, + "critical_threshold_level": crit_lvl, + "critical_threshold_cer": crit_cer, + } + return out + + +def aggregate_projection_per_engine(projection: dict) -> dict: + """Pour chaque moteur, agrège le déficit projeté en sommant + sur tous les types de dégradation. + + Lecture : *« déficit total attendu pour Tesseract = 5,2 points + de CER si on considère les 4 dégradations indépendamment »*. + + Note : la sommation **suppose l'indépendance** des + dégradations, ce qui n'est pas strictement vrai mais reste + une approximation utile pour le diagnostic. + """ + out: dict[str, dict] = {} + for engine, per_type in projection.items(): + total_deficit = 0.0 + n_types_with_data = 0 + max_deficit_type: Optional[tuple[str, float]] = None + for deg_type, stats in per_type.items(): + deficit = stats.get("deficit_vs_baseline") + if deficit is None: + continue + total_deficit += deficit + n_types_with_data += 1 + if max_deficit_type is None or deficit > max_deficit_type[1]: + max_deficit_type = (deg_type, deficit) + out[engine] = { + "total_expected_deficit": total_deficit, + "n_degradation_types": n_types_with_data, + "worst_degradation_type": ( + max_deficit_type[0] if max_deficit_type else None + ), + "worst_degradation_deficit": ( + max_deficit_type[1] if max_deficit_type else None + ), + } + return out + + +__all__ = [ + "project_robustness_on_corpus", + "aggregate_projection_per_engine", +] diff --git a/picarones/measurements/searchability.py b/picarones/measurements/searchability.py new file mode 100644 index 0000000000000000000000000000000000000000..efe9209835a81a051db26fe478fe16db24a88e64 --- /dev/null +++ b/picarones/measurements/searchability.py @@ -0,0 +1,225 @@ +"""Recherchabilité fuzzy — Sprint 84 (A.II.5). + +Sprint 84 — A.II.5 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le CER mesure les erreurs caractère par caractère. Mais pour +un usage *recherche plein-texte* (ce que font Elastic, Solr en +mode fuzzy, ou la recherche full-text de Gallica), la question +réelle est : + + *« Combien de mots de ma GT sont retrouvables dans la + sortie OCR, à orthographe approchée près ? »* + +Un CER de 8 % peut donner 95 % de findability si les erreurs +sont concentrées sur des caractères non-significatifs ou sur +quelques mots aberrants ; à l'inverse, 4 % de CER mais +distribué sur tous les noms propres rend le corpus inutilisable +pour l'indexation prosopographique. + +Méthode +------- +Pour chaque token GT, on regarde s'il existe au moins un token +hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut +2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5 +caractères). Le **rappel** est la proportion de tokens GT +ainsi retrouvés. + +Multiplicité +------------ +Si la GT contient *« le »* deux fois et l'hypothèse une fois, +seul un token GT est compté comme retrouvé (alignement +multi-set, comme ``rare_token_recall`` Sprint 71). + +Sortie +------ +``compute_searchability(reference, hypothesis)`` retourne +``{n_gt_tokens, n_searchable, recall, missed_tokens}``. + +Limites documentées +------------------- +- Tokenisation par split sur whitespace (cohérent avec le reste + du codebase). Pas de stemming ni de lemmatisation. +- Levenshtein non pondéré — substitution = insertion = suppression + = 1. Pour un poids différent (par ex. faute classique + diacritique = 0,5), passer une fonction custom. +- Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la + similarité sémantique, voir des modules futurs (BERTScore). +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from picarones.core.metric_registry import register_metric +from picarones.core.modules import ArtifactType + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Tokenisation et distance d'édition +# ────────────────────────────────────────────────────────────────────────── + + +def _split_words(text: Optional[str]) -> list[str]: + """Tokenisation par whitespace — cohérent avec + ``lexical_modernization.py``, ``rare_tokens.py``, etc.""" + if not text: + return [] + return text.split() + + +def levenshtein_distance(a: str, b: str) -> int: + """Distance de Levenshtein (substitution=insertion=suppression=1). + + Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)). + """ + if a == b: + return 0 + if len(a) < len(b): + a, b = b, a + # |a| ≥ |b| + if not b: + return len(a) + previous = list(range(len(b) + 1)) + for i, ca in enumerate(a, start=1): + current = [i] + [0] * len(b) + for j, cb in enumerate(b, start=1): + cost = 0 if ca == cb else 1 + current[j] = min( + current[j - 1] + 1, # insertion + previous[j] + 1, # suppression + previous[j - 1] + cost, # substitution + ) + previous = current + return previous[-1] + + +# ────────────────────────────────────────────────────────────────────────── +# Calcul principal +# ────────────────────────────────────────────────────────────────────────── + + +def compute_searchability( + reference: Optional[str], + hypothesis: Optional[str], + *, + max_distance: int = 2, + case_sensitive: bool = False, +) -> dict: + """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``. + + Parameters + ---------- + reference, hypothesis: + Transcriptions GT et OCR. + max_distance: + Seuil de distance de Levenshtein (≤ pour considérer un + token comme retrouvé). Défaut 2 — convention + ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères. + case_sensitive: + Si False (défaut), casse insensible côté match — la + sortie ``missed_tokens`` reste avec la casse GT + originale. + + Returns + ------- + dict + ``{ + "n_gt_tokens": int, + "n_searchable": int, + "recall": float | None, # None si n_gt_tokens == 0 + "missed_tokens": list[str], + "max_distance": int, + }`` + """ + if max_distance < 0: + raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}") + gt_tokens = _split_words(reference) + hyp_tokens = _split_words(hypothesis) + n_gt = len(gt_tokens) + if n_gt == 0: + return { + "n_gt_tokens": 0, + "n_searchable": 0, + "recall": None, + "missed_tokens": [], + "max_distance": max_distance, + } + # Multi-set : un token hypothèse ne peut servir qu'une fois. + # Tri par longueur croissante pour matcher d'abord les + # tokens GT les plus courts (où ε-fautes sont plus rares). + if case_sensitive: + gt_for_match = list(gt_tokens) + hyp_for_match = list(hyp_tokens) + else: + gt_for_match = [t.lower() for t in gt_tokens] + hyp_for_match = [t.lower() for t in hyp_tokens] + + hyp_used = [False] * len(hyp_for_match) + n_searchable = 0 + missed: list[str] = [] + for gi, gt_match in enumerate(gt_for_match): + # Court-circuit si match exact disponible + best_idx = -1 + best_dist = max_distance + 1 + for hi, used in enumerate(hyp_used): + if used: + continue + hyp_match = hyp_for_match[hi] + # Court-circuit longueur (Levenshtein ≥ |Δlen|) + if abs(len(hyp_match) - len(gt_match)) > max_distance: + continue + d = levenshtein_distance(gt_match, hyp_match) + if d < best_dist: + best_dist = d + best_idx = hi + if d == 0: + break # match exact, inutile de chercher mieux + if best_idx >= 0 and best_dist <= max_distance: + hyp_used[best_idx] = True + n_searchable += 1 + else: + missed.append(gt_tokens[gi]) + recall = n_searchable / n_gt + return { + "n_gt_tokens": n_gt, + "n_searchable": n_searchable, + "recall": recall, + "missed_tokens": missed, + "max_distance": max_distance, + } + + +# ────────────────────────────────────────────────────────────────────────── +# Enregistrement registre typé (Sprint 34) +# ────────────────────────────────────────────────────────────────────────── + + +@register_metric( + name="searchability_recall", + input_types=(ArtifactType.TEXT, ArtifactType.TEXT), + description=( + "Recherchabilité fuzzy : proportion de tokens GT retrouvés " + "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de " + "la qualité pour la recherche plein-texte (Elastic, Solr)." + ), +) +def searchability_recall_metric(reference: str, hypothesis: str) -> float: + """Variante scalaire pour le registre typé : retourne le + rappel en [0, 1], ou ``0.0`` si la GT est vide (convention + cohérente avec rare_token_recall Sprint 71). + """ + result = compute_searchability(reference, hypothesis) + recall = result.get("recall") + return 0.0 if recall is None else recall + + +__all__ = [ + "levenshtein_distance", + "compute_searchability", + "searchability_recall_metric", +] diff --git a/picarones/measurements/searchability_runner.py b/picarones/measurements/searchability_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..cf822338cd0fb86c4a58301f136568a77080a346 --- /dev/null +++ b/picarones/measurements/searchability_runner.py @@ -0,0 +1,81 @@ +"""Câblage runner de la recherchabilité (Sprint 86). + +Sprint 86 — A.II.5a (vue HTML + câblage runner). + +Le module ``picarones/core/searchability.py`` (Sprint 84) a livré +la couche de calcul. Ce helper prépare la donnée pour le runner +historique et l'agrégation par moteur. + +Adaptive masking +---------------- +Comme pour les modules philologiques (Sprint 61), on ne calcule +le rappel que si la GT contient au moins un token — pas de +calcul vide qui produirait du bruit dans le rapport. +""" + +from __future__ import annotations + +import logging +from typing import Iterable, Optional + +from picarones.core.searchability import ( + _split_words, + compute_searchability, +) + +logger = logging.getLogger(__name__) + + +def compute_searchability_metrics( + reference: Optional[str], + hypothesis: Optional[str], + *, + max_distance: int = 2, +) -> Optional[dict]: + """Recherchabilité d'un document (adaptive). + + Retourne ``None`` si la GT est vide ou ne contient aucun + token — ce qui déclenche l'adaptive masking côté HTML. + """ + if not reference or not _split_words(reference): + return None + return compute_searchability( + reference, hypothesis or "", max_distance=max_distance, + ) + + +def aggregate_searchability_metrics( + per_doc: Iterable[Optional[dict]], +) -> Optional[dict]: + """Agrège les métriques par-doc en un score corpus-wide. + + Convention : on somme les ``n_gt_tokens`` et ``n_searchable`` + et on recalcule un rappel **micro** (cohérent avec ECE/MCE + Sprint 39 et NER Sprint 38). + """ + docs = [d for d in per_doc if d] + if not docs: + return None + n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs) + n_search = sum(int(d.get("n_searchable") or 0) for d in docs) + if n_gt == 0: + return None + # On garde l'union des missed_tokens (capped pour ne pas + # exploser le JSON sur de gros corpus) + missed: list[str] = [] + for d in docs: + missed.extend(d.get("missed_tokens") or []) + return { + "n_docs": len(docs), + "n_gt_tokens": n_gt, + "n_searchable": n_search, + "recall": n_search / n_gt, + "missed_tokens_sample": missed[:50], + "max_distance": docs[0].get("max_distance", 2), + } + + +__all__ = [ + "compute_searchability_metrics", + "aggregate_searchability_metrics", +] diff --git a/picarones/measurements/specialization.py b/picarones/measurements/specialization.py new file mode 100644 index 0000000000000000000000000000000000000000..a3f251c56c578701544d9b57aea1f9d21554f033 --- /dev/null +++ b/picarones/measurements/specialization.py @@ -0,0 +1,187 @@ +"""Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b). + +Sprint 89 — A.II.8b du plan d'évolution 2026. + +Pourquoi ce module +------------------ +La matrice de divergence taxonomique (Sprint 35 +``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel +point ces moteurs se trompent-ils différemment ? »*. Ce +sprint la transforme en un **score de spécialisation** lisible +et complète la lecture par : + +- une **classification** discrète (similar / distinct / + highly_specialized) que le chercheur peut consommer sans + avoir à interpréter une distance ; +- un **top-N des paires** les plus spécialisées, qui répond + directement à la question *« quels moteurs sont les meilleurs + candidats pour un voting ensemble ? »*. + +Ce module **ne recommande pas** de pipeline d'ensemble — il +fournit l'observation factuelle et laisse le chercheur arbitrer. + +Convention de score +------------------- +On utilise la **Jensen-Shannon divergence** déjà calculée par +``inter_engine.jensen_shannon_divergence`` : elle est +symétrique, bornée dans [0, 1], et son interprétation est +intuitive : + +- ≈ 0 → profils taxonomiques identiques +- 1 → distributions totalement disjointes + +Dépendances +----------- +S'appuie strictement sur ``picarones.core.inter_engine`` (Sprint +35) — pas de double calcul, pas de logique nouvelle de +divergence. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from picarones.core.inter_engine import jensen_shannon_divergence + +logger = logging.getLogger(__name__) + + +# Seuils par convention éditoriale. La roadmap ne fixe rien : +# ces seuils sont des **guides de lecture**, pas des verdicts. +# Le chercheur peut les surcharger via ``classify_specialization``. +DEFAULT_THRESHOLDS = ( + ("similar", 0.10), + ("distinct", 0.30), + ("highly_specialized", 1.01), # tout score ≥ 0.30 +) + + +def compute_specialization_score( + taxonomy_a: dict[str, float], + taxonomy_b: dict[str, float], +) -> float: + """Score de spécialisation entre deux moteurs ∈ [0, 1]. + + 0 = mêmes erreurs, 1 = erreurs totalement disjointes. + Délègue à ``jensen_shannon_divergence`` (Sprint 35). + """ + return jensen_shannon_divergence(taxonomy_a, taxonomy_b) + + +def classify_specialization( + score: float, + thresholds: Optional[tuple[tuple[str, float], ...]] = None, +) -> str: + """Classe le score en catégorie discrète. + + Convention : + - score < 0.10 → ``similar`` + - 0.10 ≤ score < 0.30 → ``distinct`` + - score ≥ 0.30 → ``highly_specialized`` + + L'utilisateur peut passer ses propres ``thresholds`` (liste + triée par valeur croissante de tuples ``(label, max_score)``). + """ + rules = thresholds or DEFAULT_THRESHOLDS + for label, max_score in rules: + if score < max_score: + return label + # Garde-fou : si aucun seuil ne match, dernière catégorie + return rules[-1][0] + + +def compute_specialization_matrix( + taxonomies: dict[str, dict[str, float]], +) -> Optional[dict]: + """Matrice de spécialisation symétrique entre tous les moteurs. + + Parameters + ---------- + taxonomies: + Map ``{engine_name: {error_class: count_or_proportion}}``. + + Returns + ------- + dict | None + ``{ + "engines": list[str], + "matrix": list[list[float]], # carrée, symétrique + "n_pairs": int, # paires distinctes + "max_score": float, + "max_pair": (str, str) | None, + }`` ; ``None`` si moins de 2 moteurs. + """ + if not taxonomies or len(taxonomies) < 2: + return None + engines = sorted(taxonomies.keys()) + n = len(engines) + matrix = [[0.0] * n for _ in range(n)] + n_pairs = 0 + max_score = 0.0 + max_pair: Optional[tuple[str, str]] = None + for i in range(n): + for j in range(i + 1, n): + score = compute_specialization_score( + taxonomies[engines[i]], taxonomies[engines[j]], + ) + matrix[i][j] = score + matrix[j][i] = score + n_pairs += 1 + if score > max_score: + max_score = score + max_pair = (engines[i], engines[j]) + return { + "engines": engines, + "matrix": matrix, + "n_pairs": n_pairs, + "max_score": max_score, + "max_pair": max_pair, + } + + +def top_specialized_pairs( + matrix_data: Optional[dict], + n: int = 5, + *, + min_score: float = 0.0, +) -> list[dict]: + """Top-N paires de moteurs triées par score décroissant. + + Returns + ------- + list[dict] + Une liste de ``{ + "engine_a": str, "engine_b": str, + "score": float, "category": str, + }`` triée par score décroissant. Liste vide si + ``matrix_data`` est ``None`` ou que toutes les paires + sont sous ``min_score``. + """ + if not matrix_data: + return [] + engines = matrix_data["engines"] + matrix = matrix_data["matrix"] + pairs: list[dict] = [] + for i, engine_a in enumerate(engines): + for j in range(i + 1, len(engines)): + score = matrix[i][j] + if score < min_score: + continue + pairs.append({ + "engine_a": engine_a, + "engine_b": engines[j], + "score": score, + "category": classify_specialization(score), + }) + pairs.sort(key=lambda p: -p["score"]) + return pairs[:n] + + +__all__ = [ + "DEFAULT_THRESHOLDS", + "compute_specialization_score", + "classify_specialization", + "compute_specialization_matrix", + "top_specialized_pairs", +] diff --git a/picarones/measurements/statistics.py b/picarones/measurements/statistics.py new file mode 100644 index 0000000000000000000000000000000000000000..e5a6a8d6ee204d2e06fc87ff67458a68d950785c --- /dev/null +++ b/picarones/measurements/statistics.py @@ -0,0 +1,1127 @@ +"""Tests statistiques et clustering d'erreurs pour Picarones. + +Fonctions fournies +------------------ +- wilcoxon_test(a, b) : Wilcoxon signé-rangé (2 moteurs appariés) +- bootstrap_ci(values, ...) : intervalle de confiance à 95 % par bootstrap +- compute_pairwise_stats(...) : matrice de Wilcoxon entre toutes les paires +- friedman_test(engine_cer_map) : Friedman (k moteurs, n documents) [Sprint 17] +- nemenyi_posthoc(engine_cer_map) : post-hoc Nemenyi avec critical distance [Sprint 17] +- build_critical_difference_svg(...) : rendu SVG du CDD (Demšar 2006) [Sprint 17] +- compute_pareto_front(points, ...) : frontière de Pareto multi-objectifs [Sprint 19] +- cluster_errors(...) : regroupement des patterns d'erreurs +- compute_correlation_matrix(...) : matrice de corrélation des métriques +- compute_reliability_curve(...) : courbe CER vs. % docs les plus faciles +- compute_venn_data(...) : diagramme de Venn 2/3 moteurs +""" + +from __future__ import annotations + +import math +import random +import re +from collections import defaultdict +from dataclasses import dataclass +from typing import Optional + +# Import optionnel de scipy — utilisé pour le test de Wilcoxon si disponible +# (méthode exacte pour n ≤ 25, approximation normale pour n > 25). +# En son absence, l'implémentation native (approximation normale pour n ≥ 10) +# est utilisée automatiquement. +try: + from scipy.stats import wilcoxon as _scipy_wilcoxon # type: ignore[import-untyped] + _SCIPY_AVAILABLE = True +except ImportError: + _SCIPY_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Bootstrap CI +# --------------------------------------------------------------------------- + +def bootstrap_ci( + values: list[float], + n_iter: int = 1000, + ci: float = 0.95, + seed: int = 42, +) -> tuple[float, float]: + """Intervalle de confiance par bootstrap. + + Parameters + ---------- + values : liste des valeurs (ex. CER par document) + n_iter : nombre d'itérations bootstrap (défaut 1000) + ci : niveau de confiance (défaut 0.95 → 95 %) + seed : graine RNG pour reproductibilité + + Returns + ------- + (lower, upper) — les bornes de l'IC à ``ci`` % + """ + if not values: + return (0.0, 0.0) + rng = random.Random(seed) + n = len(values) + means = [] + for _ in range(n_iter): + sample = [values[rng.randint(0, n - 1)] for _ in range(n)] + means.append(sum(sample) / n) + means.sort() + alpha = (1.0 - ci) / 2.0 + lo_idx = max(0, int(alpha * n_iter)) + hi_idx = min(n_iter - 1, int((1.0 - alpha) * n_iter)) + return (means[lo_idx], means[hi_idx]) + + +# --------------------------------------------------------------------------- +# Test de Wilcoxon signé-rangé (implémentation pure Python) +# --------------------------------------------------------------------------- + +def wilcoxon_test( + a: list[float], + b: list[float], + zero_method: str = "wilcox", +) -> dict: + """Test de Wilcoxon signé-rangé entre deux séries de CER appariées. + + Retourne un dict avec : + - statistic : W = min(W⁺, W⁻) + - p_value : p-value bilatérale + - significant : bool (p < 0.05) + - interpretation : phrase lisible + - n_pairs : nombre de paires utilisées (après retrait des zéros) + - W_plus : somme des rangs des différences positives + - W_minus : somme des rangs des différences négatives + + Hypothèses et limites + --------------------- + * Les observations sont appariées (même corpus, deux moteurs différents). + * Le test est non-paramétrique : aucune hypothèse de normalité des CER. + * ``zero_method="wilcox"`` (défaut) : les paires sans différence (aᵢ = bᵢ) + sont simplement exclues. Les autres méthodes (``"pratt"``, ``"zsplit"``) + nécessitent scipy. + * **Approximation normale** (implémentation native, n ≥ 10) : + L'approximation est raisonnable pour n ≥ 10 et converge vers la + distribution exacte. Pour n < 10, une table critique simplifiée est + utilisée (p ∈ {0.04, 0.20}) — résultat **conservateur**. + * **scipy** (si installé) : ``scipy.stats.wilcoxon`` est utilisé à la place + de l'approximation native. scipy utilise la méthode exacte pour n ≤ 25 + et l'approximation normale pour n > 25, ce qui est plus précis. + * **Validité** : le test suppose la symétrie de la distribution des + différences. Avec de très petits n (< 5), les résultats sont peu fiables + quelle que soit la méthode. + + Parameters + ---------- + a, b : séries de CER (même longueur, même ordre de documents) + zero_method : gestion des paires nulles (défaut : ``"wilcox"``) + """ + if len(a) != len(b): + raise ValueError("Les deux listes doivent avoir la même longueur") + + diffs = [x - y for x, y in zip(a, b)] + + # Retirer les zéros (méthode "wilcox") + if zero_method == "wilcox": + diffs = [d for d in diffs if d != 0.0] + + n = len(diffs) + if n == 0: + return { + "statistic": 0.0, + "p_value": 1.0, + "significant": False, + "interpretation": "Aucune différence entre les deux concurrents.", + "n_pairs": 0, + } + + # Rangs des valeurs absolues + abs_diffs = [abs(d) for d in diffs] + indexed = sorted(enumerate(abs_diffs), key=lambda x: x[1]) + + # Gestion des ex-aequo : rang moyen + ranks = [0.0] * n + i = 0 + while i < n: + j = i + while j < n and abs_diffs[indexed[j][0]] == abs_diffs[indexed[i][0]]: + j += 1 + avg_rank = (i + j + 1) / 2.0 # rang moyen (1-based) + for k in range(i, j): + ranks[indexed[k][0]] = avg_rank + i = j + + W_plus = sum(ranks[k] for k in range(n) if diffs[k] > 0) + W_minus = sum(ranks[k] for k in range(n) if diffs[k] < 0) + W = min(W_plus, W_minus) + + # Calcul de la p-value : scipy si disponible, sinon approximation native + if _SCIPY_AVAILABLE: + try: + scipy_res = _scipy_wilcoxon(diffs, zero_method=zero_method) + p_value = float(scipy_res.pvalue) + except Exception: + # Repli sur l'implémentation native en cas d'erreur scipy + p_value = _native_p_value(n, W) + else: + p_value = _native_p_value(n, W) + + significant = p_value < 0.05 + + if significant: + better = "premier" if W_plus < W_minus else "second" + interpretation = ( + f"Différence statistiquement significative (p = {p_value:.4f} < 0.05). " + f"Le {better} concurrent obtient de meilleurs scores." + ) + else: + interpretation = ( + f"Différence non significative (p = {p_value:.4f} ≥ 0.05). " + "On ne peut pas conclure que l'un surpasse l'autre." + ) + + return { + "statistic": round(W, 4), + "p_value": round(p_value, 6), + "significant": significant, + "interpretation": interpretation, + "n_pairs": n, + "W_plus": round(W_plus, 4), + "W_minus": round(W_minus, 4), + } + + +def _normal_sf(z: float) -> float: + """Survival function de la loi normale standard (1 - CDF).""" + # Approximation Abramowitz & Stegun 26.2.17 + t = 1.0 / (1.0 + 0.2316419 * abs(z)) + poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + + t * (-1.821255978 + t * 1.330274429)))) + phi_z = math.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi) + p = phi_z * poly + return p if z >= 0 else 1.0 - p + + +# Table des valeurs critiques de W pour α=0.05 bilatéral (test exact, source : tables de Wilcoxon) +_W_CRITICAL = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 2, 8: 3, 9: 5} + + +def _wilcoxon_exact_p(n: int, w: float) -> float: + """P-value approximée pour petits n (< 10) via table critique simplifiée. + + Note : résultat **conservateur** — seules deux valeurs sont retournées : + 0.04 (significatif à 5 %) ou 0.20 (non significatif). + Préférer scipy pour des p-values exactes. + """ + critical = _W_CRITICAL.get(n, 0) + if w <= critical: + return 0.04 # significatif à 5 % + return 0.20 # non significatif (approximation conservative) + + +def _native_p_value(n: int, W: float) -> float: + """Calcule la p-value via l'approximation normale (n ≥ 10) ou la table exacte (n < 10).""" + if n >= 10: + mu = n * (n + 1) / 4.0 + sigma2 = n * (n + 1) * (2 * n + 1) / 24.0 + if sigma2 <= 0: + return 1.0 + z = abs((W + 0.5) - mu) / math.sqrt(sigma2) # correction de continuité + return 2.0 * _normal_sf(z) # test bilatéral + return _wilcoxon_exact_p(n, W) + + +# --------------------------------------------------------------------------- +# Matrice des tests pairwise +# --------------------------------------------------------------------------- + +def compute_pairwise_stats( + engine_cer_map: dict[str, list[float]], +) -> list[dict]: + """Calcule les tests de Wilcoxon entre toutes les paires de concurrents. + + Parameters + ---------- + engine_cer_map : dict {engine_name → [cer_doc1, cer_doc2, ...]} + + Returns + ------- + Liste de dicts, un par paire : + - engine_a, engine_b, statistic, p_value, significant, interpretation + """ + names = list(engine_cer_map.keys()) + results = [] + for i in range(len(names)): + for j in range(i + 1, len(names)): + a_name, b_name = names[i], names[j] + a_vals = engine_cer_map[a_name] + b_vals = engine_cer_map[b_name] + # Aligner les longueurs + min_len = min(len(a_vals), len(b_vals)) + if min_len < 2: + continue + res = wilcoxon_test(a_vals[:min_len], b_vals[:min_len]) + results.append({ + "engine_a": a_name, + "engine_b": b_name, + **res, + }) + return results + + +# --------------------------------------------------------------------------- +# Test de Friedman + post-hoc Nemenyi (Sprint 17) +# --------------------------------------------------------------------------- +# +# Référence : Demšar, J. (2006), "Statistical Comparisons of Classifiers over +# Multiple Data Sets", Journal of Machine Learning Research 7:1-30. Standard +# de facto pour comparer plusieurs systèmes sur plusieurs datasets — ici : +# plusieurs moteurs OCR sur plusieurs documents. Le CDD (critical difference +# diagram) issu de Nemenyi est le rendu canonique. + +# Valeurs critiques de la distribution du Studentized Range divisées par √2, +# pour df = ∞ (approximation usuelle pour Nemenyi). Source : tables de Tukey. +# Clé : nombre de traitements k ; valeur : q_α pour α ∈ {0.05, 0.01}. +_NEMENYI_Q_TABLE = { + # k q_0.05 q_0.01 + 2: (1.960, 2.576), + 3: (2.343, 2.913), + 4: (2.569, 3.113), + 5: (2.728, 3.255), + 6: (2.850, 3.364), + 7: (2.949, 3.452), + 8: (3.031, 3.526), + 9: (3.102, 3.590), + 10: (3.164, 3.646), + 11: (3.219, 3.696), + 12: (3.268, 3.741), + 13: (3.313, 3.781), + 14: (3.354, 3.818), + 15: (3.391, 3.853), + 16: (3.426, 3.886), + 17: (3.458, 3.916), + 18: (3.489, 3.944), + 19: (3.517, 3.970), + 20: (3.544, 3.995), + 25: (3.658, 4.095), + 30: (3.739, 4.167), + 40: (3.858, 4.272), + 50: (3.945, 4.349), +} + + +def _chi_square_sf(x: float, df: int) -> float: + """Survival function de la loi chi², 1 - CDF(x). + + Utilise scipy si disponible (méthode exacte), sinon Wilson-Hilferty + (approximation normale précise dès df ≥ 3). + """ + if x <= 0 or df <= 0: + return 1.0 + try: + from scipy.stats import chi2 as _chi2 # type: ignore[import-untyped] + return float(_chi2.sf(x, df)) + except ImportError: + pass + # Wilson-Hilferty : transforme chi² en approximation normale + z = (((x / df) ** (1.0 / 3.0)) - (1.0 - 2.0 / (9.0 * df))) / math.sqrt(2.0 / (9.0 * df)) + return _normal_sf(z) + + +def _rank_row(values: list[float]) -> list[float]: + """Rangs d'une ligne — petit = rang 1. Ex-aequo : rangs moyens.""" + n = len(values) + indexed = sorted(range(n), key=lambda i: values[i]) + ranks = [0.0] * n + i = 0 + while i < n: + j = i + while j < n and values[indexed[j]] == values[indexed[i]]: + j += 1 + avg_rank = (i + j + 1) / 2.0 # 1-based + for k in range(i, j): + ranks[indexed[k]] = avg_rank + i = j + return ranks + + +def _aligned_cer_matrix( + engine_cer_map: dict[str, list[float]], +) -> tuple[list[str], list[list[float]]]: + """Construit la matrice (k moteurs × n documents) alignée sur la longueur + minimale. Retourne ``(noms, matrice_colonne_par_moteur)``. + + Friedman exige des blocs (documents) complets : si les moteurs n'ont pas + tous été exécutés sur les mêmes documents, on tronque à la longueur + minimale, documentée dans le résultat via ``n_blocks``. + """ + names = list(engine_cer_map.keys()) + if not names: + return [], [] + min_len = min(len(v) for v in engine_cer_map.values()) + if min_len == 0: + return names, [] + matrix = [engine_cer_map[n][:min_len] for n in names] + return names, matrix + + +def friedman_test(engine_cer_map: dict[str, list[float]]) -> dict: + """Test de Friedman — k moteurs sur n documents appariés. + + Test non-paramétrique équivalent à l'ANOVA à mesures répétées pour des + données ordinales. Hypothèse nulle : tous les moteurs ont la même + performance moyenne. Rejet → au moins un moteur diffère des autres. + + Parameters + ---------- + engine_cer_map: + Dict ``{engine_name → [cer_doc1, cer_doc2, ...]}``. Tous les moteurs + doivent avoir été évalués sur les mêmes documents (dans le même ordre). + + Returns + ------- + dict avec : + - ``statistic`` : Q corrigé pour les ex-aequo + - ``p_value`` : p-value (scipy si dispo, sinon Wilson-Hilferty) + - ``significant`` : bool, p < 0.05 + - ``df`` : degrés de liberté = k - 1 + - ``n_blocks`` : nombre de documents (blocs) utilisés + - ``n_engines`` : nombre de moteurs (k) + - ``mean_ranks`` : dict ``{engine: rang_moyen}`` + - ``interpretation``: phrase lisible + - ``error`` : message si le test n'est pas applicable + """ + names, matrix = _aligned_cer_matrix(engine_cer_map) + k = len(names) + n = len(matrix[0]) if matrix else 0 + + if k < 2: + return { + "statistic": 0.0, "p_value": 1.0, "significant": False, + "df": 0, "n_blocks": n, "n_engines": k, + "mean_ranks": {names[0]: 1.0} if k == 1 else {}, + "interpretation": "Test de Friedman non applicable : il faut au moins 2 moteurs.", + "error": "not_enough_engines", + } + if n < 2: + return { + "statistic": 0.0, "p_value": 1.0, "significant": False, + "df": k - 1, "n_blocks": n, "n_engines": k, + "mean_ranks": {name: 1.0 for name in names}, + "interpretation": "Test de Friedman non applicable : il faut au moins 2 documents communs.", + "error": "not_enough_blocks", + } + + # Rangs par bloc (document) : pour chaque doc, ranger les k moteurs + ranks_by_engine: list[list[float]] = [[] for _ in range(k)] + for j in range(n): + row = [matrix[i][j] for i in range(k)] + row_ranks = _rank_row(row) + for i in range(k): + ranks_by_engine[i].append(row_ranks[i]) + + rank_sums = [sum(r) for r in ranks_by_engine] + mean_ranks = {names[i]: rank_sums[i] / n for i in range(k)} + + # Statistique Q non-corrigée (sans ex-aequo) + # Q = 12 / (n·k·(k+1)) · Σ R_j² − 3·n·(k+1) + Q = (12.0 / (n * k * (k + 1))) * sum(rs ** 2 for rs in rank_sums) - 3.0 * n * (k + 1) + + # Correction pour les ex-aequo (ties factor) — ajuste si des rangs sont + # partagés dans certains blocs. Formule : Q_corr = Q / (1 - T/(n·(k³−k))) + # où T = Σ (tⱼ³ − tⱼ) sur tous les groupes d'ex-aequo. + tie_correction = 0.0 + for j in range(n): + row = [matrix[i][j] for i in range(k)] + sorted_row = sorted(row) + i = 0 + while i < len(sorted_row): + count = 1 + while i + count < len(sorted_row) and sorted_row[i + count] == sorted_row[i]: + count += 1 + if count > 1: + tie_correction += count ** 3 - count + i += count + denom = 1.0 - tie_correction / (n * (k ** 3 - k)) if k >= 2 else 1.0 + if denom > 0: + Q = Q / denom + + df = k - 1 + p_value = _chi_square_sf(Q, df) + significant = p_value < 0.05 + + if significant: + interpretation = ( + f"Test de Friedman significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). " + f"Au moins un moteur diffère des autres — utiliser le post-hoc Nemenyi " + f"pour identifier les paires distinguables." + ) + else: + interpretation = ( + f"Test de Friedman non significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). " + f"Aucune différence globale détectée entre les moteurs sur ce corpus." + ) + + return { + "statistic": round(Q, 4), + "p_value": round(p_value, 6), + "significant": significant, + "df": df, + "n_blocks": n, + "n_engines": k, + "mean_ranks": {k_: round(v, 4) for k_, v in mean_ranks.items()}, + "interpretation": interpretation, + } + + +def _nemenyi_critical_value(k: int, alpha: float = 0.05) -> Optional[float]: + """Valeur critique q_α pour k traitements, df = ∞. + + Retourne ``None`` si k est hors table (< 2 ou > 50). + """ + if k < 2: + return None + if k in _NEMENYI_Q_TABLE: + q05, q01 = _NEMENYI_Q_TABLE[k] + return q05 if alpha == 0.05 else q01 if alpha == 0.01 else q05 + # Au-delà de la table : borne supérieure (conservateur) + max_k = max(_NEMENYI_Q_TABLE.keys()) + if k > max_k: + q05, q01 = _NEMENYI_Q_TABLE[max_k] + return q05 if alpha == 0.05 else q01 + # Entre deux clés : interpolation linéaire + keys = sorted(_NEMENYI_Q_TABLE.keys()) + for i in range(len(keys) - 1): + if keys[i] < k < keys[i + 1]: + lo, hi = keys[i], keys[i + 1] + q_lo = _NEMENYI_Q_TABLE[lo][0 if alpha == 0.05 else 1] + q_hi = _NEMENYI_Q_TABLE[hi][0 if alpha == 0.05 else 1] + frac = (k - lo) / (hi - lo) + return q_lo + frac * (q_hi - q_lo) + return None + + +def nemenyi_posthoc( + engine_cer_map: dict[str, list[float]], + alpha: float = 0.05, +) -> dict: + """Post-hoc de Nemenyi — identifie les paires de moteurs statistiquement + indiscernables après un test de Friedman. + + Calcule la *critical distance* CD = q_α · √(k·(k+1) / (6·n)). Deux moteurs + dont les rangs moyens diffèrent de moins que CD ne sont **pas** + statistiquement distinguables au seuil α. + + Returns + ------- + dict avec : + - ``alpha`` : seuil utilisé + - ``critical_distance`` : CD calculée + - ``q_alpha`` : valeur critique q_α issue de la table + - ``n_blocks``, ``n_engines`` + - ``mean_ranks`` : rangs moyens par moteur (dict) + - ``engines_sorted`` : liste des moteurs triés par rang croissant + - ``significant_matrix`` : matrice bool (list[list[bool]]), + ``True`` = paire significativement différente + - ``tied_groups`` : liste de listes de moteurs indiscernables + (groupes maximaux d'ex-aequo pratiques) + - ``error`` : présent si le test n'est pas applicable + """ + names, matrix = _aligned_cer_matrix(engine_cer_map) + k = len(names) + n = len(matrix[0]) if matrix else 0 + + if k < 2 or n < 2: + return { + "alpha": alpha, + "critical_distance": 0.0, + "q_alpha": 0.0, + "n_blocks": n, + "n_engines": k, + "mean_ranks": {name: 1.0 for name in names}, + "engines_sorted": list(names), + "significant_matrix": [[False] * k for _ in range(k)], + "tied_groups": [list(names)] if names else [], + "error": "not_enough_data", + } + + # Friedman fournit les rangs moyens — on les recalcule ici pour rester + # autonome (sans forcer l'utilisateur à chaîner les deux appels). + ranks_by_engine: list[list[float]] = [[] for _ in range(k)] + for j in range(n): + row = [matrix[i][j] for i in range(k)] + row_ranks = _rank_row(row) + for i in range(k): + ranks_by_engine[i].append(row_ranks[i]) + + mean_ranks_list = [sum(r) / n for r in ranks_by_engine] + mean_ranks = {names[i]: round(mean_ranks_list[i], 4) for i in range(k)} + + q_alpha = _nemenyi_critical_value(k, alpha) or 0.0 + critical_distance = q_alpha * math.sqrt(k * (k + 1) / (6.0 * n)) + + # Matrice de significativité : paire (i,j) significative si |R_i - R_j| > CD + significant_matrix = [ + [ + (i != j) and (abs(mean_ranks_list[i] - mean_ranks_list[j]) > critical_distance) + for j in range(k) + ] + for i in range(k) + ] + + # Groupes d'ex-aequo pratiques : fenêtre glissante sur les rangs triés. + # Deux moteurs sont dans le même groupe si leur écart ≤ CD. + order = sorted(range(k), key=lambda i: mean_ranks_list[i]) + sorted_names = [names[i] for i in order] + sorted_ranks = [mean_ranks_list[i] for i in order] + + tied_groups: list[list[str]] = [] + i = 0 + while i < len(sorted_names): + # étendre le groupe tant que le moteur suivant est à ≤ CD du premier du groupe + j = i + while j + 1 < len(sorted_names) and (sorted_ranks[j + 1] - sorted_ranks[i]) <= critical_distance: + j += 1 + tied_groups.append(sorted_names[i:j + 1]) + i = j + 1 if j > i else i + 1 + + return { + "alpha": alpha, + "critical_distance": round(critical_distance, 4), + "q_alpha": round(q_alpha, 4), + "n_blocks": n, + "n_engines": k, + "mean_ranks": mean_ranks, + "engines_sorted": sorted_names, + "significant_matrix": significant_matrix, + "tied_groups": tied_groups, + } + + +# --------------------------------------------------------------------------- +# Critical Difference Diagram — rendu SVG (Sprint 17) +# --------------------------------------------------------------------------- + +def build_critical_difference_svg( + nemenyi_result: dict, + width: int = 780, + row_height: int = 22, +) -> str: + """Génère le SVG du Critical Difference Diagram (Demšar 2006). + + Le diagramme montre : + * un axe horizontal des rangs moyens (1 à k), + * chaque moteur positionné sur l'axe à son rang moyen, + * des barres horizontales épaisses reliant les moteurs statistiquement + indiscernables (distance ≤ CD), + * la longueur de CD affichée au-dessus de l'axe en référence. + + Parameters + ---------- + nemenyi_result: + Résultat de ``nemenyi_posthoc``. + width: + Largeur totale du SVG en pixels. + row_height: + Hauteur de chaque ligne d'étiquette moteur (auto-adaptatif). + + Returns + ------- + Chaîne contenant le SVG (balise racine ````). + """ + k = nemenyi_result.get("n_engines", 0) + if k < 2 or nemenyi_result.get("error"): + return ( + '' + '' + 'Critical Difference Diagram non calculable — données insuffisantes.' + '' + ) + + engines_sorted: list[str] = list(nemenyi_result.get("engines_sorted", [])) + mean_ranks: dict[str, float] = dict(nemenyi_result.get("mean_ranks", {})) + tied_groups: list[list[str]] = list(nemenyi_result.get("tied_groups", [])) + cd: float = float(nemenyi_result.get("critical_distance", 0.0)) + + # Dimensions + left_pad, right_pad = 40, 40 + top_pad = 50 # espace pour l'affichage CD + axis_y = top_pad + 10 + bars_start_y = axis_y + 20 # première barre d'ex-aequo sous l'axe + # Empiler une ligne par groupe + une ligne par étiquette + label_rows = k # chaque moteur a sa propre ligne de label + bars_count = len(tied_groups) + total_h = bars_start_y + bars_count * 10 + label_rows * row_height + 20 + + axis_x0, axis_x1 = left_pad, width - right_pad + axis_width = axis_x1 - axis_x0 + + def x_for_rank(r: float) -> float: + # Rang 1 à gauche, rang k à droite + if k <= 1: + return axis_x0 + return axis_x0 + (r - 1.0) / (k - 1.0) * axis_width + + parts: list[str] = [] + parts.append( + f'' + ) + parts.append('') + + # Barre CD de référence (en haut, à gauche de l'axe) + if cd > 0 and k >= 2: + cd_bar_x0 = axis_x0 + cd_bar_x1 = axis_x0 + (cd / max(1, k - 1)) * axis_width + cd_y = top_pad - 20 + parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append(f'CD = {cd:.3f}') + + # Axe principal + parts.append(f'') + # Ticks entiers + for r in range(1, k + 1): + xt = x_for_rank(r) + parts.append(f'') + parts.append(f'{r}') + + # Barres reliant les groupes indiscernables + for i, group in enumerate(tied_groups): + if len(group) < 2: + continue + rs = [mean_ranks[n] for n in group] + x0 = x_for_rank(min(rs)) + x1 = x_for_rank(max(rs)) + y_bar = bars_start_y + i * 10 + parts.append(f'') + + # Étiquettes des moteurs : la moitié la plus basse à gauche, l'autre à droite + labels_y_base = bars_start_y + bars_count * 10 + 15 + half = (len(engines_sorted) + 1) // 2 + left_engines = engines_sorted[:half] + right_engines = engines_sorted[half:] + + for idx, name in enumerate(left_engines): + r = mean_ranks[name] + x = x_for_rank(r) + y_label = labels_y_base + idx * row_height + # Ligne du moteur vers axe + parts.append(f'') + parts.append(f'') + parts.append(f'{_svg_escape(name)} ' + f'({r:.2f})') + + for idx, name in enumerate(right_engines): + r = mean_ranks[name] + x = x_for_rank(r) + y_label = labels_y_base + idx * row_height + parts.append(f'') + parts.append(f'') + parts.append(f'{_svg_escape(name)} ' + f'({r:.2f})') + + parts.append('') + return "".join(parts) + + +def _svg_escape(text: str) -> str: + """Échappe un texte pour inclusion sûre dans un nœud SVG/XML.""" + return (text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + +# --------------------------------------------------------------------------- +# Frontière de Pareto (Sprint 19) +# --------------------------------------------------------------------------- + +def compute_pareto_front( + points: list[dict], + objectives: tuple[str, ...] = ("cer", "cost"), + name_key: str = "engine", + minimize: Optional[tuple[bool, ...]] = None, +) -> list[str]: + """Calcule la frontière de Pareto sur ``len(objectives)`` dimensions. + + Un point ``p`` est Pareto-dominant si aucun autre point n'a, pour TOUS + les objectifs, une valeur au moins aussi bonne ET au moins une valeur + strictement meilleure. + + Parameters + ---------- + points: + Liste de dicts. Chaque dict doit contenir ``name_key`` et toutes les + clés de ``objectives``. Les points dont une valeur d'objectif est + ``None`` sont ignorés (pas de comparaison possible). + objectives: + Clés des objectifs à minimiser/maximiser. + name_key: + Clé identifiant le point (par défaut ``"engine"``). + minimize: + Pour chaque objectif, ``True`` = minimiser (ex. CER, coût), + ``False`` = maximiser (ex. ancrage). Doit avoir la même longueur + que ``objectives``. + + Returns + ------- + Liste des ``name`` des points sur le front Pareto, ordre stable depuis + ``points``. + """ + if minimize is None: + minimize = tuple(True for _ in objectives) + if len(minimize) != len(objectives): + raise ValueError("`minimize` doit avoir la même longueur que `objectives`") + + valid = [] + for p in points: + try: + vals = tuple(float(p[k]) for k in objectives) + except (KeyError, TypeError, ValueError): + continue + valid.append((p[name_key], vals)) + + front: list[str] = [] + for name_a, vals_a in valid: + dominated = False + for name_b, vals_b in valid: + if name_a == name_b: + continue + # B domine A si B est ≥ aussi bon partout ET strictement meilleur quelque part + better_or_equal_everywhere = True + strictly_better_somewhere = False + for va, vb, mini in zip(vals_a, vals_b, minimize): + if mini: + if vb > va: + better_or_equal_everywhere = False + break + if vb < va: + strictly_better_somewhere = True + else: # maximiser + if vb < va: + better_or_equal_everywhere = False + break + if vb > va: + strictly_better_somewhere = True + if better_or_equal_everywhere and strictly_better_somewhere: + dominated = True + break + if not dominated: + front.append(name_a) + return front + + +# --------------------------------------------------------------------------- +# Clustering des patterns d'erreurs +# --------------------------------------------------------------------------- + +# Patterns d'erreurs fréquentes (OCR + HTR documents patrimoniaux) +_ERROR_PATTERNS = [ + # (pattern_re, label) + (r"\brn\b.*\bm\b|\bm\b.*\brn\b|rn→m|m→rn", "confusion rn/m"), + (r"[lI]→1|1→[lI]|l→1|1→l|I→1|1→I", "confusion l/1/I"), + (r"u→n|n→u|v→u|u→v", "confusion u/n/v"), + (r"[oO]→0|0→[oO]", "confusion O/0"), + (r"ſ→[fs]|[fs]→ſ", "confusion ſ/f/s"), + (r"é→e|è→e|ê→e|e→[éèê]", "erreur diacritique é/e"), + (r"œ→oe|oe→œ|æ→ae|ae→æ", "ligature œ/æ"), + (r"[fF]i→fi|fi→[fF]i", "ligature fi"), + (r"[fF]l→fl|fl→[fF]l", "ligature fl"), + (r"\s+→''|''→\s+", "segmentation espace"), +] + +def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]: + """Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution.""" + from picarones.report.diff_utils import compute_word_diff + ops = compute_word_diff(gt, hyp) + pairs = [] + for op in ops: + if op["op"] == "replace": + pairs.append((op["old"], op["new"])) + elif op["op"] == "delete": + pairs.append((op["text"], "")) + elif op["op"] == "insert": + pairs.append(("", op["text"])) + return pairs + + +@dataclass +class ErrorCluster: + """Un cluster d'erreurs similaires.""" + cluster_id: int + label: str + """Description humaine du pattern (ex. 'confusion rn/m').""" + count: int + examples: list[dict] + """Liste de {engine, gt_fragment, ocr_fragment}.""" + + def as_dict(self) -> dict: + return { + "cluster_id": self.cluster_id, + "label": self.label, + "count": self.count, + "examples": self.examples[:5], # 5 exemples max + } + + +def cluster_errors( + error_data: list[dict], + max_clusters: int = 8, +) -> list[ErrorCluster]: + """Regroupe les erreurs en clusters avec labels lisibles. + + Parameters + ---------- + error_data : liste de dicts {engine, gt, hypothesis} + max_clusters : nombre max de clusters à retourner + + Returns + ------- + Liste de ErrorCluster triée par count décroissant. + """ + # Collecter tous les patterns d'erreur avec contexte + # Clé : catégorie d'erreur → liste d'exemples + bucket: dict[str, list[dict]] = defaultdict(list) + other_pairs: list[dict] = [] + + for item in error_data: + engine = item.get("engine", "") + gt = item.get("gt", "") + hyp = item.get("hypothesis", "") + pairs = _extract_error_pairs(gt, hyp) + + for old, new in pairs: + if not old and not new: + continue + matched = False + # Essayer de matcher un pattern connu + probe = f"{old}→{new}" + for _pat, label in _ERROR_PATTERNS: + try: + if re.search(_pat, probe, re.IGNORECASE): + bucket[label].append({ + "engine": engine, + "gt_fragment": old, + "ocr_fragment": new, + }) + matched = True + break + except re.error: + pass + + if not matched: + # Regrouper les substitutions restantes par paire de caractères + if len(old) <= 3 and len(new) <= 3: + key = f"{old}→{new}" if (old and new) else (f"—→{new}" if new else f"{old}→—") + bucket[key].append({ + "engine": engine, + "gt_fragment": old, + "ocr_fragment": new, + }) + else: + other_pairs.append({ + "engine": engine, + "gt_fragment": old, + "ocr_fragment": new, + }) + + # Construire les clusters triés par fréquence + clusters: list[ErrorCluster] = [] + cluster_id = 1 + sorted_buckets = sorted(bucket.items(), key=lambda x: -len(x[1])) + + for label, examples in sorted_buckets[:max_clusters - 1]: + clusters.append(ErrorCluster( + cluster_id=cluster_id, + label=label, + count=len(examples), + examples=examples, + )) + cluster_id += 1 + + # Cluster "autres" + if other_pairs: + clusters.append(ErrorCluster( + cluster_id=cluster_id, + label="autres substitutions", + count=len(other_pairs), + examples=other_pairs, + )) + + # Trier par count décroissant et limiter + clusters.sort(key=lambda c: -c.count) + return clusters[:max_clusters] + + +# --------------------------------------------------------------------------- +# Matrice de corrélation entre métriques +# --------------------------------------------------------------------------- + +def _pearson(x: list[float], y: list[float]) -> float: + """Coefficient de corrélation de Pearson.""" + n = len(x) + if n < 2: + return 0.0 + mx = sum(x) / n + my = sum(y) / n + num = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) + den = math.sqrt( + sum((xi - mx) ** 2 for xi in x) * sum((yi - my) ** 2 for yi in y) + ) + return num / den if den > 0 else 0.0 + + +def compute_correlation_matrix( + metrics_per_doc: list[dict], + metric_keys: Optional[list[str]] = None, +) -> dict: + """Calcule la matrice de corrélation entre toutes les métriques numériques. + + Parameters + ---------- + metrics_per_doc : liste de dicts, un par document, contenant les métriques + metric_keys : clés à inclure (None → toutes les clés numériques) + + Returns + ------- + { + "labels": [...], + "matrix": [[r_ij, ...], ...] // coefficients de Pearson + } + """ + if not metrics_per_doc: + return {"labels": [], "matrix": []} + + if metric_keys is None: + # Déduire les clés numériques + sample = metrics_per_doc[0] + metric_keys = [k for k, v in sample.items() if isinstance(v, (int, float))] + + # Construire les vecteurs + vectors: dict[str, list[float]] = {k: [] for k in metric_keys} + for doc in metrics_per_doc: + for k in metric_keys: + v = doc.get(k) + vectors[k].append(float(v) if v is not None else 0.0) + + # Calculer la matrice + labels = metric_keys + n = len(labels) + matrix = [] + for i in range(n): + row = [] + for j in range(n): + r = _pearson(vectors[labels[i]], vectors[labels[j]]) + row.append(round(r, 4)) + matrix.append(row) + + return {"labels": labels, "matrix": matrix} + + +# --------------------------------------------------------------------------- +# Courbe de fiabilité (reliability curve) +# --------------------------------------------------------------------------- + +def compute_reliability_curve( + cer_values: list[float], + steps: int = 20, +) -> list[dict]: + """Pour les X% documents les plus faciles, quel est le CER moyen ? + + Returns + ------- + Liste de {pct_docs: float, mean_cer: float} + """ + if not cer_values: + return [] + sorted_cer = sorted(cer_values) + n = len(sorted_cer) + points = [] + for step in range(1, steps + 1): + pct = step / steps + cutoff = max(1, int(pct * n)) + subset = sorted_cer[:cutoff] + mean_cer = sum(subset) / len(subset) + points.append({"pct_docs": round(pct * 100, 1), "mean_cer": round(mean_cer, 6)}) + return points + + +# --------------------------------------------------------------------------- +# Données pour le diagramme de Venn (erreurs communes / exclusives) +# --------------------------------------------------------------------------- + +def compute_venn_data( + engine_error_sets: dict[str, set[str]], +) -> dict: + """Calcule les cardinalités pour un diagramme de Venn entre 2 ou 3 concurrents. + + Parameters + ---------- + engine_error_sets : {engine_name → set of doc_id:error_token_pair strings} + + Returns + ------- + Pour 2 concurrents : + {only_a, only_b, both, label_a, label_b} + Pour 3 concurrents : + {only_a, only_b, only_c, ab, ac, bc, abc, label_a, label_b, label_c} + """ + names = list(engine_error_sets.keys())[:3] # max 3 pour Venn lisible + if len(names) < 2: + return {} + + sets = {n: engine_error_sets[n] for n in names} + + if len(names) == 2: + a, b = names + sa, sb = sets[a], sets[b] + return { + "type": "venn2", + "label_a": a, + "label_b": b, + "only_a": len(sa - sb), + "only_b": len(sb - sa), + "both": len(sa & sb), + } + else: + a, b, c = names + sa, sb, sc = sets[a], sets[b], sets[c] + return { + "type": "venn3", + "label_a": a, + "label_b": b, + "label_c": c, + "only_a": len(sa - sb - sc), + "only_b": len(sb - sa - sc), + "only_c": len(sc - sa - sb), + "ab": len((sa & sb) - sc), + "ac": len((sa & sc) - sb), + "bc": len((sb & sc) - sa), + "abc": len(sa & sb & sc), + } diff --git a/picarones/measurements/structure.py b/picarones/measurements/structure.py new file mode 100644 index 0000000000000000000000000000000000000000..2724f4fc76f0a86b0b7c6e3df6fb287dcf56a45b --- /dev/null +++ b/picarones/measurements/structure.py @@ -0,0 +1,229 @@ +"""Analyse structurelle des résultats OCR. + +Mesures +------- +- **Taux de fusion de lignes** : l'OCR produit moins de lignes que le GT + (plusieurs lignes GT fusionnées en une seule). +- **Taux de fragmentation** : l'OCR produit plus de lignes que le GT + (une ligne GT découpée en plusieurs). +- **Score d'ordre de lecture** : corrélation entre l'ordre des mots GT et OCR, + approximé par la longueur de la sous-séquence commune la plus longue (LCS). +- **Taux de conservation des paragraphes** : respect des sauts de paragraphe. + +Ces métriques sont calculées indépendamment du contenu textuel — elles mesurent +la fidélité de la mise en page, pas la qualité des caractères. + +Note : sans bounding boxes disponibles, l'analyse se base uniquement sur les +sauts de ligne présents dans les textes GT et OCR. +""" + +from __future__ import annotations + +import difflib +from dataclasses import dataclass + + +@dataclass +class StructureResult: + """Résultat de l'analyse structurelle pour un document.""" + + gt_line_count: int = 0 + """Nombre de lignes dans le GT.""" + ocr_line_count: int = 0 + """Nombre de lignes dans l'OCR.""" + + line_fusion_count: int = 0 + """Nombre de fusions de lignes (GT lignes absorbées).""" + line_fragmentation_count: int = 0 + """Nombre de fragmentations (GT lignes splittées).""" + + reading_order_score: float = 1.0 + """Score d'ordre de lecture [0, 1]. 1 = ordre parfait.""" + + paragraph_conservation_score: float = 1.0 + """Score de conservation des paragraphes [0, 1].""" + + @property + def line_fusion_rate(self) -> float: + """Taux de fusion = fusions / lignes GT.""" + return self.line_fusion_count / self.gt_line_count if self.gt_line_count > 0 else 0.0 + + @property + def line_fragmentation_rate(self) -> float: + """Taux de fragmentation = fragmentations / lignes GT.""" + return self.line_fragmentation_count / self.gt_line_count if self.gt_line_count > 0 else 0.0 + + @property + def line_accuracy(self) -> float: + """Exactitude du nombre de lignes : 1 - |delta| / max(gt, ocr).""" + if self.gt_line_count == 0 and self.ocr_line_count == 0: + return 1.0 + max_lines = max(self.gt_line_count, self.ocr_line_count) + delta = abs(self.gt_line_count - self.ocr_line_count) + return max(0.0, 1.0 - delta / max_lines) + + def as_dict(self) -> dict: + return { + "gt_line_count": self.gt_line_count, + "ocr_line_count": self.ocr_line_count, + "line_fusion_count": self.line_fusion_count, + "line_fragmentation_count": self.line_fragmentation_count, + "line_fusion_rate": round(self.line_fusion_rate, 4), + "line_fragmentation_rate": round(self.line_fragmentation_rate, 4), + "line_accuracy": round(self.line_accuracy, 4), + "reading_order_score": round(self.reading_order_score, 4), + "paragraph_conservation_score": round(self.paragraph_conservation_score, 4), + } + + @classmethod + def from_dict(cls, data: dict) -> "StructureResult": + return cls( + gt_line_count=data.get("gt_line_count", 0), + ocr_line_count=data.get("ocr_line_count", 0), + line_fusion_count=data.get("line_fusion_count", 0), + line_fragmentation_count=data.get("line_fragmentation_count", 0), + reading_order_score=data.get("reading_order_score", 1.0), + paragraph_conservation_score=data.get("paragraph_conservation_score", 1.0), + ) + + +def analyze_structure(ground_truth: str, hypothesis: str) -> StructureResult: + """Analyse la structure d'un document OCR comparée au GT. + + Parameters + ---------- + ground_truth: + Texte de référence (vérité terrain), avec sauts de ligne. + hypothesis: + Texte produit par l'OCR, avec sauts de ligne. + + Returns + ------- + StructureResult + """ + gt_lines = [ln for ln in ground_truth.splitlines() if ln.strip()] + ocr_lines = [ln for ln in hypothesis.splitlines() if ln.strip()] + + n_gt = len(gt_lines) + n_ocr = len(ocr_lines) + + # Fusions et fragmentations + fusion_count, frag_count = _count_line_changes(gt_lines, ocr_lines) + + # Score d'ordre de lecture via LCS sur les mots + reading_order = _reading_order_score(ground_truth, hypothesis) + + # Score de conservation des paragraphes (sauts de ligne vides = paragraphes) + para_score = _paragraph_conservation_score(ground_truth, hypothesis) + + return StructureResult( + gt_line_count=n_gt, + ocr_line_count=n_ocr, + line_fusion_count=fusion_count, + line_fragmentation_count=frag_count, + reading_order_score=reading_order, + paragraph_conservation_score=para_score, + ) + + +def _count_line_changes(gt_lines: list[str], ocr_lines: list[str]) -> tuple[int, int]: + """Compte les fusions et fragmentations de lignes via SequenceMatcher.""" + if not gt_lines or not ocr_lines: + return 0, 0 + + fusion_count = 0 + frag_count = 0 + + # Aligner les lignes par contenu + matcher = difflib.SequenceMatcher( + None, + [ln.strip()[:30] for ln in gt_lines], # fingerprint court pour la comparaison + [ln.strip()[:30] for ln in ocr_lines], + autojunk=False, + ) + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "replace": + gt_len = i2 - i1 + ocr_len = j2 - j1 + if ocr_len < gt_len: + # Moins de lignes OCR → fusions + fusion_count += gt_len - ocr_len + elif ocr_len > gt_len: + # Plus de lignes OCR → fragmentations + frag_count += ocr_len - gt_len + elif tag == "delete": + # Lignes GT supprimées dans l'OCR → lacunes (pas fusion/frag) + pass + elif tag == "insert": + # Lignes insérées par l'OCR + frag_count += j2 - j1 + + return fusion_count, frag_count + + +def _reading_order_score(ground_truth: str, hypothesis: str) -> float: + """Score d'ordre de lecture [0, 1] basé sur la LCS des mots. + + On calcule la longueur de la sous-séquence commune la plus longue (LCS) + entre les listes de mots GT et OCR. Un score de 1 signifie que tous les + mots communs apparaissent dans le même ordre. + """ + gt_words = ground_truth.split() + hyp_words = hypothesis.split() + + if not gt_words or not hyp_words: + return 1.0 + + # Utiliser SequenceMatcher pour approximer la LCS + matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False) + # Ratio est 2 * nb_correspondances / (len_gt + len_ocr) + # C'est un proxy raisonnable de l'ordre de lecture + ratio = matcher.ratio() + return round(ratio, 4) + + +def _paragraph_conservation_score(ground_truth: str, hypothesis: str) -> float: + """Score de conservation des paragraphes [0, 1]. + + Compte les sauts de paragraphe (lignes vides) dans le GT et mesure + le taux de conservation dans l'OCR. + """ + # Un saut de paragraphe = deux sauts de ligne consécutifs + gt_paras = [p for p in ground_truth.split("\n\n") if p.strip()] + ocr_paras = [p for p in hypothesis.split("\n\n") if p.strip()] + + n_gt_paras = len(gt_paras) + if n_gt_paras <= 1: + return 1.0 # pas de paragraphe distinct → score parfait + + n_ocr_paras = len(ocr_paras) + delta = abs(n_gt_paras - n_ocr_paras) + score = max(0.0, 1.0 - delta / n_gt_paras) + return round(score, 4) + + +def aggregate_structure(results: list[StructureResult]) -> dict: + """Agrège les résultats structurels sur un corpus.""" + if not results: + return {} + + import statistics + + def _mean(values: list[float]) -> float: + return round(statistics.mean(values), 4) if values else 0.0 + + fusion_rates = [r.line_fusion_rate for r in results] + frag_rates = [r.line_fragmentation_rate for r in results] + reading_scores = [r.reading_order_score for r in results] + para_scores = [r.paragraph_conservation_score for r in results] + line_accuracies = [r.line_accuracy for r in results] + + return { + "mean_line_fusion_rate": _mean(fusion_rates), + "mean_line_fragmentation_rate": _mean(frag_rates), + "mean_reading_order_score": _mean(reading_scores), + "mean_paragraph_conservation": _mean(para_scores), + "mean_line_accuracy": _mean(line_accuracies), + "document_count": len(results), + } diff --git a/picarones/measurements/taxonomy.py b/picarones/measurements/taxonomy.py new file mode 100644 index 0000000000000000000000000000000000000000..a8d36076528d81c781e1ccbf4dd18c9341032237 --- /dev/null +++ b/picarones/measurements/taxonomy.py @@ -0,0 +1,350 @@ +"""Taxonomie des erreurs OCR — classification automatique (classes 1 à 9). + +Chaque erreur identifiée par l'alignement GT↔OCR est catégorisée selon +la taxonomie Picarones : + +| Classe | Nom | Description | +|--------|-------------------|----------------------------------------------------| +| 1 | visual_confusion | Confusion morphologique (rn/m, l/1, O/0, u/n…) | +| 2 | diacritic_error | Diacritique absent, incorrect ou ajouté | +| 3 | case_error | Erreur de casse uniquement (A/a) | +| 4 | ligature_error | Ligature non résolue ou mal résolue | +| 5 | abbreviation_error| Abréviation médiévale non développée | +| 6 | hapax | Mot introuvable dans tout lexique | +| 7 | segmentation_error| Fusion ou fragmentation de tokens (mots/lignes) | +| 8 | oov_character | Caractère hors-vocabulaire du moteur | +| 9 | lacuna | Texte présent dans le GT absent de l'OCR | +| 10 | over_normalization| Sur-normalisation LLM (voir pipelines/) | + +Note : la classe 10 est calculée par picarones/pipelines/over_normalization.py. +""" + +from __future__ import annotations + +import difflib +import unicodedata +from dataclasses import dataclass, field + + +# --------------------------------------------------------------------------- +# Tables de référence pour la classification +# --------------------------------------------------------------------------- + +#: Confusions visuelles bien connues en OCR (caractères morphologiquement proches) +VISUAL_CONFUSIONS: dict[frozenset, str] = {} +_VISUAL_PAIRS: list[tuple[str, str]] = [ + # Minuscules + ("r", "n"), ("rn", "m"), ("l", "1"), ("l", "i"), ("l", "|"), + ("O", "0"), ("O", "o"), ("u", "n"), ("n", "u"), ("v", "u"), + ("c", "e"), ("e", "c"), ("a", "o"), ("o", "a"), + ("f", "ſ"), ("ſ", "f"), ("f", "t"), + ("h", "li"), ("h", "lı"), + ("m", "rn"), ("m", "in"), + ("d", "cl"), ("d", "a"), + ("q", "g"), ("p", "q"), + # Majuscules ↔ minuscules homographes (classe 1, pas classe 3) + ("I", "l"), ("I", "1"), + # Chiffres + ("1", "I"), ("1", "l"), ("0", "O"), + # Ponctuation + (".", ","), (",", "."), +] +for _a, _b in _VISUAL_PAIRS: + VISUAL_CONFUSIONS[frozenset({_a, _b})] = f"{_a}/{_b}" + +#: Couples de ligatures pour la détection des erreurs de ligatures +from picarones.core.char_scores import LIGATURE_TABLE, DIACRITIC_MAP # noqa: E402 + +# Caractères hors-ASCII présumés hors-vocabulaire (alphabet non latin de base) +_LATIN_BASIC = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + " \t\n.,;:!?-_'\"«»()[]{}/@#%&*+=/\\|<>~^") + + +# --------------------------------------------------------------------------- +# Résultat structuré +# --------------------------------------------------------------------------- + +@dataclass +class TaxonomyResult: + """Résultat de la classification taxonomique des erreurs pour un document.""" + + counts: dict[str, int] = field(default_factory=dict) + """Nombre d'erreurs par classe. Clés : 'visual_confusion', 'diacritic_error'…""" + + examples: dict[str, list[dict]] = field(default_factory=dict) + """Exemples d'erreurs par classe (max 5 par classe). + Format : [{'gt': 'chaîne', 'ocr': 'chaîne', 'position': int}] + """ + + total_errors: int = 0 + """Nombre total d'erreurs classifiées.""" + + @property + def class_distribution(self) -> dict[str, float]: + """Distribution relative (0–1) par classe.""" + if not self.total_errors: + return {} + return { + cls: round(cnt / self.total_errors, 4) + for cls, cnt in self.counts.items() + } + + def as_dict(self) -> dict: + return { + "counts": self.counts, + "total_errors": self.total_errors, + "class_distribution": self.class_distribution, + "examples": { + cls: exs[:3] for cls, exs in self.examples.items() + }, + } + + @classmethod + def from_dict(cls, data: dict) -> "TaxonomyResult": + return cls( + counts=data.get("counts", {}), + examples=data.get("examples", {}), + total_errors=data.get("total_errors", 0), + ) + + +# Noms des classes en ordre +ERROR_CLASSES = [ + "visual_confusion", + "diacritic_error", + "case_error", + "ligature_error", + "abbreviation_error", + "hapax", + "segmentation_error", + "oov_character", + "lacuna", +] + + +# --------------------------------------------------------------------------- +# Classification principale +# --------------------------------------------------------------------------- + +def classify_errors( + ground_truth: str, + hypothesis: str, + max_examples: int = 5, +) -> TaxonomyResult: + """Classifie automatiquement les erreurs OCR dans une paire GT/OCR. + + L'alignement utilise difflib.SequenceMatcher au niveau mot pour détecter + les erreurs de segmentation, puis au niveau caractère pour les autres classes. + + Parameters + ---------- + ground_truth: + Texte de référence (vérité terrain). + hypothesis: + Texte produit par l'OCR. + max_examples: + Nombre maximal d'exemples conservés par classe. + + Returns + ------- + TaxonomyResult + """ + counts: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES} + examples: dict[str, list[dict]] = {cls: [] for cls in ERROR_CLASSES} + total = 0 + + if not ground_truth and not hypothesis: + return TaxonomyResult(counts=counts, examples=examples, total_errors=0) + + # ----------------------------------------------------------------------- + # Niveau mot : détecter segmentation (classe 7) et lacunes (classe 9) + # ----------------------------------------------------------------------- + gt_words = ground_truth.split() + hyp_words = hypothesis.split() + + word_matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False) + for tag, i1, i2, j1, j2 in word_matcher.get_opcodes(): + if tag == "delete": + # Mots GT absents de l'OCR → lacune (classe 9) + for w in gt_words[i1:i2]: + counts["lacuna"] += 1 + total += 1 + if len(examples["lacuna"]) < max_examples: + examples["lacuna"].append({"gt": w, "ocr": "", "position": i1}) + + elif tag == "insert": + # Mots ajoutés par l'OCR → généralement classe 8 (hors-vocab) + for w in hyp_words[j1:j2]: + if _is_oov_word(w): + counts["oov_character"] += 1 + total += 1 + + elif tag == "replace": + gt_seg = gt_words[i1:i2] + hyp_seg = hyp_words[j1:j2] + # Segmentation : fusion de mots (moins de mots OCR) ou fragmentation + if len(hyp_seg) != len(gt_seg): + n_seg = abs(len(gt_seg) - len(hyp_seg)) + counts["segmentation_error"] += n_seg + total += n_seg + if len(examples["segmentation_error"]) < max_examples: + examples["segmentation_error"].append({ + "gt": " ".join(gt_seg), + "ocr": " ".join(hyp_seg), + "position": i1, + }) + else: + # Paires mot-à-mot + for gt_w, hyp_w in zip(gt_seg, hyp_seg): + if gt_w != hyp_w: + _classify_word_error( + gt_w, hyp_w, counts, examples, max_examples + ) + total += 1 + + return TaxonomyResult( + counts=counts, + examples=examples, + total_errors=total, + ) + + +def _classify_word_error( + gt_word: str, + hyp_word: str, + counts: dict[str, int], + examples: dict[str, list[dict]], + max_examples: int, +) -> None: + """Classifie l'erreur entre deux mots non-identiques.""" + # Classe 3 : erreur de casse seule + if gt_word.casefold() == hyp_word.casefold() and gt_word != hyp_word: + counts["case_error"] += 1 + if len(examples["case_error"]) < max_examples: + examples["case_error"].append({"gt": gt_word, "ocr": hyp_word}) + return + + # Classe 4 : erreur de ligature + gt_norm = unicodedata.normalize("NFC", gt_word) + hyp_norm = unicodedata.normalize("NFC", hyp_word) + if _is_ligature_error(gt_norm, hyp_norm): + counts["ligature_error"] += 1 + if len(examples["ligature_error"]) < max_examples: + examples["ligature_error"].append({"gt": gt_word, "ocr": hyp_word}) + return + + # Classe 5 : erreur d'abréviation (présence de ꝑ, ꝓ, ꝗ dans le GT) + if _is_abbreviation_error(gt_norm, hyp_norm): + counts["abbreviation_error"] += 1 + if len(examples["abbreviation_error"]) < max_examples: + examples["abbreviation_error"].append({"gt": gt_word, "ocr": hyp_word}) + return + + # Classe 2 : erreur diacritique + if _is_diacritic_error(gt_norm, hyp_norm): + counts["diacritic_error"] += 1 + if len(examples["diacritic_error"]) < max_examples: + examples["diacritic_error"].append({"gt": gt_word, "ocr": hyp_word}) + return + + # Classe 1 : confusion visuelle (comparaison char par char) + if _is_visual_confusion(gt_norm, hyp_norm): + counts["visual_confusion"] += 1 + if len(examples["visual_confusion"]) < max_examples: + examples["visual_confusion"].append({"gt": gt_word, "ocr": hyp_word}) + return + + # Classe 8 : caractère hors-vocabulaire + if _is_oov_word(hyp_word): + counts["oov_character"] += 1 + if len(examples["oov_character"]) < max_examples: + examples["oov_character"].append({"gt": gt_word, "ocr": hyp_word}) + return + + # Classe 6 : hapax (erreur résiduelle non classifiable) + counts["hapax"] += 1 + if len(examples["hapax"]) < max_examples: + examples["hapax"].append({"gt": gt_word, "ocr": hyp_word}) + + +def _is_ligature_error(gt: str, hyp: str) -> bool: + """Vrai si la différence implique une ligature Unicode.""" + # GT contient une ligature que l'OCR a décomposée, ou vice versa + for lig, seqs in LIGATURE_TABLE.items(): + if lig in gt: + for seq in seqs: + if seq in hyp and lig not in hyp: + return True + for seq in seqs: + if seq in gt and lig in hyp: + return True + return False + + +def _is_abbreviation_error(gt: str, hyp: str) -> bool: + """Vrai si le GT contient un caractère d'abréviation médiévale.""" + abbreviation_chars = "\uA751\uA753\uA757" # ꝑ ꝓ ꝗ + return any(c in gt for c in abbreviation_chars) + + +def _is_diacritic_error(gt: str, hyp: str) -> bool: + """Vrai si la différence est principalement due à des diacritiques.""" + # Comparer les formes sans diacritiques + def strip_diacritics(text: str) -> str: + nfd = unicodedata.normalize("NFD", text) + return "".join(c for c in nfd if unicodedata.category(c) != "Mn") + + gt_stripped = strip_diacritics(gt) + hyp_stripped = strip_diacritics(hyp) + # Si les mots sont identiques sans diacritiques → erreur diacritique + if gt_stripped.casefold() == hyp_stripped.casefold() and gt != hyp: + return True + # Si le GT contient des diacritiques que l'OCR a perdus et que les textes + # sans diacritiques sont identiques (même longueur requise) + gt_has_diac = any(c in DIACRITIC_MAP for c in gt) + return gt_has_diac and len(gt) == len(hyp) and gt_stripped.casefold() == hyp_stripped.casefold() + + +def _is_visual_confusion(gt: str, hyp: str) -> bool: + """Vrai si la différence implique des confusions visuelles connues.""" + if abs(len(gt) - len(hyp)) > 2: + return False + # Vérifier les paires de confusions connues + for pair in VISUAL_CONFUSIONS: + chars = list(pair) + if len(chars) == 2: + a, b = chars + if a in gt and b in hyp and a not in hyp: + return True + if b in gt and a in hyp and b not in hyp: + return True + return False + + +def _is_oov_word(word: str) -> bool: + """Vrai si le mot contient des caractères hors de l'alphabet latin de base.""" + return any(c not in _LATIN_BASIC and not c.isalpha() for c in word) + + +# --------------------------------------------------------------------------- +# Agrégation +# --------------------------------------------------------------------------- + +def aggregate_taxonomy(results: list[TaxonomyResult]) -> dict: + """Agrège les résultats taxonomiques sur un corpus.""" + combined: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES} + total = 0 + for r in results: + for cls, cnt in r.counts.items(): + combined[cls] = combined.get(cls, 0) + cnt + total += r.total_errors + + distribution = { + cls: round(cnt / total, 4) if total > 0 else 0.0 + for cls, cnt in combined.items() + } + return { + "counts": combined, + "total_errors": total, + "class_distribution": distribution, + } diff --git a/picarones/measurements/taxonomy_comparison.py b/picarones/measurements/taxonomy_comparison.py new file mode 100644 index 0000000000000000000000000000000000000000..eb99d5ef20d8af1985c2dd42b777499c3d1b58f3 --- /dev/null +++ b/picarones/measurements/taxonomy_comparison.py @@ -0,0 +1,161 @@ +"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3). + +Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4). + +Pourquoi ce module +------------------ +Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale +qu'un moteur a un profil taxonomique éloigné de ses concurrents, +mais le rapport n'expose pas cette différence visuellement. Ce +sprint répond à *« deux moteurs ont le même CER global, mais lequel +fait des erreurs plus récupérables ? »*. + +Lecture concrète +---------------- +- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables + par un post-processing trivial (récupérables). +- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) → + irrécupérables sans relire l'image. + +À CER égal, A est massivement préférable pour un workflow +d'édition critique. Cette vue rend la différence visible. + +Catégorisation des classes +-------------------------- +On annote chaque classe d'erreur d'un degré de **récupérabilité** +(critère éditorial pragmatique, pas verdict imposé) : + +- ``recoverable`` : récupérable par post-processing trivial + (case_error, ligature_error, abbreviation_error) +- ``difficult`` : récupérable au prix d'un effort + (diacritic_error, visual_confusion, hapax) +- ``irrecoverable`` : impossible à corriger sans l'image + (lacuna, oov_character, segmentation_error) + +L'utilisateur consulte ces catégories comme un guide, pas un +verdict — c'est lui qui juge selon ses besoins éditoriaux. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +# Classification éditoriale. Documentée dans la docstring. +RECOVERABILITY: dict[str, str] = { + "case_error": "recoverable", + "ligature_error": "recoverable", + "abbreviation_error": "recoverable", + "diacritic_error": "difficult", + "visual_confusion": "difficult", + "hapax": "difficult", + "lacuna": "irrecoverable", + "oov_character": "irrecoverable", + "segmentation_error": "irrecoverable", +} + + +def _normalize_counts(counts: dict[str, int]) -> dict[str, float]: + """Convertit un dict de comptes en proportions [0, 1].""" + total = sum(counts.values()) + if total <= 0: + return {k: 0.0 for k in counts} + return {k: v / total for k, v in counts.items()} + + +def compare_taxonomies( + engine_a_name: str, + engine_a_counts: dict[str, int], + engine_b_name: str, + engine_b_counts: dict[str, int], +) -> Optional[dict]: + """Compare deux profils taxonomiques. + + Parameters + ---------- + engine_a_name, engine_b_name: + Noms d'identification des moteurs (utilisés dans le rendu). + engine_a_counts, engine_b_counts: + Maps ``{class_name: count}`` produites par + ``aggregate_taxonomy``. + + Returns + ------- + Optional[dict] + ``{ + "engine_a": str, "engine_b": str, + "total_a": int, "total_b": int, + "classes": list[str], # classes apparaissant chez A ou B + "proportions_a": dict[str, float], + "proportions_b": dict[str, float], + "deltas": dict[str, float], # prop_b - prop_a (signé) + "recoverability": dict[str, str], # mapping class → niveau + "totals_by_recoverability": { + "recoverable": {"a": float, "b": float}, + "difficult": {"a": float, "b": float}, + "irrecoverable": {"a": float, "b": float}, + }, + }`` + Ou ``None`` si les deux moteurs ont 0 erreur chacun. + """ + if engine_a_name == engine_b_name: + # On accepte des comparaisons même si les noms sont + # identiques (cas tests), mais on émet un warning. + logger.warning( + "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s", + engine_a_name, + ) + + total_a = sum(engine_a_counts.values()) if engine_a_counts else 0 + total_b = sum(engine_b_counts.values()) if engine_b_counts else 0 + if total_a == 0 and total_b == 0: + return None + + classes = sorted(set(engine_a_counts) | set(engine_b_counts)) + if not classes: + return None + + prop_a = _normalize_counts( + {c: engine_a_counts.get(c, 0) for c in classes}, + ) + prop_b = _normalize_counts( + {c: engine_b_counts.get(c, 0) for c in classes}, + ) + deltas = {c: prop_b[c] - prop_a[c] for c in classes} + + # Agrégat par récupérabilité (utile pour la lecture rapide) + totals_recov: dict[str, dict[str, float]] = { + "recoverable": {"a": 0.0, "b": 0.0}, + "difficult": {"a": 0.0, "b": 0.0}, + "irrecoverable": {"a": 0.0, "b": 0.0}, + } + for cls in classes: + level = RECOVERABILITY.get(cls, "difficult") + if level not in totals_recov: + level = "difficult" + totals_recov[level]["a"] += prop_a[cls] + totals_recov[level]["b"] += prop_b[cls] + + return { + "engine_a": engine_a_name, + "engine_b": engine_b_name, + "total_a": total_a, + "total_b": total_b, + "classes": classes, + "proportions_a": prop_a, + "proportions_b": prop_b, + "deltas": deltas, + "recoverability": { + cls: RECOVERABILITY.get(cls, "difficult") for cls in classes + }, + "totals_by_recoverability": totals_recov, + } + + +__all__ = [ + "RECOVERABILITY", + "compare_taxonomies", +] diff --git a/picarones/measurements/throughput.py b/picarones/measurements/throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..47d0ed674492f221013aa8a53c3632db14cbe6b5 --- /dev/null +++ b/picarones/measurements/throughput.py @@ -0,0 +1,165 @@ +"""Throughput effectif (Sprint 91 — A.II.6). + +Sprint 91 — A.II.6 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le throughput brut (pages/heure d'OCR pur) ment quand un moteur +est rapide mais imprécis : la correction humaine *post hoc* +absorbe le gain. La **vraie** vitesse opérationnelle inclut +le temps de correction. Cette métrique discrimine fortement +entre un cloud rapide à 30 % de timeouts/erreurs et un local +lent à 100 % de fiabilité. + +Formule +------- +.. code:: + + pages_par_heure_utilisable = + pages_traitées / (durée_totale + temps_correction_humaine) + +Le temps de correction est estimé linéairement : +``temps_par_erreur × nombre_d_erreurs``. Le défaut +``time_per_error_seconds=5.0`` correspond aux études HTR-United +(saisie manuelle d'une correction de mot par un opérateur +formé : ≈ 5 s par erreur). L'utilisateur peut le surcharger +pour son institution. + +Sortie +------ +``compute_effective_throughput(n_pages, duration_seconds, +n_errors, time_per_error_seconds=5.0)`` retourne ``{n_pages, +duration_seconds, n_errors, time_per_error_seconds, +correction_time_seconds, total_seconds, pages_per_hour_raw, +pages_per_hour_effective, drag_ratio}``. + +``aggregate_effective_throughput(per_engine_data)`` agrège par +moteur sur l'ensemble du corpus. +""" + +from __future__ import annotations + +import logging +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +_DEFAULT_TIME_PER_ERROR_SECONDS = 5.0 + + +def compute_effective_throughput( + n_pages: int, + duration_seconds: float, + n_errors: int, + *, + time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, +) -> Optional[dict]: + """Throughput effectif (pages/heure utilisables). + + Parameters + ---------- + n_pages: + Nombre de pages traitées. + duration_seconds: + Durée totale de l'OCR (somme des durées par doc). + n_errors: + Nombre d'erreurs (au niveau mot, typiquement + ``WER × n_words_total``). + time_per_error_seconds: + Temps moyen de correction humaine par erreur. Défaut + 5 s (HTR-United). Doit être ≥ 0. + + Returns + ------- + dict | None + ``None`` si ``n_pages == 0`` ou ``total_seconds == 0`` + (pas de division par zéro). + """ + if n_pages <= 0: + return None + if duration_seconds < 0 or n_errors < 0 or time_per_error_seconds < 0: + raise ValueError( + "duration_seconds, n_errors et time_per_error_seconds " + "doivent être ≥ 0", + ) + correction_seconds = float(n_errors) * float(time_per_error_seconds) + total_seconds = float(duration_seconds) + correction_seconds + if total_seconds <= 0: + # Aucun temps écoulé : impossible de définir un throughput + return None + pages_per_hour_raw = ( + n_pages / duration_seconds * 3600.0 + if duration_seconds > 0 else None + ) + pages_per_hour_effective = n_pages / total_seconds * 3600.0 + drag_ratio = ( + correction_seconds / total_seconds if total_seconds > 0 else 0.0 + ) + return { + "n_pages": int(n_pages), + "duration_seconds": float(duration_seconds), + "n_errors": int(n_errors), + "time_per_error_seconds": float(time_per_error_seconds), + "correction_time_seconds": correction_seconds, + "total_seconds": total_seconds, + "pages_per_hour_raw": pages_per_hour_raw, + "pages_per_hour_effective": pages_per_hour_effective, + "drag_ratio": drag_ratio, + } + + +def aggregate_effective_throughput( + per_engine: Iterable[dict], + *, + time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, +) -> Optional[dict]: + """Agrège le throughput effectif par moteur. + + Parameters + ---------- + per_engine: + Itérable de dicts ``{engine_name, n_pages, + duration_seconds, n_errors}``. + + Returns + ------- + dict | None + ``{ + "engines": [ + {"engine_name", ..., compute_effective_throughput + fields}, + ... + ], + "time_per_error_seconds": float, + }`` ou ``None`` si aucun moteur exploitable. + """ + rows: list[dict] = [] + for entry in per_engine: + if not isinstance(entry, dict): + continue + name = entry.get("engine_name") or entry.get("engine") + if not name: + continue + result = compute_effective_throughput( + int(entry.get("n_pages") or 0), + float(entry.get("duration_seconds") or 0.0), + int(entry.get("n_errors") or 0), + time_per_error_seconds=time_per_error_seconds, + ) + if result is None: + continue + result["engine_name"] = str(name) + rows.append(result) + if not rows: + return None + return { + "engines": rows, + "time_per_error_seconds": float(time_per_error_seconds), + } + + +__all__ = [ + "compute_effective_throughput", + "aggregate_effective_throughput", +] diff --git a/picarones/measurements/worst_lines.py b/picarones/measurements/worst_lines.py new file mode 100644 index 0000000000000000000000000000000000000000..dfece53263f29f83db9cb6dbaaf749d719b04857 --- /dev/null +++ b/picarones/measurements/worst_lines.py @@ -0,0 +1,199 @@ +"""Extraction transversale des « Worst lines » du corpus — Sprint 72. + +Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le percentile p95 du CER ligne (calculé par ``line_metrics.py``, +Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un +CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur +texte, leur diff, leur document parent, pour comprendre ce qui +casse. + +Ce module fournit la requête transversale qui collecte, depuis un +``BenchmarkResult``, les **N lignes les plus mal transcrites de +tout le corpus**, classées par CER ligne. Filtrable par moteur +et par strate. + +Limite documentée +----------------- +``DocumentResult.line_metrics`` ne stocke que les CER par ligne, +**pas le texte des lignes**. Pour récupérer les textes GT/hyp +on resplitte ``ground_truth`` et ``hypothesis`` du +``DocumentResult`` à l'index de la ligne. Cette logique +**suppose un BenchmarkResult non-compacté** — après ``compact()`` +les textes sont tronqués à 200 caractères et les lignes au-delà +de cette troncature ne sont plus accessibles. En pratique on +extrait les worst lines **avant** la sérialisation/compactage. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class WorstLineEntry: + """Une ligne du corpus identifiée comme mal transcrite. + + Champs + ------ + rank: + Position dans le classement (1-based, 1 = pire CER). + cer: + CER de la ligne ∈ [0, 1]. + engine_name: + Nom du moteur ayant produit cette hypothèse. + doc_id: + Identifiant du document parent. + line_index: + Index 0-based de la ligne dans le document GT. + gt_line: + Texte de la ligne dans la GT. + hyp_line: + Texte correspondant dans l'hypothèse (peut être ``""`` + si l'OCR a sauté la ligne). + script_type: + Strate du document si disponible (``script_type`` + capturé par le runner pour la stratification A.III). + """ + + rank: int + cer: float + engine_name: str + doc_id: str + line_index: int + gt_line: str + hyp_line: str + script_type: Optional[str] = None + + +def _split_lines(text: Optional[str]) -> list[str]: + """Splitte un texte en lignes (cohérent avec ``line_metrics``). + + Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les + lignes vides sont préservées. Retourne une liste vide si le + texte est None ou vide. + """ + if not text: + return [] + # ``splitlines`` gère \r\n et \r correctement + return text.splitlines() + + +def _line_at(text: Optional[str], index: int) -> str: + """Retourne la ligne à l'index demandé, ou ``""`` si l'index + est hors borne (cas où l'OCR a moins de lignes que la GT).""" + lines = _split_lines(text) + if 0 <= index < len(lines): + return lines[index] + return "" + + +def extract_worst_lines( + benchmark, + *, + top_n: int = 20, + engine_filter: Optional[str] = None, + script_type_filter: Optional[str] = None, +) -> list[WorstLineEntry]: + """Extrait les ``top_n`` lignes les plus mal transcrites du + corpus, transversalement à tous les moteurs et documents. + + Parameters + ---------- + benchmark: + ``BenchmarkResult`` non-compacté (cf. limite ci-dessus). + L'objet doit exposer ``engine_reports`` (liste de + ``EngineReport``) et optionnellement ``doc_strata`` + (map ``{doc_id: script_type}``, Sprint 45). + top_n: + Nombre de lignes à retourner. Défaut : 20. + engine_filter: + Si fourni, n'inclut que les lignes produites par ce moteur + (match exact sur ``engine_name``). + script_type_filter: + Si fourni, n'inclut que les lignes des documents de cette + strate (nécessite ``benchmark.doc_strata``). + + Returns + ------- + list[WorstLineEntry] + Liste triée par CER décroissant (pire en premier), + rang 1-based attribué après tri. Vide si aucune ligne + exploitable. + """ + if top_n <= 0: + return [] + + doc_strata = getattr(benchmark, "doc_strata", None) or {} + candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = [] + + for engine_report in getattr(benchmark, "engine_reports", []): + engine_name = engine_report.engine_name + if engine_filter is not None and engine_name != engine_filter: + continue + for dr in engine_report.document_results: + line_metrics = getattr(dr, "line_metrics", None) + if not line_metrics: + continue + cer_per_line = line_metrics.get("cer_per_line") if isinstance( + line_metrics, dict, + ) else getattr(line_metrics, "cer_per_line", None) + if not cer_per_line: + continue + doc_id = dr.doc_id + doc_strata_value = doc_strata.get(doc_id) + if ( + script_type_filter is not None + and doc_strata_value != script_type_filter + ): + continue + for idx, cer in enumerate(cer_per_line): + if cer <= 0.0: + continue + gt_line = _line_at(dr.ground_truth, idx) + hyp_line = _line_at(dr.hypothesis, idx) + if not gt_line and not hyp_line: + continue + candidates.append(( + float(cer), engine_name, doc_id, idx, + gt_line, hyp_line, doc_strata_value, + )) + + if not candidates: + return [] + + # Tri par CER décroissant ; en cas d'égalité, ordre stable + # (engine, doc_id, line_index) pour reproductibilité. + candidates.sort( + key=lambda c: (-c[0], c[1], c[2], c[3]), + ) + selected = candidates[:top_n] + + return [ + WorstLineEntry( + rank=i + 1, + cer=cer, + engine_name=engine, + doc_id=doc_id, + line_index=line_index, + gt_line=gt_line, + hyp_line=hyp_line, + script_type=script_type, + ) + for i, ( + cer, engine, doc_id, line_index, + gt_line, hyp_line, script_type, + ) in enumerate(selected) + ] + + +__all__ = [ + "WorstLineEntry", + "extract_worst_lines", +] diff --git a/tests/test_phaseE_migration.py b/tests/test_phaseE_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..ce908b81fd9fdbdae9a681ff430269cecb89fcb5 --- /dev/null +++ b/tests/test_phaseE_migration.py @@ -0,0 +1,276 @@ +"""Tests de la phase E — séparation core/ + measurements/. + +Couvre : + +- ~41 modules métriques déplacés vers ``picarones/measurements/``. +- Sous-package ``narrative/`` (4 modules + 6 familles de détecteurs + + helper) déplacé vers ``picarones/measurements/narrative/``. +- Identité préservée à travers les shims. +- Le ``core/`` strict ne contient plus que ~13 fichiers (Cercle 1). +- Les hooks builtin restent enregistrés. +- Le moteur narratif fonctionne (détection + arbitre + rendu). +- Les vues du chantier 3 fonctionnent. +- Document ``docs/architecture-cercles.md`` mis à jour avec critère DDD. +""" + +from __future__ import annotations + +import importlib +import warnings +from pathlib import Path + +import pytest + + +# ────────────────────────────────────────────────────────────────────────── +# 1. Imports historiques rétrocompat des mesures +# ────────────────────────────────────────────────────────────────────────── + + +class TestMeasurementsRetrocompat: + @pytest.mark.parametrize("module_path, attribute", [ + ("picarones.core.confusion", "build_confusion_matrix"), + ("picarones.core.taxonomy", "classify_errors"), + ("picarones.core.calibration", "compute_calibration_metrics"), + ("picarones.core.layout", "compute_layout_metrics"), + ("picarones.core.reading_order", "compute_reading_order_metrics"), + ("picarones.core.error_absorption", "compute_error_absorption"), + ("picarones.core.searchability", "compute_searchability"), + ("picarones.core.numerical_sequences", + "compute_numerical_sequence_metrics"), + ("picarones.core.readability", "flesch_score"), + ("picarones.core.specialization", "compute_specialization_score"), + ("picarones.core.throughput", "compute_effective_throughput"), + ("picarones.core.cost_projection", "project_engine"), + ("picarones.core.statistics", "bootstrap_ci"), + ("picarones.core.history", "BenchmarkHistory"), + ("picarones.core.builtin_hooks", "calibration_from_engine_result"), + ("picarones.core.line_metrics", "compute_line_metrics"), + ("picarones.core.hallucination", "compute_hallucination_metrics"), + ("picarones.core.image_quality", "analyze_image_quality"), + ("picarones.core.normalization", "PROFILES"), + ("picarones.core.rare_tokens", "extract_rare_tokens"), + ]) + def test_legacy_path_works(self, module_path: str, attribute: str): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + mod = importlib.import_module(module_path) + assert hasattr(mod, attribute), f"{module_path}.{attribute}" + + +# ────────────────────────────────────────────────────────────────────────── +# 2. Sous-package narrative/ déplacé +# ────────────────────────────────────────────────────────────────────────── + + +class TestNarrativePackageMigration: + def test_narrative_root_import(self): + from picarones.core.narrative import build_synthesis + assert callable(build_synthesis) + + def test_narrative_facts_via_shim(self): + from picarones.core.narrative.facts import Fact, FactType + assert Fact is not None + assert FactType is not None + + def test_narrative_registry_via_shim(self): + from picarones.core.narrative.registry import register_detector + assert callable(register_detector) + + def test_narrative_detectors_via_shim(self): + from picarones.core.narrative.detectors import ( + DETECTORS_BY_TYPE, + detect_global_leader_cer, + ) + assert callable(detect_global_leader_cer) + assert len(DETECTORS_BY_TYPE) == 18 + + def test_narrative_detector_family_modules(self): + # Les 6 familles restent accessibles via leur ancien chemin + from picarones.core.narrative.detectors.ranking import ( + detect_global_leader_cer, + ) + from picarones.core.narrative.detectors.pareto import ( + detect_pareto_alternative, + ) + from picarones.core.narrative.detectors.history import ( + detect_engine_unstable, + ) + from picarones.core.narrative.detectors.quality import ( + detect_confidence_warning, + ) + from picarones.core.narrative.detectors.stratum import ( + detect_stratum_winner, + ) + from picarones.core.narrative.detectors.ensemble import ( + detect_ensemble_opportunity, + ) + assert all(callable(f) for f in [ + detect_global_leader_cer, + detect_pareto_alternative, + detect_engine_unstable, + detect_confidence_warning, + detect_stratum_winner, + detect_ensemble_opportunity, + ]) + + +# ────────────────────────────────────────────────────────────────────────── +# 3. Identité préservée +# ────────────────────────────────────────────────────────────────────────── + + +class TestIdentityThroughShim: + def test_confusion_identity(self): + from picarones.core.confusion import build_confusion_matrix as via_old + from picarones.measurements.confusion import ( + build_confusion_matrix as via_new, + ) + assert via_old is via_new + + def test_narrative_facts_identity(self): + from picarones.core.narrative.facts import Fact as via_old + from picarones.measurements.narrative.facts import Fact as via_new + assert via_old is via_new + + def test_narrative_detector_identity(self): + from picarones.core.narrative.detectors.ranking import ( + detect_speed_winner as via_old, + ) + from picarones.measurements.narrative.detectors.ranking import ( + detect_speed_winner as via_new, + ) + assert via_old is via_new + + +# ────────────────────────────────────────────────────────────────────────── +# 4. core/ Cercle 1 strict — ne contient plus que ~13 modules +# ────────────────────────────────────────────────────────────────────────── + + +class TestCoreIsLean: + """Le ``core/`` post-phase E ne contient plus que les modules + Cercle 1 (abstractions + orchestration). Tout le reste est shim.""" + + @pytest.mark.parametrize("name", [ + "corpus", "modules", "results", "metrics", + "runner", "pipeline_runner", "pipeline_benchmark", + "pipeline_comparison", "pipeline_spec_loader", + "metric_registry", "metric_hooks", + "builtin_metrics", "alto_metrics", + ]) + def test_cercle1_module_present(self, name): + """Les modules Cercle 1 doivent rester dans ``core/`` (pas de shim).""" + repo = Path(__file__).parent.parent + path = repo / "picarones" / "core" / f"{name}.py" + assert path.exists() + content = path.read_text(encoding="utf-8") + # Un module Cercle 1 a > 30 lignes (vraie logique, pas shim) + n_lines = len([line for line in content.splitlines() if line.strip()]) + assert n_lines > 30, ( + f"core/{name}.py fait {n_lines} lignes — ne devrait pas être " + "un shim, c'est un module Cercle 1" + ) + + +# ────────────────────────────────────────────────────────────────────────── +# 5. Hooks builtin enregistrés (12 doc + 12 corpus) +# ────────────────────────────────────────────────────────────────────────── + + +class TestHooksStillRegistered: + def test_12_doc_hooks(self): + # Eager-load des hooks via builtin_hooks (qui est maintenant un shim + # vers measurements/builtin_hooks). + import picarones.core.builtin_hooks # noqa: F401 + from picarones.core.metric_hooks import _all_document_hook_names + + hooks = _all_document_hook_names() + # Le compte exact dépend des hooks expérimentaux qui tests + # pourraient ajouter, donc on vérifie >= 12 et la présence des + # 12 attendus. + expected = { + "confusion", "char_scores", "taxonomy", "structure", + "image_quality", "line_metrics", "hallucination", + "calibration", "philological", "searchability", + "numerical_sequences", "readability", + } + assert expected.issubset(set(hooks)) + + def test_alto_metrics_registered(self): + import picarones.core.pipeline_runner # eager-load + from picarones.core.metric_registry import select_metrics + from picarones.core.modules import ArtifactType + + metrics = select_metrics((ArtifactType.ALTO, ArtifactType.ALTO)) + names = {s.name for s in metrics} + assert "alto_text_cer" in names + assert "alto_text_wer" in names + + +# ────────────────────────────────────────────────────────────────────────── +# 6. build_synthesis fonctionne (intégration narrative complète) +# ────────────────────────────────────────────────────────────────────────── + + +class TestNarrativeIntegration: + def test_build_synthesis_works(self): + from picarones.core.narrative import build_synthesis + + synth = build_synthesis({ + "ranking": [ + {"engine": "tess", "mean_cer": 0.05}, + {"engine": "pero", "mean_cer": 0.08}, + ], + }, lang="fr") + assert "sentences" in synth + assert "facts" in synth + assert len(synth["sentences"]) >= 1 + + +# ────────────────────────────────────────────────────────────────────────── +# 7. Vues du chantier 3 fonctionnent +# ────────────────────────────────────────────────────────────────────────── + + +class TestChantier3ViewsAfterPhaseE: + def test_views_still_work(self): + from picarones.report.views import ( + build_advanced_taxonomy_view_html, + build_diagnostics_view_html, + build_economics_view_html, + ) + report_data = {"engines": [ + {"name": "tess", "cer": 0.05, + "aggregated_taxonomy": {"class_distribution": {"x": 5}}}, + {"name": "pero", "cer": 0.08, + "aggregated_taxonomy": {"class_distribution": {"x": 8}}}, + ]} + # Au moins advanced_taxonomy doit produire du HTML + html = build_advanced_taxonomy_view_html(report_data, {}) + assert isinstance(html, str) + + +# ────────────────────────────────────────────────────────────────────────── +# 8. Documentation cercles mise à jour +# ────────────────────────────────────────────────────────────────────────── + + +class TestArchitectureCerclesDocUpdated: + @pytest.fixture + def doc(self) -> str: + path = Path(__file__).parent.parent / "docs" / "architecture-cercles.md" + return path.read_text(encoding="utf-8") + + def test_critere_corrige(self, doc): + """Le critère DDD remplace l'ancien critère ambigu.""" + assert "abstractions et logique métier du domaine" in doc + assert "indépendantes de l'interface utilisateur" in doc + + def test_mention_phase_e(self, doc): + """Le doc référence le sous-package measurements/.""" + # Au moins une mention du nouveau dossier + # (chemin physique du Cercle 2) + # NB : le doc parle de ``measurements/`` mais la lettre exacte + # dépend de la formulation. On accepte plusieurs variantes. + assert "measurements" in doc.lower() or "Cercle 2" in doc