☰
Informer长序列预测:ProbSparse注意力与生成式解码器实战
2026/10/27 17:07:02 网站建设 项目流程

1. 长序列预测为什么把标准Transformer逼到了墙角

1.1 从“预测未来96个点”这个真实需求说起

做Informer这类长时间序列预测模型,起点往往不是论文,而是一个很具体的业务问句:接下来一段时间会怎样。比如电力调度要看未来一天的负荷曲线,工业设备要做提前量足够长的温度趋势预判,能源管理要按小时排产。这些场景的共同点是预测窗口很长——不是预测下一个点,而是预测未来96个、192个甚至720个时间步。传统ARIMA、Prophet这类方法在单步或短窗口上还能打,一旦把预测长度拉长,误差会累积得很厉害,因为它们大多是自回归式地一步步往外推,前面一步偏一点,后面全跟着歪。

深度学习进来之后,大家的第一个反应是用RNN或LSTM。这条路我早年走过,效果不是不能用,但训练慢、并行度差,而且长依赖衰减的问题在几百步的窗口上非常明显。再往后Transformer成了主流选择,自注意力天然能建模任意两点之间的关系,并行度也好,看起来是理想的解法。但真正上手你就会发现,标准Transformer在长序列上有一个绕不开的硬约束:注意力的时间和内存都是序列长度的平方级。

这就是Informer要解决的问题域。它是2021年一篇会议最佳论文提出的模型,专门面向长序列时间序列预测(Long Sequence Time-series Forecasting,简称LSTF)。说人话:它保留了Transformer的编码器-解码器骨架,但把注意力从平方复杂度压到对数线性级别,同时用一次前向就吐出整段预测结果,而不是一步步挤牙膏。适合谁看?我认为两类人最该认真读:一类是要在业务里落地多步预测的算法工程师,另一类是已经用过Transformer做时序、被显存和推理时长卡住的人。

1.2 平方复杂度到底贵在哪,算一笔账就清楚了

很多人对“平方复杂度”没概念,觉得无非是慢一点。我们把数字代进去看。假设输入窗口长度L=96,注意力矩阵是96×96,九千多个元素,还好。但长序列预测的场景往往要把输入拉长到720甚至更长,720的平方是518400,是96的56倍。再乘上头数、批大小、层数,显存会以肉眼可见的速度爆掉。我实测过,在单卡24G的机器上,标准Transformer用一个较长的输入窗口加上较大batch,很快就OOM,只能把batch压到很小,训练稳定性立刻变差。

更麻烦的是内存访问模式。注意力矩阵本身不只是一次矩阵乘法,中间还要存softmax前后的结果用于反向传播,所以显存占用不只是L²,而是若干个L²。序列翻倍,显存翻四倍,这种增长曲线没有任何工程手段能优雅地兜住。这也是为什么Informer的核心贡献不是“把模型做深”,而是“把注意力做稀”。

这里有个很重要的认知:稀疏化不是为了省资源而牺牲精度,而是基于一个观察——真实时序里的注意力分布本身就是长尾的。绝大多数query只对少数几个key有显著响应,剩下的注意力权重接近均匀分布,属于无效计算。既然如此,就没必要老老实实算满整个矩阵。

1.3 Informer想解决的三件事,以及它没解决的事

把论文的动机拆开看,Informer对准了三个具体问题。第一是注意力的平方复杂度,用ProbSparse注意力把复杂度降到O(L log L)。第二是堆叠层数多了之后内存占用高,用自注意力蒸馏在每层把序列长度砍半。第三是自回归解码太慢,用生成式解码器一次性输出整段预测。这三件事是一套组合拳,缺一个效果都会打折。

但我也得说清楚它没解决什么。Informer不是万能的时序模型,它对周期性、趋势性的建模依赖输入里显式的时间特征编码,如果你的数据没有清晰的时间戳结构,或者采样极不规律,它的优势会打折扣。另外在输入长度只有几十步的短序列任务上,ProbSparse的额外开销甚至可能让整体比标准注意力还慢——稀疏化的收益是随序列长度增长才显现的。这一点很多教程不讲,实际做项目时踩了才知道。

