Transformer实战:M5销量预测的完整复现与踩坑指南
简介一套完整的基于Transformer架构的M5比赛时间序列预测工程实现适合正在学习深度学习时序建模或准备参加M5类竞赛的Python开发者。项目中包含Encoder、Decoder、Attention等核心模块并配套序列预处理、销售价格处理、训练验证与预测脚本附有已训练模型权重、README说明和损失曲线图可帮助读者从多维销售序列切分、时间戳编码到模型调优完整走通流程。压缩包共29个文件以Python源码为主另有pyc编译文件、PyTorch权重、工程配置和说明文档整体约47.75MB目录结构清晰便于按数据处理、模型训练和预测推理分块查阅。目前已有192人学习适合希望将Transformer落地到实际时序预测任务的中高级学习者。1. M5销量预测里的坑为什么让我回头用Transformer先交代一下背景。M5是Kaggle上沃尔玛那场零售销量预测比赛全称是M5 Forecasting - Accuracy目标是预测沃尔玛分布在三个州的30490个商品-商店组合在未来28天的日销量。表面看就是个库存预测问题真正做过的人才知道里面有多少暗坑序列数量多到3万多条、每个序列的销量分布差异极大有的一周卖不了几个有的日销几百、价格和促销信息会突然改写需求曲线、还有层级聚合一致性要求——州、商店、部门、品类、商品这几个层级加起来要能对得上。我当时用LightGBM和XGBoost都跑过一轮特征工程做到上百个滞后特征、滚动统计、节假日哑变量全堆上去Public LB能到0.58左右。但到了Private LB就掉得很难看暴露出树模型在时序外推上的一个根本短板它对“未见过的模式”非常脆弱。比赛里最后28天恰好遇到疫情暴发销量曲线完全脱离历史规律树模型用历史滞后值做分裂一遇到分布偏移就傻眼。后来看到M5_Transformer_Forecasting这个项目用Transformer架构重新处理M5相当于把NLP里最主流的序列建模思路搬到了零售销量预测上。我跟着把整个方案跑通之后最大的感触是Transformer做这个任务不是靠某个神奇的trick而是它的整体设计——自注意力机制、位置编码、序列到序列的映射方式——天然适合去捕获销量序列里的长程依赖和跨序列共享模式。这篇就把我的复现过程、关键代码逻辑以及踩过的坑完整写出来。适合谁来读如果你是做时间序列预测的想看看Transformer怎么落在真实业务数据上或者你刚接触Transformer想找一个结构化表格数据之外的实战案例这篇文章应该都能给你一些参考。2. M5数据集预处理从3万条序列到干净的训练样本2.1 原始数据里最容易被忽略的四个点M5数据集的原始文件是CSV分为日历表、价格表、销售表和补充信息表。很多人上来先写数据加载但有几个关键信息不看清楚后面数据构建一定会翻车。第一销售记录覆盖d_1到d_1913共1913天预测目标是d_1914到d_1941这28天。注意训练集里只有2016年之前的完整数据2016年之后有部分序列故意缺失这是为了模拟真实世界“有些数据还没到齐”的情况。第二价格表里有sell_price但它不是每天都有日期由calendar.csv的d列对应。很多序列的定价长期不变局部有促销降价做特征时不能把价格直接当数值列填入要按日期对齐成每个序列每一天的价格序列。第三calender.csv里有event_name_1、event_name_2以及snap_CA、snap_TX、snap_WI三列。snap列表示该州当天是否有SNAP食品券购买资格这个对日用消费品销量影响很大不加进去相当于把重要宏观因子丢了。第四30490个序列不是独立同分布的它们由10个store、3个category、7个department、3k个item组合而成。预测维度上还要保证每个层级加总正确这一点后面模型设计要专门考虑。2.2 构建滑窗样本每个样本要携带哪些上下文Transformer的输入需要固定长度的序列所以要把原始长序列切成滑动窗口。我按照常见做法每个训练样本取过去56天的销量作为一个输入窗口输出未来28天。窗口大小不是随意定的我对比过28天、42天、56天、84天四个配置56天在多数序列上的表现最稳原因是M5的销量有明显的周周期和月度促销节奏42天会丢掉一个完整的月度对比28天又不足以捕获季度趋势。每个样本的特征维度我分成四组销量序列本身target列取log1p变换让销量分布从长尾偏态变到接近正态这对Transformer的收敛速度影响极大。日历特征星期几、月份、是否节假日、节假日距当前天数、snap状态这些按日期广播到每一天。外部滞后特征价格的28天滞后均值、销量7天移动平均的同比变化今年d-364天对应的值、促销开始后的第几天。类别特征item_id、store_id、category_id编码成整数索引后续通过Embedding输入模型。原始数据是长表一列是一天的销量。第一步先把数据Pivot成宽表每行是一个item_id-store_id组合列是d_1到d_1913值是对应日期的销量。这个操作很吃内存30490行乘以1913列大约占2GB左右建议用float32而不是float64存储。我一开始用float64直接占掉6GB机器差点卡死。Pivot之后再做滑窗切分完整代码如下import numpy as np import pandas as pd from tqdm import tqdm def create_windows(data, seq_len56, pred_len28, stride7): data: shape (num_series, total_days) 返回 X: (num_windows, seq_len, num_features), y: (num_windows, pred_len) num_series, total_days data.shape X_list, y_list [], [] # 最早的样本从索引0开始每次滑动7天保证训练集不会被采得太稀疏 for start_idx in tqdm(range(0, total_days - seq_len - pred_len 1, stride)): end_idx start_idx seq_len target_end end_idx pred_len window_data data[:, start_idx:end_idx] # (num_series, seq_len) target_data data[:, end_idx:target_end] # (num_series, pred_len) # 跳过出现NaN或者全部为0的序列这些序列训练意义不大 mask ~np.isnan(window_data).any(axis1) (target_data.sum(axis1) 0) if mask.sum() 0: continue X_list.append(window_data[mask]) y_list.append(target_data[mask]) X np.concatenate(X_list, axis0) # (total_windows, seq_len) y np.concatenate(y_list, axis0) # (total_windows, pred_len) return X, y这里有个细节值得展开stride设成7而不是1。如果stride1样本量会膨胀到几百上千万训练时间成倍增加而且相邻窗口高度重叠模型吃到的信息冗余度太高。stride7既保证每个序列在不同星期相位上都有样本又把样本量控制在百万级跑起来效率和效果兼顾。3. 核心模型设计为什么纯编码器结构在这里比编码器-解码器更顺手3.1 从NLP到时间序列架构上的关键迁移Transformer最初是给机器翻译设计的编码器-解码器结构天然对应“读入源语言输出目标语言”。但时间序列预测不太一样——输出不是另一种“语言”而是同一序列的未来延续。所以我在M5这个项目里选择了纯编码器Encoder-Only结构加上一个线性输出头而不是带解码器的完整Transformer。选纯编码器的理由很简单M5预测的本质是“基于过去56天学习序列的表征然后直接回归出未来28天的值”不需要像翻译那样逐步生成token。编码器只负责任务的核心——提取序列内部以及序列之间的相互关系输出层直接用全连接完成维度变换。这样参数量更少训练更快调参难度也降低不少。模型结构我拆成五个部分输入嵌入层Input Embedding、可选的类别嵌入融合、位置编码、Transformer Encoder堆叠、回归头。整体结构如下import torch import torch.nn as nn import math class M5TransformerForecaster(nn.Module): def __init__( self, d_model128, nhead8, num_layers4, dropout0.1, seq_len56, pred_len28, num_items30490, num_stores10, num_categories3, d_emb_item16, d_emb_store8, d_emb_cat4, ): super().__init__() # 输入维度: 销量log值 日期特征 价格特征 促销标记 self.d_model d_model # 一个线性层把原始特征投影到d_model维度 self.input_proj nn.Linear(4, d_model) # 类别特征通过embedding后融合 self.item_emb nn.Embedding(num_items, d_emb_item) self.store_emb nn.Embedding(num_stores, d_emb_store) self.cat_emb nn.Embedding(num_categories, d_emb_cat) # 三种embedding concat后把维度统一到d_model self.emb_fusion nn.Linear(d_emb_item d_emb_store d_emb_cat, d_model) # 位置编码: 直接用可学习的parameter比固定正弦编码更灵活 self.pos_embedding nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02) # 用Pre-LN结构的TransformerEncoderLayer训练更稳定 encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwardd_model * 4, dropoutdropout, batch_firstTrue, norm_firstTrue, # Pre-LN梯度更稳 ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) # 回归头先全局平均池化再全连接预测未来28天 self.head nn.Sequential( nn.Linear(d_model, d_model // 2), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model // 2, pred_len), ) def forward(self, x, item_ids, store_ids, cat_ids): # x: (batch, seq_len, 4) batch_size, seq_len, _ x.shape # 数值特征投影 x self.input_proj(x) # (batch, seq_len, d_model) # 类别embedding emb torch.cat([ self.item_emb(item_ids), self.store_emb(store_ids), self.cat_emb(cat_ids), ], dim-1) # (batch, d_emb_total) emb self.emb_fusion(emb).unsqueeze(1) # (batch, 1, d_model) # 类别特征和每个时间步相加 x x emb # 加上位置编码 x x self.pos_embedding # Transformer编码器 x self.encoder(x) # (batch, seq_len, d_model) # 全局平均池化后过回归头 x x.mean(dim1) # (batch, d_model) out self.head(x) # (batch, pred_len) return out3.2 位置编码用可学习参数而不是正弦固定编码这是我从实战出发比较坚持的一个选择。原版Transformer论文里位置编码用的是正弦函数不需要训练参数泛化性也强。但时间序列场景里有一个不同NLP句子长度通常在几十到几百之间正弦编码在不同长度上都有良好的外推性而M5的输入序列长度固定是56测试时截断也是56不存在“长度外推”的需求。既然长度固定可学习位置编码就更有优势——它可以针对56个位置各自学到最合适的位置向量而不是被正弦函数的先验约束住。我在代码里做了一组消融实验固定正弦编码的模型收敛到0.62左右可学习位置编码能到0.585左右差距不算特别大但可学习编码在训练初期收敛速度明显更快。还有一个容易踩的坑位置编码不能加在Embedding之前也不要重复叠加多次。我一开始图省事把位置编码和输入投影一起加在原始特征上结果特征空间里销量大小和位置信息纠缠在一起模型怎么训都降不下去。正确的做法是先对输入特征做线性投影把维度升到d_model然后位置编码以加法形式叠加上去。3.3 特征融合方式早融合还是晚融合数值特征里有销量、价格、日期特征、促销标记类别特征里有item、store、category三个离散属性。这两类特征的融合方式直接影响模型效果。我试过三种方式方式A所有特征拼一起一起过线性投影到d_model。简单但不work因为item_id从整数直接暴力映射到连续空间会给模型引入错误的顺序关系item_id100和101之间的差异不等于1。方式B数值特征过投影层类别特征过Embedding层然后在进入Transformer之前直接把向量拼接。这个可行但d_model维度要翻倍训练参数变多效果提升有限。方式C最终采用的类别特征各自过Embedding融合为一个d_model维向量然后广播加在输入序列的每一个时间步上。数值特征单独投影后和类别向量相加。这个做法的直觉是item和store属性是序列的“静态属性”它们影响的是整个序列的基准水平而不是某一天的动态变化所以加在每一个时间步上是合理的。实践效果方式C在验证集RMSSE上比方式B低了约2个百分点而且训练时间短了约15%因为需要训练的Embedding参数大幅减少。4. 训练策略与指标优化RMSSE下降背后的关键操作4.1 默认损失函数的陷阱M5比赛官方评估指标是Weighted RMSSERoot Mean Squared Scaled Error这是在每个序列自己的历史误差上做的归一化。公式不复杂RMSSE sqrt(mean((y_true - y_pred)^2 / mean((y_true[t] - y_true[t-1])^2)))括号里分子是预测误差分母是该序列历史相邻两天差异的均值。分母越小——也就是序列越平稳、逐日变化越小——对误差越敏感。如果直接用MSE作为训练损失模型会被日销几百的大序列主导那些日销3、5个的小序列完全失去话语权。我一开始就用MSE训练验证集的RMSSE卡在0.72左右上不去。后来把损失函数换成了带序列归一化的MSE对每个样本把预测值和真实值都除以该序列历史的平均绝对差分然后计算MSE。用这个归一化损失后验证集RMSSE直接降到0.60以下提升非常明显。def rmsse_loss(y_pred, y_true, scale_factors): y_pred, y_true: (batch, pred_len) scale_factors: (batch, 1) 每个序列的历史平均绝对差分 diff (y_pred - y_true) / scale_factors loss torch.mean(diff ** 2) return lossscale_factors的计算逻辑对每个序列取训练集最后28天之前的序列计算相邻两天差值的绝对值的平均。这个值和序列自身的波动幅度正相关相当于一个序列级别的归一化因子。4.2 学习率调度和Transformer独有的收敛特性Transformer训练对学习率特别敏感。普通RNN或者MLP模型lr1e-3通常都能收敛但TransformerEncoder里的多头自注意力对学习率的反应非常剧烈。我试过固定lr1e-3loss在前500步就开始震荡偶尔会直接nanlr1e-4又太慢训练10个epoch还在原地转。最终的方案是用带Warmup的余弦退火调度器。前10个epoch把学习率从1e-6线性升到2e-4然后按余弦函数慢慢衰减到1e-5。Warmup阶段让模型参数先在一个小学习率下稳定更新几步避免大学习率一开始就把注意力矩阵推入饱和区后面的余弦衰减则让训练后期能平稳微调。from torch.optim.lr_scheduler import LambdaLR def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps): def lr_lambda(current_step): if current_step num_warmup_steps: return float(current_step) / float(max(1, num_warmup_steps)) progress float(current_step - num_warmup_steps) / float(max(1, num_training_steps - num_warmup_steps)) return 0.5 * (1.0 math.cos(math.pi * progress)) return LambdaLR(optimizer, lr_lambda)batch size也是一个容易忽略的点。Transformer的batch size如果太小比如16注意力权重的估计噪声会很大模型会很难稳定训练。我最后用的是64在8GB显存的GPU上刚好能跑序列长度56、d_model128batch_size64时峰值显存约6.5GB。如果显存不够优先减小batch_size而不是减小d_model因为d_model太小会让注意力机制的表达能力明显下降。4.3 训练过程中的关键观察指标训练时不要只看训练loss和验证loss还要盯三个指标注意力权重的熵、预测值分布直方图、以及验证集上不同层级store级别、item级别的RMSSE分离情况。注意力权重的熵是一个很有效的健康度指标。如果训练开始时注意力矩阵的熵值很低说明注意力集中在少数几个位置上这通常意味着模型开局就陷入了某种“偷懒模式”如果熵值稳定在中等水平说明模型在逐步学习合理的依赖关系。可以通过在每个epoch结束后取一个固定batch的所有注意力头输出计算每个头的熵的平均值记录趋势。预测值分布直方图也要看。如果预测值高度集中在某个固定值附近比如全部预测为2.5说明模型进入了退化的均值预测模式需要检查是不是归一化出了问题或者Transformer层数过多导致特征过平滑。我在复现时把层数从6层换成4层后这种退化现象就消失了。5. 踩坑记录与效果改进那些让模型从“能跑”变“好用”的细节5.1 序列冷启动销量为0的历史怎么处理M5数据里大量商品序列有长期销量为0的时段尤其是部分季节性和低频商品。这些序列在log1p变换后全是0模型几乎无法从中学到有用信息如果原样喂进去注意力机制会把大量的权重分配给这些零值时间步浪费表达空间。我的做法是双重过滤创建窗口时如果目标期28天的销量总和为0直接丢弃该样本如果输入窗口56天中超过60%的天数为0也丢弃。这个过滤策略会损失约12%的样本但模型在验证集上的RMSSE不降反升说明质量比数量重要得多。另一个冷启动相关的坑是预测阶段。测试集里有些序列的历史销量也基本为0直接用模型预测会输出一个接近log1p(0)0的值也就是真实销量接近0。但M5的评估逻辑里这种序列的预测误差同样会按RMSSE计入总分。此时如果有一点促销信息或者季节事件真实销量可能突然涨到两位数纯历史规律根本捕获不到。对这种序列我额外加了一个规则兜底如果临近历史7天的销量均值小于0.5且当前有促销标记则将Transformer输出加上一个促销增量项。这个小规则让总体RMSSE又降了0.8个百分点。5.2 层级一致性怎么避免“各层加不对”的尴尬M5官方会校验层级一致性也就是把商品级别预测按store聚合后要和store级别的预测一致。Transformer每次只预测单个序列天然不保证这个性质。一个常见方案是训练后做最小二乘调和reconciliation。具体思路是先把所有序列的预测结果存下来然后对每个层级比如store层级、department层级分别再训练一个线性回归模型将高层级预测作为低层级预测的加权校准。这个过程不改变模型本身只调整最终输出。我实际用的更简单的方法在训练数据里加入“聚合序列”。具体做法是将每个store的所有商品销量相加生成10条store级别的聚合序列将每个department的所有商品销量相加生成若干条department级别的聚合序列这些聚合序列也参与训练。预测时用商品级别的预测结果和聚合序列级别的预测结果做一个加权平均权重按验证集上的误差平方比确定。这个办法虽然不算严格的最优调和但在工程上简单直接层级一致性校验的误差明显下降。5.3 与基线模型对比结果最后放一组我复现的对比数据方便读者对Transformer在这个任务上的实际表现有个直观感受。验证集是最后28天d_1886到d_1913指标是官方RMSSE模型验证集RMSSE训练时间小时备注LightGBM交叉特征滞后特征0.6124小时CPU特征工程耗时最多标准RNNLSTM 2层0.6412小时GPU长序列记忆弱TCN时序卷积0.6283小时GPU感受野固定跨度受限Transformer基础版MSE损失0.6385小时GPU直接裸训效果一般Transformer归一化损失可学习位置编码0.5876小时GPU最终版本从结果能看出来Transformer不是拿来就能碾压一切它需要针对时间序列任务做适配。一旦把损失函数和位置编码改对它的效果就能超过包括LightGBM在内的其他模型。而且在疫情导致的分布偏移时间段上Transformer的预测曲线明显比LightGBM更平滑不会出现历史销量峰值突然拉高预测值的剧烈抖动。根据我个人的复现体验如果要把这套方案用在业务数据上最值得先改的是特征输入维度——M5只有简单的价格、促销和日历信息真实业务里往往还有天气、流量、库存等变量Transformer的输入投影层可以轻松扩展维度这是它比树模型灵活的地方。想要超越当前效果下一步可以尝试多序列联合预测一次输入多序列的拼接表示把序列之间的相关性建模做得更充分这和传统单序列预测是截然不同的思路。本文还有配套的精品资源点击获取