Lección 5.7.2 · 80 min

Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer

Las arquitecturas de deep learning para series temporales se diferencian sobre todo en qué ve la red de una vez: un punto, una ventana entera, un parche de días o una serie completa. Esta lección programa cuatro, pequeñas, en PyTorch, entrena N-HiTS con neuralforecast y las pone a prever la volatilidad realizada de mañana, donde el HAR es casi el óptimo y el oráculo marca el suelo.

Fig. — Error cuadrático medio de prueba (2018-2025) de la previsión del log RV de mañana en los seis activos de vol5: cuatro redes programadas en PyTorch y NHITS de neuralforecast, frente al HAR y al oráculo (la previsión ingenua, 0,842, queda fuera de escala)
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

El banco de pruebas: volatilidad con la respuesta conocida

La lección 5.6.3 entrenó CNN 1D y LSTM con ventanas. Desde 2018 han aparecido arquitecturas pensadas para series que se citan en casi todos los artículos de previsión: la TCN (convoluciones causales dilatadas), N-BEATS y su sucesor N-HiTS (bloques densos con residuos dobles) y los transformers adaptados a series, como PatchTST e iTransformer. Esta lección programa versiones pequeñas de las cuatro, entrena N-HiTS con una librería y las compara donde se puede medir de verdad.

El problema es el de la lección 5.7.1: prever el logaritmo de la varianza realizada (log RV) de mañana de los seis activos de vol5_diario.parquet. Ahora cada modelo recibe la ventana de los últimos 64 días de cada serie, no las features HAR ya calculadas, y tiene que encontrar la estructura por sí mismo. Los datos son sintéticos y el generador es un HAR en el logaritmo de la varianza integrada, así que hay tres referencias:

ReferenciaQué esQué marca
Ingenuala log RV de hoylo mínimo que hay que batir
HARregresión sobre las medias de 1, 5 y 22 días de la ventanacasi el óptimo alcanzable con la ventana
Oráculoiv_prevista de vol5_verdad.parquet, la esperanza exacta de la varianza integradael suelo: nadie puede bajar de ahí
IN [01]python
import copy
import logging
import warnings
import numpy as np
import pandas as pd
import torch
from torch import nn

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)

v = pd.read_parquet("vol5_diario.parquet")
P = v.pivot(index="fecha", columns="activo", values="rv_5min")
P = np.log(P)
ACT = list(P.columns)
fechas = P.index
en_ent = fechas < "2016-01-01"
mu, sd = P[en_ent].mean(), P[en_ent].std()   # solo entrenamiento
Z = ((P - mu) / sd).to_numpy(dtype=np.float32)   # (días, 6)

L = 64
pos = np.arange(L, len(fechas))     # posición del día objetivo
f_obj = fechas[pos]
tramo = np.select([f_obj < "2016-01-01", f_obj < "2018-01-01"],
                  ["ent", "val"], "prueba")

def ventanas(ts):
    W = np.stack([Z[t - L:t] for t in ts])     # (n, L, 6)
    return torch.tensor(W), torch.tensor(Z[ts])

# multivariante: una muestra por fecha (para iTransformer)
D = {k: ventanas(pos[tramo == k]) for k in ("ent", "val", "prueba")}
# univariante: una muestra por fecha y activo (el resto)
U = {k: (W.permute(0, 2, 1).reshape(-1, L), Y.reshape(-1))
     for k, (W, Y) in D.items()}
print({k: tuple(W.shape) for k, (W, _) in U.items()})

def har_x(W):
    return np.c_[np.ones(len(W)), W[:, -1], W[:, -5:].mean(1),
                 W[:, -22:].mean(1)].astype(float)

b_har = np.linalg.lstsq(har_x(U["ent"][0].numpy()),
                        U["ent"][1].double().numpy(), rcond=None)[0]

ver = pd.read_parquet("vol5_verdad.parquet")
log_iv = np.log(ver.pivot(index="fecha", columns="activo",
                          values="iv_prevista"))[ACT]
cte = (P - log_iv)[en_ent].mean()   # RV frente a IV: constante
ORA = ((log_iv + cte - mu) / sd).to_numpy()

y_p = U["prueba"][1].double().numpy()
ref = {"ingenua": U["prueba"][0][:, -1].double().numpy(),
       "HAR": har_x(U["prueba"][0].numpy()) @ b_har,
       "oráculo": ORA[pos[tramo == "prueba"]].reshape(-1)}
