简介面向销售数据预测场景的Transformer完整项目基于PyTorch实现单变量多步预测适合有一定Python基础、希望将深度序列模型应用到电商销量预估、零售库存管理等实际业务场景的学习者与开发者。压缩包共六十九个文件包括六个Python脚本、一份样本数据CSV、一个环境配置文件、一篇说明文档以及六十张不同训练轮次下的预测结果对比图整体体积仅3.72MB。项目目录结构清晰data存放原始销售数据models内置Transformer网络定义utils聚合了数据处理、可视化、训练与评估等核心函数graph_one_step和graph_multi_step分别对应单步与多步预测的损失曲线和预测对比图可直观查看模型随训练轮次提升的过程。main.py串联全流程配合env文件即可快速替换数据与超参数。已有三百三十人学习可直接作为Transformer时间序列预测的入门模板也可依据自身销量数据二次改造后用于业务侧预估。1. 门店补货单背后的 Transformer为什么用它预测销售量先看一个实际场景某电商运营整理过去两年的日销售数据想预测未来 14 天的销量来指导备货和活动排期。传统做法是 ARIMA 或者 LSTM但前者对非线性、多周期叠加的销售数据拟合吃力后者训练慢、长期依赖容易丢失。换成 Transformer 之后预测效果明显改善——核心原因在于 Transformer 的自注意力机制能直接建模「去年双 11 那波爆发和今年大促之间的关系」这种跨长距离的模式而 LSTM 需要一步步把信息传递过去很容易遗忘。这个标题指向的正是这么一件事用 Transformer 结构做销售量预测并给出完整的 Python 实现包括模型构建、训练、评估和 Matplotlib 可视化。这篇文章面向的是手里有历史销售数据、想自己跑通一个预测方案的开发者。你不需要懂 NLP 里那些复杂的 BERT 衍生知识只需要掌握 PyTorch 的基础用法。读完你可以得到一份可直接运行的 Transformer 销售预测代码、关键参数的设置依据以及训练和评估阶段最常见的几个深坑和排查思路。2. 把销量预测变成 Transformer 能吃的样本滑窗、特征和标准化2.1 销售预测的本质是序列到序列问题不是分类问题Transformer 最初为 NLP 设计处理的是 token 序列。把它用到销售量预测需要先完成一个思维转换把「过去 N 天的销量」映射到「未来 M 天的销量」。这是一个典型的序列到序列Seq2Seq回归问题——输入是一个连续数值序列输出也是一个连续数值序列只不过输入和输出都不再是离散 token。这个建模方式决定了后续所有代码组织逻辑。Encoder 的输入是过去 L 天的特征序列Decoder 的输入是未来 H 天的真实值序列训练时或者预测值序列推理时输出是 H 天的预测销量。这样 Transformer 的 Encoder-Decoder 结构就自然对齐了不需要魔改架构。实际业务中单看销量数字往往不够还要叠加日期特征。因为销售量天然受星期、月份、节假日影响。我一般会做这样的特征拼接def build_features(df, date_coldate, targetsales): 构造日期相关特征返回特征列名列表 df[weekofyear] df[date_col].dt.isocalendar().week.astype(int) df[month] df[date_col].dt.month df[dayofweek] df[date_col].dt.dayofweek df[dayofmonth] df[date_col].dt.day # 滞后特征过去7天、14天、28天的销量捕捉周周期和月周期 for lag in [7, 14, 28]: df[flag_{lag}] df[target].shift(lag) # 滚动均值过去7天和14天的移动平均平滑短期波动 for window in [7, 14]: df[frolling_mean_{window}] df[target].shift(1).rolling(window).mean() # 滚动标准差反映近期波动程度 df[rolling_std_7] df[target].shift(1).rolling(7).std() return df这段代码逻辑很短但有三个地方值得说清楚。shift(lag)生成的是过去第 lag 天的值shift(1).rolling(window)是先错位一天再算滚动均值防止把当天数据泄漏进特征——这是销售预测新手最容易犯的错。日期特征单独提出来因为 Transformer 毕竟不是时序模型它不认识「今天是星期三」所有信息都得通过特征向量喂进去。滞后特征和滚动特征则把销量序列自身的历史统计信息显式注入让模型更容易学到周期规律。2.2 滑窗切分从长序列到固定长度的样本对数据清洗好之后需要用滑窗把长序列切成一个个输入输出对。这一步决定了训练样本的形态一个样本 连续 L 天的特征序列 接下来的 H 天销量序列。L 和 H 的选择直接影响模型能「看到」多远。做日粒度预测时我用 L568周H14两周这样既有足够的周周期参照样本量也不会太少。def create_sequences(features, target, seq_len56, pred_len14): 构建滑窗样本 features: (n_samples, n_features) 的数组 target: (n_samples,) 的销量数组 X, Y [], [] for i in range(len(features) - seq_len - pred_len 1): x features[i : i seq_len] # 输入过去56天的特征 y target[i seq_len : i seq_len pred_len] # 标签未来14天的销量 X.append(x) Y.append(y) return np.array(X), np.array(Y)注意这里的target是原始销量值而不是特征里的滞后列。因为滞后列本身也是从销量构造的如果再把销量直接拼进特征会重复。训练时模型目标是回归这 14 个连续数值损失函数算的是预测值和真实值之间的差距。特征数组里既有销量类特征又有日期特征所以输入通道数不是 1而是特征列总数。序列切分时还要留一个心眼样本之间有高度重叠。第 i 个样本和第 i1 个样本共享了 55 天的输入这会让训练集看起来比实际「更大」验证集评估时容易出现假性的连续性。我通常会在切分后打乱训练样本验证集和测试集保持时间顺序不动。2.3 标准化MinMax 还是 StandardScaler应用位置有讲究销售量数据有两个显著特点数值可能跨度很大日常 50 件大促 2000 件且一定非负。如果不做标准化模型会很长时间不收敛或者为了拟合大促峰值而牺牲日常预测精度。常见做法是先用np.log1p压缩数值尺度再做 MinMax 归一化到 [0,1]这样既能保留零值又能拉平量级。from sklearn.preprocessing import MinMaxScaler import numpy as np def preprocess_target(series): 对销量序列做 log1p MinMax 归一化 log_series np.log1p(series.values.reshape(-1, 1)) scaler MinMaxScaler(feature_range(0, 1)) scaled scaler.fit_transform(log_series) return scaled.flatten(), scaler逻辑说明很简单log1p是log(x1)好处是 x0 时结果也是 0正好处理销量可能为 0 的日期MinMax 再把它压到 [0,1]。但关键问题不是公式而是fit的位置。这里只能对训练集的销量做fit_transform验证集和测试集要用同一个 scaler 做transform。如果对全量数据先 fit 再切分验证集的信息已经在标准化参数的均值和最大值里了这叫标签泄漏会让评估结果虚高。特征列的标准化同样只 fit 训练集。这个原则往后每一步都要守住后面第 5 章的避坑清单里会再回到这里。3. 从 NLP 到销量预测Transformer 模型怎么改才不别扭3.1 时间序列版 Transformer 的去留去掉 Embedding保留位置编码和 MaskPyTorch 的nn.Transformer模块可以直接拿来用但直接套 NLP 版本会出问题。NLP 里有 token embedding 把离散词映射成向量销售数据已经是连续数值向量不需要这层NLP 的输入是整数索引销售数据是浮点数特征。所以时间序列 Transformer 的改造重点有三个去掉 token embedding 层、保留位置编码、在 Decoder 里用因果 mask 防止信息泄漏。位置编码是必须保留的。Transformer 结构本身没有顺序概念如果只把每天的特征直接送进去模型看到的是「一堆无序日期的集合」。原始论文里的正弦位置编码最简单不需要训练且能泛化到比训练集更长的序列import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len1000): 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) pe pe.unsqueeze(0).transpose(0, 1) # (max_len, 1, d_model) self.register_buffer(pe, pe) def forward(self, x): # x: (seq_len, batch, d_model) return x self.pe[:x.size(0)]说明两点register_buffer让位置编码参与模型存储迁移但不参与梯度更新div_term里用arange(0, d_model, 2)是保证偶数和奇数维度各占一半跟原始论文一致。max_len 设成 1000 是为了防止推理时序列比训练时的 56 还长导致索引越界实际上没人会用 1000 天的历史去预测销量这个值是安全余量。3.2 把 PyTorch 内置 Transformer 改造成销量预测器PyTorch 的nn.Transformer封装了 Encoder 和 Decoder 的完整逻辑我们需要做的只是告诉它输入输出维度以及怎么处理 mask。这里有一个容易踩的坑nn.Transformer默认的 batch 维度在中间即输入形状是(seq_len, batch, features)很多人写惯了batch_firstTrue的模型会在这里反复碰壁。class SalesTransformer(nn.Module): def __init__(self, n_features, d_model128, nhead8, num_encoder_layers3, num_decoder_layers3, dim_feedforward256, dropout0.1, pred_len14): super().__init__() self.d_model d_model self.pred_len pred_len # 输入特征维度 - d_model 的投影层 self.input_proj nn.Linear(n_features, d_model) # 位置编码 Transformer 主干 self.pos_encoder PositionalEncoding(d_model) self.transformer nn.Transformer( d_modeld_model, nheadnhead, num_encoder_layersnum_encoder_layers, num_decoder_layersnum_decoder_layers, dim_feedforwarddim_feedforward, dropoutdropout, batch_firstFalse ) # 输出投影d_model - 1 self.output_proj nn.Linear(d_model, 1) self._init_weights() def _init_weights(self): for p in self.parameters(): if p.dim() 1: nn.init.xavier_uniform_(p) def forward(self, src, tgt, src_maskNone, tgt_maskNone): # src: (seq_len, batch, n_features) # tgt: (pred_len, batch, n_features) 或 (pred_len, batch) src self.input_proj(src) * math.sqrt(self.d_model) src self.pos_encoder(src) tgt self.input_proj(tgt) * math.sqrt(self.d_model) tgt self.pos_encoder(tgt) output self.transformer(src, tgt, src_masksrc_mask, tgt_masktgt_mask) # output: (pred_len, batch, d_model) return self.output_proj(output).squeeze(-1) # (pred_len, batch, 1) - (pred_len, batch)参数怎么选直接给出一组我常用的默认值d_model128nhead83 层 Encoder、3 层 Decoder。理由如下。d_model128对销售数据这种几十维的特征已经足够不需要像 NLP 那样用 512 或 768nhead8是d_model能整除且经验上效果稳定的选择层数 3 是权衡点——层数越多能建模更复杂的关系但销售数据的样本量和特征复杂度撑不起 6 层容易过拟合。dim_feedforward256是d_model的两倍这是 Transformer 的常见做法不是硬性规定。训练时 src 和 tgt 都输入「特征序列」但注意 Decoder 的目标序列在训练和推理阶段语义不同训练时 tgt 是未来 14 天真实的特征序列推理时 tgt 是模型自己预测出的值循环拼出来的序列。这引出了 mask 的设计——训练时 Decoder 必须要 mask 掉未来信息否则模型看一眼真实答案就直接抄测试时一无所知。3.3 因果 Mask 的正确打开方式训练和推理各一套nn.Transformer的 Decoder 默认不做 mask需要显式传入。构造方法非常简单def generate_square_subsequent_mask(sz): 生成上三角为 -inf 的 mask屏蔽未来位置 mask torch.triu(torch.ones(sz, sz) * float(-inf), diagonal1) return masktorch.triu保留矩阵上三角部分diagonal1从主对角线上一行开始置为 -inf这样第 i 个位置只能看到 0 到 i 的位置看不到 i1 和之后的。注意 mask 的形状是(sz, sz)其中 sz 是目标序列长度也就是 future window 的大小 14。Encoder 不需要 mask因为输入的历史序列没有「未来信息」的说法全部可见。推理时 Decoder 的 tgt 序列是逐步生成的先放入第一天的「起始符」——在销售预测场景里我通常直接拿历史序列最后一天的值拼一个起始向量然后预测第 1 天把预测出的第 1 天拼进 tgt再预测第 2 天循环直到第 14 天。推理时 mask 照样要带上只是每次的 sz 从 1 逐渐增大到 14。4. 训练、评估和 Matplotlib 可视化从损失曲线到预测对比图4.1 训练流程与损失函数为什么我用 Huber 而不是 MSE销售量数据的标签分布偏态严重大多数日期销量平稳个别日期销量激增。如果用 MSE那些大促日期的误差会主导梯度模型变成「只为讨好大促而活着」。Huber Loss 在误差小时像 MSE误差大时像 MAE既保留了对小误差的敏感度又削弱了大误差的冲击。我的习惯是 loss 直接选nn.HuberLoss(delta1.0)。训练循环是标准的 PyTorch 流程但有两个设置需要单独说明学习率用 warmup 线性衰减batch 内序列按时间顺序排列而不是随机打乱。Transformer 对学习率极其敏感固定学习率很容易要么不收敛要么震荡。import torch.optim as optim from torch.optim.lr_scheduler import LambdaLR def train_model(model, train_loader, val_loader, epochs30, lr1e-3, warmup_steps1000): device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) criterion nn.HuberLoss(delta1.0) optimizer optim.Adam(model.parameters(), lrlr) def lr_lambda(step): if step warmup_steps: return step / warmup_steps return 0.5 * (1 math.cos(math.pi * (step - warmup_steps) / (epochs * len(train_loader) - warmup_steps))) scheduler LambdaLR(optimizer, lr_lambdalr_lambda) for epoch in range(epochs): model.train() total_loss 0.0 for batch_idx, (src, tgt_in, tgt_out) in enumerate(train_loader): src src.permute(1, 0, 2).to(device) # (seq_len, batch, n_features) tgt_in tgt_in.permute(1, 0, 2).to(device) tgt_out tgt_out.permute(1, 0).to(device) # (pred_len, batch) tgt_mask generate_square_subsequent_mask(tgt_in.size(0)).to(device) optimizer.zero_grad() output model(src, tgt_in, tgt_masktgt_mask) loss criterion(output, tgt_out) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() val_loss evaluate_model(model, val_loader, criterion) print(fEpoch {epoch1}/{epochs}, Train Loss: {total_loss / len(train_loader):.4f}, Val Loss: {val_loss:.4f})逐行说明这段代码的含义。src.permute(1, 0, 2)是因为 PyTorch 的 DataLoader 默认 batch 维度在 0而nn.Transformer要求序列维度在 0permute 之后才是模型真正需要的形状。tgt_mask每次重新生成是因为不同的 batch 里 src 和 tgt 长度一致但不能复用同一个 mask 对象否则图计算会出问题。clip_grad_norm对 Transformer 几乎是必需品——自注意力里梯度爆炸非常常见clip 到 1.0 是经验值太小会让模型学得慢太大会让训练不稳定。warmup 的作用是把学习率从 0 逐步升到设定值避免模型在更新初期就大步走错方向。销量预测模型通常几十个 epoch 就能看到明显的收敛趋势如果 10 个 epoch 内损失纹丝不动先检查标准化是否做对再调学习率不要一上来就堆层数。4.2 用 Matplotlib 画三张图训练曲线、预测对比和误差分布画图是看不出来对错的insight 都藏在可视化之后。第一张图是训练和验证损失随 epoch 的变化用来判断过拟合和收敛状态第二张图是测试集上真实销量和预测销量的对比这是给业务同事看的核心产出第三张图是预测误差的分布辅助定位误差集中于哪些日期模式。import matplotlib.pyplot as plt def plot_training_curve(train_losses, val_losses): plt.figure(figsize(10, 5)) plt.plot(train_losses, labelTrain Loss, linewidth2) plt.plot(val_losses, labelVal Loss, linewidth2) plt.xlabel(Epoch) plt.ylabel(Huber Loss) plt.title(Training and Validation Loss) plt.legend() plt.grid(True, alpha0.3) plt.tight_layout() plt.savefig(training_curve.png, dpi150) plt.show()这张图真正要看的不是绝对值而是两条曲线的相对走势。训练损失一路下降、验证损失先降后升说明模型在背训练集而不是在学规律此时需要加大 dropout 或减少层数。两者同步下降且最终接近是理想状态。如果验证损失整个训练过程都在高位震荡检查前面 2.1 节的特征构建——日期特征缺失是最常见的原因。第二张图把测试集上连续一段时间的真实值和预测值画在一起def plot_prediction_vs_actual(actual, predicted, datesNone, titleSales Prediction): plt.figure(figsize(14, 5)) plt.plot(dates, actual, labelActual, color#1f77b4, linewidth2) plt.plot(dates, predicted, labelPredicted, color#ff7f0e, linewidth2, linestyle--) plt.xlabel(Date) plt.ylabel(Sales Volume) plt.title(title) plt.legend() plt.grid(True, alpha0.3) plt.tight_layout() plt.savefig(prediction_vs_actual.png, dpi150) plt.show()观察这张图的技巧不要只看曲线平均贴合要看波峰和波谷的延迟。如果预测曲线比真实曲线「晚半拍」说明模型主要依赖滞后特征而不是周期特征此时增加dayofweek和weekofyear的权重、减少lag_28的影响会有帮助。如果预测曲线在节假日附近突然失灵单独增加节假日特征列比让模型自己从日期里猜效果好得多。第三张图是误差分布def plot_error_distribution(errors): plt.figure(figsize(8, 5)) plt.hist(errors, bins30, alpha0.7, color#2ca02c, edgecolorwhite) plt.xlabel(Prediction Error) plt.ylabel(Frequency) plt.title(Error Distribution on Test Set) plt.axvline(x0, colorred, linestyle--, linewidth2) plt.tight_layout() plt.savefig(error_distribution.png, dpi150) plt.show()误差分布图中如果直方图整体左偏或右偏说明模型存在系统性偏差——真实销量普遍高于或低于预测值。此时不要增加模型复杂度先检查是不是逆归一化环节算错了或者测试集和训练集的销量分布本身差异很大比如测试期撞上大促。误差在零附近对称展开是最健康的形态。5. Transformer 销量预测避坑清单数据泄漏、负销量和冻住的损失5.1 验证集表现很好测试集第一天就开始「抄答案」现象验证损失很低但查看测试集前几天的预测值时预测曲线几乎是真实曲线的平移——形状一致但整体偏移。原因构造特征时把当天的销量或未来信息混进了输入特征比如rolling_mean没有先 shift 就直接用了当天的真实销量。解决检查特征构造里所有派生列凡是用到 t 时刻真实值的都必须shift至少一期。更隐蔽的泄漏来自标准化如果 scaler 在包含测试数据的数据集上 fit 过测试集的信息会通过 min/max 参数泄进训练。严格保证 scaler 只从训练集 fit验证集和测试集只 transform。5.2 逆归一化之后预测销量出现负数现象预测值整体偏低小部分日期直接变成负数业务侧完全不能接受。原因训练时模型输出没有经过任何非负约束Huber Loss 不会惩罚负预测本身逆归一化后自然可能出现负值。解决训练阶段把模型输出接一个ReLU或Softplus至少保证输出非负。我的习惯是output F.softplus(output)Softplus 比 ReLU 平滑梯度不会在零处截断。逆归一化的顺序也要检查先反 MinMax再反 log1p——反过来算会产生错误的数值。5.3 训练损失十几个 epoch 几乎不动现象loss 一开始就比较大之后长期横盘过了二十个 epoch 才开始缓慢下降。原因学习率太高导致模型在最优解附近来回震荡或者没有做 warmup初始几步更新直接把参数推到坏区域也可能是d_model和特征维度匹配不合理input_proj层学不动。解决先把学习率降到 3e-4 再跑一遍确认 warmup 参数里有实际生效而不是写了代码忘了在训练循环里调用scheduler.step()。层数堆到 6 之前先试 2 层Transformer 深度对学习率的要求几乎是线性的。5.4 预测结果比简单用历史均值还差现象验证集误差算出来还不如直接拿过去 7 天均值当预测值。原因没有做针对性的 baseline 对比或者特征里周期性信息不足。解决先跑一个 naive baseline用shift(7)的结果做预测如果 Transformer 连这个都打不过不是模型问题是数据问题——最常见的是序列长度 L 太短模型看不到一个完整的销售周期或者测试期和训练期的销售分布差距太大属于分布漂移不是模型能学的范围。先加长 L 到 56 或 84再看效果。5.5 注意力 mask 维度报错IndexError 和 RuntimeError 交替出现现象训练第一个 batch 就报The size of tensor a must match the size of tensor b或 index 越界。原因nn.Transformer对数据形状的要求是(seq_len, batch, d_model)而 DataLoader 裸数据是(batch, seq_len, d_model)忘记 permute 是最常见原因。另一个坑是 tgt_mask 的尺寸必须等于目标序列长度不是等于 batch 大小。解决在喂给模型之前统一permute(1, 0, 2)mask 单独生成后.to(device)。调试时先打印src.shape、tgt.shape、tgt_mask.shape三个值的维度一眼就能定位问题。6. 把预测结果变成业务决策滚动验证和多步预测技巧有了能跑的模型下一步是让它真正服务于补货决策而不是停在「测试集上误差 10%」的实验室结论。我习惯的做法是滚动验证不把测试集一次性全部预测完而是模拟真实业务节奏——每预测完 14 天就把这 14 天的真实值并进历史数据重新训练或微调再预测下一个 14 天。这个流程能暴露模型在连续预测中的误差累积情况也能看出多久需要重新训练一轮。规模化落地时可以把训练和预测封装成独立的服务历史数据每日追加模型每 7 天自动重训一次并重新预测。多步预测策略上要区分两种做法。第一种是直接多步输出也就是模型一次预测未来 14 天本文给出的代码就是这个方案优点是快、推理一次到位缺点是 14 天的输出共享同一套注意力模式误差会在序列内部共振。第二种是递归预测每次只预测第 1 天然后把它拼进输入再预测第 2 天更接近真实业务里「每天滚动预测」的节奏但误差会逐日累积。我的建议是如果你只用于周度补货计划直接多步输出够用如果要做每日的动态调拨用递归预测更稳。递归预测的代码改动很小把 tgt 的构造从「真实值」换成「上一轮输出的预测值」但要注意训练和推理的输入分布会不一致必要时加一点噪声扰动来缓解。参数调优始终要记得 Transformer 的敏感点。nhead必须是d_model的因数dim_feedforward不要超过d_model的 4 倍dropout 在 0.1 到 0.3 之间调。如果测试集误差总在一个区间降不下来先怀疑第 5.4 条提到的分布漂移而不是继续加层数。一个习惯我保持了很久每次跑完模型先把预测结果画出来看一遍再去看指标——指标会骗人曲线图不会。希望帮到你。本文还有配套的精品资源点击获取