本章会用到的数据
公开下载:上市公司财务报表。代码会在首次运行时下载并保存到 data/course/。
这些数据能做什么:比较随机森林、XGBoost 与简单基准对公司 EPS 的预测。
分析时注意:只使用预测日期前已经披露的财务信息,并按时间先后划分样本。
判断模型:参数只在训练和验证阶段选择,最终测试期只用于报告结果。
【课堂核心】2.5 学时学习安排
| 动机与先修 |
15 |
| Bagging 与依赖结构 |
30 |
| Boosting 与伪残差 |
35 |
| 形成性检查与反馈 |
15 |
| 公开案例输入 |
15 |
| 独立练习 |
25 |
| 集中反馈与小结 |
15 |
第9章 集成学习
从群体智慧到量化决策
本章学习路线图
- 核心困境: 为何我们需要集成学习?
- 两大思想: Bagging 与 Boosting 的哲学。
- 基石技术: 自助法 (Bootstrap) 如何创造数据?
- Bagging 家族: 从袋装法到随机森林。
- Boosting 家族: 从梯度提升到 XGBoost。
- 实战演练: 使用 Python 预测公司 EPS。
- 总结与习题: 深化理解,巩固知识。
核心困境:简单 vs. 复杂
在金融预测中,我们常常面临两难。
本章核心问题:为何“三个臭皮匠”常常胜过“一个诸葛亮”?
集成学习 (Ensemble Learning) 提供了一个优雅的解决方案。
核心思想:将多个相对简单的模型(“臭皮匠”)结合起来,以期获得超越最优秀的单个复杂模型(“诸葛亮”)的预测能力。
我们的学习目标:掌握集成学习的“组合拳”
在本章结束时,你将能够:
- 理解核心思想: 比较 Bagging 与 Boosting 在何种条件下改变偏差—方差,并用开发窗验证样本外效果。
- 掌握核心技术: 解释自助法 (Bootstrap)、袋装法 (Bagging)、随机森林 (Random Forest) 和提升法 (Boosting) 的工作原理。
- 理解训练过程: 描绘出这些复杂算法的内部训练流程。
- 付诸实践: 使用 Python 和
scikit-learn 构建、训练并评估集成学习模型。
集成学习的两大流派
集成学习并非单一算法,而是一种战略思想。主要分为两大流派:
- Bagging 家族 (并行): 平均低相关基学习器通常可降低方差;能否改善样本外误差仍须验证。
- 代表:袋装法 (Bagging), 随机森林 (Random Forest)
- Boosting 家族 (串行): 逐步加法模型常可降低训练偏差,但偏差—方差与样本外表现取决于损失、树深、学习率、轮数与早停。
- 代表:AdaBoost, 梯度提升 (Gradient Boosting), XGBoost
焦点:为何偏爱决策树?
集成学习可以使用多种基础模型;决策树是 Bagging 与 Boosting 的常见基学习器,也是本章为讲清机制而选用的焦点。
- 复杂树的条件性风险:未受约束的决策树在部分样本上可能呈现低训练偏差和高抽样方差,但样本外表现取决于数据生成过程与复杂度选择。
- 平均为何可能有效:对误差相关性较低的树取平均可降低方差;幅度取决于树间相关性、采样方案和基学习器,样本外收益仍须在同一开发窗验证。
第一部分:Bagging 与随机森林
核心思想:民主投票,人多力量大
核心基石 (1): 自助法 (Bootstrap)
问题:为了估计抽样波动或训练多样化模型,我们希望构造保持正确抽样结构的重抽样数据集。但现实中通常只有一个样本。怎么办?
自助法 (Bootstrap):在 iid 经验分布下有放回重抽样,产生条件重抽样样本。这些样本共享同一个原始数据,彼此及与原样本都不是真正独立。
自助法 (Bootstrap) 的可视化流程
假设我们的原始数据集是 [A, B, C, D, E],大小 \(N=5\)。
课堂讨论:自助法为何有效?
普通逐行 iid bootstrap 需要:观测近似 iid/可交换;重抽样单位与真实依赖结构一致;目标统计量对经验分布足够规则。仅说“样本代表总体”不够。
金融数据的重抽样单位
- 日收益序列:moving/stationary block bootstrap。
- 公司内多期相关面板:按公司 cluster bootstrap。
- 时间与公司双重依赖:使用相应双向或块方案,不能逐行 iid 抽样。
这使得我们能够:
- 模拟数据集的多样性:为训练多个模型提供不同的“视角”。
- 估计统计量的不确定性:例如,通过在多个自助样本上计算均值,我们可以得到均值的置信区间。这是现代统计学中的一个革命性思想。
核心技术 (1): 袋装法 (Bagging) = 自助法 + 模型聚合
Bagging,全称 Bootstrap Aggregating,其逻辑简单而强大:
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 数据进行误差估计
对每个观测而言,未包含它的树可提供一组内部验证预测;但这些预测仍来自同一训练样本和同一调参过程。
- 对于原始数据集中的每一个数据点 \(x_i\):
- 找出所有不包含 \(x_i\) 的自助样本(约 \(B/3\) 个)。
- 让在这些样本上训练出的模型对 \(x_i\) 进行预测,然后聚合结果。
- 计算 \(x_i\) 的聚合预测值与真实值 \(y_i\) 之间的误差。
- 对所有数据点的误差取平均,就得到了OOB 误差。
OOB 的用途与边界
- 用途:低成本的内部泛化诊断。
- 可能偏差:有限样本、时间依赖、类别不平衡,或使用 OOB 结果调参。
- 不能替代:时间有序验证或最终测试集。
Bagging 的一个潜在问题
Bagging 存在一个问题:如果数据集中存在一两个非常强的预测特征,那么在每个自助样本上构建的决策树,其顶层分裂节点很可能都会选择这几个强特征。
这会导致:
- 所有基模型(决策树)的结构变得非常相似。
- 模型之间的相关性 (Correlation) 增加。
- 取平均所带来的降方差效果被削弱。
核心技术 (2): 随机森林 = Bagging + 特征随机化
随机森林 (Random Forest) 的解决方案:在 Bagging 的基础上,引入了特征随机化,以解决树之间相关性过高的问题。
随机森林 vs. Bagging:一个直观对比
| 样本层面 |
使用自助法生成多样化的训练样本 |
相同 |
| 特征层面 |
在每个节点分裂时,考虑所有 \(m\) 个特征 |
在每个节点分裂时,只考虑随机抽取的 \(k\) 个特征 |
| 结果 |
平均效果取决于单树方差与树间相关性 |
特征随机化旨在降低相关性,也可能提高单树偏差 |
| 性能 |
没有无条件排序 |
在同一切分、指标与运行时间和内存下比较 |
如果设置 \(k=m\),那么随机森林就退化成了 Bagging。
随机森林的训练步骤总结
对于 \(i = 1, \dots, B\) 次循环:
样本抽样: 从原始数据集 \(D\) 中,通过自助法抽取一个自助数据集 \(D_i\)。
构建决策树: 使用 \(D_i\) 构建一棵决策树 \(T_i\)。
- 在树的每一个节点,从全部 \(m\) 个特征中随机选择 \(k\) 个特征。
- 基于这 \(k\) 个特征,确定最佳分割点。
- 递归地重复,直到满足停止条件。
集成预测: 将 \(B\) 棵树的预测结果通过平均或投票进行组合。
第二部分:Boosting
核心思想:专家会诊,不断进步
核心技术 (3): 提升法 (Boosting) 的不同哲学
与 Bagging 家族并行训练、民主投票的思想完全不同,Boosting 采用的是一种串行、迭代、专注错误的哲学。
Boosting 的偏差—方差效果必须由验证决定
- 训练效果:逐步加法常可降低训练偏差。
- 泛化条件:共同取决于基学习器、损失、学习率、树深、轮数、正则化和早停。
- 可能结果:Boosting 既可能欠拟合,也可能过拟合。
- 选择证据:只用预先声明的开发期验证比较结果选择复杂度。
梯度提升 (Gradient Boosting) 的执行步骤 (1/4)
以下四步只描述平方损失 \(L(y,F)=\tfrac12(y-F)^2\) 的特例;此时负梯度恰好等于普通残差。
- 初始化:
- 设定一个初始的预测,通常是目标变量的均值 \(f_0(x) = \bar{y}\)。
- 计算初始残差 \(r_i = y_i - f_0(x_i)\)。
梯度提升 (Gradient Boosting) 的执行步骤 (2/4)
- 循环迭代 (for \(b = 1, \dots, B\)):
- 拟合残差: 使用特征 \(x\) 与当前的残差 \(r\) 来训练一个新的决策树模型 \(h_b(x)\)。
- 这棵树的目标不是预测 \(y\),而是预测上一步的错误。
梯度提升 (Gradient Boosting) 的执行步骤 (3/4)
- 循环迭代 (续):
- 更新预测: 将新模型的预测结果以一个较小的步长 \(\lambda\) (学习率) 加到总预测上:
\[ \large{f_b(x) := f_{b-1}(x) + \lambda h_b(x)} \]
- 更新残差: 计算新的残差:
\[ \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)
最终模型:
经过 \(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 伪残差不等于“真实类别减预测类别标签”。
形成性检查:先判断损失与重抽样单位
独立作答
- 时间序列收益能否逐日 iid bootstrap?
- logistic boosting 是否直接拟合 \(y-\hat y_{class}\)?
- 训练误差持续下降能否证明没有过拟合?
形成性检查反馈:三项都不能直接成立
- 重抽样:时间序列应保留块依赖;答错回看 自助法边界。
- 拟合对象:logistic loss 拟合负梯度,而非类别残差;答错回看 一般损失。
- 过拟合判定:需训练—验证比较或时间外窗口,并事先设定早停。
提升法的关键参数:学习率 (\(\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 逐步加法常降低训练偏差,但两者的样本外误差均取决于基学习器、损失、采样、学习率、深度、轮数和早停,须在同一开发窗验证。
- 缺点:
- 可解释性差: 最大的代价是模型变成了“黑箱”。我们很难清晰地解释出某个特定预测是如何以及为什么得出的。
- 计算成本高: 需要训练成百上千个模型,计算开销远大于单个模型。
从“白箱”到“黑箱”的可视化对比
偷窥“黑箱”:特征重要性 (Feature Importance)
虽然我们无法解释单个预测,但集成模型提供了一个强大的工具来理解模型的全局行为:特征重要性。
基本思想:一个特征的重要性,取决于它在所有树的构建过程中,对降低模型不纯度(如基尼不纯度)或误差(如均方误差)的平均贡献有多大。
如果一个特征频繁地被选作分裂节点,并且每次分裂都能显著提升模型的纯度/精度,那么它的重要性得分就高。这让我们能够知道模型主要依赖哪些特征来进行预测。
实战演练:使用集成学习预测公司每股收益 (EPS)
核心任务:预测公司的未来盈利能力
我们将对比随机森林和 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}'})) # 渲染动态结果表
结论:
- 数值只描述这 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()
特征重要性分析
让我们利用内置的特征重要性功能,看看模型认为哪些变量对预测 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()
特征重要性解读
随机森林把“资产规模”排在首位,XGBoost 则把“负债率”排在首位。这种模型间排序差异提醒我们:重要性会随模型改变,不能把它写成因果关系。
知识理解习题 1:随机森林 vs. 袋装法
问题: 请阐述随机森林与袋装法 (Bagging) 的最主要区别。
解答: 两者都基于自助采样 (Bootstrap) 来训练多个决策树。最核心的区别在于特征选择的过程:
- 袋装法 (Bagging): 在构建每棵决策树时,于每个节点进行分裂,算法会考虑所有可用的特征,并从中选择最优的一个。
- 随机森林 (Random Forest): 在构建每棵决策树时,于每个节点进行分裂,算法会先从所有特征中随机抽取一个子集,然后只在这个子集中选择最优的特征。
这个额外的随机性步骤(特征随机化)旨在降低树与树之间的相关性;若单树偏差上升过多,森林的样本外误差仍可能变差,因此必须验证。
知识理解习题 2:随机森林 vs. 提升法
问题: 请阐述随机森林与提升法 (Boosting) 的主要区别。
解答: 它们在根本的构建哲学上有所不同:
| 构建方式 |
各树可并行训练,但共享原样本且拟合结果通常统计相关 |
串行/顺序:新树依赖此前加法模型的负梯度 |
| 模型关系 |
随机化不等于统计独立 |
后续基学习器与此前模型顺序依赖 |
| 主要作用 |
平均常影响方差,但不是“过拟合修复按钮” |
逐步加法常影响训练偏差,但不是“欠拟合修复按钮” |
| 基学习器 |
树复杂度是开发期调参对象,不保证完全生长 |
树复杂度同样需调参,不保证必须是树桩 |
知识理解习题 3:max_features 如何改变偏差—方差
问题: 请阐述改变随机森林每次选取的特征个数 \(k\) (max_features) 如何影响树间相关性、单树偏差与样本外误差。
解答: 特征个数 \(k\) (max_features) 是控制随机森林模型复杂度和随机性的关键超参数。
- 减小 \(k\) 的值:
- 增加随机性: 每次分裂时,可供选择的特征更少。
- 降低相关性: 迫使模型探索不同特征,使得森林中的树更加多样化,树之间的相关性降低。
- 条件性效果: 更低的相关性可能增强平均后的降方差效果,但也可能提高单树偏差;最终用同一开发窗比较结果选择。
- 极端情况:
- 如果 \(k\) 太小(例如 \(k=1\)),每棵树的性能可能会很差。
- 如果 \(k\)太大(例如 \(k=m\)),随机森林退化成树 Bagging;是否恶化样本外误差仍由验证决定。
知识理解习题 4:学习率—轮数—树深如何共同控制复杂度
问题: 请阐述学习速率 \(\lambda\)、轮数与树深如何共同改变 Boosting 的拟合路径。
解答:
- 学习速率 \(\lambda\) (
learning_rate):
- 作用: 控制每次迭代中新加入模型的贡献权重。
- 条件性效果: 更小学习率通常需要更多轮;它不单独保证泛化,应配合预设早停并比较开发窗比较结果。
- 树的深度 (
max_depth):
- 作用: 控制每个基学习器(决策树)的复杂度。
- 条件性效果: 浅树限制单步交互复杂度,但可能欠拟合;树深、学习率与轮数必须作为联合候选在同一开发窗选择。
程序操作习题 1:手动实现自助法
问题: 请用表中的数据根据自助法,生成三个随机样本。
| 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_estimators 与 max_features,不得查看最终测试集。
作业要求:事先确定候选网格、扩展窗口与 MSE,提交每个候选的折均值并说明最终测试期未参与选择。
程序操作习题 3:调优 XGBoost
问题: 使用 XGBoost 预测。在训练窗口内比较学习率与树深组合,最终测试期只允许最终模型使用一次。
作业要求:联合比较 learning_rate 与 max_depth,提交扩展窗 MSE 排序、选择规则和最终测试期一次性使用约束。
独立任务提交点
先提交三个程序任务的索引、参数网格、验证设计与预期输出字段,再进入集中反馈;任务区不展示答案。
集中反馈:三个程序任务
- 自助法:验证有放回索引与重复检查。
- 随机森林:核对事先确定网格、扩展窗口 MSE 与最终测试期隔离。
- 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)) # 展示三组紧凑检查行
| 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 的偏差—方差结果由开发窗验证与早停决定。
- 训练误差下降不能单独诊断过拟合,测试期只开一次。
- 下一章进入神经网络,把逐步优化扩展到多层非线性表示。