简介:一套完整的基于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_len=56, pred_len=28, stride=7): """ 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(axis=1) & (target_data.sum(axis=1) > 0) if mask.sum() == 0: continue X_list.append(window_data[mask]) y_list.append(target_data[mask]) X = np.concatenate(X_list, axis=0) # (total_windows, seq_len) y = np.concatenate(y_list, axis=0) # (total_windows, pred_len) return X, y这里有个细节值得展开:stride设成7而不是1。如果stride=1,样本量会膨胀到几百上千万,训练时间成倍增加,而且相邻窗口高度重叠,模型吃到的信息冗余度太高。stride=7既保证每个序列在不同星期相位上都有样本,又把样本量控制在百万级,跑起来效率和效果兼顾。
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_model=128, nhead=8, num_layers=4, dropout=0.1, seq_len=56, pred_len=28, num_items=30490, num_stores=10, num_categories=3, d_emb_item=16, d_emb_store=8, d_emb_cat=4, ): 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_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, batch_first=True, norm_first=True, # Pre-LN,梯度更稳 ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_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(dim=1) # (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_id=100和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 RMSSE(Root 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模型,lr=1e-3通常都能收敛;但TransformerEncoder里的多头自注意力对学习率的反应非常剧烈。我试过固定lr=1e-3,loss在前500步就开始震荡,偶尔会直接nan;lr=1e-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_model=128,batch_size=64时峰值显存约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.612 | 4小时(CPU) | 特征工程耗时最多 |
| 标准RNN(LSTM 2层) | 0.641 | 2小时(GPU) | 长序列记忆弱 |
| TCN(时序卷积) | 0.628 | 3小时(GPU) | 感受野固定,跨度受限 |
| Transformer(基础版,MSE损失) | 0.638 | 5小时(GPU) | 直接裸训效果一般 |
| Transformer(归一化损失+可学习位置编码) | 0.587 | 6小时(GPU) | 最终版本 |
从结果能看出来,Transformer不是拿来就能碾压一切,它需要针对时间序列任务做适配。一旦把损失函数和位置编码改对,它的效果就能超过包括LightGBM在内的其他模型。而且在疫情导致的分布偏移时间段上,Transformer的预测曲线明显比LightGBM更平滑,不会出现历史销量峰值突然拉高预测值的剧烈抖动。
根据我个人的复现体验,如果要把这套方案用在业务数据上,最值得先改的是特征输入维度——M5只有简单的价格、促销和日历信息,真实业务里往往还有天气、流量、库存等变量,Transformer的输入投影层可以轻松扩展维度,这是它比树模型灵活的地方。想要超越当前效果,下一步可以尝试多序列联合预测(一次输入多序列的拼接表示),把序列之间的相关性建模做得更充分,这和传统单序列预测是截然不同的思路。
本文还有配套的精品资源,点击获取