跳到正文

回测过拟合:PBO 与组合清仓交叉验证

回测过拟合概率(PBO)衡量你'最佳'策略是统计幻觉的可能性。组合清仓交叉验证是计算 PBO 的工具。

第 5 篇 · 共 9 篇 约 3 分钟

一家对冲基金推出一个回测 3.2 Sharpe 比率的策略。三年后,实际只有 0.5。投资者起诉。基金经理说「市场结构变了」。真正的原因更简单:这个策略对回测过拟合了。它从来没有看起来那么好。

回测过拟合是量化策略开发的核心风险。你在历史数据上优化策略,选择表现最佳的变体,而最佳表现者——顾名思义——是你测试集中最幸运的那个。PBO(回测过拟合概率)量化了这种运气。

直觉

想象你有 100 个真正无价值的交易策略。你回测所有 100 个。按 α=0.05,5 个会显示「显著」的正收益。其中最好的可能显示 1.5 的 Sharpe。

这就是 PBO。给定你搜索的强度(你测试了多少变体),你最佳策略是假阳性的概率是多少?

正式答案需要知道两件事:你测试了多少策略,以及在真正的空假设下回测结果在这些策略之间有多大差异。

组合清仓交叉验证(CPCV)

CPCV 是 Bailey 和 López de Prado(2012)计算 PBO 的方法。核心思想:不是用 k 折,而是枚举你的数据所有可能的训练和测试分割方式。对于每个分割,计算策略的表现。如果策略是真正的空(没有技能),训练期(样本内)表现与测试期(样本外)表现之间的相关性应该接近零。高相关性暗示过拟合。

标准 purged K-fold 使用 k 个分割。CPCV 枚举所有可能的 m 训练/测试分割组合,其中 m 是为平衡计算成本与穷尽性而选择的。

CPCV 组合数:

Ncomb=∑j=1k(kj)=2k−1N_{comb} = \sum_{j=1}^{k} {k \choose j} = 2^k - 1

对于 k=6:N_comb = 63。对于 k=8:N_comb = 255。计算量指数增长。

PBO 公式

对于每个组合分割,定义:

  • SRtrainSR_{train}:训练(样本内)期间的 Sharpe 比率
  • SRtestSR_{test}:测试(样本外)期间的 Sharpe 比率

如果策略过拟合,高 SRtrainSR_{train} 将与低 SRtestSR_{test} 相关联(当分割真正是样本外时)。如果策略有真正的技能,高 SRtrainSR_{train} 应该预测高 SRtestSR_{test}。

PBO 是:在所有组合分割中,策略的 SRtrainSR_{train} 排名在前 X%,但 SRtestSR_{test} 排名在后 Y% 的比例。

常见阈值:训练 Sharpe 前 10%,测试 Sharpe 后 50%。

PBO=1Ncomb∑i=1Ncomb1[Ranktrain(i)≤0.1⋅Ncomb AND Ranktest(i)>0.5⋅Ncomb]PBO = \frac{1}{N_{comb}} \sum_{i=1}^{N_{comb}} \mathbf{1}\left[Rank_{train}(i) \leq 0.1 \cdot N_{comb} \ AND \ Rank_{test}(i) > 0.5 \cdot N_{comb}\right]

用大白话说:样本内看起来很棒的结果,有多大比例会在样本外排名低于中位数?

Python 实现

import numpy as np
import pandas as pd
from itertools import combinations

