File size: 9,407 Bytes
5d7f266 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """The recipe that is published (docs/forecast-per-region.md, sections 1, 5 and 7), as pure functions on frames of (zone, hour, horizon h) rows.
zones other than EE / LV / LT, fuel inputs usable mean( LEAR-365 with fuel , LightGBM with fuel ) `ens`
the same zones, fuel NOT usable mean( LEAR-365 without fuel , LightGBM without fuel ) `ens-nofuel`
EE, LV, LT (the Baltics: fuel does not help there) the current LightGBM without fuel `lgb`
LEAR-365 = the research's `lear_r365_g` / `lear_r365`: per zone and horizon a closed-form ridge on the RESIDUAL of the naive 'level +
shape' forecast, trained on the last 365 days of settled targets (window ending the day before the last known day, exactly what was
settled when the first forecast of a block was issued), features region_models.design (110 columns: last day's shape, last week's hour,
level trend, calendar, wind/solar/temperature and their hour interactions, and - with fuel - the gas-plant cost features) plus four
price-relative lags. Refitted every day (the research refit weekly; the daily refit was 1-2 points better and costs seconds).
LightGBM = backtest.py's model: pooled over the zones, `zone` categorical, target = price - level_7d, 400 trees.
Everything here is a function of the rows only, so training rows and live rows are built and augmented by the SAME code (the leakage
rule of features.py and fuel_features.py holds for both) and the whole recipe is unit-tested without LightGBM or the network.
"""
import os
import sys
import numpy as np
import pandas as pd
DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, DIR)
import family_models as FM # noqa: E402
import fuel_features as FUEL # noqa: E402
import region_models as RM # noqa: E402
BALTICS = ("ee", "lv", "lt")
H_MIN, H_MAX = 2, 7
LEAR_WINDOW_DAYS = 365
LEAR_MIN_ROWS = 24 * 40 # fewer training hours than this: no linear model (the zone-horizon is not published)
EXTRA_LAGS = ["d_lag_last", "d_prev", "d_lag7", "d_lag14"]
# Hourly price lags of a LIVE row can be missing where the training rows (built in hindsight) never are: the last known day of Portugal and Ireland
# has no hour 23 (it belongs to the market day that is not published yet), a zone's price feed has a hole of an hour or a day now and then. The
# LightGBM models take a missing lag as it is; the ridge cannot, so such a feature is replaced by its training-window mean (which makes its
# contribution zero) - for at most MAX_IMPUTED of these six per row, and never for weather, calendar, level or fuel features.
IMPUTABLE = ("shape_persist", "lag7_dev", "d_lag_last", "d_prev", "d_lag7", "d_lag14")
MAX_IMPUTED = 3
RECIPES = {"ens": "mean of a 365-day rolling ridge and LightGBM, with gas and carbon inputs",
"ens-nofuel": "mean of a 365-day rolling ridge and LightGBM, without fuel inputs",
"lgb": "LightGBM"}
MODEL_NAME = "eu-ens-v2"
def recipe_for(zone, fuel_ok):
"""'lgb' for the Baltics, otherwise 'ens' when the fuel inputs are usable and 'ens-nofuel' when not."""
if zone in BALTICS:
return "lgb"
return "ens" if fuel_ok else "ens-nofuel"
# ------------------------------------------------------------------ rows: fuel and the columns the linear model needs
def add_fuel_columns(df, table):
"""Rows with `date` (a normalised timestamp) and `h` -> the same rows with the leak-free fuel features and `mc_gap`
(fuel_dataset.add_fuel, applied to a frame that already has `date`)."""
out = FUEL.add_to_frame(df, table)
out["mc_gap"] = out["ccgt_mc"] - out["level_7d"]
return out
def augment(df, prices, tables=None):
"""Add what the linear model needs on top of features.make_features (+ fuel): ewm5 and the naive 'level + shape' baseline `b_lvlshape`
(the point the ridge corrects), and the price-relative lags. `df` has zone (str), t, date, h; `prices` is {zone: hourly Series on the
zone's clock}. Every added column uses only prices up to the row's last known day (target - (h - 1)); the lags k >= h - 1."""
out = df.copy()
out["zone"] = out["zone"].astype(str)
tables = tables or RM.day_tables(prices)
out = RM.attach_last_known(out, tables, ["ewm5"])
out["shape_s"] = RM.shape(out)
out["b_lvlshape"] = out["shape_s"] + out["level_7d"]
out["p_prev"] = np.nan
for (z, h), idx in out.groupby(["zone", "h"]).groups.items():
out.loc[idx, "p_prev"] = prices[z].reindex(out.loc[idx, "t"] - pd.Timedelta(days=int(h))).to_numpy()
out["d_lag_last"] = out["lag_last"] - out["level_7d"]
out["d_prev"] = out["p_prev"] - out["level_7d"]
out["d_lag7"] = out["lag7"] - out["level_7d"]
out["d_lag14"] = out["lag14"] - out["level_7d"]
return out
def lear_design(df, fuel):
"""The ridge's feature matrix (float32 frame): region_models.design + the four price-relative lags."""
return RM.design(df, fuel).join(df[EXTRA_LAGS].astype(np.float32))
# ------------------------------------------------------------------ LEAR-365
def lear_predict(history, live, fuel, window=LEAR_WINDOW_DAYS, min_rows=LEAR_MIN_ROWS, impute=True):
"""Predict the `live` rows with one ridge per (zone, h).
history settled training rows (zone, h, date, y, b_lvlshape + features), already augmented
live rows to predict (same columns, y absent), already augmented
For a (zone, h) group of live rows whose first target day is T, the training rows are those of the same zone and h whose target day
lies in (T - h - window, T - h]: T - h is the day before the last known day (T - h + 1), the newest target already settled when the
forecast is issued. Returns a Series aligned to live.index; NaN where there are too few training hours or a feature is missing -
never a patched value. The one exception is `impute` (see IMPUTABLE): a missing hourly price lag takes the training mean. The number of
imputed values is in the result's `.attrs["imputed"]`."""
out = pd.Series(np.nan, index=live.index)
out.attrs["imputed"] = 0
if live.empty:
return out
Xl = lear_design(live, fuel)
imp = [i for i, c in enumerate(Xl.columns) if c in IMPUTABLE] if impute else []
hist = history[history["h"].isin(live["h"].unique())]
for (z, h), g in live.groupby(["zone", "h"]):
first = g["date"].min()
end = first - pd.Timedelta(days=int(h))
tr = hist[(hist["zone"] == z) & (hist["h"] == h) & (hist["date"] <= end) & (hist["date"] > end - pd.Timedelta(days=window))]
if len(tr) < min_rows:
continue
Xt = lear_design(tr, fuel).to_numpy(np.float64)
yt = (tr["y"] - tr["b_lvlshape"]).to_numpy(np.float64)
ok = np.isfinite(Xt).all(axis=1) & np.isfinite(yt)
if ok.sum() < min_rows:
continue
model = FM.ridge_fit(Xt[ok], yt[ok], np.ones(int(ok.sum())))
Xg = Xl.loc[g.index].to_numpy(np.float64)
if imp:
nan = ~np.isfinite(Xg)
fill = nan[:, imp]
other = nan.copy()
other[:, imp] = False
usable = ~other.any(axis=1) & (fill.sum(axis=1) <= MAX_IMPUTED)
for k, j in enumerate(imp):
m = fill[:, k] & usable
Xg[m, j] = model[0][j]
out.attrs["imputed"] += int(m.sum())
good = np.isfinite(Xg).all(axis=1) & g["b_lvlshape"].notna().to_numpy()
if good.any():
out.loc[g.index[good]] = g.loc[g.index[good], "b_lvlshape"].to_numpy() + FM.linear_predict(model, Xg[good])
return out
# ------------------------------------------------------------------ the ensemble
MAX_FALLBACK_HOURS = 6
def point_forecast(zone, parts, fuel_ok, day_ids=None, max_fallback_hours=MAX_FALLBACK_HOURS):
"""The published central estimate of one zone from the model columns `parts` (dict name -> array/Series aligned to the zone's rows):
lear_fuel, lgb_fuel, lear_nofuel, lgb_base. NaN wherever a needed component is NaN (the day is then dropped by the caller)...
... with ONE exception, for continuity: when only the ridge is missing in an hour (a gap in the price feed that imputation could not bridge) and
`day_ids` (one id per row: the target day) is given, the LightGBM alone stands in for that hour, if at most `max_fallback_hours` hours of the same day
need it. The API shows forecast days only without a hole after the settled days, so one lost day would hide every day after it. Each model alone was
about as much better than level+shape as the mean at D+2 (docs/forecast-per-region.md, section 1); a day that needs it for more than a quarter of
its hours (a zone with no ridge at all) is dropped, not published on a model the gate did not measure."""
r = recipe_for(zone, fuel_ok)
if r == "lgb":
return np.asarray(parts["lgb_base"], dtype=float)
a, b = ("lear_fuel", "lgb_fuel") if r == "ens" else ("lear_nofuel", "lgb_base")
a, b = np.asarray(parts[a], dtype=float), np.asarray(parts[b], dtype=float)
out = (a + b) / 2
if day_ids is not None:
only_tree = ~np.isfinite(a) & np.isfinite(b)
if only_tree.any():
n = pd.Series(only_tree).groupby(np.asarray(day_ids)).transform("sum").to_numpy()
out = np.where(only_tree & (n <= max_fallback_hours), b, out)
return out
|