免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

LSTM与Transformer实战:时间序列预测的PyTorch实现与对比

LSTM与Transformer实战:时间序列预测的PyTorch实现与对比 LSTM 和 Transformer 是时间序列预测学习路线里绕不开的两个模型。LSTM 靠门控机制解决循环神经网络的长期依赖问题从论文提出一直用到现在Transformer 靠自注意力机制代替循环结构在 2017 年提出后迅速覆盖自然语言处理、计算机视觉和时间序列预测。很多入门机器学习的读者都看过这两个名字真正动手时却往往卡在几个地方论文里的一堆公式和 PyTorch 代码对应不上不知道数据集应该怎样构造模型能跑通之后又不知道下一步怎么评估、怎么排查。这篇博客会把这两条线串起来先读懂 LSTM 和 Transformer 的核心思想再用 PyTorch 分别实现两个可运行的时间序列预测模型在同一个数据集上完成训练、预测、反归一化和对比最后给出高频报错的排查路径和生产落地建议。适合的读者是正在学习机器学习、想从理论走向代码复现的开发者。不需要很强的数学基础但建议先掌握 Python、NumPy 和 PyTorch 的基本张量操作如果还没接触过 PyTorch先看一遍官方入门文档再来效率会高很多。1. 先理解 LSTM 和 Transformer 在时间序列建模里的位置1.1 LSTM 为什么能记住长时间的信息LSTM 的完整名字是 Long Short-Term Memory论文发表于 1997 年作者是 Hochreiter 和 Schmidhuber。它要解决的核心问题是普通 RNN 的梯度消失和梯度爆炸。普通 RNN 在处理长序列时信息要沿着时间步一步一步向后传每一步都经历一次矩阵乘法和非线性激活数值会不断被压缩或放大结果是模型很难记住几十步之前的信息训练时梯度也容易消失。LSTM 的解决思路不是换一个更复杂的激活函数而是增加一条“细胞状态”通道。这条通道可以理解为一条高速公路信息可以几乎不经过非线性变换地向前传递。为了让这条通道真正可控LSTM 再引入三个门遗忘门决定细胞状态里哪些旧信息要丢弃。输入门决定当前时间步有哪些新信息要写入细胞状态。输出门决定当前细胞状态有多少要暴露给隐藏状态。这个结构对应到 PyTorch 里就是nn.LSTM。使用时最需要理解的是输入输出维度输入是[batch, seq_len, input_size]输出是[batch, seq_len, hidden_size]的完整序列以及最后一层每个时间步的隐藏状态h_n和细胞状态c_n。很多代码复现卡住就是因为把output、h_n、c_n的用途混在一起。1.2 Transformer 为什么能取代循环结构Transformer 的论文是 2017 年的Attention is All You Need作者是 Vaswani 等人。它的核心判断是序列建模不一定要靠循环结构可以用自注意力机制直接计算序列中任意两个位置的关系。自注意力的公式可以写成Attention(Q, K, V) softmax(QK^T / sqrt(d_k)) V其中 Q 是查询向量K 是键向量V 是值向量。对一个输入窗口来说每个时间步都会和其他所有时间步计算相似度相似度高的位置在输出中贡献更大。好处有两个长距离依赖不会被循环结构逐步衰减理论上任意两个位置都可以直接建立关系。整个序列的注意力计算可以并行做不需要像 RNN 一样按时间步推进。但注意力本身不关心顺序。把[1, 2, 3, 4]和[4, 3, 2, 1]同时输入 Transformer如果不加额外信息模型看到的是同一组互相关系。所以 Transformer 必须引入位置编码把每个时间步的位置信息编码进输入向量。时间序列预测使用 Transformer 时不需要把数值当成 NLP 里的 token 做词嵌入而是用线性层把每个时间步的数值映射到模型维度d_model再加上位置编码然后进入多头注意力层。输出端也不要词表大小而是线性映射到预测目标长度。1.3 选型差异什么时候用 LSTM什么时候用 Transformer不少初学者会问“哪个模型更好”答案取决于数据规模、序列长度和训练资源。下面的表格适合作为选型时的起点。对比维度LSTMTransformer核心结构循环 门控自注意力 前馈网络时间步依赖必须按顺序计算全部时间步并行计算长距离依赖门控能缓解但仍会衰减理论上可建模任意位置关系数据量要求中等数据也能稳定训练数据太少容易过拟合训练资源较低较高显存随序列长度增大超参数敏感度相对较低学习率、维度、注意力头数都敏感常见定位经典 baseline当前先进模型的基础如果只是几千条数据、几十到几百个时间步LSTM 通常更容易跑出稳定结果。如果序列很长、数据量充足并且需要捕捉多个时间点之间的复杂依赖Transformer 的收益会更明显。实际项目里更推荐的做法是先实现 LSTM 作为基线再尝试 Transformer毕竟基线模型能帮助判断复杂模型是否真的带来了提升。2. 环境准备与数据构造别在这一步引入数据泄漏2.1 依赖版本和运行环境本文的代码基于 PyTorch 实现用到的核心依赖如下依赖库建议版本作用Python3.9 及以上运行环境PyTorch2.x模型构建和训练NumPy1.24 及以上数据处理pandas2.x时间序列表格操作scikit-learn1.3 及以上归一化和评估指标matplotlib3.7 及以上可视化如果电脑没有 GPU直接用 CPU 跑完全够用因为演示数据很小。学习阶段不建议为了追求大模型而加大序列长度先把流程跑通更重要。2.2 时间序列预测的任务定义时间序列预测要做的通常是给定过去L个时间点的观测值预测未来H个时间点的值。文章里统一称为“输入窗口长度”和“预测长度”。已知: timestamp_1, timestamp_2, ..., timestamp_L 预测: timestamp_{L1}, timestamp_{L2}, ..., timestamp_{LH}这个任务本身并不是 PyTorch 的nn.LSTM或nn.Transformer直接能接收的格式需要先通过滑窗把原始序列转成监督学习样本。2.3 用合成数据快速验证完整流程为了不依赖外部网络下载也为了保证每个人都有完全一致的复现环境先用合成数据跑通代码。下面的序列由正弦信号、线性趋势和随机噪声组成import numpy as np import pandas as pd import matplotlib.pyplot as plt np.random.seed(42) length 1200 t np.arange(length) value ( 10 * np.sin(2 * np.pi * t / 50) 0.02 * t np.random.normal(0, 0.5, length) ) df pd.DataFrame({time: t, value: value}) df.head()这段数据的好处是生成逻辑清楚、周期和趋势已知适合验证模型是否学到了规律。如果换成真实数据只需要把df[value].values替换成自己的观测序列后面的滑窗、归一化、训练流程都可以复用。2.4 滑窗构造样本与归一化在原始序列上按固定步长滑动窗口def create_sequences(data, input_len48, pred_len12): X, y [], [] for i in range(len(data) - input_len - pred_len 1): X.append(data[i:i input_len]) y.append(data[i input_len:i input_len pred_len]) return np.array(X, dtypenp.float32), np.array(y, dtypenp.float32)这里input_len48表示用过去 48 个时间点pred_len12表示预测未来 12 个时间点。归一化是时间序列预测里最容易出问题的一步。正确的顺序是先把原始序列按时间顺序切成训练段和测试段再对训练段做fit_transform对测试段只做transform。from sklearn.preprocessing import MinMaxScaler train_raw df[value].values[:960] test_raw df[value].values[960:] scaler MinMaxScaler() train_scaled scaler.fit_transform(train_raw.reshape(-1, 1)).reshape(-1) test_scaled scaler.transform(test_raw.reshape(-1, 1)).reshape(-1) X_train, y_train create_sequences(train_scaled) X_test, y_test create_sequences(test_scaled)如果把scaler.fit_transform作用在整个序列上再切分训练阶段就已经看到了测试数据的最大值和最小值测试指标会虚高。这种数据泄漏问题在时间序列里非常隐蔽因为代码不会报错但线上效果会明显不如实验效果。构造好样本后还需要给模型补上最后一个维度X_train X_train[..., np.newaxis] X_test X_test[..., np.newaxis] print(X_train.shape, y_train.shape)输出是类似(865, 48, 1)和(865, 12)的形状。第三维 1 表示每个时间步只有一个特征值。如果做多变量预测可以把这个维度改成特征数量。创建 DataLoader 时时间序列数据一定不要随机打乱import torch from torch.utils.data import TensorDataset, DataLoader train_dataset TensorDataset( torch.from_numpy(X_train), torch.from_numpy(y_train) ) test_dataset TensorDataset( torch.from_numpy(X_test), torch.from_numpy(y_test) ) train_loader DataLoader(train_dataset, batch_size128, shuffleFalse) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse)shuffleFalse是关键。时间序列样本之间存在前后依赖如果随机打乱训练集和测试集的分布都会被破坏模型学到的映射关系也无法迁移到真实场景。3. 用 LSTM 复现时间序列预测模型3.1 LSTM 模型结构输入、隐藏层、输出实现一个最简单的 LSTM 预测模型。输入是[batch, seq_len, input_size]的三维张量经过两层 LSTM 后取最后一个时间步的隐藏状态再通过全连接层输出未来pred_len个预测值。import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, pred_len12): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.fc nn.Linear(hidden_size, pred_len) def forward(self, x): out, _ self.lstm(x) last_hidden out[:, -1, :] return self.fc(last_hidden)这里要注意batch_firstTrue它表示输入张量的第一维是 batch。如果忘记设置默认格式是[seq_len, batch, input_size]稍后传入数据时就会出现维度不匹配。为什么只取最后一个时间步因为 LSTM 是逐步编码信息的最后一个时间步的隐藏状态已经包含了整个输入窗口的信息可以把它看作对整个输入序列的压缩表示再交给全连接层映射到预测窗口。3.2 LSTM 训练循环怎么写训练循环与普通回归任务没有本质区别使用MSELoss和Adamimport torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model LSTMPredictor().to(device) criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr1e-3) epochs 60 loss_history [] for epoch in range(epochs): model.train() total_loss 0.0 for X_batch, y_batch in train_loader: X_batch X_batch.to(device) y_batch y_batch.to(device) optimizer.zero_grad() pred model(X_batch) loss criterion(pred, y_batch) loss.backward() optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) loss_history.append(avg_loss) if (epoch 1) % 10 0: print(fEpoch {epoch 1}/{epochs}, Loss: {avg_loss:.6f})正常运行时训练 Loss 会逐步下降。因为合成数据比较简单Loss 最后通常能降到零点几以下。如果使用其他数据只要能看到稳定下降趋势就说明训练流程是对的。3.3 预测、反归一化与误差计算模型训练完成后在测试集上预测把预测值还原到原始数值尺度。model.eval() pred_list [] true_list [] with torch.no_grad(): for X_batch, y_batch in test_loader: X_batch X_batch.to(device) pred model(X_batch).cpu().numpy() pred_list.append(pred) true_list.append(y_batch.numpy()) pred_norm np.concatenate(pred_list) true_norm np.concatenate(true_list) pred_original scaler.inverse_transform(pred_norm.reshape(-1, 1)).reshape(-1) true_original scaler.inverse_transform(true_norm.reshape(-1, 1)).reshape(-1)注意反归一化时要用训练时保存的scaler不能重新 fit。计算误差时建议在原始尺度上进行from sklearn.metrics import mean_squared_error, mean_absolute_error rmse np.sqrt(mean_squared_error(true_original, pred_original)) mae mean_absolute_error(true_original, pred_original) print(fLSTM RMSE: {rmse:.4f}) print(fLSTM MAE: {mae:.4f})写成这样之后你可以画一条曲线对比真实值和预测值。对合成数据来说模型通常能抓住整体趋势但转折点附近会有滞后。3.4 LSTM 复现时最容易出错的三个点第一忘记处理隐藏状态。nn.LSTM在前向时会自动初始化h_0和c_0所以简单示例里不用手动传入但如果你在多批次训练中手动传递了上一批次的隐藏状态就要记得清零或者调用detach()否则梯度会跨批次传递。第二batch_first不一致。定义模型时用了batch_firstTrue输入数据却还是[seq_len, batch, feature]会直接报维度错误。第三目标值形状与预测值形状不匹配。pred_len是 12那么模型的输出一定是[batch, 12]而y_batch的原始形状是[batch, 12]两者可以直接计算损失。但如果把pred_len和序列长度搞混会出现[batch, 48]与[batch, 12]无法对齐的情况。4. 把 Transformer 从 NLP 迁移到时间序列预测4.1 思想迁移Token 变成数值窗口词表变成线性输出Transformer 本身不是时间序列专属模型用的时候要做几处映射。NLP 里输入是 Token 的索引需要查词嵌入表时间序列里输入是连续数值只需要一个nn.Linear(input_size, d_model)。NLP 里位置编码作用在 Token 顺序上时间序列里可以继续用三角位置编码它对任意长度的连续位置都有效。NLP 里输出维度是词表大小要接 Softmax时间序列里输出是未来多个时间点的数值直接接全连接层目标函数用 MSE。如果只是做“固定窗口预测固定窗口”使用 Transformer 的 Encoder 部分加一个输出头就可以。这个结构最简单、最容易复现也最适合教学。真正要复现论文里的 Encoder-Decoder 完整结构时再多加一个 Decoder 和因果掩码。4.2 位置编码与 Transformer Encoder 实现位置编码使用论文里的三角编码方式class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) 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) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, :x.size(1)]然后定义 Transformer 预测模型class TransformerPredictor(nn.Module): def __init__(self, input_size1, d_model64, nhead4, num_layers3, pred_len12, dropout0.1): super().__init__() self.embed nn.Linear(input_size, d_model) self.pos_encoder PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward128, dropoutdropout, batch_firstTrue ) self.encoder nn.TransformerEncoder( encoder_layer, num_layersnum_layers ) self.fc nn.Linear(d_model, pred_len) def forward(self, x): x self.embed(x) x self.pos_encoder(x) x self.encoder(x) x x[:, -1, :] return self.fc(x)这里nhead4必须能整除d_model64否则注意力头数无法分配维度。这是 Transformer 复现中最常见的维度错误来源之一。训练循环和 LSTM 基本一致只需要把模型类替换为TransformerPredictor。如果训练集很小建议把dropout从 0.1 适当调高防止过拟合。4.3 自回归预测时需要的因果掩码上面 Encoder 版本适合做固定长度窗口映射。如果要做更接近论文结构的自回归生成比如预测一步、拼回输入、再预测下一步就必须给 Decoder 加因果掩码防止模型在预测某个时间点时看到未来的目标值。def generate_square_subsequent_mask(sz): mask torch.triu( torch.ones(sz, sz) * float(-inf), diagonal1 ) return mask掩码矩阵的形状是[sz, sz]矩阵的下三角部分是 0上三角部分是负无穷。注意力分数加上这个掩码后softmax会把未来位置的概率压缩到接近 0。在完整 Encoder-Decoder 结构里Decoder 的tgt_mask必须传入transformer nn.Transformer( d_model64, nhead4, num_encoder_layers3, num_decoder_layers3, dim_feedforward128, batch_firstTrue ) tgt_mask generate_square_subsequent_mask(tgt.size(1)).to(tgt.device) out transformer(src, tgt, tgt_masktgt_mask)这个细节是论文代码和简化代码之间最常见的一道分水岭。有些人直接用nn.Transformer却忘记传tgt_mask结果训练时预测效果很好推理时完全崩溃因为模型在训练阶段看到了未来信息。4.4 Transformer 训练时比 LSTM 更敏感的配置Transformer 在时间序列预测里的超参数调起来比 LSTM 更敏感常见问题集中在三个方面。第一学习率。Transformer对学习率非常敏感lr1e-3在部分数据上会震荡lr5e-4或1e-4更稳定。第二模型维度。d_model太小表达力不足太大在小数据集上容易过拟合。小规模项目建议从 32 或 64 开始。第三训练轮数。Transformer 在小数据集上收敛通常比 LSTM 慢需要多观察验证集指标不要只盯着训练 Loss。如果训练 Loss 下降但验证指标恶化就要考虑降低num_layers或提高dropout。model TransformerPredictor( input_size1, d_model64, nhead4, num_layers3, pred_len12, dropout0.1 ).to(device)然后复用 LSTM 相同的训练循环即可。5. 同一份数据上的对比实验5.1 评估指标应该怎样计算才真实时间序列预测常用的指标是 RMSE 和 MAE。RMSE 对大误差更敏感MAE 更稳定。两者都应在反归一化后的原始数据尺度上计算这样结果才能解释成“预测值和真实值的平均偏差是多少量级”。def evaluate_on_original_scale(model, loader, scaler): model.eval() pred_norm_list [] true_norm_list [] with torch.no_grad(): for X_batch, y_batch in loader: X_batch X_batch.to(device) pred model(X_batch).cpu().numpy() pred_norm_list.append(pred) true_norm_list.append(y_batch.numpy()) pred_norm np.concatenate(pred_norm_list) true_norm np.concatenate(true_norm_list) pred_orig scaler.inverse_transform(pred_norm.reshape(-1, 1)).reshape(-1) true_orig scaler.inverse_transform(true_norm.reshape(-1, 1)).reshape(-1) rmse np.sqrt(mean_squared_error(true_orig, pred_orig)) mae mean_absolute_error(true_orig, pred_orig) return rmse, mae5.2 实验结果可以观察哪些角度在合成数据上两个模型通常都能拟合基本趋势但细节上会有差异。观察时可以从三个角度入手。一是整体误差。对比 RMSE 和 MAE确认 Transformer 在这个数据规模和序列长度下是否真的优于 LSTM。如果两者差异很小说明问题简单不需要复杂模型。二是转折点附近的预测。时间序列预测最常见的现象是滞后也就是真实值已经掉头向下了预测值还沿着原趋势向前多走一步。LSTM 和 Transformer 都可能出现这个问题但对长距离依赖的建模能力不同滞后程度也会有差异。三是训练稳定性。记录每个 epoch 的 Loss看是平稳下降还是剧烈震荡。Transformer 的 Loss 曲线经常更“抖”这不一定是坏事但要判断是否影响了最终收敛。5.3 用一张定性对比表帮助定位问题观察维度通常看到的 LSTM 行为通常看到的 Transformer 行为小数据量更稳容易收敛容易过拟合需要更强正则训练时间总时间较短总时间更长长序列误差会随长度增大优势更明显对学习率敏感度中等高可解释性门结构较直观注意力权重可视化更丰富这些是经验规律不是绝对结论。复现时不要只看一次实验应该固定随机种子、跑多次取平均。每次修改超参数都需要记录否则很难判断效果提升来自模型改动还是运气。6. 常见报错与排查路径6.1 先按这个顺序查输入、维度、归一化、训练配置代码报错时不要先怀疑模型结构按下面的顺序排查效率更高。输入数据形状是否正确。LSTM 要求[batch, seq_len, input_size]batch_firstFalse时顺序不同。模型输出和标签形状是否一致。预测长度和标签长度必须对齐。归一化是否只用了训练集统计量。测试阶段不能重新 fit。DataLoader 是否shuffleFalse。时间序列不能随机打乱。学习率是否过大或过小。观察 Loss 是否震荡或完全不变。数据是否包含NaN或无穷值。缺失值会在反向传播时污染梯度。6.2 六个高频问题与处理方案问题现象常见原因检查方式处理建议报错Expected hidden[0] size (2, batch, 64), got (1, batch, 64)num_layers2但手动传入了单层初始状态打印h0.shape对比num_layers不手动传状态或构造与num_layers匹配的初始状态报错The size of tensor a (48) must match the size of tensor b (12)预测输出长度pred_len与标签长度
返回列表