☰
用Transformer实现长期预测:数据预处理与反归一化是关键
2026/10/26 17:37:52 网站建设 项目流程

简介:项目聚焦自然语言处理领域的里程碑式Transformer模型,并将其迁移到长期时间序列预测场景,提供从数据预处理、模型构建、训练评估到可视化展示的整套Python实现,适合具备一定深度学习基础、希望深入研究序列建模与长程依赖问题的开发者和研究人员。压缩包共39个文件,压缩后整体约26.49MB,以13个Python源文件为主,配合csv数据集、txt说明、pth权重、png结果图等,覆盖数据装载、模型组件、指标计算与预测输出等完整的实验环节。目前已有275人浏览学习。代码采用模块化设计,包括Embedding、Transformer块、Projection与分解工具等,可直接运行main.py在ETTh1数据上复现预测,并生成对比序列与可视化图表。读者能深入理解自注意力、多头注意力、位置编码、残差连接等核心机制,同时掌握长期预测任务的数据处理与调参思路,获得可扩展的实验基线,并利用附带的结果图与预测CSV直观评估效果,便于后续二次开发与科研对比。

1. 长序列预测反直觉的地方:用Transformer模型实现长期预测并可视化结果,难点不在模型

不少做过时序预测的工程师,第一次把Transformer用在长期预测上,都会撞见一个反直觉的现象:训练Loss非常漂亮,预测曲线却在时间轴上整体右移,像回声一样跟着真实值走。这个zip包要解决的核心,就是用Transformer模型实现长期预测并可视化结果,把从数据切分、模型搭建到最终出图的全流程跑通。它适合已经用LSTM做过短预测、现在想跨到96步甚至更长预测窗口的人,也适合想快速验证Transformer在自家数据上效果的数据分析工程师。如果你以为注意力机制是最大门槛,那就错了,真正决定成败的是数据预处理和预测结果的还原方式。

2. 长期预测的数据预处理:这一步直接决定后面结果可不可信

2.1 用滑窗把时间序列切成模型能看懂的样本

Transformer不会凭空理解“连续时间”,它看到的是一段定长窗口。常见做法是把原始时间序列切成两个部分:左侧是历史观测窗口(seq_len),右侧是未来要预测的窗口(pred_len)。长期预测里我一般用seq_len=168(一周小时数)去预测pred_len=96(未来四天),这个比例能让模型捕捉到足够的周期和趋势。

数据切分的代码并不复杂,但有一个关键点:归一化必须在切训练集之后做,否则测试集的上下界会提前泄漏给模型。下面是完整的样本构造脚本:

import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler def create_sequence_samples(scaled, feature_cols, target_idx, seq_len=168, pred_len=96, start=0, end=None): X, Y = [], [] end = len(scaled) if end is None else end for i in range(start, end - seq_len - pred_len + 1): x = scaled[i : i + seq_len] # 历史窗口 y = scaled[i + seq_len : i + seq_len + pred_len, target_idx : target_idx + 1] # 未来窗口 X.append(x) Y.append(y.reshape(pred_len)) return np.stack(X), np.stack(Y) # 读取数据,假设 df 里包含 'target' 列和 'hour', 'peak' 等特征 feature_cols = ['target', 'hour', 'peak_flag'] target_idx = 0 values = df[feature_cols].values.astype(np.float32) train_size = int(len(values) * 0.8) scaler = MinMaxScaler() # 只在训练段上拟合,避免未来信息泄漏 scaler.fit(values[:train_size]) scaled = scaler.transform(values) X_train, Y_train = create_sequence_samples( scaled, feature_cols, target_idx, seq_len=168, pred_len=96, start=0, end=train_size) X_test, Y_test = create_sequence_samples( scaled, feature_cols, target_idx, seq_len=168, pred_len=96, start=train_size, end=None)

逻辑说明:create_sequence_samples的滑动窗口不重叠业务含义,每两个相邻样本相差一个时间步。这里没有跳过重叠样本,因为时间序列预测里多切一个样本就等于多一条训练数据。target_idx用来锁定要预测的目标列,其他特征只作为输入信息,不参与预测目标。

