---
title: "5.7.1 PyTorch: tensores, autograd y bucle de entrenamiento"
description: "Tensores, autograd comprobado con diferencias finitas, Dataset y DataLoader con cortes temporales, bucle limpio, determinismo, torch.compile y Lightning."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento.png"
---

# 5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento

> Keras esconde el bucle de entrenamiento detrás de `fit`; PyTorch lo deja a la vista. Esta lección lo escribe pieza a pieza sobre un problema con la respuesta conocida, la volatilidad realizada de mañana: tensores, gradientes comprobados con diferencias finitas, datos cortados por fechas, un bucle con parada temprana y la misma red en Lightning, con resultados idénticos bit a bit.

- 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: 75 min
- Requisitos: [5.6.2 · Un MLP sobre un panel de acciones: regularizar y comparar](https://pythonparatrading.com/curso-python-trading/nivel-5/mlp-para-datos-tabulares-financieros/), [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/), [2.1.4 · Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch](https://pythonparatrading.com/curso-python-trading/nivel-2/volatilidad-arch-garch-egarch-gjr/)

## Al terminar esta lección

- Manejar tensores sin sorpresas: memoria compartida con NumPy, `dtype` (`float32` frente a `float64`), broadcasting y dispositivo.
- Calcular gradientes con autograd y comprobarlos contra la derivada analítica y contra diferencias finitas, sabiendo elegir el paso en cada precisión.
- Construir un `Dataset` y un `DataLoader` con cortes por fecha, purga en la frontera y normalización con estadísticas del entrenamiento.
- Escribir un bucle de entrenamiento limpio (modos `train`/`eval`, `zero_grad`, parada temprana) que sea determinista, y compararlo con la solución cerrada y con una baseline.
- Situar GPU, MPS, `torch.compile` y Lightning: qué aportan, qué cuestan y cómo reproducir con Lightning el mismo resultado que el bucle escrito a mano.

## Del `fit` de Keras al bucle a la vista

En el módulo 5.6, Keras entrena con una llamada: `model.fit(x, y, epochs=…)`. Dentro hay un bucle que pide lotes,
calcula la pérdida, deriva, actualiza los pesos y evalúa. PyTorch no esconde ese bucle: lo escribes tú. Es más código,
y es la razón por la que casi toda la investigación en deep learning se publica en PyTorch: cuando algo falla, el
fallo está en líneas que puedes leer.

| Pieza | Keras 3 (5.6) | PyTorch | Lightning |
|---|---|---|---|
| Datos | arrays o `PyDataset` | `Dataset` + `DataLoader` | los mismos `DataLoader` |
| Modelo | `keras.Model` | `nn.Module` | `LightningModule` (envuelve uno) |
| Gradientes | ocultos en `fit` | `loss.backward()` | ocultos en el `Trainer` |
| Bucle | `fit` | escrito a mano | `Trainer.fit` |
| Dispositivo | backend y configuración | `.to(dispositivo)` explícito | `accelerator=` |

El problema de la lección es el de la volatilidad del nivel 5: predecir el logaritmo de la **varianza realizada** de
mañana de seis activos ficticios (`vol5_diario.parquet`) con las features del modelo HAR de Corsi (2009): la RV de
hoy, su media de 5 sesiones y su media de 22, más la parte negativa del rendimiento de hoy. Los datos son
sintéticos y la verdad es conocida: el generador es un HAR en el logaritmo de la varianza integrada, así que un HAR
lineal es casi el óptimo y sirve de vara de medir. Una red que lo mejore mucho estaría midiendo un error.

```python
import copy
import numpy as np
import pandas as pd
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset

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

v = pd.read_parquet("vol5_diario.parquet")
v["x"] = np.log(v["rv_5min"])

def har(g):
    """Features HAR de un activo y objetivo: log RV de mañana."""
    x = g["x"]
    return pd.DataFrame({
        "fecha": g["fecha"], "activo": g["activo"],
        "d": x, "w": x.rolling(5).mean(), "m": x.rolling(22).mean(),
        "neg": np.minimum(g["rendimiento"], 0),
        "y": x.shift(-1), "fecha_y": g["fecha"].shift(-1)})

h = pd.concat([har(g) for _, g in v.groupby("activo")]).dropna()
h["tramo"] = np.select(
    [h["fecha_y"] < "2016-01-01",
     (h["fecha"] >= "2016-01-01") & (h["fecha_y"] < "2018-01-01"),
     h["fecha"] >= "2018-01-01"],
    ["ent", "val", "prueba"], "purga")

ent = h["tramo"] == "ent"
centro = h[ent].groupby("activo")["d"].mean()
escala = h[ent].groupby("activo")["neg"].std()
for c in ["d", "w", "m", "y"]:
    h[c] = h[c] - h["activo"].map(centro)
h["neg"] = h["neg"] / h["activo"].map(escala)
print(h["tramo"].value_counts().to_string())
print(h.loc[h["tramo"] == "purga", ["fecha", "fecha_y"]]
      .drop_duplicates().to_string(index=False))
```
```output
tramo
prueba    12060
ent       11958
val        3012
purga        12
     fecha    fecha_y
2015-12-31 2016-01-04
2017-12-29 2018-01-02
```

Tres decisiones que se repiten en todo el módulo:

- **El corte se hace por la fecha de la etiqueta.** Entrenamiento: objetivos anteriores a 2016; validación: 2016 y
  2017; prueba: de 2018 en adelante. Las filas cuyo objetivo cruza una frontera (la del 31 de diciembre de 2015 apunta a
  la RV del 4 de enero de 2016) se **purgan**, como en la lección 5.4.2 con etiquetas más largas.
- **Las estadísticas salen del entrenamiento.** El centro de cada activo (la media de su log RV) y la escala de los
  rendimientos negativos se calculan con 2008-2015 y se aplican igual a validación y prueba. Calcularlas con toda la
  muestra mete el futuro en el pasado (lección 5.2.4).
- **Un modelo global.** Los seis activos van juntos, centrados cada uno en su nivel: 12.000 filas de entrenamiento en
  lugar de 2.000. Los cambios de nivel de la muestra (el bono en 2022, la cripto en 2019) quedan fuera del centro
  calculado con 2008-2015: el modelo tiene que seguirlos con la persistencia de las propias features.

## Tensores

Un tensor es un array de n dimensiones con tres atributos que importan: `dtype`, `shape` y `device`. Y, si se pide,
una historia de operaciones para derivar. Casi toda la API imita a NumPy; las diferencias están en los detalles.

```python
a = h[["d", "w"]].to_numpy()[:3].copy()
t = torch.from_numpy(a)          # comparte memoria con `a`
print(t.dtype, tuple(t.shape))
t[0, 0] = 9.0
print("a[0, 0] tras cambiar t:", a[0, 0])
c = torch.tensor(a)              # copia
c[0, 0] = -1.0
print("a[0, 0] tras cambiar c:", a[0, 0])
print("de floats de Python:", torch.tensor([0.5, 1.0]).dtype)

x = torch.arange(6, dtype=torch.float32).reshape(3, 2)
print(x - x.mean(dim=0))         # (3, 2) - (2,)
print(x.T.is_contiguous(), x.device)
```
```output
torch.float64 (3, 2)
a[0, 0] tras cambiar t: 9.0
a[0, 0] tras cambiar c: 9.0
de floats de Python: torch.float32
tensor([[-2., -2.],
        [ 0.,  0.],
        [ 2.,  2.]])
False cpu
```

- `torch.from_numpy` no copia: cambiar el tensor cambia el array. `torch.tensor` sí copia.
- Un array de NumPy es `float64`; un tensor creado con floats de Python es `float32`, el `dtype` por defecto de
  PyTorch y el de las capas. Mezclarlos no se convierte en silencio:

```python raises
capa = nn.Linear(2, 1)           # pesos float32
capa(torch.from_numpy(a))        # entrada float64
```
```output
RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float
```

- El broadcasting sigue las reglas de NumPy: `(3, 2) - (2,)` resta la media de cada columna.
- `x.T` es una **vista** con otros pasos de memoria (no contigua): no copia nada. Algunas operaciones (`view`) la
  rechazan; `reshape` o `.contiguous()` copian cuando hace falta.
- `x.device` dice dónde vive el tensor. Dos tensores en dispositivos distintos no se pueden operar juntos.

## Autograd y cómo comprobarlo

Cuando un tensor tiene `requires_grad=True`, PyTorch apunta cada operación que lo usa en un **grafo** dirigido. Al
llamar a `backward()` sobre un escalar, recorre ese grafo hacia atrás con la regla de la cadena y deja en cada hoja
su derivada en `.grad`. Es diferenciación automática en modo inverso: el coste es del orden de una evaluación más de
la función, tenga uno o un millón de parámetros.

Para verlo con una pérdida de las que se usan en volatilidad: un modelo lineal del logaritmo de la varianza,
$f = X w$, evaluado con la **QLIKE** (la pérdida de la tabla de previsiones del README de los datos), que salvo
constantes es

$$
L(w) = \frac{1}{n}\sum_i \left( RV_i\, e^{-f_i} + f_i \right),
\qquad
\nabla L = \frac{1}{n} X^\top \left( 1 - RV \odot e^{-f} \right).
$$

```mermaid
flowchart LR
  W[w hoja] --> F[f = X w]
  X[X datos] --> F
  F --> E[RV e^-f + f]
  E --> L[media L]
  L -. backward .-> W
```

```python
cols = ["d", "w", "m", "neg"]
Xq = torch.tensor(h.loc[ent, cols].to_numpy())
Xq = torch.cat([torch.ones(len(Xq), 1, dtype=Xq.dtype), Xq], dim=1)
rv = torch.exp(torch.tensor(h.loc[ent, "y"].to_numpy()))

def qlike(w):
    f = Xq @ w
    return (rv * torch.exp(-f) + f).mean()

w = torch.tensor([0.0, 0.4, 0.3, 0.2, -0.1], dtype=torch.float64,
                 requires_grad=True)
perdida = qlike(w)
perdida.backward()
with torch.no_grad():
    f = Xq @ w
    analitico = Xq.T @ (1 - rv * torch.exp(-f)) / len(Xq)
print("autograd :", w.grad.numpy().round(6))
print("analítico:", analitico.numpy().round(6))
print("máx. diferencia:", f"{(w.grad - analitico).abs().max():.1e}")
```
```output
autograd : [-0.127462  0.087152  0.045058  0.02778  -0.022228]
analítico: [-0.127462  0.087152  0.045058  0.02778  -0.022228]
máx. diferencia: 1.6e-15
```

Coinciden hasta el último decimal de `float64`. Cuando no hay derivada analítica (una red, una pérdida rara), la
comprobación es con **diferencias finitas centradas**: $\partial L/\partial w_j \approx (L(w + h e_j) - L(w - h
e_j)) / 2h$. Su error tiene dos partes que tiran en sentidos contrarios: el truncamiento, proporcional a $h^2$, y el
redondeo de restar dos números casi iguales, proporcional a $\varepsilon/h$, con $\varepsilon$ la precisión de la
máquina. El mejor paso está donde se cruzan, hacia $\varepsilon^{1/3}$.

```python
def dif_finitas(w0, paso, dtype):
    X_, rv_ = Xq.to(dtype), rv.to(dtype)
    w0 = w0.detach().to(dtype)
    g = torch.zeros_like(w0)
    for j in range(len(w0)):
        e = torch.zeros_like(w0)
        e[j] = paso
        Lmas = (rv_ * torch.exp(-X_ @ (w0 + e)) + X_ @ (w0 + e)).mean()
        Lmen = (rv_ * torch.exp(-X_ @ (w0 - e)) + X_ @ (w0 - e)).mean()
        g[j] = (Lmas - Lmen) / (2 * paso)
    return g

errores = {}
for dt in (torch.float64, torch.float32):
    for k in range(1, 10):
        g = dif_finitas(w, 10.0 ** -k, dt).double()
        errores[(str(dt)[6:], f"1e-{k}")] = (g - w.grad).abs().max()
err = pd.Series(errores).unstack(0)
print(err.map(lambda e: f"{e:.1e}").to_string())
print("gradcheck:", torch.autograd.gradcheck(qlike, (w,)))
```
```output
      float32  float64
1e-1  5.9e-03  5.9e-03
1e-2  5.5e-05  5.8e-05
1e-3  9.2e-05  5.8e-07
1e-4  9.5e-04  5.8e-09
1e-5  9.7e-03  6.5e-11
1e-6  8.7e-02  1.1e-10
1e-7  1.3e-01  1.2e-09
1e-8  1.3e-01  8.9e-09
1e-9  1.3e-01  1.1e-07
gradcheck: True
```

*Fig. — Error máximo del gradiente de la QLIKE calculado con diferencias finitas centradas frente al de autograd, según el paso y la precisión.* Dos curvas en escala logarítmica doble. Con pasos grandes las dos coinciden y el error baja dos décadas por cada década de paso (truncamiento). En float32 el mínimo es 5,5e-5 con h = 1e-2 y después el error sube hasta 0,13 con h = 1e-7. En float64 el mínimo (destacado) es 6,5e-11 con h = 1e-5, y con pasos menores el redondeo lo sube hasta 1,1e-7 con h = 1e-9.

La figura pone la tabla en escala logarítmica. En `float64` el error baja con $h^2$ (cada década de paso, dos décadas
de error) hasta $h = 10^{-5}$, donde llega a 6,5·10⁻¹¹; con pasos menores, el redondeo lo vuelve a subir. En
`float32` el mínimo llega mucho antes, con $h = 10^{-2}$ y un error de 5,5·10⁻⁵, y con $h = 10^{-6}$ el error (0,087)
ya es del tamaño del propio gradiente: la estimación es basura. De ahí la regla práctica: las comprobaciones de gradientes se hacen en `float64`, que es lo que
hace `torch.autograd.gradcheck` (y por eso exige entradas de doble precisión).

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

Tres trampas de autograd que se ven en código real:

- **Los gradientes se acumulan.** Cada `backward()` **suma** en `.grad`; si no se pone a cero antes de cada lote
  (`optimizer.zero_grad()`), el paso usa la suma de todos los gradientes anteriores. El ejercicio 2 lo muestra.
- **Evaluar sin `torch.no_grad()`** construye el grafo igualmente: gasta memoria y tiempo para nada.
- **`.item()`, `.numpy()` o `.detach()` cortan el grafo.** Si una pérdida personalizada pasa por NumPy, su gradiente
  no llega a los pesos, y el modelo no aprende sin que nada lo avise.

</aside>

## Dataset y DataLoader con cortes temporales

Un `Dataset` responde a dos preguntas: cuántos ejemplos hay (`__len__`) y cuál es el i-ésimo (`__getitem__`). Un
`DataLoader` los junta en lotes, los baraja si se le pide y puede cargarlos en procesos paralelos. La normalización
de las features (media y desviación de cada columna) sale, otra vez, del entrenamiento.

```python
FEATS = ["d", "w", "m", "neg"]
media = h.loc[ent, FEATS].mean()
desv = h.loc[ent, FEATS].std()

class SerieVol(Dataset):
    """Features tipificadas con el entrenamiento y log RV de mañana."""
    def __init__(self, df):
        z = (df[FEATS] - media) / desv
        self.X = torch.tensor(z.to_numpy(), dtype=torch.float32)
        self.y = torch.tensor(df["y"].to_numpy(), dtype=torch.float32)
        self.fecha_y = df["fecha_y"].to_numpy()

    def __len__(self):
        return len(self.y)

    def __getitem__(self, i):
        return self.X[i], self.y[i]

ds = {k: SerieVol(h[h["tramo"] == k]) for k in ("ent", "val", "prueba")}
print({k: len(d) for k, d in ds.items()})
print("último objetivo de ent:",
      ds["ent"].fecha_y.max().astype("datetime64[D]"))
print("primer objetivo de val:",
      ds["val"].fecha_y.min().astype("datetime64[D]"))

def cargador(semilla=0):
    return DataLoader(ds["ent"], batch_size=256, shuffle=True,
                      generator=torch.Generator().manual_seed(semilla))

xb, yb = next(iter(cargador()))
print(tuple(xb.shape), tuple(yb.shape), xb.dtype)
```
```output
{'ent': 11958, 'val': 3012, 'prueba': 12060}
último objetivo de ent: 2015-12-31
primer objetivo de val: 2016-01-05
(256, 4) (256,) torch.float32
```

Barajar **dentro** del entrenamiento es correcto: las filas ya están cortadas por fecha y cada una lleva solo
información de su pasado, así que el orden de los lotes no filtra nada; barajar ayuda al descenso por gradiente
estocástico. Lo incorrecto es barajar **antes** de cortar (`random_split` sobre toda la muestra): es el k-fold
aleatorio de la lección 5.4.1, con vecinos casi idénticos a los dos lados del corte.

El `generator` propio no es un detalle: hace que el orden de los lotes dependa solo de su semilla, no de cuántos
números aleatorios haya consumido antes el resto del programa. La sección de Lightning muestra por qué importa.

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

Con `num_workers > 0`, cada proceso de carga recibe una semilla derivada de la del `DataLoader`; si el `Dataset`
usa NumPy o `random` para aumentar datos, hay que sembrarlos en `worker_init_fn` (la página de reproducibilidad de
PyTorch trae la receta). Aquí los datos caben en memoria y se cargan en el proceso principal (`num_workers=0`, el
valor por defecto): en CPU y con tensores ya hechos, los procesos de carga solo añadirían coste.

</aside>

## nn.Module y un bucle de entrenamiento limpio

Un modelo es una subclase de `nn.Module`: en `__init__` se declaran las capas (sus parámetros quedan registrados) y
`forward` dice cómo se combinan. El bucle es siempre el mismo:

```mermaid
flowchart TB
  A[Lote x, y] --> B[zero_grad]
  B --> C[forward]
  C --> D[pérdida]
  D --> E[backward]
  E --> F[step]
  F --> A
  F --> G[val con no_grad]
  G --> H[mejor state_dict]
```

```python
class Lineal(nn.Module):
    def __init__(self, n):
        super().__init__()
        self.capa = nn.Linear(n, 1)

    def forward(self, x):
        return self.capa(x).squeeze(-1)

class MLP(nn.Module):
    def __init__(self, n, oculta=32, p=0.1):
        super().__init__()
        self.red = nn.Sequential(
            nn.Linear(n, oculta), nn.ReLU(), nn.Dropout(p),
            nn.Linear(oculta, oculta), nn.ReLU(), nn.Dropout(p),
            nn.Linear(oculta, 1))

    def forward(self, x):
        return self.red(x).squeeze(-1)

def evaluar(modelo, d):
    modelo.eval()                     # dropout apagado
    with torch.no_grad():             # sin grafo
        return nn.functional.mse_loss(modelo(d.X), d.y).item()

def entrenar(modelo, epocas=40, lr=1e-3, wd=0.0, paciencia=8,
             semilla=0, restaurar=True):
    opt = torch.optim.Adam(modelo.parameters(), lr=lr,
                           weight_decay=wd)
    dl = cargador(semilla)
    hist, mejor, sin_mejora = [], (np.inf, None, -1), 0
    for epoca in range(epocas):
        modelo.train()                # dropout encendido
        suma = 0.0
        for xb, yb in dl:
            opt.zero_grad()
            perdida = nn.functional.mse_loss(modelo(xb), yb)
            perdida.backward()
            opt.step()
            suma += perdida.item() * len(yb)
        val = evaluar(modelo, ds["val"])
        hist.append((epoca, suma / len(ds["ent"]), val))
        if val < mejor[0]:
            mejor = (val, copy.deepcopy(modelo.state_dict()), epoca)
            sin_mejora = 0
        else:
            sin_mejora += 1
            if sin_mejora >= paciencia:
                break
    if restaurar:
        modelo.load_state_dict(mejor[1])
    return pd.DataFrame(hist, columns=["epoca", "ent", "val"]), mejor[2]

torch.manual_seed(0)
lineal = Lineal(4)
hist_lin, ep_lin = entrenar(lineal, epocas=30, lr=1e-2, paciencia=30)
torch.manual_seed(0)
mlp = MLP(4)
hist_mlp, ep_mlp = entrenar(mlp, lr=1e-3, wd=1e-4)
n_par = sum(p.numel() for p in mlp.parameters())
print(f"lineal: mejor época {ep_lin}; MLP ({n_par} parámetros): "
      f"mejor época {ep_mlp} de {len(hist_mlp)}")
```
```output
lineal: mejor época 5; MLP (1249 parámetros): mejor época 8 de 17
```

Los detalles que separan un bucle limpio de uno que «casi funciona»:

- `modelo.train()` y `modelo.eval()` encienden y apagan el dropout (y, si hubiera, la normalización por lotes). Evaluar
  en modo entrenamiento da una pérdida de validación ruidosa y peor de la real.
- `opt.zero_grad()` antes de cada `backward()`, por la acumulación de gradientes.
- La parada temprana guarda una **copia** del mejor `state_dict` (`copy.deepcopy`: sin ella, el diccionario apunta a
  los mismos tensores, que se siguen actualizando) y la restaura al final.
- La época de parada se elige con la validación, así que la validación ya no es fuera de muestra para ese modelo: el
  resultado se mide en la prueba.

Un modelo lineal con pérdida cuadrática tiene solución cerrada (mínimos cuadrados). Si el bucle está bien, Adam tiene
que llegar cerca de ella. Es una comprobación barata de todo lo anterior: datos, tipificación, bucle y optimizador.

```python
Xe = np.c_[np.ones(len(ds["ent"])), ds["ent"].X.double().numpy()]
b_mco = np.linalg.lstsq(Xe, ds["ent"].y.double().numpy(),
                        rcond=None)[0]
b_torch = np.r_[lineal.capa.bias.item(),
                lineal.capa.weight.detach().double().numpy()[0]]
print(pd.DataFrame({"MCO": b_mco, "Adam": b_torch},
                   index=["const"] + FEATS).round(3).T.to_string())
# en las unidades del HAR: pendiente por unidad de log RV
print("HAR (MCO):", (b_mco[1:4] / desv[["d", "w", "m"]]).round(3)
      .to_dict())
```
```output
      const      d      w      m    neg
MCO   0.000  0.120  0.159  0.138 -0.035
Adam -0.005  0.124  0.175  0.137 -0.028
HAR (MCO): {'d': 0.2, 'w': 0.342, 'm': 0.342}
```

Adam queda cerca de la solución cerrada, no encima: las pendientes difieren en la segunda cifra (0,175 frente a 0,159
en la media de 5 días), por el ruido de los lotes de 256 filas y porque la parada temprana se queda con la época 5.
En el error de prueba la diferencia no se ve (sección siguiente). La última línea traduce los coeficientes de mínimos
cuadrados a las unidades del HAR: 0,20 para la RV de hoy y 0,34 para cada media. El generador usa 0,36, 0,34 y 0,22
sobre el logaritmo de la varianza **integrada**; la RV de un día la mide con error, y ese error atenúa el coeficiente
del día y pasa peso a las medias, que lo promedian. Es lo que se espera, no un fallo del bucle.

## El resultado, contra la referencia, y el determinismo

La figura de la cabecera muestra las curvas de validación de los dos modelos por época. El lineal se estabiliza en
cinco épocas sobre el valor de mínimos cuadrados, 0,197, y oscila alrededor por el ruido de los lotes; la MLP baja a
0,191 en su mejor época, la 8, y la parada temprana corta en la 16, tras ocho épocas sin mejorar.
Falta lo que importa: la prueba, 2018-2025, que no se ha tocado para elegir nada.

```python
def predecir(modelo, d):
    modelo.eval()
    with torch.no_grad():
        return modelo(d.X).double().numpy()

dp = ds["prueba"]
y = dp.y.double().numpy()
Xp = np.c_[np.ones(len(dp)), dp.X.double().numpy()]
pred = {"ingenua (RV de hoy)": (dp.X[:, 0].double().numpy()
                                * desv["d"] + media["d"]),
        "HAR por MCO": Xp @ b_mco,
        "lineal en PyTorch": predecir(lineal, dp),
        "MLP": predecir(mlp, dp)}
ecm = {k: np.mean((y - p) ** 2) for k, p in pred.items()}
print(pd.Series(ecm).round(3).to_string())

# diferencia de errores al cuadrado, media por semana
dif = (y - pred["HAR por MCO"]) ** 2 - (y - pred["MLP"]) ** 2
semana = pd.to_datetime(h.loc[h["tramo"] == "prueba", "fecha"]
                        ).dt.to_period("W").to_numpy()
s = pd.Series(dif).groupby(semana).mean()
print(f"MLP frente a HAR: {dif.mean() / ecm['HAR por MCO']:.1%} "
      f"menos ECM; t = {s.mean() / s.std() * np.sqrt(len(s)):.1f} "
      f"({len(s)} semanas)")
```
```output
ingenua (RV de hoy)    0.319
HAR por MCO            0.213
lineal en PyTorch      0.213
MLP                    0.208
MLP frente a HAR: 2.5% menos ECM; t = 6.3 (418 semanas)
```

*Fig. — Error cuadrático medio de prueba (2018-2025) de la previsión del log RV de mañana en los seis activos: ingenua, HAR por mínimos cuadrados, el mismo lineal entrenado en PyTorch y la MLP.* Cuatro barras horizontales. Ingenua 0,319; HAR por mínimos cuadrados 0,213; lineal en PyTorch 0,213; MLP (destacada) 0,208, un 2,5 % menos que el HAR.

Lo que dice la tabla, en orden:

- **La ingenua pierde de lejos.** La RV de hoy sola tiene un error cuadrático un 50 % mayor que el HAR: la media de
  varios días quita buena parte del error de medida de un día.
- **El lineal entrenado con Adam y el de mínimos cuadrados dan lo mismo** en la prueba. El bucle hace lo que debe.
- **La MLP mejora al HAR en un 2,5 %**, con una diferencia semanal de t = 6,3 en 418 semanas. Es una no linealidad real de
  estos datos: el error de medida del logaritmo de la RV es asimétrico (un χ² con 78 grados de libertad en logaritmos)
  y el efecto de los rendimientos negativos no es lineal en el rendimiento. Pero es una mejora pequeña sobre un modelo
  de cuatro coeficientes; con datos reales habría que comprobarla en varios periodos (lección 5.4.3) y descontar
  cuántas arquitecturas se probaron (lección 5.4.4) antes de creérsela.

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

Las cifras son de un solo entrenamiento con una semilla. Otra semilla da otra MLP y otro error; la forma honesta de
presentar una red es la media y la dispersión de varias semillas, y se deja para el ejercicio de la lección 5.7.2,
donde las arquitecturas son más caras y la variación entre semillas, mayor.

</aside>

Que el resultado sea **reproducible** no viene gratis. Con lo fijado al principio de la lección (semilla, algoritmos
deterministas, hilos fijos por el entorno) y el generador propio del `DataLoader`, dos entrenamientos con la misma
semilla dan los mismos pesos, bit a bit; con otra semilla, otros pesos.

```python
def pesos(m):
    return torch.cat([p.detach().flatten() for p in m.parameters()])

copias = []
for semilla in (0, 0, 1):
    torch.manual_seed(semilla)
    m = MLP(4)
    entrenar(m, epocas=5, semilla=semilla)
    copias.append(pesos(m))
print("semilla 0 dos veces:", torch.equal(copias[0], copias[1]))
print("semilla 0 frente a 1:", torch.equal(copias[0], copias[2]))
```
```output
semilla 0 dos veces: True
semilla 0 frente a 1: False
```

| Qué se fija | Cómo | Qué evita |
|---|---|---|
| Inicialización y dropout | `torch.manual_seed(…)` antes de crear el modelo | pesos iniciales y máscaras distintas |
| Orden de los lotes | `DataLoader(…, generator=torch.Generator().manual_seed(…))` | depender del estado del generador global |
| Algoritmos | `torch.use_deterministic_algorithms(True)` | operaciones con resultados que cambian entre ejecuciones (sobre todo en GPU) |
| Hilos | `OMP_NUM_THREADS` fijo (el runner del curso pone 4) | sumas en coma flotante repartidas de otra forma |
| Salidas | redondear a lo que pide la conclusión | decimales lejanos que cambian entre CPU |

## GPU, MPS y torch.compile

En CPU, una red de 1.249 parámetros con 12.000 filas entrena en segundos. Las redes grandes necesitan un
acelerador. PyTorch los trata como dispositivos: el modelo y cada lote se mueven con `.to(dispositivo)` y todo lo
demás es igual.

```python
if torch.cuda.is_available():
    dispositivo = torch.device("cuda")
elif torch.backends.mps.is_available():
    dispositivo = torch.device("mps")
else:
    dispositivo = torch.device("cpu")
print(dispositivo, torch.__version__)
```
```output
cpu 2.14.1+cpu
```

En una máquina con GPU NVIDIA, el bucle de entrenamiento cambia en tres líneas, y se suele añadir precisión mixta
(`bfloat16` en las GPU recientes). El código no se ejecuta en el curso, que corre solo en CPU:

```python skip
modelo = MLP(4).to(dispositivo)
opt = torch.optim.Adam(modelo.parameters(), lr=1e-3)
dl = DataLoader(ds["ent"], batch_size=4096, shuffle=True,
                pin_memory=True,
                generator=torch.Generator().manual_seed(0))
for xb, yb in dl:
    xb = xb.to(dispositivo, non_blocking=True)
    yb = yb.to(dispositivo, non_blocking=True)
    opt.zero_grad()
    with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
        perdida = nn.functional.mse_loss(modelo(xb), yb)
    perdida.backward()
    opt.step()
```

- **CUDA** (NVIDIA): el acelerador de referencia, y donde el determinismo cuesta más: muchas operaciones en GPU
  suman en un orden que cambia entre ejecuciones. Con `use_deterministic_algorithms(True)`, las que no tienen versión
  determinista lanzan un `RuntimeError` (la página de reproducibilidad de PyTorch pone el ejemplo de `index_add_`):
  mejor un error que un resultado distinto en silencio.
- **MPS** (GPU de los Mac con Apple Silicon): mismo código con `"mps"`. No todas las operaciones ni todos los tipos
  están implementados: las que faltan fallan o, con `PYTORCH_ENABLE_MPS_FALLBACK=1`, se ejecutan en CPU. Las
  comprobaciones en `float64` de este curso conviene hacerlas en CPU.
- **Cuándo compensa**: con modelos y lotes grandes. Con una red como la de esta lección, copiar los lotes a la GPU
  cuesta más que el cálculo.

`torch.compile` (PyTorch 2) traza el modelo y genera código optimizado para el dispositivo; en CPU, código C++ que
compila con el compilador del sistema. La primera llamada es lenta (compila); las siguientes, más rápidas si el
modelo es grande. Con Python 3.14 y PyTorch 2.14 funciona en CPU:

```python
import os
import tempfile

# la caché de compilación, en una carpeta temporal
os.environ["TORCHINDUCTOR_CACHE_DIR"] = tempfile.mkdtemp()
compilado = torch.compile(mlp)
mlp.eval()
with torch.no_grad():
    a_eager = mlp(dp.X)
    a_comp = compilado(dp.X)
print("mismas predicciones:", torch.allclose(a_eager, a_comp,
                                             atol=1e-5))
```
```output
mismas predicciones: True
```

Mismas predicciones (a la tolerancia de `float32`: el código generado puede sumar en otro orden). La compilación
tardó entre 20 y 25 segundos en la máquina en la que se escribió la lección, para una red que predice los 12.000 días de
prueba en milisegundos: con modelos pequeños no compensa. Tiene sentido con transformers y redes grandes, en GPU, y
cuando el mismo modelo se va a ejecutar muchas veces.

## La misma red con Lightning

Lightning reorganiza el código de PyTorch: el modelo, la pérdida de cada paso y el optimizador van en una
`LightningModule`, y el bucle lo pone el `Trainer`, que añade lo que en un proyecto grande se acaba escribiendo
igualmente: puntos de control, registro de métricas, varias GPU, precisión mixta, acumulación de gradientes. Es la base
de `neuralforecast`, que se usa en las lecciones 5.7.2 y 5.7.3.

```python
import logging
import warnings
import lightning as L

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

class ModuloVol(L.LightningModule):
    def __init__(self, red, lr=1e-3, wd=1e-4):
        super().__init__()
        self.red, self.lr, self.wd = red, lr, wd

    def training_step(self, lote, i):
        x, y = lote
        return nn.functional.mse_loss(self.red(x), y)

    def validation_step(self, lote, i):
        x, y = lote
        self.log("val", nn.functional.mse_loss(self.red(x), y))

    def configure_optimizers(self):
        return torch.optim.Adam(self.parameters(), lr=self.lr,
                                weight_decay=self.wd)

def con_lightning(epocas, gen_val):
    torch.manual_seed(0)
    red = MLP(4)
    val = DataLoader(ds["val"], batch_size=len(ds["val"]),
                     generator=gen_val)
    L.Trainer(max_epochs=epocas, accelerator="cpu",
              deterministic=True, logger=False,
              enable_progress_bar=False, enable_model_summary=False,
              enable_checkpointing=False, num_sanity_val_steps=0,
              ).fit(ModuloVol(red), cargador(0), val)
    return red

torch.manual_seed(0)
a_mano = MLP(4)
entrenar(a_mano, epocas=5, wd=1e-4, paciencia=99, restaurar=False)
for nombre, gen in [("sin generador", None),
                    ("con generador", torch.Generator())]:
    red = con_lightning(5, gen)
    print(f"Lightning, val {nombre}: pesos idénticos al bucle "
          f"a mano: {torch.equal(pesos(red), pesos(a_mano))}")
```
```output
Lightning, val sin generador: pesos idénticos al bucle a mano: False
Lightning, val con generador: pesos idénticos al bucle a mano: True
```

Con el `DataLoader` de validación **sin** generador propio, los pesos de Lightning y los del bucle a mano coinciden
en la primera época y divergen después. La causa no es Lightning: cada vez que se itera un `DataLoader`, su
iterador saca una semilla base del generador del cargador o, si no tiene, del **generador global** de torch. El bucle
a mano evalúa sin `DataLoader`; Lightning itera el de validación al final de cada época, consume un número del
generador global y cambia las máscaras de dropout de la época siguiente. Con un generador propio en el de validación,
los pesos son idénticos bit a bit. La lección vale para cualquier proyecto: todo lo aleatorio con su propio
generador, y nada que dependa del orden en que el resto del programa consume números aleatorios.

Lo que Lightning no hace por ti: cortar los datos por fecha, purgar, normalizar con el entrenamiento ni elegir la
métrica. Eso sigue en los `DataLoader` y en tu código, como en esta lección.

## Ejercicios

1. Comprueba con diferencias finitas el gradiente de la pérdida de validación de la MLP respecto a tres pesos de su
   primera capa. Trabaja en `float64` (`copy.deepcopy(mlp).double()`) y en modo `eval` (el dropout haría la pérdida
   aleatoria).

**Solución.**

```python
m64 = copy.deepcopy(mlp).double().eval()
Xv, yv = ds["val"].X.double(), ds["val"].y.double()
m64.zero_grad()
nn.functional.mse_loss(m64(Xv), yv).backward()
W = m64.red[0].weight
paso = 1e-5
for (i, j) in [(0, 0), (5, 2), (17, 3)]:
    with torch.no_grad():
        W[i, j] += paso
        Lmas = nn.functional.mse_loss(m64(Xv), yv).item()
        W[i, j] -= 2 * paso
        Lmen = nn.functional.mse_loss(m64(Xv), yv).item()
        W[i, j] += paso
    fd = (Lmas - Lmen) / (2 * paso)
    print(f"w[{i},{j}]: autograd {W.grad[i, j].item():+.8f}  "
          f"dif. finitas {fd:+.8f}")
```
```output
w[0,0]: autograd +0.00071116  dif. finitas +0.00071116
w[5,2]: autograd -0.00029703  dif. finitas -0.00029703
w[17,3]: autograd +0.00020451  dif. finitas +0.00020451
```

Coinciden en las ocho cifras impresas. Dos detalles: los pesos se modifican dentro de `torch.no_grad()` (si no,
autograd apuntaría la modificación en el grafo), y se devuelven a su valor al final. Las funciones con «esquinas»,
como ReLU, dan diferencias finitas malas si el paso cruza el cero de alguna neurona; con un paso de 10⁻⁵ es raro.

2. Quita `opt.zero_grad()` del bucle y entrena el modelo lineal 10 épocas con el mismo `lr`. ¿Qué pasa con la pérdida
   de validación y por qué?

**Solución.**

```python
def entrenar_sin_cero(modelo, epocas, lr):
    opt = torch.optim.Adam(modelo.parameters(), lr=lr)
    vals = []
    for epoca in range(epocas):
        modelo.train()
        for xb, yb in cargador(0):
            nn.functional.mse_loss(modelo(xb), yb).backward()
            opt.step()
        vals.append(round(evaluar(modelo, ds["val"]), 3))
    return vals

torch.manual_seed(0)
print("sin zero_grad:", entrenar_sin_cero(Lineal(4), 10, 1e-2))
print("con zero_grad:", hist_lin["val"].round(3).tolist()[:10])
```
```output
sin zero_grad: [0.432, 0.836, 2.191, 1.423, 2.192, 4.034, 4.052, 2.941, 9.944, 2.517]
con zero_grad: [0.221, 0.206, 0.2, 0.198, 0.198, 0.197, 0.199, 0.197, 0.198, 0.198]
```

Sin `zero_grad`, el gradiente de cada paso es la suma de todos los anteriores de la época: una especie de momento
sin amortiguar que empuja los pesos lejos del mínimo, y la pérdida de validación queda muy por encima de la del bucle
correcto. El modelo no lanza ningún error: solo entrena mal. Es el fallo más común al escribir el bucle a mano, y
una razón para encapsularlo (una función propia, como `entrenar`, o Lightning).

3. Un compañero construye el `Dataset` con todas las filas, hace `random_split(ds, [0.7, 0.15, 0.15])` y normaliza las
   features con la media de toda la muestra. Su MLP da un error de prueba algo menor que el de esta lección. ¿Qué le
   dirías?

**Solución.**

Que su prueba no mide lo que cree. Con `random_split`, cada día de prueba tiene en el entrenamiento a sus vecinos
inmediatos, con features casi iguales (la media de 22 días cambia poco de un día al siguiente) y con objetivos que son
features de la fila de al lado: la RV de mañana de una fila es la RV de hoy de la siguiente. Es la fuga del k-fold
aleatorio de la lección 5.4.1. Y la normalización con toda la muestra usa la media y la desviación de 2018-2025 para
tipificar 2008-2015: información del futuro, pequeña pero sistemática (lección 5.2.4). Además, sin cortes por fecha no
hay forma de saber cómo se habría comportado el modelo ante los cambios de nivel de 2019 y 2022. El arreglo es el de
esta lección: cortes por fecha de la etiqueta, purga en las fronteras y estadísticas del entrenamiento; y, para comparar
con el suyo, el mismo procedimiento con las dos arquitecturas.

## Autoevaluación

1. Creas `t = torch.from_numpy(a)` y después haces `t[0, 0] = 9`. ¿Qué pasa con `a`?

   - A. Cambia también: `from_numpy` comparte la memoria con el array; `torch.tensor(a)` haría una copia
   - B. Nada: `from_numpy` copia los datos
   - C. Lanza un error porque los tensores son inmutables
   - D. Cambia solo si `a` es `float32`

   *Respuesta A: Cambia también: `from_numpy` comparte la memoria con el array; `torch.tensor(a)` haría una copia.* `torch.from_numpy` y `Tensor.numpy()` comparten memoria (en CPU). Es útil para no duplicar datos grandes y peligroso si se modifica uno pensando que es una copia.

2. Comparas autograd con diferencias finitas centradas en `float32` y el error es mínimo con un paso de 0,01 y crece con pasos más pequeños. ¿Por qué?

   - A. Porque autograd es inexacto en `float32`
   - B. Porque las diferencias centradas solo funcionan con pasos grandes
   - C. Porque con pasos pequeños la resta de dos pérdidas casi iguales pierde cifras significativas (error de redondeo ≈ ε/h) y con pasos grandes domina el error de truncamiento (≈ h²): el óptimo está entre los dos
   - D. Porque la función no es derivable

   *Respuesta C: Porque con pasos pequeños la resta de dos pérdidas casi iguales pierde cifras significativas (error de redondeo ≈ ε/h) y con pasos grandes domina el error de truncamiento (≈ h²): el óptimo está entre los dos.* Con ε ≈ 1,2·10⁻⁷ en `float32`, el mejor paso para diferencias centradas está hacia ε^(1/3) ≈ 0,005; en `float64` (ε ≈ 2,2·10⁻¹⁶), hacia 10⁻⁵. Por eso las comprobaciones de gradientes se hacen en `float64`.

3. La última fila del entrenamiento tiene fecha 2015-12-31 y su objetivo es la RV del 4 de enero de 2016, que ya está en la validación. ¿Qué se hace?

   - A. Nada: es una sola fila por activo
   - B. Se mueve a la validación
   - C. Se baraja con el resto
   - D. Se purga: ninguna fila de entrenamiento puede tener un objetivo que caiga dentro del periodo de validación, igual que en el purged k-fold de 5.4.2

   *Respuesta D: Se purga: ninguna fila de entrenamiento puede tener un objetivo que caiga dentro del periodo de validación, igual que en el purged k-fold de 5.4.2.* Con horizonte de un día la fuga es pequeña, pero la regla es la misma que con etiquetas de 21 sesiones: el corte se define por la fecha de la etiqueta, no por la de las features.

4. Entrenas la misma red dos veces con la misma semilla, una con tu bucle y otra con Lightning, y los pesos difieren a partir de la segunda época. El orden de los lotes es el mismo. ¿Causa más probable?

   - A. Lightning usa otro optimizador
   - B. El `DataLoader` de validación sin `generator` propio saca su semilla del generador global de torch al iterarse, y eso cambia las máscaras de dropout de la época siguiente
   - C. Lightning no es determinista
   - D. Los hilos de la CPU

   *Respuesta B: El `DataLoader` de validación sin `generator` propio saca su semilla del generador global de torch al iterarse, y eso cambia las máscaras de dropout de la época siguiente.* Cada vez que se itera un `DataLoader`, su iterador toma una semilla base de `loader.generator` o, si no tiene, del generador global. Con un `torch.Generator()` propio en el de validación, los dos entrenamientos dan pesos idénticos.

5. La MLP baja el error cuadrático de prueba del HAR lineal un 2,5 %, con una diferencia semanal de t ≈ 6. ¿Qué conclusión es la correcta?

   - A. Que las redes son siempre mejores que los modelos lineales de volatilidad
   - B. Que hay una fuga en la prueba
   - C. Que hay una no linealidad pequeña y medible (el error de medida del log RV no es normal y el efecto de los rendimientos negativos no es lineal), pero el grueso de la previsión lo hace la estructura HAR, y la red se valida contra ella
   - D. Que el HAR está mal estimado

   *Respuesta C: Que hay una no linealidad pequeña y medible (el error de medida del log RV no es normal y el efecto de los rendimientos negativos no es lineal), pero el grueso de la previsión lo hace la estructura HAR, y la red se valida contra ella.* La verdad de los datos es un HAR en el logaritmo de la varianza integrada; la RV añade un error de medida asimétrico. Una red puede arañar algo ahí, nunca convertirlo en una previsión distinta. La referencia lineal es la que da la escala.

## Ideas clave

- Un tensor es un array con `dtype`, dispositivo y, si hace falta, historia para autograd. `from_numpy` comparte memoria; mezclar `float64` de NumPy con capas `float32` da error.
- Autograd se comprueba: contra la derivada analítica cuando existe y contra diferencias finitas centradas en `float64` (paso ≈ 10⁻⁵). En `float32` el mejor paso ronda 10⁻² y el error no baja de ≈ 5·10⁻⁵.
- Los cortes se hacen por fecha de la etiqueta, con purga en las fronteras y normalización con estadísticas del entrenamiento; barajar dentro del entrenamiento sí es correcto.
- El bucle limpio: `train()`/`eval()`, `zero_grad`, `backward`, `step`, evaluación con `no_grad` y parada temprana guardando el mejor `state_dict`. Un modelo lineal entrenado así llega a la solución de mínimos cuadrados.
- Determinismo: semilla, `use_deterministic_algorithms(True)`, hilos fijos y un `torch.Generator` propio en cada `DataLoader`. Con eso, el bucle a mano y Lightning dan pesos idénticos.
- `torch.compile` funciona en CPU con Python 3.14, pero compilar cuesta segundos que una red pequeña no recupera; la GPU (CUDA o MPS) compensa con modelos y lotes grandes.

## Recursos

### Documentación

- [PyTorch: tensores (documentación de torch.Tensor)](https://docs.pytorch.org/docs/stable/tensors.html) · docs.pytorch.org
- [PyTorch: mecánica de autograd](https://docs.pytorch.org/docs/stable/notes/autograd.html) · docs.pytorch.org
- [PyTorch: torch.utils.data (Dataset, DataLoader y aleatoriedad en la carga)](https://docs.pytorch.org/docs/stable/data.html) · docs.pytorch.org
- [PyTorch: reproducibilidad](https://docs.pytorch.org/docs/stable/notes/randomness.html) · docs.pytorch.org
- [PyTorch: introducción a torch.compile](https://docs.pytorch.org/tutorials/intermediate/torch_compile_tutorial.html) · docs.pytorch.org
- [Lightning: LightningModule y Trainer](https://lightning.ai/docs/pytorch/stable/common/lightning_module.html) · lightning.ai

### 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.6.4 · Callbacks, una pérdida de Sharpe y el modelo en producción](https://pythonparatrading.com/curso-python-trading/nivel-5/callbacks-perdidas-personalizadas-y-exportacion/)

Lección siguiente: [5.7.2 · Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer](https://pythonparatrading.com/curso-python-trading/nivel-5/tcn-n-beats-patchtst-e-itransformer/)
