跳到正文

CSCV:正确推导组合对称交叉验证

样本内最好的那个策略,凭什么在样本外还是最好?CSCV 把「哪段数据算样本内」这个选择本身当成随机变量来处理。这篇推导算法本身,讲清楚路径数为什么是 C(k, k/2) 而不是 2^k,以及 logit 变换到底在做什么。

第 8 篇 · 共 12 篇 约 12 分钟

你在同一条时间序列上回测了 NN 个策略,挑了样本内夏普最高的那个准备上线。剩下的问题只有一个:它凭什么在样本外也是最好的那个?

一次样本外检验回答不了这件事。它只给你一个数字,而这个数字差了,你分不清是策略本身没用,还是恰好切在了一段对它不利的时期上。CSCV 的思路是把”哪段数据算样本内”这个选择本身当成随机变量:把时间序列切成 kk 个块,枚举所有”一半块做样本内、另一半做样本外”的分法,在每一条这样的路径上重做一遍”选样本内赢家 → 看它样本外排第几”,最后看这个名次的分布长什么样。如果这个策略真有 edge,名次分布会挤在好的一端;如果它只是搜出来的运气,名次会散在中位数附近甚至更差。

推导里真正需要说清楚的是两件事:路径数为什么是 (kk/2)\binom{k}{k/2} 而不是 2k2^k,以及为什么最后要给名次套一个 logit。前者是 CSCV 全部统计效力的来源,后者只是让分布能被当成正态来读的技术手段。这两件事的分量不一样,下面的篇幅也不会一样。

设定

你有一条长度为 TT 的时间序列,以及在其上回测的 NN 个候选策略。每个策略有自己的收益率序列 {ri,t}t=1T\{r_{i,t}\}_{t=1}^{T},i=1,…,Ni = 1, \ldots, N。它们共享同一段历史——这正是问题的来源,也是 CSCV 唯一能作用的地方。

标准交叉验证会把序列切成 kk 个连续块,用其中 k−1k-1 块训练、在剩下那块上测试。CSCV 把这件事推到极端:它不指定哪块留作测试,而是考虑所有 (kk/2)\binom{k}{k/2} 种”一半对一半”的切法,每种切法都算一遍。注意这里是 (kk/2)\binom{k}{k/2},不是把每个块独立地扔进样本内或样本外的 2k2^k。这个区别不是省算力的技巧,它是 CSCV 成立的前提,稍后专门讲。

算法

第一步,切块。 把序列切成 kk 个连续块,第 jj 块包含索引 {(j−1)⋅T/k+1,…,j⋅T/k}\{(j-1) \cdot T/k + 1, \ldots, j \cdot T/k\},j=1,…,kj = 1, \ldots, k。kk 必须是偶数,否则没法一半对一半;具体取多少后面单独讨论,先记住多年日频数据常用 k=16k = 16,历史短的用 k=8k = 8。

第二步,枚举路径。 一条路径就是一个子集 S⊆{1,2,…,k}S \subseteq \{1, 2, \ldots, k\},∣S∣=k/2|S| = k/2。SS 里的块是样本内,SS 之外的块是样本外。这样的路径共 (kk/2)\binom{k}{k/2} 条:k=8k = 8 时 (84)=70\binom{8}{4} = 70 条,k=12k = 12 时 (126)=924\binom{12}{6} = 924 条,k=16k = 16 时 (168)=12870\binom{16}{8} = 12870 条。路径数随 kk 涨得很快,这既是好事也是账单。

第三步,在每条路径上选样本内赢家。 对路径 SS 和策略 ii,只用 SS 里各块的收益率算样本内夏普:

SRiS,IS=rˉiS,ISσ^iS,ISSR_i^{S,IS} = \frac{\bar{r}_i^{S,IS}}{\hat{\sigma}_i^{S,IS}}

其中 rˉiS,IS\bar{r}_i^{S,IS} 和 σ^iS,IS\hat{\sigma}_i^{S,IS} 只用 SS 中各块的数据计算。按 SRiS,ISSR_i^{S,IS} 从高到低排序,最高的那个就是这条路径上的样本内赢家 i∗=arg⁡max⁡iSRiS,ISi^* = \arg\max_i SR_i^{S,IS}。这一步模拟的正是你在真实研究中做的动作:搜一堆策略,留下回测最好看的那个。

第四步,算样本外表现。 同一条路径上,只用 SS 之外各块的数据给每个策略算一遍样本外夏普:

SRiS,OOS=rˉiS,OOSσ^iS,OOSSR_i^{S,OOS} = \frac{\bar{r}_i^{S,OOS}}{\hat{\sigma}_i^{S,OOS}}

注意是给所有 NN 个策略都算,不只是赢家。CSCV 的诊断量是相对名次,没有其他策略当参照物就无从谈起。

第五步,把赢家的样本外名次归一化。 设 rS∈{1,…,N}r_S \in \{1, \ldots, N\} 是 i∗i^* 在 NN 个样本外夏普中的名次,按降序排,1 表示样本外最好、NN 表示样本外最差。相对名次定义为:

ωS=rSN+1\omega_S = \frac{r_S}{N+1}

分母取 N+1N+1 而不是 NN,是有原因的:下一步要做的 logit 在 0 和 1 上发散,除以 NN 会让”样本外第一名”这条路径变成 log⁡0\log 0。除以 N+1N+1 把 ωS\omega_S 关在开区间 (0,1)(0,1) 里,同时保持 ωS=0.5\omega_S = 0.5 恰好对应中位名次。ωS\omega_S 越接近 0,样本内赢家的样本外表现越好;越接近 1,越差。

顺带说一句方向问题:Bailey 等人原文里的名次是升序的(名次越高样本外越好),所以他们的 ω\omega 和这里的正好互为 1−ω1-\omega,过拟合体现为 logit 为负而不是为正。最后算出来的 PBO 数值一样,读原论文时对一下符号就行。

第六步,收集分布。 在全部 (kk/2)\binom{k}{k/2} 条路径上收集 ωS\omega_S。ωS\omega_S 的经验 CDF 就是 CSCV 的全部输出,后面所有诊断量都是从它算出来的。

对称性是全部效力的来源

为什么不干脆用全部 2k2^k 种分配,让每个块独立地进样本内或样本外?因为这样一来路径之间就不可比了,而 CSCV 唯一的统计手段就是把路径当作可比的重复实验。

拿 k=4k = 4 看。(42)=6\binom{4}{2} = 6 条对称路径是:

路径样本内块
1{1, 2}
2{1, 3}
3{1, 4}
4{2, 3}
5{2, 4}
6{3, 4}

每个块恰好出现在 6 条路径中的 3 条里。换句话说,每个块进样本内的频率完全相同,进样本外的频率也完全相同。这就是”对称”的含义。

换成全部 24=162^4 = 16 种分配,块 1 仍然在其中 8 条路径里进样本内,边际频率还是 50%——问题不在这儿。问题在于样本内集合的大小变了:有的路径样本内一个块都没有,有的 1 个、3 个、4 个,平均 2 个。夏普估计的标准误大致按 1/n1/\sqrt{n} 走,样本内只有 1 个块的路径上,“赢家”几乎完全是噪声选出来的;样本内有 4 个块的路径干脆没有样本外可言。把这些路径的 ωS\omega_S 混在一起取分布,你得到的方差主要反映的是集合大小的波动,而不是过拟合的强弱。

等质量切分把这个噪声源整个消掉。每条路径的样本内和样本外都恰好是 k/2k/2 块,估计精度可比,路径可以等权重放在一起;每个块一半时间贡献给样本内、一半时间贡献给样本外,特定时期的偏好在平均中互相抵消。剩下能让 ωS\omega_S 系统性偏向差的一端的,就只有过拟合本身——这才是 CSCV 能作为证据用的原因。

为什么要做 logit

ωS\omega_S 被关在 (0,1)(0,1) 里,而且是离散的名次统计量,本身不服从正态。标准处理是 logit:

logit(ωS)=log⁡(ωS1−ωS)\text{logit}(\omega_S) = \log\left(\frac{\omega_S}{1 - \omega_S}\right)

ωS\omega_S 接近 0(样本内赢家也是样本外最好)时 logit 很负,接近 1(样本外最差)时很正,ωS=0.5\omega_S = 0.5 时正好是 0。它把开区间映到整条实轴上,在”数据里没有真信号、赢家纯属运气”的原假设下,变换后的分布近似正态,这样才能跟标准正态 CDF 摆在一起比较,PBO 的积分也才有个能处理的形式。

