Transformer-based bias correction & extreme-event calibration for S2S forecasts¶

This notebook trains a Space-Time Transformer that post-processes a subseasonal-to-seasonal (S2S) ensemble forecast: it removes systematic and state-dependent bias, calibrates the ensemble spread, and sharpens extremes (heatwaves / cold snaps). It is benchmarked against operational baselines (mean/variance debiasing, empirical quantile mapping) with the standard deterministic and probabilistic skill scores used in S2S verification: RMSE, ACC, CRPS, CRPSS, Brier skill.

Everything below runs top-to-bottom on a physically-motivated synthetic dataset (no downloads, no GPU). The identical pipeline runs on real ERA5 + WeatherBench2 S2S forecasts by swapping one loader — see the Real data section at the end.

In [1]:
import sys, os
sys.path.insert(0, os.path.abspath("../src"))   # run from repo without installing
import numpy as np
import matplotlib.pyplot as plt

from s2s_bc.config import SyntheticConfig, ModelConfig, TrainConfig
from s2s_bc.data import generate_s2s_dataset, temporal_split
from s2s_bc.baselines import MeanVarDebias, QuantileMapping, GPDTailModel
from s2s_bc.models import SpaceTimeTransformer
from s2s_bc.eval import Trainer
from s2s_bc.metrics import (rmse, anomaly_correlation, crps_ensemble, crps_skill_score,
                            climatological_crps, spread_error_ratio, exceedance_probability,
                            brier_skill_score, reliability_curve, rank_histogram)
np.set_printoptions(precision=3, suppress=True)

1. A synthetic S2S hindcast with realistic error structure¶

The generator injects the four error structures real dynamical S2S systems exhibit and that post-processing must remove: a lead-dependent additive bias, conditional (amplitude) bias that damps anomalies and depends on the large-scale ENSO state, tail compression (extremes under-forecast), and ensemble under-dispersion. Verification is done on anomalies (seasonal cycle removed), as is standard for S2S skill.

In [2]:
ds = generate_s2s_dataset(SyntheticConfig())
train_idx, test_idx = temporal_split(ds, 0.70)
print("forecast ensemble (N, M, T, H, W):", ds.fc.shape)
print("verifying truth   (N,    T, H, W):", ds.truth.shape)
print(f"train cases: {train_idx.size}   test cases: {test_idx.size}   leads (weeks): {[int(x) for x in ds.leads]}")
forecast ensemble (N, M, T, H, W): (514, 16, 6, 16, 16)
verifying truth   (N,    T, H, W): (514, 6, 16, 16)
train cases: 360   test cases: 154   leads (weeks): [1, 2, 3, 4, 5, 6]
In [3]:
# Diagnose the raw model: ACC decays with lead, spread < error at every lead (under-dispersed)
em, es, tr = ds.ens_mean(), ds.ens_std(), ds.truth
print(f"{'lead':>4} {'RMSE':>7} {'ACC':>6} {'spread/RMSE':>12}")
for j, l in enumerate(ds.leads):
    r = rmse(em[:, j], tr[:, j]); a = anomaly_correlation(em[:, j], tr[:, j], axis=(0, 1, 2))
    sp = spread_error_ratio(ds.fc[:, :, j], tr[:, j], member_axis=1)[2]
    print(f"{l:>4} {r:>7.3f} {a:>6.3f} {sp:>12.2f}")
lead    RMSE    ACC  spread/RMSE
   1   1.562  0.864         0.51
   2   1.891  0.762         0.51
   3   2.279  0.642         0.50
   4   2.565  0.582         0.52
   5   2.998  0.465         0.50
   6   3.404  0.391         0.49
In [4]:
# An extreme case: the raw ensemble mean compresses the warm anomaly the truth shows
j = 3; case = int(np.argmax(np.abs(tr[:, j]).reshape(tr.shape[0], -1).max(1)))
fig, ax = plt.subplots(1, 2, figsize=(7, 3.2))
v = np.abs(tr[case, j]).max()
for a, (name, fld) in zip(ax, [("Truth", tr[case, j]), ("Raw ensemble mean", em[case, j])]):
    im = a.imshow(fld, cmap="RdBu_r", vmin=-v, vmax=v, origin="lower"); a.set_title(name); a.axis("off")
fig.colorbar(im, ax=ax, fraction=0.025, label="anomaly (K)"); plt.show()
No description has been provided for this image

2. Classical baselines¶

MeanVarDebias removes the additive bias and inflates spread to match the error (fixes dispersion). QuantileMapping (EQM) maps the forecast CDF onto the observed CDF per gridpoint and lead (fixes the marginal distribution and tails). Each fixes part of the problem — neither fixes all of it.

