Skip to content

Commit 6b95162

Browse files
crop differences
1 parent 8085fcb commit 6b95162

3 files changed

Lines changed: 370 additions & 0 deletions

File tree

cybench/runs/analysis/global_insights_lib.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,96 @@ def compare_horizons(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
600600
return detail, summary
601601

602602

603+
def compare_crops_pairwise(
604+
df: pd.DataFrame,
605+
*,
606+
crop_a: str,
607+
crop_b: str,
608+
) -> tuple[pd.DataFrame, pd.DataFrame]:
609+
"""Compare NRMSE for two crops in countries that have both (inner join on country × model).
610+
611+
Returns (per_pair_detail, per_model_summary).
612+
Delta = crop_b_nrmse − crop_a_nrmse (positive ⇒ crop_a has lower NRMSE).
613+
"""
614+
if df.empty or crop_a == crop_b:
615+
return pd.DataFrame(), pd.DataFrame()
616+
617+
key_cols = ["country", "model"]
618+
for col in key_cols + ["crop", "nrmse"]:
619+
if col not in df.columns:
620+
return pd.DataFrame(), pd.DataFrame()
621+
622+
a_df = df[df["crop"] == crop_a][key_cols + ["nrmse", "r2", "n_samples"]].rename(
623+
columns={"nrmse": "crop_a_nrmse", "r2": "crop_a_r2", "n_samples": "crop_a_samples"}
624+
)
625+
b_df = df[df["crop"] == crop_b][key_cols + ["nrmse", "r2", "n_samples"]].rename(
626+
columns={"nrmse": "crop_b_nrmse", "r2": "crop_b_r2", "n_samples": "crop_b_samples"}
627+
)
628+
if a_df.empty or b_df.empty:
629+
return pd.DataFrame(), pd.DataFrame()
630+
631+
merged = a_df.merge(b_df, on=key_cols, how="inner")
632+
merged = merged[merged["crop_a_nrmse"].notna() & merged["crop_b_nrmse"].notna()].copy()
633+
if merged.empty:
634+
return pd.DataFrame(), pd.DataFrame()
635+
636+
merged["crop_a"] = crop_a
637+
merged["crop_b"] = crop_b
638+
merged["delta_nrmse"] = merged["crop_b_nrmse"] - merged["crop_a_nrmse"]
639+
merged["delta_r2"] = merged["crop_b_r2"] - merged["crop_a_r2"]
640+
merged["crop_a_better"] = merged["delta_nrmse"] > 0
641+
merged["dataset_a"] = crop_a + "_" + merged["country"]
642+
merged["dataset_b"] = crop_b + "_" + merged["country"]
643+
644+
pair_weights = merged[["crop_a_samples", "crop_b_samples"]].min(axis=1).fillna(1).clip(lower=1)
645+
merged["pair_weight"] = pair_weights
646+
647+
model_rows: list[dict[str, Any]] = []
648+
for model, grp in merged.groupby("model", sort=True):
649+
weights = grp["pair_weight"]
650+
model_rows.append(
651+
{
652+
"model": model,
653+
"n_pairs": int(len(grp)),
654+
"n_countries": int(grp["country"].nunique()),
655+
"crop_a_win_rate": float(grp["crop_a_better"].mean()),
656+
"mean_delta_nrmse": float(grp["delta_nrmse"].mean()),
657+
"weighted_delta_nrmse": _weighted_mean(grp["delta_nrmse"], weights),
658+
"mean_crop_a_nrmse": float(grp["crop_a_nrmse"].mean()),
659+
"mean_crop_b_nrmse": float(grp["crop_b_nrmse"].mean()),
660+
}
661+
)
662+
summary = pd.DataFrame(model_rows).sort_values(
663+
["weighted_delta_nrmse", "mean_delta_nrmse"], ascending=[False, False]
664+
)
665+
detail = merged.sort_values(["model", "country"]).reset_index(drop=True)
666+
return detail, summary
667+
668+
669+
def build_crop_comparison_payload(df: pd.DataFrame) -> dict[str, dict[str, Any]]:
670+
"""Per horizon, pairwise crop NRMSE on shared countries only."""
671+
if df.empty:
672+
return {}
673+
crops = _crop_keys(df)
674+
out: dict[str, dict[str, Any]] = {}
675+
for hz in ("eos", "mid"):
676+
hz_df = df[df["batch_horizon"] == hz]
677+
pairs: dict[str, Any] = {}
678+
for i, crop_a in enumerate(crops):
679+
for crop_b in crops[i + 1 :]:
680+
detail, summary = compare_crops_pairwise(hz_df, crop_a=crop_a, crop_b=crop_b)
681+
pair_key = f"{crop_a}_vs_{crop_b}"
682+
pairs[pair_key] = {
683+
"crop_a": crop_a,
684+
"crop_b": crop_b,
685+
"detail": _df_records(detail),
686+
"summary": _df_records(summary),
687+
"overall": _overall_crop_pair_stats(detail, crop_a, crop_b),
688+
}
689+
out[hz] = pairs
690+
return out
691+
692+
603693
def build_insights_payload(output_root: Path, *, version: int = 1) -> dict[str, Any]:
604694
"""Build JSON-serializable payload for the global insights dashboard."""
605695
paths = discover_summary_tables(output_root, version=version)
@@ -640,6 +730,7 @@ def build_insights_payload(output_root: Path, *, version: int = 1) -> dict[str,
640730
"horizon_summary": _df_records(horizon_summary),
641731
"horizon_detail": _df_records(horizon_detail),
642732
"overall_horizon": _overall_horizon_stats(horizon_detail),
733+
"crop_comparison": build_crop_comparison_payload(df),
643734
}
644735

645736

@@ -659,6 +750,41 @@ def _overall_horizon_stats(detail: pd.DataFrame) -> dict[str, Any]:
659750
}
660751

