M5销量预测Baseline:LightGBM特征工程与递归预测实战解析
2026/9/15 18:46:33 网站建设 项目流程

M5 Forecasting这个比赛,我印象很深。它全称是M5 Forecasting - Accuracy,是Kaggle上规模很大、关注度很高的一次时间序列预测赛事,主题是沃尔玛商品的层级销量预测。很多入门Kaggle的人都会拿这个比赛练手,网上也流传着各种baseline方案。今天我想围绕一个经典的baseline,把里面的门道掰开揉碎讲一遍,尤其适合刚接触Kaggle、想在销量预测这个方向建立整体感觉的朋友。

先说结论:这个baseline使用的核心模型是LightGBM,走的路线是“把时间序列问题转化成监督学习问题,然后用树模型硬train一波”。它的思路非常直白,但能够稳定拿到一个还不错的分数,并且为后面各种花式调参和特征工程留下了充足的空间。我最早跑通这个方案的时候,最大的感受就是:原来销量预测的baseline并不复杂,关键在于你愿不愿意把数据的结构想清楚。

1. 竞赛背景与baseline的整体思路

1.1 M5预测的是什么数据

M5的数据来自沃尔玛,预测对象是商品在“店铺-商品-日期”维度上的销量。训练集里给了从2011年1月29日到2016年4月24日的日销量,测试集要求预测接下来28天,也就是2016年4月25日到2016年5月22日的日销量。

这是典型的多步时间序列预测问题,而且不是一个序列,而是上万个序列同时要预测。数据里一共有30490个商品,分布在10个店铺里,所以每个店铺大概对应3049个商品。每天每个商品都会有一个销量记录,如果是0也照样占一行。这就导致数据量非常庞大,如果用传统的时间序列模型一个个去拟合,计算成本会高得让人想放弃。

再看数据文件,主要有四个:calendar.csv是日历信息,包含日期、事件、节假日等;sell_prices.csv是商品在每个店铺的售价,按周记录;sales_train_validation.csv是训练数据,每一行是一个商品,每一列是一个日期;sales_train_evaluation.csv则是在验证集基础上扩展出来的,包含了下一段28天的真实值,但比赛提交时并不会用到这部分,主要方便你本地做验证。

1.2 为什么用LightGBM而不是ARIMA

很多人第一反应是:预测销量,难道不应该先用ARIMA、Prophet这些经典时序模型吗?确实可以,但在M5这种场景下不太合适。原因有三个:第一,序列数量太多,逐个建模不现实;第二,我们手里有大量额外信息,比如价格变化、特殊事件、节假日、商品类别等,这些很难塞进ARIMA这类模型里;第三,LightGBM这种GBDT模型对特征交互的拟合能力很强,效果往往更好。

更重要的是,LightGBM方案的可复制性极强。你不需要对每个序列单独调参,只需要把所有数据拼接成一个大表,然后训练一个全局模型。这个思路在处理大规模层级预测问题时非常实用,尤其是当你有成百上千个商品需要同时预测的时候,一个统一模型能省下大量精力。

1.3 baseline要达到什么目标

一个好的baseline,在我看来有四个标准:代码结构清晰、能够快速跑通、分数不至于太难看、后续好扩展。M5的官方评估指标是SMAPE,baseline的分数大概在0.6到0.7之间,虽然不至于冲到排行榜前列,但作为起步已经足够了。很多顶级方案在此基础上引入了更复杂的特征、模型融合、层级一致性约束,但baseline的核心框架基本不变。

2. 数据预处理与特征工程的核心细节

2.1 把宽表转成长表是第一步

原始训练数据里,每一个商品和店铺的组合对应一行,日期作为列名横着排开。比如30490行,第1列是id,后面全是日期列。这种格式叫宽表,直接丢给机器学习模型肯定不行。我们需要把它转换成长表格式,即每一行是“商品-店铺-日期-销量”的一个观测。

这一步在pandas里用melt函数就能实现。我记得当时写的时候,花了点时间理解id列和日期列的对应关系。关键点是,melt之后要把日期列解析成datetime类型,并保留商品id、店铺id,因为这些字段后面就是特征工程的原材料。

这一步做完,数据量一下子从30490行变成了上千万行。内存压力也随之出现,所以很多人会用category类型来压缩存储,对store_id和item_id这些重复度高的列尤为有效。

2.2 日期特征和事件特征怎么生成

日期特征是最基础的一部分,直接从日期列里提取年、月、日、星期几、第几周等等。但M5的数据里还包含了很多额外信息,比如是否节假日、是否特殊事件日、事件类型是什么。这些信息在calendar.csv里都有,通过日期字段merge进来就行。

