跳到正文

时间序列的交叉验证

随机 k-fold 在时间序列上给出的数字和实盘表现毫无关系。walk-forward、purge、embargo、CPCV 的完整做法,以及一个把两侧宽度算清楚的数值例子。

第 4 篇 · 共 8 篇 约 14 分钟

你在预测 5 天前向收益。你把数据随机切成 5 折。第 4 折里既有 2018 年的数据,又有 2020 年的,又有 2023 年的。模型在剩下的折上训练。CV 分数看着挺好。上线就死。

原因是随机 k-fold 让模型用未来去预测过去,反过来也一样。它给出的不是对实盘表现的预测,而是把”样本内记忆”打扮成了”样本外验证”。机器学习课上教的那套随机切分,对时间序列不是”近似错”,是根本上错——它放行的策略,部署就会失败。

训练和测试之间的污染是沉默的杀手,因为代码看上去完全没毛病。这篇文章讲三种结构性的污染各是什么、分别用什么修——walk-forward、purge、embargo、CPCV——以及每一步能把估计往回拉多少。数学不复杂,难的是纪律。

三种结构性污染

标准 CV 在时间序列上坏掉,有三个各自独立、各自致命的原因。

第一个是未来数据漏进训练。 一次随机 k-fold 把时刻 t1t_1 放进测试集,把时刻 t2>t1t_2 > t_1 放进训练集。模型在 t2t_2 的数据上训练,却在 t1t_1 上被评估——它看到了相对测试样本的未来,它在测试样本上的预测部分由那些本不该可用的数据支撑。在图像分类或 NLP 里这没问题,因为行是 i.i.d. 样本,“未来”这个概念无意义;在时间序列里这是灾难,模型学到的那些时序模式在部署时毫无用处,因为部署时它必须在没有未来知识的情况下做预测。

第二个是相邻样本自相关。 时刻 tt 的收益、波动率、特征值跟 t+1t+1 的高度相关。随机切分把大多数相邻样本放进同一折,或者同时放进训练和测试——因为切分在时间轴上是重叠的。于是测试集根本不是”不同的”数据,它是训练集的时间邻居。CV 分数变成了”模型在训练分布内插值有多准”的估计,而不是”模型外推到未来有多准”。做交易,只有后者重要。

第三个最细,是滚动窗口让特征漏进了标签。 量化机器学习里大部分特征都是滚动窗口算的——20 日波动率、60 日动量、5 日均量——时刻 tt 的特征依赖 t−wt-w 到 t−1t-1 这段数据,窗口长度 ww。如果标签是 tt 到 t+5t+5 的前向收益,特征和标签在时刻 tt 就已经贴上了。更糟的是:如果模型在时刻 tt 的样本上训练(连特征带标签),而测试集里有 t+1t+1 的样本,那么 t+1t+1 的特征是用 t+1−wt+1-w 到 tt 的数据算的,里面包含了时刻 tt 的标签所依赖的那段价格。模型在训练时间接看到了测试标签。

这第三种叫特征/标签重叠导致的标签泄露,也是最难被发现的一种:特征算得对、标签算得对、切分是随机的,一切都合规,CV 分数却诡异地高。后面三节的修法基本上就是按这三条依次往回补。

(López de Prado, M., Advances in Financial Machine Learning, §7.1–7.4。ISBN 978-1119482086。)

Walk-Forward:修第一条

第一个诚实的修法很直接:别随机,按时间往前走。

Fold 1: train [2000-01 to 2010-12],  test [2011-01 to 2011-12]
Fold 2: train [2000-01 to 2011-12],  test [2012-01 to 2012-12]
Fold 3: train [2000-01 to 2012-12],  test [2013-01 to 2013-12]
...
Fold k: train [2000-01 to 2019-12],  test [2020-01 to 2020-12]

训练集不断扩大,测试集始终严格在训练集之后,未来数据不会漏进训练。这里有两种变体,选哪个取决于你对分布稳定性的判断:扩展窗口让训练集随时间变大,适合你相信模型受益于更多数据、底层分布相对稳定的情况,多数量化策略用这个;滚动窗口让训练集永远是最近 NN 个时期,适合你相信近期数据更相关(分布漂移、行情变化)的情况,高频策略里更典型。