ecm_ref = {k: np.mean((y_p - p) ** 2) for k, p in ref.items()}
print("HAR:", b_har.round(3))
print({k: round(e, 3) for k, e in ecm_ref.items()})
OUT [01]Ventanas de 64 días, cortes por fecha del objetivo y las tres referencias
{'ent': (11706, 64), 'val': (3018, 64), 'prueba': (12066, 64)}
HAR: [0.001 0.241 0.341 0.308]
{'ingenua': np.float64(0.842), 'HAR': np.float64(0.577), 'oráculo': np.float64(0.532)}
  • Cada serie se tipifica con la media y la desviación de su entrenamiento (2008-2015): así las seis comparten escala y un modelo global puede aprender de todas.
  • Los cortes van por la fecha del objetivo, como en 5.7.1. Con horizonte de un día no hace falta purgar: ninguna ventana de entrenamiento tiene su objetivo en la validación.
  • Las dos vistas de los mismos datos importan: 11.706 ventanas univariantes (fecha × activo) para los modelos que procesan una serie cada vez, y 1.951 ventanas multivariantes (una por fecha, seis series) para el iTransformer.
  • El oráculo necesita una constante por activo (la RV no ve la parte nocturna y su logaritmo tiene sesgo), estimada en el entrenamiento. Con ella, su error de prueba es el suelo de la tabla.

El error de prueba (en unidades tipificadas al cuadrado) es 0,842 para la ingenua, 0,577 para el HAR y 0,532 para el oráculo. El hueco que una red puede cerrar es el que hay entre el HAR y el oráculo, 0,045: un 8 % del error del HAR. Todo lo demás es ruido de medida de la RV y choques que nadie puede prever.

TCN: convoluciones causales dilatadas

Una red convolucional temporal (TCN; Bai, Kolter y Koltun, 2018) apila convoluciones 1D con dos condiciones:

  • Causales: la salida en el día tt solo usa entradas de tt hacia atrás. Se consigue rellenando con ceros solo por la izquierda.
  • Dilatadas: la capa jj mira posiciones separadas djd_j días (d=1,2,4,…d = 1, 2, 4, \ldots). El campo receptivo crece exponencialmente con el número de capas:
R=1+(k−1)∑jdj.R = 1 + (k - 1)\sum_j d_j .
Fig. — Convoluciones causales dilatadas con núcleo 2 y dilataciones 1, 2 y 4: la salida de hoy depende de los 8 últimos días y de ninguno futuro; con 6 capas (1 a 32) el campo receptivo llega a 64 (esquema)

La figura lo dibuja con tres capas (dilataciones 1, 2 y 4): 8 días. Con núcleo k=2k = 2 y dilataciones 1, 2, 4, 8, 16 y 32, R=64R = 64: seis capas bastan para ver toda la ventana. Cada capa suma su salida a la entrada (conexión residual), lo que facilita entrenar redes profundas. La red no tiene recurrencia: procesa todas las posiciones en paralelo, y por eso entrena más deprisa que una LSTM.

IN [02]python
class TCN(nn.Module):
    def __init__(self, canales=16, k=2, dil=(1, 2, 4, 8, 16, 32)):
        super().__init__()
        self.k, self.dil = k, dil
        self.entrada = nn.Conv1d(1, canales, 1)
        self.capas = nn.ModuleList(
            [nn.Conv1d(canales, canales, k, dilation=d) for d in dil])
        self.salida = nn.Linear(canales, 1)

    def forward(self, x):                      # x: (n, L)
        h = self.entrada(x.unsqueeze(1))       # (n, canales, L)
        for conv, d in zip(self.capas, self.dil):
            relleno = nn.functional.pad(h, ((self.k - 1) * d, 0))
            h = h + torch.relu(conv(relleno))  # causal y residual
        return self.salida(h[:, :, -1]).squeeze(-1)

torch.manual_seed(0)
tcn = TCN()
x = torch.zeros(1, 96, requires_grad=True)     # más larga que 64
tcn(x).sum().backward()
vistas = torch.nonzero(x.grad[0]).flatten()
print("posiciones con gradiente:", len(vistas),
      f"(de {vistas.min().item()} a {vistas.max().item()})")
print("parámetros:", sum(p.numel() for p in tcn.parameters()))
OUT [02]Una TCN y su campo receptivo, medido con autograd
posiciones con gradiente: 64 (de 32 a 95)
parámetros: 3217