参数说明:seq_len和pred_len是长期预测最先要定的两个数。一般规律是pred_len不要超过seq_len的两倍,否则模型容易退化成“复制历史均值”。常用起点:小时级数据用seq_len=168, pred_len=96;日级数据用seq_len=90, pred_len=30。后续如果发现预测曲线过度平滑,优先拉长seq_len。

2.2 反归一化:预测出来的是0到1的数字,不是业务指标

模型训练时接收的是归一化后的数据,所以预测结果也是0到1的区间。直接拿去画图,纵轴会让人看不懂,更没法计算真实误差。因此每次预测完必须做反归一化,把数值还原成原始量纲。

def inverse_target(scaler, target_idx, pred_values): # pred_values: (N, pred_len) 形状,值是归一化后的目标列 dummy = np.zeros((pred_values.shape[0], pred_values.shape[1], scaler.n_features_in_)) dummy[..., target_idx] = pred_values restored = scaler.inverse_transform( dummy.reshape(-1, scaler.n_features_in_)) return restored[:, target_idx].reshape(pred_values.shape)

逻辑说明:MinMaxScaler的反变换需要完整特征矩阵,所以先构造一个占位矩阵,把预测值放回目标列位置,再还原。target_idx必须和训练时保持一致,否则还原结果会错位。

参数说明:如果你在训练时用scaler.fit_transform(values[:train_size]),那么测试阶段也一直沿用这个scaler,千万不要重新 fit。一旦重新拟合,测试集的均值、最大值就被模型偷看到了,验证指标会虚高,这是长期预测里最常见的“自我欺骗”。

2.3 加上时间特征,模型才知道今天是星期几

纯数值序列在长期预测里容易“盲猜”,因为模型分辨不出同一数值发生在工作日还是周末。常见做法是把hour、dayofweek、is_holiday这类时间特征作为额外列拼进输入。注意:这些特征要做周期编码,而不是直接丢0到23的整数,否则模型会以为23和0是截然不同的值。

df['hour'] = df.index.hour df['dayofweek'] = df.index.dayofweek # 周期编码,避免 23 点与 0 点被人为拉远 df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)

逻辑说明:hour是0到23的循环量,直接变成数值会让模型误以为23和0距离很远。正弦/余弦编码把时间点映射到单位圆上,既保留了顺序,又不会破坏周期性。dayofweek也建议用同样的方式处理。

参数说明:这一类时间特征在长期预测里的收益非常明显,尤其当你的数据包含周末或季节性波动时。加了之后如果验证集误差下降了3%以上,说明特征有效;如果没变化,说明序列本身的趋势已经足够强,可以去掉多余的列,减少计算量。

3. 用PyTorch从零搭一个轻量Transformer模型:最小可复现结构

3.1 位置编码:让Transformer知道序列的顺序,而不是把数据当集合

Transformer没有循环结构,所以必须把“位置信息”用加法注入到输入向量里。时序预测里最常用的是正弦位置编码:偶数维度用sin,奇数维度用cos,这样的编码能表示不同频率的相对位置关系。

import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=1024, dropout=0.1): super().__init__() self.dropout = nn.Dropout(dropout) 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) pe = pe.unsqueeze(0) # (1, max_len, d_model) self.register_buffer('pe', pe) def forward(self, x): # x: (batch, seq_len, d_model) return self.dropout(x + self.pe[:, :x.size(1)])

逻辑说明:register_buffer把位置编码注册成模型的一部分,不会参与梯度更新,但会跟着模型迁移到GPU。forward里直接相加,所有batch共享同一份位置编码表。

参数说明:d_model是输入向量的维度,也是位置编码的维度。长期预测任务里,d_model从64起步通常就够用,不需要像大语言模型那样动辄1024。max_len要大于实际seq_len,否则序列超出上限会报错。

