# ============================================================
# 第一步:导入必需的Python工具库
# ============================================================
import numpy as np # 科学计算库,提供高效的数组运算(类似Excel中的批量公式计算)
import pandas as pd # 数据分析库,提供类似Excel电子表格的DataFrame数据结构
import os # 读取必需的教材数据根目录环境变量
from pathlib import Path # 用跨平台路径对象定位数据文件
import matplotlib.pyplot as plt # 绘图库,用于生成统计图表(类似Excel的图表功能,但更强大)
plt.rcParams['font.family'] = ['Source Han Serif SC'] # 统一使用出版字体思源宋体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方块的问题
from sklearn.preprocessing import PolynomialFeatures # 将原始特征x扩展为多项式 [1, x, x², ..., x^d]
from sklearn.linear_model import LinearRegression # 最基本的线性回归模型(最小二乘法拟合)
from sklearn.metrics import mean_squared_error # 计算均方误差(MSE),衡量预测精度
from sklearn.model_selection import train_test_split # 将数据随机分为训练集和验证集
np.random.seed(42) # 设置随机种子为42,确保每次运行结果完全一致(科学研究的可复现性要求)5 重采样方法 (Resampling Methods)
5.1 本章学习契约
先修:掌握第 2—4 章的损失、基线、管道和标签时间语义;能读懂日期索引切片。
学习目标与达成标准
- 区分模型选择与锁定流程的最终评估;标准是测试集只访问一次。
- 解释验证集、LOOCV 和 \(k\) 折在 IID 任务中的取舍;标准是不把普通随机折外推到时序。
- 为时序画出扩张窗口前向验证;标准是每折训练日期严格早于验证日期。
- 按标签终点 purge 切分边界;标准是训练标签最大日期小于测试原点最小日期。
- 区分 IID、分组/面板和时序 bootstrap;标准是为依赖结构选对重采样单位并限定解释。
5.1.1 入口检查与补修
入口题:以 \(t+1\) 日收益为标签,测试原点首日为 \(T\)。训练行应满足什么日期条件?
作答后展开答案、门槛与补修
答案与门槛:对每个训练原点显式保存 label_date,并要求 label_date < T。同时说出“原点早于 \(T\)”本身不足够才通过;否则回看第 4 章标签时点并重画三行时间线。
5.1.2 低风险检索、渐隐链与迁移
检索:闭卷画两折扩张窗口,标出 train、gap 和 validation;再说明为何公司面板不能当作普通 IID 行随机分折。
渐隐链:先检查已给折日期;再从标签跨度自行计算 gap/purge;最后为陌生的季度违约面板选择公司分组、时间前向或双重约束设计。
5.1.3 目标—评价证据映射
| 目标 | 学习活动 | 评价证据 |
|---|---|---|
| 1—2 | IID 方法比较与嵌套验证 | 练习 1、3、5、8—9 |
| 3—4 | 时间折渐隐链和 purge 断言 | 练习 2、6、小节 5.7 |
| 5 | 重采样单位选择与陌生迁移 | 练习 4、7、10 |
5.2 引言 (Introduction)
重采样方法(resampling methods)是现代统计学中不可或缺的工具。它们涉及从训练集中重复抽取样本并对每个样本重新拟合感兴趣的模型,从而获得关于拟合模型的额外信息。例如,为了估计线性回归拟合的变异性,我们可以从训练数据中反复抽取不同的样本,对每个新样本拟合线性回归,然后检查结果拟合的差异。这种方法使我们能够获得仅使用原始训练样本拟合模型一次所无法获得的信息。
重采样方法的计算成本可能很高,因为它们涉及使用训练数据的不同子集多次拟合相同的统计学习方法。然而,由于近年来计算能力的进步,重采样方法的计算需求通常不会成为限制因素。在本章中,我们将讨论两种最常用的重采样方法:交叉验证(cross-validation)和自助法(bootstrap)。这两种方法都是许多统计学习过程实际应用中的重要工具。
Note on 重采样方法的理论起源
重采样方法的理论基础可以追溯到20世纪70年代末和80年代初的统计学研究。自助法(Bootstrap)由Bradley Efron在1979年提出,这是统计学史上最具影响力的方法之一。交叉验证的概念则更早,可以追溯到20世纪中叶的模型选择研究。
这些方法的重要性在于它们提供了一种计算密集型但理论简单的方式来评估统计模型的性能,而不依赖于强分布假设。这使得它们在实际应用中极具价值,尤其是在传统统计推断方法可能失效的情况下。
模型评估与模型选择:
在统计学习中,我们通常关注两个相关但不同的任务:
- 模型评估(Model Assessment):评估一个给定统计学习方法在未见过的数据上的预期表现
- 模型选择(Model Selection):为模型选择适当的灵活度水平
交叉验证可以用来估计给定统计学习方法的测试误差,以评估其性能或选择适当的灵活度水平。而自助法最常用于提供参数估计或给定统计学习方法的准确性度量。
案例背景: A股个股收益率预测
在本章中,我们将使用中国A股市场的真实交易数据来演示各种重采样方法。我们将重点关注海康威视 (002415.XSHE),这是中国智能安防领域的行业龙头企业。
该数据集包含以下变量:
- 收益率(Return):当天的股票收益率(%)
- Lag1:前一天的收益率(%)
- Lag2:前两天的收益率(%)
- Volume:成交量
- Volatility:波动率指标
我们的目标是建立预测模型,试图根据过去的表现预测未来的收益率,并使用重采样方法评估模型的预测准确性。虽然股票市场预测非常困难(根据有效市场假说),但它是演示模型评估技术的绝佳场景。
5.3 交叉验证 (Cross-Validation)
在 章节 2 中,我们讨论了测试误差率(test error rate)和训练误差率(training error rate)之间的区别。测试误差是使用统计学习方法预测新观测(即未用于训练该方法的测量)响应时的平均误差。如果有一个指定的测试集,测试误差很容易计算。然而,通常情况并非如此。
相比之下,训练误差可以通过将统计学习方法用于训练中的观测来轻松计算。但正如我们在 章节 2 中所见,训练误差率通常与测试误差率相当不同,特别是前者可能严重低估后者。
在没有一个非常大的指定测试集可以直接估计测试误差率的情况下,可以使用几种技术利用可用的训练数据来估计这个量。一些方法通过对训练误差率进行数学调整来估计测试误差率。这些方法在 章节 6 中讨论。在本节中,我们考虑一类通过从拟合过程中保留训练观测的子集,然后将统计学习方法应用于那些保留的观测来估计测试误差率的方法。
在 小节 5.3.1 至 小节 5.3.4 中,为了简单起见,我们假设对定量响应进行回归。在 小节 5.3.5 中,我们考虑定性响应的分类情况。正如我们将看到的,无论响应是定量还是定性的,关键概念保持不变。
5.3.1 验证集方法 (The Validation Set Approach)
假设我们想要估计在一组观测上拟合特定统计学习方法的测试误差。验证集方法(validation set approach)是 图 5.1 的一个非常简单的策略。它涉及随机将可用观测集分为两部分:训练集(training set)和验证集(validation set)或保留集(hold-out set)。模型在训练集上拟合,并使用拟合的模型预测验证集中观测的响应。由此产生的验证集误差率——在定量响应的情况下通常使用MSE(均方误差)评估——提供了测试误差率的估计。
案例应用:使用多项式回归拟合海康威视股价收益率
让我们使用海康威视的真实历史数据来说明验证集方法。我们想要研究当天的收益率与前一天收益率之间是否存在非线性关系。
为了用真实市场真金白银的波动验证这个重采样评估过程,以下 Python 代码从本地直接抽取了 A 股长三角地区智能安防龙头企业“海康威视(002415.SZ)”过去 500 个交易日的日度真实收益率时间序列。在此任务中,我们试图用昨天的收益率(Lag1)去拟合并探测今天收益率(Return)可能存在的哪怕是极其微弱的非线性二次或高次多项式组合效应。为了评估这套机制,代码直接采用了最刚性的“验证集方法”:利用 train_test_split 直白地将这 500 天数据随机一分为二。在图表的左半部分,你看到的是“单次”切割后,模型随着多项式复杂阶数增加(1阶到5阶)在那个特定验证子集上计算出的 MSE 评估曲线;而在图表的右半图,代码写了一个 for 循环,并将这种随机对半切割验证整整重复了 10 遍!看着右图满天飞舞、毫无共识的 10 条五颜六色的评估折线,你应该能立刻顿悟验证集方法的阿喀琉斯之踵——它的最终评估结果对“如何划分这刀切在哪”实在太敏感、变异震荡性也太剧烈了。
接下来,我们从本地磁盘读取海康威视的真实股票交易数据,并计算日收益率和滞后收益率,为后续的回归建模做准备。
# ============================================================
# 第二步:从本地磁盘读取海康威视的真实股票交易数据
# ============================================================
# 根据操作系统自动选择正确的数据路径(支持Windows和Linux两个平台)
# 根据操作系统设置数据根目录路径
BOOK_DATA_DIR = Path(os.environ['BOOK_DATA_DIR']).expanduser().resolve() # 从必需环境变量解析数据根目录
assert BOOK_DATA_DIR.is_dir(), f'BOOK_DATA_DIR 不存在: {BOOK_DATA_DIR}' # 在读取前验证目录
# 拼接完整的数据文件路径(HDF5格式,一种高效的二进制存储格式)
stock_data_path = BOOK_DATA_DIR / 'stock/stock_price_pre_adjusted.h5' # 拼接前复权股价数据路径
# 按股票代码选择性读取海康威视日度行情,避免全量载入全市场数据
stock_data = pd.read_hdf( # 从HDF5文件中按股票代码选择性读取海康威视行情数据
stock_data_path, # 指定前复权行情数据文件路径
where="order_book_id='002415.XSHE'", # 仅保留海康威视的历史交易记录
columns=['date', 'order_book_id', 'close'] # 只读取计算收益率所需的日期、股票代码和收盘价字段
).reset_index() # 将MultiIndex还原为普通列,便于后续排序与特征构造
stock_data['date'] = pd.to_datetime(stock_data['date']) # 将日期列转换为datetime类型,确保时间排序正确
stock_data = stock_data.sort_values('date') # 按日期升序排列,确保时间序列的先后顺序正确
# 计算日收益率(百分比形式):pct_change()计算每天收盘价相对前一天的百分比变化
stock_data['Return'] = stock_data['close'].pct_change() * 100 # 计算百分比变化(收益率)
# 创建"滞后收益率"特征:shift(1)将昨天的收益率作为今天的预测变量
stock_data['Lag1'] = stock_data['Return'].shift(1) # 创建滞后一期特征:将昨日收益率作为今日的预测变量
# 去除含缺失值的行(第一天没有"昨天的收益率",Lag1为NaN)
analysis_data = stock_data[['Return', 'Lag1']].dropna() # 删除缺失值
# 只取最近500个交易日的数据用于演示(iloc[-500:]取最后500行)
analysis_data = analysis_data.iloc[-500:] # 只取最近500个交易日用于演示
# ============================================================
# 第三步:准备建模所需的自变量(X)和因变量(Y)
# ============================================================
# features(特征/自变量):昨天的收益率Lag1(.values转为NumPy数组,sklearn要求矩阵输入)
features = analysis_data[['Lag1']].values # 提取为NumPy数组
# target_returns(目标/因变量):今天的实际收益率,这是我们想要预测的
target_returns = analysis_data['Return'].values # 提取为NumPy数组以上代码完成了海康威视股票数据的加载和预处理。接下来,我们将使用验证集方法评估不同复杂度多项式模型的预测表现,并通过可视化揭示该方法的局限性。
左图展示了单次随机分割的验证结果。但仅靠一次分割可靠吗?接下来我们重复10次不同的随机分割,揭示验证集方法对分割方式高度敏感的致命缺陷。
degrees = list(range(1, 6)) # 冻结候选多项式阶数
def validation_curve(random_state): # 封装一次随机验证切分的拟合与评价
x_train, x_valid, y_train, y_valid = train_test_split( # 按给定种子生成教学切分
features, target_returns, test_size=0.5, random_state=random_state # 固定验证比例
) # 完成切分
curve = [] # 初始化各阶MSE
for degree in degrees: # 遍历候选复杂度
transformer = PolynomialFeatures(degree=degree) # 创建多项式转换
x_train_poly = transformer.fit_transform(x_train) # 仅在训练集拟合转换
x_valid_poly = transformer.transform(x_valid) # 用训练规则转换验证集
fitted_model = LinearRegression().fit(x_train_poly, y_train) # 拟合候选模型
curve.append(mean_squared_error(y_valid, fitted_model.predict(x_valid_poly))) # 保存验证MSE
return curve # 返回完整误差曲线
validation_mse_list = validation_curve(0) # 计算左图单次切分结果
repeated_validation_curves = [validation_curve(seed) for seed in range(10)] # 预计算十次切分结果# 第四步:只绘制已预计算的误差曲线
fig, axes = plt.subplots(1, 2, figsize=(16, 6)) # 创建双面板
axes[0].plot(degrees, validation_mse_list, marker='o', linewidth=2, markersize=8, color='#E74C3C', label='验证集MSE') # 绘制单次切分
best_degree = degrees[int(np.argmin(validation_mse_list))] # 读取本次最低MSE阶数
axes[0].axvline(best_degree, linestyle='--', color='blue', alpha=0.5, label=f'最低MSE阶数: {best_degree}') # 标记最低点
axes[0].set_xlabel('多项式阶数', fontsize=13, fontweight='bold') # 设置横轴
axes[0].set_ylabel('均方误差 (MSE)', fontsize=13, fontweight='bold') # 设置纵轴
axes[0].set_title('单次随机分割的验证MSE') # 设置左图标题
axes[0].legend(fontsize=11) # 显示左图图例
axes[0].grid(True, alpha=0.3) # 添加左图网格
for curve in repeated_validation_curves: # 遍历预计算曲线
axes[1].plot(degrees, curve, alpha=0.4, linewidth=1.5) # 绘制一次切分结果
axes[1].set_xlabel('多项式阶数', fontsize=13, fontweight='bold') # 设置横轴
axes[1].set_ylabel('均方误差 (MSE)', fontsize=13, fontweight='bold') # 设置纵轴
axes[1].set_title('10次不同随机分割的验证MSE') # 设置右图标题
axes[1].grid(True, alpha=0.3) # 添加右图网格
plt.tight_layout() # 自动调整布局
plt.show() # 显示图形
从 图 5.2 的结果可以看出:
结果的随机性:不同的验证集分割导致了MSE曲线的显著差异。有些分割可能显示二次项有帮助,而另一些则显示线性模型最好,甚至没有任何模型比截距项(0阶)更好(考虑到股票收益的不可预测性,这很常见)。
高变异性:这再次证实了验证集方法的一个主要缺点——估计的测试误差具有高变异性。
模型选择的不确定性:依据单次分割的结果来选择模型阶数是不可靠的。
注: 在金融时间序列中,通常我们不会看到像物理定律或模拟数据那样清晰的”U型”误差曲线,这也是真实数据分析的挑战之一。
立即配对正例:同一数据的前向验证
上图的随机切分是故意保留的无效反例,不是金融序列的可复制工作流。下面紧接着对同一特征、目标、候选次数和 MSE 运行扩张窗口;每折训练日都早于验证日。如果标签窗口重叠,还应在训练终点和验证起点之间加 purge/gap。
from sklearn.model_selection import TimeSeriesSplit, cross_val_score # 导入保持时间顺序的折分与评估工具
from sklearn.pipeline import make_pipeline # 将特征变换限定在每个训练折内
walk_forward_splitter = TimeSeriesSplit(n_splits=5, gap=1) # 扩张窗口并留一日隔离
walk_forward_rows = [] # 存储每个候选次数的时间感知评估
for degree in degrees: # 对与反例相同的多项式次数逐一评估
fold_pipeline = make_pipeline(PolynomialFeatures(degree=degree), LinearRegression()) # 折内拟合变换与模型
fold_losses = -cross_val_score(fold_pipeline, features, target_returns, cv=walk_forward_splitter, scoring='neg_mean_squared_error') # 计算前向折 MSE
walk_forward_rows.append({'degree': degree, 'walk_forward_mse': fold_losses.mean(), 'fold_sd': fold_losses.std()}) # 记录均值与不确定性
walk_forward_report = pd.DataFrame(walk_forward_rows) # 构造可对照的结果表
walk_forward_report['single_random_mse'] = validation_mse_list # 仅将无效随机结果作为反例对照
print(walk_forward_report.to_string(index=False)) # 同时报告误差、排序与折间波动 degree walk_forward_mse fold_sd single_random_mse
1 2.696049 1.693930 2.142310
2 2.792300 1.773888 2.178374
3 2.997603 2.109262 2.361270
4 3.159158 2.406220 4.022718
5 13.535473 23.041428 2.492874
本章后续凡涉及金融时序的模型选择,以这个前向方案为权威评估;随机 K 折和 LOOCV 只用于 i.i.d. 理论说明,不用来得出交易结论。
验证集方法的优点和缺点:
验证集方法在概念上很简单,易于实现。但它有两个潜在缺点:
估计的高变异性:如 图 5.2 的右图所示,测试误差率的验证估计可能具有很高的变异性,这取决于具体哪些观测包含在训练集中,哪些包含在验证集中。
估计的高偏差:在验证方法中,只有包含在训练集中的观测子集(而不是验证集中的那些)被用于拟合模型。由于统计方法在较少观测上训练时往往表现较差,这表明验证集误差率可能会高估在整个数据集上拟合的模型的测试误差率。
在接下来的小节中,我们将介绍交叉验证(cross-validation),这是验证集方法的一种改进,旨在解决这两个问题。
5.3.2 留一交叉验证 (Leave-One-Out Cross-Validation)
留一交叉验证(Leave-One-Out Cross-Validation, LOOCV)与 小节 5.3.1 的验证集方法密切相关,但它试图解决该方法的缺点。
与验证集方法类似,LOOCV涉及将观测集分为两部分。然而,它不是创建两个相当大小的子集,而是使用单个观测\((x_1, y_1)\)作为验证集,其余观测\(\{(x_2, y_2), \ldots, (x_n, y_n)\}\)构成训练集。统计学习方法在\(n-1\)个训练观测上拟合,并对被排除的观测使用其值\(x_1\)进行预测\(\hat{y}_1\)。由于\((x_1, y_1)\)未用于拟合过程,\(\text{MSE}_1 = (y_1 - \hat{y}_1)^2\)提供了测试误差的近似无偏估计。但即使\(\text{MSE}_1\)对测试误差是无偏的,它也是一个较差的估计,因为它高度依赖于单个观测\((x_1, y_1)\)。
我们可以通过选择\((x_2, y_2)\)作为验证数据来重复该过程,在\(n-1\)个观测\(\{(x_1, y_1), (x_3, y_3), \ldots, (x_n, y_n)\}\)上训练统计学习程序,并计算\(\text{MSE}_2 = (y_2 - \hat{y}_2)^2\)。重复此方法\(n\)次将产生\(n\)个平方误差\(\text{MSE}_1, \text{MSE}_2, \ldots, \text{MSE}_n\)。
测试MSE的LOOCV估计是这\(n\)个测试误差估计的平均值:
\[ \text{CV}_{(n)} = \frac{1}{n}\sum_{i=1}^{n}\text{MSE}_i \tag{5.1}\]
图 5.3 展示了LOOCV方法的示意图。
LOOCV与验证集方法的比较:
LOOCV相对于验证集方法有几个主要优点:
偏差更小:在LOOCV中,我们重复使用包含\(n-1\)个观测的训练集来拟合统计学习方法,这几乎与整个数据集中的观测数量一样多。这与验证集方法形成对比,后者训练集通常约为原始数据集大小的一半。因此,LOOCV方法往往不会像验证集方法那样高估测试误差率。
结果确定性:与验证集方法不同,后者由于训练/验证集分割的随机性,重复应用时会产生不同的结果,而多次执行LOOCV将总是产生相同的结果:训练/验证集分割中没有随机性。
Tip: LOOCV的计算捷径
对于最小二乘线性或多项式回归,有一个惊人的捷径可以使LOOCV的成本与单次模型拟合相同!以下公式成立:
\[ ext{CV}_{(n)} = \frac{1}{n}\sum_{i=1}^{n}\left(\frac{y_i - \hat{y}_i}{1 - h_i}\right)^2 \tag{5.2}\]
其中\(\hat{y}_i\)是来自原始最小二乘拟合的第\(i\)个拟合值,\(h_i\)是第\(i\)个观测的杠杆值(leverage)。
这就像普通的MSE,只是第\(i\)个残差除以\(1-h_i\)。杠杆值在\(1/n\)和\(1\)之间,反映了观测对其自身拟合的影响量。因此,高杠杆点的残差在这个公式中被精确地放大了正确的量,以使这个等式成立。
LOOCV是一个非常通用的方法,可以用于任何类型的预测建模。例如,我们可以将其用于逻辑回归或线性判别分析,或在后续章节中讨论的任何方法。然而,“神奇公式”(式 5.2)在一般情况下不成立,在这种情况下,模型必须重新拟合\(n\)次。
5.3.3 k折交叉验证 (k-Fold Cross-Validation)
LOOCV的一个替代方案是k折交叉验证(k-fold CV)。对于可合理视为 i.i.d. 的观测,这种方法可随机将观测集分为\(k\)个组或折(folds),大小大致相等。第一折被视为验证集,方法在剩余的\(k-1\)折上拟合。然后对保留折中的观测计算均方误差\(\text{MSE}_1\)。该过程重复\(k\)次;每次,不同的一组观测被视为验证集。这个过程产生\(k\)个测试误差估计\(\text{MSE}_1, \text{MSE}_2, \ldots, \text{MSE}_k\)。存在时间顺序或公司聚类时,则必须改用相应的前向或分组折。k折CV估计通过平均这些值计算:
\[ \text{CV}_{(k)} = \frac{1}{k}\sum_{i=1}^{k}\text{MSE}_i \tag{5.3}\]
图 5.4 展示了k折CV方法的示意图。
LOOCV与k折CV的关系:
不难看出,LOOCV是k折CV的特殊情况,其中\(k\)设置为等于\(n\)。在实践中,通常使用\(k=5\)或\(k=10\)执行k折CV。使用\(k=5\)或\(k=10\)而不是\(k=n\)的优点是什么?
最明显的优点是计算上的。LOOCV需要拟合统计学习方法\(n\)次。这可能非常耗时(对于通过最小二乘拟合的线性模型除外,在这种情况下可以使用公式@eq-loocv-shortcut)。但交叉验证是一个非常通用的方法,可以应用于几乎任何统计学习方法。一些统计学习方法的拟合过程计算量大,因此执行LOOCV可能会带来计算问题,特别是当\(n\)非常大时。相比之下,执行10折CV只需要拟合学习过程十次,这可能更可行。
图 5.5 只在一个明确的 i.i.d. 受控模拟中比较 LOOCV 与随机 10 折 CV。这样做是为了隔离两种重采样估计量的计算量和抽样变异;它不是金融时间序列的评估方案,也不把任一方法称为“权威基准”。后文的真实金融序列统一采用前向验证。
# ============================================================
# 第一步:导入交叉验证所需的额外工具库
# ============================================================
from sklearn.model_selection import KFold, LeaveOneOut, cross_val_score
# ============================================================
# 第二步:生成无时间顺序的受控 i.i.d. 数据
# ============================================================
iid_rng = np.random.default_rng(42)
iid_cv_feature = iid_rng.uniform(-2, 2, size=(180, 1))
iid_cv_target = 1 + iid_cv_feature[:, 0] ** 2 + iid_rng.normal(0, 1, 180)
degrees = range(1, 6) # 设定多项式阶数范围(1阶到5阶)
# ============================================================
# 第三步:执行LOOCV(留一交叉验证)
# ============================================================
# LOOCV的核心思想:假设有n个数据点,每次留出1个作为验证,剩余n-1个训练
# 重复n次,每次留出不同的点,最后取n次误差的平均值
# 优点:几乎无偏(训练集大小接近全集);缺点:需要训练n次模型,计算量极大
loocv_errors = [] # 存储每个多项式阶数的LOOCV平均误差
# 依次尝试每个多项式阶数,计算对应的LOOCV误差
for degree in degrees: # 遍历循环
# 将原始特征转换为多项式特征(例如degree=3时,Lag1变为[1, Lag1, Lag1², Lag1³])
polynomial_transformer = PolynomialFeatures(degree=degree) # 创建指定阶数的多项式特征转换器
# fit_transform:先学习特征范围,再将特征转换为多项式形式
poly_features = polynomial_transformer.fit_transform(iid_cv_feature)
kfold_splitter = LeaveOneOut()
# 创建线性回归模型实例
linear_model = LinearRegression() # 初始化线性回归模型
# cross_val_score自动完成:分折→训练→预测→计算MSE的全部流程
# scoring='neg_mean_squared_error':sklearn约定"越大越好",所以MSE取负值
cv_score_results = cross_val_score(linear_model, poly_features, iid_cv_target,
cv=kfold_splitter, scoring='neg_mean_squared_error') # 指定LOOCV折数和评价指标
# 取负号将sklearn的"负MSE"还原为正常的MSE值,再取均值后追加到列表
loocv_errors.append(-cv_score_results.mean()) # 计算平均MSE并追加到结果列表
# ============================================================LOOCV 计算完成后,对同一受控样本重复随机 10 折。图中的相近或差异都是本次模拟结果,不能外推为金融预测的普遍结论。
kfold_error_curves = [] # 初始化重复十折误差曲线
for seed in range(9): # 遍历九个冻结分折种子
splitter = KFold(n_splits=10, shuffle=True, random_state=seed) # 创建受控IID十折
seed_errors = [] # 初始化当前种子的各阶误差
for degree in degrees: # 遍历候选阶数
transformer = PolynomialFeatures(degree=degree) # 创建多项式转换器
polynomial_features = transformer.fit_transform(iid_cv_feature) # 转换受控IID特征
scores = cross_val_score(LinearRegression(), polynomial_features, iid_cv_target, cv=splitter, scoring='neg_mean_squared_error') # 执行十折评价
seed_errors.append(-scores.mean()) # 保存当前阶数MSE
kfold_error_curves.append(seed_errors) # 保存当前种子的完整曲线# 第四步:执行10折CV(重复9次不同的随机分割)
# ============================================================
# 创建包含两个子图的画布(左图LOOCV,右图10折CV)
fig, axes = plt.subplots(1, 2, figsize=(16, 6)) # 创建子图布局
# ---------- 左图:绘制LOOCV的MSE曲线 ----------
# LOOCV 是这里的比较对象,不是现实金融评估的“权威基准”
axes[0].plot(degrees, loocv_errors, marker='o', linewidth=2.5, markersize=8, # 绘制LOOCV误差曲线
color='#E74C3C', label='LOOCV误差') # 红色线条表示LOOCV误差
# 找出LOOCV认为最优的多项式阶数
best_degree_loocv = degrees[np.argmin(loocv_errors)] # 获取最小值的索引
# 用蓝色虚线标记最优阶数
axes[0].axvline(best_degree_loocv, linestyle='--', color='blue', alpha=0.5, # 蓝色虚线标记最优阶数
label=f'最优阶数: {best_degree_loocv}') # 设置图例文本显示最优阶数值
axes[0].set_xlabel('多项式阶数', fontsize=13, fontweight='bold') # X轴标签
axes[0].set_ylabel('均方误差 (MSE)', fontsize=13, fontweight='bold') # Y轴标签
axes[0].set_title('留一交叉验证 (LOOCV)', fontsize=14, fontweight='bold') # 左图标题
axes[0].legend(fontsize=11) # 显示图例
axes[0].grid(True, alpha=0.3) # 添加半透明网格线
# ---------- 右图:9次不同随机种子的10折CV ----------
# 核心目的:展示10折CV虽然每次结果略有波动,但整体趋势与LOOCV高度一致
# 仅比较本次 i.i.d. 模拟中的趋势与计算量,不作普遍优劣声明
for kfold_errors in kfold_error_curves: # 只绘制上一准备块已计算的误差曲线
axes[1].plot(degrees, kfold_errors, alpha=0.4, linewidth=1.5)
axes[1].set_xlabel('多项式阶数', fontsize=13, fontweight='bold') # X轴标签
axes[1].set_ylabel('均方误差 (MSE)', fontsize=13, fontweight='bold') # Y轴标签
axes[1].set_title('10折交叉验证 (9次不同随机分割)', fontsize=14, fontweight='bold') # 右图标题
axes[1].grid(True, alpha=0.3) # 添加网格线
# 自动调整子图间距
plt.tight_layout() # 自动调整子图间距
# 渲染并显示图形
plt.show() # 显示图形
从 图 5.5 可以观察本次 i.i.d. 模拟中随机 10 折结果随分折而变化。是否比一次验证集切分或 LOOCV 更稳定,应由重复模拟或任务特定实验检验,不能由单张图宣布。
k折CV的偏差-方差权衡:
我们提到\(k < n\)的k折CV相对于LOOCV具有计算优势。但抛开计算问题不谈,k折CV有一个不明显但可能更重要的优点,即它往往比LOOCV提供更准确的测试误差率估计。这与偏差-方差权衡有关。
验证集方法可能导致测试误差率的高估,因为在该方法中,用于拟合统计学习方法的训练集仅包含整个数据集的一半观测。使用这个逻辑,不难看出LOOCV将给出测试误差的近似无偏估计,因为每个训练集包含\(n-1\)个观测,这几乎与完整数据集中的观测数量一样多。并且执行\(k=5\)或\(k=10\)的k折CV将导致中等水平的偏差,因为每个训练集大约包含\((k-1)n/k\)个观测——比LOOCV方法少,但比验证集方法多得多。
然而,我们还需要考虑估计过程的方差。LOOCV具有比\(k < n\)的k折CV更高的方差。为什么是这样?当我们执行LOOCV时,我们实际上是在平均\(n\)个拟合模型的输出,每个模型都是在几乎相同的观测集上训练的;因此,这些输出彼此高度(正)相关。相比之下,当我们执行\(k < n\)的k折CV时,我们是在平均\(k\)个拟合模型的输出,这些模型的相关性较低,因为每个模型中训练集之间的重叠较小。
由于许多高度相关量的平均值比许多不那么高度相关的量的平均值具有更高的方差,因此LOOCV产生的测试误差估计往往比k折CV产生的测试误差估计具有更高的方差。
总结起来,在选择k折交叉验证中的\(k\)时存在偏差-方差权衡。通常,考虑到这些因素,使用\(k=5\)或\(k=10\)执行k折交叉验证,因为这些值已被经验证明产生的测试误差率估计既不会受过高的偏差影响,也不会受到很高的方差影响。
5.3.4 交叉验证在时间序列(金融数据)中的特殊性
在经典统计学习中,我们通常假设观测数据是独立同分布(i.i.d)的。然而,在金融领域的实证研究中(例如我们在本章预测海康威视的股票收益),数据通常是具有序列自相关的时间序列(Time Series)。如果我们在时间序列数据上直接使用标准的 k 折交叉验证(即在切分数据前随机打乱观测序列的顺序),会导致严重的数据泄露(Data Leakage)。
数据泄露的原因与后果: 在金融经济学预测中,核心目标是使用历史信息推断未来状态。如果在时间序列数据上实施随机交叉验证,由于随机洗牌,某一折的训练集中不可避免地包含了属于“未来”的观测点,而验证集却在使用“过去”的特征。这打破了时间因果性,使得模型不仅利用了历史规律,还“提前预见”了未来的波动(例如宏观基本面因子的周期性共振)。其后果是,模型在交叉验证阶段评估出的测试误差显得极具吸引力,但一旦引入完全未见过的样本外真实交易中,这种盲目的乐观偏差会导致严重的超额亏损。
前向交叉验证 (Walk-Forward / Time Series Split CV): 为了处理时间序列或投资组合的面板数据,学术界和量化工业界必须采用严格遵循时间先后顺序的重采样验证。常见做法包括:
- 滚动窗口 (Rolling Window):维持一个固定长度的时间窗口(如过去500个交易日)在时间轴上向右滑动,使用窗口内数据重新训练模型,然后在紧随其后的固定交易日度(如未来20天)进行样本外评估。
- 扩张窗口 (Expanding Window):训练集的起点固定,终点随着时间的推移不断向右推进,容纳所有已知的历史观测值,同时在增量时间步的数据上进行验证。
在本书所有金融预测实证中,前向交叉验证是默认评估设计;具体选择扩张还是滚动窗口,应与部署时可用历史长度和结构变化假设一致。
5.3.5 分类问题的交叉验证
到目前为止,我们在结果\(Y\)为定量的回归设置中说明了交叉验证的使用,并使用MSE来量化测试误差。但当\(Y\)为定性时,交叉验证也可以在分类设置中成为一个非常有用的方法。在这种情况下,交叉验证的工作方式与本章前面描述的完全相同,只是我们不是使用MSE来量化测试误差,而是使用分类错误观测的数量。
例如,在分类设置中,LOOCV误差率采用以下形式:
\[ \text{CV}_{(n)} = \frac{1}{n}\sum_{i=1}^{n}\text{Err}_i \tag{5.4}\]
其中\(\text{Err}_i = I(y_i \neq \hat{y}_i)\)。k折CV误差率和验证集误差率的定义类似。
案例应用:预测海康威视股价涨跌 (Market Direction)
让我们使用一个二元分类案例来演示交叉验证在分类问题中的应用。我们想要预测海康威视股价明天是上涨还是下跌。
接下来的代码用海康威视日收益率定义涨跌标签,并以过去两日收益率为特征。对 1 至 5 阶多项式逻辑回归,模型选择采用 5 折扩张窗口前向验证并设置一日间隔;全样本训练误差与决策边界只用于展示拟合程度,不是泛化证据。验证误差的形状与最低点必须由本次输出读取,不能预先假定为 U 型,也不能据此解释市场机制。
补充说明:交叉验证在分类任务里到底是怎样工作的
很多同学在回归问题里已经习惯了用 MSE 做验证,但到了分类问题就容易卡住:既然模型输出的是类别,为什么这里还能谈“误差曲线”?关键在于,分类任务同样需要一个可比较的损失标准,而最简单的选择就是分类错误率。
在这段实验中,每一个候选模型都会经历同样的流程:
- 先把原始特征扩展成某个指定阶数的多项式特征;
- 用这些特征训练一个逻辑回归分类器;
- 在验证折上比较预测标签与真实标签是否一致;
- 把“判错的比例”记为该折误差;
- 最后对各个前向验证窗口的误差求平均,得到该复杂度下的 CV 误差。
这里之所以把 accuracy_score 再做一次 1 - accuracy 转换,是因为本章从头到尾都在寻找“误差最小”的模型复杂度。若直接画准确率曲线,虽然信息完全等价,但和前面回归问题中“误差越低越好”的视觉语言不一致;改成错误率后,就能更直观地比较“训练误差持续下降、验证误差先降后升”的过拟合模式。
从算法视角看,这个实验同时回答了两个问题:
- 模型能否在训练样本上画出更复杂的边界? 能,所以训练误差通常越来越低;
- 这种复杂边界能否迁移到新样本上? 不一定,所以验证误差可能在某个阶数之后重新上升。
这正是交叉验证的价值所在:它不是在问“模型能不能把已知样本背下来”,而是在问“模型复杂起来以后,面对没见过的数据还能不能保持判断力”。
# ============================================================
# 第一步:导入分类建模所需的Python工具库
# ============================================================
from sklearn.linear_model import LogisticRegression # 逻辑回归分类器,输出"属于某类的概率"(0到1之间)
from sklearn.metrics import accuracy_score # 计算分类准确率 = 正确预测数 / 总样本数
# ============================================================
# 第二步:准备分类数据——定义海康威视股价"涨"与"跌"
# ============================================================
# 将连续的收益率转化为二分类标签:收益率>0(上涨)=1,≤0(下跌或持平)=0
direction_labels = (target_returns > 0).astype(int) # 转换数据类型
# 构造两个特征:Lag1(昨天收益率)和 Lag2(前天收益率)
# 投资直觉:过去两天的表现可能对明天有预测作用
# 从原始stock_data中计算Lag2(shift(2)取前天的收益率)
stock_data['Lag2'] = stock_data['Return'].shift(2) # 生成滞后2期特征(前天收益率)
# 重新构建包含Lag1、Lag2和Return的数据,去除缺失值,取最近500天
full_analysis_data = stock_data[['Return', 'Lag1', 'Lag2']].dropna().iloc[-500:] # 删除缺失值
# direction_features:用于建模的自变量矩阵(每行两个特征:Lag1和Lag2)
direction_features = full_analysis_data[['Lag1', 'Lag2']].values # 提取为NumPy数组
# direction_labels:因变量(涨=1,跌=0),基于full_analysis_data重新计算以对齐行数
direction_labels = (full_analysis_data['Return'] > 0).astype(int).values # 转换数据类型以上代码完成了分类数据的准备工作,将海康威视股票的连续收益率转化为二元涨跌标签。接下来使用保持时间顺序的扩张窗口评估不同复杂度;图中的全样本训练误差只用于展示拟合程度,不作为泛化证据。
# 第四步(左图):计算训练误差和前向CV误差
# ============================================================
degrees = range(1, 6) # 测试1阶到5阶多项式
train_errors = [] # 存储每个阶数的训练误差
cv_errors = [] # 存储每个阶数的交叉验证误差
for degree in degrees: # 遍历循环
# 将原始的2个特征(Lag1, Lag2)扩展为多项式特征
# 例如degree=2时:[Lag1, Lag2] → [1, Lag1, Lag2, Lag1², Lag1×Lag2, Lag2²]
# 这使得逻辑回归能够学习非线性的决策边界
polynomial_transformer = PolynomialFeatures(degree=degree) # 创建多项式转换器
poly_features = polynomial_transformer.fit_transform(direction_features) # 将原始特征扩展为多项式特征
# ---------- 计算训练误差 ----------
# LogisticRegression:逻辑回归分类器
# max_iter=1000:最大迭代1000次以确保收敛
# C=100:正则化参数的倒数,C越大正则化越弱(更倾向于拟合训练数据)
logistic_classifier = LogisticRegression(max_iter=1000, C=100) # 初始化逻辑回归模型
# 在全部数据上训练模型
logistic_classifier.fit(poly_features, direction_labels) # 训练/拟合模型
# 用训练好的模型对训练集本身进行预测
train_pred = logistic_classifier.predict(poly_features) # 使用模型进行预测
# 训练误差 = 1 - 准确率(即错误分类的比例)
# 训练误差通常会随模型复杂度增加而持续下降(因为模型在"记忆"训练数据)
train_error = 1 - accuracy_score(direction_labels, train_pred) # 计算准确率
train_errors.append(train_error) # 将当前阶数的训练误差添加到列表
# ---------- 计算扩张窗口前向验证误差 ----------
forward_classifier = make_pipeline(
PolynomialFeatures(degree=degree),
LogisticRegression(max_iter=1000, C=100),
)
forward_splitter = TimeSeriesSplit(n_splits=5, gap=1)
cv_score_results = cross_val_score(
forward_classifier, direction_features, direction_labels,
cv=forward_splitter, scoring='accuracy',
)
# CV误差 = 1 - 平均准确率
cv_error = 1 - cv_score_results.mean() # CV误差 = 1 - 平均准确率
cv_errors.append(cv_error) # 将当前阶数的CV误差添加到列表degrees_to_show = [1, 2] # 冻结展示复杂度
boundary_colors = ['#3498DB', '#E74C3C'] # 冻结边界颜色
boundary_labels = ['线性 (Degree 1)', '二次 (Degree 2)'] # 冻结图例文本
x_min, x_max = direction_features[:, 0].min() - 0.5, direction_features[:, 0].max() + 0.5 # 取得横轴范围
y_min, y_max = direction_features[:, 1].min() - 0.5, direction_features[:, 1].max() + 0.5 # 取得纵轴范围
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 100), np.linspace(y_min, y_max, 100)) # 创建预测网格
grid_features = np.c_[xx.ravel(), yy.ravel()] # 组装网格特征
boundary_surfaces = [] # 初始化边界概率面
for degree in degrees_to_show: # 遍历展示复杂度
transformer = PolynomialFeatures(degree=degree) # 创建多项式转换器
transformed_features = transformer.fit_transform(direction_features) # 拟合展示用全样本转换
fitted_classifier = LogisticRegression(max_iter=1000, C=100).fit(transformed_features, direction_labels) # 拟合展示模型
grid_probability = fitted_classifier.predict_proba(transformer.transform(grid_features))[:, 1] # 计算网格概率
boundary_surfaces.append(grid_probability.reshape(xx.shape)) # 保存概率面# ============================================================
# 创建画布并只绘制已预计算的误差与概率面
fig, axes = plt.subplots(1, 2, figsize=(16, 6)) # 创建双面板
axes[0].plot(degrees, train_errors, marker='o', linewidth=2.5, color='#3498DB', label='训练误差') # 绘制训练误差
axes[0].plot(degrees, cv_errors, marker='s', linewidth=2.5, color='#E74C3C', label='前向CV误差') # 绘制前向误差
best_degree_cv = degrees[int(np.argmin(cv_errors))] # 读取最低验证误差阶数
axes[0].axvline(best_degree_cv, linestyle='--', color='green', alpha=0.5, label=f'最低验证误差阶数: {best_degree_cv}') # 标记最低点
axes[0].set_xlabel('多项式阶数', fontsize=13, fontweight='bold') # 设置横轴
axes[0].set_ylabel('分类误差率', fontsize=13, fontweight='bold') # 设置纵轴
axes[0].set_title('逻辑回归误差曲线(海康威视)', fontsize=14, fontweight='bold') # 设置左图标题
axes[0].legend(fontsize=11) # 显示左图图例
axes[0].grid(True, alpha=0.3) # 添加左图网格
for surface, color, label in zip(boundary_surfaces, boundary_colors, boundary_labels): # 遍历预计算概率面
axes[1].contour(xx, yy, surface, levels=[0.5], colors=[color], linewidths=2, label=label) # 绘制0.5边界
axes[1].scatter(direction_features[direction_labels == 0, 0], direction_features[direction_labels == 0, 1], c='green', alpha=0.4, s=30, label='下跌') # 绘制负类
axes[1].scatter(direction_features[direction_labels == 1, 0], direction_features[direction_labels == 1, 1], c='red', alpha=0.4, s=30, marker='^', label='上涨') # 绘制正类
axes[1].set_xlabel('Lag1 Return (%)', fontsize=13, fontweight='bold') # 设置横轴
axes[1].set_ylabel('Lag2 Return (%)', fontsize=13, fontweight='bold') # 设置纵轴
axes[1].set_title('不同复杂度模型的样本拟合边界', fontsize=14, fontweight='bold') # 设置右图标题
axes[1].legend(fontsize=10) # 显示右图图例
plt.tight_layout() # 调整布局
plt.show() # 输出最终图表/tmp/ipykernel_1412991/238705723.py:14: UserWarning: The following kwargs were not used by contour: 'label'
axes[1].contour(xx, yy, surface, levels=[0.5], colors=[color], linewidths=2, label=label) # 绘制0.5边界
图 5.6 的左图并列全样本训练误差与 5 折扩张窗口前向验证误差。学生应从当次输出读取误差形状和最低候选阶数,并核对每折训练日期早于验证日期。真实行情的未知决策边界不能由候选多项式阶数反推;右图只展示锁定候选模型在当前样本上的拟合形状,不是真实 DGP 的证据。
5.4 自助法 (The Bootstrap)
自助法(bootstrap)是一种广泛适用且极其强大的统计工具,可用于量化与给定估计量或统计学习方法相关的不确定性。作为一个简单的例子,自助法可用于估计线性回归拟合系数的标准误差。在线性回归的特定情况下,这并不是特别有用,因为我们在 章节 3 中看到标准统计软件如R会自动输出此类标准误差。然而,自助法的力量在于它可以轻松应用于广泛的统计学习方法,包括一些难以获得变异性度量且统计软件不会自动输出的方法。
在本节中,我们通过一个简单的例子来说明自助法,在这个例子中,我们希望在一个简单模型下确定最佳投资分配。在 小节 5.4.2 中,我们探索使用自助法来评估线性模型中回归系数的变异性。
5.4.1 投资组合优化案例
假设我们希望将一笔固定金额的资金投资于两种产生收益\(X\)和\(Y\)的金融资产,其中\(X\)和\(Y\)是随机变量。我们将资金的一小部分\(\alpha\)投资于\(X\),剩余的\(1-\alpha\)投资于\(Y\)。由于这两种资产的收益存在变异性,我们希望选择\(\alpha\)来最小化投资的总风险或方差。换句话说,我们想要最小化\(\text{Var}(\alpha X + (1-\alpha)Y)\)。
可以证明,最小化风险的值由下式给出:
\[ \alpha = \frac{\sigma_Y^2 - \sigma_{XY}}{\sigma_X^2 + \sigma_Y^2 - 2\sigma_{XY}} \tag{5.5}\]
其中\(\sigma_X^2 = \text{Var}(X)\),\(\sigma_Y^2 = \text{Var}(Y)\),\(\sigma_{XY} = \text{Cov}(X, Y)\)。
在现实中,量\(\sigma_X^2, \sigma_Y^2\)和\(\sigma_{XY}\)是未知的。我们可以使用包含\(X\)和\(Y\)过去测量值的数据集来计算这些量的估计\(\hat{\sigma}_X^2, \hat{\sigma}_Y^2\)和\(\hat{\sigma}_{XY}\)。然后我们可以使用以下公式估计最小化投资方差的\(\alpha\)值:
\[ \hat{\alpha} = \frac{\hat{\sigma}_Y^2 - \hat{\sigma}_{XY}}{\hat{\sigma}_X^2 + \hat{\sigma}_Y^2 - 2\hat{\sigma}_{XY}} \tag{5.6}\]
使用真实市场数据: 海康威视与宁波银行
让我们使用中国股市的真实数据来演示这个投资组合优化问题。我们将考虑 海康威视(002415.XSHE) 和 宁波银行(002142.XSHE) 这两只具有代表性的股票。
下面用移动块 Bootstrap 估计最小方差组合权重 \(\alpha\) 的抽样不确定性。数据是两只股票对齐后的最近 100 个交易日收益;重采样单位不是孤立交易日,而是连续 10 日块,以保留块内的局部时间依赖。300 次仅用于控制教学渲染成本,正式分析还应报告对块长和重复次数的敏感性。
# ============================================================
# 第一步:设置随机种子确保可复现性
# ============================================================
np.random.seed(42) # 固定随机数生成器的起始状态,确保Bootstrap结果可重复
# ============================================================
# 第二步:从本地磁盘读取海康威视和宁波银行的股票数据
# ============================================================
# 从必需环境变量独立解析本案例的数据根,避免依赖其他代码块的临时变量
bootstrap_data_root = Path(os.environ['BOOK_DATA_DIR']).expanduser().resolve() # 解析跨平台真实数据根
assert bootstrap_data_root.is_dir(), f'BOOK_DATA_DIR 不存在: {bootstrap_data_root}' # 在读取前验证数据目录
# 拼接前复权股价数据文件的完整路径
stock_data_path = bootstrap_data_root / 'stock' / 'stock_price_pre_adjusted.h5' # 拼接前复权股价数据文件完整路径
# 分别按股票代码选择性读取两只股票的收盘价序列,避免全量载入全部A股行情
haikang_price_data = pd.read_hdf( # 读取海康威视收盘价序列
stock_data_path, # 指定前复权行情数据路径
where="order_book_id='002415.XSHE'", # 仅保留海康威视记录
columns=['date', 'close'] # 读取日期与收盘价字段,便于后续按日期对齐
).reset_index().set_index('date')['close'] # 将日期设为索引并提取收盘价序列
ningbo_bank_price_data = pd.read_hdf( # 读取宁波银行收盘价序列
stock_data_path, # 指定前复权行情数据路径
where="order_book_id='002142.XSHE'", # 仅保留宁波银行记录
columns=['date', 'close'] # 读取日期与收盘价字段,便于后续按日期对齐
).reset_index().set_index('date')['close'] # 将日期设为索引并提取收盘价序列
# 对齐两只股票的交易日期(join='inner'只保留双方都有交易的日期)
# 合并数据并删除缺失值确保数据完整性
merged_price_data = pd.concat([haikang_price_data, ningbo_bank_price_data], axis=1, join='inner').dropna()
merged_price_data.columns = ['X_Asset', 'Y_Asset'] # X=海康威视,Y=宁波银行接下来,我们计算两只股票的日收益率,并定义计算最优投资比例\(\alpha\)的核心函数——马科维茨最小方差组合公式。
# ============================================================
# 第三步:计算日收益率并准备Bootstrap样本
# ============================================================
# pct_change()自动计算每日收益率 = (今日价格-昨日价格)/昨日价格
daily_returns_df = merged_price_data.pct_change().dropna() # 删除缺失值
# 只使用最近100个交易日(约5个月),模拟"从总体中获得的一个小样本"
observation_count = 100 # 样本量设为100天
daily_returns_df = daily_returns_df.iloc[-observation_count:] # 取最后100行
haikang_returns = daily_returns_df['X_Asset'].values # 海康威视日收益率数组
ningbo_bank_returns = daily_returns_df['Y_Asset'].values # 宁波银行日收益率数组
# ============================================================
# 第四步:定义计算最优投资比例α的函数(马科维茨公式)
# ============================================================
# 这个函数实现了教材中的关键公式:
# α = (σ²_Y - σ_XY) / (σ²_X + σ²_Y - 2σ_XY)
# α表示应该投资在资产X(海康威视)上的比例,1-α投资在资产Y(宁波银行)上
# 该比例能使投资组合的总方差(总风险)最小化
def alpha_func(input_returns_data, sample_indices): # 定义函数alpha_func
# 根据给定索引取出资产X(海康威视)的收益率子集(Bootstrap中同一天可能被多次抽到)
asset_x_subset = input_returns_data.iloc[sample_indices, 0].values # 提取为NumPy数组
# 根据给定索引取出资产Y(宁波银行)的收益率子集
asset_y_subset = input_returns_data.iloc[sample_indices, 1].values # 提取为NumPy数组
# 计算资产X的样本方差(ddof=1使用无偏估计,即除以n-1)
sigma_X2 = np.var(asset_x_subset, ddof=1) # 计算资产X收益率的样本方差
# 计算资产Y的样本方差
sigma_Y2 = np.var(asset_y_subset, ddof=1) # 计算资产Y收益率的样本方差
# 计算两资产收益率的协方差(衡量"同涨同跌"倾向,np.cov返回2×2矩阵,[0,1]为协方差)
assets_covariance = np.cov(asset_x_subset, asset_y_subset, ddof=1)[0, 1] # 计算协方差矩阵
# 代入马科维茨最优投资比例公式:分子=σ²_Y-σ_XY,分母=σ²_X+σ²_Y-2σ_XY
alpha = (sigma_Y2 - assets_covariance) / (sigma_X2 + sigma_Y2 - 2 * assets_covariance) # 计算最小方差组合的最优投资比例
return alpha # 返回最优投资比例α
# ============================================================以上代码完成了数据加载和最优投资比例\(\alpha\)的核心计算函数定义。接下来,我们将运行Bootstrap自助法来估计\(\alpha\)的标准误差,并通过可视化对比自助法分布与模拟”真实”分布的相似性。
# 第四步:用原始完整样本计算α的点估计
# ============================================================
# range(observation_count)表示使用全部100天的数据(不做任何重采样)
estimated_alpha = alpha_func(daily_returns_df, range(observation_count)) # 计算原始完整样本的α点估计
print(f'Original Alpha Estimate: {estimated_alpha:.4f}') # 输出原始估计值
# 这个值告诉我们:为了最小化投资组合风险,应该把约{estimated_alpha*100:.1f}%的资金投给海康威视
# ============================================================
# 第五步:执行Bootstrap(自助法)—— 重采样300次
# ============================================================
# Bootstrap的精髓:我们只有一个100天的样本,无法重新回到市场去收集新数据
# 以连续交易日块为单位有放回抽样,保留块内序列结构
bootstrap_iterations = 300 # 教学演示使用300次Bootstrap采样以控制渲染耗时;正式研究可提高到1000次或更高
bootstrap_alpha_estimates = [] # 存储每次Bootstrap得到的α估计值
block_length_days = 10 # 用十个交易日块保留局部依赖
# 开始300次Bootstrap重采样循环
for _ in range(bootstrap_iterations): # 遍历循环
# 有放回抽取连续块起点,再拼成与原样本等长的伪序列
block_start_indices = np.random.randint(0, observation_count - block_length_days + 1, size=int(np.ceil(observation_count / block_length_days))) # 有放回抽取连续块起点
sampled_blocks = [np.arange(start, start + block_length_days) for start in block_start_indices] # 为每个起点生成连续交易日索引
sample_indices = np.concatenate(sampled_blocks)[:observation_count] # 拼接并截取与原样本等长的块样本
# 在这批"新"样本上重新计算最优投资比例α
current_bootstrap_alpha = alpha_func(daily_returns_df, sample_indices) # 在重抽样数据上计算α
bootstrap_alpha_estimates.append(current_bootstrap_alpha) # 将当次Bootstrap的α估计追加到列表
# 将结果列表转换为NumPy数组,方便数值计算
bootstrap_alpha_estimates = np.array(bootstrap_alpha_estimates) # 构建NumPy数组
# Bootstrap标准误 = 300个块重采样α估计值的标准差
# 这就是我们想要的关键结果:它衡量了α估计的不确定性大小
bootstrap_standard_error = np.std(bootstrap_alpha_estimates, ddof=1) # 计算Bootstrap标准误
print(f'Bootstrap SE: {bootstrap_standard_error:.4f}') # 输出标准误值
# ============================================================
# 第六步:生成参数化参考分布(仅用于教学对比)
# ============================================================
# 重要说明:在现实中我们不可能知道总体的真实参数!
# 这里假装我们知道总体参数(用样本的均值和协方差矩阵代替),
# 然后反复从这个"已知总体"中抽取新样本来构建α的"真实"抽样分布
# 该分布依赖多元正态与样本矩估计,不是真实总体抽样分布
population_mean = daily_returns_df.mean() # 用样本均值估计总体均值
population_covariance = daily_returns_df.cov() # 用样本协方差矩阵估计总体参数
simulated_alpha_estimates = [] # 存储每次模拟得到的α
# 从“已知总体”重复抽取300次新样本
simulation_iterations = 300 # 教学演示使用300次模拟抽样以平衡速度与分布展示效果
for _ in range(simulation_iterations): # 遍历循环
# 从"已知总体"的多元正态分布中抽取全新的100天数据
current_simulated_data = np.random.multivariate_normal(population_mean, population_covariance, observation_count) # 从多元正态分布抽取新样本
current_simulated_df = pd.DataFrame(current_simulated_data) # 转换为DataFrame格式
# 在全新数据上计算α
current_simulated_alpha = alpha_func(current_simulated_df, range(observation_count)) # 计算模拟样本的α
simulated_alpha_estimates.append(current_simulated_alpha) # 追加到结果列表
# 将模拟结果转换为NumPy数组
simulated_alpha_estimates = np.array(simulated_alpha_estimates) # 构建NumPy数组
# ============================================================Original Alpha Estimate: 0.3322
Bootstrap SE: 0.0676
上述 Bootstrap 自助法运行结果输出了两个关键数值:
- 原始 \(\alpha\) 点估计值:它反映了在当前 100 个交易日样本下、使双资产组合方差最小的海康威视配置比例。若该值约为三分之一,就意味着组合中的大部分权重仍会分配给宁波银行,从而借助两只股票收益协动关系来压低整体波动。
- Bootstrap 标准误:经过多次(此处为 300 次)有放回重采样后,\(\hat{\alpha}\) 的标准差给出了这一最优权重估计的不确定性量级。标准误越大,说明’最优配置比例’对样本抽取越敏感;对基金经理而言,这意味着仅凭 100 天数据作出的配置判断仍存在明显抽样误差,实务上应结合更长样本、稳健约束或滚动估计来辅助决策。
接下来并列绘制块 Bootstrap 与参数化模拟参考。两者接近与否应从当次图形判断;参数化参考不是真实总体分布。
# 第七步:绘制三张对比图
# ============================================================
# figsize=(18,6):宽18英寸×高6英寸的画布,包含3个子图
fig, axes = plt.subplots(1, 3, figsize=(18, 6)) # 创建子图布局
# ---------- 左图:多元正态参数化参考分布 ----------
# 这是理论基准:如果我们能反复从总体中取样,α的分布会是什么样
# 绘制模拟抽样分布的直方图(40个bins)
axes[0].hist(simulated_alpha_estimates, bins=40, alpha=0.7, color='#3498DB', edgecolor='black') # 蓝色直方图显示模拟分布
# 用红色虚线标记分布的均值
axes[0].axvline(np.mean(simulated_alpha_estimates), color='red', linestyle='--', label=f'Mean: {np.mean(simulated_alpha_estimates):.3f}') # 红色虚线标记均值
axes[0].set_xlabel('Alpha', fontsize=12, fontweight='bold') # X轴标签
axes[0].set_title('参数化参考分布\n(Parametric Reference)', fontsize=13) # 左图标题
axes[0].legend() # 显示图例
axes[0].grid(True, alpha=0.3) # 添加网格线
# ---------- 中图:Bootstrap自助法分布 ----------
# 这是我们在实际中能做到的:从单一样本出发,通过有放回抽样估计α的分布
# 绘制Bootstrap自助法分布的直方图
axes[1].hist(bootstrap_alpha_estimates, bins=40, alpha=0.7, color='#E74C3C', edgecolor='black') # 红色直方图显示Bootstrap分布
# 蓝色实线标记原始样本的α估计值
axes[1].axvline(estimated_alpha, color='blue', linewidth=2, label=f'Original: {estimated_alpha:.3f}') # 蓝色实线标记原始估计值
axes[1].set_xlabel('Alpha', fontsize=12, fontweight='bold') # X轴标签
axes[1].set_title(f'自助法分布 (Bootstrap Dist)\n(SE = {bootstrap_standard_error:.3f})', fontsize=13) # 中图标题
axes[1].legend() # 显示图例
axes[1].grid(True, alpha=0.3) # 添加网格线
# ---------- 右图:箱线图并排比较 ----------
# 箱线图可以直观比较两种方法得到的α分布:
# 中位线(箱子中间的横线)、四分位距(箱子的宽度)、异常值(胡须外的点)
# 绘制两种方法的箱线图进行并排对比
axes[2].boxplot([simulated_alpha_estimates, bootstrap_alpha_estimates], labels=['Simulation', 'Bootstrap'], patch_artist=True,
boxprops=dict(facecolor='lightblue')) # 箱子填充浅蓝色
axes[2].set_title('分布比较 (Comparison)', fontsize=13) # 右图标题
axes[2].grid(True, alpha=0.3, axis='y') # 仅在Y轴方向添加网格线
# 自动调整子图间距
plt.tight_layout() # 自动调整子图间距
# 渲染并显示图形
plt.show() # 显示图形/tmp/ipykernel_1412991/4138035420.py:32: MatplotlibDeprecationWarning: The 'labels' parameter of boxplot() has been renamed 'tick_labels' since Matplotlib 3.9; support for the old name will be dropped in 3.11.
axes[2].boxplot([simulated_alpha_estimates, bootstrap_alpha_estimates], labels=['Simulation', 'Bootstrap'], patch_artist=True,
图 5.7 清楚地展示了自助法的核心思想:
左图:显示从以样本矩拟合的多元正态参考模型生成 300 个数据集后得到的 \(\alpha\) 分布。它依赖正态性与参数估计,不是真实总体抽样分布。
中图:显示从单个时间序列样本生成 300 个连续块自助样本后得到的 \(\alpha\) 分布。块长 10 日是建模选择,应做敏感性分析。
右图:比较两种条件分布。只有当位置和离散程度在本次输出中确实接近时,才能说结果在该参数化参考下相容;差异则提示分布假设、序列依赖或块长选择值得检查。
自助法的原理:
在实践中,我们不能从原始总体生成新样本,因此无法应用上述估计\(\text{SE}(\hat{\alpha})\)的过程。然而,自助法方法允许我们使用计算机来模拟获取新样本集的过程,这样我们就可以在不生成额外样本的情况下估计\(\hat{\alpha}\)的变异性。
我们不是从总体重复获得独立的数据集,而是通过重复从原始数据集中有放回地抽样观测来获得不同的数据集。
Tip: 自助法的数学原理
自助法的理论基础是大数定律和Glivenko-Cantelli定理。当样本量\(n\)足够大时,经验分布函数(empirical distribution function)以概率1收敛于真实分布函数。因此,从原始数据中自助抽样近似于从真实总体中抽样。
具体来说,如果\(X_1, \ldots, X_n\)是从分布\(F\)中抽取的独立同分布样本,那么经验分布\(\hat{F}_n\)收敛于\(F\)。从\(\hat{F}_n\)中抽取的样本(即自助样本)的渐近性质与从\(F\)中抽取的样本相同。
这意味着,对于许多统计量,自助法可以提供其抽样分布的一致估计。
数学推导:观测点出现在自助样本中的概率
我们经常提及自助样本大约包含了原始数据中的 \(63.2\%\) 的独特观测,这一性质可以通过简单的概率论来严格证明。假设原始数据集有 \(n\) 个独立观测。在自助抽样中,我们有放回地独立随机抽取 \(n\) 次。
针对第 \(i\) 个特定观测点,在某单次抽取中它没有被选中的概率为 \(1 - \frac{1}{n}\)。 由于这 \(n\) 次抽取是相互独立的,所以在全部 \(n\) 次抽取中,第 \(i\) 个观测点始终未被选中的概率为: \[ P(\text{未被选中}) = \left(1 - \frac{1}{n}\right)^n \]
相应地,该观测点至少被选中一次的概率为: \[ P(\text{被选中}) = 1 - \left(1 - \frac{1}{n}\right)^n \]
根据微积分定理,我们知道自然对数底 \(e\) 的极限定义:\(\lim_{n \to \infty} \left(1 - \frac{1}{n}\right)^n = \frac{1}{e} \approx 0.368\)。 因此,当 \(n\) 较大时,任何特定的原始观测点大约有 \(1 - 1/e \approx 0.632\) (63.2%) 的概率出现在这个自助样本中。
那些未进入当前自助样本的剩余的约 36.8% 的观测,被称为袋外数据 (Out-of-Bag, OOB)。袋外数据极为有用,因为它们可以作为模型的天然验证集,无需像交叉验证那样做显式的数据切分。
5.4.2 自助法的实现步骤
自助法的实现步骤如下:
原始数据集:我们有包含\(n\)个观测的原始数据集\(Z = \{(x_1, y_1), \ldots, (x_n, y_n)\}\)。
创建自助样本:从\(Z\)中有放回地随机选择\(n\)个观测,产生第一个自助数据集\(Z^{*1}\)。有放回抽样意味着同一个观测可以在自助数据集中出现多次。
计算统计量:使用\(Z^{*1}\)计算感兴趣统计量的自助估计(例如,\(\hat{\alpha}^{*1}\))。
重复步骤2-3:将此过程重复\(B\)次(\(B\)是一个较大的数,如1,000或10,000),产生\(B\)个不同的自助数据集\(Z^{*1}, Z^{*2}, \ldots, Z^{*B}\)和\(B\)个相应的估计\(\hat{\alpha}^{*1}, \hat{\alpha}^{*2}, \ldots, \hat{\alpha}^{*B}\)。
计算标准误差:使用以下公式计算这些自助估计的标准误差:
\[ \text{SE}_B(\hat{\alpha}) = \sqrt{\frac{1}{B-1}\sum_{r=1}^{B}\left(\hat{\alpha}^{*r} - \frac{1}{B}\sum_{r'=1}^{B}\hat{\alpha}^{*r'}\right)^2} \tag{5.7}\]
这作为从原始数据集估计的\(\hat{\alpha}\)的标准误差的估计。
Clarification on 自助法的适用性
自助法虽然强大,但也有一些局限性:
对小样本的偏差:当样本量很小时,自助法估计可能有偏差。例如,估计中位数的标准误差在小样本时可能不准确。
对非光滑统计量的挑战:对于非光滑统计量(如中位数、分位数),自助法的一致性可能不成立。
依赖数据质量:自助法假设原始样本能够代表总体。如果原始样本有偏差或有异常值,自助法估计也会受到影响。
计算成本:虽然比重复收集数据便宜,但对于大型数据集和复杂模型,自助法的计算成本仍然可能很高。
5.5 本章小结 (Chapter Summary)
本章详细介绍了两种最重要的重采样方法:交叉验证和自助法。
5.5.1 交叉验证 (Cross-Validation)
- 验证集方法:简单直观,但估计的变异性高且可能高估测试误差。
- 留一交叉验证(LOOCV):偏差小,结果确定,但计算成本高且方差较大。
- k折交叉验证:
- 在偏差和方差之间提供了良好的权衡
- 计算效率高
- 通常使用\(k=5\)或\(k=10\)
- 分类问题的交叉验证:使用分类错误率而不是MSE来评估性能。
5.5.2 自助法 (The Bootstrap)
- 核心思想:通过有放回地从原始数据集抽样来模拟从总体抽样的过程。
- 主要用途:
- 估计参数的标准误差
- 构建置信区间
- 评估统计学习方法的变异性
- 优点:
- 适用范围广,几乎可以用于任何统计方法
- 不依赖强分布假设
- 实现简单
- 局限性:
- 小样本时可能有偏差
- 对某些统计量可能不适用
- 计算成本较高
5.5.3 实际应用建议
- IID 横截面:可用随机 \(k\) 折或 LOOCV,但预处理必须在折内拟合。
- 分组/聚类数据:按公司、客户或其他独立单位分折,避免同组信息跨折。
- 面板预测:同时审计公司聚类与时间方向,根据部署对象选择分组前向设计。
- 时间序列:用扩张或滚动窗口前向验证,并按标签跨度设置 purge/gap;序列不确定性量化使用 moving-block 或 stationary bootstrap。
重采样方法是现代统计学习实践的基础工具,它们使我们能够在没有额外数据的情况下评估模型性能和量化不确定性,是每个数据科学家必须掌握的核心技能。
5.6 理论来源与前沿
交叉验证与自助法的理论根基是用‘重复抽样’逼近‘重复实验’:当我们无法无限次从总体抽样时,就用数据自身构造近似的重复样本来估计泛化误差或统计量的不确定性。交叉验证的预测选择框架可追溯至 Stone (Stone 1974年);Efron 的 bootstrap 把重抽样思想系统化 (Efron 1979年)。这些经典结果并不自动授权对有时间依赖的数据随机打乱,本章的前向切分与 purge 是针对当前预测合同额外施加的设计约束。
近年来的研究与实践重点包括:
- 模型选择偏差:当我们用同一套交叉验证同时做模型选择与误差估计时,误差会偏乐观,需要嵌套交叉验证或独立测试集。
- 时间序列与面板数据的重采样:需要 block bootstrap、滚动窗口 CV 等结构化方法以保留相关性。
- 大规模计算下的近似:在算力受限时,用更少折数、分层抽样与并行化来取得工程上可用的近似。
5.7 贯穿项目里程碑 M05
本里程碑与 小节 6.3 的 M05 一致,预计 60 分钟。M02 已唯一冻结 project-manifest-v1 及哈希;M05 只能审计原字节、严格 purge 和训练内前向折,不能重写成员或生成第二个 split。
| 契约项 | 可审计要求 |
|---|---|
| 输入 | M02 原始 manifest 文件、登记 SHA-256、仅含 train/validation 标签的开发制品;测试键制品不得含 y |
| 输出 | 原字节/hash 一致性、成员/日期计数、两条 20 日 purge 证据、幂等 forward-fold-policy-v1.json 与具体 forward-folds-v1.json,并输出政策、制品及每折 hash |
| 时点与冻结 | 对相邻集合强制 max(previous.label_date) < min(next.prediction_date);训练事件率/多数类只用 train;测试标签继续不可见 |
| 失败条件 | 重写成员或日期、随机折作主评估、manifest/开发/测试键重复、任一哈希不符、同名输出不同字节、折内或跨评分折重复、边界非严格、读取测试标签或用验证标签定义基线 |
| 量规 | D 数据与时间边界 40%,M 方法与验证 35%,R 可复现断言与输出 25% |
5.8 练习
5.8.1 概念题
[核心|难度:1|时间:10分钟|分值:10|项目:无] 比较验证集方法、LOOCV 与 \(k\) 折交叉验证:它们在偏差、方差与计算成本上的主要差异是什么?
[核心|难度:2|时间:15分钟|分值:15|项目:无] 为什么在时间序列或金融数据中,随机打乱的交叉验证可能产生严重的数据泄露?给出一个具体例子。
[核心|难度:1|时间:10分钟|分值:10|项目:无] 什么是嵌套交叉验证?它解决了什么偏差问题?
[核心|难度:2|时间:15分钟|分值:15|项目:无] bootstrap 估计标准误与置信区间的核心假设是什么?在什么情况下 bootstrap 可能失败?
[核心|难度:1|时间:10分钟|分值:10|项目:无] 当模型选择与误差评估使用同一份数据时,为什么测试误差往往被低估?
5.8.2 应用题
[核心|难度:3|时间:60分钟|分值:40|项目:M05] 审计唯一切分与训练内折:核验 M02 manifest、开发与测试制品的原字节和 SHA-256,不得重建成员。逐边界断言
max(previous.label_date) < min(next.prediction_date);测试制品不得含y。按预注册政策只在 train 内生成扩张窗口折,对每折应用同样的label_datepurge;幂等提交包含完整fit_keys/score_keys、边界、manifest hash、policy hash、每折 hash 与全折 hash 的 JSON 制品,供 M06—M09 直接消费。[核心|难度:2|时间:25分钟|分值:20|项目:无] 选择一家长三角上市公司,估计其日收益率均值的 95% 置信区间:
- 方法一:正态近似(用样本均值与标准误)
- 方法二:bootstrap 百分位区间
比较两种区间的长度与稳定性。
- [拓展|难度:3|时间:30分钟|分值:25|项目:无] 在一个包含超参数的模型(例如岭回归或 Lasso)上,分别用普通 \(k\) 折 CV 与嵌套 CV 做模型选择,比较得到的测试误差估计是否存在系统偏差。
5.8.3 理论题
[拓展|难度:3|时间:20分钟|分值:20|项目:无] 在 LOOCV 中,证明每次训练集只少一个样本,因此 LOOCV 的训练集偏差通常较小,但方差可能较大。
[拓展|难度:2|时间:15分钟|分值:15|项目:无] 给出 bootstrap 百分位区间的定义,并解释为什么它在分布偏斜或统计量非正态时可能优于正态近似区间。
5.9 练习参考解答
展开第 5 章完整解答与评分键
统一评分与验收说明:按题面元数据分值计分;重采样单位、折日期、purge 断言、基线/模型输出和结论边界均为独立评分点。MSE 与区间端点相对容差 \(10^{-6}\);bootstrap 固定种子后允许因数值库版本导致 \(10^{-4}\) 相对差,折日期与边界不允许容差。常见失败包括时序随机分折、未保存 label_date、训练标签跨入测试、序列使用 IID bootstrap,或把两个不同测试总体的单次 MSE 差称为泄漏幅度。
5.9.1 概念题参考解答
- 三种 CV 的差异
- 验证集法:最快,但对一次切分很敏感(方差大)。
- LOOCV:偏差小,但每次训练集几乎相同,预测误差高度相关,方差可能较大,且对某些模型计算成本高。
- \(k\) 折 CV:在偏差与方差之间折中,工程上最常用(常见 \(k=5\) 或 \(k=10\))。
- 时间序列的泄露
若把 2024 年的数据随机分到训练集,而 2020 年的数据分到测试集,模型会在训练阶段‘见过未来’的分布与模式,从而高估测试性能。
- 嵌套 CV 的作用
外层用于估计泛化误差,内层用于选择超参数。这样避免‘用同一份数据既选模型又评估’导致的乐观偏差。
- bootstrap 的假设与失效
核心假设是样本能代表总体且观测近似独立同分布。强相关、结构突变或极端小样本会使 bootstrap 区间失真。
- 误差低估原因
模型选择过程本身会把噪声当作信号(对训练数据‘过度适配’),因此若评估不独立,会系统性低估测试误差。
5.9.2 应用题参考解答
- 审计 M02 的
project-manifest-v1
答案在 fresh kernel 中从显式制品目录读取 M02 的原始 manifest、机器合同、开发标签和无标签测试键。M05 不读取原始行情、不重算标签、不重新决定日期成员。课程运行器必须预登记 BOOK_PROJECT_FOLD_POLICY、BOOK_PROJECT_FOLD_ARTIFACT 及相应 SHA-256;标准文件名分别是 forward-fold-policy-v1.json 与 forward-folds-v1.json。
import hashlib # 核验原字节并计算折制品哈希
import json # 生成规范化JSON折制品
import os # 读取fresh-kernel显式入口
from pathlib import Path # 使用跨平台路径
import numpy as np # 构造日期折索引
import pandas as pd # 读取冻结制品
artifact_dir = Path(os.environ['BOOK_PROJECT_ARTIFACT_DIR']).resolve() # M02制品目录
manifest_path = artifact_dir / 'project-manifest-v1.csv' # M02原文件
development_path = artifact_dir / 'project-development-v1.csv' # 只含开发标签
test_key_path = artifact_dir / 'project-test-keys-v1.csv' # 不含标签的测试键
contract_path = artifact_dir / 'project-contract-v1.json' # M02总体与制品哈希合同
policy_path = Path(os.environ['BOOK_PROJECT_FOLD_POLICY']).resolve() # 运行器固定政策输出路径
fold_artifact_path = Path(os.environ['BOOK_PROJECT_FOLD_ARTIFACT']).resolve() # 运行器固定成员折输出路径
expected_policy_sha256 = os.environ['BOOK_PROJECT_FOLD_POLICY_SHA256'].lower() # 读取事前登记政策哈希
expected_fold_sha256 = os.environ['BOOK_PROJECT_FOLD_SHA256'].lower() # 读取事前登记折制品哈希
assert policy_path.name == 'forward-fold-policy-v1.json' # 拒绝非标准政策文件名
assert fold_artifact_path.name == 'forward-folds-v1.json' # 拒绝非标准折文件名
assert policy_path.parent == artifact_dir and fold_artifact_path.parent == artifact_dir # 所有项目制品必须进入同一桶
assert all(path.is_file() for path in [manifest_path, development_path, test_key_path, contract_path]) # 上游缺失立即失败
contract = json.loads(contract_path.read_text(encoding='utf-8')) # 读取M02权威合同
registered_hashes = contract['artifact_hashes'] # 取得各上游制品哈希manifest_bytes = manifest_path.read_bytes() # 保留原字节,不重新序列化
manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest() # 实际哈希
assert manifest_sha256 == registered_hashes['manifest_sha256'] # 拒绝权威成员变化
assert hashlib.sha256(development_path.read_bytes()).hexdigest() == registered_hashes['development_sha256'] # 拒绝开发标签变化
assert hashlib.sha256(test_key_path.read_bytes()).hexdigest() == registered_hashes['test_sha256'] # 拒绝测试键变化
manifest = pd.read_csv(manifest_path, parse_dates=['prediction_date', 'label_date']) # 读取全成员表
development_panel = pd.read_csv(development_path, parse_dates=['prediction_date', 'label_date']) # 读取仅开发标签
sealed_test_keys = pd.read_csv(test_key_path, parse_dates=['prediction_date', 'label_date']) # 读取无结果测试键
key_columns = ['order_book_id', 'prediction_date'] # 同一项目键
assert not manifest.duplicated(key_columns).any() and set(manifest['split']) == {'train', 'validation', 'test'} # 拒绝重复或缺集合
assert not development_panel.duplicated(key_columns).any() and set(development_panel['split']) == {'train', 'validation'} # 拒绝开发重复或测试混入
assert not sealed_test_keys.duplicated(key_columns).any() and 'y' not in sealed_test_keys.columns # 拒绝测试重复或结果泄漏
expected_development = manifest[manifest['split'].isin(['train', 'validation'])][key_columns].copy() # 取得权威开发键
expected_test = manifest[manifest['split'].eq('test')][key_columns + ['label_date']].copy() # 取得权威测试键
assert expected_development.to_csv(index=False, date_format='%Y-%m-%d') == development_panel[key_columns].to_csv(index=False, date_format='%Y-%m-%d') # 精确核对开发键顺序与行数
assert expected_test.to_csv(index=False, date_format='%Y-%m-%d') == sealed_test_keys[key_columns + ['label_date']].to_csv(index=False, date_format='%Y-%m-%d') # 精确核对测试键与终点日train_rows = development_panel[development_panel['split'].eq('train')].copy() # 训练标签可见
validation_rows = development_panel[development_panel['split'].eq('validation')].copy() # 验证标签可见
test_origin = sealed_test_keys['prediction_date'].min() # 只从无标签键取得原点
assert train_rows['label_date'].max() < validation_rows['prediction_date'].min() # 训练到验证purge
assert validation_rows['label_date'].max() < test_origin # 验证到测试purge
training_event_rate = train_rows['y'].mean() # 只用训练标签
training_majority_class = int(training_event_rate >= 0.5) # 训练多数类
print({'manifest_sha256': manifest_sha256, 'training_event_rate': training_event_rate, 'training_majority_class': training_majority_class}) # 输出原成员哈希与训练基线
print(manifest.groupby('split').agg(n=('split', 'size'), first_origin=('prediction_date', 'min'), last_origin=('prediction_date', 'max'))) # 输出各集合成员及日期范围policy = {'schema_version': '1', 'contract_id': 'a-share-drawdown-20d-v1', 'manifest_sha256': manifest_sha256, 'key_columns': key_columns, 'sort_columns': ['prediction_date', 'order_book_id'], 'source_split': 'train', 'construction': 'expanding-date-blocks', 'date_block_count': 6, 'purge_rule': 'fit.label_date < min(score.prediction_date)', 'consumers': ['M06', 'M07', 'M08', 'M09']} # 预注册唯一折生成政策
policy_bytes = (json.dumps(policy, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n').encode('utf-8') # 生成稳定政策字节
policy_sha256 = hashlib.sha256(policy_bytes).hexdigest() # 计算政策真实哈希
assert policy_sha256 == expected_policy_sha256 # 拒绝未登记政策
train_rows = train_rows.sort_values(['prediction_date', 'order_book_id']).reset_index(drop=True) # 按政策固定成员顺序
train_dates = np.array(sorted(train_rows['prediction_date'].unique())) # 取得训练内唯一日期
date_blocks = [block for block in np.array_split(train_dates, policy['date_block_count']) if len(block)] # 按日期而非面板行等分
assert len(date_blocks) == policy['date_block_count'] # 数据不足形成预注册折时失败
fold_records = [] # 收集可跨章消费的具体成员键
for fold_number, score_dates in enumerate(date_blocks[1:], start=1): # 逐个生成五个扩张窗口折
score_origin = pd.Timestamp(score_dates[0]) # 锁定本折评分边界
fit_rows = train_rows[train_rows['label_date'].lt(score_origin)].copy() # 按标签终点严格purge
score_rows = train_rows[train_rows['prediction_date'].isin(score_dates)].copy() # 固定本折评分成员
assert len(fit_rows) > 0 and len(score_rows) > 0 # 拒绝空拟合或评分折
assert fit_rows['label_date'].max() < score_rows['prediction_date'].min() # 机器核验purge不等式
fit_keys = [[row.order_book_id, row.prediction_date.strftime('%Y-%m-%d')] for row in fit_rows.itertuples()] # 序列化全部拟合键
score_keys = [[row.order_book_id, row.prediction_date.strftime('%Y-%m-%d')] for row in score_rows.itertuples()] # 序列化全部评分键
fold_core = {'fold_id': f'fold-{fold_number:02d}', 'fit_keys': fit_keys, 'score_keys': score_keys, 'fit_label_date_max': fit_rows['label_date'].max().strftime('%Y-%m-%d'), 'score_prediction_date_min': score_rows['prediction_date'].min().strftime('%Y-%m-%d')} # 保存成员与边界
fold_core['fold_sha256'] = hashlib.sha256((json.dumps(fold_core, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n').encode('utf-8')).hexdigest() # 为单折绑定内容哈希
fold_records.append(fold_core) # 将本折加入唯一制品all_score_keys = [tuple(key) for fold in fold_records for key in fold['score_keys']] # 汇总跨折评分成员
assert len(all_score_keys) == len(set(all_score_keys)) # 拒绝同一成员被多个评分折重复使用
for fold in fold_records: # 逐折审计内部重复与边界
assert len(fold['fit_keys']) == len(set(map(tuple, fold['fit_keys']))) # 拒绝拟合键重复
assert len(fold['score_keys']) == len(set(map(tuple, fold['score_keys']))) # 拒绝评分键重复
assert fold['fit_label_date_max'] < fold['score_prediction_date_min'] # 以ISO日期再次核验purge
fold_artifact = {'schema_version': '1', 'contract_id': policy['contract_id'], 'manifest_sha256': manifest_sha256, 'policy_sha256': policy_sha256, 'folds': fold_records} # 形成具体前向折制品
fold_bytes = (json.dumps(fold_artifact, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n').encode('utf-8') # 生成稳定折字节
fold_sha256 = hashlib.sha256(fold_bytes).hexdigest() # 计算全折制品哈希
assert fold_sha256 == expected_fold_sha256 # 拒绝未登记折成员
for output_path, output_bytes in [(policy_path, policy_bytes), (fold_artifact_path, fold_bytes)]: # 幂等处理两个输出
if output_path.exists() and output_path.read_bytes() != output_bytes: # 已存在时必须同字节
raise RuntimeError(f'status=artifact_conflict,path={output_path.name}') # 阻止静默覆盖
if not output_path.exists(): # 仅首次运行创建制品
output_path.write_bytes(output_bytes) # 写入规范化冻结字节
print({'policy_sha256': policy_sha256, 'fold_sha256': fold_sha256, 'fold_count': len(fold_records), 'fold_hashes': [fold['fold_sha256'] for fold in fold_records]}) # 输出跨章核验入口M05 的证据是“上游原哈希未变、政策与具体成员均事前登记、每折满足严格时间边界”,不是第二个切分。M06—M09 必须加载并核验这两个 JSON,直接使用 fit_keys/score_keys,不得重建日期块。真实文件权限仍需课程运行器验证;这里没有测试 y,也不以手工计数冒充访问日志。
错误设计对照:一步收益与随机切分(不属于 M05 提交)
下面旧式模板仅用于识别为何“另选任务并随机切分”会破坏贯穿合同,不能作为本题答案。
import pandas as pd # 导入pandas用于数据框操作
import numpy as np # 导入numpy用于数值计算
import os # 导入os用于路径处理
from pathlib import Path # 用路径对象解析数据根目录
from sklearn.linear_model import Ridge # 导入岭回归模型
from sklearn.metrics import mean_squared_error # 导入均方误差评估函数
BOOK_DATA_DIR = Path(os.environ['BOOK_DATA_DIR']).expanduser().resolve() # 复用必需环境变量契约
price_path = BOOK_DATA_DIR / 'stock/stock_price_pre_adjusted.h5' # 拼接前复权股价文件路径
stock_price_data = pd.read_hdf( # 从HDF5文件中按股票代码选择性读取海康威视行情数据,避免全量载入全市场数据
price_path, # 指定前复权股价文件路径
where="order_book_id='002415.XSHE'", # 仅保留海康威视记录
columns=['date', 'order_book_id', 'close'] # 只读取时间切分和收益率计算所需字段
).reset_index() # 将MultiIndex重置为普通列
stock_price_data['date'] = pd.to_datetime(stock_price_data['date']) # 确保日期为datetime类型
stock_price_data = stock_price_data.sort_values('date') # 按日期排序stock_price_data['ret'] = stock_price_data['close'].pct_change() # 计算日收益率
stock_price_data['label_date'] = stock_price_data['date'].shift(-1) # 显式保存下一期标签对应日期
stock_price_data['y'] = stock_price_data['ret'].shift(-1) # 构造下一期收益率作为预测目标
stock_price_data['x1'] = stock_price_data['ret'].rolling(5).mean() # 5日收益率均值特征
stock_price_data['x2'] = stock_price_data['ret'].rolling(20).std() # 20日收益率标准差特征
cleaned_analysis_data = stock_price_data[['date', 'label_date', 'y', 'x1', 'x2']].dropna().copy() # 保留标签日并删除末端未知标签# 时间切分
split = cleaned_analysis_data['date'].quantile(0.8) # 计算80%分位数时间点
test_time_split = cleaned_analysis_data[cleaned_analysis_data['date'] > split].copy() # 先锁定后20%测试原点
test_origin_min = test_time_split['date'].min() # 记录测试集首个预测原点
train_time_split = cleaned_analysis_data[cleaned_analysis_data['label_date'] < test_origin_min].copy() # 仅保留标签日严格早于测试原点的训练行
assert train_time_split['label_date'].max() < test_origin_min # 拒绝任何训练标签跨入测试期
assert train_time_split['date'].max() < test_time_split['date'].min() # 确认预测原点也按时间分离
print(f'训练原点: {train_time_split.date.min().date()} 至 {train_time_split.date.max().date()}') # 输出训练原点范围
print(f'测试原点: {test_time_split.date.min().date()} 至 {test_time_split.date.max().date()}') # 输出测试原点范围
print(f'训练标签最大日期: {train_time_split.label_date.max().date()}') # 输出 purge 边界证据
model_time = Ridge(alpha=1.0).fit(train_time_split[['x1', 'x2']], train_time_split['y']) # 在训练集上拟合岭回归模型
predictions_time_split = model_time.predict(test_time_split[['x1', 'x2']]) # 在测试集上预测
print('time_split_mse:', mean_squared_error(test_time_split['y'], predictions_time_split)) # 输出时间切分MSE训练原点: 2010-06-30 至 2022-11-21
测试原点: 2022-11-23 至 2025-12-30
训练标签最大日期: 2022-11-22
time_split_mse: 0.0003158843782091338
# 随机切分(示例,不建议用于时间序列)
randomized_data = cleaned_analysis_data.sample(frac=1.0, random_state=7) # 随机打乱数据顺序
sample_size = len(randomized_data) # 获取样本量
train_random_split = randomized_data.iloc[: int(0.8*sample_size)] # 前80%作为训练集
test_random_split = randomized_data.iloc[int(0.8*sample_size):] # 后20%作为测试集
model_random = Ridge(alpha=1.0).fit(train_random_split[['x1', 'x2']], train_random_split['y']) # 拟合岭回归模型
predictions_random_split = model_random.predict(test_random_split[['x1', 'x2']]) # 在测试集上预测
print('random_split_mse:', mean_squared_error(test_random_split['y'], predictions_random_split)) # 输出随机切分MSErandom_split_mse: 0.0005519100942344951
随机切分只是错误设计对照;它与时间切分的测试总体不同,因此两个单次 MSE 的差不是“泄漏幅度”的估计。
- 收益均值置信区间
- 正态近似:\(\bar r \pm 1.96\, s/\sqrt{n}\)。
- 块 bootstrap:重复抽取连续交易日块得到 \(\bar r^*\) 的分布,取 2.5% 与 97.5% 分位数。
import numpy as np # 导入numpy用于数值计算
import pandas as pd # 导入pandas用于数据框操作
import os # 本题独立解析教师提供的收益切片
from pathlib import Path # 使用显式输入路径
exercise7_returns_path = Path(os.environ['BOOK_EX7_RETURNS']).expanduser().resolve() # 冻结单列日收益切片
exercise7_returns = pd.read_csv(exercise7_returns_path) # 不依赖前题stock_price_data
if set(exercise7_returns.columns) != {'date', 'ret'}:
raise ValueError({'status': 'stopped', 'reason': 'BOOK_EX7_RETURNS_schema'})
target_returns_array = exercise7_returns['ret'].dropna().to_numpy(dtype=float) # 提取日收益率数组
sample_size = target_returns_array.size # 获取样本量
sample_mean = target_returns_array.mean() # 计算样本均值
standard_error = target_returns_array.std(ddof=1) / np.sqrt(sample_size) # 计算标准误差
confidence_interval_normal = (sample_mean - 1.96*standard_error, sample_mean + 1.96*standard_error) # 正态近似95%置信区间bootstrap_count = 500 # 教学演示使用500次bootstrap重抽样以缩短渲染时间;正式研究可进一步增加
bootstrap_sample_means = np.empty(bootstrap_count) # 初始化存储bootstrap均值的数组
random_number_generator = np.random.default_rng(7) # 创建随机数生成器,设置种子为7
for b in range(bootstrap_count): # 遍历每次bootstrap
block_length = 10
block_count = int(np.ceil(sample_size / block_length))
starts = random_number_generator.integers(0, sample_size - block_length + 1, size=block_count)
resample_indices = np.concatenate([np.arange(s, s + block_length) for s in starts])[:sample_size]
bootstrap_sample_means[b] = target_returns_array[resample_indices].mean() # 计算重抽样均值
confidence_interval_bootstrap = (np.quantile(bootstrap_sample_means, 0.025), np.quantile(bootstrap_sample_means, 0.975)) # bootstrap 95%置信区间
print('ci_norm:', confidence_interval_normal) # 输出正态近似置信区间
print('ci_boot:', confidence_interval_bootstrap) # 输出bootstrap置信区间- 嵌套 CV(完整受控演示)
这里使用受控 i.i.d. 数据专门演示嵌套机制;金融时序应把两层 KFold 都换成前向切分。
from sklearn.datasets import make_regression # 生成仅用于验证机制的受控 i.i.d. 数据
import numpy as np # 本题独立生成惩罚网格
import pandas as pd # 本题独立输出外层损失表
from sklearn.linear_model import Ridge # 本题独立建立岭回归
from sklearn.model_selection import GridSearchCV, KFold, cross_val_score # 导入内外层评估工具
from sklearn.pipeline import make_pipeline # 把标准化锁定在每个折内
from sklearn.preprocessing import StandardScaler # 导入折内缩放器
nested_features, nested_target = make_regression(n_samples=240, n_features=20, n_informative=6, noise=20, random_state=8) # 构造可复现的选模问题
ridge_pipeline = make_pipeline(StandardScaler(), Ridge()) # 封装预处理和岭回归
alpha_grid = {'ridge__alpha': np.logspace(-3, 3, 31)} # 事前冻结候选惩罚网格
inner_cv = KFold(n_splits=5, shuffle=True, random_state=8) # 内层只负责选择 alpha
outer_cv = KFold(n_splits=5, shuffle=True, random_state=18) # 外层只负责评估锁定流程
nested_search = GridSearchCV(ridge_pipeline, alpha_grid, cv=inner_cv, scoring='neg_mean_squared_error') # 定义内层搜索
outer_losses = -cross_val_score(nested_search, nested_features, nested_target, cv=outer_cv, scoring='neg_mean_squared_error') # 获取五个外层损失
nested_search.fit(nested_features, nested_target) # 演示非嵌套时会报告的最小内层 CV 损失
optimistic_loss = -nested_search.best_score_ # 这个值同时参与选模,容易偏乐观
print(pd.DataFrame({'outer_fold_mse': outer_losses}).to_string(index=False)) # 输出完整外层分布
print(f'非嵌套最小CV MSE={optimistic_loss:.2f}; 嵌套CV MSE={outer_losses.mean():.2f}') # 并列两种误差估计 outer_fold_mse
376.634829
415.771719
598.443131
530.134527
465.520344
非嵌套最小CV MSE=487.51; 嵌套CV MSE=477.30
5.9.3 理论题参考解答
- LOOCV 的偏差与方差推导
第 \(i\) 折训练集是 \(\mathcal D_{-i}=\mathcal D\setminus\{z_i\}\),故 \(|\mathcal D_{-i}|=n-1\),与最终在 \(n\) 个样本上拟合的模型训练规模只差1。若学习曲线风险 \(R(m)\) 随训练量 \(m\) 平滑,则 \(E[\widehat R_{LOO}]\approx R(n-1)=R(n)+O(n^{-1})\),因训练规模缩小导致的偏差通常较小。
但两个训练集 \(\mathcal D_{-i}\) 与 \(\mathcal D_{-j}\) 共享 \(n-2\) 个观测,所以折损失 \(L_i,L_j\) 通常正相关。因而
\[\operatorname{Var}\!\left(\frac1n\sum_{i=1}^nL_i\right)=\frac1{n^2}\left[\sum_i\operatorname{Var}(L_i)+2\sum_{i<j}\operatorname{Cov}(L_i,L_j)\right].\]
若协方差为正,第二项不会像独立损失那样消失,这就解释了 LOOCV 的误差估计方差可能高于训练集重叠更少的 \(k\) 折 CV。这是常见性质,不是对所有算法的无条件定理。
- bootstrap 百分位区间
令统计量为 \(T\),bootstrap 样本得到 \(T^*_1,\dots,T^*_B\)。百分位区间定义为 \[ [\,\mathrm{quantile}(T^*,0.025),\ \mathrm{quantile}(T^*,0.975)\,]. \] 当 \(T\) 分布偏斜或非正态时,正态近似区间可能不对称且覆盖率偏离,百分位区间往往更贴近经验分布。
5.10 章末学习闭环
逐项自检:能否选择验证集、前向 CV 或嵌套 CV;能否为相关数据选择移动块 bootstrap;能否核验而不改写 M02 manifest;能否逐折断言标签终点 purge。禁止对金融面板随机打乱作主评价,也禁止选择与误差估计共用同一层数据。常见误区是把 LOOCV 称为金标准、把 bootstrap 当自动修复依赖。
不看正文回答:嵌套 CV 分离哪两层任务?时间折为何按日期而非面板行号?M05 为什么不能重新生成 split?迁移任务:为季度违约任务设计扩张窗口。下一章复用 M02 hash 与训练内折,进行正则化选参。
展开检索答案
内层负责选择、外层负责误差估计;按行号会把同一日期或未来公司行混入训练;split 已在 M02 事前冻结,M05 只能审计。出口决策:时间方向、标签终点 purge、选择层与误差估计层分离、M02 manifest 不改写均为 must-pass;四项全过且三道检索至少两题正确才进入第 6 章。方向错回 小节 5.3.4 用月度需求重画扩张折;purge 错用“季度违约、四季标签窗”逐折断言;嵌套层次错回 小节 5.3.1 用两候选参数标出内外层;manifest 错回 小节 5.7 做原字节 hash 对照。每项复测 2 分、边界必须严格成立,之后重入 小节 5.10。