Con una entrada de 96 días, la salida solo tiene gradiente respecto a las 64 últimas posiciones: el campo receptivo es exactamente el de la fórmula, y nada del futuro entra (no hay posiciones después de la última). Es la comprobación de autograd de la lección 5.7.1 usada para auditar una arquitectura.

N-BEATS y N-HiTS: bloques que restan lo que explican

N-BEATS (Oreshkin et al., 2020) no usa convoluciones ni recurrencia: solo capas densas organizadas en bloques. Cada bloque recibe una ventana y produce dos cosas: un backcast (su reconstrucción de la ventana) y un forecast (su previsión). El bloque siguiente recibe la ventana menos el backcast, es decir, lo que el anterior no ha explicado, y la previsión final es la suma de las previsiones de todos los bloques:

backcastforecastforecastbackcastforecastVentana xBloque 1x menos backcastSumaBloque 2residuoBloque 3
Fuente Mermaid
flowchart TB
  X[Ventana x] --> B1[Bloque 1]
  B1 -- backcast --> R1[x menos backcast]
  B1 -- forecast --> S[Suma]
  R1 --> B2[Bloque 2]
  B2 -- forecast --> S
  B2 -- backcast --> R2[residuo]
  R2 --> B3[Bloque 3]
  B3 -- forecast --> S

En la versión «interpretable», los bloques se restringen a bases de tendencia (polinomios) y de estacionalidad (senos y cosenos), y sus previsiones se pueden leer por separado. N-HiTS (Challu et al., 2023) añade dos ideas para horizontes largos: cada bloque submuestrea la entrada a una frecuencia distinta (pooling) y predice pocos puntos que se interpolan, así que unos bloques se especializan en lo lento y otros en lo rápido, con mucho menos cálculo. Con horizonte de un día esas ventajas no aparecen; la sección de neuralforecast lo comprueba.

IN [03]python
class Bloque(nn.Module):
    def __init__(self, L, H, ancho):
        super().__init__()
        self.mlp = nn.Sequential(nn.Linear(L, ancho), nn.ReLU(),
                                 nn.Linear(ancho, ancho), nn.ReLU())
        self.backcast = nn.Linear(ancho, L)
        self.forecast = nn.Linear(ancho, H)

    def forward(self, x):
        h = self.mlp(x)
        return self.backcast(h), self.forecast(h)

class NBeats(nn.Module):
    def __init__(self, L=64, H=1, ancho=64, n_bloques=3):
        super().__init__()
        self.bloques = nn.ModuleList(
            [Bloque(L, H, ancho) for _ in range(n_bloques)])

    def forward(self, x):                      # x: (n, L)
        residuo, prevision = x, 0
        for b in self.bloques:
            bc, fc = b(residuo)
            residuo = residuo - bc
            prevision = prevision + fc
        return prevision.squeeze(-1)

torch.manual_seed(0)
print("parámetros:", sum(p.numel() for p in NBeats().parameters()))
OUT [03]N-BEATS genérico en 25 líneas
parámetros: 37635

Atención: qué mira cada token

Un transformer (Vaswani et al., 2017) transforma una secuencia de tokens (vectores de dimensión dd) dejando que cada uno mire a todos los demás. Cada token produce una consulta qq, una clave kk y un valor vv (proyecciones lineales aprendidas), y la salida de un token es la media de los valores de todos, ponderada por lo que se parecen su consulta y cada clave:

Atencioˊn⁡(Q,K,V)=softmax⁡ ⁣(QK⊤d)V.\operatorname{Atención}(Q, K, V) = \operatorname{softmax}\!\left(\frac{Q K^\top}{\sqrt{d}}\right) V .

El factor d\sqrt{d} evita que los productos escalares crezcan con la dimensión y saturen el softmax. Varias cabezas repiten el cálculo con proyecciones distintas, y una codificación de posición sumada a cada token le dice a la red el orden, que la atención por sí sola no ve. Para hacerlo concreto, los tokens de abajo son los ocho parches de 8 días de la última ventana de prueba del índice, sin proyecciones aprendidas (Q=K=VQ = K = V):

