Skip to content

Commit 76d9d1b

Browse files
radar love
1 parent 77d3758 commit 76d9d1b

5 files changed

Lines changed: 425 additions & 51 deletions

File tree

cybench/runs/analysis/global_insights_lib.py

Lines changed: 185 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,58 @@
1414

1515
_BASELINE_MODELS = frozenset({"average", "averageyieldmodel", "average_yield"})
1616

17+
# Evaluation views for the model×country heatmap (aligned with country dashboards / radar).
18+
MODEL_COUNTRY_AXES: tuple[dict[str, Any], ...] = (
19+
{
20+
"id": "overall",
21+
"label": "Overall",
22+
"note": "Region×year pooled metrics (all samples).",
23+
"metrics": (
24+
{"id": "r2", "column": "r2", "label": "R²", "higher_better": True},
25+
{"id": "nrmse", "column": "nrmse", "label": "NRMSE", "higher_better": False},
26+
),
27+
},
28+
{
29+
"id": "spatial",
30+
"label": "Spatial",
31+
"note": "R² on regional means (aggregate across years).",
32+
"metrics": (
33+
{"id": "r2", "column": "r2_spatial_agg", "label": "R²", "higher_better": True},
34+
),
35+
},
36+
{
37+
"id": "temporal",
38+
"label": "Temporal",
39+
"note": "R² on yearly national means (aggregate across regions).",
40+
"metrics": (
41+
{"id": "r2", "column": "r2_temporal_agg", "label": "R²", "higher_better": True},
42+
),
43+
},
44+
{
45+
"id": "anomaly",
46+
"label": "Anomaly",
47+
"note": "Pooled R² on location-de-meaned yields (r2_res, else r2_anomaly).",
48+
"metrics": (
49+
{"id": "r2", "column": "r2_res", "label": "R²", "higher_better": True},
50+
),
51+
},
52+
)
53+
54+
_NUMERIC_SUMMARY_COLS = (
55+
"nrmse",
56+
"r2",
57+
"n_samples",
58+
"n_train",
59+
"n_regions",
60+
"n_years",
61+
"r2_spatial",
62+
"r2_spatial_agg",
63+
"r2_temporal",
64+
"r2_temporal_agg",
65+
"r2_anomaly",
66+
"r2_res",
67+
)
68+
1769

1870
def is_baseline_model(model: object) -> bool:
1971
return str(model).lower().replace("-", "_") in _BASELINE_MODELS
@@ -26,6 +78,30 @@ def parse_paper_dir_name(name: str) -> tuple[str, str, int] | None:
2678
return match.group("country").upper(), match.group("horizon"), int(match.group("version"))
2779

2880

