Lección 5.10.1 · 75 min

SHAP, dependencia parcial y estabilidad de las importancias

Explicar un modelo es fácil de hacer y difícil de leer bien. En el panel del nivel se conoce cada efecto, la interacción de la reversión con el volumen y el cambio de signo del momentum en los rebotes, así que se puede comprobar qué recupera cada herramienta, qué reparte, qué no puede ver y cuánto cambia la explicación si se cambia la semilla o la muestra.

Fig. — Puesto mínimo y máximo de nueve features en el ranking de SHAP medio en ocho réplicas (cuatro ventanas de dos años y cuatro bootstrap de semanas) de un LightGBM entrenado con la diana sin ruido (arriba en cada fila) y con la etiqueta real (abajo)
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

Explicar el modelo, no el mundo

La lección 5.2.5 midió la importancia global de cada feature con cuatro métodos y llegó a una conclusión que aquí es el punto de partida: una explicación describe lo que hace el modelo, no cómo funciona el mercado. Si el modelo aprendió ruido, la explicación enseña ruido; si no aprendió algo, la explicación no lo puede enseñar. Esta lección va tres pasos más allá:

  1. Explicaciones locales y de interacción: por qué el modelo predijo esto para esta acción esta semana, y qué parte de cada efecto depende de otra feature.
  2. Forma de los efectos: la dependencia parcial y las curvas ICE, qué miden que SHAP no mide y al revés.
  3. Estabilidad: si la explicación cambiaría con otra semilla, otra muestra u otro periodo. Una explicación que no sobrevive a eso no es una explicación.
Pregunta¿Por qué esta fila?¿Qué forma tiene?¿Es estable?SHAP localPD e ICEBootstrap
Fuente Mermaid
flowchart TB
  Q[Pregunta] --> L["¿Por qué esta fila?"]
  Q --> F["¿Qué forma tiene?"]
  Q --> E["¿Es estable?"]
  L --> S[SHAP local]
  F --> P[PD e ICE]
  E --> B[Bootstrap]

