跳到正文

CPCV:正确推导组合净化交叉验证

金融里交叉验证出错只有一个原因:观测在时间上是黏连的。净化、禁运、路径计数公式全是这一件事的推论——包括 CPCV 为此付出的那个代价。

第 7 篇 · 共 12 篇 约 12 分钟

标准的 k 折交叉验证不适合金融机器学习,原因只有一个:观测在时间上是黏连的。时点 tt 的标签用到 tt 之后的数据,时点 tt 的特征用到 tt 之前的数据,于是任何一刀切下去,切口两侧都还连着。而 k 折 CV 的整套理论建立在”折与折之间独立”这个前提上。

这篇文章要推的每一样东西——净化、禁运、路径计数公式 φ[N,k]\varphi[N,k]、CPCV 和 walk-forward 的分歧——都是从那一句话推出来的。最后还有一条代价,是原始 CPCV 的介绍里经常不提的:为了换到更多路径,CPCV 允许模型拿未来的数据训练去预测过去。

泄露是怎么发生的

标准 k 折 CV 把数据切成 kk 个连续块,在 k−1k-1 块上训练、在剩下那块上测试,然后轮换。回测里的标准流程是:

  1. 从 tt 之前的数据计算特征 XtX_t。
  2. 用从 tt 到 t+ht + h 的数据计算标签 yty_t(例如前向收益)。
  3. 训练模型用 XtX_t 预测 yty_t。
  4. 在折外数据上测试。

第 2 步就是问题所在:yty_t 用到了 tt 之后的信息,于是训练集里可能存在标签跨进测试集的观测。

拿具体天数来看。假设特征里有 20 天移动平均,标签是 5 天前向收益。训练集是第 1 到 252 天,测试集是第 253 到 378 天。第 250 天这个训练观测的标签,用的是第 250 到 255 天的收益;第 252 天的标签用的是第 252 到 257 天的收益。第 253 到 257 天在测试集里。

也就是说,模型在训练阶段就已经通过标签看到了测试期前几天的收益。它在测试时表现得比它应有的水平好,而这个”好”完全是记住来的。测试指标被系统性地向上偏——这就是泄露。

净化:处理伸向未来的标签

修正很直接:把标签与测试集重叠的训练观测从训练集里删掉。这个操作叫净化(purging)。

记测试折的范围是索引 tstartt_{start} 到 tendt_{end},标签跨度是 hh。一个训练观测 tt 的标签结束于 t+ht + h,只要它够到了测试集起点,就得删:

Purge set={t∈Train:t<tstart 且 t+h≥tstart}\text{Purge set} = \{t \in \text{Train} : t < t_{start} \text{ 且 } t + h \ge t_{start}\}

两个条件都要。前一个把范围限定在测试集之前的训练观测上,后一个才是重叠判据;只写后一个会把测试集之后的整段训练数据一起误删。

代进上面的数字:h=5h = 5、tstart=253t_{start} = 253,被净化的是满足 t≥248t \ge 248 的训练观测,也就是第 248、249、250、251、252 天,正好 5 个。

净化长度等于标签跨度 hh,这是下限。标签越长,要切掉的越多——一个 21 天前向收益的标签,就要在每个测试折前面切掉 21 个观测。

禁运:处理伸向过去的特征

净化只堵住了一个方向。标签向前伸,特征向后伸,后一个方向同样会漏。

考虑测试折结束之后紧邻的那些训练观测。第 tend+3t_{end} + 3 天的特征里有一个 20 天移动平均,这个窗口往回覆盖了 tend−17t_{end} - 17 到 tend+3t_{end} + 3——大半落在测试期内。把这个观测放进训练集,等于把测试期的价格信息以特征的形式喂给了模型。

禁运(embargo)就是把测试折之后的一小段训练观测一并删掉:

Embargo set={t∈Train:tend<t≤tend+e}\text{Embargo set} = \{t \in \text{Train} : t_{end} < t \le t_{end} + e\}

