Skip to content

Commit dc9e1b1

Browse files
n_samples
1 parent 6a89229 commit dc9e1b1

8 files changed

Lines changed: 114 additions & 28 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
data
22
output
33
outputs/
4+
_build/
45
.venv/
56
venv/
67
__pycache__

cybench/runs/analysis/benchmark_run_catalog.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def _metrics_from_report_yaml(path: Path) -> dict[str, Any]:
196196
raw = OmegaConf.to_container(OmegaConf.load(path))
197197
if not isinstance(raw, dict):
198198
return {}
199-
return flatten_report_metrics(
199+
flat = flatten_report_metrics(
200200
{
201201
"n_regions": raw.get("n_regions"),
202202
"n_years": raw.get("n_years"),
@@ -207,6 +207,9 @@ def _metrics_from_report_yaml(path: Path) -> dict[str, Any]:
207207
"anomaly": raw.get("anomaly") or {},
208208
}
209209
)
210+
if raw.get("n_train") is not None:
211+
flat["n_train"] = raw.get("n_train")
212+
return flat
210213

211214

212215
def load_run_metrics(run: BenchmarkRun, *, seed: int = 42) -> dict[str, Any] | None:
@@ -221,7 +224,7 @@ def load_run_metrics(run: BenchmarkRun, *, seed: int = 42) -> dict[str, Any] | N
221224
return None
222225
return {
223226
k: summary.get(k)
224-
for k in ("n_regions", "n_years", "n_samples") + METRIC_KEYS
227+
for k in ("n_regions", "n_years", "n_samples", "n_train") + METRIC_KEYS
225228
}
226229

227230
metrics_path = _screening_metrics_path(run.path, seed=seed)

cybench/runs/analysis/collect_walk_forward_results.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from pathlib import Path
3838
from typing import Any, cast
3939

40+
import numpy as np
4041
import pandas as pd
4142
import yaml
4243
from omegaconf import OmegaConf
@@ -93,6 +94,54 @@ def resolve_model_column(run_dir: Path, model_slug: str) -> str:
9394

9495
COUNT_METRIC_KEYS: tuple[str, ...] = ("n_regions", "n_years", "n_samples")
9596

97+
_TRAIN_LOG_RE = re.compile(r"Train final model on (\d+) datapoints", re.IGNORECASE)
98+
99+
100+
def _n_train_from_split_metrics(run_dir: Path, seed: int) -> list[int]:
101+
"""Training row counts recorded per walk-forward split (``report_metrics.yaml``)."""
102+
sizes: list[int] = []
103+
for split_dir in sorted(run_dir.iterdir()):
104+
if not split_dir.is_dir() or not re.fullmatch(r"\d{4}", split_dir.name):
105+
continue
106+
metrics_path = split_dir / str(seed) / "report_metrics.yaml"
107+
if not metrics_path.is_file():
108+
continue
109+
try:
110+
raw = yaml.safe_load(metrics_path.read_text(encoding="utf-8")) or {}
111+
except (OSError, yaml.YAMLError):
112+
continue
113+
n_train = raw.get("n_train")
114+
if n_train is not None and not pd.isna(n_train):
115+
sizes.append(int(n_train))
116+
return sizes
117+
118+
119+
def _n_train_from_run_log(run_dir: Path) -> list[int]:
120+
"""Fallback for runs logged before ``n_train`` was written to report_metrics."""
121+
log_path = run_dir / "run_experiments.log"
122+
if not log_path.is_file():
123+
return []
124+
try:
125+
text = log_path.read_text(encoding="utf-8", errors="replace")
126+
except OSError:
127+
return []
128+
return [int(m.group(1)) for m in _TRAIN_LOG_RE.finditer(text)]
129+
130+
131+
def summarize_walk_forward_n_train(run_dir: Path, *, seed: int) -> dict[str, Any]:
132+
"""Mean training rows across walk-forward splits (train window grows per year)."""
133+
sizes = _n_train_from_split_metrics(run_dir, seed)
134+
if not sizes:
135+
sizes = _n_train_from_run_log(run_dir)
136+
if not sizes:
137+
return {"n_train": None, "n_train_splits": 0}
138+
return {
139+
"n_train": int(round(float(np.mean(sizes)))),
140+
"n_train_min": int(min(sizes)),
141+
"n_train_max": int(max(sizes)),
142+
"n_train_splits": len(sizes),
143+
}
144+
96145

97146
def discover_run_seeds(run_dir: Path) -> list[int]:
98147
"""Seed subfolders under walk-forward year splits (e.g. 2016/42/test_preds.csv)."""
@@ -278,6 +327,7 @@ def collect_walk_forward_run(
278327

279328
assert plot_df is not None
280329
summary = aggregate_flat_metrics_across_seeds(per_seed_flat, seeds)
330+
summary.update(summarize_walk_forward_n_train(run.path, seed=plot_seed))
281331
return summary, per_seed_rows, plot_df, model_col, plot_seed
282332

283333

cybench/runs/analysis/global_insights_lib.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def load_summary_frame(summary_paths: list[Path]) -> pd.DataFrame:
6363
if not frames:
6464
return pd.DataFrame()
6565
out = pd.concat(frames, ignore_index=True)
66-
for col in ("nrmse", "r2", "n_samples", "n_regions", "n_years"):
66+
for col in ("nrmse", "r2", "n_samples", "n_train", "n_regions", "n_years"):
6767
if col in out.columns:
6868
out[col] = pd.to_numeric(out[col], errors="coerce")
6969
return out

cybench/runs/analysis/model_family_radar_lib.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,13 @@ def family_for_model(model: str) -> str | None:
195195

196196

197197
SAMPLE_SCATTER_METRICS: tuple[dict[str, str], ...] = (
198-
{"key": "r2", "label": "Overall R²"},
199-
{"key": "r2_spatial", "label": "Spatial R² (med/yr)"},
200-
{"key": "r2_spatial_agg", "label": "Spatial R² (agg)"},
201-
{"key": "r2_temporal", "label": "Temporal R² (med/reg)"},
202-
{"key": "r2_temporal_agg", "label": "Temporal R² (agg)"},
203-
{"key": "r2_anomaly", "label": "Anomaly R² (med/reg)"},
198+
{"key": "nrmse", "label": "Overall NRMSE", "lower_is_better": True},
199+
{"key": "r2", "label": "Overall R²", "lower_is_better": False},
200+
{"key": "r2_spatial", "label": "Spatial R² (med/yr)", "lower_is_better": False},
201+
{"key": "r2_spatial_agg", "label": "Spatial R² (agg)", "lower_is_better": False},
202+
{"key": "r2_temporal", "label": "Temporal R² (med/reg)", "lower_is_better": False},
203+
{"key": "r2_temporal_agg", "label": "Temporal R² (agg)", "lower_is_better": False},
204+
{"key": "r2_anomaly", "label": "Anomaly R² (med/reg)", "lower_is_better": False},
204205
)
205206

206207

@@ -210,15 +211,15 @@ def build_sample_scatter_slice(
210211
batch_horizon: str,
211212
crop: str | None = None,
212213
) -> list[dict[str, Any]]:
213-
"""Per-family points for performance vs pooled test sample count."""
214+
"""Per-family points for performance vs mean walk-forward training set size."""
214215
work = df[df["batch_horizon"] == batch_horizon].copy() if "batch_horizon" in df.columns else df
215216
if crop:
216217
work = work[work["crop"] == crop]
217218
if work.empty:
218219
return []
219220

220221
metric_keys = [m["key"] for m in SAMPLE_SCATTER_METRICS]
221-
for key in metric_keys + ["n_samples"]:
222+
for key in metric_keys + ["n_train"]:
222223
if key in work.columns:
223224
work[key] = pd.to_numeric(work[key], errors="coerce")
224225

@@ -229,8 +230,8 @@ def build_sample_scatter_slice(
229230
continue
230231
points: list[dict[str, Any]] = []
231232
for _, row in sub.iterrows():
232-
n_samples = row.get("n_samples")
233-
if pd.isna(n_samples) or int(n_samples) <= 0:
233+
n_train = row.get("n_train")
234+
if pd.isna(n_train) or int(n_train) <= 0:
234235
continue
235236
model = str(row["model"])
236237
point: dict[str, Any] = {
@@ -239,7 +240,7 @@ def build_sample_scatter_slice(
239240
"country": str(row.get("country", "")),
240241
"crop": str(row.get("crop", "")),
241242
"dataset": f"{row.get('crop', '')}_{row.get('country', '')}",
242-
"n_samples": int(n_samples),
243+
"n_train": int(n_train),
243244
"metrics": {},
244245
}
245246
for metric in metric_keys:

cybench/runs/run_experiments.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ def main(cfg):
364364
target_col=KEY_TARGET,
365365
model_col=model_col,
366366
)
367+
report_metrics["n_train"] = len(train_dataset)
367368
metric_ls.append(eval_metric)
368369
log.info(f"Split {train_test_split[-1]} (seed {seed}) finished with metrics: {eval_metric}")
369370
log.info(

cybench/runs/viz/model_family_radar_template.html

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ <h2>Raw metrics (median across datasets)</h2>
188188
</div>
189189

190190
<div class="card">
191-
<h2>Performance vs test samples</h2>
192-
<p class="muted">Each point is one crop×country dataset. X-axis: pooled walk-forward test rows (region-years).</p>
191+
<h2>Performance vs training set size</h2>
192+
<p class="muted">Each point is one crop×country dataset. X-axis: mean training rows across walk-forward splits (region-years used to fit).</p>
193193
<div class="controls">
194194
<label>Metric
195195
<select id="scatter-metric-select"></select>
@@ -228,11 +228,19 @@ <h2>Performance vs test samples</h2>
228228
});
229229
if (!scatterMetricSelect.options.length) {
230230
const opt = document.createElement("option");
231-
opt.value = "r2";
232-
opt.textContent = "Overall ";
231+
opt.value = "nrmse";
232+
opt.textContent = "Overall NRMSE";
233233
scatterMetricSelect.appendChild(opt);
234234
}
235235

236+
const scatterMetricByKey = Object.fromEntries(
237+
(DATA.sample_scatter_metrics || []).map(m => [m.key, m])
238+
);
239+
240+
function scatterMetricLabel(key) {
241+
return (scatterMetricByKey[key] || {}).label || key;
242+
}
243+
236244
const horizonLabels = { eos: "End of season", mid: "Mid-season" };
237245
Object.keys(DATA.by_horizon).forEach(hz => {
238246
const opt = document.createElement("option");
@@ -336,10 +344,12 @@ <h2>Performance vs test samples</h2>
336344
}
337345

338346
function renderScatterFamilies() {
339-
const metricKey = scatterMetricSelect.value || "r2";
347+
const metricKey = scatterMetricSelect.value || "nrmse";
348+
const metricMeta = scatterMetricByKey[metricKey] || {};
349+
const lowerIsBetter = !!metricMeta.lower_is_better;
340350
const families = currentScatterFamilies();
341351
if (!families.length) {
342-
scatterGrid.innerHTML = '<p class="muted">No scatter data for this selection.</p>';
352+
scatterGrid.innerHTML = '<p class="muted">No scatter data for this selection (re-collect summaries to populate n_train).</p>';
343353
return;
344354
}
345355

@@ -357,12 +367,17 @@ <h2>Performance vs test samples</h2>
357367
const innerW = width - pad.l - pad.r;
358368
const innerH = height - pad.t - pad.b;
359369

360-
const xs = points.map(p => p.n_samples);
370+
const xs = points.map(p => p.n_train);
361371
const ys = points.map(p => p.metrics[metricKey]);
362372
const xMin = Math.min(...xs);
363373
const xMax = Math.max(...xs);
364-
const yMin = Math.min(-0.2, ...ys);
365-
const yMax = Math.max(1.0, ...ys);
374+
const yPad = lowerIsBetter ? 0.05 : 0.2;
375+
const yMin = lowerIsBetter
376+
? Math.max(0, Math.min(...ys) - yPad)
377+
: Math.min(-0.2, ...ys);
378+
const yMax = lowerIsBetter
379+
? Math.max(...ys) + yPad
380+
: Math.max(1.0, ...ys);
366381
const xScale = v => pad.l + ((v - xMin) / (xMax - xMin || 1)) * innerW;
367382
const yScale = v => pad.t + innerH - ((v - yMin) / (yMax - yMin || 1)) * innerH;
368383

@@ -371,7 +386,7 @@ <h2>Performance vs test samples</h2>
371386

372387
let svg = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${fam.family} scatter">`;
373388
svg += `<rect x="${pad.l}" y="${pad.t}" width="${innerW}" height="${innerH}" fill="#fff" stroke="#e6eaef"/>`;
374-
if (yMin < 0 && yMax > 0) {
389+
if (!lowerIsBetter && yMin < 0 && yMax > 0) {
375390
const y0 = yScale(0);
376391
svg += `<line x1="${pad.l}" y1="${y0}" x2="${pad.l + innerW}" y2="${y0}" stroke="#bbb" stroke-dasharray="4 3"/>`;
377392
}
@@ -381,15 +396,15 @@ <h2>Performance vs test samples</h2>
381396
svg += `<text x="${xScale(tx)}" y="${height - 8}" text-anchor="middle" font-size="10" fill="#656d76">${Math.round(tx)}</text>`;
382397
svg += `<text x="8" y="${yScale(ty) + 3}" text-anchor="start" font-size="10" fill="#656d76">${ty.toFixed(2)}</text>`;
383398
}
384-
svg += `<text x="${pad.l + innerW / 2}" y="${height - 2}" text-anchor="middle" font-size="11" fill="#1f2328">Test samples</text>`;
385-
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})"></text>`;
399+
svg += `<text x="${pad.l + innerW / 2}" y="${height - 2}" text-anchor="middle" font-size="11" fill="#1f2328">Training rows (mean)</text>`;
400+
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})">${scatterMetricLabel(metricKey)}</text>`;
386401

