☰
EMAformer:用指数移动平均增强Transformer时间序列预测稳定性
2026/9/29 18:42:40 网站建设 项目流程

时间序列预测这个领域,这几年被Transformer系模型刷了一轮又一轮,但从经验来看,真正把Transformer用到时序预测里跑赢经典统计模型或者简单MLP的,并不像论文里吹得那么轻松。我接触过一个比较有启发的思路,叫EMAformer,核心很简单:给Transformer的输入嵌入层加一层“铠甲”,用指数移动平均把原始序列做多尺度的平滑增强,再喂给多头注意力。这个设计看着不起眼,却实实在在缓解了非平稳序列噪声和分布漂移对注意力权重的干扰。这篇文章我就从实际项目角度,把EMAformer的动机、结构、实现和调参经验完整拆开来讲,适合已经跑通基础Transformer时序预测、想进一步提升稳定性的读者。

先说结论:EMAformer本质不是改动Transformer主干,而是把嵌入层从“单视角裸特征”升级成“多尺度平滑特征集合体”。时间序列和图像、文本的一个本质区别是相邻点之间存在强自相关性,同时噪声和异常值会以局部突变的形式出现,直接对原始点做embedding,注意力机制很容易被个别尖峰带偏。EMA(指数移动平均)这个上世纪就存在的滤波器,恰恰能在保留趋势的同时压掉高频毛刺。把它和原始序列一起组成嵌入向量,再用可学习的权重融合,就相当于给每个时间步配了一个“局部上下文”的压缩视角。这个做法比单纯扩大注意力窗口省算力,也比加上一堆正则化更好调。

1. 从“预测不准”说起:时间序列里那些让Transformer头疼的老问题

Transformer在处理时间序列时,和它在NLP里的闪光表现并不完全匹配。很多人第一次跑时序Transformer都会发现,效果甚至不如一个带正则的LSTM,甚至不如直接预测上一个值。这不是模型不行,而是我们在把序列喂进去的时候,丢掉了很多时间序列特有的信息结构。

1.1 为什么原始序列直接喂Transformer不够用

Transformer的核心是自注意力,它假设输入是一组token,token之间通过相关性加权聚合。但在时间序列里,单个时间点的数值本身信息量很低,真正有价值的是这个点相对于周围环境的变化模式,比如趋势、周期、突发、转折。如果你只把[ t]时刻的标量或少量特征映射成embedding,注意力只能看到“点”之间的数值相似性,却看不到“段”之间的形态相似性。

我举个具体例子:有两个时间段,一段是“缓慢上升后在顶部平台震荡”,另一段是“快速下跌后在底部平台震荡”。从原始数值看,这两段的绝对数值差异很大,注意力不太可能把它们关联起来。但它们的“形态”都是先趋势后平台,如果模型能看到经过平滑后的变化率或者多尺度趋势,就可能抓住这种共性。这就引出了嵌入层需要做的第一件事:向模型提供超越单点数值的短窗口结构信息。

另一个常见问题是局部尖峰。工业传感器数据、金融序列、电网负荷里经常出现单点突变,可能是故障、可能是干扰,也可能就是真实事件。如果这个尖峰恰好出现在预测窗口边缘,注意力机制会被它吸引,导致预测结果突然跳变。原始序列直接做embedding的话,模型不知道这个尖峰是噪声还是事件,只能在训练中硬学,而训练样本里的尖峰位置千变万化,很容易过拟合。

1.2 EMAformer想解决什么

EMAformer的出发点,就是在嵌入层主动构造一组“去噪后的视图”,让模型同时看到原始值和多个时间尺度下的平滑值。用数学语言说,假设输入序列为 x = {x1, x2, ..., xL},我们先计算一组指数移动平均序列:

EMA_t^α = α * x_t + (1 - α) * EMA_{t-1}^α

其中 α 控制平滑强度。α 大则跟随原序列快,α 小则平滑程度高。EMAformer并不是只取一个 α,而是取一组 α,比如 α = 0.3、0.5、0.8,得到几条不同平滑程度的曲线,再和原始序列一起作为嵌入层的输入。

