Add gradient-based results with a typical configuration of hyperparameters

#5
.cache/known_methods_snapshot.json CHANGED
@@ -57,13 +57,34 @@
57
  "aliases": [
58
  "ces-eki-dmc"
59
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
  },
62
  "observed_methods": [
63
  "abc",
 
64
  "etki",
65
  "hm",
66
  "iekf",
 
67
  "teki",
68
  "uki"
69
  ],
 
57
  "aliases": [
58
  "ces-eki-dmc"
59
  ]
60
+ },
61
+ "adam": {
62
+ "abbreviation": "ADAM",
63
+ "Method": "Adaptive Moment Estimation",
64
+ "family": "gradient",
65
+ "aliases": [
66
+ "adam"
67
+ ]
68
+ },
69
+ "lm": {
70
+ "abbreviation": "LM",
71
+ "Method": "Levenberg-Marquardt",
72
+ "family": "gradient",
73
+ "aliases": [
74
+ "lm",
75
+ "levenberg_marquardt",
76
+ "levenberg-marquardt",
77
+ "gradient_descent"
78
+ ]
79
  }
80
  },
81
  "observed_methods": [
82
  "abc",
83
+ "adam",
84
  "etki",
85
  "hm",
86
  "iekf",
87
+ "lm",
88
  "teki",
89
  "uki"
90
  ],
