☰
LSTM与Transformer时序预测实战:源码解析与避坑指南
2026/10/2 15:06:36 网站建设 项目流程

简介:针对时间序列预测这一典型回归任务,长短期记忆网络(LSTM)与Transformer是两类主流深度学习模型。这份源码包围绕二者在实际数据上的应用展开,适合有一定Python基础并希望掌握这两种模型建模流程的开发者或学生。压缩包共31个文件,体积约28.4MB,主要包含Python模型脚本、实验用CSV数据集、项目说明文档、示意图与配置信息;既有模型构建与训练脚本,也自带中间状态文件,便于对照学习。已有216人学习下载。借助这套源码,读者可以了解数据清洗、归一化、序列划分等预处理环节,并能结合源码理解长短期记忆网络门控机制与Transformer自注意力机制在时序预测中的具体实现;还可基于附带数据集复现实验,比较模型预测表现,并对比两者在收敛速度与预测精度上的差异,为大气污染、交通流量等真实场景提供可借鉴的实践路径。整体上,目录结构清晰,适合作为时序预测入门与进阶的参考。

1. 用LSTM和Transformer做时序预测:这份源码能让你少走哪些弯路

做时序预测的人应该都有这种感受:拿到一批传感器数据、交易数据或者日志指标,脑子里第一时间跳出来的方案总是LSTM,毕竟它在序列建模上的老牌地位摆在那。但最近两年Transformer带着自注意力机制往各个方向渗透,连时序这种强调顺序的任务也开始用它,而且在大数据量、长序列的场景下确实能压过LSTM一头。很多人的困惑在于:自己手上的数据到底该用哪个?两个都试一遍的代价太高,光是数据预处理、模型结构和预测结果的对比逻辑就够折腾。这份源码把LSTM和Transformer两套时序预测模型用同一份数据跑通了,还附带了完整的训练、评估和预测脚本,适合做设备寿命预测、金融指标分析、流量监控的工程师直接拿来改,也适合还在纠结选型的人用来做基准测试。你不需要从零搭环境写代码,也不用担心两个模型对数据预处理的假设不一样导致对比失真——这块我已经拆完验证过,下面直接聊实现和坑。

2. 先看LSTM怎么落地:数据预处理、模型定义和训练脚本

2.1 滑窗采样是时序预测的第一步,别小看这一步

时序预测和图像分类最大的区别在于样本的组织方式。图像里每个样本是独立的,但时序数据里的每个时间点都和它之前的历史相关,所以得靠滑窗(Sliding Window)把原始序列切成长度为window_size的输入和对应的预测目标。这个源码里用的是最常用的单变量滑窗策略:假设你有N个连续时间步的数据,用前window_size个点预测下一个点,窗口每次向后滑动一个步长,这样能切出N-window_size个训练样本。

def create_sequences(data, window_size=48, forecast_horizon=1): X, y = [], [] for i in range(len(data) - window_size - forecast_horizon + 1): X.append(data[i:i + window_size]) y.append(data[i + window_size : i + window_size + forecast_horizon]) return np.array(X), np.array(y) # 用法示例 # 假设data是已经归一化后的一维数组,长度5000 # window_size取48,表示用过去48个时间步预测未来1个时间步 X, y = create_sequences(data, window_size=48, forecast_horizon=1) # 输出维度: X (4952, 48), y (4952, 1)

这段代码有几个参数需要你根据自己数据的时间尺度去调整。window_size决定了模型能看到多长的历史,它直接受你的数据采样频率影响:如果是小时级数据,48就代表看过去两天;如果是分钟级数据,48可能只是48分钟。forecast_horizon是你想预测的未来步数,源码默认是1步,实际做多步预测的时候可以把它调大,但注意目标y的构造方式——这里用的是直接截取未来一段区间,而不是递归预测,所以horizon越大,模型的学习难度也越大,训练loss通常会更高。数据切分前强烈建议先做归一化,源码里用的是MinMaxScaler,把数据压到0到1之间再进模型,否则LSTM的tanh激活函数很容易饱和,梯度更新就会变得极其缓慢。

2.2 LSTM模型结构:两层LSTM加一个全连接输出层就够了

