13 多重检验

量化投资中的”假发现”陷阱

假设你测试了 1000 个量化因子,每次使用 \(\alpha = 0.05\) 的显著性水平:

  • 即使所有因子都无效\(H_0\) 全为真),期望的”显著”因子数 = \(1000 \times 0.05 = 50\)
  • 至少出现 1 个假阳性的概率:

\[\text{FWER} = 1 - (1 - \alpha)^m = 1 - (1-0.05)^{1000} \approx 100\%\]

核心问题:当同时进行大量假设检验时,第一类错误(假阳性)发生的概率急剧膨胀。

为什么“显著”不等于“真的有用”

刚接触统计检验时,容易把“显著”直接理解成“这个结论已经很可靠”。实际上,至少要区分三层含义:

  • 统计显著:结果不像纯噪声那样随机
  • 经济显著:效应大小足以覆盖交易成本、冲击成本和风险暴露
  • 样本外稳健:换一段数据、换一个市场后,结论仍大体成立

本章讨论的多重检验,主要是在修补第一层。当你一次测试很多候选因子时,原本看似显著的结果中会混入大量纯噪声。

为什么“单次 5%”不等于“整体 5%”

如果每次检验都使用 \(\alpha = 0.05\),那么至少出现 1 次假阳性的概率会随着检验次数快速上升:

检验次数 \(m\) 至少 1 次假阳性的概率
1 \(1 - (1-0.05)^1 = 5\%\)
10 \(1 - (1-0.05)^{10} \approx 40\%\)
100 \(1 - (1-0.05)^{100} \approx 99.4\%\)
1000 \(1 - (1-0.05)^{1000} \approx 100\%\)

所以,单次检验中的“5% 误报率”,绝不能直接理解为整轮筛选中的“5% 误报率”。

假设检验的基本框架回顾

\(H_0\) 为真 \(H_0\) 为假
不拒绝 \(H_0\) 正确接受 (U) 第二类错误 (T)
拒绝 \(H_0\) 第一类错误 (V) 正确拒绝 (S)

关键定义:

  • 第一类错误\(\alpha\)):拒绝了一个真实的原假设(假阳性)
  • 第二类错误\(\beta\)):未能拒绝一个虚假的原假设(假阴性)
  • 检验效力 (Power):\(1 - \beta\),正确拒绝虚假原假设的概率

p 值不是“原假设为真”的概率

很多初学者最容易误解的是 p 值的含义。

p 值真正表示的是

  • 在原假设 \(H_0\) 为真的前提下
  • 观察到当前样本,或更极端样本结果的概率

p 值并不表示

  • 原假设为真的概率
  • 这个因子未来继续有效的概率
  • 这个结果有多大的经济价值

因此,p 值小只能说明“这份样本与原假设不太相容”,并不自动推出“这个结论一定真实有效”。

两类错误的经济学含义

统计学的两类错误

实际上无效 \((H_0\) 为真\()\) 实际上有效 \((H_0\) 为假\()\)
判定”有效” I类错误(假阳性) ✅ 正确发现
判定”无效” ✅ 正确排除 II类错误(假阴性)

在量化投资中的代价

  • I类错误(假发现):把无效因子当作Alpha → 亏钱
  • II类错误(遗漏):错过真实Alpha → 少赚钱

非对称损失

  • 在学术发表中,I类错误代价更大(错误结论难以撤回)
  • 在量化投资中,I类错误直接导致资金损失
  • 因此,多重检验校正在金融中格外重要

为什么零假设为真时 p 值会近似均匀分布

当检验方法设定正确,而且 \(H_0\) 真的成立时,p 值会在 0 到 1 之间近似均匀分布:

\[ P(p \leq t) \approx t, \quad 0 \leq t \leq 1 \]

这意味着:

  • 约 1% 的 p 值会小于 0.01
  • 约 5% 的 p 值会小于 0.05
  • 约 10% 的 p 值会小于 0.10

所以,即使所有候选因子都无效,也会自然冒出一批“小 p 值”。

模拟实验:1000 个无效因子的检验

Code
import numpy as np  # 导入numpy用于数值计算
import matplotlib.pyplot as plt  # 导入绑图库
from scipy import stats  # 导入统计模块

plt.rcParams['font.family'] = ['Source Han Serif SC', 'Noto Serif CJK SC', 'SimSun']  # 设置中文字体优先级
plt.rcParams['axes.unicode_minus'] = False  # 解决负号显示问题

np.random.seed(42)  # 设置随机种子保证可重现
num_tests = 1000  # 假设检验次数(1000个因子)
null_test_statistics = np.random.standard_normal(num_tests)  # 从标准正态分布生成检验统计量(所有H0为真)
null_p_values = 2 * (1 - stats.norm.cdf(np.abs(null_test_statistics)))  # 计算双侧p值

num_false_positives = np.sum(null_p_values < 0.05)  # 统计p<0.05的假阳性个数

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))  # 创建1行2列子图

# 左图:p值直方图
axes[0].hist(null_p_values, bins=50, color='#008080', alpha=0.7, edgecolor='white')  # 绘制p值频率直方图
axes[0].axvline(x=0.05, color='#E3120B', linestyle='--', linewidth=2, label='α = 0.05')  # 标注显著性阈值
axes[0].set_xlabel('p 值')  # x轴标签
axes[0].set_ylabel('频次')  # y轴标签
axes[0].set_title(f'p值分布(假阳性: {num_false_positives} 个)')  # 标题(含假阳性数)
axes[0].legend()  # 显示图例

