---
title: "5.8.3 Uso práctico: zero-shot, covariables y ajuste fino"
description: "Contexto, cuantiles y su calibración, covariables con TimesFM-2.5 y ajuste fino de Chronos-Bolt sobre la volatilidad realizada, medidos fuera de muestra."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/uso-practico-zero-shot-covariables-y-fine-tuning/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/uso-practico-zero-shot-covariables-y-fine-tuning.png"
---

# 5.8.3 · 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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.8 · Modelos fundacionales de series temporales
- Duración: 75 min
- Requisitos: [5.8.2 · Las familias de modelos fundacionales: arquitectura, datos y licencia](https://pythonparatrading.com/curso-python-trading/nivel-5/familias-chronos-timesfm-moirai-y-kronos/), [5.7.3 · Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos](https://pythonparatrading.com/curso-python-trading/nivel-5/modelos-probabilisticos-cuantiles-y-deepar/), [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/)

## Al terminar esta lección

- Elegir la longitud del contexto midiendo el error fuera de muestra, y saber por qué depende de la memoria de la serie.
- Medir la calibración de los cuantiles de un modelo fundacional, total y condicionada, y recalibrarlos con un ajuste conforme por cuantil.
- Convertir cuantiles del logaritmo en una previsión de la varianza esperada y evaluarla con QLIKE.
- Saber qué modelos admiten covariables en las versiones instaladas, y usar las de TimesFM-2.5 (XReg) con un placebo de control.
- Ajustar la cabeza de salida de Chronos-Bolt con datos propios en CPU, vigilar la validación y decidir con una prueba honesta si el ajuste fino compensa.

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

```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í.

```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")
```
```output
['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.

```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})")
```
```output
          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).* Dos líneas en escala logarítmica de contexto, de 16 a 1 024 sesiones. Cripto, destacado, baja de 1,109 con 16 sesiones a 1,0 con 512 y 1,008 con 1 024: más contexto ayuda hasta unos dos años. El índice va de 1,096 con 16 sesiones a valores entre 0,995 y 1,041 desde 32, y ninguna diferencia es significativa (16 frente a 512: t = 1,7).

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.

<aside class="callout" data-kind="nota">

Las diferencias de la tabla salen de 126 orígenes: se ven los órdenes de magnitud, no décimas. Una prueba con todos los
días tardaría unas dieciséis veces más; aquí se sacrifica precisión por tiempo de ejecución de la lección.

</aside>

## Cuantiles: ¿cubren lo que prometen?

Chronos-Bolt devuelve nueve cuantiles. Un cuantil $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:

```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())
```
```output
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 $\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):

```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()))
```
```output
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_\tau$ del periodo de calibración, el cuantil corregido es
$q_\tau + Q_\tau(e)$. Se calibra con 2018-2020 y se mide en 2021-2025, sin mirar la prueba:

```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()})
```
```output
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: $\text{QLIKE} = RV/h - \log(RV/h) - 1$, robusta al ruido de la RV como medida de la varianza.

```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}")
```
```output
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é:

```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):

```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})")
```
```output
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):

```python skip
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:

```python skip
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])
```

<aside class="callout" data-kind="riesgo">

Una covariable «futura conocida» que en realidad no se conocía a la hora de prever es una fuga, y los modelos con
covariables la aprovechan sin avisar. El caso típico en volatilidad: usar como covariable de mañana un dato que se
publica después del cierre de hoy, o el cierre de un mercado que cierra más tarde. Fecha cada covariable por el momento
en que se conoce (lección 5.2.2).

</aside>

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

```python skip
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`:

```python skip
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.

```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))
```
```output
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:

```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()
```
```output
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).* Dos líneas a lo largo de 200 pasos. La pérdida de entrenamiento baja sin parar de 4,430 a 4,179. La de validación, destacada, pasa de 4,717 a 4,681 en los primeros 50 pasos y se queda plana (4,684 al final): a partir de ahí el ajuste solo memoriza las ventanas de entrenamiento.

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:

```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
```
```output
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 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

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

```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))
```
```output
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 %.

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

```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);
```
```output
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.

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

## 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?

   - A. Siempre el máximo que admita el modelo
   - B. Siempre el mínimo, porque es más rápido
   - 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
   - D. Con la longitud de la temporada más larga

   *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?

   - A. La cola baja es demasiado estrecha (los días tranquilos lo son más de lo previsto) y la alta está bien calibrada
   - B. Está perfectamente calibrada
   - C. La cola alta es demasiado estrecha
   - D. El modelo está sesgado hacia arriba en todos los cuantiles

   *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?

   - A. Una previsión mucho mejor
   - B. Nada: la cobertura empeora
   - C. Una mediana más precisa
   - 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

   *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?

   - A. Que la covariable mejora el modelo un 4,5 % y hay que usarla
   - 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
   - C. Que XReg no funciona
   - D. Que el placebo demuestra que el modelo está sobreajustado

   *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?

   - A. El ajuste ha funcionado
   - B. Hay un error en la evaluación
   - 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
   - D. Hacía falta entrenar más pasos

   *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

### Research

- [Usos de la volatilidad](https://pythonparatrading.com/usos-de-la-volatilidad/) · Research-0003 · 6 min

### Documentación

- [Chronos-2: cuaderno de inicio (covariables y ajuste fino)](https://github.com/amazon-science/chronos-forecasting/blob/main/notebooks/chronos-2-quickstart.ipynb) · github.com
- [google-research/timesfm (GitHub): covariables con XReg en TimesFM 2.5](https://github.com/google-research/timesfm) · github.com
- [Nixtla: variables exógenas en TimeGPT](https://www.nixtla.io/docs/forecasting/exogenous-variables/numeric_features) · nixtla.io

### Libros

- [Romano, Y., Patterson, E. y Candès, E. (2019): Conformalized Quantile Regression. arXiv:1905.03222](https://arxiv.org/abs/1905.03222) · arxiv.org

### Datos

- [Datos del nivel 5: rendimientos y varianza realizada de seis activos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/vol5_diario.parquet) · Descarga · vol5_diario.parquet

Lección anterior: [5.8.2 · Las familias de modelos fundacionales: arquitectura, datos y licencia](https://pythonparatrading.com/curso-python-trading/nivel-5/familias-chronos-timesfm-moirai-y-kronos/)

Lección siguiente: [5.8.4 · Modelos fundacionales en finanzas: qué dice la evidencia](https://pythonparatrading.com/curso-python-trading/nivel-5/evidencia-de-los-modelos-fundacionales-en-finanzas/)
