In [1]:
Copied!
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
print('Libraries loaded')
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
In [2]:
Copied!
import matplotlib.pyplot as plt
# 1. 设置系统自带的中文字体(这里使用黑体 SimHei)
plt.rcParams['font.sans-serif'] = ['SimHei'] # 如果你想用微软雅黑,可以改成 ['Microsoft YaHei']
# 2. 解决更换字体后,负号(-)显示为方块的问题
plt.rcParams['axes.unicode_minus'] = False
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,并将此当作发现的"真信号"。
In [3]:
Copied!
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警告:以上显著策略都是随机噪音,在新数据上必然失败!')
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) 控制在 α 以内。
In [4]:
Copied!
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 校正几乎完全消除了随机噪音导致的虚假发现')
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,并且随着数据量增加两者不收敛。
In [5]:
Copied!
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()
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
In [ ]:
Copied!