Download code/region_models.py from Elpriser/eu-price-forecast: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/Elpriser/eu-price-forecast/resolve/main/code/region_models.py
- Command line
-
hf download hf://Elpriser/eu-price-forecast/code/region_models.py
-
curl -L -o region_models.py https://huggingface.co/Elpriser/eu-price-forecast/resolve/main/code/region_models.py
13.3 kB
| """The per-region candidate models of docs/forecast-per-region.md, as pure functions on a frame of (zone, t, h) rows - no | |
| files, no network - so they are unit-tested and the backtest (region_backtest.py) is only orchestration. | |
| Every candidate is a forecast column built only from what the row's own features say was known at issue time (the dataset's | |
| leakage rule: price lag k exists only when k >= h - 1, day statistics end at the last known day T - h + 1, fuel from the close | |
| of the day before issue). Candidates: | |
| level_shape (the naive to beat) the seasonal4w hourly profile shifted so its daily mean = the mean of the last 7 known days | |
| a_ewm5/_ewm7 the same shape, level = exponentially weighted mean of the daily means, half-life 5 / 7 days | |
| b_ridge_* ridge regression on a few features for the RESIDUAL y - level_shape: last known day's hourly shape, last week's | |
| same hour, level trend, weekday / hour / holiday, wind / solar / temperature forecasts (also times hour), and - | |
| in the `gas` variant - the fuel features. One model per zone and horizon. | |
| c_rank a LightGBM on the within-day DEVIATION of the price (the hour ordering and nothing else); scored by regret | |
| d_lin a linear model of the DAILY mean on the fuel cost (CCGT marginal cost, its 7/28-day change), the last known level | |
| and the day's weather, plus the seasonal shape: "a level model that knows the fuel price" | |
| e_ref* reference-zone forecast + the zone's recent spread to it (weekday-hour spread of the last four weeks, and / or the | |
| last 7 days' level spread): for zones that follow a neighbour (CEE / SEE after DE or HU, the Baltics after FI / SE4) | |
| f_ptes PT = the ES forecast (the two zones clear at one price in most hours) | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| GROUPS = { | |
| "nordic": ["no1", "no2", "no3", "no4", "no5", "se1", "se2", "se3", "se4", "fi", "dk1", "dk2"], | |
| "core": ["de", "fr", "nl", "be", "at", "ch", "cz", "pl"], | |
| "iberia": ["es", "pt"], | |
| "it-n": ["it-n"], | |
| "cee": ["hu", "sk", "ro", "bg", "gr", "hr", "si", "rs", "me", "mk", "al"], | |
| "baltics": ["ee", "lv", "lt"], | |
| "ie": ["ie"], | |
| } | |
| ZONE_GROUP = {z: g for g, zs in GROUPS.items() for z in zs} | |
| # standard-time UTC offset of each zone's wall clock; all of Europe changes to summer time on the same two dates, so the | |
| # difference between two zones' clocks is constant through the year | |
| STD_OFFSET = {**{z: 1 for z in ["no1", "no2", "no3", "no4", "no5", "se1", "se2", "se3", "se4", "dk1", "dk2", "de", "fr", "nl", "be", | |
| "at", "ch", "cz", "pl", "it-n", "es", "hu", "sk", "hr", "si", "rs", "me", "mk", "al"]}, | |
| **{z: 2 for z in ["fi", "gr", "ro", "bg", "ee", "lv", "lt"]}, **{z: 0 for z in ["pt", "ie"]}} | |
| NAIVE = ["b_lag7", "b_seasonal", "b_persist", "b_lvlshape"] | |
| HOURS = list(range(24)) | |
| BLOCKS = {"night": range(0, 6), "morning": range(6, 11), "midday": range(11, 17), "evening": range(17, 24)} | |
| # ------------------------------------------------------------------ levels and shapes | |
| def day_tables(prices, halflives=(5, 7)): | |
| """{zone: hourly Series on the zone's local clock} -> {zone: DataFrame indexed by calendar day with | |
| day_mean, ewm<hl> (exponentially weighted mean of the daily means up to and including the day)}.""" | |
| out = {} | |
| for z, p in prices.items(): | |
| dm = p.groupby(p.index.normalize()).mean() | |
| dm = dm.reindex(pd.date_range(dm.index.min(), dm.index.max(), freq="D")) | |
| t = pd.DataFrame({"day_mean": dm, "day_std": p.groupby(p.index.normalize()).std().reindex(dm.index), | |
| "level_7d": dm.rolling(7, min_periods=3).mean()}) | |
| for hl in halflives: | |
| t[f"ewm{hl}"] = dm.ewm(halflife=hl, min_periods=3, ignore_na=False).mean() | |
| out[z] = t | |
| return out | |
| def attach_last_known(df, tables, cols): | |
| """Append the columns `cols` of each zone's day table taken at the LAST KNOWN day (date - (h - 1)).""" | |
| out = df.copy() | |
| for c in cols: | |
| out[c] = np.nan | |
| last = out["date"] - pd.to_timedelta(out["h"] - 1, unit="D") | |
| for z, idx in out.groupby("zone", observed=True).groups.items(): | |
| t = tables.get(str(z)) | |
| if t is None: | |
| continue | |
| sub = last.loc[idx] | |
| for c in cols: | |
| out.loc[idx, c] = t[c].reindex(sub.to_numpy()).to_numpy() | |
| return out | |
| def day_mean_of(df, col): | |
| """The mean of `col` over each (zone, date, h) group, broadcast back to the rows.""" | |
| return df.groupby(["zone", "date", "h"], observed=True, sort=False)[col].transform("mean") | |
| def shape(df): | |
| """The seasonal hourly profile minus its own daily mean: pure shape, no level.""" | |
| return df["seasonal4w"] - day_mean_of(df, "seasonal4w") | |
| def level_shape(df, level_col="level_7d"): | |
| """Level + shape with the level taken from `level_col` (level_7d = the naive baseline of the existing backtest).""" | |
| return shape(df) + df[level_col] | |
| # ------------------------------------------------------------------ (b) ridge | |
| def _blocks(df): | |
| return {b: df["hour"].isin(list(r)).astype(np.float32) for b, r in BLOCKS.items()} | |
| def design(df, fuel, extra=()): | |
| """The ridge's feature matrix for rows `df` (float32). `fuel` adds the gas / carbon columns; `extra` names further columns | |
| (the Nordic reservoir / hydro-precipitation features) that are used as they are, an empty value counting as 0.""" | |
| X = {} | |
| for c in extra: | |
| X[c] = df[c].fillna(0).astype(np.float32) | |
| X["shape_persist"] = (df["lag_last"] - df["last_day_mean"]).astype(np.float32) | |
| X["lag7_dev"] = (df["lag7"] - df["seasonal4w"]).astype(np.float32) | |
| X["trend"] = (df["level_7d"] - df["level_30d"]).astype(np.float32) | |
| X["last_vs_lvl"] = (df["last_day_mean"] - df["level_7d"]).astype(np.float32) | |
| X["ewm_vs_lvl"] = (df["ewm5"] - df["level_7d"]).astype(np.float32) | |
| X["is_weekend"] = df["is_weekend"].astype(np.float32) | |
| X["is_holiday"] = df["is_holiday"].astype(np.float32) | |
| X["xmas_week"] = df["xmas_week"].astype(np.float32) | |
| for d in range(1, 7): | |
| X[f"wd{d}"] = (df["weekday"] == d).astype(np.float32) | |
| hd = {k: (df["hour"] == k).astype(np.float32) for k in HOURS} | |
| for k in HOURS[1:]: | |
| X[f"hr{k}"] = hd[k] | |
| for v in ("wind", "rad", "temp", "reg_wind"): | |
| x = df[v].astype(np.float32) | |
| X[v] = x | |
| for k in HOURS: # wind and solar act differently by hour of the day | |
| X[f"wind_x_h{k}"] = df["wind"].astype(np.float32) * hd[k] | |
| X[f"rad_x_h{k}"] = df["rad"].astype(np.float32) * hd[k] | |
| if fuel: | |
| blocks = _blocks(df) | |
| gap = (df["ccgt_mc"] - df["level_7d"]).astype(np.float32) | |
| for name, v in (("gap", gap), ("mc_d7", df["ccgt_mc_d7"]), ("mc_d28", df["ccgt_mc_d28"])): | |
| for b, m in blocks.items(): | |
| X[f"{name}_{b}"] = (v * m).astype(np.float32) | |
| X["eua_d28"] = df["eua_d28"].astype(np.float32) | |
| X["gas_m1_spread"] = df["gas_m1_spread"].astype(np.float32) | |
| return pd.DataFrame(X, index=df.index) | |
| def ridge_residual(train, test, fuel, alpha=50.0, base="b_lvlshape", extra=()): | |
| """Fit y - base on `train`, return base + prediction on `test` (NaN where a feature is missing). Features are standardised on | |
| the training rows; the intercept is free.""" | |
| from sklearn.linear_model import Ridge | |
| Xtr, Xte = design(train, fuel, extra), design(test, fuel, extra) | |
| ok = Xtr.notna().all(axis=1) & train[base].notna() & train["y"].notna() | |
| if ok.sum() < 500: | |
| return pd.Series(np.nan, index=test.index) | |
| mu, sd = Xtr[ok].mean(), Xtr[ok].std().replace(0, 1.0) | |
| m = Ridge(alpha=alpha).fit(((Xtr[ok] - mu) / sd).to_numpy(np.float32), (train.loc[ok, "y"] - train.loc[ok, base]).to_numpy()) | |
| good = Xte.notna().all(axis=1) & test[base].notna() | |
| out = pd.Series(np.nan, index=test.index) | |
| if good.any(): | |
| out[good] = test.loc[good, base].to_numpy() + m.predict(((Xte[good] - mu) / sd).to_numpy(np.float32)) | |
| return out | |
| # ------------------------------------------------------------------ (d) daily linear model on the fuel cost | |
| DAILY_FEATS = ["mc_gap", "ccgt_mc_d7", "ccgt_mc_d28", "last_vs_ewm", "trend", "wind_day", "rad_day", "temp_day", "is_weekend", "is_holiday"] | |
| def daily_frame(df): | |
| """One row per (zone, date, h): the day's mean price and the day-level features of the linear model.""" | |
| d = df.assign(wind_day=df["wind"], rad_day=df["rad"], temp_day=df["temp"], dm=df["y"]) | |
| g = d.groupby(["zone", "date", "h"], observed=True, sort=False) | |
| agg = g.agg(dm=("dm", "mean"), n=("dm", "count"), wind_day=("wind_day", "mean"), rad_day=("rad_day", "mean"), | |
| temp_day=("temp_day", "mean"), is_weekend=("is_weekend", "first"), is_holiday=("is_holiday", "first"), | |
| ewm7=("ewm7", "first"), level_7d=("level_7d", "first"), level_30d=("level_30d", "first"), | |
| last_day_mean=("last_day_mean", "first"), ccgt_mc=("ccgt_mc", "first"), ccgt_mc_d7=("ccgt_mc_d7", "first"), | |
| ccgt_mc_d28=("ccgt_mc_d28", "first")).reset_index() | |
| agg["mc_gap"] = agg["ccgt_mc"] - agg["ewm7"] | |
| agg["last_vs_ewm"] = agg["last_day_mean"] - agg["ewm7"] | |
| agg["trend"] = agg["level_7d"] - agg["level_30d"] | |
| return agg | |
| def daily_linear(train_days, test_days, alpha=5.0): | |
| """Ridge on day-level features for (daily mean - ewm7); returns the predicted daily mean for each test day (NaN if a | |
| feature is missing). Fitted on one zone and one horizon at a time by the caller.""" | |
| from sklearn.linear_model import Ridge | |
| ok = train_days[DAILY_FEATS + ["dm", "ewm7"]].notna().all(axis=1) & (train_days["n"] >= 22) | |
| if ok.sum() < 60: | |
| return pd.Series(np.nan, index=test_days.index) | |
| X = train_days.loc[ok, DAILY_FEATS] | |
| mu, sd = X.mean(), X.std().replace(0, 1.0) | |
| m = Ridge(alpha=alpha).fit(((X - mu) / sd).to_numpy(), (train_days.loc[ok, "dm"] - train_days.loc[ok, "ewm7"]).to_numpy()) | |
| good = test_days[DAILY_FEATS + ["ewm7"]].notna().all(axis=1) | |
| out = pd.Series(np.nan, index=test_days.index) | |
| if good.any(): | |
| out[good] = test_days.loc[good, "ewm7"].to_numpy() + m.predict(((test_days.loc[good, DAILY_FEATS] - mu) / sd).to_numpy()) | |
| return out | |
| # ------------------------------------------------------------------ (c) rank features | |
| RANK_FEATS = ["zone", "h", "hour", "weekday", "month", "is_weekend", "is_holiday", "doy_sin", "doy_cos", | |
| "wind", "rad", "temp", "precip", "wind_dev", "rad_dev", "temp_dev", "wind_day", "rad_day", | |
| "shape_s", "shape_persist", "lag7_dev", "last_day_std", "level_7d", "mc_gap", "ccgt_mc"] | |
| def rank_frame(df): | |
| """Add the within-day deviation features and the target `dev` (price minus the day's mean price).""" | |
| d = df.copy() | |
| for v in ("wind", "rad", "temp"): | |
| dm = day_mean_of(d, v) | |
| d[f"{v}_day"] = dm | |
| d[f"{v}_dev"] = d[v] - dm | |
| d["shape_s"] = shape(d) | |
| d["shape_persist"] = d["lag_last"] - d["last_day_mean"] | |
| d["lag7_dev"] = d["lag7"] - d["seasonal4w"] | |
| d["dev"] = d["y"] - day_mean_of(d, "y") | |
| return d | |
| # ------------------------------------------------------------------ (e) reference-zone model | |
| def spread_series(p_zone, p_ref, offset_hours): | |
| """Hourly spread zone - reference on the ZONE's clock. `offset_hours` = the zone's standard-time offset minus the | |
| reference's: the reference's local timestamp t_r is the zone's t_r + offset.""" | |
| ref = p_ref.copy() | |
| ref.index = ref.index + pd.Timedelta(hours=int(offset_hours)) | |
| return (p_zone - ref.reindex(p_zone.index)).dropna() | |
| def spread_features(rows, spread): | |
| """For rows (zone, t, date, h): sp_wdh = the mean of the spread at t - 7k days, k = 1..4 (all >= h - 1 for h <= 8, so known | |
| at issue); sp_lvl = the mean of the daily mean spread over the 7 days ending at the last known day (date - (h-1)).""" | |
| lags = [spread.reindex(rows["t"] - pd.Timedelta(days=7 * k)).to_numpy() for k in range(1, 5)] | |
| sp_wdh = np.nanmean(np.vstack(lags), axis=0) if len(rows) else np.array([]) | |
| dmean = spread.groupby(spread.index.normalize()).mean() | |
| dmean = dmean.reindex(pd.date_range(dmean.index.min(), dmean.index.max(), freq="D")) | |
| lvl7 = dmean.rolling(7, min_periods=3).mean() | |
| last = rows["date"] - pd.to_timedelta(rows["h"] - 1, unit="D") | |
| return pd.DataFrame({"sp_wdh": sp_wdh, "sp_lvl": lvl7.reindex(last.to_numpy()).to_numpy()}, index=rows.index) | |
| def reference_candidates(rows, ref_fc, sp): | |
| """The three reference-zone forecasts for `rows` given the reference's forecast on the zone's clock (`ref_fc`, aligned to | |
| rows) and the spread features `sp` (spread_features).""" | |
| g = pd.DataFrame({"zone": rows["zone"], "date": rows["date"], "h": rows["h"], "w": sp["sp_wdh"]}) | |
| wdm = g.groupby(["zone", "date", "h"], observed=True, sort=False)["w"].transform("mean") | |
| return pd.DataFrame({"e1": ref_fc + sp["sp_wdh"], # ref + the weekday-hour spread | |
| "e2": ref_fc + sp["sp_lvl"] + (sp["sp_wdh"] - wdm), # ref + level spread + spread SHAPE | |
| "e3": ref_fc + sp["sp_lvl"]}, index=rows.index) # ref + level spread only | |