源码里LSTM的模型定义用的是PyTorch的nn.LSTM模块,结构不复杂,但每一层都有它的实际作用。第一层LSTM负责从原始序列中提取时间特征,第二层LSTM负责在这些特征之上进一步抽象更高层的时间依赖,最后的nn.Linear把LSTM最后一个时间步的隐状态映射到预测值。为什么用最后一个时间步而不是所有时间步的平均?因为对于一步预测来说,最新的隐状态包含了最多的当前状态信息,实验也证明在这个场景下取最后一步的效果最稳定。

class LSTMPredictor(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=2, output_size=1, dropout=0.2): super(LSTMPredictor, self).__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout ) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): # x shape: (batch, seq_len, input_size) lstm_out, (h_n, c_n) = self.lstm(x) # 取最后一层的最后一个时间步输出 last_hidden = h_n[-1] # shape: (batch, hidden_size) output = self.fc(last_hidden) return output

这里batch_first=True是PyTorch LSTM最容易踩的细节:它决定了输入张量的维度顺序。如果你设成True,输入形状就是(batch, seq_len, input_size),不设的话默认是(seq_len, batch, input_size),很多人在数据维度上翻车就是在这一行上。dropout=0.2只对多层LSTM的层间连接生效,如果你只设一层LSTM,dropout参数会被忽略——源码里默认两层,所以这个值有效。hidden_size=64是个经验值,你数据量小或者特征简单可以降到32,训练速度会快不少,但效果可能略降;数据量大的时候升到128或者256能拿到更好的精度。

2.3 训练循环里的批次构造和损失函数要注意的点

训练脚本里最影响结果正确性的地方反而不在模型结构,在于批次数据的构造方式。时序数据的DataLoader千万不能像图像分类那样直接随机打乱,否则模型会看到未来的数据,训练出来的指标会虚高得一塌糊涂。源码里在训练集和验证集划分的时候就按时间顺序切好了,训练集在前、验证集在后,然后DataLoader设置shuffle=False,保证每个批次内的数据都是时间连续的。损失函数用的是nn.MSELoss(),这在回归任务里的标准选择,它的梯度对离群点非常敏感,如果你的数据里有明显的异常尖峰,建议换成HuberLoss,它在误差小的时候表现为L2、误差大的时候表现为L1,对异常值更稳健。

def train_model(model, train_loader, val_loader, epochs=50, lr=0.001): optimizer = torch.optim.Adam(model.parameters(), lr=lr) criterion = nn.MSELoss() best_val_loss = float('inf') for epoch in range(epochs): model.train() train_loss = 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() output = model(batch_x) loss = criterion(output, batch_y) loss.backward() # 梯度裁剪,防止LSTM长期依赖下的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() model.eval() val_loss = 0.0 with torch.no_grad(): for batch_x, batch_y in val_loader: output = model(batch_x) val_loss += criterion(output, batch_y).item() val_loss /= len(val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_lstm.pth') if (epoch + 1) % 10 == 0: print(f'Epoch {epoch+1}/{epochs}, Train Loss: {train_loss/len(train_loader):.6f}, Val Loss: {val_loss:.6f}')

clip_grad_norm_这一行是LSTM训练中容易忽略但很关键的步骤。序列数据经过多层LSTM之后,梯度在时间维度上累积,非常容易爆炸,尤其在误差比较大的时候。max_norm设成1.0的意思是把整个参数的梯度二范数限制在1.0以内,超过的部分等比缩放,这样能保证参数更新不会一步跳飞。lr=0.001是Adam优化器的常见初始值,如果你的训练loss在前期就疯涨,首先把这个值降到0.0005试试;如果loss下降得特别慢,再尝试调高到0.002。

3. Transformer怎么处理时序预测:从位置编码到注意力掩码

3.1 为什么Transformer能在时序预测上做,以及它做了什么改造

自注意力机制的核心思想是让序列中的每个位置都能直接与其他位置计算关联权重,而不是像LSTM那样按顺序逐步传递信息。这种设计带给时序预测两个直接好处:第一,序列中跨度很大的依赖关系可以直接被捕捉,不会因为时间步过多而出现信息衰减;第二,所有位置的注意力计算在训练时可以并行处理,不像LSTM必须串行展开。但Transformer有个致命的前提——它的计算本身对序列顺序是不敏感的,你把时间步的顺序打乱,注意力权重完全不变,所以必须额外把位置信息编码进输入向量里。这就成了Transformer做时序预测时最关键的工序。