.claude/skills/leaderboard-manager/references/manage-display.md CHANGED
@@ -206,6 +206,60 @@ x=alt.X(
206
 
207
  ---
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  ## Display change checklist
210
 
211
  1. Edit the relevant file (`leaderboard.py` for both leaderboards; `MethodDetails.py` for
 
206
 
207
  ---
208
 
209
+ ## Plotting conventions and shared display constants
210
+
211
+ ### Where shared constants live
212
+
213
+ `src/common/method_registry.py` is the single source of truth for any display property
214
+ that must be consistent across multiple pages or charts. Currently it exports:
215
+
216
+ - **`METHOD_COLORS: dict[str, str]`** — abbreviation → hex color for every known method,
217
+ derived from `KNOWN_METHODS` in declaration order.
218
+ - **`_METHOD_PALETTE`** — the 10-slot Vega tableau10 palette that backs `METHOD_COLORS`.
219
+
220
+ **Rule:** if a constant is used in more than one file, it belongs in `method_registry.py`,
221
+ not defined locally in `leaderboard.py` or a page file.
222
+
223
+ When **adding a new method**, its color is assigned automatically — no manual update
224
+ needed, as long as it is appended to the end of `KNOWN_METHODS`. The palette cycles every
225
+ 10 methods.
226
+
227
+ ### How to import shared colors in a new chart
228
+
229
+ ```python
230
+ try:
231
+ from common.method_registry import METHOD_COLORS
232
+ except ModuleNotFoundError:
233
+ from src.common.method_registry import METHOD_COLORS
234
+
235
+ method_color = alt.Color(
236
+ "abbreviation:N",
237
+ title="Method",
238
+ scale=alt.Scale(domain=list(METHOD_COLORS.keys()), range=list(METHOD_COLORS.values())),
239
+ )
240
+ ```
241
+
242
+ Always use the full registry domain/range (not a filtered per-benchmark subset) so colors
243
+ are stable when the user switches benchmarks or navigates between pages.
244
+
245
+ ### General plotting conventions
246
+
247
+ Unless the task explicitly asks for something different, follow these defaults for every
248
+ new Altair chart in the app:
249
+
250
+ | Concern | Convention |
251
+ |---|---|
252
+ | Method color | `METHOD_COLORS` via the import above — never a local palette |
253
+ | Benchmark color | `alt.Color("benchmark:N")` — Altair assigns automatically; no custom palette needed |
254
+ | Full-width charts | `st.altair_chart(chart, use_container_width=True)` — never `width="stretch"` |
255
+ | Ensemble-size x-axis (quantitative) | `alt.Axis(values=ens_ticks, format="d")` — derive ticks from data, integer format |
256
+ | Ensemble-size x-axis (ordinal bar) | `ensemble_size:O` with `sort=[str(e) for e in ens_ticks]` and `axis=alt.Axis(labelAngle=0)` |
257
+ | Y-axis scale | Linear by default; add `scale=alt.Scale(type="log")` only when explicitly requested |
258
+ | Tooltips | Always include `abbreviation`, `ensemble_size`, and the primary metric with `format=".4f"` |
259
+ | Failed runs | Mark with a cross point (`mark_point(shape="cross", angle=45, size=200, filled=True)`) at y=0, same color as the method line |
260
+
261
+ ---
262
+
263
  ## Display change checklist
264
 
265
  1. Edit the relevant file (`leaderboard.py` for both leaderboards; `MethodDetails.py` for
CLAUDE.md CHANGED
@@ -61,6 +61,8 @@ calibration_benchmark/
61
  │ ├── *.nc Kalman results (TEKI, ETKI, IEKF)
62
  │ ├── bayesian/ ABC + HM results
63
  │ ├── UKI_results/ UKI results
 
 
64
  │ └── ces-eki-dmc_results/ CES-EKI-DMC ensemble-results files (UQ source)
65
  │ ├── ces-eki-dmc_l63_ensemble_results_2026-06-15.nc
66
  │ ├── ces-eki-dmc_l96_ensemble_results_2026-06-15.nc
@@ -86,7 +88,7 @@ calibration_benchmark/
86
  | `algorithm_type` | Canonical method key (e.g. `teki`) |
87
  | `algorithm_alias` | Normalized raw name from NetCDF |
88
  | `abbreviation` | Display name (e.g. `TEKI`) |
89
- | `family` | `Kalman` · `Bayesian` · `calibrate_then_emulate` |
90
  | `rmse_target` | Target RMSE level |
91
  | `ensemble_size` | Ensemble / particle count |
92
  | `metric` | Mean forward-model runs over `random_seed` (lower = better; −1 = failed) |
@@ -150,6 +152,8 @@ annotations in the suitability table. Update if a new benchmark is added.
150
  | `abc` | ABC | Bayesian | — |
151
  | `hm` | HM | Bayesian | — |
152
  | `ces-eki-dmc` | CES-EKI-DMC | calibrate_then_emulate | — |
 
 
153
 
154
  ## How to extend
155
 
 
61
  │ ├── *.nc Kalman results (TEKI, ETKI, IEKF)
62
  │ ├── bayesian/ ABC + HM results
63
  │ ├── UKI_results/ UKI results
64
+ │ ├── adam_results/ ADAM gradient optimizer results
65
+ │ ├── levenberg_marquardt_results/ LM gradient optimizer results
66
  │ └── ces-eki-dmc_results/ CES-EKI-DMC ensemble-results files (UQ source)
67
  │ ├── ces-eki-dmc_l63_ensemble_results_2026-06-15.nc
68
  │ ├── ces-eki-dmc_l96_ensemble_results_2026-06-15.nc
 
88
  | `algorithm_type` | Canonical method key (e.g. `teki`) |
89
  | `algorithm_alias` | Normalized raw name from NetCDF |
90
  | `abbreviation` | Display name (e.g. `TEKI`) |
91
+ | `family` | `Kalman` · `Bayesian` · `calibrate_then_emulate` · `gradient` |
92
  | `rmse_target` | Target RMSE level |
93
  | `ensemble_size` | Ensemble / particle count |
94
  | `metric` | Mean forward-model runs over `random_seed` (lower = better; −1 = failed) |
 
152
  | `abc` | ABC | Bayesian | — |
153
  | `hm` | HM | Bayesian | — |
154
  | `ces-eki-dmc` | CES-EKI-DMC | calibrate_then_emulate | — |
155
+ | `adam` | ADAM | gradient | — |
156
+ | `lm` | LM | gradient | gradient_descent |
157
 
158
  ## How to extend
159
 
data/UKI_results/uki_l96_spatial_forcing_ensemble_results.nc CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:dad0b7dfa06ba4e56bedd0173a4c9312674e9ab97845244578b27abb0cef997a
3
- size 12408
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c8999ffa5c70d8e891443a24929622e203e0e55bb263be52add1b0585b8a6c7
3
+ size 12343
data/adam_results/leaderboard_adam_l63_2026-06-26.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ecd403e20e439541dd463e8776df75b7f07f129b728499a1b0c856513ee6dd07
3
+ size 11440
data/adam_results/leaderboard_adam_l96_const-force_2026-06-26.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6b8487b9895121629d875a23dccdbd3fcc32ea138044e87ef6590d531b4d85d2
3
+ size 11440
data/adam_results/leaderboard_adam_l96_vec-force_2026-06-26.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:539eb03a55d4ffe0a51b3b45db3b5dc0d0e159c8132e71639ba2eb5c0f72af95
3
+ size 11440
data/levenberg_marquardt_results/leaderboard_lm_l63_2026-06-29.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3fd9735597718164ef37f3f24bd010d6984b6b1ad9cec7d7e8d9f851715f791
3
+ size 11440
data/levenberg_marquardt_results/leaderboard_lm_l96_const-force_2026-06-29.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f5de3b6ad488344217323ac4be8b96ad213c2cba03b075ace5fb57ef09d9e67b
3
+ size 11440
data/levenberg_marquardt_results/leaderboard_lm_l96_vec-force_2026-06-29.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7a647aeac2cc740665d27164aef3295bf7f07f8d461013dcb396ec843624c47e
3
+ size 11440
src/common/leaderboard.py CHANGED
@@ -24,18 +24,24 @@ import altair as alt
24
  import pandas as pd
25
  import streamlit as st
26
 
27
- # Stable categorical palette (Vega tableau10). Methods are assigned colors by their
28
- # sorted position in the full available-method list, so toggling visibility never
29
- # reassigns colors to other methods.
30
- _METHOD_PALETTE = [
31
- "#4c78a8", "#f58518", "#e45756", "#72b7b2", "#54a24b",
32
- "#eeca3b", "#b279a2", "#ff9da6", "#9d755d", "#bab0ac",
33
- ]
34
 
35
  _SUITABLE = "#009E73" # Okabe-Ito teal-green (colorblind-safe)
36
  _UNSUITABLE = "#C0392B" # dark red
37
  _UNTESTED = "#BDBDBD" # gray
38
 
 
 
 
 
 
 
 
 
 
39
 
40
  def _render_suitability_table(
41
  store: pd.DataFrame,
@@ -50,7 +56,16 @@ def _render_suitability_table(
50
  store["benchmark"].unique().tolist(),
51
  key=lambda bm: benchmark_dims[bm][0] if (benchmark_dims and bm in benchmark_dims) else bm,
52
  )
53
- methods = sorted(store["abbreviation"].dropna().unique().tolist())
 
 
 
 
 
 
 
 
 
54
 
55
  target_str = str(float(suitability_target))
56
  target_str_col = f"{target_col}_str"
@@ -119,8 +134,8 @@ def _render_suitability_table(
119
  f"Evaluated at target = {suitability_target}. "
120
  f"**Green**: at some ensemble size, failure rate < {failure_threshold:.0f}% "
121
  f"and mean budget ≤ {ratio_threshold:.0f}× the best method (ratio shown). "
122
- "**Red**: does not meet criteria. "
123
- "**Gray**: no data for this experiment."
124
  )
125
  st.dataframe(display_df.style.apply(_style, axis=None), use_container_width=True)
126
 
@@ -254,14 +269,12 @@ def render_leaderboard(
254
  valid_saved = [m for m in saved_methods if m in available_methods]
255
  st.session_state[k_methods] = valid_saved if valid_saved else available_methods
256
 
257
- # Stable color scale: domain covers ALL methods so colors don't shift when a
258
- # subset is displayed.
259
- color_domain = available_methods
260
- color_range = [_METHOD_PALETTE[i % len(_METHOD_PALETTE)] for i in range(len(available_methods))]
261
  method_color = alt.Color(
262
  "abbreviation:N",
263
  title="Method",
264
- scale=alt.Scale(domain=color_domain, range=color_range),
265
  )
266
 
267
  def build_scored_table(input_df: pd.DataFrame, add_rank: bool = True) -> pd.DataFrame:
@@ -377,10 +390,41 @@ def render_leaderboard(
377
  lambda rank: f"{ {1: '🥇', 2: '🥈', 3: '🥉'}.get(rank, '')} #{rank}".strip()
378
  )
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  return scored_df
381
 
382
  leaderboard_df = build_scored_table(filtered, add_rank=True)
383
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  if scoring_mode == "Mean Forward Model Runs":
385
  score_basis = "mean forward-model runs at the selected target level (lower is better)"
386
  elif scoring_mode == "Minimum Forward Model Runs":
@@ -538,6 +582,35 @@ def render_leaderboard(
538
  ],
539
  )
540
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  if chart_layers:
542
  st.altair_chart(alt.layer(*chart_layers), use_container_width=True)
543
 
 
24
  import pandas as pd
25
  import streamlit as st
26
 
27
+ try:
28
+ from common.method_registry import METHOD_COLORS
29
+ except ModuleNotFoundError:
30
+ from src.common.method_registry import METHOD_COLORS
 
 
 
31
 
32
  _SUITABLE = "#009E73" # Okabe-Ito teal-green (colorblind-safe)
33
  _UNSUITABLE = "#C0392B" # dark red
34
  _UNTESTED = "#BDBDBD" # gray
35
 
36
+ # Family display order in the leaderboard table (lower = earlier).
37
+ # Unknown families fall back to 99 and appear at the end.
38
+ _FAMILY_ORDER: dict[str, int] = {
39
+ "Kalman": 0,
40
+ "Bayesian": 1,
41
+ "gradient": 2,
42
+ "calibrate_then_emulate": 3,
43
+ }
44
+
45
 
46
  def _render_suitability_table(
47
  store: pd.DataFrame,
 
56
  store["benchmark"].unique().tolist(),
57
  key=lambda bm: benchmark_dims[bm][0] if (benchmark_dims and bm in benchmark_dims) else bm,
58
  )
59
+ _abbr_family = (
60
+ store[["abbreviation", "family"]].dropna()
61
+ .drop_duplicates("abbreviation")
62
+ .set_index("abbreviation")["family"]
63
+ .to_dict()
64
+ )
65
+ methods = sorted(
66
+ store["abbreviation"].dropna().unique().tolist(),
67
+ key=lambda a: (_FAMILY_ORDER.get(_abbr_family.get(a, ""), 99), a),
68
+ )
69
 
70
  target_str = str(float(suitability_target))
71
  target_str_col = f"{target_col}_str"
 
134
  f"Evaluated at target = {suitability_target}. "
135
  f"**Green**: at some ensemble size, failure rate < {failure_threshold:.0f}% "
136
  f"and mean budget ≤ {ratio_threshold:.0f}× the best method (ratio shown). "
137
+ "**Red** (failed): data present but all runs failed to reach the target. "
138
+ "**Gray (—)**: no data for this benchmark."
139
  )
140
  st.dataframe(display_df.style.apply(_style, axis=None), use_container_width=True)
141
 
 
269
  valid_saved = [m for m in saved_methods if m in available_methods]
270
  st.session_state[k_methods] = valid_saved if valid_saved else available_methods
271
 
272
+ # Color scale fixed to the full registry so every chart in the app uses the same
273
+ # color per method regardless of which benchmark or page is shown.
 
 
274
  method_color = alt.Color(
275
  "abbreviation:N",
276
  title="Method",
277
+ scale=alt.Scale(domain=list(METHOD_COLORS.keys()), range=list(METHOD_COLORS.values())),
278
  )
279
 
280
  def build_scored_table(input_df: pd.DataFrame, add_rank: bool = True) -> pd.DataFrame:
 
390
  lambda rank: f"{ {1: '🥇', 2: '🥈', 3: '🥉'}.get(rank, '')} #{rank}".strip()
391
  )
392
 
393
+ # Append methods that had data for this target but every seed failed (all-NaN metric).
394
+ # They appear at the bottom of the table as "DNF" so users can distinguish
395
+ # "tried and failed" from "not tested on this benchmark".
396
+ tried_abbrevs = set(ranking_source["abbreviation"].dropna().unique())
397
+ ranked_abbrevs = set(scored_df["abbreviation"].dropna().unique())
398
+ dnf_abbrevs = tried_abbrevs - ranked_abbrevs
399
+ if dnf_abbrevs:
400
+ dnf_rows = failure_agg[failure_agg["abbreviation"].isin(dnf_abbrevs)].copy()
401
+ for col in [
402
+ "Score", "mean_runs_score", "minimum_runs_score", "ensemble_score",
403
+ "Mean Forward Model Runs", "Minimum Forward Model Runs",
404
+ "Optimal Ensemble Size", "Ensemble Sizes Used",
405
+ ]:
406
+ dnf_rows[col] = float("nan")
407
+ if add_rank:
408
+ dnf_rows["Rank"] = float("nan")
409
+ dnf_rows["Placement"] = "DNF"
410
+ scored_df = pd.concat([scored_df, dnf_rows], ignore_index=True)
411
+
412
  return scored_df
413
 
414
  leaderboard_df = build_scored_table(filtered, add_rank=True)
415
 
416
+ # Re-sort by family group, then by performance within each group.
417
+ # Placement numbers still reflect overall performance rank.
418
+ if not leaderboard_df.empty:
419
+ leaderboard_df = leaderboard_df.assign(
420
+ _family_sort=leaderboard_df["family"].map(_FAMILY_ORDER).fillna(99),
421
+ _is_dnf=leaderboard_df["Score"].isna(),
422
+ ).sort_values(
423
+ ["_family_sort", "_is_dnf", "Score"],
424
+ ascending=[True, True, False],
425
+ na_position="last",
426
+ ).drop(columns=["_family_sort", "_is_dnf"]).reset_index(drop=True)
427
+
428
  if scoring_mode == "Mean Forward Model Runs":
429
  score_basis = "mean forward-model runs at the selected target level (lower is better)"
430
  elif scoring_mode == "Minimum Forward Model Runs":
 
582
  ],
583
  )
