/* FonBench — version statique du classement. * * Un Space statique ne peut pas exécuter de Python : cette copie affiche le * classement, la file et le formulaire, mais l'évaluation reste sur le Space * Gradio qui détient le GPU et l'accès au corpus privé. Les données sont lues * en direct depuis la même base : rien ici n'est une copie figée. * * La clé ci-dessous est publique par conception — c'est la Row Level Security * qui décide de ce qui est lisible, pas le secret de la clé. */ const SUPABASE = "https://cqdimvcnmhrsdcoobkmd.supabase.co/rest/v1"; const ANON = "sb_publishable_MapYll-_Y0hNoLYOfaDR3w_tsTEHyiz"; const H = { apikey: ANON }; const EVALUATEUR = "https://huggingface.co/spaces/Kimyayd/FonBench"; const TYPES = { base: "🌍 General", maison: "🔧 FonBench", tiers: "👥 Community" }; // « Fine-tuned » réunit les deux origines : c'est la question la plus // fréquente — quels modèles ont vu du fongbe. const VUES = [ ["All models", null], ["🎯 Fine-tuned on Fon", ["maison", "tiers"]], ["🔧 By FonBench", ["maison"]], ["👥 By the community", ["tiers"]], ["🌍 General-purpose", ["base"]], ]; const ONGLETS = [ ["leaderboard", "Leaderboard"], ["queue", "Queue"], ["submit", "Submit a model"], ["finetuning", "Fine-tuning"], ["about", "About"], ]; let etat = { vue: 0, benchmarks: [], resultats: [], tri: null, sens: 1 }; /* --- accès aux données ------------------------------------------------ */ async function lire(chemin) { const r = await fetch(`${SUPABASE}/${chemin}`, { headers: H }); if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); return r.json(); } /* --- mise en forme ----------------------------------------------------- */ const pct = (x) => (x === null || x === undefined ? "—" : (x * 100).toFixed(1)); function taille(n) { if (!n) return "—"; return n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : `${Math.round(n / 1e6)}M`; } /* La colonne model_kind a d'abord valu « fongbe » ou « base » avant de passer * à trois valeurs. On retombe sur le préfixe du dépôt le cas échéant, sinon * une base à l'ancien schéma ferait disparaître des lignes entières. */ function categorie(r) { const k = r.model_kind; if (["base", "maison", "tiers"].includes(k)) return k; if (k === "fongbe") return r.model_id.startsWith("fonbench/") ? "maison" : "tiers"; return "base"; } function lienModele(id, checkpoint) { if (!id) return "—"; const url = `https://huggingface.co/${id}`; const base = `${id}`; return checkpoint ? `${base}
↳ ${checkpoint}` : base; } const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); /* --- classement -------------------------------------------------------- */ const COLONNES = [ ["#", null], ["Model", null], ["Type", null], ["T-WER", "twer"], ["WER_seg", "wer_notone"], ["WER_ton", "wer_ton"], ["WER", "wer"], ["CER", "cer"], ["MER", "mer"], ["WIL", "wil"], ["RTFx", "rtfx"], ["Size", "model_params"], ["Base model", null], ["Trained on", null], ["Architecture", null], ["Decoder", null], ]; function entraineSur(r) { if (!r.train_data) return "not declared"; if (r.train_data === "aucun fongbe") return "no Fon data"; return r.train_hours ? `${r.train_data} · ${+r.train_hours}h` : r.train_data; } function dessinerClassement() { const rows = etat.resultats; const bench = etat.benchmarks.find((b) => b.id === document.getElementById("bench").value) || {}; // Renseigne les listes d'architectures et de décodeurs à partir des données. for (const [id, champ] of [["arch", "architecture"], ["dec", "decoder_type"]]) { const sel = document.getElementById(id); const vals = [...new Set(rows.map((r) => r[champ]).filter(Boolean))].sort(); const courant = sel.value; sel.innerHTML = '' + vals.map((v) => `${esc(v)}`).join(""); } let sel = rows.slice(); const vises = VUES[etat.vue][1]; if (vises) sel = sel.filter((r) => vises.includes(categorie(r))); const a = document.getElementById("arch").value; const d = document.getElementById("dec").value; if (a) sel = sel.filter((r) => r.architecture === a); if (d) sel = sel.filter((r) => r.decoder_type === d); if (document.getElementById("contam").checked) sel = sel.filter((r) => !r.contamination_flag); // Le corpus annote-t-il les tons ? Si oui le T-WER fait foi, sinon le // WER_seg : comparer des WER bruts entre conventions tonales différentes // n'aurait aucun sens. const tonal = rows.some((r) => r.twer !== null && r.twer !== undefined); const metrique = tonal ? "twer" : "wer_notone"; const grand = 9e9; if (etat.tri) { sel.sort((x, y) => etat.sens * ((x[etat.tri] ?? grand) - (y[etat.tri] ?? grand))); } else { const mode = document.getElementById("sort").value; if (mode === "speed") sel.sort((x, y) => (y.rtfx ?? 0) - (x.rtfx ?? 0)); else if (mode === "size") sel.sort((x, y) => (x.model_params ?? 0) - (y.model_params ?? 0)); else sel.sort((x, y) => (x[metrique] ?? grand) - (y[metrique] ?? grand)); } const medaille = { 1: "🥇", 2: "🥈", 3: "🥉" }; const corps = sel.map((r, i) => { const ck = (r.model_revision || "").split(":"); const nom = lienModele(r.model_id, ck.length > 1 ? esc(ck.slice(1).join(":")) : null) + (r.contamination_flag ? " ⚠️" : ""); return ` ${i + 1} ${medaille[i + 1] || ""} ${nom}${TYPES[categorie(r)]} ${pct(r.twer)}${pct(r.wer_notone)} ${pct(r.wer_ton)}${pct(r.wer)} ${pct(r.cer)}${pct(r.mer)} ${pct(r.wil)} ${r.rtfx ? (+r.rtfx).toFixed(1) + "×" : "—"} ${taille(r.model_params)} ${lienModele(r.base_model, null)} ${esc(entraineSur(r))} ${esc(r.architecture || "—")}${esc(r.decoder_type || "—")} `; }).join(""); document.getElementById("board").innerHTML = `${COLONNES.map(([t, c], i) => `${t}${ etat.tri === c && c ? (etat.sens > 0 ? " ▲" : " ▼") : ""}`).join("")} ${corps || 'Aucun modèle.'}`; document.querySelectorAll("#board th[data-col]").forEach((th) => { const c = th.dataset.col; if (!c) return; th.onclick = () => { etat.sens = etat.tri === c ? -etat.sens : 1; etat.tri = c; dessinerClassement(); }; }); const compte = { maison: 0, tiers: 0, base: 0 }; sel.forEach((r) => compte[categorie(r)]++); const drapeau = ` `; document.getElementById("meta").innerHTML = `${drapeau} ${esc(bench.name || "")} ${bench.num_utterances ?? "?"} utterances ${bench.duration_hours ?? "?"} hours ${bench.is_private ? 'private test set' : ""} ranked by ${tonal ? "T-WER" : "WER_seg"} ${sel.length} of ${rows.length} models shown 🔧 ${compte.maison} fine-tuned by FonBench · 👥 ${compte.tiers} fine-tuned by the community · 🌍 ${compte.base} general-purpose`; document.getElementById("legend").innerHTML = "All error rates in %, lower is better — except RTFx, where " + "higher means faster. ⚠️ marks a model likely trained on this test " + "set: its score is not comparable. Click a column header to sort."; } async function chargerClassement() { const id = document.getElementById("bench").value; try { etat.resultats = await lire( `results?is_hidden=eq.false&benchmark_id=eq.${encodeURIComponent(id)}&select=*`); dessinerClassement(); } catch (e) { document.getElementById("board").innerHTML = `Database unreachable: ${esc(e.message)}`; } } /* --- file d'attente ---------------------------------------------------- */ async function chargerFile() { const etats = { pending: "⏳ pending", running: "⚙️ running", done: "✅ done", failed: "❌ failed", rejected: "🚫 rejected", }; try { const rows = await lire("public_queue?select=*&order=created_at.desc&limit=50"); const corps = rows.map((r) => { const t = r.progress_total || 0, f = r.progress_done || 0; let av = t ? `${f}/${t}` : "—"; if (t && r.status === "running") av += ` (${Math.floor((f * 100) / t)}%)`; return `${esc(r.model_id)}${etats[r.status] || esc(r.status)} ${av}${esc((r.error_message || "").slice(0, 120))} ${esc((r.created_at || "").slice(0, 10))}`; }).join(""); document.getElementById("queue").innerHTML = `ModelStatusProgressDetails Submitted ${corps || 'Queue empty.'}`; } catch (e) { document.getElementById("queue").innerHTML = `Database unreachable: ${esc(e.message)}`; } } /* --- soumission -------------------------------------------------------- */ async function soumettre(ev) { ev.preventDefault(); const sortie = document.getElementById("submit-out"); const val = (id) => document.getElementById(id).value.trim(); const modele = val("f-model"); const bouts = modele.split("/"); if (bouts.length !== 2 || !bouts[0] || !bouts[1]) { sortie.innerHTML = '
❌ Invalid format
The identifier ' + "must look like organisation/name, exactly as it appears in " + "the model URL on Hugging Face.
"; return; } const corps = { model_id: modele, benchmark_id: val("f-bench") }; for (const [champ, id] of [["hf_username", "f-user"], ["contact", "f-contact"], ["note", "f-note"], ["train_data", "f-data"], ["base_model", "f-base"]]) { if (val(id)) corps[champ] = val(id); } if (val("f-hours")) corps.train_hours = parseFloat(val("f-hours")); try { const r = await fetch(`${SUPABASE}/public_requests`, { method: "POST", headers: { ...H, "Content-Type": "application/json", Prefer: "return=minimal" }, body: JSON.stringify(corps), }); if (!r.ok) { // La base applique elle-même les garde-fous (doublon, débit, file // pleine) et renvoie un message déjà rédigé. let detail = await r.text(); try { detail = JSON.parse(detail).message || detail; } catch (_) {} sortie.innerHTML = `
❌ Submission rejected
${esc(detail)}
`; return; } sortie.innerHTML = `
${esc(modele)} is queued
Evaluation runs in slices on the GPU of Kimyayd/FonBench. Depending on the available quota, expect anywhere from a few minutes to a few hours. The score will appear in the leaderboard once computed — follow progress in the Queue tab.
`; chargerFile(); } catch (e) { sortie.innerHTML = `
❌ Could not submit
${esc(e.message)}
`; } } /* --- textes longs ------------------------------------------------------ */ const NOTE_METRIQUES = `