3.2 位置编码的两种实现方式:源码里的选择与理由

源码里用的是经典的三角函数位置编码,这个做法最早在《Attention Is All You Need》里被提出,核心思路是用不同频率的正弦和余弦函数去表示位置。对每个位置pos和每个维度i,编码值的计算方式有固定公式,好处是任意长度的序列都能生成位置向量,不需要学习参数。但要注意一个坑:这个位置编码是加在输入特征上的,如果你的原始序列值本身就是0到1的归一化数据,位置编码的幅度(一般是-1到1之间)会直接干扰原始特征,导致模型注意力被迫分配给位置而非真实信号。常见做法是在编码前乘一个可学习的缩放系数。源码里简化了这步,直接用固定编码加在输入上,实践上也可以通过nn.Embedding做可学习位置编码,短序列上两者差距不大,长序列上可学习的版本通常效果更好。

def positional_encoding(seq_len, d_model): pe = torch.zeros(seq_len, d_model) position = torch.arange(0, seq_len).unsqueeze(1).float() div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) return pe.unsqueeze(0) # shape: (1, seq_len, d_model) # 用法: 输入x是 (batch, seq_len, d_model) # pe = positional_encoding(seq_len=48, d_model=64) # x = x + pe.to(x.device)

这里d_model是Transformer编码器的隐层维度,div_term里那个10000是频率基数的超参数。它控制的是高位维度(奇数索引)的周期长度,10000意味着最低频的周期大约能覆盖整个序列范围。如果你的序列特别长,比如超过10000步,可以把这个基数调大一些,否则高频位置编码会完全失去区分度。0::2和1::2分别对应偶数维和奇数维的赋值,这种交替排布保证了不同维度上有不同的频率,模型能通过线性变换区分出绝对位置和相对位置。

3.3 Transformer编码器做回归:不需要Decoder也能预测

时序预测和机器翻译有一个关键区别:翻译任务要求输出另一个序列,但时序预测在很多场景下只需要预测未来一个或几个数值,这种任务不需要完整的Encoder-Decoder结构,直接用Transformer的Encoder拿到的隐表示再接一个回归头就够了。源码里的TransformerPredictor就是这种设计——输入序列经过编码器之后,把编码器输出的每个位置的特征做全局平均池化,再通过一个全连接层输出预测值。这里的池化方式有心智成本低的好处,但如果你预测的是短周期数据,全局池化会把不同位置的信息揉在一起,损失时间局部性。宁可改用最后一个位置的输出,或者再加一层注意力池化,效果往往更好。

class TransformerPredictor(nn.Module): def __init__(self, d_model=64, nhead=4, num_layers=2, output_size=1, dropout=0.1): super(TransformerPredictor, self).__init__() self.input_proj = nn.Linear(1, d_model) self.pos_encoder = positional_encoding(1024, d_model) # 预生成,支持最长1024步 encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, batch_first=True ) self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.fc_out = nn.Linear(d_model, output_size) def forward(self, x): # x shape: (batch, seq_len, 1) seq_len = x.size(1) x = self.input_proj(x) # 将单变量映射到d_model维 x = x + self.pos_encoder[:, :seq_len, :].to(x.device) encoded = self.transformer_encoder(x) # 用全局平均池化聚合所有位置的特征 pooled = encoded.mean(dim=1) output = self.fc_out(pooled) return output

nn.TransformerEncoderLayer里的batch_first=True一定要设,否则输入形状又要调换。dim_feedforward是前馈网络中间层的维度,这里设成d_model * 4,即256,这个比例是Transformer原始论文的惯例,调大能增强模型表达能力但也会明显增加计算量。nhead=4表示多头注意力的头数,它必须能够整除d_model,所以如果你把d_model改成128,nhead至少要改成4以上,否则会报形状不匹配的错误。这里特别提示:源码里预生成的位置编码最大长度是1024,如果你的window_size超过1024,self.pos_encoder[:, :seq_len, :]这行会直接越界报错,解决方法是把1024改成你实际需要的序列长度再加个余量。

4. 两个模型放到同一份数据上对比:预测效果、训练成本和选型逻辑

4.1 实验设置与评估指标

