Kimyayd commited on
Commit
e70a0ad
·
verified ·
1 Parent(s): 856c1b5

FonBench : leaderboard + evaluateur CPU autonome

Browse files
Files changed (5) hide show
  1. README.md +61 -15
  2. app.py +398 -0
  3. evaluator.py +429 -0
  4. fonbench_eval.py +264 -0
  5. requirements.txt +15 -0
README.md CHANGED
@@ -1,15 +1,61 @@
1
- ---
2
- title: FonBench
3
- emoji: 🏆
4
- colorFrom: red
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.24.0
8
- python_version: '3.12'
9
- app_file: app.py
10
- pinned: false
11
- license: apache-2.0
12
- short_description: 'Classement public des modèles de reconnaissance vocale pour '
13
- ---
14
-
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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