IN [04]python
indice = ACT.index("indice")
fichas = U["prueba"][0].reshape(-1, 6, L)[-1, indice].reshape(8, 8)
Q = K = V = fichas.unsqueeze(0)                # (1, 8 tokens, d=8)
pesos = torch.softmax(Q @ K.transpose(-2, -1) / 8 ** 0.5, dim=-1)
a_mano = pesos @ V
de_torch = nn.functional.scaled_dot_product_attention(Q, K, V)
print("iguales:", torch.allclose(a_mano, de_torch, atol=1e-6))
print("pesos del último parche sobre los ocho:")
print(pesos[0, -1].numpy().round(2))
OUT [04]Atención de producto escalado, a mano y con PyTorch
iguales: True
pesos del último parche sobre los ocho:
[0.08 0.07 0.14 0.08 0.05 0.08 0.12 0.38]

Cada fila de pesos suma 1 y dice de qué parches toma información un token: el último parche pone 0,38 en sí mismo y reparte el resto; después de él pesan más el tercero (0,14) y el séptimo (0,12), los que más se le parecen. En un transformer de verdad, las proyecciones aprendidas deciden qué significa «parecerse». El coste es cuadrático en el número de tokens: con un token por día y ventanas de un año serían 252² productos por capa y cabeza. Ese coste, y que un día suelto lleva muy poca información, explican por qué los primeros transformers para series (Informer, Autoformer, FEDformer) rendían peor de lo que prometían: Zeng et al. (2023) mostraron que un modelo lineal sobre la ventana (DLinear) los batía en los bancos de pruebas habituales. Las dos respuestas que siguen cambian qué es un token.

PatchTST e iTransformer: qué es un token

PatchTST (Nie et al., 2023) corta cada serie en parches de varios días consecutivos y usa cada parche como token: con 64 días y parches de 8, la atención trabaja sobre 8 tokens en lugar de 64, y cada token lleva un trozo de dinámica local. Además es independiente por canal: cada serie se procesa por separado con los mismos pesos, como los modelos univariantes de esta lección. El título de su artículo, «una serie temporal vale 64 palabras», viene de ahí.

Fig. — La última ventana de prueba del índice (64 sesiones hasta el 30 de diciembre de 2025) cortada en 8 parches de 8 días, los tokens de PatchTST; sobre cada parche, el peso de atención que le da el último parche en la celda de la lección

iTransformer (Liu et al., 2024) da la vuelta a la idea: cada serie completa (los 64 días de un activo) se proyecta a un solo token, y la atención relaciona series entre sí. La dimensión temporal la procesa la red densa que el transformer aplica a cada token. Tiene sentido cuando unas series informan sobre otras: la volatilidad del índice sobre la de una acción, o la de un tipo de cambio sobre la de otro.

Serie 64 días8 parches8 tokensPatchTST6 series1 token por serieiTransformer
Fuente Mermaid
flowchart LR
  S[Serie 64 días] --> P[8 parches]
  P --> T1[8 tokens]
  T1 --> A1[PatchTST]
  M[6 series] --> E[1 token por serie]
  E --> A2[iTransformer]
IN [05]python
def codificador(d, cabezas=4, capas=2):
    capa = nn.TransformerEncoderLayer(d, cabezas, dim_feedforward=64,
                                      dropout=0.1, batch_first=True)
    return nn.TransformerEncoder(capa, capas,
                                 enable_nested_tensor=False)

class PatchTST(nn.Module):
    def __init__(self, L=64, parche=8, d=32):
        super().__init__()
        self.parche, n = parche, L // parche
        self.proy = nn.Linear(parche, d)
        self.posicion = nn.Parameter(0.02 * torch.randn(n, d))
        self.enc = codificador(d)
        self.cabeza = nn.Linear(n * d, 1)

    def forward(self, x):                      # x: (n, L)
        fichas = x.unfold(1, self.parche, self.parche)  # (n, 8, 8)
        h = self.enc(self.proy(fichas) + self.posicion)
        return self.cabeza(h.flatten(1)).squeeze(-1)

class ITransformer(nn.Module):
    def __init__(self, L=64, d=32):
        super().__init__()
        self.proy = nn.Linear(L, d)            # una serie, un token
        self.enc = codificador(d)
        self.cabeza = nn.Linear(d, 1)

    def forward(self, x):                      # x: (n, L, 6)
        h = self.enc(self.proy(x.transpose(1, 2)))   # (n, 6, d)
        return self.cabeza(h).squeeze(-1)            # (n, 6)

torch.manual_seed(0)
for m in (PatchTST(), ITransformer()):
    print(type(m).__name__, "parámetros:",
          sum(p.numel() for p in m.parameters()))
