CAISO Outage Model Granularity Comparison#
Authors: Claire Kim · Brian Nguyen ·
Created: 2026-08-21 ·
Tags: #Experiment #Outages #Validation
Daily, hourly, and 15-minute sampling of the CAISO outage model are compared. The daily market-zone NB2 is the baseline; the hourly version holds the mapping and market zones fixed and asks whether outage probability improves when timing is modeled directly.
from pathlib import Path
import sys
import warnings
import numpy as np
import pandas as pd
import polars as pl
from bokeh.io import output_notebook, show
from bokeh.models import ColumnDataSource, FactorRange, HoverTool, Span
from bokeh.plotting import figure
from bokeh.resources import INLINE
from statsmodels.tools.sm_exceptions import (
ConvergenceWarning,
HessianInversionWarning,
)
from bokeh.transform import dodge
from sklearn.metrics import roc_auc_score, r2_score
pd.set_option("display.max_columns", 160)
pd.set_option("display.width", 180)
warnings.filterwarnings("ignore", category=FutureWarning)
# The daily and county fits sit on the alpha = 0 dispersion boundary, so
# statsmodels reports a failed convergence check and an uninvertible Hessian
# while still returning the Poisson-limit coefficients. The tables below carry
# the fitted alpha and the held-out scores, which is where a failed fit shows.
warnings.filterwarnings("ignore", category=ConvergenceWarning)
warnings.filterwarnings("ignore", category=HessianInversionWarning)
ROOT = Path.cwd()
if ROOT.name == "notebooks":
ROOT = ROOT.parent
if str(ROOT / "src") not in sys.path:
sys.path.insert(0, str(ROOT / "src"))
from caiso_outage_nb_pipeline import (
MARKET_ZONE_FEATURES,
MARKET_ZONE_HOURLY_FEATURES,
build_caiso_market_zone_nb_panel,
build_caiso_market_zone_hourly_nb_panel,
fit_negative_binomial_model,
binary_model_performance_by_group,
likelihood_ratio_block_tests,
poisson_negative_binomial_comparison,
rolling_backtest_logistic_probability_model,
fit_logistic_probability_model,
rolling_backtest_negative_binomial_model,
model_performance_by_group,
interval_market_zone_outage_counts,
interval_market_zone_target_summary,
resource_mapping_coverage_summary,
)
from chart_theme import SERIES, html_table, style
DATA_DIR = ROOT / "data"
PROCESSED_DIR = DATA_DIR / "processed"
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
output_notebook(INLINE, hide_banner=True)
1. Baseline Mapping Layer#
The same conservative resource-to-plant-to-market-zone crosswalk is used across granularities, so the comparison isolates time sampling.
daily_panel, mapped_events, resource_crosswalk = build_caiso_market_zone_nb_panel(DATA_DIR)
coverage = resource_mapping_coverage_summary(
mapped_events,
mapped_events,
resource_crosswalk,
start_date=daily_panel["date"].min(),
end_date=daily_panel["date"].max(),
)
coverage_display = coverage[
[
"outage_type",
"events",
"matched_events",
"event_coverage",
"unique_resources",
"matched_unique_resources",
"resource_coverage",
"crosswalk_rows",
]
]
display(html_table(pl.from_pandas(coverage_display.round(3))))
print(f"Daily baseline rows: {len(daily_panel):,}")
print(f"Date window: {daily_panel['date'].min()} to {daily_panel['date'].max()}")
| outage_type | events | matched_events | event_coverage | unique_resources | matched_unique_resources | resource_coverage | crosswalk_rows |
|---|---|---|---|---|---|---|---|
| FORCED | 243,709 | 147,616 | 0.606 | 1,779 | 740 | 0.416 | 754 |
| PLANNED | 16,736 | 8,812 | 0.527 | 1,245 | 630 | 0.506 | 754 |
| ALL | 260,445 | 156,428 | 0.601 | 1,838 | 747 | 0.406 | 754 |
Daily baseline rows: 5,520
Date window: 2021-06-17 to 2026-06-30
Coverage is unchanged from the daily model. Unmatched resources remain sampling error and basis risk.
2. Target Sparsity Across Granularities#
Daily counts are dense, hourly counts sparse but workable, and 15-minute counts sparse enough to push the problem toward event ranking or a hurdle model rather than count prediction.
hourly_panel, _, _ = build_caiso_market_zone_hourly_nb_panel(DATA_DIR)
summary_rows = []
for label, panel in [("daily", daily_panel), ("hourly", hourly_panel)]:
grouped = panel.groupby("region")["Y"].agg(
rows="size",
mean="mean",
variance="var",
zero_share=lambda s: (s == 0).mean(),
outage_share=lambda s: (s > 0).mean(),
p90=lambda s: s.quantile(0.90),
p95=lambda s: s.quantile(0.95),
p99=lambda s: s.quantile(0.99),
max="max",
).reset_index()
grouped["frequency"] = label
grouped["variance_to_mean"] = grouped["variance"] / grouped["mean"].replace(0, np.nan)
summary_rows.append(grouped)
fifteen_summary = interval_market_zone_target_summary(
mapped_events,
start_date=daily_panel["date"].min(),
end_date=daily_panel["date"].max(),
freq="15min",
)
fifteen_summary["frequency"] = "15min"
granularity_summary = pd.concat(summary_rows + [fifteen_summary], ignore_index=True)
cols = ["frequency", "region", "rows", "mean", "variance", "variance_to_mean", "zero_share", "outage_share", "p90", "p95", "p99", "max"]
display(html_table(pl.from_pandas(granularity_summary[cols].round(4))))
hourly_start_hour_summary = (
hourly_panel.groupby("hour")["Y"]
.agg(
region_hour_rows="size",
forced_starts="sum",
mean_starts="mean",
zero_share=lambda s: (s == 0).mean(),
max_starts="max",
)
.reset_index()
)
hourly_start_hour_summary["start_share"] = (
hourly_start_hour_summary["forced_starts"] / hourly_start_hour_summary["forced_starts"].sum()
)
display(html_table(pl.from_pandas(hourly_start_hour_summary.round(4))))
| frequency | region | rows | mean | variance | variance_to_mean | zero_share | outage_share | p90 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|---|---|---|---|
| daily | NP15 | 1,840 | 24.62 | 33.94 | 1.38 | 0.000 | 1.000 | 32.00 | 35.00 | 41.00 | 52 |
| daily | SP15 | 1,840 | 29.75 | 84.13 | 2.83 | 0.000 | 1.000 | 42.00 | 46.00 | 52.00 | 65 |
| daily | ZP26 | 1,840 | 25.85 | 48.24 | 1.87 | 0.000 | 1.000 | 34.00 | 37.00 | 42.00 | 63 |
| hourly | NP15 | 44,160 | 1.03 | 10.49 | 10.22 | 0.680 | 0.320 | 2.00 | 3.00 | 18.00 | 35 |
| hourly | SP15 | 44,160 | 1.24 | 8.59 | 6.93 | 0.567 | 0.433 | 3.00 | 5.00 | 16.00 | 38 |
| hourly | ZP26 | 44,160 | 1.08 | 9.74 | 9.04 | 0.633 | 0.367 | 2.00 | 4.00 | 18.00 | 33 |
| 15min | NP15 | 176,640 | 0.26 | 2.75 | 10.72 | 0.908 | 0.092 | 0.00 | 1.00 | 8.00 | 34 |
| 15min | SP15 | 176,640 | 0.31 | 2.31 | 7.45 | 0.868 | 0.132 | 1.00 | 1.00 | 7.00 | 38 |
| 15min | ZP26 | 176,640 | 0.27 | 2.57 | 9.55 | 0.892 | 0.108 | 1.00 | 1.00 | 6.00 | 31 |
| hour | region_hour_rows | forced_starts | mean_starts | zero_share | max_starts | start_share |
|---|---|---|---|---|---|---|
| 0 | 5,520 | 79,445 | 14.39 | 0.001 | 38 | 0.538 |
| 1 | 5,535 | 5,071 | 0.92 | 0.699 | 10 | 0.034 |
| 2 | 5,505 | 844 | 0.15 | 0.872 | 8 | 0.006 |
| 3 | 5,520 | 959 | 0.17 | 0.858 | 7 | 0.006 |
| 4 | 5,520 | 1,239 | 0.22 | 0.820 | 9 | 0.008 |
| 5 | 5,520 | 2,354 | 0.43 | 0.700 | 14 | 0.016 |
| 6 | 5,520 | 5,423 | 0.98 | 0.449 | 10 | 0.037 |
| 7 | 5,520 | 4,801 | 0.87 | 0.483 | 9 | 0.033 |
| 8 | 5,520 | 4,874 | 0.88 | 0.461 | 10 | 0.033 |
| 9 | 5,520 | 4,634 | 0.84 | 0.488 | 10 | 0.031 |
| 10 | 5,520 | 4,463 | 0.81 | 0.498 | 13 | 0.030 |
| 11 | 5,520 | 3,897 | 0.71 | 0.541 | 9 | 0.026 |
| 12 | 5,520 | 3,797 | 0.69 | 0.539 | 17 | 0.026 |
| 13 | 5,520 | 2,902 | 0.53 | 0.622 | 8 | 0.020 |
| 14 | 5,520 | 2,682 | 0.49 | 0.646 | 14 | 0.018 |
| 15 | 5,520 | 2,474 | 0.45 | 0.672 | 8 | 0.017 |
| 16 | 5,520 | 2,637 | 0.48 | 0.662 | 10 | 0.018 |
| 17 | 5,520 | 2,737 | 0.50 | 0.651 | 9 | 0.018 |
| 18 | 5,520 | 2,906 | 0.53 | 0.641 | 9 | 0.020 |
| 19 | 5,520 | 2,253 | 0.41 | 0.707 | 7 | 0.015 |
| 20 | 5,520 | 2,213 | 0.40 | 0.710 | 11 | 0.015 |
| 21 | 5,520 | 1,768 | 0.32 | 0.759 | 7 | 0.012 |
| 22 | 5,520 | 1,595 | 0.29 | 0.787 | 8 | 0.011 |
| 23 | 5,520 | 1,648 | 0.30 | 0.772 | 10 | 0.011 |
Zero share is the transition: a zone almost always has at least one matched forced outage start per day, while roughly 57-68% of region-hours and 87-91% of region-intervals at 15-minute granularity are zero.
plot_data = granularity_summary.copy()
plot_data["frequency"] = pd.Categorical(plot_data["frequency"], ["daily", "hourly", "15min"], ordered=True)
plot_data = plot_data.sort_values(["frequency", "region"])
zero_share_factors = list(zip(plot_data["frequency"].astype(str), plot_data["region"]))
zero_share_src = ColumnDataSource(
{"x": zero_share_factors, "zero_share": plot_data["zero_share"].to_list()}
)
fig = figure(
title="Zero-Count Share by Granularity and Market Zone",
x_range=FactorRange(*zero_share_factors),
y_range=(0, 1),
y_axis_label="Zero Share",
height=360,
sizing_mode="stretch_width",
toolbar_location="above",
)
fig.vbar(x="x", top="zero_share", width=0.8, color=SERIES[0], source=zero_share_src)
fig.add_tools(
HoverTool(tooltips=[("granularity, zone", "@x"), ("zero share", "@zero_share{0.000}")])
)
show(style(fig))
start_hour_src = ColumnDataSource(
{
"hour": hourly_start_hour_summary["hour"].to_list(),
"start_share": hourly_start_hour_summary["start_share"].to_list(),
}
)
fig = figure(
title="Matched Forced Starts by Pacific Start Hour",
x_axis_label="Pacific Hour",
y_axis_label="Share of Starts",
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
fig.vbar(x="hour", top="start_share", width=0.8, color=SERIES[0], source=start_hour_src)
fig.add_tools(
HoverTool(tooltips=[("hour", "@hour"), ("share of starts", "@start_share{0.000}")])
)
show(style(fig))
A large share of recorded starts lands at midnight Pacific, so the hourly model is a probability-ranking prototype pending a timestamp-quality review.
3. Daily NB2 Baseline#
The daily model estimates zone-day outage intensity.
daily_fit = fit_negative_binomial_model(
daily_panel,
feature_cols=MARKET_ZONE_FEATURES,
target_col="Y",
test_size=0.20,
high_count_quantile=0.90,
)
daily_metrics = pd.Series(daily_fit.metrics).loc[
[
"alpha",
"n_train",
"n_test",
"train_mae",
"test_mae",
"train_median_ae",
"test_median_ae",
"train_p95_ae",
"test_p95_ae",
"train_rmse",
"test_rmse",
"train_nll",
"test_nll",
"train_auc_high_outage",
"test_auc_high_outage",
"train_brier_high_outage",
"test_brier_high_outage",
"train_high_count_threshold",
"test_high_count_threshold",
]
].rename("daily_value").to_frame()
display(html_table(pl.from_pandas(daily_metrics.round(4).reset_index())))
daily_oos_by_region = model_performance_by_group(
daily_fit.fitted_df,
group_cols=["sample", "region"],
target_col="Y",
high_count_threshold=daily_fit.high_count_threshold,
)
oos_cols = [
"region",
"rows",
"mean_actual",
"mean_predicted",
"zero_share",
"mae",
"median_ae",
"p95_ae",
"rmse",
"count_r2",
"max_actual",
"max_predicted",
"high_outage_share",
"auc_high_outage",
"brier_high_outage",
]
display(html_table(pl.from_pandas(daily_oos_by_region.query("sample == 'test'")[oos_cols].round(4))))
display(
html_table(
pl.from_pandas(
daily_fit.coefficients
.query("feature != 'const'")
.sort_values("abs_coefficient", ascending=False)
[["feature", "coefficient", "std_error", "z_stat", "p_value", "incidence_rate_ratio", "irr_low", "irr_high"]]
.head(10)
.round(4)
)
)
)
| index | daily_value |
|---|---|
| alpha | 0.00 |
| n_train | 4,341.00 |
| n_test | 1,086.00 |
| train_mae | 4.05 |
| test_mae | 4.61 |
| train_median_ae | 3.20 |
| test_median_ae | 3.83 |
| train_p95_ae | 10.34 |
| test_p95_ae | 11.26 |
| train_rmse | 5.28 |
| test_rmse | 5.89 |
| train_nll | 3.07 |
| test_nll | 3.19 |
| train_auc_high_outage | 0.88 |
| test_auc_high_outage | 0.82 |
| train_brier_high_outage | 0.06 |
| test_brier_high_outage | 0.03 |
| train_high_count_threshold | 38.00 |
| test_high_count_threshold | 38.00 |
| region | rows | mean_actual | mean_predicted | zero_share | mae | median_ae | p95_ae | rmse | count_r2 | max_actual | max_predicted | high_outage_share | auc_high_outage | brier_high_outage |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| NP15 | 362 | 23.05 | 22.91 | 0.000 | 3.724 | 3.006 | 9.22 | 4.761 | 0.123 | 43.00 | 33.82 | 0.008 | 0.538 | 0.009 |
| SP15 | 362 | 23.96 | 26.39 | 0.000 | 5.045 | 4.130 | 12.07 | 6.261 | 0.327 | 50.00 | 45.92 | 0.052 | 0.863 | 0.044 |
| ZP26 | 362 | 21.76 | 23.65 | 0.000 | 5.058 | 4.369 | 12.04 | 6.488 | 0.152 | 63.00 | 45.61 | 0.028 | 0.771 | 0.026 |
| feature | coefficient | std_error | z_stat | p_value | incidence_rate_ratio | irr_low | irr_high |
|---|---|---|---|---|---|---|---|
| Y_lag1 | 0.111 | 0.004 | 31.24 | 0.000 | 1.117 | 1.109 | 1.125 |
| log_system_load_mean | 0.048 | 0.006 | 7.46 | 0.000 | 1.049 | 1.036 | 1.062 |
| weekend | −0.046 | 0.004 | −13.07 | 0.000 | 0.955 | 0.948 | 0.961 |
| month_sin | −0.035 | 0.005 | −6.60 | 0.000 | 0.966 | 0.956 | 0.976 |
| region_SP15 | 0.035 | 0.004 | 8.08 | 0.000 | 1.035 | 1.027 | 1.044 |
| month_cos | −0.031 | 0.005 | −5.86 | 0.000 | 0.970 | 0.960 | 0.980 |
| region_NP15 | −0.025 | 0.004 | −6.25 | 0.000 | 0.975 | 0.967 | 0.983 |
| active_forced_mw_mean_lag1 | −0.022 | 0.003 | −6.95 | 0.000 | 0.979 | 0.973 | 0.985 |
| planned_start_count_lag1 | −0.010 | 0.003 | −3.27 | 0.001 | 0.990 | 0.984 | 0.996 |
| cdd65 | −0.010 | 0.005 | −1.79 | 0.073 | 0.990 | 0.979 | 1.001 |
Daily tracks the level (test MAE 4.61 on a mean of 27) and ranks high-count days well (test AUC 0.82). What it cannot express is P(Y > 0): every held-out region-day has a positive matched count, so the daily probability readout is P(high outage day) and nothing else. A high outage period is one whose count reaches the 90th percentile of the training counts, rounded up to a whole number: high_count_threshold = ceil(quantile(Y_train, 0.90)), reported per fit as train_high_count_threshold / test_high_count_threshold. The threshold is fixed on training data, so the held-out share sitting above it is a result rather than a definition, and it is a count of outage starts - not megawatts and not a severity measure. Sections 4 to 7 ask whether hourly sampling buys a sharper probability layer than this one.
4. Hourly NB2 Extension#
The target is new forced outage starts per market-zone hour, with hourly load, active outage MW, and Open-Meteo weather joined at the same timestamp. Lagged outage and active-MW terms are log-scaled so the NB log link is not dominated by a few spike hours.
hourly_fit = fit_negative_binomial_model(
hourly_panel,
feature_cols=MARKET_ZONE_HOURLY_FEATURES,
target_col="Y",
test_size=0.20,
high_count_quantile=0.90,
maxiter=250,
)
hourly_metrics = pd.Series(hourly_fit.metrics).loc[
[
"alpha",
"n_train",
"n_test",
"train_mae",
"test_mae",
"train_median_ae",
"test_median_ae",
"train_p95_ae",
"test_p95_ae",
"train_rmse",
"test_rmse",
"train_nll",
"test_nll",
"train_auc_outage",
"test_auc_outage",
"train_auc_high_outage",
"test_auc_high_outage",
"train_brier_outage",
"test_brier_outage",
"train_brier_high_outage",
"test_brier_high_outage",
"train_high_count_threshold",
"test_high_count_threshold",
]
].rename("hourly_value").to_frame()
display(html_table(pl.from_pandas(hourly_metrics.round(4).reset_index())))
hourly_oos_by_region = model_performance_by_group(
hourly_fit.fitted_df,
group_cols=["sample", "region"],
target_col="Y",
high_count_threshold=hourly_fit.high_count_threshold,
)
display(html_table(pl.from_pandas(hourly_oos_by_region.query("sample == 'test'")[oos_cols].round(4))))
display(
html_table(
pl.from_pandas(
hourly_fit.coefficients
.query("feature != 'const'")
.sort_values("abs_coefficient", ascending=False)
[["feature", "coefficient", "std_error", "z_stat", "p_value", "incidence_rate_ratio", "irr_low", "irr_high"]]
.head(12)
.round(4)
)
)
)
| index | hourly_value |
|---|---|
| alpha | 0.45 |
| n_train | 104,100.00 |
| n_test | 26,028.00 |
| train_mae | 0.82 |
| test_mae | 0.80 |
| train_median_ae | 0.40 |
| test_median_ae | 0.38 |
| train_p95_ae | 2.65 |
| test_p95_ae | 2.65 |
| train_rmse | 1.44 |
| test_rmse | 1.40 |
| train_nll | 1.08 |
| test_nll | 1.07 |
| train_auc_outage | 0.69 |
| test_auc_outage | 0.68 |
| train_auc_high_outage | 0.78 |
| test_auc_high_outage | 0.78 |
| train_brier_outage | 0.21 |
| test_brier_outage | 0.21 |
| train_brier_high_outage | 0.10 |
| test_brier_high_outage | 0.10 |
| train_high_count_threshold | 2.00 |
| test_high_count_threshold | 2.00 |
| region | rows | mean_actual | mean_predicted | zero_share | mae | median_ae | p95_ae | rmse | count_r2 | max_actual | max_predicted | high_outage_share | auc_high_outage | brier_high_outage |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| NP15 | 8,676 | 0.961 | 0.789 | 0.667 | 0.766 | 0.331 | 2.667 | 1.438 | 0.737 | 27.00 | 21.25 | 0.117 | 0.771 | 0.075 |
| SP15 | 8,676 | 0.999 | 0.975 | 0.594 | 0.828 | 0.438 | 2.622 | 1.374 | 0.608 | 27.00 | 24.43 | 0.180 | 0.797 | 0.111 |
| ZP26 | 8,676 | 0.907 | 0.792 | 0.622 | 0.804 | 0.384 | 2.672 | 1.378 | 0.580 | 24.00 | 18.78 | 0.149 | 0.754 | 0.099 |
| feature | coefficient | std_error | z_stat | p_value | incidence_rate_ratio | irr_low | irr_high |
|---|---|---|---|---|---|---|---|
| log1p_Y_lag24h | 0.871 | 0.003 | 257.39 | 0.000 | 2.388 | 2.373 | 2.404 |
| hour_sin | 0.101 | 0.006 | 17.76 | 0.000 | 1.107 | 1.094 | 1.119 |
| log_system_load_mean | 0.101 | 0.007 | 14.83 | 0.000 | 1.106 | 1.091 | 1.121 |
| weekend | −0.095 | 0.005 | −19.71 | 0.000 | 0.909 | 0.900 | 0.918 |
| region_SP15 | 0.063 | 0.005 | 11.62 | 0.000 | 1.065 | 1.054 | 1.076 |
| log1p_active_forced_mw_lag1h | −0.053 | 0.004 | −14.91 | 0.000 | 0.948 | 0.942 | 0.955 |
| month_sin | 0.044 | 0.007 | 6.68 | 0.000 | 1.045 | 1.032 | 1.059 |
| region_NP15 | −0.039 | 0.006 | −6.88 | 0.000 | 0.962 | 0.951 | 0.973 |
| hour_cos | −0.029 | 0.006 | −4.64 | 0.000 | 0.971 | 0.959 | 0.983 |
| log1p_Y_lag1h | 0.027 | 0.004 | 6.08 | 0.000 | 1.027 | 1.018 | 1.036 |
| log1p_planned_start_count_lag1h | 0.024 | 0.004 | 5.53 | 0.000 | 1.024 | 1.015 | 1.032 |
| temperature_f_std | −0.012 | 0.005 | −2.43 | 0.015 | 0.988 | 0.979 | 0.998 |
Both probability objects are meaningful at hourly granularity: P(Y > 0) for any outage-start hour and P(Y >= 2) for a higher-count hour. The strongest signals are the 24-hour lag, load, and hour-of-day structure.
5. Specification Tests#
The count models are checked against Poisson benchmarks and nested feature-block likelihood-ratio tests.
daily_feature_blocks = {
"outage state": ["Y_lag1", "planned_start_count_lag1", "active_forced_mw_mean_lag1"],
"weather": ["cdd65", "hdd65", "temperature_f_std"],
"load": ["log_system_load_mean"],
"calendar": ["weekend", "month_sin", "month_cos"],
"zone effects": ["region_NP15", "region_SP15"],
}
hourly_feature_blocks = {
"outage state": ["log1p_Y_lag1h", "log1p_Y_lag24h", "log1p_planned_start_count_lag1h", "log1p_active_forced_mw_lag1h"],
"weather": ["cdh65", "hdh65", "temperature_f_std"],
"load": ["log_system_load_mean"],
"calendar": ["hour_sin", "hour_cos", "weekend", "month_sin", "month_cos"],
"zone effects": ["region_NP15", "region_SP15"],
}
daily_nb_vs_poisson = poisson_negative_binomial_comparison(
daily_panel,
feature_cols=MARKET_ZONE_FEATURES,
target_col="Y",
test_size=0.20,
high_count_quantile=0.90,
maxiter=250,
)
hourly_nb_vs_poisson = poisson_negative_binomial_comparison(
hourly_panel,
feature_cols=MARKET_ZONE_HOURLY_FEATURES,
target_col="Y",
test_size=0.20,
high_count_quantile=0.90,
maxiter=250,
)
nb_vs_poisson = pd.concat(
[
daily_nb_vs_poisson.assign(frequency="daily"),
hourly_nb_vs_poisson.assign(frequency="hourly"),
],
ignore_index=True,
)
display(html_table(pl.from_pandas(nb_vs_poisson[["frequency", "model", "alpha", "train_llf", "aic", "test_mae", "test_rmse", "test_nll", "lr_chi2_vs_poisson", "lr_p_value_boundary_mixture"]].round(4))))
daily_block_tests = likelihood_ratio_block_tests(
daily_panel,
feature_cols=MARKET_ZONE_FEATURES,
feature_blocks=daily_feature_blocks,
target_col="Y",
test_size=0.20,
high_count_quantile=0.90,
maxiter=250,
).assign(frequency="daily")
hourly_block_tests = likelihood_ratio_block_tests(
hourly_panel,
feature_cols=MARKET_ZONE_HOURLY_FEATURES,
feature_blocks=hourly_feature_blocks,
target_col="Y",
test_size=0.20,
high_count_quantile=0.90,
maxiter=250,
).assign(frequency="hourly")
block_tests = pd.concat([daily_block_tests, hourly_block_tests], ignore_index=True)
display(
html_table(
pl.from_pandas(
block_tests[
["frequency", "block", "df", "lr_chi2", "p_value", "full_test_nll", "reduced_test_nll", "delta_test_nll_reduced_minus_full"]
].round(4)
)
)
)
plot_blocks = block_tests.copy()
plot_blocks["minus_log10_p"] = -np.log10(plot_blocks["p_value"].clip(lower=1e-300))
block_order = list(dict.fromkeys(plot_blocks["block"]))
block_wide = (
plot_blocks.pivot(index="block", columns="frequency", values="minus_log10_p")
.reindex(block_order)
.reset_index()
)
block_src = ColumnDataSource(
{
"block": block_wide["block"].to_list(),
"daily": block_wide["daily"].to_list(),
"hourly": block_wide["hourly"].to_list(),
}
)
fig = figure(
title="NB2 Feature-Block Likelihood-Ratio Tests",
x_range=FactorRange(*block_order),
y_axis_label="-log10 p-value",
height=360,
sizing_mode="stretch_width",
toolbar_location="above",
)
fig.vbar(x=dodge("block", -0.17, range=fig.x_range), top="daily", width=0.3,
color=SERIES[0], legend_label="daily", source=block_src)
fig.vbar(x=dodge("block", 0.17, range=fig.x_range), top="hourly", width=0.3,
color=SERIES[1], legend_label="hourly", source=block_src)
fig.add_layout(Span(location=-np.log10(0.05), dimension="width",
line_color="#555555", line_width=1, line_dash="dotted"))
fig.add_tools(
HoverTool(tooltips=[("block", "@block"), ("daily", "@daily{0.00}"), ("hourly", "@hourly{0.00}")])
)
fig.legend.location = "top_right"
show(style(fig))
| frequency | model | alpha | train_llf | aic | test_mae | test_rmse | test_nll | lr_chi2_vs_poisson | lr_p_value_boundary_mixture |
|---|---|---|---|---|---|---|---|---|---|
| daily | Poisson | 0.000 | −13,323.84 | 26,673.69 | 4.609 | 5.887 | 3.187 | — | — |
| daily | Negative binomial NB2 | 0.000 | −13,323.84 | 26,675.68 | 4.609 | 5.887 | 3.187 | 0.00 | 0.482 |
| hourly | Poisson | 0.000 | −113,745.99 | 227,523.99 | 0.756 | 1.261 | 1.098 | — | — |
| hourly | Negative binomial NB2 | 0.453 | −112,148.28 | 224,330.55 | 0.799 | 1.397 | 1.066 | 3,195.44 | 0.000 |
| frequency | block | df | lr_chi2 | p_value | full_test_nll | reduced_test_nll | delta_test_nll_reduced_minus_full |
|---|---|---|---|---|---|---|---|
| daily | outage state | 3 | 933.85 | 0.000 | 3.187 | 3.628 | 0.441 |
| daily | zone effects | 2 | 207.85 | 0.000 | 3.187 | 3.147 | −0.040 |
| daily | calendar | 3 | 187.26 | 0.000 | 3.187 | 3.224 | 0.037 |
| daily | load | 1 | 55.22 | 0.000 | 3.187 | 3.183 | −0.004 |
| daily | weather | 3 | 4.55 | 0.208 | 3.187 | 3.183 | −0.004 |
| hourly | outage state | 4 | 41,972.55 | 0.000 | 1.066 | 1.236 | 0.169 |
| hourly | calendar | 5 | 935.33 | 0.000 | 1.066 | 1.072 | 0.006 |
| hourly | zone effects | 2 | 372.05 | 0.000 | 1.066 | 1.067 | 0.000 |
| hourly | load | 1 | 221.92 | 0.000 | 1.066 | 1.068 | 0.002 |
| hourly | weather | 3 | 7.28 | 0.063 | 1.066 | 1.066 | 0.000 |
The daily fit puts alpha at 0.000 and the likelihood-ratio test against Poisson at 0.00, so the daily NB2 is a Poisson in everything but name - the conditional features absorb the overdispersion the raw zone-day counts show. Only the hourly target keeps genuine residual dispersion, at alpha 0.45. The block tests separate in-sample significance from holdout usefulness: a block can have a small p-value while adding only a small improvement in test NLL.
6. Daily vs Hourly Performance View#
Daily estimates zone-day outage intensity; hourly estimates outage-start timing and short-window event risk.
comparison = pd.DataFrame([
{
"model": "daily market-zone NB2",
"rows": len(daily_fit.fitted_df),
"target_mean": daily_fit.fitted_df["Y"].mean(),
"zero_share": (daily_fit.fitted_df["Y"] == 0).mean(),
"alpha": daily_fit.metrics["alpha"],
"test_mae": daily_fit.metrics["test_mae"],
"test_median_ae": daily_fit.metrics["test_median_ae"],
"test_p95_ae": daily_fit.metrics["test_p95_ae"],
"test_rmse": daily_fit.metrics["test_rmse"],
"test_max_predicted": daily_fit.metrics["test_max_predicted"],
"test_nll": daily_fit.metrics["test_nll"],
"test_auc_outage": daily_fit.metrics["test_auc_outage"],
"test_auc_high_outage": daily_fit.metrics["test_auc_high_outage"],
"test_brier_high_outage": daily_fit.metrics["test_brier_high_outage"],
"probability_use": "P(high outage day)",
},
{
"model": "hourly market-zone NB2",
"rows": len(hourly_fit.fitted_df),
"target_mean": hourly_fit.fitted_df["Y"].mean(),
"zero_share": (hourly_fit.fitted_df["Y"] == 0).mean(),
"alpha": hourly_fit.metrics["alpha"],
"test_mae": hourly_fit.metrics["test_mae"],
"test_median_ae": hourly_fit.metrics["test_median_ae"],
"test_p95_ae": hourly_fit.metrics["test_p95_ae"],
"test_rmse": hourly_fit.metrics["test_rmse"],
"test_max_predicted": hourly_fit.metrics["test_max_predicted"],
"test_nll": hourly_fit.metrics["test_nll"],
"test_auc_outage": hourly_fit.metrics["test_auc_outage"],
"test_auc_high_outage": hourly_fit.metrics["test_auc_high_outage"],
"test_brier_high_outage": hourly_fit.metrics["test_brier_high_outage"],
"probability_use": "P(any outage hour), P(high outage hour)",
},
])
display(html_table(pl.from_pandas(comparison.round(4))))
auc_plot = pd.concat(
[
daily_oos_by_region.query("sample == 'test'").assign(model="daily: high-outage day", auc=lambda df: df["auc_high_outage"]),
hourly_oos_by_region.query("sample == 'test'").assign(model="hourly: any-outage hour", auc=lambda df: df["auc_outage"]),
hourly_oos_by_region.query("sample == 'test'").assign(model="hourly: high-outage hour", auc=lambda df: df["auc_high_outage"]),
],
ignore_index=True,
)
auc_plot = auc_plot[["model", "region", "auc"]].dropna()
auc_models = list(dict.fromkeys(auc_plot["model"]))
auc_regions = sorted(auc_plot["region"].unique())
auc_wide = (
auc_plot.pivot(index="region", columns="model", values="auc")
.reindex(auc_regions)
.reset_index()
)
auc_src = ColumnDataSource(
{
"region": auc_wide["region"].to_list(),
**{model: auc_wide[model].to_list() for model in auc_models},
}
)
fig = figure(
title="Out-of-Sample Probability Ranking by Market Zone",
x_range=FactorRange(*auc_regions),
y_range=(0.45, 1.0),
y_axis_label="Test AUC",
height=360,
sizing_mode="stretch_width",
toolbar_location="above",
)
for offset, color, model in zip([-0.25, 0.0, 0.25], SERIES, auc_models):
fig.vbar(x=dodge("region", offset, range=fig.x_range), top=model, width=0.22,
color=color, legend_label=model, source=auc_src)
fig.add_layout(Span(location=0.50, dimension="width", line_color="#555555",
line_width=1, line_dash="dotted"))
fig.legend.location = "bottom_right"
show(style(fig))
| model | rows | target_mean | zero_share | alpha | test_mae | test_median_ae | test_p95_ae | test_rmse | test_max_predicted | test_nll | test_auc_outage | test_auc_high_outage | test_brier_high_outage | probability_use |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| daily market-zone NB2 | 5,427 | 26.85 | 0.000 | 0.000 | 4.609 | 3.830 | 11.26 | 5.887 | 45.92 | 3.187 | — | 0.818 | 0.026 | P(high outage day) |
| hourly market-zone NB2 | 130,128 | 1.12 | 0.626 | 0.453 | 0.799 | 0.384 | 2.65 | 1.397 | 24.43 | 1.066 | 0.680 | 0.777 | 0.095 | P(any outage hour), P(high outage hour) |
Three of the four granularity-event combinations are rankable. Daily zone-days are never zero (zero_share 0.000, every zone starts at least one outage each day), so P(any outage day) has a single class and no AUC is defined - the dash in the table above. Hourly is 62.6% zeros, which is what makes the any-outage event rankable at all, and P(any outage hour) is the one question only hourly can answer. Read each AUC by its own event: daily ranks high-count days, hourly ranks any-outage hours and high-outage hours. The two high-outage columns are the only near-comparison, and even there the events differ - a day above 38 starts against an hour above 2 - so section 7 scores them on repeated folds rather than on this single split.
hourly_fitted = hourly_fit.fitted_df.copy()
hourly_fitted["interval_start_utc"] = pd.to_datetime(hourly_fitted["interval_start_utc"])
recent_start = hourly_fitted["interval_start_utc"].max() - pd.Timedelta(21, "D")
recent = hourly_fitted[hourly_fitted["interval_start_utc"] >= recent_start]
recent_regions = sorted(recent["region"].unique())
fig = figure(
title="Hourly Actual Counts and NB Expected Counts, Last 21 Days",
x_axis_type="datetime",
y_axis_label="Forced Starts/Hour",
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
for color, region in zip(SERIES, recent_regions):
sub = recent[recent["region"].eq(region)].sort_values("interval_start_utc")
fig.line(sub["interval_start_utc"], sub["Y"], color=color, line_width=1.0,
legend_label=f"{region} actual")
fig.line(sub["interval_start_utc"], sub["mu_hat"], color=color, line_width=1.2,
line_dash="dashed", legend_label=f"{region} expected")
fig.legend.location = "top_left"
show(style(fig))
fig = figure(
title="Hourly NB-Implied P(any outage start)",
x_axis_type="datetime",
y_axis_label="P(Y > 0)",
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
for color, region in zip(SERIES, recent_regions):
sub = recent[recent["region"].eq(region)].sort_values("interval_start_utc")
fig.line(sub["interval_start_utc"], sub["p_outage_hat"], color=color,
line_width=1.2, legend_label=region)
fig.legend.location = "top_left"
show(style(fig))
The hourly fitted line is a conditional intensity, not a spike reconstruction: it ranks outage-prone hours rather than matching counts one-for-one.
7. Purged Walk-Forward Backtests#
The robustness check uses four future test windows, each trained on earlier observations with the immediate lookback window purged before the test block and an embargo gap between scored blocks.
daily_backtest = rolling_backtest_negative_binomial_model(
daily_panel,
feature_cols=MARKET_ZONE_FEATURES,
target_col="Y",
time_col="date",
n_splits=4,
initial_train_fraction=0.60,
purge_gap="1D",
embargo_gap="1D",
high_count_quantile=0.90,
maxiter=250,
)
hourly_backtest = rolling_backtest_negative_binomial_model(
hourly_panel,
feature_cols=MARKET_ZONE_HOURLY_FEATURES,
target_col="Y",
time_col="interval_start_utc",
n_splits=4,
initial_train_fraction=0.60,
purge_gap="24h",
embargo_gap="24h",
high_count_quantile=0.90,
maxiter=250,
)
daily_logit_backtest = rolling_backtest_logistic_probability_model(
daily_panel,
feature_cols=MARKET_ZONE_FEATURES,
target_col="Y",
threshold=daily_fit.high_count_threshold,
time_col="date",
n_splits=4,
initial_train_fraction=0.60,
purge_gap="1D",
embargo_gap="1D",
maxiter=200,
positive_label="high_outage_day",
)
hourly_logit_backtest = rolling_backtest_logistic_probability_model(
hourly_panel,
feature_cols=MARKET_ZONE_HOURLY_FEATURES,
target_col="Y",
threshold=hourly_fit.high_count_threshold,
time_col="interval_start_utc",
n_splits=4,
initial_train_fraction=0.60,
purge_gap="24h",
embargo_gap="24h",
maxiter=200,
positive_label="high_outage_hour",
)
backtest_comparison = pd.DataFrame([
{
"model": "daily NB2: P(high outage day)",
"folds": len(daily_backtest.fold_metrics),
"rows": daily_backtest.overall_metrics.loc[0, "rows"],
"mae": daily_backtest.overall_metrics.loc[0, "mae"],
"median_ae": daily_backtest.overall_metrics.loc[0, "median_ae"],
"p95_ae": daily_backtest.overall_metrics.loc[0, "p95_ae"],
"rmse": daily_backtest.overall_metrics.loc[0, "rmse"],
"nll": daily_backtest.overall_metrics.loc[0, "nll"],
"auc_outage": daily_backtest.overall_metrics.loc[0, "auc_outage"],
"auc_high_outage": daily_backtest.overall_metrics.loc[0, "auc_high_outage"],
"brier_high_outage": daily_backtest.overall_metrics.loc[0, "brier_high_outage"],
},
{
"model": "hourly NB2: P(high outage hour)",
"folds": len(hourly_backtest.fold_metrics),
"rows": hourly_backtest.overall_metrics.loc[0, "rows"],
"mae": hourly_backtest.overall_metrics.loc[0, "mae"],
"median_ae": hourly_backtest.overall_metrics.loc[0, "median_ae"],
"p95_ae": hourly_backtest.overall_metrics.loc[0, "p95_ae"],
"rmse": hourly_backtest.overall_metrics.loc[0, "rmse"],
"nll": hourly_backtest.overall_metrics.loc[0, "nll"],
"auc_outage": hourly_backtest.overall_metrics.loc[0, "auc_outage"],
"auc_high_outage": hourly_backtest.overall_metrics.loc[0, "auc_high_outage"],
"brier_high_outage": hourly_backtest.overall_metrics.loc[0, "brier_high_outage"],
},
])
display(html_table(pl.from_pandas(backtest_comparison.round(4))))
probability_backtest_comparison = pd.DataFrame([
{
"model": "daily NB2-implied",
"target": "high outage day",
"rows": daily_backtest.overall_metrics.loc[0, "rows"],
"positive_share": daily_backtest.overall_metrics.loc[0, "high_outage_share"],
"auc": daily_backtest.overall_metrics.loc[0, "auc_high_outage"],
"brier": daily_backtest.overall_metrics.loc[0, "brier_high_outage"],
},
{
"model": "daily logistic",
"target": "high outage day",
"rows": daily_logit_backtest.overall_metrics.loc[0, "rows"],
"positive_share": daily_logit_backtest.overall_metrics.loc[0, "positive_share"],
"auc": daily_logit_backtest.overall_metrics.loc[0, "auc"],
"brier": daily_logit_backtest.overall_metrics.loc[0, "brier"],
},
{
"model": "hourly NB2-implied",
"target": "high outage hour",
"rows": hourly_backtest.overall_metrics.loc[0, "rows"],
"positive_share": hourly_backtest.overall_metrics.loc[0, "high_outage_share"],
"auc": hourly_backtest.overall_metrics.loc[0, "auc_high_outage"],
"brier": hourly_backtest.overall_metrics.loc[0, "brier_high_outage"],
},
{
"model": "hourly logistic",
"target": "high outage hour",
"rows": hourly_logit_backtest.overall_metrics.loc[0, "rows"],
"positive_share": hourly_logit_backtest.overall_metrics.loc[0, "positive_share"],
"auc": hourly_logit_backtest.overall_metrics.loc[0, "auc"],
"brier": hourly_logit_backtest.overall_metrics.loc[0, "brier"],
},
])
display(html_table(pl.from_pandas(probability_backtest_comparison.round(4))))
fold_plot = pd.concat(
[
daily_backtest.fold_metrics.assign(model="daily NB2", auc=lambda df: df["auc_high_outage"]),
hourly_backtest.fold_metrics.assign(model="hourly NB2", auc=lambda df: df["auc_high_outage"]),
daily_logit_backtest.fold_metrics.assign(model="daily logistic", auc=lambda df: df["auc"]),
hourly_logit_backtest.fold_metrics.assign(model="hourly logistic", auc=lambda df: df["auc"]),
],
ignore_index=True,
)
fold_display_cols = ["model", "fold", "test_start", "test_end", "purge_gap", "embargo_gap", "n_test", "mae", "median_ae", "p95_ae", "rmse", "auc_outage", "auc_high_outage", "auc", "brier_high_outage", "brier"]
display(html_table(pl.from_pandas(fold_plot[[col for col in fold_display_cols if col in fold_plot.columns]].round(4))))
count_plot = fold_plot[fold_plot["model"].isin(["daily NB2", "hourly NB2"])]
fig = figure(
title="Purged WFO Count Error",
x_axis_label="Fold",
y_axis_label="MAE",
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
for color, model in zip(SERIES, ["daily NB2", "hourly NB2"]):
sub = count_plot[count_plot["model"].eq(model)].sort_values("fold")
fig.line(sub["fold"], sub["mae"], color=color, line_width=2, legend_label=model)
fig.scatter(sub["fold"], sub["mae"], color=color, size=7)
fig.legend.location = "top_right"
show(style(fig))
fold_models = list(dict.fromkeys(fold_plot["model"]))
fig = figure(
title="Purged WFO High-Outage Ranking",
x_axis_label="Fold",
y_axis_label="AUC",
y_range=(0.45, 0.90),
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
for color, model in zip(SERIES, fold_models):
sub = fold_plot[fold_plot["model"].eq(model)].sort_values("fold")
fig.line(sub["fold"], sub["auc"], color=color, line_width=2, legend_label=model)
fig.scatter(sub["fold"], sub["auc"], color=color, size=7)
fig.add_layout(Span(location=0.50, dimension="width", line_color="#555555",
line_width=1, line_dash="dotted"))
fig.legend.location = "bottom_right"
show(style(fig))
hourly_region_backtest = hourly_backtest.region_metrics[
[
"region",
"rows",
"mean_actual",
"mean_predicted",
"mae",
"median_ae",
"p95_ae",
"rmse",
"auc_outage",
"auc_high_outage",
"brier_high_outage",
]
]
display(html_table(pl.from_pandas(hourly_region_backtest.round(4))))
region_src = ColumnDataSource(
{
"region": hourly_region_backtest["region"].to_list(),
"auc_high_outage": hourly_region_backtest["auc_high_outage"].to_list(),
}
)
fig = figure(
title="Hourly Backtest High-Outage AUC by Market Zone",
x_range=FactorRange(*hourly_region_backtest["region"].to_list()),
y_range=(0.45, 0.90),
y_axis_label="AUC",
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
fig.vbar(x="region", top="auc_high_outage", width=0.6, color=SERIES[0], source=region_src)
fig.add_layout(Span(location=0.50, dimension="width", line_color="#555555",
line_width=1, line_dash="dotted"))
fig.add_tools(
HoverTool(tooltips=[("zone", "@region"), ("AUC", "@auc_high_outage{0.000}")])
)
show(style(fig))
| model | folds | rows | mae | median_ae | p95_ae | rmse | nll | auc_outage | auc_high_outage | brier_high_outage |
|---|---|---|---|---|---|---|---|---|---|---|
| daily NB2: P(high outage day) | 4 | 2,163 | 4.769 | 3.928 | 11.60 | 6.077 | 3.233 | — | 0.870 | 0.044 |
| hourly NB2: P(high outage hour) | 4 | 51,837 | 0.819 | 0.399 | 2.67 | 1.428 | 1.090 | 0.683 | 0.774 | 0.100 |
| model | target | rows | positive_share | auc | brier |
|---|---|---|---|---|---|
| daily NB2-implied | high outage day | 2,163 | 0.056 | 0.870 | 0.044 |
| daily logistic | high outage day | 2,163 | 0.056 | 0.864 | 0.043 |
| hourly NB2-implied | high outage hour | 51,837 | 0.155 | 0.774 | 0.100 |
| hourly logistic | high outage hour | 51,837 | 0.155 | 0.778 | 0.099 |
| model | fold | test_start | test_end | purge_gap | embargo_gap | n_test | mae | median_ae | p95_ae | rmse | auc_outage | auc_high_outage | auc | brier_high_outage | brier |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| daily NB2 | 1 | 2024-06-06 00:00 | 2024-12-03 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 543 | 4.360 | 3.605 | 10.81 | 5.718 | — | 0.842 | 0.842 | 0.099 | — |
| daily NB2 | 2 | 2024-12-05 00:00 | 2025-07-04 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 543 | 5.640 | 5.062 | 12.81 | 6.849 | — | 0.813 | 0.813 | 0.023 | — |
| daily NB2 | 3 | 2025-07-06 00:00 | 2026-01-02 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 543 | 4.836 | 4.065 | 11.48 | 6.133 | — | 0.874 | 0.874 | 0.035 | — |
| daily NB2 | 4 | 2026-01-04 00:00 | 2026-06-30 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 534 | 4.231 | 3.388 | 10.95 | 5.515 | — | 0.671 | 0.671 | 0.018 | — |
| hourly NB2 | 1 | 2024-06-06 02:00 | 2024-12-03 21:00 | 1 days 00:00:00 | 1 days 00:00:00 | 13,011 | 0.880 | 0.493 | 2.81 | 1.552 | 0.692 | 0.777 | 0.777 | 0.111 | — |
| hourly NB2 | 2 | 2024-12-04 22:00 | 2025-07-04 16:00 | 1 days 00:00:00 | 1 days 00:00:00 | 13,011 | 0.795 | 0.394 | 2.59 | 1.356 | 0.679 | 0.768 | 0.768 | 0.100 | — |
| hourly NB2 | 3 | 2025-07-05 17:00 | 2026-01-02 11:00 | 1 days 00:00:00 | 1 days 00:00:00 | 13,011 | 0.814 | 0.381 | 2.68 | 1.434 | 0.693 | 0.775 | 0.775 | 0.100 | — |
| hourly NB2 | 4 | 2026-01-03 12:00 | 2026-06-30 09:00 | 1 days 00:00:00 | 1 days 00:00:00 | 12,804 | 0.788 | 0.394 | 2.59 | 1.360 | 0.665 | 0.778 | 0.778 | 0.091 | — |
| daily logistic | 1 | 2024-06-06 00:00 | 2024-12-03 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 543 | — | — | — | — | — | — | 0.836 | — | 0.098 |
| daily logistic | 2 | 2024-12-05 00:00 | 2025-07-04 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 543 | — | — | — | — | — | — | 0.802 | — | 0.023 |
| daily logistic | 3 | 2025-07-06 00:00 | 2026-01-02 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 543 | — | — | — | — | — | — | 0.862 | — | 0.034 |
| daily logistic | 4 | 2026-01-04 00:00 | 2026-06-30 00:00 | 1 days 00:00:00 | 1 days 00:00:00 | 534 | — | — | — | — | — | — | 0.677 | — | 0.018 |
| hourly logistic | 1 | 2024-06-06 02:00 | 2024-12-03 21:00 | 1 days 00:00:00 | 1 days 00:00:00 | 13,011 | — | — | — | — | — | — | 0.782 | — | 0.107 |
| hourly logistic | 2 | 2024-12-04 22:00 | 2025-07-04 16:00 | 1 days 00:00:00 | 1 days 00:00:00 | 13,011 | — | — | — | — | — | — | 0.772 | — | 0.099 |
| hourly logistic | 3 | 2025-07-05 17:00 | 2026-01-02 11:00 | 1 days 00:00:00 | 1 days 00:00:00 | 13,011 | — | — | — | — | — | — | 0.778 | — | 0.099 |
| hourly logistic | 4 | 2026-01-03 12:00 | 2026-06-30 09:00 | 1 days 00:00:00 | 1 days 00:00:00 | 12,804 | — | — | — | — | — | — | 0.779 | — | 0.090 |
| region | rows | mean_actual | mean_predicted | mae | median_ae | p95_ae | rmse | auc_outage | auc_high_outage | brier_high_outage |
|---|---|---|---|---|---|---|---|---|---|---|
| NP15 | 17,279 | 1.015 | 0.852 | 0.774 | 0.331 | 2.642 | 1.484 | 0.661 | 0.774 | 0.074 |
| SP15 | 17,279 | 1.097 | 1.063 | 0.877 | 0.572 | 2.680 | 1.417 | 0.700 | 0.771 | 0.127 |
| ZP26 | 17,279 | 0.962 | 0.858 | 0.806 | 0.378 | 2.668 | 1.381 | 0.675 | 0.758 | 0.100 |
On the five-year panel the daily high-outage layer ranks better than the hourly one and is steadier fold to fold than the earlier one-year reading suggested: backtest AUC 0.870 against hourly’s 0.774, ahead in three of the four folds. The two are not scored on the same event, though - a high outage day is a day above 38 starts, met on 5.6% of held-out zone-days, while a high outage hour is an hour above 2 starts, met on 15.5% of held-out zone-hours - so the Brier gap (0.044 daily against 0.100 hourly in the walk-forward, 0.026 against 0.095 on the single split) mostly reflects the rarer event, not a better model. What the comparison does support: modelling the timing directly did not buy a sharper high-outage signal, and hourly’s own claim rests on P(any outage hour), which daily cannot express at all. Daily’s weak fold is the most recent one (AUC 0.671 against hourly’s 0.778 in fold 4). The logistic benchmark does not beat the NB-implied probability at either granularity (0.864 against 0.870 daily; 0.778 against 0.774 hourly, inside noise).
8. Fifteen-Minute Feasibility Check#
A 15-minute target is constructible from curtailment start timestamps, and CAISO market data exists at 15-minute and 5-minute frequencies; the constraint is sparsity.
fifteen_panel = interval_market_zone_outage_counts(
mapped_events,
start_date=daily_panel["date"].min(),
end_date=daily_panel["date"].max(),
freq="15min",
)
fifteen_summary_display = fifteen_summary[cols].round(4)
display(html_table(pl.from_pandas(fifteen_summary_display)))
fifteen_times = pd.Index(sorted(pd.to_datetime(fifteen_panel["interval_start_utc"]).unique()))
initial_count = int(np.floor(len(fifteen_times) * 0.60))
fifteen_chunks = np.array_split(fifteen_times[initial_count:].to_numpy(), 4)
fifteen_window_rows = []
for fold, chunk in enumerate(fifteen_chunks, start=1):
if len(chunk) == 0:
continue
start = pd.Timestamp(chunk[0])
end = pd.Timestamp(chunk[-1])
sub = fifteen_panel[pd.to_datetime(fifteen_panel["interval_start_utc"]).between(start, end, inclusive="both")]
grouped = (
sub.groupby("region")["Y"]
.agg(
rows="size",
mean="mean",
variance="var",
zero_share=lambda s: (s == 0).mean(),
outage_share=lambda s: (s > 0).mean(),
p95=lambda s: s.quantile(0.95),
p99=lambda s: s.quantile(0.99),
max="max",
)
.reset_index()
)
grouped["fold"] = fold
grouped["test_start"] = start
grouped["test_end"] = end
grouped["variance_to_mean"] = grouped["variance"] / grouped["mean"].replace(0, np.nan)
fifteen_window_rows.append(grouped)
fifteen_window_summary = pd.concat(fifteen_window_rows, ignore_index=True)
display(
html_table(
pl.from_pandas(
fifteen_window_summary[
["fold", "region", "rows", "mean", "variance_to_mean", "zero_share", "outage_share", "p95", "p99", "max"]
].round(4)
)
)
)
fig = figure(
title="15-Minute Target Sparsity Across Future Windows",
x_axis_label="Future Window",
y_axis_label="Zero-Count Share",
y_range=(0.80, 0.96),
height=340,
sizing_mode="stretch_width",
toolbar_location="above",
)
for color, region in zip(SERIES, sorted(fifteen_window_summary["region"].unique())):
sub = fifteen_window_summary[fifteen_window_summary["region"].eq(region)].sort_values("fold")
fig.line(sub["fold"], sub["zero_share"], color=color, line_width=2, legend_label=region)
fig.scatter(sub["fold"], sub["zero_share"], color=color, size=7)
fig.legend.location = "bottom_right"
show(style(fig))
rt15_path = DATA_DIR / "opensource" / "caiso_lmp_real_time_15_min.parquet"
if rt15_path.exists():
rt15 = pd.read_parquet(rt15_path)
rt15_summary = pd.Series({
"rt15_rows": len(rt15),
"rt15_first_interval_utc": rt15["interval_start_utc"].min(),
"rt15_last_interval_utc": rt15["interval_start_utc"].max(),
"rt15_locations": rt15["location"].nunique(),
})
display(html_table(pl.from_pandas(rt15_summary.astype(str).to_frame("value").reset_index())))
| frequency | region | rows | mean | variance | variance_to_mean | zero_share | outage_share | p90 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 15min | NP15 | 176,640 | 0.256 | 2.749 | 10.72 | 0.908 | 0.092 | 0.000 | 1.000 | 8.000 | 34 |
| 15min | SP15 | 176,640 | 0.310 | 2.310 | 7.45 | 0.868 | 0.132 | 1.000 | 1.000 | 7.000 | 38 |
| 15min | ZP26 | 176,640 | 0.269 | 2.571 | 9.55 | 0.892 | 0.108 | 1.000 | 1.000 | 6.000 | 31 |
| fold | region | rows | mean | variance_to_mean | zero_share | outage_share | p95 | p99 | max |
|---|---|---|---|---|---|---|---|---|---|
| 1 | NP15 | 17,664 | 0.294 | 13.31 | 0.906 | 0.094 | 1.000 | 11.37 | 33 |
| 1 | SP15 | 17,664 | 0.337 | 6.12 | 0.837 | 0.163 | 1.000 | 7.00 | 26 |
| 1 | ZP26 | 17,664 | 0.300 | 10.08 | 0.888 | 0.112 | 1.000 | 9.37 | 30 |
| 2 | NP15 | 17,664 | 0.230 | 7.89 | 0.904 | 0.096 | 1.000 | 6.00 | 30 |
| 2 | SP15 | 17,664 | 0.238 | 4.40 | 0.873 | 0.127 | 1.000 | 6.00 | 24 |
| 2 | ZP26 | 17,664 | 0.191 | 3.83 | 0.892 | 0.108 | 1.000 | 4.00 | 25 |
| 3 | NP15 | 17,664 | 0.243 | 9.06 | 0.906 | 0.094 | 1.000 | 8.00 | 27 |
| 3 | SP15 | 17,664 | 0.269 | 5.37 | 0.866 | 0.134 | 1.000 | 6.00 | 28 |
| 3 | ZP26 | 17,664 | 0.239 | 5.63 | 0.888 | 0.112 | 1.000 | 6.00 | 21 |
| 4 | NP15 | 17,664 | 0.237 | 8.24 | 0.902 | 0.098 | 1.000 | 7.00 | 22 |
| 4 | SP15 | 17,664 | 0.233 | 5.05 | 0.888 | 0.112 | 1.000 | 7.00 | 26 |
| 4 | ZP26 | 17,664 | 0.214 | 4.97 | 0.891 | 0.109 | 1.000 | 6.00 | 23 |
| index | value |
|---|---|
| rt15_rows | 369394 |
| rt15_first_interval_utc | 2023-05-11 07:00:00 |
| rt15_last_interval_utc | 2026-08-27 06:45:00 |
| rt15_locations | 5 |
About nine out of ten zone-intervals are zero, which points to a hurdle, zero-inflated, or ranking model rather than plain NB2, especially while outage timestamp conventions are unverified.
9. Findings#
Daily is the baseline for count intensity and, on the five-year panel, the better high-outage ranker: walk-forward AUC 0.870 against hourly’s 0.774, ahead in three of four folds. The two score different events (a day above 38 starts, an hour above 2), so the Brier gap between them is not a like-for-like comparison.
Modelling the timing directly did not buy a sharper high-outage signal. What hourly adds is P(any outage hour), an event daily cannot express because no held-out zone-day is ever zero.
Daily’s weakness is recency, not level: fold 4 falls to 0.671 while hourly holds 0.778.
Logistic benchmarks are a direct binary alternative to the NB-implied probabilities and do not beat them at either granularity.
The daily fit’s dispersion is zero, so the daily NB2 is a Poisson in practice; only the hourly target carries residual overdispersion (alpha 0.45).
Fifteen-minute counts are constructible from outage timestamps but sparse enough that the model class should change.
Mapping remains the main sampling risk; higher-confidence resource-to-zone mapping should raise coverage.