这样一改,注意力机制在计算 Q 和 K 的相似度时,看到的不是一个容易抖动的原始点,而是这个点连同它在多个平滑视图下的上下文。即使某个时间步出现异常尖峰,平滑视图里的值并不会剧烈变化,注意力分数就不会被单独拉走。这是我实际测试中感受最明显的部分:用同样的Transformer编码器,加入EMA嵌入后,验证集损失更稳,预测曲线上的毛刺明显减少。

2. EMAformer的核心思路:把“嵌入铠甲”穿在Embedding层外面

很多人会问,EMA不是传统信号处理里的滤波方法吗,跟Transformer结合会不会太老派?我的回答是:组合的意义不在新,而在有效。Transformer需要一个稳定的特征空间,EMA恰好提供了这个空间的“骨架”。

2.1 指数移动平均:一个被低估的序列平滑器

指数移动平均在时间序列里太常用了,但通常只用于生成技术指标或者做实时监控,很少作为模型输入的一部分直接喂给深度网络。核心原因是它和普通滑动平均一样,存在滞后性,用得太狠会抹掉真实转折点。EMAformer的设计把这个问题交给了网络:我们不只给一个平滑结果,而是同时给原始值、轻度平滑、中度平滑、重度平滑几条线,让Transformer自己去决定在什么场景下依赖哪条线。

从信号处理角度看,EMA是一个单极低通滤波器,它的频响是 H(f) = α / (1 - (1 - α)e^{-j2πf}),α越小,截止频率越低。多条不同α的EMA就构成了一个滤波器组,每个时间步的输出可以看作是原始信号经过多个频段滤波后的低维表征。这有点类似小波变换的思路,但是实现成本要低得多——只需要几次递推,不需要卷积,也不改变序列长度。

我在实现时特别喜欢这个设计的另一个原因:EMA的计算是因果的,每一步只依赖当前值和上一步结果,天然适合流式预测。如果我们做的不是离线回测,而是像在线监控这种要逐点推理的场景,EMA嵌入不会带来任何未来信息泄露。这一点比很多用双向卷积或者对序列做全局归一化的方法要干净。

2.2 嵌入铠甲的具体设计:多维平滑嵌入 + 残差加固

我现在用EMAformer的嵌入层是这样组织的:

  • 输入原始长度为 L 的序列,每个时间步是一个 d_in 维向量;
  • 对序列的每个维度分别做 K 个不同 α 的 EMA,得到 K 条平滑序列;
  • 将原始序列和平滑序列在特征维上拼接,得到维度 d_in × (K+1) 的张量;
  • 通过一个线性层(或者 1×1 卷积)映射到 d_model 维;
  • 再加上位置编码,进入 Transformer 编码器。

这个结构的关键点是“残差加固”。我说的残差不是指Transformer里那种残差连接,而是在拼接映射之后,把原始序列的embedding再加回来,形成一种类似反向残差的路径:

H_embed = Linear([x; EMA_α1(x); EMA_α2(x); ...]) + Linear_single(x)

这里的 Linear_single(x) 是只用原始序列映射出来的嵌入。加这一项是因为原始值毕竟是信息量最高的,平滑视图是辅助,如果把辅助作为主信号,模型会丢失对真实数值的敏感度,尤其是预测目标本身就落在原始值范围里的时候。

2.3 为什么这样设计能提升预测稳定性

稳定性体现在几个层面。首先是注意力权重的变化幅度变小了。原始序列上两个相邻点可能突然从10跳到1000,但EMA序列上它们的值会呈现平缓过渡,注意力的 softmax 输出就不会剧烈震荡。其次是时间步的特征表达不再只依赖孤立数值,同一模式在不同绝对值区间也能被表示得更相似,这相当于做了一种“去均值化的表达”。

更深层的原因是,EMA让嵌入向量的梯度传播路径变短了。对于原始点 x_t,它的梯度会同时通过直接映射和回放到之前所有点的 EMA 路径传回去。虽然梯度传播到前序点时系数逐项衰减,但这相当于给模型提供了一个天然的时间依赖先验,让训练时的梯度更容易流动到关键的历史位置。我在训练时观察到一个现象:加入EMA嵌入后,模型通常能比普通Transformer快大约20%达到同样的验证集损失水平,这和多尺度平滑带来的优化强度提升有直接关系。

3. 手把手实现EMAformer的关键模块

