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

注意看,训练集一直在扩大,验证集一直在往后推。这就是时序交叉验证的核心思想。

💡 个人经验: 我习惯把n_splits设得大一些,比如10折。这样每个验证集的时间跨度更短,能更细致地观察模型在不同市场环境下的表现。但要注意,折数太多会导致训练集重叠严重,计算成本也高。

Purged Walk-Forward Cross-Validation

到这里,你可能觉得TimeSeriesSplit已经够用了。但我要说——还不够。

金融数据有个特点:事件会留下后遗症

比如某天出了个重大利好,股价暴涨。接下来几天,价格可能还在消化这个信息。如果你把验证集紧挨着训练集,验证集里的数据其实还受训练集尾部事件的影响。这就产生了"数据污染"。

Marcos López de Prado在他的书里提出了一个解决方案——Purged Walk-Forward Cross-Validation(清洗后的滚动交叉验证)。

核心思想就两点:

  1. 清洗(Purge):在训练集和验证集之间,留出一段"缓冲期"。这段数据既不参与训练,也不参与验证。让训练集尾部的事件影响消散掉。
  2. 嵌入(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
🎯 实际效果: 我在一个高频因子策略上做过对比。用普通TimeSeriesSplit,验证集夏普比1.8,实盘只有0.6。改用Purged Walk-Forward后,验证集夏普比降到1.2,但实盘做到了1.0。虽然数字小了,但更真实。

三种方法的对比

方法 数据泄露风险 计算复杂度 适用场景
普通K折 极高 不适用于时序数据
TimeSeriesSplit 中等 低频交易、长周期策略
Purged Walk-Forward 高频交易、事件驱动策略

我个人建议:能上Purged Walk-Forward就别用TimeSeriesSplit。虽然代码多写几行,但换来的是更可靠的验证结果。尤其是做高频策略时,数据污染的影响会被放大,清洗期和嵌入期必不可少。

⚠️ 避坑指南: 我曾经在一个日内策略上,把清洗期设得太短(只有1根K线)。结果模型还是学到了训练集尾部的微观结构特征。后来我把清洗期延长到10根K线,验证结果才稳定下来。清洗期的长度,建议至少是预测周期的2-3倍。

知识体系总览

下面这张图,帮你理清三种交叉验证的核心逻辑:

时序数据交叉验证方法对比 普通K折 随机打乱,时序破坏 TimeSeriesSplit 保持顺序,逐步滚动 Purged Walk-Forward 清洗+嵌入,防泄露 数据泄露风险 适用场景 ❌ 不推荐 低频策略 高频/事件驱动 推荐指数 ★☆☆☆☆ ★★★☆☆ ★★★★★

嗯,总结一下。时序数据的交叉验证,核心就一句话:尊重时间顺序,防止信息泄露。普通K折别用,TimeSeriesSplit是底线,Purged Walk-Forward是进阶。具体选哪个,看你的策略频率和数据特性。

记住,回测做得再漂亮,不如实盘跑一天。交叉验证的目的不是让数字好看,而是让模型在真实市场上能活下去。

📌 核心要点:
  • 普通K折在时序数据上会导致严重的数据泄露
  • TimeSeriesSplit保持时间顺序,是基本要求
  • Purged Walk-Forward通过清洗和嵌入,进一步降低污染
  • 清洗期长度建议为预测周期的2-3倍

专注资料整理