回测过拟合是量化研究里最普遍、最难自察、代价最高的错误。它不同于代码 bug——代码 bug 会让结果异常,而过拟合会让结果「漂亮得合理」,让研究员深信自己发现了金矿。等到实盘亏钱,已经付出了真金白银。
前视偏差、幸存者偏差这些「实现层」的陷阱,回测框架设计与前视偏差 已经系统讲过,那篇的重点是「怎么在框架里消除它们」。本文的重点不同:即使框架完全正确,回测结果仍然大概率是过拟合的,因为问题出在研究方法本身,而不是代码。
真正难的地方在于:过拟合不是「一个错误」,而是「一种统计必然」。只要你测试了足够多的想法,其中总会有一些因为运气而显得有效。防范过拟合,本质上是对抗这种统计必然——用多重检验校正、用概率化的过拟合度量、用流程纪律来约束研究的自由度。
本文按「根因 → 偏差的方法论 → 多重检验 → 样本外 → DSR → PBO → 其它检验 → 实验设计 → 流程纪律」的顺序展开。
目录
- 回测过拟合的统计必然
- 前视与幸存者偏差的方法论视角
- 数据窥探与多重检验
- 样本外验证的正确用法
- Deflated Sharpe Ratio
- PBO 与 CSCV
- White Reality Check 与 SPA 检验
- 最小回测长度与实验设计
- 研究流程纪律
1. 回测过拟合的统计必然
先看一个让人清醒的数学事实:如果你测试 N 个完全无效的策略,它们中最好的那个的 Sharpe 期望不为零。
import numpy as np
def expected_max_sharpe(n_trials, n_obs):
# N 个独立同分布的标准正态 Sharpe 估计,最大值的期望
# ≈ sqrt(2 ln N) 个标准差,标准差 ≈ 1/sqrt(n_obs)
return np.sqrt(2 * np.log(n_trials)) / np.sqrt(n_obs) * np.sqrt(252)
print(f"{expected_max_sharpe(100, 252*5):.2f}") # 100 个无效策略,5 年数据
print(f"{expected_max_sharpe(1000, 252*5):.2f}") # 1000 个
结果很震撼:1000 个纯随机策略,5 年数据,最好的那个年化 Sharpe 期望就有 1 左右。这意味着一个「Sharpe = 1」的回测结果,在测试了 1000 个想法之后,完全不构成任何证据。
这就是「研究者自由度」(researcher degrees of freedom)的威力:选样本区间、选参数、选股票池、选止损规则……每一个选择都是一个自由度,累积起来就是一次大规模的多重检验。Lopez de Prado 称之为「回测过拟合」,统计学家称之为「分叉路径的花园」(garden of forking paths)。
「试了多少次」在真实研究中远超直觉。一个研究员在一年里:测 200 个因子、每个因子试 5 组参数、换 3 个持有期、再叠加 2 套股票池过滤——光一个人就贡献了 6000 次隐含试验。团队的试验次数是累加的,因为大家共享同一份市场数据。这就是为什么「只测了十几个想法」几乎总是错觉。
2. 前视与幸存者偏差的方法论视角
前视偏差和幸存者偏差在框架层可以被消除,但它们的方法论危害值得单独强调:这两类偏差的效应是「单调向上」的——它们几乎不会让结果变差,只会让结果变好。这使它们极难被察觉。
| 偏差 | 方向 | 难以察觉的原因 |
|---|---|---|
| 前视偏差 | 系统性高估 | 结果「合理」,看不出异常 |
| 幸存者偏差 | 系统性高估 | 只用了「活下来」的样本 |
| 样本选择 | 完全失真 | 只挑了「好年份」 |
一个关键的方法论检验是「偏差敏感性测试」:故意引入偏差,看结果变化多大。
def bias_sensitivity(strategy_fn, data, biases):
results = {}
for name, inject in biases.items():
# 每种偏差注入后,重新回测,比较 Sharpe 变化
results[name] = strategy_fn(inject(data)).sharpe
return results
如果注入「用当日收盘价成交」这一个偏差,Sharpe 从 1.2 跳到 2.5,说明策略的收益高度依赖前视信息,大概率不可用。反之,如果各种偏差注入后 Sharpe 变化不大,说明策略的稳健性较好。
幸存者偏差的量化同样重要。用「当前指数成分」回测过去,与用「历史成分快照」回测,两者的收益差通常在每年 2~5%。这个差值不是「误差」,而是系统性的虚假收益。
3. 数据窥探与多重检验
数据窥探(data snooping)的本质是用同一份数据既生成假设又检验假设。经典的后果是:p 值失去意义。
多重检验的校正方法有三类:
| 方法 | 原理 | 特点 |
|---|---|---|
| Bonferroni | p × N | 最简单,过于保守 |
| FDR (Benjamini-Hochberg) | 控制错误发现率 | 平衡,常用 |
| Harvey-Liu 折扣 | Sharpe 按试验次数打折 | 金融专用 |
Bonferroni 把显著性阈值除以试验次数(0.05 / 1000 = 0.00005),几乎什么都通不过——它控制的是「至少一次假阳性」的概率,在策略筛选场景过于严苛。
FDR 更实用:它控制「被判为显著的结果里,假阳性的比例」:
from statsmodels.stats.multitest import multipletests
p_values = np.array([test_strategy(s)[1] for s in strategies])
reject, p_adj, _, _ = multipletests(p_values, alpha=0.05, method='fdr_bh')
print(f"原始显著 {reject.sum()} 个,FDR 校正后 {reject.sum()} 个")
Harvey-Liu 的「Sharpe 折扣」 是金融领域最实用的校正:他们发现,考虑多重检验后,一个「显著」的策略 Sharpe 至少要在 3.0 以上(而非传统的 2.0),才够得上 5% 的显著性水平。这个结论直接推翻了大量「t > 2 就显著」的研究。
关键的操作性建议:在开始研究前就记录你打算测试多少个想法。事后才统计「我到底试了多少次」,几乎总是低估——因为人会忘记那些「明显不行、试了一下就放弃」的尝试,而这些恰恰是多重检验的一部分。
4. 样本外验证的正确用法
样本外(out-of-sample)验证被广泛使用,但用错了就等于没用。常见的错误用法:
| 错误用法 | 问题 |
|---|---|
| 反复看样本外结果调参 | 样本外变成了样本内 |
| 只用一个样本外区间 | 结果依赖区间选择 |
| 样本外表现差就换区间 | 数据窥探的变体 |
| 训练/测试不分时序 | 标签重叠泄漏 |
样本外只能看一次,这是铁律。一旦你根据样本外结果调整了策略,这个样本外就「污染」了,不再独立。实践中,团队应该把最终的样本外结果封存,只在策略上线决策时看一次,且看之前冻结所有参数。
滚动验证(walk-forward) 比单次划分更稳健,因为它在多个时间段上验证。但它也有陷阱:如果每一折都用来调参,那么多折平均仍然是样本内的。正确的做法是嵌套:外层做验证,内层做调参。
def nested_walk_forward(data, outer_folds, inner_folds, strategy_grid):
"""外层评估,内层调参,避免调参泄漏到评估"""
scores = []
for train_outer, test_outer in outer_folds(data):
best_params, best_score = None, -np.inf
for train_in, val_in in inner_folds(train_outer): # 内层调参
for params in strategy_grid:
s = evaluate(strategy_grid[params], train_in, val_in)
if s > best_score:
best_score, best_params = s, params
scores.append(evaluate(strategy_grid[best_params], train_outer, test_outer))
return np.mean(scores) # 只有外层成绩才是「样本外」的
多市场、多时段验证是另一个稳健性检验:一个真正有效的策略,应该在多个市场(A 股、港股、美股)和多个时段都有效。只在 A 股 2015-2020 有效的策略,大概率是拟合了那段特定行情。
5. Deflated Sharpe Ratio
Deflated Sharpe Ratio(DSR)由 Bailey 与 Lopez de Prado 提出,它把「样本内 Sharpe」折算成考虑试验次数、样本长度、收益分布形状之后的显著性。
DSR 的输入有四个:
SR:样本内 Sharpe
T:样本长度(观测数)
N:尝试的独立策略数
γ₃, γ₄:收益的偏度与峰度
核心思想是把「最大 Sharpe 的期望」作为基准,而不是 0:
from scipy.stats import norm
def deflated_sharpe(sr, T, n_trials, skew, kurt, sr_std=None):
# 期望的最大 Sharpe(无效策略在 N 次试验下的最好表现)
if sr_std is None:
sr_std = 1.0 / np.sqrt(T) # 单次 Sharpe 估计的标准差
e_max = sr_std * ((1 - np.euler_gamma) * norm.ppf(1 - 1 / n_trials)
+ np.euler_gamma * norm.ppf(1 - 1 / (n_trials * np.e)))
# 收益非正态时 Sharpe 的标准误要修正
se = np.sqrt((1 - skew * sr + (kurt - 1) / 4 * sr ** 2) / (T - 1))
return norm.cdf((sr - e_max) / se) # 返回 DSR(概率形式)
DSR 的解读:它输出一个 0~1 的概率。如果 DSR < 0.95,说明在考虑了「试了 N 次」之后,你的 Sharpe 不够显著。很多「Sharpe = 2」的策略,在 N = 1000 时 DSR 只有 0.6——完全不显著。
DSR 有两个容易忽略的细节:
- N 是「独立」策略数,不是「总」策略数。高度相关的策略(同一想法的不同参数)算作一个。实践中 N 很难精确估计,应该保守估计。
- 偏度与峰度必须代入。量化策略的收益通常负偏、厚尾(平时小赚、偶尔大亏),这会让 Sharpe 的标准误变大,进一步降低显著性。
6. PBO 与 CSCV
PBO(Probability of Backtest Overfitting) 回答的问题是:在多个候选配置里,样本内最优的那个,在样本外掉到中位数以下的概率是多少?
计算方法是 CSCV(Combinatorial Symmetric Cross-Validation):把收益矩阵按时间切成 S 段,枚举所有「选一半做训练、另一半做测试」的组合,统计「训练集最优配置」在测试集的排名:
from itertools import combinations
def cscv_pbo(returns, n_splits=16):
# returns: T×N,N 个候选配置的收益序列
chunks = np.array_split(np.arange(len(returns)), n_splits)
logits = []
for train_idx in combinations(range(n_splits), n_splits // 2):
test_idx = [i for i in range(n_splits) if i not in train_idx]
tr = np.concatenate([chunks[i] for i in train_idx])
te = np.concatenate([chunks[i] for i in test_idx])
is_sharpe = returns[tr].mean(0) / returns[tr].std(0) # 各配置的样本内 Sharpe
best = np.argmax(is_sharpe) # 样本内最优
oos = returns[te].mean(0) / returns[te].std(0)
rank = (oos < oos[best]).mean() + 0.5 * (oos == oos[best]).mean()
logits.append(np.log(rank / (1 - rank + 1e-12))) # 相对排名 → logit
return (np.array(logits) <= 0).mean() # 样本外落到后 50% 的比例 = PBO
PBO 的解读:低于 0.5 是底线,低于 0.3 才较放心。PBO 高说明「选最优」这个动作本身不可靠——你选出来的配置在样本外大概率表现平平。
CSCV 的一个额外好处是它不依赖对策略独立性的假设,也不需要估计试验次数 N。它直接从「候选配置集合」出发,用组合的方式评估「选择过程」的可靠性。这让它比 DSR 更容易落地。
DSR 与 PBO 是互补的:DSR 回答「这个 Sharpe 显著吗」,PBO 回答「我的选择过程可靠吗」。前者针对单个结果,后者针对整个研究流程。两者都应该出现在策略上线评审的材料里。
CSCV 的候选集合构造是主观的,这决定了 PBO 的解读边界。候选集合应该包含你真实考虑过的所有配置,而不只是「最终进入决赛的几个」。如果只把两个差不多的配置放进候选集合,PBO 会人为地偏低。一个实用的做法是:把参数搜索过程中的全部网格点作为候选集合,这样 PBO 反映的是真实的搜索过程。
7. White Reality Check 与 SPA 检验
当候选策略数量很多、且存在相关性时,White’s Reality Check 是更严格的检验。它的思路是bootstrap 生成「无效世界」下的最大收益分布,然后看真实的最大收益是否显著超出:
def reality_check(strategy_returns, benchmark_returns, n_boot=1000):
# strategy_returns: T×N,每个策略相对基准的超额收益
T, N = strategy_returns.shape
best = strategy_returns.mean(0).max() # 观测到的最好策略
stat_dist = []
for _ in range(n_boot):
# 用平稳 bootstrap 重采样时间点(保留时序相关性)
idx = stationary_bootstrap_indices(T)
stat_dist.append(strategy_returns[idx].mean(0).max() - strategy_returns.mean(0).max())
p_value = (np.array(stat_dist) >= best).mean()
return p_value # 越小越显著
Hansen’s SPA 检验是 Reality Check 的改进版,它排除了「明显劣于基准」的策略对检验的干扰,统计功效更高。
这些检验的共同价值是:它们把「我试了很多个,最好的这个有效吗」这个问题,变成了一个有明确 p 值的统计问题。与 DSR 相比,它们不要求你估计 N,而是直接从策略集合的分布出发。
实践中,Reality Check 与 SPA 的实现比 DSR/PBO 复杂,通常只在正式的策略评审环节使用。日常研究中,DSR + PBO 已经能拦下绝大多数过拟合的策略。
8. 最小回测长度与实验设计
最小回测长度(Minimum Backtest Length, MinBTL) 回答一个前置问题:要检验一个 Sharpe 为 SR 的策略,至少需要多长的回测数据?
MinBTL ≈ 2 · ln(N) / SR²
N:试验次数,SR:期望的 Sharpe(年化需换算)
import numpy as np
def min_backtest_years(target_sharpe, n_trials):
# 年化 Sharpe 下的最小回测年数
return 2 * np.log(n_trials) / (target_sharpe ** 2)
print(f"{min_backtest_years(1.0, 100):.1f} 年") # 目标 Sharpe 1,试 100 次
print(f"{min_backtest_years(1.0, 1000):.1f} 年") # 试 1000 次
结果往往让人沮丧:目标 Sharpe = 1、试了 1000 个想法,需要约 14 年的数据才能区分「真有效」和「运气好」。而大多数团队只有 5~10 年的数据。这意味着很多研究在数据量上就不具备得出结论的条件。
MinBTL 的实践含义是:降低试验次数比延长数据更有效。与其测试 1000 个想法,不如用经济学逻辑先筛选,只测 20 个有明确假设的想法——这能把 MinBTL 从 14 年降到 6 年。
实验设计的原则:
1. 先写假设,再跑回测(而不是先跑再看哪个好)
2. 记录所有尝试过的想法,包括放弃的
3. 参数搜索用粗网格 + 稳健性检验,不做精细搜索
4. 保留一个从未看过的最终样本外
5. 优先检验「经济学上应该有效」的想法
参数搜索的方式直接决定过拟合程度:
| 搜索方式 | 试验次数 | 过拟合风险 |
|---|---|---|
| 单一默认参数 | 1 | 极低 |
| 粗网格(如 3×3) | 9 | 低 |
| 精细网格 | 数百 | 高 |
| 数值优化到最优 | 无穷 | 极高 |
「参数平原」比「参数尖峰」可信。如果一组参数表现好,且它的邻域参数表现也都不错,说明这是一个稳健的区域;如果只有孤立的一个点表现好、旁边就是悬崖,那大概率是噪声。参数搜索的正确姿势是找平原,而不是找最高点。
9. 研究流程纪律
方法论最终要落到流程上。过拟合的根源是研究自由度,纪律的作用就是约束自由度。
| 纪律 | 作用 |
|---|---|
| 假设先行 | 防止「先射箭再画靶」 |
| 全量记录 | 让多重检验可校正 |
| 代码评审 | 独立复核,发现隐藏偏差 |
| 独立复现 | 换人用不同代码重跑 |
| 上线前封存样本外 | 保证最终检验独立 |
| 事前设定否决线 | 防止事后找理由 |
「假设先行」是最重要的一条。正确的研究顺序是:先有经济学或行为学上的假设(「为什么这个因子应该有效」),再用数据检验;而不是先看数据里有什么规律,再编一个理由解释它。后者必然过拟合,因为你在数据里找到的规律本身就是噪声。
独立复现是第二重要的。同一个研究员用同一套代码「验证」自己的策略,几乎必然通过——因为验证过程中会不自觉地做出一系列「合理」的选择。换一个人、用另一套独立实现的代码重跑,能暴露大量隐藏的偏差。
事前设定否决线也很关键。在开始研究前就写清楚:「如果样本外 Sharpe < 1.0,或者 PBO > 0.5,或者 DSR < 0.95,则策略否决」。事前设定的标准不会被「沉没成本」影响;事后设定的标准,总能为想上线的东西找到理由。
机器学习建模里的 purging、CPCV,本质上也是流程纪律在 ML 场景的具体化——防止验证过程泄漏。因子研究的多重检验校正,则与 因子研究与有效性检验 里讨论的 FDR 是同一套工具。
权衡取舍
| 维度 | 严格纪律 | 宽松探索 |
|---|---|---|
| 假阳性 | 少 | 多 |
| 错过真信号 | 多 | 少 |
| 研究效率 | 低 | 高 |
| 实盘可靠性 | 高 | 低 |
纪律与研究速度存在真实张力。严格的预注册和多重检验校正会显著降低「产出」(大量想法被否决),但提高了「存活率」。对于管理大资金的团队,宁可少发现几个策略,也不能把过拟合的策略推上实盘——一次大规模回撤的代价远超几个错过的策略。
度量上也有取舍:DSR 依赖对 N 的估计(易错),PBO 依赖候选集合的构造(主观)。两者都不完美,但都比「只看样本内 Sharpe」强得多。实践中应该同时报告,并让它们互相印证。
常见坑清单
- 只看样本内 Sharpe:不考虑试验次数,任何 Sharpe 都不可信。
- 事后统计 N:忘记放弃的尝试,低估多重检验。
- 反复看样本外:一旦根据样本外调整,它就不再是样本外。
- 只用一个样本外区间:结果依赖区间选择,应多时段验证。
- 用 Bonferroni 一刀切:过于保守,错过真实信号,应用 FDR。
- DSR 忽略偏度峰度:负偏厚尾的收益,Sharpe 标准误被低估。
- PBO 用单次划分:应使用 CSCV 的多组合枚举。
- 不做最小长度检验:数据量根本不足以得出结论。
- 先看数据再编假设:在噪声里找规律,必然过拟合。
- 不做事前否决线:沉没成本让人为想上线的东西找理由。
小结
回测过拟合不是「不小心犯的错」,而是统计上的必然。只要你测试了足够多的想法,其中总会有一些因为运气而显得有效。防范的核心不是「更仔细地检查」,而是用统计工具量化运气的影响:DSR 校正试验次数、PBO 评估选择过程、MinBTL 判断数据是否够用。
判断一个研究流程是否可靠,看三个问题:有没有记录所有尝试过的想法(包括放弃的)?有没有封存一个从未看过的样本外?有没有事前设定的否决标准? 三个都是「是」,才可能做出可信的研究;否则再漂亮的回测也只是运气。
下一步建议阅读 因子研究与有效性检验 ,看这些方法论在因子层面的具体落地;如果你关心回测框架本身如何实现这些约束,可以回顾 回测框架设计与前视偏差 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。