FonBench / jml_v21 /texte.py
Kimyayd's picture
evaluateur: benchmarks classes sur le WER notes avec le code de reference JML v2.1
998b24f verified
Raw History Blame Contribute Delete
6.55 kB
"""Normalisation du texte fon — module autonome du chantier (aucune dépendance au code du dépôt).
Trois espaces d'écriture, du plus riche au plus pauvre :
brut ──normaliser()──► forme canonique : NFD, minuscules, homoglyphes corrigés
──cible_tonale()─► ce qui se prononce : lettres, espaces, tons ; ni ponctuation ni chiffres
──sans_tons()───► les mêmes lettres, tons retirés des voyelles
**L'espace d'évaluation** est `espace_eval(t) = sans_tons(cible_tonale(t))`, appliqué à la
référence ET à l'hypothèse. Il reproduit à l'identique la définition utilisée par FonBench et par
le document scientifique du projet : c'est la condition pour que nos chiffres soient comparables
aux 26 modèles externes déjà mesurés. L'identité est **vérifiée par un test** sur tout le corpus
(`pipeline/tests/test_equivalence_texte.py`), pas supposée.
Choix documentés (DECISIONS.md, D-TXT-*) :
- NFD en interne : `ɔ́` et `ɛ́` n'ont pas de forme précomposée ; en NFC le corpus serait mixte.
- Les apostrophes d'élision disparaissent (`d'é` → `dé`) : c'est la convention de l'espace
d'évaluation historique. Un modèle qui les écrirait serait pénalisé à tort.
- Les chiffres disparaissent : la norme veut qu'on transcrive la langue dite ; les références
qui en contiennent encore (0,6 %) sont signalées à part, pas corrigées en silence.
- Un ton n'est retiré que s'il porte sur une voyelle : sur une consonne (`ń`), c'est une lettre.
"""
from __future__ import annotations
import re
import unicodedata
# Tons du fon, en marques combinantes (NFD)
AIGU, CARON, GRAVE, CIRCONFLEXE, MACRON = "́", "̌", "̀", "̂", "̄"
TONS = frozenset({AIGU, CARON, GRAVE, CIRCONFLEXE, MACRON})
# Diacritiques sans valeur tonale en fon, rencontrés dans le corpus : retirés
PARASITES = frozenset({"̧", "̈", "̣"}) # cédille, tréma, point souscrit
LETTRES = frozenset("abcdefghijklmnopqrstuvwxyz") | {"ɔ", "ɖ", "ɛ"}
CHIFFRES = frozenset("0123456789")
VOYELLES = frozenset("aeiou") | {"ɔ", "ɛ"}
BALISAGE = frozenset("<>+*")
HOMOGLYPHES = { # graphie fautive → lettre ou signe correct
"ε": "ɛ", "Ε": "ɛ", # epsilon grec
"ð": "ɖ", "đ": "ɖ", "ɗ": "ɖ", # d barrés ou crochetés
"‚": ",", "“": '"', "”": '"', "‘": "'", "’": "'",
"œ": "oe",
}
_ESPACES = re.compile(r" {2,}")
def normaliser(texte: str) -> str:
"""Forme canonique : NFD, minuscules, homoglyphes corrigés. Idempotente."""
t = unicodedata.normalize("NFD", texte).lower()
t = unicodedata.normalize("NFD", t) # la minusculisation peut recomposer
for faux, juste in HOMOGLYPHES.items():
t = t.replace(faux, juste)
return unicodedata.normalize("NFD", t)
def cible_tonale(texte: str, chiffres: bool = False) -> str:
"""Ce qui se prononce : lettres, espaces et tons. Tout le reste est retiré.
`chiffres=True` (D-NUM-1, variante d'entraînement) : chaque chiffre 0-9 est gardé comme un mot isolé
(« 21h » → « 2 1 h ») ; `recoller_chiffres` refait les nombres après décodage. L'espace d'évaluation
officiel, lui, ne change pas (chiffres retirés)."""
sortie: list[str] = []
for c in normaliser(texte):
if c in TONS:
# un ton sans lettre avant lui (tapé après une espace) ne se prononce pas
if sortie and sortie[-1] != " ":
sortie.append(c)
elif chiffres and c in CHIFFRES:
sortie.extend((" ", c, " ")) # un chiffre = un « mot » (KenLM : vocabulaire fermé 0-9)
elif c in LETTRES or c == " ":
sortie.append(c)
elif c.isdigit() or c in PARASITES or c in BALISAGE \
or unicodedata.category(c).startswith("P"):
continue
elif c.isspace():
sortie.append(" ")
# tout autre caractère (hors alphabet) est retiré
return _ESPACES.sub(" ", "".join(sortie)).strip()
def sans_tons(texte: str) -> str:
"""Retire les tons portés par une voyelle ; garde ceux d'une consonne (ce sont des lettres)."""
sortie, base = [], ""
for c in unicodedata.normalize("NFD", texte):
if c in TONS:
if base in VOYELLES:
continue
else:
base = c
sortie.append(c)
return "".join(sortie)
def espace_eval(texte: str, chiffres: bool = False) -> str:
"""Espace de mesure du WER sans tons : identique à FonBench (avec `chiffres=False`)."""
return " ".join(sans_tons(cible_tonale(texte, chiffres)).split())
def espace_eval_tonal(texte: str, chiffres: bool = False) -> str:
"""Espace de mesure du WER avec tons."""
return " ".join(cible_tonale(texte, chiffres).split())
def recoller_chiffres(t: str) -> str:
"""Sortie d'un modèle D-NUM-1 : les chiffres isolés consécutifs redeviennent un nombre (« 2 1 » → « 21 »)."""
out: list[str] = []
for w in t.split():
if w.isdigit() and out and out[-1].isdigit():
out[-1] += w
else:
out.append(w)
return " ".join(out)
def suite_chiffres(t: str) -> str:
"""Mesure D-NUM-1 : la suite ordonnée des chiffres d'un texte, sans séparateurs (insensible au groupement)."""
return "".join(c for c in unicodedata.normalize("NFD", t) if c in CHIFFRES)
def _auto_test() -> None:
assert espace_eval("Un nɔ́ ɖɔxó, kpódó mɛ!") == "un nɔ ɖɔxo kpodo mɛ"
assert espace_eval("D'é mɛ 2026 \"bureau\".") == "dé mɛ bureau".replace("é", "e")
assert espace_eval_tonal("Kɔ́ 2 026 F, 3.5 %", chiffres=True) == unicodedata.normalize("NFD", "kɔ́ 2 0 2 6 f 3 5")
assert espace_eval("ɖo 21h", chiffres=True) == "ɖo 2 1 h" and recoller_chiffres("ɖo 2 1 h 0 5") == "ɖo 21 h 05"
assert suite_chiffres("Numéro : 05 55, le 2 026") == "05552026"
assert espace_eval("Kɔ 2026", chiffres=False) == "kɔ"
assert espace_eval_tonal("Mǐ ná yi") == unicodedata.normalize("NFD", "mǐ ná yi")
assert espace_eval("ɛ́ ε ð") == "ɛ ɛ ɖ" # homoglyphes
assert sans_tons(unicodedata.normalize("NFD", "ń")) == unicodedata.normalize("NFD", "ń")
assert espace_eval(" a ́ b ") == "a b" # ton orphelin, espaces multiples
for t in ("Nɛ́ ká gbɔn", "É cí nú mì ɖɔ"):
assert normaliser(normaliser(t)) == normaliser(t) # idempotence
print("texte.py : auto-test OK")
if __name__ == "__main__":
_auto_test()