Lesson 5.7.3 · 75 min

Probabilistic models: quantile loss, DeepAR and intervals

To size a position, set a stop or compute a VaR, the mean forecast is not enough: you need the distribution. This lesson predicts quantiles of tomorrow's return with a network trained on the quantile loss and with a DeepAR, compares them with the quantiles of a GJR-GARCH and of an oracle, and shows how to check that a 90% interval covers 90% on the days that matter too.

Fig. — Coverage of the 90% interval for tomorrow's return in 2018-2025, by terciles of days by true expected volatility: each row spans the lowest to the highest coverage of its three terciles (dashed line: 90%)
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

From a number to a distribution

Lessons 5.7.1 and 5.7.2 predicted a number: tomorrow's log RV. Many trading decisions need more. A VaR is a quantile (lesson 4.2.3); a sensible stop depends on how far the price can move on a normal day; the size of a position under volatility targeting, on the expected dispersion. A probabilistic model forecasts the distribution of the future value, or at least several of its quantiles, and prediction intervals come from there.

Two intervals that are often confused:

Confidence intervalPrediction interval
About whata parameter (the mean, a slope)the next observation
What it capturesthe uncertainty of the estimatethat uncertainty plus the noise of the data point itself
With more datait narrows toward zeroit narrows down to the conditional dispersion, no further
In this lesson—the 90% interval of tomorrow's return

The problem: forecast quantiles of tomorrow's return for the six assets of vol5_diario.parquet. The data README says it clearly: the conditional mean of the return is constant, so there is nothing to forecast in the mean. But the dispersion changes a lot and is predictable (it is a HAR on the logarithm of variance): a good probabilistic model has to widen the interval on volatile days and narrow it on calm ones. Synthetic data with a known truth: vol5_verdad.parquet gives the expected integrated variance of each day, the best possible scale data.

IN [01]python
import copy
import logging
import warnings
import numpy as np
import pandas as pd
import torch
from torch import nn
from scipy import stats

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
TAU = np.array([0.01, 0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 0.99])
I05, I95 = 1, 7                       # positions of 5% and 95%

v = pd.read_parquet("vol5_diario.parquet").merge(
    pd.read_parquet("vol5_verdad.parquet")[
        ["fecha", "activo", "iv_prevista"]], on=["fecha", "activo"])

def filas(g):
    x = np.log(g["rv_5min"])
    return pd.DataFrame({
        "fecha": g["fecha"], "activo": g["activo"],
        "r": g["rendimiento"], "d": x, "w": x.rolling(5).mean(),
        "m": x.rolling(22).mean(), "d_sig": x.shift(-1),
        "r_sig": g["rendimiento"].shift(-1),
        "iv_sig": g["iv_prevista"].shift(-1),
        "fecha_y": g["fecha"].shift(-1)})

h = (pd.concat([filas(g) for _, g in v.groupby("activo")])
     .dropna().reset_index(drop=True))
h["tramo"] = np.select(
    [h["fecha_y"] < "2016-01-01",
     (h["fecha"] >= "2016-01-01") & (h["fecha_y"] < "2018-01-01"),
     h["fecha"] >= "2018-01-01"], ["ent", "val", "prueba"], "purga")
ent, val, pru = (h["tramo"] == k for k in ("ent", "val", "prueba"))

escala = h[ent].groupby("activo")["r"].std()     # 2008-2015 only
centro = h[ent].groupby("activo")["d"].mean()
h["z"] = h["r"] / h["activo"].map(escala)
h["z_sig"] = h["r_sig"] / h["activo"].map(escala)
for c in ["d", "w", "m", "d_sig"]:
    h[c] = h[c] - h["activo"].map(centro)
y = h.loc[pru, "z_sig"].to_numpy()
print(h["tramo"].value_counts().to_string())
print(f"kurtosis of z_sig on the test set: {stats.kurtosis(y):.1f}")
OUT [01]Standardized returns, HAR features and date splits
tramo
prueba    12060
ent       11958
val        3012
purga        12
kurtosis of z_sig on the test set: 3.5

