---
title: "5.10.1 SHAP, dependencia parcial y estabilidad"
description: "Valores e interacciones SHAP, dependencia parcial e ICE frente a efectos plantados, y cuánto cambian las importancias con semillas, ventanas y bootstrap."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/shap-dependencia-parcial-y-estabilidad/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/shap-dependencia-parcial-y-estabilidad.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.10 · Interpretabilidad, robustez y riesgo de modelo
- Duración: 75 min
- Requisitos: [5.2.5 · Importancia de features: MDI, MDA, SFI y SHAP](https://pythonparatrading.com/curso-python-trading/nivel-5/importancia-de-features-mdi-mda-sfi-y-shap/), [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/), [5.1.1 · Por qué el machine learning financiero es distinto](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-el-ml-financiero-es-distinto/)

## Al terminar esta lección

- Usar los valores SHAP de un árbol para explicar una predicción concreta y los valores de interacción para separar el efecto principal de una feature de su interacción con otra.
- Calcular la dependencia parcial y las curvas ICE con scikit-learn y leer en el abanico de curvas una interacción plantada.
- Distinguir lo que reparte SHAP (la atribución) de lo que mide la dependencia parcial (el efecto condicionado), y reconocer que un modelo no puede explicar un estado que no ve.
- Medir la estabilidad de una importancia con semillas, ventanas temporales y bootstrap de semanas, y saber cuándo un ranking es ruido.
- Reconocer cuándo engañan las explicaciones con features correlacionadas: sustitución, reparto arbitrario dentro de un grupo y extrapolación de la dependencia parcial.

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

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

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

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

```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)
```
```output
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^{-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 $\Phi_{jj}$ es el efecto principal de la
feature $j$ y cada casilla fuera de la diagonal $\Phi_{jk}$ es la mitad de su interacción con $k$, de modo que
$\phi_j = \Phi_{jj} + \sum_{k \ne j} \Phi_{jk}$.

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

```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})")
```
```output
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 $j$ en el valor $x$:

$$
\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 $i$ cuando solo se mueve su feature $j$. 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).

```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)
```
```output
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.* Curvas que bajan de izquierda a derecha: la predicción cae al subir el rendimiento de la última semana (reversión). La media del tercio de volumen bajo, destacada, es la más inclinada: −7,6 pb por unidad frente a −8,4 del generador. Volumen medio: −5,1 frente a −5,5. Volumen alto: −2,5 frente a −2,4. Las curvas individuales se abren en abanico: la pendiente depende del volumen de cada fila.

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.

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

La dependencia parcial mueve una feature dejando las demás fijas, también cuando están correlacionadas: con `ret_1s`
en +2 y `rsi_14` en −2 evalúa al modelo en combinaciones que no existen (una acción que sube mucho en la semana con un
RSI de sobreventa). Allí el modelo extrapola y la curva puede inventar formas. Las curvas ALE (*accumulated local
effects*, Apley y Zhu, 2020) evitan el problema acumulando diferencias locales dentro de cada tramo de la feature, solo
con filas que de verdad están ahí.

</aside>

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

```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)
```
```output
                   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.* Barras horizontales. Verdad del entrenamiento: −7,07. PD del modelo con el régimen verdadero: −6,47, casi la verdad. SHAP del mismo modelo, destacado: −0,94, porque reparte la interacción con la feature de régimen. PD con estado observable: +1,26. PD sin estado: +3,88, el mismo efecto que en calma.

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.

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

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

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

```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)
```
```output
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ón | Qué muestra la explicación | Qué hacer |
|---|---|---|
| Features correlacionadas | reparto arbitrario entre ellas, cambia con la réplica | importancia por grupos |
| Feature quitada o añadida | su sustituta hereda la importancia | leer cada importancia con su lista de sustitutas |
| Interacción | SHAP la reparte entre las dos features | valores de interacción, ICE condicionado |
| Estado no observado | un efecto medio fiel al modelo y falso en el régimen | añadir el estado o no fiarse del efecto en ese régimen |
| Dependencia parcial con correlaciones | combinaciones imposibles, extrapolación | ALE, o PD solo en el rango conjunto de los datos |
| Etiqueta ruidosa | rankings que cambian con la muestra | réplicas, rangos de puestos, controles de ruido |

## Cómo presentar una explicación

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

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

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

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

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

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

   - A. Que el modelo está sobreajustado
   - B. La aditividad (eficiencia): el valor base más la suma de los SHAP de la fila es exactamente la predicción
   - C. Que SHAP mide la importancia causal de cada feature
   - D. Que la fila es un dato atípico

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

   - A. 2,73 en cada uno
   - B. Cero, porque SHAP no mide interacciones
   - C. 5,46, el doble
   - 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

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

   - 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
   - B. SHAP está mal calculado
   - C. La dependencia parcial está sesgada por la correlación
   - D. El modelo no aprendió el cambio de signo

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

   - A. La de las semillas, porque es la más alta
   - B. Ninguna: SHAP es exacto en árboles
   - 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)
   - D. Solo las ventanas

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

   - A. Que `ruido_4` tiene un efecto que el momentum tapaba
   - 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
   - C. Que hay que quitar siempre el momentum
   - D. Que SHAP falla con features continuas

   *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

### Documentación

- [Lundberg y Lee (2017): A Unified Approach to Interpreting Model Predictions, arXiv:1705.07874](https://arxiv.org/abs/1705.07874) · arxiv.org
- [Lundberg et al. (2020): From local explanations to global understanding with explainable AI for trees, Nature Machine Intelligence 2, 56-67](https://doi.org/10.1038/s42256-019-0138-9) · doi.org
- [Friedman (2001): Greedy function approximation: a gradient boosting machine, Annals of Statistics 29(5) (dependencia parcial)](https://doi.org/10.1214/aos/1013203451) · doi.org
- [Goldstein, Kapelner, Bleich y Pitkin (2015): Peeking Inside the Black Box (curvas ICE), JCGS 24(1)](https://doi.org/10.1080/10618600.2014.907095) · doi.org
- [Apley y Zhu (2020): Visualizing the Effects of Predictor Variables in Black Box Supervised Learning Models (ALE), JRSS B 82(4)](https://doi.org/10.1111/rssb.12377) · doi.org
- [scikit-learn: dependencia parcial y expectativa condicional individual](https://scikit-learn.org/stable/modules/partial_dependence.html) · scikit-learn.org

### Datos

- [Datos del nivel 5: coeficientes verdaderos y régimen de cada semana (CSV)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_coef_verdad.csv) · Descarga · panel5_coef_verdad.csv

Lección anterior: [5.9.3 · Por qué el RL sobreajusta en trading direccional](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-el-rl-sobreajusta-en-trading-direccional/)

Lección siguiente: [5.10.2 · Deriva de datos y de concepto: detectarla y qué hacer](https://pythonparatrading.com/curso-python-trading/nivel-5/deriva-de-datos-y-de-concepto/)
