☰
Transformer+LSTM混合架构:多变量时间序列预测的稳定实战方案
2026/10/21 2:54:30 网站建设 项目流程

半年前我接了一个多变量时间序列预测的任务,数据量不算大,但周期性叠加得很明显。我先是拿LSTM跑了一版,效果中规中矩,多步预测的误差累计得厉害;换成纯Transformer,结果在小数据集上反复过拟合,超参稍微动一下就崩。后来我把两个模型串起来,做了一个Transformer+LSTM的混合结构,才算是找到一个既稳又能打的配置。这篇文章就把这套方案的完整代码、设计理由和调试过程全部摊开讲清楚,适合正在做时间序列预测、想尝试混合架构、或者被单一模型瓶颈卡住的开发者。

1. 为什么要在时间序列预测里把LSTM和Transformer拼在一起

1.1 单一LSTM的瓶颈在哪里

LSTM对时间序列处理的核心优势是循环结构:它按时间步逐个读取输入,通过门控机制决定记住什么、遗忘什么。这种顺序处理方式天然契合时序数据的因果性——预测当前时刻时,模型"看到"的信息严格来自过去,不会看到未来的任何内容。这一点在金融、工业监控这类对因果关系敏感的场景里特别重要。

但LSTM的短板也很明显。首先是长期依赖问题,虽然门控机制比原始RNN改善了很多,但在序列长度达到几百甚至上千时,信息在逐时间步传递中仍然会衰减。其次是它天生不擅长并行计算:必须等前一个时间步的计算完成才能算下一个时间步,训练速度明显受限。更重要的是,LSTM对多个变量之间的全局交互关系建模偏弱,它更擅长捕捉邻近时间步之间的局部依赖。

我实际遇到过这种情况:用LSTM预测一段有明显的日周期和周周期叠加的负荷曲线,短期预测还不错,一旦预测窗口拉长到24步以上,模型就开始把最近的趋势机械外推,周期性细节失真严重。这就是典型的长期依赖丢失加全局模式建模不足。

1.2 Transformer在序列预测上的短板

Transformer用自注意力机制直接建模序列中任意两个位置之间的关系,一步到位地克服了LSTM的长期依赖问题。每个位置的表示都通过加权汇总整个序列的信息得到,理论上不管距离多远,模型都能捕捉到依赖关系。配合位置编码,它还能保留时间顺序信息。这是它在自然语言处理和各大模型架构中成为主流的原因,也是很多时间序列任务开始借鉴Transformer encoder的原因。

但Transformer在时间序列预测上不是银弹。第一个问题是位置编码是"加"进去的,不是模型内生学到的,如果序列的周期模式很复杂,单纯的正弦位置编码不一定能完整表达时间信息。第二个问题是我在实际项目中感受最深的:自注意力对数据量和噪声非常敏感,小样本场景下模型极容易过拟合,注意力矩阵退化成对训练集的死记硬背。第三个问题是Transformer对归一化和学习率很挑剔,参数稍微调不好,训练就会剧烈震荡。

我记得试过用纯Transformer跑一个只有几千条样本的传感器数据集,验证集loss完全下不去,换了两组学习率和dropout配置都没有明显好转,最后只能放弃这个方案。问题不在模型本身,而是小数据和全局注意力之间天然存在"容量过剩"的矛盾。

1.3 混合架构的互补逻辑

既然LSTM擅长局部时序和因果建模,Transformer擅长全局依赖和并行解耦,把它们组合起来就是很自然的选择。我采用的方案是一个串行结构:原始输入先经过LSTM层,提取出按时间顺序排列的深层特征序列,再把特征序列送入Transformer Encoder,让自注意力机制在LSTM输出的高维特征上建立全局关联,最后通过输出头得到预测结果。

这个顺序设计是我对比了几种方案后确定的。如果把顺序反过来,即先进Transformer再进LSTM,自注意力输出的特征被LSTM逐时间步滚动处理,虽然理论上可行,但训练稳定性明显差一些。原因在于自注意力输出已经是"全局感知"的表示,再用LSTM的顺序门控去重新压缩,会丢失一部分全局信息,而且梯度回传链路更长。

