资讯动态

Transformer实战M5销量预测:从数据预处理到训练避坑全指南

发布时间:2026/10/13 7:41:46 来源:尧图企业网站定制
简介围绕M5比赛数据集的Transformer时序预测方案涵盖商品销售多维时序数据的预处理、模型构建、训练验证与批量预测等环节面向Python时间序列开发者与竞赛选手可帮助解决高维多频序列预测中的建模复杂度和精度提升问题。压缩包共29个文件大小约47.75MB以Python脚本为主涵盖数据清洗与序列生成、注意力机制实现、编码解码器搭建及训练推理脚本另含已训练权重、项目配置、说明文档和损失曲线图便于快速复现。已有194人浏览学习。资源价值集中在完整可运行的赛题方案从多通道数据展开、时间戳编码到Transformer训练调优再到批量预测与并行加速均有对应代码支撑适合作为课设、竞赛或入门Transformer时序预测的参考基线尤其可借助预训练权重直接验证效果并继续改进。1. 用 Transformer 预测 M5 比赛数据集时序任务里的注意力机制到底怎么落代码做 Kaggle 的人对 M5 比赛应该不陌生这是一个预测沃尔玛商品未来 28 天销量的经典时序任务。问题的难度不在于数据量而在于它同时包含 30490 个商品的层次结构、日历事件的扰动、价格波动带来的序列非平稳性传统 LSTM 在这种多序列、多特征场景下很容易被长短期依赖同时折磨。某次复现实验里我把一套标准 Transformer 代码搬到了 M5 上发现如果不做数据预处理和窗口构造层面的适配训练曲线基本是平的。这份资源的核心价值在于它把 Transformer 架构完整地落地到了 M5 数据集上从数据清洗、序列构建到训练推理都给出了能直接跑通的 Python 实现。适合已经跑通 LSTM 时序预测、想看看注意力机制在真实比赛数据上能到什么样效果的开发者。它不是一个黑盒调参工具而是一份能改、能拆、能换数据集的工程参考。2. 数据集底色与预处理先看清 30490 个序列的存储结构再谈建模2.1 M5 数据集的层次结构与长尾分布为什么不能直接灌进 TransformerM5 数据集由 30490 个商品序列组成每个序列长度 1969 天1913 天训练 28 天验证 28 天评估。数据分散在 calendar.csv、sell_prices.csv、sales_train_validation.csv、sales_train_evaluation.csv 四张表里。其中 sales 表的结构是宽表——每行是一个商品 ID每列是一天列名是 d_1 到 d_1941。这种结构直接喂给 Transformer 会出两个问题一是同一批次里不同商品的历史长度虽然一致但序列的稀疏程度完全不同很多商品销量大量为零注意力机制会把大量计算花在无意义的零值位置上二是宽表展开成序列后如果直接按商品维度切分模型看到的是纯数值流缺少价格、事件、节假日这些外部信号。import pandas as pd import numpy as np # 读取宽表数据 sales pd.read_csv(sales_train_validation.csv) calendar pd.read_csv(calendar.csv) prices pd.read_csv(sell_prices.csv) # 宽表转长表把 d_1 到 d_1913 的列堆叠为 date 维度 sales_long sales.melt( id_vars[id, item_id, dept_id, cat_id, store_id, state_id], var_named, value_namedemand ) # 将 d_x 转换为日期索引用于和 calendar 关联 sales_long[d] sales_long[d].str.replace(d_, ).astype(int) calendar[d] calendar[d].str.replace(d_, ).astype(int) # 合并日历特征拿到事件、节假日、星期等信息 df sales_long.merge(calendar, ond, howleft) print(df[[id, d, demand, wm_yr_wk, event_name_1, snap_CA]].head())这段代码的核心思路是把原本每行一个商品的宽表变成每个商品每天一行的长表。Transformer 需要的输入形状是[batch, seq_len, feature_dim]宽表无法直接形成这个三维张量。melt 操作把 30490 个商品的 1913 天数据展开成约 5800 万行这会让内存压力变大所以实际操作里建议先对 dept_id 或 store_id 做分组切片一次只处理一个层级的子集。d列的字符串转 int 是为了和 calendar 表做 join保证每个时间点都能拿到对应的节假日和星期特征。合并完成后df 里会出现大量重复的日历列后续按需选列即可。2.2 价格特征的对齐与缺失处理这里最容易埋坑sell_prices.csv 里存的不是逐日价格而是每周价格。这意味着从日粒度看价格列存在大量重复值但直接 forward fill 会出问题——遇到促销周或临时调价重复值会掩盖价格突变信号。常见做法是先把价格按 item_id 和 store_id 对齐到每个商品再通过 wm_yr_wk 字段映射到日历表上。# 价格表与日历表拼接得到每日价格周粒度广播 df df.merge( prices[[item_id, store_id, wm_yr_wk, sell_price]], on[item_id, store_id, wm_yr_wk], howleft ) # 处理缺失价格优先用该商品全局均值填充 df[sell_price] df[sell_price].fillna( df.groupby(item_id)[sell_price].transform(mean) ) # 做一个价格波动率特征反映序列局部变化 df[price_change] df.groupby(item_id)[sell_price].pct_change().fillna(0)价格列合并后缺失值主要来自两种场景一是某些商品在某周没有上架记录二是宽表尾部日期超出了价格表覆盖范围。这里按商品均值填充比全局均值合理因为不同商品的绝对价格差异很大单个靠中间价差距悬殊的商品用全局均值填充会引入无意义的噪声。price_change特征是个简单但是非常有效的预测增强——M5 数据里价格下降往往伴随销量拉升这个非线性关系单纯靠数值输入很难被 Transformer 直接学到。2.3 特征列筛选与数值稳定性序列模型输入的最后一公里M5 数据里可用特征远不止销量本身event_name_1事件名、event_type_1事件类型、snap_CA/TX/WI三个州的 SNAP 补贴标识、wday星期几、month、year 等等。选哪些进模型取决于你是用 Teacher Forcing 还是自回归。常见做法是把事件类特征编码成 int把星期和月份直接作为数值特征把 SNAP 标识作为 0/1 标志。# 特征编码 df[event_type_1] df[event_type_1].fillna(none).astype(category) df[event_type_code] df[event_type_1].cat.codes # 序列特征列 feature_cols [ demand, sell_price, price_change, event_type_code, snap_CA, snap_TX, snap_WI, wday, month ] # 创建平移特征让模型在 t 时刻能看到过去 7 天的销量窗口 for lag in [1, 7, 28]: df[fdemand_lag_{lag}] df.groupby(id)[demand].shift(lag) # 处理长序列中的极端值按商品做分位数截断 df[demand_clipped] df.groupby(id)[demand].transform( lambda x: x.clip(lower0, upperx.quantile(0.99)) )平移特征的处理需要谨慎。如果直接对 df 做 shift在 id 切换的边界上会出现跨商品的数据泄漏所以必须按 id 分组再 shift。量化截断这里 clip 的 upper 是按每个商品自身 99 分位做的这样可以保留商品间的差异性而不是全局一刀切。实际上在 M5 数据集上某些商品一天销量能到几百某些商品常年为零全局截断会把销量大的商品序列压扁让 Transformer 学不到它们的长尾爆发模式。3. 时间窗口与注意力掩码数据处理好之后如何把序列装进 Transformer3.1 滑动窗口划分训练集和验证集避免未来信息穿越M5 数据划分有一条硬性规定最终评估只对最后 28 天做预测。但训练时不能只拿最后 28 天做验证因为模型的序列输入需要历史窗口如果把最后 28 天整体划出去组成这些验证样本的输入窗口也会落在训练集里这就形成了重叠。合理的做法是用滚动窗口训练样本由长度为 L 的输入序列和长度为 H 的目标序列组成验证集取最后两组不重叠的目标序列。def create_sequences(df, feature_cols, input_len90, output_len28): 为单个商品创建输入-输出序列对 df: 已按日期升序排列的商品数据 X, y [], [] data df[feature_cols].values total len(data) # 从最后一个完整可预测点开始向前滑动 for start in range(total - input_len - output_len 1): end start input_len X.append(data[start:end]) y.append(data[end:end output_len, 0]) # 只取 demand 列作为目标 return np.array(X), np.array(y)create_sequences函数里有个关键细节y 取的是data[end:endoutput_len, 0]这里的 0 索引对应 feature_cols 的第一列也就是 demand。如果你把 demand 放在了 feature_cols 的中间位置这个索引就要跟着变。另一个坑是滑动步长默认步长为 1这会生成大量高度重叠的序列M5 这种长序列数据会导致训练集膨胀到几十万条甚至更多。在实践中步长可以设为 7 或者 14在序列数量和数据冗余之间取平衡。3.2 批量构造与填充策略商品序列长度不一致时怎么办不同商品在特征工程阶段生成的序列数量可能完全不同因为价格缺失、首日上架时间不同导致有效长度有差异。PyTorch 的 DataLoader 要求同一 batch 内张量形状一致处理的办法有两种一是 pad 到 batch 内最大长度二是干脆在预处理阶段把每个商品截断到同一个固定长度。Transformer 和 LSTM 不同它没有天然的循环结构所以 padding 后需要用 attention mask 让模型忽略填充位置。import torch from torch.utils.data import Dataset, DataLoader class M5Dataset(Dataset): def __init__(self, X, y, pad_len120): self.X torch.FloatTensor(X) self.y torch.FloatTensor(y) self.pad_len pad_len def __len__(self): return len(self.X) def __getitem__(self, idx): x self.X[idx] # 如果序列短于 pad_len在尾部补零 if x.shape[0] self.pad_len: pad torch.zeros(self.pad_len - x.shape[0], x.shape[1]) x torch.cat([x, pad], dim0) return x, self.y[idx] BATCH_SIZE 64 dataset M5Dataset(X_train, y_train, pad_len120) loader DataLoader(dataset, batch_sizeBATCH_SIZE, shuffleTrue, drop_lastTrue) for batch_x, batch_y in loader: print(Batch X shape:, batch_x.shape) print(Batch y shape:, batch_y.shape) breakdrop_lastTrue在这里是推荐保留的因为最后一个 batch 如果样本数小于 batch_size做 LayerNorm 时统计量会偏虽然影响不大但时序模型对数值稳定性敏感建议去掉尾巴。padding 方向也有讲究这里选择在尾部补零配合注意力掩码时掩码只需要把尾部位置标记为 False 即可。但如果你的 Transformer 使用的是 causal mask自回归约束就需要区分 padding mask 和 causal mask两个 mask 做逻辑与操作后才能进 attention 层。3.3 位置编码不是可选项M5 数据的周期信号需要提前注入M5 销量数据有非常明显的周周期和年度周期。Transformer 本身没有时间顺序概念位置编码是唯一让模型区分「第 1 天」和「第 90 天」的途径。常见的正弦位置编码在长序列上效果不错但遇到 M5 这种包含 90 天输入窗口的任务直接用原始正弦编码会带来一个尴尬问题模型需要同时捕捉「第 3 天到第 10 天的短期趋势」和「第 60 天到第 90 天的季节性模式」而位置编码的向量在高频段的响应很弱。def positional_encoding(seq_len, d_model): pe torch.zeros(seq_len, d_model) position torch.arange(0, seq_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * -(np.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) return pe.unsqueeze(0) # 使用示例输入维度 [batch, seq_len, d_model] seq_len 120 d_model 64 pe positional_encoding(seq_len, d_model) print(Positional encoding shape:, pe.shape)在实际改造中我更倾向于把位置编码替换成可学习的 Embedding因为 M5 赛季的年份跨度和节假日分布具有特定性正弦编码基于的频率假设不一定匹配数据实际周期。可学习位置编码参数量很小seq_len × d_model在 GPU 上的训练开销几乎可以忽略但效果往往比固定正弦编码高几个点。在资源包提供的代码里默认使用可学习位置编码这是和很多开源实现不一样的地方。4. 训练循环与损失函数M5 数据集上训练 Transformer 的完整实操4.1 模型结构选择与参数量设置d_model、n_heads、层数的经验值M5 预测任务本质是回归任务不同于分类任务的 token 输出需要在 Transformer 编码器/解码器之后接一个回归头。资源包里的模型结构采用编码器-解码器框架输入窗口长度为 90输出长度为 28中间通过 cross-attention 让解码器从编码器提取的历史特征中生成未来预测。实际使用中有两组参数影响最大d_model 决定序列特征的表示维度n_heads 决定注意力头数。在 M5 这种中等规模数据上d_model64、n_heads4、layers3 是一个起点配置调到 128 和 6 层之后训练时间翻了倍但精度提升有限性价比最高的点基本落在 64 和 3 层。import torch.nn as nn from torch.nn import TransformerEncoder, TransformerEncoderLayer class M5Transformer(nn.Module): def __init__(self, input_dim, d_model64, n_heads4, layers3, output_len28): super().__init__() self.input_proj nn.Linear(input_dim, d_model) self.pos_encoder nn.Embedding(120, d_model) # 可学习位置编码 encoder_layer TransformerEncoderLayer( d_modeld_model, nheadn_heads, dim_feedforward128, dropout0.1, batch_firstTrue ) self.encoder TransformerEncoder(encoder_layer, num_layerslayers) # 回归头直接输出未来 28 天销量 self.regressor nn.Sequential( nn.Linear(d_model, 32), nn.ReLU(), nn.Linear(32, output_len) ) def forward(self, x): # x: [batch, seq_len, feature_dim] batch_size, seq_len, _ x.shape x self.input_proj(x) positions torch.arange(seq_len, devicex.device).unsqueeze(0) x x self.pos_encoder(positions) x self.encoder(x) # 取最后一个时间步的编码作为池化结果 x x[:, -1, :] return self.regressor(x)batch_firstTrue会让数据形状保持[batch, seq_len, d_model]不用来回 permute减少代码出错的概率。dim_feedforward 设置为 128 是刻意的比 d_model 的两倍稍小因为在 M5 数据上过大的 FFN 层会导致过拟合。这里回归头只取编码器输出的最后一个位置如果你的预测目标不是 28 天而是更长可以考虑用 attention pooling 而不是截取最后一个 token——但实验表明在 M5 这种销量序列里最后一个 token 承载的信息量已经包含近期趋势足够支撑多步预测。4.2 数据标准化与损失函数选择M5 赛题的销量不是正态分布M5 成绩评估用的是 RMSSERoot Mean Squared Scaled Error它会对每个序列的误差按训练段平均绝对偏差做缩放。这意味着模型在销量低的商品上犯错会被放大在销量高的商品上犯错会被降低。直接使用 MSE 作为损失函数会引导模型重点优化高销量商品对低销量商品不敏感。实践中需要在损失函数上做适配或者对标签做缩放处理。def rmse_loss(pred, target, scale_factor): 基于每个序列的 MAE 进行缩放 diff pred - target # scale_factor: 训练段该序列的平均绝对偏差 scaled_error diff / scale_factor.unsqueeze(1) return torch.sqrt(torch.mean(scaled_error ** 2)) # 训练循环核心片段 optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) for epoch in range(30): model.train() epoch_loss 0.0 for batch_x, batch_y in loader: optimizer.zero_grad() pred model(batch_x) # loss 需要传入当前 batch 对应的 scale_factor loss rmse_loss(pred, batch_y, batch_scale) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() epoch_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {epoch_loss/len(loader):.4f})scale_factor 需要从训练序列里预先算好并和每个样本的 id 绑定。在 DataLoader 里返回的数据结构要同时包含 X、y 和 scale_factor这样 loss 计算时才能对每个样本做独立缩放。一个常见的翻车现场把 scale_factor 写成了全局常量导致所有序列的误差被同一个数缩放最终 RMSSE 不但没降反而更高。clip_grad_norm_ 在 Transformer 训练中几乎是必需的因为注意力机制里的梯度范数波动比 LSTM 大得多不 clip 的话训练到第 10 个 epoch 左右 loss 会突然爆掉直接 NaN。4.3 学习率预热与退火为什么上来用大学习率会振荡Transformer 在训练初期对学习率很敏感。原版论文用的是 warmup 衰减的调度策略因为注意力层的梯度在训练开始时方差较大如果初始学习率太高参数会直接跳出最优区域。这里使用 CosineAnnealingLR把初始学习率设成 1e-3warmup 阶段实际上依靠 LAMB/AdamW 优化器自带的 bias correction 来缓解但更稳妥的做法是自己实现 warmup 调度器。from torch.optim.lr_scheduler import LambdaLR def warmup_cosine_scheduler(optimizer, warmup_steps500, total_steps5000): def lr_lambda(step): if step warmup_steps: return step / warmup_steps else: progress (step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * (1 np.cos(np.pi * progress)) return LambdaLR(optimizer, lr_lambda) scheduler warmup_cosine_scheduler(optimizer, warmup_steps300, total_steps3000)warmup_steps 的选择和 batch 数量有关。M5 训练集如果按步长 7 切分大概能产生 3 万到 5 万个序列样本batch_size64 的话一个 epoch 约 500 到 800 步。warmup 设置在 300 步左右等价于半个 epoch 内让学习率线性升到峰值之后余弦退火到接近零。如果你在训练时发现前 500 步 loss 没有明显下降不要急着调学习率先确认 warmup 代码是否生效了——很多人的调度器根本没进优化器因为忘了在optimizer.step()之后调scheduler.step()。5. 避坑指南M5 Transformer 中值得记录的五个翻车现场5.1 训练集和验证集的样本重叠导致评估虚高现象验证集 RMSSE 特别低但提交到排行榜后分数明显变差。原因用滑动窗口生成序列时验证集的输入窗口包含了训练集的尾部样本模型实际上见过了验证集输入窗口里的数据产生了信息泄漏。这是 M5 任务最常见的隐藏坑。解决把最后 56 天的数据单独划分出来28 天验证 28 天测试训练集只允许用到 d_1857 之前的数据。生成序列时如果起始位置在 d_1857 之后就跳过。后续上线时严格按这个切分逻辑走评估结果才有参考价值。5.2 目标列索引写错模型一直在学习预测价格现象loss 在降低但预测值明显不是销量量级偏差达到几千。原因create_sequences里 y 取的是data[end:endoutput_len]把特征列的第一列当成 demand。如果把 sell_price 放在了 feature_cols 的第一列模型学的就是价格预测销量根本没有被监督。解决在创建序列之前检查 feature_cols 的顺序最好用一个独立变量demand_idx feature_cols.index(demand)来动态获取索引而不是写死0。每次修改特征列后都要回头检查这个索引。5.3 长序列训练显存溢出模型根本塞不进 GPU现象batch_size64 时显存占用直接拉满报 CUDA out of memory。原因M5 数据特征维度不高但序列长度 120、batch 64 时attention 矩阵是64 × 4 × 120 × 120核算下来约 368 万个元素中间激活值会持续占用显存。如果 layer 数堆到 6 层激活值累积更夸张。解决梯度累积是首选方案。把 batch_size 降到 16然后每 4 个 batch 做一次参数更新效果等价于 batch_size64。另一个办法是减少序列长度到 90用截断的后 90 天作为输入M5 里 90 天已经能覆盖季度周期信息丢失不严重。5.4 销量序列全是零模型输出负值现象某些商品连续几十天销量为零Transformer 学出来的预测出现负销量不符合业务约束。原因M5 数据里长尾商品很多零销量占比超过 30% 的序列不在少数。模型在回归头输出时没有任何约束负值在数学上没有被惩罚。解决对模型输出做 ReLU 截断或者在回归头后面加一个绝对值的激活。更优雅的做法是在后处理阶段把负值替换为 0但这样会损失模型的概率信息——推荐直接用torch.clamp(pred, min0)在 forward 里处理然后评估时再做一次后处理。这个方法对实际提分有显著帮助。5.5 Teacher Forcing 和自回归预测的差异导致测试阶段崩盘现象训练时 loss 收敛得不错但推理阶段用自回归方式逐日预测时误差累积越来越大28 天后偏差失控。原因训练时用的是 Teacher Forcing即每一步的输入都来自真实值推理时只能把上一步的预测值作为下一步输入两者分布不一致产生 exposure bias。解决在训练中引入 scheduled sampling以一定概率用预测值替代真实值作为下一步输入。在 M5 这种短期预测任务上可以选择在前半段训练用 Teacher Forcing后半段以 10%~20% 概率切换到预测值让模型适应误差传播。6. 预测结果的后处理与集成从单模到稳定上分的两个实战技巧6.1 各层级一致性的后处理让上层和下层预测对齐M5 的最终评分对层次结构有要求每个商品的预测值加起来需要等于对应类别和门店的预测值。直接用独立模型预测 30490 个商品底层预测累加后往往和上层预测对不上这种不一致会直接拉低分数。可以采用两步校准先用模型预测底层商品再用线性回归把底层预测压到和上层预测一致。def hierarchy_reconcile(bottom_up_pred, top_pred, hierarchy_matrix): bottom_up_pred: 底层商品预测, shape [n_sku, horizon] top_pred: 上层预测, shape [n_agg, horizon] hierarchy_matrix: 层次聚合矩阵, 每行表示上层节点由哪些 SKU 组成 reconciled bottom_up_pred.copy() for t in range(bottom_up_pred.shape[1]): # 计算聚合值与上层预测差异的比例 agg hierarchy_matrix reconciled[:, t] ratio top_pred[:, t] / agg # 把比例广播到底层 weight hierarchy_matrix.T np.nan_to_num(ratio) reconciled[:, t] bottom_up_pred[:, t] * weight return reconciled这个方法的本质是 top-down 修正但保留底层趋势。如果模型在某个商品上预测特别准而只是聚合比例不对用这个修复后误差不会损失太多。需要注意的是ratio 计算时 agg 可能为零需要在分母上加上一个极小值 epsilon否则会出现除零警告。6.2 不同随机种子模型的集成把 Transformer 的不确定性转化为稳定性Transformer 训练对随机种子极其敏感同一个参数配置随机种子不同成绩可以波动 2% 到 5%。单模成绩最高的那个种子不一定在下一次运行中仍然最高所以要稳住分数就得做集成。M5 上常见的集成策略是取 3 到 5 个不同种子的模型对预测结果做加权平均。权重的确定可以用验证集上的 RMSSE 反比计算误差小的模型权重更高。# 集成 5 个种子的预测 predictions [] # 每个元素是 [n_sku, 28] 的预测矩阵 val_scores [] # 每个模型在验证集上的 RMSSE # 权重 1/score 的归一化结果 weights 1.0 / np.array(val_scores) weights weights / weights.sum() ensemble_pred np.zeros_like(predictions[0]) for pred, w in zip(predictions, weights): ensemble_pred w * pred集成带来的提升在 M5 上通常比换模型结构的收益还要大。曾经试过一个复杂结构但精调后的单模验证集成绩比集成低了 0.8%而且单模在 test 上的波动远大于集成。训练 5 个模型的时间成本可以接受——M5 数据规模不大单卡 2080Ti 一个模型 30 个 epoch 大概一个半小时。从那以后我每次做时序比赛都强制自己至少跑 3 个随机种子的模型再做加权集成而不是花时间去抠那零点几个点的单模精度。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