1111from cybench .runs .analysis .global_insights_lib import (
1212 attach_baseline_metrics ,
1313 discover_summary_tables ,
14- is_baseline_model ,
1514 load_summary_frame ,
1615)
1716
18- # Scientific views → headline metric per axis (from aggregated_metrics / METRIC_KEYS ).
17+ # Scientific views → headline metric per axis (aggregate / pooled, not slice medians ).
1918EVALUATION_VIEWS : tuple [dict [str , str ], ...] = (
2019 {
2120 "label" : "Overall" ,
2221 "metric" : "r2" ,
23- "question" : "Can the model predict crop yields accurately?" ,
22+ "question" : "Can the model predict crop yields accurately (all region×year rows) ?" ,
2423 },
2524 {
2625 "label" : "Spatial" ,
27- "metric" : "r2_spatial " ,
28- "question" : "For a typical year, can it reproduce spatial productivity patterns?" ,
26+ "metric" : "r2_spatial_agg " ,
27+ "question" : "Can it reproduce long-run spatial yield patterns (R² on regional means) ?" ,
2928 },
3029 {
3130 "label" : "Temporal" ,
32- "metric" : "r2_temporal " ,
33- "question" : "For a typical region, can it reproduce year-to-year yield dynamics ?" ,
31+ "metric" : "r2_temporal_agg " ,
32+ "question" : "Can it reproduce national yield trends (R² on yearly national means) ?" ,
3433 },
3534 {
3635 "label" : "Anomaly" ,
37- "metric" : "r2_anomaly " ,
38- "question" : "Can it predict deviations from a region's expected yield ?" ,
36+ "metric" : "r2_res " ,
37+ "question" : "Can it predict location-de-meaned yield deviations (pooled R²) ?" ,
3938 },
4039)
4140
4241VIEW_METRICS : tuple [str , ...] = tuple (v ["metric" ] for v in EVALUATION_VIEWS )
4342
4443MODEL_FAMILIES : dict [str , list [str ]] = {
44+ "Naive baselines" : ["average" , "average_yield" , "trend" ],
4545 "Process-Based" : ["lpjml_bc" , "twso_bc" ],
4646 "Feature-Engineered ML" : ["lightgbm" , "xgboost" , "random_forest" , "ridge" ],
4747 "Sequence / Deep TS" : [
5858 "Tabular Foundation" : ["tabpfn" , "tabicl" , "tabdpt" ],
5959}
6060
61- DEFAULT_REPRESENTATIVES : dict [str , str ] = {
62- "Process-Based" : "lpjml_bc" ,
63- "Feature-Engineered ML" : "lightgbm" ,
64- "Sequence / Deep TS" : "transformer_lf" ,
65- "Tabular Foundation" : "tabpfn" ,
66- }
61+ FAMILY_ORDER : tuple [str , ...] = tuple (MODEL_FAMILIES .keys ())
6762
6863FAMILY_COLORS : dict [str , str ] = {
64+ "Naive baselines" : "#6c757d" ,
6965 "Process-Based" : "#e76f51" ,
7066 "Feature-Engineered ML" : "#2a9d8f" ,
7167 "Sequence / Deep TS" : "#457b9d" ,
7268 "Tabular Foundation" : "#9b5de5" ,
7369}
7470
71+ RADAR_NORMALIZATION_NOTE = (
72+ "Each axis is independently normalized to highlight the relative strengths of "
73+ "each modeling paradigm. Absolute R² values are reported in Table X."
74+ )
75+
7576MODEL_DISPLAY_NAMES : dict [str , str ] = {
77+ "average" : "Average" ,
78+ "average_yield" : "Average" ,
7679 "lpjml_bc" : "LPJmL" ,
7780 "twso_bc" : "TWSO" ,
7881 "lightgbm" : "LightGBM" ,
8588 "tabpfn" : "TabPFN" ,
8689 "tabicl" : "TabICL" ,
8790 "tabdpt" : "TabDPT" ,
91+ "trend" : "Trend" ,
8892}
8993
94+ def is_naive_radar_model (model : object ) -> bool :
95+ slug = str (model ).lower ().replace ("-" , "_" )
96+ return slug in {"average" , "averageyieldmodel" , "average_yield" , "trend" }
97+
9098
9199def _metric_higher_is_better (metric : str ) -> bool :
92100 if metric in HIGHER_IS_BETTER :
@@ -96,30 +104,32 @@ def _metric_higher_is_better(metric: str) -> bool:
96104 return True
97105
98106
99- def _median_per_model (df : pd .DataFrame , metrics : tuple [str , ...]) -> pd .DataFrame :
100- """Median metric per model (one value per crop×country row in *df*)."""
101- if df .empty or "model" not in df .columns :
107+ def _median_for_models (
108+ df : pd .DataFrame , models : list [str ], metrics : tuple [str , ...]
109+ ) -> pd .DataFrame :
110+ """Median metric per model slug (one value per crop×country row in *df*)."""
111+ if df .empty or "model" not in df .columns or not models :
102112 return pd .DataFrame ()
103- work = df [~ df ["model" ].apply ( is_baseline_model )]
113+ work = df [df ["model" ].isin ( models )]
104114 if work .empty :
105115 return pd .DataFrame ()
106116 present = [m for m in metrics if m in work .columns ]
107117 if not present :
108118 return pd .DataFrame ()
109- grouped = work .groupby ("model" , sort = True )[present ].median ()
110- return grouped
119+ return work .groupby ("model" , sort = True )[present ].median ()
111120
112121
113122def pick_representatives (
114123 df : pd .DataFrame ,
115124 * ,
116- selection_metric : str = "r2 " ,
125+ selection_metric : str = "nrmse " ,
117126 overrides : dict [str , str ] | None = None ,
118127) -> dict [str , str ]:
119- """Pick one model slug per family (default: best median overall R² in frame)."""
120- overrides = dict (overrides or DEFAULT_REPRESENTATIVES )
128+ """Pick one model slug per family (default: lowest median NRMSE in frame)."""
129+ overrides = dict (overrides or {} )
121130 chosen : dict [str , str ] = {}
122131 models_in_frame = set (df ["model" ].astype (str )) if "model" in df .columns else set ()
132+ higher_is_better = _metric_higher_is_better (selection_metric )
123133
124134 for family , candidates in MODEL_FAMILIES .items ():
125135 override = overrides .get (family )
@@ -133,12 +143,12 @@ def pick_representatives(
133143 med = med [med .notna ()]
134144 if med .empty :
135145 continue
136- chosen [family ] = str (med .idxmax ())
146+ chosen [family ] = str (med .idxmin () if not higher_is_better else med . idxmax ())
137147 return chosen
138148
139149
140150def relative_scores (raw : pd .DataFrame ) -> pd .DataFrame :
141- """Min–max normalize each view column across all models in *raw* (higher radius = better)."""
151+ """Min–max normalize each view column across family representatives (higher radius = better)."""
142152 out = raw .copy ()
143153 for view in EVALUATION_VIEWS :
144154 label = view ["label" ]
@@ -158,43 +168,66 @@ def relative_scores(raw: pd.DataFrame) -> pd.DataFrame:
158168 return out
159169
160170
161- def _family_records (
171+ def _records_from_medians (
162172 medians : pd .DataFrame ,
163- representatives : dict [str , str ],
173+ rel_all : pd .DataFrame ,
174+ entries : list [tuple [str , str , str , str , bool ]],
164175) -> list [dict [str , Any ]]:
176+ """Build radar rows from median table. *entries*: (index, family, display, color, is_naive)."""
165177 view_labels = [v ["label" ] for v in EVALUATION_VIEWS ]
166- rel_all = relative_scores (medians .copy ()) if not medians .empty else pd .DataFrame ()
167-
168178 rows : list [dict [str , Any ]] = []
169- for family , model in representatives . items () :
170- if model not in medians .index :
179+ for model_key , family , display_name , color , is_naive in entries :
180+ if model_key not in medians .index :
171181 continue
172- raw_row = medians .loc [model ]
182+ raw_row = medians .loc [model_key ]
173183 raw : dict [str , float | None ] = {}
174184 for view in EVALUATION_VIEWS :
175185 val = raw_row .get (view ["metric" ])
176186 raw [view ["metric" ]] = None if pd .isna (val ) else round (float (val ), 4 )
177187 relative = {
178188 label : (
179- round (float (rel_all [label ].loc [model ]), 4 )
180- if label in rel_all .columns and model in rel_all .index
189+ round (float (rel_all [label ].loc [model_key ]), 4 )
190+ if label in rel_all .columns and model_key in rel_all .index
181191 else None
182192 )
183193 for label in view_labels
184194 }
185195 rows .append (
186196 {
187197 "family" : family ,
188- "model" : model ,
189- "display_name" : MODEL_DISPLAY_NAMES .get (model , model ),
190- "color" : FAMILY_COLORS .get (family , "#666" ),
198+ "model" : model_key ,
199+ "display_name" : display_name ,
200+ "color" : color ,
201+ "is_naive" : is_naive ,
191202 "raw" : raw ,
192203 "relative" : relative ,
193204 }
194205 )
195206 return rows
196207
197208
209+ def _family_records (
210+ medians : pd .DataFrame ,
211+ representatives : dict [str , str ],
212+ * ,
213+ rel_all : pd .DataFrame | None = None ,
214+ ) -> list [dict [str , Any ]]:
215+ if rel_all is None :
216+ rel_all = relative_scores (medians .copy ()) if not medians .empty else pd .DataFrame ()
217+ entries = [
218+ (
219+ representatives [family ],
220+ family ,
221+ MODEL_DISPLAY_NAMES .get (representatives [family ], representatives [family ]),
222+ FAMILY_COLORS .get (family , "#666" ),
223+ family == "Naive baselines" ,
224+ )
225+ for family in FAMILY_ORDER
226+ if family in representatives
227+ ]
228+ return _records_from_medians (medians , rel_all , entries )
229+
230+
198231def family_for_model (model : str ) -> str | None :
199232 for family , models in MODEL_FAMILIES .items ():
200233 if model in models :
@@ -361,9 +394,11 @@ def build_radar_slice(
361394 work = df [df ["batch_horizon" ] == batch_horizon ].copy () if "batch_horizon" in df .columns else df
362395 if crop :
363396 work = work [work ["crop" ] == crop ]
364- medians = _median_per_model (work , VIEW_METRICS )
365397 reps = pick_representatives (work , overrides = representatives )
366- families = _family_records (medians , reps )
398+ rep_models = list (reps .values ())
399+ medians = _median_for_models (work , rep_models , VIEW_METRICS )
400+ rel_all = relative_scores (medians .copy ()) if not medians .empty else pd .DataFrame ()
401+ families = _family_records (medians , reps , rel_all = rel_all )
367402 return {
368403 "batch_horizon" : batch_horizon ,
369404 "crop" : crop or "all" ,
@@ -416,9 +451,8 @@ def build_radar_payload(
416451 "by_horizon" : by_horizon ,
417452 "sample_scatter_metric" : SAMPLE_SCATTER_METRIC ,
418453 "sample_scatter" : build_sample_scatter_payload (df , representatives = representatives ),
419- "normalization_note" : (
420- "Each axis is min–max normalized across all models in the selected horizon "
421- "and crop filter. Radar vertices show one representative per family; radii "
422- "indicate where that representative sits relative to the full model field."
454+ "normalization_note" : RADAR_NORMALIZATION_NOTE ,
455+ "representative_selection" : (
456+ "One model per family: lowest median NRMSE across datasets in the selection."
423457 ),
424458 }
0 commit comments