而"LSTM先压缩局部、Transformer再提炼全局"的设计更像一个流水线:LSTM像预处理工序,把原始数据转成信息密度更高的特征序列;Transformer像后道精加工,在特征层面捕捉全局关系。这样每个模块都只负责自己最擅长的事,整体训练更稳定,参数量也控制得住。后面我会给出完整的实现代码,你可以直接参考这个结构。

2. 项目设计与数据准备:滑动窗口、归一化和防泄露

2.1 项目整体结构设计

动手写代码之前,我先明确了整个项目的模块划分,避免模型代码和数据处理耦合在一起。通常我会分成下面几个文件或代码区块:

  • 数据加载与预处理模块,负责读取原始表格数据、填充缺失值、归一化
  • 数据集构建模块,负责把连续序列切成滑动窗口样本
  • 模型定义模块,包含LSTM层、位置编码、Transformer Encoder、输出头
  • 训练与验证模块,包含训练循环、早停、模型保存
  • 评估模块,计算RMSE、MAE、MAPE等指标并做可视化

这种模块化设计的好处是:换数据集时只改数据加载部分,模型和训练逻辑可以原样复用;模型架构调整时也只改模型定义文件,数据流程不用动。我在项目里经常需要试多组对比实验,这种结构能省很多时间。

整个项目的运行环境建议Python 3.8以上,PyTorch 1.10以上,CPU可以跑通小规模数据,但序列长度和数据量上来之后强烈建议用GPU训练。

2.2 滑动窗口样本构建与归一化

时间序列预测的第一步是把原始的连续时间序列转成监督学习所需的样本形式。核心思路是滑动窗口:用过去seq_len个时间步的特征作为输入,预测未来pred_len个时间步的数值。这个窗口构造直接决定了模型能"看到"多长的历史信息,是一个非常重要的超参数。

窗口长度选多少不能拍脑袋。通常要考虑数据本身的周期长度。如果数据有明显的小时级或日级周期,seq_len至少要覆盖一个完整的周期长度,甚至两到三个周期,否则模型根本没有足够的历史信息去推断周期规律。但窗口越长,训练样本数量越少,显存占用也越大,需要权衡。

构建滑动窗口的代码很简单,但有几个细节值得注意。原始数据如果是多变量,输入矩阵形状是[总时间步数, 特征维度],切完窗口后X的形状是[样本数, seq_len, 特征维度],预测目标Y的形状是[样本数, pred_len, 特征维度]。

import numpy as np def create_sequences(data, seq_len, pred_len): """ 把连续的多变量时间序列切成滑动窗口样本。 data: [N, feature_dim] 返回: X [样本数, seq_len, feature_dim], Y [样本数, pred_len, feature_dim] """ X, Y = [], [] total = len(data) for i in range(total - seq_len - pred_len + 1): X.append(data[i:i + seq_len]) Y.append(data[i + seq_len:i + seq_len + pred_len]) return np.array(X), np.array(Y)

归一化这一步我再强调都不为过。时间序列预测里最常用的归一化方式是MinMaxScaler,把数据压缩到0到1之间。这样做的原因是LSTM和Transformer里的激活函数、注意力权重计算都对输入尺度很敏感,未经归一化的原始数据会让损失函数地貌变得非常崎岖,训练很难收敛。

2.3 数据集划分与防泄露

数据集划分是时间序列预测里最容易踩坑的地方,核心原则是:不能随机打乱后划分,必须按时间顺序划分。因为时间序列样本之间存在高度相关性,随机打乱等于把未来信息泄漏到了训练集中,模型学到的规律会被严重高估。

我习惯的划分比例是训练集70%、验证集15%、测试集15%,严格按时间顺序切。比如总共有10000个时间步,前7000个作为训练数据来生成滑动窗口样本,接下来1500个作为验证,最后1500个作为测试。

