Add effective_sample_size() and document that overlapping backtest windows are not independent - #3176
Conversation
…s are not independent backtest() reduces per-window metrics with np.nanmean. When stride is smaller than forecast_horizon the windows overlap, so those per-window scores are strongly autocorrelated and the window count badly overstates how much the backtest pins down. effective_sample_size() reports how many independent observations a correlated sample is worth, using the autocorrelation sum truncated by Geyer's initial positive sequence rule. It sits next to _bartlett_formula, which already corrects ACF confidence bands for the same reason. The backtest() docstring now points at it from the reduction parameter.
|
Hi @ipezygj and thanks for the PR. Usually, we prefer to first open an issue before a PR so we can discuss whether we will address the issue or not. Having said that, I'm not sure I quite follow what this PR is trying to solve. The user is free to chose For example if our use case was to predict the next 7 days each day (with daily frequency), then |
|
Thanks for looking, and you're right — I made the claim too broadly. Overlapping windows are the correct simulation of "predict the next 7 days, each day", nothing about The narrower claim I do want to defend is this: the number of windows Setup:
Two things in that table:
Substituting Where this touches the library rather than a user's own arithmetic: There's also precedent inside So the PR is not claiming Repro script (runs in ~70s)import numpy as np
from darts import TimeSeries
from darts.metrics import mae
from darts.models import LinearRegressionModel
from darts.utils.statistics import effective_sample_size
N, LAGS, HORIZON, SEASON, R = 1000, 24, 12, 24, 800
def realisation(seed):
rng = np.random.default_rng(seed)
t = np.arange(N)
noise = np.zeros(N)
for i in range(1, N):
noise[i] = 0.6 * noise[i - 1] + rng.normal(0, 1)
return TimeSeries.from_values(10 * np.sin(2 * np.pi * t / SEASON) + noise)
def window_scores(model, series, stride):
return np.asarray(
model.backtest(
series, start=0.4, forecast_horizon=HORIZON, stride=stride, metric=mae,
reduction=None, retrain=False, last_points_only=False, verbose=False,
),
dtype=float,
).ravel()
model = LinearRegressionModel(lags=LAGS)
model.fit(realisation(seed=0))
for stride in (1, HORIZON):
means, naive, ess = [], [], []
for r in range(R):
s = window_scores(model, realisation(1000 + r), stride)
sd = s.std(ddof=1)
means.append(s.mean())
naive.append(sd / np.sqrt(s.size))
ess.append(sd / np.sqrt(effective_sample_size(s)))
means, naive, ess = map(np.asarray, (means, naive, ess))
truth, true_se = means.mean(), means.std(ddof=1)
cover = lambda se: np.mean(np.abs(means - truth) <= 1.96 * se)
print(f"stride={stride}: naive {naive.mean():.4f} | ESS {ess.mean():.4f} | "
f"true {true_se:.4f} | coverage naive {cover(naive):.1%} ESS {cover(ess):.1%}") |
What
backtest()reduces the per-window error scores withnp.nanmean. Whenstride < forecast_horizonthe evaluation windows overlap, so those scores are not independent — and the number of windows badly overstates how much the backtest actually pins down.This adds
darts.utils.statistics.effective_sample_size(), which says how many independent observations a correlated sample is worth, and a note onbacktest()'sreductionparameter pointing at it.Measured, on darts' own backtest()
Setup: a seasonal series of 400 points,
LinearRegressionModel(lags=24),forecast_horizon=12,metric=mae,reduction=None— 190 windows. Ground truth for coverage is the expected per-window MAE under the data-generating process, pooled over 60 independent series (11 400 windows), so it does not come from any interval being tested.The per-window scores are heavily autocorrelated:
A 95% interval built from them as if they were independent covers the truth 27.5% of the time:
std / sqrt(190)Two controls, to show the cause is the overlap and not the data:
stride=1), autocorrelated noisestride=forecast_horizon)Setting
stride = forecast_horizonremoves the autocorrelation almost entirely, and a white-noise series still shows it — so it comes from the windows sharing data, not from the series being autocorrelated.What this PR deliberately does not add
My first plan was a confidence-interval helper. The measurements above killed it: the best within-series correction I tried reaches 79% coverage against a nominal 95%. Shipping that as a confidence interval would be shipping a number that lies, in a library where people compare models on exactly these scores.
The reason is visible in the last table: even with the overlap removed, coverage only reaches 62.7%. The residual is variation between series realisations, and no amount of resampling inside a single series can see it. A backtest of one series cannot yield a valid interval for expected performance on a new series — that is a property of the setup, not of the estimator.
So this adds a diagnostic, not an interval, and the docstring says so in those words.
effective_sample_sizeanswers "how much is this backtest worth", which is answerable. It does not pretend to answer "what is the error bar on this model's future performance", which from one series is not.Changes
darts/utils/statistics.py:effective_sample_size(values, max_lag=None). Autocorrelation sum truncated at the first non-positive lag (Geyer's initial positive sequence rule). Usesstatsmodels.tsa.stattools.acf, already imported in that module — no new dependency. It sits beside_bartlett_formula, which already corrects ACF confidence bands for the same reason, so the module is not learning a new idea here.darts/models/forecasting/forecasting_model.py: a.. note::underreductionexplaining the overlap and pointing at the new function.darts/tests/utils/test_effective_sample_size.py: 15 tests.Testing
The two that carry the weight are known-value checks against formulas the implementation does not use:
n_eff ≈ n(n = 4000, within 15%);n_eff ≈ n(1-ρ)/(1+ρ), the analytic value, within 25%. The implementation sums estimated autocorrelations instead, so this is two routes meeting rather than one restated.Plus two integration tests on a real backtest: overlapping windows must come out worth less than a third of their count, and non-overlapping windows must keep more than half — the control from the table above, pinned.
I then ran the suite against seven deliberately broken versions to check the tests can fail:
Two things that mutation run found, both fixed in this branch:
-ar1(rho=0.9). Negating a series does not change its autocorrelation, so that case was testing nothing. It now usesar1(rho=-0.7).np.clip(..., 1.0, n)on the result. The upper bound is unreachable: the truncated sum runs over strictly positive autocorrelations, so the inflation factor is ≥ 1 by construction and the result can never exceedn. An unreachable guard reads as if it were doing work, so it is gone, replaced by a comment stating the invariant — and the test now asserts the invariant itself rather than the clamp.The remaining floor (
max(1.0, ...)) is not exercised by any test: I could not construct an input that reaches it (the most extreme small-n, strongly-correlated case I searched gaven_eff = 2.48atn = 4). It is not provably unreachable the way the upper bound is, so I kept it, but I would rather say plainly that it is untested than leave you to find that out.ruff checkandruff format --checkare clean on all three files.pytest darts/tests/utils/test_effective_sample_size.py→ 15 passed in 1.6s.I will add the
CHANGELOG.mdentry once this has a PR number.Notes
backtest()gains only documentation.utils/statistics.py, or under a different name, say the word.