# 右图:-log10(p)散点图
axes[1].scatter(range(num_tests), -np.log10(null_p_values), s=5, alpha=0.5, color='gray')  # 绘制-log10(p)散点
axes[1].axhline(y=-np.log10(0.05), color='#E3120B', linestyle='--', linewidth=2, label='α = 0.05')  # 显著性阈值线
axes[1].set_xlabel('因子编号')  # x轴标签
axes[1].set_ylabel('$-\\log_{10}(p)$')  # y轴标签
axes[1].set_title('所有因子都无效,但假阳性大量出现')  # 标题
axes[1].legend()  # 显示图例

plt.tight_layout()  # 自动调整间距
plt.show()  # 显示图表
Figure 1: 1000个全部为零假设的p值分布(模拟数据)

一批检验为什么要被看成同一个 family

多重检验里的 family,可以理解为“应当一起结算错误预算的一组检验”。

常见例子:

  • 对同一只股票测试 50 条均线策略
  • 对 80 多个行业同时检验 Alpha
  • 对 300 个候选因子做一轮统一筛选

只要这些检验共同服务于同一轮研究结论或投资决策,就应该被看成同一个 family,不能只盯着单个 p 值解释。

FWER 与 FDR:两种不同的错误控制策略

两种错误控制策略对比 FWER 控制 家族错误率 Pr(至少1个假阳性) ≤ α 极度保守 ✓ 控制任何假阳性 ✗ 可能错过真效应 代表方法: Bonferroni, Holm FDR 控制 错误发现率 E[V/R] ≤ q 适度宽松 ✓ 允许少量假阳性 ✓ 保留更多真效应 代表方法: BH, Storey q 更保守 更灵活 检验次数 m 越大,二者的差距越大 大规模因子筛选时 FDR 控制通常更实用
Figure 2: FWER 与 FDR 对比:两种不同的多重检验校正的哲学

Bonferroni 的直觉是“把总犯错预算切成 m 份”

把总错误预算 \(\alpha\) 理解成一笔固定预算:

  • 如果只做 1 次检验,这 0.05 可以全部给这 1 次
  • 如果做 10 次检验,每次最多分到 0.005
  • 如果做 100 次检验,每次最多分到 0.0005
检验次数 \(m\) Bonferroni 阈值 \(\alpha/m\)
5 0.0100
50 0.0010
500 0.0001

这能保护整体错误率,但代价是:检验越多,每次分到的“单次犯错额度”越小,真信号也会越来越难通过。

FWER控制的数学原理

FWER(族错误率)

\[ \text{FWER} = P(\text{至少一个假阳性}) = 1 - (1-\alpha)^m \]

\(m\) 很大时,FWER趋近于1

例子\(\alpha = 0.05\),测试 \(m = 100\) 个假设

\[ \text{FWER} = 1 - (1-0.05)^{100} \approx 0.994 \]

几乎必然出现假阳性!

FDR(假发现率)

\[ \text{FDR} = E\left[\frac{\text{假阳性数}}{\text{所有拒绝数}}\right] = E\left[\frac{V}{R}\right] \]

  • FDR关注的是”在你宣称的发现中,有多少比例是假的”
  • FDR \(\leq\) FWER,控制FDR更宽松但更实用

读 FDR 公式时先看分子再看分母最后看期望

FDR 公式最容易被误读的地方,是很多人只盯着 \(V/R\) 这个比值,却忽略了最外层还有一个期望。

更稳妥的读法是:

  1. 先看分子 \(V\):它表示假阳性数,也就是错判成“有发现”的个数
  2. 再看分母 \(R\):它表示所有拒绝数,也就是你最终宣称的全部发现数
  3. 然后把 \(V/R\) 读成“发现名单里有多少比例是假的”:这一步强调的是比例,而不是假阳性的绝对个数
  4. 最后再看最外层的 \(E[\,]\):FDR 控制的不是某一次实验里一定等于多少,而是重复很多次后,这个假发现比例的平均水平不超过阈值

课堂口令:先看假发现有几个,再看总共报了几个,最后再问长期平均能不能控住。

Bonferroni:最简单的 FWER 控制方法

核心思想:将显著性水平均匀分配给每一次检验。

\[\alpha_{\text{adjusted}} = \frac{\alpha}{m}\]

等价地,对 p 值进行调整:

\[p_{\text{adjusted}} = \min(m \times p_{\text{原始}}, \; 1)\]

优点:简单、直观、对任何依赖结构都有效

缺点:当 \(m\) 很大时极度保守——例如 \(m = 1000\) 时,\(\alpha_{\text{adjusted}} = 0.00005\)

Bonferroni 的实际执行顺序

如果你真的要把 Bonferroni 用到一组结果上,动作顺序很固定:

  1. 先明确这一批检验是不是同一个 family
  2. 数清楚总检验次数 \(m\)
  3. 选定整体错误预算 \(\alpha\)
  4. 把单次阈值改成 \(\alpha/m\),或者把每个 p 值都乘以 \(m\)
  5. 再判断哪些结果仍然低于阈值

两个等价版本

  • 改阈值:看原始 p 值是否小于 \(\alpha/m\)
  • 改 p 值:看调整后 p 值是否小于原始 \(\alpha\)

最容易错的地方:family 还没划清,就先把全部检验一股脑地除以一个过大的 \(m\)

案例:A 股强势股票筛选

import pandas as pd  # 导入pandas用于数据操作
import os  # 导入os用于路径处理

DATA_DIR = 'C:/qiufei/data' if os.name == 'nt' else '/home/ubuntu/r2_data_mount/data'  # 跨平台数据路径

# 只读取2020年的前复权股价数据
stock_close_2020 = pd.read_hdf(  # 按年份选择性读取2020年分析所需的股价子集
    os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5'),  # 指定前复权股价文件
    key='data',  # 指定HDF5中的数据键
    where=["date>=Timestamp('2020-01-01')", "date<Timestamp('2021-01-01')"],  # 只读取2020年数据
    columns=['close']  # 只保留计算收益率所需的收盘价列
).copy()  # 保留MultiIndex以减少不必要的索引重建

