|
| 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 | + } |
0 commit comments