Skip to content

Commit 89fd356

Browse files
continent
1 parent 5338f07 commit 89fd356

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

cybench/runs/analysis/global_insights_lib.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,204 @@ def build_horizon_skill_curves_payload(
12301230
}
12311231

12321232

1233+
CONTINENT_REGION_ORDER: tuple[str, ...] = (
1234+
"Africa",
1235+
"Asia",
1236+
"Europe",
1237+
"North America",
1238+
"South America",
1239+
)
1240+
1241+
1242+
def _country_to_continent() -> dict[str, str]:
1243+
from cybench.config import CONTINENT_DICT
1244+
1245+
out: dict[str, str] = {}
1246+
for continent, codes in CONTINENT_DICT.items():
1247+
for cc in codes:
1248+
out[str(cc).upper()] = continent
1249+
return out
1250+
1251+
1252+
def _continent_region_id(label: str) -> str:
1253+
if label == "Global":
1254+
return "global"
1255+
return label.lower().replace(" ", "_")
1256+
1257+
1258+
def _region_country_sets(work: pd.DataFrame) -> list[tuple[str, str, set[str]]]:
1259+
"""(region_id, label, countries) for Global and each continent with data."""
1260+
if work.empty or "country" not in work.columns:
1261+
return [("global", "Global", set())]
1262+
all_countries = {str(c).upper() for c in work["country"].unique()}
1263+
cc_to_cont = _country_to_continent()
1264+
from cybench.config import CONTINENT_DICT
1265+
1266+
regions: list[tuple[str, str, set[str]]] = [("global", "Global", all_countries)]
1267+
for continent in CONTINENT_REGION_ORDER:
1268+
codes = {str(c).upper() for c in CONTINENT_DICT.get(continent, [])}
1269+
present = all_countries & codes
1270+
if present:
1271+
regions.append((_continent_region_id(continent), continent, present))
1272+
return regions
1273+
1274+
1275+
def _filter_work_to_countries(work: pd.DataFrame, countries: set[str]) -> pd.DataFrame:
1276+
if work.empty or not countries:
1277+
return work.iloc[0:0].copy()
1278+
upper = {str(c).upper() for c in countries}
1279+
return work[work["country"].astype(str).str.upper().isin(upper)].copy()
1280+
1281+
1282+
def build_continent_horizon_payload(
1283+
df: pd.DataFrame,
1284+
*,
1285+
representatives: dict[str, str] | None = None,
1286+
) -> dict[str, Any]:
1287+
"""Per-region horizon medians for EOS family representatives."""
1288+
from cybench.runs.analysis.model_family_radar_lib import (
1289+
FAMILY_COLORS,
1290+
FAMILY_ORDER,
1291+
MODEL_DISPLAY_NAMES,
1292+
pick_representatives,
1293+
)
1294+
1295+
horizons = horizons_in_data(df)
1296+
if len(horizons) < 2:
1297+
return {
1298+
"horizons": [],
1299+
"by_crop": {},
1300+
"note": "Need at least two forecast horizons with collected summaries.",
1301+
}
1302+
1303+
rep_source_hz = "eos" if "eos" in horizons else horizons[-1]
1304+
rep_work = _filter_summary_work(df, batch_horizon=rep_source_hz)
1305+
reps = pick_representatives(rep_work, overrides=representatives)
1306+
trend_model = reps.get("Naive baselines", "trend")
1307+
1308+
value_columns = tuple(c for c in HORIZON_SKILL_VALUE_COLUMNS if c in df.columns)
1309+
crop_keys = ["all", *_crop_keys(df)]
1310+
by_crop: dict[str, Any] = {}
1311+
1312+
for crop_key in crop_keys:
1313+
crop_filter = None if crop_key == "all" else crop_key
1314+
crop_work = _filter_summary_work(df, crop=crop_filter)
1315+
region_specs = _region_country_sets(crop_work)
1316+
regions_meta: list[dict[str, Any]] = []
1317+
entries: list[dict[str, Any]] = []
1318+
1319+
for region_id, region_label, countries in region_specs:
1320+
regional_work = _filter_work_to_countries(crop_work, countries)
1321+
regions_meta.append(
1322+
{
1323+
"id": region_id,
1324+
"label": region_label,
1325+
"n_countries": len(countries),
1326+
}
1327+
)
1328+
if regional_work.empty:
1329+
continue
1330+
for family in FAMILY_ORDER:
1331+
model = reps.get(family)
1332+
if not model or model == trend_model:
1333+
continue
1334+
entry = _build_model_horizon_entry_independent(
1335+
regional_work,
1336+
model=model,
1337+
trend_model=trend_model,
1338+
horizons=horizons,
1339+
value_columns=value_columns,
1340+
)
1341+
if entry is None:
1342+
continue
1343+
entries.append(
1344+
{
1345+
"region": region_label,
1346+
"region_id": region_id,
1347+
"family": family,
1348+
"display": MODEL_DISPLAY_NAMES.get(model, model),
1349+
"color": FAMILY_COLORS.get(family, "#666"),
1350+
**entry,
1351+
}
1352+
)
1353+
1354+
by_crop[crop_key] = {
1355+
"regions": regions_meta,
1356+
"entries": entries,
1357+
}
1358+
1359+
rep_labels = ", ".join(f"{f}: {m}" for f, m in reps.items())
1360+
return {
1361+
"horizons": [
1362+
{"id": hz, "label": HORIZON_DISPLAY_LABELS.get(hz, hz), "order": i}
1363+
for i, hz in enumerate(horizons)
1364+
],
1365+
"representatives_summary": rep_labels,
1366+
"by_crop": by_crop,
1367+
"note": (
1368+
"EOS family representatives only. Median across countries within each region "
1369+
"(each country weighs equally). Horizons scored independently — blank cells mean "
1370+
"no runs at that horizon in that region. Small regions (e.g. Asia, Americas) may "
1371+
"have few countries; check n in tooltips via country counts per horizon in the "
1372+
"exported table."
1373+
),
1374+
}
1375+
1376+
1377+
def build_continent_horizon_table(
1378+
df: pd.DataFrame,
1379+
*,
1380+
crop: str | None = None,
1381+
horizons: tuple[str, ...] | None = None,
1382+
metrics: tuple[str, ...] = ("nrmse", "r2", "r_spatial", "r_temporal", "r_res"),
1383+
representatives: dict[str, str] | None = None,
1384+
) -> pd.DataFrame:
1385+
"""Flatten :func:`build_continent_horizon_payload` to a CSV-friendly table."""
1386+
payload = build_continent_horizon_payload(df, representatives=representatives)
1387+
horizons = horizons or tuple(h["id"] for h in payload.get("horizons", []))
1388+
crop_key = crop or "all"
1389+
slice_data = payload.get("by_crop", {}).get(crop_key, {})
1390+
present_metrics = tuple(m for m in metrics if m in HORIZON_SKILL_VALUE_COLUMNS)
1391+
1392+
rows: list[dict[str, Any]] = []
1393+
for entry in slice_data.get("entries", []):
1394+
row: dict[str, Any] = {
1395+
"region": entry.get("region"),
1396+
"region_id": entry.get("region_id"),
1397+
"family": entry.get("family"),
1398+
"model": entry.get("model"),
1399+
"display_name": entry.get("display"),
1400+
"eos_only": entry.get("eos_only", False),
1401+
}
1402+
points_by_hz = {p["horizon"]: p for p in entry.get("points", [])}
1403+
for hz in horizons:
1404+
pt = points_by_hz.get(hz)
1405+
row[f"n_countries_{hz}"] = pt.get("n_countries") if pt else None
1406+
for metric in present_metrics:
1407+
stats = (pt.get("metrics") or {}).get(metric) if pt else None
1408+
if isinstance(stats, dict):
1409+
row[f"{hz}_{metric}_median"] = stats.get("median")
1410+
row[f"{hz}_{metric}_q25"] = stats.get("q25")
1411+
row[f"{hz}_{metric}_q75"] = stats.get("q75")
1412+
else:
1413+
row[f"{hz}_{metric}_median"] = float("nan")
1414+
row[f"{hz}_{metric}_q25"] = float("nan")
1415+
row[f"{hz}_{metric}_q75"] = float("nan")
1416+
rows.append(row)
1417+
1418+
if not rows:
1419+
return pd.DataFrame()
1420+
region_order = ["Global", *list(CONTINENT_REGION_ORDER)]
1421+
ord_map = {name: i for i, name in enumerate(region_order)}
1422+
out = pd.DataFrame(rows)
1423+
out["_region_ord"] = out["region"].map(lambda r: ord_map.get(r, 99))
1424+
return (
1425+
out.sort_values(["_region_ord", "family", "model"])
1426+
.drop(columns=["_region_ord"])
1427+
.reset_index(drop=True)
1428+
)
1429+
1430+
12331431
def _model_country_count(work: pd.DataFrame, model: str) -> int:
12341432
sub = work[work["model"] == model]
12351433
if sub.empty or "country" not in sub.columns:
@@ -1523,6 +1721,7 @@ def build_insights_payload(output_root: Path, *, version: int = 2) -> dict[str,
15231721
"model_country": model_country,
15241722
"model_country_skilled": model_country_skilled,
15251723
"horizon_skill_curves": build_horizon_skill_curves_payload(df),
1724+
"continent_horizons": build_continent_horizon_payload(df),
15261725
"crop_comparison": build_crop_comparison_payload(df),
15271726
"country_map_cc": country_map_cc,
15281727
"benchmark_map_isos": benchmark_map_isos,

cybench/runs/viz/global_insights_template.html

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,10 @@ <h2>Forecast horizon vs performance</h2>
502502
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;">By family (median across countries)</h3>
503503
<div class="table-scroll" id="horizon-curves-table-wrap"></div>
504504
<p class="muted note" id="horizon-curves-metric-note"></p>
505+
<h3 style="font-size:1rem;margin:1.5rem 0 0.5rem;">By region (median across countries)</h3>
506+
<p class="muted" id="horizon-continent-note"></p>
507+
<p class="muted" id="horizon-continent-meta"></p>
508+
<div class="table-scroll" id="horizon-continent-table-wrap"></div>
505509
</div>
506510

507511
<div class="card">
@@ -2032,11 +2036,16 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
20322036
const hscAxisToolbar = document.getElementById("horizon-curves-axis-toolbar");
20332037
const hscMetricWrap = document.getElementById("horizon-curves-metric-wrap");
20342038
const hscMetricSelect = document.getElementById("horizon-curves-metric");
2039+
const hscContinentNote = document.getElementById("horizon-continent-note");
2040+
const hscContinentMeta = document.getElementById("horizon-continent-meta");
2041+
const hscContinentTableWrap = document.getElementById("horizon-continent-table-wrap");
20352042
let hscCrop = "all";
20362043
let hscAxis = "overall";
20372044
let hscMetric = "nrmse";
20382045
let hscTableSort = "family";
20392046
let hscTableDir = "asc";
2047+
let hscContinentTableSort = "region";
2048+
let hscContinentTableDir = "asc";
20402049

20412050
const HSC_AXES = HSC.axes || MATRIX_AXES.map(axis => ({
20422051
...axis,
@@ -2160,6 +2169,11 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
21602169
}
21612170

21622171
hscNote.textContent = HSC.note || "Horizon skill curves require at least two collected horizons.";
2172+
if (hscContinentNote) {
2173+
hscContinentNote.textContent = (DATA.continent_horizons || {}).note || (
2174+
"Family representatives by continent (median across countries in each region)."
2175+
);
2176+
}
21632177

21642178
const hscCropKeys = Object.keys(HSC.by_crop || {});
21652179
if (hscCropKeys.length) {
@@ -2202,6 +2216,7 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
22022216
hscChart.innerHTML = `<p class="muted">Not enough paired horizon data for this crop.</p>`;
22032217
hscLegend.innerHTML = "";
22042218
hscTableWrap.innerHTML = `<p class="muted">No horizon table for this crop.</p>`;
2219+
renderHorizonContinentTable();
22052220
return;
22062221
}
22072222

@@ -2252,6 +2267,55 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
22522267
metricValueFn: hscMetricValue,
22532268
cellBgFn: hscCellBg,
22542269
});
2270+
renderHorizonContinentTable();
2271+
}
2272+
2273+
function renderHorizonContinentTable() {
2274+
if (!hscContinentTableWrap) return;
2275+
const CONT = DATA.continent_horizons || {};
2276+
const slice = (CONT.by_crop || {})[hscCrop] || { entries: [], regions: [] };
2277+
const horizons = CONT.horizons || HSC.horizons || [];
2278+
const metricCol = hscMetricColumn(hscMetric);
2279+
const entries = slice.entries || [];
2280+
2281+
const regionBits = (slice.regions || [])
2282+
.filter(r => (r.n_countries || 0) > 0)
2283+
.map(r => `${r.label} (${r.n_countries})`);
2284+
let meta = regionBits.length ? regionBits.join(" · ") : "No regional breakdown for this crop.";
2285+
const reps = CONT.representatives_summary || HSC.representatives_summary || "";
2286+
if (reps) meta += ` · Representatives: ${reps}`;
2287+
if (hscContinentMeta) hscContinentMeta.textContent = meta;
2288+
2289+
if (!entries.length || horizons.length < 2) {
2290+
hscContinentTableWrap.innerHTML = `<p class="muted">No regional horizon table for this crop.</p>`;
2291+
return;
2292+
}
2293+
2294+
renderHorizonPointsTable({
2295+
container: hscContinentTableWrap,
2296+
horizons,
2297+
entries,
2298+
leadingCols: [
2299+
{ key: "region", label: "Region", value: entry => entry.region },
2300+
{ key: "family", label: "Family", value: entry => entry.family },
2301+
{ key: "model", label: "Model", value: entry => entry.display || entry.model },
2302+
],
2303+
sortKey: hscContinentTableSort,
2304+
sortDir: hscContinentTableDir,
2305+
onSort: (key) => {
2306+
if (hscContinentTableSort === key) {
2307+
hscContinentTableDir = hscContinentTableDir === "asc" ? "desc" : "asc";
2308+
} else {
2309+
hscContinentTableSort = key;
2310+
hscContinentTableDir = key === "region" || key === "family" || key === "model" ? "asc" : "desc";
2311+
}
2312+
renderHorizonContinentTable();
2313+
},
2314+
metricId: hscMetric,
2315+
metricCol,
2316+
metricValueFn: hscMetricValue,
2317+
cellBgFn: hscCellBg,
2318+
});
22552319
}
22562320