# 先根据收盘价样本量筛出有效股票,再只对目标股票计算收益率
close_count_2020 = stock_close_2020.groupby(level='order_book_id')['close'].count()  # 按股票统计非缺失收盘价数量
valid_stock_list = close_count_2020[close_count_2020 >= 101].index.tolist()  # 日收益率至少100个意味着收盘价至少101个
selected_stock_list = valid_stock_list[:500]  # 只保留后续检验会用到的前500只股票
selected_stock_mask_2020 = stock_close_2020.index.get_level_values('order_book_id').isin(selected_stock_list)  # 标记需要保留的股票样本
selected_stock_close_2020 = stock_close_2020.loc[selected_stock_mask_2020, ['close']].copy()  # 只提取目标股票的收盘价子集
selected_stock_close_2020['daily_return'] = selected_stock_close_2020.groupby(level='order_book_id')['close'].pct_change()  # 仅对目标股票计算日收益率
stock_price_2020 = selected_stock_close_2020[['daily_return']].reset_index()  # 只保留后续检验需要的股票代码和收益率

print(f'2020年有效股票数量: {len(valid_stock_list)}')  # 输出有效股票数
2020年有效股票数量: 3932

未校正 vs Bonferroni 校正效果对比

Code
from scipy import stats  # 导入统计库
from statsmodels.stats.multitest import multipletests  # 导入多重检验校正工具

# 对每只股票执行单侧 t 检验 (H1: μ > 0)
selected_stock_frame = stock_price_2020.loc[  # 一次性提取参与检验的股票收益率
    :, ['order_book_id', 'daily_return']  # 数据准备阶段已限制为前500只股票,这里直接复用
].dropna().copy()  # 去除缺失收益率并复制
selected_stock_stats = selected_stock_frame.groupby('order_book_id', sort=False)['daily_return'].agg(['count', 'mean', 'std']).reset_index()  # 一次性汇总样本量、均值和标准差
selected_stock_stats = selected_stock_stats[selected_stock_stats['count'] > 1].copy()  # 只保留能计算标准差的股票
selected_stock_stats['std'] = selected_stock_stats['std'].replace(0, np.nan)  # 标准差为0时记为缺失以避免除零
selected_stock_stats['t_statistic'] = selected_stock_stats['mean'] / (selected_stock_stats['std'] / np.sqrt(selected_stock_stats['count']))  # 用分组统计量直接计算t统计量
selected_stock_stats['two_sided_p'] = 2 * stats.t.sf(np.abs(selected_stock_stats['t_statistic']), df=selected_stock_stats['count'] - 1)  # 基于t分布计算双侧p值
selected_stock_stats['one_sided_p'] = np.where(  # 将双侧p值转换为单侧p值
    selected_stock_stats['t_statistic'].isna(), 1.0, np.where(selected_stock_stats['t_statistic'] > 0, selected_stock_stats['two_sided_p'] / 2, 1 - selected_stock_stats['two_sided_p'] / 2)  # 缺失统计量视为不显著
)  # 完成单侧p值构造

p_values_array = selected_stock_stats['one_sided_p'].to_numpy()  # 转为numpy数组
t_stat_array = selected_stock_stats['t_statistic'].to_numpy()  # 转为numpy数组备用

# Bonferroni 校正
bonferroni_reject, bonferroni_p_adjusted, _, _ = multipletests(p_values_array, alpha=0.05, method='bonferroni')  # Bonferroni校正

fig, axes = plt.subplots(1, 2, figsize=(13, 5))  # 创建1行2列子图

# 左图:未校正
is_significant_raw = p_values_array < 0.05  # 未校正下的显著性判断
axes[0].scatter(range(len(p_values_array)), -np.log10(p_values_array),  # 绘制-log10(p)散点
    c=['#E3120B' if s else 'gray' for s in is_significant_raw], s=8, alpha=0.6)  # 显著=红色
axes[0].axhline(y=-np.log10(0.05), color='blue', linestyle='--', label='α=0.05')  # 原始阈值线
axes[0].set_title(f'未校正(显著: {sum(is_significant_raw)} 只)')  # 标题
axes[0].set_xlabel('股票编号')  # x轴
axes[0].set_ylabel('$-\\log_{10}(p)$')  # y轴
axes[0].legend()  # 图例

# 右图:Bonferroni 校正后
axes[1].scatter(range(len(p_values_array)), -np.log10(p_values_array),  # 同样的-log10(p)
    c=['#E3120B' if s else 'gray' for s in bonferroni_reject], s=8, alpha=0.6)  # Bonferroni显著=红色
bonferroni_threshold = 0.05 / len(p_values_array)  # 计算Bonferroni校正阈值
axes[1].axhline(y=-np.log10(bonferroni_threshold), color='blue', linestyle='--', label=f'α/m={bonferroni_threshold:.2e}')  # 校正阈值线
axes[1].set_title(f'Bonferroni 校正(显著: {sum(bonferroni_reject)} 只)')  # 标题
axes[1].set_xlabel('股票编号')  # x轴
axes[1].set_ylabel('$-\\log_{10}(p)$')  # y轴
axes[1].legend()  # 图例

plt.tight_layout()  # 自动调整间距
plt.show()  # 显示图表
Figure 3: 未校正与 Bonferroni 校正的股票筛选对比

为什么 BH 一定要先给 p 值排序

BH 方法的阈值不是看“这个假设是谁”,而是看“这个 p 值排第几名”。

先排序的原因

  • 最小的 p 值最像真信号
  • 中间的 p 值真假难分
  • 最大的 p 值更像噪声

