SHAP值在风控模型可解释性中的实战
SHAP 值:风控模型可解释性的终极武器
标签:信贷风控 · 机器学习 · SHAP · 模型可解释性 · 特征重要性
一、为什么风控模型必须可解释?
信贷审批场景有一个不同于推荐/搜索的死线:你必须能回答"为什么拒了这笔贷款"。
这不是技术偏好,是监管刚需。《个人信息保护法》第 24 条明确说:通过自动化决策作出对个人权益有重大影响的决定时,个人有权要求说明。翻译成人话就是——用户问"为什么拒我",你不能说"模型说的"。
传统评分卡的答案很漂亮:
score = 0.3 × 收入 + 0.25 × 负债比 + 0.2 × 征信分 + ...
每个变量的权重清清楚楚,哪个变量拉低了总分一目了然。这是 线性可解释性。
但 XGBoost、LightGBM 这类 GBDT 模型不是线性的——几百棵树、几千个分裂节点叠加在一起,你怎么解释"为什么这个申贷人被判定为高风险"?
SHAP(SHapley Additive exPlanations)就是用来回答这个问题的。
说白了:SHAP 做的事 = "把每个特征对最终预测结果的贡献算清楚,并且保证所有贡献加起来等于最终的预测分"。
二、SHAP 的核心原理
2.1 从博弈论到机器学习
SHAP 值源于 1953 年诺贝尔经济学奖得主 Lloyd Shapley 提出的 Shapley 值——一个公平分配合作收益的数学方法。
类比:四个朋友合伙做生意赚了 100 万,每人贡献不同。怎么分钱公平?Shapley 值的答案是:算每个人在所有可能的"加入顺序"里带来的边际收益平均值。
A 加入时的边际收益 = 联盟{A}的收入 - 联盟{}的收入
B 在 A 之后加入:边际 = 联盟{A,B}的收入 - 联盟{A}的收入
B 在 A 之前加入:边际 = 联盟{B}的收入 - 联盟{}的收入
... 所有排列求平均 ...
SHAP 把这个思想搬到机器学习模型上:
特征"收入"的 SHAP 值
= 在所有可能的特征子集组合下
× 加入"收入"这个特征带来的预测变化
× 求加权平均
2.2 SHAP 的三个关键性质
| 性质 | 含义 | 为什么重要 |
|---|---|---|
| 局部保真 | 单个样本的预测 = baseline + Σ SHAP值 | 每笔贷款的预测都能被完全拆解 |
| 缺失性 | 缺失特征贡献为 0 | 不会编造不存在特征的贡献 |
| 一致性 | 模型升级后,贡献更大的特征 SHAP 值一定更大 | SHAP 排序和模型行为严格一致 |
一致性是 SHAP 相比传统 Feature Importance 最本质的优势。 传统 Feature Importance 只看"分裂多少次/减少多少不纯度",不关心方向(正/负贡献),SHAP 能告诉你某个特征对这笔具体贷款是加分还是减分。
三、实战代码:从模型训练到 SHAP 解释
3.1 环境准备
import pandas as pd
import numpy as np
import lightgbm as lgb
import shap
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
# 模拟风控数据
np.random.seed(42)
n = 50_000
X = pd.DataFrame({
"income_monthly": np.random.lognormal(mean=8.5, sigma=0.6, size=n), # 月收入
"debt_ratio": np.random.beta(2, 5, size=n) * 100, # 负债率 %
"credit_score": np.random.normal(650, 80, size=n).clip(350, 850), # 征信分
"multi_head_count": np.random.poisson(2, size=n).clip(0, 20), # 多头借贷平台数
"employment_years": np.random.gamma(2, 3, size=n).clip(0, 40), # 工作年限
"loan_amount": np.random.lognormal(10, 0.8, size=n), # 申请金额
"age": np.random.normal(35, 10, size=n).clip(18, 65).astype(int), # 年龄
"overdue_6m_count": np.random.poisson(0.5, size=n).clip(0, 10), # 近6月逾期次数
})
# 构造标签:坏样本 = 约 15%
logit = (
-0.001 * X["credit_score"]
+ 0.05 * X["debt_ratio"]
+ 0.15 * X["multi_head_count"]
+ 0.8 * X["overdue_6m_count"]
- 0.0005 * X["income_monthly"]
- 0.1 * X["employment_years"]
+ 2.0
)
prob = 1 / (1 + np.exp(-logit))
y = (np.random.random(n) < prob).astype(int)
print(f"坏样本率: {y.mean():.2%}")
3.2 训练 LightGBM 模型
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
model = lgb.LGBMClassifier(
objective="binary",
num_leaves=31,
learning_rate=0.05,
n_estimators=200,
min_data_in_leaf=100,
random_state=42,
verbosity=-1,
)
model.fit(X_train, y_train)
print(f"Test AUC: {model.score(X_test, y_test):.4f}")
3.3 SHAP 解释器初始化
# 方式一:TreeExplainer(树模型专用,最快)
explainer = shap.TreeExplainer(model)
# 取测试集前 2000 条做解释(大数据集抽样)
shap_values = explainer.shap_values(X_test.iloc[:2000])
# shap_values 的 shape: (2000, 8) —— 每个样本每个特征的 SHAP 值
# 注意 LGBMClassifier 返回 (n, n_features) 而非 (n, n_features, 2)
print(f"SHAP values shape: {shap_values.shape}")
print(f"Sums to model output: {np.allclose(shap_values.sum(1) + explainer.expected_value, np.log(model.predict_proba(X_test.iloc[:2000])[:, 1] / (1 - model.predict_proba(X_test.iloc[:2000])[:, 1])), atol=0.01)}")
四、SHAP 四大核心可视化
4.1 Summary Plot:全局特征重要性 + 方向
shap.summary_plot(shap_values, X_test.iloc[:2000], show=False)
plt.title("SHAP Feature Importance: Risk Score")
plt.tight_layout()
plt.savefig("shap_summary.png", dpi=150)
这个图一条线说清三件事:
- 颜色:红色 = 高值,蓝色 = 低值
- 位置:x 轴正向 = 推高风险,负向 = 拉低风险
- 排列:y 轴从上到下 = 重要度递减
典型风控结论:overdue_6m_count 高(红点全在右边)→ 推高风险;credit_score 高(红点在左边)→ 拉低风险。
4.2 Waterfall Plot:单笔贷款归因
# 挑一个被拒的样本(预测概率最高)
sample_idx = np.argmax(model.predict_proba(X_test.iloc[:2000])[:, 1])
shap.waterfall_plot(
shap.Explanation(
values=shap_values[sample_idx],
base_values=explainer.expected_value,
data=X_test.iloc[:2000].iloc[sample_idx].values,
feature_names=X.columns.tolist(),
),
show=False,
)
这个图是给业务/合规用的。你能精确说出:
"这笔贷款的风险分是 0.82,其中 baseline 0.15,被
overdue_6m_count=4推高了 0.22,multi_head_count=7推高了 0.18,credit_score=480推高了 0.15。但income_monthly=35000往回拉了 0.08。"
比"模型说不批"强一万倍。
4.3 Dependence Plot:单特征与 SHAP 的依赖关系
# credit_score 和 SHAP 值的关系
shap.dependence_plot(
"credit_score", shap_values, X_test.iloc[:2000],
interaction_index="debt_ratio", # 自动上色交互最强的特征
show=False,
)
Dependence Plot 能发现非线性关系和交互效应:
- 征信分 < 600 时,SHAP 值陡然升高(门槛效应)
- 征信分高 + 负债比也高的点,SHAP 值反而中等(模型学到了"能借到钱的人信用其实不差")
4.4 Force Plot:交互式归因
shap.initjs()
shap.force_plot(
explainer.expected_value,
shap_values[0],
X_test.iloc[:2000].iloc[0],
matplotlib=False,
)
Force Plot 生成一个交互式 HTML,红蓝箭头左右拔河,直观展示每个特征把预测值往哪边推。适合直接截图放进评审报告。
五、Java 生产环境集成 SHAP
模型训练阶段用 Python 的 SHAP 计算特征重要性,但这只是在实验环境看报告。线上实时推理要返回解释的话,有两种路径:
路径 A:离线预计算 + 在线查表(推荐)
# 离线:计算每个特征分箱的 SHAP 均值
import json
shap_bins = {}
X_sample = X_test.iloc[:2000].copy()
for col in X.columns:
# 对数值特征做分箱
X_sample[f"{col}_bin"] = pd.qcut(X_sample[col], q=10, duplicates="drop")
bin_means = X_sample.groupby(f"{col}_bin", observed=False).apply(
lambda g: shap_values[X_sample.loc[g.index].index, X.columns.get_loc(col)].mean()
)
shap_bins[col] = {
"intervals": [str(iv) for iv in bin_means.index.categories],
"shap_means": bin_means.tolist(),
}
with open("shap_lookup.json", "w") as f:
json.dump(shap_bins, f, indent=2)
// Java 线上:查表 + 累加解释
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
public class ShapExplainer {
private Map<String, ShapBinInfo> lookupTable;
public String explain(Map<String, Double> features) {
double totalShap = 0.15; // baseline
StringBuilder sb = new StringBuilder("风险归因:\n");
for (var entry : features.entrySet()) {
String feat = entry.getKey();
double val = entry.getValue();
ShapBinInfo bin = lookupTable.get(feat);
if (bin == null) continue;
double shapVal = bin.getShapFor(val);
totalShap += shapVal;
sb.append(String.format(" %s = %.1f → %.3f\n", feat, val, shapVal));
}
sb.append(String.format("最终风险分: %.3f", totalShap));
return sb.toString();
}
}
路径 B:SHAP 内核实时计算(训练时采样基准样本)
# 离线:保存基准样本集
shap.sample(X_train, 100).to_csv("shap_background.csv", index=False)
# 线上推理服务加载这 100 条背景样本
# 新请求到达 → 加进背景样本 → 算 SHAP → 移除
# 这就是 KernelExplainer 的 "扰动法"
实践建议:路径 A(查表法)延迟 < 1ms,适合实时风控引擎;路径 B 单次计算 50-200ms,适合异步审批场景或只在"被拒后"触发解释。
六、SHAP 的三个常见坑
坑 1:把 SHAP 值当因果推断
SHAP 告诉你 "这个特征让预测分变化了多少",不告诉你 "改了它就会改变结果"。风控里经常有这种 case:
multi_head_count=7SHAP 值很高,但用户可能是因为征信本身就差才借了 7 家——因果方向反了- SHAP 是关联不是因果,做策略调整时不能只看 SHAP 就决定"降低多头权重"
坑 2:TreeExplainer 的 version 坑
# Shap < 0.41
shap_values = explainer.shap_values(X)
# shape: (n, n_features, 2) —— 二维数组,取 [..., 1]
# Shap >= 0.41
shap_values = explainer.shap_values(X)
# shape: (n, n_features) —— 一维数组
# 但 lightgbm 模型有时仍返回 3D,用下面的兼容写法:
shap_values = explainer(X)
if isinstance(shap_values, list):
shap_values = shap_values[1] # 二分类取正类
坑 3:特征量太大时 summary_plot 是废图
特征数 > 50 时,Summary Plot 密密麻麻看不清楚。实战做法:
# 只展示 Top 15 特征
shap.summary_plot(shap_values, X_test.iloc[:2000],
max_display=15, show=False)
# 或用 Bar Plot 只看重要度(忽略方向)
shap.plots.bar(shap.Explanation(shap_values,
base_values=explainer.expected_value,
feature_names=X.columns), max_display=15)
七、总结
| 问题 | SHAP 的答案 |
|---|---|
| 哪个特征最重要? | Summary Plot |
| 这笔贷款为什么被拒? | Waterfall Plot |
| 征信分低于多少开始危险? | Dependence Plot |
| 两个特征怎么交互的? | Dependence Plot + interaction_index |
| 线上怎么给用户返回解释? | 查表法(< 1ms) |
SHAP 不是银弹——它不会告诉你因果,不会自动发现交互效应,计算成本在某些场景也偏大。但它解决了风控模型上线最致命的问题:从黑盒到可解释,从"模型说的"到"因为你的多头借贷数远高于同类客群基准"。
下一篇预告:时间窗口特征设计——近 7 天/30 天/90 天,怎么滚出稳定又有效的时序特征。