OUT [05]PatchTST e iTransformer con las capas de PyTorch
PatchTST parámetros: 17889
ITransformer parámetros: 19201

Los dos usan las capas estándar de PyTorch (nn.TransformerEncoderLayer: atención con varias cabezas, red densa, normalización y residuos). enable_nested_tensor=False evita un aviso: la optimización con tensores anidados solo se aplica con máscaras de relleno, que aquí no hay.

Entrenar y comparar

Las cuatro redes se entrenan con el mismo bucle que en 5.7.1, con una diferencia práctica: como los datos ya son tensores en memoria, los lotes se sacan con una permutación propia (con su generador) en lugar de un DataLoader; el resultado es el mismo y evita sobrecoste. Parada temprana con la validación (2016-2017), prueba en 2018-2025. Son redes pequeñas y pocas épocas para que la lección se ejecute en un par de minutos en CPU; en producción se entrenaría más y con varias semillas.

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

def prever(modelo, X):
    modelo.eval()
    with torch.no_grad():
        return modelo(X).reshape(-1).double().numpy()

redes = {"TCN": (TCN, U, {}), "N-BEATS": (NBeats, U, {}),
         "PatchTST": (PatchTST, U, {}),
         "iTransformer": (ITransformer, D,
                          {"epocas": 40, "paciencia": 8, "lote": 32})}
pred, filas = dict(ref), []
for nombre, (clase, datos, kw) in redes.items():
    torch.manual_seed(0)
    m = clase()
    epoca = entrenar(m, datos, **kw)
    pred[nombre] = prever(m, datos["prueba"][0])
    filas.append((nombre, sum(p.numel() for p in m.parameters()),
                  epoca))

hueco = ecm_ref["HAR"] - ecm_ref["oráculo"]
tabla = pd.DataFrame(filas, columns=["modelo", "parámetros",
                                     "mejor época"]).set_index("modelo")
tabla["ECM prueba"] = [np.mean((y_p - pred[n]) ** 2)
                       for n in tabla.index]
tabla["hueco cerrado"] = (ecm_ref["HAR"] - tabla["ECM prueba"]) / hueco
tabla.round(3)
OUT [06]Las cuatro redes con el mismo bucle
              parámetros  mejor época  ECM prueba  hueco cerrado
modelo
TCN                 3217            7       0.570          0.145
N-BEATS            37635            4       0.590         -0.290
PatchTST           17889            7       0.588         -0.249
iTransformer       19201            4       0.623         -1.043

Las predicciones del iTransformer salen en orden fecha × activo, el mismo que las univariantes (reshape de (n, 6)), así que todas se comparan fila a fila con los mismos objetivos. La columna «hueco cerrado» mide qué fracción de la distancia entre el HAR y el oráculo recorre cada modelo: 0 es el HAR, 1 sería el oráculo y un valor negativo, peor que el HAR.

IN [07]python
semana = np.repeat(f_obj[tramo == "prueba"].to_period("W"), 6)
for nombre in ("TCN", "N-BEATS"):
    dif = (y_p - pred["HAR"]) ** 2 - (y_p - pred[nombre]) ** 2
    s = pd.Series(dif).groupby(semana).mean()
    print(f"{nombre} frente a HAR: {dif.mean():+.4f} por día; "
          f"t = {s.mean() / s.std() * np.sqrt(len(s)):.1f}")
OUT [07]TCN frente a HAR, diferencia emparejada por semanas
TCN frente a HAR: +0.0065 por día; t = 3.1
N-BEATS frente a HAR: -0.0130 por día; t = -5.7

La misma familia en neuralforecast

Programar la arquitectura enseña lo que hace; para trabajar se usa una librería. neuralforecast (Nixtla) trae más de treinta modelos (TCN, NBEATS, NHITS, PatchTST, iTransformer, DeepAR…) sobre PyTorch y Lightning, con un formato de datos único: una tabla larga con unique_id (la serie), ds (la fecha o un entero) e y. Su cross_validation con refit=False hace exactamente el esquema de esta lección: entrena una vez con todo lo anterior a la validación, para la parada temprana con la validación y prevé cada día de la prueba con la ventana que termina el día anterior.

Como ds se usa un entero (la posición de la sesión) en lugar de la fecha: las sesiones de la NYSE no tienen una frecuencia regular de calendario y freq=1 evita que la librería invente fechas. La configuración por defecto de NHITS tiene unos 2,5 millones de parámetros y está pensada para horizontes largos; con capas de 64 neuronas baja a unos 58.000, entrena en una fracción del tiempo y prevé mejor.

