回测偏差与过拟合防范

回测偏差与过拟合防范的方法论:前视与幸存者偏差为何无法靠代码修复、数据窥探与多重检验的数学后果、Deflated Sharpe Ratio 的完整计算、PBO 与 CSCV、White Reality Check 与 SPA 检验、最小回测长度,以及研究流程纪律与预注册实验设计。

回测过拟合是量化研究里最普遍、最难自察、代价最高的错误。它不同于代码 bug——代码 bug 会让结果异常,而过拟合会让结果「漂亮得合理」,让研究员深信自己发现了金矿。等到实盘亏钱,已经付出了真金白银。

前视偏差、幸存者偏差这些「实现层」的陷阱,回测框架设计与前视偏差 已经系统讲过,那篇的重点是「怎么在框架里消除它们」。本文的重点不同:即使框架完全正确,回测结果仍然大概率是过拟合的,因为问题出在研究方法本身,而不是代码。

真正难的地方在于:过拟合不是「一个错误」,而是「一种统计必然」。只要你测试了足够多的想法,其中总会有一些因为运气而显得有效。防范过拟合,本质上是对抗这种统计必然——用多重检验校正、用概率化的过拟合度量、用流程纪律来约束研究的自由度。

本文按「根因 → 偏差的方法论 → 多重检验 → 样本外 → DSR → PBO → 其它检验 → 实验设计 → 流程纪律」的顺序展开。

目录

  1. 回测过拟合的统计必然
  2. 前视与幸存者偏差的方法论视角
  3. 数据窥探与多重检验
  4. 样本外验证的正确用法
  5. Deflated Sharpe Ratio
  6. PBO 与 CSCV
  7. White Reality Check 与 SPA 检验
  8. 最小回测长度与实验设计
  9. 研究流程纪律

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 值失去意义。

多重检验的校正方法有三类:

方法原理特点
Bonferronip × 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 有两个容易忽略的细节:

  1. N 是「独立」策略数,不是「总」策略数。高度相关的策略(同一想法的不同参数)算作一个。实践中 N 很难精确估计,应该保守估计。
  2. 偏度与峰度必须代入。量化策略的收益通常负偏、厚尾(平时小赚、偶尔大亏),这会让 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」强得多。实践中应该同时报告,并让它们互相印证。

常见坑清单

  1. 只看样本内 Sharpe:不考虑试验次数,任何 Sharpe 都不可信。
  2. 事后统计 N:忘记放弃的尝试,低估多重检验。
  3. 反复看样本外:一旦根据样本外调整,它就不再是样本外。
  4. 只用一个样本外区间:结果依赖区间选择,应多时段验证。
  5. 用 Bonferroni 一刀切:过于保守,错过真实信号,应用 FDR。
  6. DSR 忽略偏度峰度:负偏厚尾的收益,Sharpe 标准误被低估。
  7. PBO 用单次划分:应使用 CSCV 的多组合枚举。
  8. 不做最小长度检验:数据量根本不足以得出结论。
  9. 先看数据再编假设:在噪声里找规律,必然过拟合。
  10. 不做事前否决线:沉没成本让人为想上线的东西找理由。

小结

回测过拟合不是「不小心犯的错」,而是统计上的必然。只要你测试了足够多的想法,其中总会有一些因为运气而显得有效。防范的核心不是「更仔细地检查」,而是用统计工具量化运气的影响:DSR 校正试验次数、PBO 评估选择过程、MinBTL 判断数据是否够用。

判断一个研究流程是否可靠,看三个问题:有没有记录所有尝试过的想法(包括放弃的)?有没有封存一个从未看过的样本外?有没有事前设定的否决标准? 三个都是「是」,才可能做出可信的研究;否则再漂亮的回测也只是运气。

下一步建议阅读 因子研究与有效性检验 ,看这些方法论在因子层面的具体落地;如果你关心回测框架本身如何实现这些约束,可以回顾 回测框架设计与前视偏差 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「量化交易」更多文章

  1. 风险模型与因子归因
  2. 市场微结构与流动性
  3. 衍生品定价与希腊字母