2. Informer的整体设计思路拆解

2.1 骨架没变,变的是“看哪里”和“怎么吐结果”

第一次读Informer的结构图,容易觉得它很复杂,因为画了一堆分支。但拆开看,它还是编码器-解码器那一套,改动集中在三个位置。编码器里,多头注意力换成ProbSparse版本,每层后面接一个蒸馏模块把长度减半。解码器里,输入不再是“上一步的输出”,而是“历史已知段+一段占位符”,注意力用带掩码的版本防止看到未来。输出端一次给出pred_len个点的预测。

这种设计选择的背后逻辑其实很朴素:既然长序列预测的瓶颈在计算量和推理速度,那就从这两个地方下手。ProbSparse解决“算得太多”,蒸馏解决“层数堆不上去”,生成式解码解决“推得太慢”。三者组合之后,你可以在同样的显存下把输入窗口拉得更长,把预测窗口拉得更长,这在原来的标准Transformer上是做不到的。

我个人比较欣赏的一点是,它的改造都是“可插拔”的。你完全可以在自己的项目里只上ProbSparse注意力,保留原有的解码方式;也可以只用生成式解码器,注意力还是标准的。这种模块化让迁移成本低了不少,不像某些模型必须整套搬。

2.2 三个关键改造的取舍逻辑,逐个说透

先说ProbSparse注意力的取舍。它是“近似”而不是“精确”,意味着精度上必然有损失。论文的做法是用KL散度衡量每个query的“活跃程度”,活跃的query做完整注意力,不活跃的用value的均值兜底。为了进一步省算力,KL散度本身也用max减mean来近似,而且只在随机采样的一部分key上算。这三层近似叠起来,理论上是有风险的。但实测下来,在时序数据上精度损失很小,因为时序的注意力分布确实集中。这是一个典型的“用一点点精度换大量算力”的交易,划得来。

第二个是自注意力蒸馏。做法是用一维卷积加最大池化,把序列长度减半。这里要注意,卷积用了循环填充(circular padding),目的是保持时序的边界连续性——序列首尾在周期上可能是相连的,普通零填充会人为制造一个断点。这个细节很小,但对周期性强的数据影响不小,我早期自己实现时忽略了它,结果在周期边界处的预测明显毛刺。

第三个是生成式解码器。传统做法是训练时用teacher forcing,推理时一步步喂回自己的输出,误差累积严重。Informer的做法是训练和推理都统一成“一次性输出”,解码器输入是历史已知的一段真实值加一段全零占位符。占位符位置的输出就是要预测的值。这个设计让推理只需一次前向,速度提升非常明显,但也带来一个约束:解码器输入的已知段长度必须固定,不能灵活调整。

2.3 什么数据适合上Informer,什么数据别硬套

判断标准我总结成三条,很实用。第一,预测窗口要不短,至少几十步以上,如果你的任务只是预测下一个点或者未来五步,标准Transformer甚至简单的LSTM就够,上Informer属于杀鸡用牛刀,还容易因为稀疏化引入不必要的偏差。第二,输入要有清晰的多尺度时间结构,也就是数据里存在小时、天、周这类周期性,因为Informer的时间戳嵌入是吃这些特征的,没有的话等于浪费了一路输入。

第三,样本量要够。Informer参数量在千万量级,小数据集上很容易过拟合,我见过在只有几千条样本的数据上直接上Informer,训练损失降得很好看,验证集RMSE却很糟。这种情况不如退回去用轻量模型,或者先把数据增强、滑窗步长这些前置工作做扎实。表格对比一下会更直观:

数据特征建议方案理由
预测步长小于24,样本万级以下轻量模型或统计方法稀疏化收益不抵开销
预测步长24至96,有明显日周期Informer标准配置正好落在设计目标区间
预测步长超过192,输入窗口长Informer加长输入和蒸馏优势最明显
采样不规律、无时间戳慎用,需先重构特征时间嵌入无从下手

3. 核心机制逐层拆解与手写实现

3.1 输入嵌入:三路信息拧成一根向量

Informer的输入嵌入有三路。第一路是标量值本身,通过一维卷积映射到d_model维度,这里用卷积而不是全连接,是为了在映射的同时保留局部时序模式,相当于顺手做了一个小范围的平滑。第二路是位置编码,用的是标准正弦位置编码,给每个时间步一个位置标识。第三路是时间戳嵌入,把每个时刻的月、日、星期、小时、分钟这些离散特征分别做Embedding再相加。

三路相加之后得到编码器输入。这里有个实操细节:值嵌入用卷积时,卷积核大小和padding要匹配,否则序列长度会变,后面的位置编码就对不齐了。我一般用kernel_size=3、padding=1,这样长度保持不变。另外时间戳特征要提前离线处理好,别在训练循环里做,会拖慢不少。

class DataEmbedding(nn.Module): def __init__(self, c_in, d_model, dropout=0.05): super().__init__() self.value_embed = nn.Conv1d(c_in, d_model, kernel_size=3, padding=1) self.temporal_embed = nn.Embedding(64, d_model) # 简化版,各类时间特征共用 self.dropout = nn.Dropout(dropout) def forward(self, x, x_mark): # x: [B, L, C] x_mark: [B, L, 4] 已编码好的时间特征索引 x = self.value_embed(x.permute(0, 2, 1)).permute(0, 2, 1) t = self.temporal_embed(x_mark).sum(dim=2) return self.dropout(x + t)

位置编码部分我就不重复贴了,正弦公式是通用的。需要强调的是,位置编码和值嵌入是相加不是拼接,所以d_model必须一致,这个在配置时容易写错。

3.2 ProbSparse注意力:怎么挑出“值得算”的query

这是整个模型最核心也最容易写错的部分。思路是:对每个query算一个稀疏性得分M,得分高说明这个query的注意力分布偏离均匀分布,值得做完整计算;得分低的query就用value的均值代替,因为它本来也没关注什么。M的定义基于KL散度,但实际实现用max减mean来近似。

import math import torch import torch.nn as nn class ProbSparseAttention(nn.Module): def __init__(self, d_model, n_heads, factor=5, dropout=0.05): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_heads self.factor = factor self.q_proj = nn.Linear(d_model, d_model) self.k_proj = nn.Linear(d_model, d_model) self.v_proj = nn.Linear(d_model, d_model) self.out_proj = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, queries, keys, values, attn_mask=None): B, L, _ = queries.shape S = keys.shape[1] H = self.n_heads q = self.q_proj(queries).view(B, L, H, -1).permute(0, 2, 1, 3) k = self.k_proj(keys).view(B, S, H, -1).permute(0, 2, 1, 3) v = self.v_proj(values).view(B, S, H, -1).permute(0, 2, 1, 3) # 采样数量 u = c * ln(L),c 就是 factor u = max(1, int(self.factor * math.log(max(2, L)))) u = min(u, L) score = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if attn_mask is not None: score = score.masked_fill(attn_mask, -1e9) # max - mean 近似 KL 散度,得到每个 query 的活跃度 m = score.max(dim=-1).values - score.mean(dim=-1) # [B,H,L] top_u = m.topk(u, dim=-1).indices # [B,H,u] # 只对活跃 query 做完整注意力 q_sparse = torch.gather( q, 2, top_u.unsqueeze(-1).expand(-1, -1, -1, self.d_k)) attn = torch.softmax( q_sparse @ k.transpose(-2, -1) / math.sqrt(self.d_k), dim=-1) attn = self.dropout(attn) out_sparse = attn @ v # [B,H,u,d] # 其余 query 用 value 均值兜底 out = v.mean(dim=2, keepdim=True).expand(-1, -1, L, -1).clone() out.scatter_(2, top_u.unsqueeze(-1).expand(-1, -1, -1, self.d_k), out_sparse) out = out.permute(0, 2, 1, 3).reshape(B, L, self.d_model) return self.out_proj(out)

