Skip to content

Commit e1a59fe

Browse files
dahboard
1 parent 006bfc9 commit e1a59fe

3 files changed

Lines changed: 188 additions & 11 deletions

File tree

cybench/runs/analysis/global_insights_lib.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"eos": "End of season (~100% observed)",
2525
}
2626

27+
# Process baselines evaluated only at end-of-season (no mid/qtr walk-forward runs).
28+
EOS_ONLY_HORIZON_CURVE_MODELS: frozenset[str] = frozenset({"lpjml_bc"})
29+
2730

2831
def horizons_in_data(df: pd.DataFrame) -> tuple[str, ...]:
2932
"""Horizons present in a summary frame, ordered by increasing season progress."""
@@ -774,6 +777,68 @@ def _family_curve_points(
774777
return points
775778

776779

780+
def _eos_only_family_points(
781+
work: pd.DataFrame,
782+
*,
783+
model: str,
784+
trend_model: str,
785+
) -> list[dict[str, Any]]:
786+
"""Median EOS metrics for models not run at earlier forecast horizons."""
787+
eos = work[
788+
(work["model"] == model) & (work["batch_horizon"] == "eos") & work["nrmse"].notna()
789+
]
790+
if eos.empty:
791+
return []
792+
793+
trend_eos = work[
794+
(work["model"] == trend_model) & (work["batch_horizon"] == "eos") & work["nrmse"].notna()
795+
]
796+
trend_by_country: dict[str, float] = {}
797+
if not trend_eos.empty and "country" in trend_eos.columns:
798+
for country, grp in trend_eos.groupby("country"):
799+
med = grp["nrmse"].median()
800+
if pd.notna(med) and float(med) > 0:
801+
trend_by_country[str(country)] = float(med)
802+
803+
country_nrmse: list[float] = []
804+
country_skill: list[float] = []
805+
if "country" in eos.columns:
806+
for country, grp in eos.groupby("country"):
807+
med = grp["nrmse"].median()
808+
if pd.isna(med):
809+
continue
810+
nrmse = float(med)
811+
country_nrmse.append(nrmse)
812+
trend_val = trend_by_country.get(str(country))
813+
if trend_val and trend_val > 0:
814+
country_skill.append(1.0 - nrmse / trend_val)
815+
else:
816+
med = eos["nrmse"].median()
817+
if pd.notna(med):
818+
country_nrmse.append(float(med))
819+
820+
nrmse_series = pd.Series(country_nrmse, dtype=float)
821+
skill_series = pd.Series(country_skill, dtype=float)
822+
return [
823+
{
824+
"horizon": "eos",
825+
"median_nrmse": (
826+
round(float(nrmse_series.median()), 4) if not nrmse_series.empty else None
827+
),
828+
"q25_nrmse": (
829+
round(float(nrmse_series.quantile(0.25)), 4) if not nrmse_series.empty else None
830+
),
831+
"q75_nrmse": (
832+
round(float(nrmse_series.quantile(0.75)), 4) if not nrmse_series.empty else None
833+
),
834+
"median_skill_vs_trend": (
835+
round(float(skill_series.median()), 4) if not skill_series.empty else None
836+
),
837+
"n_countries": int(len(country_nrmse)),
838+
}
839+
]
840+
841+
777842
def build_horizon_skill_curves_payload(
778843
df: pd.DataFrame,
779844
*,
@@ -830,18 +895,27 @@ def build_horizon_skill_curves_payload(
830895
model = reps.get(family)
831896
if not model or model == trend_model:
832897
continue
833-
points = _family_curve_points(
834-
wide, model=model, trend_model=trend_model, horizons=horizons
835-
)
898+
eos_only = model in EOS_ONLY_HORIZON_CURVE_MODELS
899+
if eos_only:
900+
points = _eos_only_family_points(work, model=model, trend_model=trend_model)
901+
else:
902+
points = _family_curve_points(
903+
wide, model=model, trend_model=trend_model, horizons=horizons
904+
)
836905
if not points:
837906
continue
907+
n_horizons_with_data = sum(
908+
1 for p in points if p.get("median_nrmse") is not None or p.get("median_skill_vs_trend") is not None
909+
)
838910
families.append(
839911
{
840912
"family": family,
841913
"model": model,
842914
"display": MODEL_DISPLAY_NAMES.get(model, model),
843915
"color": FAMILY_COLORS.get(family, "#666"),
844916
"points": points,
917+
"eos_only": eos_only,
918+
"plot": (not eos_only) and n_horizons_with_data >= 2,
845919
}
846920
)
847921

@@ -865,11 +939,16 @@ def build_horizon_skill_curves_payload(
865939
"note": (
866940
"Fixed model per family (representatives chosen at "
867941
f"{HORIZON_DISPLAY_LABELS.get(rep_source_hz, rep_source_hz)}). "
868-
"Only countries with data at every plotted horizon are included "
942+
"Curves use only countries with data at every plotted horizon "
869943
"(inner join on country×model). Each country contributes one median NRMSE "
870944
"(median across crops when crop=all). IQR band = spread across countries. "
945+
"LPJmL is end-of-season only and appears in the table but not on the curve plot. "
871946
"Hyperparameters are tuned per horizon (screening at each lead time)."
872947
),
948+
"plot_excluded_note": (
949+
"EOS-only baselines (LPJmL) are omitted from the curve plot; see the table for "
950+
"their end-of-season median."
951+
),
873952
"metric_notes": {
874953
"nrmse": "Median pooled NRMSE across countries (lower is better).",
875954
"skill_vs_trend": (

cybench/runs/viz/global_insights_template.html

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ <h2>Forecast horizon vs performance</h2>
267267
<p class="muted" id="horizon-curves-meta"></p>
268268
<div class="curve-chart-wrap" id="horizon-curves-chart"></div>
269269
<div class="curve-legend" id="horizon-curves-legend"></div>
270+
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;">By family (median across countries)</h3>
271+
<div class="table-scroll" id="horizon-curves-table-wrap"></div>
270272
<p class="muted note" id="horizon-curves-metric-note"></p>
271273
</div>
272274

@@ -791,11 +793,14 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
791793
const hscMeta = document.getElementById("horizon-curves-meta");
792794
const hscChart = document.getElementById("horizon-curves-chart");
793795
const hscLegend = document.getElementById("horizon-curves-legend");
796+
const hscTableWrap = document.getElementById("horizon-curves-table-wrap");
794797
const hscMetricNote = document.getElementById("horizon-curves-metric-note");
795798
const hscCropSelect = document.getElementById("horizon-curves-crop");
796799
const hscMetricSelect = document.getElementById("horizon-curves-metric");
797800
let hscCrop = "all";
798801
let hscMetric = "nrmse";
802+
let hscTableSort = "family";
803+
let hscTableDir = "asc";
799804

800805
hscNote.textContent = HSC.note || "Horizon skill curves require at least two collected horizons.";
801806

@@ -825,7 +830,13 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
825830
const slice = (HSC.by_crop || {})[hscCrop] || { families: [], n_countries: 0, countries: [] };
826831
const horizons = HSC.horizons || [];
827832
const metricNotes = HSC.metric_notes || {};
828-
hscMetricNote.textContent = metricNotes[hscMetric] || "";
833+
const plotFamilies = (slice.families || []).filter(fam => fam.plot !== false);
834+
const tableFamilies = slice.families || [];
835+
let metricNote = metricNotes[hscMetric] || "";
836+
if (HSC.plot_excluded_note && (slice.families || []).some(fam => fam.eos_only)) {
837+
metricNote = metricNote ? `${metricNote} ${HSC.plot_excluded_note}` : HSC.plot_excluded_note;
838+
}
839+
hscMetricNote.textContent = metricNote;
829840

830841
const reps = HSC.representatives_summary || "";
831842
const excluded = slice.excluded_countries || [];
@@ -837,9 +848,10 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
837848
}
838849
hscMeta.textContent = meta;
839850

840-
if (!slice.families || !slice.families.length || horizons.length < 2) {
851+
if (!tableFamilies.length || horizons.length < 2) {
841852
hscChart.innerHTML = `<p class="muted">Not enough paired horizon data for this crop.</p>`;
842853
hscLegend.innerHTML = "";
854+
hscTableWrap.innerHTML = `<p class="muted">No horizon table for this crop.</p>`;
843855
return;
844856
}
845857

@@ -851,8 +863,12 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
851863
const nX = horizons.length;
852864
const xAt = (i) => pad.l + (nX <= 1 ? innerW / 2 : (i / (nX - 1)) * innerW);
853865

866+
if (!plotFamilies.length) {
867+
hscChart.innerHTML = `<p class="muted">No multi-horizon families to plot for this crop.</p>`;
868+
hscLegend.innerHTML = "";
869+
} else {
854870
const allVals = [];
855-
for (const fam of slice.families) {
871+
for (const fam of plotFamilies) {
856872
for (const pt of fam.points || []) {
857873
const v = hscMetricValue(pt, hscMetric);
858874
if (v !== null && v !== undefined && !Number.isNaN(v)) allVals.push(v);
@@ -864,8 +880,7 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
864880
if (!allVals.length) {
865881
hscChart.innerHTML = `<p class="muted">No metric values for this selection.</p>`;
866882
hscLegend.innerHTML = "";
867-
return;
868-
}
883+
} else {
869884

870885
const yMinRaw = Math.min(...allVals);
871886
const yMaxRaw = Math.max(...allVals);
@@ -892,7 +907,7 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
892907
svg += `<text x="${pad.l + innerW / 2}" y="${height - 6}" text-anchor="middle" font-size="11" fill="#1f2328">Season progress (more of season observed →)</text>`;
893908
svg += `<text x="14" y="${pad.t + innerH / 2}" text-anchor="middle" font-size="11" fill="#1f2328" transform="rotate(-90 14 ${pad.t + innerH / 2})">${yLabel}</text>`;
894909

895-
for (const fam of slice.families) {
910+
for (const fam of plotFamilies) {
896911
const color = fam.color || "#457b9d";
897912
const pts = (fam.points || []).map((pt, i) => {
898913
const hzIdx = horizons.findIndex(h => h.id === pt.horizon);
@@ -922,9 +937,61 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
922937
svg += "</svg>";
923938
hscChart.innerHTML = svg;
924939

925-
hscLegend.innerHTML = slice.families.map(fam =>
940+
hscLegend.innerHTML = plotFamilies.map(fam =>
926941
`<span><span class="swatch" style="background:${fam.color}"></span>${fam.family} (${fam.display})</span>`
927942
).join("");
943+
}
944+
}
945+
946+
const tableRows = tableFamilies.map(fam => {
947+
const byHz = Object.fromEntries((fam.points || []).map(pt => [pt.horizon, pt]));
948+
const row = {
949+
family: fam.family,
950+
model: fam.display || fam.model,
951+
eos_only: !!fam.eos_only,
952+
};
953+
for (const hz of horizons) {
954+
const pt = byHz[hz.id];
955+
row[`hz_${hz.id}`] = pt ? hscMetricValue(pt, hscMetric) : null;
956+
}
957+
return row;
958+
});
959+
const sortedRows = [...tableRows].sort((a, b) => {
960+
const cmp = compareSortValues(a, b, hscTableSort);
961+
return hscTableDir === "asc" ? cmp : -cmp;
962+
});
963+
const tableCols = [
964+
{ key: "family", label: "Family", sortable: true },
965+
{ key: "model", label: "Model", sortable: true },
966+
...horizons.map(hz => ({
967+
key: `hz_${hz.id}`,
968+
label: hz.label || hz.id,
969+
sortable: true,
970+
format: (v, row) => {
971+
if (v === null || v === undefined) {
972+
return row.eos_only ? '<span class="muted">EOS only</span>' : "—";
973+
}
974+
return fmt(v);
975+
},
976+
bg: (v) => (hscMetric === "nrmse" ? fixedNrmseBg(v) : null),
977+
})),
978+
];
979+
renderSortableTable(
980+
hscTableWrap,
981+
tableCols,
982+
sortedRows,
983+
hscTableSort,
984+
hscTableDir,
985+
(key) => {
986+
if (hscTableSort === key) {
987+
hscTableDir = hscTableDir === "asc" ? "desc" : "asc";
988+
} else {
989+
hscTableSort = key;
990+
hscTableDir = key === "family" || key === "model" ? "asc" : "desc";
991+
}
992+
renderHorizonSkillCurves();
993+
},
994+
);
928995
}
929996

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

tests/runs/test_global_insights.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,10 +534,41 @@ def test_horizon_skill_curves_inner_join_countries():
534534
assert "US" in maize["excluded_countries"]
535535

536536
xgb = next(f for f in maize["families"] if f["model"] == "xgboost")
537+
assert xgb["plot"] is True
538+
assert xgb.get("eos_only") is not True
537539
nrmse_by_hz = {p["horizon"]: p["median_nrmse"] for p in xgb["points"]}
538540
assert nrmse_by_hz["mid"] > nrmse_by_hz["qtr"] > nrmse_by_hz["eos"]
539541

540542

543+
def test_horizon_skill_curves_eos_only_lpjml_in_table_not_plot(tmp_path: Path):
544+
df = _three_horizon_fixture(tmp_path)
545+
lpj_rows = [
546+
{
547+
"crop": "maize",
548+
"country": cc,
549+
"model": "lpjml_bc",
550+
"batch_horizon": "eos",
551+
"nrmse": 0.19,
552+
"r2": 0.5,
553+
"n_samples": 40,
554+
}
555+
for cc in ("DE", "FR")
556+
]
557+
df = pd.concat([df, pd.DataFrame(lpj_rows)], ignore_index=True)
558+
559+
payload = build_horizon_skill_curves_payload(df)
560+
maize = payload["by_crop"]["maize"]
561+
lpj = next(f for f in maize["families"] if f["model"] == "lpjml_bc")
562+
assert lpj["eos_only"] is True
563+
assert lpj["plot"] is False
564+
assert len(lpj["points"]) == 1
565+
assert lpj["points"][0]["horizon"] == "eos"
566+
assert lpj["points"][0]["median_nrmse"] == 0.19
567+
plot_models = {f["model"] for f in maize["families"] if f["plot"]}
568+
assert "lpjml_bc" not in plot_models
569+
assert "plot_excluded_note" in payload
570+
571+
541572
def test_build_insights_payload_includes_qtr_and_curves(tmp_path: Path):
542573
_three_horizon_fixture(tmp_path)
543574
payload = build_insights_payload(tmp_path, version=1)

0 commit comments

Comments
 (0)