Lección 5.2.5 · 70 min

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.

Fig. — Panel del nivel 5 con la etiqueta real: MDI (dentro de muestra) y MDA con purged k-fold (fuera de muestra, ±2 errores típicos) de 14 de las 23 features. Arriba, las de efecto plantado; abajo, las heredadas y el ruido
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

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:

MedidaPreguntaDatosDónde falla
MDI (mean decrease impurity)¿Cuánto reduce la impureza en los cortes del árbol?EntrenamientoRuido, cardinalidad, sustitución
MDA (mean decrease accuracy, permutación)¿Cuánto empeora la predicción fuera de muestra sin ella?PruebaSustitución, error grande
SFI (single feature importance)¿Cuánto predice sola?PruebaInteracciones, información prestada
SHAP¿Cuánto aporta a cada predicción concreta?CualquieraExplica el modelo, no los datos
FeaturesModeloImportanciaHipótesisBacktest final
Fuente 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:

GrupoFeaturesPapel en el generador
Efecto propioret_1s, mom_12_1, valor_bp, sue, vol_63d, sentimientoEntran en el alfa
Solo en interacciónvolumen_rel (con ret_1s), dias_desde_informe (con sue)Modulan otro efecto
Heredan un efectoruido_4 (0,6 × momentum + ruido); ey, roe, rsi_14, ret_1m, ret_3m, dist_max_52s, vol_21d, log_capitalizacionCorrelacionadas con las verdaderas, sin efecto propio
Ruidoruido_1, ruido_2, ruido_3, ruido_5Ninguno

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.
IN [01]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")
OUT [01]Panel en rangos normales y dos dianas
(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.

IN [02]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}")
OUT [02]Purged k-fold por bloques de semanas y el IC como puntuación
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.

IN [03]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)
OUT [03]MDI con la diana sin ruido y con la etiqueta real
                     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.

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.

IN [04]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)
OUT [04]MDA con purged k-fold
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≈2,4t \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=2t = 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 %.

IN [05]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)
OUT [05]SFI: cada feature sola
                    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 (1−ρ)/2\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:

IN [06]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")
OUT [06]Grupos de features por correlación
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.

IN [07]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)
OUT [07]MDA por grupos
                            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

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:

IN [08]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)
OUT [08]SHAP con la etiqueta real
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⋅g(volumen_rel))(1 - 0{,}5\cdot g(\text{volumen\_rel})), más fuerte cuando el volumen es bajo. Para un término c⋅x⋅(1−0,5z)c\cdot x\cdot(1 - 0{,}5z), el valor de Shapley de xx es c⋅x⋅(1−0,25z)c\cdot x\cdot(1 - 0{,}25z): la mitad del término cruzado se atribuye a zz. La pendiente del SHAP de ret_1s en cada tercio de volumen_rel, relativa al tercio medio, se puede comparar con esa respuesta exacta:

IN [09]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)
OUT [09]SHAP de la reversión según el volumen relativo
       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

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

nosíCandidatasGruposMDA con purga¿Sobre controles?DescartarSHAP: formaValidación final
Fuente 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=2t = 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
IN [10]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)
OUT [10]MDI con todas las features en cada corte
          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.

  1. 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
IN [11]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)
OUT [11]La fuga en la importancia
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.

  1. 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 (1−ρ)/2\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).

Respuesta plegada en cada pregunta

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?

    • APorque sue no tiene efecto
    • BPorque 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
    • CPorque las features están en rangos normales
    • DPorque faltan árboles
    Ver respuesta

    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?

    • ADe que la moneda tiene efecto negativo
    • BDe la semilla del generador
    • CDe que el bosque usa max_features=1
    • DDel 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
    Ver respuesta

    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?

    • AQue el SFI está mal calculado
    • BQue ret_1m tiene un efecto no lineal
    • CUn 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
    • DQue el MDA necesita más permutaciones
    Ver respuesta

    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?

    • AQue 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
    • BQue ruido_1 tiene un efecto pequeño y hay que conservarla
    • CQue el purged k-fold tiene una fuga
    • DQue el MDA siempre está sesgado al alza
    Ver respuesta

    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?

    • APorque 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
    • BPorque el modelo no aprendió la interacción
    • CPorque SHAP no funciona con árboles
    • DPorque la muestra es pequeña
    Ver respuesta

    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

Datos