El banco de pruebas es el panel del nivel 5: 400 acciones, 21 features semanales en rango normal (sin la fuga revision_analistas), dos dianas (la etiqueta real y el alfa verdadero, la parte predecible sin ruido) y el régimen de mercado de cada semana, que el generador publica en panel5_coef_verdad.csv. Los modelos se entrenan con 2012-2019 y se explican con filas de 2020-2025.

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")
cv = pd.read_csv("panel5_coef_verdad.csv", parse_dates=["fecha"])
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana"]],
             on=["fecha", "ticker"])
      .merge(v[["fecha", "ticker", "alfa_verdadero"]],
             on=["fecha", "ticker"])
      .merge(cv[["fecha", "regimen"]], on="fecha")
      .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)
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
reg = d["regimen"].to_numpy()
tr = (d["fecha"] < "2020-01-01").to_numpy()   # 2012-2019
te = ~tr                                       # 2020-2025
print(X.shape, f"entrenamiento {tr.sum()} · prueba {te.sum()}")
periodo = np.where(tr, "2012-2019", "2020-2025")
print(pd.crosstab(periodo, reg, rownames=["semanas"],
                  colnames=[None]) // 400)
OUT [01]Panel en rangos normales, dos dianas y el régimen
(291600, 21) entrenamiento 166400 · prueba 125200
           calma  estres  rebote
semanas
2012-2019    353      37      26
2020-2025    222      52      39

Hay 416 semanas de entrenamiento y 313 de prueba; los rebotes (tras cada episodio de estrés, cuando el momentum verdadero se invierte, lección 5.1.1) son 26 en el entrenamiento y 39 en la prueba. El modelo es un LightGBM moderado (300 árboles de 15 hojas, al menos 500 filas por hoja, submuestras de filas y columnas) como el de la lección 5.5.3.

IN [02]python
import lightgbm as lgb

def lgbm(cols, obj, filas=tr, semilla=0, n=300, lr=0.05, w=None):
    return lgb.LGBMRegressor(
        n_estimators=n, learning_rate=lr, num_leaves=15,
        min_child_samples=500, subsample=0.5, subsample_freq=1,
        colsample_bytree=0.8, random_state=semilla, n_jobs=4,
        deterministic=True, force_row_wise=True, verbose=-1,
    ).fit(X.loc[filas, cols].to_numpy(), obj[filas],
          sample_weight=w)

def ic_semanal(pred, obj, filas):
    """IC de rangos medio de las semanas de `filas`."""
    r = pd.DataFrame({"p": pred, "y": obj[filas],
                      "s": d["fecha"].to_numpy()[filas]})
    c = r.groupby("s")[["p", "y"]].corr("spearman")
    return c.xs("p", level=1)["y"].mean()

m_a = lgbm(FEATS, a)          # aprende la parte predecible
m_y = lgbm(FEATS, y)          # lo que se puede hacer en la práctica
Xte = X.loc[te, FEATS].to_numpy()
for nombre, m in [("diana sin ruido", m_a), ("etiqueta", m_y)]:
    p = m.predict(Xte)
    print(f"{nombre:16s} IC con el alfa {ic_semanal(p, a, te):.3f}"
          f" · con la etiqueta {ic_semanal(p, y, te):.4f}")
print(f"oráculo          IC con la etiqueta "
      f"{ic_semanal(a[te], y, te):.4f}")
OUT [02]Dos LightGBM: con la diana sin ruido y con la etiqueta
diana sin ruido  IC con el alfa 0.783 · con la etiqueta 0.0183
etiqueta         IC con el alfa 0.294 · con la etiqueta 0.0077
oráculo          IC con la etiqueta 0.0262

Con la diana sin ruido, el modelo alcanza un IC de 0,783 con el alfa de 2020-2025: ha aprendido buena parte de la estructura plantada. Con la etiqueta real se queda en 0,0077, frente al techo del oráculo (0,0262). Y el modelo de la diana sin ruido, evaluado contra la etiqueta, da 0,0183: lo que un modelo podría sacar si la señal no estuviera enterrada. Para ver qué recupera cada herramienta se usa sobre todo el primero; para ver cuánto se puede fiar uno de una explicación con datos reales, los dos.

SHAP local: por qué esta predicción

Los valores SHAP reparten la diferencia entre la predicción de una fila y el valor base (la predicción media en los datos del modelo) entre las features. La propiedad que lo hace útil es la aditividad: base más suma de valores igual a predicción, sin resto. shap.TreeExplainer la cumple de forma exacta en modelos de árboles (Lundberg et al., 2020).

IN [03]python
import shap

rng = np.random.default_rng(1)
muestra = rng.choice(np.flatnonzero(te), 1000, replace=False)
Xm = X.loc[muestra, FEATS].to_numpy()
ex_a = shap.TreeExplainer(m_a)
sv = ex_a.shap_values(Xm)
suma = ex_a.expected_value + sv.sum(axis=1)
print("máx. |base + suma SHAP − predicción|:",
      f"{np.abs(suma - m_a.predict(Xm)).max():.1e}")
i = 0                                      # una acción-semana
fila = pd.DataFrame({"valor (rango normal)": Xm[i],
                     "shap (pb)": sv[i]}, index=FEATS)
print(d.loc[muestra[i], ["fecha", "ticker"]].to_dict(),
      f"base {ex_a.expected_value:.2f} pb · predicción "
      f"{m_a.predict(Xm[[i]])[0]:.2f} pb · alfa verdadero "
      f"{a[muestra[i]]:.2f} pb")
fila.reindex(fila["shap (pb)"].abs().sort_values(
    ascending=False).index).head(6).round(2)
OUT [03]SHAP: aditividad y la explicación de una fila
máx. |base + suma SHAP − predicción|: 9.6e-14
{'fecha': Timestamp('2020-05-29 00:00:00'), 'ticker': 'MACE'} base 0.01 pb · predicción 15.65 pb · alfa verdadero 11.34 pb
              valor (rango normal)  shap (pb)
valor_bp                      2.08       7.11
vol_63d                      -0.78       2.82
ey                            1.68       2.14
mom_12_1                      0.32       1.25
ret_1s                       -0.12       1.16
dist_max_52s                  1.68      -0.90

La diferencia máxima entre base más suma y predicción es de 10−1310^{-13}. Para la acción MACE el 2020-05-29, el modelo predice 15,65 pb de rendimiento extra para la semana siguiente (el alfa verdadero era 11,34). La mayor parte viene de valor_bp (7,11 pb, con un rango normal de 2,08: una acción muy barata por patrimonio), seguida de una volatilidad baja (vol_63d en −0,78 aporta 2,82) y de ey (2,14), que no tiene efecto propio y carga con parte del value por su correlación con valor_bp. Es la explicación que pide un gestor («¿por qué compramos esto?») y es exactamente la del modelo, incluidos sus préstamos entre features correlacionadas.

Valores de interacción

El valor SHAP de una feature mezcla su efecto propio con su parte de las interacciones. Los valores de interacción SHAP (Lundberg et al., 2020) lo separan en una matriz por fila: la diagonal Φjj\Phi_{jj} es el efecto principal de la feature jj y cada casilla fuera de la diagonal Φjk\Phi_{jk} es la mitad de su interacción con kk, de modo que ϕj=Φjj+∑k≠jΦjk\phi_j = \Phi_{jj} + \sum_{k \ne j} \Phi_{jk}.

El generador planta la reversión como crev⋅g(r)⋅(1−0,5 g(v))c_{rev}\cdot g(r)\cdot(1 - 0{,}5\, g(v)), con rr = ret_1s y vv = volumen_rel. El efecto principal de ret_1s tiene pendiente crevc_{rev} y el término cruzado, −0,5 crev-0{,}5\,c_{rev} por unidad de g(r) g(v)g(r)\,g(v), que se reparte a partes iguales entre Φrv\Phi_{rv} y Φvr\Phi_{vr}: −0,25 crev-0{,}25\,c_{rev} en cada una.

IN [04]python
si = ex_a.shap_interaction_values(Xm[:600])
inter = np.abs(si).mean(axis=0)
np.fill_diagonal(inter, 0)
pares = pd.Series(
    {f"{FEATS[j]} × {FEATS[k]}": 2 * inter[j, k]
     for j in range(len(FEATS)) for k in range(j + 1, len(FEATS))})
print(pares.sort_values(ascending=False).head(4).round(2)
      .to_string())

j, k = FEATS.index("ret_1s"), FEATS.index("volumen_rel")
r, z = Xm[:600, j], Xm[:600, k]
c_rev = cv.loc[cv["fecha"] < "2020-01-01", "c_rev"].mean() * 1e4
print(f"c_rev verdadero medio 2012-2019: {c_rev:.2f} pb")
print(f"efecto principal de ret_1s: pendiente "
      f"{np.polyfit(r, si[:, j, j], 1)[0]:.2f} (verdad {c_rev:.2f})")
print(f"interacción con volumen_rel (cada mitad): "
      f"{np.polyfit(r * z, si[:, j, k], 1)[0]:.2f} "
      f"(verdad {-0.25 * c_rev:.2f})")
OUT [04]Valores de interacción SHAP frente a la verdad
ret_1s × volumen_rel        1.47
ret_1s × vol_63d            0.39
mom_12_1 × valor_bp         0.35
sue × dias_desde_informe    0.32
c_rev verdadero medio 2012-2019: -5.46 pb
efecto principal de ret_1s: pendiente -4.74 (verdad -5.46)
interacción con volumen_rel (cada mitad): 0.93 (verdad 1.37)

El par con más interacción, con diferencia, es ret_1s × volumen_rel (1,47 pb de media en valor absoluto, contando las dos casillas). El cuarto, sue × dias_desde_informe, también está plantado (el SUE decae con los días desde el informe). Los otros dos no: ret_1s × vol_63d y mom_12_1 × valor_bp son interacciones que el modelo encontró en el entrenamiento, quizá porque la reversión y el momentum cambian con el régimen y la volatilidad o el value se mueven con él, quizá ruido. Una interacción grande en el modelo es una hipótesis, no un descubrimiento.

Los tamaños se pueden comparar con la verdad. El efecto principal de ret_1s tiene pendiente −4,74 frente al c_rev medio de 2012-2019 (−5,46) y cada mitad de la interacción, 0,93 frente a 1,37. El modelo aprendió la reversión con un 87 % de su tamaño y la interacción con un 68 %: la regularización (hojas de al menos 500 filas, 300 árboles con tasa de aprendizaje 0,05) encoge más lo que es más difícil de ver.

Dependencia parcial e ICE

La dependencia parcial (Friedman, 2001) responde a otra pregunta: cómo cambia la predicción media al mover una feature con las demás como están. Para la feature jj en el valor xx:

PDj(x)=1n∑i=1nf^(x, xi,−j)\text{PD}_j(x) = \frac{1}{n}\sum_{i=1}^{n} \hat f\left(x,\, \mathbf{x}_{i,-j}\right)

Cada sumando es una curva ICE (individual conditional expectation, Goldstein et al., 2015): la predicción de la fila ii cuando solo se mueve su feature jj. La dependencia parcial es la media de las curvas ICE; si las curvas son paralelas, el efecto no depende de las demás features, y si se abren en abanico, hay interacción. sklearn.inspection.partial_dependence las calcula con cualquier modelo con predict (aquí, por fuerza bruta: 21 puntos de la rejilla × 1.000 filas).

IN [05]python
from sklearn.inspection import partial_dependence

pdep = partial_dependence(m_a, Xm, [j], kind="both",
                          grid_resolution=21,
                          percentiles=(0.02, 0.98))
rejilla = pdep["grid_values"][0]
print("PD de ret_1s (pb), de −2 a +2:",
      np.round(pdep["average"][0][::5], 1))
pend = np.array([np.polyfit(rejilla, c, 1)[0]
                 for c in pdep["individual"][0]])    # una por ICE
vol = Xm[:, k]
tercio = pd.cut(vol, [-np.inf, -0.43, 0.43, np.inf],
                labels=["bajo", "medio", "alto"])
ice = pd.Series(pend).groupby(tercio, observed=True).mean()
media_v = pd.Series(vol).groupby(tercio, observed=True).mean()
verdad = 1 - 0.5 * media_v
print(f"corr(pendiente ICE, volumen_rel) = "
      f"{np.corrcoef(pend, vol)[0, 1]:.2f}")
pd.DataFrame({"pendiente ICE (pb)": ice,
              "ICE relativa": ice / ice["medio"],
              "verdad relativa": verdad / verdad["medio"]}).round(2)
OUT [05]Dependencia parcial e ICE de la reversión
PD de ret_1s (pb), de −2 a +2: [ 10.    5.1  -0.4  -6.1 -10.7]
corr(pendiente ICE, volumen_rel) = 0.96
       pendiente ICE (pb)  ICE relativa  verdad relativa
bajo                -7.58          1.48             1.52
medio               -5.12          1.00             1.00
alto                -2.46          0.48             0.43
Fig. — Curvas ICE centradas de ret_1s del LightGBM entrenado con la diana sin ruido (20 acciones-semana de 2020-2025, en gris) y su media en cada tercio de volumen_rel, con la pendiente en pb por unidad y la del generador

La dependencia parcial de ret_1s baja de +10 pb a −10,7 pb entre los percentiles 2 y 98: la reversión media. Las curvas ICE no son paralelas: la correlación entre la pendiente de cada curva y el volumen_rel de su fila es 0,96. Agrupadas por tercios de volumen, las pendientes son −7,58, −5,12 y −2,46 pb por unidad; relativas al tercio medio, 1,48 y 0,48, frente a 1,52 y 0,43 del generador. La figura las pone en pb junto a la verdad con el c_rev medio del entrenamiento.

Es la diferencia con la lección 5.2.5, donde las pendientes SHAP del mismo efecto por tercios daban 1,25 y 0,72 frente al valor exacto de Shapley, 1,27 y 0,72. SHAP atribuye: reparte la interacción entre las dos features. El ICE agrupado condiciona: dice cómo responde la predicción a ret_1s cuando el volumen está bajo, con la interacción entera. Ninguno está mal; responden a preguntas distintas.

El momentum según el régimen: lo que el modelo no ve

El momentum del panel cambia con el régimen de mercado: 5,0 pb por unidad en calma, 1,7 en estrés y −7,1 en rebote (medias de 2012-2019). Un modelo solo puede reproducir eso si ve el régimen. Tres versiones con la diana sin ruido: sin estado (las 21 features), con un estado observable (la media transversal de ret_1m, ret_3m y dist_max_52s: cómo le ha ido al mercado el último mes, el último trimestre y cuánto cae desde máximos) y con el régimen verdadero como feature, algo que en la realidad nadie tiene. En cada régimen de 2020-2025 se mide la pendiente del momentum con SHAP y con la dependencia parcial calculada sobre filas de ese régimen.

IN [06]python
for c in ["ret_1m", "ret_3m", "dist_max_52s"]:   # estado observable
    X[f"mercado_{c}"] = sem[c].transform("mean")
X["rebote"] = (reg == "rebote").astype(float)    # estado verdadero
X["estres"] = (reg == "estres").astype(float)
modelos = {
    "sin estado": FEATS,
    "estado observable": FEATS + ["mercado_ret_1m", "mercado_ret_3m",
                                  "mercado_dist_max_52s"],
    "régimen verdadero": FEATS + ["rebote", "estres"],
}
rng = np.random.default_rng(2)
jm = FEATS.index("mom_12_1")
filas_t = {}
for nombre, cols in modelos.items():
    m = m_a if nombre == "sin estado" else lgbm(cols, a)
    for rg in ["calma", "estres", "rebote"]:
        s = rng.choice(np.flatnonzero(te & (reg == rg)), 800,
                       replace=False)
        Xs = X.loc[s, cols].to_numpy()
        sv_s = shap.TreeExplainer(m).shap_values(Xs)
        pdr = partial_dependence(m, Xs, [jm], grid_resolution=11,
                                 percentiles=(0.05, 0.95))
        filas_t[(nombre, rg)] = {
            "shap": np.polyfit(Xs[:, jm], sv_s[:, jm], 1)[0],
            "pd": np.polyfit(pdr["grid_values"][0],
                             pdr["average"][0], 1)[0]}
t = pd.DataFrame(filas_t).T.unstack().loc[list(modelos)]
verdad = cv.groupby([cv["fecha"] >= "2020-01-01", "regimen"]
                    )["c_mom"].mean().unstack() * 1e4
t.loc["verdad 2012-2019"] = list(verdad.loc[False]) * 2
t.loc["verdad 2020-2025"] = list(verdad.loc[True]) * 2
t.round(2)
OUT [06]El momentum según el régimen: tres modelos
                   shap                  pd
                  calma estres rebote calma estres rebote
sin estado         3.64   3.67   3.61  3.84   3.87   3.88
estado observable  3.84   1.96   2.32  4.27   0.40   1.26
régimen verdadero  4.00   2.83  -0.94  4.58   2.00  -6.47
verdad 2012-2019   5.01   1.74  -7.07  5.01   1.74  -7.07
verdad 2020-2025   4.23   2.22 -12.68  4.23   2.22 -12.68
Fig. — Pendiente del momentum en las semanas de rebote de 2020-2025 según la dependencia parcial (PD) y según SHAP, para tres LightGBM entrenados con la diana sin ruido: con el régimen verdadero como feature, con un estado de mercado observable y sin estado

Sin estado, las dos herramientas dan la misma pendiente en los tres regímenes (3,6-3,9 pb): el modelo aprendió un momentum medio y lo aplica siempre. La explicación es fiel al modelo y el modelo se equivoca en los rebotes, donde la verdad es −12,68 en la prueba. Con el régimen verdadero, la dependencia parcial en filas de rebote da −6,47, muy cerca de los −7,07 del entrenamiento (los rebotes de 2020-2025 son más violentos que los que vio el modelo). SHAP, en cambio, da −0,94: la interacción momentum × régimen se reparte, y la mitad va a la feature rebote. Leer el SHAP del momentum como «el momentum casi no importa en los rebotes» sería un error.

El estado observable no basta (+1,26 en rebote con la dependencia parcial). La rentabilidad del mercado en el último mes y en el último trimestre, o la caída desde máximos, no separan un rebote de un estrés a tiempo, y con 26 semanas de rebote en el entrenamiento el modelo tiene muy pocos ejemplos. Es la misma conclusión de la lección 5.10.2 vista desde la explicación: lo que el modelo no ve, ni lo aprende ni lo explica, y una explicación limpia de un modelo sin el estado correcto da una imagen tranquilizadora y falsa.

Estabilidad de las importancias

Una importancia es una estimación y tiene error. Tres fuentes de variación, de menos a más relevante:

  • Semilla: la misma muestra con otro azar en el entrenamiento (submuestras de filas y columnas).
  • Bootstrap de semanas: otra muestra del mismo proceso, remuestreando semanas enteras con reemplazo (las filas de una semana comparten el mercado de esa semana; remuestrear filas sueltas subestimaría la variación).
  • Ventana temporal: dos años de datos distintos. Además del muestreo, los efectos verdaderos cambian (el generador los hace variar con un AR(1) semanal y con el régimen).

Para cada réplica se entrena un LightGBM algo más ligero (150 árboles, tasa 0,1) y se mide el SHAP medio en valor absoluto en las mismas 1.000 filas de prueba. La estabilidad es la correlación de rangos media entre réplicas.

IN [07]python
from scipy.stats import spearmanr

def importancia(m):
    return np.abs(shap.TreeExplainer(m).shap_values(Xm)).mean(0)

fechas = d["fecha"].to_numpy()
semanas = np.unique(fechas[tr])
ventanas = [("2012", "2013"), ("2014", "2015"),
            ("2016", "2017"), ("2018", "2019")]
imp = {}
for diana, obj in [("alfa", a), ("etiqueta", y)]:
    for s in range(4):                 # misma muestra, otra semilla
        imp[(diana, "semilla", s)] = importancia(
            lgbm(FEATS, obj, semilla=s, n=150, lr=0.1))
    for i0, i1 in ventanas:            # dos años de datos cada una
        filas = (d["fecha"] >= f"{i0}-01-01") & (
            d["fecha"] <= f"{i1}-12-31")
        imp[(diana, "ventana", i0)] = importancia(
            lgbm(FEATS, obj, filas.to_numpy(), n=150, lr=0.1))
    for b in range(4):                 # semanas con reemplazo
        veces = pd.Series(np.random.default_rng(b).choice(
            semanas, len(semanas))).value_counts()
        w = pd.Series(fechas[tr]).map(veces).fillna(0).to_numpy()
        imp[(diana, "bootstrap", b)] = importancia(
            lgbm(FEATS, obj, n=150, lr=0.1, w=w))
imp = pd.DataFrame(imp, index=FEATS)

def rho_medio(cols):
    c = spearmanr(imp[cols]).statistic
    return c[np.triu_indices(len(cols), 1)].mean()

pd.DataFrame({diana: {p: rho_medio([c for c in imp
                                     if c[:2] == (diana, p)])
                      for p in ["semilla", "ventana", "bootstrap"]}
              for diana in ["alfa", "etiqueta"]}).round(2)
OUT [07]Estabilidad: semillas, ventanas y bootstrap de semanas
           alfa  etiqueta
semilla    0.99      0.81
ventana    0.90      0.31
bootstrap  0.98      0.51

Con la diana sin ruido, la explicación es estable con todo: 0,99 entre semillas, 0,98 entre bootstrap y 0,90 entre ventanas, la más baja porque los efectos verdaderos cambian de un periodo a otro. Con la etiqueta real se desmorona: 0,81 entre semillas, 0,51 entre bootstrap y 0,31 entre ventanas. La estabilidad entre semillas es engañosa, porque el modelo lee el mismo ruido de la misma muestra de forma parecida; la incertidumbre que importa es la de los datos.

IN [08]python
rangos = imp.rank(ascending=False)
sel = ["ret_1s", "mom_12_1", "vol_63d", "valor_bp", "sue",
       "sentimiento", "ret_1m", "ruido_1", "ruido_4"]
replicas = [c for c in imp if c[1] != "semilla"]
res = {}
for diana in ["alfa", "etiqueta"]:
    rr = rangos[[c for c in replicas if c[0] == diana]]
    res[(diana, "mín")] = rr.min(axis=1)
    res[(diana, "máx")] = rr.max(axis=1)
pd.DataFrame(res).loc[sel].astype(int)
OUT [08]El ranking de cada feature en las ocho réplicas
            alfa     etiqueta
             mín máx      mín máx
ret_1s         1   1        1   5
mom_12_1       2   4        1   8
vol_63d        2   5        1   9
valor_bp       3   6        2  18
sue            3   5        2  18
sentimiento    4   6       14  21
ret_1m        11  20        1  17
ruido_1       18  21        8  20
ruido_4       15  21        6  19

La figura de la cabecera pone los rangos de puestos en las ocho réplicas de datos (ventanas y bootstrap). Con la diana sin ruido, ret_1s es siempre la primera y ruido_1 está siempre entre las cuatro últimas. Con la etiqueta real, valor_bp y sue, con efecto plantado, van del 2.º al 18.º puesto; ret_1m, sin efecto propio, del 1.º al 17.º; ruido_4 llega al 6.º y ruido_1 al 8.º. Solo ret_1s se mantiene arriba (del 1.º al 5.º), y sentimiento, con efecto pero con un 32 % de valores vacíos rellenados con cero, no pasa del 14.º. Un informe que dijera «la tercera feature más importante es X» con estos datos estaría informando de una realización del ruido.

Cuando las explicaciones engañan: features correlacionadas

La primera fuente de inestabilidad es el reparto dentro de grupos de features correlacionadas: el modelo puede usar cualquiera de ellas y el reparto cambia de una réplica a otra aunque el total no cambie. Se comprueba con los grupos por correlación de la lección 5.2.5.

IN [09]python
grupos = {"reversión": ["ret_1s", "ret_1m", "rsi_14"],
          "medio plazo": ["ret_3m", "dist_max_52s"],
          "momentum": ["mom_12_1", "ruido_4"],
          "volatilidad": ["vol_21d", "vol_63d"],
          "value-calidad": ["valor_bp", "ey", "roe"]}
etiqueta = {c: g for g, cs in grupos.items() for c in cs}
imp_g = imp.groupby([etiqueta.get(c, c) for c in FEATS]).sum()
boot = [c for c in imp if c[:2] == ("etiqueta", "bootstrap")]
cg = spearmanr(imp_g[boot]).statistic
print(f"etiqueta, bootstrap: rho medio por features "
      f"{rho_medio(boot):.2f} · por grupos "
      f"{cg[np.triu_indices(4, 1)].mean():.2f}")
cuota = imp.loc[grupos["reversión"], boot]
(cuota / cuota.sum()).round(2)
OUT [09]Grupos de features correlacionadas: más estables
etiqueta, bootstrap: rho medio por features 0.51 · por grupos 0.83
        etiqueta
       bootstrap
               0     1     2     3
ret_1s      0.44  0.45  0.51  0.39
ret_1m      0.27  0.27  0.27  0.39
rsi_14      0.29  0.27  0.22  0.22

Sumando el SHAP de cada grupo, la correlación de rangos entre bootstrap pasa de 0,51 a 0,83. Dentro del grupo de la reversión, la cuota de ret_1s va del 39 % al 51 % según la réplica y ret_1m llega al 39 % en una de ellas: el grupo es estable, el reparto no.

La forma más extrema es la sustitución: si se quita una feature, su sustituta ocupa el sitio.

IN [10]python
sin_mom = [c for c in FEATS if c != "mom_12_1"]
m_sin = lgbm(sin_mom, a)
def shap_medio(m, cols):
    s = shap.TreeExplainer(m).shap_values(X.loc[muestra, cols]
                                          .to_numpy())
    return pd.Series(np.abs(s).mean(0), index=cols)

comp = pd.DataFrame({"con mom_12_1": shap_medio(m_a, FEATS),
                     "sin mom_12_1": shap_medio(m_sin, sin_mom)})
p_sin = m_sin.predict(X.loc[te, sin_mom].to_numpy())
print("IC con el alfa en 2020-2025:",
      f"con {ic_semanal(m_a.predict(Xte), a, te):.3f} · sin "
      f"{ic_semanal(p_sin, a, te):.3f}")
print("corr(ruido_4, mom_12_1) =",
      round(X["ruido_4"].corr(X["mom_12_1"]), 2),
      "· corr(dist_max_52s, mom_12_1) =",
      round(X["dist_max_52s"].corr(X["mom_12_1"]), 2))
comp.loc[["mom_12_1", "dist_max_52s", "ruido_4", "ret_3m",
          "ret_1m"]].round(2)
OUT [10]Quitar el momentum: una feature sin efecto ocupa su sitio
IC con el alfa en 2020-2025: con 0.783 · sin 0.777
corr(ruido_4, mom_12_1) = 0.6 · corr(dist_max_52s, mom_12_1) = 0.64
              con mom_12_1  sin mom_12_1
mom_12_1              3.08           NaN
dist_max_52s          0.32          1.74
ruido_4               0.03          0.94
ret_3m                0.09          0.17
ret_1m                0.25          0.34

Sin mom_12_1, el SHAP medio de ruido_4 (0,6 × momentum más ruido, sin efecto propio) pasa de 0,03 a 0,94 pb y el de dist_max_52s (correlación 0,64), de 0,32 a 1,74. El IC del modelo apenas cambia (0,783 frente a 0,777): la información del momentum sigue dentro, repartida entre sus vecinas. Para quien mire solo la explicación, el segundo modelo «demuestra» que la distancia al máximo de 52 semanas es una de las features más importantes del mercado.

Las mismas trampas, resumidas:

SituaciónQué muestra la explicaciónQué hacer
Features correlacionadasreparto arbitrario entre ellas, cambia con la réplicaimportancia por grupos
Feature quitada o añadidasu sustituta hereda la importancialeer cada importancia con su lista de sustitutas
InteracciónSHAP la reparte entre las dos featuresvalores de interacción, ICE condicionado
Estado no observadoun efecto medio fiel al modelo y falso en el régimenañadir el estado o no fiarse del efecto en ese régimen
Dependencia parcial con correlacionescombinaciones imposibles, extrapolaciónALE, o PD solo en el rango conjunto de los datos
Etiqueta ruidosarankings que cambian con la muestraréplicas, rangos de puestos, controles de ruido

Cómo presentar una explicación

nosíModelo validadoRéplicasGrupos¿Estable?No interpretarForma: PD, ICEHipótesis
Fuente Mermaid
flowchart TB
  M[Modelo validado] --> R[Réplicas]
  R --> G[Grupos]
  G --> E{¿Estable?}
  E -- no --> N[No interpretar]
  E -- sí --> F[Forma: PD, ICE]
  F --> H[Hipótesis]
  • Primero el modelo, después la explicación. Explicar un modelo que no supera la validación honesta (lecciones 5.4.2 y 5.4.4) es explicar ruido.
  • Réplicas siempre. Bootstrap de bloques de tiempo y ventanas; se informa del rango de cada importancia o de su puesto, no de un número.
  • Grupos antes que features sueltas, con controles de ruido dentro del modelo (lección 5.2.5).
  • SHAP para atribuir, dependencia parcial e ICE para la forma, y valores de interacción cuando la pregunta es si dos features se refuerzan.
  • Una explicación es una hipótesis. Si sugiere un mecanismo (la reversión es más fuerte con poco volumen), el mecanismo se contrasta aparte, con datos que no se usaron para encontrarlo.

Ejercicios

  1. TreeExplainer calcula por defecto los valores SHAP siguiendo los caminos del árbol (tree_path_dependent), que respetan en parte las correlaciones de los datos de entrenamiento. Con feature_perturbation="interventional" y un fondo de 100 filas, rompe esas correlaciones. Compara el SHAP medio de las dos versiones en 500 filas. ¿Dónde difieren?
Solución
IN [11]python
fondo = X.loc[np.random.default_rng(3).choice(
    np.flatnonzero(tr), 100, replace=False), FEATS].to_numpy()
sv_int = shap.TreeExplainer(
    m_a, data=fondo, feature_perturbation="interventional"
).shap_values(Xm[:500])
sv_cam = ex_a.shap_values(Xm[:500])
dif = pd.DataFrame({"intervencional": np.abs(sv_int).mean(0),
                    "caminos": np.abs(sv_cam).mean(0)}, index=FEATS)
dif["diferencia"] = dif["intervencional"] - dif["caminos"]
dif.sort_values("caminos", ascending=False).head(11).round(2)
OUT [11]SHAP intervencional frente al de caminos del árbol
              intervencional  caminos  diferencia
ret_1s                  4.63     4.37        0.26
mom_12_1                3.22     3.06        0.16
vol_63d                 2.56     2.52        0.04
sue                     2.42     2.45       -0.04
valor_bp                2.35     2.29        0.06
sentimiento             1.84     1.82        0.02
ey                      0.88     0.85        0.03
volumen_rel             0.82     0.77        0.05
roe                     0.49     0.43        0.05
dist_max_52s            0.46     0.33        0.13
ret_1m                  0.33     0.25        0.08

El orden apenas cambia y las diferencias son pequeñas en las features con efecto (ret_1s 4,63 frente a 4,37, mom_12_1 3,22 frente a 3,06). Las relativas más grandes están en features sin efecto propio y correlacionadas con otras: dist_max_52s sube de 0,33 a 0,46 (un 39 %) y ret_1m de 0,25 a 0,33. La versión intervencional sustituye las features «ausentes» por valores de filas del fondo elegidas al azar, rompiendo su correlación con las presentes: evalúa al modelo en combinaciones poco realistas y atribuye algo más a las vecinas correlacionadas. Responde a «qué hace el modelo si cambio esta feature y nada más»; la de caminos se acerca más a «qué hace con los datos tal como vienen». Con features casi independientes las dos coinciden; con grupos muy correlacionados conviene mirar las dos o, mejor, agrupar.

  1. Explica la misma acción-semana (la fila MACE del 2020-05-29) con cuatro LightGBM de semillas distintas, entrenados con la diana sin ruido y con la etiqueta real. ¿Coinciden las tres features con más SHAP?
Solución
IN [12]python
fila = Xm[[0]]
for diana, obj in [("alfa", a), ("etiqueta", y)]:
    print(diana)
    for s in range(4):
        m = lgbm(FEATS, obj, semilla=s, n=150, lr=0.1)
        sv1 = pd.Series(shap.TreeExplainer(m).shap_values(fila)[0],
                        index=FEATS)
        top = sv1.abs().sort_values(ascending=False).index[:3]
        print(f"  semilla {s}: pred {m.predict(fila)[0]:6.2f} pb · "
              + ", ".join(f"{c} {sv1[c]:+.1f}" for c in top))
OUT [12]La explicación local de una fila con cuatro semillas
alfa
  semilla 0: pred  15.54 pb · valor_bp +7.1, vol_63d +2.6, ey +2.3
  semilla 1: pred  15.22 pb · valor_bp +7.3, vol_63d +2.9, ey +2.2
  semilla 2: pred  14.32 pb · valor_bp +7.1, vol_63d +2.6, ey +2.1
  semilla 3: pred  15.04 pb · valor_bp +7.1, vol_63d +2.8, ey +2.0
etiqueta
  semilla 0: pred  24.23 pb · valor_bp +15.5, ret_1s +6.1, volumen_rel +4.5
  semilla 1: pred   2.87 pb · valor_bp +9.1, dist_max_52s -5.1, dias_desde_informe +2.3
  semilla 2: pred  -0.15 pb · valor_bp +9.4, rsi_14 -6.8, ruido_5 +5.5
  semilla 3: pred  -0.08 pb · dias_desde_informe +6.5, vol_63d -4.8, volumen_rel -4.3

Con la diana sin ruido, las cuatro semillas cuentan la misma historia: predicción entre 14,3 y 15,5 pb y las mismas tres razones (valor_bp +7,1 a +7,3, vol_63d, ey). Con la etiqueta real, la misma fila recibe predicciones de 24,2, 2,9, −0,2 y −0,1 pb, y las razones cambian con la semilla: valor_bp aparece tres veces, pero junto a ret_1s, dist_max_52s, rsi_14, dias_desde_informe y hasta ruido_5, un ruido puro, con +5,5 pb. Cada explicación es exacta para su modelo; el problema es que los modelos entrenados con una etiqueta tan ruidosa difieren mucho entre sí en cada fila aunque su IC medio sea parecido. Una explicación local de un modelo con poca señal hay que promediarla en varios modelos (o presentar su rango) antes de contársela a nadie.

  1. Un comité de inversión pide «las tres razones por las que el modelo compra» cada semana. Con lo visto en la lección, ¿qué le entregarías y con qué advertencias?
Solución

Por fila, los valores SHAP del modelo agregados por grupos de features correlacionadas (value-calidad, reversión, momentum, volatilidad...), no por features sueltas, porque el reparto dentro de un grupo es arbitrario y cambia con la semilla y la muestra. Las tres razones serían los tres grupos con más SHAP de la acción, con su signo y su tamaño en puntos básicos, y la predicción total, que es su suma más la base. Las advertencias: la explicación describe el modelo, no el mercado; con una relación señal/ruido como la de las acciones, el modelo explica sobre todo cómo reparte un efecto pequeño, y las explicaciones de una fila con la etiqueta real cambian mucho de una semilla a otra (ejercicio 2), así que conviene promediar varios modelos o mostrar el rango. Si un grupo domina de forma desproporcionada, hay que auditarlo antes de presentarlo (puede ser una fuga, lección 5.2.4). Y periódicamente, la estabilidad global: importancias por grupos con bootstrap de semanas y por ventanas, y la forma de los efectos principales con dependencia parcial e ICE, para detectar si el modelo ha cambiado de criterio (lección 5.10.2).

Respuesta plegada en cada pregunta

Autoevaluación

  1. Para una fila, el valor base del explicador es 0,01 pb, la suma de sus valores SHAP es 15,64 pb y la predicción del modelo 15,65 pb. ¿Qué propiedad de SHAP se ve?

    • AQue el modelo está sobreajustado
    • BLa aditividad (eficiencia): el valor base más la suma de los SHAP de la fila es exactamente la predicción
    • CQue SHAP mide la importancia causal de cada feature
    • DQue la fila es un dato atípico
    Ver respuesta

    Respuesta B. La aditividad (eficiencia): el valor base más la suma de los SHAP de la fila es exactamente la predicción

    Los valores de Shapley reparten la diferencia entre la predicción y el valor base sin dejar nada fuera. En la celda la diferencia máxima entre las dos cosas es de 1e-13: error de redondeo. La aditividad dice cómo se reparte la predicción, no si las features causan nada.

  2. La interacción plantada entre ret_1s y volumen_rel vale −0,5·c_rev por unidad del producto (2,73 pb con el c_rev medio de 2012-2019). ¿Qué pendiente se espera en cada una de las dos casillas de interacción SHAP del par, (ret_1s, volumen_rel) y (volumen_rel, ret_1s)?

    • A2,73 en cada uno
    • BCero, porque SHAP no mide interacciones
    • C5,46, el doble
    • DLa mitad, 1,37: la matriz de interacciones es simétrica y reparte el término cruzado a partes iguales entre las dos casillas
    Ver respuesta

    Respuesta D. La mitad, 1,37: la matriz de interacciones es simétrica y reparte el término cruzado a partes iguales entre las dos casillas

    Los valores de interacción de Lundberg et al. (2020) ponen la mitad del efecto conjunto en cada casilla del par. El LightGBM da 0,93 frente a 1,37: aprendió la interacción con unos dos tercios de su tamaño, como cabe esperar de un modelo regularizado.

  3. Con el régimen verdadero como feature, el momentum en rebote tiene una pendiente de dependencia parcial de −6,47 y una pendiente SHAP de −0,94 (verdad −7,07). ¿Cuál es la lectura correcta?

    • ALas dos son correctas y responden a preguntas distintas: la dependencia parcial en filas de rebote mide el efecto del momentum dado el régimen; SHAP reparte la interacción momentum × régimen entre las dos features, y la mitad va a la de régimen
    • BSHAP está mal calculado
    • CLa dependencia parcial está sesgada por la correlación
    • DEl modelo no aprendió el cambio de signo
    Ver respuesta

    Respuesta A. Las dos son correctas y responden a preguntas distintas: la dependencia parcial en filas de rebote mide el efecto del momentum dado el régimen; SHAP reparte la interacción momentum × régimen entre las dos features, y la mitad va a la de régimen

    Para un término b·x·z, el SHAP de x lleva aproximadamente la mitad de b·x·z (más un término con la media de z). Si se quiere saber cómo responde la predicción al momentum en un régimen, la herramienta es la dependencia parcial o el ICE condicionado a ese régimen.

  4. Con la etiqueta real, la correlación de rangos media entre las importancias SHAP de cuatro semillas es 0,81, la de cuatro bootstrap de semanas, 0,51, y la de cuatro ventanas de dos años, 0,31. ¿Cuál mide la incertidumbre que importa?

    • ALa de las semillas, porque es la más alta
    • BNinguna: SHAP es exacto en árboles
    • CEl bootstrap y las ventanas: las semillas solo cambian el azar del entrenamiento con los mismos datos, y la duda está en los datos (otra muestra, otro periodo)
    • DSolo las ventanas
    Ver respuesta

    Respuesta C. El bootstrap y las ventanas: las semillas solo cambian el azar del entrenamiento con los mismos datos, y la duda está en los datos (otra muestra, otro periodo)

    Una importancia estable entre semillas puede ser una lectura estable del mismo ruido. El bootstrap de semanas imita otra muestra del mismo proceso; las ventanas añaden que los efectos cambian con el tiempo. Con la diana sin ruido las tres pasan de 0,90.

  5. Al quitar mom_12_1, el SHAP medio de ruido_4 (sin efecto propio, correlación 0,60 con el momentum) pasa de 0,03 a 0,94 pb y el IC del modelo casi no cambia (0,783 frente a 0,777). ¿Qué enseña?

    • AQue ruido_4 tiene un efecto que el momentum tapaba
    • BQue una explicación alta no demuestra que la feature cause nada: el modelo usa la feature correlacionada que tenga a mano, y la importancia se mueve entre sustitutas según qué features entren
    • CQue hay que quitar siempre el momentum
    • DQue SHAP falla con features continuas
    Ver respuesta

    Respuesta B. Que una explicación alta no demuestra que la feature cause nada: el modelo usa la feature correlacionada que tenga a mano, y la importancia se mueve entre sustitutas según qué features entren

    Es el efecto de sustitución, ahora en SHAP. dist_max_52s (correlación 0,64) sube de 0,32 a 1,74. La importancia de una feature solo se puede leer junto con la lista de sus sustitutas; por eso se agrupa.

Ideas clave

  • SHAP es aditivo: el valor base más la suma de los valores de una fila es la predicción. Eso permite explicar una decisión concreta, pero explica el modelo, no el mundo.
  • Los valores de interacción SHAP encuentran la interacción plantada reversión × volumen como el par más fuerte (1,47 pb) y miden su tamaño: 0,93 por casilla frente a 1,37 de la verdad. Los pares siguientes mezclan una interacción plantada (SUE × días desde el informe) con otras que no lo están.
  • La dependencia parcial da el efecto medio y las curvas ICE, su heterogeneidad. Agrupadas por tercios de volumen, las pendientes ICE recuperan la interacción completa (1,48 y 0,48 frente a 1,52 y 0,43 de la verdad), cosa que SHAP reparte.
  • Un modelo solo puede explicar lo que ve. Sin el régimen, el momentum tiene la misma pendiente en calma y en rebote (3,9 pb); con el régimen verdadero, la dependencia parcial en rebote da −6,5 frente a −7,1; con un estado de mercado observable, +1,3: los rebotes no se reconocen a tiempo.
  • Con la etiqueta real las importancias son inestables: correlación de rangos de 0,51 entre bootstrap de semanas y de 0,31 entre ventanas, y valor_bp va del 2.º al 18.º puesto. Por grupos de features correlacionadas sube a 0,83.
  • Con features correlacionadas la importancia se mueve entre sustitutas: sin el momentum, ruido_4 y dist_max_52s ocupan su sitio y el modelo predice casi igual. Se agrupa, se miden réplicas y se informa del rango, no del puesto.

Recursos