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.
- Módulo
- 5.7 · Deep learning con PyTorch
- Duración
- 80 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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í |
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()}){'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 solo usa entradas de hacia atrás. Se consigue rellenando con ceros solo por la izquierda.
- Dilatadas: la capa mira posiciones separadas días (). El campo receptivo crece exponencialmente con el número de capas:
La figura lo dibuja con tres capas (dilataciones 1, 2 y 4): 8 días. Con núcleo y dilataciones 1, 2, 4, 8, 16 y 32, : 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.
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()))posiciones con gradiente: 64 (de 32 a 95)
parámetros: 3217Con 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:
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.
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()))parámetros: 37635Atención: qué mira cada token
Un transformer (Vaswani et al., 2017) transforma una secuencia de tokens (vectores de dimensión ) dejando que cada uno mire a todos los demás. Cada token produce una consulta , una clave y un valor (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:
El factor 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 ():
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))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í.
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.
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]
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()))PatchTST parámetros: 17889
ITransformer parámetros: 19201Los 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.
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) 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.043Las 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.
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}")TCN frente a HAR: +0.0065 por día; t = 3.1
N-BEATS frente a HAR: -0.0130 por día; t = -5.7La 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.
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}")(12066, 5) parámetros: 58499
ECM prueba NHITS: 0.600El 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
vol5cada 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.
| 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
- 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
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}")ECM por semilla: [0.59 0.591 0.593] media 0.591, desv. 0.001
HAR: 0.577Las 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.
- 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
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))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.002El 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.
- 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
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.
¿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.
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
vol5cada 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.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).
¿Por qué el N-HiTS por defecto de
neuralforecastes 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
Documentación
- Bai, Kolter y Koltun (2018): An Empirical Evaluation of Generic Convolutional and Recurrent Networks for Sequence Modeling (TCN)
- Oreshkin et al. (2020): N-BEATS, Neural basis expansion analysis for interpretable time series forecasting
- Challu et al. (2023): N-HiTS, Neural Hierarchical Interpolation for Time Series Forecasting
- Nie et al. (2023): A Time Series is Worth 64 Words (PatchTST)
- Liu et al. (2024): iTransformer, Inverted Transformers Are Effective for Time Series Forecasting
- Zeng et al. (2023): Are Transformers Effective for Time Series Forecasting?