Walk-forward 模拟的是实盘的顺序——在历史上训练、在未来上评估——所以它的 CV 分数是对实盘表现好得多的估计。但它仍然偏乐观,有两个原因。一是模型选择(超参、特征工程、阈值)是看着验证折做的,而验证折在严格意义上不是测试数据:研究员看过它、用它做过决策,在很多验证折上的多重比较会放大乐观偏差。二是它没有把折与折之间的交易建模进去——实盘里你交易这个模型、付成本、然后把模型向前滚,回测常常忘了这一层。

一个 walk-forward 的 Sharpe 1.0,大致对应随机 CV 上的 Sharpe 2.5–3.0。这就是第一条污染的典型量级。

Purge:修第三条

Walk-forward 修了时间切分,但它没碰特征和标签的重叠。

把问题重述一遍。预测 r(t,t+h)r(t, t+h),也就是从 tt 开始的 hh 日前向收益,时刻 tt 的特征依赖 t−wt-w 到 t−1t-1 这段数据,标签窗口是 [t,t+h][t, t+h],测试折覆盖 [Ttest,start,Ttest,end][T_{\text{test,start}}, T_{\text{test,end}}]。现在考虑一个在 ttraint_{\text{train}} 的训练样本,它的标签窗口是 [ttrain,ttrain+h][t_{\text{train}}, t_{\text{train}} + h]。如果

[ttrain,ttrain+h]∩[Ttest,start,Ttest,end]≠∅[t_{\text{train}}, t_{\text{train}} + h] \cap [T_{\text{test,start}}, T_{\text{test,end}}] \neq \emptyset

那么这个训练样本的标签就用到了测试期的数据——模型是在一个跟测试数据有重叠的标签上训练的。

修法叫 purge:把训练集里所有”标签窗口跟测试窗口有重叠”的样本删掉。对测试折前面那一侧,需要删的宽度正好是 hh,也就是标签窗口的长度:t<Ttest,start−ht < T_{\text{test,start}} - h 的样本,标签整个落在测试期之前,是干净的。

注意特征那一侧在这里不需要处理。对测试折之前的训练样本,特征窗口 [t−w,t−1][t-w, t-1] 完全落在测试期之前,够不到测试数据。特征泄露是另一侧的问题。

(López de Prado, M., Advances in Financial Machine Learning, 第 7 章。ISBN 978-1119482086。)

Embargo:修另一侧

Purge 处理的是测试折前面那一侧的标签泄露,embargo 处理的是后面那一侧的特征泄露。规则是不对称的,这一点值得说清楚,因为把两侧当成一回事是最常见的实现错误。

为什么后面那一侧也要管?测试折在 Ttest,endT_{\text{test,end}} 结束之后,下一段训练数据紧接着开始(rolling CV 会这样,测试之后重训也会这样)。而时刻 Ttest,end+1T_{\text{test,end}} + 1 的特征依赖 Ttest,end+1−wT_{\text{test,end}} + 1 - w 到 Ttest,endT_{\text{test,end}} 这段数据——最后那一截就在测试窗口之内。这些样本的标签是干净的(它们全在测试之后),但特征被污染了。

修法是 embargo:测试折结束后,跳过接下来 EE 天的训练数据。这一侧的正确宽度是特征回看长度 ww,因为特征窗口再往后 ww 天就完全脱离测试折了。

所以两侧的宽度由不同的东西决定:前面那一侧由标签窗口 hh 决定,后面那一侧由特征回看 ww 决定。合起来,被排除的区间是 t∈[Ttest,start−h, Ttest,end+w]t \in [T_{\text{test,start}} - h,\ T_{\text{test,end}} + w]。Purge 加 embargo 就是 purged walk-forward CV,时间序列量化机器学习最起码的诚实标准。

把两侧的宽度算一遍

抽象规则容易点头,落到日期上才知道自己有没有真懂。

设标签是 5 日前向收益(h=5h = 5),特征里有一个 20 日滚动波动率 VtV_t,用 [t−20,t−1][t-20, t-1] 这些天的日收益标准差算(w=20w = 20)。测试折覆盖第 [100,200][100, 200] 天。逐个样本看。

t=80t = 80 的训练样本:特征 V80V_{80} 用 [60,79][60, 79] 的收益算,全在训练期;标签 r80,85r_{80, 85} 覆盖第 80 到 85 天,也全在训练期。干净。

