Skip to content

Commit 596eab8

Browse files
dashboard
1 parent 6271de3 commit 596eab8

6 files changed

Lines changed: 235 additions & 41 deletions

cybench/runs/analysis/collect_walk_forward_results.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,9 @@ def summary_rows_to_dashboard_records(
395395
("region_year", "r2", "r2"),
396396
("region_year", "nrmse", "nrmse"),
397397
("spatial", "r2", "r2_spatial"),
398+
("spatial", "r2_agg", "r2_spatial_agg"),
398399
("temporal", "r2", "r2_temporal"),
400+
("temporal", "r2_agg", "r2_temporal_agg"),
399401
("anomaly", "r2", "r2_anomaly"),
400402
]
401403
records: list[dict[str, Any]] = []

cybench/runs/analysis/model_family_radar_lib.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,25 +273,80 @@ def build_sample_scatter_slice(
273273
return families_out
274274

275275

276+
def summarize_sample_scatter(families: list[dict[str, Any]]) -> dict[str, Any]:
277+
"""Headline stats for the training-size scatter (percentiles + rank correlation)."""
278+
rows: list[dict[str, Any]] = []
279+
for fam in families:
280+
for p in fam.get("points") or []:
281+
rel = p.get("relative_nrmse")
282+
n_train = p.get("n_train")
283+
if rel is None or n_train is None:
284+
continue
285+
rows.append(
286+
{
287+
"family": fam.get("family"),
288+
"n_train": int(n_train),
289+
"relative_nrmse": float(rel),
290+
}
291+
)
292+
if not rows:
293+
return {}
294+
295+
frame = pd.DataFrame(rows)
296+
n = len(frame)
297+
p05 = float(frame["n_train"].quantile(0.05))
298+
p95 = float(frame["n_train"].quantile(0.95))
299+
core = frame[(frame["n_train"] >= p05) & (frame["n_train"] <= p95)]
300+
rho = float(core["n_train"].corr(core["relative_nrmse"], method="spearman")) if len(core) >= 5 else float("nan")
301+
302+
per_family: dict[str, float | None] = {}
303+
for family, grp in core.groupby("family"):
304+
if len(grp) < 4:
305+
per_family[str(family)] = None
306+
else:
307+
per_family[str(family)] = round(
308+
float(grp["n_train"].corr(grp["relative_nrmse"], method="spearman")), 3
309+
)
310+
311+
return {
312+
"n_points": n,
313+
"n_outliers_x": int(n - len(core)),
314+
"x_p05": int(round(p05)),
315+
"x_p50": int(round(float(frame["n_train"].median()))),
316+
"x_p95": int(round(p95)),
317+
"x_min": int(frame["n_train"].min()),
318+
"x_max": int(frame["n_train"].max()),
319+
"spearman_rho_core": None if pd.isna(rho) else round(rho, 3),
320+
"spearman_family_core": per_family,
321+
}
322+
323+
276324
def build_sample_scatter_payload(
277325
df: pd.DataFrame,
278326
*,
279327
representatives: dict[str, str] | None = None,
280-
) -> dict[str, dict[str, list[dict[str, Any]]]]:
328+
) -> dict[str, dict[str, Any]]:
281329
by_horizon: dict[str, dict[str, list[dict[str, Any]]]] = {}
282330
for hz in ("eos", "mid"):
283331
if "batch_horizon" in df.columns and hz not in set(df["batch_horizon"].astype(str)):
284332
continue
285-
by_crop: dict[str, list[dict[str, Any]]] = {
286-
"all": build_sample_scatter_slice(
287-
df, batch_horizon=hz, representatives=representatives
288-
),
333+
by_crop: dict[str, Any] = {}
334+
all_fams = build_sample_scatter_slice(
335+
df, batch_horizon=hz, representatives=representatives
336+
)
337+
by_crop["all"] = {
338+
"families": all_fams,
339+
"summary": summarize_sample_scatter(all_fams),
289340
}
290341
if "crop" in df.columns:
291342
for crop in sorted({str(c) for c in df["crop"].dropna().unique()}):
292-
by_crop[crop] = build_sample_scatter_slice(
343+
fams = build_sample_scatter_slice(
293344
df, batch_horizon=hz, crop=crop, representatives=representatives
294345
)
346+
by_crop[crop] = {
347+
"families": fams,
348+
"summary": summarize_sample_scatter(fams),
349+
}
295350
by_horizon[hz] = by_crop
296351
return by_horizon
297352

cybench/runs/viz/dashboard_template.html

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@
106106
text-transform: uppercase;
107107
letter-spacing: 0.03em;
108108
}
109+
th.group-head .group-hint {
110+
display: block;
111+
font-weight: 400;
112+
font-size: 0.65rem;
113+
text-transform: none;
114+
letter-spacing: 0;
115+
margin-top: 0.12rem;
116+
line-height: 1.2;
117+
}
118+
th .metric-sub {
119+
font-weight: 500;
120+
font-size: 0.72rem;
121+
color: var(--muted);
122+
}
109123
tr.metric-row { cursor: pointer; }
110124
tr.metric-row:hover td { filter: brightness(0.97); }
111125
tr.metric-row.selected td {
@@ -251,11 +265,41 @@ <h3>Drill-down plots</h3>
251265
{ key: "region_year_r", label: "r", group: "Region-Year", higherBetter: true, title: "Pearson r on all region-year rows" },
252266
{ key: "region_year_r2", label: "R²", group: "Region-Year", higherBetter: true, title: "R² on all region-year rows" },
253267
{ key: "region_year_nrmse", label: "NRMSE", group: "Region-Year", higherBetter: false, title: "Normalized RMSE on absolute yields" },
254-
{ key: "spatial_r2", label: "R² (med/yr)", group: "Spatial", higherBetter: true, title: "Median across years of within-year cross-region R² (years with ≥3 regions)" },
255-
{ key: "spatial_r2_agg", label: "R² (agg)", group: "Spatial", higherBetter: true, title: "R² on regional mean yields (mean across years per region)" },
256-
{ key: "temporal_r2", label: "R² (med/reg)", group: "Temporal", higherBetter: true, title: "Median across regions of within-region cross-year R² (regions with ≥3 years)" },
257-
{ key: "temporal_r2_agg", label: "R² (agg)", group: "Temporal", higherBetter: true, title: "R² on national mean yield series across years" },
258-
{ key: "anomaly_r2", label: "R² (med/reg)", group: "Anomaly", higherBetter: true, title: "Median across regions of R² on yield minus regional mean" },
268+
{
269+
key: "spatial_r2",
270+
label: "R²<br><span class=\"metric-sub\">med/yr</span>",
271+
group: "Spatial",
272+
higherBetter: true,
273+
title: "Slice by year (aggregate across time); R² across regions each year; then median across years (years with ≥3 regions)",
274+
},
275+
{
276+
key: "spatial_r2_agg",
277+
label: "R²<br><span class=\"metric-sub\">agg</span>",
278+
group: "Spatial",
279+
higherBetter: true,
280+
title: "Mean yield across years per region (aggregate across time); then one R² across regions",
281+
},
282+
{
283+
key: "temporal_r2",
284+
label: "R²<br><span class=\"metric-sub\">med/reg</span>",
285+
group: "Temporal",
286+
higherBetter: true,
287+
title: "Slice by region (aggregate across space); R² across years per region; then median across regions (regions with ≥3 years)",
288+
},
289+
{
290+
key: "temporal_r2_agg",
291+
label: "R²<br><span class=\"metric-sub\">agg</span>",
292+
group: "Temporal",
293+
higherBetter: true,
294+
title: "Mean yield across regions each year (aggregate across space); then one R² across years",
295+
},
296+
{
297+
key: "anomaly_r2",
298+
label: "R²<br><span class=\"metric-sub\">med/reg</span>",
299+
group: "Anomaly",
300+
higherBetter: true,
301+
title: "Per region: R² on yield minus regional mean; then median across regions",
302+
},
259303
];
260304

261305
// Absolute color scales — comparable across rows and dashboards.
@@ -442,8 +486,8 @@ <h3>Drill-down plots</h3>
442486
<th rowspan="2" class="sortable" data-sort="n_years">Years ${sortMark("n_years")}</th>
443487
<th rowspan="2" class="sortable" data-sort="n_samples">Samples ${sortMark("n_samples")}</th>
444488
<th colspan="3" class="group-head">Region-Year</th>
445-
<th colspan="1" class="group-head">Spatial</th>
446-
<th colspan="1" class="group-head">Temporal</th>
489+
<th colspan="2" class="group-head" title="Spatial pattern recovery">Spatial<span class="group-hint">slice by year → R² → median</span></th>
490+
<th colspan="2" class="group-head" title="Temporal dynamics recovery">Temporal<span class="group-hint">slice by region → R² → median</span></th>
447491
<th colspan="1" class="group-head">Anomaly</th>
448492
</tr>
449493
<tr>`;

cybench/runs/viz/model_family_radar_template.html

Lines changed: 94 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ <h2>Raw metrics (median across datasets)</h2>
177177

178178
<div class="card">
179179
<h2>Performance vs training set size</h2>
180-
<p class="muted" id="scatter-note">Each point is one crop×country dataset for the family representative. Y-axis: model NRMSE divided by average yield NRMSE (1.0 = baseline; lower is better). X-axis: mean training rows across walk-forward splits.</p>
180+
<p class="muted" id="scatter-note">Each point is one crop×country dataset for the family representative. Y-axis: model NRMSE ÷ average yield NRMSE (1.0 = baseline; lower is better). X-axis: log₁₀(mean training rows); view spans the 5th–95th percentile range.</p>
181181
<div class="scatter-panel" id="scatter-panel"></div>
182182
</div>
183183
</div>
@@ -296,82 +296,150 @@ <h2>Performance vs training set size</h2>
296296
`).join("");
297297
}
298298

