Spaces:
Sleeping
Sleeping
Claude
feat(sprint-E.2): 10 modules measurements/ migrΓ©s vers evaluation/metrics/
4eb91d0 unverified | """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) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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", | |
| ] | |