387402
points.forEach(p => {
388-
const cx = xScale(p.n_samples);
403+
const cx = xScale(p.n_train);
389404
const cy = yScale(p.metrics[metricKey]);
390405
const col = modelColor(p.model, modelIdx[p.model]);
391406
svg += `<circle cx="${cx}" cy="${cy}" r="5" fill="${col}" fill-opacity="0.85" stroke="#fff" stroke-width="1">
392-
<title>${p.display_name} · ${p.dataset}\nN=${p.n_samples}, ${metricKey}=${p.metrics[metricKey]}</title>
407+
<title>${p.display_name} · ${p.dataset}\nTrain=${p.n_train}, ${metricKey}=${p.metrics[metricKey]}</title>
393408
</circle>`;
394409
});
395410
svg += "</svg>";

tests/runs/test_collect_walk_forward_results.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,18 @@ def test_summary_rows_to_dashboard_records(tmp_path: Path):
237237
scatter_recs = [r for r in records if r.get("images", {}).get("scatter")]
238238
assert scatter_recs
239239
assert "maize_NL" in scatter_recs[0]["images"]["scatter"]
240+
241+
242+
def test_summarize_walk_forward_n_train_from_log(tmp_path: Path):
243+
from cybench.runs.analysis.collect_walk_forward_results import summarize_walk_forward_n_train
244+
245+
run_dir = tmp_path / "maize_DE_tabpfn_walk_forward_eos_20260616_135557"
246+
run_dir.mkdir()
247+
(run_dir / "run_experiments.log").write_text(
248+
"Train final model on 2783 datapoints (years [...])\n"
249+
"Train final model on 3225 datapoints (years [...])\n",
250+
encoding="utf-8",
251+
)
252+
out = summarize_walk_forward_n_train(run_dir, seed=42)
253+
assert out["n_train_splits"] == 2
254+
assert out["n_train"] == 3004

0 commit comments

Comments
 (0)