Transformer长期预测实战:一套代码搞定预测与注意力可视化
简介变换器Transformer模型长期预测与可视化项目基于《Attention is All You Need》提出的自注意力机制面向希望掌握Python建模与序列预测的NLP学习者。项目代码完整覆盖数据清洗、模型构建、训练评估和预测展示流程并内置ETTh1电力负荷样例数据便于直接运行与二次开发。资源共39个文件压缩包约26.49MB包含13个.py源码文件、多个.csv数据集、.xml工程配置以及预训练权重、依赖清单和结果图目录结构清晰便于按模块查阅。已有274人学习下载适合用于理解自注意力、多头注意力、位置编码等核心机制。通过调整预测长度或网络超参数可观察模型在长期预测任务上的表现并结合可视化对比图评估误差与趋势拟合效果。项目从数据处理到模型训练均有详细Python实现既适合新手入门Transformer应用也为进阶者修改预测策略提供试验基础。1. Transformer模型做长期预测一套代码把预测曲线和注意力权重同时画出来把Transformer从NLP搬到时间序列做长期预测再配上可视化这个Python代码包听起来不起眼但它真正解决的是LSTM时代最头疼的一件事预测长度一拉长误差就像滚雪球一样越来越大。我以前用LSTM做96步预测前48步精度还能看后面直接躺平成一条均值线。标题里的长期预测指的就是这类多步时间序列预测任务它跟机器翻译里的序列生成不是一回事不需要词表不需要解码出一个个token输入是一段连续数值输出也是一段连续数值。这个代码包的落地路径很清晰构造滑动窗口数据搭建Transformer编码器训练看loss最后把真实值、预测值和注意力权重可视化出来。适合两类人一类是已经用过LSTM做时序预测、想换架构的从业者另一类是刚看完Transformer架构工作原理、不想碰NLP就想上手的人。2. 长期预测的前置准备数据格式、归一化与look-back该设多少在把模型跑起来之前先把数据格式搞对。网上讲TensorFlow利用Transformer做回归的案例不多很多做NLP出身的人第一次拿Transformer做时序预测卡住的不是模型而是数据张量到底长什么样。Transformer吃的是三维输入(batch, seq_len, feature_dim)时序预测里seq_len就是look-back窗口长度feature_dim是特征维度输出则是(batch, predict_len)。这一章先把窗口怎么切、归一化怎么避坑、验证集怎么分讲透这些前置准备决定了后面所有代码能不能跑出有意义的结果。2.1 从LSTM到Transformer长期预测到底难在哪长期预测有三道坎第一道是长距离依赖。LSTM虽然设计了门控机制但信息每经过一个时间步就要过一次门实际能记住的上下文长度远小于理论值100步之前的信息基本被冲淡了。第二道是误差累积LSTM做多步预测常见做法是迭代式推理第一步的误差会被第二步放大滚到第48步已经面目全非。第三道是数据分布漂移训练集最后一段的分布往往和预测区间不一致模型学到的模式在预测区间失效。Transformer把第一道坎直接绕开了。自注意力机制让任意两个时间步之间只有一条计算路径路径长度为196步之前的信息和上一步的信息在你眼中地位相同不存在“遗忘”。知乎上那些讲解Transformer注意力机制的文章把query、key、value讲得很细落地到时间序列里你要理解的是每个历史时间步都会对当前步产生一个加权贡献这个权重由相似度算出比LSTM的链式传递更直接。误差累积问题也在单步直接映射的场景下被弱化因为很多长期预测实现是Encoder输出直接映射到目标长度不做逐步推理。但Transformer不是银弹。数据量不够时它比LSTM更容易过拟合序列长度超过512步时注意力矩阵的内存占用按平方增长。所以在动手之前先想清楚你的预测目标是不是真的需要Transformer如果预测步数在8步以内LSTM或线性模型已经够用没必要引入这套复杂度。2.2 用滑动窗口构造训练样本label_len、pred_len与数据泄漏先约定三个参数look_back是模型每次看到的历史长度predict_len是单次预测的未来步数label_len在某些实现里代表解码器的起始输入长度。对这份代码包来说核心就两个look_back和predict_len。以96和48为例业务含义是“用最近96个采样点预测未来48个采样点”如果数据是15分钟一条就是看一天预测半天。import numpy as np from sklearn.preprocessing import StandardScaler def make_samples(data, look_back96, predict_len48): X, y [], [] for i in range(len(data) - look_back - predict_len 1): X.append(data[i:i look_back]) y.append(data[i look_back:i look_back predict_len]) return np.array(X), np.array(y) # 生成一段带趋势和噪声的模拟序列 raw np.sin(np.linspace(0, 100, 5000)) 0.1 * np.random.randn(5000) # 归一化只对训练集fit防止未来信息混入 scaler StandardScaler() train_raw raw[:4000].reshape(-1, 1) scaler.fit(train_raw) raw_scaled scaler.transform(raw.reshape(-1, 1)).flatten() X, y make_samples(raw_scaled) print(X.shape, y.shape) # (4857, 96) (4857, 48)这段代码的逻辑是滑窗切样本每个训练样本由连续96个历史点组成标签是对应后面的48个点。窗口每次滑动1步数据量比较充足如果数据量大可以设置step参数让窗口每隔2步或4步滑一次减少样本冗余。归一化这里有个关键动作scaler.fit用的是训练集前4000个点而不是整条序列这是数据泄漏的经典雷区后面避坑章节还会再提。切分时也要按时间顺序不能像分类任务那样随机打乱否则验证集相当于偷看了训练集后面的数据。look_back的取值没有统一标准我的经验是先看业务周期做日级别的电力负荷预测拿最近7天的数据168个点比拿3天72个点效果好再做一次消融实验把look_back从48、96、168各跑一遍比较验证集loss。predict_len则直接由业务需求定48还是96取决于你要提前多久做决策模型不需要输出太长超出业务窗口的长度都是无效工作量。3. 搭建Transformer模型位置编码、多头注意力与可运行代码模型结构这一章我按“能跑通优先”的思路来。Transformer架构及其工作原理的教程铺天盖地但真正落到时间序列回归上很多细节和NLP不一样没有词嵌入不需要掩码Decoder很多时候是多余的。这份代码包的核心我只保留三样东西位置编码、TransformerEncoder层、输出映射头。用最少的代码把模型跑通再根据效果决定要不要加Decoder。3.1 位置编码是时序信号的唯一顺序来源公式与代码Transformer没有循环结构输入序列在模型眼里是一个集合顺序信息全靠位置编码硬塞进去。Transformer的位置信息怎么计算代码里比公式直观得多。正余弦位置编码的思路是偶数维度用正弦函数奇数维度用余弦函数不同维度对应不同频率这样每个位置都能用一组唯一的向量表示。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len512): 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() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): return x self.pe[:, :x.size(1)]register_buffer把位置编码注册成模型的持久缓冲区不参与梯度计算但会跟着模型一起迁移到GPU。max_len512在这里不只是上限也决定了模型能处理的最长序列如果测试阶段序列长度超过512这里会直接截断报错避坑章节再展开。前向时self.pe[:, :x.size(1)]按当前序列长度切出对应编码shape是(1, seq_len, d_model)广播加到输入上。Transformer的词嵌入矩阵在NLP里通常有V10000而且是随机初始化的时序预测里没有预训练词表这一说输入特征直接过一个线性层投影到d_model维就是这里的embedding层。随机初始化完全没问题因为时序特征经过归一化后分布相对固定模型自己能学到合适的映射。3.2 最小可运行的encoder模型核心代码与超参数解释长期预测的主流实现有两种EncoderDecoder和EncoderLinear头。Decoder结构在机器翻译里必须存在因为要逐词生成但时序预测的输出是连续数值区间Decoder反而引入不必要的复杂度。我一般先用Encoder均值池化Linear头跑通效果不够再加Decoder。class TimeSeriesTransformer(nn.Module): def __init__(self, feature_dim1, d_model64, n_heads4, num_layers2, dropout0.1, predict_len48): super().__init__() self.input_proj nn.Linear(feature_dim, d_model) self.pos_enc PositionalEncoding(d_model, max_len512) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadn_heads, dropoutdropout, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.output_proj nn.Linear(d_model, predict_len) def forward(self, x): # x: (batch, look_back, feature_dim) x self.input_proj(x) x self.pos_enc(x) enc_out self.encoder(x) # (batch, look_back, d_model) pooled enc_out.mean(dim1) # 对时间维做均值池化 return self.output_proj(pooled) # (batch, predict_len)batch_firstTrue把张量排成(batch, seq_len, d_model)和PyTorch默认的(seq_len, batch, d_model)不同这个参数设错会得到一堆维度不匹配的报错。input_proj把单变量特征从1维映射到64维多变量时序就把feature_dim改成实际特征数。enc_out.mean(dim1)是对时间维做均值池化把所有历史步的信息压缩成一个向量再经过Linear(64, 48)直接映射到预测区间。这个做法有个隐含假设未来48个值可以从历史编码的“平均状态”中推出来如果任务里近期模式比远期模式更重要可以改成取最后一个时间步的编码enc_out[:, -1, :]效果差异值得一试。超参数参照这张表起步参数建议值说明d_model64编码维度太小拟合不足太大容易过拟合且显存暴涨n_heads4头数时序数据用4或8足够16以上在数据量小时反而变差num_layers2编码器层数小数据集2层足够4层以上看验证集收益dropout0.1防止过拟合数据量大可调到0.2batch_size64显存16G以内都用得了learning_rate1e-3AdamW配ReduceLROnPlateau稳妥d_model必须是n_heads的整数倍这是多头注意力的维度划分前提设成64和4正好匹配。训练时如果发现loss震荡不降先检查学习率是不是太大再把dropout降到0.05试一轮。4. 训练与可视化把预测结果、误差和注意力权重画到一张图上模型搭好了训练环节更像一场调试而不是炼丹。这一章直接给出可复制的训练循环然后把可视化拆成三张图预测对比图、误差带图、注意力热力图。第一张图验证预测准不准第二张图看误差分布有没有规律第三张图回答那个经典问题——模型到底在看哪几个历史时间步。4.1 训练循环与收敛可视化早停和学习率衰减怎么配损失函数用MSE多步预测的每个位置误差平方后再平均对异常值敏感能推动模型把大误差压下来。训练循环里我加了三个小装置梯度裁剪防止loss突然冲到NaN、ReduceLROnPlateau当验证集停滞时降学习率、早停保存最优模型权重。import torch import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset train_dataset TensorDataset(torch.from_numpy(X_train).float(), torch.from_numpy(y_train).float()) val_dataset TensorDataset(torch.from_numpy(X_val).float(), torch.from_numpy(y_val).float()) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse) model TimeSeriesTransformer(predict_len48) optimizer optim.AdamW(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5) loss_fn nn.MSELoss() best_val float(inf) early_stop_counter 0 patience 10 train_losses, val_losses [], [] for epoch in range(200): model.train() epoch_loss 0.0 for xb, yb in train_loader: optimizer.zero_grad() pred model(xb) loss loss_fn(pred, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() epoch_loss loss.item() model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: val_loss loss_fn(model(xb), yb).item() val_loss / len(val_loader) scheduler.step(val_loss) train_losses.append(epoch_loss / len(train_loader)) val_losses.append(val_loss) if val_loss best_val: best_val val_loss torch.save(model.state_dict(), best_model.pt) early_stop_counter 0 else: early_stop_counter 1 if early_stop_counter patience: print(fearly stop at epoch {epoch}) break print(fepoch {epoch} | train loss {train_losses[-1]:.4f} | val loss {val_loss:.4f})shuffleTrue在训练加载器里没问题因为样本是滑窗生成的打乱的是样本顺序而不是时间顺序。梯度裁剪max_norm1.0是Transformer训练的习惯防止深层编码器梯度爆炸。ReduceLROnPlateau的patience5表示连续5个epoch验证集没下降就把学习率减半和早停的patience10配合模型在验证集上停滞10个epoch就停了省时间。收敛可视化就是每次把train_losses和val_losses画出来如果训练loss持续下降但验证loss在第30个epoch开始回升说明过拟合了早停会拦住如果两个loss都一直横盘大概率是学习率太小或模型容量不够。4.2 用matplotlib画出三张关键图预测对比、误差带与注意力热力图测试集上跑一次前向把预测值逆变换回原始尺度再和真实值画在同一张图上。注意这里必须用scaler.inverse_transform否则两张图单位对不上。import matplotlib.pyplot as plt import numpy as np model.load_state_dict(torch.load(best_model.pt)) model.eval() # 取测试集第一个样本 x_sample torch.from_numpy(X_test[:1]).float() y_true y_test[0] with torch.no_grad(): y_pred model(x_sample)[0].numpy() # 逆变换还原到原始数值范围 y_pred_raw scaler.inverse_transform(y_pred.reshape(-1, 1)).flatten() y_true_raw scaler.inverse_transform(y_true.reshape(-1, 1)).flatten() time_axis np.arange(len(y_pred_raw)) fig, axes plt.subplots(3, 1, figsize(10, 10)) # 图1真实值与预测值对比 axes[0].plot(time_axis, y_true_raw, labeltrue, linewidth1.5) axes[0].plot(time_axis, y_pred_raw, labelpred, linewidth1.5, alpha0.8) axes[0].set_title(Prediction vs True) axes[0].legend() # 图2误差带 error y_pred_raw - y_true_raw axes[1].plot(time_axis, error, colorcoral, linewidth1) axes[1].fill_between(time_axis, error, 0, colorcoral, alpha0.3) axes[1].axhline(0, colorgray, linewidth0.8) axes[1].set_title(Prediction Error) # 图3注意力权重热力图 attention_maps [] def hook_fn(module, input, output): # pytorch不同版本返回结构不同output可能是tuple或单个张量 attn output[1][0] if isinstance(output, tuple) else output attention_maps.append(attn.detach().numpy()) model.encoder.layers[-1].self_attn.register_forward_hook(hook_fn) with torch.no_grad(): model(x_sample) im axes[2].imshow(attention_maps[-1].mean(axis1), aspectauto, cmapviridis) axes[2].set_title(Attention Weights) fig.colorbar(im, axaxes[2]) plt.tight_layout() plt.savefig(forecast_visualization.png, dpi150)第一张图如果真实曲线和预测曲线在开头几步贴合、后面逐渐分离说明误差累积仍然存在但比LSTM的“后48步躺平”已经好很多。第二张误差带如果出现明显的周期性波动比如每条48步窗口的中间误差最大说明模型对突变点的响应滞后可以考虑把look_back加大。第三张注意力热力图横轴是历史时间步纵轴是注意力头颜色越亮代表被关注的权重越高。理想情况下能看到模型对近端时间步给予更高权重如果热力图一片均匀说明模型没学到时序关系回去检查位置编码是否加上了如果全黑多数是hook拿错了输出对象。数据可视化这一层matplotlib负责的是“验证”最终接业务时可以把预测结果推到Grafana或ECharts做可视化大屏但那是展示层的事模型验证阶段matplotlib的对比图才是说实话的地方。5. 避坑与排查Transformer做长期预测最容易翻车的5个地方这套代码跑起来不难但想让预测结果真正可用坑全在细节里。这一节把我在长期预测项目里踩过的坑按“现象-原因-解决”整理成清单每一项都能对上号。5.1 数据、窗口与归一化相关的坑坑1模型预测结果是一条直线loss还特别低。现象训练loss降到0.001以下验证集上预测曲线接近水平线只比均值好一点点。这是MSE回归的固有倾向模型发现预测均值能最小化平方误差尤其在数据存在强趋势或周期性时模型直接躺平。另一个常见的翻车场景是数据没做差分序列非平稳Transformer学到的注意力权重全堆在均值附近。解决方法是先用一阶差分把趋势去掉再做预测或者改用分位数损失函数比如pinball loss对极端值更敏感实测里差分的效果最直接预测完成后把差分结果累加回去即可。坑2验证loss很低上线后预测效果崩盘。现象开发环境验证集MAPE只有5%跑到真实场景误差直接翻倍。原因几乎都是数据泄漏。泄漏有两个来源一是归一化时用了全部数据的均值和标准差训练集里混入了未来信息二是验证集和训练集在时间轴上重叠滑窗生成的样本在切分边界上包含了对方的数据。解决方法是严格按时间顺序切分训练集占前80%验证集和测试集依次往后排切分后让验证集起点比训练集终点至少晚look_back个时间步给数据留一段gap。这个gap很关键它可以防止训练集最后一个窗口的标签恰好落进验证集。归一化的fit操作只允许发生在训练集上验证集和测试集只能用transform。坑3look_back取值靠猜模型表现忽好忽坏。现象窗口长度从96改成168验证loss突然涨了20%换一组数据又反过来。窗口长度本质上是给模型画了一个信息边界太短丢掉了周期性信息太长引入了无关噪声。解决方法是做一轮窗口长度消融[48, 96, 168, 240]各跑一遍每遍用早停选最优epoch比较验证loss。对日周期明显的数据窗口至少覆盖一个完整周期周周期明显的数据1687天×24点起步。我见过一个极端案例把窗口从96加到240之后模型直接不收敛原因是1649维的输入让注意力矩阵变得很稀疏减少到168反而好了。5.2 模型、训练与可视化相关的坑坑4测试序列长度和训练时不一致模型直接报错。现象训练时look_back96部署时来了一条长度512的序列self.pe[:, :x.size(1)]截断编码倒是能跑但准确率崩了。根本原因是位置编码的max_len512是个硬限制任何超过512的输入都会被截断而截断掉的信息里可能包含关键历史模式。还有一个容易忽略的边界就是predict_len大于look_back时均值池化和Linear头仍然能工作但注意力看到的有效信息长度不足预测结果跟瞎猜差不多。解决方法是把max_len设得比实际使用的最大序列长度大留出15%余量同时在数据加载时就限制序列长度宁可丢弃超长尾样本也不让模型硬吃。坑5注意力热力图全黑或全灰看不到任何结构。现象图三画出来一片深色看不出哪里被关注。这个坑十次有八次出在hook的返回值上。PyTorch不同版本里nn.MultiheadAttention的forward输出可能是(attn_output, attn_output_weights)元组也可能直接返回张量代码里output[1][0]在后者的情况下取到的是张量的某个维度而不是注意力权重。解决方法是加一行调试代码print(type(output), len(output) if isinstance(output, tuple) else output.shape)看清楚再取。另一个原因是注意力头的数量太多画mean(axis1)把所有头的平均权重画在一起模式被糊掉了。降到4个头单独画最后一个头最后一层的权重结构会清楚得多。6. 从验证到落地滚动预测的递归推理技巧与业务指标校准单次预测输出48个未来点业务上往往要一个月甚至一个季度的曲线这就要做递归滚动推理。把模型的预测结果拼接到输入序列尾部丢掉最旧的对应长度再继续预测像推土机一样不断向前推进。这个技巧在这个代码包里的价值在于不需要改模型结构就能把预测长度扩展到任意步数。def recursive_forecast(model, x_input, total_steps, step_size48): # x_input: (1, look_back, feature_dim) model.eval() outputs [] cur x_input for _ in range(total_steps // step_size): with torch.no_grad(): pred model(cur) # (1, step_size) outputs.append(pred) # 拼接预测结果丢掉最旧的step_size个历史点 cur torch.cat([cur[:, step_size:, :], pred.unsqueeze(-1)], dim1) return torch.cat(outputs, dim1)这段代码有两个前提step_size必须小于等于look_back否则拼接时维度不够预测仅作为输入的一部分真实值能拿到时优先用真实值回填减少误差传播。滚动推理时误差会随步数累积20步以内还行推到50步以上曲线质量下降很快。我一般给业务方两条曲线一条是单次预测的48步结果一条是滚动预测的长周期趋势线用途不同前者用来做短期操作决策后者看大方向。loss不能回答业务问题预测结果能不能用得用业务指标说话。多步预测的误差我推荐按位置分桶统计比如把48个预测位置分成前16、中16、后16三段分别计算MAE。如果前段MAE是1.5后段MAE涨到5.8说明模型短期可信、长期漂移严重业务上就要限制预测结果的使用窗口。MAPE在数值接近零时不稳定遇到业务指标的小数值要改用加权MAPE。最稳妥的做法是shadow test模型在测试集上跑完之后把预测结果和实际值一起挂到Grafana或ECharts的看板上跑两周真实数据对照不急于替换现有预测方案。可视化大屏上展示预测结果时把置信区间一起画出来比给一条单独的预测线要诚实得多业务方也更愿意接受。做这个代码包最大的感觉是Transformer做长期预测最难的从来不是把模型跑起来而是让模型预测的“长期”经得起业务验证。希望帮到你少走一点我当时熬夜排查的弯路。本文还有配套的精品资源点击获取