def compute_pbo(returns_matrix, n_train_test_splits=6, top_pct=0.10, bottom_pct=0.50):
    """
    Compute Probability of Backtest Overfitting using CPCV.

    Parameters
    ----------
    returns_matrix : array (n_samples, n_strategies)
        Backtest returns for each strategy
    n_train_test_splits : int
        Number of train/test partitions to enumerate (k in 2^k - 1)
    top_pct : float
        Top X% of train-Sharpe rankings considered "winning"
    bottom_pct : float
        Bottom Y% of test-Sharpe rankings considered "losing"

    Returns
    -------
    pbo : float
        Probability of backtest overfitting
    out_of_sample_sharpes : array
        Sharpe ratios for each combinatorial partition
    """
    n_samples, n_strategies = returns_matrix.shape
    n_partitions = 2 ** n_train_test_splits - 1

    # Compute Sharpe for each strategy (full period)
    full_sharpes = sharpe_ratio(returns_matrix)

    # Enumerate all combinatorial splits
    k = n_train_test_splits
    splits = list(range(n_samples))
    out_of_sample_sharpes = []

    for r in range(1, k + 1):
        for train_indices in combinations(range(k), r):
            # Build train and test masks
            train_mask = np.zeros(k, dtype=bool)
            test_mask = np.zeros(k, dtype=bool)

            train_indices = list(train_indices)
            for i in range(k):
                if i in train_indices:
                    train_mask[i] = True
                else:
                    test_mask[i] = True

            # Map fold indices to sample indices
            samples_per_fold = n_samples // k
            train_start_folds = [i * samples_per_fold for i in train_indices]
            test_start_folds = [i * samples_per_fold for i in range(k) if i not in train_indices]

            train_mask_samples = np.zeros(n_samples, dtype=bool)
            test_mask_samples = np.zeros(n_samples, dtype=bool)

            for i, fold_start in enumerate([idx * samples_per_fold for idx in train_indices]):
                train_mask_samples[fold_start:fold_start + samples_per_fold] = True

            for fold_start in test_start_folds:
                test_mask_samples[fold_start:fold_start + samples_per_fold] = True

            # Compute Sharpe for each strategy on train and test
            if train_mask_samples.sum() > 0 and test_mask_samples.sum() > 0:
                train_sharpes = sharpe_ratio(returns_matrix[train_mask_samples])
                test_sharpes = sharpe_ratio(returns_matrix[test_mask_samples])
                out_of_sample_sharpes.append(test_sharpes)

    out_of_sample_sharpes = np.array(out_of_sample_sharpes)

    # Rank strategies by full-period Sharpe
    ranks = np.argsort(np.argsort(-full_sharpes))  # Descending rank

    # PBO: top X% by train, bottom Y% by test
    n_strats = len(full_sharpes)
    top_threshold = int(top_pct * n_strats)
    bottom_threshold = int(bottom_pct * n_strats)

    n_overfitted = 0
    for i, test_sharpe in enumerate(out_of_sample_sharpes):
        train_rank = ranks[i]
        test_rank = np.argsort(np.argsort(-test_sharpe))[i]

        if train_rank < top_threshold and test_rank >= bottom_threshold:
            n_overfitted += 1

    pbo = n_overfitted / len(out_of_sample_sharpes)

    return pbo, out_of_sample_sharpes


def sharpe_ratio(returns, risk_free=0.0, periods_per_year=252):
    """Annualized Sharpe ratio."""
    excess = returns - risk_free
    if np.std(excess) == 0:
        return 0.0
    return np.mean(excess) / np.std(excess) * np.sqrt(periods_per_year)

如何解读 PBO

PBO解读
< 5%策略可能是稳健的。最佳回测是运气的概率低。
5–25%谨慎行事。检查你的搜索是否穷尽。
25–50%过拟合风险高。考虑更简单的策略或更多数据。
> 50%「最佳」策略是假阳性的可能性高于真实。这是掷硬币——勉强比随机好。

PBO 为 30% 意味着 30% 的情况下,一个按样本内 Sharpe 排名前 10% 的策略会按样本外 Sharpe 排名后 50%。这是掷硬币。

常见陷阱

使用的组合分割太少:k=4 只给出 15 个分割。这太少,无法可靠估计 PBO。如果数据允许,使用 k≥6(63+ 分割)或 k≥8(255 分割)。

不清仓:没有清仓的 CPCV 与标准 K-fold 一样存在前视偏差。清仓应根据策略的信号半衰期进行校准。

对单个策略报告 PBO:当你从许多策略变体中选择时,PBO 才有意义。如果你只测试一个策略与基准相比,PBO 不适用——使用标准统计检验。

忽略先验率:如果你的宇宙中 90% 的策略真正无价值,PBO 为 10% 仍然意味着大多数「最佳」策略是假阳性。PBO 需要在你任何策略有真实技能的先验概率背景下解读。

在知识体系中的位置

PBO 是策略选择过程是否过拟合的 definitive test。它需要组合清仓交叉验证作为输入。《多重检验》涵盖了你只筛选特征时的更简单(但功效较低)的校正。《概率化回测》提供了解释回测结果的贝叶斯框架。

我们的平台在接收任何策略之前会计算其 PBO。PBO > 25% 的策略会被标记为需要额外审查或拒绝。

参考文献

  • Bailey, López de Prado, “The Share of Tradeable and Non-Tradeable Strategies,” Journal of Computational Finance, 2012.
  • Bailey, Borwein, López de Prado, “Stock Return Data: A Non-Factorial Analysis,” Journal of Financial Data Science, 2017.
  • López de Prado, Advances in Financial Machine Learning, 第 12 章. Wiley, 2018. ISBN: 978-1-119-48208-6.
  • White, “A Reality Check for Data Snooping,” Econometrica, 2000.