t=95t = 95 的训练样本:特征 V95V_{95} 用 [75,94][75, 94] 算,仍然干净;但标签 r95,100r_{95, 100} 一直延伸到第 100 天,而第 100 天是测试折的第一天。泄露。

t=96t = 96 同理,标签 r96,101r_{96, 101} 覆盖第 100–101 天,仍在测试期内。泄露。

所以前面那一侧要 purge 掉的是 t∈[95,99]t \in [95, 99],正好 5 天,等于 hh。t≥100t \ge 100 的样本本身就在测试折里,不属于训练集。

再看后面那一侧。t=201t = 201 的训练样本:标签 r201,206r_{201, 206} 全在测试之后,干净;但特征 V201V_{201} 用的是 [181,200][181, 200] 的收益,整段都在测试折内。泄露。 t=220t = 220 的样本,特征 V220V_{220} 用 [200,219][200, 219],第 200 天还在测试折里。泄露。 到 t=221t = 221,特征 V221V_{221} 用 [201,220][201, 220],终于完全脱离测试期。干净。

所以后面那一侧要 embargo 掉的是 t∈[201,220]t \in [201, 220],正好 20 天,等于 ww。

两侧加起来一共排除 25 天。在一个 1,000 天的训练集上大约是 2.5%——量不大,但去掉的偏差是结构性的。这也是为什么值得反复强调两侧宽度的来源不同:如果你在两侧都用 hh,后面那一侧会漏掉 15 天的特征污染;如果你在两侧都用 h+wh + w,你会白扔掉 20 天完全干净的训练数据。

跳过 purge 会让 CV Sharpe 虚高,虚高多少取决于 hh 和 ww 相对测试折长度的比例——标签窗口越长、特征回看越长、测试折越短,虚得越厉害。算术并不微妙,污染是结构性的,唯一的问题是你记不记得在 CV 流水线里强制执行它。每当你看到一个带滚动窗口的特征,你就面对这个问题一次。

(Hyndman, R. J., & Athanasopoulos, G., Forecasting: Principles and Practice, 第 3 版,§5.10 Time series cross-validation。)

代码长什么样

伪代码层面,带 purge 和 embargo 的 walk-forward 循环只有五行:

for fold in walk_forward_splits(data, embargo=5):
    train, test = purge(fold.train, fold.test, label_horizon=5)
    model.fit(train.features, train.labels)
    sharpe = evaluate(model, test, transaction_cost=0.001)

生产代码里你会想把两侧的 gap 显式算出来(并且按上一节的规则,让 embargo 宽度跟特征回看走,而不是跟标签窗口走),但重要的是结构:purge 是一个函数调用,不是一句注释;transaction_cost 是一个参数,不是事后才想起来的东西。

CPCV:从一个点估计变成一个分布

Walk-forward 给你一条穿越数据的路径——在 [2010, 2018] 上训、在 2019 上测,在 [2010, 2019] 上训、在 2020 上测,以此类推——最后得到一串 Sharpe,取平均。这个平均是个点估计,没有任何不确定性的度量。

组合式 purged 交叉验证(CPCV)把它推广成很多条路径。做法是先把数据切成 NN 个连续的时间组,比如 24 年切成 6 组、每组 4 年;然后对每一种”选 kk 组作为测试集”的组合,在剩下的 N−kN - k 组上训练(带 purge 和 embargo),在选中的 kk 组上评估。

这里有一个容易搞混的计数。切分——也就是选哪 kk 组做测试——有 (Nk)\binom{N}{k} 种,但时间有序的回测路径数是

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

N=6,k=2N = 6, k = 2 时有 (62)=15\binom{6}{2} = 15 个切分,却只有 φ[6,2]=26⋅15=5\varphi[6,2] = \frac{2}{6}\cdot 15 = 5 条路径;N=6,k=3N = 6, k = 3 是 20 个切分、10 条路径;N=8,k=4N = 8, k = 4 是 70 个切分、35 条路径。组合爆炸就是代价,每个切分都要完整重训一次模型。

换来的东西是 Sharpe 在路径上的分布而不是一个数:你可以算中位 Sharpe、25 和 75 分位、Sharpe 大于 1 的概率。这比单一数字信息量大得多,尤其是当你想知道”这个策略在最差的那几条路径上是什么样”的时候。实际中对贵的模型(深度网络、大规模集成)可以从所有组合里随机抽 50 条路径来训,分布估计收敛得很快,不需要跑满。