这段是教学版,能跑通但没做工程优化。真正用时要注意几点:factor的取值直接决定采样量,原实现默认5,L=96时u约等于23,也就是只算23个query的完整注意力,剩下73个用均值,省得很凶。如果发现精度掉得厉害,先把factor调到8或10试试,比改模型结构见效快。另外scatter回填时要确保top_u索引顺序和out_sparse对应,写错的话不会报错,只会静默地给出错误结果,这个坑我踩过一次,debug花了很久。

3.3 自注意力蒸馏:把序列长度一层层砍半

蒸馏模块的作用是控制内存。每经过一层编码器,序列长度减半,两层之后长度变成四分之一。实现上就是卷积加激活加池化,卷积核用循环填充保持边界连续。

class Distilling(nn.Module): def __init__(self, d_model): super().__init__() self.conv = nn.Conv1d(d_model, d_model, kernel_size=3, padding=1, padding_mode='circular') self.norm = nn.BatchNorm1d(d_model) self.act = nn.ELU() self.pool = nn.MaxPool1d(2, stride=2) def forward(self, x): # x: [B, L, D] x = self.conv(x.permute(0, 2, 1)) x = self.pool(self.act(self.norm(x))) return x.permute(0, 2, 1) # 长度变为 L//2

这里有个数字要提前算好。假设编码器堆了n层,输入长度L,每层减半,最后一层输出长度大约是L除以2的n次方。如果L=96、n=2,最后长度是24;如果n=3,长度是12。长度太小会丢失信息,所以层数不能无限堆。我一般把n控制在2到3,L较小时用2层,L超过500时才考虑3层。另外L必须是偶数才能整齐减半,构造样本时要保证左填充或截断后长度为偶数,否则池化会丢一个时间步,累积到后面的解码就会错位。

3.4 生成式解码器:一次前向吐出整段预测

解码器的输入是两段拼接:前label_len步是历史的已知真实值,后pred_len步是全零占位符。注意力里用因果掩码保证每个位置只能看到自己和更早的位置,但因为占位符那部分的输入是零,模型实际上是靠编码器输出的交叉注意力来“填”这些位置。

# 构造解码器输入:label_len 步已知 + pred_len 步占位 dec_in = torch.cat([ x_enc[:, -label_len:, :], torch.zeros(B, pred_len, x_enc.shape[-1], device=x_enc.device) ], dim=1) # 解码器输出只取后 pred_len 步 out = model(x_enc, x_mark_enc, dec_in, x_mark_dec) pred = out[:, -pred_len:, :] # [B, pred_len, C]

这个设计的好处是推理速度。对比一下就很明显:自回归方式预测96步要前向96次,生成式只要1次。但代价是,占位符位置的信息完全靠交叉注意力从编码器拿,如果编码器对历史压缩得太狠(比如蒸馏层太多),解码器就“无米下锅”,表现是预测曲线过于平滑、缺乏细节。这时候要么减一层蒸馏,要么把label_len加长,给解码器更多真实上下文。

4. 从零跑通一个任务:数据、训练与调参

4.1 滑窗构造样本与标准化,别在这里翻车

数据准备这一步看着无聊,但出问题最多。我用的是一个公开的电力负荷类数据集做验证,包含多变量序列和对应的时间戳。核心是把长序列切成一个个(输入窗口, 预测窗口)的样本对。参数设定一般是seq_len=96、label_len=48、pred_len=96,滑窗步长stride=1。