BH 的做法是:

  • 按从小到大排序
  • 给第 1 名最严格但仍可接受的阈值
  • 随着名次往后,阈值线性放宽
  • 找到“最后一个还能过线的位置”

所以,BH 不是逐个独立判定,而是在整批结果里寻找一条整体截断线。

BH 方法:自适应的 FDR 控制

BH 算法步骤

  1. \(m\) 个 p 值从小到大排序:\(p_{(1)} \leq p_{(2)} \leq \cdots \leq p_{(m)}\)
  2. 找到最大的 \(k\),使得 \(p_{(k)} \leq \frac{k}{m} \cdot q\)
  3. 拒绝排序在前 \(k\) 个的所有假设

核心优势:BH 的阈值线 \(\frac{k}{m} \cdot q\)线性递增的:

  • 排序靠前的 p 值(更可能是真信号)获得更宽松的阈值
  • 排序靠后的 p 值(更可能是噪声)获得更严格的阈值
  • 自适应地平衡发现率假阳性率

操作口令:先排序,再画线,再从后往前找“最后一个仍然过线的位置”。把这三个动作记住,BH 算法就很难再背混。

数学保证:在独立或正依赖条件下,\(\text{FDR} \leq \frac{m_0}{m} \cdot q \leq q\)

底线提醒\(q=5\%\) 不代表你拒绝的每一个假设都有 95% 概率是真的;它控制的是“长期平均意义下,所有发现里假发现所占比例”。

BH 的完整执行顺序是“排序 画线 回找 截断”

真正做 BH 时,建议把流程死记成四个动作:

  1. 排序:把所有 p 值从小到大排好
  2. 画线:给第 \(k\) 名配上阈值 \(kq/m\)
  3. 回找:从后往前找最后一个仍满足 \(p_{(k)} \leq kq/m\) 的位置
  4. 截断:把排在这个位置之前的所有假设一起拒绝

为什么一定要从后往前找?

  • 因为 BH 想找的是“最多可以保留到第几名”
  • 一旦找到最后一个过线位置,前面所有更小的 p 值都一起保留
  • 所以 BH 的决策对象是一整段前缀,不是单个孤立的检验

一句操作口令:先排序,再画线,再回头找最后一个过线者。

BH方法的数学推导

BH算法

  1. \(m\) 个p值从小到大排序:\(p_{(1)} \leq p_{(2)} \leq \cdots \leq p_{(m)}\)

  2. 找到最大的 \(k\) 使得:

\[ p_{(k)} \leq \frac{k}{m} \cdot q \]

  1. 拒绝所有 \(p_{(1)}, \ldots, p_{(k)}\) 对应的假设

为什么BH有效?

  • 关键:BH的阈值线 \(\frac{k}{m} \cdot q\)自适应的
  • 真正有效的因子的p值会聚集在0附近
  • BH自动”校准”阈值,允许更多真发现通过

与Bonferroni的对比

  • Bonferroni:统一阈值 \(\alpha/m\)(过于保守)
  • BH:递增阈值 \(kq/m\)(更灵活)

结果解读顺序:先定义 family,再选控制目标(FWER 还是 FDR),最后才套公式和阈值;如果 family 划错了,后面的校正再精细也没意义。

BH 实战:注入 Alpha 信号的检测

# 读取2021年的前复权股价数据
stock_close_2021 = pd.read_hdf(  # 按年份选择性读取2021年分析所需的股价子集
    os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5'),  # 指定前复权股价文件
    key='data',  # 指定HDF5中的数据键
    where=["date>=Timestamp('2021-01-01')", "date<Timestamp('2022-01-01')"],  # 只读取2021年数据
    columns=['close']  # 只保留计算收益率所需的收盘价列
).copy()  # 保留MultiIndex以减少不必要的索引重建

# 先根据收盘价样本量筛出有效股票,再只对有效股票计算收益率
close_count_2021 = stock_close_2021.groupby(level='order_book_id')['close'].count()  # 按股票统计非缺失收盘价数量
valid_2021_stocks = close_count_2021[close_count_2021 >= 101].index.tolist()  # 日收益率至少100个意味着收盘价至少101个
valid_stock_mask_2021 = stock_close_2021.index.get_level_values('order_book_id').isin(valid_2021_stocks)  # 标记有效股票样本
valid_stock_close_2021 = stock_close_2021.loc[valid_stock_mask_2021, ['close']].copy()  # 只提取有效股票的收盘价子集
valid_stock_close_2021['daily_return'] = valid_stock_close_2021.groupby(level='order_book_id')['close'].pct_change()  # 只对有效股票计算日收益率
stock_price_2021 = valid_stock_close_2021[['daily_return']].reset_index()  # 只保留后续分析需要的股票代码和收益率

# 随机注入100只股票的正Alpha(+0.5%/天)
np.random.seed(2024)  # 设置随机种子
injected_alpha_stocks = np.random.choice(valid_2021_stocks, size=100, replace=False)  # 随机选100只注入Alpha
injected_stock_set = set(injected_alpha_stocks)  # 转为集合便于查询

# 为注入Alpha的股票修改收益率
stock_price_2021_modified = stock_price_2021.copy()  # 复制数据
alpha_mask = stock_price_2021_modified['order_book_id'].isin(injected_stock_set)  # 标记被注入的股票
stock_price_2021_modified.loc[alpha_mask, 'daily_return'] += 0.005  # 注入每日+0.5%的正Alpha

