Energy Demand Forecasting

Rolling-window backtested hourly electricity demand forecasting — PJM AEP region, 2004–2018

Author

Alvin Alias

Published

June 10, 2026

1 Executive Summary

This report presents a rigorous evaluation of three hourly electricity demand forecasting models — naive seasonal baseline, SARIMA, and Facebook Prophet — evaluated on rolling 7-day forecast windows spanning the PJM AEP dataset.

Headline result: SARIMA achieved WAPE 7.9% across 84 rolling-window evaluations (7-day forecast horizon, 2005–2018) vs. 8.6% for the naive seasonal baseline — a 7.8% improvement. The honest surprise: Prophet (10.4% WAPE) underperformed even the naive baseline. Its smooth additive seasonalities cannot track the sharp hour-to-hour load dynamics that a same-hour-last-week lag captures for free. SARIMA also won peak-hour WAPE (8.5% vs. 9.0% naive vs. 10.7% Prophet) and was the most stable model in 2018, when both other models’ errors spiked.

The retail transfer: This exact framework — train on historical data, forecast the next period, compare plan vs. actual, roll forward — is how Costco, Walmart, and Target run store-level demand planning. Replace “MW” with “units” and the same src/backtesting.py module is directly reusable.

Metric Naive seasonal SARIMA Prophet
WAPE 8.6% 7.9% 10.4%
MAE (MW) 1,327 1,223 1,604
Directional acc. 86.0% 87.2% 73.1%
Peak-hour WAPE 9.0% 8.5% 10.7%
Bias (MW) +128 +55 +135
n windows 84 84 84

2 Data

Source: PJM Hourly Energy Consumption — AEP region (American Electric Power, Ohio/Indiana service area)
Provider: Kaggle Coverage: hourly load history, with exact date range and row count computed from the downloaded file

Code
from src.features import load_pjm_data
df = load_pjm_data('../data/raw/AEP_hourly.csv')
pd.DataFrame({
    "Metric": ["Total rows", "Date range", "Mean load (MW)", "Min load (MW)", "Max load (MW)", "Missing values"],
    "Value": [f"{len(df):,}", f"{df.index.min().date()}{df.index.max().date()}",
              f"{df['load_mw'].mean():.0f}", f"{df['load_mw'].min():.0f}",
              f"{df['load_mw'].max():.0f}", f"{df['load_mw'].isna().sum()}"],
})
[features] Filled 27 missing hourly values (forward-fill, limit=3)
[features] Loaded 121,296 hourly rows | 2004-10-01 01:00:00 → 2018-08-03 00:00:00
Table 1: Dataset summary
Metric Value
0 Total rows 121,296
1 Date range 2004-10-01 → 2018-08-03
2 Mean load (MW) 15499
3 Min load (MW) 9581
4 Max load (MW) 25695
5 Missing values 0

3 Exploratory Data Analysis

3.1 Seasonal Patterns

Electricity demand exhibits three nested seasonalities: daily (peak at 5–8pm), weekly (weekdays higher than weekends), and annual (summer cooling + winter heating double peak). This multi-scale structure is the core challenge for forecasting.

Code
from IPython.display import Image
Image('../figures/01_load_patterns.png')
Figure 1: Hourly load patterns — daily, weekly, and seasonal

3.2 Seasonal Decomposition

Code
Image('../figures/01_decomposition.png')
Figure 2: STL decomposition — trend + seasonality + residual

3.3 Stationarity (ADF Test)

Code
from statsmodels.tsa.stattools import adfuller
# ADF on the most recent 3 years (autolag over the full 121k-row series is
# minutes of runtime for no extra insight); maxlag=48 covers two daily cycles
recent = df.loc['2015-08-01':, 'load_mw'].dropna()
result = adfuller(recent, maxlag=48, autolag=None)
print(f"ADF statistic: {result[0]:.4f}  |  p-value: {result[1]:.6f}")
print(f"Conclusion: {'Stationary (p < 0.05)' if result[1] < 0.05 else 'Non-stationary'}")
ADF statistic: -11.8827  |  p-value: 0.000000
Conclusion: Stationary (p < 0.05)

4 Models

4.1 Naive Seasonal Baseline

Predict the value from the same hour last week (168h lag). This is the hardest-to-beat simple baseline for hourly energy demand because weekly seasonality is the dominant signal. Any model that cannot beat the naive baseline is not useful.

4.2 SARIMA

SARIMA(p,d,q)(P,D,Q,s) — statistical time-series model using autoregressive and moving-average terms plus seasonal differencing.

Parameter selection: ADF test confirmed near-stationarity (d=0). ACF/PACF plots suggested AR(1) + MA(1) with seasonal period s=24 (daily cycle).