def build_windows(data, seq_len, label_len, pred_len, stride=1): samples = [] total = seq_len + pred_len for start in range(0, len(data) - total + 1, stride): x = data[start : start + seq_len] y = data[start + seq_len - label_len : start + seq_len + pred_len] samples.append((x, y)) return samples

注意y的长度是label_len + pred_len,因为解码器需要前面那一段已知值。这个偏移量很多人第一次写会搞错,导致标签和输入错位一格,训练出来的模型会整体滞后。

标准化我要单独强调:必须用训练集的均值和方差,不能全量数据算。我见过直接用全量标准化的,验证集信息泄漏,指标虚高,上线立刻掉下来。稳妥做法是把均值方差存下来,推理时复用。另外对多变量数据,可以逐通道标准化,也可以全局标准化,通道量纲差异大时选前者。

4.2 损失函数与学习率策略的选择

原实现用MSE,理由是长序列预测更看重整体形状,MSE对大偏差惩罚重,能压住离群点。但我实际用下来,如果你的数据里存在尖峰或者异常值,MSE会被少数极端样本主导,训练不稳定。这时候换成MAE或者Huber会更稳。我一般先跑MSE做基线,如果发现损失曲线震荡剧烈,再换Huber试。

学习率方面,Informer原配置用了warmup加衰减的组合,峰值学习率在1e-4量级。这个值不算激进,配batch size 32比较合适。如果显存不够只能开到batch 8,学习率相应往下调到3e-5左右,否则容易发散。我的习惯是先固定小学习率跑200步,看损失是否稳定下降,确认没问题再开warmup。

4.3 关键超参数怎么定,附一组可复用的配置

超参数这块,我把常用的默认配置整理成表,直接抄作业基本不会出大错:

参数取值说明
d_model512太小容量不够,太大显存吃紧
n_heads8每头64维,是常见均衡点
e_layers2编码器层数,配合蒸馏用
d_layers1解码器一层通常够用
d_ff2048前馈层,一般是d_model的4倍
factor5稀疏采样系数,精度不够就调大
dropout0.05时序任务不宜过高
batch_size32显存允许下尽量大
lr1e-4配warmup使用

算一下参数量级,方便你预估显存。每层编码器的注意力部分大约4×d_model²个参数,512的平方是26万,乘4约100万;前馈层是2×d_model×d_ff,约200万。一层加起来300万左右,两层600万,再加上嵌入层、输出投影、解码器,整体在千万参数上下。这个量级在单卡上训练是可以接受的,但如果你的数据只有几万条,过拟合风险确实存在,dropout和早停要配好。

4.4 训练现场的观察记录

实际训练时我关注三条曲线:训练损失、验证损失、以及验证集上的MAE。正常的形态是训练损失快速下降后趋缓,验证损失同步下降但稍高。如果验证损失在几十个epoch后开始回升,就是过拟合信号,这时候要么加dropout,要么减层数。

还有一个容易忽略的指标是“预测曲线的形状”。我不只看数值误差,还会把预测结果和真实曲线画在一起看。很多模型MAE看着不错,但画出来是一条平滑的直线,完全没有捕捉到波动。这种情况说明模型学到的只是均值,ProbSparse的稀疏化可能过度了,或者factor设得太小。把factor从5调到10,往往曲线立刻有了细节。

训练时长上,在单卡上跑一个中等规模的数据集,每epoch几分钟量级,通常几十个epoch收敛。如果发现跑了几百epoch还在缓慢下降,多半是学习率太小或者warmup太长,可以适当调整。别盲目追求训练损失降到极低,时序预测的泛化比拟合重要得多。

5. 常见问题与排查技巧实录

5.1 损失降得漂亮,预测却“贴地走”

这是最高频的问题。训练损失一路降,验证MAE也还行,但把预测画出来一看,几乎是一条水平线,只是跟着均值走。原因通常有三个。第一是标准化方式不对,如果对每个窗口单独标准化,模型学不到绝对水平,输出自然趋向均值。第二是ProbSparse的factor太小,稀疏化过度,模型只保留了最主导的那部分注意力,细节全丢。第三是解码器的label_len太短,给的上下文不足。

