whojavumusic commited on
Commit
ee561c5
·
1 Parent(s): 14ce963

new version with new metrics

Browse files
analytics.py CHANGED
@@ -17,6 +17,16 @@ from plotly.colors import qualitative
17
 
18
  from metrics_config import LIVE_SCENARIO_KEYS, SCENARIO_METRICS, metric_by_key
19
 
 
 
 
 
 
 
 
 
 
 
20
  # Consistent height for Gradio Plot
21
  _FIG_HEIGHT = 460
22
  _TEMPLATE = "plotly_white"
@@ -50,9 +60,86 @@ def _cached_model_size_mb(model_id: str) -> float:
50
  return mb
51
 
52
 
53
- def _log_normalize(values: np.ndarray, *, inverse: bool) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  """
55
- Log-normalize an array across all models to a 0-1 "strength" score.
56
 
57
  * `inverse=True` -> lower raw value is better (WER, memory). Strength = 1 - norm.
58
  * `inverse=False` -> higher raw value is better (RTF). Strength = norm.
@@ -64,7 +151,7 @@ def _log_normalize(values: np.ndarray, *, inverse: bool) -> np.ndarray:
64
  mask = np.isfinite(v) & (v > 0)
65
  if not mask.any():
66
  return out
67
- logv = np.log(v[mask])
68
  lo = float(np.min(logv))
69
  hi = float(np.max(logv))
70
  if hi - lo < 1e-9:
@@ -73,6 +160,8 @@ def _log_normalize(values: np.ndarray, *, inverse: bool) -> np.ndarray:
73
  out[mask] = (logv - lo) / (hi - lo)
74
  if inverse:
75
  out[mask] = 1.0 - out[mask]
 
 
76
  return out
77
 
78
 
@@ -82,15 +171,15 @@ def _log_normalize(values: np.ndarray, *, inverse: bool) -> np.ndarray:
82
  # raw_display_name: human-readable name of the underlying metric, shown in the hover tooltip.
83
  # raw_unit : optional unit suffix for the raw value in the tooltip.
84
  #
85
- # Live conditions reflected: Anechoic, Noisy, Treble Eval, Real RIR, Difficult.
86
- # Plus Speed (RTF) and Compactness (weight-file size).
87
  _RADAR_AXES: tuple[tuple[str, str, str | None, bool, str, str], ...] = (
88
- ("Anechoic", "wer_clean", None, True, "Anechoic WER", ""),
89
- ("Noisy", "wer_noisy", None, True, "Noisy WER", ""),
90
- ("Treble Eval", "wer_reverberant", None, True, "Treble Eval WER", ""),
91
- ("Real RIR", "wer_real", None, True, "Real RIR WER", ""),
92
- ("Difficult", "wer_difficult", None, True, "Difficult WER", ""),
93
- ("Speed", "eval_rtf", None, False, "RTF", realtime"),
 
94
  # Compactness: smaller model -> more compact -> farther from centre.
95
  # `inverse=True` makes `_log_normalize` emit `1 - norm`, so the smallest
96
  # observed model maps to strength = 1.0 (radar edge) and the largest
@@ -154,13 +243,17 @@ def _raw_to_analytics_df(raw: list[dict]) -> pd.DataFrame:
154
  """
155
  Parse leaderboard rows; coerce scenario WER + timing columns to float (NaN if missing).
156
 
157
- Also computes derived columns used by the robustness radar:
158
- * `avg_wer` — mean of live scenario WERs present for that row.
159
- * `model_size_mb` — total weight-file size fetched from the Hub (cached).
 
160
  """
 
 
161
  if not raw:
162
  return pd.DataFrame()
163
- df = pd.DataFrame(raw)
 
164
  if "model_id" not in df.columns:
165
  return pd.DataFrame()
166
  for m in SCENARIO_METRICS:
@@ -178,6 +271,8 @@ def _raw_to_analytics_df(raw: list[dict]) -> pd.DataFrame:
178
  df["num_params_m"] = df["num_params"] / 1e6
179
  live_cols = [c for c in LIVE_SCENARIO_KEYS if c in df.columns]
180
  df["avg_wer"] = df[live_cols].mean(axis=1, skipna=True) if live_cols else np.nan
 
 
181
  df["model_size_mb"] = df["model_id"].apply(_cached_model_size_mb)
182
  return df
183
 
@@ -191,10 +286,61 @@ def available_metric_keys(df: pd.DataFrame) -> list[str]:
191
  return keys
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def plot_robustness_radar(
195
  df: pd.DataFrame,
196
  model_ids: Sequence[str],
197
- title: str = "Model robustness profile — strength 01, farther from centre is better",
198
  ) -> go.Figure:
199
  """
200
  Fixed-axis robustness radar — **strength** view (lines only, no fill).
