09 集成学习:从群体智慧到量化决策

本章会用到的数据

  • 公开下载上市公司财务报表。代码会在首次运行时下载并保存到 data/course/

  • 这些数据能做什么:比较随机森林、XGBoost 与简单基准对公司 EPS 的预测。

  • 分析时注意:只使用预测日期前已经披露的财务信息,并按时间先后划分样本。

  • 判断模型:参数只在训练和验证阶段选择,最终测试期只用于报告结果。

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

学习内容 分钟
动机与先修 15
Bagging 与依赖结构 30
Boosting 与伪残差 35
形成性检查与反馈 15
公开案例输入 15
独立练习 25
集中反馈与小结 15

第9章 集成学习

从群体智慧到量化决策

集成学习概念图 两个简单的弱学习器(圆形、方形)通过集成方法结合成一个强大的集成模型。 集成学习:化零为整的力量 模型 A + 模型 B 集成模型

本章学习路线图

  1. 核心困境: 为何我们需要集成学习?
  2. 两大思想: Bagging 与 Boosting 的哲学。
  3. 基石技术: 自助法 (Bootstrap) 如何创造数据?
  4. Bagging 家族: 从袋装法到随机森林。
  5. Boosting 家族: 从梯度提升到 XGBoost。
  6. 实战演练: 使用 Python 预测公司 EPS。
  7. 总结与习题: 深化理解,巩固知识。

核心困境:简单 vs. 复杂

在金融预测中,我们常常面临两难。

简单模型与复杂模型的对比 左侧是一个欠拟合的简单模型(直线),右侧是一个过拟合的复杂模型(曲线)。 简单模型 (欠拟合) 无法捕捉数据趋势 复杂模型 (过拟合) 完美拟合噪声,泛化能力差

本章核心问题:为何“三个臭皮匠”常常胜过“一个诸葛亮”?

集成学习 (Ensemble Learning) 提供了一个优雅的解决方案。

核心思想:将多个相对简单的模型(“臭皮匠”)结合起来,以期获得超越最优秀的单个复杂模型(“诸葛亮”)的预测能力。

三个臭皮匠 vs 一个诸葛亮 一个现代风格的天平,左侧是三个代表简单模型的小人,右侧是一个代表复杂模型的大师,天平达到平衡,象征群体智慧的力量。 “三个臭皮匠” (多个简单模型) “一个诸葛亮” (单个复杂模型)

我们的学习目标:掌握集成学习的“组合拳”

在本章结束时,你将能够:

  • 理解核心思想: 比较 Bagging 与 Boosting 在何种条件下改变偏差—方差,并用开发窗验证样本外效果。
  • 掌握核心技术: 解释自助法 (Bootstrap)、袋装法 (Bagging)、随机森林 (Random Forest) 和提升法 (Boosting) 的工作原理。
  • 理解训练过程: 描绘出这些复杂算法的内部训练流程。
  • 付诸实践: 使用 Python 和 scikit-learn 构建、训练并评估集成学习模型。

集成学习的两大流派

集成学习并非单一算法,而是一种战略思想。主要分为两大流派:

  1. Bagging 家族 (并行): 平均低相关基学习器通常可降低方差;能否改善样本外误差仍须验证。
    • 代表:袋装法 (Bagging), 随机森林 (Random Forest)
  2. Boosting 家族 (串行): 逐步加法模型常可降低训练偏差,但偏差—方差与样本外表现取决于损失、树深、学习率、轮数与早停。
    • 代表:AdaBoost, 梯度提升 (Gradient Boosting), XGBoost

焦点:为何偏爱决策树?

集成学习可以使用多种基础模型;决策树是 Bagging 与 Boosting 的常见基学习器,也是本章为讲清机制而选用的焦点。

  • 复杂树的条件性风险:未受约束的决策树在部分样本上可能呈现低训练偏差和高抽样方差,但样本外表现取决于数据生成过程与复杂度选择。
  • 平均为何可能有效:对误差相关性较低的树取平均可降低方差;幅度取决于树间相关性、采样方案和基学习器,样本外收益仍须在同一开发窗验证。

第一部分:Bagging 与随机森林

核心思想:民主投票,人多力量大

核心基石 (1): 自助法 (Bootstrap)

问题:为了估计抽样波动或训练多样化模型,我们希望构造保持正确抽样结构的重抽样数据集。但现实中通常只有一个样本。怎么办?

自助法 (Bootstrap):在 iid 经验分布下有放回重抽样,产生条件重抽样样本。这些样本共享同一个原始数据,彼此及与原样本都不是真正独立。

自助法 (Bootstrap) 的可视化流程

假设我们的原始数据集是 [A, B, C, D, E],大小 \(N=5\)

自助法抽样流程 从一个包含五个彩色方块的原始数据集中,通过有放回抽样生成三个新的自助样本,每个样本中元素的数量和构成都不同。 原始数据集 (N=5) A B C D E 有放回抽样 N 次... 自助样本 (Bootstrap Samples) 样本 1: 样本 2: 样本 3: 关键结论:每个自助样本都与原始样本略有不同,为训练“多样化”的模型提供了数据基础。

课堂讨论:自助法为何有效?

普通逐行 iid bootstrap 需要:观测近似 iid/可交换;重抽样单位与真实依赖结构一致;目标统计量对经验分布足够规则。仅说“样本代表总体”不够。

金融数据的重抽样单位

  • 日收益序列:moving/stationary block bootstrap。
  • 公司内多期相关面板:按公司 cluster bootstrap。
  • 时间与公司双重依赖:使用相应双向或块方案,不能逐行 iid 抽样。

这使得我们能够:

  1. 模拟数据集的多样性:为训练多个模型提供不同的“视角”。
  2. 估计统计量的不确定性:例如,通过在多个自助样本上计算均值,我们可以得到均值的置信区间。这是现代统计学中的一个革命性思想。

核心技术 (1): 袋装法 (Bagging) = 自助法 + 模型聚合

Bagging,全称 Bootstrap Aggregating,其逻辑简单而强大:

Bagging 流程图 从原始数据通过Bootstrap生成多个数据集,每个数据集训练一个模型,最后将所有模型结果聚合。 原始数据 1. Bootstrap 样本 1 样本 2 样本 B ... 2. 训练独立模型 模型 1: f(1)(x) 模型 2: f(2)(x) 模型 B: f(B)(x) ... 3. 聚合 (平均/投票) 最终预测

Bagging 回归的数学表达

对于一个回归任务,Bagging 模型的最终预测 \(\hat{f}_{\text{bag}}(x)\) 是所有基模型预测值的平均:

\[ \large{\hat{f}_{\text{bag}}(x) = \frac{1}{B} \sum_{b=1}^{B} \hat{f}^{(b)}(x)} \tag{1}\]

  • \(B\): 自助样本的数量,也是基模型的数量。
  • \(\hat{f}^{(b)}(x)\): 在第 \(b\) 个自助样本上训练得到的模型的预测。

Bagging 方差为何有下限

在常见的非负同相关设定 \(0\leq\rho\leq1\) 下,若各基学习器误差方差同为 \(\sigma^2\)、两两相关系数同为 \(\rho\),则

\[\operatorname{Var}\!\left(\frac{1}{B}\sum_{b=1}^{B}e_b\right)=\sigma^2\left[\rho+\frac{1-\rho}{B}\right].\]

核心思想

  • 方差下降条件:基学习器误差不完全相关。
  • 相关性下限:当 \(B\) 增大时,方差仍存在 \(\rho\sigma^2\) 下限。
  • 金融类比:组合分散风险同样依赖资产并非完全同向波动。

“免费”的验证集:袋外数据 (Out-of-Bag)

在自助采样的过程中,每个新样本都是通过对原数据有放回地抽取 \(N\) 次得到的。

一个有趣的问题是:对于原始数据集中的某一个特定数据点,它在某一次自助采样中不被抽中的概率是多少?

  • 每次抽样时,不被抽中的概率是 \(1 - \frac{1}{N}\)
  • 我们总共抽了 \(N\) 次,所以完全不被抽中的概率是 \(\left(1 - \frac{1}{N}\right)^N\)

OOB 概率的极限是 1/e

\(N\) 趋向于无穷大时,我们利用一个重要的极限公式:

\[ \large{\lim_{N \to \infty} \left(1 - \frac{1}{N}\right)^N = e^{-1} \approx 0.368} \tag{2}\]

这意味着:

  • 平均而言,每个自助样本中只包含了原数据中约 63.2% 的独特数据点。
  • 剩下的约 36.8% 的数据点没有被用于该样本的训练,它们被称为袋外 (Out-of-Bag, OOB) 数据

利用 OOB 数据进行误差估计

对每个观测而言,未包含它的树可提供一组内部验证预测;但这些预测仍来自同一训练样本和同一调参过程。

  1. 对于原始数据集中的每一个数据点 \(x_i\)
  2. 找出所有不包含 \(x_i\) 的自助样本(约 \(B/3\) 个)。
  3. 让在这些样本上训练出的模型对 \(x_i\) 进行预测,然后聚合结果。
  4. 计算 \(x_i\) 的聚合预测值与真实值 \(y_i\) 之间的误差。
  5. 对所有数据点的误差取平均,就得到了OOB 误差

OOB 的用途与边界

  • 用途:低成本的内部泛化诊断。
  • 可能偏差:有限样本、时间依赖、类别不平衡,或使用 OOB 结果调参。
  • 不能替代:时间有序验证或最终测试集。

Bagging 的一个潜在问题

Bagging 存在一个问题:如果数据集中存在一两个非常强的预测特征,那么在每个自助样本上构建的决策树,其顶层分裂节点很可能都会选择这几个强特征。

这会导致:

  • 所有基模型(决策树)的结构变得非常相似。
  • 模型之间的相关性 (Correlation) 增加。
  • 取平均所带来的降方差效果被削弱。

核心技术 (2): 随机森林 = Bagging + 特征随机化

随机森林 (Random Forest) 的解决方案:在 Bagging 的基础上,引入了特征随机化,以解决树之间相关性过高的问题。

随机森林的特征随机化 在决策树的一个分裂节点,随机森林从所有特征中随机选择一个子集,并仅在该子集中选择最佳分裂特征。 随机森林的核心创新:特征随机化 在决策树的每个节点进行分裂时... 所有特征 (m个) F1 F2 F3 F4 F5 ... 随机抽取 k个特征 (k<m) 特征子集 F2 F5 F3 选择最优分裂 用 F3 分裂? 效果:强制模型探索不同特征,**降低树之间的相关性**。

随机森林 vs. Bagging:一个直观对比

特征 Bagging (使用决策树) 随机森林
样本层面 使用自助法生成多样化的训练样本 相同
特征层面 在每个节点分裂时,考虑所有 \(m\) 个特征 在每个节点分裂时,只考虑随机抽取\(k\) 个特征
结果 平均效果取决于单树方差与树间相关性 特征随机化旨在降低相关性,也可能提高单树偏差
性能 没有无条件排序 在同一切分、指标与运行时间和内存下比较

如果设置 \(k=m\),那么随机森林就退化成了 Bagging。

随机森林的训练步骤总结

对于 \(i = 1, \dots, B\) 次循环:

  1. 样本抽样: 从原始数据集 \(D\) 中,通过自助法抽取一个自助数据集 \(D_i\)

  2. 构建决策树: 使用 \(D_i\) 构建一棵决策树 \(T_i\)

    • 在树的每一个节点,从全部 \(m\) 个特征中随机选择 \(k\) 个特征
    • 基于这 \(k\) 个特征,确定最佳分割点
    • 递归地重复,直到满足停止条件。
  3. 集成预测: 将 \(B\) 棵树的预测结果通过平均或投票进行组合。

第二部分:Boosting

核心思想:专家会诊,不断进步

核心技术 (3): 提升法 (Boosting) 的不同哲学

与 Bagging 家族并行训练、民主投票的思想完全不同,Boosting 采用的是一种串行、迭代、专注错误的哲学。