3.2 用Encoder一次性输出整个预测窗口,比自回归Decoder更适合长期预测

如果需要预测未来96个点,不一定要让Transformer像翻译模型那样一个个“蹦词”。更快、更稳的做法是只保留编码器,把所有历史时间步编码成特征,再通过一层全连接直接输出整个预测窗口。

class TransformerForecaster(nn.Module): def __init__(self, feature_dim, d_model=128, nhead=8, num_layers=3, dropout=0.1, pred_len=96): super().__init__() self.input_proj = nn.Linear(feature_dim, d_model) self.pos_enc = PositionalEncoding(d_model, dropout=dropout) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, batch_first=True, activation='gelu' ) self.encoder = nn.TransformerEncoder( encoder_layer, num_layers=num_layers) self.output_proj = nn.Sequential( nn.Linear(d_model, d_model * 2), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model * 2, pred_len) ) def forward(self, src): # src: (batch, seq_len, feature_dim) src = self.input_proj(src) src = self.pos_enc(src) memory = self.encoder(src) # 对所有时间步取平均,得到序列级特征 pooled = memory.mean(dim=1) return self.output_proj(pooled)

逻辑说明:input_proj把原始特征维度映射到d_model,之后进入的是标准的Transformer编码器。memory.mean(dim=1)是对整个历史窗口做池化,好处是不管seq_len怎么变,输出层参数固定。output_proj里的两层全连接负责把序列特征拉伸成pred_len维,得到未来窗口。

参数说明:nhead通常设为8,但d_model必须是nhead的整数倍。num_layers在3到6之间;数据量只有几万条时,3层就已经足够,堆更多层只会让过拟合更早出现。dim_feedforward一般取d_model * 4,这是Transformer默认的放大倍数,太大并不会带来明显收益。

3.3 为什么不用Decoder做自回归:训练慢,误差还会随步长放大

很多NLP背景的人一看到Transformer就想去实现Encoder-Decoder结构,但长期预测场景里,Decoder自回归方式有两个明显的痛点:一是训练阶段要按时间步喂target,复杂度高;二是预测阶段第96步会累积前面95步的误差,越往后越飘。这个zip包的做法是Encoder一次性并行输出全部预测步,训练和推理都快,指标也更稳定。如果后续想进一步优化,可以换成Informer、Autoformer这类面向长序列的变体,但基础Transformer已经能覆盖大多数业务需求。

4. 训练与评估:长期预测的Loss、指标和验证方法

4.1 损失函数选MSE还是MAE:不能只盯一个数

长期预测里最常用的损失函数是MSE,因为它对大误差惩罚更重,能让模型更关注极端值。但问题是,MSE会让模型在预测多个时间步时倾向于输出“期望值”,也就是把未来不确定的部分平均掉,结果看起来像一条钝化的曲线。所以我一般同时计算MSE和MAE,训练用MSE,评估时两个都看。

import torch.nn as nn criterion_mse = nn.MSELoss() criterion_mae = nn.L1Loss() def evaluate(model, val_loader): model.eval() total_mse, total_mae, cnt = 0.0, 0.0, 0 with torch.no_grad(): for xb, yb in val_loader: out = model(xb) loss_mse = criterion_mse(out, yb) loss_mae = criterion_mae(out, yb) total_mse += loss_mse.item() * xb.size(0) total_mae += loss_mae.item() * xb.size(0) cnt += xb.size(0) return total_mse / cnt, total_mae / cnt

逻辑说明:yb的形状是(batch, pred_len),out也是同样形状,直接算损失。这里先算每个batch的误差,再按样本数加权平均,避免最后一批样本数少导致指标抖动。

参数说明:如果业务更关注“预测错了多少度”,用MAE;如果更关注“极端日子别出大错”,用MSE。实际操作中我会训练两个模型分别用不同损失函数,再在验证集上比较,挑误差更小的那个。

4.2 学习率、Batch Size、Epochs的常见起点