In [5]:
fc_tr, ob_tr = ds.fc[train_idx], ds.truth[train_idx]
fc_te, ob_te = ds.fc[test_idx], ds.truth[test_idx]

mvd = MeanVarDebias().fit(fc_tr, ob_tr)
qm  = QuantileMapping().fit(fc_tr, ob_tr)
corrected = {"RAW": fc_te, "MeanVarDebias": mvd.transform(fc_te), "QuantileMapping": qm.transform(fc_te)}

Extreme-value tails (GPD)¶

The raw ensemble badly under-predicts rare heatwaves. A peaks-over-threshold Generalised Pareto fit gives a smooth, extrapolatable tail and physically meaningful return levels.

In [6]:
g = GPDTailModel(0.90).fit(ob_tr.ravel())
print(f"GPD tail: threshold u={g.u:.2f} K, shape xi={g.xi:.3f}, scale sigma={g.sigma:.3f}")
for yr in (2, 5, 10, 20, 50):
    print(f"  {yr:>2}-year return level: {g.return_level(1/(yr*len(ds.leads))):.2f} K")
GPD tail: threshold u=3.69 K, shape xi=-0.208, scale sigma=1.592
   2-year return level: 3.97 K
   5-year return level: 5.25 K
  10-year return level: 6.07 K
  20-year return level: 6.78 K
  50-year return level: 7.57 K

3. Train the Space-Time Transformer¶

Each (lead, spatial-patch) pair is a token; full space-time self-attention lets a token use the whole field and all leads to infer the large-scale state driving the conditional bias. The head emits a per-gridpoint mean correction and a per-token spread inflation. Training minimises a tail-weighted CRPS + mean-anchoring MSE (probabilistic calibration without sacrificing deterministic skill). The NumPy model trains here; a parity PyTorch version lives in s2s_bc.models.transformer_torch for GPU.

In [7]:
model = SpaceTimeTransformer(ds.fc.shape[2:], ModelConfig(d_model=64, depth=2, n_heads=4, patch=4))
print("trainable parameters:", model.n_params())
hist = Trainer(model, TrainConfig(epochs=16, batch_cases=32, lr=2.5e-3)).fit(
    fc_tr, ob_tr, fc_te, ob_te, verbose=True)
corrected["Transformer"] = model.correct_ensemble(fc_te)
trainable parameters: 71185
epoch  0/16 loss=4.0568  val_CRPSS=+0.087 (2.1s)
epoch  1/16 loss=3.9321  val_CRPSS=+0.108 (1.9s)
epoch  2/16 loss=3.8756  val_CRPSS=+0.108 (1.9s)
epoch  3/16 loss=3.7644  val_CRPSS=+0.124 (1.9s)
epoch  4/16 loss=3.7441  val_CRPSS=+0.133 (1.9s)
epoch  5/16 loss=3.6741  val_CRPSS=+0.126 (1.9s)
epoch  6/16 loss=3.6599  val_CRPSS=+0.145 (1.9s)
epoch  7/16 loss=3.6380  val_CRPSS=+0.138 (1.9s)
epoch  8/16 loss=3.5949  val_CRPSS=+0.147 (1.9s)
epoch  9/16 loss=3.5822  val_CRPSS=+0.131 (1.9s)
epoch 10/16 loss=3.5505  val_CRPSS=+0.144 (2.0s)
epoch 11/16 loss=3.5321  val_CRPSS=+0.143 (1.9s)
epoch 12/16 loss=3.5272  val_CRPSS=+0.145 (1.9s)
epoch 13/16 loss=3.5201  val_CRPSS=+0.127 (1.9s)
epoch 14/16 loss=3.5153  val_CRPSS=+0.140 (1.9s)
epoch 15/16 loss=3.5008  val_CRPSS=+0.142 (1.9s)
In [8]:
ep = np.array(hist["epoch"]); plt.figure(figsize=(6, 3.6))
plt.plot(ep, hist["train_loss"], label="train loss")
plt.plot(ep, 1 - np.array(hist["val_crps"]) / hist["crps_val_raw"], "o-", label="val CRPSS vs raw")
plt.xlabel("epoch"); plt.legend(); plt.title("Training convergence"); plt.grid(alpha=.3); plt.show()
No description has been provided for this image

4. Benchmark — deterministic, probabilistic and extreme skill¶

In [9]:
c_raw = crps_ensemble(fc_te, ob_te, member_axis=1)
c_clim = np.broadcast_to(climatological_crps(ob_te, sample_axis=0)[None], c_raw.shape)
ths = {q: np.quantile(ob_tr, q/100, axis=0) for q in (90, 95, 98)}