661752

753+
def _overall_crop_pair_stats(
754+
detail: pd.DataFrame, crop_a: str, crop_b: str
755+
) -> dict[str, Any]:
756+
if detail.empty:
757+
return {
758+
"crop_a": crop_a,
759+
"crop_b": crop_b,
760+
"interpretation": (
761+
f"No paired {crop_a}/{crop_b} comparisons in countries with both crops."
762+
),
763+
}
764+
weights = detail["pair_weight"]
765+
paired_countries = sorted(detail["country"].unique())
766+
crop_a_label = crop_a.replace("_", " ")
767+
crop_b_label = crop_b.replace("_", " ")
768+
return {
769+
"crop_a": crop_a,
770+
"crop_b": crop_b,
771+
"n_pairs": int(len(detail)),
772+
"n_countries": int(len(paired_countries)),
773+
"paired_countries": paired_countries,
774+
"crop_a_win_rate": float(detail["crop_a_better"].mean()),
775+
"mean_delta_nrmse": float(detail["delta_nrmse"].mean()),
776+
"weighted_delta_nrmse": float(_weighted_mean(detail["delta_nrmse"], weights)),
777+
"mean_crop_a_nrmse": float(detail["crop_a_nrmse"].mean()),
778+
"mean_crop_b_nrmse": float(detail["crop_b_nrmse"].mean()),
779+
"interpretation": (
780+
f"Paired comparison in countries with both crops. "
781+
f"delta_nrmse = {crop_b_label}{crop_a_label}; "
782+
f"positive values mean {crop_a_label} has lower NRMSE. "
783+
f"Crop A win % = share of country×model pairs where {crop_a_label} wins."
784+
),
785+
}
786+
787+
662788
def _df_records(frame: pd.DataFrame) -> list[dict[str, Any]]:
663789
if frame.empty:
664790
return []

cybench/runs/viz/global_insights_template.html

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,38 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per crop × country × m
246246
</p>
247247
</div>
248248

