|
8 | 8 | import pandas as pd |
9 | 9 |
|
10 | 10 | 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 | +) |
12 | 17 |
|
13 | 18 | # Scientific views → headline metric per axis (from aggregated_metrics / METRIC_KEYS). |
14 | 19 | EVALUATION_VIEWS: tuple[dict[str, str], ...] = ( |
@@ -95,10 +100,13 @@ def _median_per_model(df: pd.DataFrame, metrics: tuple[str, ...]) -> pd.DataFram |
95 | 100 | """Median metric per model (one value per crop×country row in *df*).""" |
96 | 101 | if df.empty or "model" not in df.columns: |
97 | 102 | 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] |
99 | 107 | if not present: |
100 | 108 | return pd.DataFrame() |
101 | | - grouped = df.groupby("model", sort=True)[present].median() |
| 109 | + grouped = work.groupby("model", sort=True)[present].median() |
102 | 110 | return grouped |
103 | 111 |
|
104 | 112 |
|
@@ -194,83 +202,96 @@ def family_for_model(model: str) -> str | None: |
194 | 202 | return None |
195 | 203 |
|
196 | 204 |
|
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 | +} |
206 | 212 |
|
207 | 213 |
|
208 | 214 | def build_sample_scatter_slice( |
209 | 215 | df: pd.DataFrame, |
210 | 216 | *, |
211 | 217 | batch_horizon: str, |
212 | 218 | crop: str | None = None, |
| 219 | + representatives: dict[str, str] | None = None, |
213 | 220 | ) -> 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.""" |
215 | 222 | work = df[df["batch_horizon"] == batch_horizon].copy() if "batch_horizon" in df.columns else df |
216 | 223 | if crop: |
217 | 224 | work = work[work["crop"] == crop] |
218 | 225 | if work.empty: |
219 | 226 | return [] |
220 | 227 |
|
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"): |
223 | 229 | if key in work.columns: |
224 | 230 | work[key] = pd.to_numeric(work[key], errors="coerce") |
225 | 231 |
|
| 232 | + work = attach_baseline_metrics(work) |
| 233 | + reps = pick_representatives(work, overrides=representatives) |
| 234 | + |
226 | 235 | 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] |
229 | 238 | if sub.empty: |
230 | 239 | continue |
231 | 240 | points: list[dict[str, Any]] = [] |
232 | 241 | for _, row in sub.iterrows(): |
233 | 242 | n_train = row.get("n_train") |
| 243 | + baseline_nrmse = row.get("baseline_nrmse") |
| 244 | + nrmse = row.get("nrmse") |
234 | 245 | if pd.isna(n_train) or int(n_train) <= 0: |
235 | 246 | 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 | + ) |
252 | 263 | if points: |
253 | 264 | families_out.append( |
254 | 265 | { |
255 | 266 | "family": family, |
| 267 | + "model": model, |
| 268 | + "display_name": MODEL_DISPLAY_NAMES.get(model, model), |
256 | 269 | "color": FAMILY_COLORS.get(family, "#666"), |
257 | 270 | "points": points, |
258 | 271 | } |
259 | 272 | ) |
260 | 273 | return families_out |
261 | 274 |
|
262 | 275 |
|
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]]]]: |
264 | 281 | by_horizon: dict[str, dict[str, list[dict[str, Any]]]] = {} |
265 | 282 | for hz in ("eos", "mid"): |
266 | 283 | if "batch_horizon" in df.columns and hz not in set(df["batch_horizon"].astype(str)): |
267 | 284 | continue |
268 | 285 | 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 | + ), |
270 | 289 | } |
271 | 290 | if "crop" in df.columns: |
272 | 291 | 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 | + ) |
274 | 295 | by_horizon[hz] = by_crop |
275 | 296 | return by_horizon |
276 | 297 |
|
@@ -338,8 +359,8 @@ def build_radar_payload( |
338 | 359 | for family, models in MODEL_FAMILIES.items() |
339 | 360 | }, |
340 | 361 | "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), |
343 | 364 | "normalization_note": ( |
344 | 365 | "Each axis is min–max normalized across all models in the selected horizon " |
345 | 366 | "and crop filter. Radar vertices show one representative per family; radii " |
|
0 commit comments