| |
| """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 |
| |
| |
| |
| 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 |
| |
| |
| |
| 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() |
|
|