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.
- Módulo
- 5.6 · Deep learning con Keras 3
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
El 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):
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.
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)]})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 () 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:
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}")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.9969Ridge 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 ; 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:
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) 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.0035La 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:
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}")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.0054Con 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 (aquí 0,3) y las demás se escalan por . 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, , 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 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:
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) 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.0163La 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:
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())é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):
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())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.
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) 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.0022El 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
- 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
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) 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 FalseEl 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.
- 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 conlayers.Add. Entrénalo como los anteriores y compara su IC de validación con el de la tabla.
Solución
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())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.
- 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
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.
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.
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.
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.
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
Libros
- Gu, S., Kelly, B. y Xiu, D. (2020): Empirical Asset Pricing via Machine Learning. The Review of Financial Studies 33(5), 2223-2273
- Grinsztajn, L., Oyallon, E. y Varoquaux, G. (2022): Why do tree-based models still outperform deep learning on tabular data? arXiv:2207.08815
- Loshchilov, I. y Hutter, F.: Decoupled Weight Decay Regularization (AdamW). arXiv:1711.05101
- Srivastava, N. et al. (2014): Dropout: A Simple Way to Prevent Neural Networks from Overfitting. JMLR 15