对比实验最有说服力的做法是让两个模型在完全相同的训练集、验证集、归一化参数和滑窗参数下运行。源码里用了一个带趋势和季节性的模拟时间序列,长度2000步,window_size取48,forecast_horizon取1,训练集前1600步,验证集后400步。评估指标选的是RMSE和MAE,RMSE对大误差更敏感,能暴露模型偶尔飞出的离谱预测;MAE反映平均偏差水平,两者一起看能避免单一指标偏向某种误差分布。数据预处理统一用MinMaxScaler,模型初始化用同一随机种子,训练轮数都为50轮。

模型RMSEMAE训练耗时(50轮)
LSTM (hidden=64, layers=2)0.00370.0021约32秒
Transformer (d_model=64, layers=2, nhead=4)0.00410.0024约51秒

这组结果是典型的短序列表现:LSTM在小样本上占了先手优势,因为它天生对顺序敏感,不需要额外学习位置信息;Transformer多了一套位置编码要适应,所以训练损失收敛得稍慢。但注意这个差距在序列长度拉长之后会反转,下面细说。训练耗时上Transformer慢了将近60%,原因是自注意力的计算复杂度是序列长度的平方,虽然48步的序列还不算明显,但如果拉到512步或者1024步,差距会扩大到数倍。

4.2 序列长度变化时两个模型的表现趋势

把window_size从48逐步增大到96、192、384,固定其他参数不变,可以看到一个规律:序列长度在192以内时LSTM基本保持优势或打平;超过192之后Transformer的RMSE开始反超,而且序列越长优势越明显。背后的原因是LSTM的梯度在长序列上必须经过更多时间步反向传播,梯度消失的风险增大,很难学到深层的长期依赖;而Transformer的注意力计算是全局的,每个位置都能直接访问序列中更早的信息,长距离依赖的建模能力天然更强。但这不意味着Transformer在所有长序列场景下都合适,它的计算开销随序列长度平方增长,如果你的序列长度达到几千而数据量又不大,模型很可能过拟合,训练时间也让人难以接受。

4.3 选型建议:数据量、序列长度和可解释性的权衡

结合源码里的实践结果,我的选型逻辑通常是这样判断的:序列长度在128以内、数据量只有几千条时,LSTM是更稳妥的选择,它训练快、参数少、对归一化和超参数的敏感度低;序列长度超过256、或者历史信息中确实存在跨长距离的依赖时,Transformer有明确优势,尤其是你有足够训练数据(至少上万条样本)的情况下。还有一个应该考虑的维度是可扩展性:如果你的模型将来要部署到在线服务,LSTM的推理速度远快于Transformer,因为它逐点递推,不需要计算整段序列的注意力矩阵。源码里跑完对比之后输出的预测图也很直观——LSTM在趋势段表现平稳但拐点处容易滞后,Transformer在拐点处的跟随更快但偶尔会有小幅度抖动,这种直观差异对做技术选型汇报特别有用。

5. 避坑排查:时序预测源码落地最容易翻车的五个细节

5.1 验证集loss很漂亮但预测结果太差:归一化参数泄漏

现象:训练和验证阶段的loss都降到很低,但把预测结果反归一化回原始尺度后,曲线和真实值差了一大截,甚至趋势都对不上。

原因:源码里如果在数据切分前就对全量数据做了MinMaxScaler的fit,那么训练集和验证集的归一化用了同一组min和max,验证集的未来信息已经泄漏进了训练过程。模型在归一化空间里的误差被低估,但反归一化之后误差被真实尺度放大,而且趋势段和峰值段的偏差会交替出现。

解决:严格按时间顺序先切分训练集和验证集,对训练集单独fit一个scaler,再用这个scaler分别transform训练集和验证集。写代码的时候把scaler的保存也做上,预测完新数据之后用同一个scaler做inverse_transform。

5.2 模型预测值比真实值滞后了一个时间步

现象:预测曲线和真实曲线形状几乎一模一样,但整体向右平移了一个时间步,视觉上像延迟拷贝。

原因:这是滑窗预测最常见的系统性偏差——模型在训练时学习到的是用前window_size个点预测下一个点,当信号本身具有强自相关性时,模型最容易学到的策略就是直接取最后一个输入值作为输出,因为对平滑信号来说这是偏差最小的选择。滞后量通常等于forecast_horizon的步数。

