Skip to content

Residual-from-Baseline Target Transform (residual_1w)

Overview

Instead of predicting raw EUR/MWh directly, EPF predicts the deviation from the price at the same slot one week prior. At training time we subtract that baseline from the target; at inference time we add it back. This is the residual_1w target transform, controlled by EPF_TARGET_TRANSFORM=residual_1w in src/config.py.

This is the only sanctioned target transform for production. It survives every ablation and is load-bearing for ES, PT, and DE. France (FR) is the one country that trains better with no transform — nuclear baseload makes weekly patterns stable enough that the raw EUR target is already easy.

The transform

At slot t, the model is trained and predicts:

y_model(t) = y_actual(t) - y_actual(t - 168h)

At inference time the forecast is reconstructed as:

forecast(t) = y_model_predicted(t) + y_actual(t - 168h)

Where y_actual(t - 168h) is the realized price at the same hour / quarter-hour exactly one week before. For multi-step forecasts up to D+7, the baseline reaches back to prices that are already published in REE / ENTSO-E, so there is no bootstrap or dependency on other model outputs.

Why weekly (not daily, not 4-week)

Residual transforms shift the learning target from absolute price level to deviation from a known pattern. The choice of baseline determines what pattern the model is subtracting out.

BaselineWhat it subtractsProblem
Same slot 24h agoDay-over-day driftDoesn’t separate weekdays from weekends. A Monday’s baseline is Sunday, which is the wrong regime.
Same slot 168h ago (1w)Day-of-week + hour-of-day pattern✅ Current winner
Same slot 28d ago (4w)Same DOW+hour but averaged over a monthLeaks “regime memory”: during a transition (e.g. winter → spring, or into a crisis) the baseline is anchored to the old regime. Hurts MAE and spike recall.
Median of last 7 days at same hourSmoothed weekly patternSimilar regime-memory problem. Empirically close to 1w but never better on the same window.

The 1-week baseline is a sweet spot: it handles the dominant weekly-seasonality pattern without over-smoothing across regime transitions. Its weakness — a Tuesday that’s a public holiday one week and not the next — is handled by the Z1 country-aware holiday features, which flag the current day rather than relying on the baseline’s naive 168h lag.

Why this beats no transform for ES / PT / DE

The pre-transform target y_actual(t) mixes three distinct signals:

  1. Baseline price level — average EUR/MWh for the country’s market regime
  2. Weekly seasonality — day-of-week and hour-of-day pattern
  3. Residual — weather, commodity, scarcity, spike dynamics

Signal 1 is easy and boring — the model “wins MAE cheaply” by learning the long-run mean. Signal 2 is a well-known cyclic pattern. Only signal 3 is what we actually want the model focused on. Subtracting the 1-week baseline removes signals 1 and 2 almost cleanly, leaving the model with a smaller-magnitude, higher-information-density target. Empirically this gives:

  • Lower MAE across spike and non-spike periods
  • Better spike recall (the model isn’t diluted by low-information baseline)
  • Better directional accuracy (fewer “big flat baseline prediction + noise” errors)
  • Better spread capture (the model’s prediction range tracks actual price dispersion)

Why FR is the exception

France’s wholesale market is dominated by EDF’s nuclear baseload, which gives FR the most stable day-over-day and week-over-week price patterns of any country in EPF. The residual target has lower signal-to-noise in FR because there’s already less weekly variation to subtract out — the model ends up fitting micro-noise. Training against raw EUR gives FR better MAE (v6.0 ablation 24.52 with no transform vs ~25.1 with residual_1w).

Where the transform lives in code

  • src/config.py exposes EPF_TARGET_TRANSFORM (default residual_1w)
  • src/models/direct_trainer.py applies the subtract-baseline step before fitting
  • src/models/direct_predictor.py adds the baseline back at inference time, with a drift guard that aborts if the baseline column is missing

See the XGBoost page for how the transform interacts with the rest of the recipe, and the v11.0 changelog for the validation history.