3、训练/验证/测试集划分:时间序列数据划分原则、滚动窗口验证、扩展窗口验证、避免未来信息泄露的划分技巧

做量化交易深度学习,最怕什么?

不是模型不收敛,不是梯度爆炸。我最怕的是——模型在回测里跑得飞起,一上实盘就崩。这种痛,经历过的人都懂。

为什么会这样?十有八九,是数据划分出了问题。你想想看,金融数据跟猫狗图片完全不一样。图片你可以随机打乱,今天拍的猫和明天拍的猫没区别。但股票价格呢?今天的价格天然依赖于昨天的价格。这就是时间序列数据的核心特征——时序依赖性

核心原则:时间序列数据划分,永远不能打乱顺序。训练集必须在验证集之前,验证集必须在测试集之前。这是铁律,没得商量。

3.1 时间序列数据划分的基本原则

我个人习惯把数据划分想象成「时间隧道」。你只能从过去走向未来,不能倒着走。具体来说,有这几个要点:

  • 时间顺序必须保持:训练集的时间段必须早于验证集,验证集早于测试集。不能混用。
  • 不能使用未来数据:训练模型时,只能用截止到某个时间点的历史数据。任何来自未来的信息都是作弊。
  • 样本之间不独立:今天的价格受昨天影响,所以不能像图像分类那样随机抽样。

我在项目中遇到过最典型的错误:有人把2018-2022年的数据随机打乱,分出80%训练、20%测试。结果模型「学会」了用2022年的数据预测2019年的涨跌。你说这能实盘吗?

⚠️ 避坑指南:我曾经接手过一个项目,对方说模型回测年化收益50%。我一看代码,发现他用的是随机划分。我问他:「你2022年的模型怎么知道2020年的行情?」他沉默了。后来改了时序划分,年化直接掉到12%。这才是真实水平。

3.2 三种主流划分方法

好,原则讲完了。咱们来看看具体怎么操作。常用的方法有三种,我按推荐程度排序:

3.2.1 简单时间序列划分

最基础的方法。按时间顺序切一刀:前70%训练,中间15%验证,最后15%测试。

# 简单时间序列划分示例
import pandas as pd
from sklearn.model_selection import train_test_split

# 假设df是时间排序后的数据
train_size = int(len(df) * 0.7)
val_size = int(len(df) * 0.15)

train = df.iloc[:train_size]
val = df.iloc[train_size:train_size+val_size]
test = df.iloc[train_size+val_size:]

这种方法简单粗暴,但有个问题:验证集和测试集只用了最后一段数据。如果市场风格变了,你的模型可能来不及适应。

3.2.2 滚动窗口验证(Rolling Window)

这是我个人最常用的方法。说白了,就是让训练窗口「滚着走」。

举个例子:假设你有一个5年的数据,窗口大小设为1年。第一次用第1年训练,第2年验证;第二次用第2年训练,第3年验证;以此类推。这样你就有4个训练-验证对。

# 滚动窗口验证伪代码
window_size = 252  # 一年交易日
for start in range(0, len(df) - window_size*2, step):
    train = df.iloc[start:start+window_size]
    val = df.iloc[start+window_size:start+window_size*2]
    # 训练模型,记录验证集表现

这样做的好处很明显:模型见过不同市场环境下的数据,泛化能力更强。我在做高频策略时,特别喜欢用滚动窗口。因为市场微观结构变化太快,固定窗口根本跟不上。

💡 小技巧:滚动窗口的步长(step)怎么设?我一般设成窗口大小的10%-20%。步长太小,计算量太大;步长太大,会漏掉市场风格切换的节点。

3.2.3 扩展窗口验证(Expanding Window)

扩展窗口跟滚动窗口类似,但有一个关键区别:训练集只增不减。

第一次用第1年训练,第2年验证;第二次用第1-2年训练,第3年验证;第三次用第1-3年训练,第4年验证……训练数据越来越多。

# 扩展窗口验证伪代码
min_window = 504  # 最少两年数据
for end in range(min_window, len(df) - 252, 63):  # 每季度扩展一次
    train = df.iloc[:end]
    val = df.iloc[end:end+252]
    # 训练模型,记录验证集表现

