Skip to content

Commit 77d3758

Browse files
radar love
1 parent 596eab8 commit 77d3758

3 files changed

Lines changed: 156 additions & 71 deletions

File tree

cybench/runs/analysis/model_family_radar_lib.py

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,37 +11,37 @@
1111
from cybench.runs.analysis.global_insights_lib import (
1212
attach_baseline_metrics,
1313
discover_summary_tables,
14-
is_baseline_model,
1514
load_summary_frame,
1615
)
1716

18-
# Scientific views → headline metric per axis (from aggregated_metrics / METRIC_KEYS).
17+
# Scientific views → headline metric per axis (aggregate / pooled, not slice medians).
1918
EVALUATION_VIEWS: tuple[dict[str, str], ...] = (
2019
{
2120
"label": "Overall",
2221
"metric": "r2",
23-
"question": "Can the model predict crop yields accurately?",
22+
"question": "Can the model predict crop yields accurately (all region×year rows)?",
2423
},
2524
{
2625
"label": "Spatial",
27-
"metric": "r2_spatial",
28-
"question": "For a typical year, can it reproduce spatial productivity patterns?",
26+
"metric": "r2_spatial_agg",
27+
"question": "Can it reproduce long-run spatial yield patterns (R² on regional means)?",
2928
},
3029
{
3130
"label": "Temporal",
32-
"metric": "r2_temporal",
33-
"question": "For a typical region, can it reproduce year-to-year yield dynamics?",
31+
"metric": "r2_temporal_agg",
32+
"question": "Can it reproduce national yield trends (R² on yearly national means)?",
3433
},
3534
{
3635
"label": "Anomaly",
37-
"metric": "r2_anomaly",
38-
"question": "Can it predict deviations from a region's expected yield?",
36+
"metric": "r2_res",
37+
"question": "Can it predict location-de-meaned yield deviations (pooled R²)?",
3938
},
4039
)
4140

4241
VIEW_METRICS: tuple[str, ...] = tuple(v["metric"] for v in EVALUATION_VIEWS)
4342

