Skip to content

Commit ef41b44

Browse files
committed
Add backtest actual-vs-prediction points and render per-window charts
1 parent db9d346 commit ef41b44

4 files changed

Lines changed: 134 additions & 60 deletions

File tree

site/src/layouts/ForecastPage.astro

Lines changed: 112 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ const { data } = Astro.props;
66
const history = data.history ?? [];
77
const forecast = data.forecast ?? [];
88
const backtest = data.backtest ?? [];
9+
const backtestPoints = data.backtest_points ?? [];
910
1011
const runTitle = data?.meta?.slug || data.request?.run_name_root || (data.request?.series_names || []).join(', ') || 'series';
1112
1213
const seriesIds = [...new Set([
1314
...history.map((r) => r.unique_id),
1415
...forecast.map((r) => r.unique_id),
16+
...backtestPoints.map((r) => r.unique_id),
1517
])];
1618
1719
const parseDs = (v) => {
@@ -21,13 +23,18 @@ const parseDs = (v) => {
2123
return Number.isNaN(d.getTime()) ? null : d;
2224
};
2325
24-
const svgChartForSeries = (seriesId, valuesType = 'forecast') => {
25-
const hRows = history.filter((r) => r.unique_id === seriesId).map((r) => ({ ...r, iso: parseDs(r.ds)?.toISOString() })).filter((r) => r.iso);
26-
const fRows = forecast.filter((r) => r.unique_id === seriesId).map((r) => ({ ...r, iso: parseDs(r.ds)?.toISOString() })).filter((r) => r.iso);
27-
const labels = [...new Set([...hRows.map((r) => r.iso), ...fRows.map((r) => r.iso)])].sort();
26+
const buildChartState = (actualRows, forecastRows, h = 320) => {
27+
const aRows = actualRows
28+
.map((r) => ({ ...r, iso: parseDs(r.ds)?.toISOString() }))
29+
.filter((r) => r.iso);
30+
const fRows = forecastRows
31+
.map((r) => ({ ...r, iso: parseDs(r.ds)?.toISOString() }))
32+
.filter((r) => r.iso);
33+
34+
const labels = [...new Set([...aRows.map((r) => r.iso), ...fRows.map((r) => r.iso)])].sort();
2835
2936
const actualValues = labels.map((l) => {
30-
const row = hRows.find((r) => r.iso === l);
37+
const row = aRows.find((r) => r.iso === l);
3138
return row ? Number(row.y) : null;
3239
});
3340
@@ -44,11 +51,12 @@ const svgChartForSeries = (seriesId, valuesType = 'forecast') => {
4451
...actualValues.filter((v) => Number.isFinite(v)),
4552
...modelSeries.flatMap((s) => s.values.filter((v) => Number.isFinite(v))),
4653
];
54+
4755
const yMin = allY.length ? Math.min(...allY) : 0;
4856
const yMax = allY.length ? Math.max(...allY) : 1;
4957
5058
const W = 920;
51-
const H = valuesType === 'backtest' ? 240 : 320;
59+
const H = h;
5260
const PAD = { t: 18, r: 18, b: 40, l: 44 };
5361
const innerW = W - PAD.l - PAD.r;
5462
const innerH = H - PAD.t - PAD.b;
@@ -77,31 +85,32 @@ const svgChartForSeries = (seriesId, valuesType = 'forecast') => {
7785
return { W, H, PAD, innerW, innerH, labels, actualValues, actualPath, modelSeries, colors, xFor, yFor, yMin, yMax };
7886
};
7987
80-
81-
const buildSmapeSpark = (rows) => {
82-
const W = 920, H = 200, PAD = { t: 16, r: 16, b: 32, l: 38 };
83-
const vals = rows.map((r) => Number(r.smape || 0));
84-
const min = vals.length ? Math.min(...vals) : 0;
85-
const max = vals.length ? Math.max(...vals) : 1;
86-
const range = max - min || 1;
87-
const x = (i) => PAD.l + (rows.length <= 1 ? 0 : (i / (rows.length - 1)) * (W - PAD.l - PAD.r));
88-
const y = (v) => PAD.t + (1 - ((v - min) / range)) * (H - PAD.t - PAD.b);
89-
let path = '';
90-
rows.forEach((r, i) => {
91-
const xv = x(i), yv = y(Number(r.smape || 0));
92-
path += path ? ` L ${xv} ${yv}` : `M ${xv} ${yv}`;
93-
});
94-
const points = rows.map((r, i) => ({ x: x(i), y: y(Number(r.smape || 0)) }));
95-
return { W, H, PAD, path, points };
88+
const getMainChart = (sid) => {
89+
const hRows = history.filter((r) => r.unique_id === sid);
90+
const fRows = forecast.filter((r) => r.unique_id === sid);
91+
return buildChartState(hRows, fRows, 320);
9692
};
9793
9894
const backtestBySeries = seriesIds.map((sid) => {
99-
const rows = backtest.filter((r) => r.unique_id === sid);
100-
const byModel = [...new Set(rows.map((r) => r.model))].map((m) => ({
101-
model: m,
102-
rows: rows.filter((r) => r.model === m).sort((a, b) => Number(a.window) - Number(b.window)),
103-
}));
104-
return { sid, rows, byModel };
95+
const scoreRows = backtest.filter((r) => r.unique_id === sid);
96+
const windowIds = [...new Set(scoreRows.map((r) => Number(r.window)).filter(Number.isFinite))].sort((a, b) => a - b);
97+
98+
const windows = windowIds.map((w) => {
99+
const pts = backtestPoints.filter((p) => p.unique_id === sid && Number(p.window) === w);
100+
const actualMap = new Map();
101+
pts.forEach((p) => {
102+
const key = String(p.ds);
103+
if (!actualMap.has(key)) actualMap.set(key, { ds: p.ds, y: p.y });
104+
});
105+
const actualRows = [...actualMap.values()];
106+
const forecastRows = pts.map((p) => ({ ds: p.ds, model: p.model, yhat: p.yhat }));
107+
const chart = buildChartState(actualRows, forecastRows, 260);
108+
109+
const scores = scoreRows.filter((r) => Number(r.window) === w).sort((a, b) => String(a.model).localeCompare(String(b.model)));
110+
return { window: w, chart, scores };
111+
});
112+
113+
return { sid, windows, scoreRows };
105114
});
106115
---
107116
<MainLayout title={`Forecast ${runTitle}`}>
@@ -112,7 +121,7 @@ const backtestBySeries = seriesIds.map((sid) => {
112121

113122
<section class="mt-4 space-y-4">
114123
{seriesIds.map((sid) => {
115-
const chart = svgChartForSeries(sid);
124+
const chart = getMainChart(sid);
116125
const back = backtestBySeries.find((x) => x.sid === sid);
117126
return (
118127
<article class="rounded-xl border border-slate-700 bg-slate-900/70 p-4">
@@ -175,35 +184,82 @@ const backtestBySeries = seriesIds.map((sid) => {
175184

176185
<details class="mt-4 rounded-lg border border-slate-700 bg-slate-950/40 p-3">
177186
<summary class="cursor-pointer font-semibold text-sm">Backfill testing + accuracies</summary>
178-
{back && back.rows.length > 0 ? (
179-
<div class="mt-3 space-y-3">
180-
{back.byModel.map((m, mi) => {
181-
const spark = buildSmapeSpark(m.rows);
182-
const color = ['#ef4444','#22c55e','#a855f7','#f97316'][mi%4];
183-
return (
184-
<div class="rounded border border-slate-700 p-2">
185-
<div class="text-xs text-slate-300 mb-1">{m.model} SMAPE by backtest window</div>
186-
<div class="overflow-x-auto">
187-
<svg viewBox={`0 0 ${spark.W} ${spark.H}`} class="min-w-[680px] w-full rounded bg-slate-950">
188-
<rect x={spark.PAD.l} y={spark.PAD.t} width={spark.W-spark.PAD.l-spark.PAD.r} height={spark.H-spark.PAD.t-spark.PAD.b} fill="none" stroke="#1e293b" />
189-
<path d={spark.path} fill="none" stroke={color} stroke-width="2" />
190-
{spark.points.map((pt) => <circle cx={pt.x} cy={pt.y} r="3" fill={color} />)}
191-
</svg>
192-
</div>
187+
{back && back.windows.length > 0 ? (
188+
<div class="mt-3 space-y-4">
189+
{back.windows.map((wObj) => (
190+
<div class="rounded border border-slate-700 p-3">
191+
<div class="text-xs text-slate-300 mb-2">Backtest window {wObj.window} · actual vs predicted</div>
192+
<div class="overflow-x-auto">
193+
<svg viewBox={`0 0 ${wObj.chart.W} ${wObj.chart.H}`} class="min-w-[720px] w-full rounded bg-slate-950">
194+
<rect x={wObj.chart.PAD.l} y={wObj.chart.PAD.t} width={wObj.chart.innerW} height={wObj.chart.innerH} fill="none" stroke="#1e293b" />
195+
196+
{Array.from({ length: 5 }).map((_, i) => {
197+
const y = wObj.chart.PAD.t + (i / 4) * wObj.chart.innerH;
198+
const val = (wObj.chart.yMax - ((i / 4) * (wObj.chart.yMax - wObj.chart.yMin))).toFixed(1);
199+
return (
200+
<g>
201+
<line x1={wObj.chart.PAD.l} y1={y} x2={wObj.chart.W - wObj.chart.PAD.r} y2={y} stroke="#0f172a" />
202+
<text x="6" y={y + 4} font-size="11" fill="#94a3b8">{val}</text>
203+
</g>
204+
);
205+
})}
206+
207+
{wObj.chart.actualPath && <path d={wObj.chart.actualPath} fill="none" stroke="#38bdf8" stroke-width="2" />}
208+
{wObj.chart.actualValues.map((v, i) => {
209+
const y = wObj.chart.yFor(v);
210+
if (y === null) return null;
211+
return <circle cx={wObj.chart.xFor(i)} cy={y} r="2.7" fill="#38bdf8" />;
212+
})}
213+
214+
{wObj.chart.modelSeries.map((s, idx) => {
215+
const d = (() => {
216+
let p = '';
217+
s.values.forEach((v, i) => {
218+
const y = wObj.chart.yFor(v);
219+
if (y === null) return;
220+
const x = wObj.chart.xFor(i);
221+
p += p ? ` L ${x} ${y}` : `M ${x} ${y}`;
222+
});
223+
return p;
224+
})();
225+
226+
return (
227+
<>
228+
{d && <path d={d} fill="none" stroke={wObj.chart.colors[idx % wObj.chart.colors.length]} stroke-width="2" stroke-dasharray="6 4" />}
229+
{s.values.map((v, i) => {
230+
const y = wObj.chart.yFor(v);
231+
if (y === null) return null;
232+
return <circle cx={wObj.chart.xFor(i)} cy={y} r="2.4" fill={wObj.chart.colors[idx % wObj.chart.colors.length]} />;
233+
})}
234+
</>
235+
);
236+
})}
237+
</svg>
193238
</div>
194-
);
195-
})}
196-
197-
<div class="overflow-x-auto">
198-
<table class="min-w-[760px] text-sm w-full">
199-
<thead><tr class="text-slate-300"><th class="p-2 text-left">Window</th><th class="p-2 text-left">Model</th><th class="p-2 text-left">SMAPE</th><th class="p-2 text-left">Holdout</th></tr></thead>
200-
<tbody>
201-
{back.rows.sort((a,b)=>Number(a.window)-Number(b.window)).map((row) => (
202-
<tr><td class="p-2">{row.window}</td><td class="p-2">{row.model}</td><td class="p-2">{row.smape}</td><td class="p-2">{String(row.holdout_start)}{String(row.holdout_end)}</td></tr>
239+
240+
<div class="mt-2 flex flex-wrap gap-3 text-xs text-slate-300">
241+
<span class="inline-flex items-center gap-1"><span class="inline-block w-4 h-[2px] bg-sky-400"></span>Actual</span>
242+
{wObj.chart.modelSeries.map((s, idx) => (
243+
<span class="inline-flex items-center gap-1"><span class="inline-block w-4 h-[2px]" style={`background:${wObj.chart.colors[idx % wObj.chart.colors.length]}`}></span>Predicted ({s.model})</span>
203244
))}
204-
</tbody>
205-
</table>
206-
</div>
245+
</div>
246+
247+
<div class="mt-2 overflow-x-auto">
248+
<table class="min-w-[460px] text-xs w-full">
249+
<thead><tr class="text-slate-300"><th class="p-2 text-left">Model</th><th class="p-2 text-left">SMAPE</th><th class="p-2 text-left">Holdout Range</th></tr></thead>
250+
<tbody>
251+
{wObj.scores.map((row) => (
252+
<tr>
253+
<td class="p-2">{row.model}</td>
254+
<td class="p-2">{row.smape}</td>
255+
<td class="p-2">{String(row.holdout_start)}{String(row.holdout_end)}</td>
256+
</tr>
257+
))}
258+
</tbody>
259+
</table>
260+
</div>
261+
</div>
262+
))}
207263
</div>
208264
) : (
209265
<p class="mt-2 text-sm text-slate-400">No backtest rows for this series yet (need more history or windows).</p>

site/src/pages/forecasts/[slug].astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export async function getStaticPaths() {
2020
history: z.array(z.any()).default([]),
2121
forecast: z.array(z.any()).default([]),
2222
backtest: z.array(z.any()).default([]),
23+
backtest_points: z.array(z.any()).default([]),
2324
meta: z.any().optional(),
2425
});
2526

weatherman/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def main() -> None:
2929
"history": result.history.to_dict(orient="records"),
3030
"forecast": result.forecast.to_dict(orient="records"),
3131
"backtest": result.backtest.to_dict(orient="records"),
32+
"backtest_points": result.backtest_points.to_dict(orient="records"),
3233
}
3334
_save_payload(Path(args.output), payload)
3435

weatherman/service.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class ForecastResult:
2828
forecast: pd.DataFrame
2929
backend: str
3030
backtest: pd.DataFrame
31+
backtest_points: pd.DataFrame
3132

3233

3334
def _smape(y_true: np.ndarray, y_pred: np.ndarray) -> float:
@@ -81,16 +82,18 @@ def _forecast_nixtla_compare(
8182
freq: str,
8283
do_backtest: bool,
8384
backtest_windows: int,
84-
) -> tuple[pd.DataFrame, pd.DataFrame]:
85+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
8586
models = [AutoARIMA(season_length=1), AutoETS(season_length=1)]
8687

8788
backtest_df = pd.DataFrame(columns=["unique_id", "window", "model", "smape", "horizon", "holdout_start", "holdout_end"])
89+
backtest_points_df = pd.DataFrame(columns=["unique_id", "window", "model", "ds", "y", "yhat"])
8890
if do_backtest:
8991
min_len = int(df.groupby("unique_id").size().min())
9092
max_possible_windows = max(0, (min_len // horizon) - 1)
9193
windows = max(1, min(backtest_windows, max_possible_windows)) if max_possible_windows > 0 else 0
9294

9395
scores = []
96+
backtest_points = []
9497
for w in range(windows):
9598
# Rolling holdout from older to newer windows
9699
offset = horizon * (windows - w)
@@ -124,14 +127,26 @@ def _forecast_nixtla_compare(
124127
"holdout_end": holdout_end,
125128
}
126129
)
130+
for _, r in uid_df.iterrows():
131+
backtest_points.append(
132+
{
133+
"unique_id": uid,
134+
"window": w + 1,
135+
"model": model_name,
136+
"ds": r["ds"],
137+
"y": float(r["y"]),
138+
"yhat": float(r[model_name]),
139+
}
140+
)
127141
backtest_df = pd.DataFrame(scores)
142+
backtest_points_df = pd.DataFrame(backtest_points)
128143

129144
sf = StatsForecast(models=models, freq=freq, n_jobs=1)
130145
fcst = sf.forecast(df=df, h=horizon)
131146

132147
cols = [c for c in MODEL_NAMES if c in fcst.columns]
133148
long_fcst = fcst.melt(id_vars=["unique_id", "ds"], value_vars=cols, var_name="model", value_name="yhat")
134-
return long_fcst, backtest_df
149+
return long_fcst, backtest_df, backtest_points_df
135150

136151

137152
def _forecast_autogluon(df: pd.DataFrame, horizon: int) -> pd.DataFrame:
@@ -169,8 +184,9 @@ def forecast_from_request(req: ForecastRequest) -> ForecastResult:
169184
if backend == "autogluon":
170185
forecast = _forecast_autogluon(history, req.horizon)
171186
backtest = pd.DataFrame(columns=["model", "smape", "horizon"])
187+
backtest_points = pd.DataFrame(columns=["unique_id", "window", "model", "ds", "y", "yhat"])
172188
else:
173-
forecast, backtest = _forecast_nixtla_compare(
189+
forecast, backtest, backtest_points = _forecast_nixtla_compare(
174190
history,
175191
req.horizon,
176192
freq,
@@ -179,4 +195,4 @@ def forecast_from_request(req: ForecastRequest) -> ForecastResult:
179195
)
180196
backend = "nixtla"
181197

182-
return ForecastResult(history=history, forecast=forecast, backend=backend, backtest=backtest)
198+
return ForecastResult(history=history, forecast=forecast, backend=backend, backtest=backtest, backtest_points=backtest_points)

0 commit comments

Comments
 (0)