# ============================================================
# 准备验证集实验所需的数组、表格、路径、作图与回归接口
# ============================================================
import numpy as np # 生成固定随机样本并计算多项式预测误差
import pandas as pd # 保存候选阶数与各次切分的误差结果
import os # 读取必需的教材数据根目录环境变量
from pathlib import Path # 用跨平台路径对象定位数据文件
import matplotlib.pyplot as plt # 比较单次与重复切分的验证误差曲线
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 导读
重采样用已有样本构造重复的拟合与评价机会,从而估计预测误差或统计量的不确定性。本章比较验证集、交叉验证和 bootstrap,并重点说明金融时序中为什么必须保留时间顺序与依赖结构。综合练习直接沿用第 2 章的公司—预测日样本,不另换任务或筛选更有利的观察期。
5.2 学习目标
完成本章后,读者应能够:
- 区分模型选择与最终评价,说明测试集为何不能参与调参。
- 比较验证集、LOOCV 与 \(k\) 折交叉验证的偏差、方差和计算成本。
- 为时间序列构造扩张窗口前向验证,并按标签终点设置 gap 或 purge。
- 说明普通 bootstrap 在规则统计量下成立所需的代表性与正则条件。
- 为 IID、分组和弱依赖时序选择相应的重采样单位,并解释 OOB 误差的正确聚合方式。
5.3 引言 (Introduction)
重采样方法(resampling methods)从观测样本中重复构造训练或评价子样本,用来研究拟合结果随样本变化的程度。例如,可以重复拟合线性回归并比较系数,或重复划分训练集与验证集并比较预测损失。所得结论依赖抽样设计是否与目标总体和数据依赖结构相符。
重采样需要重复拟合模型,计算成本取决于样本规模、候选数量和模型复杂度。本章讨论两类常用方法:用于模型评价与选择的交叉验证(cross-validation),以及用于近似统计量抽样分布的自助法(bootstrap)。
交叉验证与 bootstrap 减少了对某些解析公式的依赖,但并非“无假设”方法。随机折通常要求观测可交换;普通逐行 bootstrap 通常以 IID 抽样为起点;时间序列和面板数据则需要保留相应依赖结构。
模型评估与模型选择:
在统计学习中,我们通常关注两个相关但不同的任务:
- 模型评估(Model Assessment):评估一个给定统计学习方法在未见过的数据上的预期表现
- 模型选择(Model Selection):为模型选择适当的灵活度水平
交叉验证可以用来估计给定统计学习方法的测试误差,以评估其性能或选择适当的灵活度水平。而自助法最常用于提供参数估计或给定统计学习方法的准确性度量。
案例背景: A股个股收益率预测
在本章中,我们使用中国 A 股交易数据演示不同重采样方法,并以海康威视(002415.XSHE)作为单股示例。该案例用于说明方法步骤,个股结果不外推为行业结论。
该数据集包含以下变量:
- 收益率(Return):当天的股票收益率(%)
- Lag1:前一天的收益率(%)
- Lag2:前两天的收益率(%)
- Volume:成交量
- Volatility:波动率指标
我们的目标是根据过去信息建立收益率预测模型,并用重采样方法估计其样本外误差。金融收益具有时间顺序、弱信号和可能的依赖结构,因此这个案例适合比较随机切分与前向评价的差别;它不预设收益可稳定预测。
5.4 交叉验证 (Cross-Validation)
在 章节 2 中,我们讨论了测试误差率(test error rate)和训练误差率(training error rate)之间的区别。测试误差是使用统计学习方法预测新观测(即未用于训练该方法的测量)响应时的平均误差。如果有一个指定的测试集,测试误差很容易计算。然而,通常情况并非如此。
相比之下,训练误差可以通过将统计学习方法用于训练中的观测来轻松计算。但正如我们在 章节 2 中所见,训练误差率通常与测试误差率相当不同,特别是前者可能严重低估后者。
在没有一个非常大的指定测试集可以直接估计测试误差率的情况下,可以使用几种技术利用可用的训练数据来估计这个量。一些方法通过对训练误差率进行数学调整来估计测试误差率。这些方法在 章节 6 中讨论。在本节中,我们考虑一类通过从拟合过程中保留训练观测的子集,然后将统计学习方法应用于那些保留的观测来估计测试误差率的方法。
在 小节 5.4.1 至 小节 5.4.4 中,为了简单起见,我们假设对定量响应进行回归。在 小节 5.4.5 中,我们考虑定性响应的分类情况。正如我们将看到的,无论响应是定量还是定性的,关键概念保持不变。
5.4.1 验证集方法 (The Validation Set Approach)
假设我们想要估计在一组观测上拟合特定统计学习方法的测试误差。验证集方法(validation set approach)是 图 5.1 的一个非常简单的策略。它涉及随机将可用观测集分为两部分:训练集(training set)和验证集(validation set)或保留集(hold-out set)。模型在训练集上拟合,并使用拟合的模型预测验证集中观测的响应。由此产生的验证集误差率——在定量响应的情况下通常使用MSE(均方误差)评估——提供了测试误差率的估计。
案例应用:使用多项式回归拟合海康威视股价收益率
让我们使用海康威视的真实历史数据来说明验证集方法。我们想要研究当天的收益率与前一天收益率之间是否存在非线性关系。
下面使用海康威视最近 500 个交易日的真实收益率,考察一次随机验证集切分对误差估计的影响。特征是前一日收益率,候选模型为 1—5 阶多项式。左图展示一次切分的验证 MSE,右图展示 10 次不同随机切分的结果;曲线差异直接说明单次验证集估计会受到样本划分影响。由于金融数据具有时间顺序,这里只把随机切分作为反例,随后用前向验证给出可用于预测评价的设计。
接下来,我们从本地磁盘读取海康威视的真实股票交易数据,并计算日收益率和滞后收益率,为后续的回归建模做准备。
# ============================================================
# 第二步:从本地磁盘读取海康威视的真实股票交易数据
# ============================================================
# 明文给出在线教材的数据根;本地复现时只需替换引号中的绝对路径
BOOK_DATA_DIR = Path('/home/ubuntu/r2_data_mount/data').resolve() # 将固定数据根解析为绝对路径对象
os.environ['BOOK_DATA_DIR'] = str(BOOK_DATA_DIR) # 为本章后续重采样入口登记统一数据根
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' # 拼接前复权股价数据路径
assert stock_data_path.is_file(), f'缺少前复权行情文件: {stock_data_path}' # 在HDF读取前核对目标文件
# 按股票代码选择性读取海康威视日度行情,避免全量载入全市场数据
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 # 形成单特征模型所需二维矩阵
# target_returns(目标/因变量):今天的实际收益率,这是我们想要预测的
target_returns = analysis_data['Return'].values # 形成与滞后特征逐日对齐的响应向量以上代码完成了海康威视股票数据的加载和预处理。接下来,我们将使用验证集方法评估不同复杂度多项式模型的预测表现,并通过可视化揭示该方法的局限性。
左图展示了单次随机分割的验证结果。接下来重复 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.4.2 留一交叉验证 (Leave-One-Out Cross-Validation)
留一交叉验证(Leave-One-Out Cross-Validation, LOOCV)与 小节 5.4.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.4.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。这样做是为了隔离两种重采样估计量的计算量和抽样变异;它不是金融时间序列的评估方案,也不把任一方法称为“权威基准”。后文的真实金融序列统一采用前向验证。
# ============================================================
# 加载留一与随机十折切分器,以统一负MSE评分比较二者
# ============================================================
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() # 每轮仅留出一个观测以实现LOOCV
# 创建线性回归模型实例
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与重复十折误差
# ---------- 左图:绘制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)] # 读取本次LOOCV误差最低的预设阶数
# 用蓝色虚线标记最优阶数
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') # 横轴对应事前候选的模型复杂度
axes[0].set_ylabel('均方误差 (MSE)', fontsize=13, fontweight='bold') # 纵轴报告LOOCV预测误差
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') # 与左图共享同一复杂度候选集
axes[1].set_ylabel('均方误差 (MSE)', fontsize=13, fontweight='bold') # 纵轴报告各次十折CV误差
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.4.4 交叉验证在时间序列(金融数据)中的特殊性
在经典统计学习中,我们通常假设观测数据是独立同分布(i.i.d)的。然而,在金融领域的实证研究中(例如我们在本章预测海康威视的股票收益),数据通常是具有序列自相关的时间序列(Time Series)。如果我们在时间序列数据上直接使用标准的 k 折交叉验证(即在切分数据前随机打乱观测序列的顺序),会导致严重的数据泄露(Data Leakage)。
数据泄露的原因与后果: 在金融经济学预测中,目标通常是使用历史信息推断未来状态。若先随机打乱时间序列再做交叉验证,训练折可能含有晚于验证观测的信息,因而不再模拟实际部署时的信息集。序列相关和状态持续性还可能使相邻时期同时进入训练折与验证折,造成过于乐观的误差估计;偏差的方向与大小取决于数据生成过程、特征窗口和切分方式,不能预先断言。
前向交叉验证 (Walk-Forward / Time Series Split CV): 对于部署目标为未来时期的时间序列或投资组合面板任务,验证切分通常应遵循时间先后顺序。常见做法包括:
- 滚动窗口 (Rolling Window):维持一个固定长度的时间窗口(如过去500个交易日)在时间轴上向右滑动,使用窗口内数据重新训练模型,然后在紧随其后的固定交易日度(如未来20天)进行样本外评估。
- 扩张窗口 (Expanding Window):训练集的起点固定,终点随着时间的推移不断向右推进,容纳所有已知的历史观测值,同时在增量时间步的数据上进行验证。
在本书所有金融预测实证中,前向交叉验证是默认评估设计;具体选择扩张还是滚动窗口,应与部署时可用历史长度和结构变化假设一致。
5.4.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 转换,是因为本章从头到尾都在寻找“误差最小”的模型复杂度。若直接画准确率曲线,虽然信息完全等价,但和前面回归问题中“误差越低越好”的视觉语言不一致;改成错误率后,就能更直观地比较“训练误差持续下降、验证误差先降后升”的过拟合模式。
从算法视角看,这个实验同时回答了两个问题:
- 模型能否在训练样本上画出更复杂的边界? 能,所以训练误差通常越来越低;
- 这种复杂边界能否迁移到新样本上? 不一定,所以验证误差可能在某个阶数之后重新上升。
这正是交叉验证的价值所在:它不是在问“模型能不能把已知样本背下来”,而是在问“模型复杂起来以后,面对没见过的数据还能不能保持判断力”。
# ============================================================
# 加载概率分类器与零一损失,用于比较前向验证和样本内拟合
# ============================================================
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) # 将正收益编码为1供分类示例使用
# 构造两个特征: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:] # 保留三个字段齐全的最近500个交易日
# direction_features:用于建模的自变量矩阵(每行两个特征:Lag1和Lag2)
direction_features = full_analysis_data[['Lag1', 'Lag2']].values # 形成两列滞后收益的分类特征矩阵
# 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_1409817/980304886.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.5 自助法 (The Bootstrap)
自助法(bootstrap)通过对已有样本重复重采样,近似特定统计量在明确抽样机制下的不确定性。它可用于估计线性回归系数等规则统计量的标准误,也可用于解析方差难以获得的方法;是否有效仍取决于样本代表性、依赖结构和统计量的规则性。
在本节中,我们通过一个简单的例子来说明自助法,在这个例子中,我们希望在一个简单模型下确定最佳投资分配。在 小节 5.5.2 中,我们探索使用自助法来评估线性模型中回归系数的变异性。
5.5.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_value = os.environ.get('BOOK_DATA_DIR') # 独立读取本案例所需的数据根配置
assert bootstrap_data_root_value, '请先设置 BOOK_DATA_DIR,使其指向包含 stock/ 子目录的数据根' # 缺失时给出修复方向
bootstrap_data_root = Path(bootstrap_data_root_value).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' # 拼接前复权股价数据文件完整路径
assert stock_data_path.is_file(), f'缺少前复权行情文件: {stock_data_path}' # 在两次选择性读取前核对文件
# 分别按股票代码选择性读取两只股票的收盘价序列,避免全量载入全部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 # 取得同一重采样索引下的第一资产收益
# 根据给定索引取出资产Y(宁波银行)的收益率子集
asset_y_subset = input_returns_data.iloc[sample_indices, 1].values # 配对抽取第二资产收益以保留同期相关
# 计算资产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\)的标准误差,并将其与一个依赖正态分布假设的参数化参考分布并列展示。两者回答的是条件不同的问题,图形接近不能证明 Bootstrap 恢复了未知的真实抽样分布。
# 第四步:用原始完整样本计算α的点估计
# ============================================================
# range(observation_count)表示使用全部100天的数据(不做任何重采样)
estimated_alpha = alpha_func(daily_returns_df, range(observation_count)) # 计算原始完整样本的α点估计
print(f'Original Alpha Estimate: {estimated_alpha:.4f}') # 输出原始估计值
# 该样本点估计描述无额外约束的双资产最小方差权重,不构成配置建议
# ============================================================
# 第五步:执行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) # 转为数组以计算分位数区间和标准误
# 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() # 用样本协方差矩阵估计总体参数
parametric_reference_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)) # 计算模拟样本的α
parametric_reference_alpha_estimates.append(current_simulated_alpha) # 追加到参数化参考结果
# 将参数化机制重复结果整理为可计算分位数的向量
parametric_reference_alpha_estimates = np.array(parametric_reference_alpha_estimates) # 保存每次机制重复得到的alpha估计
# ============================================================Original Alpha Estimate: 0.3322
Bootstrap SE: 0.0676
上述 Bootstrap 自助法运行结果输出了两个关键数值:
- 原始 \(\alpha\) 点估计值:它反映了在当前 100 个交易日样本下、使双资产组合方差最小的海康威视配置比例。若该值约为三分之一,就意味着组合中的大部分权重仍会分配给宁波银行,从而借助两只股票收益协动关系来压低整体波动。
- Bootstrap 标准误:经过 300 次连续块重采样后,\(\hat{\alpha}\) 的标准差描述该抽样方案下权重估计的不确定性量级。标准误越大,说明样本权重对重采样越敏感;这一结果不包含交易成本、参数约束或未来分布变化,因此不直接构成配置建议。
接下来并列绘制块 Bootstrap 与参数化模拟参考。两者接近与否应从当次图形判断;参数化参考不是真实总体分布。
# 第七步:绘制三张对比图
# ============================================================
# figsize=(18,6):宽18英寸×高6英寸的画布,包含3个子图
fig, axes = plt.subplots(1, 3, figsize=(18, 6)) # 并列呈现单次样本、重采样与机制实验分布
# ---------- 左图:多元正态参数化参考分布 ----------
# 该分布条件于样本矩与正态假设,仅作为机制比较参照
# 绘制模拟抽样分布的直方图(40个bins)
axes[0].hist(parametric_reference_alpha_estimates, bins=40, alpha=0.7, color='#3498DB', edgecolor='black') # 蓝色直方图显示参数化参考分布
# 用红色虚线标记分布的均值
axes[0].axvline(np.mean(parametric_reference_alpha_estimates), color='red', linestyle='--', label=f'Mean: {np.mean(parametric_reference_alpha_estimates):.3f}') # 红色虚线标记参考分布均值
axes[0].set_xlabel('Alpha', fontsize=12, fontweight='bold') # 横轴为资产配置统计量的参数化参考估计
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') # 横轴为移动块bootstrap重估的统计量
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([parametric_reference_alpha_estimates, bootstrap_alpha_estimates], labels=['Parametric reference', 'Block 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_1409817/4116341511.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([parametric_reference_alpha_estimates, bootstrap_alpha_estimates], labels=['Parametric reference', 'Block bootstrap'], patch_artist=True,
图 5.7 并列展示两种条件于当前样本与建模选择的近似分布:
左图:显示从以样本矩拟合的多元正态参考模型生成 300 个数据集后得到的 \(\alpha\) 分布。它依赖正态性与参数估计,不是真实总体抽样分布。
中图:显示从单个时间序列样本生成 300 个连续块自助样本后得到的 \(\alpha\) 分布。块长 10 日是建模选择,应做敏感性分析。
右图:比较两种条件分布。只有当位置和离散程度在本次输出中确实接近时,才能说结果在该参数化参考下相容;差异则提示分布假设、序列依赖或块长选择值得检查。
自助法的原理:
在实践中,我们通常不能从目标总体反复取得独立数据集。自助法改为从当前样本按预先说明的单位重采样,并以统计量在这些重采样样本间的变化近似其抽样不确定性。
我们不是从总体重复获得独立的数据集,而是通过重复从原始数据集中有放回地抽样观测来获得不同的数据集。
补充说明:样本分布接近总体不自动保证 bootstrap 有效
即使经验分布能够逼近总体分布,非线性的统计量也可能放大尾部、边界或不连续点附近的微小差异。因此,bootstrap 结论必须同时核对抽样单位与统计量规则性。均值等平滑统计量通常较容易处理;样本极值、边界参数、非唯一分位点或分布跳点则可能需要专门方法。这里不把任何单一收敛定理解释为对所有统计量的统一保证。
数学推导:观测点出现在自助样本中的概率
我们经常提及自助样本大约包含了原始数据中的 \(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%) 的概率出现在这个自助样本中。
那些未进入某一次自助样本的观测称为该次拟合的袋外数据 (Out-of-Bag, OOB)。约 \(36.8\%\) 是单个观测在单次普通 IID bootstrap 中落袋外的概率,不代表存在一个固定的 36.8% 验证集。计算聚合 OOB 误差时,对每个观测 \(i\) 只汇总那些未抽中 \(i\) 的拟合所给出的预测,再把各观测损失平均;不能把不同重复的袋外行直接拼接后当作相互独立的测试样本。对时间依赖数据,普通逐行 OOB 仍会破坏时间结构,不能替代前向评价。
5.5.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}\)的标准误差的估计。
自助法的适用条件与局限
自助法的有效性具有明确边界:
有限样本误差:样本很小时,经验分布对总体尾部和局部密度的近似可能很差,bootstrap 标准误与区间覆盖率也会不稳定。
统计量必须足够规则:均值和分布局部平滑处的唯一分位数通常较易重采样;样本极值、边界参数、非唯一分位点或分布跳点可能使普通 bootstrap 失效,需要针对目标量另行设计推断。
依赖结构必须保留:普通逐行 bootstrap 以 IID 或可交换观测为基础。对较稳定且依赖较弱的时序,可比较移动块或平稳 bootstrap,并报告块长敏感性;结构突变、趋势或季节制度变化需要相应的建模或局部重采样设计。
原样本代表性与计算成本:重采样不能修复覆盖不足、选择偏差或错误测量;复杂模型的大量重复拟合也可能成为实际限制。
5.6 本章小结 (Chapter Summary)
本章介绍两类常用重采样方法:交叉验证和自助法。
5.6.1 交叉验证 (Cross-Validation)
- 验证集方法:简单直观,但估计的变异性高且可能高估测试误差。
- 留一交叉验证(LOOCV):偏差小,结果确定,但计算成本高且方差较大。
- k折交叉验证:
- 在偏差和方差之间提供了良好的权衡
- 计算效率高
- 通常使用\(k=5\)或\(k=10\)
- 分类问题的交叉验证:使用分类错误率而不是MSE来评估性能。
5.6.2 自助法 (The Bootstrap)
- 核心思想:通过有放回地从原始数据集抽样来模拟从总体抽样的过程。
- 主要用途:
- 估计参数的标准误差
- 构建置信区间
- 评估统计学习方法的变异性
- 优点:
- 对许多规则统计量可减少解析分布推导
- 分布假设可以较弱,但仍依赖抽样代表性、独立或依赖结构以及统计泛函的正则性
- 实现简单
- 局限性:
- 小样本时可能有偏差
- 对某些统计量可能不适用
- 计算成本较高
5.6.3 实际应用建议
- IID 横截面:可用随机 \(k\) 折或 LOOCV,但预处理必须在折内拟合。
- 分组/聚类数据:按公司、客户或其他独立单位分折,避免同组信息跨折。
- 面板预测:同时核对公司聚类与时间方向,根据部署对象选择分组前向设计。
- 时间序列:用扩张或滚动窗口前向验证,并按标签跨度设置 purge/gap;序列不确定性量化使用 moving-block 或 stationary bootstrap。
重采样方法为模型评价和不确定性量化提供了可复核工具,但所得结论只在重采样单位、依赖结构与部署目标相匹配时成立。
5.7 理论来源与前沿
交叉验证与自助法的理论根基是用‘重复抽样’逼近‘重复实验’:当我们无法无限次从总体抽样时,就用数据自身构造近似的重复样本来估计泛化误差或统计量的不确定性。交叉验证的预测选择框架可追溯至 Stone (Stone 1974年);Efron 的 bootstrap 把重抽样思想系统化 (Efron 1979年)。这些经典结果不适用于把有时间依赖的数据任意打乱;本章的前向切分与 purge 用于匹配当前任务的标签窗口。
近年来的研究与实践重点包括:
- 模型选择偏差:当我们用同一套交叉验证同时做模型选择与误差估计时,误差会偏乐观,需要嵌套交叉验证或独立测试集。
- 时间序列与面板数据的重采样:需要 block bootstrap、滚动窗口 CV 等结构化方法以保留相关性。
- 大规模计算下的近似:在算力受限时,用更少折数、分层抽样与并行化来取得工程上可用的近似。
5.8 综合案例:前向交叉验证
本案例把重采样用于真实的金融时序评价,并严格沿用 小节 2.7 的公司—预测日样本。为保证从干净内核运行,下面在本章内定义并调用同一 BOOK_DATA_DIR 构造器;特征、真实峰谷最大回撤标签、日期边界和四项返回对象均与第 2 章一致,不读取外部辅助脚本。学生只在开发期构造扩张窗口折,并保证每一折的拟合标签期结束于评分期开始之前。
先定义前向最大回撤函数。对路径 \(P_0,\ldots,P_{20}\),先计算运行峰值 \(H_j=\max_{0\le k\le j}P_k\),再取 \(\min_j(P_j/H_j-1)\)。因此价格路径 \(100\to120\to105\) 的最大回撤是 \(105/120-1=-12.5\%\),即使终点仍高于预测日价格,也必须记为事件。
展开共享样本的峰谷回撤函数
import os # 读取跨平台数据根环境变量
from pathlib import Path # 用路径对象安全拼接本地文件
import numpy as np # 构造有限特征和时间分段
import pandas as pd # 处理公司—日期面板
def calculate_forward_max_drawdown(price_values, horizon_days=20): # 计算含预测日在内的前向峰谷最大回撤
price_array = np.asarray(price_values, dtype=float) # 转为连续数值数组以构造滑动路径
window_size = horizon_days + 1 # 路径包含t日与其后horizon_days个交易日
drawdowns = np.full(price_array.size, np.nan, dtype=float) # 末端窗口不完整时保持未知
if price_array.size < window_size: # 保护短序列而不虚构标签
return drawdowns # 返回全缺失结果表示没有完整路径
price_windows = np.lib.stride_tricks.sliding_window_view(price_array, window_size) # 构造每个预测日的完整价格路径
running_peaks = np.maximum.accumulate(price_windows, axis=1) # 逐路径记录每一时点之前的最高价
drawdowns[:price_windows.shape[0]] = np.min(price_windows / running_peaks - 1, axis=1) # 取峰值后最深谷值跌幅
return drawdowns # 返回与原价格序列等长的前向最大回撤
m02_fixture_drawdown = calculate_forward_max_drawdown([100, 120, 105], horizon_days=2)[0] # 用先涨后跌路径区分峰谷回撤与起点收益
assert np.isclose(m02_fixture_drawdown, -0.125) # 核对峰值120到谷值105的跌幅为百分之十二点五
assert m02_fixture_drawdown <= -0.10 # 核对该路径必须被判为百分之十回撤事件下面把数据读取、预测日特征和峰谷标签封装为一个准备函数。固定公司名单是教学研究边界,不是事后按收益挑出的“优胜者”;return_1d 等特征均以 \(t\) 日结束,只有标签计算向未来展开。
展开共享样本的数据准备函数
def prepare_m02_price_panel(book_data_dir): # 从统一数据根构造未切分的公司—预测日样本
data_root = Path(book_data_dir).expanduser().resolve() # 同时接受环境变量字符串或Path对象
assert data_root.is_dir(), f'BOOK_DATA_DIR 不是有效目录: {data_root}' # 在读取前验证数据挂载
price_path = data_root / 'stock/stock_price_post_adjusted.h5' # 指向后复权日行情
assert price_path.is_file(), f'缺少后复权行情文件: {price_path}' # 在HDF读取前给出明确文件提示
company_ids = ('600104.XSHG', '002415.XSHE', '600276.XSHG', '002230.XSHE') # 固定四家长三角教学公司
price_frames = [pd.read_hdf(price_path, where=f'order_book_id={company_id!r}', columns=['close', 'volume']).reset_index() for company_id in company_ids] # 在存储层选择所需公司和字段
price_panel = pd.concat(price_frames, ignore_index=True) # 合并为公司—交易日面板
price_panel['prediction_date'] = pd.to_datetime(price_panel['date']) # 把交易日定义为收盘后预测原点
price_panel = price_panel.loc[price_panel['prediction_date'].between('2012-01-01', '2024-12-31')].copy() # 固定研究观察期
price_panel = price_panel.sort_values(['order_book_id', 'prediction_date']).reset_index(drop=True) # 保证窗口只在公司内按时间推进
grouped_prices = price_panel.groupby('order_book_id', sort=False) # 建立公司内窗口边界
price_panel['return_1d'] = grouped_prices['close'].transform(lambda prices: prices.pct_change(fill_method=None)) # 计算截至预测日的一日收益
price_panel['momentum_5d'] = grouped_prices['close'].transform(lambda prices: prices.pct_change(5, fill_method=None)) # 计算截至预测日的五日动量
price_panel['volatility_20d'] = grouped_prices['return_1d'].transform(lambda returns: returns.rolling(20, min_periods=20).std()) # 计算过去二十日波动率
volume_mean_20d = grouped_prices['volume'].transform(lambda volume: volume.rolling(20, min_periods=20).mean()) # 估计预测日前成交量常态
price_panel['volume_ratio_20d'] = price_panel['volume'] / volume_mean_20d # 表示当日成交量相对过去均值的活跃度
feature_names = ['return_1d', 'momentum_5d', 'volatility_20d', 'volume_ratio_20d'] # 固定跨章特征名称与顺序
price_panel['max_drawdown_20d'] = grouped_prices['close'].transform(lambda prices: calculate_forward_max_drawdown(prices, horizon_days=20)) # 计算t至t+20真实峰谷最大回撤
price_panel['label_end_date'] = grouped_prices['prediction_date'].shift(-20) # 记录未来第二十个公司交易日
price_panel['drawdown_event_20d'] = price_panel['max_drawdown_20d'].le(-0.10).where(price_panel['max_drawdown_20d'].notna()).astype('Int64') # 完整路径才生成二元事件
price_panel = price_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=feature_names + ['max_drawdown_20d', 'drawdown_event_20d', 'label_end_date']).copy() # 排除无效特征和未完成路径
return price_panel, feature_names # 把未切分样本交给公共构造函数公共函数负责固定切分、边界断言和四项跨章交付。任何章节在干净内核中都可以用 build_m02_shared_sample(BOOK_DATA_DIR) 重建同一对象,不需要依赖前一章的内存状态。
展开共享样本的时间切分函数
def build_m02_shared_sample(book_data_dir): # 返回第2至第9章唯一的共享分析对象
price_panel, feature_names = prepare_m02_price_panel(book_data_dir) # 从统一数据根重建真实峰谷标签
validation_start = pd.Timestamp('2020-01-01') # 固定验证段起点
test_start = pd.Timestamp('2022-01-01') # 固定封存测试段起点
study_end = pd.Timestamp('2024-12-31') # 固定研究终点
train_mask = (price_panel['prediction_date'] < validation_start) & (price_panel['label_end_date'] < validation_start) # 清除伸入验证期的训练标签
validation_mask = price_panel['prediction_date'].between(validation_start, test_start, inclusive='left') & (price_panel['label_end_date'] < test_start) # 清除伸入测试期的验证标签
test_mask = price_panel['prediction_date'].between(test_start, study_end, inclusive='both') # 标记只供第九章最终评价的日期
price_panel['split'] = np.select([train_mask, validation_mask, test_mask], ['train', 'validation', 'test'], default='unused') # 赋予互斥时间段
shared_columns = ['order_book_id', 'prediction_date', 'label_end_date', *feature_names, 'max_drawdown_20d', 'drawdown_event_20d', 'split'] # 复用第2章权威字段顺序
shared_sample = price_panel.loc[price_panel['split'] != 'unused', shared_columns].copy() # 排除边界隔离行并形成共享对象
assert list(shared_sample.columns) == shared_columns # 阻止同名对象发生字段或顺序漂移
assert not shared_sample.duplicated(['order_book_id', 'prediction_date']).any() # 验证公司—预测日键唯一
assert np.isfinite(shared_sample['max_drawdown_20d']).all() and shared_sample['max_drawdown_20d'].le(0).all() # 验证连续目标有限且非正
assert shared_sample['drawdown_event_20d'].astype(bool).eq(shared_sample['max_drawdown_20d'].le(-0.10)).all() # 验证二元标签由连续目标唯一派生
assert set(shared_sample['drawdown_event_20d'].unique()) == {0, 1} # 验证二元标签包含两类
assert shared_sample.loc[shared_sample['split'] == 'train', 'label_end_date'].max() < validation_start # 验证训练标签不触及验证期
assert shared_sample.loc[shared_sample['split'] == 'validation', 'label_end_date'].max() < test_start # 验证验证标签不触及测试期
training_event_rate = float(shared_sample.loc[shared_sample['split'] == 'train', 'drawdown_event_20d'].mean()) # 只用训练标签估计概率基线
test_keys = shared_sample.loc[shared_sample['split'] == 'test', ['order_book_id', 'prediction_date']].copy() # 固定第九章最终比较键
return shared_sample, feature_names, training_event_rate, test_keys # 返回约定的四项跨章交付下面只保留构造器调用为默认可见代码;缺少数据根时,提示直接说明需要设置的环境变量。
book_data_dir_value = os.environ.get('BOOK_DATA_DIR') # 读取显式配置的数据根
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向本书本地数据根目录' # 提供可执行的配置提示
BOOK_DATA_DIR = Path(book_data_dir_value).expanduser().resolve() # 解析当前数据根
m02_shared_sample, m02_features, m02_training_event_rate, m02_test_keys = build_m02_shared_sample(BOOK_DATA_DIR) # 在当前干净内核构造共享对象
m05_expected_columns = ['order_book_id', 'prediction_date', 'label_end_date', *m02_features, 'max_drawdown_20d', 'drawdown_event_20d', 'split'] # 声明第2章连续与二元目标的有序交接模式
assert list(m02_shared_sample.columns) == m05_expected_columns # 在重采样前阻止字段、目标或顺序漂移本章的前向折只覆盖训练段和验证段;m02_test_keys 只保存最终比较身份,测试标签和成绩均不进入重采样选择。
交付内容包括各折训练/评分日期、训练期事件率基线、候选模型的逐折 Brier 分数,以及一段解释随机分折为何会泄漏未来信息的文字。若数据日期不足以形成至少三个前向折,应说明限制,不得改用随机切分冒充时间验证。
5.9 练习
5.9.1 概念题
[核心|难度:1|分值:10|任务:独立] 比较验证集方法、LOOCV 与 \(k\) 折交叉验证:它们在偏差、方差与计算成本上的主要差异是什么?
[核心|难度:2|分值:15|任务:独立] 为什么在时间序列或金融数据中,随机打乱的交叉验证可能产生严重的数据泄露?给出一个具体例子。
[核心|难度:1|分值:10|任务:独立] 什么是嵌套交叉验证?它解决了什么偏差问题?
[核心|难度:2|分值:15|任务:独立] bootstrap 估计标准误与置信区间的核心假设是什么?在什么情况下 bootstrap 可能失败?
[核心|难度:1|分值:10|任务:独立] 当模型选择与误差评估使用同一份数据时,为什么测试误差往往被低估?
5.9.2 应用题
[核心|难度:3|分值:40|任务:综合案例] 构造训练内前向折:直接使用第 2 章交付的
m02_shared_sample,保持order_book_id—prediction_date键、m02_features、label_end_date与二元目标drawdown_event_20d不变,不重新筛选公司或日期。在开发期内构造至少三个扩张窗口折,逐折报告拟合期与评分期日期、训练事件率基线和候选分类器的 Brier 分数,并验证 \(\max(\text{fit.label\_end\_date})<\min(\text{score.prediction\_date})\)。随机五折只用于说明打乱时间的后果,不作为金融时序主评价。[拓展|难度:2|分值:20|任务:独立] 选择一家长三角上市公司,估计其日收益率均值的 95% 置信区间:
- 方法一:正态近似(用样本均值与标准误)
- 方法二:bootstrap 百分位区间
比较两种区间的长度与稳定性。
- [拓展|难度:3|分值:25|任务:独立] 在一个包含超参数的模型(例如岭回归或 Lasso)上,分别用普通 \(k\) 折 CV 与嵌套 CV 做模型选择,比较得到的测试误差估计是否存在系统偏差。
5.9.3 理论题
[拓展|难度:3|分值:20|任务:独立] 在 LOOCV 中,证明每次训练集只少一个样本,因此 LOOCV 的训练集偏差通常较小,但方差可能较大。
[拓展|难度:2|分值:15|任务:独立] 给出 bootstrap 百分位区间的定义,并解释为什么它在分布偏斜或统计量非正态时可能优于正态近似区间。
5.10 练习参考解答
展开第 5 章完整解答与评分键
统一评分与验收说明:按题面元数据分值计分;重采样单位、折日期、purge 断言、基线/模型输出和结论边界均为独立评分点。MSE 与区间端点相对容差 \(10^{-6}\);bootstrap 固定种子后允许因数值库版本导致 \(10^{-4}\) 相对差,折日期与边界不允许容差。常见失败包括时序随机分折、未保存 label_end_date、训练标签跨入评分期、序列使用 IID bootstrap,或把两个不同测试总体的单次 MSE 差称为泄漏幅度。
5.10.1 概念题参考解答
- 三种 CV 的差异
- 验证集法:最快,但对一次切分很敏感(方差大)。
- LOOCV:偏差小,但每次训练集几乎相同,预测误差高度相关,方差可能较大,且对某些模型计算成本高。
- \(k\) 折 CV:在偏差与方差之间折中,工程上最常用(常见 \(k=5\) 或 \(k=10\))。
- 时间序列的泄露
若把 2024 年的数据随机分到训练集,而 2020 年的数据分到测试集,模型会在训练阶段‘见过未来’的分布与模式,从而高估测试性能。
- 嵌套 CV 的作用
外层用于估计泛化误差,内层用于选择超参数。这样避免‘用同一份数据既选模型又评估’导致的乐观偏差。
- bootstrap 的假设与失效
普通逐行 bootstrap 要求原样本能够代表目标总体,并通常以 IID 或可交换观测为起点。分布局部平滑处的唯一分位数通常较易重采样;样本极值、边界参数、非唯一分位点或分布跳点可能失败。相关时序应使用保留局部顺序的块方法并检查块长敏感性;结构突变或趋势不能靠随机抽块自动修复。
- 误差低估原因
模型选择过程本身会把噪声当作信号(对训练数据‘过度适配’),因此若评估不独立,会系统性低估测试误差。
5.10.2 应用题参考解答
- 构造训练内前向折
使用本章从 BOOK_DATA_DIR 重建的 m02_shared_sample,不得因重采样方法重新选择证券、时期或目标。保留 order_book_id—prediction_date 键、m02_features、label_end_date 与 drawdown_event_20d。按预测日期排序后,第 \(j\) 折只使用评分块之前且 20 个公司交易日标签窗已经结束的观测拟合模型。对每折分别计算训练事件率基线和候选分类器的 Brier 分数,最后报告均值与标准差。
完整得分点如下:日期排序与样本单位 6 分;严格标签期边界 10 分;至少三个扩张窗口折 8 分;训练基线与候选模型逐折误差 10 分;解释随机折泄漏及结论边界 6 分。若某折只有一个类别,应删除该折并说明原因;不得移动日期边界来制造可运行结果。
预期结果不是某个固定模型获胜,而是一张逐折表。表中至少包含 fold、fit_end、score_start、score_end、event_rate、baseline_brier 和 model_brier。只有当候选模型在多个连续未来块上稳定优于训练事件率基线时,才能说它提供了增量预测证据。
表 5.2 的紧凑实现只使用训练与验证行,按三个预先给定的日期分位点形成扩张窗口。每折的标签结束日断言是结果可信的必要组成,而不是可省略的运行检查。
from sklearn.linear_model import LogisticRegression # 使用合法概率分类器作为固定候选
from sklearn.metrics import brier_score_loss # 比较逐折概率平方误差
from sklearn.pipeline import make_pipeline # 把折内预处理与模型绑定
from sklearn.preprocessing import StandardScaler # 仅用每折拟合期估计尺度
m05_development_sample = m02_shared_sample.loc[m02_shared_sample['split'].isin(['train', 'validation'])].sort_values('prediction_date').copy() # 排除封存测试段
m05_development_dates = np.array(sorted(m05_development_sample['prediction_date'].unique())) # 以唯一预测日期定义时间折
m05_score_starts = [pd.Timestamp(m05_development_dates[int(len(m05_development_dates) * fraction)]) for fraction in (0.55, 0.70, 0.85)] # 预先固定三个评分起点
m05_score_ends = m05_score_starts[1:] + [pd.Timestamp(m05_development_dates[-1]) + pd.Timedelta(days=1)] # 形成互不重叠评分块
m05_fold_records = [] # 收集逐折边界与损失
for m05_fold, (m05_score_start, m05_score_end) in enumerate(zip(m05_score_starts, m05_score_ends), start=1): # 依次推进扩张窗口
m05_fit_sample = m05_development_sample.loc[(m05_development_sample['prediction_date'] < m05_score_start) & (m05_development_sample['label_end_date'] < m05_score_start)].copy() # 清除跨入评分期的标签
m05_score_sample = m05_development_sample.loc[m05_development_sample['prediction_date'].between(m05_score_start, m05_score_end, inclusive='left')].copy() # 取得当折未来评分块
assert m05_fit_sample['label_end_date'].max() < m05_score_sample['prediction_date'].min() # 验证标签期严格早于评分期
m05_event_rate = float(m05_fit_sample['drawdown_event_20d'].mean()) # 只用当折拟合标签估计常数概率
m05_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)) # 为当折建立独立训练管道
m05_model.fit(m05_fit_sample[m02_features], m05_fit_sample['drawdown_event_20d'].astype(int)) # 仅在当折拟合期训练
m05_probability = m05_model.predict_proba(m05_score_sample[m02_features])[:, 1] # 生成当折合法概率
m05_fold_records.append({'fold': m05_fold, 'fit_end': m05_fit_sample['prediction_date'].max(), 'score_start': m05_score_sample['prediction_date'].min(), 'score_end': m05_score_sample['prediction_date'].max(), 'event_rate': m05_event_rate, 'baseline_brier': brier_score_loss(m05_score_sample['drawdown_event_20d'], np.repeat(m05_event_rate, len(m05_score_sample))), 'model_brier': brier_score_loss(m05_score_sample['drawdown_event_20d'], m05_probability)}) # 记录每折时间边界及模型相对事件率基线的Brier误差
m05_forward_table = pd.DataFrame(m05_fold_records) # 汇总逐折结果
print(m05_forward_table.to_string(index=False)) # 输出边界、基线与模型损失 fold fit_end score_start score_end event_rate baseline_brier model_brier
1 2017-05-18 2017-06-20 2018-11-28 0.267492 0.255593 0.246012
2 2018-10-31 2018-11-29 2020-06-17 0.289644 0.217139 0.216009
3 2020-05-20 2020-06-18 2021-12-03 0.299294 0.219247 0.192250
先逐折确认 fit_end < score_start 且标签终点断言成立,再比较 model_brier 与 baseline_brier。单折改善不足以支持方法优胜;若改善只出现在一个市场阶段,应报告不稳定,而不是用全样本平均掩盖时间异质性。
- 收益均值置信区间
- HAC 区间:对收益均值的截距回归使用 Newey–West 型 HAC 协方差;这里事前固定最大滞后为 20 个交易日。
- 移动块 bootstrap:事前比较 5、10、20 日块长,并分别用 500 与 2000 次重复检查百分位区间的 Monte Carlo 波动。块长和重复次数都不得在看到最窄区间后再选择。
import numpy as np # 生成块重采样索引并计算收益均值的经验分位数
import pandas as pd # 汇总块长与重复次数对应的区间敏感性结果
import os # 读取全书统一数据根
import statsmodels.api as sm # 用截距回归计算HAC均值区间
from pathlib import Path # 构造跨平台行情路径
exercise7_data_root_value = os.environ.get('BOOK_DATA_DIR') # 独立读取练习所需的数据根配置
assert exercise7_data_root_value, '请先设置 BOOK_DATA_DIR,使其指向包含 stock/ 子目录的数据根' # 给出可执行的环境配置提示
exercise7_data_root = Path(exercise7_data_root_value).expanduser().resolve() # 使用统一数据根
assert exercise7_data_root.is_dir(), f'BOOK_DATA_DIR 不存在: {exercise7_data_root}' # 在读取前验证数据目录
exercise7_price_path = exercise7_data_root / 'stock' / 'stock_price_post_adjusted.h5' # 定位后复权行情
assert exercise7_price_path.is_file(), f'缺少后复权行情文件: {exercise7_price_path}' # 在HDF读取前验证文件
exercise7_prices = pd.read_hdf( # 选择性读取海康威视真实行情
exercise7_price_path, # 指定本地HDF文件
where="order_book_id='002415.XSHE'", # 固定长三角案例公司
columns=['date', 'order_book_id', 'close'], # 仅读取收益计算所需列
).reset_index() # 恢复日期与证券索引列
exercise7_prices = exercise7_prices.sort_values('date').drop_duplicates('date') # 固定交易日顺序
target_returns_array = exercise7_prices['close'].pct_change().dropna().to_numpy(dtype=float) # 计算日收益率
sample_size = target_returns_array.size # 获取样本量
assert sample_size > 40, '有效收益率不足,无法使用20日HAC与块长敏感性网格' # 保证最大滞后和块长具有基本支持
sample_mean = target_returns_array.mean() # 计算样本均值
hac_lag_count = 20 # 事前固定约一个交易月的最大滞后
hac_mean_fit = sm.OLS(target_returns_array, np.ones((sample_size, 1))).fit(cov_type='HAC', cov_kwds={'maxlags': hac_lag_count}) # 用常数回归估计均值及HAC协方差
confidence_interval_hac = tuple(hac_mean_fit.conf_int(alpha=0.05)[0]) # 提取双侧95%HAC区间
print({'n': sample_size, 'mean': sample_mean, 'hac_maxlags': hac_lag_count, 'hac_ci': confidence_interval_hac}) # 输出样本与HAC推断证据{'n': 3788, 'mean': 0.0008456782473922387, 'hac_maxlags': 20, 'hac_ci': (0.00011794764681604471, 0.0015734088479684328)}
def moving_block_mean_interval(return_values, block_length_days, bootstrap_count, seed): # 封装固定块长与重复次数的一次敏感性计算
random_number_generator = np.random.default_rng(seed) # 为每个预设组合建立可复算随机流
observation_count = len(return_values) # 记录重抽样目标长度
block_count = int(np.ceil(observation_count / block_length_days)) # 计算每次需要拼接的连续块数
bootstrap_means = np.empty(bootstrap_count) # 预分配均值分布以控制内存
for bootstrap_index in range(bootstrap_count): # 逐次生成移动块伪序列
block_starts = random_number_generator.integers(0, observation_count - block_length_days + 1, size=block_count) # 有放回抽取合法块起点
sampled_indices = np.concatenate([np.arange(start, start + block_length_days) for start in block_starts])[:observation_count] # 拼接并截取原样本长度
bootstrap_means[bootstrap_index] = return_values[sampled_indices].mean() # 保存该伪序列的日均收益
interval = np.quantile(bootstrap_means, [0.025, 0.975]) # 取得百分位区间端点
return float(interval[0]), float(interval[1]) # 返回便于汇总的标量端点block_length_grid = [5, 10, 20] # 事前覆盖一周、两周与约一月交易块
bootstrap_count_grid = [500, 2000] # 事前比较教学预算与较低Monte Carlo误差预算
bootstrap_sensitivity_records = [] # 保存全部预设组合而不挑选最窄区间
for block_length_days in block_length_grid: # 遍历预注册块长
for bootstrap_count in bootstrap_count_grid: # 遍历预注册重复次数
interval_low, interval_high = moving_block_mean_interval(target_returns_array, block_length_days, bootstrap_count, seed=7000 + 10 * block_length_days + bootstrap_count) # 为组合生成确定性独立随机流
bootstrap_sensitivity_records.append({'block_length_days': block_length_days, 'bootstrap_count': bootstrap_count, 'ci_low': interval_low, 'ci_high': interval_high, 'ci_width': interval_high - interval_low}) # 记录区间与宽度
bootstrap_sensitivity_table = pd.DataFrame(bootstrap_sensitivity_records) # 形成完整敏感性表
print(bootstrap_sensitivity_table.to_string(index=False)) # 输出所有组合供比较块长与Monte Carlo波动 block_length_days bootstrap_count ci_low ci_high ci_width
5 500 0.000132 0.001580 0.001448
5 2000 0.000089 0.001576 0.001487
10 500 0.000049 0.001566 0.001517
10 2000 0.000126 0.001534 0.001408
20 500 0.000147 0.001589 0.001442
20 2000 0.000110 0.001566 0.001456
先比较同一重复次数下不同块长的端点,再比较同一块长下 500 与 2000 次重复的变化。若块长改变导致结论方向或区间宽度明显变化,应报告依赖建模敏感;若只在重复次数间轻微波动,则主要是有限 Monte Carlo 误差。HAC 与块 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.10.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)\,]. \] 当 bootstrap 能有效逼近 \(T\) 的抽样分布时,经验分位数可以反映偏斜,因而可能比对称正态近似更合适。这一判断仍要求目标量对样本扰动足够稳定。百分位法并不自动修正偏差或保证覆盖率;极值、边界参数、非唯一分位点或分布跳点等问题需要另行设计推断。
5.11 章末回顾
逐项自检:能否选择验证集、前向 CV 或嵌套 CV;能否为相关数据选择移动块 bootstrap;能否保持既定样本与切分规则;能否逐折核对标签终点。禁止对金融面板随机打乱作主评价,也禁止选择与误差估计共用同一层数据。常见误区是把 LOOCV 称为金标准、把 bootstrap 当自动修复依赖。
不看正文回答:嵌套 CV 分离哪两层任务?时间折为何按日期而非面板行号?为什么不同模型必须使用同一组时间折?迁移任务:为季度违约任务设计扩张窗口。下一章沿用相同的训练内折,进行正则化选参。
展开检索答案
内层负责选择、外层负责误差估计;按行号会把同一日期或未来公司行混入训练;共用时间折才能把性能差异归因于方法,而不是评价样本不同。学习完成后,应能解释时间方向、标签终点边界以及选择层与误差估计层为何必须分离。若仍有困难,可回到 小节 5.4.4 用月度需求重画扩张折,并在 小节 5.4.1 的双层示例中标出内层选模与外层估计。