22572321
hscCropSelect.addEventListener("change", () => {

tests/runs/test_global_insights.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
aggregate_model_leaderboard,
1313
attach_baseline_metrics,
1414
build_dashboard_hrefs,
15+
build_continent_horizon_payload,
16+
build_continent_horizon_table,
1517
build_family_models_horizon_table,
1618
build_horizon_skill_curves_payload,
1719
build_insights_payload,
@@ -583,6 +585,73 @@ def test_horizon_skill_curves_eos_only_lpjml_in_table_not_plot(tmp_path: Path):
583585
assert "plot_excluded_note" in payload
584586

585587

588+
def test_continent_horizon_payload_splits_regions():
589+
import tempfile
590+
591+
with tempfile.TemporaryDirectory() as tmp:
592+
df = _three_horizon_fixture(Path(tmp))
593+
za_rows = [
594+
{
595+
"crop": "maize",
596+
"country": "ZA",
597+
"model": model,
598+
"batch_horizon": hz,
599+
"nrmse": nrmse,
600+
"r2": 0.5,
601+
"r_spatial": 0.5,
602+
"r_temporal": 0.4,
603+
"r_res": 0.3,
604+
"n_samples": 30,
605+
}
606+
for hz, nrmse_by_model in [
607+
("mid", {"ridge": 0.40, "trend": 0.44, "xgboost": 0.38, "average": 0.46, "lstm_lf": 0.45}),
608+
("qtr", {"ridge": 0.32, "trend": 0.40, "xgboost": 0.30, "average": 0.42, "lstm_lf": 0.37}),
609+
("eos", {"ridge": 0.25, "trend": 0.38, "xgboost": 0.24, "average": 0.40, "lstm_lf": 0.30}),
610+
]
611+
for model, nrmse in nrmse_by_model.items()
612+
]
613+
df = pd.concat([df, pd.DataFrame(za_rows)], ignore_index=True)
614+
615+
payload = build_continent_horizon_payload(df)
616+
assert len(payload["horizons"]) == 3
617+
maize = payload["by_crop"]["maize"]
618+
regions = {r["label"]: r["n_countries"] for r in maize["regions"]}
619+
assert regions["Global"] == 4
620+
assert regions["Africa"] == 1
621+
assert regions["Europe"] == 2
622+
assert regions["North America"] == 1
623+
624+
europe_xgb = next(
625+
e
626+
for e in maize["entries"]
627+
if e["region"] == "Europe" and e["model"] == "xgboost"
628+
)
629+
africa_xgb = next(
630+
e
631+
for e in maize["entries"]
632+
if e["region"] == "Africa" and e["model"] == "xgboost"
633+
)
634+
europe_eos = next(
635+
p["metrics"]["nrmse"]["median"]
636+
for p in europe_xgb["points"]
637+
if p["horizon"] == "eos"
638+
)
639+
africa_eos = next(
640+
p["metrics"]["nrmse"]["median"]
641+
for p in africa_xgb["points"]
642+
if p["horizon"] == "eos"
643+
)
644+
assert europe_eos < africa_eos
645+
646+
table = build_continent_horizon_table(df, crop="maize")
647+
assert "region" in table.columns
648+
assert set(table["region"]) >= {"Global", "Europe", "Africa"}
649+
650+
insights = build_insights_payload(Path(tmp), version=1)
651+
assert "continent_horizons" in insights
652+
assert insights["continent_horizons"]["by_crop"]["maize"]["regions"]
653+
654+
586655
def test_horizon_models_include_partial_early_coverage():
587656
df = pd.DataFrame(
588657
[

0 commit comments

Comments
 (0)