Reading the table

Fon is a tonal language: tones are written with diacritics (á, ɔ́, ě…) and change the meaning of words. But Fon corpora don't follow the same convention — some mark no tone at all. A raw WER therefore isn't comparable from one corpus to the next. Hence three families of measures.

MetricWhat it tells you
WER_segWord errors with tones stripped. Measures phonetic accuracy and stays comparable across every corpus.
WER_tonErrors on tone marks alone. Shown as “—” when the corpus doesn't annotate tones, so the figure is never misleading.
T-WERWER_seg + 2 × WER_ton. The headline metric: it penalises tone mistakes twice over.
WER, CERWord and character errors, tones included.
MER, WILMatch error rate and word information lost — more robust when a model produces many insertions.
RTFxSeconds of audio per second of compute. Higher is faster.

Model types. 🌍 General-purpose models were not built for Fon: some are multilingual systems that do cover it among a thousand other languages (the MMS family ships a Fon adapter), others are multilingual without Fon, and others again are monolingual systems for English or French. They are not base models in the pretrained sense — wav2vec2-large-960h-lv60-self is a finished English recogniser, not a starting point. The Trained on column says exactly what each one saw. 🔧 FonBench and 👥 Community models were fine-tuned on Fon.

Truly base models — raw pretrained checkpoints such as facebook/wav2vec2-large-xlsr-53 — cannot appear here at all: without a CTC head or a vocabulary they transcribe nothing. They show up only in the Base model column, as the starting point of the models fine-tuned from them.

