---
title: "5.7.2 TCN, N-BEATS, PatchTST e iTransformer"
description: "Cuatro arquitecturas de series programadas en PyTorch y N-HiTS en neuralforecast, contra el HAR y el oráculo en la previsión de la volatilidad realizada."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/tcn-n-beats-patchtst-e-itransformer/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/tcn-n-beats-patchtst-e-itransformer.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.7 · Deep learning con PyTorch
- Duración: 80 min
- Requisitos: [5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/), [5.6.3 · CNN 1D, LSTM y GRU: ventanas, escalado y fugas](https://pythonparatrading.com/curso-python-trading/nivel-5/cnn-1d-lstm-y-gru-para-secuencias/), [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

- Explicar qué hace cada arquitectura (convoluciones causales dilatadas, bloques con backcast, atención sobre parches o sobre series) y qué sesgo inductivo aporta.
- Programar una TCN, un N-BEATS, un PatchTST y un iTransformer pequeños en PyTorch y comprobar con autograd el campo receptivo de la TCN.
- Calcular la atención de producto escalado a mano y contrastarla con la función de PyTorch.
- Entrenar N-HiTS con `neuralforecast` y su validación cruzada temporal sin reentrenar.
- Comparar todos los modelos con la previsión ingenua, el HAR y el oráculo, con el error de prueba, la fracción del hueco cerrado y una diferencia emparejada.

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

| Referencia | Qué es | Qué marca |
|---|---|---|
| Ingenua | la log RV de hoy | lo mínimo que hay que batir |
| HAR | regresión sobre las medias de 1, 5 y 22 días de la ventana | casi el óptimo alcanzable con la ventana |
| Oráculo | `iv_prevista` de `vol5_verdad.parquet`, la esperanza exacta de la varianza integrada | el suelo: nadie puede bajar de ahí |

```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()})
```
```output
{'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 $t$ solo usa entradas de $t$ hacia atrás. Se consigue rellenando con ceros solo
  por la izquierda.
- **Dilatadas**: la capa $j$ mira posiciones separadas $d_j$ días ($d = 1, 2, 4, \ldots$). El campo receptivo crece
  exponencialmente con el número de capas:

$$
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).* Cuatro filas de 12 puntos: la entrada y tres capas. Desde la salida de hoy (destacada, último punto de la capa 3) bajan dos conexiones separadas 4 posiciones, de cada una de ellas dos separadas 2 y de cada una de esas dos contiguas: en la entrada quedan marcados los 8 últimos días.

La figura lo dibuja con tres capas (dilataciones 1, 2 y 4): 8 días. Con núcleo $k = 2$ y dilataciones 1, 2, 4, 8,
16 y 32, $R = 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.

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

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

```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()))
```
```output
parámetros: 37635
```

## Atención: qué mira cada token

Un transformer (Vaswani et al., 2017) transforma una secuencia de **tokens** (vectores de dimensión $d$) dejando que
cada uno mire a todos los demás. Cada token produce una consulta $q$, una clave $k$ y un valor $v$ (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:

$$
\operatorname{Atención}(Q, K, V) = \operatorname{softmax}\!\left(\frac{Q K^\top}{\sqrt{d}}\right) V .
$$

El factor $\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 = V$):

```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))
```
```output
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.* Una serie de 64 puntos entre -2,1 y 1,6 dividida por líneas verticales en 8 parches. Encima, los pesos de atención del último parche: 0,08, 0,07, 0,14, 0,08, 0,05, 0,08, 0,12 y 0,38 para sí mismo (destacado, junto con su tramo de la serie). A la derecha, un círculo hueco marca el objetivo del día 65, -0,13.

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

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

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

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

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

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

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

Estos resultados son de **un** problema con la respuesta conocida y casi lineal. No dicen que la TCN sea mejor que
un transformer en general; dicen que, con poca señal y una estructura sencilla, la arquitectura importa menos que la
validación y la baseline. En la literatura, las comparaciones a gran escala dan resultados mixtos y muy dependientes
del banco de pruebas (Zeng et al., 2023, y la respuesta de PatchTST); el módulo 5.8 vuelve sobre ello con los modelos
fundacionales, y la lección 5.8.5 compara un fundacional con GARCH y LightGBM en estos mismos datos.

</aside>

| Arquitectura | Token o unidad | Sesgo inductivo | Cuándo considerarla |
|---|---|---|---|
| TCN | posición en el tiempo | causalidad y localidad multiescala | series largas, entrenamiento rápido |
| N-BEATS / N-HiTS | ventana entera | descomposición aditiva por bloques | muchas series, horizontes largos (N-HiTS) |
| PatchTST | parche de días | dinámica local, independencia por canal | ventanas largas, muchas series parecidas |
| iTransformer | serie entera | relaciones entre series | muchas series que se influyen |
| HAR | medias de 1, 5 y 22 días | persistencia a varias escalas | volatilidad: 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.**

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

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

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

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

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

   - A. 6, uno por capa
   - B. 32, la mayor dilatación
   - C. 64: 1 + (2 − 1)·(1 + 2 + 4 + 8 + 16 + 32)
   - D. Todos, porque es convolucional

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

   - 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í
   - B. En los dos, un día
   - C. En PatchTST, una serie; en iTransformer, un parche
   - D. En los dos, un activo

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

   - A. Los transformers no sirven para series
   - B. Le faltan épocas
   - C. Tiene una fuga
   - 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

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

   - A. Nada: es mejor que el HAR
   - 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
   - C. Aumentar el número de capas hasta superar al oráculo
   - D. Usar solo el periodo de prueba en el que gana

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

   - 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
   - B. Porque no admite horizonte 1
   - C. Porque solo funciona con GPU
   - D. Porque necesita covariables

   *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

### Documentación

- [Bai, Kolter y Koltun (2018): An Empirical Evaluation of Generic Convolutional and Recurrent Networks for Sequence Modeling (TCN)](https://arxiv.org/abs/1803.01271) · arxiv.org
- [Oreshkin et al. (2020): N-BEATS, Neural basis expansion analysis for interpretable time series forecasting](https://arxiv.org/abs/1905.10437) · arxiv.org
- [Challu et al. (2023): N-HiTS, Neural Hierarchical Interpolation for Time Series Forecasting](https://arxiv.org/abs/2201.12886) · arxiv.org
- [Nie et al. (2023): A Time Series is Worth 64 Words (PatchTST)](https://arxiv.org/abs/2211.14730) · arxiv.org
- [Liu et al. (2024): iTransformer, Inverted Transformers Are Effective for Time Series Forecasting](https://arxiv.org/abs/2310.06625) · arxiv.org
- [Zeng et al. (2023): Are Transformers Effective for Time Series Forecasting?](https://arxiv.org/abs/2205.13504) · 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.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/)

Lección siguiente: [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/)
