聊聊刚收尾的一个项目:Rossmann商店销售预测。这是Kaggle上非常经典的一个回归类竞赛题,数据来自德国Rossmann连锁药店,1115家门店、101万条销售记录,任务是预测每家门店每天的销售额。我用Xgboost集成算法把整个流程完整跑了一遍,最终验证集RMSPE稳定在0.105左右。这个结果放在竞赛榜上不算顶尖,但从特征处理到模型调参再到业务解读,整个过程走下来,收获远比一个漂亮分数大得多。这篇就完整记录我的思路、步骤和踩过的坑,适合有一定机器学习基础、正想拿真实数据集练手Xgboost回归的朋友。
Rossmann这个题好在哪?数据规模适中、业务含义清晰、特征类型丰富,有数值、有类别、有时间序列属性,正好能把特征工程和集成树的功底都练到。而且它的评估指标是RMSPE而不是RMSE,这个细节让很多人栽过跟头,下文会专门讲。
1. 项目背景与问题定义
1.1 业务理解:药店销售预测到底在解决什么问题
Rossmann是德国的大型连锁药店,在全国有几千家门店。竞赛提供的数据是2013年1月到2015年7月期间,1115家门店的日销售记录及各门店的属性信息。业务目标是预测未来六周内,每家门店每天的销售额。
这个需求在零售行业非常典型。门店的销售预测直接决定了三件事:补货计划怎么做、排班怎么安排、促销活动怎么定。如果预测偏乐观,会造成库存积压,药品和日化品都有保质期,积压意味着直接损失;如果预测偏保守,缺货又会流失客户。所以零售企业对预测精度极其敏感,哪怕只是误差降低一两个百分点,放到全国几千家门店的体量上,都是千万级的成本节省。
数据层面有两个文件:train.csv和store.csv。train包含销售记录,核心字段有门店ID、销售日期、日销售额、当日顾客数、是否促销、假日类型、学校假期标记;store包含门店静态信息,比如门店类型、商品种类、竞争对手距离、是否参与长期促销等。要注意的是,train只有有营业记录的数据,测试集却包含所有日期,这就要求模型在推断时还得学会判断门店哪天开门。
1.2 为什么选Xgboost而不是其他模型
这个问题的建模方式其实有好几条路:用Prophet这类时间序列模型单独对每个门店拟合,用LightGBM或Xgboost做监督回归,甚至用深度学习。我最终选了Xgboost,理由很直接。
第一,数据量级和特征结构适合。101万条记录对深度学习来说不算多,但对Prophet来说逐店建模又太碎——1115家门店要拟合1115个模型,周期长且难以统一优化。Xgboost恰好在这个数据规模上表现稳定,训练速度快,还能统一处理所有门店。
第二,表格式数据是GBDT的主场。销售记录包含大量类别型特征(门店类型、促销状态、假日类型)和数值特征(顾客数、竞争距离),这些特征的组合关系非常复杂,Xgboost天然擅长通过树分裂捕捉这类特征交互。
第三,工程细节省心。Xgboost原生支持缺失值处理、内置交叉验证接口、支持自定义评估函数,并且对特征量纲不敏感,省去了一大堆标准化和编码的预处理工作。
当然,LightGBM在这个数据集上也表现很好,训练速度比Xgboost快,内存占用也小。我后面对比过,两者精度接近,Xgboost最终胜出主要在调参空间更可控,尤其在小批量数据上不容易过拟合。
2. 数据探索与特征工程
2.1 探索性分析:先看懂业务规律再动手
拿到数据先别急着写特征,我习惯先做一轮EDA,把销售数据和业务对应起来看。
销售额分布呈现明显的右偏,大部分门店日销售额在5000到10000欧元之间,少数大店能达到20000以上。这种分布对回归模型不太友好,直接预测原始值,模型会把更多精力放在拟合大销售额门店上,而小门店的相对误差会被忽略。所以我对销售额做了log1p变换,把偏态分布拉向正态,训练目标和评估都在对数空间进行。
顾客数和销售额高度正相关,相关系数接近0.85。但注意:测试集中没有顾客数这一列,所以不能用顾客数直接做特征,只能作为训练时的辅助理解。不过可以用它构造一个门店级别的均值特征,即“某门店历史平均客单价”,这个特征在测试集也能算出来。
日期维度上,周几对销售影响巨大。德国的零售规律是周六销售最高,周日大部分商店关门,销售额为0;同时节假日前后销售额会有明显波动。按月看,12月因为圣诞采购明显高峰,7到8月夏季会有一个小低谷。
促销信息很关键。数据显示促销期间平均销售额提升约30%,而且不同门店对促销的响应差异很大——同样开促销,A店的增量可能是B店的五倍。这说明促销特征不是单纯一个布尔值,要结合门店历史水平和促销频率做交互。
2.2 特征工程:真正有效的特征组合
我的特征体系分四类:时间特征、门店静态特征、促销特征、历史统计特征。
时间特征方面,我提取了年月日、星期几、是否周末、是否节假日、月份、年度第几周。其中星期几是预测力最强的单特征,因为零售业的周周期性非常明显。还加了一个“距最近节假日的天数”和一个“距下个节假日的天数”,用来捕捉节前囤货和节后疲软效应,这个特征对拉动验证集分数特别有效。还有一个“当月第几天”,靠近发薪日的几天销售会小幅上扬,这在德国也有明显的消费行为支撑。
门店静态特征里,StoreType(门店类型)和Assortment(商品种类)都是类别型,直接用标签编码。CompetitionDistance(最近竞争对手距离)是连续值,部分门店缺失。原始特征直接喂给Xgboost也能用,但我做了分箱处理,把距离分成0-500米、500-2000米、2000-5000米、5000米以上四档,效果比直接用连续值略好。原因是竞争对手对销售的影响不是线性的,距离近了才有显著冲击效应。
历史统计特征是我这轮提升最大的部分。对每个门店,计算了开赛前若干周内的销售均值、标准差、最大最小值,代表这家店的正常经营水平。本质上是一个粗略的时序全局特征,让模型知道“这家店平时大概是什么规模”。
一开始我还尝试过滞后特征——比如昨天、上周同一天的销售额,这类特征对销售预测确实很强,但在Rossmann竞赛里有个坑:测试集没有滞后信息,因为6月的销售数据依赖7月的前序数据,滞后多少天就会产生多少天的冷启动缺口。所以最终方案放弃了常规滞后特征,改用门店级别的滚动统计量,避免线上线下不一致。
2.3 缺失值处理:Xgboost的隐藏技能
Rossmann数据里有两处明显缺失:CompetitionDistance大约0.3%的缺失,以及CompetitionOpenSinceMonth/Year的几千条缺失。很多教程介绍到这里都会说“用中位数填充”,但我实测效果并不好。
原因在于,Xgboost本身在节点分裂时会对缺失值自动学习最优的默认方向,也就是说,把缺失保留为NaN,模型能自己找到“缺失应该走左还是走右”。用中位数填充反而把缺失样本硬塞进了一个可能不合适的分布里。
我的做法是:CompetitionDistance缺失的填充一个极大值99999,让树把缺失当作一个独立的离散状态;CompetitionOpenSinceMonth和CompetitionOpenSinceYear填充0,同时增加一个布尔特征标记该字段是否缺失,让模型显式知道“这里数据是缺的”。这类缺失指示特征在Xgboost里往往能带来意想不到的提升。
顺带说一句,很多人提到“Xgboost会处理空值”,这个说法需要精确理解:Xgboost不区分“缺失”和“被填了一个值”,它只是在训练时对缺失样本自动学习最佳分裂方向。如果你用sklearn接口,缺失值会被默认处理;但用原生接口时,遇到稀疏矩阵和缺失值,需要确认输入格式是xgb.DMatrix且带有缺失标记。实际项目中不要完全依赖这个机制,关键特征还是手动填充更稳妥。
3. Xgboost核心原理与选择逻辑
3.1 集成学习本质:三个臭皮匠为什么顶个诸葛亮
Xgboost是boosting集成算法的典型代表。boosting的核心思想是串行训练多个弱学习器,每个新学习器重点关注之前所有学习器没做对的样本。
用大白话解释:假设你要预测销量,第一个模型学完,发现预测偏高的单子集中在节假日,那么第二个模型就加大对这些样本的权重;第二个模型学完,又发现之前偏低的单子集中在促销门店,第三个模型就重点纠正这块。每一轮都是在前一轮残差的基础上继续拟合,最终把所有模型加权求和。
Xgboost相比传统GBDT的关键改进有两点。第一,传统GBDT只用一阶导数信息,也就是把损失函数对预测值求一阶导作为残差,然后去拟合这个残差;Xgboost对损失函数做了二阶泰勒展开,同时用一阶导和二阶导,相当于每个样本不仅告诉模型“偏了多远”,还告诉模型“偏的斜率变化有多快”,这样拟合精度明显更高。第二,Xgboost在目标函数里加入了正则化项,惩罚树的叶子节点个数和叶子权重的L2范数。这两项约束让每棵树不会过分追求拟合训练集,从而降低过拟合风险。
具体到Rossmann项目,模型要学的是101万条样本里的复杂非线性模式。比如促销的效果不是恒定不变的——同类门店周末促销效果和工作日促销效果就是完全不同的两个量,这类交互关系用线性模型只能靠手工交叉特征才能捕捉,而树模型天然支持特征分裂组合,这是选择Xgboost的底层逻辑。
3.2 Xgboost的工程优势与适用场景
除了算法原理上的优势,Xgboost的工程实现也很成熟。训练时可以开多线程并行,树的分裂点查找是按特征维度独立计算的,天然适合并行化;支持缓存优化,对大矩阵处理很友好;DMatrix数据结构的设计让数据在内存中的访问效率高于DataFrame直接训练。
在Rossmann数据上,我用的是原生的xgb.train接口配合DMatrix,比起sklearn的XGBRegressor包装接口,原生接口虽然代码更啰嗦,但自由度更高,可以自定义训练过程中的评估指标、更方便地控制早停和监测。如果你只是做快速实验,用XGBRegressor也可以,但做竞赛或者严谨的调参流程时,建议用原生接口跑。
从我自己的经验看,Xgboost适合的数据特征是:中等规模表格数据(几万到几百万行)、特征以数值和低基数类别为主、特征间存在复杂交互关系、要处理缺失值。如果你的数据是大规模高维稀疏(比如文本TF-IDF后几十万维),或者图像音频类数据,那就是XGBoost力所不及的领域了。
4. 模型训练与调参全流程
4.1 数据划分:时间序列必须按时间切
Rossmann是个带时间属性的回归任务,数据划分不能随机打乱,否则会严重高估模型表现。原因在于测试集时间在训练集之后,模型要预测的未来。如果随机划分,训练集里混入了未来数据,模型相当于“偷看答案”,验证集分数会虚高。
正确做法是按时间切。我用2013年1月到2014年12月作为训练集,2015年1月到2015年3月作为验证集,2015年4月到6月作为测试集。这个划分方式也符合实际业务逻辑——用历史两年数据建模,预测未来半年。
同时注意有一个隐含问题:2015年7月末测试集最后几周的数据里,所有门店的促销标记全是1,也就是开展了全门店统一促销。训练数据里促销状态是多样的,但这个特殊时段在历史中没有对应模式,模型需要外推。这个现象我一开始没注意到,后来看特征重要性时发现Promo重要性意外偏高,才意识到是测试集促销分布不均匀导致的,这算是一个隐藏的数据陷阱。
4.2 关键参数:先搞懂每个参数在干什么
Xgboost参数很多,但真正需要精细调节的就那七八个。我把它们分成三类。
树结构参数:max_depth控制每棵树的最大深度,默认6,在中等数据量上够用。我试过4到10之间的取值,Rossmann数据在8附近表现最好。min_child_weight是对叶子节点样本权重和的最小值限制,默认1,调大到5降低了过拟合。这两个参数决定了树能学多复杂的模式,但过大容易过拟合,需要一起调整。
采样参数:subsample是每棵树随机使用的行比例,colsample_bytree是每棵树随机使用的列比例。这两个参数的原理和随机森林类似,都是在每棵树训练时引入随机性,降低树之间的相关性。在Rossmann项目上,我最终用subsample=0.8、colsample_bytree=0.7。注意这个参数对精度影响不是单调的,太小模型欠拟合,太大模型过拟合。
正则化和步长:eta即learning rate,控制每棵树对最终结果的贡献步长。eta越小,模型需要的树就越多,精度上限更高,但训练时间更长。我固定eta=0.05,对应需要将近2000棵树;如果你用eta=0.01,可能需要上万棵树才能收敛到同等精度。alpha(L1正则)和lambda(L2正则)在Rossmann数据集上影响不大,我保持默认,只在最后微调时略增了lambda。
在损失函数上,我做了对数变换后目标函数是reg:squarederror(MSE),这是回归任务最常用的选择。sklearn接口的XGBRegressor默认objective也是这个,如果不做对数变换直接预测原始销售额,模型会偏重大店而忽略小店,这是我一开始踩过的坑。
4.3 调参策略:先粗后细,网格加经验的组合
我的调参分了三个阶段。
第一阶段,固定eta为0.1,先粗调max_depth和min_child_weight,做2x2的网格搜索。粗搜的目的是定位最优范围而不是追求精确,所以步长可以大一点。遍历结果看,max_depth=8、min_child_weight=5的组合在验证集RMSPE上明显优于max_depth=6和min_child_weight=1的默认组合,差距大约0.015。
第二阶段,固定树的参数,调subsample和colsample_bytree,同样是网格搜索,范围从0.5到0.9。这一步提升幅度较小,验证集RMSPE只降低了0.003左右,但对防止过拟合效果明显。
第三阶段,降低eta到0.05,同时增加迭代轮数,用早停来防止过度迭代。早停的机制是:每训练一轮,用验证集算一次RMSPE,如果连续100轮验证集分数都没有创新低,就停止。这比固定树数要稳,因为不同eta下最佳树数完全不一样。
整个调参过程跑下来大概花了大半天,主要是网格搜索比较耗时。中途也试过贝叶斯优化调参,但收益不明显,在这个数据规模上传统网格搜索配合经验判断已经完全够用。
训练部分的代码结构整理如下:
import xgboost as xgb import numpy as np import pandas as pd from sklearn.model_selection import train_test_split # 读取特征工程后的数据 df = pd.read_csv('data/rf_features_v2.csv') X = df.drop(['Sales', 'Date', 'Customers'], axis=1) y = np.log1p(df['Sales'].values) # 按时间切分训练集和验证集 X_train = X[df['Date'] < '2015-01-01'] X_val = X[(df['Date'] >= '2015-01-01') & (df['Date'] < '2015-04-01')] y_train = y[df['Date'] < '2015-01-01'] y_val = y[(df['Date'] >= '2015-01-01') & (df['Date'] < '2015-04-01')] d_train = xgb.DMatrix(X_train, label=y_train) d_val = xgb.DMatrix(X_val, label=y_val) # 自定义RMPSE评估函数 def rmspe(preds, dtrain): labels = dtrain.get_label() # preds是log空间预测,需要还原 preds = np.expm1(preds) labels = np.expm1(labels) return 'rmspe', float(np.sqrt(np.mean(((labels - preds) / labels) ** 2))) params = { 'objective': 'reg:squarederror', 'eta': 0.05, 'max_depth': 8, 'min_child_weight': 5, 'subsample': 0.8, 'colsample_bytree': 0.7, 'lambda': 1.0, 'alpha': 0.0, 'nthread': 8, 'seed': 42 } model = xgb.train( params, d_train, num_boost_round=3000, evals=[(d_val, 'val')], custom_metric=rmspe, early_stopping_rounds=100, verbose_eval=100 )需要注意两个容易出错的地方。第一,我训练时用的是对数变换后的y,自定义评估函数中要把预测值和真实值都通过expm1还原后再算RMSPE,否则算出的指标和竞赛成绩口径不一致。第二,不要漏了seed参数,否则每次训练结果会有波动,调参时很难判断提升是来自参数还是随机性。
4.4 阈值处理与预测修正
模型预测完成后还需要做一道后处理:对测试集中Open=0的记录,预测值直接设为0。因为门店不营业不可能有销售。这个修正能显著提升最终分数,竞赛评测是按全部记录计算的,不营业的样本如果预测成非零值,会产生巨大误差。
还有一个细节:如果门店当天营业,但历史客单价极低且没有顾客记录,模型可能会预测出负值,log空间的预测还原后可能出现小于0的数。我做了个简单修正,把这类预测值截断到0,避免输出不合常理的负数。
5. 结果评估与业务落地
5.1 用RMSE还是RMSPE:评估指标如何影响模型行为
这是Rossmann项目最有教学价值的部分。竞赛明确要求用RMSPE即均方根百分比误差,而不是RMSE均方根误差。公式长这样:
RMSPE = sqrt(mean(((y_true - y_pred) / y_true) ^ 2))
RMSE惩罚的是绝对误差,一家销售额20000的大店误差1000,对应的RMSE贡献是1000;一家销售额1000的小店误差100,RMSE贡献只有100。这会引导模型优先把大店拟合准确,小店精度损失却被忽略。
RMSPE惩罚的是相对误差,大店误差1000只带来5%的相对误差,小店误差100却带来10%的相对误差,后者对RMSPE的惩罚反而更大。这更符合零售业务的价值导向——各门店的预测准确率都要有保障,而不是只照顾好大店。
实际操作中,我一开始训练时用了RMSE作为验证标准,发现验证集RMSE很低,但最终RMSPE反而不好。后来换了RMSPE作为早停监测指标才对齐了目标。这是一个典型的“评估指标没对齐目标导致优化方向跑偏”的案例,大家在拿到项目时第一件事一定是确认评估口径。
5.2 特征重要性解读:模型学到的业务逻辑
训练完成后,我用get_score函数查看了特征重要性。排名靠前的特征依次是:日期相关特征(年份、月份、星期几)、历史销售均值、促销标记、竞争对手距离。这个排序和业务直觉完全吻合。
历史销售均值重要性很高,说明零售业预测里“门店自身的基准水平”比任何外部特征都重要——这验证了滚动统计特征的构建思路是对的。星期几和月份的重要性反映了消费行为的周期性。促销标记的重要性说明促销活动对销量的拉升在所有特征中是普遍泛化的规律,而不是个别门店的特殊现象。
我在实际解读时还发现了一个有意思的交互:商店类型和节假日特征的联合重要性很高。对比预测结果发现,a类门店(大卖场型)在节假日前后的销售额变化幅度远大于b类门店(普通社区店),这可能是因为大卖场型的选址更偏商业区,节假日人流波动更大。这类信息对业务排班和备货有直接参考价值。
5.3 误差分布:看模型在哪类样本上表现差
评估的最后一步,我做了预测误差的分段分析,按真实销售额把测试样本分成几个区间,分别计算各区间RMSPE。
结果很典型:销售额在2000欧元以下的小店和淡季日均销售区间,RMSPE高达0.18以上;销售额在5000到10000之间的中等门店,RMSPE约0.09;大店反而在0.075左右。原因很好理解:小店销售基数低,绝对值波动小但相对波动大;而且小店往往受单一大客户、周边事件影响更强,模式更难学。
如果业务侧不愿意对全部门店采用统一模型,可以考虑按门店规模分组建模,分别训练后再集成;不过我这轮时间有限,没做这个优化。这类误差分层分析对实际业务非常有价值——它告诉我们模型预算应该优先花在哪些样本上,以及哪些样本的预测天然就带有更高的不确定性。
6. 常见问题与避坑指南
6.1 高频问题速查表
我把项目里遇到的典型问题和排查办法整理成了表格,按出现频率排序:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| RMSE低但RMSPE高 | 验证集RMSE很好,公开榜分数却不理想 | 评估指标没对齐,模型偏向大店 | 自定义RMSPE评估函数,用RMSPE做早停依据 |
| 测试集促销列全为1 | 促销特征重要性异常高 | 测试集最后几周全门店统一促销 | 识别特殊时段,单独评估外推性能 |
| 预测出负值 | 某些店铺预测销售额为负 | log空间还原后,小值样本可能为负 | 预测后做max(0, pred)截断 |
| 时间序列随机划分 | 验证集分数虚高 | 训练集混入未来信息 | 按时间顺序切分,不用随机划分 |
| Xgboost训练结果不稳定 | 两次训练结果不同 | 没有固定随机种子 | 设置seed参数并保持一致 |
| CompetitionDistance全缺失填充 | 模型效果不升反降 | 填充值改变数据分布,丢失缺失信息 | 用缺失指示特征+大常数填充 |
6.2 实操心得:这个项目教给我的几件事
第一件事,不要迷信参数数量,先把特征和损失函数搞对。我整个项目花在特征工程上的时间大概是模型调参的四倍,但提升幅度也相应地大得多。与其花两小时暴力搜参,不如先认真做两小时EDA和特征设计。
第二件事,业务理解在建模前就要完成。Rossmann项目的所有关键决策几乎都源于对零售业务的理解——为什么用RMSPE、为什么要处理Open=0的记录、为什么促销特征要与门店类型交叉。业务逻辑不清楚,后面每一步都是盲调参数。
第三件事,验证集上的结果不是最终结果。测试集最后几周的促销全为1这个现象,如果不做深入数据分析根本发现不了。模型在验证集上表现再好,到了测试分布偏移的场景就是另外一回事。所以建模过程中,一定要留出一部分时间专门验证训练集和测试集的特征分布差异。
第四件事,log变换这个操作看似简单,却对回归任务影响巨大。不做log变换时,模型预测的重点会被大销售额门店主导;做了之后,模型能够更均匀地学习不同规模门店的模式。很多新手拿到回归项目就开始堆特征,忽略了目标变量本身的分布问题,这是性价比最低的遗漏。
Rossmann这个项目做完,最大的感受是:Xgboost作为工具已经很成熟,难的是如何把业务问题翻译成特征和评估指标,再根据反馈不断迭代。这个能力没法靠读文档学会,只能在真实项目中积累。如果你正打算找一个表格类回归数据集练手,Rossmann从数据规模、特征丰富度到业务背景都是很好的选择。把这个流程完整跑通,你对GBDT类模型的理解会上一个台阶。以后遇到实际问题时,这个项目积累的“先理解业务再动手建模”的方法论,可能比任何一个模型参数都更有用。