Skip to content

Commit 9bbf8de

Browse files
dashboard
1 parent 6fe4eb9 commit 9bbf8de

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Aggregate walk-forward summaries for cross-country and horizon comparisons."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import re
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import pandas as pd
11+
12+
_PAPER_DIR_RE = re.compile(
13+
r"^paper_walk_forward_(?P<country>[a-z]{2})_(?P<horizon>eos|mid)_v(?P<version>\d+)$"
14+
)
15+
16+
17+
def parse_paper_dir_name(name: str) -> tuple[str, str, int] | None:
18+
match = _PAPER_DIR_RE.match(name)
19+
if not match:
20+
return None
21+
return match.group("country").upper(), match.group("horizon"), int(match.group("version"))
22+
23+
24+
def discover_summary_tables(output_root: Path, *, version: int = 1) -> list[Path]:
25+
"""Return walk_forward_summary.csv paths under paper_walk_forward_* dirs."""
26+
if not output_root.is_dir():
27+
return []
28+
paths: list[Path] = []
29+
for entry in sorted(output_root.iterdir()):
30+
if not entry.is_dir():
31+
continue
32+
parsed = parse_paper_dir_name(entry.name)
33+
if parsed is None or parsed[2] != version:
34+
continue
35+
summary = entry / "walk_forward_summary.csv"
36+
if summary.is_file():
37+
paths.append(summary)
38+
return paths
39+
40+
41+
def load_summary_frame(summary_paths: list[Path]) -> pd.DataFrame:
42+
"""Load and tag rows from multiple country/horizon summary CSVs."""
43+
frames: list[pd.DataFrame] = []
44+
for path in summary_paths:
45+
parsed = parse_paper_dir_name(path.parent.name)
46+
if parsed is None:
47+
continue
48+
country, batch_hz, version = parsed
49+
df = pd.read_csv(path)
50+
if df.empty:
51+
continue
52+
df = df.copy()
53+
df["country"] = country
54+
df["batch_horizon"] = batch_hz
55+
df["version"] = version
56+
df["paper_dir"] = path.parent.name
57+
frames.append(df)
58+
if not frames:
59+
return pd.DataFrame()
60+
out = pd.concat(frames, ignore_index=True)
61+
for col in ("nrmse", "r2", "n_samples", "n_regions", "n_years"):
62+
if col in out.columns:
63+
out[col] = pd.to_numeric(out[col], errors="coerce")
64+
return out
65+
66+
67+
def _weighted_mean(series: pd.Series, weights: pd.Series) -> float:
68+
mask = series.notna() & weights.notna() & (weights > 0)
69+
if not mask.any():
70+
return float("nan")
71+
s = series[mask]
72+
w = weights[mask]
73+
return float((s * w).sum() / w.sum())
74+
75+
76+
def aggregate_model_leaderboard(df: pd.DataFrame) -> pd.DataFrame:
77+
"""Rank models across countries using sample-weighted mean NRMSE."""
78+
if df.empty or "model" not in df.columns:
79+
return pd.DataFrame()
80+
81+
work = df[df["nrmse"].notna()].copy()
82+
if work.empty:
83+
return pd.DataFrame()
84+
85+
if "n_samples" not in work.columns:
86+
work["n_samples"] = 1.0
87+
work["n_samples"] = work["n_samples"].fillna(1).clip(lower=1)
88+
89+
rows: list[dict[str, Any]] = []
90+
for model, grp in work.groupby("model", sort=True):
91+
rows.append(
92+
{
93+
"model": model,
94+
"weighted_nrmse": _weighted_mean(grp["nrmse"], grp["n_samples"]),
95+
"mean_nrmse": float(grp["nrmse"].mean()),
96+
"median_nrmse": float(grp["nrmse"].median()),
97+
"mean_r2": float(grp["r2"].mean()) if "r2" in grp else float("nan"),
98+
"n_datasets": int(len(grp)),
99+
"n_countries": int(grp["country"].nunique()) if "country" in grp else 0,
100+
"total_samples": int(grp["n_samples"].sum()),
101+
}
102+
)
103+
out = pd.DataFrame(rows)
104+
out = out.sort_values(["weighted_nrmse", "mean_nrmse"], ascending=[True, True])
105+
out.insert(0, "rank", range(1, len(out) + 1))
106+
return out.reset_index(drop=True)
107+
108+
109+
def compare_horizons(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
110+
"""Compare eos vs mid-season NRMSE per (crop, country, model).
111+
112+
Returns (per_pair_detail, per_model_summary).
113+
Delta = mid_nrmse - eos_nrmse (positive ⇒ end-of-season is better).
114+
"""
115+
if df.empty:
116+
return pd.DataFrame(), pd.DataFrame()
117+
118+
eos = df[df["batch_horizon"] == "eos"].copy()
119+
mid = df[df["batch_horizon"] == "mid"].copy()
120+
if eos.empty or mid.empty:
121+
return pd.DataFrame(), pd.DataFrame()
122+
123+
key_cols = ["crop", "country", "model"]
124+
for col in key_cols:
125+
if col not in eos.columns or col not in mid.columns:
126+
return pd.DataFrame(), pd.DataFrame()
127+
128+
eos_keyed = eos[key_cols + ["nrmse", "r2", "n_samples"]].rename(
129+
columns={"nrmse": "eos_nrmse", "r2": "eos_r2", "n_samples": "eos_samples"}
130+
)
131+
mid_keyed = mid[key_cols + ["nrmse", "r2", "n_samples"]].rename(
132+
columns={"nrmse": "mid_nrmse", "r2": "mid_r2", "n_samples": "mid_samples"}
133+
)
134+
merged = eos_keyed.merge(mid_keyed, on=key_cols, how="inner")
135+
merged = merged[merged["eos_nrmse"].notna() & merged["mid_nrmse"].notna()].copy()
136+
if merged.empty:
137+
return pd.DataFrame(), pd.DataFrame()
138+
139+
merged["delta_nrmse"] = merged["mid_nrmse"] - merged["eos_nrmse"]
140+
merged["delta_r2"] = merged["mid_r2"] - merged["eos_r2"]
141+
merged["eos_better"] = merged["delta_nrmse"] > 0
142+
merged["dataset"] = merged["crop"] + "_" + merged["country"]
143+
144+
pair_weights = merged[["eos_samples", "mid_samples"]].min(axis=1).fillna(1).clip(lower=1)
145+
merged["pair_weight"] = pair_weights
146+
147+
model_rows: list[dict[str, Any]] = []
148+
for model, grp in merged.groupby("model", sort=True):
149+
weights = grp["pair_weight"]
150+
model_rows.append(
151+
{
152+
"model": model,
153+
"n_pairs": int(len(grp)),
154+
"eos_win_rate": float(grp["eos_better"].mean()),
155+
"mean_delta_nrmse": float(grp["delta_nrmse"].mean()),
156+
"weighted_delta_nrmse": _weighted_mean(grp["delta_nrmse"], weights),
157+
"mean_eos_nrmse": float(grp["eos_nrmse"].mean()),
158+
"mean_mid_nrmse": float(grp["mid_nrmse"].mean()),
159+
}
160+
)
161+
summary = pd.DataFrame(model_rows).sort_values(
162+
["weighted_delta_nrmse", "mean_delta_nrmse"], ascending=[False, False]
163+
)
164+
detail = merged.sort_values(["model", "country", "crop"]).reset_index(drop=True)
165+
return detail, summary
166+
167+
168+
def build_insights_payload(output_root: Path, *, version: int = 1) -> dict[str, Any]:
169+
"""Build JSON-serializable payload for the global insights dashboard."""
170+
paths = discover_summary_tables(output_root, version=version)
171+
df = load_summary_frame(paths)
172+
leaderboard = aggregate_model_leaderboard(df)
173+
horizon_detail, horizon_summary = compare_horizons(df)
174+
175+
def _df_records(frame: pd.DataFrame) -> list[dict[str, Any]]:
176+
if frame.empty:
177+
return []
178+
out = frame.copy()
179+
for col in out.columns:
180+
if pd.api.types.is_float_dtype(out[col]):
181+
out[col] = out[col].round(4)
182+
return out.where(pd.notna(out), None).to_dict(orient="records")
183+
184+
countries = sorted(df["country"].unique()) if "country" in df.columns else []
185+
return {
186+
"output_root": str(output_root.resolve()),
187+
"n_summary_files": len(paths),
188+
"n_rows": int(len(df)),
189+
"n_countries": int(df["country"].nunique()) if "country" in df.columns else 0,
190+
"countries": countries,
191+
"leaderboard": _df_records(leaderboard),
192+
"horizon_summary": _df_records(horizon_summary),
193+
"horizon_detail": _df_records(horizon_detail),
194+
"overall_horizon": _overall_horizon_stats(horizon_detail),
195+
}
196+
197+
198+
def _overall_horizon_stats(detail: pd.DataFrame) -> dict[str, Any]:
199+
if detail.empty:
200+
return {}
201+
weights = detail["pair_weight"]
202+
return {
203+
"n_pairs": int(len(detail)),
204+
"eos_win_rate": float(detail["eos_better"].mean()),
205+
"mean_delta_nrmse": float(detail["delta_nrmse"].mean()),
206+
"weighted_delta_nrmse": float(_weighted_mean(detail["delta_nrmse"], weights)),
207+
"interpretation": (
208+
"delta_nrmse = mid − eos; positive values mean end-of-season (nowcast) "
209+
"has lower NRMSE than mid-season."
210+
),
211+
}

tests/runs/test_global_insights.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Tests for global insights aggregation."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pandas as pd
8+
9+
from cybench.runs.analysis.global_insights_lib import (
10+
aggregate_model_leaderboard,
11+
compare_horizons,
12+
load_summary_frame,
13+
parse_paper_dir_name,
14+
)
15+
16+
17+
def test_parse_paper_dir_name():
18+
assert parse_paper_dir_name("paper_walk_forward_de_eos_v1") == ("DE", "eos", 1)
19+
assert parse_paper_dir_name("paper_walk_forward_pl_mid_v1") == ("PL", "mid", 1)
20+
assert parse_paper_dir_name("paper_walk_forward") is None
21+
22+
23+
def test_aggregate_model_leaderboard_weighted_nrmse():
24+
df = pd.DataFrame(
25+
[
26+
{"model": "ridge", "nrmse": 0.10, "r2": 0.9, "n_samples": 100, "country": "DE"},
27+
{"model": "ridge", "nrmse": 0.30, "r2": 0.5, "n_samples": 10, "country": "FR"},
28+
{"model": "xgboost", "nrmse": 0.20, "r2": 0.7, "n_samples": 50, "country": "DE"},
29+
]
30+
)
31+
board = aggregate_model_leaderboard(df)
32+
assert list(board["model"]) == ["ridge", "xgboost"]
33+
assert board.loc[board["model"] == "ridge", "weighted_nrmse"].iloc[0] < 0.15
34+
assert int(board.loc[board["model"] == "ridge", "total_samples"].iloc[0]) == 110
35+
36+
37+
def test_compare_horizons_eos_better():
38+
df = pd.DataFrame(
39+
[
40+
{
41+
"crop": "maize",
42+
"country": "DE",
43+
"model": "ridge",
44+
"batch_horizon": "eos",
45+
"nrmse": 0.10,
46+
"r2": 0.9,
47+
"n_samples": 50,
48+
},
49+
{
50+
"crop": "maize",
51+
"country": "DE",
52+
"model": "ridge",
53+
"batch_horizon": "mid",
54+
"nrmse": 0.20,
55+
"r2": 0.7,
56+
"n_samples": 50,
57+
},
58+
]
59+
)
60+
detail, summary = compare_horizons(df)
61+
assert len(detail) == 1
62+
assert detail["delta_nrmse"].iloc[0] == 0.10
63+
assert bool(detail["eos_better"].iloc[0]) is True
64+
assert summary.loc[summary["model"] == "ridge", "eos_win_rate"].iloc[0] == 1.0
65+
66+
67+
def test_load_summary_frame_from_tmp(tmp_path: Path):
68+
de_dir = tmp_path / "paper_walk_forward_de_eos_v1"
69+
de_dir.mkdir()
70+
pd.DataFrame(
71+
[
72+
{
73+
"crop": "maize",
74+
"country": "DE",
75+
"model": "ridge",
76+
"horizon": "eos",
77+
"dataset": "maize_DE",
78+
"nrmse": 0.12,
79+
"r2": 0.8,
80+
"n_samples": 40,
81+
"n_regions": 4,
82+
"n_years": 10,
83+
}
84+
]
85+
).to_csv(de_dir / "walk_forward_summary.csv", index=False)
86+
frame = load_summary_frame([de_dir / "walk_forward_summary.csv"])
87+
assert len(frame) == 1
88+
assert frame["country"].iloc[0] == "DE"
89+
assert frame["batch_horizon"].iloc[0] == "eos"

0 commit comments

Comments
 (0)