最近两年接手过好几个时间序列的活儿,从工业设备传感器预测性维护,到电商销量预测,再到电力负荷的短期曲线回归。有个现象反复出现:只要任务带点"序列"两个字,团队里总有人第一反应是"上 Transformer 吧"。可真跑起来,一个只有一层线性映射的基线经常把精心调过的注意力模型按在地上摩擦,这种落差不是个例,而是这个领域过去几年最真实的写照。围绕《Transformers in Time Series: A Survey》这个主题,我想把这些年读综述、复现代码、踩坑排错的经验摊开讲一遍:Transformer 到底在被改造成什么样才适配时间序列,哪些改造是真有用的结构性改进,哪些只是换个名字的调参,以及在什么条件下它压根不该被端上桌。这篇内容适合已经熟悉注意力机制基本结构、准备把它用到预测或异常检测任务上的工程师,也适合刚读完综述但被一堆缩写绕晕、想搞清楚各方案差别到底在哪的读者。
1. 时间序列建模的"注意力转向"到底在解决什么问题
1.1 老一代时序模型卡在哪三处
先把背景说清楚,不然容易把 Transformer 当成万能药。经典时序方法大致分三支:统计模型(ARIMA 家族、指数平滑、状态空间模型)、循环网络(LSTM/GRU)、卷积类(TCN 及其变体)。这三支各有各的天花板,而且这些天花板恰好是自注意力想补的洞。
统计模型的硬伤在于假设太强。ARIMA 要求序列(差分后)近似平稳,参数是线性组合,遇到多变量之间的非线性耦合、突变型负荷、节假日尖峰,拟合能力就见底了。工程上常见的做法是加外生变量、做季节分解、人工构造特征,最后模型复杂度全堆在特征工程上,换个业务场景就得重做一遍。
循环网络的硬伤是串行。LSTM 在理论上能记住长依赖,实践中梯度在几百步之后就衰减得厉害,而且时间步必须一个一个算,GPU 并行度极低。一个长度 2048 的序列,训练时就是 2048 次串行前向,吞吐上不去,长序列场景直接被卡死。卷积类用膨胀卷积扩大了感受野,但感受野再大也是局部的,跨千步的全局依赖需要堆很深的层,参数量和调参难度一起上升。
还有一类隐性问题是多变量交互。真实业务里几十上百个传感器、SKU、路段指标同时输入,它们之间的相关性是时变的——早高峰时路段之间强相关,深夜就各走各的。RNN 和 CNN 对这类"动态成对关系"没有天然的表达机制,只能靠把所有变量拼成一个向量硬塞进去。
1.2 Transformer 被搬进时序领域的原始动机
自注意力最直接的优势就是"任意两个位置一步可达"。序列里第 1 个点和第 1000 个点之间的距离,在注意力里永远是 1,不像 RNN 要传 999 步。这一点对长周期建模(比如电力负荷的周周期、交通流量的日周期叠加)吸引力极大。
第二个动机是并行。整个序列一次性进矩阵乘法,训练吞吐比 RNN 高一个数量级,这意味着可以在同样的算力预算下喂进更多数据、跑更多组超参。
第三个动机是统一建模。多变量序列可以组织成"变量 × 时间"的二维结构,注意力矩阵天然能同时表达"同一变量不同时刻的关系"和"同一时刻不同变量的关系",不用为每种关系单独设计模块。这正是后来通道维度各种玩法(通道独立、通道混合、变量作为 token)的源头。
最后一个动机,也是最容易被低估的:预训练与迁移的想象空间。时序领域长期缺一个"通用骨干",每个任务都从零训。Transformer 在文本和图像上验证过的"大规模预训练 + 下游微调"范式,让很多人相信时序也能走这条路。这几年确实出现了不少时序基础模型的尝试,虽然离真正通用还有距离,但方向是被这个动机驱动的。
1.3 一个反直觉的事实:强基线没那么好打
如果只能记住这篇内容里的一句话,我希望是这句:在这几年的公开基准上,结构极简的线性模型多次追平甚至超过复杂的 Transformer 变体。
这不是个别现象。有个研究做过系统性对比,把一个"对回看窗口做线性映射、直接输出预测步"的极简模型,和当时一堆精心设计的注意力模型放在同一套评测协议下跑,结果在多个长序列预测数据集上,线性模型不仅没输,还在不少设置下赢了。这件事在社区里引起过不小的讨论,随后又有多篇工作指出,之前的对比里存在评测口径不一致、基线调参不充分、回看窗口长度设置不对等问题。
我的理解是,这背后有三层原因。第一层是数据本身的性质。很多时序基准的序列,趋势和季节性占主导,用"最后几个点的加权平均"就能拿到不错的分数,复杂模型提取的高阶交互在这类数据上是噪声。第二层是过参数化。时序样本量往往远小于 NLP 的语料规模,几十万参数量级的注意力模型在小数据集上极易过拟合,而线性模型的归纳偏置反而更匹配。第三层是评测协议。归一化方式、损失函数、随机种子、回看窗口长度,任何一项设置不同,排名都可能翻过来。
所以说,读综述的时候千万别只看"我们比 SOTA 提升了 X%",要盯住它的基线是怎么配的、回看窗口设成多少、有没有用可逆归一化、跑了几次取平均。这些细节决定了一个结论能不能被复现。我后面会专门用一章讲这些坑。
2. 把 Transformer 适配到时间序列,改的到底是哪几块
原版 Transformer 是为离散符号序列设计的,直接套到连续数值序列上会出好几处水土不服。综述类的资料通常会把改造点归成几类,我按"改动位置"重新组织一下,这样对着代码看更直观。
2.1 位置编码:先解决置换不变性这道坎
自注意力本身是置换不变的——把输入序列打乱顺序,输出只是跟着打乱,模型内部完全感知不到"谁在前谁在后"。文本靠词序表达语义,时序靠时间顺序表达因果,所以位置信息必须显式注入。
常见的几种做法,适用场景差别很大:
| 编码方式 | 实现要点 | 适用与局限 |
|---|---|---|
| 正弦绝对位置编码 | 固定公式,无需训练 | 简单,但外推到训练时没见过的长度会失效 |
| 可学习绝对编码 | 一个nn.Embedding(max_len, d_model) | 灵活,但序列长度变化就得重训,长序列参数浪费 |
| 相对位置编码 | 在注意力打分里加入 i-j 的偏置 | 外推性更好,实现稍复杂 |
| 时间戳特征编码 | 把小时、星期、月份、节假日做 embedding 后相加 | 时序特有,对有强周期性的业务数据收益明显 |
实际工程里我几乎总是把可学习位置编码和时间戳编码一起用。原因很实际:业务数据里"周一早上八点"和"周三早上八点"的负载曲线完全不同,纯位置编码只能表达"这是第 5 个点还是第 50 个点",表达不了"这是工作日早高峰"。时间戳编码补的正是这块。
代码上很轻量,一个nn.Embedding拼接就够:
import torch import torch.nn as nn class TimeFeatureEmbedding(nn.Module): def __init__(self, d_model, freq='h'): super().__init__() # 小时(24) + 星期(7) + 月(12) + 日(31) + 是否节假日(2) self.cardinalities = [24, 7, 12, 31, 2] self.embs = nn.ModuleList([ nn.Embedding(c, d_model) for c in self.cardinalities ]) def forward(self, time_feats): # time_feats: (B, L, 5) 长整型 out = 0 for i, emb in enumerate(self.embs): out = out + emb(time_feats[..., i]) return out # (B, L, d_model)有个细节容易被忽略:位置编码的注入方式(相加还是拼接)对长序列预测的影响不小。相加是主流,参数量省;拼接在某些模型里效果更稳定,因为不会和值嵌入混在一起互相干扰。我一般先试相加,如果发现长 horizon 的误差尾部特别差,再换成拼接对比一次。
2.2 注意力矩阵的稀疏化:把 O(L²) 按下去
这是时序 Transformer 改造最集中的一块。序列长度 L 上到几千,注意力矩阵就是几百万个元素,显存和计算都吃不消。综述里常见的几类思路,本质都是在给注意力矩阵"减负"。
局部窗口注意力。假设每个点只和左右各 w 个邻居交互,复杂度降到 O(L·w)。这符合时序的局部连续性先验,但丢掉了长依赖,需要堆叠多层来间接扩大感受野。
稀疏采样。让每个 query 只和一部分 key 计算,比如按指数间隔跳跃采样(第 i 个点关注 i、i±1、i±2、i±4、i±8……),这样少量层就能覆盖全序列。或者用打分筛选,只保留 top-k 的注意力连接。
低秩与核近似。用可分解的近似替代完整矩阵乘法,把 O(L²) 压到 O(L·d)。代价是精度损失,在长序列上通常可接受。
分块与 Patch 化。把连续 P 个时间点压成一个 token,序列长度直接除以 P,注意力复杂度降为原来的 1/P²。这是近几年收益最高、落地最广的一招,下一章单独讲。
频域方法。用傅里叶变换或小波把序列转到频域再做注意力,或者在频域做全局滤波。对强周期性数据效果好,但对非平稳、突发型序列要谨慎。
给个直观的复杂度对照,方便选型时估算预算:
| 方案 | 时间复杂度 | 显存占用 | 长依赖能力 |
|---|---|---|---|
| 全注意力 | O(L²·d) | 高 | 强 |
| 局部窗口 | O(L·w·d) | 低 | 弱,靠层数堆 |
| 稀疏采样 | O(L·logL·d) | 中 | 中到强 |
| 低秩近似 | O(L·d²) | 中 | 中 |
| Patch 化 + 全注意力 | O((L/P)²·d) | 低 | 强(局部信息聚合) |
我自己的经验是:序列长度在 512 以内,全注意力完全够用,别折腾稀疏化;超过 1024 再考虑 Patch 化;超过 4096 才需要稀疏采样和分块组合起来用。很多团队一上来就上最复杂的稀疏注意力,结果代码复杂度翻倍,效果还不如把 Patch 长度调对。
2.3 编码器-解码器怎么摆:自回归还是一把出
原版 Transformer 是编码器-解码器的结构,为机器翻译设计:编码器读源句,解码器自回归地一步步生成目标句。搬到时序预测上,这个结构要重新想。
自回归解码就是每预测一步,把预测值塞回输入,再预测下一步。优点是训练和推理一致,缺点是误差累积——预测 96 步就要串行跑 96 次前向,推理延迟高,而且第一步偏了后面全歪。
一次性多步输出是让解码器(或直接用一个线性头)一次吐出未来 H 步。训练时用真实值做监督,推理时一次前向搞定。速度上碾压自回归,但模型要自己学会各步之间的相关性,H 很大时后段精度会掉。
还有一类折中做法,用类似"生成式解码"的思路:解码器输入一段由"已观测段的尾部 + 占位符"拼成的序列,一次前向输出全部预测步。占位符的作用是保留位置信息但屏蔽未来值,这样既避免误差累积,又保留了解码器的结构。
我选型的判断标准很朴素:如果推理延迟是硬约束(在线服务、边缘设备),直接多步;如果 horizon 很短(比如预测未来 12 步以内)且精度要求高,自回归也行;如果 horizon 长、又要一次出完,就用占位符式的生成式解码。另外,如果任务本身是"给一段历史,输出一段未来",而且历史段和未来段的统计特性差异大(比如促销期),在解码器里显式注入未来时段的已知协变量(日历、计划排产)几乎是必需的,纯自回归很难把这类信息用好。
2.4 通道维度的处理:通道独立还是通道混合
这是我觉得最值得展开、但综述里往往一笔带过的一块。多变量时序输入的形状是 (变量数 C, 时间长度 L),怎么把它塞进注意力,有两种截然不同的组织方式。
通道混合(Channel Mixing)的做法是:在每个时间步上,把 C 个变量的值拼成一个 token,token 维度等于 C,序列长度还是 L。注意力在时间维上计算,每个位置的表示天然融合了所有变量。原版 Transformer 的时间序列适配大多走这条路。优点是能显式建模变量间相关性;缺点是当 C 很大(几百上千)时,token 维度爆炸,且不同变量的量纲差异会污染表示。
通道独立(Channel Independent)的做法反过来:把每个变量当成一条独立的单变量序列,分别过同一个共享权重的模型,最后拼起来。注意力只在单变量内部的时间维上算。这个思路一开始看着"退步"了——完全放弃了跨变量建模。但实际跑下来,在多个公开数据集上它就是更稳。
我的理解是,原因在于分布漂移。真实数据里各变量的量级、噪声水平、缺失模式差别巨大,通道混合时,一个量级大十倍的变量会主导共享表示,小变量被淹没。通道独立相当于给每个变量单独做了一遍归一化和表示学习,虽然丢了相关性,但换来了鲁棒性。而且很多任务里,真正的跨变量信息其实已经通过外生协变量或者后续的融合层补上了。
现在的通行做法是:先跑通道独立拿一个基线,如果业务上明确知道变量间有强物理耦合(比如同一台设备上的温度、压力、振动),再试通道混合或者"变量作为 token"的方案。后者是近两年比较流行的一种变体:把每个变量的整条时间序列先编码成一个 token,序列长度变成 C,注意力在变量维上算。这样做的好处是变量之间的交互被显式建模,而且计算量只和变量数的平方相关,跟时间长度基本解耦。缺点是时间维的细节要靠前面的编码器(通常是 Patch 化的线性层或卷积)先提炼。
3. 按任务场景拆:预测之外的四大类应用
综述类内容最容易犯的毛病是按模型分类讲一遍就完了,读者看完还是不知道自己的活儿该用哪套。我换个角度,按任务拆。
3.1 长序列预测:指标、窗口与"看起来赢了"的陷阱
这是时序 Transformer 最卷的战场,也是争议最大的地方。标准设置是给定回看窗口 L,预测未来 H 步,指标用 MSE 和 MAE。
几个必须自己动手核对的设置项:
回看窗口长度。很多工作默认 L=96 或 L=336,但你的业务可能需要 L=2016(三个月的小时级数据)。窗口太短,模型看不到完整的季节周期,误差会系统性偏高;窗口太长,注意力显存吃紧,而且远端信息大多没用。我的做法是做一次窗口扫描,画出"窗口长度-MSE"的曲线,找拐点,而不是照抄论文设置。
多变量与单变量评测口径。有些论文的多变量结果是把所有变量拼成一个长向量算一个平均 MSE,有些是逐变量算再平均,两者差别不小。复现时务必对齐。
归一化方式。见第 4 章,这里先记住:用不用可逆实例归一化,能让同一模型的 MSE 差出 10% 以上。
滚动预测与单次预测。有些评测是"一次输入预测未来 96 步",有些是"滚动预测 96 步、每步用真实值更新输入"。后者明显更容易,因为模型只负责 1 步。别把两种结果放在一起比。
关于"看起来赢了"这件事,我的判断标准是:如果新模型相对线性基线的提升幅度小于 5%,且没有做多随机种子取平均,基本可以认为是噪声。时序数据集的方差本来就大,单次运行的结果不可信。曾经有一次我们团队兴冲冲地报告"注意力模型比基线好 3%",结果换了个随机种子跑,直接反转成差 2%。后来就定了个规矩:所有对比跑 5 个种子,报均值 ± 标准差。
3.2 异常检测:重构式与预测式的分野
异常检测的范式跟预测不同。主流有两类:重构式(用自编码结构压缩再还原,还原误差大的点判为异常)和预测式(预测下一个点,预测误差大的判为异常)。Transformer 在这两类里都能当骨干,重构式更常见。
这里的坑比预测任务多得多。第一个坑是阈值。重构误差是个连续值,怎么定"多高算异常"?常见做法是在验证集上按分位数取(比如取 99 分位),或者用 POT(极值理论)动态定阈。直接用训练集误差的 3σ 在业务数据上基本不可用,因为训练集往往不包含异常样本,误差分布和线上不一致。
第二个坑是"点调整"。学术评测里有个常见的后处理:如果一段连续异常被预测成断裂的几段,就把中间填上,算作一次正确检出。这个操作能把 F1 抬高不少,但它掩盖了模型定位精度的问题。工业场景里,定位精度直接影响排查效率,我一般会同时报"点调整前"和"点调整后"两组数。
第三个坑是异常比例极低。千分之几的异常率下,准确率这个指标完全没有意义(全判正常也能有 99% 以上)。必须用 precision/recall/F1,或者 AUC-PR。而 AUC-ROC 在极端不平衡下会显得过于乐观。
第四个坑是"异常"本身的定义。业务上的异常往往是"超出正常波动范围的模式",而不仅是"数值特别大"。一个缓慢漂移的传感器,单点看都正常,整体看已经跑偏。用纯重构式模型很难捕捉这种,因为它在每个局部点上都重构得很好。这时候预测式或者"窗口级打分"更靠谱。
3.3 分类与表征学习:短序列反而更难
时序分类(比如心电、肌电、动作识别)的序列长度通常不长(几百到几千),但类别多、类间差异细微。Transformer 用在这类任务上,改造点跟预测任务不太一样。
位置编码在这里的作用要弱一些,因为分类关注的是"整体模式"而不是"时间顺序的因果"。反倒是通道处理和 Patch 化更关键:很多分类数据是多通道传感器信号,通道间的相位关系是重要判别信息,通道独立会把这种信息丢掉。
另一个常见做法是先做自监督预训练再微调。掩码重构(随机遮掉一段,让模型还原)是最常用的预训练目标。这个思路在小样本分类上确实有效,但要注意:掩码比例和掩码策略对下游影响很大。时序有强连续性,遮掉 15% 的连续片段,模型可以靠前后插值轻松还原,学不到什么;遮掉随机散点反而更难。我试过的比较稳的设置是"随机连续的片段掩码,比例 20%~30%,片段长度取序列长度的 1/10 左右"。
3.4 缺失值填补与超分辨率
这两个任务相对小众,但工程价值高。缺失值填补是把含 NaN 的序列补全;超分辨率是把低频采样(比如每小时一条)上采样到高频(每分钟一条)。
Transformer 在这两个任务上的优势是"双向看"——填补某个点的时候,前后的信息都能用(不像预测任务只能用历史)。实现上用非因果的注意力掩码就行。关键细节是缺失掩码要作为额外输入喂进去,让模型知道哪些位置原本是缺失的,不然它会把填充的 0 当真值学。
超分辨率则要注意频率混叠。低频数据上采样到高频,本质是信息不足的插值问题,模型学的是"在训练集上最可能的插值模式"。如果业务场景的采样率变化很大,训练时要覆盖多种上采样倍率,否则换个倍率就崩。
4. 归一化、Patch 化与训练细节:决定成败的那些"小改动"
这一章讲的东西,在综述里通常只占半页,但实际复现时它们对结果的影响比我换模型结构还大。
4.1 可逆实例归一化:为什么它几乎是标配
时序数据的分布漂移是常态。同一条销量曲线,平稳期在 100 上下波动,促销期飙到 800。如果按全局统计量标准化,促销期的数据在模型眼里就是"极端异常值",训练时会把模型带偏。
可逆实例归一化(RevIN)的思路很直接:在每个输入窗口内部单独做标准化,预测完再反标准化还原。训练时模型看到的永远是均值 0、方差 1 的窗口,"预测形状"变成主要任务,"预测量级"交给还原步骤处理。
实现不复杂,但有几个细节必须注意:
import torch import torch.nn as nn class RevIN(nn.Module): def __init__(self, num_features, eps=1e-5, affine=True): super().__init__() self.eps = eps self.affine = affine if affine: self.weight = nn.Parameter(torch.ones(1, 1, num_features)) self.bias = nn.Parameter(torch.zeros(1, 1, num_features)) def forward(self, x, mode): # x: (B, L, C) if mode == 'norm': self.mean = x.mean(dim=1, keepdim=True).detach() self.std = torch.sqrt(x.var(dim=1, keepdim=True, unbiased=False) + self.eps).detach() x = (x - self.mean) / self.std if self.affine: x = x * self.weight + self.bias return x elif mode == 'denorm': if self.affine: x = (x - self.bias) / self.weight return x * self.std + self.mean三个细节值得单独说。第一,mean和std必须 detach。如果让梯度从反标准化回传到统计量,模型会学到"通过调整输入分布的统计量来降低损失"这种捷径,训练看起来正常但泛化崩掉。第二,方差要用有偏估计(unbiased=False)。用无偏估计在短窗口上方差会被放大 L/(L-1) 倍,窗口很短时(比如 L=12)误差明显。第三,affine参数的作用是给归一化后的表示留一个可学习的缩放自由度,不加也能跑,加了通常更稳,尤其是多变量场景。
还有个变体进阶做法:对每个变量用不同的统计量(也就是按通道独立做 RevIN)。这在通道独立的模型里是默认操作,在通道混合的模型里也有帮助。
4.2 Patch 化:一箭双雕的结构改动
把连续 P 个时间点合并成一个 token,这个操作看似简单,收益却是双份的。
第一份收益是算力。序列长度从 L 降到 L/P,注意力矩阵从 L² 降到 (L/P)²,直接省下 P² 倍。P=16 的时候,显存占用能降到 1/256,原来跑不动的长序列一下就能跑了。
第二份收益是语义。单个时间点携带的信息量极低(就是一个数),模型让它去和几千个其他单点算注意力,等于在噪声里找关系。把一段连续点打包成一个 patch,每个 token 就承载了"这一小段的形状、斜率、波动"这些有意义的局部语义,注意力在 patch 之间算,学到的东西层次更高。这跟图像里把像素块变成 patch 是同一个道理。
实现上要注意两点。一是patch 之间可以重叠(stride < patch_len),重叠能缓解切分边界处的信息断裂,代价是序列变长一点。二是patch 内部要先做一个线性投影或者小卷积再送进注意力,直接把 P 个点拼成向量效果通常不如先降维。
class Patching(nn.Module): def __init__(self, patch_len=16, stride=8, d_model=128): super().__init__() self.patch_len = patch_len self.stride = stride self.proj = nn.Linear(patch_len, d_model) def forward(self, x): # x: (B, L, C) -> (B, N, C, d_model) x = x.transpose(1, 2) # (B, C, L) patches = x.unfold(dimension=-1, size=self.patch_len, step=self.stride) # (B, C, N, P) return self.proj(patches) # (B, C, N, d_model)Patch 长度怎么定?我的经验是跟着数据的周期性走。小时级数据、日周期是 24 个点,patch_len 取 12~24 比较自然;分钟级数据、日周期 1440,patch_len 可以取 60~120。如果拿不准,就扫一遍 {8, 12, 16, 24, 32},看验证集损失。别小看这个参数,我见过同一模型只改 patch_len 就从"打不过基线"变成"明显领先"的情况。
4.3 损失函数与评估口径的对齐
MSE 是默认选择,但它对异常值敏感。业务数据里如果有大量尖峰(促销、故障、节假日),MSE 会让模型过度关注这些点,牺牲整体形状的拟合。这时候可以试 MAE,或者用 Huber 损失折中。
还有个常被忽略的点:训练损失和评估指标要一致。如果最终业务关心的是 MAE,训练用 MSE 就存在目标错配。我一般会同时记录两种损失下的模型表现,选业务指标更好的那个。
如果要做区间预测(给置信区间而不是点估计),可以用分位数损失,一次输出多个分位点。这在库存、容量规划这类场景里比点预测有用得多,因为决策者关心的是"最坏情况会有多少",而不是"平均会有多少"。
5. 复现时序 Transformer 时最容易翻车的几个点
这一章是我这些年踩坑的实录。每一条都真实发生过,希望后来人少走点弯路。
5.1 数据泄漏:标准化顺序和滑窗切分
这是最隐蔽、也最致命的一类错误,因为它不会报错,只会让你的离线指标好得离谱,上线后一落千丈。
第一个泄漏点是标准化的时机。正确顺序是:先按时间切分训练/验证/测试集,只用训练集的统计量去标准化所有集。很多人图省事,在整个数据集上算均值和方差再标准化,等于让模型提前知道了未来的分布。这个错误在分布漂移明显的数据上能把离线 MSE 虚低 20% 以上。用了 RevIN 之后这个问题会缓解很多,因为 RevIN 是在每个窗口内部做标准化,天然不看全局统计量——这也是我推荐 RevIN 的另一个理由。
第二个泄漏点是滑窗切分。用滑动窗口构造样本时,训练集最后一个窗口的预测目标可能和验证集第一个窗口的输入重叠。如果窗口步长设得小,训练集和验证集会有大量重叠片段,验证指标虚高。正确做法是在切分点留一个不小于预测长度的间隔(gap)。
第三个泄漏点是特征工程。移动平均、同比环比、标准化后的滞后特征,只要用了未来信息就会泄漏。典型的错误是"用整月的均值做特征",但预测的是月中的某一天。这类特征在离线评估里看起来很有用,因为均值里包含了未来,上线后就是个随机数。
我现在的习惯是:在训练脚本里加一个断言,检查所有特征的时间戳严格小于预测目标的时间戳。这个检查能拦住九成的泄漏。
5.2 学习率、warmup 与正则化的组合
时序 Transformer 的训练比 CNN/RNN 敏感得多,尤其是学习率和 warmup。
学习率上,我一般从 1e-4 开始,配合 AdamW,weight_decay 设 1e-4 到 1e-2(数据量越小,正则要越强)。如果训练损失在前 5 个 epoch 就掉到接近 0,而验证损失不降,基本是过拟合了,先把 dropout 从 0.1 提到 0.3,再把模型维度砍一半试试。
warmup 在时序任务里容易被忽略,但确实有用。前几百步用线性 warmup 到目标学习率,能避免早期梯度爆炸——注意力在初始化时打分接近均匀,梯度分布比较怪,直接上大学习率容易把参数推到坏区域。经验值:warmup 步数取总步数的 5%~10%。
另一个经常被忽略的是梯度裁剪。时序数据里的尖峰会导致偶发的巨大梯度,把模型参数打飞。clip_grad_norm_设 1.0 到 5.0,成本几乎为零,收益明显。
还有EMA(指数移动平均)。对时序 Transformer 这种训练不稳定的模型,维护一份参数的滑动平均用于推理,通常能白捡 1%~3% 的提升,尤其是在验证损失波动大的时候。
5.3 随机种子、多次运行与报告方式
这一条在学术论文里普遍做得不够,工程上却是必须的。
时序模型的训练方差比图像模型大得多。同一个配置换个种子,测试 MSE 波动 5% 是常事。如果只跑一次就下结论"模型 A 比模型 B 好 2%",那基本等于抛硬币。
我的做法是:核心对比跑 5 个种子,报告均值 ± 标准差;如果两组结果的均值差小于标准差,就认为没有显著差异,继续找原因而不是急着换模型。这个规矩帮我们省下过很多次"白折腾"。
另外,种子的控制范围要覆盖全流程:数据加载器的 shuffle、参数初始化、dropout、以及任何数据增强的随机性。只设torch.manual_seed是不够的,numpy和random也要一起设,DataLoader的worker_init_fn也要配。
5.4 显存与吞吐:那些影响实验节奏的工程细节
做研究的时候容易忽略,但这些细节直接决定你一天能跑几组实验。
混合精度。时序模型的注意力部分用 fp16 通常没问题,但 RevIN 的反标准化里有除法和开方,容易在 fp16 下溢出。我一般把 RevIN 强制留在 fp32,其余部分用 autocast。
梯度累积。时序任务的有效 batch size 往往不能太大(数据集小),但小 batch 训练不稳定。用梯度累积模拟大 batch 是个折中,注意 BatchNorm 在这种情况下会失效,所以时序模型里一般用 LayerNorm。
序列长度和 batch size 的权衡。显存固定时,把 L 加倍相当于把 batch 减半的显存代价(因为注意力是 L²)。实践中我倾向于保住 batch size、控制 L,因为小 batch 带来的训练噪声更难处理。
6. 什么情况下我会劝你别用 Transformer
前面讲了这么多改造和技巧,最后这一章反过来说:很多场景真的不需要它。判断标准不复杂,看几条就够。
6.1 收益的临界点在哪
数据量。如果单条序列的历史样本少于几千个窗口,注意力模型很难训稳。一个小型 GRU 或者线性模型往往更快、更稳、更省。注意力需要的参数量决定了它是个"吃数据"的结构,数据不够时它的表达能力反而变成负担。
序列长度。回看窗口在 100 以内时,全局依赖本来就不存在,局部卷积加线性头的组合效率高得多。注意力的全局建模优势要在窗口上到几百甚至上千才体现出来。
变量数。变量数很少(比如个位数)且相互关系不复杂时,通道混合的收益有限,通道独立的单变量模型就够用,还更好解释。
可解释性要求。有些业务(比如工业质控、医疗辅助)需要给出明确的归因。注意力权重看起来像"可解释性",但实际用起来很不可靠——同一份输入,换层、换头、换种子,注意力分布就变了。这种场景下,基于特征重要性或者简单模型的方案更容易过审。
延迟约束。在线推理要求毫秒级响应时,一个几十层的注意力模型基本没戏,除非你能接受定期批量预测。
6.2 一张选型决策表
把上面的判断整理成一张表,遇到新任务时对着走一遍:
| 场景特征 | 推荐起点 | 理由 |
|---|---|---|
| 样本少、序列短、要求快 | 线性 / 轻量 GRU | 归纳偏置匹配,训练稳定 |
| 单变量、强周期、中等长度 | 季节性分解 + 线性 | 周期项可以显式建模,不必学 |
| 多变量、长窗口、数据充足 | Patch 化 + 通道独立 Transformer | 全局依赖 + 局部语义兼顾 |
| 变量间强物理耦合 | 变量作为 token 的 Transformer | 显式建模变量交互 |
| 在线低延迟 | 蒸馏后的小模型或卷积方案 | 注意力推理成本高 |
| 需要区间预测 | 分位数损失 + 任意骨干 | 损失函数比结构更关键 |
| 异常检测、样本极不平衡 | 重构式 + 窗口级打分 | 点级打分噪声太大 |
我自己的习惯是永远先跑三个基线:最后值重复、季节性朴素预测、线性回归。这三个跑完,心里就有底了——如果 Transformer 打不过它们,问题多半在数据或评测协议上,而不是模型不够花哨。这个习惯帮我避免过很多次"上来就搞大模型、两周后发现方向错了"的浪费。
最后分享一个实操中反复验证过的小经验:在把模型交给业务方之前,一定要做一次残差分析——把预测误差按时间段、按变量、按数值区间分别画出来。我遇到过的典型情况是,整体 MSE 看着不错,但拆开一看,所有误差都集中在每天的两个高峰时段。这时候调整模型的收益远不如在特征里显式加入"距高峰还有多久"这类信息。模型结构决定上限,但对业务节律的理解决定了下限,而这个下限往往才是线上表现的决定因素。