Boosting 流程示意图 一个串行过程:第一个模型产生残差,第二个模型拟合残差,第三个模型拟合新的残差,最终将所有模型相加。 原始数据 模型 1(弱学习器) 残差 1(模型1的错误) 模型 2(拟合残差1) 残差 2(累积错误) ... 模型 B = 强学习器 核心思想:每个新模型都致力于修正前面所有模型的累积误差。

Boosting 的偏差—方差效果必须由验证决定

  • 训练效果:逐步加法常可降低训练偏差。
  • 泛化条件:共同取决于基学习器、损失、学习率、树深、轮数、正则化和早停。
  • 可能结果:Boosting 既可能欠拟合,也可能过拟合。
  • 选择证据:只用预先声明的开发期验证比较结果选择复杂度。

梯度提升 (Gradient Boosting) 的执行步骤 (1/4)

以下四步只描述平方损失 \(L(y,F)=\tfrac12(y-F)^2\) 的特例;此时负梯度恰好等于普通残差。

  1. 初始化:
      1. 设定一个初始的预测,通常是目标变量的均值 \(f_0(x) = \bar{y}\)
      1. 计算初始残差 \(r_i = y_i - f_0(x_i)\)

梯度提升 (Gradient Boosting) 的执行步骤 (2/4)

  1. 循环迭代 (for \(b = 1, \dots, B\)):
      1. 拟合残差: 使用特征 \(x\)当前的残差 \(r\) 来训练一个新的决策树模型 \(h_b(x)\)
      • 这棵树的目标不是预测 \(y\),而是预测上一步的错误

梯度提升 (Gradient Boosting) 的执行步骤 (3/4)

  1. 循环迭代 (续):
      1. 更新预测: 将新模型的预测结果以一个较小的步长 \(\lambda\) (学习率) 加到总预测上:

      \[ \large{f_b(x) := f_{b-1}(x) + \lambda h_b(x)} \]

      1. 更新残差: 计算新的残差:

      \[ \large{r_i := y_i - f_b(x) = (y_i - f_{b-1}(x)) - \lambda h_b(x_i) = r_{i, \text{old}} - \lambda h_b(x_i)} \]

梯度提升 (Gradient Boosting) 的执行步骤 (4/4)

  1. 最终模型:

    经过 \(B\) 轮迭代,最终的模型是所有基学习器(经过学习率缩放后)的加和:

    \[ \large{F(x) = f_B(x) = f_0(x) + \lambda \sum_{b=1}^{B} h_b(x)} \]

一般损失拟合负梯度而非普通残差

对一般可微损失,第 \(b\) 轮的伪残差是

\[ r_{ib}=-\left.\frac{\partial L(y_i,F(x_i))}{\partial F(x_i)}\right|_{F=F_{b-1}}. \]

  • 拟合对象:新基学习器拟合 \(r_{ib}\)
  • 叶区步长:树模型可在每个 \(R_{mb}\) 估计最优步长 \(\gamma_{mb}\),再以学习率 \(\nu\) 更新。
  • 分类边界:logistic loss 伪残差不等于“真实类别减预测类别标签”。

形成性检查:先判断损失与重抽样单位

独立作答

  1. 时间序列收益能否逐日 iid bootstrap?
  2. logistic boosting 是否直接拟合 \(y-\hat y_{class}\)
  3. 训练误差持续下降能否证明没有过拟合?

形成性检查反馈:三项都不能直接成立

  1. 重抽样:时间序列应保留块依赖;答错回看 自助法边界
  2. 拟合对象:logistic loss 拟合负梯度,而非类别残差;答错回看 一般损失
  3. 过拟合判定:需训练—验证比较或时间外窗口,并事先设定早停。

提升法的关键参数:学习率 (\(\lambda\))

在提升法的更新步骤中,\(\lambda\) (又称收缩参数学习率) 是一个至关重要的超参数。

\[ \large{f_b(x) := f_{b-1}(x) + \lambda h_b(x)} \]

  • 它控制了每次迭代中,新模型对总体模型的贡献程度。
  • 较小的 \(\lambda\) (例如 0.01-0.1) 意味着每次只学习一点点,模型更新的步伐更稳健。
  • 较小的学习率通常需要更多轮;是否改善泛化取决于轮数、树深、采样和早停,不能由学习率单独判断。

实践中的权衡\(\lambda\) 越小,通常需要的基模型数量 \(B\) 就越多,训练时间也越长。

常用提升算法“全家桶” (1/4)

  • AdaBoost (自适应提升):
    • 优点: 思想简单,是早期的成功算法。它通过增加被错误分类样本的权重来迭代。
    • 缺点: 对噪声和异常值非常敏感,容易导致过拟合。

常用提升算法“全家桶” (2/4)

  • XGBoost (极限梯度提升):
    • 机制: 提供正则项、行列采样、缺失值分支与并行实现;正则化用于惩罚或控制复杂度,不保证不过拟合。
    • 边界: 速度与精度取决于实现版本、硬件、数据、指标和调参预算。

常用提升算法“全家桶” (3/4)

  • LightGBM (轻量级梯度提升):
    • 机制: 叶优先生长与直方图实现可能在特定数据和硬件下节省计算与内存。
    • 边界: 仍须在相同运行时间和内存、数据切分和指标下与其他实现比较。

常用提升算法“全家桶” (4/4)

  • CatBoost (类别梯度提升):
    • 机制: 为类别特征 (Categorical Features) 提供有序统计等处理路径,可减少手工编码步骤。
    • 边界: 是否优于独热编码或其他基线取决于任务、泄漏控制、指标和计算预算。

第三部分:权衡与实践

在性能与可解释性之间寻找平衡

集成学习的终极权衡:性能 vs. 可解释性

  • 优点:
    • 候选性能: 集成学习常是竞赛与预测任务的强基线,但优势必须在确定数据、指标与时间窗上复现。
    • 复杂度效果: Bagging 对低相关基学习器取平均,常降低估计方差;Boosting 逐步加法常降低训练偏差,但两者的样本外误差均取决于基学习器、损失、采样、学习率、深度、轮数和早停,须在同一开发窗验证。
  • 缺点:
    • 可解释性差: 最大的代价是模型变成了“黑箱”。我们很难清晰地解释出某个特定预测是如何以及为什么得出的。
    • 计算成本高: 需要训练成百上千个模型,计算开销远大于单个模型。

