H, SEASON = 14, 7
clean = SeasonalGenerator(
engine='polars', min_length=200, max_length=200, freq='D',
seasonality_period=SEASON, seasonality_amplitude=8.0, base_level=100.0,
noise_level=2.0, seed=11,
).generate(n_series=40)
rng = np.random.default_rng(0)
ids = clean['unique_id'].unique(maintain_order=True).to_list()
series = [clean.filter(pl.col('unique_id') == u)['y'].to_numpy() for u in ids]
dates = clean.filter(pl.col('unique_id') == ids[0])['ds'].to_list()
def trend_seasonal_forecast(train, h, season):
"""Least-squares trend + per-position seasonal means (outlier-sensitive)."""
n = len(train)
t = np.arange(n)
slope, intercept = np.polyfit(t, train, 1)
resid = train - (slope * t + intercept)
seasonal = np.array([resid[k::season].mean() for k in range(season)])
fut = np.arange(n, n + h)
return slope * fut + intercept + seasonal[fut % season]
# `mase` takes its scale from train_df. Passing the CLEAN training frame, the
# same one at every contamination level, is what keeps the denominator honest:
# scaling by the contaminated series would inflate it and make error appear to
# fall as noise is added.
clean_train_df = pl.DataFrame(
{
'unique_id': [uid for uid in ids for _ in dates[:-H]],
'ds': [d for _ in ids for d in dates[:-H]],
'y': np.concatenate([y[:-H] for y in series]),
}
)
levels = [0.0, 0.02, 0.05, 0.08, 0.12, 0.16]
curve = []
for contamination in levels:
forecasts = []
for y in series:
train, test = y[:-H].copy(), y[-H:]
k = int(round(contamination * len(train)))
if k:
idx = rng.choice(len(train), size=k, replace=False)
train[idx] += rng.choice([-1, 1], size=k) * rng.uniform(15, 35, size=k)
forecasts.append(trend_seasonal_forecast(train, H, SEASON))
scored = mase(
pl.DataFrame(
{
'unique_id': [uid for uid in ids for _ in range(H)],
'ds': [d for _ in ids for d in dates[-H:]],
'y': np.concatenate([y[-H:] for y in series]),
'forecast': np.concatenate(forecasts),
}
),
models=['forecast'],
seasonality=SEASON,
train_df=clean_train_df,
)
curve.append(float(scored['forecast'].mean()))
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot([c * 100 for c in levels], curve, marker='o', color='crimson')
ax.set(xlabel='% of training points corrupted', ylabel='mean MASE on clean holdout',
title='Forecast error vs training contamination')
ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
print('MASE at 0% / 16% contamination: '
f'{curve[0]:.3f} / {curve[-1]:.3f}')