这里有一个容易被忽视的细节:归一化的scaler必须只用训练集的数据来拟合,然后用这个已经拟合好的scaler去转换验证集和测试集。很多新手会把整个数据集一起fit,然后在划分,这样测试集的信息已经通过scaler泄漏到训练过程中了,得到的评估指标会偏乐观。

from sklearn.preprocessing import MinMaxScaler # 假设 raw_data 是 [N, feature_dim],已经按时间顺序排列 train_size = int(len(raw_data) * 0.7) val_size = int(len(raw_data) * 0.15) train_raw = raw_data[:train_size] val_raw = raw_data[train_size:train_size + val_size] test_raw = raw_data[train_size + val_size:] # 只用训练集拟合scaler,再转换全部数据 scaler = MinMaxScaler() scaler.fit(train_raw) train_scaled = scaler.transform(train_raw) val_scaled = scaler.transform(val_raw) test_scaled = scaler.transform(test_raw) seq_len, pred_len = 60, 1 X_train, Y_train = create_sequences(train_scaled, seq_len, pred_len) X_val, Y_val = create_sequences(val_scaled, seq_len, pred_len) X_test, Y_test = create_sequences(test_scaled, seq_len, pred_len)

注意验证集和测试集生成的窗口样本,X窗口内部严格属于各自区间,不会跨区间取数据。这样设置的意义在于:模型在验证集上的表现能够真实反映它在未见数据上的泛化能力,而不是因为借用了训练集或未来的信息而显得虚高。

3. 模型核心代码拆解:LSTM做特征、Transformer做全局建模

3.1 模型结构总览与参数说明

我构建的混合模型可以拆成三个主要部分。第一部分是LSTM层,接收原始滑动窗口数据,输出按时间步排列的隐藏状态序列。第二部分是Transformer Encoder,对LSTM输出的特征序列做全局注意力建模。第三部分是输出头,把Transformer编码后的特征映射成最终的预测值。

整个模型的可配置参数比较多,我先把核心参数列出来:

参数取值说明
feature_dim数据实际特征维度输入数据的变量个数
seq_len60历史窗口长度
pred_len1 或 多步预测未来步数
lstm_hidden128LSTM隐藏状态维度
lstm_layers2LSTM层数
d_model128Transformer编码维度
nhead8多头注意力头数
num_encoder_layers2Transformer Encoder层数
dropout0.1全局dropout比例

这个参数配置适合中等规模的数据集,我在实际项目里就是基于这一组参数起调的。d_model和lstm_hidden保持一致,这样LSTM输出到Transformer输入转换时维度平滑过渡,不需要大量线性投影。

3.2 LSTM特征提取层实现

LSTM层使用PyTorch内置的nn.LSTM实现,设置batch_first=True,这样输入输出的形状一目了然。输入[batch_size, seq_len, feature_dim],输出也是[batch_size, seq_len, lstm_hidden]。

这里有个细节值得说明一下:LSTM的两个层之间,PyTorch会自动在层间施加dropout,这正好利用了PyTorchnn.LSTM的dropout参数。设置num_layers=2时,第一层的输出作为第二层的输入,最后一层输出的隐藏状态序列就是我们送入Transformer的特征。这种堆叠LSTM的方式能提取更抽象的时间特征,比单层LSTM表达能力强不少。

我自己测试过,单层LSTM在复杂周期数据上拟合能力明显不够,输出曲线比较"钝",两层是一个性价比比较高的选择,三层以上提升有限且训练变慢。

3.3 Transformer Encoder与位置编码实现

Transformer Encoder使用的也是PyTorch内置的nn.TransformerEncoderLayer和nn.TransformerEncoder。这里有一个关键的前提:Transformer的自注意力机制本身是不关心顺序的,因此必须给输入序列加上位置编码,否则序列被任意打乱,模型看到的特征集合完全相同,预测结果也不会变化,时序信息就完全丢失了。