从“白箱”到“黑箱”的可视化对比

白箱模型 vs. 黑箱模型 左侧是一个清晰、容易检查的决策树(白箱)。右侧是一个内部充满复杂连接网络的黑箱模型。 决策树 (白箱) X1>5 X2>2 类别 B 类别 A 类别 B 决策路径清晰可循 集成模型 (黑箱) 输入 输出 内部决策过程复杂,难以追踪

偷窥“黑箱”:特征重要性 (Feature Importance)

虽然我们无法解释单个预测,但集成模型提供了一个强大的工具来理解模型的全局行为特征重要性

基本思想:一个特征的重要性,取决于它在所有树的构建过程中,对降低模型不纯度(如基尼不纯度)或误差(如均方误差)的平均贡献有多大

如果一个特征频繁地被选作分裂节点,并且每次分裂都能显著提升模型的纯度/精度,那么它的重要性得分就高。这让我们能够知道模型主要依赖哪些特征来进行预测。

实战演练:使用集成学习预测公司每股收益 (EPS)

核心任务:预测公司的未来盈利能力

  • 对象:每股收益(Earnings Per Share, EPS)衡量公司盈利能力。

  • 任务价值:EPS 与估值和股价判断相关,因此是投资者和分析师关注的预测对象。

  • 目标变量 (y): 下一财年基本每股收益

  • 特征变量 (X):

    • log_assets: 总资产对数
    • debt_ratio: 负债率
    • roa: 资产回报率

我们将对比随机森林和 XGBoost 在这个任务上的表现。

准备工作:导入必需的 Python 库

我们将使用以下 Python 库来完成我们的任务。

代码
# 为“准备工作:导入必需的 Python 库”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“准备工作:导入必需的 Python 库”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“准备工作:导入必需的 Python 库”,从 `pathlib` 导入`Path` 用于构造可跨平台操作的文件路径。
from pathlib import Path
# 为“准备工作:导入必需的 Python 库”,从 `sklearn.ensemble` 导入`RandomForestRegressor` 用于拟合随机森林回归器。
from sklearn.ensemble import RandomForestRegressor
# 为“准备工作:导入必需的 Python 库”,从 `sklearn.metrics` 导入`mean_squared_error` 用于计算真实值与预测值的均方误差。
from sklearn.metrics import mean_squared_error
# 为“准备工作:导入必需的 Python 库”,从 `xgboost` 导入`XGBRegressor` 用于拟合 XGBoost 回归器。
from xgboost import XGBRegressor
# 为“准备工作:导入必需的 Python 库”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“准备工作:导入必需的 Python 库”导入 `seaborn` 并绑定 `sns`,用于编码当前任务的统计分布或分组关系。
import seaborn as sns
# 导入 warnings 接口,用于在机制演示中控制已知第三方提示而不掩盖模型或数据错误。
import warnings

# 按给定的类别与来源筛选运行时警告。
warnings.filterwarnings('ignore')
# 设置可视化风格
sns.set_style("whitegrid")
plt.rcParams['font.serif'] = ['Source Han Serif SC'] # 使用项目规定的中文字体
plt.rcParams['font.family'] = ['serif'] # 统一图形字体族
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

数据获取与准备:使用真实世界数据

  • 数据:公开 financial_statement.h5 中 8 家沪苏浙皖上市公司年度财报。
  • 预测时点:每份财报 info_date 所在月末。
  • 目标:同一公司下一份年度财报披露的 EPS。
  • 版本边界:当前文件无法还原各历史日期当时保存的数据版本;只检查披露日期,不将结果解释为完整历史版本回测。
代码
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择财务报表文件。
financial_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/financial_statement.h5'), Path('C:/qiufei/data/stock/financial_statement.h5'), Path('data/course/financial_statement.h5')] if candidate_path.exists()), Path('data/course/financial_statement.h5'))
if not financial_path.exists():
    financial_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/financial_statement.h5', financial_path)
company_ids = ['600276.XSHG', '600009.XSHG', '600585.XSHG', '603259.XSHG', '002415.XSHE', '600104.XSHG', '600019.XSHG', '600406.XSHG']  # 固定长三角公司池
financial_columns = ['order_book_id', 'quarter', 'info_date', 'total_assets', 'total_liabilities', 'net_profit_parent_company', 'basic_earnings_per_share']  # 限定建模字段
company_frames = []  # 收集公司级选择性读取结果
for company_id in company_ids:  # 避免载入全市场全部列
    company_frame = pd.read_hdf(financial_path, key='financial_data', where=f'order_book_id == "{company_id}"', columns=financial_columns)  # 读取单家公司
    company_frames.append(company_frame)  # 保存当前公司年度财报
