Transformer时间序列预测:完整实现与避坑指南
简介这份ZIP包基于Transformer实现时间序列预测面向深度学习初学者和有经验的开发者提供了一套从数据预处理、模型构建、训练优化到评估部署全流程的可复现工程示例。压缩包共91个文件大小约48.85MB其中40个ipynb为交互式可视化、训练与基准对比流程24个py文件实现编码器、解码器、多头注意力等核心模块并包含超参数搜索、交叉验证、学习曲线绘制等工具脚本另有RST文档、README说明、示例图片与配置文件目录模块划分清晰。已有299人浏览学习。内容不仅详细演示自注意力、位置编码等关键概念还通过与传统ARIMA、LSTM等方法的对比实验、K折交叉验证和误差分析直观展示Transformer在天气、股票、电力等场景的预测效果与调优思路读者可在此基础上修改数据集与超参数进一步开展实验研究是理解时序Transformer原理及工程实现的优质实践参考。1. 基于 Transformer 的时间序列预测一份能直接跑通的完整实现做时间序列预测的人多少都有过类似的窝火经历LSTM 拟合阶段看着还行预测长度一拉长输出就开始滞后、漂移想试试 Transformer又被“模型代码哪里来、数据怎么喂进去、结果拿什么评估”这一串事卡在原地。这份基于 Transformer 的时间序列预测项目正好把整条链路补齐了——数据窗口切分、模型构建、训练调参、学习曲线、交叉验证、基准对比全部都有对应的脚本和 notebook。它适合两类人一是想把手头预测任务迁移到 Transformer 上的工程师二是刚接触深度学习、想弄清楚自注意力机制和 Encoder-Decoder 到底怎么落地的新手。前者可以直接改参数跑自己的数据后者可以顺着脚本把每个模块读透。2. 项目结构拆解九个关键文件分别什么时候动打开压缩包后你会看到好几个顶层目录tst 目录放的是模型实现src 目录放训练与评估逻辑visualization 目录放绘图代码docs 目录是自动生成的文档notebooks 和根目录下还散着几个 .ipynb。这个分层不算复杂但有一个容易先入为主的误区以为核心在 notebook 里。其实 notebook 只是给你调试和演示用的真正能重复执行的训练脚本是 src/training.py。下面按职责把关键文件分成三组讲。2.1 训练主链路training.py、dataset.py、metrics.py为什么这三个文件必须分开因为训练循环、数据加载、指标计算是三个独立的修改点。你换数据集时只动 dataset.py改损失函数时只动 training.py评估标准变了只动 metrics.py。混在一个文件里改一轮下来就会改出各种隐藏依赖。文件职责你什么时候会打开它src/training.py模型构建、优化器初始化、训练循环、模型保存调整学习率、epoch、损失函数时src/dataset.py数据加载、滑窗切分、归一化换数据集、调整 lookback/horizon 时src/metrics.pyRMSE、MAE、MAPE 等评估指标计算改变评估标准时src/benchmark.pyTransformer 与 LSTM/ARIMA 对比做横向性能对比时src/cross_validation.py时间序列交叉验证判断模型泛化稳定性时training.py 里的结构是标准的 PyTorch 训练循环先构建模型实例定义损失函数和优化器然后按 epoch 遍历训练集每个 batch 完成前向、反向、参数更新每隔若干轮在验证集上算一次指标并打印。这种脚本里最不值得反复改的是循环本身最值得调的是优化器参数和训练轮数。我建议你第一次跑通时只动两个参数——n_epochs 和 learning_rate其他保持默认等流程顺了再逐步放开。dataset.py 则决定了模型看到的是什么形态的数据。时间序列和图像、文本的加载方式差异很大图像是随机读样本时序必须按时间顺序截窗口这个文件值得你逐行读一遍后面第 4 章我会专门拆它。2.2 调参与诊断工具search.py、learning_curve.py、cross_validation.py这三个脚本分布在 src 和根目录下是模型出问题时用来诊断的。learning_curve.py 绘制学习曲线横轴是训练轮次纵轴是 losstrain loss 和 val loss 之间的 gap 能直接告诉你模型是过拟合还是欠拟合。cross_validation.py 实现时序交叉验证search.py 负责超参数搜索常见做法是随机搜索或网格搜索。这里有一个很容易被忽略的细节普通回归里 sklearn 的 KFold 随机划分在这里不能直接用。时序样本前后有依赖关系随机打散等于把未来信息泄漏给训练集。常见的替代方案是前向链式划分expanding window或滚动窗口划分后面 4.3 节会给具体实现。2.3 可视化、基准对比与文档导出visualization 目录下除了 notebook还有一个 utils/plot_functions.py绘图函数都抽在这里比如预测值与真实值的对齐图、残差分布图。这种抽取方式是对的——调试过程中你会反复画同一类图封装成函数比每次在 notebook 里复制粘贴强太多。benchmark.ipynb 和 benchmark.py 用于模型横向对比把 Transformer、LSTM、ARIMA 放在同一批数据、同一个窗口设置下比较 RMSE。export_doc.py 用于从源码自动生成文档模型定稿后可以用来导出训练配置和模型说明存档。根目录的 labels.json 存的是时间序列的标签或元数据训练和评估阶段都会用到。到这里文件边界应该清楚了。从下一章开始我会把模型实现部分的代码逐块拆开重点放在多头注意力和位置编码这两个最容易理解偏差的点上。3. Transformer 核心实现拆解多头注意力、位置编码和参数选择这一章对应 tst 目录下的 transformer.py、encoder.py、decoder.py、multiHeadAttention.py、positionwiseFeedForward.py、loss.py 这几个文件。很多人读过 Transformer 论文但看代码时会卡在维度变换上。这里按“注意力 → 位置编码 → 前馈网络与损失”的顺序把实现逻辑串联起来并给出可以直接落地的参数选择建议。3.1 多头注意力为什么 Q/K/V 要拆成多个头Transformer 摆脱了 RNN 的序列递推结构让序列里每个位置直接关注所有其他位置。注意力计算的核心分三步把输入映射成 Query、Key、Value 三组向量计算 Q 和 K 的点积作为两两之间的相关度用 softmax 转成权重再用权重去加权 V。公式是Attention(Q,K,V) softmax(QK^T / sqrt(d_k)) V除以 sqrt(d_k) 这一步很多人忽略但很关键。当维度变大时点积的数值会变大softmax 会滑入梯度极小的区域训练就推不动了缩放一下能把梯度稳住。多头就是把 d_model 维的向量切成 n_heads 份每个头在低维子空间独立做注意力最后拼回原维度再过一次线性层。多头不是一个可有可无的锦上添花而是让模型在不同子空间里捕获不同依赖模式——比如一个头关注近期趋势另一个头关注周期性波动。单头注意力只能把这些模式平均掉。import math import torch import torch.nn as nn import torch.nn.functional as F class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout0.1): super().__init__() assert d_model % n_heads 0, d_model 必须能被 n_heads 整除 self.d_k d_model // n_heads self.n_heads n_heads self.w_q nn.Linear(d_model, d_model) self.w_k nn.Linear(d_model, d_model) self.w_v nn.Linear(d_model, d_model) self.w_o nn.Linear(d_model, d_model) self.dropout nn.Dropout(dropout) def forward(self, query, key, value, maskNone): batch_size query.size(0) # 线性映射后切成 n_heads 个头每个头维度是 d_k Q self.w_q(query).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) K self.w_k(key).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) V self.w_v(value).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) # 缩放点积注意力 scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn self.dropout(F.softmax(scores, dim-1)) context torch.matmul(attn, V) # 把多头结果拼接回 d_model 维度 context context.transpose(1, 2).contiguous().view(batch_size, -1, self.n_heads * self.d_k) return self.w_o(context)这段代码里最容易写错的是 view 和 transpose 的顺序。先把线性层输出 view 成 (batch_size, seq_len, n_heads, d_k)再 transpose(1, 2) 变成 (batch_size, n_heads, seq_len, d_k)这样每个头才能独立计算 attention。mask 参数在时序预测里一般用于 padding mask——把补零的位置在 softmax 前赋予一个极小值这里用的是 -1e9避免 pad 位参与注意力权重计算。如果 mask 漏了模型会去关注无效位置验证集指标看起来还行但上线后预测值会周期性偏低。参数选择上d_model 不是越大越好。NLP 里常用的 512 维直接搬到单变量时序预测上参数太多小数据集特别容易过拟合。我一般会建议单变量时序用 d_model64多变量特征多时再提到 128。n_heads 对应取 4 或 8d_model64 时我倾向 4 头这样每个头分到 d_k16信息容量比 8 头时的 d_k8 更充足。3.2 Encoder-Decoder 整体架构与位置编码transformer.py 里的整体结构是标准 Encoder-DecoderEncoder 负责把历史窗口编码成上下文表示Decoder 根据上下文逐步生成未来序列。每层 Encoder 包含多头注意力子层和前馈网络子层每个子层后面叠了残差连接和层归一化。Decoder 在两层之间多了一个 cross-attention 子层用来对齐当前生成位置与 Encoder 输出的关系。但要注意Transformer 本身是顺序无关的你把序列调换顺序计算结果不会变。对时间序列来说这不行——预测必须知道“第 3 个时间步在第 7 个时间步之前”。所以输入必须叠加位置编码。原始实现用正弦余弦函数生成固定编码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).unsqueeze(1).float() 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) def forward(self, x): # 位置编码直接加到输入向量上 return x self.pe[:x.size(1)]位置编码是加在输入向量上不是拼接。d_model 是偶数时 sin 和 cos 各占一半维度如果 d_model 是奇数最后一维会落到 sin 上导致维度不匹配这也是一个隐藏坑。这里还有一个容易被新手忽略的点register_buffer 注册的张量会随模型保存和加载但不会作为可学习参数所以位置编码表会被固定在最长 max_len 范围内。对时间序列预测位置编码有两种选择固定 sinusoidal 编码和可学习位置编码。可学习编码在训练长度固定的情况下通常效果更好但外推能力差——训练时最长序列 96 步推理时给你 240 步历史新位置没有对应编码效果会断崖式下跌。所以如果你要处理的序列长度变化范围大我建议先用 sinusoidal 或者干脆固定 lookback 窗口。3.3 前馈网络与损失函数两个影响收敛的细节positionwiseFeedForward.py 实现的是逐位置前馈网络本质上就是两个线性层夹一个激活函数。这里有个参数取舍问题。class PositionwiseFeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout0.1): super().__init__() self.linear1 nn.Linear(d_model, d_ff) self.linear2 nn.Linear(d_ff, d_model) self.dropout nn.Dropout(dropout) def forward(self, x): return self.linear2(self.dropout(F.relu(self.linear1(x))))d_ff 是中间隐藏维度论文里用的是 2048但那是配合 d_model512 的配置。小数据上照搬 2048 会让前馈层成为参数大户占用绝大部分显存和训练时间。时间序列场景我一般把 d_ff 设在 d_model 的 2 到 4 倍即 d_model64 时 d_ff 取 128 到 256。loss.py 里定义的是回归损失函数常见选项是 MSE、MAE、Huber。MSE 对大误差敏感梯度下降方向会被少数异常值主导MAE 稳健但对所有样本梯度恒定收敛慢Huber 是两者折中在误差小于阈值时用平方损失大于阈值时用绝对值损失是时序回归里比较省心的选择。如果你用的是电力负荷这类有突变的数据Huber 通常比 MSE 稳。核心超参数给一个起始配置表直接照着设基本不会出大问题参数推荐起始值说明d_model64单变量序列 64 够用多变量提到 128n_heads4d_model64 时 4 头比 8 头稳n_layers2-3时序数据量小堆太深过拟合d_ff128-256不要照搬论文的 2048dropout0.1-0.3噪声大的数据可以调高learning_rate1e-4 到 5e-4Transformer 对学习率敏感4. 训练与评估流水线滑窗、训练循环、学习曲线与超参搜索模型结构看懂了接下来就是怎么把数据喂进去、怎么训练、怎么评估。这一章对应 src 目录下的 dataset.py、training.py、learning_curve.py、cross_validation.py、search.py按一次完整实验的推进顺序来讲。4.1 dataset.py滑窗切分与数据泄漏的第一道防线时间序列预测的第一步是把原始序列切成“输入-输出”对。这里有两个核心参数lookback 是模型看多长的历史horizon 是模型要预测多长的未来。切分代码是典型的滑窗逻辑from torch.utils.data import Dataset import numpy as np import torch class TimeSeriesDataset(Dataset): def __init__(self, data, lookback48, horizon12): self.X, self.y [], [] # 最后一个窗口的下标是 len(data) - lookback - horizon for i in range(len(data) - lookback - horizon 1): self.X.append(data[i:i lookback]) # 过去 lookback 步 self.y.append(data[i lookback:i lookback horizon]) # 未来 horizon 步 self.X torch.tensor(np.array(self.X), dtypetorch.float32) self.y torch.tensor(np.array(self.y), dtypetorch.float32) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx]这里 range 的终止条件很容易写错。如果不减去 horizon最后一个样本的 y 会索引越界如果减多了会丢掉尾部样本。我第一次写类似代码时就踩过结果预测结果整体滞后了一个 horizon 的长度。lookback 和 horizon 的比例一般取 3:1 到 4:1比如预测未来 12 小时历史窗口给 48 小时左右。数据切完之后还有一个关键操作归一化。常见做法是 MinMaxScaler 或 StandardScaler。但注意scaler 只能拿训练集的数据去 fit验证集和测试集只做 transform。如果对整个序列做全局 fit相当于把未来数据的最大值、最小值提前告诉了训练过程验证集 loss 会异常好看真实部署时立刻现原形。4.2 training.py训练循环里的两个关键细节训练循环本身是标准的 PyTorch 代码没有太多花活for epoch in range(n_epochs): model.train() train_loss 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() output model(batch_x) # shape: (batch, horizon, d_model) loss criterion(output, batch_y) # 回归损失 loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item()有两个细节值得专门说明。第一个是梯度裁剪clip_grad_norm_ 这行很多初学者会删掉因为觉得“模型没爆就不需要”。但 Transformer 层数一旦堆到 3 层以上梯度范数在训练初期波动很剧烈不加裁剪会出现某个 batch 的 loss 突然跳到正常值十倍以上的情况。Max norm 设 1.0 是一个能覆盖大多数场景的保守值。第二个是学习率策略。Transformer 对学习率非常敏感固定学习率训练经常出现前期 loss 下降很慢、后期直接发散的问题。常见的做法是 Noam 式学习率前若干步 warmup 线性升高之后按步数倒数衰减。这个项目里如果你直接跑 training.py 用的是固定学习率训练曲线不平滑建议改成 warmup 加余弦退火的组合一般 warmup 步数设为总步数的 5% 到 10%。4.3 learning_curve.py 与 cross_validation.py过拟合与泛化判断训练完成后第一个要看的图是学习曲线。learning_curve.py 画出的曲线通常有两条train loss 和 val loss。判断逻辑很简单train loss 降但 val loss 不降甚至回升是过拟合优先加 dropout 或减小 d_model两条线都平但绝对值高是欠拟合优先加深层数或加大 lookback。交叉验证在时间序列里和标准 KFold 不一样。KFold 随机打乱会把未来样本混进训练集造成泄漏。常见替代方案是前向链式划分def forward_chain_cv(data, n_splits5): split_size len(data) // (n_splits 1) for i in range(1, n_splits 1): train_end i * split_size val_end train_end split_size train data[:train_end] # 只有过去 val data[train_end:val_end] # 未来 # 每组 train/val 上训练并记录指标这种划分的意义在于每一折的训练集都严格早于验证集模拟真实场景里“用历史预测未来”的过程。n_splits 一般取 5如果你的数据量小可以降到 3否则每一折训练集太薄模型学不到完整模式。4.4 search.py超参数搜索怎么控制成本超参数搜索看起来简单跑起来才知道肉疼。Transformer 训练一轮不算快如果网格搜索把 lookback、d_model、n_layers、learning_rate、dropout 全部组合一遍几十上百次训练能把机器挂一整天。我控制成本的常见做法是分两阶段第一阶段固定结构参数只搜三个最敏感的learning_rate、dropout、lookback。第二阶段用搜到的较优组合完整训练并做交叉验证。param_grid { lookback: [24, 48, 96], dropout: [0.1, 0.2, 0.3], learning_rate: [1e-4, 3e-4, 5e-4], }还有一个小技巧搜索阶段把 n_epochs 从 100 降到 30。30 轮足够拉开差距明显差的组合早点淘汰保留 top 3 再用 100 轮完整训练。提示搜索过程中记录每组参数的 val loss不要只看最后一次验证集指标。某些学习率下模型中期表现好但后期发散只看最终值会被骗。5. 避坑时间序列 Transformer 的五个高频翻车点Transformer 做时间序列预测坑和 NLP 不完全一样。这里按我自己和身边同事踩过的真实问题整理五个高频翻车点分成数据和训练两个阶段每一条都按现象、原因、解决三步说清楚。5.1 数据与预处理阶段的两个坑坑 1全局归一化导致数据泄漏现象验证集 loss 降到 0.01 以下曲线漂亮得像造假但放到真实场景预测前几步偏差就明显偏大。原因对整个序列做了全局 MinMaxScalerscaler 在 fit 时看到了未来数据的最大值和最小值。验证集和训练集共享同一套归一化范围相当于把未来的统计信息提前给了模型。解决严格按训练集 fit、验证集和测试集只 transform。代码上就是 sklearn 的 scaler.fit(train_data) 之后再对 val_data 调用 scaler.transform禁止对全量数据调 fit_transform。这个顺序写死在 dataset.py 里不在 notebook 里临时处理。坑 2滑窗错位导致标签偏移现象预测曲线形状和真实曲线很像但整体向右平移了一个 horizon 长度像影子一样挂在真实值后面。原因滑窗切分时 range 的终止条件写错导致 y 窗口和 X 窗口没有对齐。常见错误是 for i in range(len(data) - lookback) 少减了一个 horizony 的起始位置多推后了若干步。解决回到 4.1 节那段切分代码检查 range 的上界是 len(data) - lookback - horizon 1并且 y 的起始下标是 i lookback。写一个最小数据手工验证data [1,2,3,4,5,6]lookback2horizon1第一个样本 X 应该是 [1,2]、y 应该是 [3]。能手工算对代码基本不会错。5.2 训练与推理阶段的三个坑坑 3序列长度变了效果断崖式下跌现象训练时 lookback 固定 96推理时想用 240 步历史提高精度结果指标比训练时还差一大截。原因位置编码是固定长度的。用 sinusoidal 编码时推理序列长度超出训练时见过的最大长度新位置的编码是模型没见过的注意力分布会乱掉用可学习位置编码时超出部分连编码都没有。解决推理时 lookback 必须和训练一致。想要长历史就用滑动窗口截取最近 96 步作为模型输入。如果一定要支持变长输入把位置编码换成可外推的编码方案或者干脆在设计模型时就确定 max_len 并留足余量。坑 4Loss 变成 NaN 或者中途爆掉现象第二个 epoch loss 打印出来是 nan有时候前面都正常突然一个 batch 后直接崩掉。原因排查顺序很重要。首先数据里有 NaN 或 Inf这个最隐蔽其次学习率太大梯度更新一步跨过头最后是梯度爆炸层数加深后梯度范数累积。解决按顺序排查。先检查原始数据有没有异常值或缺失值再加梯度裁剪 nn.utils.clip_grad_norm_(model.parameters(), 1.0)最后把学习率降到 1e-4 再试。很多时候加完梯度裁剪就好了说明是梯度范数问题不是数据问题。坑 5随机性导致结果复现不出来现象同样的代码、同样的参数第二次运行和第一次运行的指标差不少有时候差 0.2 个 RMSE。原因PyTorch 默认有随机性。数据加载器的 shuffle、dropout 的随机掩码、模型参数初始化都会导致训练结果偏差。如果代码里没有固定随机种子每次跑都是不同的模型。解决在训练脚本开头加 torch.manual_seed(42)、np.random.seed(42)并在数据加载器里设置 generator 参数。追求完全复现还要设置 torch.backends.cudnn.deterministic True。固定种子后同一个脚本多次运行指标应该基本一致这也是后续调参的前提——不固定种子的话你分不清指标变化是参数改出来的还是随机波动。6. benchmark 与可视化判断模型是真进步还是假好看训练完模型最后一个问题是怎么向别人证明它真的好——以及怎么向自己证明它不是碰巧过拟合。benchmark.py 和 visualization 目录就是干这个的。benchmark 的常见做法是把 Transformer、LSTM、ARIMA 放在同一批数据、同一个 lookback/horizon 下对比。对比时要盯住三个基本指标指标适用场景注意点RMSE对大步长误差敏感单位与原始数据一致异常值会拉高MAE稳健不受个别异常点影响对小误差不敏感MAPE看相对误差百分比数据接近 0 时不可用对比时最容易犯的错是给不同模型用不同的预处理。LSTM 用了 MinMaxTransformer 用了 StandardScaler最后指标差异根本说不清是模型差异还是预处理差异。我一般会统一归一化方式、统一训练集和测试集划分只允许模型结构和超参数不同。visualization 这块不要只看“预测值和真实值叠在一起”的那张图。图好看不等于模型可靠。我常用的验证方法是把误差按预测步长拆开画曲线——横轴是第 1 步、第 2 步直到第 horizon 步纵轴是每步的平均绝对误差。正常情况误差随步长递增但如果第 1 步误差就很大说明模型在短时预测上都没学好问题出在输入窗口或者数据预处理而不是预测长度太长。有一次我给客户交付负荷预测模型整体 RMSE 比 LSTM 低 8%客户当场看预测对齐图也很满意。结果第二天对方的算法工程师把误差按步长拆开发现优势全部集中在长 horizon 段前 3 步的误差反而比 LSTM 大。原因是我们用了自回归式的 teacher forcing 训练模型习惯了用前一步的真实值修正误差推理时只能用自己的预测值短步优势就丢了。从那以后我每个新数据集都会强制先跑一遍 forward chain 交叉验证再上 benchmark并且把分步误差曲线画出来确认模型在短、中、长三个预测区间都没有明显短板才敢下结论说它真的变好了。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →