Spaces:
Sleeping
Sleeping
File size: 8,377 Bytes
4eb91d0 | 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 | """RecherchabilitΓ© fuzzy β Sprint 84 (A.II.5).
Sprint 84 β A.II.5 du plan d'Γ©volution 2026.
Pourquoi ce module
------------------
Le CER mesure les erreurs caractère par caractère. Mais pour
un usage *recherche plein-texte* (ce que font Elastic, Solr en
mode fuzzy, ou la recherche full-text de Gallica), la question
rΓ©elle est :
*Β« Combien de mots de ma GT sont retrouvables dans la
sortie OCR, à orthographe approchée près ? »*
Un CER de 8 % peut donner 95 % de findability si les erreurs
sont concentrées sur des caractères non-significatifs ou sur
quelques mots aberrants ; Γ l'inverse, 4 % de CER mais
distribuΓ© sur tous les noms propres rend le corpus inutilisable
pour l'indexation prosopographique.
MΓ©thode
-------
Pour chaque token GT, on regarde s'il existe au moins un token
hypothèse à distance de Levenshtein †``max_distance`` (défaut
2, valeur Elastic ``fuzziness: AUTO`` standard pour mots β₯ 5
caractères). Le **rappel** est la proportion de tokens GT
ainsi retrouvΓ©s.
MultiplicitΓ©
------------
Si la GT contient *« le »* deux fois et l'hypothèse une fois,
seul un token GT est comptΓ© comme retrouvΓ© (alignement
multi-set, comme ``rare_token_recall`` Sprint 71).
Sortie
------
``compute_searchability(reference, hypothesis)`` retourne
``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
Limites documentΓ©es
-------------------
- Tokenisation par split sur whitespace (cohΓ©rent avec le reste
du codebase). Pas de stemming ni de lemmatisation.
- Levenshtein non pondΓ©rΓ© β substitution = insertion = suppression
= 1. Pour un poids diffΓ©rent (par ex. faute classique
diacritique = 0,5), passer une fonction custom.
- Pas de sΓ©mantique : *Β« roi Β»* β *Β« souverain Β»*. Pour la
similaritΓ© sΓ©mantique, voir des modules futurs (BERTScore).
"""
from __future__ import annotations
import logging
from typing import Optional
from picarones.evaluation.metric_registry import register_metric
from picarones.domain.artifacts import ArtifactType
logger = logging.getLogger(__name__)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Tokenisation et distance d'Γ©dition
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _split_words(text: Optional[str]) -> list[str]:
"""Tokenisation par whitespace β cohΓ©rent avec
``lexical_modernization.py``, ``rare_tokens.py``, etc."""
if not text:
return []
return text.split()
def levenshtein_distance(a: str, b: str) -> int:
"""Distance de Levenshtein (substitution=insertion=suppression=1).
ImplΓ©mentation DP O(|a|Β·|b|) en mΓ©moire O(min(|a|,|b|)).
"""
if a == b:
return 0
if len(a) < len(b):
a, b = b, a
# |a| β₯ |b|
if not b:
return len(a)
previous = list(range(len(b) + 1))
for i, ca in enumerate(a, start=1):
current = [i] + [0] * len(b)
for j, cb in enumerate(b, start=1):
cost = 0 if ca == cb else 1
current[j] = min(
current[j - 1] + 1, # insertion
previous[j] + 1, # suppression
previous[j - 1] + cost, # substitution
)
previous = current
return previous[-1]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Calcul principal
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def compute_searchability(
reference: Optional[str],
hypothesis: Optional[str],
*,
max_distance: int = 2,
case_sensitive: bool = False,
) -> dict:
"""RecherchabilitΓ© fuzzy de ``reference`` dans ``hypothesis``.
Parameters
----------
reference, hypothesis:
Transcriptions GT et OCR.
max_distance:
Seuil de distance de Levenshtein (β€ pour considΓ©rer un
token comme retrouvΓ©). DΓ©faut 2 β convention
``fuzziness: AUTO`` d'Elastic pour mots β₯ 5 caractΓ¨res.
case_sensitive:
Si False (dΓ©faut), casse insensible cΓ΄tΓ© match β la
sortie ``missed_tokens`` reste avec la casse GT
originale.
Returns
-------
dict
``{
"n_gt_tokens": int,
"n_searchable": int,
"recall": float | None, # None si n_gt_tokens == 0
"missed_tokens": list[str],
"max_distance": int,
}``
"""
if max_distance < 0:
raise ValueError(f"max_distance doit Γͺtre β₯ 0, reΓ§u {max_distance}")
gt_tokens = _split_words(reference)
hyp_tokens = _split_words(hypothesis)
n_gt = len(gt_tokens)
if n_gt == 0:
return {
"n_gt_tokens": 0,
"n_searchable": 0,
"recall": None,
"missed_tokens": [],
"max_distance": max_distance,
}
# Multi-set : un token hypothèse ne peut servir qu'une fois.
# Tri par longueur croissante pour matcher d'abord les
# tokens GT les plus courts (oΓΉ Ξ΅-fautes sont plus rares).
if case_sensitive:
gt_for_match = list(gt_tokens)
hyp_for_match = list(hyp_tokens)
else:
gt_for_match = [t.lower() for t in gt_tokens]
hyp_for_match = [t.lower() for t in hyp_tokens]
hyp_used = [False] * len(hyp_for_match)
n_searchable = 0
missed: list[str] = []
for gi, gt_match in enumerate(gt_for_match):
# Court-circuit si match exact disponible
best_idx = -1
best_dist = max_distance + 1
for hi, used in enumerate(hyp_used):
if used:
continue
hyp_match = hyp_for_match[hi]
# Court-circuit longueur (Levenshtein β₯ |Ξlen|)
if abs(len(hyp_match) - len(gt_match)) > max_distance:
continue
d = levenshtein_distance(gt_match, hyp_match)
if d < best_dist:
best_dist = d
best_idx = hi
if d == 0:
break # match exact, inutile de chercher mieux
if best_idx >= 0 and best_dist <= max_distance:
hyp_used[best_idx] = True
n_searchable += 1
else:
missed.append(gt_tokens[gi])
recall = n_searchable / n_gt
return {
"n_gt_tokens": n_gt,
"n_searchable": n_searchable,
"recall": recall,
"missed_tokens": missed,
"max_distance": max_distance,
}
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Enregistrement registre typΓ© (Sprint 34)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@register_metric(
name="searchability_recall",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"RecherchabilitΓ© fuzzy : proportion de tokens GT retrouvΓ©s "
"dans l'OCR Γ distance de Levenshtein β€ 2. Proxy direct de "
"la qualitΓ© pour la recherche plein-texte (Elastic, Solr)."
),
)
def searchability_recall_metric(reference: str, hypothesis: str) -> float:
"""Variante scalaire pour le registre typΓ© : retourne le
rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
cohΓ©rente avec rare_token_recall Sprint 71).
"""
result = compute_searchability(reference, hypothesis)
recall = result.get("recall")
return 0.0 if recall is None else recall
__all__ = [
"levenshtein_distance",
"compute_searchability",
"searchability_recall_metric",
]
|