跳到正文

学习路线 02:组合清仓 CV — 测试策略稳健性

7步学习路线:从理解为什么单一 train-test 划分不够,到组合枚举所有划分方式,到计算 PBO。

第 2 篇 · 共 7 篇 约 3 分钟

目标:完成后,你将理解为什么单一 train-test 划分会误导你,CPCV 如何枚举所有可能的划分方式来测试稳健性,以及如何计算 PBO(回测过拟合概率)。

前置知识:熟悉 purged K-fold CV(学习路线 01)。


第 1 步:这是什么?

你用 5 折 purged K-fold 运行。得到样本外 Sharpe 1.2。你应该相信吗?

一个不舒服的问题:如果数据划分的方式恰好产生了有利结果呢?5 折只有一种划分方式。如果那个特定划分恰好与牛市期间对齐呢?

标准 purged K-fold 给你一个答案。但一个划分的一个答案不够稳健。

组合清仓 CV(CPCV) 枚举所有可能的数据划分方式,不是 k 种,而是 2^k - 1 种。

对于 k=6:63 种不同的划分。对于 k=8:255 种划分。

每个划分给出不同的 train/test 分割。如果你的策略在所有 63 种划分中样本外 Sharpe 都持续高,它是稳健的。如果差异很大——有些高,有些低或负——你就有问题了。


第 2 步:为什么这很重要?

一个具体的场景。你有一个动量策略。你在 3 年数据上优化它。你用 k=5 purged K-fold。样本外 Sharpe 是 1.4。

但 CPCV 会发现:策略只在测试集包含最近一年时表现良好(那是强劲牛市)。当测试集是早期(波动更大),Sharpe 跌到 0.2。

一个 purged K-fold 划分掩盖了这一点。63 种 CPCV 划分揭示了它。

CPCV 告诉你策略表现是否在不同的数据划分方式下稳定,而不只是一个划分上的幸运。


第 3 步:核心机制

CPCV 通过选择 k 然后枚举 k 个折的所有非空子集作为训练集来工作。剩余的折是测试集。

对于 k=4 折:

  • 训练 {1},测试 {2,3,4}
  • 训练 {2},测试 {1,3,4}
  • 训练 {3},测试 {1,2,4}
  • 训练 {4},测试 {1,2,3}
  • 训练 {1,2},测试 {3,4}
  • …(所有 2^4 - 1 = 15 种组合)

对于每个组合,你:

  1. 在选定的折上训练
  2. 在留出的折上测试
  3. 记录训练 Sharpe 和测试 Sharpe

然后你分析所有组合中训练 Sharpe 和测试 Sharpe 之间的关系。


第 4 步:关键公式

组合数: Ncomb=2k−1N_{comb} = 2^k - 1

对于 k=6:N_comb = 63。对于 k=8:N_comb = 255。

对于每个组合 i:

  • 在折子集上训练,得到训练 Sharpe:SRtrain(i)SR_{train}^{(i)}
  • 在留出折上测试,得到测试 Sharpe:SRtest(i)SR_{test}^{(i)}

PBO(回测过拟合概率): 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]

用大白话说:在所有训练 Sharpe 排名前 10% 的组合中,有多少比例在测试 Sharpe 排名后 50%?

PBO 为 10% 意味着:10% 的情况下,一个在训练中看起来很棒的策略在测试中看起来很糟糕。这是掷硬币。你想要 PBO < 5%。


第 5 步:实现

import numpy as np
from itertools import combinations

def cpcv_pbo(returns_matrix, k=6, top_pct=0.10, bottom_pct=0.50):
    """
    使用 CPCV 计算回测过拟合概率。
    """
    n_samples, n_strategies = returns_matrix.shape
    samples_per_fold = n_samples // k

    # 全期 Sharpe 用于排名
    full_sharpes = np.array([sharpe_ratio(returns_matrix[:, i])
                              for i in range(n_strategies)])

    # 存储每个组合的训练和测试 Sharpe
    train_sharpes_all = []
    test_sharpes_all = []

    # 枚举 k 折的所有非空子集
    for r in range(1, k + 1):
        for train_folds in combinations(range(k), r):
            train_mask = np.zeros(k, dtype=bool)
            train_mask[list(train_folds)] = True
            test_mask = ~train_mask

            # 构建样本掩码
            train_samples = np.zeros(n_samples, dtype=bool)
            test_samples = np.zeros(n_samples, dtype=bool)

            for fold_idx in range(k):
                start = fold_idx * samples_per_fold
                end = start + samples_per_fold
                if train_mask[fold_idx]:
                    train_samples[start:end] = True
                if test_mask[fold_idx]:
                    test_samples[start:end] = True

            if train_samples.sum() > 0 and test_samples.sum() > 0:
                train_ret = returns_matrix[train_samples]
                test_ret = returns_matrix[test_samples]

                tsr = sharpe_ratio(train_ret)
                esr = sharpe_ratio(test_ret)

                train_sharpes_all.append(tsr)
                test_sharpes_all.append(esr)

    train_sharpes_all = np.array(train_sharpes_all)
    test_sharpes_all = np.array(test_sharpes_all)

    # 计算 PBO
    n_comb = len(train_sharpes_all)
    top_threshold = int(top_pct * n_comb)
    bottom_threshold = int(bottom_pct * n_comb)

    n_overfitted = 0
    for i in range(n_comb):
        train_rank = np.sum(full_sharpes > full_sharpes[i])
        test_rank = np.sum(test_sharpes_all > test_sharpes_all[i])

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

    pbo = n_overfitted / n_comb

    return {
        'pbo': pbo,
        'train_sharpes': train_sharpes_all,
        'test_sharpes': test_sharpes_all,
        'n_combinations': n_comb
    }


def sharpe_ratio(returns, periods_per_year=252):
    if np.std(returns) == 0:
        return 0.0
    return np.mean(returns) / np.std(returns) * np.sqrt(periods_per_year)

第 6 步:如何解读结果

PBO < 5%:策略可能是稳健的。最佳回测是运气的概率低。

PBO 5-25%:谨慎行事。策略可能过拟合。检查你测试了多少策略变体——如果你测试了很多,这个 PBO 仍然过于乐观。

PBO > 25%:过拟合概率高。超过 1/4 的情况下,一个看起来很棒的训练结果在测试中表现糟糕。

看训练 vs. 测试相关性:如果高训练 Sharpe 持续导致高测试 Sharpe,策略是稳健的。如果关系微弱或为负,策略是脆弱的。


第 7 步:常见陷阱 + 在知识体系中的位置

陷阱:使用的 k 太小。 k=4 只给 15 种组合——太少,无法可靠估计 PBO。使用 k≥6(63+ 组合)。如果数据允许,k=8(255 组合)更好。

陷阱:CPCV 不清仓。 不清仓的 CPCV 存在前视偏差。总是结合 purge 间隙(根据你信号半衰期校准)使用。

陷阱:忘记 PBO 是有条件的。 如果你测试的 100 个策略变体中有 90% 是真正无价值的,PBO 为 10% 意味着 10% × 90% = 9% 是看起来好的中的假阳性。先验率很重要。

在知识体系中的位置:

  • 当你从许多策略变体中选择并想知道最佳是假阳性的概率时
  • 作为 PBO 计算的输入
  • 在上实盘前测试策略稳健性

CPCV + PBO 是策略过拟合的 definitive test。在部署任何基于历史数据优化的策略之前使用它。


下一步

准备好下一级了吗?→ 学习路线 03:缩减 Sharpe 比率

想要百科参考卡片?→ 回测过拟合:PBO(百科词条)