Spaces:
Running
Running
File size: 14,188 Bytes
f593a34 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | """Score d'expansion d'abréviations médiévales — Sprint 56.
Sprint 56 — A.II.3.2 du plan d'évolution 2026 (axe philologique).
Pourquoi ce module
------------------
Sur les manuscrits médiévaux (chartes, registres, copies de droit
canonique), les scribes utilisent intensivement des **signes
d'abréviation** : ``ꝑ`` (per/par), ``ꝓ`` (pro), ``ꝗ`` (qui),
``ꝙ`` (quia), ``ꝯ`` (con/-us), ``⁊`` (et), tilde combinant pour
``-en/-an``, etc.
Un OCR/HTR a deux comportements possibles face à ces signes :
1. **Préservation** : la forme abrégée est gardée telle quelle
(``ꝑ`` → ``ꝑ``). C'est le comportement attendu d'une
transcription **diplomatique** (édition critique).
2. **Développement** : le signe est remplacé par sa forme
développée (``ꝑ`` → ``per``). C'est le comportement attendu
d'une édition **modernisée**.
Une troisième possibilité — et c'est l'erreur qu'on cherche à
détecter : le signe est **mal restitué** (remplacé par un
caractère ASCII proche, supprimé, ou mal développé).
Ce module produit deux scores complémentaires :
- ``abbreviation_strict_score`` : taux d'abréviations GT dont la
**forme abrégée Unicode est préservée** dans l'OCR.
- ``abbreviation_expansion_score`` : taux d'abréviations GT dont
**soit** la forme abrégée, **soit** la forme développée
attendue, est présente dans l'OCR.
Le **ratio** des deux dit beaucoup sur la convention adoptée :
- ``strict ≈ expansion`` proche de 1 → le moteur est diplomatique
(préserve l'abrégé) ;
- ``strict << expansion`` → le moteur est modernisant (développe
systématiquement) ;
- les deux faibles → le moteur perd les abréviations (signal
d'erreur OCR).
Stratégie de découpage
----------------------
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
Layout F1 (54), Bloc Unicode (55) : couche de calcul pure d'abord.
Le câblage runner et la vue HTML suivent dans des sprints dédiés.
Limites documentées
-------------------
- L'alignement est **bag-of-occurrences** (proxy positionnel
simple) : on compte les occurrences GT et on vérifie leur
présence dans l'hyp. Pas d'alignement séquentiel rigoureux.
- La table d'abréviations couvre les signes les plus courants en
scriptura latine européenne (Capelli). Elle est extensible via
``ABBREVIATION_EXPANSIONS``.
- Pour les abréviations marquées par un **tilde combinant**
(``p̃``, ``q̃``), on détecte la séquence ``lettre + U+0303``.
Pas de gestion fine des polices Capelli/MUFI complètes.
"""
from __future__ import annotations
import logging
import re
import unicodedata
from typing import Optional
from picarones.core.metric_registry import register_metric
from picarones.core.modules import ArtifactType
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────
# Table d'expansions
# ──────────────────────────────────────────────────────────────────────────
# Signes d'abréviation latins médiévaux les plus courants.
# Source : Capelli, "Lexicon Abbreviaturarum" (1929) + MUFI.
#
# La clé est une chaîne (1 ou 2 code-points pour le cas tilde
# combinant) ; la valeur est la liste des expansions courantes
# acceptées (les détails varient selon la convention éditoriale,
# on accepte plusieurs formes).
ABBREVIATION_EXPANSIONS: dict[str, tuple[str, ...]] = {
"ꝑ": ("per", "par"), # U+A751
"ꝓ": ("pro",), # U+A753
"ꝗ": ("qui",), # U+A757
"ꝙ": ("quia",), # U+A759
"ꝯ": ("us", "con"), # U+A76F
"⁊": ("et",), # U+204A "et" tironien
"ꝝ": ("rum",), # U+A75D
"ꝫ": ("et",), # U+A76B
"ꝭ": ("is",), # U+A76D
# Tilde combinant après lettre (U+0303 = ̃) : pẽ, qũ, etc.
"p̃": ("par", "per"),
"q̃": ("que", "qui"),
"ñ": ("an", "en"), # U+00F1 (Latin-1 Sup)
# Note : ñ existe aussi comme caractère latin moderne (espagnol),
# donc l'attribuer aux abréviations introduit du bruit ; on
# laisse au benchmark le soin d'évaluer. Pour les éditeurs
# médiévistes qui veulent restreindre, ils peuvent passer par
# une table custom (à venir).
}
# Set des "premiers code-points" reconnus comme début d'une
# abréviation (pour balayage rapide).
_ABBR_FIRST_CHARS: frozenset[str] = frozenset(
abbr[0] for abbr in ABBREVIATION_EXPANSIONS
)
# Combining tilde (U+0303) — utilisé pour la détection p̃, q̃, etc.
_COMBINING_TILDE = "̃"
# ──────────────────────────────────────────────────────────────────────────
# Détection d'abréviations dans un texte
# ──────────────────────────────────────────────────────────────────────────
def detect_abbreviations(text: Optional[str]) -> list[str]:
"""Liste des abréviations médiévales détectées dans ``text``,
dans l'ordre d'apparition.
Reconnaît :
- Les caractères Unicode dédiés présents dans
``ABBREVIATION_EXPANSIONS`` (``ꝑ``, ``ꝓ``, ``⁊``…).
- Les séquences ``lettre + U+0303`` (tilde combinant) si la
paire est dans la table (``p̃``, ``q̃``).
Doublons conservés : si le texte contient deux ``ꝑ``, la liste
en a deux. Cohérent avec le calcul bag-of-occurrences en aval.
"""
if not text:
return []
found: list[str] = []
# Forme NFD pour reconnaître les ã, p̃, q̃ même quand l'utilisateur
# passe la forme NFC (« ñ » = U+00F1 sera traité par le mapping
# direct ; les séquences manuelles ``p`` + tilde combinant restent
# détectables).
text_nfd = unicodedata.normalize("NFD", text)
i = 0
while i < len(text_nfd):
ch = text_nfd[i]
# Cas 1 : lettre + tilde combinant
if i + 1 < len(text_nfd) and text_nfd[i + 1] == _COMBINING_TILDE:
seq = ch + _COMBINING_TILDE
if seq in ABBREVIATION_EXPANSIONS:
found.append(seq)
i += 2
continue
# Cas 2 : caractère unicode dédié
if ch in ABBREVIATION_EXPANSIONS:
found.append(ch)
i += 1
return found
# ──────────────────────────────────────────────────────────────────────────
# Scores
# ──────────────────────────────────────────────────────────────────────────
def _hyp_contains_abbr(hypothesis: str, abbr: str) -> bool:
"""Vrai si la forme abrégée ``abbr`` apparaît telle quelle dans
``hypothesis``. Sensible aux deux formes NFC / NFD pour les
séquences à tilde combinant."""
if abbr in hypothesis:
return True
# Pour les séquences ``lettre + tilde combinant``, l'hyp peut
# avoir une forme NFC (ex. ``ñ`` au lieu de ``n + U+0303``).
nfd = unicodedata.normalize("NFD", hypothesis)
return abbr in nfd
def _hyp_contains_expansion(
hypothesis: str, expansions: tuple[str, ...],
) -> bool:
"""Vrai si l'une des formes développées apparaît dans ``hypothesis``
(recherche insensible à la casse, sur les frontières de mots
pour limiter les faux positifs sur les sous-chaînes courtes
type ``us`` ou ``et``)."""
if not expansions:
return False
hyp_lower = hypothesis.lower()
for exp in expansions:
if not exp:
continue
# Recherche frontière de mot pour les expansions courtes.
# Pour ``per`` ou ``pro`` : on accepte le développement à
# n'importe quelle position d'un mot (tolère ``per`` dans
# ``permettre``, c'est imprécis mais pragmatique). Pour
# les expansions très courtes (≤ 2 lettres), on impose un
# mot complet pour limiter le bruit.
if len(exp) <= 2:
if re.search(rf"\b{re.escape(exp)}\b", hyp_lower):
return True
else:
if exp.lower() in hyp_lower:
return True
return False
def compute_abbreviation_metrics(
reference: Optional[str],
hypothesis: Optional[str],
) -> dict:
"""Calcule les scores d'abréviation strict et d'expansion.
Parameters
----------
reference:
Texte GT (avec abréviations médiévales originales).
hypothesis:
Texte produit par l'OCR.
Returns
-------
dict
``{
"n_abbreviations_in_reference": int,
"n_strict_preserved": int, # forme abrégée préservée
"n_expansion_preserved": int, # abrégée OU développée
"strict_score": float, # ∈ [0, 1]
"expansion_score": float, # ∈ [0, 1]
"per_abbreviation": [
{"abbr", "strict_preserved", "expansion_preserved",
"expansions"},
...
],
}``
Cas dégénérés
-------------
- GT vide ou sans abréviation détectée → tous les compteurs à 0
et les scores à ``0.0`` (convention : on ne récompense pas
l'absence d'abréviations).
- GT non vide avec abréviations + hyp vide → tous les scores
à ``0.0``.
"""
ref = reference or ""
hyp = hypothesis or ""
abbreviations = detect_abbreviations(ref)
n = len(abbreviations)
if n == 0:
return {
"n_abbreviations_in_reference": 0,
"n_strict_preserved": 0,
"n_expansion_preserved": 0,
"strict_score": 0.0,
"expansion_score": 0.0,
"per_abbreviation": [],
}
n_strict = 0
n_expansion = 0
per_abbr: list[dict] = []
for abbr in abbreviations:
expansions = ABBREVIATION_EXPANSIONS.get(abbr, ())
strict_ok = _hyp_contains_abbr(hyp, abbr)
# Expansion : on accepte la forme abrégée OU le développement.
# Convention : si l'OCR a préservé la forme abrégée, c'est
# aussi compté comme valide pour le score d'expansion (le
# moteur n'a pas perdu l'information ; il a juste choisi
# une convention diplomatique).
expansion_ok = strict_ok or _hyp_contains_expansion(hyp, expansions)
if strict_ok:
n_strict += 1
if expansion_ok:
n_expansion += 1
per_abbr.append({
"abbr": abbr,
"strict_preserved": strict_ok,
"expansion_preserved": expansion_ok,
"expansions": list(expansions),
})
return {
"n_abbreviations_in_reference": n,
"n_strict_preserved": n_strict,
"n_expansion_preserved": n_expansion,
"strict_score": n_strict / n,
"expansion_score": n_expansion / n,
"per_abbreviation": per_abbr,
}
def abbreviation_strict_score(
reference: Optional[str], hypothesis: Optional[str],
) -> float:
"""Raccourci : taux de préservation **stricte** des abréviations
Unicode (forme abrégée gardée telle quelle)."""
return compute_abbreviation_metrics(reference, hypothesis)["strict_score"]
def abbreviation_expansion_score(
reference: Optional[str], hypothesis: Optional[str],
) -> float:
"""Raccourci : taux de préservation par expansion (forme abrégée
OU forme développée présente dans l'hyp)."""
return compute_abbreviation_metrics(reference, hypothesis)["expansion_score"]
# ──────────────────────────────────────────────────────────────────────────
# Enregistrement dans le registre typé (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────
@register_metric(
name="abbreviation_strict_score",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"Taux d'abréviations médiévales (Unicode dédié + lettre + "
"tilde combinant) dont la forme abrégée est préservée telle "
"quelle dans l'OCR. Idéal pour les éditions diplomatiques."
),
higher_is_better=True,
tags={"text", "abbreviation", "philology", "medieval"},
)
def _registered_strict(reference: str, hypothesis: str) -> float:
return abbreviation_strict_score(reference, hypothesis)
@register_metric(
name="abbreviation_expansion_score",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"Taux d'abréviations dont SOIT la forme abrégée Unicode SOIT "
"la forme développée attendue (per, pro, et…) est présente "
"dans l'OCR. Score plus large que strict_score."
),
higher_is_better=True,
tags={"text", "abbreviation", "philology", "medieval"},
)
def _registered_expansion(reference: str, hypothesis: str) -> float:
return abbreviation_expansion_score(reference, hypothesis)
__all__ = [
"ABBREVIATION_EXPANSIONS",
"detect_abbreviations",
"compute_abbreviation_metrics",
"abbreviation_strict_score",
"abbreviation_expansion_score",
]
|