249+
<div class="card">
250+
<h2>Research Q3 — Which crop is easier to predict (fair comparison)?</h2>
251+
<p class="muted">
252+
Pooling all countries biases crop rankings (e.g. maize-heavy regions may be harder).
253+
Here we keep only <strong>countries with both crops</strong> and compare NRMSE within the same
254+
country and model — a paired crop×country comparison.
255+
</p>
256+
<div class="controls">
257+
<span class="muted">Horizon</span>
258+
<div class="toolbar" id="rq3-horizon-toolbar">
259+
<button type="button" data-horizon="eos" class="active">End of season</button>
260+
<button type="button" data-horizon="mid">Mid-season</button>
261+
</div>
262+
<label>Crop pair
263+
<select id="rq3-pair-filter"></select>
264+
</label>
265+
</div>
266+
<p class="muted" id="crop-pair-note"></p>
267+
<div class="stat-grid" id="crop-pair-stats"></div>
268+
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;">By model (paired countries only)</h3>
269+
<div class="table-scroll" id="crop-pair-summary-wrap"></div>
270+
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3>
271+
<div class="controls">
272+
<label>Model <input type="search" id="crop-detail-model-filter" placeholder="Filter…" /></label>
273+
<label>Country
274+
<select id="crop-detail-country-filter"><option value="">All</option></select>
275+
</label>
276+
</div>
277+
<div class="table-scroll" id="crop-pair-detail-wrap"></div>
278+
<p class="muted note" id="crop-pair-delta-note"></p>
279+
</div>
280+
249281
<p class="muted">
250282
<a href="model_families.html">Model families (paper summary)</a> ·
251283
<a href="index.html">← Back to country dashboards</a>
@@ -774,6 +806,150 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per crop × country × m
774806
countryFilter.addEventListener("change", renderHorizonDetail);
775807
modelFilter.addEventListener("input", renderHorizonDetail);
776808
renderHorizonDetail();
809+
810+
// Crop pair comparison (RQ3)
811+
let rq3Horizon = "eos";
812+
let rq3PairKey = "";
813+
const rq3HorizonToolbar = document.getElementById("rq3-horizon-toolbar");
814+
const rq3PairFilter = document.getElementById("rq3-pair-filter");
815+
const cropPairNote = document.getElementById("crop-pair-note");
816+
const cropPairStats = document.getElementById("crop-pair-stats");
817+
const cropPairDeltaNote = document.getElementById("crop-pair-delta-note");
818+
const cropDetailCountryFilter = document.getElementById("crop-detail-country-filter");
819+
const cropDetailModelFilter = document.getElementById("crop-detail-model-filter");
820+
const cpsWrap = document.getElementById("crop-pair-summary-wrap");
821+
const cpdWrap = document.getElementById("crop-pair-detail-wrap");
822+
823+
function currentCropPairSlice() {
824+
const hz = (DATA.crop_comparison || {})[rq3Horizon] || {};
825+
const keys = Object.keys(hz).sort();
826+
if (!rq3PairKey && keys.length) rq3PairKey = keys[0];
827+
if (rq3PairKey && !hz[rq3PairKey] && keys.length) rq3PairKey = keys[0];
828+
return hz[rq3PairKey] || null;
829+
}
830+
831+
function populateCropPairOptions() {
832+
const hz = (DATA.crop_comparison || {})[rq3Horizon] || {};
833+
const keys = Object.keys(hz).sort();
834+
rq3PairFilter.innerHTML = keys.length
835+
? keys.map(k => {
836+
const p = hz[k];
837+
const label = `${p.crop_a} vs ${p.crop_b}`;
838+
return `<option value="${k}">${label}</option>`;
839+
}).join("")
840+
: `<option value="">No crop pairs</option>`;
841+
if (keys.length && (!rq3PairKey || !hz[rq3PairKey])) rq3PairKey = keys[0];
842+
rq3PairFilter.value = rq3PairKey || "";
843+
}
844+
845+
let cpsSort = "weighted_delta_nrmse";
846+
let cpsDir = "desc";
847+
let cpdSort = "delta_nrmse";
848+
let cpdDir = "desc";
849+
850+
function renderCropPairComparison() {
851+
const slice = currentCropPairSlice();
852+
const overall = slice ? (slice.overall || {}) : {};
853+
const cropA = overall.crop_a || "crop A";
854+
const cropB = overall.crop_b || "crop B";
855+
const cropALabel = cropA.charAt(0).toUpperCase() + cropA.slice(1);
856+
const cropBLabel = cropB.charAt(0).toUpperCase() + cropB.slice(1);
857+
858+
cropPairNote.textContent = overall.interpretation || "No paired crop comparison for this horizon.";
859+
cropPairDeltaNote.textContent = slice
860+
? `Δ NRMSE = ${cropBLabel}${cropALabel}. Positive ⇒ ${cropALabel} has lower NRMSE. `
861+
+ `${cropALabel} win % = share of country×model pairs where ${cropALabel} wins.`
862+
: "";
863+
864+
if (!overall.n_pairs) {
865+
cropPairStats.innerHTML = `<p class="muted">No paired comparisons available.</p>`;
866+
cpsWrap.innerHTML = "";
867+
cpdWrap.innerHTML = "";
868+
return;
869+
}
870+
871+
const countries = overall.paired_countries || [];
872+
cropPairStats.innerHTML = `
873+
<div class="stat"><div class="label">Paired countries</div><div class="value">${overall.n_countries || countries.length}</div></div>
874+
<div class="stat"><div class="label">Country×model pairs</div><div class="value">${overall.n_pairs}</div></div>
875+
<div class="stat"><div class="label">${cropALabel} wins</div><div class="value good">${pct(overall.crop_a_win_rate)}</div></div>
876+
<div class="stat"><div class="label">Mean ${cropALabel} NRMSE</div><div class="value">${fmt(overall.mean_crop_a_nrmse)}</div></div>
877+
<div class="stat"><div class="label">Mean ${cropBLabel} NRMSE</div><div class="value">${fmt(overall.mean_crop_b_nrmse)}</div></div>
878+
<div class="stat"><div class="label">Weighted Δ NRMSE</div><div class="value">${fmt(overall.weighted_delta_nrmse)}</div></div>`;
879+
880+
const cpsCols = [
881+
{ key: "model", label: "Model" },
882+
{ key: "n_countries", label: "Countries" },
883+
{ key: "n_pairs", label: "Pairs" },
884+
{ key: "crop_a_win_rate", label: `${cropALabel} win %`, format: pct },
885+
{ key: "weighted_delta_nrmse", label: "Weighted Δ NRMSE", bg: fixedDeltaBg },
886+
{ key: "mean_delta_nrmse", label: "Mean Δ NRMSE", bg: fixedDeltaBg },
887+
{ key: "mean_crop_a_nrmse", label: `Mean ${cropALabel} NRMSE`, bg: fixedNrmseBg },
888+
{ key: "mean_crop_b_nrmse", label: `Mean ${cropBLabel} NRMSE`, bg: fixedNrmseBg },
889+
];
890+
const summaryRows = [...(slice.summary || [])].sort((a, b) => {
891+
const dir = cpsDir === "asc" ? 1 : -1;
892+
const cmp = compareSortValues(a, b, cpsSort);
893+
if (cmp !== 0) return dir * cmp;
894+
return a.model.localeCompare(b.model);
895+
});
896+
renderSortableTable(cpsWrap, cpsCols, summaryRows, cpsSort, cpsDir, (key) => {
897+
if (cpsSort === key) cpsDir = cpsDir === "asc" ? "desc" : "asc";
898+
else { cpsSort = key; cpsDir = key === "crop_a_win_rate" || key.includes("delta") ? "desc" : "asc"; }
899+
renderCropPairComparison();
900+
});
901+
902+
const detailCountries = [...new Set((slice.detail || []).map(r => r.country))].sort();
903+
cropDetailCountryFilter.innerHTML = `<option value="">All</option>` +
904+
detailCountries.map(c => `<option value="${c}">${c}</option>`).join("");
905+
906+
const cpdCols = [
907+
{ key: "country", label: "Country" },
908+
{ key: "model", label: "Model" },
909+
{ key: "crop_a_nrmse", label: `${cropALabel} NRMSE`, bg: fixedNrmseBg },
910+
{ key: "crop_b_nrmse", label: `${cropBLabel} NRMSE`, bg: fixedNrmseBg },
911+
{ key: "delta_nrmse", label: "Δ NRMSE", bg: fixedDeltaBg },
912+
{ key: "crop_a_r2", label: `${cropALabel} R²` },
913+
{ key: "crop_b_r2", label: `${cropBLabel} R²` },
914+
{ key: "crop_a_samples", label: `${cropALabel} samples`, format: (v) => v == null ? "—" : String(v) },
915+
{ key: "crop_b_samples", label: `${cropBLabel} samples`, format: (v) => v == null ? "—" : String(v) },
916+
];
917+
const cq = cropDetailCountryFilter.value;
918+
const mq = cropDetailModelFilter.value.trim().toLowerCase();
919+
const detailRows = (slice.detail || []).filter(r => {
920+
if (cq && r.country !== cq) return false;
921+
if (mq && !r.model.toLowerCase().includes(mq)) return false;
922+
return true;
923+
}).sort((a, b) => {
924+
const dir = cpdDir === "asc" ? 1 : -1;
925+
const cmp = compareSortValues(a, b, cpdSort);
926+
if (cmp !== 0) return dir * cmp;
927+
return a.model.localeCompare(b.model);
928+
});
929+
renderSortableTable(cpdWrap, cpdCols, detailRows, cpdSort, cpdDir, (key) => {
930+
if (cpdSort === key) cpdDir = cpdDir === "asc" ? "desc" : "asc";
931+
else { cpdSort = key; cpdDir = key === "country" || key === "model" ? "asc" : "desc"; }
932+
renderCropPairComparison();
933+
});
934+
}
935+
936+
rq3HorizonToolbar.querySelectorAll("button").forEach(btn => {
937+
btn.addEventListener("click", () => {
938+
rq3HorizonToolbar.querySelectorAll("button").forEach(b => b.classList.remove("active"));
939+
btn.classList.add("active");
940+
rq3Horizon = btn.dataset.horizon;
941+
populateCropPairOptions();
942+
renderCropPairComparison();
943+
});
944+
});
945+
rq3PairFilter.addEventListener("change", () => {
946+
rq3PairKey = rq3PairFilter.value;
947+
renderCropPairComparison();
948+
});
949+
cropDetailCountryFilter.addEventListener("change", renderCropPairComparison);
950+
cropDetailModelFilter.addEventListener("input", renderCropPairComparison);
951+
populateCropPairOptions();
952+
renderCropPairComparison();
777953
</script>
778954
</body>
779955
</html>

0 commit comments

Comments
 (0)