Spaces:
Sleeping
Sleeping
File size: 9,522 Bytes
d756039 0864c88 75b91fd d756039 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | """Métriques de lisibilité (Flesch) — Sprint 52.
Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure
de la métrique Flesch, indépendante de tout alignement OCR/GT.
Pourquoi ce module
------------------
Les LLM produisent du texte plus « lisse » que les manuscrits
historiques. Cette tendance à la modernisation est mesurable par la
différence de score de lisibilité entre la GT et la sortie OCR/LLM —
**indépendamment des classes taxonomiques** et **sans alignement
caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
moderne plausible mais déconnecté de la GT).
Stratégie de découpage
----------------------
Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
découpe :
- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
``flesch_delta``. Aucune dépendance externe ; les heuristiques de
comptage de syllabes sont en pur Python, déterministes, testées.
- **Sprints suivants** — câblage runner pour calculer
``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
Formules
--------
- **Anglais** (Flesch original 1948) :
``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
- **Français** (Kandel-Moles 1958) :
``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
0 ↔ « très difficile ». Une **augmentation** du score quand on passe
de la GT à l'OCR signale une simplification (typique des LLM
modernisants). Une **chute** signale une dégradation OCR.
Limites documentées
-------------------
- Le comptage de syllabes est heuristique. En français, des règles
comme « -ier non final = 2 syllabes » ne sont pas appliquées
finement. Acceptable pour une métrique de **comparaison relative**
(delta GT vs OCR), pas pour publier une absolue.
- Sur des textes très courts (< 20 mots), la formule perd en
fiabilité. Le seuil minimal est documenté.
"""
from __future__ import annotations
import logging
import re
from typing import Literal
from picarones.evaluation.metric_registry import register_metric
from picarones.domain.artifacts import ArtifactType
logger = logging.getLogger(__name__)
Language = Literal["fr", "en"]
# Coefficients de la formule Flesch selon la langue.
_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
"en": (206.835, 1.015, 84.6), # Flesch 1948
"fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
}
# Voyelles utilisées pour l'heuristique de comptage de syllabes.
# On utilise un set qui inclut les diacritiques courantes en FR/EN.
_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
# Regex de découpage en phrases : ponctuation finale + espace ou fin.
# Tolère les multiples points (« ... ») et garde un découpage robuste.
_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
# Regex de tokenisation simple (mots) : séquences de caractères "lettres".
_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
# ──────────────────────────────────────────────────────────────────────────
# Compteurs de base
# ──────────────────────────────────────────────────────────────────────────
def count_words(text: str) -> int:
"""Nombre de mots (tokens alphanumériques) dans ``text``."""
if not text:
return 0
return len(_WORD_RE.findall(text))
def count_sentences(text: str) -> int:
"""Nombre de phrases dans ``text``.
Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
éviter une division par zéro dans la formule de Flesch sur les
textes sans ponctuation finale.
"""
if not text:
return 0
parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
n = len(parts)
if n == 0 and count_words(text) > 0:
return 1
return n
def count_syllables_word(word: str) -> int:
"""Heuristique de comptage de syllabes pour un mot isolé.
Règle : on compte les **groupes de voyelles consécutives** (en
incluant ``y`` et les diacritiques courantes). C'est une
approximation grossière mais déterministe et testable.
Cas limites :
- mot vide → 0
- mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``)
- mot d'une seule voyelle isolée → 1
"""
if not word:
return 0
word = word.lower()
in_vowel_group = False
count = 0
for ch in word:
if ch in _VOWELS:
if not in_vowel_group:
count += 1
in_vowel_group = True
else:
in_vowel_group = False
return count or 1
def count_syllables(text: str) -> int:
"""Somme des syllabes de tous les mots de ``text``."""
if not text:
return 0
return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
# ──────────────────────────────────────────────────────────────────────────
# Score Flesch
# ──────────────────────────────────────────────────────────────────────────
def flesch_score(text: str, lang: Language = "fr") -> float:
"""Calcule le score de lisibilité Flesch pour ``text``.
Parameters
----------
text:
Texte à évaluer. Peut contenir ponctuation, accents, etc.
lang:
``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
Returns
-------
float
Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
vide ou sans mot exploitable.
Notes
-----
Le score chute fortement avec :
- longues phrases (mots/phrases élevé)
- mots polysyllabiques (syllabes/mots élevé)
Une montée du score lors du passage GT → OCR signale qu'un LLM a
« lissé » la langue (phrases plus courtes, mots plus communs).
"""
if lang not in _FLESCH_COEFFS:
raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
n_words = count_words(text)
if n_words == 0:
return 0.0
n_sentences = max(1, count_sentences(text))
n_syllables = count_syllables(text)
if n_syllables == 0:
return 0.0
base, k_words, k_syll = _FLESCH_COEFFS[lang]
raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
return max(0.0, min(100.0, raw))
def flesch_delta(
reference: str,
hypothesis: str,
lang: Language = "fr",
) -> float:
"""Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
Interprétation
--------------
- **Positif** : l'hypothèse OCR est plus lisible que la GT —
signal d'**over-normalisation** (typique des LLM qui modernisent
des textes anciens).
- **Négatif** : l'OCR est moins lisible — signal de dégradation
(caractères mal reconnus brisent la fluidité).
- **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
Borné dans ``[-100, +100]``.
"""
return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
# ──────────────────────────────────────────────────────────────────────────
# Enregistrement dans le registre typé (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────
@register_metric(
name="flesch_delta_fr",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
"OCR et la GT. Positif = OCR plus lisible (signal "
"d'over-normalisation LLM). Aucun alignement requis."
),
higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
tags={"text", "readability", "over_normalization"},
)
def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
return flesch_delta(reference, hypothesis, lang="fr")
@register_metric(
name="flesch_delta_en",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
"Positive = OCR easier to read than GT (LLM smoothing signal). "
"No alignment required."
),
higher_is_better=False,
tags={"text", "readability", "over_normalization"},
)
def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
return flesch_delta(reference, hypothesis, lang="en")
__all__ = [
"flesch_score",
"flesch_delta",
"count_words",
"count_sentences",
"count_syllables",
"count_syllables_word",
]
|