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」。那你的模型到底过不过拟合?天知道。
嗯,数据划分这块就讲到这里。记住一句话:时间序列的划分,本质是在模拟「用过去预测未来」这个真实过程。任何违背这个原则的操作,都是在给自己挖坑。