Picarones / picarones /measurements /rare_tokens.py
Claude
phaseE: séparation core/ (Cercle 1) + measurements/ (Cercle 2)
d756039 unverified
Raw
History Blame
9.42 kB
"""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",
]