4443
MODEL_FAMILIES: dict[str, list[str]] = {
44+
"Naive baselines": ["average", "average_yield", "trend"],
4545
"Process-Based": ["lpjml_bc", "twso_bc"],
4646
"Feature-Engineered ML": ["lightgbm", "xgboost", "random_forest", "ridge"],
4747
"Sequence / Deep TS": [
@@ -58,21 +58,24 @@
5858
"Tabular Foundation": ["tabpfn", "tabicl", "tabdpt"],
5959
}
6060

61-
DEFAULT_REPRESENTATIVES: dict[str, str] = {
62-
"Process-Based": "lpjml_bc",
63-
"Feature-Engineered ML": "lightgbm",
64-
"Sequence / Deep TS": "transformer_lf",
65-
"Tabular Foundation": "tabpfn",
66-
}
61+
FAMILY_ORDER: tuple[str, ...] = tuple(MODEL_FAMILIES.keys())
6762

6863
FAMILY_COLORS: dict[str, str] = {
64+
"Naive baselines": "#6c757d",
6965
"Process-Based": "#e76f51",
7066
"Feature-Engineered ML": "#2a9d8f",
7167
"Sequence / Deep TS": "#457b9d",
7268
"Tabular Foundation": "#9b5de5",
7369
}
7470

71+
RADAR_NORMALIZATION_NOTE = (
72+
"Each axis is independently normalized to highlight the relative strengths of "
73+
"each modeling paradigm. Absolute R² values are reported in Table X."
74+
)
75+
7576
MODEL_DISPLAY_NAMES: dict[str, str] = {
77+
"average": "Average",
78+
"average_yield": "Average",
7679
"lpjml_bc": "LPJmL",
7780
"twso_bc": "TWSO",
7881
"lightgbm": "LightGBM",
@@ -85,8 +88,13 @@
8588
"tabpfn": "TabPFN",
8689
"tabicl": "TabICL",
8790
"tabdpt": "TabDPT",
91+
"trend": "Trend",
8892
}
8993

94+
def is_naive_radar_model(model: object) -> bool:
95+
slug = str(model).lower().replace("-", "_")
96+
return slug in {"average", "averageyieldmodel", "average_yield", "trend"}
97+
9098

9199
def _metric_higher_is_better(metric: str) -> bool:
92100
if metric in HIGHER_IS_BETTER:
@@ -96,30 +104,32 @@ def _metric_higher_is_better(metric: str) -> bool:
96104
return True
97105

98106

99-
def _median_per_model(df: pd.DataFrame, metrics: tuple[str, ...]) -> pd.DataFrame:
100-
"""Median metric per model (one value per crop×country row in *df*)."""
101-
if df.empty or "model" not in df.columns:
107+
def _median_for_models(
108+
df: pd.DataFrame, models: list[str], metrics: tuple[str, ...]
109+
) -> pd.DataFrame:
110+
"""Median metric per model slug (one value per crop×country row in *df*)."""
111+
if df.empty or "model" not in df.columns or not models:
102112
return pd.DataFrame()
103-
work = df[~df["model"].apply(is_baseline_model)]
113+
work = df[df["model"].isin(models)]
104114
if work.empty:
105115
return pd.DataFrame()
106116
present = [m for m in metrics if m in work.columns]
107117
if not present:
108118
return pd.DataFrame()
109-
grouped = work.groupby("model", sort=True)[present].median()
110-
return grouped
119+
return work.groupby("model", sort=True)[present].median()
111120

112121

113122
def pick_representatives(
114123
df: pd.DataFrame,
115124
*,
116-
selection_metric: str = "r2",
125+
selection_metric: str = "nrmse",
117126
overrides: dict[str, str] | None = None,
118127
) -> dict[str, str]:
119-
"""Pick one model slug per family (default: best median overall R² in frame)."""
120-
overrides = dict(overrides or DEFAULT_REPRESENTATIVES)
128+
"""Pick one model slug per family (default: lowest median NRMSE in frame)."""
129+
overrides = dict(overrides or {})
121130
chosen: dict[str, str] = {}
122131
models_in_frame = set(df["model"].astype(str)) if "model" in df.columns else set()
132+
higher_is_better = _metric_higher_is_better(selection_metric)
123133

124134
for family, candidates in MODEL_FAMILIES.items():
125135
override = overrides.get(family)
@@ -133,12 +143,12 @@ def pick_representatives(
133143
med = med[med.notna()]
134144
if med.empty:
135145
continue
136-
chosen[family] = str(med.idxmax())
146+
chosen[family] = str(med.idxmin() if not higher_is_better else med.idxmax())
137147
return chosen
138148

139149

140150
def relative_scores(raw: pd.DataFrame) -> pd.DataFrame:
141-
"""Min–max normalize each view column across all models in *raw* (higher radius = better)."""
151+
"""Min–max normalize each view column across family representatives (higher radius = better)."""
142152
out = raw.copy()
143153
for view in EVALUATION_VIEWS:
144154
label = view["label"]
@@ -158,43 +168,66 @@ def relative_scores(raw: pd.DataFrame) -> pd.DataFrame:
158168
return out
159169

160170

161-
def _family_records(
171+
def _records_from_medians(
162172
medians: pd.DataFrame,
163-
representatives: dict[str, str],
173+
rel_all: pd.DataFrame,
174+
entries: list[tuple[str, str, str, str, bool]],
164175
) -> list[dict[str, Any]]:
176+
"""Build radar rows from median table. *entries*: (index, family, display, color, is_naive)."""
165177
view_labels = [v["label"] for v in EVALUATION_VIEWS]
166-
rel_all = relative_scores(medians.copy()) if not medians.empty else pd.DataFrame()
167-
168178
rows: list[dict[str, Any]] = []
169-
for family, model in representatives.items():
170-
if model not in medians.index:
179+
for model_key, family, display_name, color, is_naive in entries:
180+
if model_key not in medians.index:
171181
continue
172-
raw_row = medians.loc[model]
182+
raw_row = medians.loc[model_key]
173183
raw: dict[str, float | None] = {}
174184
for view in EVALUATION_VIEWS:
175185
val = raw_row.get(view["metric"])
176186
raw[view["metric"]] = None if pd.isna(val) else round(float(val), 4)
177187
relative = {
178188
label: (
179-
round(float(rel_all[label].loc[model]), 4)
180-
if label in rel_all.columns and model in rel_all.index
189+
round(float(rel_all[label].loc[model_key]), 4)
190+
if label in rel_all.columns and model_key in rel_all.index
181191
else None
182192
)
183193
for label in view_labels
184194
}
185195
rows.append(
186196
{
187197
"family": family,
188-
"model": model,
189-
"display_name": MODEL_DISPLAY_NAMES.get(model, model),
190-
"color": FAMILY_COLORS.get(family, "#666"),
198+
"model": model_key,
199+
"display_name": display_name,
200+
"color": color,
201+
"is_naive": is_naive,
191202
"raw": raw,
192203
"relative": relative,
193204
}
194205
)
195206
return rows
196207

197208

209+
def _family_records(
210+
medians: pd.DataFrame,
211+
representatives: dict[str, str],
212+
*,
213+
rel_all: pd.DataFrame | None = None,
214+
) -> list[dict[str, Any]]:
215+
if rel_all is None:
216+
rel_all = relative_scores(medians.copy()) if not medians.empty else pd.DataFrame()
217+
entries = [
218+
(
219+
representatives[family],
220+
family,
221+
MODEL_DISPLAY_NAMES.get(representatives[family], representatives[family]),
222+
FAMILY_COLORS.get(family, "#666"),
223+
family == "Naive baselines",
224+
)
225+
for family in FAMILY_ORDER
226+
if family in representatives
227+
]
228+
return _records_from_medians(medians, rel_all, entries)
229+
230+
198231
def family_for_model(model: str) -> str | None:
199232
for family, models in MODEL_FAMILIES.items():
200233
if model in models:
@@ -361,9 +394,11 @@ def build_radar_slice(
361394
work = df[df["batch_horizon"] == batch_horizon].copy() if "batch_horizon" in df.columns else df
362395
if crop:
363396
work = work[work["crop"] == crop]
364-
medians = _median_per_model(work, VIEW_METRICS)
365397
reps = pick_representatives(work, overrides=representatives)
366-
families = _family_records(medians, reps)
398+
rep_models = list(reps.values())
399+
medians = _median_for_models(work, rep_models, VIEW_METRICS)
400+
rel_all = relative_scores(medians.copy()) if not medians.empty else pd.DataFrame()
401+
families = _family_records(medians, reps, rel_all=rel_all)
367402
return {
368403
"batch_horizon": batch_horizon,
369404
"crop": crop or "all",
@@ -416,9 +451,8 @@ def build_radar_payload(
416451
"by_horizon": by_horizon,
417452
"sample_scatter_metric": SAMPLE_SCATTER_METRIC,
418453
"sample_scatter": build_sample_scatter_payload(df, representatives=representatives),
419-
"normalization_note": (
420-
"Each axis is min–max normalized across all models in the selected horizon "
421-
"and crop filter. Radar vertices show one representative per family; radii "
422-
"indicate where that representative sits relative to the full model field."
454+
"normalization_note": RADAR_NORMALIZATION_NOTE,
455+
"representative_selection": (
456+
"One model per family: lowest median NRMSE across datasets in the selection."
423457
),
424458
}

cybench/runs/viz/model_family_radar_template.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@
138138
<p class="back"><a href="index.html">← All dashboards</a></p>
139139
<header>
140140
<h1>Model family comparison</h1>
141-
<p class="lead">Relative performance across evaluation views (one representative per model family).</p>
141+
<p class="lead">across Overall, Spatial, Temporal, and Anomaly views one best-NRMSE representative per modeling paradigm (five families).</p>
142142
</header>
143143

144144
<div class="card">

0 commit comments

Comments
 (0)