CSCV:正确推导组合对称交叉验证
样本内最好的那个策略,凭什么在样本外还是最好?CSCV 把「哪段数据算样本内」这个选择本身当成随机变量来处理。这篇推导算法本身,讲清楚路径数为什么是 C(k, k/2) 而不是 2^k,以及 logit 变换到底在做什么。
你在同一条时间序列上回测了 个策略,挑了样本内夏普最高的那个准备上线。剩下的问题只有一个:它凭什么在样本外也是最好的那个?
一次样本外检验回答不了这件事。它只给你一个数字,而这个数字差了,你分不清是策略本身没用,还是恰好切在了一段对它不利的时期上。CSCV 的思路是把”哪段数据算样本内”这个选择本身当成随机变量:把时间序列切成 个块,枚举所有”一半块做样本内、另一半做样本外”的分法,在每一条这样的路径上重做一遍”选样本内赢家 → 看它样本外排第几”,最后看这个名次的分布长什么样。如果这个策略真有 edge,名次分布会挤在好的一端;如果它只是搜出来的运气,名次会散在中位数附近甚至更差。
推导里真正需要说清楚的是两件事:路径数为什么是 而不是 ,以及为什么最后要给名次套一个 logit。前者是 CSCV 全部统计效力的来源,后者只是让分布能被当成正态来读的技术手段。这两件事的分量不一样,下面的篇幅也不会一样。
设定
你有一条长度为 的时间序列,以及在其上回测的 个候选策略。每个策略有自己的收益率序列 ,。它们共享同一段历史——这正是问题的来源,也是 CSCV 唯一能作用的地方。
标准交叉验证会把序列切成 个连续块,用其中 块训练、在剩下那块上测试。CSCV 把这件事推到极端:它不指定哪块留作测试,而是考虑所有 种”一半对一半”的切法,每种切法都算一遍。注意这里是 ,不是把每个块独立地扔进样本内或样本外的 。这个区别不是省算力的技巧,它是 CSCV 成立的前提,稍后专门讲。
算法
第一步,切块。 把序列切成 个连续块,第 块包含索引 ,。 必须是偶数,否则没法一半对一半;具体取多少后面单独讨论,先记住多年日频数据常用 ,历史短的用 。
第二步,枚举路径。 一条路径就是一个子集 ,。 里的块是样本内, 之外的块是样本外。这样的路径共 条: 时 条, 时 条, 时 条。路径数随 涨得很快,这既是好事也是账单。
第三步,在每条路径上选样本内赢家。 对路径 和策略 ,只用 里各块的收益率算样本内夏普:
其中 和 只用 中各块的数据计算。按 从高到低排序,最高的那个就是这条路径上的样本内赢家 。这一步模拟的正是你在真实研究中做的动作:搜一堆策略,留下回测最好看的那个。
第四步,算样本外表现。 同一条路径上,只用 之外各块的数据给每个策略算一遍样本外夏普:
注意是给所有 个策略都算,不只是赢家。CSCV 的诊断量是相对名次,没有其他策略当参照物就无从谈起。
第五步,把赢家的样本外名次归一化。 设 是 在 个样本外夏普中的名次,按降序排,1 表示样本外最好、 表示样本外最差。相对名次定义为:
分母取 而不是 ,是有原因的:下一步要做的 logit 在 0 和 1 上发散,除以 会让”样本外第一名”这条路径变成 。除以 把 关在开区间 里,同时保持 恰好对应中位名次。 越接近 0,样本内赢家的样本外表现越好;越接近 1,越差。
顺带说一句方向问题:Bailey 等人原文里的名次是升序的(名次越高样本外越好),所以他们的 和这里的正好互为 ,过拟合体现为 logit 为负而不是为正。最后算出来的 PBO 数值一样,读原论文时对一下符号就行。
第六步,收集分布。 在全部 条路径上收集 。 的经验 CDF 就是 CSCV 的全部输出,后面所有诊断量都是从它算出来的。
对称性是全部效力的来源
为什么不干脆用全部 种分配,让每个块独立地进样本内或样本外?因为这样一来路径之间就不可比了,而 CSCV 唯一的统计手段就是把路径当作可比的重复实验。
拿 看。 条对称路径是:
| 路径 | 样本内块 |
|---|---|
| 1 | {1, 2} |
| 2 | {1, 3} |
| 3 | {1, 4} |
| 4 | {2, 3} |
| 5 | {2, 4} |
| 6 | {3, 4} |
每个块恰好出现在 6 条路径中的 3 条里。换句话说,每个块进样本内的频率完全相同,进样本外的频率也完全相同。这就是”对称”的含义。
换成全部 种分配,块 1 仍然在其中 8 条路径里进样本内,边际频率还是 50%——问题不在这儿。问题在于样本内集合的大小变了:有的路径样本内一个块都没有,有的 1 个、3 个、4 个,平均 2 个。夏普估计的标准误大致按 走,样本内只有 1 个块的路径上,“赢家”几乎完全是噪声选出来的;样本内有 4 个块的路径干脆没有样本外可言。把这些路径的 混在一起取分布,你得到的方差主要反映的是集合大小的波动,而不是过拟合的强弱。
等质量切分把这个噪声源整个消掉。每条路径的样本内和样本外都恰好是 块,估计精度可比,路径可以等权重放在一起;每个块一半时间贡献给样本内、一半时间贡献给样本外,特定时期的偏好在平均中互相抵消。剩下能让 系统性偏向差的一端的,就只有过拟合本身——这才是 CSCV 能作为证据用的原因。
为什么要做 logit
被关在 里,而且是离散的名次统计量,本身不服从正态。标准处理是 logit:
接近 0(样本内赢家也是样本外最好)时 logit 很负,接近 1(样本外最差)时很正, 时正好是 0。它把开区间映到整条实轴上,在”数据里没有真信号、赢家纯属运气”的原假设下,变换后的分布近似正态,这样才能跟标准正态 CDF 摆在一起比较,PBO 的积分也才有个能处理的形式。
有人会顺着这条路继续做 t 检验:设
没有过拟合时 ( 中心在 0.5),存在过拟合时 (中心高于 0.5),然后算
其中 和 是 logit 名次的样本均值与样本标准差, 是路径数,再由 读 p 值( 时 t 分布和正态已经没区别)。
这个数字可以算,但不要当成正经的 p 值。 条路径高度重叠: 时任意两条路径平均共享 4 个块,12870 个 远远不是 12870 个独立观测。分母里的 因此严重高估了精度,t 值会大得离谱,几乎任何轻微偏移都能”显著”。原论文报告 PBO 这个概率而不报 p 值,正是绕开了这一点。把 logit 均值当成一个描述性的偏移量看就好。
一个算完的例子
设 个策略、 个块,于是有 条路径。
五个策略在整段回测上的样本外夏普是 0.6、0.4、0.2、0.0、-0.2,策略 1 样本外最好,策略 5 最差。但样本内排序不同:样本内夏普最高的是策略 4。它的样本外夏普是 0.0,在五个里排第 4——中位数是 0.2(策略 3),所以它落在中位数之下。整段回测这一次切分给出的相对名次是 。
一次切分说明不了什么,所以把 70 条路径都跑一遍,每条路径各自找样本内赢家、各自算它的样本外名次。假设结果是一半一半:35 条路径上 ,35 条上 。那么 PBO = 0.50。样本内赢家有一半时间落在样本外中位数以下——这恰好是”闭着眼睛随便挑一个策略”的表现,说明样本内那点排序优势到样本外一点也没剩下。
换一种结果:只有 10 条路径(14%)上 ,60 条(86%)上 ,PBO = 0.86。样本内赢家有 86% 的时间低于样本外中位数。这不只是”没有 edge”,而是比随机挑还差:搜索过程稳定地挑中了那些样本内运气最好、因而样本外最该失望的策略。这是过拟合的直接证据。
从 到 PBO
回测过拟合概率就是上面那个比例的正式写法:
等价地就是 ,即样本内赢家落到样本外中位数以下的路径占比。PBO 的推导单独一篇文章讲,这里只需要看清两者的关系:CSCV 是引擎,PBO 是从引擎输出上读的一个刻度。之所以分成两篇,是因为两边的推导彼此独立——你可以在不改 CSCV 一行代码的前提下换掉 PBO 的定义。
块为什么必须连续,又必须够长
连续性不是实现上的偷懒。设想另一种做法:先把观测随机打乱,再切成 个等长块。这样样本内和样本外的观测在打乱后近似独立,样本外表现看上去成了策略在独立数据上的干净估计,统计上甚至更漂亮。
但它不对应任何真实场景。生产里策略是按时间顺序部署在连续时期上的:样本内截止到 ,样本外从 开始,波动率聚集、状态持续这些东西一个都不会因为你打乱数据而消失。打乱把这些结构洗掉了,于是估出来的样本外表现比你真实会遇到的好看。连续块保住了这个结构——每条路径本质上就是一个 walk-forward 配置,CSCV 无非是把所有等质量配置都跑了一遍,得到的是”这个策略在不同 walk-forward 路径下会是什么样”的经验分布。
于是 的选择变成一笔明确的交易:路径数换块长。 只有 条路径,统计功效低到没什么可读的,但每块有 个观测,块内夏普估得稳。 有 条路径,功效充足但算不完,而且每块只剩 个观测,块内噪声开始主导。8 到 16 之间是常用区间,路径数从 70 到 12870,块长也还说得过去;Bailey 等人的例子用的就是 16 个块,对 8 年以上日频数据每块大约半年。历史只有三五年时退到 或 ,路径少一些,估 PBO 仍然够用。
真正的下限来自块长而不是路径数。日频数据下,每块 60 个观测(一个季度)时夏普的标准误已经大到勉强能用,120 个观测(半年)对日频策略算合理,250 个观测(一年)比较稳健,流动性好的策略通常按这个来。反过来算一下就知道约束有多紧:(5 年日频)想要每块 250 天, 得取 5——但 必须是偶数,等质量切分对奇数 不成立,所以实际只能取 (每块 210 天)或 (每块 126 天)。策略越吃数据,越应该往块长那头让步。盘中信号更极端,一块可能得放好几年的数据,夏普估计才站得住。
什么时候可以信它,什么时候它会骗你
CSCV 有效的前提有三条,而且都不是形式条件。块要足够大以保住块内的时间依赖,日频股票收益率上 1 到 3 个月是合适的量级;回测本身不能有前视偏差,因为每条路径的样本外表现会被同样地污染,CSCV 看到的是一片干净的假象;策略在各块之间必须定义一致,如果它在不同块用不同参数,样本外表现之间根本不可比。单参数策略跑整段回测,CSCV 定义清楚;随时间自适应调参的元策略要改口径——把整个元策略当成”策略”,CSCV 评估的就是元策略是不是过拟合的。
失效的方式也很具体。数据有长程依赖(波动率聚集、状态持续)而块又切得太小时,块之间的近似独立不成立, 的分布会失真。策略用了跨块信息就更直接:只要特征里有一个是在全量数据上标准化的,样本内集合已经被污染,样本外表现被系统性抬高,PBO 看起来会好得可疑。还有一条硬约束是数据量: 时每块短到连夏普都估不准,此时 CSCV 输出的分布反映的是估计噪声。日频、5 年以上的常规量化回测一般没问题;历史更短或者高频场景,换别的工具。
有三类问题它压根不负责。第一类是状态切换:如果回测里存在一次影响所有策略的状态变化,样本内和样本外切分都会横跨两个状态, 把状态平均掉了——赢家可能只是早期状态的赢家,而 CSCV 报出来的是一个中性的数字,这一点它看不见。第二类是容量:策略在小资金上赚钱、在大资金上亏钱,CSCV 对着同一段回测评估,跟资金规模无关。第三类是实现差异:回测假设一种执行模型、生产用另一种,这个缺口对 CSCV 完全不可见。后两类是回测保真度的问题,不是统计问题,别指望一个统计量替你回答。
顺带说一个用法上的扩展。CSCV 是为多重检验设计的——搜 个策略、挑最好的那个,诊断量是赢家的相对样本外名次。但它也能用在单个策略上:路径照旧沿时间块生成,只是每条路径上算的不再是名次,而是同一个策略的样本内夏普与样本外夏普之差。如果这个差在所有路径上都稳定为正,策略是过拟合的。这是”表现退化”诊断,和跨策略的 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 起点的名次,再除以 。这不是洁癖,除以 会让样本外第一名的路径在 logit 那一步直接产生 。
除了 PBO 还能报什么
CSCV 的标准可视化是 logit 图:算出 的经验 CDF,把它和标准正态 CDF 画在同一张图上。没有过拟合时两条曲线应当基本吻合;存在系统性过拟合时经验 CDF 整体右移。如果它是双峰的,说明样本内赢家有时候运气好(样本外表现不错)、有时候运气差,而不是稳定地差——这跟”整体右移”是两种不同的病,一个 PBO 数字区分不出来。
这正是应该同时报数字和图的理由。CSCV 输出的是完整的名次分布,PBO 只是把它压成一个标量;同一份输出还能读出样本内赢家的平均样本外名次 、名次的标准差、分布的各个分位数、赢家进入前四分之一的概率。研究报告里多报几个,快速诊断时 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 变换。