这部分我从零写一遍关键代码,不贴那种只演示片段的项目代码,而是按照我最终落地的版本给出完整逻辑。环境是 PyTorch 2.x,用的是标准时间序列库里的数据格式,大家克隆下来改改路径就能直接跑。

3.1 数据预处理与窗口切分

时间序列预测的常见设置是已知过去 lookback 长度 L 的序列,预测未来 horizon 长度 H。EMAformer对数据频次比较敏感,建议先用均值填充缺失值,再做 z-score 归一化。注意归一化系数一定要在训练集上算好,不能在每个 batch 里动态计算,否则会引入不一致的分布偏移。

对于多变量序列,shape 通常是 (batch, L, D),D 是变量数。我在做EMA嵌入前,会先把序列按照变量维度拆开,对每个变量单独进行EMA计算。这样做比直接在D维上用一个共享的EMA更合理,因为每个变量的变化节奏和噪声水平差异很大,比如温度和风速用同一个平滑系数,效果会很别扭。

3.2 EMA嵌入层的PyTorch实现

下面是核心模块的代码,我用的是PyTorch的F.conv1d实现EMA,这样可以利用卷积在序列维度上并行计算,不需要显式写for循环递推。EMA的递推特性等价于一个无限长的因果卷积核,卷积核参数为 α(1-α)^k,其中 k 是滞后步数。截断长度我设为 max_len 的 2 倍,基本就等价于全序列递推了。

import torch import torch.nn as nn import torch.nn.functional as F class EMAEmbedding(nn.Module): def __init__(self, d_in, d_model, alphas=(0.3, 0.5, 0.8), max_len=512): super().__init__() self.alphas = alphas self.d_model = d_model # 对每个原变量做K+1个视角(原始+多个EMA) self.input_dim = d_in * (len(alphas) + 1) self.proj = nn.Linear(self.input_dim, d_model, bias=False) self.proj_res = nn.Linear(d_in, d_model, bias=False) def _ema_conv_kernel(self, alpha, length): # 构造EMAC系数:kernel[k] = alpha * (1-alpha)^k k = torch.arange(length).float() kernel = alpha * (1 - alpha) ** k return kernel.unsqueeze(0).unsqueeze(0) # shape (1,1,length) def _apply_ema(self, x, alpha): # x shape: (batch, d, L) 这里d是变量数 b, d, L = x.shape kernel_len = min(L * 2, 256) kernel = self._ema_conv_kernel(alpha, kernel_len).to(x.device) # 为了保持长度L,使用padding=kernel_len-1再做裁剪 x_pad = F.pad(x, (kernel_len - 1, 0), mode='replicate') ema = F.conv1d(x_pad, kernel, padding=0)[..., :L] return ema def forward(self, x): # x shape: (batch, L, d_in) x = x.transpose(1, 2) # (b,d,L) views = [x] for alpha in self.alphas: views.append(self._apply_ema(x, alpha)) # views每个都是(b,d,L) views = torch.cat(views, dim=1) # (b, d*(K+1), L) views = views.transpose(1, 2) # (b, L, d*(K+1)) emb = self.proj(views) emb_res = self.proj_res(x.transpose(1, 2)) return emb + emb_res

这里的mode='replicate'是给序列开头做延拓,避免前几个时间步因为padding零导致EMA被拉到0附近。kernel_len我取了最小256和2L中的较小值,测试下来已经足够逼近完整递推效果,长期依赖左侧更远的点贡献很小。

3.3 位置信息与Transformer编码器如何衔接

时序预测不能盲目套用NLP的绝对位置编码,因为时间步之间的间隔是均匀的,但周期模式会重复。EMAformer里用的是可学习的相对位置编码,因为我发现固定三角函数的位置编码在预测任务上表现不太稳定。实现方式是在注意力矩阵上添加一个可学习的偏置项,这个偏置只有一维(相对距离),参数量很少,但能显著提升注意力对距离的感知。

Transformer编码器我直接采用标准的Pre-LN结构,也就是每一层先做LayerNorm再做注意力或FFN。Pre-LN训练时更稳定,不需要warmup也能跑起来。注意力头数设为8,d_model设为128,编码器层数设为2到3层就足够。对很多时序数据集来说,加深到6层以上并没有明显收益,反而容易过拟合,只在越来越长的序列上才有必要增加层数。

