github-actions[bot] Copilot commited on
Commit
803219b
·
1 Parent(s): 83f7a75

feat: active filter banner, scope/filter tab rename, refactor docs

Browse files

- Rename '⚙️ Settings' tab to '🔬 Scope / Filter'
- Add active filter status banner shown across all tabs when filters reduce the row count
- Add _filter_status_banner helper wired to both filter buttons
- Add docs/dashboard-questions.md: 6-question variant-first manifest
- Add docs/refactor-plan.md: full visualization refactor design doc

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Files changed (6) hide show
  1. app.py +118 -50
  2. docs/dashboard-questions.md +15 -0
  3. docs/refactor-plan.md +133 -0
  4. src/data.py +4 -0
  5. src/plots.py +38 -20
  6. uv.lock +0 -0
app.py CHANGED
@@ -32,9 +32,7 @@ except Exception as exc:
32
  _METRIC_CHOICES = list(CLASSIFICATION_METRICS.values())
33
  _METRIC_KEYS = list(CLASSIFICATION_METRICS.keys())
34
 
35
- _CATEGORY_CHOICES = (
36
- sorted(_RAW_DF["task_category"].unique().tolist()) if not _RAW_DF.empty else []
37
- )
38
 
39
  _DATASET_CHOICES = (
40
  sorted(_RAW_DF["dataset_name"].dropna().unique().tolist())
@@ -62,6 +60,7 @@ def _numeric_range(df: pd.DataFrame, col: str) -> tuple[float, float]:
62
  # Render helpers
63
  # ---------------------------------------------------------------------------
64
 
 
65
  def render_leaderboard(df: pd.DataFrame, metric_label: str):
66
  key = _metric_key(metric_label)
67
  return ranking_table(df, key), violin_plot(df, key)
@@ -94,7 +93,7 @@ def render_context(df: pd.DataFrame, metric_label: str):
94
  def render_variant_heatmap(
95
  df: pd.DataFrame,
96
  metric_label: str,
97
- model_alias: str,
98
  aggregate_by_dataset: bool = False,
99
  ):
100
  key = _metric_key(metric_label)
@@ -118,6 +117,7 @@ def apply_and_preview(
118
  dim_max: float,
119
  ctx_min: float,
120
  ctx_max: float,
 
121
  ) -> tuple[pd.DataFrame, pd.DataFrame, str]:
122
  filters = {
123
  "subsample_train": "full" if prod_only else None,
@@ -128,6 +128,7 @@ def apply_and_preview(
128
  "embedding_dim_max": dim_max,
129
  "max_context_size_min": ctx_min,
130
  "max_context_size_max": ctx_max,
 
131
  }
132
  result = apply_filters(df, filters)
133
  preview = result.head(50) if not result.empty else pd.DataFrame()
@@ -136,6 +137,7 @@ def apply_and_preview(
136
 
137
  def _hash_label(row: pd.Series) -> str:
138
  """Human-readable label for an embedding config hash dropdown entry."""
 
139
  def _s(val) -> str:
140
  return "" if val is None or (isinstance(val, float) and pd.isna(val)) else str(val)
141
 
@@ -175,11 +177,17 @@ def get_head_types_for_hash(loaded_df: pd.DataFrame, model: str, key: str) -> li
175
  _DEFAULT_INSP_COLS = ["kept", "run_id", "run_at", "subsample_train", "task_name", "mcc_test", "accuracy_test"]
176
 
177
 
178
- def build_inspector_table(loaded_df: pd.DataFrame, raw_df: pd.DataFrame,
179
- model: str, key: str, head_type: str,
180
- selected_cols: list[str] | None = None,
181
- task_cats: list[str] | None = None,
182
- tasks: list[str] | None = None) -> pd.DataFrame:
 
 
 
 
 
 
183
  if loaded_df.empty:
184
  return pd.DataFrame()
185
  masks = []
@@ -193,6 +201,8 @@ def build_inspector_table(loaded_df: pd.DataFrame, raw_df: pd.DataFrame,
193
  masks.append(loaded_df["task_category"].isin(task_cats))
194
  if tasks and "task_name" in loaded_df.columns:
195
  masks.append(loaded_df["task_name"].isin(tasks))
 
 
196
  if not masks:
197
  return pd.DataFrame()
198
  combined = masks[0]
@@ -205,7 +215,6 @@ def build_inspector_table(loaded_df: pd.DataFrame, raw_df: pd.DataFrame,
205
  return sub[[c for c in cols if c in sub.columns or c == "kept"]].reset_index(drop=True)
206
 
207
 
208
-
209
  def exclude_run_ids(df: pd.DataFrame, run_ids_text: str) -> tuple[pd.DataFrame, pd.DataFrame, str]:
210
  ids = [r.strip() for r in run_ids_text.split(",") if r.strip()]
211
  result = apply_filters(df, {"exclude_run_ids": ids})
@@ -213,6 +222,17 @@ def exclude_run_ids(df: pd.DataFrame, run_ids_text: str) -> tuple[pd.DataFrame,
213
  return result, preview, f"{len(result)} rows active"
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
216
  # ---------------------------------------------------------------------------
217
  # Build UI
218
  # ---------------------------------------------------------------------------
@@ -226,21 +246,33 @@ _insp_default_model = all_models[0] if all_models else None
226
  _insp_default_keys = get_hash_choices_for_model(_LOADED_DF, _insp_default_model)
227
  _insp_default_heads = get_head_types_for_hash(_LOADED_DF, _insp_default_model, None)
228
  _ALL_INSP_COLS = ["kept"] + _LOADED_DF.columns.tolist() if not _LOADED_DF.empty else _DEFAULT_INSP_COLS
229
- _all_task_cats = sorted(_LOADED_DF["task_category"].dropna().unique().tolist()) if not _LOADED_DF.empty and "task_category" in _LOADED_DF.columns else []
230
- _all_tasks = sorted(_LOADED_DF["task_name"].dropna().unique().tolist()) if not _LOADED_DF.empty and "task_name" in _LOADED_DF.columns else []
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as demo:
233
-
234
  gr.Markdown(
235
  "# 🧬 DNA Foundation Model Benchmark Leaderboard\n"
236
  "Compare DNA language models across genomics classification tasks."
237
  )
238
 
 
 
239
  # ── Global controls ───────────────────────────────────────────────────────
240
  with gr.Row():
241
- metric_dd = gr.Dropdown(
242
- choices=_METRIC_CHOICES, value=_METRIC_CHOICES[0], label="Metric", scale=2
243
- )
244
 
245
  # ── Shared state ──────────────────────────────────────────────────────────
246
  loaded_df_state = gr.State(_LOADED_DF)
@@ -249,19 +281,17 @@ with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as dem
249
 
250
  # ── Tabs ──────────────────────────────────────────────────────────────────
251
  with gr.Tabs() as tabs:
252
-
253
  # ── Tab 1: Leaderboard ────────────────────────────────────────────────
254
  with gr.Tab("🏅 Leaderboard"):
255
- leaderboard_table = gr.Dataframe(
256
- label="Rankings (mean metric per category)", interactive=False, wrap=True
257
- )
258
  violin_fig = gr.Plot(label="Metric distribution per model")
259
 
260
  gr.Markdown("---\n### Drill into model variants")
261
  model_dd = gr.Dropdown(
262
  choices=all_models,
263
- value=all_models[0] if all_models else None,
264
- label="Select model",
 
265
  )
266
  agg_dataset_cb = gr.Checkbox(
267
  label="Aggregate by dataset (show mean per benchmark dataset instead of per task)",
@@ -295,63 +325,69 @@ with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as dem
295
  with gr.Tab("🔬 Context Length"):
296
  context_fig = gr.Plot(label="Performance vs task sequence length")
297
 
298
- # ── Tab 5: Settings ───────────────────────────────────────────────────
299
- with gr.Tab("⚙️ Settings"):
300
  prod_only_cb = gr.Checkbox(
301
  label="Production runs only (subsample_train IS NULL)",
302
  value=False,
303
  info="Currently returns no data — all runs use subsample_train=0.05.",
304
  )
305
- exclude_models_ms = gr.Dropdown(
306
- choices=all_models, multiselect=True, label="Exclude models", value=[]
 
 
 
 
 
307
  )
308
  with gr.Row():
309
  params_min_sl = gr.Slider(
310
- minimum=params_min_v, maximum=params_max_v,
311
- value=params_min_v, label="Model params (min)"
312
  )
313
  params_max_sl = gr.Slider(
314
- minimum=params_min_v, maximum=params_max_v,
315
- value=params_max_v, label="Model params (max)"
316
  )
317
  with gr.Row():
318
  dim_min_sl = gr.Slider(
319
- minimum=dim_min_v, maximum=dim_max_v,
320
- value=dim_min_v, label="Embedding dim (min)"
321
  )
322
  dim_max_sl = gr.Slider(
323
- minimum=dim_min_v, maximum=dim_max_v,
324
- value=dim_max_v, label="Embedding dim (max)"
325
  )
326
  with gr.Row():
327
  ctx_min_sl = gr.Slider(
328
- minimum=ctx_min_v, maximum=ctx_max_v,
329
- value=ctx_min_v, label="Max context size (min bp)"
330
  )
331
  ctx_max_sl = gr.Slider(
332
- minimum=ctx_min_v, maximum=ctx_max_v,
333
- value=ctx_max_v, label="Max context size (max bp)"
334
  )
335
  apply_btn = gr.Button("Apply Filters", variant="primary")
336
 
337
  gr.Markdown("---")
338
- run_ids_tb = gr.Textbox(
339
- label="Exclude run_ids (comma-separated)", placeholder="run_id_1, run_id_2, …"
340
- )
341
  exclude_runs_btn = gr.Button("Exclude run_ids")
342
 
343
  gr.Markdown("---")
344
  settings_count = gr.Label(label="Active rows")
345
  settings_preview = gr.Dataframe(label="Preview (first 50 rows)", interactive=False)
346
 
347
- gr.Markdown("---\n### Raw Run Inspector\nSelect a model and embedding config to audit which runs were kept or dropped by dedup.")
 
 
348
  with gr.Row():
349
  insp_model_dd = gr.Dropdown(choices=all_models, value=_insp_default_model, label="Model", scale=2)
350
- insp_cache_dd = gr.Dropdown(choices=_insp_default_keys, value=None, label="Embedding config hash", scale=3)
 
 
351
  insp_head_dd = gr.Dropdown(choices=_insp_default_heads, value=None, label="Head type", scale=2)
352
  with gr.Row():
353
- insp_task_cat_ms = gr.Dropdown(choices=_all_task_cats, value=None, multiselect=True, label="Task category", scale=2)
 
 
354
  insp_task_ms = gr.Dropdown(choices=_all_tasks, value=None, multiselect=True, label="Task", scale=3)
 
 
 
355
  insp_cols_dd = gr.Dropdown(
356
  choices=_ALL_INSP_COLS,
357
  value=_DEFAULT_INSP_COLS,
@@ -395,12 +431,22 @@ with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as dem
395
  apply_btn.click(
396
  apply_and_preview,
397
  inputs=[
398
- raw_df_state, prod_only_cb, exclude_models_ms,
399
- params_min_sl, params_max_sl,
400
- dim_min_sl, dim_max_sl,
401
- ctx_min_sl, ctx_max_sl,
 
 
 
 
 
 
402
  ],
403
  outputs=[filtered_df_state, settings_preview, settings_count],
 
 
 
 
404
  )
405
 
406
  # Settings — Exclude run_ids
@@ -408,6 +454,10 @@ with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as dem
408
  exclude_run_ids,
409
  inputs=[filtered_df_state, run_ids_tb],
410
  outputs=[filtered_df_state, settings_preview, settings_count],
 
 
 
 
411
  )
412
 
413
  # Inspector — model updates hash choices; model+hash each update head_type choices independently
@@ -426,8 +476,26 @@ with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as dem
426
  [loaded_df_state, insp_model_dd, insp_cache_dd],
427
  insp_head_dd,
428
  )
429
- _insp_inputs = [loaded_df_state, raw_df_state, insp_model_dd, insp_cache_dd, insp_head_dd, insp_cols_dd, insp_task_cat_ms, insp_task_ms]
430
- for trigger in [insp_model_dd, insp_cache_dd, insp_head_dd, insp_cols_dd, insp_task_cat_ms, insp_task_ms]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  trigger.change(build_inspector_table, _insp_inputs, insp_table)
432
 
433
  # Trigger resize on tab switch so Plotly reflows to correct container width
 
32
  _METRIC_CHOICES = list(CLASSIFICATION_METRICS.values())
33
  _METRIC_KEYS = list(CLASSIFICATION_METRICS.keys())
34
 
35
+ _CATEGORY_CHOICES = sorted(_RAW_DF["task_category"].unique().tolist()) if not _RAW_DF.empty else []
 
 
36
 
37
  _DATASET_CHOICES = (
38
  sorted(_RAW_DF["dataset_name"].dropna().unique().tolist())
 
60
  # Render helpers
61
  # ---------------------------------------------------------------------------
62
 
63
+
64
  def render_leaderboard(df: pd.DataFrame, metric_label: str):
65
  key = _metric_key(metric_label)
66
  return ranking_table(df, key), violin_plot(df, key)
 
93
  def render_variant_heatmap(
94
  df: pd.DataFrame,
95
  metric_label: str,
96
+ model_alias: list[str] | str,
97
  aggregate_by_dataset: bool = False,
98
  ):
99
  key = _metric_key(metric_label)
 
117
  dim_max: float,
118
  ctx_min: float,
119
  ctx_max: float,
120
+ pooling_strategies: list[str] | None = None,
121
  ) -> tuple[pd.DataFrame, pd.DataFrame, str]:
122
  filters = {
123
  "subsample_train": "full" if prod_only else None,
 
128
  "embedding_dim_max": dim_max,
129
  "max_context_size_min": ctx_min,
130
  "max_context_size_max": ctx_max,
131
+ "pooling_strategies": pooling_strategies or [],
132
  }
133
  result = apply_filters(df, filters)
134
  preview = result.head(50) if not result.empty else pd.DataFrame()
 
137
 
138
  def _hash_label(row: pd.Series) -> str:
139
  """Human-readable label for an embedding config hash dropdown entry."""
140
+
141
  def _s(val) -> str:
142
  return "" if val is None or (isinstance(val, float) and pd.isna(val)) else str(val)
143
 
 
177
  _DEFAULT_INSP_COLS = ["kept", "run_id", "run_at", "subsample_train", "task_name", "mcc_test", "accuracy_test"]
178
 
179
 
180
+ def build_inspector_table(
181
+ loaded_df: pd.DataFrame,
182
+ raw_df: pd.DataFrame,
183
+ model: str,
184
+ key: str,
185
+ head_type: str,
186
+ selected_cols: list[str] | None = None,
187
+ task_cats: list[str] | None = None,
188
+ tasks: list[str] | None = None,
189
+ pooling_strategies: list[str] | None = None,
190
+ ) -> pd.DataFrame:
191
  if loaded_df.empty:
192
  return pd.DataFrame()
193
  masks = []
 
201
  masks.append(loaded_df["task_category"].isin(task_cats))
202
  if tasks and "task_name" in loaded_df.columns:
203
  masks.append(loaded_df["task_name"].isin(tasks))
204
+ if pooling_strategies and "pooling_strategy" in loaded_df.columns:
205
+ masks.append(loaded_df["pooling_strategy"].isin(pooling_strategies))
206
  if not masks:
207
  return pd.DataFrame()
208
  combined = masks[0]
 
215
  return sub[[c for c in cols if c in sub.columns or c == "kept"]].reset_index(drop=True)
216
 
217
 
 
218
  def exclude_run_ids(df: pd.DataFrame, run_ids_text: str) -> tuple[pd.DataFrame, pd.DataFrame, str]:
219
  ids = [r.strip() for r in run_ids_text.split(",") if r.strip()]
220
  result = apply_filters(df, {"exclude_run_ids": ids})
 
222
  return result, preview, f"{len(result)} rows active"
223
 
224
 
225
+ def _filter_status_banner(filtered_df: pd.DataFrame, raw_df: pd.DataFrame) -> str:
226
+ if filtered_df.empty or raw_df.empty:
227
+ return ""
228
+ if len(filtered_df) < len(raw_df):
229
+ return (
230
+ f"⚠️ **Active scope filters** — {len(filtered_df):,} / {len(raw_df):,} rows visible. "
231
+ "Go to 🔬 Scope / Filter to review."
232
+ )
233
+ return ""
234
+
235
+
236
  # ---------------------------------------------------------------------------
237
  # Build UI
238
  # ---------------------------------------------------------------------------
 
246
  _insp_default_keys = get_hash_choices_for_model(_LOADED_DF, _insp_default_model)
247
  _insp_default_heads = get_head_types_for_hash(_LOADED_DF, _insp_default_model, None)
248
  _ALL_INSP_COLS = ["kept"] + _LOADED_DF.columns.tolist() if not _LOADED_DF.empty else _DEFAULT_INSP_COLS
249
+ _all_task_cats = (
250
+ sorted(_LOADED_DF["task_category"].dropna().unique().tolist())
251
+ if not _LOADED_DF.empty and "task_category" in _LOADED_DF.columns
252
+ else []
253
+ )
254
+ _all_tasks = (
255
+ sorted(_LOADED_DF["task_name"].dropna().unique().tolist())
256
+ if not _LOADED_DF.empty and "task_name" in _LOADED_DF.columns
257
+ else []
258
+ )
259
+ _all_pooling_strategies = (
260
+ sorted(_LOADED_DF["pooling_strategy"].dropna().unique().tolist())
261
+ if not _LOADED_DF.empty and "pooling_strategy" in _LOADED_DF.columns
262
+ else []
263
+ )
264
 
265
  with gr.Blocks(title="DNA Benchmark Leaderboard", theme=gr.themes.Soft()) as demo:
 
266
  gr.Markdown(
267
  "# 🧬 DNA Foundation Model Benchmark Leaderboard\n"
268
  "Compare DNA language models across genomics classification tasks."
269
  )
270
 
271
+ filter_banner = gr.Markdown("")
272
+
273
  # ── Global controls ───────────────────────────────────────────────────────
274
  with gr.Row():
275
+ metric_dd = gr.Dropdown(choices=_METRIC_CHOICES, value=_METRIC_CHOICES[0], label="Metric", scale=2)
 
 
276
 
277
  # ── Shared state ──────────────────────────────────────────────────────────
278
  loaded_df_state = gr.State(_LOADED_DF)
 
281
 
282
  # ── Tabs ──────────────────────────────────────────────────────────────────
283
  with gr.Tabs() as tabs:
 
284
  # ── Tab 1: Leaderboard ────────────────────────────────────────────────
285
  with gr.Tab("🏅 Leaderboard"):
286
+ leaderboard_table = gr.Dataframe(label="Rankings (mean metric per category)", interactive=False, wrap=True)
 
 
287
  violin_fig = gr.Plot(label="Metric distribution per model")
288
 
289
  gr.Markdown("---\n### Drill into model variants")
290
  model_dd = gr.Dropdown(
291
  choices=all_models,
292
+ value=[all_models[0]] if all_models else [],
293
+ multiselect=True,
294
+ label="Select model(s) — single model shows config variants; multiple shows one row per model",
295
  )
296
  agg_dataset_cb = gr.Checkbox(
297
  label="Aggregate by dataset (show mean per benchmark dataset instead of per task)",
 
325
  with gr.Tab("🔬 Context Length"):
326
  context_fig = gr.Plot(label="Performance vs task sequence length")
327
 
328
+ # ── Tab 5: Scope / Filter ─────────────────────────────────────────────
329
+ with gr.Tab("🔬 Scope / Filter"):
330
  prod_only_cb = gr.Checkbox(
331
  label="Production runs only (subsample_train IS NULL)",
332
  value=False,
333
  info="Currently returns no data — all runs use subsample_train=0.05.",
334
  )
335
+ exclude_models_ms = gr.Dropdown(choices=all_models, multiselect=True, label="Exclude models", value=[])
336
+ pooling_filter_ms = gr.Dropdown(
337
+ choices=_all_pooling_strategies,
338
+ multiselect=True,
339
+ label="Pooling strategy",
340
+ value=[],
341
+ info="Leave empty to include all pooling strategies.",
342
  )
343
  with gr.Row():
344
  params_min_sl = gr.Slider(
345
+ minimum=params_min_v, maximum=params_max_v, value=params_min_v, label="Model params (min)"
 
346
  )
347
  params_max_sl = gr.Slider(
348
+ minimum=params_min_v, maximum=params_max_v, value=params_max_v, label="Model params (max)"
 
349
  )
350
  with gr.Row():
351
  dim_min_sl = gr.Slider(
352
+ minimum=dim_min_v, maximum=dim_max_v, value=dim_min_v, label="Embedding dim (min)"
 
353
  )
354
  dim_max_sl = gr.Slider(
355
+ minimum=dim_min_v, maximum=dim_max_v, value=dim_max_v, label="Embedding dim (max)"
 
356
  )
357
  with gr.Row():
358
  ctx_min_sl = gr.Slider(
359
+ minimum=ctx_min_v, maximum=ctx_max_v, value=ctx_min_v, label="Max context size (min bp)"
 
360
  )
361
  ctx_max_sl = gr.Slider(
362
+ minimum=ctx_min_v, maximum=ctx_max_v, value=ctx_max_v, label="Max context size (max bp)"
 
363
  )
364
  apply_btn = gr.Button("Apply Filters", variant="primary")
365
 
366
  gr.Markdown("---")
367
+ run_ids_tb = gr.Textbox(label="Exclude run_ids (comma-separated)", placeholder="run_id_1, run_id_2, …")
 
 
368
  exclude_runs_btn = gr.Button("Exclude run_ids")
369
 
370
  gr.Markdown("---")
371
  settings_count = gr.Label(label="Active rows")
372
  settings_preview = gr.Dataframe(label="Preview (first 50 rows)", interactive=False)
373
 
374
+ gr.Markdown(
375
+ "---\n### Raw Run Inspector\nSelect a model and embedding config to audit which runs were kept or dropped by dedup."
376
+ )
377
  with gr.Row():
378
  insp_model_dd = gr.Dropdown(choices=all_models, value=_insp_default_model, label="Model", scale=2)
379
+ insp_cache_dd = gr.Dropdown(
380
+ choices=_insp_default_keys, value=None, label="Embedding config hash", scale=3
381
+ )
382
  insp_head_dd = gr.Dropdown(choices=_insp_default_heads, value=None, label="Head type", scale=2)
383
  with gr.Row():
384
+ insp_task_cat_ms = gr.Dropdown(
385
+ choices=_all_task_cats, value=None, multiselect=True, label="Task category", scale=2
386
+ )
387
  insp_task_ms = gr.Dropdown(choices=_all_tasks, value=None, multiselect=True, label="Task", scale=3)
388
+ insp_pooling_ms = gr.Dropdown(
389
+ choices=_all_pooling_strategies, value=None, multiselect=True, label="Pooling strategy", scale=2
390
+ )
391
  insp_cols_dd = gr.Dropdown(
392
  choices=_ALL_INSP_COLS,
393
  value=_DEFAULT_INSP_COLS,
 
431
  apply_btn.click(
432
  apply_and_preview,
433
  inputs=[
434
+ raw_df_state,
435
+ prod_only_cb,
436
+ exclude_models_ms,
437
+ params_min_sl,
438
+ params_max_sl,
439
+ dim_min_sl,
440
+ dim_max_sl,
441
+ ctx_min_sl,
442
+ ctx_max_sl,
443
+ pooling_filter_ms,
444
  ],
445
  outputs=[filtered_df_state, settings_preview, settings_count],
446
+ ).then(
447
+ _filter_status_banner,
448
+ inputs=[filtered_df_state, raw_df_state],
449
+ outputs=filter_banner,
450
  )
451
 
452
  # Settings — Exclude run_ids
 
454
  exclude_run_ids,
455
  inputs=[filtered_df_state, run_ids_tb],
456
  outputs=[filtered_df_state, settings_preview, settings_count],
457
+ ).then(
458
+ _filter_status_banner,
459
+ inputs=[filtered_df_state, raw_df_state],
460
+ outputs=filter_banner,
461
  )
462
 
463
  # Inspector — model updates hash choices; model+hash each update head_type choices independently
 
476
  [loaded_df_state, insp_model_dd, insp_cache_dd],
477
  insp_head_dd,
478
  )
479
+ _insp_inputs = [
480
+ loaded_df_state,
481
+ raw_df_state,
482
+ insp_model_dd,
483
+ insp_cache_dd,
484
+ insp_head_dd,
485
+ insp_cols_dd,
486
+ insp_task_cat_ms,
487
+ insp_task_ms,
488
+ insp_pooling_ms,
489
+ ]
490
+ for trigger in [
491
+ insp_model_dd,
492
+ insp_cache_dd,
493
+ insp_head_dd,
494
+ insp_cols_dd,
495
+ insp_task_cat_ms,
496
+ insp_task_ms,
497
+ insp_pooling_ms,
498
+ ]:
499
  trigger.change(build_inspector_table, _insp_inputs, insp_table)
500
 
501
  # Trigger resize on tab switch so Plotly reflows to correct container width
docs/dashboard-questions.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dashboard Questions
2
+
3
+ The ranking unit is a **variant** — a specific tuple of `(model_hf_id, pooling, layer, head)`.
4
+ Model family (`model_alias`) is a grouping/filter dimension, not a ranking unit.
5
+
6
+ | # | Question | Scope |
7
+ |---|---|---|
8
+ | Q1 | Which specific variant achieves the highest overall performance (best mean across all tasks)? | Leaderboard |
9
+ | Q2 | Which specific variant wins each individual task? | Leaderboard |
10
+ | Q3 | For a given model family, which config axis matters most (pooling / layer / head)? | Config ablation |
11
+ | Q4 | Does the best config generalize across tasks, or is it task-specific? | Config ablation |
12
+ | Q5 | What is the performance vs. speed / memory tradeoff across variants? | Efficiency |
13
+ | Q6 | Does performance degrade as task sequence length approaches context size? | Generalization |
14
+
15
+ > **Note on violin plot:** it shows distribution across all experiments per model, masking the best variant. Replace with a strip/dot plot showing per-task scores of the best variant only.
docs/refactor-plan.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dashboard Refactor Plan
2
+
3
+ > See `docs/dashboard-questions.md` for the question manifest this plan targets.
4
+
5
+ ## Guiding principle
6
+
7
+ The ranking unit is a **variant** — a concrete tuple of `(model_hf_id, pooling_strategy, layer_selection, head_type)`.
8
+ `model_alias` is a grouping/filter label, not a ranking unit.
9
+ Every visualization should default to showing the **best variant** per model family, not the mean across all experiments.
10
+
11
+ ---
12
+
13
+ ## Root gap
14
+
15
+ Two things were planned but never built:
16
+
17
+ 1. **`get_best_variants(df, metric) → pd.DataFrame`** in `src/data.py`
18
+ Should return one row per `(model_alias, task_name)` — the row belonging to the variant with the highest mean metric across all tasks for that family. This is the "best consistent config" view (Q1).
19
+
20
+ 2. **A "best config / all configs" toggle** in `app.py`
21
+ A global Radio or Checkbox that switches `filtered_df` between:
22
+ - `get_best_variants(filtered_df, metric)` — one variant per family
23
+ - `filtered_df` as-is — all config variants (for ablation exploration)
24
+
25
+ Without these, every chart is permanently in "all configs" mode and Q1/Q2 cannot be answered.
26
+
27
+ ---
28
+
29
+ ## Data layer changes (`src/data.py`)
30
+
31
+ ### New: `get_best_variants(df, metric) → pd.DataFrame`
32
+
33
+ **Logic:**
34
+ 1. Build a task-agnostic variant key: `(model_hf_id, pooling_strategy, layer_fmt, head_type)` where `layer_fmt = _fmt_layer(layer_selection_params)` (same normalisation used in `heatmap_variants`)
35
+ 2. For each `(model_alias, variant_key)`, compute mean of `metric` across all tasks
36
+ 3. Per `model_alias`, keep only the variant_key with the highest mean
37
+ 4. Return the subset of `df` rows belonging to those winning variants
38
+
39
+ **Notes:**
40
+ - The variant key must be task-agnostic (same approach as `heatmap_variants._embed_key`)
41
+ - `embedding_config_hash` cannot be used here — it is task-specific
42
+ - Result still has one row per task (not one row per variant), so all per-task charts work unchanged
43
+ - When `metric` changes, the winning variant may change — the function must be called with the currently selected metric
44
+
45
+ ### No changes needed to `deduplicate` or `apply_filters`
46
+
47
+ ---
48
+
49
+ ## App layer changes (`src/app.py`)
50
+
51
+ ### New: best-config toggle
52
+
53
+ Add a global `gr.Radio` with choices `["Best config per model", "All configs"]`, default `"Best config per model"`.
54
+
55
+ Add a new `gr.State: display_df` that is derived from `filtered_df` + the toggle:
56
+ - `"Best config per model"` → `display_df = get_best_variants(filtered_df, metric_key)`
57
+ - `"All configs"` → `display_df = filtered_df`
58
+
59
+ All visualization render functions should consume `display_df`, not `filtered_df` directly — **except** the variant `heatmap_variants` and the Inspector, which always need the full `filtered_df`.
60
+
61
+ The toggle and the metric dropdown both need to trigger recomputation of `display_df`.
62
+
63
+ ---
64
+
65
+ ## Visualization changes
66
+
67
+ ### `ranking_table` — Q1
68
+
69
+ **Current:** rows = `model_alias`, values = mean metric per task category across all variants.
70
+ **Target:** rows = variant label (e.g. `"nt · mean · last (3) · linear"`), grouped under `model_alias`. Values = mean metric per task category, computed only over that variant's rows.
71
+ **Change:** consume `display_df`; when in "best config" mode the row is already one variant per family, so the table naturally becomes variant-ranked.
72
+ Add `model_alias` as the first column so family membership is still visible.
73
+
74
+ ### `violin_plot` — Q1
75
+
76
+ **Current:** distribution across all experiments (configs × tasks) per `model_alias` — masks the best variant.
77
+ **Target:** replace with a **strip/dot plot** where each dot = one task score of the best variant for that family. The "distribution" then reflects performance spread across tasks, not across experiments.
78
+ **Change:** rename or replace `violin_plot` with a dot/strip plot; consume `display_df`.
79
+ Keep the violin shape optional (switchable) but make the default the dot plot.
80
+
81
+ ### `bar_plot_per_category` and `bar_plot_per_task` — Q2
82
+
83
+ **Current:** X = category/task, bars = `model_alias`, height = mean across all variants.
84
+ **Target:** bars = best variant per family (labeled with variant name, colored by family).
85
+ **Change:** consume `display_df`; no logic change needed in the plot functions themselves — the fix is upstream in `display_df`.
86
+
87
+ ### `heatmap_variants` — Q3, Q4
88
+
89
+ **Current (single model):** rows = all config variants, cols = tasks. ✅ Correct for Q3/Q4.
90
+ **Current (multi-model):** rows = `model_alias`, mean across all variants. This view is less useful now.
91
+ **Target for multi-model:** rows = best variant per family (one row per family, using the same task-agnostic key). This lets you compare families at their best without the multi-model averaging obscuring things.
92
+ **Change:** for multi-model mode, pre-filter to best variant rows before building the heatmap.
93
+
94
+ **Missing — Q3 single-axis ablation:**
95
+ There is no view that isolates one config dimension while holding others fixed. This would require a new chart or a pivot within `heatmap_variants`. Defer to a future iteration — the current heatmap already partially answers Q3.
96
+
97
+ ### `scatter_speed` — Q5
98
+
99
+ **Current:** one point per row — all configs mixed, no labeling of which points are best variants.
100
+ **Target:** in "best config" mode, one point per family (the best variant), clearly labeled. In "all configs" mode, all points visible with variant label in hover.
101
+ **Change:** consume `display_df`; add variant label to hover text.
102
+ **Missing: VRAM chart.** `vram_model_mb` and `peak_vram_extraction_mb` are in the data but unused. Add a third subplot (or a second tab within Speed) for VRAM vs metric.
103
+
104
+ ### `bubble_context_length` — Q6
105
+
106
+ **Current:** one bubble per row — all configs mixed, signal diluted.
107
+ **Target:** in "best config" mode, one bubble per `(model_alias, task)` using the best variant.
108
+ **Change:** consume `display_df`; no logic change needed in the plot function.
109
+
110
+ ---
111
+
112
+ ## Tab / layout changes
113
+
114
+ | Tab | Change needed |
115
+ |---|---|
116
+ | Leaderboard | Add best-config toggle (global); replace violin with dot/strip plot |
117
+ | Per-Category | Consume `display_df` — no structural change |
118
+ | Speed vs Performance | Add VRAM subplot; consume `display_df` |
119
+ | Context Length | Consume `display_df` — no structural change |
120
+ | Settings | No change |
121
+
122
+ ---
123
+
124
+ ## Implementation order
125
+
126
+ 1. `get_best_variants` in `data.py` — foundation for everything else
127
+ 2. `display_df` state + toggle in `app.py`
128
+ 3. Swap `violin_plot` for dot/strip plot in Leaderboard
129
+ 4. Update `ranking_table` to show variant label
130
+ 5. `scatter_speed` VRAM subplot
131
+ 6. `heatmap_variants` multi-model best-variant mode
132
+
133
+ Steps 2–6 are mostly wiring changes once step 1 exists; the individual plot functions require minimal logic changes.
src/data.py CHANGED
@@ -103,6 +103,10 @@ def apply_filters(df: pd.DataFrame, filters: dict) -> pd.DataFrame:
103
  if excluded_models:
104
  result = result[~result["model_alias"].isin(excluded_models)]
105
 
 
 
 
 
106
  excluded_runs = filters.get("exclude_run_ids") or []
107
  if excluded_runs and "run_id" in result.columns:
108
  result = result[~result["run_id"].isin(excluded_runs)]
 
103
  if excluded_models:
104
  result = result[~result["model_alias"].isin(excluded_models)]
105
 
106
+ pooling_strategies = filters.get("pooling_strategies") or []
107
+ if pooling_strategies and "pooling_strategy" in result.columns:
108
+ result = result[result["pooling_strategy"].isin(pooling_strategies)]
109
+
110
  excluded_runs = filters.get("exclude_run_ids") or []
111
  if excluded_runs and "run_id" in result.columns:
112
  result = result[~result["run_id"].isin(excluded_runs)]
src/plots.py CHANGED
@@ -265,21 +265,34 @@ def violin_plot(df: pd.DataFrame, metric: str) -> go.Figure:
265
  def heatmap_variants(
266
  df: pd.DataFrame,
267
  metric: str,
268
- model_alias: str,
269
  aggregate_by_dataset: bool = False,
270
  ) -> go.Figure:
271
  """Heatmap: rows = config variants (or single row if only one), cols = tasks (or datasets) + Overall.
272
 
 
 
273
  By default columns are individual task_names ordered by category then alphabetically.
274
  When aggregate_by_dataset=True columns collapse to dataset_key values (mean per dataset).
275
- Rows sorted best→worst by Overall when multiple variants exist.
276
  """
277
  metric_label = CLASSIFICATION_METRICS.get(metric, metric)
278
 
279
- if not model_alias or df.empty or metric not in df.columns:
 
 
 
 
 
 
280
  return go.Figure()
281
 
282
- mdf = df[df["model_alias"] == model_alias]
 
 
 
 
 
283
  if mdf.empty:
284
  return go.Figure()
285
 
@@ -292,21 +305,26 @@ def heatmap_variants(
292
  return str(val)
293
 
294
  mdf = mdf.copy()
295
- mdf["_embed_key"] = list(zip(
296
- mdf.get("model_hf_id", pd.Series("", index=mdf.index)).map(_norm),
297
- mdf.get("pooling_strategy", pd.Series("", index=mdf.index)).map(_norm),
298
- mdf.get("layer_selection_params", pd.Series("", index=mdf.index)).map(_fmt_layer),
299
- mdf.get("head_type", pd.Series("", index=mdf.index)).map(_norm),
300
- ))
301
- configs = sorted(mdf["_embed_key"].unique())
302
-
303
- if len(configs) > 1:
304
- rows_iter = [
305
- (_variant_label(mdf[mdf["_embed_key"] == c].iloc[0]), mdf[mdf["_embed_key"] == c])
306
- for c in configs
307
- ]
308
  else:
309
- rows_iter = [(_variant_label(mdf.iloc[0]), mdf)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
  # Build column list depending on aggregation mode
312
  if aggregate_by_dataset:
@@ -332,7 +350,7 @@ def heatmap_variants(
332
  rows_z, rows_text, rows_hover, row_labels = [], [], [], []
333
  for label, vdf in rows_iter:
334
  z_row, t_row, h_row = [], [], []
335
- config_lines = _variant_hover_lines(vdf)
336
  for col_key, col_name in zip(col_keys, col_names[:-1]):
337
  vals = _cell_vals(vdf, col_key)
338
  v = round(float(vals.mean()), 3) if not vals.empty else None
@@ -400,7 +418,7 @@ def heatmap_variants(
400
  v_overhead = t_margin + bottom_margin
401
  height = max(v_overhead + 80, n_rows * 48 + v_overhead + 20)
402
  fig.update_layout(
403
- title=f"{metric_label} — {model_alias}",
404
  height=height,
405
  template="plotly_white",
406
  xaxis=dict(side="top", tickfont=dict(size=9 if not aggregate_by_dataset else 11), tickangle=tick_angle),
 
265
  def heatmap_variants(
266
  df: pd.DataFrame,
267
  metric: str,
268
+ model_alias: str | list[str],
269
  aggregate_by_dataset: bool = False,
270
  ) -> go.Figure:
271
  """Heatmap: rows = config variants (or single row if only one), cols = tasks (or datasets) + Overall.
272
 
273
+ When model_alias is a list with >1 entry: 1 row per model (mean over all variants).
274
+ When a single model: rows = embedding config variants.
275
  By default columns are individual task_names ordered by category then alphabetically.
276
  When aggregate_by_dataset=True columns collapse to dataset_key values (mean per dataset).
277
+ Rows sorted best→worst by Overall when multiple rows exist.
278
  """
279
  metric_label = CLASSIFICATION_METRICS.get(metric, metric)
280
 
281
+ # Normalise to list
282
+ if isinstance(model_alias, str):
283
+ model_aliases = [model_alias] if model_alias else []
284
+ else:
285
+ model_aliases = [m for m in (model_alias or []) if m]
286
+
287
+ if not model_aliases or df.empty or metric not in df.columns:
288
  return go.Figure()
289
 
290
+ multi_model = len(model_aliases) > 1
291
+
292
+ if multi_model:
293
+ mdf = df[df["model_alias"].isin(model_aliases)]
294
+ else:
295
+ mdf = df[df["model_alias"] == model_aliases[0]]
296
  if mdf.empty:
297
  return go.Figure()
298
 
 
305
  return str(val)
306
 
307
  mdf = mdf.copy()
308
+
309
+ if multi_model:
310
+ # One row per model; no variant breakdown
311
+ rows_iter = [(m, mdf[mdf["model_alias"] == m]) for m in model_aliases]
 
 
 
 
 
 
 
 
 
312
  else:
313
+ # Build embedding-config key to split into variant rows
314
+ mdf["_embed_key"] = list(zip(
315
+ mdf.get("model_hf_id", pd.Series("", index=mdf.index)).map(_norm),
316
+ mdf.get("pooling_strategy", pd.Series("", index=mdf.index)).map(_norm),
317
+ mdf.get("layer_selection_params", pd.Series("", index=mdf.index)).map(_fmt_layer),
318
+ mdf.get("head_type", pd.Series("", index=mdf.index)).map(_norm),
319
+ ))
320
+ configs = sorted(mdf["_embed_key"].unique())
321
+ if len(configs) > 1:
322
+ rows_iter = [
323
+ (_variant_label(mdf[mdf["_embed_key"] == c].iloc[0]), mdf[mdf["_embed_key"] == c])
324
+ for c in configs
325
+ ]
326
+ else:
327
+ rows_iter = [(_variant_label(mdf.iloc[0]), mdf)]
328
 
329
  # Build column list depending on aggregation mode
330
  if aggregate_by_dataset:
 
350
  rows_z, rows_text, rows_hover, row_labels = [], [], [], []
351
  for label, vdf in rows_iter:
352
  z_row, t_row, h_row = [], [], []
353
+ config_lines = "" if multi_model else _variant_hover_lines(vdf)
354
  for col_key, col_name in zip(col_keys, col_names[:-1]):
355
  vals = _cell_vals(vdf, col_key)
356
  v = round(float(vals.mean()), 3) if not vals.empty else None
 
418
  v_overhead = t_margin + bottom_margin
419
  height = max(v_overhead + 80, n_rows * 48 + v_overhead + 20)
420
  fig.update_layout(
421
+ title=f"{metric_label} — {', '.join(model_aliases)}",
422
  height=height,
423
  template="plotly_white",
424
  xaxis=dict(side="top", tickfont=dict(size=9 if not aggregate_by_dataset else 11), tickangle=tick_angle),
uv.lock ADDED
The diff for this file is too large to render. See raw diff