Speed. RTFx depends on the hardware, recorded with each score. Only compare speeds at equal hardware.

`; const FINE_TUNING = `

The FonBench fine-tuning runs

Four pretrained models were fine-tuned on Fon under strictly identical conditions, so that any gap between them comes from the starting model alone.

The data. The training corpus holds 44,225 utterances, of which 13,716 were kept — 30.00 hours from 471 speakers. The sample is not drawn at random: it is built by taking turns across speakers, each contributing a little before any one of them dominates. For generalisation, the diversity of voices matters more than raw volume.

The 471 training speakers and the 45 test speakers are strictly disjoint — verified, zero in common. A temporal cutoff separates the transcripts; 20 sentences out of 2,555 (0.8%) do appear in both, too few to move a score but worth stating rather than claiming a perfect separation.

The protocol. Identical across all four: 3 epochs, i.e. 2,574 steps with 257 of warmup. CTC decoding, character vocabulary built from the training corpus, feature extractor taken from the base model. A single 24 GB L4, fp16, gradient checkpointing.

Resulting modelBaseLRBatch Wall timeDev lossWER_seg
wav2vec2-large-xlsr-53-fon-30hwav2vec2-large-xlsr-53 3·10⁻⁴8×254 min0.46838.6%
mms-300m-fon-30hmms-300m3·10⁻⁴ 8×2not retainednot retained42.8%
w2v-bert-2.0-fon-30hw2v-bert-2.03·10⁻⁵ 4×4116 min3.10296.9%
AfriHuBERT-fon-30hajesujoba/AfriHuBERT 3·10⁻⁵8×224 min3.078100.0%

