Picarones / picarones /evaluation /metrics /searchability.py
Claude
feat(sprint-E.2): 10 modules measurements/ migrΓ©s vers evaluation/metrics/
4eb91d0 unverified
Raw
History Blame
8.38 kB
"""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",
]