Lección 5.6.2 · 70 min

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.

Fig. — Pérdida de entrenamiento y de validación de un MLP de 128x2 sin regularizar (Adam 0,001) sobre el panel del nivel 5, época a época: entreno 2012-2016, validación purgada 2017-2018
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

El 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):

Entreno 2012-2016Validación 2017-2018Reentreno 2012-2018Prueba 2019-2025
Fuente 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.

IN [01]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)]})
OUT [01]Backend JAX, panel y particiones purgadas
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 (α=10\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:

IN [02]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}")
OUT [02]Las baselines: Ridge y LightGBM
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 IC2≈0,0004\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:

IN [03]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)
OUT [03]Un MLP de manual
       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:

IN [04]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}")
OUT [04]La escala del paso: una sola capa lineal
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 pp (aquí 0,3) y las demás se escalan por 1/(1−p)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←w−η λ ww \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 λ∥w∥2\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:

IN [05]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)
OUT [05]Dropout, weight decay y anchura, con tres semillas
                        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

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:

IN [06]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())
OUT [06]Parada temprana con la pérdida y con el IC
é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):

IN [07]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())
OUT [07]La lotería de la semilla y el promedio de redes
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.

IN [08]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)
OUT [08]Prueba 2019-2025: MLP frente a las baselines
                      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

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.

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
IN [09]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)
OUT [09]IC por año
         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.

  1. 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
IN [10]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())
OUT [10]Rama lineal más rama no lineal
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.

  1. 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).

Respuesta plegada en cada pregunta

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?

    • ASí: si no baja el MSE, no predice nada
    • BNo: 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
    • CSí: el modelo tiene una fuga
    • DNo: el MSE de validación siempre es mayor que el de entrenamiento
    Ver respuesta

    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?

    • AQue 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
    • BQue ha encontrado mucha señal
    • CQue hace falta entrenar más épocas
    • DQue la tasa de aprendizaje es demasiado baja
    Ver respuesta

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

    • APorque Glorot no funciona con datos tabulares
    • BPorque la tasa más baja entrena más épocas
    • CPorque 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
    • DPor casualidad de la semilla
    Ver respuesta

    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?

    • AQuedarse con la semilla de 0,0163
    • BIgnorarlo: la semilla no importa
    • CSubir el número de épocas hasta que coincidan
    • DTratar 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
    Ver respuesta

    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?

    • AEl MLP es peor que Ridge
    • BNo 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
    • CEl MLP es mejor porque gana en 2021 y 2022
    • DHay que repetir la prueba hasta que gane
    Ver respuesta

    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

Datos