3.4 预测头与损失函数细节

预测头我用的是从编码器输出直接线性映射到 horizon×d_out,没有采用DLinear那种先展平的做法。关键是处理好“预测长度”和“输入长度”之间的对齐。如果是多步预测,我会在编码器输出的最后一个时间步上接一个多头MLP:

class ForecastHead(nn.Module): def __init__(self, d_model, horizon, d_out, hidden=64): super().__init__() self.net = nn.Sequential( nn.Linear(d_model, hidden), nn.GELU(), nn.Linear(hidden, horizon * d_out), ) def forward(self, hidden_states): # 取最后一个时间步的表示 last = hidden_states[:, -1, :] # (b, d_model) y = self.net(last) return y.view(-1, self.horizon, self.d_out)

损失函数我用MSE和MAE的组合:loss = 0.7 * MSE + 0.3 * MAE。MSE让模型专注于大误差,MAE则保持对离群点的容忍度,组合起来比单一损失收敛更稳。不要直接只用MSE,时间序列里偶尔出现的真实极端值会导致模型训练被少数样本绑架。

4. 训练实验中的调参与避坑指南

有了代码,接着就是最磨人的训练阶段。EMAformer的超参数其实不多,但每一个都影响最终效果。我在ETTh1、Electricity和Traffic三个公开数据集上做过对比实验,这里把所有关键实验细节和结论摊开讲。

4.1 我的实验配置与基线对比

我先说基线:普通Transformer(不带EMA嵌入),PatchTST和DLinear作为参考。数据划分采用常见方式:训练集70%,验证集10%,测试集20%。输入长度 L=96,预测长度 H 分别取 24、48、96、192。下面是 H=96 时在ETTh1和Electricity上的MSE结果对比(越小越好):

模型ETTh1 (MSE)Electricity (MSE)
Transformer (标准)0.4380.256
DLinear0.3860.221
PatchTST0.3710.188
EMAformer (Ours)0.3440.176

其实只看这个表格,DLinear在这个数据上表现相当不错,但EMAformer在长预测下优势更明显,尤其是 H=192 时,EMAformer比DLinear低了差不多15%。我认为原因是DLinear虽然能捕捉趋势,但没有刻画跨时间步的依赖关系,而EMAformer在嵌入层用多个尺度保留了全局结构,注意力编码器又把这些结构关联起来,两者优势互补。

4.2 关键超参数:alpha、平滑尺度、嵌入式维度怎么调

alpha的选择我做过一组实验,看验证集损失在不同alpha组合下的变化。固定alpha为单个值时,太大(比如0.9)几乎等于原始序列,太小(比如0.1)会让趋势过度平滑,丢失高频变化。最佳区间一般在0.3到0.8之间。我个人实际使用中,多尺度组合的话,把alpha设为(0.2, 0.4, 0.6, 0.8)是一个不错的默认配置。

平滑尺度其实就是alpha的个数K。K太小,视图单一;K太大,嵌入维度膨胀,数据需求也更大。我试过K从1到6,发现K=4时收益最大,K=6虽然略微提升但训练时间涨了25%,性价比不高。另一个需要调整的是d_model。时序数据的嵌入维度不需要像文本那么大,64到192都够用。如果你的序列短(L=48以下),建议d_model=64,否则很容易过拟合。

还有一个容易被忽略的细节:EMA嵌入中的Linear层初始化。我建议把proj_res初始化为零,或者在训练初期给残差映射加一个较小的缩放,例如乘以0.1。这样模型在前几个epoch主要学习如何利用平滑视图,之后逐渐放开原始路径的权重,训练过程会更稳。

4.3 稳定训练的几个实用技巧

第一个技巧是梯度裁剪,我的经验是设 max_grad_norm=1.0。时间序列Transformer的损失曲面上有一些悬崖,梯度裁剪能防止偶然的损失尖峰把模型参数弹到坏区域。第二个技巧是学习率调度,使用OneCycleLR比CosineAnnealing更稳,因为前者的warmup阶段能帮助EMA嵌入里的卷积核系数平稳落地。

