简介:这是天池菜鸟需求预测与分仓规划第二赛季的高分参赛源码及说明文档,面向大学生竞赛选手,以及计算机、通信、人工智能、自动化等相关专业学生、教师或从业者。项目基于Hadoop MapReduce框架,完整实现了梯度提升回归树(GBRT)训练流程,涵盖特征生成、决策回归树、GBRT回归器训练等关键环节,并配有SQL脚本、XML和properties等工程配置文件,清晰呈现分布式机器学习任务从数据准备到模型输出的完整结构。
资源包共113个文件,压缩后约24MB,以Java源文件、编译后的class文件、依赖jar包为主体,另有XML配置、SQL脚本、properties、prefs等辅助文件,可导入开发环境直接运行调试。代码已经过调试验证,确保可运行,目前已有174人学习下载。适合作为期末课程设计、课程大作业、毕业设计或竞赛进阶的参考项目;基础扎实的读者还可替换数据集或调整特征处理逻辑,实现不同的预测功能,具备较强的二次开发价值。
1. 天池菜鸟需求预测与分仓规划:这套高分源码到底帮你省了哪些事
做供应链预测的同行应该都有同感:算法模型不是最难的,最难的是让预测结果真正落到仓库里、变成可执行的补货计划。天池菜鸟这个赛题把两件事绑在了一起——先做需求预测,再做分仓规划,本质上就是一个缩小的供应链计划系统。我拆这份第二赛季高分源码的时候,最直观的感受是:它没有堆花哨模型,而是把特征工程、分位数预测、库存分配串成了一条能跑通的链路,每一环都有具体的调参痕迹和踩坑记录。这份资源适合正在打天池竞赛、或者刚接手供应链预测项目的人,照着跑一遍,能少走至少两周弯路。本文会把源码里的关键做法拆开讲:从数据处理到分仓决策,哪一步该怎么做、参数怎么设、哪里容易翻车,全摆出来。
2. 先看懂赛题再动手:数据口径、评估指标与拿到源码后的第一件事
2.1 赛题数据长什么样:订单表、仓配表与时间口径
做这份源码之前,先把赛题给的数据结构理清楚。天池菜鸟这个赛题给的数据一般分三类:一是历史订单数据,包含订单日期、商品ID、仓库ID、订单数量;二是商品信息表,包含品类、SKU属性;三是仓配信息,即哪些仓库覆盖哪些城市、物流时效之类。第二赛季还会额外给一些分仓维度的约束,比如仓容、起补线、最大库存上限。
拿到源码包后,我建议先别看模型代码,先把数据处理脚本跑一遍,看它到底读进来哪些字段。这份源码的做法是:把所有订单按“日期 + 商品 + 仓”聚合,得到一个天级别的销量序列。注意,订单表里通常有两个时间字段——下单时间和出库时间,预测的目标是某个需求口径,源码里选了出库时间作为对齐口径,因为分仓规划关心的是“某天仓里要发出去多少货”,不是“用户哪天下的单”。这个选择直接影响了后面所有特征的时间对齐,如果你自己拿数据复现,先确认你的目标字段跟源码一致,否则后面全对不上。
数据聚合之后,一般会做一个简单的缺失检查。天池数据里常见的问题是部分SKU在部分仓库下没有销量记录,源码的处理方式是保留全零序列,不删掉——因为分仓规划里这些仓可能后续会有补货需求。这里有个细节:聚合粒度决定样本量,也决定模型能学到什么。源码用的是“商品 + 仓”粒度建模,而不是把仓合并成一个大区,原因是分仓规划阶段需要每个仓单独的输出,提前在预测阶段拆开,后面做分配就不用手动拆数了。
2.2 评估指标才是真裁判:P50分位数损失为什么难优化
竞赛跟实际项目最大的区别就是有明确的评估函数,而这份源码的优化方向完全是围绕评估指标来的。第二赛季的预测部分用的是分位数损失,常见的是P50或P90的pinball loss。源码里明确以P50为主,因为分仓规划环节需要的是一个“中位数预测”而不是“均值预测”——均值预测在库存场景里会导致系统性的欠补或者过补。
P50分位数损失的定义不复杂:当预测值大于实际值时,损失为0.5乘以误差绝对值;当预测值小于实际值时,损失为0.5乘以误差绝对值。因为P50的权重对称,它本质上接近MAE,但又比MAE对异常值的容忍度不同。这意味着什么?你用LightGBM的L2损失训练出来的模型,跟用分位数损失训练出来的模型,在同一个验证集上的排名可能完全不同。
我在看这份源码时注意到,作者在训练集构造上专门做了时间切分:用最后N天作为验证集,而不是随机切分。这个做法非常重要,因为供应链数据有强时间相关性,随机切分会把未来信息泄漏进训练集,造成线下验证分数虚高、线上翻车。源码里默认用最后20天做验证,如果你要复现,建议保持这个设置,不要为了“提高分数”改成随机切分——那是在给自己埋雷。
2.3 源码目录解读:先跑通 baseline 再改特征
拿到源码包后第一件事不是阅读全部代码,而是找一个能直接运行的入口脚本。这份源码的目录结构大概是这样的:data(原始数据与中间特征)、feature(特征工程脚本)、model(训练与预测脚本)、plan(分仓规划脚本)。我一般会先看一眼有没有run.sh或者main.py这类入口,有的话直接跑通,确认环境依赖没问题,再逐文件读。
跑通baseline之后,要做的事情是记录“原始分数”。源码里应该有README或说明文档写明了线下验证分数,把它记下来,后续每一次特征改动、参数调整,都跟这个基准比。这份源码的价值在于,它不是把最终结果直接给你,而是给了你一条可以逐步改进的路径——比如先加滞后特征、再加滑窗统计、再调模型参数、再做分仓规则,每一步都是可验证的。这种结构比一份“只有最终代码没有过程”的资源实用得多,因为你能看清分数是在哪个环节涨上去的。
提示:跑通用脚本之前,先确认Python版本和依赖库版本。LightGBM的版本差异会导致结果不完全一致,源码里如果写了requirements.txt,尽量按版本安装。
3. 需求预测阶段的特征工程:从原始订单到可训练样本
3.1 时序特征怎么构造:滞后、滑窗与节假日编码
预测模型用的特征是这份源码的核心部分,也是分数差异最大的地方。先把方法说清楚:对每个“商品 + 仓”的时间序列,构造三类特征。第一类是滞后特征,即过去7天、14天、28天的销量值直接作为特征列;第二类是滑窗统计,取过去7天、14天、30天的均值、最大值、最小值、标准差;第三类是日历特征,包括星期几、是否月初、是否月末、节假日前后N天。
源码里对滞后特征有一个关键处理:不是简单地shift(1)到shift(7),而是用了shift(7)、shift(14)、shift(28)这种跨周期的滞后。原因是近几天销量在需求预测里往往包含“促销余波”或“补货到货”的噪声,而周级别的滞后能更好地捕捉周期性。我自己在类似场景里也验证过,7天的滞后比1天的滞后在稳定性上更好,因为电商订单里哪天爆量、哪天低谷,周期规律比连续规律更重要。
滑窗统计的窗口长度不是拍脑袋定的。源码里分别试了7、14、30天,最后保留的是14天和30天两个窗口。窗口太长会引入太久远的信息,窗口太短则噪声太大。这里有个常见翻车点:直接对全零序列算滑窗统计,标准差为0、均值也为0,这些特征本身没问题,但要注意不要让模型把所有特征都学成“零序列专用”,否则后续有销量波动的SKU预测会被拉偏。源码的处理是对全零序列单独打了一个标记特征is_zero_seq,这种做法值得抄。
节假日编码怎么做?天池数据里通常没有明确的节假日标签,但可以通过日期反推出。源码的做法是构造“距离最近法定节假日前后N天”这种特征,比如before_holiday_3、after_holiday_3。对于双十一这种大促,会额外给一个is_promotion的强特征,这个特征对菜鸟这种电商物流场景的预测提升非常明显。如果你自己处理的数据没有大促标注,可以从销量突刺反推一个近似标签。
# 特征构造示例(基于 pandas 的时序聚合后处理) import pandas as pd import numpy as np def build_time_features(df, group_cols=['sku_id', 'warehouse_id'], date_col='dt', target='qty'): df = df.sort_values(group_cols + [date_col]).reset_index(drop=True) g = df.groupby(group_cols)[target] # 滞后特征:7/14/28天前销量 for lag in [7, 14, 28]: df[f'lag_{lag}'] = g.shift(lag) # 滑窗统计:14天和30天的均值与标准差 for win in [14, 30]: df[f'rolling_mean_{win}'] = g.transform( lambda x: x.rolling(win, min_periods=1).mean()) df[f'rolling_std_{win}'] = g.transform( lambda x: x.rolling(win, min_periods=1).std()) # 全零序列标记 df['is_zero_seq'] = (df['rolling_mean_30'] == 0).astype(int) # 日历特征 df['dt'] = pd.to_datetime(df[date_col]) df['dayofweek'] = df['dt'].dt.dayofweek df['is_month_start'] = (df['dt'].dt.day <= 3).astype(int) df['is_month_end'] = (df['dt'].dt.day >= 26).astype(int) return df这段代码的逻辑是:先按“商品 + 仓”分组排序,保证每个序列的时间顺序正确。然后用shift构造滞后销量,用rolling构造滑窗统计。min_periods=1这个参数要特别注意——它表示窗口内至少要有1个非空值才能计算,否则结果为NaN。在预测未来时,历史窗口前几天的数据是不完整的,如果不设这个参数,会直接丢掉早期样本。is_zero_seq是全零序列的标记,模型可以据此区分“本来就没货”和“没销量记录”两种情况。
3.2 预测模型选型与参数:LightGBM 的调参建议
这份源码最终用的是 LightGBM 而不是 XGBoost,原因有两个:一是LightGBM训练速度快,在“商品 + 仓”这个粒度下样本量通常有几百万行,XGBoost迭代一轮要慢很多;二是LightGBM对缺失值的处理更友好,时序特征里因为换货、缺货导致的NaN不会直接报错,而是自动学习缺失方向。很多新手喜欢同时跑多个模型做个加权融合,但实际中需求预测场景里,单模型调好的分数往往比粗暴融合更高。
损失函数的选择上,源码用的是L1损失而不是默认的L2。注意,这里的L1对应的是MAE,而不是P50分位数损失。严格来讲,分位数损失需要在参数里设置objective='quantile'、alpha=0.5,但天池第二赛季的评估指标对P50的要求,用L1近似也能拿到接近的效果,而且训练更稳定。源码里有个注释说明了为什么不用quantile:分位数损失在梯度上对离群点的反馈是线性的,跟L1相同,但quantile目标在部分LightGBM版本里对数值型特征的收敛速度更慢。
import lightgbm as lgb params = { 'objective': 'regression_l1', # 用L1损失近似P50分位数 'metric': 'mae', 'learning_rate': 0.05, 'num_leaves': 64, 'max_depth': 8, 'min_child_samples': 30, 'subsample': 0.8, 'colsample_bytree': 0.7, 'cat_smooth': 10, 'lambda_l1': 0.5, 'lambda_l2': 1.0, 'verbose': -1 } model = lgb.train( params, lgb.Dataset(X_train, y_train), num_boost_round=1000, valid_sets=[lgb.Dataset(X_valid, y_valid)], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(50)] )参数按这个思路理解:learning_rate=0.05配合early_stopping(100)是常规组合,让模型训得慢一点、训得稳一点。num_leaves=64在百万级样本下不算大,如果样本量超过500万,可以适当加到96或128,但对内存占用会有要求。min_child_samples=30是防止过拟合的关键,尤其这个赛题有大量全零序列,叶子节点样本太少会直接记住噪声。colsample_bytree=0.7和subsample=0.8是通用的行、列采样,能增强泛化能力。cat_smooth=10是针对分类特征(商品品类、仓ID)的正则,这个参数容易被人忽略,但在类别数很多时非常管用。
调参时不要盲目网格搜索。我建议只调三个参数:num_leaves、learning_rate、min_child_samples。先固定learning_rate=0.05,在64、96、128三个值里试num_leaves,选验证集分数最好的;然后把min_child_samples在10到50之间扫一遍;最后再决定要不要把learning_rate降到0.02并增加迭代轮数。这份源码里的参数就是按照这个顺序调出来的,不是一次性试出来的。
4. 分仓规划这一关:把预测值变成可执行的库存分配
4.1 分仓规划的问题建模与约束
分仓规划在整个赛题里占的分值权重最高,也是最容易拉开差距的部分。需求预测做得好,如果分仓方案是拍脑袋定的,总分照样上不去。备货决策的目标是:在满足每个仓服务水平的前提下,最小化总备货成本。但实际赛题里有三个硬约束:一是每个仓有最大库存上限,超过就要承担额外成本;二是每个仓有起补线,库存低于该线必须补货;三是补货存在提前期,不是当天决定当天到。
源码里把这个问题拆成了两步:第一步是确定每个仓的目标库存上限量,第二步是确定补货时间和补货数量。常见做法是用预测需求量乘以一个服务水平系数来折算安全库存。这个系数的确定方式很关键——如果全部仓用同一个系数,结果一定不是最优的。源码的做法是:按“销量波动程度”给仓分档,波动小的仓用1.0的系数,波动大的仓用1.2,相当于用历史方差自适应调整安全库存。
这个逻辑背后是对评估函数的理解。分仓规划的评估通常会同时惩罚缺货和积压:缺货意味着需求没有被满足,扣分很重;积压意味着库存周转慢,扣分相对轻。所以一个朴素的策略是“宁可多备,不可少备”——但仓库容量上限又限制了不能无脑多备。源码在这个约束下给出的方案是:把预测分位数的余量跟仓容上限做比较,超出上限的部分,优先分配给历史缺货率高的仓。
4.2 源码里的分配策略:贪心与回退机制
源码里具体的分配算法是一个带回退机制的贪心策略。思想是这样的:假设总可备货量是total_stock,先把每个仓按服务水平系数算出“期望备货量”,然后按优先级从高到低依次分配。优先级由两个因素决定:缺货率(历史缺货次数 / 总天数)和销量占比。缺货率高、销量占比大的仓排在最前面,优先拿货。
这里有一个很容易翻车的地方:如果只用缺货率做优先级,那么销量极低但偶尔缺货的仓会拿到过高的库存,占用了大仓的额度。源码里的解决方案是加入一个“单位缺货损失”的考虑,用缺货率乘以销量均值作为排序权重,相当于把缺货程度折算成绝对丢失的订单量,再排序。这个思路在供应链管理里叫“加权缺货优先级”,逻辑上很合理。
# 分仓分配的贪心回退逻辑示例(简化版) def allocate_stock(pred_df, total_stock, capacity_dict, shortage_rate_dict): # pred_df: 每行是 [warehouse_id, predicted_daily_qty, service_level] df = pred_df.copy() df['weight'] = df['predicted_daily_qty'] * shortage_rate_dict[df['warehouse_id']] df = df.sort_values('weight', ascending=False).reset_index(drop=True) allocation = {} remaining = total_stock for _, row in df.iterrows(): wid = row['warehouse_id'] expected = row['predicted_daily_qty'] * row['service_level'] # 回退逻辑:如果期望量超过容量,先按容量分配 alloc = min(expected, capacity_dict[wid]) alloc = min(alloc, remaining) # 总库存不足时,按比例收缩 alloc = max(alloc, 0) allocation[wid] = alloc remaining -= alloc if remaining <= 0: break # 如果总库存还有剩余,按比例追加 if remaining > 0: total_weight = df['weight'].sum() for _, row in df.iterrows(): wid = row['warehouse_id'] extra = remaining * row['weight'] / total_weight allocation[wid] = min(allocation[wid] + extra, capacity_dict[wid]) return allocation这段代码的核心是先按“缺货损失权重”排序,权重高的仓优先分配。回退机制体现在min(alloc, remaining)这一步——当库存不足时,不会让前面的仓拿满,而是给后面的仓留出最低保障。最后如果还有剩余库存,再按权重比例追加分配,但同样受容量上限约束。注意这个逻辑只适合“分仓数不多、容量约束简单”的场景,如果仓数超过100个,建议改用线性规划求解,贪心策略的次优性会变得明显。
提示:源码里的实际规划代码比这个示例复杂,核心区别在于它多了“补货时间点”的决策,即不是一次性分配,而是在未来N天里按天滚动分配。复现时先把这个示例跑通,再去读源码里的完整实现,会容易很多。
4.3 为什么要滚动分配而不是一次分配
第二赛季的赛题一般会要求预测未来一段时间(比如14天)并且做每天的补货决策。一次性把14天的库存都分配完看起来省事,但实际行不通:每天都有新订单进来,预测也在变,早期分配的库存很可能在第3天就不够用了。源码采用的策略是按天滚动:第1天先用第1天的预测结果做决策,第2天用新数据重新预测、重新分配。
滚动分配的成本是计算量增加了数倍,但效果提升非常明显。因为需求预测模型一旦预测步长超过3天,误差会指数级增长,滚动分配等于让模型每走一步都校准一次。这份源码的分数优势,很大程度就来自这个机制——它不是把预测和规划割裂开,而是把预测当成分仓规划的输入,每隔一天重新推一次。这个思想比任何模型trick都值得学。
5. 高分源码的避坑清单:这些坑我踩过,别跟我一样交学费
5.1 现象:线下验证分数很高,线上提交分数暴跌
原因:时间泄漏。训练集里用了未来信息,比如用整个时间窗口的均值做特征归一化,或者把未来销量平均值作为特征输入。天池赛题的数据是按时间排序的,随机切分验证集会掩盖这个问题。
解决:严格按时间切分验证集,训练集只使用预测目标日之前的数据。特征里凡是用到“全局统计量”的,都要改成“截止当前日的滚动统计量”。源码里所有特征都是逐日计算的,没有用到任何未来窗口的数据。
5.2 现象:分仓规划跑出来,总库存分配完,但实际模拟时大量缺货
原因:预测用的是P50中位数,分仓时又做了安全库存加成,两个环节的风险叠加导致部分仓实际备货不足。P50意味着有50%的概率实际需求会超过预测值,如果再叠加服务水平系数不当,缺货率会超过预期。
解决:把服务水平系数从1.0提高到1.15或1.2,同时把缺货扣分的成本单独拆出来算。源码里对波动率高的仓用了1.2系数,对稳定的仓用1.0,这样既控制总库存成本,又降低缺货风险。
5.3 现象:LightGBM训练到一半内存爆掉,进程被杀
原因:“商品 + 仓”粒度的样本量极大,如果特征里再包含高基数的类别特征(比如1万个SKU),LightGBM会为每个类别建分裂点,内存消耗成倍增长。
解决:降低num_leaves和max_depth,把高基数的类别特征先做频次编码,只保留出现次数大于阈值的类。源码里对SKU特征做了frequency_encode,少于10次出现的SKU统一编码为“other”,这一步直接让内存占用降了一半。
5.4 现象:预测结果普遍偏小,整体备货量不足
原因:目标值里有大量零值,L1损失在零值占比高时会把预测往0方向拉。这是计数数据建模的通病,尤其是在非促销日。
解决:先单独训练一个分类模型判断“当天是否有销量”,再对有销量的样本训练回归模型。源码里的做法更简单:对训练样本按销量是否大于0分桶,在LightGBM里通过增加min_child_samples的值来抑制零值样本的干扰。如果线上分数还是偏低,就把预测结果乘以一个1.05到1.1的整体系数,粗暴但有效。
5.5 现象:分仓规划用了贪心策略,但速度慢到跑不完
原因:贪心策略本身不慢,慢的是每次分配都要遍历所有“商品 + 仓”组合,如果组合数超过几百万,Python循环跑起来会非常吃力。
解决:源码里的关键做法是把分仓规划分成两步:先按“商品”维度聚合预测结果,做一次粗分配;再在单个商品内部按“仓”维度做细化分配。这样两层拆分之后,单次规划的规模缩小了一个量级。另外,用numpy向量化替代iterrows循环,速度能提升10到20倍。
6. 进阶:把分数再往上拉的三个细节验证
先说第一个细节:对预测结果做“分仓汇总校准”。单仓预测误差在汇总到总量时可能相互抵消,也可能同向叠加。我见过很多人单仓分数很漂亮,但总量一合计偏差就很大。更稳妥的做法是:先训练单仓模型,拿到每个仓的预测值后,按仓的销量占比做一次加权归一化,确保各仓预测之和与总量预测尽量一致。不需要额外训练一个总量模型,只用一个缩放系数就够了。这个改动通常能让分仓规划的决策边界更稳。
第二个细节:在分仓规划里加入“延迟满足”机制。规则很简单:如果某仓当前库存还够用,就不急着补货,把补货动作推迟到库存跌到起补线以下。这个策略能显著减少积压成本。源码里有个reorder_point参数,默认等于预测值的1.2倍。如果你发现线上缺货少但周转成本高,试着把系数调到1.0;如果缺货频繁,向1.4靠,别超过1.5,否则仓库容量会直接爆掉。
第三个细节:验证模型改进时,不要只看总分数,要分层看。按商品品类分、按仓分、按促销日和非促销日分。你改一个特征,可能总量分数没变,但促销日预测好了、非促销日变差了,总分持平但分仓表现差很多。源码里作者配了不少诊断脚本,能够按维度输出误差热力图,这个习惯我从这拆源码的经验里保留了——现在每个预测实验跑完,强制先分层看误差,再决定去留。
拆完这份天池菜鸟高分源码,我最大的收获不是某个模型涨了多少分,而是它全程在跟同一个评估指标较劲,每一步改动都有明确的目标。从那以后我自己做预测项目,也强制先定义清楚评估口径,再动手建模型。希望这份拆解能帮你在自己的赛道上少走一段弯路。
资源入口见标题:天池菜鸟需求预测与分仓规划第二赛季参赛源码+说明(高分项目),含完整源码与配套说明文档,下载后对照本文逐章节看,收获会更大。
本文还有配套的精品资源,点击获取