584
  )
585
+ # Dashed vertical rule for methods with only one ensemble size so they
586
+ # remain easy to spot when multi-ensemble methods dominate the x-axis.
587
+ if not all_ens_combos.empty:
588
+ single_ens_abbrevs = (
589
+ all_ens_combos.groupby("abbreviation")["ensemble_size"]
590
+ .nunique()
591
+ .pipe(lambda s: s[s == 1].index.tolist())
592
+ )
593
+ if single_ens_abbrevs:
594
+ rule_df = (
595
+ all_ens_combos[all_ens_combos["abbreviation"].isin(single_ens_abbrevs)]
596
+ .drop_duplicates()
597
+ )
598
+ chart_layers.append(
599
+ alt.Chart(rule_df)
600
+ .mark_rule(strokeDash=[4, 4], opacity=0.5)
601
+ .encode(
602
+ x=alt.X(
603
+ "ensemble_size:Q",
604
+ axis=alt.Axis(values=ens_ticks, format="d"),
605
+ ),
606
+ color=method_color,
607
+ tooltip=[
608
+ alt.Tooltip("abbreviation:N", title="Method"),
609
+ alt.Tooltip("ensemble_size:Q", title="Ensemble Size"),
610
+ ],
611
+ )
612
+ )
613
+
614
  if chart_layers:
615
  st.altair_chart(alt.layer(*chart_layers), use_container_width=True)
616
 
src/common/method_registry.py CHANGED
@@ -46,7 +46,34 @@ KNOWN_METHODS = {
46
  "Method": "Calibrate Emulate Sample (EKI-DataMisfitController)",
47
  "family": "calibrate_then_emulate",
48
  "aliases": ["ces-eki-dmc"]
49
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
 
 
46
  "Method": "Calibrate Emulate Sample (EKI-DataMisfitController)",
47
  "family": "calibrate_then_emulate",
48
  "aliases": ["ces-eki-dmc"]
49
+ },
50
+ "adam": {
51
+ "abbreviation": "ADAM",
52
+ "Method": "Adaptive Moment Estimation",
53
+ "family": "gradient",
54
+ "aliases": ["adam"],
55
+ },
56
+ "lm": {
57
+ "abbreviation": "LM",
58
+ "Method": "Levenberg-Marquardt",
59
+ "family": "gradient",
60
+ "aliases": ["lm", "levenberg_marquardt", "levenberg-marquardt", "gradient_descent"],
61
+ },
62
+ }
63
+
64
+
65
+ # Vega tableau10 palette — one slot per method in KNOWN_METHODS declaration order.
66
+ # New methods appended to KNOWN_METHODS get the next slot; existing colors never shift.
67
+ _METHOD_PALETTE = [
68
+ "#4c78a8", "#f58518", "#e45756", "#72b7b2", "#54a24b",
69
+ "#eeca3b", "#b279a2", "#ff9da6", "#9d755d", "#bab0ac",
70
+ ]
71
+
72
+ # Stable abbreviation → hex color mapping. Import this wherever Altair charts are built
73
+ # so every plot in the app assigns the same color to each method.
74
+ METHOD_COLORS: dict[str, str] = {
75
+ meta["abbreviation"]: _METHOD_PALETTE[i % len(_METHOD_PALETTE)]
76
+ for i, meta in enumerate(KNOWN_METHODS.values())
77
  }