Code
Image('../figures/03_acf_pacf.png')
Figure 3: ACF and PACF — daily resampled series

4.3 Prophet

Facebook Prophet decomposes the time series into trend + seasonality + holiday components. Unlike SARIMA, it handles non-stationarity automatically and supports multiple seasonality periods (daily + weekly + annual) natively.

Code
Image('../figures/04_prophet_components.png')
Figure 4: Prophet component decomposition — trend, weekly, annual, holidays

5 Rolling-Window Backtesting

5.1 Methodology

“A single train/test split can hide how model performance changes over time. Rolling-window backtesting shows performance across seasons, years, and demand regimes — which is what you need before trusting a forecasting system in production.”

Setup: 365-day training window → 7-day forecast → step forward 8 weeks → repeat across all data. Yields 84 evaluation windows spanning 2005–2018 — every season and demand regime — each with a fresh model fit. (SARIMA is re-fit on the most recent 90 days of each training window; it is a short-memory model, and longer histories multiply fit time without improving a 7-day-ahead forecast.)

Code
Image('../figures/05_backtest_wape_over_time.png')
Figure 5: WAPE over time (8-week rolling mean) — drift and seasonal error regimes

5.2 Drift Analysis

The WAPE-over-time chart highlights when model error rises during specific seasons, years, or load regimes. This is the operational reason to use a rolling backtest: a model that looks good on one holdout period may be less reliable during high-load seasons or unusual demand regimes.


6 Results

Code
from src.backtesting import BacktestEvaluator
bt = BacktestEvaluator()
results = pd.read_parquet('../data/processed/backtest_results.parquet')
bt.aggregate_metrics(results).round(4)
Table 2: Model comparison — aggregated over all rolling windows
wape mape mae rmse directional_acc bias peak_hour_wape n_windows n_forecasts
model_name
sarima 0.0792 0.0785 1223.3137 1593.7889 0.8718 54.6043 0.0849 84 14112
naive_seasonal 0.0860 0.0850 1326.9624 1810.8498 0.8600 128.0151 0.0904 84 14112
prophet 0.1039 0.1035 1603.6766 2160.7097 0.7315 134.9236 0.1069 84 14112

6.1 What the numbers say

SARIMA wins, and Prophet loses to the naive baseline. SARIMA had the best WAPE in 43 of 84 windows (naive: 26, Prophet: 15) and leads on every aggregate metric — WAPE, MAE, RMSE, directional accuracy, bias, and peak-hour WAPE. Prophet’s smooth trend + seasonality decomposition systematically misses sharp hour-to-hour transitions, which is most visible in its directional accuracy (73% vs. 87% for SARIMA). The lesson worth stating plainly: a same-hour-last-week lag is a strong baseline for hourly load, and a popular library default does not beat it.

Drift: per-year WAPE shows SARIMA is also the most robust model late in the series — in 2018, naive error rose to 13.2% and Prophet’s to 19.2%, while SARIMA held at 8.9%. Models that re-fit on recent data (SARIMA’s 90-day window) adapt to regime change; Prophet’s year-long seasonal curves lag behind it.

Code
Image('../figures/05_sample_forecast.png')
Figure 6: Sample 7-day forecast vs. actual — representative window

7 Retail Demand Planning Transfer

The methodology in this project is directly transferable to retail demand planning:

Component Energy domain Retail domain
Target variable Load (MW) Units sold / inventory turns
Granularity Hourly Daily / weekly
Training window 365 days 52–104 weeks
Forecast horizon 7 days Next week / next month
Rolling backtest ✓ — same code ✓ — same src/backtesting.py
Plan-vs-actual metric WAPE / MAPE WAPE / MAPE / bias
Regime changes Seasonal peaks, economic shifts, weather-driven load changes Promotions, stockouts, holidays, demand shocks

The src/backtesting.py module from this project is imported directly by the Retail Returns Intelligence project for rolling 30-day plan-vs-actual evaluation of the return-prediction model.


8 Limitations

  1. Single region: AEP region only. Performance may differ for other grid operators with different load profiles (industrial vs. residential mix, climate zone).

  2. No external regressors: Weather data not included in this version. Temperature is the strongest predictor of HVAC-driven demand; adding it would likely improve SARIMA/Prophet.

  3. No quantile forecasts: Point forecasts only. Grid operators use P10/P50/P90 uncertainty bands for reserve planning — a production system would add quantile regression or conformal prediction intervals.

  4. SARIMA computation time: Each window requires a fresh SARIMA fit (~10–30s). This is feasible for backtesting but not suitable for real-time inference at high frequency. In production, SARIMA would be replaced by a pre-trained LightGBM or TCN model.


Report generated with Quarto. Source: github.com/aalias01/energy-demand-forecasting