---
title: "5.5.1 Ridge, Lasso, Elastic Net y logística"
description: "Ridge, Lasso, Elastic Net y logística sobre un panel con la verdad publicada: caminos de regularización, ruido seleccionado y walk-forward honesto."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica.png"
---

# 5.5.1 · 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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- 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](https://pythonparatrading.com/curso-python-trading/nivel-2/factores-y-estrategias-de-eventos/), [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/), [2.5.3 · Validación cruzada combinatoria purgada para estrategias](https://pythonparatrading.com/curso-python-trading/nivel-2/validacion-cruzada-combinatoria-purgada-cpcv/), [5.2.4 · Normalización sin leakage y rank transversal](https://pythonparatrading.com/curso-python-trading/nivel-5/normalizacion-sin-leakage-y-rank-transversal/), [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/), [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/)

## Al terminar esta lección

- Escribir las funciones objetivo de MCO, Ridge, Lasso, Elastic Net y la regresión logística penalizada, y traducir la intensidad α de scikit-learn a unidades que se entienden.
- Comparar los coeficientes estimados con los verdaderos e identificar los tres fallos típicos: colinealidad, ruido seleccionado y confusión que ninguna penalización corrige.
- Leer un camino de regularización y comprobar si la selección del Lasso se repite en otros periodos y con el mismo signo.
- Elegir α con una validación purgada dentro de un walk-forward anual y comparar los modelos por su IC con error típico y diferencias emparejadas.
- Usar la regresión logística para la dirección y entender por qué sus probabilidades apenas se separan del 50 %.

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

$$
\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})$, 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 $n$; Lasso y Elastic Net sí.** Con features ortogonales de varianza 1, Ridge
  multiplica cada coeficiente de MCO por $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, $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).

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

```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")
```
```output
(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.

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

```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)
```
```output
                    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).* Una fila por feature. El cuadrado hueco es la verdad y el lleno la estimación de MCO, con su intervalo. ret_1s (verdad −5,1, MCO −5,1) y mom_12_1 (4,7 y 5,3) se recuperan bien; valor_bp sale en 5,9 con verdad 3,6 y ey en −4,3 con verdad 0,4, compensándose por colinealidad. vol_63d (destacada) tiene verdad −3,7 y MCO +1,8: la beta de las acciones volátiles tapa la prima. Los intervalos miden entre 3,5 y 12 puntos básicos de ancho.

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.

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

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

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

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

```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)
```
```output
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'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.

```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)))
```
```output
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.* Cinco filas con el IC medio semanal y su intervalo del 95 %. MCO con 21 features, 0,0195. MCO con 231 (destacado) baja a 0,0133 y Ridge con 231 se queda en 0,0134. Lasso con 231 recupera 0,0188, casi lo mismo que el modelo pequeño. El oráculo da 0,0256.

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:

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

```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))
```
```output
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.* Seis filas con el IC medio semanal y su intervalo del 95 %. MCO (destacado) 0,0184; Ridge 0,0175; Lasso 0,0185; Elastic Net 0,0175; logística 0,0160; oráculo, el alfa verdadero, 0,0269. Los cinco modelos tienen intervalos que se solapan casi por completo, de 0,010 a 0,026: la regularización no mejora a MCO. El oráculo marca el techo.

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.

```python
pd.DataFrame(alfa_elegido, index=range(2015, 2026)).drop(columns="mco")
```
```output
          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 \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.

```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}")
```
```output
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.

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

En este panel el ridge de referencia del README tiene un Sharpe bruto de 1,25 y un Sharpe neto de −0,18 con 10 pb por
lado y una rotación semanal del 265 %. Un IC estadísticamente significativo no es una estrategia rentable: la lección
5.4.4 hace el backtest con costes de estas señales.

</aside>

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

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

```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)
```
```output
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 $n$ 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.**

```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")
```
```output
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).

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 `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.**

```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")
```
```output
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.

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

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

   - A. Los dos lo multiplican por 0,5
   - B. Ridge lo pone a cero y Lasso lo deja igual
   - C. Ridge lo multiplica por 1/(1 + α/n) ≈ 0,59 y Lasso le resta 1 pb (umbral suave): queda en 4 pb
   - D. Ninguno lo cambia, porque α es pequeño frente a n

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

   - A. Que `ey` tiene una fuga
   - B. Que hacen falta más árboles
   - C. Que el value no tiene efecto
   - 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

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

   - A. `ruido_2` tiene un efecto que el generador no documenta
   - 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
   - C. Hay que bajar α hasta que salga
   - D. El Lasso está mal implementado

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

   - 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)
   - B. Que la rejilla está mal
   - C. Que hay que reentrenar cada semana
   - D. Que Ridge sobreajusta mucho

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

   - A. Sí: un acierto tan bajo no se puede operar
   - 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
   - C. No: un acierto del 50,6 % es excelente en cualquier caso
   - D. Sí, porque su log-loss es mayor que el de una moneda

   *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

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [scikit-learn 1.9: modelos lineales (Ridge, Lasso, Elastic Net, regresión logística)](https://scikit-learn.org/stable/modules/linear_model.html) · scikit-learn.org

### Libros

- [Hoerl, A. E. y Kennard, R. W. (1970): Ridge Regression: Biased Estimation for Nonorthogonal Problems. Technometrics 12(1), 55-67](https://doi.org/10.1080/00401706.1970.10488634) · doi.org
- [Tibshirani, R. (1996): Regression Shrinkage and Selection via the Lasso. Journal of the Royal Statistical Society B 58(1), 267-288](https://doi.org/10.1111/j.2517-6161.1996.tb02080.x) · doi.org
- [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](https://doi.org/10.1111/j.1467-9868.2005.00503.x) · doi.org
- [Gu, S., Kelly, B. y Xiu, D. (2020): Empirical Asset Pricing via Machine Learning. The Review of Financial Studies 33(5), 2223-2273](https://doi.org/10.1093/rfs/hhaa009) · doi.org

### Datos

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

Lección anterior: [5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/backtest-de-senales-de-ml-y-deflated-sharpe/)

Lección siguiente: [5.5.2 · Árboles de decisión, Random Forest y bagging](https://pythonparatrading.com/curso-python-trading/nivel-5/arboles-random-forest-y-bagging/)