78
 
79
 
src/data_store.py CHANGED
@@ -32,21 +32,27 @@ DATASET_FILES = {
32
  "l63_ensemble_results.nc",
33
  "UKI_results/uki_l63_ensemble_results.nc",
34
  "bayesian/l63_abc.nc",
35
- "bayesian/l63_hm.nc"
 
 
36
  ],
37
  "L96": [
38
  "l96_ensemble_results.nc",
39
  "UKI_results/uki_l96_ensemble_results.nc",
40
- "bayesian/l96_abc.nc"
 
 
41
  ],
42
  "L96_NN_FORCING": [
43
  "l96_nn_forcing_ensemble_results.nc",
44
- "UKI_results/uki_l96_nn_forcing_ensemble_results.nc"
45
  ],
46
  "L96_SPATIAL_FORCING": [
47
  "l96_spatial_forcing_ensemble_results.nc",
48
  "UKI_results/uki_l96_spatial_forcing_ensemble_results.nc",
49
- "bayesian/l96_varying_abc.nc"
 
 
50
  ],
51
  }
52
 
@@ -146,8 +152,8 @@ def _load_store(dataset_files: dict[str, list[str]], target_col: str, *, drop_na
146
  )
147
  continue
148
 
149
- # Track failures (metric == -1) as a percentage
150
- failures_count = (metric == -1).sum(dim="random_seed")
151
 