# 预先汇总股票层面的收益率统计量,避免后续逐只股票反复筛表
valid_2021_return_frame = stock_price_2021_modified.loc[  # 一次性提取有效股票的收益率样本
    stock_price_2021_modified['order_book_id'].isin(valid_2021_stocks), ['order_book_id', 'daily_return']  # 只保留股票代码和收益率
].dropna().copy()  # 去除缺失收益率并复制
valid_2021_stock_stats = valid_2021_return_frame.groupby('order_book_id', sort=False)['daily_return'].agg(['count', 'mean', 'std']).reset_index()  # 汇总样本量、均值和标准差
valid_2021_stock_stats = valid_2021_stock_stats[valid_2021_stock_stats['count'] >= 30].copy()  # 只保留样本量足够的股票
valid_2021_stock_stats['std'] = valid_2021_stock_stats['std'].replace(0, np.nan)  # 标准差为0时记为缺失以避免除零
valid_2021_stock_stats['t_stat'] = valid_2021_stock_stats['mean'] / (valid_2021_stock_stats['std'] / np.sqrt(valid_2021_stock_stats['count']))  # 直接用分组统计量计算t统计量
valid_2021_stock_stats['two_sided_p'] = 2 * stats.t.sf(np.abs(valid_2021_stock_stats['t_stat']), df=valid_2021_stock_stats['count'] - 1)  # 基于t分布计算双侧p值
valid_2021_stock_stats['one_sided_p'] = np.where(  # 将双侧p值转换为单侧p值
    valid_2021_stock_stats['t_stat'].isna(), 1.0, np.where(valid_2021_stock_stats['t_stat'] > 0, valid_2021_stock_stats['two_sided_p'] / 2, 1 - valid_2021_stock_stats['two_sided_p'] / 2)  # 缺失统计量视为不显著
)  # 完成单侧p值构造
valid_2021_stock_stats['is_true_alpha'] = valid_2021_stock_stats['order_book_id'].isin(injected_stock_set)  # 标记哪些股票被注入了真实Alpha

print(f'2021年有效股票总数: {len(valid_2021_stocks)}')  # 输出总股票数
print(f'注入 Alpha 的股票数: {len(injected_alpha_stocks)}')  # 输出注入Alpha的股票数
2021年有效股票总数: 4428
注入 Alpha 的股票数: 100

三种方法对比:BH 的优势一目了然

Code
# 对所有股票进行 t 检验
p_arr = valid_2021_stock_stats['one_sided_p'].to_numpy()  # 直接复用股票层面的单侧p值
t_arr = valid_2021_stock_stats['t_stat'].to_numpy()  # 直接复用股票层面的t统计量
truth_arr = valid_2021_stock_stats['is_true_alpha'].to_numpy()  # 直接复用真实Alpha标签

# 三种校正方法
raw_reject = p_arr < 0.05  # 未校正
bonf_reject, _, _, _ = multipletests(p_arr, alpha=0.05, method='bonferroni')  # Bonferroni
bh_reject, bh_p_adj, _, _ = multipletests(p_arr, alpha=0.05, method='fdr_bh')  # BH
Figure 4
# 计算各方法的关键指标
def calculate_performance_metrics(reject_decisions, true_labels):  # 定义性能计算函数
    true_positive = np.sum(reject_decisions & true_labels)  # 真阳性(正确拒绝)
    false_positive = np.sum(reject_decisions & ~true_labels)  # 假阳性(错误拒绝)
    total_rejections = np.sum(reject_decisions)  # 总拒绝数
    fdr = false_positive / total_rejections if total_rejections > 0 else 0  # 错误发现率
    tpr = true_positive / np.sum(true_labels) if np.sum(true_labels) > 0 else 0  # 真阳性率(检验效力)
    return {'TP': true_positive, 'FP': false_positive, 'R': total_rejections, 'FDR': f'{fdr:.2%}', 'TPR': f'{tpr:.2%}'}  # 返回指标字典

results_comparison = pd.DataFrame({  # 创建对比表
    '未校正 (α=0.05)': calculate_performance_metrics(raw_reject, truth_arr),  # 未校正结果
    'Bonferroni': calculate_performance_metrics(bonf_reject, truth_arr),  # Bonferroni结果
    'BH (q=0.05)': calculate_performance_metrics(bh_reject, truth_arr)  # BH结果
})  # 三列三行的性能对比表
results_comparison  # 显示对比表格
Table 1: 三种方法的检验性能对比
未校正 (α=0.05) Bonferroni BH (q=0.05)
TP 92 11 39
FP 242 0 0
R 334 11 39
FDR 72.46% 0.00% 0.00%
TPR 92.00% 11.00% 39.00%

可视化:BH 自适应阈值线与火山图

Code
fig, axes = plt.subplots(1, 2, figsize=(13, 5))  # 创建1行2列子图

# 左图:BH 自适应阈值线
sorted_indices = np.argsort(p_arr)  # 按p值从小到大排序的索引
sorted_p = p_arr[sorted_indices]  # 排序后的p值
m_total = len(p_arr)  # 总检验次数
bh_threshold_line = np.arange(1, m_total + 1) / m_total * 0.05  # BH阈值线: k/(m*q),线性递增

axes[0].scatter(range(m_total), sorted_p, s=3, c='gray', alpha=0.5, label='排序后的p值')  # 排序p值散点图
axes[0].plot(range(m_total), bh_threshold_line, color='#008080', linewidth=2, label='BH 阈值线')  # BH阈值线
axes[0].set_xlabel('排序位置 k')  # x轴
axes[0].set_ylabel('p 值')  # y轴
axes[0].set_title('BH 自适应阈值线')  # 标题
axes[0].set_ylim(0, 0.1)  # 限制y轴范围便于观察
axes[0].legend()  # 图例

# 右图:火山图
positive_bh_mask = bh_reject & (t_arr > 0)  # 标记BH校正后显著的正向效应
negative_bh_mask = bh_reject & (t_arr < 0)  # 标记BH校正后显著的负向效应
neutral_bh_mask = ~(positive_bh_mask | negative_bh_mask)  # 标记其余未显著或接近0的点

