8  基于树的方法

8.1 导读

决策树用一系列可读的分裂规则划分特征空间,适合表达阈值效应与变量交互;单棵深树却常有较高方差。Bagging、随机森林与 Boosting 通过组合多棵树改善预测,但也增加调参和解释成本。本章既用受控机制实验展示树的分段几何,也用真实中国上市公司财务与行情数据比较单树和集成方法。

8.2 学习目标

完成本章后,读者应能够:

  1. 根据分裂准则与代价复杂度目标解释树的生长和剪枝。
  2. 推导 Bagging 平均预测的方差,并解释随机森林为何要随机抽取特征。
  3. 区分分类误差、Gini 指数与熵的几何性质。
  4. 在共同前向验证块比较事件率基线、剪枝树、随机森林和 Boosting,并把最终测试保留到第 9 章。
  5. 把特征重要性限定为预测贡献摘要,不将其解释为因果作用。

本章介绍用于回归和分类的基于树的方法。这些方法通过将预测变量空间分层或分割成若干简单区域来进行。为了对给定观测进行预测,我们通常使用该观测所属区域中训练观测的响应值均值或众数。由于用于分割预测变量空间的规则集可以总结为一棵树,这类方法被称为决策树方法。

单棵树的分裂规则较易解释,但预测精度和稳定性取决于数据结构、剪枝与验证设计。Bagging、随机森林、Boosting 和贝叶斯加性回归树通过组合多棵树改变偏差—方差权衡;它们是否优于线性或其他监督学习方法,必须在相同样本外键和损失函数上检验,不能由模型类别预先决定。

8.3 决策树的基础

决策树可以同时应用于回归和分类问题。我们首先考虑回归问题,然后转向分类。

8.3.1 回归树

为了激发对回归树的学习,我们从一个金融分析的例子开始。

受控恒等式演示:树如何重构杜邦关系(不得解释为预测)

我们使用同一期 A 股财务数据,根据销售净利率资产周转率重构 ROE。这些量共享净利润、收入与资产等会计构成项,因此本例只展示树的分段规则如何逼近乘法恒等关系,不能作为未来 ROE 的预测证据。真正的预测任务必须使用预测时点可得、且不由目标代数构成的特征,并把标签放在下一报告期。

我们首先加载数据并选取部分具有代表性的公司(例如制造业公司)。

先用受控模拟说明树的分段常数表示。净利率和周转率独立生成,响应按已知乘法函数 \(Y=2\times\text{Margin}\times\text{Turnover}+\varepsilon\) 生成。这里的目标是观察树如何切分特征空间,不是企业 ROE 预测或金融经验发现。

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']  # 取模拟响应作为回归树目标而非真实 ROE

接下来,拟合回归树并可视化其决策规则。

# 拟合回归树(限制深度以便可视化)
regression_tree_model = DecisionTreeRegressor(max_depth=2, random_state=42)  # 限制树深以便逐项解释分裂规则
regression_tree_model.fit(features_matrix, target_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()  # 输出叶节点阈值与预测值供正文逐条核对
深度为二的回归树按模拟净利率与周转率切分,并在叶节点给出平均响应。
图 8.1: 受控模拟中回归树对已知乘法响应面的分段逼近。

按照树的比喻,区域\(R_1, R_2, R_3\)被称为树的终端节点叶子。在 图 8.1 中,我们可以看到树是如何根据”净利率”和”周转率”将公司分层的。

例如,典型的分割规则可能是:

  • 如果 净利率 <= 0.05,则预测 ROE 较低。
  • 如果 净利率 > 0.05周转率 > 0.8,则预测 ROE 较高。

这些规则只是在逼近预先写入的乘法数据生成过程,说明树的可读性与分段常数性质;它们不是企业筛选规则。

8.3.1.1 通过特征空间分层进行预测

我们现在讨论构建回归树的过程。粗略地说,有两个步骤:

  1. 我们将预测变量空间——即\(X_1, X_2, \ldots, X_p\)的可能值集合——分成\(J\)个不同且不重叠的区域,\(R_1, R_2, \ldots, R_J\)

  2. 对于落入区域\(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\),再在完整训练数据上获得对应子树。

8.3.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,并在混合节点增大。它们在 \(p\in(0,1)\) 上都是严格凹函数:\(G''(p)=-4<0\)\(D''(p)=-1/p-1/(1-p)<0\),所以都在 \(p=1/2\) 取得最大值,而不是最小值。两者不是彼此的一阶泰勒近似;实践中经常给出相近的候选分裂排序,是因为形状相似,而不是因为凸性或错误展开。最终准则仍应结合验证表现与任务损失。

注意:分类误差与节点纯度

虽然分类误差率直观上很自然,但它不是用于树生长的最佳标准。这是因为分类误差函数不连续——区域中类别比例的微小变化可能不会改变分类误差,但会显著改变基尼指数或熵。这使得基于分类误差的优化算法难以找到最佳分割点。因此,在实践中,我们使用基尼指数或熵来生长树,但在剪枝时可以使用分类误差率,如果最终剪枝树的预测准确性是目标的话。

选读核对:树如何逼近已知乘法规则

下面不是企业盈利预测,而是一个已知真值的受控模拟。我们先独立生成净利率、周转率和权益乘数,再按杜邦乘积定义同期 ROE 及 High_ROE。因此,树只能展示如何用分段矩形逼近一个已知的乘法边界;其准确率没有样本外金融含义,也不能被解释成模型“发现”了盈利规律。

from sklearn.tree import DecisionTreeClassifier, plot_tree  # 估计Gini分裂并读取叶节点分类规则
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')  # 固定浅层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 只显示树对预先写入数据生成过程的规则做了近似。若要研究真实的未来盈利,必须把特征限定在预测时点可得的信息,并把目标放到更晚报告期;后面的经验比较遵守这一契约。

8.3.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) 所示,那么决策树值得列为候选。两种结构都不预先保证更高精度。

提示:何时把树或线性模型列为候选?

树模型在以下条件下值得列为候选:

  1. 非线性关系: 当预测变量与响应之间存在复杂的非线性交互作用时
  2. 混合变量类型: 树的思想可以处理连续与类别变量;但 scikit-learn 的常用树估计器要求先把字符串类别编码为数值,不能把编码值的顺序误当成经济顺序
  3. 可解释性优先: 当模型的解释性比预测精度更重要时
  4. 缺失值处理: 是否原生支持缺失值取决于具体估计器与软件版本;使用前应查验 API,并在训练折内完成插补
  5. 特征端极端值: 阈值分裂只使用特征的次序与切点,因此特征值的极端幅度通常不会像在线性距离或斜率计算中那样直接放大影响;但孤立点仍可能诱发不稳定分裂

线性模型在以下条件下值得列为候选:

  1. 线性关系: 当关系近似线性时
  2. 高维结构: 当 \(p \gg n\) 且稀疏性或其他低维结构可信,并采用与该结构相配的正则化时
  3. 机械外推: 线性模型能把拟合直线或超平面延伸到训练支持域之外,而树通常只能延续边界叶的常数预测;只有域外仍保持相同函数形式的假设可信时,这种机械外推才有实质依据

这里的异常值必须区分来源:树对特征端极端幅度可能较不敏感,但平方损失回归树以叶内均值预测,响应端异常值仍会改变分裂准则与叶均值,因而不具有天然鲁棒性。上述条件只决定哪些模型值得比较,不能预先决定谁的预测更准;预测精度必须在共同的样本外键、切分与损失下判定。

8.3.4 树的优缺点

用于回归和分类的决策树相对于第3章和第4章中看到的更经典的方法具有许多优点:

  • 规则可读: 浅树可以用一组分裂阈值和叶节点预测来描述;是否比线性模型更易解释取决于树深、变量数和使用者任务。
  • 直观性: 有些人认为决策树更接近人类的决策制定,而不是之前章节中看到的回归和分类方法。
  • 可视化: 浅树可以用图形展示分裂阈值和叶节点预测;树较深时,图形的可读性会下降。
  • 处理定性变量: 树的分裂思想可处理定性预测变量;但具体实现是否需要编码取决于估计器。

单棵树的样本外精度可能低于其他候选方法,而且它具有较高的不稳定性:训练数据的小幅变化可能改变上层分裂,进而改变后续树结构。

通过聚合多棵决策树,Bagging、随机森林和Boosting可以改变单树的偏差—方差特征;是否改善预测性能仍需样本外验证。

8.4 Bagging、随机森林、Boosting和贝叶斯加性回归树

集成方法把多个简单的“构建块”模型组合为一个预测器。这些构建块有时称为弱学习器,因为单独模型的预测能力可能有限;组合能否降低误差取决于单模型误差、模型间相关性与验证证据。

我们现在讨论Bagging、随机森林、Boosting和贝叶斯加性回归树。这些是集成方法,其简单的构建块是回归或分类树。

8.4.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\)(如果模型独立的话)。自助树并不完全独立,因此实际方差降低取决于树间相关性。

当然,这是不实际的,因为我们通常无法访问多个训练集。相反,我们可以使用自助法,从(单个)训练数据集中重复采样。在这种方法中,我们生成\(B\)个不同的自助训练数据集。我们在第\(b\)个自助训练集上训练我们的方法以得到\(\hat{f}^{*b}(x)\),最后平均所有预测以获得: \[ \hat{f}_{\text{bag}}(x) = \frac{1}{B} \sum_{b=1}^{B} \hat{f}^{*b}(x) \]

这称为Bagging。

对回归树应用Bagging时,我们使用\(B\)个自助训练集构建\(B\)个充分生长、未剪枝的回归树,然后平均其预测。单树通常偏差较低而方差较高;平均能降低多少方差取决于树间相关性。

袋外误差估计

Bagging可以用每棵树未抽到的袋外观测构造内部误差估计。每棵树都拟合到观测的自助子集;平均而言,约三分之二的观测至少出现一次,未被抽到的观测称为袋外(OOB)观测。把普通逐行 OOB 误差解释为测试误差,依赖观测近似可交换、数据可视为 i.i.d. 抽样等条件。

我们可以使用该观测为 OOB 的树来预测第 \(i\) 个观测的响应,这通常产生大约 \(B/3\) 个预测;回归时取平均,分类时可作多数投票,便得到单个 OOB 预测。汇总全部观测后可计算 OOB MSE 或分类误差。在可交换条件合理时,它是训练期有用的内部诊断;对于时间序列、分组数据或公司面板,普通逐行 bootstrap 会破坏依赖结构或时间顺序,必须改用组感知、块式或前向评价。此时普通 OOB 既不能替代相应的外层评价,也不能直接支持未来性能结论。

8.4.2 随机森林

随机森林通过一种称为去相关的小调整提供了对Bagged树的改进。如在Bagging中一样,我们在自助训练样本上构建许多决策树。但是,在构建这些决策树时,每次考虑树中的分割时,从完整的\(p\)个预测变量集中选择\(m\)个预测变量的随机样本作为分割候选。只允许分割使用这\(m\)个预测变量之一。每次分割时都会抽取一个新的\(m\)个预测变量样本,通常我们选择\(m \approx \sqrt{p}\)——即每次分割时考虑的预测变量数量大约等于总预测变量数量的平方根。

这一候选限制的作用是降低树间相关性。如果一个预测变量在多数节点上都能带来最大的局部不纯度降低,Bagging中的许多树会在上层重复选择它,从而形成结构相近、预测高度相关的树。对高度相关的预测取平均,方差降低小于相关性较低时。

随机森林在每次分割时只考虑随机抽取的预测变量子集。因此,一个强预测变量在某次分割中不进入候选集的概率为\((p-m)/p\),其他变量便可能被选中。这种去相关机制在不过度增加单树误差时,可降低集成平均的方差。

Bagging和随机森林之间的主要区别在于预测变量子集大小\(m\)的选择。当\(m=p\)时,随机森林退化为Bagging。\(m=\sqrt{p}\)是分类任务的常用起点,但是否优于Bagging必须由具体任务的样本外误差确认。

8.4.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

  1. \(\hat{f}(x) = 0\),\(r_i = y_i\)对所有训练集中的\(i\)
  2. 对于\(b = 1, 2, \ldots, B\):
    1. 使用\(d\)个分割将树\(\hat{f}^b\)拟合到训练数据\((X, r)\)
    2. 通过添加新树的收缩版本来更新\(\hat{f}\): \[ \hat{f}(x) \leftarrow \hat{f}(x) + \lambda \hat{f}^b(x) \]
    3. 更新残差: \[ r_i \leftarrow r_i - \lambda \hat{f}^b(x_i) \]
  3. 输出Boosted模型: \[ \hat{f}(x) = \sum_{b=1}^{B} \lambda \hat{f}^b(x) \]

Boosting有三个调优参数:

  1. 树的数量\(B\)。与Bagging和随机森林不同,如果\(B\)太大,Boosting可能会过拟合,尽管这种过拟合往往发生得很慢(如果有的话)。我们使用交叉验证来选择\(B\)
  2. 收缩参数\(\lambda\),一个小的正数。这控制Boosting的学习率。典型值是0.01或0.001,正确的选择可能取决于问题。非常小的\(\lambda\)可能需要使用非常大的\(B\)值才能获得良好的性能。
  3. 每棵树中的分割数量\(d\),它控制Boosting集成的复杂性。通常\(d=1\)效果很好,在这种情况下每棵树是一个树桩,由单个分割组成。在这种情况下,Boosted集成实际上是拟合一个可加模型,因为每一项只涉及单个变量。更一般地,\(d\)交互深度,控制Boosted模型的交互阶数,因为\(d\)个分割最多可以涉及\(d\)个变量。

8.4.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\))的一组可能扰动之一,倾向于改善对部分残差的拟合。

有两种扰动:

  1. 我们可以通过添加或修剪分支来改变树的结构
  2. 我们可以改变树的每个终端节点中的预测

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.4.5 案例研究:预测下一报告期 ROE 的集成方法比较

本例用报告期 \(t\) 首个可用披露版本中的财务比率预测同一公司连续下一报告期 \(t+1\) 的 ROE。报告期不是可用日期:特征只能从该版本的 info_date 起使用,因此把它记为 prediction_origin;下一报告期版本的 info_date 则是标签实际可核验的 target_available_date。公司内先按报告期排序并核验季度连续性和披露先后,再按披露日执行前向切分。当前净利率、周转率和杠杆可以作为滞后特征,但它们不能代数重构未来目标。

我们将比较以下方法:

  1. 单棵回归树
  2. Bagging (使用所有特征)
  3. 随机森林 (使用特征子集)
  4. 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  # 以同一平方损失评价未来ROE预测

# 重新加载明确年份与字段的真实切片,在保留多期公司面板的同时控制出版内存峰值
import os  # 拼接文件路径并为后续独立代码块登记统一数据根
BOOK_DATA_DIR = os.path.abspath('/home/ubuntu/r2_data_mount/data')  # 明文定义在线教材的BOOK_DATA_DIR绝对路径
DATA_DIR = BOOK_DATA_DIR  # 保留本章后续代码使用的数据根名称
os.environ['BOOK_DATA_DIR'] = 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')  # 定位下一季度ROE案例的财务报表输入
assert os.path.isfile(path), f'缺少财务报表文件: {path};请检查 BOOK_DATA_DIR'  # 在 HDF 读取前核对本例所需的确切文件
financial_data_all = pd.read_hdf(  # 选择性读取足够覆盖前向训练与测试的真实季度面板
    path,  # 指定财务报表 HDF 文件
    where="quarter>='2018q1' & quarter<='2023q4'",  # 固定六年连续季度支持域
    columns=['quarter', 'order_book_id', 'info_date', 'net_profit', 'operating_revenue', 'total_assets', 'equity_parent_company'],  # 同时读取披露日以建立可用时点
).reset_index(drop=True)  # 重建唯一行索引,禁止 HDF 重复索引放大后续标签选择

读取结果先按公司、报告期和 info_date 排序;若数据源保留同一报告期的多个披露版本,只保留首个有完整披露日的版本。这里的“首个”指当前数据快照中最早可见版本,不能替代更完整的历史版本库。下面先计算本期财务比率,再用公司内位移生成下一报告期标签及其可用日;位移后的末期缺失会在后续清洗中排除。

# 先锁定当前数据快照中的首个披露版本
financial_data_all['info_date'] = pd.to_datetime(financial_data_all['info_date'])  # 将披露日统一为可比较时间戳
financial_data_all = financial_data_all.dropna(subset=['info_date']).sort_values(['order_book_id', 'quarter', 'info_date'])  # 先删除无可审计日期的版本
financial_data_all = financial_data_all.drop_duplicates(['order_book_id', 'quarter'], keep='first').copy()  # 同一公司报告期只保留最早版本

# 构造杜邦分析所需的财务比率特征
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']  # 按归母权益口径计算本期 ROE
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['prediction_origin'] = financial_data_all['info_date']  # 当前版本披露日就是最早可用预测时点
financial_data_all['future_ROE'] = financial_data_all.groupby('order_book_id')['ROE'].shift(-1)  # 仅在同一公司内取下一记录 ROE
financial_data_all['future_period'] = financial_data_all.groupby('order_book_id')['period'].shift(-1)  # 同步保存标签所属季度以审计时序
financial_data_all['target_available_date'] = financial_data_all.groupby('order_book_id')['info_date'].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', 'prediction_origin', 'target_available_date']
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['Margin'].between(-0.5, 0.5)) &  # 过滤净利率极端值
    (cleaned_financial_data['Turnover'].between(0, 5)) &  # 过滤周转率极端值
    (cleaned_financial_data['Lev'].between(1, 10)) &  # 限制权益乘数在本例预设支持域
    (cleaned_financial_data['target_available_date'] > cleaned_financial_data['prediction_origin']) &  # 目标必须在预测时点后才披露
    ((cleaned_financial_data['future_period_ordinal'] - cleaned_financial_data['period_ordinal']) == 1)  # 用整数差拒绝季度缺口并避免对象数组膨胀
]  # 不得依据future_ROE的实现值筛选或截尾评分样本

# 按报告期前向切分;为控制资源,每个期间独立做与目标无关的固定上限抽样
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_origins = modeling_data['prediction_origin'].drop_duplicates().sort_values()  # 按实际披露日而非报告期定位边界
split_origin = ordered_origins.iloc[max(1, int(len(ordered_origins) * 0.7))]  # 用前70%的可用日期训练、后段日期评价
test_mask = modeling_data['prediction_origin'] >= split_origin  # 后段披露日起点只用于本章前向评价
first_score_origin = modeling_data.loc[test_mask, 'prediction_origin'].min()  # 取共同评价块最早预测时点
train_mask = modeling_data['target_available_date'] < first_score_origin  # 训练标签须在所有评价预测发生前可得
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']  # 对齐训练行的下一季度 ROE
target_test = modeling_data.loc[test_mask, 'future_ROE']  # 对齐评价行的下一季度 ROE
assert modeling_data.loc[train_mask, 'target_available_date'].max() < modeling_data.loc[test_mask, 'prediction_origin'].min()  # 禁止训练标签跨过评分起点
assert (modeling_data.loc[test_mask, 'target_available_date'] > modeling_data.loc[test_mask, 'prediction_origin']).all()  # 核对评分时标签尚不可见
/tmp/ipykernel_1433006/3418334329.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))  # 在共同未来期量化单树平方损失

# 2. Bagging(使用RandomForestRegressor但max_features=None,即每次分裂使用全部特征)
bagging_model = RandomForestRegressor(n_estimators=100, max_features=None, random_state=42)  # 用全特征的百棵自助树定义 Bagging 对照
bagging_model.fit(features_train, target_train)  # 用同一早期样本估计全特征自助树集成
bagging_model_mse = mean_squared_error(target_test, bagging_model.predict(features_test))  # 在共同未来期量化Bagging平方损失

# 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))  # 在共同未来期量化森林平方损失

# 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以柱状图进行可视化对比。

# 汇总同一未来测试期的四类方法误差
methods = ['单棵树', 'Bagging', '随机森林', 'Boosting']  # 四种方法的中文标签
mse_values = [single_tree_mse, bagging_model_mse, random_forest_mse, boosting_model_mse]  # 对应的MSE值列表

plt.figure(figsize=(10, 6))  # 为四项MSE比较保留一致的横向间距
bars = plt.bar(methods, mse_values, color=['#E3120B', '#2C3E50', '#008080', '#F0A700'])  # 用柱高编码测试MSE并以颜色区分候选
plt.title('下一报告期 ROE:未来测试期 MSE', fontsize=14)  # 限定评价对象为下一报告期与锁定测试期
plt.ylabel('均方误差 (MSE)', fontsize=12)  # 标明柱高越低表示锁定测试期平方损失越小
plt.xlabel('方法', fontsize=12)  # 标明横轴对应共享样本上的四类树候选

# 在每根柱顶标出可复核的测试MSE
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}',  # 保留四位小数以比较相近误差
             ha='center', va='bottom', fontsize=10)  # 保持误差值与对应柱中心对齐

plt.grid(axis='y', alpha=0.3)  # 用水平参考线辅助比较细小MSE差异
plt.tight_layout()  # 避免方法名和误差值在出版页裁切
plt.show()  # 输出锁定测试期误差排序供正文解读
四根柱分别表示单棵树、Bagging、随机森林和Boosting在未来测试期的均方误差。
图 8.3: 不同树方法预测下一连续报告期ROE时,在锁定未来测试期上的MSE。

图 8.3 所示,应以柱高较低者作为这一次前向切分中的较优方法。这里预测的是未来 ROE,不再把同期杜邦恒等式当成泛化证据;一次测试期的排序也不能证明某种集成方法普遍占优。

8.4.6 案例研究: 随机森林与提升树预测 A 股未来 20 日最大回撤

树集成方法能够表示非线性关系,也不要求像距离模型那样先统一特征尺度。在这个分类案例中,我们应用随机森林和运行环境中可用的提升树实现,预测海康威视 002415.XSHE\(t\)\(t+20\) 的 21 个公司交易日收盘路径中,是否出现从运行峰值到其后谷值不低于 10% 的最大回撤。

XGBoost 通过损失函数的二阶泰勒近似(利用 Hessian 信息)优化,并在目标函数中加入对叶节点数量和叶节点权重的正则化;这些设计提供了可调节的复杂度约束,但泛化表现仍须由验证数据判断。

我们使用截至公司日 \(t\) 收盘可得的滞后收益率、波动率和成交量特征,并把未来第 20 个交易日记为 label_date。不足完整窗口的末端标签保持未知;训练与验证之间执行严格的标签时域 purge。

这一示范只使用训练—验证区间,不接触第 9 章封存的最终测试键。随机森林通过样本与特征抽样降低单树方差;若运行环境提供 XGBoost,则用浅树、低学习率和行列抽样约束复杂度,否则动态改用 scikit-learn 的梯度提升实现。所有模型必须与训练事件率概率基线和训练多数类基线同表比较,具体表现只从当次验证输出解释。

算法拆解:随机森林和 XGBoost 到底是怎么学出来的

树模型的代码往往看起来很短,但背后的学习过程并不简单。为了避免学生把它误解成“黑箱一跑就出结果”,这里把两个算法的机制拆开说清楚。

随机森林的流程是:

  1. 从训练样本中反复做有放回抽样,生成很多不同的 bootstrap 子样本;
  2. 在每个子样本上训练一棵分类树;
  3. 每次分裂节点时,不是看全部特征,而是只随机抽一部分特征参与竞争;
  4. 预测时让很多棵树一起投票,或者对概率取平均。

这样做的核心目的,是让每棵树“看见的世界”都稍有不同,从而降低单棵树对噪声的过度敏感。

XGBoost则不是“并行种很多树再平均”,而是“让后一棵树专门去修正前一棵树犯下的错”。它的流程更像连续补考:

  1. 先从一个很粗糙的初始预测开始;
  2. 计算当前模型在哪些样本上预测得不够好;
  3. 训练一棵新树,专门去拟合这些残差或梯度信息;
  4. 把新树以较小学习率叠加到已有模型上;
  5. 重复很多轮,逐步改进整体预测。

随机森林对多棵去相关树取平均,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  # 同时核对阈值分类、排序与概率质量
import warnings  # 控制第三方提升模型的非关键兼容告警
warnings.filterwarnings('ignore')  # 保持课堂输出聚焦于验证损失与概率诊断

try:  # 优先使用本节讨论的XGBoost候选
    import xgboost as xgb  # 建立带正则与抽样约束的序列提升候选
    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  # 缺少XGBoost时保留同类序列提升对照

# 确保负回撤及中文特征名在诊断图中可读
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 使用项目规定的中文字体
plt.rcParams['axes.unicode_minus'] = False  # 确保负回撤与特征贡献的符号可辨认

下面的海康威视后复权数据只用于单股教学演示,不属于第 8 章贯穿项目,也不产生项目候选。这里的标签同样取 \(t\)\(t+20\) 路径中运行峰值到其后谷值的最大跌幅,不能用“未来最低价相对 \(t\) 日价格”替代。

import os  # 读取跨平台数据根环境变量
from pathlib import Path  # 安全拼接本地行情路径
book_data_dir_value = os.environ.get('BOOK_DATA_DIR')  # 读取显式配置的数据根
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向本书本地数据根目录'  # 给出可执行的缺失配置提示
DATA_DIR = Path(book_data_dir_value).expanduser().resolve()  # 解析绝对数据根
assert DATA_DIR.is_dir(), f'BOOK_DATA_DIR 不是有效目录: {DATA_DIR}'  # 在读取前核对目录
path = DATA_DIR / 'stock/stock_price_post_adjusted.h5'  # 使用与贯穿项目一致的后复权行情
assert path.is_file(), f'缺少后复权行情文件: {path};请检查 BOOK_DATA_DIR'  # 在HDF读取前给出明确文件提示
case_company_id = '002415.XSHE'  # 固定海康威视教学标的
stock_price_data = pd.read_hdf(  # 从真实 HDF 中直接选择海康威视,避免载入全市场行情
    path,  # 指定后复权股价文件
    where=f'order_book_id={case_company_id!r}',  # 在存储层过滤唯一教学标的
    columns=['date', 'order_book_id', 'close', 'volume'],  # 只读取特征与标签所需字段
).reset_index()  # 将 HDF 索引恢复为普通列
stock_price_data = stock_price_data.sort_values('date')  # 按日期升序排列确保时序正确

下面先用三点夹具核对峰谷顺序:路径 \(100\to120\to105\) 的最大回撤为 \(105/120-1=-12.5\%\),所以应记为事件。

def calculate_case_forward_max_drawdown(price_values, horizon_days=20):  # 计算含t日在内的前向峰谷最大回撤
    price_array = np.asarray(price_values, dtype=float)  # 转为数值数组构造滑动路径
    window_size = horizon_days + 1  # 路径包含t至t+horizon_days
    drawdowns = np.full(price_array.size, np.nan, dtype=float)  # 末端不完整窗口保持未知
    if price_array.size < window_size:  # 短序列没有完整标签窗口
        return drawdowns  # 返回全缺失标签
    price_windows = np.lib.stride_tricks.sliding_window_view(price_array, window_size)  # 生成各预测日起点的路径
    running_peaks = np.maximum.accumulate(price_windows, axis=1)  # 确保峰值先于对应谷值
    drawdowns[:price_windows.shape[0]] = np.min(price_windows / running_peaks - 1, axis=1)  # 取每条路径最深回撤
    return drawdowns  # 返回与原序列等长结果

case_fixture_drawdown = calculate_case_forward_max_drawdown([100, 120, 105], horizon_days=2)[0]  # 核对先涨后跌路径
assert np.isclose(case_fixture_drawdown, -0.125)  # 核对峰值120到谷值105跌幅
case_fixture_event = int(case_fixture_drawdown <= -0.10)  # 按案例阈值生成夹具事件
assert case_fixture_event == 1  # 核对该路径必须标记为事件
stock_price_data['Ret'] = stock_price_data['close'].pct_change(fill_method=None)  # 计算截至t日收益特征
forward_max_drawdown = calculate_case_forward_max_drawdown(stock_price_data['close'], horizon_days=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 = np.isfinite(forward_max_drawdown) & stock_price_data['label_date'].notna()  # 完整未来窗口掩码
stock_price_data.loc[known_label, 'Target'] = (forward_max_drawdown[known_label] <= -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()  # 进入建模样本的标签必须已知

# 此处只演示单股时间边界,不能据此代表全市场表现
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.3414823438106325 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(  # 建立带行列抽样约束的提升树候选
        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 候选的正则化与随机抽样设置
    xgboost_classifier.fit(stock_features_train, stock_target_train)  # 仅在共享训练段估计提升树
    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)  # 缺少 XGBoost 时仅在共享训练段拟合后备候选
    models['Gradient Boosting'] = gradient_boosting_classifier  # 存入模型字典

接下来,评估两个模型的验证集表现并可视化特征重要性。

# 3. 在同一验证期联读概率指标与特征重要性
plt.figure(figsize=(14, 6))  # 并列呈现各候选的重要性与验证Brier

for i, (name, model) in enumerate(models.items()):  # 遍历所有已训练模型
    # 用同一概率输出同时计算排序与校准指标
    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)  # 将重要性条形与对应预测特征逐项对齐
    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)  # 辅助比较相邻特征的重要性差异
    
plt.tight_layout()  # 避免双面板标题与特征名重叠
plt.show()  # 输出重要性与Brier联读证据
[Random Forest] Validation Accuracy: 0.7278, AUC: 0.5806, Brier: 0.1930, baseline Brier: 0.2018
[XGBoost] Validation Accuracy: 0.7287, AUC: 0.5847, Brier: 0.1930, baseline Brier: 0.2018
并列条形图比较随机森林与运行时可用提升实现对滞后收益、波动率和成交量特征的不纯度重要性。
图 8.4: 随机森林与当前可用提升实现对t至t+20真实峰谷最大回撤验证任务的特征重要性。

图 8.4 只显示本次验证拟合的不纯度重要性;它必须与同块现场输出的 AUC、Brier 和训练事件率基线共同评分。不得预写 AUC 或 Brier 的固定区间,也不得把单次模型排序外推为普遍结论;若验证结果没有增量信息,应保留失败结果并检查支持域、类别比例与标签窗口,而不是改看封存测试集。

此外,XGBoost 的列抽样(colsample_bytree)、行抽样(subsample)和学习率缩减提供了额外的复杂度控制旋钮;在低信噪比金融数据中,这些设置是否改善验证表现需要现场比较。

8.5 实验: 基于树的方法

在本节中,我们使用 Python 的 sklearn 库演示基于树的方法。先保留一个“同期目标构成项导致完美分类”的反例,随后练习改用下一报告期标签。反例的高分数是泄漏诊断,不是模型能力。

8.5.1 拟合分类树

我们首先构建一个分类树,用于预测一家公司是否能获得正的净资产收益率(ROE > 0)。

下面的代码故意把 Margin 与由同一净利润定义的 Profitable 放在一起。它只用于让读者识别目标泄漏;随机切分和接近完美的分数均不得用于选模或业务判断。合格的端到端流程见本章应用题:先按报告期切分,再用训练期交叉验证选择 ccp_alpha,最后评价下一期标签。 代码最后调用 export_text,把树的分裂规则打印为缩进文本。这样便于逐条检查阈值、叶节点类别与潜在异常分裂;若用于业务规则,还需另行验证稳定性、公平性与样本外损失,不能直接照搬训练树。

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  # 分解总体正确率与两类误判方向

# 读取受控泄漏诊断与前向树实验共用的财务面板
import os  # 从统一环境变量解析财务实验数据根
book_data_dir_value = os.environ.get('BOOK_DATA_DIR')  # 安全读取统一数据根配置
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向包含 stock/ 子目录的数据根'  # 给出缺失环境变量的可执行修复方向
DATA_DIR = os.path.abspath(os.path.expanduser(book_data_dir_value))  # 解析用户配置的跨平台绝对目录
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}'  # 缺少挂载时立即失败
path = os.path.join(DATA_DIR, 'stock/financial_statement.h5')  # 定位泄漏反例与前向树实验的共同报表输入
assert os.path.isfile(path), f'缺少财务报表文件: {path};请检查 BOOK_DATA_DIR'  # 在 HDF 读取前核对本例所需的确切文件
financial_statement_data = pd.read_hdf(  # 选择性读取回归树实验所需真实季度面板
    path,  # 指定财务报表 HDF 文件
    where="quarter>='2018q1' & quarter<='2023q4'",  # 固定与集成比较相同的六年支持域
    columns=['quarter', 'order_book_id', 'info_date', '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['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)  # 随机划分训练/测试集

接下来,拟合分类树并评估其分类效果。

表 8.1: 分类树的训练集和测试集性能
# 以浅树暴露同期目标派生特征的泄漏规则
tree_classifier = DecisionTreeClassifier(criterion='gini', max_depth=3, random_state=42)  # 限制树深以便识别同期目标泄漏规则
tree_classifier.fit(clf_features_train, clf_target_train)  # 仅用训练部分估计泄漏诊断树

# 检查封存部分上同期派生特征是否引发异常高分
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}')  # 呈现误分方向供泄漏诊断

# 导出完整分裂规则,核对是否直接重用净利润符号
classification_tree_rules = export_text(tree_classifier, feature_names=list(classification_features.columns))  # 将阈值、方向与叶节点类别转成可审查文本
print('\n部分树规则:\n')  # 分隔性能证据与规则证据
print(classification_tree_rules)  # 呈现完整阈值链供目标派生检查
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 时到底该先看什么

树模型会同时产生多种评价指标;为避免把它们当作可互换的“分数”,可按下面顺序阅读:

  1. 先看混淆矩阵:它告诉你模型把哪一类错成了哪一类,是所有分类指标的来源。
  2. 再看准确率:回答“总体上判对了多少”,适合类别相对平衡的场景。
  3. 再区分精确率和召回率
    • 精确率高,表示模型发出的“风险警报”更可信;
    • 召回率高,表示模型漏掉的真正风险更少。
  4. 最后看 AUC:如果模型能输出概率,AUC 反映的是排序能力,而不是某个固定阈值下的表现。

尤其在金融任务里,Accuracy 和 AUC 往往要结合起来看。一个模型的准确率可能一般,但 AUC 不低,说明它虽然在默认阈值下表现平平,却可能仍有不错的排序价值;只要进一步调阈值、结合交易成本或风控成本重新设计决策规则,仍有业务利用空间。反过来,如果准确率看起来不差,但 AUC 接近0.5,就往往意味着模型只是吃到了类别比例或默认阈值的便宜,本质上并没有真正学会区分风险与非风险。

8.5.2 拟合回归树

下面结束泄漏反例,改用 \(t\) 期特征预测同一公司连续 \(t+1\) 期 ROE,并按报告期前向切分。这样,回归树、随机森林和 Boosting 共用同一组可复核的训练集与锁定未来测试集。

表 8.2: 回归树的测试集MSE和R²
from sklearn.tree import DecisionTreeRegressor  # 估计下一报告期ROE的分段常数基线
from sklearn.metrics import mean_squared_error, r2_score  # 同时报告绝对平方损失与相对解释度

financial_statement_data['period'] = pd.PeriodIndex(financial_statement_data['quarter'], freq='Q')  # 将报告期转为可比较季度
financial_statement_data['info_date'] = pd.to_datetime(financial_statement_data['info_date'])  # 统一版本披露日类型
financial_statement_data = financial_statement_data.dropna(subset=['info_date']).sort_values(['order_book_id', 'period', 'info_date'])  # 先按披露时点排列版本
financial_statement_data = financial_statement_data.drop_duplicates(['order_book_id', 'period'], keep='first').copy()  # 锁定快照中首个披露版本
financial_statement_data['ROE'] = financial_statement_data['net_profit'] / financial_statement_data['equity_parent_company']  # 用归母权益构造本期 ROE
financial_statement_data = financial_statement_data.sort_values(['order_book_id', 'period'])  # 为公司内向前移位建立时间顺序
financial_statement_data['prediction_origin'] = financial_statement_data['info_date']  # 当前版本披露日定义预测起点
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['target_available_date'] = financial_statement_data.groupby('order_book_id')['info_date'].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', 'prediction_origin', 'target_available_date']  # 限定本例清洗与时点字段
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.loc[future_rows['target_available_date'] > future_rows['prediction_origin']].sort_values('prediction_origin')  # 不按目标实现值筛选,只保留预测后披露的标签
cutoff = future_rows['prediction_origin'].drop_duplicates().sort_values().iloc[int(future_rows['prediction_origin'].nunique() * 0.7)]  # 以披露日而非报告期确定前向边界
regression_features = future_rows[['Margin', 'Turnover', 'Lev', 'Log_Assets']]  # 固定回归树及集成模型共用的特征顺序
regression_target_roe = future_rows['future_ROE']  # 取连续下一季度 ROE 作为目标
reg_features_test = regression_features.loc[future_rows['prediction_origin'] >= cutoff]  # 仅用边界及以后披露版本评价
reg_first_score_origin = future_rows.loc[reg_features_test.index, 'prediction_origin'].min()  # 读取评价块最早预测时点
reg_features_train = regression_features.loc[future_rows['target_available_date'] < reg_first_score_origin]  # 训练标签必须在评分前披露
reg_target_train = regression_target_roe.loc[reg_features_train.index]  # 按索引对齐训练标签
reg_target_test = regression_target_roe.loc[reg_features_test.index]  # 按索引对齐评价标签
assert future_rows.loc[reg_features_train.index, 'target_available_date'].max() < future_rows.loc[reg_features_test.index, 'prediction_origin'].min()  # 核对标签可用日边界

# 拟合回归树(限制最大深度4层,平衡偏差与方差)
tree_regressor = DecisionTreeRegressor(max_depth=4, random_state=42)  # 以有限深度控制未来ROE回归树复杂度
tree_regressor.fit(reg_features_train, reg_target_train)  # 在早期季度拟合深度四的下一期 ROE 树

# 预测并评估
predicted_roe = tree_regressor.predict(reg_features_test)  # 对测试集进行ROE预测

# 用同一未来季度同时衡量平方损失和相对均值基准的改善
test_set_mse = mean_squared_error(reg_target_test, predicted_roe)  # 汇总未来ROE预测的平方损失
test_set_r2 = r2_score(reg_target_test, predicted_roe)  # 比较回归树与测试期均值基准

print(f'测试集MSE: {test_set_mse:.4f}')  # 报告未来季度预测的平方损失
print(f'测试集R²: {test_set_r2:.4f}')  # 报告相对测试期均值基准的解释比例
测试集MSE: 1.5724
测试集R²: -1.1070

打印出的 MSE 与 \(R^2\) 是本次锁定未来期间的现场结果。\(R^2\) 较低或为负都应如实保留;它表示模型未能优于测试期均值基准,而不是代码失败。后续集成方法必须使用完全相同的时间切分。

8.5.3 Bagging和随机森林

我们使用 RandomForestRegressor 来改进预测。记住,Bagging 只是 max_features 设置为总特征数的随机森林的一个特例。

单棵树的极限就是集成的起点。RandomForestRegressor 当前默认 max_features=1.0,即每次分裂考察全部特征;要主动降低树间相关性,应显式设置小于 1 的比例或特征数。feature_importances_ 汇总训练样本上的 MSE 不纯度下降,可能偏向取值多的变量;它不是“因子有效性”、经济贡献或因果效应,解释时应与锁定测试集上的 permutation importance 对照。

表 8.3: Bagging和随机森林的特征重要性
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}')  # 报告森林在同一未来季度上的平方损失
print(f'随机森林测试集R²: {r2_score(reg_target_test, random_forest_regressor.predict(reg_features_test)):.4f}')  # 报告森林相对测试期均值基准的增量
print('实际 max_features:', random_forest_regressor.get_params()['max_features'])  # 核验候选没有退化为全特征 Bagging

# 特征重要性(回归树基于 MSE 不纯度下降的累计,不是 Gini 或因果贡献)
feature_importance_df = pd.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: 1.1285
随机森林测试集R²: -0.5122
实际 max_features: sqrt

特征重要性 (Random Forest):
            importance
Margin        0.284049
Lev           0.265476
Turnover      0.254772
Log_Assets    0.195703

比较上方两个现场 MSE 才能判断随机森林在该测试期是否改善。feature_importances_ 是训练样本内不纯度下降的描述量,不是经济贡献、因果效应或可交易因子证据;需要解释时应另用锁定测试期的置换重要性和重复切分稳定性。

8.5.4 Boosting

最后,我们使用梯度提升树(Gradient Boosting)。

最后拟合梯度提升树(GradientBoostingRegressor):n_estimators=100learning_rate=0.1max_depth=3。这组参数只是本次预先固定比较的实现配方,不预示 Boosting 胜出。它与单棵树、随机森林共用完全相同的未来测试期,模型排序只由现场 MSE 及跨切分稳定性决定。

表 8.4: Boosting的测试集性能
from sklearn.ensemble import GradientBoostingRegressor  # 用序列残差学习形成同口径回归候选

# Boosting(100棵树,学习率0.1,最大深度3层的弱学习器)
# 用浅树序列拟合下一期 ROE 的残差结构
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}')  # 报告提升树在共同测试期的平方损失
print(f'Boosting测试集R²: {r2_score(reg_target_test, gradient_boosting_regressor.predict(reg_features_test)):.4f}')  # 报告提升树相对测试期均值基准的增量
Boosting测试集MSE: 1.0071
Boosting测试集R²: -0.3495

上方现场输出与同一锁定未来测试期的单棵树、随机森林结果共同决定排序。若集成方法没有改善,就应报告“未观察到改进”;一次切分不能验证普遍优势,也不能把训练样本的不纯度重要性解释成经济机制。

8.6 小结

本章介绍了基于树的方法,包括决策树、Bagging、随机森林、Boosting和BART。这些方法具有以下特点:

优点:

  1. 灵活性: 可以建模复杂的非线性关系和交互作用
  2. 鲁棒性: 对异常值和不相关变量相对鲁棒
  3. 可解释性: 单棵树易于可视化和解释
  4. 处理混合数据类型: 可以自然地处理定量和定性预测变量
  5. 无需特征缩放: 不需要标准化或归一化特征

缺点:

  1. 预测精度: 单棵树的方差往往较高,但是否不如其他方法必须用同一样本外切分判定
  2. 不稳定性: 数据的微小变化可能导致树结构的巨大变化
  3. 外推能力: 无法很好地外推到训练数据范围之外
  4. 集成方法的解释性: Bagging、随机森林和Boosting降低了可解释性

何时使用:

  • 当预测变量和响应之间的关系可能高度非线性且复杂时
  • 当可解释性比预测精度更重要时(使用单棵树)
  • 当预测精度重要时,把集成方法纳入同切分候选,而不预先指定胜者
  • 当有混合类型的预测变量(定量和定性)时

8.7 理论来源与前沿

树模型的理论起点是‘递归分割’:通过一系列可解释的规则把特征空间切分成若干区域,并在每个区域内做简单预测。CART 把分裂准则、剪枝与交叉验证组合成一套可操作的算法体系。随机森林通过对样本与特征的随机化降低单棵树的方差 (Breiman 2001年);梯度 Boosting 则把弱学习器按损失的负梯度方向逐步叠加 (Friedman 2001年)。这些来源支持算法定义,不替代本章样本外验证,也不把特征重要性升级为因果效应。

近年来的前沿主要集中在:

  1. 可解释的集成模型:在保持准确度的同时,用特征重要性、部分依赖与 SHAP 等方法解释模型。
  2. 因果森林与异质性效应:把树集成用于估计处理效应异质性,服务政策评估与精准营销。
  3. 高效实现与大规模训练:例如直方图分裂、单边梯度采样与分布式训练,使得工程部署更可扩展。

学习证据:概念与理论题合计 60 分,达到 42 分表示基础达标;应用题合计 40 分,必须同时给出基线、同键模型比较、有限 AUC/Brier、重要性局限和失败时的保留基线结论。

8.8 综合案例:树模型预测未来回撤

本章综合案例不再另建单股标签;前面的海康威视示范只讲解单股窗口与树接口,不产生第 8 章候选。为保证从干净内核运行,下面在本章内定义并调用与 小节 2.7 相同的 BOOK_DATA_DIR 构造器;真实峰谷最大回撤标签、日期边界和四项返回对象均保持一致,不读取外部辅助脚本。

先定义前向最大回撤函数。对路径 \(P_0,\ldots,P_{20}\),先计算运行峰值 \(H_j=\max_{0\le k\le j}P_k\),再取 \(\min_j(P_j/H_j-1)\)。因此价格路径 \(100\to120\to105\) 的最大回撤是 \(105/120-1=-12.5\%\),即使终点仍高于预测日价格,也必须记为事件。

展开共享样本的峰谷回撤函数
import os  # 读取跨平台数据根环境变量
from pathlib import Path  # 用路径对象安全拼接本地文件
import numpy as np  # 构造有限特征和时间分段
import pandas as pd  # 处理公司—日期面板

def calculate_forward_max_drawdown(price_values, horizon_days=20):  # 计算含预测日在内的前向峰谷最大回撤
    price_array = np.asarray(price_values, dtype=float)  # 转为连续数值数组以构造滑动路径
    window_size = horizon_days + 1  # 路径包含t日与其后horizon_days个交易日
    drawdowns = np.full(price_array.size, np.nan, dtype=float)  # 末端窗口不完整时保持未知
    if price_array.size < window_size:  # 保护短序列而不虚构标签
        return drawdowns  # 返回全缺失结果表示没有完整路径
    price_windows = np.lib.stride_tricks.sliding_window_view(price_array, window_size)  # 构造每个预测日的完整价格路径
    running_peaks = np.maximum.accumulate(price_windows, axis=1)  # 逐路径记录每一时点之前的最高价
    drawdowns[:price_windows.shape[0]] = np.min(price_windows / running_peaks - 1, axis=1)  # 取峰值后最深谷值跌幅
    return drawdowns  # 返回与原价格序列等长的前向最大回撤

m02_fixture_drawdown = calculate_forward_max_drawdown([100, 120, 105], horizon_days=2)[0]  # 用先涨后跌路径区分峰谷回撤与起点收益
assert np.isclose(m02_fixture_drawdown, -0.125)  # 核对峰值120到谷值105的跌幅为百分之十二点五
assert m02_fixture_drawdown <= -0.10  # 核对该路径必须被判为百分之十回撤事件

下面把数据读取、预测日特征和峰谷标签封装为一个准备函数。固定公司名单是教学研究边界,不是事后按收益挑出的“优胜者”;return_1d 等特征均以 \(t\) 日结束,只有标签计算向未来展开。

展开共享样本的数据准备函数
def prepare_m02_price_panel(book_data_dir):  # 从统一数据根构造未切分的公司—预测日样本
    data_root = Path(book_data_dir).expanduser().resolve()  # 同时接受环境变量字符串或Path对象
    assert data_root.is_dir(), f'BOOK_DATA_DIR 不是有效目录: {data_root}'  # 在读取前验证数据挂载
    price_path = data_root / 'stock/stock_price_post_adjusted.h5'  # 指向后复权日行情
    assert price_path.is_file(), f'缺少后复权行情文件: {price_path};请检查 BOOK_DATA_DIR'  # 在HDF读取前给出明确文件提示
    company_ids = ('600104.XSHG', '002415.XSHE', '600276.XSHG', '002230.XSHE')  # 固定四家长三角教学公司
    price_frames = [pd.read_hdf(price_path, where=f'order_book_id={company_id!r}', columns=['close', 'volume']).reset_index() for company_id in company_ids]  # 在存储层选择所需公司和字段
    price_panel = pd.concat(price_frames, ignore_index=True)  # 合并为公司—交易日面板
    price_panel['prediction_date'] = pd.to_datetime(price_panel['date'])  # 把交易日定义为收盘后预测原点
    price_panel = price_panel.loc[price_panel['prediction_date'].between('2012-01-01', '2024-12-31')].copy()  # 固定研究观察期
    price_panel = price_panel.sort_values(['order_book_id', 'prediction_date']).reset_index(drop=True)  # 保证窗口只在公司内按时间推进
    grouped_prices = price_panel.groupby('order_book_id', sort=False)  # 建立公司内窗口边界
    price_panel['return_1d'] = grouped_prices['close'].transform(lambda prices: prices.pct_change(fill_method=None))  # 计算截至预测日的一日收益
    price_panel['momentum_5d'] = grouped_prices['close'].transform(lambda prices: prices.pct_change(5, fill_method=None))  # 计算截至预测日的五日动量
    price_panel['volatility_20d'] = grouped_prices['return_1d'].transform(lambda returns: returns.rolling(20, min_periods=20).std())  # 计算过去二十日波动率
    volume_mean_20d = grouped_prices['volume'].transform(lambda volume: volume.rolling(20, min_periods=20).mean())  # 估计预测日前成交量常态
    price_panel['volume_ratio_20d'] = price_panel['volume'] / volume_mean_20d  # 表示当日成交量相对过去均值的活跃度
    feature_names = ['return_1d', 'momentum_5d', 'volatility_20d', 'volume_ratio_20d']  # 固定跨章特征名称与顺序
    price_panel['max_drawdown_20d'] = grouped_prices['close'].transform(lambda prices: calculate_forward_max_drawdown(prices, horizon_days=20))  # 计算t至t+20真实峰谷最大回撤
    price_panel['label_end_date'] = grouped_prices['prediction_date'].shift(-20)  # 记录未来第二十个公司交易日
    price_panel['drawdown_event_20d'] = price_panel['max_drawdown_20d'].le(-0.10).where(price_panel['max_drawdown_20d'].notna()).astype('Int64')  # 完整路径才生成二元事件
    price_panel = price_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=feature_names + ['drawdown_event_20d', 'label_end_date']).copy()  # 排除无效特征和未完成路径
    return price_panel, feature_names  # 把未切分样本交给公共构造函数

公共函数负责固定切分、边界断言和四项跨章交付。任何章节在干净内核中都可以用 build_m02_shared_sample(BOOK_DATA_DIR) 重建同一对象,不需要依赖前一章的内存状态。

展开共享样本的时间切分函数
def build_m02_shared_sample(book_data_dir):  # 返回第2至第9章唯一的共享分析对象
    price_panel, feature_names = prepare_m02_price_panel(book_data_dir)  # 从统一数据根重建真实峰谷标签
    validation_start = pd.Timestamp('2020-01-01')  # 固定验证段起点
    test_start = pd.Timestamp('2022-01-01')  # 固定封存测试段起点
    study_end = pd.Timestamp('2024-12-31')  # 固定研究终点
    train_mask = (price_panel['prediction_date'] < validation_start) & (price_panel['label_end_date'] < validation_start)  # 清除伸入验证期的训练标签
    validation_mask = price_panel['prediction_date'].between(validation_start, test_start, inclusive='left') & (price_panel['label_end_date'] < test_start)  # 清除伸入测试期的验证标签
    test_mask = price_panel['prediction_date'].between(test_start, study_end, inclusive='both')  # 标记只供第九章最终评价的日期
    price_panel['split'] = np.select([train_mask, validation_mask, test_mask], ['train', 'validation', 'test'], default='unused')  # 赋予互斥时间段
    shared_columns = ['order_book_id', 'prediction_date', 'label_end_date', *feature_names, 'max_drawdown_20d', 'drawdown_event_20d', 'split']  # 固定跨章字段顺序与连续标签
    shared_sample = price_panel.loc[price_panel['split'] != 'unused', shared_columns].copy()  # 排除边界隔离行并形成共享对象
    assert list(shared_sample.columns) == shared_columns  # 验证跨章列顺序与第2章权威契约完全一致
    assert not shared_sample.duplicated(['order_book_id', 'prediction_date']).any()  # 验证公司—预测日键唯一
    assert np.isfinite(shared_sample['max_drawdown_20d']).all() and shared_sample['max_drawdown_20d'].le(0).all()  # 验证连续回撤有限且非正
    assert shared_sample['drawdown_event_20d'].astype(int).eq(shared_sample['max_drawdown_20d'].le(-0.10).astype(int)).all()  # 验证二元标签由连续回撤唯一导出
    assert set(shared_sample['drawdown_event_20d'].unique()) == {0, 1}  # 验证二元标签包含两类
    assert shared_sample.loc[shared_sample['split'] == 'train', 'label_end_date'].max() < validation_start  # 验证训练标签不触及验证期
    assert shared_sample.loc[shared_sample['split'] == 'validation', 'label_end_date'].max() < test_start  # 验证验证标签不触及测试期
    training_event_rate = float(shared_sample.loc[shared_sample['split'] == 'train', 'drawdown_event_20d'].mean())  # 只用训练标签估计概率基线
    test_keys = shared_sample.loc[shared_sample['split'] == 'test', ['order_book_id', 'prediction_date']].copy()  # 固定第九章最终比较键
    return shared_sample, feature_names, training_event_rate, test_keys  # 返回约定的四项跨章交付

下面只保留构造器调用为默认可见代码;缺少数据根时,提示直接说明需要设置的环境变量。

book_data_dir_value = os.environ.get('BOOK_DATA_DIR')  # 读取显式配置的数据根
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向本书本地数据根目录'  # 提供可执行的配置提示
BOOK_DATA_DIR = Path(book_data_dir_value).expanduser().resolve()  # 解析当前数据根
m02_shared_sample, m02_features, m02_training_event_rate, m02_test_keys = build_m02_shared_sample(BOOK_DATA_DIR)  # 在当前干净内核构造共享对象

本章只用训练段拟合、验证段比较;测试键虽已固定,但测试标签和成绩继续封存。

下面在相同训练键上拟合一个受限深度且带固定剪枝惩罚的树、随机森林和梯度提升,并在相同验证键上与训练事件率比较。参数是透明教学候选,不声称已经达到全局最优;测试段仍不进入任何矩阵。

from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier  # 比较序列提升与样本特征抽样的树集成候选
from sklearn.metrics import average_precision_score, brier_score_loss, roc_auc_score  # 评价不平衡排序与概率误差
from sklearn.tree import DecisionTreeClassifier  # 建立可逐条读取分裂规则的浅树候选

m08_required_columns = ['order_book_id', 'prediction_date', 'label_end_date', *m02_features, 'max_drawdown_20d', 'drawdown_event_20d', 'split']  # 锁定第2章共享字段与顺序
assert list(m02_shared_sample.columns) == m08_required_columns  # 防止第8章重新定义或重排估计对象
m08_train_sample = m02_shared_sample.loc[m02_shared_sample['split'] == 'train'].copy()  # 取得固定训练键
m08_validation_sample = m02_shared_sample.loc[m02_shared_sample['split'] == 'validation'].copy()  # 取得固定验证键
m08_train_features = m08_train_sample[m02_features]  # 使用第2章固定特征顺序
m08_validation_features = m08_validation_sample[m02_features]  # 保持验证列完全一致
m08_train_target = m08_train_sample['drawdown_event_20d'].astype(int)  # 使用同一回撤事件标签
m08_validation_target = m08_validation_sample['drawdown_event_20d'].astype(int)  # 使用同一验证估计对象
m08_models = {'pruned_tree': DecisionTreeClassifier(max_depth=4, min_samples_leaf=40, ccp_alpha=0.001, random_state=42), 'random_forest': RandomForestClassifier(n_estimators=300, max_depth=6, min_samples_leaf=20, max_features='sqrt', random_state=42, n_jobs=2), 'gradient_boosting': GradientBoostingClassifier(n_estimators=150, learning_rate=0.03, max_depth=2, random_state=42)}  # 声明有限且可复算的树候选
for model in m08_models.values():  # 对每个候选执行相同训练流程
    model.fit(m08_train_features, m08_train_target)  # 仅用训练段拟合树结构

下一块只计算验证指标。阅读时先比较 Brier 与训练事件率基线,再查看 AUROC 和 PR-AUC 是否一致;PR-AUC 还应与验证事件率参照。若模型排序改变但 Brier 变差,不能称概率风险改善。

表 8.5: 第8章同一回撤样本上的树模型验证期动态比较
m08_cost_threshold = 1 / 9  # 按误报成本1与漏报成本8预先锁定行动阈值
m08_baseline_action = m02_training_event_rate >= m08_cost_threshold  # 将训练事件率常数概率映射为固定行动
m08_baseline_cost = np.where((m08_validation_target == 1) & (not m08_baseline_action), 8, np.where((m08_validation_target == 0) & m08_baseline_action, 1, 0))  # 计算验证期常数规则成本
m08_validation_records = [{'model': 'training_event_rate', 'validation_brier': brier_score_loss(m08_validation_target, np.repeat(m02_training_event_rate, len(m08_validation_target))), 'validation_auc': 0.5, 'validation_pr_auc': float(m08_validation_target.mean()), 'validation_mean_cost': float(m08_baseline_cost.mean())}]  # 建立训练事件率参照
for model_name, model in m08_models.items():  # 逐个读取同一验证键上的概率
    validation_probability = model.predict_proba(m08_validation_features)[:, 1]  # 生成合法回撤事件概率
    validation_action = validation_probability >= m08_cost_threshold  # 应用测试前声明的共同成本阈值
    validation_cost = np.where((m08_validation_target == 1) & ~validation_action, 8, np.where((m08_validation_target == 0) & validation_action, 1, 0))  # 逐行计算误报与漏报成本
    m08_validation_records.append({'model': model_name, 'validation_brier': brier_score_loss(m08_validation_target, validation_probability), 'validation_auc': roc_auc_score(m08_validation_target, validation_probability), 'validation_pr_auc': average_precision_score(m08_validation_target, validation_probability), 'validation_mean_cost': float(validation_cost.mean())})  # 记录概率、排序与成本证据
m08_validation_table = pd.DataFrame(m08_validation_records)  # 汇总为学生可读的模型表
print('训练/验证样本数:', len(m08_train_sample), len(m08_validation_sample))  # 核对比较分母
print('验证事件率参照:', float(m08_validation_target.mean()))  # 为PR-AUC提供必要基准
print(m08_validation_table.to_string(index=False))  # 读取各模型相对朴素基线的增量
训练/验证样本数: 7572 1864
验证事件率参照: 0.3449570815450644
              model  validation_brier  validation_auc  validation_pr_auc  validation_mean_cost
training_event_rate          0.229024        0.500000           0.344957              0.655043
        pruned_tree          0.208759        0.676542           0.487939              0.663090
      random_forest          0.207488        0.682461           0.526838              0.636266
  gradient_boosting          0.207085        0.687719           0.530692              0.642704

树模型只有在验证 Brier 低于事件率基线、排序指标也有用且结论对合理参数扰动不敏感时,才值得交给第 9 章。随机森林或 Boosting 没有改进时,应保留简单基线。分裂次数或不纯度重要性只表示模型如何使用变量,不代表经济贡献,更不代表因果效应。

8.9 练习

8.9.1 概念题

  1. 比较分类误差、Gini 指数和熵,并说明后二者的凹性与极值。 [核心|难度:2|分值:8|任务:独立]

  2. 解释代价复杂度剪枝为何能降低深树的方差。 [核心|难度:2|分值:7|任务:独立]

  3. 推导 Bagging 平均预测的方差,并解释随机森林为何随机抽取特征。 [核心|难度:2|分值:10|任务:独立]

  4. 比较 Bagging 与 Boosting 的训练方式、主要收益和风险。 [核心|难度:2|分值:10|任务:独立]

8.9.2 应用题

  1. 使用本地财务数据比较剪枝树、随机森林和梯度提升对下一报告期 ROE 的预测,并与均值基线比较。随机森林 OOB 只作为训练期内部诊断,不参与选参、不替代前向外层评价,也不单独支持未来性能结论。 [核心|难度:3|分值:20|任务:独立]

  2. 使用本章重建的共享行情样本比较剪枝树、随机森林与梯度提升,报告验证期事件率基线、AUROC、PR-AUC、Brier 与预先声明的阈值成本;测试评价留到第 9 章。 [核心|难度:3|分值:20|任务:综合案例]

8.9.3 理论题

  1. 在二分类下证明 Gini 与熵为凹函数,并求其最大值位置。 [拓展|难度:3|分值:15|任务:推导]

  2. 说明平方损失下梯度提升每一步为何拟合当前残差。 [拓展|难度:3|分值:10|任务:推导]

展开完整参考解答与评分键

8.10 练习参考解答

8.10.1 概念题参考解答

  1. 分类误差 \(1-\max_k p_k\) 只关心最大类别比例,对小变化不敏感。Gini 为 \(1-\sum_kp_k^2\),熵为 \(-\sum_kp_k\log p_k\);它们在类别概率单纯形上为凹函数,在纯节点为 0,在均匀分布时最大。熵对很小概率的变化更敏感,但实际分裂常相近。

  2. 完全生长的树能追踪训练样本的细小波动,偏差可能低而方差高。代价复杂度目标 \[\sum_{m=1}^{|T|}\sum_{i:x_i\in R_m}(y_i-\hat y_{R_m})^2+\alpha|T|\] 用叶节点数惩罚复杂度。只在训练期交叉验证选择 \(\alpha\),可在拟合与稳定性间权衡;剪枝不保证每个数据集都改善测试误差。

  3. 若每棵树方差为 \(\sigma^2\)、任意两棵相关系数为 \(\rho\),则 \[\operatorname{Var}(\bar f) =\frac{1}{B^2}\left[B\sigma^2+B(B-1)\rho\sigma^2\right] =\rho\sigma^2+\frac{1-\rho}{B}\sigma^2.\] 增加 \(B\) 只能消除第二项;随机森林在每次分裂随机限制候选特征,目的是降低树间相关性 \(\rho\)

  4. Bagging 在不同 bootstrap 样本上并行训练深树并平均,主要降低方差;Boosting 串行拟合当前损失的负梯度,常能降低偏差,但对学习率、树深、轮数和噪声更敏感。Bagging 可用 OOB 误差,Boosting 通常需要训练期验证和早停。二者都没有无条件优胜关系。

8.10.2 应用题参考解答

  1. 下一报告期 ROE(20 分):数据、连续季度标签与披露时点直接复用 小节 8.4.5。核心答案须保留 MarginTurnoverLevLog_Assets 四个特征,并明确 prediction_origin=info_date_ttarget_available_date=info_date_{t+1}。若同一公司—报告期有多个版本,只能使用当前历史库中最早披露版本;训练标签的 target_available_date 必须严格早于评分键最早的 prediction_origin。不得用 future_ROE 的大小、符号或事后分位数定义样本、截尾子集或选参口径。

    最终提交逐未来报告期结果表,至少报告两个披露日边界、模型、拟合与评分样本量、键签名、MSE、MAE、相对训练均值基线的损失改善和训练内参数。诊断应展示叶节点样本量分布、代价复杂度路径、随机森林 OOB 覆盖与误差,以及 Boosting 的训练内轮数—验证损失曲线;Boosting 轮数只能由内层曲线选择,外层 future_ROE 不得进入早停。公司面板不满足普通逐行可交换假设,因此 OOB 在本题只描述外层训练块内的拟合诊断,不参与选参、不替代按披露日推进的前向外层评价,也不单独支持未来性能结论。若某集成模型只在汇总均值上略优、逐期排序不稳定或未超过均值基线,应报告无稳定增量并保留更简单候选。特征重要性只描述预测规则的使用频率或损失下降,不能解释为财务比率导致未来 ROE。评分为数据版本与时点 4 分、同键前向切分 4 分、训练内参数 4 分、规定输出与诊断 4 分、限制解释 4 分。

下面给出可直接复算的完整答案。外层仍按 future_period 组织结果,但每一折的实际边界取该评分折最早 prediction_origin;内层同样按最早评分起点清除尚未披露的训练标签。所有模型在每个外层折共享同一组公司—预测起点键。

from sklearn.metrics import mean_absolute_error  # 为逐季度比较补充绝对误差
import json  # 把训练内选中的有限参数写成可审计文本

m08_roe_outer_periods = modeling_data.loc[test_mask, 'future_period'].drop_duplicates().sort_values()  # 沿用正文后30%的未来季度
m08_roe_tree_grid = [  # 声明小型剪枝候选集并避免连续参数搜索
    {'ccp_alpha': 0.0, 'max_depth': 3, 'min_samples_leaf': 20},  # 保留浅树无额外剪枝候选
    {'ccp_alpha': 0.0005, 'max_depth': 4, 'min_samples_leaf': 20},  # 比较温和成本复杂度剪枝
    {'ccp_alpha': 0.001, 'max_depth': 5, 'min_samples_leaf': 40},  # 比较更强剪枝与更大叶节点
]  # 完成有限剪枝网格
m08_roe_rf_grid = [  # 声明有限随机森林候选集
    {'n_estimators': 80, 'max_features': 'sqrt', 'min_samples_leaf': 10},  # 强化特征随机化
    {'n_estimators': 80, 'max_features': 1.0, 'min_samples_leaf': 20},  # 对照全特征Bagging式森林
]  # 完成有限森林网格
m08_roe_boost_grid = [  # 声明有限梯度提升候选集
    {'max_estimators': 80, 'learning_rate': 0.03, 'max_depth': 1},  # 在训练内曲线上搜索至多80棵树桩
    {'max_estimators': 80, 'learning_rate': 0.05, 'max_depth': 1},  # 比较更快学习率的树桩序列
    {'max_estimators': 80, 'learning_rate': 0.03, 'max_depth': 2},  # 比较二阶交互弱树
]  # 完成有限提升网格
def make_m08_roe_model(model_name, parameters, use_oob=False):  # 按统一接口构造三个回归候选
    if model_name == 'pruned_tree':  # 识别成本复杂度剪枝树
        return DecisionTreeRegressor(**parameters, random_state=42)  # 固定随机流并传入训练内参数
    if model_name == 'random_forest':  # 识别自助抽样森林
        return RandomForestRegressor(**parameters, random_state=42, n_jobs=2, bootstrap=True, oob_score=use_oob)  # 仅最终拟合开启OOB诊断
    boost_parameters = parameters.copy()  # 避免修改报告表中的原始参数字典
    max_estimators = boost_parameters.pop('max_estimators', None)  # 读取训练内曲线允许的轮数上限
    boost_parameters['n_estimators'] = boost_parameters.get('n_estimators', max_estimators)  # 最终拟合改用内层选中轮数
    return GradientBoostingRegressor(**boost_parameters, random_state=42)  # 构造不查看外层响应的提升序列
def make_m08_inner_folds(outer_fit):  # 建立严格按披露日清除的训练内前向折
    folds = []  # 收集拟合块、评分块与其实际起点
    candidate_periods = outer_fit['future_period'].drop_duplicates().sort_values(ascending=False)  # 从最新历史季度向前寻找合格折
    for inner_period in candidate_periods:  # 逐个构造尚未看见的训练内评分季度
        inner_score = outer_fit.loc[outer_fit['future_period'] == inner_period].copy()  # 固定该季度共同公司键
        inner_origin = inner_score['prediction_origin'].min()  # 用最早预测起点约束所有评分行
        inner_fit = outer_fit.loc[(outer_fit['future_period'] < inner_period) & (outer_fit['target_available_date'] < inner_origin)].copy()  # 只保留当时已披露标签
        if min(len(inner_fit), len(inner_score)) < 20:  # 跳过样本不足的训练内折
            continue  # 不让极小折主导参数选择
        assert inner_fit['target_available_date'].max() < inner_score['prediction_origin'].min()  # 强制标签可用日早于评分起点
        assert (inner_score['target_available_date'] > inner_score['prediction_origin']).all()  # 评分时目标必须仍未知
        folds.append((inner_fit, inner_score, inner_origin))  # 登记可审计训练内折
        if len(folds) == 2:  # 两个最近的合格披露时点折足以完成有限网格教学比较
            break  # 停止向更早季度扩展以控制出版执行成本
    assert folds, '当前外层训练历史不足以建立无泄漏内层折'  # 没有合格折时立即失败
    return folds  # 返回训练内选参唯一可见的数据
def score_m08_candidate(model_name, parameters, folds):  # 用相同训练内折评价一个参数候选
    if model_name != 'gradient_boosting':  # 单树与森林只需每折一个损失
        losses = []  # 保存共同训练内折MSE
        for inner_fit, inner_score, _ in folds:  # 遍历严格时点折
            candidate = make_m08_roe_model(model_name, parameters)  # 构造当前有限候选
            candidate.fit(inner_fit[feature_names], inner_fit['future_ROE'])  # 只拟合当时已披露标签
            losses.append(mean_squared_error(inner_score['future_ROE'], candidate.predict(inner_score[feature_names])))  # 记录训练内评分损失
        return float(np.mean(losses)), parameters.copy(), []  # 返回参数与空阶段曲线
    fold_curves = []  # 保存每个训练内折的逐阶段损失
    for inner_fit, inner_score, _ in folds:  # 每折均不接触外层响应
        candidate = make_m08_roe_model(model_name, parameters)  # 先拟合至声明的最大轮数
        candidate.fit(inner_fit[feature_names], inner_fit['future_ROE'])  # 只使用可得训练标签
        fold_curves.append([mean_squared_error(inner_score['future_ROE'], prediction) for prediction in candidate.staged_predict(inner_score[feature_names])])  # 形成训练内曲线
    mean_curve = np.mean(np.asarray(fold_curves), axis=0)  # 对相同轮数的训练内折取平均
    best_stage = int(np.argmin(mean_curve) + 1)  # 轮数完全由训练内平均曲线选择
    selected = {'n_estimators': best_stage, 'learning_rate': parameters['learning_rate'], 'max_depth': parameters['max_depth']}  # 锁定最终提升参数
    return float(mean_curve[best_stage - 1]), selected, mean_curve.tolist()  # 返回曲线供证据表展示
def tune_m08_roe_model(model_name, parameter_grid, outer_fit, outer_period):  # 仅用外层历史选择参数
    folds = make_m08_inner_folds(outer_fit)  # 固定各候选共享的披露时点折
    evaluations = []  # 收集候选平均损失、参数与阶段曲线
    for candidate_id, parameters in enumerate(parameter_grid):  # 逐项评价预先声明网格
        inner_mse, selected, curve = score_m08_candidate(model_name, parameters, folds)  # 获取训练内证据
        evaluations.append((inner_mse, candidate_id, selected, curve))  # 保留候选身份供审计
    best_mse, best_id, best_parameters, _ = min(evaluations, key=lambda item: item[0])  # 只按训练内MSE锁参
    curve_rows = []  # 准备提升曲线长表
    for _, candidate_id, _, curve in evaluations:  # 保留每个提升候选的全部训练内阶段
        for stage, loss in enumerate(curve, start=1):  # 逐轮登记平均验证损失
            curve_rows.append({'future_period': str(outer_period), 'candidate_id': candidate_id, 'stage': stage, 'inner_MSE': loss, 'selected_candidate': candidate_id == best_id, 'selected_stage': candidate_id == best_id and stage == best_parameters.get('n_estimators')})  # 标出最终候选与轮数
    return best_mse, best_parameters, curve_rows  # 返回锁参结果与训练内曲线
def build_m08_pruning_rows(best_parameters, outer_fit, outer_period, final_leaf_count):  # 在锁定结构约束下重拟合剪枝路径检查点
    selected_depth = best_parameters['max_depth']  # 沿用训练内选中的最大深度
    selected_min_leaf = best_parameters['min_samples_leaf']  # 沿用训练内选中的最小叶规模
    path_estimator = DecisionTreeRegressor(max_depth=selected_depth, min_samples_leaf=selected_min_leaf, random_state=42)  # 构造与最终树同约束的路径基树
    pruning_path = path_estimator.cost_complexity_pruning_path(outer_fit[feature_names], outer_fit['future_ROE'])  # 只用外层训练集计算最弱链接路径
    selected_alpha = float(best_parameters['ccp_alpha'])  # 保留有限网格锁定的精确剪枝强度
    nearest_step = int(np.argmin(np.abs(pruning_path.ccp_alphas - selected_alpha)))  # 定位离精确网格值最近的实际路径节点
    checkpoint_steps = np.unique(np.r_[np.linspace(0, len(pruning_path.ccp_alphas) - 1, 5).round().astype(int), nearest_step])  # 选择首尾、分位点与最近节点
    displayed_alphas = np.unique(np.r_[pruning_path.ccp_alphas[checkpoint_steps], selected_alpha])  # 强制把精确选中值纳入展示集合
    path_rows = []  # 收集逐alpha重拟合后的结构与训练不纯度
    for checkpoint_alpha in displayed_alphas:  # 逐个重拟合每个将展示的剪枝强度
        checkpoint_model = DecisionTreeRegressor(max_depth=selected_depth, min_samples_leaf=selected_min_leaf, ccp_alpha=float(checkpoint_alpha), random_state=42)  # 保持除alpha外的结构约束不变
        checkpoint_model.fit(outer_fit[feature_names], outer_fit['future_ROE'])  # 仅在外层训练块上重拟合检查点模型
        leaf_mask = checkpoint_model.tree_.children_left == checkpoint_model.tree_.children_right  # 识别重拟合树的叶节点
        training_impurity = float(np.sum(checkpoint_model.tree_.weighted_n_node_samples[leaf_mask] * checkpoint_model.tree_.impurity[leaf_mask]) / checkpoint_model.tree_.weighted_n_node_samples[0])  # 计算叶节点加权训练MSE
        matching_steps = np.flatnonzero(np.isclose(pruning_path.ccp_alphas, checkpoint_alpha, rtol=0.0, atol=1e-15))  # 判断该alpha是否为实际最弱链接断点
        path_step = int(matching_steps[0]) if matching_steps.size else -1  # 用负一步标记仅来自预设网格的精确alpha
        is_exact_selected = bool(np.isclose(checkpoint_alpha, selected_alpha, rtol=0.0, atol=1e-15))  # 标出训练内精确选中的网格值
        is_nearest_path = path_step == nearest_step  # 标出距离选中值最近的实际路径节点
        path_rows.append({'future_period': str(outer_period), 'path_step': path_step, 'ccp_alpha': float(checkpoint_alpha), 'leaf_count': checkpoint_model.get_n_leaves(), 'training_impurity': training_impurity, 'max_depth': selected_depth, 'min_samples_leaf': selected_min_leaf, 'exact_selected_alpha': is_exact_selected, 'nearest_path_alpha': is_nearest_path})  # 保存可独立核对的重拟合证据
    exact_leaf_counts = [row['leaf_count'] for row in path_rows if row['exact_selected_alpha']]  # 提取精确选中alpha对应的叶数
    assert exact_leaf_counts == [final_leaf_count]  # 强制精确alpha重拟合叶数等于最终拟合树诊断
    return path_rows  # 返回不接触外层评分响应的路径证据
def diagnose_m08_tree(model_name, fitted_model, best_parameters, inner_mse, outer_fit, outer_period):  # 生成纯训练数据结构诊断
    selected_stage = best_parameters.get('n_estimators', np.nan) if model_name == 'gradient_boosting' else np.nan  # 轮数只属于提升树早停证据
    diagnostic = {'future_period': str(outer_period), 'model': model_name, 'inner_MSE': inner_mse, 'leaf_count': np.nan, 'leaf_min': np.nan, 'leaf_p25': np.nan, 'leaf_median': np.nan, 'leaf_p75': np.nan, 'leaf_max': np.nan, 'OOB_n': np.nan, 'OOB_MSE': np.nan, 'OOB_R2': np.nan, 'selected_stage': selected_stage}  # 初始化跨模型字段
    path_rows = []  # 只为剪枝树返回路径检查点
    if model_name == 'pruned_tree':  # 汇报叶分布与代价复杂度路径
        leaf_sizes = pd.Series(fitted_model.apply(outer_fit[feature_names])).value_counts()  # 统计每个训练叶的样本量
        quantiles = leaf_sizes.quantile([0.25, 0.5, 0.75])  # 读取叶规模四分位数
        diagnostic.update({'leaf_count': int(leaf_sizes.size), 'leaf_min': int(leaf_sizes.min()), 'leaf_p25': float(quantiles.loc[0.25]), 'leaf_median': float(quantiles.loc[0.5]), 'leaf_p75': float(quantiles.loc[0.75]), 'leaf_max': int(leaf_sizes.max())})  # 保存完整叶规模摘要
        path_rows = build_m08_pruning_rows(best_parameters, outer_fit, outer_period, diagnostic['leaf_count'])  # 用同约束逐alpha重拟合并核对最终树叶数
    if model_name == 'random_forest':  # 计算训练内袋外覆盖、损失和解释度
        oob_mask = np.isfinite(fitted_model.oob_prediction_)  # 识别有袋外预测的训练行
        diagnostic.update({'OOB_n': int(oob_mask.sum()), 'OOB_MSE': mean_squared_error(outer_fit.loc[oob_mask, 'future_ROE'], fitted_model.oob_prediction_[oob_mask]), 'OOB_R2': r2_score(outer_fit.loc[oob_mask, 'future_ROE'], fitted_model.oob_prediction_[oob_mask])})  # 汇报OOB证据
    return diagnostic, path_rows  # 返回结构摘要与剪枝路径
m08_roe_results = []  # 收集外层逐季度基线与模型结果
m08_roe_diagnostics = []  # 收集叶分布、OOB与选中轮数诊断
m08_roe_pruning_paths = []  # 收集剪枝路径检查点
m08_roe_boost_curves = []  # 收集全部训练内Boosting阶段曲线
m08_roe_specs = {  # 配对模型名称与有限参数网格
    'pruned_tree': m08_roe_tree_grid,  # 注册成本复杂度剪枝候选
    'random_forest': m08_roe_rf_grid,  # 注册随机森林候选
    'gradient_boosting': m08_roe_boost_grid,  # 注册梯度提升候选
}  # 完成统一候选登记
for outer_period in m08_roe_outer_periods:  # 逐未来标签季度执行扩展窗口评价
    outer_score = modeling_data.loc[test_mask & modeling_data['future_period'].eq(outer_period)].copy()  # 固定后段披露日起点内的共同评分键
    outer_origin = outer_score['prediction_origin'].min()  # 用最早公司预测起点定义共同信息集
    outer_fit = modeling_data.loc[(modeling_data['future_period'] < outer_period) & (modeling_data['target_available_date'] < outer_origin)].copy()  # 只用评分前已披露历史标签
    assert outer_score['order_book_id'].is_unique  # 核对公司键在当前未来季度唯一
    assert outer_fit['target_available_date'].max() < outer_score['prediction_origin'].min()  # 强制训练标签早于所有评分起点
    assert (outer_score['target_available_date'] > outer_score['prediction_origin']).all()  # 评分发生时目标尚不可见
    baseline_prediction = np.repeat(outer_fit['future_ROE'].mean(), len(outer_score))  # 只用训练响应均值构造常数预测
    baseline_mse = mean_squared_error(outer_score['future_ROE'], baseline_prediction)  # 计算共同键均值基线MSE
    baseline_mae = mean_absolute_error(outer_score['future_ROE'], baseline_prediction)  # 计算共同键均值基线MAE
    key_hash = format(int(pd.util.hash_pandas_object(outer_score[['order_book_id', 'prediction_origin']], index=False).sum()), '016x')  # 生成共同公司—起点键签名
    baseline_record = {'future_period': str(outer_period), 'prediction_origin_min': outer_score['prediction_origin'].min().date(), 'prediction_origin_max': outer_score['prediction_origin'].max().date(), 'target_available_min': outer_score['target_available_date'].min().date(), 'target_available_max': outer_score['target_available_date'].max().date(), 'fit_n': len(outer_fit), 'score_n': len(outer_score), 'key_hash': key_hash, 'model': 'training_mean', 'MSE': baseline_mse, 'MAE': baseline_mae, 'improvement': 0.0, 'best_params': '{}'}  # 登记基线与日期契约
    m08_roe_results.append(baseline_record)  # 先加入每季比较分母
for outer_period in m08_roe_outer_periods:  # 对同一外层季度补入三个训练内调参模型
    outer_score = modeling_data.loc[test_mask & modeling_data['future_period'].eq(outer_period)].copy()  # 重建后段披露日起点内相同评分键
    outer_origin = outer_score['prediction_origin'].min()  # 重建该折共同信息起点
    outer_fit = modeling_data.loc[(modeling_data['future_period'] < outer_period) & (modeling_data['target_available_date'] < outer_origin)].copy()  # 重建可得历史标签
    baseline_mse = next(row['MSE'] for row in m08_roe_results if row['future_period'] == str(outer_period))  # 读取本季度训练均值参照
    baseline_row = next(row for row in m08_roe_results if row['future_period'] == str(outer_period))  # 读取共同日期与键契约
    for model_name, parameter_grid in m08_roe_specs.items():  # 逐个评估剪枝树、森林与提升树
        inner_mse, best_parameters, curve_rows = tune_m08_roe_model(model_name, parameter_grid, outer_fit, outer_period)  # 只用披露时点历史锁参
        m08_roe_boost_curves.extend(curve_rows)  # 保存训练内轮数曲线且不查看外层响应
        fitted_model = make_m08_roe_model(model_name, best_parameters, use_oob=(model_name == 'random_forest'))  # 构造锁参后的最终候选
        fitted_model.fit(outer_fit[feature_names], outer_fit['future_ROE'])  # 在完整外层训练集重拟合
        outer_prediction = fitted_model.predict(outer_score[feature_names])  # 对未参与选参的共同公司键预测
        model_mse = mean_squared_error(outer_score['future_ROE'], outer_prediction)  # 计算本季度外层MSE
        model_mae = mean_absolute_error(outer_score['future_ROE'], outer_prediction)  # 计算本季度外层MAE
        improvement = (baseline_mse - model_mse) / baseline_mse if baseline_mse > 0 else np.nan  # 以正值表示优于训练均值
        result_row = {**baseline_row, 'model': model_name, 'MSE': model_mse, 'MAE': model_mae, 'improvement': improvement, 'best_params': json.dumps(best_parameters, ensure_ascii=False, sort_keys=True)}  # 复用共同日期、样本量与键签名
        m08_roe_results.append(result_row)  # 保存逐季度同键评价结果
        diagnostic_row, path_rows = diagnose_m08_tree(model_name, fitted_model, best_parameters, inner_mse, outer_fit, outer_period)  # 生成纯训练数据结构证据
        m08_roe_diagnostics.append(diagnostic_row)  # 保存诊断且不用于改写外层选择
        m08_roe_pruning_paths.extend(path_rows)  # 保存路径检查点供独立核对
m08_roe_results_table = pd.DataFrame(m08_roe_results).sort_values(['future_period', 'model'])  # 整理逐季度规定字段
assert m08_roe_results_table.groupby('future_period')['score_n'].nunique().eq(1).all()  # 核对每季四个方法评分键数相同
assert m08_roe_results_table.groupby('future_period')['key_hash'].nunique().eq(1).all()  # 核对每季四个方法公司—起点键签名相同
display(m08_roe_results_table.style.format({'MSE': '{:.6f}', 'MAE': '{:.6f}', 'improvement': '{:.2%}'}))  # 渲染可评分主结果表
表 8.6: 习题5:扩展窗口下下一报告期ROE的逐季度同键比较
  future_period prediction_origin_min prediction_origin_max target_available_min target_available_max fit_n score_n key_hash model MSE MAE improvement best_params
11 2021Q1 2023-04-29 2023-04-29 2025-07-03 2025-07-03 1736 1 b266d94ab89011f2 gradient_boosting 0.002821 0.053109 -232.60% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 80}
9 2021Q1 2023-04-29 2023-04-29 2025-07-03 2025-07-03 1736 1 b266d94ab89011f2 pruned_tree 0.006151 0.078430 -625.37% {"ccp_alpha": 0.001, "max_depth": 5, "min_samples_leaf": 40}
10 2021Q1 2023-04-29 2023-04-29 2025-07-03 2025-07-03 1736 1 b266d94ab89011f2 random_forest 0.000214 0.014613 74.82% {"max_features": 1.0, "min_samples_leaf": 20, "n_estimators": 80}
0 2021Q1 2023-04-29 2023-04-29 2025-07-03 2025-07-03 1736 1 b266d94ab89011f2 training_mean 0.000848 0.029121 0.00% {}
14 2022Q1 2024-03-16 2024-04-29 2024-10-18 2026-04-24 2282 6 d360b7f3471fddc7 gradient_boosting 0.001444 0.034919 -181.27% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 70}
12 2022Q1 2024-03-16 2024-04-29 2024-10-18 2026-04-24 2282 6 d360b7f3471fddc7 pruned_tree 0.003684 0.054101 -617.81% {"ccp_alpha": 0.001, "max_depth": 5, "min_samples_leaf": 40}
13 2022Q1 2024-03-16 2024-04-29 2024-10-18 2026-04-24 2282 6 d360b7f3471fddc7 random_forest 0.007443 0.080144 -1350.08% {"max_features": 1.0, "min_samples_leaf": 20, "n_estimators": 80}
1 2022Q1 2024-03-16 2024-04-29 2024-10-18 2026-04-24 2282 6 d360b7f3471fddc7 training_mean 0.000513 0.021593 0.00% {}
17 2022Q2 2023-04-29 2023-07-14 2023-08-10 2023-10-31 2241 63 ac5fd95accf88019 gradient_boosting 0.003199 0.032269 32.21% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 70}
15 2022Q2 2023-04-29 2023-07-14 2023-08-10 2023-10-31 2241 63 ac5fd95accf88019 pruned_tree 0.009577 0.059837 -102.93% {"ccp_alpha": 0.001, "max_depth": 5, "min_samples_leaf": 40}
16 2022Q2 2023-04-29 2023-07-14 2023-08-10 2023-10-31 2241 63 ac5fd95accf88019 random_forest 0.010043 0.055366 -112.81% {"max_features": 1.0, "min_samples_leaf": 20, "n_estimators": 80}
2 2022Q2 2023-04-29 2023-07-14 2023-08-10 2023-10-31 2241 63 ac5fd95accf88019 training_mean 0.004719 0.041066 0.00% {}
20 2022Q3 2023-07-28 2023-08-31 2023-10-12 2023-10-31 2275 248 5382e2a881875055 gradient_boosting 0.005262 0.040545 39.24% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 70}
18 2022Q3 2023-07-28 2023-08-31 2023-10-12 2023-10-31 2275 248 5382e2a881875055 pruned_tree 0.040862 0.081556 -371.80% {"ccp_alpha": 0.001, "max_depth": 5, "min_samples_leaf": 40}
19 2022Q3 2023-07-28 2023-08-31 2023-10-12 2023-10-31 2275 248 5382e2a881875055 random_forest 0.020138 0.050859 -132.52% {"max_features": 1.0, "min_samples_leaf": 20, "n_estimators": 80}
3 2022Q3 2023-07-28 2023-08-31 2023-10-12 2023-10-31 2275 248 5382e2a881875055 training_mean 0.008661 0.059870 0.00% {}
23 2022Q4 2023-10-14 2024-12-20 2024-04-30 2026-03-27 2501 248 3a7da313f7c43cb1 gradient_boosting 0.068775 0.089404 -165.99% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 53}
21 2022Q4 2023-10-14 2024-12-20 2024-04-30 2026-03-27 2501 248 3a7da313f7c43cb1 pruned_tree 0.086212 0.132261 -233.43% {"ccp_alpha": 0.001, "max_depth": 5, "min_samples_leaf": 40}
22 2022Q4 2023-10-14 2024-12-20 2024-04-30 2026-03-27 2501 248 3a7da313f7c43cb1 random_forest 0.050859 0.094117 -96.70% {"max_features": 1.0, "min_samples_leaf": 20, "n_estimators": 80}
4 2022Q4 2023-10-14 2024-12-20 2024-04-30 2026-03-27 2501 248 3a7da313f7c43cb1 training_mean 0.025856 0.098533 0.00% {}
26 2023Q1 2024-03-29 2025-04-30 2024-04-26 2026-04-28 2813 12 694d8c1f371a07dd gradient_boosting 0.002042 0.042358 -370.42% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 80}
24 2023Q1 2024-03-29 2025-04-30 2024-04-26 2026-04-28 2813 12 694d8c1f371a07dd pruned_tree 0.005500 0.055595 -1167.01% {"ccp_alpha": 0.0, "max_depth": 3, "min_samples_leaf": 20}
25 2023Q1 2024-03-29 2025-04-30 2024-04-26 2026-04-28 2813 12 694d8c1f371a07dd random_forest 0.005000 0.054454 -1051.75% {"max_features": "sqrt", "min_samples_leaf": 10, "n_estimators": 80}
5 2023Q1 2024-03-29 2025-04-30 2024-04-26 2026-04-28 2813 12 694d8c1f371a07dd training_mean 0.000434 0.019234 0.00% {}
29 2023Q2 2024-04-09 2024-07-03 2024-07-30 2026-04-23 2844 250 aaa4dffe57a5e2d0 gradient_boosting 0.005453 0.035048 26.66% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 80}
27 2023Q2 2024-04-09 2024-07-03 2024-07-30 2026-04-23 2844 250 aaa4dffe57a5e2d0 pruned_tree 0.006800 0.043704 8.54% {"ccp_alpha": 0.0, "max_depth": 3, "min_samples_leaf": 20}
28 2023Q2 2024-04-09 2024-07-03 2024-07-30 2026-04-23 2844 250 aaa4dffe57a5e2d0 random_forest 0.002633 0.028794 64.59% {"max_features": "sqrt", "min_samples_leaf": 10, "n_estimators": 80}
6 2023Q2 2024-04-09 2024-07-03 2024-07-30 2026-04-23 2844 250 aaa4dffe57a5e2d0 training_mean 0.007435 0.039173 0.00% {}
32 2023Q3 2024-07-26 2024-08-31 2024-10-12 2024-10-31 3049 250 b2fda93c799d9ccc gradient_boosting 0.004417 0.036271 38.42% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 80}
30 2023Q3 2024-07-26 2024-08-31 2024-10-12 2024-10-31 3049 250 b2fda93c799d9ccc pruned_tree 0.053049 0.070225 -639.58% {"ccp_alpha": 0.0, "max_depth": 3, "min_samples_leaf": 20}
31 2023Q3 2024-07-26 2024-08-31 2024-10-12 2024-10-31 3049 250 b2fda93c799d9ccc random_forest 0.038469 0.051117 -436.32% {"max_features": "sqrt", "min_samples_leaf": 10, "n_estimators": 80}
7 2023Q3 2024-07-26 2024-08-31 2024-10-12 2024-10-31 3049 250 b2fda93c799d9ccc training_mean 0.007173 0.054036 0.00% {}
35 2023Q4 2023-10-28 2025-04-19 2024-04-30 2026-04-30 2636 250 c8dd214fb84b5b10 gradient_boosting 0.023225 0.076523 20.86% {"learning_rate": 0.03, "max_depth": 2, "n_estimators": 53}
33 2023Q4 2023-10-28 2025-04-19 2024-04-30 2026-04-30 2636 250 c8dd214fb84b5b10 pruned_tree 0.062602 0.107837 -113.33% {"ccp_alpha": 0.001, "max_depth": 5, "min_samples_leaf": 40}
34 2023Q4 2023-10-28 2025-04-19 2024-04-30 2026-04-30 2636 250 c8dd214fb84b5b10 random_forest 0.046120 0.083643 -57.16% {"max_features": "sqrt", "min_samples_leaf": 10, "n_estimators": 80}
8 2023Q4 2023-10-28 2025-04-19 2024-04-30 2026-04-30 2636 250 c8dd214fb84b5b10 training_mean 0.029346 0.098600 0.00% {}

表中 improvement 定义为 \((\operatorname{MSE}_{\text{mean}}-\operatorname{MSE}_{\text{model}})/\operatorname{MSE}_{\text{mean}}\);正值才表示相对当季训练响应均值有增量。key_hash 相同才证明四个方法确实用了相同公司—预测起点键;best_params 来自披露日约束后的训练内折,不是使用外层季度反推的参数。

m08_roe_diagnostic_table = pd.DataFrame(m08_roe_diagnostics).sort_values(['future_period', 'model'])  # 汇总不同模型适用的诊断证据
display(m08_roe_diagnostic_table.style.format({'inner_MSE': '{:.6f}', 'OOB_MSE': '{:.6f}', 'OOB_R2': '{:.3f}', 'leaf_p25': '{:.1f}', 'leaf_median': '{:.1f}', 'leaf_p75': '{:.1f}'}))  # 渲染叶分布、OOB与选中轮数
表 8.7: 习题5:叶节点分布、随机森林OOB与训练内提升轮数诊断
  future_period model inner_MSE leaf_count leaf_min leaf_p25 leaf_median leaf_p75 leaf_max OOB_n OOB_MSE OOB_R2 selected_stage
2 2021Q1 gradient_boosting 0.022971 nan nan nan nan nan nan nan nan nan 80.000000
0 2021Q1 pruned_tree 0.069873 4.000000 40.000000 46.8 143.5 530.8 1409.000000 nan nan nan nan
1 2021Q1 random_forest 0.069396 nan nan nan nan nan nan 1736.000000 0.816695 0.013 nan
5 2022Q1 gradient_boosting 0.012677 nan nan nan nan nan nan nan nan nan 70.000000
3 2022Q1 pruned_tree 0.037056 5.000000 40.000000 40.0 77.0 186.0 1939.000000 nan nan nan nan
4 2022Q1 random_forest 0.031871 nan nan nan nan nan nan 2282.000000 0.643182 0.009 nan
8 2022Q2 gradient_boosting 0.012709 nan nan nan nan nan nan nan nan nan 70.000000
6 2022Q2 pruned_tree 0.037103 4.000000 40.000000 56.5 172.5 676.2 1856.000000 nan nan nan nan
7 2022Q2 random_forest 0.031914 nan nan nan nan nan nan 2241.000000 0.640308 0.006 nan
11 2022Q3 gradient_boosting 0.012677 nan nan nan nan nan nan nan nan nan 70.000000
9 2022Q3 pruned_tree 0.037056 5.000000 40.000000 40.0 77.0 185.0 1933.000000 nan nan nan nan
10 2022Q3 random_forest 0.031871 nan nan nan nan nan nan 2275.000000 0.650655 0.001 nan
14 2022Q4 gradient_boosting 0.008622 nan nan nan nan nan nan nan nan nan 53.000000
12 2022Q4 pruned_tree 0.028728 5.000000 40.000000 40.0 83.0 218.0 2120.000000 nan nan nan nan
13 2022Q4 random_forest 0.022926 nan nan nan nan nan nan 2501.000000 0.592745 0.001 nan
17 2023Q1 gradient_boosting 0.007977 nan nan nan nan nan nan nan nan nan 80.000000
15 2023Q1 pruned_tree 0.021878 5.000000 20.000000 161.0 254.0 875.0 1503.000000 nan nan nan nan
16 2023Q1 random_forest 0.012009 nan nan nan nan nan nan 2813.000000 0.512612 0.030 nan
20 2023Q2 gradient_boosting 0.007977 nan nan nan nan nan nan nan nan nan 80.000000
18 2023Q2 pruned_tree 0.021878 5.000000 20.000000 164.0 255.0 886.0 1519.000000 nan nan nan nan
19 2023Q2 random_forest 0.012009 nan nan nan nan nan nan 2844.000000 0.507030 0.030 nan
23 2023Q3 gradient_boosting 0.007960 nan nan nan nan nan nan nan nan nan 80.000000
21 2023Q3 pruned_tree 0.021854 5.000000 20.000000 178.0 261.0 948.0 1642.000000 nan nan nan nan
22 2023Q3 random_forest 0.011984 nan nan nan nan nan nan 3049.000000 0.471154 0.037 nan
26 2023Q4 gradient_boosting 0.006544 nan nan nan nan nan nan nan nan nan 53.000000
24 2023Q4 pruned_tree 0.021839 5.000000 40.000000 40.0 90.0 229.0 2237.000000 nan nan nan nan
25 2023Q4 random_forest 0.010885 nan nan nan nan nan nan 2636.000000 0.542958 0.036 nan
m08_roe_pruning_table = pd.DataFrame(m08_roe_pruning_paths).sort_values(['future_period', 'ccp_alpha'])  # 汇总同约束路径断点与精确选中alpha
m08_exact_path_leaves = m08_roe_pruning_table.loc[m08_roe_pruning_table['exact_selected_alpha']].set_index('future_period')['leaf_count'].sort_index()  # 提取各期精确网格alpha的重拟合叶数
m08_final_tree_leaves = m08_roe_diagnostic_table.loc[m08_roe_diagnostic_table['model'] == 'pruned_tree'].set_index('future_period')['leaf_count'].astype(int).sort_index()  # 提取最终拟合树的诊断叶数
assert m08_exact_path_leaves.index.equals(m08_final_tree_leaves.index) and np.array_equal(m08_exact_path_leaves.to_numpy(), m08_final_tree_leaves.to_numpy())  # 再次核对精确alpha与最终树结构一致
display(m08_roe_pruning_table.style.format({'ccp_alpha': '{:.6f}', 'training_impurity': '{:.6f}'}))  # 显示每个重拟合检查点的叶数与训练不纯度
表 8.8: 习题5:锁定深度与最小叶规模后在外层训练集重拟合的剪枝路径检查点
  future_period path_step ccp_alpha leaf_count training_impurity max_depth min_samples_leaf exact_selected_alpha nearest_path_alpha
0 2021Q1 0 0.000000 14 0.788025 5 40 False False
1 2021Q1 3 0.000110 11 0.788239 5 40 False False
2 2021Q1 6 0.000275 8 0.788771 5 40 False False
3 2021Q1 8 0.000726 6 0.790064 5 40 False False
4 2021Q1 9 0.000861 4 0.791786 5 40 False True
5 2021Q1 -1 0.001000 4 0.791786 5 40 True False
6 2021Q1 11 0.031106 1 0.827730 5 40 False False
7 2022Q1 0 0.000000 13 0.614913 5 40 False False
8 2022Q1 3 0.000108 10 0.615158 5 40 False False
9 2022Q1 6 0.000215 7 0.615654 5 40 False False
10 2022Q1 8 0.000753 5 0.616943 5 40 False True
11 2022Q1 -1 0.001000 5 0.616943 5 40 True False
12 2022Q1 11 0.023898 1 0.649192 5 40 False False
13 2022Q2 0 0.000000 13 0.612854 5 40 False False
14 2022Q2 3 0.000124 10 0.613173 5 40 False False
15 2022Q2 6 0.000524 7 0.614080 5 40 False False
16 2022Q2 8 0.000641 4 0.615998 5 40 False True
17 2022Q2 -1 0.001000 4 0.615998 5 40 True False
18 2022Q2 11 0.024295 1 0.644404 5 40 False False
19 2022Q3 0 0.000000 13 0.616781 5 40 False False
20 2022Q3 3 0.000106 10 0.617011 5 40 False False
21 2022Q3 6 0.000209 7 0.617496 5 40 False False
22 2022Q3 8 0.000750 5 0.618793 5 40 False True
23 2022Q3 -1 0.001000 5 0.618793 5 40 True False
24 2022Q3 11 0.023974 1 0.651131 5 40 False False
25 2022Q4 0 0.000000 14 0.561697 5 40 False False
26 2022Q4 3 0.000092 11 0.561932 5 40 False False
27 2022Q4 6 0.000162 8 0.562336 5 40 False False
28 2022Q4 9 0.000704 5 0.563961 5 40 False True
29 2022Q4 -1 0.001000 5 0.563961 5 40 True False
30 2022Q4 12 0.021816 1 0.593407 5 40 False False
31 2023Q1 0 0.000000 5 0.484059 3 20 True True
32 2023Q1 1 0.000623 4 0.484682 3 20 False False
33 2023Q1 2 0.001281 3 0.485963 3 20 False False
34 2023Q1 3 0.002955 2 0.488918 3 20 False False
35 2023Q1 4 0.039634 1 0.528551 3 20 False False
36 2023Q2 0 0.000000 5 0.478843 3 20 True True
37 2023Q2 1 0.000626 4 0.479469 3 20 False False
38 2023Q2 2 0.001259 3 0.480727 3 20 False False
39 2023Q2 3 0.002968 2 0.483696 3 20 False False
40 2023Q2 4 0.039214 1 0.522910 3 20 False False
41 2023Q3 0 0.000000 5 0.446834 3 20 True True
42 2023Q3 1 0.000639 4 0.447473 3 20 False False
43 2023Q3 2 0.001132 3 0.448605 3 20 False False
44 2023Q3 3 0.003117 2 0.451721 3 20 False False
45 2023Q3 4 0.037294 1 0.489016 3 20 False False
46 2023Q4 0 0.000000 14 0.533362 5 40 False False
47 2023Q4 3 0.000105 11 0.533609 5 40 False False
48 2023Q4 6 0.000134 8 0.533982 5 40 False False
49 2023Q4 9 0.000684 5 0.535503 5 40 False True
50 2023Q4 -1 0.001000 5 0.535503 5 40 True False
51 2023Q4 12 0.020529 1 0.563252 5 40 False False

表 8.8 的每一行都固定训练内选中的 max_depthmin_samples_leaf,并只在相应外层训练块上按该行 ccp_alpha 重新拟合。exact_selected_alpha 标出有限网格实际锁定的精确值;nearest_path_alpha 标出离它最近的最弱链接路径断点;若精确值不是路径断点,则其 path_step\(-1\)training_impurity 是重拟合树各叶节点 MSE 按训练样本权重加总的结果。代码同时断言精确选中值的 leaf_count表 8.7 中最终拟合树的诊断一致,因此本表是训练期结构证据,不接触外层评分响应。

m08_roe_boost_curve_table = pd.DataFrame(m08_roe_boost_curves)  # 整理所有训练内候选的逐阶段损失
m08_curve_max = m08_roe_boost_curve_table.groupby(['future_period', 'candidate_id'])['stage'].transform('max')  # 读取各曲线终点
m08_curve_checkpoint = m08_roe_boost_curve_table['stage'].isin([1, 10, 25, 50]) | m08_roe_boost_curve_table['stage'].eq(m08_curve_max) | m08_roe_boost_curve_table['selected_stage']  # 保留教学检查点和选中轮数
display(m08_roe_boost_curve_table.loc[m08_curve_checkpoint].style.format({'inner_MSE': '{:.6f}'}))  # 只展示曲线检查点而保留完整计算证据
表 8.9: 习题5:Boosting训练内阶段损失曲线检查点
  future_period candidate_id stage inner_MSE selected_candidate selected_stage
0 2021Q1 0 1 0.030719 False False
9 2021Q1 0 10 0.029100 False False
24 2021Q1 0 25 0.027616 False False
49 2021Q1 0 50 0.026728 False False
79 2021Q1 0 80 0.026496 False False
80 2021Q1 1 1 0.030567 False False
89 2021Q1 1 10 0.028285 False False
104 2021Q1 1 25 0.026845 False False
129 2021Q1 1 50 0.026461 False False
159 2021Q1 1 80 0.025791 False False
160 2021Q1 2 1 0.030524 True False
169 2021Q1 2 10 0.027646 True False
184 2021Q1 2 25 0.025183 True False
209 2021Q1 2 50 0.023633 True False
239 2021Q1 2 80 0.022971 True True
240 2022Q1 0 1 0.015387 False False
249 2022Q1 0 10 0.014695 False False
264 2022Q1 0 25 0.014069 False False
289 2022Q1 0 50 0.013689 False False
319 2022Q1 0 80 0.013616 False False
320 2022Q1 1 1 0.015322 False False
329 2022Q1 1 10 0.014347 False False
344 2022Q1 1 25 0.013755 False False
369 2022Q1 1 50 0.013600 False False
399 2022Q1 1 80 0.013524 False False
400 2022Q1 2 1 0.015281 True False
409 2022Q1 2 10 0.014116 True False
424 2022Q1 2 25 0.013204 True False
449 2022Q1 2 50 0.012825 True False
469 2022Q1 2 70 0.012677 True True
479 2022Q1 2 80 0.012784 True False
480 2022Q2 0 1 0.015431 False False
489 2022Q2 0 10 0.014737 False False
504 2022Q2 0 25 0.014109 False False
529 2022Q2 0 50 0.013728 False False
559 2022Q2 0 80 0.013655 False False
560 2022Q2 1 1 0.015366 False False
569 2022Q2 1 10 0.014389 False False
584 2022Q2 1 25 0.013795 False False
609 2022Q2 1 50 0.013639 False False
639 2022Q2 1 80 0.013563 False False
640 2022Q2 2 1 0.015325 True False
649 2022Q2 2 10 0.014157 True False
664 2022Q2 2 25 0.013241 True False
689 2022Q2 2 50 0.012859 True False
709 2022Q2 2 70 0.012709 True True
719 2022Q2 2 80 0.012816 True False
720 2022Q3 0 1 0.015387 False False
729 2022Q3 0 10 0.014695 False False
744 2022Q3 0 25 0.014069 False False
769 2022Q3 0 50 0.013689 False False
799 2022Q3 0 80 0.013616 False False
800 2022Q3 1 1 0.015322 False False
809 2022Q3 1 10 0.014347 False False
824 2022Q3 1 25 0.013755 False False
849 2022Q3 1 50 0.013600 False False
879 2022Q3 1 80 0.013524 False False
880 2022Q3 2 1 0.015281 True False
889 2022Q3 2 10 0.014116 True False
904 2022Q3 2 25 0.013204 True False
929 2022Q3 2 50 0.012825 True False
949 2022Q3 2 70 0.012677 True True
959 2022Q3 2 80 0.012784 True False
960 2022Q4 0 1 0.010588 False False
969 2022Q4 0 10 0.010162 False False
984 2022Q4 0 25 0.009790 False False
1009 2022Q4 0 50 0.009559 False False
1039 2022Q4 0 80 0.009522 False False
1040 2022Q4 1 1 0.010548 False False
1049 2022Q4 1 10 0.009953 False False
1064 2022Q4 1 25 0.009612 False False
1089 2022Q4 1 50 0.009511 False False
1119 2022Q4 1 80 0.009472 False False
1120 2022Q4 2 1 0.010494 True False
1129 2022Q4 2 10 0.009481 True False
1144 2022Q4 2 25 0.008813 True False
1169 2022Q4 2 50 0.008680 True False
1172 2022Q4 2 53 0.008622 True True
1199 2022Q4 2 80 0.008856 True False
1200 2023Q1 0 1 0.010349 False False
1209 2023Q1 0 10 0.010040 False False
1224 2023Q1 0 25 0.009795 False False
1249 2023Q1 0 50 0.009673 False False
1279 2023Q1 0 80 0.009659 False False
1280 2023Q1 1 1 0.010319 False False
1289 2023Q1 1 10 0.009898 False False
1304 2023Q1 1 25 0.009697 False False
1329 2023Q1 1 50 0.009656 False False
1359 2023Q1 1 80 0.009646 False False
1360 2023Q1 2 1 0.010291 True False
1369 2023Q1 2 10 0.009349 True False
1384 2023Q1 2 25 0.008497 True False
1409 2023Q1 2 50 0.008104 True False
1439 2023Q1 2 80 0.007977 True True
1440 2023Q2 0 1 0.010349 False False
1449 2023Q2 0 10 0.010040 False False
1464 2023Q2 0 25 0.009795 False False
1489 2023Q2 0 50 0.009673 False False
1519 2023Q2 0 80 0.009659 False False
1520 2023Q2 1 1 0.010319 False False
1529 2023Q2 1 10 0.009898 False False
1544 2023Q2 1 25 0.009697 False False
1569 2023Q2 1 50 0.009656 False False
1599 2023Q2 1 80 0.009646 False False
1600 2023Q2 2 1 0.010291 True False
1609 2023Q2 2 10 0.009349 True False
1624 2023Q2 2 25 0.008497 True False
1649 2023Q2 2 50 0.008104 True False
1679 2023Q2 2 80 0.007977 True True
1680 2023Q3 0 1 0.010327 False False
1689 2023Q3 0 10 0.010019 False False
1704 2023Q3 0 25 0.009775 False False
1729 2023Q3 0 50 0.009653 False False
1759 2023Q3 0 80 0.009639 False False
1760 2023Q3 1 1 0.010297 False False
1769 2023Q3 1 10 0.009877 False False
1784 2023Q3 1 25 0.009677 False False
1809 2023Q3 1 50 0.009636 False False
1839 2023Q3 1 80 0.009626 False False
1840 2023Q3 2 1 0.010269 True False
1849 2023Q3 2 10 0.009330 True False
1864 2023Q3 2 25 0.008479 True False
1889 2023Q3 2 50 0.008087 True False
1919 2023Q3 2 80 0.007960 True True
1920 2023Q4 0 1 0.008552 False False
1929 2023Q4 0 10 0.008253 False False
1944 2023Q4 0 25 0.008019 False False
1969 2023Q4 0 50 0.007907 False False
1999 2023Q4 0 80 0.007895 False False
2000 2023Q4 1 1 0.008522 False False
2009 2023Q4 1 10 0.008116 False False
2024 2023Q4 1 25 0.007928 False False
2049 2023Q4 1 50 0.007893 False False
2079 2023Q4 1 80 0.007886 False False
2080 2023Q4 2 1 0.008492 True False
2089 2023Q4 2 10 0.007622 True False
2104 2023Q4 2 25 0.006874 True False
2129 2023Q4 2 50 0.006584 True False
2132 2023Q4 2 53 0.006544 True True
2159 2023Q4 2 80 0.006563 True False
m08_roe_model_rows = m08_roe_results_table.loc[m08_roe_results_table['model'] != 'training_mean'].copy()  # 排除改善恒为零的基线行
m08_roe_summary = m08_roe_model_rows.groupby('model').agg(mean_improvement=('improvement', 'mean'), sd_improvement=('improvement', 'std'), positive_quarters=('improvement', lambda values: int((values > 0).sum())), evaluated_quarters=('improvement', 'size')).reset_index()  # 汇总均值、离散度与胜出频数
display(m08_roe_summary.style.format({'mean_improvement': '{:.2%}', 'sd_improvement': '{:.2%}'}))  # 渲染跨季度稳定性证据
表 8.10: 习题5:下一报告期ROE模型的跨季度稳定性摘要
  model mean_improvement sd_improvement positive_quarters evaluated_quarters
0 gradient_boosting -88.10% 152.92% 5 9
1 pruned_tree -429.19% 371.17% 1 9
2 random_forest -344.22% 513.03% 2 9
stable_candidates = m08_roe_summary.loc[m08_roe_summary['positive_quarters'] == m08_roe_summary['evaluated_quarters'], 'model']  # 要求每个外层季度都优于均值基线
if stable_candidates.empty:  # 根据现场逐季度结果判断是否存在稳定增量
    print('现场结论:没有树候选在所有评价季度都优于训练响应均值,应报告无稳定增量并保留均值基线。')  # 如实输出失败或不稳定结果
else:  # 仅在逐季度条件全部满足时报告稳定候选
    print('现场结论:逐季度均改善的候选为', ', '.join(stable_candidates.tolist()), ';仍需结合改善幅度与诊断决定是否采用。')  # 限定结论范围
现场结论:没有树候选在所有评价季度都优于训练响应均值,应报告无稳定增量并保留均值基线。

表 8.9 中的 selected_stage 完全来自训练内阶段损失,代码从未计算外层逐阶段曲线。随机森林 OOB 指标、剪枝路径和叶规模也都只使用外层训练块;其中 OOB 只是一项训练期内部诊断,不参与选参、不替代前向外层评价,也不能据此声称未来性能。由于未按外层 future_ROE 的实现值截尾或另造“紧支持域”,表 8.6 的键不会随结果好坏变化。若 表 8.10 显示平均改善为正但胜出季度很少,仍应按不稳定结果解释,不能用跨期平均掩盖逐期失败。

  1. 共享回撤样本(20 分):完整数据身份、峰谷最大回撤定义、边界隔离与候选拟合直接复用 小节 8.8;验证期的规范输出是 表 8.5,不再复制一份答案代码。必须保持正文参数:max_depth=4min_samples_leaf=40ccp_alpha=0.001 的剪枝树;300 棵、max_depth=6min_samples_leaf=20max_features='sqrt' 的随机森林;以及 150 棵、learning_rate=0.03max_depth=2 的梯度提升。误报成本为 1、漏报成本为 8 时,若概率口径已经校准,事前行动阈值为 \(1/(1+8)\);阈值不得按验证结果重搜。

    可评分输出包括训练/验证日期边界与样本数、训练事件率、验证事件率,以及每个候选的 Brier、AUROC、PR-AUC 和平均阈值成本。诊断先检查公司—预测日键唯一、训练标签结束早于验证期、验证标签结束早于测试期、各段均含两类,再比较树深/叶节点限制的合理扰动是否改变结论。Brier 和平均成本优先回答概率决策是否改善,AUROC 与 PR-AUC只补充排序证据;若指标冲突,必须并列报告。测试段在本章继续封存,结论只适用于给定四家公司、2012–2024 年研究窗口和交易成本前的风险排序;重要性不是因果贡献。评分为估计对象与峰谷标签 4 分、边界与封存测试 4 分、固定候选和成本阈值 4 分、完整验证输出与诊断 4 分、限制解释 4 分。

8.10.3 理论题参考解答

  1. 二分类时 \[G(p)=2p(1-p),\qquad H(p)=-p\log p-(1-p)\log(1-p).\]\[G''(p)=-4<0,\] \[H''(p)=-\frac1p-\frac1{1-p}<0,\qquad 0<p<1.\] 因此二者严格凹。由对称性或一阶条件 \(G'(p)=2-4p=0\)\(H'(p)=\log[(1-p)/p]=0\),最大值都在 \(p=1/2\);纯节点 \(p=0\)\(1\) 时取 0。

  2. 平方损失为 \(L(y,F)=\frac12(y-F)^2\),对当前预测 \(F_{m-1}(x_i)\) 的负梯度是 \[-\frac{\partial L}{\partial F}\bigg|_{F=F_{m-1}(x_i)} =y_i-F_{m-1}(x_i)=r_{im}.\] 因而第 \(m\) 步用弱学习器 \(h_m(x)\) 拟合残差 \(r_{im}\),再更新 \[F_m(x)=F_{m-1}(x)+\nu\gamma_mh_m(x),\] 其中 \(\gamma_m\) 可由线搜索确定,\(\nu\) 是学习率。较小 \(\nu\) 通常需要更多轮数,但是否改善泛化仍需验证与早停。

8.11 章末回顾

树方法把非线性和交互写成可读分裂,集成方法则通过平均或序列修正改善预测。应能解释剪枝、OOB、特征子采样与负梯度更新,并准确陈述 Gini 和熵的凹性。真实案例的结论必须来自共同时间切分下的样本外指标;机制模拟、同期会计恒等关系和重要性排名都不是现实因果证据。

无提示检索

  1. 成本复杂度剪枝中的参数如何权衡训练拟合与树规模?
  2. 随机森林为什么在每次分裂时抽取特征,它试图降低什么相关性?
  3. 为什么本章的前向验证表不能称为最终测试成绩?
展开检索反馈与定向返回

若第 1 题不确定,返回“树的剪枝”;若第 2 题不确定,返回“随机森林”;若第 3 题不确定,返回真实案例的时间切分与验证表说明。

下一章将先锁定支持向量机候选,再与既有模型共同开启一次最终测试期,避免反复查看测试结果。

Breiman, Leo. 2001年. 《Random Forests》. Machine Learning 45 (1): 5~32. https://doi.org/10.1023/A:1010933404324.
Chipman, Hugh A., Edward I. George, 和 Robert E. McCulloch. 2010年. BART: Bayesian Additive Regression Trees》. The Annals of Applied Statistics 4 (1): 266~98. https://doi.org/10.1214/09-AOAS285.
Friedman, Jerome H. 2001年. 《Greedy Function Approximation: A Gradient Boosting Machine》. The Annals of Statistics 29 (5): 1189~232. https://doi.org/10.1214/aos/1013203451.