152
  failure_rate = (failures_count / dataset.sizes["random_seed"]) * 100
153
 
@@ -180,7 +186,12 @@ def _load_store(dataset_files: dict[str, list[str]], target_col: str, *, drop_na
180
  )
181
  continue
182
 
183
- records.append(df[required_cols])
 
 
 
 
 
184
 
185
  if not records:
186
  return pd.DataFrame(
@@ -193,10 +204,16 @@ def _load_store(dataset_files: dict[str, list[str]], target_col: str, *, drop_na
193
  merged["ensemble_size"] = pd.to_numeric(merged["ensemble_size"], errors="coerce")
194
  merged["metric"] = pd.to_numeric(merged["metric"], errors="coerce")
195
  merged["failure_rate"] = pd.to_numeric(merged["failure_rate"], errors="coerce").fillna(0.0)
 
196
  if drop_nan_metric:
197
- merged = merged.dropna(subset=["ensemble_size", "metric"])
 
 
 
 
 
198
  else:
199
- merged = merged.dropna(subset=["ensemble_size"])
200
  merged["ensemble_size"] = merged["ensemble_size"].astype(int)
201
  merged["abbreviation"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("abbreviation"))
202
  merged["Method"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("Method"))
 
32
  "l63_ensemble_results.nc",
33
  "UKI_results/uki_l63_ensemble_results.nc",
34
  "bayesian/l63_abc.nc",
35
+ "bayesian/l63_hm.nc",
36
+ "adam_results/leaderboard_adam_l63_2026-06-26.nc",
37
+ "levenberg_marquardt_results/leaderboard_lm_l63_2026-06-29.nc",
38
  ],
39
  "L96": [
40
  "l96_ensemble_results.nc",
41
  "UKI_results/uki_l96_ensemble_results.nc",
42
+ "bayesian/l96_abc.nc",
43
+ "adam_results/leaderboard_adam_l96_const-force_2026-06-26.nc",
44
+ "levenberg_marquardt_results/leaderboard_lm_l96_const-force_2026-06-29.nc",
45
  ],
46
  "L96_NN_FORCING": [
47
  "l96_nn_forcing_ensemble_results.nc",
48
+ "UKI_results/uki_l96_nn_forcing_ensemble_results.nc",
49
  ],
50
  "L96_SPATIAL_FORCING": [
51
  "l96_spatial_forcing_ensemble_results.nc",
52
  "UKI_results/uki_l96_spatial_forcing_ensemble_results.nc",
53
+ "bayesian/l96_varying_abc.nc",
54
+ "adam_results/leaderboard_adam_l96_vec-force_2026-06-26.nc",
55
+ "levenberg_marquardt_results/leaderboard_lm_l96_vec-force_2026-06-29.nc",
56
  ],
57
  }