@@ -203,7 +349,7 @@ def plot_robustness_radar(
203
  size / parameter count are inverted before normalisation; RTF is not.
204
  Strength is a 0–1 score computed per-axis across **all** leaderboard rows
205
  with a log transform + global min/max, so axes are directly comparable
206
- across models. Missing values plot as 0 on that axis (honestly empty).
207
 
208
  The Compactness axis prefers `num_params_m` (recorded for every modern
209
  eval) and falls back to `model_size_mb` only when params are unavailable;
@@ -257,7 +403,10 @@ def plot_robustness_radar(
257
  for _lab, strength, raw_series, _src, inv, raw_disp, raw_unit, using_fb in axes:
258
  s = strength[idx]
259
  rv = raw_series[idx]
260
- r_vals.append(float(s) if np.isfinite(s) else 0.0)
 
 
 
261
  direction = "lower is better" if inv else "higher is better"
262
  if not np.isfinite(rv):
263
  raw_text.append(f"{raw_disp}: — ({direction})")
@@ -290,8 +439,11 @@ def plot_robustness_radar(
290
  fig.update_layout(
291
  polar=dict(
292
  radialaxis=dict(
293
- visible=True, range=[0, 1], showticklabels=True, tickformat=".2f",
294
- tickvals=[0.0, 0.25, 0.5, 0.75, 1.0],
 
 
 
295
  ),
296
  angularaxis=dict(direction="clockwise", rotation=90),
297
  ),
@@ -322,7 +474,7 @@ def plot_compare_models_across_scenarios(
322
  title: str = "WER across models — one line per scenario (click legend to show/hide scenarios)",
323
  ) -> go.Figure:
324
  """
325
- Line chart: x = model (sorted by Average WER), y = WER, one trace per scenario.
326
 
327
  Scaling to more models now just extends the x axis instead of adding more traces,
328
  which keeps the legend small (≤ #scenarios) and makes cross-scenario comparisons
@@ -339,8 +491,10 @@ def plot_compare_models_across_scenarios(
339
  if sub.empty:
340
  return _empty_fig("No matching models.")
341
 
342
- # Sort models by Average WER ascending so the x axis reads best → worst.
343
- if "avg_wer" in sub.columns:
 
 
344
  sub = sub.sort_values("avg_wer", ascending=True, na_position="last")
345
  sub = sub.reset_index(drop=True)
346
 
@@ -373,7 +527,7 @@ def plot_compare_models_across_scenarios(
373
  tickangle = -45 if n_models > 8 else -25
374
  fig.update_layout(
375
  title=dict(text=title, x=0.5, xanchor="center"),
376
- xaxis=dict(title="Model (sorted by Average WER)", tickangle=tickangle, automargin=True),
377
  yaxis=dict(title="WER (lower is better)", rangemode="tozero"),
378
  template=_TEMPLATE,
379
  height=_FIG_HEIGHT,
@@ -400,7 +554,7 @@ def plot_scenario_heatmap(
400
  - and how the top of the leaderboard compares to the rest.
401
 
402
  `metric_keys` selects which scenario columns to show (in given order). Models
403
- are sorted by Average WER ascending and capped at `top_n` rows so the chart
404
  stays legible even with hundreds of leaderboard entries.
405
  """
406
  metric_keys = [k for k in metric_keys if k in df.columns]
@@ -408,7 +562,9 @@ def plot_scenario_heatmap(
408
  return _empty_fig("Not enough data for the heatmap.")
409
 
410
  d = df.copy()
411
- if "avg_wer" in d.columns:
 
 
412
  d = d.sort_values("avg_wer", ascending=True, na_position="last")
413
  d = d.head(max(1, int(top_n))).reset_index(drop=True)
414
 
@@ -465,12 +621,13 @@ def plot_scenario_heatmap(
465
  height = int(min(900, max(_FIG_HEIGHT, 80 + 18 * n_rows)))
466
  fig.update_layout(
467
  title=dict(
468
- text=title or f"WER heatmap — top {n_rows} model(s) × {len(keep_keys)} scenario(s)",
 
469
  x=0.5,
470
  xanchor="center",
471
  ),
472
  xaxis=dict(title="Scenario", tickangle=0, side="top", automargin=True),
473
- yaxis=dict(title="Model (sorted by Average WER)", automargin=True, autorange="reversed"),
474
  template=_TEMPLATE,
475
  height=height,
476
  margin=dict(l=80, r=40, t=80, b=40),
@@ -480,23 +637,24 @@ def plot_scenario_heatmap(
480
 
481
  def plot_clean_vs_reverb_scatter(
482
  df: pd.DataFrame,
483
- title: str = "Clean vs reverberant WER — points above the diagonal degrade more under reverb",
484
  ) -> go.Figure:
485
  """
486
- Scatter: x = WER clean, y = WER reverberant. Models typically sit above y=x (reverb harder).
487
  """
488
- if df.empty or "wer_clean" not in df.columns or "wer_reverberant" not in df.columns:
489
- return _empty_fig("Need wer_clean and wer_reverberant in the leaderboard.")
490
-
491
- d = df[["model_id", "wer_clean", "wer_reverberant"]].copy()
492
- d["wer_clean"] = pd.to_numeric(d["wer_clean"], errors="coerce")
493
- d["wer_reverberant"] = pd.to_numeric(d["wer_reverberant"], errors="coerce")
494
- d = d.dropna(subset=["wer_clean", "wer_reverberant"])
 
495
  if d.empty:
496
- return _empty_fig("No complete clean/reverb WER pairs yet.")
497
 
498
  fig = go.Figure()
499
- mx = float(max(d["wer_clean"].max(), d["wer_reverberant"].max()))
500
  fig.add_trace(
501
  go.Scatter(
502
  x=[0, mx],
@@ -510,21 +668,21 @@ def plot_clean_vs_reverb_scatter(
510
  short = d["model_id"].str.split("/").str[-1].str[:28]
511
  fig.add_trace(
512
  go.Scatter(
513
- x=d["wer_clean"],
514
- y=d["wer_reverberant"],
515
  mode="markers",
516
  text=short,
517
  marker=dict(size=10, opacity=0.85),
518
  hovertemplate=(
519
- "<b>%{text}</b><br>Clean WER: %{x:.4f}<br>Reverb WER: %{y:.4f}<extra></extra>"
520
  ),
521
  )
522
  )
523
 
524
  fig.update_layout(
525
  title=dict(text=title, x=0.5, xanchor="center"),
526
- xaxis=dict(title="WER — clean (lower is better)", rangemode="tozero"),
527
- yaxis=dict(title="WER — reverberant (lower is better)", rangemode="tozero"),
528
  template=_TEMPLATE,
529
  height=_FIG_HEIGHT,
530
  margin=dict(l=60, r=40, t=60, b=60),
@@ -556,27 +714,64 @@ def plot_latency_vs_wer(
556
  "No rows with both timing and WER. Run new evaluations to populate eval_wall_time_s / RTF."
557
  )
558
 
559
- xm = metric_by_key(y_metric_key)
560
- y_label = (xm.short if xm else y_metric_key) + " WER"
561
  x_title = {
562
  "eval_wall_time_s": "Total inference wall time (s)",
563
  "eval_rtf": "RTF (audio seconds / inference seconds)",
564
  "num_params_m": "Parameters (millions)",
565
  }.get(x_key, x_key)
566
 
567
- short = d["model_id"].str.split("/").str[-1].str[:28]
568
- fig = go.Figure(
569
- data=[
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  go.Scatter(
571
- x=d[x_key],
572
- y=d[y_metric_key],
573
  mode="markers",
574
- text=short,
575
- marker=dict(size=10, opacity=0.85),
576
- hovertemplate=f"<b>%{{text}}</b><br>{x_title}: %{{x:.4f}}<br>{y_label}: %{{y:.4f}}<extra></extra>",
 
 
 
 
 
577
  )
578
- ]
579
- )
 
 
580
  ttl = title or f"Speed vs accuracy — {y_label}"
581
  fig.update_layout(
582
  title=dict(text=ttl, x=0.5, xanchor="center"),
@@ -585,6 +780,8 @@ def plot_latency_vs_wer(
585
  template=_TEMPLATE,
586
  height=_FIG_HEIGHT,
587
  margin=dict(l=60, r=40, t=60, b=60),
 
 
588
  )
589
  return fig
590
 
@@ -601,8 +798,11 @@ def plot_scenario_bar_summary(df: pd.DataFrame, top_n: int = 8) -> go.Figure:
601
  return _empty_fig("No core WER columns.")
602
 
603
  d = df.copy()
604
- d["_avg"] = d[cols].mean(axis=1, skipna=True)
605
- d = d.sort_values("_avg", ascending=True).head(top_n)
 
 
 
606
 
607
  scenarios = cols
608
  x_labels = [metric_by_key(c).short if metric_by_key(c) else c for c in scenarios]
@@ -625,7 +825,7 @@ def plot_scenario_bar_summary(df: pd.DataFrame, top_n: int = 8) -> go.Figure:
625
  fig.update_layout(
626
  barmode="group",
627
  title=dict(
628
- text=f"Core scenarios — top {len(d)} models (click legend to hide/show)",
629
  x=0.5,
630
  xanchor="center",
631
  ),
 
17
 
18
  from metrics_config import LIVE_SCENARIO_KEYS, SCENARIO_METRICS, metric_by_key
19
 
20
+ # Far-field ranking weights (renormalized per model over metrics that have data).
21
+ FAR_FIELD_WEIGHTS: dict[str, float] = {
22
+ "wer_anechoic_speech": 0.05,
23
+ "wer_lab_measured": 0.10,
24
+ "wer_lab_simulated": 0.10,
25
+ "wer_realistic_high_snr": 0.20,
26
+ "wer_realistic_low_snr": 0.35,
27
+ "wer_moving_sources": 0.20,
28
+ }
29
+
30
  # Consistent height for Gradio Plot
31
  _FIG_HEIGHT = 460
32
  _TEMPLATE = "plotly_white"
 
60
  return mb
61
 
62
 
63
+ def _coerce_positive_float(v) -> float | None:
64
+ if v is None or v == "":
65
+ return None
66
+ try:
67
+ x = float(v)
68
+ return x if np.isfinite(x) and x > 0 else None
69
+ except (TypeError, ValueError):
70
+ return None
71
+
72
+
73
+ def compute_wer_log_bounds(rows: list[dict], keys: Sequence[str]) -> dict[str, tuple[float, float]]:
74
+ """Per-key log10(WER) bounds across leaderboard rows (WER > 0 only)."""
75
+ bounds: dict[str, tuple[float, float]] = {}
76
+ for k in keys:
77
+ vals: list[float] = []
78
+ for r in rows:
79
+ x = _coerce_positive_float(r.get(k))
80
+ if x is not None:
81
+ vals.append(float(np.log10(x)))
82
+ if vals:
83
+ bounds[k] = (float(min(vals)), float(max(vals)))
84
+ else:
85
+ bounds[k] = (0.0, 0.0)
86
+ return bounds
87
+
88
+
89
+ def wer_quality_from_bounds(wer, lo: float, hi: float) -> float | None:
90
+ """Inverted log10 min-max quality in [0, 1]; lower WER -> higher quality."""
91
+ x = _coerce_positive_float(wer)
92
+ if x is None:
93
+ return None
94
+ lv = float(np.log10(x))
95
+ if hi - lo < 1e-9:
96
+ return 0.5
97
+ t = (lv - lo) / (hi - lo)
98
+ t = float(np.clip(t, 0.0, 1.0))
99
+ return 1.0 - t
100
+
101
+
102
+ def far_field_score_for_row(
103
+ row: dict,
104
+ bounds: dict[str, tuple[float, float]],
105
+ metric_keys: Sequence[str] | None = None,
106
+ ) -> float | None:
107
+ """Composite score in [0, 100]; higher is better. Missing metrics drop out with weight renorm."""
108
+ keys = list(metric_keys) if metric_keys is not None else list(LIVE_SCENARIO_KEYS)
109
+ num = 0.0
110
+ den = 0.0
111
+ for k in keys:
112
+ w = FAR_FIELD_WEIGHTS.get(k)
113
+ if w is None:
114
+ continue
115
+ lo, hi = bounds.get(k, (0.0, 0.0))
116
+ q = wer_quality_from_bounds(row.get(k), lo, hi)
117
+ if q is None:
118
+ continue
119
+ num += w * q
120
+ den += w
121
+ if den <= 0:
122
+ return None
123
+ return round(100.0 * num / den, 2)
124
+
125
+
126
+ def compute_far_field_score_map(rows: list[dict]) -> dict[str, float]:
127
+ """model_id -> score for sorting CSV rows."""
128
+ bounds = compute_wer_log_bounds(rows, LIVE_SCENARIO_KEYS)
129
+ out: dict[str, float] = {}
130
+ for r in rows:
131
+ mid = r.get("model_id")
132
+ if not mid:
133
+ continue
134
+ s = far_field_score_for_row(r, bounds)
135
+ if s is not None:
136
+ out[str(mid)] = float(s)
137
+ return out
138
+
139
+
140
+ def _log_normalize(values: np.ndarray, *, inverse: bool, strength_floor: float = 0.1) -> np.ndarray:
141
  """
142
+ Log10-normalize an array across all models to a "strength" score (default display floor 0.1).
143
 
144
  * `inverse=True` -> lower raw value is better (WER, memory). Strength = 1 - norm.
145
  * `inverse=False` -> higher raw value is better (RTF). Strength = norm.
 
151
  mask = np.isfinite(v) & (v > 0)
152
  if not mask.any():
153
  return out
154
+ logv = np.log10(np.maximum(v[mask], 1e-12))
155
  lo = float(np.min(logv))
156
  hi = float(np.max(logv))
157
  if hi - lo < 1e-9:
 
160
  out[mask] = (logv - lo) / (hi - lo)
161
  if inverse:
162
  out[mask] = 1.0 - out[mask]
163
+ fin = np.isfinite(out)
164
+ out[fin] = np.clip(np.maximum(out[fin], strength_floor), strength_floor, 1.0)
165
  return out
166
 
167
 
 
171
  # raw_display_name: human-readable name of the underlying metric, shown in the hover tooltip.
172
  # raw_unit : optional unit suffix for the raw value in the tooltip.
173
  #
174
+ # Live far-field scenario axes + Speed (RTF) + Compactness (parameters / Hub size).
 
175
  _RADAR_AXES: tuple[tuple[str, str, str | None, bool, str, str], ...] = (
176
+ ("Anechoic speech", "wer_anechoic_speech", None, True, "Anechoic speech WER", ""),
177
+ ("Lab measured", "wer_lab_measured", None, True, "Lab measured WER", ""),
178
+ ("Lab simulated", "wer_lab_simulated", None, True, "Lab simulated WER", ""),
179
+ ("Realistic Hi SNR", "wer_realistic_high_snr", None, True, "Realistic high-SNR WER", ""),
180
+ ("Realistic Lo SNR", "wer_realistic_low_snr", None, True, "Realistic low-SNR WER", ""),
181
+ ("Moving sources", "wer_moving_sources", None, True, "Moving sources WER", ""),
182
+ ("Speed", "eval_rtf", None, False, "RTF", "× realtime"),
183
  # Compactness: smaller model -> more compact -> farther from centre.
184
  # `inverse=True` makes `_log_normalize` emit `1 - norm`, so the smallest
185
  # observed model maps to strength = 1.0 (radar edge) and the largest
 
243
  """
244
  Parse leaderboard rows; coerce scenario WER + timing columns to float (NaN if missing).
245
 
246
+ Computes:
247
+ * ``avg_wer`` — mean of live scenario WERs present for that row (reference).
248
+ * ``ff_score``far-field composite score (0–100, higher is better).
249
+ * ``model_size_mb`` — total weight-file size fetched from the Hub (cached).
250
  """
251
+ from init import normalize_legacy_csv_row
252
+
253
  if not raw:
254
  return pd.DataFrame()
255
+ rows = [normalize_legacy_csv_row(dict(r)) for r in raw]
256
+ df = pd.DataFrame(rows)
257
  if "model_id" not in df.columns:
258
  return pd.DataFrame()
259
  for m in SCENARIO_METRICS:
 
271
  df["num_params_m"] = df["num_params"] / 1e6
272
  live_cols = [c for c in LIVE_SCENARIO_KEYS if c in df.columns]
273
  df["avg_wer"] = df[live_cols].mean(axis=1, skipna=True) if live_cols else np.nan
274
+ bounds = compute_wer_log_bounds(rows, LIVE_SCENARIO_KEYS)
275
+ df["ff_score"] = [far_field_score_for_row(r, bounds) for r in rows]
276
  df["model_size_mb"] = df["model_id"].apply(_cached_model_size_mb)
277
  return df
278
 
 
286
  return keys
287
 
288
 
289
+ def plot_leaderboard_score_bars(
290
+ df: pd.DataFrame,
291
+ top_n: int = 40,
292
+ title: str | None = None,
293
+ ) -> go.Figure:
294
+ """
295
+ Ranked horizontal bar chart of ``ff_score`` (Artificial Analysis–style highlights).
296
+ """
297
+ if df.empty or "ff_score" not in df.columns:
298
+ return _empty_fig("No leaderboard scores yet.")
299
+ d = df[["model_id", "ff_score"]].dropna(subset=["ff_score"]).copy()
300
+ if d.empty:
301
+ return _empty_fig("No scores computed.")
302
+ n = max(1, int(top_n))
303
+ d = d.sort_values("ff_score", ascending=False, na_position="last").head(n)
304
+ labels = d["model_id"].str.split("/").str[-1].str[:42]
305
+ scores = d["ff_score"].astype(float)
306
+ fig = go.Figure(
307
+ go.Bar(
308
+ x=scores,
309
+ y=labels,
310
+ orientation="h",
311
+ marker=dict(
312
+ color=scores,
313
+ colorscale="Viridis",
314
+ cmid=50,
315
+ cmin=0,
316
+ cmax=100,
317
+ showscale=True,
318
+ colorbar=dict(title="Score"),
319
+ ),
320
+ text=[f"{s:.1f}" for s in scores],
321
+ textposition="outside",
322
+ hovertemplate="<b>%{y}</b><br>Score: %{x:.2f} / 100<extra></extra>",
323
+ )
324
+ )
325
+ fig.update_layout(
326
+ title=dict(
327
+ text=title or f"Far-field score (top {len(d)} models) · Higher is better",
328
+ x=0.5,
329
+ xanchor="center",
330
+ ),
331
+ xaxis=dict(title="Score (out of 100)", range=[0, min(110, float(scores.max()) * 1.15 + 5)]),
332
+ yaxis=dict(autorange="reversed"),
333
+ template=_TEMPLATE,
334
+ height=int(min(920, max(_FIG_HEIGHT, 100 + 22 * len(d)))),
335
+ margin=dict(l=200, r=100, t=60, b=50),
336
+ )
337
+ return fig
338
+
339
+
340
  def plot_robustness_radar(
341
  df: pd.DataFrame,
342
  model_ids: Sequence[str],
343
+ title: str = "Robustness radarlog10-scaled strength (floor 0.1), farther from centre is better",
344
  ) -> go.Figure:
345
  """
346
  Fixed-axis robustness radar — **strength** view (lines only, no fill).
 
349
  size / parameter count are inverted before normalisation; RTF is not.
350
  Strength is a 0–1 score computed per-axis across **all** leaderboard rows
351
  with a log transform + global min/max, so axes are directly comparable
352
+ across models. Missing or invalid raw values use strength **0.1** (display floor).
353
 
354
  The Compactness axis prefers `num_params_m` (recorded for every modern
355
  eval) and falls back to `model_size_mb` only when params are unavailable;
 
403
  for _lab, strength, raw_series, _src, inv, raw_disp, raw_unit, using_fb in axes:
404
  s = strength[idx]
405
  rv = raw_series[idx]
406
+ if np.isfinite(s):
407
+ r_vals.append(float(max(s, 0.1)))
408
+ else:
409
+ r_vals.append(0.1)
410
  direction = "lower is better" if inv else "higher is better"
411
  if not np.isfinite(rv):
412
  raw_text.append(f"{raw_disp}: — ({direction})")
 
439
  fig.update_layout(
440
  polar=dict(
441
  radialaxis=dict(
442
+ visible=True,
443
+ range=[0.1, 1.0],
444
+ showticklabels=True,
445
+ tickformat=".2f",
446
+ tickvals=[0.1, 0.25, 0.5, 0.75, 1.0],
447
  ),
448
  angularaxis=dict(direction="clockwise", rotation=90),
449
  ),
 
474
  title: str = "WER across models — one line per scenario (click legend to show/hide scenarios)",
475
  ) -> go.Figure:
476
  """
477
+ Line chart: x = model (sorted by far-field score), y = WER, one trace per scenario.
478
 
479
  Scaling to more models now just extends the x axis instead of adding more traces,
480
  which keeps the legend small (≤ #scenarios) and makes cross-scenario comparisons
 
491
  if sub.empty:
492
  return _empty_fig("No matching models.")
493
 
494
+ # Sort models by far-field score (fallback: Average WER).
495
+ if "ff_score" in sub.columns:
496
+ sub = sub.sort_values("ff_score", ascending=False, na_position="last")
497
+ elif "avg_wer" in sub.columns:
498
  sub = sub.sort_values("avg_wer", ascending=True, na_position="last")
499
  sub = sub.reset_index(drop=True)
500
 
 
527
  tickangle = -45 if n_models > 8 else -25
528
  fig.update_layout(
529
  title=dict(text=title, x=0.5, xanchor="center"),
530
+ xaxis=dict(title="Model (sorted by far-field score)", tickangle=tickangle, automargin=True),
531
  yaxis=dict(title="WER (lower is better)", rangemode="tozero"),
532
  template=_TEMPLATE,
533
  height=_FIG_HEIGHT,
 
554
  - and how the top of the leaderboard compares to the rest.
555
 
556
  `metric_keys` selects which scenario columns to show (in given order). Models
557
+ are sorted by far-field score (fallback: mean WER) and capped at `top_n` rows so the chart
558
  stays legible even with hundreds of leaderboard entries.
559
  """
560
  metric_keys = [k for k in metric_keys if k in df.columns]
 
562
  return _empty_fig("Not enough data for the heatmap.")
563
 
564
  d = df.copy()
565
+ if "ff_score" in d.columns:
566
+ d = d.sort_values("ff_score", ascending=False, na_position="last")
567
+ elif "avg_wer" in d.columns:
568
  d = d.sort_values("avg_wer", ascending=True, na_position="last")
569
  d = d.head(max(1, int(top_n))).reset_index(drop=True)
570
 
 
621
  height = int(min(900, max(_FIG_HEIGHT, 80 + 18 * n_rows)))
622
  fig.update_layout(
623
  title=dict(
624
+ text=title
625
+ or f"WER heatmap — top {n_rows} model(s) × {len(keep_keys)} scenario(s) (sorted by score)",
626
  x=0.5,
627
  xanchor="center",
628
  ),
629
  xaxis=dict(title="Scenario", tickangle=0, side="top", automargin=True),
630
+ yaxis=dict(title="Model (sorted by far-field score)", automargin=True, autorange="reversed"),
631
  template=_TEMPLATE,
632
  height=height,
633
  margin=dict(l=80, r=40, t=80, b=40),
 
637
 
638
  def plot_clean_vs_reverb_scatter(
639
  df: pd.DataFrame,
640
+ title: str = "Anechoic vs lab-simulated WER — points above the diagonal degrade more under simulation",
641
  ) -> go.Figure:
642
  """
643
+ Scatter: x = anechoic WER, y = lab-simulated WER. Models often sit above y=x when simulation is harder.
644
  """
645
+ c1, c2 = "wer_anechoic_speech", "wer_lab_simulated"
646
+ if df.empty or c1 not in df.columns or c2 not in df.columns:
647
+ return _empty_fig("Need anechoic and lab-simulated WER columns in the leaderboard.")
648
+
649
+ d = df[["model_id", c1, c2]].copy()
650
+ d[c1] = pd.to_numeric(d[c1], errors="coerce")
651
+ d[c2] = pd.to_numeric(d[c2], errors="coerce")
652
+ d = d.dropna(subset=[c1, c2])
653
  if d.empty:
654
+ return _empty_fig("No complete anechoic / lab-simulated WER pairs yet.")
655
 
656
  fig = go.Figure()
657
+ mx = float(max(d[c1].max(), d[c2].max()))
658
  fig.add_trace(
659
  go.Scatter(
660
  x=[0, mx],
 
668
  short = d["model_id"].str.split("/").str[-1].str[:28]
669
  fig.add_trace(
670
  go.Scatter(
671
+ x=d[c1],
672
+ y=d[c2],
673
  mode="markers",
674
  text=short,
675
  marker=dict(size=10, opacity=0.85),
676
  hovertemplate=(
677
+ "<b>%{text}</b><br>Anechoic WER: %{x:.4f}<br>Lab simulated WER: %{y:.4f}<extra></extra>"
678
  ),
679
  )
680
  )
681
 
682
  fig.update_layout(
683
  title=dict(text=title, x=0.5, xanchor="center"),
684
+ xaxis=dict(title="WER — anechoic speech (lower is better)", rangemode="tozero"),
685
+ yaxis=dict(title="WER — lab simulated (lower is better)", rangemode="tozero"),
686
  template=_TEMPLATE,
687
  height=_FIG_HEIGHT,
688
  margin=dict(l=60, r=40, t=60, b=60),
 
714
  "No rows with both timing and WER. Run new evaluations to populate eval_wall_time_s / RTF."
715
  )
716
 
717
+ m_met = metric_by_key(y_metric_key)
718
+ y_label = (m_met.short if m_met else y_metric_key) + " WER"
719
  x_title = {
720
  "eval_wall_time_s": "Total inference wall time (s)",
721
  "eval_rtf": "RTF (audio seconds / inference seconds)",
722
  "num_params_m": "Parameters (millions)",
723
  }.get(x_key, x_key)
724
 
725
+ xs = d[x_key].to_numpy(dtype=float)
726
+ ys = d[y_metric_key].to_numpy(dtype=float)
727
+ mx_f = float(np.nanmedian(xs))
728
+ my_f = float(np.nanmedian(ys))
729
+
730
+ def _bucket(xv: float, yv: float) -> tuple[str, str]:
731
+ if x_key == "eval_rtf":
732
+ fast = xv >= mx_f
733
+ else:
734
+ fast = xv <= mx_f
735
+ accurate = yv <= my_f
736
+ if fast and accurate:
737
+ return "Fast · accurate", "#22c55e"
738
+ if fast and not accurate:
739
+ return "Fast · higher WER", "#3b82f6"
740
+ if not fast and accurate:
741
+ return "Slower · lower WER", "#f59e0b"
742
+ return "Slower · higher WER", "#ef4444"
743
+
744
+ buckets = [_bucket(float(a), float(b)) for a, b in zip(xs, ys)]
745
+ labels = [b[0] for b in buckets]
746
+
747
+ fig = go.Figure()
748
+ for lab, color in (
749
+ ("Fast · accurate", "#22c55e"),
750
+ ("Fast · higher WER", "#3b82f6"),
751
+ ("Slower · lower WER", "#f59e0b"),
752
+ ("Slower · higher WER", "#ef4444"),
753
+ ):
754
+ idx = [i for i, lb in enumerate(labels) if lb == lab]
755
+ if not idx:
756
+ continue
757
+ fig.add_trace(
758
  go.Scatter(
759
+ x=d.iloc[idx][x_key],
760
+ y=d.iloc[idx][y_metric_key],
761
  mode="markers",
762
+ text=d.iloc[idx]["model_id"].str.split("/").str[-1].str[:28],
763
+ name=lab,
764
+ legendgroup=lab,
765
+ marker=dict(size=11, opacity=0.9, color=color, line=dict(width=0.6, color="white")),
766
+ hovertemplate=(
767
+ f"<b>%{{text}}</b><br>{lab}<br>{x_title}: %{{x:.4f}}<br>"
768
+ f"{y_label}: %{{y:.4f}}<extra></extra>"
769
+ ),
770
  )
771
+ )
772
+
773
+ fig.add_vline(x=mx_f, line_dash="dot", line_color="rgba(0,0,0,0.25)")
774
+ fig.add_hline(y=my_f, line_dash="dot", line_color="rgba(0,0,0,0.25)")
775
  ttl = title or f"Speed vs accuracy — {y_label}"
776
  fig.update_layout(
777
  title=dict(text=ttl, x=0.5, xanchor="center"),
 
780
  template=_TEMPLATE,
781
  height=_FIG_HEIGHT,
782
  margin=dict(l=60, r=40, t=60, b=60),
783
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
784
+ showlegend=True,
785
  )
786
  return fig
787
 
 
798
  return _empty_fig("No core WER columns.")
799
 
800
  d = df.copy()
801
+ if "ff_score" in d.columns:
802
+ d = d.sort_values("ff_score", ascending=False, na_position="last").head(int(top_n))
803
+ else:
804
+ d["_avg"] = d[cols].mean(axis=1, skipna=True)
805
+ d = d.sort_values("_avg", ascending=True).head(int(top_n))
806
 
807
  scenarios = cols
808
  x_labels = [metric_by_key(c).short if metric_by_key(c) else c for c in scenarios]
 
825
  fig.update_layout(
826
  barmode="group",
827
  title=dict(
828
+ text=f"WER by scenario — top {len(d)} models by score (click legend to hide/show)",
829
  x=0.5,
830
  xanchor="center",
831
  ),
app.py CHANGED
@@ -3,7 +3,6 @@ import time
3
 
4
  import gradio as gr
5
  import pandas as pd
6
- import numpy as np
7
  from constants import (
8
  ABOUT_TEXT,
9
  APP_TITLE,
@@ -16,17 +15,20 @@ from constants import (
16
  import analytics
17
  from backends import default_family_id
18
  import job_queue
19
- from metrics_config import SCENARIO_METRICS
20
 
21
  from init import (
22
  is_model_on_hub,
23
  load_raw_results,
24
  load_results,
 
25
  )
26
  from utils_display import (
27
  AutoEvalColumn,
28
  SCENARIO_DISPLAY_COLS,
 
29
  fields,
 
30
  styled_error,
31
  styled_message,
32
  styled_warning,
@@ -38,7 +40,7 @@ from utils_display import (
38
 
39
  COLS = [c.name for c in fields(AutoEvalColumn)]
40
  TYPES = [c.type for c in fields(AutoEvalColumn)]
41
- AVG_COL = AutoEvalColumn.avg_wer.name # "Average ⬇️"
42
  MODEL_COL = AutoEvalColumn.model.name # "Model"
43
 
44
  # Load initial data
@@ -61,31 +63,43 @@ def filter_main_table(search_query, selected_columns):
61
  )
62
  filtered_df = filtered_df[mask]
63
 
64
- # Hide toggleable columns that the user unchecked. Model + Average always visible.
65
- all_toggleable = [c for c in COLS if c not in (MODEL_COL, AVG_COL)]
66
  columns_to_hide = set(all_toggleable) - set(selected_columns)
67
  filtered_df = filtered_df[[c for c in filtered_df.columns if c not in columns_to_hide]]
68
 
69
- # Recompute the Average column from whichever benchmark columns are visible.
70
- visible_benchmarks = [
71
- c for c in SCENARIO_DISPLAY_COLS if c in filtered_df.columns and c in selected_columns
 
 
 
 
 
 
 
72
  ]
73
- if visible_benchmarks:
74
- def compute_avg(row):
75
- vals = [float(row[c]) for c in visible_benchmarks if row[c] != "NA"]
76
- return round(np.mean(vals), 4) if vals else "NA"
77
- filtered_df[AVG_COL] = filtered_df.apply(compute_avg, axis=1)
78
- else:
79
- filtered_df[AVG_COL] = "NA"
80
-
81
- # Sort: numeric Average ascending, NA rows go last.
82
- def _avg_key(v):
 
 
 
 
 
83
  try:
84
- return (0, float(v))
85
  except Exception:
86
  return (1, 0.0)
87
 
88
- filtered_df = filtered_df.assign(_sort=filtered_df[AVG_COL].apply(_avg_key))
89
  filtered_df = filtered_df.sort_values(by="_sort").drop(columns="_sort")
90
  return filtered_df
91
 
@@ -95,6 +109,14 @@ def filter_main_table(search_query, selected_columns):
95
  # ---------------------------------------------------------------------------
96
 
97
 
 
 
 
 
 
 
 
 
98
  def submit_model(model_id: str, submission_notes: str) -> str:
99
  """Validate and enqueue evaluation; a background worker runs jobs one at a time.
100
 
@@ -113,11 +135,12 @@ def submit_model(model_id: str, submission_notes: str) -> str:
113
  if row["model_id"] == model_id:
114
  return styled_message(
115
  f"Model '{model_id}' has already been evaluated. "
116
- f"Anechoic: {row.get('wer_clean', '')} | "
117
- f"Noisy: {row.get('wer_noisy', '')} | "
118
- f"Treble Eval: {row.get('wer_reverberant', '')} | "
119
- f"Real RIR: {row.get('wer_real', '')} | "
120
- f"Difficult: {row.get('wer_difficult', '')}"
 
121
  )
122
 
123
  on_hub, err_msg = is_model_on_hub(model_id)
@@ -142,11 +165,12 @@ def submit_model(model_id: str, submission_notes: str) -> str:
142
  if row["model_id"] == model_id:
143
  return styled_message(
144
  f"Model '{model_id}' has already been evaluated. "
145
- f"Anechoic: {row.get('wer_clean', '')} | "
146
- f"Noisy: {row.get('wer_noisy', '')} | "
147
- f"Treble Eval: {row.get('wer_reverberant', '')} | "
148
- f"Real RIR: {row.get('wer_real', '')} | "
149
- f"Difficult: {row.get('wer_difficult', '')}"
 
150
  )
151
  return styled_error("Could not enqueue; please try again.")
152
  if err == "queue_full":
@@ -205,18 +229,21 @@ def _analytics_initial():
205
  live_keys = [m.key for m in SCENARIO_METRICS if m.status == "live"]
206
  line_default = [k for k in live_keys if k in avail] or list(avail)
207
  sel = ids[: min(5, len(ids))]
208
- fig_r = analytics.plot_robustness_radar(df, sel)
209
- fig_hm = analytics.plot_scenario_heatmap(df, line_default, top_n=40)
210
- fig_lat = analytics.plot_latency_vs_wer(df, "eval_wall_time_s", "wer_reverberant")
 
 
211
  fig_b = analytics.plot_scenario_bar_summary(df, 8)
212
  return (
213
  gr.update(choices=ids, value=sel),
214
  gr.update(value=line_default),
215
  gr.update(choices=_LATENCY_X_CHOICES, value="eval_wall_time_s"),
216
- gr.update(choices=_LATENCY_Y_CHOICES, value="wer_reverberant"),
217
- fig_r,
218
  fig_hm,
219
  fig_lat,
 
220
  fig_b,
221
  )
222
 
@@ -233,12 +260,14 @@ def _analytics_apply(models, line_keys, top_n, latency_x, latency_y):
233
  if not lk:
234
  lk = avail
235
  lx = latency_x if latency_x in ("eval_wall_time_s", "eval_rtf", "num_params_m") else "eval_wall_time_s"
236
- ly = latency_y if latency_y in [m.key for m in SCENARIO_METRICS] else "wer_reverberant"
237
- fig_r = analytics.plot_robustness_radar(df, valid_models)
238
- fig_hm = analytics.plot_scenario_heatmap(df, lk, top_n=int(top_n) if top_n else 40)
 
239
  fig_lat = analytics.plot_latency_vs_wer(df, lx, ly)
240
- fig_b = analytics.plot_scenario_bar_summary(df, int(top_n))
241
- return fig_r, fig_hm, fig_lat, fig_b
 
242
 
243
 
244
  # ---------------------------------------------------------------------------
@@ -481,19 +510,16 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
481
  gr.Markdown(
482
  "Charts are **interactive (Plotly)**: use the **legend** to show or hide traces "
483
  "(single click toggles, double‑click isolates), drag to **zoom**, and **hover** for values.\n\n"
484
- "**Robustness radar** plots each selected model on fixed **strength** axes covering the "
485
- "live conditions (**Anechoic**, **Noisy**, **Treble Eval**, **Real RIR**, **Difficult**) "
486
- "plus **Speed** (RTF) and **Compactness** (model size). Axes are phrased so that farther from "
487
- "the centre = better (WER and size are inverted; RTF is not). Strength is a 0–1 score "
488
- "computed globally across all leaderboard rows with a log transform, so axes and shapes are "
489
- "directly comparable. Hover any vertex for the raw metric value and its direction.\n\n"
490
- "**WER heatmap** shows the top‑N models (sorted by Average WER) on the y axis and the chosen "
491
- "scenarios on the x axis, with colour = WER (greener is better). This scales cleanly to "
492
- "hundreds of models every row is one model, every column is one condition.\n\n"
493
- "**Speed vs accuracy** uses total inference wall time, RTF, or parameter count on the x axis "
494
- "and a chosen scenario WER on the y axis (timing appears after evaluations that record "
495
- "`eval_wall_time_s` / `eval_rtf`, and parameter counts after evaluations that record `num_params`).\n\n"
496
- "**Bars** group core scenarios for the top models."
497
  )
498
  with gr.Row():
499
  an_models = gr.Dropdown(
@@ -509,7 +535,7 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
509
  80,
510
  value=30,
511
  step=1,
512
- label="Top‑N models (heatmap & bar chart)",
513
  scale=1,
514
  )
515
  an_line_metrics = gr.CheckboxGroup(
@@ -519,22 +545,24 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
519
  )
520
  an_apply = gr.Button("Apply / refresh charts", variant="primary")
521
  with gr.Row():
522
- an_plot_radar = gr.Plot(label="Robustness radarstrength 0–1, lines only")
523
  an_plot_compare = gr.Plot(label="WER heatmap — models × scenarios (greener = better)")
524
  gr.Markdown("### Speed vs accuracy")
525
  with gr.Row():
526
  an_latency_x = gr.Dropdown(
527
- label="X axis (speed)",
528
  choices=_LATENCY_X_CHOICES,
529
  value="eval_wall_time_s",
530
  )
531
  an_latency_y = gr.Dropdown(
532
  label="Y axis (WER scenario)",
533
  choices=_LATENCY_Y_CHOICES,
534
- value="wer_reverberant",
535
  )
536
- an_plot_latency = gr.Plot(label="Speed vs WER")
537
- an_plot_bar = gr.Plot(label="Core scenarios — top models (grouped)")
 
 
538
 
539
  an_apply.click(
540
  fn=_analytics_apply,
@@ -546,9 +574,10 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
546
  an_latency_y,
547
  ],
548
  outputs=[
549
- an_plot_radar,
550
  an_plot_compare,
551
  an_plot_latency,
 
552
  an_plot_bar,
553
  ],
554
  )
@@ -591,9 +620,10 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
591
  an_line_metrics,
592
  an_latency_x,
593
  an_latency_y,
594
- an_plot_radar,
595
  an_plot_compare,
596
  an_plot_latency,
 
597
  an_plot_bar,
598
  ],
599
  )
 
3
 
4
  import gradio as gr
5
  import pandas as pd
 
6
  from constants import (
7
  ABOUT_TEXT,
8
  APP_TITLE,
 
15
  import analytics
16
  from backends import default_family_id
17
  import job_queue
18
+ from metrics_config import LIVE_SCENARIO_KEYS, SCENARIO_METRICS
19
 
20
  from init import (
21
  is_model_on_hub,
22
  load_raw_results,
23
  load_results,
24
+ normalize_legacy_csv_row,
25
  )
26
  from utils_display import (
27
  AutoEvalColumn,
28
  SCENARIO_DISPLAY_COLS,
29
+ SCENARIO_DISPLAY_TO_KEY,
30
  fields,
31
+ model_id_from_leaderboard_cell,
32
  styled_error,
33
  styled_message,
34
  styled_warning,
 
40
 
41
  COLS = [c.name for c in fields(AutoEvalColumn)]
42
  TYPES = [c.type for c in fields(AutoEvalColumn)]
43
+ SCORE_COL = AutoEvalColumn.ff_score.name # "Score (out of 100)"
44
  MODEL_COL = AutoEvalColumn.model.name # "Model"
45
 
46
  # Load initial data
 
63
  )
64
  filtered_df = filtered_df[mask]
65
 
66
+ # Hide toggleable columns that the user unchecked. Model + Score stay visible.
67
+ all_toggleable = [c for c in COLS if c not in (MODEL_COL, SCORE_COL)]
68
  columns_to_hide = set(all_toggleable) - set(selected_columns)
69
  filtered_df = filtered_df[[c for c in filtered_df.columns if c not in columns_to_hide]]
70
 
71
+ raw_rows = load_raw_results()
72
+ for r in raw_rows:
73
+ normalize_legacy_csv_row(r)
74
+ bounds = analytics.compute_wer_log_bounds(raw_rows, LIVE_SCENARIO_KEYS)
75
+ raw_by_mid = {r["model_id"]: r for r in raw_rows}
76
+
77
+ visible_metric_keys = [
78
+ SCENARIO_DISPLAY_TO_KEY[c]
79
+ for c in SCENARIO_DISPLAY_COLS
80
+ if c in filtered_df.columns and c in selected_columns
81
  ]
82
+ if not visible_metric_keys:
83
+ visible_metric_keys = list(LIVE_SCENARIO_KEYS)
84
+
85
+ def compute_score(row):
86
+ mid = model_id_from_leaderboard_cell(row[MODEL_COL])
87
+ rdict = raw_by_mid.get(mid)
88
+ if not rdict:
89
+ return "NA"
90
+ s = analytics.far_field_score_for_row(rdict, bounds, metric_keys=visible_metric_keys)
91
+ return round(float(s), 2) if s is not None else "NA"
92
+
93
+ filtered_df[SCORE_COL] = filtered_df.apply(compute_score, axis=1)
94
+
95
+ # Sort by Score descending; NA last.
96
+ def _score_key(v):
97
  try:
98
+ return (0, -float(v))
99
  except Exception:
100
  return (1, 0.0)
101
 
102
+ filtered_df = filtered_df.assign(_sort=filtered_df[SCORE_COL].apply(_score_key))
103
  filtered_df = filtered_df.sort_values(by="_sort").drop(columns="_sort")
104
  return filtered_df
105
 
 
109
  # ---------------------------------------------------------------------------
110
 
111
 
112
+ def _wer_cell(row: dict, key: str) -> str:
113
+ normalize_legacy_csv_row(row)
114
+ v = row.get(key, "")
115
+ if v is None or (isinstance(v, str) and str(v).strip() == ""):
116
+ return "—"
117
+ return str(v).strip()
118
+
119
+
120
  def submit_model(model_id: str, submission_notes: str) -> str:
121
  """Validate and enqueue evaluation; a background worker runs jobs one at a time.
122
 
 
135
  if row["model_id"] == model_id:
136
  return styled_message(
137
  f"Model '{model_id}' has already been evaluated. "
138
+ f"Anechoic speech: {_wer_cell(row, 'wer_anechoic_speech')} | "
139
+ f"Lab measured: {_wer_cell(row, 'wer_lab_measured')} | "
140
+ f"Lab simulated: {_wer_cell(row, 'wer_lab_simulated')} | "
141
+ f"Realistic High SNR: {_wer_cell(row, 'wer_realistic_high_snr')} | "
142
+ f"Realistic Low SNR: {_wer_cell(row, 'wer_realistic_low_snr')} | "
143
+ f"Moving sources: {_wer_cell(row, 'wer_moving_sources')}"
144
  )
145
 
146
  on_hub, err_msg = is_model_on_hub(model_id)
 
165
  if row["model_id"] == model_id:
166
  return styled_message(
167
  f"Model '{model_id}' has already been evaluated. "
168
+ f"Anechoic speech: {_wer_cell(row, 'wer_anechoic_speech')} | "
169
+ f"Lab measured: {_wer_cell(row, 'wer_lab_measured')} | "
170
+ f"Lab simulated: {_wer_cell(row, 'wer_lab_simulated')} | "
171
+ f"Realistic High SNR: {_wer_cell(row, 'wer_realistic_high_snr')} | "
172
+ f"Realistic Low SNR: {_wer_cell(row, 'wer_realistic_low_snr')} | "
173
+ f"Moving sources: {_wer_cell(row, 'wer_moving_sources')}"
174
  )
175
  return styled_error("Could not enqueue; please try again.")
176
  if err == "queue_full":
 
229
  live_keys = [m.key for m in SCENARIO_METRICS if m.status == "live"]
230
  line_default = [k for k in live_keys if k in avail] or list(avail)
231
  sel = ids[: min(5, len(ids))]
232
+ tn = 40
233
+ fig_score = analytics.plot_leaderboard_score_bars(df, top_n=tn)
234
+ fig_hm = analytics.plot_scenario_heatmap(df, line_default, top_n=tn)
235
+ fig_lat = analytics.plot_latency_vs_wer(df, "eval_wall_time_s", "wer_lab_simulated")
236
+ fig_radar = analytics.plot_robustness_radar(df, sel)
237
  fig_b = analytics.plot_scenario_bar_summary(df, 8)
238
  return (
239
  gr.update(choices=ids, value=sel),
240
  gr.update(value=line_default),
241
  gr.update(choices=_LATENCY_X_CHOICES, value="eval_wall_time_s"),
242
+ gr.update(choices=_LATENCY_Y_CHOICES, value="wer_lab_simulated"),
243
+ fig_score,
244
  fig_hm,
245
  fig_lat,
246
+ fig_radar,
247
  fig_b,
248
  )
249
 
 
260
  if not lk:
261
  lk = avail
262
  lx = latency_x if latency_x in ("eval_wall_time_s", "eval_rtf", "num_params_m") else "eval_wall_time_s"
263
+ ly = latency_y if latency_y in [m.key for m in SCENARIO_METRICS] else "wer_lab_simulated"
264
+ tn = int(top_n) if top_n else 40
265
+ fig_score = analytics.plot_leaderboard_score_bars(df, top_n=tn)
266
+ fig_hm = analytics.plot_scenario_heatmap(df, lk, top_n=tn)
267
  fig_lat = analytics.plot_latency_vs_wer(df, lx, ly)
268
+ fig_radar = analytics.plot_robustness_radar(df, valid_models)
269
+ fig_b = analytics.plot_scenario_bar_summary(df, tn)
270
+ return fig_score, fig_hm, fig_lat, fig_radar, fig_b
271
 
272
 
273
  # ---------------------------------------------------------------------------
 
510
  gr.Markdown(
511
  "Charts are **interactive (Plotly)**: use the **legend** to show or hide traces "
512
  "(single click toggles, double‑click isolates), drag to **zoom**, and **hover** for values.\n\n"
513
+ "**Far-field score (bar)** ranks models by the same weighted **Score (out of 100)** as the "
514
+ "leaderboard (higher is better). **WER heatmap** shows the top‑N models (sorted by score) on "
515
+ "the y axis and chosen scenarios on the x axis greener is lower WER.\n\n"
516
+ "**Speed vs accuracy** scatter uses median splits on the axes: points are **color-coded** "
517
+ "(green = faster-for-axis & lower WER, blue/orange/red = other quadrants). Dotted lines show "
518
+ "the medians used for those buckets.\n\n"
519
+ "**Robustness radar** plots selected models on **log₁₀‑scaled strength** axes for each live "
520
+ "scenario plus **RTF** and **compactness** (parameters / Hub size). Display strengths use a "
521
+ "**0.1 floor** so traces stay readable. Farther from the centre = better on every spoke.\n\n"
522
+ "**Grouped bars** compare raw WER across scenarios for the top models."
 
 
 
523
  )
524
  with gr.Row():
525
  an_models = gr.Dropdown(
 
535
  80,
536
  value=30,
537
  step=1,
538
+ label="Top‑N models (score bars, heatmap & grouped WER)",
539
  scale=1,
540
  )
541
  an_line_metrics = gr.CheckboxGroup(
 
545
  )
546
  an_apply = gr.Button("Apply / refresh charts", variant="primary")
547
  with gr.Row():
548
+ an_plot_score = gr.Plot(label="Far-field scoretop N models · Higher is better")
549
  an_plot_compare = gr.Plot(label="WER heatmap — models × scenarios (greener = better)")
550
  gr.Markdown("### Speed vs accuracy")
551
  with gr.Row():
552
  an_latency_x = gr.Dropdown(
553
+ label="X axis (speed / compute)",
554
  choices=_LATENCY_X_CHOICES,
555
  value="eval_wall_time_s",
556
  )
557
  an_latency_y = gr.Dropdown(
558
  label="Y axis (WER scenario)",
559
  choices=_LATENCY_Y_CHOICES,
560
+ value="wer_lab_simulated",
561
  )
562
+ an_plot_latency = gr.Plot(label="Speed vs accuracy — color-coded quadrants")
563
+ with gr.Row():
564
+ an_plot_radar = gr.Plot(label="Robustness radar — strength with 0.1 floor")
565
+ an_plot_bar = gr.Plot(label="WER by scenario — top models (grouped)")
566
 
567
  an_apply.click(
568
  fn=_analytics_apply,
 
574
  an_latency_y,
575
  ],
576
  outputs=[
577
+ an_plot_score,
578
  an_plot_compare,
579
  an_plot_latency,
580
+ an_plot_radar,
581
  an_plot_bar,
582
  ],
583
  )
 
620
  an_line_metrics,
621
  an_latency_x,
622
  an_latency_y,
623
+ an_plot_score,
624
  an_plot_compare,
625
  an_plot_latency,
626
+ an_plot_radar,
627
  an_plot_bar,
628
  ],
629
  )
constants.py CHANGED
@@ -38,26 +38,26 @@ BANNER = (
38
  "<h1>FFASR</h1>"
39
  "<div class='ffasr-subtitle'>Far‑Field Automatic Speech Recognition — a multi‑condition leaderboard</div>"
40
  "<div class='ffasr-badges'>"
41
- "<span class='ffasr-badge'>Anechoic</span>"
42
- "<span class='ffasr-badge'>Noisy</span>"
43
- "<span class='ffasr-badge'>Treble Eval</span>"
44
- "<span class='ffasr-badge'>Real RIR</span>"
45
- "<span class='ffasr-badge'>Difficult</span>"
46
  "<span class='ffasr-badge'>WER · RTF</span>"
47
  "</div>"
48
  "</div>"
49
  )
50
 
51
  INTRODUCTION_TEXT = (
52
- "**FFASR** benchmarks speech‑recognition models on the kind of audio they encounter outside the close‑mic "
53
- "studio. Every model runs on the **same held‑out set** and the **same text normalization**, so numbers are "
54
- "directly comparable.\n\n"
55
- "Five core conditions are scored today: **Anechoic** (close‑mic, dry), **Noisy** (additive noise), "
56
- "**Treble Eval Dataset** (simulated reverberation), **Real RIR Dataset** (real‑room impulse responses), "
57
- "and **Difficult Conditions** (low SNR, strong reverb, distortion). Models are ranked by **Average** WER "
58
- "across these conditionslower is better.\n\n"
 
59
  "Paste a Hugging Face model id in the **Submit** tab — scoring runs server‑side and your model never sees "
60
- "the evaluation audio directly. Use the **Analysis** tab for robustness, heatmap, and speed‑vs‑accuracy views."
 
61
  )
62
 
63
  CITATION_TEXT = """@misc{ffasr_leaderboard_2026,
@@ -70,33 +70,56 @@ CITATION_TEXT = """@misc{ffasr_leaderboard_2026,
70
  ABOUT_TEXT = """
71
  ## About FFASR
72
 
73
- **FFASR** (Far‑Field Automatic Speech Recognition) is an evaluation leaderboard that stresses ASR models
74
- on the kinds of audio encountered beyond the close‑mic studio — real rooms, real distance, real noise.
 
75
 
76
- Five core conditions are evaluated today:
77
 
78
- * **Anechoic** close‑mic, dry speech; the "easy" baseline.
79
- * **Noisy** — speech mixed with additive background noise.
80
- * **Treble Eval Dataset** — speech with simulated reverberation from the Treble eval set.
81
- * **Real RIR Dataset** — speech convolved with real‑room impulse responses (non‑simulated).
82
- * **Difficult Conditions** — adversarial mixtures (low SNR, strong reverb, distortion).
 
 
 
 
 
83
 
84
  Every submission is evaluated on the **same private held‑out set**, using the **same reference transcripts** and the
85
  **same text normalization pipeline** (lowercasing + whitespace/punctuation normalization, following the Whisper
86
  convention). That is why scores across models are directly comparable.
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  ## What you see on the leaderboard
89
 
90
  | Column | Meaning |
91
  |---|---|
92
- | **Average ⬇️** | Mean WER across the visible benchmark columns — the primary ranking (lower is better). |
93
- | **Anechoic** | WER on close‑mic dry speech. |
94
- | **Noisy** | WER under additive background noise. |
95
- | **Treble Eval Dataset** | WER on the simulatedreverberation eval split. |
96
- | **Real RIR Dataset** | WER on speech convolved with realroom impulse responses. |
97
- | **Difficult Conditions** | WER on adversarial / hard‑condition mixtures. |
 
98
  | **RTF** | *Audio seconds ÷ inference seconds*. Higher is faster; >1 means faster than real time. |
99
- | **Params (M)** | Total trainable parameters in the loaded model, in millions. |
100
 
101
  Timing depends on the hardware this Space runs on, so treat RTF as a **comparison between submissions on this
102
  leaderboard**, not as an absolute hardware number.
@@ -156,8 +179,7 @@ WER = (S + I + D) / N = (1 + 0 + 1) / 6 ≈ 0.33
156
  ### Speed & memory
157
 
158
  * **RTF** (real‑time factor) is the primary speed metric: higher = faster model for the same audio.
159
- * **Memory** on the robustness radar reflects total weight‑file size reported by the Hub (safetensors/bin/gguf/…).
160
- Smaller models are rewarded on that axis.
161
 
162
  ## Privacy and integrity
163
 
 
38
  "<h1>FFASR</h1>"
39
  "<div class='ffasr-subtitle'>Far‑Field Automatic Speech Recognition — a multi‑condition leaderboard</div>"
40
  "<div class='ffasr-badges'>"
41
+ "<span class='ffasr-badge'>Far-field score</span>"
42
+ "<span class='ffasr-badge'>Lab vs realistic</span>"
43
+ "<span class='ffasr-badge'>Moving sources</span>"
 
 
44
  "<span class='ffasr-badge'>WER · RTF</span>"
45
  "</div>"
46
  "</div>"
47
  )
48
 
49
  INTRODUCTION_TEXT = (
50
+ "**FFASR** benchmarks speech‑recognition models on audio closer to **far‑field** deployment noisy rooms, "
51
+ "reverberation, and adverse SNRs — not just studio‑dry speech. Every model runs on the **same held‑out set** "
52
+ "and the **same text normalization**, so numbers are directly comparable.\n\n"
53
+ "Each submission reports **WER** across six scenario columns (**anechoic speech**, **lab measured**, "
54
+ "**lab simulated**, **realistic high / low SNR**, **moving sources**) plus **RTF** and **parameter count**. "
55
+ "**Moving sources** fills when that evaluation split is available (otherwise shown as NA). Models are ranked "
56
+ "by a far‑field‑weighted **Score (out of 100)** **higher is better** — derived from per‑scenario WER vs "
57
+ "the rest of the leaderboard.\n\n"
58
  "Paste a Hugging Face model id in the **Submit** tab — scoring runs server‑side and your model never sees "
59
+ "the evaluation audio directly. Use the **Analysis** tab for ranked scores, heatmaps, radar robustness, and "
60
+ "color‑coded speed‑vs‑accuracy plots."
61
  )
62
 
63
  CITATION_TEXT = """@misc{ffasr_leaderboard_2026,
 
70
  ABOUT_TEXT = """
71
  ## About FFASR
72
 
73
+ **FFASR** (Far‑Field Automatic Speech Recognition) is an evaluation leaderboard that stresses ASR models on audio
74
+ beyond close‑mic studio conditions realistic acoustics, difficult SNRs, and (when enabled in the eval harness)
75
+ moving sources.
76
 
77
+ ### Scenario columns (WER · lower is better)
78
 
79
+ The leaderboard surfaces six complementary benchmarks:
80
+
81
+ * **Anechoic speech** — dry close‑mic baseline (included but **down‑weighted** in the headline score so rankings emphasise harder realism).
82
+ * **Lab measured** — controlled recordings / additive artifacts captured under measured lab conditions.
83
+ * **Lab simulated** — simulated room acoustics (historically Treble‑style simulated reverberation).
84
+ * **Realistic cond.: High SNR** — real‑world‑style mixtures at comparatively forgiving SNR.
85
+ * **Realistic cond.: Low SNR** — hard mixtures (low SNR, strong reverb, distortion) weighted heavily toward deployment realism.
86
+ * **Moving sources** — moving talkers / geometry shifts (**NA until that packed split ships** in the evaluation pipeline).
87
+
88
+ WER measures word‑level accuracy; **lower WER is always better** for each scenario column individually.
89
 
90
  Every submission is evaluated on the **same private held‑out set**, using the **same reference transcripts** and the
91
  **same text normalization pipeline** (lowercasing + whitespace/punctuation normalization, following the Whisper
92
  convention). That is why scores across models are directly comparable.
93
 
94
+ ### Far-field score (out of 100 · higher is better)
95
+
96
+ Instead of a flat average across every scenario, the headline **Score (out of 100)** combines scenarios with weights tuned for far‑field impact:
97
+
98
+ | Scenario bucket | Role in the score |
99
+ |---|---|
100
+ | Anechoic speech | Sanity baseline — contributes only a little so rankings aren’t dominated by easy speech |
101
+ | Lab measured / simulated | Important diagnostics — modest weights versus tougher realism |
102
+ | Realistic high SNR | Medium weight |
103
+ | Realistic low SNR | High weight |
104
+ | Moving sources | Highest planned weight once populated |
105
+
106
+ Technically, each populated scenario contributes an inverted **log₁₀(WER)** quality score relative to every other model on the leaderboard, then weighted components are summed and scaled to **0–100**. Missing scenarios drop out automatically with weights **renormalised** over what remains.
107
+
108
+ You can **narrow which scenarios influence Score** by toggling benchmark columns on the Leaderboard tab — only checked scenarios participate in the recomputed headline score.
109
+
110
  ## What you see on the leaderboard
111
 
112
  | Column | Meaning |
113
  |---|---|
114
+ | **Score (out of 100)** | Far‑field weighted composite ranking (**higher is better**). |
115
+ | **Anechoic speech** | WER on dry close‑mic speech. |
116
+ | **Lab measured** | WER on measured lab / controlled noisy conditions. |
117
+ | **Lab simulated** | WER on simulated reverberation / lab simulations. |
118
+ | **Realistic cond.: High SNR** | WER on realistic highSNR mixtures. |
119
+ | **Realistic cond.: Low SNR** | WER on realistic low‑SNR / adversarial mixtures. |
120
+ | **Moving sources** | WER when the moving‑source split is available (**NA** otherwise). |
121
  | **RTF** | *Audio seconds ÷ inference seconds*. Higher is faster; >1 means faster than real time. |
122
+ | **# PARAMs** | Trainable parameters (millions). |
123
 
124
  Timing depends on the hardware this Space runs on, so treat RTF as a **comparison between submissions on this
125
  leaderboard**, not as an absolute hardware number.
 
179
  ### Speed & memory
180
 
181
  * **RTF** (real‑time factor) is the primary speed metric: higher = faster model for the same audio.
182
+ * **Compactness** on the robustness radar prefers recorded parameter counts (millions) and falls back to Hub weight‑file sizes when needed smaller models extend farther on that spoke.
 
183
 
184
  ## Privacy and integrity
185
 
evaluation/orchestrator.py CHANGED
@@ -409,7 +409,7 @@ def run_evaluation(
409
  ``progress_cb(samples_done_across_all_conditions, samples_total_across_all_conditions, current_condition_key)``
410
  periodically during the run.
411
 
412
- Returns wer_clean, wer_noisy, wer_reverberant, num_samples, model_id, eval_family, plus timing.
413
  """
414
  apply_cpu_thread_settings_once()
415
 
 
409
  ``progress_cb(samples_done_across_all_conditions, samples_total_across_all_conditions, current_condition_key)``
410
  periodically during the run.
411
 
412
+ Returns wer_clean, wer_noisy, wer_reverberant, wer_real, wer_difficult, num_samples, model_id, eval_family, plus timing.
413
  """
414
  apply_cpu_thread_settings_once()
415
 
init.py CHANGED
@@ -35,6 +35,7 @@ __all__ = [
35
  "list_audio_files",
36
  "load_audio_file",
37
  "load_transcript_file",
 
38
  ]
39
 
40
  csv_lock = threading.Lock()
@@ -54,6 +55,34 @@ CSV_FIELDS = [
54
  "submitted_at",
55
  ]
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  def leaderboard_row_from_eval_result(
59
  result: dict,
@@ -64,9 +93,19 @@ def leaderboard_row_from_eval_result(
64
  row = {k: "" for k in CSV_FIELDS}
65
  row["model_id"] = str(result.get("model_id", ""))
66
  row["eval_family"] = str(result.get("eval_family", ""))
67
- for key in SCENARIO_KEYS:
68
- if key in result and result[key] is not None:
69
- row[key] = str(result[key])
 
 
 
 
 
 
 
 
 
 
70
  row["num_samples"] = str(result.get("num_samples", ""))
71
  for k in ("eval_audio_seconds", "eval_wall_time_s", "eval_rtf", "num_params"):
72
  if k in result and result[k] is not None and result[k] != "":
@@ -136,6 +175,7 @@ def load_raw_results() -> list[dict]:
136
  if not row.get("eval_family"):
137
  row["eval_family"] = "—"
138
  row.setdefault("submission_notes", "")
 
139
  return results
140
  return []
141
  except Exception:
@@ -169,6 +209,8 @@ def load_results() -> pd.DataFrame:
169
  those still live in the CSV (for moderators and analytics) but are hidden from the
170
  public table.
171
  """
 
 
172
  raw_rows = load_raw_results()
173
 
174
  if not raw_rows:
@@ -176,13 +218,22 @@ def load_results() -> pd.DataFrame:
176
 
177
  raw_df = pd.DataFrame(raw_rows)
178
 
179
- # Coerce live-condition columns to numeric so the average / sort behave sensibly.
180
  live_cols = [c for c in LIVE_SCENARIO_KEYS if c in raw_df.columns]
181
  for c in live_cols:
182
  raw_df[c] = pd.to_numeric(raw_df[c], errors="coerce")
183
 
184
- raw_df["avg_wer"] = raw_df[live_cols].mean(axis=1, skipna=True) if live_cols else pd.NA
185
- raw_df = raw_df.sort_values("avg_wer", ascending=True).reset_index(drop=True)
 
 
 
 
 
 
 
 
 
186
 
187
  def _num_or_na(series, rnd=4):
188
  return series.apply(
@@ -191,17 +242,18 @@ def load_results() -> pd.DataFrame:
191
 
192
  display_df = pd.DataFrame()
193
  display_df[AutoEvalColumn.model.name] = raw_df["model_id"].apply(make_clickable_model)
194
- display_df[AutoEvalColumn.avg_wer.name] = raw_df["avg_wer"].apply(
195
- lambda x: round(float(x), 4) if pd.notna(x) else "NA"
196
  )
197
 
198
  # Map: scenario CSV key -> display column name (defined in AutoEvalColumn).
199
  scenario_col_map = {
200
- "wer_clean": AutoEvalColumn.wer_clean.name,
201
- "wer_noisy": AutoEvalColumn.wer_noisy.name,
202
- "wer_reverberant": AutoEvalColumn.wer_reverb.name,
203
- "wer_real": AutoEvalColumn.wer_real.name,
204
- "wer_difficult": AutoEvalColumn.wer_difficult.name,
 
205
  }
206
  for csv_key, display_name in scenario_col_map.items():
207
  if csv_key in raw_df.columns:
 
35
  "list_audio_files",
36
  "load_audio_file",
37
  "load_transcript_file",
38
+ "normalize_legacy_csv_row",
39
  ]
40
 
41
  csv_lock = threading.Lock()
 
55
  "submitted_at",
56
  ]
57
 
58
+ # Legacy CSV / evaluation wire-format keys -> canonical ``SCENARIO_KEYS`` column names.
59
+ _LEGACY_WER_TO_CANONICAL: tuple[tuple[str, str], ...] = (
60
+ ("wer_clean", "wer_anechoic_speech"),
61
+ ("wer_noisy", "wer_lab_measured"),
62
+ ("wer_reverberant", "wer_lab_simulated"),
63
+ ("wer_real", "wer_realistic_high_snr"),
64
+ ("wer_difficult", "wer_realistic_low_snr"),
65
+ )
66
+
67
+
68
+ def _cell_empty(v) -> bool:
69
+ return v is None or (isinstance(v, str) and v.strip() == "")
70
+
71
+
72
+ def normalize_legacy_csv_row(row: dict) -> dict:
73
+ """Fill canonical scenario columns from legacy leaderboard CSV keys when needed."""
74
+ for old, new in _LEGACY_WER_TO_CANONICAL:
75
+ ov = row.get(old)
76
+ nv = row.get(new)
77
+ if _cell_empty(nv) and not _cell_empty(ov):
78
+ row[new] = str(ov).strip() if not isinstance(ov, str) else ov.strip()
79
+ # Older CSVs used ``wer_high_snr`` as a standalone column.
80
+ hn = row.get("wer_high_snr")
81
+ rh = row.get("wer_realistic_high_snr")
82
+ if _cell_empty(rh) and not _cell_empty(hn):
83
+ row["wer_realistic_high_snr"] = str(hn).strip() if isinstance(hn, str) else str(hn)
84
+ return row
85
+
86
 
87
  def leaderboard_row_from_eval_result(
88
  result: dict,
 
93
  row = {k: "" for k in CSV_FIELDS}
94
  row["model_id"] = str(result.get("model_id", ""))
95
  row["eval_family"] = str(result.get("eval_family", ""))
96
+ # Evaluation pipeline still emits legacy wer_* keys on the wire.
97
+ _EVAL_WIRE_TO_CANONICAL = {
98
+ "wer_anechoic_speech": "wer_clean",
99
+ "wer_lab_measured": "wer_noisy",
100
+ "wer_lab_simulated": "wer_reverberant",
101
+ "wer_realistic_high_snr": "wer_real",
102
+ "wer_realistic_low_snr": "wer_difficult",
103
+ "wer_moving_sources": "wer_moving_sources",
104
+ }
105
+ for canon_key, wire_key in _EVAL_WIRE_TO_CANONICAL.items():
106
+ v = result.get(wire_key)
107
+ if v is not None and v != "":
108
+ row[canon_key] = str(v)
109
  row["num_samples"] = str(result.get("num_samples", ""))
110
  for k in ("eval_audio_seconds", "eval_wall_time_s", "eval_rtf", "num_params"):
111
  if k in result and result[k] is not None and result[k] != "":
 
175
  if not row.get("eval_family"):
176
  row["eval_family"] = "—"
177
  row.setdefault("submission_notes", "")
178
+ normalize_legacy_csv_row(row)
179
  return results
180
  return []
181
  except Exception:
 
209
  those still live in the CSV (for moderators and analytics) but are hidden from the
210
  public table.
211
  """
212
+ import analytics
213
+
214
  raw_rows = load_raw_results()
215
 
216
  if not raw_rows:
 
218
 
219
  raw_df = pd.DataFrame(raw_rows)
220
 
221
+ # Coerce live-condition columns to numeric so scoring / sorting behave sensibly.
222
  live_cols = [c for c in LIVE_SCENARIO_KEYS if c in raw_df.columns]
223
  for c in live_cols:
224
  raw_df[c] = pd.to_numeric(raw_df[c], errors="coerce")
225
 
226
+ bounds = analytics.compute_wer_log_bounds(raw_rows, LIVE_SCENARIO_KEYS)
227
+ raw_df["_ff_score"] = [
228
+ analytics.far_field_score_for_row(dict(r), bounds) for r in raw_rows
229
+ ]
230
+ # Tie-breaker: lower mean WER among populated scenarios is better when scores tie.
231
+ raw_df["_mean_wer"] = raw_df[live_cols].mean(axis=1, skipna=True) if live_cols else pd.NA
232
+ raw_df = raw_df.sort_values(
233
+ by=["_ff_score", "_mean_wer"],
234
+ ascending=[False, True],
235
+ na_position="last",
236
+ ).reset_index(drop=True)
237
 
238
  def _num_or_na(series, rnd=4):
239
  return series.apply(
 
242
 
243
  display_df = pd.DataFrame()
244
  display_df[AutoEvalColumn.model.name] = raw_df["model_id"].apply(make_clickable_model)
245
+ display_df[AutoEvalColumn.ff_score.name] = raw_df["_ff_score"].apply(
246
+ lambda x: round(float(x), 2) if pd.notna(x) else "NA"
247
  )
248
 
249
  # Map: scenario CSV key -> display column name (defined in AutoEvalColumn).
250
  scenario_col_map = {
251
+ "wer_anechoic_speech": AutoEvalColumn.wer_anechoic.name,
252
+ "wer_lab_measured": AutoEvalColumn.wer_lab_measured.name,
253
+ "wer_lab_simulated": AutoEvalColumn.wer_lab_simulated.name,
254
+ "wer_realistic_high_snr": AutoEvalColumn.wer_realistic_high_snr.name,
255
+ "wer_realistic_low_snr": AutoEvalColumn.wer_realistic_low_snr.name,
256
+ "wer_moving_sources": AutoEvalColumn.wer_moving_sources.name,
257
  }
258
  for csv_key, display_name in scenario_col_map.items():
259
  if csv_key in raw_df.columns:
job_queue.py CHANGED
@@ -275,20 +275,12 @@ def _progress_update(job_id: str, done: int, total: int, condition: str) -> None
275
  j.progress_condition = condition or ""
276
 
277
 
278
- def _leaderboard_sort_key(row: dict) -> float:
279
- """Mean WER across live scenario columns (matches init.load_results ordering)."""
280
- from metrics_config import LIVE_SCENARIO_KEYS
281
-
282
- vals: list[float] = []
283
- for key in LIVE_SCENARIO_KEYS:
284
- raw = row.get(key, "")
285
- if raw == "" or raw is None:
286
- continue
287
- try:
288
- vals.append(float(raw))
289
- except (TypeError, ValueError):
290
- continue
291
- return sum(vals) / len(vals) if vals else float("inf")
292
 
293
 
294
  def _evaluate_single_job(job_id: str, mid: str, fid: str) -> dict:
@@ -436,7 +428,7 @@ def _worker_loop() -> None:
436
  submission_notes=notes,
437
  )
438
  )
439
- rows.sort(key=_leaderboard_sort_key)
440
  save_raw_results(rows)
441
 
442
  with _jobs_lock:
 
275
  j.progress_condition = condition or ""
276
 
277
 
278
+ def _leaderboard_sort_rows_inplace(rows: list[dict]) -> None:
279
+ """Sort leaderboard CSV rows by far-field score (descending)."""
280
+ from analytics import compute_far_field_score_map
281
+
282
+ scores = compute_far_field_score_map(rows)
283
+ rows.sort(key=lambda r: (-scores.get(r.get("model_id", ""), 0.0), r.get("model_id") or ""))
 
 
 
 
 
 
 
 
284
 
285
 
286
  def _evaluate_single_job(job_id: str, mid: str, fid: str) -> dict:
 
428
  submission_notes=notes,
429
  )
430
  )
431
+ _leaderboard_sort_rows_inplace(rows)
432
  save_raw_results(rows)
433
 
434
  with _jobs_lock:
metrics_config.py CHANGED
@@ -1,8 +1,8 @@
1
  """
2
  Scenario metrics: CSV column keys, labels, and grouping for leaderboard + analytics.
3
 
4
- Core metrics are produced by the current evaluation pipeline. Additional keys are reserved
5
- for future packed test sets (same WER semantics: lower is better).
6
  """
7
 
8
  from __future__ import annotations
@@ -28,68 +28,52 @@ class ScenarioMetric:
28
  # Order defines CSV column order for scenario WER columns.
29
  SCENARIO_METRICS: tuple[ScenarioMetric, ...] = (
30
  ScenarioMetric(
31
- key="wer_clean",
32
- label="Anechoic (close-mic, dry)",
33
- short="Anechoic",
34
- description="Close-mic / anechoic dry speech — the easy baseline.",
35
  group="Core benchmarks",
36
  status="live",
37
  ),
38
  ScenarioMetric(
39
- key="wer_noisy",
40
- label="Noisy",
41
- short="Noisy",
42
- description="Speech mixed with additive background noise.",
43
  group="Core benchmarks",
44
  status="live",
45
  ),
46
  ScenarioMetric(
47
- key="wer_reverberant",
48
- label="Treble Eval Dataset",
49
- short="Treble Eval",
50
- description="Reverberant speech from the simulated Treble eval dataset.",
51
  group="Core benchmarks",
52
  status="live",
53
  ),
54
  ScenarioMetric(
55
- key="wer_real",
56
- label="Real RIR Dataset",
57
- short="Real RIR",
58
- description="Speech convolved with real-room impulse responses (non-simulated).",
59
- group="Core benchmarks",
60
  status="live",
61
  ),
62
  ScenarioMetric(
63
- key="wer_difficult",
64
- label="Difficult Conditions",
65
- short="Difficult",
66
- description="Challenging acoustic mixtures (low SNR, strong reverb, distortion).",
67
- group="Core benchmarks",
68
  status="live",
69
  ),
70
  ScenarioMetric(
71
- key="wer_high_snr",
72
- label="High SNR scenario",
73
- short="High SNR",
74
- description="High signal-to-noise conditions (reserved for future packed split).",
75
- group="Acoustic conditions",
76
- status="planned",
77
- ),
78
- ScenarioMetric(
79
- key="wer_distant",
80
- label="Distant talker",
81
- short="Distant",
82
- description="Far-field / low direct-path energy (reserved).",
83
- group="Acoustic conditions",
84
- status="planned",
85
- ),
86
- ScenarioMetric(
87
- key="wer_highly_reverberant",
88
- label="Highly reverberant",
89
- short="High reverb",
90
- description="Strong late reflections / long RT60 (reserved).",
91
- group="Acoustic conditions",
92
- status="planned",
93
  ),
94
  )
95
 
 
1
  """
2
  Scenario metrics: CSV column keys, labels, and grouping for leaderboard + analytics.
3
 
4
+ Canonical scenario keys are far-field oriented. Legacy keys (wer_clean, …) are migrated
5
+ on CSV load in ``init.normalize_legacy_csv_row``.
6
  """
7
 
8
  from __future__ import annotations
 
28
  # Order defines CSV column order for scenario WER columns.
29
  SCENARIO_METRICS: tuple[ScenarioMetric, ...] = (
30
  ScenarioMetric(
31
+ key="wer_anechoic_speech",
32
+ label="Anechoic speech (close-mic, dry)",
33
+ short="Anechoic speech",
34
+ description="Close-mic / anechoic dry speech — easy baseline (downweighted for far-field ranking).",
35
  group="Core benchmarks",
36
  status="live",
37
  ),
38
  ScenarioMetric(
39
+ key="wer_lab_measured",
40
+ label="Lab measured",
41
+ short="Lab measured",
42
+ description="Controlled lab recordings with measured acoustics / additive noise.",
43
  group="Core benchmarks",
44
  status="live",
45
  ),
46
  ScenarioMetric(
47
+ key="wer_lab_simulated",
48
+ label="Lab simulated",
49
+ short="Lab simulated",
50
+ description="Simulated acoustics (e.g. simulated room / Treble-style reverb).",
51
  group="Core benchmarks",
52
  status="live",
53
  ),
54
  ScenarioMetric(
55
+ key="wer_realistic_high_snr",
56
+ label="Realistic conditions — High SNR",
57
+ short="Realistic · High SNR",
58
+ description="Realistic far-field / room conditions at relatively high SNR.",
59
+ group="Realistic conditions",
60
  status="live",
61
  ),
62
  ScenarioMetric(
63
+ key="wer_realistic_low_snr",
64
+ label="Realistic conditions — Low SNR",
65
+ short="Realistic · Low SNR",
66
+ description="Hard realistic mixtures (low SNR, strong reverb, distortion).",
67
+ group="Realistic conditions",
68
  status="live",
69
  ),
70
  ScenarioMetric(
71
+ key="wer_moving_sources",
72
+ label="Moving sources",
73
+ short="Moving sources",
74
+ description="Moving talker / varying geometry (populated when the eval split is available).",
75
+ group="Realistic conditions",
76
+ status="live",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  ),
78
  )
79
 
scripts/run_eval.py CHANGED
@@ -43,6 +43,8 @@ def main() -> None:
43
  print("wer_clean:", out["wer_clean"])
44
  print("wer_noisy:", out["wer_noisy"])
45
  print("wer_reverberant:", out["wer_reverberant"])
 
 
46
  print("num_samples:", out["num_samples"])
47
 
48
 
 
43
  print("wer_clean:", out["wer_clean"])
44
  print("wer_noisy:", out["wer_noisy"])
45
  print("wer_reverberant:", out["wer_reverberant"])
46
+ print("wer_real:", out.get("wer_real"))
47
+ print("wer_difficult:", out.get("wer_difficult"))
48
  print("num_samples:", out["num_samples"])
49
 
50
 
scripts/run_eval_and_publish.py CHANGED
@@ -685,33 +685,24 @@ def _write_csv(path: str, fields: list[str], rows: list[dict]) -> None:
685
 
686
 
687
  def _append_leaderboard_local(csv_path: str, result: dict, notes: str) -> dict:
688
- from init import CSV_FIELDS, leaderboard_row_from_eval_result
689
- from metrics_config import LIVE_SCENARIO_KEYS
690
 
691
  rows = _read_csv(csv_path)
692
  for row in rows:
693
  for k in CSV_FIELDS:
694
  row.setdefault(k, "")
 
695
  if any(r.get("model_id") == result["model_id"] for r in rows):
696
  raise RuntimeError(
697
  f"Model {result['model_id']!r} already in {csv_path}; refusing to duplicate."
698
  )
699
  new_row = leaderboard_row_from_eval_result(result, _now_iso(), submission_notes=notes)
 
700
  rows.append(new_row)
701
 
702
- def _avg(r: dict) -> float:
703
- vals: list[float] = []
704
- for k in LIVE_SCENARIO_KEYS:
705
- v = r.get(k, "")
706
- if v == "" or v is None:
707
- continue
708
- try:
709
- vals.append(float(v))
710
- except Exception:
711
- continue
712
- return sum(vals) / len(vals) if vals else float("inf")
713
-
714
- rows.sort(key=_avg)
715
  _write_csv(csv_path, CSV_FIELDS, rows)
716
  return new_row
717
 
@@ -954,9 +945,11 @@ def main() -> int:
954
  print(f"device: {device_str}")
955
  print(f"dtype: {args.dtype}")
956
  print(f"batch_size: {args.batch_size}")
957
- print(f"wer_clean: {result.get('wer_clean')}")
958
- print(f"wer_noisy: {result.get('wer_noisy')}")
959
- print(f"wer_reverberant: {result.get('wer_reverberant')}")
 
 
960
  print(f"num_samples: {result.get('num_samples')}")
961
  print(f"eval_wall_time_s: {result.get('eval_wall_time_s')}")
962
  print(f"eval_rtf: {result.get('eval_rtf')}")
 
685
 
686
 
687
  def _append_leaderboard_local(csv_path: str, result: dict, notes: str) -> dict:
688
+ from analytics import compute_far_field_score_map
689
+ from init import CSV_FIELDS, leaderboard_row_from_eval_result, normalize_legacy_csv_row
690
 
691
  rows = _read_csv(csv_path)
692
  for row in rows:
693
  for k in CSV_FIELDS:
694
  row.setdefault(k, "")
695
+ normalize_legacy_csv_row(row)
696
  if any(r.get("model_id") == result["model_id"] for r in rows):
697
  raise RuntimeError(
698
  f"Model {result['model_id']!r} already in {csv_path}; refusing to duplicate."
699
  )
700
  new_row = leaderboard_row_from_eval_result(result, _now_iso(), submission_notes=notes)
701
+ normalize_legacy_csv_row(new_row)
702
  rows.append(new_row)
703
 
704
+ scores = compute_far_field_score_map(rows)
705
+ rows.sort(key=lambda r: (-scores.get(r.get("model_id", ""), 0.0), r.get("model_id") or ""))
 
 
 
 
 
 
 
 
 
 
 
706
  _write_csv(csv_path, CSV_FIELDS, rows)
707
  return new_row
708
 
 
945
  print(f"device: {device_str}")
946
  print(f"dtype: {args.dtype}")
947
  print(f"batch_size: {args.batch_size}")
948
+ print(f"wer_clean (→ anechoic CSV): {result.get('wer_clean')}")
949
+ print(f"wer_noisy (→ lab measured CSV): {result.get('wer_noisy')}")
950
+ print(f"wer_reverberant (→ lab sim CSV): {result.get('wer_reverberant')}")
951
+ print(f"wer_real (→ realistic hi SNR CSV): {result.get('wer_real')}")
952
+ print(f"wer_difficult (→ realistic lo CSV): {result.get('wer_difficult')}")
953
  print(f"num_samples: {result.get('num_samples')}")
954
  print(f"eval_wall_time_s: {result.get('eval_wall_time_s')}")
955
  print(f"eval_rtf: {result.get('eval_rtf')}")
utils_display.py CHANGED
@@ -16,27 +16,39 @@ def fields(raw_class):
16
  @dataclass(frozen=True)
17
  class AutoEvalColumn:
18
  model = ColumnContent("Model", "markdown")
19
- avg_wer = ColumnContent("Average ⬇️", "number")
20
- wer_clean = ColumnContent("Anechoic", "number")
21
- wer_noisy = ColumnContent("Noisy", "number")
22
- wer_reverb = ColumnContent("Treble Eval Dataset", "number")
23
- wer_real = ColumnContent("Real RIR Dataset", "number")
24
- wer_difficult = ColumnContent("Difficult Conditions", "number")
 
25
  eval_rtf = ColumnContent("RTF", "number")
26
- params_m = ColumnContent("Params (M)", "number")
27
 
28
 
29
  # Display names of the per-condition WER benchmark columns. Used to:
30
- # * compute Average WER across the visible benchmark columns in the leaderboard filter,
31
  # * sanity-check column visibility logic.
32
  SCENARIO_DISPLAY_COLS: tuple[str, ...] = (
33
- AutoEvalColumn.wer_clean.name,
34
- AutoEvalColumn.wer_noisy.name,
35
- AutoEvalColumn.wer_reverb.name,
36
- AutoEvalColumn.wer_real.name,
37
- AutoEvalColumn.wer_difficult.name,
 
38
  )
39
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  # Custom URL mappings for models not on HuggingFace or with special pages
42
  _CUSTOM_LINKS = {
@@ -86,3 +98,17 @@ def styled_warning(warn):
86
 
87
  def styled_message(message):
88
  return f"<p style='color: green; font-size: 20px; text-align: center;'>{message}</p>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  @dataclass(frozen=True)
17
  class AutoEvalColumn:
18
  model = ColumnContent("Model", "markdown")
19
+ ff_score = ColumnContent("Score (out of 100)", "number")
20
+ wer_anechoic = ColumnContent("Anechoic speech", "number")
21
+ wer_lab_measured = ColumnContent("Lab measured", "number")
22
+ wer_lab_simulated = ColumnContent("Lab simulated", "number")
23
+ wer_realistic_high_snr = ColumnContent("Realistic cond.: High SNR", "number")
24
+ wer_realistic_low_snr = ColumnContent("Realistic cond.: Low SNR", "number")
25
+ wer_moving_sources = ColumnContent("Moving sources", "number")
26
  eval_rtf = ColumnContent("RTF", "number")
27
+ params_m = ColumnContent("# PARAMs", "number")
28
 
29
 
30
  # Display names of the per-condition WER benchmark columns. Used to:
31
+ # * compute far-field Score from the visible benchmark columns in the leaderboard filter,
32
  # * sanity-check column visibility logic.
33
  SCENARIO_DISPLAY_COLS: tuple[str, ...] = (
34
+ AutoEvalColumn.wer_anechoic.name,
35
+ AutoEvalColumn.wer_lab_measured.name,
36
+ AutoEvalColumn.wer_lab_simulated.name,
37
+ AutoEvalColumn.wer_realistic_high_snr.name,
38
+ AutoEvalColumn.wer_realistic_low_snr.name,
39
+ AutoEvalColumn.wer_moving_sources.name,
40
  )
41
 
42
+ # Leaderboard display header -> canonical CSV / analytics metric key.
43
+ SCENARIO_DISPLAY_TO_KEY: dict[str, str] = {
44
+ AutoEvalColumn.wer_anechoic.name: "wer_anechoic_speech",
45
+ AutoEvalColumn.wer_lab_measured.name: "wer_lab_measured",
46
+ AutoEvalColumn.wer_lab_simulated.name: "wer_lab_simulated",
47
+ AutoEvalColumn.wer_realistic_high_snr.name: "wer_realistic_high_snr",
48
+ AutoEvalColumn.wer_realistic_low_snr.name: "wer_realistic_low_snr",
49
+ AutoEvalColumn.wer_moving_sources.name: "wer_moving_sources",
50
+ }
51
+
52
 
53
  # Custom URL mappings for models not on HuggingFace or with special pages
54
  _CUSTOM_LINKS = {
 
98
 
99
  def styled_message(message):
100
  return f"<p style='color: green; font-size: 20px; text-align: center;'>{message}</p>"
101
+
102
+
103
+ def model_id_from_leaderboard_cell(cell) -> str:
104
+ """Recover Hugging Face model id from the markdown Model column."""
105
+ if not isinstance(cell, str):
106
+ return ""
107
+ cell = cell.strip()
108
+ if "</a>" in cell:
109
+ import re
110
+
111
+ m = re.search(r">([^<]+)</a>\s*$", cell)
112
+ if m:
113
+ return m.group(1).strip()
114
+ return cell