有人会顺着这条路继续做 t 检验:设

H0:μωˉ=0vs.H1:μωˉ>0H_0: \mu_{\bar{\omega}} = 0 \quad \text{vs.} \quad H_1: \mu_{\bar{\omega}} > 0

没有过拟合时 μωˉ=0\mu_{\bar{\omega}} = 0(ωS\omega_S 中心在 0.5),存在过拟合时 μωˉ>0\mu_{\bar{\omega}} > 0(中心高于 0.5),然后算

t=ωˉmeanσ^ωˉ/∣S∣t = \frac{\bar{\omega}_{mean}}{\hat{\sigma}_{\bar{\omega}}/\sqrt{|\mathcal{S}|}}

其中 ωˉmean\bar{\omega}_{mean} 和 σ^ωˉ\hat{\sigma}_{\bar{\omega}} 是 logit 名次的样本均值与样本标准差,∣S∣|\mathcal{S}| 是路径数,再由 p-value=1−Φ(t)\text{p-value} = 1 - \Phi(t) 读 p 值(∣S∣=12870|\mathcal{S}| = 12870 时 t 分布和正态已经没区别)。

这个数字可以算,但不要当成正经的 p 值。(kk/2)\binom{k}{k/2} 条路径高度重叠:k=16k = 16 时任意两条路径平均共享 4 个块,12870 个 ωS\omega_S 远远不是 12870 个独立观测。分母里的 ∣S∣\sqrt{|\mathcal{S}|} 因此严重高估了精度,t 值会大得离谱,几乎任何轻微偏移都能”显著”。原论文报告 PBO 这个概率而不报 p 值,正是绕开了这一点。把 logit 均值当成一个描述性的偏移量看就好。

一个算完的例子

设 N=5N = 5 个策略、k=8k = 8 个块,于是有 (84)=70\binom{8}{4} = 70 条路径。

五个策略在整段回测上的样本外夏普是 0.6、0.4、0.2、0.0、-0.2,策略 1 样本外最好,策略 5 最差。但样本内排序不同:样本内夏普最高的是策略 4。它的样本外夏普是 0.0,在五个里排第 4——中位数是 0.2(策略 3),所以它落在中位数之下。整段回测这一次切分给出的相对名次是 ω=4/6≈0.67\omega = 4/6 \approx 0.67。

一次切分说明不了什么,所以把 70 条路径都跑一遍,每条路径各自找样本内赢家、各自算它的样本外名次。假设结果是一半一半:35 条路径上 ωS≤0.5\omega_S \le 0.5,35 条上 ωS>0.5\omega_S > 0.5。那么 PBO = 0.50。样本内赢家有一半时间落在样本外中位数以下——这恰好是”闭着眼睛随便挑一个策略”的表现,说明样本内那点排序优势到样本外一点也没剩下。

换一种结果:只有 10 条路径(14%)上 ωS≤0.5\omega_S \le 0.5,60 条(86%)上 ωS>0.5\omega_S > 0.5,PBO = 0.86。样本内赢家有 86% 的时间低于样本外中位数。这不只是”没有 edge”,而是比随机挑还差:搜索过程稳定地挑中了那些样本内运气最好、因而样本外最该失望的策略。这是过拟合的直接证据。

从 ωS\omega_S 到 PBO

回测过拟合概率就是上面那个比例的正式写法:

PBO=Pr⁡[logit(ωS)>0]=1−F^logit(ω)(0)PBO = \Pr\left[\text{logit}(\omega_S) > 0\right] = 1 - \hat{F}_{\text{logit}(\omega)}(0)

等价地就是 Pr⁡[ωS>0.5]\Pr[\omega_S > 0.5],即样本内赢家落到样本外中位数以下的路径占比。PBO 的推导单独一篇文章讲,这里只需要看清两者的关系:CSCV 是引擎,PBO 是从引擎输出上读的一个刻度。之所以分成两篇,是因为两边的推导彼此独立——你可以在不改 CSCV 一行代码的前提下换掉 PBO 的定义。

块为什么必须连续,又必须够长

连续性不是实现上的偷懒。设想另一种做法:先把观测随机打乱,再切成 kk 个等长块。这样样本内和样本外的观测在打乱后近似独立,样本外表现看上去成了策略在独立数据上的干净估计,统计上甚至更漂亮。

但它不对应任何真实场景。生产里策略是按时间顺序部署在连续时期上的:样本内截止到 T1T_1,样本外从 T1+1T_1 + 1 开始,波动率聚集、状态持续这些东西一个都不会因为你打乱数据而消失。打乱把这些结构洗掉了,于是估出来的样本外表现比你真实会遇到的好看。连续块保住了这个结构——每条路径本质上就是一个 walk-forward 配置,CSCV 无非是把所有等质量配置都跑了一遍,得到的是”这个策略在不同 walk-forward 路径下会是什么样”的经验分布。

于是 kk 的选择变成一笔明确的交易:路径数换块长。k=4k = 4 只有 (42)=6\binom{4}{2} = 6 条路径,统计功效低到没什么可读的,但每块有 T/4T/4 个观测,块内夏普估得稳。k=32k = 32 有 (3216)≈6×108\binom{32}{16} \approx 6 \times 10^8 条路径,功效充足但算不完,而且每块只剩 T/32T/32 个观测,块内噪声开始主导。8 到 16 之间是常用区间,路径数从 70 到 12870,块长也还说得过去;Bailey 等人的例子用的就是 16 个块,对 8 年以上日频数据每块大约半年。历史只有三五年时退到 k=8k = 8 或 k=12k = 12,路径少一些,估 PBO 仍然够用。

真正的下限来自块长而不是路径数。日频数据下,每块 60 个观测(一个季度)时夏普的标准误已经大到勉强能用,120 个观测(半年)对日频策略算合理,250 个观测(一年)比较稳健,流动性好的策略通常按这个来。反过来算一下就知道约束有多紧:T=1260T = 1260(5 年日频)想要每块 250 天,kk 得取 5——但 kk 必须是偶数,等质量切分对奇数 kk 不成立,所以实际只能取 k=6k = 6(每块 210 天)或 k=10k = 10(每块 126 天)。策略越吃数据,越应该往块长那头让步。盘中信号更极端,一块可能得放好几年的数据,夏普估计才站得住。

什么时候可以信它,什么时候它会骗你

CSCV 有效的前提有三条,而且都不是形式条件。块要足够大以保住块内的时间依赖,日频股票收益率上 1 到 3 个月是合适的量级;回测本身不能有前视偏差,因为每条路径的样本外表现会被同样地污染,CSCV 看到的是一片干净的假象;策略在各块之间必须定义一致,如果它在不同块用不同参数,样本外表现之间根本不可比。单参数策略跑整段回测,CSCV 定义清楚;随时间自适应调参的元策略要改口径——把整个元策略当成”策略”,CSCV 评估的就是元策略是不是过拟合的。

失效的方式也很具体。数据有长程依赖(波动率聚集、状态持续)而块又切得太小时,块之间的近似独立不成立,ωS\omega_S 的分布会失真。策略用了跨块信息就更直接:只要特征里有一个是在全量数据上标准化的,样本内集合已经被污染,样本外表现被系统性抬高,PBO 看起来会好得可疑。还有一条硬约束是数据量:T<5kT < 5k 时每块短到连夏普都估不准,此时 CSCV 输出的分布反映的是估计噪声。日频、5 年以上的常规量化回测一般没问题;历史更短或者高频场景,换别的工具。

有三类问题它压根不负责。第一类是状态切换:如果回测里存在一次影响所有策略的状态变化,样本内和样本外切分都会横跨两个状态,ωS\omega_S 把状态平均掉了——赢家可能只是早期状态的赢家,而 CSCV 报出来的是一个中性的数字,这一点它看不见。第二类是容量:策略在小资金上赚钱、在大资金上亏钱,CSCV 对着同一段回测评估,跟资金规模无关。第三类是实现差异:回测假设一种执行模型、生产用另一种,这个缺口对 CSCV 完全不可见。后两类是回测保真度的问题,不是统计问题,别指望一个统计量替你回答。

