学习路线 02:组合清仓 CV — 测试策略稳健性
7步学习路线:从理解为什么单一 train-test 划分不够,到组合枚举所有划分方式,到计算 PBO。
目标:完成后,你将理解为什么单一 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 种组合)
对于每个组合,你:
- 在选定的折上训练
- 在留出的折上测试
- 记录训练 Sharpe 和测试 Sharpe
然后你分析所有组合中训练 Sharpe 和测试 Sharpe 之间的关系。
第 4 步:关键公式
组合数:
对于 k=6:N_comb = 63。对于 k=8:N_comb = 255。
对于每个组合 i:
- 在折子集上训练,得到训练 Sharpe:
- 在留出折上测试,得到测试 Sharpe:
PBO(回测过拟合概率):
用大白话说:在所有训练 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(百科词条)