---
title: "5.2.5 Importancia de features: MDI, MDA, SFI y SHAP"
description: "Cuatro medidas de importancia sobre un panel con features verdaderas y de puro ruido conocidas: sesgos del MDI, MDA con purga, SFI, SHAP y grupos."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/importancia-de-features-mdi-mda-sfi-y-shap/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/importancia-de-features-mdi-mda-sfi-y-shap.png"
---

# 5.2.5 · Importancia de features: MDI, MDA, SFI y SHAP

> Medir qué features usa un modelo es la herramienta de investigación más útil del ML financiero y una de las más fáciles de malinterpretar. En el panel del nivel se sabe qué features tienen efecto, cuáles solo lo heredan de otra y cuáles son ruido puro, así que cada método se puede juzgar contra la verdad: con una diana sin ruido y con la etiqueta real.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.2 · Ingeniería de características
- Duración: 70 min
- Requisitos: [5.1.2 · Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn](https://pythonparatrading.com/curso-python-trading/nivel-5/aprendizaje-supervisado-y-pipelines-de-scikit-learn/), [5.2.4 · Normalización sin leakage y rank transversal](https://pythonparatrading.com/curso-python-trading/nivel-5/normalizacion-sin-leakage-y-rank-transversal/), [2.1.8 · El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»](https://pythonparatrading.com/curso-python-trading/nivel-2/pruebas-multiples-bonferroni-fdr-y-factor-zoo/)

## Al terminar esta lección

- Calcular MDI, MDA con purged k-fold, SFI y SHAP sobre el mismo bosque y explicar qué pregunta responde cada uno.
- Reconocer los sesgos del MDI (dentro de muestra, cardinalidad, reparto entre features correlacionadas) y por qué con una etiqueta ruidosa reparte la importancia casi por igual.
- Medir el efecto de sustitución en MDA y SFI y corregirlo con la importancia por grupos de features correlacionadas (clustered feature importance).
- Leer la importancia con su error típico y con controles de ruido conocidos, y no confundirla con una prueba de que la feature predice.
- Comprobar con SHAP que la explicación es del modelo y no del mundo: un bosque que no aprendió una interacción no la enseña.

## Para qué sirve la importancia (y para qué no)

Un backtest dice si una estrategia ganó dinero en una muestra; no dice por qué. La importancia de features intenta
responder a la segunda pregunta: qué variables usa un modelo y cuánto pierde sin ellas. López de Prado (2018,
capítulo 8) lo resume en una regla de trabajo: el backtest no es una herramienta de investigación, la importancia de
features sí. Se investiga con la importancia (qué fenómeno hay detrás, qué features sobran, qué hipótesis merece un
experimento nuevo) y se usa el backtest una sola vez, al final, para validar (lecciones 2.5.4 y 5.4.4).

Hay cuatro familias de medidas, y cada una responde a una pregunta distinta:

| Medida | Pregunta | Datos | Dónde falla |
|---|---|---|---|
| MDI (*mean decrease impurity*) | ¿Cuánto reduce la impureza en los cortes del árbol? | Entrenamiento | Ruido, cardinalidad, sustitución |
| MDA (*mean decrease accuracy*, permutación) | ¿Cuánto empeora la predicción fuera de muestra sin ella? | Prueba | Sustitución, error grande |
| SFI (*single feature importance*) | ¿Cuánto predice sola? | Prueba | Interacciones, información prestada |
| SHAP | ¿Cuánto aporta a cada predicción concreta? | Cualquiera | Explica el modelo, no los datos |

```mermaid
flowchart TB
  F[Features] --> M[Modelo]
  M --> I[Importancia]
  I --> H[Hipótesis]
  H --> F
  I -.-> B[Backtest final]
```

El problema de juzgar estas medidas con datos reales es que nunca se sabe cuál es la respuesta. El panel del nivel 5
sí la tiene.

## El banco de pruebas: verdad conocida y dos dianas

El panel tiene 400 acciones ficticias, 729 semanas con etiqueta y 22 features semanales (sin `revision_analistas`,
la fuga de la lección 5.2.4). El README del nivel publica qué hace cada una en el modelo generador:

| Grupo | Features | Papel en el generador |
|---|---|---|
| Efecto propio | `ret_1s`, `mom_12_1`, `valor_bp`, `sue`, `vol_63d`, `sentimiento` | Entran en el alfa |
| Solo en interacción | `volumen_rel` (con `ret_1s`), `dias_desde_informe` (con `sue`) | Modulan otro efecto |
| Heredan un efecto | `ruido_4` (0,6 × momentum + ruido); `ey`, `roe`, `rsi_14`, `ret_1m`, `ret_3m`, `dist_max_52s`, `vol_21d`, `log_capitalizacion` | Correlacionadas con las verdaderas, sin efecto propio |
| Ruido | `ruido_1`, `ruido_2`, `ruido_3`, `ruido_5` | Ninguno |

A esas 22 se añade aquí una feature de control fabricada en la propia celda, `ruido_binario`: una moneda al aire, con
solo dos valores. Las features entran como rango normal de cada semana (lección 5.2.4) y hay dos dianas:

- **La etiqueta real**: el rendimiento de la semana siguiente menos la media de la semana, en puntos básicos.
- **El alfa verdadero** menos su media: la parte predecible de la etiqueta, sin el ruido. Ningún modelo real tiene
  esta diana; sirve para ver qué dirían los métodos si la señal no estuviera enterrada.

```python
import numpy as np
import pandas as pd
from scipy import stats

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",
               "fin_sig_semana"]], on=["fecha", "ticker"])
      .merge(v[["fecha", "ticker", "alfa_verdadero"]],
             on=["fecha", "ticker"])
      .dropna(subset=["ret_sig_semana"])
      .reset_index(drop=True))
FEATS = [c for c in f.columns
         if c not in ("fecha", "ticker", "sector",
                      "revision_analistas")]

sem = d.groupby("fecha")
def gauss(col):
    n = sem[col].transform("count")
    return stats.norm.ppf((sem[col].rank() - 0.5) / n)

X = pd.DataFrame({c: gauss(c) for c in FEATS}).fillna(0.0)
rng = np.random.default_rng(0)
X["ruido_binario"] = rng.integers(0, 2, len(X)).astype(float)
cols = list(X.columns)

def menos_media(col):   # en puntos básicos
    return ((d[col] - sem[col].transform("mean")) * 1e4).to_numpy()

y = menos_media("ret_sig_semana")      # la etiqueta real
a = menos_media("alfa_verdadero")      # la diana sin ruido
print(X.shape)
print(f"desv. etiqueta {y.std():.0f} pb · "
      f"desv. alfa {a.std():.1f} pb")
```
```output
(291600, 22)
desv. etiqueta 366 pb · desv. alfa 12.7 pb
```

La etiqueta tiene una desviación de 366 pb y el alfa, de 12,7: el ruido es 29 veces la señal. Esa proporción decide
casi todo lo que sigue.

El modelo es un random forest de 50 árboles con las elecciones de López de Prado para medir importancia: `max_features=1` (en cada
corte se elige una feature al azar, para que ninguna tape a sus correlacionadas, el *masking*), hojas grandes
(`min_samples_leaf=1000`) y submuestras del 20 % por árbol. La puntuación fuera de muestra es el IC semanal (lección
5.1.3), y la validación, un purged k-fold de cinco bloques de semanas con embargo de cuatro (lección 5.4.2): con la
etiqueta de una semana la purga apenas quita filas; el embargo evita que las features persistentes conecten el final de
un bloque con el principio del siguiente.

```python
from sklearn.ensemble import RandomForestRegressor

fechas = np.sort(d["fecha"].unique())
semana = np.searchsorted(fechas, d["fecha"].to_numpy())
fin = d["fin_sig_semana"].to_numpy()
inicio = d["fecha"].to_numpy()

def pliegues(k=5, embargo=4):
    for b in np.array_split(np.arange(len(fechas)), k):
        te = np.isin(semana, b)
        ini_te, fin_te = fechas[b[0]], fin[te].max()
        solapa = (fin > ini_te) & (inicio <= fin_te)   # purga
        emb = (semana > b[-1]) & (semana <= b[-1] + embargo)
        yield ~te & ~solapa & ~emb, te

def ic_semanal(pred, obj, sem_idx):
    r = pd.DataFrame({"p": pred, "y": obj}).groupby(sem_idx).rank()
    r = r - r.groupby(sem_idx).transform("mean")
    num = (r["p"] * r["y"]).groupby(sem_idx).sum()
    den = np.sqrt((r["p"] ** 2).groupby(sem_idx).sum()
                  * (r["y"] ** 2).groupby(sem_idx).sum())
    return (num / den).mean()

def bosque(n=50, muestra=0.2):
    return RandomForestRegressor(
        n_estimators=n, max_features=1, min_samples_leaf=1000,
        max_samples=muestra, n_jobs=4, random_state=0)

for i, (tr, te) in enumerate(pliegues()):
    print(i, f"prueba {te.sum():6d}  entrenamiento {tr.sum():6d}  "
          f"fuera {(~tr & ~te).sum():5d}")
```
```output
0 prueba  58400  entrenamiento 231600  fuera  1600
1 prueba  58400  entrenamiento 231600  fuera  1600
2 prueba  58400  entrenamiento 231600  fuera  1600
3 prueba  58400  entrenamiento 231600  fuera  1600
4 prueba  58000  entrenamiento 233600  fuera     0
```

## MDI: la importancia dentro de muestra

Cada corte de un árbol reduce la impureza (en regresión, el error cuadrático) de su nodo. El MDI de una feature es la
suma de esas reducciones en todos los cortes que la usan, ponderada por la fracción de muestras que pasan por el
nodo, promediada en los árboles y normalizada para que el total sea 100 %. Es gratis (sale del entrenamiento) y es lo
que devuelve `feature_importances_` en scikit-learn.

```python
mdi = pd.DataFrame({
    "alfa": bosque().fit(X, a).feature_importances_,
    "etiqueta": bosque().fit(X, y).feature_importances_,
}, index=cols) * 100
mdi.sort_values("alfa", ascending=False).round(2)
```
```output
                     alfa  etiqueta
ret_1s              21.60      6.47
vol_63d             12.75      6.64
valor_bp             8.17      4.76
ey                   8.12      5.59
rsi_14               7.98      5.30
sue                  7.60      4.63
mom_12_1             6.11      5.47
sentimiento          5.66      3.05
vol_21d              5.46      5.41
ret_1m               4.78      4.84
roe                  3.98      5.06
dist_max_52s         3.06      4.36
ruido_4              1.90      4.13
log_capitalizacion   0.92      4.22
ret_3m               0.86      4.57
volumen_rel          0.53      3.87
dias_desde_informe   0.13      4.85
ruido_3              0.10      3.67
ruido_1              0.08      4.15
ruido_2              0.08      3.63
ruido_5              0.08      4.22
ruido_binario        0.02      1.13
```

Con la diana sin ruido, el MDI se parece a la verdad: `ret_1s` (la reversión, el efecto más fuerte) se lleva el
21,6 %, las features de ruido puro, un 0,1 % o menos, y la moneda, un 0,02 %. Pero ya enseña su primer sesgo: reparte
el efecto entre features correlacionadas. `ey` (8,1 %) y `rsi_14` (8,0 %), sin efecto propio, superan
a `mom_12_1` (6,1 %), que sí lo tiene, y `ret_1m` (4,8 %) se le acerca: con `max_features=1`, cuando el árbol no puede elegir a la verdadera,
elige a su vecina.

Con la etiqueta real, el MDI no separa nada. Todas las features continuas quedan entre el 3,6 % y el 6,6 %, alrededor
del 4,5 % de un reparto uniforme; `ruido_1` (4,2 %) y `ruido_5` (4,2 %) están por delante de `sue` (4,6 %) por
décimas. La razón es que el MDI se mide **dentro de muestra**: si el 99,9 % de la varianza de la etiqueta es ruido,
los cortes que más la reducen son los que ajustan ruido, y cualquier feature continua sirve. Las dos excepciones son
`sentimiento` (3,1 %), que tiene un 32 % de valores vacíos rellenados con 0, y la moneda (1,1 %): con menos valores
distintos hay menos cortes posibles. Es el **sesgo de cardinalidad** del MDI, documentado en la propia ayuda de
scikit-learn: favorece a las features con muchos valores, aunque sean ruido.

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

El MDI de un modelo entrenado con datos financieros reales se parece mucho más a la columna de la derecha que a la de
la izquierda. Una lista de features ordenada por `feature_importances_` no es evidencia de nada: ordena, sobre todo,
qué features tienen más valores distintos y más varianza para ajustar ruido.

</aside>

## MDA: lo que se pierde fuera de muestra

La importancia por permutación (MDA) se mide en datos que el modelo no ha visto: se entrena en cada pliegue, se puntúa
en la prueba, se baraja una feature en la prueba (rompe su relación con la etiqueta y conserva su distribución) y se
vuelve a puntuar. La caída de la puntuación es la importancia; se promedia en los pliegues y su error típico sale de la
dispersión entre ellos. Aquí la caída se mide en milésimas de IC.

```python
def mda(obj, grupos=None, n=50):
    grupos = grupos or [[c] for c in cols]
    rng = np.random.default_rng(0)
    caidas, base = {" + ".join(g): [] for g in grupos}, []
    for tr, te in pliegues():
        m = bosque(n).fit(X.values[tr], obj[tr])
        Xte = X.values[te]
        s0 = ic_semanal(m.predict(Xte), obj[te], semana[te])
        base.append(s0)
        for g in grupos:
            Xp = Xte.copy()
            j = [cols.index(c) for c in g]
            Xp[:, j] = Xp[rng.permutation(len(Xp))][:, j]
            s = ic_semanal(m.predict(Xp), obj[te], semana[te])
            caidas[" + ".join(g)].append(s0 - s)
    c = pd.DataFrame(caidas).T * 1000      # milésimas de IC
    return pd.DataFrame({"mda": c.mean(axis=1),
                         "et": c.std(axis=1) / np.sqrt(c.shape[1])}
                        ), np.mean(base)

mda_a, base_a = mda(a)
mda_y, base_y = mda(y)
print(f"IC fuera de muestra: alfa {base_a:.3f} · "
      f"etiqueta {base_y:.4f}")
tabla = pd.DataFrame({"mda alfa": mda_a["mda"],
                      "mda etiqueta": mda_y["mda"],
                      "et etiqueta": mda_y["et"]})
tabla.sort_values("mda etiqueta", ascending=False).round(2)
```
```output
IC fuera de muestra: alfa 0.652 · etiqueta 0.0148
                    mda alfa  mda etiqueta  et etiqueta
ret_1s                156.82          4.04         1.09
valor_bp               25.71          3.27         0.89
ey                     22.81          1.84         0.54
mom_12_1               26.92          1.67         0.58
ruido_1                 0.01          1.20         0.50
ruido_binario          -0.08          1.02         0.66
sentimiento            34.83          0.82         0.47
ret_1m                 -0.11          0.80         0.66
sue                    39.69          0.70         0.60
dias_desde_informe     -0.11          0.58         0.59
ruido_4                 5.47          0.47         0.24
roe                     2.17          0.29         0.50
dist_max_52s            8.70          0.23         0.23
volumen_rel             2.28          0.22         0.21
rsi_14                 14.20          0.19         0.81
ruido_2                 0.18          0.09         0.52
vol_63d                57.46         -0.04         0.49
log_capitalizacion      1.37         -0.08         0.41
ruido_5                 0.29         -0.23         0.82
vol_21d                 9.30         -0.41         1.02
ruido_3                 0.14         -0.41         0.30
ret_3m                 -1.19         -0.74         0.54
```

Fuera de muestra, el bosque logra un IC de 0,652 con la diana sin ruido y de 0,0148 con la etiqueta real; el techo del
oráculo con la etiqueta real es 0,029 (lección 5.2.4). Con la diana sin ruido, el MDA es limpio: todas las features de
ruido quedan en cero y las de efecto propio arriba. Pero tiene el sesgo contrario al del MDI: `ret_1m` (−0,1) y
`roe` (2,2) caen a casi nada y `rsi_14` (14,2) queda muy por debajo de `sue` o `mom_12_1`, porque permutar una feature cuando su vecina sigue en el modelo apenas
cuesta: el modelo se apoya en la otra. Es el **efecto de sustitución**.

Con la etiqueta real, el MDA separa algo: `ret_1s` (4,0 ± 1,1), `valor_bp` (3,3 ± 0,9), `ey` (1,8 ± 0,5) y
`mom_12_1` (1,7 ± 0,6) quedan arriba. Pero la lista sigue con `ruido_1` (1,2 ± 0,5, $t \approx 2{,}4$) y
`ruido_binario` (1,0 ± 0,7): dos features de ruido por delante de `sue` y `sentimiento`, que tienen efecto. Y
`vol_63d`, con efecto plantado, sale en cero: la etiqueta solo resta la media de la semana, y en el panel las acciones
volátiles tienen más beta, así que la prima de baja volatilidad queda tapada por la beta (el README del nivel lo mide).

La figura de la cabecera pone las dos columnas de la etiqueta real juntas. Con 22 features, que alguna de ruido pase de
$t = 2$ es lo esperable (lección 2.1.8). Por eso conviene meter siempre en el modelo varios **controles de ruido
conocidos**, como aquí `ruido_binario`: su importancia da la vara de lo que el azar produce, y una feature real tiene
que quedar claramente por encima de todos ellos.

## SFI: cada feature por separado

El SFI elimina la sustitución por construcción: entrena un modelo con **una sola** feature y mide su puntuación fuera
de muestra, con la misma validación. Es caro (un modelo por feature y pliegue), así que aquí cada uno tiene 10 árboles
y submuestras del 5 %.

```python
def sfi(obj, n=10):
    res = {}
    for j, c in enumerate(cols):
        s = [ic_semanal(bosque(n, 0.05)
                        .fit(X.values[tr][:, [j]], obj[tr])
                        .predict(X.values[te][:, [j]]),
                        obj[te], semana[te])
             for tr, te in pliegues()]
        res[c] = {"sfi": np.mean(s) * 1000,
                  "et": np.std(s, ddof=1) / np.sqrt(len(s)) * 1000}
    return pd.DataFrame(res).T

sfi_a, sfi_y = sfi(a), sfi(y)
pd.DataFrame({"sfi alfa": sfi_a["sfi"], "sfi etiqueta": sfi_y["sfi"],
              "et etiqueta": sfi_y["et"]}
             ).sort_values("sfi alfa", ascending=False).round(1)
```
```output
                    sfi alfa  sfi etiqueta  et etiqueta
ret_1s                 463.8          12.5          3.0
vol_63d                300.3           2.7          1.9
sue                    283.5           2.1          1.4
ey                     276.4           1.9          0.9
mom_12_1               269.2           5.2          1.4
valor_bp               245.8           3.8          2.6
vol_21d                232.0           1.1          2.3
rsi_14                 223.6           3.5          1.0
sentimiento            211.6           1.4          0.8
roe                    185.9           2.7          1.0
ret_1m                 183.1           3.3          2.2
dist_max_52s           173.9           2.6          1.4
ruido_4                159.4          -2.5          3.4
ret_3m                  63.2           0.4          2.1
log_capitalizacion      51.2           0.8          1.8
ruido_3                  8.7          -1.0          2.1
volumen_rel              5.5           0.7          1.7
dias_desde_informe       1.2           0.2          1.3
ruido_binario            1.1          -2.5          1.3
ruido_1                  1.0           0.3          1.9
ruido_5                 -1.2           1.5          1.7
ruido_2                 -2.0          -1.3          2.1
```

Con la diana sin ruido, el SFI muestra la otra cara de la sustitución: `ret_1m` (183), `rsi_14` (224), `ey` (276) y
`roe` (186) parecen casi tan predictivas como las verdaderas, porque solas cargan con el efecto de su vecina. Y no ve las
interacciones: `volumen_rel` (5,5) y `dias_desde_informe` (1,2) no predicen nada solas, aunque el modelo completo las
use para modular la reversión y el SUE.

Con la etiqueta real, el SFI destaca a `ret_1s` (12,5 ± 3,0) y, a distancia, a `mom_12_1` (5,2 ± 1,4) y a
`rsi_14` (3,5 ± 1,0), que sola carga con parte de la reversión. Features sin efecto propio como `roe` (2,7 ± 1,0)
quedan a la altura de `valor_bp` (3,8 ± 2,6) y `sue` (2,1 ± 1,4), y `ruido_4` y `ruido_binario` salen negativos
(−2,5): un modelo de una feature sin efecto aprende ruido del entrenamiento y lo paga en la prueba.

## Importancia por grupos de features correlacionadas

La solución de López de Prado (2020, capítulo 6) a la sustitución es dejar de preguntar por cada feature y preguntar
por **grupos**: agrupar las features por su correlación y medir el MDA permutando el grupo entero, de modo que el modelo
no pueda refugiarse en una vecina. La distancia habitual es $\sqrt{(1 - \rho)/2}$, que vale 0 para dos features
idénticas y 1 para dos opuestas; con un enlace medio (*average linkage*) y un corte en 0,5 salen los grupos:

```python
from scipy.cluster.hierarchy import fcluster, linkage
from scipy.spatial.distance import squareform

C = X[FEATS].corr()
dist = np.sqrt(0.5 * (1 - C.to_numpy()))
Z = linkage(squareform(dist, checks=False), method="average")
etiq = fcluster(Z, t=0.5, criterion="distance")
grupos = {}
for c, k in zip(FEATS, etiq):
    grupos.setdefault(k, []).append(c)
grupos = list(grupos.values()) + [["ruido_binario"]]
for g in grupos:
    if len(g) > 1:
        print(" + ".join(g))
print(len(grupos), "grupos")
```
```output
ret_1s + ret_1m + rsi_14
ret_3m + dist_max_52s
mom_12_1 + ruido_4
vol_21d + vol_63d
valor_bp + ey + roe
15 grupos
```

Los cinco grupos con más de una feature tienen sentido económico: reversión a corto (`ret_1s`, `ret_1m`, `rsi_14`),
rendimiento a medio plazo (`ret_3m`, `dist_max_52s`), momentum (`mom_12_1` y su copia ruidosa `ruido_4`), volatilidad
(`vol_21d`, `vol_63d`) y value-calidad (`valor_bp`, `ey`, `roe`). López de Prado propone elegir el número de grupos con
un algoritmo de clustering con la silueta como criterio (ONC); un umbral fijo sobre un dendrograma, como aquí, es la
versión sencilla y basta cuando los grupos son tan claros.

```python
cmda_a, _ = mda(a, grupos)
cmda_y, _ = mda(y, grupos)
pd.DataFrame({"alfa": cmda_a["mda"], "etiqueta": cmda_y["mda"],
              "et etiqueta": cmda_y["et"]}
             ).sort_values("etiqueta", ascending=False).round(2)
```
```output
                            alfa  etiqueta  et etiqueta
ret_1s + ret_1m + rsi_14  258.38      6.31         2.27
valor_bp + ey + roe       124.07      4.45         1.90
vol_21d + vol_63d         112.37      2.44         1.29
mom_12_1 + ruido_4         41.51      2.40         0.71
ruido_binario              -0.01      1.27         0.82
log_capitalizacion          1.29      0.81         0.46
ruido_1                     0.10      0.68         0.43
sue                        40.23      0.58         0.63
sentimiento                34.80      0.53         0.31
dias_desde_informe          0.15      0.50         0.75
volumen_rel                 2.28      0.46         0.20
ret_3m + dist_max_52s       8.43      0.28         0.66
ruido_5                     0.20     -0.09         0.71
ruido_3                     0.10     -0.18         0.20
ruido_2                     0.08     -0.19         0.23
```

*Fig. — MDA por grupos de features correlacionadas con la etiqueta real del panel (purged k-fold, ±2 errores típicos): los cuatro grupos con efecto plantado quedan arriba.* Puntos con intervalo, uno por grupo. Reversión (ret_1s, ret_1m, rsi_14), destacada, 6,3; value-calidad 4,5; volatilidad 2,4; momentum 2,4. Después ruido_binario 1,3, log_capitalizacion 0,8, ruido_1 0,7, sue 0,6, sentimiento 0,5, días desde informe 0,5, volumen_rel 0,5, medio plazo 0,3 y ruido_5, ruido_3 y ruido_2 alrededor de −0,2. Solo los intervalos de reversión, value-calidad y momentum quedan claramente por encima de cero.

Con la diana sin ruido, el grupo de la reversión (258) recoge lo que el MDA individual repartía mal, y el de
value-calidad (124) ya no depende de cuál de las tres features mire el modelo. Con la etiqueta real, los cuatro grupos
con efecto plantado son los cuatro primeros: reversión (6,3 ± 2,3), value-calidad (4,5 ± 1,9), volatilidad
(2,4 ± 1,3) y momentum (2,4 ± 0,7). Detrás, la moneda (1,3 ± 0,8) recuerda el tamaño del ruido de la medida.

## SHAP: la aportación a cada predicción

Los valores de Shapley vienen de la teoría de juegos cooperativos: reparten el «pago» de una coalición entre sus
jugadores según su aportación media en todas las coaliciones posibles. SHAP (Lundberg y Lee, 2017) los aplica a una
predicción: la diferencia entre la predicción de una fila y la media se reparte entre las features, y la suma de los
valores es exactamente esa diferencia. Para árboles, `shap.TreeExplainer` los calcula de forma exacta y rápida. La
importancia global habitual es la media de los valores absolutos:

```python
import shap

tr, te = list(pliegues())[-1]
m_y = bosque().fit(X.values[tr], y[tr])
muestra = np.random.default_rng(1).choice(np.flatnonzero(te), 3000,
                                          replace=False)
sv = shap.TreeExplainer(m_y).shap_values(X.values[muestra])
shap_y = pd.Series(np.abs(sv).mean(axis=0), index=cols)
shap_y.sort_values(ascending=False).round(2).head(10)
```
```output
ret_1s      1.02
rsi_14      0.94
ret_1m      0.83
ruido_1     0.71
mom_12_1    0.67
ruido_5     0.66
valor_bp    0.64
ey          0.59
roe         0.53
vol_63d     0.51
dtype: float64
```

Calculado en filas de prueba del último pliegue con el bosque entrenado en la etiqueta real, el SHAP medio ordena
`ret_1s` primero, pero mete en el top 10 a `ruido_1` (0,71) y `ruido_5` (0,66) por delante de `valor_bp`: SHAP explica
lo que hace el modelo, y este modelo, entrenado con ruido, usa ruido. No es una medida de cuánto predice una feature,
sino de cuánto mueve las predicciones.

La ventaja de SHAP está en el detalle: dice cómo depende la aportación de una feature de su valor y del de las demás.
El generador planta una interacción: la reversión se multiplica por $(1 - 0{,}5\cdot g(\text{volumen\_rel}))$, más
fuerte cuando el volumen es bajo. Para un término $c\cdot x\cdot(1 - 0{,}5z)$, el valor de Shapley de $x$ es
$c\cdot x\cdot(1 - 0{,}25z)$: la mitad del término cruzado se atribuye a $z$. La pendiente del SHAP de `ret_1s` en
cada tercio de `volumen_rel`, relativa al tercio medio, se puede comparar con esa respuesta exacta:

```python
import lightgbm as lgb

m_a = bosque().fit(X.values[tr], a[tr])
m_l = lgb.LGBMRegressor(
    n_estimators=300, learning_rate=0.05, num_leaves=15,
    min_child_samples=500, subsample=0.5, subsample_freq=1,
    random_state=0, n_jobs=4, deterministic=True,
    force_row_wise=True, verbose=-1).fit(X.values[tr], a[tr])
xs = X.iloc[muestra]
j = cols.index("ret_1s")
tercio = pd.cut(xs["volumen_rel"], [-np.inf, -0.43, 0.43, np.inf],
                labels=["bajo", "medio", "alto"])

def pendientes(modelo):
    sv = shap.TreeExplainer(modelo).shap_values(X.values[muestra])
    p = pd.Series({k: np.polyfit(xs["ret_1s"][tercio == k],
                                 sv[(tercio == k).to_numpy(), j], 1)[0]
                   for k in ["bajo", "medio", "alto"]})
    return p / p["medio"]

media_g = xs.groupby(tercio, observed=True)["volumen_rel"].mean()
t = pd.DataFrame({"bosque": pendientes(m_a),
                  "LightGBM": pendientes(m_l),
                  "SHAP exacto": 1 - 0.25 * media_g,
                  "efecto verdadero": 1 - 0.5 * media_g})
t.round(2)
```
```output
       bosque  LightGBM  SHAP exacto  efecto verdadero
bajo     1.05      1.25         1.27              1.55
medio    1.00      1.00         1.00              1.00
alto     0.91      0.72         0.72              0.45
```

*Fig. — Pendiente del SHAP de ret_1s en cada tercio de volumen_rel, relativa al tercio medio, con la diana sin ruido: LightGBM, el bosque de max_features=1, el valor exacto de Shapley y el efecto verdadero del generador.* Cuatro líneas de bajo a alto volumen relativo. Efecto verdadero: 1,55, 1 y 0,45. Shapley exacto, que atribuye la mitad de la interacción a volumen_rel: 1,27, 1 y 0,72. LightGBM, destacado: 1,25, 1 y 0,72, casi encima del exacto. Bosque: 1,05, 1 y 0,91, casi plano: apenas aprendió la interacción.

El LightGBM (árboles de hasta 15 hojas, que pueden combinar features en un mismo camino) da 1,25 y 0,72, sobre el valor
exacto de Shapley (1,27 y 0,72). El bosque de las secciones anteriores da 1,05 y 0,91: con una feature al azar por
corte y hojas de 1.000 filas, apenas aprendió la interacción, y SHAP lo refleja con fidelidad. La explicación es del
modelo, no de los datos: si el modelo no aprendió algo, SHAP no lo va a enseñar, y si aprendió ruido, lo enseñará como
si fuera señal.

## Cómo usar todo esto

```mermaid
flowchart TB
  A[Candidatas] --> G[Grupos]
  G --> P[MDA con purga]
  P --> C{¿Sobre controles?}
  C -- no --> D[Descartar]
  C -- sí --> S[SHAP: forma]
  S --> V[Validación final]
```

- **Nunca solo MDI.** Con etiquetas financieras es casi uniforme y está sesgado por la cardinalidad. Si se usa, que sea
  con la diana de un problema con señal o como diagnóstico del modelo, no de los datos.
- **MDA con la misma validación honesta del modelo** (purga, embargo) y con la puntuación económica (IC, Sharpe de la
  señal), no con el acierto bruto.
- **Grupos antes que features sueltas** cuando hay correlaciones fuertes, y siempre con controles de ruido.
- **Error típico y pruebas múltiples.** Un MDA con $t = 2$ entre 22 features no es un hallazgo.
- **La selección también sobreajusta.** Elegir features por su importancia en toda la muestra y después validar en la
  misma muestra es un sesgo de selección: la elección se hace dentro de cada ventana de entrenamiento o se separa un
  periodo que nadie ha mirado (lecciones 5.4.3 y 5.4.4).

## Ejercicios

1. Repite el MDI con la diana sin ruido y `max_features=None` (todas las features en cada corte). ¿Qué les pasa a las
   features correlacionadas con `ret_1s`?

**Solución.**

```python
todas = RandomForestRegressor(
    n_estimators=20, max_features=None, min_samples_leaf=1000,
    max_samples=0.2, n_jobs=4, random_state=0).fit(X, a)
comp = pd.DataFrame({
    "max_features=1": mdi["alfa"],
    "max_features=None": todas.feature_importances_ * 100})
comp.loc[["ret_1s", "ret_1m", "rsi_14", "valor_bp", "ey", "roe",
          "mom_12_1", "ruido_4"]].round(2)
```
```output
          max_features=1  max_features=None
ret_1s             21.60              52.35
ret_1m              4.78               0.00
rsi_14              7.98               0.00
valor_bp            8.17               2.03
ey                  8.12               8.60
roe                 3.98               0.00
mom_12_1            6.11               1.84
ruido_4             1.90               0.00
```

Con todas las features disponibles en cada corte, el árbol elige siempre la que más reduce la impureza y las vecinas
desaparecen: `ret_1s` pasa del 21,6 % al 52,4 % y `ret_1m`, `rsi_14`, `roe` y `ruido_4` caen a cero. Es el
*masking*. Y no siempre gana la verdadera: en el grupo de value-calidad se queda con casi todo `ey` (8,6 %) y
`valor_bp`, la que tiene el efecto plantado, baja al 2,0 %, porque en cada corte manda la que mejor ajusta esa
muestra, no la causal. Con `max_features=1` el reparto es más justo entre correlacionadas, a costa de que todas parezcan
algo importantes; por eso la importancia por grupos es la respuesta de fondo.

2. Añade `revision_analistas` (la fuga de la lección 5.2.4) al modelo y calcula el MDA de cuatro features con la
   etiqueta real. ¿Detecta la importancia la fuga?

**Solución.**

```python
X["revision_analistas"] = gauss("revision_analistas")
X["revision_analistas"] = X["revision_analistas"].fillna(0.0)
cols = list(X.columns)
mda_fuga, base_fuga = mda(y, [["revision_analistas"], ["ret_1s"],
                              ["valor_bp"], ["ruido_1"]])
print(f"IC fuera de muestra con la fuga: {base_fuga:.3f}")
mda_fuga.round(2)
```
```output
IC fuera de muestra con la fuga: 0.157
                       mda    et
revision_analistas  155.61  2.89
ret_1s                0.68  0.53
valor_bp              0.53  0.24
ruido_1              -0.15  0.13
```

La fuga sale cien veces más importante que cualquier otra feature (156 milésimas de IC frente a menos de 1) y el IC
fuera de muestra del bosque sube de 0,015 a 0,157, cinco veces el techo del oráculo. La importancia no detecta la
fuga; la señala, para quien sepa leerla. Una feature que se lleva casi toda la importancia de un problema con tan poca
señal no es un hallazgo: es el primer sitio donde buscar un error de fechas. Con la fuga en el modelo, además, el resto
de features pierde importancia (la de `ret_1s` baja de 4,0 a 0,7): el modelo explica con la fuga parte de lo que antes
explicaba con ellas.

3. Tienes 300 features candidatas para un modelo de selección de acciones semanal y diez años de datos. Describe un
   protocolo para quedarte con unas pocas sin engañarte.

**Solución.**

Primero, auditoría de fechas de cada feature (lección 5.2.4): ninguna medida de importancia detecta una fuga. Después,
transformar en rangos de cada fecha y agrupar por correlación (con distancia $\sqrt{(1-\rho)/2}$ y un criterio como la
silueta) para trabajar con decenas de grupos y no con cientos de features. Añadir varios controles de ruido (features
aleatorias con la misma persistencia que las reales: una de ruido i.i.d. y otra AR(1) lenta). Medir el MDA por grupos
con purged k-fold y la puntuación económica, solo en los primeros siete años; conservar los grupos claramente por
encima del mejor control, con el número de pruebas en mente (lección 2.1.8). Dentro de cada grupo, elegir la feature
más sencilla o la de mejor SFI. Mirar con SHAP la forma de cada efecto por si contradice la hipótesis económica. Y
validar una sola vez el modelo final en los tres últimos años, que no se usaron para elegir nada, con Deflated Sharpe
por todos los intentos (lección 5.4.4).

## Autoevaluación

1. Con la etiqueta real del panel, el MDI de un bosque da a `ruido_1` (ruido i.i.d.) un 4,2 % de la importancia y a `sue` (efecto plantado) un 4,6 %. ¿Por qué el MDI no los separa?

   - A. Porque `sue` no tiene efecto
   - B. Porque el MDI se mide dentro de muestra: con una relación señal/ruido de 1 a 30, los árboles usan cualquier feature continua para ajustar ruido y el reparto sale casi uniforme
   - C. Porque las features están en rangos normales
   - D. Porque faltan árboles

   *Respuesta B: Porque el MDI se mide dentro de muestra: con una relación señal/ruido de 1 a 30, los árboles usan cualquier feature continua para ajustar ruido y el reparto sale casi uniforme.* El MDI suma la reducción de impureza de los cortes en los datos de entrenamiento. Si casi toda la variación de la etiqueta es ruido, los cortes que más la reducen son los que ajustan ruido, y cualquier feature continua sirve igual. Con la diana sin ruido (el alfa verdadero) el MDI de `ruido_1` es 0,08 %.

2. `ruido_binario` (una moneda al aire) recibe un 1,1 % del MDI con la etiqueta real; `ruido_1`, un 4,2 %. Los dos son ruido. ¿De dónde sale la diferencia?

   - A. De que la moneda tiene efecto negativo
   - B. De la semilla del generador
   - C. De que el bosque usa `max_features=1`
   - D. Del sesgo de cardinalidad del MDI: una feature con dos valores ofrece un solo corte posible y una continua, miles, así que la continua tiene más ocasiones de ajustar ruido

   *Respuesta D: Del sesgo de cardinalidad del MDI: una feature con dos valores ofrece un solo corte posible y una continua, miles, así que la continua tiene más ocasiones de ajustar ruido.* Es el sesgo documentado del MDI a favor de las features con muchos valores distintos. Con la diana sin ruido, las dos tienen un MDI de casi cero.

3. Con la diana sin ruido, `ret_1m` (sin efecto propio, correlación 0,48 con `ret_1s`) tiene un MDA de −0,1 y un SFI de 183. ¿Qué indica la diferencia?

   - A. Que el SFI está mal calculado
   - B. Que `ret_1m` tiene un efecto no lineal
   - C. Un efecto de sustitución: sola, `ret_1m` lleva información prestada de `ret_1s`; con `ret_1s` en el modelo, permutarla no cuesta nada porque el modelo usa la original
   - D. Que el MDA necesita más permutaciones

   *Respuesta C: Un efecto de sustitución: sola, `ret_1m` lleva información prestada de `ret_1s`; con `ret_1s` en el modelo, permutarla no cuesta nada porque el modelo usa la original.* El MDA mide lo que el modelo pierde sin la feature dado el resto; el SFI, lo que la feature predice sola. Las dos lecturas son correctas y responden a preguntas distintas; la importancia por grupos reúne a `ret_1s`, `ret_1m` y `rsi_14` y les asigna el efecto conjunto.

4. Con la etiqueta real, `ruido_1` tiene un MDA de 1,20 ± 0,50 milésimas de IC (t ≈ 2,4) en el purged k-fold. ¿Qué se concluye?

   - A. Que con 22 features y una etiqueta tan ruidosa, alguna feature de ruido saldrá «significativa» por azar: hay que comparar con controles de ruido conocidos y corregir por pruebas múltiples
   - B. Que `ruido_1` tiene un efecto pequeño y hay que conservarla
   - C. Que el purged k-fold tiene una fuga
   - D. Que el MDA siempre está sesgado al alza

   *Respuesta A: Que con 22 features y una etiqueta tan ruidosa, alguna feature de ruido saldrá «significativa» por azar: hay que comparar con controles de ruido conocidos y corregir por pruebas múltiples.* Es un falso positivo de manual. Con varias features de ruido conocidas en el modelo, su distribución de importancias da la vara: una feature real tiene que quedar claramente por encima de todas ellas, y no solo pasar de t = 2 (lección 2.1.8).

5. Un LightGBM entrenado con la diana sin ruido da a `ret_1s` pendientes SHAP relativas de 1,25 con volumen bajo y 0,72 con volumen alto; el efecto verdadero es 1,55 y 0,45. ¿Por qué no coinciden?

   - A. Porque SHAP reparte la interacción entre las dos features que intervienen: la mitad va a `volumen_rel`, y lo esperado para `ret_1s` es 1,27 y 0,72, que es lo que mide
   - B. Porque el modelo no aprendió la interacción
   - C. Porque SHAP no funciona con árboles
   - D. Porque la muestra es pequeña

   *Respuesta A: Porque SHAP reparte la interacción entre las dos features que intervienen: la mitad va a `volumen_rel`, y lo esperado para `ret_1s` es 1,27 y 0,72, que es lo que mide.* Para un término c·x·(1 − 0,5·z), el valor de Shapley de x es c·x·(1 − 0,25·z): la otra mitad del término cruzado se atribuye a z. El bosque con `max_features=1` y hojas grandes, en cambio, casi no aprendió la interacción (1,05 y 0,91), y SHAP lo refleja: explica el modelo, no los datos.

## Ideas clave

- MDI, MDA, SFI y SHAP responden a preguntas distintas: cuánto usa el modelo cada feature en entrenamiento (MDI), cuánto pierde fuera de muestra sin ella (MDA), cuánto predice sola (SFI) y cuánto aporta a cada predicción (SHAP).
- Con la etiqueta real del panel, el MDI reparte la importancia casi por igual entre features verdaderas y ruido (de 3,6 % a 6,6 % frente al 4,5 % uniforme) y penaliza a la feature binaria: está dentro de muestra y sesgado por la cardinalidad.
- El MDA con purged k-fold y el IC como puntuación separa algo las features verdaderas (`ret_1s` 4,0 ± 1,1 milésimas de IC), pero con un error típico grande: un ruido puro sale con t ≈ 2,4. La importancia se lee con su error y frente a controles de ruido.
- Las features correlacionadas se sustituyen: el MDA las infravalora y el SFI les presta el efecto de su vecina. La importancia por grupos (correlación, enlace medio, umbral 0,5) asigna el efecto al grupo.
- SHAP explica el modelo: un LightGBM que aprendió la interacción reversión × volumen la enseña con las pendientes exactas de Shapley; un bosque que no la aprendió, no.
- Ninguna medida de importancia detecta una fuga: `revision_analistas` sale cien veces más importante que el resto. Una importancia desproporcionada es motivo de auditoría, no de celebración.

## Recursos

### Documentación

- [Lundberg y Lee (2017): A Unified Approach to Interpreting Model Predictions (SHAP), arXiv:1705.07874](https://arxiv.org/abs/1705.07874) · arxiv.org
- [scikit-learn: importancia por permutación frente a MDI en un random forest](https://scikit-learn.org/stable/auto_examples/inspection/plot_permutation_importance.html) · scikit-learn.org
- [SHAP: TreeExplainer](https://shap.readthedocs.io/en/latest/generated/shap.TreeExplainer.html) · shap.readthedocs.io

### Libros

- [López de Prado (2018): Advances in Financial Machine Learning, Wiley (cap. 8, importancia de features)](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com
- [López de Prado (2020): Machine Learning for Asset Managers, Cambridge Elements (cap. 6, importancia por grupos)](https://www.cambridge.org/core/elements/machine-learning-for-asset-managers/6D9211305EA2E425D33A9F38D0AE3545) · cambridge.org

### Datos

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

Lección anterior: [5.2.4 · Normalización sin leakage y rank transversal](https://pythonparatrading.com/curso-python-trading/nivel-5/normalizacion-sin-leakage-y-rank-transversal/)

Lección siguiente: [5.3.1 · Etiquetado de horizonte fijo y sus problemas](https://pythonparatrading.com/curso-python-trading/nivel-5/etiquetado-de-horizonte-fijo/)
