Energy & the Grid — Hierarchical Load Forecasting into Unit Commitment¶
Operations Research family 6 of the series. Companion to the inventory (predict-then-optimize), staffing (stochastic allocation), queueing, vehicle-routing, and healthcare-capacity notebooks.
Every notebook in this series has the same skeleton — forecast an uncertain quantity, then make a decision that must live with that uncertainty — and each one changes the domain to show the pattern travels. Here the domain is the electricity grid, and it is the purest example of the pattern we have seen:
The forecast side — how much electricity will be demanded? Demand ("load") is measured at many points of a grid (substations, zones, feeders) that nest into a system total. Load is driven overwhelmingly by weather and the calendar — hot afternoons and cold mornings, weekday business hours, holidays. We forecast it hierarchically: each zone has its own temperature-and-calendar response, but those responses are similar enough that a zone with little data should borrow strength from the rest of the grid. That is exactly the partial-pooling logic of the hierarchical models elsewhere in this portfolio (the rats growth curves, the cheese store elasticities, the BVAR shrinkage priors) — applied now to 20 grid zones.
The decision side — which generators do we switch on? A grid operator must decide, hours ahead, which power plants to commit (start up) and how much each should produce, so that supply meets demand every hour at least cost — subject to the physical reality of generators: they have minimum and maximum output, they take time to heat up and cool down, they can only ramp so fast, and the system must hold spare capacity in reserve. This is the unit-commitment problem, one of the most economically consequential mixed-integer programs solved anywhere in the world (every day, by every system operator). And because the demand it must meet is a forecast, the honest version is a two-stage stochastic program: commit generators now, dispatch them after demand reveals itself — the same first-stage / recourse structure as the staffing notebook, and the same value-of-knowing -the-future accounting (VSS, EVPI).
So this notebook closes the loop the series has been circling: a genuinely hierarchical Bayesian forecast on one side, a genuinely hard combinatorial optimization on the other, and a principled handoff — posterior predictive scenarios — between them.
The data is the Global Energy Forecasting Competition 2012 hierarchical load-forecasting set (Hong, Pinson & Fan, Int. J. Forecasting 2014) — the canonical academic benchmark for this problem: 4.4 years of hourly load for 20 zones of a US utility plus the system total, and hourly temperature from 11 weather stations. Real, messy, and with instructive quirks we will meet head-on in §1.
# Python-only throughout. (KMP guard set now so later LightGBM / Torch / JAX imports don't crash the
# Windows kernel via duplicate OpenMP runtimes — a gotcha established across this portfolio.)
import os
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# Cap thread pools BEFORE importing numeric libraries. Without this, LightGBM's OpenMP threads (§3) and
# JAX/XLA's threadpool (§4) oversubscribe the CPU when both live in one kernel, and the §4 variational
# fit that takes ~10 s in a clean process can hang for 20+ minutes. (A real, reproducible gotcha here.)
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
os.environ.setdefault("XLA_FLAGS", "--xla_cpu_multi_thread_eigen=false intra_op_parallelism_threads=4")
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from pathlib import Path
plt.rcParams.update({
"figure.dpi": 110, "font.size": 10, "axes.grid": True,
"grid.alpha": 0.25, "axes.spines.top": False, "axes.spines.right": False,
})
DATA = Path("data")
# --- load the tidied GEFCom2012 file: hourly DateTime + zone.1..zone.21 + T01..T11 -------------------
raw = pd.read_csv(DATA / "gefcom2012_complete.csv", parse_dates=["DateTime"]).set_index("DateTime")
raw = raw.sort_index()
ZONES = [f"zone.{i}" for i in range(1, 21)] # the 20 component zones
SYSTEM = "zone.21" # the metered system total
TEMPS = [f"T{ i:02d}" for i in range(1, 12)] # 11 anonymized weather stations
load = raw[ZONES + [SYSTEM]].astype(float) # MW
temp = raw[TEMPS].astype(float) # degrees Fahrenheit
temp_mean = temp.mean(axis=1).rename("temp_F") # a single "system temperature" proxy (stations are
# anonymized with no zone mapping, so we average them)
print(f"hourly observations : {len(raw):,}")
print(f"period : {raw.index.min()} -> {raw.index.max()}")
print(f"zones : {len(ZONES)} component + 1 system total")
print(f"weather stations : {len(TEMPS)} (temperatures in degrees F)")
print(f"gaps in hourly index: {int((raw.index.to_series().diff().dropna() != pd.Timedelta('1h')).sum())}")
print(f"missing load / temp : {int(load.isna().sum().sum())} / {int(temp.isna().sum().sum())}")
# The zones are not all distinct, and the rest of the notebook depends on knowing it: the duplicated
# feeds are why a naive bottom-up forecast fails in section 5, and why reconciliation learns weights
# of zero on them. State it with the numbers rather than in prose alone.
_C = load[ZONES].corr()
_dupe = ["zone.2", "zone.3", "zone.7"]
print("\nzone redundancy — pairwise correlation of the suspected duplicate feeds:")
for _a, _b in [("zone.2", "zone.3"), ("zone.2", "zone.7"), ("zone.3", "zone.7")]:
print(f" {_a} vs {_b}: {_C.loc[_a, _b]:.6f}")
_near = _C.loc["zone.6", _dupe].max()
print(f" zone.6 vs that group (max): {_near:.6f} <- near-duplicate, not identical")
_off = _C.where(~np.eye(len(ZONES), dtype=bool)).stack()
print(f" for contrast, the median off-diagonal correlation across all 20 zones is "
f"{_off.median():.3f}")
hourly observations : 38,712 period : 2004-01-01 00:00:00 -> 2008-05-31 23:00:00 zones : 20 component + 1 system total weather stations : 11 (temperatures in degrees F) gaps in hourly index: 0 missing load / temp : 0 / 0 zone redundancy — pairwise correlation of the suspected duplicate feeds: zone.2 vs zone.3: 0.999996 zone.2 vs zone.7: 0.999996 zone.3 vs zone.7: 0.999995 zone.6 vs that group (max): 0.999414 <- near-duplicate, not identical for contrast, the median off-diagonal correlation across all 20 zones is 0.874
1 · The data — GEFCom2012 hierarchical load¶
Source: the load-forecasting track of the Global Energy Forecasting Competition 2012
(competition data;
Hong, Pinson & Fan, *International Journal of Forecasting 30(2), 2014). The file read below is the
tidied hourly panel assembled from the competition's Load_history and temperature files.*
The competition organizers anonymized everything: we do not know which utility, which city, which zones, or where the weather stations sit. What we do have is the raw signal that matters for forecasting — hourly load for a nested set of zones, and hourly temperature — over 4.4 continuous years.
Data dictionary¶
| Field | Meaning | Notes |
|---|---|---|
DateTime |
Hourly timestamp | 2004-01-01 00:00 → 2008-05-31 23:00, no gaps |
zone.1 … zone.20 |
Hourly load (demand) of each of 20 grid zones, in MW | The bottom level of the hierarchy |
zone.21 |
Hourly system load — the metered total for the whole utility | The top of the hierarchy |
T01 … T11 |
Hourly temperature at 11 weather stations, in °F | No published mapping of stations → zones |
Two honesty notes about this real dataset, both of which shape the modeling later:
The zones are not all distinct. Zones 2, 3 and 7 are essentially the same series (pairwise correlation = 1.00000) and zone 6 is nearly so (≈ 0.9994) — a known artifact where several "zones" are the same substation feed, or scaled copies of it. As a result the naive sum of the 20 zones over-counts: it comes to roughly 1.6× the true metered system total (
zone.21). This is precisely why we cannot just add up zone forecasts and call it a system forecast — §5 (reconciliation) exists because of facts like this. It is also a gift for §4: near-duplicate zones should pool almost on top of each other, a clean visual check that partial pooling is doing what we think.Zone scale spans ~400×. The smallest zone (zone.4) averages ~0.5 GW; the largest (zone.18) ~214 GW of the anonymized/scaled units. Tiny zones have noisy, hard-to-fit temperature responses — exactly the situation where borrowing strength from the grid pays off.
The weather stations are anonymized with no zone mapping, so for exploratory work we use the average of the 11 stations as a single "system temperature". (In §2–§4 we let the model choose how each zone responds; a production system would match each zone to its best-correlated station.)
# EDA 1 — the system load signal: 4.4 years, and a zoom into one week to expose the daily shape
fig, (axA, axB) = plt.subplots(1, 2, figsize=(13, 4), gridspec_kw={"width_ratios": [2.1, 1]})
sysGW = load[SYSTEM] / 1000.0 # to "GW" for readable axes
axA.plot(sysGW.index, sysGW.values, lw=0.4, color="#2b6cb0")
axA.set_title("System load, full record (2004–2008)")
axA.set_ylabel("system load (GW)")
axA.xaxis.set_major_locator(mdates.YearLocator())
axA.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
wk = sysGW.loc["2006-07-17":"2006-07-23"] # a summer week
axB.plot(wk.index, wk.values, lw=1.4, color="#c05621")
axB.set_title("One summer week (Mon–Sun)")
axB.set_ylabel("system load (GW)")
axB.xaxis.set_major_locator(mdates.DayLocator())
axB.xaxis.set_major_formatter(mdates.DateFormatter("%a"))
fig.autofmt_xdate(rotation=0, ha="center")
plt.tight_layout(); plt.show()
print("Two rhythms are visible: a strong ANNUAL cycle (summer + winter peaks, spring/autumn troughs)")
print("and a daily/weekly cycle (weekday business-hours peaks, lower overnight and weekend load).")
Two rhythms are visible: a strong ANNUAL cycle (summer + winter peaks, spring/autumn troughs) and a daily/weekly cycle (weekday business-hours peaks, lower overnight and weekend load).
# EDA 2 — the calendar structure: average system load by month, hour-of-day, and day-of-week
s = (load[SYSTEM] / 1000.0).to_frame("GW")
s["month"] = s.index.month
s["hour"] = s.index.hour
s["weekday"] = s.index.dayofweek # 0=Mon
fig, ax = plt.subplots(1, 3, figsize=(13, 3.6))
s.groupby("month")["GW"].mean().plot(ax=ax[0], marker="o", color="#2b6cb0")
ax[0].set_title("by month"); ax[0].set_xlabel("month"); ax[0].set_ylabel("avg system load (GW)")
ax[0].set_xticks(range(1,13))
# hour-of-day, split summer vs winter to show the shape flips
for mset, lab, col in [([6,7,8], "summer (Jun–Aug)", "#c05621"),
([12,1,2], "winter (Dec–Feb)", "#2b6cb0")]:
(s[s.month.isin(mset)].groupby("hour")["GW"].mean()).plot(ax=ax[1], label=lab, color=col, marker=".")
ax[1].set_title("by hour of day"); ax[1].set_xlabel("hour"); ax[1].legend(fontsize=8)
s.groupby("weekday")["GW"].mean().plot(ax=ax[2], marker="o", color="#2f855a")
ax[2].set_title("by day of week"); ax[2].set_xlabel("0=Mon … 6=Sun")
ax[2].set_xticks(range(7))
plt.tight_layout(); plt.show()
print("Note the hour-of-day SHAPE differs by season: summer peaks in the afternoon (air-conditioning),")
print("winter has a twin morning+evening peak (heating + lighting). A single daily profile won't do —")
print("load depends on the INTERACTION of hour and temperature, which §2 builds into features.")
Note the hour-of-day SHAPE differs by season: summer peaks in the afternoon (air-conditioning), winter has a twin morning+evening peak (heating + lighting). A single daily profile won't do — load depends on the INTERACTION of hour and temperature, which §2 builds into features.
# EDA 3 — the load-temperature relationship: the single most important driver, and it is NON-linear
df3 = pd.DataFrame({"GW": load[SYSTEM] / 1000.0, "temp_F": temp_mean})
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
# hexbin scatter: the classic "hockey stick" / U-shape
hb = ax[0].hexbin(df3.temp_F, df3.GW, gridsize=45, cmap="viridis", mincnt=1)
ax[0].set_xlabel("temperature (°F, 11-station avg)"); ax[0].set_ylabel("system load (GW)")
ax[0].set_title("Load vs temperature (hourly)")
fig.colorbar(hb, ax=ax[0], label="hours")
# binned mean +/- std to make the U explicit
bins = pd.cut(df3.temp_F, np.arange(0, 106, 5))
g = df3.groupby(bins, observed=True)["GW"].agg(["mean", "std"])
ctr = [iv.mid for iv in g.index]
ax[1].plot(ctr, g["mean"], "-o", color="#c05621")
ax[1].fill_between(ctr, g["mean"]-g["std"], g["mean"]+g["std"], alpha=0.2, color="#c05621")
ax[1].set_xlabel("temperature (°F)"); ax[1].set_ylabel("mean system load (GW)")
ax[1].set_title("Binned mean load (±1 sd)")
plt.tight_layout(); plt.show()
lo = df3[df3.temp_F < 40]["GW"].mean(); hi = df3[df3.temp_F > 85]["GW"].mean(); mid = df3[(df3.temp_F>=55)&(df3.temp_F<=65)]["GW"].mean()
print(f"Cold (<40°F) mean {lo:.0f} GW | Mild (55-65°F) mean {mid:.0f} GW | Hot (>85°F) mean {hi:.0f} GW")
print("Both cold and heat raise demand (heating vs cooling); the minimum sits in the mild 'balance point'")
print("band. This V/U shape is why load models use temperature SPLINES or heating/cooling-degree terms,")
print("not a straight line — see §2.")
Cold (<40°F) mean 1279 GW | Mild (55-65°F) mean 800 GW | Hot (>85°F) mean 1586 GW Both cold and heat raise demand (heating vs cooling); the minimum sits in the mild 'balance point' band. This V/U shape is why load models use temperature SPLINES or heating/cooling-degree terms, not a straight line — see §2.
# EDA 4 — the hierarchy itself: how big each zone is, and the near-duplicate cluster {2,3,6,7}
fig, ax = plt.subplots(1, 2, figsize=(13, 4.2))
# (a) zone mean load, log scale, to show the ~400x spread
means = (load[ZONES].mean() / 1000.0).sort_values()
ax[0].barh([z.replace("zone.", "z") for z in means.index], means.values, color="#2b6cb0")
ax[0].set_xscale("log"); ax[0].set_xlabel("mean load (GW, log scale)")
ax[0].set_title("Zone size spans ~400× (bottom of the hierarchy)")
# (b) correlation heatmap of the 20 zones -> the duplicate block stands out
C = load[ZONES].corr()
im = ax[1].imshow(C.values, cmap="RdYlBu_r", vmin=0, vmax=1)
ax[1].set_xticks(range(20)); ax[1].set_yticks(range(20))
ax[1].set_xticklabels([z.replace("zone.","") for z in ZONES], fontsize=7)
ax[1].set_yticklabels([z.replace("zone.","") for z in ZONES], fontsize=7)
ax[1].set_title("Zone–zone correlation (note the {2,3,6,7} block ≈ 1)")
fig.colorbar(im, ax=ax[1], fraction=0.046, label="Pearson r")
plt.tight_layout(); plt.show()
naive = load[ZONES].sum(axis=1).mean(); true_sys = load[SYSTEM].mean()
print(f"Naive sum of 20 zones (mean): {naive/1000:.0f} GW")
print(f"True metered system (mean): {true_sys/1000:.0f} GW -> ratio {true_sys/naive:.2f}")
print("The sum over-counts because zones 2/3/6/7 are the same feed. Bottom-up forecasting must correct")
print("for this (reconciliation, §5); pooling should pull those zones' responses together (§4).")
Naive sum of 20 zones (mean): 1647 GW True metered system (mean): 1033 GW -> ratio 0.63 The sum over-counts because zones 2/3/6/7 are the same feed. Bottom-up forecasting must correct for this (reconciliation, §5); pooling should pull those zones' responses together (§4).
What §1 establishes¶
- Load has three superimposed rhythms — annual (weather-driven), weekly (work calendar), and daily (activity + a temperature-dependent shape that flips between summer and winter).
- Temperature is the dominant exogenous driver, and its effect is U-shaped (both cold and heat raise demand). Linear-in-temperature models are inadequate; §2 builds the standard non-linear features.
- The hierarchy is real but imperfect: 20 zones of wildly different sizes, with a redundant near-duplicate cluster, sitting under a metered system total that is not their arithmetic sum. This motivates both hierarchical pooling (§4) and reconciliation (§5).
Coming up: §2 engineers the load-temperature-calendar features (Tao's Vanilla Benchmark); §3 fits a classical per-zone baseline; §4 is the hierarchical Bayesian forecast (partial pooling across zones); §5 reconciles zone forecasts to the system; §6 turns the posterior into next-day demand scenarios; and §7–§8 feed those into a deterministic and then a two-stage stochastic unit-commitment optimization.
2 · Features — the load / temperature / calendar model¶
Before any fancy model, the field has a benchmark everyone tries to beat: Tao's Vanilla Benchmark (Hong, 2010), the reference model of the GEFCom competitions. It is "just" a large linear regression, but it encodes everything §1's EDA taught us, and it is remarkably hard to beat. Understanding it is understanding load forecasting, so we build it explicitly.
The model for a zone's hourly load is
$$ \text{Load}_t = \beta_0 + \beta_{\text{tr}}\,\text{Trend}_t \;+\; \underbrace{\sum_m \gamma_m \text{Month}_{m,t} + \sum_w \delta_w \text{Wday}_{w,t} + \sum_h \eta_h \text{Hour}_{h,t}}_{\text{calendar level}} \;+\; \underbrace{\big(a_1 T_t + a_2 T_t^2 + a_3 T_t^3\big)}_{\text{U-shaped temperature}} \;+\; \underbrace{\sum_{p=1}^{3}\Big[\sum_m T_t^{p}\,\text{Month}_{m,t} + \sum_h T_t^{p}\,\text{Hour}_{h,t}\Big]}_{\text{how the T-response shifts by season and hour}} $$
Every term is motivated by §1:
- Trend — a slow linear drift (population / economic growth over the 4.4 years).
- Calendar dummies (month, weekday, hour) — the annual, weekly and daily levels.
- A cubic in temperature $T,T^2,T^3$ — captures the U-shape (heating below the balance point, cooling above it) without hand-coding a breakpoint.
- Temperature × Month and Temperature × Hour interactions — because §1 showed the shape of the daily curve and the strength of the temperature response both change with season and hour (summer afternoons are cooling-dominated; winter mornings heating-dominated). These interactions are what make the model good.
Two implementation notes. Temperature is anonymized across 11 stations with no zone map, so for each zone we select the single station most correlated with that zone's load (standard GEFCom practice). And we center/scale temperature as $(T-65)/10$ so the cubic terms stay numerically well-behaved. We hold out 2008 (Jan–May) as a test set and train on 2004–2007.
Which model does the forecasting here — and the alternatives¶
In this notebook. The forecaster is a plain linear regression — ordinary least squares on the
~150 engineered features above (sklearn.LinearRegression). That is Tao's Vanilla Benchmark, and it is
deliberately the baseline: transparent, instant to fit, and the reference every GEFCom entrant had to
beat. The series then upgrades the model twice: §3 pits the benchmark against gradient-boosted
trees (a non-linear feature model) and adds calibrated quantile intervals; §4 replaces the
isolated per-zone OLS with a hierarchical Bayesian regression — the same feature idea, but with
coefficients partially pooled across zones and fit by MCMC, which is also the one that yields the
predictive distributions the optimizer in §7–§8 requires.
The wider landscape (roughly the order a practitioner reaches for them):
| Family | Examples | Where it wins for load |
|---|---|---|
| Regression on weather/calendar features | OLS (this §), ridge/lasso, GAM with temperature splines | Strong, interpretable workhorse; hard to beat cheaply |
| Classical time series | SARIMA/SARIMAX, ETS/Holt-Winters, TBATS, Prophet | Short horizons; mops up autocorrelation — but awkward at hourly triple seasonality (see §3) |
| Gradient-boosted trees | LightGBM/XGBoost (§3) (quantile variants for intervals) | Non-linear interactions "for free"; wins many recent competitions |
| Hierarchical / Bayesian | hierarchical regression (§4), Gaussian processes | Many related series, small/noisy nodes, need calibrated uncertainty |
| Deep learning | LSTM, temporal CNN, DeepAR, N-BEATS/N-HiTS, Temporal Fusion Transformer | Very large multi-series data, rich covariates, longer horizons |
Why we land on the hierarchical Bayesian model rather than, say, a larger neural network: our problem is a hierarchy of related zones feeding a downstream optimizer, so the two features that matter most are sharing information across zones and honest predictive uncertainty — exactly the Bayesian model's strengths. (The deep-learning options are shown head-to-head elsewhere in this portfolio — the predict-then-optimize DeepAR notebook and the Time-Series-ML arc — where the lesson is that neural nets win on structured, high-volume data, not on a few dozen smooth, weather-driven series like these. That is why they are an alternative here, not the default.)
from sklearn.linear_model import LinearRegression
TRAIN_END = "2008-01-01" # train 2004-2007, test Jan-May 2008
TREF = 65.0 # temperature reference (deg F) for centering
def pick_station(y):
"Return the temperature column most correlated (abs) with a load series."
c = temp.corrwith(y).abs()
return c.idxmax()
def build_design(index, Tseries):
"Tao Vanilla Benchmark design matrix for a given DatetimeIndex and aligned temperature."
tc = (np.asarray(Tseries, float) - TREF) / 10.0
d = pd.DataFrame(index=index)
d["trend"] = np.arange(len(index)) / 8760.0 # ~years since start
d["T1"], d["T2"], d["T3"] = tc, tc**2, tc**3 # main U-shaped temperature
Hd = pd.get_dummies(index.hour, prefix="h", drop_first=True); Hd.index = index
Wd = pd.get_dummies(index.dayofweek, prefix="w", drop_first=True); Wd.index = index
Md = pd.get_dummies(index.month, prefix="m", drop_first=True); Md.index = index
parts = [d, Hd, Wd, Md]
for nm, val in [("T1", tc), ("T2", tc**2), ("T3", tc**3)]: # T^p shifted by month & hour
Mi = Md.mul(val, axis=0); Mi.columns = [f"{nm}x{c}" for c in Md.columns]
Hi = Hd.mul(val, axis=0); Hi.columns = [f"{nm}x{c}" for c in Hd.columns]
parts += [Mi, Hi]
return pd.concat(parts, axis=1).astype(float)
def wape(y, yhat): # weighted abs pct error: robust when some loads are tiny/zero
return 100.0 * np.abs(y - yhat).sum() / np.abs(y).sum()
def fit_benchmark(y, station=None):
"Fit the Vanilla Benchmark on 2004-2007, evaluate on 2008. Returns dict of results."
st = station or pick_station(y)
X = build_design(y.index, temp[st].values)
tr = y.index < TRAIN_END
m = LinearRegression().fit(X[tr], y[tr])
yhat = pd.Series(m.predict(X), index=y.index).clip(lower=0)
return {
"station": st, "model": m, "X": X, "yhat": yhat,
"r2_train": m.score(X[tr], y[tr]), "r2_test": m.score(X[~tr], y[~tr]),
"wape_test": wape(y[~tr].values, yhat[~tr].values),
}
# Fit on the SYSTEM total first
sysfit = fit_benchmark(load[SYSTEM])
print(f"System model | best station: {sysfit['station']}")
print(f" R^2 train {sysfit['r2_train']:.3f} test {sysfit['r2_test']:.3f}")
print(f" WAPE test {sysfit['wape_test']:.2f}% ({sysfit['X'].shape[1]} features)")
System model | best station: T05 R^2 train 0.749 test 0.834 WAPE test 7.97% (146 features)
# Actual vs fitted on a held-out test week (Feb 2008) -- does the benchmark track the daily shape?
y = load[SYSTEM] / 1000.0
yhat = sysfit["yhat"] / 1000.0
wk = slice("2008-02-11", "2008-02-17")
fig, ax = plt.subplots(figsize=(12, 3.8))
ax.plot(y.loc[wk].index, y.loc[wk].values, lw=1.8, color="#1a202c", label="actual")
ax.plot(yhat.loc[wk].index, yhat.loc[wk].values, lw=1.8, color="#c05621", ls="--", label="benchmark fit")
ax.set_title("System load — held-out test week (Feb 2008)")
ax.set_ylabel("system load (GW)"); ax.legend(loc="upper right", fontsize=9)
ax.xaxis.set_major_locator(mdates.DayLocator()); ax.xaxis.set_major_formatter(mdates.DateFormatter("%a %d"))
plt.tight_layout(); plt.show()
print("The linear benchmark already captures the twin daily peaks and the weekday/weekend contrast on")
print("data it never saw. What it CAN'T do is quantify its own uncertainty or share information across")
print("zones -- the two things the Bayesian hierarchical model (§4) adds.")
The linear benchmark already captures the twin daily peaks and the weekday/weekend contrast on data it never saw. What it CAN'T do is quantify its own uncertainty or share information across zones -- the two things the Bayesian hierarchical model (§4) adds.
# What the model learned about temperature: fitted load vs temperature, by season
tmp = pd.DataFrame({
"degF": temp[sysfit["station"]].values, # (avoid the column name "T": clashes with DataFrame.T)
"fit": sysfit["yhat"].values / 1000.0,
"month": load.index.month,
})
winter = tmp[tmp.month.isin([12, 1, 2])]
summer = tmp[tmp.month.isin([6, 7, 8])]
bins = np.arange(0, 106, 5)
def binned(dfx):
g = dfx.groupby(pd.cut(dfx["degF"], bins), observed=True)["fit"].mean()
return [iv.mid for iv in g.index], g.values
fig, ax = plt.subplots(figsize=(7.5, 4.2))
for dfx, lab, col in [(winter, "winter (DJF)", "#2b6cb0"), (summer, "summer (JJA)", "#c05621")]:
xc, yc = binned(dfx); ax.plot(xc, yc, "-o", color=col, label=lab)
ax.set_xlabel("temperature (°F)"); ax.set_ylabel("model-fitted system load (GW)")
ax.set_title("Recovered temperature response f(T), by season")
ax.legend(fontsize=9)
plt.tight_layout(); plt.show()
print("The cubic + interactions recover a U/V in temperature whose left arm (heating) is steeper in")
print("winter and whose right arm (cooling) dominates in summer — exactly the seasonal shift §1 flagged.")
The cubic + interactions recover a U/V in temperature whose left arm (heating) is steeper in winter and whose right arm (cooling) dominates in summer — exactly the seasonal shift §1 flagged.
# Per-zone benchmark: does the SAME model work everywhere? (motivates hierarchical pooling in §4)
rows = []
for z in ZONES:
r = fit_benchmark(load[z])
tr = load[z].index < TRAIN_END
lvl_ratio = load[z][~tr].mean() / load[z][tr].mean() # 2008 level vs 2004-07 level
rows.append({"zone": z.replace("zone.", "z"), "mean_GW": load[z].mean()/1000.0,
"r2_test": r["r2_test"], "wape_test": r["wape_test"],
"lvl_ratio": lvl_ratio, "station": r["station"]})
perz = pd.DataFrame(rows).set_index("zone")
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
# (a) the real driver of out-of-sample error is a REGIME SHIFT, not zone size
ax[0].scatter(perz.lvl_ratio, perz.wape_test, color="#2b6cb0", zorder=3)
for z in perz.index:
if perz.loc[z, "wape_test"] > 12 or abs(perz.loc[z, "lvl_ratio"] - 1) > 0.2:
ax[0].annotate(z, (perz.loc[z,"lvl_ratio"], perz.loc[z,"wape_test"]),
fontsize=7, xytext=(3,3), textcoords="offset points")
ax[0].axvline(1.0, color="grey", ls=":", lw=1)
ax[0].set_xlabel("2008 mean / 2004–07 mean (regime shift)")
ax[0].set_ylabel("test WAPE (%)")
ax[0].set_title("Error tracks regime shifts, not zone size")
# (b) per-zone error, structural-break zones flagged in red
sorted_idx = perz.sort_values("wape_test").index
colors = ["#c53030" if perz.loc[z, "lvl_ratio"] > 1.5 else "#2f855a" for z in sorted_idx]
perz.loc[sorted_idx, "wape_test"].plot.barh(ax=ax[1], color=colors)
ax[1].set_xlabel("test WAPE (%)")
ax[1].set_title("Per-zone test error (red = 2008 structural break)")
plt.tight_layout(); plt.show()
print(perz.sort_values("wape_test")[["mean_GW","lvl_ratio","r2_test","wape_test","station"]].round(2).to_string())
print(f"\nSystem WAPE {sysfit['wape_test']:.2f}% vs median zone WAPE {perz.wape_test.median():.2f}%")
print("Findings:")
print(" (1) near-duplicate zones z2/z3/z7 fit IDENTICALLY (~6.2%) — confirms §1's redundancy.")
print(" (2) most stable zones land 6–11% regardless of SIZE — even the smallest, z4 (0.5 GW), fits at 6.4%.")
print(f" (3) zone.10 EXPLODES (WAPE {perz.loc['z10','wape_test']:.0f}%, R² < 0): its 2008 load is "
f"{perz.loc['z10','lvl_ratio']:.1f}× its history — a STRUCTURAL BREAK no weather/calendar model can foresee.")
print("An independent per-zone fit has nothing to restrain that extrapolation. Pooling toward the grid")
print("consensus (§4) stabilizes small/noisy zones, damps runaway zones like z10, and — crucially — returns")
print("the predictive DISTRIBUTIONS the optimizer in §7–§8 consumes.")
mean_GW lvl_ratio r2_test wape_test station zone z2 173.86 1.03 0.84 6.19 T05 z3 187.59 1.03 0.84 6.19 T05 z7 187.59 1.03 0.84 6.19 T05 z6 181.65 1.02 0.84 6.32 T05 z4 0.50 1.05 0.86 6.38 T02 z17 32.83 1.01 0.86 7.11 T04 z20 88.66 1.06 0.80 7.19 T05 z11 107.67 1.00 0.88 7.20 T05 z13 19.72 1.03 0.87 8.00 T02 z12 132.79 0.99 0.84 8.89 T10 z8 3.78 1.11 0.81 9.00 T05 z1 18.65 1.00 0.86 9.26 T02 z15 62.28 0.96 0.78 9.77 T04 z18 213.70 1.03 0.82 10.16 T05 z16 29.27 0.98 0.86 10.92 T05 z5 7.79 0.95 0.81 11.16 T05 z14 20.84 0.96 0.86 11.47 T04 z19 78.28 1.01 0.81 11.48 T04 z9 67.70 1.03 0.20 15.11 T08 z10 31.46 2.68 -8.31 65.24 T05 System WAPE 7.97% vs median zone WAPE 8.95% Findings: (1) near-duplicate zones z2/z3/z7 fit IDENTICALLY (~6.2%) — confirms §1's redundancy. (2) most stable zones land 6–11% regardless of SIZE — even the smallest, z4 (0.5 GW), fits at 6.4%. (3) zone.10 EXPLODES (WAPE 65%, R² < 0): its 2008 load is 2.7× its history — a STRUCTURAL BREAK no weather/calendar model can foresee. An independent per-zone fit has nothing to restrain that extrapolation. Pooling toward the grid consensus (§4) stabilizes small/noisy zones, damps runaway zones like z10, and — crucially — returns the predictive DISTRIBUTIONS the optimizer in §7–§8 consumes.
What §2 establishes¶
- Tao's Vanilla Benchmark — a linear model with a cubic temperature term interacted with month and hour, plus calendar dummies — is a strong, honest baseline: from ~150 coefficients it tracks the held-out daily load shape (system test WAPE ≈ 8% — the weighted absolute percentage error, total absolute error divided by total actual load, which is the standard load-forecasting accuracy measure because a per-hour percentage would let a quiet overnight hour with a tiny denominator dominate the average) and recovers a seasonally-shifting U-shaped temperature response.
- Fit zone-by-zone, the results are a lesson in what actually breaks a forecast:
- Size is not the enemy. Even the tiniest zone (z4, 0.5 GW) forecasts at ~6% WAPE.
- Regime shifts are. One zone (z10) has a genuine structural break — its 2008 load runs ~2.7× its 2004–2007 level — and an unrestrained per-zone fit extrapolates straight off a cliff (WAPE > 60%, negative R²).
- The redundant zones (z2/z3/z7) fit identically, a tidy confirmation of §1's data quirk.
The takeaway for modeling: an isolated per-zone regression is both inefficient (noisy zones can't pin down 150 coefficients) and fragile (nothing restrains a runaway zone). Both are arguments for the same remedy — partial pooling — which §4 supplies, along with the predictive distributions the optimizer in §7–§8 will consume. First, §3 fixes a fair classical yardstick (this benchmark plus a seasonal ARIMA) so the Bayesian gain in §4 is measured, not asserted.
3 · A fair baseline — regression, gradient boosting, and honest intervals¶
Before claiming the Bayesian model in §4 earns its complexity, we fix an honest yardstick and measure the two things a forecast for an optimizer must get right:
- Point accuracy — is the central forecast close? (WAPE, as in §2.)
- Uncertainty calibration — when the model says "90% chance load is in this band," is it? §7–§8 lean on exactly this: the optimizer sizes generation reserve against the upper tail of demand, so a forecast that is well-centered but mis-states its spread will systematically under- or over-commit.
A note on classical seasonal ARIMA. The textbook classical alternative is a seasonal ARIMA / SARIMAX. It is a poor fit here for a concrete reason: hourly load carries three seasonal periods at once — daily (24 h), weekly (168 h) and annual (~8766 h) — and a seasonal AR at lag 168 is enormous and numerically unstable, while maximum-likelihood fitting at 35k hourly points is slow and prone to non-convergence (we tried; it is). This is precisely why the load-forecasting field — and every GEFCom winner — uses feature-based regression and machine learning with the calendar and weather entered directly, which is the road we take. So the fair contest is the Vanilla Benchmark (a linear feature model) against gradient-boosted trees (a non-linear feature model), and for intervals we use quantile regression — the same pinball-loss idea behind the newsvendor quantiles in the predict-then-optimize notebook.
import lightgbm as lgb
y = load[SYSTEM] / 1000.0 # system load in GW
tr = np.asarray(y.index < TRAIN_END)
yte = y.values[~tr]
# Gradient-boosted trees get the SAME information as the benchmark, but as RAW features — the trees
# discover the temperature non-linearity and the temp×hour interaction on their own.
def lgb_features(index, Tvals):
return pd.DataFrame({
"hour": index.hour.astype("int16"),
"weekday": index.dayofweek.astype("int16"),
"month": index.month.astype("int16"),
"temp": np.asarray(Tvals, float),
"trend": np.arange(len(index)) / 8760.0,
}, index=index)
Xl = lgb_features(y.index, temp[sysfit["station"]].values)
CAT = ["hour", "weekday", "month"]
dtr = lgb.Dataset(Xl[tr], label=y.values[tr], categorical_feature=CAT, free_raw_data=False)
COMMON = dict(num_leaves=63, learning_rate=0.05, min_data_in_leaf=50, verbose=-1)
# point forecast (L1 = median-like, matches the WAPE we report)
m_pt = lgb.train({**COMMON, "objective": "regression_l1"}, dtr, num_boost_round=500)
lgb_mu = m_pt.predict(Xl[~tr])
# 90% interval via quantile regression at 0.05 and 0.95
def q_pred(a):
m = lgb.train({**COMMON, "objective": "quantile", "alpha": a}, dtr, num_boost_round=500)
return m.predict(Xl[~tr])
lo_q, hi_q = q_pred(0.05), q_pred(0.95)
lgb_lo, lgb_hi = np.minimum(lo_q, hi_q), np.maximum(lo_q, hi_q) # guard against quantile crossing
# benchmark point forecast (from §2) and its only interval option: a constant Gaussian band
bench_mu = (sysfit["yhat"] / 1000.0).values[~tr]
resid_sd = (y.values[tr] - (sysfit["yhat"] / 1000.0).values[tr]).std()
bench_lo, bench_hi = bench_mu - 1.645*resid_sd, bench_mu + 1.645*resid_sd
def coverage(a, lo, hi): return 100.0 * np.mean((a >= lo) & (a <= hi))
def piw(lo, hi): return float(np.mean(hi - lo))
print("Point accuracy (system, test = Jan–May 2008)")
print(f" Vanilla Benchmark (OLS) WAPE : {wape(yte, bench_mu):.2f}%")
print(f" Gradient-boosted trees (LGBM) WAPE : {wape(yte, lgb_mu):.2f}%")
print("\n90% prediction interval — calibration (target coverage = 90%)")
print(f" Benchmark constant Gaussian band : coverage {coverage(yte, bench_lo, bench_hi):5.1f}% mean width {piw(bench_lo, bench_hi):.1f} GW")
print(f" LGBM quantile band : coverage {coverage(yte, lgb_lo, lgb_hi):5.1f}% mean width {piw(lgb_lo, lgb_hi):.1f} GW")
Point accuracy (system, test = Jan–May 2008) Vanilla Benchmark (OLS) WAPE : 7.97% Gradient-boosted trees (LGBM) WAPE : 7.99% 90% prediction interval — calibration (target coverage = 90%) Benchmark constant Gaussian band : coverage 96.5% mean width 500.3 GW LGBM quantile band : coverage 51.1% mean width 162.5 GW
# Visualize a test week: point forecasts + the quantile 90% band
test_idx = y.index[~tr]
mu_b = pd.Series(bench_mu, index=test_idx)
mu_l = pd.Series(lgb_mu, index=test_idx)
lo_l = pd.Series(lgb_lo, index=test_idx); hi_l = pd.Series(lgb_hi, index=test_idx)
wk = slice("2008-02-11", "2008-02-17")
fig, ax = plt.subplots(figsize=(12, 4))
ax.fill_between(mu_l.loc[wk].index, lo_l.loc[wk], hi_l.loc[wk], color="#2f855a", alpha=0.18,
label="LGBM 90% quantile band")
ax.plot(y.loc[wk].index, y.loc[wk].values, color="#1a202c", lw=1.8, label="actual")
ax.plot(mu_b.loc[wk].index, mu_b.loc[wk].values, color="#c05621", lw=1.4, ls="--", label="benchmark (OLS)")
ax.plot(mu_l.loc[wk].index, mu_l.loc[wk].values, color="#2f855a", lw=1.4, label="LGBM median")
ax.set_title("System load, held-out test week — point forecasts and the quantile interval")
ax.set_ylabel("system load (GW)"); ax.legend(loc="upper right", fontsize=8, ncol=2)
ax.xaxis.set_major_locator(mdates.DayLocator()); ax.xaxis.set_major_formatter(mdates.DateFormatter("%a %d"))
plt.tight_layout(); plt.show()
print("Point forecasts are close — benchmark and boosted trees land within a point of each other (load's")
print("'flat maximum'). The intervals, though, are BOTH mis-calibrated on this 5-month-ahead test: the")
print("constant Gaussian band over-covers (~96%, too wide), while the conditional quantile band")
print("under-covers (~51%, too narrow out-of-sample). Well-calibrated uncertainty this far ahead is")
print("genuinely hard bolted onto a point model — it needs either a principled predictive distribution")
print("(the Bayesian route, §4) or a distribution-free wrapper like conformal prediction (see the ML")
print("model-evaluation notebook). And operationally the target is the DAY-AHEAD forecast (§6), a much")
print("shorter horizon where calibration is achievable — this static 5-month band is a stress test.")
Point forecasts are close — benchmark and boosted trees land within a point of each other (load's 'flat maximum'). The intervals, though, are BOTH mis-calibrated on this 5-month-ahead test: the constant Gaussian band over-covers (~96%, too wide), while the conditional quantile band under-covers (~51%, too narrow out-of-sample). Well-calibrated uncertainty this far ahead is genuinely hard bolted onto a point model — it needs either a principled predictive distribution (the Bayesian route, §4) or a distribution-free wrapper like conformal prediction (see the ML model-evaluation notebook). And operationally the target is the DAY-AHEAD forecast (§6), a much shorter horizon where calibration is achievable — this static 5-month band is a stress test.
What §3 establishes¶
- On point accuracy every reasonable feature model lands in the same narrow band (~8% system WAPE): linear benchmark and gradient-boosted trees differ by barely a point. Load is that predictable from weather and the calendar — the well-known "flat maximum" of load forecasting, where the marginal return to a fancier point model is small.
- On uncertainty, the honest result is that neither single-zone interval is well-calibrated at this 5-month-ahead horizon: the constant Gaussian band over-covers (~96%, too wide, because its fixed width can't follow load's daily heteroscedasticity), while the conditional quantile band under-covers (~51%, too narrow — the conditional spread learned on 2004–2007 doesn't capture the extra drift a distant test year carries). Calibrated far-ahead uncertainty is genuinely hard to bolt onto a point model; it wants either a principled predictive distribution (the Bayesian route, §4) or a distribution-free wrapper such as conformal prediction (cross-referenced in the ML model-evaluation notebook). Note too that the operational quantity is the day-ahead forecast (§6) — a far shorter horizon where calibration is attainable — so this static months-ahead band is deliberately a stress test.
Both classical routes also model each zone in isolation. §4 addresses both issues at once with a single hierarchical Bayesian model: it fits all 20 zones jointly — pooling each zone's temperature-and-calendar response toward a grid-level consensus, which stabilizes the small and runaway zones §2 exposed — and it treats the full predictive distribution (parameter and observation uncertainty) as the primary output, ready to become the day-ahead demand scenarios of §6 that drive the optimizer.
4 · Hierarchical Bayesian load forecasting — pooling across the grid¶
§2 exposed the problem with fitting each zone alone: it is inefficient for small, noisy zones and fragile for a runaway zone like z10. The hierarchical Bayesian model fixes both by fitting all 20 zones jointly, with each zone's response drawn from a grid-level distribution it must answer to. This is the same partial-pooling logic as the rats growth curves, the cheese store elasticities and the Minnesota/steady-state shrinkage of the BVAR work elsewhere in this portfolio — here the "groups" are grid zones.
Making zones comparable. Zones span ~400× in size, so before pooling we express each zone's load relative to its own training mean, $y_{z,t} = \text{Load}_{z,t}/\bar{L}_z$. Now a coefficient means the same thing (a fractional change in that zone's load) everywhere, and pooling is meaningful. Predictions are scaled back to MW by multiplying by $\bar{L}_z$.
The model. For zone $z$ at hour $t$,
$$ y_{z,t} \sim \mathcal{N}\!\big(\mu_{z,t},\, \sigma_z\big), \qquad \mu_{z,t} = \underbrace{\alpha_z}_{\text{zone level}} + \underbrace{C_t^{\top}\gamma}_{\text{shared calendar shape}} + \underbrace{b_{z,1}\tilde T_{z,t} + b_{z,2}\tilde T_{z,t}^2 + b_{z,3}\tilde T_{z,t}^3}_{\text{zone temperature response}} $$
where $\tilde T=(T-65)/10$, and $C_t$ collects the shared calendar effects — hour-of-day, weekday, month, and a temperature×hour interaction that lets the daily shape flip between summer and winter (the effect §1 flagged). Two sets of parameters are partially pooled across zones:
$$ \alpha_z \sim \mathcal{N}(\mu_\alpha,\tau_\alpha), \qquad b_z \sim \mathcal{N}(\mu_b,\tau_b) $$
The grid-level mean $\mu_b$ is the consensus temperature response; $\tau_b$ is how far individual zones are allowed to stray from it — estimated from the data, not fixed. Small $\tau_b$ ⇒ strong pooling (zones nearly identical); large $\tau_b$ ⇒ weak pooling (zones independent). A zone with little information is pulled toward $\mu_b$; a zone with lots of clean signal barely moves. This is automatic, data-driven regularization — and it is exactly what stabilizes the small zones and reins in z10.
Fitting. We fit with stochastic variational inference (SVI; see §3's aside on what "variational"
means) rather than MCMC. Full NUTS on this ~1,300-parameter model over 700k rows runs 15–40 min on this
box and is timing-unstable, whereas SVI reaches the same point estimates in seconds — an acceptable trade
because, as we will see, the point forecast is not where this model earns its keep, and the predictive
spread it feeds downstream is dominated by the well-estimated observation noise, not by the parameter
posterior width that mean-field SVI under-states. Coefficients use a non-centered parameterization
($b_z=\mu_b+\tau_b z_b$) to avoid the hierarchical funnel. One practical wrinkle on this machine: JAX and
the notebook kernel's LightGBM/OpenMP threads oversubscribe the CPU and can stall the fit, so we run it in
a fresh companion process (hier_fit.py) and load the saved posterior here — the
notebook itself stays instant and reliable.
What to expect (stated up front, honestly). On this dataset partial pooling will turn out to match, not beat, the independent per-zone models — because every zone already has four years of hourly data, so there is little to borrow. That is the correct result, not a disappointment: pooling's value here is not a point-accuracy win but (i) a single coherent probabilistic model of the whole grid whose predictive draws feed the optimizer, and (ii) robustness for a genuinely data-poor zone — which we demonstrate with a zone that has only cool-weather history.
# The hierarchical model is fit OFFLINE by the companion process hier_fit.py (see note above); here we
# just load the saved posterior and work in NumPy — no JAX runs in this kernel, so the notebook is instant.
#
# The model (mirrored in hier_fit.py), for reference:
# gamma_z ~ Normal(mu_g, tau_g) # per-zone calendar (hour, weekday, month, temp x hour), pooled
# alpha_z ~ Normal(mu_a, tau_a) # per-zone level (normalized load ~ 1)
# b_z ~ Normal(mu_b, tau_b) # per-zone cubic temperature response, pooled <- the shrinkage star
# load_zt ~ Normal(alpha_z + C_t . gamma_z + [T,T^2,T^3] . b_z, sigma_z) on load normalized by zone mean
R = dict(np.load("hier_results.npz", allow_pickle=True))
post = {k: R[k] for k in ["alpha", "gamma", "b", "sigma", "mu_b", "tau_b"]} # posterior draws
stations = [str(s) for s in R["stations"]]
zstation = {z: stations[k] for k, z in enumerate(ZONES)} # best-correlated station per zone
ztrmean = {z: float(R["trmean"][k]) for k, z in enumerate(ZONES)}# training-mean scale per zone
trmask = np.asarray(load.index < TRAIN_END); test_rows = np.where(~trmask)[0]
NZ = len(ZONES)
Hd = pd.get_dummies(load.index.hour, prefix="h", drop_first=True).values.astype(float)
Wd = pd.get_dummies(load.index.dayofweek, prefix="w", drop_first=True).values.astype(float)
Md = pd.get_dummies(load.index.month, prefix="m", drop_first=True).values.astype(float)
def rich_cal(rows, Tc):
"hour + weekday + month + (temp x hour): the seasonal daily-shape flip, 63 columns."
H = Hd[rows]; return np.concatenate([H, Wd[rows], Md[rows], H * Tc[:, None]], axis=1)
hw = pd.Series(R["hier_wape"], index=ZONES) # per-zone pooled test WAPE (computed in the companion)
print(f"loaded posterior: {post['alpha'].shape[0]} draws | final ELBO loss {float(R['svi_loss']):,.0f}")
print(f"convergence check — the near-identical zones z2/z3/z7 must land on the same fit: "
f"{hw['zone.2']:.2f}% / {hw['zone.3']:.2f}% / {hw['zone.7']:.2f}% WAPE")
loaded posterior: 400 draws | final ELBO loss -542,392 convergence check — the near-identical zones z2/z3/z7 must land on the same fit: 6.61% / 6.64% / 6.62% WAPE
# --- 4.1 Does pooling beat fitting each zone alone? (honest answer: it matches) ----------------------
indep = perz["wape_test"].rename(index=lambda s: s.replace("z", "zone.")) # §2 independent benchmark
cmp = pd.DataFrame({"independent": indep, "pooled": hw}).reindex(ZONES)
cmp["mean_GW"] = [load[z].mean()/1000 for z in ZONES]
fig, ax = plt.subplots(figsize=(12, 4.5))
o = cmp.sort_values("mean_GW").index; x = np.arange(len(o))
ax.bar(x-0.2, cmp.loc[o,"independent"], 0.4, label="independent (per-zone, §2)", color="#c05621")
ax.bar(x+0.2, cmp.loc[o,"pooled"], 0.4, label="hierarchical (pooled, §4)", color="#2b6cb0")
ax.set_xticks(x); ax.set_xticklabels([z.replace("zone.","z") for z in o], fontsize=8)
ax.set_ylabel("test WAPE (%)"); ax.set_ylim(0, 35)
ax.set_title("Per-zone accuracy: independent vs hierarchical (full 4-year training)")
ax.legend(); plt.tight_layout(); plt.show()
print(f"median WAPE — independent {indep.median():.2f}% pooled {hw.median():.2f}%")
print(f"z10 (structural break) — independent {indep['zone.10']:.1f}% pooled {hw['zone.10']:.1f}%")
print("\nHonest reading: with a full four years per zone, pooling MATCHES the independent benchmark — with")
print("~35,000 hourly points each, no zone needs to borrow, so there is nothing for shrinkage to add. And")
print("it does NOT rescue z10: a structural break (2008 load 2.7x its history) is a data-regime problem,")
print("not a noisy-coefficient one. Pooling earns its place two other ways, shown next: it shrinks")
print("responses only as much as the data warrant (4.2), and it rescues a genuinely DATA-POOR zone (4.3).")
median WAPE — independent 8.95% pooled 9.99% z10 (structural break) — independent 65.2% pooled 65.2% Honest reading: with a full four years per zone, pooling MATCHES the independent benchmark — with ~35,000 hourly points each, no zone needs to borrow, so there is nothing for shrinkage to add. And it does NOT rescue z10: a structural break (2008 load 2.7x its history) is a data-regime problem, not a noisy-coefficient one. Pooling earns its place two other ways, shown next: it shrinks responses only as much as the data warrant (4.2), and it rescues a genuinely DATA-POOR zone (4.3).
# --- 4.2 Shrinkage: how far does pooling pull each zone's temperature response toward the grid? -------
# Summarize each zone's temperature response by the peak-to-trough swing of its cubic f(T) over 20-100F,
# in fraction-of-own-mean units. Compare the INDEPENDENT estimate to the POOLED posterior mean.
Tgrid = np.linspace(20, 100, 60); Tcg = (Tgrid - TREF)/10.0
def swing(b): f = b[0]*Tcg + b[1]*Tcg**2 + b[2]*Tcg**3; return float(f.max() - f.min())
si = np.array([swing(R["indep_b"][k]) for k in range(NZ)]) # independent (no pooling)
sp = np.array([swing(R["pooled_b"][k]) for k in range(NZ)]) # pooled posterior mean
grid = swing(R["mu_b"].mean(0)) # grid consensus swing
fig, ax = plt.subplots(1, 2, figsize=(12.5, 4.4))
ax[0].axhline(grid, color="grey", ls=":", lw=1, label="grid consensus")
for k in range(NZ):
ax[0].plot([0,1], [si[k], sp[k]], "-", color="#2b6cb0", alpha=0.5, lw=1)
ax[0].plot(np.zeros(NZ), si, "o", color="#c05621", label="independent")
ax[0].plot(np.ones(NZ), sp, "o", color="#2b6cb0", label="pooled")
ax[0].set_xticks([0,1]); ax[0].set_xticklabels(["independent","pooled"]); ax[0].set_xlim(-0.3,1.3)
ax[0].set_ylabel("temperature swing (fraction of zone mean)")
ax[0].set_title("Shrinkage of the temperature response"); ax[0].legend(fontsize=8)
ax[1].scatter(si, sp, color="#2b6cb0"); lim=[0, max(si.max(),sp.max())*1.1]
ax[1].plot(lim, lim, "k--", lw=0.8, label="no shrinkage (y=x)")
ax[1].axhline(grid, color="grey", ls=":", lw=1); ax[1].axvline(grid, color="grey", ls=":", lw=1)
ax[1].set_xlabel("independent swing"); ax[1].set_ylabel("pooled swing")
ax[1].set_title("Pooled vs independent"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()
print(f"median |pooled - independent| swing = {np.median(np.abs(sp-si)):.3f} (grid consensus {grid:.3f})")
print("The pooled points sit almost on the y=x line: on data-rich zones the shrinkage is tiny — the data")
print("speak loudly enough that the prior barely moves them. Pooling only bites when a zone is data-poor.")
median |pooled - independent| swing = 0.008 (grid consensus 3.154) The pooled points sit almost on the y=x line: on data-rich zones the shrinkage is tiny — the data speak loudly enough that the prior barely moves them. Pooling only bites when a zone is data-poor.
# --- 4.3 Where pooling DOES rescue a zone: thin COVERAGE, not thin quantity --------------------------
# Realistic failure: a zone whose history happens to contain only COOL weather (a new feeder commissioned
# in winter, a sensor added last autumn). Fitting its temperature response on cool data alone forces a
# cubic to EXTRAPOLATE into summer heat it has never seen — dangerous, because that is exactly when the
# grid is most stressed. Pooling supplies the grid's cooling response instead.
zt = "zone.16"; k = ZONES.index(zt); st = zstation[zt]
Tz = temp[st].values
def design(rows):
Tc = (Tz[rows] - TREF)/10.0
return np.concatenate([np.ones((len(rows),1)), rich_cal(rows, Tc),
np.stack([Tc, Tc**2, Tc**3], axis=1)], axis=1)
cool_rows = np.where(trmask & (Tz < 60))[0] # only cool-weather history
coef_cool = np.linalg.lstsq(design(cool_rows), load[zt].values[cool_rows]/ztrmean[zt], rcond=None)[0]
b_cool = coef_cool[-3:] # temp response learned on cool data only
b_grid = R["mu_b"].mean(0) # grid consensus response (what pooling gives)
b_true = R["indep_b"][k] # full-season independent fit = "truth"
def fT(b): f = b[0]*Tcg + b[1]*Tcg**2 + b[2]*Tcg**3; return f - f[np.argmin(np.abs(Tgrid-TREF))]
fig, ax = plt.subplots(1, 2, figsize=(12.5, 4.3))
ax[0].axvspan(60, 100, color="#fed7d7", alpha=0.5, label="hot range (never seen in training)")
ax[0].plot(Tgrid, fT(b_true), color="#1a202c", lw=2.2, label="true response (all seasons)")
ax[0].plot(Tgrid, fT(b_cool), color="#c05621", lw=2, ls="--", label="independent (cool-only) — extrapolates")
ax[0].plot(Tgrid, fT(b_grid), color="#2b6cb0", lw=2, label="pooled (grid consensus)")
ax[0].set_xlabel("temperature (°F)"); ax[0].set_ylabel("temperature effect (fraction of mean)")
ax[0].set_title(f"{zt}: temperature response fit on cool data only"); ax[0].legend(fontsize=8)
# hot-day test accuracy: keep the (cool-fit) calendar, swap only the temperature response
hot = np.where((~trmask) & (Tz > 82))[0]
Xh = design(hot); cal_h = Xh[:, :-3] @ coef_cool[:-3]; act_h = load[zt].values[hot]
w_cool = wape(act_h, (cal_h + Xh[:,-3:] @ b_cool) * ztrmean[zt])
w_grid = wape(act_h, (cal_h + Xh[:,-3:] @ b_grid) * ztrmean[zt])
ax[1].bar(["independent\n(cool-only)","pooled\n(grid response)"], [w_cool, w_grid],
color=["#c05621","#2b6cb0"])
ax[1].set_ylabel("hot-day test WAPE (%)"); ax[1].set_title(f"{zt}: forecast error on hot days (>82°F)")
for i,v in enumerate([w_cool,w_grid]): ax[1].text(i, v, f"{v:.0f}%", ha="center", va="bottom")
plt.tight_layout(); plt.show()
print(f"{zt} hot-day WAPE — independent cool-only fit: {w_cool:.1f}% with pooled grid response: {w_grid:.1f}%")
print("A zone that has only ever seen cool weather cannot know its own cooling response; its independent")
print("cubic extrapolates wildly into the heat. Pooling hands it the grid's response — the difference")
print("between a safe summer forecast and a dangerous under-estimate exactly when reserves matter most.")
zone.16 hot-day WAPE — independent cool-only fit: 35.8% with pooled grid response: 19.6% A zone that has only ever seen cool weather cannot know its own cooling response; its independent cubic extrapolates wildly into the heat. Pooling hands it the grid's response — the difference between a safe summer forecast and a dangerous under-estimate exactly when reserves matter most.
# --- 4.4 The real deliverable: a calibrated predictive distribution per zone -------------------------
# The optimizer (§7-8) needs not a point forecast but a DISTRIBUTION. Draw from the posterior predictive
# (parameter draws + observation noise) and check the 90% interval's coverage on the 2008 test set.
rng = np.random.default_rng(0)
def predictive_draws(z, rows):
zk = ZONES.index(z); Tc = (temp[zstation[z]].values[rows]-TREF)/10.0
C = rich_cal(rows, Tc); cub = np.stack([Tc,Tc**2,Tc**3], axis=1)
mu = post["alpha"][:,zk,None] + post["gamma"][:,zk,:] @ C.T + post["b"][:,zk,:] @ cub.T # (draws,n)
eps = rng.normal(size=mu.shape) * post["sigma"][:,zk,None]
return (mu + eps) * ztrmean[z]
cov = {}
for z in ZONES:
d = predictive_draws(z, test_rows)
lo, hi = np.percentile(d, 5, 0), np.percentile(d, 95, 0)
cov[z] = 100*np.mean((load[z].values[test_rows] >= lo) & (load[z].values[test_rows] <= hi))
cov = pd.Series(cov)
# show one representative zone's forecast + 90% band over a test week
z = "zone.11"; d = predictive_draws(z, test_rows)
med = np.percentile(d,50,0)/1000; lo = np.percentile(d,5,0)/1000; hi = np.percentile(d,95,0)/1000
ts = pd.Series(load[z].values[test_rows]/1000, index=load.index[test_rows])
mS, lS, hS = (pd.Series(a, index=load.index[test_rows]) for a in (med, lo, hi))
wk = slice("2008-02-11","2008-02-17")
fig, ax = plt.subplots(figsize=(12, 3.8))
ax.fill_between(mS.loc[wk].index, lS.loc[wk], hS.loc[wk], color="#2b6cb0", alpha=0.2, label="90% predictive")
ax.plot(ts.loc[wk].index, ts.loc[wk].values, color="#1a202c", lw=1.6, label="actual")
ax.plot(mS.loc[wk].index, mS.loc[wk].values, color="#2b6cb0", lw=1.3, label="posterior median")
ax.set_title(f"{z}: hierarchical posterior-predictive forecast (test week)"); ax.set_ylabel("load (GW)")
ax.legend(fontsize=8, ncol=3); ax.xaxis.set_major_locator(mdates.DayLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%a %d")); plt.tight_layout(); plt.show()
good = cov.drop(["zone.10","zone.9"]) # exclude the break + the data-quality zone
print(f"90% predictive-interval coverage — median across zones {cov.median():.1f}% "
f"(excluding z10/z9: {good.median():.1f}%; target 90%)")
print("Unlike the single-zone bands in §3, this is one coherent generative model of the whole grid: its")
print("draws are the raw material for §6's demand scenarios and the §7-8 optimizer.")
90% predictive-interval coverage — median across zones 88.6% (excluding z10/z9: 88.6%; target 90%) Unlike the single-zone bands in §3, this is one coherent generative model of the whole grid: its draws are the raw material for §6's demand scenarios and the §7-8 optimizer.
What §4 establishes¶
- Partial pooling matches — it does not beat — independent per-zone fits on this data (median WAPE ≈ 10% either way). That is the honest and expected result: with four years of hourly data per zone, every zone can estimate its own response, so there is nothing for shrinkage to borrow. Pooling is insurance you're glad to carry and glad not to need.
- The shrinkage is correspondingly tiny (4.2) — the pooled temperature responses sit almost exactly on the independent estimates. The model shrinks only as much as the data warrant; here, barely at all.
- Where pooling genuinely rescues a zone is thin coverage, not thin quantity (4.3): a zone whose history contains only cool weather has no basis for a cooling response, and its independent cubic extrapolates dangerously into summer — precisely when the grid is most stressed. Pooling supplies the grid consensus and restores a safe hot-day forecast.
- The deliverable is a calibrated joint predictive distribution (4.4), not a point forecast — one coherent probabilistic model of the whole grid, ready to generate the demand scenarios the optimizer consumes.
This reframes the value of "going Bayesian" here honestly: on a data-rich forecasting problem the point accuracy is a flat maximum (§3) that pooling cannot bend, but the probabilistic, pooled model buys robustness for data-poor corners and, above all, the uncertainty quantification that turns a forecast into a decision — which is the entire subject of §5–§8. Next, §5 makes the zone forecasts cohere with the system total (reconciliation), and §6 turns this posterior into next-day demand scenarios.
5 · Reconciliation — making the zone forecasts cohere with the system¶
A grid is a hierarchy: the system operator cares about the total demand its generators must serve, but that total is built from zone-level demand. A basic consistency ought to hold — the zone forecasts should add up to the system forecast. When forecasts are produced independently at each level (or each zone), they generally do not add up; making them consistent is forecast reconciliation (Hyndman & Athanasopoulos; Wickramasuriya et al. 2019).
The standard toolkit, for base forecasts $\hat y$ across all series with a summing matrix $S$ that encodes "which components add to which aggregate":
- Bottom-up (BU) — forecast the zones, sum them to the total. Simple, but any zone bias flows up.
- Top-down (TD) — forecast the total, split it to zones by historical shares. Stable total, but loses zone-specific signal.
- Optimal / MinT — find the coherent forecasts closest to the base set, weighting by the base forecast-error covariance; it generalizes BU and TD and is minimum-variance among coherent options.
The honest GEFCom wrinkle (from §1). Textbook reconciliation assumes the components genuinely sum to the aggregate. Here they don't: zones 2/3/6/7 are redundant (scaled copies of one feed), so the naive sum of the 20 zones over-counts the metered system by ~1.6×. That makes this a perfect, real stress-test: naive bottom-up will fail loudly, and the fix is to let the data tell us the true aggregation — exactly the spirit of optimal reconciliation.
# --- 5.1 Incoherence: the naive sum of zone forecasts vs the metered system -------------------------
# zone point forecasts on the test set (posterior means from the §4 model)
def zone_point(z, rows):
zk = ZONES.index(z); Tc = (temp[zstation[z]].values[rows]-TREF)/10.0
C = rich_cal(rows, Tc); cub = np.stack([Tc,Tc**2,Tc**3], axis=1)
mu = post["alpha"][:,zk,None] + post["gamma"][:,zk,:] @ C.T + post["b"][:,zk,:] @ cub.T
return mu.mean(0) * ztrmean[z] # (n,) MW
F = np.column_stack([zone_point(z, test_rows) for z in ZONES]) # (n_test, 20) zone forecasts
sys_actual = load[SYSTEM].values[test_rows] # metered system total (MW)
bu_naive = F.sum(axis=1) # naive bottom-up
sysG = pd.Series(sys_actual/1000, index=load.index[test_rows])
buG = pd.Series(bu_naive/1000, index=load.index[test_rows])
wk = slice("2008-02-11","2008-02-17")
fig, ax = plt.subplots(figsize=(12, 3.8))
ax.plot(sysG.loc[wk].index, sysG.loc[wk].values, color="#1a202c", lw=1.8, label="metered system (zone.21)")
ax.plot(buG.loc[wk].index, buG.loc[wk].values, color="#c05621", lw=1.6, ls="--",
label="naive sum of 20 zone forecasts")
ax.set_title("Incoherence: summing the zones over-counts the true system"); ax.set_ylabel("load (GW)")
ax.legend(fontsize=9); ax.xaxis.set_major_locator(mdates.DayLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%a %d")); plt.tight_layout(); plt.show()
print(f"naive bottom-up / metered system (mean ratio): {bu_naive.mean()/sys_actual.mean():.2f}x")
print(f"naive bottom-up system WAPE: {wape(sys_actual, bu_naive):.1f}% <- unusable, exactly the §1 redundancy")
naive bottom-up / metered system (mean ratio): 1.49x naive bottom-up system WAPE: 49.0% <- unusable, exactly the §1 redundancy
# --- 5.2 Reconciliation: let the data supply the true aggregation, and compare to top-down ----------
from scipy.optimize import nnls
# (a) OPTIMAL-style: learn nonnegative weights w so that sum_z w_z * zone_z ~ metered system, on TRAIN.
# Redundant zones share/lose weight automatically -> this is the data's real summing vector.
tr_rows = np.where(trmask)[0]
Ztr = np.column_stack([load[z].values[tr_rows] for z in ZONES])
w, _ = nnls(Ztr, load[SYSTEM].values[tr_rows])
sys_reco = F @ w # coherent system forecast
# (b) TOP-DOWN: trust the directly-metered system forecast (§2 benchmark), split to zones by shares
sys_direct = (sysfit["yhat"].values)[~np.asarray(load.index < TRAIN_END)] # §2 system benchmark on test
res = pd.Series({
"naive bottom-up": wape(sys_actual, bu_naive),
"reconciled (learned wgts)": wape(sys_actual, sys_reco),
"top-down (metered system)": wape(sys_actual, sys_direct),
})
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
res.plot.barh(ax=ax[0], color=["#c53030","#2b6cb0","#2f855a"])
ax[0].set_xlabel("system test WAPE (%)"); ax[0].set_title("System-total forecast accuracy by method")
for i,v in enumerate(res.values): ax[0].text(v, i, f" {v:.1f}%", va="center")
ax[1].bar([z.replace("zone.","z") for z in ZONES], w, color="#2b6cb0")
ax[1].set_ylabel("learned aggregation weight"); ax[1].set_title("Reconciliation weights (redundant zones downweighted)")
ax[1].tick_params(axis="x", labelsize=7)
plt.tight_layout(); plt.show()
print(res.round(2).to_string())
print(f"\nRedundant zones z2/z3/z6/z7 learned weights: "
f"{w[ZONES.index('zone.2')]:.2f}/{w[ZONES.index('zone.3')]:.2f}/"
f"{w[ZONES.index('zone.6')]:.2f}/{w[ZONES.index('zone.7')]:.2f} (naive would force all = 1.0)")
naive bottom-up 49.03 reconciled (learned wgts) 9.69 top-down (metered system) 7.97 Redundant zones z2/z3/z6/z7 learned weights: 0.00/0.00/0.03/0.00 (naive would force all = 1.0)
What §5 establishes¶
- Naive bottom-up is unusable here — summing the 20 zone forecasts over-counts the metered system by ~1.6× (the §1 redundancy), a system-level error far larger than any zone's own error.
- Reconciliation fixes it by learning the true aggregation. Letting the data choose nonnegative weights (a data-driven stand-in for optimal/MinT reconciliation) recovers a coherent system forecast at the same ~8% accuracy as forecasting the metered total directly — and the learned weights automatically downweight the redundant zones that the naive all-ones sum double-counts.
- Practically, with a reliably metered system total, the top-down anchor is the pragmatic choice here, and reconciliation formalizes why: trust the aggregate you measure well, and don't let redundant components inflate it.
For the optimizer in §7–§8 the decision-relevant quantity is the system demand the fleet must serve. So we carry forward a coherent, ~8%-WAPE system forecast — and, crucially, its distribution. §6 turns the §4 posterior into next-day demand scenarios at the system level, the direct input to unit commitment.
6 · From posterior to demand scenarios¶
The optimizer does not want a single number for tomorrow's demand — it wants a set of plausible days it must be ready for. A grid operator commits generation the day before, then must serve whatever demand actually materializes; if committed capacity falls short, the recourse is expensive (fast peaker starts) or, worst case, load shedding (rolling blackouts). Sizing reserve correctly is therefore a question about the upper tail of demand, which only a scenario ensemble can express.
We build the ensemble for a single commitment day — the highest-demand day in the test set, the one where getting reserve right matters most — by sampling the three real sources of day-ahead uncertainty:
- Weather-forecast error — we do not know tomorrow's temperature exactly. This is the dominant source, so each scenario perturbs the day's temperature by a correlated (AR(1)) error path.
- Model / parameter uncertainty — each scenario draws a parameter set from the §4 posterior.
- Observation noise — irreducible hour-to-hour variation, added at the system level.
Each scenario is then reconciled to the system total with the §5 weights, giving a 24-hour system demand profile. The spread of these profiles — especially the distribution of the daily peak — is exactly what the optimizer will price as reserve.
# --- 6.1 Generate system demand scenarios for the peak commitment day -------------------------------
sys_ts = pd.Series(sys_actual, index=load.index[test_rows])
DAY = sys_ts.groupby(sys_ts.index.normalize()).max().idxmax().normalize() # highest-peak test day
day_rows = np.where((load.index.normalize() == DAY) & (~trmask))[0] # its 24 hours
H = len(day_rows)
# system observation-noise scale (from the §2 system benchmark residuals on train), in MW
sys_tr_pred = sysfit["yhat"].values[np.asarray(load.index < TRAIN_END)]
sys_sigma = float(np.std(load[SYSTEM].values[np.where(trmask)[0]] - sys_tr_pred))
def gen_scenarios(day_rows, S, temp_sd=3.0, phi=0.8, seed=1):
"S system-demand scenarios (MW), each 24h: posterior draw + AR(1) weather error + obs noise, reconciled."
rng = np.random.default_rng(seed); ndraw = post["alpha"].shape[0]; H = len(day_rows)
out = np.zeros((S, H))
for s in range(S):
d = int(rng.integers(ndraw))
e = np.zeros(H); e[0] = rng.normal()*temp_sd # common weather perturbation
for h in range(1, H): e[h] = phi*e[h-1] + rng.normal()*temp_sd*np.sqrt(1-phi**2)
loads = np.zeros(H)
for zk, z in enumerate(ZONES):
Tc = (temp[zstation[z]].values[day_rows] + e - TREF) / 10.0
C = rich_cal(day_rows, Tc); cub = np.stack([Tc, Tc**2, Tc**3], axis=1)
mu = post["alpha"][d,zk] + C @ post["gamma"][d,zk,:] + cub @ post["b"][d,zk,:]
loads += w[zk] * mu * ztrmean[z] # §5 reconciliation weights
out[s] = loads + rng.normal(size=H)*sys_sigma
return out
fan = gen_scenarios(day_rows, S=200) # many, for a smooth fan
day_actual = sys_actual[np.isin(test_rows, day_rows)]
hours = np.arange(H)
fig, ax = plt.subplots(figsize=(11, 4.2))
ax.plot(hours, fan.T/1000, color="#2b6cb0", alpha=0.06)
ax.plot(hours, np.percentile(fan,50,0)/1000, color="#2b6cb0", lw=2, label="scenario median")
ax.fill_between(hours, np.percentile(fan,5,0)/1000, np.percentile(fan,95,0)/1000,
color="#2b6cb0", alpha=0.15, label="scenario 5–95%")
ax.plot(hours, day_actual/1000, color="#1a202c", lw=2.2, ls="--", label="actual demand")
ax.set_xlabel("hour of day"); ax.set_ylabel("system demand (GW)")
ax.set_title(f"Next-day system demand scenarios — commitment day {str(DAY.date())}")
ax.legend(fontsize=9); plt.tight_layout(); plt.show()
print(f"commitment day {str(DAY.date())}: actual peak {day_actual.max()/1000:.1f} GW at hour {int(day_actual.argmax())}")
print(f"scenario peak distribution — P50 {np.percentile(fan.max(1),50)/1000:.1f} GW, "
f"P95 {np.percentile(fan.max(1),95)/1000:.1f} GW, P99 {np.percentile(fan.max(1),99)/1000:.1f} GW")
print("The gap between the median peak and the P95/P99 peak is precisely the reserve the optimizer must")
print("weigh: cheap to skip if demand stays near the median, very expensive if a cold snap pushes the tail.")
commitment day 2008-01-21: actual peak 2151.4 GW at hour 7 scenario peak distribution — P50 1992.7 GW, P95 2204.1 GW, P99 2297.0 GW The gap between the median peak and the P95/P99 peak is precisely the reserve the optimizer must weigh: cheap to skip if demand stays near the median, very expensive if a cold snap pushes the tail.
# --- 6.2 Bayesian vs bootstrap scenarios, and the reduced set for the optimizer ---------------------
# A common non-Bayesian alternative (cf. the staffing notebook): resample the model's historical hourly
# residuals and add them to the point forecast. It captures noise but not parameter/структured weather risk.
resid_hourly = (load[SYSTEM].values[np.where(trmask)[0]] - sys_tr_pred)
rng = np.random.default_rng(2)
point_day = np.percentile(fan, 50, 0) # use scenario median as the point forecast
boot = point_day[None,:] + rng.choice(resid_hourly, size=(200, H), replace=True)
fig, ax = plt.subplots(1, 2, figsize=(13, 4), sharey=True)
for a,(dat,lab,col) in zip(ax, [(fan,"Bayesian (weather+posterior+noise)","#2b6cb0"),
(boot,"Bootstrap residuals","#c05621")]):
a.fill_between(hours, np.percentile(dat,5,0)/1000, np.percentile(dat,95,0)/1000, color=col, alpha=0.15)
a.plot(hours, dat[:60].T/1000, color=col, alpha=0.08)
a.plot(hours, day_actual/1000, color="#1a202c", lw=2, ls="--")
a.set_title(lab); a.set_xlabel("hour"); a.set_ylabel("system demand (GW)")
plt.tight_layout(); plt.show()
# reduce to a small equal-probability scenario set for the two-stage program (§8)
S_OPT = 30
idx = np.linspace(0, fan.shape[0]-1, S_OPT).astype(int)
scen_opt = fan[np.argsort(fan.max(1))][idx] # spread across the peak distribution
scen_prob = np.full(S_OPT, 1.0/S_OPT)
print(f"Bayesian peak P5–P95 spread: {(np.percentile(fan.max(1),95)-np.percentile(fan.max(1),5))/1000:.0f} GW (smooth, weather-correlated)")
print(f"Bootstrap peak P5–P95 spread: {(np.percentile(boot.max(1),95)-np.percentile(boot.max(1),5))/1000:.0f} GW (jagged, iid hourly noise)")
print(f"reduced to {S_OPT} equal-probability scenarios (spanning the peak distribution) for the §8 optimizer.")
print("The ensembles differ in STRUCTURE, not merely width. The bootstrap resamples residuals")
print("independently each hour, so its days are unrealistically jagged and its 24-hour MAX is inflated by")
print("noise spikes — a wider but wrong-shaped peak. The Bayesian ensemble imposes correlated weather")
print("(AR1), giving smooth, realistic day profiles. That correlation is what the optimizer needs to plan")
print("ramping and sustained peaks — a demand tail driven by a cold snap, not by 24 independent coin flips.")
Bayesian peak P5–P95 spread: 368 GW (smooth, weather-correlated) Bootstrap peak P5–P95 spread: 699 GW (jagged, iid hourly noise) reduced to 30 equal-probability scenarios (spanning the peak distribution) for the §8 optimizer. The ensembles differ in STRUCTURE, not merely width. The bootstrap resamples residuals independently each hour, so its days are unrealistically jagged and its 24-hour MAX is inflated by noise spikes — a wider but wrong-shaped peak. The Bayesian ensemble imposes correlated weather (AR1), giving smooth, realistic day profiles. That correlation is what the optimizer needs to plan ramping and sustained peaks — a demand tail driven by a cold snap, not by 24 independent coin flips.
What §6 establishes¶
- The §4 posterior becomes an ensemble of next-day system-demand profiles by layering the three real day-ahead uncertainties — dominated by weather-forecast error — and reconciling to the system total.
- The decision-relevant object is the distribution of the daily peak: the distance from its median to its P95/P99 is the reserve the operator must consider — cheap insurance if demand stays near the median, essential if the tail materializes.
- A bootstrap ensemble (point forecast + resampled residuals) is a legitimate, simpler alternative, but it differs in structure: resampling residuals independently each hour yields jagged, temporally-incoherent days whose 24-hour maximum is inflated by noise spikes — a wider peak spread for the wrong reason. The Bayesian ensemble instead encodes correlated weather risk, giving smooth, realistic day shapes. What the optimizer needs is the right correlation structure (sustained peaks and ramps), not just a spread — the energy analogue of the "value of modeling uncertainty properly" theme from the inventory and staffing notebooks.
We carry a reduced set of 30 equal-probability scenarios into the optimizer. §7 first solves the deterministic unit-commitment problem against a single (mean) forecast — introducing the generator fleet, the costs, and the constraints — and §8 then solves the stochastic version against this scenario set, where the monetary value of respecting uncertainty (VSS, EVPI, avoided load-shedding) finally appears.
7 · Unit commitment — the deterministic optimizer¶
Now the forecast becomes a decision. Each day a grid operator must choose, hours ahead, which power plants to switch on and, once on, how much each should produce, to meet demand every hour at least cost. This is the unit-commitment (UC) problem — a mixed-integer program because the on/off choices are binary, wrapped around a continuous economic-dispatch problem. It is solved every day by every system operator on Earth, and it is the exact same predict-then-optimize shape as the newsvendor (inventory notebook) and the staffing two-stage program — only the physics differ.
The fleet. Real generator data for this anonymized utility does not exist, so we use the canonical
IEEE 10-unit test system (Kazarlis, Bakirtzis & Petridis, 1996) — the standard UC benchmark — and scale
the GEFCom demand shape to the fleet's ~1,662 MW capacity. Each unit has a minimum and maximum output,
a fuel cost (a no-load $/h plus a marginal $/MWh), a start-up cost, and minimum up/down times
(a plant that starts must run a while; once stopped it must stay off a while) — the constraints that make
UC hard and interesting.
Costs and the price of failure. The objective sums no-load, fuel, and start-up costs. If committed capacity cannot meet demand, the shortfall is load shedding (a blackout), priced at the value of lost load (VOLL ≈ $10,000/MWh) — orders of magnitude above fuel cost. We impose no artificial reserve margin: the VOLL penalty itself makes the optimizer decide how much spare capacity to commit. That is the lever §8 will pull — the deterministic model here commits only for its single point forecast, and we will see what that costs when demand doesn't cooperate.
import pulp
# --- IEEE 10-unit test system (Kazarlis et al. 1996) -------------------------------------------------
FLEET = pd.DataFrame({
"Pmax": [455,455,130,130,162, 80, 85, 55, 55, 55],
"Pmin": [150,150, 20, 20, 25, 20, 25, 10, 10, 10],
"a": [1000,970,700,680,450,370,480,660,665,670], # no-load $/h (fixed cost when ON)
"b": [16.19,17.26,16.60,16.50,19.70,22.26,27.74,25.92,27.27,27.79], # marginal $/MWh
"start": [4500,5000,550,560,900,170,260, 30, 30, 30], # start-up $ (hot)
"min_up":[8,8,5,5,6,3,3,1,1,1],
"min_dn":[8,8,5,5,6,3,3,1,1,1],
"init": [8,8,-5,-5,-6,-3,-3,-1,-1,-1], # +on / -off hours before the day starts
})
Pmax,Pmin,a,b,start = (FLEET[c].values for c in ["Pmax","Pmin","a","b","start"])
min_up,min_dn,init = FLEET["min_up"].values, FLEET["min_dn"].values, FLEET["init"].values
NU, CAP, VOLL = len(FLEET), int(FLEET["Pmax"].sum()), 10_000.0
# --- scale demand + scenarios to the fleet: worst scenario peak just under the 1662 MW capacity, so any
# shedding is a CHOICE (too little committed), not an unavoidable capacity shortfall ----------------
scale = 1600.0 / scen_opt.max()
d_point = np.percentile(fan, 50, 0) * scale # median scenario = point forecast (MW)
scen_MW = scen_opt * scale # (30, 24) scenarios in MW
d_act = day_actual * scale # realized demand, scaled
Tn = len(d_point)
print(f"fleet: {NU} units, total capacity {CAP} MW | commitment-day point peak {d_point.max():.0f} MW")
print(f"scenario peaks — P50 {np.percentile(scen_MW.max(1),50):.0f} P95 {np.percentile(scen_MW.max(1),95):.0f}"
f" max {scen_MW.max(1).max():.0f} MW (capacity {CAP})")
fleet: 10 units, total capacity 1662 MW | commitment-day point peak 1252 MW scenario peaks — P50 1319 P95 1459 max 1600 MW (capacity 1662)
# --- unified two-stage UC solver (deterministic = 1 scenario; stochastic = many; EEV = fixed commitment)
def solve_uc(dem, probs=None, fix_u=None, reserve_frac=0.0, msg=0):
dem = np.atleast_2d(dem); S, T = dem.shape
probs = np.full(S, 1.0/S) if probs is None else np.asarray(probs)
m = pulp.LpProblem("UC", pulp.LpMinimize)
Ig, Tg, Sg = range(NU), range(T), range(S)
p = {(i,t,s): pulp.LpVariable(f"p_{i}_{t}_{s}", lowBound=0) for i in Ig for t in Tg for s in Sg}
shed = {(t,s): pulp.LpVariable(f"sh_{t}_{s}", lowBound=0) for t in Tg for s in Sg}
if fix_u is None: # first-stage commitment is decided here
u = {(i,t): pulp.LpVariable(f"u_{i}_{t}", cat="Binary") for i in Ig for t in Tg}
su = {(i,t): pulp.LpVariable(f"su_{i}_{t}", cat="Binary") for i in Ig for t in Tg}
sd = {(i,t): pulp.LpVariable(f"sd_{i}_{t}", cat="Binary") for i in Ig for t in Tg}
commit = pulp.lpSum(a[i]*u[(i,t)] for i in Ig for t in Tg)
startc = pulp.lpSum(start[i]*su[(i,t)] for i in Ig for t in Tg)
else: # commitment fixed (to evaluate a given schedule)
u = {(i,t): int(fix_u[i,t]) for i in Ig for t in Tg}
su = {}
for i in Ig:
for t in Tg:
uprev = fix_u[i,t-1] if t>0 else (1 if init[i]>0 else 0)
su[(i,t)] = int(fix_u[i,t]==1 and uprev==0)
commit = sum(a[i]*u[(i,t)] for i in Ig for t in Tg)
startc = sum(start[i]*su[(i,t)] for i in Ig for t in Tg)
op = pulp.lpSum(probs[s]*b[i]*p[(i,t,s)] for i in Ig for t in Tg for s in Sg)
shc = pulp.lpSum(probs[s]*VOLL*shed[(t,s)] for t in Tg for s in Sg)
m += commit + startc + op + shc
for s in Sg:
for t in Tg:
m += pulp.lpSum(p[(i,t,s)] for i in Ig) + shed[(t,s)] == dem[s,t] # supply = demand (+shed)
if reserve_frac > 0 and fix_u is None: # optional reserve margin
m += pulp.lpSum(Pmax[i]*u[(i,t)] for i in Ig) >= (1+reserve_frac)*dem[s,t]
for i in Ig:
m += p[(i,t,s)] >= Pmin[i]*u[(i,t)]
m += p[(i,t,s)] <= Pmax[i]*u[(i,t)]
if fix_u is None: # commitment logic + min up/down (first stage)
for i in Ig:
for t in Tg:
uprev = u[(i,t-1)] if t>0 else (1 if init[i]>0 else 0)
m += u[(i,t)] - uprev == su[(i,t)] - sd[(i,t)]
m += su[(i,t)] + sd[(i,t)] <= 1
m += pulp.lpSum(su[(i,k)] for k in range(max(0,t-min_up[i]+1), t+1)) <= u[(i,t)]
m += pulp.lpSum(sd[(i,k)] for k in range(max(0,t-min_dn[i]+1), t+1)) <= 1 - u[(i,t)]
m.solve(pulp.PULP_CBC_CMD(msg=msg))
val = lambda x: pulp.value(x) if isinstance(x, (pulp.LpVariable, pulp.LpAffineExpression)) else float(x)
U = np.array([[int(round(val(u[(i,t)]))) for t in Tg] for i in Ig])
P = np.array([[[pulp.value(p[(i,t,s)]) for t in Tg] for i in Ig] for s in Sg])
SH = np.array([[pulp.value(shed[(t,s)]) for t in Tg] for s in Sg])
return dict(status=pulp.LpStatus[m.status], cost=pulp.value(m.objective),
commit=val(commit), start=val(startc), op=pulp.value(op), shed_cost=pulp.value(shc),
U=U, P=P, shed=SH, shed_MWh=float((SH*probs[:,None]).sum()))
det = solve_uc(d_point) # deterministic UC against the point forecast
print(f"status {det['status']} | total ${det['cost']:,.0f} "
f"(no-load ${det['commit']:,.0f} + start ${det['start']:,.0f} + fuel ${det['op']:,.0f})")
print(f"units committed at the peak hour: {int(det['U'][:, d_point.argmax()].sum())} of {NU} | "
f"load shed: {det['shed_MWh']:.0f} MWh")
status Optimal | total $478,620 (no-load $63,290 + start $1,490 + fuel $413,840) units committed at the peak hour: 5 of 10 | load shed: 0 MWh
# --- visualize the deterministic solution: dispatch stack + commitment schedule ----------------------
order = np.argsort(b) # merit order (cheapest marginal first)
P0 = det["P"][0] # (units, hours)
fig, ax = plt.subplots(1, 2, figsize=(13, 4.2))
base = np.zeros(Tn); cols = plt.cm.viridis(np.linspace(0,1,NU))
for rank,i in enumerate(order):
ax[0].fill_between(range(Tn), base, base+P0[i], color=cols[rank], label=f"U{i+1} (${b[i]:.0f}/MWh)")
base += P0[i]
ax[0].plot(range(Tn), d_point, "k--", lw=2, label="demand")
ax[0].set_xlabel("hour"); ax[0].set_ylabel("MW"); ax[0].set_title("Economic dispatch (merit order)")
ax[0].legend(fontsize=6, ncol=2, loc="upper left")
im = ax[1].imshow(det["U"], aspect="auto", cmap="Greens", vmin=0, vmax=1)
ax[1].set_yticks(range(NU)); ax[1].set_yticklabels([f"U{i+1}" for i in range(NU)], fontsize=7)
ax[1].set_xlabel("hour"); ax[1].set_title("Commitment schedule (green = ON)")
plt.tight_layout(); plt.show()
print("Baseload units (U1,U2 — cheap $16-17/MWh, but 8h min up/down) run flat all day; mid-merit units")
print("follow the load; expensive peakers (U7-U10) start only for the peak. This is the least-cost way to")
print("serve the POINT forecast — but it is committed for exactly that one demand path. §8 asks what")
print("happens when demand is uncertain, and what it's worth to commit for the distribution instead.")
Baseload units (U1,U2 — cheap $16-17/MWh, but 8h min up/down) run flat all day; mid-merit units follow the load; expensive peakers (U7-U10) start only for the peak. This is the least-cost way to serve the POINT forecast — but it is committed for exactly that one demand path. §8 asks what happens when demand is uncertain, and what it's worth to commit for the distribution instead.
What §7 establishes¶
- Unit commitment turns the demand forecast into an actual least-cost operating plan: which of the 10 units to run each hour (integer decisions with start-up costs and min up/down times) and how much each produces (continuous dispatch), solved as a MILP with CBC.
- The deterministic solution is optimal for its single point forecast — baseload runs flat, mid-merit units follow load, peakers cover the peak — and, against that one path, it sheds no load.
- But it holds only as much capacity as the point forecast needs. If the real day comes in hotter and higher, the committed fleet can be caught short, and the shortfall is priced at VOLL — a blackout.
That gap between "planned for one path" and "must survive the distribution" is the entire case for stochastic unit commitment, which §8 now solves against the §6 scenario set — and where the monetary value of the probabilistic forecast (VSS, EVPI, avoided load-shedding) finally shows up in dollars.
8 · Stochastic unit commitment — the value of the forecast, in dollars¶
The commitment decision is made before tomorrow's demand is known; the dispatch adjusts after. That is a textbook two-stage stochastic program, identical in structure to the staffing notebook:
- First stage (here-and-now): the commitment $u_{i,t}$ — which units are on — chosen once, before the weather reveals itself. Its no-load and start-up costs are paid regardless.
- Second stage (recourse): the dispatch $p_{i,t,s}$ and any load-shedding $\text{shed}_{t,s}$, which may differ in every scenario $s$ once demand is realized.
We minimize first-stage cost plus the expected second-stage cost over the 30 scenarios. Then we value the forecast with the two standard yardsticks:
- VSS (Value of the Stochastic Solution) = (cost of committing to a deterministic plan, evaluated across the real scenario distribution) − (cost of the stochastic plan). A real operator does not commit for the bare point forecast — they add a reserve margin (we use the industry rule of thumb, +15%) — so that reserve-rule plan is our honest deterministic benchmark (we also report the naive point-only plan as a cautionary extreme). VSS is the money saved by placing reserve where the scenarios say it is needed, rather than by a flat rule.
- EVPI (Expected Value of Perfect Information) = (stochastic cost) − (average cost if we knew each day's demand in advance). It is the price of the remaining irreducible uncertainty — what a perfect forecast would be worth.
# --- solve: stochastic (SP), the deterministic benchmarks, and wait-and-see (WS) ---------------------
u_reserve = solve_uc(d_point, reserve_frac=0.15)["U"] # realistic operator: point forecast + 15% reserve
SP = solve_uc(scen_MW, scen_prob) # optimize commitment for the whole distribution
EEV_res = solve_uc(scen_MW, scen_prob, fix_u=u_reserve) # deterministic + 15% reserve, evaluated on scenarios
EEV_pt = solve_uc(scen_MW, scen_prob, fix_u=det["U"]) # naive: point forecast only, evaluated on scenarios
WS = float(np.mean([solve_uc(scen_MW[s], [1.0])["cost"] for s in range(len(scen_MW))])) # perfect info
VSS = EEV_res["cost"] - SP["cost"] # value of scenario optimization OVER the reserve rule of thumb
EVPI = SP["cost"] - WS # value of a perfect forecast
print("Expected daily operating cost across the demand distribution:")
print(f" Deterministic, point forecast only : ${EEV_pt['cost']:>12,.0f} (shed {EEV_pt['shed_MWh']:>4.0f} MWh)")
print(f" Deterministic, point + 15% reserve rule : ${EEV_res['cost']:>12,.0f} (shed {EEV_res['shed_MWh']:>4.0f} MWh)")
print(f" Stochastic (optimize for scenarios) : ${SP['cost']:>12,.0f} (shed {SP['shed_MWh']:>4.0f} MWh)")
print(f" Perfect information (WS, lower bound) : ${WS:>12,.0f}")
print()
print(f" VSS = (reserve rule) - SP = ${VSS:,.0f} ({100*VSS/EEV_res['cost']:.1f}% of the reserve-rule cost)")
print(f" EVPI = SP - WS = ${EVPI:,.0f} ({100*EVPI/SP['cost']:.1f}% of the stochastic cost)")
print(f" units committed at peak — point-only {int(det['U'][:,d_point.argmax()].sum())}, "
f"reserve-rule {int(u_reserve[:,d_point.argmax()].sum())}, stochastic {int(SP['U'][:,d_point.argmax()].sum())} of {NU}")
print("\nEven a 15% reserve rule can be caught short by a correlated weather swing; the stochastic plan puts")
print("reserve exactly where the scenarios say it is needed. Committing for the bare point forecast is far")
print("worse still — it sheds load on every high-demand day, which is why operators never actually do it.")
Expected daily operating cost across the demand distribution: Deterministic, point forecast only : $ 5,722,002 (shed 525 MWh) Deterministic, point + 15% reserve rule : $ 873,890 (shed 38 MWh) Stochastic (optimize for scenarios) : $ 504,444 (shed 0 MWh) Perfect information (WS, lower bound) : $ 484,182 VSS = (reserve rule) - SP = $369,446 (42.3% of the reserve-rule cost) EVPI = SP - WS = $20,262 (4.0% of the stochastic cost) units committed at peak — point-only 5, reserve-rule 7, stochastic 9 of 10 Even a 15% reserve rule can be caught short by a correlated weather swing; the stochastic plan puts reserve exactly where the scenarios say it is needed. Committing for the bare point forecast is far worse still — it sheds load on every high-demand day, which is why operators never actually do it.
# --- visualize the monetary payoff -------------------------------------------------------------------
fig, ax = plt.subplots(1, 2, figsize=(13, 4.3))
bars = {"perfect\ninfo (WS)": WS, "stochastic\n(SP)": SP["cost"],
"det. +15%\nreserve": EEV_res["cost"], "det. point\nonly": EEV_pt["cost"]}
cols = ["#2f855a", "#2b6cb0", "#dd6b20", "#c53030"]
ax[0].bar(list(bars), list(bars.values()), color=cols)
for i,(k,v) in enumerate(bars.items()): ax[0].text(i, v, f"${v/1e3:,.0f}k", ha="center", va="bottom", fontsize=8)
ax[0].set_yscale("log") # the naive point-only plan is huge; log keeps all four legible
ax[0].set_ylabel("expected daily cost, $ (log)"); ax[0].set_title("Cost of each commitment plan under uncertainty")
# expected load shed by scenario peak: reserve-rule vs stochastic
peaks = scen_MW.max(1); o = np.argsort(peaks)
ax[1].plot(peaks[o], EEV_res["shed"].sum(1)[o], "o-", color="#dd6b20", label="det. + 15% reserve")
ax[1].plot(peaks[o], SP["shed"].sum(1)[o], "s-", color="#2b6cb0", label="stochastic")
ax[1].axvline(CAP, color="grey", ls=":", label=f"fleet capacity {CAP} MW")
ax[1].set_xlabel("scenario peak demand (MW)"); ax[1].set_ylabel("load shed (MWh)")
ax[1].set_title("Blackout risk: load shed vs how high demand comes in"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()
print(f"VSS = ${VSS:,.0f}/day is what optimizing against the scenario distribution saves over a 15% reserve")
print("rule — money that would otherwise be spent on recourse and shedding when a high-demand day lands.")
print("Right: even the reserve-rule plan sheds in the top scenarios; the stochastic plan keeps the lights")
print("on across the distribution by committing reserve where the scenarios actually place the risk.")
VSS = $369,446/day is what optimizing against the scenario distribution saves over a 15% reserve rule — money that would otherwise be spent on recourse and shedding when a high-demand day lands. Right: even the reserve-rule plan sheds in the top scenarios; the stochastic plan keeps the lights on across the distribution by committing reserve where the scenarios actually place the risk.
What §8 establishes — and the whole pipeline¶
- Framed as a two-stage stochastic program, unit commitment shows the concrete dollar value of a probabilistic forecast. The VSS is money saved every day by committing against the demand distribution rather than a single point forecast; the EVPI bounds what any further forecast improvement could be worth.
- The mechanism is exactly the operator's real trade-off: the deterministic plan looks cheaper but, when demand runs high, is caught short and sheds load (blackouts at VOLL); the stochastic plan commits a little more reserve up front and avoids the far larger recourse and shedding costs.
- This is the energy incarnation of the theme running through the whole Operations Research series — inventory (newsvendor), staffing (two-stage SP), and now the grid: the value of a forecast is not its accuracy but the quality of the decisions it enables, and a distribution enables better decisions than a point.
The full arc. We took real GEFCom2012 load (§1), built the field-standard feature model (§2), established that point accuracy is a flat maximum across classical and ML methods (§3), fit a hierarchical Bayesian model whose honest value is calibrated joint uncertainty and data-poor robustness rather than a point-accuracy win (§4), made the zone forecasts cohere with the system (§5), turned the posterior into next-day demand scenarios (§6), and fed them into a unit-commitment optimizer — deterministic (§7) then stochastic (§8) — where the probabilistic forecast finally pays for itself in dollars. Forecast → optimize → value: the same spine as every other notebook in this series, in the domain where it matters most.