4、交叉验证在时序数据中的应用
做量化交易的朋友,对交叉验证应该不陌生。但我要先泼一盆冷水——普通K折交叉验证,在时序数据上基本是废的。
为什么?因为时间序列有顺序。你拿未来的数据去训练,预测过去的事情,这不叫验证,这叫作弊。我刚开始做深度学习交易模型时,就犯过这个错。模型在回测里跑得飞起,一上实盘就崩。后来才发现,是交叉验证方式选错了。
今天我们就来聊聊,时序数据到底该怎么玩交叉验证。
普通K折交叉验证的问题
先看一个典型的错误做法。
假设我们有2018年到2023年的日频数据。普通K折会把数据随机打乱,分成5份。比如第1份是2019年、2021年混在一起,第2份是2018年、2022年混在一起。然后拿第1份训练,第2份验证。
你想想看,这合理吗?
训练集里包含了2021年的数据,验证集里却有2019年的数据。模型相当于提前看到了未来的信息。这种"未来信息泄露"会让验证结果虚高。我见过有人用这种方式调参,回测夏普比做到3.0,实盘直接变成0.5。
- 数据泄露:未来数据污染了训练过程
- 时序依赖被破坏:价格序列的连续性丢失
- 过拟合被掩盖:模型实际上在记忆未来模式
说白了,普通K折假设样本是独立同分布的。但金融数据不是。今天的价格和昨天的价格高度相关。你强行打乱,就是在制造虚假的预测能力。
时间序列交叉验证(TimeSeriesSplit)
那正确的做法是什么?
很简单——保持时间顺序。
Scikit-learn里有个TimeSeriesSplit,就是干这个的。它的逻辑是:训练集永远在验证集之前。比如5折的情况下:
- 第1折:训练[0:100],验证[100:120]
- 第2折:训练[0:120],验证[120:140]
- 第3折:训练[0:140],验证[140:160]
- 以此类推
这样,每次验证时,模型看到的都是历史数据,预测的是未来数据。这才符合真实交易场景。
代码实现也很简单:
from sklearn.model_selection import TimeSeriesSplit
import numpy as np
# 假设有1000个交易日的数据
X = np.random.randn(1000, 10)
y = np.random.randn(1000)
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(X):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# 训练和验证逻辑
print(f"训练集: {train_idx[0]}-{train_idx[-1]}, 验证集: {test_idx[0]}-{test_idx[-1]}")
输出结果会是:
训练集: 0-199, 验证集: 200-399
训练集: 0-399, 验证集: 400-599
训练集: 0-599, 验证集: 600-799
训练集: 0-799, 验证集: 800-999
注意看,训练集一直在扩大,验证集一直在往后推。这就是时序交叉验证的核心思想。
Purged Walk-Forward Cross-Validation
到这里,你可能觉得TimeSeriesSplit已经够用了。但我要说——还不够。
金融数据有个特点:事件会留下后遗症。
比如某天出了个重大利好,股价暴涨。接下来几天,价格可能还在消化这个信息。如果你把验证集紧挨着训练集,验证集里的数据其实还受训练集尾部事件的影响。这就产生了"数据污染"。
Marcos López de Prado在他的书里提出了一个解决方案——Purged Walk-Forward Cross-Validation(清洗后的滚动交叉验证)。
核心思想就两点:
- 清洗(Purge):在训练集和验证集之间,留出一段"缓冲期"。这段数据既不参与训练,也不参与验证。让训练集尾部的事件影响消散掉。
- 嵌入(Embargo):验证集之后的数据,也不能出现在训练集中。防止未来信息反向泄露。
我举个例子你就明白了。
假设我们做5日频的预测。训练集是第1天到第100天,验证集是第106天到第110天。中间的第101天到第105天,就是清洗期。这5天里,训练集尾部的事件(比如第100天的暴涨)对验证集的影响已经减弱了。
代码实现稍微复杂一点:
import numpy as np
from sklearn.model_selection import BaseCrossValidator
class PurgedWalkForwardCV(BaseCrossValidator):
def __init__(self, n_splits=5, purge_days=5, embargo_days=2):
self.n_splits = n_splits
self.purge_days = purge_days
self.embargo_days = embargo_days
def split(self, X, y=None, groups=None):
n_samples = len(X)
test_size = n_samples // (self.n_splits + 1)
for i in range(self.n_splits):
train_end = (i + 1) * test_size - self.purge_days
test_start = (i + 1) * test_size
test_end = test_start + test_size
# 清洗:去掉训练集尾部
train_idx = list(range(0, train_end))
# 嵌入:去掉验证集之后的数据
embargo_end = test_end + self.embargo_days
train_idx = [idx for idx in train_idx if idx >= embargo_end or idx < test_start]
test_idx = list(range(test_start, min(test_end, n_samples)))
yield train_idx, test_idx
def get_n_splits(self, X=None, y=None, groups=None):
return self.n_splits
三种方法的对比
| 方法 | 数据泄露风险 | 计算复杂度 | 适用场景 |
|---|---|---|---|
| 普通K折 | 极高 | 低 | 不适用于时序数据 |
| TimeSeriesSplit | 中等 | 低 | 低频交易、长周期策略 |
| Purged Walk-Forward | 低 | 中 | 高频交易、事件驱动策略 |
我个人建议:能上Purged Walk-Forward就别用TimeSeriesSplit。虽然代码多写几行,但换来的是更可靠的验证结果。尤其是做高频策略时,数据污染的影响会被放大,清洗期和嵌入期必不可少。
知识体系总览
下面这张图,帮你理清三种交叉验证的核心逻辑:
嗯,总结一下。时序数据的交叉验证,核心就一句话:尊重时间顺序,防止信息泄露。普通K折别用,TimeSeriesSplit是底线,Purged Walk-Forward是进阶。具体选哪个,看你的策略频率和数据特性。
记住,回测做得再漂亮,不如实盘跑一天。交叉验证的目的不是让数字好看,而是让模型在真实市场上能活下去。
- 普通K折在时序数据上会导致严重的数据泄露
- TimeSeriesSplit保持时间顺序,是基本要求
- Purged Walk-Forward通过清洗和嵌入,进一步降低污染
- 清洗期长度建议为预测周期的2-3倍