print(f"{'method':16} {'RMSE':>6} {'ACC':>6} {'CRPS':>6} {'CRPSS_raw':>10} "
      f"{'CRPSS_clim':>11} {'sprd/err':>9} {'BSS_q90':>8}")
for m, fc in corrected.items():
    c = crps_ensemble(fc, ob_te, member_axis=1)
    p90 = exceedance_probability(fc, ths[90][None], member_axis=1)
    bss = brier_skill_score(p90, (ob_te > ths[90][None]).astype(float))
    print(f"{m:16} {rmse(fc.mean(1), ob_te):>6.3f} "
          f"{anomaly_correlation(fc.mean(1), ob_te, axis=(0,1,2,3)):>6.3f} {c.mean():>6.3f} "
          f"{crps_skill_score(c, c_raw):>+10.3f} {crps_skill_score(c, c_clim):>+11.3f} "
          f"{spread_error_ratio(fc, ob_te, member_axis=1)[2]:>9.2f} {bss:>+8.3f}")
method             RMSE    ACC   CRPS  CRPSS_raw  CRPSS_clim  sprd/err  BSS_q90
RAW               2.510  0.582  1.468     +0.000      +0.084      0.50   -0.020
MeanVarDebias     2.508  0.580  1.328     +0.095      +0.171      1.00   +0.040
QuantileMapping   2.426  0.616  1.426     +0.028      +0.110      0.53   +0.025
Transformer       2.334  0.625  1.251     +0.147      +0.219      1.22   +0.086
In [10]:
# CRPSS vs lead -- the transformer's advantage grows with lead time
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
col = {"MeanVarDebias": "#2980b9", "QuantileMapping": "#27ae60", "Transformer": "#e74c3c"}
for m in ("MeanVarDebias", "QuantileMapping", "Transformer"):
    cs = [crps_skill_score(crps_ensemble(corrected[m][:, :, j], ob_te[:, j], member_axis=1),
                           crps_ensemble(fc_te[:, :, j], ob_te[:, j], member_axis=1)) for j in range(len(ds.leads))]
    ax[0].plot(ds.leads, cs, "-o", color=col[m], label=m)
ax[0].axhline(0, color="k", lw=.8); ax[0].set_xlabel("lead (weeks)"); ax[0].set_ylabel("CRPSS vs raw")
ax[0].set_title("Probabilistic skill gain by lead"); ax[0].legend(); ax[0].grid(alpha=.3)

# reliability for the q90 heatwave event
ob90 = (ob_te > ths[90][None]).astype(float)
ax[1].plot([0, 1], [0, 1], "k--", lw=1)
for m in corrected:
    p = exceedance_probability(corrected[m], ths[90][None], member_axis=1)
    fcb, obf, _ = reliability_curve(p, ob90, 10); ax[1].plot(fcb, obf, "-o", ms=4,
        color=col.get(m, "#7f8c8d"), label=m)
ax[1].set_xlabel("forecast probability"); ax[1].set_ylabel("observed frequency")
ax[1].set_title("Reliability: P(anomaly > q90)"); ax[1].legend(fontsize=8); ax[1].grid(alpha=.3)
plt.tight_layout(); plt.show()
No description has been provided for this image

5. Switching to real reanalysis (ERA5 + WeatherBench2)¶

The loaders in s2s_bc.data.real return the same container, so only the first line changes. ERA5 (ARCO-ERA5 / WeatherBench2) and the S2S forecasts are public, anonymous Zarr on Google Cloud — no Copernicus key. Install the optional stack with pip install -r requirements-full.txt.

from s2s_bc.data.real import build_real_dataset, RealDataConfig
ds = build_real_dataset(RealDataConfig(variable="2m_temperature",
                                       region=(20, 55, 230, 300),   # CONUS
                                       start="2000-01-01", end="2019-12-31"))
# ... identical baselines / transformer / metrics from here on.

The NASA GEOS-S2S path (the job's primary dataset) is wired via the IRI Data Library SubX endpoint in load_subx_geos_s2s. For large grids use the PyTorch model (s2s_bc.models.transformer_torch) on GPU with Dask-backed, chunked Zarr access.

Summary¶

A compact Space-Time Transformer, trained on a tail-weighted CRPS + MSE objective, beats the operational baselines on every aggregate score — RMSE, ACC, CRPS, CRPSS and moderate-extreme Brier skill — with its largest gains at the longest leads, exactly where S2S forecasts are weakest and post-processing matters most. The classical baselines remain strong, complementary references (quantile mapping for the marginal distribution, EVT for the far tail).