经验之谈:对销量预测来说,星期几通常比日期本身重要得多,因为销量有明显的周周期性。比如周末的销量往往比工作日高,而节假日前后的销量会出现明显的波动。M5的数据里,五月到七月还有特殊事件,这些都会对销量产生扰动,所以在特征里一定要体现。

事件特征需要特别处理,因为一个日期可能有多个事件同时发生,而LightGBM这类模型并不支持多值特征直接输入。常见做法是只取第一个事件名,或者把事件名做成分组统计标志。我当时直接把事件类型设为序号,用-1表示无事件,效果还可以。

2.3 价格特征和滚动统计特征

价格数据来自sell_prices.csv,但同一商品在不同店铺的价格可能不一样,同一商品的价格在不同周也可能不一样,所以merge的时候要用store_id、item_id、wm_yr_wk三个键一起关联。

光有价格还不够,价格的变化趋势更有意义。比如这个商品本周比上周贵了还是便宜了,变化幅度有多大。这些差分特征能让模型捕捉到“促销打折”对销量的刺激作用。M5里有很多商品价格的周期性变动,如果只用绝对价格,模型很难区分是正常定价还是促销。

滚动统计特征更是销量预测的重头戏。常见做法是计算过去7天、14天、30天的销量均值、标准差、最大值、最小值。这些特征能帮助模型理解近期销量水平和波动情况。不过这里有个大坑:做滚动统计时千万不能把未来数据泄露进来,一定要只用过去的信息。我一开始图省事,直接在整个长表上做shift操作,结果验证集分数虚高,但提交后分数一落千丈,后来才反应过来是泄露了。

2.4 滞后特征的选择与风险

滞后特征就是过去第n天的销量,比如lag_1表示昨天销量,lag_7表示上周同一天销量。M5这种数据,因为存在强烈的周期性,滞后特征往往比滚动均值更能直接提供信息。

滞后特征怎么选,一般看两点:一是合理的时间窗口,二是不要选得过多导致维度爆炸。baseline里通常选择1、7、28这类关键节点。对于M5而言,28天滞后尤其重要,因为测试集要预测的就是未来28天,你大概可以从过去28天的数据中找到相似的销售模式。

但滞后特征有一个隐患:在预测未来较远的天数时,近期滞后值并不存在。举个例子,你要预测5月10日的销量,5月9日的销量是不知道的,这时候lag_1就成了缺失值。所以很多baseline会采用“递归预测”的方式,也就是先用已有数据预测明天的销量,再把预测值当作已知数据继续预测后天,依次滚动。这种办法简单有效,但误差会累积。

3. 模型训练、验证和预测的完整实现

3.1 训练集和验证集怎么划分

时间序列的验证集划分和普通机器学习完全不同,不能随机打乱,必须严格按时间顺序。通常做法是把最后一段历史数据当作验证集,比如M5的数据里,训练集到2016年4月24日,验证集就选择2016年3月27日到4月24日这28天。

为什么要选28天?因为测试集要预测28天,为了模拟这个场景,验证集也应该覆盖28天。这样你在验证阶段就能以一个更接近真实提交的方式评估模型效果。当时我为了快速迭代,先用更少的天数验证,后来发现评估结果和真实提交对不上,折腾了很久。

训练集的确定也有讲究。M5的数据从2011年就有了,但如果你全部用来训练,不仅耗时长,而且太久远的数据对预测近期销量的帮助有限。很多baseline会只取最近一两年的数据训练。我当时取了最后一年半的数据,效果和全量数据差不多,但训练速度快了很多。

3.2 自定义SMAPE评估函数

M5的评估指标是SMAPE,全称是Symetric Mean Absolute Percentage Error,公式是:

SMAPE = 200% / n * sum(|F_t - A_t| / (|A_t| + |F_t|))

这个指标的特点是,它同时考虑了真实值和预测值,当两者都很大时,误差占比会较低;当真实值为0,而预测值不为0时,误差会非常大。这也是M5的一个难点,因为很多商品的销量本来就是0,如果模型输出一个非零值,SMAPE就容易被拉高。

在baseline里,我们通常会在LightGBM中开启一个自定义评估函数。LightGBM自带很多内置指标,但没有SMAPE,需要自己写一个。写的时候要注意:返回的必须是(name, value, is_higher_better)这样的三元组,否则会报错。我当时就在这里卡了很久,报错信息又不直观,差点以为是自己模型设置出了问题。

