← 返回量化与 AI 投研概念库

时序交叉验证:随机切一刀,模型就已经看过答案了

最后更新 2026-08-18
R2 · 注意风险

你在网上找到一份「用机器学习预测股票涨跌」的教程。代码很短,数据读进来,特征算好,然后是那句几乎所有机器学习入门都会写的:把数据随机打乱,八成拿去训练,两成留着测试。跑完一看,准确率 0.87。

你把自己的数据换进去,跑出来 0.85。你有点激动,又有点犯嘀咕:这么容易?

确实这么容易——容易的是那个 0.85,它几乎不需要模型有任何本事。问题就出在「随机打乱」这四个字上。 在绝大多数机器学习任务里它是标准动作,在金融数据上它是一个能让整套结论作废的错误,而且是中文资料里最常见的那一个。

先说人话:这不是预测,这是填空

想象一个天气预报培训班。教材是过去一整年的天气记录,一天一张卡片,共三百六十五张。

教官把卡片洗匀,随机抽出二百九十张给你学习,剩下七十五张留着考你。考题是:「三月十六号,下不下雨?」

你翻翻自己手里的学习卡片——三月十五号在,三月十七号也在。十五号阴,十七号晴,中间那天多半是转晴的过程。你答「小雨转阴」,答对了。

你答对了,但你刚才做的事根本不是预报。预报是站在十五号晚上,往前一片漆黑地猜十六号;你做的是知道了前后两天,往中间填一个空。 一个是外推,一个是插值,难度差着量级。

真实世界里,你永远处在时间轴的最右端,右边什么都没有。所以任何一次「考试」,只要考题的右边有你学过的东西,这场考试就失去了意义——它衡量的是插值能力,而你要用的是外推能力。

随机打乱做的正是这件事:它把三月十七号发到了训练集,把三月十六号发到了测试集。模型不需要理解任何因果,只要学会「看两边猜中间」就能拿高分。而这个本事,你在实盘里一天都用不上。

顺带说一句,前面提到的这类「用了当时还拿不到的信息」的毛病有个统一的名字,叫前视偏差,它是量化系统里的头号顽疾,前视偏差那篇专门讲了它的其它化身。随机切数据只是其中最粗暴、也最容易被忽略的一种。

在投资里,泄漏是从哪几个口子进来的

知道了「不能随机切」还不够。真正难的是:按时间切了,泄漏还可能从别的口子进来。 下面四个口子,每一个都足以让一份漂亮的验证结果变成废纸。

口子一:左右夹击

这就是前面天气卡片的情形,最直白也最致命。只要测试集里某个时间点的两侧都出现在训练集里,模型就有机会做插值。

它有个更隐蔽的变体:你按时间切了,但特征是用整段历史算出来的。比如你给每只股票算了个「相对整个五年样本的估值分位数」,这个数字本身就含了五年内所有信息。哪怕你把最后一年留作测试,那一年样本上的这个特征值,早就被前四年之外的数据(也就是它自己所在的未来)参与定义过了。

口子二:标签重叠——两条样本共用一段未来

这个口子最不直观,但在因子研究里几乎无处不在。

假设你要预测的是「未来五个交易日的收益」。那么三月十号这条样本的答案,取决于三月十一号到三月十五号的走势;三月十一号这条样本的答案,取决于三月十二号到三月十六号的走势。这两条样本的答案,共享了四天的未来

现在把三月十号丢进训练集、三月十一号丢进测试集。表面上时间顺序没乱——训练的日期在前,测试的日期在后。可测试题的答案里有八成内容,已经作为训练答案发给模型了。这就是所谓的标签重叠泄漏:切分点两侧的样本,答案是黏连的。

黏连的长度由两件事决定:你的预测周期有多长(预测未来五天,就黏五天),以及答案要多久才真正落地。qlib 在它的多周期任务生成器里,把后半件事单独起了个名字叫 label_leak_n,注释里举的例子非常具体:你在第 T 天收盘后做出预测,而标签定义成「第 T+1 天买入、第 T+2 天卖出的收益」,那么这条样本要用到两天之后的信息才算完整——label_leak_n 就是 2。