axes[1].scatter(t_arr[neutral_bh_mask], -np.log10(p_arr[neutral_bh_mask]), s=6, c='gray', alpha=0.5)  # 批量绘制未显著散点
axes[1].scatter(t_arr[positive_bh_mask], -np.log10(p_arr[positive_bh_mask]), s=6, c='#E3120B', alpha=0.5)  # 批量绘制显著正效应散点
axes[1].scatter(t_arr[negative_bh_mask], -np.log10(p_arr[negative_bh_mask]), s=6, c='#008080', alpha=0.5)  # 批量绘制显著负效应散点

axes[1].set_xlabel('t 统计量')  # x轴
axes[1].set_ylabel('$-\\log_{10}(p)$')  # y轴
axes[1].set_title('火山图(BH 校正)')  # 标题

plt.tight_layout()  # 自动调整间距
plt.show()  # 显示图表
Figure 5: BH 自适应阈值线(左)与火山图(右)

读 BH 阈值线和火山图时先看截断边界再看方向分布

这类双图最容易看乱的地方,是把左图和右图混成“哪边点更高就更显著”。更稳妥的阅读顺序是:

  1. 先读左图的截断逻辑:先看灰色排序 p 值和绿色 BH 阈值线在哪里开始分开
  2. 再找“最后一个仍在线下方的位置”:这决定了 BH 最终保留到第几名,而不是逐点各自判断
  3. 然后再读右图的方向:横轴右侧表示正向效应更强,左侧表示负向效应更强
  4. 最后才看颜色和稀疏程度:红点、青点告诉你哪些结果在 BH 校正后被保留下来,以及留下来的信号主要集中在哪个方向

课堂口令:先看左图定边界,再看右图分方向。左图回答“保留到哪里”,右图回答“留下来的信号长什么样”。

Holm 方法与 Storey q值

Holm 方法(逐步下降法):

  1. 排序 p 值:\(p_{(1)} \leq p_{(2)} \leq \cdots \leq p_{(m)}\)
  2. 从最小开始,比较 \(p_{(k)}\)\(\frac{\alpha}{m - k + 1}\)
  3. 一旦某个 \(p_{(k)}\) 不显著,停止,后续全部不拒绝

Storey q 值方法

  • 估计真零假设的比例 \(\hat{\pi}_0\) → 更精确的 FDR 控制
  • q 值 = 某个发现被判为显著时的最小 FDR
  • 适用于 \(m\) 非常大且真效应比例较高的场景
方法 控制目标 保守程度 检验效力
Bonferroni FWER 最保守
Holm FWER 较保守
BH FDR 适中
Storey q FDR 最灵活 最高

Holm 为什么是一旦失败就全停

Holm 的顺序看起来像 Bonferroni 的小改版,但核心在于它有一个停止规则

实际执行顺序

  1. 先把 p 值从小到大排序
  2. 第 1 个和 \(\alpha/m\)
  3. 第 2 个和 \(\alpha/(m-1)\)
  4. 按顺序一路往后检
  5. 一旦某一步不过线,后面全部停止并判为不显著

为什么可以停?

  • 因为排序后的 p 值只会越来越大
  • 而 Holm 的阈值虽然逐渐放宽,但控制逻辑要求保持整段拒绝的一致性
  • 前面某一步已经失败,后面就不能再零散地单独放行

一句话理解:Holm 是“从最强信号开始逐步花预算”,预算链条一旦断掉,后面就全停。

q 值可以理解成“这个发现最小需要多宽松的 FDR 门槛”

Storey q 值最容易被误读成“另一种 p 值”。更准确的理解是:

q 值回答的是:如果你想把这个发现保留下来,整体允许的 FDR 至少要放宽到多少?

阅读顺序

  1. 先看这个结果的原始 p 值有多小
  2. 再看对应 q 值有多大
  3. 最后问:如果我设定的 FDR 目标是 5%,这个 q 值是否不超过 5%

例如:

  • 若某结果的 q 值是 0.03,说明把 FDR 目标设在 3% 或更宽松时,它都能保留下来
  • 若某结果的 q 值是 0.12,说明只有当你愿意接受 12% 左右的错误发现率时,它才算显著

所以:q 值不是“这条结论为真的概率”,而是“这条结论进入发现名单所需的最小 FDR 门槛”。

校正后的 p 值应该怎样解释

原始 p 值回答的是:单个检验在原假设下有多极端。

校正后的 p 值回答的是:当你把这组检验放在一起看时,它是否仍然足够显著。

阅读规则:

  • 校正后 p 值通常会比原始 p 值更大
  • 如果校正后仍小于阈值,说明它在“整轮筛选”里仍站得住
  • 如果原始显著但校正后不显著,说明它更像“筛选过程里的幸运结果”

报告结果时,最好同时给出原始 p 值与校正后 p 值,而不是只汇报最漂亮的那个版本。

P-Hacking 与数据偷窥的危害

P-Hacking(p值黑客)是量化金融研究中的严重问题:

  • 在大量回测中”挖掘”出看起来显著的策略
  • Harvey, Liu & Zhu (2016) 发现:超过一半的金融”异象因子”可能是假的
  • 他们建议将 t 统计量的显著性阈值提高到 t > 3.0

常见的数据偷窥手法

  • 在同一数据集上测试大量策略,只报告显著的
  • 不断调整回测参数直到出现好结果
  • 对同一假设尝试多种统计检验方法

防范措施

  • 预先注册研究计划和假设
  • 样本外验证:将数据分为训练集和测试集
  • 报告所有结果,包括不显著的检验
  • 使用 BH 或 Bonferroni 进行系统性的多重检验校正

防范P-Hacking的制度设计

