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.

Fig. — Camino del Lasso sobre el panel sintético (2012-2018): coeficiente de siete features según la penalización α, con la feature de ruido ruido_2 destacada
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

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 nn observaciones, la matriz de features XX (tipificadas) y la etiqueta yy, las cinco variantes de esta lección minimizan, tal como las escribe scikit-learn 1.9:

MCO:∥y−Xw∥22Ridge:∥y−Xw∥22+α∥w∥22Lasso:12n∥y−Xw∥22+α∥w∥1Elastic Net:12n∥y−Xw∥22+αρ∥w∥1+α(1−ρ)2∥w∥22\begin{aligned} \text{MCO:}\quad & \lVert y - Xw \rVert_2^2 \\ \text{Ridge:}\quad & \lVert y - Xw \rVert_2^2 + \alpha \lVert w \rVert_2^2 \\ \text{Lasso:}\quad & \tfrac{1}{2n}\lVert y - Xw \rVert_2^2 + \alpha \lVert w \rVert_1 \\ \text{Elastic Net:}\quad & \tfrac{1}{2n}\lVert y - Xw \rVert_2^2 + \alpha\rho \lVert w \rVert_1 + \tfrac{\alpha(1-\rho)}{2} \lVert w \rVert_2^2 \end{aligned}

donde ρ\rho 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, p=1/(1+e−x′w)p = 1/(1 + e^{-x'w}), 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 nn; Lasso y Elastic Net sí. Con features ortogonales de varianza 1, Ridge multiplica cada coeficiente de MCO por 1/(1+α/n)1/(1 + \alpha/n): con 145 600 filas, alpha=1e5 lo deja en un 59 %. Lasso aplica un umbral suave: resta α a su valor absoluto y lo pone a cero si no llega, wj=sign⁡(w^j)max⁡(∣w^j∣−α,0)w_j = \operatorname{sign}(\hat w_j)\max(|\hat w_j| - \alpha, 0). Si la etiqueta está en puntos básicos, el α del Lasso también: alpha=1 quita 1 pb a cada coeficiente.
  • En la logística, penalty está obsoleto desde la 1.8 y desaparece en la 1.10: la penalización se elige con l1_ratio (0 = L2, 1 = L1) y C=np.inf quita la penalización. El código antiguo con penalty="l1" emite un FutureWarning.

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

sínonosísíFeaturesMuy correladasHay que elegirMCO o Ridge suaveRidgeElastic NetMuchas candidatasLasso o ENet
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.

IN [01]python
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")
OUT [01]El panel en rangos normales
(291600, 21) filas de entrenamiento: 145600
desv. típica de y: 365 pb

La 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.

IN [02]python
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)
OUT [02]La verdad lineal
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: float64

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

IN [03]python
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)
OUT [03]MCO con errores agrupados por semana
                    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.26
Fig. — Coeficientes de MCO de las 21 features frente a la proyección lineal del alfa verdadero, con su intervalo del 95 % agrupado por semana (panel sintético, 2012-2018)

La figura resume la tabla. Tres lecturas:

  • Los efectos grandes se recuperan. ret_1s sale en −5,10 con verdad −5,13 y mom_12_1 en 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. ey sale en −4,34 (verdad 0,41), valor_bp en 5,90 (verdad 3,59) y roe en 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_63d tiene 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.
IN [04]python
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))
OUT [04]Colinealidad
          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.00

ey (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 α=0,5 n\alpha = 0{,}5\,n (cada coeficiente, multiplicado por unos dos tercios si las features fueran ortogonales) y Lasso con α=1\alpha = 1 (un umbral de 1 pb).

IN [05]python
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)
OUT [05]Ridge y Lasso con la misma intensidad aproximada
                    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.00

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

IN [06]python
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)
OUT [06]Caminos de regularización
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: float64

El 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.

IN [07]python
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)
OUT [07]¿Se repite la selección en otros años?
                    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.7

Contar 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.

IN [08]python
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)
OUT [08]231 candidatas: 21 features y sus 210 productos
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: float64

Con α = 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 X′XX'X (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.

IN [09]python
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)))
OUT [09]Fuera de muestra: 21 frente a 231
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.9
Fig. — IC de 2019 a 2025 con 21 features y con 231 (las 21 y sus 210 productos), entrenando con 2012-2018: MCO, Ridge y Lasso frente al oráculo

Añ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:

Datos hasta t−1SubentrenoValida t−2 y t−1IC por cada αMejor αReentreno con todoPredice el año t
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.

IN [10]python
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))
OUT [10]Walk-forward anual con α elegido en validación
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.8
Fig. — IC fuera de muestra de los modelos lineales en un walk-forward anual 2015-2025, con α elegido cada año en una validación purgada de dos años

En 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.

IN [11]python
pd.DataFrame(alfa_elegido, index=range(2015, 2026)).drop(columns="mco")
OUT [11]El α que elige la validación, año a año
          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.2

El α 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 n≫pn \gg p, 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.

IN [12]python
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}")
OUT [12]Diferencias emparejadas frente a MCO
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.0

Emparejar 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.

IN [13]python
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)))
OUT [13]Regresión logística para la dirección
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.4

El 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 nn. Con l1_ratio=1.0 (L1) hace falta solver="saga" o "liblinear"; los dos son más lentos con este tamaño.

IN [14]python
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)
OUT [14]Coeficientes de la logística frente a los de MCO
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.87

Los 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 StandardScaler dentro 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 nn y el otro no. Un alpha=1 en 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_63d sale 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

  1. 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
IN [15]python
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")
OUT [15]El Lasso y la fuga
coeficiente de la fuga: 69.7 pb (ret_1s: -4.0 pb)
α en que entra: 62.2 | features con α = 5: 2

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

  1. 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 regimen de panel5_coef_verdad.csv) con todo el periodo y compara el coeficiente de mom_12_1 con la media del coeficiente verdadero c_mom en esas semanas.
Solución
IN [16]python
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")
OUT [16]El momentum por régimen
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 pb

MCO 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.

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

  1. En scikit-learn, Ridge(alpha=1e5) y Lasso(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.

  2. MCO estima un coeficiente de −4,3 pb para ey y de 5,9 pb para valor_bp, con verdades de 0,4 y 3,6. ¿Qué explica mejor el error?

    • AQue ey tiene una fuga
    • BQue hacen falta más árboles
    • CQue el value no tiene efecto
    • DColinealidad: las dos features tienen correlación 0,71 y ey también se parece a roe (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 ey también se parece a roe (0,72); MCO reparte la señal con signos opuestos que se compensan

    Con 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 ey y roe.

  3. En el camino del Lasso, ruido_2 (ruido puro) entra con el mismo α que sentimiento (efecto verdadero). ¿Qué conclusión es correcta?

    • Aruido_2 tiene 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).

  4. 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.

  5. 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

Datos