11 超越“黑箱”——监督学习总结与模型可解释性

本章会用到的数据

  • 公开下载财务报表审计意见。代码会在首次运行时下载并保存到 data/course/

  • 这些数据能做什么:解释哪些变量影响模型预测,并观察单个公司的预测理由。

  • 分析时注意:模型解释说明预测怎样形成,不自动等于因果关系。

  • 判断模型:先确认模型在较晚数据上有预测价值,再讨论变量重要性和 SHAP 结果。

【课堂核心】3 学时学习安排

学习内容 分钟
动机与先修 20
全局与局部归因 45
解释边界 30
形成性检查与反馈 20
独立练习 45
任务反馈与小结 20

【可选拓展】:未列入本表的额外 SHAP 图形和解释器细节安排课后。

本章学习目标:洞悉模型决策,超越预测

在本章结束时,我希望你们能够:

  • 比较:用同一评价标准比较样本外表现,并判断模型是否容易解释。
  • 全局解释:运用特征重要性描述拟合模型的依赖与归因排序
  • 局部解释:运用 SHAP 值分解单个预测的模型归因构成。
  • 代码实现:为复杂模型生成并解读可解释性报告。

议程概览

  1. 回顾:监督学习的核心思想与应用
  2. 比较:分别检查预测增量与透明度
  3. 全局解释:特征重要性
  4. 局部解释:SHAP值详解
  5. 实战:Python代码演练
  6. 总结与讨论

模型选择:分别检查增量与透明度

想象一个场景:一家银行利用机器学习模型来审批贷款申请,面临一个关键选择。

模型增量与检查路径分别待检查 玩具平衡秤比较两个候选实现;二者的样本外表现与检查路径均待同一规则检查,不预设复杂度、透明度或商业收益。 ? 模型B(候选实现) 检查路径待检查 表现待检查 ? 模型A(候选实现) 检查路径待检查 表现待检查

思考题:首席风险官的抉择

作为银行的首席风险官,你会选择哪个候选实现?为什么?

  • 评价模型 A:在同一最终测试集、阈值和成本矩阵上测量表现,并按统一解释规则检查输入、路径或解释接口。
  • 评价模型 B:执行完全相同的性能、校准、稳定性与合规审查,不因模型名称预设增量或透明度。
  • 结论:这是定性玩具情景,没有预设准确率或坏账金额。决策必须报告事件率、阈值、混淆成本、校准、解释与监管约束。

监督学习:温故而知新

监督学习是利用带有已知标签的数据集 \((X, y)\) 来训练模型 \(f\),使其能够对新数据进行预测。

  • \(X\): 特征变量 (Features / Predictors)
  • \(y\): 目标变量 (Target / Label)
监督学习流程图 一个展示监督学习基本流程的图表,从历史数据到模型训练,再到对新数据进行预测。 历史数据 (已标记) (特征 X, 标签 y) 机器学习模型 学习函数 f(X) ≈ y (模型训练) 新数据预测 (新 X → 预测 ŷ)

金融应用之一:回归问题 (预测连续值)

回归任务的目标是预测一个连续的数值

  • 股价预测: 根据公司财报、宏观经济指标预测未来股价。
  • 房价评估: 基于房屋面积、地理位置、市场趋势等预测房产价值。
  • 信用评分: 预测一个借款人的信用分数。
  • 波动率建模: 预测未来一段时间内某项资产的价格波动幅度。

金融应用之二:分类问题 (预测离散类别)

分类任务的目标是预测一个离散的类别

  • 贷款违约预测: 预测借款人是否会违约(是/否)。
  • 欺诈检测: 判断一笔信用卡交易是否为欺诈行为(是/否)。
  • 市场方向预测: 预测明天股市是上涨还是下跌(涨/跌)。
  • 客户流失分析: 预测一位客户是否会离开(流失/不流失)。

模型名称不能预先决定性能与透明度

同一模型家族可因实现、约束和解释接口不同而处于不同位置;样本外增量与检查是否容易解释必须分别测量。

模型选择的双轴证据清单 左右两张卡片分别列出样本外增量与检查是否容易解释需要记录的证据;模型家族不能预排高低。 样本外增量 • 固定最终测试窗口 • 预设指标与成本 • 简单基线比较 • 不确定性与稳定性 检查是否容易解释 • 输入与版本记录 • 内部路径或解释接口 • 解释稳定性 • 限制与结果不理想时如何解释 线性、树、集成与神经网络均须逐实现填证据,禁止按家族预排高低

关键概念:欠拟合 vs. 过拟合

模型的拟合程度是其泛化能力的关键。

  • 欠拟合 (Underfitting): 模型过于简单,未能捕捉到数据中的基本规律。就像用一条直线去拟合一条曲线。
  • 过拟合 (Overfitting): 模型过于复杂,把数据中的噪声也当作规律来学习,导致在新数据上表现很差。

理论支撑:偏差-方差权衡 (Bias-Variance Tradeoff)

  • 偏差 (Bias): 模型预测值与真实值之间的系统性差异。高偏差通常意味着欠拟合
  • 方差 (Variance): 模型在不同训练数据集上预测结果的变动程度。高方差通常意味着过拟合

总误差 ≈ 偏差² + 方差 + 不可约误差

偏差-方差权衡的可视化解释 三个靶子,分别展示了低偏差/低方差、低偏差/高方差(过拟合)和高偏差/低方差(欠拟合)的情况。 低偏差, 低方差(理想模型) 低偏差, 高方差(过拟合) 高偏差, 低方差(欠拟合)

可视化理解:过拟合的陷阱

下图使用固定种子生成的含噪余弦观测,只解释模型复杂度机制,不构成中国金融市场证据。每个面板同时报告训练 MSE 与对已知无噪声生成函数的独立网格 MSE。

代码
# 导入 Matplotlib,绘制模拟观测、已知生成函数和三种候选预测曲线。
import matplotlib.pyplot as plt
# 创建 `fig, axes` 画布,承载“模型复杂度与拟合效果的关系”的并排视觉比较。
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# 遍历三种候选阶数,把同源计算指标直接写入对应面板标题。
for plot_index, polynomial_degree in enumerate(candidate_degrees):
    # 选取当前子图轴,分别承载欠拟合、适度拟合与过拟合曲线。
    axis = axes[plot_index]
    # 读取当前候选的共享执行结果,避免图题与曲线分别维护数值。
    candidate_evidence = model_evidence_by_degree[polynomial_degree]
    # 绘制已知无噪声生成函数,为独立网格误差提供可见参照。
    axis.plot(independent_feature_grid, independent_true_responses, color='#005f73', linestyle='--', linewidth=1.8, label='已知生成函数')
    # 绘制当前阶数在独立网格上的预测曲线,展示训练点之间的形态。
    axis.plot(independent_feature_grid, candidate_evidence['grid_predictions'], label='模型拟合', color='crimson', linewidth=2)
    # 绘制固定种子生成的含噪观测,明确散点并非真实金融数据。
    axis.scatter(simulated_feature_observations, simulated_noisy_responses, edgecolor='#174F87', s=30, label='模拟含噪观测', facecolors='none')
    # 标注模拟特征横轴,使生成机制的输入范围清晰可见。
    axis.set_xlabel('模拟特征')
    # 标注模拟响应纵轴,使其不被误读为实际金融指标。
    axis.set_ylabel('模拟响应')
    # 为“可视化理解:过拟合的陷阱”,限定横轴范围,使比较对象使用一致尺度。
    axis.set_xlim((0, 1))
    # 为“可视化理解:过拟合的陷阱”,限定纵轴范围,使比较对象使用一致尺度。
    axis.set_ylim((-2, 2))
    # 用同一执行结果动态报告训练与网格 MSE,避免静态结论和代码输出漂移。
    axis.set_title(f"{candidate_labels[polynomial_degree]}(阶数={polynomial_degree}\n训练 MSE={candidate_evidence['train_mse']:.4f}|网格 MSE={candidate_evidence['grid_mse']:.4f}", fontsize=12)
    # 显示“可视化理解:过拟合的陷阱”图例,使颜色或线型与比较对象一一对应。
    axis.legend(loc='upper right', fontsize=9)

# 用总标题再次标明模拟属性与证据口径,防止教学机制被误读为真实经验结果。
fig.suptitle('模拟余弦机制:训练拟合不等于独立网格表现', fontsize=18)
# 为“可视化理解:过拟合的陷阱”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout(rect=[0, 0, 1, 0.96])
# 显示一阶、四阶与十五阶的模拟机制证据,比较训练误差与独立网格误差。
plt.show()
三个面板展示同一组模拟含噪余弦观测及 1、4、15 阶多项式曲线;动态图题报告训练 MSE 与独立无噪声网格 MSE,高阶模型训练误差较低但网格误差显著升高。
图 1: 模拟余弦机制中的模型复杂度、训练误差与独立网格误差

复杂模型的“黑箱”困境

随机森林、梯度提升树、神经网络等扩大了候选函数类;是否胜过简单基线,必须由同一最终测试窗口的证据判定。

黑箱模型 一个表示黑箱模型的图表,数据输入进去,预测结果出来,但内部工作原理未知。 输入数据 ? ? ? 复杂模型 预测结果

某些实现的内部路径较难直接检查,但透明度取决于具体实现与解释接口,并非复杂度的必然代价。

为什么我们需要可解释性?

  • 金融监管:许多法规(如美国的ECOA)要求对信贷决策给出明确解释。
  • 商业决策:理解“为什么”可以帮助我们发现新的商业洞察,而不仅仅是做出预测。
  • 模型调试:如果模型做出了奇怪的预测,可解释性工具可以帮助我们诊断问题所在。
  • 建立信任:无论是对客户、管理者还是合作伙伴,一个可以解释的模型更容易被信任和接受。

解决方案(一):全局特征重要性

这是最常用、最直观的解释方法,它回答的问题是:

“置换哪些特征,会使当前模型的样本外评分下降最多?”

一种常见的计算方法是置换特征重要性 (Permutation Feature Importance)

置换特征重要性的核心思想

其逻辑如下:

  1. 在固定评价样本上,按预先声明的指标(例如 R²)计算基准分数。
  2. 保持其他列不变,对某一列重复置换;每次重新评分并保存“基准分数 − 置换分数”。
  3. 报告分数下降的分布与不确定性,而不是只报一次随机置换。
  4. 高度相关的替代特征会分摊或遮蔽重要性;结果只对当前模型、样本、指标与置换机制成立,不是因果作用。

置换特征重要性:可视化流程

置换特征重要性流程图 一个分四步的流程图,解释了置换特征重要性的计算过程:计算基准分,打乱单列特征,重新评估,计算分数差值得出重要性。 1. 计算基准分数 模型在原始 测试集上评估 R² = 0.80 2. 打乱单个特征 F1F2F3F4 ABZD EFXH IJYL 3. 重新评估 使用被“破坏” 的数据集预测 新 R² = 0.65 4. 计算特征重要性 0.80 - 0.65 = 0.15

解决方案(二):SHAP值——从全局到个体的解释

全局归因报告当前模型在给定样本与背景下的依赖排序,但无法解释单个预测的归因构成。

SHAP (SHapley Additive exPlanations) 解决了这个问题,它回答:

“对于这一个特定的预测,每个特征各自贡献了多少?”

SHAP值的灵感来源:夏普里值

这个思想源于博弈论中的夏普里值 (Shapley Value)

  • 场景: 一个团队合作完成项目,获得了100万奖金。
  • 问题: 如何公平地将奖金分配给每个成员?
  • 夏普里值的思想:计算每个成员在所有可能“合作子集”中的边际贡献并取平均;该分配满足效率、对称、虚玩家与可加性等合作博弈公理,但不保证群体公平、程序公平、监管公平或规范正当性。

SHAP将这个思想应用于机器学习:特征就是“团队成员”,预测结果就是“奖金”。

SHAP值的核心公式:将预测分解

SHAP将每个样本 \(i\) 的预测值 \(f(x^{(i)})\) 分解为基准值和各个特征的贡献之和:

\[ \large{ f(x^{(i)}) = \text{base\_value} + \sum_{j=1}^{k} \text{SHAP}(x_j^{(i)}) } \]

  • base_value: 解释器在其背景分布、输出尺度与特征依赖假设下给出的期望模型输出;它不一定等于全数据预测均值。
  • SHAP(x_j^{(i)}): 第 \(j\) 个特征对于样本 \(i\) 预测的贡献值
    • 如果为,表示该特征值将预测推高
    • 如果为,表示该特征值将预测拉低

实战:解释中国上市公司年度 EPS 模型

  • 目标: 解释随机森林如何把同一年度已披露财务字段映射为基本每股收益;这是模型函数检查,不是提前预测。
  • 数据: 公开 stock/financial_statement.h5,key=financial_data;字段为公司、季度、披露日、资产、负债、营收、归母净利润、经营现金流与 EPS,样本期 2015—2024 年年度 Q4。
  • 时点边界:一条记录只有在其 info_date 后才可用于解释;2015—2021 年训练,2022—2024 年留出。若改成预测任务,必须另设决策日并确保标签晚于特征。
  • 模型:使用随机森林回归候选实现;留出增量与解释稳定性均待本次规则检查。
  • 任务:
    1. 训练模型。
    2. 计算并可视化全局特征重要性
    3. 使用SHAP来解释模型的个体和全局行为。

步骤 1:导入必要的工具库

代码
# 为“步骤 1:导入必要的工具库”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤 1:导入必要的工具库”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 导入 SHAP 并绑定为 `shap`,用于构造树模型解释器及全局、局部贡献图。
import shap
# 为“步骤 1:导入必要的工具库”,从 `sklearn.ensemble` 导入`RandomForestRegressor` 用于拟合随机森林回归器。
from sklearn.ensemble import RandomForestRegressor
# 为“步骤 1:导入必要的工具库”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“步骤 1:导入必要的工具库”,从 `IPython.display` 导入 `display`,在幻灯片中渲染表格结果。
from IPython.display import display

# 为了让matplotlib的图表更好看

步骤 2:加载并探索数据

代码
from pathlib import Path
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择财务报表文件。
financial_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/financial_statement.h5'), Path('C:/qiufei/data/stock/financial_statement.h5'), Path('data/course/financial_statement.h5')] if candidate_path.exists()), Path('data/course/financial_statement.h5'))
if not financial_path.exists():
    financial_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/financial_statement.h5', financial_path)
# 确定年度 Q4 行条件,使大型 HDF5 在读取前先按样本期过滤。
financial_quarters = [f'{year}q4' for year in range(2015, 2025)]
# 只读取公司、季度、披露日、五项解释字段与 EPS,控制大表内存占用。
financial_columns = ['order_book_id', 'quarter', 'info_date', 'total_assets', 'total_liabilities', 'operating_revenue', 'net_profit_parent_company', 'cash_from_operating_activities', 'basic_earnings_per_share']
# 从表式 HDF5 键同时选择年度行与必要列,数据获取与后续解释逻辑保持解耦。
financial_raw = pd.read_hdf(financial_path, key='financial_data', where='quarter in financial_quarters', columns=financial_columns)
# 统一披露日期类型,为版本选择与时间切分建立可复算时点。
financial_raw['info_date'] = pd.to_datetime(financial_raw['info_date'])
# 检查 HDF 行过滤只返回事先确定年度,防止季度口径静默漂移。
assert financial_raw['quarter'].isin(financial_quarters).all(), 'HDF 行筛选返回了事先确定范围外季度'
# 从季度字段提取财年,作为严格时间留出的分组变量。
financial_raw['fiscal_year'] = financial_raw['quarter'].str[:4].astype(int)
# 对公司—财年保留最后披露版本,避免同一报告修订重复进入样本。
financial_panel = financial_raw.sort_values(['order_book_id', 'fiscal_year', 'info_date']).drop_duplicates(['order_book_id', 'fiscal_year'], keep='last')
# 固定五项财务解释字段,避免把目标或披露后信息放进模型输入。
financial_features = ['total_assets', 'total_liabilities', 'operating_revenue', 'net_profit_parent_company', 'cash_from_operating_activities']
# 删除目标或输入缺失的记录,保持模型和 SHAP 使用完全相同的行。
financial_panel = financial_panel.dropna(subset=financial_features + ['basic_earnings_per_share']).copy()
# 提取公开财务特征矩阵,按亿元缩放只改善数值可读性而不改变树的排序。
X = financial_panel[financial_features].div(1e8)
# 提取同一已披露年度报告的 EPS 响应,明确本节是同期模型解释。
y = financial_panel['basic_earnings_per_share'].astype(float)
# 用 2015—2021 财年拟合候选模型,留出后续年份作解释检查。
X_train, y_train = X.loc[financial_panel['fiscal_year'].le(2021)], y.loc[financial_panel['fiscal_year'].le(2021)]
# 用 2022—2024 财年形成留出样本,不随机打乱公司—年份记录。
X_test, y_test = X.loc[financial_panel['fiscal_year'].ge(2022)], y.loc[financial_panel['fiscal_year'].ge(2022)]

# 快速浏览一下数据
print("特征数据 (X_train) 概览:")
# 将五项财务特征改用投影友好的短中文列名,只影响展示而不改变模型输入。
training_preview = X_train.head().rename(columns={'total_assets': '资产', 'total_liabilities': '负债', 'operating_revenue': '营收', 'net_profit_parent_company': '归母净利', 'cash_from_operating_activities': '经营现金流'})
# 展示短列名训练样本,核对五项指标及亿元口径。
display(training_preview)
特征数据 (X_train) 概览:
资产 负债 营收 归母净利 经营现金流
3 25071.49 23456.49 961.63 218.65 3617.44
3 29534.34 27512.63 1077.15 225.99 5032.00
3 32484.74 30264.20 1057.86 231.89 4112.04
3 34185.92 31785.50 1167.16 248.18 4362.78
3 39390.70 36260.87 1379.58 281.95 5872.14

步骤 3:训练候选集成模型

随机森林是一个非线性候选实现。在读取测试结果前确定训练均值常数基线;RF 与基线只在同一测试集比较。

代码
# 为“步骤 3:训练候选集成模型”导入 `time` 并绑定 `time`,用于记录当前任务的实际运行耗时。
import time

# 初始化随机森林回归器
# 为了在低配置服务器上更快运行,我们减少了树的数量和深度
rf_model = RandomForestRegressor(
    n_estimators=50,        # 从100减少到50棵树
    max_depth=10,           # 限制树的最大深度
    min_samples_split=10,   # 保持原设置
    min_samples_leaf=5,     # 增加叶子节点的最小样本数
    # 固定随机森林的抽样与特征选择过程,确保结果可复算。
    random_state=42,
    n_jobs=-1              # 使用所有可用CPU核心
# 完成 `rf_model` 配置,以 50 棵限深树控制课堂运行时间与过拟合风险。
)

# 在计时前提示随机森林拟合即将开始。
print("正在训练随机森林模型...")
# 记录 `start_time` 的时间戳,用于计算该步骤的实际运行耗时。
start_time = time.time()

# 使用训练数据拟合模型
rf_model.fit(X_train, y_train)

# 记录 `end_time` 的时间戳,用于计算该步骤的实际运行耗时。
end_time = time.time()
# 报告随机森林训练耗时,说明后续解释计算的额外成本基准。
print(f"模型训练完成!用时: {end_time - start_time:.2f} 秒")
正在训练随机森林模型...
模型训练完成!用时: 1.13 秒

步骤 3.1:同窗基线与不确定性

测试行 paired bootstrap 量化观测可交换假设下的抽样不确定性;它不重拟合模型,也不能替代外部或时间外验证。

代码
from sklearn.metrics import mean_squared_error, r2_score  # 用同一测试集计算候选与基线的可比指标
rf_predictions = rf_model.predict(X_test)  # 生成确定随机森林在测试行上的预测
training_mean_predictions = np.full(len(y_test), float(y_train.mean()))  # 以训练期目标均值生成事先确定常数基线
y_test_array = y_test.to_numpy(dtype=float)  # 固定测试响应的数组顺序供成对重抽样
rf_r2 = r2_score(y_test_array, rf_predictions)  # 计算随机森林测试集 R²
baseline_r2 = r2_score(y_test_array, training_mean_predictions)  # 计算训练均值基线测试集 R²
rf_mse = mean_squared_error(y_test_array, rf_predictions)  # 计算随机森林测试集 MSE
baseline_mse = mean_squared_error(y_test_array, training_mean_predictions)  # 计算训练均值基线测试集 MSE
bootstrap_rng = np.random.default_rng(2026)  # 固定重抽样随机种子以支持复算
bootstrap_rf_r2, bootstrap_delta_mse = [], []  # 保存有效重抽样的 R² 与成对 MSE 差
for bootstrap_iteration in range(1000):  # 重抽样测试行以近似当前切片的抽样不确定性
    bootstrap_index = bootstrap_rng.integers(0, len(y_test_array), len(y_test_array))  # 成对抽取响应和两组预测的同一行
    if np.ptp(y_test_array[bootstrap_index]) == 0:  # 跳过响应无变异、R² 无定义的重抽样
        continue  # 不把无定义的 R² 纳入区间
    bootstrap_rf_r2.append(r2_score(y_test_array[bootstrap_index], rf_predictions[bootstrap_index]))  # 保存当前重抽样的随机森林 R²
    bootstrap_delta_mse.append(mean_squared_error(y_test_array[bootstrap_index], rf_predictions[bootstrap_index]) - mean_squared_error(y_test_array[bootstrap_index], training_mean_predictions[bootstrap_index]))  # 保存 RF 相对基线的成对 MSE 差
assert len(bootstrap_rf_r2) >= 950 and np.isfinite(bootstrap_rf_r2).all() and np.isfinite(bootstrap_delta_mse).all()  # 拒绝有效重复不足或非有限结果
rf_r2_ci = np.quantile(bootstrap_rf_r2, [0.025, 0.975])  # 计算随机森林 R² 的百分位区间
delta_mse_ci = np.quantile(bootstrap_delta_mse, [0.025, 0.975])  # 计算 RF 减基线 MSE 的百分位区间
print({'RF测试R²': round(rf_r2, 4), '训练均值基线R²': round(baseline_r2, 4), 'RF测试MSE': round(rf_mse, 4), '训练均值基线MSE': round(baseline_mse, 4), 'RF R² paired-bootstrap 95%区间': np.round(rf_r2_ci, 4).tolist(), 'ΔMSE=RF−基线 95%区间': np.round(delta_mse_ci, 4).tolist()})  # 输出本次干净执行生成的比较证据
{'RF测试R²': 0.4502, '训练均值基线R²': -0.0008, 'RF测试MSE': 1.1945, '训练均值基线MSE': 2.1743, 'RF R² paired-bootstrap 95%区间': [0.4075, 0.5005], 'ΔMSE=RF−基线 95%区间': [-1.3212, -0.7175]}

解释边界

  • 方向\(\Delta MSE<0\) 才有利于 RF。
  • 不确定性:区间若跨 0,当前留出样本不足以支持稳定增量判断。
  • bootstrap 假设:公司—年份观测可交换;同公司时间依赖会削弱该假设。
  • 任务边界:只解释已披露同期 EPS 模型,不构成未来预测、投资或因果承诺。

步骤 4.1:全局视角 - 内置特征重要性

  • 当前指标RandomForestRegressor.feature_importances_ 报告节点回归不纯度加权下降的 MDI。
  • 默认回归口径:平方误差。
  • 分类边界:Gini 不纯度只对应分类树。
代码
# 读取已拟合随机森林的 MDI 特征重要性向量,作为全局解释的模型内指标。
importances = rf_model.feature_importances_
# 为“步骤 4.1:全局视角 - 内置特征重要性”,固定 `features` 的特征名称,使图表标签与模型输入列一一对应。
feature_display_names = {
    'total_assets': '总资产',
    'total_liabilities': '总负债',
    'operating_revenue': '营业收入',
    'net_profit_parent_company': '归母净利润',
    'cash_from_operating_activities': '经营活动现金流'
}
features = pd.Index([feature_display_names.get(name, name) for name in X_train.columns])

# 按 MDI 数值升序取得特征索引,使水平条形图从低到高排列。
indices = np.argsort(importances)

# 创建 MDI 水平条形图画布,为排序后的特征标签和条长预留空间。
plt.figure(figsize=(12, 8))
# 将图题设为“随机森林模型计算的特征重要性”,直接说明当前图形的比较目的。
plt.title('随机森林模型计算的特征重要性', fontsize=18)
# 按重要性降序绘制 MDI 水平条,条长对应每个特征的随机森林不纯度贡献。
plt.barh(range(len(indices)), importances[indices], color='#003366', align='center')
# 将纵轴刻度标签设为与排序后 MDI 数值一一对应的特征名。
plt.yticks(range(len(indices)), [features[i] for i in indices])
# 将横轴标为“相对重要性”,明确横向编码的变量。
plt.xlabel('相对重要性', fontsize=14)
# 显示排序后的 MDI 重要性条形图,识别模型依赖特征并提醒该排序不表示因果贡献。
plt.show()
水平条形图展示中国上市公司 EPS 随机森林的非负 MDI 特征重要性;条越长表示训练样本内相对不纯度下降越大,不表示作用方向或因果效应。
图 2: 随机森林模型的全局特征重要性

步骤 4.2:解读特征重要性

核心洞察:

  • 当前随机森林对资产、负债、营收、归母净利润与经营现金流的 MDI 排序由本次执行决定;相关财务字段可能分摊或替代重要性。
  • 排序只表示当前训练样本中的分裂依赖,不表示方向、因果效应或现实驱动机制。

局限性:

  • 我们只得到拟合模型的相对依赖排序,不知道各特征对具体预测的归因方向。
  • 这是一种全局解释,无法告诉我们对于某个特定公司—财年,各字段如何共同形成模型输出。

步骤 5.1:个体视角 - 初始化SHAP解释器

现在我们进入更精细的层面。我们创建一个 shap.Explainer 对象,它会封装我们的模型,为计算SHAP值做准备。

代码
# 加载JS库以在notebook中美观地显示SHAP图
shap.initjs()

# 对于随机森林,使用TreeExplainer更加高效
# 同时设置较小的背景数据集以加速计算
background_sample = X_train.sample(n=min(100, len(X_train)), random_state=42)  # 从训练期抽取不超过100行作为背景
# 基于已拟合树模型建立 `explainer`,把预测拆解为基准值与特征贡献。
explainer = shap.TreeExplainer(rf_model, background_sample)

# 打印 TreeExplainer 背景样本的行列形状,核对解释基准只取自训练数据。
print(f"使用TreeExplainer,背景数据集大小: {background_sample.shape}")

步骤 5.2:计算SHAP值

我们使用解释器来计算测试集中每个样本的SHAP值。

代码
# 为“步骤 5.2:计算SHAP值”导入 `time` 并绑定 `time`,用于记录当前任务的实际运行耗时。
import time

# 为了加快计算速度,我们只使用测试集的前500个样本
# 在配置较低的服务器上,这可以显著减少计算时间
X_test_sample = X_test.iloc[:500]

# 报告“原始测试集大小: {X_test.shape}”中的 `X_test`,核对“步骤 5.2:计算SHAP值”的样本形状。
print(f"原始测试集大小: {X_test.shape}")
# 报告“采样后测试集大小: {X_test_sample.shape}”中的 `X_test_sample`,核对“步骤 5.2:计算SHAP值”的样本形状。
print(f"采样后测试集大小: {X_test_sample.shape}")

# 记录SHAP计算时间
start_time = time.time()
# 在计时后提示采样测试集的 SHAP 计算即将开始。
print("正在计算SHAP值,请稍候...")

# 使用解释器计算采样测试集的SHAP值
shap_values = explainer(X_test_sample)
shap_values.feature_names = [feature_display_names.get(name, name) for name in X_test_sample.columns]
X_test_sample_display = X_test_sample.rename(columns=feature_display_names)

# 记录 `end_time` 的时间戳,用于计算该步骤的实际运行耗时。
end_time = time.time()
# 报告 SHAP 计算耗时,与模型训练耗时作量级比较。
print(f"SHAP计算完成!用时: {end_time - start_time:.2f} 秒")

# 打印 SHAP 解释结果的容器类型,核对当前版本返回的是解释对象而非预测数组。
print("SHAP值对象类型:", type(shap_values))
# 报告“SHAP值维度:”中的 `shap_values`,核对“步骤 5.2:计算SHAP值”的样本形状。
print("SHAP值维度:", shap_values.shape)
原始测试集大小: (15985, 5)
采样后测试集大小: (500, 5)
正在计算SHAP值,请稍候...
SHAP计算完成!用时: 1.23 秒
SHAP值对象类型: <class 'shap._explanation.Explanation'>
SHAP值维度: (500, 5)

步骤 5.3:解读SHAP可视化(1) - 瀑布图

瀑布图(Waterfall Plot)用于检查单个模型输出。我们查看留出集中第一个中国公司—财年记录的 EPS 模型分解。

代码
# 可视化测试集中第一个样本的预测解释
# shap_values[0] 表示我们只看第一个样本
shap.plots.waterfall(shap_values[0], show=False)
plt.gca().set_xlabel('SHAP 贡献值(模型输出尺度)')
plt.tight_layout()
plt.show()
瀑布图分解留出集中首个中国公司—财年的 EPS 模型输出;条长与横向位移表示各特征相对基准值的贡献,正负方向区分推高与压低预测,不作因果解释。
图 3: SHAP 瀑布图分解留出集中首个中国公司—财年的 EPS 模型输出

如何阅读瀑布图?

  • 基准值:直接读取本次 shap_values[0].base_values;它对应训练背景样本上的解释器输出,而不是硬编码常数。
  • 贡献值:直接读取本次 shap_values[0].values;正负条只表示特征把当前模型输出相对基准推高或拉低。
  • 加总检查base_value + sum(SHAP) 应与当前模型输出在同一尺度上相等;任何固定示例数字都不能替代本次运行结果。
  • 边界:瀑布图解释模型函数,不证明资产、营收或利润对 EPS 具有因果效应;同期会计恒等关系也不等于预测信息。

步骤 5.4:解读SHAP可视化(2) - 蜂群图

蜂群图(Beeswarm Plot)是SHAP最强大的可视化之一,它将所有样本的SHAP值都呈现在一张图上,提供了全局视角

代码
# 绘制蜂群图来展示采样样本的SHAP值
shap.summary_plot(shap_values.values, X_test_sample_display, show=False)
summary_figure = plt.gcf()
summary_figure.axes[0].set_xlabel('SHAP 贡献值(模型输出尺度)')
if len(summary_figure.axes) > 1:
    summary_figure.axes[-1].set_ylabel('特征值:低 → 高')
plt.tight_layout()
plt.show()
蜂群图展示当前模型的全局 SHAP 归因分布;横向位移表示相对贡献,正负方向区分推高与压低模型输出,不作因果解释。
图 4: 当前模型的全局 SHAP 归因分布

如何阅读蜂群图?

  • 归因排序: 特征按 mean(|SHAP|) 从上到下排序;最上方字段只在当前模型、背景与样本中归因绝对值最高。
  • 归因方向:每个点是一个样本;横轴位置表示该特征在当前模型、背景与输出尺度下把预测相对基准推高或拉低。
  • 特征值大小: 点的颜色表示原始特征值的大小(红色代表值高,蓝色代表值低)。
  • 当前模型模式:若某财务字段高值多对应正归因,只说明本模型与样本中的分配模式;它不能确认经济机制、结构效应或因果关系。

步骤 5.5:解读SHAP可视化(3) - 依赖图

依赖图展示特征取值与模型归因之间的条件关联;颜色分层可以提出交互假设,但普通依赖图本身不能确认统计交互。

代码
# 绘制营业收入字段的依赖图,检查模型归因随输入变化的形状。
# 关闭自动交互选择,避免把着色变量误称为已验证交互
shap.dependence_plot('营业收入', shap_values.values, X_test_sample_display, interaction_index=None, show=False)
plt.gca().set_xlabel('营业收入(亿元)')
plt.gca().set_ylabel('营业收入的 SHAP 贡献值')
plt.tight_layout()
plt.show()
横轴为营业收入(亿元)、纵轴为该特征的 SHAP 贡献;点云显示特征水平变化时模型归因的方向、非线性和离散范围。
图 5: 营业收入特征的 SHAP 依赖图

如何阅读依赖图?

  • 条件关联:横轴是以亿元计的营业收入,纵轴是它对当前模型输出的贡献;散点形状只描述当前模型和样本。
  • 不是交互检验:本图没有计算 SHAP interaction values,也没有稳定性重复,因此不得称为已识别的交互效应。
  • 不是经济规律:若要讨论结构机制,必须另行提出识别设计、计算预先声明的交互诊断并做样本外稳定性检查。

阶段小结:归因不等于因果

  • 三维取舍:预测性能、计算成本与可解释性。

  • 没有通用最优点:具体项目需求决定哪个维度更重要。

  • 全局解释是第一步:特征重要性报告当前拟合模型的依赖或归因排序,不等于现实重要性。

  • 局部归因有条件:SHAP 在给定模型、背景分布与特征表示下分配预测输出,回答“模型如何分配该预测”,不回答现实因果为什么。

  • 普通 dependence plot 不等于 interaction values,更不等于交互稳定性。

  • 沟通工具:向审批者、客户和监管者说明模型依据。

  • 风险管理工具:在金融这一高度受监管行业中,解释能力与预测准确性同样需要被评估。

课堂讨论:知识理解

  1. 请说明均方误差(MSE)和交叉熵(Cross-Entropy)损失函数分别适用于解决何种问题?
  2. 面对过拟合问题,除了我们今天提到的方法,还有哪些改善策略?(例如:正则化、数据增强、Early Stopping)
  3. 我们如何系统地确定模型的超参数(Hyperparameters),例如随机森林中的树的数量?(例如:网格搜索、随机搜索、贝叶斯优化)
  4. 在今天讨论的模型中,哪些更适合数据量较大的工作?哪些在数据量较小时表现可能更稳健?

课堂讨论答案与评分标准

  1. 损失函数与 API

    • MSE:连续回归点预测。
    • 多类交叉熵\(-\sum_k y_k\log p_k\),其中 \(p\) 是合法概率。
    • PyTorch 多分类nn.CrossEntropyLoss 直接接收 logits 和整数类别标签,内部执行 log_softmax + NLL;不要先 softmax。
    • 单 logit 二分类:通常使用内部含 sigmoid 的 BCEWithLogitsLoss;边界与第 17 章的 logits 说明一致。
  2. 正则化与 early stopping 只在训练—验证期选择;数据增强只用于训练且必须保持标签和领域语义;测试期不参与。

  3. 在开发期保存网格/随机/贝叶斯搜索的全部候选,按预设指标与 tie-break 确定;树数不能靠测试集挑。

  4. 不能仅按样本量给模型排名;还取决于维度、噪声、非线性、类不平衡与预算。小样本通常优先简单/正则化并报告不确定性,高容量模型需要更强验证证据。

加问:SHAP 值高不能证明因果作用或交互;因果需要识别设计,交互需要 interaction values、稳定性与外部验证。

课堂独立任务:审计风险与 SHAP

  • 数据:公开 stock/audit_opinion.h5stock/financial_statement.h5;不依赖仓库中不存在的 loan_default.csv
  • 预测决策时点:目标财年 6 月 30 日。
  • 标签:下一年披露的该财年审计意见。
  • 特征边界:上一财年且在决策日之前已披露的财报。

课堂独立任务:模型与全局解释

  1. 建立模型:使用梯度提升分类器 (GradientBoostingClassifier) 预测公司是否收到非标准审计意见。
  2. 全局解释:按 \(\operatorname{mean}(|\text{SHAP}|)\) 排序绝对归因,并说明该分布只对应当前模型、背景样本与输出尺度,不写成现实“影响最大”。

课堂独立任务:局部解释

  1. 局部解释
    • 选择最终测试期中的前3家公司
    • 使用 SHAP 分别生成这3家公司的瀑布图
    • 根据瀑布图,说明各特征相对解释基准如何推高或拉低模型输出;贡献仅解释模型函数,不代表现实影响或因果作用。

课堂独立任务:提交物与评分

提交物与评分标准

  • 25%:点时样本数量变化和唯一键。
  • 20%:模型与多数类同窗指标。
  • 25%:SHAP 说明与全局稳定性。
  • 20%:三家公司局部加总核对。
  • 10%:因果、样本量和失败边界。

课堂练习:先完成再查看答案:四类证据齐全后再看答案

  • 数据证据:数据时点表。
  • 性能证据:模型/基线指标表。
  • 稳定性证据:三种子稳定性表。
  • 局部证据:三家公司局部解释草图。

缺少任一类证据,或直接把 SHAP 写成现实影响,均返回任务页修订;完成前不进入参考实现。

课堂任务参考实现:构造公开样本

  • 标签编码:“非标准审计意见”设为 1。
  • 目标财年:记为 \(t\)
  • 特征来源\(t-1\) 年年报,且必须在 \(t\) 年 6 月 30 日前披露。
  • 防止泄漏:不将目标审计意见同日才可知的信息放入特征。
代码
from pathlib import Path  # 管理公开数据路径
from urllib.request import urlretrieve  # 复用本章已安装的浏览器标识下载器
import numpy as np  # 处理无穷比率
import pandas as pd  # 合并审计意见与财务报表
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择股票数据根目录。
stock_data_root = next((candidate_root for candidate_root in [Path('/home/ubuntu/r2_data_mount/data/stock'), Path('C:/qiufei/data/stock'), Path('data/course')] if candidate_root.exists()), Path('data/course'))
# 将审计意见文件定位到已选择的股票数据根目录。
audit_path = stock_data_root / 'audit_opinion.h5'
# 将财务报表文件定位到已选择的股票数据根目录。
financial_path = stock_data_root / 'financial_statement.h5'
# 在读者环境缺少数据根目录时创建项目缓存目录。
stock_data_root.mkdir(parents=True, exist_ok=True)
if not audit_path.exists():
    urlretrieve('https://assets.qiufei.site/data/stock/audit_opinion.h5', audit_path)
if not financial_path.exists():
    urlretrieve('https://assets.qiufei.site/data/stock/financial_statement.h5', financial_path)
audit_opinions = pd.read_hdf(audit_path, key='audit_opinion')
financial_columns = ['order_book_id', 'quarter', 'info_date', 'total_assets', 'total_liabilities', 'operating_revenue', 'net_profit_parent_company']  # 限定财务字段
audit_opinions['info_date'] = pd.to_datetime(audit_opinions['info_date'])  # 统一审计意见披露日类型
audit_opinions = audit_opinions.query("type == 'financial_statements' and quarter.str.endswith('q4')", engine='python').copy()  # 仅保留年度财报审计意见
audit_opinions['modified_opinion'] = audit_opinions['opinion_type'].ne('unqualified').astype(int)  # 构造风险标签
audit_opinions['target_year'] = audit_opinions['quarter'].str[:4].astype(int)  # 从目标季度提取财年
audit_opinions['decision_date'] = pd.to_datetime(audit_opinions['target_year'].astype(str) + '-06-30')  # 固定财年年中的预测时点
audit_opinions['label_deadline'] = pd.to_datetime((audit_opinions['target_year'] + 1).astype(str) + '-06-30')  # 确定次年六月末标签可得窗口
late_label_count = int((audit_opinions['info_date'] > audit_opinions['label_deadline']).sum())  # 统计晚于标签窗口的披露记录
audit_opinions = audit_opinions.query('info_date <= label_deadline').copy()  # 排除不能按题设及时观察的审计标签
duplicate_label_count = int(audit_opinions.duplicated(['order_book_id', 'target_year'], keep=False).sum())  # 统计公司财年重复标签记录
audit_opinions = audit_opinions.sort_values(['order_book_id', 'target_year', 'info_date']).drop_duplicates(['order_book_id', 'target_year'], keep='last')  # 保留窗口内最后披露版本
assert not audit_opinions.duplicated(['order_book_id', 'target_year']).any(), '审计标签键仍不唯一'  # 强制公司财年唯一性

课堂任务参考实现:点时合并与唯一键

代码
annual_quarters = sorted((audit_opinions['target_year'] - 1).astype(str).add('q4').unique().tolist())  # 只读取可能进入特征的年度季度
financial_reports = pd.read_hdf(financial_path, key='financial_data', where='quarter in annual_quarters', columns=financial_columns)  # 按年度季度选择性读取财务列
financial_reports['info_date'] = pd.to_datetime(financial_reports['info_date'])  # 统一财报披露日类型
financial_reports['target_year'] = financial_reports['quarter'].str[:4].astype(int) + 1  # 将上一财年对齐到下一目标财年
audit_financial_panel = audit_opinions.merge(financial_reports, on=['order_book_id', 'target_year'], suffixes=('_audit', '_financial'))  # 按公司与相邻财年合并
audit_financial_panel = audit_financial_panel.query('info_date_financial <= decision_date').copy()  # 只保留决策日前已披露信息
audit_financial_panel['debt_ratio'] = audit_financial_panel['total_liabilities'] / audit_financial_panel['total_assets']  # 构造负债率
audit_financial_panel['net_margin'] = audit_financial_panel['net_profit_parent_company'] / audit_financial_panel['operating_revenue']  # 构造净利率
audit_financial_panel = audit_financial_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=['debt_ratio', 'net_margin', 'modified_opinion'])  # 清除无效比率
audit_financial_panel = audit_financial_panel.sort_values(['order_book_id', 'target_year', 'info_date_financial']).drop_duplicates(['order_book_id', 'target_year'], keep='last')  # 同一公司财年仅保留最后可得特征版本
assert not audit_financial_panel.duplicated(['order_book_id', 'target_year']).any(), '建模面板键仍不唯一'  # 防止重复公司财年泄漏权重
print({'文件': ['audit_opinion.h5', 'financial_statement.h5'], '键': ['audit_opinion', 'financial_data'], '晚披露排除': late_label_count, '重复记录': duplicate_label_count})  # 输出数据说明与排除数量
print(audit_financial_panel[['target_year', 'info_date_financial', 'decision_date', 'info_date_audit']].head())  # 检查三类时点顺序
print({'样本期': (int(audit_financial_panel['target_year'].min()), int(audit_financial_panel['target_year'].max())), '公司数': int(audit_financial_panel['order_book_id'].nunique()), '样本数': len(audit_financial_panel)})  # 输出样本覆盖
print(audit_financial_panel['modified_opinion'].value_counts().sort_index())  # 报告类别计数
{'文件': ['audit_opinion.h5', 'financial_statement.h5'], '键': ['audit_opinion', 'financial_data'], '晚披露排除': 10329, '重复记录': 12}
      target_year info_date_financial decision_date info_date_audit
178          2020          2020-05-29    2020-06-30      2021-04-30
1017         2024          2024-04-30    2024-06-30      2025-04-30
1981         2022          2022-04-29    2022-06-30      2023-03-31
2247         2022          2022-04-30    2022-06-30      2023-04-28
2268         2022          2022-04-30    2022-06-30      2023-04-26
{'样本期': (2016, 2025), '公司数': 40, '样本数': 41}
modified_opinion
0    10
1    31
Name: count, dtype: int64

课堂任务参考实现:模型、SHAP 与判断标准

代码
import shap  # 计算树模型的局部贡献
from sklearn.ensemble import GradientBoostingClassifier  # 建立梯度提升分类基准
from sklearn.metrics import average_precision_score, balanced_accuracy_score, confusion_matrix  # 报告不平衡分类指标
from sklearn.utils.class_weight import compute_sample_weight  # 平衡训练期少数类权重
feature_names = ['debt_ratio', 'net_margin']  # 固定可解释特征顺序
audit_feature_display_names = {'debt_ratio': '负债率', 'net_margin': '净利率'}
split_year = audit_financial_panel['target_year'].quantile(0.8, interpolation='lower')  # 按完整财年确定最终测试边界
training_panel = audit_financial_panel.query('target_year <= @split_year')  # 较早完整财年用于训练
testing_panel = audit_financial_panel.query('target_year > @split_year')  # 较晚完整财年作为最终测试期
assert len(training_panel) > 0 and len(testing_panel) > 0, '训练期或最终测试期为空'  # 在窗口无样本时明确停止
assert testing_panel['order_book_id'].nunique() >= 3, '最终测试期不足三家不同公司,无法兑现三项局部解释'  # 按唯一公司而非公司年行数自查对象数量
explanation_positions = testing_panel.reset_index(drop=True).drop_duplicates('order_book_id').index[:3].tolist()  # 固定三家不同公司的首条最终测试观测位置
assert training_panel['modified_opinion'].nunique() == 2 and testing_panel['modified_opinion'].nunique() == 2, '窗口必须同时包含两类标签'  # 避免输出不可定义指标
audit_risk_model = GradientBoostingClassifier(random_state=42)  # 固定模型随机状态
training_weights = compute_sample_weight('balanced', training_panel['modified_opinion'])  # 只从训练期估计类别权重
audit_risk_model.fit(training_panel[feature_names], training_panel['modified_opinion'], sample_weight=training_weights)  # 仅在训练期拟合
testing_probabilities = audit_risk_model.predict_proba(testing_panel[feature_names])[:, 1]  # 生成最终测试期风险概率
testing_predictions = (testing_probabilities >= 0.5).astype(int)  # 使用预先固定的0.5阈值分类
majority_predictions = np.repeat(training_panel['modified_opinion'].mode().iloc[0], len(testing_panel))  # 建立训练期多数类基准

公开 SHAP:样本数量变化、性能与解释器说明

  • 总样本:41 个公司年。
  • 训练/测试:35 / 6 个公司年。
  • 解释边界:以下蜂群图只是六个观测的诊断图,不是稳定的总体特征排序。
代码
print({'目标年': sorted(audit_financial_panel['target_year'].unique().tolist()), '切分年': int(split_year), '固定阈值': 0.5})  # 输出窗口和确定阈值
print({'样本数量变化': {'点时合并后': len(audit_financial_panel), '训练公司年': len(training_panel), '最终测试公司年': len(testing_panel)}, '公司数': audit_financial_panel['order_book_id'].nunique()})  # 明示小样本证据边界
print({'训练类别': training_panel['modified_opinion'].value_counts().sort_index().to_dict(), '测试类别': testing_panel['modified_opinion'].value_counts().sort_index().to_dict()})  # 输出两窗口类别分布
print('模型/多数类平衡准确率:', balanced_accuracy_score(testing_panel['modified_opinion'], testing_predictions), balanced_accuracy_score(testing_panel['modified_opinion'], majority_predictions))  # 比较排序外的分类表现
print('PR-AUC:', average_precision_score(testing_panel['modified_opinion'], testing_probabilities))  # 报告少数类排序表现
print('混淆矩阵:\n', confusion_matrix(testing_panel['modified_opinion'], testing_predictions))  # 报告误报与漏报
audit_background = training_panel[feature_names].sample(min(200, len(training_panel)), random_state=42)  # 固定训练背景分布
audit_explainer = shap.TreeExplainer(audit_risk_model, audit_background, model_output='raw', feature_perturbation='interventional')  # 在原始模型输出尺度解释条件贡献
audit_shap_values = audit_explainer(testing_panel[feature_names])  # 计算完整最终测试期解释对象
audit_shap_values.feature_names = [audit_feature_display_names[name] for name in feature_names]
print({'SHAP对象': type(audit_shap_values).__name__, '背景样本': len(audit_background), '输出尺度': 'raw', '扰动语义': 'interventional'})  # 报告解释器说明
{'目标年': [2016, 2020, 2022, 2023, 2024, 2025], '切分年': 2024, '固定阈值': 0.5}
{'样本数量变化': {'点时合并后': 41, '训练公司年': 35, '最终测试公司年': 6}, '公司数': 40}
{'训练类别': {0: 7, 1: 28}, '测试类别': {0: 3, 1: 3}}
模型/多数类平衡准确率: 0.6666666666666666 0.5
PR-AUC: 0.8666666666666667
混淆矩阵:
 [[1 2]
 [0 3]]
{'SHAP对象': 'Explanation', '背景样本': 35, '输出尺度': 'raw', '扰动语义': 'interventional'}

公开 SHAP:预设随机种子稳定性检查

代码
stability_rows = []  # 收集预先固定种子下的归因摘要
for stability_seed in [7, 42, 2026]:  # 用三组预设种子重复训练而不改测试窗
    stability_model = GradientBoostingClassifier(random_state=stability_seed)  # 只改变模型训练种子
    stability_model.fit(training_panel[feature_names], training_panel['modified_opinion'], sample_weight=training_weights)  # 复用同一训练样本与权重
    stability_background = training_panel[feature_names].sample(min(25, len(training_panel)), random_state=stability_seed)  # 只从训练期重抽背景
    stability_explainer = shap.TreeExplainer(stability_model, stability_background, model_output='raw', feature_perturbation='interventional')  # 保持原始输出尺度
    stability_values = stability_explainer(testing_panel[feature_names]).values  # 对同一六个最终测试观测计算归因
    for feature_position, feature_name in enumerate(feature_names):  # 逐特征记录方向与平均绝对贡献
        stability_rows.append({'随机种子': stability_seed, '财务指标': audit_feature_display_names[feature_name], '平均贡献': stability_values[:, feature_position].mean(), '平均绝对贡献': np.abs(stability_values[:, feature_position]).mean()})
stability_table = pd.DataFrame(stability_rows)  # 形成种子×特征结果表
stability_table['排序'] = stability_table.groupby('随机种子')['平均绝对贡献'].rank(ascending=False, method='dense')
display(stability_table.round(5))  # 如实展示排序或方向是否随种子变化
表 1: 三组训练种子下两项财务特征的SHAP方向与排序
随机种子 财务指标 平均贡献 平均绝对贡献 排序
0 7 负债率 -1.51457 3.77613 2.0
1 7 净利率 1.13641 4.09486 1.0
2 42 负债率 -1.97082 3.62729 1.0
3 42 净利率 -0.07453 3.58190 2.0
4 2026 负债率 -0.65028 3.83345 2.0
5 2026 净利率 1.87752 4.72909 1.0

只有三组种子的方向和排序一致时,才可写“在这六个最终测试观测中较稳定”;无论结果如何,都不得将其推广为 A 股总体重要性。

公开 SHAP:最终测试期全局分布

代码
shap.plots.beeswarm(audit_shap_values, show=False)  # 展示最终测试期特征归因分布
audit_global_figure = plt.gcf()
audit_global_figure.axes[0].set_xlabel('SHAP 贡献值(模型输出尺度)')
if len(audit_global_figure.axes) > 1:
    audit_global_figure.axes[-1].set_ylabel('特征值:低 → 高')
plt.tight_layout()  # 收紧图形边界以适应投影画布
plt.show()  # 输出一幅全局解释图
蜂群图按平均绝对归因排列负债率与净利率;横轴是原始模型输出尺度的贡献,颜色编码特征值,图形只解释当前模型。
图 6: 公开审计风险模型最终测试期的全局 SHAP 分布

公开 SHAP:前三家公司局部解释(1/3)

代码
company_position = explanation_positions[0]  # 固定第一家不同公司的最终测试期解释对象
company_record = testing_panel.iloc[company_position]  # 读取公司、目标年与真实标签
company_raw_output = float(audit_risk_model.decision_function(testing_panel[feature_names].iloc[[company_position]])[0])  # 取得与SHAP相同的原始对数几率尺度
company_shap_total = float(audit_shap_values.base_values[company_position] + audit_shap_values.values[company_position].sum())  # 在raw尺度累加基准与贡献
display(pd.DataFrame({'财务指标': audit_shap_values.feature_names, 'SHAP 贡献值': audit_shap_values.values[company_position]}).round(5))
shap.plots.waterfall(audit_shap_values[company_position], show=False)  # 绘制第一家公司贡献分解
plt.gca().set_xlabel('SHAP 贡献值(模型输出尺度)')
plt.tight_layout()  # 收紧当前瀑布图边界
plt.show()  # 单独输出第一幅局部解释图
财务指标 SHAP 贡献值
0 负债率 -8.60245
1 净利率 -8.05981
(a) 最终测试期第一家公司审计风险模型的局部贡献
瀑布图从训练背景基准出发,依次累加负债率与净利率贡献至第一家公司的原始模型输出;不作因果解释。
(b)
图 7

公开 SHAP:前三家公司局部解释(2/3)

代码
company_position = explanation_positions[1]  # 固定第二家不同公司的最终测试期解释对象
company_record = testing_panel.iloc[company_position]  # 读取公司、目标年与真实标签
company_raw_output = float(audit_risk_model.decision_function(testing_panel[feature_names].iloc[[company_position]])[0])  # 取得与SHAP相同的原始对数几率尺度
company_shap_total = float(audit_shap_values.base_values[company_position] + audit_shap_values.values[company_position].sum())  # 在raw尺度累加基准与贡献
display(pd.DataFrame({'财务指标': audit_shap_values.feature_names, 'SHAP 贡献值': audit_shap_values.values[company_position]}).round(5))
shap.plots.waterfall(audit_shap_values[company_position], show=False)  # 绘制第二家公司贡献分解
plt.gca().set_xlabel('SHAP 贡献值(模型输出尺度)')
plt.tight_layout()  # 收紧当前瀑布图边界
plt.show()  # 单独输出第二幅局部解释图
财务指标 SHAP 贡献值
0 负债率 5.27068
1 净利率 -1.01159
(a) 最终测试期第二家公司审计风险模型的局部贡献
瀑布图从同一训练背景基准累加两项财务特征贡献至第二家公司的模型输出,便于与第一家公司比较。
(b)
图 8

公开 SHAP:前三家公司局部解释(3/3)

代码
company_position = explanation_positions[2]  # 固定第三家不同公司的最终测试期解释对象
company_record = testing_panel.iloc[company_position]  # 读取公司、目标年与真实标签
company_raw_output = float(audit_risk_model.decision_function(testing_panel[feature_names].iloc[[company_position]])[0])  # 取得与SHAP相同的原始对数几率尺度
company_shap_total = float(audit_shap_values.base_values[company_position] + audit_shap_values.values[company_position].sum())  # 在raw尺度累加基准与贡献
display(pd.DataFrame({'财务指标': audit_shap_values.feature_names, 'SHAP 贡献值': audit_shap_values.values[company_position]}).round(5))
shap.plots.waterfall(audit_shap_values[company_position], show=False)  # 绘制第三家公司贡献分解
plt.gca().set_xlabel('SHAP 贡献值(模型输出尺度)')
plt.tight_layout()  # 收紧当前瀑布图边界
plt.show()  # 单独输出第三幅局部解释图
财务指标 SHAP 贡献值
0 负债率 -1.64770
1 净利率 5.18223
(a) 最终测试期第三家公司审计风险模型的局部贡献
瀑布图展示第三家公司两项财务特征相对同一背景基准的正负贡献及最终模型输出。
(b)
图 9

课堂独立任务反馈

完整答案标准

  • 时点与样本:数据键、目标财年、财报披露日、决策日、41→35/6 样本数量变化和类别分布。
  • 性能:模型/多数类平衡准确率、PR-AUC、固定阈值和混淆矩阵。
  • 稳定性:三组随机种子结果。
  • 局部核对:前三家公司 raw 输出、raw 基准加贡献、加总残差和单独预测概率。
  • 解释边界:SHAP 解释模型函数,而非真实因果机制。
  • 单一类别失败路径:PR-AUC 与平衡准确率不可回答;向前扩展测试窗口,不改用随机切分。

感谢聆听 & Q&A

本章小结

  • 能在最终测试样本上比较模型、置换重要性与 SHAP 稳定性,并核对局部加总。
  • 解释依赖于模型、背景分布、特征表示和输出尺度。
  • 本章不建立因果效应,也不把普通 dependence plot 当交互证据。
  • 下一章进入降维,因为高维表示必须在折内拟合并用样本外任务检验。