Transformer训练比较吃调参,但长期预测任务有自己的稳定配置。下面是一组我从多个项目里沉淀下来的起点,适合小时级数据、样本量在几万条左右的场景。

参数建议值说明
d_model64 - 128数据量小就选64
nhead8d_model必须能被8整除
num_layers3超过6层容易过拟合
dropout0.1 - 0.3数据量小往0.3靠
learning rate1e-3配合Warmup使用
batch size32 - 64显存不足就减半
epochs50 - 100配合早停

训练循环里我习惯加学习率衰减和模型保存,这样验证集一旦连续5个epoch不降,就自动减少学习率,避免一直震荡。

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', patience=5, factor=0.5) for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() out = model(xb) loss = criterion_mse(out, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() val_mse, val_mae = evaluate(model, val_loader) scheduler.step(val_mse) torch.save(model.state_dict(), "transformer_longterm.pt")

逻辑说明:clip_grad_norm_用来限制梯度最大范数,Transformer在长序列上经常出现梯度爆炸,这个操作能避免一次更新把参数冲飞。ReduceLROnPlateau监控验证集MSE,连续5个epoch不降就砍掉一半学习率。

参数说明:max_norm=1.0是对梯度向量的L2范数做截断,数值太小会让训练变慢,太大就起不到保护作用。weight_decay=1e-4是L2正则,能压住一些过拟合,但不是越大越好。

4.3 评估指标:MSE、MAE之外还要看方向命中率

长期预测模型的业务价值,不仅取决于数值准不准,还取决于“涨跌方向”判断对不对。比如预测电力负荷,如果数值差0.1但方向全对,调度可以提前准备;如果数值很准但方向全反,反而会让决策出错。所以我额外加了一个方向命中率指标:

def directional_accuracy(y_true, y_pred): # y_true, y_pred: (batch, pred_len) diff_true = np.diff(y_true, axis=1) diff_pred = np.diff(y_pred, axis=1) return np.mean(np.sign(diff_true) == np.sign(diff_pred))

逻辑说明:先算相邻时刻的变化量,再比较变化符号是否一致。方向命中率只关心涨跌,不关心幅度,和MSE互补。长期预测里,这个指标通常能到60%以上就算可用。

参数说明:如果方向命中率一直在50%以下,说明模型学到的主要是波动惯性,基本等于抛硬币,这时候先去检查数据预处理,别急着加模型复杂度。

5. 避坑记录:长期预测的五个高频翻车点,第一个几乎人人都踩

5.1 预测曲线比真实值滞后一拍:先查差分和归一化,再考虑改模型

现象:预测曲线形状和真实值几乎一样,但整体向右平移,像回声一样跟在真实值后面,误差看起来很大,但曲线“轮廓”又很相似。

原因:序列本身是随机游走或强自相关,Transformer学到的是“把上一个时刻的值直接搬过来”,因为这样能让训练Loss显著下降,代价是预测未来时永远慢半拍。

解决:对目标列做一阶差分,让模型去预测变化量而不是绝对水平。预测完成后,再用累加方式还原:

# 训练前 values_diff = np.diff(values[:, target_idx], axis=0) # 预测完成后还原 pred_level = np.concatenate([ [values[-1, target_idx]], pred_diff ]).cumsum()

逻辑说明:差分后的序列变得平稳,模型不再能靠复制上一时刻“偷懒”,必须真实学习变化规律。还原累加时,以最后一刻的真实值为起点,后续每步加上预测的变化量。

参数说明:如果差分之后预测仍然滞后,再试着一阶差分加24小时季节差分(对小时数据),把昨日同时刻的差值也去掉。这个方法能解决大部分滞后问题,而且比改模型结构便宜得多。

5.2 归一化用全数据集拟合,验证集指标虚高

现象:验证集MSE比测试集好看非常多,模型一上真实生产环境就“翻车”,误差翻倍。

原因:在切训练集之前,用全部数据fit了MinMaxScaler。测试集的最小值和最大值都被Scaler记住了,相当于模型提前偷看了未来数据。

