FonBench : leaderboard + evaluateur CPU autonome
Browse files- README.md +61 -15
- app.py +398 -0
- evaluator.py +429 -0
- fonbench_eval.py +264 -0
- requirements.txt +15 -0
README.md
CHANGED
|
@@ -1,15 +1,61 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: FonBench
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.24.0
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FonBench
|
| 3 |
+
emoji: 🗣️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.24.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: Classement public des modèles ASR pour le fongbe
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# FonBench
|
| 15 |
+
|
| 16 |
+
Classement public des modèles de reconnaissance vocale pour le **fongbe**,
|
| 17 |
+
langue tonale parlée par environ 2,3 millions de personnes au Bénin.
|
| 18 |
+
|
| 19 |
+
N'importe qui peut proposer un modèle du Hub : il est évalué
|
| 20 |
+
automatiquement sur le CPU de ce Space, puis ajouté au classement. Aucun
|
| 21 |
+
compte n'est nécessaire.
|
| 22 |
+
|
| 23 |
+
## Métriques
|
| 24 |
+
|
| 25 |
+
Le fongbe s'écrit avec des tons (á, ɔ́, ě…) qui changent le sens des mots,
|
| 26 |
+
mais les corpus ne suivent pas tous la même convention — certains n'en
|
| 27 |
+
notent aucun. Un WER brut n'est donc pas comparable d'un corpus à l'autre.
|
| 28 |
+
D'où :
|
| 29 |
+
|
| 30 |
+
- **WER_seg** — erreurs sur les mots, tons retirés. Justesse phonétique,
|
| 31 |
+
comparable partout. C'est le classement par défaut.
|
| 32 |
+
- **WER_ton** — erreurs sur les seules marques tonales. Non calculé quand
|
| 33 |
+
le corpus n'annote pas les tons, pour ne pas fausser la mesure.
|
| 34 |
+
- **T-WER** = `WER_seg + 2 × WER_ton` — métrique phare, double pénalité
|
| 35 |
+
tonale.
|
| 36 |
+
- **RTFx** — durée d'audio traitée par seconde de calcul. Plus haut = plus
|
| 37 |
+
rapide. Dépend du matériel : ne comparez qu'à matériel égal.
|
| 38 |
+
|
| 39 |
+
Le calcul est ouvert et reproductible : `fonbench_eval.py`.
|
| 40 |
+
|
| 41 |
+
## Corpus de test
|
| 42 |
+
|
| 43 |
+
Le jeu de test principal (2 555 énoncés, 4,98 h, 45 locuteurs) **n'est pas
|
| 44 |
+
publié**. Les corpus fongbe publics circulent depuis 2016 et plusieurs
|
| 45 |
+
modèles s'y sont entraînés, ce qui gonfle artificiellement leurs scores.
|
| 46 |
+
Ses locuteurs sont disjoints de ceux de l'entraînement. Seuls les scores
|
| 47 |
+
agrégés sont rendus publics — ni l'audio ni les transcriptions ne sont
|
| 48 |
+
accessibles depuis ce Space.
|
| 49 |
+
|
| 50 |
+
## Protocole
|
| 51 |
+
|
| 52 |
+
- Révision de modèle figée : chaque score est rattaché au hash du dépôt.
|
| 53 |
+
- Normalisation de texte identique pour tous les modèles.
|
| 54 |
+
- `trust_remote_code=False` : aucun code arbitraire n'est exécuté. Les
|
| 55 |
+
poids `.bin` sont lus en mode `weights_only`.
|
| 56 |
+
- Un couple (modèle, révision, corpus) n'est jamais réévalué.
|
| 57 |
+
|
| 58 |
+
## Soumettre un modèle
|
| 59 |
+
|
| 60 |
+
Dépôt **public** sur le Hub. Architectures reconnues : wav2vec2,
|
| 61 |
+
wav2vec2-BERT, HuBERT, MMS, Whisper.
|
app.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FonBench — leaderboard ASR pour le fongbe.
|
| 2 |
+
|
| 3 |
+
Vitrine publique du classement, formulaire de soumission, et lancement de
|
| 4 |
+
l'évaluateur de fond (voir evaluator.py). Cette interface ne lit que des
|
| 5 |
+
scores agrégés : ni l'audio ni les transcriptions du corpus de test n'y
|
| 6 |
+
transitent jamais.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import requests
|
| 17 |
+
|
| 18 |
+
import evaluator
|
| 19 |
+
|
| 20 |
+
SUPABASE_URL = os.environ.get(
|
| 21 |
+
"SUPABASE_URL", "https://cqdimvcnmhrsdcoobkmd.supabase.co"
|
| 22 |
+
).rstrip("/")
|
| 23 |
+
ANON_KEY = os.environ.get(
|
| 24 |
+
"SUPABASE_ANON_KEY", "sb_publishable_MapYll-_Y0hNoLYOfaDR3w_tsTEHyiz"
|
| 25 |
+
)
|
| 26 |
+
REST = f"{SUPABASE_URL}/rest/v1"
|
| 27 |
+
HEADERS = {"apikey": ANON_KEY}
|
| 28 |
+
|
| 29 |
+
CACHE_SECONDS = 60
|
| 30 |
+
_cache: dict = {}
|
| 31 |
+
|
| 32 |
+
# Indigo profond et ocre — les couleurs des tentures appliquées d'Abomey.
|
| 33 |
+
CSS = """
|
| 34 |
+
:root { --fb-indigo:#2a2a5e; --fb-ocre:#c8873b; }
|
| 35 |
+
.fb-title { font-size:2.1rem; font-weight:700; color:var(--fb-indigo);
|
| 36 |
+
margin-bottom:.15rem; letter-spacing:-.02em; }
|
| 37 |
+
.dark .fb-title { color:#b9b9ee; }
|
| 38 |
+
.fb-sub { color:#6b6b7b; margin-bottom:1rem; }
|
| 39 |
+
.fb-note { font-size:.9rem; line-height:1.55; }
|
| 40 |
+
.fb-badge { display:inline-block; padding:.1rem .5rem; border-radius:999px;
|
| 41 |
+
font-size:.75rem; font-weight:600; background:var(--fb-ocre);
|
| 42 |
+
color:#fff; }
|
| 43 |
+
footer { display:none !important; }
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --- lecture de la base -------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def fetch(path: str, params: dict, ttl: int = CACHE_SECONDS):
|
| 50 |
+
key = (path, tuple(sorted(params.items())))
|
| 51 |
+
hit = _cache.get(key)
|
| 52 |
+
if hit and time.time() - hit[0] < ttl:
|
| 53 |
+
return hit[1]
|
| 54 |
+
r = requests.get(f"{REST}/{path}", headers=HEADERS, params=params, timeout=30)
|
| 55 |
+
r.raise_for_status()
|
| 56 |
+
data = r.json()
|
| 57 |
+
_cache[key] = (time.time(), data)
|
| 58 |
+
return data
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_benchmarks() -> list[dict]:
|
| 62 |
+
return fetch("benchmarks", {"is_active": "eq.true", "select": "*",
|
| 63 |
+
"order": "id"}, ttl=600)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_results(benchmark_id: str) -> list[dict]:
|
| 67 |
+
return fetch("results", {"is_hidden": "eq.false",
|
| 68 |
+
"benchmark_id": f"eq.{benchmark_id}",
|
| 69 |
+
"select": "*"})
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# --- mise en forme ------------------------------------------------------
|
| 73 |
+
|
| 74 |
+
def pct(x) -> str:
|
| 75 |
+
return "—" if x is None else f"{float(x) * 100:.1f} %"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def params_txt(n) -> str:
|
| 79 |
+
if not n:
|
| 80 |
+
return "—"
|
| 81 |
+
n = int(n)
|
| 82 |
+
return f"{n / 1e9:.1f} Md".replace(".", ",") if n >= 1e9 else f"{n / 1e6:.0f} M"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_table(benchmark_id: str, archs: list[str], decoders: list[str],
|
| 86 |
+
hide_contaminated: bool, sort_by: str):
|
| 87 |
+
try:
|
| 88 |
+
rows = get_results(benchmark_id)
|
| 89 |
+
except Exception as exc: # noqa: BLE001
|
| 90 |
+
return (pd.DataFrame({"Erreur": [f"Base injoignable : {exc}"]}),
|
| 91 |
+
"", gr.update(), gr.update())
|
| 92 |
+
|
| 93 |
+
all_archs = sorted({r["architecture"] for r in rows if r.get("architecture")})
|
| 94 |
+
all_decs = sorted({r["decoder_type"] for r in rows if r.get("decoder_type")})
|
| 95 |
+
|
| 96 |
+
sel = rows
|
| 97 |
+
if archs:
|
| 98 |
+
sel = [r for r in sel if r.get("architecture") in archs]
|
| 99 |
+
if decoders:
|
| 100 |
+
sel = [r for r in sel if r.get("decoder_type") in decoders]
|
| 101 |
+
if hide_contaminated:
|
| 102 |
+
sel = [r for r in sel if not r.get("contamination_flag")]
|
| 103 |
+
|
| 104 |
+
# Le corpus annote-t-il les tons ? Si oui le T-WER fait foi, sinon on
|
| 105 |
+
# classe sur le WER_seg — comparer des WER bruts entre corpus dont les
|
| 106 |
+
# conventions tonales diffèrent n'aurait aucun sens.
|
| 107 |
+
tonal = any(r.get("twer") is not None for r in rows)
|
| 108 |
+
metric = "twer" if tonal else "wer_notone"
|
| 109 |
+
if sort_by == "Vitesse (RTFx)":
|
| 110 |
+
sel.sort(key=lambda r: -(r.get("rtfx") or 0))
|
| 111 |
+
elif sort_by == "Taille du modèle":
|
| 112 |
+
sel.sort(key=lambda r: (r.get("model_params") or 0))
|
| 113 |
+
else:
|
| 114 |
+
sel.sort(key=lambda r: (r.get(metric) is None, r.get(metric) or 9e9))
|
| 115 |
+
|
| 116 |
+
data = []
|
| 117 |
+
for i, r in enumerate(sel, 1):
|
| 118 |
+
medaille = {1: "🥇", 2: "🥈", 3: "🥉"}.get(i, "")
|
| 119 |
+
nom = f"[{r['model_id']}](https://huggingface.co/{r['model_id']})"
|
| 120 |
+
if r.get("contamination_flag"):
|
| 121 |
+
nom += " ⚠️"
|
| 122 |
+
data.append({
|
| 123 |
+
"#": f"{i} {medaille}".strip(),
|
| 124 |
+
"Modèle": nom,
|
| 125 |
+
"T-WER": pct(r.get("twer")),
|
| 126 |
+
"WER_seg": pct(r.get("wer_notone")),
|
| 127 |
+
"WER_ton": pct(r.get("wer_ton")),
|
| 128 |
+
"WER": pct(r.get("wer")),
|
| 129 |
+
"CER": pct(r.get("cer")),
|
| 130 |
+
"MER": pct(r.get("mer")),
|
| 131 |
+
"WIL": pct(r.get("wil")),
|
| 132 |
+
"RTFx": "—" if not r.get("rtfx") else f"{float(r['rtfx']):.1f}×",
|
| 133 |
+
"Taille": params_txt(r.get("model_params")),
|
| 134 |
+
"Architecture": r.get("architecture") or "—",
|
| 135 |
+
"Décodage": r.get("decoder_type") or "—",
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
bench = next((b for b in get_benchmarks() if b["id"] == benchmark_id), {})
|
| 139 |
+
entete = (
|
| 140 |
+
f"**{bench.get('name', benchmark_id)}** — "
|
| 141 |
+
f"{bench.get('num_utterances', '?')} énoncés · "
|
| 142 |
+
f"{bench.get('duration_hours', '?')} h"
|
| 143 |
+
+ (" · <span class='fb-badge'>corpus privé</span>"
|
| 144 |
+
if bench.get("is_private") else "")
|
| 145 |
+
+ f" · classement par **{'T-WER' if tonal else 'WER_seg'}**"
|
| 146 |
+
+ f" · {len(sel)} modèle(s) affiché(s) sur {len(rows)}"
|
| 147 |
+
)
|
| 148 |
+
return (pd.DataFrame(data), entete,
|
| 149 |
+
gr.update(choices=all_archs), gr.update(choices=all_decs))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
NOTE_METRIQUES = """
|
| 153 |
+
### Lire ce tableau
|
| 154 |
+
|
| 155 |
+
Le fongbe est une langue **tonale** : les tons s'écrivent avec des accents
|
| 156 |
+
(á, ɔ́, ě…) et changent le sens des mots. Or les corpus fongbe ne suivent pas
|
| 157 |
+
la même convention — certains ne notent aucun ton. Comparer un WER brut d'un
|
| 158 |
+
corpus à l'autre n'aurait donc pas de sens. D'où trois familles de mesures.
|
| 159 |
+
|
| 160 |
+
| Mesure | Ce qu'elle dit |
|
| 161 |
+
|---|---|
|
| 162 |
+
| **WER_seg** | erreurs sur les mots, **tons retirés**. Mesure la justesse phonétique et reste comparable entre tous les corpus. |
|
| 163 |
+
| **WER_ton** | erreurs portant sur les seules marques tonales. Vaut « — » quand le corpus n'annote pas les tons, pour ne pas fausser la mesure. |
|
| 164 |
+
| **T-WER** | `WER_seg + 2 × WER_ton`. Métrique phare : elle pénalise doublement les fautes de ton. |
|
| 165 |
+
| WER, CER | erreurs de mots et de caractères, tons compris. Repères classiques. |
|
| 166 |
+
| MER, WIL | *match error rate* et *word information lost* : plus robustes quand un modèle produit beaucoup d'insertions. |
|
| 167 |
+
| RTFx | durée d'audio traitée par seconde de calcul. **Plus haut = plus rapide.** |
|
| 168 |
+
|
| 169 |
+
Pour toutes ces mesures sauf le RTFx, **plus bas vaut mieux**.
|
| 170 |
+
|
| 171 |
+
⚠️ **Contamination.** Un modèle marqué ⚠️ a probablement été entraîné sur ce
|
| 172 |
+
jeu de test : son score paraît excellent sans rien prouver. Il reste affiché
|
| 173 |
+
par transparence, mais n'est pas comparable aux autres.
|
| 174 |
+
|
| 175 |
+
⏱️ **Vitesse.** Le RTFx dépend du matériel. Les modèles évalués sur le CPU du
|
| 176 |
+
Space sont bien plus lents que ceux mesurés sur GPU L4 : ne comparez les
|
| 177 |
+
vitesses qu'entre modèles évalués sur le même matériel.
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# --- file d'attente -----------------------------------------------------
|
| 182 |
+
|
| 183 |
+
def build_queue():
|
| 184 |
+
try:
|
| 185 |
+
rows = fetch("public_queue", {"select": "*", "order": "created_at.desc",
|
| 186 |
+
"limit": "50"}, ttl=15)
|
| 187 |
+
except Exception as exc: # noqa: BLE001
|
| 188 |
+
return pd.DataFrame({"Erreur": [f"Base injoignable : {exc}"]}), ""
|
| 189 |
+
|
| 190 |
+
etats = {"pending": "⏳ en attente", "running": "⚙️ en cours",
|
| 191 |
+
"done": "✅ terminé", "failed": "❌ échec",
|
| 192 |
+
"rejected": "🚫 refusé"}
|
| 193 |
+
data = []
|
| 194 |
+
for r in rows:
|
| 195 |
+
total = r.get("progress_total") or 0
|
| 196 |
+
done = r.get("progress_done") or 0
|
| 197 |
+
avance = f"{done}/{total}" if total else "—"
|
| 198 |
+
if total and r["status"] == "running":
|
| 199 |
+
avance += f" ({done * 100 // total} %)"
|
| 200 |
+
data.append({
|
| 201 |
+
"Modèle": r["model_id"],
|
| 202 |
+
"État": etats.get(r["status"], r["status"]),
|
| 203 |
+
"Avancement": avance,
|
| 204 |
+
"Détail": (r.get("error_message") or "")[:120],
|
| 205 |
+
"Déposé le": (r.get("created_at") or "")[:10],
|
| 206 |
+
})
|
| 207 |
+
|
| 208 |
+
etat = evaluator.status()
|
| 209 |
+
ligne = f"**Évaluateur :** {etat['message']}"
|
| 210 |
+
if etat.get("model"):
|
| 211 |
+
ligne += f" — {etat['model']} ({etat['done']}/{etat['total']})"
|
| 212 |
+
return pd.DataFrame(data), ligne
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# --- soumission ---------------------------------------------------------
|
| 216 |
+
|
| 217 |
+
def submit(model_id: str, hf_username: str, contact: str, note: str,
|
| 218 |
+
benchmark_id: str):
|
| 219 |
+
model_id = (model_id or "").strip()
|
| 220 |
+
if "/" not in model_id or len(model_id.split("/")) != 2 or \
|
| 221 |
+
not all(model_id.split("/")):
|
| 222 |
+
return ("### ❌ Format invalide\n"
|
| 223 |
+
"L'identifiant doit être de la forme `organisation/nom`, "
|
| 224 |
+
"tel qu'il apparaît dans l'URL du modèle sur Hugging Face.")
|
| 225 |
+
|
| 226 |
+
payload = {"model_id": model_id, "benchmark_id": benchmark_id}
|
| 227 |
+
for champ, valeur in (("hf_username", hf_username), ("contact", contact),
|
| 228 |
+
("note", note)):
|
| 229 |
+
if (valeur or "").strip():
|
| 230 |
+
payload[champ] = valeur.strip()
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
r = requests.post(
|
| 234 |
+
f"{REST}/public_requests",
|
| 235 |
+
headers={**HEADERS, "Content-Type": "application/json",
|
| 236 |
+
"Prefer": "return=minimal"},
|
| 237 |
+
json=payload, timeout=30,
|
| 238 |
+
)
|
| 239 |
+
except Exception as exc: # noqa: BLE001
|
| 240 |
+
return f"### ❌ Envoi impossible\nBase injoignable : {exc}"
|
| 241 |
+
|
| 242 |
+
if r.status_code >= 400:
|
| 243 |
+
# La base applique elle-même les garde-fous (doublon, débit, file
|
| 244 |
+
# pleine) et renvoie un message déjà rédigé en français.
|
| 245 |
+
try:
|
| 246 |
+
detail = r.json().get("message") or r.text
|
| 247 |
+
except Exception: # noqa: BLE001
|
| 248 |
+
detail = r.text
|
| 249 |
+
return f"### ❌ Demande refusée\n{detail}"
|
| 250 |
+
|
| 251 |
+
_cache.clear()
|
| 252 |
+
return (
|
| 253 |
+
f"### ✅ `{model_id}` est en file d'attente\n\n"
|
| 254 |
+
"L'évaluation tourne sur le CPU du Space : comptez une à deux heures "
|
| 255 |
+
"pour un modèle CTC de taille moyenne, bien davantage pour un gros "
|
| 256 |
+
"modèle autorégressif. Le score apparaîtra au classement une fois "
|
| 257 |
+
"calculé — suivez l'avancement dans l'onglet **File d'attente**."
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
A_PROPOS = """
|
| 262 |
+
## FonBench
|
| 263 |
+
|
| 264 |
+
Premier classement public de reconnaissance vocale pour le **fongbe**, langue
|
| 265 |
+
tonale parlée par environ 2,3 millions de personnes au Bénin. Le projet
|
| 266 |
+
répond à une difficulté concrète : jusqu'ici, les scores publiés sur le
|
| 267 |
+
fongbe n'étaient pas comparables entre eux, faute d'un protocole et d'un jeu
|
| 268 |
+
de test communs.
|
| 269 |
+
|
| 270 |
+
### Pourquoi le corpus de test n'est pas publié
|
| 271 |
+
|
| 272 |
+
Les corpus fongbe publics circulent depuis 2016, et plusieurs modèles
|
| 273 |
+
diffusés se sont entraînés dessus. Leur WER sur ces données paraît alors
|
| 274 |
+
remarquable — sans rien dire de leur capacité réelle à transcrire une voix
|
| 275 |
+
nouvelle. Nous l'avons mesuré : l'un des jeux de test que nous utilisions
|
| 276 |
+
partageait **83 % de ses énoncés** avec les données d'entraînement d'un split
|
| 277 |
+
public.
|
| 278 |
+
|
| 279 |
+
Le jeu de test principal (**2 555 énoncés, 4,98 h, 45 locuteurs**) n'est donc
|
| 280 |
+
pas diffusé. Ses locuteurs sont disjoints de ceux de l'entraînement, et une
|
| 281 |
+
coupure temporelle sépare les textes. Seuls les scores agrégés sont rendus
|
| 282 |
+
publics : personne — pas même via ce Space — ne peut en télécharger l'audio
|
| 283 |
+
ou les transcriptions.
|
| 284 |
+
|
| 285 |
+
### Le protocole
|
| 286 |
+
|
| 287 |
+
- **Révision figée.** Chaque score est rattaché au hash exact du dépôt évalué.
|
| 288 |
+
Republier un modèle sous le même nom ne change pas le score déjà obtenu.
|
| 289 |
+
- **Normalisation commune.** Même code de normalisation et de calcul pour
|
| 290 |
+
tous les modèles, tons compris. Il est ouvert : `fonbench_eval.py`.
|
| 291 |
+
- **Aucun code arbitraire.** Les modèles sont chargés avec
|
| 292 |
+
`trust_remote_code=False` : le code personnalisé éventuellement présent
|
| 293 |
+
dans un dépôt n'est jamais exécuté. Les poids `.bin`, encore majoritaires
|
| 294 |
+
sur les modèles fongbe, sont lus en mode `weights_only`.
|
| 295 |
+
- **Pas de calcul en double.** Un couple (modèle, révision, corpus) n'est
|
| 296 |
+
jamais réévalué : le score existant est réutilisé.
|
| 297 |
+
|
| 298 |
+
### Soumettre un modèle
|
| 299 |
+
|
| 300 |
+
Le dépôt doit être **public** sur le Hub. Architectures reconnues :
|
| 301 |
+
wav2vec2, wav2vec2-BERT, HuBERT, MMS, Whisper. Aucun compte n'est requis.
|
| 302 |
+
"""
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# --- interface ----------------------------------------------------------
|
| 306 |
+
|
| 307 |
+
def build_ui() -> gr.Blocks:
|
| 308 |
+
try:
|
| 309 |
+
benchs = get_benchmarks()
|
| 310 |
+
except Exception: # noqa: BLE001 — l'interface doit s'afficher malgré tout
|
| 311 |
+
benchs = []
|
| 312 |
+
choix = [(f"{b['name']} ({b.get('num_utterances', '?')} énoncés)", b["id"])
|
| 313 |
+
for b in benchs]
|
| 314 |
+
defaut = "jml-test-v1" if any(b["id"] == "jml-test-v1" for b in benchs) \
|
| 315 |
+
else (benchs[0]["id"] if benchs else "")
|
| 316 |
+
|
| 317 |
+
# Gradio 6 : `theme` et `css` se passent à launch(), plus au constructeur.
|
| 318 |
+
with gr.Blocks(title="FonBench — leaderboard ASR fongbe") as demo:
|
| 319 |
+
gr.HTML("<div class='fb-title'>FonBench</div>"
|
| 320 |
+
"<div class='fb-sub'>Classement public des modèles de "
|
| 321 |
+
"reconnaissance vocale pour le fongbe</div>")
|
| 322 |
+
|
| 323 |
+
with gr.Tabs():
|
| 324 |
+
with gr.Tab("Classement"):
|
| 325 |
+
with gr.Row():
|
| 326 |
+
b_sel = gr.Dropdown(choix, value=defaut, label="Corpus",
|
| 327 |
+
scale=3)
|
| 328 |
+
tri = gr.Dropdown(
|
| 329 |
+
["Qualité (métrique de classement)", "Vitesse (RTFx)",
|
| 330 |
+
"Taille du modèle"],
|
| 331 |
+
value="Qualité (métrique de classement)",
|
| 332 |
+
label="Trier par", scale=2)
|
| 333 |
+
with gr.Row():
|
| 334 |
+
f_arch = gr.Dropdown([], multiselect=True,
|
| 335 |
+
label="Architecture", scale=2)
|
| 336 |
+
f_dec = gr.Dropdown([], multiselect=True,
|
| 337 |
+
label="Décodage", scale=2)
|
| 338 |
+
f_cont = gr.Checkbox(label="Masquer les modèles contaminés",
|
| 339 |
+
scale=2)
|
| 340 |
+
rafraichir = gr.Button("Rafraîchir", scale=1)
|
| 341 |
+
|
| 342 |
+
entete = gr.Markdown()
|
| 343 |
+
table = gr.Dataframe(interactive=False, wrap=True,
|
| 344 |
+
datatype=["str", "markdown"] + ["str"] * 11)
|
| 345 |
+
gr.Markdown(NOTE_METRIQUES, elem_classes="fb-note")
|
| 346 |
+
|
| 347 |
+
entrees = [b_sel, f_arch, f_dec, f_cont, tri]
|
| 348 |
+
sorties = [table, entete, f_arch, f_dec]
|
| 349 |
+
for widget in (b_sel, tri, f_arch, f_dec, f_cont):
|
| 350 |
+
widget.change(build_table, entrees, sorties)
|
| 351 |
+
rafraichir.click(lambda: _cache.clear(), None, None).then(
|
| 352 |
+
build_table, entrees, sorties)
|
| 353 |
+
demo.load(build_table, entrees, sorties)
|
| 354 |
+
|
| 355 |
+
with gr.Tab("File d'attente"):
|
| 356 |
+
etat_line = gr.Markdown()
|
| 357 |
+
q_table = gr.Dataframe(interactive=False, wrap=True)
|
| 358 |
+
gr.Button("Rafraîchir").click(build_queue, None,
|
| 359 |
+
[q_table, etat_line])
|
| 360 |
+
gr.Markdown(
|
| 361 |
+
"L'évaluation tourne sur le CPU partagé du Space : comptez "
|
| 362 |
+
"**1 à 2 h** pour un modèle CTC de 300 M paramètres, et "
|
| 363 |
+
"**plusieurs jours** pour un gros modèle autorégressif de "
|
| 364 |
+
"type Whisper. Une évaluation interrompue par un "
|
| 365 |
+
"redémarrage reprend là où elle s'était arrêtée.",
|
| 366 |
+
elem_classes="fb-note")
|
| 367 |
+
demo.load(build_queue, None, [q_table, etat_line])
|
| 368 |
+
|
| 369 |
+
with gr.Tab("Soumettre un modèle"):
|
| 370 |
+
gr.Markdown(
|
| 371 |
+
"Le dépôt doit être **public** et ne pas dépendre de code "
|
| 372 |
+
"personnalisé : `trust_remote_code` est désactivé. Les "
|
| 373 |
+
"poids `safetensors` comme `.bin` sont acceptés. "
|
| 374 |
+
"Aucun compte requis.")
|
| 375 |
+
s_model = gr.Textbox(label="Identifiant Hugging Face",
|
| 376 |
+
placeholder="organisation/nom-du-modele")
|
| 377 |
+
with gr.Row():
|
| 378 |
+
s_user = gr.Textbox(label="Votre pseudo HF (facultatif)")
|
| 379 |
+
s_contact = gr.Textbox(label="Contact (facultatif)")
|
| 380 |
+
s_note = gr.Textbox(label="Note (facultatif)", lines=2,
|
| 381 |
+
placeholder="Base, données d'entraînement…")
|
| 382 |
+
s_bench = gr.Dropdown(choix, value=defaut, label="Corpus")
|
| 383 |
+
s_out = gr.Markdown()
|
| 384 |
+
gr.Button("Soumettre", variant="primary").click(
|
| 385 |
+
submit, [s_model, s_user, s_contact, s_note, s_bench], s_out)
|
| 386 |
+
|
| 387 |
+
with gr.Tab("À propos"):
|
| 388 |
+
gr.Markdown(A_PROPOS, elem_classes="fb-note")
|
| 389 |
+
|
| 390 |
+
return demo
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
if __name__ == "__main__":
|
| 394 |
+
evaluator.start()
|
| 395 |
+
build_ui().launch(
|
| 396 |
+
css=CSS,
|
| 397 |
+
theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="amber"),
|
| 398 |
+
)
|
evaluator.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FonBench — évaluateur de fond du Space.
|
| 2 |
+
|
| 3 |
+
Un unique thread démon : il prend une demande dans la file Supabase,
|
| 4 |
+
charge le corpus de test PRIVÉ, transcrit par tranches et republie les
|
| 5 |
+
scores. Aucun GPU : tout se fait sur le CPU du Space.
|
| 6 |
+
|
| 7 |
+
Trois précautions valent d'être expliquées, parce qu'elles dictent la
|
| 8 |
+
structure du fichier.
|
| 9 |
+
|
| 10 |
+
1. Le corpus est chargé AVANT le modèle, et le token qui y donne accès est
|
| 11 |
+
retiré de l'environnement dès l'import. Le Space charge des modèles
|
| 12 |
+
écrits par des inconnus : aucun ne doit tourner dans un processus où
|
| 13 |
+
traîne encore de quoi lire le corpus. S'y ajoute `trust_remote_code=False`
|
| 14 |
+
sans exception — c'est ce qui empêche le code personnalisé d'un dépôt de
|
| 15 |
+
s'exécuter.
|
| 16 |
+
|
| 17 |
+
2. On reprend après redémarrage. Un Space gratuit redémarre souvent et une
|
| 18 |
+
évaluation dure des heures : sans reprise, les gros modèles ne
|
| 19 |
+
finiraient jamais. Ce qu'on sauvegarde à chaque tranche, ce sont des
|
| 20 |
+
COMPTEURS d'erreurs, jamais des transcriptions — sinon on recopierait le
|
| 21 |
+
corpus privé dans la base. La somme des compteurs redonne exactement les
|
| 22 |
+
mêmes scores (voir fonbench_eval).
|
| 23 |
+
|
| 24 |
+
3. Le Space n'a pas la clé service de la base. Il écrit via quatre
|
| 25 |
+
procédures protégées par un jeton dédié : au pire, un jeton volé permet
|
| 26 |
+
de polluer la file publique, pas de toucher aux comptes ni aux scores.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
import threading
|
| 33 |
+
import time
|
| 34 |
+
import traceback
|
| 35 |
+
|
| 36 |
+
import requests
|
| 37 |
+
|
| 38 |
+
import fonbench_eval as fe
|
| 39 |
+
|
| 40 |
+
# --- configuration ------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
SUPABASE_URL = os.environ.get(
|
| 43 |
+
"SUPABASE_URL", "https://cqdimvcnmhrsdcoobkmd.supabase.co"
|
| 44 |
+
).rstrip("/")
|
| 45 |
+
# Publique par conception : la Row Level Security fait le tri, pas le secret.
|
| 46 |
+
ANON_KEY = os.environ.get(
|
| 47 |
+
"SUPABASE_ANON_KEY", "sb_publishable_MapYll-_Y0hNoLYOfaDR3w_tsTEHyiz"
|
| 48 |
+
)
|
| 49 |
+
SPACE_TOKEN = os.environ.get("SPACE_TOKEN", "")
|
| 50 |
+
|
| 51 |
+
# Sorti de l'environnement immédiatement : un modèle inconnu chargé plus
|
| 52 |
+
# tard ne doit pas pouvoir le lire dans os.environ.
|
| 53 |
+
_DATA_TOKEN = os.environ.pop("HF_DATA_TOKEN", None) or os.environ.pop(
|
| 54 |
+
"HF_TOKEN", None
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
REST = f"{SUPABASE_URL}/rest/v1"
|
| 58 |
+
HEADERS = {"apikey": ANON_KEY, "Content-Type": "application/json"}
|
| 59 |
+
|
| 60 |
+
WORKER_VERSION = "space-1.0"
|
| 61 |
+
POLL_SECONDS = 60
|
| 62 |
+
CHUNK_CTC = 50
|
| 63 |
+
CHUNK_SEQ2SEQ = 10 # un Whisper met des minutes par énoncé sur CPU
|
| 64 |
+
BATCH_CTC = 4
|
| 65 |
+
|
| 66 |
+
# Architectures autorégressives : pipeline + découpage à 30 s, sinon les
|
| 67 |
+
# énoncés longs font échouer Whisper.
|
| 68 |
+
SEQ2SEQ_TYPES = {
|
| 69 |
+
"whisper", "speech_to_text", "speech-encoder-decoder",
|
| 70 |
+
"speech_encoder_decoder", "seamless_m4t", "seamless_m4t_v2",
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
_state = {"message": "démarrage…", "model": None, "done": 0, "total": 0}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def log(msg: str) -> None:
|
| 77 |
+
print(f"[evaluator] {msg}", flush=True)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def status() -> dict:
|
| 81 |
+
return dict(_state)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# --- accès à la file ----------------------------------------------------
|
| 85 |
+
|
| 86 |
+
def rpc(name: str, payload: dict):
|
| 87 |
+
r = requests.post(
|
| 88 |
+
f"{REST}/rpc/{name}", headers=HEADERS, json=payload, timeout=60
|
| 89 |
+
)
|
| 90 |
+
if r.status_code >= 400:
|
| 91 |
+
raise RuntimeError(f"{name}: {r.status_code} {r.text[:200]}")
|
| 92 |
+
return r.json() if r.text.strip() else None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# --- audio --------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def decode_audio(cell, target_sr: int = 16000):
|
| 98 |
+
"""Décode un audio (WAV/FLAC/OGG/Opus/WebM/MP3…) en mono float32 16 kHz.
|
| 99 |
+
|
| 100 |
+
PyAV plutôt que soundfile : le corpus contient des conteneurs que
|
| 101 |
+
libsndfile ne sait pas ouvrir (WebM, Opus) et des fichiers dont
|
| 102 |
+
l'en-tête le fait échouer (« array is too big »).
|
| 103 |
+
"""
|
| 104 |
+
import io
|
| 105 |
+
|
| 106 |
+
import av
|
| 107 |
+
import numpy as np
|
| 108 |
+
|
| 109 |
+
raw = cell["bytes"] if isinstance(cell, dict) else cell
|
| 110 |
+
if raw is None and isinstance(cell, dict) and cell.get("path"):
|
| 111 |
+
with open(cell["path"], "rb") as f:
|
| 112 |
+
raw = f.read()
|
| 113 |
+
|
| 114 |
+
with av.open(io.BytesIO(raw)) as container:
|
| 115 |
+
stream = container.streams.audio[0]
|
| 116 |
+
resampler = av.audio.resampler.AudioResampler(
|
| 117 |
+
format="flt", layout="mono", rate=target_sr
|
| 118 |
+
)
|
| 119 |
+
chunks: list = []
|
| 120 |
+
|
| 121 |
+
def _emit(frame):
|
| 122 |
+
res = resampler.resample(frame)
|
| 123 |
+
for rf in res if isinstance(res, list) else ([res] if res else []):
|
| 124 |
+
chunks.append(rf.to_ndarray().reshape(-1))
|
| 125 |
+
|
| 126 |
+
for frame in container.decode(stream):
|
| 127 |
+
_emit(frame)
|
| 128 |
+
_emit(None) # flush du resampler
|
| 129 |
+
|
| 130 |
+
if not chunks:
|
| 131 |
+
return np.zeros(1, dtype="float32")
|
| 132 |
+
return np.concatenate(chunks).astype("float32")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# --- corpus -------------------------------------------------------------
|
| 136 |
+
|
| 137 |
+
_dataset_cache: dict = {}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def load_corpus(bench: dict):
|
| 141 |
+
"""Charge le corpus de test. Gardé en mémoire entre deux évaluations."""
|
| 142 |
+
from datasets import Audio, load_dataset
|
| 143 |
+
|
| 144 |
+
key = (bench["hf_dataset_id"], bench.get("hf_revision"), bench["hf_split"])
|
| 145 |
+
if key in _dataset_cache:
|
| 146 |
+
return _dataset_cache[key]
|
| 147 |
+
|
| 148 |
+
ds = load_dataset(
|
| 149 |
+
bench["hf_dataset_id"],
|
| 150 |
+
split=bench["hf_split"],
|
| 151 |
+
revision=bench.get("hf_revision") or None,
|
| 152 |
+
token=_DATA_TOKEN,
|
| 153 |
+
)
|
| 154 |
+
if bench.get("sample_size"):
|
| 155 |
+
ds = ds.shuffle(seed=bench.get("sample_seed") or 42)
|
| 156 |
+
ds = ds.select(range(min(bench["sample_size"], len(ds))))
|
| 157 |
+
ds = ds.cast_column("audio", Audio(decode=False))
|
| 158 |
+
_dataset_cache.clear() # 16 Go de RAM : un seul corpus à la fois
|
| 159 |
+
_dataset_cache[key] = ds
|
| 160 |
+
return ds
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# --- modèle -------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
class Transcriber:
|
| 166 |
+
"""Charge un modèle du Hub et transcrit des tableaux 16 kHz."""
|
| 167 |
+
|
| 168 |
+
def __init__(self, model_id: str, revision: str):
|
| 169 |
+
import torch
|
| 170 |
+
from transformers import AutoConfig
|
| 171 |
+
|
| 172 |
+
torch.set_num_threads(max(1, (os.cpu_count() or 2)))
|
| 173 |
+
self.torch = torch
|
| 174 |
+
self.model_id = model_id
|
| 175 |
+
|
| 176 |
+
cfg = AutoConfig.from_pretrained(
|
| 177 |
+
model_id, revision=revision, trust_remote_code=False
|
| 178 |
+
)
|
| 179 |
+
self.architecture = cfg.model_type
|
| 180 |
+
self.seq2seq = cfg.model_type in SEQ2SEQ_TYPES or any(
|
| 181 |
+
"ConditionalGeneration" in a or "Seq2Seq" in a
|
| 182 |
+
for a in (getattr(cfg, "architectures", None) or [])
|
| 183 |
+
)
|
| 184 |
+
self.chunk_size = CHUNK_SEQ2SEQ if self.seq2seq else CHUNK_CTC
|
| 185 |
+
self.decoder_type = "encoder-decoder" if self.seq2seq else "ctc"
|
| 186 |
+
|
| 187 |
+
if self.seq2seq:
|
| 188 |
+
self._load_seq2seq(model_id, revision)
|
| 189 |
+
else:
|
| 190 |
+
self._load_ctc(model_id, revision)
|
| 191 |
+
|
| 192 |
+
self.model_params = sum(p.numel() for p in self.model.parameters())
|
| 193 |
+
|
| 194 |
+
def _load_seq2seq(self, model_id, revision):
|
| 195 |
+
from transformers import (
|
| 196 |
+
AutoModelForSpeechSeq2Seq,
|
| 197 |
+
AutoProcessor,
|
| 198 |
+
pipeline,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
self.processor = AutoProcessor.from_pretrained(
|
| 202 |
+
model_id, revision=revision, trust_remote_code=False
|
| 203 |
+
)
|
| 204 |
+
self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
| 205 |
+
model_id, revision=revision, trust_remote_code=False,
|
| 206 |
+
dtype=self.torch.float32,
|
| 207 |
+
).eval()
|
| 208 |
+
self.pipe = pipeline(
|
| 209 |
+
"automatic-speech-recognition",
|
| 210 |
+
model=self.model,
|
| 211 |
+
tokenizer=self.processor.tokenizer,
|
| 212 |
+
feature_extractor=self.processor.feature_extractor,
|
| 213 |
+
# Sans ce découpage, tout énoncé de plus de 30 s fait échouer
|
| 214 |
+
# Whisper sur une incompatibilité de dimensions.
|
| 215 |
+
chunk_length_s=30,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
def _load_ctc(self, model_id, revision):
|
| 219 |
+
from transformers import AutoModelForCTC, AutoProcessor
|
| 220 |
+
|
| 221 |
+
self.processor = AutoProcessor.from_pretrained(
|
| 222 |
+
model_id, revision=revision, trust_remote_code=False
|
| 223 |
+
)
|
| 224 |
+
self.model = AutoModelForCTC.from_pretrained(
|
| 225 |
+
model_id, revision=revision, trust_remote_code=False,
|
| 226 |
+
).eval()
|
| 227 |
+
self.pipe = None
|
| 228 |
+
|
| 229 |
+
# MMS multilingue : les poids fongbe vivent dans un adaptateur séparé,
|
| 230 |
+
# sans quoi le modèle transcrit dans une tout autre langue.
|
| 231 |
+
try:
|
| 232 |
+
if getattr(self.model.config, "adapter_attn_dim", None):
|
| 233 |
+
self.model.load_adapter("fon")
|
| 234 |
+
self.processor.tokenizer.set_target_lang("fon")
|
| 235 |
+
log("adaptateur MMS « fon » chargé")
|
| 236 |
+
except Exception as exc: # noqa: BLE001 — modèle déjà spécialisé
|
| 237 |
+
log(f"pas d'adaptateur fon ({type(exc).__name__}) — poids tels quels")
|
| 238 |
+
|
| 239 |
+
def __call__(self, arrays: list) -> list[str]:
|
| 240 |
+
if self.seq2seq:
|
| 241 |
+
return [
|
| 242 |
+
(self.pipe(a)["text"] or "").strip() for a in arrays
|
| 243 |
+
]
|
| 244 |
+
out: list[str] = []
|
| 245 |
+
for i in range(0, len(arrays), BATCH_CTC):
|
| 246 |
+
batch = arrays[i:i + BATCH_CTC]
|
| 247 |
+
inputs = self.processor(
|
| 248 |
+
batch, sampling_rate=16000, return_tensors="pt", padding=True
|
| 249 |
+
)
|
| 250 |
+
with self.torch.inference_mode():
|
| 251 |
+
logits = self.model(**inputs).logits
|
| 252 |
+
ids = self.torch.argmax(logits, dim=-1)
|
| 253 |
+
out.extend(t.strip() for t in self.processor.batch_decode(ids))
|
| 254 |
+
return out
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# --- une évaluation -----------------------------------------------------
|
| 258 |
+
|
| 259 |
+
def check_model(model_id: str) -> str:
|
| 260 |
+
"""Révision figée du modèle. Lève si le dépôt est inutilisable."""
|
| 261 |
+
from huggingface_hub import HfApi
|
| 262 |
+
|
| 263 |
+
# Sans token : un modèle soumis doit être public pour être vérifiable.
|
| 264 |
+
api = HfApi(token=False)
|
| 265 |
+
try:
|
| 266 |
+
info = api.model_info(model_id, files_metadata=False)
|
| 267 |
+
except Exception: # noqa: BLE001
|
| 268 |
+
raise RuntimeError("Modèle introuvable ou privé sur le Hub.")
|
| 269 |
+
|
| 270 |
+
# On accepte aussi les poids `.bin` : la moitié des modèles fongbe
|
| 271 |
+
# publics datent d'avant safetensors, les exclure viderait le classement.
|
| 272 |
+
# Le risque du pickle est couvert ailleurs — transformers charge les .bin
|
| 273 |
+
# avec `weights_only=True` (donc sans exécution de code) depuis la 4.36,
|
| 274 |
+
# et `trust_remote_code=False` bloque le code personnalisé du dépôt.
|
| 275 |
+
names = [s.rfilename for s in (info.siblings or [])]
|
| 276 |
+
if not any(n.endswith((".safetensors", ".bin", ".ckpt", ".pt"))
|
| 277 |
+
for n in names):
|
| 278 |
+
raise RuntimeError(
|
| 279 |
+
"Aucun fichier de poids trouvé dans le dépôt "
|
| 280 |
+
"(.safetensors ou .bin attendu)."
|
| 281 |
+
)
|
| 282 |
+
return info.sha
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def run_job(job: dict) -> None:
|
| 286 |
+
request, bench = job["request"], job["benchmark"]
|
| 287 |
+
rid, model_id = request["id"], request["model_id"]
|
| 288 |
+
_state.update(model=model_id, message="préparation", done=0, total=0)
|
| 289 |
+
log(f"▶ {model_id} sur {bench['id']}")
|
| 290 |
+
|
| 291 |
+
revision = check_model(model_id)
|
| 292 |
+
|
| 293 |
+
# Corpus d'abord : le token de lecture ne doit jamais coexister avec un
|
| 294 |
+
# modèle inconnu déjà chargé en mémoire.
|
| 295 |
+
ds = load_corpus(bench)
|
| 296 |
+
total = len(ds)
|
| 297 |
+
|
| 298 |
+
verdict = rpc("space_begin", {
|
| 299 |
+
"p_token": SPACE_TOKEN, "p_id": rid,
|
| 300 |
+
"p_revision": revision, "p_total": total,
|
| 301 |
+
})
|
| 302 |
+
if verdict == "duplicate":
|
| 303 |
+
log(f"↷ {model_id} : déjà évalué à cette révision")
|
| 304 |
+
_state.update(message="doublon ignoré", model=None)
|
| 305 |
+
return
|
| 306 |
+
|
| 307 |
+
tr = Transcriber(model_id, revision)
|
| 308 |
+
chunk = tr.chunk_size
|
| 309 |
+
|
| 310 |
+
saved = job.get("progress") or {}
|
| 311 |
+
counters = saved.get("counters") or fe.new_counters()
|
| 312 |
+
counters = {k: int(counters.get(k, 0)) for k in fe.COUNTER_KEYS}
|
| 313 |
+
next_chunk = int(saved.get("next_chunk") or 0)
|
| 314 |
+
audio_s = float(saved.get("audio_seconds") or 0.0)
|
| 315 |
+
compute_s = float(saved.get("compute_seconds") or 0.0)
|
| 316 |
+
if next_chunk:
|
| 317 |
+
log(f"↻ reprise à la tranche {next_chunk} ({next_chunk * chunk} énoncés)")
|
| 318 |
+
|
| 319 |
+
# Les tailles de tranche sont calibrées pour qu'une tranche reste sous
|
| 320 |
+
# les 20 min au-delà desquelles la base considère la tâche abandonnée :
|
| 321 |
+
# 50 énoncés en CTC (~2 min), 10 seulement pour un Whisper sur CPU
|
| 322 |
+
# (~13 min dans le pire cas). Chaque tranche vaut donc battement de cœur.
|
| 323 |
+
n_chunks = (total + chunk - 1) // chunk
|
| 324 |
+
skipped = 0
|
| 325 |
+
|
| 326 |
+
for ci in range(next_chunk, n_chunks):
|
| 327 |
+
rows = ds.select(range(ci * chunk, min((ci + 1) * chunk, total)))
|
| 328 |
+
arrays, refs = [], []
|
| 329 |
+
for row in rows:
|
| 330 |
+
try:
|
| 331 |
+
arrays.append(decode_audio(row["audio"]))
|
| 332 |
+
refs.append(row["transcription"])
|
| 333 |
+
except Exception: # noqa: BLE001 — énoncé illisible : pas la faute du modèle
|
| 334 |
+
skipped += 1
|
| 335 |
+
|
| 336 |
+
if arrays:
|
| 337 |
+
t0 = time.time()
|
| 338 |
+
hyps = tr(arrays)
|
| 339 |
+
compute_s += time.time() - t0
|
| 340 |
+
audio_s += sum(len(a) for a in arrays) / 16000.0
|
| 341 |
+
fe.accumulate(counters, refs, hyps)
|
| 342 |
+
|
| 343 |
+
done = min((ci + 1) * chunk, total)
|
| 344 |
+
_state.update(message="évaluation", done=done, total=total)
|
| 345 |
+
rpc("space_checkpoint", {
|
| 346 |
+
"p_token": SPACE_TOKEN, "p_id": rid, "p_next_chunk": ci + 1,
|
| 347 |
+
"p_done": done, "p_counters": counters,
|
| 348 |
+
"p_audio": round(audio_s, 2), "p_compute": round(compute_s, 2),
|
| 349 |
+
})
|
| 350 |
+
rtfx = audio_s / compute_s if compute_s else 0
|
| 351 |
+
log(f" {done}/{total} — RTFx {rtfx:.2f}")
|
| 352 |
+
|
| 353 |
+
if skipped:
|
| 354 |
+
log(f"⚠ {skipped} énoncés illisibles écartés")
|
| 355 |
+
if not counters["n_scored"]:
|
| 356 |
+
raise RuntimeError("Aucune transcription exploitable produite.")
|
| 357 |
+
|
| 358 |
+
metrics = fe.finalize(counters)
|
| 359 |
+
rtfx = round(audio_s / compute_s, 3) if compute_s else None
|
| 360 |
+
rpc("space_finish", {
|
| 361 |
+
"p_token": SPACE_TOKEN, "p_id": rid, "p_status": "done",
|
| 362 |
+
"p_metrics": {k: metrics[k] for k in (
|
| 363 |
+
"wer", "cer", "mer", "wil", "wer_seg", "cer_seg", "wer_ton", "twer"
|
| 364 |
+
)},
|
| 365 |
+
"p_meta": {
|
| 366 |
+
"architecture": tr.architecture,
|
| 367 |
+
"decoder_type": tr.decoder_type,
|
| 368 |
+
"model_params": tr.model_params,
|
| 369 |
+
"rtfx": rtfx,
|
| 370 |
+
"rtf": round(1 / rtfx, 4) if rtfx else None,
|
| 371 |
+
"eval_seconds": round(compute_s, 1),
|
| 372 |
+
"hardware": "HF Space CPU",
|
| 373 |
+
"worker_version": WORKER_VERSION,
|
| 374 |
+
},
|
| 375 |
+
"p_error": None,
|
| 376 |
+
})
|
| 377 |
+
log(f"✔ {model_id} — WER_seg {metrics['wer_seg']:.1%} "
|
| 378 |
+
f"T-WER {metrics['twer']} RTFx {rtfx}")
|
| 379 |
+
_state.update(message="terminé", model=None, done=0, total=0)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def loop() -> None:
|
| 383 |
+
if not SPACE_TOKEN:
|
| 384 |
+
_state["message"] = "SPACE_TOKEN absent — évaluateur à l'arrêt"
|
| 385 |
+
log("SPACE_TOKEN absent : aucune évaluation ne sera lancée.")
|
| 386 |
+
return
|
| 387 |
+
if not _DATA_TOKEN:
|
| 388 |
+
_state["message"] = "HF_DATA_TOKEN absent — évaluateur à l'arrêt"
|
| 389 |
+
log("HF_DATA_TOKEN absent : le corpus privé est illisible.")
|
| 390 |
+
return
|
| 391 |
+
|
| 392 |
+
log("évaluateur démarré")
|
| 393 |
+
while True:
|
| 394 |
+
job = None
|
| 395 |
+
try:
|
| 396 |
+
job = rpc("space_claim", {"p_token": SPACE_TOKEN})
|
| 397 |
+
except Exception as exc: # noqa: BLE001 — réseau : on réessaie
|
| 398 |
+
log(f"⚠ space_claim : {exc}")
|
| 399 |
+
|
| 400 |
+
if not job:
|
| 401 |
+
_state.update(message="aucune tâche en attente", model=None)
|
| 402 |
+
time.sleep(POLL_SECONDS)
|
| 403 |
+
continue
|
| 404 |
+
|
| 405 |
+
try:
|
| 406 |
+
run_job(job)
|
| 407 |
+
except Exception as exc: # noqa: BLE001 — tout échec doit être publié
|
| 408 |
+
log(f"✘ échec : {exc}")
|
| 409 |
+
traceback.print_exc()
|
| 410 |
+
_state.update(message=f"échec : {exc}", model=None)
|
| 411 |
+
try:
|
| 412 |
+
rpc("space_finish", {
|
| 413 |
+
"p_token": SPACE_TOKEN, "p_id": job["request"]["id"],
|
| 414 |
+
"p_status": "failed", "p_metrics": None, "p_meta": None,
|
| 415 |
+
# Message court et sans extrait du corpus.
|
| 416 |
+
"p_error": f"{type(exc).__name__}: {exc}"[:300],
|
| 417 |
+
})
|
| 418 |
+
except Exception: # noqa: BLE001
|
| 419 |
+
traceback.print_exc()
|
| 420 |
+
finally:
|
| 421 |
+
import gc
|
| 422 |
+
|
| 423 |
+
gc.collect()
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def start() -> threading.Thread:
|
| 427 |
+
t = threading.Thread(target=loop, name="fonbench-eval", daemon=True)
|
| 428 |
+
t.start()
|
| 429 |
+
return t
|
fonbench_eval.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FonBench — normalisation du texte fongbe et métriques ASR.
|
| 2 |
+
|
| 3 |
+
Copie conforme du module de référence utilisé par le worker GPU : les
|
| 4 |
+
scores publiés par le Space doivent être comparables au chiffre près à
|
| 5 |
+
ceux déjà au classement.
|
| 6 |
+
|
| 7 |
+
Métriques (un seul passage) :
|
| 8 |
+
· Qualité : WER, CER, MER, WIL (jiwer)
|
| 9 |
+
· Segmentale : WER_seg / CER_seg — texte dé-tonalisé (accents retirés).
|
| 10 |
+
Non biaisée : comparable même entre corpus qui ne marquent
|
| 11 |
+
pas les tons (ALFFA n'en met aucune, JML les met toutes).
|
| 12 |
+
· Tonale : WER_ton — taux d'erreur sur les seules marques tonales.
|
| 13 |
+
None si le corpus n'annote pas les tons (sinon biaisé).
|
| 14 |
+
· Phare : T-WER = WER_seg + 2·WER_ton (double pénalité tonale).
|
| 15 |
+
|
| 16 |
+
S'y ajoute ici, par rapport au module du worker, une variante **par
|
| 17 |
+
tranches** (`accumulate` / `finalize`). Une évaluation sur CPU dure des
|
| 18 |
+
heures et le Space redémarre : il faut pouvoir reprendre. Mais stocker les
|
| 19 |
+
transcriptions déjà produites reviendrait à recopier le corpus privé hors
|
| 20 |
+
du Space. On ne garde donc que des compteurs d'erreurs, dont la somme
|
| 21 |
+
redonne *exactement* les mêmes scores — les alignements jiwer étant
|
| 22 |
+
indépendants d'un énoncé à l'autre, c'est une identité, pas une
|
| 23 |
+
approximation. `test_accumulation.py` le vérifie.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import re
|
| 29 |
+
import unicodedata
|
| 30 |
+
|
| 31 |
+
import jiwer
|
| 32 |
+
|
| 33 |
+
FONBENCH_EVAL_VERSION = "0.3.0"
|
| 34 |
+
|
| 35 |
+
_WHITESPACE = re.compile(r"\s+")
|
| 36 |
+
|
| 37 |
+
# Voyelles fongbe (base, après minuscules/NFD).
|
| 38 |
+
_VOWELS = set("aeiouɛɔ")
|
| 39 |
+
|
| 40 |
+
# Marques tonales combinantes → symbole de ton.
|
| 41 |
+
_TONE_MARKS = {
|
| 42 |
+
"́": "H", # accent aigu — ton haut
|
| 43 |
+
"̀": "L", # accent grave — ton bas
|
| 44 |
+
"̌": "R", # caron — ton montant
|
| 45 |
+
"̂": "F", # circonflexe — ton descendant
|
| 46 |
+
"̄": "M", # macron — ton moyen
|
| 47 |
+
}
|
| 48 |
+
# Seuil : en dessous, le corpus n'annote pas vraiment les tons → WER_ton biaisé.
|
| 49 |
+
_TONE_ANNOTATION_THRESHOLD = 0.10
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def normalize_fon(text: str) -> str:
|
| 53 |
+
"""Normalise un texte fongbe avant le calcul des métriques."""
|
| 54 |
+
text = unicodedata.normalize("NFC", text)
|
| 55 |
+
text = text.lower()
|
| 56 |
+
text = "".join(
|
| 57 |
+
ch
|
| 58 |
+
for ch in text
|
| 59 |
+
if unicodedata.category(ch)[0] in ("L", "M", "N") or ch.isspace()
|
| 60 |
+
)
|
| 61 |
+
return _WHITESPACE.sub(" ", text).strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def strip_tones(text: str) -> str:
|
| 65 |
+
"""Retire les marques tonales, garde les lettres fongbe.
|
| 66 |
+
|
| 67 |
+
ɖ, ɛ, ɔ, ŋ… sont des caractères atomiques (non décomposables) et restent ;
|
| 68 |
+
seuls les accents combinants (á→a, ɔ́→ɔ, ě→e) tombent.
|
| 69 |
+
"""
|
| 70 |
+
decomposed = unicodedata.normalize("NFD", text)
|
| 71 |
+
without_marks = "".join(
|
| 72 |
+
ch for ch in decomposed if unicodedata.category(ch) != "Mn"
|
| 73 |
+
)
|
| 74 |
+
return unicodedata.normalize("NFC", without_marks)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def tone_sequence(text: str) -> tuple[list[str], int]:
|
| 78 |
+
"""Séquence de tons (un par voyelle) + nombre de voyelles marquées."""
|
| 79 |
+
d = unicodedata.normalize("NFD", text)
|
| 80 |
+
seq: list[str] = []
|
| 81 |
+
marked = 0
|
| 82 |
+
i = 0
|
| 83 |
+
while i < len(d):
|
| 84 |
+
ch = d[i]
|
| 85 |
+
if ch in _VOWELS:
|
| 86 |
+
tone = "."
|
| 87 |
+
j = i + 1
|
| 88 |
+
while j < len(d) and unicodedata.category(d[j]) == "Mn":
|
| 89 |
+
if d[j] in _TONE_MARKS:
|
| 90 |
+
tone = _TONE_MARKS[d[j]]
|
| 91 |
+
j += 1
|
| 92 |
+
if tone != ".":
|
| 93 |
+
marked += 1
|
| 94 |
+
seq.append(tone)
|
| 95 |
+
i = j
|
| 96 |
+
else:
|
| 97 |
+
i += 1
|
| 98 |
+
return seq, marked
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def levenshtein(a: list[str], b: list[str]) -> int:
|
| 102 |
+
"""Distance d'édition entre deux séquences (espace linéaire)."""
|
| 103 |
+
if not a:
|
| 104 |
+
return len(b)
|
| 105 |
+
if not b:
|
| 106 |
+
return len(a)
|
| 107 |
+
prev = list(range(len(b) + 1))
|
| 108 |
+
for i, ca in enumerate(a, 1):
|
| 109 |
+
cur = [i]
|
| 110 |
+
for j, cb in enumerate(b, 1):
|
| 111 |
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
| 112 |
+
prev = cur
|
| 113 |
+
return prev[-1]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# --- calcul en un passage (référence) ----------------------------------
|
| 117 |
+
|
| 118 |
+
def compute_metrics(references: list[str], hypotheses: list[str]) -> dict:
|
| 119 |
+
"""Toutes les métriques FonBench, sur des listes parallèles réf/hyp."""
|
| 120 |
+
counters = new_counters()
|
| 121 |
+
accumulate(counters, references, hypotheses)
|
| 122 |
+
if not counters["n_scored"]:
|
| 123 |
+
raise ValueError("aucune référence non vide après normalisation")
|
| 124 |
+
return finalize(counters)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# --- calcul par tranches (reprise après redémarrage) --------------------
|
| 128 |
+
|
| 129 |
+
COUNTER_KEYS = (
|
| 130 |
+
"w_s", "w_d", "w_i", "w_h", # mots, tons compris
|
| 131 |
+
"c_s", "c_d", "c_i", "c_h", # caractères, tons compris
|
| 132 |
+
"ws_s", "ws_d", "ws_i", "ws_h", # mots dé-tonalisés
|
| 133 |
+
"cs_s", "cs_d", "cs_i", "cs_h", # caractères dé-tonalisés
|
| 134 |
+
"tone_dist", "tone_units", "tone_marked",
|
| 135 |
+
"n_scored",
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def new_counters() -> dict:
|
| 140 |
+
return {k: 0 for k in COUNTER_KEYS}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def accumulate(counters: dict, references: list[str], hypotheses: list[str]) -> dict:
|
| 144 |
+
"""Ajoute une tranche aux compteurs. Modifie et renvoie `counters`."""
|
| 145 |
+
if len(references) != len(hypotheses):
|
| 146 |
+
raise ValueError(
|
| 147 |
+
f"références ({len(references)}) et hypothèses ({len(hypotheses)}) "
|
| 148 |
+
"doivent avoir la même longueur"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
pairs = [
|
| 152 |
+
(normalize_fon(ref), normalize_fon(hyp))
|
| 153 |
+
for ref, hyp in zip(references, hypotheses)
|
| 154 |
+
]
|
| 155 |
+
# Une référence vide rendrait le WER indéfini : on écarte la paire.
|
| 156 |
+
pairs = [(r, h) for r, h in pairs if r]
|
| 157 |
+
if not pairs:
|
| 158 |
+
return counters
|
| 159 |
+
|
| 160 |
+
refs = [r for r, _ in pairs]
|
| 161 |
+
hyps = [h for _, h in pairs]
|
| 162 |
+
refs_seg = [strip_tones(r) for r in refs]
|
| 163 |
+
hyps_seg = [strip_tones(h) for h in hyps]
|
| 164 |
+
|
| 165 |
+
for prefix, out in (
|
| 166 |
+
("w", jiwer.process_words(refs, hyps)),
|
| 167 |
+
("c", jiwer.process_characters(refs, hyps)),
|
| 168 |
+
("ws", jiwer.process_words(refs_seg, hyps_seg)),
|
| 169 |
+
("cs", jiwer.process_characters(refs_seg, hyps_seg)),
|
| 170 |
+
):
|
| 171 |
+
counters[f"{prefix}_s"] += out.substitutions
|
| 172 |
+
counters[f"{prefix}_d"] += out.deletions
|
| 173 |
+
counters[f"{prefix}_i"] += out.insertions
|
| 174 |
+
counters[f"{prefix}_h"] += out.hits
|
| 175 |
+
|
| 176 |
+
for r, h in zip(refs, hyps):
|
| 177 |
+
rs, rm = tone_sequence(r)
|
| 178 |
+
hs, _ = tone_sequence(h)
|
| 179 |
+
counters["tone_dist"] += levenshtein(rs, hs)
|
| 180 |
+
counters["tone_units"] += len(rs)
|
| 181 |
+
counters["tone_marked"] += rm
|
| 182 |
+
|
| 183 |
+
counters["n_scored"] += len(refs)
|
| 184 |
+
return counters
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _rate(errors: int, total: int) -> float | None:
|
| 188 |
+
return round(errors / total, 4) if total else None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def finalize(counters: dict) -> dict:
|
| 192 |
+
"""Métriques finales à partir des compteurs cumulés."""
|
| 193 |
+
c = {k: int(counters.get(k, 0)) for k in COUNTER_KEYS}
|
| 194 |
+
|
| 195 |
+
ref_words = c["w_h"] + c["w_s"] + c["w_d"]
|
| 196 |
+
hyp_words = c["w_h"] + c["w_s"] + c["w_i"]
|
| 197 |
+
wer_errors = c["w_s"] + c["w_d"] + c["w_i"]
|
| 198 |
+
|
| 199 |
+
wer = _rate(wer_errors, ref_words)
|
| 200 |
+
mer = _rate(wer_errors, wer_errors + c["w_h"])
|
| 201 |
+
if not ref_words:
|
| 202 |
+
wil = None
|
| 203 |
+
elif not hyp_words:
|
| 204 |
+
# Modèle muet : plus aucune information transmise (jiwer pose wip = 0).
|
| 205 |
+
wil = 1.0
|
| 206 |
+
else:
|
| 207 |
+
wil = round(1 - (c["w_h"] / ref_words) * (c["w_h"] / hyp_words), 4)
|
| 208 |
+
|
| 209 |
+
cer = _rate(c["c_s"] + c["c_d"] + c["c_i"], c["c_h"] + c["c_s"] + c["c_d"])
|
| 210 |
+
wer_seg = _rate(
|
| 211 |
+
c["ws_s"] + c["ws_d"] + c["ws_i"], c["ws_h"] + c["ws_s"] + c["ws_d"]
|
| 212 |
+
)
|
| 213 |
+
cer_seg = _rate(
|
| 214 |
+
c["cs_s"] + c["cs_d"] + c["cs_i"], c["cs_h"] + c["cs_s"] + c["cs_d"]
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
units, marked = c["tone_units"], c["tone_marked"]
|
| 218 |
+
annotated = units > 0 and (marked / units) >= _TONE_ANNOTATION_THRESHOLD
|
| 219 |
+
wer_ton = round(c["tone_dist"] / units, 4) if annotated else None
|
| 220 |
+
twer = (
|
| 221 |
+
round(wer_seg + 2 * wer_ton, 4)
|
| 222 |
+
if wer_ton is not None and wer_seg is not None
|
| 223 |
+
else None
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"wer": wer,
|
| 228 |
+
"cer": cer,
|
| 229 |
+
"mer": mer,
|
| 230 |
+
"wil": wil,
|
| 231 |
+
"wer_seg": wer_seg,
|
| 232 |
+
"cer_seg": cer_seg,
|
| 233 |
+
"wer_ton": wer_ton,
|
| 234 |
+
"twer": twer,
|
| 235 |
+
"tone_annotated": annotated,
|
| 236 |
+
"num_utterances_scored": c["n_scored"],
|
| 237 |
+
"version": FONBENCH_EVAL_VERSION,
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
if __name__ == "__main__":
|
| 242 |
+
# Auto-tests : python fonbench_eval.py
|
| 243 |
+
assert normalize_fon("Ɖò xwégbè, é ɖù nǔ!") == "ɖò xwégbè é ɖù nǔ"
|
| 244 |
+
assert strip_tones("étɛ́ ká ɖíe") == "etɛ ka ɖie", strip_tones("étɛ́ ká ɖíe")
|
| 245 |
+
|
| 246 |
+
seq, marked = tone_sequence("étɛ́ ká ɖíe") # é=H, ɛ́=H, á=H, í=H, e=.
|
| 247 |
+
assert seq == ["H", "H", "H", "H", "."], seq
|
| 248 |
+
assert marked == 4, marked
|
| 249 |
+
|
| 250 |
+
# Erreur purement tonale : segmental parfait, tonal fautif
|
| 251 |
+
m = compute_metrics(["étɛ́ ká ɖíe"], ["etɛ ka ɖie"])
|
| 252 |
+
assert m["wer_seg"] == 0.0, m
|
| 253 |
+
assert m["wer_ton"] and m["wer_ton"] > 0, m
|
| 254 |
+
assert m["twer"] == round(0 + 2 * m["wer_ton"], 4), m
|
| 255 |
+
|
| 256 |
+
# Corpus sans tons (type ALFFA) : wer_ton doit être None (non biaisé)
|
| 257 |
+
m2 = compute_metrics(["un yi axi me"], ["un yi axi me"])
|
| 258 |
+
assert m2["wer_ton"] is None and m2["twer"] is None, m2
|
| 259 |
+
|
| 260 |
+
# Modèle muet : WIL saturé, pas de division par zéro
|
| 261 |
+
m3 = compute_metrics(["étɛ́ ká ɖíe"], [""])
|
| 262 |
+
assert m3["wil"] == 1.0, m3
|
| 263 |
+
|
| 264 |
+
print("fonbench_eval OK —", compute_metrics(["étɛ́ ká ɖíe"], ["etɛ ka die"]))
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Roues CPU : la roue torch par défaut embarque CUDA (~2,5 Go) pour rien
|
| 2 |
+
# sur un Space CPU basic, et fait dépasser la limite de build.
|
| 3 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 4 |
+
|
| 5 |
+
torch==2.5.1
|
| 6 |
+
transformers>=4.46,<5
|
| 7 |
+
# <4.0 : les versions 4.x imposent torchcodec pour décoder l'audio, alors
|
| 8 |
+
# qu'on décode nous-mêmes avec PyAV (Audio(decode=False)).
|
| 9 |
+
datasets>=2.20,<4.0
|
| 10 |
+
huggingface_hub>=0.25
|
| 11 |
+
av>=12.0
|
| 12 |
+
jiwer>=3.0
|
| 13 |
+
numpy<2
|
| 14 |
+
pandas
|
| 15 |
+
requests
|