81+
def dashboard_href_for_paper_dir(paper_dir_name: str) -> str | None:
82+
"""Relative GitHub Pages path to a country dashboard (e.g. ``de_walk_forward_eos_v1/dashboard.html``)."""
83+
parsed = parse_paper_dir_name(paper_dir_name)
84+
if parsed is None:
85+
return None
86+
country, hz, ver = parsed
87+
slug = f"{country.lower()}_walk_forward_{hz}_v{ver}"
88+
return f"{slug}/dashboard.html"
89+
90+
91+
def build_dashboard_hrefs(output_root: Path, *, version: int = 1) -> dict[str, dict[str, str]]:
92+
"""Map CY-Bench country code -> horizon (``eos``/``mid``) -> dashboard HTML href."""
93+
hrefs: dict[str, dict[str, str]] = {}
94+
for path in discover_summary_tables(output_root, version=version):
95+
parsed = parse_paper_dir_name(path.parent.name)
96+
if parsed is None:
97+
continue
98+
country, hz, _ver = parsed
99+
rel = dashboard_href_for_paper_dir(path.parent.name)
100+
if rel:
101+
hrefs.setdefault(country, {})[hz] = rel
102+
return hrefs
103+
104+
29105
def discover_summary_tables(output_root: Path, *, version: int = 1) -> list[Path]:
30106
"""Return walk_forward_summary.csv paths under paper_walk_forward_* dirs."""
31107
if not output_root.is_dir():
@@ -43,6 +119,79 @@ def discover_summary_tables(output_root: Path, *, version: int = 1) -> list[Path
43119
return paths
44120

45121

122+
def compat_legacy_summary_columns(df: pd.DataFrame) -> pd.DataFrame:
123+
"""Map pre-v2 walk_forward_summary columns to current aggregate metric names.
124+
125+
Older collects stored aggregate spatial/temporal R² in ``r2_spatial`` /
126+
``r2_temporal``; current schema uses ``r2_spatial_agg`` / ``r2_temporal_agg``.
127+
"""
128+
if df.empty:
129+
return df
130+
out = df.copy()
131+
if "r2_spatial_agg" not in out.columns and "r2_spatial" in out.columns:
132+
out["r2_spatial_agg"] = out["r2_spatial"]
133+
if "r2_temporal_agg" not in out.columns and "r2_temporal" in out.columns:
134+
out["r2_temporal_agg"] = out["r2_temporal"]
135+
if "r2_res" not in out.columns and "r2_anomaly" in out.columns:
136+
out["r2_res"] = out["r2_anomaly"]
137+
return out
138+
139+
140+
def _series_for_matrix_column(grp: pd.DataFrame, column: str) -> pd.Series:
141+
"""Return numeric series for a matrix column, with anomaly fallbacks."""
142+
if column == "r2_res":
143+
if "r2_res" in grp.columns:
144+
s = pd.to_numeric(grp["r2_res"], errors="coerce")
145+
if "r2_anomaly" in grp.columns:
146+
return s.fillna(pd.to_numeric(grp["r2_anomaly"], errors="coerce"))
147+
return s
148+
if "r2_anomaly" in grp.columns:
149+
return pd.to_numeric(grp["r2_anomaly"], errors="coerce")
150+
return pd.Series(dtype=float)
151+
if column not in grp.columns:
152+
return pd.Series(dtype=float)
153+
return pd.to_numeric(grp[column], errors="coerce")
154+
155+
156+
def _median_in_group(grp: pd.DataFrame, column: str) -> float | None:
157+
vals = _series_for_matrix_column(grp, column).dropna()
158+
if vals.empty:
159+
return None
160+
return float(vals.median())
161+
162+
163+
def _axis_metrics_for_group(grp: pd.DataFrame) -> dict[str, dict[str, float | None]]:
164+
axes: dict[str, dict[str, float | None]] = {}
165+
for axis in MODEL_COUNTRY_AXES:
166+
metrics: dict[str, float | None] = {}
167+
for spec in axis["metrics"]:
168+
metrics[str(spec["id"])] = _median_in_group(grp, str(spec["column"]))
169+
axes[str(axis["id"])] = metrics
170+
return axes
171+
172+
173+
def matrix_axes_payload() -> list[dict[str, Any]]:
174+
"""JSON-serializable axis definitions for the insights heatmap UI."""
175+
out: list[dict[str, Any]] = []
176+
for axis in MODEL_COUNTRY_AXES:
177+
out.append(
178+
{
179+
"id": axis["id"],
180+
"label": axis["label"],
181+
"note": axis["note"],
182+
"metrics": [
183+
{
184+
"id": m["id"],
185+
"label": m["label"],
186+
"higher_better": m["higher_better"],
187+
}
188+
for m in axis["metrics"]
189+
],
190+
}
191+
)
192+
return out
193+
194+
46195
def load_summary_frame(summary_paths: list[Path]) -> pd.DataFrame:
47196
"""Load and tag rows from multiple country/horizon summary CSVs."""
48197
frames: list[pd.DataFrame] = []
@@ -63,10 +212,10 @@ def load_summary_frame(summary_paths: list[Path]) -> pd.DataFrame:
63212
if not frames:
64213
return pd.DataFrame()
65214
out = pd.concat(frames, ignore_index=True)
66-
for col in ("nrmse", "r2", "n_samples", "n_train", "n_regions", "n_years"):
215+
for col in _NUMERIC_SUMMARY_COLS:
67216
if col in out.columns:
68217
out[col] = pd.to_numeric(out[col], errors="coerce")
69-
return out
218+
return compat_legacy_summary_columns(out)
70219

71220

72221
def _weighted_mean(series: pd.Series, weights: pd.Series) -> float:
@@ -134,24 +283,33 @@ def _filter_summary_work(
134283

135284

136285
def _model_median_by_country(work: pd.DataFrame) -> dict[str, dict[str, Any]]:
137-
"""Per-model median of per-country NRMSE/R².
286+
"""Per-model median of per-country axis metrics.
138287
139-
Each country contributes one value: the median within that country (relevant when
140-
crop=all spans multiple crops in the same country). The model summary is then the
141-
median across those country values — not a median over all crop×country rows pooled.
288+
Each country contributes one value per axis metric: the median within that country
289+
(relevant when crop=all spans multiple crops in the same country). The model summary
290+
is then the median across those country values.
142291
"""
143292
totals: dict[str, dict[str, Any]] = {}
144293
for model, model_grp in work.groupby("model", sort=True):
145-
country_nrmse: list[float] = []
146-
country_r2: list[float] = []
294+
by_country: list[dict[str, dict[str, float | None]]] = []
147295
for _, country_grp in model_grp.groupby("country", sort=True):
148-
country_nrmse.append(float(country_grp["nrmse"].median()))
149-
if "r2" in country_grp.columns:
150-
country_r2.append(float(country_grp["r2"].median()))
151-
totals[str(model)] = {
152-
"median_nrmse": round(float(pd.Series(country_nrmse).median()), 4),
153-
"median_r2": round(float(pd.Series(country_r2).median()), 4) if country_r2 else None,
154-
}
296+
by_country.append(_axis_metrics_for_group(country_grp))
297+
298+
axes: dict[str, dict[str, float | None]] = {}
299+
for axis in MODEL_COUNTRY_AXES:
300+
axis_id = str(axis["id"])
301+
axes[axis_id] = {}
302+
for spec in axis["metrics"]:
303+
metric_id = str(spec["id"])
304+
country_vals = [
305+
c[axis_id][metric_id]
306+
for c in by_country
307+
if c[axis_id].get(metric_id) is not None
308+
]
309+
axes[axis_id][metric_id] = (
310+
round(float(pd.Series(country_vals).median()), 4) if country_vals else None
311+
)
312+
totals[str(model)] = axes
155313
return totals
156314

157315

@@ -177,12 +335,13 @@ def aggregate_model_leaderboard(
177335
for model, grp in work.groupby("model", sort=True):
178336
beat_rate = _beat_baseline_rate(str(model), grp)
179337
totals = by_country[str(model)]
338+
overall = totals.get("overall", {})
180339

181340
rows.append(
182341
{
183342
"model": model,
184-
"median_nrmse": totals["median_nrmse"],
185-
"median_r2": totals["median_r2"] if totals["median_r2"] is not None else float("nan"),
343+
"median_nrmse": overall.get("nrmse"),
344+
"median_r2": overall.get("r2"),
186345
"beat_baseline_rate": beat_rate,
187346
"n_datasets": int(len(grp)),
188347
"n_countries": int(grp["country"].nunique()) if "country" in grp else 0,
@@ -230,7 +389,7 @@ def build_model_country_matrix(
230389
crop: str | None = None,
231390
skilled_only: bool = False,
232391
) -> dict[str, Any]:
233-
"""Model × country matrix (median NRMSE and R² per model×country)."""
392+
"""Model × country matrix (median metrics per evaluation axis)."""
234393
work = _filter_summary_work(
235394
df, batch_horizon=batch_horizon, crop=crop, skilled_only=skilled_only
236395
)
@@ -240,12 +399,16 @@ def build_model_country_matrix(
240399
cells: list[dict[str, Any]] = []
241400
for (model, country), grp in work.groupby(["model", "country"], sort=True):
242401
beat_rate = _beat_baseline_rate(str(model), grp)
402+
axes = _axis_metrics_for_group(grp)
403+
overall = axes.get("overall", {})
243404
cells.append(
244405
{
245406
"model": model,
246407
"country": country,
247-
"median_nrmse": float(grp["nrmse"].median()),
248-
"median_r2": float(grp["r2"].median()) if "r2" in grp else None,
408+
"axes": axes,
409+
# Legacy flat keys for overall (leaderboard parity).
410+
"median_nrmse": overall.get("nrmse"),
411+
"median_r2": overall.get("r2"),
249412
"n_datasets": int(len(grp)),
250413
"beat_baseline_rate": beat_rate,
251414
}
@@ -363,6 +526,8 @@ def build_insights_payload(output_root: Path, *, version: int = 1) -> dict[str,
363526
baseline_models = sorted({str(m) for m in df["model"].unique() if is_baseline_model(m)})
364527
return {
365528
"output_root": str(output_root.resolve()),
529+
"dashboard_hrefs": build_dashboard_hrefs(output_root, version=version),
530+
"matrix_axes": matrix_axes_payload(),
366531
"n_summary_files": len(paths),
367532
"n_rows": int(len(df)),
368533
"n_countries": int(df["country"].nunique()) if "country" in df.columns else 0,

cybench/runs/viz/dashboard_template.html

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ <h3>Model comparison</h3>
221221
<div class="table-scroll" id="heatmap-wrap"></div>
222222
</div>
223223

224-
<div class="card">
224+
<div class="card" id="drill-down">
225225
<h3>Drill-down plots</h3>
226226
<div id="selection-label" class="muted">Click a table row to inspect plots.</div>
227227
<div class="toolbar">
@@ -320,6 +320,28 @@ <h3>Drill-down plots</h3>
320320
let sortKey = "region_year_nrmse";
321321
let sortDir = "asc";
322322
let colorMode = "fixed";
323+
let pendingDeepLink = readDeepLink();
324+
325+
function readDeepLink() {
326+
const params = new URLSearchParams(window.location.search);
327+
const model = params.get("model");
328+
if (!model) return null;
329+
const dataset = params.get("dataset") || "";
330+
const panel = params.get("panel") || "";
331+
return { model, dataset, panel };
332+
}
333+
334+
function applyDeepLinkFilters() {
335+
if (!pendingDeepLink) return;
336+
if (pendingDeepLink.model) modelFilter.value = pendingDeepLink.model;
337+
if (pendingDeepLink.dataset) datasetFilter.value = pendingDeepLink.dataset;
338+
if (["maps", "scatter", "temporal"].includes(pendingDeepLink.panel)) {
339+
activePanel = pendingDeepLink.panel;
340+
panelButtons.forEach(b => {
341+
b.classList.toggle("active", b.dataset.panel === activePanel);
342+
});
343+
}
344+
}
323345

324346
function parseDataset(dataset) {
325347
const idx = dataset.lastIndexOf("_");
@@ -560,6 +582,22 @@ <h3>Drill-down plots</h3>
560582
}
561583
}
562584

585+
if (pendingDeepLink?.model) {
586+
const deep = pendingDeepLink;
587+
const target = metricRows.find(r => {
588+
const rec = JSON.parse(decodeURIComponent(r.dataset.row));
589+
if (rec.model !== deep.model) return false;
590+
if (deep.dataset) return rec.dataset === deep.dataset;
591+
return true;
592+
});
593+
pendingDeepLink = null;
594+
if (target) {
595+
target.click();
596+
document.getElementById("drill-down")?.scrollIntoView({ behavior: "smooth", block: "start" });
597+
return;
598+
}
599+
}
600+
563601
if (metricRows.length > 0) {
564602
metricRows[0].click();
565603
} else {
@@ -645,5 +683,6 @@ <h3>Drill-down plots</h3>
645683
});
646684

647685
buildRows();
686+
applyDeepLinkFilters();
648687
renderHeatmap();
649688
</script>

0 commit comments

Comments
 (0)