P-Hacking的常见手段

  1. 选择性报告:只报告显著的结果
  2. 数据挖掘:反复尝试直到找到显著结果
  3. 样本操纵:调整样本期直到p值达标
  4. 模型fishing:换不同的控制变量组合

学术界的应对措施

  • 预注册(Pre-registration):事先公布研究方案
  • 样本外验证(Out-of-sample testing):在独立数据集上验证
  • 复制研究(Replication studies)

量化投资的应对措施

措施 说明
时间序列分割 开发期 / 验证期 / 实盘期
多市场测试 同一策略在不同市场验证
多重检验校正 对所有测试过的策略做BH校正

样本外验证为什么不能替代多重检验校正

很多同学会问:‘我已经做了样本外验证,还需要多重检验校正吗?’

答案通常是:仍然需要

工具 主要解决的问题
多重检验校正 同一轮筛选里测试太多,假阳性累积
样本外验证 历史规律能否跨时间继续成立

这两个工具不能互相替代,因为:

  • 你可能在样本内先试了很多策略,已经发生了选择偏差
  • 即使最后只拿 1 个策略去做样本外,前面的“筛选运气”仍可能残留
  • 最稳妥的做法是:先做多重检验控制,再做样本外验证

案例:海康威视技术指标的多重检验

Code
# 获取海康威视(002415.XSHE)的股价数据
hikvision_data = pd.read_hdf(  # 直接读取海康威视在案例所需时段的股价子集
    os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5'),  # 指定前复权股价文件
    key='data',  # 指定HDF5中的数据键
    where=["order_book_id=='002415.XSHE'", "date>=Timestamp('2020-01-01')", "date<Timestamp('2022-01-01')"],  # 只读取海康威视2020-2021年数据
    columns=['close']  # 只保留计算策略收益所需的收盘价列
).reset_index().sort_values('date').copy()  # 恢复索引、按日期排序并复制

hikvision_data['daily_return'] = hikvision_data['close'].pct_change()  # 计算日收益率

# 测试 50 条均线策略 (MA5 到 MA54)
ma_periods = range(5, 55)  # MA周期从5到54
strategy_p_values = []  # 存储各策略的p值

for ma_period in ma_periods:  # 遍历每个MA周期
    hikvision_data[f'MA_{ma_period}'] = hikvision_data['close'].rolling(ma_period).mean()  # 计算移动平均
    signal_mask = hikvision_data['close'] > hikvision_data[f'MA_{ma_period}']  # 价格在均线上方时持仓
    strategy_returns = hikvision_data.loc[signal_mask, 'daily_return'].dropna()  # 持仓期间的收益率
    if len(strategy_returns) > 30:  # 样本量充足时
        t_stat, p_val = stats.ttest_1samp(strategy_returns, 0)  # t检验:均值是否>0
        strategy_p_values.append(p_val / 2 if t_stat > 0 else 1)  # 转为单侧p值
    else:  # 样本量不足
        strategy_p_values.append(1.0)  # 设为不显著

strategy_p_arr = np.array(strategy_p_values)  # 转numpy数组
bonf_reject_ma, _, _, _ = multipletests(strategy_p_arr, alpha=0.05, method='bonferroni')  # Bonferroni校正
bh_reject_ma, _, _, _ = multipletests(strategy_p_arr, alpha=0.05, method='fdr_bh')  # BH校正

fig, ax = plt.subplots(figsize=(11, 5))  # 创建图表
x_pos = list(ma_periods)  # x轴位置
ax.bar(x_pos, -np.log10(strategy_p_arr), color=[  # 绘制-log10(p)柱状图
    '#E3120B' if strategy_p_arr[i] < 0.05 else 'gray' for i in range(len(strategy_p_arr))  # 未校正显著=红色
], alpha=0.7)  # 透明度0.7
ax.axhline(y=-np.log10(0.05), color='blue', linestyle='--', label='未校正 α=0.05')  # 未校正阈值
ax.axhline(y=-np.log10(0.05/len(strategy_p_arr)), color='#E3120B', linestyle='-', label='Bonferroni')  # Bonferroni阈值
ax.set_xlabel('移动平均周期 (MA)')  # x轴
ax.set_ylabel('$-\\log_{10}(p)$')  # y轴
ax.set_title(f'海康威视50条MA策略: 未校正显著 {sum(strategy_p_arr < 0.05)} 条 → 校正后 {sum(bonf_reject_ma)} 条')  # 标题
ax.legend()  # 图例
plt.tight_layout()  # 自动调整间距
plt.show()  # 显示图表
Figure 6: 海康威视50条均线策略的多重检验校正

关键发现:大量未校正下”显著”的策略,在 Bonferroni 或 BH 校正后全部变为不显著 → 典型的数据偷窥结果!

案例:行业 Alpha 的多重检验

Code
# 读取上市公司基本信息获取行业分类
stock_basic = pd.read_hdf(os.path.join(DATA_DIR, 'stock/stock_basic_data.h5'))  # 读取基本信息

# 直接复用前面已经读取好的2021年收益率表,避免重复读取大文件
stock_with_industry = stock_price_2021.loc[:, ['order_book_id', 'daily_return']].merge(  # 只取行业检验需要的列并合并行业标签
    stock_basic[['order_book_id', 'industry_name']], on='order_book_id', how='left'  # 左连接补充行业信息
).dropna(subset=['industry_name', 'daily_return']).copy()  # 去除缺失行业和缺失收益率

