1. 这节究竟解决什么实际问题?¶
6.4 过拟合的危险与 p-Hacking (The Dangers of Overfitting) 的具体任务是:检验参数搜索和 p-hacking 如何提高样本内结果并降低样本外可靠性。 本节将明确输入数据、计算过程、输出及其在后续研究中的用途。
2. 前置知识¶
掌握特征、标签、训练集/测试集和基本回测概念。 对公式的理解应包括变量定义、单位、时间索引和适用条件。
3. 数据说明¶
特征默认来自固定样本或模拟样本;每个标签必须标明预测期限及其可交易时间。 运行前先检查 shape、日期范围、列名与缺失值。数据看起来能画图,不代表它适合回答研究问题。
4. 学习目标与完成标准¶
完成本节后,你应能用自己的话解释 6.4 过拟合的危险与 p-Hacking (The Dangers of Overfitting) 的输入、计算步骤、输出和局限;尤其能说明:检验参数搜索和 p-hacking 如何提高样本内结果并降低样本外可靠性。 你还应能独立改动一个参数并解释结果为什么变化,指出至少一个会让结论失真的假设。
5. 核心概念与公式¶
机器学习模型从训练样本估计输入与目标之间的关系。训练集拟合程度不能代表样本外预测能力。
训练时只能看到训练区间;特征时间不得晚于决策时间,标签与测试区间必须隔离。 看到公式时依次确认:每个符号的单位是什么?它属于哪个时点?分母会不会为零?它是描述性统计量、预测量,还是交易规则?
6. 从零实现¶
原始代码按照“准备输入 → 计算中间量 → 得到结果 → 解释输出”的顺序组织。每个单元应检查变量类型、索引、维度和代表性数值。中间变量用于核对公式实现和定位计算错误。
7. 成熟库实现¶
sklearn/PyTorch 加速建模;管道、切分和经济解释仍由研究者负责。 使用库函数前先阅读参数含义、默认值和返回对象;库输出一个数字,并不说明这个数字适合你的交易假设。
8. 两种实现的差异¶
从零实现便于核对公式、调试和识别隐含假设;成熟库适合在计算逻辑已验证后复用。两种实现依赖相同的数据口径与时点假设,使用库函数不会改变输入数据的质量。
9. 预期结果与检查范围¶
你应看到维度正确的 Series/DataFrame、日期单调递增的索引,以及能支持“检验参数搜索和 p-hacking 如何提高样本内结果并降低样本外可靠性。”这一任务的表格或图形。金融日收益率均值通常接近 0;若出现极大的连续收益、空表、全 NaN 或索引错位,应先排查数据和对齐逻辑,而不是立即相信结果。
10. 常见错误与诊断¶
随机打乱时序、Scaler 在全样本 fit、目标泄漏、调参反复查看测试集、把准确率等同收益。
诊断顺序:先打印类型与 shape,再看 head()/tail(),接着检查日期和 NaN,最后才比较数值。这样能避免在错误的对象上反复改参数。
11. 研究偏差检查¶
时间泄漏、p-hacking、多重检验和样本选择会制造虚假的 Alpha;必须报告朴素基线。 结果至少应与一个简单基线比较,并明确哪些部分只是教学演示。
12. 三层练习与答案¶
- 基础练习:验证一个中间变量、补全一段小计算,并用一句话解释输出。
- 研究练习:改变窗口、样本区间、约束或标的,记录哪些结论仍成立。
- 开放项目:把本节方法嵌入模块项目,报告基线、失败结果和限制。
题目在 ../exercises/06_ml_trading/04_dangers_of_overfitting_exercises.ipynb;完整推导、可运行参考实现与结果检查在 ../solutions/06_ml_trading/04_dangers_of_overfitting_solutions.ipynb。请先独立作答,再打开答案。
13. 延伸阅读¶
延伸材料:scikit-learn 模型选择文档。阅读记录应包括研究假设、数据频率、样本范围和结论适用条件。
14. 下一节¶
下一节是 05_cross_validation。本节的概念会成为理解它的输入,而不是孤立的名词。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
print('Libraries loaded')
Libraries loaded
import matplotlib.pyplot as plt
# 1. 设置系统自带的中文字体(这里使用黑体 SimHei)
plt.rcParams['font.sans-serif'] = ['SimHei'] # 如果你想用微软雅黑,可以改成 ['Microsoft YaHei']
# 2. 解决更换字体后,负号(-)显示为方块的问题
plt.rcParams['axes.unicode_minus'] = False
1. 模拟 p-Hacking:测试 100 个随机策略¶
如果我们测试 100 个完全随机(无效)的策略,每个的 p 值检验使用 α=5%, 期望会有多少个"偶然"通过显著性检验?答案是 约 5 个。
这就是量化研究中最常见的 Data Mining Bias(数据挖掘偏差), 也称为 p-Hacking:不断调整参数直到 p < 0.05,并将此当作发现的"真信号"。
n_strategies = 100
n_obs = 252 # 一年数据
alpha_level = 0.05 # 显著性水平
p_values = []
for _ in range(n_strategies):
# 每个策略都是纯随机的(期望收益为0)
returns = np.random.normal(0, 0.01, n_obs)
t_stat, p_val = stats.ttest_1samp(returns, 0.0)
p_values.append(p_val)
p_values = np.array(p_values)
# 统计多少个随机策略"偶然"通过了显著性检验
false_positives = (p_values < alpha_level).sum()
print(f'测试了 {n_strategies} 个完全随机(无效)策略')
print(f'其中 {false_positives} 个 p-value < {alpha_level};这些结果来自无真实信号的模拟。')
print(f'虚假阳性率: {false_positives/n_strategies:.1%} (理论上约 {alpha_level:.0%})')
# 可视化 p 值分布
plt.figure(figsize=(10, 4))
plt.hist(p_values, bins=20, color='steelblue', alpha=0.7, edgecolor='white')
plt.axvline(alpha_level, color='red', linewidth=2, linestyle='--',
label=f'显著性阈值 (α={alpha_level})')
plt.xlabel('p-value')
plt.ylabel('策略数量')
plt.title(f'100 个随机策略的 p-value 分布(阈值以下:{false_positives})')
plt.legend()
print('\n说明:这些显著结果来自随机模拟,不能作为存在可重复信号的证据。')
测试了 100 个完全随机(无效)策略 其中 4 个 p-value < 0.05;这些结果来自无真实信号的模拟。 虚假阳性率: 4.0% (理论上约 5%) 说明:这些显著结果来自随机模拟,不能作为存在可重复信号的证据。
2. Bonferroni 校正:多重比较的自我救赎¶
当我们同时测试 K 个策略时,应该将每个测试的显著性阈值调整为:
$$\alpha_{adjusted} = \frac{\alpha}{K}$$
这样可以将整个测试集的家族错误率 (Family-Wise Error Rate) 控制在 α 以内。
K = n_strategies
alpha_bonferroni = alpha_level / K # Bonferroni 校正后的阈值
bonferroni_positives = (p_values < alpha_bonferroni).sum()
print(f'未校正(直接 α={alpha_level})显著策略数: {false_positives}')
print(f'Bonferroni 校正后 (α={alpha_bonferroni:.4f}) 显著策略数: {bonferroni_positives}')
print(f'\n결론:Bonferroni 校正几乎完全消除了随机噪音导致的虚假发现')
未校正(直接 α=0.05)显著策略数: 4 Bonferroni 校正后 (α=0.0005) 显著策略数: 0 결론:Bonferroni 校正几乎完全消除了随机噪音导致的虚假发现
3. 使用学习曲线检查过拟合¶
正确的回测框架中,你应该总是检查学习曲线:
- X 轴:训练数据量
- 蓝线:训练集上的 Sharpe
- 红线:验证集上的 Sharpe
过拟合的特征:训练集 Sharpe 远高于验证集 Sharpe,并且随着数据量增加两者不收敛。
def compute_sharpe(returns):
if returns.std() == 0:
return 0
return returns.mean() / returns.std() * np.sqrt(252)
# 生成一段真实有 Alpha 的数据 vs 完全随机的数据
n_total = 1000
train_sizes = range(50, 800, 50)
real_alpha_returns = np.random.normal(0.0005, 0.010, n_total) # 真实 Alpha
random_returns = np.random.normal(0.0000, 0.010, n_total) # 无效策略
train_sharpes_real, val_sharpes_real = [], []
train_sharpes_rand, val_sharpes_rand = [], []
for ts in train_sizes:
train_real, val_real = real_alpha_returns[:ts], real_alpha_returns[ts:]
train_rand, val_rand = random_returns[:ts], random_returns[ts:]
train_sharpes_real.append(compute_sharpe(train_real))
val_sharpes_real.append(compute_sharpe(val_real))
train_sharpes_rand.append(compute_sharpe(train_rand))
val_sharpes_rand.append(compute_sharpe(val_rand))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
ax1.plot(list(train_sizes), train_sharpes_real, 'b-o', ms=4, label='训练集 Sharpe')
ax1.plot(list(train_sizes), val_sharpes_real, 'r-o', ms=4, label='验证集 Sharpe')
ax1.axhline(0, color='gray', alpha=0.5)
ax1.set_title('真实 Alpha 策略的学习曲线(两者收敛)')
ax1.set_xlabel('训练集大小')
ax1.set_ylabel('Sharpe 比率')
ax1.legend()
ax2.plot(list(train_sizes), train_sharpes_rand, 'b-o', ms=4, label='训练集 Sharpe')
ax2.plot(list(train_sizes), val_sharpes_rand, 'r-o', ms=4, label='验证集 Sharpe')
ax2.axhline(0, color='gray', alpha=0.5)
ax2.set_title('无效(随机)策略的学习曲线(不收敛的过拟合)')
ax2.set_xlabel('训练集大小')
ax2.set_ylabel('Sharpe 比率')
ax2.legend()
plt.suptitle('学习曲线:检测策略是 Alpha 还是 Overfit', fontsize=13)
plt.tight_layout()
plt.show()
练习¶
- 将策略数量增至 1000 个重新模拟,Bonferroni 校正后仍然有多少个虚假阳性?
- 实现一个真实的 Walk-Forward 回测框架:将数据分为 5 段,每次用前 N 段训练、第 N+1 段验证。
- 查阅 FDR (False Discovery Rate) 控制方法(Benjamini-Hochberg 程序),相比 Bonferroni 有什么优势?
下一节 → 05_cross_validation.ipynb