解决:严格遵循“只让Scaler接触训练段”的原则,测试段的数据只做transform,不做fit。另外,时间序列交叉验证也不能随机打乱,要按时间顺序滚动切分,否则同样会泄漏。

5.3 d_model设得太大,小数据直接过拟合

现象:训练Loss降到0.01,验证Loss在20个epoch后开始猛涨,出现典型的“训练好、验证差”剪刀差。

原因:Transformer参数多,几万条样本根本喂不饱一个d_model=512的模型。长期预测不是大语言模型,不需要超大规模参数。

解决:把d_model从128降到64,把dropout从0.1提到0.3,同时配合早停。如果还过拟合,就缩减num_layers到2层,甚至只用1层TransformerEncoder,效果不一定更差。

5.4 显存不足,长序列一跑就OOM

现象:seq_len设成512、batch_size设成128,训练刚跑一个batch就报CUDA out of memory。

原因:Transformer自注意力复杂度是O(n²),序列长度每增加一倍,显存占用大约变成四倍。长期预测里没必须把seq_len堆到500以上。

解决:先把seq_len限制在168到240之间,batch_size降到32。如果还想继续用长序列,可以改用Flash Attention或把注意力计算改成线性复杂度,但这个方案对大部分业务场景来说可能得不偿失。

5.5 节假日和周末预测全面失真:没有给模型“日历感知”

现象:工作日预测误差还行,一到周末或节假日,预测值明显偏离真实值,而且偏差方向高度一致。

原因:模型看到的只有数值序列,不知道哪一天是周末,也无法区分“春节放假”和“普通周一”。数值再相似,背后的业务规律完全不同。

解决:在特征列里加入is_holiday、dayofweek、month这些业务标签。is_holiday需要根据国家法定假日表生成,这一步虽然麻烦,但对负荷、客流、销量预测的收益往往比调模型结构更明显。

6. 进阶:用残差自相关和滚动验证,判断模型能不能上生产

训练完模型后,别急着看MSE就收工。我养成了一个习惯:每次预测完都先画出三类图——预测曲线、残差分布、残差自相关图。预测曲线只能看出“整体拟合得好不好”,残差自相关才能真正告诉你“模型漏掉了什么”。

如果残差在24小时处还有明显的正相关峰,说明模型没有学到日周期规律。这时候回去加时间特征,或者把seq_len拉长到覆盖至少两个完整周期,比单纯加层数更管用。如果残差自相关在多个滞后处都接近0,说明模型已经把可学到的规律都榨干了。

滚动验证是另一个容易被忽视的步骤。很多项目只切了一次训练集/测试集,模型在运气好的测试段上表现不错,上线后遇到不同季节、不同业务周期就原形毕露。我会把测试段再切成若干个不重叠的子区间,分别计算每个子区间的MSE和方向命中率,观察误差是否稳定。如果某个月误差突然放大,多半是那个月有节假日或业务活动,模型没感知到。

# 假设 test_y, pred_y 都是原始量纲 residual = test_y - pred_y # 计算前24个滞后的自相关系数 lags = np.arange(1, 25) autocorr = [np.corrcoef(residual[:-l], residual[l:])[0, 1] for l in lags]

逻辑说明:residual[:-l]和residual[l:]错位对齐,计算相关系数。如果autocorr[23]明显大于0.2,说明残差里还有24小时周期,模型没有学完整。

参数说明:自相关峰值对应的滞后位置,就是模型缺失的周期长度。小时数据看24,周数据看168,月数据看30。找到峰值后,优先调整输入特征和序列长度,而不是更换更大的模型。

我最早做长期预测时,只看训练和验证Loss,上线后才发现模型对节假日的预测一团糟。后来吃了亏,才把“先画图,后调参”变成固定流程。这个zip包里的可视化脚本,本质上就是帮你快速走通这条验证路径。先跑通最小案例,再针对残差暴露的问题逐步优化,比一开始就花大量精力堆复杂模型要有效得多。希望帮到你。

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

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

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

立即咨询