financial_panel = pd.concat(company_frames, ignore_index=True)  # 合并公司样本
financial_panel = financial_panel.query("quarter.str.endswith('q4')", engine='python').copy()  # 仅保留年度财报
financial_panel['info_date'] = pd.to_datetime(financial_panel['info_date'])  # 统一披露日类型
financial_panel['feature_year'] = financial_panel['quarter'].str[:4].astype(int)  # 提取数据版本所标财年,仅用于检查
financial_panel = financial_panel.query('2012 <= feature_year <= 2024').copy()  # 确定题面声明的年度样本范围
financial_panel['decision_date'] = financial_panel['info_date'] + pd.offsets.MonthEnd(0)  # 将预测时点固定在每次披露月末
financial_panel = financial_panel.query('info_date <= decision_date').copy()  # 排除预测时点之后才披露的记录
financial_panel = financial_panel.sort_values(['order_book_id', 'feature_year', 'info_date']).drop_duplicates(['order_book_id', 'feature_year'], keep='last')  # 同一公司财年保留点时可得的最后版本
代码
financial_panel['log_assets'] = np.log(financial_panel['total_assets'])  # 构造规模特征
financial_panel['debt_ratio'] = financial_panel['total_liabilities'] / financial_panel['total_assets']  # 构造资本结构特征
financial_panel['roa'] = financial_panel['net_profit_parent_company'] / financial_panel['total_assets']  # 构造盈利能力特征
financial_panel['next_eps'] = financial_panel.groupby('order_book_id')['basic_earnings_per_share'].shift(-1)  # 对齐下一财年EPS
financial_panel['target_year'] = financial_panel.groupby('order_book_id')['feature_year'].shift(-1)  # 记录移位后目标的真实财年
financial_panel['target_info_date'] = financial_panel.groupby('order_book_id')['info_date'].shift(-1)  # 记录下一份年度目标真正变为可知的日期
model_panel = financial_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=['log_assets', 'debt_ratio', 'roa', 'next_eps', 'target_year', 'target_info_date'])  # 清理无效特征与无后续目标行
model_panel = model_panel.query('target_year == feature_year + 1')  # 排除公司财年断档造成的非相邻标签
model_panel = model_panel.query('target_info_date > decision_date').sort_values(['target_info_date', 'order_book_id'])  # 排除目标已可见的泄漏行并按披露时点排序
assert len(model_panel) > 0, '集成学习的相邻财年 EPS 面板为空'  # 先阻止空表让年度邻接断言真空通过
assert model_panel['target_year'].eq(model_panel['feature_year'] + 1).all(), 'EPS 目标并非严格下一财年'  # 检查 t→t+1 年度邻接
feature_names = ['log_assets', 'debt_ratio', 'roa']  # 固定特征顺序
X = model_panel[feature_names]  # 构建特征矩阵
y = model_panel['next_eps']  # 构建下一财年目标

# 标出公开财务特征预览,供学生检查集成模型实际可见的三列输入。
print('数据集预览 (X):')
# 输出模型特征前五行,确认每列均来自固定财务字段并与目标对齐。
print(X.head())
print(model_panel[['order_book_id', 'feature_year', 'info_date', 'decision_date', 'target_info_date']].head())  # 检查披露日不晚于预测决策日且目标在其后
数据集预览 (X):
     log_assets  debt_ratio       roa
313   23.083127    0.181130  0.201792
116   23.688994    0.172912  0.081461
201   25.195173    0.415288  0.072067
483   26.120867    0.456891  0.045680
568   23.099157    0.548497  0.121043
    order_book_id  feature_year  info_date decision_date target_info_date
313   002415.XSHE          2012 2014-04-22    2014-04-30       2015-03-12
116   600009.XSHG          2012 2014-03-29    2014-03-31       2015-03-12
201   600585.XSHG          2012 2014-03-25    2014-03-31       2015-03-24
483   600019.XSHG          2012 2014-03-29    2014-03-31       2015-03-27
568   600406.XSHG          2012 2014-03-28    2014-03-31       2015-03-28

数据集划分:训练集与测试集

按目标披露日划分训练集与最终测试集,确保测试目标披露不早于训练目标。

代码
test_start_date = pd.Timestamp('2024-01-01')  # 固定最终测试目标披露期
training_mask = model_panel['target_info_date'] < test_start_date  # 只用较早披露目标训练
testing_mask = model_panel['target_info_date'] >= test_start_date  # 将较晚披露目标最终测试
X_train, X_test = X.loc[training_mask], X.loc[testing_mask]  # 保持目标披露时点隔离
y_train, y_test = y.loc[training_mask], y.loc[testing_mask]  # 对齐目标与特征

# 报告“训练集大小: {X_train.shape[0]} 条样本”中的 `X_train`,核对“数据集划分:训练集与测试集”的样本形状。
print(f'训练集大小: {X_train.shape[0]} 条样本')
# 报告“测试集大小: {X_test.shape[0]} 条样本”中的 `X_test`,核对“数据集划分:训练集与测试集”的样本形状。
print(f'测试集大小: {X_test.shape[0]} 条样本')
训练集大小: 62 条样本
测试集大小: 24 条样本

模型一:训练随机森林回归器

我们来构建第一个模型:随机森林。

  • n_estimators=100: 构建一个包含 100 棵决策树的森林。
  • max_features='sqrt': 在每个节点分裂时,随机选择 \(\sqrt{m}\) 个特征。
  • oob_score=True: 开启袋外数据评估,这是一个“免费”的验证过程。
  • random_state=42: 确保模型的可复现性。
代码
# 建立含 100 棵树、平方根特征抽样与 OOB 诊断的随机森林回归器。
rf_model = RandomForestRegressor(
    # 使用 100 棵树平均个体树的预测,降低单树方差。
    n_estimators=100,
    # 固定随机种子,使抽样和特征选择可复算。
    random_state=42,
    # 每次分裂只考察平方根数量的候选特征,增加树之间差异。
    max_features='sqrt',
    # 启用袋外样本评分,获得训练期内部的回归 R² 诊断。
    oob_score=True
# 完成 `rf_model` 参数表,固定树数量、随机特征比例、袋外评分与随机种子。
)

# 用较早年度训练窗估计随机森林各树结构,最终测试年度不参与拟合。
rf_model.fit(X_train, y_train)

# 打印随机森林训练期 OOB R²,作为重采样诊断而非最终测试替代。
print(f'随机森林模型的 OOB 得分 (R²): {rf_model.oob_score_:.4f}')
随机森林模型的 OOB 得分 (R²): 0.6102

评估随机森林模型

  • 预测窗口:用已训练模型预测测试集。
  • 评估指标:均方误差(Mean Squared Error, MSE)。
  • 判读:在同一任务和样本上,MSE 越小表示平均平方预测误差越低。
代码
# 对随后最终测试特征生成随机森林 EPS 预测数组。
y_pred_rf = rf_model.predict(X_test)

# 计算随机森林预测与真实 EPS 的测试 MSE,保持与其他模型相同评分窗。
mse_rf = mean_squared_error(y_test, y_pred_rf)