# 按行业一次性汇总统计量并计算双侧p值
industry_stats = stock_with_industry.groupby('industry_name')['daily_return'].agg(['count', 'mean', 'std']).reset_index()  # 汇总行业样本量、均值和标准差
industry_stats = industry_stats[industry_stats['count'] > 100].copy()  # 只保留样本量足够的行业
industry_stats['std'] = industry_stats['std'].replace(0, np.nan)  # 标准差为0时记为缺失以避免除零
industry_stats['t_stat'] = industry_stats['mean'] / (industry_stats['std'] / np.sqrt(industry_stats['count']))  # 直接用汇总统计量计算t统计量
industry_stats['p_val'] = 2 * stats.t.sf(np.abs(industry_stats['t_stat']), df=industry_stats['count'] - 1)  # 基于t分布计算双侧p值
industry_stats = industry_stats.dropna(subset=['p_val']).copy()  # 去除无法计算p值的行业

industry_names = industry_stats['industry_name'].to_numpy()  # 行业名数组
industry_p_arr = industry_stats['p_val'].to_numpy()  # p值数组
Figure 7
Code
# 三种校正
industry_raw_sig = industry_p_arr < 0.05  # 未校正显著判断
industry_bonf_rej, _, _, _ = multipletests(industry_p_arr, alpha=0.05, method='bonferroni')  # Bonferroni
industry_bh_rej, _, _, _ = multipletests(industry_p_arr, alpha=0.05, method='fdr_bh')  # BH

fig, axes = plt.subplots(1, 3, figsize=(15, 5))  # 1行3列子图
method_configs = [  # 三种方法的配置
    ('未校正 (α=0.05)', industry_raw_sig, int(sum(industry_raw_sig))),  # 未校正配置
    ('Bonferroni', industry_bonf_rej, int(sum(industry_bonf_rej))),  # Bonferroni配置
    ('BH (q=0.05)', industry_bh_rej, int(sum(industry_bh_rej)))  # BH配置
]  # 三种方法

for ax_idx, (method_name, reject_mask, count) in enumerate(method_configs):  # 遍历三种方法
    colors = ['#E3120B' if r else 'gray' for r in reject_mask]  # 显著=红色,不显著=灰色
    axes[ax_idx].barh(range(len(industry_p_arr)), -np.log10(industry_p_arr), color=colors, alpha=0.7)  # 水平柱状图
    axes[ax_idx].set_title(f'{method_name}(显著: {count})')  # 标题含显著数量
    axes[ax_idx].set_xlabel('$-\\log_{10}(p)$')  # x轴标签

plt.tight_layout()  # 自动调整间距
plt.show()  # 显示图表
Figure 8: 行业Alpha检验: 未校正 vs Bonferroni vs BH

方法选择决策框架

多重检验校正方法选择指南 检验次数 m m < 10: 可不校正 需要严格控制假阳性? 是 → FWER Bonferroni 最简单、最保守 α_adj = α/m Holm 比Bonferroni更有效力 否 → FDR BH 方法 最常用、平衡好 FDR ≤ q Storey q 值 m很大且真效应多时 m很大: 优先FDR 最佳实践清单 1. 预先注册研究假设 2. 报告所有检验结果 3. 样本外验证 4. 关注效应量而非p值 5. 尽可能使用独立数据复制关键发现
Figure 9: 多重检验校正方法的选择决策树与最佳实践

默认起点:如果你拿不准应该多保守,就先用 BH 做主结果,再用更严格的 Holm 或 Bonferroni 做稳健性对照。

多重检验在不同金融场景的应用

场景1:因子发现

  • 测试上百个潜在Alpha因子
  • 推荐:BH方法,控制FDR在5%-10%
  • 目标:在控制假发现率的前提下,尽量多发现有效因子

场景2:风险监控

  • 同时监控多个交易策略的风险指标
  • 推荐:Bonferroni,控制FWER
  • 理由:任何一个假警报都可能导致不必要的平仓

场景3:学术研究

  • 资产定价实证中的多重假设检验
  • 推荐:Holm方法BH方法
  • 需要在论文中明确报告校正方法

核心原则:测试的假设越多,校正越重要

本章小结

三个核心认知

  1. 多重检验会累积假阳性:同时做 \(m\) 次检验时,第一类错误概率 \(\to 1 - (1-\alpha)^m\),远大于 \(\alpha\)
  2. 两种控制策略:FWER(不容许任何假阳性)vs FDR(控制假阳性比例)
  3. 三大校正方法:Bonferroni(最保守)→ Holm(中间)→ BH(最灵活)

量化投资中的最佳实践

  • 事前:明确研究假设,使用独立的样本外数据验证
  • 事中:对所有检验应用 BH 或 Bonferroni 校正
  • 事后:报告所有结果(包括不显著的),关注效应量而非仅看 p 值
  • 终极原则:用独立数据复制你的关键发现

最小路线图:先问这一批检验是否属于同一个 family,再问业务最怕哪种错误,最后才比较哪种校正方法更“强”或更“宽松”。

最后压缩成一句话:多重检验校正不是为了让结果更难显著,而是为了让“显著”这两个字重新变得可信。

本章三句工作口令

  1. 先定义这批检验是不是同一个 family
  2. 再定义业务最怕的是任何假阳性,还是可接受比例的假阳性
  3. 最后把校正结果和样本外复制放在一起看,而不是只盯着单个 p 值

从多重检验到可复制研究

本章的核心要点

  1. 问题意识:测试越多,假发现越多——这是统计规律,不是你的过错
  2. 解决方案:选择合适的校正方法(Bonferroni / Holm / BH)
  3. 思维方式:把”是否显著”升级为”控制了错误率后是否显著”

更宏观的视角——可复制性危机

  • 2015年,对100篇心理学论文的复制研究,仅36%能被复制
  • 金融学也面临类似问题:Harvey, Liu & Zhu (2016) 发现大量”已发表”因子无法复制

你能做什么?

  • 养成做多重检验校正的习惯
  • 始终进行样本外验证
  • 对”太好以至于不真实”的结果保持警惕