排查顺序我一般是这样:先检查标准化,确认用的是全局统计量;再把factor临时改成L(等于退回标准注意力),看曲线是否恢复细节;最后调整label_len。这三步基本能定位问题。实测下来,factor从5调到10,是性价比最高的一次改动。

5.2 ProbSparse采样引发的静默错误

这个坑比较隐蔽,不会报错,只会让结果莫名其妙地差。典型表现是:模型能训,损失也降,但换一个随机种子结果波动极大。原因多半是top_u索引的处理有问题,比如gather和scatter的维度没对齐,或者掩码在稀疏计算路径上没有正确应用。

我的检查方法是写一个小单测:构造一个极小的输入(比如L=8、单头),把factor设成一个很大的值让u等于L,此时ProbSparse理论上应该等价于标准注意力,两者的输出应该几乎一致。如果对不上,说明实现有bug。这个方法很笨但极其有效,我每次改完注意力代码都会跑一遍。另外注意,u取整时要用int而不是round,边界情况下差一个都会导致索引越界或者采样不足。

5.3 预测步长一拉长就崩,问题出在哪

短窗口预测还行,把pred_len从96拉到336,指标立刻崩掉。这通常不是模型本身的问题,而是训练样本的分布变了。预测窗口变长之后,样本总数减少(因为要留出更长的尾部),有效训练数据变少;同时预测远端的信息量本来就低,误差自然会涨。

应对办法有三条。一是把stride调小,尽量榨出更多样本;二是适当增加输入窗口seq_len,让编码器看到更长的历史,这条最有效;三是检查解码器的位置编码,如果pred_len超过了训练时的最大位置,位置编码会外推到没见过的区域,表现会突然变差,这时候要把位置编码的上限提前留足。我在一次实验里就是卡在这个点上,把位置编码的max_len从500调到1000之后,长窗口指标立刻回升了一大截。

5.4 问题速查表

把上面这些整理成表,遇到问题时按顺序对一遍,能省不少时间:

现象最可能原因处理动作
预测曲线过度平滑factor过小或蒸馏层过多factor调至10,减一层蒸馏
损失震荡不收敛学习率偏大或存在异常样本降lr,换Huber损失
换种子结果差异大稀疏索引处理有bug用u=L做等价性单测
长窗口指标骤降位置编码外推越界提前扩大位置编码上限
验证集虚高、上线掉用全量数据做了标准化改用训练集统计量
边界处出现毛刺蒸馏卷积用了零填充改为循环填充

6. 几个我认为值得单独说的工程细节

推理阶段的批处理值得调。生成式解码器一次出整段结果,意味着推理时可以把多个样本拼成一个大batch,吞吐提升很明显。但要注意解码器输入的占位符长度必须一致,如果你的业务场景里预测长度是变化的,就得按长度分桶,别硬凑。

模型保存和加载也有个细节。Informer里有几处依赖配置的参数(比如最大位置编码长度、时间特征的分箱数),保存时要把配置一起序列化,别只存state_dict。我吃过这个亏,换台机器加载时因为配置默认值不同,位置编码维度对不上,报了一个很隐晦的shape错误,查了半天才发现是配置没跟着走。

最后说一个我个人觉得最实用的经验:不要一上来就追最新最复杂的变体。Informer的原始结构已经把长序列预测的主要矛盾解决得差不多了,很多后续改进是在特定数据分布上做微调。如果你手头的数据是典型的工业或能源类多变量序列,老老实实把原始结构调好,把数据窗口、标准化、factor这几个地方抠细,效果往往比换模型更明显。我在几个项目里反复验证过这一点,模型结构的边际收益远小于数据处理的边际收益。这个判断不一定对所有人都成立,但至少在我接触的场景里,它一次又一次被印证。

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

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

立即咨询