有一个必须澄清的混淆:CPCV 给出很多回测路径,但回测过拟合概率(PBO) 不是从这些路径里数出来的,它是另一个独立过程的产物——组合对称交叉验证(CSCV),见 AFML §11.6。CSCV 把数据切成 SS 块,构造所有 (SS/2)\binom{S}{S/2} 个等半测试划分,对每个划分把候选策略的样本内排名跟样本外排名做比较,按 rank-logit 流程得出 PBO,也就是”样本内最佳策略落在样本外排名后一半”的划分占比。它不是”样本内排名赢过样本外排名的 CPCV 路径占比”。把 CSCV 当成跟 CPCV 并列的另一项诊断,即便两者可以共用配置输入。

(López de Prado, M., Advances in Financial Machine Learning, §12.4。ISBN 978-1119482086。)

Deflated Sharpe:为你试过多少次做调整

Purged walk-forward 告诉你模型在单一样本外路径上的 Sharpe。但你很可能跑了 50 个变体才挑中这个,而 50 次试验里的最大值天然上偏——哪怕每个模型都是纯噪声,最大的那个 Sharpe 也会是正的、可观的。这就是量化研究里的多重检验问题,你没法用”我只跑了 50 次”或者”我有研究计划”逃掉:试验发生了,最大值就有偏。

Deflated Sharpe 比率(DSR)按试验次数、Sharpe 估计在试验间的方差、以及收益分布的偏度和峰度来调整观察到的 Sharpe:

DSR=Φ ⁣[(SR^−SR∗)T−11−γ^3SR^+(γ^4−1)SR^24]DSR = \Phi\!\left[\frac{(\hat{SR} - SR^{*})\sqrt{T-1}}{\sqrt{1 - \hat{\gamma}_3 \hat{SR} + \frac{(\hat{\gamma}_4 - 1)\hat{SR}^2}{4}}}\right]

其中

SR∗=V[{SRn}]⋅[(1−γ) Z−1 ⁣(1−1N)+γ Z−1 ⁣(1−1N⋅e)]SR^{*} = \sqrt{V[\{SR_n\}]} \cdot \left[(1 - \gamma)\, Z^{-1}\!\left(1 - \frac{1}{N}\right) + \gamma\, Z^{-1}\!\left(1 - \frac{1}{N \cdot e}\right)\right]

γ≈0.5772\gamma \approx 0.5772 是欧拉-马歇罗尼常数,NN 是试验次数,TT 是收益观察次数,γ^3\hat{\gamma}_3 和 γ^4\hat{\gamma}_4 是收益的偏度和峰度,Φ[⋅]\Phi[\cdot] 是标准正态 CDF。(见 AFML §14.7.3。)

下面这组数字是示意,不来自任何具体来源,但足以说明量级。单次试验出来的 Sharpe 1.5,中括号里的 z 统计量大约是 2.0,对应 DSR =Φ(2.0)≈0.98= \Phi(2.0) \approx 0.98,高度显著。同一个 Sharpe 若是 50 次试验里挑出来的,z 降到 1.2 左右,DSR =Φ(1.2)≈0.88= \Phi(1.2) \approx 0.88。到 500 次试验,z 基本归零,DSR 趋向 0.5——观察到的 Sharpe 完全可以用试验选择来解释。顺带一个实用的识别技巧:DSR 是 [0,1][0,1] 上的概率值,不是 z 分数,所以任何以”DSR”名义报出来的大于 1 的数字,实际上是没经过 Φ\Phi 变换的原始 z 统计量。

所以规矩是报出试验次数。如果你报不出来——因为忘了数,或者因为那些迭代是在几个月里非正式发生的——那你就不能说你的 Sharpe 在统计上有意义。DSR 最合适的用法是现实检验而不是主要指标:它不解决底层的过拟合,只告诉你观察到的 Sharpe 里有多大比例是试验选择的人为产物。

Purged CV 也防不住的五个坑

Purged walk-forward 是诚实的,但它防的只是标签泄露那一种污染。剩下的坑都是别的形式的泄露,需要单独处理。

按日期切了训练/测试,但特征计算没有日期纪律。 标签干净,可某个用 2018–2024 全段数据算出来的特征把测试期信息漏进了训练。修法是每个特征必须有明确的 as-of 日期,特征流水线强制 point-in-time 计算。