IN [08]python
from neuralforecast import NeuralForecast
from neuralforecast.models import NHITS

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

larga = (pd.DataFrame(Z, columns=ACT).rename_axis("ds").reset_index()
         .melt(id_vars="ds", var_name="unique_id", value_name="y"))
nhits = NHITS(h=1, input_size=L, mlp_units=[[64, 64]] * 3,
              max_steps=600, val_check_steps=50,
              early_stop_patience_steps=4, learning_rate=1e-3,
              batch_size=32, windows_batch_size=256, scaler_type=None,
              random_seed=0, accelerator="cpu", deterministic=True,
              logger=False, enable_progress_bar=False,
              enable_model_summary=False, enable_checkpointing=False)
nf = NeuralForecast(models=[nhits], freq=1)
cv = nf.cross_validation(larga, val_size=(tramo == "val").sum(),
                         test_size=(tramo == "prueba").sum(),
                         n_windows=None, step_size=1, refit=False)
print(cv.shape, "parámetros:",
      sum(p.numel() for p in nf.models[0].parameters()))
cv = cv.sort_values(["ds", "unique_id"])       # fecha × activo
assert np.allclose(cv["y"].to_numpy(), y_p, atol=1e-6)
pred["NHITS (neuralforecast)"] = cv["NHITS"].to_numpy()
print(f"ECM prueba NHITS: {np.mean((y_p - cv['NHITS']) ** 2):.3f}")
OUT [08]NHITS con neuralforecast y validación cruzada temporal
(12066, 5) parámetros: 58499
ECM prueba NHITS: 0.600

El assert comprueba que los objetivos de la librería son los mismos, en el mismo orden, que los de la tabla anterior: si no lo fueran, la comparación no valdría nada. Es una línea que conviene poner siempre que se mezclan resultados de código propio y de una librería.

Lo que dicen los resultados

La figura de la cabecera reúne la prueba. Cuatro lecturas:

  • Todas las redes baten con holgura a la ingenua y ninguna se acerca al oráculo. El hueco entre el HAR y el oráculo es pequeño (un 8 % del error) y es casi todo lo que hay: el resto del error es medida y choques.
  • Solo la TCN mejora al HAR, y poco: 0,0065 por día, un 15 % del hueco, con t = 3,1 en la diferencia semanal. Es una evidencia moderada de un solo entrenamiento y una partición, después de probar cinco modelos; N-BEATS y PatchTST, en cambio, quedan peor que el HAR (N-BEATS con t = −5,7). Que una red tenga más parámetros no le da ventaja si la estructura que necesita es la de un modelo de cuatro coeficientes.
  • El iTransformer es el peor, por dos razones que se pueden nombrar. Su ventaja es relacionar series, y en vol5 cada activo sigue su propio HAR (solo comparten unos pocos choques); y ve seis veces menos ejemplos (una muestra por fecha), así que sobreajusta antes. Con volatilidades reales, correlacionadas entre activos, la atención entre series tendría algo que aprovechar.
  • El N-HiTS de la librería queda por detrás del HAR: más parámetros y una arquitectura pensada para horizontes largos no sustituyen a la estructura correcta.
ArquitecturaToken o unidadSesgo inductivoCuándo considerarla
TCNposición en el tiempocausalidad y localidad multiescalaseries largas, entrenamiento rápido
N-BEATS / N-HiTSventana enteradescomposición aditiva por bloquesmuchas series, horizontes largos (N-HiTS)
PatchTSTparche de díasdinámica local, independencia por canalventanas largas, muchas series parecidas
iTransformerserie enterarelaciones entre seriesmuchas series que se influyen
HARmedias de 1, 5 y 22 díaspersistencia a varias escalasvolatilidad: la baseline obligatoria

Ejercicios

  1. Las cifras de la tabla son de una semilla. Entrena N-BEATS (el más rápido de entrenar de los cuatro) con las semillas 0, 1 y 2 y da la media y la dispersión del error de prueba. ¿Cambia la conclusión frente al HAR?
Solución
IN [09]python
errores = []
for semilla in (0, 1, 2):
    torch.manual_seed(semilla)
    m = NBeats()
    entrenar(m, U, semilla=semilla)
    errores.append(np.mean((y_p - prever(m, U["prueba"][0])) ** 2))
