时间序列预测最近讨论度最高的组合大概就是LSTM和Transformer的混合模型了。说“杀疯了”有点标题党但实际效果确实出乎意料。我在做流量预测、销量预测这类任务时单用LSTM总觉得它记不住太远的周期规律单用Transformer又总感觉它抓不住紧邻时间点的连续变化于是把两个网络拼起来用效果通常比任何一个单模型都稳有些数据集上甚至直接涨点明显。这篇文章不搞虚的我把为什么融合、融合结构怎么选、代码怎么写、训练时会踩到哪些坑全部摊开讲清楚。适合已经跑通基础模型、想进一步提点的同学也适合刚接触时间序列但不想只停留在调用现成API层面的读者。1. 为什么要把LSTM和Transformer放在一起1.1 单一模型各自的“偏科”问题时间序列预测的核心难点在于真实序列从来不是单一模式。拿服务器负载举例前一分钟的数值大概率会延续到下一分钟这是局部连续性但负载每天有峰谷、每周有规律性这是全局周期。局部连续性和全局规律是两种不同层面的信息单一模型很难同时抓牢。LSTM属于循环结构按时间顺序逐个读入数据天然对顺序敏感能比较好地建模局部连续变化。问题是它的记忆容量有限信息在一个个时间步之间传递距离一长就容易衰减而且串行计算序列越长训练越慢。Transformer的自注意力机制则允许序列中任意两个位置直接交互无论相隔多远都能建立依赖所以长周期规律抓得比较准还能并行训练。但它更像“一眼扫过全文”容易忽略相邻位置之间那种细微的连续关系而且本身不包含顺序信息必须靠位置编码补上先后关系。用生活化的类比来说LSTM像逐字逐句做笔记的人记得住上下文但笔记本容量有限Transformer像快速翻完整本书找关键词的人全局把握能力强但容易漏掉字里行间的微妙变化。放到时间序列里单用哪一类都会“偏科”。这也是为什么这两年工业界和竞赛里大家开始频繁把两者接在一起——不是某个模型落伍了而是它们解决的问题本身就不重叠。1.2 哪种融合方式更适合时间序列融合方式大致可以分成串行、并行、多尺度三类。串行结构最直观常见做法是LSTM在前、Transformer在后LSTM先把原始序列压缩成携带顺序信息的隐状态序列Transformer再在这个隐状态序列上做全局依赖建模。反过来也可以Transformer先提取全局表示LSTM再按时间顺序解码。串行的优点是结构清晰缺点是前一个网络的输出质量会直接影响后一个网络某个分支一旦成为瓶颈整体效果就被卡住。并行结构是我个人最推荐起步尝试的两个分支同时读同一段序列LSTM分支负责提取局部时序特征Transformer分支负责提取全局依赖特征最后把特征拼接或加权融合后送入预测头。这种设计的优点在于局部信息和全局信息两条线互不干扰融合层可解释性强改造单模型代码的工程量也最小。多尺度融合则更进一步用不同大小的滑动窗口分别采样序列小窗口抓细节大窗口抓周期再分别建模融合。多尺度效果确实好但采样窗口的粒度需要精细调试初期不建议直接上。我的经验是先做并行融合用最小成本验证“融合”这个假设在当前数据集上是否成立。如果并行结构涨点明显再考虑串行或者门控、跨注意力这些更强交互的方案。盲目追求复杂结构很容易在调试阶段就耗尽耐心。2. 模型结构设计与参数选择2.1 并行融合结构的细节拆解假设输入X的形状是(B, L, D)B是批大小L是序列长度D是特征维度。并行融合模型中两个分支共享同一个输入但内部处理逻辑完全不同。LSTM分支我用的是两层LSTM隐藏维度hidden_dim一般取64或128。输入先经过一个线性映射到d_model进入LSTM后取最后一层的最后时间步隐状态作为局部特征代表。为什么取最后时间步因为LSTM的最后一个隐状态在理论上概括了整个序列的信息。实际情况中如果序列特别长最后隐状态会偏向序列末尾所以也可以尝试对所有时间步输出做平均池化或者取最后几个时间步的均值。我的习惯是先在最后时间步上跑一版如果滞后明显再改池化方式。Transformer分支输入同样先线性映射到d_model加上位置编码过2到4层TransformerEncoder然后对所有时间步输出做平均池化作为全局特征代表。平均池化比取最后一步更稳因为注意力输出每个位置都携带了全局交互后的信息平均池化相当于让模型“投票”决定哪些位置重要而不是强制用最后一个位置概括全部。融合部分把LSTM特征(B, hidden_dim)和Transformer特征(B, d_model)拼起来得到(B, hidden_dim d_model)过两层全连接输出未来pred_len个值。两层全连接之间的激活函数我用GELU比ReLU更平滑训练时不容易出现神经元死亡。2.2 串行结构到底怎么串串行结构如果没设计好很容易出现维度对不齐或者信息丢失的问题。先说LSTM在前的方案。LSTM按时间步输出隐状态序列形状是(B, L, hidden_dim)。这里hidden_dim不一定等于Transformer需要的d_model所以通常要在LSTM后面接一个线性层把hidden_dim投影到d_model再加位置编码进入Transformer。注意如果你只取LSTM最后一步的隐状态然后强行塞给Transformer那Transformer等于是拿一个token做自注意力基本没有意义。串行结构中LSTM必须返回完整的时间步输出序列。再说Transformer在前的方案。原始序列加位置编码后过Transformer得到(B, L, d_model)的全局增强序列然后这个序列按时间顺序喂给LSTMLSTM的最后一个隐状态进入预测头。这种方案适合那些全局规律已经很明显、但局部还原能力需要LSTM来补的任务。串行结构还有一个实用技巧如果序列很长不要直接把LSTM输出的完整序列全部喂给Transformer可以先做时间维度的下采样比如每两个时间步合并一次把长度降到原来的二分之一。Transformer的自注意力复杂度是O(L^2)长度翻倍计算量涨四倍降采样能省不少训练时间。当然如果序列长度只有几十就没必要折腾这些。2.3 关键超参数参考表超参数方面我整理了一份常用的参考范围。这不是绝对标准但作为起点基本靠谱。参数推荐范围说明序列长度L目标周期的1到2倍预测日流量至少要包含14天左右的数据来覆盖周周期d_model32到128数据量小取小值数据量大可以取128以上LSTM层数1到3两层性价比最高再多容易过拟合LSTM隐藏维度与d_model同量级64或128比较常见Transformer层数2到4超过4层在小数据集上很容易过拟合注意力头数4或8必须能被d_model整除Dropout0.1到0.3训练样本少时取0.3样本充足取0.1Batch Size32到128时间序列样本量通常足够大不用太小序列长度是个容易被忽略的关键参数。如果数据存在明显的周周期序列长度至少要覆盖一个完整周期覆盖两个周期效果更好但也别太长否则Transformer计算压力大而且额外信息可能只是噪声。d_model和注意力头数要配合起来看d_model128配8个头每个头处理16维信息粒度比较合理d_model64配4个头每个头也是16维。小于16维时注意力表达力会明显下降。2.4 进阶门控融合与跨注意力简单拼接并不总是最优解。如果两个分支提取的特征重要性在不同时间段会变化可以使用门控融合让模型自己学习LSTM特征和Transformer特征各占多少权重。具体做法是把两个特征拼接后过一个线性层加sigmoid得到0到1之间的门控值g输出等于g乘LSTM特征加上(1-g)乘Transformer特征。门控融合相当于给模型装了一个动态开关在局部依赖强的时刻多听LSTM的在周期规律明显的时刻多听Transformer的。更强的融合方式是跨注意力。简单来说把LSTM输出的隐状态序列作为Query把Transformer编码输出作为Key和Value。这样LSTM在逐步解码时可以随时去全局特征里“检索”相关信息。这种结构不再是最后阶段才融合而是两个分支在计算过程中就不断交换信息。代价是计算量上升代码也复杂不少。我的建议是数据量小、任务较简单时用拼接就够了数据量大、序列复杂到拼接融合涨不动的时候再考虑门控和跨注意力。3. 实操从数据预处理到完整训练3.1 数据划分与归一化时间序列的数据处理和普通表格数据不太一样最核心的原则是不能随机打乱必须按时间顺序切分。我直接给一段可用的窗口生成代码import numpy as np from sklearn.preprocessing import StandardScaler def make_samples(data, seq_len, pred_len): xs, ys [], [] for i in range(len(data) - seq_len - pred_len 1): xs.append(data[i:i seq_len]) ys.append(data[i seq_len:i seq_len pred_len]) return np.array(xs), np.array(ys) # 按比例切分原始序列一定是时间顺序 train_data raw[:int(len(raw) * 0.8)] val_data raw[int(len(raw) * 0.8):int(len(raw) * 0.9)] test_data raw[int(len(raw) * 0.9):] # 归一化只用训练集计算均值和标准差 scaler StandardScaler() train_scaled scaler.fit_transform(train_data.reshape(-1, 1)).reshape(-1) val_scaled scaler.transform(val_data.reshape(-1, 1)).reshape(-1) test_scaled scaler.transform(test_data.reshape(-1, 1)).reshape(-1)这里有一个非常常见的错误先对整个原始序列计算均值方差再切分训练集和测试集。这样做的话测试集的统计信息已经在训练阶段暴露过验证指标会虚高线上效果马上打回原形。正确的做法就是上面代码展示的先切分再在训练集上fit然后transform验证集和测试集。如果做K折交叉验证StandardScaler必须在每一折的训练部分重新fit不能复用。窗口重叠也是一个容易忽略的泄漏点。时间序列窗口滑动产生后相邻两个样本只有一步之差内容高度重合。如果你用随机切分把窗口样本分成训练集和验证集那验证集里可能出现和训练集几乎一样的样本验证loss会低得离谱。解决方法是直接按时间顺序切分或者用TimeSeriesSplit这类时序交叉验证工具。如果数据量够在训练集和验证集之间留一段gap会更安全避免短期依赖让模型“作弊”。3.2 混合模型完整实现下面给出一个可以直接跑的PyTorch实现。我没有搞太花哨的变体就是并行融合结构的标准写法。import torch import torch.nn as nn import numpy as np class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len1000): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len).unsqueeze(1).float() div_term torch.exp(torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) pe pe.unsqueeze(0) # (1, max_len, d_model) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, :x.size(1), :] class HybridLSTMTransformer(nn.Module): def __init__(self, input_dim, d_model, nhead, num_transformer_layers, lstm_hidden, num_lstm_layers, pred_len, dropout0.1): super().__init__() self.input_proj nn.Linear(input_dim, d_model) self.positional_encoding PositionalEncoding(d_model, max_len2000) self.lstm nn.LSTM( input_sized_model, hidden_sizelstm_hidden, num_layersnum_lstm_layers, batch_firstTrue, dropout(dropout if num_lstm_layers 1 else 0.0) ) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwardd_model * 4, dropoutdropout, activationgelu, batch_firstTrue, ) self.transformer nn.TransformerEncoder( encoder_layer, num_layersnum_transformer_layers ) self.fusion_head nn.Sequential( nn.Linear(lstm_hidden d_model, d_model), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model, pred_len) ) def forward(self, x): # x: (batch, seq_len, input_dim) proj self.input_proj(x) # (B, L, d_model) # LSTM分支不需要位置编码直接输入 lstm_out, (h_n, _) self.lstm(proj) lstm_feat h_n[-1] # (B, lstm_hidden) # Transformer分支需要位置编码 trans_in self.positional_encoding(proj) trans_out self.transformer(trans_in) # (B, L, d_model) trans_feat trans_out.mean(dim1) # (B, d_model) feat torch.cat([lstm_feat, trans_feat], dim-1) return self.fusion_head(feat)代码里的位置编码用了常见的正弦编码好处是支持变长序列而且不需要训练避免可学习位置编码在小数据上过拟合。batch_firstTrue要求PyTorch 1.9以上如果版本偏旧就把Transformer的输入从(B, L, d_model)转换成(L, B, d_model)去掉batch_first。LSTM分支这里直接使用不带位置编码的投影结果因为LSTM本身按时间顺序处理数据位置先后已经由循环结构天然表达了。3.3 训练流程与收敛技巧训练流程本身不复杂但有几个细节直接影响最终效果。优化器我推荐AdamWweight_decay设1e-5就好。SGD在时间序列任务上往往收敛偏慢没必要给自己添麻烦。学习率建议从1e-4起步配合余弦退火调度。Transformer部分对学习率波动比LSTM敏感得多学习率太大会在一开始就震荡所以梯度裁剪基本要加nn.utils.clip_grad_norm_(model.parameters(), 1.0)就够。损失函数方面MSE最常用但容易被离群点带偏MAE更稳但收敛慢Huber损失介于两者之间对异常值不那么敏感。我更推荐用Huber损失作为默认。如果发现预测曲线滞后明显可以在损失里加入一阶差分项让模型不仅预测数值还要预测变化趋势loss_fn nn.SmoothL1Loss() loss loss_fn(pred, y) 0.5 * loss_fn(pred[:, 1:] - pred[:, :-1], y[:, 1:] - y[:, :-1])多步预测时直接输出pred_len个值比递归预测更省心。递归预测误差会不断累积后面几步基本没法看。直接多步输出虽然不能完全消除误差累积但至少每个时间步都是独立输出的模型更容易学到不同预测步长之间的差异。还有一个非常实用的技巧如果LSTM分支和Transformer分支的收敛节奏不一致可以给两个分支设置不同的学习率。比如Transformer分支用1e-4LSTM分支用1e-3融合头也用1e-3。参数分组在PyTorch里就是传入一个list给optimizeroptimizer torch.optim.AdamW([ {params: model.lstm.parameters(), lr: 1e-3}, {params: model.transformer.parameters(), lr: 1e-4}, {params: model.fusion_head.parameters(), lr: 1e-3}, ], weight_decay1e-5)这样能明显减少“一个分支把另一个分支带偏”的情况。4. 踩坑记录时间序列预测的典型问题4.1 数据泄漏归一化与切分顺序的坑数据泄漏是时间序列预测里最容易犯、也最隐蔽的错误。很多人把归一化放在切分之前整个序列算出均值和方差再切训练测试集。这样做验证时指标非常漂亮模型上线后却立刻崩掉因为你让模型偷看了未来的分布信息。窗口重叠是另一个变体。滑动窗口生成的样本前后只差一步如果你还是按普通机器学习习惯去做train_test_split随机抽样训练集和验证集里会出现大量内容几乎相同的样本。模型在验证集上表现的“泛化”其实是记忆真实使用时按时间推进它就没有这种作弊条件了。所以我的建议是时间序列必须按时间切分而且如果需要做交叉验证要用TimeSeriesSplit每一折都保证训练数据完全在验证数据之前。4.2 预测滞后与过平滑几乎所有时间序列预测模型都会遇到滞后问题。预测曲线看起来像是把真实曲线整体向右平移了一段或者干脆就是一条被压扁的“均值线”。原因在于MSE损失下模型学到的最优策略往往是输出条件均值而时间序列短期相关性极强条件均值自然接近最后几个点的值于是模型就走捷径把“上一个数”搬运过来。解决滞后问题可以从几个方向入手。第一损失函数加差分项前面已经提过。第二直接预测多个步长而不是单步递归。第三输入中加入时间特征比如星期几、小时、是否节假日。这些特征能帮模型定位周期减少对局部惯性的依赖。第四在并行融合模型里可以适当加大Transformer分支的权重因为滞后问题本质上说明模型过度依赖局部信息而Transformer分支输出的全局周期信息没有被充分利用。4.3 收敛慢、梯度不稳定与NaN混合模型训练时最常见的恶性问题就是loss变成NaN。原因一般是学习率过大或者数值不稳定。Transformer的注意力计算里如果q和k的点积过大softmax会进入饱和区梯度容易出现问题位置编码初始化不当也会加剧这个问题。解决办法学习率从1e-4开始不要一上来就1e-3加上梯度裁剪如果还出现NaN把AdamW的eps从默认1e-8调到1e-6。另一个问题是两个分支“内耗”。LSTM分支收敛快Transformer分支收敛慢最后融合头可能主要接收LSTM的信息Transformer分支学了跟没学一样。判断方法很简单训练时把两个分支的梯度范数分别打印出来如果相差超过一个数量级就要分学习率或者先冻结一个分支单独训练另一支。4.4 常见问题速查表这里汇总几个高频问题方便排查。现象可能原因解决思路验证集指标好测试集崩时序泄漏或窗口重叠按时间顺序切分训练验证之间留gap预测曲线比真实滞后MSE偏好均值局部依赖过重加差分损失直接预测多步加入时间特征loss变成NaN学习率过大或数值不稳定降低学习率梯度裁剪调AdamW的eps验证loss降不下去Transformer分支过拟合增大dropout减小d_model减少Transformer层数一个分支主导另一个分支收敛节奏差异大分学习率先冻结一支预训练另一支长序列训练太慢Transformer复杂度O(L^2)时间维降采样截断序列或换稀疏注意力最后单独分享一个我反复验证有效的小技巧训练混合模型时别一上来就端到端全量训练。我的做法是先把LSTM分支和Transformer分支分别作为独立模型训到收敛保存checkpoint然后加载到混合模型里作为初始化只随机初始化融合头再用很小的学习率微调整个模型。这样做的原因是两个分支的收敛速度通常不一致从零开始一起训练很容易变成一方强势、另一方沦为摆设。先单独预训练能保证两个分支都学到了有效表示融合时才是真正的“强强联合”。把这个流程固化之后我在几个时间序列任务上的效果明显更稳定基本没有再出现分支权重失效的问题。如果你正在被混合模型的调试折磨真心建议先试试这个办法。