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.
- Módulo
- 5.2 · Ingeniería de características
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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 |
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:
| 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.
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")(291600, 22)
desv. etiqueta 366 pb · desv. alfa 12.7 pbLa 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.
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}")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 0MDI: 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.
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) 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.13Con 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.
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)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.54Fuera 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, ) 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
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 %.
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) 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.1Con 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 , 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:
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")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 gruposLos 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.
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) 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.23Con 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:
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)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: float64Calculado 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 , más
fuerte cuando el volumen es bajo. Para un término , el valor de Shapley de es
: la mitad del término cruzado se atribuye a . La pendiente del SHAP de ret_1s en
cada tercio de volumen_rel, relativa al tercio medio, se puede comparar con esa respuesta exacta:
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) 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.45El 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
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 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
- 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 conret_1s?
Solución
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) 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.00Con 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.
- 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
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)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.13La 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.
- 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 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
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 asue(efecto plantado) un 4,6 %. ¿Por qué el MDI no los separa?- APorque
sueno 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_1es 0,08 %.- APorque
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.
Con la diana sin ruido,
ret_1m(sin efecto propio, correlación 0,48 conret_1s) tiene un MDA de −0,1 y un SFI de 183. ¿Qué indica la diferencia?- AQue el SFI está mal calculado
- BQue
ret_1mtiene un efecto no lineal - CUn efecto de sustitución: sola,
ret_1mlleva información prestada deret_1s; conret_1sen 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_1mlleva información prestada deret_1s; conret_1sen el modelo, permutarla no cuesta nada porque el modelo usa la originalEl 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_1myrsi_14y les asigna el efecto conjunto.Con la etiqueta real,
ruido_1tiene 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_1tiene 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).
Un LightGBM entrenado con la diana sin ruido da a
ret_1spendientes 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 pararet_1ses 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 pararet_1ses 1,27 y 0,72, que es lo que midePara 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=1y 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.- APorque SHAP reparte la interacción entre las dos features que intervienen: la mitad va a
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_1s4,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_analistassale cien veces más importante que el resto. Una importancia desproporcionada es motivo de auditoría, no de celebración.