# 报告“随机森林模型的均方误差 (MSE): {mse_rf:.4f}”中的 `mse_rf`,评价“评估随机森林模型”的模型表现。
print(f'随机森林模型的均方误差 (MSE): {mse_rf:.4f}')
随机森林模型的均方误差 (MSE): 1.1870

模型二:训练 XGBoost 回归器

接下来,我们构建 XGBoost 模型。

  • n_estimators=100: 迭代 100 轮(构建 100 个弱学习器)。
  • learning_rate=0.1: 学习率,控制每次迭代的步长。
  • max_depth=3: 每棵树的最大深度为 3,限制了单个模型的复杂度。
  • random_state=42: 确保可复现性。
代码
# 建立固定树数、学习率与深度的 XGBoost 回归器,作为同窗集成学习比较模型。
xgb_model = XGBRegressor(
    # 依次拟合 100 棵提升树。
    n_estimators=100,
    # 将每棵新树的贡献缩减为 0.1,控制逐步更新幅度。
    learning_rate=0.1,
    # 把单棵树深度限制为 3,约束交互复杂度。
    max_depth=3,
    # 固定 XGBoost 的随机过程以便复算。
    random_state=42,
    objective='reg:squarederror' # 指定目标函数
# 完成 `xgb_model` 参数表,固定提升轮数、学习率、树深与平方误差目标。
)

# 用同一较早年度训练窗估计 XGBoost 树序列,隔离算法差异。
_ = xgb_model.fit(X_train, y_train)  # 抑制无表头 estimator repr,后页用具名指标报告证据

评估 XGBoost 模型

同样,我们用训练好的 XGBoost 模型对测试集进行预测,并计算 MSE。

代码
# 对同一最终测试特征生成 XGBoost EPS 预测数组。
y_pred_xgb = xgb_model.predict(X_test)

# 计算 XGBoost 的最终测试 MSE,与随机森林按同一目标单位比较。
mse_xgb = mean_squared_error(y_test, y_pred_xgb)
baseline_predictions = np.repeat(y_train.mean(), len(y_test))  # 只用训练期信息构造朴素基线
baseline_mse = mean_squared_error(y_test, baseline_predictions)  # 在同一最终测试期评价基线

# 打印 XGBoost 在最终测试集上的 MSE,与同窗随机森林和基线按相同单位比较。
print(f'XGBoost 模型的均方误差 (MSE): {mse_xgb:.4f}')
print(f'训练期均值基准 MSE: {baseline_mse:.4f}')  # 检查复杂模型是否提供增量预测价值
XGBoost 模型的均方误差 (MSE): 1.2691
训练期均值基准 MSE: 2.0945

结果分析与对比

让我们将两个模型的性能并排比较。

代码
comparison_table = pd.DataFrame({  # 汇总同一最终测试期的可比指标
    '模型': ['随机森林', 'XGBoost', '训练期均值基准'],  # 明确模型与朴素基线
    '测试集均方误差 (MSE)': [mse_rf, mse_xgb, baseline_mse]  # 使用已计算的逐观测 MSE
# 完成 `comparison_table` 的两列定义,使三个模型在同一测试 MSE 口径下比较。
})
display(comparison_table.style.format({'测试集均方误差 (MSE)': '{:.4f}'}))  # 渲染动态结果表
表 1: 集成模型与训练期均值基准的最终测试 MSE
  模型 测试集均方误差 (MSE)
0 随机森林 1.1870
1 XGBoost 1.2691
2 训练期均值基准 2.0945

结论:

  • 数值只描述这 8 家公司和当前时间切分;应根据表中模型是否低于训练期均值基准,判断是否存在增量预测价值。
  • 即使某个集成模型胜出,小样本结果也不能证明其相对所有简单模型或全 A 股普遍占优。

结果可视化:预测值 vs. 真实值

一个好的模型,其预测值应该紧密地分布在真实值周围。我们可以通过散点图来直观地评估这一点。图中的红线是 \(y=x\) 的完美预测线。

代码
# 为“结果可视化:预测值 vs. 真实值”,创建本图所需画布与坐标轴,并保存到 `(fig, (ax1, ax2))`。
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5), sharey=True)

# 随机森林
ax1.scatter(y_test, y_pred_rf, alpha=0.7, edgecolors='k', c='#3498db')
# 以 `[y_test.min(), y_test.max()]` 为横轴、`[y_test.min(), y_test.max()]` 为纵轴绘制曲线,展示“集成学习模型预测值与真实值的对比”。
ax1.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--', lw=2)
# 将横轴标为“真实 EPS”,明确横向编码的变量。
ax1.set_xlabel('真实 EPS')
# 将纵轴标为“预测 EPS”,明确纵向编码的变量。
ax1.set_ylabel('预测 EPS')
# 将图题设为“随机森林 (MSE: {mse_rf:.4f})”,直接说明当前图形的比较目的。
ax1.set_title(f'随机森林 (MSE: {mse_rf:.4f})')

# 绘制 XGBoost 预测结果
# 以 `y_test` 为横轴、`y_pred_xgb` 为纵轴绘制散点,展示“集成学习模型预测值与真实值的对比”。
ax2.scatter(y_test, y_pred_xgb, alpha=0.7, edgecolors='k', c='#2ecc71')
# 以 `[y_test.min(), y_test.max()]` 为横轴、`[y_test.min(), y_test.max()]` 为纵轴绘制曲线,展示“集成学习模型预测值与真实值的对比”。
ax2.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--', lw=2)
# 将横轴标为“真实 EPS”,明确横向编码的变量。
ax2.set_xlabel('真实 EPS')
# 将图题设为“XGBoost (MSE: {mse_xgb:.4f})”,直接说明当前图形的比较目的。
ax2.set_title(f'XGBoost (MSE: {mse_xgb:.4f})')

# 将图题设为“模型预测性能可视化”,直接说明当前图形的比较目的。
plt.suptitle('模型预测性能可视化', fontsize=16)
# 为“结果可视化:预测值 vs. 真实值”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
# 显示最终测试样本预测值对真实值散点及 45 度线,检查系统高估、低估与离群预测。
plt.show()
集成学习模型预测值与真实值的对比:横轴为真实值、纵轴为模型预测值;点越接近 45 度参考线误差越小,两个模型用分面或颜色比较。
图 1: 集成学习模型预测值与真实值的对比