于是切分处要空出的距离,等于「预测周期 + 落地延迟」。它的代码里就是这么算的:

trunc_segments(self.ta, segments, days=hr + self.label_leak_n, test_key=self.test_key)

hr 是预测周期。这一行的意思是:以测试段的起点为界,把训练段和验证段的尾巴往前砍掉这么多天。砍掉的那一小段数据,不是浪费,是隔离带。

口子三:同一天的横截面也会串味

A 股市场一天有几千只股票,如果你的样本是「股票 × 日期」,随机切的时候会把同一天的甲股票放进训练、乙股票放进测试。

这看起来没问题——毕竟是两只不同的股票。可那一天如果是暴跌日,全市场普跌,那么模型从甲股票身上学到的「这一天大家都在跌」,直接就能用来答乙股票那道题。同一个时点上的股票,收益里有一大块是共同的市场因素,它们从来不是相互独立的样本。

所以时序切分的最小单位必须是时间,不是样本。一刀切下去,要么整天都在训练侧,要么整天都在测试侧,不能劈开。

口子四:预处理阶段的偷跑

这一个最容易漏,因为它发生在模型训练之前。

去极值、标准化、填补缺失值,这些步骤都需要统计量——均值、标准差、分位数。如果你图省事,在切分之前对整段数据算了一次均值和标准差然后统一变换,那么测试段的每一个数字,都被一个含有测试段自身信息的参数处理过了。泄漏量不大,但足以让结果系统性偏乐观。

qlib 把这件事看得很重。它的处理器要求你显式传入拟合的起止时间,源码注释里连用了三个感叹号:

# NOTE: correctly set the `fit_start_time` and `fit_end_time` is very important !!!
# `fit_end_time` **must not** include any information from the test data!!!

而在它的配置模板里,这两个时间不是让你随便填的,而是直接引用训练段的起止:

fit_start_time="<dataset.kwargs.segments.train.0>"
fit_end_time="<dataset.kwargs.segments.train.1>"

统计量只能在训练段上拟合,然后原样搬到测试段上用。 这是一条没有例外的纪律。

系统层:一个成熟框架是怎么把随机切这条路堵死的

看一个框架怎么设计接口,往往比看它怎么实现更有信息量——因为接口里没有的东西,说明设计者认为你压根不该做

qlib 里数据集的切分配置长这样:

"segments": {
    "train": train,
    "valid": valid,
    "test": test,
}

trainvalidtest 三个键,每个对应的是一对日期,而不是一个比例。你没有办法在这里写「随机 80%」,也没有一个叫 shuffle 的开关等着你打开。切分的语义被固定成了「三段连续时间,按顺序排」。

这个设计的含义值得多想一层:它不是提供了「时序切分」这个选项,而是只提供了这一种切分。想犯错,得绕开整个数据集抽象自己动手才行。

在这之上,它的滚动任务生成器负责把「切一次」变成「切很多次」。你给一个步长,它就沿着时间轴一段一段往后推,每推一步生成一个独立的训练—测试任务。窗口可以固定起点只往后延伸(越往后训练数据越多),也可以整段平移保持长度不变(只学近期)。这两种排法各自在假设什么、什么时候该选哪种,滚动前进验证那篇讲得更细,这里只强调一点:时序交叉验证的「多折」版本,本质上就是滚动前进——所谓的第 k 折,就是第 k 个时间窗口,折与折之间不是平等的,靠后的折用的是更多的历史。

而隔离带的实现,就是前面出现过的那个函数。它的文档字符串只有一句话,意思是:为了避免未来信息泄漏,各个数据段应当按测试段的起点做截断。落到代码上是这么干的:

test_start = min(t for t in segments[test_key] if t is not None)
for k in list(segments.keys()):
    if k != test_key:
        segments[k] = ta.truncate(segments[k], test_start, days)

只有测试段不动,其它所有段的尾部都往前收缩。这里有个细节值得注意:收缩的是训练段,不是测试段。 测试段是你要如实评估的那部分,一天都不能少;被牺牲的永远是训练数据。