另外预测值有个小技巧。LightGBM回归输出的是连续值,而销量是整数,所以很多人会做四舍五入处理。但不要小看这个细节,对低销量商品来说,四舍五入到0还是1,对SMAPE的影响很大。我测试过,提交前将预测值在0.5以下置为0,能在不改变整体结构的情况下让分数稍微好看一点。

3.3 LightGBM参数设置与训练技巧

LightGBM的可调参数很多,但对baseline来说,有几个参数是核心:learning_rate、num_leaves、max_depth、min_data_in_leaf、feature_fraction、bagging_fraction。

我常用的参数大概是这样:

params = { 'objective': 'regression', 'metric': 'rmse', 'learning_rate': 0.05, 'num_leaves': 255, 'max_depth': 7, 'min_data_in_leaf': 50, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'verbosity': -1 }

这里learning_rate设得低一点,好处是每个树对最终结果的贡献更小,模型更不容易过拟合,代价是训练时间变长。num_leaves控制树的复杂度,过大会导致过拟合,过小则欠拟合。min_data_in_leaf也很重要,它可以防止模型在数据稀疏的叶子节点上学到过于极端的值。

训练的时候,早停机制一定要开。通过early_stopping_rounds设置,比如50轮内验证集分数没有提升就停止训练。这既能防止过拟合,也能节省时间。我见过很多人不设早停,硬生生训练几百棵树,浪费资源不说,效果反而更差。

还有一个经验是:不要在第一次训练时就把num_boost_round设得很大,比如99999,然后用early_stopping慢慢跑,这样效率很低。先跑一次,看验证分数大概在什么水平,再决定后续的参数调整方向。

3.4 按店铺分模型还是全局一个模型

这是M5 baseline里的一个经典选择。M5有10个店铺,每个店铺有几千个商品。你可以把所有店铺的数据合在一起,训练一个全局模型,也可以按店铺分开训练10个模型。

我当时的做法是按店铺分开训练,原因有几个:一是每个店铺的销售模式差异很大,分开建模能让模型学到更针对性的规律;二是分开训练的内存压力更小,速度更快;三是后续调试某个店铺时不会影响其他店铺。

但分开训练也有代价,就是管理起来麻烦一点。你需要循环遍历每个店铺,保存多个模型文件,预测时也要分别处理。不过用groupby加循环就能搞定,代码层面并不复杂。

如果你时间有限,或者机器的内存比较紧张,用一个全局模型作为baseline也完全可以,分数差距并没有想象中那么大。关键还是特征工程做到位。

3.5 预测未来28天的完整过程

预测过程是整个baseline里最需要小心的地方。测试集里有未来28天,但我们在训练模型时,特征里包含了滞后特征。预测第一天时,昨天、前天的销量都是已知的,所以没有问题。但预测第二天时,昨天的销量就变成了未知数,只能拿预测值顶上。

这就是递归预测的雏形。具体操作是:先把测试集的初始特征准备好,然后循环28天,每一天都用模型预测当天的销量,然后把预测值填入对应的滞后特征列,供下一天使用。

实现时有一个小技巧:把测试数据按日期列组织,每一天的特征矩阵单独提取出来,预测完当天后更新滞后特征字典,再构造下一天的输入。这个逻辑听起来不复杂,但如果代码写得不清晰,很容易出现特征错位,导致预测结果乱成一团。

我建议在写这部分代码前,先把数据结构想清楚:你手里有什么,每一步之后新增了什么,哪些值是真值,哪些值是预测值。如果你能做到每走一步都清楚当前数据的状态,后面就不会出乱子。

4. 提交格式与常见坑点

4.1 提交文件的格式要求

Kaggle的提交文件需要用id列匹配,格式是:第一列是id,形式为“商品id_验证方式”,比如HOBBIES_1_001_CA_1_validation,后面跟着28列,分别是F1到F28,代表未来28天的预测值。行数和训练数据里的商品数量一致,也就是30490行。

这里有一个容易出问题的地方:训练数据里有validation和evaluation两种序列,对应的id后缀也不同。你只需要根据比赛要求提交对应的部分,通常用evaluation后缀。有些人会在id生成逻辑上踩坑,导致提交后报错说行数或列数不匹配。

一个稳妥的做法是:直接读取官方提供的sample_submission.csv,把你预测出来的值对应填进去,不要自己重新构造id列。这样最安全,也能避免格式问题。

4.2 层级一致性问题