我用的位置编码是Transformer经典的正弦位置编码,它比可学习的位置编码泛化能力更好,对超出训练序列长度的位置也能提供合理的编码。关键代码实现如下:

import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, dropout=0.1, max_len=5000): super().__init__() self.dropout = nn.Dropout(p=dropout) pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.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): x = x + self.pe[:, :x.size(1)] return self.dropout(x)

需要特别注意:LSTM的输出虽然是按时间顺序产生的,但进入Transformer后,自注意力机制依然需要位置编码来区分不同时间步。这一点很多人会忽略,以为LSTM的顺序输出到了Transformer里天然带顺序信息——实际上注意力计算只认特征,不认顺序,不加位置编码,模型无法区分"第10步"和"第50步"。

我采用的融合方式是先对LSTM输出做一层线性投影,把lstm_hidden维度的特征映射到d_model维度,然后加位置编码,再送入Transformer Encoder。线性投影的作用是让特征维度对齐,同时给模型一个特征变换的余地。

3.4 输出头设计与完整模型

在Transformer Encoder输出后,我取最后一个时间步的隐藏表示作为整个序列的综合特征,再通过一个两层全连接网络输出预测值。取最后一个时间步的优势是:经过自注意力建模后,最后位置的表示已经包含了整个序列的全局信息,用它来做预测既简洁又有效。

完整模型代码如下:

import torch.nn as nn class LSTMTransformerForecaster(nn.Module): def __init__(self, feature_dim, seq_len, pred_len, lstm_hidden=128, lstm_layers=2, d_model=128, nhead=8, num_encoder_layers=2, dropout=0.1): super().__init__() self.feature_dim = feature_dim self.pred_len = pred_len # 1. LSTM特征提取 self.lstm = nn.LSTM( input_size=feature_dim, hidden_size=lstm_hidden, num_layers=lstm_layers, batch_first=True, dropout=dropout if lstm_layers > 1 else 0.0, bidirectional=False ) # 2. 线性投影 + 位置编码 + Transformer Encoder self.input_proj = nn.Linear(lstm_hidden, d_model) self.pos_encoder = PositionalEncoding(d_model, dropout) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=4 * d_model, dropout=dropout, batch_first=True, activation='relu' ) self.transformer_encoder = nn.TransformerEncoder( encoder_layer, num_layers=num_encoder_layers ) # 3. 输出头 self.fc_out = nn.Sequential( nn.Linear(d_model, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, pred_len * feature_dim) ) def forward(self, x): # x: [batch, seq_len, feature_dim] lstm_out, _ = self.lstm(x) # [batch, seq_len, lstm_hidden] proj = self.input_proj(lstm_out) # [batch, seq_len, d_model] proj = self.pos_encoder(proj) enc_out = self.transformer_encoder(proj) # [batch, seq_len, d_model] last_feat = enc_out[:, -1, :] # [batch, d_model] out = self.fc_out(last_feat) # [batch, pred_len * feature_dim] out = out.view(-1, self.pred_len, self.feature_dim) return out

模型各层的输出形状变化我整理了一下,方便理解数据流:

层输入形状输出形状
LSTM[batch, seq_len, feature_dim][batch, seq_len, lstm_hidden]
线性投影[batch, seq_len, lstm_hidden][batch, seq_len, d_model]
位置编码[batch, seq_len, d_model][batch, seq_len, d_model]
Transformer Encoder[batch, seq_len, d_model][batch, seq_len, d_model]
取最后时间步[batch, seq_len, d_model][batch, d_model]
输出头[batch, d_model][batch, pred_len, feature_dim]

在forward里取的是enc_out[:, -1, :],也就是编码后序列的最后一个位置。实际使用中如果你发现最后位置信息不够稳,也可以改成对所有时间步做平均池化,或者再加一个带权重的attention池化,效果因数据而异,可以都试一下再定。

4. 训练调参实录:学习率、梯度裁剪、早停和一组可复现的参数

4.1 损失函数、优化器与学习率策略

