公司动态

用Pytorch实现Transformer时间序列预测:从单步到多步的完整指南

📅 2026/8/31 5:54:46
用Pytorch实现Transformer时间序列预测:从单步到多步的完整指南
简介本资源是一套基于PyTorch实现的Transformer时间序列预测完整实验方案面向机器学习初学者与时间序列建模实践者聚焦单步与多步预测两大核心任务适用于气象、能源、金融等领域的时序数据分析场景。压缩包共10个文件3.21MB包含2个核心训练脚本transformer-singlestep.py与transformer-multistep.py、1个真实温度数据集daily-min-temperatures.csv、1张预测效果可视化图transformer-future200.png、1个动态训练过程演示gifresult.gif、1份环境依赖说明requirements.txt及README文档等结构清晰、开箱即用。已有2909人学习下载所有代码均经实测可直接运行附带每日最低温度数据上的100轮训练结果与多步预测对比分析便于读者理解模型输入构造、位置编码设计、掩码机制应用及输出解码逻辑是深入掌握Transformer在时序领域落地的优质实践素材。 拿到一个时间序列预测需求时很多人第一反应是先套LSTM。我一开始也是这么干的但项目数据拉长到几百个时间步后LSTM训练慢不说往后预测的曲线像一个被压扁的弹簧越来越“平”。朋友建议试试Transformer我带着将信将疑的态度用Pytorch从零搭了一套单步和多步预测实验。没想到的是模型把长距离依赖抓得很稳预测结果比LSTM明显更贴合趋势。这篇文章就是这次实验的记录从为什么选Transformer、单步/多步怎么设计到Pytorch完整实现和踩坑实录都会拆开讲。适合已经会上手Pytorch、但还没用Transformer处理过时间序列的同学也适合做量化、销量预测、设备监测等场景的工程师参考。我会尽量把每个关键选择背后的原因写清楚而不是只丢代码。1. 整体设计与思路拆解1.1 为什么用Transformer而不是LSTM在时间序列上做预测本质上是在建模“过去的时间点如何影响未来”。LSTM和GRU的思路是递归地、逐步地把信息往后传这种过程的优点是参数少、可解释性强但缺点也很明显训练是串行的序列一长梯度消失和长期依赖丢失的问题就会显现。Transformer的设计是直接计算序列中任意两个位置之间的关联度通过多头注意力机制一次性看到全局信息。放在时间序列里这就意味着模型可以自己学会“第1个时间点对第50个时间点很重要”而不是像RNN那样靠门控一路“背”过去。不过话说回来Transformer也不是没有代价。它不像LSTM自带顺序概念必须靠位置编码把顺序信息硬塞进输入而且注意力矩阵的空间复杂度是O(n^2)窗口特别长的时候会吃显存。这决定了我们在实验里不能无脑堆窗口需要在数据长度和模型容量之间做取舍。但总体上对于中等长度几十到几百个时间点的时间序列Transformer的精度和训练效率都很有竞争力。我在做实验对比时同一个数据切了两份一份用两层LSTM一份用两层Transformer encoder。LSTM训练了接近200个epoch才收敛Transformer在100个epoch左右就已经更好了尤其在后半段的预测曲线上Transformer更贴近原始趋势。这其实不难理解时间序列里“之前一段时间的形态”往往比“最后一个点的值”更重要而注意力机制天生擅长捕捉这种形态。1.2 单步预测和多步预测的差异单步预测是经典的监督学习任务输入过去一段序列输出下一个时间点的值。多步预测的难度要高一个档次因为输出的是一个连续的未来区间不只是“下一个点”的精确值还要考虑未来多个点之间的叠加误差。我在实验中分别实现了两种多步方案这里先做一个直观对比指标直接多步递归多步实现复杂度输出层维度设为horizon简单需要把预测值拼回输入循环推理推理速度快一次forward得到K个点慢要连续forward K次误差累积几乎没有因为K个点同时输出明显越往后越偏训练难度需要同时拟合K个标签数据量小时难度大训练时仍是单步难度低适用场景horizon较大、训练数据充足小规模快速实验、模型迭代期直接多步Direct Multi-stepTransformer输出层的维度直接设为horizon长度让模型一次生成未来K个点。缺点是模型要同时拟合K个输出训练难度高但推理快。递归多步Recursive Multi-step先用单步模型预测出下一个点再把这个预测值当输入滑进窗口继续预测下下个点。实现简单但误差会随着步数累积越往后越偏。还有一种Teacher Forcing的训练方式只在训练时用真实值替代预测值喂养模型推理时再用预测值这个后面在训练循环里会讲。这两个方案我都跑了也记录了各自的loss曲线和误差指标。直接多步在horizon比较大时会比递归更稳但前提是训练数据要充足递归多步在小样本上更灵活适合快速迭代。建议你在自己的数据上都试试对比后再选。2. 核心细节解析与实操要点2.1 位置编码不能省但不要照搬NLPTransformer本身没有顺序感。把序列顺序打乱注意力算出来的结果是一模一样的。所以位置编码是Transformer做时序预测绕不开的基础。NLP里常见的是正弦位置编码把位置索引用sin/cos函数映射到embedding维度上。在时间序列里这个方式一样能用但我测试下来直接把时间戳比如间隔、周几、小时作为额外特征拼进输入效果往往更好尤其是数据有明显周期性的场景。实用的做法是“加”而不是“拼”把正弦位置编码加到输入embedding上然后额外把标准化后的时间特征拼在输入特征维度后面。这样模型既能感知绝对位置也能感知周期性信息。代码实现时位置编码的维度要和d_model一致否则维度对不上没法加。为什么要乘sqrt(d_model)这是Transformer原论文里的一个细节。输入经过embedding后数值范围通常会比较小直接往上面加位置编码位置信息很容易盖过原始特征。乘上sqrt(d_model)相当于把输入embedding放大到和位置编码一个量级让两者在相加时都能保留有效信息。我在实验里试过不乘这个系数训练loss收敛明显变慢所以这个细节不建议省。2.2 数据预处理决定模型上限先讲一个我踩过的坑一开始我直接用整个序列的均值和方差做归一化训练loss很漂亮但测试特别差。后来才意识到这是把未来信息透漏给了训练过程也就是典型的数据泄漏。正确的做法是只在训练集上fit scaler再用同一套scaler去transform验证集和测试集。滑动窗口的构造也值得细品。单步预测时每条样本是[look_back, feature_dim]的输入和[1]的标签多步预测时标签就变成[horizon]或者[horizon, feature_dim]。窗口长度look_back一般取序列长度的10%~20%不能太短也不能太长。我常用look_back24或48来预测未来6~12个点效果比拍脑袋定的窗口好不少。另外如果数据有缺失不要用均值填充尽量用前向填充或插值避免引入错误的局部趋势。关于归一化我一般用MinMaxScaler。它在数据有明显上下界时非常稳能让模型更快收敛。StandardScaler也不是不行但对异常值更敏感。如果数据里有特别大的尖峰MinMax可能把正常值压得太扁这时候可以先做一轮异常值截断再用。不要一上来就把所有数据丢进模型先画图看分布这个习惯能省掉后面很多麻烦。2.3 核心超参数怎么定才有得跑下面是这次实验里的核心超参数可以直接照抄参数数值说明d_model64输入embedding和注意力投影的维度nhead4多头注意力头数需能被d_model整除num_encoder_layers2标准Transformer里encoder层数我用了2层dim_feedforward256前馈网络隐藏层维度dropout0.1防止过拟合batch_size64显存不大就调小建议32~128learning_rate1e-3初始学习率配合cosine调度epochs100早停耐心值建议设20注意nhead一定要能整除d_model否则Pytorch会直接报错。层数不是越多越好我试过4层在数据量不大的情况下反而更容易过拟合。学习率建议用AdamW 余弦退火Transformer对学习率比LSTM敏感固定学习率容易震荡。dim_feedforward我习惯设成d_model的4倍左右太大参数量涨得快太小表达能力受限。还有一个常被忽略的参数是dropout。时间序列训练数据一般不会特别大dropout设太小容易过拟合设太大又可能欠拟合。0.1到0.2之间是个安全区间。如果你的数据量超过十几万条可以把dropout降到0.05加快收敛。3. 实操过程与核心环节实现3.1 环境搭建与项目结构动手前先把环境理顺。我习惯用conda建一个独立环境避免跟其他项目打架。conda create -n ts_transformer python3.9 -y conda activate ts_transformer pip install torch2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install numpy pandas matplotlib scikit-learn如果你用的是GPU机器装完后第一时间检查一下CUDA是否可用python -c import torch; print(torch.cuda.is_available(), torch.cuda.device_count())输出“True 1”就说明Pytorch能用GPU了。如果输出False不是驱动问题就是Pytorch版本没选对。我还在Jetson等嵌入式设备上装过那又得单独挑适配版本不建议新手一上来就碰。项目目录我习惯这样组织time_series_transformer/ ├── data.csv # 原始数据 ├── dataset.py # 数据集与预处理工具 ├── model.py # Transformer模型定义 ├── train.py # 单步/多步训练脚本 ├── evaluate.py # 评估与可视化 └── requirements.txt这个结构看着简单但足够用。实验迭代时最怕把所有代码堆在一个文件里改一个地方牵一发动全身。把数据、模型、训练、评估拆开后面换数据集或者换模型会轻松很多。3.2 数据准备与Dataset类为了把实验说透我用了一段周期信号叠加噪声的数据模拟传感器读数或者销量曲线。你也可以换成自己的csv只要确保日期列按时间升序排列。import numpy as np import pandas as pd from torch.utils.data import Dataset, DataLoader # 生成示例数据正弦 线性趋势 噪声 np.random.seed(42) t np.arange(0, 1200) signal 10 * np.sin(2 * np.pi * t / 50) salary t * 0.01 noise np.random.normal(0, 1, sizet.shape) data signal salary noise注意这个数据只是示例真正项目里数据要复杂得多但处理和建模逻辑是一样的。这里我故意加了一个单调趋势是想看Transformer能不能在趋势和周期并存时学出结构。接下来是滑动窗口。我写了一个继承Dataset的类输入是[look_back, feature_dim]序列标签是未来horizon个点。class TimeSeriesDataset(Dataset): def __init__(self, data, look_back24, horizon6, step1): self.x, self.y [], [] for i in range(0, len(data) - look_back - horizon 1, step): x data[i:i look_back] y data[i look_back:i look_back horizon] self.x.append(x) self.y.append(y) self.x np.array(self.x, dtypenp.float32) self.y np.array(self.y, dtypenp.float32) def __len__(self): return len(self.x) def __getitem__(self, idx): return self.x[idx], self.y[idx]需要注意这里的data是一维数组所以我构建x时每个时间点只有1个特征。如果有多维特征沿着最后一个维度拼接即可上一节的“拼时间戳”也是在这里做。构造好的样本可以用DataLoader加载look_back, horizon 24, 6 dataset TimeSeriesDataset(data, look_back, horizon) # 按时间顺序切分 train_size int(0.7 * len(dataset)) val_size int(0.15 * len(dataset)) test_size len(dataset) - train_size - val_size train_data, val_data, test_data torch.utils.data.random_split( dataset, [train_size, val_size, test_size], generatortorch.Generator().manual_seed(42) )这里要特别提醒random_split会在内部做随机打乱但因为我们构造样本时本身就是按时间顺序滑动窗口生成的可能不太合适。更稳妥的做法是直接按索引切分不调用random_split具体看你自己需求。如果是严格的时序预测建议手动切片保证训练集在时间上完全早于验证集和测试集。3.3 Transformer模型核心实现标准Transformer结构里包含Encoder和Decoder。但时间序列预测不一定需要Decoder尤其是单步预测和直接多步预测用Encoder堆叠后接一个全连接输出层就能取得不错的效果。我在实验中选的是Encoder-only方案结构更轻训练更快。如果想做递归多步同样可以复用这个Encoder只是在推理时多绕几圈。下面是完整的模型定义位置编码和Encoder主体都放进去。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, dropout0.1, max_len5000): super().__init__() self.dropout nn.Dropout(pdropout) 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() * (-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) self.register_buffer(pe, pe) def forward(self, x): x x self.pe[:, :x.size(1), :] return self.dropout(x) class TimeSeriesTransformer(nn.Module): def __init__(self, feature_dim1, d_model64, nhead4, num_layers2, dropout0.1): super().__init__() self.d_model d_model self.input_proj nn.Linear(feature_dim, d_model) self.pos_encoder PositionalEncoding(d_model, dropout) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward256, dropoutdropout, batch_firstTrue, ) self.transformer_encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) def forward(self, x): # x: [batch, seq_len, feature_dim] x self.input_proj(x) * math.sqrt(self.d_model) x self.pos_encoder(x) x self.transformer_encoder(x) return x在单步任务里输出层可以只取编码序列的最后一帧再接一个Linear。在多步任务里要么对最后一帧输出horizon维要么把整个编码序列压平再接全连接。后者参数量大一般用前者class SingleStepHead(nn.Module): def __init__(self, d_model): super().__init__() self.fc nn.Linear(d_model, 1) def forward(self, enc_output): return self.fc(enc_output[:, -1, :]) # 取最后一个位置的编码 class MultiStepHead(nn.Module): def __init__(self, d_model, horizon): super().__init__() self.fc nn.Linear(d_model, horizon) def forward(self, enc_output): return self.fc(enc_output[:, -1, :])为什么取“最后一个位置的编码”而不是所有位置的编码因为TransformerEncoder每一层都会做全局注意力也就是说每个位置的输出都已经包含了对整个序列的“总结”。最后一个位置在时间上离预测目标最近拿它做输出头的信息瓶颈最合适。如果要保留更多细节也可以把最后几帧拼起来但代价是参数量增加收益不一定明显。3.4 单步预测训练流程训练循环本身不复杂但有几个地方我会特别注意。第一个是loss单步用MSE这是回归任务的标配。第二个是优化器AdamW配合余弦退火几乎是当前Transformer训练的标准配置比单纯用Adam稳定。def train_model(model, train_loader, val_loader, epochs50, lr1e-3): device cuda if torch.cuda.is_available() else cpu model model.to(device) criterion nn.MSELoss() optimizer torch.optim.AdamW(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) for epoch in range(epochs): model.train() train_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() enc_out model(x) pred model.output_head(enc_out) loss criterion(pred, y) loss.backward() optimizer.step() train_loss loss.item() scheduler.step() val_loss evaluate(model, val_loader, criterion, device) if (epoch 1) % 10 0: print(fEpoch {epoch1}, Train Loss: {train_loss/len(train_loader):.6f}, Val Loss: {val_loss:.6f}) return model写这段代码时要注意model.output_head是我们在外部挂上去的。一个更方便的做法是在TimeSeriesTransformer里增加一个head_type参数直接在构造函数里决定使用单步头还是多步头这样训练代码可以共用。训练时如果loss长时间不下降先查两件事学习率是不是太大数据有没有标准化。绝大多数奇怪现象都出在这两处。3.5 多步预测实现方式直接多步训练起来和单步几乎一样只是y的维度从1变成horizon输出头换成MultiStepHeadloss仍然用MSE。推理的时候一次拿到的就是未来horizon个点非常直接。递归多步需要额外写一个推理函数把模型预测出的下一个值拼到输入序列尾部同时丢掉最前面的点让窗口“滑”起来。def recursive_forecast(model, history, steps, devicecpu): # history: [look_back, feature_dim] model.eval() history torch.tensor(history, dtypetorch.float32).unsqueeze(0).to(device) preds [] with torch.no_grad(): for _ in range(steps): enc_out model(history) pred model.output_head(enc_out) # [1, 1] preds.append(pred.item()) pred pred.unsqueeze(-1) # [1, 1, 1] history torch.cat([history[:, 1:, :], pred], dim1) return preds这个思路很直观但坑也在这输入用的预测值本身就是不准的误差会随着步数增多不断放大。我实测下来horizon6时还能看horizon24时后面基本变成一条平滑直线。缓解误差累积有几个实用技巧训练时用Teacher Forcing即以一定概率用真实值替代预测值去喂下一步让模型适应自己的预测对输出做clip限制预测值的合理范围避免极端值把窗口带偏推理阶段用确定性策略比如取中位数而不是单次路径。3.6 评估与可视化评估指标我用三个MAE、RMSE和R2。MAE好解释RMSE对大误差更敏感R2则能反应模型对真实趋势的拟合程度。from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score def evaluate_metrics(y_true, y_pred): mae mean_absolute_error(y_true, y_pred) rmse mean_squared_error(y_true, y_pred, squaredFalse) r2 r2_score(y_true, y_pred) return {MAE: mae, RMSE: rmse, R2: r2}画图建议用matplotlib把测试集真实值和预测值画在同一张图里。多步预测时我会把每个预测窗口的起点和终点连起来画成阶梯状能更清楚地看到误差累积发生在哪个阶段。import matplotlib.pyplot as plt plt.figure(figsize(12, 5)) plt.plot(y_true, labelTrue) plt.plot(y_pred, labelPred) plt.legend() plt.title(Time Series Prediction) plt.show()这张图是判断模型有没有“学到东西”的最快方式。如果pred和true的相位都对不上先检查窗口长度是否合理如果趋势对但局部抖动不对考虑加更多特征或调d_model。4. 常见问题与排查技巧实录4.1 数据泄漏最隐蔽也最致命数据泄漏是时间序列预测里出现频率最高的“幽灵问题”。除了归一化泄漏还有一种是数据集划分时不注意时间顺序把未来样本混进训练集结果模型在测试集上表现“超神”一到线上就崩。处理办法是严格按时间顺序划分且在构造滑动窗口时让训练集只使用该时间点之前的数据。另外如果数据来自多个实体比如多个城市或设备的序列不能把它们混在一起打乱划分应该按实体分组切分否则也会出现实体级别的泄漏。我自己的判断标准很简单如果训练集和测试集的MAE差距小得离谱或者测试集比训练集还好那大概率是泄漏了。正常情况训练误差应该略低于测试误差这个经验虽然不绝对但能帮你快速发现问题。4.2 训练Loss不降或变成NaN我遇到过一次Loss变成NaN的情况查了半天发现是没有对输入数据做归一化数值稍微大一点经过多头注意力里的softmax后就爆炸了。解决方法是先把数据缩放到[0, 1]或标准化到均值0方差1再开始训练。如果标准化后还是NaN检查一下学习率Transformer的训练不太吃固定的大学习率建议从1e-4或者3e-4开始试。还有一种可能是位置编码的max_len不够序列长度超过上限导致pe切片出问题这种一般会直接报IndexError不太会静默NaN。另外一个容易忽视的点是batch_first。Pytorch的TransformerEncoderLayer默认batch_firstFalse如果输入维度是[batch, seq_len, feature]必须显式设置batch_firstTrue否则会对seq_len维度做batch维处理虽然不报错但结果完全不对。这种错误很难排查因为它表现成loss一直在下降但验证曲线不对劲。4.3 多步预测后期曲线“平”了多步预测的误差累积问题前面提过这是所有递归式预测的通病。处理思路有两个方向一是把模型换成直接多步输出一次性预测完整未来区间从根源上避免递归累积二是引入机器学习里的鲁棒性技巧比如训练时对输入加小噪声、预测时对输出做平滑。我自己的经验是如果horizon不超过10递归多步能省显存且效果不差如果horizon到了24以上直接多步会更稳定。建议在实验设置里同时保留这两种模式用验证集决定到底用哪个。有时“曲线平了”也不全是误差累积的问题可能是数据本身的信噪比太低。我画过频谱图之后发现数据里真正可预测的成分只占很小一部分模型很容易倾向于输出均值附近的值。这时候考虑对数据做差分或季节分解把趋势和周期拆开建模再对残差做预测。4.4 显存和训练速度问题注意力矩阵的大小是平方级增长的窗口长度从24改成96显存占用可能直接翻好几倍。如果显存不够优先调小batch_size或者用梯度累积gradient accumulation模拟更大的batch。Pytorch的自动混合精度AMP也值得开torch.cuda.amp里封装得已经很成熟能省不少显存还能加速训练。下面是一个简化的混合精度训练片段scaler torch.cuda.amp.GradScaler() for x, y in train_loader: optimizer.zero_grad() with torch.cuda.amp.autocast(): pred model(x) loss criterion(pred, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()AMP在NVIDIA GPU上用Pytorch 2.x已经非常稳定CPU上不用开。还有一个建议尽量用batch_firstTrue的TransformerEncoderLayerPytorch默认是False如果搞错了维度虽然报错你会知道但排查起来很烦。4.5 长序列窗口的改进空间标准Transformer处理几百甚至上千的时间步时显存和效率都会遇到瓶颈。我这次实验主要用的是中等长度窗口但如果你遇到超长序列建议看看Transformer的改进变体比如Informer的稀疏注意力、Autoformer的分解机制、PatchTST把时间序列切成patch再编码。这些都在标准Transformer的思路上做了针对性优化很适合高频金融数据、长时间传感器监测这类场景。我后续也在计划把PatchTST并进这套实验框架里做对比。另外提醒一句模型调参不是全部。数据质量、特征工程、异常值处理对最终预测效果的影响往往比换模型更明显。我在做这套实验时花了将近一半时间在清洗和构建特征上模型本身虽然重要但绝不是唯一变量。做完整套单步、多步实验后我最深的体会是Transformer不是一个“万能开关”它适合的是长依赖和多变量交互明显的时间序列。在这套实验中我用Pytorch从数据预处理、模型搭建到评估可视化完整走了一遍也踩了不少坑。如果你正打算在自己的数据上复现建议先把数据好好看一眼再按文中的代码结构一点点搭别急着直接套大模型。先把单步跑通再多步最后再回头调超参这样每一步都能定位到问题。希望这篇记录能帮你少走点弯路。本文还有配套的精品资源点击获取