用全样本统计量做标准化。 你用整个数据集算了每个特征的均值和标准差,再用这套参数去标准化训练和测试。测试集的标准化被训练集之外的信息带偏了。修法是在每折内只用这一折的训练数据估标准化参数。

在很多模型变体里挑 CV Sharpe 最好的那个。 即便有 purged CV,试了 50 个 XGBoost 变体再报最高的那个,你就是在因变量上做了选择。修法是报跨变体的中位 Sharpe,或者一个预先注册的模型规格的 Sharpe。

拿测试集挑超参。 跑了 purged CV 拿到一个 Sharpe,意识到 learning rate 调错了,重跑再拿一个更好的,如此往复。每碰一次验证或测试折,就是一次多重比较事件。修法是超参只在训练集内部用内部 CV 锁定,外层测试集只碰一次,留给最终报数。

在 CV 里忽略交易成本。 CV Sharpe 是按零成本成交算的,而真实成交每笔要几个基点,策略一年换手十次。CV Sharpe 1.5 的可部署 Sharpe 可能只有 0.3。修法是在每一折里就把成本建进去,而不是等到最后回测才补。

统一的主题是:流水线的任何一步,只要用了从合法训练集之外漏出来的信息,就是污染。Purged CV 只堵住其中一个洞。

从 CV Sharpe 到实盘 Sharpe

把上面这些放在一起,可以给出量化研究里最有用的一张表——不同 CV 方法给出的数字,大致要打多少折才是实盘:

CV 方法实盘 Sharpe 估计
随机 k-fold,无 purgeCV × 0.20(糟糕)
随机 k-fold,带 purgeCV × 0.30
Walk-forward,无 purgeCV × 0.40
Walk-forward,带 purge + embargoCV × 0.55
CPCV,中位路径CV × 0.60
CPCV,25 分位路径CV × 0.50(保守)

这些乘数是学术和业界研究的经验平均值,不是精确值;它们是我们能拿到的最好估计。按这张表,一个 purged walk-forward 给出 Sharpe 2.0 的策略,实盘大概在 1.0 上下;给出 0.8 的,实盘落在 0.4–0.6。Deflated Sharpe 会在这之上再扣一道多重检验的水分。

最诚实的用法是把它压成一句话:假设你的实盘 Sharpe 是 purged walk-forward Sharpe 的一半,围绕这个数来设计策略。如果策略在回测表现的一半下仍然有吸引力,就部署;如果没有,换个更好的策略。

偷工减料是会复合的

单看每一步,偷工减料的代价似乎不大——吃掉一两成的表现而已。问题是它们相乘。

步骤老实版偷工版
特征Point-in-time,无前视部分特征用了未来数据
池子无存活偏差只用现在的池子
CVPurged walk-forward随机 k-fold
模型选择预注册,单一模型100 个变体,挑最好的
测试集碰一次碰了 20 次
交易成本按 5 个基点建模按 0 个基点建模

六步都偷,偷工版本的回测 Sharpe 可能是 2.5,而老实版本是 0.6。研究员深信自己拿到了一个 Sharpe 2.5 的策略,靠这个募资,部署,亏钱。CV 流水线的严格程度是策略预期表现的一个乘法约束,所有环节都偷工减料,得到的数字跟实盘表现之间没有任何关系。

交叉验证只是诚实评估里的一块,旁边还有几块要一起做:用 Deflated Sharpe 按回测次数调整;减掉一个 haircut,覆盖容量、换手、融券成本这些回测没充分建模的东西;留至少 20% 的数据作为样本外期,全程不动直到最后评估,碰一次做评估可以、碰超过一次就作废;以及永远不部署一个训练截止在某个固定历史期的模型,它必须随新数据周期性重训,否则会随着过期而衰减。

多数量化研究都过拟合了,不是因为研究员不小心,而是因为这个问题本身就难、过拟合本身容易。严格的交叉验证不是可选项,它是声称”这个策略有效”的最低入场费。


参考文献

  • López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. §7.1–7.4(purging 与 embargo)、§11.6(CSCV 与 PBO)、§12.4(CPCV)、§14.7.3(Deflated Sharpe Ratio)。ISBN 978-1119482086。
  • Hyndman, R. J., & Athanasopoulos, G. Forecasting: Principles and Practice, 第 3 版,§5.10(Time series cross-validation)。