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.

Fig. — Calibración de los nueve cuantiles de Chronos-Bolt zero-shot sobre la log RV de mañana, 2018-2025 (1 006 días): frecuencia observada por debajo de cada cuantil menos su nivel nominal
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

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:

TransformarContextoCuantilesRecalibrarCovariablesAjuste finoPrueba honesta
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í.

IN [01]python
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")
OUT [01]Datos y modelo
['accion', 'bono', 'cripto', 'divisa', 'indice', 'oro'] 2011 sesiones de prueba

Cuá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.

IN [02]python
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})")
OUT [02]Longitud del contexto: de 16 a 1 024 sesiones
          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)
Fig. — Error cuadrático medio de Chronos-Bolt zero-shot a un día según la longitud del contexto, relativo al de 512 sesiones (126 orígenes de 2018-2025 por activo)

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 qτq_\tau está calibrado si la realizada queda por debajo de él una fracción τ\tau 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:

IN [03]python
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())
OUT [03]Cuantiles con todos los días pares de la prueba
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.912

La 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 0,25/1 006≈0,016\sqrt{0{,}25/1\,006} \approx 0{,}016). 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):

IN [04]python
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()))
OUT [04]Cobertura condicionada: por tercios de volatilidad prevista
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 e=y−qτe = y - q_\tau del periodo de calibración, el cuantil corregido es qτ+Qτ(e)q_\tau + Q_\tau(e). Se calibra con 2018-2020 y se mide en 2021-2025, sin mirar la prueba:

IN [05]python
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()})
OUT [05]Recalibrar con 2018-2020 y medir en 2021-2025
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: QLIKE=RV/h−log⁡(RV/h)−1\text{QLIKE} = RV/h - \log(RV/h) - 1, robusta al ruido de la RV como medida de la varianza.

IN [06]python
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}")
OUT [06]De cuantiles del logaritmo a la varianza esperada
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.364

En 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é:

PasadasModeloFuturas conocidasEstáticasPrevisión
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:

ModeloCovariablesCómo
Chronos-Bolt 2.3.2nosolo la serie
Chronos-2 (Chronos2Pipeline, misma librería)pasadas y futuras conocidas, numéricas y categóricasatenció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óricasXReg: 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):

IN [07]python
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})")
OUT [07]TimesFM-2.5 con covariables (XReg)
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):

IN [08]python
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:

IN [09]python
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:

IN [10]python
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:

IN [11]python
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)          # TimeGPT

Lo 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.

IN [12]python
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))
OUT [12]Ajuste fino de la cabeza de Chronos-Bolt: datos
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:

IN [13]python
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()
OUT [13]Ajuste fino de la cabeza: entrenamiento
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.684
Fig. — Ajuste fino de la cabeza de salida de Chronos-Bolt-small (2,53 M de 47,7 M parámetros) con la log RV de los seis activos: pérdida en entrenamiento (2009-2016) y en validación (2017)

La 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:

IN [14]python
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 publicado
OUT [14]Ajuste fino frente a zero-shot en 2018-2025
indice  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 estableel zero-shot ya está cerca del mejor modelo estimado
se valida con una prueba posterior fijada de antemanose eligen pasos, tasa o capas mirando la prueba

Ejercicios

  1. 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
IN [15]python
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))
OUT [15]Cobertura por tercios tras recalibrar
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 %.

  1. 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
IN [16]python
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);
OUT [16]Ajuste fino con lr = 1e-3
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.034

Con 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.

  1. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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).

Recursos