顺带说一个用法上的扩展。CSCV 是为多重检验设计的——搜 NN 个策略、挑最好的那个,诊断量是赢家的相对样本外名次。但它也能用在单个策略上:路径照旧沿时间块生成,只是每条路径上算的不再是名次,而是同一个策略的样本内夏普与样本外夏普之差。如果这个差在所有路径上都稳定为正,策略是过拟合的。这是”表现退化”诊断,和跨策略的 PBO 互补,用的是同一个引擎。

实现

import numpy as np
from itertools import combinations

def cscv_returns(returns_matrix, k=16):
    """
    Compute CSCV relative OOS ranks.
    
    Parameters
    ----------
    returns_matrix : ndarray of shape (T, N)
        Returns for N strategies over T observations.
    k : int
        Number of blocks (must be even).
    
    Returns
    -------
    omega : ndarray of shape (n_paths,)
        Relative OOS rank of the IS winner for each path.
    """
    T, N = returns_matrix.shape
    block_size = T // k
    blocks = [returns_matrix[i*block_size:(i+1)*block_size] for i in range(k)]
    
    paths = list(combinations(range(k), k // 2))
    omega = np.zeros(len(paths))
    
    for s_idx, S in enumerate(paths):
        # In-sample blocks
        is_data = np.concatenate([blocks[j] for j in S], axis=0)
        # Out-of-sample blocks
        oos_blocks = [j for j in range(k) if j not in S]
        oos_data = np.concatenate([blocks[j] for j in oos_blocks], axis=0)
        
        # IS Sharpe
        is_means = is_data.mean(axis=0)
        is_stds = is_data.std(axis=0, ddof=1)
        is_sharpe = is_means / is_stds
        
        # OOS Sharpe
        oos_means = oos_data.mean(axis=0)
        oos_stds = oos_data.std(axis=0, ddof=1)
        oos_sharpe = oos_means / oos_stds
        
        # IS winner
        is_winner = np.argmax(is_sharpe)
        
        # OOS rank of IS winner (1 = best)
        oos_ranking = np.argsort(-oos_sharpe)  # descending
        rank = np.where(oos_ranking == is_winner)[0][0] + 1
        omega[s_idx] = rank / (N + 1)  # relative rank, kept inside (0, 1)
    
    return omega

# Demo
np.random.seed(42)
T, N = 2000, 8
# Strategy 1 has true edge, rest are noise
true_sr = np.zeros(N)
true_sr[0] = 0.001  # Best true strategy
returns = true_sr[None, :] + np.random.normal(0, 0.01, size=(T, N))

omega = cscv_returns(returns, k=8)
print(f"Number of paths: {len(omega)}")
print(f"Fraction with omega < 0.5: {(omega < 0.5).mean():.3f}")

注意 rank 那两行:np.where 给出的是 0 起点的下标,要 +1+1 变成 1 起点的名次,再除以 N+1N+1。这不是洁癖,除以 NN 会让样本外第一名的路径在 logit 那一步直接产生 −∞-\infty。

除了 PBO 还能报什么

CSCV 的标准可视化是 logit 图:算出 ωˉS=logit(ωS)\bar{\omega}_S = \text{logit}(\omega_S) 的经验 CDF,把它和标准正态 CDF 画在同一张图上。没有过拟合时两条曲线应当基本吻合;存在系统性过拟合时经验 CDF 整体右移。如果它是双峰的,说明样本内赢家有时候运气好(样本外表现不错)、有时候运气差,而不是稳定地差——这跟”整体右移”是两种不同的病,一个 PBO 数字区分不出来。

这正是应该同时报数字和图的理由。CSCV 输出的是完整的名次分布,PBO 只是把它压成一个标量;同一份输出还能读出样本内赢家的平均样本外名次 ωˉS\bar{\omega}_S、名次的标准差、分布的各个分位数、赢家进入前四分之一的概率。研究报告里多报几个,快速诊断时 PBO 一个数也就够了。

参考文献

  • 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-PBO 原始论文。
  • López de Prado, M. (2018). Advances in Financial Machine Learning, §11.6. Wiley. ISBN 978-1-119-48208-6。CSCV 算法和 Python 实现。
  • Bailey, D. H., Borwein, J., López de Prado, M., and Zhu, Q. J. (2014). “The Deflated Sharpe Ratio: Correcting for Selection Bias, Non-Normality, and Multiple Testing.” Journal of Portfolio Management, 40(5), 94–107。最早在这里出现 logit 变换。