其中 ee 是禁运长度。注意禁运只作用在测试折之后,因为测试折之前的那一侧已经由净化处理过了。两个操作方向相反、各管一头,合起来才能让训练集和测试集在”已知标签跨度和特征窗口”的前提下互不相通。

ee 怎么定?标签跨度短、特征窗口也短的策略,e=he = h 就够。更一般的经验法则是

e=max⁡(h,w)e = \max(h, w)

其中 ww 是特征里最长的滚动窗口。这样标签依赖和特征依赖都被覆盖。有些实现直接取总数据的 1% 到 5%,省事但不看具体特征,属于粗糙的保守做法。e=0e = 0 就是不做禁运。

还有一种不太标准但值得知道的用法:把 ee 设成市场状态的典型长度,强制训练数据和测试数据来自同一个状态。如果测试期在高波动状态、训练集里却混着低波动状态的数据,模型学到的关系可能根本不适用。代价是每次切分可用的观测更少,好处是交叉验证更贴近你真正关心的问题。

从一次切分到组合切分

净化和禁运解决了泄露,但没解决另一个问题:标准 k 折只给你 kk 条测试路径,样本量太小,你没法判断”样本外夏普 1.2”这个数字有多稳。

CPCV 的想法是把切分方式组合化。López de Prado 的记号是:把时间序列切成 NN 个连续组,每次切分挑 kk 个组做测试、其余 N−kN-k 个组做训练。于是切分数是

切分数=(Nk)\text{切分数} = \binom{N}{k}

这里必须把切分数和路径数分开,因为原始文献里两个数不一样,混起来会把后面的计算全搞错。

一次切分只覆盖时间轴的一部分(那 kk 个测试组)。要拼出一条走完整条时间轴的回测路径,你得给每个组都找到一次它被测试的记录。每个组在全部切分里被测试的次数是 (N−1k−1)\binom{N-1}{k-1},而这个数正好就是能拼出的完整路径数:

φ[N,k]=kN(Nk)=(N−1k−1)\varphi[N, k] = \frac{k}{N}\binom{N}{k} = \binom{N-1}{k-1}

两个写法相等,验算一下就知道。N=6N = 6、k=2k = 2 时,切分数是 (62)=15\binom{6}{2} = 15,路径数是 26×15=5\frac{2}{6} \times 15 = 5,也等于 (51)=5\binom{5}{1} = 5。这就是 AFML 里那个经典的 6 组 2 测例子:15 次训练,换来 5 条完整的样本外净值曲线。

对比一下 walk-forward:同样的数据,它只给你 1 条。这个从 1 到 5 的跃迁就是 CPCV 的全部卖点。

几个常用配置:

NNkk切分数 (Nk)\binom{N}{k}路径数 φ[N,k]\varphi[N,k]
4263
62155
632010
82287
102459
1226611
16212015
20219019

k=2k = 2 时路径数就是 N−1N - 1,增长是线性的,而切分数(也就是你要训练模型的次数)是二次增长。这条曲线决定了实践中的取舍:想多要一条路径,得多训练一批模型。

算法

第 1 步:把数据划分为 NN 个连续、不重叠的组。

第 2 步:枚举所有大小为 kk 的组合作为测试组,其余 N−kN-k 个组为训练组。

第 3 步:对每个测试组的起点 tstartt_{start},净化训练集中 [tstart−h,tstart)[t_{start} - h, t_{start}) 范围内的观测。

第 4 步:对每个测试组的终点 tendt_{end},禁运训练集中 (tend,tend+e](t_{end}, t_{end} + e] 范围内的观测。

第 5 步:在净化并禁运之后的训练集上训练模型。

第 6 步:在测试组上生成预测,记录表现。

第 7 步:对全部 (Nk)\binom{N}{k} 次切分重复。

第 8 步:把各组的测试预测拼装成 φ[N,k]\varphi[N,k] 条完整路径,得到一个表现分布。

