Lesson 5.10.1 · 75 min
SHAP, partial dependence and the stability of feature importances
Explaining a model is easy to do and hard to read well. In the level's panel every effect is known, the interaction of reversal with volume and the sign change of momentum in rebounds, so you can check what each tool recovers, what it splits, what it cannot see and how much the explanation changes if the seed or the sample changes.
- Module
- 5.10 · Interpretability, robustness and model risk
- Duration
- 75 min
- Prerequisites
- 5.2.5 · Feature importance: MDI, MDA, SFI and SHAP, 5.5.3 · Gradient boosting: XGBoost, LightGBM and CatBoost, 5.1.1 · Why financial machine learning is different
- Author
- Jesús Cuesta
- Updated
Level 5 lessons
5.1 · ML fundamentals applied to finance
5.2 · Feature engineering
5.3 · Labelling and sampling
5.4 · Validation for financial series
5.5 · Classical models
- 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
- 5.5.2 Decision trees, Random Forest and bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
- 5.5.4 Optuna and the risk of overfitting the validation
- 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
- 5.5.6 Learning to rank for stock selection
5.6 · Deep learning with Keras 3
5.7 · Deep learning with PyTorch
- 5.7.1 PyTorch: tensors, autograd and the training loop
- 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
- 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
- 5.7.4 Graph neural networks for asset relationships
- 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
5.8 · Time series foundation models
- 5.8.1 Time series foundation models: pretrain once, forecast without training
- 5.8.2 Foundation model families: architecture, data and license
- 5.8.3 In practice: context, quantiles, covariates and fine-tuning
- 5.8.4 Foundation models in finance: what the evidence says
- 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
5.9 · Reinforcement learning
5.10 · Interpretability, robustness and model risk
Explaining the model, not the world
Lesson 5.2.5 measured the global importance of each feature with four methods and reached a conclusion that is the starting point here: an explanation describes what the model does, not how the market works. If the model learned noise, the explanation shows noise; if it did not learn something, the explanation cannot show it. This lesson goes three steps further:
- Local and interaction explanations: why the model predicted this for this stock this week, and what part of each effect depends on another feature.
- Shape of the effects: partial dependence and ICE curves, what they measure that SHAP does not and vice versa.
- Stability: whether the explanation would change with another seed, another sample or another period. An explanation that does not survive that is not an explanation.
Mermaid source
flowchart TB Q[Question] --> L["Why this row?"] Q --> F["What shape does it have?"] Q --> E["Is it stable?"] L --> S[Local SHAP] F --> P[PD and ICE] E --> B[Bootstrap]
The test bench is the level 5 panel: 400 stocks, 21 weekly features in normal rank (without the leak
revision_analistas), two targets (the real label and the true alpha, the predictable part without noise) and the
market regime of each week, which the generator publishes in panel5_coef_verdad.csv. The models are trained on
2012-2019 and explained with rows from 2020-2025.
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): # in basis points
return ((d[col] - sem[col].transform("mean")) * 1e4).to_numpy()
y = menos_media("ret_sig_semana") # the real label
a = menos_media("alfa_verdadero") # the noise-free target
reg = d["regimen"].to_numpy()
tr = (d["fecha"] < "2020-01-01").to_numpy() # 2012-2019
te = ~tr # 2020-2025
print(X.shape, f"training {tr.sum()} · test {te.sum()}")
periodo = np.where(tr, "2012-2019", "2020-2025")
print(pd.crosstab(periodo, reg, rownames=["weeks"],
colnames=[None]) // 400)(291600, 21) training 166400 · test 125200
calma estres rebote
weeks
2012-2019 353 37 26
2020-2025 222 52 39There are 416 training weeks and 313 test weeks; the rebounds (after each stress episode, when true momentum reverses, lesson 5.1.1) are 26 in training and 39 in the test. The model is a moderate LightGBM (300 trees of 15 leaves, at least 500 rows per leaf, row and column subsampling) like the one in lesson 5.5.3.
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):
"""Mean rank IC over the weeks of `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) # learns the predictable part
m_y = lgbm(FEATS, y) # what can be done in practice
Xte = X.loc[te, FEATS].to_numpy()
for nombre, m in [("noise-free", m_a), ("label", m_y)]:
p = m.predict(Xte)
print(f"{nombre:16s} IC with the alpha {ic_semanal(p, a, te):.3f}"
f" · with the label {ic_semanal(p, y, te):.4f}")
print(f"oracle IC with the label "
f"{ic_semanal(a[te], y, te):.4f}")noise-free IC with the alpha 0.783 · with the label 0.0183
label IC with the alpha 0.294 · with the label 0.0077
oracle IC with the label 0.0262With the noise-free target, the model reaches an IC of 0.783 with the 2020-2025 alpha: it has learned a good part of the planted structure. With the real label it stays at 0.0077, against the oracle's ceiling (0.0262). And the noise-free target model, evaluated against the label, gives 0.0183: what a model could extract if the signal were not buried. To see what each tool recovers, the first model is used most; to see how far an explanation with real data can be trusted, both.
Local SHAP: why this prediction
SHAP values split the difference between the prediction for a row and the base value (the mean prediction on the
model's data) among the features. The property that makes it useful is additivity: base plus the sum of values
equals the prediction, with no remainder. shap.TreeExplainer satisfies it exactly for tree models (Lundberg et al.,
2020).
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("max. |base + SHAP sum − prediction|:",
f"{np.abs(suma - m_a.predict(Xm)).max():.1e}")
i = 0 # one stock-week
fila = pd.DataFrame({"value (normal rank)": Xm[i],
"shap (bp)": sv[i]}, index=FEATS)
print(d.loc[muestra[i], ["fecha", "ticker"]].to_dict(),
f"base {ex_a.expected_value:.2f} bp · prediction "
f"{m_a.predict(Xm[[i]])[0]:.2f} bp · true alpha "
f"{a[muestra[i]]:.2f} bp")
fila.reindex(fila["shap (bp)"].abs().sort_values(
ascending=False).index).head(6).round(2)max. |base + SHAP sum − prediction|: 9.6e-14
{'fecha': Timestamp('2020-05-29 00:00:00'), 'ticker': 'MACE'} base 0.01 bp · prediction 15.65 bp · true alpha 11.34 bp
value (normal rank) shap (bp)
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.90The maximum difference between base plus sum and the prediction is . For the stock MACE on 2020-05-29, the
model predicts 15.65 bp of excess return for the following week (the true alpha was 11.34). Most of it comes from
valor_bp (7.11 bp, with a normal rank of 2.08: a stock that is very cheap relative to book value), followed by low
volatility (vol_63d at −0.78 contributes 2.82) and by ey (2.14), which has no effect of its own and carries part of
the value effect through its correlation with valor_bp. It is the explanation a portfolio manager asks for (“why are
we buying this?”) and it is exactly the model's, including its borrowing between correlated features.
Interaction values
The SHAP value of a feature mixes its own effect with its share of the interactions. SHAP interaction values (Lundberg et al., 2020) separate it into a matrix per row: the diagonal is the main effect of feature and each off-diagonal cell is half of its interaction with , so that .
The generator plants reversal as , with = ret_1s and =
volumen_rel. The main effect of ret_1s has slope and the cross term, per unit of
, is split equally between and : in each.
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"mean true c_rev 2012-2019: {c_rev:.2f} bp")
print(f"main effect of ret_1s: slope "
f"{np.polyfit(r, si[:, j, j], 1)[0]:.2f} (truth {c_rev:.2f})")
print(f"interaction with volumen_rel (each half): "
f"{np.polyfit(r * z, si[:, j, k], 1)[0]:.2f} "
f"(truth {-0.25 * c_rev:.2f})")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
mean true c_rev 2012-2019: -5.46 bp
main effect of ret_1s: slope -4.74 (truth -5.46)
interaction with volumen_rel (each half): 0.93 (truth 1.37)The pair with the most interaction, by far, is ret_1s × volumen_rel (1.47 bp on average in absolute value,
counting both cells). The fourth, sue × dias_desde_informe, is also planted (the SUE decays with the days since
the report). The other two are not: ret_1s × vol_63d and mom_12_1 × valor_bp are interactions the model found
in training, perhaps because reversal and momentum change with the regime and volatility or value move with it,
perhaps noise. A large interaction in the model is a hypothesis, not a discovery.
The sizes can be compared with the truth. The main effect of ret_1s has a slope of −4.74 against the mean c_rev of
2012-2019 (−5.46), and each half of the interaction, 0.93 against 1.37. The model learned reversal at 87% of its size
and the interaction at 68%: regularization (leaves of at least 500 rows, 300 trees with a learning rate of 0.05)
shrinks more what is harder to see.
Partial dependence and ICE
Partial dependence (Friedman, 2001) answers another question: how the mean prediction changes when one feature is moved with the others left as they are. For feature at value :
Each term is an ICE curve (individual conditional expectation, Goldstein et al., 2015): the prediction for row
when only its feature is moved. Partial dependence is the mean of the ICE curves; if the curves are parallel,
the effect does not depend on the other features, and if they fan out, there is interaction.
sklearn.inspection.partial_dependence computes them with any model that has predict (here, by brute force: 21 grid
points × 1,000 rows).
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 of ret_1s (bp), from −2 to +2:",
np.round(pdep["average"][0][::5], 1))
pend = np.array([np.polyfit(rejilla, c, 1)[0]
for c in pdep["individual"][0]]) # one per ICE
vol = Xm[:, k]
tercio = pd.cut(vol, [-np.inf, -0.43, 0.43, np.inf],
labels=["low", "mid", "high"])
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(ICE slope, volumen_rel) = "
f"{np.corrcoef(pend, vol)[0, 1]:.2f}")
pd.DataFrame({"ICE slope (bp)": ice,
"relative ICE": ice / ice["mid"],
"relative truth": verdad / verdad["mid"]}).round(2)PD of ret_1s (bp), from −2 to +2: [ 10. 5.1 -0.4 -6.1 -10.7]
corr(ICE slope, volumen_rel) = 0.96
ICE slope (bp) relative ICE relative truth
low -7.58 1.48 1.52
mid -5.12 1.00 1.00
high -2.46 0.48 0.43The partial dependence of ret_1s falls from +10 bp to −10.7 bp between the 2nd and 98th percentiles: the mean
reversal. The ICE curves are not parallel: the correlation between the slope of each curve and the volumen_rel of its
row is 0.96. Grouped by volume terciles, the slopes are −7.58, −5.12 and −2.46 bp per unit; relative to the middle
tercile, 1.48 and 0.48, against 1.52 and 0.43 from the generator. The figure shows them in bp alongside the truth with
the mean c_rev of the training period.
This is the difference with lesson 5.2.5, where the SHAP slopes of the same effect by terciles gave 1.25 and 0.72
against the exact Shapley value, 1.27 and 0.72. SHAP attributes: it splits the interaction between the two
features. Grouped ICE conditions: it says how the prediction responds to ret_1s when volume is low, with the
whole interaction. Neither is wrong; they answer different questions.
Momentum by regime: what the model does not see
Momentum in the panel changes with the market regime: 5.0 bp per unit in calm, 1.7 in stress and −7.1 in rebound
(2012-2019 means). A model can only reproduce that if it sees the regime. Three versions with the noise-free target:
no state (the 21 features), with an observable state (the cross-sectional mean of ret_1m, ret_3m and
dist_max_52s: how the market has done over the last month, the last quarter and how far it is below its highs) and
with the true regime as a feature, something nobody has in reality. In each 2020-2025 regime the slope of momentum
is measured with SHAP and with the partial dependence computed on rows of that regime.
for c in ["ret_1m", "ret_3m", "dist_max_52s"]: # observable state
X[f"mercado_{c}"] = sem[c].transform("mean")
X["rebote"] = (reg == "rebote").astype(float) # true state
X["estres"] = (reg == "estres").astype(float)
modelos = {
"no state": FEATS,
"observable state": FEATS + ["mercado_ret_1m", "mercado_ret_3m",
"mercado_dist_max_52s"],
"true regime": 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 == "no state" 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["truth 2012-2019"] = list(verdad.loc[False]) * 2
t.loc["truth 2020-2025"] = list(verdad.loc[True]) * 2
t.round(2) shap pd
calma estres rebote calma estres rebote
no state 3.64 3.67 3.61 3.84 3.87 3.88
observable state 3.84 1.96 2.32 4.27 0.40 1.26
true regime 4.00 2.83 -0.94 4.58 2.00 -6.47
truth 2012-2019 5.01 1.74 -7.07 5.01 1.74 -7.07
truth 2020-2025 4.23 2.22 -12.68 4.23 2.22 -12.68Without state, both tools give the same slope in all three regimes (3.6-3.9 bp): the model learned an average
momentum and applies it always. The explanation is faithful to the model and the model is wrong in rebounds, where the
truth is −12.68 in the test. With the true regime, partial dependence on rebound rows gives −6.47, very close to the
−7.07 of training (the 2020-2025 rebounds are more violent than the ones the model saw). SHAP, on the other hand, gives
−0.94: the momentum × regime interaction is split, and half goes to the rebote feature. Reading the SHAP of momentum
as “momentum barely matters in rebounds” would be a mistake.
The observable state is not enough (+1.26 in rebound with partial dependence). The market's return over the last month and the last quarter, or the drawdown from highs, do not separate a rebound from stress in time, and with 26 rebound weeks in training the model has very few examples. It is the same conclusion as lesson 5.10.2 seen from the side of explanation: what the model does not see, it neither learns nor explains, and a clean explanation of a model without the right state gives a reassuring and false picture.
Stability of importances
An importance is an estimate and has an error. Three sources of variation, from least to most relevant:
- Seed: the same sample with different randomness in training (row and column subsampling).
- Bootstrap of weeks: another sample from the same process, resampling whole weeks with replacement (the rows of a week share that week's market; resampling individual rows would underestimate the variation).
- Time window: two different years of data. Besides sampling, the true effects change (the generator makes them vary with a weekly AR(1) and with the regime).
For each replica a somewhat lighter LightGBM is trained (150 trees, rate 0.1) and the mean absolute SHAP is measured on the same 1,000 test rows. Stability is the mean rank correlation between replicas.
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 [("alpha", a), ("label", y)]:
for s in range(4): # same sample, another seed
imp[(diana, "seed", s)] = importancia(
lgbm(FEATS, obj, semilla=s, n=150, lr=0.1))
for i0, i1 in ventanas: # two years of data each
filas = (d["fecha"] >= f"{i0}-01-01") & (
d["fecha"] <= f"{i1}-12-31")
imp[(diana, "window", i0)] = importancia(
lgbm(FEATS, obj, filas.to_numpy(), n=150, lr=0.1))
for b in range(4): # weeks with replacement
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 ["seed", "window", "bootstrap"]}
for diana in ["alpha", "label"]}).round(2) alpha label
seed 0.99 0.81
window 0.90 0.31
bootstrap 0.98 0.51With the noise-free target, the explanation is stable under everything: 0.99 across seeds, 0.98 across bootstraps and 0.90 across windows, the lowest because the true effects change from one period to another. With the real label it falls apart: 0.81 across seeds, 0.51 across bootstraps and 0.31 across windows. Stability across seeds is misleading, because the model reads the same noise of the same sample in a similar way; the uncertainty that matters is that of the data.
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] != "seed"]
res = {}
for diana in ["alpha", "label"]:
rr = rangos[[c for c in replicas if c[0] == diana]]
res[(diana, "min")] = rr.min(axis=1)
res[(diana, "max")] = rr.max(axis=1)
pd.DataFrame(res).loc[sel].astype(int) alpha label
min max min max
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 19The figure at the top shows the rank ranges in the eight data replicas (windows and bootstrap). With the noise-free
target, ret_1s is always first and ruido_1 is always among the last four. With the real label, valor_bp and sue,
with a planted effect, range from 2nd to 18th place; ret_1m, with no effect of its own, from 1st to 17th; ruido_4
reaches 6th and ruido_1 8th. Only ret_1s stays at the top (from 1st to 5th), and sentimiento, which has an effect
but 32% of its values missing and filled with zero, never gets above 14th. A report saying “the third most important
feature is X” with these data would be reporting one realization of the noise.
When explanations mislead: correlated features
The first source of instability is the split within groups of correlated features: the model can use any of them and the split changes from one replica to another even if the total does not. This is checked with the correlation groups of lesson 5.2.5.
grupos = {"reversal": ["ret_1s", "ret_1m", "rsi_14"],
"medium term": ["ret_3m", "dist_max_52s"],
"momentum": ["mom_12_1", "ruido_4"],
"volatility": ["vol_21d", "vol_63d"],
"value-quality": ["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] == ("label", "bootstrap")]
cg = spearmanr(imp_g[boot]).statistic
print(f"label, bootstrap: mean rho by features "
f"{rho_medio(boot):.2f} · by groups "
f"{cg[np.triu_indices(4, 1)].mean():.2f}")
cuota = imp.loc[grupos["reversal"], boot]
(cuota / cuota.sum()).round(2)label, bootstrap: mean rho by features 0.51 · by groups 0.83
label
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.22Summing the SHAP of each group, the rank correlation across bootstraps goes from 0.51 to 0.83. Within the reversal
group, the share of ret_1s ranges from 39% to 51% depending on the replica and ret_1m reaches 39% in one of them:
the group is stable, the split is not.
The most extreme form is substitution: if a feature is removed, its substitute takes its place.
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({"with mom_12_1": shap_medio(m_a, FEATS),
"without mom_12_1": shap_medio(m_sin, sin_mom)})
p_sin = m_sin.predict(X.loc[te, sin_mom].to_numpy())
print("IC with the alpha in 2020-2025:",
f"with {ic_semanal(m_a.predict(Xte), a, te):.3f} · without "
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)IC with the alpha in 2020-2025: with 0.783 · without 0.777
corr(ruido_4, mom_12_1) = 0.6 · corr(dist_max_52s, mom_12_1) = 0.64
with mom_12_1 without 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.34Without mom_12_1, the mean SHAP of ruido_4 (0.6 × momentum plus noise, no effect of its own) goes from 0.03 to
0.94 bp and that of dist_max_52s (correlation 0.64), from 0.32 to 1.74. The model's IC barely changes (0.783 against
0.777): the momentum information is still inside, spread among its neighbors. For someone looking only at the
explanation, the second model “proves” that the distance to the 52-week high is one of the most important features in
the market.
The same traps, summarized:
| Situation | What the explanation shows | What to do |
|---|---|---|
| Correlated features | arbitrary split among them, changes with the replica | importance by groups |
| Feature removed or added | its substitute inherits the importance | read each importance with its list of substitutes |
| Interaction | SHAP splits it between the two features | interaction values, conditioned ICE |
| Unobserved state | a mean effect faithful to the model and false in the regime | add the state or do not trust the effect in that regime |
| Partial dependence with correlations | impossible combinations, extrapolation | ALE, or PD only within the joint range of the data |
| Noisy label | rankings that change with the sample | replicas, rank ranges, noise controls |
How to present an explanation
Mermaid source
flowchart TB
M[Validated model] --> R[Replicas]
R --> G[Groups]
G --> E{Stable?}
E -- no --> N[Do not interpret]
E -- yes --> F[Shape: PD, ICE]
F --> H[Hypothesis]- First the model, then the explanation. Explaining a model that does not pass honest validation (lessons 5.4.2 and 5.4.4) is explaining noise.
- Always replicas. Bootstrap of time blocks and windows; report the range of each importance or of its rank, not a single number.
- Groups before individual features, with noise controls inside the model (lesson 5.2.5).
- SHAP for attribution, partial dependence and ICE for shape, and interaction values when the question is whether two features reinforce each other.
- An explanation is a hypothesis. If it suggests a mechanism (reversal is stronger with low volume), the mechanism is tested separately, with data that were not used to find it.
Exercises
- By default
TreeExplainercomputes SHAP values by following the tree paths (tree_path_dependent), which partly respect the correlations of the training data. Withfeature_perturbation="interventional"and a background of 100 rows, it breaks those correlations. Compare the mean SHAP of the two versions on 500 rows. Where do they differ?
Solution
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({"interventional": np.abs(sv_int).mean(0),
"tree path": np.abs(sv_cam).mean(0)}, index=FEATS)
dif["difference"] = dif["interventional"] - dif["tree path"]
dif.sort_values("tree path", ascending=False).head(11).round(2) interventional tree path difference
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.08The order barely changes and the differences are small for the features with an effect (ret_1s 4.63 against 4.37,
mom_12_1 3.22 against 3.06). The largest relative differences are in features with no effect of their own that are
correlated with others: dist_max_52s rises from 0.33 to 0.46 (39%) and ret_1m from 0.25 to 0.33. The
interventional version replaces the “absent” features with values from background rows chosen at random, breaking
their correlation with the present ones: it evaluates the model on unrealistic combinations and attributes somewhat
more to the correlated neighbors. It answers “what does the model do if I change this feature and nothing else”; the
tree-path version is closer to “what does it do with the data as they come”. With nearly independent features the two
agree; with highly correlated groups it is worth looking at both or, better, grouping.
- Explain the same stock-week (the
MACErow of 2020-05-29) with four LightGBMs with different seeds, trained with the noise-free target and with the real label. Do the three features with the highest SHAP agree?
Solution
fila = Xm[[0]]
for diana, obj in [("alpha", a), ("label", 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" seed {s}: pred {m.predict(fila)[0]:6.2f} bp · "
+ ", ".join(f"{c} {sv1[c]:+.1f}" for c in top))alpha
seed 0: pred 15.54 bp · valor_bp +7.1, vol_63d +2.6, ey +2.3
seed 1: pred 15.22 bp · valor_bp +7.3, vol_63d +2.9, ey +2.2
seed 2: pred 14.32 bp · valor_bp +7.1, vol_63d +2.6, ey +2.1
seed 3: pred 15.04 bp · valor_bp +7.1, vol_63d +2.8, ey +2.0
label
seed 0: pred 24.23 bp · valor_bp +15.5, ret_1s +6.1, volumen_rel +4.5
seed 1: pred 2.87 bp · valor_bp +9.1, dist_max_52s -5.1, dias_desde_informe +2.3
seed 2: pred -0.15 bp · valor_bp +9.4, rsi_14 -6.8, ruido_5 +5.5
seed 3: pred -0.08 bp · dias_desde_informe +6.5, vol_63d -4.8, volumen_rel -4.3With the noise-free target, the four seeds tell the same story: a prediction between 14.3 and 15.5 bp and the same
three reasons (valor_bp +7.1 to +7.3, vol_63d, ey). With the real label, the same row receives predictions of
24.2, 2.9, −0.2 and −0.1 bp, and the reasons change with the seed: valor_bp appears three times, but alongside
ret_1s, dist_max_52s, rsi_14, dias_desde_informe and even ruido_5, pure noise, with +5.5 bp. Each explanation
is exact for its model; the problem is that models trained with such a noisy label differ a lot from one another on
each row even though their mean IC is similar. A local explanation of a model with little signal has to be averaged
over several models (or presented as a range) before telling it to anyone.
- An investment committee asks every week for “the three reasons why the model is buying”. With what you have seen in the lesson, what would you deliver and with which caveats?
Solution
Per row, the model's SHAP values aggregated by groups of correlated features (value-quality, reversal, momentum, volatility...), not by individual features, because the split within a group is arbitrary and changes with the seed and the sample. The three reasons would be the stock's three groups with the highest SHAP, with their sign and their size in basis points, and the total prediction, which is their sum plus the base. The caveats: the explanation describes the model, not the market; with a signal-to-noise ratio like that of equities, the model mainly explains how it splits a small effect, and the explanations of a row with the real label change a lot from one seed to another (exercise 2), so it is advisable to average several models or show the range. If a group dominates disproportionately, it has to be audited before being presented (it may be a leak, lesson 5.2.4). And periodically, global stability: importances by groups with a bootstrap of weeks and by windows, and the shape of the main effects with partial dependence and ICE, to detect whether the model has changed its criteria (lesson 5.10.2).
Each answer is folded under its question
Self-assessment
For one row, the explainer's base value is 0.01 bp, the sum of its SHAP values is 15.64 bp and the model's prediction is 15.65 bp. Which SHAP property does this show?
- AThat the model is overfitted
- BAdditivity (efficiency): the base value plus the sum of the row's SHAP values is exactly the prediction
- CThat SHAP measures the causal importance of each feature
- DThat the row is an outlier
Show answer
Answer B. Additivity (efficiency): the base value plus the sum of the row's SHAP values is exactly the prediction
Shapley values split the difference between the prediction and the base value without leaving anything out. In the cell the maximum difference between the two is 1e-13: rounding error. Additivity says how the prediction is split, not whether the features cause anything.
The planted interaction between
ret_1sandvolumen_relis worth −0.5·c_rev per unit of the product (2.73 bp with the mean c_rev of 2012-2019). What slope is expected in each of the pair's two SHAP interaction cells, (ret_1s,volumen_rel) and (volumen_rel,ret_1s)?- A2.73 in each
- BZero, because SHAP does not measure interactions
- C5.46, twice as much
- DHalf, 1.37: the interaction matrix is symmetric and splits the cross term equally between the two cells
Show answer
Answer D. Half, 1.37: the interaction matrix is symmetric and splits the cross term equally between the two cells
The interaction values of Lundberg et al. (2020) put half of the joint effect in each cell of the pair. The LightGBM gives 0.93 against 1.37: it learned the interaction at about two thirds of its size, as expected from a regularized model.
With the true regime as a feature, momentum in a rebound has a partial dependence slope of −6.47 and a SHAP slope of −0.94 (truth −7.07). What is the correct reading?
- ABoth are correct and answer different questions: partial dependence on rebound rows measures the effect of momentum given the regime; SHAP splits the momentum × regime interaction between the two features, and half goes to the regime feature
- BSHAP is computed wrongly
- CPartial dependence is biased by the correlation
- DThe model did not learn the sign change
Show answer
Answer A. Both are correct and answer different questions: partial dependence on rebound rows measures the effect of momentum given the regime; SHAP splits the momentum × regime interaction between the two features, and half goes to the regime feature
For a term b·x·z, the SHAP of x carries roughly half of b·x·z (plus a term with the mean of z). If you want to know how the prediction responds to momentum in a regime, the tool is partial dependence or ICE conditioned on that regime.
With the real label, the mean rank correlation between the SHAP importances of four seeds is 0.81, that of four bootstraps of weeks 0.51, and that of four two-year windows 0.31. Which one measures the uncertainty that matters?
- AThe seeds one, because it is the highest
- BNone: SHAP is exact for trees
- CThe bootstrap and the windows: seeds only change the randomness of training on the same data, and the doubt lies in the data (another sample, another period)
- DOnly the windows
Show answer
Answer C. The bootstrap and the windows: seeds only change the randomness of training on the same data, and the doubt lies in the data (another sample, another period)
An importance that is stable across seeds can be a stable reading of the same noise. The bootstrap of weeks imitates another sample from the same process; the windows add that the effects change over time. With the noise-free target all three are above 0.90.
When
mom_12_1is removed, the mean SHAP ofruido_4(no effect of its own, correlation 0.60 with momentum) goes from 0.03 to 0.94 bp and the model's IC barely changes (0.783 against 0.777). What does this teach?- AThat
ruido_4has an effect that momentum was hiding - BThat a high explanation does not prove that the feature causes anything: the model uses whatever correlated feature it has at hand, and importance moves between substitutes depending on which features are included
- CThat momentum should always be removed
- DThat SHAP fails with continuous features
Show answer
Answer B. That a high explanation does not prove that the feature causes anything: the model uses whatever correlated feature it has at hand, and importance moves between substitutes depending on which features are included
It is the substitution effect, now in SHAP.
dist_max_52s(correlation 0.64) rises from 0.32 to 1.74. The importance of a feature can only be read together with the list of its substitutes; that is why features are grouped.- AThat
Key points
- SHAP is additive: the base value plus the sum of a row's values is the prediction. That makes it possible to explain a specific decision, but it explains the model, not the world.
- SHAP interaction values find the planted reversal × volume interaction as the strongest pair (1.47 bp) and measure its size: 0.93 per cell against 1.37 for the truth. The following pairs mix a planted interaction (SUE × days since the report) with others that are not planted.
- Partial dependence gives the mean effect and the ICE curves its heterogeneity. Grouped by volume terciles, the ICE slopes recover the full interaction (1.48 and 0.48 against 1.52 and 0.43 for the truth), which SHAP splits.
- A model can only explain what it sees. Without the regime, momentum has the same slope in calm and in rebound (3.9 bp); with the true regime, partial dependence in rebound gives −6.5 against −7.1; with an observable market state, +1.3: rebounds are not recognized in time.
- With the real label the importances are unstable: a rank correlation of 0.51 across bootstraps of weeks and of 0.31 across windows, and
valor_bpranges from 2nd to 18th place. By groups of correlated features it rises to 0.83. - With correlated features importance moves between substitutes: without momentum,
ruido_4anddist_max_52stake its place and the model predicts almost the same. Group, measure replicas and report the range, not the rank.
Resources
Documentation
- Lundberg and Lee (2017): A Unified Approach to Interpreting Model Predictions, arXiv:1705.07874
- Lundberg et al. (2020): From local explanations to global understanding with explainable AI for trees, Nature Machine Intelligence 2, 56-67
- Friedman (2001): Greedy function approximation: a gradient boosting machine, Annals of Statistics 29(5) (partial dependence)
- Goldstein, Kapelner, Bleich and Pitkin (2015): Peeking Inside the Black Box (ICE curves), JCGS 24(1)
- Apley and Zhu (2020): Visualizing the Effects of Predictor Variables in Black Box Supervised Learning Models (ALE), JRSS B 82(4)
- scikit-learn: partial dependence and individual conditional expectation