特征重要性分析

让我们利用内置的特征重要性功能,看看模型认为哪些变量对预测 EPS 最重要。

代码
# 获取特征重要性
importance_rf = rf_model.feature_importances_
# 提取 `importance_xgb` 的特征重要性,比较模型分裂对各财务变量的依赖程度。
importance_xgb = xgb_model.feature_importances_
# 图中使用业务名称;原始字段名仍保留在代码中。
feature_display_names = {'log_assets': '资产规模', 'debt_ratio': '负债率', 'roa': '资产收益率'}
features = pd.Index([feature_display_names.get(name, name) for name in X.columns])

# 为“特征重要性分析”,创建本图所需画布与坐标轴,并保存到 `(fig, (ax1, ax2))`。
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5))

# 随机森林
sns.barplot(x=importance_rf, y=features, ax=ax1, palette='Blues_r')
# 将图题设为“随机森林 特征重要性”,直接说明当前图形的比较目的。
ax1.set_title('随机森林 特征重要性')
# 将横轴标为“重要性得分”,明确横向编码的变量。
ax1.set_xlabel('重要性得分')
ax1.set_ylabel('')

# 绘制 XGBoost 特征重要性
# 以 `importance_xgb` 分组、`features` 为柱高,比较“随机森林与XGBoost的特征重要性对比”。
sns.barplot(x=importance_xgb, y=features, ax=ax2, palette='Greens_r')
# 将图题设为“XGBoost 特征重要性”,直接说明当前图形的比较目的。
ax2.set_title('XGBoost 特征重要性')
# 将横轴标为“重要性得分”,明确横向编码的变量。
ax2.set_xlabel('重要性得分')
ax2.set_ylabel('')

# 为“特征重要性分析”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout()
# 显示随机森林与 XGBoost 的并列特征重要性排序,比较模型依赖差异并提醒其非因果含义。
plt.show()
并列水平条形图比较三个非负特征重要性:随机森林以 log_assets 居首、debt_ratio 与 roa 次之;XGBoost 以 debt_ratio 居首、log_assets 次之、roa 最低。条长只表示各模型内的相对重要性,不表示正负作用或因果效应。
图 2: 随机森林与XGBoost的特征重要性对比

特征重要性解读

随机森林把“资产规模”排在首位,XGBoost 则把“负债率”排在首位。这种模型间排序差异提醒我们:重要性会随模型改变,不能把它写成因果关系。

习题解答与知识深化

知识理解习题 1:随机森林 vs. 袋装法

问题: 请阐述随机森林与袋装法 (Bagging) 的最主要区别。

解答: 两者都基于自助采样 (Bootstrap) 来训练多个决策树。最核心的区别在于特征选择的过程

  • 袋装法 (Bagging): 在构建每棵决策树时,于每个节点进行分裂,算法会考虑所有可用的特征,并从中选择最优的一个。
  • 随机森林 (Random Forest): 在构建每棵决策树时,于每个节点进行分裂,算法会先从所有特征中随机抽取一个子集,然后只在这个子集中选择最优的特征。

这个额外的随机性步骤(特征随机化)旨在降低树与树之间的相关性;若单树偏差上升过多,森林的样本外误差仍可能变差,因此必须验证。

知识理解习题 2:随机森林 vs. 提升法

问题: 请阐述随机森林与提升法 (Boosting) 的主要区别。

解答: 它们在根本的构建哲学上有所不同:

方面 随机森林 (Bagging 家族) 提升法 (Boosting 家族)
构建方式 各树可并行训练,但共享原样本且拟合结果通常统计相关 串行/顺序:新树依赖此前加法模型的负梯度
模型关系 随机化不等于统计独立 后续基学习器与此前模型顺序依赖
主要作用 平均常影响方差,但不是“过拟合修复按钮” 逐步加法常影响训练偏差,但不是“欠拟合修复按钮”
基学习器 树复杂度是开发期调参对象,不保证完全生长 树复杂度同样需调参,不保证必须是树桩

知识理解习题 3:max_features 如何改变偏差—方差

问题: 请阐述改变随机森林每次选取的特征个数 \(k\) (max_features) 如何影响树间相关性、单树偏差与样本外误差。

解答: 特征个数 \(k\) (max_features) 是控制随机森林模型复杂度和随机性的关键超参数。

  • 减小 \(k\) 的值:
    • 增加随机性: 每次分裂时,可供选择的特征更少。
    • 降低相关性: 迫使模型探索不同特征,使得森林中的树更加多样化,树之间的相关性降低。
    • 条件性效果: 更低的相关性可能增强平均后的降方差效果,但也可能提高单树偏差;最终用同一开发窗比较结果选择。
  • 极端情况:
    • 如果 \(k\) 太小(例如 \(k=1\)),每棵树的性能可能会很差。
    • 如果 \(k\)太大(例如 \(k=m\)),随机森林退化成树 Bagging;是否恶化样本外误差仍由验证决定。

知识理解习题 4:学习率—轮数—树深如何共同控制复杂度

问题: 请阐述学习速率 \(\lambda\)、轮数与树深如何共同改变 Boosting 的拟合路径。

解答:

  1. 学习速率 \(\lambda\) (learning_rate):
    • 作用: 控制每次迭代中新加入模型的贡献权重。
    • 条件性效果: 更小学习率通常需要更多轮;它不单独保证泛化,应配合预设早停并比较开发窗比较结果。
  2. 树的深度 (max_depth):
    • 作用: 控制每个基学习器(决策树)的复杂度。
    • 条件性效果: 浅树限制单步交互复杂度,但可能欠拟合;树深、学习率与轮数必须作为联合候选在同一开发窗选择。

程序操作习题 1:手动实现自助法

问题: 请用表中的数据根据自助法,生成三个随机样本。

x1 x2 y
1.76 1.87 -0.10
0.40 -0.98 0.41
0.98 0.95 0.14
2.24 -0.15 1.45

