Picarones / picarones /evaluation /metrics /lexical_modernization.py
Claude
refactor(evaluation): Sprint A14-S10 — déplacement de 23 fichiers de calcul vers evaluation/metrics/
052fb51 unverified
Raw
History Blame
8.99 kB
"""Détection de la sur-normalisation lexicale par les LLM/VLM —
Sprint 80 (A.I.7).
Sprint 80 — A.I.7 du plan d'évolution 2026.
Pourquoi ce module
------------------
Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un
moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit
rien sur **quoi** corriger dans le prompt. Ce module produit
une **table de fréquences détaillée** :
+----------------------+--------------------+------+----------+
| Forme historique GT | Forme modernisée | n GT | % modern |
+======================+====================+======+==========+
| maistre | maître | 47 | 85 % |
| nostre | nostre | 92 | 8 % |
| veoir | voir | 23 | 100 % |
+----------------------+--------------------+------+----------+
Lecture immédiate : *« le LLM modernise systématiquement
maistre → maître ; pour préserver l'orthographe historique, ajouter
au prompt "ne pas moderniser maistre, nostre, veoir" »*.
Méthode
-------
Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque
``replace`` ou ``equal`` produit une paire ``(gt_token,
hyp_token)``. On accumule pour chaque ``gt_token`` :
- ``n_total`` : nombre d'occurrences du token dans la GT
- ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token``
- ``variants`` : dict des hyp_tokens observés avec leur count
Stop-list
---------
L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à
ignorer). Par défaut, vide — le module ne tente pas de deviner ce
qui est « moderne » ou « historique », c'est au chercheur de
fournir le filtre adapté à son corpus.
Sortie
------
``compute_lexical_modernization`` retourne une structure adaptée
au rendu HTML. ``aggregate_lexical_modernization`` agrège
plusieurs documents.
Limites documentées
-------------------
- Tokenisation au niveau mot (split sur espace) — cohérent avec
``taxonomy.py`` et autres modules. Pas de stemming ni de
lemmatisation.
- La métrique mesure la **réécriture lexicale** ; elle n'attrape
pas les modernisations infra-mot (perte du s long ſ qui se
fond dans la même forme). Pour ça, voir ``early_modern_typography``
(Sprint 58) et ``equivalence_profile`` (Sprint 78).
"""
from __future__ import annotations
import difflib
import logging
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
def _split_words(text: Optional[str]) -> list[str]:
"""Tokenisation simple par split sur whitespace."""
if not text:
return []
return text.split()
def compute_lexical_modernization(
reference: Optional[str],
hypothesis: Optional[str],
*,
stop_list: Optional[Iterable[str]] = None,
case_sensitive: bool = False,
) -> dict:
"""Calcule le tableau de modernisation lexicale pour un document.
Returns
-------
dict
``{
"n_gt_tokens": int,
"tokens": {
gt_token: {
"n_total": int,
"n_modernized": int,
"rate_modernized": float, # ∈ [0, 1]
"variants": {hyp_token: count, ...},
},
...
},
}``
Si ``reference`` est vide → ``tokens == {}``.
"""
ref_tokens = _split_words(reference)
hyp_tokens = _split_words(hypothesis)
if not ref_tokens:
return {"n_gt_tokens": 0, "tokens": {}}
if not case_sensitive:
ref_for_match = [t.lower() for t in ref_tokens]
hyp_for_match = [t.lower() for t in hyp_tokens]
else:
ref_for_match = ref_tokens
hyp_for_match = hyp_tokens
stop = frozenset(
(t.lower() if not case_sensitive else t)
for t in (stop_list or [])
)
# On accumule par gt_token (forme display = forme originale,
# match key = forme casée selon ``case_sensitive``).
tokens_data: dict[str, dict] = {}
matcher = difflib.SequenceMatcher(
None, ref_for_match, hyp_for_match, autojunk=False,
)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
gt_orig = ref_tokens[i1 + k]
gt_match = ref_for_match[i1 + k]
if gt_match in stop:
continue
slot = tokens_data.setdefault(
gt_orig,
{"n_total": 0, "n_modernized": 0, "variants": {}},
)
slot["n_total"] += 1
elif tag == "replace":
# Apparier 1-à-1 quand possible
paired = min(i2 - i1, j2 - j1)
for k in range(paired):
gt_orig = ref_tokens[i1 + k]
gt_match = ref_for_match[i1 + k]
if gt_match in stop:
continue
hyp_orig = hyp_tokens[j1 + k]
slot = tokens_data.setdefault(
gt_orig,
{"n_total": 0, "n_modernized": 0, "variants": {}},
)
slot["n_total"] += 1
slot["n_modernized"] += 1
slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1
# Si plus de gt que de hyp, le reste des gt_tokens est
# « perdu » — on les compte comme totaux mais pas comme
# modernisés (on ne sait pas en quoi).
for k in range(paired, i2 - i1):
gt_orig = ref_tokens[i1 + k]
gt_match = ref_for_match[i1 + k]
if gt_match in stop:
continue
slot = tokens_data.setdefault(
gt_orig,
{"n_total": 0, "n_modernized": 0, "variants": {}},
)
slot["n_total"] += 1
slot["n_modernized"] += 1
slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
elif tag == "delete":
# gt présent, pas en hyp → modernisation par
# suppression (ou perte pure)
for k in range(i2 - i1):
gt_orig = ref_tokens[i1 + k]
gt_match = ref_for_match[i1 + k]
if gt_match in stop:
continue
slot = tokens_data.setdefault(
gt_orig,
{"n_total": 0, "n_modernized": 0, "variants": {}},
)
slot["n_total"] += 1
slot["n_modernized"] += 1
slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
# Calcul du taux par token
for slot in tokens_data.values():
total = slot["n_total"]
slot["rate_modernized"] = (
slot["n_modernized"] / total if total > 0 else 0.0
)
return {
"n_gt_tokens": len(ref_tokens),
"tokens": tokens_data,
}
def aggregate_lexical_modernization(
per_doc_results: Iterable[dict],
) -> dict:
"""Agrège des ``compute_lexical_modernization`` per-doc.
Renvoie la structure agrégée corpus-wide avec la même forme
que ``compute_lexical_modernization``.
"""
agg_tokens: dict[str, dict] = {}
n_gt_total = 0
for doc_result in per_doc_results:
if not doc_result:
continue
n_gt_total += doc_result.get("n_gt_tokens", 0)
for gt, data in (doc_result.get("tokens") or {}).items():
slot = agg_tokens.setdefault(
gt, {"n_total": 0, "n_modernized": 0, "variants": {}},
)
slot["n_total"] += data.get("n_total", 0)
slot["n_modernized"] += data.get("n_modernized", 0)
for hyp_t, count in (data.get("variants") or {}).items():
slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count
for slot in agg_tokens.values():
total = slot["n_total"]
slot["rate_modernized"] = (
slot["n_modernized"] / total if total > 0 else 0.0
)
return {
"n_gt_tokens": n_gt_total,
"tokens": agg_tokens,
}
def top_modernized_tokens(
data: dict,
*,
n: int = 20,
min_total: int = 1,
) -> list[tuple[str, dict]]:
"""Top-N tokens GT par taux de modernisation.
Filtre les tokens dont ``n_total < min_total`` (anecdotiques).
Tri par ``rate_modernized`` décroissant, tie-break par
``n_total`` décroissant.
"""
tokens = data.get("tokens") or {}
candidates = [
(gt, slot) for gt, slot in tokens.items()
if slot.get("n_total", 0) >= min_total
and slot.get("n_modernized", 0) > 0
]
candidates.sort(
key=lambda pair: (
-pair[1].get("rate_modernized", 0.0),
-pair[1].get("n_total", 0),
pair[0],
),
)
return candidates[:n]
__all__ = [
"compute_lexical_modernization",
"aggregate_lexical_modernization",
"top_modernized_tokens",
]