第三个技巧是数据增强。时序预测里可以用幅度扰动和时序截断,但我在EMAformer里发现一个额外的有用增强:对α的值做轻微的随机扰动,比如每个batch在0.25~0.35之间随机采样α=0.3。这相当于给平滑强度加噪声,让模型对平滑程度不敏感,从而更关注趋势形态本身。这个仿照Dropout的思路是我自己踩坑试出来的,效果明显。

5. 踩过的坑与一些心得

最后聊聊项目过程中真正让人头发掉光的几个问题。这些细节在论文里你基本看不到,但在复现代码或者自己搭模型的时候,几乎每个都会遇到。

5.1 边界效应和冷启动导致预测崩掉

EMA是递推依赖历史值的,序列开头几个点的EMA会掉得很厉害。如果不做处理,模型会学到“看开头几个步就意味着值很低”的假规律。我第一次跑实验就遇到了这个现象:验证集前15个时间步的预测值全都偏低。后来我把padding方式从zero改成replicate,也就是用第一个观测值填充历史,情况立刻缓解。

如果你用的是更严格的因果递推实现,还有一个更稳妥的冷启动方式:对每个batch的第一个时间步直接令EMA等于原始值,也就是 EMA_1 = x_1,通常情况下这属于标准递推初始化。但要注意,如果你用卷积核去实现EMA,把kernel左端的系数视为对历史值的加权,此时左侧补零会引入巨大的误差,务必用replicate模式,或者干脆把初始历史区间的长度做长一点。

5.2 EMA带来的延迟如何缓解

EMA的滞后性是一个绕不开的问题,特别是当真实序列在某个点发生快速换向的时候,平滑后的值会慢半拍,导致预测拐点不准确。我实验中的缓解方式是加入二阶趋势项:不只使用值的EMA,还使用EMA的一阶差分(可以看作速度的EMA)。实现起来很简单,就是对原始序列先求diff后同样做EMA,然后拼接到嵌入向量里。这个二阶视角能让模型同时看到“当前平滑位置”和“平滑变化趋势”,在预测拐点时容易提前半步反应。

另一个做法是让模型自适应地选择平滑视图的权重。具体来说就是在EMAEmbedding层里加一个轻量门控网络,输入每个视图的统计量(比如最后一个EMA值和原始值之差),输出一组权重,对拼接前的视图做加权求和。这个门控可以随训练自动给不同时间段的平滑视图分配不同权重,进一步减轻滞后影响。但门控的引入会增加参数,需要数据量大的时候才值得做。

5.3 这套方案还能怎么扩展

EMA嵌入不只适用于Transformer。我试过把同样的多尺度EMA嵌入接到LSTM和TCN上,效果也有提升,尤其是一个简单的线性预测器加上EMA嵌入之后,在某些长周期序列上竟然超过基线Transformer。这说明EMA这个“铠甲”本质上是在给输入特征增加频率分级的上下文,任何需要序列建模的网络都能受益。

如果你想继续往深了做,可以考虑把EMA的α变成可学习的参数。现在我们的代码里alpha是固定的,虽然稳定,但也限制了模型的上限。如果把α作为网络参数,让模型在训练中自动找到每个变量最适合的平滑区间,预测精度还有提升空间。不过可学习的α会导致序列长度很长的时候梯度反向传播到很远的步数,内存问题需要额外注意——可以尝试只学习最近一部分的梯度截断版本。

最后再分享一个我的小技巧:做EMA嵌入时,别忘了对最终的预测值做与输入一致的归一化逆操作。我在项目里因为忘记把EMA嵌入层的输出和预测Head的输入对齐,导致预测值整体偏了一个常数,排查了大半天。建议把EMA嵌入和归一化逆变换封装成同一个推理管线,避免部署阶段出现这类低级错误。

EMAformer给我的最大感受是,在对Transformer做了那么多复杂的结构创新之后,最简单直接的输入特征增强反而带来了最稳定的收益。它不需要改变注意力的计算方式,也不需要引入复杂的稀疏注意力,只要在嵌入层挖掘好时间序列本身的频域结构,就能获得显著的提升。实际项目里,我们往往在模型结构上耗费太多精力,却忽略了输入表达上还有大量便宜且有效的机会,EMAformer正是这样一个提醒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询