第 8 步是 CPCV 区别于”跑很多次交叉验证”的地方。你要的不是 (Nk)\binom{N}{k} 个孤立的分数,而是 φ[N,k]\varphi[N,k] 条完整的、可以各自算夏普和最大回撤的净值曲线。

汇总:拼接还是平均

拿到多条路径之后,有两种汇总方式,它们回答的问题不同。

拼接是把一条路径上所有测试段的预测串起来,在这条完整序列上算一次表现。它回答的是”如果这个策略在整个时间轴上一直跑,结果是什么”。

平均是先给每条路径各算一个表现数字,再对路径取平均。它回答的是”一条典型路径长什么样”。

两个都看。需要注意的是,拼接夏普和各路径夏普的平均通常不相等,而且它们之间没有简单的换算关系——拼接夏普由整条序列的总体均值和总体标准差决定,一段高波动时期会同时影响分子和分母。两个数差得远时,说明各路径之间差异很大,这本身就是有用的信息。

比单个数字更值得看的是整个分布:均值告诉你在随机选定的市场条件下部署的预期表现;方差告诉你这个预期有多可信;最差的那几条路径决定了你的风险预算;负偏则意味着偶尔会有很难看的时期。

这几个量合起来才构成部署判断。平均夏普 1.0、路径间标准差 0.5 的策略,比平均夏普 0.8、标准差 0.2 的策略更难上线——前者的意思是你不知道自己会拿到哪一种结果。CPCV 直接把这个分布交到你手上,这是它相对单条路径的 walk-forward 最实在的优势。

一个算到底的例子

T=2000T = 2000 个观测(约 8 年日频数据),切成 N=6N = 6 个组,每组约 333 个观测(约 1.3 年)。每次切分取 k=2k = 2 个组做测试。

标签跨度 h=5h = 5(5 天前向收益),最大特征窗口 w=60w = 60(60 天移动平均),于是禁运长度 e=max⁡(5,60)=60e = \max(5, 60) = 60。

切分数 (62)=15\binom{6}{2} = 15,路径数 φ[6,2]=5\varphi[6,2] = 5,每个组在 15 次切分里被测试 5 次。

看一次具体切分。假设测试组覆盖第 333–665 天和第 1332–1664 天:

  • 净化:删除训练集中第 328–332 天,以及第 1327–1331 天。每处 5 个观测。
  • 禁运:删除训练集中第 666–725 天,以及第 1665–1724 天。每处 60 个观测。

这一次切分总共丢掉 130 个观测。测试集约 666 个观测,训练集从名义上的 1332 个降到约 1202 个。

跑遍 15 次切分,平均每次丢掉约 87 个观测,占整条序列的 4.3%,占名义训练数据的约 6.5%。丢的量随两个测试组是否相邻而变——相邻时两段禁运区里有一段落在测试组内部,本来就不算训练数据,所以浪费更少。

这个 6.5% 就是正确性的价格。它换来的是训练集和测试集之间不再有已知的信息通道。

一份可跑的实现

import numpy as np
from itertools import combinations
from math import comb


def cpcv_splits(T, n_groups=6, n_test=2, label_horizon=5, embargo=60):
    """
    Generate CPCV train/test splits with purging and embargo.

    Yields (train_idx, test_idx) for each of C(n_groups, n_test) splits.
    The number of complete backtest paths these splits support is
    phi[N, k] = k * C(N, k) / N = C(N - 1, k - 1).

    Parameters
    ----------
    T : int
        Number of observations.
    n_groups : int
        N, the number of contiguous groups the series is cut into.
    n_test : int
        k, how many groups are held out for testing in each split.
    label_horizon : int
        h, how far forward a label reaches. Drives purging.
    embargo : int
        e, how many observations after a test group are dropped from training.
    """
    g = T // n_groups
    bounds = [(i * g, (i + 1) * g if i < n_groups - 1 else T) for i in range(n_groups)]

    for test_groups in combinations(range(n_groups), n_test):
        blocked = np.zeros(T, dtype=bool)
        test_idx = np.concatenate([np.arange(*bounds[j]) for j in test_groups])
        blocked[test_idx] = True
        for j in test_groups:
            t_start, t_end = bounds[j]
            blocked[max(0, t_start - label_horizon):t_start] = True   # purge
            blocked[t_end:min(T, t_end + embargo)] = True             # embargo
        yield np.flatnonzero(~blocked), np.sort(test_idx)