损失函数我用的是均方误差(MSE),因为预测目标是连续数值,MSE对较大误差的惩罚更重,能让模型优先把偏差大的点压下来。如果你的预测目标是高波动数据,也可以考虑Huber Loss,它对异常点的敏感度更低,训练会更稳。我在这个项目里用MSE效果不错,就维持了默认选择。

优化器选AdamW,这是Adam的改进版本,把权值衰减和梯度更新过程解耦,在Transformer类模型上表现更稳定。在时间序列预测任务里,AdamW配合warmup策略是一个很可靠的组合。学习率初始值我建议从1e-3起手,如果loss震荡明显,马上降到5e-4或3e-4。Transformer对学习率的敏感度比纯LSTM要高,这是我在多次实验中印象最深的一点。

梯度裁剪是必须加的环节。LSTM加Transformer的混合模型在训练初期容易出现梯度爆炸,表现为loss突然跳到极大值然后再也不回来。梯度裁剪的作用是把梯度的范数限制在一个范围内,防止更新步长过大。我把max_norm设为1.0,跑下来很稳。

训练循环代码:

import torch import torch.nn as nn def train_epoch(model, dataloader, criterion, optimizer, device): model.train() total_loss = 0.0 total_count = 0 for x_batch, y_batch in dataloader: x_batch = x_batch.to(device).float() y_batch = y_batch.to(device).float() optimizer.zero_grad() pred = model(x_batch) loss = criterion(pred, y_batch) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() batch_size = x_batch.size(0) total_loss += loss.item() * batch_size total_count += batch_size return total_loss / total_count

4.2 训练循环与早停机制

完整训练时我用验证集loss来监控模型状态,并执行早停。早停的机制很简单:当验证集loss连续若干个epoch没有刷新最低值,就停止训练,并恢复保存过的最佳模型权重。这个机制既能防止过拟合,又能避免训练时间白白浪费。

我给早停位置设置的耐心值是10个epoch。也就是说,如果连续10个epoch验证loss都没有下降,就认为模型已经收敛到当前配置下的最佳状态。在模型训练过程中,每次验证集loss刷新新低就保存一次模型权重,这样即使后面训练跑偏了,我们手里的模型依然是验证集表现最好的版本。

def train_model(model, train_loader, val_loader, criterion, optimizer, epochs, device, patience=10): best_val_loss = float('inf') bad_epochs = 0 best_state = None for epoch in range(epochs): train_loss = train_epoch(model, train_loader, criterion, optimizer, device) val_loss = validate(model, val_loader, criterion, device) if val_loss < best_val_loss: best_val_loss = val_loss best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print(f"早停: epoch {epoch + 1}") break model.load_state_dict(best_state) return model, best_val_loss

4.3 参数如何调:一份实际调参记录

写到这里,我把这个模型在一组公开的负荷类数据集上的调参过程列出来,方便你对照着自己实验。这里的数据集名称就不方便透露了,但数据和典型电力负荷场景高度相似,有周期性,有波动,也有少量噪音。

第一组实验的初始参数是learning_rate=1e-3,dropout=0.2,batch_size=32。跑了20个epoch训练loss下降正常,但验证loss在第8个epoch之后就停滞了,最终验证RMSE在0.13左右。第二组实验把学习率降到5e-4,dropout降到0.1,验证RMSE降到了0.11。第三组实验把batch_size提高到64,同时给Transformer Encoder层数加到3层,验证RMSE进一步降到0.098。

但要注意,同一组参数换一个数据集不一定还是最优的。我的建议是:先固定一个baseline配置跑通,然后单独调一个变量观察效果,不要同时改多个参数,否则很难判断每个参数到底起了什么作用。

5. 我踩过的三个坑:归一化泄漏、训练震荡和小数据过拟合

5.1 归一化泄漏导致训练翻滚

第一个坑出现在数据预处理阶段。一开始我图省事,把全部数据丢给MinMaxScaler拟合后再划分训练集和测试集。当时没觉得有什么问题,训练loss下降得很漂亮,验证loss看起来也不错,但到了测试集上做评估,指标明显比预期差,而且测试曲线前半段预测还不错,越往后越走偏。