Each return is divided by the standard deviation of its asset in training (z), so the six share a scale and can be modeled together, as in the two previous lessons (ent, val and prueba are, as there, training, validation and test). The tails are fat: excess kurtosis of 3.5 on the test set, as in real daily returns. The features are those of the HAR (today's log RV and its 5- and 22-day means, centered per asset) plus today's return. Nine quantiles are forecast at once, from 1% to 99%; the 90% interval goes from the 5% quantile to the 95% one.

The quantile loss

To train a model that forecasts the τ\tau quantile, the pinball loss is used (Koenker and Bassett, 1978): with u=y−qu = y - q the error,

ρτ(u)=max⁡(τu,  (τ−1) u).\rho_\tau(u) = \max\big(\tau u,\; (\tau - 1)\, u\big).

If the data point lies above the forecast quantile (u>0u > 0), it costs τu\tau u; if it lies below, (1−τ)∣u∣(1 - \tau)|u|. For τ=0.05\tau = 0.05, falling short on the downside costs 19 times more than overshooting on the upside, and the minimum is reached when only 5% of the data lie below: at the quantile. With τ=0.5\tau = 0.5 it is half the absolute error and gives the median.

Fig. — Pinball loss ρτ(u) = max(τu, (τ − 1)u) for the 5%, 50% and 95% quantiles: the 5% quantile penalises sitting above the data 19 times more than below it

The way to check it is the one from lesson 5.7.1: find with autograd the constant that minimizes the mean loss and compare it with the empirical quantile.

IN [02]python
cripto = torch.tensor(h.loc[ent & (h["activo"] == "cripto"),
                            "z_sig"].to_numpy())

def pinball(q, y, tau):
    u = y - q
    return torch.maximum(tau * u, (tau - 1) * u).mean()

for tau in (0.05, 0.50, 0.95):
    c = torch.zeros((), dtype=torch.float64, requires_grad=True)
    opt = torch.optim.Adam([c], lr=0.05)
    paso = torch.optim.lr_scheduler.LambdaLR(
        opt, lambda k: 1 - k / 2000)   # rate that decays to zero
    for _ in range(2000):
        opt.zero_grad()
        pinball(c, cripto, tau).backward()
        opt.step()
        paso.step()
    print(f"τ = {tau:.2f}: pinball minimum {c.item():+.3f}, "
          f"empirical quantile {np.quantile(cripto.numpy(), tau):+.3f}")
OUT [02]The minimum of the pinball loss is the empirical quantile
τ = 0.05: pinball minimum -1.652, empirical quantile -1.650
τ = 0.50: pinball minimum +0.052, empirical quantile +0.052
τ = 0.95: pinball minimum +1.504, empirical quantile +1.504

They agree; the two thousandths at 5% are np.quantile's interpolation between two consecutive data points, a stretch where the pinball loss is almost flat. The learning rate that decays to zero is not decoration: the pinball gradient moves in jumps (each data point contributes −τ-\tau or 1−τ1 - \tau), and with a fixed rate Adam oscillates around the minimum with an amplitude of the order of that rate. The mean pinball loss over several quantiles is also a proper scoring rule (Gneiting and Raftery, 2007): in expectation, no model scores better than the one that gives the true distribution. That is why it serves to compare probabilistic models, and coverage alone does not: an interval can cover 90% while being useless, as will be seen.

The benchmarks: unconditional, HAR scale, oracle and GARCH

Before any network, four ways of forecasting quantiles that set the scale:

  • Unconditional: the empirical quantiles of z_sig in training, the same every day.
  • HAR scale: tomorrow's volatility forecast with the RV HAR, σ^t=ex^t+1/2\hat\sigma_t = e^{\hat x_{t+1}/2}, times the empirical quantiles of the returns divided by it. It separates the scale (predictable) from the shape (stable).
  • Scale oracle: the same with the true expected integrated variance, iv_prevista. Nobody has it; it marks the floor.
  • GJR-GARCH(1,1) with Student's t (lesson 2.1.4), estimated per asset with arch on 2008-2015 and applied without re-estimation: its quantiles are μ+σt+1 tν−1(τ)\mu + \sigma_{t+1}\, t_\nu^{-1}(\tau), with the t standardized to unit variance.
IN [03]python
def cuantiles_por_escala(sigma, en):
    """sigma (all rows) times the empirical quantiles of
    z_sig / sigma in training."""
    forma = np.quantile(h.loc[ent, "z_sig"] / sigma[ent], TAU)
    return sigma[en].to_numpy()[:, None] * forma

def har_x(en):
    return np.c_[np.ones(en.sum()), h.loc[en, ["d", "w", "m"]]]

b = np.linalg.lstsq(har_x(ent), h.loc[ent, "d_sig"], rcond=None)[0]
sig_har = pd.Series(np.exp(0.5 * har_x(h["tramo"] != "") @ b))
sig_ora = np.sqrt(h["iv_sig"]) / h["activo"].map(escala)

Q = {"unconditional": np.tile(np.quantile(h.loc[ent, "z_sig"], TAU),
                              (pru.sum(), 1)),
     "HAR scale": cuantiles_por_escala(sig_har, pru),
     "oracle": cuantiles_por_escala(sig_ora, pru)}
print({k: q.shape for k, q in Q.items()})
OUT [03]Unconditional quantiles, HAR scale and oracle
{'unconditional': (12060, 9), 'HAR scale': (12060, 9), 'oracle': (12060, 9)}
IN [04]python
from arch import arch_model

q_garch = np.zeros((pru.sum(), len(TAU)))
param = {}
for a in sorted(h["activo"].unique()):
    r = v[v["activo"] == a].set_index("fecha")["rendimiento"] * 100
    modelo = arch_model(r, mean="Constant", vol="GARCH", p=1, o=1,
                        q=1, dist="t")
    res = modelo.fit(last_obs="2016-01-01", disp="off")
    # forecasts made on each date t >= 2018 for t + 1
    var = res.forecast(start="2018-01-01", horizon=1,
                       reindex=False).variance["h.1"]
    t_tip = modelo.distribution.ppf(TAU, res.params[["nu"]])
    filas_a = (h.loc[pru, "activo"] == a).to_numpy()
    sigma = np.sqrt(var.reindex(h.loc[pru, "fecha"][filas_a]))
    q_garch[filas_a] = ((res.params["mu"] + sigma.to_numpy()[:, None]
                         * t_tip) / 100 / escala[a])
    param[a] = res.params[["alpha[1]", "gamma[1]", "beta[1]", "nu"]]
Q["GJR-GARCH-t"] = q_garch
pd.DataFrame(param).T.round(3)
OUT [04]GJR-GARCH(1,1)-t with arch, no re-estimation on the test set
        alpha[1]  gamma[1]  beta[1]     nu
accion     0.000     0.045    0.964  3.431
bono       0.023     0.051    0.856  5.276
cripto     0.016     0.034    0.946  3.297
divisa     0.025     0.016    0.942  4.355
indice     0.015     0.021    0.962  4.806
oro        0.046    -0.046    0.886  4.907

With last_obs the model is estimated on the data before 2016; forecast(start=…) applies those parameters while filtering all the data and returns, for each date tt, the variance forecast for t+1t + 1, which is exactly what is needed: the row of test date tt with its target for tomorrow. Returns are in percent (multiplied by 100) because arch's optimizer converges better at that scale, and the ppf of its t distribution is already standardized to unit variance. The persistences (α+γ/2+β\alpha + \gamma/2 + \beta) are around 0.9-0.99 and the degrees of freedom between 3.3 and 5.3: fat tails in all of them.

A quantile network

The direct way to forecast quantiles with deep learning: a network with one output per quantile, trained with the sum of the pinball losses. It has a known problem, quantile crossing: nothing forces the 5% quantile to stay below the 10% one. The solution is to build it in: the network gives the first quantile and the jumps between consecutive quantiles, which go through softplus (always positive) and are accumulated.

IN [05]python
FE = ["d", "w", "m", "z"]
med, des = h.loc[ent, FE].mean(), h.loc[ent, FE].std()

def tensores(en):
    X = torch.tensor(((h.loc[en, FE] - med) / des).to_numpy(),
                     dtype=torch.float32)
    return X, torch.tensor(h.loc[en, "z_sig"].to_numpy(),
                           dtype=torch.float32)

datos = {k: tensores(en) for k, en in
         [("ent", ent), ("val", val), ("prueba", pru)]}
tau_t = torch.tensor(TAU, dtype=torch.float32)

def pinball_media(q, y):
    u = y[:, None] - q
    return torch.maximum(tau_t * u, (tau_t - 1) * u).mean()

class RedCuantiles(nn.Module):
    def __init__(self, n, k=len(TAU), oculta=32):
        super().__init__()
        self.red = nn.Sequential(
            nn.Linear(n, oculta), nn.ReLU(),
            nn.Linear(oculta, oculta), nn.ReLU(),
            nn.Linear(oculta, k))

    def forward(self, x):
        o = self.red(x)
        saltos = nn.functional.softplus(o[:, 1:])
        return torch.cat([o[:, :1],
                          o[:, :1] + torch.cumsum(saltos, dim=1)], 1)

def entrenar(modelo, perdida, datos, epocas=30, lr=1e-3,
             paciencia=5, lote=256, perdida_val=None):
    perdida_val = perdida_val or perdida
    (Xe, Ye), (Xv, Yv) = datos["ent"], datos["val"]
    opt = torch.optim.Adam(modelo.parameters(), lr=lr)
    gen = torch.Generator().manual_seed(0)
    mejor, sin_mejora = (np.inf, None, -1), 0
    for epoca in range(epocas):
        modelo.train()
        orden = torch.randperm(len(Xe), generator=gen)
        for i in range(0, len(Xe), lote):
            j = orden[i:i + lote]
            opt.zero_grad()
            perdida(modelo(Xe[j]), Ye[j]).backward()
            opt.step()
        modelo.eval()
        with torch.no_grad():
            pv = perdida_val(modelo(Xv), Yv).item()
        if pv < mejor[0]:
            mejor = (pv, copy.deepcopy(modelo.state_dict()), epoca)
            sin_mejora = 0
        else:
            sin_mejora += 1
            if sin_mejora >= paciencia:
                break
    modelo.load_state_dict(mejor[1])
    return mejor[2]

torch.manual_seed(0)
red_q = RedCuantiles(len(FE))
print("best epoch:", entrenar(red_q, pinball_media, datos))
red_q.eval()
with torch.no_grad():
    Q["quantile network"] = red_q(datos["prueba"][0]).double().numpy()
    q_val_red = red_q(datos["val"][0]).double().numpy()
cruces = (np.diff(Q["quantile network"], axis=1) <= 0).sum()
print("crossed quantiles on the test set:", cruces)
OUT [05]Monotone quantile network with pinball loss
best epoch: 27
crossed quantiles on the test set: 0

Zero crossings, by construction. The price of monotonicity is nil: the network can represent any set of ordered quantiles.

DeepAR: one distribution per step

DeepAR (Salinas, Flunkert and Gasthaus, 2017; Amazon) is a recurrent network that, at each step of the series, receives the previous value and the covariates and returns the parameters of a distribution for the next value. It is trained by maximizing the likelihood of what was observed at every step of the window, and it is used in two ways: for one step, reading the quantiles of the forecast distribution; for several, sampling a value, feeding it in as the input of the next step and repeating (Monte Carlo paths).

samplingz today and log RVLSTMPrevious stateμ, σ, νStudent's tQuantiles
Mermaid source
flowchart TB
  E[z today and log RV] --> R[LSTM]
  H[Previous state] --> R
  R --> P[μ, σ, ν]
  P --> D[Student's t]
  D --> C[Quantiles]
  D -. sampling .-> E

The differences from the quantile network: the shape of the distribution is chosen by the modeler (here a Student's t, which lets fat tails be learned through a parameter ν), and the extreme quantiles come from that shape, not from estimating each one separately with the few data points in the tail. The version of our own below uses 32-day windows, a 16-unit LSTM and log RV as a covariate; it is small so that it trains in seconds (in production, more units, more epochs and several seeds).

IN [06]python
LV = 32
h["xs"] = h["d"] / h.loc[ent, "d"].std()
act = h["activo"].to_numpy()
zz, xx = h["z"].to_numpy(np.float32), h["xs"].to_numpy(np.float32)
zs = h["z_sig"].to_numpy(np.float32)

def secuencias(en):
    """Windows of LV days (z and log RV) ending at each row of
    `en`, and the target of each step (the next day's z)."""
    fin = [i for i in np.flatnonzero(en.to_numpy())
           if i - LV + 1 >= 0 and act[i - LV + 1] == act[i]]
    S = np.stack([np.c_[zz[i - LV + 1:i + 1], xx[i - LV + 1:i + 1]]
                  for i in fin])
    Y = np.stack([zs[i - LV + 1:i + 1] for i in fin])
    return torch.tensor(S), torch.tensor(Y)

seq = {k: secuencias(en) for k, en in
       [("ent", ent), ("val", val), ("prueba", pru)]}
assert len(seq["prueba"][0]) == pru.sum()

class DeepAR(nn.Module):
    def __init__(self, entradas=2, oculta=16):
        super().__init__()
        self.lstm = nn.LSTM(entradas, oculta, batch_first=True)
        self.cabeza = nn.Linear(oculta, 3)

    def forward(self, s):                       # (n, LV, 2)
        o = self.cabeza(self.lstm(s)[0])        # (n, LV, 3)
        sigma = nn.functional.softplus(o[..., 1]) + 1e-3
        nu = 2 + nn.functional.softplus(o[..., 2])
        return o[..., 0], sigma, nu

def nll(salida, y):                             # all steps
    mu, sigma, nu = salida
    t = torch.distributions.StudentT(nu, mu, sigma)
    return -t.log_prob(y).mean()

def nll_ultimo(salida, y):                      # only the last one
    return nll([p[:, -1] for p in salida], y[:, -1])

torch.manual_seed(0)
deepar = DeepAR()
print("best epoch:", entrenar(deepar, nll, seq, epocas=20,
                              paciencia=4, perdida_val=nll_ultimo))
deepar.eval()
with torch.no_grad():
    mu, sigma, nu = (p[:, -1].double().numpy()
                     for p in deepar(seq["prueba"][0]))
Q["DeepAR"] = (mu[:, None] + sigma[:, None]
               * stats.t.ppf(TAU[None, :], nu[:, None]))
print(f"mean ν on the test set: {nu.mean():.1f}")
OUT [06]A DeepAR of our own: LSTM and Student's t likelihood
best epoch: 17
mean ν on the test set: 4.6
  • Training uses the likelihood of the 32 steps of each window (that is how DeepAR trains); validation uses only that of the last one, which is the one used for forecasting and the only one whose target falls entirely in 2016-2017.
  • torch.distributions.StudentT gives the log-likelihood with a gradient; it has no quantile function, so the quantiles come from scipy.stats.t.ppf with the forecast parameters. With a normal, Normal.icdf would do it in torch.
  • The mean ν it learns is around 4-5, in line with the degrees of freedom the GARCH estimates.

The library version is neuralforecast.models.DeepAR, with the same data format as lesson 5.7.2, the distribution in the loss (DistributionLoss) and log RV as a historical covariate (hist_exog_list): it is only known up to today. Its intervals come from sampling the forecast distribution (DeepAR-lo-90, DeepAR-hi-90).

IN [07]python
from neuralforecast import NeuralForecast
from neuralforecast.models import DeepAR as DeepARNF
from neuralforecast.losses.pytorch import DistributionLoss, MQLoss

for n in ("pytorch_lightning", "lightning.pytorch",
          "lightning_fabric", "lightning", "neuralforecast"):
    logging.getLogger(n).setLevel(logging.ERROR)
warnings.filterwarnings("ignore", module="pytorch_lightning")
warnings.filterwarnings("ignore", module="lightning")

c_lrv = v[v["fecha"] < "2016-01-01"].groupby("activo")["rv_5min"] \
    .apply(lambda s: np.log(s).mean())
larga = pd.DataFrame({
    "unique_id": v["activo"], "ds": v.groupby("activo").cumcount(),
    "y": v["rendimiento"] / v["activo"].map(escala),
    "lrv": np.log(v["rv_5min"]) - v["activo"].map(c_lrv)})
nf = NeuralForecast(models=[DeepARNF(
    h=1, input_size=LV, lstm_n_layers=1, lstm_hidden_size=16,
    lstm_dropout=0.0, hist_exog_list=["lrv"],
    loss=DistributionLoss(distribution="StudentT", level=[90]),
    valid_loss=MQLoss(level=[90]), max_steps=300,
    val_check_steps=50, early_stop_patience_steps=3, batch_size=32,
    windows_batch_size=256, scaler_type=None, random_seed=0,
    accelerator="cpu", deterministic=True, logger=False,
    enable_progress_bar=False, enable_model_summary=False,
    enable_checkpointing=False)], freq=1)
n_pru = pru.sum() // 6
cv = nf.cross_validation(larga, val_size=val.sum() // 6 + 1,
                         test_size=n_pru, n_windows=None,
                         step_size=1, refit=False)
cv = cv.sort_values(["unique_id", "ds"])
assert np.allclose(cv["y"].to_numpy(), y, atol=1e-6)
cob_nf = ((cv["y"] >= cv["DeepAR-lo-90"])
          & (cv["y"] <= cv["DeepAR-hi-90"])).mean()
print(f"neuralforecast DeepAR: 90% coverage {cob_nf:.1%}")
OUT [07]neuralforecast's DeepAR with log RV as a covariate
neuralforecast DeepAR: 90% coverage 88.9%

The library version covers 88.9%: with 300 training steps it falls somewhat short, and its quantiles come from sampling the distribution, not from the formula. Tuning max_steps, the network size and the number of samples is the normal work with a library; the code of our own above serves to know what it does inside.

Calibration: total and conditional

Three measures for each model, on the 2018-2025 test set:

  • Coverage of the 90% interval and exceedances of the 5% quantile (the fraction of days below it; it should be 5%), with the p-value of the Kupiec test from lesson 4.2.3 (proportion of exceedances versus the promised one).
  • Mean pinball loss over the nine quantiles: the proper rule that summarizes the whole distribution.
  • Mean width of the 90% interval: at equal coverage, narrower is more informative.
IN [08]python
def kupiec(excesos, n, p):
    x = excesos
    pi = x / n
    lr = -2 * ((n - x) * np.log(1 - p) + x * np.log(p)
               - (n - x) * np.log(1 - pi) - x * np.log(pi))
    return stats.chi2.sf(lr, 1)

def resumen(q):
    u = y[:, None] - q
    exc = (y < q[:, I05]).sum()
    return {"coverage 90": np.mean((y >= q[:, I05])
                                   & (y <= q[:, I95])),
            "exceed. 5": exc / len(y),
            "p Kupiec": kupiec(exc, len(y), 0.05),
            "below 1%": np.mean(y < q[:, 0]),
            "pinball": np.maximum(TAU * u, (TAU - 1) * u).mean(),
            "width 90": np.mean(q[:, I95] - q[:, I05])}

tabla = pd.DataFrame({k: resumen(q) for k, q in Q.items()}).T
tabla.round(4)
OUT [08]Coverage, exceedances, Kupiec and pinball for each model
                  coverage 90  exceed. 5  p Kupiec  below 1%  pinball  width 90
unconditional          0.9040     0.0480    0.3129    0.0113   0.1771    3.1451
HAR scale              0.8999     0.0522    0.2626    0.0105   0.1737    3.0368
oracle                 0.8993     0.0516    0.4296    0.0116   0.1734    3.0311
GJR-GARCH-t            0.9021     0.0539    0.0523    0.0104   0.1755    3.0782
quantile network       0.9022     0.0478    0.2740    0.0177   0.1746    3.0642
DeepAR                 0.9011     0.0554    0.0076    0.0107   0.1741    3.0504

On total coverage they all look good: the six cover between 89.9% and 90.4%. Even the unconditional quantiles, which give the same interval on a calm day in 2019 as in March 2020. The pinball loss does rank them: the oracle is the best (0.1734), the HAR scale is almost level with it (0.1737), and DeepAR (0.1741), the quantile network (0.1746) and the GJR-GARCH (0.1755) sit between them and the unconditional quantiles (0.1771). The exceedances of the 5% quantile are close to 5% for all of them, although Kupiec rejects DeepAR's (5.5%, p = 0.008) and leaves the GARCH at the limit (p = 0.05); with 12,060 days, half a point is already detectable. And in the 1% tail, the quantile network leaves 1.8% of the days below: estimating an extreme quantile from its own data is hard. The way to see what fails is to split the test set by volatility bucket: the thirds of days according to the true expected variance (which no model uses; on real data, that day's realized RV).

IN [09]python
tercio = pd.qcut(sig_ora[pru].to_numpy(), 3,
                 labels=["low", "medium", "high"])
cob = pd.DataFrame({
    k: pd.Series((y >= q[:, I05]) & (y <= q[:, I95]))
       .groupby(np.asarray(tercio)).mean()
    for k, q in Q.items()}).T[["low", "medium", "high"]]
cob.round(3)
OUT [09]90% coverage by volatility third
                    low  medium   high
unconditional     0.957   0.915  0.839
HAR scale         0.905   0.904  0.891
oracle            0.887   0.904  0.906
GJR-GARCH-t       0.938   0.905  0.864
quantile network  0.915   0.905  0.886
DeepAR            0.905   0.905  0.893

The figure at the top draws it. The unconditional quantiles cover 95.7% of calm days and 83.9% of volatile ones: their 90% total is the average of an interval that is too wide and another that is too narrow, and it fails just when risk matters. The HAR scale, the quantile network and DeepAR stay within 1.5 points of 90% in the three thirds. The GJR-GARCH, which sees volatility only through squared returns, reacts more slowly: 93.8% on calm days and 86.4% on volatile ones. Not even the oracle nails the three thirds (88.7% in the low one): once the scale is known, the shape of standardized returns, estimated on 2008-2015, is not exactly the same in every bucket, and shocks (March 2020 in the index, November 2022 in crypto) cannot be forecast with the previous day's information.

Fig. — 90% interval forecast by the hand-written DeepAR for the crypto's daily return in the first half of 2020 (band) and observed returns (line; highlighted, the days outside)

The figure shows DeepAR's 90% interval for crypto in the first half of 2020, converted to returns in percent. The volatility shock of March (a jump in variance, not an extreme return that day) does not show on the day it happens: the interval widens as the RV registers it, from a mean width of 6.7 points in January to 11.8 in June. Thirteen of the 125 days fall outside (10.4%), spread over the whole half-year; four in January, when the interval is narrowest, and the largest, a +13.5% on February 19. That is what an honest conditional model does: it reacts to information, it does not guess it.

Conformal prediction: adjusting with the validation set

Conformal prediction corrects an interval so that it covers what it promises, without assumptions about the model. The version for quantiles (CQR; Romano, Patterson and Candès, 2019): on a calibration set the model has not seen, you measure how far each data point falls outside the interval, Ei=max⁡(q^lo−yi,  yi−q^hi)E_i = \max(\hat q_{lo} - y_i,\; y_i - \hat q_{hi}) (negative if it falls inside), and widen (or narrow) the interval with the ⌈(n+1)(1−α)⌉/n\lceil (n+1)(1-\alpha) \rceil / n quantile of those values. If calibration and test are exchangeable, coverage on the test set is guaranteed.

IN [10]python
yv = datos["val"][1].double().numpy()
lo, hi = q_val_red[:, I05], q_val_red[:, I95]
E = np.maximum(lo - yv, yv - hi)
n = len(E)
ajuste = np.quantile(E, np.ceil((n + 1) * 0.90) / n)
q_red = Q["quantile network"]
cob_val = np.mean((yv >= lo) & (yv <= hi))
cob_antes = np.mean((y >= q_red[:, I05]) & (y <= q_red[:, I95]))
cob_cqr = np.mean((y >= q_red[:, I05] - ajuste)
                  & (y <= q_red[:, I95] + ajuste))
print(f"validation coverage: {cob_val:.1%}; adjustment {ajuste:+.3f}")
print(f"test: before {cob_antes:.1%}, after {cob_cqr:.1%}")
OUT [10]CQR with the 2016-2017 validation set
validation coverage: 91.1%; adjustment -0.058
test: before 90.2%, after 89.1%

On the validation set the network covered somewhat more than 90%, so the adjustment is negative: it narrows the interval. On the test set, where the network already covered almost exactly 90%, the narrowed interval falls short. It is not a failure of the method: its guarantee requires validation and test to be alike, and 2016-2017 was a calmer period than 2018-2025. In time series, the adaptive variants (which recompute the adjustment with a rolling window of recent errors) come closer to the promise. The general lesson: no statistical correction replaces measuring coverage in the period and the buckets in which the model is going to be used.

ModelWhat it forecastsTailsCost
Unconditionalfixed quantilesempiricalnone; useless for risk
HAR scale + empirical shapetomorrow's σstandardized empiricalone regression
GJR-GARCH-ttomorrow's σStudent's tone estimation per asset
Quantile networkeach quantilelearned, poor at 1%a small network
DeepARμ, σ, ν per stepStudent's ta recurrent network

Exercises

  1. Remove the monotone parameterization from the quantile network (have forward return self.red(x) as is), train it the same way and count how many pairs of consecutive quantiles cross on the test set.
Solution
IN [11]python
class RedLibre(RedCuantiles):
    def forward(self, x):
        return self.red(x)

torch.manual_seed(0)
libre = RedLibre(len(FE))
entrenar(libre, pinball_media, datos)
libre.eval()
with torch.no_grad():
    q_libre = libre(datos["prueba"][0]).double().numpy()
cruzados = np.diff(q_libre, axis=1) <= 0
print("crossed pairs:", cruzados.sum(), "of", cruzados.size)
print("days with some crossing:", f"{cruzados.any(axis=1).mean():.1%}")
print("pinball:", round(resumen(q_libre)["pinball"], 4),
      "monotone:", round(tabla.loc["quantile network", "pinball"], 4))
OUT [11]Quantile network without monotonicity
crossed pairs: 0 of 96480
days with some crossing: 0.0%
pinball: 0.174 monotone: 0.1746

With these data and this network there is no crossing at all, and the pinball loss is almost the same (0.1740 versus 0.1746): the quantiles of a standardized return are well separated and the network learns to order them. Crossings appear with more quantiles closer together, less data or extrapolation to extreme features. The monotone parameterization costs nothing and removes the problem; fixing it afterward (sorting the outputs) also works, but it hides the fact that the network had learned something incoherent.

  1. Repeat the table of 90% coverage by volatility third for crypto only, and compare the HAR scale with the GJR-GARCH-t. Which one adapts better and why?
Solution
IN [12]python
es_cripto = (h.loc[pru, "activo"] == "cripto").to_numpy()
t_c = pd.qcut(sig_ora[pru].to_numpy()[es_cripto], 3,
              labels=["low", "medium", "high"])
for k in ("HAR scale", "GJR-GARCH-t", "DeepAR"):
    q, yc = Q[k][es_cripto], y[es_cripto]
    dentro = pd.Series((yc >= q[:, I05]) & (yc <= q[:, I95]))
    print(f"{k:<12}", dentro.groupby(np.asarray(t_c)).mean()
          .reindex(["low", "medium", "high"]).round(3).to_dict())
OUT [12]Coverage by third for crypto
HAR scale    {'low': 0.887, 'medium': 0.918, 'high': 0.901}
GJR-GARCH-t  {'low': 0.936, 'medium': 0.936, 'high': 0.882}
DeepAR       {'low': 0.897, 'medium': 0.912, 'high': 0.897}

The HAR scale uses the 5-minute RV, a much more precise measure of today's volatility than a single day's squared return, which is all the GARCH sees. That is why it reacts sooner to level shifts (crypto has one in 2019 and frequent jumps) and its coverage varies less across thirds (from 88.7% to 91.8%, versus 88.2-93.6% for the GARCH, which also over-covers on calm and medium days). The exact difference depends on the asset; the general point is that a daily risk model improves more with better volatility data (intraday RV) than with a more complex architecture.

  1. A colleague proposes measuring the quality of the 90% interval with total coverage alone and choosing the model whose coverage is closest to 90%. Which model would they choose with the lesson's table, and what would you tell them?
Solution

With the lesson's table, the unconditional quantiles come close to 90% and could end up chosen: it is the model that gives the same interval every day. Total coverage is a necessary condition, not a sufficient one. Three more things have to be asked of it: conditional coverage (by volatility third, by asset, by regime), independence of exceedances (that they do not come in clusters, the Christoffersen test of 4.2.3) and a proper scoring rule (the mean pinball loss, or the CRPS if the whole distribution is forecast) that rewards information: at equal calibration, the narrowest interval is the best. With those criteria, the unconditional model comes last and the HAR scale, close to the oracle.

Each answer is folded under its question

Self-assessment

  1. Which constant value minimizes the mean pinball loss with τ = 0.05 over a sample?

    • AThe sample mean
    • BThe median
    • CThe empirical 5% quantile of the sample
    • DThe mean minus 1.645 standard deviations
    Show answer

    Answer C. The empirical 5% quantile of the sample

    The derivative of the loss with respect to the constant is the fraction of observations below it minus τ: it vanishes when 5% of the observations lie below. With τ = 0.5 it gives the median; the mean comes from squared error.

  2. Some unconditional quantiles (the same every day) cover 90.4% on the test set. Are they well calibrated?

    • AYes: they cover what they promise
    • BOnly on average: they cover 95.7% on calm days and 83.9% on volatile ones, just when risk matters
    • CNo: they should cover 100%
    • DYes, if the Kupiec test does not reject
    Show answer

    Answer B. Only on average: they cover 95.7% on calm days and 83.9% on volatile ones, just when risk matters

    Marginal coverage is not enough. A useful interval covers what it promises conditional on what is known today; the practical way to check it is to split the test set by volatility bucket (or by regime) and measure coverage in each one.

  3. Why is the quantile network parameterized as the first quantile plus a cumulative sum of positive values?

    • ASo that it trains faster
    • BBecause the pinball loss requires it
    • CSo that the median is zero
    • DSo that the quantiles do not cross: with free outputs, the 5% quantile can end up above the 10% one on some days
    Show answer

    Answer D. So that the quantiles do not cross: with free outputs, the 5% quantile can end up above the 10% one on some days

    Training each quantile with its own loss does not guarantee the order. softplus makes the jumps between consecutive quantiles positive and the cumulative sum orders them by construction.

  4. The 1% quantile of the quantile network leaves 1.8% of the test days below it, and that of the GJR-GARCH-t, 1.0%. Why is it harder for the network?

    • ABecause a 1% quantile is learned from 1% of the data: with 12,000 rows, about 120 observations in the tail; the GARCH imposes a parametric form (Student's t) that extrapolates the tail using all the data
    • BBecause the network has few layers
    • CBecause the GARCH sees the future
    • DBecause the pinball loss does not work in the tails
    Show answer

    Answer A. Because a 1% quantile is learned from 1% of the data: with 12,000 rows, about 120 observations in the tail; the GARCH imposes a parametric form (Student's t) that extrapolates the tail using all the data

    Tails are few observations. A model with a parametric tail shape, or a network that forecasts the parameters of a distribution (DeepAR), suffers less than one that estimates each extreme quantile separately.

  5. The conformal adjustment with the 2016-2017 validation set takes the quantile network's coverage from 90.2% to 89.1% on the test set. Why does it not give exactly 90%?

    • ABecause the code is wrong
    • BBecause there is little test data
    • CBecause the guarantee of conformal prediction requires validation and test to be exchangeable, and a financial series changes regime: 2016-2017 was a calmer period than 2018-2025
    • DBecause conformal prediction only works with linear models
    Show answer

    Answer C. Because the guarantee of conformal prediction requires validation and test to be exchangeable, and a financial series changes regime: 2016-2017 was a calmer period than 2018-2025

    Conformal prediction is a guarantee without assumptions about the model, but not without assumptions about the data. In time series, its adaptive variants recompute the adjustment over a rolling window.

Key points

  • The pinball loss ρτ(u) = max(τu, (τ − 1)u) is minimized at the τ quantile. It is a proper scoring rule: it rewards getting the distribution right, not just covering.
  • Total coverage is misleading: unconditional quantiles cover 90.4% overall, but 83.9% on the most volatile third of days. Calibration is measured conditionally (by volatility bucket).
  • With almost-HAR volatility data, the strongest benchmark is simple: the HAR σ times the empirical quantiles of standardized returns. It matches the oracle and beats the GARCH, the quantile network and DeepAR.
  • A quantile network needs monotonicity by construction and struggles in the extreme tails; DeepAR forecasts the parameters of a distribution per step and its tails come from the chosen shape (here, Student's t).
  • Conformal prediction corrects the width with the validation set, but in time series its guarantee depends on validation and test being alike.

Resources