print("ECM por semilla:", np.round(errores, 3),
      f"media {np.mean(errores):.3f}, desv. {np.std(errores):.3f}")
print(f"HAR: {ecm_ref['HAR']:.3f}")
OUT [09]N-BEATS con tres semillas
ECM por semilla: [0.59  0.591 0.593] media 0.591, desv. 0.001
HAR: 0.577

Las tres semillas dan 0,590, 0,591 y 0,593: una dispersión de 0,001, mucho menor que la distancia al HAR (0,014). Aquí la conclusión no depende de la semilla: N-BEATS queda por detrás del HAR con cualquiera de las tres. No siempre es así; con redes más grandes, menos datos o transformers, la dispersión entre semillas puede ser del orden de las diferencias entre arquitecturas. La forma honesta de presentar una red es la media y la dispersión de varias semillas (o el ensemble de todas, lección 5.10.3), con la baseline al lado.

  1. DLinear y sus parientes dicen que un modelo lineal sobre la ventana completa es un rival difícil. Ajusta por mínimos cuadrados una regresión del objetivo sobre los 64 días de la ventana (65 coeficientes) y compárala con el HAR (4 coeficientes).
Solución
IN [10]python
def con_cte(W):
    return np.c_[np.ones(len(W)), W.double().numpy()]

b64 = np.linalg.lstsq(con_cte(U["ent"][0]),
                      U["ent"][1].double().numpy(), rcond=None)[0]
e64 = np.mean((y_p - con_cte(U["prueba"][0]) @ b64) ** 2)
print(f"lineal 64 días: {e64:.3f}   HAR: {ecm_ref['HAR']:.3f}")
print("pesos de los 5 últimos días:", b64[-5:].round(3))
print("suma de los pesos de los días 23 a 64 atrás:",
      b64[1:43].sum().round(3))
OUT [10]Lineal sobre la ventana completa
lineal 64 días: 0.580   HAR: 0.577
pesos de los 5 últimos días: [0.082 0.086 0.061 0.096 0.32 ]
suma de los pesos de los días 23 a 64 atrás: 0.002

El lineal sobre la ventana queda prácticamente empatado con el HAR (0,580 frente a 0,577), algo peor: tiene 65 coeficientes para aprender lo que el HAR fija con 4 (pesos que decrecen con la distancia en tres escalones). Los pesos que estima tienen la misma forma: el del último día (0,32) domina, los de los días anteriores son menores y los de más allá del día 22 suman 0,002, nada, porque la media mensual del HAR tampoco llega más atrás. Es la lección de DLinear en pequeño: una vez la estructura es casi lineal, la complejidad extra se paga en varianza de estimación. El HAR gana en interpretabilidad y en estabilidad con pocos datos.

  1. Con datos reales de volatilidad de 50 acciones del mismo índice, ¿esperarías que el iTransformer mejorase su posición respecto a esta lección? ¿Qué comprobarías antes de creértelo?
Solución