后来排查发现,问题就出在归一化上。用全部数据fit出来的scaler包含了测试集的最大最小值,这相当于模型在训练阶段已经"偷看"了未来数据的分布范围。反映在结果上就是验证指标虚高、测试表现落差大。修复方法前面已经写了:先划分好训练集验证集测试集,然后用训练集的统计量去fit scaler,再应用到其他集合。这个改动让测试集和验证集的指标变得一致了。

5.2 训练震荡与NaN排查链路

第二个坑是训练到一半loss突然变成NaN。这个现象在混合模型里比纯LSTM更常见,原因来自于多个方面。我当时按下面这个链路一步步排查,分享出来供参考:

第一步,先检查数据里有没有缺失值或无穷值。用np.isinf和np.isnan扫一遍输入数据,确认干净。第二步,检查归一化后的数据范围,MinMaxScaler后有没有出现超出0到1范围的值。第三步,检查学习率,把学习率从1e-3降到3e-4再试,如果NaN出现的时间点明显推迟或者不再出现,说明是学习率过大导致梯度爆炸。第四步,加上梯度裁剪,作为兜底保护。第五步,检查位置编码和注意力层的数值变化,在forward中间打印几层输出的均值和方差,看是不是在某一层数值爆掉。

我最后的结论是学习率加梯度裁剪的组合解决了问题。现在我的习惯是,任何Transformer相关模型的训练一开始就直接加上梯度裁剪,而不是等出了NaN再去排查。

5.3 过拟合与评估指标陷阱

第三个坑是小数据上的过拟合。我最早用纯Transformer跑小数据集时,训练集loss能降到非常低,验证集却很高。混合模型在这个问题上好了很多,但依然有同样的风险。

我处理过拟合的方法有三个层面。第一个是dropout,LSTM层和Transformer层都适度开了dropout,从0.2调到0.3对泛化有明显帮助。第二个是早停,前面已经实现了,核心逻辑就是让模型在验证集最优时停下来。第三个是减小模型容量,如果隐藏状态维度从128降到64之后验证集loss反而下降,说明模型过于复杂,数据量支撑不起。

评估指标也不能只看RMSE。RMSE对大误差敏感,很多时候RMSE降下来了,但预测曲线仍然有明显的相位偏移或波动滞后。我会额外看MAE和MAPE,特别是MAPE,它体现的是相对误差,对于数值跨度大的序列更有参考价值。预测曲线和真实曲线的时间对齐也很重要,有时候模型已经退化成了"把上一时刻的值复制到下一时刻",这种模型的RMSE不会太差,但完全没有任何预测能力,只看RMSE根本发现不了这种问题。

6. 最后再分享一个我的扩展经验

这个混合模型结构我现在已经用成了模板,换数据集的时候只需要改feature_dim和窗口参数。一个很有用的扩展是预测多步结果作为滚动预测的输入,也就是把模型自己的输出拼进历史窗口,再预测下下一步。这样做要特别注意误差累积问题,我通常会限制滚动步数,到5步以上就重新用真实数据校正一次窗口。

另一个扩展方向是给Transformer Encoder的输出加一层简单的注意力池化,而不是只用最后一个时间步。我在几个数据集上对比过,加池化的版本在长周期数据上稳定一些,尤其当最后一个时间步恰好落在异常波动点时,只取最后一步容易损失全局信息。

在实际部署时,我建议把训练好的模型和scaler一起保存,推理前先做同样的归一化,预测后做反归一化还原真实数值。这一步看似简单,但经常有同事直接输出归一化结果,导致预测值对不上业务口径。

这套Transformer+LSTM混合方案算不上什么花哨的新架构,但它解决了我实际项目中最头疼的稳定性和精度问题,核心代码结构清晰,跑通之后可以按需扩展。如果你也在时间序列预测的路上摸爬滚打,希望这篇实战记录能帮你少走几步弯路。

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

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

立即咨询