Model Comparison & Selection#
Navigation: ← Previous: State-Space Models
We compare all classical methods head-to-head using time-series cross-validation, proper forecast metrics, and the Diebold-Mariano test for statistical significance.
from pathlib import Path
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
plt.style.use('seaborn-v0_8-whitegrid')
DATA_DIR = Path('data')
if not DATA_DIR.exists():
DATA_DIR = Path('projects/time-series-analysis/data')
def load_series(name):
"""Load a bundled CSV as a DatetimeIndex Series."""
df = pd.read_csv(DATA_DIR / name, parse_dates=['date'])
return df.set_index('date')['value'].sort_index()
def load_air_passengers():
path = DATA_DIR / 'air_passengers.csv'
if path.exists():
return load_series('air_passengers.csv')
from statsmodels.datasets import get_rdataset
air = get_rdataset('AirPassengers', 'datasets').data
s = air['value']
s.index = pd.date_range('1949-01', periods=len(s), freq='MS')
return s
def load_milk():
path = DATA_DIR / 'monthly_milk.csv'
if path.exists():
return load_series('monthly_milk.csv')
url = 'https://raw.githubusercontent.com/jbrownlee/Datasets/master/monthly-milk-production-pounds.csv'
return pd.read_csv(url, index_col=0, parse_dates=True).squeeze()
def load_co2():
from statsmodels.datasets import co2
return co2.load().data.resample('MS').mean().ffill().squeeze()
def load_sunspots():
from statsmodels.datasets import sunspots
d = sunspots.load_pandas().data
return d.set_index('YEAR')['SUNACTIVITY']
def load_retail():
path = DATA_DIR / 'uk_retail_sales.csv'
if path.exists():
return load_series('uk_retail_sales.csv')
raise FileNotFoundError('uk_retail_sales.csv not found in data/')
from sklearn.model_selection import TimeSeriesSplit
from statsmodels.tsa.holtwinters import ExponentialSmoothing, SimpleExpSmoothing
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.statespace.structural import UnobservedComponents
from scipy import stats
def mae(y, yhat): return np.mean(np.abs(y - yhat))
def rmse(y, yhat): return np.sqrt(np.mean((y - yhat) ** 2))
def mape(y, yhat): return np.mean(np.abs((y - yhat) / y)) * 100
def smape(y, yhat): return np.mean(2 * np.abs(y - yhat) / (np.abs(y) + np.abs(yhat))) * 100
def crps_normal(y, mu, sigma):
"""CRPS for Gaussian predictive distribution."""
z = (y - mu) / sigma
return sigma * (z * (2 * stats.norm.cdf(z) - 1) + 2 * stats.norm.pdf(z) - 1 / np.sqrt(np.pi))
def diebold_mariano(e1, e2, h=1):
"""DM test: H0 equal forecast accuracy."""
d = e1**2 - e2**2
d_mean = d.mean()
n = len(d)
gamma0 = np.var(d, ddof=1)
var_d = gamma0 / n
if var_d <= 0:
return 0.0, 1.0
dm_stat = d_mean / np.sqrt(var_d)
p = 2 * (1 - stats.norm.cdf(np.abs(dm_stat)))
return dm_stat, p
Time Series Cross-Validation#
Expanding-window CV respects temporal ordering — no random shuffling.
def evaluate_models(series, test_size=12):
train, test = series.iloc[:-test_size], series.iloc[-test_size:]
results = {}
# Holt-Winters multiplicative
hw = ExponentialSmoothing(
train, trend='add', seasonal='mul', seasonal_periods=12,
initialization_method='estimated'
).fit(optimized=True)
fc = hw.forecast(len(test))
results['HW Multiplicative'] = {'MAE': mae(test, fc), 'RMSE': rmse(test, fc), 'MAPE': mape(test, fc)}
# SARIMA airline
sar = SARIMAX(train, order=(0,1,1), seasonal_order=(0,1,1,12)).fit(disp=False)
fc = sar.forecast(len(test))
results['SARIMA Airline'] = {'MAE': mae(test, fc), 'RMSE': rmse(test, fc), 'MAPE': mape(test, fc)}
# Structural
uc = UnobservedComponents(train, level='local linear trend', seasonal=12).fit(disp=False)
fc = uc.forecast(len(test))
results['Structural'] = {'MAE': mae(test, fc), 'RMSE': rmse(test, fc), 'MAPE': mape(test, fc)}
return pd.DataFrame(results).T
air = load_air_passengers()
milk = load_milk()
air_results = evaluate_models(air)
milk_results = evaluate_models(milk)
print('Air Passengers (12-month holdout):')
display(air_results.style.format('{:.2f}'))
print('Milk Production (12-month holdout):')
display(milk_results.style.format('{:.2f}'))
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
Air Passengers (12-month holdout):
| MAE | RMSE | MAPE | |
|---|---|---|---|
| HW Multiplicative | 10.30 | 15.81 | 2.21 |
| SARIMA Airline | 16.22 | 21.09 | 3.65 |
| Structural | 78.78 | 91.29 | 15.77 |
Milk Production (12-month holdout):
| MAE | RMSE | MAPE | |
|---|---|---|---|
| HW Multiplicative | 5.24 | 6.49 | 0.63 |
| SARIMA Airline | 4.78 | 5.51 | 0.57 |
| Structural | 4.57 | 5.47 | 0.55 |
# Diebold-Mariano: HW vs SARIMA on Air Passengers
train = air.iloc[:-12]
test = air.iloc[-12:]
hw = ExponentialSmoothing(train, trend='add', seasonal='mul', seasonal_periods=12,
initialization_method='estimated').fit(optimized=True)
sar = SARIMAX(train, order=(0,1,1), seasonal_order=(0,1,1,12)).fit(disp=False)
e_hw = test.values - hw.forecast(12).values
e_sar = test.values - sar.forecast(12).values
dm, p = diebold_mariano(e_hw, e_sar)
print(f'Diebold-Mariano (HW vs SARIMA): stat={dm:.3f}, p={p:.4f}')
Diebold-Mariano (HW vs SARIMA): stat=-1.575, p=0.1154
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/statsmodels/tsa/base/tsa_model.py:480: ValueWarning: No frequency information was provided, so inferred frequency MS will be used.
self._init_dates(dates, freq)
When to Use Which#
SES: No trend/season; maximum simplicity
Holt-Winters: Clear trend+season; transparent parameters
ARIMA/SARIMA: Complex autocorrelation; formal inference
Structural: Interpretable components; missing data; calendar effects
M-competitions (M1–M5) showed exponential smoothing often matches ARIMA; combinations frequently win.
Key Takeaways#
Out-of-sample matters: In-sample AIC can mislead; always hold out recent data.
No universal winner: Best model depends on DGP, horizon, and series length.
DM test: Formal comparison of forecast accuracy; use with caution at small sample sizes.
Looking ahead: Neural forecasters (N-BEATS, TFT), Prophet, and gradient boosting extend this toolkit in future projects.
Navigation: ← Previous: State-Space Models