请先独立写出三组索引(允许重复),并说明为什么这些重抽样不能称为与原样本真正独立。

程序操作习题 2:调优随机森林

问题: 使用随机森林预测。只在训练窗口内调整树的数量 n_estimatorsmax_features,不得查看最终测试集。

作业要求:事先确定候选网格、扩展窗口与 MSE,提交每个候选的折均值并说明最终测试期未参与选择。

程序操作习题 3:调优 XGBoost

问题: 使用 XGBoost 预测。在训练窗口内比较学习率与树深组合,最终测试期只允许最终模型使用一次。

作业要求:联合比较 learning_ratemax_depth,提交扩展窗 MSE 排序、选择规则和最终测试期一次性使用约束。

独立任务提交点

先提交三个程序任务的索引、参数网格、验证设计与预期输出字段,再进入集中反馈;任务区不展示答案。

集中反馈:三个程序任务

  1. 自助法:验证有放回索引与重复检查。
  2. 随机森林:核对事先确定网格、扩展窗口 MSE 与最终测试期隔离。
  3. XGBoost:核对同样的候选、验证与隔离证据。

任一答案使用测试期选参,均返回相应练习重做。

反馈 1:三个固定 seed 的有放回样本

代码
data = {'x1': [1.76, 0.40, 0.98, 2.24], 'x2': [1.87, -0.98, 0.95, -0.15], 'y': [-0.10, 0.41, 0.14, 1.45]}  # 固定四条母样本供手工核对
original_df = pd.DataFrame(data)  # 建立两特征一响应的母样本表
bootstrap_audit_rows = []  # 收集三组索引与重复检查
for random_seed in range(3):  # 遍历三个固定种子
    bootstrap_sample = original_df.sample(n=len(original_df), replace=True, random_state=random_seed)  # 生成等长有放回样本
    bootstrap_audit_rows.append({'seed': random_seed, '抽样索引': ', '.join(map(str, bootstrap_sample.index.tolist())), '唯一观测数': bootstrap_sample.index.nunique(), '重复数': len(bootstrap_sample) - bootstrap_sample.index.nunique()})  # 保存可复算答案
display(pd.DataFrame(bootstrap_audit_rows))  # 展示三组紧凑检查行
seed 抽样索引 唯一观测数 重复数
0 0 0, 3, 1, 0 3 1
1 1 1, 3, 0, 0 3 1
2 2 0, 3, 1, 0 3 1

反馈 2:随机森林扩展窗调参

对时间有序样本使用扩展窗口交叉验证;四行自助样本只解释 Bootstrap,不能承担调参证据。

代码
from sklearn.model_selection import TimeSeriesSplit, cross_val_score  # 建立只向未来验证的扩展窗口
rf_parameter_grid = [(10, 'sqrt'), (50, 'sqrt'), (50, 2)]  # 控制课堂计算量的候选组合
time_series_cv = TimeSeriesSplit(n_splits=3)  # 每折训练期均早于验证期
rf_validation_results = []  # 保存训练期内部的验证证据
for tree_count, feature_count in rf_parameter_grid:  # 遍历预先声明的候选参数
    candidate_model = RandomForestRegressor(n_estimators=tree_count, max_features=feature_count, random_state=42)  # 固定随机状态
    fold_mse = -cross_val_score(candidate_model, X_train, y_train, cv=time_series_cv, scoring='neg_mean_squared_error')  # 只用训练期验证
    rf_validation_results.append({'n_estimators': tree_count, 'max_features': feature_count, '验证MSE': fold_mse.mean()})  # 汇总折均值
print(pd.DataFrame(rf_validation_results).sort_values('验证MSE').to_string(index=False))  # 选择验证误差最低组合
 n_estimators max_features    验证MSE
           50            2 1.153303
           10         sqrt 1.351529
           50         sqrt 1.616228

反馈 3:XGBoost 联合调参

代码
xgb_parameter_grid = [(0.01, 1), (0.1, 1), (0.1, 3), (0.5, 3)]  # 预先声明低成本候选组合
xgb_validation_results = []  # 保存训练期扩展窗口验证证据
for learning_rate, maximum_depth in xgb_parameter_grid:  # 遍历学习率与树深组合
    candidate_model = XGBRegressor(n_estimators=100, learning_rate=learning_rate, max_depth=maximum_depth, random_state=42, objective='reg:squarederror')  # 建立候选提升模型
    fold_mse = -cross_val_score(candidate_model, X_train, y_train, cv=time_series_cv, scoring='neg_mean_squared_error')  # 禁止使用最终测试期调参
    xgb_validation_results.append({'learning_rate': learning_rate, 'max_depth': maximum_depth, '验证MSE': fold_mse.mean()})  # 汇总验证误差
print(pd.DataFrame(xgb_validation_results).sort_values('验证MSE').to_string(index=False))  # 输出可自查答案
 learning_rate  max_depth    验证MSE
          0.50          3 0.686146
          0.10          3 0.783667
          0.10          1 1.629955
          0.01          1 2.097535

本章总结:集成学习的智慧

  • 集成学习是一种元算法:它是一种组合其他模型的高级策略,而非一个具体的模型。
  • 两大流派,不是一一对应的修复按钮:Bagging 常通过平均低相关基学习器降低方差;Boosting 的偏差与方差变化必须在同一开发窗验证。
  • 性能与解释性的权衡: 集成模型是强候选基线;是否优于单模型须在确定数据、指标、时间窗和运行时间和内存下复现,额外复杂度也会增加解释与检查成本。
  • 实践出真知: 集成学习的效果高度依赖于超参数调优。在金融实践中,对 n_estimators, max_features, learning_rate, max_depth 等参数进行仔细的交叉验证是取得成功的关键。

本章小结

  • 能先确定 bootstrap 重抽样单位,并区分普通残差与一般损失的负梯度。
  • Bagging 常通过平均低相关学习器降方差;Boosting 的偏差—方差结果由开发窗验证与早停决定。
  • 训练误差下降不能单独诊断过拟合,测试期只开一次。
  • 下一章进入神经网络,把逐步优化扩展到多层非线性表示。