58
 
 
152
  )
153
  continue
154
 
155
+ # Track failures: -1 sentinel OR NaN (some methods use NaN instead of -1)
156
+ failures_count = ((metric == -1) | metric.isnull()).sum(dim="random_seed")
157
 
158
  failure_rate = (failures_count / dataset.sizes["random_seed"]) * 100
159
 
 
186
  )
187
  continue
188
 
189
+ # Tag rows from files with exactly one ensemble size. Only those rows
190
+ # are retained when metric is NaN — they represent a method that was
191
+ # genuinely attempted at that size but every seed failed. NaN rows from
192
+ # multi-size files are placeholder entries for sizes never actually run.
193
+ df["_single_ens"] = dataset.sizes.get("ensemble_size", 1) == 1
194
+ records.append(df[required_cols + ["_single_ens"]])
195
 
196
  if not records:
197
  return pd.DataFrame(
 
204
  merged["ensemble_size"] = pd.to_numeric(merged["ensemble_size"], errors="coerce")
205
  merged["metric"] = pd.to_numeric(merged["metric"], errors="coerce")
206
  merged["failure_rate"] = pd.to_numeric(merged["failure_rate"], errors="coerce").fillna(0.0)
207
+ merged = merged.dropna(subset=["ensemble_size"])
208
  if drop_nan_metric:
209
+ # Keep NaN-metric rows only when they came from a single-ensemble-size file
210
+ # AND every seed failed (failure_rate ≈ 100 %). Those represent a method that
211
+ # was genuinely attempted but never converged. NaN rows from multi-size files
212
+ # are placeholders for ensemble sizes that were never actually run.
213
+ single_ens = merged.pop("_single_ens").fillna(False)
214
+ merged = merged[merged["metric"].notna() | (single_ens & (merged["failure_rate"] >= 99.9))]
215
  else:
216
+ merged = merged.drop(columns=["_single_ens"], errors="ignore")
217
  merged["ensemble_size"] = merged["ensemble_size"].astype(int)
218
  merged["abbreviation"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("abbreviation"))
219
  merged["Method"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("Method"))
src/pages/MethodDetails.py CHANGED
@@ -74,6 +74,16 @@ method_meta = {
74
  "url": "https://doi.org/10.1016/j.jcp.2020.109716",
75
  "summary": "Calibrate-Emulate-Sample: uses EKI with a DataMisfitController to select training points, builds a GP emulator of the forward model, then samples the posterior via MCMC.",
76
  },
 
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
  # Selection UI (defaults to query param if valid)
 
74
  "url": "https://doi.org/10.1016/j.jcp.2020.109716",
75
  "summary": "Calibrate-Emulate-Sample: uses EKI with a DataMisfitController to select training points, builds a GP emulator of the forward model, then samples the posterior via MCMC.",
76
  },
77
+ "ADAM": {
78
+ "citation": "Kingma & Ba, ICLR, 2015",
79
+ "url": "https://doi.org/10.48550/arXiv.1412.6980",
80
+ "summary": "Adaptive Moment Estimation — gradient-based optimizer that adapts per-parameter learning rates using first and second moment estimates of the gradient.",
81
+ },
82
+ "LM": {
83
+ "citation": "Levenberg, 1944; Marquardt, 1963; Fletcher, 1971",
84
+ "url": "https://doi.org/10.1090/qam/10666",
85
+ "summary": "Levenberg-Marquardt — damped least-squares algorithm that interpolates between gradient descent and Gauss-Newton steps for efficient nonlinear least-squares minimization.",
86
+ },
87
  }
88
 
89
  # Selection UI (defaults to query param if valid)
src/pages/OptimizationLeaderboard.py CHANGED
@@ -34,4 +34,5 @@ render_leaderboard(
34
  default_target=1.1,
35
  raw_page="pages/RawData.py",
36
  benchmark_dims=BENCHMARK_DIMS,
 
37
  )
 
34
  default_target=1.1,
35
  raw_page="pages/RawData.py",
36
  benchmark_dims=BENCHMARK_DIMS,
37
+ show_failure_panel=True,
38
  )