T, N, k = 2000, 6, 2
splits = list(cpcv_splits(T, n_groups=N, n_test=k, label_horizon=5, embargo=60))

print(f"splits: {len(splits)} (= C({N},{k}) = {comb(N, k)})")
print(f"backtest paths: {k * comb(N, k) // N} (= C({N-1},{k-1}) = {comb(N-1, k-1)})")
for i, (train_idx, test_idx) in enumerate(splits[:3], start=1):
    dropped = T - len(train_idx) - len(test_idx)
    print(f"  split {i}: train {len(train_idx)}, test {len(test_idx)}, dropped {dropped}")

输出是 15 次切分、5 条路径,前三次切分的训练集大小分别是 1274、1209、1209。第一次切分丢的观测明显更少(60 个对 125 个),因为它的两个测试组恰好相邻。

blocked 这个布尔掩码是这份实现的关键:净化和禁运都只是往掩码上打标记,最后一次性取补集。这比在索引列表上反复做集合差要好写得多,也不容易漏掉边界条件。

CPCV 和 CSCV 的区别

这两个缩写只差一个字母,讲的却是两件事,混起来是常见错误。

CSCV 假设观测独立同分布,不做净化也不做禁运,用等质量的对称切分算样本外排名,服务的最终产物是 PBO——它衡量的是”你的策略选择程序有没有过拟合”。CPCV 带净化和禁运,服务的产物是测试表现分布——它衡量的是”这个模型的样本外表现大概是什么样、有多稳”。

所以判据是你想问什么。想知道”我从一堆候选里挑最好的这个动作靠不靠谱”,用 CSCV 算 PBO。想知道”这个模型上线之后大概什么表现”,用 CPCV。两者不冲突,正经流程里通常都会跑。

CPCV 和 walk-forward:一个真实的取舍

有一种说法是 walk-forward 分析是 CPCV 的特例。这个说法不成立,而且掩盖了一个重要区别。

Walk-forward 严格保持时间顺序:永远拿过去训练、拿未来测试。CPCV 不保持——在 (Nk)\binom{N}{k} 次切分里,有大量切分的训练组排在测试组后面。第 5 组做测试、第 1 到 4 组和第 6 组做训练时,模型是拿第 6 组(未来)的数据在预测第 5 组(过去)。

这不是实现瑕疵,是 CPCV 的设计前提。它假设数据生成过程在整个样本期内足够稳定,稳定到”用未来训练”和”用过去训练”学到的是同一套关系。这个假设换来的就是路径数——从 walk-forward 的 1 条变成 φ[N,k]\varphi[N,k] 条。

假设成立时,这笔交易很划算:你用同一份数据得到了一个表现分布而不是一个点估计。假设不成立时——真实的结构断点、监管变化、市场微观结构改革——CPCV 会给出比 walk-forward 更乐观的结果,因为它让模型见到了它在真实时点上不可能见到的东西。

实践中的处理是两个都跑。CPCV 给你分布和统计功效,walk-forward 给你一个不含时间穿越的诚实基准。两者结论一致时你可以放心;walk-forward 明显更差时,通常是在提示你的数据生成过程没有你以为的那么平稳。

计算成本

CPCV 贵,而且贵在切分数而不是路径数上。N=10N = 10、k=2k = 2 时只有 9 条路径,却要训练 45 次模型;N=16N = 16、k=2k = 2 时 15 条路径要训练 120 次。对梯度提升或者深度学习模型,这个倍数很快就变成”跑一晚上”和”跑一周”的区别。

