nordic-price-forecast / backtest.py
x2q's picture
Nordic pooled model, daily retrain 2026-08-01
501a8d0 verified
Raw
History Blame Contribute Delete
5.83 kB
#!/usr/bin/env python3
"""Rolling-origin backtest of the pooled 13-zone model.
Reported PER ZONE, not just pooled. A pooled average is exactly the metric
that can look healthy while one zone is served badly β€” NO4 and NL have very
different price behaviour, and an average over 13 zones would hide either of
them going wrong. The whole point of checking is to catch that.
Baselines per zone:
seasonal4w β€” mean of the same hour on the last 4 same-weekdays
persistence β€” same hour on the most recent known day
Post-processing reuses what the Danish model established: the model's daily
LEVEL with its intraday SHAPE blended 50/50 against the seasonal profile.
That was measured there to beat both parents on both metrics, and it is
carried over rather than re-derived.
"""
import os
import numpy as np
import pandas as pd
import lightgbm as lgb
P = os.path.dirname(os.path.abspath(__file__))
FEATS = ["zone", "h", "hour", "weekday", "month", "is_weekend", "doy_sin", "doy_cos",
"lag1", "lag2", "lag3", "lag7", "lag14", "lag21", "lag28",
"seasonal4w", "last_day_mean", "level_30d",
"wind", "rad", "temp", "precip", "precip_24h",
"reg_wind", "reg_temp", "hydro_precip", "hydro_precip_7d",
"reservoir_pct", "reservoir_anom", "nordic_reservoir_anom"]
QUANTS = [(0.1, "lo"), (0.5, "md"), (0.9, "hi")]
BLEND = 0.5
def train(df):
models = {}
for a, name in QUANTS:
m = lgb.LGBMRegressor(objective="quantile", alpha=a, n_estimators=500,
num_leaves=127, min_child_samples=40, learning_rate=0.05,
verbosity=-1, n_jobs=12)
m.fit(df[FEATS], df["y"], categorical_feature=["zone"])
models[name] = m
return models
def blend(frame, col):
g = frame.groupby(["zone", "date", "h"], observed=True, sort=False)
pm = g[col].transform("mean")
sm = g["seasonal4w"].transform("mean")
out = pm + (1 - BLEND) * (frame[col] - pm) + BLEND * (frame["seasonal4w"] - sm)
return out.where(sm.notna(), frame[col])
def main():
df = pd.read_parquet(f"{P}/dataset_nordic.parquet")
df["t"] = pd.to_datetime(df.t)
df["date"] = df.t.dt.normalize()
df = df.sort_values("t")
preds = []
for pm in pd.period_range("2025-04", "2026-07", freq="M"):
train_df = df[df.date < pm.start_time]
test = df[(df.date >= pm.start_time) & (df.date <= pm.end_time)].copy()
if len(train_df) < 50_000 or not len(test):
continue
# Reservoir columns are legitimately empty for the non-hydro zones
# (DK1/DK2/NL have no reservoirs), so they must stay OUT of the dropna
# subset β€” including them would delete every row for those zones.
RES = {"reservoir_pct", "reservoir_anom"}
req = [c for c in FEATS
if not c.startswith("lag") and c != "zone" and c not in RES]
tr = train_df.dropna(subset=req)
models = train(tr)
for q in ("lo", "md", "hi"):
test[f"p_{q}"] = models[q].predict(test[FEATS])
preds.append(test)
print(f" {pm}: train {len(tr):,} test {len(test):,}", flush=True)
out = pd.concat(preds, ignore_index=True)
out["hyb"] = blend(out, "p_md")
out["b_persist"] = np.where(out.h <= 4, out.lag3.fillna(out.lag7), out.lag7)
out.to_parquet(f"{P}/backtest_nordic.parquet", index=False)
rows = []
for z, sub in out.groupby("zone", observed=True):
denom = sub.y.abs().mean()
g = sub.groupby(["date", "h"], observed=True)
y_is_min = (g["y"].rank(method="first") == 1).values
rank = sub.hyb.groupby([sub.date, sub.h]).rank(method="first")
hit = (rank[y_is_min] <= 3).mean() * 100
cov = ((sub.y >= sub.p_lo) & (sub.y <= sub.p_hi)).mean() * 100
# Regret: what following the forecast's 3 cheapest hours costs versus
# perfect timing. Unlike hit-rate this stays meaningful in flat zones,
# where being "wrong" between near-identical hours costs almost nothing.
reg_m, reg_s, reg_r = [], [], []
for _, gg in sub.groupby(["date", "h"], observed=True):
if gg.y.isna().all():
continue
lo_y = gg.y.min()
reg_m.append(gg.nsmallest(3, "hyb").y.mean() - lo_y)
reg_s.append(gg.nsmallest(3, "seasonal4w").y.mean() - lo_y)
reg_r.append(gg.y.mean() - lo_y)
rows.append({
"zone": z, "mean_eur": denom,
"regret": np.mean(reg_m), "regret_seasonal": np.mean(reg_s),
"captured%": (1 - np.mean(reg_m) / np.mean(reg_r)) * 100,
"model": (sub.hyb - sub.y).abs().mean(),
"seasonal": (sub.seasonal4w - sub.y).abs().mean(),
"persist": (sub.b_persist - sub.y).abs().mean(),
"hit%": hit, "raw_cov%": cov,
})
r = pd.DataFrame(rows)
r["model%"] = r.model / r.mean_eur * 100
r["seasonal%"] = r.seasonal / r.mean_eur * 100
r["gain%"] = (r.model / r.seasonal - 1) * 100
print("\n═══ Per-zone MAE (EUR/MWh), test 2025-04 β†’ 2026-07 ═══")
r["regret_gain%"] = (r.regret / r.regret_seasonal - 1) * 100
print(r[["zone", "mean_eur", "model", "seasonal", "model%", "gain%",
"regret", "regret_seasonal", "regret_gain%", "captured%", "hit%"]]
.round(1).to_string(index=False))
print(f"\npooled MAE: model {(out.hyb-out.y).abs().mean():.1f} | "
f"seasonal {(out.seasonal4w-out.y).abs().mean():.1f} | "
f"persistence {(out.b_persist-out.y).abs().mean():.1f}")
print("\nMAE by horizon:")
print(out.assign(ae=(out.hyb-out.y).abs()).groupby("h").ae.mean().round(1).to_string())
r.to_csv(f"{P}/backtest_by_zone.csv", index=False)
print("BACKTEST DONE", flush=True)
if __name__ == "__main__":
main()