File size: 13,275 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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""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