Lección 5.7.3 · 75 min
Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
Para dimensionar, poner un stop o calcular un VaR no basta con la previsión media: hace falta la distribución. Esta lección predice cuantiles del rendimiento de mañana con una red entrenada con la pérdida cuantílica y con un DeepAR, los compara con los cuantiles de un GJR-GARCH y de un oráculo, y enseña a comprobar que un intervalo del 90 % cubre el 90 % también en los días que importan.
- Módulo
- 5.7 · Deep learning con PyTorch
- Duración
- 75 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
De un número a una distribución
Las lecciones 5.7.1 y 5.7.2 predijeron un número: la log RV de mañana. Muchas decisiones de trading necesitan más. Un VaR es un cuantil (lección 4.2.3); un stop razonable depende de cuánto se puede mover el precio en un día normal; el tamaño de una posición con volatility targeting, de la dispersión esperada. Un modelo probabilístico prevé la distribución del valor futuro, o al menos varios de sus cuantiles, y de ahí salen intervalos de predicción.
Dos intervalos que se confunden a menudo:
| Intervalo de confianza | Intervalo de predicción | |
|---|---|---|
| Sobre qué | un parámetro (la media, una pendiente) | la próxima observación |
| Qué recoge | la incertidumbre de la estimación | esa incertidumbre más el ruido del propio dato |
| Con más datos | se estrecha hacia cero | se estrecha hasta la dispersión condicional, no más |
| En esta lección | — | el intervalo del 90 % del rendimiento de mañana |
El problema: prever cuantiles del rendimiento de mañana de los seis activos de vol5_diario.parquet. El README
de los datos lo dice claro: la media condicional del rendimiento es constante, así que no hay nada que prever en la
media. Pero la dispersión cambia mucho y es previsible (es un HAR en el logaritmo de la varianza): un buen modelo
probabilístico tiene que ensanchar el intervalo los días volátiles y estrecharlo los tranquilos. Datos sintéticos con
la verdad conocida: vol5_verdad.parquet da la varianza integrada esperada de cada día, el mejor dato de escala
posible.
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 # posiciones de 5 % y 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() # solo 2008-2015
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"curtosis de z_sig en la prueba: {stats.kurtosis(y):.1f}")tramo
prueba 12060
ent 11958
val 3012
purga 12
curtosis de z_sig en la prueba: 3.5Cada rendimiento se divide por la desviación de su activo en el entrenamiento (z), así que los seis comparten
escala y se pueden modelar juntos, como en las dos lecciones anteriores. Las colas son gruesas: curtosis en exceso de
3,5 en la prueba, como en los rendimientos diarios reales. Las features son las del HAR (log RV de hoy y
sus medias de 5 y 22 días, centradas por activo) más el rendimiento de hoy. Se prevén nueve cuantiles a la vez, del 1
al 99 %; el intervalo del 90 % va del cuantil 5 % al 95 %.
La pérdida cuantílica
Para entrenar un modelo que prevé el cuantil se usa la pérdida pinball (Koenker y Bassett, 1978): con el error,
Si el dato queda por encima del cuantil previsto (), cuesta ; si queda por debajo, . Para , quedarse corto por abajo cuesta 19 veces más que pasarse por arriba, y el mínimo se alcanza cuando solo el 5 % de los datos queda por debajo: en el cuantil. Con es la mitad del error absoluto y da la mediana.
La forma de comprobarlo es la de la lección 5.7.1: buscar con autograd la constante que minimiza la pérdida media y compararla con el cuantil empírico.
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) # tasa que baja a cero
for _ in range(2000):
opt.zero_grad()
pinball(c, cripto, tau).backward()
opt.step()
paso.step()
print(f"τ = {tau:.2f}: mínimo de la pinball {c.item():+.3f}, "
f"cuantil empírico {np.quantile(cripto.numpy(), tau):+.3f}")τ = 0.05: mínimo de la pinball -1.652, cuantil empírico -1.650
τ = 0.50: mínimo de la pinball +0.052, cuantil empírico +0.052
τ = 0.95: mínimo de la pinball +1.504, cuantil empírico +1.504Coinciden; los dos milésimos del 5 % son la interpolación de np.quantile entre dos datos consecutivos, un tramo
en el que la pinball es casi plana. La tasa de aprendizaje que baja a cero no es un adorno: el gradiente de la pinball va a saltos (cada dato
aporta o ), y con una tasa fija Adam oscila alrededor del mínimo con una amplitud del orden de esa
tasa. La pinball media sobre varios cuantiles es además una regla de
puntuación propia (Gneiting y Raftery, 2007): en esperanza, ningún modelo puntúa mejor que el que da la distribución
verdadera. Por eso sirve para comparar modelos probabilísticos, y la cobertura sola no: un intervalo puede cubrir el
90 % siendo inútil, como se verá.
Las referencias: incondicional, escala HAR, oráculo y GARCH
Antes de cualquier red, cuatro formas de prever cuantiles que marcan la escala:
- Incondicional: los cuantiles empíricos de
z_sigen el entrenamiento, los mismos todos los días. - Escala HAR: la volatilidad de mañana prevista con el HAR de la RV, , por los cuantiles empíricos de los rendimientos divididos por ella. Separa la escala (previsible) de la forma (estable).
- Oráculo de escala: lo mismo con la varianza integrada verdadera esperada,
iv_prevista. Nadie la tiene; marca el suelo. - GJR-GARCH(1,1) con t de Student (lección 2.1.4), estimado por activo con
archen 2008-2015 y aplicado sin reestimar: sus cuantiles son , con la t tipificada a varianza 1.
def cuantiles_por_escala(sigma, en):
"""sigma (todas las filas) por los cuantiles empíricos de
z_sig / sigma en el entrenamiento."""
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 = {"incondicional": np.tile(np.quantile(h.loc[ent, "z_sig"], TAU),
(pru.sum(), 1)),
"escala HAR": cuantiles_por_escala(sig_har, pru),
"oráculo": cuantiles_por_escala(sig_ora, pru)}
print({k: q.shape for k, q in Q.items()}){'incondicional': (12060, 9), 'escala HAR': (12060, 9), 'oráculo': (12060, 9)}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")
# previsiones hechas en cada fecha t >= 2018 para 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) 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.907Con last_obs el modelo se estima con los datos anteriores a 2016; forecast(start=…) aplica esos parámetros
filtrando todos los datos y devuelve, para cada fecha , la varianza prevista para , que es exactamente lo
que se necesita: la fila de la fecha de la prueba con su objetivo de mañana. Los rendimientos van en tanto por
ciento (multiplicados por 100) porque el optimizador de arch converge mejor con esa escala, y ppf de su
distribución t ya está tipificada a varianza 1. Las persistencias () rondan 0,9-0,99 y los
grados de libertad, entre 3,3 y 5,3: colas gruesas en todos.
Una red de cuantiles
La forma directa de prever cuantiles con deep learning: una red con una salida por cuantil, entrenada con la suma de
las pérdidas pinball. Tiene un problema conocido, el cruce de cuantiles: nada obliga a que el cuantil del 5 %
quede por debajo del del 10 %. La solución es construirlo: la red da el primer cuantil y los saltos entre
cuantiles consecutivos, que pasan por softplus (siempre positivos) y se acumulan.
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("mejor época:", entrenar(red_q, pinball_media, datos))
red_q.eval()
with torch.no_grad():
Q["red de cuantiles"] = red_q(datos["prueba"][0]).double().numpy()
q_val_red = red_q(datos["val"][0]).double().numpy()
cruces = (np.diff(Q["red de cuantiles"], axis=1) <= 0).sum()
print("cuantiles cruzados en la prueba:", cruces)mejor época: 27
cuantiles cruzados en la prueba: 0Cero cruces, por construcción. El precio de la monotonía es nulo: la red puede representar cualquier conjunto de cuantiles ordenados.
DeepAR: una distribución por paso
DeepAR (Salinas, Flunkert y Gasthaus, 2017; Amazon) es una red recurrente que, en cada paso de la serie, recibe el valor anterior y las covariables y devuelve los parámetros de una distribución para el valor siguiente. Se entrena maximizando la verosimilitud de lo observado en todos los pasos de la ventana, y se usa de dos formas: para un paso, leyendo los cuantiles de la distribución prevista; para varios, muestreando un valor, metiéndolo como entrada del paso siguiente y repitiendo (trayectorias de Monte Carlo).
Fuente Mermaid
flowchart TB E[z hoy y log RV] --> R[LSTM] H[Estado previo] --> R R --> P[μ, σ, ν] P --> D[t de Student] D --> C[Cuantiles] D -. muestreo .-> E
Las diferencias con la red de cuantiles: la forma de la distribución la elige quien modela (aquí una t de Student, que deja aprender colas gruesas con un parámetro ν), y los cuantiles extremos salen de esa forma, no de estimar cada uno por separado con los pocos datos de la cola. La versión propia de abajo usa ventanas de 32 días, un LSTM de 16 unidades y la log RV como covariable; es pequeña para que entrene en segundos (en producción, más unidades, más épocas y varias semillas).
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):
"""Ventanas de LV días (z y log RV) que terminan en cada fila de
`en`, y el objetivo de cada paso (el z del día siguiente)."""
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): # todos los pasos
mu, sigma, nu = salida
t = torch.distributions.StudentT(nu, mu, sigma)
return -t.log_prob(y).mean()
def nll_ultimo(salida, y): # solo el último
return nll([p[:, -1] for p in salida], y[:, -1])
torch.manual_seed(0)
deepar = DeepAR()
print("mejor época:", 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"ν medio en la prueba: {nu.mean():.1f}")mejor época: 17
ν medio en la prueba: 4.6- El entrenamiento usa la verosimilitud de los 32 pasos de cada ventana (así entrena DeepAR); la validación, solo la del último, que es el que se usa para prever y el único cuyo objetivo cae entero en 2016-2017.
torch.distributions.StudentTda la log-verosimilitud con gradiente; no tiene función cuantil, así que los cuantiles salen descipy.stats.t.ppfcon los parámetros previstos. Con una normal,Normal.icdflo haría en torch.- El ν medio que aprende ronda 4-5, en línea con los grados de libertad que estima el GARCH.
La versión de librería es neuralforecast.models.DeepAR, con el mismo formato de datos que la lección 5.7.2, la
distribución en la pérdida (DistributionLoss) y la log RV como covariable histórica (hist_exog_list): solo se
conoce hasta hoy. Sus intervalos salen de muestrear la distribución prevista (DeepAR-lo-90, DeepAR-hi-90).
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: cobertura del 90 % {cob_nf:.1%}")neuralforecast DeepAR: cobertura del 90 % 88.9%La versión de librería cubre el 88,9 %: con 300 pasos de entrenamiento queda algo corta, y sus cuantiles salen de
muestrear la distribución, no de la fórmula. Ajustar max_steps, el tamaño de la red y las muestras es el trabajo
normal con una librería; el código propio de arriba sirve para saber qué hace por dentro.
Calibración: total y condicional
Tres medidas para cada modelo, en la prueba 2018-2025:
- Cobertura del intervalo del 90 % y excesos del cuantil 5 % (la fracción de días por debajo; debería ser 5 %), con el p-valor del test de Kupiec de la lección 4.2.3 (proporción de excesos frente a la prometida).
- Pinball media sobre los nueve cuantiles: la regla propia que resume la distribución entera.
- Anchura media del intervalo del 90 %: a igual cobertura, más estrecho es más informativo.
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 {"cobertura 90": np.mean((y >= q[:, I05])
& (y <= q[:, I95])),
"excesos 5": exc / len(y),
"p Kupiec": kupiec(exc, len(y), 0.05),
"debajo del 1 %": np.mean(y < q[:, 0]),
"pinball": np.maximum(TAU * u, (TAU - 1) * u).mean(),
"anchura 90": np.mean(q[:, I95] - q[:, I05])}
tabla = pd.DataFrame({k: resumen(q) for k, q in Q.items()}).T
tabla.round(4) cobertura 90 excesos 5 p Kupiec debajo del 1 % pinball anchura 90
incondicional 0.9040 0.0480 0.3129 0.0113 0.1771 3.1451
escala HAR 0.8999 0.0522 0.2626 0.0105 0.1737 3.0368
oráculo 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
red de cuantiles 0.9022 0.0478 0.2740 0.0177 0.1746 3.0642
DeepAR 0.9011 0.0554 0.0076 0.0107 0.1741 3.0504En la cobertura total todos parecen buenos: los seis cubren entre el 89,9 % y el 90,4 %. Hasta los cuantiles incondicionales, que dan el mismo intervalo un día tranquilo de 2019 que en marzo de 2020. La pinball sí ordena: el oráculo es el mejor (0,1734), la escala HAR queda casi a su altura (0,1737), y DeepAR (0,1741), la red de cuantiles (0,1746) y el GJR-GARCH (0,1755) quedan entre ellos y los incondicionales (0,1771). Los excesos del cuantil 5 % están cerca del 5 % en todos, aunque Kupiec rechaza los de DeepAR (5,5 %, p = 0,008) y deja al GARCH en el límite (p = 0,05); con 12.060 días, medio punto ya es detectable. Y en la cola del 1 %, la red de cuantiles deja por debajo el 1,8 % de los días: estimar un cuantil extremo con sus propios datos es difícil. La forma de ver qué falla es partir la prueba por tramos de volatilidad: los tercios de días según la varianza verdadera esperada (que ningún modelo usa; en datos reales, la RV realizada de ese día).
tercio = pd.qcut(sig_ora[pru].to_numpy(), 3,
labels=["baja", "media", "alta"])
cob = pd.DataFrame({
k: pd.Series((y >= q[:, I05]) & (y <= q[:, I95]))
.groupby(np.asarray(tercio)).mean()
for k, q in Q.items()}).T[["baja", "media", "alta"]]
cob.round(3) baja media alta
incondicional 0.957 0.915 0.839
escala HAR 0.905 0.904 0.891
oráculo 0.887 0.904 0.906
GJR-GARCH-t 0.938 0.905 0.864
red de cuantiles 0.915 0.905 0.886
DeepAR 0.905 0.905 0.893La figura de la cabecera lo dibuja. Los cuantiles incondicionales cubren el 95,7 % de los días tranquilos y el 83,9 % de los volátiles: su 90 % total es la media de un intervalo demasiado ancho y otro demasiado estrecho, y falla justo cuando el riesgo importa. La escala HAR, la red de cuantiles y DeepAR quedan a menos de 1,5 puntos del 90 % en los tres tercios. El GJR-GARCH, que ve la volatilidad solo a través de los rendimientos al cuadrado, reacciona más despacio: 93,8 % en los días tranquilos y 86,4 % en los volátiles. Ni el oráculo clava los tres tercios (88,7 % en el bajo): conocida la escala, la forma de los rendimientos tipificados, estimada en 2008-2015, no es exactamente la misma en todos los tramos, y los choques (marzo de 2020 en el índice, noviembre de 2022 en la cripto) no se pueden prever con la información del día anterior.
La figura muestra el intervalo del 90 % de DeepAR para la cripto en el primer semestre de 2020, pasado a rendimientos en tanto por ciento. El choque de volatilidad de marzo (un salto en la varianza, no un rendimiento extremo ese día) no se ve el día en que ocurre: el intervalo se va ensanchando a medida que la RV lo registra, de 6,7 puntos de anchura media en enero a 11,8 en junio. Trece de los 125 días quedan fuera (10,4 %), repartidos por todo el semestre; cuatro en enero, cuando el intervalo es más estrecho, y el mayor, un +13,5 % el 19 de febrero. Es lo que hace un modelo condicional honesto: reacciona a la información, no la adivina.
Predicción conforme: ajustar con la validación
La predicción conforme corrige un intervalo para que cubra lo prometido sin supuestos sobre el modelo. La versión para cuantiles (CQR; Romano, Patterson y Candès, 2019): en un conjunto de calibración que el modelo no ha visto, se mide cuánto se sale cada dato del intervalo, (negativo si queda dentro), y se ensancha (o estrecha) el intervalo con el cuantil de esos valores. Si calibración y prueba son intercambiables, la cobertura de la prueba queda garantizada.
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["red de cuantiles"]
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"cobertura en validación: {cob_val:.1%}; ajuste {ajuste:+.3f}")
print(f"prueba: antes {cob_antes:.1%}, después {cob_cqr:.1%}")cobertura en validación: 91.1%; ajuste -0.058
prueba: antes 90.2%, después 89.1%En la validación la red cubría algo más del 90 %, así que el ajuste es negativo: estrecha el intervalo. En la prueba, donde la red ya cubría casi exactamente el 90 %, el intervalo estrechado se queda por debajo. No es un fallo del método: su garantía exige que validación y prueba se parezcan, y 2016-2017 fue un periodo más tranquilo que 2018-2025. En series, las variantes adaptativas (que recalculan el ajuste con una ventana móvil de los errores recientes) se acercan más a la promesa. La lección general: ninguna corrección estadística sustituye a medir la cobertura en el periodo y en los tramos en que se va a usar el modelo.
| Modelo | Qué prevé | Colas | Coste |
|---|---|---|---|
| Incondicional | cuantiles fijos | empíricas | ninguno; inútil en riesgo |
| Escala HAR + forma empírica | σ de mañana | empíricas tipificadas | una regresión |
| GJR-GARCH-t | σ de mañana | t de Student | una estimación por activo |
| Red de cuantiles | cada cuantil | aprendidas, malas en el 1 % | una red pequeña |
| DeepAR | μ, σ, ν por paso | t de Student | una red recurrente |
Ejercicios
- Quita la parametrización monótona de la red de cuantiles (que
forwarddevuelvaself.red(x)tal cual), entrénala igual y cuenta cuántos pares de cuantiles consecutivos se cruzan en la prueba.
Solución
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("pares cruzados:", cruzados.sum(), "de", cruzados.size)
print("días con algún cruce:", f"{cruzados.any(axis=1).mean():.1%}")
print("pinball:", round(resumen(q_libre)["pinball"], 4),
"monótona:", round(tabla.loc["red de cuantiles", "pinball"], 4))pares cruzados: 0 de 96480
días con algún cruce: 0.0%
pinball: 0.174 monótona: 0.1746Con estos datos y esta red no hay ningún cruce, y la pinball es casi la misma (0,1740 frente a 0,1746): los cuantiles de un rendimiento tipificado están bien separados y la red aprende a ordenarlos. Aparecen con más cuantiles, más cercanos entre sí, menos datos o extrapolación a features extremas. La parametrización monótona no cuesta nada y elimina el problema; arreglarlo después (ordenando las salidas) también funciona, pero esconde que la red había aprendido algo incoherente.
- Repite la tabla de cobertura del 90 % por tercios de volatilidad solo para la cripto, y compara la escala HAR con el GJR-GARCH-t. ¿Cuál se adapta mejor y por qué?
Solución
es_cripto = (h.loc[pru, "activo"] == "cripto").to_numpy()
t_c = pd.qcut(sig_ora[pru].to_numpy()[es_cripto], 3,
labels=["baja", "media", "alta"])
for k in ("escala HAR", "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(["baja", "media", "alta"]).round(3).to_dict())escala HAR {'baja': 0.887, 'media': 0.918, 'alta': 0.901}
GJR-GARCH-t {'baja': 0.936, 'media': 0.936, 'alta': 0.882}
DeepAR {'baja': 0.897, 'media': 0.912, 'alta': 0.897}La escala HAR usa la RV de 5 minutos, una medida mucho más precisa de la volatilidad de hoy que el rendimiento al cuadrado de un día, que es lo único que ve el GARCH. Por eso reacciona antes a los cambios de nivel (la cripto tiene uno en 2019 y saltos frecuentes) y su cobertura varía menos entre tercios (del 88,7 % al 91,8 %, frente al 88,2-93,6 % del GARCH, que además cubre de más en los días tranquilos y medios). La diferencia exacta depende del activo; lo general es que un modelo de riesgo diario mejora más con mejores datos de volatilidad (RV intradía) que con una arquitectura más compleja.
- Un compañero propone medir la calidad del intervalo del 90 % solo con la cobertura total y elegir el modelo cuya cobertura quede más cerca del 90 %. ¿Qué modelo elegiría con la tabla de la lección y qué le contestarías?
Solución
Con la tabla de la lección, los incondicionales quedan cerca del 90 % y podrían salir elegidos: es el modelo que da el mismo intervalo todos los días. La cobertura total es una condición necesaria, no suficiente. Hay que pedirle tres cosas más: cobertura condicional (por tercios de volatilidad, por activo, por régimen), independencia de los excesos (que no lleguen en rachas, el test de Christoffersen de 4.2.3) y una regla de puntuación propia (la pinball media, o el CRPS si se prevé la distribución entera) que premie la información: a igual calibración, el intervalo más estrecho es el mejor. Con esos criterios, el incondicional queda el último y la escala HAR, cerca del oráculo.
Respuesta plegada en cada pregunta
Autoevaluación
¿Qué valor constante minimiza la pérdida pinball media con τ = 0,05 sobre una muestra?
- ALa media de la muestra
- BLa mediana
- CEl cuantil 5 % empírico de la muestra
- DLa media menos 1,645 desviaciones
Ver respuesta
Respuesta C. El cuantil 5 % empírico de la muestra
La derivada de la pérdida respecto a la constante es la fracción de observaciones por debajo menos τ: se anula cuando el 5 % de las observaciones queda por debajo. Con τ = 0,5 da la mediana; la media sale del error cuadrático.
Unos cuantiles incondicionales (los mismos todos los días) cubren el 90,4 % en la prueba. ¿Están bien calibrados?
- ASí: cubren lo que prometen
- BSolo en media: cubren el 95,7 % en los días tranquilos y el 83,9 % en los volátiles, justo cuando el riesgo importa
- CNo: deberían cubrir el 100 %
- DSí, si el test de Kupiec no rechaza
Ver respuesta
Respuesta B. Solo en media: cubren el 95,7 % en los días tranquilos y el 83,9 % en los volátiles, justo cuando el riesgo importa
La cobertura marginal no basta. Un intervalo útil cubre lo prometido condicionado a lo que se sabe hoy; la forma práctica de comprobarlo es partir la prueba por tramos de volatilidad (o por régimen) y medir la cobertura en cada uno.
¿Por qué la red de cuantiles se parametriza como el primer cuantil más una suma acumulada de valores positivos?
- APara que entrene más deprisa
- BPorque la pérdida pinball lo exige
- CPara que la mediana sea cero
- DPara que los cuantiles no se crucen: con salidas libres, el cuantil del 5 % puede quedar por encima del del 10 % en algunos días
Ver respuesta
Respuesta D. Para que los cuantiles no se crucen: con salidas libres, el cuantil del 5 % puede quedar por encima del del 10 % en algunos días
Entrenar cada cuantil con su pérdida no garantiza el orden.
softplushace positivos los saltos entre cuantiles consecutivos y la suma acumulada los ordena por construcción.El cuantil del 1 % de la red de cuantiles deja por debajo el 1,8 % de los días de prueba, y el del GJR-GARCH-t, el 1,0 %. ¿Por qué cuesta más a la red?
- APorque un cuantil del 1 % se aprende con el 1 % de los datos: con 12.000 filas, unas 120 observaciones en la cola; el GARCH impone una forma paramétrica (t de Student) que extrapola la cola con todos los datos
- BPorque la red tiene pocas capas
- CPorque el GARCH ve el futuro
- DPorque la pinball no funciona en las colas
Ver respuesta
Respuesta A. Porque un cuantil del 1 % se aprende con el 1 % de los datos: con 12.000 filas, unas 120 observaciones en la cola; el GARCH impone una forma paramétrica (t de Student) que extrapola la cola con todos los datos
Las colas son pocas observaciones. Un modelo con forma paramétrica de la cola, o una red que prevé los parámetros de una distribución (DeepAR), sufre menos que una que estima cada cuantil extremo por separado.
El ajuste conforme con la validación de 2016-2017 lleva la cobertura de la red de cuantiles del 90,2 % al 89,1 % en la prueba. ¿Por qué no da exactamente el 90 %?
- APorque el código está mal
- BPorque hay pocos datos en la prueba
- CPorque la garantía de la predicción conforme exige que la validación y la prueba sean intercambiables, y una serie financiera cambia de régimen: 2016-2017 fue un periodo más tranquilo que 2018-2025
- DPorque la predicción conforme solo funciona con modelos lineales
Ver respuesta
Respuesta C. Porque la garantía de la predicción conforme exige que la validación y la prueba sean intercambiables, y una serie financiera cambia de régimen: 2016-2017 fue un periodo más tranquilo que 2018-2025
La predicción conforme es una garantía sin supuestos sobre el modelo, pero no sin supuestos sobre los datos. En series, sus variantes adaptativas recalculan el ajuste con una ventana móvil.
Ideas clave
- La pérdida pinball ρτ(u) = máx(τu, (τ − 1)u) se minimiza en el cuantil τ. Es una regla de puntuación propia: premia acertar la distribución, no solo cubrir.
- La cobertura total engaña: los cuantiles incondicionales cubren el 90,4 % en total, pero el 83,9 % en el tercio de días más volátiles. La calibración se mide condicionada (por tramos de volatilidad).
- Con datos de volatilidad casi HAR, la referencia más fuerte es simple: σ del HAR por los cuantiles empíricos de los rendimientos tipificados. Queda a la altura del oráculo y por delante del GARCH, la red de cuantiles y DeepAR.
- Una red de cuantiles necesita monotonía por construcción y sufre en las colas extremas; DeepAR prevé los parámetros de una distribución por paso y sus colas salen de la forma elegida (aquí, t de Student).
- La predicción conforme corrige la anchura con la validación, pero en series su garantía depende de que validación y prueba se parezcan.
Recursos
Documentación
- Koenker y Bassett (1978): Regression Quantiles, Econometrica 46(1)
- Salinas, Flunkert y Gasthaus (2017, arXiv): DeepAR, Probabilistic Forecasting with Autoregressive Recurrent Networks
- Romano, Patterson y Candès (2019): Conformalized Quantile Regression
- Gneiting y Raftery (2007): Strictly Proper Scoring Rules, Prediction, and Estimation
- arch: modelos de volatilidad, previsión y distribuciones