Es razonable esperarlo: las volatilidades de acciones del mismo índice están muy correlacionadas (comparten el factor de mercado y los anuncios macro), y la atención entre series puede usar la volatilidad del resto para corregir la de cada una; además, con 50 series cada muestra tiene más información. Antes de creérselo: compararlo con un HAR que incluya la volatilidad del índice como regresor (la forma lineal de esa misma idea), validar con walk-forward y reentrenamiento (lección 5.4.3), repetir con varias semillas y comprobar que la normalización de cada serie usa solo su pasado. Si el HAR con la volatilidad del mercado le iguala, la atención no aporta nada que justifique su coste.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una TCN con núcleo 2 y dilataciones 1, 2, 4, 8, 16 y 32, ¿cuántos días del pasado puede ver su última salida?

    • A6, uno por capa
    • B32, la mayor dilatación
    • C64: 1 + (2 − 1)·(1 + 2 + 4 + 8 + 16 + 32)
    • DTodos, porque es convolucional
    Ver respuesta

    Respuesta C. 64: 1 + (2 − 1)·(1 + 2 + 4 + 8 + 16 + 32)

    Cada capa amplía el campo receptivo en (k − 1)·d. Con dilataciones que se duplican, el campo crece exponencialmente con el número de capas; la lección lo comprueba contando las posiciones de la entrada con gradiente distinto de cero.

  2. ¿Qué es un token en PatchTST y qué es en iTransformer?

    • AEn PatchTST, un parche de días consecutivos de una serie; en iTransformer, la ventana completa de una serie, así que la atención relaciona series entre sí
    • BEn los dos, un día
    • CEn PatchTST, una serie; en iTransformer, un parche
    • DEn los dos, un activo
    Ver respuesta

    Respuesta A. En PatchTST, un parche de días consecutivos de una serie; en iTransformer, la ventana completa de una serie, así que la atención relaciona series entre sí

    PatchTST corta cada serie en parches (aquí 8 de 8 días) y la atención mira entre parches de la misma serie. iTransformer invierte la idea: cada serie es un token y la atención mira entre series; el tiempo lo procesa la red densa de cada token.

  3. En los datos de la lección, el iTransformer queda el peor de los modelos entrenados. ¿Explicación más razonable?

    • ALos transformers no sirven para series
    • BLe faltan épocas
    • CTiene una fuga
    • DSu ventaja es relacionar series, y aquí las seis volatilidades son casi independientes; además ve 6 veces menos ejemplos (una muestra por fecha, no por activo y fecha) y sobreajusta antes
    Ver respuesta

    Respuesta D. Su ventaja es relacionar series, y aquí las seis volatilidades son casi independientes; además ve 6 veces menos ejemplos (una muestra por fecha, no por activo y fecha) y sobreajusta antes

    En el generador de vol5 cada activo tiene su propio HAR; solo comparten unos pocos choques. Con datos reales (volatilidades correlacionadas entre activos) la atención entre series tiene algo que aprovechar; aquí, no.

  4. La TCN cierra en torno a un 15 % del hueco entre el HAR y el oráculo, con una diferencia semanal frente al HAR de t ≈ 3. ¿Qué harías antes de usarla en producción?

    • ANada: es mejor que el HAR
    • BRepetir con varias semillas y en walk-forward con reentrenamiento, contar cuántas arquitecturas y configuraciones se probaron, y comparar el coste de mantenerla con lo que gana sobre un modelo de cuatro coeficientes
    • CAumentar el número de capas hasta superar al oráculo
    • DUsar solo el periodo de prueba en el que gana
    Ver respuesta

    Respuesta B. Repetir con varias semillas y en walk-forward con reentrenamiento, contar cuántas arquitecturas y configuraciones se probaron, y comparar el coste de mantenerla con lo que gana sobre un modelo de cuatro coeficientes

    Una mejora pequeña de un solo entrenamiento y una sola partición es frágil aunque su t sea 3. Con cinco modelos probados, el mejor sale favorecido por selección (lección 5.4.4).

  5. ¿Por qué el N-HiTS por defecto de neuralforecast es una mala elección para este problema sin ajustarlo?

    • APorque su configuración por defecto tiene unos 2,5 millones de parámetros para unas 12.000 ventanas de entrenamiento: sobreajusta y entrena mucho más despacio que una versión de 58.000
    • BPorque no admite horizonte 1
    • CPorque solo funciona con GPU
    • DPorque necesita covariables
    Ver respuesta

    Respuesta A. Porque su configuración por defecto tiene unos 2,5 millones de parámetros para unas 12.000 ventanas de entrenamiento: sobreajusta y entrena mucho más despacio que una versión de 58.000

    Los valores por defecto están pensados para horizontes largos y muchas series. Con pocas series y horizonte 1, una red pequeña llega antes y mejor.

Ideas clave

  • La TCN ve el pasado con convoluciones causales dilatadas: campo receptivo 1 + (k − 1)·Σd, comprobable con autograd. Es la más barata (3.217 parámetros) y aquí la mejor de las redes.
  • N-BEATS apila bloques que predicen y restan su «backcast» a la entrada; N-HiTS añade submuestreo e interpolación para horizontes largos. Con horizonte 1 su ventaja no aparece.
  • La atención es softmax(QKᵀ/√d)·V. En series, el token importa: un día suelto es mal token; un parche (PatchTST) o una serie entera (iTransformer) funcionan mejor.
  • Contra la verdad: ingenua 0,842, HAR 0,577 y oráculo 0,532 de error de prueba; las redes van de 0,570 a 0,623. Ninguna se acerca al oráculo y solo la TCN mejora un poco al HAR.
  • Con una respuesta casi lineal y poca señal, la arquitectura importa menos que la validación y la baseline. Un modelo de cuatro coeficientes es la vara de medir de cualquier red.

Recursos