Lección 5.5.1 · 70 min
Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
Los modelos lineales penalizados son la baseline de todo el ML transversal: rápidos, estables y difíciles de batir. Esta lección los ajusta sobre el panel sintético del nivel, cuyos coeficientes verdaderos se conocen, y mide qué recuperan, qué ruido seleccionan y cuánto mejoran a MCO cuando la validación es honesta.
- Módulo
- 5.5 · Modelos clásicos
- Duración
- 70 min
- Requisitos
- 2.3.5 · Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos, 2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil, 2.5.3 · Validación cruzada combinatoria purgada para estrategias, 5.2.4 · Normalización sin leakage y rank transversal, 5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML, 5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia
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
Por qué penalizar un modelo lineal
Un modelo lineal transversal predice el rendimiento de la semana siguiente de cada acción como una suma ponderada de sus features. Es el modelo más sencillo que se puede escribir y, en datos financieros, el más difícil de batir: la señal es pequeña, casi lineal en los rangos de las features y cambia con el tiempo, y un modelo flexible gasta su flexibilidad en ajustar ruido. Por eso las regresiones regularizadas son la baseline de todo el módulo: cada modelo de las lecciones 5.5.2 a 5.7.5 tiene que ganarles fuera de muestra para justificar su coste.
Con observaciones, la matriz de features (tipificadas) y la etiqueta , las cinco variantes de esta lección minimizan, tal como las escribe scikit-learn 1.9:
donde es l1_ratio (0,5 por defecto en ElasticNet). La regresión logística modela la probabilidad de que la
acción quede por encima de la mediana, , minimiza la log-loss y se penaliza con C (la inversa
de la intensidad) y l1_ratio.
Dos detalles de scikit-learn que conviene saber antes de tocar α:
- Ridge no divide la pérdida por ; Lasso y Elastic Net sí. Con features ortogonales de varianza 1, Ridge
multiplica cada coeficiente de MCO por : con 145 600 filas,
alpha=1e5lo deja en un 59 %. Lasso aplica un umbral suave: resta α a su valor absoluto y lo pone a cero si no llega, . Si la etiqueta está en puntos básicos, el α del Lasso también:alpha=1quita 1 pb a cada coeficiente. - En la logística,
penaltyestá obsoleto desde la 1.8 y desaparece en la 1.10: la penalización se elige conl1_ratio(0 = L2, 1 = L1) yC=np.infquita la penalización. El código antiguo conpenalty="l1"emite unFutureWarning.
La geometría explica la diferencia entre las dos penalizaciones. La de Ridge es una bola redonda: el óptimo toca la bola en un punto cualquiera, con todos los coeficientes distintos de cero y más pequeños. La del Lasso es un rombo con picos en los ejes: el óptimo suele caer en un pico, donde varios coeficientes valen exactamente cero. Por eso Ridge encoge y Lasso selecciona. Elastic Net tiene picos (selecciona) y lados curvos (reparte el peso entre features correlacionadas en vez de quedarse con una al azar).
Fuente Mermaid
flowchart TB
A[Features] --> B{Muy correladas}
B -->|sí| C{Hay que elegir}
B -->|no| D[MCO o Ridge suave]
C -->|no| E[Ridge]
C -->|sí| F[Elastic Net]
A --> G{Muchas candidatas}
G -->|sí| H[Lasso o ENet]Ninguna de las tres cosas sustituye a lo que de verdad decide el resultado: features conocidas en su fecha (lección 5.2.2), normalización sin fuga (5.2.4) y una validación que respete el tiempo (5.4.2 y 5.4.3).
El banco de pruebas: el panel con la verdad publicada
Los datos son sintéticos: 400 acciones ficticias, 730 semanas de 2012 a 2025 y 21 features semanales, generadas
con un modelo cuyos efectos se publican en public/curso/datos/nivel-5/README.md. El rendimiento esperado extra de la
semana siguiente (alfa_verdadero, en panel5_verdad.parquet) depende de seis efectos con coeficientes del orden de
5 pb por semana: momentum, reversión de la semana (más fuerte con poco volumen), value, sorpresa de resultados que
decae, baja volatilidad y sentimiento. Otras 14 features no tienen efecto propio, cinco de ellas ruido puro. La fuga
revision_analistas se deja fuera (el ejercicio 1 la usa). Nada de lo que sale aquí es evidencia sobre mercados
reales; es un laboratorio donde se sabe qué debería encontrar cada método.
Las features se transforman en rangos normales transversales de cada semana (lección 5.2.4), con 0 cuando falta
el dato (el sentimiento falta en un 32 % de las filas), y la etiqueta es el rendimiento de la semana siguiente menos
la media de su semana, en puntos básicos.
import numpy as np
import pandas as pd
from scipy.stats import norm
f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
v = pd.read_parquet("panel5_verdad.parquet")
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana"]],
on=["fecha", "ticker"])
.merge(v, 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)
def sin_media(s):
return s - s.groupby(d["fecha"]).transform("mean")
G = pd.DataFrame({k: g(d[k]) for k in FE})
y = 1e4 * sin_media(d["ret_sig_semana"]) # pb por semana
sem = d["fecha"].to_numpy()
ent = sem < np.datetime64("2019-01-01") # 2012-2018
print(G.shape, "filas de entrenamiento:", ent.sum())
print(f"desv. típica de y: {y[ent].std():.0f} pb")(291600, 21) filas de entrenamiento: 145600
desv. típica de y: 365 pbLa etiqueta tiene una desviación típica de 365 pb por semana y los efectos son de unos 5 pb por unidad de feature: una relación señal/ruido de uno a setenta. Para saber qué debería recuperar un modelo lineal en esta representación, la siguiente celda proyecta el alfa verdadero sobre las 21 features. Esos coeficientes son la verdad lineal: lo mejor que puede hacer un modelo lineal en los rangos de estas features.
from sklearn.linear_model import LinearRegression
alfa = 1e4 * sin_media(d["alfa_verdadero"])
proy = LinearRegression().fit(G[ent], alfa[ent])
verdad = pd.Series(proy.coef_, index=FE)
print(f"R² del alfa sobre G: {proy.score(G[ent], alfa[ent]):.2f}")
print(f"R² del oráculo sobre y: "
f"{alfa[ent].var() / y[ent].var():.4f}")
verdad[verdad.abs() > 0.3].round(2)R² del alfa sobre G: 0.61
R² del oráculo sobre y: 0.0010
ret_1s -5.13
ret_1m 0.44
mom_12_1 4.65
vol_63d -3.73
dist_max_52s -0.49
valor_bp 3.59
ey 0.41
roe -0.36
sue 2.92
sentimiento 2.93
dtype: float64Dos cifras para tener presentes durante toda la lección. La primera: un modelo lineal en los rangos explica el 61 % de la varianza del alfa verdadero; el resto es la interacción de la reversión con el volumen, el decaimiento del SUE, la saturación del sentimiento, la parte del grafo de clientes (lección 5.7.4) y la variación semanal de los coeficientes. La segunda: el oráculo, que conoce el alfa exacto, explica el 0,10 % de la varianza de la etiqueta. Un R² fuera de muestra del 1 % en este panel no sería un gran modelo: sería una fuga.
MCO frente a la verdad
Con 145 600 filas, MCO tiene mucha información. El error típico hay que calcularlo bien: las 400 acciones de una misma semana comparten los shocks de su sector, así que las filas no son independientes. La celda agrupa los errores por semana (errores cluster, lección 2.1.1).
import statsmodels.api as sm
mco = sm.OLS(y[ent].to_numpy(), sm.add_constant(G[ent])).fit(
cov_type="cluster", cov_kwds={"groups": sem[ent]})
tabla = pd.DataFrame({"verdad": verdad, "mco": mco.params[FE],
"t": mco.tvalues[FE]})
tabla.round(2) verdad mco t
ret_1s -5.13 -5.10 -2.25
ret_1m 0.44 2.36 0.80
ret_3m 0.16 -1.99 -0.82
mom_12_1 4.65 5.29 2.23
vol_21d -0.01 -1.26 -0.52
vol_63d -3.73 1.76 0.69
volumen_rel -0.00 -0.20 -0.20
rsi_14 0.02 -2.12 -0.85
dist_max_52s -0.49 -0.65 -0.26
log_capitalizacion 0.04 -0.45 -0.43
valor_bp 3.59 5.90 2.77
ey 0.41 -4.34 -1.41
roe -0.36 2.42 1.09
sue 2.92 2.04 1.74
dias_desde_informe 0.02 0.23 0.23
sentimiento 2.93 1.91 1.64
ruido_1 -0.00 -1.25 -1.26
ruido_2 -0.00 1.27 1.40
ruido_3 0.07 -0.15 -0.17
ruido_4 -0.00 -0.02 -0.02
ruido_5 -0.05 -0.24 -0.26La figura resume la tabla. Tres lecturas:
- Los efectos grandes se recuperan.
ret_1ssale en −5,10 con verdad −5,13 ymom_12_1en 5,29 con 4,65. Pero con errores agrupados la t de la reversión es −2,25, no −5 como daría la fórmula ingenua: siete años de datos dan menos información independiente de lo que sugieren 145 600 filas. - La colinealidad desordena los coeficientes.
eysale en −4,34 (verdad 0,41),valor_bpen 5,90 (verdad 3,59) yroeen 2,42 (verdad −0,36). Los tres se compensan: subir uno y bajar otro apenas cambia el ajuste. - La confusión no se arregla con estadística.
vol_63dtiene un efecto verdadero de −3,73 y MCO lo estima en +1,76. Las acciones volátiles tienen más beta y el mercado sintético sube de media, así que la etiqueta (que solo quita la media de la semana, no la beta) premia la volatilidad. Es el mismo efecto que documenta el README de los datos con Fama-MacBeth. Ninguna penalización lo corrige; hace falta otra etiqueta (rendimiento residual de beta) o otra feature.
print(G.loc[ent, ["valor_bp", "ey", "roe", "sue"]].corr().round(2))
print(G.loc[ent, ["ret_1s", "rsi_14", "mom_12_1", "ruido_4"]]
.corr().round(2)) valor_bp ey roe sue
valor_bp 1.00 0.71 0.16 0.02
ey 0.71 1.00 0.72 0.18
roe 0.16 0.72 1.00 0.29
sue 0.02 0.18 0.29 1.00
ret_1s rsi_14 mom_12_1 ruido_4
ret_1s 1.00 0.55 0.01 0.00
rsi_14 0.55 1.00 0.02 0.01
mom_12_1 0.01 0.02 1.00 0.60
ruido_4 0.00 0.01 0.60 1.00ey (beneficio entre capitalización) es casi el producto de valor_bp (patrimonio entre capitalización) y roe
(beneficio entre patrimonio): correlaciones de 0,71 y 0,72. rsi_14 es una versión de la reversión (0,55 con
ret_1s) y ruido_4 se construyó como 0,6 × momentum más ruido (0,60). Son los grupos donde un modelo lineal sin
penalización reparte mal el peso.
Ridge y Lasso: encoger o seleccionar
La celda siguiente ajusta Ridge con (cada coeficiente, multiplicado por unos dos tercios si las features fueran ortogonales) y Lasso con (un umbral de 1 pb).
from sklearn.linear_model import Lasso, Ridge
n = ent.sum()
X, Y = G[ent].to_numpy(), y[ent].to_numpy()
tabla = tabla[["verdad", "mco"]].copy()
tabla["ridge"] = Ridge(alpha=0.5 * n).fit(X, Y).coef_
tabla["lasso"] = Lasso(alpha=1.0).fit(X, Y).coef_
tabla.round(2) verdad mco ridge lasso
ret_1s -5.13 -5.10 -3.25 -4.41
ret_1m 0.44 2.36 -0.05 -0.00
ret_3m 0.16 -1.99 -0.76 -0.09
mom_12_1 4.65 5.29 2.40 2.92
vol_21d -0.01 -1.26 -0.13 0.00
vol_63d -3.73 1.76 0.81 0.12
volumen_rel -0.00 -0.20 -0.17 -0.00
rsi_14 0.02 -2.12 -1.42 -0.78
dist_max_52s -0.49 -0.65 0.09 -0.00
log_capitalizacion 0.04 -0.45 -0.28 -0.00
valor_bp 3.59 5.90 1.81 2.12
ey 0.41 -4.34 0.19 0.00
roe -0.36 2.42 0.24 0.00
sue 2.92 2.04 1.54 1.27
dias_desde_informe 0.02 0.23 0.19 0.00
sentimiento 2.93 1.91 1.10 0.42
ruido_1 -0.00 -1.25 -0.83 -0.24
ruido_2 -0.00 1.27 0.86 0.28
ruido_3 0.07 -0.15 -0.13 -0.00
ruido_4 -0.00 -0.02 0.63 0.00
ruido_5 -0.05 -0.24 -0.15 -0.00Ridge deshace la compensación del grupo del value: ey pasa de −4,34 a 0,19 y roe de 2,42 a 0,24, cerca de sus
verdades. A cambio, todo encoge (la reversión baja a −3,25) y aparece el efecto de grupo: ruido_4, que MCO
dejaba en −0,02, recibe 0,63 porque Ridge reparte el peso del momentum entre las dos features correlacionadas
(mom_12_1 cae a 2,40). Para un ranking da igual que todo encoja; lo que importa son las proporciones.
Lasso pone a cero 11 de las 21 features, entre ellas ey y roe. Pero no acierta con la lista: deja ruido_1
(−0,24) y ruido_2 (0,28), ruido puro; rsi_14 (−0,78), que solo copia a la reversión; y vol_63d con el signo
cambiado (0,12). Y encoge mucho el sentimiento (0,42 frente a una verdad de 2,93).
from sklearn.linear_model import lasso_path
alfas = np.geomspace(6, 0.05, 40)
_, camino, _ = lasso_path(X - X.mean(0), Y - Y.mean(), alphas=alfas)
camino = pd.DataFrame(camino.T, index=alfas.round(3), columns=FE)
entra = (camino.abs() > 0).idxmax() # primer α, de mayor a menor
entra[camino.abs().max() > 0].sort_values(ascending=False).head(12)ret_1s 5.307
mom_12_1 3.248
sue 2.541
valor_bp 2.541
rsi_14 1.988
ruido_2 1.216
ruido_1 1.216
sentimiento 1.216
vol_63d 1.076
ret_3m 0.952
ret_1m 0.356
log_capitalizacion 0.356
dtype: float64El camino de regularización recorre α de mayor a menor y anota cuándo entra cada feature. La figura de la cabecera
dibuja siete de esos caminos. Entran primero los efectos más fuertes (ret_1s con α = 5,3; mom_12_1 con 3,2;
sue y valor_bp con 2,5), después rsi_14 (2,0), que no tiene efecto propio pero se parece a la reversión, y con
α = 1,2 entran a la vez ruido_1, ruido_2 y sentimiento: el Lasso no distingue un efecto verdadero de 3 pb de
una correlación casual de siete años de datos. vol_63d entra después, con el signo de la beta.
El Lasso selecciona ruido
Que una feature entre en el modelo no dice que tenga efecto. Una forma barata de comprobarlo es repetir la selección en periodos distintos y mirar si se repite y con el mismo signo. La celda ajusta el mismo Lasso (α = 1,5) en siete ventanas de dos años, de 2012-2013 a 2024-2025.
anio = pd.DatetimeIndex(sem).year.to_numpy()
signos = {}
for a0 in range(2012, 2026, 2):
m = (anio >= a0) & (anio <= a0 + 1)
las = Lasso(alpha=1.5).fit(G[m], y[m])
signos[a0] = pd.Series(np.sign(las.coef_), index=FE)
sg = pd.DataFrame(signos)
cuenta = pd.DataFrame({"entra": (sg != 0).sum(axis=1),
"positiva": (sg > 0).sum(axis=1),
"negativa": (sg < 0).sum(axis=1),
"efecto": verdad.round(1)})
cuenta.sort_values("entra", ascending=False).head(12) entra positiva negativa efecto
ret_1s 6 0 6 -5.1
valor_bp 6 5 1 3.6
log_capitalizacion 5 3 2 0.0
sue 5 5 0 2.9
ret_3m 4 1 3 0.2
mom_12_1 4 4 0 4.7
vol_21d 4 3 1 -0.0
ruido_1 4 1 3 -0.0
ret_1m 3 2 1 0.4
sentimiento 3 3 0 2.9
dias_desde_informe 3 1 2 0.0
vol_63d 3 3 0 -3.7Contar cuántas veces entra no basta: log_capitalizacion, sin efecto, entra en 5 de las 7 ventanas, más que
mom_12_1 (4) o sentimiento (3). Lo que separa unas de otras es el signo: los efectos verdaderos lo mantienen
(ret_1s, 6 negativas de 6; sue, 5 positivas de 5; mom_12_1, 4 de 4; sentimiento, 3 de 3) y las features sin
efecto lo cambian (log_capitalizacion, 3 y 2; ruido_1, 1 y 3; dias_desde_informe, 1 y 2). La excepción es la de
siempre: vol_63d es estable (3 de 3)... con el signo equivocado, porque la confusión con la beta también es estable.
La estabilidad descarta el ruido; no descarta un sesgo.
Con 21 features el problema es pequeño. Con muchas candidatas, que es lo habitual cuando se generan features en masa,
es grave. La celda siguiente añade los 210 productos de pares de features (231 columnas) y mira qué entra primero. La
única interacción verdadera es ret_1s*volumen_rel: la reversión es más fuerte con poco volumen.
from itertools import combinations
pares = list(combinations(FE, 2))
nombres = FE + [f"{a}*{b}" for a, b in pares]
X231 = np.column_stack(
[G.to_numpy()] + [(G[a] * G[b]).to_numpy() for a, b in pares]
).astype(np.float32)
Xc = X231[ent] - X231[ent].mean(0)
al, cam, _ = lasso_path(Xc, Y - Y.mean(), precompute=True,
alphas=np.geomspace(6, 1.5, 12))
cam = pd.DataFrame(cam.T, index=al.round(2), columns=nombres)
orden = (cam.abs() > 0).idxmax()[cam.abs().max() > 0]
orden.sort_values(ascending=False).head(14)ret_1s 5.29
mom_12_1 3.62
valor_bp 2.48
sue 2.48
rsi_14 2.19
ret_1m*volumen_rel 2.19
mom_12_1*ey 2.19
roe*dias_desde_informe 2.19
vol_63d*dias_desde_informe 2.19
vol_63d*ruido_1 2.19
valor_bp*ruido_5 2.19
sue*ruido_1 1.93
ret_1s*volumen_rel 1.93
ret_3m*mom_12_1 1.93
dtype: float64Con α = 2,19 entran seis interacciones falsas de golpe (entre ellas vol_63d*ruido_1 y valor_bp*ruido_5, ruido
por construcción) y la verdadera, ret_1s*volumen_rel, entra un paso después, junto a otras dos falsas. Con 210
candidatas, el máximo de 210 correlaciones casuales es tan grande como un efecto verdadero pequeño: es el problema de
las pruebas múltiples de la lección 2.1.8 con otro disfraz. float32 en X231 no es un detalle: la matriz pesa
270 MB en vez de 540, y precompute=True hace que el Lasso trabaje con (231 × 231) en lugar de recorrer las
filas en cada iteración.
¿Importa fuera de muestra? La celda entrena con 2012-2018 (sin la última semana, cuya etiqueta acaba en 2019: la purga mínima de la lección 5.4.2) y mide el IC semanal de 2019 a 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"]
def resumen(serie):
se = serie.std() / np.sqrt(len(serie))
return f"IC {serie.mean():.4f} ± {se:.4f} t {serie.mean()/se:4.1f}"
purga = sem < np.datetime64("2018-12-24") # sin la última semana
pru = sem >= np.datetime64("2019-01-01")
Ytr = y[purga].to_numpy()
for nombre, cols, mod in [
("mco 21", slice(0, 21), LinearRegression()),
("mco 231", slice(None), LinearRegression()),
("ridge 231", slice(None), Ridge(alpha=2.0 * n)),
("lasso 231", slice(None), Lasso(alpha=2.0, precompute=True))]:
mod.fit(X231[purga][:, cols], Ytr)
s = mod.predict(X231[pru][:, cols])
print(f"{nombre:10s}", resumen(ic(s, pru)))
print(f"{'oráculo':10s}", resumen(ic(alfa[pru].to_numpy(), pru)))mco 21 IC 0.0195 ± 0.0045 t 4.4
mco 231 IC 0.0133 ± 0.0035 t 3.9
ridge 231 IC 0.0134 ± 0.0036 t 3.7
lasso 231 IC 0.0188 ± 0.0051 t 3.7
oráculo IC 0.0256 ± 0.0052 t 4.9Añadir 210 candidatas a MCO le cuesta un tercio del IC (de 0,0195 a 0,0133). Ridge no lo arregla (0,0134): encoge las 210 interacciones de ruido pero no las quita, y su suma sigue moviendo el ranking. Lasso sí recupera casi todo (0,0188) porque pone a cero la mayoría. Ninguno supera al modelo pequeño. La regularización es un seguro contra un mal conjunto de candidatas, no una forma de convertirlas en señal; el seguro más barato es no generar 210 candidatas sin una hipótesis detrás (lección 2.3.5).
Validación honesta: walk-forward con α elegido sin mirar la prueba
Hasta aquí α se ha fijado a mano. En la práctica se elige con validación, y la validación tiene que respetar el tiempo. El esquema de la celda siguiente, para cada año de 2015 a 2025:
Fuente Mermaid
flowchart TB A[Datos hasta t−1] --> B[Subentreno] A --> C[Valida t−2 y t−1] B --> D[IC por cada α] C --> D D --> E[Mejor α] E --> F[Reentreno con todo] F --> G[Predice el año t]
- Entrenamiento: todas las semanas anteriores al año, menos la última (su etiqueta termina dentro del año de prueba).
- Validación: los dos últimos años del entrenamiento; el subentreno acaba una semana antes de que empiecen.
- Selección: el α con mayor IC medio en validación; después se reentrena con todo el entrenamiento y se predice el año.
Es un walk-forward con ventana expansiva (lección 5.4.3) con una validación purgada dentro (lección 5.4.2). La etiqueta es de una semana, así que basta purgar una semana; con etiquetas de 21 sesiones habría que purgar cuatro.
from sklearn.linear_model import ElasticNet
modelos = {
"mco": (lambda a: LinearRegression(), [None]),
"ridge": (lambda a: Ridge(alpha=a), [1e3, 1e4, 1e5, 1e6]),
"lasso": (lambda a: Lasso(alpha=a), [0.1, 0.5, 1, 2, 3]),
"enet": (lambda a: ElasticNet(alpha=a), [0.2, 1, 2, 4, 6]),
}
semana = np.timedelta64(7, "D")
Xg, yg = G.to_numpy(), y.to_numpy()
pred = {k: np.full(len(d), np.nan) for k in modelos}
alfa_elegido = {k: [] for k in modelos}
for a in range(2015, 2026):
ini, ini_val = (np.datetime64(f"{a}-01-01"),
np.datetime64(f"{a - 2}-01-01"))
tr = sem < ini - semana # purga de una semana
sub = sem < ini_val - semana
val = tr & (sem >= ini_val) # los dos últimos años
te = anio == a
for k, (hacer, rejilla) in modelos.items():
mejor = rejilla[0]
if len(rejilla) > 1:
notas = [ic(hacer(r).fit(Xg[sub], yg[sub])
.predict(Xg[val]), val).mean()
for r in rejilla]
mejor = rejilla[int(np.argmax(notas))]
alfa_elegido[k].append(mejor)
pred[k][te] = hacer(mejor).fit(Xg[tr], yg[tr]).predict(Xg[te])
wf = anio >= 2015
ics = {k: ic(p[wf], wf) for k, p in pred.items()}
ics["oráculo"] = ic(alfa[wf].to_numpy(), wf)
for k, s in ics.items():
print(f"{k:8s}", resumen(s))mco IC 0.0184 ± 0.0038 t 4.9
ridge IC 0.0175 ± 0.0039 t 4.5
lasso IC 0.0185 ± 0.0039 t 4.7
enet IC 0.0175 ± 0.0040 t 4.4
oráculo IC 0.0269 ± 0.0040 t 6.8En 574 semanas de prueba, MCO da un IC de 0,0184 ± 0,0038, el mismo que el ridge de referencia del README de los datos (0,0185). Ridge (0,0175), Lasso (0,0185) y Elastic Net (0,0175) quedan dentro de un error típico. El oráculo da 0,0269: el modelo lineal se lleva unas dos terceras partes de la señal que existe, y lo que falta no se recupera penalizando. La tabla de α elegidos explica por qué la validación no ayuda.
pd.DataFrame(alfa_elegido, index=range(2015, 2026)).drop(columns="mco") ridge lasso enet
2015 100000.0 3.0 4.0
2016 1000000.0 3.0 4.0
2017 1000000.0 1.0 2.0
2018 10000.0 0.5 0.2
2019 1000.0 0.1 0.2
2020 1000.0 0.1 0.2
2021 10000.0 0.5 0.2
2022 1000000.0 1.0 2.0
2023 1000000.0 3.0 6.0
2024 1000.0 0.1 0.2
2025 1000.0 0.1 0.2El α de Ridge salta del mínimo al máximo de la rejilla de un año a otro (1 000 en 2019-2020, 1 000 000 en 2022-2023). No es inestabilidad del método: es que la curva de validación es casi plana. Con , la penalización apenas cambia la dirección del vector de coeficientes, y un IC de rangos solo depende de la dirección (multiplicar todas las predicciones por 0,5 no cambia ningún ranking). Lo que elige la validación es, sobre todo, ruido de dos años. La comparación justa entre modelos es emparejada: las mismas semanas, la diferencia de IC semana a semana y su error típico.
for k in ["ridge", "lasso", "enet", "oráculo"]:
dif = ics[k] - ics["mco"]
se = dif.std() / np.sqrt(len(dif))
print(f"{k:8s} {dif.mean():+.4f} ± {se:.4f} "
f"t {dif.mean() / se:+.1f}")ridge -0.0010 ± 0.0010 t -1.0
lasso +0.0000 ± 0.0011 t +0.0
enet -0.0009 ± 0.0012 t -0.8
oráculo +0.0085 ± 0.0042 t +2.0Emparejar reduce el error típico de 0,0038 a 0,0010: los modelos se equivocan en las mismas semanas. Aun así, ninguna diferencia es significativa; la del oráculo lo es por poco (t = 2,0), lo que da una idea de lo difícil que es demostrar fuera de muestra incluso una mejora que existe. Con cinco modelos probados, además, el mejor IC está sesgado al alza; la corrección por el número de pruebas (Deflated Sharpe) es la lección 5.4.4.
Logística: la dirección en lugar del tamaño
La regresión logística cambia la pregunta: en vez del rendimiento, la probabilidad de que la acción quede por encima de la mediana de su semana. Tiene dos ventajas en datos financieros: la etiqueta binaria ignora las colas (un +40 % cuenta lo mismo que un +1 %) y la salida es una probabilidad que se puede calibrar. El coste es tirar la información del tamaño.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import log_loss
z = (d["ret_sig_semana"]
> d.groupby("fecha")["ret_sig_semana"].transform("median"))
z = z.astype(int).to_numpy()
p = np.full(len(d), np.nan)
for a in range(2015, 2026):
tr = sem < np.datetime64(f"{a}-01-01") - semana
te = anio == a
lr = LogisticRegression(C=1.0, l1_ratio=0.0, max_iter=500)
p[te] = lr.fit(Xg[tr], z[tr]).predict_proba(Xg[te])[:, 1]
acierto = ((p[wf] > 0.5) == z[wf]).mean()
print(f"acierto {100 * acierto:.2f} % "
f"log-loss {log_loss(z[wf], p[wf]):.5f} (azar {np.log(2):.5f})")
print("percentiles 1, 50 y 99 de p:",
np.percentile(p[wf], [1, 50, 99]).round(3))
print(resumen(ic(p[wf], wf)))acierto 50.60 % log-loss 0.69314 (azar 0.69315)
percentiles 1, 50 y 99 de p: [0.471 0.5 0.527]
IC 0.0160 ± 0.0036 t 4.4El acierto es del 50,60 %; el del oráculo en el mismo periodo, según el README, del 50,89 %. La log-loss, 0,69314, queda una centésima de milésima por debajo de la de una moneda (ln 2 = 0,69315), y el 98 % de las probabilidades está entre 0,471 y 0,527. Así se ve un buen modelo cuando el R² verdadero es del 0,1 %: casi indistinguible del azar acción por acción, útil cuando se ordenan 400 acciones a la vez cada semana durante años. El IC, 0,0160, queda algo por debajo del de MCO (0,0184): la etiqueta binaria tira información.
C=1.0 con 150 000 filas es una penalización casi nula: en la logística de scikit-learn la pérdida es una suma, no
una media, así que el peso de la penalización baja con . Con l1_ratio=1.0 (L1) hace falta solver="saga" o
"liblinear"; los dos son más lentos con este tamaño.
lr = LogisticRegression(C=1.0, l1_ratio=0.0, max_iter=500)
lr.fit(Xg[ent], z[ent])
comp = pd.DataFrame({"mco_pb": tabla["mco"],
"logit_x1000": 1000 * lr.coef_[0]}, index=FE)
print(f"correlación: {comp.corr().iloc[0, 1]:.3f}")
comp.loc[["ret_1s", "mom_12_1", "valor_bp", "sue", "ey"]].round(2)correlación: 0.933
mco_pb logit_x1000
ret_1s -5.10 -22.33
mom_12_1 5.29 22.88
valor_bp 5.90 30.85
sue 2.04 5.71
ey -4.34 -14.87Los coeficientes de la logística y los de MCO tienen una correlación de 0,933: los dos modelos ven lo mismo. Las
diferencias están donde la cola importa: el sue pesa relativamente menos en la logística (5,71 frente a 22,33 de la
reversión, cuando en MCO es 2,04 frente a 5,10), porque buena parte de su efecto llega en saltos grandes que la
etiqueta binaria trata como cualquier otra subida.
Errores típicos
- Penalizar sin tipificar. Ridge y Lasso penalizan todos los coeficientes por igual; si una feature está en
millones y otra en tanto por uno, la penalización castiga a la segunda. Rangos normales o
StandardScalerdentro del pipeline (lección 5.1.2), ajustado solo con el entrenamiento. - Leer el α de Ridge como el del Lasso. En scikit-learn, uno escala con y el otro no. Un
alpha=1en Ridge con 145 600 filas no hace casi nada; en Lasso quita 1 pb a cada coeficiente. - Interpretar la selección del Lasso como evidencia. Entra lo que correlaciona con el residuo en esa muestra, ruido incluido. Hay que repetirla en otros periodos y exigir el mismo signo.
- Elegir α con k-fold barajado. En este panel las etiquetas son semanales y no se solapan, pero con etiquetas de 21 sesiones el k-fold aleatorio promete aciertos del 60 % que no existen (lección 5.4.1). Validación purgada siempre.
- Esperar que la penalización corrija una confusión.
vol_63dsale con el signo de la beta en MCO, en Ridge, en Lasso y en las siete ventanas. Es un problema de la etiqueta, no del estimador. - Comparar modelos sin emparejar ni contar las pruebas. Diferencias de 0,001 en IC son ruido; se comparan semana a semana y con el número de configuraciones probadas a la vista (lección 5.4.4).
Ejercicios
- Añade
revision_analistas(la feature con fuga) a las 21 y ajusta el Lasso con α = 1 en 2012-2018. ¿Qué coeficiente recibe y en qué α entra en el camino? ¿Detecta la regularización que la feature es una fuga?
Solución
G["revision_analistas"] = g(d["revision_analistas"])
X22 = G[ent].to_numpy()
las = Lasso(alpha=1.0).fit(X22, Y)
print(f"coeficiente de la fuga: {las.coef_[-1]:.1f} pb "
f"(ret_1s: {las.coef_[0]:.1f} pb)")
al22, cam22, _ = lasso_path(X22 - X22.mean(0), Y - Y.mean(),
alphas=np.geomspace(80, 5, 12))
print("α en que entra:", al22[np.argmax(cam22[-1] != 0)].round(1),
"| features con α = 5:", int((cam22[:, -1] != 0).sum()))
G = G.drop(columns="revision_analistas")coeficiente de la fuga: 69.7 pb (ret_1s: -4.0 pb)
α en que entra: 62.2 | features con α = 5: 2La fuga recibe 69,7 pb, unas 17 veces el coeficiente de la reversión (−4,0 pb, que encoge porque la fuga le quita
parte de su papel), y entra en el camino con α = 62,2, cuando ret_1s entraba con 5,3: con α = 5 solo hay dos
features en el modelo. La regularización hace exactamente lo que debe: premiar a la feature que más correlaciona con la
etiqueta. Por eso no puede detectar una fuga; al contrario, la elige la primera. La fuga solo se descubre auditando
cuándo se conoce cada dato (lección 5.2.2).
- El momentum del panel se invierte en los rebotes del mercado (crash del momentum, README de los datos). Ajusta MCO
por separado en las semanas de cada régimen (columna
regimendepanel5_coef_verdad.csv) con todo el periodo y compara el coeficiente demom_12_1con la media del coeficiente verdaderoc_momen esas semanas.
Solución
cv = pd.read_csv("panel5_coef_verdad.csv", parse_dates=["fecha"])
reg = d["fecha"].map(cv.set_index("fecha")["regimen"]).to_numpy()
cmom = d["fecha"].map(cv.set_index("fecha")["c_mom"]).to_numpy()
for r in ["calma", "estres", "rebote"]:
m = reg == r
b = LinearRegression().fit(G[m], y[m]).coef_[FE.index("mom_12_1")]
print(f"{r:7s} semanas {len(np.unique(sem[m])):3d} "
f"MCO {b:6.2f} pb verdad {1e4 * cmom[m].mean():6.2f} pb")calma semanas 575 MCO 6.47 pb verdad 4.71 pb
estres semanas 89 MCO 5.63 pb verdad 2.02 pb
rebote semanas 65 MCO -1.88 pb verdad -10.44 pbMCO recupera el signo en los tres regímenes, no el tamaño: 6,47 pb en calma (verdad 4,71), 5,63 en estrés (2,02) y −1,88 en los rebotes, donde la verdad es −10,44. Con 65 semanas de rebote el error típico es de varios puntos básicos y la estimación queda lejos de la verdad. Un modelo lineal global promedia los tres regímenes y en un rebote apuesta justo al revés de lo que conviene. Hay dos salidas: dar al modelo el régimen como feature (con interacciones, que es lo que hacen los árboles de la lección 5.5.2) o reentrenar más a menudo con ventanas que reaccionen (lección 5.4.3). Las dos cuestan varianza: hay pocas semanas de cada régimen raro.
- Sin código: un compañero presenta un Lasso sobre 300 features generadas automáticamente que selecciona 12 y da un IC medio de 0,08 en un k-fold de cinco particiones. ¿Qué tres cosas pedirías antes de creerlo?
Solución
Primero, la validación: un IC de 0,08 es tres veces el del oráculo de este panel y, en datos reales, una cifra así casi siempre es una fuga o un k-fold barajado con etiquetas solapadas. Pediría un walk-forward con purga y embargo (lecciones 5.4.2 y 5.4.3) con el α elegido dentro de cada entrenamiento, no con todos los datos. Segundo, la estabilidad: las 12 features elegidas en otros periodos, con su signo; con 300 candidatas el Lasso elige ruido con facilidad, como aquí con 231. Tercero, la auditoría temporal de las 12 features (cuándo se conoce cada dato) y el número total de configuraciones probadas para corregir el resultado con el Deflated Sharpe (lección 5.4.4). Solo después tendría sentido hablar de costes y de capacidad.
Respuesta plegada en cada pregunta
Autoevaluación
En scikit-learn,
Ridge(alpha=1e5)yLasso(alpha=1)sobre 145 600 filas con features de varianza 1. ¿Qué hace cada uno, aproximadamente, con un coeficiente de MCO de 5 pb si las features fueran ortogonales?- ALos dos lo multiplican por 0,5
- BRidge lo pone a cero y Lasso lo deja igual
- CRidge lo multiplica por 1/(1 + α/n) ≈ 0,59 y Lasso le resta 1 pb (umbral suave): queda en 4 pb
- DNinguno lo cambia, porque α es pequeño frente a n
Ver respuesta
Respuesta C. Ridge lo multiplica por 1/(1 + α/n) ≈ 0,59 y Lasso le resta 1 pb (umbral suave): queda en 4 pb
La pérdida de Ridge en scikit-learn no se divide por n, así que su intensidad efectiva es α/n; la del Lasso sí (1/2n), y con features tipificadas su α está en las unidades del coeficiente: es el umbral que se resta a cada uno.
MCO estima un coeficiente de −4,3 pb para
eyy de 5,9 pb paravalor_bp, con verdades de 0,4 y 3,6. ¿Qué explica mejor el error?- AQue
eytiene una fuga - BQue hacen falta más árboles
- CQue el value no tiene efecto
- DColinealidad: las dos features tienen correlación 0,71 y
eytambién se parece aroe(0,72); MCO reparte la señal con signos opuestos que se compensan
Ver respuesta
Respuesta D. Colinealidad: las dos features tienen correlación 0,71 y
eytambién se parece aroe(0,72); MCO reparte la señal con signos opuestos que se compensanCon features muy correlacionadas, MCO tiene varianza alta en la dirección de su diferencia: un coeficiente sube y el otro baja sin que cambie mucho el ajuste. Ridge lo corrige (ey 0,19, valor_bp 1,81) y Lasso elimina
eyyroe.- AQue
En el camino del Lasso,
ruido_2(ruido puro) entra con el mismo α quesentimiento(efecto verdadero). ¿Qué conclusión es correcta?- A
ruido_2tiene un efecto que el generador no documenta - BCon esta relación señal/ruido, el orden de entrada no distingue un efecto pequeño de una correlación casual de la muestra; hay que comprobar si la selección y el signo se repiten en otros periodos
- CHay que bajar α hasta que salga
- DEl Lasso está mal implementado
Ver respuesta
Respuesta B. Con esta relación señal/ruido, el orden de entrada no distingue un efecto pequeño de una correlación casual de la muestra; hay que comprobar si la selección y el signo se repiten en otros periodos
El Lasso selecciona lo que más correlaciona con el residuo en esa muestra. En ventanas de dos años, las features verdaderas mantienen el signo (ret_1s, 6 de 6 negativas; sue, 5 de 5 positivas) y las de ruido lo cambian (ruido_1: una positiva y tres negativas).
- A
En el walk-forward 2015-2025, el α que elige la validación salta de 1 000 a 1 000 000 en Ridge de un año a otro. ¿Qué indica?
- AQue la curva de validación es casi plana y ruidosa: el IC apenas cambia con α y la elección es casi aleatoria, por eso Ridge no mejora a MCO (diferencia −0,0010 ± 0,0010)
- BQue la rejilla está mal
- CQue hay que reentrenar cada semana
- DQue Ridge sobreajusta mucho
Ver respuesta
Respuesta A. Que la curva de validación es casi plana y ruidosa: el IC apenas cambia con α y la elección es casi aleatoria, por eso Ridge no mejora a MCO (diferencia −0,0010 ± 0,0010)
Con n ≫ p, la penalización apenas cambia la dirección del vector de coeficientes, que es lo único que importa para un IC de rangos. Elegir α optimizando un IC ruidoso es elegir por azar.
La logística de la dirección da un acierto del 50,60 % y probabilidades entre 0,471 y 0,527 (percentiles 1 y 99). ¿Es un modelo inútil?
- ASí: un acierto tan bajo no se puede operar
- BNo necesariamente: el oráculo acierta el 50,89 %; con 400 acciones por semana, un acierto pequeño y estable da un IC de 0,016 con t = 4,4. Lo que no se puede hacer es leer sus probabilidades como certezas
- CNo: un acierto del 50,6 % es excelente en cualquier caso
- DSí, porque su log-loss es mayor que el de una moneda
Ver respuesta
Respuesta B. No necesariamente: el oráculo acierta el 50,89 %; con 400 acciones por semana, un acierto pequeño y estable da un IC de 0,016 con t = 4,4. Lo que no se puede hacer es leer sus probabilidades como certezas
En ML transversal lo que se opera es el orden de muchas acciones a la vez, no la probabilidad de una. La log-loss (0,69314) queda apenas por debajo de la de una moneda (0,69315): es lo esperable cuando el R² verdadero es del 0,1 %.
Ideas clave
- Ridge encoge todos los coeficientes y reparte el peso entre features correlacionadas; Lasso los pone a cero (selecciona); Elastic Net mezcla las dos. En scikit-learn, Ridge usa α sin dividir por n y Lasso con 1/2n.
- Con 145 600 filas y 21 features, MCO ya recupera bien los efectos grandes (ret_1s −5,1 frente a −5,1 verdadero). Lo que falla es la colinealidad (ey, roe, valor_bp) y la confusión (vol_63d sale con el signo cambiado por la beta), que ninguna penalización corrige.
- El Lasso selecciona ruido: ruido_2 entra a la vez que sentimiento, y con 231 candidatas entran seis interacciones falsas antes que la verdadera. Una selección se cree solo si se repite en otros periodos con el mismo signo.
- En un walk-forward con α elegido en una validación purgada, ninguna regularización mejora a MCO con 21 features (IC 0,0184 frente a 0,0175-0,0185). La regularización ayuda cuando hay muchas candidatas: con 231, Lasso recupera 0,0188 frente a 0,0133 de MCO.
- La logística da el mismo orden que la regresión (correlación de coeficientes 0,93) con probabilidades entre 0,47 y 0,53. El techo es el oráculo: IC 0,0269.
Recursos
Libros
- Hoerl, A. E. y Kennard, R. W. (1970): Ridge Regression: Biased Estimation for Nonorthogonal Problems. Technometrics 12(1), 55-67
- Tibshirani, R. (1996): Regression Shrinkage and Selection via the Lasso. Journal of the Royal Statistical Society B 58(1), 267-288
- Zou, H. y Hastie, T. (2005): Regularization and Variable Selection via the Elastic Net. Journal of the Royal Statistical Society B 67(2), 301-320
- Gu, S., Kelly, B. y Xiu, D. (2020): Empirical Asset Pricing via Machine Learning. The Review of Financial Studies 33(5), 2223-2273