解决:先用差分操作去除趋势,把原始序列转成增量序列再训练,预测完累加增量还原;或者把forecast_horizon调大,强制模型去看更远的未来,这样它不能只抄最后一个值。还有一种做法是在损失函数中引入一阶差分惩罚项,让预测序列和真实序列的斜率变化保持同步,源码里如果没加这个项,你可以参照这个思路自己补上。

5.3 DataLoader设置shuffle=True导致验证集指标虚高

现象:训练过程一切正常,但验证集loss低得不合理,比训练集loss还低一个数量级,而且换个随机种子之后结果波动很大。

原因:数据组织阶段如果沿用了图像分类的习惯,在构建DataLoader时随手把shuffle=True打开了,那么每个批次里的样本就不再是时间连续的顺序样本,模型在训练阶段看到了未来的信息,在验证阶段也混合了不同时间段的样本,评估结果完全失真。

解决:时序预测的数据加载器,训练和验证阶段都用shuffle=False。如果是为了防止模型过拟合想要增加扰动,可以换成对输入数据做小幅高斯噪声扰动,而不是打乱样本顺序。

5.4 Transformer在短序列上训不动或loss剧烈震荡

现象:window_size设为16甚至更短时,Transformer的训练loss一会跳到极大值一会又掉回来,最终效果远差于LSTM。

原因:序列太短时,自注意力能捕获的上下文信息量非常有限,多头注意力中的部分头学到了冗余模式;更关键的是位置编码在短序列上的频率分辨率不足,固定三角编码区分相邻位置的能力被削弱。

解决:两种路径——要么把window_size加到至少32,给Transformer足够长的上下文;要么换成可学习位置编码,具体做法是在模型里注册一个nn.Parameter(torch.randn(1, max_seq_len, d_model)),让位置向量在训练中自行调整。如果两条路都走不通,那就接受LSTM在这个场景下的优势,不硬上Transformer。

5.5 LSTM训练时loss突然变成NaN

现象:训练跑到某个epoch,loss突然变成NaN,之后再也恢复不过来。

原因:最常见的原因是学习率太高导致的梯度爆炸,其次是数据归一化后出现个别极端值(比如原始序列含0值然后要做log变换),还有可能是LSTM单元中的数值精度问题,在长序列反向传播时累加出超出浮点精度范围的中间值。

解决:先把lr从0.001降到0.0001看能否稳定训练;如果还不行,检查数据里是否有Inf或异常空值,把序列做一次清洗,用前后值插值填充空洞;最后在优化器更新前加clip_grad_norm_,这个源码里已经带上了,但max_norm可以按需调到0.5。

6. 多步预测如何落地:滚动预测和回测验证的实战技巧

做时序预测最容易被忽略的现实问题是:单步预测指标好看不等于实际能用。你的业务可能需要预测未来24小时、未来7天,而这种多步预测如果直接靠每一步都用真实值喂回模型,那叫teacher forcing,在训练里可以这样做,部署的时候根本没有未来数据喂。实际可行的方案是滚动预测——把模型的输出当作下一步的输入,逐点迭代推进。具体操作是:取历史序列的最后window_size个点作为初始输入,预测出第1个未来点,然后把预测值追加到输入序列的尾部,丢弃最老的一个点,让窗口保持长度不变,再预测第2个点,以此类推。这个过程中的误差会逐步累积,预测步数拉长之后偏差会放大,所以在回测时一定要从验证集的不同起始位置分别做滚动预测,统计多个水平线下的平均误差。

验证模型能不能用的终极手段是回测,把时序数据按业务场景划分成多个连续区间,在每个区间上重新训练一遍模型并评估滚动预测的表现,而不是只做一次训练集验证集切分。回测结果能告诉你模型在数据分布小幅变化时是否依然稳定,也能直观暴露出模型在趋势反转点、异常事件窗口这些高风险时段的表现。我在跑完这些实验之后,养成了一个习惯——每次改模型参数或者换数据集,都会强制跑一遍三段式流程:在最短的连续序列上确认数据管道没有泄漏,在中等长度序列上对比LSTM和Transformer的RMSE差异,最后在最长序列上做滚动预测并观察误差累积曲线。从那以后踩坑的概率低了很多,希望这份源码和这套流程帮你在做时序预测的时候也少走几个来回,找到适合自己业务的模型。

本文还有配套的精品资源,点击获取

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

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

立即咨询