组合清仓交叉验证 (CPCV)
CPCV 通过枚举所有可能的 train/test 划分来扩展 purged K-fold,实现稳健的性能估计和 PBO 计算。
标准 purged K-fold 给你 k 个 train/test 划分。CPCV 给你所有 2^k - 1 个 k 折的非空子集作为训练集,其余作为测试集。这种穷尽性使得 PBO(回测过拟合概率)计算成为可能。
标准 Purged K-Fold 的核心问题
Purged K-fold 是比天真 K-fold 的一步升级。但它仍然有一个根本限制:k 折只能给你 k 个不同的 train/test 划分。
如果你划分数据的方式恰好与有利的市场结构对齐怎么办?你的 OOS 估计可能偏乐观——只是运气好,划分碰巧合适。
更广泛地说:一种划分不能告诉你策略表现是否在不同划分方式下稳定。你需要多种划分。
CPCV 如何工作
CPCV 选择一个数字 k,然后枚举那些 k 折的所有非空子集作为训练集。剩余的折成为测试集。
对于 k=6 折(说明性):
折组合 | 训练折 | 测试折
------------|----------|----------
{1} | 折 1 | 折 2,3,4,5,6
{2} | 折 2 | 折 1,3,4,5,6
{3} | 折 3 | 折 1,2,4,5,6
{1,2} | 折 1,2 | 折 3,4,5,6
{1,3} | 折 1,3 | 折 2,4,5,6
...
(所有 2^6-1 = 63 种组合)
对于每个组合,你计算策略在训练部分和测试部分的 Sharpe。然后你分析关系。
数学
组合划分数:
对于 k=6:N_comb = 63。对于 k=8:N_comb = 255。计算成本随 k 指数增长。
对于每个组合 i:
- 训练 Sharpe:
- 测试 Sharpe:
你寻找的关键关系:高 是否预测高 ?
- 如果是(高相关性):策略是稳健的
- 如果否(低或负相关性):策略是过拟合的
PBO(回测过拟合概率) 从这个关系计算。它测量高训练排名变成低测试排名的频率。
Python 实现
import numpy as np
from itertools import combinations
def cpcv_partitions(n_samples, k=6, n_purge=None, pct_embargo=0.01):
"""
生成所有 CPCV train/test 划分。
"""
n_per_fold = n_samples // k
n_embargo = int(n_samples * pct_embargo)
if n_purge is None:
n_purge = int(np.sqrt(n_samples))
# 枚举 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_indices = []
test_indices = []
for fold_idx in range(k):
start = fold_idx * n_per_fold
end = start + n_per_fold
if train_mask[fold_idx]:
train_indices.extend(range(start, end))
if test_mask[fold_idx]:
test_indices.extend(range(start, end))
# embargo 后测试折
embargo_indices = []
for fold_idx in range(k):
if test_mask[fold_idx]:
start = fold_idx * n_per_fold + n_per_fold
end = min(start + n_embargo, n_samples)
embargo_indices.extend(range(start, end))
embargo_set = set(embargo_indices)
train_indices = [i for i in train_indices if i not in embargo_set]
test_indices = [i for i in test_indices if i not in embargo_set]
yield np.array(train_indices), np.array(test_indices)
def cpcv_evaluate(model, X, y, k=6, pct_embargo=0.01):
"""
使用 CPCV 评估策略。
"""
from sklearn.base import clone
train_sharpes = []
test_sharpes = []
for train_idx, test_idx in cpcv_partitions(len(X), k=k, pct_embargo=pct_embargo):
if len(train_idx) < 10 or len(test_idx) < 10:
continue
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
model_clone = clone(model)
model_clone.fit(X_train, y_train)
preds_test = model_clone.predict(X_test)
test_ret = preds_test * y_test
test_sharpe = sharpe_ratio(test_ret)
test_sharpes.append(test_sharpe)
return {
'test_sharpes': np.array(test_sharpes),
'n_partitions': len(test_sharpes)
}
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)
如何解读结果
训练-测试相关性 是你的主要诊断。高训练 Sharpe 预测高测试 Sharpe(相关性 > 0.7)——策略是稳健的。相关性接近 0 或为负意味着策略表现有结构性依赖或过拟合。
PBO(从这些结果计算)告诉你高训练/低测试结果的比例。PBO < 5% 是好的。PBO > 25% 应该是 dealbreaker。
测试 Sharpe 的分布 告诉你关于结构性敏感度。紧密分布(跨划分低标准差)意味着无论市场条件如何,策略表现相似。宽分布意味着策略对你训练和测试的时期敏感。
常见陷阱
使用的 k 太小。 k=4 只给 15 种划分——不足以可靠估计 PBO。使用 k≥6(63+)或 k=8(255),如果你的数据每折有足够样本。
在每个划分中忘记清仓。 不清仓的 CPCV 在折边界存在前视偏差。总是应用清仓间隙。
计算成本。 N_comb = 2^k - 1 增长很快。k=8 给 255 种组合,每种需要完整模型拟合。对于大型策略筛选,k=6(63 种组合)是实用的折中。
忽略划分内的 embargo。 每个测试折后应有 embargo 间隙,特别是对于高频策略。
在知识体系中的位置
CPCV 是 PBO 计算的基础。它提供了穷尽性的 train/test 划分枚举,使测量”很棒的样本内”变成”糟糕的样本外”的频率成为可能。
CPCV 还给你比标准 purged K-fold 更稳健的 OOS 估计:不是一种划分,而是 N_comb 种划分,每种给出不同的 OOS Sharpe。在所有划分上平均给出更稳定的估计。
参考文献
- López de Prado, Advances in Financial Machine Learning, 第 12 章. Wiley, 2018. ISBN: 978-1-119-48208-6.
- 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.