Spaces:
Running
Running
| """Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026). | |
| Pourquoi ce module | |
| ------------------ | |
| Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en | |
| masquant des **erreurs systématiques sur les tokens rares** : noms | |
| propres, toponymes peu fréquents, mots techniques, formules latines | |
| récurrentes mais pas dominantes. Pour un usage prosopographique | |
| (indexation de noms, recherche généalogique), ce sont précisément | |
| ces tokens-là qui comptent. | |
| Ce module mesure le **rappel sur les tokens rares** d'un corpus — | |
| défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax + | |
| dis legomena, terminologie de lexicométrie classique). | |
| Hypothèse à valider expérimentalement | |
| ------------------------------------- | |
| La conjecture du plan A.I.1 : *« cette métrique discrimine plus | |
| les moteurs que le CER global »*. Si confirmée sur un corpus | |
| patrimonial réel, elle gagne sa place dans le tableau de | |
| classement principal — décision laissée au chercheur après | |
| observation. | |
| Stratégie de découpage | |
| ---------------------- | |
| Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche | |
| de calcul pure d'abord, sans intégration runner. La vue HTML | |
| « worst lines / rare tokens manqués » suit dans un sprint dédié. | |
| Pas d'enregistrement dans le registre typé Sprint 34 | |
| ---------------------------------------------------- | |
| La métrique exige **trois entrées** (reference, hypothesis, set | |
| des tokens rares) et le set des rares est calculé corpus-wide | |
| (donc connu seulement après itération sur tout le corpus). La | |
| signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur | |
| appelle explicitement ``compute_rare_token_recall`` avec le set | |
| qu'il a calculé. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| from collections import Counter | |
| from typing import Iterable, Optional | |
| logger = logging.getLogger(__name__) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Tokenisation Unicode-aware | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Token = séquence maximale de caractères de mot Unicode (\w en | |
| # Python 3 utilise déjà la table Unicode), incluant l'apostrophe | |
| # typographique '’' à l'intérieur (« l'an », « d’une ») et les | |
| # tirets internes (« peut-être »). La ponctuation isolée et les | |
| # espaces sont des séparateurs. | |
| _TOKEN_RE = re.compile( | |
| r"\w+(?:[’'\-]\w+)*", | |
| flags=re.UNICODE, | |
| ) | |
| def tokenize(text: Optional[str]) -> list[str]: | |
| """Tokenisation Unicode-aware. | |
| Conserve les contractions (``l'an``, ``d’une``) et les mots | |
| composés (``peut-être``, ``c'est-à-dire``) comme un seul token. | |
| Casse préservée — l'utilisateur normalise lui-même via | |
| ``case_sensitive=False`` dans les fonctions aval s'il le veut. | |
| """ | |
| if not text: | |
| return [] | |
| return _TOKEN_RE.findall(text) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Distribution de fréquence corpus-wide | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def frequency_distribution( | |
| documents: Iterable[str], | |
| *, | |
| case_sensitive: bool = False, | |
| ) -> Counter[str]: | |
| """Calcule ``{token: count}`` sur l'ensemble du corpus. | |
| Parameters | |
| ---------- | |
| documents: | |
| Itérable de textes (typiquement les ``ground_truth`` des | |
| documents du corpus). | |
| case_sensitive: | |
| Si ``False`` (défaut), tous les tokens sont mis en | |
| minuscule avant comptage. | |
| """ | |
| counter: Counter[str] = Counter() | |
| for doc in documents: | |
| tokens = tokenize(doc) | |
| if not case_sensitive: | |
| tokens = [t.lower() for t in tokens] | |
| counter.update(tokens) | |
| return counter | |
| def extract_rare_tokens( | |
| documents: Iterable[str], | |
| *, | |
| max_freq: int = 2, | |
| case_sensitive: bool = False, | |
| ) -> frozenset[str]: | |
| """Retourne l'ensemble des tokens dont la fréquence | |
| corpus-wide est ``≤ max_freq``. | |
| Convention de lexicométrie : ``max_freq=1`` retourne uniquement | |
| les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne | |
| hapax + dis legomena (≤ 2 occurrences) — défaut. | |
| Les tokens qui n'apparaissent **jamais** dans le corpus ne sont | |
| évidemment pas inclus (le ``Counter`` ne les liste pas). | |
| """ | |
| if max_freq < 1: | |
| raise ValueError("max_freq doit être ≥ 1") | |
| counter = frequency_distribution( | |
| documents, case_sensitive=case_sensitive, | |
| ) | |
| return frozenset(t for t, c in counter.items() if c <= max_freq) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Calcul du rappel par document | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def compute_rare_token_recall( | |
| reference: Optional[str], | |
| hypothesis: Optional[str], | |
| rare_tokens: Iterable[str], | |
| *, | |
| case_sensitive: bool = False, | |
| ) -> dict: | |
| """Calcule le rappel sur les tokens rares présents dans la GT. | |
| Parameters | |
| ---------- | |
| reference: | |
| Texte GT du document. | |
| hypothesis: | |
| Texte produit par l'OCR. | |
| rare_tokens: | |
| Itérable des tokens rares — typiquement le résultat de | |
| ``extract_rare_tokens`` sur le corpus complet. | |
| case_sensitive: | |
| Si ``False`` (défaut), la comparaison se fait sur les | |
| formes minuscules. | |
| Returns | |
| ------- | |
| dict | |
| ``{ | |
| "n_rare_tokens_in_reference": int, | |
| # nombre d'**occurrences** de tokens rares dans la GT | |
| # (multiplicité préservée — un token rare présent 2 | |
| # fois compte 2) | |
| "n_rare_tokens_recalled": int, | |
| # nombre d'occurrences correctement présentes dans hyp | |
| # (alignement bag-of-tokens : min(count_ref, count_hyp)) | |
| "recall": float, | |
| # ratio dans [0, 1], ou 0.0 si aucun rare en GT | |
| "missed_tokens": list[str], | |
| # liste des tokens rares **manqués** (avec multiplicité, | |
| # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp → | |
| # missed_tokens contient ["Dupont"] une fois) | |
| }`` | |
| Cas dégénérés | |
| ------------- | |
| - GT vide ou aucun token rare présent → recall = 0.0, listes | |
| vides (convention : on ne récompense pas l'absence de | |
| tokens rares). | |
| - Hyp vide avec rares en GT → tous manqués, recall = 0.0. | |
| """ | |
| ref = reference or "" | |
| hyp = hypothesis or "" | |
| if case_sensitive: | |
| rare_set = frozenset(rare_tokens) | |
| ref_tokens = tokenize(ref) | |
| hyp_tokens = tokenize(hyp) | |
| else: | |
| rare_set = frozenset(t.lower() for t in rare_tokens) | |
| ref_tokens = [t.lower() for t in tokenize(ref)] | |
| hyp_tokens = [t.lower() for t in tokenize(hyp)] | |
| # Multiplicité : on compte uniquement les rares présents dans la GT | |
| ref_rare_counts: Counter[str] = Counter( | |
| t for t in ref_tokens if t in rare_set | |
| ) | |
| n_rare_in_ref = sum(ref_rare_counts.values()) | |
| if n_rare_in_ref == 0: | |
| return { | |
| "n_rare_tokens_in_reference": 0, | |
| "n_rare_tokens_recalled": 0, | |
| "recall": 0.0, | |
| "missed_tokens": [], | |
| } | |
| # Bag-of-tokens dans hyp pour les tokens rares uniquement | |
| hyp_rare_counts: Counter[str] = Counter( | |
| t for t in hyp_tokens if t in rare_set | |
| ) | |
| # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count) | |
| n_recalled = 0 | |
| missed: list[str] = [] | |
| for token, ref_count in ref_rare_counts.items(): | |
| hyp_count = hyp_rare_counts.get(token, 0) | |
| recalled = min(ref_count, hyp_count) | |
| n_recalled += recalled | |
| missed_count = ref_count - recalled | |
| if missed_count > 0: | |
| missed.extend([token] * missed_count) | |
| return { | |
| "n_rare_tokens_in_reference": n_rare_in_ref, | |
| "n_rare_tokens_recalled": n_recalled, | |
| "recall": n_recalled / n_rare_in_ref, | |
| "missed_tokens": missed, | |
| } | |
| def rare_token_recall( | |
| reference: Optional[str], | |
| hypothesis: Optional[str], | |
| rare_tokens: Iterable[str], | |
| *, | |
| case_sensitive: bool = False, | |
| ) -> float: | |
| """Raccourci : retourne uniquement le rappel ∈ [0, 1].""" | |
| return compute_rare_token_recall( | |
| reference, hypothesis, rare_tokens, | |
| case_sensitive=case_sensitive, | |
| )["recall"] | |
| __all__ = [ | |
| "tokenize", | |
| "frequency_distribution", | |
| "extract_rare_tokens", | |
| "compute_rare_token_recall", | |
| "rare_token_recall", | |
| ] | |