AI可解释性折腾手记
上线前老板问"这个用户为什么被标记为高风险",我只能答"模型输出概率 0.87",被追着问了三天,最后不得不去看特征重要性和 SHAP 值。
最开始觉得模型效果好不好看 AUC、F1 就行了,后来被现实教育了两次。
先说说为什么这事非做不可
最开始觉得模型效果好不好看 AUC、F1 就行了,后来被现实教育了两次。
第一次是做风控,模型准确率 95% 以上,但业务方拒绝上线。理由很直接:你能解释清楚为什么标记这个人风险高吗?不是光看概率,要看是哪个特征起了作用,哪几个特征组合触发了阈值。
第二次是做推荐,用户投诉说"为什么总是给我推这类东西"。产品经理要我解释推荐逻辑,翻了半天日志才发现某几个隐含特征权重过高,导致推荐内容过度聚集。
这两次之后才算明白,模型可解释性不是学术题,是上线必备。
从特征重要性开始
最直接的方法是看特征重要性,但这里面坑不少。
from sklearn.ensemble import RandomForestClassifier
import shap
import numpy as np
# 先看传统的特征重要性
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
importances = rf.feature_importances_
feature_names = X_train.columns
# 这个数字看起来很漂亮
for name, imp in sorted(zip(feature_names, importances), key=lambda x: -x[1]):
print(f"{name}: {imp:.4f}")
跑出来结果:年龄: 0.234、收入: 0.198、学历: 0.156,看着很清晰。
问题很快就暴露了。有一天发现模型的某个特征重要性突然飙升,追踪过去发现特征工程里把同一组数据用不同方式加工了三次,模型就学到了同一个信息的不同表达形式。
坑一:特征相关性会扭曲重要性。两个高度相关的特征会互相"分走"重要性,导致单独看都很低,组合起来其实很高。
解决方案:先做特征相关性检查,把相关系数大于 0.85 的特征合并或删除。
import pandas as pd
correlation_matrix = X_train.corr().abs()
upper_triangle = correlation_matrix.where(
np.triu(np.ones(correlation_matrix.shape), k=1).astype(bool)
)
# 找出高度相关的特征对
high_correlation_pairs = []
for col in upper_triangle.columns:
for row in upper_triangle.index:
if upper_triangle.loc[row, col] > 0.85:
high_correlation_pairs.append((row, col, upper_triangle.loc[row, col]))
print("高度相关的特征对:")
for row, col, val in high_correlation_pairs:
print(f"{row} - {col}: {val:.3f}")
SHAP 的实际使用
特征重要性只能告诉你"哪些特征重要",SHAP 能告诉你"某个特征在某个样本上起了多大作用"。
# 用 TreeExplainer 处理树模型
explainer = shap.TreeExplainer(rf)
shap_values = explainer.shap_values(X_test)
# 看单个样本的解释
sample_idx = 42
shap.force_plot(
explainer.expected_value[1],
shap_values[1][sample_idx],
X_test.iloc[sample_idx],
feature_names=feature_names,
matplotlib=True
)
SHAP 真的好用,但第一次在生产环境跑的时候踩了个大坑。
模型是 XGBoost,数据量 100 万行,单样本 SHAP 解释花了 3 秒多。用户界面要求 500ms 内返回,直接超时。
坑二:SHAP 计算慢,特别是在大模型上。
解决方案:用 KernelExplainer 或采样近似,但损失精度;或者预计算常用特征组合的 SHAP 值。
# 采样近似来加速
background_data = shap.sample(X_train, 100) # 只用 100 个样本作为背景
explainer = shap.KernelExplainer(rf.predict_proba, background_data)
# 对单个样本解释
sample = X_test.iloc[[sample_idx]]
shap_values = explainer.shap_values(sample, nsamples=50) # 减少采样次数
后来还发现一个问题:SHAP 值对特征顺序敏感。两个特征高度相关,解释结果会因为先解释哪个而完全不同。
坑三:SHAP 在特征高度相关时不稳定。
这时候只能做取舍:要么接受这个不确定性,要么提前做特征去相关。
LIME 的实战
LIME 更偏向局部解释,在单个样本上解释能力不错。
from lime.lime_tabular import LimeTabularExplainer
explainer = LimeTabularExplainer(
X_train.values,
feature_names=feature_names,
class_names=['low_risk', 'high_risk'],
mode='classification'
)
# 解释单个样本
exp = explainer.explain_instance(
X_test.iloc[sample_idx].values,
rf.predict_proba,
num_features=5
)
exp.show_in_notebook()
LIME 有个明显问题:每次解释结果都不完全一样。因为它在局部用线性模型近似,采样是随机的。
坑四:LIME 结果不稳定,需要多次运行取平均。
# 多次运行取平均
results = []
for i in range(10):
exp = explainer.explain_instance(
X_test.iloc[sample_idx].values,
rf.predict_proba,
num_features=5
)
results.append(exp.as_list())
# 汇总平均贡献
feature_contributions = {}
for result in results:
for feature, contribution in result:
if feature not in feature_contributions:
feature_contributions[feature] = []
feature_contributions[feature].append(contribution)
for feature, contributions in feature_contributions.items():
print(f"{feature}: {np.mean(contributions):.4f} ± {np.std(contributions):.4f}")
LIME 还有个坑:参数调整很敏感。num_features 设小了会漏掉关键信息,设大了会引入噪音。
注意力可视化(如果是 NLP 模型)
做文本分类时用过注意力可视化,能直接看到模型关注了哪些词。
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
tokenizer = AutoTokenizer.from_pretrained('bert-base-chinese')
model = AutoModelForSequenceClassification.from_pretrained('bert-base-chinese')
text = "用户投诉产品质量问题,要求退款"
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits
attention_weights = outputs.attentions[-1] # 最后一层的注意力
# 可视化注意力权重
# 这部分需要自己写可视化代码,或者用 bertviz
注意力可视化有个深层问题:高注意力不一定等于高重要性。有时候模型只是习惯性地关注某些词,比如"的"、“了"这种虚词,但它们对预测结果影响不大。
坑五:注意力权重 ≠ 特征重要性。
后来学乖了,做注意力可视化前先看整体分布,过滤掉那种全文本都高频关注的词。
实际项目中的组合使用
没有一个方法能解决所有问题,最后在项目里是这样组合的:
- 开发阶段:用特征重要性做初步筛选,快速去掉明显没用的特征
- 调参阶段:用 SHAP 看单个样本的解释,帮助调试模型
- 上线准备:用 LIME 准备典型场景的解释案例,给业务方演示
- 监控阶段:定期跑 SHAP 摘要图,看特征贡献是否稳定
# 定期检查特征贡献稳定性
def check_stability(model, X_test, explainer, num_samples=100):
shap_values_list = []
for i in range(5): # 运行 5 次
indices = np.random.choice(len(X_test), num_samples, replace=False)
sample_X = X_test.iloc[indices]
shap_values = explainer.shap_values(sample_X)
shap_values_list.append(shap_values)
# 计算方差
shap_array = np.array(shap_values_list)
variances = np.var(shap_array, axis=0)
return variances
还没解决的问题
有些问题现在也还在探索:
类别不平衡:负样本只占 1%,模型基本都预测负类,解释的时候发现模型对负类特征的贡献极小,解释结果看起来像"模型没学到什么”,但实际上只是预测太保守。
时间序列特征:用户行为是序列数据,SHAP 只能解释静态特征,怎么把时间维度纳入解释还在摸索。
多模态模型:图文结合的模型,如何统一解释图像和文本特征对预测的贡献?
隐私保护:解释有时候会暴露敏感信息(比如模型学到了"来自特定 IP 段"这个特征),怎么在不泄露隐私的情况下提供解释?
写在后面
可解释性不是银弹,有时候模型效果好就先上线,解释慢慢补。但如果你要跟业务方解释清楚"为什么模型这么判",最好提前准备好解释工具和案例。
别等到上线那天被问"用户 A 为什么被拒"时才想起来看 SHAP 图。
版权声明: 本文首发于 指尖魔法屋-AI可解释性折腾手记(https://blog.thinkmoon.cn/post/178-ai-explainability-from-blackbox-to-transparent/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。