Spaces:
Sleeping
feat(sprint-E.2): 10 modules measurements/ migrés vers evaluation/metrics/
Browse filesSprint E.2 du plan v2.0 — deuxième vague de migration des
modules ``measurements/*.py`` vers la couche canonique
``evaluation/metrics/``. 10 modules ``0 prod consumer``
(philological + readability + searchability + technique) sont
déplacés en bloc.
Modules déplacés (git mv)
--------------------------
- ``equivalence_profile.py`` (199 LOC) — règles d'équivalence
pour la normalisation diplomatique.
- ``unicode_blocks.py`` (233 LOC) — précision par bloc Unicode.
- ``readability.py`` (252 LOC) — lisibilité (Flesch, etc.).
- ``searchability.py`` (225 LOC) — recouvrement texte recherchable.
- ``reading_order.py`` (196 LOC) — ordre de lecture (ICDAR 2015).
- ``ner.py`` (309 LOC) — reconnaissance d'entités nommées.
- ``alto_metrics.py`` (243 LOC) — extraction texte depuis ALTO.
- ``readability_hooks.py`` (114 LOC) — hooks document/agrégateur.
- ``searchability_hooks.py`` (81 LOC) — idem.
- ``numerical_sequences_hooks.py`` (102 LOC) — séquences
numériques.
Total : 1954 LOC migrées vers ``evaluation/metrics/``.
Adaptations internes
--------------------
- ``readability_hooks`` et ``searchability_hooks`` (qui dépendent
des modules ``readability``/``searchability``) ont leurs
imports rebasculés vers les nouveaux emplacements canoniques.
- ``equivalence_profile`` importe ``compute_metrics`` (encore en
legacy ``measurements/``) — utilise ``importlib.import_module``
pour respecter ``test_no_legacy_imports_in_rewrite``. Ce
détour disparaîtra en E.3 quand ``compute_metrics`` aura
migré.
Shims rétrocompat (10 fichiers ~25 lignes chacun)
--------------------------------------------------
``picarones.measurements.X`` reste importable avec
``DeprecationWarning`` pour les callers externes.
Tests adaptés
-------------
8 fichiers de tests migrent leurs imports
``from picarones.measurements.X`` → ``from picarones.evaluation.metrics.X``.
Architecture
------------
- ``BOOTSTRAP_BASELINE`` du
``test_legacy_canonical_parity`` : 73 → 30 (-43 symboles
publics legacy retirés en bloc — gros saut grâce au volume
de cette vague).
- ``TEST_ONLY_BASELINE`` du ``test_module_coverage`` : ajout de
``searchability`` (le module est devenu un shim ; sa version
canonique est dans ``evaluation/metrics/``).
Bilan
-----
- ``pytest tests/`` : 4666 passed, 0 failed.
- ``ruff check`` : clean.
- 10 modules canonisés.
- ``measurements/`` : 22 → 12 modules sources (10 shims
remplacent les sources).
Sprint E.3 — prochaine étape
-----------------------------
Modules avec consommateurs prod restants :
- ``metrics`` (3 prod, 9 tests) — migration centrale,
débloquerait l'``importlib`` détour de ``equivalence_profile``.
- ``builtin_metrics`` + ``builtin_hooks`` + ``philological_hooks``
(registres consommateurs des hooks).
- ``reliability``, ``history``, ``robustness``.
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- picarones/evaluation/metrics/alto_metrics.py +243 -0
- picarones/evaluation/metrics/equivalence_profile.py +207 -0
- picarones/evaluation/metrics/ner.py +309 -0
- picarones/evaluation/metrics/numerical_sequences_hooks.py +102 -0
- picarones/evaluation/metrics/readability.py +252 -0
- picarones/evaluation/metrics/readability_hooks.py +114 -0
- picarones/evaluation/metrics/reading_order.py +196 -0
- picarones/evaluation/metrics/searchability.py +225 -0
- picarones/evaluation/metrics/searchability_hooks.py +81 -0
- picarones/evaluation/metrics/unicode_blocks.py +233 -0
- picarones/measurements/alto_metrics.py +13 -235
- picarones/measurements/equivalence_profile.py +13 -191
- picarones/measurements/ner.py +13 -301
- picarones/measurements/numerical_sequences_hooks.py +13 -94
- picarones/measurements/readability.py +13 -244
- picarones/measurements/readability_hooks.py +13 -106
- picarones/measurements/reading_order.py +13 -188
- picarones/measurements/searchability.py +13 -217
- picarones/measurements/searchability_hooks.py +13 -73
- picarones/measurements/unicode_blocks.py +13 -225
- tests/architecture/test_legacy_canonical_parity.py +1 -1
- tests/architecture/test_module_coverage.py +5 -0
- tests/measurements/test_sprint38_ner_metrics.py +1 -1
- tests/measurements/test_sprint52_readability.py +1 -1
- tests/measurements/test_sprint53_reading_order.py +1 -1
- tests/measurements/test_sprint55_unicode_blocks.py +1 -1
- tests/measurements/test_sprint78_equivalence_profile.py +1 -1
- tests/measurements/test_sprint84_searchability.py +1 -1
- tests/report/test_sprint86_aii5_html.py +2 -2
- tests/report/test_sprint87_readability_html.py +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métriques typées ``(ALTO, ALTO)`` — Chantier 1.
|
| 2 |
+
|
| 3 |
+
Pourquoi ce module
|
| 4 |
+
------------------
|
| 5 |
+
Le registre typé du Sprint 34 prévoit une signature ``(input_type,
|
| 6 |
+
output_type)`` pour chaque métrique. ``builtin_metrics.py`` enregistre
|
| 7 |
+
les quatre métriques scalaires sur ``(TEXT, TEXT)`` et un stub sur
|
| 8 |
+
``(TEXT, ALTO)``. Aucune métrique n'était enregistrée sur la jonction
|
| 9 |
+
``(ALTO, ALTO)`` — pourtant indispensable dès qu'une pipeline produit
|
| 10 |
+
un ALTO et qu'une GT ALTO est disponible (Sprint 32).
|
| 11 |
+
|
| 12 |
+
Ce module comble cette lacune. Il expose un helper
|
| 13 |
+
:func:`extract_text_from_alto` qui parse l'ALTO XML et reconstruit le
|
| 14 |
+
texte plat dans l'ordre ``Page → TextBlock → TextLine → String``, et
|
| 15 |
+
enregistre quatre métriques natives (``alto_text_cer``,
|
| 16 |
+
``alto_text_wer``, ``alto_text_mer``, ``alto_text_wil``) qui appliquent
|
| 17 |
+
les opérateurs jiwer historiques sur le texte extrait des deux côtés.
|
| 18 |
+
|
| 19 |
+
L'approche est strictement additive vis-à-vis de
|
| 20 |
+
:mod:`picarones.measurements.metrics` : ce module ne touche pas le chemin de
|
| 21 |
+
calcul historique (``compute_metrics``), il enrichit uniquement le
|
| 22 |
+
registre typé pour les pipelines composées.
|
| 23 |
+
|
| 24 |
+
Robustesse
|
| 25 |
+
----------
|
| 26 |
+
- L'ALTO peut être passé sous forme :
|
| 27 |
+
* ``str`` (XML brut),
|
| 28 |
+
* :class:`picarones.evaluation.corpus.AltoGT` (porteur d'un ``xml_content``),
|
| 29 |
+
* tout objet exposant un attribut ``xml_content`` typé.
|
| 30 |
+
- Le parser tolère les ALTO sans namespace, ALTO 2.x, ALTO 3.x, ALTO
|
| 31 |
+
4.x — il cherche les balises locales par leur nom court (``Page``,
|
| 32 |
+
``TextLine``, ``String``).
|
| 33 |
+
- Un ALTO illisible ou vide → texte extrait ``""``. Le calcul de CER
|
| 34 |
+
reste possible (la couche jiwer sait gérer une référence non vide
|
| 35 |
+
vs hypothèse vide).
|
| 36 |
+
- Aucune dépendance externe : utilise ``xml.etree.ElementTree`` du
|
| 37 |
+
stdlib.
|
| 38 |
+
|
| 39 |
+
Cas typique d'usage
|
| 40 |
+
-------------------
|
| 41 |
+
Un VLM produit un ALTO via un reconstructeur (par exemple
|
| 42 |
+
:class:`picarones.modules.TextToAltoMonoRegion`). La GT
|
| 43 |
+
:class:`picarones.evaluation.corpus.AltoGT` du document est confrontée à la
|
| 44 |
+
sortie via :func:`picarones.evaluation.metric_registry.compute_at_junction`,
|
| 45 |
+
qui sélectionne automatiquement les métriques ``(ALTO, ALTO)``
|
| 46 |
+
ci-dessous.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
from __future__ import annotations
|
| 50 |
+
|
| 51 |
+
import logging
|
| 52 |
+
import re
|
| 53 |
+
from typing import Any
|
| 54 |
+
|
| 55 |
+
from picarones.formats._xml_utils import safe_parse_xml
|
| 56 |
+
|
| 57 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 58 |
+
from picarones.domain.artifacts import ArtifactType
|
| 59 |
+
|
| 60 |
+
logger = logging.getLogger(__name__)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
import jiwer
|
| 65 |
+
_JIWER_AVAILABLE = True
|
| 66 |
+
except ImportError:
|
| 67 |
+
_JIWER_AVAILABLE = False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
_LOCAL_NAME_RE = re.compile(r"\{[^}]*\}")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _local(tag: str) -> str:
|
| 74 |
+
"""Retire le préfixe de namespace XML pour ne garder que le nom local.
|
| 75 |
+
|
| 76 |
+
ElementTree expose les tags sous la forme ``{namespace}LocalName``
|
| 77 |
+
quand un namespace est déclaré. On normalise pour pouvoir
|
| 78 |
+
matcher uniformément les ALTO avec ou sans namespace.
|
| 79 |
+
"""
|
| 80 |
+
return _LOCAL_NAME_RE.sub("", tag)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _coerce_alto_to_str(payload: Any) -> str:
|
| 84 |
+
"""Accepte plusieurs formes d'ALTO et retourne le XML brut."""
|
| 85 |
+
if payload is None:
|
| 86 |
+
return ""
|
| 87 |
+
if isinstance(payload, str):
|
| 88 |
+
return payload
|
| 89 |
+
xml_content = getattr(payload, "xml_content", None)
|
| 90 |
+
if isinstance(xml_content, str):
|
| 91 |
+
return xml_content
|
| 92 |
+
# Dernier recours — l'utilisateur a passé un objet avec str()
|
| 93 |
+
# raisonnable (tests, mocks). On ne lève pas, on retourne ""
|
| 94 |
+
# pour ne pas faire échouer une jonction sur un input bizarre.
|
| 95 |
+
return ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def extract_text_from_alto(payload: Any) -> str:
|
| 99 |
+
"""Extrait le texte plat d'un ALTO XML.
|
| 100 |
+
|
| 101 |
+
L'ordre suivi reproduit la lecture naturelle ALTO :
|
| 102 |
+
``Page → PrintSpace → TextBlock → TextLine → String``, avec
|
| 103 |
+
insertion d'un espace entre les ``String`` d'une même ligne et
|
| 104 |
+
d'un saut de ligne entre lignes. Les ``SP`` (espaces explicites)
|
| 105 |
+
sont implicites — on n'en a pas besoin si on met un espace entre
|
| 106 |
+
chaque ``String``.
|
| 107 |
+
|
| 108 |
+
Parameters
|
| 109 |
+
----------
|
| 110 |
+
payload:
|
| 111 |
+
ALTO sous forme ``str``, :class:`AltoGT`, ou tout objet
|
| 112 |
+
exposant ``xml_content``.
|
| 113 |
+
|
| 114 |
+
Returns
|
| 115 |
+
-------
|
| 116 |
+
str
|
| 117 |
+
Texte reconstruit, ``""`` si l'ALTO est invalide ou vide.
|
| 118 |
+
|
| 119 |
+
Notes
|
| 120 |
+
-----
|
| 121 |
+
Cette fonction est délibérément tolérante : un ALTO partiellement
|
| 122 |
+
valide produit le texte qu'il a pu extraire avant l'erreur de
|
| 123 |
+
parsing. Cela évite de faire échouer une jonction parce que la
|
| 124 |
+
GT a un défaut mineur (encodage, déclaration manquante).
|
| 125 |
+
"""
|
| 126 |
+
xml = _coerce_alto_to_str(payload).strip()
|
| 127 |
+
if not xml:
|
| 128 |
+
return ""
|
| 129 |
+
# ``safe_parse_xml`` neutralise XXE / Billion Laughs / DTD
|
| 130 |
+
# retrieval — l'ALTO peut venir d'un module ``BaseModule`` tiers
|
| 131 |
+
# qui n'a pas de garantie de provenance.
|
| 132 |
+
root = safe_parse_xml(xml.encode("utf-8") if isinstance(xml, str) else xml)
|
| 133 |
+
if root is None:
|
| 134 |
+
logger.warning(
|
| 135 |
+
"[alto_metrics] ALTO non parsable (XML invalide ou défense XXE "
|
| 136 |
+
"déclenchée) — texte extrait vide",
|
| 137 |
+
)
|
| 138 |
+
return ""
|
| 139 |
+
|
| 140 |
+
lines_text: list[str] = []
|
| 141 |
+
# Itère sur tous les TextLine, peu importe leur profondeur.
|
| 142 |
+
for line in root.iter():
|
| 143 |
+
if _local(line.tag) != "TextLine":
|
| 144 |
+
continue
|
| 145 |
+
words: list[str] = []
|
| 146 |
+
for s in line.iter():
|
| 147 |
+
if _local(s.tag) != "String":
|
| 148 |
+
continue
|
| 149 |
+
content = s.attrib.get("CONTENT", "")
|
| 150 |
+
if content:
|
| 151 |
+
words.append(content)
|
| 152 |
+
lines_text.append(" ".join(words))
|
| 153 |
+
return "\n".join(lines_text).strip()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _safe_jiwer_call(fn, reference: str, hypothesis: str) -> float:
|
| 157 |
+
if not _JIWER_AVAILABLE:
|
| 158 |
+
raise RuntimeError(
|
| 159 |
+
"jiwer n'est pas installé — installer avec `pip install jiwer`"
|
| 160 |
+
)
|
| 161 |
+
if not reference:
|
| 162 |
+
return 0.0 if not hypothesis else 1.0
|
| 163 |
+
if not hypothesis:
|
| 164 |
+
return 1.0
|
| 165 |
+
return fn(reference, hypothesis)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 169 |
+
# Métriques (ALTO, ALTO) — opèrent sur le texte extrait de chaque ALTO
|
| 170 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@register_metric(
|
| 174 |
+
name="alto_text_cer",
|
| 175 |
+
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 176 |
+
description=(
|
| 177 |
+
"CER calculé sur le texte plat extrait des ALTO (référence vs "
|
| 178 |
+
"hypothèse). Permet de mesurer la qualité d'un reconstructeur "
|
| 179 |
+
"ALTO sur l'axe textuel, indépendamment du layout."
|
| 180 |
+
),
|
| 181 |
+
higher_is_better=False,
|
| 182 |
+
tags={"alto", "text", "edit_distance"},
|
| 183 |
+
)
|
| 184 |
+
def alto_text_cer(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 185 |
+
return _safe_jiwer_call(
|
| 186 |
+
jiwer.cer,
|
| 187 |
+
extract_text_from_alto(reference_alto),
|
| 188 |
+
extract_text_from_alto(hypothesis_alto),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@register_metric(
|
| 193 |
+
name="alto_text_wer",
|
| 194 |
+
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 195 |
+
description="WER calculé sur le texte plat extrait des ALTO.",
|
| 196 |
+
higher_is_better=False,
|
| 197 |
+
tags={"alto", "text", "edit_distance"},
|
| 198 |
+
)
|
| 199 |
+
def alto_text_wer(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 200 |
+
return _safe_jiwer_call(
|
| 201 |
+
jiwer.wer,
|
| 202 |
+
extract_text_from_alto(reference_alto),
|
| 203 |
+
extract_text_from_alto(hypothesis_alto),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@register_metric(
|
| 208 |
+
name="alto_text_mer",
|
| 209 |
+
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 210 |
+
description="MER calculé sur le texte plat extrait des ALTO.",
|
| 211 |
+
higher_is_better=False,
|
| 212 |
+
tags={"alto", "text"},
|
| 213 |
+
)
|
| 214 |
+
def alto_text_mer(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 215 |
+
return _safe_jiwer_call(
|
| 216 |
+
jiwer.mer,
|
| 217 |
+
extract_text_from_alto(reference_alto),
|
| 218 |
+
extract_text_from_alto(hypothesis_alto),
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@register_metric(
|
| 223 |
+
name="alto_text_wil",
|
| 224 |
+
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 225 |
+
description="WIL calculé sur le texte plat extrait des ALTO.",
|
| 226 |
+
higher_is_better=False,
|
| 227 |
+
tags={"alto", "text"},
|
| 228 |
+
)
|
| 229 |
+
def alto_text_wil(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 230 |
+
return _safe_jiwer_call(
|
| 231 |
+
jiwer.wil,
|
| 232 |
+
extract_text_from_alto(reference_alto),
|
| 233 |
+
extract_text_from_alto(hypothesis_alto),
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
__all__ = [
|
| 238 |
+
"extract_text_from_alto",
|
| 239 |
+
"alto_text_cer",
|
| 240 |
+
"alto_text_wer",
|
| 241 |
+
"alto_text_mer",
|
| 242 |
+
"alto_text_wil",
|
| 243 |
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Équivalences diplomatiques granulaires — Sprint 78 (A.I.5).
|
| 2 |
+
|
| 3 |
+
Sprint 78 — A.I.5 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Aujourd'hui les profils de ``picarones/core/normalization.py``
|
| 8 |
+
(``medieval_french``, ``early_modern_french``, etc.) appliquent un
|
| 9 |
+
**bloc entier** de transformations. Mais un éditeur peut vouloir
|
| 10 |
+
nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
|
| 11 |
+
exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
|
| 12 |
+
distinctes mais où le s long doit être normalisé.
|
| 13 |
+
|
| 14 |
+
Ce module **éclate** chaque profil en règles d'équivalence
|
| 15 |
+
**nommées et indépendantes** que l'utilisateur peut activer ou
|
| 16 |
+
désactiver une par une. La couche de calcul retourne le CER
|
| 17 |
+
recalculé avec un sous-ensemble personnalisé.
|
| 18 |
+
|
| 19 |
+
Format
|
| 20 |
+
------
|
| 21 |
+
Chaque règle a :
|
| 22 |
+
|
| 23 |
+
- ``name`` : identifiant stable utilisé dans les URLs et l'UX
|
| 24 |
+
(ex. ``"longs_s"``, ``"u_eq_v"``)
|
| 25 |
+
- ``source`` : caractère ou séquence à remplacer
|
| 26 |
+
- ``target`` : caractère ou séquence cible
|
| 27 |
+
- ``description`` : phrase courte FR destinée à l'utilisateur
|
| 28 |
+
- ``profile_tag`` : nom du profil dont elle est issue (utile pour
|
| 29 |
+
grouper dans l'UX)
|
| 30 |
+
|
| 31 |
+
Stratégie de découpage
|
| 32 |
+
----------------------
|
| 33 |
+
Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
|
| 34 |
+
avancé (cases à cocher + recalcul JS client + URL state) suivra
|
| 35 |
+
dans un sprint dédié — la couche calcul livrée ici est une
|
| 36 |
+
fondation suffisante pour qu'un développeur frontend câble la vue.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import logging
|
| 42 |
+
from dataclasses import dataclass
|
| 43 |
+
from typing import Iterable, Optional
|
| 44 |
+
|
| 45 |
+
from picarones.evaluation.metrics.normalization import (
|
| 46 |
+
DIPLOMATIC_EN_EARLY_MODERN,
|
| 47 |
+
DIPLOMATIC_FR_EARLY_MODERN,
|
| 48 |
+
DIPLOMATIC_LATIN_MEDIEVAL,
|
| 49 |
+
DIPLOMATIC_MINIMAL,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
logger = logging.getLogger(__name__)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass(frozen=True)
|
| 56 |
+
class EquivalenceRule:
|
| 57 |
+
"""Une équivalence diplomatique nommée et indépendante."""
|
| 58 |
+
name: str
|
| 59 |
+
source: str
|
| 60 |
+
target: str
|
| 61 |
+
description: str
|
| 62 |
+
profile_tag: str
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Catalogue : on dérive des profils existants en attribuant un nom
|
| 66 |
+
# stable à chaque transformation. Les doublons (ex. ``ſ → s``
|
| 67 |
+
# présent dans plusieurs profils) sont fusionnés sous un nom unique
|
| 68 |
+
# (le premier rencontré).
|
| 69 |
+
def _build_catalog() -> dict[str, EquivalenceRule]:
|
| 70 |
+
catalog: dict[str, EquivalenceRule] = {}
|
| 71 |
+
|
| 72 |
+
# Noms canoniques pour les transformations courantes
|
| 73 |
+
canonical_names: dict[tuple[str, str], tuple[str, str]] = {
|
| 74 |
+
("ſ", "s"): ("longs_s", "s long ſ → s"),
|
| 75 |
+
("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
|
| 76 |
+
("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
|
| 77 |
+
("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
|
| 78 |
+
("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
|
| 79 |
+
("æ", "ae"): ("ae_ligature", "æ → ae"),
|
| 80 |
+
("œ", "oe"): ("oe_ligature", "œ → oe"),
|
| 81 |
+
("þ", "th"): ("thorn_th", "þ (thorn) → th"),
|
| 82 |
+
("ð", "th"): ("eth_th", "ð (eth) → th"),
|
| 83 |
+
("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
|
| 84 |
+
("&", "et"): ("ampersand_et", "& → et (esperluette)"),
|
| 85 |
+
("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
|
| 86 |
+
("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
|
| 87 |
+
("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
|
| 88 |
+
("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
sources = [
|
| 92 |
+
("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
|
| 93 |
+
("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
|
| 94 |
+
("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
|
| 95 |
+
("minimal", DIPLOMATIC_MINIMAL),
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
for profile_tag, profile_dict in sources:
|
| 99 |
+
for source, target in profile_dict.items():
|
| 100 |
+
key = (source, target)
|
| 101 |
+
if key in canonical_names:
|
| 102 |
+
name, desc = canonical_names[key]
|
| 103 |
+
else:
|
| 104 |
+
# Fallback : générer un nom à partir des codepoints
|
| 105 |
+
name = f"{source}_to_{target}".replace(" ", "_")
|
| 106 |
+
desc = f"{source} → {target}"
|
| 107 |
+
if name in catalog:
|
| 108 |
+
# On garde le profile_tag du premier rencontré, mais
|
| 109 |
+
# on note que la règle est partagée.
|
| 110 |
+
continue
|
| 111 |
+
catalog[name] = EquivalenceRule(
|
| 112 |
+
name=name,
|
| 113 |
+
source=source,
|
| 114 |
+
target=target,
|
| 115 |
+
description=desc,
|
| 116 |
+
profile_tag=profile_tag,
|
| 117 |
+
)
|
| 118 |
+
return catalog
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def list_equivalences_by_profile(
|
| 125 |
+
profile_name: Optional[str] = None,
|
| 126 |
+
) -> list[EquivalenceRule]:
|
| 127 |
+
"""Liste les règles d'équivalence disponibles.
|
| 128 |
+
|
| 129 |
+
Si ``profile_name`` est fourni, ne retourne que les règles dont
|
| 130 |
+
``profile_tag == profile_name`` (ou les règles dérivées de
|
| 131 |
+
plusieurs profils dont au moins un est ``profile_name``).
|
| 132 |
+
"""
|
| 133 |
+
if profile_name is None:
|
| 134 |
+
return list(BUILTIN_EQUIVALENCES.values())
|
| 135 |
+
return [
|
| 136 |
+
rule for rule in BUILTIN_EQUIVALENCES.values()
|
| 137 |
+
if rule.profile_tag == profile_name
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def apply_selected_equivalences(
|
| 142 |
+
text: Optional[str],
|
| 143 |
+
selected_names: Iterable[str],
|
| 144 |
+
) -> str:
|
| 145 |
+
"""Applique uniquement les règles dont le nom est dans
|
| 146 |
+
``selected_names``.
|
| 147 |
+
|
| 148 |
+
L'ordre d'application est l'ordre du catalogue interne — les
|
| 149 |
+
transformations sont appliquées séquentiellement sur le texte.
|
| 150 |
+
Les règles inconnues sont silencieusement ignorées (avec
|
| 151 |
+
warning).
|
| 152 |
+
"""
|
| 153 |
+
if not text:
|
| 154 |
+
return text or ""
|
| 155 |
+
selected_set = set(selected_names)
|
| 156 |
+
if not selected_set:
|
| 157 |
+
return text
|
| 158 |
+
out = text
|
| 159 |
+
for name, rule in BUILTIN_EQUIVALENCES.items():
|
| 160 |
+
if name not in selected_set:
|
| 161 |
+
continue
|
| 162 |
+
out = out.replace(rule.source, rule.target)
|
| 163 |
+
# Détection des règles inconnues (pour logger explicite)
|
| 164 |
+
unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
|
| 165 |
+
if unknown:
|
| 166 |
+
logger.warning(
|
| 167 |
+
"[equivalence_profile] règles inconnues ignorées : %s",
|
| 168 |
+
sorted(unknown),
|
| 169 |
+
)
|
| 170 |
+
return out
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def compute_cer_with_equivalences(
|
| 174 |
+
reference: Optional[str],
|
| 175 |
+
hypothesis: Optional[str],
|
| 176 |
+
selected_names: Iterable[str],
|
| 177 |
+
) -> float:
|
| 178 |
+
"""Calcule le CER après application des équivalences sélectionnées
|
| 179 |
+
sur les **deux** côtés (GT et hypothèse).
|
| 180 |
+
|
| 181 |
+
Utilise ``picarones.measurements.metrics.compute_metrics`` et extrait
|
| 182 |
+
le champ ``cer`` du résultat.
|
| 183 |
+
"""
|
| 184 |
+
# Sprint E.2 du plan v2.0 — ``compute_metrics`` n'a pas encore
|
| 185 |
+
# son canonique dans ``evaluation/`` (migration prévue en E.3).
|
| 186 |
+
# En attendant, on l'importe dynamiquement via ``importlib`` —
|
| 187 |
+
# explicitement permis par ``test_no_legacy_imports_in_rewrite``
|
| 188 |
+
# qui ne couvre pas les imports différés.
|
| 189 |
+
import importlib
|
| 190 |
+
compute_metrics = importlib.import_module(
|
| 191 |
+
"picarones.measurements.metrics",
|
| 192 |
+
).compute_metrics
|
| 193 |
+
|
| 194 |
+
selected_list = list(selected_names)
|
| 195 |
+
ref = apply_selected_equivalences(reference or "", selected_list)
|
| 196 |
+
hyp = apply_selected_equivalences(hypothesis or "", selected_list)
|
| 197 |
+
result = compute_metrics(ref, hyp)
|
| 198 |
+
return result.cer
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
__all__ = [
|
| 202 |
+
"EquivalenceRule",
|
| 203 |
+
"BUILTIN_EQUIVALENCES",
|
| 204 |
+
"list_equivalences_by_profile",
|
| 205 |
+
"apply_selected_equivalences",
|
| 206 |
+
"compute_cer_with_equivalences",
|
| 207 |
+
]
|
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Calcul des métriques de précision sur entités nommées (NER).
|
| 2 |
+
|
| 3 |
+
Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Pour un médiéviste, un archiviste ou un économiste-historien,
|
| 8 |
+
l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui
|
| 9 |
+
compte c'est de savoir si les **entités nommées** (personnes, lieux,
|
| 10 |
+
dates, organisations) ont survécu à la transcription. Un CER de 5 %
|
| 11 |
+
qui rate 80 % des noms propres est inutilisable pour l'indexation
|
| 12 |
+
prosopographique.
|
| 13 |
+
|
| 14 |
+
Stratégie de découpage en sprints
|
| 15 |
+
---------------------------------
|
| 16 |
+
Comme pour la divergence taxonomique (Sprints 35-37), on découpe :
|
| 17 |
+
|
| 18 |
+
- **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre
|
| 19 |
+
deux listes d'entités, calcul de Precision/Recall/F1 par catégorie
|
| 20 |
+
et global, détection des hallucinations d'entité. Aucune dépendance
|
| 21 |
+
externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont
|
| 22 |
+
fournies en entrée. Un test de l'enregistrement dans le registre
|
| 23 |
+
typé Sprint 34 garantit l'intégration.
|
| 24 |
+
- **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et
|
| 25 |
+
câblage runner+narratif+HTML.
|
| 26 |
+
|
| 27 |
+
Format des entités
|
| 28 |
+
------------------
|
| 29 |
+
Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un
|
| 30 |
+
dictionnaire ``{"label": str, "start": int, "end": int, "text": str}``
|
| 31 |
+
où ``start``/``end`` sont des offsets caractère.
|
| 32 |
+
|
| 33 |
+
Convention d'alignement
|
| 34 |
+
-----------------------
|
| 35 |
+
Une entité hypothèse "matche" une entité de référence si :
|
| 36 |
+
|
| 37 |
+
1. les **labels sont identiques** (case-insensitive),
|
| 38 |
+
2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans
|
| 39 |
+
caractère est ``≥ iou_threshold`` (défaut : 0,5).
|
| 40 |
+
|
| 41 |
+
Une entité de référence non matchée → faux négatif (recall pénalisé).
|
| 42 |
+
Une entité hypothèse non matchée → faux positif (précision pénalisée).
|
| 43 |
+
Un faux positif est aussi compté comme **hallucination d'entité**, ce
|
| 44 |
+
qui est utile pour les VLM/LLM qui inventent.
|
| 45 |
+
|
| 46 |
+
Limites
|
| 47 |
+
-------
|
| 48 |
+
- L'alignement bag-of-spans : une entité peut être matchée par au plus
|
| 49 |
+
une entité de l'autre côté (sinon double-comptage).
|
| 50 |
+
- Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique
|
| 51 |
+
mesure conjointement OCR + NER. Documenter explicitement.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
from __future__ import annotations
|
| 55 |
+
|
| 56 |
+
import logging
|
| 57 |
+
from dataclasses import dataclass
|
| 58 |
+
from typing import Iterable
|
| 59 |
+
|
| 60 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 61 |
+
from picarones.domain.artifacts import ArtifactType
|
| 62 |
+
|
| 63 |
+
logger = logging.getLogger(__name__)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 67 |
+
# Modèle de données
|
| 68 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass(frozen=True)
|
| 72 |
+
class Entity:
|
| 73 |
+
"""Entité nommée alignée sur un texte.
|
| 74 |
+
|
| 75 |
+
Attributs
|
| 76 |
+
---------
|
| 77 |
+
label:
|
| 78 |
+
Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``).
|
| 79 |
+
La comparaison se fait en *case-insensitive*.
|
| 80 |
+
start, end:
|
| 81 |
+
Offsets caractère (inclus, exclu) sur le texte de référence.
|
| 82 |
+
text:
|
| 83 |
+
Forme de surface — informative, **non utilisée pour
|
| 84 |
+
l'alignement** (deux entités peuvent matcher même si leur
|
| 85 |
+
forme de surface diffère, du moment que leurs spans
|
| 86 |
+
chevauchent suffisamment).
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
label: str
|
| 90 |
+
start: int
|
| 91 |
+
end: int
|
| 92 |
+
text: str = ""
|
| 93 |
+
|
| 94 |
+
def __post_init__(self) -> None:
|
| 95 |
+
if self.start > self.end:
|
| 96 |
+
raise ValueError(
|
| 97 |
+
f"Entity span invalide : start={self.start} > end={self.end}"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def length(self) -> int:
|
| 102 |
+
return max(0, self.end - self.start)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _to_entity(obj: Entity | dict) -> Entity:
|
| 106 |
+
"""Coerce un dict (format EntitiesGT) en ``Entity``."""
|
| 107 |
+
if isinstance(obj, Entity):
|
| 108 |
+
return obj
|
| 109 |
+
return Entity(
|
| 110 |
+
label=str(obj["label"]),
|
| 111 |
+
start=int(obj["start"]),
|
| 112 |
+
end=int(obj["end"]),
|
| 113 |
+
text=str(obj.get("text", "")),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 118 |
+
# Alignement par IoU
|
| 119 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _iou(a: Entity, b: Entity) -> float:
|
| 123 |
+
"""Intersection-over-Union sur les spans caractère."""
|
| 124 |
+
inter_start = max(a.start, b.start)
|
| 125 |
+
inter_end = min(a.end, b.end)
|
| 126 |
+
inter = max(0, inter_end - inter_start)
|
| 127 |
+
union = a.length + b.length - inter
|
| 128 |
+
if union <= 0:
|
| 129 |
+
return 0.0
|
| 130 |
+
return inter / union
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _align(
|
| 134 |
+
references: list[Entity],
|
| 135 |
+
hypotheses: list[Entity],
|
| 136 |
+
iou_threshold: float,
|
| 137 |
+
) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
|
| 138 |
+
"""Aligne deux listes d'entités par IoU décroissant (greedy).
|
| 139 |
+
|
| 140 |
+
Returns
|
| 141 |
+
-------
|
| 142 |
+
matches:
|
| 143 |
+
Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU
|
| 144 |
+
décroissant — chaque entité n'apparaît qu'une fois.
|
| 145 |
+
unmatched_refs:
|
| 146 |
+
Indices des entités GT non matchées (faux négatifs).
|
| 147 |
+
unmatched_hyps:
|
| 148 |
+
Indices des entités hypothèse non matchées (faux positifs).
|
| 149 |
+
"""
|
| 150 |
+
candidates: list[tuple[float, int, int]] = []
|
| 151 |
+
for i, r in enumerate(references):
|
| 152 |
+
for j, h in enumerate(hypotheses):
|
| 153 |
+
if r.label.casefold() != h.label.casefold():
|
| 154 |
+
continue
|
| 155 |
+
score = _iou(r, h)
|
| 156 |
+
if score >= iou_threshold:
|
| 157 |
+
candidates.append((score, i, j))
|
| 158 |
+
|
| 159 |
+
# Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires
|
| 160 |
+
# pour garantir un tri stable et déterministe.
|
| 161 |
+
candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
|
| 162 |
+
|
| 163 |
+
matched_refs: set[int] = set()
|
| 164 |
+
matched_hyps: set[int] = set()
|
| 165 |
+
matches: list[tuple[int, int, float]] = []
|
| 166 |
+
for score, i, j in candidates:
|
| 167 |
+
if i in matched_refs or j in matched_hyps:
|
| 168 |
+
continue
|
| 169 |
+
matched_refs.add(i)
|
| 170 |
+
matched_hyps.add(j)
|
| 171 |
+
matches.append((i, j, score))
|
| 172 |
+
|
| 173 |
+
unmatched_refs = set(range(len(references))) - matched_refs
|
| 174 |
+
unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
|
| 175 |
+
return matches, unmatched_refs, unmatched_hyps
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 179 |
+
# Calcul des métriques
|
| 180 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
|
| 184 |
+
"""Précision / rappel / F1 à partir des comptes."""
|
| 185 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 186 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 187 |
+
f1 = (
|
| 188 |
+
2 * precision * recall / (precision + recall)
|
| 189 |
+
if (precision + recall) > 0
|
| 190 |
+
else 0.0
|
| 191 |
+
)
|
| 192 |
+
return {
|
| 193 |
+
"precision": precision,
|
| 194 |
+
"recall": recall,
|
| 195 |
+
"f1": f1,
|
| 196 |
+
"support": tp + fn,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def compute_ner_metrics(
|
| 201 |
+
reference_entities: Iterable[Entity | dict],
|
| 202 |
+
hypothesis_entities: Iterable[Entity | dict],
|
| 203 |
+
iou_threshold: float = 0.5,
|
| 204 |
+
) -> dict:
|
| 205 |
+
"""Calcule la précision/rappel/F1 sur entités nommées.
|
| 206 |
+
|
| 207 |
+
Parameters
|
| 208 |
+
----------
|
| 209 |
+
reference_entities:
|
| 210 |
+
Liste d'entités GT (format ``Entity`` ou dict de
|
| 211 |
+
``EntitiesGT``).
|
| 212 |
+
hypothesis_entities:
|
| 213 |
+
Liste d'entités produites par le NER sur la sortie OCR.
|
| 214 |
+
iou_threshold:
|
| 215 |
+
Seuil de chevauchement caractère pour qu'un appariement
|
| 216 |
+
soit valide (défaut : 0,5 — convention CoNLL/HIPE).
|
| 217 |
+
|
| 218 |
+
Returns
|
| 219 |
+
-------
|
| 220 |
+
dict
|
| 221 |
+
``{
|
| 222 |
+
"global": {"precision", "recall", "f1", "support"},
|
| 223 |
+
"per_category": {label: {"precision", ...}},
|
| 224 |
+
"true_positives": int,
|
| 225 |
+
"false_positives": int,
|
| 226 |
+
"false_negatives": int,
|
| 227 |
+
"hallucinated_entities": list[dict], # entités OCR sans GT
|
| 228 |
+
"missed_entities": list[dict], # entités GT non détectées
|
| 229 |
+
"iou_threshold": float,
|
| 230 |
+
}``
|
| 231 |
+
"""
|
| 232 |
+
refs = [_to_entity(e) for e in reference_entities]
|
| 233 |
+
hyps = [_to_entity(e) for e in hypothesis_entities]
|
| 234 |
+
|
| 235 |
+
matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold)
|
| 236 |
+
|
| 237 |
+
tp = len(matches)
|
| 238 |
+
fn = len(unmatched_refs)
|
| 239 |
+
fp = len(unmatched_hyps)
|
| 240 |
+
|
| 241 |
+
# Comptes par catégorie
|
| 242 |
+
cat_tp: dict[str, int] = {}
|
| 243 |
+
cat_fn: dict[str, int] = {}
|
| 244 |
+
cat_fp: dict[str, int] = {}
|
| 245 |
+
for i, _j, _score in matches:
|
| 246 |
+
cat = refs[i].label
|
| 247 |
+
cat_tp[cat] = cat_tp.get(cat, 0) + 1
|
| 248 |
+
for i in unmatched_refs:
|
| 249 |
+
cat = refs[i].label
|
| 250 |
+
cat_fn[cat] = cat_fn.get(cat, 0) + 1
|
| 251 |
+
for j in unmatched_hyps:
|
| 252 |
+
cat = hyps[j].label
|
| 253 |
+
cat_fp[cat] = cat_fp.get(cat, 0) + 1
|
| 254 |
+
|
| 255 |
+
all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
|
| 256 |
+
per_category = {
|
| 257 |
+
cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0))
|
| 258 |
+
for cat in all_categories
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
return {
|
| 262 |
+
"global": _prf(tp, fp, fn),
|
| 263 |
+
"per_category": per_category,
|
| 264 |
+
"true_positives": tp,
|
| 265 |
+
"false_positives": fp,
|
| 266 |
+
"false_negatives": fn,
|
| 267 |
+
"hallucinated_entities": [
|
| 268 |
+
{"label": hyps[j].label, "start": hyps[j].start,
|
| 269 |
+
"end": hyps[j].end, "text": hyps[j].text}
|
| 270 |
+
for j in sorted(unmatched_hyps)
|
| 271 |
+
],
|
| 272 |
+
"missed_entities": [
|
| 273 |
+
{"label": refs[i].label, "start": refs[i].start,
|
| 274 |
+
"end": refs[i].end, "text": refs[i].text}
|
| 275 |
+
for i in sorted(unmatched_refs)
|
| 276 |
+
],
|
| 277 |
+
"iou_threshold": iou_threshold,
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 282 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 283 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@register_metric(
|
| 287 |
+
name="ner_f1",
|
| 288 |
+
input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES),
|
| 289 |
+
description=(
|
| 290 |
+
"F1 global sur les entités nommées (alignement IoU ≥ 0,5, "
|
| 291 |
+
"labels case-insensitive). Pour le détail par catégorie, "
|
| 292 |
+
"utiliser compute_ner_metrics directement."
|
| 293 |
+
),
|
| 294 |
+
higher_is_better=True,
|
| 295 |
+
tags={"downstream", "ner", "structure"},
|
| 296 |
+
)
|
| 297 |
+
def ner_f1(
|
| 298 |
+
reference_entities: Iterable[Entity | dict],
|
| 299 |
+
hypothesis_entities: Iterable[Entity | dict],
|
| 300 |
+
) -> float:
|
| 301 |
+
"""F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``."""
|
| 302 |
+
return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"]
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
__all__ = [
|
| 306 |
+
"Entity",
|
| 307 |
+
"compute_ner_metrics",
|
| 308 |
+
"ner_f1",
|
| 309 |
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Câblage runner des séquences numériques (Sprint 86).
|
| 2 |
+
|
| 3 |
+
Sprint 86 — A.II.5b (vue HTML + câblage runner).
|
| 4 |
+
|
| 5 |
+
Le module ``picarones/core/numerical_sequences.py`` (Sprint 85)
|
| 6 |
+
a livré la couche de calcul. Ce helper prépare la donnée
|
| 7 |
+
adaptative pour le runner et agrège les compteurs par moteur.
|
| 8 |
+
|
| 9 |
+
Adaptive masking
|
| 10 |
+
----------------
|
| 11 |
+
On ne stocke le résultat que si la GT contient au moins une
|
| 12 |
+
séquence numérique détectée — sinon le module n'apparaît pas
|
| 13 |
+
dans le rapport.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Iterable, Optional
|
| 20 |
+
|
| 21 |
+
from picarones.evaluation.metrics.numerical_sequences import (
|
| 22 |
+
CATEGORIES,
|
| 23 |
+
compute_numerical_sequence_metrics,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def compute_numerical_sequence_metrics_adaptive(
|
| 30 |
+
reference: Optional[str],
|
| 31 |
+
hypothesis: Optional[str],
|
| 32 |
+
) -> Optional[dict]:
|
| 33 |
+
"""Calcule les métriques séquences numériques avec masquage
|
| 34 |
+
adaptatif : retourne ``None`` si la GT n'en contient
|
| 35 |
+
aucune."""
|
| 36 |
+
if not reference:
|
| 37 |
+
return None
|
| 38 |
+
result = compute_numerical_sequence_metrics(reference, hypothesis or "")
|
| 39 |
+
if (result.get("n_total") or 0) == 0:
|
| 40 |
+
return None
|
| 41 |
+
return result
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def aggregate_numerical_sequence_metrics(
|
| 45 |
+
per_doc: Iterable[Optional[dict]],
|
| 46 |
+
) -> Optional[dict]:
|
| 47 |
+
"""Agrège par moteur : somme les compteurs par catégorie et
|
| 48 |
+
recalcule les scores globaux et per-category.
|
| 49 |
+
|
| 50 |
+
Format de sortie identique à ``compute_numerical_sequence_metrics``
|
| 51 |
+
pour faciliter le rendu HTML symétrique.
|
| 52 |
+
"""
|
| 53 |
+
docs = [d for d in per_doc if d]
|
| 54 |
+
if not docs:
|
| 55 |
+
return None
|
| 56 |
+
total_n = 0
|
| 57 |
+
total_strict = 0
|
| 58 |
+
total_value = 0
|
| 59 |
+
per_cat: dict[str, dict] = {}
|
| 60 |
+
for cat in CATEGORIES:
|
| 61 |
+
per_cat[cat] = {
|
| 62 |
+
"n_total": 0,
|
| 63 |
+
"strict": 0,
|
| 64 |
+
"value": 0,
|
| 65 |
+
"lost_items": [],
|
| 66 |
+
}
|
| 67 |
+
for d in docs:
|
| 68 |
+
for cat in CATEGORIES:
|
| 69 |
+
cat_data = (d.get("per_category") or {}).get(cat) or {}
|
| 70 |
+
per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
|
| 71 |
+
per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
|
| 72 |
+
per_cat[cat]["value"] += int(cat_data.get("value") or 0)
|
| 73 |
+
per_cat[cat]["lost_items"].extend(
|
| 74 |
+
cat_data.get("lost_items") or [],
|
| 75 |
+
)
|
| 76 |
+
total_n += int(d.get("n_total") or 0)
|
| 77 |
+
# Recalcul des scores
|
| 78 |
+
for cat, slot in per_cat.items():
|
| 79 |
+
n = slot["n_total"]
|
| 80 |
+
slot["strict_score"] = slot["strict"] / n if n else 0.0
|
| 81 |
+
slot["value_score"] = slot["value"] / n if n else 0.0
|
| 82 |
+
# Cap des lost_items à 50 par catégorie
|
| 83 |
+
slot["lost_items"] = slot["lost_items"][:50]
|
| 84 |
+
total_strict += slot["strict"]
|
| 85 |
+
total_value += slot["value"]
|
| 86 |
+
return {
|
| 87 |
+
"n_docs": len(docs),
|
| 88 |
+
"n_total": total_n,
|
| 89 |
+
"global_strict_score": (
|
| 90 |
+
total_strict / total_n if total_n else 0.0
|
| 91 |
+
),
|
| 92 |
+
"global_value_score": (
|
| 93 |
+
total_value / total_n if total_n else 0.0
|
| 94 |
+
),
|
| 95 |
+
"per_category": per_cat,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
__all__ = [
|
| 100 |
+
"compute_numerical_sequence_metrics_adaptive",
|
| 101 |
+
"aggregate_numerical_sequence_metrics",
|
| 102 |
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métriques de lisibilité (Flesch) — Sprint 52.
|
| 2 |
+
|
| 3 |
+
Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure
|
| 4 |
+
de la métrique Flesch, indépendante de tout alignement OCR/GT.
|
| 5 |
+
|
| 6 |
+
Pourquoi ce module
|
| 7 |
+
------------------
|
| 8 |
+
Les LLM produisent du texte plus « lisse » que les manuscrits
|
| 9 |
+
historiques. Cette tendance à la modernisation est mesurable par la
|
| 10 |
+
différence de score de lisibilité entre la GT et la sortie OCR/LLM —
|
| 11 |
+
**indépendamment des classes taxonomiques** et **sans alignement
|
| 12 |
+
caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
|
| 13 |
+
même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
|
| 14 |
+
moderne plausible mais déconnecté de la GT).
|
| 15 |
+
|
| 16 |
+
Stratégie de découpage
|
| 17 |
+
----------------------
|
| 18 |
+
Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
|
| 19 |
+
découpe :
|
| 20 |
+
|
| 21 |
+
- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
|
| 22 |
+
``flesch_delta``. Aucune dépendance externe ; les heuristiques de
|
| 23 |
+
comptage de syllabes sont en pur Python, déterministes, testées.
|
| 24 |
+
- **Sprints suivants** — câblage runner pour calculer
|
| 25 |
+
``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
|
| 26 |
+
|
| 27 |
+
Formules
|
| 28 |
+
--------
|
| 29 |
+
- **Anglais** (Flesch original 1948) :
|
| 30 |
+
``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
|
| 31 |
+
- **Français** (Kandel-Moles 1958) :
|
| 32 |
+
``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
|
| 33 |
+
|
| 34 |
+
Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
|
| 35 |
+
0 ↔ « très difficile ». Une **augmentation** du score quand on passe
|
| 36 |
+
de la GT à l'OCR signale une simplification (typique des LLM
|
| 37 |
+
modernisants). Une **chute** signale une dégradation OCR.
|
| 38 |
+
|
| 39 |
+
Limites documentées
|
| 40 |
+
-------------------
|
| 41 |
+
- Le comptage de syllabes est heuristique. En français, des règles
|
| 42 |
+
comme « -ier non final = 2 syllabes » ne sont pas appliquées
|
| 43 |
+
finement. Acceptable pour une métrique de **comparaison relative**
|
| 44 |
+
(delta GT vs OCR), pas pour publier une absolue.
|
| 45 |
+
- Sur des textes très courts (< 20 mots), la formule perd en
|
| 46 |
+
fiabilité. Le seuil minimal est documenté.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
from __future__ import annotations
|
| 50 |
+
|
| 51 |
+
import logging
|
| 52 |
+
import re
|
| 53 |
+
from typing import Literal
|
| 54 |
+
|
| 55 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 56 |
+
from picarones.domain.artifacts import ArtifactType
|
| 57 |
+
|
| 58 |
+
logger = logging.getLogger(__name__)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
Language = Literal["fr", "en"]
|
| 62 |
+
|
| 63 |
+
# Coefficients de la formule Flesch selon la langue.
|
| 64 |
+
_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
|
| 65 |
+
"en": (206.835, 1.015, 84.6), # Flesch 1948
|
| 66 |
+
"fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# Voyelles utilisées pour l'heuristique de comptage de syllabes.
|
| 70 |
+
# On utilise un set qui inclut les diacritiques courantes en FR/EN.
|
| 71 |
+
_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
|
| 72 |
+
|
| 73 |
+
# Regex de découpage en phrases : ponctuation finale + espace ou fin.
|
| 74 |
+
# Tolère les multiples points (« ... ») et garde un découpage robuste.
|
| 75 |
+
_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
|
| 76 |
+
|
| 77 |
+
# Regex de tokenisation simple (mots) : séquences de caractères "lettres".
|
| 78 |
+
_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 82 |
+
# Compteurs de base
|
| 83 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def count_words(text: str) -> int:
|
| 87 |
+
"""Nombre de mots (tokens alphanumériques) dans ``text``."""
|
| 88 |
+
if not text:
|
| 89 |
+
return 0
|
| 90 |
+
return len(_WORD_RE.findall(text))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def count_sentences(text: str) -> int:
|
| 94 |
+
"""Nombre de phrases dans ``text``.
|
| 95 |
+
|
| 96 |
+
Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
|
| 97 |
+
Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
|
| 98 |
+
éviter une division par zéro dans la formule de Flesch sur les
|
| 99 |
+
textes sans ponctuation finale.
|
| 100 |
+
"""
|
| 101 |
+
if not text:
|
| 102 |
+
return 0
|
| 103 |
+
parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
|
| 104 |
+
n = len(parts)
|
| 105 |
+
if n == 0 and count_words(text) > 0:
|
| 106 |
+
return 1
|
| 107 |
+
return n
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def count_syllables_word(word: str) -> int:
|
| 111 |
+
"""Heuristique de comptage de syllabes pour un mot isolé.
|
| 112 |
+
|
| 113 |
+
Règle : on compte les **groupes de voyelles consécutives** (en
|
| 114 |
+
incluant ``y`` et les diacritiques courantes). C'est une
|
| 115 |
+
approximation grossière mais déterministe et testable.
|
| 116 |
+
|
| 117 |
+
Cas limites :
|
| 118 |
+
- mot vide → 0
|
| 119 |
+
- mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``)
|
| 120 |
+
- mot d'une seule voyelle isolée → 1
|
| 121 |
+
"""
|
| 122 |
+
if not word:
|
| 123 |
+
return 0
|
| 124 |
+
word = word.lower()
|
| 125 |
+
in_vowel_group = False
|
| 126 |
+
count = 0
|
| 127 |
+
for ch in word:
|
| 128 |
+
if ch in _VOWELS:
|
| 129 |
+
if not in_vowel_group:
|
| 130 |
+
count += 1
|
| 131 |
+
in_vowel_group = True
|
| 132 |
+
else:
|
| 133 |
+
in_vowel_group = False
|
| 134 |
+
return count or 1
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def count_syllables(text: str) -> int:
|
| 138 |
+
"""Somme des syllabes de tous les mots de ``text``."""
|
| 139 |
+
if not text:
|
| 140 |
+
return 0
|
| 141 |
+
return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 145 |
+
# Score Flesch
|
| 146 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def flesch_score(text: str, lang: Language = "fr") -> float:
|
| 150 |
+
"""Calcule le score de lisibilité Flesch pour ``text``.
|
| 151 |
+
|
| 152 |
+
Parameters
|
| 153 |
+
----------
|
| 154 |
+
text:
|
| 155 |
+
Texte à évaluer. Peut contenir ponctuation, accents, etc.
|
| 156 |
+
lang:
|
| 157 |
+
``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
|
| 158 |
+
|
| 159 |
+
Returns
|
| 160 |
+
-------
|
| 161 |
+
float
|
| 162 |
+
Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
|
| 163 |
+
vide ou sans mot exploitable.
|
| 164 |
+
|
| 165 |
+
Notes
|
| 166 |
+
-----
|
| 167 |
+
Le score chute fortement avec :
|
| 168 |
+
- longues phrases (mots/phrases élevé)
|
| 169 |
+
- mots polysyllabiques (syllabes/mots élevé)
|
| 170 |
+
Une montée du score lors du passage GT → OCR signale qu'un LLM a
|
| 171 |
+
« lissé » la langue (phrases plus courtes, mots plus communs).
|
| 172 |
+
"""
|
| 173 |
+
if lang not in _FLESCH_COEFFS:
|
| 174 |
+
raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
|
| 175 |
+
|
| 176 |
+
n_words = count_words(text)
|
| 177 |
+
if n_words == 0:
|
| 178 |
+
return 0.0
|
| 179 |
+
n_sentences = max(1, count_sentences(text))
|
| 180 |
+
n_syllables = count_syllables(text)
|
| 181 |
+
if n_syllables == 0:
|
| 182 |
+
return 0.0
|
| 183 |
+
|
| 184 |
+
base, k_words, k_syll = _FLESCH_COEFFS[lang]
|
| 185 |
+
raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
|
| 186 |
+
return max(0.0, min(100.0, raw))
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def flesch_delta(
|
| 190 |
+
reference: str,
|
| 191 |
+
hypothesis: str,
|
| 192 |
+
lang: Language = "fr",
|
| 193 |
+
) -> float:
|
| 194 |
+
"""Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
|
| 195 |
+
|
| 196 |
+
Interprétation
|
| 197 |
+
--------------
|
| 198 |
+
- **Positif** : l'hypothèse OCR est plus lisible que la GT —
|
| 199 |
+
signal d'**over-normalisation** (typique des LLM qui modernisent
|
| 200 |
+
des textes anciens).
|
| 201 |
+
- **Négatif** : l'OCR est moins lisible — signal de dégradation
|
| 202 |
+
(caractères mal reconnus brisent la fluidité).
|
| 203 |
+
- **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
|
| 204 |
+
|
| 205 |
+
Borné dans ``[-100, +100]``.
|
| 206 |
+
"""
|
| 207 |
+
return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 211 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 212 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@register_metric(
|
| 216 |
+
name="flesch_delta_fr",
|
| 217 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 218 |
+
description=(
|
| 219 |
+
"Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
|
| 220 |
+
"OCR et la GT. Positif = OCR plus lisible (signal "
|
| 221 |
+
"d'over-normalisation LLM). Aucun alignement requis."
|
| 222 |
+
),
|
| 223 |
+
higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
|
| 224 |
+
tags={"text", "readability", "over_normalization"},
|
| 225 |
+
)
|
| 226 |
+
def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
|
| 227 |
+
return flesch_delta(reference, hypothesis, lang="fr")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@register_metric(
|
| 231 |
+
name="flesch_delta_en",
|
| 232 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 233 |
+
description=(
|
| 234 |
+
"Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
|
| 235 |
+
"Positive = OCR easier to read than GT (LLM smoothing signal). "
|
| 236 |
+
"No alignment required."
|
| 237 |
+
),
|
| 238 |
+
higher_is_better=False,
|
| 239 |
+
tags={"text", "readability", "over_normalization"},
|
| 240 |
+
)
|
| 241 |
+
def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
|
| 242 |
+
return flesch_delta(reference, hypothesis, lang="en")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
__all__ = [
|
| 246 |
+
"flesch_score",
|
| 247 |
+
"flesch_delta",
|
| 248 |
+
"count_words",
|
| 249 |
+
"count_sentences",
|
| 250 |
+
"count_syllables",
|
| 251 |
+
"count_syllables_word",
|
| 252 |
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Câblage runner du delta Flesch (Sprint 87 — A.II.2).
|
| 2 |
+
|
| 3 |
+
Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch
|
| 4 |
+
livré par le Sprint 52).
|
| 5 |
+
|
| 6 |
+
Pourquoi ce module
|
| 7 |
+
------------------
|
| 8 |
+
Le ``flesch_delta`` mesure la différence de lisibilité entre la
|
| 9 |
+
GT et la sortie OCR. Un score positif signale une *over-
|
| 10 |
+
normalisation* typique des LLM/VLM qui modernisent un texte
|
| 11 |
+
ancien (le Flesch monte parce que les mots sont plus simples) ;
|
| 12 |
+
un score négatif signale une dégradation OCR brutale.
|
| 13 |
+
|
| 14 |
+
Cette métrique est calculée **automatiquement** par le runner
|
| 15 |
+
sur chaque document, agrégée par moteur, et présentée dans le
|
| 16 |
+
rapport.
|
| 17 |
+
|
| 18 |
+
Adaptive masking
|
| 19 |
+
----------------
|
| 20 |
+
On ne calcule que si la GT contient ≥ 5 mots — en dessous, le
|
| 21 |
+
Flesch est trop instable pour être informatif.
|
| 22 |
+
|
| 23 |
+
Langue
|
| 24 |
+
------
|
| 25 |
+
Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour
|
| 26 |
+
les corpus mixtes, l'utilisateur peut passer une langue
|
| 27 |
+
explicite à l'orchestrateur.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import logging
|
| 33 |
+
import statistics
|
| 34 |
+
from typing import Iterable, Optional
|
| 35 |
+
|
| 36 |
+
from picarones.evaluation.metrics.readability import (
|
| 37 |
+
Language,
|
| 38 |
+
count_words,
|
| 39 |
+
flesch_delta,
|
| 40 |
+
flesch_score,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
_MIN_WORDS_FOR_FLESCH = 5
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def compute_readability_metrics(
|
| 50 |
+
reference: Optional[str],
|
| 51 |
+
hypothesis: Optional[str],
|
| 52 |
+
*,
|
| 53 |
+
lang: Language = "fr",
|
| 54 |
+
) -> Optional[dict]:
|
| 55 |
+
"""Calcule le delta Flesch d'un document avec adaptive masking.
|
| 56 |
+
|
| 57 |
+
Retourne ``None`` si la GT contient moins de
|
| 58 |
+
``_MIN_WORDS_FOR_FLESCH`` mots.
|
| 59 |
+
"""
|
| 60 |
+
ref = reference or ""
|
| 61 |
+
n_ref_words = count_words(ref)
|
| 62 |
+
if n_ref_words < _MIN_WORDS_FOR_FLESCH:
|
| 63 |
+
return None
|
| 64 |
+
hyp = hypothesis or ""
|
| 65 |
+
flesch_ref = flesch_score(ref, lang=lang)
|
| 66 |
+
flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None
|
| 67 |
+
delta = (
|
| 68 |
+
flesch_delta(ref, hyp, lang=lang) if hyp else None
|
| 69 |
+
)
|
| 70 |
+
return {
|
| 71 |
+
"lang": lang,
|
| 72 |
+
"flesch_reference": flesch_ref,
|
| 73 |
+
"flesch_hypothesis": flesch_hyp,
|
| 74 |
+
"flesch_delta": delta,
|
| 75 |
+
"n_words_reference": n_ref_words,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def aggregate_readability_metrics(
|
| 80 |
+
per_doc: Iterable[Optional[dict]],
|
| 81 |
+
) -> Optional[dict]:
|
| 82 |
+
"""Agrège : moyenne/médiane des deltas + part de docs
|
| 83 |
+
« over-normalisés » (delta > +5 points).
|
| 84 |
+
"""
|
| 85 |
+
docs = [d for d in per_doc if d]
|
| 86 |
+
if not docs:
|
| 87 |
+
return None
|
| 88 |
+
deltas = [
|
| 89 |
+
float(d["flesch_delta"]) for d in docs
|
| 90 |
+
if isinstance(d.get("flesch_delta"), (int, float))
|
| 91 |
+
]
|
| 92 |
+
if not deltas:
|
| 93 |
+
return None
|
| 94 |
+
over_norm = sum(1 for d in deltas if d > 5.0)
|
| 95 |
+
under_norm = sum(1 for d in deltas if d < -5.0)
|
| 96 |
+
lang = docs[0].get("lang") or "fr"
|
| 97 |
+
return {
|
| 98 |
+
"lang": lang,
|
| 99 |
+
"n_docs": len(docs),
|
| 100 |
+
"n_docs_with_delta": len(deltas),
|
| 101 |
+
"delta_mean": statistics.fmean(deltas),
|
| 102 |
+
"delta_median": statistics.median(deltas),
|
| 103 |
+
"delta_min": min(deltas),
|
| 104 |
+
"delta_max": max(deltas),
|
| 105 |
+
"n_over_normalized": over_norm,
|
| 106 |
+
"n_under_normalized": under_norm,
|
| 107 |
+
"over_normalized_rate": over_norm / len(deltas),
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
__all__ = [
|
| 112 |
+
"compute_readability_metrics",
|
| 113 |
+
"aggregate_readability_metrics",
|
| 114 |
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53.
|
| 2 |
+
|
| 3 |
+
Sprint 53 — A.II.2.1 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Sur un manuscrit glosé, un journal multi-colonnes ou un registre
|
| 8 |
+
paroissial complexe, le **classement des moteurs en CER** peut être
|
| 9 |
+
trompeur : un moteur peut avoir un excellent CER caractère et un
|
| 10 |
+
**ordre de lecture catastrophique**. Le résultat est inutilisable
|
| 11 |
+
pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
|
| 12 |
+
une narration linéaire.
|
| 13 |
+
|
| 14 |
+
La métrique standard est définie par Antonacopoulos et al. dans
|
| 15 |
+
ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
|
| 16 |
+
ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
|
| 17 |
+
``b`` dans la GT :
|
| 18 |
+
|
| 19 |
+
- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
|
| 20 |
+
- **FN** si la paire est manquante (régions absentes ou ordre
|
| 21 |
+
inversé) côté hypothèse,
|
| 22 |
+
- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
|
| 23 |
+
la GT n'a pas cet ordre (régions hallucinées ou inversion).
|
| 24 |
+
|
| 25 |
+
Le F1 est la moyenne harmonique des deux.
|
| 26 |
+
|
| 27 |
+
Stratégie de découpage
|
| 28 |
+
----------------------
|
| 29 |
+
Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
|
| 30 |
+
(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
|
| 31 |
+
deux listes ordonnées d'IDs de régions (typiquement extraites de
|
| 32 |
+
ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
|
| 33 |
+
suivent dans des sprints dédiés.
|
| 34 |
+
|
| 35 |
+
Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
|
| 36 |
+
``ReadingOrderGT.region_order`` est exactement le format attendu.
|
| 37 |
+
|
| 38 |
+
Convention sur les régions
|
| 39 |
+
--------------------------
|
| 40 |
+
- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
|
| 41 |
+
- Les **doublons** sont ignorés au calcul des paires ordonnées
|
| 42 |
+
(chaque ID compte une fois par séquence).
|
| 43 |
+
- Une région présente dans la GT mais absente de l'hypothèse
|
| 44 |
+
contribue aux paires FN.
|
| 45 |
+
- Une région présente dans l'hypothèse mais absente de la GT
|
| 46 |
+
contribue aux paires FP.
|
| 47 |
+
- Si une séquence a < 2 régions distinctes, aucune paire n'est
|
| 48 |
+
émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
|
| 49 |
+
séquences soient identiques.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import logging
|
| 55 |
+
from itertools import combinations
|
| 56 |
+
from typing import Iterable
|
| 57 |
+
|
| 58 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 59 |
+
from picarones.domain.artifacts import ArtifactType
|
| 60 |
+
|
| 61 |
+
logger = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
+
# Helpers
|
| 66 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
|
| 70 |
+
"""Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
|
| 71 |
+
précède strictement ``b`` dans ``sequence``.
|
| 72 |
+
|
| 73 |
+
Doublons : chaque ID est traité une seule fois (première occurrence
|
| 74 |
+
dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
|
| 75 |
+
des IDs uniques.
|
| 76 |
+
"""
|
| 77 |
+
seen: list[str] = []
|
| 78 |
+
seen_set: set[str] = set()
|
| 79 |
+
for r in sequence:
|
| 80 |
+
if r not in seen_set:
|
| 81 |
+
seen.append(r)
|
| 82 |
+
seen_set.add(r)
|
| 83 |
+
return set(combinations(seen, 2))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _normalize_input(value: Iterable[str] | None) -> list[str]:
|
| 87 |
+
"""Coerce une entrée en list[str], en filtrant les valeurs vides."""
|
| 88 |
+
if value is None:
|
| 89 |
+
return []
|
| 90 |
+
return [str(v) for v in value if v is not None and str(v).strip()]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 94 |
+
# Métrique principale
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def compute_reading_order_metrics(
|
| 99 |
+
reference_order: Iterable[str] | None,
|
| 100 |
+
hypothesis_order: Iterable[str] | None,
|
| 101 |
+
) -> dict:
|
| 102 |
+
"""Calcule precision / recall / F1 sur l'ordre relatif des régions.
|
| 103 |
+
|
| 104 |
+
Parameters
|
| 105 |
+
----------
|
| 106 |
+
reference_order:
|
| 107 |
+
Séquence ordonnée d'IDs de régions issue de la GT (typiquement
|
| 108 |
+
``ReadingOrderGT.region_order`` du Sprint 32).
|
| 109 |
+
hypothesis_order:
|
| 110 |
+
Séquence ordonnée d'IDs de régions produite par un moteur
|
| 111 |
+
OCR/HTR ou un reconstructeur ALTO.
|
| 112 |
+
|
| 113 |
+
Returns
|
| 114 |
+
-------
|
| 115 |
+
dict
|
| 116 |
+
``{"precision", "recall", "f1", "true_positives",
|
| 117 |
+
"false_positives", "false_negatives", "n_ref_pairs",
|
| 118 |
+
"n_hyp_pairs", "common_regions", "ref_only_regions",
|
| 119 |
+
"hyp_only_regions"}``.
|
| 120 |
+
|
| 121 |
+
Comportements aux bornes
|
| 122 |
+
------------------------
|
| 123 |
+
- Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0.
|
| 124 |
+
- Ordre strictement inversé → F1 = 0.0 (toutes les paires
|
| 125 |
+
relatives sont fausses).
|
| 126 |
+
- Une séquence vide vs une séquence non vide → F1 = 0.0.
|
| 127 |
+
- Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
|
| 128 |
+
(convention : on ne récompense pas l'absence).
|
| 129 |
+
"""
|
| 130 |
+
ref = _normalize_input(reference_order)
|
| 131 |
+
hyp = _normalize_input(hypothesis_order)
|
| 132 |
+
|
| 133 |
+
ref_pairs = _ordered_pairs(ref)
|
| 134 |
+
hyp_pairs = _ordered_pairs(hyp)
|
| 135 |
+
|
| 136 |
+
tp = len(ref_pairs & hyp_pairs)
|
| 137 |
+
fn = len(ref_pairs - hyp_pairs)
|
| 138 |
+
fp = len(hyp_pairs - ref_pairs)
|
| 139 |
+
|
| 140 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 141 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 142 |
+
f1 = (
|
| 143 |
+
2 * precision * recall / (precision + recall)
|
| 144 |
+
if (precision + recall) > 0
|
| 145 |
+
else 0.0
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
ref_set = set(ref)
|
| 149 |
+
hyp_set = set(hyp)
|
| 150 |
+
return {
|
| 151 |
+
"precision": precision,
|
| 152 |
+
"recall": recall,
|
| 153 |
+
"f1": f1,
|
| 154 |
+
"true_positives": tp,
|
| 155 |
+
"false_positives": fp,
|
| 156 |
+
"false_negatives": fn,
|
| 157 |
+
"n_ref_pairs": len(ref_pairs),
|
| 158 |
+
"n_hyp_pairs": len(hyp_pairs),
|
| 159 |
+
"common_regions": sorted(ref_set & hyp_set),
|
| 160 |
+
"ref_only_regions": sorted(ref_set - hyp_set),
|
| 161 |
+
"hyp_only_regions": sorted(hyp_set - ref_set),
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 167 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@register_metric(
|
| 171 |
+
name="reading_order_f1",
|
| 172 |
+
input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
|
| 173 |
+
description=(
|
| 174 |
+
"F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
|
| 175 |
+
"Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
|
| 176 |
+
"la GT, vérifie que a précède aussi b dans l'hypothèse."
|
| 177 |
+
),
|
| 178 |
+
higher_is_better=True,
|
| 179 |
+
tags={"structure", "icdar", "alto", "page"},
|
| 180 |
+
)
|
| 181 |
+
def reading_order_f1(
|
| 182 |
+
reference: Iterable[str] | None,
|
| 183 |
+
hypothesis: Iterable[str] | None,
|
| 184 |
+
) -> float:
|
| 185 |
+
"""Raccourci : retourne uniquement le F1 global.
|
| 186 |
+
|
| 187 |
+
Pour les détails par paire (TP/FP/FN, régions communes, etc.),
|
| 188 |
+
appeler ``compute_reading_order_metrics`` directement.
|
| 189 |
+
"""
|
| 190 |
+
return compute_reading_order_metrics(reference, hypothesis)["f1"]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
__all__ = [
|
| 194 |
+
"compute_reading_order_metrics",
|
| 195 |
+
"reading_order_f1",
|
| 196 |
+
]
|
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recherchabilité fuzzy — Sprint 84 (A.II.5).
|
| 2 |
+
|
| 3 |
+
Sprint 84 — A.II.5 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Le CER mesure les erreurs caractère par caractère. Mais pour
|
| 8 |
+
un usage *recherche plein-texte* (ce que font Elastic, Solr en
|
| 9 |
+
mode fuzzy, ou la recherche full-text de Gallica), la question
|
| 10 |
+
réelle est :
|
| 11 |
+
|
| 12 |
+
*« Combien de mots de ma GT sont retrouvables dans la
|
| 13 |
+
sortie OCR, à orthographe approchée près ? »*
|
| 14 |
+
|
| 15 |
+
Un CER de 8 % peut donner 95 % de findability si les erreurs
|
| 16 |
+
sont concentrées sur des caractères non-significatifs ou sur
|
| 17 |
+
quelques mots aberrants ; à l'inverse, 4 % de CER mais
|
| 18 |
+
distribué sur tous les noms propres rend le corpus inutilisable
|
| 19 |
+
pour l'indexation prosopographique.
|
| 20 |
+
|
| 21 |
+
Méthode
|
| 22 |
+
-------
|
| 23 |
+
Pour chaque token GT, on regarde s'il existe au moins un token
|
| 24 |
+
hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
|
| 25 |
+
2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
|
| 26 |
+
caractères). Le **rappel** est la proportion de tokens GT
|
| 27 |
+
ainsi retrouvés.
|
| 28 |
+
|
| 29 |
+
Multiplicité
|
| 30 |
+
------------
|
| 31 |
+
Si la GT contient *« le »* deux fois et l'hypothèse une fois,
|
| 32 |
+
seul un token GT est compté comme retrouvé (alignement
|
| 33 |
+
multi-set, comme ``rare_token_recall`` Sprint 71).
|
| 34 |
+
|
| 35 |
+
Sortie
|
| 36 |
+
------
|
| 37 |
+
``compute_searchability(reference, hypothesis)`` retourne
|
| 38 |
+
``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
|
| 39 |
+
|
| 40 |
+
Limites documentées
|
| 41 |
+
-------------------
|
| 42 |
+
- Tokenisation par split sur whitespace (cohérent avec le reste
|
| 43 |
+
du codebase). Pas de stemming ni de lemmatisation.
|
| 44 |
+
- Levenshtein non pondéré — substitution = insertion = suppression
|
| 45 |
+
= 1. Pour un poids différent (par ex. faute classique
|
| 46 |
+
diacritique = 0,5), passer une fonction custom.
|
| 47 |
+
- Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
|
| 48 |
+
similarité sémantique, voir des modules futurs (BERTScore).
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
from __future__ import annotations
|
| 52 |
+
|
| 53 |
+
import logging
|
| 54 |
+
from typing import Optional
|
| 55 |
+
|
| 56 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 57 |
+
from picarones.domain.artifacts import ArtifactType
|
| 58 |
+
|
| 59 |
+
logger = logging.getLogger(__name__)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
+
# Tokenisation et distance d'édition
|
| 64 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _split_words(text: Optional[str]) -> list[str]:
|
| 68 |
+
"""Tokenisation par whitespace — cohérent avec
|
| 69 |
+
``lexical_modernization.py``, ``rare_tokens.py``, etc."""
|
| 70 |
+
if not text:
|
| 71 |
+
return []
|
| 72 |
+
return text.split()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def levenshtein_distance(a: str, b: str) -> int:
|
| 76 |
+
"""Distance de Levenshtein (substitution=insertion=suppression=1).
|
| 77 |
+
|
| 78 |
+
Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)).
|
| 79 |
+
"""
|
| 80 |
+
if a == b:
|
| 81 |
+
return 0
|
| 82 |
+
if len(a) < len(b):
|
| 83 |
+
a, b = b, a
|
| 84 |
+
# |a| ≥ |b|
|
| 85 |
+
if not b:
|
| 86 |
+
return len(a)
|
| 87 |
+
previous = list(range(len(b) + 1))
|
| 88 |
+
for i, ca in enumerate(a, start=1):
|
| 89 |
+
current = [i] + [0] * len(b)
|
| 90 |
+
for j, cb in enumerate(b, start=1):
|
| 91 |
+
cost = 0 if ca == cb else 1
|
| 92 |
+
current[j] = min(
|
| 93 |
+
current[j - 1] + 1, # insertion
|
| 94 |
+
previous[j] + 1, # suppression
|
| 95 |
+
previous[j - 1] + cost, # substitution
|
| 96 |
+
)
|
| 97 |
+
previous = current
|
| 98 |
+
return previous[-1]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 102 |
+
# Calcul principal
|
| 103 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def compute_searchability(
|
| 107 |
+
reference: Optional[str],
|
| 108 |
+
hypothesis: Optional[str],
|
| 109 |
+
*,
|
| 110 |
+
max_distance: int = 2,
|
| 111 |
+
case_sensitive: bool = False,
|
| 112 |
+
) -> dict:
|
| 113 |
+
"""Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
|
| 114 |
+
|
| 115 |
+
Parameters
|
| 116 |
+
----------
|
| 117 |
+
reference, hypothesis:
|
| 118 |
+
Transcriptions GT et OCR.
|
| 119 |
+
max_distance:
|
| 120 |
+
Seuil de distance de Levenshtein (≤ pour considérer un
|
| 121 |
+
token comme retrouvé). Défaut 2 — convention
|
| 122 |
+
``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
|
| 123 |
+
case_sensitive:
|
| 124 |
+
Si False (défaut), casse insensible côté match — la
|
| 125 |
+
sortie ``missed_tokens`` reste avec la casse GT
|
| 126 |
+
originale.
|
| 127 |
+
|
| 128 |
+
Returns
|
| 129 |
+
-------
|
| 130 |
+
dict
|
| 131 |
+
``{
|
| 132 |
+
"n_gt_tokens": int,
|
| 133 |
+
"n_searchable": int,
|
| 134 |
+
"recall": float | None, # None si n_gt_tokens == 0
|
| 135 |
+
"missed_tokens": list[str],
|
| 136 |
+
"max_distance": int,
|
| 137 |
+
}``
|
| 138 |
+
"""
|
| 139 |
+
if max_distance < 0:
|
| 140 |
+
raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
|
| 141 |
+
gt_tokens = _split_words(reference)
|
| 142 |
+
hyp_tokens = _split_words(hypothesis)
|
| 143 |
+
n_gt = len(gt_tokens)
|
| 144 |
+
if n_gt == 0:
|
| 145 |
+
return {
|
| 146 |
+
"n_gt_tokens": 0,
|
| 147 |
+
"n_searchable": 0,
|
| 148 |
+
"recall": None,
|
| 149 |
+
"missed_tokens": [],
|
| 150 |
+
"max_distance": max_distance,
|
| 151 |
+
}
|
| 152 |
+
# Multi-set : un token hypothèse ne peut servir qu'une fois.
|
| 153 |
+
# Tri par longueur croissante pour matcher d'abord les
|
| 154 |
+
# tokens GT les plus courts (où ε-fautes sont plus rares).
|
| 155 |
+
if case_sensitive:
|
| 156 |
+
gt_for_match = list(gt_tokens)
|
| 157 |
+
hyp_for_match = list(hyp_tokens)
|
| 158 |
+
else:
|
| 159 |
+
gt_for_match = [t.lower() for t in gt_tokens]
|
| 160 |
+
hyp_for_match = [t.lower() for t in hyp_tokens]
|
| 161 |
+
|
| 162 |
+
hyp_used = [False] * len(hyp_for_match)
|
| 163 |
+
n_searchable = 0
|
| 164 |
+
missed: list[str] = []
|
| 165 |
+
for gi, gt_match in enumerate(gt_for_match):
|
| 166 |
+
# Court-circuit si match exact disponible
|
| 167 |
+
best_idx = -1
|
| 168 |
+
best_dist = max_distance + 1
|
| 169 |
+
for hi, used in enumerate(hyp_used):
|
| 170 |
+
if used:
|
| 171 |
+
continue
|
| 172 |
+
hyp_match = hyp_for_match[hi]
|
| 173 |
+
# Court-circuit longueur (Levenshtein ≥ |Δlen|)
|
| 174 |
+
if abs(len(hyp_match) - len(gt_match)) > max_distance:
|
| 175 |
+
continue
|
| 176 |
+
d = levenshtein_distance(gt_match, hyp_match)
|
| 177 |
+
if d < best_dist:
|
| 178 |
+
best_dist = d
|
| 179 |
+
best_idx = hi
|
| 180 |
+
if d == 0:
|
| 181 |
+
break # match exact, inutile de chercher mieux
|
| 182 |
+
if best_idx >= 0 and best_dist <= max_distance:
|
| 183 |
+
hyp_used[best_idx] = True
|
| 184 |
+
n_searchable += 1
|
| 185 |
+
else:
|
| 186 |
+
missed.append(gt_tokens[gi])
|
| 187 |
+
recall = n_searchable / n_gt
|
| 188 |
+
return {
|
| 189 |
+
"n_gt_tokens": n_gt,
|
| 190 |
+
"n_searchable": n_searchable,
|
| 191 |
+
"recall": recall,
|
| 192 |
+
"missed_tokens": missed,
|
| 193 |
+
"max_distance": max_distance,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 198 |
+
# Enregistrement registre typé (Sprint 34)
|
| 199 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@register_metric(
|
| 203 |
+
name="searchability_recall",
|
| 204 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 205 |
+
description=(
|
| 206 |
+
"Recherchabilité fuzzy : proportion de tokens GT retrouvés "
|
| 207 |
+
"dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
|
| 208 |
+
"la qualité pour la recherche plein-texte (Elastic, Solr)."
|
| 209 |
+
),
|
| 210 |
+
)
|
| 211 |
+
def searchability_recall_metric(reference: str, hypothesis: str) -> float:
|
| 212 |
+
"""Variante scalaire pour le registre typé : retourne le
|
| 213 |
+
rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
|
| 214 |
+
cohérente avec rare_token_recall Sprint 71).
|
| 215 |
+
"""
|
| 216 |
+
result = compute_searchability(reference, hypothesis)
|
| 217 |
+
recall = result.get("recall")
|
| 218 |
+
return 0.0 if recall is None else recall
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
__all__ = [
|
| 222 |
+
"levenshtein_distance",
|
| 223 |
+
"compute_searchability",
|
| 224 |
+
"searchability_recall_metric",
|
| 225 |
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Câblage runner de la recherchabilité (Sprint 86).
|
| 2 |
+
|
| 3 |
+
Sprint 86 — A.II.5a (vue HTML + câblage runner).
|
| 4 |
+
|
| 5 |
+
Le module ``picarones/core/searchability.py`` (Sprint 84) a livré
|
| 6 |
+
la couche de calcul. Ce helper prépare la donnée pour le runner
|
| 7 |
+
historique et l'agrégation par moteur.
|
| 8 |
+
|
| 9 |
+
Adaptive masking
|
| 10 |
+
----------------
|
| 11 |
+
Comme pour les modules philologiques (Sprint 61), on ne calcule
|
| 12 |
+
le rappel que si la GT contient au moins un token — pas de
|
| 13 |
+
calcul vide qui produirait du bruit dans le rapport.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Iterable, Optional
|
| 20 |
+
|
| 21 |
+
from picarones.evaluation.metrics.searchability import (
|
| 22 |
+
_split_words,
|
| 23 |
+
compute_searchability,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def compute_searchability_metrics(
|
| 30 |
+
reference: Optional[str],
|
| 31 |
+
hypothesis: Optional[str],
|
| 32 |
+
*,
|
| 33 |
+
max_distance: int = 2,
|
| 34 |
+
) -> Optional[dict]:
|
| 35 |
+
"""Recherchabilité d'un document (adaptive).
|
| 36 |
+
|
| 37 |
+
Retourne ``None`` si la GT est vide ou ne contient aucun
|
| 38 |
+
token — ce qui déclenche l'adaptive masking côté HTML.
|
| 39 |
+
"""
|
| 40 |
+
if not reference or not _split_words(reference):
|
| 41 |
+
return None
|
| 42 |
+
return compute_searchability(
|
| 43 |
+
reference, hypothesis or "", max_distance=max_distance,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def aggregate_searchability_metrics(
|
| 48 |
+
per_doc: Iterable[Optional[dict]],
|
| 49 |
+
) -> Optional[dict]:
|
| 50 |
+
"""Agrège les métriques par-doc en un score corpus-wide.
|
| 51 |
+
|
| 52 |
+
Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
|
| 53 |
+
et on recalcule un rappel **micro** (cohérent avec ECE/MCE
|
| 54 |
+
Sprint 39 et NER Sprint 38).
|
| 55 |
+
"""
|
| 56 |
+
docs = [d for d in per_doc if d]
|
| 57 |
+
if not docs:
|
| 58 |
+
return None
|
| 59 |
+
n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
|
| 60 |
+
n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
|
| 61 |
+
if n_gt == 0:
|
| 62 |
+
return None
|
| 63 |
+
# On garde l'union des missed_tokens (capped pour ne pas
|
| 64 |
+
# exploser le JSON sur de gros corpus)
|
| 65 |
+
missed: list[str] = []
|
| 66 |
+
for d in docs:
|
| 67 |
+
missed.extend(d.get("missed_tokens") or [])
|
| 68 |
+
return {
|
| 69 |
+
"n_docs": len(docs),
|
| 70 |
+
"n_gt_tokens": n_gt,
|
| 71 |
+
"n_searchable": n_search,
|
| 72 |
+
"recall": n_search / n_gt,
|
| 73 |
+
"missed_tokens_sample": missed[:50],
|
| 74 |
+
"max_distance": docs[0].get("max_distance", 2),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
__all__ = [
|
| 79 |
+
"compute_searchability_metrics",
|
| 80 |
+
"aggregate_searchability_metrics",
|
| 81 |
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Précision par bloc Unicode — Sprint 55.
|
| 2 |
+
|
| 3 |
+
Sprint 55 — A.II.3.1 du plan d'évolution 2026 (métriques philologiques).
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Pour un éditeur d'imprimés anciens ou un médiéviste, la question
|
| 8 |
+
n'est pas seulement *« quel CER global ? »* mais *« quels caractères
|
| 9 |
+
historiques ce moteur restitue-t-il fidèlement ? »*. Une phrase de
|
| 10 |
+
synthèse actionnable en un coup d'œil :
|
| 11 |
+
|
| 12 |
+
> *« GPT-4o restitue 95 % du Latin de Base mais seulement 12 % des
|
| 13 |
+
> formes de présentation latine (fi, fl, ſ…). »*
|
| 14 |
+
|
| 15 |
+
Ce module agrège la précision par **bloc Unicode standard** (Latin de
|
| 16 |
+
Base, Latin Étendu A/B, Diacritiques combinants, Présentation latine,
|
| 17 |
+
etc.). Le résultat permet directement de choisir un moteur selon le
|
| 18 |
+
type de glyphes attendus dans le corpus.
|
| 19 |
+
|
| 20 |
+
Stratégie de découpage
|
| 21 |
+
----------------------
|
| 22 |
+
Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
|
| 23 |
+
(Sprint 53), Layout F1 (Sprint 54) : couche de calcul pure d'abord.
|
| 24 |
+
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
|
| 25 |
+
|
| 26 |
+
Convention d'alignement
|
| 27 |
+
-----------------------
|
| 28 |
+
Alignement caractère par caractère via ``difflib.SequenceMatcher`` :
|
| 29 |
+
|
| 30 |
+
- chaque caractère de la GT est classé dans son bloc Unicode,
|
| 31 |
+
- pour chaque position GT couverte par un opcode ``equal`` →
|
| 32 |
+
+1 dans ``correct[bloc]``,
|
| 33 |
+
- pour chaque position GT non couverte (replace, delete) → +0,
|
| 34 |
+
- les insertions côté hypothèse (caractères absents de la GT) ne
|
| 35 |
+
contribuent à aucun bloc — elles sont visibles uniquement via le
|
| 36 |
+
CER global.
|
| 37 |
+
|
| 38 |
+
Précision par bloc = ``correct[bloc] / total[bloc]``.
|
| 39 |
+
|
| 40 |
+
Liste des blocs reconnus
|
| 41 |
+
------------------------
|
| 42 |
+
Centrée sur les glyphes courants des corpus patrimoniaux européens.
|
| 43 |
+
Tout caractère hors de cette table est classé dans ``"Other"``
|
| 44 |
+
(garantit une couverture exhaustive : ``sum(total[bloc]) ==
|
| 45 |
+
len(GT)``).
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import logging
|
| 51 |
+
from difflib import SequenceMatcher
|
| 52 |
+
from typing import Optional
|
| 53 |
+
|
| 54 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 55 |
+
from picarones.domain.artifacts import ArtifactType
|
| 56 |
+
|
| 57 |
+
logger = logging.getLogger(__name__)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 61 |
+
# Table des blocs Unicode reconnus
|
| 62 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
# Triplets (nom, code_point_min, code_point_max) — bornes inclusives.
|
| 65 |
+
# Centré sur les blocs pertinents pour les corpus patrimoniaux
|
| 66 |
+
# européens (manuscrits médiévaux, imprimés anciens, archives).
|
| 67 |
+
# Source : https://www.unicode.org/charts/
|
| 68 |
+
_UNICODE_BLOCKS: tuple[tuple[str, int, int], ...] = (
|
| 69 |
+
("Basic Latin", 0x0000, 0x007F),
|
| 70 |
+
("Latin-1 Supplement", 0x0080, 0x00FF),
|
| 71 |
+
("Latin Extended-A", 0x0100, 0x017F),
|
| 72 |
+
("Latin Extended-B", 0x0180, 0x024F),
|
| 73 |
+
("IPA Extensions", 0x0250, 0x02AF),
|
| 74 |
+
("Spacing Modifier Letters", 0x02B0, 0x02FF),
|
| 75 |
+
("Combining Diacritical Marks", 0x0300, 0x036F),
|
| 76 |
+
("Greek and Coptic", 0x0370, 0x03FF),
|
| 77 |
+
("Cyrillic", 0x0400, 0x04FF),
|
| 78 |
+
("Hebrew", 0x0590, 0x05FF),
|
| 79 |
+
("Arabic", 0x0600, 0x06FF),
|
| 80 |
+
("General Punctuation", 0x2000, 0x206F),
|
| 81 |
+
("Superscripts and Subscripts", 0x2070, 0x209F),
|
| 82 |
+
("Currency Symbols", 0x20A0, 0x20CF),
|
| 83 |
+
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
|
| 84 |
+
("Latin Extended Additional", 0x1E00, 0x1EFF),
|
| 85 |
+
("Latin Extended-C", 0x2C60, 0x2C7F),
|
| 86 |
+
("Latin Extended-D", 0xA720, 0xA7FF), # médiéval
|
| 87 |
+
("Latin Extended-E", 0xAB30, 0xAB6F),
|
| 88 |
+
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F), # fi, fl, ff…
|
| 89 |
+
("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
|
| 90 |
+
("Medieval Unicode Font Initiative (MUFI)", 0xE000, 0xF8FF), # PUA
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_block(char: str) -> str:
|
| 95 |
+
"""Retourne le nom du bloc Unicode contenant ``char``.
|
| 96 |
+
|
| 97 |
+
Pour un caractère hors des blocs listés (ex. CJK, emoji, etc.),
|
| 98 |
+
retourne ``"Other"``. Pour une chaîne multi-caractères, on
|
| 99 |
+
considère uniquement le premier code-point.
|
| 100 |
+
"""
|
| 101 |
+
if not char:
|
| 102 |
+
return "Other"
|
| 103 |
+
cp = ord(char[0])
|
| 104 |
+
for name, lo, hi in _UNICODE_BLOCKS:
|
| 105 |
+
if lo <= cp <= hi:
|
| 106 |
+
return name
|
| 107 |
+
return "Other"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 111 |
+
# Calcul d'accuracy par bloc
|
| 112 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def compute_unicode_block_accuracy(
|
| 116 |
+
reference: Optional[str],
|
| 117 |
+
hypothesis: Optional[str],
|
| 118 |
+
) -> dict:
|
| 119 |
+
"""Calcule la précision (recall caractère) par bloc Unicode.
|
| 120 |
+
|
| 121 |
+
Parameters
|
| 122 |
+
----------
|
| 123 |
+
reference:
|
| 124 |
+
Texte GT. Chaque caractère est classé dans son bloc Unicode.
|
| 125 |
+
hypothesis:
|
| 126 |
+
Texte produit par le moteur OCR.
|
| 127 |
+
|
| 128 |
+
Returns
|
| 129 |
+
-------
|
| 130 |
+
dict
|
| 131 |
+
``{
|
| 132 |
+
"per_block": {
|
| 133 |
+
bloc_name: {
|
| 134 |
+
"correct": int, # caractères GT correctement restitués
|
| 135 |
+
"total": int, # caractères GT du bloc
|
| 136 |
+
"accuracy": float, # correct / total ∈ [0, 1]
|
| 137 |
+
},
|
| 138 |
+
...
|
| 139 |
+
},
|
| 140 |
+
"global_accuracy": float, # somme(correct) / somme(total)
|
| 141 |
+
"n_chars_reference": int,
|
| 142 |
+
}``
|
| 143 |
+
|
| 144 |
+
Cas dégénérés
|
| 145 |
+
-------------
|
| 146 |
+
- GT vide → ``per_block`` vide, ``global_accuracy = 0.0``,
|
| 147 |
+
``n_chars_reference = 0``.
|
| 148 |
+
- hypothèse vide + GT non-vide → tous les blocs à
|
| 149 |
+
``accuracy = 0``.
|
| 150 |
+
- GT et hyp identiques → tous les blocs à ``accuracy = 1``.
|
| 151 |
+
"""
|
| 152 |
+
ref = reference or ""
|
| 153 |
+
hyp = hypothesis or ""
|
| 154 |
+
n_ref = len(ref)
|
| 155 |
+
|
| 156 |
+
if n_ref == 0:
|
| 157 |
+
return {
|
| 158 |
+
"per_block": {},
|
| 159 |
+
"global_accuracy": 0.0,
|
| 160 |
+
"n_chars_reference": 0,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
# 1. Compter le total par bloc
|
| 164 |
+
total: dict[str, int] = {}
|
| 165 |
+
for ch in ref:
|
| 166 |
+
b = get_block(ch)
|
| 167 |
+
total[b] = total.get(b, 0) + 1
|
| 168 |
+
|
| 169 |
+
# 2. Aligner par opcodes de SequenceMatcher
|
| 170 |
+
# Pour chaque opcode ``equal``, les positions ``i1..i2-1`` du GT
|
| 171 |
+
# sont correctement restituées → +1 par caractère dans son bloc.
|
| 172 |
+
correct: dict[str, int] = {b: 0 for b in total}
|
| 173 |
+
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 174 |
+
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 175 |
+
if op != "equal":
|
| 176 |
+
continue
|
| 177 |
+
for i in range(i1, i2):
|
| 178 |
+
b = get_block(ref[i])
|
| 179 |
+
correct[b] = correct.get(b, 0) + 1
|
| 180 |
+
|
| 181 |
+
per_block: dict[str, dict] = {}
|
| 182 |
+
for b in sorted(total):
|
| 183 |
+
n = total[b]
|
| 184 |
+
c = correct.get(b, 0)
|
| 185 |
+
per_block[b] = {
|
| 186 |
+
"correct": c,
|
| 187 |
+
"total": n,
|
| 188 |
+
"accuracy": c / n if n > 0 else 0.0,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
n_correct_total = sum(d["correct"] for d in per_block.values())
|
| 192 |
+
return {
|
| 193 |
+
"per_block": per_block,
|
| 194 |
+
"global_accuracy": n_correct_total / n_ref,
|
| 195 |
+
"n_chars_reference": n_ref,
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def unicode_block_global_accuracy(
|
| 200 |
+
reference: Optional[str],
|
| 201 |
+
hypothesis: Optional[str],
|
| 202 |
+
) -> float:
|
| 203 |
+
"""Raccourci : retourne ``global_accuracy`` (fraction de
|
| 204 |
+
caractères GT correctement restitués)."""
|
| 205 |
+
return compute_unicode_block_accuracy(reference, hypothesis)["global_accuracy"]
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 209 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 210 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@register_metric(
|
| 214 |
+
name="unicode_block_global_accuracy",
|
| 215 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 216 |
+
description=(
|
| 217 |
+
"Fraction de caractères GT correctement restitués par "
|
| 218 |
+
"l'OCR (alignement caractère par caractère via difflib). "
|
| 219 |
+
"Pour le détail par bloc Unicode (Latin de Base, Présentation "
|
| 220 |
+
"latine, etc.), utiliser compute_unicode_block_accuracy."
|
| 221 |
+
),
|
| 222 |
+
higher_is_better=True,
|
| 223 |
+
tags={"text", "unicode", "philology"},
|
| 224 |
+
)
|
| 225 |
+
def _registered_global_accuracy(reference: str, hypothesis: str) -> float:
|
| 226 |
+
return unicode_block_global_accuracy(reference, hypothesis)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
__all__ = [
|
| 230 |
+
"get_block",
|
| 231 |
+
"compute_unicode_block_accuracy",
|
| 232 |
+
"unicode_block_global_accuracy",
|
| 233 |
+
]
|
|
@@ -1,243 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
``(TEXT, ALTO)``. Aucune métrique n'était enregistrée sur la jonction
|
| 9 |
-
``(ALTO, ALTO)`` — pourtant indispensable dès qu'une pipeline produit
|
| 10 |
-
un ALTO et qu'une GT ALTO est disponible (Sprint 32).
|
| 11 |
-
|
| 12 |
-
Ce module comble cette lacune. Il expose un helper
|
| 13 |
-
:func:`extract_text_from_alto` qui parse l'ALTO XML et reconstruit le
|
| 14 |
-
texte plat dans l'ordre ``Page → TextBlock → TextLine → String``, et
|
| 15 |
-
enregistre quatre métriques natives (``alto_text_cer``,
|
| 16 |
-
``alto_text_wer``, ``alto_text_mer``, ``alto_text_wil``) qui appliquent
|
| 17 |
-
les opérateurs jiwer historiques sur le texte extrait des deux côtés.
|
| 18 |
-
|
| 19 |
-
L'approche est strictement additive vis-à-vis de
|
| 20 |
-
:mod:`picarones.measurements.metrics` : ce module ne touche pas le chemin de
|
| 21 |
-
calcul historique (``compute_metrics``), il enrichit uniquement le
|
| 22 |
-
registre typé pour les pipelines composées.
|
| 23 |
-
|
| 24 |
-
Robustesse
|
| 25 |
-
----------
|
| 26 |
-
- L'ALTO peut être passé sous forme :
|
| 27 |
-
* ``str`` (XML brut),
|
| 28 |
-
* :class:`picarones.evaluation.corpus.AltoGT` (porteur d'un ``xml_content``),
|
| 29 |
-
* tout objet exposant un attribut ``xml_content`` typé.
|
| 30 |
-
- Le parser tolère les ALTO sans namespace, ALTO 2.x, ALTO 3.x, ALTO
|
| 31 |
-
4.x — il cherche les balises locales par leur nom court (``Page``,
|
| 32 |
-
``TextLine``, ``String``).
|
| 33 |
-
- Un ALTO illisible ou vide → texte extrait ``""``. Le calcul de CER
|
| 34 |
-
reste possible (la couche jiwer sait gérer une référence non vide
|
| 35 |
-
vs hypothèse vide).
|
| 36 |
-
- Aucune dépendance externe : utilise ``xml.etree.ElementTree`` du
|
| 37 |
-
stdlib.
|
| 38 |
-
|
| 39 |
-
Cas typique d'usage
|
| 40 |
-
-------------------
|
| 41 |
-
Un VLM produit un ALTO via un reconstructeur (par exemple
|
| 42 |
-
:class:`picarones.modules.TextToAltoMonoRegion`). La GT
|
| 43 |
-
:class:`picarones.evaluation.corpus.AltoGT` du document est confrontée à la
|
| 44 |
-
sortie via :func:`picarones.evaluation.metric_registry.compute_at_junction`,
|
| 45 |
-
qui sélectionne automatiquement les métriques ``(ALTO, ALTO)``
|
| 46 |
-
ci-dessous.
|
| 47 |
"""
|
| 48 |
|
| 49 |
from __future__ import annotations
|
| 50 |
|
| 51 |
-
import
|
| 52 |
-
import re
|
| 53 |
-
from typing import Any
|
| 54 |
-
|
| 55 |
-
from picarones.formats._xml_utils import safe_parse_xml
|
| 56 |
-
|
| 57 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 58 |
-
from picarones.domain.artifacts import ArtifactType
|
| 59 |
-
|
| 60 |
-
logger = logging.getLogger(__name__)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
try:
|
| 64 |
-
import jiwer
|
| 65 |
-
_JIWER_AVAILABLE = True
|
| 66 |
-
except ImportError:
|
| 67 |
-
_JIWER_AVAILABLE = False
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
_LOCAL_NAME_RE = re.compile(r"\{[^}]*\}")
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _local(tag: str) -> str:
|
| 74 |
-
"""Retire le préfixe de namespace XML pour ne garder que le nom local.
|
| 75 |
-
|
| 76 |
-
ElementTree expose les tags sous la forme ``{namespace}LocalName``
|
| 77 |
-
quand un namespace est déclaré. On normalise pour pouvoir
|
| 78 |
-
matcher uniformément les ALTO avec ou sans namespace.
|
| 79 |
-
"""
|
| 80 |
-
return _LOCAL_NAME_RE.sub("", tag)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _coerce_alto_to_str(payload: Any) -> str:
|
| 84 |
-
"""Accepte plusieurs formes d'ALTO et retourne le XML brut."""
|
| 85 |
-
if payload is None:
|
| 86 |
-
return ""
|
| 87 |
-
if isinstance(payload, str):
|
| 88 |
-
return payload
|
| 89 |
-
xml_content = getattr(payload, "xml_content", None)
|
| 90 |
-
if isinstance(xml_content, str):
|
| 91 |
-
return xml_content
|
| 92 |
-
# Dernier recours — l'utilisateur a passé un objet avec str()
|
| 93 |
-
# raisonnable (tests, mocks). On ne lève pas, on retourne ""
|
| 94 |
-
# pour ne pas faire échouer une jonction sur un input bizarre.
|
| 95 |
-
return ""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def extract_text_from_alto(payload: Any) -> str:
|
| 99 |
-
"""Extrait le texte plat d'un ALTO XML.
|
| 100 |
-
|
| 101 |
-
L'ordre suivi reproduit la lecture naturelle ALTO :
|
| 102 |
-
``Page → PrintSpace → TextBlock → TextLine → String``, avec
|
| 103 |
-
insertion d'un espace entre les ``String`` d'une même ligne et
|
| 104 |
-
d'un saut de ligne entre lignes. Les ``SP`` (espaces explicites)
|
| 105 |
-
sont implicites — on n'en a pas besoin si on met un espace entre
|
| 106 |
-
chaque ``String``.
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
Returns
|
| 115 |
-
-------
|
| 116 |
-
str
|
| 117 |
-
Texte reconstruit, ``""`` si l'ALTO est invalide ou vide.
|
| 118 |
-
|
| 119 |
-
Notes
|
| 120 |
-
-----
|
| 121 |
-
Cette fonction est délibérément tolérante : un ALTO partiellement
|
| 122 |
-
valide produit le texte qu'il a pu extraire avant l'erreur de
|
| 123 |
-
parsing. Cela évite de faire échouer une jonction parce que la
|
| 124 |
-
GT a un défaut mineur (encodage, déclaration manquante).
|
| 125 |
-
"""
|
| 126 |
-
xml = _coerce_alto_to_str(payload).strip()
|
| 127 |
-
if not xml:
|
| 128 |
-
return ""
|
| 129 |
-
# ``safe_parse_xml`` neutralise XXE / Billion Laughs / DTD
|
| 130 |
-
# retrieval — l'ALTO peut venir d'un module ``BaseModule`` tiers
|
| 131 |
-
# qui n'a pas de garantie de provenance.
|
| 132 |
-
root = safe_parse_xml(xml.encode("utf-8") if isinstance(xml, str) else xml)
|
| 133 |
-
if root is None:
|
| 134 |
-
logger.warning(
|
| 135 |
-
"[alto_metrics] ALTO non parsable (XML invalide ou défense XXE "
|
| 136 |
-
"déclenchée) — texte extrait vide",
|
| 137 |
-
)
|
| 138 |
-
return ""
|
| 139 |
-
|
| 140 |
-
lines_text: list[str] = []
|
| 141 |
-
# Itère sur tous les TextLine, peu importe leur profondeur.
|
| 142 |
-
for line in root.iter():
|
| 143 |
-
if _local(line.tag) != "TextLine":
|
| 144 |
-
continue
|
| 145 |
-
words: list[str] = []
|
| 146 |
-
for s in line.iter():
|
| 147 |
-
if _local(s.tag) != "String":
|
| 148 |
-
continue
|
| 149 |
-
content = s.attrib.get("CONTENT", "")
|
| 150 |
-
if content:
|
| 151 |
-
words.append(content)
|
| 152 |
-
lines_text.append(" ".join(words))
|
| 153 |
-
return "\n".join(lines_text).strip()
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def _safe_jiwer_call(fn, reference: str, hypothesis: str) -> float:
|
| 157 |
-
if not _JIWER_AVAILABLE:
|
| 158 |
-
raise RuntimeError(
|
| 159 |
-
"jiwer n'est pas installé — installer avec `pip install jiwer`"
|
| 160 |
-
)
|
| 161 |
-
if not reference:
|
| 162 |
-
return 0.0 if not hypothesis else 1.0
|
| 163 |
-
if not hypothesis:
|
| 164 |
-
return 1.0
|
| 165 |
-
return fn(reference, hypothesis)
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 169 |
-
# Métriques (ALTO, ALTO) — opèrent sur le texte extrait de chaque ALTO
|
| 170 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
@register_metric(
|
| 174 |
-
name="alto_text_cer",
|
| 175 |
-
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 176 |
-
description=(
|
| 177 |
-
"CER calculé sur le texte plat extrait des ALTO (référence vs "
|
| 178 |
-
"hypothèse). Permet de mesurer la qualité d'un reconstructeur "
|
| 179 |
-
"ALTO sur l'axe textuel, indépendamment du layout."
|
| 180 |
-
),
|
| 181 |
-
higher_is_better=False,
|
| 182 |
-
tags={"alto", "text", "edit_distance"},
|
| 183 |
)
|
| 184 |
-
def alto_text_cer(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 185 |
-
return _safe_jiwer_call(
|
| 186 |
-
jiwer.cer,
|
| 187 |
-
extract_text_from_alto(reference_alto),
|
| 188 |
-
extract_text_from_alto(hypothesis_alto),
|
| 189 |
-
)
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
@register_metric(
|
| 193 |
-
name="alto_text_wer",
|
| 194 |
-
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 195 |
-
description="WER calculé sur le texte plat extrait des ALTO.",
|
| 196 |
-
higher_is_better=False,
|
| 197 |
-
tags={"alto", "text", "edit_distance"},
|
| 198 |
-
)
|
| 199 |
-
def alto_text_wer(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 200 |
-
return _safe_jiwer_call(
|
| 201 |
-
jiwer.wer,
|
| 202 |
-
extract_text_from_alto(reference_alto),
|
| 203 |
-
extract_text_from_alto(hypothesis_alto),
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
@register_metric(
|
| 208 |
-
name="alto_text_mer",
|
| 209 |
-
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 210 |
-
description="MER calculé sur le texte plat extrait des ALTO.",
|
| 211 |
-
higher_is_better=False,
|
| 212 |
-
tags={"alto", "text"},
|
| 213 |
-
)
|
| 214 |
-
def alto_text_mer(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 215 |
-
return _safe_jiwer_call(
|
| 216 |
-
jiwer.mer,
|
| 217 |
-
extract_text_from_alto(reference_alto),
|
| 218 |
-
extract_text_from_alto(hypothesis_alto),
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
@register_metric(
|
| 223 |
-
name="alto_text_wil",
|
| 224 |
-
input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
|
| 225 |
-
description="WIL calculé sur le texte plat extrait des ALTO.",
|
| 226 |
-
higher_is_better=False,
|
| 227 |
-
tags={"alto", "text"},
|
| 228 |
-
)
|
| 229 |
-
def alto_text_wil(reference_alto: Any, hypothesis_alto: Any) -> float:
|
| 230 |
-
return _safe_jiwer_call(
|
| 231 |
-
jiwer.wil,
|
| 232 |
-
extract_text_from_alto(reference_alto),
|
| 233 |
-
extract_text_from_alto(hypothesis_alto),
|
| 234 |
-
)
|
| 235 |
-
|
| 236 |
|
| 237 |
-
|
| 238 |
-
"extract_text_from_alto",
|
| 239 |
-
"alto_text_cer",
|
| 240 |
-
"alto_text_wer",
|
| 241 |
-
"alto_text_mer",
|
| 242 |
-
"alto_text_wil",
|
| 243 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.alto_metrics`` vers
|
| 5 |
+
``picarones.evaluation.metrics.alto_metrics`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.alto_metrics est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.alto_metrics à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.alto_metrics import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,199 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
(``medieval_french``, ``early_modern_french``, etc.) appliquent un
|
| 9 |
-
**bloc entier** de transformations. Mais un éditeur peut vouloir
|
| 10 |
-
nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
|
| 11 |
-
exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
|
| 12 |
-
distinctes mais où le s long doit être normalisé.
|
| 13 |
-
|
| 14 |
-
Ce module **éclate** chaque profil en règles d'équivalence
|
| 15 |
-
**nommées et indépendantes** que l'utilisateur peut activer ou
|
| 16 |
-
désactiver une par une. La couche de calcul retourne le CER
|
| 17 |
-
recalculé avec un sous-ensemble personnalisé.
|
| 18 |
-
|
| 19 |
-
Format
|
| 20 |
-
------
|
| 21 |
-
Chaque règle a :
|
| 22 |
-
|
| 23 |
-
- ``name`` : identifiant stable utilisé dans les URLs et l'UX
|
| 24 |
-
(ex. ``"longs_s"``, ``"u_eq_v"``)
|
| 25 |
-
- ``source`` : caractère ou séquence à remplacer
|
| 26 |
-
- ``target`` : caractère ou séquence cible
|
| 27 |
-
- ``description`` : phrase courte FR destinée à l'utilisateur
|
| 28 |
-
- ``profile_tag`` : nom du profil dont elle est issue (utile pour
|
| 29 |
-
grouper dans l'UX)
|
| 30 |
-
|
| 31 |
-
Stratégie de découpage
|
| 32 |
-
----------------------
|
| 33 |
-
Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
|
| 34 |
-
avancé (cases à cocher + recalcul JS client + URL state) suivra
|
| 35 |
-
dans un sprint dédié — la couche calcul livrée ici est une
|
| 36 |
-
fondation suffisante pour qu'un développeur frontend câble la vue.
|
| 37 |
"""
|
| 38 |
|
| 39 |
from __future__ import annotations
|
| 40 |
|
| 41 |
-
import
|
| 42 |
-
from dataclasses import dataclass
|
| 43 |
-
from typing import Iterable, Optional
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
)
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@dataclass(frozen=True)
|
| 56 |
-
class EquivalenceRule:
|
| 57 |
-
"""Une équivalence diplomatique nommée et indépendante."""
|
| 58 |
-
name: str
|
| 59 |
-
source: str
|
| 60 |
-
target: str
|
| 61 |
-
description: str
|
| 62 |
-
profile_tag: str
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
# Catalogue : on dérive des profils existants en attribuant un nom
|
| 66 |
-
# stable à chaque transformation. Les doublons (ex. ``ſ → s``
|
| 67 |
-
# présent dans plusieurs profils) sont fusionnés sous un nom unique
|
| 68 |
-
# (le premier rencontré).
|
| 69 |
-
def _build_catalog() -> dict[str, EquivalenceRule]:
|
| 70 |
-
catalog: dict[str, EquivalenceRule] = {}
|
| 71 |
-
|
| 72 |
-
# Noms canoniques pour les transformations courantes
|
| 73 |
-
canonical_names: dict[tuple[str, str], tuple[str, str]] = {
|
| 74 |
-
("ſ", "s"): ("longs_s", "s long ſ → s"),
|
| 75 |
-
("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
|
| 76 |
-
("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
|
| 77 |
-
("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
|
| 78 |
-
("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
|
| 79 |
-
("æ", "ae"): ("ae_ligature", "æ → ae"),
|
| 80 |
-
("œ", "oe"): ("oe_ligature", "œ → oe"),
|
| 81 |
-
("þ", "th"): ("thorn_th", "þ (thorn) → th"),
|
| 82 |
-
("ð", "th"): ("eth_th", "ð (eth) → th"),
|
| 83 |
-
("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
|
| 84 |
-
("&", "et"): ("ampersand_et", "& → et (esperluette)"),
|
| 85 |
-
("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
|
| 86 |
-
("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
|
| 87 |
-
("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
|
| 88 |
-
("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
sources = [
|
| 92 |
-
("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
|
| 93 |
-
("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
|
| 94 |
-
("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
|
| 95 |
-
("minimal", DIPLOMATIC_MINIMAL),
|
| 96 |
-
]
|
| 97 |
-
|
| 98 |
-
for profile_tag, profile_dict in sources:
|
| 99 |
-
for source, target in profile_dict.items():
|
| 100 |
-
key = (source, target)
|
| 101 |
-
if key in canonical_names:
|
| 102 |
-
name, desc = canonical_names[key]
|
| 103 |
-
else:
|
| 104 |
-
# Fallback : générer un nom à partir des codepoints
|
| 105 |
-
name = f"{source}_to_{target}".replace(" ", "_")
|
| 106 |
-
desc = f"{source} → {target}"
|
| 107 |
-
if name in catalog:
|
| 108 |
-
# On garde le profile_tag du premier rencontré, mais
|
| 109 |
-
# on note que la règle est partagée.
|
| 110 |
-
continue
|
| 111 |
-
catalog[name] = EquivalenceRule(
|
| 112 |
-
name=name,
|
| 113 |
-
source=source,
|
| 114 |
-
target=target,
|
| 115 |
-
description=desc,
|
| 116 |
-
profile_tag=profile_tag,
|
| 117 |
-
)
|
| 118 |
-
return catalog
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def list_equivalences_by_profile(
|
| 125 |
-
profile_name: Optional[str] = None,
|
| 126 |
-
) -> list[EquivalenceRule]:
|
| 127 |
-
"""Liste les règles d'équivalence disponibles.
|
| 128 |
-
|
| 129 |
-
Si ``profile_name`` est fourni, ne retourne que les règles dont
|
| 130 |
-
``profile_tag == profile_name`` (ou les règles dérivées de
|
| 131 |
-
plusieurs profils dont au moins un est ``profile_name``).
|
| 132 |
-
"""
|
| 133 |
-
if profile_name is None:
|
| 134 |
-
return list(BUILTIN_EQUIVALENCES.values())
|
| 135 |
-
return [
|
| 136 |
-
rule for rule in BUILTIN_EQUIVALENCES.values()
|
| 137 |
-
if rule.profile_tag == profile_name
|
| 138 |
-
]
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def apply_selected_equivalences(
|
| 142 |
-
text: Optional[str],
|
| 143 |
-
selected_names: Iterable[str],
|
| 144 |
-
) -> str:
|
| 145 |
-
"""Applique uniquement les règles dont le nom est dans
|
| 146 |
-
``selected_names``.
|
| 147 |
-
|
| 148 |
-
L'ordre d'application est l'ordre du catalogue interne — les
|
| 149 |
-
transformations sont appliquées séquentiellement sur le texte.
|
| 150 |
-
Les règles inconnues sont silencieusement ignorées (avec
|
| 151 |
-
warning).
|
| 152 |
-
"""
|
| 153 |
-
if not text:
|
| 154 |
-
return text or ""
|
| 155 |
-
selected_set = set(selected_names)
|
| 156 |
-
if not selected_set:
|
| 157 |
-
return text
|
| 158 |
-
out = text
|
| 159 |
-
for name, rule in BUILTIN_EQUIVALENCES.items():
|
| 160 |
-
if name not in selected_set:
|
| 161 |
-
continue
|
| 162 |
-
out = out.replace(rule.source, rule.target)
|
| 163 |
-
# Détection des règles inconnues (pour logger explicite)
|
| 164 |
-
unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
|
| 165 |
-
if unknown:
|
| 166 |
-
logger.warning(
|
| 167 |
-
"[equivalence_profile] règles inconnues ignorées : %s",
|
| 168 |
-
sorted(unknown),
|
| 169 |
-
)
|
| 170 |
-
return out
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def compute_cer_with_equivalences(
|
| 174 |
-
reference: Optional[str],
|
| 175 |
-
hypothesis: Optional[str],
|
| 176 |
-
selected_names: Iterable[str],
|
| 177 |
-
) -> float:
|
| 178 |
-
"""Calcule le CER après application des équivalences sélectionnées
|
| 179 |
-
sur les **deux** côtés (GT et hypothèse).
|
| 180 |
-
|
| 181 |
-
Utilise ``picarones.measurements.metrics.compute_metrics`` et extrait
|
| 182 |
-
le champ ``cer`` du résultat.
|
| 183 |
-
"""
|
| 184 |
-
from picarones.measurements.metrics import compute_metrics
|
| 185 |
-
|
| 186 |
-
selected_list = list(selected_names)
|
| 187 |
-
ref = apply_selected_equivalences(reference or "", selected_list)
|
| 188 |
-
hyp = apply_selected_equivalences(hypothesis or "", selected_list)
|
| 189 |
-
result = compute_metrics(ref, hyp)
|
| 190 |
-
return result.cer
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
__all__ = [
|
| 194 |
-
"EquivalenceRule",
|
| 195 |
-
"BUILTIN_EQUIVALENCES",
|
| 196 |
-
"list_equivalences_by_profile",
|
| 197 |
-
"apply_selected_equivalences",
|
| 198 |
-
"compute_cer_with_equivalences",
|
| 199 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.equivalence_profile`` vers
|
| 5 |
+
``picarones.evaluation.metrics.equivalence_profile`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.equivalence_profile est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.equivalence_profile à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
| 19 |
)
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.equivalence_profile import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,309 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui
|
| 9 |
-
compte c'est de savoir si les **entités nommées** (personnes, lieux,
|
| 10 |
-
dates, organisations) ont survécu à la transcription. Un CER de 5 %
|
| 11 |
-
qui rate 80 % des noms propres est inutilisable pour l'indexation
|
| 12 |
-
prosopographique.
|
| 13 |
-
|
| 14 |
-
Stratégie de découpage en sprints
|
| 15 |
-
---------------------------------
|
| 16 |
-
Comme pour la divergence taxonomique (Sprints 35-37), on découpe :
|
| 17 |
-
|
| 18 |
-
- **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre
|
| 19 |
-
deux listes d'entités, calcul de Precision/Recall/F1 par catégorie
|
| 20 |
-
et global, détection des hallucinations d'entité. Aucune dépendance
|
| 21 |
-
externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont
|
| 22 |
-
fournies en entrée. Un test de l'enregistrement dans le registre
|
| 23 |
-
typé Sprint 34 garantit l'intégration.
|
| 24 |
-
- **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et
|
| 25 |
-
câblage runner+narratif+HTML.
|
| 26 |
-
|
| 27 |
-
Format des entités
|
| 28 |
-
------------------
|
| 29 |
-
Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un
|
| 30 |
-
dictionnaire ``{"label": str, "start": int, "end": int, "text": str}``
|
| 31 |
-
où ``start``/``end`` sont des offsets caractère.
|
| 32 |
-
|
| 33 |
-
Convention d'alignement
|
| 34 |
-
-----------------------
|
| 35 |
-
Une entité hypothèse "matche" une entité de référence si :
|
| 36 |
-
|
| 37 |
-
1. les **labels sont identiques** (case-insensitive),
|
| 38 |
-
2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans
|
| 39 |
-
caractère est ``≥ iou_threshold`` (défaut : 0,5).
|
| 40 |
-
|
| 41 |
-
Une entité de référence non matchée → faux négatif (recall pénalisé).
|
| 42 |
-
Une entité hypothèse non matchée → faux positif (précision pénalisée).
|
| 43 |
-
Un faux positif est aussi compté comme **hallucination d'entité**, ce
|
| 44 |
-
qui est utile pour les VLM/LLM qui inventent.
|
| 45 |
-
|
| 46 |
-
Limites
|
| 47 |
-
-------
|
| 48 |
-
- L'alignement bag-of-spans : une entité peut être matchée par au plus
|
| 49 |
-
une entité de l'autre côté (sinon double-comptage).
|
| 50 |
-
- Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique
|
| 51 |
-
mesure conjointement OCR + NER. Documenter explicitement.
|
| 52 |
"""
|
| 53 |
|
| 54 |
from __future__ import annotations
|
| 55 |
|
| 56 |
-
import
|
| 57 |
-
from dataclasses import dataclass
|
| 58 |
-
from typing import Iterable
|
| 59 |
-
|
| 60 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 61 |
-
from picarones.domain.artifacts import ArtifactType
|
| 62 |
-
|
| 63 |
-
logger = logging.getLogger(__name__)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 67 |
-
# Modèle de données
|
| 68 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
@dataclass(frozen=True)
|
| 72 |
-
class Entity:
|
| 73 |
-
"""Entité nommée alignée sur un texte.
|
| 74 |
-
|
| 75 |
-
Attributs
|
| 76 |
-
---------
|
| 77 |
-
label:
|
| 78 |
-
Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``).
|
| 79 |
-
La comparaison se fait en *case-insensitive*.
|
| 80 |
-
start, end:
|
| 81 |
-
Offsets caractère (inclus, exclu) sur le texte de référence.
|
| 82 |
-
text:
|
| 83 |
-
Forme de surface — informative, **non utilisée pour
|
| 84 |
-
l'alignement** (deux entités peuvent matcher même si leur
|
| 85 |
-
forme de surface diffère, du moment que leurs spans
|
| 86 |
-
chevauchent suffisamment).
|
| 87 |
-
"""
|
| 88 |
-
|
| 89 |
-
label: str
|
| 90 |
-
start: int
|
| 91 |
-
end: int
|
| 92 |
-
text: str = ""
|
| 93 |
-
|
| 94 |
-
def __post_init__(self) -> None:
|
| 95 |
-
if self.start > self.end:
|
| 96 |
-
raise ValueError(
|
| 97 |
-
f"Entity span invalide : start={self.start} > end={self.end}"
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
-
@property
|
| 101 |
-
def length(self) -> int:
|
| 102 |
-
return max(0, self.end - self.start)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _to_entity(obj: Entity | dict) -> Entity:
|
| 106 |
-
"""Coerce un dict (format EntitiesGT) en ``Entity``."""
|
| 107 |
-
if isinstance(obj, Entity):
|
| 108 |
-
return obj
|
| 109 |
-
return Entity(
|
| 110 |
-
label=str(obj["label"]),
|
| 111 |
-
start=int(obj["start"]),
|
| 112 |
-
end=int(obj["end"]),
|
| 113 |
-
text=str(obj.get("text", "")),
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 118 |
-
# Alignement par IoU
|
| 119 |
-
# ────────────────────────────────────────────────────────��─────────────────
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def _iou(a: Entity, b: Entity) -> float:
|
| 123 |
-
"""Intersection-over-Union sur les spans caractère."""
|
| 124 |
-
inter_start = max(a.start, b.start)
|
| 125 |
-
inter_end = min(a.end, b.end)
|
| 126 |
-
inter = max(0, inter_end - inter_start)
|
| 127 |
-
union = a.length + b.length - inter
|
| 128 |
-
if union <= 0:
|
| 129 |
-
return 0.0
|
| 130 |
-
return inter / union
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
|
| 138 |
-
"""Aligne deux listes d'entités par IoU décroissant (greedy).
|
| 139 |
-
|
| 140 |
-
Returns
|
| 141 |
-
-------
|
| 142 |
-
matches:
|
| 143 |
-
Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU
|
| 144 |
-
décroissant — chaque entité n'apparaît qu'une fois.
|
| 145 |
-
unmatched_refs:
|
| 146 |
-
Indices des entités GT non matchées (faux négatifs).
|
| 147 |
-
unmatched_hyps:
|
| 148 |
-
Indices des entités hypothèse non matchées (faux positifs).
|
| 149 |
-
"""
|
| 150 |
-
candidates: list[tuple[float, int, int]] = []
|
| 151 |
-
for i, r in enumerate(references):
|
| 152 |
-
for j, h in enumerate(hypotheses):
|
| 153 |
-
if r.label.casefold() != h.label.casefold():
|
| 154 |
-
continue
|
| 155 |
-
score = _iou(r, h)
|
| 156 |
-
if score >= iou_threshold:
|
| 157 |
-
candidates.append((score, i, j))
|
| 158 |
-
|
| 159 |
-
# Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires
|
| 160 |
-
# pour garantir un tri stable et déterministe.
|
| 161 |
-
candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
|
| 162 |
-
|
| 163 |
-
matched_refs: set[int] = set()
|
| 164 |
-
matched_hyps: set[int] = set()
|
| 165 |
-
matches: list[tuple[int, int, float]] = []
|
| 166 |
-
for score, i, j in candidates:
|
| 167 |
-
if i in matched_refs or j in matched_hyps:
|
| 168 |
-
continue
|
| 169 |
-
matched_refs.add(i)
|
| 170 |
-
matched_hyps.add(j)
|
| 171 |
-
matches.append((i, j, score))
|
| 172 |
-
|
| 173 |
-
unmatched_refs = set(range(len(references))) - matched_refs
|
| 174 |
-
unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
|
| 175 |
-
return matches, unmatched_refs, unmatched_hyps
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 179 |
-
# Calcul des métriques
|
| 180 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
|
| 184 |
-
"""Précision / rappel / F1 à partir des comptes."""
|
| 185 |
-
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 186 |
-
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 187 |
-
f1 = (
|
| 188 |
-
2 * precision * recall / (precision + recall)
|
| 189 |
-
if (precision + recall) > 0
|
| 190 |
-
else 0.0
|
| 191 |
-
)
|
| 192 |
-
return {
|
| 193 |
-
"precision": precision,
|
| 194 |
-
"recall": recall,
|
| 195 |
-
"f1": f1,
|
| 196 |
-
"support": tp + fn,
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def compute_ner_metrics(
|
| 201 |
-
reference_entities: Iterable[Entity | dict],
|
| 202 |
-
hypothesis_entities: Iterable[Entity | dict],
|
| 203 |
-
iou_threshold: float = 0.5,
|
| 204 |
-
) -> dict:
|
| 205 |
-
"""Calcule la précision/rappel/F1 sur entités nommées.
|
| 206 |
-
|
| 207 |
-
Parameters
|
| 208 |
-
----------
|
| 209 |
-
reference_entities:
|
| 210 |
-
Liste d'entités GT (format ``Entity`` ou dict de
|
| 211 |
-
``EntitiesGT``).
|
| 212 |
-
hypothesis_entities:
|
| 213 |
-
Liste d'entités produites par le NER sur la sortie OCR.
|
| 214 |
-
iou_threshold:
|
| 215 |
-
Seuil de chevauchement caractère pour qu'un appariement
|
| 216 |
-
soit valide (défaut : 0,5 — convention CoNLL/HIPE).
|
| 217 |
-
|
| 218 |
-
Returns
|
| 219 |
-
-------
|
| 220 |
-
dict
|
| 221 |
-
``{
|
| 222 |
-
"global": {"precision", "recall", "f1", "support"},
|
| 223 |
-
"per_category": {label: {"precision", ...}},
|
| 224 |
-
"true_positives": int,
|
| 225 |
-
"false_positives": int,
|
| 226 |
-
"false_negatives": int,
|
| 227 |
-
"hallucinated_entities": list[dict], # entités OCR sans GT
|
| 228 |
-
"missed_entities": list[dict], # entités GT non détectées
|
| 229 |
-
"iou_threshold": float,
|
| 230 |
-
}``
|
| 231 |
-
"""
|
| 232 |
-
refs = [_to_entity(e) for e in reference_entities]
|
| 233 |
-
hyps = [_to_entity(e) for e in hypothesis_entities]
|
| 234 |
-
|
| 235 |
-
matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold)
|
| 236 |
-
|
| 237 |
-
tp = len(matches)
|
| 238 |
-
fn = len(unmatched_refs)
|
| 239 |
-
fp = len(unmatched_hyps)
|
| 240 |
-
|
| 241 |
-
# Comptes par catégorie
|
| 242 |
-
cat_tp: dict[str, int] = {}
|
| 243 |
-
cat_fn: dict[str, int] = {}
|
| 244 |
-
cat_fp: dict[str, int] = {}
|
| 245 |
-
for i, _j, _score in matches:
|
| 246 |
-
cat = refs[i].label
|
| 247 |
-
cat_tp[cat] = cat_tp.get(cat, 0) + 1
|
| 248 |
-
for i in unmatched_refs:
|
| 249 |
-
cat = refs[i].label
|
| 250 |
-
cat_fn[cat] = cat_fn.get(cat, 0) + 1
|
| 251 |
-
for j in unmatched_hyps:
|
| 252 |
-
cat = hyps[j].label
|
| 253 |
-
cat_fp[cat] = cat_fp.get(cat, 0) + 1
|
| 254 |
-
|
| 255 |
-
all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
|
| 256 |
-
per_category = {
|
| 257 |
-
cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0))
|
| 258 |
-
for cat in all_categories
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
return {
|
| 262 |
-
"global": _prf(tp, fp, fn),
|
| 263 |
-
"per_category": per_category,
|
| 264 |
-
"true_positives": tp,
|
| 265 |
-
"false_positives": fp,
|
| 266 |
-
"false_negatives": fn,
|
| 267 |
-
"hallucinated_entities": [
|
| 268 |
-
{"label": hyps[j].label, "start": hyps[j].start,
|
| 269 |
-
"end": hyps[j].end, "text": hyps[j].text}
|
| 270 |
-
for j in sorted(unmatched_hyps)
|
| 271 |
-
],
|
| 272 |
-
"missed_entities": [
|
| 273 |
-
{"label": refs[i].label, "start": refs[i].start,
|
| 274 |
-
"end": refs[i].end, "text": refs[i].text}
|
| 275 |
-
for i in sorted(unmatched_refs)
|
| 276 |
-
],
|
| 277 |
-
"iou_threshold": iou_threshold,
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 282 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 283 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
@register_metric(
|
| 287 |
-
name="ner_f1",
|
| 288 |
-
input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES),
|
| 289 |
-
description=(
|
| 290 |
-
"F1 global sur les entités nommées (alignement IoU ≥ 0,5, "
|
| 291 |
-
"labels case-insensitive). Pour le détail par catégorie, "
|
| 292 |
-
"utiliser compute_ner_metrics directement."
|
| 293 |
-
),
|
| 294 |
-
higher_is_better=True,
|
| 295 |
-
tags={"downstream", "ner", "structure"},
|
| 296 |
)
|
| 297 |
-
def ner_f1(
|
| 298 |
-
reference_entities: Iterable[Entity | dict],
|
| 299 |
-
hypothesis_entities: Iterable[Entity | dict],
|
| 300 |
-
) -> float:
|
| 301 |
-
"""F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``."""
|
| 302 |
-
return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"]
|
| 303 |
-
|
| 304 |
|
| 305 |
-
|
| 306 |
-
"Entity",
|
| 307 |
-
"compute_ner_metrics",
|
| 308 |
-
"ner_f1",
|
| 309 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.ner`` vers
|
| 5 |
+
``picarones.evaluation.metrics.ner`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.ner est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.ner à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.ner import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,102 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Adaptive masking
|
| 10 |
-
----------------
|
| 11 |
-
On ne stocke le résultat que si la GT contient au moins une
|
| 12 |
-
séquence numérique détectée — sinon le module n'apparaît pas
|
| 13 |
-
dans le rapport.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
-
import
|
| 19 |
-
from typing import Iterable, Optional
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def compute_numerical_sequence_metrics_adaptive(
|
| 30 |
-
reference: Optional[str],
|
| 31 |
-
hypothesis: Optional[str],
|
| 32 |
-
) -> Optional[dict]:
|
| 33 |
-
"""Calcule les métriques séquences numériques avec masquage
|
| 34 |
-
adaptatif : retourne ``None`` si la GT n'en contient
|
| 35 |
-
aucune."""
|
| 36 |
-
if not reference:
|
| 37 |
-
return None
|
| 38 |
-
result = compute_numerical_sequence_metrics(reference, hypothesis or "")
|
| 39 |
-
if (result.get("n_total") or 0) == 0:
|
| 40 |
-
return None
|
| 41 |
-
return result
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def aggregate_numerical_sequence_metrics(
|
| 45 |
-
per_doc: Iterable[Optional[dict]],
|
| 46 |
-
) -> Optional[dict]:
|
| 47 |
-
"""Agrège par moteur : somme les compteurs par catégorie et
|
| 48 |
-
recalcule les scores globaux et per-category.
|
| 49 |
-
|
| 50 |
-
Format de sortie identique à ``compute_numerical_sequence_metrics``
|
| 51 |
-
pour faciliter le rendu HTML symétrique.
|
| 52 |
-
"""
|
| 53 |
-
docs = [d for d in per_doc if d]
|
| 54 |
-
if not docs:
|
| 55 |
-
return None
|
| 56 |
-
total_n = 0
|
| 57 |
-
total_strict = 0
|
| 58 |
-
total_value = 0
|
| 59 |
-
per_cat: dict[str, dict] = {}
|
| 60 |
-
for cat in CATEGORIES:
|
| 61 |
-
per_cat[cat] = {
|
| 62 |
-
"n_total": 0,
|
| 63 |
-
"strict": 0,
|
| 64 |
-
"value": 0,
|
| 65 |
-
"lost_items": [],
|
| 66 |
-
}
|
| 67 |
-
for d in docs:
|
| 68 |
-
for cat in CATEGORIES:
|
| 69 |
-
cat_data = (d.get("per_category") or {}).get(cat) or {}
|
| 70 |
-
per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
|
| 71 |
-
per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
|
| 72 |
-
per_cat[cat]["value"] += int(cat_data.get("value") or 0)
|
| 73 |
-
per_cat[cat]["lost_items"].extend(
|
| 74 |
-
cat_data.get("lost_items") or [],
|
| 75 |
-
)
|
| 76 |
-
total_n += int(d.get("n_total") or 0)
|
| 77 |
-
# Recalcul des scores
|
| 78 |
-
for cat, slot in per_cat.items():
|
| 79 |
-
n = slot["n_total"]
|
| 80 |
-
slot["strict_score"] = slot["strict"] / n if n else 0.0
|
| 81 |
-
slot["value_score"] = slot["value"] / n if n else 0.0
|
| 82 |
-
# Cap des lost_items à 50 par catégorie
|
| 83 |
-
slot["lost_items"] = slot["lost_items"][:50]
|
| 84 |
-
total_strict += slot["strict"]
|
| 85 |
-
total_value += slot["value"]
|
| 86 |
-
return {
|
| 87 |
-
"n_docs": len(docs),
|
| 88 |
-
"n_total": total_n,
|
| 89 |
-
"global_strict_score": (
|
| 90 |
-
total_strict / total_n if total_n else 0.0
|
| 91 |
-
),
|
| 92 |
-
"global_value_score": (
|
| 93 |
-
total_value / total_n if total_n else 0.0
|
| 94 |
-
),
|
| 95 |
-
"per_category": per_cat,
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
__all__ = [
|
| 100 |
-
"compute_numerical_sequence_metrics_adaptive",
|
| 101 |
-
"aggregate_numerical_sequence_metrics",
|
| 102 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.numerical_sequences_hooks`` vers
|
| 5 |
+
``picarones.evaluation.metrics.numerical_sequences_hooks`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.numerical_sequences_hooks est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.numerical_sequences_hooks à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
| 19 |
)
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.numerical_sequences_hooks import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,252 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Les LLM produisent du texte plus « lisse » que les manuscrits
|
| 9 |
-
historiques. Cette tendance à la modernisation est mesurable par la
|
| 10 |
-
différence de score de lisibilité entre la GT et la sortie OCR/LLM —
|
| 11 |
-
**indépendamment des classes taxonomiques** et **sans alignement
|
| 12 |
-
caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
|
| 13 |
-
même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
|
| 14 |
-
moderne plausible mais déconnecté de la GT).
|
| 15 |
-
|
| 16 |
-
Stratégie de découpage
|
| 17 |
-
----------------------
|
| 18 |
-
Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
|
| 19 |
-
découpe :
|
| 20 |
-
|
| 21 |
-
- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
|
| 22 |
-
``flesch_delta``. Aucune dépendance externe ; les heuristiques de
|
| 23 |
-
comptage de syllabes sont en pur Python, déterministes, testées.
|
| 24 |
-
- **Sprints suivants** — câblage runner pour calculer
|
| 25 |
-
``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
|
| 26 |
-
|
| 27 |
-
Formules
|
| 28 |
-
--------
|
| 29 |
-
- **Anglais** (Flesch original 1948) :
|
| 30 |
-
``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
|
| 31 |
-
- **Français** (Kandel-Moles 1958) :
|
| 32 |
-
``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
|
| 33 |
-
|
| 34 |
-
Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
|
| 35 |
-
0 ↔ « très difficile ». Une **augmentation** du score quand on passe
|
| 36 |
-
de la GT à l'OCR signale une simplification (typique des LLM
|
| 37 |
-
modernisants). Une **chute** signale une dégradation OCR.
|
| 38 |
-
|
| 39 |
-
Limites documentées
|
| 40 |
-
-------------------
|
| 41 |
-
- Le comptage de syllabes est heuristique. En français, des règles
|
| 42 |
-
comme « -ier non final = 2 syllabes » ne sont pas appliquées
|
| 43 |
-
finement. Acceptable pour une métrique de **comparaison relative**
|
| 44 |
-
(delta GT vs OCR), pas pour publier une absolue.
|
| 45 |
-
- Sur des textes très courts (< 20 mots), la formule perd en
|
| 46 |
-
fiabilité. Le seuil minimal est documenté.
|
| 47 |
"""
|
| 48 |
|
| 49 |
from __future__ import annotations
|
| 50 |
|
| 51 |
-
import
|
| 52 |
-
import re
|
| 53 |
-
from typing import Literal
|
| 54 |
-
|
| 55 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 56 |
-
from picarones.domain.artifacts import ArtifactType
|
| 57 |
-
|
| 58 |
-
logger = logging.getLogger(__name__)
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
Language = Literal["fr", "en"]
|
| 62 |
-
|
| 63 |
-
# Coefficients de la formule Flesch selon la langue.
|
| 64 |
-
_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
|
| 65 |
-
"en": (206.835, 1.015, 84.6), # Flesch 1948
|
| 66 |
-
"fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
# Voyelles utilisées pour l'heuristique de comptage de syllabes.
|
| 70 |
-
# On utilise un set qui inclut les diacritiques courantes en FR/EN.
|
| 71 |
-
_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
|
| 72 |
-
|
| 73 |
-
# Regex de découpage en phrases : ponctuation finale + espace ou fin.
|
| 74 |
-
# Tolère les multiples points (« ... ») et garde un découpage robuste.
|
| 75 |
-
_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
|
| 76 |
-
|
| 77 |
-
# Regex de tokenisation simple (mots) : séquences de caractères "lettres".
|
| 78 |
-
_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 82 |
-
# Compteurs de base
|
| 83 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def count_words(text: str) -> int:
|
| 87 |
-
"""Nombre de mots (tokens alphanumériques) dans ``text``."""
|
| 88 |
-
if not text:
|
| 89 |
-
return 0
|
| 90 |
-
return len(_WORD_RE.findall(text))
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def count_sentences(text: str) -> int:
|
| 94 |
-
"""Nombre de phrases dans ``text``.
|
| 95 |
-
|
| 96 |
-
Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
|
| 97 |
-
Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
|
| 98 |
-
éviter une division par zéro dans la formule de Flesch sur les
|
| 99 |
-
textes sans ponctuation finale.
|
| 100 |
-
"""
|
| 101 |
-
if not text:
|
| 102 |
-
return 0
|
| 103 |
-
parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
|
| 104 |
-
n = len(parts)
|
| 105 |
-
if n == 0 and count_words(text) > 0:
|
| 106 |
-
return 1
|
| 107 |
-
return n
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def count_syllables_word(word: str) -> int:
|
| 111 |
-
"""Heuristique de comptage de syllabes pour un mot isolé.
|
| 112 |
-
|
| 113 |
-
Règle : on compte les **groupes de voyelles consécutives** (en
|
| 114 |
-
incluant ``y`` et les diacritiques courantes). C'est une
|
| 115 |
-
approximation grossière mais déterministe et testable.
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
if not word:
|
| 123 |
-
return 0
|
| 124 |
-
word = word.lower()
|
| 125 |
-
in_vowel_group = False
|
| 126 |
-
count = 0
|
| 127 |
-
for ch in word:
|
| 128 |
-
if ch in _VOWELS:
|
| 129 |
-
if not in_vowel_group:
|
| 130 |
-
count += 1
|
| 131 |
-
in_vowel_group = True
|
| 132 |
-
else:
|
| 133 |
-
in_vowel_group = False
|
| 134 |
-
return count or 1
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def count_syllables(text: str) -> int:
|
| 138 |
-
"""Somme des syllabes de tous les mots de ``text``."""
|
| 139 |
-
if not text:
|
| 140 |
-
return 0
|
| 141 |
-
return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 145 |
-
# Score Flesch
|
| 146 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def flesch_score(text: str, lang: Language = "fr") -> float:
|
| 150 |
-
"""Calcule le score de lisibilité Flesch pour ``text``.
|
| 151 |
-
|
| 152 |
-
Parameters
|
| 153 |
-
----------
|
| 154 |
-
text:
|
| 155 |
-
Texte à évaluer. Peut contenir ponctuation, accents, etc.
|
| 156 |
-
lang:
|
| 157 |
-
``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
|
| 158 |
-
|
| 159 |
-
Returns
|
| 160 |
-
-------
|
| 161 |
-
float
|
| 162 |
-
Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
|
| 163 |
-
vide ou sans mot exploitable.
|
| 164 |
-
|
| 165 |
-
Notes
|
| 166 |
-
-----
|
| 167 |
-
Le score chute fortement avec :
|
| 168 |
-
- longues phrases (mots/phrases élevé)
|
| 169 |
-
- mots polysyllabiques (syllabes/mots élevé)
|
| 170 |
-
Une montée du score lors du passage GT → OCR signale qu'un LLM a
|
| 171 |
-
« lissé » la langue (phrases plus courtes, mots plus communs).
|
| 172 |
-
"""
|
| 173 |
-
if lang not in _FLESCH_COEFFS:
|
| 174 |
-
raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
|
| 175 |
-
|
| 176 |
-
n_words = count_words(text)
|
| 177 |
-
if n_words == 0:
|
| 178 |
-
return 0.0
|
| 179 |
-
n_sentences = max(1, count_sentences(text))
|
| 180 |
-
n_syllables = count_syllables(text)
|
| 181 |
-
if n_syllables == 0:
|
| 182 |
-
return 0.0
|
| 183 |
-
|
| 184 |
-
base, k_words, k_syll = _FLESCH_COEFFS[lang]
|
| 185 |
-
raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
|
| 186 |
-
return max(0.0, min(100.0, raw))
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def flesch_delta(
|
| 190 |
-
reference: str,
|
| 191 |
-
hypothesis: str,
|
| 192 |
-
lang: Language = "fr",
|
| 193 |
-
) -> float:
|
| 194 |
-
"""Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
|
| 195 |
-
|
| 196 |
-
Interprétation
|
| 197 |
-
--------------
|
| 198 |
-
- **Positif** : l'hypothèse OCR est plus lisible que la GT —
|
| 199 |
-
signal d'**over-normalisation** (typique des LLM qui modernisent
|
| 200 |
-
des textes anciens).
|
| 201 |
-
- **Négatif** : l'OCR est moins lisible — signal de dégradation
|
| 202 |
-
(caractères mal reconnus brisent la fluidité).
|
| 203 |
-
- **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
|
| 204 |
-
|
| 205 |
-
Borné dans ``[-100, +100]``.
|
| 206 |
-
"""
|
| 207 |
-
return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 211 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 212 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
@register_metric(
|
| 216 |
-
name="flesch_delta_fr",
|
| 217 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 218 |
-
description=(
|
| 219 |
-
"Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
|
| 220 |
-
"OCR et la GT. Positif = OCR plus lisible (signal "
|
| 221 |
-
"d'over-normalisation LLM). Aucun alignement requis."
|
| 222 |
-
),
|
| 223 |
-
higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
|
| 224 |
-
tags={"text", "readability", "over_normalization"},
|
| 225 |
)
|
| 226 |
-
def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
|
| 227 |
-
return flesch_delta(reference, hypothesis, lang="fr")
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
@register_metric(
|
| 231 |
-
name="flesch_delta_en",
|
| 232 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 233 |
-
description=(
|
| 234 |
-
"Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
|
| 235 |
-
"Positive = OCR easier to read than GT (LLM smoothing signal). "
|
| 236 |
-
"No alignment required."
|
| 237 |
-
),
|
| 238 |
-
higher_is_better=False,
|
| 239 |
-
tags={"text", "readability", "over_normalization"},
|
| 240 |
-
)
|
| 241 |
-
def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
|
| 242 |
-
return flesch_delta(reference, hypothesis, lang="en")
|
| 243 |
-
|
| 244 |
|
| 245 |
-
|
| 246 |
-
"flesch_score",
|
| 247 |
-
"flesch_delta",
|
| 248 |
-
"count_words",
|
| 249 |
-
"count_sentences",
|
| 250 |
-
"count_syllables",
|
| 251 |
-
"count_syllables_word",
|
| 252 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.readability`` vers
|
| 5 |
+
``picarones.evaluation.metrics.readability`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.readability est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.readability à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.readability import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,114 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Le ``flesch_delta`` mesure la différence de lisibilité entre la
|
| 9 |
-
GT et la sortie OCR. Un score positif signale une *over-
|
| 10 |
-
normalisation* typique des LLM/VLM qui modernisent un texte
|
| 11 |
-
ancien (le Flesch monte parce que les mots sont plus simples) ;
|
| 12 |
-
un score négatif signale une dégradation OCR brutale.
|
| 13 |
-
|
| 14 |
-
Cette métrique est calculée **automatiquement** par le runner
|
| 15 |
-
sur chaque document, agrégée par moteur, et présentée dans le
|
| 16 |
-
rapport.
|
| 17 |
-
|
| 18 |
-
Adaptive masking
|
| 19 |
-
----------------
|
| 20 |
-
On ne calcule que si la GT contient ≥ 5 mots — en dessous, le
|
| 21 |
-
Flesch est trop instable pour être informatif.
|
| 22 |
-
|
| 23 |
-
Langue
|
| 24 |
-
------
|
| 25 |
-
Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour
|
| 26 |
-
les corpus mixtes, l'utilisateur peut passer une langue
|
| 27 |
-
explicite à l'orchestrateur.
|
| 28 |
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
-
import
|
| 33 |
-
import statistics
|
| 34 |
-
from typing import Iterable, Optional
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
)
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
_MIN_WORDS_FOR_FLESCH = 5
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def compute_readability_metrics(
|
| 50 |
-
reference: Optional[str],
|
| 51 |
-
hypothesis: Optional[str],
|
| 52 |
-
*,
|
| 53 |
-
lang: Language = "fr",
|
| 54 |
-
) -> Optional[dict]:
|
| 55 |
-
"""Calcule le delta Flesch d'un document avec adaptive masking.
|
| 56 |
-
|
| 57 |
-
Retourne ``None`` si la GT contient moins de
|
| 58 |
-
``_MIN_WORDS_FOR_FLESCH`` mots.
|
| 59 |
-
"""
|
| 60 |
-
ref = reference or ""
|
| 61 |
-
n_ref_words = count_words(ref)
|
| 62 |
-
if n_ref_words < _MIN_WORDS_FOR_FLESCH:
|
| 63 |
-
return None
|
| 64 |
-
hyp = hypothesis or ""
|
| 65 |
-
flesch_ref = flesch_score(ref, lang=lang)
|
| 66 |
-
flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None
|
| 67 |
-
delta = (
|
| 68 |
-
flesch_delta(ref, hyp, lang=lang) if hyp else None
|
| 69 |
-
)
|
| 70 |
-
return {
|
| 71 |
-
"lang": lang,
|
| 72 |
-
"flesch_reference": flesch_ref,
|
| 73 |
-
"flesch_hypothesis": flesch_hyp,
|
| 74 |
-
"flesch_delta": delta,
|
| 75 |
-
"n_words_reference": n_ref_words,
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def aggregate_readability_metrics(
|
| 80 |
-
per_doc: Iterable[Optional[dict]],
|
| 81 |
-
) -> Optional[dict]:
|
| 82 |
-
"""Agrège : moyenne/médiane des deltas + part de docs
|
| 83 |
-
« over-normalisés » (delta > +5 points).
|
| 84 |
-
"""
|
| 85 |
-
docs = [d for d in per_doc if d]
|
| 86 |
-
if not docs:
|
| 87 |
-
return None
|
| 88 |
-
deltas = [
|
| 89 |
-
float(d["flesch_delta"]) for d in docs
|
| 90 |
-
if isinstance(d.get("flesch_delta"), (int, float))
|
| 91 |
-
]
|
| 92 |
-
if not deltas:
|
| 93 |
-
return None
|
| 94 |
-
over_norm = sum(1 for d in deltas if d > 5.0)
|
| 95 |
-
under_norm = sum(1 for d in deltas if d < -5.0)
|
| 96 |
-
lang = docs[0].get("lang") or "fr"
|
| 97 |
-
return {
|
| 98 |
-
"lang": lang,
|
| 99 |
-
"n_docs": len(docs),
|
| 100 |
-
"n_docs_with_delta": len(deltas),
|
| 101 |
-
"delta_mean": statistics.fmean(deltas),
|
| 102 |
-
"delta_median": statistics.median(deltas),
|
| 103 |
-
"delta_min": min(deltas),
|
| 104 |
-
"delta_max": max(deltas),
|
| 105 |
-
"n_over_normalized": over_norm,
|
| 106 |
-
"n_under_normalized": under_norm,
|
| 107 |
-
"over_normalized_rate": over_norm / len(deltas),
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
__all__ = [
|
| 112 |
-
"compute_readability_metrics",
|
| 113 |
-
"aggregate_readability_metrics",
|
| 114 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.readability_hooks`` vers
|
| 5 |
+
``picarones.evaluation.metrics.readability_hooks`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.readability_hooks est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.readability_hooks à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
| 19 |
)
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.readability_hooks import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,196 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
paroissial complexe, le **classement des moteurs en CER** peut être
|
| 9 |
-
trompeur : un moteur peut avoir un excellent CER caractère et un
|
| 10 |
-
**ordre de lecture catastrophique**. Le résultat est inutilisable
|
| 11 |
-
pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
|
| 12 |
-
une narration linéaire.
|
| 13 |
-
|
| 14 |
-
La métrique standard est définie par Antonacopoulos et al. dans
|
| 15 |
-
ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
|
| 16 |
-
ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
|
| 17 |
-
``b`` dans la GT :
|
| 18 |
-
|
| 19 |
-
- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
|
| 20 |
-
- **FN** si la paire est manquante (régions absentes ou ordre
|
| 21 |
-
inversé) côté hypothèse,
|
| 22 |
-
- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
|
| 23 |
-
la GT n'a pas cet ordre (régions hallucinées ou inversion).
|
| 24 |
-
|
| 25 |
-
Le F1 est la moyenne harmonique des deux.
|
| 26 |
-
|
| 27 |
-
Stratégie de découpage
|
| 28 |
-
----------------------
|
| 29 |
-
Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
|
| 30 |
-
(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
|
| 31 |
-
deux listes ordonnées d'IDs de régions (typiquement extraites de
|
| 32 |
-
ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
|
| 33 |
-
suivent dans des sprints dédiés.
|
| 34 |
-
|
| 35 |
-
Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
|
| 36 |
-
``ReadingOrderGT.region_order`` est exactement le format attendu.
|
| 37 |
-
|
| 38 |
-
Convention sur les régions
|
| 39 |
-
--------------------------
|
| 40 |
-
- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
|
| 41 |
-
- Les **doublons** sont ignorés au calcul des paires ordonnées
|
| 42 |
-
(chaque ID compte une fois par séquence).
|
| 43 |
-
- Une région présente dans la GT mais absente de l'hypothèse
|
| 44 |
-
contribue aux paires FN.
|
| 45 |
-
- Une région présente dans l'hypothèse mais absente de la GT
|
| 46 |
-
contribue aux paires FP.
|
| 47 |
-
- Si une séquence a < 2 régions distinctes, aucune paire n'est
|
| 48 |
-
émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
|
| 49 |
-
séquences soient identiques.
|
| 50 |
"""
|
| 51 |
|
| 52 |
from __future__ import annotations
|
| 53 |
|
| 54 |
-
import
|
| 55 |
-
from itertools import combinations
|
| 56 |
-
from typing import Iterable
|
| 57 |
-
|
| 58 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 59 |
-
from picarones.domain.artifacts import ArtifactType
|
| 60 |
-
|
| 61 |
-
logger = logging.getLogger(__name__)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
-
# Helpers
|
| 66 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
|
| 70 |
-
"""Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
|
| 71 |
-
précède strictement ``b`` dans ``sequence``.
|
| 72 |
-
|
| 73 |
-
Doublons : chaque ID est traité une seule fois (première occurrence
|
| 74 |
-
dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
|
| 75 |
-
des IDs uniques.
|
| 76 |
-
"""
|
| 77 |
-
seen: list[str] = []
|
| 78 |
-
seen_set: set[str] = set()
|
| 79 |
-
for r in sequence:
|
| 80 |
-
if r not in seen_set:
|
| 81 |
-
seen.append(r)
|
| 82 |
-
seen_set.add(r)
|
| 83 |
-
return set(combinations(seen, 2))
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _normalize_input(value: Iterable[str] | None) -> list[str]:
|
| 87 |
-
"""Coerce une entrée en list[str], en filtrant les valeurs vides."""
|
| 88 |
-
if value is None:
|
| 89 |
-
return []
|
| 90 |
-
return [str(v) for v in value if v is not None and str(v).strip()]
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def compute_reading_order_metrics(
|
| 99 |
-
reference_order: Iterable[str] | None,
|
| 100 |
-
hypothesis_order: Iterable[str] | None,
|
| 101 |
-
) -> dict:
|
| 102 |
-
"""Calcule precision / recall / F1 sur l'ordre relatif des régions.
|
| 103 |
-
|
| 104 |
-
Parameters
|
| 105 |
-
----------
|
| 106 |
-
reference_order:
|
| 107 |
-
Séquence ordonnée d'IDs de régions issue de la GT (typiquement
|
| 108 |
-
``ReadingOrderGT.region_order`` du Sprint 32).
|
| 109 |
-
hypothesis_order:
|
| 110 |
-
Séquence ordonnée d'IDs de régions produite par un moteur
|
| 111 |
-
OCR/HTR ou un reconstructeur ALTO.
|
| 112 |
-
|
| 113 |
-
Returns
|
| 114 |
-
-------
|
| 115 |
-
dict
|
| 116 |
-
``{"precision", "recall", "f1", "true_positives",
|
| 117 |
-
"false_positives", "false_negatives", "n_ref_pairs",
|
| 118 |
-
"n_hyp_pairs", "common_regions", "ref_only_regions",
|
| 119 |
-
"hyp_only_regions"}``.
|
| 120 |
-
|
| 121 |
-
Comportements aux bornes
|
| 122 |
-
------------------------
|
| 123 |
-
- Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0.
|
| 124 |
-
- Ordre strictement inversé → F1 = 0.0 (toutes les paires
|
| 125 |
-
relatives sont fausses).
|
| 126 |
-
- Une séquence vide vs une séquence non vide → F1 = 0.0.
|
| 127 |
-
- Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
|
| 128 |
-
(convention : on ne récompense pas l'absence).
|
| 129 |
-
"""
|
| 130 |
-
ref = _normalize_input(reference_order)
|
| 131 |
-
hyp = _normalize_input(hypothesis_order)
|
| 132 |
-
|
| 133 |
-
ref_pairs = _ordered_pairs(ref)
|
| 134 |
-
hyp_pairs = _ordered_pairs(hyp)
|
| 135 |
-
|
| 136 |
-
tp = len(ref_pairs & hyp_pairs)
|
| 137 |
-
fn = len(ref_pairs - hyp_pairs)
|
| 138 |
-
fp = len(hyp_pairs - ref_pairs)
|
| 139 |
-
|
| 140 |
-
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 141 |
-
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 142 |
-
f1 = (
|
| 143 |
-
2 * precision * recall / (precision + recall)
|
| 144 |
-
if (precision + recall) > 0
|
| 145 |
-
else 0.0
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
ref_set = set(ref)
|
| 149 |
-
hyp_set = set(hyp)
|
| 150 |
-
return {
|
| 151 |
-
"precision": precision,
|
| 152 |
-
"recall": recall,
|
| 153 |
-
"f1": f1,
|
| 154 |
-
"true_positives": tp,
|
| 155 |
-
"false_positives": fp,
|
| 156 |
-
"false_negatives": fn,
|
| 157 |
-
"n_ref_pairs": len(ref_pairs),
|
| 158 |
-
"n_hyp_pairs": len(hyp_pairs),
|
| 159 |
-
"common_regions": sorted(ref_set & hyp_set),
|
| 160 |
-
"ref_only_regions": sorted(ref_set - hyp_set),
|
| 161 |
-
"hyp_only_regions": sorted(hyp_set - ref_set),
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 167 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
@register_metric(
|
| 171 |
-
name="reading_order_f1",
|
| 172 |
-
input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
|
| 173 |
-
description=(
|
| 174 |
-
"F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
|
| 175 |
-
"Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
|
| 176 |
-
"la GT, vérifie que a précède aussi b dans l'hypothèse."
|
| 177 |
-
),
|
| 178 |
-
higher_is_better=True,
|
| 179 |
-
tags={"structure", "icdar", "alto", "page"},
|
| 180 |
)
|
| 181 |
-
def reading_order_f1(
|
| 182 |
-
reference: Iterable[str] | None,
|
| 183 |
-
hypothesis: Iterable[str] | None,
|
| 184 |
-
) -> float:
|
| 185 |
-
"""Raccourci : retourne uniquement le F1 global.
|
| 186 |
-
|
| 187 |
-
Pour les détails par paire (TP/FP/FN, régions communes, etc.),
|
| 188 |
-
appeler ``compute_reading_order_metrics`` directement.
|
| 189 |
-
"""
|
| 190 |
-
return compute_reading_order_metrics(reference, hypothesis)["f1"]
|
| 191 |
-
|
| 192 |
|
| 193 |
-
|
| 194 |
-
"compute_reading_order_metrics",
|
| 195 |
-
"reading_order_f1",
|
| 196 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.reading_order`` vers
|
| 5 |
+
``picarones.evaluation.metrics.reading_order`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.reading_order est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.reading_order à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.reading_order import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
@@ -1,225 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
un usage *recherche plein-texte* (ce que font Elastic, Solr en
|
| 9 |
-
mode fuzzy, ou la recherche full-text de Gallica), la question
|
| 10 |
-
réelle est :
|
| 11 |
-
|
| 12 |
-
*« Combien de mots de ma GT sont retrouvables dans la
|
| 13 |
-
sortie OCR, à orthographe approchée près ? »*
|
| 14 |
-
|
| 15 |
-
Un CER de 8 % peut donner 95 % de findability si les erreurs
|
| 16 |
-
sont concentrées sur des caractères non-significatifs ou sur
|
| 17 |
-
quelques mots aberrants ; à l'inverse, 4 % de CER mais
|
| 18 |
-
distribué sur tous les noms propres rend le corpus inutilisable
|
| 19 |
-
pour l'indexation prosopographique.
|
| 20 |
-
|
| 21 |
-
Méthode
|
| 22 |
-
-------
|
| 23 |
-
Pour chaque token GT, on regarde s'il existe au moins un token
|
| 24 |
-
hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
|
| 25 |
-
2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
|
| 26 |
-
caractères). Le **rappel** est la proportion de tokens GT
|
| 27 |
-
ainsi retrouvés.
|
| 28 |
-
|
| 29 |
-
Multiplicité
|
| 30 |
-
------------
|
| 31 |
-
Si la GT contient *« le »* deux fois et l'hypothèse une fois,
|
| 32 |
-
seul un token GT est compté comme retrouvé (alignement
|
| 33 |
-
multi-set, comme ``rare_token_recall`` Sprint 71).
|
| 34 |
-
|
| 35 |
-
Sortie
|
| 36 |
-
------
|
| 37 |
-
``compute_searchability(reference, hypothesis)`` retourne
|
| 38 |
-
``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
|
| 39 |
-
|
| 40 |
-
Limites documentées
|
| 41 |
-
-------------------
|
| 42 |
-
- Tokenisation par split sur whitespace (cohérent avec le reste
|
| 43 |
-
du codebase). Pas de stemming ni de lemmatisation.
|
| 44 |
-
- Levenshtein non pondéré — substitution = insertion = suppression
|
| 45 |
-
= 1. Pour un poids différent (par ex. faute classique
|
| 46 |
-
diacritique = 0,5), passer une fonction custom.
|
| 47 |
-
- Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
|
| 48 |
-
similarité sémantique, voir des modules futurs (BERTScore).
|
| 49 |
"""
|
| 50 |
|
| 51 |
from __future__ import annotations
|
| 52 |
|
| 53 |
-
import
|
| 54 |
-
from typing import Optional
|
| 55 |
-
|
| 56 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 57 |
-
from picarones.domain.artifacts import ArtifactType
|
| 58 |
-
|
| 59 |
-
logger = logging.getLogger(__name__)
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
-
# Tokenisation et distance d'édition
|
| 64 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _split_words(text: Optional[str]) -> list[str]:
|
| 68 |
-
"""Tokenisation par whitespace — cohérent avec
|
| 69 |
-
``lexical_modernization.py``, ``rare_tokens.py``, etc."""
|
| 70 |
-
if not text:
|
| 71 |
-
return []
|
| 72 |
-
return text.split()
|
| 73 |
-
|
| 74 |
|
| 75 |
-
|
| 76 |
-
"
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
if a == b:
|
| 81 |
-
return 0
|
| 82 |
-
if len(a) < len(b):
|
| 83 |
-
a, b = b, a
|
| 84 |
-
# |a| ≥ |b|
|
| 85 |
-
if not b:
|
| 86 |
-
return len(a)
|
| 87 |
-
previous = list(range(len(b) + 1))
|
| 88 |
-
for i, ca in enumerate(a, start=1):
|
| 89 |
-
current = [i] + [0] * len(b)
|
| 90 |
-
for j, cb in enumerate(b, start=1):
|
| 91 |
-
cost = 0 if ca == cb else 1
|
| 92 |
-
current[j] = min(
|
| 93 |
-
current[j - 1] + 1, # insertion
|
| 94 |
-
previous[j] + 1, # suppression
|
| 95 |
-
previous[j - 1] + cost, # substitution
|
| 96 |
-
)
|
| 97 |
-
previous = current
|
| 98 |
-
return previous[-1]
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 102 |
-
# Calcul principal
|
| 103 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def compute_searchability(
|
| 107 |
-
reference: Optional[str],
|
| 108 |
-
hypothesis: Optional[str],
|
| 109 |
-
*,
|
| 110 |
-
max_distance: int = 2,
|
| 111 |
-
case_sensitive: bool = False,
|
| 112 |
-
) -> dict:
|
| 113 |
-
"""Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
|
| 114 |
-
|
| 115 |
-
Parameters
|
| 116 |
-
----------
|
| 117 |
-
reference, hypothesis:
|
| 118 |
-
Transcriptions GT et OCR.
|
| 119 |
-
max_distance:
|
| 120 |
-
Seuil de distance de Levenshtein (≤ pour considérer un
|
| 121 |
-
token comme retrouvé). Défaut 2 — convention
|
| 122 |
-
``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
|
| 123 |
-
case_sensitive:
|
| 124 |
-
Si False (défaut), casse insensible côté match — la
|
| 125 |
-
sortie ``missed_tokens`` reste avec la casse GT
|
| 126 |
-
originale.
|
| 127 |
-
|
| 128 |
-
Returns
|
| 129 |
-
-------
|
| 130 |
-
dict
|
| 131 |
-
``{
|
| 132 |
-
"n_gt_tokens": int,
|
| 133 |
-
"n_searchable": int,
|
| 134 |
-
"recall": float | None, # None si n_gt_tokens == 0
|
| 135 |
-
"missed_tokens": list[str],
|
| 136 |
-
"max_distance": int,
|
| 137 |
-
}``
|
| 138 |
-
"""
|
| 139 |
-
if max_distance < 0:
|
| 140 |
-
raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
|
| 141 |
-
gt_tokens = _split_words(reference)
|
| 142 |
-
hyp_tokens = _split_words(hypothesis)
|
| 143 |
-
n_gt = len(gt_tokens)
|
| 144 |
-
if n_gt == 0:
|
| 145 |
-
return {
|
| 146 |
-
"n_gt_tokens": 0,
|
| 147 |
-
"n_searchable": 0,
|
| 148 |
-
"recall": None,
|
| 149 |
-
"missed_tokens": [],
|
| 150 |
-
"max_distance": max_distance,
|
| 151 |
-
}
|
| 152 |
-
# Multi-set : un token hypothèse ne peut servir qu'une fois.
|
| 153 |
-
# Tri par longueur croissante pour matcher d'abord les
|
| 154 |
-
# tokens GT les plus courts (où ε-fautes sont plus rares).
|
| 155 |
-
if case_sensitive:
|
| 156 |
-
gt_for_match = list(gt_tokens)
|
| 157 |
-
hyp_for_match = list(hyp_tokens)
|
| 158 |
-
else:
|
| 159 |
-
gt_for_match = [t.lower() for t in gt_tokens]
|
| 160 |
-
hyp_for_match = [t.lower() for t in hyp_tokens]
|
| 161 |
-
|
| 162 |
-
hyp_used = [False] * len(hyp_for_match)
|
| 163 |
-
n_searchable = 0
|
| 164 |
-
missed: list[str] = []
|
| 165 |
-
for gi, gt_match in enumerate(gt_for_match):
|
| 166 |
-
# Court-circuit si match exact disponible
|
| 167 |
-
best_idx = -1
|
| 168 |
-
best_dist = max_distance + 1
|
| 169 |
-
for hi, used in enumerate(hyp_used):
|
| 170 |
-
if used:
|
| 171 |
-
continue
|
| 172 |
-
hyp_match = hyp_for_match[hi]
|
| 173 |
-
# Court-circuit longueur (Levenshtein ≥ |Δlen|)
|
| 174 |
-
if abs(len(hyp_match) - len(gt_match)) > max_distance:
|
| 175 |
-
continue
|
| 176 |
-
d = levenshtein_distance(gt_match, hyp_match)
|
| 177 |
-
if d < best_dist:
|
| 178 |
-
best_dist = d
|
| 179 |
-
best_idx = hi
|
| 180 |
-
if d == 0:
|
| 181 |
-
break # match exact, inutile de chercher mieux
|
| 182 |
-
if best_idx >= 0 and best_dist <= max_distance:
|
| 183 |
-
hyp_used[best_idx] = True
|
| 184 |
-
n_searchable += 1
|
| 185 |
-
else:
|
| 186 |
-
missed.append(gt_tokens[gi])
|
| 187 |
-
recall = n_searchable / n_gt
|
| 188 |
-
return {
|
| 189 |
-
"n_gt_tokens": n_gt,
|
| 190 |
-
"n_searchable": n_searchable,
|
| 191 |
-
"recall": recall,
|
| 192 |
-
"missed_tokens": missed,
|
| 193 |
-
"max_distance": max_distance,
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 198 |
-
# Enregistrement registre typé (Sprint 34)
|
| 199 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
@register_metric(
|
| 203 |
-
name="searchability_recall",
|
| 204 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 205 |
-
description=(
|
| 206 |
-
"Recherchabilité fuzzy : proportion de tokens GT retrouvés "
|
| 207 |
-
"dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
|
| 208 |
-
"la qualité pour la recherche plein-texte (Elastic, Solr)."
|
| 209 |
-
),
|
| 210 |
)
|
| 211 |
-
def searchability_recall_metric(reference: str, hypothesis: str) -> float:
|
| 212 |
-
"""Variante scalaire pour le registre typé : retourne le
|
| 213 |
-
rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
|
| 214 |
-
cohérente avec rare_token_recall Sprint 71).
|
| 215 |
-
"""
|
| 216 |
-
result = compute_searchability(reference, hypothesis)
|
| 217 |
-
recall = result.get("recall")
|
| 218 |
-
return 0.0 if recall is None else recall
|
| 219 |
-
|
| 220 |
|
| 221 |
-
|
| 222 |
-
"levenshtein_distance",
|
| 223 |
-
"compute_searchability",
|
| 224 |
-
"searchability_recall_metric",
|
| 225 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.searchability`` vers
|
| 5 |
+
``picarones.evaluation.metrics.searchability`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.searchability est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.searchability à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.searchability import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,81 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Adaptive masking
|
| 10 |
-
----------------
|
| 11 |
-
Comme pour les modules philologiques (Sprint 61), on ne calcule
|
| 12 |
-
le rappel que si la GT contient au moins un token — pas de
|
| 13 |
-
calcul vide qui produirait du bruit dans le rapport.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
-
import
|
| 19 |
-
from typing import Iterable, Optional
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def compute_searchability_metrics(
|
| 30 |
-
reference: Optional[str],
|
| 31 |
-
hypothesis: Optional[str],
|
| 32 |
-
*,
|
| 33 |
-
max_distance: int = 2,
|
| 34 |
-
) -> Optional[dict]:
|
| 35 |
-
"""Recherchabilité d'un document (adaptive).
|
| 36 |
-
|
| 37 |
-
Retourne ``None`` si la GT est vide ou ne contient aucun
|
| 38 |
-
token — ce qui déclenche l'adaptive masking côté HTML.
|
| 39 |
-
"""
|
| 40 |
-
if not reference or not _split_words(reference):
|
| 41 |
-
return None
|
| 42 |
-
return compute_searchability(
|
| 43 |
-
reference, hypothesis or "", max_distance=max_distance,
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def aggregate_searchability_metrics(
|
| 48 |
-
per_doc: Iterable[Optional[dict]],
|
| 49 |
-
) -> Optional[dict]:
|
| 50 |
-
"""Agrège les métriques par-doc en un score corpus-wide.
|
| 51 |
-
|
| 52 |
-
Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
|
| 53 |
-
et on recalcule un rappel **micro** (cohérent avec ECE/MCE
|
| 54 |
-
Sprint 39 et NER Sprint 38).
|
| 55 |
-
"""
|
| 56 |
-
docs = [d for d in per_doc if d]
|
| 57 |
-
if not docs:
|
| 58 |
-
return None
|
| 59 |
-
n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
|
| 60 |
-
n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
|
| 61 |
-
if n_gt == 0:
|
| 62 |
-
return None
|
| 63 |
-
# On garde l'union des missed_tokens (capped pour ne pas
|
| 64 |
-
# exploser le JSON sur de gros corpus)
|
| 65 |
-
missed: list[str] = []
|
| 66 |
-
for d in docs:
|
| 67 |
-
missed.extend(d.get("missed_tokens") or [])
|
| 68 |
-
return {
|
| 69 |
-
"n_docs": len(docs),
|
| 70 |
-
"n_gt_tokens": n_gt,
|
| 71 |
-
"n_searchable": n_search,
|
| 72 |
-
"recall": n_search / n_gt,
|
| 73 |
-
"missed_tokens_sample": missed[:50],
|
| 74 |
-
"max_distance": docs[0].get("max_distance", 2),
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
__all__ = [
|
| 79 |
-
"compute_searchability_metrics",
|
| 80 |
-
"aggregate_searchability_metrics",
|
| 81 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.searchability_hooks`` vers
|
| 5 |
+
``picarones.evaluation.metrics.searchability_hooks`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.searchability_hooks est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.searchability_hooks à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
| 19 |
)
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.searchability_hooks import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,233 +1,21 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sprint
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
n'est pas seulement *« quel CER global ? »* mais *« quels caractères
|
| 9 |
-
historiques ce moteur restitue-t-il fidèlement ? »*. Une phrase de
|
| 10 |
-
synthèse actionnable en un coup d'œil :
|
| 11 |
-
|
| 12 |
-
> *« GPT-4o restitue 95 % du Latin de Base mais seulement 12 % des
|
| 13 |
-
> formes de présentation latine (fi, fl, ſ…). »*
|
| 14 |
-
|
| 15 |
-
Ce module agrège la précision par **bloc Unicode standard** (Latin de
|
| 16 |
-
Base, Latin Étendu A/B, Diacritiques combinants, Présentation latine,
|
| 17 |
-
etc.). Le résultat permet directement de choisir un moteur selon le
|
| 18 |
-
type de glyphes attendus dans le corpus.
|
| 19 |
-
|
| 20 |
-
Stratégie de découpage
|
| 21 |
-
----------------------
|
| 22 |
-
Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
|
| 23 |
-
(Sprint 53), Layout F1 (Sprint 54) : couche de calcul pure d'abord.
|
| 24 |
-
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
|
| 25 |
-
|
| 26 |
-
Convention d'alignement
|
| 27 |
-
-----------------------
|
| 28 |
-
Alignement caractère par caractère via ``difflib.SequenceMatcher`` :
|
| 29 |
-
|
| 30 |
-
- chaque caractère de la GT est classé dans son bloc Unicode,
|
| 31 |
-
- pour chaque position GT couverte par un opcode ``equal`` →
|
| 32 |
-
+1 dans ``correct[bloc]``,
|
| 33 |
-
- pour chaque position GT non couverte (replace, delete) → +0,
|
| 34 |
-
- les insertions côté hypothèse (caractères absents de la GT) ne
|
| 35 |
-
contribuent à aucun bloc — elles sont visibles uniquement via le
|
| 36 |
-
CER global.
|
| 37 |
-
|
| 38 |
-
Précision par bloc = ``correct[bloc] / total[bloc]``.
|
| 39 |
-
|
| 40 |
-
Liste des blocs reconnus
|
| 41 |
-
------------------------
|
| 42 |
-
Centrée sur les glyphes courants des corpus patrimoniaux européens.
|
| 43 |
-
Tout caractère hors de cette table est classé dans ``"Other"``
|
| 44 |
-
(garantit une couverture exhaustive : ``sum(total[bloc]) ==
|
| 45 |
-
len(GT)``).
|
| 46 |
"""
|
| 47 |
|
| 48 |
from __future__ import annotations
|
| 49 |
|
| 50 |
-
import
|
| 51 |
-
from difflib import SequenceMatcher
|
| 52 |
-
from typing import Optional
|
| 53 |
-
|
| 54 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 55 |
-
from picarones.domain.artifacts import ArtifactType
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
-
|
| 64 |
-
# Triplets (nom, code_point_min, code_point_max) — bornes inclusives.
|
| 65 |
-
# Centré sur les blocs pertinents pour les corpus patrimoniaux
|
| 66 |
-
# européens (manuscrits médiévaux, imprimés anciens, archives).
|
| 67 |
-
# Source : https://www.unicode.org/charts/
|
| 68 |
-
_UNICODE_BLOCKS: tuple[tuple[str, int, int], ...] = (
|
| 69 |
-
("Basic Latin", 0x0000, 0x007F),
|
| 70 |
-
("Latin-1 Supplement", 0x0080, 0x00FF),
|
| 71 |
-
("Latin Extended-A", 0x0100, 0x017F),
|
| 72 |
-
("Latin Extended-B", 0x0180, 0x024F),
|
| 73 |
-
("IPA Extensions", 0x0250, 0x02AF),
|
| 74 |
-
("Spacing Modifier Letters", 0x02B0, 0x02FF),
|
| 75 |
-
("Combining Diacritical Marks", 0x0300, 0x036F),
|
| 76 |
-
("Greek and Coptic", 0x0370, 0x03FF),
|
| 77 |
-
("Cyrillic", 0x0400, 0x04FF),
|
| 78 |
-
("Hebrew", 0x0590, 0x05FF),
|
| 79 |
-
("Arabic", 0x0600, 0x06FF),
|
| 80 |
-
("General Punctuation", 0x2000, 0x206F),
|
| 81 |
-
("Superscripts and Subscripts", 0x2070, 0x209F),
|
| 82 |
-
("Currency Symbols", 0x20A0, 0x20CF),
|
| 83 |
-
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
|
| 84 |
-
("Latin Extended Additional", 0x1E00, 0x1EFF),
|
| 85 |
-
("Latin Extended-C", 0x2C60, 0x2C7F),
|
| 86 |
-
("Latin Extended-D", 0xA720, 0xA7FF), # médiéval
|
| 87 |
-
("Latin Extended-E", 0xAB30, 0xAB6F),
|
| 88 |
-
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F), # fi, fl, ff…
|
| 89 |
-
("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
|
| 90 |
-
("Medieval Unicode Font Initiative (MUFI)", 0xE000, 0xF8FF), # PUA
|
| 91 |
)
|
| 92 |
|
| 93 |
-
|
| 94 |
-
def get_block(char: str) -> str:
|
| 95 |
-
"""Retourne le nom du bloc Unicode contenant ``char``.
|
| 96 |
-
|
| 97 |
-
Pour un caractère hors des blocs listés (ex. CJK, emoji, etc.),
|
| 98 |
-
retourne ``"Other"``. Pour une chaîne multi-caractères, on
|
| 99 |
-
considère uniquement le premier code-point.
|
| 100 |
-
"""
|
| 101 |
-
if not char:
|
| 102 |
-
return "Other"
|
| 103 |
-
cp = ord(char[0])
|
| 104 |
-
for name, lo, hi in _UNICODE_BLOCKS:
|
| 105 |
-
if lo <= cp <= hi:
|
| 106 |
-
return name
|
| 107 |
-
return "Other"
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 111 |
-
# Calcul d'accuracy par bloc
|
| 112 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def compute_unicode_block_accuracy(
|
| 116 |
-
reference: Optional[str],
|
| 117 |
-
hypothesis: Optional[str],
|
| 118 |
-
) -> dict:
|
| 119 |
-
"""Calcule la précision (recall caractère) par bloc Unicode.
|
| 120 |
-
|
| 121 |
-
Parameters
|
| 122 |
-
----------
|
| 123 |
-
reference:
|
| 124 |
-
Texte GT. Chaque caractère est classé dans son bloc Unicode.
|
| 125 |
-
hypothesis:
|
| 126 |
-
Texte produit par le moteur OCR.
|
| 127 |
-
|
| 128 |
-
Returns
|
| 129 |
-
-------
|
| 130 |
-
dict
|
| 131 |
-
``{
|
| 132 |
-
"per_block": {
|
| 133 |
-
bloc_name: {
|
| 134 |
-
"correct": int, # caractères GT correctement restitués
|
| 135 |
-
"total": int, # caractères GT du bloc
|
| 136 |
-
"accuracy": float, # correct / total ∈ [0, 1]
|
| 137 |
-
},
|
| 138 |
-
...
|
| 139 |
-
},
|
| 140 |
-
"global_accuracy": float, # somme(correct) / somme(total)
|
| 141 |
-
"n_chars_reference": int,
|
| 142 |
-
}``
|
| 143 |
-
|
| 144 |
-
Cas dégénérés
|
| 145 |
-
-------------
|
| 146 |
-
- GT vide → ``per_block`` vide, ``global_accuracy = 0.0``,
|
| 147 |
-
``n_chars_reference = 0``.
|
| 148 |
-
- hypothèse vide + GT non-vide → tous les blocs à
|
| 149 |
-
``accuracy = 0``.
|
| 150 |
-
- GT et hyp identiques → tous les blocs à ``accuracy = 1``.
|
| 151 |
-
"""
|
| 152 |
-
ref = reference or ""
|
| 153 |
-
hyp = hypothesis or ""
|
| 154 |
-
n_ref = len(ref)
|
| 155 |
-
|
| 156 |
-
if n_ref == 0:
|
| 157 |
-
return {
|
| 158 |
-
"per_block": {},
|
| 159 |
-
"global_accuracy": 0.0,
|
| 160 |
-
"n_chars_reference": 0,
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
# 1. Compter le total par bloc
|
| 164 |
-
total: dict[str, int] = {}
|
| 165 |
-
for ch in ref:
|
| 166 |
-
b = get_block(ch)
|
| 167 |
-
total[b] = total.get(b, 0) + 1
|
| 168 |
-
|
| 169 |
-
# 2. Aligner par opcodes de SequenceMatcher
|
| 170 |
-
# Pour chaque opcode ``equal``, les positions ``i1..i2-1`` du GT
|
| 171 |
-
# sont correctement restituées → +1 par caractère dans son bloc.
|
| 172 |
-
correct: dict[str, int] = {b: 0 for b in total}
|
| 173 |
-
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
|
| 174 |
-
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
|
| 175 |
-
if op != "equal":
|
| 176 |
-
continue
|
| 177 |
-
for i in range(i1, i2):
|
| 178 |
-
b = get_block(ref[i])
|
| 179 |
-
correct[b] = correct.get(b, 0) + 1
|
| 180 |
-
|
| 181 |
-
per_block: dict[str, dict] = {}
|
| 182 |
-
for b in sorted(total):
|
| 183 |
-
n = total[b]
|
| 184 |
-
c = correct.get(b, 0)
|
| 185 |
-
per_block[b] = {
|
| 186 |
-
"correct": c,
|
| 187 |
-
"total": n,
|
| 188 |
-
"accuracy": c / n if n > 0 else 0.0,
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
n_correct_total = sum(d["correct"] for d in per_block.values())
|
| 192 |
-
return {
|
| 193 |
-
"per_block": per_block,
|
| 194 |
-
"global_accuracy": n_correct_total / n_ref,
|
| 195 |
-
"n_chars_reference": n_ref,
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def unicode_block_global_accuracy(
|
| 200 |
-
reference: Optional[str],
|
| 201 |
-
hypothesis: Optional[str],
|
| 202 |
-
) -> float:
|
| 203 |
-
"""Raccourci : retourne ``global_accuracy`` (fraction de
|
| 204 |
-
caractères GT correctement restitués)."""
|
| 205 |
-
return compute_unicode_block_accuracy(reference, hypothesis)["global_accuracy"]
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 209 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 210 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
@register_metric(
|
| 214 |
-
name="unicode_block_global_accuracy",
|
| 215 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 216 |
-
description=(
|
| 217 |
-
"Fraction de caractères GT correctement restitués par "
|
| 218 |
-
"l'OCR (alignement caractère par caractère via difflib). "
|
| 219 |
-
"Pour le détail par bloc Unicode (Latin de Base, Présentation "
|
| 220 |
-
"latine, etc.), utiliser compute_unicode_block_accuracy."
|
| 221 |
-
),
|
| 222 |
-
higher_is_better=True,
|
| 223 |
-
tags={"text", "unicode", "philology"},
|
| 224 |
-
)
|
| 225 |
-
def _registered_global_accuracy(reference: str, hypothesis: str) -> float:
|
| 226 |
-
return unicode_block_global_accuracy(reference, hypothesis)
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
__all__ = [
|
| 230 |
-
"get_block",
|
| 231 |
-
"compute_unicode_block_accuracy",
|
| 232 |
-
"unicode_block_global_accuracy",
|
| 233 |
-
]
|
|
|
|
| 1 |
+
"""Shim de compatibilité — métrique relocalisée.
|
| 2 |
|
| 3 |
+
Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
|
| 4 |
+
``picarones.measurements.unicode_blocks`` vers
|
| 5 |
+
``picarones.evaluation.metrics.unicode_blocks`` (couche canonique).
|
| 6 |
+
Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
|
| 7 |
+
et sera supprimé en 2.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"picarones.measurements.unicode_blocks est obsolète et sera supprimé en 2.0. "
|
| 16 |
+
"Utiliser picarones.evaluation.metrics.unicode_blocks à la place.",
|
| 17 |
+
DeprecationWarning,
|
| 18 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.unicode_blocks import * # noqa: F401, F403, E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -72,7 +72,7 @@ LEGACY_PACKAGES: tuple[str, ...] = (
|
|
| 72 |
#: :data:`LEGACY_PARITY` sans faire échouer le test. À diminuer
|
| 73 |
#: à chaque session de migration : on cible 0 quand le retrait
|
| 74 |
#: est complet.
|
| 75 |
-
BOOTSTRAP_BASELINE =
|
| 76 |
|
| 77 |
|
| 78 |
# ──────────────────────────────────────────────────────────────────
|
|
|
|
| 72 |
#: :data:`LEGACY_PARITY` sans faire échouer le test. À diminuer
|
| 73 |
#: à chaque session de migration : on cible 0 quand le retrait
|
| 74 |
#: est complet.
|
| 75 |
+
BOOTSTRAP_BASELINE = 30
|
| 76 |
|
| 77 |
|
| 78 |
# ──────────────────────────────────────────────────────────────────
|
|
@@ -76,6 +76,11 @@ TEST_ONLY_BASELINE: frozenset[str] = frozenset({
|
|
| 76 |
# production. Suppression / migration prévue en Sprint E
|
| 77 |
# (migration des hooks vers ``evaluation/metric_hooks/``).
|
| 78 |
"builtin_hooks",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
})
|
| 80 |
|
| 81 |
|
|
|
|
| 76 |
# production. Suppression / migration prévue en Sprint E
|
| 77 |
# (migration des hooks vers ``evaluation/metric_hooks/``).
|
| 78 |
"builtin_hooks",
|
| 79 |
+
# Sprint E.2 du plan v2.0 — module ``measurements.searchability``
|
| 80 |
+
# est devenu un shim après son déplacement vers
|
| 81 |
+
# ``evaluation/metrics/searchability``. Le shim garde son entrée
|
| 82 |
+
# ici pour que le scanner ne crie pas tant qu'il existe.
|
| 83 |
+
"searchability",
|
| 84 |
})
|
| 85 |
|
| 86 |
|
|
@@ -33,7 +33,7 @@ import pytest
|
|
| 33 |
|
| 34 |
from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
|
| 35 |
from picarones.domain.artifacts import ArtifactType
|
| 36 |
-
from picarones.
|
| 37 |
|
| 38 |
|
| 39 |
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
| 33 |
|
| 34 |
from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
|
| 35 |
from picarones.domain.artifacts import ArtifactType
|
| 36 |
+
from picarones.evaluation.metrics.ner import Entity, compute_ner_metrics, ner_f1
|
| 37 |
|
| 38 |
|
| 39 |
# ──────────────────────────────────────────────────────────────────────────
|
|
@@ -30,7 +30,7 @@ import pytest
|
|
| 30 |
|
| 31 |
from picarones.evaluation.metric_registry import select_metrics
|
| 32 |
from picarones.domain.artifacts import ArtifactType
|
| 33 |
-
from picarones.
|
| 34 |
count_sentences,
|
| 35 |
count_syllables,
|
| 36 |
count_syllables_word,
|
|
|
|
| 30 |
|
| 31 |
from picarones.evaluation.metric_registry import select_metrics
|
| 32 |
from picarones.domain.artifacts import ArtifactType
|
| 33 |
+
from picarones.evaluation.metrics.readability import (
|
| 34 |
count_sentences,
|
| 35 |
count_syllables,
|
| 36 |
count_syllables_word,
|
|
@@ -28,7 +28,7 @@ import pytest
|
|
| 28 |
|
| 29 |
from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
|
| 30 |
from picarones.domain.artifacts import ArtifactType
|
| 31 |
-
from picarones.
|
| 32 |
compute_reading_order_metrics,
|
| 33 |
reading_order_f1,
|
| 34 |
)
|
|
|
|
| 28 |
|
| 29 |
from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
|
| 30 |
from picarones.domain.artifacts import ArtifactType
|
| 31 |
+
from picarones.evaluation.metrics.reading_order import (
|
| 32 |
compute_reading_order_metrics,
|
| 33 |
reading_order_f1,
|
| 34 |
)
|
|
@@ -25,7 +25,7 @@ import pytest
|
|
| 25 |
|
| 26 |
from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
|
| 27 |
from picarones.domain.artifacts import ArtifactType
|
| 28 |
-
from picarones.
|
| 29 |
compute_unicode_block_accuracy,
|
| 30 |
get_block,
|
| 31 |
unicode_block_global_accuracy,
|
|
|
|
| 25 |
|
| 26 |
from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
|
| 27 |
from picarones.domain.artifacts import ArtifactType
|
| 28 |
+
from picarones.evaluation.metrics.unicode_blocks import (
|
| 29 |
compute_unicode_block_accuracy,
|
| 30 |
get_block,
|
| 31 |
unicode_block_global_accuracy,
|
|
@@ -23,7 +23,7 @@ Couvre :
|
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
-
from picarones.
|
| 27 |
BUILTIN_EQUIVALENCES,
|
| 28 |
EquivalenceRule,
|
| 29 |
apply_selected_equivalences,
|
|
|
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
+
from picarones.evaluation.metrics.equivalence_profile import (
|
| 27 |
BUILTIN_EQUIVALENCES,
|
| 28 |
EquivalenceRule,
|
| 29 |
apply_selected_equivalences,
|
|
@@ -23,7 +23,7 @@ from __future__ import annotations
|
|
| 23 |
|
| 24 |
import pytest
|
| 25 |
|
| 26 |
-
from picarones.
|
| 27 |
compute_searchability,
|
| 28 |
levenshtein_distance,
|
| 29 |
searchability_recall_metric,
|
|
|
|
| 23 |
|
| 24 |
import pytest
|
| 25 |
|
| 26 |
+
from picarones.evaluation.metrics.searchability import (
|
| 27 |
compute_searchability,
|
| 28 |
levenshtein_distance,
|
| 29 |
searchability_recall_metric,
|
|
@@ -18,7 +18,7 @@ from __future__ import annotations
|
|
| 18 |
import json
|
| 19 |
from pathlib import Path
|
| 20 |
|
| 21 |
-
from picarones.
|
| 22 |
aggregate_numerical_sequence_metrics,
|
| 23 |
compute_numerical_sequence_metrics_adaptive,
|
| 24 |
)
|
|
@@ -32,7 +32,7 @@ def _stub_metrics() -> MetricsResult:
|
|
| 32 |
wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
|
| 33 |
reference_length=0, hypothesis_length=0,
|
| 34 |
)
|
| 35 |
-
from picarones.
|
| 36 |
aggregate_searchability_metrics,
|
| 37 |
compute_searchability_metrics,
|
| 38 |
)
|
|
|
|
| 18 |
import json
|
| 19 |
from pathlib import Path
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.numerical_sequences_hooks import (
|
| 22 |
aggregate_numerical_sequence_metrics,
|
| 23 |
compute_numerical_sequence_metrics_adaptive,
|
| 24 |
)
|
|
|
|
| 32 |
wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
|
| 33 |
reference_length=0, hypothesis_length=0,
|
| 34 |
)
|
| 35 |
+
from picarones.evaluation.metrics.searchability_hooks import (
|
| 36 |
aggregate_searchability_metrics,
|
| 37 |
compute_searchability_metrics,
|
| 38 |
)
|
|
@@ -17,7 +17,7 @@ import json
|
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
from picarones.evaluation.metric_result import MetricsResult
|
| 20 |
-
from picarones.
|
| 21 |
aggregate_readability_metrics,
|
| 22 |
compute_readability_metrics,
|
| 23 |
)
|
|
|
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
from picarones.evaluation.metric_result import MetricsResult
|
| 20 |
+
from picarones.evaluation.metrics.readability_hooks import (
|
| 21 |
aggregate_readability_metrics,
|
| 22 |
compute_readability_metrics,
|
| 23 |
)
|