简介:面向时间序列竞赛与深度学习进阶者的 Python 实现方案,完整展示了如何用 Transformer 架构处理 M5 比赛的多维销售数据。从数据预处理、序列构造、模型搭建到训练验证与批量预测均有覆盖,适合希望掌握注意力机制在时间序列中应用的读者。压缩包共 29 个文件,包含 12 个 Python 脚本、6 个 pyc 编译文件、4 个项目配置 xml、1 个模型权重文件、1 个说明文档及 loss 曲线图等,整体约 47.75MB。代码按注意力层、编码器、解码器、辅助运算、训练流程、序列处理等模块拆分,目录结构清晰,便于按需查阅。已有 194 人学习下载。从这份资源中可获取完整赛题方案:既有自行实现的 Transformer 层模块,也有训练、测试、预测脚本与训练好的模型权重,配合说明文档能快速复现实验;同时可学习针对 M5 多序列销售数据的时间戳编码、多通道展开与批量预测等实用设计。
1. 用Transformer预测M5销量:先想清楚再动手
M5比赛数据集在销量预测圈子里几乎是绕不开的试金石:层级多、序列长、价格在评测期直接消失,任何模型到了这里都会显形。我见过不少把Transformer搬上去的团队,第一轮就吃了败仗,误差不降反升,最后灰溜溜退回树模型。问题从来不在架构,而在方案——数据怎么聚合、目标怎么构造、预测怎么输出,每一步都决定了Transformer是发挥并行注意力的优势,还是变成一个昂贵的黑匣子。这篇笔记会把这套从数据到参数的路数完整拆开,目标是让没接触过M5的人能照着复现,让被“Transformer跑不起来”坑过的熟手知道问题出在哪。
2. M5数据集结构:为什么Transformer在这里既可能很强、也可能翻车
2.1 M5的数据层级与评测逻辑
M5数据集来自一个大型零售平台的真实销售记录,时间跨度约五年,覆盖三个区域、十个店铺、三千多个部门、三万多条单品序列。评测任务不是单序列预测,而是层级预测:给定历史销售数据,要预测未来二十八天每一天的销量,并且误差会在每个层级上分别计算,再按销量占比加权汇总。
这个设计决定了你不能只盯着单条序列看。顶层聚合序列相对平滑,底层单品序列则充斥着零值、弱趋势和突发波动。数据文件通常拆成销售表、日历表、价格表三类,销售表记录每一天每个单品的销量,日历表提供日期属性和事件标记,价格表则在训练期可见、在评测期完全缺失。这个价格缺口是M5最阴险的设计之一,很多队伍训练时用着价格特征精度很高,评测时一上线就崩。
处理M5的第一步动作通常是合并三张表再按层级聚合。我一般会在原始粒度上把销售数据与日历、价格对齐,然后分别生成总-区域-店铺-部门-单品五层聚合结果。每一层都保留完整的日期索引,方便后续滑窗时按同一套时间轴切分。如果你只想快速验证模型,跑三个区域汇总的顶层和中层数据就够了,不必一上来就全量跑三万多条单品。
2.2 Transformer的强项与M5的场景是否匹配
Transformer的核心机制是自注意力,它能把序列里任意两个位置直接建立关联,长距离依赖的建模能力天然强于RNN。在M5这个场景里,这个特性有时是优势,有时是错觉。直观来说,销售序列里的长期依赖往往表现为年度季节性,二十八天的预测窗口并不算长,LSTM理论上也能覆盖,但Transformer的并行训练速度和灵活的多协变量融合能力是实打实的优势。
真正让Transformer在M5上翻车的不是注意力机制本身,而是对输入数据的处理方式。单品序列里大量零值和极端促销波动会扰乱注意力分配,如果不做聚合增强或序列归一化,模型学到的权重会偏向那些波动大的时间点。另一个常见误区是直接把三万多条序列丢进去训练,注意力矩阵和批处理复杂度都会爆炸,训练时间变得不可接受,效果却未必优于按层级分组训练。
我的经验结论是:Transformer不是用来替换树模型,而是用来替换LSTM的序列建模层。你仍然可以用特征工程和树模型做基线,把Transformer方案当作一个能同时输出多个层级预测的统一模型。当你有足够多的历史序列、需要按外部事件对齐多变量信息时,Transformer才真正值得投入。
2.3 三种结构选型:自回归、非自回归、混合
用Transformer做销量预测,结构上大致有三条路线。
第一条是标准自回归Decoder结构,把前一天的预测值当作下一天的输入,逐位生成未来二十八天。这条路在长文本生成里很有效,但在销量预测里是隐患:每一轮预测都会累积误差,早一步预测偏了,后面的结果跟着整体漂移;而且训练时要用Teacher Forcing,推理时却面临完全不同的输入分布,模型很容易在这两者之间找不到平衡点。
第二条是我个人在M5上最常用、也最推荐的结构——非自回归输出。编码器读入历史九十天序列,解码器不再逐位预测,而是把未来二十八天对应的日期特征和事件标记作为输入,一次性输出整段预测。这样做的好处是计算快、结构简单、不会误差累积,同时因为解码器的输入是未来时间段的确定性特征而非模型预测值,训练和推理的行为是一致的。解码器的输入可以理解为“告诉模型未来这段时间有哪些特殊日子”,注意力机制负责在历史里找相似年份、相似事件对应的销售模式。
第三条路线是把Transformer当作特征抽取器,池化历史序列的隐状态,再拼接外部特征交给树模型输出预测。这条路如果你已经在现有系统里重度依赖树模型,迁移成本最低。常见做法是把Transformer编码器输出的最后一层向量当作序列摘要特征,替代手工统计特征。缺点是推理链路变长,调试起来麻烦,一般适合已有成熟特征管线的团队。
3. 数据预处理与滑窗构造:把五年历史切成模型能吃的样本
3.1 三张表的合并与层级聚合
数据预处理是整个流程里最不性感但决定成败的一步。我通常会把销售表、日历表、价格表先合并成一张完整的长表,再做层级聚合。下面这段代码描述的是合并和顶层聚合的常见做法。
import pandas as pd sales = pd.read_csv("sales_train.csv") # 日期 x 单品id的销售宽表 calendar = pd.read_csv("calendar.csv") # 日期、事件、星期属性 prices = pd.read_csv("sell_prices.csv") # 单品在不同周的售价 # 把宽表面转为长表:一列是日期,一列是商品,一列是销量 sales_long = sales.melt( id_vars=["item_id", "store_id"], var_name="date", value_name="sales" ) # 合并日期属性和价格,注意价格用前向填充补齐每日价格 sales_long = sales_long.merge(calendar, on="date", how="left") sales_long = sales_long.merge(prices, on=["item_id", "store_id", "wm_yr_wk"], how="left") sales_long["price"] = sales_long.groupby(["item_id", "store_id"])["price"].ffill() # 聚合到区域层级,方便后续快速验证 agg_state = sales_long.groupby(["date", "state_id"]).agg( sales_sum=("sales", "sum"), price_avg=("price", "mean"), event1=("event_type_1", "first"), ).reset_index()这段代码有几个关键点。宽表转长表是第一步,不要跳过,因为Transformer的输入是按时间序列组织的,不是按商品矩阵组织的。价格列必须做前向填充,因为原始价格表是周粒度记录,而销售是日粒度,直接合并会产生大量空值。聚合到区域层级只是为了快速跑通流程,正式训练时你可以在部门或店铺层级重复同样的逻辑。
这里还有一个容易犯的错误:如果把价格列直接放进模型,训练时表现很好,评测时会因为价格缺失而报错或产生NA。常见处理策略是只保留价格的统计特征,比如过去四周平均价格、相对平均价格的折扣率,而不是把原始价格序列当作逐日输入。折扣率在未来会缺失,但可以用近期均值填充。
3.2 滑窗样本构造:输入九十天、输出二十八天
Transformer训练需要固定长度的输入输出对。M5的预测目标是未来二十八天,所以输出长度固定为28;输入长度我通常选90天或56天,太短学不到季节模式,太长干扰噪声增多。滑窗步长也要注意:训练时步长可以大一些,验证时步长必须等于预测周期长度,否则验证集的构造会和真实评测不一致。
def create_windows(series, input_len=90, output_len=28, stride=7): windows_x = [] windows_y = [] length = len(series) for start in range(0, length - input_len - output_len + 1, stride): end = start + input_len x = series[start:end] y = series[end:end + output_len] windows_x.append(x) windows_y.append(y) return windows_x, windows_y这个函数看起来简单,但参数选择有讲究。训练阶段stride可以设为7或14,这样能把五年数据切出足够的样本量,又不让相邻样本高度相关。验证阶段stride必须设为28,让窗口恰好从训练期结束位置滑到验证期开始位置。一个典型的坑是训练用stride=1生成大量重叠样本,模型反而过拟合到相邻窗口的细微差别上,泛化能力下降。
如果你要预测多个层级,建议每一层独立调用这个函数,并把同一时间段的样本对齐保存,方便后续做层级一致性后处理。样本数量可以直接估算:五年大约1800天,扣除输入和输出长度后,stride=7大约能生成两百多个样本,单一层级训练数据够用,底层单品建议先聚合到部门层级再跑。
3.3 归一化与特征编码:别让价格和销量打架
Transformer的注意力机制对输入尺度非常敏感。销量序列动辄上千,事件标记是0/1,如果直接拼接,注意力权重会被数值大的维度主导。我一般对连续特征做标准化,对类别特征做嵌入或简单one-hot。这里给出一个常用归一化策略,它和图像领域的batch norm不同,是针对每条序列独立统计的。
def normalize_series(series): mean = series.mean() std = series.std() + 1e-6 return (series - mean) / std, mean, std这条序列级归一化是Transformer时间序列预测里最核心的trick之一。它消掉了不同商品之间的销量尺度差异,让模型学到的是“形状”而不是“绝对值”。注意,统计量必须在训练窗口上计算,然后把同样的均值标准差应用到后续预测结果的反归一化上。如果错误地整条序列统一计算均值,会造成数据泄漏,验证集上看到非常漂亮的分数,真实评测却惨不忍睹。
特征编码我一般会准备四类:日期属性(星期、月份、年),事件标记(事件类型、事件距今天数),价格相关特征(过去四周均价、折扣率),以及历史统计(滞后7/14/28天销量)。事件标记对未来时间点是已知的,因此可以安全地用在解码器端输入里。这是Transformer相对树模型在处理未来特征上非常大的一个便利。
4. 模型实现:搭一个轻量级Transformer预测器
4.1 输入映射与位置编码
Transformer本身没有序列顺序概念,必须把位置信息编码进输入。M5这里的序列是固定九十天的滑窗,我建议位置编码的最大长度设置成训练时窗口长度即可,不需要按整段五年历史编码。位置编码可以选择固定正弦余弦函数,也可以选择可学习位置嵌入,下面用固定位置编码展示,实现最简单。
import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=512): 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(1000.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), :]固定位置编码的好处是推理时长度超出训练长度也不会报错,但M5场景里不需要超出长度。真正要注意的是,编码器的位置编码和未来二十八天的解码器位置编码不能共用一个从头开始的索引,而应该是历史窗口末尾的延续。我通常直接把解码器位置偏移设定为输入窗口长度,模型才能理解“未来”是接在“过去”之后的。
4.2 Encoder-Decoder主体结构
M5场景不需要特别深的Transformer。层数超过四层之后收益递减,反而容易过拟合到训练期的促销波动。我一般用两层Encoder加一层Decoder,隐藏维度128,注意力头数4,这组参数在M5上训练速度和精度比较平衡。
class M5Transformer(nn.Module): def __init__(self, input_dim, d_model=128, nhead=4, num_encoder=2, num_decoder=1, output_len=28): super().__init__() self.input_proj = nn.Linear(input_dim, d_model) self.pos_enc = PositionalEncoding(d_model, max_len=256) encoder_layer = nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward=256, dropout=0.1, batch_first=True) self.encoder = nn.TransformerEncoder(encoder_layer, num_encoder) decoder_layer = nn.TransformerDecoderLayer(d_model, nhead, dim_feedforward=256, dropout=0.1, batch_first=True) self.decoder = nn.TransformerDecoder(decoder_layer, num_decoder) self.output_proj = nn.Linear(d_model, output_len) def forward(self, src, tgt_feats): src_emb = self.input_proj(src) src_emb = self.pos_enc(src_emb) tgt_emb = self.input_proj(tgt_feats) tgt_emb = self.pos_enc(tgt_emb) memory = self.encoder(src_emb) out = self.decoder(tgt_emb, memory) return self.output_proj(out)这段代码里最容易被忽略的设计是tgt_feats。它并不是模型自己生成的前一个预测值,而是未来二十八天的外部特征,包括日期属性和事件标记。模型通过解码器交叉注意力,从历史记忆中筛选与未来时间段相似的销售模式,然后一次性输出预测。我前面强调过,这种非自回归设计是M5比赛方案里被验证最稳定的做法,训练并行度也高。
dim_feedforward设为256,不需要更大。输出层的output_len=28意味着直接从每个token输出一个标量,但这和未来时间点是一一对应的,可以理解为解码器输出的每一个位置对应未来第几天。
4.3 损失函数、Mask与输出形状
M5的销量数据长尾分布严重,直接回归原始销量会导致模型把所有精力放在高销量商品上,中低销量序列被完全忽略。常见做法是用log1p变换压缩尺度,或使用分位数损失直接建模分布。我推荐在训练时用分位数损失,它不仅更贴近M5的评估方式,还能顺带获得预测区间。
def quantile_loss(pred, target, tau=0.5): diff = target - pred loss = torch.maximum(tau * diff, (tau - 1) * diff) return loss.mean()tau=0.5时,这个损失等价于平均绝对误差,相对MSE更抗异常值。如果你想要更好效果,可以同时输出三个分位点,tau分别取0.5、0.1、0.9,用三个并行的输出头实现,最终提交时用0.5分位作为点预测。
batch内如果使用了Mask,要特别注意Transformer的两个Mask:Encoder的padding mask,因为同一batch内不同序列长度一致时可以不设;Decoder的attention mask,非自回归结构不需要因果mask。很多实现直接套用文本Transformer的因果mask,把解码器每个位置限制成只能看前面的位置,这反而打破了“一次性输出28天”的设计初衷。
5. 训练配置与避坑:参数怎么设、哪里容易翻车
5.1 理解WRMSSE评估指标
M5竞赛的官方指标是WRMSSE,全称加权均方根标度误差。它的计算逻辑是:对每个层级、每个序列,把预测误差除以一个基准误差,这个基准误差是历史上最后一天作为naive预测的误差。得到的比值再按层级权重加权。简单理解就是:你的误差比“拿昨天销量当明天预测”这个最笨方法好多少,然后按层级重要性汇总。
这个指标有一个直接后果:低销量序列容易把比值放大,因为它们的历史波动小,任何偏差都会被标度放大。所以训练时别把loss直接设为MSE或MAE,可以按序列销量做权重归一化,让每条序列在平均意义上贡献相同。我自己做的时候用每个序列训练窗口内的平均销量作为缩放因子,效果直观地优于不缩放。
验证集构造逻辑是:把训练数据的最后二十八天当作验证期,前面的历史用于训练。滑窗函数中stride在验证时设为28,就是为了保证验证样本的时间范围与官方评测一致。如果你用stride=1切割验证样本,验证分数会虚高,因为相邻窗口几乎重叠。
5.2 训练超参数:学习率、warmup、早停
训练超参数直接影响最终分数。经过多次实验,我一般按下面这组参数起步,再根据验证集微调。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning rate | 3e-4 | AdamW默认配置 |
| warmup步数 | 2000 | 避免前期不稳定 |
| batch size | 32 | 按GPU显存调整 |
| dropout | 0.1 | 对M5不会欠拟合 |
| 早停patience | 8个epoch | 防止过拟合 |
| 最大epoch | 50 | 一般10-20收敛 |
学习率warmup在Transformer里不是玄学,是保证训练稳定的必要条件。没有warmup时,注意力层的梯度在初期波动极大,容易把模型推向坏的局部最优。warmup步数2000听起来多,但配合batch size 32,也就几十个step的事,代价很小。
损失函数选择上面提过分位数损失。训练时把验证集WRMSSE作为早停依据,每轮结束计算一次。如果模型在验证集上连续多个轮次没有改善,回退到历史最佳参数再评估,然后结束训练。
5.3 五个踩坑记录:现象、原因、解决
第一个坑是价格特征在评测集上直接失效。现象是本地验证表现优秀,提交到评测后误差飙升。原因是价格列在评测期为缺失值,训练用的特征分布和测试完全不匹配。解决方案是只保留价格统计特征,如过去四周均价和折扣率,同时在评测期用训练末期均值填充。
第二个坑是序列级归一化计算泄漏。现象是验证指标好到不真实,换了另一种切分方式后指标大幅恶化。原因是整条序列统一计算均值和方差,包含了未来信息,验证窗口的分布被未来数据污染。解决方案是把归一化的均值方差限定在训练窗口内计算,验证时用同一统计量。
第三个坑是解码器误用因果Mask。现象是模型训练的loss下降很慢,而且预测结果呈现奇怪的滞后性,预测值几乎等于最近几天的移动平均。原因是非自回归解码器被因果Mask限制,每个未来位置只能看到未来的前几个位置,失去了整体信息。解决方案是去掉解码器的因果Mask,让每个位置能看到完整未来特征。
第四个坑是验证集滑窗步长与预测周期不一致。现象是本地验证得分很高,真实评测惨败。原因是验证时用stride=1生成大量重叠窗口,导致验证误差被重叠平滑。解决方案是训练stride=7,验证stride=28,保证验证窗口和真实评测窗口对齐。
第五个坑是模型输出的预测值出现负数。现象是loss没异常,但反归一化后预测销量大量小于零。原因是归一化后的序列经过了线性输出层,模型并不天然知道销量非负。解决方案是在输出后加ReLU或Softplus激活,或者直接对log1p后的目标做回归,再反变换回原尺度。
5.4 一个可度量的基线对照
判断Transformer方案到底值不值的唯一办法是跑基线。我会在同样的数据窗口和特征下,跑一组LightGBM、一组LSTM、一组本文的Transformer。轻量级实验可以在区域层级上进行,每天跑一次,生成未来二十八天预测。
从我自己跑的结果来看,区域层级和店铺层级上Transformer能明显优于LSTM,与调优后的树模型持平;部门层级以下,Transformer和树模型互有胜负,树模型在稀疏序列上略占优势。如果你在底层单品序列上强行上Transformer,训练时间增加数倍,指标却可能不如简单的线性外推。
这类对比实验的最大价值是帮你确定模型投入的边界:Transformer更适合作为统一预测框架,而不是每个层级的最优单模型。后续如果你想追求最高精度,一套Transformer加上树模型集成,再配合层级调和,会比单独压榨某个模型省力得多。
6. 进阶操作:从拿到baseline到稳态方案
当基础模型能稳定跑通后,下一步就不是继续堆模型层数,而是围绕M5特有的几个问题做针对性修补。我的习惯是优先处理层级一致性问题,因为它直接关系到最终加权指标的稳定性。
用Transformer分别预测了顶层、区域层、店铺层之后,你会发现各层预测的数字合不上:所有区域预测值加起来和顶层预测值有明显出入。这个问题叫层级不一致。常见做法是保持底层预测不变,自下而上逐层相加生成高层预测;或者保持顶层预测不变,把高层预测按底层占比拆分到下层。我通常选择后者,因为顶层序列更平滑、预测更准,作为总额约束后按比例分配到下层,能显著压低高层的WRMSSE贡献。
另外一个值得投入的方向是概率输出。M5数据集适合概率预测,这不仅能得到更完整的业务信息,在后续集成时也更有用。给模型加两个额外输出头,分别预测0.1和0.9分位,用分位数损失训练。集成时对多个分位求平均再排序,通常能得到比单模型更稳定的中位数预测。
集成策略上,最简单有效的做法是多个不同种子的模型做均值。注意不要只对最终预测做均值,更好的做法是对编码器输出的隐状态做平均,再喂给解码器。前一种方式只会平滑噪声,后一种方式能保留更完整的注意力信息。
最后说一个我自己吃过亏的地方:第一次完整跑通这个方案时,我非常自信地提交了验证集分数最好的那份模型,忽略了它对价格特征的依赖,结果正式评测直接崩掉。从那以后我养成了一个习惯,训练完第一件事不是看分数,而是检查模型在特征缺失场景下的表现,把价格列强行置空、把事件标记置空,观察验证指标的波动幅度。如果波动大,说明模型过度依赖某个过期特征,趁早修正比事后后悔好得多。
M5数据集值得做,不是因为它的分数多有含金量,而是它逼着你把数据、建模、评估、部署这一整条链路的边界摸清。希望这篇笔记能帮你少走一段弯路。
本文还有配套的精品资源,点击获取