Verification and the evaluation protocol¶
A forecasting system that cannot honestly measure itself is a random number generator with good marketing. This page specifies the scores, the significance machinery, and the protocol that makes both meaningful. The decision procedure built on top — which method actually gets promoted — is model selection.
1. Point scores¶
Implemented in: metrics/deterministic.py
MAE is the promotion metric. Bias gets its own column rather than being folded into MAE, because a method can have unremarkable MAE while being systematically and correctably wrong — that column is what caught the defect recorded in ADR 0004.
Note the estimand mismatch this creates: MAE is minimized by the conditional median, RMSE by the conditional mean. Methods fitted by least squares and promoted on MAE are optimizing one functional and judged on another. See notation §5.
2. Probabilistic scores¶
Implemented in: metrics/probabilistic.py — the only module that touches scoringrules
Pinball loss¶
The proper scoring rule for a single quantile; minimized in expectation by the true \(\tau\)-quantile.
CRPS¶
The continuous ranked probability score is the integral of the Brier score over
all thresholds, and equivalently \(2\int_0^1 \rho_\tau\,d\tau\). The leaderboard
reports the energy form for an ensemble, evaluated on the sorted quantile
grid treated as members (crps_ensemble, via scoringrules).
A quantile-grid approximation also exists, and it is worth being precise about
because a common shorthand gets it wrong. crps_from_quantiles is not twice
the mean pinball loss — it is a weighted rectangle rule over probability levels:
with the first and last weights closed against the boundaries \(0\) and \(1\): \(w_1 = (\tau_1 + \tau_2)/2 - 0\) and \(w_m = 1 - (\tau_{m-1} + \tau_m)/2\). The two coincide only for an equally-spaced grid; the board reports the energy form. Levels must be strictly increasing within \((0,1)\) or the function raises.
Brier score and reliability¶
for PoP against binary occurrence. reliability_bins produces the calibration
table — forecast probability versus observed frequency over 10 bins — because a
probability scored only by MAE is a probability nobody is checking the
calibration of.
PIT and coverage¶
computed by interpolating truth into the quantile grid (clipped to \([0,1]\) at the
ends). A calibrated forecast has uniform PIT; the histogram's shape names the
failure — U-shaped means under-dispersed, hump-shaped over-dispersed, sloped
means biased. empirical_coverage reports the fraction of truths inside a
declared interval, checked against nominal.
3. Skill¶
Skill is always relative, and always relative to a named reference:
The default references are three:
| Reference | What beating it proves |
|---|---|
best_provider |
you beat the best single API, chosen per bucket by training MAE |
equal_weight |
the raw, ungrounded arithmetic mean — beating it proves grounding works |
damped_grounded_equal_weight |
grounded, climatology-damped equal weight — the strong baseline |
Three, not one, because each answers a different question, and "we beat the worst
provider" is not a claim worth making. Per-variable overrides live in
[promotion.references]; the leaderboard computes columns for the union of
defaults and overrides so a pinned skill_vs_equal_weight column never silently
changes meaning when someone edits config.
The floor every method must clear is the baseline set: persistence (the current
station reading held constant), climatology (ridge-regularized Fourier
regression of truth on month and hour), and best_provider.
4. Diebold–Mariano¶
Implemented in: metrics/dm.py::diebold_mariano
An MAE difference of 0.02 °C over 700 samples is noise. DM tests \(H_0\): equal expected loss, on the paired loss differentials \(d_t = \ell^A_t - \ell^B_t\).
Bartlett-kernel HAC variance. At multi-hour leads, consecutive forecast errors are serially correlated and the naive variance is far too small:
floored at 0 (a truncated HAC estimator can go negative).
Harvey–Leybourne–Newbold correction, with a Student-\(t(n-1)\) reference, because these slices have hundreds rather than millions of samples:
Guards. MIN_SAMPLES = 8; \(h \ge 1\) and \(h < n\) required. A zero variance
with a nonzero mean yields \(\pm\infty\) and \(p = 0\) — the honest reading of "these
two methods differ identically on every case".
\(n\) is printed next to every leaderboard row, so a thin daily slice announces its own lack of power rather than hiding behind a p-value.
DM is a test, not a decision procedure
A leaderboard runs dozens of these every night, on overlapping data, and re-reads them daily. Taking the argmin and calling it the winner commits three errors at once — multiplicity, optional stopping, and the winner's curse. What DM feeds is model selection; it is no longer the arbiter itself.
5. Rolling-origin protocol¶
Implemented in: backtest/splits.py, backtest/engine.py
At each origin \(O\):
The subtlety is entirely in the first line. It is not \(T_t \le O\). A row issued yesterday about tomorrow has an issue time in the past and a truth that has not happened yet; training on it is leakage.
Fold plans are computed in epoch microseconds. Both expanding and rolling (180-day) windows are supported and reported side by side, because they answer different questions: expanding asks what do you know?, rolling asks what have you learned lately?
6. Leakage: assumed present until proven absent¶
Four defences, all executable:
-
Fold-plan invariants — property-based, via Hypothesis over randomized configurations: \(\max(\texttt{truth\_known\_at}[\text{train}]) \le O < \min(T[\text{test}])\), \(\text{train} \cap \text{test} = \emptyset\), rolling windows really are bounded.
-
The poisoning sentinel. For each fold, corrupt every truth value not yet knowable at that fold's origin (add \(10^6\)) and re-run the whole engine. Assert the fold's test predictions are bit-identical. If future truth reaches a model through any path — a feature, a fitted aggregate, a stateful blender — this test fails. It is the single most valuable test in the codebase, because it does not require anyone to have anticipated the leak.
-
Fresh instances. The registry stores factories, never instances; the engine constructs a new blender per fold. A stateful method (the online experts) cannot leak yesterday's weights into today's fit.
-
Feature audit. No column beginning
t__may reach a blender — enforced inForecastMatrix.__post_init__, so the illegal object cannot be constructed.
A fifth discipline sits in the dataset layer rather than the engine: the
causal QC variant. Issue-time obs__ features are filtered by
apply_causal_qc, which applies bounds immediately, flags flatlines only once
their duration crosses the threshold, and omits the isolated-spike rule
entirely — because that rule needs a later sample to fire. See
truth QC.
7. The provenance wall¶
Backfilled ("synthetic") and live rows are never pooled. They live in
separate files, carry a source_kind tag, and any attempt to mix them raises
MixedProvenanceError. The provenance is a filesystem property — it is in the
matrix filename — not merely a runtime check.
This is not fastidiousness. The synthetic archive contains three open NWP models at 24-hour-multiple leads; the live archive contains eleven commercial providers at native cadence. A leaderboard built on the former says nothing about the latter, and a number quietly averaging the two would be worse than no number.
Identity is tracked by three fingerprints — dataset_fingerprint (byte-level,
over stably-sorted parquet), config_fingerprint, and code_identity — and
serving refuses evidence that does not match the current ones. See
ADR 0005.
8. Self-verification¶
Implemented in: reports/verification.py
The backtest can only measure the backtest. Serving has its own code path, its own snapshot construction, and its own failure modes, so the system scores what it actually served against truth as truth arrives.
verify_history joins served forecasts to subsequently-arrived truth and
computes live_mae, live_rmse, live_bias per
(product, variable, truth semantics, lead bucket, method, dataset fingerprint,
release id), with _MIN_SCORED = 5. compare_to_backtest places
(n-weighted) beside it. A persistent positive gap is serving-path drift — a bug no backtest can find, because the backtest is not the thing that is broken. The gap also drives the live demotion gate.