在它的滚动入口里,这个隔离带的天数是这么给的:

RollingGen(step=self.step, trunc_days=self.horizon + 1)

预测周期加一。为什么是加一而不是加二,取决于那套配置里标签的具体定义——这不是一个可以照抄的常数,是一个必须自己推一遍的量。 你的标签用几天实现、数据延迟几天到手,隔离带就该多宽。抄别人的数字,等于抄了别人的标签定义。

那些「按时间切了」但仍然不成立的情况

用了现成的时序切分器,就以为万事大吉。 各种机器学习库里都有按时间顺序划分的切分工具,它们确实解决了「左右夹击」这个口子。但标签重叠那个口子,需要你主动声明隔离带的宽度;不声明,默认就是零间隔——训练段的最后一天和测试段的第一天紧挨着,答案照样黏连。工具替你做的是它被要求做的事,不是你以为它会做的事。

把折数当成越多越好。 通用交叉验证里,折数越多每折的训练数据越多,估计越稳。时序场景下不是这样:最早的那一折能用的历史极少,模型在那一折上的表现主要反映「数据不够」,把它和后面几折的成绩平均起来,得到的是一个没有明确含义的数字。看时序验证的结果,要一折一折地看它随时间怎么变,而不是只看那个平均值。表现如果从早期到近期一路下滑,那是这套规则正在失效的信号,平均值会把它抹平。

把验证均值当成收益预期。 交叉验证给出的是「这套方法在若干段历史上的表现分布」,它连交易成本、冲击成本、容量限制都还没算进去,离一份可执行的收益预期差着好几层。

以为时间切干净了就没有前视。 切分只管住了「样本之间」的泄漏。如果你的数据本身就是被后来的修订擦洗过的版本——比如财报数据按报告期回填而不是按公布日——那么每一折训练数据里都埋着未来,切得再规矩也没用。这类污染发生在切分之前,得从数据源那一层解决。

反复调整切分方式,直到结果好看。 隔离带调宽调窄、窗口调长调短、折数改来改去,然后挑一个最好看的报出来——这已经不是验证,是数据窥探。切分方案应该在看到任何结果之前就定下来,之后不许改。这条纪律和「样本外只能用一次」是同一件事的两个说法,样本内与样本外那篇展开讲了它是怎么在你毫无察觉的情况下失效的。

🚧 避坑

别拿别人的隔离带天数当默认值。 隔离带宽度是由你自己的标签定义和数据延迟决定的,一份配置里写 3 天,换一套标签可能就该是 10 天。留窄了,泄漏还在;留宽了,白白扔掉训练数据。这个数字必须自己推一遍——推的过程本身就是在逼你把「我到底在预测什么、这个答案什么时候才算数」想清楚,这比数字本身更值钱。

小结

  • 随机打乱训练集和测试集,会让模型「知道两边猜中间」,把外推问题偷换成插值问题。金融数据里,这一刀下去结论就作废了。
  • 按时间切只堵住了第一个口子。还有三个:预测周期造成的标签重叠(相邻样本共用同一段未来)、同一天不同股票的横截面串味、以及标准化去极值等预处理用了全样本统计量
  • 因此切分处要留一段隔离带,宽度 ≈ 预测周期 + 答案落地延迟。被砍掉的永远是训练段,测试段一天都不能少。
  • 成熟框架的做法是把错误路径直接删掉:切分配置里只接受日期区间,没有随机比例,也没有 shuffle 开关;预处理的统计量被强制绑定在训练段的时间范围上。
  • 时序交叉验证的多折版本就是滚动前进。看结果要一折一折看趋势,平均值会掩盖「这套规则正在失效」这个最重要的信号。

一句话记住这篇:考试题的答案,不能在复习资料里出现过——哪怕只出现了一半。

本文所引源码与注释均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念各自在说什么;本站内容为投资教育,不构成任何投资建议,边界见 免责声明

内容有错、看不懂、或想看下一篇?告诉我们 →

本文为投资教育整理,不构成任何投资建议;不荐个股/基金、不预测点位、不承诺收益。关键数据与结论请结合权威来源自行验证,并见风险免责声明