import numpy as np # 数值计算基础库
import pandas as pd # 表格数据处理库
import matplotlib.pyplot as plt # 数据可视化绑定
from sklearn.tree import DecisionTreeRegressor, plot_tree # 回归树模型与可视化
from sklearn.model_selection import train_test_split # 训练/测试集划分
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC'] # 使用项目规定的可复现中文字体
plt.rcParams['axes.unicode_minus'] = False # 配置全局绑图参数
tree_rng = np.random.default_rng(42)
tree_simulation = pd.DataFrame({
'Margin': tree_rng.uniform(0.01, 0.20, 1000),
'Turnover': tree_rng.uniform(0.2, 2.0, 1000)
})
tree_simulation['Response'] = (
2 * tree_simulation['Margin'] * tree_simulation['Turnover']
+ tree_rng.normal(0, 0.01, len(tree_simulation))
)
features_matrix = tree_simulation[['Margin', 'Turnover']]
target_roe = tree_simulation['Response']8 基于树的方法
8.1 本章学习契约
- 先修:分类指标、时间切分、Bootstrap 与第 6—7 章的基线/验证合同。
- 目标 O8.1:从分裂准则与代价复杂度目标解释树的生长和剪枝,并只在训练期选择
ccp_alpha。 - 目标 O8.2:推导 Bagging 平均的方差,解释随机森林特征子抽样如何降低树间相关性。
- 目标 O8.3:构造冻结的未来 20 日最大回撤标签与
label_date,使未知末端保持缺失并从建模样本排除。 - 目标 O8.4:在同一未来测试期比较事件率基线、剪枝树、随机森林和 Boosting 的 AUC/Brier。
- 目标 O8.5:输出重要性并将其限定为预测分裂贡献,不解释成因果作用。
入口检查(先作答再展开):若 Ret.shift(-1) 的末行是缺失,为什么不能直接做 (x > 0).astype(int)?
展开入口检查答案与补修路径
缺失比较会成为 False 并被编码为 0,伪造未知负类;应先保存未来值和有效掩码,再生成可空标签。答错者先运行章末纯内存三行探针,再进入案例。
低风险检索:闭卷写出 OOB 样本的定义,并说明 Bagging 与随机森林的唯一关键差别。
渐隐链:正文演示可空末端标签;项目题消费 M02 manifest 与 M05 折审计;练习 8 要求独立扩展特征集和滚动稳定性审计。
陌生迁移:把下一期亏损迁移到下一期现金流为负,重新声明连续期、基线、剪枝路径和失败条件。
本章介绍用于回归和分类的基于树的方法。这些方法通过将预测变量空间分层或分割成若干简单区域来进行。为了对给定观测进行预测,我们通常使用该观测所属区域中训练观测的响应值均值或众数。由于用于分割预测变量空间的规则集可以总结为一棵树,这类方法被称为决策树方法。
基于树的方法简单且易于解释。然而,在预测精度方面,它们通常无法与第6章和第7章中看到的最佳监督学习方法相媲美。因此,本章我们还介绍了Bagging、随机森林、Boosting和贝叶斯加性回归树。这些方法都涉及产生多棵树,然后将其组合以产生单一的共识预测。我们将看到,组合大量树通常可以显著提高预测精度,代价是解释性有所降低。
8.2 决策树的基础
决策树可以同时应用于回归和分类问题。我们首先考虑回归问题,然后转向分类。
8.2.1 回归树
为了激发对回归树的学习,我们从一个金融分析的例子开始。
受控恒等式演示:树如何重构杜邦关系(不得解释为预测)
我们使用同一期 A 股财务数据,根据销售净利率与资产周转率重构 ROE。这些量共享净利润、收入与资产等会计构成项,因此本例只展示树的分段规则如何逼近乘法恒等关系,不能作为未来 ROE 的预测证据。真正的预测任务必须使用预测时点可得、且不由目标代数构成的特征,并把标签放在下一报告期。
我们首先加载数据并选取部分具有代表性的公司(例如制造业公司)。
先用受控模拟说明树的分段常数表示。净利率和周转率独立生成,响应按已知乘法函数 \(Y=2\times\text{Margin}\times\text{Turnover}+\varepsilon\) 生成。这里的目标是观察树如何切分特征空间,不是企业 ROE 预测或金融经验发现。
接下来,拟合回归树并可视化其决策规则。
# 拟合回归树(限制深度以便可视化)
regression_tree_model = DecisionTreeRegressor(max_depth=2, random_state=42) # 创建深度为2的回归树模型
regression_tree_model.fit(features_matrix, target_roe) # 在特征矩阵和ROE目标上训练模型
# 绘制决策树结构图
plt.figure(figsize=(12, 8)) # 设置画布大小
plot_tree(regression_tree_model, feature_names=['净利率', '周转率'], filled=True, rounded=True, # 绘制树结构,按预测值填色
fontsize=10, impurity=False, precision=3) # 隐藏不纯度指标,保留3位小数
plt.title('受控模拟:回归树逼近乘法响应面', fontsize=14)
plt.show() # 显示图表
按照树的比喻,区域\(R_1, R_2, R_3\)被称为树的终端节点或叶子。在 图 8.1 中,我们可以看到树是如何根据”净利率”和”周转率”将公司分层的。
例如,典型的分割规则可能是:
- 如果 净利率 <= 0.05,则预测 ROE 较低。
- 如果 净利率 > 0.05 且 周转率 > 0.8,则预测 ROE 较高。
这些规则只是在逼近预先写入的乘法数据生成过程,说明树的可读性与分段常数性质;它们不是企业筛选规则。
8.2.1.1 通过特征空间分层进行预测
我们现在讨论构建回归树的过程。粗略地说,有两个步骤:
我们将预测变量空间——即\(X_1, X_2, \ldots, X_p\)的可能值集合——分成\(J\)个不同且不重叠的区域,\(R_1, R_2, \ldots, R_J\)。
对于落入区域\(R_j\)的每个观测,我们进行相同的预测,即使用\(R_j\)中训练观测的响应均值。
例如,假设在步骤1中我们得到两个区域\(R_1\)和\(R_2\),第一个区域中训练观测的响应均值为10,第二个区域为20。那么对于给定观测\(X = x\),如果\(x \in R_1\),我们预测值为10;如果\(x \in R_2\),我们预测值为20。
如何构建区域?理论上,区域可以是任何形状。然而,我们选择将预测变量空间分成高维矩形或盒子,为了简单和便于解释所得到的预测模型。目标是找到盒子\(R_1, \ldots, R_J\),使残差平方和(RSS)最小化:
\[ \sum_{j=1}^{J} \sum_{i \in R_j} (y_i - \hat{y}_{R_j})^2 \tag{8.1}\]
其中\(\hat{y}_{R_j}\)是第\(j\)个盒子中训练观测的响应均值。
不幸的是,考虑将特征空间分成\(J\)个盒子的每种可能分区在计算上是不可行的。因此,我们采用一种自上而下、贪婪的方法,称为递归二元分割。该方法之所以是自上而下的,是因为它从树的顶部开始(此时所有观测属于单个区域),然后依次分割预测变量空间;每次分割由树下方两个新分支指示。它是贪婪的,因为在树构建过程的每一步,都会做出在该特定步骤最佳分割,而不是向前看并选择在未来的某个步骤中导致更好的树的分割。
提示:递归二元分割的直观理解
递归二元分割就像是在玩一个”20个问题”的游戏。在游戏中,你可以通过问最多20个是非题来猜出对方在想什么。为了最快地猜出答案,你应该在每个问题中尽可能地缩小可能性范围。类似地,递归二元分割在每个节点选择能够最大程度减少不确定性的分割。
这个过程的数学基础是:对于任何预测变量\(X_j\)和切割点\(s\),我们定义两个半平面: \[ R_1(j, s) = \{X | X_j < s\} \quad \text{和} \quad R_2(j, s) = \{X | X_j \geq s\} \]
我们寻求使下式最小化的\(j\)和\(s\): \[ \sum_{i:x_i \in R_1(j, s)} (y_i - \hat{y}_{R_1})^2 + \sum_{i:x_i \in R_2(j, s)} (y_i - \hat{y}_{R_2})^2 \]
其中\(\hat{y}_{R_1}\)和\(\hat{y}_{R_2}\)分别是\(R_1(j, s)\)和\(R_2(j, s)\)中训练观测的响应均值。
树剪枝
上述过程可能在训练集上产生良好的预测,但很可能过拟合数据,导致测试集性能不佳。这是因为生成的树可能太复杂。一个具有较少分割(即较少区域\(R_1, \ldots, R_J\))的较小树可能会以较低的方差和更好的解释性为代价,产生略高的偏差。
一种更好的策略是先生成一棵非常大的树\(T_0\),然后对其进行剪枝以获得子树。我们如何确定最佳剪枝方式?直观地说,我们的目标是选择测试误差率最低的子树。给定一个子树,我们可以使用交叉验证或验证集方法来估计其测试误差。
成本复杂度剪枝——也称为最弱链接剪枝——为我们提供了一种方法。我们不考虑每个可能的子树,而是考虑由非负调优参数\(\alpha\)索引的一系列树。对于每个\(\alpha\)值,对应一个子树\(T \subset T_0\),使得: \[ \sum_{m=1}^{|T|} \sum_{i:x_i \in R_m} (y_i - \hat{y}_{R_m})^2 + \alpha |T| \tag{8.2}\]
尽可能小。其中\(|T|\)表示树\(T\)的终端节点数量,\(R_m\)是对应于第\(m\)个终端节点的矩形(即预测变量空间的子集),\(\hat{y}_{R_m}\)是与\(R_m\)关联的预测响应——即\(R_m\)中训练观测的均值。
调优参数\(\alpha\)控制子树的复杂性与其对训练数据的拟合之间的权衡。当\(\alpha = 0\)时,子树\(T\)将简单地等于\(T_0\),因为此时(式 8.2)只衡量训练误差。然而,随着\(\alpha\)增加,拥有许多终端节点的树会有代价,因此(式 8.2)倾向于针对较小的子树最小化。
事实证明,当我们从零开始增加(式 8.2)中的\(\alpha\)时,分支以嵌套和可预测的方式从树中剪除,因此获得作为\(\alpha\)函数的整棵子树序列很容易。我们可以使用验证集或交叉验证来选择\(\alpha\)的值,然后返回完整数据集并获得对应于\(\alpha\)的子树。
8.2.2 分类树
分类树与回归树非常相似,只是它用于预测定性响应而不是定量响应。回顾一下,对于回归树,观测的预测响应由属于同一终端节点的训练观测的响应均值给出。相反,对于分类树,我们预测每个观测属于训练观测在所属区域中最常出现的类别。
在解释分类树的结果时,我们不仅对与特定终端节点区域关联的类别预测感兴趣,而且对落入该区域的训练观测的类别比例感兴趣。
分类树的误差度量
生长分类树的任务与生长回归树非常相似。正如在回归设置中一样,我们使用递归二元分割来生长分类树。然而,在分类设置中,RSS不能用作进行二元分割的标准。RSS的自然替代是分类误差率。由于我们计划将给定区域中的观测分配给该区域中最常出现的训练观测类别,因此分类误差率简单地就是该区域中不属于最常见类别的训练观测的比例:
\[ E = 1 - \max_k (\hat{p}_{mk}) \tag{8.3}\]
其中\(\hat{p}_{mk}\)表示第\(m\)区域中训练观测中属于第\(k\)类的比例。
然而,事实证明,对于树生长,分类误差不够敏感,在实践中,两种其他度量更可取。基尼指数定义为: \[ G = \sum_{k=1}^{K} \hat{p}_{mk}(1 - \hat{p}_{mk}) \tag{8.4}\]
这是\(K\)个类别中总方差的一个度量。不难看出,如果所有\(\hat{p}_{mk}\)都接近于0或1,则基尼指数取较小值。因此,基尼指数被称为节点纯度的一个度量——小值表示节点主要包含来自单个类别的观测。
基尼指数的替代方案是熵,由下式给出: \[ D = -\sum_{k=1}^{K} \hat{p}_{mk} \log \hat{p}_{mk} \tag{8.5}\]
由于\(0 \leq \hat{p}_{mk} \leq 1\),因此\(0 \leq -\hat{p}_{mk} \log \hat{p}_{mk}\)。可以证明,如果\(\hat{p}_{mk}\)都接近于0或接近于1,则熵将取接近于0的值。因此,与基尼指数一样,如果第\(m\)个节点是纯的,熵将取较小的值。事实上,基尼指数和熵在数值上非常相似。
易混淆概念辨析:基尼指数不是熵的一阶近似
从纯度函数(Impurity Function)的视角看,对于最常见的二分类问题,假设正类的比例为 \(p\),负类的比例为 \(1-p\)。 1. 基尼指数: \[ G(p) = p(1-p) + (1-p)p = 2p(1-p) \] 这实际上衡量了如果从该节点中随机抽取两个样本,它们碰巧属于不同类别的预期概率,也是二项分布方差的两倍。 2. 交叉熵: \[ D(p) = -p \log p - (1-p) \log (1-p) \] 从信息论的角度看,它计算的是节点中信息的“预期惊奇度(Surprisal)”或不确定性。
两者都关于 \(p=1/2\) 对称、在纯节点取 0,并在混合节点增大,但它们不是彼此的一阶泰勒近似。把 \(\log p\approx p-1\) 同时用于 \(p\) 与 \(1-p\) 没有共同有效的展开邻域:当 \(p\) 接近 1 时,\(1-p\) 接近 0,反之亦然。实践中二者经常给出相同或相近的候选分裂排序,这是形状相似的经验结果,不是上述错误展开的结论。选择准则应由交叉验证和任务损失检验。
注意:分类误差与节点纯度
虽然分类误差率直观上很自然,但它不是用于树生长的最佳标准。这是因为分类误差函数不连续——区域中类别比例的微小变化可能不会改变分类误差,但会显著改变基尼指数或熵。这使得基于分类误差的优化算法难以找到最佳分割点。因此,在实践中,我们使用基尼指数或熵来生长树,但在剪枝时可以使用分类误差率,如果最终剪枝树的预测准确性是目标的话。
选读审计:树如何逼近已知乘法规则
下面不是企业盈利预测,而是一个已知真值的受控模拟。我们先独立生成净利率、周转率和权益乘数,再按杜邦乘积定义同期 ROE 及 High_ROE。因此,树只能展示如何用分段矩形逼近一个已知的乘法边界;其准确率没有样本外金融含义,也不能被解释成模型“发现”了盈利规律。
from sklearn.tree import DecisionTreeClassifier, plot_tree # 分类树模型与可视化
simulation_rng = np.random.default_rng(42)
dupont_audit = pd.DataFrame({
'Margin': simulation_rng.uniform(0.01, 0.20, 1200),
'Turnover': simulation_rng.uniform(0.2, 2.0, 1200),
'Leverage': simulation_rng.uniform(1.0, 4.0, 1200)
})
dupont_audit['ROE_identity'] = dupont_audit.eval('Margin * Turnover * Leverage')
dupont_audit['High_ROE'] = (dupont_audit['ROE_identity'] > 0.15).astype(int)
classification_features = dupont_audit[['Margin', 'Turnover', 'Leverage']]
high_roe_target = dupont_audit['High_ROE']
# 拟合基尼准则的分类树(限制深度为3以保持可读性)
classification_tree_model = DecisionTreeClassifier(max_depth=3, random_state=42, criterion='gini') # 初始化分类树
classification_tree_model.fit(classification_features, high_roe_target) # 训练分类树
# 绘制分类树结构图
plt.figure(figsize=(14, 10)) # 设置画布大小
plot_tree(classification_tree_model, # 绘制树结构
feature_names=['净利率', '周转率', '杠杆'], # 指定特征中文名称
class_names=['普通', '高盈利'], # 指定类别标签
filled=True, # 按类别填色
rounded=True, # 圆角节点框
fontsize=10) # 设置字体大小
plt.title('受控模拟:逼近 ROE 恒等式阈值', fontsize=14) # 添加图表标题
plt.show() # 显示图表
图 8.2 只显示树对预先写入数据生成过程的规则做了近似。若要研究真实的未来盈利,必须把特征限定在预测时点可得的信息,并把目标放到更晚报告期;后面的经验比较遵守这一契约。
8.2.3 树与线性模型的比较
回归和分类树与第3章和第4章中介绍的更经典的回归和分类方法有很大的不同。特别是,线性回归假设具有以下形式的模型: \[ f(X) = \beta_0 + \sum_{j=1}^{p} X_j \beta_j \tag{8.6}\]
而回归树假设具有以下形式的模型: \[ f(X) = \sum_{m=1}^{M} c_m \cdot 1(X \in R_m) \tag{8.7}\]
其中\(R_1, \ldots, R_M\)表示特征空间的划分,如 图 8.1 所示。
哪个模型更好?这取决于手头的问题。如果特征与响应之间的关系可以很好地由线性模型如(式 8.6)近似,那么线性回归等方法可能会很好地工作,并且将性能优于不利用这种线性结构的回归树。相反,如果特征和响应之间存在高度非线性和复杂的关系,如模型(式 8.7)所示,那么决策树可能优于经典方法。
提示:何时使用树模型?
树模型在以下情况下特别有用:
- 非线性关系: 当预测变量与响应之间存在复杂的非线性交互作用时
- 混合变量类型: 树的思想可以处理连续与类别变量;但
scikit-learn的常用树估计器要求先把字符串类别编码为数值,不能把编码值的顺序误当成经济顺序 - 可解释性优先: 当模型的解释性比预测精度更重要时
- 缺失值处理: 是否原生支持缺失值取决于具体估计器与软件版本;使用前应查验 API,并在训练折内完成插补
- 异常值鲁棒性: 树对异常值相对鲁棒
线性模型在以下情况下更合适:
- 线性关系: 当关系近似线性时
- 高维稀疏数据: 当\(p \gg n\)时
- 预测精度优先: 当目标是最大化预测精度时
- 外推: 当需要在训练数据范围之外进行预测时(树无法很好地外推)
8.2.4 树的优缺点
用于回归和分类的决策树相对于第3章和第4章中看到的更经典的方法具有许多优点:
- 易于解释: 树非常容易向人们解释。事实上,它们甚至比线性回归更容易解释!
- 直观性: 有些人认为决策树更接近人类的决策制定,而不是之前章节中看到的回归和分类方法。
- 可视化: 树可以图形化显示,并且很容易被非专家解释(尤其是如果它们很小)。
- 处理定性变量: 树可以轻松处理定性预测变量,而无需创建虚拟变量。
遗憾的是,树通常不具有与本书中看到的其他一些回归和分类方法相同的预测精度水平。此外,树可能非常不稳定。换句话说,数据的微小变化可能导致最终估计树的巨大变化。
然而,通过聚合许多决策树,使用Bagging、随机森林和Boosting等方法,可以显著提高树的预测性能。我们将在下一节介绍这些概念。
8.3 Bagging、随机森林、Boosting和贝叶斯加性回归树
集成方法是一种将许多简单的”构建块”模型组合起来,以获得单一且可能非常强大的模型的方法。这些简单的构建块模型有时被称为弱学习器,因为它们单独可能产生平庸的预测。
我们现在讨论Bagging、随机森林、Boosting和贝叶斯加性回归树。这些是集成方法,其简单的构建块是回归或分类树。
8.3.1 Bagging
自助法,在第5章中介绍,是一个非常强大的思想。它在许多难以甚至不可能直接计算感兴趣量的标准差的情况下使用。我们在这里看到,自助法可以在完全不同的上下文中使用,以提高统计学习方法(如决策树)的性能。
第8.1节中讨论的决策树具有高方差。这意味着如果我们将训练数据随机分成两部分,并对这两半都拟合决策树,我们得到的结果可能非常不同。相比之下,低方差的过程如果应用于不同的数据集,将产生相似的结果;如果\(n\)对\(p\)的比率适中,线性回归往往具有低方差。
Bootstrap聚合或Bagging,是一种用于降低统计学习方法方差的一般用途方法;我们在这里介绍它,因为它特别有用,并且经常在决策树的上下文中使用。
回顾一下,给定\(n\)个独立观测\(Z_1, \ldots, Z_n\),每个方差为\(\sigma^2\),观测均值的方差为\(\sigma^2/n\)。换句话说,平均一组观测会降低方差。因此,降低统计学习方法的方差并增加测试集准确性的一种自然方法是:从总体中获取许多训练集,使用每个训练集构建单独的预测模型,然后平均结果预测。
提示:为什么Bagging能降低方差?
Bagging之所以能降低方差,是因为样本均值的方差等于总体方差除以样本大小:\(\text{Var}(\bar{Z}) = \sigma^2/n\)。这意味着如果我们有\(B\)个独立的训练集,拟合\(B\)个模型\(\hat{f}^1(x), \ldots, \hat{f}^B(x)\),然后平均它们: \[ \hat{f}_{\text{avg}}(x) = \frac{1}{B} \sum_{b=1}^{B} \hat{f}^b(x) \]
那么平均的方差将是单个模型方差的\(1/B\)(如果模型独立的话)。即使我们使用自助样本导致模型不完全独立,Bagging仍然可以显著降低方差。
当然,这是不实际的,因为我们通常无法访问多个训练集。相反,我们可以使用自助法,从(单个)训练数据集中重复采样。在这种方法中,我们生成\(B\)个不同的自助训练数据集。我们在第\(b\)个自助训练集上训练我们的方法以得到\(\hat{f}^{*b}(x)\),最后平均所有预测以获得: \[ \hat{f}_{\text{bag}}(x) = \frac{1}{B} \sum_{b=1}^{B} \hat{f}^{*b}(x) \]
这称为Bagging。
虽然Bagging可以提高许多回归方法的预测,但它对决策树特别有用。要对回归树应用Bagging,我们简单地使用\(B\)个自助训练集构建\(B\)个回归树,然后平均结果预测。这些树生长得很深,并且不被剪枝。因此,每棵单独的树具有高方差,但偏差低。平均这\(B\)棵树会降低方差。
袋外误差估计
事实证明,有一种非常直接的方法可以估计Bagged模型的测试误差,而无需执行交叉验证或验证集方法。回顾一下,Bagging的关键是树被重复拟合到观测的自助子集。可以证明,平均而言,每棵Bagged树使用大约三分之二的观测。未被用来拟合给定Bagged树的三分之一观测被称为袋外(OOB)观测。
我们可以使用每棵树中该观测为OOB的树来预测第\(i\)个观测的响应。这将产生第\(i\)个观测的大约\(B/3\)个预测。为了获得第\(i\)个观测的单个预测,我们可以平均这些预测响应(如果回归是目标),或者可以采用多数投票(如果分类是目标)。这导致第\(i\)个观测的单个OOB预测。可以对所有\(n\)个观测中的每一个以这种方式获得OOB预测,从中可以计算整体OOB MSE(对于回归问题)或分类误差(对于分类问题)。得到的OOB误差是Bagged模型测试误差的有效估计。
8.3.2 随机森林
随机森林通过一种称为去相关的小调整提供了对Bagged树的改进。如在Bagging中一样,我们在自助训练样本上构建许多决策树。但是,在构建这些决策树时,每次考虑树中的分割时,从完整的\(p\)个预测变量集中选择\(m\)个预测变量的随机样本作为分割候选。只允许分割使用这\(m\)个预测变量之一。每次分割时都会抽取一个新的\(m\)个预测变量样本,通常我们选择\(m \approx \sqrt{p}\)——即每次分割时考虑的预测变量数量大约等于总预测变量数量的平方根。
换句话说,在构建随机森林时,算法甚至不允许考虑大多数可用的预测变量。这听起来很疯狂,但它有一个巧妙的理由。假设数据集中有一个非常强的预测变量,以及其他一些中等强度的预测变量。那么在Bagged树的集合中,大多数或所有树都将在顶部分割中使用这个强预测变量。因此,所有Bagged树看起来都非常相似。因此,Bagged树的预测将高度相关。不幸的是,平均许多高度相关的量不会像平均许多不相关的量那样导致方差的很大降低。
随机森林通过强制每次分割只考虑预测变量的子集来克服这个问题。因此,平均而言,\((p-m)/p\)的分割甚至不会考虑强预测变量,因此其他预测变量将有更多的机会。我们可以将这个过程视为去相关树,从而使得到的树的平均方差更小,因此更可靠。
Bagging和随机森林之间的主要区别在于预测变量子集大小\(m\)的选择。例如,如果使用\(m=p\)构建随机森林,那么这简单地等同于Bagging。在许多应用中,随机森林使用\(m=\sqrt{p}\)导致比Bagging更低的测试误差。
8.3.3 Boosting
我们现在讨论Boosting,这是提高决策树预测结果的另一种方法。像Bagging一样,Boosting是一种可以应用于许多统计学习方法的回归或分类的一般方法。这里我们将Boosting的讨论限制在决策树的上下文中。
回顾一下,Bagging涉及创建原始训练数据集的多个副本,使用自助法对每个副本拟合单独的决策树,然后组合所有树以创建单个预测模型。值得注意的是,每棵树都是在自助数据集上构建的,独立于其他树。
Boosting的工作方式类似,只是树是顺序生长的:每棵树都使用以前生长的树的信息生长。Boosting不涉及自助采样;相反,每棵树都拟合原始数据集的修改版本。
我们首先考虑回归设置。像Bagging一样,Boosting涉及组合大量的决策树\(\hat{f}^1, \ldots, \hat{f}^B\)。Boosting在算法8.1中描述。
提示:Boosting的直观理解
Boosting的核心思想是”慢速学习”。与其拟合一个单一的大决策树(这可能过拟合),Boosting慢慢学习:给定当前模型,我们将一棵决策树拟合到当前模型的残差。也就是说,我们使用当前残差而不是结果\(Y\)作为响应来拟合树。然后我们将这棵新的决策树添加到拟合函数中以更新残差。
每棵这些树可以相当小,只有几个终端节点,由参数\(d\)控制。通过将小树拟合到残差,我们在模型表现不佳的区域慢慢改进\(\hat{f}\)。收缩参数\(\lambda\)进一步减慢这个过程,允许更多和形状不同的树来攻击残差。
一般来说,统计学习方法学习慢的往往表现良好。注意,在Boosting中,与Bagging不同,每棵树的构造强烈依赖于已经生长的树。
算法8.1:用于回归树的Boosting
- 设\(\hat{f}(x) = 0\),\(r_i = y_i\)对所有训练集中的\(i\)
- 对于\(b = 1, 2, \ldots, B\):
- 使用\(d\)个分割将树\(\hat{f}^b\)拟合到训练数据\((X, r)\)
- 通过添加新树的收缩版本来更新\(\hat{f}\): \[ \hat{f}(x) \leftarrow \hat{f}(x) + \lambda \hat{f}^b(x) \]
- 更新残差: \[ r_i \leftarrow r_i - \lambda \hat{f}^b(x_i) \]
- 输出Boosted模型: \[ \hat{f}(x) = \sum_{b=1}^{B} \lambda \hat{f}^b(x) \]
Boosting有三个调优参数:
- 树的数量\(B\)。与Bagging和随机森林不同,如果\(B\)太大,Boosting可能会过拟合,尽管这种过拟合往往发生得很慢(如果有的话)。我们使用交叉验证来选择\(B\)。
- 收缩参数\(\lambda\),一个小的正数。这控制Boosting的学习率。典型值是0.01或0.001,正确的选择可能取决于问题。非常小的\(\lambda\)可能需要使用非常大的\(B\)值才能获得良好的性能。
- 每棵树中的分割数量\(d\),它控制Boosting集成的复杂性。通常\(d=1\)效果很好,在这种情况下每棵树是一个树桩,由单个分割组成。在这种情况下,Boosted集成实际上是拟合一个可加模型,因为每一项只涉及单个变量。更一般地,\(d\)是交互深度,控制Boosted模型的交互阶数,因为\(d\)个分割最多可以涉及\(d\)个变量。
8.3.4 贝叶斯加性回归树
最后,我们讨论贝叶斯加性回归树(BART),另一种使用决策树作为构建块的集成方法。这里的先验、树更新与后验平均以 BART 原始论文为来源 (Chipman 等 2010年)。为简单起见,我们针对回归(而不是分类)介绍BART。
回顾一下,Bagging和随机森林从平均回归树进行预测,每棵树都使用数据和/或预测变量的随机样本构建。每棵树与其他树分开构建。相反,Boosting使用树的加权和,每棵树都通过拟合当前拟合的残差来构造。因此,每棵新树都试图捕获当前模型集尚未考虑的信号。
BART与这两种方法都有关:每棵树以随机方式构建,如Bagging和随机森林,每棵树都试图捕获当前模型尚未考虑的信号,如Boosting。
BART的主要新颖性在于生成新树的方式。BART让\(K\)表示回归树的数量,\(B\)表示运行BART算法的迭代次数。\(\hat{f}^b_k(x)\)表示第\(b\)次迭代中使用的第\(k\)个回归树在\(x\)处的预测。在每次迭代结束时,来自该迭代的\(K\)树将被求和,即\(\hat{f}^b(x) = \sum_{k=1}^{K} \hat{f}^b_k(x)\),对于\(b = 1, \ldots, B\)。
在BART算法的第一次迭代中,所有树都被初始化为具有单个根节点,\(\hat{f}^1_k(x) = \frac{1}{nK} \sum_{i=1}^{n} y_i\),即响应值的均值除以树的总数。因此,\(\hat{f}^1(x) = \sum_{k=1}^{K} \hat{f}^1_k(x) = \frac{1}{n} \sum_{i=1}^{n} y_i\)。
在随后的迭代中,BART一次更新一棵树。在第\(b\)次迭代中,要更新第\(k\)棵树,我们从每个响应值中减去除第\(k\)棵树之外的所有树的预测,以获得部分残差: \[ r_i = y_i - \sum_{k' < k} \hat{f}^b_{k'}(x_i) - \sum_{k' > k} \hat{f}^{b-1}_{k'}(x_i) \]
然后,BART不是从当前部分残差拟合一棵新树,而是随机选择从前一次迭代的树(\(\hat{f}^{b-1}_k\))的一组可能扰动之一,倾向于改善对部分残差的拟合。
有两种扰动:
- 我们可以通过添加或修剪分支来改变树的结构
- 我们可以改变树的每个终端节点中的预测
BART的输出是一系列预测模型\(\hat{f}^b(x) = \sum_{k=1}^{K} \hat{f}^b_k(x)\),对于\(b = 1, 2, \ldots, B\)。我们通常丢弃前几个预测模型,因为在早期迭代中获得的模型——称为预烧期——往往不能提供非常好的结果。我们让\(L\)表示预烧迭代次数;例如,我们可能取\(L = 200\)。然后,为了获得单个预测,我们只需取预烧迭代后的平均值: \[ \hat{f}(x) = \frac{1}{B - L} \sum_{b = L + 1}^{B} \hat{f}^b(x) \]
8.3.5 案例研究:预测下一报告期 ROE 的集成方法比较
本例用报告期 \(t\) 可见的财务比率预测同一公司连续下一报告期 \(t+1\) 的 ROE。公司内先排序并核验季度连续性,再按报告期前向切分;测试期整体晚于训练期。当前净利率、周转率和杠杆可以作为滞后特征,但它们不能代数重构未来目标。
我们将比较以下方法:
- 单棵回归树
- Bagging (使用所有特征)
- 随机森林 (使用特征子集)
- Boosting (梯度提升树)
图 8.3 显示四种方法在同一锁定未来测试期的均方误差。图只支持本数据切分内的相对比较;是否优于单棵树由现场输出决定。
import numpy as np # 数值计算基础库
import pandas as pd # 表格数据处理库
import matplotlib.pyplot as plt # 数据可视化绑定
from sklearn.tree import DecisionTreeRegressor # 回归树模型
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor # 随机森林与梯度提升回归
from sklearn.metrics import mean_squared_error # 均方误差评估
# 重新加载明确年份与字段的真实切片,在保留多期公司面板的同时控制出版内存峰值
import os # 跨平台路径处理
DATA_DIR = os.path.abspath(os.path.expanduser(os.environ['BOOK_DATA_DIR'])) # 从必需环境变量解析数据根目录
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}' # 缺少挂载时立即失败
path = os.path.join(DATA_DIR, 'stock/financial_statement.h5') # 拼接财务报表文件路径
financial_data_all = pd.read_hdf( # 选择性读取足够覆盖前向训练与测试的真实季度面板
path, # 指定财务报表 HDF 文件
where="quarter>='2018q1' & quarter<='2023q4'", # 固定六年连续季度支持域
columns=['quarter', 'order_book_id', 'net_profit', 'operating_revenue', 'total_assets', 'equity_parent_company'], # 只读取建模所需字段
).reset_index(drop=True) # 重建唯一行索引,禁止 HDF 重复索引放大后续标签选择
# 构造杜邦分析所需的财务比率特征
financial_data_all['Margin'] = financial_data_all['net_profit'] / financial_data_all['operating_revenue'] # 计算销售净利率
financial_data_all['Turnover'] = financial_data_all['operating_revenue'] / financial_data_all['total_assets'] # 计算资产周转率
financial_data_all['Lev'] = financial_data_all['total_assets'] / financial_data_all['equity_parent_company'] # 计算权益乘数(财务杠杆)
financial_data_all['Log_Assets'] = np.log(financial_data_all['total_assets'] + 1) # 对数化总资产以捕捉规模效应
financial_data_all['ROE'] = financial_data_all['net_profit'] / financial_data_all['equity_parent_company']
financial_data_all['period'] = pd.PeriodIndex(financial_data_all['quarter'], freq='Q')
financial_data_all = financial_data_all.sort_values(['order_book_id', 'period'])
financial_data_all['future_ROE'] = financial_data_all.groupby('order_book_id')['ROE'].shift(-1)
financial_data_all['future_period'] = financial_data_all.groupby('order_book_id')['period'].shift(-1)
financial_data_all['period_ordinal'] = financial_data_all['period'].astype('int64') # 用紧凑季度序号核验连续性
financial_data_all['future_period_ordinal'] = financial_data_all.groupby('order_book_id')['period_ordinal'].shift(-1) # 构造下一记录季度序号数据加载和特征构造完毕后,接下来对数据进行清洗与异常值过滤,并划分训练集与测试集,为后续四种模型的公平对比做好准备。
# 只保留公司内连续的下一季度,并清洗分析列
analysis_cols = ['Margin', 'Turnover', 'Lev', 'Log_Assets', 'future_ROE']
financial_data_all[analysis_cols] = financial_data_all[analysis_cols].replace([np.inf, -np.inf], np.nan) # 仅分析列中的inf替换为NaN
cleaned_financial_data = financial_data_all.dropna(subset=analysis_cols) # 仅按分析列去除缺失值
cleaned_financial_data = cleaned_financial_data[ # 定义cleaned_financial_data变量
(cleaned_financial_data['Margin'].between(-0.5, 0.5)) & # 过滤净利率极端值
(cleaned_financial_data['Turnover'].between(0, 5)) & # 过滤周转率极端值
(cleaned_financial_data['Lev'].between(1, 10)) &
(cleaned_financial_data['future_ROE'].between(-0.5, 0.5)) &
((cleaned_financial_data['future_period_ordinal'] - cleaned_financial_data['period_ordinal']) == 1) # 用整数差拒绝季度缺口并避免对象数组膨胀
] # 执行数据处理操作
# 按报告期前向切分;为控制资源,每个期间独立做与目标无关的固定上限抽样
modeling_data = cleaned_financial_data.groupby('period', group_keys=False).apply(
lambda frame: frame.sample(min(250, len(frame)), random_state=42)
).sort_values('period')
ordered_periods = modeling_data['period'].drop_duplicates().sort_values()
split_period = ordered_periods.iloc[max(1, int(len(ordered_periods) * 0.7))]
train_mask = modeling_data['future_period'] < split_period # 清除边界:训练标签也必须早于测试起点
test_mask = modeling_data['period'] >= split_period
feature_names = ['Margin', 'Turnover', 'Lev', 'Log_Assets']
features_train = modeling_data.loc[train_mask, feature_names]
features_test = modeling_data.loc[test_mask, feature_names]
target_train = modeling_data.loc[train_mask, 'future_ROE']
target_test = modeling_data.loc[test_mask, 'future_ROE']
assert modeling_data.loc[train_mask, 'period'].max() < modeling_data.loc[test_mask, 'period'].min()
assert modeling_data.loc[train_mask, 'future_period'].max() < modeling_data.loc[test_mask, 'period'].min()/tmp/ipykernel_1418386/4050797870.py:14: FutureWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
modeling_data = cleaned_financial_data.groupby('period', group_keys=False).apply(
接下来,分别训练四种树方法并计算测试集均方误差。
# 1. 单棵回归树(无限制深度,高方差基线模型)
single_regression_tree = DecisionTreeRegressor(random_state=42) # 初始化单棵回归树
single_regression_tree.fit(features_train, target_train) # 在训练集上拟合模型
single_tree_mse = mean_squared_error(target_test, single_regression_tree.predict(features_test)) # 计算测试集MSE
# 2. Bagging(使用RandomForestRegressor但max_features=None,即每次分裂使用全部特征)
bagging_model = RandomForestRegressor(n_estimators=100, max_features=None, random_state=42) # 初始化Bagging模型(100棵树)
bagging_model.fit(features_train, target_train) # 训练Bagging集成
bagging_model_mse = mean_squared_error(target_test, bagging_model.predict(features_test)) # 计算测试集MSE
# 3. 随机森林(max_features='sqrt'实现特征子空间随机化去相关)
random_forest_model = RandomForestRegressor(n_estimators=100, max_features='sqrt', random_state=42) # 初始化随机森林
random_forest_model.fit(features_train, target_train) # 训练随机森林
random_forest_mse = mean_squared_error(target_test, random_forest_model.predict(features_test)) # 计算测试集MSE
# 4. Boosting(梯度提升树:串行残差学习)
boosting_model = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42) # 初始化梯度提升模型
boosting_model.fit(features_train, target_train) # 训练梯度提升集成
boosting_model_mse = mean_squared_error(target_test, boosting_model.predict(features_test)) # 计算测试集MSE最后,将四种方法的测试集MSE以柱状图进行可视化对比。
# 准备可视化数据
methods = ['单棵树', 'Bagging', '随机森林', 'Boosting'] # 四种方法的中文标签
mse_values = [single_tree_mse, bagging_model_mse, random_forest_mse, boosting_model_mse] # 对应的MSE值列表
plt.figure(figsize=(10, 6)) # 设置画布大小
bars = plt.bar(methods, mse_values, color=['#E3120B', '#2C3E50', '#008080', '#F0A700']) # 绘制柱状图,每种方法使用不同颜色
plt.title('下一报告期 ROE:未来测试期 MSE', fontsize=14) # 添加图表标题
plt.ylabel('均方误差 (MSE)', fontsize=12) # 设置Y轴标签
plt.xlabel('方法', fontsize=12) # 设置X轴标签
# 在柱子上添加数值标签
for bar, value in zip(bars, mse_values): # 遍历每个柱子和对应MSE值
height = bar.get_height() # 获取柱子高度
plt.text(bar.get_x() + bar.get_width()/2., height, # 在柱顶居中位置添加文本
f'{value:.4f}', # 格式化为4位小数
ha='center', va='bottom', fontsize=10) # 水平居中、底部对齐
plt.grid(axis='y', alpha=0.3) # 添加Y轴网格线
plt.tight_layout() # 自动调整布局防止标签重叠
plt.show() # 显示图表
如 图 8.3 所示,应以柱高较低者作为这一次前向切分中的较优方法。这里预测的是未来 ROE,不再把同期杜邦恒等式当成泛化证据;一次测试期的排序也不能证明某种集成方法普遍占优。
8.3.6 案例研究: 随机森林与提升树预测 A 股未来 20 日最大回撤
在金融量化领域,树集成方法能够捕捉非线性关系,也不要求像距离模型那样先统一特征尺度。在这个分类案例中,我们应用随机森林和运行环境中可用的提升树实现,预测海康威视 002415.XSHE 在公司日 \(t\) 收盘后未来 20 个交易日内是否发生不低于 10% 的最大回撤。
XGBoost 通过对损失函数直接进行二阶泰勒展开优化(利用了Hessian信息),并显式地在目标函数中引入结构风险惩罚项(即对树的叶子节点数量和叶子节点权重分数的正则化),从而在非线性提取能力和抗过拟合能力之间取得了极佳的理论平衡。
我们使用截至公司日 \(t\) 收盘可得的滞后收益率、波动率和成交量特征,并把未来第 20 个交易日记为 label_date。不足完整窗口的末端标签保持未知;训练与验证之间执行严格的标签时域 purge。
这一示范只使用训练—验证区间,不接触 M09 封存的最终测试键。随机森林通过样本与特征抽样降低单树方差;若运行环境提供 XGBoost,则用浅树、低学习率和行列抽样约束复杂度,否则动态改用 scikit-learn 的梯度提升实现。所有模型必须与训练事件率概率基线和训练多数类基线同表比较,具体表现只从当次验证输出解释。
算法拆解:随机森林和 XGBoost 到底是怎么学出来的
树模型的代码往往看起来很短,但背后的学习过程并不简单。为了避免学生把它误解成“黑箱一跑就出结果”,这里把两个算法的机制拆开说清楚。
随机森林的流程是:
- 从训练样本中反复做有放回抽样,生成很多不同的 bootstrap 子样本;
- 在每个子样本上训练一棵分类树;
- 每次分裂节点时,不是看全部特征,而是只随机抽一部分特征参与竞争;
- 预测时让很多棵树一起投票,或者对概率取平均。
这样做的核心目的,是让每棵树“看见的世界”都稍有不同,从而降低单棵树对噪声的过度敏感。
XGBoost则不是“并行种很多树再平均”,而是“让后一棵树专门去修正前一棵树犯下的错”。它的流程更像连续补考:
- 先从一个很粗糙的初始预测开始;
- 计算当前模型在哪些样本上预测得不够好;
- 训练一棵新树,专门去拟合这些残差或梯度信息;
- 把新树以较小学习率叠加到已有模型上;
- 重复很多轮,逐步改进整体预测。
因此,随机森林更像“多位专家独立投票”,XGBoost 更像“一个专家团队连续纠错”。在金融场景里,如果数据噪声极高、信号极弱,XGBoost 的学习率、树深和抽样比例就尤其重要,因为它们直接控制模型是“抓住微弱信号”,还是“追着噪声狂奔”。
import numpy as np # 数值计算基础库
import pandas as pd # 表格数据处理库
import matplotlib.pyplot as plt # 数据可视化绑定
from sklearn.ensemble import RandomForestClassifier # 随机森林分类器
from sklearn.metrics import accuracy_score, roc_auc_score, brier_score_loss # 准确率、AUC与Brier评估
import warnings # 警告控制模块
warnings.filterwarnings('ignore') # 关闭冗余警告信息
try: # 异常处理开始
import xgboost as xgb # XGBoost 极端梯度提升框架
HAS_XGB = True # 标记 xgboost 可用
except ImportError: # 捕获异常
HAS_XGB = False # 标记 xgboost 不可用
print('Warning: xgboost is not installed. Using GradientBoostingClassifier as fallback.') # 提示用户将使用sklearn替代
from sklearn.ensemble import GradientBoostingClassifier # 导入梯度提升分类作为后备
# 取消中文字体警告
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC'] # 使用项目规定的中文字体
plt.rcParams['axes.unicode_minus'] = False # 正确显示负号下面的海康威视前复权数据只用于单股教学演示,不属于 M08 贯穿项目,也不产生项目候选;M08 在章末独立读取合同规定的后复权全公司数据。
import os # 跨平台路径处理
DATA_DIR = os.path.abspath(os.path.expanduser(os.environ['BOOK_DATA_DIR'])) # 从必需环境变量解析数据根目录
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}' # 缺少挂载时立即失败
path = os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5') # 拼接前复权股价文件路径
stock_price_data = pd.read_hdf( # 从真实 HDF 中直接选择海康威视,避免载入全市场行情
path, # 指定前复权股价文件
where="order_book_id='002415.XSHE'", # 在存储层过滤唯一教学标的
columns=['date', 'order_book_id', 'close', 'volume'], # 只读取特征与标签所需字段
).reset_index() # 将 HDF 索引恢复为普通列
stock_price_data = stock_price_data.sort_values('date') # 按日期升序排列确保时序正确
# 构建金融特征
stock_price_data['Ret'] = stock_price_data['close'].pct_change() # 计算日收益率
future_minimum_close = stock_price_data['close'].shift(-1).iloc[::-1].rolling(20, min_periods=20).min().iloc[::-1] # 未来20日最低收盘
stock_price_data['prediction_date'] = pd.to_datetime(stock_price_data['date']) # 公司日t收盘后的预测原点
stock_price_data['label_date'] = stock_price_data['prediction_date'].shift(-20) # 未来第20个交易日
stock_price_data['Target'] = pd.Series(pd.NA, index=stock_price_data.index, dtype='Int64') # 不足20日保持未知
known_label = future_minimum_close.notna() & stock_price_data['label_date'].notna() # 完整未来窗口掩码
stock_price_data.loc[known_label, 'Target'] = (future_minimum_close.loc[known_label] / stock_price_data.loc[known_label, 'close'] - 1 <= -0.10).astype(int) # 最大回撤事件
assert stock_price_data.loc[stock_price_data['label_date'].isna(), 'Target'].isna().all() # 末端未知不得变成负类# 滞后收益率特征(过去1-3天的收益率)
for i in range(1, 4): # 循环生成Lag_1到Lag_3
stock_price_data[f'Lag_{i}'] = stock_price_data['Ret'].shift(i) # 第i日的滞后收益率
# 波动率与成交量特征
stock_price_data['Vol_5'] = stock_price_data['Ret'].rolling(5).std() # 过去5日收益率的滚动标准差(短期波动率)
stock_price_data['Vol_Change'] = stock_price_data['volume'].pct_change() # 成交量环比变化率
# 清洗空值和无穷值(pct_change在前值为0时会产生inf)
features_list = [f'Lag_{i}' for i in range(1, 4)] + ['Vol_5', 'Vol_Change'] # 定义特征列名列表
unknown_terminal_count = int(stock_price_data['Target'].isna().sum()) # 在删除前登记未知末端数量
stock_price_data = stock_price_data.replace([np.inf, -np.inf], np.nan).dropna(subset=features_list + ['Target', 'label_date']) # 建模时排除未知标签
assert stock_price_data['Target'].notna().all() # 进入建模样本的标签必须已知
# 此处只演示时间边界;不得把单股对象并入项目manifest
split_idx = int(len(stock_price_data) * 0.7) # 只用日期位置确定验证起点
validation_origin = stock_price_data['prediction_date'].iloc[split_idx] # 固定验证期首个预测日
train_stock_data = stock_price_data.loc[stock_price_data['label_date'] < validation_origin].copy() # 清除标签窗口跨界样本
validation_stock_data = stock_price_data.loc[stock_price_data['prediction_date'] >= validation_origin].copy() # 后段仅作验证集
assert min(len(train_stock_data), len(validation_stock_data)) >= 20 # 分段样本量保护
assert stock_price_data['prediction_date'].is_monotonic_increasing and stock_price_data['prediction_date'].is_unique # 日期守卫
assert train_stock_data['label_date'].max() < validation_stock_data['prediction_date'].min() # 20日标签严格purge
assert train_stock_data['Target'].nunique() == validation_stock_data['Target'].nunique() == 2 # 两段均需两类
stock_features_train = train_stock_data[features_list].values # 提取训练集特征矩阵
stock_target_train = train_stock_data['Target'].values # 提取训练集目标向量
stock_features_validation = validation_stock_data[features_list].values # 验证特征矩阵
stock_target_validation = validation_stock_data['Target'].values # 验证标签
training_event_rate = float(train_stock_data['Target'].mean()) # 训练事件率概率基线
training_majority_class = int(training_event_rate >= 0.5) # 训练多数类基线
print('unknown_terminal_count=', unknown_terminal_count, 'training_event_rate=', training_event_rate, 'training_majority_class=', training_majority_class) # 输出审计unknown_terminal_count= 20 training_event_rate= 0.18626309662398138 training_majority_class= 0
接下来,训练随机森林和 XGBoost 分类模型。
# 2. 训练分类模型
models = {} # 初始化空字典,用于存储模型名称与实例的映射
# 随机森林分类器(300棵树,限制深度为5防止过拟合)
rf_classifier = RandomForestClassifier(n_estimators=300, max_depth=5, max_features='sqrt', random_state=42) # 初始化随机森林
rf_classifier.fit(stock_features_train, stock_target_train) # 在训练集上拟合随机森林
models['Random Forest'] = rf_classifier # 将训练好的随机森林存入模型字典
# XGBoost(或梯度提升作为后备方案)
if HAS_XGB: # 若xgboost已安装则使用XGBClassifier
xgboost_classifier = xgb.XGBClassifier( # 定义xgboost_classifier变量
n_estimators=300, # 迭代300轮
max_depth=3, # 极浅的树深度(强正则化)
learning_rate=0.01, # 极低学习率防止过拟合
subsample=0.8, # 行抽样比例80%
colsample_bytree=0.8, # 列抽样比例80%
random_state=42, # 固定随机种子
eval_metric='logloss' # 使用对数损失作为评估指标
) # 完成构建
xgboost_classifier.fit(stock_features_train, stock_target_train) # 训练XGBoost模型
models['XGBoost'] = xgboost_classifier # 存入模型字典
else: # 若xgboost未安装则使用sklearn的GradientBoostingClassifier
gradient_boosting_classifier = GradientBoostingClassifier(n_estimators=300, max_depth=3, learning_rate=0.01, random_state=42) # 初始化梯度提升分类器
gradient_boosting_classifier.fit(stock_features_train, stock_target_train) # 训练梯度提升模型
models['Gradient Boosting'] = gradient_boosting_classifier # 存入模型字典接下来,评估两个模型的验证集表现并可视化特征重要性。
# 3. 评估与可视化
plt.figure(figsize=(14, 6)) # 创建宽幅画布用于并排展示
for i, (name, model) in enumerate(models.items()): # 遍历所有已训练模型
# 预测概率用于计算AUC
y_pred_proba = model.predict_proba(stock_features_validation)[:, 1] # 获取验证期回撤事件概率
y_pred = model.predict(stock_features_validation) # 获取验证期离散预测
acc = accuracy_score(stock_target_validation, y_pred) # 计算验证集准确率
auc = roc_auc_score(stock_target_validation, y_pred_proba) # 计算验证集AUC
model_brier = brier_score_loss(stock_target_validation, y_pred_proba) # 计算验证Brier
baseline_brier = brier_score_loss(stock_target_validation, np.repeat(training_event_rate, len(stock_target_validation))) # 训练事件率基线
print(f'[{name}] Validation Accuracy: {acc:.4f}, AUC: {auc:.4f}, Brier: {model_brier:.4f}, baseline Brier: {baseline_brier:.4f}') # 动态输出
# 提取并可视化特征重要性
plt.subplot(1, len(models), i+1) # 创建子图(按模型数量排列)
importances = model.feature_importances_ # 获取基于不纯度下降的描述性重要性
indices = np.argsort(importances) # 按重要性升序排列的索引
plt.barh(range(len(indices)), importances[indices], color='#3498DB', align='center') # 绘制水平条形图
plt.yticks(range(len(indices)), [features_list[idx] for idx in indices], fontsize=11) # 设置Y轴为特征名称
plt.xlabel('特征重要性(不纯度下降;非因果效应)', fontsize=12, fontweight='bold') # 澄清重要性不等于经济贡献
plt.title(f'{name}\n(Validation Brier = {model_brier:.3f})', fontsize=13, fontweight='bold') # 运行时动态名称和指标
plt.grid(axis='x', alpha=0.3) # 添加X轴网格线
plt.tight_layout() # 自动调整子图间距
plt.show() # 显示图表[Random Forest] Validation Accuracy: 0.8320, AUC: 0.5707, Brier: 0.1373, baseline Brier: 0.1384
[XGBoost] Validation Accuracy: 0.8338, AUC: 0.5547, Brier: 0.1372, baseline Brier: 0.1384
图 8.4 只显示本次验证拟合的不纯度重要性;它必须与同块现场输出的 AUC、Brier 和训练事件率基线共同评分。不得预写 AUC 或 Brier 的固定区间,也不得把单次模型排序外推为普遍结论;若验证结果没有增量信息,应保留失败结果并检查支持域、类别比例与标签窗口,而不是改看封存测试集。
此外,XGBoost 通过列抽样(colsample_bytree)、行抽样(subsample)和学习率缩减,相对于基础的随机森林,在应对金融数据极低信噪比属性时,往往能提供一条更可控的正则化削减路径。
8.4 实验: 基于树的方法
在本节中,我们使用 Python 的 sklearn 库演示基于树的方法。先保留一个“同期目标构成项导致完美分类”的反例,随后练习改用下一报告期标签。反例的高分数是泄漏诊断,不是模型能力。
8.4.1 拟合分类树
我们首先构建一个分类树,用于预测一家公司是否能获得正的净资产收益率(ROE > 0)。
下面的代码故意把 Margin 与由同一净利润定义的 Profitable 放在一起。它只用于让读者识别目标泄漏;随机切分和接近完美的分数均不得用于选模或业务判断。合格的端到端流程见本章应用题:先按报告期切分,再用训练期交叉验证选择 ccp_alpha,最后评价下一期标签。 特别地,代码的最后一部分调用了极其有用的 export_text 函数。与其看一张庞大而混乱的图像,它直接将树的生长规则打印成了类似 if-else 的缩进文本,这对于需要把风控规则直接硬编码进生产系统的量化工程师来说,是不可多得的良药。
import numpy as np # 数值计算基础库
import pandas as pd # 表格数据处理库
from sklearn.tree import DecisionTreeClassifier, plot_tree, export_text # 分类树、可视化与规则导出
from sklearn.model_selection import train_test_split, GridSearchCV # 训练/测试集划分与网格搜索
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report # 分类评估(准确率、混淆矩阵、报告)
# 1. 加载和准备数据
import os # 跨平台路径处理
DATA_DIR = os.path.abspath(os.path.expanduser(os.environ['BOOK_DATA_DIR'])) # 从必需环境变量解析数据根目录
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}' # 缺少挂载时立即失败
path = os.path.join(DATA_DIR, 'stock/financial_statement.h5') # 拼接财务报表文件路径
financial_statement_data = pd.read_hdf( # 选择性读取回归树实验所需真实季度面板
path, # 指定财务报表 HDF 文件
where="quarter>='2018q1' & quarter<='2023q4'", # 固定与集成比较相同的六年支持域
columns=['quarter', 'order_book_id', 'net_profit', 'operating_revenue', 'total_assets', 'equity_parent_company'], # 只读取实验所需字段
).reset_index(drop=True) # 重建唯一行索引,确保特征与目标一一对应# 构造特征和目标
financial_statement_data['Margin'] = financial_statement_data['net_profit'] / financial_statement_data['operating_revenue'] # 计算销售净利率
financial_statement_data['Turnover'] = financial_statement_data['operating_revenue'] / financial_statement_data['total_assets'] # 计算资产周转率
financial_statement_data['Lev'] = financial_statement_data['total_assets'] / financial_statement_data['equity_parent_company'] # 计算权益乘数(财务杠杆)
financial_statement_data['Log_Assets'] = np.log(financial_statement_data['total_assets'] + 1) # 对数化总资产以捕捉规模效应
# 目标变量: 是否盈利 (Net Profit > 0)
financial_statement_data['Profitable'] = (financial_statement_data['net_profit'] > 0).astype(int) # 二元分类目标(1=盈利,0=亏损)
# 清洗(仅对分析列去除inf和NaN,避免全表dropna丢弃过多行)
lab_analysis_cols = ['Margin', 'Turnover', 'Lev', 'Log_Assets', 'Profitable'] # 实验部分所需的分析列
financial_statement_data[['Margin', 'Turnover', 'Lev', 'Log_Assets']] = financial_statement_data[['Margin', 'Turnover', 'Lev', 'Log_Assets']].replace([np.inf, -np.inf], np.nan) # 仅分析列中的inf替换为NaN
cleaned_statement_data = financial_statement_data.dropna(subset=lab_analysis_cols) # 仅按分析列去除缺失值
cleaned_statement_data = cleaned_statement_data[ # 定义cleaned_statement_data变量
(cleaned_statement_data['Margin'].between(-1, 1)) & # 过滤净利率极端值
(cleaned_statement_data['Turnover'].between(0, 10)) # 过滤周转率极端值
] # 执行数据处理操作
# 采样以加快实验速度(安全采样,防止数据不足)
subset_modeling_data = cleaned_statement_data.sample(n=min(2000, len(cleaned_statement_data)), random_state=42) # 随机抽取建模子样本
classification_features = subset_modeling_data[['Margin', 'Turnover', 'Lev', 'Log_Assets']] # 提取四维特征矩阵
target_profitable = subset_modeling_data['Profitable'] # 提取是否盈利目标向量
# 分割为训练集(70%)和测试集(30%)
clf_features_train, clf_features_test, clf_target_train, clf_target_test = train_test_split(classification_features, target_profitable, test_size=0.3, random_state=42) # 随机划分训练/测试集接下来,拟合分类树并评估其分类效果。
# 2. 拟合分类树
tree_classifier = DecisionTreeClassifier(criterion='gini', max_depth=3, random_state=42) # 初始化分类树(Gini准则,最大深度3层)
tree_classifier.fit(clf_features_train, clf_target_train) # 在训练集上拟合分类树
# 3. 评估模型
predicted_classes = tree_classifier.predict(clf_features_test) # 对测试集进行分类预测
print('Classification Report:') # 输出分类报告标题
print(classification_report(clf_target_test, predicted_classes)) # 打印精确率、召回率、F1分数
# 混淆矩阵
confusion_matrix_result = confusion_matrix(clf_target_test, predicted_classes) # 计算混淆矩阵
print(f'混淆矩阵:\n{confusion_matrix_result}') # 输出混淆矩阵
# 4. 可视化规则(文本形式,便于硬编码进生产系统)
classification_tree_rules = export_text(tree_classifier, feature_names=list(classification_features.columns)) # 导出树规则为文本格式
print('\n部分树规则:\n') # 输出规则标题
print(classification_tree_rules) # 打印树的if-else规则文本Classification Report:
precision recall f1-score support
0 1.00 1.00 1.00 92
1 1.00 1.00 1.00 508
accuracy 1.00 600
macro avg 1.00 1.00 1.00 600
weighted avg 1.00 1.00 1.00 600
混淆矩阵:
[[ 92 0]
[ 0 508]]
部分树规则:
|--- Margin <= 0.00
| |--- class: 0
|--- Margin > 0.00
| |--- class: 1
若该树出现近乎完美的测试表现,首先应把它视为泄漏诊断信号:Margin 的分子正是定义当期盈利标签的净利润,在营业收入为正时其符号与标签近乎恒等。这个受控反例用于练习发现目标派生特征,不能作为预测模型成绩;后续案例改用下一期标签与预测时可获得的特征。
补充说明:看到 Accuracy、Precision、Recall、F1 和 AUC 时到底该先看什么
树模型章节里指标很多,学生最容易迷失在“数字都很好看,但不知道该怎么解释”。可以按下面顺序阅读:
- 先看混淆矩阵:它告诉你模型把哪一类错成了哪一类,是所有分类指标的来源。
- 再看准确率:回答“总体上判对了多少”,适合类别相对平衡的场景。
- 再区分精确率和召回率:
- 精确率高,表示模型发出的“风险警报”更可信;
- 召回率高,表示模型漏掉的真正风险更少。
- 最后看 AUC:如果模型能输出概率,AUC 反映的是排序能力,而不是某个固定阈值下的表现。
尤其在金融任务里,Accuracy 和 AUC 往往要结合起来看。一个模型的准确率可能一般,但 AUC 不低,说明它虽然在默认阈值下表现平平,却可能仍有不错的排序价值;只要进一步调阈值、结合交易成本或风控成本重新设计决策规则,仍有业务利用空间。反过来,如果准确率看起来不差,但 AUC 接近0.5,就往往意味着模型只是吃到了类别比例或默认阈值的便宜,本质上并没有真正学会区分风险与非风险。
8.4.2 拟合回归树
下面结束泄漏反例,改用 \(t\) 期特征预测同一公司连续 \(t+1\) 期 ROE,并按报告期前向切分。这样,回归树、随机森林和 Boosting 共用同一组可审计的训练集与锁定未来测试集。
from sklearn.tree import DecisionTreeRegressor # 回归树模型
from sklearn.metrics import mean_squared_error, r2_score # MSE 与 R² 评估
financial_statement_data['period'] = pd.PeriodIndex(financial_statement_data['quarter'], freq='Q')
financial_statement_data['ROE'] = financial_statement_data['net_profit'] / financial_statement_data['equity_parent_company']
financial_statement_data = financial_statement_data.sort_values(['order_book_id', 'period'])
financial_statement_data['future_ROE'] = financial_statement_data.groupby('order_book_id')['ROE'].shift(-1)
financial_statement_data['future_period'] = financial_statement_data.groupby('order_book_id')['period'].shift(-1)
financial_statement_data['period_ordinal'] = financial_statement_data['period'].astype('int64') # 用紧凑整数表示季度
financial_statement_data['future_period_ordinal'] = financial_statement_data.groupby('order_book_id')['period_ordinal'].shift(-1) # 构造下一记录季度序号
future_gap = financial_statement_data['future_period_ordinal'] - financial_statement_data['period_ordinal'] # 直接计算季度序号差
future_rows = financial_statement_data.loc[future_gap.eq(1)].copy() # 仅保留公司内严格连续的下一季度
regression_columns = ['Margin', 'Turnover', 'Lev', 'Log_Assets', 'future_ROE']
future_rows[regression_columns] = future_rows[regression_columns].replace([np.inf, -np.inf], np.nan)
future_rows = future_rows.dropna(subset=regression_columns)
future_rows = future_rows[future_rows['future_ROE'].between(-0.5, 0.5)].sort_values('period')cutoff = future_rows['period'].drop_duplicates().sort_values().iloc[int(future_rows['period'].nunique() * 0.7)]
regression_features = future_rows[['Margin', 'Turnover', 'Lev', 'Log_Assets']]
regression_target_roe = future_rows['future_ROE']
reg_features_train = regression_features.loc[future_rows['future_period'] < cutoff]
reg_features_test = regression_features.loc[future_rows['period'] >= cutoff]
reg_target_train = regression_target_roe.loc[reg_features_train.index]
reg_target_test = regression_target_roe.loc[reg_features_test.index]
# 拟合回归树(限制最大深度4层,平衡偏差与方差)
tree_regressor = DecisionTreeRegressor(max_depth=4, random_state=42) # 初始化回归树
tree_regressor.fit(reg_features_train, reg_target_train) # 在训练集上拟合模型
# 预测并评估
predicted_roe = tree_regressor.predict(reg_features_test) # 对测试集进行ROE预测
# 计算MSE和R²
test_set_mse = mean_squared_error(reg_target_test, predicted_roe) # 计算测试集均方误差
test_set_r2 = r2_score(reg_target_test, predicted_roe) # 计算测试集R²决定系数
print(f'测试集MSE: {test_set_mse:.4f}') # 输出MSE
print(f'测试集R²: {test_set_r2:.4f}') # 输出R²测试集MSE: 0.0048
测试集R²: 0.3975
打印出的 MSE 与 \(R^2\) 是本次锁定未来期间的现场结果。\(R^2\) 较低或为负都应如实保留;它表示模型未能优于测试期均值基准,而不是代码失败。后续集成方法必须使用完全相同的时间切分。
8.4.3 Bagging和随机森林
我们使用 RandomForestRegressor 来改进预测。记住,Bagging 只是 max_features 设置为总特征数的随机森林的一个特例。
单棵树的极限就是集成的起点。RandomForestRegressor 当前默认 max_features=1.0,即每次分裂考察全部特征;要主动降低树间相关性,应显式设置小于 1 的比例或特征数。feature_importances_ 汇总训练样本上的 MSE 不纯度下降,可能偏向取值多的变量;它不是“因子有效性”、经济贡献或因果效应,解释时应与锁定测试集上的 permutation importance 对照。
from sklearn.ensemble import RandomForestRegressor # 随机森林回归
# 随机森林显式使用特征子空间;全特征版本属于 Bagging 对照
random_forest_regressor = RandomForestRegressor(
n_estimators=100, max_features='sqrt', random_state=42
)
random_forest_regressor.fit(reg_features_train, reg_target_train) # 在训练集上拟合随机森林
rf_regressor_mse = mean_squared_error(reg_target_test, random_forest_regressor.predict(reg_features_test)) # 计算测试集MSE
print(f'随机森林测试集MSE: {rf_regressor_mse:.4f}') # 输出MSE
print(f'随机森林测试集R²: {r2_score(reg_target_test, random_forest_regressor.predict(reg_features_test)):.4f}') # 输出R²
print('实际 max_features:', random_forest_regressor.get_params()['max_features'])
# 特征重要性(回归树基于 MSE 不纯度下降的累计,不是 Gini 或因果贡献)
feature_importance_df = pd.DataFrame({ # 构建DataFrame数据表
'importance': random_forest_regressor.feature_importances_ # 提取各特征的重要性分数
}, index=regression_features.columns).sort_values('importance', ascending=False) # 按重要性降序排列
print('\n特征重要性 (Random Forest):') # 输出特征重要性标题
print(feature_importance_df) # 打印特征重要性排名表随机森林测试集MSE: 0.0039
随机森林测试集R²: 0.5144
实际 max_features: sqrt
特征重要性 (Random Forest):
importance
Margin 0.427495
Lev 0.213020
Turnover 0.210473
Log_Assets 0.149013
比较上方两个现场 MSE 才能判断随机森林在该测试期是否改善。feature_importances_ 是训练样本内不纯度下降的描述量,不是经济贡献、因果效应或可交易因子证据;需要解释时应另用锁定测试期的置换重要性和重复切分稳定性。
8.4.4 Boosting
最后,我们使用梯度提升树(Gradient Boosting)。
最后拟合梯度提升树(GradientBoostingRegressor):n_estimators=100、learning_rate=0.1、max_depth=3。这组参数只是本次冻结比较的实现配方,不预示 Boosting 胜出。它与单棵树、随机森林共用完全相同的未来测试期,模型排序只由现场 MSE 及跨切分稳定性决定。
from sklearn.ensemble import GradientBoostingRegressor # 梯度提升回归
# Boosting(100棵树,学习率0.1,最大深度3层的弱学习器)
# 初始化梯度提升模型
gradient_boosting_regressor = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1,
max_depth=3, random_state=42) # 初始化梯度提升回归模型
gradient_boosting_regressor.fit(reg_features_train, reg_target_train) # 在训练集上拟合模型
gb_regressor_mse = mean_squared_error(reg_target_test, gradient_boosting_regressor.predict(reg_features_test)) # 计算测试集MSE
print(f'Boosting测试集MSE: {gb_regressor_mse:.4f}') # 输出MSE
print(f'Boosting测试集R²: {r2_score(reg_target_test, gradient_boosting_regressor.predict(reg_features_test)):.4f}') # 输出R²Boosting测试集MSE: 0.0042
Boosting测试集R²: 0.4742
上方现场输出与同一锁定未来测试期的单棵树、随机森林结果共同决定排序。若集成方法没有改善,就应报告“未观察到改进”;一次切分不能验证普遍优势,也不能把训练样本的不纯度重要性解释成经济机制。
8.5 小结
本章介绍了基于树的方法,包括决策树、Bagging、随机森林、Boosting和BART。这些方法具有以下特点:
优点:
- 灵活性: 可以建模复杂的非线性关系和交互作用
- 鲁棒性: 对异常值和不相关变量相对鲁棒
- 可解释性: 单棵树易于可视化和解释
- 处理混合数据类型: 可以自然地处理定量和定性预测变量
- 无需特征缩放: 不需要标准化或归一化特征
缺点:
- 预测精度: 单棵树的方差往往较高,但是否不如其他方法必须用同一样本外切分判定
- 不稳定性: 数据的微小变化可能导致树结构的巨大变化
- 外推能力: 无法很好地外推到训练数据范围之外
- 集成方法的解释性: Bagging、随机森林和Boosting降低了可解释性
何时使用:
- 当预测变量和响应之间的关系可能高度非线性且复杂时
- 当可解释性比预测精度更重要时(使用单棵树)
- 当预测精度重要时,把集成方法纳入同切分候选,而不预先指定胜者
- 当有混合类型的预测变量(定量和定性)时
8.6 理论来源与前沿
树模型的理论起点是‘递归分割’:通过一系列可解释的规则把特征空间切分成若干区域,并在每个区域内做简单预测。CART 把分裂准则、剪枝与交叉验证组合成一套可操作的算法体系。随机森林通过对样本与特征的随机化降低单棵树的方差 (Breiman 2001年);梯度 Boosting 则把弱学习器按损失的负梯度方向逐步叠加 (Friedman 2001年)。这些来源支持算法定义,不替代本章样本外验证,也不把特征重要性升级为因果效应。
近年来的前沿主要集中在:
- 可解释的集成模型:在保持准确度的同时,用特征重要性、部分依赖与 SHAP 等方法解释模型。
- 因果森林与异质性效应:把树集成用于估计处理效应异质性,服务政策评估与精准营销。
- 高效实现与大规模训练:例如直方图分裂、单边梯度采样与分布式训练,使得工程部署更可扩展。
目标—评价证据:O8.1 对应练习 2、7;O8.2 对应练习 3—5、9;O8.3—O8.5 对应练习 7、8 与 M08;Boosting 更新对应练习 6、10。达标要求概念/理论题至少 70% 分,终端未知标签探针通过,应用题包含基线、三模型、有限 AUC/Brier 和重要性表。
8.7 项目里程碑:M08 树模型卡与泄漏审计
本里程碑落实 小节 6.3,并复用 a-share-drawdown-20d-v1、M02 唯一 manifest/hash 与 M05 审计折。输出:末端 NA/label_date 审计、训练期剪枝路径、训练基线,以及剪枝树/RF/Boosting 的验证 AUC、Brier、重要性和 registry 模型卡;M08 不访问测试标签。Bagging 保留为正文概念,不是 M08 必交候选。
时点与资源:训练 label_date 最大值严格早于验证 prediction_date 最小值;课堂目标 60 分钟。失败条件:manifest/hash 不匹配、未知未来被编码为 0、缺 label_date、访问测试键、固定 fallback 名称、RF 未做特征子抽样或缺训练基线。量规(20 分):标签/时点 6 分,训练期选择 5 分,验证证据 5 分,复现与解释 4 分。
本章闭环:M08 不复用正文的海康威视单股对象,而是独立读取后复权全公司源,以 manifest 为权威左表验证开发键完全相等。剪枝树、随机森林与 sklearn 梯度提升共用同一特征 schema,同时登记 AUC、Brier、重要性与可复算产物哈希。
8.8 练习
8.8.1 概念题
对分类树而言,Gini 指数与交叉熵(entropy)作为分裂准则有什么差异?它们在实践中的表现通常差别大吗? [核心|难度:1|时间:7分钟|分值:5|项目:无]
解释“剪枝(pruning)”的目的。为什么直接生长到最大深度的树往往会过拟合? [核心|难度:1|时间:7分钟|分值:5|项目:无]
bagging 为什么能降低方差?它对偏差的影响一般是什么方向? [核心|难度:2|时间:7分钟|分值:5|项目:无]
随机森林相对于 bagging 额外引入了“特征子采样”。这一步主要解决什么问题? [核心|难度:1|时间:7分钟|分值:5|项目:无]
解释 OOB(out-of-bag)误差的含义。它为什么可以近似交叉验证误差? [核心|难度:2|时间:7分钟|分值:5|项目:无]
Boosting 与 bagging 的核心思路分别是什么?Boosting 中学习率(shrinkage)通常如何影响性能与训练轮数? [核心|难度:2|时间:7分钟|分值:5|项目:无]
8.8.2 应用题
按
a-share-drawdown-20d-v1与 M02 唯一 manifest,在 M05 审计的训练—验证键上训练以下模型,并报告训练基线、验证 AUC/Brier、重要性、label_date边界和末端NA计数;不得访问测试标签: [核心|难度:3|时间:60分钟|分值:20|项目:M08]- 单棵分类树(含剪枝)
- 随机森林
- 梯度提升树(GBDT)
要求用时间切分评估,并比较三者的 AUC、校准(Brier)与可解释性(特征重要性)。
在同一任务上,比较: [拓展|难度:3|时间:40分钟|分值:15|项目:无]
- 使用全部特征训练随机森林
- 只使用你认为“经济含义最强”的 5–10 个特征训练随机森林
讨论预测性能变化与稳健性权衡。
8.8.3 理论题
设有 \(B\) 个基学习器的平均预测 \(\bar f(x)=\frac1B\sum_{b=1}^B f_b(x)\)。在 \(\mathrm{Var}(f_b)=\sigma^2\) 且两两相关系数为 \(\rho\) 的假设下,推导 \(\mathrm{Var}(\bar f(x))\),并据此解释“相关性越低,bagging 越有效”。 [补救|难度:2|时间:18分钟|分值:10|项目:无]
说明对平方损失,梯度提升(gradient boosting)的每一步更新等价于对残差拟合一个弱学习器,并写出更新形式。 [补救|难度:2|时间:15分钟|分值:10|项目:无]
展开完整参考解答与评分键
8.9 练习参考解答
统一评分与验收:每题分值见元数据;概念/推导题按“定义 40%—机制/推导 40%—边界 20%”给分。代码题要求终端未知标签保持缺失、指标有限、概率在 \([0,1]\)、重要性非负且和在 \(10^{-6}\) 内接近 1。测试期剪枝、单类 AUC、缺基线、未知标签编码为 0 或因果化重要性均判失败。
8.9.1 概念题参考解答
差异:Gini 与 entropy 都衡量节点纯度,二者都是凸函数并在纯节点取 0。实践中分裂结果常非常接近,差别通常不大。
剪枝目的:降低方差、提升泛化。最大树会把训练集噪声也当作信号进行复杂切分,导致测试误差上升。剪枝相当于做复杂度选择。
bagging 降方差:通过对不同自助样本训练的模型做平均,噪声被相互抵消,从而降低方差;偏差通常变化不大(对高方差学习器最有效,如深树)。
特征子采样的作用:降低树与树之间的相关性(尤其是强预测特征会反复被选为顶部分裂,导致树结构相似),相关性降低后平均的方差下降更明显。
OOB 含义:每棵树只用到约 63.2% 的样本(自助法重复抽样),未被该树抽中的样本是它的“袋外”样本。对每个样本,用未见过它的树投票/平均得到预测,再算误差,近似于交叉验证。
bagging vs boosting:bagging 并行训练、靠平均降方差;boosting 串行训练、每步针对当前误差(残差/梯度)改进,既可能降偏差也可能影响方差。学习率越小,通常需要更多迭代但泛化更稳健。
8.9.2 应用题参考解答
- 冻结回撤任务的训练—验证比较:本答案从 fresh kernel 读取运行器提供的开发特征/开发标签,不读取全量行情,也不自行重建标签。manifest 开发键和
label_date是权威;完整无结果日历仅用于证明每家公司末端窗口保持未知,任一缺键、多键或冲突列都 fail closed。
import hashlib # 为 manifest、键、registry 和产物生成内容哈希
import json # 读写公共 JSON Lines registry
import os # 读取课程运行器显式提供的隔离入口
import pickle # 序列化开发键重拟后的树模型
from pathlib import Path # 解析运行器输入路径
import numpy as np # 执行折索引、剪枝选择与有限值守卫
import pandas as pd # 读取权限分离的开发制品与无结果日历
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier # 使用固定 sklearn 集成实现
from sklearn.metrics import brier_score_loss, roc_auc_score # 同时记录概率与排序指标
from sklearn.tree import DecisionTreeClassifier # 拟合成本复杂度剪枝树
m08_manifest_path = Path(os.environ['BOOK_PROJECT_MANIFEST']).expanduser().resolve() # 锁定M02唯一manifest
m08_manifest_hash = hashlib.sha256(m08_manifest_path.read_bytes()).hexdigest() # 计算实际 manifest 哈希
assert m08_manifest_hash == os.environ['BOOK_PROJECT_MANIFEST_SHA256'].lower() # 拒绝切分漂移
m08_manifest = pd.read_csv(m08_manifest_path) # 读取冻结键与分段
m08_manifest[['prediction_date', 'label_date']] = m08_manifest[['prediction_date', 'label_date']].apply(pd.to_datetime) # 统一原点与权威终点日类型
assert not m08_manifest.duplicated(['order_book_id', 'prediction_date']).any() # 要求公司日键唯一m08_feature_path = Path(os.environ['BOOK_PROJECT_DEVELOPMENT_FEATURES']).resolve() # 只解析运行器提供的开发特征
m08_label_path = Path(os.environ['BOOK_PROJECT_DEVELOPMENT_LABELS']).resolve() # 只解析运行器提供的开发标签
m08_calendar_path = Path(os.environ['BOOK_PROJECT_KEY_CALENDAR']).resolve() # 解析不含结果的完整公司交易日日历
m08_features = ['ret_1', 'momentum_5', 'momentum_20', 'volatility_20'] # 冻结统一价量特征 schema
m08_key_columns = ['order_book_id', 'prediction_date'] # 冻结跨制品公司日键
m08_feature_frame = pd.read_csv(m08_feature_path, parse_dates=['prediction_date']) # 读取不含结果代理的开发特征
m08_label_frame = pd.read_csv(m08_label_path, parse_dates=['prediction_date']) # 读取只含开发事件的标签源
m08_calendar = pd.read_csv(m08_calendar_path, parse_dates=['prediction_date', 'label_date']) # 读取无结果终点日日历
m08_forbidden_features = {'y', 'event', 'label_date', 'close', 'future_min', 'future_min_close', 'split'} # 禁止未来窗口与保留字段进入特征源
assert set(m08_feature_frame.columns).issuperset(m08_key_columns + m08_features) # 特征源必须覆盖冻结模型列
assert not m08_forbidden_features.intersection(m08_feature_frame.columns) # 审计前不得持有结果代理
assert set(m08_label_frame.columns) == set(m08_key_columns + ['y']) # 标签源只能含开发键和事件
assert set(m08_calendar.columns) == set(m08_key_columns + ['label_date']) # 日历不得夹带任何结果字段
assert not m08_feature_frame.duplicated(m08_key_columns).any() and not m08_label_frame.duplicated(m08_key_columns).any() # 开发制品键必须唯一
m08_feature_frame = m08_feature_frame[m08_key_columns + m08_features].copy() # 只保留事前登记特征
assert set(m08_label_frame['y'].dropna().unique()) == {0, 1} # 标签必须是完整二元开发结果
m08_unknown_by_company = m08_calendar['label_date'].isna().groupby(m08_calendar['order_book_id']).sum() # 从完整无结果日历统计真实末端未知
assert len(m08_unknown_by_company) > 1 and m08_unknown_by_company.ge(20).all() # 每家公司至少保留二十个无法完整观察的末端原点
m08_unknown_terminal_count = int(m08_unknown_by_company.sum()) # 登记不会被 manifest 过滤抹去的末端未知证据def m08_key_hash(key_frame): # 定义与公共 registry 一致的键哈希
canonical_keys = key_frame[['order_book_id', 'prediction_date']].sort_values(['order_book_id', 'prediction_date']).copy() # 固定行列顺序
canonical_keys['prediction_date'] = canonical_keys['prediction_date'].dt.strftime('%Y-%m-%d') # 固定日期序列化
return hashlib.sha256(canonical_keys.to_csv(index=False, lineterminator='\n').encode()).hexdigest() # 返回 SHA-256
m08_development_manifest = m08_manifest[m08_manifest['split'].isin(['train', 'validation'])].copy() # 只领取开发键
assert {'order_book_id', 'prediction_date', 'label_date', 'split'}.issubset(m08_manifest.columns) # manifest 提供唯一权威 label_date
assert not any(column.endswith(('_x', '_y')) for column in m08_manifest.columns) # 禁止上游列冲突后缀
m08_development_key_set = set(map(tuple, m08_development_manifest[m08_key_columns].to_numpy())) # 冻结权威开发键集合
assert m08_development_key_set == set(map(tuple, m08_feature_frame[m08_key_columns].to_numpy())) == set(map(tuple, m08_label_frame[m08_key_columns].to_numpy())) # 拒绝开发制品缺键或增键
m08_frame = m08_development_manifest.merge(m08_feature_frame, on=m08_key_columns, how='left', validate='one_to_one', indicator='feature_merge') # 权威左连接开发特征
assert m08_frame['feature_merge'].eq('both').all() # 要求全部开发键有特征
m08_frame = m08_frame.drop(columns='feature_merge').merge(m08_label_frame, on=m08_key_columns, how='left', validate='one_to_one', indicator='label_merge') # 权威左连接开发标签
assert m08_frame['label_merge'].eq('both').all() # 要求全部开发键有标签
m08_frame = m08_frame.rename(columns={'y': 'event'}).drop(columns='label_merge') # 连接后统一章内事件名
assert not any(column.endswith(('_x', '_y')) for column in m08_frame.columns) # 明确拒绝静默重名
assert not m08_frame[m08_features + ['event', 'label_date']].isna().any().any() # 禁止静默丢失开发键
m08_frame = m08_frame.sort_values(['prediction_date', 'order_book_id']).reset_index(drop=True) # 固定面板顺序
assert m08_key_hash(m08_frame) == m08_key_hash(m08_development_manifest) # 审计实际建模键与 manifest 开发键完全相等
m08_train = m08_frame[m08_frame['split'].eq('train')].copy().reset_index(drop=True) # 冻结训练键
m08_validation = m08_frame[m08_frame['split'].eq('validation')].copy().reset_index(drop=True) # 冻结验证键
assert m08_train['label_date'].max() < m08_validation['prediction_date'].min() # 执行严格 purge
assert m08_train['event'].nunique() == m08_validation['event'].nunique() == 2 # 禁止单类拟合或 AUC
m08_development_hashes = {'train': m08_key_hash(m08_train), 'validation': m08_key_hash(m08_validation), 'combined': m08_key_hash(m08_frame)} # 登记开发键哈希m08_policy_path = Path(os.environ['BOOK_PROJECT_FOLD_POLICY']).resolve() # 解析 M05 持久化折政策
m08_fold_path = Path(os.environ['BOOK_PROJECT_FOLD_ARTIFACT']).resolve() # 解析 M05 持久化折成员
m08_policy_bytes = m08_policy_path.read_bytes() # 保留政策原始字节
m08_fold_bytes = m08_fold_path.read_bytes() # 保留折成员原始字节
m08_policy_hash = hashlib.sha256(m08_policy_bytes).hexdigest() # 计算实际政策哈希
m08_fold_hash = hashlib.sha256(m08_fold_bytes).hexdigest() # 计算实际折成员哈希
assert m08_policy_hash == os.environ['BOOK_PROJECT_FOLD_POLICY_SHA256'].lower() # 拒绝政策漂移
assert m08_fold_hash == os.environ['BOOK_PROJECT_FOLD_SHA256'].lower() # 拒绝折成员漂移
m08_policy = json.loads(m08_policy_bytes) # 解析已核验政策
m08_fold_artifact = json.loads(m08_fold_bytes) # 解析已核验折成员
assert m08_policy['schema_version'] == '1' and m08_policy['contract_id'] == 'a-share-drawdown-20d-v1' # 核对政策身份
assert m08_policy['manifest_sha256'] == m08_manifest_hash and m08_policy['source_split'] == 'train' # 绑定唯一 manifest 训练段
assert m08_policy['key_columns'] == m08_key_columns and m08_policy['construction'] == 'expanding-date-blocks' # 禁止键或构造漂移
assert m08_policy['purge_rule'] == 'fit.label_date < min(score.prediction_date)' # 固定 purge 规则
assert m08_fold_artifact['schema_version'] == '1' and m08_fold_artifact['contract_id'] == m08_policy['contract_id'] # 核对折制品版本与任务身份
assert m08_fold_artifact['policy_sha256'] == m08_policy_hash and m08_fold_artifact['manifest_sha256'] == m08_manifest_hash # 关闭制品关联链m08_train_key_index = {(row.order_book_id, row.prediction_date.strftime('%Y-%m-%d')): row.Index for row in m08_train.itertuples()} # 将持久化键映射为训练行索引
m08_forward_folds = [] # 只保存从 M05 制品还原的索引对
for m08_fold_record in m08_fold_artifact['folds']: # 逐折核验持久化成员
m08_fold_core = {key: value for key, value in m08_fold_record.items() if key != 'fold_sha256'} # 分离单折内容与登记哈希
m08_actual_fold_hash = hashlib.sha256((json.dumps(m08_fold_core, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n').encode()).hexdigest() # 复算单折规范哈希
assert m08_actual_fold_hash == m08_fold_record['fold_sha256'] # 任一折内容被改动即失败
m08_fit_keys = [tuple(key) for key in m08_fold_record['fit_keys']] # 恢复拟合键
m08_score_keys = [tuple(key) for key in m08_fold_record['score_keys']] # 恢复评分键
assert len(m08_fit_keys) == len(set(m08_fit_keys)) and len(m08_score_keys) == len(set(m08_score_keys)) # 折内键唯一
assert set(m08_fit_keys).isdisjoint(m08_score_keys) # 拟合与评分成员互斥
assert set(m08_fit_keys + m08_score_keys).issubset(m08_train_key_index) # 折键只能来自 manifest train
m08_fit_index = np.array([m08_train_key_index[key] for key in m08_fit_keys], dtype=int) # 恢复拟合索引
m08_score_index = np.array([m08_train_key_index[key] for key in m08_score_keys], dtype=int) # 恢复评分索引
assert m08_train.iloc[m08_fit_index]['label_date'].max() < m08_train.iloc[m08_score_index]['prediction_date'].min() # 用权威终点日复核 purge
assert m08_fold_record['fit_label_date_max'] == m08_train.iloc[m08_fit_index]['label_date'].max().strftime('%Y-%m-%d') # 核对拟合边界
assert m08_fold_record['score_prediction_date_min'] == m08_train.iloc[m08_score_index]['prediction_date'].min().strftime('%Y-%m-%d') # 核对评分边界
assert m08_train.iloc[m08_fit_index]['event'].nunique() == m08_train.iloc[m08_score_index]['event'].nunique() == 2 # 禁止单类折
m08_forward_folds.append((m08_fit_index, m08_score_index)) # 登记已核验 M05 折m08_first_fit_index = m08_forward_folds[0][0] # 只用首个持久化拟合成员生成有限剪枝网格
m08_full_alpha_path = DecisionTreeClassifier(random_state=0).cost_complexity_pruning_path(m08_train.iloc[m08_first_fit_index][m08_features], m08_train.iloc[m08_first_fit_index]['event'].astype(int)).ccp_alphas # 不查看任何评分成员生成路径
m08_unique_alphas = np.unique(m08_full_alpha_path) # 移除重复剪枝强度
m08_alpha_path = m08_unique_alphas[::max(1, len(m08_unique_alphas) // 20)] # 事前控制课堂候选数
m08_alpha_brier = [] # 收集每个候选的同折平均损失
for candidate_alpha in m08_alpha_path: # 显式比较剪枝候选
m08_fold_brier = [] # 收集当前强度的全部 M05 折证据
for m08_fit_index, m08_score_index in m08_forward_folds: # 严格复用 M05 折成员
m08_alpha_model = DecisionTreeClassifier(ccp_alpha=candidate_alpha, min_samples_leaf=20, random_state=0) # 固定其他树约束
m08_alpha_model.fit(m08_train.iloc[m08_fit_index][m08_features], m08_train.iloc[m08_fit_index]['event'].astype(int)) # 仅拟合持久化 fit 键
m08_alpha_probability = m08_alpha_model.predict_proba(m08_train.iloc[m08_score_index][m08_features])[:, 1] # 只预测持久化 score 键
m08_fold_brier.append(brier_score_loss(m08_train.iloc[m08_score_index]['event'].astype(int), m08_alpha_probability)) # 记录当折 Brier
m08_alpha_brier.append(float(np.mean(m08_fold_brier))) # 汇总同一折政策下的选择损失
m08_selected_alpha = float(m08_alpha_path[int(np.argmin(m08_alpha_brier))]) # 只依据 M05 折冻结剪枝参数m08_candidate_specs = { # 声明三个标准树候选
'pruned_tree': DecisionTreeClassifier(ccp_alpha=m08_selected_alpha, min_samples_leaf=20, random_state=0), # 训练期剪枝树
'random_forest': RandomForestClassifier(n_estimators=300, max_features='sqrt', min_samples_leaf=20, n_jobs=-1, random_state=0), # 固定特征子抽样 RF
'gradient_boosting': GradientBoostingClassifier(n_estimators=300, max_depth=2, learning_rate=0.03, random_state=0), # 固定 sklearn GBDT
} # 完成统一候选集
m08_train_y = m08_train['event'].astype(int) # 冻结训练标签
m08_validation_y = m08_validation['event'].astype(int) # 冻结验证标签
m08_result_rows = [{'candidate_id': 'training_event_rate_baseline', 'auc': 0.5, 'brier': brier_score_loss(m08_validation_y, np.repeat(m08_train_y.mean(), len(m08_validation_y)))}] # 训练事件率基线
m08_importance_rows = [] # 保存三模型的非因果不纯度重要性
for candidate_id, fitted_model in m08_candidate_specs.items(): # 仅在训练键拟合已冻结候选
fitted_model.fit(m08_train[m08_features], m08_train_y) # 拟合正式训练候选
validation_probability = fitted_model.predict_proba(m08_validation[m08_features])[:, 1] # 验证键只预测一次
m08_result_rows.append({'candidate_id': candidate_id, 'auc': roc_auc_score(m08_validation_y, validation_probability), 'brier': brier_score_loss(m08_validation_y, validation_probability)}) # 同时记录 AUC 与 Brier
m08_importance_rows.extend({'candidate_id': candidate_id, 'feature': feature_name, 'importance': float(feature_importance)} for feature_name, feature_importance in zip(m08_features, fitted_model.feature_importances_)) # 保留全特征重要性
m08_result_table = pd.DataFrame(m08_result_rows) # 整理模型与基线指标
m08_importance_table = pd.DataFrame(m08_importance_rows) # 整理长表重要性
assert np.isfinite(m08_result_table[['auc', 'brier']]).all().all() # 禁止非有限验证指标
assert (m08_importance_table['importance'] >= 0).all() # 核对不纯度重要性非负
print(m08_result_table.to_string(index=False), '\n', m08_importance_table.to_string(index=False)) # 输出不预写胜者的现场证据
print('unknown_terminal_count=', m08_unknown_terminal_count, 'manifest_sha256=', m08_manifest_hash) # 输出末端与切分审计m08_registry_path = Path(os.environ['BOOK_CANDIDATE_REGISTRY']).expanduser().resolve() # 锁定公共 registry
m08_registry_path.parent.mkdir(parents=True, exist_ok=True) # 确保运行器授权目录存在
m08_artifact_directory = m08_registry_path.parent / 'candidate_artifacts' # 统一产物根目录
m08_artifact_directory.mkdir(parents=True, exist_ok=True) # 创建候选产物目录
m08_registry_records = [] # 收集三个标准候选记录
for candidate_id, model_recipe in m08_candidate_specs.items(): # 按冻结配方在完整开发键上重拟
development_model = model_recipe.__class__(**model_recipe.get_params()) # 新建同配方估计器
development_model.fit(m08_frame[m08_features], m08_frame['event'].astype(int)) # 在训练加验证键上重拟
artifact_bytes = pickle.dumps(development_model, protocol=5) # 序列化 M09 将读取的真实候选
artifact_path = m08_artifact_directory / f'm08-{candidate_id}.pkl' # 生成唯一产物名
artifact_path.write_bytes(artifact_bytes) # 持久化冻结模型
validation_row = m08_result_table.set_index('candidate_id').loc[candidate_id] # 取回当次验证证据
candidate_importance = m08_importance_table[m08_importance_table['candidate_id'].eq(candidate_id)].set_index('feature')['importance'].to_dict() # 将重要性并入验证记录
m08_registry_records.append({'contract_id': 'a-share-drawdown-20d-v1', 'milestone_id': 'M08', 'candidate_id': candidate_id, 'implementation': type(development_model).__module__ + '.' + type(development_model).__name__, 'feature_schema': {'columns': m08_features, 'dtype': 'float64', 'as_of': 'company-day-t-close'}, 'manifest_sha256': m08_manifest_hash, 'fold_policy_sha256': m08_policy_hash, 'fold_sha256': m08_fold_hash, 'development_key_hashes': m08_development_hashes, 'preprocessing': ['none; tree models are fit on raw frozen features'], 'frozen_hyperparameters': model_recipe.get_params(), 'validation_metrics': {'auc': float(validation_row['auc']), 'brier': float(validation_row['brier']), 'impurity_importance': candidate_importance}, 'refit_recipe': {'fit_splits': ['train', 'validation'], 'target': 'event', 'probability_rule': 'predict_proba'}, 'artifact_sha256': hashlib.sha256(artifact_bytes).hexdigest(), 'artifact_file': str(artifact_path.relative_to(m08_registry_path.parent)), 'status': 'ready', 'failure_reason': None}) # 填满公共 schema 与 M05 折证据m08_required_fields = {'contract_id', 'milestone_id', 'candidate_id', 'implementation', 'feature_schema', 'manifest_sha256', 'fold_policy_sha256', 'fold_sha256', 'development_key_hashes', 'preprocessing', 'frozen_hyperparameters', 'validation_metrics', 'refit_recipe', 'artifact_sha256', 'status', 'failure_reason'} # 复用含 M05 折证据的公共 schema
assert all(m08_required_fields.issubset(record) for record in m08_registry_records) # 缺任一必需字段即失败
m08_existing_records = [json.loads(line) for line in m08_registry_path.read_text(encoding='utf-8').splitlines() if line.strip()] if m08_registry_path.exists() else [] # 保留其他里程碑已登记候选
m08_candidate_ids = {record['candidate_id'] for record in m08_registry_records} # 定义本次幂等更新范围
m08_preserved_records = [record for record in m08_existing_records if not (record.get('milestone_id') == 'M08' and record.get('candidate_id') in m08_candidate_ids)] # 移除本里程碑旧版本
m08_complete_registry = sorted(m08_preserved_records + m08_registry_records, key=lambda record: (record['milestone_id'], record['candidate_id'])) # 固定 registry 行序
m08_registry_text = ''.join(json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n' for record in m08_complete_registry) # 生成可复算 JSONL
m08_registry_path.write_text(m08_registry_text, encoding='utf-8') # 幂等写回完整 registry
print('registry_sha256=', hashlib.sha256(m08_registry_text.encode()).hexdigest(), 'records=', len(m08_registry_records)) # 输出 registry 审计证据剪枝强度只在训练键内选择,三个候选只在冻结验证键上记一次 AUC/Brier。不纯度重要性仅是已拟合树的分裂摘要,不是经济贡献或因果效应;registry 写权限与测试集不可见性仍由外部运行器执行。
撤下的旧季度亏损模板(仅作代码审查,不属于 M08):以下静态材料保留用于辨认“换了标签/切分就不再是同一项目”的失败模式,不执行、不计分,也不得报告其中的测试指标。
import pandas as pd # 导入 pandas 用于数据处理
import numpy as np # 数值计算库(处理无穷值等)
from sklearn.metrics import roc_auc_score, brier_score_loss # AUC 与 Brier 评分
from sklearn.tree import DecisionTreeClassifier # 分类树模型
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier # 随机森林与梯度提升分类器
import os # 跨平台路径处理
DATA_DIR = os.path.abspath(os.path.expanduser(os.environ['BOOK_DATA_DIR'])) # 从必需环境变量解析数据根目录
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}' # 缺少挂载时立即失败
df = pd.read_hdf(os.path.join(DATA_DIR, 'stock/financial_statement.h5')) # 读取财务报表数据
# 简单构造特征 (同 Lab)
df['period'] = pd.PeriodIndex(df['quarter'], freq='Q')
df = df.sort_values(['order_book_id', 'period']).copy()
df['debt_ratio'] = df['total_liabilities'] / df['total_assets'] # 用非目标构成项刻画杠杆风险
df['log_assets'] = np.log(df['total_assets'].clip(lower=1)) # 以预测期可得总资产刻画规模
df['current_ratio'] = df['current_assets'] / df['current_liabilities'] # 流动性特征
df['asset_turnover'] = df['operating_revenue'] / df['total_assets'] # 经营效率特征
df['cash_ratio'] = df['cash_equivalent'] / df['total_assets'] # 现金缓冲特征
df['future_net_profit'] = df.groupby('order_book_id')['net_profit'].shift(-1) # 下一报告期净利润;末期保持缺失
df['future_period'] = df.groupby('order_book_id')['period'].shift(-1)
df['next_period_loss'] = df['future_net_profit'].lt(0).astype(int) # 标签严格位于下一报告期
tree_feature_names = ['debt_ratio', 'log_assets', 'current_ratio', 'asset_turnover', 'cash_ratio'] # 预先声明预测特征
exercise_analysis_cols = tree_feature_names + ['future_net_profit', 'period', 'future_period']
df[tree_feature_names] = df[tree_feature_names].replace([np.inf, -np.inf], np.nan) # 清理分析列非有限值
df = df.dropna(subset=exercise_analysis_cols).sort_values(['quarter', 'order_book_id']) # 删除标签末期缺失并按真实日期排序
quarter_gap = (df['future_period'] - df['period']).apply(lambda offset: offset.n)
df = df.loc[quarter_gap.eq(1)].sort_values(['period', 'order_book_id'])数据准备就绪后,按照时间顺序将前 80% 的样本作为训练集、后 20% 作为测试集,分别训练单棵分类树、随机森林和梯度提升三种模型,并在测试集上比较它们的 AUC 和 Brier 评分。
unique_periods = np.sort(df['period'].unique())
test_start_period = unique_periods[int(len(unique_periods) * 0.8)] # 最后20%报告期仅用于测试
train_rows = df['future_period'] < test_start_period
test_rows = df['period'] >= test_start_period
features_train = df.loc[train_rows, tree_feature_names].to_numpy()
features_test = df.loc[test_rows, tree_feature_names].to_numpy()
target_train = df.loc[train_rows, 'next_period_loss'].astype(int).to_numpy()
target_test = df.loc[test_rows, 'next_period_loss'].astype(int).to_numpy()
assert df.loc[train_rows, 'future_period'].max() < df.loc[test_rows, 'period'].min()
training_periods = np.sort(df.loc[train_rows, 'period'].unique()) # 只从训练期取得验证边界
validation_period = training_periods[int(len(training_periods) * 0.8)] # 训练期末段仅用于剪枝选择
subtrain_rows = train_rows & (df['future_period'] < validation_period) # 子训练标签也必须早于验证起点
validation_rows = train_rows & (df['period'] >= validation_period) # 较后训练期作为验证集
assert subtrain_rows.sum() > 0 and validation_rows.sum() > 0 and test_rows.sum() > 0 # 三段样本必须非空
assert all(df.loc[rows, 'next_period_loss'].nunique() == 2 for rows in [subtrain_rows, validation_rows, test_rows]) # AUC与分类拟合要求两类candidate_alphas = DecisionTreeClassifier(random_state=0).cost_complexity_pruning_path( # 仅用子训练期生成剪枝路径
df.loc[subtrain_rows, tree_feature_names], df.loc[subtrain_rows, 'next_period_loss'].astype(int)
).ccp_alphas
candidate_alphas = np.unique(candidate_alphas)[::max(1, len(np.unique(candidate_alphas)) // 20)] # 控制候选数
validation_brier = [] # 保存每个候选的训练期验证误差
for alpha in candidate_alphas: # 显式遍历训练期剪枝路径
candidate_tree = DecisionTreeClassifier(ccp_alpha=alpha, min_samples_leaf=50, random_state=0) # 固定候选剪枝强度
candidate_tree.fit(df.loc[subtrain_rows, tree_feature_names], df.loc[subtrain_rows, 'next_period_loss'].astype(int)) # 只拟合子训练期
validation_probability = candidate_tree.predict_proba(df.loc[validation_rows, tree_feature_names])[:, 1] # 验证期概率
validation_brier.append(brier_score_loss(df.loc[validation_rows, 'next_period_loss'].astype(int), validation_probability)) # 记录Brier
selected_alpha = float(candidate_alphas[int(np.argmin(validation_brier))]) # 只按训练期验证段选择
print('剪枝候选数:', len(candidate_alphas), '选中ccp_alpha:', selected_alpha, '验证Brier:', min(validation_brier)) # 显示选择证据锁定剪枝参数后才在完整训练期重拟合三个模型,并与训练期事件率概率基线比较。
decision_tree_model = DecisionTreeClassifier(ccp_alpha=selected_alpha, min_samples_leaf=50, random_state=0) # 使用训练期选出的剪枝参数
random_forest_model = RandomForestClassifier(n_estimators=500, max_features='sqrt', min_samples_leaf=50, n_jobs=-1, random_state=0) # 随机森林
gradient_boosting_model = GradientBoostingClassifier(learning_rate=0.05, n_estimators=300, max_depth=2, random_state=0) # 梯度提升
exercise_models = [('tree', decision_tree_model), ('rf', random_forest_model), ('gb', gradient_boosting_model)] # 三模型清单
test_probabilities = {} # 保存锁定测试期概率
for name, model in exercise_models: # 遍历三种模型
model.fit(features_train, target_train) # 只用完整训练期拟合
test_probabilities[name] = model.predict_proba(features_test)[:, 1] # 测试期只做一次预测
baseline_probability = np.repeat(target_train.mean(), len(target_test)) # 训练期事件率概率基线
test_probabilities['event-rate baseline'] = baseline_probability # 纳入同表比较
for name, probability in test_probabilities.items(): # 输出规定的两项指标
print(name, 'AUC=', roc_auc_score(target_test, probability), 'Brier=', brier_score_loss(target_test, probability)) # 现场评价最后输出随机森林和梯度提升的重要性;它们是预测分裂贡献,不是因果效应。
importance_table = pd.DataFrame({ # 汇总两类树集成的重要性
'feature': tree_feature_names, # 特征名称
'random_forest': random_forest_model.feature_importances_, # RF不纯度下降
'gradient_boosting': gradient_boosting_model.feature_importances_ # GB不纯度下降
}) # 完成重要性表
importance_table['mean_importance'] = importance_table[['random_forest', 'gradient_boosting']].mean(axis=1) # 排序摘要
print(importance_table.sort_values('mean_importance', ascending=False).to_string(index=False)) # 显示全部特征预期输出性质是:候选剪枝数大于 0、每个模型与基线各有有限 AUC/Brier、重要性为非负且每个模型内和约为 1(浮点容差 \(10^{-6}\))。若训练/验证/测试任一集合为空或只有一类、连续报告期断裂、指标非有限或重要性和超出容差,答案应失败并回到数据审计。若模型未稳定改善基线,应报告无增量预测证据。
- 少特征 vs 全特征:少特征往往更稳健(更少噪声维度、信息泄露风险更低、重要性解释更清晰),但若删掉了关键交互或弱信号,AUC 可能下降。建议在多期滚动窗口上比较均值与波动(稳定性)。
8.9.3 理论题参考解答
- 平均后的方差:
\[ \mathrm{Var}(\bar f)=\mathrm{Var}\Big(\frac1B\sum_{b=1}^B f_b\Big)=\frac{1}{B^2}\Big(B\sigma^2 + B(B-1)\rho\sigma^2\Big) =\rho\sigma^2+\frac{1-\rho}{B}\sigma^2. \]
当 \(B\to\infty\),方差趋于 \(\rho\sigma^2\),因此相关性 \(\rho\) 越小,bagging 的方差下限越低;随机森林正是通过特征子采样来降低 \(\rho\)。
- 平方损失的 boosting 更新:设损失 \(L(y,f)=\frac12(y-f)^2\),其对 \(f\) 的负梯度是残差 \(r_i=y_i-f(x_i)\)。第 \(m\) 步拟合一个弱学习器 \(g_m\) 逼近残差,然后更新
\[ f_m(x)=f_{m-1}(x)+\nu\,g_m(x), \]
其中 \(\nu\in(0,1]\) 是学习率。这说明 boosting 本质上是在函数空间做梯度下降。
8.10 章末学习闭环
逐项自检:能否解释分裂、剪枝、随机森林抽样与 boosting;能否从后复权全公司源精确覆盖开发键;能否报告 AUC/Brier/重要性;能否写出三个候选制品。禁止复用单股示例冒充项目总体,也禁止把重要性解释为因果。常见误区是预设集成必胜、把静默丢键当成功。
不看正文回答:剪枝参数在哪里选择?随机森林为何抽特征?学习率有何作用?迁移任务:为供应链中断风险写树模型审计。下一章消费完整 registry 并完成一次性最终比较。
展开检索答案
剪枝只在训练内部选择;特征抽样降低树间相关性;较小学习率缩小每步更新,通常需要更多树。出口决策:开发键精确一致、剪枝只在训练内选择、验证指标不被重要性替代三项为 must-pass;三项必须逐项通过,不得用其他目标或总分抵消。
- MP8-1|开发键精确一致:首次证据是 列表 8.1 的键集合、行数、重复/缺失守卫与 hash。若键缺失、重复或被静默丢弃,记
MP8-KEY-FAIL,回到该审计块;异形复测改用含一项缺失键和一项重复键的“供应链中断”小表,必须分别结构化停止并打印差集。评分键为“同一键集合、同一行数、hash 可复算”三项全对,2 分。 - MP8-2|训练内剪枝:首次证据是 列表 8.2 中
ccp_alpha只由 M05 训练内部前向折选择的位置。若验证期或测试期参与选参,记MP8-PRUNE-FAIL,回到该选择块与 小节 8.2.1;异形复测更换候选网格与时间边界,要求标出训练内选择、锁定重拟合和一次性评价三段。评分键为“候选只见训练折、锁定后不再改参、评价集只评分”三项全对,2 分。 - MP8-3|验证指标与重要性分工:首次证据是 列表 8.3 的训练事件率基线、同键 AUC/Brier 与独立重要性输出。若用重要性大小替代样本外指标,记
MP8-METRIC-FAIL,回到该证据块及 小节 8.3.2 与 小节 8.3.3;异形复测给出“重要性集中但 Brier 劣于基线”的新表,必须判定为没有增量预测证据。评分键为“指标用于评价、重要性仅作预测分裂贡献、不得作因果解释”三项全对,2 分。
三项复测均通过且其余目标至少一项有证据、检索至少两题正确,才从 小节 8.10 重入并进入第 9 章。