第 6 章 · 04 时序交叉验证 MultipleTimeSeriesCV 本节摘要:本节讲金融机器学习的灵魂工具——时序交叉验证。普通的 K-Fold 随机切数据,在金融时序上等于「用未来训练去预测过去」,是数据泄露的元凶。本节讲三件事:为什么金融必须用时序 CV(训练段必须严格在测试段之前,且要 purge 掉标签重叠的样本);sklearn 自带的 TimeSeriesSplit 的局限;以及项目自带的 MultipleTimeSeriesCV——它支持多支股票的面板数据、固定训练/测试窗长度、purge 间隔(lookahead)防止标签泄露。最后讨论 Lopez de Prado 提出的 purge 与 embargo 高级技巧。掌握这一节,你才能在第 7 章做可信的模型评估。
本节摘要:本节讲金融机器学习的灵魂工具——时序交叉验证。普通的 K-Fold 随机切数据,在金融时序上等于「用未来训练去预测过去」,是数据泄露的元凶。本节讲三件事:为什么金融必须用时序 CV(训练段必须严格在测试段之前,且要 purge 掉标签重叠的样本);sklearn 自带的 TimeSeriesSplit 的局限;以及项目自带的 MultipleTimeSeriesCV——它支持多支股票的面板数据、固定训练/测试窗长度、purge 间隔(lookahead)防止标签泄露。最后讨论 Lopez de Prado 提出的 purge 与 embargo 高级技巧。掌握这一节,你才能在第 7 章做可信的模型评估。
内容来源:原项目
ch06/04_cross_validation.py、根目录utils.py的MultipleTimeSeriesCV,汉化并套用体系化模板。
⚠️ 风险提示:数据泄露是金融 ML 最常见、最致命的陷阱。任何「CV 分数奇高」的模型都先怀疑泄露——大部分 ML4T 的「神奇收益」都来自这里。
阅读完本节,你应当能够:
train_period_length / test_period_length / lookahead 三个参数。普通 K-Fold 把数据随机切成 K 份,每次留一份当验证。这在独立同分布数据上没问题,但金融时序不是 iid:
两大泄露源:
💡 核心心法:金融 CV 的铁律是「训练段的任何信息都不能来自验证段之后」。时间只能向前流动。
sklearn 自带的 TimeSeriesSplit 解决了第一个问题:每个 fold 的训练段严格在验证段之前:
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) for train, validate in tscv.split(data): print(train, validate) # 输出(以 1..10 为例): # [1 2 3 4 5] [6] # [1 2 3 4 5 6] [7] # ...
训练集逐 fold 扩大(expanding window),验证集始终在末尾。但它有三个局限:
| 局限 | 说明 |
|---|---|
| 单一时间序列 | 假设数据是一维时序,不支持 (date, ticker) 面板 |
| expanding only | 训练窗只增不减,无法做 rolling window(固定窗长) |
| 无 purge | 不处理标签重叠,预测多日收益时会泄露 |
04_cross_validation.py 演示了几个 sklearn 切分器:
from sklearn.model_selection import (train_test_split, KFold, LeaveOneOut, LeavePOut, ShuffleSplit, TimeSeriesSplit) train_test_split(data, train_size=0.8) # 单次切,无 CV KFold(n_splits=5) # 随机切,金融禁用 KFold(n_splits=5, shuffle=True) # 显式 shuffle,金融更禁用 LeaveOneOut() # 留一,小样本用,金融禁用 LeavePOut(p=2) # 留 p,组合爆炸 ShuffleSplit(n_splits=3, test_size=2) # 随机,金融禁用 TimeSeriesSplit(n_splits=5) # 时序扩展窗,金融可用但有限
金融实务中,几乎所有「shuffle/random」的切分器都要慎用。
项目根目录 utils.py 的 MultipleTimeSeriesCV 是为金融面板数据量身定制的。它支持:
(symbol, date)。train_period_length + test_period_length,训练窗滚动而非扩展。lookahead 参数在训练和测试之间留出标签长度的间隔,防止标签重叠泄露。from utils import MultipleTimeSeriesCV cv = MultipleTimeSeriesCV(n_splits=3, train_period_length=126, # 训练窗 126 天(半年) test_period_length=21, # 测试窗 21 天(一个月) lookahead=1) # 标签是 1 天前向收益
每个 fold 的训练段在测试段之前,且中间隔了 lookahead - 1 天(purge)。
class MultipleTimeSeriesCV: def __init__(self, n_splits=3, train_period_length=126, test_period_length=21, lookahead=None, date_idx='date', shuffle=False): self.n_splits = n_splits self.lookahead = lookahead self.test_length = test_period_length self.train_length = train_period_length ... def split(self, X, y=None, groups=None): unique_dates = X.index.get_level_values(self.date_idx).unique() days = sorted(unique_dates, reverse=True) # 从最近往回滚 split_idx = [] for i in range(self.n_splits): test_end_idx = i * self.test_length test_start_idx = test_end_idx + self.test_length train_end_idx = test_start_idx + self.lookahead - 1 # purge 间隔 train_start_idx = train_end_idx + self.train_length + self.lookahead - 1 split_idx.append([train_start_idx, train_end_idx, test_start_idx, test_end_idx]) # 把日期索引转回行索引 dates = X.reset_index()[[self.date_idx]] for train_start, train_end, test_start, test_end in split_idx: train_idx = dates[(dates[self.date_idx] > days[train_start]) & (dates.date <= days[train_end])].index test_idx = dates[(dates.date > days[test_start]) & (dates.date <= days[test_end])].index yield train_idx.to_numpy(), test_idx.to_numpy()
关键点:days 按日期倒序排序,从最近端往回滚动,保证「最近的测试集」对应的训练在它之前。lookahead 体现在 train_end_idx = test_start_idx + self.lookahead - 1——训练段末尾留出 lookahead 长度的间隔,因为训练段最后 lookahead 天的标签会延伸到测试段,必须剔除。
MultipleTimeSeriesCV 的 lookahead 实现了基本的 purge。Lopez de Prado 在《Advances in Financial Machine Learning》里把这套理论系统化:
⚠️ 实务陷阱:很多人用时序 CV 但忘了 lookahead,等于「我做了 CV 啊怎么还是泄露?」。lookahead 必须等于你标签的前向窗口长度。
| 参数 | 典型值 | 选择依据 |
|---|---|---|
train_period_length |
63/126/252(季/半年/年) | 足够学到稳定模式,又不跨多个制度 |
test_period_length |
10/21(两周/月) | 短到模式不过时,长到统计有意义 |
lookahead |
标签天数 | 严格等于预测目标的前向窗口 |
n_splits |
3~10 | 多则稳但慢,少则快但噪声大 |
经验法则:训练段要比测试段长 3~6 倍,保证模型有足够样本;n_splits 至少 5 折以降低单折方差。
本章完。下一章我们进入「建模篇」——线性模型选股,从 OLS 到 Ridge/Lasso 正则化,再到 Fama-MacBeth 横截面回归。