muchai-mercy commited on
Commit
2cdca7d
·
0 Parent(s):

pazabench version 2 release

Browse files
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tflite filter=lfs diff=lfs merge=lfs -text
29
+ *.tgz filter=lfs diff=lfs merge=lfs -text
30
+ *.wasm filter=lfs diff=lfs merge=lfs -text
31
+ *.xz filter=lfs diff=lfs merge=lfs -text
32
+ *.zip filter=lfs diff=lfs merge=lfs -text
33
+ *.zst filter=lfs diff=lfs merge=lfs -text
34
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
35
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ auto_evals/
2
+ venv/
3
+ .venv/
4
+ __pycache__/
5
+ .env
6
+ .ipynb_checkpoints
7
+ *ipynb
8
+ .vscode/
9
+
10
+ eval-queue/
11
+ eval-results/
12
+ eval-queue-bk/
13
+ eval-results-bk/
14
+ logs/
15
+
16
+ results/
17
+ *.pem
18
+
19
+ deployment.zip
20
+
21
+ # Deployment files
22
+ download_data_assets.sh
23
+ deployment.sh
24
+ dev-results_summary.csv
25
+ # docker-compose.yml
26
+ docker-compose.dev.yml
27
+ # Dockerfile
28
+ pyproject.toml
29
+ uv.lock
30
+ startup.sh
31
+ .dockerignore
32
+ results_summary.csv
33
+ upload_results_summary.py
34
+ *results_summary*
35
+
36
+ # OS/IDE files
37
+ .DS_Store
38
+ *.swp
39
+ *.swo
40
+ Thumbs.db
41
+
42
+ # Gradio cache
43
+ .gradio/
44
+
45
+ .env.example
46
+ src/aggregate_results.py
47
+ docker-compose.yml
48
+ .gitignore
49
+ src/aggregate_results.py
50
+ Dockerfile
51
+
README.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PazaBench
3
+ emoji: 🥇
4
+ colorFrom: green
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: true
9
+ license: mit
10
+ short_description: ASR Leaderboard for low resource languages
11
+ sdk_version: 5.43.1
12
+ tags:
13
+ - leaderboard
14
+ ---
15
+
16
+ # PazaBench Leaderboard
17
+
18
+ An Automatic Speech Recognition (ASR) benchmark for low-resource languages developed by **Microsoft Research Africa, Nairobi Lab**. Launching with **39 African Languages** across **52 State-of-the-Art ASR and Language Models**, PazaBench compares three key metrics: **Character Error Rate (CER)**, **Word Error Rate (WER)**, and **RTFx (Inverse Real-Time Factor)**.
app.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any
3
+
4
+ import gradio as gr
5
+ import pandas as pd
6
+ from apscheduler.schedulers.background import BackgroundScheduler
7
+ from huggingface_hub import snapshot_download
8
+
9
+
10
+ from src.about import (
11
+ CITATION_BUTTON_LABEL,
12
+ CITATION_BUTTON_TEXT,
13
+ CHANGELOG_HEADER,
14
+ CHANGELOG_CARDS_HTML,
15
+ EVALUATION_LANGUAGE_TEXT,
16
+ get_dataset_group_label,
17
+ get_introduction_text,
18
+ get_llm_benchmarks_text,
19
+ get_llm_benchmarks_datasets_text,
20
+ )
21
+ from src.constants import (
22
+ ASR_DISPLAY_COLUMNS,
23
+ FILTER_COLUMN_ORDER,
24
+ FILTER_PARAM_MAP,
25
+ INTERPRETATIONS,
26
+ METRIC_CONFIGS,
27
+ )
28
+ from src.display.css_html_js import (
29
+ custom_css,
30
+ fix_aria_hidden_focus_js,
31
+ light_mode_head,
32
+ chart_gallery_html,
33
+ section_divider_html,
34
+ footer_html,
35
+ )
36
+ from src.display.utils import EVAL_COLS
37
+ from src.language_metadata import get_all_regions
38
+ from src.envs import API, EVAL_REQUESTS_PATH, QUEUE_REPO, REPO_ID, TOKEN, HF_ENABLED
39
+ from src.populate import get_evaluation_queue_df
40
+ from src.submission.submit import add_language_eval_request
41
+ from src.visualizations import (
42
+ create_model_leaderboard,
43
+ create_cer_leaderboard,
44
+ create_speed_accuracy_scatter,
45
+ create_wer_cer_correlation,
46
+ create_language_coverage_chart,
47
+ create_language_location_map,
48
+ get_language_sample_info,
49
+ get_all_languages,
50
+ )
51
+ from src.data_processing import (
52
+ load_asr_results,
53
+ get_filter_options,
54
+ filter_asr_dataframe,
55
+ compute_metric_tables,
56
+ compute_metric_dataframes,
57
+ strip_dataset_labels,
58
+ VIEW_MODE_COLUMNS,
59
+ DEFAULT_VIEW_MODE,
60
+ )
61
+
62
+ from src.telemetry import (
63
+ track_event,
64
+ track_filter_change,
65
+ track_visualization_view,
66
+ track_error,
67
+ )
68
+ # App-specific constants
69
+ SORT_DIRECTION_CHOICES = ["Ascending", "Descending"]
70
+ DEFAULT_SORT_DIRECTION = "Ascending"
71
+
72
+
73
+ def _sorted_column_values(frame: pd.DataFrame, column: str) -> list[str]:
74
+ """Get sorted unique values from a column."""
75
+ if column not in frame.columns or frame.empty:
76
+ return []
77
+ values = sorted({value for value in frame[column].dropna().unique() if value != "Unknown"})
78
+ if (frame[column] == "Unknown").any():
79
+ values.append("Unknown")
80
+ return values
81
+
82
+
83
+ def update_metric_tables(models, languages, dataset_groups, view_mode, african_regions=None):
84
+ """Update HTML metric tables based on current filters."""
85
+ clean_dataset_groups = strip_dataset_labels(dataset_groups)
86
+ tables = compute_metric_tables(
87
+ models or [],
88
+ languages or [],
89
+ clean_dataset_groups,
90
+ view_mode,
91
+ african_regions=african_regions or [],
92
+ asr_results_df=ASR_RESULTS_DF,
93
+ )
94
+ return [tables.get("cer", ""), tables.get("wer", ""), tables.get("rtfx", "")]
95
+
96
+
97
+ def update_metric_dataframes(models, languages, dataset_groups, view_mode, african_regions=None, language_support=None):
98
+ """Update metric dataframes for interactive sorting."""
99
+ clean_dataset_groups = strip_dataset_labels(dataset_groups)
100
+ dataframes = compute_metric_dataframes(
101
+ models or [],
102
+ languages or [],
103
+ clean_dataset_groups,
104
+ view_mode,
105
+ african_regions=african_regions or [],
106
+ language_support=language_support or [],
107
+ asr_results_df=ASR_RESULTS_DF,
108
+ )
109
+ return [
110
+ dataframes.get("cer", pd.DataFrame()),
111
+ dataframes.get("wer", pd.DataFrame()),
112
+ dataframes.get("rtfx", pd.DataFrame())
113
+ ]
114
+
115
+
116
+ def _language_support_to_list(value: str | None) -> list[str]:
117
+ """Convert the single-select 'Model Language support' choice to the backend list form."""
118
+ if not value or value == "All":
119
+ return []
120
+ return [value]
121
+
122
+
123
+ def refresh_filter_dropdowns(
124
+ models: list[str] | None,
125
+ languages: list[str] | None,
126
+ dataset_groups: list[str] | None,
127
+ african_regions: list[str] | None = None,
128
+ ):
129
+ """Refresh filter dropdown options based on current selections."""
130
+ if ASR_RESULTS_DF.empty:
131
+ empty_update = gr.update(choices=[], value=None)
132
+ return [empty_update] * (len(FILTER_COLUMN_ORDER) + 1)
133
+
134
+ clean_dataset_groups = strip_dataset_labels(dataset_groups) if dataset_groups else []
135
+
136
+ selections = {
137
+ "models": list(models or []),
138
+ "languages": list(languages or []),
139
+ "dataset_groups": clean_dataset_groups,
140
+ }
141
+
142
+ updates: list[Any] = []
143
+ for column in FILTER_COLUMN_ORDER:
144
+ param = FILTER_PARAM_MAP[column]
145
+ filter_kwargs = {key: list(value) for key, value in selections.items()}
146
+ filter_kwargs[param] = []
147
+ filtered = filter_asr_dataframe(
148
+ ASR_RESULTS_DF,
149
+ models=filter_kwargs["models"],
150
+ languages=filter_kwargs["languages"],
151
+ dataset_groups=filter_kwargs["dataset_groups"],
152
+ african_regions=african_regions,
153
+ )
154
+ options = _sorted_column_values(filtered, column)
155
+ current_selection = [value for value in selections[param] if value in options]
156
+
157
+ if column == "dataset_group":
158
+ labeled_options = [get_dataset_group_label(dg, ASR_RESULTS_DF) for dg in options]
159
+ labeled_selection = [get_dataset_group_label(dg, ASR_RESULTS_DF) for dg in current_selection] if current_selection else None
160
+ updates.append(gr.update(choices=labeled_options, value=labeled_selection))
161
+ else:
162
+ updates.append(gr.update(choices=options, value=current_selection or None))
163
+
164
+ updates.append(gr.update(choices=get_all_regions(), value=african_regions or None))
165
+ return updates
166
+
167
+
168
+ # Initialize data
169
+ try:
170
+ ASR_RESULTS_DF = load_asr_results()
171
+ ASR_RESULTS_ERROR: str | None = None
172
+ except (FileNotFoundError, ValueError) as err:
173
+ ASR_RESULTS_DF = pd.DataFrame(columns=ASR_DISPLAY_COLUMNS)
174
+ ASR_RESULTS_ERROR = str(err)
175
+ track_error(err, {"source": "results_csv"}, context="data_load")
176
+
177
+ ASR_FILTER_OPTIONS = get_filter_options(ASR_RESULTS_DF) if ASR_RESULTS_ERROR is None else {
178
+ "model": [],
179
+ "language": [],
180
+ "dataset_group": [],
181
+ "dataset_group_labels": [],
182
+ "african_region": [],
183
+ "language_support": [],
184
+ }
185
+
186
+ if ASR_RESULTS_ERROR is None:
187
+ ASR_INITIAL_TABLES = compute_metric_tables([], [], [], DEFAULT_VIEW_MODE, asr_results_df=ASR_RESULTS_DF)
188
+ ASR_INITIAL_DATAFRAMES = compute_metric_dataframes([], [], [], DEFAULT_VIEW_MODE, language_support=[], asr_results_df=ASR_RESULTS_DF)
189
+ else:
190
+ ASR_INITIAL_TABLES = {metric: f"<p class='metric-table-empty'>⚠️ {ASR_RESULTS_ERROR}</p>" for metric in METRIC_CONFIGS}
191
+ ASR_INITIAL_DATAFRAMES = {metric: pd.DataFrame() for metric in METRIC_CONFIGS}
192
+
193
+ (
194
+ finished_eval_queue_df,
195
+ running_eval_queue_df,
196
+ pending_eval_queue_df,
197
+ ) = get_evaluation_queue_df(EVAL_REQUESTS_PATH, EVAL_COLS)
198
+
199
+
200
+ def restart_space():
201
+ """Restart the HuggingFace Space (only if HF is enabled)."""
202
+ if HF_ENABLED and API:
203
+ try:
204
+ API.restart_space(repo_id=REPO_ID)
205
+ except Exception as e:
206
+ print(f"Could not restart space: {e}")
207
+ else:
208
+ print("HuggingFace Hub disabled, skipping space restart")
209
+
210
+ ### Space initialisation
211
+ if HF_ENABLED:
212
+ try:
213
+ print(f"Downloading requests from {QUEUE_REPO}...")
214
+ print(EVAL_REQUESTS_PATH)
215
+ snapshot_download(
216
+ repo_id=QUEUE_REPO, local_dir=EVAL_REQUESTS_PATH, repo_type="dataset", tqdm_class=None, etag_timeout=30, token=TOKEN
217
+ )
218
+ except Exception as e:
219
+ print(f"Could not download requests: {e}")
220
+ else:
221
+ print("HuggingFace Hub disabled, skipping dataset downloads")
222
+
223
+ demo = gr.Blocks(css=custom_css, head=light_mode_head)
224
+ with demo:
225
+ # Inject accessibility fix for aria-hidden focusable elements (WCAG 4.1.2)
226
+ gr.HTML(fix_aria_hidden_focus_js)
227
+ gr.Image(
228
+ value="src/assets/PazaBench-Banner2.png",
229
+ show_label=False,
230
+ container=False,
231
+ elem_id="banner-image",
232
+ )
233
+
234
+ with gr.Tabs(elem_classes="tab-buttons") as tabs:
235
+ with gr.TabItem("🎙️ ASR Metrics", elem_id="asr-results-tab", id=0):
236
+ if ASR_RESULTS_ERROR:
237
+ gr.Markdown(f"⚠️ {ASR_RESULTS_ERROR}")
238
+ else:
239
+ gr.Markdown(get_introduction_text(ASR_RESULTS_DF), elem_classes="markdown-text")
240
+ gr.Markdown(
241
+ "### Compare models at a glance. Use the filters below to customize your view "
242
+ "or explore the data directly in the tables.",
243
+ elem_classes="explainer-text"
244
+ )
245
+
246
+ # Filter and customise accordion (collapsed by default)
247
+ with gr.Accordion("🔧 Filter and customise", open=False, elem_id="filter-customize-accordion"):
248
+ gr.Markdown(
249
+ "Compare all models by default, or narrow the view by model, language, region, or dataset. "
250
+ "Choose whether columns show model families or individual models."
251
+ )
252
+ with gr.Row():
253
+ model_filter = gr.Dropdown(
254
+ choices=ASR_FILTER_OPTIONS["model"],
255
+ multiselect=True,
256
+ label="Model",
257
+ value=None,
258
+ allow_custom_value=False,
259
+ )
260
+ language_filter = gr.Dropdown(
261
+ choices=ASR_FILTER_OPTIONS["language"],
262
+ multiselect=True,
263
+ label="Language",
264
+ value=None,
265
+ allow_custom_value=False,
266
+ )
267
+ dataset_filter = gr.Dropdown(
268
+ choices=ASR_FILTER_OPTIONS.get("dataset_group_labels", ASR_FILTER_OPTIONS["dataset_group"]),
269
+ multiselect=True,
270
+ label="Dataset group",
271
+ value=None,
272
+ allow_custom_value=False,
273
+ )
274
+
275
+ with gr.Row():
276
+ african_region_filter = gr.Dropdown(
277
+ choices=ASR_FILTER_OPTIONS.get("african_region", []),
278
+ multiselect=True,
279
+ label="African Region",
280
+ value=None,
281
+ allow_custom_value=False,
282
+ info="Filter languages by geographic region",
283
+ )
284
+ language_support_filter = gr.Dropdown(
285
+ choices=[("All", "All"), ("Multilingual", "Multilingual"), ("English Only", "English-only")],
286
+ multiselect=False,
287
+ label="Model Language support",
288
+ value="All",
289
+ allow_custom_value=False,
290
+ info="All models, multilingual or English-only",
291
+ )
292
+
293
+ view_mode_radio = gr.Radio(
294
+ choices=list(VIEW_MODE_COLUMNS.keys()),
295
+ value=DEFAULT_VIEW_MODE,
296
+ label="Columns show",
297
+ interactive=True,
298
+ )
299
+
300
+ apply_filters_button = gr.Button("Update dashboard", variant="primary")
301
+
302
+ gr.Markdown(
303
+ "💡 **Leaderboard Guide:** *Languages are ordered alphabetically.* Model families are **ranked from left to right by the average performance across languages**. *Click on filter & customize above to filter languages or model.*",
304
+ elem_classes="markdown-text"
305
+ )
306
+
307
+ with gr.Tabs(elem_classes="metric-tabs"):
308
+ with gr.TabItem("CER ⬇️"):
309
+ gr.Markdown("📉 **Lower is better** — Character Error Rate measures the percentage of characters incorrectly transcribed. This is especially important for languages with rich word forms, where meaning is built by combining word parts, therefore errors at the character level can significantly impact meaning.<br><br>*See 📊 Visualizations tab for CER Performance chart.*", elem_classes="metric-note")
310
+ cer_table = gr.Dataframe(
311
+ value=ASR_INITIAL_DATAFRAMES.get("cer", pd.DataFrame()),
312
+ elem_id="cer-leaderboard-table",
313
+ interactive=False,
314
+ wrap=False,
315
+ max_height=800,
316
+ )
317
+ with gr.TabItem("WER ⬇️"):
318
+ gr.Markdown("📉 **Lower is better** — Word Error Rate measures the percentage of words incorrectly transcribed.<br><br>*See 📊 Visualizations tab for WER Performance chart.*", elem_classes="metric-note")
319
+ wer_table = gr.Dataframe(
320
+ value=ASR_INITIAL_DATAFRAMES.get("wer", pd.DataFrame()),
321
+ elem_id="wer-leaderboard-table",
322
+ interactive=False,
323
+ wrap=False,
324
+ max_height=800,
325
+ )
326
+ with gr.TabItem("RTFx ⬆️"):
327
+ gr.Markdown("📈 **Higher is better** — Inverse Real-Time Factor (RTFx) measures how fast transcription runs relative to real-time audio duration.<br><br>*See 📊 Visualizations tab for Speed vs Accuracy chart.*", elem_classes="metric-note")
328
+ rtfx_table = gr.Dataframe(
329
+ value=ASR_INITIAL_DATAFRAMES.get("rtfx", pd.DataFrame()),
330
+ elem_id="rtfx-leaderboard-table",
331
+ interactive=False,
332
+ wrap=False,
333
+ max_height=800,
334
+ )
335
+
336
+ def update_metric_dataframes_with_telemetry(models, languages, dataset_groups, view_mode, african_regions, language_support):
337
+ """Wrapper that adds telemetry tracking to filter updates."""
338
+ language_support_selected = _language_support_to_list(language_support)
339
+ track_event("dashboard_update", {
340
+ # Counts for quick filtering
341
+ "models_count": len(models) if models else 0,
342
+ "languages_count": len(languages) if languages else 0,
343
+ "datasets_count": len(dataset_groups) if dataset_groups else 0,
344
+ "regions_count": len(african_regions) if african_regions else 0,
345
+ "language_support_count": len(language_support_selected),
346
+ # Actual filter values (limit to prevent huge payloads)
347
+ "models": ",".join(models[:10]) if models else "",
348
+ "languages": ",".join(languages[:10]) if languages else "",
349
+ "dataset_groups": ",".join(dataset_groups[:10]) if dataset_groups else "",
350
+ "african_regions": ",".join(african_regions[:10]) if african_regions else "",
351
+ "language_support": ",".join(language_support_selected),
352
+ "view_mode": view_mode or "",
353
+ "tab": "asr_metrics",
354
+ })
355
+
356
+ return update_metric_dataframes(models, languages, dataset_groups, view_mode, african_regions, language_support_selected)
357
+
358
+ apply_filters_button.click(
359
+ update_metric_dataframes_with_telemetry,
360
+ inputs=[
361
+ model_filter,
362
+ language_filter,
363
+ dataset_filter,
364
+ view_mode_radio,
365
+ african_region_filter,
366
+ language_support_filter,
367
+ ],
368
+ outputs=[cer_table, wer_table, rtfx_table],
369
+ )
370
+
371
+ with gr.TabItem("📊 Visualizations", elem_id="visualizations-tab", id=1):
372
+ if ASR_RESULTS_ERROR:
373
+ gr.Markdown(f"⚠️ {ASR_RESULTS_ERROR}")
374
+ else:
375
+ gr.Markdown(
376
+ "### Explore key visualizations revealing insights about model performance, "
377
+ "accuracy and efficiency trade-offs and cross-metric correlations across the benchmark.",
378
+ elem_classes="markdown-text"
379
+ )
380
+
381
+ # Chart Gallery - Quick Navigation
382
+ gr.Markdown("### 📋 Available Charts", elem_classes="markdown-text")
383
+ gr.HTML(chart_gallery_html)
384
+
385
+
386
+ # Plot 1: CER Performance Leaderboard (with language filter)
387
+ gr.HTML("<div id='cer-leaderboard-section'></div>")
388
+ gr.Markdown("## 🏆 CER Performance Leaderboard", elem_classes="markdown-text")
389
+ gr.Markdown("*Shows model families by default. Select language(s) above to see top 15 individual models. CER is the preferred metric for low-resource and agglutinative languages.*", elem_classes="markdown-text")
390
+ with gr.Accordion("ℹ️ How to interpret this chart", open=False):
391
+ gr.Markdown(INTERPRETATIONS['cer_leaderboard'])
392
+ viz_language_filter_cer_leaderboard = gr.Dropdown(
393
+ choices=ASR_FILTER_OPTIONS["language"],
394
+ multiselect=True,
395
+ label="Filter by Language",
396
+ value=None,
397
+ allow_custom_value=False,
398
+ )
399
+ plot_cer_leaderboard = gr.Plot(value=create_cer_leaderboard(ASR_RESULTS_DF), elem_classes="viz-scroll-plot")
400
+ gr.Markdown("💡 *For the best experience, view these charts on a desktop or tablet in landscape mode.*", elem_classes="markdown-text")
401
+
402
+ # Plot 2: WER Performance Leaderboard (with language filter)
403
+ gr.HTML("<div id='leaderboard-section'></div>")
404
+ gr.Markdown("## 📊 WER Performance Leaderboard", elem_classes="markdown-text")
405
+ gr.Markdown("*Shows model families by default. Select language(s) above to see top 15 individual models.*", elem_classes="markdown-text")
406
+ with gr.Accordion("ℹ️ How to interpret this chart", open=False):
407
+ gr.Markdown(INTERPRETATIONS['leaderboard'])
408
+ viz_language_filter_leaderboard = gr.Dropdown(
409
+ choices=ASR_FILTER_OPTIONS["language"],
410
+ multiselect=True,
411
+ label="Filter by Language",
412
+ value=None,
413
+ allow_custom_value=False,
414
+ )
415
+ plot_leaderboard = gr.Plot(value=create_model_leaderboard(ASR_RESULTS_DF), elem_classes="viz-scroll-plot")
416
+ gr.Markdown("💡 *For the best experience, view these charts on a desktop or tablet in landscape mode.*", elem_classes="markdown-text")
417
+
418
+ # Plot 3: Speed vs Accuracy Tradeoff (Individual Models only)
419
+ gr.HTML("<div id='speed-accuracy-section'></div>")
420
+ gr.Markdown("## 🚀 Speed versus Accuracy Tradeoffs", elem_classes="markdown-text")
421
+ with gr.Accordion("ℹ️ How to interpret this chart", open=False):
422
+ gr.Markdown(INTERPRETATIONS['speed_accuracy'])
423
+ viz_language_filter_speed = gr.Dropdown(
424
+ choices=ASR_FILTER_OPTIONS["language"],
425
+ multiselect=True,
426
+ label="Filter by Language",
427
+ value=None,
428
+ allow_custom_value=False,
429
+ )
430
+ plot_speed_accuracy = gr.Plot(value=create_speed_accuracy_scatter(ASR_RESULTS_DF, view_mode="individual_model"), elem_classes="viz-scroll-plot")
431
+ gr.Markdown("💡 *For the best experience, view these charts on a desktop or tablet in landscape mode.*", elem_classes="markdown-text")
432
+
433
+ # Plot 4: CER vs WER Correlation (with language filter)
434
+ gr.HTML("<div id='correlation-section'></div>")
435
+ gr.Markdown("## 🔍 CER versus WER Correlation", elem_classes="markdown-text")
436
+ with gr.Accordion("ℹ️ How to interpret this chart", open=False):
437
+ gr.Markdown(INTERPRETATIONS['correlation'])
438
+ with gr.Row():
439
+ viz_language_filter_correlation = gr.Dropdown(
440
+ choices=ASR_FILTER_OPTIONS["language"],
441
+ multiselect=True,
442
+ label="Filter by Language",
443
+ value=["Swahili"],
444
+ allow_custom_value=False,
445
+ scale=3,
446
+ )
447
+ top_n_models_slider = gr.Slider(
448
+ minimum=0,
449
+ maximum=20,
450
+ step=1,
451
+ value=0,
452
+ label="Top N Models (0 = all)",
453
+ scale=1,
454
+ )
455
+ plot_correlation = gr.Plot(value=create_wer_cer_correlation(ASR_RESULTS_DF, languages=["Swahili"]), elem_classes="viz-scroll-plot")
456
+ gr.Markdown("💡 *For the best experience, view these charts on a desktop or tablet in landscape mode.*", elem_classes="markdown-text")
457
+
458
+ # Visualization update functions with telemetry
459
+ def update_wer_leaderboard_with_telemetry(langs):
460
+ """Update WER leaderboard with telemetry tracking."""
461
+ track_visualization_view("wer_leaderboard", languages=langs if langs else None)
462
+ return create_model_leaderboard(ASR_RESULTS_DF, languages=langs if langs else None)
463
+
464
+ def update_cer_leaderboard_with_telemetry(langs):
465
+ """Update CER leaderboard with telemetry tracking."""
466
+ track_visualization_view("cer_leaderboard", languages=langs if langs else None)
467
+ return create_cer_leaderboard(ASR_RESULTS_DF, languages=langs if langs else None)
468
+
469
+ def update_speed_accuracy_with_telemetry(langs):
470
+ """Update speed-accuracy chart with telemetry tracking."""
471
+ track_visualization_view("speed_accuracy", languages=langs if langs else None)
472
+ return create_speed_accuracy_scatter(ASR_RESULTS_DF, view_mode="individual_model", languages=langs if langs else None)
473
+
474
+ def update_correlation_with_telemetry(langs, top_n):
475
+ """Update correlation chart with telemetry tracking."""
476
+ track_visualization_view(
477
+ "wer_cer_correlation",
478
+ properties={"top_n_models": str(int(top_n)) if top_n else "all"},
479
+ languages=langs if langs else None
480
+ )
481
+ return create_wer_cer_correlation(
482
+ ASR_RESULTS_DF,
483
+ languages=langs if langs else None,
484
+ top_n_models=int(top_n) if top_n else None
485
+ )
486
+
487
+ # Update plots when language filters change
488
+ viz_language_filter_leaderboard.change(
489
+ fn=update_wer_leaderboard_with_telemetry,
490
+ inputs=[viz_language_filter_leaderboard],
491
+ outputs=[plot_leaderboard]
492
+ )
493
+
494
+ viz_language_filter_cer_leaderboard.change(
495
+ fn=update_cer_leaderboard_with_telemetry,
496
+ inputs=[viz_language_filter_cer_leaderboard],
497
+ outputs=[plot_cer_leaderboard]
498
+ )
499
+
500
+ viz_language_filter_speed.change(
501
+ fn=update_speed_accuracy_with_telemetry,
502
+ inputs=[viz_language_filter_speed],
503
+ outputs=[plot_speed_accuracy]
504
+ )
505
+
506
+ # Update correlation chart when language or top N changes
507
+ viz_language_filter_correlation.change(
508
+ fn=update_correlation_with_telemetry,
509
+ inputs=[viz_language_filter_correlation, top_n_models_slider],
510
+ outputs=[plot_correlation]
511
+ )
512
+ top_n_models_slider.change(
513
+ fn=update_correlation_with_telemetry,
514
+ inputs=[viz_language_filter_correlation, top_n_models_slider],
515
+ outputs=[plot_correlation]
516
+ )
517
+
518
+ with gr.TabItem("📝 Benchmark Inputs", elem_id="llm-benchmark-tab-table", id=2):
519
+ gr.Markdown(get_llm_benchmarks_text(ASR_RESULTS_DF), elem_classes="markdown-text")
520
+
521
+ gr.Markdown("---", elem_classes="markdown-text")
522
+ gr.Markdown("## 🗺️ Language Coverage", elem_classes="markdown-text")
523
+ gr.Markdown(
524
+ "Explore where each language in PazaBench is spoken across Africa and see the sample distribution by language.",
525
+ elem_classes="markdown-text"
526
+ )
527
+
528
+ # Row 1: Language selector map and PazaBench overview with sample distribution
529
+ gr.Markdown("#### 🔍 Explore by Language", elem_classes="markdown-text")
530
+ with gr.Row(elem_id="language-coverage-row"):
531
+ with gr.Column(scale=1):
532
+ benchmark_language_filter = gr.Dropdown(
533
+ choices=get_all_languages(ASR_RESULTS_DF),
534
+ value=None,
535
+ label="Select a Language",
536
+ multiselect=True,
537
+ allow_custom_value=False,
538
+ info="Select languages to see where they are spoken"
539
+ )
540
+ language_location_map = gr.Plot(value=create_language_location_map())
541
+ gr.Markdown("💡 *Select a language to see the countries where it is spoken.*", elem_classes="markdown-text")
542
+ with gr.Column(scale=2):
543
+ language_sample_info = gr.HTML(
544
+ value=get_language_sample_info(None, ASR_RESULTS_DF),
545
+ )
546
+ # Sample Distribution by Language (stacked under PazaBench overview)
547
+ gr.Markdown("#### 📊 Sample Distribution by Language", elem_classes="markdown-text")
548
+ language_coverage_plot = gr.Plot(
549
+ value=create_language_coverage_chart(asr_df=ASR_RESULTS_DF),
550
+ elem_id="language-coverage-plot",
551
+ )
552
+
553
+ # Event handlers for language selector with telemetry
554
+ def update_benchmark_language_views(langs):
555
+ """Update all language-related views at once to avoid infinite loops."""
556
+ # Track telemetry
557
+ if langs:
558
+ track_filter_change("language", langs, tab="benchmark_inputs")
559
+ track_visualization_view("language_location_map", languages=langs)
560
+ track_visualization_view("language_coverage", languages=langs)
561
+
562
+ # Return all three outputs at once
563
+ return (
564
+ create_language_location_map(langs if langs else None),
565
+ get_language_sample_info(langs if langs else None, ASR_RESULTS_DF),
566
+ create_language_coverage_chart(langs if langs else None, asr_df=ASR_RESULTS_DF),
567
+ )
568
+
569
+ benchmark_language_filter.change(
570
+ fn=update_benchmark_language_views,
571
+ inputs=[benchmark_language_filter],
572
+ outputs=[language_location_map, language_sample_info, language_coverage_plot]
573
+ )
574
+
575
+ gr.Markdown(get_llm_benchmarks_datasets_text(ASR_RESULTS_DF), elem_classes="markdown-text")
576
+
577
+ with gr.TabItem("➕ Request Language Eval", elem_id="lang-eval-tab", id=3):
578
+ gr.Markdown(EVALUATION_LANGUAGE_TEXT, elem_classes="markdown-text")
579
+
580
+ with gr.Row():
581
+ dataset_location = gr.Radio(
582
+ choices=["HuggingFace", "Other"],
583
+ value="HuggingFace",
584
+ label="Dataset Location *",
585
+ info="Select where your dataset is hosted"
586
+ )
587
+
588
+ with gr.Row():
589
+ with gr.Column():
590
+ lang_dataset_name = gr.Textbox(
591
+ label="Dataset Name *",
592
+ placeholder="e.g., username/dataset-name",
593
+ info="Full Hugging Face dataset ID",
594
+ visible=True
595
+ )
596
+ lang_dataset_url = gr.Textbox(
597
+ label="Dataset URL *",
598
+ placeholder="https://example.com/dataset",
599
+ info="Full URL to the publicly available dataset",
600
+ visible=False
601
+ )
602
+ lang_dataset_config = gr.Textbox(
603
+ label="Dataset Subset/Language",
604
+ placeholder="default",
605
+ info="Dataset subset or language configuration (leave empty for default)"
606
+ )
607
+ lang_dataset_split = gr.Textbox(
608
+ label="Dataset Split",
609
+ placeholder="test",
610
+ info="Split to evaluate (default: test)"
611
+ )
612
+ with gr.Column():
613
+ lang_license = gr.Textbox(
614
+ label="License *",
615
+ placeholder="e.g., CC-BY-4.0, MIT, Apache-2.0",
616
+ info="License of the dataset (required)"
617
+ )
618
+ lang_audio_column = gr.Textbox(
619
+ label="Audio Column",
620
+ placeholder="audio",
621
+ info="Name of the column containing audio data (optional, default: audio)"
622
+ )
623
+ lang_text_column = gr.Textbox(
624
+ label="Text/Transcript Column",
625
+ placeholder="text",
626
+ info="Name of the column containing transcriptions (optional, default: text)"
627
+ )
628
+
629
+ def toggle_dataset_inputs(location):
630
+ """Toggle visibility of dataset name vs URL based on location selection."""
631
+ if location == "HuggingFace":
632
+ return gr.update(visible=True), gr.update(visible=False)
633
+ else:
634
+ return gr.update(visible=False), gr.update(visible=True)
635
+
636
+ dataset_location.change(
637
+ toggle_dataset_inputs,
638
+ inputs=[dataset_location],
639
+ outputs=[lang_dataset_name, lang_dataset_url]
640
+ )
641
+
642
+ lang_submit_button = gr.Button("Submit Language Evaluation Request", variant="primary")
643
+ lang_submission_result = gr.Markdown()
644
+
645
+ def submit_language_eval_with_telemetry(
646
+ location, dataset_name, dataset_url, dataset_config, dataset_split,
647
+ audio_column, text_column, license
648
+ ):
649
+ """Wrapper that adds telemetry to submission attempts and resets form on success."""
650
+ from src.telemetry import track_submission_attempt
651
+
652
+ result = add_language_eval_request(
653
+ location, dataset_name, dataset_url, dataset_config, dataset_split,
654
+ audio_column, text_column, license
655
+ )
656
+
657
+ # Determine if submission was successful based on result message
658
+ success = result and ("success" in result.lower() or "submitted" in result.lower())
659
+ error_msg = None if success else result
660
+
661
+ # Use dataset name or URL based on location
662
+ ds_identifier = dataset_name if location == "HuggingFace" else dataset_url
663
+ track_submission_attempt(
664
+ dataset_name=ds_identifier or "unknown",
665
+ success=success,
666
+ error_message=error_msg
667
+ )
668
+
669
+ # On success, reset form fields; on error, keep values so user can fix and retry
670
+ if success:
671
+ # Reset: location, name, url, config, split, audio_col, text_col, license
672
+ return (
673
+ result, "HuggingFace", "", "", "", "", "", "", "",
674
+ gr.update(visible=True), gr.update(visible=False),
675
+ )
676
+ else:
677
+ return (result,) + tuple(gr.update() for _ in range(10))
678
+
679
+ lang_submit_button.click(
680
+ submit_language_eval_with_telemetry,
681
+ [
682
+ dataset_location,
683
+ lang_dataset_name,
684
+ lang_dataset_url,
685
+ lang_dataset_config,
686
+ lang_dataset_split,
687
+ lang_audio_column,
688
+ lang_text_column,
689
+ lang_license,
690
+ ],
691
+ [
692
+ lang_submission_result,
693
+ dataset_location,
694
+ lang_dataset_name,
695
+ lang_dataset_url,
696
+ lang_dataset_config,
697
+ lang_dataset_split,
698
+ lang_audio_column,
699
+ lang_text_column,
700
+ lang_license,
701
+ lang_dataset_name, # visibility update
702
+ lang_dataset_url, # visibility update
703
+ ],
704
+ )
705
+
706
+ with gr.TabItem("🗓️ Change Log", elem_id="changelog-tab", id=4):
707
+ gr.Markdown(CHANGELOG_HEADER, elem_classes="markdown-text")
708
+ gr.HTML(CHANGELOG_CARDS_HTML)
709
+
710
+ with gr.Row():
711
+ with gr.Accordion("📙 Citation", open=False):
712
+ citation_button = gr.Textbox(
713
+ value=CITATION_BUTTON_TEXT,
714
+ label=CITATION_BUTTON_LABEL,
715
+ lines=20,
716
+ elem_id="citation-button",
717
+ )
718
+
719
+ # Footer with links visible across all pages and tabs
720
+ gr.HTML(footer_html)
721
+
722
+ # Only start scheduler if HuggingFace Hub is enabled
723
+ if HF_ENABLED:
724
+ scheduler = BackgroundScheduler()
725
+ scheduler.add_job(restart_space, "interval", seconds=1800)
726
+ scheduler.start()
727
+ print("Background scheduler started for HuggingFace Space restart")
728
+ else:
729
+ print("HuggingFace Hub disabled, background scheduler not started")
730
+
731
+ # Resolve server port from PORT / WEBSITES_PORT / GRADIO_SERVER_PORT
732
+ port = int(os.environ.get("PORT", os.environ.get("WEBSITES_PORT", os.environ.get("GRADIO_SERVER_PORT", 7860))))
733
+ print(f"Starting Gradio server on port {port}...")
734
+
735
+ demo.queue(
736
+ default_concurrency_limit=40
737
+ ).launch(
738
+ server_name="0.0.0.0",
739
+ server_port=port,
740
+ share=True,
741
+ pwa=True,
742
+ )
743
+
es-metadata.yml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ schemaVersion: 0.0.1
2
+ isProduction: true
3
+ accountableOwners:
4
+ service: 6ed18b01-b071-4f02-b219-2224b1f0a421
5
+ routing:
6
+ defaultAreaPath:
7
+ org: msr-africa
8
+ path: Gecko MSR Africa
requirements.txt ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiofiles==24.1.0
2
+ aiohappyeyeballs==2.6.1
3
+ aiohttp==3.13.3
4
+ aiosignal==1.4.0
5
+ annotated-doc==0.0.4
6
+ annotated-types==0.7.0
7
+ anyio==4.12.1
8
+ apscheduler==3.11.2
9
+ asgiref==3.11.0
10
+ attrs==25.4.0
11
+ authlib==1.6.6
12
+ azure-core==1.38.0
13
+ azure-core-tracing-opentelemetry==1.0.0b12
14
+ azure-identity==1.25.1
15
+ azure-monitor-opentelemetry==1.8.4
16
+ azure-monitor-opentelemetry-exporter==1.0.0b46
17
+ black==25.12.0
18
+ brotli==1.2.0
19
+ certifi==2026.1.4
20
+ cffi==2.0.0
21
+ charset-normalizer==3.4.4
22
+ click==8.3.1
23
+ cryptography==46.0.3
24
+ datasets==4.4.2
25
+ dill==0.4.0
26
+ evaluate==0.4.6
27
+ fastapi==0.128.0
28
+ ffmpy==1.0.0
29
+ filelock==3.20.2
30
+ frozenlist==1.8.0
31
+ fsspec==2025.10.0
32
+ gradio==6.2.0
33
+ gradio-client==2.0.2
34
+ groovy==0.1.2
35
+ h11==0.16.0
36
+ hf-xet==1.2.0
37
+ httpcore==1.0.9
38
+ httpx==0.28.1
39
+ huggingface-hub==0.36.0
40
+ idna==3.11
41
+ importlib-metadata==8.7.1
42
+ isodate==0.7.2
43
+ itsdangerous==2.2.0
44
+ jinja2==3.1.6
45
+ jiwer==4.0.0
46
+ markdown-it-py==4.0.0
47
+ markupsafe==3.0.3
48
+ mdurl==0.1.2
49
+ msal==1.34.0
50
+ msal-extensions==1.3.1
51
+ msrest==0.7.1
52
+ multidict==6.7.0
53
+ multiprocess==0.70.18
54
+ mypy-extensions==1.1.0
55
+ narwhals==2.15.0
56
+ numpy==2.4.0
57
+ oauthlib==3.3.1
58
+ opentelemetry-api==1.39.0
59
+ opentelemetry-instrumentation==0.60b0
60
+ opentelemetry-instrumentation-asgi==0.60b0
61
+ opentelemetry-instrumentation-dbapi==0.60b0
62
+ opentelemetry-instrumentation-django==0.60b0
63
+ opentelemetry-instrumentation-fastapi==0.60b0
64
+ opentelemetry-instrumentation-flask==0.60b0
65
+ opentelemetry-instrumentation-psycopg2==0.60b0
66
+ opentelemetry-instrumentation-requests==0.60b0
67
+ opentelemetry-instrumentation-urllib==0.60b0
68
+ opentelemetry-instrumentation-urllib3==0.60b0
69
+ opentelemetry-instrumentation-wsgi==0.60b0
70
+ opentelemetry-resource-detector-azure==0.1.5
71
+ opentelemetry-sdk==1.39.0
72
+ opentelemetry-semantic-conventions==0.60b0
73
+ opentelemetry-util-http==0.60b0
74
+ orjson==3.11.5
75
+ packaging==25.0
76
+ pandas==2.3.3
77
+ pathspec==1.0.2
78
+ patsy==1.0.2
79
+ pillow==11.3.0
80
+ platformdirs==4.5.1
81
+ plotly==6.5.1
82
+ propcache==0.4.1
83
+ psutil==7.2.1
84
+ pyarrow==22.0.0
85
+ pycparser==2.23
86
+ pydantic==2.12.3
87
+ pydantic-core==2.41.4
88
+ pydub==0.25.1
89
+ pygments==2.19.2
90
+ pyjwt==2.10.1
91
+ python-dateutil==2.9.0.post0
92
+ python-dotenv==1.2.1
93
+ python-multipart==0.0.21
94
+ pytokens==0.3.0
95
+ pytz==2025.2
96
+ pyyaml==6.0.3
97
+ rapidfuzz==3.14.3
98
+ regex==2025.11.3
99
+ requests==2.32.5
100
+ requests-oauthlib==2.0.0
101
+ rich==14.2.0
102
+ safehttpx==0.1.7
103
+ safetensors==0.7.0
104
+ scipy==1.16.3
105
+ semantic-version==2.10.0
106
+ sentencepiece==0.2.1
107
+ shellingham==1.5.4
108
+ six==1.17.0
109
+ starlette==0.50.0
110
+ statsmodels==0.14.6
111
+ tokenizers==0.22.2
112
+ tomlkit==0.13.3
113
+ tqdm==4.67.1
114
+ transformers==4.57.3
115
+ typer==0.21.1
116
+ typing-extensions==4.15.0
117
+ typing-inspection==0.4.2
118
+ tzdata==2025.3
119
+ tzlocal==5.3.1
120
+ urllib3==2.6.3
121
+ uvicorn==0.40.0
122
+ wrapt==1.17.3
123
+ xxhash==3.6.0
124
+ yarl==1.22.0
125
+ zipp==3.23.0
src/about.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ # Your leaderboard name
9
+ TITLE = """<h1 align="center" id="space-title">PazaBench Leaderboard</h1>"""
10
+
11
+ _DATA_DIR = Path(__file__).parent / "data"
12
+
13
+
14
+ def _load_json(name: str):
15
+ """Load a JSON data file bundled under ``src/data/``."""
16
+ with open(_DATA_DIR / name, encoding="utf-8") as data_file:
17
+ return json.load(data_file)
18
+
19
+
20
+ def _load_text(name: str) -> str:
21
+ """Load a text/HTML template bundled under ``src/data/``."""
22
+ with open(_DATA_DIR / name, encoding="utf-8") as data_file:
23
+ return data_file.read()
24
+
25
+ # Dataset-group and model-family metadata are stored in src/data/*.json
26
+ DATASET_GROUP_METADATA = _load_json("dataset_group_metadata.json")
27
+
28
+ MODEL_FAMILY_METADATA = _load_json("model_family_metadata.json")
29
+
30
+
31
+ def _is_loaded_frame(asr_results_df: Any | None) -> bool:
32
+ return asr_results_df is not None and not getattr(asr_results_df, "empty", True)
33
+
34
+
35
+ def _format_count(value: int | float | None) -> str:
36
+ if value is None:
37
+ return "0"
38
+ return f"{int(value):,}"
39
+
40
+
41
+ def _to_int(value: Any) -> int:
42
+ try:
43
+ return int(float(value))
44
+ except (TypeError, ValueError):
45
+ return 0
46
+
47
+
48
+ def _plural(word: str, count: int) -> str:
49
+ if count != 1 and word.endswith("family"):
50
+ return f"{word[:-1]}ies"
51
+ if count != 1 and word.endswith("y"):
52
+ return f"{word[:-1]}ies"
53
+ return word if count == 1 else f"{word}s"
54
+
55
+
56
+ def _sorted_unique(values: Iterable[Any]) -> list[str]:
57
+ return sorted({str(value) for value in values if str(value).strip() and str(value) != "Unknown"})
58
+
59
+
60
+ def _format_inline_list(values: list[str], max_items: int = 8) -> str:
61
+ if not values:
62
+ return "not available"
63
+ visible = values[:max_items]
64
+ rendered = ", ".join(visible)
65
+ remaining = len(values) - len(visible)
66
+ if remaining > 0:
67
+ rendered = f"{rendered}, and {_format_count(remaining)} more"
68
+ return rendered
69
+
70
+
71
+ def _format_language_summary(languages: list[str]) -> str:
72
+ language_count = len(languages)
73
+ if language_count == 0:
74
+ return "Not available"
75
+ language_list = _format_inline_list(languages, max_items=12)
76
+ return f"{language_list} (**{_format_count(language_count)} {_plural('language', language_count)}**)"
77
+
78
+
79
+ def _split_level_frame(asr_results_df: Any | None):
80
+ if not _is_loaded_frame(asr_results_df):
81
+ return None
82
+
83
+ required_columns = ["dataset_group", "split", "language"]
84
+ if any(column not in asr_results_df.columns for column in required_columns):
85
+ return None
86
+
87
+ columns = required_columns + (["num_samples"] if "num_samples" in asr_results_df.columns else [])
88
+ split_frame = asr_results_df.loc[:, columns].copy()
89
+ split_frame = split_frame.dropna(subset=required_columns)
90
+ split_frame = split_frame[split_frame["language"] != "Unknown"]
91
+
92
+ if "num_samples" in split_frame.columns:
93
+ split_frame["num_samples"] = split_frame["num_samples"].apply(_to_int)
94
+ return (
95
+ split_frame
96
+ .groupby(required_columns, dropna=False, as_index=False)["num_samples"]
97
+ .max()
98
+ )
99
+
100
+ return split_frame.drop_duplicates(required_columns)
101
+
102
+
103
+ def get_benchmark_summary(asr_results_df: Any | None = None) -> dict[str, int]:
104
+ """Return benchmark coverage counts derived from the loaded results dataframe."""
105
+ if not _is_loaded_frame(asr_results_df):
106
+ metadata_languages = {
107
+ language
108
+ for metadata in DATASET_GROUP_METADATA.values()
109
+ for language in metadata.get("languages", [])
110
+ }
111
+ return {
112
+ "dataset_groups": len(DATASET_GROUP_METADATA),
113
+ "splits": 0,
114
+ "languages": len(metadata_languages),
115
+ "samples": 0,
116
+ "model_families": len(MODEL_FAMILY_METADATA),
117
+ "models": 0,
118
+ }
119
+
120
+ split_frame = _split_level_frame(asr_results_df)
121
+ sample_count = 0
122
+ if split_frame is not None and "num_samples" in split_frame.columns:
123
+ sample_count = int(split_frame["num_samples"].sum())
124
+
125
+ return {
126
+ "dataset_groups": int(asr_results_df["dataset_group"].nunique()) if "dataset_group" in asr_results_df.columns else 0,
127
+ "splits": int(asr_results_df["split"].nunique()) if "split" in asr_results_df.columns else 0,
128
+ "languages": int(asr_results_df["language"].nunique()) if "language" in asr_results_df.columns else 0,
129
+ "samples": sample_count,
130
+ "model_families": int(asr_results_df["model_family"].nunique()) if "model_family" in asr_results_df.columns else 0,
131
+ "models": int(asr_results_df["model"].nunique()) if "model" in asr_results_df.columns else 0,
132
+ }
133
+
134
+
135
+ def _dataset_link(dataset_group: str) -> str:
136
+ metadata = DATASET_GROUP_METADATA.get(dataset_group, {})
137
+ url = metadata.get("url")
138
+ return f"[{dataset_group}]({url})" if url else dataset_group
139
+
140
+
141
+ def _model_link(model_id: str) -> str:
142
+ return f"[{model_id}](https://huggingface.co/{model_id})"
143
+
144
+
145
+ def _family_display_name(model_family: str) -> str:
146
+ metadata = MODEL_FAMILY_METADATA.get(model_family)
147
+ if metadata:
148
+ return metadata["display_name"]
149
+ return model_family.replace("_", " ").replace("-", " ").title()
150
+
151
+
152
+ def _family_description(model_family: str) -> str:
153
+ metadata = MODEL_FAMILY_METADATA.get(model_family)
154
+ return metadata["description"] if metadata else "ASR model family in the loaded benchmark results."
155
+
156
+
157
+ def get_dataset_group_languages(dataset_group: str, asr_results_df: Any | None = None) -> list[str]:
158
+ """Return list of languages for a dataset group, preferring loaded results over static metadata."""
159
+ split_frame = _split_level_frame(asr_results_df)
160
+ if split_frame is not None:
161
+ dataset_rows = split_frame[split_frame["dataset_group"] == dataset_group]
162
+ if not dataset_rows.empty:
163
+ return _sorted_unique(dataset_rows["language"].tolist())
164
+
165
+ metadata = DATASET_GROUP_METADATA.get(dataset_group)
166
+ if metadata:
167
+ return _sorted_unique(metadata.get("languages", []))
168
+ return []
169
+
170
+
171
+ def get_dataset_group_label(dataset_group: str, asr_results_df: Any | None = None) -> str:
172
+ """Return a formatted label with description and language count."""
173
+ meta = DATASET_GROUP_METADATA.get(dataset_group)
174
+ languages = get_dataset_group_languages(dataset_group, asr_results_df)
175
+ if meta:
176
+ language_count = len(languages)
177
+ if language_count > 0:
178
+ return f"{dataset_group} ({meta['description']}, {_format_count(language_count)} {_plural('language', language_count)})"
179
+ return f"{dataset_group} ({meta['description']})"
180
+ return dataset_group
181
+
182
+
183
+ def get_introduction_text(asr_results_df: Any | None = None) -> str:
184
+ """Build the homepage introduction with live counts."""
185
+ summary = get_benchmark_summary(asr_results_df)
186
+ if _is_loaded_frame(asr_results_df):
187
+ launch_sentence = (
188
+ f"Launching with **{_format_count(summary['languages'])} African "
189
+ f"{_plural('Language', summary['languages'])}** across "
190
+ f"**{_format_count(summary['models'])} State-of-the-Art ASR** and **Language Models**, "
191
+ "PazaBench compares three key metrics: **Character Error Rate (CER)**, "
192
+ "**Word Error Rate (WER)**, and **RTFx (Inverse Real-Time Factor)**."
193
+ )
194
+ else:
195
+ launch_sentence = (
196
+ "PazaBench compares three key metrics: **Character Error Rate (CER)**, "
197
+ "**Word Error Rate (WER)**, and **RTFx (Inverse Real-Time Factor)**."
198
+ )
199
+
200
+ return f"""
201
+ The PazaBench Leaderboard is an Automatic Speech Recognition (ASR) benchmark for low-resource languages developed by the **[Microsoft Research Africa, Nairobi Lab](https://www.microsoft.com/en-us/research/lab/microsoft-research-lab-africa-nairobi/)**. {launch_sentence}
202
+ """
203
+
204
+
205
+ def get_llm_benchmarks_text(asr_results_df: Any | None = None) -> str:
206
+ """Build the Benchmark Inputs overview from the live results."""
207
+ summary = get_benchmark_summary(asr_results_df)
208
+
209
+ if _is_loaded_frame(asr_results_df) and summary["dataset_groups"]:
210
+ dataset_sentence = (
211
+ f"The PazaBench evaluation dataset is unified from **{_format_count(summary['dataset_groups'])} "
212
+ f"{_plural('dataset', summary['dataset_groups'])}** listed below. It captures "
213
+ f"**{_format_count(summary['splits'])} test {_plural('split', summary['splits'])}** across the listed "
214
+ f"**{_format_count(summary['languages'])} {_plural('language', summary['languages'])}** and adds up to "
215
+ f"**{_format_count(summary['samples'])} {_plural('sample', summary['samples'])}**. The dataset consists of "
216
+ "16 kHz mono speech audio recordings with aligned transcriptions and accompanying split-level metadata. "
217
+ "For each language, data from all available sources are unified to provide a balanced measure of model performance."
218
+ )
219
+ else:
220
+ dataset_sentence = (
221
+ "The PazaBench evaluation dataset unifies public speech datasets into benchmark-ready "
222
+ "speech/transcript pairs with aligned transcriptions and split-level metadata."
223
+ )
224
+
225
+ return f"""
226
+ ## PazaBench Inputs
227
+
228
+ ### Evaluation Dataset
229
+
230
+ {dataset_sentence}
231
+ """
232
+
233
+
234
+ def _build_dataset_table(asr_results_df: Any | None = None) -> str:
235
+ split_frame = _split_level_frame(asr_results_df)
236
+ if split_frame is not None:
237
+ dataset_groups = _sorted_unique(split_frame["dataset_group"].tolist())
238
+ else:
239
+ dataset_groups = sorted(DATASET_GROUP_METADATA)
240
+
241
+ group_rows = []
242
+ for dataset_group in dataset_groups:
243
+ metadata = DATASET_GROUP_METADATA.get(dataset_group, {})
244
+ languages = get_dataset_group_languages(dataset_group, asr_results_df)
245
+ sample_count = None
246
+ if split_frame is not None:
247
+ dataset_rows = split_frame[split_frame["dataset_group"] == dataset_group]
248
+ if "num_samples" in dataset_rows.columns:
249
+ sample_count = int(dataset_rows["num_samples"].sum())
250
+ group_rows.append((dataset_group, metadata, languages, sample_count))
251
+
252
+ # Order by Total Samples descending.
253
+ group_rows.sort(key=lambda row: row[3] if row[3] is not None else -1, reverse=True)
254
+
255
+ rows = [
256
+ "| Dataset | Description | Languages | Total Samples | License |",
257
+ "|---------|-------------|-----------|---------------|---------|",
258
+ ]
259
+ for dataset_group, metadata, languages, sample_count in group_rows:
260
+ rows.append(
261
+ "| "
262
+ + " | ".join([
263
+ _dataset_link(dataset_group),
264
+ str(metadata.get("description", "Benchmark speech dataset")),
265
+ _format_language_summary(languages),
266
+ _format_count(sample_count) if sample_count is not None else "Not loaded",
267
+ str(metadata.get("license", "See dataset card")),
268
+ ])
269
+ + " |"
270
+ )
271
+
272
+ return "\n".join(rows)
273
+
274
+
275
+ def _build_model_table(asr_results_df: Any | None = None) -> str:
276
+ rows = [
277
+ "| Model Family | Model |",
278
+ "|--------------|-------|",
279
+ ]
280
+
281
+ if _is_loaded_frame(asr_results_df) and {"model_family", "model"}.issubset(asr_results_df.columns):
282
+ grouped = (
283
+ asr_results_df.loc[:, ["model_family", "model"]]
284
+ .dropna()
285
+ .drop_duplicates()
286
+ .sort_values(["model_family", "model"])
287
+ .groupby("model_family", sort=True)["model"]
288
+ .apply(list)
289
+ )
290
+ families = grouped.index.tolist()
291
+ else:
292
+ grouped = {}
293
+ families = sorted(MODEL_FAMILY_METADATA)
294
+
295
+ # Order: Paza first, then alphabetical by display name.
296
+ families = sorted(families, key=lambda family: _family_display_name(family).lower())
297
+ families = (
298
+ [family for family in families if _family_display_name(family).lower().startswith("paza")]
299
+ + [family for family in families if not _family_display_name(family).lower().startswith("paza")]
300
+ )
301
+
302
+ for model_family in families:
303
+ models = grouped[model_family] if hasattr(grouped, "__getitem__") and model_family in grouped else []
304
+ model_links = ", ".join(_model_link(model) for model in models) if models else "Generated from loaded results"
305
+ rows.append(
306
+ "| "
307
+ + " | ".join([
308
+ _family_display_name(model_family),
309
+ model_links,
310
+ ])
311
+ + " |"
312
+ )
313
+
314
+ return "\n".join(rows)
315
+
316
+
317
+ def get_llm_benchmarks_datasets_text(asr_results_df: Any | None = None) -> str:
318
+ """Build the dataset and model inventory tables."""
319
+ summary = get_benchmark_summary(asr_results_df)
320
+ dataset_table = _build_dataset_table(asr_results_df)
321
+ model_table = _build_model_table(asr_results_df)
322
+
323
+ if _is_loaded_frame(asr_results_df):
324
+ models_intro = (
325
+ f"PazaBench evaluates **{_format_count(summary['model_families'])} SOTA ASR model "
326
+ f"{_plural('family', summary['model_families'])} across {_format_count(summary['models'])} "
327
+ f"individual {_plural('model', summary['models'])}** listed below:"
328
+ )
329
+ else:
330
+ models_intro = "PazaBench evaluates the SOTA ASR model families listed below:"
331
+
332
+ return f"""
333
+ ## Datasets
334
+
335
+ {dataset_table}
336
+
337
+ ---
338
+
339
+ ## Evaluated Models
340
+
341
+ {models_intro}
342
+
343
+ {model_table}
344
+
345
+ **Whisper Post-Processing:** Whisper model results include a duration-based truncation step to mitigate hallucination and known over-generation behavior.
346
+
347
+ ---
348
+
349
+ ## Acknowledgements
350
+
351
+ We gratefully acknowledge the dataset creators and leaderboard teams whose contributions made PazaBench possible:
352
+
353
+ **Datasets:** We extend our gratitude to the creators, community contributors, and maintainers of [African Next Voices Kenya](https://huggingface.co/datasets/MCAA1-MSU/anv_data_ke), [African Next Voices South Africa](https://huggingface.co/datasets/dsfsi-anv/za-african-next-voices), [ALFFA](https://openslr.org/25/), [DigiGreen Kikuyu ASR](https://huggingface.co/datasets/DigiGreen/KikuyuASR_trainingdataset), [Google FLEURS](https://huggingface.co/datasets/google/fleurs), [Mozilla Common Voice](https://commonvoice.mozilla.org/), [Naija Voices](https://huggingface.co/datasets/naijavoices/naijavoices-dataset), [WAXAL](https://huggingface.co/datasets/google/WaxalNLP), [Alaleye Fongbe](https://huggingface.co/datasets/alaleye/fon), [Sartify Sukuma Voices](https://huggingface.co/datasets/sartifyllc/Sukuma-Voices), and [CLEAR-Global TWB Voice 1.0](https://huggingface.co/datasets/CLEAR-Global/TWB-Voice-1.0) whose efforts have been invaluable in advancing African languages speech data.
354
+
355
+ **Reference Implementation:** We recognize the foundational work of the [Open ASR Leaderboard](https://huggingface.co/spaces/hf-audio/open_asr_leaderboard) by Hugging Face Audio team and appreciate the contributors of the [open_asr_leaderboard repository](https://github.com/huggingface/open_asr_leaderboard) for creating reproducible evaluation scripts.
356
+ """
357
+
358
+
359
+ def get_evaluation_model_text(asr_results_df: Any | None = None) -> str:
360
+ summary = get_benchmark_summary(asr_results_df)
361
+ language_phrase = (
362
+ f"all {_format_count(summary['languages'])} currently loaded benchmark {_plural('language', summary['languages'])}"
363
+ if _is_loaded_frame(asr_results_df) and summary["languages"] > 0
364
+ else "all supported benchmark languages"
365
+ )
366
+
367
+ return f"""
368
+ ### Submit a Model for Evaluation
369
+
370
+ Add a new ASR model to PazaBench. We'll evaluate it across {language_phrase}.
371
+
372
+ **Requirements:**
373
+ - Model must be **publicly available** on [Hugging Face Hub](https://huggingface.co/models)
374
+ - Must support speech-to-text / ASR tasks
375
+ - Should be compatible with `transformers` AutoModel or provide clear loading instructions
376
+ """
377
+
378
+ EVALUATION_LANGUAGE_TEXT = """
379
+ ### Request Language Evaluation
380
+
381
+ Submit a language dataset from any region for evaluation on PazaBench. We'll benchmark it using all supported ASR models. Provide the dataset source in the form below.
382
+
383
+ **Requirements:**
384
+ - Dataset must be publicly accessible on Hugging Face Hub or via a public URL
385
+ - Must contain audio samples with text transcriptions
386
+ - Audio should be 16kHz mono WAV format (will be resampled if needed)
387
+ """
388
+
389
+ CHANGELOG_HEADER = """
390
+ ### Change Log
391
+
392
+ What changed in each PazaBench release, with key coverage metrics per version. The full model, dataset and language lists live in the **ASR Metrics** and **Benchmark Inputs** tabs.
393
+ """
394
+
395
+ # Per-version release cards (HTML template lives in src/data/changelog_cards.html)
396
+ CHANGELOG_CARDS_HTML = _load_text("changelog_cards.html")
397
+
398
+ INTRODUCTION_TEXT = get_introduction_text()
399
+ LLM_BENCHMARKS_TEXT = get_llm_benchmarks_text()
400
+ LLM_BENCHMARKS_DATASETS_TEXT = get_llm_benchmarks_datasets_text()
401
+ EVALUATION_MODEL_TEXT = get_evaluation_model_text()
402
+
403
+ CITATION_BUTTON_LABEL = "Copy the following snippet to cite these results"
404
+ CITATION_BUTTON_TEXT = r"""
405
+ @misc{pazabench2026,
406
+ title={PazaBench: A Benchmark for Automatic Speech Recognition on Low Resource Languages},
407
+ author={Microsoft Research Africa, Nairobi},
408
+ year={2026},
409
+ howpublished={\url{https://www.microsoft.com/en-us/research/project/project-gecko/}},
410
+ note={Alpha version. Part of Project Gecko - Equitable Generative AI for the Global Majority}
411
+ }
412
+ """
src/assets/PazaBench-Banner-Mobile.png ADDED

Git LFS Details

  • SHA256: cf59e6888eb108a3fff9f22c89c568cff8c5249ce6198ae38a8db020d292165b
  • Pointer size: 130 Bytes
  • Size of remote file: 21 kB
src/assets/PazaBench-Banner2.png ADDED

Git LFS Details

  • SHA256: ef8d36b06ecad363b675efcf77a3466d9079950b48d19c9e8a0d67b4a9275894
  • Pointer size: 131 Bytes
  • Size of remote file: 204 kB
src/constants.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Constants and mappings for PazaBench.
3
+
4
+ This module contains all mapping dictionaries and configuration constants
5
+ that are shared across the application.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ _DATA_DIR = Path(__file__).parent / "data"
12
+
13
+
14
+ def _load_json(name: str):
15
+ """Load a JSON data file bundled under ``src/data/``."""
16
+ with open(_DATA_DIR / name, encoding="utf-8") as data_file:
17
+ return json.load(data_file)
18
+
19
+
20
+ # =============================================================================
21
+ # File Paths
22
+ # =============================================================================
23
+
24
+ RESULTS_CSV_PATH = Path("results_summary.csv")
25
+ RESULTS_CSV_FILENAME = "results_summary.csv"
26
+
27
+ MODEL_FAMILY_ALIASES = {
28
+ "hubert": "facebook_hubert",
29
+ }
30
+
31
+
32
+ def canonicalize_model_family(model_family: str) -> str:
33
+ """Map legacy or duplicate family identifiers to a single canonical value."""
34
+ return MODEL_FAMILY_ALIASES.get(model_family, model_family)
35
+
36
+ # =============================================================================
37
+ # Filter Configuration
38
+ # =============================================================================
39
+
40
+ FILTER_COLUMN_ORDER = ["model", "language", "dataset_group"]
41
+ FILTER_PARAM_MAP = {
42
+ "model": "models",
43
+ "language": "languages",
44
+ "dataset_group": "dataset_groups",
45
+ }
46
+
47
+ # =============================================================================
48
+ # Display Configuration
49
+ # =============================================================================
50
+
51
+ ASR_DISPLAY_COLUMNS = [
52
+ "model_family",
53
+ "model",
54
+ "dataset_group",
55
+ "split",
56
+ "language",
57
+ "region",
58
+ "cer",
59
+ "wer",
60
+ "rtfx",
61
+ "duration_sec",
62
+ "inference_time_sec",
63
+ "num_samples",
64
+ ]
65
+ ASR_NUMERIC_COLUMNS = ["wer", "cer", "rtfx", "duration_sec", "inference_time_sec", "num_samples"]
66
+ ASR_TEXT_COLUMNS = ["model_family", "model", "dataset_group", "split", "language", "region"]
67
+
68
+ # =============================================================================
69
+ # Metric Configuration
70
+ # =============================================================================
71
+
72
+ METRIC_CONFIGS = {
73
+ "cer": {"label": "CER", "better": "lower", "fmt": "{:.2f}"},
74
+ "wer": {"label": "WER", "better": "lower", "fmt": "{:.2f}"},
75
+ "rtfx": {"label": "RTFx", "better": "higher", "fmt": "{:.2f}"},
76
+ }
77
+
78
+ VIEW_MODE_COLUMNS = {
79
+ "Model families": "model_family",
80
+ "Individual models": "model",
81
+ }
82
+ DEFAULT_VIEW_MODE = "Model families"
83
+
84
+ # =============================================================================
85
+ # Language Normalization
86
+ # =============================================================================
87
+
88
+ LANGUAGE_NAME_MAPPING = _load_json("language_name_mapping.json")
89
+
90
+ # =============================================================================
91
+ # Geographic Mappings
92
+ # =============================================================================
93
+
94
+ # Language to country mapping for Africa map (using ISO 3166-1 alpha-3 codes)
95
+ LANGUAGE_COUNTRY_MAP = _load_json("language_country_map.json")
96
+
97
+ # Country code to name mapping
98
+ COUNTRY_NAMES = _load_json("country_names.json")
99
+
100
+ # All African countries (ISO 3166-1 alpha-3 -> name), used to show "No coverage"
101
+ # on the map for countries without benchmark data.
102
+ AFRICAN_COUNTRIES = _load_json("african_countries.json")
103
+
104
+ # Language to countries mapping (full country names)
105
+ # Used for language metadata and region lookups
106
+ LANGUAGE_TO_COUNTRIES_MAP: dict[str, list[str]] = _load_json("language_to_countries_map.json")
107
+
108
+ # Country to African region mapping (geographical)
109
+ COUNTRY_TO_REGION_MAP: dict[str, str] = _load_json("country_to_region_map.json")
110
+
111
+ # =============================================================================
112
+ # Visualization Interpretation Text
113
+ # =============================================================================
114
+
115
+ INTERPRETATIONS = {
116
+ 'speed_accuracy': """
117
+ - Each bubble represents a specific model; **bubble size = model parameter count**
118
+ - **X-axis (WER)**: Left is better (more accurate)
119
+ - **Y-axis (RTFx)**: Up is better (faster processing)
120
+ - **Top-left quadrant (⭐)**: Ideal zone - fast AND accurate models
121
+ - Gray dashed lines show median values for reference
122
+ - Uses median values to reduce impact of outliers
123
+ - Hover over bubbles to see exact parameter counts (e.g., 1.5B, 300M)
124
+ """,
125
+
126
+ 'leaderboard': """
127
+ - **No language selected**: Shows model families (aggregated across all languages)
128
+ - **Language(s) selected**: Shows top 15 individual models for those languages
129
+ - The horizontal bars show the median Word Error Rate (WER)
130
+ - Lower WER values (left side) indicate better accuracy
131
+ - Error bars represent the standard deviation, showing variability
132
+ - Bar colors correspond to each model family's assigned color
133
+ - Hover over bars to see additional metrics like RTFx (speed) and total samples evaluated
134
+ - Uses median instead of mean to reduce impact of outliers
135
+ """,
136
+
137
+ 'cer_leaderboard': """
138
+ - **No language selected**: Shows model families (aggregated across all languages)
139
+ - **Language(s) selected**: Shows top 15 individual models for those languages
140
+ - The horizontal bars show the median Character Error Rate (CER)
141
+ - Lower CER values (left side) indicate better accuracy
142
+ - CER is especially important for agglutinative and low-resource languages
143
+ - Error bars represent the standard deviation, showing variability
144
+ - Bar colors correspond to each model family's assigned color
145
+ - Hover over bars to see additional metrics like WER, RTFx (speed) and total samples evaluated
146
+ - Uses median instead of mean to reduce impact of outliers
147
+ """,
148
+
149
+ 'correlation': """
150
+ - Each point represents one evaluation result
151
+ - Strong positive correlation means CER and WER move together
152
+ - Models with high character errors typically also have high word errors
153
+ - The trend line shows the overall relationship
154
+ - **Below the line**: Models make more accurate character-level predictions (phonetically closer errors)
155
+ - **Above the line**: Models make more severe character-level errors per word mistake
156
+ - Can be filtered by language to analyze specific language patterns
157
+ """,
158
+
159
+ 'consistency': """
160
+ - Coefficient of Variation (CV) = (Standard Deviation / Median) × 100%
161
+ - **Lower CV** = more consistent performance across different languages
162
+ - **Higher CV** = performance varies widely depending on the language
163
+ - Bar colors correspond to each model family's assigned color
164
+ - Important for production deployment - you want consistent models
165
+ - Outliers have been removed using IQR method for more robust analysis
166
+ - Uses median instead of mean for more robust central tendency measure
167
+ """
168
+ }
src/data/african_countries.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "DZA": "Algeria",
3
+ "AGO": "Angola",
4
+ "BEN": "Benin",
5
+ "BWA": "Botswana",
6
+ "BFA": "Burkina Faso",
7
+ "BDI": "Burundi",
8
+ "CPV": "Cabo Verde",
9
+ "CMR": "Cameroon",
10
+ "CAF": "Central African Republic",
11
+ "TCD": "Chad",
12
+ "COM": "Comoros",
13
+ "COG": "Republic of Congo",
14
+ "COD": "DR Congo",
15
+ "CIV": "Côte d'Ivoire",
16
+ "DJI": "Djibouti",
17
+ "EGY": "Egypt",
18
+ "GNQ": "Equatorial Guinea",
19
+ "ERI": "Eritrea",
20
+ "SWZ": "Eswatini",
21
+ "ETH": "Ethiopia",
22
+ "GAB": "Gabon",
23
+ "GMB": "Gambia",
24
+ "GHA": "Ghana",
25
+ "GIN": "Guinea",
26
+ "GNB": "Guinea-Bissau",
27
+ "KEN": "Kenya",
28
+ "LSO": "Lesotho",
29
+ "LBR": "Liberia",
30
+ "LBY": "Libya",
31
+ "MDG": "Madagascar",
32
+ "MWI": "Malawi",
33
+ "MLI": "Mali",
34
+ "MRT": "Mauritania",
35
+ "MUS": "Mauritius",
36
+ "MAR": "Morocco",
37
+ "MOZ": "Mozambique",
38
+ "NAM": "Namibia",
39
+ "NER": "Niger",
40
+ "NGA": "Nigeria",
41
+ "RWA": "Rwanda",
42
+ "STP": "São Tomé and Príncipe",
43
+ "SEN": "Senegal",
44
+ "SYC": "Seychelles",
45
+ "SLE": "Sierra Leone",
46
+ "SOM": "Somalia",
47
+ "ZAF": "South Africa",
48
+ "SSD": "South Sudan",
49
+ "SDN": "Sudan",
50
+ "TZA": "Tanzania",
51
+ "TGO": "Togo",
52
+ "TUN": "Tunisia",
53
+ "UGA": "Uganda",
54
+ "ZMB": "Zambia",
55
+ "ZWE": "Zimbabwe"
56
+ }
src/data/changelog_cards.html ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <div class="changelog-cards" style="display: flex; flex-wrap: wrap; gap: 20px; align-items: stretch;">
3
+ <div style="flex: 1 1 340px; background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%); border-radius: 12px; padding: 20px; border: 1px solid #e0e7ef;">
4
+ <h4 style="margin: 0 0 2px 0; color: #0f172a; font-size: 1.1em; font-weight: 600;">🗓️ v2 · Release</h4>
5
+ <div style="color: #666; font-size: 0.82rem; margin: 0 0 14px 0; border-bottom: 2px solid #8CD0FF; padding-bottom: 8px;">June 30, 2026</div>
6
+ <div style="display: grid; gap: 8px;">
7
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
8
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🌍 Languages</span>
9
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">61</span>
10
+ </div>
11
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
12
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📁 Dataset groups</span>
13
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">11</span>
14
+ </div>
15
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
16
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📍 Countries</span>
17
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">38</span>
18
+ </div>
19
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
20
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🎙️ Models</span>
21
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">53</span>
22
+ </div>
23
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
24
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🧩 Model families</span>
25
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">16</span>
26
+ </div>
27
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
28
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🧪 Evaluation splits</span>
29
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">93</span>
30
+ </div>
31
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
32
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📊 Test samples</span>
33
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">417,460</span>
34
+ </div>
35
+ </div>
36
+ <div style="margin-top: 14px; font-size: 0.85rem; line-height: 1.5;">
37
+ <strong style="color: #0f172a;">What's new</strong>
38
+ <ul style="margin: 6px 0 0 0; padding-left: 18px; color: #444;">
39
+ <li>Added <strong>SeamlessM4T</strong> — a new model and model family</li>
40
+ <li>Four new dataset groups: <a href="https://huggingface.co/datasets/alaleye/fon">Alaleye</a>, <a href="https://huggingface.co/datasets/sartifyllc/Sukuma-Voices">Sartify LLC</a>, <a href="https://huggingface.co/datasets/CLEAR-Global/TWB-Voice-1.0">TWB</a>, <a href="https://huggingface.co/datasets/google/WaxalNLP">WAXAL</a></li>
41
+ <li>New filter for model language support (multilingual vs. English-only)</li>
42
+ <li>Changelog</li>
43
+ </ul>
44
+ </div>
45
+ </div>
46
+ <div style="flex: 1 1 340px; background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%); border-radius: 12px; padding: 20px; border: 1px solid #e0e7ef;">
47
+ <h4 style="margin: 0 0 2px 0; color: #0f172a; font-size: 1.1em; font-weight: 600;">🗓️ v1 · Initial release</h4>
48
+ <div style="color: #666; font-size: 0.82rem; margin: 0 0 14px 0; border-bottom: 2px solid #8CD0FF; padding-bottom: 8px;">February 5, 2026</div>
49
+ <div style="display: grid; gap: 8px;">
50
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
51
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🌍 Languages</span>
52
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">39</span>
53
+ </div>
54
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
55
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📁 Dataset groups</span>
56
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">7</span>
57
+ </div>
58
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
59
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📍 Countries</span>
60
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">34</span>
61
+ </div>
62
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
63
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🎙️ Models</span>
64
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">52</span>
65
+ </div>
66
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
67
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🧩 Model families</span>
68
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">15</span>
69
+ </div>
70
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
71
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🧪 Evaluation splits</span>
72
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">60</span>
73
+ </div>
74
+ <div style="background: white; padding: 9px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
75
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📊 Test samples</span>
76
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">209,540</span>
77
+ </div>
78
+ </div>
79
+ <div style="margin-top: 14px; font-size: 0.85rem; line-height: 1.5;">
80
+ <strong style="color: #0f172a;">Highlights</strong>
81
+ <ul style="margin: 6px 0 0 0; padding-left: 18px; color: #444;">
82
+ <li>Initial public launch of PazaBench</li>
83
+ <li>Scored on <strong>CER</strong>, <strong>WER</strong> and <strong>RTFx</strong></li>
84
+ <li>Whisper duration-truncation to curb hallucination</li>
85
+ </ul>
86
+ </div>
87
+ </div>
88
+ </div>
src/data/country_names.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ZAF": "South Africa",
3
+ "ETH": "Ethiopia",
4
+ "EGY": "Egypt",
5
+ "MAR": "Morocco",
6
+ "DZA": "Algeria",
7
+ "TUN": "Tunisia",
8
+ "LBY": "Libya",
9
+ "SDN": "Sudan",
10
+ "CMR": "Cameroon",
11
+ "MOZ": "Mozambique",
12
+ "KEN": "Kenya",
13
+ "BFA": "Burkina Faso",
14
+ "CIV": "Côte d'Ivoire",
15
+ "SEN": "Senegal",
16
+ "MLI": "Mali",
17
+ "NGA": "Nigeria",
18
+ "GIN": "Guinea",
19
+ "NER": "Niger",
20
+ "UGA": "Uganda",
21
+ "RWA": "Rwanda",
22
+ "COD": "DR Congo",
23
+ "COG": "Congo",
24
+ "TZA": "Tanzania",
25
+ "MWI": "Malawi",
26
+ "ZMB": "Zambia",
27
+ "LSO": "Lesotho",
28
+ "BWA": "Botswana",
29
+ "ZWE": "Zimbabwe",
30
+ "SOM": "Somalia",
31
+ "ERI": "Eritrea",
32
+ "GHA": "Ghana",
33
+ "AGO": "Angola",
34
+ "GMB": "Gambia",
35
+ "BEN": "Benin",
36
+ "TCD": "Chad",
37
+ "TGO": "Togo",
38
+ "MDG": "Madagascar",
39
+ "SWZ": "Eswatini"
40
+ }
src/data/country_to_region_map.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Kenya": "East Africa",
3
+ "Tanzania": "East Africa",
4
+ "Uganda": "East Africa",
5
+ "Rwanda": "East Africa",
6
+ "Burundi": "East Africa",
7
+ "Ethiopia": "East Africa",
8
+ "Eritrea": "East Africa",
9
+ "Somalia": "East Africa",
10
+ "Djibouti": "East Africa",
11
+ "South Africa": "Southern Africa",
12
+ "Namibia": "Southern Africa",
13
+ "Botswana": "Southern Africa",
14
+ "Zimbabwe": "Southern Africa",
15
+ "Zambia": "Southern Africa",
16
+ "Malawi": "Southern Africa",
17
+ "Mozambique": "Southern Africa",
18
+ "Angola": "Southern Africa",
19
+ "Lesotho": "Southern Africa",
20
+ "Eswatini": "Southern Africa",
21
+ "Nigeria": "West Africa",
22
+ "Ghana": "West Africa",
23
+ "Senegal": "West Africa",
24
+ "Gambia": "West Africa",
25
+ "Guinea": "West Africa",
26
+ "Mali": "West Africa",
27
+ "Burkina Faso": "West Africa",
28
+ "Côte d'Ivoire": "West Africa",
29
+ "Niger": "West Africa",
30
+ "Cameroon": "West Africa",
31
+ "DR Congo": "Central Africa",
32
+ "Republic of Congo": "Central Africa",
33
+ "Central African Republic": "Central Africa",
34
+ "Gabon": "Central Africa",
35
+ "Egypt": "North Africa",
36
+ "Libya": "North Africa",
37
+ "Tunisia": "North Africa",
38
+ "Algeria": "North Africa",
39
+ "Morocco": "North Africa",
40
+ "Sudan": "North Africa",
41
+ "Benin": "West Africa",
42
+ "Togo": "West Africa",
43
+ "Chad": "Central Africa",
44
+ "Madagascar": "East Africa"
45
+ }
src/data/dataset_group_metadata.json ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ALFFA": {
3
+ "description": "Read and broadcast speech for African ASR benchmarks",
4
+ "url": "https://openslr.org/25/",
5
+ "license": "MIT",
6
+ "languages": [
7
+ "Amharic",
8
+ "Swahili",
9
+ "Wolof"
10
+ ]
11
+ },
12
+ "African Next Voices Kenya": {
13
+ "description": "Conversational speech collected in Kenya",
14
+ "url": "https://huggingface.co/datasets/MCAA1-MSU/anv_data_ke",
15
+ "license": "CC BY 4.0",
16
+ "languages": [
17
+ "Dholuo",
18
+ "Kalenjin",
19
+ "Kikuyu",
20
+ "Maasai",
21
+ "Somali"
22
+ ]
23
+ },
24
+ "African Next Voices South Africa": {
25
+ "description": "Conversational speech collected in South Africa",
26
+ "url": "https://huggingface.co/datasets/dsfsi-anv/za-african-next-voices",
27
+ "license": "CC BY 4.0",
28
+ "languages": [
29
+ "Sesotho",
30
+ "Setswana",
31
+ "Xhosa",
32
+ "Xitsonga",
33
+ "Tshivenda",
34
+ "Zulu"
35
+ ]
36
+ },
37
+ "Google FLEURS": {
38
+ "description": "Read speech from the FLEURS multilingual benchmark",
39
+ "url": "https://huggingface.co/datasets/google/fleurs",
40
+ "license": "CC BY 4.0",
41
+ "languages": [
42
+ "Afrikaans",
43
+ "Amharic",
44
+ "Fula",
45
+ "Ganda",
46
+ "Hausa",
47
+ "Igbo",
48
+ "Kamba",
49
+ "Lingala",
50
+ "Dholuo",
51
+ "Northern Sotho",
52
+ "Nyanja",
53
+ "Oromo",
54
+ "Shona",
55
+ "Somali",
56
+ "Swahili",
57
+ "Umbundu",
58
+ "Wolof",
59
+ "Xhosa",
60
+ "Yoruba",
61
+ "Zulu"
62
+ ]
63
+ },
64
+ "DigiGreen Kikuyu ASR": {
65
+ "description": "Agricultural speech from Kikuyu-speaking farmers",
66
+ "url": "https://huggingface.co/datasets/DigiGreen/KikuyuASR_trainingdataset",
67
+ "license": "Apache 2.0",
68
+ "languages": [
69
+ "Kikuyu"
70
+ ]
71
+ },
72
+ "WAXAL": {
73
+ "description": "Community speech corpus for African languages",
74
+ "url": "https://huggingface.co/datasets/google/WaxalNLP",
75
+ "license": "Mixed (CC BY 4.0 / CC BY-SA 4.0)",
76
+ "languages": [
77
+ "Acholi",
78
+ "Akan",
79
+ "Amharic",
80
+ "Dagbani",
81
+ "Dagaare",
82
+ "Ewe",
83
+ "Fula",
84
+ "Ikposo",
85
+ "Lingala",
86
+ "Luganda",
87
+ "Maasai",
88
+ "Malagasy",
89
+ "Nyankole",
90
+ "Oromo",
91
+ "Sidamo",
92
+ "Shona",
93
+ "Soga",
94
+ "Tigrinya",
95
+ "Wolaytta"
96
+ ]
97
+ },
98
+ "Alaleye": {
99
+ "description": "Read speech in Fongbe",
100
+ "url": "https://huggingface.co/datasets/alaleye/fon",
101
+ "license": "CC BY 4.0",
102
+ "languages": [
103
+ "Fongbe"
104
+ ]
105
+ },
106
+ "Sartify LLC": {
107
+ "description": "Sukuma speech corpus",
108
+ "url": "https://huggingface.co/datasets/sartifyllc/Sukuma-Voices",
109
+ "license": "CC BY 4.0",
110
+ "languages": [
111
+ "Sukuma"
112
+ ]
113
+ },
114
+ "TWB": {
115
+ "description": "Read speech curated by CLEAR Global",
116
+ "url": "https://huggingface.co/datasets/CLEAR-Global/TWB-Voice-1.0",
117
+ "license": "CC BY-NC 4.0 (gated access)",
118
+ "languages": [
119
+ "Hausa",
120
+ "Kanuri",
121
+ "Chadian Arabic",
122
+ "Swahili"
123
+ ]
124
+ },
125
+ "Mozilla Common Voice 23.0": {
126
+ "description": "Crowdsourced speech from Common Voice 23.0",
127
+ "url": "https://commonvoice.mozilla.org/",
128
+ "license": "CC0 1.0",
129
+ "languages": [
130
+ "Adamawa Fulfulde",
131
+ "Afrikaans",
132
+ "Amharic",
133
+ "Arabic",
134
+ "Baatonum",
135
+ "Bafut",
136
+ "Bankon",
137
+ "Basaa",
138
+ "Borgu Fulfulde",
139
+ "Dholuo",
140
+ "Dioula",
141
+ "Ekoti",
142
+ "Hausa",
143
+ "Igbo",
144
+ "Kabyle",
145
+ "Kalenjin",
146
+ "Kidaw'ida",
147
+ "Kinyarwanda",
148
+ "Luganda",
149
+ "Mbo",
150
+ "Northern Sotho",
151
+ "Nyungwe",
152
+ "Setswana",
153
+ "South Ndebele",
154
+ "Swahili",
155
+ "Swati",
156
+ "Tamazight",
157
+ "Tigre",
158
+ "Tigrinya",
159
+ "Tsonga",
160
+ "Twi",
161
+ "Venda",
162
+ "Xhosa",
163
+ "Yoruba",
164
+ "Zulu"
165
+ ]
166
+ },
167
+ "Naija Voices": {
168
+ "description": "Conversational Nigerian speech",
169
+ "url": "https://huggingface.co/datasets/naijavoices/naijavoices-dataset",
170
+ "license": "CC BY-NC-SA 4.0",
171
+ "languages": [
172
+ "Hausa",
173
+ "Igbo",
174
+ "Yoruba"
175
+ ]
176
+ }
177
+ }
src/data/language_country_map.json ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Afrikaans": [
3
+ "ZAF"
4
+ ],
5
+ "Amharic": [
6
+ "ETH"
7
+ ],
8
+ "Arabic": [
9
+ "EGY",
10
+ "MAR",
11
+ "DZA",
12
+ "TUN",
13
+ "LBY",
14
+ "SDN"
15
+ ],
16
+ "Basaa": [
17
+ "CMR"
18
+ ],
19
+ "Dholuo": [
20
+ "KEN"
21
+ ],
22
+ "Dioula": [
23
+ "BFA",
24
+ "CIV"
25
+ ],
26
+ "Acholi": [
27
+ "UGA"
28
+ ],
29
+ "Akan": [
30
+ "GHA"
31
+ ],
32
+ "Adamawa Fulfulde": [
33
+ "NGA",
34
+ "CMR"
35
+ ],
36
+ "Baatonum": [
37
+ "BEN"
38
+ ],
39
+ "Bafut": [
40
+ "CMR"
41
+ ],
42
+ "Bankon": [
43
+ "CMR"
44
+ ],
45
+ "Ekoti": [
46
+ "MOZ"
47
+ ],
48
+ "Dagbani": [
49
+ "GHA"
50
+ ],
51
+ "Dagaare": [
52
+ "GHA"
53
+ ],
54
+ "Fula": [
55
+ "SEN",
56
+ "MLI",
57
+ "NGA",
58
+ "GIN",
59
+ "CMR",
60
+ "NER"
61
+ ],
62
+ "Borgu Fulfulde": [
63
+ "BEN",
64
+ "NGA"
65
+ ],
66
+ "Chadian Arabic": [
67
+ "TCD"
68
+ ],
69
+ "Luganda": [
70
+ "UGA"
71
+ ],
72
+ "Hausa": [
73
+ "NGA",
74
+ "NER"
75
+ ],
76
+ "Igbo": [
77
+ "NGA"
78
+ ],
79
+ "Kabyle": [
80
+ "DZA"
81
+ ],
82
+ "Kalenjin": [
83
+ "KEN"
84
+ ],
85
+ "Kamba": [
86
+ "KEN"
87
+ ],
88
+ "Kidaw'ida": [
89
+ "KEN"
90
+ ],
91
+ "Kikuyu": [
92
+ "KEN"
93
+ ],
94
+ "Kinyarwanda": [
95
+ "RWA"
96
+ ],
97
+ "Kanuri": [
98
+ "NGA",
99
+ "NER",
100
+ "TCD"
101
+ ],
102
+ "Ikposo": [
103
+ "TGO",
104
+ "GHA"
105
+ ],
106
+ "Lingala": [
107
+ "COD",
108
+ "COG"
109
+ ],
110
+ "Maasai": [
111
+ "KEN"
112
+ ],
113
+ "Malagasy": [
114
+ "MDG"
115
+ ],
116
+ "Mbo": [
117
+ "CMR"
118
+ ],
119
+ "Northern Sotho": [
120
+ "ZAF"
121
+ ],
122
+ "Nyanja": [
123
+ "MWI",
124
+ "ZMB"
125
+ ],
126
+ "Nyankole": [
127
+ "UGA"
128
+ ],
129
+ "Nyungwe": [
130
+ "MOZ"
131
+ ],
132
+ "Oromo": [
133
+ "ETH"
134
+ ],
135
+ "Soga": [
136
+ "UGA"
137
+ ],
138
+ "Sesotho": [
139
+ "ZAF",
140
+ "LSO"
141
+ ],
142
+ "Setswana": [
143
+ "ZAF",
144
+ "BWA"
145
+ ],
146
+ "South Ndebele": [
147
+ "ZAF"
148
+ ],
149
+ "Shona": [
150
+ "ZWE"
151
+ ],
152
+ "Somali": [
153
+ "KEN",
154
+ "SOM"
155
+ ],
156
+ "Swati": [
157
+ "SWZ",
158
+ "ZAF"
159
+ ],
160
+ "Swahili": [
161
+ "KEN",
162
+ "TZA",
163
+ "COD"
164
+ ],
165
+ "Tamazight": [
166
+ "MAR",
167
+ "DZA"
168
+ ],
169
+ "Tigre": [
170
+ "ERI"
171
+ ],
172
+ "Tigrinya": [
173
+ "ERI",
174
+ "ETH"
175
+ ],
176
+ "Tsonga": [
177
+ "ZAF",
178
+ "MOZ"
179
+ ],
180
+ "Venda": [
181
+ "ZAF"
182
+ ],
183
+ "Tshivenda": [
184
+ "ZAF",
185
+ "ZWE"
186
+ ],
187
+ "Twi": [
188
+ "GHA"
189
+ ],
190
+ "Umbundu": [
191
+ "AGO"
192
+ ],
193
+ "Wolaytta": [
194
+ "ETH"
195
+ ],
196
+ "Wolof": [
197
+ "SEN",
198
+ "GMB"
199
+ ],
200
+ "Xhosa": [
201
+ "ZAF"
202
+ ],
203
+ "Xitsonga": [
204
+ "ZAF",
205
+ "MOZ"
206
+ ],
207
+ "Yoruba": [
208
+ "NGA",
209
+ "BEN"
210
+ ],
211
+ "Zulu": [
212
+ "ZAF"
213
+ ]
214
+ }
src/data/language_name_mapping.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "Ganda": "Luganda",
3
+ "Luganda": "Luganda",
4
+ "Dholuo": "Dholuo"
5
+ }
src/data/language_to_countries_map.json ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Swahili": [
3
+ "Kenya",
4
+ "Tanzania",
5
+ "Uganda",
6
+ "DR Congo"
7
+ ],
8
+ "Dholuo": [
9
+ "Kenya"
10
+ ],
11
+ "Dholuo (Luo)": [
12
+ "Kenya"
13
+ ],
14
+ "Acholi": [
15
+ "Uganda"
16
+ ],
17
+ "Akan": [
18
+ "Ghana"
19
+ ],
20
+ "Adamawa Fulfulde": [
21
+ "Nigeria",
22
+ "Cameroon"
23
+ ],
24
+ "Baatonum": [
25
+ "Benin"
26
+ ],
27
+ "Bafut": [
28
+ "Cameroon"
29
+ ],
30
+ "Bankon": [
31
+ "Cameroon"
32
+ ],
33
+ "Kalenjin": [
34
+ "Kenya"
35
+ ],
36
+ "Kikuyu": [
37
+ "Kenya"
38
+ ],
39
+ "Kamba": [
40
+ "Kenya"
41
+ ],
42
+ "Maasai": [
43
+ "Kenya",
44
+ "Tanzania"
45
+ ],
46
+ "Kidaw'ida": [
47
+ "Kenya",
48
+ "Tanzania"
49
+ ],
50
+ "Luganda": [
51
+ "Uganda"
52
+ ],
53
+ "Kinyarwanda": [
54
+ "Rwanda"
55
+ ],
56
+ "Dagbani": [
57
+ "Ghana"
58
+ ],
59
+ "Dagaare": [
60
+ "Ghana"
61
+ ],
62
+ "Somali": [
63
+ "Somalia",
64
+ "Kenya",
65
+ "Ethiopia"
66
+ ],
67
+ "Amharic": [
68
+ "Ethiopia"
69
+ ],
70
+ "Tigrinya": [
71
+ "Eritrea",
72
+ "Ethiopia"
73
+ ],
74
+ "Tigre": [
75
+ "Eritrea"
76
+ ],
77
+ "Oromo": [
78
+ "Ethiopia"
79
+ ],
80
+ "Afrikaans": [
81
+ "South Africa",
82
+ "Namibia"
83
+ ],
84
+ "Zulu": [
85
+ "South Africa"
86
+ ],
87
+ "Xhosa": [
88
+ "South Africa"
89
+ ],
90
+ "Setswana": [
91
+ "Botswana",
92
+ "South Africa"
93
+ ],
94
+ "Sesotho": [
95
+ "South Africa",
96
+ "Lesotho"
97
+ ],
98
+ "Northern Sotho": [
99
+ "South Africa"
100
+ ],
101
+ "Xitsonga": [
102
+ "South Africa",
103
+ "Mozambique"
104
+ ],
105
+ "Tshivenda": [
106
+ "South Africa"
107
+ ],
108
+ "South Ndebele": [
109
+ "South Africa"
110
+ ],
111
+ "Shona": [
112
+ "Zimbabwe"
113
+ ],
114
+ "Nyanja": [
115
+ "Malawi",
116
+ "Zambia"
117
+ ],
118
+ "Nyungwe": [
119
+ "Mozambique"
120
+ ],
121
+ "Ekoti": [
122
+ "Mozambique"
123
+ ],
124
+ "Yoruba": [
125
+ "Nigeria"
126
+ ],
127
+ "Igbo": [
128
+ "Nigeria"
129
+ ],
130
+ "Hausa": [
131
+ "Nigeria",
132
+ "Niger"
133
+ ],
134
+ "Wolof": [
135
+ "Senegal",
136
+ "Gambia"
137
+ ],
138
+ "Fula": [
139
+ "Senegal",
140
+ "Guinea",
141
+ "Mali",
142
+ "Nigeria"
143
+ ],
144
+ "Borgu Fulfulde": [
145
+ "Benin",
146
+ "Nigeria"
147
+ ],
148
+ "Chadian Arabic": [
149
+ "Chad"
150
+ ],
151
+ "Twi": [
152
+ "Ghana"
153
+ ],
154
+ "Dioula": [
155
+ "Burkina Faso",
156
+ "Côte d'Ivoire",
157
+ "Mali"
158
+ ],
159
+ "Kanuri": [
160
+ "Nigeria",
161
+ "Niger",
162
+ "Chad"
163
+ ],
164
+ "Ikposo": [
165
+ "Togo",
166
+ "Ghana"
167
+ ],
168
+ "Basaa": [
169
+ "Cameroon"
170
+ ],
171
+ "Ewe": [
172
+ "Ghana",
173
+ "Togo"
174
+ ],
175
+ "Fongbe": [
176
+ "Benin"
177
+ ],
178
+ "Mbo": [
179
+ "Cameroon"
180
+ ],
181
+ "Lingala": [
182
+ "DR Congo",
183
+ "Republic of Congo"
184
+ ],
185
+ "Umbundu": [
186
+ "Angola"
187
+ ],
188
+ "Malagasy": [
189
+ "Madagascar"
190
+ ],
191
+ "Arabic": [
192
+ "Egypt",
193
+ "Libya",
194
+ "Tunisia",
195
+ "Algeria",
196
+ "Morocco",
197
+ "Sudan"
198
+ ],
199
+ "Kabyle": [
200
+ "Algeria"
201
+ ],
202
+ "Tamazight": [
203
+ "Morocco",
204
+ "Algeria"
205
+ ],
206
+ "Wolaytta": [
207
+ "Ethiopia"
208
+ ],
209
+ "Soga": [
210
+ "Uganda"
211
+ ]
212
+ }
src/data/model_family_metadata.json ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Phi-4": {
3
+ "display_name": "Microsoft Phi-4",
4
+ "description": "Multimodal speech-language baseline evaluated for speech-to-text."
5
+ },
6
+ "facebook-data2vec": {
7
+ "display_name": "Facebook Data2Vec",
8
+ "description": "Self-supervised speech representations fine-tuned for ASR."
9
+ },
10
+ "facebook-mms": {
11
+ "display_name": "Facebook MMS",
12
+ "description": "Massively Multilingual Speech CTC models."
13
+ },
14
+ "facebook-wav2vec2": {
15
+ "display_name": "Facebook Wav2Vec2",
16
+ "description": "Wav2Vec2 ASR baselines trained on English speech."
17
+ },
18
+ "facebook-wav2vec2-conformer": {
19
+ "display_name": "Facebook Wav2Vec2 Conformer",
20
+ "description": "Conformer variants of Wav2Vec2 for ASR."
21
+ },
22
+ "facebook_hubert": {
23
+ "display_name": "Facebook HuBERT",
24
+ "description": "HuBERT speech representation models fine-tuned for ASR."
25
+ },
26
+ "granite-speech": {
27
+ "display_name": "IBM Granite Speech",
28
+ "description": "Speech-language models with Granite LLM backbones."
29
+ },
30
+ "kyutai": {
31
+ "display_name": "Kyutai STT",
32
+ "description": "Streaming speech-to-text model."
33
+ },
34
+ "lite_asr": {
35
+ "display_name": "Lite ASR / EfficientSpeech",
36
+ "description": "Efficient Whisper-derived ASR variants tuned for speed or accuracy."
37
+ },
38
+ "moonshine": {
39
+ "display_name": "Moonshine",
40
+ "description": "Compact encoder-decoder ASR models optimized for small-footprint use."
41
+ },
42
+ "nvidia-nemo": {
43
+ "display_name": "NVIDIA NeMo ASR",
44
+ "description": "NeMo encoder-decoder and transducer ASR models."
45
+ },
46
+ "omnilingual": {
47
+ "display_name": "Facebook Omnilingual ASR",
48
+ "description": "Omnilingual CTC and LLM ASR variants."
49
+ },
50
+ "openai-whisper": {
51
+ "display_name": "OpenAI Whisper",
52
+ "description": "Whisper encoder-decoder models across multiple model sizes."
53
+ },
54
+ "paza": {
55
+ "display_name": "Paza by Microsoft Research Africa, Nairobi",
56
+ "description": "Paza ASR and speech-language models adapted for African-language evaluation."
57
+ },
58
+ "qwen2-audio": {
59
+ "display_name": "Qwen2 Audio",
60
+ "description": "Qwen2 Audio speech-language models."
61
+ },
62
+ "seamlessM4T": {
63
+ "display_name": "SeamlessM4T",
64
+ "description": "Meta SeamlessM4T multilingual speech-to-text models."
65
+ }
66
+ }
src/data_processing.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data loading, filtering, aggregation and metric computation for PazaBench.
3
+
4
+ This module handles all data processing logic:
5
+ - Loading ASR results from CSV (with HuggingFace Hub fallback)
6
+ - Filtering dataframes by model, language, dataset, region
7
+ - Aggregating results by language with proper RTFx computation
8
+ - Building metric pivot tables and DataFrames
9
+ """
10
+
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ import pandas as pd
15
+ from huggingface_hub import hf_hub_download
16
+
17
+ from src.constants import (
18
+ ASR_DISPLAY_COLUMNS,
19
+ ASR_NUMERIC_COLUMNS,
20
+ ASR_TEXT_COLUMNS,
21
+ canonicalize_model_family,
22
+ DEFAULT_VIEW_MODE,
23
+ FILTER_COLUMN_ORDER,
24
+ LANGUAGE_NAME_MAPPING,
25
+ METRIC_CONFIGS,
26
+ RESULTS_CSV_FILENAME,
27
+ RESULTS_CSV_PATH,
28
+ VIEW_MODE_COLUMNS,
29
+ )
30
+ from src.display.styling import format_metric_value
31
+ from src.envs import HF_ENABLED, RESULTS_REPO, TOKEN
32
+ from src.language_metadata import get_language_regions
33
+
34
+
35
+ REQUIRED_RESULTS_COLUMNS = [
36
+ "model_family",
37
+ "model",
38
+ "dataset_group",
39
+ "split",
40
+ "language",
41
+ "wer",
42
+ "cer",
43
+ "num_samples",
44
+ ]
45
+ def _validate_results_schema(frame: pd.DataFrame) -> None:
46
+ """Validate required columns are present without blocking app startup."""
47
+ missing = [column for column in REQUIRED_RESULTS_COLUMNS if column not in frame.columns]
48
+ if missing:
49
+ raise ValueError(
50
+ "ASR results CSV is missing required columns: "
51
+ + ", ".join(sorted(missing))
52
+ + ". Re-run the aggregator and ensure new fields are mapped correctly."
53
+ )
54
+
55
+ # Normalize empty strings in text columns to NA (later filled as "Unknown").
56
+ for column in ASR_TEXT_COLUMNS:
57
+ if column in frame.columns:
58
+ frame[column] = frame[column].replace(r"^\s*$", pd.NA, regex=True)
59
+
60
+
61
+ # =============================================================================
62
+ # Data Loading
63
+ # =============================================================================
64
+
65
+ @lru_cache(maxsize=1)
66
+ def _cached_asr_results(csv_path: str) -> pd.DataFrame:
67
+ """Load ASR results from HuggingFace Hub or fall back to local file."""
68
+ path = Path(csv_path)
69
+
70
+ if HF_ENABLED and RESULTS_REPO:
71
+ try:
72
+ print(f"Downloading {RESULTS_CSV_FILENAME} from {RESULTS_REPO}...")
73
+ downloaded_path = hf_hub_download(
74
+ repo_id=RESULTS_REPO,
75
+ filename=RESULTS_CSV_FILENAME,
76
+ repo_type="dataset",
77
+ token=TOKEN,
78
+ )
79
+ path = Path(downloaded_path)
80
+ print(f"Successfully downloaded results from {RESULTS_REPO}")
81
+ except Exception as e:
82
+ print(f"Could not download from HuggingFace Hub: {e}")
83
+ print(f"Falling back to local file at {csv_path}")
84
+ path = Path(csv_path)
85
+
86
+ if not path.exists():
87
+ raise FileNotFoundError(
88
+ f"ASR results CSV not found at {path}. "
89
+ "Please generate it via src.aggregate_results before launching the app."
90
+ )
91
+
92
+ frame = pd.read_csv(path)
93
+
94
+ _validate_results_schema(frame)
95
+
96
+ # Convert numeric columns
97
+ for column in ASR_NUMERIC_COLUMNS:
98
+ if column in frame.columns:
99
+ frame[column] = pd.to_numeric(frame[column], errors="coerce")
100
+
101
+ # Fill missing text columns
102
+ for column in ASR_TEXT_COLUMNS:
103
+ if column in frame.columns:
104
+ frame[column] = frame[column].fillna("Unknown")
105
+
106
+ if "model_family" in frame.columns:
107
+ frame["model_family"] = frame["model_family"].map(canonicalize_model_family)
108
+
109
+ # Normalize language names
110
+ if "language" in frame.columns:
111
+ frame["language"] = frame["language"].replace(LANGUAGE_NAME_MAPPING)
112
+
113
+ # Filter out rows with very few samples
114
+ MIN_SAMPLES_THRESHOLD = 10
115
+ if "num_samples" in frame.columns:
116
+ frame = frame[frame["num_samples"] >= MIN_SAMPLES_THRESHOLD]
117
+
118
+ # Add region metadata column
119
+ if "language" in frame.columns:
120
+ frame["african_region"] = frame["language"].apply(
121
+ lambda x: ", ".join(get_language_regions(x))
122
+ )
123
+
124
+ return frame
125
+
126
+
127
+ def load_asr_results(csv_path: Path = RESULTS_CSV_PATH) -> pd.DataFrame:
128
+ """Load ASR results DataFrame."""
129
+ return _cached_asr_results(str(csv_path))
130
+
131
+
132
+ # =============================================================================
133
+ # Filtering
134
+ # =============================================================================
135
+
136
+ def _sorted_column_values(frame: pd.DataFrame, column: str) -> list[str]:
137
+ """Get sorted unique values from a column, with 'Unknown' at the end."""
138
+ if column not in frame.columns or frame.empty:
139
+ return []
140
+ values = sorted({value for value in frame[column].dropna().unique() if value != "Unknown"})
141
+ if (frame[column] == "Unknown").any():
142
+ values.append("Unknown")
143
+ return values
144
+
145
+
146
+ def get_filter_options(frame: pd.DataFrame) -> dict[str, list[str]]:
147
+ """Get all available filter options from the DataFrame."""
148
+ from src.about import get_dataset_group_label
149
+ from src.language_metadata import get_all_regions
150
+
151
+ options = {column: _sorted_column_values(frame, column) for column in FILTER_COLUMN_ORDER}
152
+ options["dataset_group_labels"] = [
153
+ get_dataset_group_label(dg, frame) for dg in options.get("dataset_group", [])
154
+ ]
155
+ options["african_region"] = get_all_regions()
156
+ options["language_support"] = _sorted_column_values(frame, "language_support")
157
+ return options
158
+
159
+
160
+ def get_languages_for_filters(
161
+ frame: pd.DataFrame,
162
+ african_regions: list[str] | None = None,
163
+ ) -> list[str]:
164
+ """Get languages that match the given region filter."""
165
+ from src.language_metadata import get_languages_by_region
166
+
167
+ if not african_regions:
168
+ return _sorted_column_values(frame, "language")
169
+
170
+ region_languages: set[str] = set()
171
+ for region in african_regions:
172
+ region_languages.update(get_languages_by_region(region))
173
+
174
+ available_languages = set(_sorted_column_values(frame, "language"))
175
+ return sorted(region_languages & available_languages)
176
+
177
+
178
+ def filter_asr_dataframe(
179
+ frame: pd.DataFrame,
180
+ *,
181
+ models: list[str] | None = None,
182
+ languages: list[str] | None = None,
183
+ dataset_groups: list[str] | None = None,
184
+ language_support: list[str] | None = None,
185
+ african_regions: list[str] | None = None,
186
+ ) -> pd.DataFrame:
187
+ """Filter the ASR results DataFrame by the given criteria."""
188
+ from src.language_metadata import get_languages_by_region
189
+
190
+ filtered = frame.copy()
191
+
192
+ if models:
193
+ filtered = filtered[filtered["model"].isin(models)]
194
+ if languages:
195
+ filtered = filtered[filtered["language"].isin(languages)]
196
+ if dataset_groups:
197
+ filtered = filtered[filtered["dataset_group"].isin(dataset_groups)]
198
+ if language_support and "language_support" in filtered.columns:
199
+ filtered = filtered[filtered["language_support"].isin(language_support)]
200
+
201
+ # Apply region filter by getting languages for selected regions
202
+ if african_regions:
203
+ region_languages: set[str] = set()
204
+ for region in african_regions:
205
+ region_languages.update(get_languages_by_region(region))
206
+ if region_languages and "language" in filtered.columns:
207
+ filtered = filtered[filtered["language"].isin(region_languages)]
208
+
209
+ return filtered
210
+
211
+
212
+ def prepare_display_dataframe(
213
+ frame: pd.DataFrame,
214
+ max_rows: int,
215
+ include_split: bool = True
216
+ ) -> pd.DataFrame:
217
+ """Prepare a DataFrame for display with proper formatting."""
218
+ columns = [col for col in ASR_DISPLAY_COLUMNS if col in frame.columns]
219
+ if not include_split and "split" in columns:
220
+ columns = [col for col in columns if col != "split"]
221
+
222
+ display = frame.loc[:, columns].copy()
223
+
224
+ for column in ["wer", "cer", "rtfx"]:
225
+ if column in display.columns:
226
+ display[column] = display[column].round(3)
227
+ for column in ["duration_sec", "inference_time_sec"]:
228
+ if column in display.columns:
229
+ display[column] = display[column].round(2)
230
+
231
+ display = display.head(max_rows)
232
+ display.insert(0, "", range(1, len(display) + 1))
233
+ return display
234
+
235
+
236
+ # =============================================================================
237
+ # Aggregation
238
+ # =============================================================================
239
+
240
+ def _join_unique_values(values: pd.Series) -> str | None:
241
+ """Join unique non-empty values from a Series."""
242
+ if values is None:
243
+ return None
244
+ unique_values = [str(v) for v in values.dropna().unique() if str(v).strip()]
245
+ return ", ".join(unique_values) if unique_values else None
246
+
247
+
248
+ def _weighted_average(series: pd.Series, weights: pd.Series) -> float | None:
249
+ """Compute weighted average of a series."""
250
+ numeric = pd.to_numeric(series, errors="coerce").dropna()
251
+ if numeric.empty:
252
+ return None
253
+ aligned_weights = weights.loc[numeric.index].fillna(0).astype(float)
254
+ total_weight = aligned_weights.sum()
255
+ if total_weight <= 0:
256
+ return float(round(numeric.mean(), 4))
257
+ return float(round((numeric * aligned_weights).sum() / total_weight, 4))
258
+
259
+
260
+ def _compute_rtfx_from_totals(
261
+ duration_series: pd.Series,
262
+ inference_time_series: pd.Series
263
+ ) -> float | None:
264
+ """Compute RTFx as Total Audio Duration / Total Transcription Time."""
265
+ duration_numeric = pd.to_numeric(duration_series, errors="coerce").dropna()
266
+ inference_numeric = pd.to_numeric(inference_time_series, errors="coerce").dropna()
267
+
268
+ common_index = duration_numeric.index.intersection(inference_numeric.index)
269
+ if common_index.empty:
270
+ return None
271
+
272
+ total_duration = duration_numeric.loc[common_index].sum()
273
+ total_inference = inference_numeric.loc[common_index].sum()
274
+
275
+ if total_inference <= 0:
276
+ return None
277
+
278
+ return float(round(total_duration / total_inference, 4))
279
+
280
+
281
+ def aggregate_by_language(frame: pd.DataFrame) -> pd.DataFrame:
282
+ """Aggregate results by model and language with proper metric computation."""
283
+ if frame.empty or "language" not in frame.columns:
284
+ return frame
285
+
286
+ group_keys = [col for col in ["model_family", "model", "language"] if col in frame.columns]
287
+ if not group_keys:
288
+ return frame
289
+
290
+ aggregated_rows: list[dict[str, object]] = []
291
+ for _, group in frame.groupby(group_keys, dropna=False):
292
+ weight_series = group["num_samples"] if "num_samples" in group.columns else pd.Series([1] * len(group))
293
+ weight_series = pd.to_numeric(weight_series, errors="coerce").fillna(0).astype(float)
294
+
295
+ # Compute RTFx: sum(duration) / sum(inference_time)
296
+ rtfx_value = None
297
+ if "duration_sec" in group.columns and "inference_time_sec" in group.columns:
298
+ rtfx_value = _compute_rtfx_from_totals(group["duration_sec"], group["inference_time_sec"])
299
+
300
+ # Sum durations and inference times
301
+ total_duration = None
302
+ total_inference = None
303
+ if "duration_sec" in group.columns:
304
+ duration_numeric = pd.to_numeric(group["duration_sec"], errors="coerce").dropna()
305
+ if not duration_numeric.empty:
306
+ total_duration = float(round(duration_numeric.sum(), 4))
307
+ if "inference_time_sec" in group.columns:
308
+ inference_numeric = pd.to_numeric(group["inference_time_sec"], errors="coerce").dropna()
309
+ if not inference_numeric.empty:
310
+ total_inference = float(round(inference_numeric.sum(), 4))
311
+
312
+ aggregated_rows.append({
313
+ "model_family": group.get("model_family", pd.Series([None])).iloc[0] if "model_family" in group else None,
314
+ "model": group.get("model", pd.Series([None])).iloc[0] if "model" in group else None,
315
+ "dataset_group": _join_unique_values(group["dataset_group"]) if "dataset_group" in group.columns else None,
316
+ "language": group.get("language", pd.Series([None])).iloc[0] if "language" in group else None,
317
+ "region": _join_unique_values(group["region"]) if "region" in group.columns else None,
318
+ "wer": _weighted_average(group["wer"], weight_series) if "wer" in group.columns else None,
319
+ "cer": _weighted_average(group["cer"], weight_series) if "cer" in group.columns else None,
320
+ "rtfx": rtfx_value,
321
+ "duration_sec": total_duration,
322
+ "inference_time_sec": total_inference,
323
+ "num_samples": int(weight_series.sum()) if weight_series.sum() > 0 else len(group),
324
+ })
325
+
326
+ return pd.DataFrame(aggregated_rows)
327
+
328
+
329
+ # =============================================================================
330
+ # Metric Tables
331
+ # =============================================================================
332
+
333
+ def _pivot_metric_table(aggregated: pd.DataFrame, metric: str, view_mode: str) -> pd.DataFrame:
334
+ """Create a pivot table for a specific metric."""
335
+ if aggregated.empty or metric not in aggregated.columns:
336
+ return pd.DataFrame()
337
+
338
+ column_key = VIEW_MODE_COLUMNS.get(view_mode, VIEW_MODE_COLUMNS[DEFAULT_VIEW_MODE])
339
+ if column_key not in aggregated.columns:
340
+ fallback = "model" if "model" in aggregated.columns else None
341
+ if fallback is None:
342
+ return pd.DataFrame()
343
+ column_key = fallback
344
+
345
+ pivot = (
346
+ aggregated
347
+ .pivot_table(index="language", columns=column_key, values=metric, aggfunc="median")
348
+ .sort_index()
349
+ )
350
+ pivot = pivot.dropna(how="all")
351
+ if pivot.empty:
352
+ return pivot
353
+
354
+ # Sort columns by performance
355
+ column_scores = pivot.mean(skipna=True)
356
+ ascending = METRIC_CONFIGS.get(metric, {}).get("better", "lower") == "lower"
357
+ ordered_columns = column_scores.sort_values(ascending=ascending).index.tolist()
358
+
359
+ # Ensure all columns are included
360
+ missing = [col for col in pivot.columns if col not in ordered_columns]
361
+ ordered_columns.extend(missing)
362
+
363
+ return pivot[ordered_columns]
364
+
365
+
366
+ def _build_metric_table_html(pivot: pd.DataFrame, metric: str) -> str:
367
+ """Build HTML table for a metric pivot table."""
368
+ config = METRIC_CONFIGS[metric]
369
+ if pivot.empty:
370
+ return f"<p class='metric-table-empty'>No {config['label']} data available for the current filters.</p>"
371
+
372
+ header_cells = "".join(f"<th>{col}</th>" for col in pivot.columns)
373
+ rows_html: list[str] = []
374
+
375
+ for i, (language, row) in enumerate(pivot.iterrows(), 1):
376
+ cell_html = []
377
+ for column in pivot.columns:
378
+ value = row[column]
379
+ display = format_metric_value(value, config["fmt"])
380
+ cell_html.append(f"<td>{display}</td>")
381
+
382
+ onclick = (
383
+ "(function(el){var tr=el.parentElement;"
384
+ "var isHighlighted=tr.classList.contains('row-highlighted');"
385
+ "tr.parentElement.querySelectorAll('tr').forEach(function(r){"
386
+ "r.classList.remove('row-highlighted')});"
387
+ "if(!isHighlighted){tr.classList.add('row-highlighted')}})(this)"
388
+ )
389
+ rows_html.append(
390
+ f"<tr><td>{i}</td><th onclick=\"{onclick}\">{language}</th>{''.join(cell_html)}</tr>"
391
+ )
392
+
393
+ caption = f"<caption>Columns sorted by overall {config['label']} performance</caption>"
394
+ table_html = (
395
+ "<div class='metric-table-wrapper'>"
396
+ f"<table class='metric-table'>{caption}<thead><tr><th>#</th><th>Language</th>{header_cells}</tr></thead>"
397
+ f"<tbody>{''.join(rows_html)}</tbody></table></div>"
398
+ )
399
+ return table_html
400
+
401
+
402
+ def _build_metric_dataframe(pivot: pd.DataFrame, metric: str) -> pd.DataFrame:
403
+ """Build a sortable DataFrame from the pivot table."""
404
+ if pivot.empty:
405
+ return pd.DataFrame()
406
+
407
+ df = pivot.reset_index()
408
+ df = df.rename(columns={"language": "Language"})
409
+
410
+ # Round numeric columns
411
+ for col in df.columns:
412
+ if col != "Language" and df[col].dtype in ['float64', 'float32']:
413
+ df[col] = df[col].round(2)
414
+
415
+ df.insert(0, "", range(1, len(df) + 1))
416
+ return df
417
+
418
+
419
+ def compute_metric_tables(
420
+ models: list[str] | None,
421
+ languages: list[str] | None,
422
+ dataset_groups: list[str] | None,
423
+ view_mode: str,
424
+ african_regions: list[str] | None = None,
425
+ language_support: list[str] | None = None,
426
+ asr_results_df: pd.DataFrame | None = None,
427
+ ) -> dict[str, str]:
428
+ """Compute HTML metric tables for all metrics."""
429
+ if asr_results_df is None or asr_results_df.empty:
430
+ return {metric: "<p class='metric-table-empty'>No ASR results loaded.</p>" for metric in METRIC_CONFIGS}
431
+
432
+ filtered = filter_asr_dataframe(
433
+ asr_results_df,
434
+ models=models,
435
+ languages=languages,
436
+ dataset_groups=dataset_groups,
437
+ language_support=language_support,
438
+ african_regions=african_regions,
439
+ )
440
+ aggregated = aggregate_by_language(filtered)
441
+
442
+ tables: dict[str, str] = {}
443
+ for metric in METRIC_CONFIGS:
444
+ pivot = _pivot_metric_table(aggregated, metric, view_mode)
445
+ tables[metric] = _build_metric_table_html(pivot, metric)
446
+ return tables
447
+
448
+
449
+ def compute_metric_dataframes(
450
+ models: list[str] | None,
451
+ languages: list[str] | None,
452
+ dataset_groups: list[str] | None,
453
+ view_mode: str,
454
+ african_regions: list[str] | None = None,
455
+ language_support: list[str] | None = None,
456
+ asr_results_df: pd.DataFrame | None = None,
457
+ ) -> dict[str, pd.DataFrame]:
458
+ """Compute metric DataFrames for interactive sorting."""
459
+ if asr_results_df is None or asr_results_df.empty:
460
+ return {metric: pd.DataFrame() for metric in METRIC_CONFIGS}
461
+
462
+ filtered = filter_asr_dataframe(
463
+ asr_results_df,
464
+ models=models,
465
+ languages=languages,
466
+ dataset_groups=dataset_groups,
467
+ language_support=language_support,
468
+ african_regions=african_regions,
469
+ )
470
+ aggregated = aggregate_by_language(filtered)
471
+
472
+ dataframes: dict[str, pd.DataFrame] = {}
473
+ for metric in METRIC_CONFIGS:
474
+ pivot = _pivot_metric_table(aggregated, metric, view_mode)
475
+ dataframes[metric] = _build_metric_dataframe(pivot, metric)
476
+ return dataframes
477
+
478
+
479
+ # =============================================================================
480
+ # Helper Functions
481
+ # =============================================================================
482
+
483
+ def strip_dataset_label(label: str) -> str:
484
+ """Strip descriptor from dataset label.
485
+ E.g., 'ALFFA (read speech & broadcast news, 3 languages)' -> 'ALFFA'
486
+ """
487
+ if " (" in label:
488
+ return label.split(" (")[0]
489
+ return label
490
+
491
+
492
+ def strip_dataset_labels(labels: list[str] | None) -> list[str]:
493
+ """Strip descriptors from a list of dataset labels."""
494
+ if not labels:
495
+ return []
496
+ return [strip_dataset_label(label) for label in labels]
src/display/css_html_js.py ADDED
@@ -0,0 +1,2205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ custom_css = """
2
+
3
+ /* ===== FORCE LIGHT MODE ===== */
4
+ /* Override Gradio's dark mode completely - this app is light mode only */
5
+ :root, html, body, .gradio-container, .dark, body.dark, html.dark {
6
+ --body-background-fill: white !important;
7
+ --background-fill-primary: white !important;
8
+ --background-fill-secondary: #f9fafb !important;
9
+ --body-text-color: #1f2937 !important;
10
+ --body-text-color-subdued: #6b7280 !important;
11
+ --block-background-fill: white !important;
12
+ --block-border-color: #e5e7eb !important;
13
+ --block-label-background-fill: #f3f4f6 !important;
14
+ --block-label-text-color: #374151 !important;
15
+ --block-title-text-color: #111827 !important;
16
+ --input-background-fill: white !important;
17
+ --input-border-color: #d1d5db !important;
18
+ --table-even-background-fill: #f9fafb !important;
19
+ --table-odd-background-fill: white !important;
20
+ --table-row-focus: #e0f2fe !important;
21
+ --table-border-color: #e5e7eb !important;
22
+ --border-color-primary: #e5e7eb !important;
23
+ --border-color-accent: #8CD0FF !important;
24
+ --color-accent: #8CD0FF !important;
25
+ --neutral-50: #f9fafb !important;
26
+ --neutral-100: #f3f4f6 !important;
27
+ --neutral-200: #e5e7eb !important;
28
+ --neutral-300: #d1d5db !important;
29
+ --neutral-400: #9ca3af !important;
30
+ --neutral-500: #6b7280 !important;
31
+ --neutral-600: #4b5563 !important;
32
+ --neutral-700: #374151 !important;
33
+ --neutral-800: #1f2937 !important;
34
+ --neutral-900: #111827 !important;
35
+ --neutral-950: #030712 !important;
36
+ }
37
+
38
+ /* Apply light mode background to main containers only, exclude Plotly */
39
+ html, body, .gradio-container {
40
+ background: white !important;
41
+ color: #1f2937 !important;
42
+ }
43
+
44
+ /* Force light mode on Gradio table components (not Plotly) */
45
+ .dark .svelte-table, body.dark .svelte-table,
46
+ .dark [data-testid="dataframe"], body.dark [data-testid="dataframe"],
47
+ .table-wrap:not(.js-plotly-plot *),
48
+ .dataframe:not(.js-plotly-plot *) {
49
+ background: white !important;
50
+ color: #1f2937 !important;
51
+ }
52
+
53
+ /* Target Gradio tables specifically, exclude Plotly SVG tables */
54
+ .gradio-container table:not(.js-plotly-plot table),
55
+ .dark .gradio-container table:not(.js-plotly-plot table) {
56
+ background: white !important;
57
+ color: #1f2937 !important;
58
+ }
59
+
60
+ .gradio-container th:not(.js-plotly-plot th),
61
+ .gradio-container td:not(.js-plotly-plot td),
62
+ .dark .gradio-container th:not(.js-plotly-plot th),
63
+ .dark .gradio-container td:not(.js-plotly-plot td) {
64
+ background-color: inherit !important;
65
+ color: #1f2937 !important;
66
+ border-color: #e5e7eb !important;
67
+ }
68
+
69
+ .gradio-container thead:not(.js-plotly-plot thead),
70
+ .dark .gradio-container thead:not(.js-plotly-plot thead) {
71
+ background: #f8fafc !important;
72
+ }
73
+
74
+ .gradio-container tbody tr:nth-child(even):not(.js-plotly-plot tr),
75
+ .dark .gradio-container tbody tr:nth-child(even):not(.js-plotly-plot tr) {
76
+ background: #f9fafb !important;
77
+ }
78
+
79
+ .gradio-container tbody tr:nth-child(odd):not(.js-plotly-plot tr),
80
+ .dark .gradio-container tbody tr:nth-child(odd):not(.js-plotly-plot tr) {
81
+ background: white !important;
82
+ }
83
+
84
+ /* Override prefers-color-scheme dark for main elements */
85
+ @media (prefers-color-scheme: dark) {
86
+ :root, html, body, .gradio-container {
87
+ --body-background-fill: white !important;
88
+ --background-fill-primary: white !important;
89
+ --body-text-color: #1f2937 !important;
90
+ background: white !important;
91
+ color: #1f2937 !important;
92
+ }
93
+ }
94
+
95
+ /* Ensure Plotly charts render correctly - DO NOT override their styles */
96
+ .js-plotly-plot, .js-plotly-plot *,
97
+ .plotly, .plotly *,
98
+ .js-plotly-plot svg, .js-plotly-plot svg * {
99
+ background: unset !important;
100
+ background-color: unset !important;
101
+ }
102
+
103
+ /* Reset Plotly container backgrounds */
104
+ .js-plotly-plot .plotly .main-svg,
105
+ .js-plotly-plot .plotly .bg {
106
+ fill: white !important;
107
+ }
108
+
109
+ /* Microsoft-style font family - using system fonts with proper bold weights */
110
+ /* Note: Segoe UI from cdnfonts may not have all weights, rely on system fallbacks */
111
+
112
+ /* Apply font globally */
113
+ .gradio-container,
114
+ .gradio-container *,
115
+ body {
116
+ font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
117
+ }
118
+
119
+ /* Bold text weights */
120
+ b, strong,
121
+ .gradio-container b,
122
+ .gradio-container strong,
123
+ .prose b,
124
+ .prose strong,
125
+ .md b,
126
+ .md strong,
127
+ .markdown-code b,
128
+ .markdown-code strong,
129
+ [class*="markdown"] b,
130
+ [class*="markdown"] strong,
131
+ p b, p strong,
132
+ span b, span strong,
133
+ div b, div strong {
134
+ font-weight: 600 !important;
135
+ }
136
+
137
+ /* Banner image styling */
138
+ #banner-image {
139
+ width: 100%;
140
+ margin-bottom: 1rem;
141
+ }
142
+
143
+ #banner-image img {
144
+ width: 100%;
145
+ height: auto;
146
+ object-fit: contain;
147
+ border-radius: 8px;
148
+ }
149
+
150
+ /* Custom metric table variables (light mode only) */
151
+ :root {
152
+ --metric-table-bg: #ffffff;
153
+ --metric-table-border: #d3d3d3;
154
+ --metric-table-header-bg: #f8fafc;
155
+ --metric-table-header-color: #0f172a;
156
+ --metric-table-row-header-bg: #f2f5f9;
157
+ --metric-table-caption: #666666;
158
+ --metric-table-empty-color: #777777;
159
+ --metric-table-row-highlight: #DDF1FF;
160
+ }
161
+
162
+ /* ===== GLOBAL TEXT SELECTION FIX ===== */
163
+ /* Fix text selection highlight for better contrast in dark mode */
164
+ ::selection {
165
+ background-color: rgba(140, 208, 255, 0.4) !important;
166
+ color: inherit !important;
167
+ }
168
+
169
+ ::-moz-selection {
170
+ background-color: rgba(140, 208, 255, 0.4) !important;
171
+ color: inherit !important;
172
+ }
173
+
174
+ /* PazaBench brand color for buttons */
175
+ button.primary {
176
+ background-color: #8CD0FF !important;
177
+ border-color: #8CD0FF !important;
178
+ color: #0f172a !important;
179
+ }
180
+
181
+ button.primary:hover {
182
+ background-color: #6bc0f5 !important;
183
+ border-color: #6bc0f5 !important;
184
+ color: #0f172a !important;
185
+ }
186
+
187
+ /* WCAG 4.1.2 Fix: Make buttons inside aria-hidden containers non-interactive */
188
+ /* Neutralize focusable buttons in aria-hidden containers */
189
+ .visually-hidden[aria-hidden="true"] button,
190
+ [aria-hidden="true"] .tab-container button,
191
+ .tab-container.visually-hidden[aria-hidden="true"] button,
192
+ div[aria-hidden="true"] button {
193
+ pointer-events: none !important;
194
+ visibility: hidden !important;
195
+ }
196
+
197
+ /* Link color + markdown table styling */
198
+ a {
199
+ text-decoration: none;
200
+ color: #464feb;
201
+ }
202
+ /* Change Log cards: remove link horizontal padding */
203
+ .changelog-cards a {
204
+ padding-left: 0;
205
+ padding-right: 0;
206
+ }
207
+ .markdown-text table th,
208
+ .markdown-text table td {
209
+ border: 1px solid #e6e6e6;
210
+ }
211
+ .markdown-text table th {
212
+ background-color: #f5f5f5;
213
+ }
214
+
215
+ /* Scroll the tall Sample Distribution chart inside a fixed-height container. */
216
+ #language-coverage-plot {
217
+ max-height: 640px !important;
218
+ overflow-y: auto !important;
219
+ overflow-x: hidden !important;
220
+ }
221
+
222
+ /* Hide the Plotly logo ("Produced with Plotly") from every chart's modebar. */
223
+ .js-plotly-plot .modebar-btn--logo,
224
+ .js-plotly-plot a[data-title="Produced with Plotly"] {
225
+ display: none !important;
226
+ }
227
+
228
+ /* Typography hierarchy */
229
+ .markdown-text {
230
+ font-size: 16px !important;
231
+ line-height: 1.6 !important;
232
+ }
233
+
234
+ /* ===== FILTER & CUSTOMIZE ACCORDION STYLING ===== */
235
+ /* Match accordion text size to 15px */
236
+ #filter-customize-accordion .label-wrap span,
237
+ #filter-customize-accordion .label-wrap .open-button,
238
+ #filter-customize-accordion > div > button span {
239
+ font-size: 15px !important;
240
+ font-weight: 400 !important; /* Regular, not bold */
241
+ }
242
+
243
+ /* ===== EXPLAINER TEXT STYLING ===== */
244
+ /* Reduce font size for 'Compare models at a glance' to 15px */
245
+ .explainer-text,
246
+ .explainer-text h3,
247
+ .explainer-text p {
248
+ font-size: 15px !important;
249
+ line-height: 1.5 !important;
250
+ }
251
+
252
+ /* ===== LEADERBOARD TABLE ROW NUMBER COLUMN ===== */
253
+ /* Make row number column (first column with empty header) narrow and regular weight */
254
+ #wer-leaderboard-table th:first-child,
255
+ #cer-leaderboard-table th:first-child,
256
+ #rtfx-leaderboard-table th:first-child {
257
+ width: 40px !important;
258
+ min-width: 40px !important;
259
+ max-width: 60px !important;
260
+ font-weight: 400 !important;
261
+ }
262
+
263
+ #wer-leaderboard-table td:first-child,
264
+ #cer-leaderboard-table td:first-child,
265
+ #rtfx-leaderboard-table td:first-child {
266
+ width: 40px !important;
267
+ min-width: 40px !important;
268
+ max-width: 60px !important;
269
+ font-weight: 400 !important; /* Regular, not bold */
270
+ text-align: center;
271
+ }
272
+
273
+ /* ===== LANGUAGE COLUMN BACKGROUND COLOR ===== */
274
+ /* Make language column (second column) have a light blue background for differentiation */
275
+ #wer-leaderboard-table th:nth-child(2),
276
+ #cer-leaderboard-table th:nth-child(2),
277
+ #rtfx-leaderboard-table th:nth-child(2) {
278
+ background-color: #EEF8FF !important;
279
+ }
280
+
281
+ #wer-leaderboard-table td:nth-child(2),
282
+ #cer-leaderboard-table td:nth-child(2),
283
+ #rtfx-leaderboard-table td:nth-child(2) {
284
+ background-color: #EEF8FF !important;
285
+ }
286
+
287
+ /* Dark mode language column - slightly muted blue */
288
+ body.dark #wer-leaderboard-table th:nth-child(2),
289
+ body.dark #cer-leaderboard-table th:nth-child(2),
290
+ body.dark #rtfx-leaderboard-table th:nth-child(2),
291
+ body.dark #wer-leaderboard-table td:nth-child(2),
292
+ body.dark #cer-leaderboard-table td:nth-child(2),
293
+ body.dark #rtfx-leaderboard-table td:nth-child(2),
294
+ [data-theme="dark"] #wer-leaderboard-table th:nth-child(2),
295
+ [data-theme="dark"] #cer-leaderboard-table th:nth-child(2),
296
+ [data-theme="dark"] #rtfx-leaderboard-table th:nth-child(2),
297
+ [data-theme="dark"] #wer-leaderboard-table td:nth-child(2),
298
+ [data-theme="dark"] #cer-leaderboard-table td:nth-child(2),
299
+ [data-theme="dark"] #rtfx-leaderboard-table td:nth-child(2) {
300
+ background-color: rgba(140, 208, 255, 0.3) !important;
301
+ }
302
+
303
+ /* ===== HIDE GRADIO DATAFRAME CONTEXT MENU ===== */
304
+ /* Hide the right-click context menu (filter option) as it has no function */
305
+ .cell-menu,
306
+ .context-menu,
307
+ [data-testid="dataframe"] .cell-menu,
308
+ [data-testid="dataframe"] .context-menu,
309
+ .table-wrap .cell-menu,
310
+ .table-wrap .context-menu,
311
+ .svelte-virtual-table-viewport .cell-menu,
312
+ .svelte-virtual-table-viewport .context-menu,
313
+ #wer-leaderboard-table .cell-menu,
314
+ #cer-leaderboard-table .cell-menu,
315
+ #rtfx-leaderboard-table .cell-menu,
316
+ #wer-leaderboard-table .context-menu,
317
+ #cer-leaderboard-table .context-menu,
318
+ #rtfx-leaderboard-table .context-menu {
319
+ display: none !important;
320
+ visibility: hidden !important;
321
+ pointer-events: none !important;
322
+ }
323
+
324
+ /* Metric direction notes styling */
325
+ .metric-note {
326
+ font-size: 14px !important;
327
+ color: #666 !important;
328
+ margin-bottom: 8px !important;
329
+ padding: 8px 12px !important;
330
+ background-color: #f8f9fa !important;
331
+ border-left: 3px solid #8CD0FF !important;
332
+ border-radius: 4px !important;
333
+ }
334
+
335
+ /* Remove nested styling from markdown content inside metric-note */
336
+ .metric-note p,
337
+ .metric-note .prose p,
338
+ .metric-note .prose,
339
+ .metric-note > div,
340
+ .metric-note .markdown-code,
341
+ .metric-note .md {
342
+ margin: 0 !important;
343
+ padding: 0 !important;
344
+ background: transparent !important;
345
+ border: none !important;
346
+ }
347
+
348
+ #models-to-add-text {
349
+ font-size: 18px !important;
350
+ }
351
+
352
+ #citation-button span {
353
+ font-size: 16px !important;
354
+ }
355
+
356
+ #citation-button textarea {
357
+ font-size: 16px !important;
358
+ }
359
+
360
+ #citation-button > label > button {
361
+ margin: 6px;
362
+ transform: scale(1.3);
363
+ }
364
+
365
+ #leaderboard-table {
366
+ margin-top: 15px
367
+ }
368
+
369
+ #leaderboard-table-lite {
370
+ margin-top: 15px
371
+ }
372
+
373
+ /* Sortable Dataframe Leaderboard Styles */
374
+ #wer-leaderboard-table,
375
+ #cer-leaderboard-table,
376
+ #rtfx-leaderboard-table {
377
+ margin-top: 15px;
378
+ }
379
+
380
+ #wer-leaderboard-table table,
381
+ #cer-leaderboard-table table,
382
+ #rtfx-leaderboard-table table {
383
+ font-size: 14px;
384
+ }
385
+
386
+ /* ===== COLUMN SIZING ===== */
387
+ /* Headers styling */
388
+ #wer-leaderboard-table th,
389
+ #cer-leaderboard-table th,
390
+ #rtfx-leaderboard-table th {
391
+ position: relative;
392
+ overflow: hidden;
393
+ text-overflow: ellipsis;
394
+ white-space: nowrap;
395
+ min-width: 70px; /* Ensure short model names like "paza" are not truncated */
396
+ }
397
+
398
+ /* Resize handle on right edge of headers */
399
+ #wer-leaderboard-table th .col-resize-handle,
400
+ #cer-leaderboard-table th .col-resize-handle,
401
+ #rtfx-leaderboard-table th .col-resize-handle {
402
+ position: absolute;
403
+ right: 0;
404
+ top: 0;
405
+ bottom: 0;
406
+ width: 5px;
407
+ cursor: col-resize;
408
+ background: transparent;
409
+ z-index: 10;
410
+ }
411
+
412
+ #wer-leaderboard-table th .col-resize-handle:hover,
413
+ #cer-leaderboard-table th .col-resize-handle:hover,
414
+ #rtfx-leaderboard-table th .col-resize-handle:hover,
415
+ #wer-leaderboard-table th .col-resize-handle.resizing,
416
+ #cer-leaderboard-table th .col-resize-handle.resizing,
417
+ #rtfx-leaderboard-table th .col-resize-handle.resizing {
418
+ background: #8CD0FF;
419
+ }
420
+
421
+ /* Cell content handling */
422
+ #wer-leaderboard-table td,
423
+ #cer-leaderboard-table td,
424
+ #rtfx-leaderboard-table td {
425
+ overflow: hidden;
426
+ text-overflow: ellipsis;
427
+ white-space: nowrap;
428
+ }
429
+
430
+ /* Show full content on hover via tooltip */
431
+ #wer-leaderboard-table td[title],
432
+ #cer-leaderboard-table td[title],
433
+ #rtfx-leaderboard-table td[title],
434
+ #wer-leaderboard-table th[title],
435
+ #cer-leaderboard-table th[title],
436
+ #rtfx-leaderboard-table th[title] {
437
+ cursor: help;
438
+ }
439
+
440
+ /* Make column headers look clickable for sorting */
441
+ #wer-leaderboard-table th,
442
+ #cer-leaderboard-table th,
443
+ #rtfx-leaderboard-table th {
444
+ cursor: pointer;
445
+ user-select: none;
446
+ transition: background-color 0.2s ease;
447
+ font-weight: 600;
448
+ }
449
+
450
+ /* Force header font size to 15px - must target all descendants due to Gradio 6 Svelte component nesting */
451
+ #wer-leaderboard-table th,
452
+ #wer-leaderboard-table th *,
453
+ #cer-leaderboard-table th,
454
+ #cer-leaderboard-table th *,
455
+ #rtfx-leaderboard-table th,
456
+ #rtfx-leaderboard-table th * {
457
+ font-size: 15px !important;
458
+ }
459
+
460
+ #wer-leaderboard-table th:hover,
461
+ #cer-leaderboard-table th:hover,
462
+ #rtfx-leaderboard-table th:hover {
463
+ background-color: transparent !important;
464
+ text-decoration: underline;
465
+ }
466
+
467
+ /* ===== LEADERBOARD TABLE HEADER SELECTION FIX ===== */
468
+ /* Remove focus/selection highlight from leaderboard table headers */
469
+ /* Keep only sort arrows visible, remove the box-shadow ring */
470
+ #wer-leaderboard-table th.focus,
471
+ #cer-leaderboard-table th.focus,
472
+ #rtfx-leaderboard-table th.focus,
473
+ #wer-leaderboard-table th:focus,
474
+ #cer-leaderboard-table th:focus,
475
+ #rtfx-leaderboard-table th:focus,
476
+ #wer-leaderboard-table th.sorted,
477
+ #cer-leaderboard-table th.sorted,
478
+ #rtfx-leaderboard-table th.sorted {
479
+ --ring-color: transparent !important;
480
+ box-shadow: none !important;
481
+ outline: none !important;
482
+ background-color: transparent !important;
483
+ }
484
+
485
+ /* Dark mode specific for leaderboard tables */
486
+ body.dark #wer-leaderboard-table th.focus,
487
+ body.dark #cer-leaderboard-table th.focus,
488
+ body.dark #rtfx-leaderboard-table th.focus,
489
+ body.dark #wer-leaderboard-table th:focus,
490
+ body.dark #cer-leaderboard-table th:focus,
491
+ body.dark #rtfx-leaderboard-table th:focus,
492
+ body.dark #wer-leaderboard-table th.sorted,
493
+ body.dark #cer-leaderboard-table th.sorted,
494
+ body.dark #rtfx-leaderboard-table th.sorted,
495
+ [data-theme="dark"] #wer-leaderboard-table th.focus,
496
+ [data-theme="dark"] #cer-leaderboard-table th.focus,
497
+ [data-theme="dark"] #rtfx-leaderboard-table th.focus,
498
+ [data-theme="dark"] #wer-leaderboard-table th:focus,
499
+ [data-theme="dark"] #cer-leaderboard-table th:focus,
500
+ [data-theme="dark"] #rtfx-leaderboard-table th:focus,
501
+ [data-theme="dark"] #wer-leaderboard-table th.sorted,
502
+ [data-theme="dark"] #cer-leaderboard-table th.sorted,
503
+ [data-theme="dark"] #rtfx-leaderboard-table th.sorted {
504
+ --ring-color: transparent !important;
505
+ box-shadow: none !important;
506
+ outline: none !important;
507
+ background-color: transparent !important;
508
+ }
509
+
510
+ /* Remove selection highlight from internal header elements */
511
+ #wer-leaderboard-table th .cell-wrap,
512
+ #cer-leaderboard-table th .cell-wrap,
513
+ #rtfx-leaderboard-table th .cell-wrap,
514
+ #wer-leaderboard-table th .header-button,
515
+ #cer-leaderboard-table th .header-button,
516
+ #rtfx-leaderboard-table th .header-button {
517
+ background-color: transparent !important;
518
+ box-shadow: none !important;
519
+ outline: none !important;
520
+ }
521
+
522
+ body.dark #wer-leaderboard-table th .cell-wrap,
523
+ body.dark #cer-leaderboard-table th .cell-wrap,
524
+ body.dark #rtfx-leaderboard-table th .cell-wrap,
525
+ body.dark #wer-leaderboard-table th .header-button,
526
+ body.dark #cer-leaderboard-table th .header-button,
527
+ body.dark #rtfx-leaderboard-table th .header-button,
528
+ [data-theme="dark"] #wer-leaderboard-table th .cell-wrap,
529
+ [data-theme="dark"] #cer-leaderboard-table th .cell-wrap,
530
+ [data-theme="dark"] #rtfx-leaderboard-table th .cell-wrap,
531
+ [data-theme="dark"] #wer-leaderboard-table th .header-button,
532
+ [data-theme="dark"] #cer-leaderboard-table th .header-button,
533
+ [data-theme="dark"] #rtfx-leaderboard-table th .header-button {
534
+ background-color: transparent !important;
535
+ box-shadow: none !important;
536
+ outline: none !important;
537
+ }
538
+
539
+ /* Language column styling (first column after index) */
540
+ #wer-leaderboard-table td:first-child,
541
+ #cer-leaderboard-table td:first-child,
542
+ #rtfx-leaderboard-table td:first-child {
543
+ font-weight: 600;
544
+ position: sticky;
545
+ left: 0;
546
+ background-color: var(--metric-table-row-header-bg);
547
+ z-index: 1;
548
+ }
549
+
550
+ /* Make # and Language columns look clickable for row-level sorting */
551
+ #wer-leaderboard-table td:nth-child(1),
552
+ #wer-leaderboard-table td:nth-child(2),
553
+ #cer-leaderboard-table td:nth-child(1),
554
+ #cer-leaderboard-table td:nth-child(2),
555
+ #rtfx-leaderboard-table td:nth-child(1),
556
+ #rtfx-leaderboard-table td:nth-child(2) {
557
+ cursor: pointer;
558
+ transition: background-color 0.2s ease, color 0.2s ease;
559
+ position: relative;
560
+ }
561
+
562
+ /* Row sort button styling */
563
+ .row-sort-btn {
564
+ display: inline-block;
565
+ margin-left: 4px;
566
+ font-size: 10px;
567
+ opacity: 0.4;
568
+ cursor: pointer;
569
+ vertical-align: middle;
570
+ }
571
+
572
+ #wer-leaderboard-table td:nth-child(2):hover .row-sort-btn,
573
+ #cer-leaderboard-table td:nth-child(2):hover .row-sort-btn,
574
+ #rtfx-leaderboard-table td:nth-child(2):hover .row-sort-btn {
575
+ opacity: 0.8;
576
+ }
577
+
578
+ #wer-leaderboard-table td:nth-child(1):hover,
579
+ #wer-leaderboard-table td:nth-child(2):hover,
580
+ #cer-leaderboard-table td:nth-child(1):hover,
581
+ #cer-leaderboard-table td:nth-child(2):hover,
582
+ #rtfx-leaderboard-table td:nth-child(1):hover,
583
+ #rtfx-leaderboard-table td:nth-child(2):hover {
584
+ background-color: rgba(140, 208, 255, 0.2) !important;
585
+ text-decoration: underline;
586
+ color: #0066cc !important;
587
+ }
588
+
589
+ #search-bar-table-box > div:first-child {
590
+ background: none;
591
+ border: none;
592
+ }
593
+
594
+ #search-bar {
595
+ padding: 0px;
596
+ }
597
+
598
+ /* Limit the width of the first AutoEvalColumn so that names don't expand too much */
599
+ #leaderboard-table td:nth-child(2),
600
+ #leaderboard-table th:nth-child(2) {
601
+ max-width: 400px;
602
+ overflow: auto;
603
+ white-space: nowrap;
604
+ }
605
+
606
+ .tab-buttons button {
607
+ font-size: 20px;
608
+ }
609
+
610
+ /* PazaBench brand color for active tabs */
611
+ .tab-buttons button.selected,
612
+ .tabs button.selected,
613
+ button.tab-nav.selected,
614
+ [role="tab"][aria-selected="true"],
615
+ .tab-nav.selected {
616
+ background-color: #DDF1FF !important;
617
+ border-color: #8CD0FF !important;
618
+ color: #0f172a !important;
619
+ }
620
+
621
+ /* Subtle hover state for tabs - light mode */
622
+ .tab-buttons button:hover,
623
+ .tabs button:hover,
624
+ button.tab-nav:hover,
625
+ [role="tab"]:hover {
626
+ background-color: #DDF1FF !important;
627
+ }
628
+
629
+ /* ===== DARK MODE TAB HOVER FIX ===== */
630
+ /* Make tab hover transparent in dark mode to prevent text obscuring */
631
+ body.dark .tab-buttons button:hover,
632
+ body.dark .tabs button:hover,
633
+ body.dark button.tab-nav:hover,
634
+ body.dark [role="tab"]:hover,
635
+ [data-theme="dark"] .tab-buttons button:hover,
636
+ [data-theme="dark"] .tabs button:hover,
637
+ [data-theme="dark"] button.tab-nav:hover,
638
+ [data-theme="dark"] [role="tab"]:hover,
639
+ .dark .tab-buttons button:hover,
640
+ .dark .tabs button:hover,
641
+ .dark button.tab-nav:hover,
642
+ .dark [role="tab"]:hover {
643
+ background-color: transparent !important;
644
+ text-decoration: underline;
645
+ }
646
+
647
+ /* Dark mode active tab - ensure text is visible */
648
+ body.dark .tab-buttons button.selected,
649
+ body.dark .tabs button.selected,
650
+ body.dark button.tab-nav.selected,
651
+ body.dark [role="tab"][aria-selected="true"],
652
+ [data-theme="dark"] .tab-buttons button.selected,
653
+ [data-theme="dark"] .tabs button.selected,
654
+ [data-theme="dark"] button.tab-nav.selected,
655
+ [data-theme="dark"] [role="tab"][aria-selected="true"],
656
+ .dark .tab-buttons button.selected,
657
+ .dark .tabs button.selected,
658
+ .dark button.tab-nav.selected,
659
+ .dark [role="tab"][aria-selected="true"] {
660
+ background-color: rgba(140, 208, 255, 0.2) !important;
661
+ border-color: #8CD0FF !important;
662
+ color: #e5e7eb !important;
663
+ }
664
+
665
+ /* Prevent text selection on tabs */
666
+ .tab-buttons button,
667
+ .tabs button,
668
+ button.tab-nav,
669
+ [role="tab"] {
670
+ -webkit-user-select: none !important;
671
+ -moz-user-select: none !important;
672
+ -ms-user-select: none !important;
673
+ user-select: none !important;
674
+ }
675
+
676
+ /* Override Gradio's default orange accent colors with PazaBench blue */
677
+ /* Tab underline/border colors */
678
+ .tabs .tab-nav::after,
679
+ .tab-buttons button::after,
680
+ button.tab-nav::after,
681
+ [role="tab"]::after {
682
+ background-color: #8CD0FF !important;
683
+ }
684
+
685
+ .tabs .tab-nav.selected::after,
686
+ .tab-buttons button.selected::after,
687
+ button.tab-nav.selected::after,
688
+ [role="tab"][aria-selected="true"]::after {
689
+ background-color: #8CD0FF !important;
690
+ }
691
+
692
+ /* Gradio dataframe selected cell borders */
693
+ .table-wrap td.focus,
694
+ .table-wrap th.focus,
695
+ table td:focus,
696
+ table th:focus,
697
+ .dataframe td.selected,
698
+ .dataframe th.selected,
699
+ .svelte-table td.focus,
700
+ .svelte-table th.focus,
701
+ [data-testid="dataframe"] td.selected,
702
+ [data-testid="dataframe"] th.selected {
703
+ outline-color: #8CD0FF !important;
704
+ border-color: #8CD0FF !important;
705
+ box-shadow: 0 0 0 2px #8CD0FF !important;
706
+ }
707
+
708
+ /* Override orange focus rings and borders globally */
709
+ *:focus {
710
+ outline-color: #8CD0FF !important;
711
+ }
712
+
713
+ input:focus,
714
+ textarea:focus,
715
+ select:focus,
716
+ button:focus {
717
+ border-color: #8CD0FF !important;
718
+ box-shadow: 0 0 0 2px rgba(140, 208, 255, 0.3) !important;
719
+ }
720
+
721
+ /* Gradio specific accent color overrides */
722
+ .gradio-container {
723
+ --color-accent: #8CD0FF !important;
724
+ --color-accent-soft: #DDF1FF !important;
725
+ /* Radio and Checkbox - only style the indicator dot, not the label background */
726
+ --checkbox-label-background-fill-selected: transparent !important;
727
+ --checkbox-background-color-selected: #8CD0FF !important;
728
+ --checkbox-border-color-selected: #8CD0FF !important;
729
+ --slider-color: #8CD0FF !important;
730
+ /* Primary button colors */
731
+ --primary-50: #DDF1FF !important;
732
+ --primary-100: #c5e7ff !important;
733
+ --primary-200: #aeddff !important;
734
+ --primary-300: #8CD0FF !important;
735
+ --primary-400: #6bc0f5 !important;
736
+ --primary-500: #4ab0eb !important;
737
+ --primary-600: #29a0e1 !important;
738
+ --primary-700: #0890d7 !important;
739
+ }
740
+
741
+ /* Dark mode accent colors */
742
+ body.dark .gradio-container,
743
+ [data-theme="dark"] .gradio-container {
744
+ --color-accent: #8CD0FF !important;
745
+ --color-accent-soft: #1e3a5f !important;
746
+ /* Radio and Checkbox - only style the indicator dot, not the label background */
747
+ --checkbox-label-background-fill-selected: transparent !important;
748
+ --checkbox-background-color-selected: #8CD0FF !important;
749
+ --checkbox-border-color-selected: #8CD0FF !important;
750
+ --slider-color: #8CD0FF !important;
751
+ }
752
+
753
+ /* ===== LOADING/PROGRESS BAR FIX FOR DARK MODE ===== */
754
+ /* Make loading indicator more subtle/transparent */
755
+ .progress-bar,
756
+ .eta-bar,
757
+ [class*="progress"],
758
+ [class*="loader"],
759
+ [class*="loading"],
760
+ .wrap.svelte-j1gjts,
761
+ .wrap.svelte-j1gjts .progress-bar {
762
+ background-color: transparent !important;
763
+ }
764
+
765
+ /* Gradio's internal progress/loading indicators */
766
+ body.dark .progress-bar,
767
+ body.dark .eta-bar,
768
+ body.dark [class*="progress"],
769
+ [data-theme="dark"] .progress-bar,
770
+ [data-theme="dark"] .eta-bar,
771
+ [data-theme="dark"] [class*="progress"],
772
+ .dark .progress-bar,
773
+ .dark .eta-bar,
774
+ .dark [class*="progress"] {
775
+ background-color: transparent !important;
776
+ }
777
+
778
+ /* ===== ACCORDION HOVER FIX FOR DARK MODE ===== */
779
+ /* Fix accordion text highlight/selection obscuring text in dark mode */
780
+ /* Target accordion header button hover states - using actual Gradio class names */
781
+ .label-wrap:hover,
782
+ button.label-wrap:hover {
783
+ background-color: transparent !important;
784
+ -webkit-user-select: none !important;
785
+ -moz-user-select: none !important;
786
+ -ms-user-select: none !important;
787
+ user-select: none !important;
788
+ }
789
+
790
+ /* Dark mode specific accordion hover fix */
791
+ body.dark .label-wrap:hover,
792
+ body.dark button.label-wrap:hover,
793
+ [data-theme="dark"] .label-wrap:hover,
794
+ [data-theme="dark"] button.label-wrap:hover,
795
+ .dark .label-wrap:hover,
796
+ .dark button.label-wrap:hover {
797
+ background-color: transparent !important;
798
+ -webkit-user-select: none !important;
799
+ user-select: none !important;
800
+ }
801
+
802
+ /* Prevent text selection highlight on accordion labels */
803
+ .label-wrap::selection,
804
+ .label-wrap *::selection,
805
+ button.label-wrap::selection,
806
+ button.label-wrap *::selection {
807
+ background-color: transparent !important;
808
+ }
809
+
810
+ body.dark .label-wrap::selection,
811
+ body.dark .label-wrap *::selection,
812
+ [data-theme="dark"] .label-wrap::selection,
813
+ [data-theme="dark"] .label-wrap *::selection,
814
+ .dark .label-wrap::selection,
815
+ .dark .label-wrap *::selection {
816
+ background-color: transparent !important;
817
+ }
818
+
819
+ /* ===== DATAFRAME/TABLE HEADER HOVER FIX FOR DARK MODE ===== */
820
+ /* Fix table header hover states obscuring text */
821
+ body.dark table th:hover,
822
+ body.dark [data-testid="dataframe"] th:hover,
823
+ body.dark .table-wrap th:hover,
824
+ body.dark .svelte-table th:hover,
825
+ [data-theme="dark"] table th:hover,
826
+ [data-theme="dark"] [data-testid="dataframe"] th:hover,
827
+ [data-theme="dark"] .table-wrap th:hover,
828
+ [data-theme="dark"] .svelte-table th:hover,
829
+ .dark table th:hover,
830
+ .dark [data-testid="dataframe"] th:hover,
831
+ .dark .table-wrap th:hover,
832
+ .dark .svelte-table th:hover {
833
+ background-color: transparent !important;
834
+ -webkit-user-select: none !important;
835
+ user-select: none !important;
836
+ }
837
+
838
+ /* More comprehensive table header fix - catch Gradio's generated classes */
839
+ body.dark th,
840
+ body.dark thead th,
841
+ body.dark .header,
842
+ body.dark [class*="header"],
843
+ body.dark th[role="columnheader"],
844
+ [data-theme="dark"] th,
845
+ [data-theme="dark"] thead th,
846
+ [data-theme="dark"] .header,
847
+ [data-theme="dark"] [class*="header"],
848
+ [data-theme="dark"] th[role="columnheader"],
849
+ .dark th,
850
+ .dark thead th,
851
+ .dark .header,
852
+ .dark [class*="header"],
853
+ .dark th[role="columnheader"] {
854
+ -webkit-user-select: none !important;
855
+ -moz-user-select: none !important;
856
+ user-select: none !important;
857
+ }
858
+
859
+ body.dark th:hover,
860
+ body.dark thead th:hover,
861
+ body.dark th[role="columnheader"]:hover,
862
+ [data-theme="dark"] th:hover,
863
+ [data-theme="dark"] thead th:hover,
864
+ [data-theme="dark"] th[role="columnheader"]:hover,
865
+ .dark th:hover,
866
+ .dark thead th:hover,
867
+ .dark th[role="columnheader"]:hover {
868
+ background-color: transparent !important;
869
+ text-decoration: underline;
870
+ }
871
+
872
+ /* Prevent text selection on table headers */
873
+ th::selection,
874
+ th *::selection,
875
+ thead::selection,
876
+ thead *::selection {
877
+ background-color: transparent !important;
878
+ }
879
+
880
+ /* ===== GRADIO DATAFRAME HEADER SELECTION/FOCUS FIX ===== */
881
+ /* Remove the blue highlight ring from selected/focused column headers in dark mode */
882
+ /* Gradio uses .focus class and --ring-color variable for header selection state */
883
+ body.dark th.focus,
884
+ body.dark thead th.focus,
885
+ body.dark .table-wrap th.focus,
886
+ body.dark [data-testid="dataframe"] th.focus,
887
+ [data-theme="dark"] th.focus,
888
+ [data-theme="dark"] thead th.focus,
889
+ [data-theme="dark"] .table-wrap th.focus,
890
+ [data-theme="dark"] [data-testid="dataframe"] th.focus,
891
+ .dark th.focus,
892
+ .dark thead th.focus,
893
+ .dark .table-wrap th.focus,
894
+ .dark [data-testid="dataframe"] th.focus {
895
+ --ring-color: transparent !important;
896
+ box-shadow: none !important;
897
+ outline: none !important;
898
+ }
899
+
900
+ /* Also handle selected header state */
901
+ body.dark th.selected,
902
+ body.dark thead th.selected,
903
+ body.dark th[aria-selected="true"],
904
+ [data-theme="dark"] th.selected,
905
+ [data-theme="dark"] thead th.selected,
906
+ [data-theme="dark"] th[aria-selected="true"],
907
+ .dark th.selected,
908
+ .dark thead th.selected,
909
+ .dark th[aria-selected="true"] {
910
+ --ring-color: transparent !important;
911
+ box-shadow: none !important;
912
+ outline: none !important;
913
+ background-color: transparent !important;
914
+ }
915
+
916
+ /* Header focus state */
917
+ th.focus,
918
+ thead th.focus,
919
+ .table-wrap th.focus,
920
+ [data-testid="dataframe"] th.focus {
921
+ --ring-color: transparent !important;
922
+ box-shadow: none !important;
923
+ outline: none !important;
924
+ }
925
+
926
+ th.selected,
927
+ thead th.selected,
928
+ th[aria-selected="true"] {
929
+ --ring-color: transparent !important;
930
+ box-shadow: none !important;
931
+ outline: none !important;
932
+ }
933
+
934
+ /* ===== KEEP SORT ARROWS VISIBLE ===== */
935
+ /* Ensure sort arrows (SortArrowUp/SortArrowDown) remain visible */
936
+ /* Sort indicators container in Gradio dataframe header */
937
+ .sort-indicators,
938
+ .table-wrap .sort-indicators,
939
+ [data-testid="dataframe"] .sort-indicators,
940
+ th .sort-indicators {
941
+ display: flex !important;
942
+ visibility: visible !important;
943
+ opacity: 1 !important;
944
+ }
945
+
946
+ /* Sort arrow icons */
947
+ .sort-arrow,
948
+ .sort-indicators .sort-arrow,
949
+ th .sort-arrow {
950
+ display: flex !important;
951
+ visibility: visible !important;
952
+ opacity: 1 !important;
953
+ color: var(--body-text-color) !important;
954
+ }
955
+
956
+ /* Sort priority badge (shows order when multi-column sorting) */
957
+ .sort-priority,
958
+ .sort-indicators .sort-priority,
959
+ th .sort-priority {
960
+ display: flex !important;
961
+ visibility: visible !important;
962
+ }
963
+
964
+ /* Ensure sorted column headers show their state via arrows only, not highlight */
965
+ th.sorted,
966
+ th[aria-sort="ascending"],
967
+ th[aria-sort="descending"] {
968
+ background-color: transparent !important;
969
+ }
970
+
971
+ body.dark th.sorted,
972
+ body.dark th[aria-sort="ascending"],
973
+ body.dark th[aria-sort="descending"],
974
+ [data-theme="dark"] th.sorted,
975
+ [data-theme="dark"] th[aria-sort="ascending"],
976
+ [data-theme="dark"] th[aria-sort="descending"],
977
+ .dark th.sorted,
978
+ .dark th[aria-sort="ascending"],
979
+ .dark th[aria-sort="descending"] {
980
+ background-color: transparent !important;
981
+ }
982
+
983
+ /* ===== GRADIO DATAFRAME INTERNAL HEADER BUTTON FIX ===== */
984
+ /* Gradio's TableHeader uses .cell-wrap with a button.header-button inside */
985
+ /* Remove any selection/focus styling from these internal elements */
986
+ th .cell-wrap,
987
+ thead th .cell-wrap,
988
+ .table-wrap th .cell-wrap,
989
+ [data-testid="dataframe"] th .cell-wrap {
990
+ background-color: transparent !important;
991
+ box-shadow: none !important;
992
+ outline: none !important;
993
+ }
994
+
995
+ th .header-button,
996
+ thead th .header-button,
997
+ th .cell-wrap .header-button,
998
+ th button.header-button {
999
+ background-color: transparent !important;
1000
+ box-shadow: none !important;
1001
+ outline: none !important;
1002
+ }
1003
+
1004
+ /* Dark mode specific for internal header elements */
1005
+ body.dark th .cell-wrap,
1006
+ body.dark th .header-button,
1007
+ body.dark th .cell-wrap .header-button,
1008
+ [data-theme="dark"] th .cell-wrap,
1009
+ [data-theme="dark"] th .header-button,
1010
+ [data-theme="dark"] th .cell-wrap .header-button,
1011
+ .dark th .cell-wrap,
1012
+ .dark th .header-button,
1013
+ .dark th .cell-wrap .header-button {
1014
+ background-color: transparent !important;
1015
+ box-shadow: none !important;
1016
+ outline: none !important;
1017
+ }
1018
+
1019
+ /* Remove highlight on header button focus/active states */
1020
+ th .header-button:focus,
1021
+ th .header-button:active,
1022
+ th .header-button:hover,
1023
+ th .cell-wrap:focus,
1024
+ th .cell-wrap:active {
1025
+ background-color: transparent !important;
1026
+ box-shadow: none !important;
1027
+ outline: none !important;
1028
+ }
1029
+
1030
+ body.dark th .header-button:focus,
1031
+ body.dark th .header-button:active,
1032
+ body.dark th .header-button:hover,
1033
+ body.dark th .cell-wrap:focus,
1034
+ body.dark th .cell-wrap:active,
1035
+ [data-theme="dark"] th .header-button:focus,
1036
+ [data-theme="dark"] th .header-button:active,
1037
+ [data-theme="dark"] th .header-button:hover,
1038
+ [data-theme="dark"] th .cell-wrap:focus,
1039
+ [data-theme="dark"] th .cell-wrap:active,
1040
+ .dark th .header-button:focus,
1041
+ .dark th .header-button:active,
1042
+ .dark th .header-button:hover,
1043
+ .dark th .cell-wrap:focus,
1044
+ .dark th .cell-wrap:active {
1045
+ background-color: transparent !important;
1046
+ box-shadow: none !important;
1047
+ outline: none !important;
1048
+ }
1049
+
1050
+ /* Fix table cell selection/focus in dark mode - make background subtle */
1051
+ body.dark table th:focus,
1052
+ body.dark table td:focus,
1053
+ body.dark [data-testid="dataframe"] th:focus,
1054
+ body.dark [data-testid="dataframe"] td:focus,
1055
+ [data-theme="dark"] table th:focus,
1056
+ [data-theme="dark"] table td:focus,
1057
+ [data-theme="dark"] [data-testid="dataframe"] th:focus,
1058
+ [data-theme="dark"] [data-testid="dataframe"] td:focus,
1059
+ .dark table th:focus,
1060
+ .dark table td:focus,
1061
+ .dark [data-testid="dataframe"] th:focus,
1062
+ .dark [data-testid="dataframe"] td:focus {
1063
+ background-color: rgba(140, 208, 255, 0.15) !important;
1064
+ }
1065
+
1066
+ /* Prevent text selection on table headers */
1067
+ table th::selection,
1068
+ table th *::selection,
1069
+ [data-testid="dataframe"] th::selection,
1070
+ [data-testid="dataframe"] th *::selection {
1071
+ background-color: transparent !important;
1072
+ }
1073
+
1074
+ /* ===== GLOBAL DARK MODE TEXT SELECTION FIX ===== */
1075
+ /* Fix text selection color in dark mode globally for better contrast */
1076
+ body.dark ::selection,
1077
+ [data-theme="dark"] ::selection,
1078
+ .dark ::selection {
1079
+ background-color: rgba(140, 208, 255, 0.4) !important;
1080
+ color: inherit !important;
1081
+ }
1082
+
1083
+ /* Override Radio button selected indicator (the inner dot) - Gradio 5 specific */
1084
+ input[type="radio"]:checked {
1085
+ accent-color: #8CD0FF !important;
1086
+ }
1087
+
1088
+ /* Radio group - remove background fill, keep only dot styled */
1089
+ .gradio-radio label.selected,
1090
+ [data-testid*="radio-label"].selected {
1091
+ background-color: transparent !important;
1092
+ border-color: transparent !important;
1093
+ }
1094
+
1095
+ /* Checkbox checked styling */
1096
+ input[type="checkbox"]:checked {
1097
+ accent-color: #8CD0FF !important;
1098
+ }
1099
+
1100
+ #scale-logo {
1101
+ border-style: none !important;
1102
+ box-shadow: none;
1103
+ display: block;
1104
+ margin-left: auto;
1105
+ margin-right: auto;
1106
+ max-width: 600px;
1107
+ }
1108
+
1109
+ #scale-logo .download {
1110
+ display: none;
1111
+ }
1112
+ #filter_type{
1113
+ border: 0;
1114
+ padding-left: 0;
1115
+ padding-top: 0;
1116
+ }
1117
+ #filter_type label {
1118
+ display: flex;
1119
+ }
1120
+ #filter_type label > span{
1121
+ margin-top: var(--spacing-lg);
1122
+ margin-right: 0.5em;
1123
+ }
1124
+ #filter_type label > .wrap{
1125
+ width: 103px;
1126
+ }
1127
+ #filter_type label > .wrap .wrap-inner{
1128
+ padding: 2px;
1129
+ }
1130
+ #filter_type label > .wrap .wrap-inner input{
1131
+ width: 1px
1132
+ }
1133
+ #filter-columns-type{
1134
+ border:0;
1135
+ padding:0.5;
1136
+ }
1137
+ #filter-columns-size{
1138
+ border:0;
1139
+ padding:0.5;
1140
+ }
1141
+ #box-filter > .form{
1142
+ border: 0
1143
+ }
1144
+
1145
+ .metric-tabs button {
1146
+ font-size: 18px;
1147
+ }
1148
+
1149
+ .metric-table-wrapper {
1150
+ overflow-x: auto;
1151
+ margin-top: 1rem;
1152
+ }
1153
+
1154
+ /* ===== MARKDOWN TABLE COLUMN WIDTHS ===== */
1155
+ /* Ensure Datasets and Evaluated Models tables have proper column widths */
1156
+ /* Target markdown-rendered tables in Benchmark Inputs tab */
1157
+ .markdown-text table th:nth-child(4),
1158
+ .markdown-text table td:nth-child(4),
1159
+ .prose table th:nth-child(4),
1160
+ .prose table td:nth-child(4) {
1161
+ min-width: 80px !important;
1162
+ white-space: nowrap;
1163
+ }
1164
+
1165
+ .markdown-text table th:nth-child(5),
1166
+ .markdown-text table td:nth-child(5),
1167
+ .prose table th:nth-child(5),
1168
+ .prose table td:nth-child(5) {
1169
+ min-width: 100px !important;
1170
+ white-space: nowrap;
1171
+ }
1172
+
1173
+ /* Chart Gallery Styles */
1174
+ .chart-gallery {
1175
+ display: grid;
1176
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
1177
+ gap: 1rem;
1178
+ margin: 1rem 0;
1179
+ }
1180
+
1181
+ /* Plotly Chart Responsiveness */
1182
+ .js-plotly-plot, .plotly {
1183
+ width: 100% !important;
1184
+ }
1185
+
1186
+ .js-plotly-plot .plotly .main-svg {
1187
+ width: 100% !important;
1188
+ }
1189
+
1190
+ /* Better mobile display for Plotly charts */
1191
+ @media (max-width: 768px) {
1192
+ .js-plotly-plot .plotly .legend {
1193
+ font-size: 9px !important;
1194
+ }
1195
+
1196
+ .js-plotly-plot .plotly .gtitle {
1197
+ font-size: 14px !important;
1198
+ }
1199
+
1200
+ .js-plotly-plot .plotly .xtitle,
1201
+ .js-plotly-plot .plotly .ytitle {
1202
+ font-size: 11px !important;
1203
+ }
1204
+
1205
+ /* Stack the map and info/chart column full-width on mobile. */
1206
+ #language-coverage-row {
1207
+ flex-direction: column !important;
1208
+ }
1209
+
1210
+ #language-coverage-row > * {
1211
+ width: 100% !important;
1212
+ min-width: 0 !important;
1213
+ }
1214
+
1215
+ /* Wide content tables scroll horizontally on mobile. */
1216
+ .markdown-text table {
1217
+ display: block;
1218
+ overflow-x: auto;
1219
+ -webkit-overflow-scrolling: touch;
1220
+ }
1221
+ .markdown-text table th,
1222
+ .markdown-text table td {
1223
+ white-space: nowrap;
1224
+ }
1225
+
1226
+ /* Visualisation charts keep a usable min-width and scroll horizontally on mobile. */
1227
+ .viz-scroll-plot {
1228
+ overflow-x: auto !important;
1229
+ -webkit-overflow-scrolling: touch;
1230
+ }
1231
+ .viz-scroll-plot .js-plotly-plot {
1232
+ min-width: 700px !important;
1233
+ }
1234
+
1235
+ /* Leaderboard metric tables keep their min-width but scroll inside their wrapper. */
1236
+ .metric-table-wrapper,
1237
+ .metric-table-container,
1238
+ .table-wrap {
1239
+ overflow-x: auto !important;
1240
+ -webkit-overflow-scrolling: touch;
1241
+ }
1242
+ }
1243
+
1244
+ .chart-card {
1245
+ background: var(--metric-table-bg, #ffffff);
1246
+ border: 1px solid var(--metric-table-border, #d3d3d3);
1247
+ border-radius: 8px;
1248
+ padding: 1rem;
1249
+ transition: box-shadow 0.2s ease, transform 0.2s ease;
1250
+ }
1251
+
1252
+ .chart-card:hover {
1253
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1254
+ transform: translateY(-2px);
1255
+ }
1256
+
1257
+ .chart-card h4 {
1258
+ margin: 0 0 0.5rem 0;
1259
+ color: var(--metric-table-header-color, #0f172a);
1260
+ font-size: 1.1rem;
1261
+ }
1262
+
1263
+ .chart-card p {
1264
+ margin: 0;
1265
+ color: var(--metric-table-caption, #666666);
1266
+ font-size: 0.9rem;
1267
+ line-height: 1.4;
1268
+ }
1269
+
1270
+ .metric-table {
1271
+ border-collapse: collapse;
1272
+ width: 100%;
1273
+ min-width: 600px;
1274
+ text-align: center;
1275
+ font-size: 16px;
1276
+ background-color: var(--metric-table-bg);
1277
+ }
1278
+
1279
+ .metric-table caption {
1280
+ caption-side: bottom;
1281
+ padding-top: 0.5rem;
1282
+ color: var(--metric-table-caption);
1283
+ font-size: 0.9rem;
1284
+ }
1285
+
1286
+ .metric-table th,
1287
+ .metric-table td {
1288
+ border: 1px solid var(--metric-table-border);
1289
+ padding: 0.5rem;
1290
+ }
1291
+
1292
+ .metric-table thead th {
1293
+ background-color: var(--metric-table-header-bg);
1294
+ color: var(--metric-table-header-color);
1295
+ position: sticky;
1296
+ top: 0;
1297
+ z-index: 1;
1298
+ }
1299
+
1300
+ .metric-table tbody th {
1301
+ text-align: left;
1302
+ background-color: var(--metric-table-row-header-bg);
1303
+ color: var(--metric-table-header-color);
1304
+ position: sticky;
1305
+ left: 0;
1306
+ z-index: 2;
1307
+ cursor: pointer;
1308
+ }
1309
+
1310
+ .metric-table tbody th:hover {
1311
+ text-decoration: underline;
1312
+ }
1313
+
1314
+ .metric-table tbody tr.row-highlighted {
1315
+ background-color: var(--metric-table-row-highlight) !important;
1316
+ }
1317
+
1318
+ .metric-table tbody tr.row-highlighted td,
1319
+ .metric-table tbody tr.row-highlighted th {
1320
+ background-color: var(--metric-table-row-highlight) !important;
1321
+ }
1322
+
1323
+ /* Leaderboard Gradio dataframe row highlighting */
1324
+ #wer-leaderboard-table tbody tr.leaderboard-row-highlighted,
1325
+ #cer-leaderboard-table tbody tr.leaderboard-row-highlighted,
1326
+ #rtfx-leaderboard-table tbody tr.leaderboard-row-highlighted {
1327
+ background-color: var(--metric-table-row-highlight, rgba(140, 208, 255, 0.3)) !important;
1328
+ }
1329
+
1330
+ #wer-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1331
+ #cer-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1332
+ #rtfx-leaderboard-table tbody tr.leaderboard-row-highlighted td {
1333
+ background-color: var(--metric-table-row-highlight, rgba(140, 208, 255, 0.3)) !important;
1334
+ }
1335
+
1336
+ /* Dark mode leaderboard row highlighting */
1337
+ body.dark #wer-leaderboard-table tbody tr.leaderboard-row-highlighted,
1338
+ body.dark #cer-leaderboard-table tbody tr.leaderboard-row-highlighted,
1339
+ body.dark #rtfx-leaderboard-table tbody tr.leaderboard-row-highlighted,
1340
+ [data-theme="dark"] #wer-leaderboard-table tbody tr.leaderboard-row-highlighted,
1341
+ [data-theme="dark"] #cer-leaderboard-table tbody tr.leaderboard-row-highlighted,
1342
+ [data-theme="dark"] #rtfx-leaderboard-table tbody tr.leaderboard-row-highlighted {
1343
+ background-color: rgba(140, 208, 255, 0.25) !important;
1344
+ }
1345
+
1346
+ body.dark #wer-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1347
+ body.dark #cer-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1348
+ body.dark #rtfx-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1349
+ [data-theme="dark"] #wer-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1350
+ [data-theme="dark"] #cer-leaderboard-table tbody tr.leaderboard-row-highlighted td,
1351
+ [data-theme="dark"] #rtfx-leaderboard-table tbody tr.leaderboard-row-highlighted td {
1352
+ background-color: rgba(140, 208, 255, 0.25) !important;
1353
+ }
1354
+
1355
+ /* Make all leaderboard cells clickable for row highlighting */
1356
+ #wer-leaderboard-table tbody td,
1357
+ #cer-leaderboard-table tbody td,
1358
+ #rtfx-leaderboard-table tbody td {
1359
+ cursor: pointer;
1360
+ }
1361
+
1362
+ .metric-table-empty {
1363
+ padding: 1rem;
1364
+ text-align: center;
1365
+ color: var(--metric-table-empty-color);
1366
+ }
1367
+
1368
+ /* Footer Styles */
1369
+ .paza-footer {
1370
+ margin-top: 2rem;
1371
+ padding: 1.5rem 1rem;
1372
+ border-top: 1px solid var(--metric-table-border, #d3d3d3);
1373
+ text-align: center;
1374
+ font-size: 0.9rem;
1375
+ color: var(--metric-table-caption, #666666);
1376
+ }
1377
+
1378
+ .paza-footer a {
1379
+ color: #0078d4;
1380
+ text-decoration: none;
1381
+ transition: color 0.2s ease;
1382
+ }
1383
+
1384
+ .paza-footer a:hover {
1385
+ color: #106ebe;
1386
+ text-decoration: underline;
1387
+ }
1388
+
1389
+ /* Dark mode footer link colors */
1390
+ body.dark .paza-footer a,
1391
+ [data-theme="dark"] .paza-footer a,
1392
+ body[class*="dark"] .paza-footer a,
1393
+ [data-color-mode="dark"] .paza-footer a {
1394
+ color: #60a5fa;
1395
+ }
1396
+
1397
+ body.dark .paza-footer a:hover,
1398
+ [data-theme="dark"] .paza-footer a:hover,
1399
+ body[class*="dark"] .paza-footer a:hover,
1400
+ [data-color-mode="dark"] .paza-footer a:hover {
1401
+ color: #93c5fd;
1402
+ }
1403
+
1404
+ .paza-footer-links {
1405
+ display: flex;
1406
+ flex-wrap: wrap;
1407
+ justify-content: center;
1408
+ align-items: center;
1409
+ gap: 1rem 1.5rem;
1410
+ }
1411
+
1412
+ .paza-footer-links .copyright {
1413
+ font-size: 0.85rem;
1414
+ }
1415
+ """
1416
+
1417
+ get_window_url_params = """
1418
+ function(url_params) {
1419
+ const params = new URLSearchParams(window.location.search);
1420
+ url_params = Object.fromEntries(params);
1421
+ return url_params;
1422
+ }
1423
+ """
1424
+
1425
+ # WCAG 4.1.2 Fix: JavaScript to remove focusability from aria-hidden elements.
1426
+ # Also includes dark mode flash prevention script.
1427
+ fix_aria_hidden_focus_js = """
1428
+ <script>
1429
+ // Dark mode flash prevention - run immediately before page renders
1430
+ (function() {
1431
+ // Check for HuggingFace Spaces dark mode preference or system preference
1432
+ const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
1433
+ const isHFDark = document.documentElement.classList.contains('dark') ||
1434
+ document.body?.classList.contains('dark') ||
1435
+ document.documentElement.getAttribute('data-theme') === 'dark' ||
1436
+ document.documentElement.getAttribute('data-color-mode') === 'dark';
1437
+
1438
+ if (isDarkMode || isHFDark) {
1439
+ // Apply dark class immediately to prevent flash
1440
+ document.documentElement.classList.add('dark');
1441
+ document.documentElement.setAttribute('data-theme', 'dark');
1442
+ if (document.body) {
1443
+ document.body.classList.add('dark');
1444
+ } else {
1445
+ // If body not ready, wait for it
1446
+ document.addEventListener('DOMContentLoaded', function() {
1447
+ document.body.classList.add('dark');
1448
+ });
1449
+ }
1450
+ }
1451
+
1452
+ // Also listen for theme changes from HF Spaces
1453
+ const observer = new MutationObserver(function(mutations) {
1454
+ mutations.forEach(function(mutation) {
1455
+ if (mutation.attributeName === 'class' ||
1456
+ mutation.attributeName === 'data-theme' ||
1457
+ mutation.attributeName === 'data-color-mode') {
1458
+ const html = document.documentElement;
1459
+ const body = document.body;
1460
+ if (html.classList.contains('dark') ||
1461
+ html.getAttribute('data-theme') === 'dark' ||
1462
+ html.getAttribute('data-color-mode') === 'dark') {
1463
+ if (body && !body.classList.contains('dark')) {
1464
+ body.classList.add('dark');
1465
+ }
1466
+ }
1467
+ }
1468
+ });
1469
+ });
1470
+
1471
+ observer.observe(document.documentElement, {
1472
+ attributes: true,
1473
+ attributeFilter: ['class', 'data-theme', 'data-color-mode']
1474
+ });
1475
+ })();
1476
+ </script>
1477
+ <script>
1478
+ (function() {
1479
+ function fixAriaHiddenFocus() {
1480
+ // Find all aria-hidden containers and make their focusable children unfocusable
1481
+ const hiddenContainers = document.querySelectorAll('[aria-hidden="true"]');
1482
+ hiddenContainers.forEach(container => {
1483
+ const focusableElements = container.querySelectorAll(
1484
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1485
+ );
1486
+ focusableElements.forEach(el => {
1487
+ el.setAttribute('tabindex', '-1');
1488
+ el.setAttribute('aria-disabled', 'true');
1489
+ el.setAttribute('data-a11y-fixed', 'true');
1490
+ // Also disable the button to prevent any form of focus
1491
+ if (el.tagName === 'BUTTON') {
1492
+ el.disabled = true;
1493
+ }
1494
+ });
1495
+ });
1496
+ }
1497
+
1498
+ // Run immediately
1499
+ fixAriaHiddenFocus();
1500
+
1501
+ // Run on DOMContentLoaded
1502
+ document.addEventListener('DOMContentLoaded', fixAriaHiddenFocus);
1503
+
1504
+ // Run after a short delay to catch late-loading Gradio components
1505
+ setTimeout(fixAriaHiddenFocus, 100);
1506
+ setTimeout(fixAriaHiddenFocus, 500);
1507
+ setTimeout(fixAriaHiddenFocus, 1000);
1508
+ setTimeout(fixAriaHiddenFocus, 2000);
1509
+
1510
+ // Use MutationObserver to handle dynamically added content
1511
+ const observer = new MutationObserver((mutations) => {
1512
+ fixAriaHiddenFocus();
1513
+ });
1514
+
1515
+ // Start observing when body is available
1516
+ function startObserver() {
1517
+ if (document.body) {
1518
+ observer.observe(document.body, {
1519
+ childList: true,
1520
+ subtree: true,
1521
+ attributes: true,
1522
+ attributeFilter: ['aria-hidden']
1523
+ });
1524
+ fixAriaHiddenFocus();
1525
+ } else {
1526
+ setTimeout(startObserver, 10);
1527
+ }
1528
+ }
1529
+ startObserver();
1530
+ })();
1531
+
1532
+ // Row highlighting for metric tables when clicking on language
1533
+ (function() {
1534
+ function setupRowHighlighting() {
1535
+ const tables = document.querySelectorAll('.metric-table');
1536
+ tables.forEach(table => {
1537
+ if (table.dataset.highlightSetup) return;
1538
+ table.dataset.highlightSetup = 'true';
1539
+
1540
+ const rows = table.querySelectorAll('tbody tr');
1541
+ rows.forEach(row => {
1542
+ const languageCell = row.querySelector('th');
1543
+ if (languageCell) {
1544
+ languageCell.addEventListener('click', function() {
1545
+ // Toggle highlight on the clicked row
1546
+ const isHighlighted = row.classList.contains('row-highlighted');
1547
+ // Remove highlight from all rows in this table
1548
+ rows.forEach(r => r.classList.remove('row-highlighted'));
1549
+ // If row wasn't highlighted, highlight it now
1550
+ if (!isHighlighted) {
1551
+ row.classList.add('row-highlighted');
1552
+ }
1553
+ });
1554
+ }
1555
+ });
1556
+ });
1557
+ }
1558
+
1559
+ // Run on load and observe for dynamically added tables
1560
+ document.addEventListener('DOMContentLoaded', setupRowHighlighting);
1561
+ setTimeout(setupRowHighlighting, 100);
1562
+ setTimeout(setupRowHighlighting, 500);
1563
+ setTimeout(setupRowHighlighting, 1000);
1564
+ setTimeout(setupRowHighlighting, 2000);
1565
+
1566
+ const rowObserver = new MutationObserver((mutations) => {
1567
+ setupRowHighlighting();
1568
+ });
1569
+
1570
+ function startRowObserver() {
1571
+ if (document.body) {
1572
+ rowObserver.observe(document.body, {
1573
+ childList: true,
1574
+ subtree: true
1575
+ });
1576
+ setupRowHighlighting();
1577
+ } else {
1578
+ setTimeout(startRowObserver, 10);
1579
+ }
1580
+ }
1581
+ startRowObserver();
1582
+ })();
1583
+
1584
+ // Row highlighting and row-based sorting for Gradio leaderboard dataframes (WER, CER, RTFx tables)
1585
+ (function() {
1586
+ // Track sort state per table per row
1587
+ const rowSortState = {};
1588
+ // Track which containers have event listeners attached
1589
+ const setupContainers = new WeakSet();
1590
+
1591
+ function setupLeaderboardRowSorting() {
1592
+ const leaderboardIds = ['#wer-leaderboard-table', '#cer-leaderboard-table', '#rtfx-leaderboard-table'];
1593
+
1594
+ leaderboardIds.forEach(tableId => {
1595
+ const container = document.querySelector(tableId);
1596
+ if (!container) return;
1597
+
1598
+ // Initialize sort state for this table
1599
+ if (!rowSortState[tableId]) {
1600
+ rowSortState[tableId] = { rowIndex: null, ascending: true };
1601
+ }
1602
+
1603
+ // Skip if this specific container element already has event listener
1604
+ if (setupContainers.has(container)) return;
1605
+ setupContainers.add(container);
1606
+
1607
+ // Use event delegation on the container - Gradio uses divs, not tables
1608
+ container.addEventListener('click', function(e) {
1609
+ // Don't intercept clicks on sort buttons in header
1610
+ if (e.target.closest('button') || e.target.closest('.sort-button')) {
1611
+ return;
1612
+ }
1613
+
1614
+ // Ignore clicks in the aria-hidden sizing table
1615
+ const ariaHiddenTable = e.target.closest('table[aria-hidden="true"]');
1616
+ if (ariaHiddenTable) {
1617
+ return;
1618
+ }
1619
+
1620
+ // Find the clicked cell - Gradio uses div.cell-wrap or td
1621
+ let clickedCell = e.target.closest('td');
1622
+ if (!clickedCell) {
1623
+ // Maybe clicked on span.text inside cell-wrap
1624
+ const cellWrap = e.target.closest('.cell-wrap');
1625
+ if (cellWrap) {
1626
+ clickedCell = cellWrap.closest('td');
1627
+ }
1628
+ }
1629
+ if (!clickedCell) {
1630
+ return;
1631
+ }
1632
+
1633
+ const clickedRow = clickedCell.closest('tr');
1634
+ if (!clickedRow) {
1635
+ return;
1636
+ }
1637
+
1638
+ // Check if this is a body row (not header) - use the specific class
1639
+ const tbody = clickedRow.closest('tbody.tbody') || clickedRow.closest('tbody');
1640
+ if (!tbody) {
1641
+ return;
1642
+ }
1643
+
1644
+ // Make sure this tbody is inside a svelte-virtual-table-viewport (not the hidden sizing table)
1645
+ const virtualViewport = tbody.closest('svelte-virtual-table-viewport');
1646
+ if (!virtualViewport) {
1647
+ return;
1648
+ }
1649
+
1650
+ // Get all cells in the row
1651
+ const allCells = Array.from(clickedRow.querySelectorAll('td'));
1652
+ const cellIndex = allCells.indexOf(clickedCell);
1653
+
1654
+ // If clicking on # column (index 0) or Language column (index 1), sort the row
1655
+ if (cellIndex === 0 || cellIndex === 1) {
1656
+ e.preventDefault();
1657
+ e.stopPropagation();
1658
+
1659
+ // Find the virtual table (the one with actual data, not aria-hidden one)
1660
+ const virtualTableViewport = container.querySelector('svelte-virtual-table-viewport');
1661
+ const table = virtualTableViewport ?
1662
+ virtualTableViewport.querySelector('table.table') :
1663
+ container.querySelector('table:not([aria-hidden="true"])');
1664
+ const thead = table ? table.querySelector('thead.thead') || table.querySelector('thead') : null;
1665
+ const virtualTbody = table ? table.querySelector('tbody.tbody') || table.querySelector('tbody') : null;
1666
+
1667
+ if (table && thead && virtualTbody) {
1668
+ sortRowColumns(tableId, table, thead, virtualTbody, clickedRow);
1669
+ }
1670
+ return;
1671
+ }
1672
+
1673
+ // For other cells, just toggle highlight
1674
+ const allRows = tbody.querySelectorAll('tr');
1675
+ const isHighlighted = clickedRow.classList.contains('leaderboard-row-highlighted');
1676
+
1677
+ // Remove highlight from all rows
1678
+ allRows.forEach(row => row.classList.remove('leaderboard-row-highlighted'));
1679
+
1680
+ // If row wasn't highlighted, highlight it now
1681
+ if (!isHighlighted) {
1682
+ clickedRow.classList.add('leaderboard-row-highlighted');
1683
+ }
1684
+ }, true); // Use capture phase
1685
+
1686
+ // Add tooltips to # and Language columns to explain row sorting
1687
+ addRowSortTooltips(container);
1688
+ });
1689
+ }
1690
+
1691
+ function addRowSortTooltips(container) {
1692
+ // Find the virtual table tbody (not the aria-hidden one)
1693
+ const virtualTableViewport = container.querySelector('svelte-virtual-table-viewport');
1694
+ const table = virtualTableViewport ?
1695
+ virtualTableViewport.querySelector('table.table') :
1696
+ container.querySelector('table:not([aria-hidden="true"])');
1697
+ const tbody = table ? table.querySelector('tbody.tbody') || table.querySelector('tbody') : null;
1698
+ if (!tbody) return;
1699
+
1700
+ const rows = tbody.querySelectorAll('tr');
1701
+ rows.forEach(row => {
1702
+ const cells = row.querySelectorAll('td');
1703
+ if (cells.length >= 2) {
1704
+ // # column (index 0)
1705
+ if (cells[0] && !cells[0].dataset.tooltipSet) {
1706
+ cells[0].title = 'Click to sort columns by this row\'s values';
1707
+ cells[0].dataset.tooltipSet = 'true';
1708
+ }
1709
+ // Language column (index 1)
1710
+ if (cells[1] && !cells[1].dataset.tooltipSet) {
1711
+ const langName = cells[1].textContent.trim();
1712
+ cells[1].title = `Click to sort model columns by ${langName}'s scores`;
1713
+ cells[1].dataset.tooltipSet = 'true';
1714
+ }
1715
+ }
1716
+ });
1717
+ }
1718
+
1719
+ function sortRowColumns(tableId, table, thead, tbody, clickedRow) {
1720
+ // Get all header cells
1721
+ const headerRow = thead.querySelector('tr');
1722
+ if (!headerRow) {
1723
+ return;
1724
+ }
1725
+
1726
+ const headerCells = Array.from(headerRow.querySelectorAll('th'));
1727
+ const rowCells = Array.from(clickedRow.querySelectorAll('td'));
1728
+
1729
+ // First 2 columns are # and Language - don't include in sort
1730
+ const fixedCols = 2;
1731
+
1732
+ // Extract model columns (starting from index 2)
1733
+ const modelHeaders = headerCells.slice(fixedCols);
1734
+ const modelCells = rowCells.slice(fixedCols);
1735
+
1736
+ if (modelHeaders.length === 0 || modelCells.length === 0) {
1737
+ return;
1738
+ }
1739
+
1740
+ // Get row index to track sort state
1741
+ const allBodyRows = Array.from(tbody.querySelectorAll('tr'));
1742
+ const rowIndex = allBodyRows.indexOf(clickedRow);
1743
+
1744
+ // Determine sort direction - toggle if same row clicked again
1745
+ let ascending;
1746
+ if (rowSortState[tableId].rowIndex === rowIndex) {
1747
+ ascending = !rowSortState[tableId].ascending;
1748
+ } else {
1749
+ // Determine default direction based on table type
1750
+ // WER/CER: lower is better, so ascending (best first)
1751
+ // RTFx: higher is better, so descending (best first)
1752
+ ascending = !tableId.includes('rtfx');
1753
+ }
1754
+ rowSortState[tableId] = { rowIndex, ascending };
1755
+
1756
+ // Create array of {header, cell, value} for sorting
1757
+ const columnsData = modelHeaders.map((header, i) => {
1758
+ const cell = modelCells[i];
1759
+ // Get text content - handle Gradio's nested structure
1760
+ let cellText = '';
1761
+ if (cell) {
1762
+ // Try different selectors for Gradio's structure
1763
+ const textEl = cell.querySelector('.cell-wrap .text') ||
1764
+ cell.querySelector('.cell-wrap') ||
1765
+ cell.querySelector('span') ||
1766
+ cell;
1767
+ cellText = textEl.textContent.trim();
1768
+ }
1769
+ // Parse value, treating '--' or empty as Infinity (worst)
1770
+ let value = parseFloat(cellText);
1771
+ if (isNaN(value) || cellText === '--' || cellText === '') {
1772
+ value = ascending ? Infinity : -Infinity;
1773
+ }
1774
+ return { header, cell, value, originalIndex: i };
1775
+ });
1776
+
1777
+ // Sort columns by value
1778
+ columnsData.sort((a, b) => {
1779
+ if (ascending) {
1780
+ return a.value - b.value;
1781
+ } else {
1782
+ return b.value - a.value;
1783
+ }
1784
+ });
1785
+
1786
+ // Store new column order indices
1787
+ const newOrder = columnsData.map(col => col.originalIndex);
1788
+
1789
+ // Reorder header cells (after fixed columns)
1790
+ const newHeaderOrder = [];
1791
+ for (let i = 0; i < fixedCols; i++) {
1792
+ newHeaderOrder.push(headerCells[i]);
1793
+ }
1794
+ newOrder.forEach(idx => {
1795
+ newHeaderOrder.push(headerCells[fixedCols + idx]);
1796
+ });
1797
+
1798
+ // Clear and rebuild header row
1799
+ while (headerRow.firstChild) {
1800
+ headerRow.removeChild(headerRow.firstChild);
1801
+ }
1802
+ newHeaderOrder.forEach(cell => {
1803
+ if (cell) headerRow.appendChild(cell);
1804
+ });
1805
+
1806
+ // Reorder cells in all body rows
1807
+ allBodyRows.forEach((row, rIdx) => {
1808
+ const cells = Array.from(row.querySelectorAll('td'));
1809
+ const newCellOrder = [];
1810
+ for (let i = 0; i < fixedCols; i++) {
1811
+ newCellOrder.push(cells[i]);
1812
+ }
1813
+ newOrder.forEach(idx => {
1814
+ newCellOrder.push(cells[fixedCols + idx]);
1815
+ });
1816
+
1817
+ // Clear and rebuild row
1818
+ while (row.firstChild) {
1819
+ row.removeChild(row.firstChild);
1820
+ }
1821
+ newCellOrder.forEach(cell => {
1822
+ if (cell) row.appendChild(cell);
1823
+ });
1824
+ });
1825
+
1826
+ // Highlight the clicked row to show it was used for sorting
1827
+ allBodyRows.forEach(row => row.classList.remove('leaderboard-row-highlighted'));
1828
+ // Find the row at the same index after reordering
1829
+ const newRows = tbody.querySelectorAll('tr');
1830
+ if (newRows[rowIndex]) {
1831
+ newRows[rowIndex].classList.add('leaderboard-row-highlighted');
1832
+ }
1833
+ }
1834
+
1835
+ // Run on load and observe for dynamically added tables
1836
+ document.addEventListener('DOMContentLoaded', setupLeaderboardRowSorting);
1837
+ setTimeout(setupLeaderboardRowSorting, 500);
1838
+ setTimeout(setupLeaderboardRowSorting, 1000);
1839
+ setTimeout(setupLeaderboardRowSorting, 2000);
1840
+ setTimeout(setupLeaderboardRowSorting, 3000);
1841
+ setTimeout(setupLeaderboardRowSorting, 5000);
1842
+
1843
+ const leaderboardObserver = new MutationObserver((mutations) => {
1844
+ // Check if table content was updated - need to reset sort state
1845
+ for (const m of mutations) {
1846
+ if (m.type === 'childList') {
1847
+ // Check if this affects one of our leaderboard tables
1848
+ const targetContainer = m.target.closest('#wer-leaderboard-table, #cer-leaderboard-table, #rtfx-leaderboard-table');
1849
+ if (targetContainer) {
1850
+ // Reset sort state for this table when its content changes
1851
+ const tableId = '#' + targetContainer.id;
1852
+ if (rowSortState[tableId]) {
1853
+ rowSortState[tableId] = { rowIndex: null, ascending: true };
1854
+ }
1855
+ // Refresh tooltips for the updated content
1856
+ addRowSortTooltips(targetContainer);
1857
+ }
1858
+ }
1859
+ }
1860
+
1861
+ // Always try to set up - the WeakSet will prevent duplicate listeners
1862
+ setupLeaderboardRowSorting();
1863
+
1864
+ // Also add tooltips to any newly added tables
1865
+ const containers = document.querySelectorAll('#wer-leaderboard-table, #cer-leaderboard-table, #rtfx-leaderboard-table');
1866
+ containers.forEach(addRowSortTooltips);
1867
+ });
1868
+
1869
+ function startLeaderboardObserver() {
1870
+ if (document.body) {
1871
+ leaderboardObserver.observe(document.body, {
1872
+ childList: true,
1873
+ subtree: true
1874
+ });
1875
+ setupLeaderboardRowSorting();
1876
+ } else {
1877
+ setTimeout(startLeaderboardObserver, 10);
1878
+ }
1879
+ }
1880
+ startLeaderboardObserver();
1881
+ })();
1882
+
1883
+ // Column resizing for leaderboard dataframes (WER, CER, RTFx tables)
1884
+ (function() {
1885
+ function setupColumnResize() {
1886
+ const leaderboardIds = ['#wer-leaderboard-table', '#cer-leaderboard-table', '#rtfx-leaderboard-table'];
1887
+
1888
+ leaderboardIds.forEach(tableId => {
1889
+ const container = document.querySelector(tableId);
1890
+ if (!container) return;
1891
+
1892
+ const table = container.querySelector('table');
1893
+ if (!table || table.dataset.resizeSetup) return;
1894
+ table.dataset.resizeSetup = 'true';
1895
+
1896
+ const headers = table.querySelectorAll('thead th');
1897
+
1898
+ headers.forEach((th, index) => {
1899
+ // Skip if resize handle already exists
1900
+ if (th.querySelector('.col-resize-handle')) return;
1901
+
1902
+ // Add title attribute for tooltip showing full header text
1903
+ const headerText = th.textContent.trim();
1904
+ if (headerText && !th.getAttribute('title')) {
1905
+ th.setAttribute('title', headerText);
1906
+ }
1907
+
1908
+ // Create resize handle
1909
+ const resizeHandle = document.createElement('div');
1910
+ resizeHandle.className = 'col-resize-handle';
1911
+ th.appendChild(resizeHandle);
1912
+
1913
+ let startX, startWidth, isResizing = false;
1914
+
1915
+ resizeHandle.addEventListener('mousedown', function(e) {
1916
+ e.preventDefault();
1917
+ e.stopPropagation();
1918
+ isResizing = true;
1919
+ startX = e.pageX;
1920
+ startWidth = th.offsetWidth;
1921
+ resizeHandle.classList.add('resizing');
1922
+ document.body.style.cursor = 'col-resize';
1923
+ document.body.style.userSelect = 'none';
1924
+ });
1925
+
1926
+ document.addEventListener('mousemove', function(e) {
1927
+ if (!isResizing) return;
1928
+ const diff = e.pageX - startX;
1929
+ const newWidth = Math.max(60, Math.min(300, startWidth + diff));
1930
+ th.style.width = newWidth + 'px';
1931
+ th.style.minWidth = newWidth + 'px';
1932
+ th.style.maxWidth = newWidth + 'px';
1933
+
1934
+ // Also resize corresponding cells in tbody
1935
+ const tbody = table.querySelector('tbody');
1936
+ if (tbody) {
1937
+ const rows = tbody.querySelectorAll('tr');
1938
+ rows.forEach(row => {
1939
+ const cell = row.children[index];
1940
+ if (cell) {
1941
+ cell.style.width = newWidth + 'px';
1942
+ cell.style.minWidth = newWidth + 'px';
1943
+ cell.style.maxWidth = newWidth + 'px';
1944
+ }
1945
+ });
1946
+ }
1947
+ });
1948
+
1949
+ document.addEventListener('mouseup', function() {
1950
+ if (isResizing) {
1951
+ isResizing = false;
1952
+ resizeHandle.classList.remove('resizing');
1953
+ document.body.style.cursor = '';
1954
+ document.body.style.userSelect = '';
1955
+ }
1956
+ });
1957
+ });
1958
+
1959
+ // Add title attributes to all cells for tooltip on truncated content
1960
+ const cells = table.querySelectorAll('tbody td');
1961
+ cells.forEach(cell => {
1962
+ const cellText = cell.textContent.trim();
1963
+ if (cellText && !cell.getAttribute('title')) {
1964
+ cell.setAttribute('title', cellText);
1965
+ }
1966
+ });
1967
+ });
1968
+ }
1969
+
1970
+ // Run on load and observe for dynamically added tables
1971
+ document.addEventListener('DOMContentLoaded', setupColumnResize);
1972
+ setTimeout(setupColumnResize, 100);
1973
+ setTimeout(setupColumnResize, 500);
1974
+ setTimeout(setupColumnResize, 1000);
1975
+ setTimeout(setupColumnResize, 2000);
1976
+ setTimeout(setupColumnResize, 3000);
1977
+
1978
+ const resizeObserver = new MutationObserver((mutations) => {
1979
+ setupColumnResize();
1980
+ });
1981
+
1982
+ function startResizeObserver() {
1983
+ if (document.body) {
1984
+ resizeObserver.observe(document.body, {
1985
+ childList: true,
1986
+ subtree: true
1987
+ });
1988
+ setupColumnResize();
1989
+ } else {
1990
+ setTimeout(startResizeObserver, 10);
1991
+ }
1992
+ }
1993
+ startResizeObserver();
1994
+ })();
1995
+
1996
+ // Disable right-click context menu on dataframes (filter option has no function)
1997
+ (function() {
1998
+ function disableDataframeContextMenu() {
1999
+ const leaderboardIds = ['#wer-leaderboard-table', '#cer-leaderboard-table', '#rtfx-leaderboard-table'];
2000
+
2001
+ leaderboardIds.forEach(tableId => {
2002
+ const container = document.querySelector(tableId);
2003
+ if (!container || container.dataset.contextMenuDisabled) return;
2004
+ container.dataset.contextMenuDisabled = 'true';
2005
+
2006
+ // Prevent default context menu on right-click
2007
+ container.addEventListener('contextmenu', function(e) {
2008
+ e.preventDefault();
2009
+ e.stopPropagation();
2010
+ return false;
2011
+ }, true);
2012
+ });
2013
+
2014
+ // Also target any dataframe tables globally
2015
+ const dataframes = document.querySelectorAll('[data-testid="dataframe"], .table-wrap, .svelte-virtual-table-viewport');
2016
+ dataframes.forEach(df => {
2017
+ if (df.dataset.contextMenuDisabled) return;
2018
+ df.dataset.contextMenuDisabled = 'true';
2019
+
2020
+ df.addEventListener('contextmenu', function(e) {
2021
+ e.preventDefault();
2022
+ e.stopPropagation();
2023
+ return false;
2024
+ }, true);
2025
+ });
2026
+ }
2027
+
2028
+ // Run on load and observe for dynamically added tables
2029
+ document.addEventListener('DOMContentLoaded', disableDataframeContextMenu);
2030
+ setTimeout(disableDataframeContextMenu, 100);
2031
+ setTimeout(disableDataframeContextMenu, 500);
2032
+ setTimeout(disableDataframeContextMenu, 1000);
2033
+ setTimeout(disableDataframeContextMenu, 2000);
2034
+
2035
+ const contextMenuObserver = new MutationObserver((mutations) => {
2036
+ disableDataframeContextMenu();
2037
+ });
2038
+
2039
+ function startContextMenuObserver() {
2040
+ if (document.body) {
2041
+ contextMenuObserver.observe(document.body, {
2042
+ childList: true,
2043
+ subtree: true
2044
+ });
2045
+ disableDataframeContextMenu();
2046
+ } else {
2047
+ setTimeout(startContextMenuObserver, 10);
2048
+ }
2049
+ }
2050
+ startContextMenuObserver();
2051
+ })();
2052
+ </script>
2053
+ """
2054
+
2055
+
2056
+ light_mode_head = """
2057
+ <script>
2058
+ // Force light mode immediately to prevent flash of dark mode
2059
+ (function() {
2060
+ // Remove dark class if present
2061
+ document.documentElement.classList.remove('dark');
2062
+ document.body && document.body.classList.remove('dark');
2063
+
2064
+ // Set theme parameter in URL if not already light (only once on initial load)
2065
+ var url = new URL(window.location);
2066
+ if (url.searchParams.get('__theme') !== 'light') {
2067
+ url.searchParams.set('__theme', 'light');
2068
+ // Preserve the hash fragment when updating URL
2069
+ var newUrl = url.pathname + url.search + url.hash;
2070
+ history.replaceState(null, '', newUrl);
2071
+ }
2072
+
2073
+ // Also watch for dark class being added and remove it
2074
+ var observer = new MutationObserver(function(mutations) {
2075
+ mutations.forEach(function(mutation) {
2076
+ if (mutation.attributeName === 'class') {
2077
+ var target = mutation.target;
2078
+ if (target.classList.contains('dark')) {
2079
+ target.classList.remove('dark');
2080
+ }
2081
+ }
2082
+ });
2083
+ });
2084
+
2085
+ // Start observing document element
2086
+ observer.observe(document.documentElement, { attributes: true });
2087
+
2088
+ // Also observe body when it's ready
2089
+ if (document.body) {
2090
+ observer.observe(document.body, { attributes: true });
2091
+ } else {
2092
+ document.addEventListener('DOMContentLoaded', function() {
2093
+ document.body.classList.remove('dark');
2094
+ observer.observe(document.body, { attributes: true });
2095
+ });
2096
+ }
2097
+ })();
2098
+ </script>
2099
+ <style>
2100
+ /* Force light mode by overriding Gradio's CSS variables */
2101
+ :root, html, body, .gradio-container, .dark, body.dark {
2102
+ --body-background-fill: white !important;
2103
+ --background-fill-primary: white !important;
2104
+ --background-fill-secondary: #f9fafb !important;
2105
+ --body-text-color: #1f2937 !important;
2106
+ --body-text-color-subdued: #6b7280 !important;
2107
+ --block-background-fill: white !important;
2108
+ --block-border-color: #e5e7eb !important;
2109
+ --block-label-background-fill: #f3f4f6 !important;
2110
+ --block-label-text-color: #374151 !important;
2111
+ --block-title-text-color: #111827 !important;
2112
+ --input-background-fill: white !important;
2113
+ --input-border-color: #d1d5db !important;
2114
+ --table-even-background-fill: #f9fafb !important;
2115
+ --table-odd-background-fill: white !important;
2116
+ --table-row-focus: #e0f2fe !important;
2117
+ --border-color-primary: #e5e7eb !important;
2118
+ --neutral-50: #f9fafb !important;
2119
+ --neutral-100: #f3f4f6 !important;
2120
+ --neutral-200: #e5e7eb !important;
2121
+ --neutral-700: #374151 !important;
2122
+ --neutral-800: #1f2937 !important;
2123
+ --neutral-900: #111827 !important;
2124
+ --neutral-950: #030712 !important;
2125
+ }
2126
+
2127
+ /* Apply light background to main containers only */
2128
+ html, body, .gradio-container {
2129
+ background: white !important;
2130
+ color: #1f2937 !important;
2131
+ }
2132
+
2133
+ /* Target Gradio tables specifically, exclude Plotly */
2134
+ .gradio-container table:not(.js-plotly-plot table),
2135
+ .dark .gradio-container table:not(.js-plotly-plot table) {
2136
+ background: white !important;
2137
+ color: #1f2937 !important;
2138
+ }
2139
+
2140
+ .gradio-container th:not(.js-plotly-plot th),
2141
+ .gradio-container td:not(.js-plotly-plot td) {
2142
+ background: inherit !important;
2143
+ color: #1f2937 !important;
2144
+ border-color: #e5e7eb !important;
2145
+ }
2146
+
2147
+ /* Override system dark mode preference */
2148
+ @media (prefers-color-scheme: dark) {
2149
+ :root, html, body, .gradio-container {
2150
+ --body-background-fill: white !important;
2151
+ --background-fill-primary: white !important;
2152
+ --background-fill-secondary: #f9fafb !important;
2153
+ --body-text-color: #1f2937 !important;
2154
+ --block-background-fill: white !important;
2155
+ background: white !important;
2156
+ color: #1f2937 !important;
2157
+ }
2158
+ }
2159
+
2160
+ /* Plotly charts */
2161
+ .js-plotly-plot, .js-plotly-plot *,
2162
+ .plotly, .plotly * {
2163
+ background: unset !important;
2164
+ background-color: unset !important;
2165
+ }
2166
+ </style>
2167
+ """
2168
+
2169
+ # =============================================================================
2170
+ # HTML Templates
2171
+ # =============================================================================
2172
+
2173
+ chart_gallery_html = """
2174
+ <div class="chart-gallery">
2175
+ <div class="chart-card">
2176
+ <h4>🏆 CER Performance Leaderboard</h4>
2177
+ <p>Ranks models by Character Error Rate. Especially useful for agglutinative and low-resource languages.</p>
2178
+ </div>
2179
+ <div class="chart-card">
2180
+ <h4>🏆 WER Performance Leaderboard</h4>
2181
+ <p>Ranks model families or top individual models by Word Error Rate. Filter by language to see top performers.</p>
2182
+ </div>
2183
+ <div class="chart-card">
2184
+ <h4>🚀 Speed vs Accuracy Tradeoffs</h4>
2185
+ <p>Scatter plot comparing model speed (RTFx) against accuracy (WER). Find the sweet spot between fast and accurate models.</p>
2186
+ </div>
2187
+ <div class="chart-card">
2188
+ <h4>🔍 CER vs WER Correlation</h4>
2189
+ <p>Scatter plot showing relationship between character and word error rates. Understand error patterns across models.</p>
2190
+ </div>
2191
+ </div>
2192
+ """
2193
+
2194
+ section_divider_html = "<hr style='margin: 2rem 0;'>"
2195
+
2196
+ footer_html = """
2197
+ <footer class="paza-footer">
2198
+ <div class="paza-footer-links">
2199
+ <a href="https://go.microsoft.com/fwlink/?LinkId=521839" target="_blank" rel="noopener noreferrer">Privacy &amp; Cookies</a>
2200
+ <a href="https://www.microsoft.com/en-us/research/lab/microsoft-research-lab-africa-nairobi/" target="_blank" rel="noopener noreferrer">Microsoft Research Africa, Nairobi</a>
2201
+ <a href="https://www.microsoft.com/en-us/research/project/project-gecko/" target="_blank" rel="noopener noreferrer">Project Gecko</a>
2202
+ <a href="https://www.microsoft.com/en-us/research/" target="_blank" rel="noopener noreferrer" class="copyright">&copy; Microsoft Research 2026</a>
2203
+ </div>
2204
+ </footer>
2205
+ """
src/display/formatting.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def model_hyperlink(link, model_name):
2
+ return f'<a target="_blank" href="{link}" style="color: var(--link-text-color); text-decoration: underline;text-decoration-style: dotted;">{model_name}</a>'
3
+
4
+
5
+ def make_clickable_model(model_name):
6
+ link = f"https://huggingface.co/{model_name}"
7
+ return model_hyperlink(link, model_name)
8
+
9
+
10
+ def styled_error(error):
11
+ # Remove trailing full stop if present for cleaner display
12
+ error_text = error.rstrip('.')
13
+ return f"<p style='color: red; font-size: 14px; text-align: center;'>{error_text}</p>"
14
+
15
+
16
+ def styled_warning(warn):
17
+ return f"<p style='color: orange; font-size: 20px; text-align: center;'>{warn}</p>"
18
+
19
+
20
+ def styled_message(message):
21
+ return f"<p style='color: green; font-size: 20px; text-align: center;'>{message}</p>"
22
+
23
+
24
+ def has_no_nan_values(df, columns):
25
+ return df[columns].notna().all(axis=1)
26
+
27
+
28
+ def has_nan_values(df, columns):
29
+ return df[columns].isna().any(axis=1)
src/display/model_family_colors.csv ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Model Families,Model ID,Color
2
+ Granite,ibm-granite/granite-speech-3.3-2b,#288A56
3
+ Granite,ibm-granite/granite-speech-3.3-8b,#42B870
4
+ granite-speech,ibm-granite/granite-speech-3.3-2b,#288A56
5
+ Kyutai,kyutai/stt-2.6b-en,#43E5CA
6
+ LiteASR,efficient-speech/lite-whisper-large-v3-acc,#944500
7
+ LiteASR,efficient-speech/lite-whisper-large-v3,#CA6407
8
+ LiteASR,efficient-speech/lite-whisper-large-v3-fast,#FE8401
9
+ LiteASR,efficient-speech/lite-whisper-large-v3-turbo-acc,#FFA43D
10
+ LiteASR,efficient-speech/lite-whisper-large-v3-turbo,#FFC470
11
+ LiteASR,efficient-speech/lite-whisper-large-v3-turbo-fast,#FFE4BE
12
+ lite_asr,efficient-speech/lite-whisper-large-v3,#CA6407
13
+ Moonshine,usefulsensors/moonshine-base,#CC23D1
14
+ Moonshine,usefulsensors/moonshine-tiny,#E656EB
15
+ NeMo,nvidia/canary-1b-v2,#379539
16
+ NeMo,nvidia/canary-qwen-2.5b,#62BE55
17
+ NeMo,nvidia/parakeet-tdt-0.6b-v3,#76D45E
18
+ nvidia-nemo,nvidia/canary-1b-v2,#379539
19
+ nvidia-nemo,nvidia/canary-qwen-2.5b,#62BE55
20
+ nvidia-nemo,nvidia/parakeet-tdt-0.6b-v3,#76D45E
21
+ Omnilingual,omniASR-CTC-300M,#9FF2EA
22
+ Omnilingual,omniASR-CTC-1B,#43E5CA
23
+ Omnilingual,omniASR-CTC-3B,#1EC8B0
24
+ Omnilingual,omniASR-CTC-7B,#22918B
25
+ Omnilingual,omniASR-LLM-300M,#136C6C
26
+ Omnilingual,omniASR-LLM-1B,#01263F
27
+ Omnilingual,omniASR-LLM-3B,#024169
28
+ Omnilingual,omniASR-LLM-7B,#09638E
29
+ Omnilingual,omniASR-LLM-7B-ZS,#1384B1
30
+ Phi-4,microsoft/Phi-4-multimodal-instruct,#64DE89
31
+ Qwen2-Audio,Qwen/Qwen2-Audio-7B-Instruct,#4A43CB
32
+ Qwen2-Audio,Qwen/Qwen2-Audio-7B,#6864F6
33
+ SpeechBrain,speechbrain/asr-transformer-transformerlm-librispeech,#FAB500
34
+ SpeechBrain,speechbrain/asr-crdnn-rnnlm-librispeech,#FFCD0F
35
+ Wav2Vec2,facebook/wav2vec2-large-960h,#003580
36
+ Wav2Vec2,facebook/wav2vec2-large-960h-lv60-self,#0057AA
37
+ Wav2Vec2,facebook/wav2vec2-large-robust-ft-libri-960h,#0078D4
38
+ Wav2Vec2,facebook/wav2vec2-conformer-rel-pos-large-960h-ft,#0FAFFF
39
+ Wav2Vec2,facebook/wav2vec2-conformer-rope-large-960h-ft,#3BD5FF
40
+ facebook-wav2vec2-conformer,facebook/wav2vec2-conformer,#0FAFFF
41
+ Whisper,openai/whisper-tiny,#420C5F
42
+ Whisper,openai/whisper-tiny.en,#55107A
43
+ Whisper,openai/whisper-base,#691A90
44
+ Whisper,openai/whisper-base.en,#7F21A9
45
+ Whisper,openai/whisper-small,#A931D8
46
+ Whisper,openai/whisper-small.en,#CA59F7
47
+ Whisper,openai/whisper-medium,#DC8AFD
48
+ Whisper,openai/whisper-medium.en,#EBC7FA
49
+ Whisper,openai/whisper-large,#CEB0FF
50
+ Whisper,openai/whisper-large-v2,#AC80FF
51
+ Whisper,openai/whisper-large-v3,#8B52F4
52
+ Whisper,openai/whisper-large-v3-turbo,#6D37CD
53
+ openai-whisper,openai/whisper-large-v3,#8B52F4
54
+ Distil-Whisper,distil-whisper/distil-medium.en,#CA2134
55
+ Distil-Whisper,distil-whisper/distil-large-v2,#E82C41
56
+ Distil-Whisper,distil-whisper/distil-large-v3,#FB5B6C
57
+ CrisperWhisper,nyrahealth/CrisperWhisper,#EC318F
58
+ Facebook-MMS,facebook/mms-1b-all,#09638E
59
+ Facebook-MMS,facebook/mms-1b-fl102,#024169
60
+ facebook-mms,facebook/mms-1b-all,#09638E
61
+ paza,paza/microsoft-paza-mms-1b-all,#8CD0FF
62
+ paza,paza/microsoft-paza-whisper-large-v3-turbo,#BABAFF
63
+ paza,paza/microsoft-paza-Phi-4-multimodal-instruct,#CEB0FF
64
+ Facebook-Wav2Vec2,facebook/wav2vec2-base-960h,#0A1852
65
+ facebook-wav2vec2,facebook/wav2vec2-base-960h,#0A1852
66
+ Facebook-Data2Vec,facebook/data2vec-audio-base-960h,#0D1F69
67
+ Facebook-Data2Vec,facebook/data2vec-audio-large-960h,#163697
68
+ facebook-data2vec,facebook/data2vec-audio-base-960h,#0D1F69
69
+ Facebook-HuBERT,facebook/hubert-large-ls960-ft,#2052CB
70
+ Facebook-HuBERT,facebook/hubert-xlarge-ls960-ft,#367AF2
71
+ facebook_hubert,facebook/hubert-large-ls960-ft,#2052CB
72
+ Facebook-Omnilingual-ASR,facebook/omniASR-CTC-300M,#58AAFE
73
+ Facebook-Omnilingual-ASR,facebook/omniASR-CTC-1B,#B3E0FF
74
+ Facebook-Omnilingual-ASR,facebook/omniASR-CTC-3B,#D1ECFF
75
+ Facebook-Omnilingual-ASR,facebook/omniASR-CTC-7B,#95D3FF
76
+ Facebook-Omnilingual-ASR,facebook/omniASR-LLM-300M,#0C74A1
77
+ Facebook-Omnilingual-ASR,facebook/omniASR-LLM-1B,#119FC5
78
+ Facebook-Omnilingual-ASR,facebook/omniASR-LLM-3B,#16BBDA
79
+ Facebook-Omnilingual-ASR,facebook/omniASR-LLM-7B,#36DFF1
80
+ Facebook-Omnilingual-ASR,facebook/omniASR-LLM-7B-ZS,#9FF0F9
81
+ lite_asr_trunc,efficient/speech-lite-whisper-large-v3,#B35900
82
+ lite_asr_trunc,efficient/speech-lite-whisper-large-v3-acc,#CC6600
83
+ lite_asr_trunc,efficient/speech-lite-whisper-large-v3-fast,#E67300
84
+ lite_asr_trunc,efficient/speech-lite-whisper-large-v3-turbo,#FF8000
85
+ lite_asr_trunc,efficient/speech-lite-whisper-large-v3-turbo-acc,#FF9933
86
+ lite_asr_trunc,efficient/speech-lite-whisper-large-v3-turbo-fast,#FFB366
87
+ openai-whisper_trunc,openai/whisper-tiny.en,#5D1480
88
+ openai-whisper_trunc,openai/whisper-base.en,#7A1CA8
89
+ openai-whisper_trunc,openai/whisper-small.en,#9827D2
90
+ openai-whisper_trunc,openai/whisper-medium.en,#B44AE8
91
+ openai-whisper_trunc,openai/whisper-large,#C96FF3
92
+ openai-whisper_trunc,openai/whisper-large-v2,#DA94FA
93
+ openai-whisper_trunc,openai/whisper-large-v3,#E8B5FF
94
+ openai-whisper_trunc,distil/whisper-distil-medium.en,#D12545
95
+ openai-whisper_trunc,distil/whisper-distil-large-v2,#E83050
96
+ openai-whisper_trunc,distil/whisper-distil-large-v3,#F5566E
src/display/styling.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Styling and color utilities for the leaderboard display."""
2
+
3
+ import pandas as pd
4
+
5
+
6
+ def format_metric_value(value: float | None, fmt: str) -> str:
7
+ """Format a metric value for display."""
8
+ if value is None or pd.isna(value):
9
+ return "--"
10
+ try:
11
+ return fmt.format(float(value))
12
+ except Exception:
13
+ return f"{value}"
14
+
15
+
16
+ def hex_to_rgb(code: str) -> tuple[int, int, int]:
17
+ """Convert hex color code to RGB tuple."""
18
+ code = code.lstrip("#")
19
+ return tuple(int(code[i:i + 2], 16) for i in (0, 2, 4))
20
+
21
+
22
+ def rgb_to_hex(rgb: tuple[float, float, float]) -> str:
23
+ """Convert RGB tuple to hex color code."""
24
+ return "#" + "".join(f"{max(0, min(255, int(round(channel)))):02x}" for channel in rgb)
25
+
26
+
27
+ def interpolate_color(start_hex: str, end_hex: str, ratio: float) -> str:
28
+ """Interpolate between two colors based on ratio."""
29
+ ratio = max(0.0, min(1.0, ratio))
30
+ start = hex_to_rgb(start_hex)
31
+ end = hex_to_rgb(end_hex)
32
+ blended = tuple(start[i] + (end[i] - start[i]) * ratio for i in range(3))
33
+ return rgb_to_hex(blended)
34
+
35
+
36
+ def cell_color(value: float | None, minimum: float | None, maximum: float | None, better: str) -> str:
37
+ """Get cell background color based on value and metric direction."""
38
+ if value is None or pd.isna(value) or minimum is None or maximum is None:
39
+ return "#f3f4f6"
40
+ if abs(maximum - minimum) < 1e-9:
41
+ ratio = 0.5
42
+ else:
43
+ ratio = (float(value) - minimum) / (maximum - minimum)
44
+ if better == "higher":
45
+ ratio = 1.0 - ratio
46
+ return interpolate_color("#d9f7be", "#ffccc7", ratio)
src/display/utils.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+
5
+
6
+ def fields(raw_class):
7
+ return [v for k, v in raw_class.__dict__.items() if k[:2] != "__" and k[-2:] != "__"]
8
+
9
+
10
+ # User-facing column definitions.
11
+ @dataclass
12
+ class ColumnContent:
13
+ name: str
14
+ type: str
15
+ displayed_by_default: bool
16
+ hidden: bool = False
17
+ never_hidden: bool = False
18
+
19
+
20
+ ## For the queue columns in the submission tab
21
+ class EvalQueueColumn: # Queue column
22
+ model = ColumnContent("model", "markdown", True)
23
+ revision = ColumnContent("revision", "str", True)
24
+ private = ColumnContent("private", "bool", True)
25
+ precision = ColumnContent("precision", "str", True)
26
+ weight_type = ColumnContent("weight_type", "str", True)
27
+ status = ColumnContent("status", "str", True)
28
+
29
+ ## All the model information that we might need
30
+ @dataclass
31
+ class ModelDetails:
32
+ name: str
33
+ display_name: str = ""
34
+ symbol: str = "" # emoji
35
+
36
+
37
+ class ModelType(Enum):
38
+ PT = ModelDetails(name="pretrained", symbol="🟢")
39
+ FT = ModelDetails(name="fine-tuned", symbol="🔶")
40
+ IFT = ModelDetails(name="instruction-tuned", symbol="⭕")
41
+ RL = ModelDetails(name="RL-tuned", symbol="🟦")
42
+ Unknown = ModelDetails(name="", symbol="?")
43
+
44
+ def to_str(self, separator=" "):
45
+ return f"{self.value.symbol}{separator}{self.value.name}"
46
+
47
+ @staticmethod
48
+ def from_str(type):
49
+ if "fine-tuned" in type or "🔶" in type:
50
+ return ModelType.FT
51
+ if "pretrained" in type or "🟢" in type:
52
+ return ModelType.PT
53
+ if "RL-tuned" in type or "🟦" in type:
54
+ return ModelType.RL
55
+ if "instruction-tuned" in type or "⭕" in type:
56
+ return ModelType.IFT
57
+ return ModelType.Unknown
58
+
59
+ class WeightType(Enum):
60
+ Adapter = ModelDetails("Adapter")
61
+ Original = ModelDetails("Original")
62
+ Delta = ModelDetails("Delta")
63
+
64
+ class Precision(Enum):
65
+ float16 = ModelDetails("float16")
66
+ bfloat16 = ModelDetails("bfloat16")
67
+ Unknown = ModelDetails("?")
68
+
69
+ @staticmethod
70
+ def from_str(precision):
71
+ if precision in ["torch.float16", "float16"]:
72
+ return Precision.float16
73
+ if precision in ["torch.bfloat16", "bfloat16"]:
74
+ return Precision.bfloat16
75
+ return Precision.Unknown
76
+
77
+
78
+ EVAL_COLS = [c.name for c in fields(EvalQueueColumn)]
79
+ EVAL_TYPES = [c.type for c in fields(EvalQueueColumn)]
80
+
src/envs.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+ from huggingface_hub import HfApi
5
+
6
+ load_dotenv()
7
+
8
+ TOKEN = os.environ.get("HF_TOKEN")
9
+ OWNER = os.environ.get("OWNER")
10
+
11
+ REPO_ID = os.environ.get("REPO_ID")
12
+ QUEUE_REPO = os.environ.get("SUBMISSIONS_REPO")
13
+ RESULTS_REPO = os.environ.get("RESULTS_REPO")
14
+
15
+ # If you setup a cache later, just change HF_HOME
16
+ CACHE_PATH=os.getenv("HF_HOME", ".")
17
+
18
+ # Local caches
19
+ EVAL_REQUESTS_PATH = os.path.join(CACHE_PATH, "eval-queue")
20
+ EVAL_RESULTS_PATH = os.path.join(CACHE_PATH, "eval-results")
21
+ EVAL_REQUESTS_PATH_BACKEND = os.path.join(CACHE_PATH, "eval-queue-bk")
22
+ EVAL_RESULTS_PATH_BACKEND = os.path.join(CACHE_PATH, "eval-results-bk")
23
+
24
+ # Only create API if token is available
25
+ API = HfApi(token=TOKEN) if TOKEN else None
26
+
27
+ # Flag to enable/disable HuggingFace Hub features
28
+ HF_ENABLED = TOKEN is not None and os.environ.get("DISABLE_HF_HUB", "").lower() != "true"
29
+
30
+ # Telemetry: Set TELEMETRY_ENABLED=true to enable Azure Application Insights
31
+ TELEMETRY_ENABLED = os.environ.get("TELEMETRY_ENABLED", "false").lower() == "true"
32
+
src/language_metadata.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from src.constants import COUNTRY_TO_REGION_MAP, LANGUAGE_TO_COUNTRIES_MAP
4
+
5
+
6
+ def get_language_countries(language: str) -> List[str]:
7
+ """Get the list of countries where a language is spoken."""
8
+ return LANGUAGE_TO_COUNTRIES_MAP.get(language, ["Unknown"])
9
+
10
+
11
+ def get_language_regions(language: str) -> List[str]:
12
+ """Get the list of African regions where a language is spoken."""
13
+ countries = get_language_countries(language)
14
+ regions = set()
15
+ for country in countries:
16
+ region = COUNTRY_TO_REGION_MAP.get(country)
17
+ if region:
18
+ regions.add(region)
19
+ return list(regions) if regions else ["Unknown"]
20
+
21
+
22
+ def get_all_regions() -> List[str]:
23
+ """Get all unique African regions."""
24
+ regions = set(COUNTRY_TO_REGION_MAP.values())
25
+ return sorted(regions)
26
+
27
+
28
+ def get_languages_by_region(region: str) -> List[str]:
29
+ """Get all languages spoken in a specific African region."""
30
+ countries_in_region = [c for c, r in COUNTRY_TO_REGION_MAP.items() if r == region]
31
+ languages = set()
32
+ for lang, countries in LANGUAGE_TO_COUNTRIES_MAP.items():
33
+ if any(c in countries_in_region for c in countries):
34
+ languages.add(lang)
35
+ return list(languages)
src/leaderboard/read_evals.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import glob
2
+ import json
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ import dateutil
7
+ import numpy as np
8
+
9
+ from src.display.formatting import make_clickable_model
10
+ from src.display.utils import AutoEvalColumn, ModelType, Tasks, Precision, WeightType
11
+ from src.submission.check_validity import is_model_on_hub
12
+
13
+
14
+ @dataclass
15
+ class EvalResult:
16
+ """Represents one full evaluation. Built from a combination of the result and request file for a given run.
17
+ """
18
+ eval_name: str # org_model_precision (uid)
19
+ full_model: str # org/model (path on hub)
20
+ org: str
21
+ model: str
22
+ revision: str # commit hash, "" if main
23
+ results: dict
24
+ precision: Precision = Precision.Unknown
25
+ model_type: ModelType = ModelType.Unknown # Pretrained, fine tuned, ...
26
+ weight_type: WeightType = WeightType.Original # Original or Adapter
27
+ architecture: str = "Unknown"
28
+ license: str = "?"
29
+ likes: int = 0
30
+ num_params: int = 0
31
+ date: str = "" # submission date of request file
32
+ still_on_hub: bool = False
33
+
34
+ @classmethod
35
+ def init_from_json_file(self, json_filepath):
36
+ """Inits the result from the specific model result file"""
37
+ with open(json_filepath) as fp:
38
+ data = json.load(fp)
39
+
40
+ config = data.get("config")
41
+
42
+ # Precision
43
+ precision = Precision.from_str(config.get("model_dtype"))
44
+
45
+ # Get model and org
46
+ org_and_model = config.get("model_name", config.get("model_args", None))
47
+ org_and_model = org_and_model.split("/", 1)
48
+
49
+ if len(org_and_model) == 1:
50
+ org = None
51
+ model = org_and_model[0]
52
+ result_key = f"{model}_{precision.value.name}"
53
+ else:
54
+ org = org_and_model[0]
55
+ model = org_and_model[1]
56
+ result_key = f"{org}_{model}_{precision.value.name}"
57
+ full_model = "/".join(org_and_model)
58
+
59
+ still_on_hub, _, model_config = is_model_on_hub(
60
+ full_model, config.get("model_sha", "main"), trust_remote_code=True, test_tokenizer=False
61
+ )
62
+ architecture = "?"
63
+ if model_config is not None:
64
+ architectures = getattr(model_config, "architectures", None)
65
+ if architectures:
66
+ architecture = ";".join(architectures)
67
+
68
+ # Extract results available in this file (some results are split in several files)
69
+ results = {}
70
+ for task in Tasks:
71
+ task = task.value
72
+
73
+ # We average all scores of a given metric (not all metrics are present in all files)
74
+ accs = np.array([v.get(task.metric, None) for k, v in data["results"].items() if task.benchmark == k])
75
+ if accs.size == 0 or any([acc is None for acc in accs]):
76
+ continue
77
+
78
+ mean_acc = np.mean(accs)
79
+ results[task.benchmark] = mean_acc
80
+
81
+ return self(
82
+ eval_name=result_key,
83
+ full_model=full_model,
84
+ org=org,
85
+ model=model,
86
+ results=results,
87
+ precision=precision,
88
+ revision= config.get("model_sha", ""),
89
+ still_on_hub=still_on_hub,
90
+ architecture=architecture
91
+ )
92
+
93
+ def update_with_request_file(self, requests_path):
94
+ """Finds the relevant request file for the current model and updates info with it"""
95
+ request_file = get_request_file_for_model(requests_path, self.full_model, self.precision.value.name)
96
+
97
+ try:
98
+ with open(request_file, "r") as f:
99
+ request = json.load(f)
100
+ self.model_type = ModelType.from_str(request.get("model_type", ""))
101
+ self.weight_type = WeightType[request.get("weight_type", "Original")]
102
+ self.license = request.get("license", "?")
103
+ self.likes = request.get("likes", 0)
104
+ self.num_params = request.get("params", 0)
105
+ self.date = request.get("submitted_time", "")
106
+ except Exception:
107
+ print(f"Could not find request file for {self.org}/{self.model} with precision {self.precision.value.name}")
108
+
109
+ def to_dict(self):
110
+ """Converts the Eval Result to a dict compatible with our dataframe display"""
111
+ average = sum([v for v in self.results.values() if v is not None]) / len(Tasks)
112
+ data_dict = {
113
+ "eval_name": self.eval_name, # not a column, just a save name,
114
+ AutoEvalColumn.precision.name: self.precision.value.name,
115
+ AutoEvalColumn.model_type.name: self.model_type.value.name,
116
+ AutoEvalColumn.model_type_symbol.name: self.model_type.value.symbol,
117
+ AutoEvalColumn.weight_type.name: self.weight_type.value.name,
118
+ AutoEvalColumn.architecture.name: self.architecture,
119
+ AutoEvalColumn.model.name: make_clickable_model(self.full_model),
120
+ AutoEvalColumn.revision.name: self.revision,
121
+ AutoEvalColumn.average.name: average,
122
+ AutoEvalColumn.license.name: self.license,
123
+ AutoEvalColumn.likes.name: self.likes,
124
+ AutoEvalColumn.params.name: self.num_params,
125
+ AutoEvalColumn.still_on_hub.name: self.still_on_hub,
126
+ }
127
+
128
+ for task in Tasks:
129
+ data_dict[task.value.col_name] = self.results[task.value.benchmark]
130
+
131
+ return data_dict
132
+
133
+
134
+ def get_request_file_for_model(requests_path, model_name, precision):
135
+ """Selects the correct request file for a given model. Only keeps runs tagged as FINISHED"""
136
+ request_files = os.path.join(
137
+ requests_path,
138
+ f"{model_name}_eval_request_*.json",
139
+ )
140
+ request_files = glob.glob(request_files)
141
+
142
+ # Select correct request file (precision)
143
+ request_file = ""
144
+ request_files = sorted(request_files, reverse=True)
145
+ for tmp_request_file in request_files:
146
+ with open(tmp_request_file, "r") as f:
147
+ req_content = json.load(f)
148
+ if (
149
+ req_content["status"] in ["FINISHED"]
150
+ and req_content["precision"] == precision.split(".")[-1]
151
+ ):
152
+ request_file = tmp_request_file
153
+ return request_file
154
+
155
+
156
+ def get_raw_eval_results(results_path: str, requests_path: str) -> list[EvalResult]:
157
+ """From the path of the results folder root, extract all needed info for results"""
158
+ model_result_filepaths = []
159
+
160
+ for root, _, files in os.walk(results_path):
161
+ # We should only have json files in model results
162
+ if len(files) == 0 or any([not f.endswith(".json") for f in files]):
163
+ continue
164
+
165
+ # Sort the files by date
166
+ try:
167
+ files.sort(key=lambda x: x.removesuffix(".json").removeprefix("results_")[:-7])
168
+ except dateutil.parser._parser.ParserError:
169
+ files = [files[-1]]
170
+
171
+ for file in files:
172
+ model_result_filepaths.append(os.path.join(root, file))
173
+
174
+ eval_results = {}
175
+ for model_result_filepath in model_result_filepaths:
176
+ # Creation of result
177
+ eval_result = EvalResult.init_from_json_file(model_result_filepath)
178
+ eval_result.update_with_request_file(requests_path)
179
+
180
+ # Store results of same eval together
181
+ eval_name = eval_result.eval_name
182
+ if eval_name in eval_results.keys():
183
+ eval_results[eval_name].results.update({k: v for k, v in eval_result.results.items() if v is not None})
184
+ else:
185
+ eval_results[eval_name] = eval_result
186
+
187
+ results = []
188
+ for v in eval_results.values():
189
+ try:
190
+ v.to_dict() # we test if the dict version is complete
191
+ results.append(v)
192
+ except KeyError: # not all eval values present
193
+ continue
194
+
195
+ return results
src/model_counts.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Parameter Counts Dictionary for PazaBench Leaderboard
2
+ # To be used as bubble sizes in the speech vs accuracy tradeoff chart
3
+ # Last updated with verified HuggingFace model card data
4
+
5
+ MODEL_PARAMETER_COUNTS = {
6
+ # Facebook Data2Vec family (based on wav2vec2 architecture)
7
+ "facebook/data2vec-audio-base-960h": 94_400_000, # 94.4M
8
+ "facebook/data2vec-audio-large-960h": 315_000_000, # ~315M (large architecture)
9
+
10
+ # Facebook MMS (Massively Multilingual Speech) family
11
+ "facebook/mms-1b-all": 1_000_000_000, # 1B params
12
+ "facebook/mms-1b-fl102": 1_000_000_000, # 1B params
13
+
14
+ # Facebook Wav2Vec2 family
15
+ "facebook/wav2vec2-base-960h": 94_400_000, # 94.4M
16
+ "facebook/wav2vec2-large-960h": 315_000_000, # ~315M (large)
17
+ "facebook/wav2vec2-large-960h-lv60-self": 315_000_000, # ~315M (large)
18
+ "facebook/wav2vec2-large-robust-ft-libri-960h": 315_000_000, # ~315M
19
+
20
+ # Facebook Wav2Vec2 Conformer family
21
+ "facebook/wav2vec2-conformer-rel-pos-large-960h-ft": 600_000_000, # 600M
22
+ "facebook/wav2vec2-conformer-rope-large-960h-ft": 600_000_000, # 600M
23
+
24
+ # Facebook HuBERT family
25
+ "facebook/hubert-large-ls960-ft": 315_000_000, # ~315M (large)
26
+ "facebook/hubert-xlarge-ls960-ft": 1_000_000_000, # 1B params
27
+
28
+ # IBM Granite Speech family (verified from HF model cards)
29
+ # Note: Total params include speech encoder + LLM backbone
30
+ "ibm/granite-granite-speech-3.3-2b": 3_000_000_000, # 3B params
31
+ "ibm/granite-granite-speech-3.3-8b": 9_000_000_000, # 9B params
32
+
33
+ # Kyutai family
34
+ "kyutai/stt-2.6b-en": 2_600_000_000, # 2.6B params
35
+
36
+ # Lite ASR / EfficientSpeech family (Whisper-based compressions)
37
+ # https://huggingface.co/efficient-speech
38
+ "efficient/speech-lite-whisper-large-v3": 1_000_000_000, # ~1B (large-v3 compression)
39
+ "efficient/speech-lite-whisper-large-v3-acc": 1_000_000_000, # ~1B (accuracy-optimized)
40
+ "efficient/speech-lite-whisper-large-v3-fast": 1_000_000_000, # ~1B (speed-optimized)
41
+ "efficient/speech-lite-whisper-large-v3-turbo": 600_000_000, # ~0.6B (turbo compression)
42
+ "efficient/speech-lite-whisper-large-v3-turbo-acc": 600_000_000, # ~0.6B (turbo accuracy)
43
+ "efficient/speech-lite-whisper-large-v3-turbo-fast": 600_000_000, # ~0.6B (turbo fast)
44
+
45
+ # Moonshine family (Useful Sensors)
46
+ "usefulsensors/moonshine-tiny": 27_100_000, # 27.1M params
47
+ "usefulsensors/moonshine-base": 61_500_000, # 61.5M params
48
+
49
+ # OpenAI Whisper family
50
+ "openai/whisper-tiny.en": 37_800_000, # 37.8M
51
+ "openai/whisper-base.en": 72_600_000, # 72.6M
52
+ "openai/whisper-small.en": 244_000_000, # 244M
53
+ "openai/whisper-medium.en": 769_000_000, # 769M
54
+ "openai/whisper-large": 1_550_000_000, # 1550M
55
+ "openai/whisper-large-v2": 1_550_000_000, # 1550M
56
+ "openai/whisper-large-v3": 1_550_000_000, # 1550M
57
+ "openai/whisper-large-v3-turbo": 809_000_000, # 809M
58
+
59
+ # Distil-Whisper family
60
+ "distil/whisper-distil-large-v2": 756_000_000, # 756M
61
+ "distil/whisper-distil-large-v3": 756_000_000, # 756M
62
+ "distil/whisper-distil-medium.en": 394_000_000, # 394M
63
+
64
+ # Paza family (actual model names from results_summary.csv)
65
+ "paza/microsoft-paza-Phi-4-multimodal-instruct": 5_600_000_000, # 5.6B
66
+ "paza/microsoft-paza-mms-1b-all": 1_000_000_000, # 1B
67
+ "paza/microsoft-paza-whisper-large-v3-turbo": 809_000_000, # 809M
68
+
69
+ # Qwen2 Audio family
70
+ "Qwen/Qwen2-Audio-7B": 8_000_000_000, # 8B params
71
+ "Qwen/Qwen2-Audio-7B-Instruct": 8_000_000_000, # 8B params
72
+
73
+ # OmniASR family - CTC models
74
+ "facebook/omniASR-CTC-300M": 325_494_996, # 325M
75
+ "facebook/omniASR-CTC-1B": 975_065_300, # 975M
76
+ "facebook/omniASR-CTC-3B": 3_080_423_636, # 3.08B
77
+ "facebook/omniASR-CTC-7B": 6_504_786_132, # 6.5B
78
+
79
+ # OmniASR family - LLM models (encoder-decoder)
80
+ "facebook/omniASR-LLM-300M": 1_627_603_584, # 1.6B
81
+ "facebook/omniASR-LLM-1B": 2_275_710_592, # 2.3B
82
+ "facebook/omniASR-LLM-3B": 4_376_679_040, # 4.4B
83
+ "facebook/omniASR-LLM-7B": 7_801_041_536, # 7.8B
84
+
85
+ # OmniASR family - Zero-shot model
86
+ "facebook/omniASR-LLM-7B-ZS": 7_810_900_608, # 7.8B
87
+
88
+ # Microsoft Phi-4 family
89
+ "microsoft/Phi-4-multimodal-instruct": 5_600_000_000, # 5.6B params
90
+
91
+ # NVIDIA NeMo ASR family
92
+ "nvidia/canary-1b": 1_000_000_000, # ~1B (FastConformer encoder-decoder)
93
+ "nvidia/canary-1b-v2": 1_000_000_000, # ~1B params
94
+ "nvidia/canary-qwen-2.5b": 2_500_000_000, # 2.5B params
95
+ "nvidia/parakeet-tdt-0.6b-v2": 600_000_000, # 600M
96
+ "nvidia/parakeet-tdt-0.6b-v3": 600_000_000, # 600M params
97
+
98
+ # Seamless M4T family
99
+ "facebook/seamless-m4t-v2-large": 2_300_000_000, # 2.3B params
100
+ }
src/populate.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+
4
+ import pandas as pd
5
+
6
+ from src.display.formatting import make_clickable_model
7
+ from src.display.utils import EvalQueueColumn
8
+
9
+
10
+ def get_evaluation_queue_df(save_path: str, cols: list) -> list[pd.DataFrame]:
11
+ """Creates the different dataframes for the evaluation queues requestes"""
12
+ if not os.path.exists(save_path):
13
+ return pd.DataFrame(columns=cols), pd.DataFrame(columns=cols), pd.DataFrame(columns=cols)
14
+
15
+ entries = [entry for entry in os.listdir(save_path) if not entry.startswith(".")]
16
+ all_evals = []
17
+
18
+ for entry in entries:
19
+ if ".json" in entry:
20
+ file_path = os.path.join(save_path, entry)
21
+ with open(file_path) as fp:
22
+ data = json.load(fp)
23
+
24
+ data[EvalQueueColumn.model.name] = make_clickable_model(data["model"])
25
+ data[EvalQueueColumn.revision.name] = data.get("revision", "main")
26
+
27
+ all_evals.append(data)
28
+ elif ".md" not in entry:
29
+ # this is a folder
30
+ sub_entries = [e for e in os.listdir(f"{save_path}/{entry}") if os.path.isfile(e) and not e.startswith(".")]
31
+ for sub_entry in sub_entries:
32
+ file_path = os.path.join(save_path, entry, sub_entry)
33
+ with open(file_path) as fp:
34
+ data = json.load(fp)
35
+
36
+ data[EvalQueueColumn.model.name] = make_clickable_model(data["model"])
37
+ data[EvalQueueColumn.revision.name] = data.get("revision", "main")
38
+ all_evals.append(data)
39
+
40
+ pending_list = [e for e in all_evals if e["status"] in ["PENDING", "RERUN"]]
41
+ running_list = [e for e in all_evals if e["status"] == "RUNNING"]
42
+ finished_list = [e for e in all_evals if e["status"].startswith("FINISHED") or e["status"] == "PENDING_NEW_EVAL"]
43
+ df_pending = pd.DataFrame.from_records(pending_list, columns=cols)
44
+ df_running = pd.DataFrame.from_records(running_list, columns=cols)
45
+ df_finished = pd.DataFrame.from_records(finished_list, columns=cols)
46
+ return df_finished[cols], df_running[cols], df_pending[cols]
src/submission/check_validity.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from collections import defaultdict
4
+
5
+ import huggingface_hub
6
+ from huggingface_hub import ModelCard
7
+ from huggingface_hub.hf_api import ModelInfo
8
+ from transformers import AutoConfig
9
+ from transformers.models.auto.tokenization_auto import AutoTokenizer
10
+
11
+ def check_model_card(repo_id: str) -> tuple[bool, str]:
12
+ """Checks if the model card and license exist and have been filled"""
13
+ try:
14
+ card = ModelCard.load(repo_id)
15
+ except huggingface_hub.utils.EntryNotFoundError:
16
+ return False, "Please add a model card to your model to explain how you trained/fine-tuned it."
17
+
18
+ # Enforce license metadata
19
+ if card.data.license is None:
20
+ if not ("license_name" in card.data and "license_link" in card.data):
21
+ return False, (
22
+ "License not found. Please add a license to your model card using the `license` metadata or a"
23
+ " `license_name`/`license_link` pair."
24
+ )
25
+
26
+ # Enforce card content
27
+ if len(card.text) < 200:
28
+ return False, "Please add a description to your model card, it is too short."
29
+
30
+ return True, ""
31
+
32
+ def is_model_on_hub(model_name: str, revision: str, token: str = None, trust_remote_code=False, test_tokenizer=False) -> tuple[bool, str]:
33
+ """Checks if the model model_name is on the hub, and whether it (and its tokenizer) can be loaded with AutoClasses."""
34
+ try:
35
+ config = AutoConfig.from_pretrained(model_name, revision=revision, trust_remote_code=trust_remote_code, token=token)
36
+ if test_tokenizer:
37
+ try:
38
+ AutoTokenizer.from_pretrained(model_name, revision=revision, trust_remote_code=trust_remote_code, token=token)
39
+ except ValueError as e:
40
+ return (
41
+ False,
42
+ f"uses a tokenizer which is not in a transformers release: {e}",
43
+ None
44
+ )
45
+ except Exception:
46
+ return (False, "'s tokenizer cannot be loaded. Is your tokenizer class in a stable transformers release, and correctly configured?", None)
47
+ return True, None, config
48
+
49
+ except ValueError:
50
+ return (
51
+ False,
52
+ "needs to be launched with `trust_remote_code=True`. For safety reason, we do not allow these models to be automatically submitted to the leaderboard.",
53
+ None
54
+ )
55
+
56
+ except Exception:
57
+ return False, "was not found on hub!", None
58
+
59
+
60
+ def get_model_size(model_info: ModelInfo, precision: str):
61
+ """Gets the model size from the configuration, or the model name if the configuration does not contain the information."""
62
+ try:
63
+ model_size = round(model_info.safetensors["total"] / 1e9, 3)
64
+ except (AttributeError, TypeError):
65
+ return 0 # Unknown model sizes are indicated as 0, see NUMERIC_INTERVALS in app.py
66
+
67
+ size_factor = 8 if (precision == "GPTQ" or "gptq" in model_info.modelId.lower()) else 1
68
+ model_size = size_factor * model_size
69
+ return model_size
70
+
71
+ def get_model_arch(model_info: ModelInfo):
72
+ """Gets the model architecture from the configuration"""
73
+ return model_info.config.get("architectures", "Unknown")
74
+
75
+ def already_submitted_models(requested_models_dir: str) -> set[str]:
76
+ """Gather a list of already submitted models to avoid duplicates"""
77
+ depth = 1
78
+ file_names = []
79
+ users_to_submission_dates = defaultdict(list)
80
+
81
+ for root, _, files in os.walk(requested_models_dir):
82
+ current_depth = root.count(os.sep) - requested_models_dir.count(os.sep)
83
+ if current_depth == depth:
84
+ for file in files:
85
+ if not file.endswith(".json"):
86
+ continue
87
+ with open(os.path.join(root, file), "r") as f:
88
+ info = json.load(f)
89
+ file_names.append(f"{info['model']}_{info['revision']}_{info['precision']}")
90
+
91
+ # Select organisation
92
+ if info["model"].count("/") == 0 or "submitted_time" not in info:
93
+ continue
94
+ organisation, _ = info["model"].split("/")
95
+ users_to_submission_dates[organisation].append(info["submitted_time"])
96
+
97
+ return set(file_names), users_to_submission_dates
src/submission/submit.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ from datetime import datetime, timezone
5
+ from urllib.parse import urlparse
6
+
7
+ from huggingface_hub import dataset_info
8
+ from huggingface_hub.utils import HfHubHTTPError, RepositoryNotFoundError
9
+
10
+ from src.display.formatting import styled_error, styled_message, styled_warning
11
+ from src.envs import API, EVAL_REQUESTS_PATH, TOKEN, QUEUE_REPO
12
+
13
+
14
+ def is_dataset_on_hub(dataset_name: str, token: str = None) -> tuple[bool, str | None]:
15
+ """Check if a dataset exists on the Hugging Face Hub and is publicly available."""
16
+ try:
17
+ info = dataset_info(dataset_name, token=token)
18
+ if info.private:
19
+ return False, "is private. Please make the dataset publicly available on Hugging Face Hub."
20
+ return True, None
21
+ except RepositoryNotFoundError:
22
+ return False, "was not found on the Hub!"
23
+ except HfHubHTTPError as e:
24
+ return False, f"could not be accessed: {str(e)}"
25
+ except Exception as e:
26
+ return False, f"error checking dataset: {str(e)}"
27
+
28
+
29
+ def is_valid_url(url: str) -> tuple[bool, str | None]:
30
+ """Validate URL using urllib.parse with strict scheme enforcement."""
31
+ if not url or not url.strip():
32
+ return False, "URL cannot be empty."
33
+
34
+ url = url.strip()
35
+
36
+ try:
37
+ parsed = urlparse(url)
38
+
39
+ # Strict scheme validation - only http/https allowed
40
+ if parsed.scheme not in ('http', 'https'):
41
+ return False, "URL must start with http:// or https://"
42
+
43
+ # Must have a valid network location (domain)
44
+ if not parsed.netloc:
45
+ return False, "Invalid URL domain. Please provide a complete URL."
46
+
47
+ # Extract hostname (remove port if present)
48
+ hostname = parsed.hostname
49
+ if not hostname or '.' not in hostname:
50
+ return False, "Invalid URL domain. Please provide a complete URL."
51
+
52
+ # Validate hostname format (alphanumeric, dots, hyphens only)
53
+ # This blocks javascript:, data:, vbscript: and other injection schemes
54
+ hostname_parts = hostname.split('.')
55
+ for part in hostname_parts:
56
+ if not part or not all(c.isalnum() or c == '-' for c in part):
57
+ return False, "Invalid domain name in URL."
58
+ if part.startswith('-') or part.endswith('-'):
59
+ return False, "Invalid domain name in URL."
60
+
61
+ return True, None
62
+ except Exception:
63
+ return False, "Invalid URL format."
64
+
65
+
66
+ def add_language_eval_request(
67
+ location: str,
68
+ dataset_name: str,
69
+ dataset_url: str,
70
+ dataset_config: str,
71
+ dataset_split: str,
72
+ audio_column: str,
73
+ text_column: str,
74
+ license: str,
75
+ ):
76
+ """Submit a request to evaluate a new language/dataset on all models."""
77
+ # Validate required fields based on location
78
+ if not license or not license.strip():
79
+ return styled_error("Please provide a license for the dataset.")
80
+
81
+ if location == "HuggingFace":
82
+ # Validate HuggingFace dataset
83
+ if not dataset_name or not dataset_name.strip():
84
+ return styled_error("Please provide a dataset name.")
85
+
86
+ dataset_name = dataset_name.strip()
87
+ if "/" not in dataset_name:
88
+ return styled_error("Dataset name must be in the format 'owner/dataset-name' (e.g., 'mozilla-foundation/common_voice_13_0').")
89
+
90
+ # Check if dataset exists on Hub
91
+ dataset_on_hub, error = is_dataset_on_hub(dataset_name, token=TOKEN)
92
+ if not dataset_on_hub:
93
+ return styled_error(f'Dataset "{dataset_name}" {error}')
94
+
95
+ dataset_identifier = dataset_name
96
+ dataset_source = "huggingface"
97
+ else:
98
+ # Validate external URL
99
+ if not dataset_url or not dataset_url.strip():
100
+ return styled_error("Please provide a dataset URL.")
101
+
102
+ valid_url, error = is_valid_url(dataset_url)
103
+ if not valid_url:
104
+ return styled_error(error)
105
+
106
+ dataset_url = dataset_url.strip()
107
+ dataset_identifier = dataset_url
108
+ dataset_source = "external"
109
+
110
+ current_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
111
+
112
+ # Use defaults for optional fields
113
+ config = dataset_config.strip() if dataset_config and dataset_config.strip() else "default"
114
+ split = dataset_split.strip() if dataset_split and dataset_split.strip() else "test"
115
+ audio_col = audio_column.strip() if audio_column and audio_column.strip() else "audio"
116
+ text_col = text_column.strip() if text_column and text_column.strip() else "text"
117
+
118
+ # Create safe identifier for filename
119
+ if dataset_source == "huggingface":
120
+ safe_dataset = dataset_identifier.replace("/", "_").replace(" ", "_")
121
+ else:
122
+ # For external URLs, create a safe identifier from the URL
123
+ safe_dataset = re.sub(r'[^a-zA-Z0-9]', '_', dataset_identifier)[:100]
124
+ safe_config = config.replace(" ", "_").lower()
125
+
126
+ OUT_DIR = f"{EVAL_REQUESTS_PATH}/language_requests"
127
+ os.makedirs(OUT_DIR, exist_ok=True)
128
+
129
+ # Check if similar request already exists
130
+ existing_files = os.listdir(OUT_DIR) if os.path.exists(OUT_DIR) else []
131
+ for existing_file in existing_files:
132
+ if existing_file.startswith(f"lang_eval_{safe_dataset}_{safe_config}_"):
133
+ return styled_warning("A similar evaluation request for this dataset configuration already exists.")
134
+
135
+ # Create language evaluation request entry
136
+ eval_entry = {
137
+ "type": "language_evaluation",
138
+ "source": dataset_source,
139
+ "dataset": dataset_identifier,
140
+ "config": config,
141
+ "split": split,
142
+ "audio_column": audio_col,
143
+ "text_column": text_col,
144
+ "license": license.strip() if license else "",
145
+ "status": "PENDING",
146
+ "submitted_time": current_time,
147
+ }
148
+
149
+ # Create unique filename
150
+ filename = f"lang_eval_{safe_dataset}_{safe_config}_{current_time.replace(':', '-')}.json"
151
+
152
+ print(f"Creating language eval request: {filename}")
153
+ out_path = f"{OUT_DIR}/{filename}"
154
+
155
+ with open(out_path, "w") as f:
156
+ f.write(json.dumps(eval_entry, indent=2))
157
+
158
+ # Upload to Hub if API is available
159
+ if API:
160
+ try:
161
+ print("Uploading language eval request")
162
+ API.upload_file(
163
+ path_or_fileobj=out_path,
164
+ path_in_repo=f"language_requests/{filename}",
165
+ repo_id=QUEUE_REPO,
166
+ repo_type="dataset",
167
+ commit_message=f"Add language evaluation request for {dataset_identifier} ({config})",
168
+ )
169
+ os.remove(out_path)
170
+ except Exception as e:
171
+ print(f"Could not upload to Hub: {e}")
172
+ # Keep local file if upload fails
173
+
174
+ source_label = "Hugging Face" if dataset_source == "huggingface" else "External URL"
175
+ return styled_message(
176
+ f"✅ Your language evaluation request has been submitted!\n\n"
177
+ f"**Source:** {source_label}\n"
178
+ f"**Dataset:** {dataset_identifier}\n"
179
+ f"**Config:** {config}\n\n"
180
+ f"We will review your request and run evaluations on all supported models."
181
+ )
src/telemetry.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import uuid
4
+ import threading
5
+ from contextlib import contextmanager
6
+ from datetime import datetime
7
+ from functools import wraps
8
+ from typing import Any, Callable
9
+
10
+ # Telemetry configuration from environment variables
11
+ TELEMETRY_ENABLED = os.environ.get("TELEMETRY_ENABLED", "false").lower() == "true"
12
+ APPLICATIONINSIGHTS_CONNECTION_STRING = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING", "")
13
+
14
+ # Debug mode - prints telemetry events to console/docker logs
15
+ TELEMETRY_DEBUG = os.environ.get("TELEMETRY_DEBUG", "false").lower() == "true"
16
+
17
+ # Cloud role name for Application Insights (required when telemetry is enabled)
18
+ CLOUD_ROLE_NAME = os.environ.get("OTEL_SERVICE_NAME")
19
+
20
+ # Module-level state
21
+ _telemetry_initialized = False
22
+ _tracer = None
23
+ _meter = None
24
+ _logger = None
25
+
26
+ # Metric instruments (initialized lazily)
27
+ _counters: dict[str, Any] = {}
28
+ _histograms: dict[str, Any] = {}
29
+
30
+ # =============================================================================
31
+ # Session Context Management
32
+ # =============================================================================
33
+
34
+ # Thread-local storage for session context
35
+ _session_context = threading.local()
36
+
37
+
38
+ def _generate_event_id() -> str:
39
+
40
+ return str(uuid.uuid4())[:8]
41
+
42
+
43
+ def set_session_context(session_hash: str | None) -> str:
44
+
45
+ if session_hash:
46
+ _session_context.session_id = session_hash[:12] # Truncate for readability
47
+ else:
48
+ # Generate a temporary session ID if none provided
49
+ if not hasattr(_session_context, 'session_id') or not _session_context.session_id:
50
+ _session_context.session_id = f"anon_{_generate_event_id()}"
51
+ return _session_context.session_id
52
+
53
+
54
+ def get_session_id() -> str:
55
+
56
+ if not hasattr(_session_context, 'session_id') or not _session_context.session_id:
57
+ return set_session_context(None)
58
+ return _session_context.session_id
59
+
60
+
61
+ def clear_session_context() -> None:
62
+
63
+ if hasattr(_session_context, 'session_id'):
64
+ _session_context.session_id = None
65
+
66
+
67
+ def _debug_log(message: str) -> None:
68
+
69
+ if TELEMETRY_DEBUG:
70
+ print(f"[TELEMETRY] {message}", flush=True)
71
+
72
+
73
+ def _initialize_telemetry() -> bool:
74
+
75
+ global _telemetry_initialized, _tracer, _meter, _logger
76
+
77
+ if _telemetry_initialized:
78
+ return True
79
+
80
+ if not TELEMETRY_ENABLED:
81
+ return False
82
+
83
+ if not APPLICATIONINSIGHTS_CONNECTION_STRING:
84
+ logging.warning("APPLICATIONINSIGHTS_CONNECTION_STRING not set. Telemetry disabled.")
85
+ return False
86
+
87
+ if not CLOUD_ROLE_NAME:
88
+ logging.warning("OTEL_SERVICE_NAME not set. Telemetry disabled.")
89
+ return False
90
+
91
+ try:
92
+ from azure.monitor.opentelemetry import configure_azure_monitor
93
+ from opentelemetry import trace, metrics
94
+
95
+ # Set cloud role name via environment variable (OpenTelemetry standard)
96
+ os.environ.setdefault("OTEL_SERVICE_NAME", CLOUD_ROLE_NAME)
97
+
98
+ # Configure Azure Monitor OpenTelemetry
99
+ configure_azure_monitor(
100
+ connection_string=APPLICATIONINSIGHTS_CONNECTION_STRING,
101
+ logger_name="pazabench",
102
+ )
103
+
104
+ # Get tracer and meter
105
+ _tracer = trace.get_tracer("pazabench.telemetry")
106
+ _meter = metrics.get_meter("pazabench.telemetry")
107
+
108
+ # Set up logger for custom events
109
+ _logger = logging.getLogger("pazabench")
110
+ _logger.setLevel(logging.INFO)
111
+
112
+ _telemetry_initialized = True
113
+ logging.info(f"Telemetry initialized. Cloud Role Name: {CLOUD_ROLE_NAME}")
114
+ return True
115
+
116
+ except ImportError as e:
117
+ logging.warning(f"Azure Monitor OpenTelemetry packages not installed: {e}")
118
+ return False
119
+ except Exception as e:
120
+ logging.warning(f"Failed to initialize telemetry: {e}")
121
+ return False
122
+
123
+
124
+ def _ensure_initialized(func: Callable) -> Callable:
125
+
126
+ @wraps(func)
127
+ def wrapper(*args, **kwargs):
128
+ if not _telemetry_initialized:
129
+ _initialize_telemetry()
130
+ if not _telemetry_initialized:
131
+ return None
132
+ return func(*args, **kwargs)
133
+ return wrapper
134
+
135
+
136
+ # =============================================================================
137
+ # Custom Events - Track user interactions and behavior
138
+ # =============================================================================
139
+
140
+ @_ensure_initialized
141
+ def track_event(
142
+ name: str,
143
+ properties: dict[str, Any] | None = None,
144
+ parent_event_id: str | None = None
145
+ ) -> str | None:
146
+
147
+ # Generate unique event ID for this event
148
+ event_id = _generate_event_id()
149
+ session_id = get_session_id()
150
+
151
+ # Build properties with automatic session and event tracking
152
+ all_properties: dict[str, Any] = {
153
+ "event_id": event_id,
154
+ "session_id": session_id,
155
+ "timestamp": datetime.utcnow().isoformat() + "Z",
156
+ }
157
+ if parent_event_id:
158
+ all_properties["parent_event_id"] = parent_event_id
159
+ if properties:
160
+ all_properties.update(properties)
161
+
162
+ # Debug logging to console/docker logs
163
+ props_str = ", ".join(f"{k}={v}" for k, v in all_properties.items())
164
+ _debug_log(f"EVENT: {name} | {props_str}")
165
+
166
+ if not _logger:
167
+ return event_id
168
+
169
+ extra = {"microsoft.custom_event.name": name}
170
+ extra.update(all_properties)
171
+
172
+ _logger.info(f"Event: {name}", extra=extra)
173
+ return event_id
174
+
175
+
176
+ @_ensure_initialized
177
+ def track_page_view(page_name: str, properties: dict[str, Any] | None = None) -> None:
178
+
179
+ event_properties = {"page_name": page_name}
180
+ if properties:
181
+ event_properties.update(properties)
182
+ track_event("page_view", event_properties)
183
+
184
+
185
+ @_ensure_initialized
186
+ def track_filter_change(
187
+ filter_type: str,
188
+ values: list[str] | None,
189
+ tab: str | None = None
190
+ ) -> None:
191
+
192
+ properties = {
193
+ "filter_type": filter_type,
194
+ "values_count": len(values) if values else 0,
195
+ "values": ",".join(values[:10]) if values else "", # Limit to first 10 for size
196
+ }
197
+ if tab:
198
+ properties["tab"] = tab
199
+ track_event("filter_change", properties)
200
+
201
+
202
+ @_ensure_initialized
203
+ def track_language_interest(
204
+ language: str,
205
+ context: str,
206
+ properties: dict[str, Any] | None = None
207
+ ) -> None:
208
+
209
+ event_properties = {"language": language, "context": context}
210
+ if properties:
211
+ event_properties.update(properties)
212
+ track_event("language_interest", event_properties)
213
+
214
+
215
+ @_ensure_initialized
216
+ def track_model_interest(
217
+ model: str,
218
+ context: str,
219
+ properties: dict[str, Any] | None = None
220
+ ) -> None:
221
+
222
+ event_properties = {"model": model, "context": context}
223
+ if properties:
224
+ event_properties.update(properties)
225
+ track_event("model_interest", event_properties)
226
+
227
+
228
+ @_ensure_initialized
229
+ def track_visualization_view(
230
+ chart_name: str,
231
+ properties: dict[str, Any] | None = None,
232
+ languages: list[str] | None = None,
233
+ models: list[str] | None = None
234
+ ) -> str | None:
235
+
236
+ event_properties: dict[str, Any] = {"chart_name": chart_name}
237
+
238
+ # Include languages in the main event (no separate events needed)
239
+ if languages:
240
+ event_properties["languages"] = ",".join(languages[:10]) # Limit to 10
241
+ event_properties["languages_count"] = str(len(languages))
242
+
243
+ # Include models if provided
244
+ if models:
245
+ event_properties["models"] = ",".join(models[:10]) # Limit to 10
246
+ event_properties["models_count"] = str(len(models))
247
+
248
+ if properties:
249
+ event_properties.update(properties)
250
+
251
+ return track_event("visualization_view", event_properties)
252
+
253
+
254
+ @_ensure_initialized
255
+ def track_submission_attempt(
256
+ dataset_name: str,
257
+ success: bool,
258
+ error_message: str | None = None
259
+ ) -> None:
260
+
261
+ properties = {
262
+ "dataset_name": dataset_name,
263
+ "success": str(success),
264
+ }
265
+ if error_message:
266
+ properties["error_message"] = error_message[:500] # Limit size
267
+ track_event("submission_attempt", properties)
268
+
269
+
270
+ @_ensure_initialized
271
+ def track_accordion_toggle(accordion_name: str, is_open: bool) -> None:
272
+
273
+ track_event("accordion_toggle", {
274
+ "accordion_name": accordion_name,
275
+ "action": "open" if is_open else "close"
276
+ })
277
+
278
+
279
+ @_ensure_initialized
280
+ def track_metric_tab_view(metric: str) -> None:
281
+
282
+ track_event("metric_tab_view", {"metric": metric})
283
+
284
+
285
+ @_ensure_initialized
286
+ def track_view_mode_change(view_mode: str) -> None:
287
+
288
+ track_event("view_mode_change", {"view_mode": view_mode})
289
+
290
+
291
+ @_ensure_initialized
292
+ def track_region_filter(regions: list[str] | None) -> None:
293
+
294
+ track_filter_change("african_region", regions, tab="asr_metrics")
295
+
296
+
297
+ # =============================================================================
298
+ # Metrics - Track numeric measurements
299
+ # =============================================================================
300
+
301
+ def _get_counter(name: str, description: str = "") -> Any:
302
+
303
+ if not _meter:
304
+ return None
305
+ if name not in _counters:
306
+ _counters[name] = _meter.create_counter(
307
+ name,
308
+ description=description,
309
+ unit="1"
310
+ )
311
+ return _counters[name]
312
+
313
+
314
+ def _get_histogram(name: str, description: str = "", unit: str = "ms") -> Any:
315
+
316
+ if not _meter:
317
+ return None
318
+ if name not in _histograms:
319
+ _histograms[name] = _meter.create_histogram(
320
+ name,
321
+ description=description,
322
+ unit=unit
323
+ )
324
+ return _histograms[name]
325
+
326
+
327
+ @_ensure_initialized
328
+ def track_metric(
329
+ name: str,
330
+ value: float,
331
+ attributes: dict[str, str] | None = None,
332
+ metric_type: str = "histogram"
333
+ ) -> None:
334
+
335
+ attrs = attributes or {}
336
+
337
+ if metric_type == "counter":
338
+ counter = _get_counter(name, f"Counter for {name}")
339
+ if counter:
340
+ counter.add(int(value), attrs)
341
+ else:
342
+ histogram = _get_histogram(name, f"Histogram for {name}")
343
+ if histogram:
344
+ histogram.record(value, attrs)
345
+
346
+
347
+ @_ensure_initialized
348
+ def increment_counter(name: str, attributes: dict[str, str] | None = None) -> None:
349
+
350
+ track_metric(name, 1, attributes, metric_type="counter")
351
+
352
+
353
+ # =============================================================================
354
+ # Error Tracking - Track exceptions and errors
355
+ # =============================================================================
356
+
357
+ @_ensure_initialized
358
+ def track_error(
359
+ exception: Exception | None = None,
360
+ properties: dict[str, Any] | None = None,
361
+ context: str | None = None
362
+ ) -> None:
363
+
364
+ # Debug logging
365
+ exc_info = f"{type(exception).__name__}: {exception}" if exception else "No exception"
366
+ _debug_log(f"ERROR: context={context} | {exc_info}")
367
+
368
+ if not _tracer:
369
+ return
370
+
371
+ from opentelemetry.trace import SpanKind
372
+
373
+ error_properties = properties or {}
374
+ if context:
375
+ error_properties["context"] = context
376
+
377
+ with _tracer.start_as_current_span(
378
+ f"error_{context or 'unknown'}",
379
+ kind=SpanKind.INTERNAL
380
+ ) as span:
381
+ if exception:
382
+ span.record_exception(exception)
383
+ error_properties["exception_type"] = type(exception).__name__
384
+ error_properties["exception_message"] = str(exception)[:500]
385
+
386
+ for key, value in error_properties.items():
387
+ span.set_attribute(f"error.{key}", str(value))
388
+
389
+
390
+ # =============================================================================
391
+ # Tracing - Track operations with spans
392
+ # =============================================================================
393
+
394
+ @contextmanager
395
+ def start_span(name: str, attributes: dict[str, str] | None = None):
396
+
397
+ if not _tracer or not _telemetry_initialized:
398
+ yield None
399
+ return
400
+
401
+ with _tracer.start_as_current_span(name) as span:
402
+ if attributes:
403
+ for key, value in attributes.items():
404
+ span.set_attribute(key, str(value))
405
+ yield span
406
+
407
+
408
+ # =============================================================================
409
+ # Utility Functions
410
+ # =============================================================================
411
+
412
+ def is_telemetry_enabled() -> bool:
413
+
414
+ return _telemetry_initialized
415
+
416
+
417
+ def get_telemetry_status() -> dict[str, Any]:
418
+
419
+ return {
420
+ "enabled": TELEMETRY_ENABLED,
421
+ "initialized": _telemetry_initialized,
422
+ "connection_string_set": bool(APPLICATIONINSIGHTS_CONNECTION_STRING),
423
+ "cloud_role_name": CLOUD_ROLE_NAME,
424
+ }
425
+
426
+
427
+ # =============================================================================
428
+ # Convenience wrapper for tracking in Gradio callbacks
429
+ # =============================================================================
430
+
431
+ def with_telemetry(event_name: str, **event_props):
432
+
433
+ def decorator(func: Callable) -> Callable:
434
+ @wraps(func)
435
+ def wrapper(*args, **kwargs):
436
+ track_event(event_name, event_props)
437
+ return func(*args, **kwargs)
438
+ return wrapper
439
+ return decorator
440
+
441
+
442
+ def init_session_from_request(request) -> str:
443
+
444
+ session_hash = None
445
+ if request:
446
+ # Try to get session_hash from gr.Request object
447
+ session_hash = getattr(request, 'session_hash', None)
448
+ if not session_hash:
449
+ # Fallback: try to get from request headers or query params
450
+ session_hash = getattr(request, 'username', None)
451
+ return set_session_context(session_hash)
452
+
453
+
454
+ def track_page_load(page_name: str, request=None) -> str | None:
455
+
456
+ if request:
457
+ init_session_from_request(request)
458
+ return track_page_view(page_name)
459
+
460
+
461
+ # Initialize telemetry on module load if enabled
462
+ if TELEMETRY_ENABLED:
463
+ _initialize_telemetry()
src/visualizations.py ADDED
@@ -0,0 +1,1193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PazaBench Visualization Functions for Gradio Integration
3
+ """
4
+
5
+ import os
6
+
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
9
+
10
+ from src.constants import (
11
+ AFRICAN_COUNTRIES,
12
+ COUNTRY_NAMES,
13
+ LANGUAGE_COUNTRY_MAP
14
+ )
15
+ from src.model_counts import MODEL_PARAMETER_COUNTS
16
+
17
+
18
+ # Load model family colors from CSV
19
+ def _load_model_family_colors() -> tuple[dict[str, str], dict[str, str]]:
20
+ """
21
+ Load color mappings from the model_family_colors.csv file.
22
+ Returns:
23
+ - model_family_colors: dict mapping model_family -> color (first color for that family)
24
+ - model_id_colors: dict mapping model_id -> color
25
+ """
26
+ csv_path = os.path.join(os.path.dirname(__file__), 'display', 'model_family_colors.csv')
27
+ model_family_colors = {}
28
+ model_id_colors = {}
29
+
30
+ try:
31
+ df = pd.read_csv(csv_path)
32
+ for _, row in df.iterrows():
33
+ family = row['Model Families']
34
+ model_id = row['Model ID']
35
+ color = row['Color']
36
+
37
+ # Store first color encountered for each family (using normalized key)
38
+ normalized_family = _normalize_family_name(family)
39
+ if normalized_family not in model_family_colors:
40
+ model_family_colors[normalized_family] = color
41
+
42
+ # Store color for each model ID
43
+ model_id_colors[model_id] = color
44
+ except Exception as e:
45
+ print(f"Warning: Could not load model family colors: {e}")
46
+
47
+ return model_family_colors, model_id_colors
48
+
49
+
50
+ def _normalize_family_name(family: str) -> str:
51
+ """Normalize family name for consistent lookup (lowercase, remove underscores/dashes)."""
52
+ return family.lower().replace('_', '').replace('-', '').replace(' ', '')
53
+
54
+
55
+ MODEL_FAMILY_COLORS, MODEL_ID_COLORS = _load_model_family_colors()
56
+
57
+
58
+ def _get_color_for_family(family: str) -> str:
59
+ """Get color for a model family, with fallback."""
60
+ normalized = _normalize_family_name(family)
61
+ return MODEL_FAMILY_COLORS.get(normalized, '#888888')
62
+
63
+
64
+ def _get_color_for_model(model_id: str) -> str:
65
+ """Get color for a specific model ID, with fallback to family color."""
66
+ if model_id in MODEL_ID_COLORS:
67
+ return MODEL_ID_COLORS[model_id]
68
+ # Try to find family and use family color
69
+ for family, color in MODEL_FAMILY_COLORS.items():
70
+ if family.lower() in model_id.lower():
71
+ return color
72
+ return '#888888'
73
+
74
+
75
+ def _remove_wer_outliers(df: pd.DataFrame, multiplier: float = 1.5) -> pd.DataFrame:
76
+ """
77
+ Remove WER outliers using IQR method for cleaner visualizations.
78
+ Only removes HIGH outliers (poor performers), keeps LOW outliers (best performers).
79
+
80
+ Args:
81
+ df: DataFrame with 'wer' column
82
+ multiplier: IQR multiplier (default 1.5 for standard outlier detection)
83
+
84
+ Returns:
85
+ DataFrame with high outliers removed
86
+ """
87
+ if df.empty or 'wer' not in df.columns:
88
+ return df
89
+ Q1 = df['wer'].quantile(0.25)
90
+ Q3 = df['wer'].quantile(0.75)
91
+ IQR = Q3 - Q1
92
+ # Only remove HIGH outliers (poor performers), keep LOW outliers (best performers)
93
+ # Lower WER is better, so we don't want to remove low-WER entries
94
+ upper_bound = Q3 + multiplier * IQR
95
+ return df[df['wer'] <= upper_bound]
96
+
97
+
98
+ def _get_language_sample_counts(asr_df: pd.DataFrame | None = None) -> dict[str, int]:
99
+ """Derive per-language sample counts from ASR results; returns {} when data is unavailable."""
100
+ if asr_df is None or asr_df.empty:
101
+ print("No ASR data available, using default language sample counts.")
102
+ return {}
103
+ if 'language' not in asr_df.columns or 'num_samples' not in asr_df.columns:
104
+ print("Required columns not found in ASR data.")
105
+ return {}
106
+
107
+ dedupe_keys = [column for column in ['language', 'dataset_group', 'split'] if column in asr_df.columns]
108
+ if not dedupe_keys:
109
+ dedupe_keys = ['language']
110
+
111
+ sample_frame = asr_df[[column for column in set(dedupe_keys + ['num_samples'])]].copy()
112
+ sample_frame['num_samples'] = pd.to_numeric(sample_frame['num_samples'], errors='coerce').fillna(0)
113
+ sample_frame = sample_frame.dropna(subset=['language'])
114
+ sample_frame = sample_frame[sample_frame['language'] != 'Unknown']
115
+
116
+ if sample_frame.empty:
117
+ print("No valid language data found, using default language sample counts.")
118
+ return {}
119
+
120
+ # Dedupe across repeated model rows by taking the max split count before summing by language.
121
+ unique_counts = sample_frame.groupby(dedupe_keys, dropna=False)['num_samples'].max().reset_index()
122
+ by_language = unique_counts.groupby('language')['num_samples'].sum()
123
+
124
+ counts = {
125
+ str(language): int(value)
126
+ for language, value in by_language.items()
127
+ if pd.notna(value) and float(value) > 0
128
+ }
129
+ return counts
130
+
131
+
132
+ def create_language_coverage_chart(
133
+ selected_languages: list[str] | None = None,
134
+ asr_df: pd.DataFrame | None = None,
135
+ ) -> go.Figure:
136
+ """
137
+ Create a horizontal bar chart showing sample counts for each language in PazaBench.
138
+ Languages are sorted by sample count (descending).
139
+ Selected languages are highlighted with a different color.
140
+
141
+ Args:
142
+ selected_languages: List of languages to highlight (None = no highlighting)
143
+ """
144
+ # Create dataframe from language sample counts
145
+ language_counts = _get_language_sample_counts(asr_df)
146
+ df = pd.DataFrame([
147
+ {"Language": lang, "Sample Count": count}
148
+ for lang, count in language_counts.items()
149
+ ])
150
+
151
+ # Sort by sample count descending
152
+ df = df.sort_values("Sample Count", ascending=True) # ascending=True for horizontal bar (bottom to top)
153
+
154
+ # Determine colors - highlight selected languages
155
+ if selected_languages and len(selected_languages) > 0:
156
+ colors = [
157
+ "#0078D4" if lang in selected_languages else "#D0E8FF"
158
+ for lang in df["Language"]
159
+ ]
160
+ # Add border to selected bars
161
+ line_widths = [2 if lang in selected_languages else 0 for lang in df["Language"]]
162
+ line_colors = ["#005A9E" if lang in selected_languages else "rgba(0,0,0,0)" for lang in df["Language"]]
163
+ # Show the sample size on the selected bars.
164
+ bar_text = [
165
+ f"{count:,}" if lang in selected_languages else ""
166
+ for lang, count in zip(df["Language"], df["Sample Count"])
167
+ ]
168
+ else:
169
+ colors = "#8CD0FF" # Solid blue color matching theme
170
+ line_widths = 0
171
+ line_colors = "rgba(0,0,0,0)"
172
+ bar_text = None
173
+
174
+ fig = go.Figure(go.Bar(
175
+ y=df["Language"],
176
+ x=df["Sample Count"],
177
+ orientation='h',
178
+ marker=dict(
179
+ color=colors,
180
+ line=dict(width=line_widths, color=line_colors) if selected_languages else None,
181
+ ),
182
+ text=bar_text,
183
+ textposition='outside',
184
+ textfont=dict(size=10),
185
+ cliponaxis=False,
186
+ hovertemplate='<b>%{y}</b><br>Samples: %{x:,}<extra></extra>',
187
+ ))
188
+
189
+ # Build title
190
+ if selected_languages and len(selected_languages) > 0:
191
+ title_text = f"Language Coverage in PazaBench ({len(selected_languages)} selected)"
192
+ else:
193
+ title_text = "Language Coverage in PazaBench"
194
+
195
+ # One row per language (all labels visible); the container is scrollable in the UI.
196
+ chart_height = max(600, 26 * len(df) + 140)
197
+
198
+ fig.update_layout(
199
+ title=dict(
200
+ text=title_text,
201
+ font=dict(size=16),
202
+ x=0.5
203
+ ),
204
+ xaxis_title="Number of Samples",
205
+ yaxis=dict(
206
+ title="",
207
+ tickmode="linear",
208
+ dtick=1,
209
+ tickfont=dict(size=11),
210
+ automargin=True,
211
+ ),
212
+ height=chart_height,
213
+ autosize=True,
214
+ margin=dict(l=120, r=70, t=60, b=40),
215
+ template='plotly_white',
216
+ showlegend=False,
217
+ )
218
+
219
+ return fig
220
+
221
+
222
+ def _wrap_languages(languages: list[str], per_line: int = 3) -> str:
223
+ """Wrap a language list across multiple hover lines so it fits inside the map."""
224
+ if not languages:
225
+ return "—"
226
+ return "<br>".join(
227
+ ", ".join(languages[i:i + per_line]) for i in range(0, len(languages), per_line)
228
+ )
229
+
230
+
231
+ def create_language_location_map(languages: str | list[str] | None = None) -> go.Figure:
232
+ """
233
+ Create an interactive choropleth map of Africa. Countries with benchmark data are
234
+ highlighted (solid blue) with the languages spoken there wrapped in the tooltip;
235
+ countries without data show "No coverage". When language(s) are selected, the
236
+ countries where they are spoken are highlighted and the remaining data countries
237
+ are shown in a lighter shade.
238
+
239
+ Args:
240
+ languages: The language(s) to highlight on the map (single string or list)
241
+ """
242
+ fig = go.Figure()
243
+
244
+ if isinstance(languages, str):
245
+ languages = [languages]
246
+
247
+ # Which languages are spoken in each country that has benchmark data.
248
+ country_language_map = {}
249
+ for lang, codes in LANGUAGE_COUNTRY_MAP.items():
250
+ for code in codes:
251
+ country_language_map.setdefault(code, []).append(lang)
252
+ data_codes = set(country_language_map)
253
+
254
+ selected = bool(languages)
255
+ if selected:
256
+ highlight_codes = {code for lang in languages for code in LANGUAGE_COUNTRY_MAP.get(lang, [])}
257
+ else:
258
+ highlight_codes = set(data_codes)
259
+
260
+ # Trace 1 — African countries with no benchmark data: light grey, "No coverage".
261
+ no_coverage = [code for code in AFRICAN_COUNTRIES if code not in data_codes]
262
+ if no_coverage:
263
+ fig.add_trace(go.Choropleth(
264
+ locations=no_coverage,
265
+ z=[0] * len(no_coverage),
266
+ text=[AFRICAN_COUNTRIES.get(code, code) for code in no_coverage],
267
+ hovertemplate="<b>%{text}</b><br>No coverage<extra></extra>",
268
+ colorscale=[[0, "#EDF1F5"], [1, "#EDF1F5"]],
269
+ showscale=False,
270
+ marker_line_color="white",
271
+ marker_line_width=0.5,
272
+ ))
273
+
274
+ # Trace 2 — data countries not currently highlighted (selected view only):
275
+ # lighter blue, still surface their languages so users know they have coverage.
276
+ if selected:
277
+ other = sorted(data_codes - highlight_codes)
278
+ if other:
279
+ fig.add_trace(go.Choropleth(
280
+ locations=other,
281
+ z=[0] * len(other),
282
+ text=[COUNTRY_NAMES.get(code, code) for code in other],
283
+ customdata=[_wrap_languages(sorted(country_language_map[code])) for code in other],
284
+ hovertemplate="<b>%{text}</b><br>Languages: %{customdata}<extra></extra>",
285
+ colorscale=[[0, "#DCEEFB"], [1, "#DCEEFB"]],
286
+ showscale=False,
287
+ marker_line_color="white",
288
+ marker_line_width=0.5,
289
+ ))
290
+
291
+ # Trace 3 — highlighted countries: solid blue with a wrapped language tooltip.
292
+ highlight = sorted(highlight_codes)
293
+ if highlight:
294
+ customdata = []
295
+ for code in highlight:
296
+ if selected:
297
+ langs = [lang for lang in languages if code in LANGUAGE_COUNTRY_MAP.get(lang, [])]
298
+ else:
299
+ langs = sorted(country_language_map.get(code, []))
300
+ customdata.append(_wrap_languages(langs))
301
+ fig.add_trace(go.Choropleth(
302
+ locations=highlight,
303
+ z=[1] * len(highlight),
304
+ text=[COUNTRY_NAMES.get(code, code) for code in highlight],
305
+ customdata=customdata,
306
+ hovertemplate="<b>%{text}</b><br>Languages: %{customdata}<extra></extra>",
307
+ colorscale=[[0, "#8CD0FF"], [1, "#8CD0FF"]],
308
+ showscale=False,
309
+ marker_line_color="white",
310
+ marker_line_width=0.5,
311
+ ))
312
+
313
+ if selected and highlight:
314
+ if len(languages) == 1:
315
+ title_text = f"Where {languages[0]} is spoken"
316
+ else:
317
+ title_text = f"Where {len(languages)} selected languages are spoken"
318
+ else:
319
+ title_text = "Select a language to explore"
320
+
321
+ fig.update_geos(
322
+ visible=True,
323
+ resolution=50,
324
+ scope="africa",
325
+ showcountries=True,
326
+ countrycolor="lightgray",
327
+ showcoastlines=True,
328
+ coastlinecolor="gray",
329
+ showland=True,
330
+ landcolor="#f5f5f5",
331
+ showocean=True,
332
+ oceancolor="#e3f2fd",
333
+ showlakes=True,
334
+ lakecolor="#e3f2fd",
335
+ projection_type="natural earth",
336
+ center=dict(lat=5, lon=20),
337
+ )
338
+
339
+ fig.update_layout(
340
+ title=dict(
341
+ text=title_text,
342
+ font=dict(size=16),
343
+ x=0.5
344
+ ),
345
+ height=600,
346
+ autosize=True,
347
+ margin=dict(l=5, r=5, t=50, b=5),
348
+ geo=dict(
349
+ bgcolor="rgba(0,0,0,0)",
350
+ )
351
+ )
352
+
353
+ # Use SVG renderer for better resolution
354
+ fig.update_layout(
355
+ template="plotly_white",
356
+ )
357
+
358
+ return fig
359
+
360
+
361
+ def get_language_sample_info(languages: str | list[str] | None = None, asr_df: pd.DataFrame | None = None) -> str:
362
+ """
363
+ Get the sample count information for specific language(s) as styled HTML.
364
+ Returns HTML for display in Gradio.
365
+
366
+ Args:
367
+ languages: The language(s) to get information for (single string or list)
368
+ asr_df: DataFrame with ASR results to extract dataset groups
369
+ """
370
+ # Normalize to list
371
+ if isinstance(languages, str):
372
+ languages = [languages]
373
+
374
+ language_counts = _get_language_sample_counts(asr_df)
375
+
376
+ if languages and len(languages) > 0:
377
+ # Filter to valid languages
378
+ valid_languages = [lang for lang in languages if lang in language_counts]
379
+
380
+ if valid_languages:
381
+ # Aggregate data across all selected languages
382
+ total_samples = sum(language_counts.get(lang, 0) for lang in valid_languages)
383
+ all_countries = set()
384
+ for lang in valid_languages:
385
+ all_countries.update(LANGUAGE_COUNTRY_MAP.get(lang, []))
386
+ country_names = sorted([COUNTRY_NAMES.get(code, code) for code in all_countries])
387
+
388
+ # Get dataset groups from ASR data if available
389
+ dataset_groups = set()
390
+ if asr_df is not None and not asr_df.empty and 'language' in asr_df.columns:
391
+ for lang in valid_languages:
392
+ lang_data = asr_df[asr_df['language'] == lang]
393
+ if not lang_data.empty and 'dataset_group' in lang_data.columns:
394
+ dataset_groups.update(lang_data['dataset_group'].unique().tolist())
395
+ dataset_groups = sorted(dataset_groups)
396
+
397
+ # Build title based on number of languages
398
+ if len(valid_languages) == 1:
399
+ title = f"🌍 {valid_languages[0]}"
400
+ else:
401
+ title = f"🌍 {', '.join(sorted(valid_languages))}"
402
+
403
+ html = f"""
404
+ <div style="background: linear-gradient(135deg, #DDF1FF 0%, #ffffff 100%); border-radius: 12px; padding: 20px; border: 1px solid #8CD0FF;">
405
+ <h4 style="margin: 0 0 16px 0; color: #0f172a; font-size: 1.1em; font-weight: 600; border-bottom: 2px solid #8CD0FF; padding-bottom: 8px;">
406
+ {title}
407
+ </h4>
408
+
409
+ <div style="display: grid; gap: 12px;">
410
+ <div style="background: white; padding: 12px 16px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
411
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📊 Total Samples</span>
412
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">{total_samples:,}</span>
413
+ </div>
414
+
415
+ <div style="background: white; padding: 12px 16px; border-radius: 8px;">
416
+ <div style="color: #666; font-weight: 500; margin-bottom: 6px; font-size: 0.9rem;">📍 Countries ({len(country_names)})</div>
417
+ <div style="color: #0f172a; font-weight: 500; font-size: 0.9rem;">{', '.join(country_names) if country_names else 'N/A'}</div>
418
+ </div>
419
+
420
+ <div style="background: white; padding: 12px 16px; border-radius: 8px;">
421
+ <div style="color: #666; font-weight: 500; margin-bottom: 6px; font-size: 0.9rem;">📁 Dataset Sources ({len(dataset_groups)})</div>
422
+ <div style="color: #0f172a; font-size: 0.9rem;">
423
+ {(', '.join(dataset_groups) if dataset_groups else '<em>No dataset info available</em>')}
424
+ </div>
425
+ </div>
426
+ </div>
427
+ </div>
428
+ """
429
+ return html
430
+
431
+ # Default view - show sample overview summary
432
+ total_languages = len(language_counts)
433
+ total_samples = sum(language_counts.values())
434
+ total_countries = len(set(code for codes in LANGUAGE_COUNTRY_MAP.values() for code in codes))
435
+
436
+ html = f"""
437
+ <div style="background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%); border-radius: 12px; padding: 20px; border: 1px solid #e0e7ef;">
438
+ <h4 style="margin: 0 0 16px 0; color: #0f172a; font-size: 1.1em; font-weight: 600; border-bottom: 2px solid #8CD0FF; padding-bottom: 8px;">
439
+ 📊 Sample Overview
440
+ </h4>
441
+
442
+ <div style="display: grid; gap: 10px; margin-bottom: 16px;">
443
+ <div style="background: white; padding: 10px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
444
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">🌍 Languages</span>
445
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">{total_languages}</span>
446
+ </div>
447
+ <div style="background: white; padding: 10px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
448
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📊 Total Samples</span>
449
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">{total_samples:,}</span>
450
+ </div>
451
+ <div style="background: white; padding: 10px 14px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px solid #e5e7eb;">
452
+ <span style="color: #666; font-weight: 500; font-size: 0.9rem;">📍 Countries</span>
453
+ <span style="color: #0f172a; font-weight: 700; font-size: 0.9rem;">{total_countries}</span>
454
+ </div>
455
+ </div>
456
+
457
+ <p style="margin: 14px 0 0 0; font-size: 0.85rem; color: #666; font-style: italic; text-align: center;">
458
+ 👈 Select a language on the left to explore its details
459
+ </p>
460
+ </div>
461
+ """
462
+
463
+ return html
464
+
465
+
466
+ def get_language_sample_info_df(language: str | None = None, asr_df: pd.DataFrame | None = None) -> pd.DataFrame:
467
+ """Return language sample info as a DataFrame."""
468
+ language_counts = _get_language_sample_counts(asr_df)
469
+ if language and language in language_counts:
470
+ sample_count = language_counts[language]
471
+ countries = LANGUAGE_COUNTRY_MAP.get(language, [])
472
+ country_names = [COUNTRY_NAMES.get(code, code) for code in countries]
473
+
474
+ df = pd.DataFrame({
475
+ "Metric": ["Language", "Total Samples", "Countries"],
476
+ "Value": [
477
+ language,
478
+ f"{sample_count:,}",
479
+ ", ".join(country_names) if country_names else "N/A"
480
+ ]
481
+ })
482
+ else:
483
+ df = pd.DataFrame({
484
+ "Metric": ["Language", "Total Samples", "Countries"],
485
+ "Value": ["Select a language", "-", "-"]
486
+ })
487
+
488
+ return df
489
+
490
+
491
+ def get_all_languages(asr_df: pd.DataFrame | None = None) -> list[str]:
492
+ """Get a sorted list of all languages in PazaBench."""
493
+ return sorted(_get_language_sample_counts(asr_df).keys())
494
+
495
+
496
+ def create_africa_language_map() -> go.Figure:
497
+ """
498
+ Create an interactive choropleth map of Africa showing language coverage.
499
+ Hover over countries to see the languages spoken there.
500
+ """
501
+ # Build country data with languages
502
+ country_data = {}
503
+ for language, countries in LANGUAGE_COUNTRY_MAP.items():
504
+ for country_code in countries:
505
+ if country_code not in country_data:
506
+ country_data[country_code] = {
507
+ "languages": [],
508
+ "count": 0,
509
+ "country_name": COUNTRY_NAMES.get(country_code, country_code)
510
+ }
511
+ country_data[country_code]["languages"].append(language)
512
+ country_data[country_code]["count"] += 1
513
+
514
+ # Create dataframe for plotly
515
+ df_map = pd.DataFrame([
516
+ {
517
+ "country_code": code,
518
+ "country_name": data["country_name"],
519
+ "language_count": data["count"],
520
+ "languages": ", ".join(sorted(data["languages"]))
521
+ }
522
+ for code, data in country_data.items()
523
+ ])
524
+
525
+ fig = go.Figure(go.Choropleth(
526
+ locations=df_map["country_code"],
527
+ z=df_map["language_count"],
528
+ text=df_map["country_name"],
529
+ customdata=df_map[["languages", "language_count"]],
530
+ hovertemplate="<b>%{text}</b><br>" +
531
+ "Languages: %{customdata[1]}<br>" +
532
+ "<i>%{customdata[0]}</i><extra></extra>",
533
+ colorscale=[
534
+ [0, "#DDF1FF"],
535
+ [0.25, "#B5E0FF"],
536
+ [0.5, "#8CD0FF"],
537
+ [0.75, "#6BC0F5"],
538
+ [1, "#4AAFEB"]
539
+ ],
540
+ showscale=True,
541
+ colorbar=dict(
542
+ title="Languages",
543
+ tickmode="linear",
544
+ tick0=1,
545
+ dtick=1
546
+ ),
547
+ marker_line_color="white",
548
+ marker_line_width=0.5,
549
+ ))
550
+
551
+ fig.update_geos(
552
+ visible=True,
553
+ resolution=50,
554
+ scope="africa",
555
+ showcountries=True,
556
+ countrycolor="lightgray",
557
+ showcoastlines=True,
558
+ coastlinecolor="gray",
559
+ showland=True,
560
+ landcolor="#f5f5f5",
561
+ showocean=True,
562
+ oceancolor="#e3f2fd",
563
+ showlakes=True,
564
+ lakecolor="#e3f2fd",
565
+ projection_type="natural earth",
566
+ center=dict(lat=5, lon=20),
567
+ )
568
+
569
+ fig.update_layout(
570
+ title=dict(
571
+ text="African Languages in PazaBench",
572
+ font=dict(size=18),
573
+ x=0.5
574
+ ),
575
+ height=600,
576
+ autosize=True,
577
+ margin=dict(l=5, r=5, t=50, b=5),
578
+ geo=dict(
579
+ bgcolor="rgba(0,0,0,0)",
580
+ ),
581
+ template="plotly_white",
582
+ )
583
+
584
+ return fig
585
+
586
+
587
+ def create_model_leaderboard(df: pd.DataFrame, languages: list[str] | None = None, top_n_models: int = 15) -> go.Figure:
588
+ """
589
+ Visualization 1: Model Family / Individual Model Performance Leaderboard
590
+ - When no language filter: Shows model families (aggregated)
591
+ - When language(s) selected: Shows top N individual models
592
+
593
+ Outliers are removed for cleaner visualization.
594
+
595
+ Args:
596
+ df: DataFrame with evaluation results
597
+ languages: List of languages to filter by (None = all languages)
598
+ top_n_models: Number of top individual models to show when languages are filtered (default: 15)
599
+ """
600
+ # Apply language filter if provided
601
+ filtered_df = df.copy()
602
+ if languages:
603
+ filtered_df = filtered_df[filtered_df['language'].isin(languages)]
604
+
605
+ # Remove WER outliers for cleaner visualization
606
+ filtered_df = _remove_wer_outliers(filtered_df)
607
+
608
+ # Determine mode: individual models if languages selected, otherwise model families
609
+ show_individual_models = languages is not None and len(languages) > 0
610
+
611
+ if show_individual_models:
612
+ # Individual model mode: show top N models by median WER
613
+ model_perf = filtered_df.groupby(['model_family', 'model']).agg({
614
+ 'wer': ['median', 'std', 'count'],
615
+ 'cer': 'median',
616
+ 'rtfx': 'median'
617
+ }).reset_index()
618
+
619
+ # Get unique sample counts per model
620
+ unique_samples = filtered_df.groupby(['model_family', 'model', 'language', 'dataset_group'])['num_samples'].first().reset_index()
621
+ model_samples = unique_samples.groupby(['model_family', 'model'])['num_samples'].sum().reset_index()
622
+ model_samples.columns = ['model_family', 'model', 'total_samples']
623
+
624
+ model_perf.columns = ['model_family', 'model', 'wer_median', 'wer_std', 'count', 'cer_median', 'rtfx_median']
625
+ model_perf = model_perf.merge(model_samples, on=['model_family', 'model'], how='left')
626
+ model_perf = model_perf.sort_values('wer_median').head(top_n_models)
627
+
628
+ # Create short model name for display
629
+ model_perf['model_short'] = model_perf['model'].apply(
630
+ lambda x: x.split('/')[-1] if '/' in x else x
631
+ )
632
+
633
+ # Get colors for each model based on family
634
+ model_perf['color'] = model_perf['model_family'].apply(_get_color_for_family)
635
+
636
+ fig = go.Figure()
637
+
638
+ fig.add_trace(go.Bar(
639
+ y=model_perf['model_short'],
640
+ x=model_perf['wer_median'],
641
+ orientation='h',
642
+ marker=dict(color=model_perf['color']),
643
+ text=model_perf['wer_median'].round(2),
644
+ textposition='outside',
645
+ hovertemplate=(
646
+ '<b>%{customdata[0]}</b><br>' +
647
+ '<i>Family: %{customdata[1]}</i><br><br>' +
648
+ '<b>Median WER:</b> %{x:.3f}<br>' +
649
+ '<b>RTFx:</b> %{customdata[2]:.1f}<br>' +
650
+ '<b>Evaluations:</b> %{customdata[3]}<br>' +
651
+ '<b>Samples:</b> %{customdata[4]:,}<extra></extra>'
652
+ ),
653
+ customdata=model_perf[['model', 'model_family', 'rtfx_median', 'count', 'total_samples']]
654
+ ))
655
+
656
+ # Build title with language info (languages is guaranteed to be non-empty here)
657
+ lang_list = languages if languages else []
658
+ lang_str = ", ".join(lang_list[:3]) + ("..." if len(lang_list) > 3 else "")
659
+ title_text = f"Top {min(top_n_models, len(model_perf))} Models for {lang_str}"
660
+
661
+ else:
662
+ # Model family mode
663
+ model_perf = filtered_df.groupby('model_family').agg({
664
+ 'wer': ['median', 'std', 'count'],
665
+ 'cer': 'median',
666
+ 'rtfx': 'median'
667
+ }).reset_index()
668
+
669
+ # Get unique sample counts per model family (avoid double-counting across models)
670
+ unique_samples = filtered_df.groupby(['model_family', 'language', 'dataset_group'])['num_samples'].first().reset_index()
671
+ family_samples = unique_samples.groupby('model_family')['num_samples'].sum().reset_index()
672
+ family_samples.columns = ['model_family', 'total_samples']
673
+
674
+ model_perf.columns = ['model_family', 'wer_median', 'wer_std', 'count', 'cer_median', 'rtfx_median']
675
+ model_perf = model_perf.merge(family_samples, on='model_family', how='left')
676
+ model_perf = model_perf.sort_values('wer_median')
677
+
678
+ # Get colors for each model family
679
+ model_perf['color'] = model_perf['model_family'].apply(_get_color_for_family)
680
+
681
+ fig = go.Figure()
682
+
683
+ fig.add_trace(go.Bar(
684
+ y=model_perf['model_family'],
685
+ x=model_perf['wer_median'],
686
+ orientation='h',
687
+ error_x=dict(type='data', array=model_perf['wer_std']),
688
+ marker=dict(color=model_perf['color']),
689
+ text=model_perf['wer_median'].round(2),
690
+ textposition='outside',
691
+ hovertemplate=(
692
+ '<b>%{y}</b><br><br>' +
693
+ '<b>Median WER:</b> %{x:.3f}<br>' +
694
+ '<b>Std Dev:</b> %{customdata[0]:.3f}<br>' +
695
+ '<b>RTFx:</b> %{customdata[1]:.1f}<br>' +
696
+ '<b>Evaluations:</b> %{customdata[2]}<br>' +
697
+ '<b>Samples:</b> %{customdata[3]:,}<extra></extra>'
698
+ ),
699
+ customdata=model_perf[['wer_std', 'rtfx_median', 'count', 'total_samples']]
700
+ ))
701
+
702
+ title_text = "Model Family Performance Leaderboard"
703
+
704
+ # Calculate dynamic height based on number of items
705
+ num_items = len(model_perf)
706
+ height = max(400, min(700, 100 + num_items * 35))
707
+
708
+ fig.update_layout(
709
+ title=title_text,
710
+ xaxis_title="Word Error Rate (WER)",
711
+ yaxis_title="",
712
+ height=height,
713
+ autosize=True,
714
+ showlegend=False,
715
+ template='plotly_white',
716
+ margin=dict(l=200, r=30, t=60, b=60)
717
+ )
718
+
719
+ return fig
720
+
721
+
722
+ def create_cer_leaderboard(df: pd.DataFrame, languages: list[str] | None = None, top_n_models: int = 15) -> go.Figure:
723
+ """
724
+ Visualization: CER Model Family / Individual Model Performance Leaderboard
725
+ - When no language filter: Shows model families (aggregated)
726
+ - When language(s) selected: Shows top N individual models
727
+
728
+ Outliers are removed for cleaner visualization.
729
+
730
+ Args:
731
+ df: DataFrame with evaluation results
732
+ languages: List of languages to filter by (None = all languages)
733
+ top_n_models: Number of top individual models to show when languages are filtered (default: 15)
734
+ """
735
+ # Apply language filter if provided
736
+ filtered_df = df.copy()
737
+ if languages:
738
+ filtered_df = filtered_df[filtered_df['language'].isin(languages)]
739
+
740
+ # Remove CER outliers for cleaner visualization (similar to WER outlier removal)
741
+ if not filtered_df.empty and 'cer' in filtered_df.columns:
742
+ Q1 = filtered_df['cer'].quantile(0.25)
743
+ Q3 = filtered_df['cer'].quantile(0.75)
744
+ IQR = Q3 - Q1
745
+ upper_bound = Q3 + 1.5 * IQR
746
+ filtered_df = filtered_df[filtered_df['cer'] <= upper_bound]
747
+
748
+ # Determine mode: individual models if languages selected, otherwise model families
749
+ show_individual_models = languages is not None and len(languages) > 0
750
+
751
+ if show_individual_models:
752
+ # Individual model mode: show top N models by median CER
753
+ model_perf = filtered_df.groupby(['model_family', 'model']).agg({
754
+ 'cer': ['median', 'std', 'count'],
755
+ 'wer': 'median',
756
+ 'rtfx': 'median'
757
+ }).reset_index()
758
+
759
+ # Get unique sample counts per model
760
+ unique_samples = filtered_df.groupby(['model_family', 'model', 'language', 'dataset_group'])['num_samples'].first().reset_index()
761
+ model_samples = unique_samples.groupby(['model_family', 'model'])['num_samples'].sum().reset_index()
762
+ model_samples.columns = ['model_family', 'model', 'total_samples']
763
+
764
+ model_perf.columns = ['model_family', 'model', 'cer_median', 'cer_std', 'count', 'wer_median', 'rtfx_median']
765
+ model_perf = model_perf.merge(model_samples, on=['model_family', 'model'], how='left')
766
+ model_perf = model_perf.sort_values('cer_median').head(top_n_models)
767
+
768
+ # Create short model name for display
769
+ model_perf['model_short'] = model_perf['model'].apply(
770
+ lambda x: x.split('/')[-1] if '/' in x else x
771
+ )
772
+
773
+ # Get colors for each model based on family
774
+ model_perf['color'] = model_perf['model_family'].apply(_get_color_for_family)
775
+
776
+ fig = go.Figure()
777
+
778
+ fig.add_trace(go.Bar(
779
+ y=model_perf['model_short'],
780
+ x=model_perf['cer_median'],
781
+ orientation='h',
782
+ marker=dict(color=model_perf['color']),
783
+ text=model_perf['cer_median'].round(2),
784
+ textposition='outside',
785
+ hovertemplate=(
786
+ '<b>%{customdata[0]}</b><br>' +
787
+ '<i>Family: %{customdata[1]}</i><br><br>' +
788
+ '<b>Median CER:</b> %{x:.3f}<br>' +
789
+ '<b>WER:</b> %{customdata[2]:.3f}<br>' +
790
+ '<b>RTFx:</b> %{customdata[3]:.1f}<br>' +
791
+ '<b>Evaluations:</b> %{customdata[4]}<br>' +
792
+ '<b>Samples:</b> %{customdata[5]:,}<extra></extra>'
793
+ ),
794
+ customdata=model_perf[['model', 'model_family', 'wer_median', 'rtfx_median', 'count', 'total_samples']]
795
+ ))
796
+
797
+ # Build title with language info (languages is guaranteed to be non-empty here)
798
+ lang_list = languages if languages else []
799
+ lang_str = ", ".join(lang_list[:3]) + ("..." if len(lang_list) > 3 else "")
800
+ title_text = f"Top {min(top_n_models, len(model_perf))} Models by CER for {lang_str}"
801
+
802
+ else:
803
+ # Model family mode
804
+ model_perf = filtered_df.groupby('model_family').agg({
805
+ 'cer': ['median', 'std', 'count'],
806
+ 'wer': 'median',
807
+ 'rtfx': 'median'
808
+ }).reset_index()
809
+
810
+ # Get unique sample counts per model family (avoid double-counting across models)
811
+ unique_samples = filtered_df.groupby(['model_family', 'language', 'dataset_group'])['num_samples'].first().reset_index()
812
+ family_samples = unique_samples.groupby('model_family')['num_samples'].sum().reset_index()
813
+ family_samples.columns = ['model_family', 'total_samples']
814
+
815
+ model_perf.columns = ['model_family', 'cer_median', 'cer_std', 'count', 'wer_median', 'rtfx_median']
816
+ model_perf = model_perf.merge(family_samples, on='model_family', how='left')
817
+ model_perf = model_perf.sort_values('cer_median')
818
+
819
+ # Get colors for each model family
820
+ model_perf['color'] = model_perf['model_family'].apply(_get_color_for_family)
821
+
822
+ fig = go.Figure()
823
+
824
+ fig.add_trace(go.Bar(
825
+ y=model_perf['model_family'],
826
+ x=model_perf['cer_median'],
827
+ orientation='h',
828
+ error_x=dict(type='data', array=model_perf['cer_std']),
829
+ marker=dict(color=model_perf['color']),
830
+ text=model_perf['cer_median'].round(2),
831
+ textposition='outside',
832
+ hovertemplate=(
833
+ '<b>%{y}</b><br><br>' +
834
+ '<b>Median CER:</b> %{x:.3f}<br>' +
835
+ '<b>Std Dev:</b> %{customdata[0]:.3f}<br>' +
836
+ '<b>WER:</b> %{customdata[1]:.3f}<br>' +
837
+ '<b>RTFx:</b> %{customdata[2]:.1f}<br>' +
838
+ '<b>Evaluations:</b> %{customdata[3]}<br>' +
839
+ '<b>Samples:</b> %{customdata[4]:,}<extra></extra>'
840
+ ),
841
+ customdata=model_perf[['cer_std', 'wer_median', 'rtfx_median', 'count', 'total_samples']]
842
+ ))
843
+
844
+ title_text = "Model Family Performance by CER"
845
+
846
+ # Calculate dynamic height based on number of items
847
+ num_items = len(model_perf)
848
+ height = max(400, min(700, 100 + num_items * 35))
849
+
850
+ fig.update_layout(
851
+ title=title_text,
852
+ xaxis_title="Character Error Rate (CER)",
853
+ yaxis_title="",
854
+ height=height,
855
+ autosize=True,
856
+ showlegend=False,
857
+ template='plotly_white',
858
+ margin=dict(l=200, r=30, t=60, b=60)
859
+ )
860
+
861
+ return fig
862
+
863
+
864
+ def create_speed_accuracy_scatter(df: pd.DataFrame, view_mode: str = "model_family", languages: list[str] | None = None) -> go.Figure:
865
+ """
866
+ Visualization 2: Speed vs Accuracy Tradeoff
867
+ Scatter plot showing the relationship between WER and RTFx with quadrants.
868
+ Outliers are removed for cleaner visualization.
869
+
870
+ Args:
871
+ df: DataFrame with evaluation results
872
+ view_mode: Either "model_family" (bubbles same size per family, color by family) or
873
+ "individual_model" (bubble size = model params, color = model family)
874
+ languages: List of languages to filter by (None = all languages)
875
+ """
876
+ # Apply language filter if provided
877
+ if languages:
878
+ df = df[df['language'].isin(languages)]
879
+
880
+ # Remove WER outliers for cleaner visualization
881
+ df = _remove_wer_outliers(df)
882
+ if view_mode == "individual_model":
883
+ # Aggregate by individual model
884
+ model_agg = df.groupby(['model_family', 'model']).agg({
885
+ 'wer': 'median',
886
+ 'rtfx': 'median',
887
+ 'cer': 'median',
888
+ }).reset_index()
889
+
890
+ # Get unique sample counts per model
891
+ unique_samples = df.groupby(['model_family', 'model', 'language', 'dataset_group'])['num_samples'].first().reset_index()
892
+ model_samples = unique_samples.groupby(['model_family', 'model'])['num_samples'].sum().reset_index()
893
+ model_samples.columns = ['model_family', 'model', 'num_samples']
894
+ model_agg = model_agg.merge(model_samples, on=['model_family', 'model'], how='left')
895
+
896
+ # Get parameter count for each individual model
897
+ model_agg['params'] = model_agg['model'].apply(
898
+ lambda m: MODEL_PARAMETER_COUNTS.get(m, 500_000_000) # Default 500M
899
+ )
900
+ model_agg['params_billions'] = model_agg['params'] / 1_000_000_000
901
+ model_agg['params_display'] = model_agg['params'].apply(
902
+ lambda x: f"{x/1_000_000_000:.1f}B" if x >= 1_000_000_000 else f"{x/1_000_000:.0f}M"
903
+ )
904
+
905
+ # Create short model name for display
906
+ model_agg['model_short'] = model_agg['model'].apply(
907
+ lambda x: x.split('/')[-1] if '/' in x else x
908
+ )
909
+
910
+ fig = go.Figure()
911
+
912
+ # Add scatter traces for each model family
913
+ for family in model_agg['model_family'].unique():
914
+ family_data = model_agg[model_agg['model_family'] == family]
915
+ family_color = _get_color_for_family(family)
916
+ fig.add_trace(go.Scatter(
917
+ x=family_data['wer'],
918
+ y=family_data['rtfx'],
919
+ mode='markers',
920
+ name=family,
921
+ marker=dict(
922
+ size=family_data['params'] / family_data['params'].max() * 50 + 10,
923
+ sizemode='diameter',
924
+ sizemin=8,
925
+ color=family_color
926
+ ),
927
+ customdata=family_data[['model_short', 'cer', 'num_samples', 'params_display']].values,
928
+ hovertemplate=(
929
+ '<b>%{customdata[0]}</b><br><br>' +
930
+ '<b>WER:</b> %{x:.3f}<br>' +
931
+ '<b>RTFx:</b> %{y:.1f}<br>' +
932
+ '<b>CER:</b> %{customdata[1]:.3f}<br>' +
933
+ '<b>Samples:</b> %{customdata[2]:,}<br>' +
934
+ '<b>Parameters:</b> %{customdata[3]}<extra></extra>'
935
+ )
936
+ ))
937
+
938
+ title_text = "Speed vs Accuracy Tradeoff by Individual Model"
939
+ else:
940
+ # Aggregate by model family
941
+ model_agg = df.groupby('model_family').agg({
942
+ 'wer': 'median',
943
+ 'rtfx': 'median',
944
+ 'cer': 'median',
945
+ 'model': 'first' # Get a representative model name for parameter lookup
946
+ }).reset_index()
947
+
948
+ # Get unique sample counts per model family
949
+ unique_samples = df.groupby(['model_family', 'language', 'dataset_group'])['num_samples'].first().reset_index()
950
+ family_samples = unique_samples.groupby('model_family')['num_samples'].sum().reset_index()
951
+ family_samples.columns = ['model_family', 'num_samples']
952
+ model_agg = model_agg.merge(family_samples, on='model_family', how='left')
953
+
954
+ # For model family view, use a uniform size (no bubble size variation)
955
+ # Use a constant for params to make bubbles the same size
956
+ model_agg['params'] = 1_000_000_000 # Use constant 1B for uniform bubble size
957
+ model_agg['params_display'] = 'N/A' # Not applicable in family view
958
+
959
+ fig = go.Figure()
960
+
961
+ # Add scatter traces for each model family
962
+ for family in model_agg['model_family'].unique():
963
+ family_data = model_agg[model_agg['model_family'] == family]
964
+ family_color = _get_color_for_family(family)
965
+ fig.add_trace(go.Scatter(
966
+ x=family_data['wer'],
967
+ y=family_data['rtfx'],
968
+ mode='markers',
969
+ name=family,
970
+ marker=dict(
971
+ size=20,
972
+ sizemode='diameter',
973
+ color=family_color
974
+ ),
975
+ customdata=family_data[['cer', 'num_samples']].values,
976
+ hovertemplate=(
977
+ '<b>' + family + '</b><br><br>' +
978
+ '<b>WER:</b> %{x:.3f}<br>' +
979
+ '<b>RTFx:</b> %{y:.1f}<br>' +
980
+ '<b>CER:</b> %{customdata[0]:.3f}<br>' +
981
+ '<b>Samples:</b> %{customdata[1]:,}<extra></extra>'
982
+ )
983
+ ))
984
+
985
+ title_text = "Speed vs Accuracy Tradeoff by Model Family"
986
+
987
+ # Add quadrant lines at median
988
+ median_wer = model_agg['wer'].median()
989
+ median_rtfx = model_agg['rtfx'].median()
990
+
991
+ fig.add_hline(y=median_rtfx, line_dash="dash", line_color="gray", annotation_text="Median RTFx", annotation_position="right")
992
+ fig.add_vline(x=median_wer, line_dash="dash", line_color="gray", annotation_text="Median WER", annotation_position="top")
993
+
994
+ # Add quadrant label - centered in the "Fast & Accurate" quadrant (low WER, high RTFx)
995
+ # The ideal quadrant is: x from min to median_wer, y from median_rtfx to max
996
+ quadrant_center_x = (model_agg['wer'].min() + median_wer) / 2
997
+ quadrant_center_y = (median_rtfx + model_agg['rtfx'].max()) / 2
998
+
999
+ fig.add_annotation(
1000
+ x=quadrant_center_x,
1001
+ y=quadrant_center_y,
1002
+ text="Fast & Accurate ⭐",
1003
+ showarrow=False,
1004
+ font=dict(size=12, color="green", family="Arial Black")
1005
+ )
1006
+
1007
+ fig.update_layout(
1008
+ title=title_text,
1009
+ xaxis_title="WER",
1010
+ yaxis_title="RTFx",
1011
+ yaxis=dict(rangemode='tozero'), # Ensure y-axis starts at 0 (RTFx can't be negative)
1012
+ height=550,
1013
+ autosize=True,
1014
+ template='plotly_white',
1015
+ legend=dict(
1016
+ orientation="h",
1017
+ yanchor="top",
1018
+ y=-0.15,
1019
+ xanchor="center",
1020
+ x=0.5,
1021
+ font=dict(size=10)
1022
+ ),
1023
+ margin=dict(l=60, r=20, t=50, b=120)
1024
+ )
1025
+
1026
+ return fig
1027
+
1028
+
1029
+ def create_wer_cer_correlation(df: pd.DataFrame, languages: list[str] | None = None, top_n_models: int | None = None) -> go.Figure:
1030
+ """
1031
+ Visualization 7: WER vs CER Correlation
1032
+ Scatter plot showing the relationship between word and character error rates.
1033
+ Defaults to Swahili if no language is specified.
1034
+ Outliers are removed for cleaner visualization.
1035
+
1036
+ Args:
1037
+ df: DataFrame with evaluation results
1038
+ languages: List of languages to filter by (defaults to ["Swahili"] if None)
1039
+ top_n_models: If specified, only show top N models by WER (0 = show all)
1040
+ """
1041
+ # Default to Swahili if no language filter provided
1042
+ if not languages:
1043
+ languages = ["Swahili"]
1044
+
1045
+ # Apply language filter
1046
+ filtered_df = df.copy()
1047
+ filtered_df = filtered_df[filtered_df['language'].isin(languages)]
1048
+
1049
+ # Remove WER outliers for cleaner visualization
1050
+ filtered_df = _remove_wer_outliers(filtered_df)
1051
+
1052
+ # Apply top N models filter if specified
1053
+ if top_n_models and top_n_models > 0:
1054
+ model_wer = filtered_df.groupby('model')['wer'].median().sort_values()
1055
+ top_models = model_wer.head(top_n_models).index.tolist()
1056
+ filtered_df = filtered_df[filtered_df['model'].isin(top_models)]
1057
+
1058
+ fig = go.Figure()
1059
+
1060
+ # Add scatter traces for each model family
1061
+ for family in filtered_df['model_family'].unique():
1062
+ family_data = filtered_df[filtered_df['model_family'] == family]
1063
+ family_color = _get_color_for_family(family)
1064
+ # Normalize size for better visualization
1065
+ max_samples = filtered_df['num_samples'].max() if not filtered_df.empty else 1
1066
+ sizes = (family_data['num_samples'] / max_samples * 25 + 5).values if not family_data.empty else [10]
1067
+
1068
+ fig.add_trace(go.Scatter(
1069
+ x=family_data['wer'],
1070
+ y=family_data['cer'],
1071
+ mode='markers',
1072
+ name=family,
1073
+ marker=dict(
1074
+ size=sizes,
1075
+ sizemode='diameter',
1076
+ sizemin=5,
1077
+ opacity=0.6,
1078
+ color=family_color
1079
+ ),
1080
+ customdata=family_data[['language', 'model', 'dataset_group', 'num_samples']].values,
1081
+ hovertemplate=(
1082
+ '<b>%{customdata[0]}</b><br><br>' +
1083
+ '<b>Model:</b> %{customdata[1]}<br>' +
1084
+ '<b>Dataset:</b> %{customdata[2]}<br>' +
1085
+ '<b>WER:</b> %{x:.3f}<br>' +
1086
+ '<b>CER:</b> %{y:.3f}<br>' +
1087
+ '<b>Samples:</b> %{customdata[3]:,}<extra></extra>'
1088
+ )
1089
+ ))
1090
+
1091
+ # Add trendline
1092
+ if len(filtered_df) > 1:
1093
+ import numpy as np
1094
+ z = np.polyfit(filtered_df['wer'], filtered_df['cer'], 1)
1095
+ p = np.poly1d(z)
1096
+ x_range = np.linspace(filtered_df['wer'].min(), filtered_df['wer'].max(), 100)
1097
+ fig.add_trace(go.Scatter(
1098
+ x=x_range,
1099
+ y=p(x_range),
1100
+ mode='lines',
1101
+ name='Trend',
1102
+ line=dict(color='gray', dash='dash'),
1103
+ hoverinfo='skip'
1104
+ ))
1105
+
1106
+ # Calculate correlation
1107
+ correlation = filtered_df[['wer', 'cer']].corr().iloc[0, 1] if len(filtered_df) > 1 else 0
1108
+
1109
+ # Build title with language info
1110
+ lang_str = ", ".join(languages[:3]) + ("..." if len(languages) > 3 else "")
1111
+ title_text = f"WER vs CER Correlation for {lang_str} (r={correlation:.2f})"
1112
+
1113
+ fig.update_layout(
1114
+ title=title_text,
1115
+ xaxis_title="WER",
1116
+ yaxis_title="CER",
1117
+ height=550,
1118
+ autosize=True,
1119
+ template='plotly_white',
1120
+ legend=dict(
1121
+ orientation="h",
1122
+ yanchor="top",
1123
+ y=-0.15,
1124
+ xanchor="center",
1125
+ x=0.5,
1126
+ font=dict(size=10)
1127
+ ),
1128
+ margin=dict(l=60, r=20, t=50, b=120),
1129
+ # Zoom X-axis to useful range (0-1.5)
1130
+ xaxis=dict(range=[0, 1.5]),
1131
+ yaxis=dict(range=[0, 1.5])
1132
+ )
1133
+
1134
+ return fig
1135
+
1136
+
1137
+ def create_model_consistency(df: pd.DataFrame) -> go.Figure:
1138
+ """
1139
+ Visualization 9: Model Consistency Analysis
1140
+ Shows coefficient of variation (CV) to measure consistency across languages.
1141
+ Removes high outliers only using IQR method (keeps best performers).
1142
+ """
1143
+ # Remove only HIGH outliers using IQR method on WER (keep best performers)
1144
+ Q1 = df['wer'].quantile(0.25)
1145
+ Q3 = df['wer'].quantile(0.75)
1146
+ IQR = Q3 - Q1
1147
+ upper_bound = Q3 + 1.5 * IQR
1148
+ df_no_outliers = df[df['wer'] <= upper_bound]
1149
+
1150
+ model_variance = df_no_outliers.groupby('model_family').agg({
1151
+ 'wer': ['median', 'std', 'count']
1152
+ }).reset_index()
1153
+
1154
+ model_variance.columns = ['model_family', 'wer_median', 'wer_std', 'count']
1155
+ model_variance['cv'] = (model_variance['wer_std'] / model_variance['wer_median'] * 100)
1156
+ model_variance = model_variance.sort_values('cv')
1157
+
1158
+ # Get colors for each model family
1159
+ model_variance['color'] = model_variance['model_family'].apply(_get_color_for_family)
1160
+
1161
+ fig = go.Figure()
1162
+
1163
+ fig.add_trace(go.Bar(
1164
+ y=model_variance['model_family'],
1165
+ x=model_variance['cv'],
1166
+ orientation='h',
1167
+ marker=dict(
1168
+ color=model_variance['color']
1169
+ ),
1170
+ text=model_variance['cv'].round(1),
1171
+ textposition='outside',
1172
+ hovertemplate=(
1173
+ '<b>%{y}</b><br><br>' +
1174
+ '<b>Coefficient of Variation:</b> %{x:.1f}%<br>' +
1175
+ '<b>Median WER:</b> %{customdata[0]:.3f}<br>' +
1176
+ '<b>Std Dev:</b> %{customdata[1]:.3f}<br>' +
1177
+ '<b>Evaluations:</b> %{customdata[2]}<extra></extra>'
1178
+ ),
1179
+ customdata=model_variance[['wer_median', 'wer_std', 'count']]
1180
+ ))
1181
+
1182
+ fig.update_layout(
1183
+ title="Model Consistency Ranking (Outliers Removed)",
1184
+ xaxis_title="Coefficient of Variation (%)",
1185
+ yaxis_title="Model Family",
1186
+ height=550,
1187
+ template='plotly_white',
1188
+ showlegend=False,
1189
+ margin=dict(l=200, r=100, t=80, b=80)
1190
+ )
1191
+
1192
+ return fig
1193
+