时间序列的交叉验证
随机 k-fold 在时间序列上给出的数字和实盘表现毫无关系。walk-forward、purge、embargo、CPCV 的完整做法,以及一个把两侧宽度算清楚的数值例子。
你在预测 5 天前向收益。你把数据随机切成 5 折。第 4 折里既有 2018 年的数据,又有 2020 年的,又有 2023 年的。模型在剩下的折上训练。CV 分数看着挺好。上线就死。
原因是随机 k-fold 让模型用未来去预测过去,反过来也一样。它给出的不是对实盘表现的预测,而是把”样本内记忆”打扮成了”样本外验证”。机器学习课上教的那套随机切分,对时间序列不是”近似错”,是根本上错——它放行的策略,部署就会失败。
训练和测试之间的污染是沉默的杀手,因为代码看上去完全没毛病。这篇文章讲三种结构性的污染各是什么、分别用什么修——walk-forward、purge、embargo、CPCV——以及每一步能把估计往回拉多少。数学不复杂,难的是纪律。
三种结构性污染
标准 CV 在时间序列上坏掉,有三个各自独立、各自致命的原因。
第一个是未来数据漏进训练。 一次随机 k-fold 把时刻 放进测试集,把时刻 放进训练集。模型在 的数据上训练,却在 上被评估——它看到了相对测试样本的未来,它在测试样本上的预测部分由那些本不该可用的数据支撑。在图像分类或 NLP 里这没问题,因为行是 i.i.d. 样本,“未来”这个概念无意义;在时间序列里这是灾难,模型学到的那些时序模式在部署时毫无用处,因为部署时它必须在没有未来知识的情况下做预测。
第二个是相邻样本自相关。 时刻 的收益、波动率、特征值跟 的高度相关。随机切分把大多数相邻样本放进同一折,或者同时放进训练和测试——因为切分在时间轴上是重叠的。于是测试集根本不是”不同的”数据,它是训练集的时间邻居。CV 分数变成了”模型在训练分布内插值有多准”的估计,而不是”模型外推到未来有多准”。做交易,只有后者重要。
第三个最细,是滚动窗口让特征漏进了标签。 量化机器学习里大部分特征都是滚动窗口算的——20 日波动率、60 日动量、5 日均量——时刻 的特征依赖 到 这段数据,窗口长度 。如果标签是 到 的前向收益,特征和标签在时刻 就已经贴上了。更糟的是:如果模型在时刻 的样本上训练(连特征带标签),而测试集里有 的样本,那么 的特征是用 到 的数据算的,里面包含了时刻 的标签所依赖的那段价格。模型在训练时间接看到了测试标签。
这第三种叫特征/标签重叠导致的标签泄露,也是最难被发现的一种:特征算得对、标签算得对、切分是随机的,一切都合规,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]
训练集不断扩大,测试集始终严格在训练集之后,未来数据不会漏进训练。这里有两种变体,选哪个取决于你对分布稳定性的判断:扩展窗口让训练集随时间变大,适合你相信模型受益于更多数据、底层分布相对稳定的情况,多数量化策略用这个;滚动窗口让训练集永远是最近 个时期,适合你相信近期数据更相关(分布漂移、行情变化)的情况,高频策略里更典型。
Walk-forward 模拟的是实盘的顺序——在历史上训练、在未来上评估——所以它的 CV 分数是对实盘表现好得多的估计。但它仍然偏乐观,有两个原因。一是模型选择(超参、特征工程、阈值)是看着验证折做的,而验证折在严格意义上不是测试数据:研究员看过它、用它做过决策,在很多验证折上的多重比较会放大乐观偏差。二是它没有把折与折之间的交易建模进去——实盘里你交易这个模型、付成本、然后把模型向前滚,回测常常忘了这一层。
一个 walk-forward 的 Sharpe 1.0,大致对应随机 CV 上的 Sharpe 2.5–3.0。这就是第一条污染的典型量级。
Purge:修第三条
Walk-forward 修了时间切分,但它没碰特征和标签的重叠。
把问题重述一遍。预测 ,也就是从 开始的 日前向收益,时刻 的特征依赖 到 这段数据,标签窗口是 ,测试折覆盖 。现在考虑一个在 的训练样本,它的标签窗口是 。如果
那么这个训练样本的标签就用到了测试期的数据——模型是在一个跟测试数据有重叠的标签上训练的。
修法叫 purge:把训练集里所有”标签窗口跟测试窗口有重叠”的样本删掉。对测试折前面那一侧,需要删的宽度正好是 ,也就是标签窗口的长度: 的样本,标签整个落在测试期之前,是干净的。
注意特征那一侧在这里不需要处理。对测试折之前的训练样本,特征窗口 完全落在测试期之前,够不到测试数据。特征泄露是另一侧的问题。
(López de Prado, M., Advances in Financial Machine Learning, 第 7 章。ISBN 978-1119482086。)
Embargo:修另一侧
Purge 处理的是测试折前面那一侧的标签泄露,embargo 处理的是后面那一侧的特征泄露。规则是不对称的,这一点值得说清楚,因为把两侧当成一回事是最常见的实现错误。
为什么后面那一侧也要管?测试折在 结束之后,下一段训练数据紧接着开始(rolling CV 会这样,测试之后重训也会这样)。而时刻 的特征依赖 到 这段数据——最后那一截就在测试窗口之内。这些样本的标签是干净的(它们全在测试之后),但特征被污染了。
修法是 embargo:测试折结束后,跳过接下来 天的训练数据。这一侧的正确宽度是特征回看长度 ,因为特征窗口再往后 天就完全脱离测试折了。
所以两侧的宽度由不同的东西决定:前面那一侧由标签窗口 决定,后面那一侧由特征回看 决定。合起来,被排除的区间是 。Purge 加 embargo 就是 purged walk-forward CV,时间序列量化机器学习最起码的诚实标准。
把两侧的宽度算一遍
抽象规则容易点头,落到日期上才知道自己有没有真懂。
设标签是 5 日前向收益(),特征里有一个 20 日滚动波动率 ,用 这些天的日收益标准差算()。测试折覆盖第 天。逐个样本看。
的训练样本:特征 用 的收益算,全在训练期;标签 覆盖第 80 到 85 天,也全在训练期。干净。
的训练样本:特征 用 算,仍然干净;但标签 一直延伸到第 100 天,而第 100 天是测试折的第一天。泄露。
同理,标签 覆盖第 100–101 天,仍在测试期内。泄露。
所以前面那一侧要 purge 掉的是 ,正好 5 天,等于 。 的样本本身就在测试折里,不属于训练集。
再看后面那一侧。 的训练样本:标签 全在测试之后,干净;但特征 用的是 的收益,整段都在测试折内。泄露。 的样本,特征 用 ,第 200 天还在测试折里。泄露。 到 ,特征 用 ,终于完全脱离测试期。干净。
所以后面那一侧要 embargo 掉的是 ,正好 20 天,等于 。
两侧加起来一共排除 25 天。在一个 1,000 天的训练集上大约是 2.5%——量不大,但去掉的偏差是结构性的。这也是为什么值得反复强调两侧宽度的来源不同:如果你在两侧都用 ,后面那一侧会漏掉 15 天的特征污染;如果你在两侧都用 ,你会白扔掉 20 天完全干净的训练数据。
跳过 purge 会让 CV Sharpe 虚高,虚高多少取决于 和 相对测试折长度的比例——标签窗口越长、特征回看越长、测试折越短,虚得越厉害。算术并不微妙,污染是结构性的,唯一的问题是你记不记得在 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)把它推广成很多条路径。做法是先把数据切成 个连续的时间组,比如 24 年切成 6 组、每组 4 年;然后对每一种”选 组作为测试集”的组合,在剩下的 组上训练(带 purge 和 embargo),在选中的 组上评估。
这里有一个容易搞混的计数。切分——也就是选哪 组做测试——有 种,但时间有序的回测路径数是
时有 个切分,却只有 条路径; 是 20 个切分、10 条路径; 是 70 个切分、35 条路径。组合爆炸就是代价,每个切分都要完整重训一次模型。
换来的东西是 Sharpe 在路径上的分布而不是一个数:你可以算中位 Sharpe、25 和 75 分位、Sharpe 大于 1 的概率。这比单一数字信息量大得多,尤其是当你想知道”这个策略在最差的那几条路径上是什么样”的时候。实际中对贵的模型(深度网络、大规模集成)可以从所有组合里随机抽 50 条路径来训,分布估计收敛得很快,不需要跑满。
有一个必须澄清的混淆:CPCV 给出很多回测路径,但回测过拟合概率(PBO) 不是从这些路径里数出来的,它是另一个独立过程的产物——组合对称交叉验证(CSCV),见 AFML §11.6。CSCV 把数据切成 块,构造所有 个等半测试划分,对每个划分把候选策略的样本内排名跟样本外排名做比较,按 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:
其中
是欧拉-马歇罗尼常数, 是试验次数, 是收益观察次数, 和 是收益的偏度和峰度, 是标准正态 CDF。(见 AFML §14.7.3。)
下面这组数字是示意,不来自任何具体来源,但足以说明量级。单次试验出来的 Sharpe 1.5,中括号里的 z 统计量大约是 2.0,对应 DSR ,高度显著。同一个 Sharpe 若是 50 次试验里挑出来的,z 降到 1.2 左右,DSR 。到 500 次试验,z 基本归零,DSR 趋向 0.5——观察到的 Sharpe 完全可以用试验选择来解释。顺带一个实用的识别技巧:DSR 是 上的概率值,不是 z 分数,所以任何以”DSR”名义报出来的大于 1 的数字,实际上是没经过 变换的原始 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,无 purge | CV × 0.20(糟糕) |
| 随机 k-fold,带 purge | CV × 0.30 |
| Walk-forward,无 purge | CV × 0.40 |
| Walk-forward,带 purge + embargo | CV × 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,无前视 | 部分特征用了未来数据 |
| 池子 | 无存活偏差 | 只用现在的池子 |
| CV | Purged 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)。