M5有一个特殊之处:官方提交的预测结果,会被聚合成12个不同层级,分别评估SMAPE,最后再求这些层级分数的均值。这意味着,如果你的预测在不同层级之间互相矛盾,比如从商品层级加总得到的店铺销量,和直接从店铺层级预测得到的结果不一致,分数就会有损失。

baseline方案一般不做层间协调,因为BL和顶级方案的主要差异之一就在于此。但如果你的目标是快速上手,层级一致性可以先搁置。后续想要提升,可以做一层post-processing,对预测结果进行比例分配,保证加总一致。

4.3 零销量与负值处理

预测结果需要是合理的销量,不能是负数。但LightGBM回归模型不会主动约束输出非负,所以对预测结果要做后处理。常见做法是:小于0的值直接置为0,或者对所有预测值取max(0, pred)。

还有一个更细的坑:有些商品在历史上的某些时间段完全没有销量,模型可能会学到一个偏高的基线值。这时候可以考虑对这类商品单独处理,比如固定预测为0。但这个方法要慎用,因为你说不准未来它会不会突然有销量。

我实操下来,最稳妥的方法是:在提交前检查预测值的分布,看看有没有明显不合理的数值,比如某些商品预测出了几十几百的销量,远高于历史峰值。遇到这种情况,可以考虑做截断处理。这类后处理虽然不起眼,但往往能稳定提高一点分数。

5. 常见问题与排查技巧实录

5.1 内存不够怎么办

M5的数据在长表格式下,行数高达数千万,如果全部特征都展开,内存很容易爆掉。我的经验是优先把所有id列和日期列转为category类型,价格和销量相关列保持float32。这样内存消耗能减少一半以上。

如果还是不够,可以采用分块训练的方式,比如对每个store单独处理,不把全部数据都读进内存。我在8GB内存的笔记本上跑过一个简化版baseline,用这种分块方式也能跑完,就是时间长了点。

还可以减少训练数据范围,比如只取2015年之后的数据,效果下降不多,但内存和训练时间都能大幅下降。

5.2 验证集分数和排行榜分数对不上

这是很多新人最困惑的地方。原因可能有很多,比如验证集的时间段和测试集不完全同分布,或者递归预测过程中误差累积的方式不同。还有一种可能是,验证集里使用了真实的历史滞后特征,但测试集里用的是预测值,两者特征质量不一样。

要缓解这个问题,可以尝试在验证集上完全模拟测试集的预测流程,也就是从第29天开始,只用预测值做滞后特征。这样验证环境更接近真实提交环境。虽然分数通常会比“用真实滞后值”低一些,但参考价值更高。

5.3 LightGBM训练太慢

如果只用一个全局模型,几千万数据加上几百棵树,训练时间很容易超过半小时。解决办法之一是按store分模型,把一个大任务拆成10个小任务并行处理;之二是调高learning_rate,减少迭代次数;之三是用更少的训练数据量,只保留最近一年的数据。

另外,LightGBM支持类别特征直接输入,你可以把store_id、item_id等作为categorical_feature传入,效果一般比one-hot好,速度也更快。不过类别数量太多时会额外消耗内存,批量处理时要注意。

5.4 提交后分数不升反降

这种情况多半是特征或后处理出了问题。我遇到过一次,是因为在后处理时把所有低于0.5的预测值都置为0,结果对高销量商品的预测值也被误伤,导致SMAPE飙升。后来改成只对历史低销量商品做这个操作,才恢复正常。

所以一定要分商品类型评估后处理的影响,不能一概而论。再一个常见的坑是,递归预测过程中,因为预测值本身有误差,导致滞后特征也带上误差,后续预测越来越偏。这时候可以考虑每个预测日都重新用历史真实值加上已预测的值来计算滚动特征,而不是用预测值直接替换所有滞后特征。

最后分享一点心得体会

M5这个baseline,对我来说最大的价值不是分数,而是让我把时间序列预测的整个流程走得清清楚楚。从宽表转长表,到特征工程,再到递归预测,每一步都有坑,但每一步踩过去之后,你对数据和模型的理解都会上一个台阶。

如果你准备拿这个baseline去参加Kaggle,或者在自己的项目里借鉴,我的建议是:先不要急着优化分数,把代码跑通、把数据流理清,然后画一张特征和预测流程图。等你对全局有了把握,再去逐个优化特征和模型参数,会发现很多东西都顺理成章。

这个baseline后续可以扩展的方向也很多,比如加入更多滚动统计特征、尝试模型融合、引入层级一致性约束,甚至用深度学习模型替代LightGBM。但无论往哪个方向走,理解这个baseline的每一个细节,都会让你的进阶之路更稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询