299-
function currentScatterFamilies() {
299+
function currentScatterSlice() {
300300
const hz = horizonSelect.value;
301301
const crop = cropSelect.value;
302-
return ((DATA.sample_scatter || {})[hz] || {})[crop] || [];
302+
const raw = ((DATA.sample_scatter || {})[hz] || {})[crop] || [];
303+
if (Array.isArray(raw)) {
304+
return { families: raw, summary: {} };
305+
}
306+
return { families: raw.families || [], summary: raw.summary || {} };
307+
}
308+
309+
function percentile(sorted, p) {
310+
if (!sorted.length) return 0;
311+
const idx = (sorted.length - 1) * p;
312+
const lo = Math.floor(idx);
313+
const hi = Math.ceil(idx);
314+
if (lo === hi) return sorted[lo];
315+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
316+
}
317+
318+
function spearmanRho(xs, ys) {
319+
if (xs.length < 5) return null;
320+
const rank = arr => {
321+
const order = arr.map((v, i) => [v, i]).sort((a, b) => a[0] - b[0]);
322+
const out = new Array(arr.length);
323+
order.forEach(([_, i], r) => { out[i] = r + 1; });
324+
return out;
325+
};
326+
const rx = rank(xs);
327+
const ry = rank(ys);
328+
const mx = rx.reduce((a, b) => a + b, 0) / rx.length;
329+
const my = ry.reduce((a, b) => a + b, 0) / ry.length;
330+
let num = 0, dx = 0, dy = 0;
331+
for (let i = 0; i < rx.length; i++) {
332+
const vx = rx[i] - mx;
333+
const vy = ry[i] - my;
334+
num += vx * vy;
335+
dx += vx * vx;
336+
dy += vy * vy;
337+
}
338+
return dx && dy ? num / Math.sqrt(dx * dy) : null;
303339
}
304340

305341
function renderScatter() {
306342
const metricKey = scatterMetric.key || "relative_nrmse";
307343
const refY = scatterMetric.reference ?? 1.0;
308-
const families = currentScatterFamilies();
344+
const { families, summary } = currentScatterSlice();
309345
const series = families
310346
.map(fam => ({
311347
...fam,
312348
points: (fam.points || []).filter(
313-
p => p[metricKey] !== null && p[metricKey] !== undefined
349+
p => p[metricKey] !== null && p[metricKey] !== undefined && p.n_train > 0
314350
),
315351
}))
316352
.filter(fam => fam.points.length);
317353

318354
if (!series.length) {
319355
scatterPanel.innerHTML =
320356
'<p class="muted">No scatter data for this selection (re-collect summaries with average_yield baseline and n_train).</p>';
357+
scatterNote.textContent =
358+
"Each point is one crop×country dataset for the family representative.";
321359
return;
322360
}
323361

362+
const allPoints = series.flatMap(fam =>
363+
fam.points.map(p => ({ ...p, family: fam.family, color: fam.color, rep: fam.display_name }))
364+
);
365+
const nTrainSorted = allPoints.map(p => p.n_train).sort((a, b) => a - b);
366+
const xP05 = summary.x_p05 ?? Math.round(percentile(nTrainSorted, 0.05));
367+
const xP95 = summary.x_p95 ?? Math.round(percentile(nTrainSorted, 0.95));
368+
const xP50 = summary.x_p50 ?? Math.round(percentile(nTrainSorted, 0.5));
369+
const log = v => Math.log10(Math.max(v, 1));
370+
const logP05 = log(xP05);
371+
const logP95 = log(xP95);
372+
const logPad = 0.04 * (logP95 - logP05 || 1);
373+
374+
const corePts = allPoints.filter(p => p.n_train >= xP05 && p.n_train <= xP95);
375+
const rho = summary.spearman_rho_core ?? spearmanRho(
376+
corePts.map(p => p.n_train),
377+
corePts.map(p => p[metricKey])
378+
);
379+
const nOut = summary.n_outliers_x ?? allPoints.length - corePts.length;
380+
324381
const width = 720;
325-
const height = 360;
326-
const pad = { l: 56, r: 20, t: 20, b: 48 };
382+
const height = 380;
383+
const pad = { l: 58, r: 24, t: 20, b: 52 };
327384
const innerW = width - pad.l - pad.r;
328385
const innerH = height - pad.t - pad.b;
329386

330-
const allPoints = series.flatMap(fam =>
331-
fam.points.map(p => ({ ...p, family: fam.family, color: fam.color, rep: fam.display_name }))
332-
);
333-
const xs = allPoints.map(p => p.n_train);
334387
const ys = allPoints.map(p => p[metricKey]);
335-
const xMin = Math.min(...xs);
336-
const xMax = Math.max(...xs);
337388
const yMin = Math.max(0, Math.min(...ys, refY) - 0.08);
338389
const yMax = Math.max(...ys, refY) + 0.08;
339-
const xScale = v => pad.l + ((v - xMin) / (xMax - xMin || 1)) * innerW;
390+
const xScale = v => pad.l + ((log(v) - logP05 + logPad) / (logP95 - logP05 + 2 * logPad || 1)) * innerW;
340391
const yScale = v => pad.t + innerH - ((v - yMin) / (yMax - yMin || 1)) * innerH;
341392

342393
let svg = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Performance vs training size">`;
343394
svg += `<rect x="${pad.l}" y="${pad.t}" width="${innerW}" height="${innerH}" fill="#fff" stroke="#e6eaef"/>`;
344395

345396
const yRef = yScale(refY);
346397
svg += `<line x1="${pad.l}" y1="${yRef}" x2="${pad.l + innerW}" y2="${yRef}" stroke="#bbb" stroke-dasharray="5 4"/>`;
347-
svg += `<text x="${pad.l + innerW + 4}" y="${yRef + 3}" font-size="10" fill="#656d76">baseline</text>`;
398+
svg += `<text x="${pad.l + innerW + 4}" y="${yRef + 3}" font-size="10" fill="#656d76">1.0</text>`;
348399

400+
const tickVals = [xP05, xP50, xP95].filter((v, i, a) => a.indexOf(v) === i);
401+
tickVals.forEach(tx => {
402+
svg += `<text x="${xScale(tx)}" y="${height - 12}" text-anchor="middle" font-size="10" fill="#656d76">${tx >= 1000 ? `${Math.round(tx / 1000)}k` : tx}</text>`;
403+
});
349404
for (let i = 0; i <= 4; i++) {
350-
const tx = xMin + (i / 4) * (xMax - xMin);
351405
const ty = yMin + (i / 4) * (yMax - yMin);
352-
svg += `<text x="${xScale(tx)}" y="${height - 10}" text-anchor="middle" font-size="10" fill="#656d76">${Math.round(tx)}</text>`;
353406
svg += `<text x="10" y="${yScale(ty) + 3}" text-anchor="start" font-size="10" fill="#656d76">${ty.toFixed(2)}</text>`;
354407
}
355-
svg += `<text x="${pad.l + innerW / 2}" y="${height - 2}" text-anchor="middle" font-size="11" fill="#1f2328">Training rows (mean)</text>`;
408+
svg += `<text x="${pad.l + innerW / 2}" y="${height - 2}" text-anchor="middle" font-size="11" fill="#1f2328">Training rows (mean, log scale; ${xP05.toLocaleString()}${xP95.toLocaleString()})</text>`;
356409
svg += `<text x="16" y="${pad.t + innerH / 2}" text-anchor="middle" font-size="11" fill="#1f2328" transform="rotate(-90 16 ${pad.t + innerH / 2})">${scatterMetric.label || metricKey}</text>`;
357410

358-
series.forEach(fam => {
359-
fam.points.forEach(p => {
360-
const cx = xScale(p.n_train);
361-
const cy = yScale(p[metricKey]);
362-
svg += `<circle cx="${cx}" cy="${cy}" r="6" fill="${fam.color}" fill-opacity="0.82" stroke="#fff" stroke-width="1.2">
363-
<title>${fam.family} · ${p.display_name} · ${p.dataset}
364-
Train=${p.n_train}, rel NRMSE=${p[metricKey]}
411+
allPoints.forEach(p => {
412+
const isOut = p.n_train < xP05 || p.n_train > xP95;
413+
const cx = xScale(Math.min(Math.max(p.n_train, xP05), xP95));
414+
const cy = yScale(p[metricKey]);
415+
const r = isOut ? 5 : 6;
416+
const stroke = isOut ? p.color : "#fff";
417+
const sw = isOut ? 2 : 1.2;
418+
const fill = isOut ? "#fff" : p.color;
419+
const outLabel = isOut ? " [outside range]" : "";
420+
svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" fill-opacity="${isOut ? 1 : 0.82}" stroke="${stroke}" stroke-width="${sw}">
421+
<title>${p.family} · ${p.display_name} · ${p.dataset}${outLabel}
422+
Train=${p.n_train.toLocaleString()}, rel NRMSE=${p[metricKey]}
365423
(model=${p.nrmse}, baseline=${p.baseline_nrmse})</title>
366424
</circle>`;
367-
});
368425
});
369426
svg += "</svg>";
370427

371428
const legend = series.map(fam =>
372429
`<span style="color:${fam.color}"><strong>${fam.family}</strong> · ${fam.display_name}</span>`
373430
).join("");
374431

432+
let statLine = "";
433+
if (rho !== null && rho !== undefined) {
434+
const strength = Math.abs(rho) < 0.15 ? "no clear" : Math.abs(rho) < 0.35 ? "weak" : "moderate";
435+
const dir = rho < -0.05 ? "more data → lower relative NRMSE" : rho > 0.05 ? "more data → higher relative NRMSE" : "flat trend";
436+
statLine = `Spearman ρ = ${Number(rho).toFixed(2)} across datasets in range (${strength} trend; ${dir}). `;
437+
}
438+
scatterNote.textContent =
439+
`Y-axis: model NRMSE ÷ average yield NRMSE (1.0 = baseline). X-axis uses log₁₀ scale over the 5th–95th percentile of training size (median ${xP50.toLocaleString()} rows). `
440+
+ `${nOut} point${nOut === 1 ? "" : "s"} outside that range shown as open circles at the axis edge. `
441+
+ statLine;
442+
375443
scatterPanel.innerHTML = `${svg}<div class="scatter-legend">${legend}</div>`;
376444
}
377445

tests/runs/test_collect_walk_forward_results.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def test_summary_rows_to_dashboard_records(tmp_path: Path):
225225
(assets / "maize_NL_scatter.png").write_bytes(b"png")
226226

227227
records = summary_rows_to_dashboard_records(rows, tmp_path)
228-
assert len(records) == 6
228+
assert len(records) == 8
229229
assert records[0]["view"] == "region_year"
230230
nrmse_rec = next(r for r in records if r["view"] == "region_year" and r["metric"] == "nrmse")
231231
assert nrmse_rec["value"] == 0.31
@@ -234,6 +234,10 @@ def test_summary_rows_to_dashboard_records(tmp_path: Path):
234234
assert r_rec["value_std"] == 0.004
235235
r2_rec = next(r for r in records if r["view"] == "region_year" and r["metric"] == "r2")
236236
assert r2_rec["value_std"] is None
237+
sp_agg = next(r for r in records if r["view"] == "spatial" and r["metric"] == "r2_agg")
238+
assert sp_agg["value"] == -0.93
239+
tm_agg = next(r for r in records if r["view"] == "temporal" and r["metric"] == "r2_agg")
240+
assert tm_agg["value"] == -5.10
237241
scatter_recs = [r for r in records if r.get("images", {}).get("scatter")]
238242
assert scatter_recs
239243
assert "maize_NL" in scatter_recs[0]["images"]["scatter"]

0 commit comments

Comments
 (0)