Fine-tuning makes the difference, not the base. chrisjay/fonxlsr starts from exactly the same model as our best run — facebook/wav2vec2-large-xlsr-53, confirmed in its configuration — and reaches 69.3% where we reach 38.6%. The error is cut by a factor of 1.8 with 30 hours of well-chosen data.

Two runs out of four failed, and that deserves saying plainly. w2v-bert-2.0 and AfriHuBERT do learn: their loss drops clearly during training. But it plateaus around 3.1 against 0.47 for the run that succeeds — the signature of settling into the trivial CTC solution, emitting the blank symbol everywhere. This is not a learning-rate problem: both were first run at 3·10⁻⁴, then rerun at 3·10⁻⁵ — the figures in the table — for exactly the same outcome. The same collapse was later reproduced independently by three afrihubert-fon-asr-vanilla runs from another team, all between 97.8% and 99.7%; the same encoder preceded by continued pretraining on 960 h of Fon reaches 18.3%. Four failures and one success point at the same cause.

Both failures stay in the leaderboard. Removing them would paint a flattering and false picture of what fine-tuning guarantees.

Reproducing. The training and evaluation code is published at github.com/Izzoudine/EvalScripts — one script per evaluated model.

`; const A_PROPOS = `

FonBench

The first public speech-recognition leaderboard for Fon, a tonal language spoken by over two million people in Benin. The project answers a concrete difficulty: until now, published Fon results were not comparable with one another, for lack of a shared protocol and a shared test set.

Why the test set is not published

Public Fon corpora have been circulating since 2016, and several released models were trained on them. Their WER on that data then looks remarkable — while saying nothing about their real ability to transcribe an unseen voice. We measured it: one of the test sets we were using shared 83% of its utterances with the training split of a public dataset.

The main test set (2,555 utterances, 4.98 hours, 45 speakers) is therefore not distributed. Only aggregate scores are made public. It is not secret, though: request access to JMLdata/fon-test-v1 and you can recompute any row yourself.

The protocol

Where things run

This page is a static Space: it renders the leaderboard and accepts submissions, but cannot execute Python. Evaluation runs on Kimyayd/FonBench, which holds the GPU and the read access to the private test set. Both read and write the same database, so what you see here is live, not a copy.

Verify any number

Scoring code, a standalone evaluator and one script per evaluated model: github.com/Izzoudine/EvalScripts. Expect agreement within ±0.0002 — CTC padding depends on batch composition, and we would rather document that than round the published figures to three decimals.

`; /* --- démarrage --------------------------------------------------------- */ function onglets() { const nav = document.getElementById("tabs"); nav.innerHTML = ONGLETS.map(([id, titre], i) => ``).join(""); nav.querySelectorAll("button").forEach((b) => { b.onclick = () => { nav.querySelectorAll("button").forEach((x) => x.setAttribute("aria-selected", x === b)); ONGLETS.forEach(([id]) => document.getElementById(`tab-${id}`).classList.toggle("hidden", id !== b.dataset.tab)); if (b.dataset.tab === "queue") chargerFile(); }; }); } async function demarrer() { onglets(); document.getElementById("metrics-note").innerHTML = NOTE_METRIQUES; document.getElementById("ft").innerHTML = FINE_TUNING; document.getElementById("about").innerHTML = A_PROPOS; document.getElementById("views").innerHTML = VUES.map(([t], i) => ``).join(""); document.querySelectorAll("#views button").forEach((b) => { b.onclick = () => { etat.vue = +b.dataset.vue; document.querySelectorAll("#views button").forEach((x) => x.setAttribute("aria-pressed", x === b)); dessinerClassement(); }; }); try { etat.benchmarks = await lire("benchmarks?is_active=eq.true&select=*&order=id"); } catch (e) { document.getElementById("meta").textContent = `Database unreachable: ${e.message}`; return; } const options = etat.benchmarks.map((b) => ``).join(""); document.getElementById("bench").innerHTML = options; document.getElementById("f-bench").innerHTML = options; ["bench", "sort", "arch", "dec"].forEach((id) => { document.getElementById(id).onchange = () => { etat.tri = null; if (id === "bench") chargerClassement(); else dessinerClassement(); }; }); document.getElementById("contam").onchange = dessinerClassement; document.getElementById("refresh").onclick = chargerClassement; document.getElementById("refresh-queue").onclick = chargerFile; document.getElementById("submit-form").onsubmit = soumettre; chargerClassement(); } demarrer();