几个能用的折中办法:NN 取 6 到 10,让切分数留在几十的量级;用快模型(线性、浅树)跑 CPCV 探索,只在最终选定的配置上用慢模型;切分之间完全独立,直接并行。

超参数选择也是同一笔账。原则性的做法是对网格里每个配置跑一遍 CPCV、按平均测试表现选,这比标准交叉验证诚实(没有泄露),代价是网格大小乘以切分数。网格只有 5 到 20 个配置时还算可以接受;参数上百万的深度模型就别想了,那种情况下用一个快的代理(比如单个验证集)做搜索,只对最终配置跑 CPCV 确认。

它保证了什么,没保证什么

CPCV 的核心保证是:在净化和禁运之后,给定已知的标签跨度和特征窗口,训练集和测试集之间没有信息通道。加粗的那半句是全部限定条件。

这个独立性是近似的,残留的依赖至少有三个来源:跨度超过禁运长度的波动率聚集;数据生成过程里的结构断点;你自己没想到、因而没有计入 ww 的特征依赖。最后一种最危险,因为它是静默的——一个偷偷用了 250 天窗口的特征,配上 60 天的禁运,泄露照旧发生而没有任何报错。

有个便宜的检验方法:看测试期残差的自相关。如果残差表现出显著的自相关(Ljung-Box 之类),禁运多半设短了。这比重新审计一遍特征代码快得多。

更要紧的是,CPCV 解决的是泄露这一类问题,仅此一类。它不处理执行成本,不处理容量约束,不处理状态变化。一个 CPCV 表现优秀的模型完全可能因为交易成本吃掉全部边际、或者因为规模放大后冲击成本失控而在实盘失败。

所以严格的部署判断是分层的:先用带正确净化和禁运的 CPCV 拿到测试表现分布,再依次按容量、执行成本和状态做折减,最后拿折减后的分布去做决定。CPCV 只是第一步,而后面几步没有一步能被它替代。

文献

CPCV 的根在时间序列交叉验证这一支文献里。Bergmeir 和 Benítez(2012)系统检查了把标准交叉验证用在时间序列预测器评估上会引入多大偏差,是这条线上常被引用的一篇;Hyndman 和 Athanasopoulos 的 Forecasting: Principles and Practice 则给出了时间序列交叉验证的教科书处理。净化和禁运本身是针对金融机器学习的实务发明,处理的是通用时间序列文献里不太出现的两个问题——标签重叠和特征窗口。

对金融领域的研究论文,CPCV 正在变成默认要求。方法清楚、结果可复现,而且拒绝报告它通常本身就是个信号。如果你要写这样一篇论文,报告完整的测试表现分布,不要只报均值:分布是诊断,均值只是摘要。

参考文献

  • López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. ISBN 978-1-119-48208-6。第 7 章讲净化与禁运,第 12 章讲通过交叉验证做回测(CPCV)。主要参考。
  • Bailey, D. H., Borwein, J., López de Prado, M., and Zhu, Q. J. (2017). “The Probability of Backtest Overfitting.” Journal of Computational Finance, 20(4), 39–70。CSCV 变体。
  • Bergmeir, C. and Benítez, J. M. (2012). “On the use of cross-validation for time series predictor evaluation.” Information Sciences, 191, 192–213.
  • Hyndman, R. J. and Athanasopoulos, G. Forecasting: Principles and Practice. OTexts。时间序列交叉验证的标准处理。
  • Aronson, D. (2006). Evidence-Based Technical Analysis, 第 6、9–12 章. Wiley. ISBN 978-0-470-00874-4。技术分析假设检验的统计基础。
  • Harvey, C. R., Liu, Y., and Zhu, H. (2016). “…and the Cross-Section of Expected Returns.” Review of Financial Studies, 29(1), 5–68。因子研究的多重检验。