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
1870def 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+
29105def 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+
46195def 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
72221def _weighted_mean (series : pd .Series , weights : pd .Series ) -> float :
@@ -134,24 +283,33 @@ def _filter_summary_work(
134283
135284
136285def _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 ,
0 commit comments