Lección 5.8.3 · 75 min
Uso práctico: contexto, cuantiles, covariables y ajuste fino
Prever con un modelo fundacional es una llamada, pero usarlo bien exige varias decisiones: cuánta historia darle, cómo leer y corregir sus cuantiles, cuándo añadir covariables y si merece la pena ajustarlo con datos propios. Esta lección toma cada decisión sobre la volatilidad realizada del nivel 5 y la mide fuera de muestra, con diferencias pareadas. Spoiler honesto: casi todo lo que parece una mejora obvia aquí no mejora nada.
- Módulo
- 5.8 · Modelos fundacionales de series temporales
- 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
Las decisiones que la llamada no toma
Las lecciones 5.8.1 y 5.8.2 usaron los modelos casi con sus opciones por defecto. En la práctica hay que decidir varias cosas, y cada una se puede medir con una prueba fuera de muestra:
Fuente Mermaid
flowchart TB A[Transformar] --> B[Contexto] B --> C[Cuantiles] C --> D[Recalibrar] D --> E[Covariables] E --> F[Ajuste fino] F --> G[Prueba honesta]
El banco de pruebas sigue siendo el de las lecciones anteriores: el logaritmo de la varianza realizada de vol5, con
previsión a un día y origen móvil en 2018-2025. La transformación ya está decidida (el logaritmo: casi gaussiano y sin
cotas). El resto se decide aquí.
import numpy as np
import pandas as pd
import torch
from chronos import BaseChronosPipeline
torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
vol = pd.read_parquet("vol5_diario.parquet")
log_rv, rend = {}, {}
for a in vol["activo"].unique():
d = vol[vol["activo"] == a].set_index("fecha")
log_rv[a] = np.log(d["rv_5min"].to_numpy())
rend[a] = d["rendimiento"].to_numpy()
fechas = pd.DatetimeIndex(d.index)
prueba = np.flatnonzero(fechas.year >= 2018)
bolt = BaseChronosPipeline.from_pretrained(
"amazon/chronos-bolt-small", device_map="cpu",
dtype=torch.float32)
def zero_shot(y, origenes, contexto, lote=256):
"""Cuantiles 0,1…0,9 de mañana con `contexto` sesiones."""
salida = []
for k in range(0, len(origenes), lote):
v = np.stack([y[i - contexto:i] for i in origenes[k:k + lote]])
q, _ = bolt.predict_quantiles(
inputs=torch.tensor(v, dtype=torch.float32),
prediction_length=1)
salida.append(q[:, 0, :].numpy().astype(float))
return np.concatenate(salida)
print(sorted(log_rv), len(prueba), "sesiones de prueba")['accion', 'bono', 'cripto', 'divisa', 'indice', 'oro'] 2011 sesiones de pruebaCuánta historia darle
Chronos-Bolt admite hasta 2 048 sesiones de contexto y TimesFM-2.5 hasta 16 384. Más contexto da más información sobre el nivel y la dinámica de la serie, pero cuesta tiempo (más parches) y puede meter historia que ya no se parece al presente. La celda barre de 16 a 1 024 sesiones con un origen de cada 16 (126 días por activo) y compara cada contexto con el de 512 mediante la diferencia pareada.
orig = prueba[::16]
contextos = [16, 32, 64, 128, 256, 512, 1024]
ecm, err = {}, {}
for a in ("indice", "cripto"):
real = log_rv[a][orig]
for c in contextos:
e = (real - zero_shot(log_rv[a], orig, c)[:, 4]) ** 2
ecm[(a, c)], err[(a, c)] = e.mean(), e
tabla = pd.Series(ecm).unstack(0).round(3)
tabla.index.name = "contexto"
print(tabla.to_string())
for a in ("indice", "cripto"):
for c in (16, 64):
d = err[(a, c)] - err[(a, 512)]
t = d.mean() / d.std(ddof=1) * np.sqrt(len(d))
print(f"{a}: {c} − 512 sesiones {d.mean():+.4f} (t = {t:+.1f})") cripto indice
contexto
16 0.429 0.240
32 0.412 0.220
64 0.417 0.228
128 0.402 0.223
256 0.393 0.221
512 0.387 0.219
1024 0.390 0.218
indice: 16 − 512 sesiones +0.0205 (t = +1.7)
indice: 64 − 512 sesiones +0.0092 (t = +1.1)
cripto: 16 − 512 sesiones +0.0413 (t = +3.1)
cripto: 64 − 512 sesiones +0.0296 (t = +3.1)Los dos activos se comportan distinto. En el índice, el error baja de 0,240 con 16 sesiones a 0,220 con 32 y a partir de ahí se mueve entre 0,218 y 0,228 sin orden claro; ni siquiera 16 frente a 512 es significativo (t = 1,7). En cripto, el error baja de forma sostenida de 0,429 a 0,387 con 512 sesiones, y la diferencia frente a 16 o 64 sesiones tiene t = 3,1. La razón es el ruido: la RV de cripto, con doce saltos al año de media y un error de medida grande, necesita más días para que el modelo estime su nivel; en el índice, sin saltos, un mes y medio basta. Con 1 024 sesiones no se gana nada en ninguno de los dos. En la práctica, 256-512 sesiones es una elección razonable para volatilidad diaria, y la prueba hay que repetirla en cada serie.
Cuantiles: ¿cubren lo que prometen?
Chronos-Bolt devuelve nueve cuantiles. Un cuantil está calibrado si la realizada queda por debajo de él una fracción de los días. La lección 5.7.3 enseñó a medirlo con modelos entrenados; la diferencia aquí es que el modelo no ha visto la serie, así que su calibración es la que traiga del preentrenamiento. Con un origen de cada dos sesiones (1 006 días por activo) y un contexto de 256:
C = 256
tau = np.arange(1, 10) / 10
orig = prueba[::2]
zs = {a: zero_shot(log_rv[a], orig, C) for a in ("indice", "cripto")}
filas = {}
for a, q in zs.items():
real = log_rv[a][orig]
filas[a] = (real[:, None] <= q).mean(0)
frec = pd.DataFrame(filas, index=tau).round(3)
frec.index.name = "nominal"
print(frec.T.to_string())nominal 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9
indice 0.124 0.235 0.332 0.428 0.507 0.600 0.690 0.786 0.901
cripto 0.103 0.206 0.296 0.394 0.480 0.583 0.685 0.796 0.912La figura de la cabecera dibuja la diferencia entre la frecuencia observada y la nominal, con la banda de ±2 errores típicos (con 1 006 días, el error típico de una frecuencia del 50 % es ). En cripto los nueve cuantiles quedan dentro o cerca de la banda: la calibración es buena. En el índice, la cola baja es demasiado estrecha: el 12,4 % de los días queda por debajo del cuantil 0,1 y el 33,2 % por debajo del 0,3, cuando deberían ser el 10 % y el 30 %. La cola alta está bien (90,1 % en el 0,9). Dicho de otra forma: el modelo no espera días tan tranquilos como los que tiene el índice sintético.
La cobertura total puede esconder problemas que solo aparecen al condicionar, como enseñó la lección 5.7.3. Se parte la prueba en tercios según la mediana prevista (un dato que se conoce la víspera):
def cobertura(real, q):
return np.mean((real >= q[:, 0]) & (real <= q[:, 8]))
for a, q in zs.items():
real = log_rv[a][orig]
tercio = pd.qcut(q[:, 4], 3, labels=["baja", "media", "alta"])
cob = {t: cobertura(real[tercio == t], q[tercio == t])
for t in ("baja", "media", "alta")}
print(f"{a:<7} total {cobertura(real, q):.1%} " + " ".join(
f"{t} {v:.1%}" for t, v in cob.items()))indice total 77.6% baja 76.2% media 75.8% alta 80.9%
cripto total 80.8% baja 80.4% media 83.3% alta 78.8%En el índice el intervalo del 10 al 90 % cubre el 76,2 % y el 75,8 % en los tercios de volatilidad baja y media, y el 80,9 % en el alto: es en los días normales donde falla, coherente con la cola baja estrecha. En cripto el patrón es el contrario y más suave (83,3 % en el tercio medio, 78,8 % en el alto). Con unos 335 días por tercio, el error típico de cada cobertura es de unos 2,2 puntos, así que solo las desviaciones del índice en los tercios bajo y medio están claramente fuera.
Recalibrar: un ajuste conforme por cuantil
Si los cuantiles están sesgados de forma estable, se pueden corregir con un periodo de calibración. La versión más sencilla de la regresión cuantílica conforme (Romano, Patterson y Candès, 2019) desplaza cada cuantil por el cuantil empírico de sus residuos: con los residuos del periodo de calibración, el cuantil corregido es . Se calibra con 2018-2020 y se mide en 2021-2025, sin mirar la prueba:
def pinball(real, q):
u = real[:, None] - q
return np.maximum(tau * u, (tau - 1) * u).mean()
cal = fechas.year[orig] <= 2020
ajustes = {}
for a, q in zs.items():
real = log_rv[a][orig]
# desplazamiento por cuantil: frecuencia nominal en 2018-2020
res = real[cal, None] - q[cal]
delta = np.array([np.quantile(res[:, j], tau[j])
for j in range(9)])
qa = q + delta
ajustes[a] = delta
c0 = cobertura(real[~cal], q[~cal])
c1 = cobertura(real[~cal], qa[~cal])
print(f"{a:<7} 2021-2025: cobertura {c0:.1%} a {c1:.1%}; pinball "
f"{pinball(real[~cal], q[~cal]):.4f} a "
f"{pinball(real[~cal], qa[~cal]):.4f}")
print({a: d.round(3).tolist() for a, d in ajustes.items()})indice 2021-2025: cobertura 78.3% a 80.3%; pinball 0.1352 a 0.1351
cripto 2021-2025: cobertura 80.9% a 81.2%; pinball 0.1659 a 0.1661
{'indice': [-0.046, -0.039, -0.046, -0.026, 0.024, 0.024, 0.023, 0.0, 0.02], 'cripto': [-0.047, -0.011, -0.003, -0.008, 0.026, 0.008, 0.015, -0.014, -0.062]}En el índice, la cobertura de 2021-2025 sube del 78,3 % al 80,3 %, casi la nominal; los desplazamientos son de unas centésimas en log (−0,046 en el cuantil 0,1, que baja la cola). En cripto, que ya estaba calibrado, pasa del 80,9 % al 81,2 %. La pinball apenas cambia (0,1352 a 0,1351 y 0,1659 a 0,1661): el ajuste corrige la cobertura media, no añade información. Sirve cuando lo que se va a usar es el intervalo (para fijar un límite de riesgo o el tamaño de una posición, lección 4.2.2), no para mejorar la previsión. Y no garantiza la cobertura exacta en series, porque los residuos no son intercambiables: la lección 5.7.3 lo trata con detalle.
De cuantiles del logaritmo a la varianza
Para dimensionar o comparar con un GARCH hace falta la varianza esperada de mañana, no la mediana de su logaritmo. Exponenciar la mediana da la mediana de la varianza, que es menor que su media cuando la distribución tiene cola a la derecha. Con nueve cuantiles hay una aproximación directa: exponenciar cada cuantil y promediar (una cuadratura tosca de la esperanza). La medida para comparar previsiones de varianza es la QLIKE, la del README del nivel y la de la lección 5.8.5: , robusta al ruido de la RV como medida de la varianza.
def qlike(rv, h):
return np.mean(rv / h - np.log(rv / h) - 1)
for a, q in zs.items():
rv = np.exp(log_rv[a][orig])
mediana = np.exp(q[:, 4])
media_q = np.exp(q).mean(1) # media de los cuantiles, en nivel
print(f"{a:<7} QLIKE con exp(mediana) {qlike(rv, mediana):.3f} "
f"con la media de los cuantiles {qlike(rv, media_q):.3f}")indice QLIKE con exp(mediana) 0.106 con la media de los cuantiles 0.103
cripto QLIKE con exp(mediana) 0.397 con la media de los cuantiles 0.364En el índice la diferencia es pequeña (0,106 a 0,103); en cripto, con una distribución más ancha, la QLIKE baja de 0,397 a 0,364. Cuanto más incierta es la previsión, más se aleja la media de la mediana y más importa no confundirlas. La lección 5.8.5 compara estas previsiones de varianza con un GARCH y un LightGBM.
Covariables: quién las admite y para qué
Una covariable es otra serie que ayuda a prever la que interesa. Hay tres tipos, según lo que se sabe cuando se prevé:
Fuente Mermaid
flowchart LR P[Pasadas] --> M[Modelo] F[Futuras conocidas] --> M S[Estáticas] --> M M --> Y[Previsión]
Las pasadas solo se conocen hasta hoy (el volumen, otra serie de mercado); las futuras conocidas se saben de antemano para el horizonte (el calendario, una subasta, una publicación programada); las estáticas describen la serie (el sector, el tipo de activo). En las versiones instaladas en el entorno del curso:
| Modelo | Covariables | Cómo |
|---|---|---|
| Chronos-Bolt 2.3.2 | no | solo la serie |
Chronos-2 (Chronos2Pipeline, misma librería) | pasadas y futuras conocidas, numéricas y categóricas | atención de grupo, de forma nativa; pesos no en la caché |
TimesFM-2.5 (timesfm 3.0.2) | dinámicas y estáticas, numéricas y categóricas | XReg: regresión lineal en el contexto más TimesFM, con JAX |
TimeGPT (SDK nixtla) | futuras (X_df) e históricas (hist_exog_list) | en el servicio de pago |
XReg, el mecanismo de TimesFM-2.5 (añadido en octubre de 2025, según el README del proyecto), no mete las
covariables en el transformer. En el modo "xreg + timesfm" ajusta, para cada serie, una regresión lineal del objetivo
sobre las covariables dentro del propio contexto y deja que TimesFM prevea el residuo; el modo "timesfm + xreg" lo
hace al revés. Exige compilar con return_backcast=True y usa JAX (instalado en el entorno). Las covariables dinámicas
se pasan con la longitud del contexto más el horizonte: sus valores futuros tienen que conocerse.
¿Qué covariable tiene sentido para la volatilidad de mañana? El README del nivel documenta un efecto apalancamiento en el índice: la log-varianza de hoy depende de la parte negativa del choque de ayer (γ = 0,18). El rendimiento de hoy se conoce al cierre, así que «el rendimiento negativo del día anterior» es una covariable legítima para el día siguiente. De control, un placebo: ruido gaussiano sin ninguna relación con la serie. Con un origen de cada ocho sesiones (252 días):
import timesfm
tfm = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
"google/timesfm-2.5-200m-pytorch", torch_compile=False)
tfm.compile(timesfm.ForecastConfig(
max_context=C, max_horizon=128, normalize_inputs=True,
use_continuous_quantile_head=True, fix_quantile_crossing=True,
force_flip_invariance=False, per_core_batch_size=64,
return_backcast=True)) # XReg lo exige
a = "indice"
y, r = log_rv[a], rend[a]
neg = np.r_[0.0, np.minimum(r[:-1], 0)] / r.std() # neg[j] = r⁻ de j−1
ruido = np.random.default_rng(0).normal(size=len(y))
o = prueba[::8]
real = y[o]
contextos_tfm = [y[i - C:i] for i in o]
# forecast() rellena la lista que recibe hasta un múltiplo del lote:
# se le pasa una copia
punto, _ = tfm.forecast(horizon=1, inputs=list(contextos_tfm))
e0 = (real - punto[:, -1]) ** 2 # con backcast, el último es mañana
print(f"sin covariables ECM {e0.mean():.4f}")
for nombre, x in (("rendimiento negativo", neg),
("ruido (placebo)", ruido)):
p, _ = tfm.forecast_with_covariates(
inputs=list(contextos_tfm),
dynamic_numerical_covariates={
nombre: [x[i - C:i + 1] for i in o]},
xreg_mode="xreg + timesfm")
e = (real - np.array([v[0] for v in p])) ** 2
d = e - e0
t = d.mean() / d.std(ddof=1) * np.sqrt(len(d))
print(f"{nombre:<20} ECM {e.mean():.4f} diferencia {d.mean():+.4f}"
f" (t = {t:+.1f})")sin covariables ECM 0.1853
rendimiento negativo ECM 0.1769 diferencia -0.0085 (t = -0.8)
ruido (placebo) ECM 0.1850 diferencia -0.0003 (t = -1.2)Un detalle del código que cuesta un error: forecast() modifica la lista que recibe, rellenándola con series
vacías hasta un múltiplo del tamaño del lote. Si se reutiliza la misma lista en forecast_with_covariates, ya no
coincide con las covariables. Por eso se le pasan copias.
La covariable con efecto real baja el ECM de 0,1853 a 0,1769, un 4,5 %, pero con t = −0,8: no se distingue del ruido. El placebo deja el error en 0,1850 (t = −1,2 sobre una diferencia de tres diezmilésimas). El efecto existe en los datos, pero es pequeño: el choque de la varianza se ve a través del rendimiento con ruido (la correlación entre los dos es ρ = −0,45), la RV de mañana tiene su propio error de medida y la regresión lineal se estima con 256 días en cada contexto. Para afirmar la mejora harían falta muchos más días de prueba; para usarla en producción, además, una razón económica.
Chronos-2 haría lo mismo de forma nativa. Su código, con el formato de entrada de su documentación (un diccionario por serie, con las covariables pasadas del tamaño del contexto y las futuras del tamaño del horizonte):
from chronos import Chronos2Pipeline
chronos2 = Chronos2Pipeline.from_pretrained("amazon/chronos-2",
device_map="cpu")
entradas = [{
"target": y[i - C:i],
"past_covariates": {"neg": neg[i - C:i]},
"future_covariates": {"neg": neg[i:i + 1]}, # conocida al cierre
} for i in o]
cuantiles, media = chronos2.predict_quantiles(
entradas, prediction_length=1, quantile_levels=[0.1, 0.5, 0.9])Y TimeGPT, con un DataFrame de las covariables futuras (X_df), según su documentación:
import os
from nixtla import NixtlaClient
cliente = NixtlaClient(api_key=os.environ["NIXTLA_API_KEY"])
historia = pd.DataFrame({"unique_id": "indice", "ds": fechas[:-1],
"y": y[:-1], "neg": neg[:-1]})
futuro = pd.DataFrame({"unique_id": "indice", "ds": fechas[-1:],
"neg": neg[-1:]})
prevision = cliente.forecast(df=historia, X_df=futuro, h=1, freq="B",
level=[80])Ajuste fino: cuándo compensa
El ajuste fino (fine-tuning) entrena un poco más el modelo con datos propios. Hay tres grados: el completo (todos los pesos), el de adaptadores (LoRA: matrices pequeñas añadidas a las capas, con el resto congelado) y el de la cabeza (solo la capa de salida). El completo de Chronos-Bolt-small en CPU tarda varios segundos por paso de entrenamiento con lotes de 32 ventanas en esta máquina, demasiado para la lección; su código, con la pérdida cuantílica que el propio modelo calcula cuando recibe el objetivo, sería este:
bolt.model.train()
opt = torch.optim.AdamW(bolt.model.parameters(), lr=1e-5)
for paso in range(1000):
contexto, objetivo = lote_de_ventanas() # (32, C) y (32, 1)
salida = bolt.model(context=contexto, target=objetivo)
opt.zero_grad()
salida.loss.backward()
opt.step()
bolt.model.eval()Chronos-2 trae el ajuste fino en la librería (Chronos2Pipeline.fit, completo o con LoRA, que devuelve una copia
ajustada), con la forma de su cuaderno de inicio; TimeGPT lo ofrece en el servicio con finetune_steps:
ajustado = chronos2.fit(inputs=entradas_entrenamiento,
prediction_length=1, num_steps=1000,
learning_rate=1e-5, batch_size=32)
prevision = cliente.forecast(df=historia, h=1, freq="B",
finetune_steps=10) # TimeGPTLo que sí cabe en la lección es el ajuste de la cabeza. En Chronos-Bolt es un bloque residual (ResidualBlock) que
convierte la salida del decodificador en los nueve cuantiles de los 64 pasos. Con el resto del modelo congelado, la
salida del decodificador de cada ventana se calcula una sola vez y el entrenamiento solo toca la cabeza: segundos de
CPU. Los datos de ajuste son ventanas de los seis activos con el día objetivo entre 2009 y 2016; la validación, 2017; la
prueba, la misma de las secciones anteriores (2018-2025). Ninguna ventana de entrenamiento llega a la prueba.
del tfm
m = bolt.model
cabeza = m.output_patch_embedding
print(cabeza.__class__.__name__,
f"{sum(p.numel() for p in cabeza.parameters()) / 1e6:.2f} M de",
f"{sum(p.numel() for p in m.parameters()) / 1e6:.1f} M",
"parámetros")
rng = np.random.default_rng(0)
def ventanas(desde, hasta, n):
"""n ventanas por activo cuyo día objetivo cae entre esos años."""
X, T = [], []
for a, y in log_rv.items():
ok = np.flatnonzero((fechas.year >= desde)
& (fechas.year <= hasta))
for i in rng.choice(ok[ok >= C], n, replace=False):
X.append(y[i - C:i])
T.append(y[i])
return (torch.tensor(np.stack(X), dtype=torch.float32),
torch.tensor(T, dtype=torch.float32))
@torch.no_grad()
def ocultos(X):
"""Salida del decodificador congelado y escala de cada ventana."""
H, L = [], []
for k in range(0, len(X), 256):
h, (loc, esc), emb, mask = m.encode(X[k:k + 256])
H.append(m.decode(emb, mask, h))
L.append(torch.cat([loc, esc], 1))
return torch.cat(H), torch.cat(L)
X_tr, T_tr = ventanas(2009, 2016, 200)
X_va, T_va = ventanas(2017, 2017, 50)
H_tr, L_tr = ocultos(X_tr)
H_va, L_va = ocultos(X_va)
print("entrenamiento", tuple(H_tr.shape),
"validación", tuple(H_va.shape))ResidualBlock 2.53 M de 47.7 M parámetros
entrenamiento (1200, 1, 512) validación (300, 1, 512)La pérdida es la del propio Chronos-Bolt (la pinball de los nueve cuantiles, sumada y multiplicada por 2) sobre el objetivo tipificado con la media y la desviación del contexto, como hace el modelo. Se entrena 200 pasos con Adam y se mira la validación cada 50:
import copy
tau_t = torch.tensor(m.chronos_config.quantiles)
def perdida(H, L, T):
"""Pérdida cuantílica de Chronos-Bolt en el primer paso,
con el objetivo tipificado como el contexto."""
q = cabeza(H).view(len(H), 9, -1)[:, :, 0]
z = (T[:, None] - L[:, :1]) / L[:, 1:]
return (2 * torch.abs((z - q) * ((z <= q).float() - tau_t))
).sum(1).mean()
original = copy.deepcopy(cabeza.state_dict())
opt = torch.optim.Adam(cabeza.parameters(), lr=1e-4)
g = torch.Generator().manual_seed(0)
for paso in range(201):
if paso % 50 == 0:
with torch.no_grad():
print(f"paso {paso:3d} entrenamiento "
f"{perdida(H_tr, L_tr, T_tr).item():.3f} validación "
f"{perdida(H_va, L_va, T_va).item():.3f}")
lote = torch.randperm(len(H_tr), generator=g)[:256]
opt.zero_grad()
perdida(H_tr[lote], L_tr[lote], T_tr[lote]).backward()
opt.step()paso 0 entrenamiento 4.430 validación 4.717
paso 50 entrenamiento 4.328 validación 4.681
paso 100 entrenamiento 4.269 validación 4.682
paso 150 entrenamiento 4.223 validación 4.679
paso 200 entrenamiento 4.179 validación 4.684La pérdida de entrenamiento baja sin parar (4,430 a 4,179), pero la de validación solo mejora en los primeros 50 pasos (4,717 a 4,681) y luego se queda plana: a partir de ahí, la cabeza memoriza las 1 200 ventanas. El número de pasos se fijó de antemano; elegirlo mirando la prueba sería la selección de la lección 5.5.4. La prueba decide:
ft = {a: zero_shot(log_rv[a], orig, C) for a in ("indice", "cripto")}
for a in ft:
real = log_rv[a][orig]
u0 = real[:, None] - zs[a]
u1 = real[:, None] - ft[a]
p0 = np.maximum(tau * u0, (tau - 1) * u0).mean(1)
p1 = np.maximum(tau * u1, (tau - 1) * u1).mean(1)
d = p1 - p0
t = d.mean() / d.std(ddof=1) * np.sqrt(len(d))
print(f"{a:<7} pinball {p0.mean():.4f} a {p1.mean():.4f} "
f"(t = {t:+.1f}); ECM {np.mean(u0[:, 4] ** 2):.4f} a "
f"{np.mean(u1[:, 4] ** 2):.4f}; cobertura "
f"{cobertura(real, zs[a]):.1%} a "
f"{cobertura(real, ft[a]):.1%}")
cabeza.load_state_dict(original); # vuelve el modelo publicadoindice pinball 0.1356 a 0.1369 (t = +1.4); ECM 0.1953 a 0.1979; cobertura 77.6% a 76.4%
cripto pinball 0.1660 a 0.1675 (t = +1.4); ECM 0.3713 a 0.3671; cobertura 80.8% a 79.3%El ajuste empeora un poco la pinball en los dos activos (0,1356 a 0,1369 en el índice, 0,1660 a 0,1675 en cripto, t = 1,4 en ambos) y baja algo la cobertura. El ECM del índice sube y el de cripto baja (0,3713 a 0,3671): señales mixtas dentro del ruido. La conclusión es la honesta: en esta serie, el modelo preentrenado ya extraía casi toda la información, y 1 200 ventanas no bastan para enseñarle algo que no sepa sin hacerle olvidar otra cosa.
| El ajuste fino suele compensar si… | Suele no compensar si… |
|---|---|
| la serie se parece poco al corpus (unidades, frecuencia o dinámica raras) | la serie tiene una estructura sencilla y frecuente (persistencia, como la volatilidad) |
| hay muchos datos propios (miles de series o años de alta frecuencia) | hay pocas series y pocos años |
| el zero-shot está claramente sesgado y el sesgo es estable | el zero-shot ya está cerca del mejor modelo estimado |
| se valida con una prueba posterior fijada de antemano | se eligen pasos, tasa o capas mirando la prueba |
Ejercicios
- Repite la cobertura por tercios de volatilidad prevista después del ajuste conforme, solo en 2021-2025. ¿Corrige el ajuste los tercios del índice que fallaban, o solo la cobertura total?
Solución
for a, q in zs.items():
real, qa = log_rv[a][orig][~cal], (q + ajustes[a])[~cal]
q0 = q[~cal]
tercio = pd.qcut(q0[:, 4], 3, labels=["baja", "media", "alta"])
fila = []
for t in ("baja", "media", "alta"):
s = tercio == t
fila.append(f"{t} {cobertura(real[s], q0[s]):.1%} a "
f"{cobertura(real[s], qa[s]):.1%}")
print(f"{a:<7}", " ".join(fila))indice baja 75.2% a 76.7% media 76.1% a 78.5% alta 83.7% a 85.6%
cripto baja 82.9% a 82.4% media 80.4% a 80.9% alta 79.4% a 80.4%El ajuste sube la cobertura en los tres tercios del índice (del 75,2 % al 76,7 % en el bajo, del 76,1 % al 78,5 % en el medio y del 83,7 % al 85,6 % en el alto), pero no los iguala: el desplazamiento es el mismo para todos los días, así que traslada el sesgo sin corregir su forma. Tras el ajuste, los días tranquilos siguen por debajo del 80 % y los volátiles, por encima. Corregir la cobertura condicionada exige un ajuste que dependa del tramo (calibrar por tercios, con tres veces menos datos en cada uno) o un modelo mejor, no un desplazamiento global. En cripto, que ya estaba bien, todo queda entre el 80 % y el 83 %.
- Repite el ajuste fino de la cabeza con una tasa de aprendizaje diez veces mayor (
lr=1e-3). ¿Qué pasa con la pérdida de validación?
Solución
opt = torch.optim.Adam(cabeza.parameters(), lr=1e-3)
g = torch.Generator().manual_seed(0)
for paso in range(201):
if paso % 50 == 0:
with torch.no_grad():
print(f"paso {paso:3d} entrenamiento "
f"{perdida(H_tr, L_tr, T_tr).item():.3f} validación "
f"{perdida(H_va, L_va, T_va).item():.3f}")
lote = torch.randperm(len(H_tr), generator=g)[:256]
opt.zero_grad()
perdida(H_tr[lote], L_tr[lote], T_tr[lote]).backward()
opt.step()
cabeza.load_state_dict(original);paso 0 entrenamiento 4.430 validación 4.717
paso 50 entrenamiento 4.089 validación 4.699
paso 100 entrenamiento 3.849 validación 4.819
paso 150 entrenamiento 3.487 validación 4.949
paso 200 entrenamiento 3.160 validación 5.034Con una tasa diez veces mayor, la validación mejora un poco en los primeros 50 pasos (4,717 a 4,699) y después empeora sin parar hasta 5,034, por encima del punto de partida, mientras el entrenamiento baja de 4,430 a 3,160. Es el sobreajuste en su forma más clara: la cabeza aprende de memoria las 1 200 ventanas y pierde lo que sabía. Sin una validación separada en el tiempo, la pérdida de entrenamiento habría dicho que el ajuste iba muy bien. La celda devuelve al final la cabeza publicada.
- Quieres prever la volatilidad de mañana de una acción europea con un modelo con covariables, y propones dos: el cierre del S&P 500 de hoy (la bolsa de Nueva York cierra a las 22:00, hora de Madrid) y el VIX de mañana a la apertura europea. Prevés a las 17:35, tras el cierre europeo. ¿Cuáles son legítimas y de qué tipo?
Solución
Ninguna de las dos tal como está planteada. A las 17:35 el S&P 500 sigue abierto: su cierre de hoy no se conoce, así que como covariable de «hoy» es una fuga; lo legítimo es su cierre de ayer (pasada) o su valor a las 17:35 (pasada, fechada a esa hora). El VIX de mañana a la apertura europea es futuro y no se conoce de antemano: no es una covariable futura conocida, sino lo que se quiere prever con otro nombre. Covariables futuras conocidas de verdad serían el calendario de resultados de la empresa o de publicaciones macro programadas (BCE, IPC), que se saben con días de antelación. Cualquier modelo que acepte covariables las tratará como se le digan; la responsabilidad de fecharlas es de quien las prepara (lección 5.2.2).
Respuesta plegada en cada pregunta
Autoevaluación
Con Chronos-Bolt sobre la log RV del índice, pasar de 16 a 512 sesiones de contexto baja el ECM un 9 % con t = 1,7, y de 64 a 512 no hay diferencia significativa. En cripto, de 64 a 512 la mejora tiene t = 3,1. ¿Cómo se elige el contexto?
- ASiempre el máximo que admita el modelo
- BSiempre el mínimo, porque es más rápido
- CMidiendo fuera de muestra en cada serie: la historia útil depende de la memoria de la serie y de su ruido; aquí, unas 256-512 sesiones bastan, y más contexto cuesta tiempo sin ganar precisión
- DCon la longitud de la temporada más larga
Ver respuesta
Respuesta C. Midiendo fuera de muestra en cada serie: la historia útil depende de la memoria de la serie y de su ruido; aquí, unas 256-512 sesiones bastan, y más contexto cuesta tiempo sin ganar precisión
La serie de cripto es más ruidosa: el modelo necesita más días para estimar su nivel. Pasado cierto punto, la historia vieja no aporta y el coste de la atención sigue creciendo.
El 12,4 % de los días la log RV del índice queda por debajo del cuantil 0,1 previsto, y el 90,1 % por debajo del 0,9. ¿Qué le pasa a la distribución prevista?
- ALa cola baja es demasiado estrecha (los días tranquilos lo son más de lo previsto) y la alta está bien calibrada
- BEstá perfectamente calibrada
- CLa cola alta es demasiado estrecha
- DEl modelo está sesgado hacia arriba en todos los cuantiles
Ver respuesta
Respuesta A. La cola baja es demasiado estrecha (los días tranquilos lo son más de lo previsto) y la alta está bien calibrada
Con 1 006 días, el error típico de una frecuencia del 10 % es de 0,9 puntos: 12,4 % está a más de dos errores típicos. En el 0,9 la frecuencia observada es 90,1 %, justo la nominal.
Un ajuste conforme por cuantil, estimado en 2018-2020, lleva la cobertura del 10-90 % del índice en 2021-2025 del 78,3 % al 80,3 %, pero la pinball pasa de 0,1352 a 0,1351. ¿Qué se gana?
- AUna previsión mucho mejor
- BNada: la cobertura empeora
- CUna mediana más precisa
- DIntervalos con la cobertura prometida, útiles para dimensionar o fijar límites; la distribución apenas cambia, así que la puntuación casi no se mueve
Ver respuesta
Respuesta D. Intervalos con la cobertura prometida, útiles para dimensionar o fijar límites; la distribución apenas cambia, así que la puntuación casi no se mueve
El ajuste solo desplaza cada cuantil unas centésimas en log. Corrige el sesgo de cobertura medio, no la información del modelo. La lección 5.7.3 explica por qué en series no garantiza la cobertura exacta.
Con TimesFM-2.5 y XReg, la covariable «rendimiento negativo de ayer» baja el ECM del índice de 0,1853 a 0,1769 con t = −0,8, y un placebo de ruido apenas lo cambia. ¿Qué se concluye?
- AQue la covariable mejora el modelo un 4,5 % y hay que usarla
- BQue con 252 días no se distingue del ruido: el efecto apalancamiento existe en los datos (γ = 0,18), pero es pequeño frente al error de la RV y la regresión lineal en el contexto lo estima con mucho ruido
- CQue XReg no funciona
- DQue el placebo demuestra que el modelo está sobreajustado
Ver respuesta
Respuesta B. Que con 252 días no se distingue del ruido: el efecto apalancamiento existe en los datos (γ = 0,18), pero es pequeño frente al error de la RV y la regresión lineal en el contexto lo estima con mucho ruido
Una mejora con t = −0,8 no es evidencia. El placebo sirve para lo contrario: comprobar que añadir una covariable sin información no empeora mucho ni «mejora» por azar con frecuencia. Para afirmar la mejora haría falta mucha más muestra.
El ajuste fino de la cabeza de Chronos-Bolt baja la pérdida de entrenamiento de 4,430 a 4,179, la de validación de 4,717 a 4,684 y, en 2018-2025, la pinball del índice pasa de 0,1356 a 0,1369. ¿Qué ha pasado?
- AEl ajuste ha funcionado
- BHay un error en la evaluación
- CEl modelo memoriza las ventanas de entrenamiento: la validación se estanca en 50 pasos y fuera de muestra el resultado empeora un poco. El modelo preentrenado ya extraía casi toda la información de esta serie
- DHacía falta entrenar más pasos
Ver respuesta
Respuesta C. El modelo memoriza las ventanas de entrenamiento: la validación se estanca en 50 pasos y fuera de muestra el resultado empeora un poco. El modelo preentrenado ya extraía casi toda la información de esta serie
Es el patrón clásico del sobreajuste, con un modelo de 2,5 millones de parámetros entrenables y 1 200 ventanas. El ajuste fino ayuda cuando la serie se parece poco al corpus de preentrenamiento o hay muchos datos propios; aquí no se da ninguna de las dos cosas.
Ideas clave
- La longitud del contexto se mide: en el índice da igual a partir de 32 sesiones (16 frente a 512: t = 1,7); en cripto, más ruidosa, mejora hasta 256-512 (64 frente a 512: t = 3,1).
- Los cuantiles zero-shot casi están calibrados: en el índice la cola baja es estrecha (12,4 % bajo el cuantil 0,1) y en los días de volatilidad alta la cobertura cambia. Un ajuste conforme por cuantil devuelve la cobertura nominal (78,3 % a 80,3 %) sin cambiar la pinball.
- La varianza esperada no es la exponencial de la mediana del log: la media de los cuantiles en nivel baja la QLIKE (cripto: 0,397 a 0,364).
- Covariables en las versiones instaladas: Chronos-Bolt no; Chronos-2 sí (pasadas, futuras y categóricas; pesos no en caché); TimesFM-2.5 sí, con XReg (regresión lineal en el contexto y TimesFM sobre el residuo). En
vol5, ni la covariable con efecto real ni el placebo cambian algo significativo. - El ajuste fino de la cabeza de Chronos-Bolt cabe en segundos de CPU, pero sobreajusta: la validación se estanca y fuera de muestra la pinball empeora un 1 % (t = 1,4).