Spaces:
Sleeping
Sleeping
Claude
refactor(evaluation): Sprint A14-S10 — déplacement de 23 fichiers de calcul vers evaluation/metrics/
052fb51 unverified | """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", | |
| ] | |