---
title: "5.6.2 MLP para datos tabulares financieros"
description: "MLP de Keras sobre el panel del nivel 5 con validación purgada: dropout, weight decay, parada temprana y semillas, frente a Ridge y LightGBM."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/mlp-para-datos-tabulares-financieros/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/mlp-para-datos-tabulares-financieros.png"
---

# 5.6.2 · Un MLP sobre un panel de acciones: regularizar y comparar

> Un perceptrón multicapa puede aproximar cualquier función, y con 100.000 filas de un panel financiero eso es un problema: aprende el ruido antes que la señal. Esta lección entrena MLP sobre el panel del nivel 5 con validación purgada, mide qué aportan el dropout, el weight decay y la parada temprana, y los compara con Ridge y LightGBM sin maquillar el resultado.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.6 · Deep learning con Keras 3
- Duración: 70 min
- Requisitos: [5.6.1 · Keras 3: un modelo, varios motores de cálculo](https://pythonparatrading.com/curso-python-trading/nivel-5/keras-3-multi-backend/), [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/), [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/)

## Al terminar esta lección

- Preparar un panel de features para una red (rangos normales por fecha, particiones purgadas) y medir el modelo con el IC semanal, no con la pérdida.
- Reconocer el sobreajuste de un MLP sin regularizar y explicar por qué la escala del paso y la inicialización importan cuando la señal es diminuta.
- Aplicar dropout, weight decay (AdamW) y parada temprana en Keras, y medir su efecto con varias semillas.
- Comparar el MLP con Ridge y LightGBM en una prueba fuera de muestra con errores típicos de la diferencia, y sacar una conclusión honesta.

## El problema y la vara de medir

Un perceptrón multicapa (MLP) es la red más simple: capas densas con una no linealidad entre ellas. Con anchura
suficiente puede aproximar cualquier función continua, y por eso es tentador para datos tabulares: debería encontrar las
interacciones y las formas no lineales que una regresión lineal no ve. En el panel del nivel 5 esas interacciones
existen (la reversión de una semana se debilita con el volumen, el SUE decae con los días, el sentimiento satura), así
que hay algo que encontrar. El problema es la escala: el oráculo, el alfa verdadero, explica alrededor de un 0,09 % de
la varianza transversal semanal (README de los datos). Una red que pueda aprender cualquier cosa aprenderá antes el
99,9 % de ruido que el 0,1 % de señal.

La lección sigue el diseño de 5.5.3 para que los números sean comparables: las 21 features sin la fuga
(`revision_analistas`), transformadas en **rangos normales por semana** (lección 5.2.4), y como etiqueta el rango normal
de `ret_sig_semana`. Las particiones son por fechas con un hueco de una semana entre ellas, que purga el solape de las
etiquetas semanales (lección 5.4.2):

```mermaid
flowchart TB
  E[Entreno 2012-2016] --> V[Validación 2017-2018]
  V --> R[Reentreno 2012-2018]
  R --> P[Prueba 2019-2025]
```

La validación elige la arquitectura, la regularización y el número de épocas; la prueba se mira **una sola vez**, al
final, con el modelo elegido reentrenado con entreno y validación. La métrica es el **IC**: la correlación de rangos
semana a semana entre la predicción y el rendimiento siguiente, promediada. Es lo que usa una cartera larga-corta
(lección 5.1.3).

Las redes se entrenan con el backend **JAX**, fijado en la primera línea antes de importar Keras: en la lección 5.6.1
el mismo entrenamiento de un modelo pequeño fue varias veces más rápido que con PyTorch.

```python
import os
os.environ["KERAS_BACKEND"] = "jax"      # antes del primer import keras
import numpy as np
import pandas as pd
from scipy.stats import norm
from sklearn.linear_model import Ridge
import lightgbm as lgb
import keras
from keras import layers

f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana"]],
             on=["fecha", "ticker"])
      .dropna(subset=["ret_sig_semana"])
      .reset_index(drop=True))
FE = [c for c in f.columns[3:] if c != "revision_analistas"]

def g(s):
    """Rango normal de cada semana (lección 5.2.4); sin dato, 0."""
    por = s.groupby(d["fecha"])
    u = (por.rank() - 0.5) / por.transform("count")
    return pd.Series(norm.ppf(u), index=s.index).fillna(0.0)

X = np.column_stack([g(d[k]) for k in FE]).astype("float32")
y = g(d["ret_sig_semana"]).to_numpy().astype("float32")
sem = d["fecha"].to_numpy()
fe = np.datetime64
ent = sem < fe("2016-12-26")                          # 2012-2016
val = (sem >= fe("2017-01-06")) & (sem < fe("2018-12-24"))
tr_todo = sem < fe("2018-12-24")                      # 2012-2018
te = sem >= fe("2019-01-01")                          # 2019-2025

def ic(score, mask):
    """IC de rangos de cada semana entre score y la etiqueta."""
    t = pd.DataFrame({"s": score, "f": sem[mask],
                      "y": d.loc[mask, "ret_sig_semana"].to_numpy()})
    t[["s", "y"]] = t.groupby("f")[["s", "y"]].rank()
    return t.groupby("f")[["s", "y"]].corr().xs("s", level=1)["y"]

print("backend:", keras.config.backend(), "| features:", len(FE))
print({k: (int(m.sum()), len(np.unique(sem[m]))) for k, m in
       [("entreno", ent), ("validación", val), ("prueba", te)]})
```
```output
backend: jax | features: 21
{'entreno': (103600, 259), 'validación': (41200, 103), 'prueba': (146000, 365)}
```

Son 103.600 filas de entrenamiento (259 semanas de 400 acciones), 41.200 de validación y 146.000 de prueba.

## Las baselines

Antes de la red, los modelos que tiene que batir: Ridge ($\alpha = 10$) y el LightGBM de la lección 5.5.3 con 25 árboles,
el número que eligió su validación. Los dos se entrenan con 2012-2016 para la validación y con 2012-2018 para la
prueba. Se mide también el error cuadrático de validación de Ridge y el de predecir siempre 0:

```python
def lgbm(n=25):
    return lgb.LGBMRegressor(
        n_estimators=n, learning_rate=0.02, max_depth=2, num_leaves=4,
        min_child_samples=3000, subsample=0.5, subsample_freq=1,
        colsample_bytree=0.8, reg_lambda=10, random_state=0, n_jobs=4,
        deterministic=True, force_row_wise=True, verbose=-1)

base = {}
for nombre, modelo in [("ridge", lambda: Ridge(alpha=10.0)),
                       ("lightgbm", lgbm)]:
    v_ = ic(modelo().fit(X[ent], y[ent]).predict(X[val]), val)
    p_ = ic(modelo().fit(X[tr_todo], y[tr_todo]).predict(X[te]), te)
    base[nombre] = p_
    print(f"{nombre:<9} IC validación {v_.mean():.4f}   prueba "
          f"{p_.mean():.4f} ± {p_.std() / np.sqrt(len(p_)):.4f}")
mse_cero = np.mean(y[val] ** 2)
mse_ridge = np.mean((Ridge(alpha=10.0).fit(X[ent], y[ent])
                     .predict(X[val]) - y[val]) ** 2)
print(f"MSE de validación: predecir 0 {mse_cero:.4f}, "
      f"Ridge {mse_ridge:.4f}")
```
```output
ridge     IC validación 0.0155   prueba 0.0180 ± 0.0045
lightgbm  IC validación 0.0077   prueba 0.0139 ± 0.0047
MSE de validación: predecir 0 0.9968, Ridge 0.9969
```

Ridge tiene un IC de validación de 0,0155 y de prueba de 0,0180 ± 0,0045; LightGBM, 0,0077 y 0,0139: las mismas
cifras de la lección 5.5.3. La última línea es la primera advertencia: el MSE de Ridge en validación, 0,9969, es
**peor** que el de predecir 0, 0,9968, y sin embargo Ridge ordena las acciones con un IC positivo. Con un IC de 0,02,
lo máximo que una predicción puede bajar el error cuadrático de una etiqueta de varianza 1 es del orden de
$\text{IC}^2 \approx 0{,}0004$; un desajuste mínimo de escala o de nivel lo tapa. La pérdida sirve para entrenar, no
para elegir.

## Un MLP de manual y su sobreajuste

La receta de cualquier tutorial: dos capas ocultas de 128 neuronas con ReLU, Adam con tasa 0,001, lotes de 512 y 20
épocas. Un *callback* propio (lección 5.6.4) mide el IC de validación al final de cada época:

```python
class ICValidacion(keras.callbacks.Callback):
    """Mide el IC de validación al final de cada época."""
    def on_train_begin(self, logs=None):
        self.ic = []

    def on_epoch_end(self, epoca, logs=None):
        p = self.model.predict(X[val], batch_size=8192, verbose=0)
        self.ic.append(ic(p.ravel(), val).mean())

keras.utils.set_random_seed(0)
manual = keras.Sequential([
    keras.Input((len(FE),)),
    layers.Dense(128, activation="relu"),
    layers.Dense(128, activation="relu"),
    layers.Dense(1)])
manual.compile(keras.optimizers.Adam(1e-3), "mse")
cb = ICValidacion()
h = manual.fit(X[ent], y[ent], epochs=20, batch_size=512, verbose=0,
               validation_data=(X[val], y[val]), callbacks=[cb])
curva_manual = pd.DataFrame({"pérdida entreno": h.history["loss"],
                             "pérdida val.": h.history["val_loss"],
                             "IC val.": cb.ic},
                            index=range(1, 21)).rename_axis("época")
curva_manual.iloc[[0, 1, 3, 7, 11, 15, 19]].round(4)
```
```output
       pérdida entreno  pérdida val.  IC val.
época
1               1.0134        1.0058   0.0116
2               0.9986        1.0040   0.0067
4               0.9917        1.0086   0.0090
8               0.9811        1.0084   0.0005
12              0.9689        1.0170  -0.0005
16              0.9550        1.0317   0.0061
20              0.9389        1.0498   0.0035
```

La figura de la cabecera dibuja las dos pérdidas época a época. La de entrenamiento baja sin parar, de 1,013 a 0,939:
un 7 % menos, cuando la señal entera vale menos de un 0,1 %. Es memoria, no aprendizaje. La de validación toca su
mínimo en la tercera época (1,0032) sin bajar nunca de la de predecir 0, y después sube hasta 1,050. El IC de
validación salta entre −0,0027 y 0,0118 sin tendencia, y en las últimas épocas cae por debajo de cero: por debajo de Ridge en todas las épocas.

Con 19.457 parámetros (21×128 + 128×128 + 128 pesos y sus sesgos) y 103.600 filas de etiqueta casi pura de ruido, el
resultado era previsible. Pero hay una segunda causa, menos evidente, que afecta incluso a un modelo lineal.

## La escala del paso y de la inicialización

En este panel los coeficientes buenos son diminutos: los de Ridge valen 0,0054 de media en valor absoluto. La
inicialización de Glorot, la de serie de `Dense`, da pesos aleatorios de varias centésimas, y Adam con tasa 0,001 mueve
cada peso del orden de 0,001 en cada lote, en la dirección que marca un gradiente casi todo ruido. La celda entrena una
sola capa `Dense(1)`, que es una regresión lineal como la de 5.6.1, de dos formas:

```python
def lineal(init, lr, semilla=0, epocas=12):
    keras.utils.set_random_seed(semilla)
    m = keras.Sequential([keras.Input((len(FE),)),
                          layers.Dense(1, kernel_initializer=init)])
    m.compile(keras.optimizers.Adam(lr), "mse")
    m.fit(X[ent], y[ent], epochs=epocas, batch_size=1024, verbose=0)
    w = m.layers[-1].get_weights()[0].ravel()
    p = m.predict(X[val], batch_size=8192, verbose=0).ravel()
    return ic(p, val).mean(), np.abs(w).mean()

w_ridge = np.abs(Ridge(alpha=10.0).fit(X[ent], y[ent]).coef_).mean()
for init, lr in [("glorot_uniform", 1e-3), ("zeros", 3e-4)]:
    ic_v, w_m = lineal(init, lr)
    print(f"{init:<15}lr {lr:.0e}: IC validación {ic_v:.4f}, "
          f"|peso| medio {w_m:.4f}")
print(f"Ridge: |peso| medio {w_ridge:.4f}")
```
```output
glorot_uniform lr 1e-03: IC validación 0.0013, |peso| medio 0.0308
zeros          lr 3e-04: IC validación 0.0150, |peso| medio 0.0049
Ridge: |peso| medio 0.0054
```

Con la configuración de serie, la capa lineal termina con pesos de 0,031 de media, seis veces los de Ridge, y un IC de
validación de 0,0013: ruido. Con la salida inicializada a **cero** y una tasa de 0,0003, los pesos quedan en 0,0049 y el
IC en 0,0150, a la altura de Ridge (0,0155). El modelo es el mismo; cambia que el optimizador no tenga que deshacer un
punto de partida aleatorio mayor que la señal, ni añadir con cada paso un ruido mayor que el efecto que busca.

La regla práctica para redes con datos financieros: inicializar la última capa a cero (la red empieza prediciendo la
media), tasas pequeñas y lotes grandes. Las demás capas sí necesitan pesos iniciales aleatorios: con todo a cero, las
neuronas de una capa serían idénticas y recibirían el mismo gradiente.

## Regularización: dropout, weight decay y anchura

Con esa base, tres herramientas de regularización y una cuarta, la más simple, que es usar una red pequeña:

- **Dropout**: en cada lote, cada neurona oculta se apaga con probabilidad $p$ (aquí 0,3) y las demás se escalan por
  $1/(1-p)$. Obliga a la red a no depender de combinaciones concretas de neuronas. Solo actúa al entrenar.
- **Weight decay**: cada paso encoge los pesos hacia cero, $w \leftarrow w - \eta\,\lambda\,w$, aparte del gradiente.
  Es la versión desacoplada de la penalización L2 (AdamW, de Loshchilov y Hutter): con Adam no es lo mismo que sumar
  $\lambda\lVert w\rVert^2$ a la pérdida, porque Adam reescala cada gradiente.
- **Parada temprana**: dejar de entrenar cuando la validación deja de mejorar (siguiente sección).
- **Capacidad**: 16 neuronas en una capa en lugar de 64×2.

Cada configuración se entrena 8 épocas con **tres semillas**. Con una sola, no se podría distinguir el efecto de la
regularización del azar de la inicialización, del dropout y del orden de los lotes:

```python
def mlp(ancho, capas, dropout=0.0, wd=0.0, lr=3e-4):
    m = keras.Sequential([keras.Input((len(FE),))])
    for _ in range(capas):
        m.add(layers.Dense(ancho, activation="relu"))
        if dropout:
            m.add(layers.Dropout(dropout))
    m.add(layers.Dense(1, kernel_initializer="zeros"))
    opt = (keras.optimizers.AdamW(lr, weight_decay=wd) if wd
           else keras.optimizers.Adam(lr))
    m.compile(opt, "mse")
    return m

CONFIG = {"64x2 sin regularizar": (64, 2, 0.0, 0.0),
          "64x2 dropout 0,3": (64, 2, 0.3, 0.0),
          "64x2 weight decay 0,01": (64, 2, 0.0, 1e-2),
          "16x1 dropout + decay": (16, 1, 0.3, 1e-2)}
EPOCAS = 8
pred_val, filas = {}, []
for nombre, cfg in CONFIG.items():
    ics = []
    for s in range(3):
        keras.utils.set_random_seed(s)
        m = mlp(*cfg)
        m.fit(X[ent], y[ent], epochs=EPOCAS, batch_size=1024,
              verbose=0)
        p = m.predict(X[val], batch_size=8192, verbose=0).ravel()
        pred_val[(nombre, s)] = p
        ics.append(ic(p, val).mean())
    filas.append({"modelo": nombre, "parámetros": m.count_params(),
                  "IC medio": np.mean(ics), "mín.": min(ics),
                  "máx.": max(ics)})
tabla_reg = pd.DataFrame(filas).set_index("modelo")
tabla_reg.round(4)
```
```output
                        parámetros  IC medio    mín.    máx.
modelo
64x2 sin regularizar          5633    0.0130  0.0113  0.0147
64x2 dropout 0,3              5633    0.0116  0.0111  0.0122
64x2 weight decay 0,01        5633    0.0128  0.0113  0.0144
16x1 dropout + decay           369    0.0126  0.0107  0.0163
```

*Fig. — IC de validación de cuatro MLP con tres semillas cada uno (8 épocas, Adam o AdamW 0,0003, salida inicializada a cero) frente a Ridge, en el panel del nivel 5.* Cuatro filas de tres marcas, una por semilla, entre 0,0107 y 0,0163. Solo una marca, una semilla del 16x1 con dropout y decay, pasa de la línea discontinua de Ridge (0,0155, destacada); las demás quedan a su izquierda. La dispersión entre semillas de un mismo modelo es tan grande como la diferencia entre modelos.

La figura pone las doce marcas en una escala. Cuatro lecturas:

- **Ninguna configuración supera a Ridge de forma consistente.** Las medias van de 0,0116 a 0,0130, todas por debajo
  de 0,0155. Solo una semilla, la del 16x1 con dropout y decay, pasa de Ridge (0,0163).
- **La semilla pesa tanto como la configuración.** Dentro del 16x1 las tres semillas van de 0,0107 a 0,0163; entre
  las medias de las cuatro configuraciones hay 0,0014. Con estos números no se puede afirmar que una regularización sea
  mejor que otra.
- **Regularizar no rescata a la red grande.** Con la base correcta (salida a cero, tasa baja, pocas épocas), el 64x2 sin
  regularizar ya no sobreajusta tanto como el MLP de manual, y el dropout no lo mejora: con 8 épocas y tasa baja, el
  propio entrenamiento corto actúa como regularización.
- **El modelo pequeño cuesta quince veces menos parámetros** (369 frente a 5.633) para el mismo resultado. Es el que se
  lleva a la prueba.

## Parada temprana

`keras.callbacks.EarlyStopping` vigila una métrica de validación, para cuando no mejora en `patience` épocas y, con
`restore_best_weights=True`, devuelve los pesos de la mejor época. Vigilar `val_loss` es lo de serie:

```python
keras.utils.set_random_seed(0)
m = mlp(*CONFIG["16x1 dropout + decay"])
parada = keras.callbacks.EarlyStopping(
    monitor="val_loss", patience=3, restore_best_weights=True)
cb = ICValidacion()
h = m.fit(X[ent], y[ent], epochs=30, batch_size=1024, verbose=0,
          validation_data=(X[val], y[val]), callbacks=[cb, parada])
perd = np.array(h.history["val_loss"])
print(f"épocas entrenadas: {len(perd)}; mejor pérdida de validación "
      f"en la época {perd.argmin() + 1}")
print(f"IC de validación en esa época: {cb.ic[perd.argmin()]:.4f}; "
      f"mejor IC: {max(cb.ic):.4f} (época {np.argmax(cb.ic) + 1})")
print("pérdidas de validación:", np.round(perd, 5).tolist())
```
```output
épocas entrenadas: 5; mejor pérdida de validación en la época 2
IC de validación en esa época: 0.0134; mejor IC: 0.0134 (época 2)
pérdidas de validación: [0.99664, 0.99664, 0.99665, 0.99671, 0.99673]
```

La pérdida de validación casi no se mueve: las cinco épocas están entre 0,99664 y 0,99673, diferencias en la cuarta
cifra decimal. La parada se decide por un ruido de 0,00001. Aquí coincide con el mejor IC (época 2, 0,0134), pero es
suerte: en el MLP de manual, el IC no tenía relación con la pérdida. Lo coherente es parar con la métrica que importa,
el IC de validación; la lección 5.6.4 escribe ese *callback* de parada. Y cualquier criterio de parada usa la
validación para elegir: el número de épocas es un hiperparámetro más, y la validación deja de ser una estimación limpia
(la prueba sí lo es).

## La lotería de la semilla

Las tres semillas del modelo elegido dan IC distintos. Entrenar varias redes y promediar sus predicciones es la forma
más barata de reducir esa varianza (un ensemble como los de la lección 5.10.3):

```python
nombre = "16x1 dropout + decay"
por_semilla = [ic(pred_val[(nombre, s)], val).mean() for s in range(3)]
media = np.mean([pred_val[(nombre, s)] for s in range(3)], axis=0)
print("IC por semilla:", np.round(por_semilla, 4).tolist())
print(f"IC del promedio de las tres: {ic(media, val).mean():.4f}")
corr = np.corrcoef([pred_val[(nombre, s)] for s in range(3)])
print("correlación entre predicciones:",
      np.round(corr[np.triu_indices(3, 1)], 3).tolist())
```
```output
IC por semilla: [0.0109, 0.0163, 0.0107]
IC del promedio de las tres: 0.0141
correlación entre predicciones: [0.771, 0.732, 0.783]
```

Las predicciones de las tres redes están correlacionadas entre 0,73 y 0,78: se parecen, pero no son la misma red. El
promedio da 0,0141, por encima de dos de las tres semillas y sin depender de haber elegido la buena. Elegir la semilla
de 0,0163 sería el sesgo de selección de siempre (lecciones 2.5.1 y 5.5.4): el máximo de tres ruidos.

## La prueba, una sola vez

El modelo elegido (16x1 con dropout 0,3 y weight decay 0,01, 8 épocas, promedio de tres semillas) se reentrena con
2012-2018 y se mide en 2019-2025, junto a las baselines y al oráculo. La columna importante es la diferencia con Ridge
**semana a semana**, con su error típico: las 365 semanas son las mismas para todos, y la comparación pareada quita el
ruido común.

```python
preds = []
for s in range(3):
    keras.utils.set_random_seed(s)
    m = mlp(*CONFIG["16x1 dropout + decay"])
    m.fit(X[tr_todo], y[tr_todo], epochs=EPOCAS, batch_size=1024,
          verbose=0)
    preds.append(m.predict(X[te], batch_size=8192, verbose=0).ravel())
ic_mlp = ic(np.mean(preds, axis=0), te)

verdad = pd.read_parquet("panel5_verdad.parquet",
                         columns=["fecha", "ticker", "alfa_verdadero"])
alfa = d[["fecha", "ticker"]].merge(verdad, how="left")
ic_oraculo = ic(alfa.loc[te, "alfa_verdadero"].to_numpy(), te)

filas = []
for nombre, serie in [("oráculo", ic_oraculo),
                      ("MLP (3 semillas)", ic_mlp),
                      ("ridge", base["ridge"]),
                      ("lightgbm", base["lightgbm"])]:
    dif = serie - base["ridge"]
    filas.append({"modelo": nombre, "IC": serie.mean(),
                  "e.t.": serie.std() / np.sqrt(len(serie)),
                  "menos ridge": dif.mean(),
                  "e.t. dif.": dif.std() / np.sqrt(len(dif))})
pd.DataFrame(filas).set_index("modelo").round(4)
```
```output
                      IC    e.t.  menos ridge  e.t. dif.
modelo
oráculo           0.0256  0.0052       0.0076     0.0043
MLP (3 semillas)  0.0172  0.0042      -0.0008     0.0017
ridge             0.0180  0.0045       0.0000     0.0000
lightgbm          0.0139  0.0047      -0.0041     0.0022
```

*Fig. — IC de prueba (2019-2025, 365 semanas) del promedio de tres MLP, de Ridge y de LightGBM entrenados con 2012-2018, frente al oráculo, en el panel del nivel 5.* Barras horizontales con su error típico. Oráculo 0,0256; Ridge 0,0180; MLP (destacado) 0,0172; LightGBM 0,0139. Los errores típicos, de unas 0,0045, solapan los tres modelos.

El resultado honesto:

- El MLP da un IC de 0,0172 ± 0,0042 y Ridge de 0,0180 ± 0,0045. La diferencia, −0,0008 ± 0,0017, es la mitad de su
  error típico: **no hay evidencia de que el MLP mejore a Ridge**, ni de que sea peor.
- LightGBM queda por debajo de Ridge (−0,0041 ± 0,0022), como en la lección 5.5.3.
- El oráculo, 0,0256, marca el techo: los tres modelos recogen entre la mitad y dos tercios. Lo que falta está en las
  interacciones y en los efectos que cambian con el régimen, justo lo que una red debería encontrar y que con 14 años de
  datos semanales no se distingue del ruido.

Ante un empate, gana el modelo simple: Ridge se entrena en un segundo, no depende de una semilla, sus coeficientes se
leen y se auditan, y su comportamiento fuera de muestra es más previsible. Esto concuerda con la literatura sobre
datos tabulares (Grinsztajn, Oyallon y Varoquaux, 2022: con conjuntos medianos, los árboles siguen por delante de las
redes). En el otro sentido, Gu, Kelly y Xiu (2020) encontraron que las redes y los árboles eran los mejores métodos para
predecir rendimientos de acciones de EE. UU., gracias a las interacciones no lineales, con muchas más features y
décadas de datos mensuales. Las dos cosas son compatibles: el MLP necesita mucha más información de la que tiene una
señal tan débil en unas pocas features.

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

Esta lección ha probado al menos ocho configuraciones (el MLP de manual, dos lineales, cuatro regularizaciones, la
parada temprana) antes de la prueba. Si se hubiera mirado la prueba para elegir entre ellas, el IC de prueba estaría
inflado por la selección. La forma de contarlo es registrar todos los intentos y descontar con el Deflated Sharpe o la
PBO (lecciones 2.5.4 y 5.4.4). Y si la red hubiera ganado por poco, la pregunta siguiente sería si la ventaja sobrevive
a los costes: la cartera de un IC de 0,018 tiene una rotación semanal alta (README de los datos: Sharpe neto de
Ridge cercano a 0 con 10 pb por lado).

</aside>

## Cuándo sí merece la pena un MLP

Nada de lo anterior dice que las redes no sirvan en finanzas; dice cuándo no aportan. Los casos en los que una red
tiene ventaja real son los que se salen de una tabla pequeña de features:

- **Muchas features y muchas interacciones**, con muestras grandes (cientos de características, décadas, miles de
  activos), como en Gu, Kelly y Xiu.
- **Entradas con estructura**: secuencias (lección 5.6.3), texto, imágenes del libro de órdenes, grafos (lección 5.7.4).
- **Pérdidas a medida**: optimizar directamente la cartera o una función de utilidad (lecciones 5.6.4 y 5.7.5).
- **Representaciones compartidas**: un *embedding* del activo o del sector aprendido junto con el modelo (API funcional,
  lección 5.6.1).

Para un panel tabular como este, el orden razonable es: Ridge como baseline, boosting como primera alternativa no lineal
(5.5.3) y un MLP pequeño, regularizado y promediado como tercera, solo si la validación purgada lo justifica.

## Ejercicios

1. Calcula el IC de prueba del MLP y de Ridge año a año. ¿En cuántos de los siete años gana el MLP?

**Solución.**

```python
anual = pd.DataFrame({"mlp": ic_mlp, "ridge": base["ridge"]})
anual = anual.groupby(anual.index.year.rename("año")).mean()
anual["mlp gana"] = anual["mlp"] > anual["ridge"]
anual.round(4)
```
```output
         mlp   ridge  mlp gana
año
2019 -0.0011  0.0021     False
2020  0.0054  0.0062     False
2021  0.0216  0.0110      True
2022  0.0313  0.0311      True
2023 -0.0106 -0.0041     False
2024  0.0439  0.0497     False
2025  0.0299  0.0302     False
```

El MLP gana en dos de los siete años (2021 y 2022), en uno de ellos por mucho (0,0216 frente a 0,0110), y pierde en los
otros cinco por poco. Los dos modelos se mueven juntos: los años buenos (2022, 2024, 2025) y los malos (2019, 2023)
son los mismos, porque los dos recogen sobre todo la misma señal lineal. Un año de 52 semanas tiene un error típico del
IC de unas 0,01: ninguna diferencia anual es significativa por sí sola.

2. Construye con la API funcional un modelo **ancho y profundo**: una rama lineal (`Dense(1)` sobre las features) más la
   rama del MLP 16x1, sumadas con `layers.Add`. Entrénalo como los anteriores y compara su IC de validación con el de
   la tabla.

**Solución.**

```python
ics = []
for s in range(3):
    keras.utils.set_random_seed(s)
    x_in = keras.Input((len(FE),))
    lin = layers.Dense(1, kernel_initializer="zeros")(x_in)
    h = layers.Dropout(0.3)(layers.Dense(16, activation="relu")(x_in))
    prof = layers.Dense(1, kernel_initializer="zeros")(h)
    ap = keras.Model(x_in, layers.Add()([lin, prof]))
    ap.compile(keras.optimizers.AdamW(3e-4, weight_decay=1e-2), "mse")
    ap.fit(X[ent], y[ent], epochs=EPOCAS, batch_size=1024, verbose=0)
    p = ap.predict(X[val], batch_size=8192, verbose=0).ravel()
    ics.append(ic(p, val).mean())
print("IC de validación por semilla:", np.round(ics, 4).tolist())
```
```output
IC de validación por semilla: [0.0158, 0.0152, 0.0161]
```

Las tres semillas dan 0,0158, 0,0152 y 0,0161: la media, 0,0157, queda a la altura de Ridge (0,0155) y por encima de
cualquiera de los MLP de la tabla, con mucha menos dispersión entre semillas. La rama lineal recoge la señal que Ridge
ya encontraba y la rama no lineal solo añade correcciones; si no hay nada que añadir, el modelo se queda en lo lineal.
Es la arquitectura «wide and deep»: una forma de que la red no pueda hacerlo peor que su baseline lineal por
construcción. Para saber si la rama no lineal aporta algo, la prueba es la misma de la lección: diferencia pareada con
Ridge en la prueba y su error típico.

3. La lección eligió el número de épocas (8) y la configuración mirando una sola partición de validación (2017-2018).
   Propón un procedimiento más robusto con las herramientas del módulo 5.4 y di qué coste tiene con redes.

**Solución.**

Usar un purged k-fold sobre 2012-2018 (lección 5.4.2), con purga por la fecha de fin de cada etiqueta y un embargo
pequeño, y elegir configuración y épocas por el IC medio de los pliegues, promediando además varias semillas en cada
pliegue. Con 5 pliegues, 4 configuraciones y 3 semillas son 60 entrenamientos en lugar de 12: con JAX y un modelo de
369 parámetros es asumible; con redes grandes, no, y se recurre a menos pliegues o a un walk-forward con pocos cortes
(lección 5.4.3). Lo que no cambia: la prueba 2019-2025 se mira una vez, y todos los intentos se registran para
descontar la selección (Deflated Sharpe, lección 5.4.4).

## Autoevaluación

1. En validación, Ridge tiene un MSE de 0,9969 y predecir siempre 0 da 0,9968. ¿Significa que Ridge no sirve?

   - A. Sí: si no baja el MSE, no predice nada
   - B. No: con una señal tan pequeña el MSE apenas puede bajar (como mucho, alrededor del IC al cuadrado), y un desajuste de escala o de media lo tapa; Ridge ordena bien (IC 0,0155), que es lo que usa la cartera
   - C. Sí: el modelo tiene una fuga
   - D. No: el MSE de validación siempre es mayor que el de entrenamiento

   *Respuesta B: No: con una señal tan pequeña el MSE apenas puede bajar (como mucho, alrededor del IC al cuadrado), y un desajuste de escala o de media lo tapa; Ridge ordena bien (IC 0,0155), que es lo que usa la cartera.* Con un IC de 0,02, la reducción posible del error cuadrático es del orden de 0,0004 sobre 1. Cualquier sesgo de nivel o de escala es mayor que eso. Para un modelo de ranking transversal, la métrica es el IC o la cartera, no la pérdida.

2. El MLP de 128x2 sin regularizar baja la pérdida de entrenamiento de 1,013 a 0,939 en 20 épocas. ¿Qué indica?

   - A. Que memoriza el ruido: el oráculo explica menos del 0,1 % de la varianza, así que cualquier bajada de la pérdida de entrenamiento mucho mayor que eso es sobreajuste; la de validación sube a la vez
   - B. Que ha encontrado mucha señal
   - C. Que hace falta entrenar más épocas
   - D. Que la tasa de aprendizaje es demasiado baja

   *Respuesta A: Que memoriza el ruido: el oráculo explica menos del 0,1 % de la varianza, así que cualquier bajada de la pérdida de entrenamiento mucho mayor que eso es sobreajuste; la de validación sube a la vez.* Una reducción de la pérdida del 7 % es imposible con la señal que hay. La curva de validación, que toca su mínimo en la tercera época y sube después, lo confirma.

3. Una capa lineal entrenada con Adam (tasa 0,001, pesos iniciales de Glorot) da un IC de validación de 0,0013; con pesos iniciales a cero y tasa 0,0003, 0,0150. ¿Por qué?

   - A. Porque Glorot no funciona con datos tabulares
   - B. Porque la tasa más baja entrena más épocas
   - C. Porque los coeficientes buenos son del orden de 0,005: los pesos iniciales aleatorios (unas 0,03) y los pasos grandes de Adam dejan un ruido mayor que la propia señal
   - D. Por casualidad de la semilla

   *Respuesta C: Porque los coeficientes buenos son del orden de 0,005: los pesos iniciales aleatorios (unas 0,03) y los pasos grandes de Adam dejan un ruido mayor que la propia señal.* Ridge da pesos de 0,0054 de media. Partiendo de 0,03 y con pasos del orden de 0,001 por lote, los pesos no se acercan a esa escala en 12 épocas. En finanzas, la escala del paso y de la inicialización se elige pensando en el tamaño del efecto.

4. Tres semillas del mismo MLP dan IC de validación de 0,0107, 0,0109 y 0,0163. ¿Qué se hace con eso?

   - A. Quedarse con la semilla de 0,0163
   - B. Ignorarlo: la semilla no importa
   - C. Subir el número de épocas hasta que coincidan
   - D. Tratar la dispersión como parte del error del modelo: comparar configuraciones por la media de varias semillas y, si se usa el modelo, promediar las redes

   *Respuesta D: Tratar la dispersión como parte del error del modelo: comparar configuraciones por la media de varias semillas y, si se usa el modelo, promediar las redes.* Elegir la mejor semilla es elegir el máximo de un ruido: sesgo de selección (lecciones 2.5.1 y 5.5.4). El promedio de las tres da 0,0141, más estable que cualquiera de ellas.

5. En la prueba 2019-2025, el MLP da un IC de 0,0172 y Ridge de 0,0180, con una diferencia de −0,0008 ± 0,0017. ¿Qué se concluye?

   - A. El MLP es peor que Ridge
   - B. No hay evidencia de que el MLP mejore a Ridge: la diferencia es la mitad de su error típico; a igual resultado, se queda el modelo simple, más barato y más fácil de auditar
   - C. El MLP es mejor porque gana en 2021 y 2022
   - D. Hay que repetir la prueba hasta que gane

   *Respuesta B: No hay evidencia de que el MLP mejore a Ridge: la diferencia es la mitad de su error típico; a igual resultado, se queda el modelo simple, más barato y más fácil de auditar.* Una diferencia de 0,5 errores típicos es ruido. La prueba se usa una sola vez; repetirla buscando un ganador la convierte en validación.

## Ideas clave

- En un panel financiero la pérdida casi no se mueve (predecir 0 da 0,9968 y Ridge 0,9969): el modelo se mide con el IC semanal y con la cartera, no con el MSE.
- Un MLP grande sin regularizar memoriza: la pérdida de entrenamiento baja un 7 % y la de validación sube desde la tercera época.
- La escala importa: con coeficientes buenos del orden de 0,005, una salida inicializada a cero y una tasa de 0,0003 hacen que una capa lineal llegue a Ridge; con Glorot y 0,001, no.
- Dropout, weight decay y parada temprana ayudan poco cuando el modelo ya es pequeño; la variación entre semillas (0,0107 a 0,0163) es del orden de las diferencias entre configuraciones.
- El promedio de tres redes estabiliza la predicción; en prueba, el MLP queda en 0,0172 frente a 0,0180 de Ridge (diferencia −0,0008 ± 0,0017): sin evidencia de mejora, gana el modelo simple.

## Recursos

### Documentación

- [Keras: EarlyStopping](https://keras.io/api/callbacks/early_stopping/) · keras.io
- [Keras: AdamW](https://keras.io/api/optimizers/adamw/) · keras.io

### Libros

- [Gu, S., Kelly, B. y Xiu, D. (2020): Empirical Asset Pricing via Machine Learning. The Review of Financial Studies 33(5), 2223-2273](https://doi.org/10.1093/rfs/hhaa009) · doi.org
- [Grinsztajn, L., Oyallon, E. y Varoquaux, G. (2022): Why do tree-based models still outperform deep learning on tabular data? arXiv:2207.08815](https://arxiv.org/abs/2207.08815) · arxiv.org
- [Loshchilov, I. y Hutter, F.: Decoupled Weight Decay Regularization (AdamW). arXiv:1711.05101](https://arxiv.org/abs/1711.05101) · arxiv.org
- [Srivastava, N. et al. (2014): Dropout: A Simple Way to Prevent Neural Networks from Overfitting. JMLR 15](https://jmlr.org/papers/v15/srivastava14a.html) · jmlr.org

### Datos

- [Datos del nivel 5: features del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [5.6.1 · Keras 3: un modelo, varios motores de cálculo](https://pythonparatrading.com/curso-python-trading/nivel-5/keras-3-multi-backend/)

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