这种方法适合数据量不大的场景。你想想看,如果总共只有3年数据,滚动窗口可能只能切出2-3个窗口。但扩展窗口能用上所有历史数据,模型学得更充分。

不过要注意:扩展窗口的早期模型数据太少,效果可能不稳定。我一般会在模型权重上做衰减——越早的数据权重越低。

3.3 避免未来信息泄露的划分技巧

这块是重头戏。很多看似合理的划分,其实都藏着未来信息泄露的坑。我踩过的坑,今天一次性告诉你。

3.3.1 特征计算的时间对齐

最常见的问题:计算技术指标时用了未来数据。

比如你计算5日均线,用的是今天及前4天的数据。这没问题。但如果你不小心用了今天及后4天的数据,那就是未来信息了。

# ❌ 错误做法:使用了未来数据
df['ma5'] = df['close'].rolling(window=5).mean().shift(-2)  # shift(-2)用了未来数据

# ✅ 正确做法:只使用历史数据
df['ma5'] = df['close'].rolling(window=5).mean().shift(1)  # shift(1)确保只用过去数据

我记得有一次,一个实习生写的特征工程代码,所有指标都shift(-1)。回测结果漂亮得不像话。我一查,好家伙,每个特征都偷看了明天的数据。这哪是预测,这是作弊啊。

3.3.2 标签构建的防泄露

构建标签(比如未来N日收益率)时,要确保标签时间在特征时间之后。

# 构建未来5日收益率标签
df['future_return'] = df['close'].shift(-5) / df['close'] - 1

# 划分时,确保训练集和验证集不重叠
# 假设今天是t日,特征用t日数据,标签是t+5日收益率
# 那么验证集最早只能从t+6日开始

这里有个细节:如果你用t日的特征预测t+5日的收益率,那么验证集和训练集之间至少要间隔5天。否则验证集里可能包含训练集标签用到的数据。

关键点:特征时间窗口和标签时间窗口不能有重叠。我习惯在划分时额外留出一个「缓冲期」,通常是标签窗口长度的1.5倍。

3.3.3 交叉验证的时序适配

传统的K折交叉验证不适合时间序列。为什么?因为第1折的数据可能在时间上晚于第2折,这就造成了未来信息泄露。

正确的做法是使用时序交叉验证(TimeSeriesSplit)。Scikit-learn里就有现成的:

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)
for train_idx, val_idx in tscv.split(X):
    # train_idx始终在val_idx之前
    X_train, X_val = X[train_idx], X[val_idx]
    y_train, y_val = y[train_idx], y[val_idx]

TimeSeriesSplit的原理就是滚动窗口。每次划分,训练集都是历史数据,验证集是未来数据。这才符合金融数据的逻辑。

3.4 实战建议:如何选择划分方法?

说了这么多,到底该用哪种?我根据经验给个参考:

场景 推荐方法 原因
数据量充足(5年以上) 滚动窗口验证 能捕捉市场风格变化,计算量可控
数据量有限(1-3年) 扩展窗口验证 充分利用有限数据,模型更稳定
策略调参阶段 时序交叉验证 避免过拟合到某一段特定行情
最终评估 简单时间序列划分 最接近实盘场景,评估结果可信

我个人习惯的组合是:调参时用5折时序交叉验证,选好参数后用滚动窗口做一次稳健性检验,最后用简单划分做最终评估。三层验证下来,基本不会出大问题。

💡 最后提醒:无论用哪种方法,测试集只能使用一次。用完就废了。我见过有人反复用测试集调参,最后测试集变成了「训练集2.0」。那你的模型到底过不过拟合?天知道。

嗯,数据划分这块就讲到这里。记住一句话:时间序列的划分,本质是在模拟「用过去预测未来」这个真实过程。任何违背这个原则的操作,都是在给自己挖坑。

时间序列数据划分知识体系 数据划分核心原则 基本原则 保持时间顺序 禁止未来信息 样本非独立 三种划分方法 简单时间划分 滚动窗口验证 扩展窗口验证 防泄露技巧 特征时间对齐 标签防重叠 时序交叉验证 核心思想:用过去预测未来 任何违背时序依赖的划分都是过拟合的根源 公众号:蓝海资料掘金营 · 微信:deep3321