Skip to content

Commit e4b9440

Browse files
radar love
1 parent dc9e1b1 commit e4b9440

3 files changed

Lines changed: 169 additions & 168 deletions

File tree

cybench/runs/analysis/model_family_radar_lib.py

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
import pandas as pd
99

1010
from cybench.runs.analysis.benchmark_run_catalog import HIGHER_IS_BETTER, LOWER_IS_BETTER
11-
from cybench.runs.analysis.global_insights_lib import discover_summary_tables, load_summary_frame
11+
from cybench.runs.analysis.global_insights_lib import (
12+
attach_baseline_metrics,
13+
discover_summary_tables,
14+
is_baseline_model,
15+
load_summary_frame,
16+
)
1217

1318
# Scientific views → headline metric per axis (from aggregated_metrics / METRIC_KEYS).
1419
EVALUATION_VIEWS: tuple[dict[str, str], ...] = (
@@ -95,10 +100,13 @@ def _median_per_model(df: pd.DataFrame, metrics: tuple[str, ...]) -> pd.DataFram
95100
"""Median metric per model (one value per crop×country row in *df*)."""
96101
if df.empty or "model" not in df.columns:
97102
return pd.DataFrame()
98-
present = [m for m in metrics if m in df.columns]
103+
work = df[~df["model"].apply(is_baseline_model)]
104+
if work.empty:
105+
return pd.DataFrame()
106+
present = [m for m in metrics if m in work.columns]
99107
if not present:
100108
return pd.DataFrame()
101-
grouped = df.groupby("model", sort=True)[present].median()
109+
grouped = work.groupby("model", sort=True)[present].median()
102110
return grouped
103111

104112

@@ -194,83 +202,96 @@ def family_for_model(model: str) -> str | None:
194202
return None
195203

196204

197-
SAMPLE_SCATTER_METRICS: tuple[dict[str, str], ...] = (
198-
{"key": "nrmse", "label": "Overall NRMSE", "lower_is_better": True},
199-
{"key": "r2", "label": "Overall R²", "lower_is_better": False},
200-
{"key": "r2_spatial", "label": "Spatial R² (med/yr)", "lower_is_better": False},
201-
{"key": "r2_spatial_agg", "label": "Spatial R² (agg)", "lower_is_better": False},
202-
{"key": "r2_temporal", "label": "Temporal R² (med/reg)", "lower_is_better": False},
203-
{"key": "r2_temporal_agg", "label": "Temporal R² (agg)", "lower_is_better": False},
204-
{"key": "r2_anomaly", "label": "Anomaly R² (med/reg)", "lower_is_better": False},
205-
)
205+
SAMPLE_SCATTER_METRIC: dict[str, Any] = {
206+
"key": "relative_nrmse",
207+
"label": "NRMSE / average yield",
208+
"baseline_model": "average_yield",
209+
"lower_is_better": True,
210+
"reference": 1.0,
211+
}
206212

207213

208214
def build_sample_scatter_slice(
209215
df: pd.DataFrame,
210216
*,
211217
batch_horizon: str,
212218
crop: str | None = None,
219+
representatives: dict[str, str] | None = None,
213220
) -> list[dict[str, Any]]:
214-
"""Per-family points for performance vs mean walk-forward training set size."""
221+
"""One representative per family: relative NRMSE vs average_yield by training size."""
215222
work = df[df["batch_horizon"] == batch_horizon].copy() if "batch_horizon" in df.columns else df
216223
if crop:
217224
work = work[work["crop"] == crop]
218225
if work.empty:
219226
return []
220227

221-
metric_keys = [m["key"] for m in SAMPLE_SCATTER_METRICS]
222-
for key in metric_keys + ["n_train"]:
228+
for key in ("nrmse", "n_train"):
223229
if key in work.columns:
224230
work[key] = pd.to_numeric(work[key], errors="coerce")
225231

232+
work = attach_baseline_metrics(work)
233+
reps = pick_representatives(work, overrides=representatives)
234+
226235
families_out: list[dict[str, Any]] = []
227-
for family, members in MODEL_FAMILIES.items():
228-
sub = work[work["model"].isin(members)].copy()
236+
for family, model in reps.items():
237+
sub = work[work["model"] == model]
229238
if sub.empty:
230239
continue
231240
points: list[dict[str, Any]] = []
232241
for _, row in sub.iterrows():
233242
n_train = row.get("n_train")
243+
baseline_nrmse = row.get("baseline_nrmse")
244+
nrmse = row.get("nrmse")
234245
if pd.isna(n_train) or int(n_train) <= 0:
235246
continue
236-
model = str(row["model"])
237-
point: dict[str, Any] = {
238-
"model": model,
239-
"display_name": MODEL_DISPLAY_NAMES.get(model, model),
240-
"country": str(row.get("country", "")),
241-
"crop": str(row.get("crop", "")),
242-
"dataset": f"{row.get('crop', '')}_{row.get('country', '')}",
243-
"n_train": int(n_train),
244-
"metrics": {},
245-
}
246-
for metric in metric_keys:
247-
val = row.get(metric)
248-
point["metrics"][metric] = (
249-
None if val is None or pd.isna(val) else round(float(val), 4)
250-
)
251-
points.append(point)
247+
if pd.isna(baseline_nrmse) or float(baseline_nrmse) <= 0 or pd.isna(nrmse):
248+
continue
249+
rel = float(nrmse) / float(baseline_nrmse)
250+
points.append(
251+
{
252+
"model": model,
253+
"display_name": MODEL_DISPLAY_NAMES.get(model, model),
254+
"country": str(row.get("country", "")),
255+
"crop": str(row.get("crop", "")),
256+
"dataset": f"{row.get('crop', '')}_{row.get('country', '')}",
257+
"n_train": int(n_train),
258+
"nrmse": round(float(nrmse), 4),
259+
"baseline_nrmse": round(float(baseline_nrmse), 4),
260+
"relative_nrmse": round(rel, 4),
261+
}
262+
)
252263
if points:
253264
families_out.append(
254265
{
255266
"family": family,
267+
"model": model,
268+
"display_name": MODEL_DISPLAY_NAMES.get(model, model),
256269
"color": FAMILY_COLORS.get(family, "#666"),
257270
"points": points,
258271
}
259272
)
260273
return families_out
261274

262275

263-
def build_sample_scatter_payload(df: pd.DataFrame) -> dict[str, dict[str, list[dict[str, Any]]]]:
276+
def build_sample_scatter_payload(
277+
df: pd.DataFrame,
278+
*,
279+
representatives: dict[str, str] | None = None,
280+
) -> dict[str, dict[str, list[dict[str, Any]]]]:
264281
by_horizon: dict[str, dict[str, list[dict[str, Any]]]] = {}
265282
for hz in ("eos", "mid"):
266283
if "batch_horizon" in df.columns and hz not in set(df["batch_horizon"].astype(str)):
267284
continue
268285
by_crop: dict[str, list[dict[str, Any]]] = {
269-
"all": build_sample_scatter_slice(df, batch_horizon=hz),
286+
"all": build_sample_scatter_slice(
287+
df, batch_horizon=hz, representatives=representatives
288+
),
270289
}
271290
if "crop" in df.columns:
272291
for crop in sorted({str(c) for c in df["crop"].dropna().unique()}):
273-
by_crop[crop] = build_sample_scatter_slice(df, batch_horizon=hz, crop=crop)
292+
by_crop[crop] = build_sample_scatter_slice(
293+
df, batch_horizon=hz, crop=crop, representatives=representatives
294+
)
274295
by_horizon[hz] = by_crop
275296
return by_horizon
276297

@@ -338,8 +359,8 @@ def build_radar_payload(
338359
for family, models in MODEL_FAMILIES.items()
339360
},
340361
"by_horizon": by_horizon,
341-
"sample_scatter_metrics": list(SAMPLE_SCATTER_METRICS),
342-
"sample_scatter": build_sample_scatter_payload(df),
362+
"sample_scatter_metric": SAMPLE_SCATTER_METRIC,
363+
"sample_scatter": build_sample_scatter_payload(df, representatives=representatives),
343364
"normalization_note": (
344365
"Each axis is min–max normalized across all models in the selected horizon "
345366
"and crop filter. Radar vertices show one representative per family; radii "

0 commit comments

Comments
 (0)