
简介这是天池菜鸟需求预测与分仓规划比赛第二赛季的参赛作品包面向物流电商、供应链优化方向的竞赛选手也适合正在寻找毕设源码的学生。压缩包内共114个文件以Java源码24个java、依赖库23个jar和XML工程配置12个xml为主另有SQL数据库脚本、设计报告等整体约24.09MB结构清晰便于按模块查阅。目前已有248人浏览学习。资源完整覆盖从数据预处理、特征工程、多模型训练到模型融合与预测输出的流程包含XGBoost、GBDT、随机森林、SVR、ARIMA等算法的实现并针对补多/补少成本计算、多仓协同、异常促销日过滤等难点给出具体处理思路设计报告还介绍滑窗特征、分仓与全国特征构造以及基于成本的加权融合方法作者线上成绩约88万比纯规则方法降低11万具有较强的实战参考价值。1. 一份比赛源码包背后是整条供应链的优化题天池上的“菜鸟-需求预测与分仓规划”比赛表面看是给一份赛后开源的项目源码实际上是一道非常经典的“预测决策”两段式赛题先预测浙江省内每个仓库未来几天的需求量再决定每个 SKU 在各个仓放多少货、补多少货。和单纯刷准确率的比赛不同这个赛题的落地评测指标是库存成本——预测不准会多压库存预测准了但分仓不合理同样会多花仓储费所以最终拿名次的方案一定是一个“预测模型库存策略”的组合拳。这份源码加数据库加设计报告的三件套正好对应了数据预处理、模型训练和方案撰写三个完整交付环节适合想系统接触供应链场景的新手选手也适合已经把 LightGBM 跑熟、想看看时间序列特征工程怎么和业务目标对齐的进阶玩家。读者只需要带着“从订单到补货决策”这条主线去读就能把一个比赛项目真正拆开。2. 从原始订单到模型样本先把业务数据变成能训练的时间序列2.1 比赛给出的数据长什么样菜鸟赛题提供的数据核心是两张表订单表和商品信息表。订单表记录了历史每一天每个仓库按 SKU 的订单明细商品表中则是 SKU 对应的品类、价格、重量等静态属性。天池的比赛通常不直接给你一个“按天汇总好了”的销量表而是要自己从订单行项目里聚合出来。第一次打这种赛题的人最容易犯的错误就是把订单明细直接拿去喂模型——LightGBM 对行级明细不是不能学但会学出一堆无意义的噪声而且训练样本量会爆炸到几千万行特征工程变得很不现实。正确做法是先做时间粒度和空间粒度的统一把订单明细按“日期仓库SKU”三个维度聚合成需求量形成一条条干净的时间序列。这一步在 SQL 里完成最稳妥。订单表一般有字段 order_time、warehouse_id、sku_id、quantity其中 quantity 是订单行里的件数。注意有些比赛还有一个发货量和退货量需求预测一般以“下单量”为准因为补货计划要覆盖的是客户已经产生的需求而不是实际履约的量。-- 把订单明细聚合成按天、仓、SKU 的需求量 SELECT DATE(order_time) AS dt, warehouse_id AS wh_id, sku_id, SUM(quantity) AS demand, COUNT(DISTINCT order_id) AS order_cnt, COUNT(*) AS line_cnt FROM order_detail WHERE order_time 2020-01-01 GROUP BY DATE(order_time), warehouse_id, sku_id;这段 SQL 的作用是把行级订单压缩成“一天一个仓一个 SKU 一条记录”的日频序列。这里几个字段需要说明一下demand是后续所有预测的目标值order_cnt和line_cnt不是拿来直接预测的而是作为特征候选因为它们能反映需求是集中在大单还是分散在小单对库存策略有参考价值。WHERE过滤了历史开始日期目的是避免和训练集外的脏数据混在一起。比赛数据量大的时候这段查询跑在数据库上可能要几分钟建议低位先存成临时表后续所有特征工程都从这个临时表出发。2.2 窗口特征才是时间序列模型的主干数据聚合完成后接下来就是把“历史需求”变成模型能直接消费的数值特征。核心思想是第 T 天的需求量要用 T 天以前已知的信息去预测。这里有一个铁律——任何特征都不能包含 T 天当天的信息更不能包含 T 天之后的信息否则就是数据泄露。也就是说训练集中第 T 条样本的特征窗口必须截止到 T-1目标值是 T。特征按用途可以分成三类。第一类是历史统计特征过去 7 天、14 天、28 天的滚动均值、滚动标准差、滚动最大值。第二类是日期特征星期几、月中第几天、是否月初/月末菜鸟这种电商属性的需求月底往往有一波采购高峰以及是否节假日。第三类是静态特征SKU 的品类编码、价格区间、上线时长。import pandas as pd import numpy as np from pandas.tseries.offsets import Day # df 是上一步 SQL 聚合出来的结果列为 dt / wh_id / sku_id / demand def build_window_features(df): df df.sort_values([sku_id, wh_id, dt]).reset_index(dropTrue) # 按 SKU 仓库分组构造滚动统计量 grouped df.groupby([sku_id, wh_id])[demand] for window in [7, 14, 28]: df[fdemand_rolling_mean_{window}] grouped.transform( lambda x: x.shift(1).rolling(window).mean()) df[fdemand_rolling_std_{window}] grouped.transform( lambda x: x.shift(1).rolling(window).std()) df[fdemand_rolling_max_{window}] grouped.transform( lambda x: x.shift(1).rolling(window).max()) # 滞后特征T-1、T-7、T-14 的需求 for lag in [1, 7, 14]: df[fdemand_lag_{lag}] grouped.transform( lambda x: x.shift(lag)) # 星期特征0 表示周一 df[dayofweek] pd.to_datetime(df[dt]).dt.dayofweek df[dayofmonth] pd.to_datetime(df[dt]).dt.day # 月初和月末的布尔特征 df[is_month_start] df[dayofmonth].isin([1, 2, 3]).astype(int) df[is_month_end] df[dayofmonth].isin([28, 29, 30, 31]).astype(int) return dfshift(1)是整个函数最关键的动作它把特征值全部向后挪一天保证第 T 天的样本只看得到 T-1 天及以前的信息。rolling(window)括号里的 7、14、28 分别对应一周、两周、四周的周期菜鸟这类物流赛题的需求有很强的星期周期性所以 7 天的倍数窗口优先考虑。还要注意grouped.transform是按每个 SKU 和仓库独立计算的如果全表一起算不同仓库之间会因为销量基数差异导致特征失真。2.3 样本切分直接决定线下验证的置信度特征做好之后下一个关键动作是划训练集和验证集。很多选手用随机切分这在时间序列里是致命的——模型会“偷看”未来数据。比赛数据是 2019 年到 2020 年某个时段的日订单正确的切分方式是按照时间顺序比如用前 80% 时间做训练后 20% 做验证。更严格一点验证集的起点必须晚于训练集的终点中间可以保留一段 gap比如留 7 天作为缓冲。# 按时间切分最后 30 天做线上验证前的线下模拟 split_date df[dt].max() - pd.Timedelta(days30) train_df df[df[dt] split_date] valid_df df[df[dt] split_date] feature_cols [c for c in df.columns if c not in [dt, wr_id, sku_id, dema]] X_train train_df[feature_cols] y_train train_df[demand] X_valid valid_df[feature_cols] y_valid valid_df[demand]这 30 天的 gap 值不是随手填的它代表“从最后一次数据观察到实际补货决策之间的延迟”。线下验证集越接近线上环境调参才越有指导意义。还有一个容易忽略的细节feature_cols里如果有类别型字段比如品类的 IDLightGBM 里需要显式指定为categorical_feature否则模型会当连续值处理效果可能不差但解释性差很多。类别特征的缺失值处理也有讲究建议用-1填充而不是 0避免和真实为 0 的数值特征混淆。3. 需求预测主体用 LightGBM 搭出一个高性价比基线3.1 为什么选择 GBDT 而不是 LSTM很多人看到时间序列就觉得该上 LSTM 或者 Transformer 这类深度学习模型但实战中这类比赛里 GBDT 类模型往往更稳。原因有三个一是比赛样本量虽然不小但按“日仓SKU”分组后每组序列长度其实有限深度学习对长序列的建模优势发挥不出来二是特征工程里已经手动注入了周期性和滞后信息GBDT 学非线性关系的能力足够吃下这些信息三是 GBDT 对缺失值、离群点的鲁棒性远好于神经网络初赛阶段不用花太多时间做数据清洗。后续如果想要更高分可以在这个基线之上做 LightGBM 和 Prophet 的融合这放到最后一章讲。LightGBM 的直方图算法使得它在训练时间和内存占用上比 XGBoost 更友好几千万的样本跑几分钟就能出结果。目标函数选regression_l2或者huber都可以。先看一个最简训练脚本import lightgbm as lgb params { objective: regression_l2, metric: mae, learning_rate: 0.05, num_leaves: 63, max_depth: 7, min_child_samples: 30, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1, seed: 2024 } dtrain lgb.Dataset(X_train, labely_train, categorical_feature[sku_id, wh_id, category_id]) dvalid lgb.Dataset(X_valid, labely_valid, referencedtrain) model lgb.train( params, dtrain, num_boost_round3000, valid_sets[dvalid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)] ) model.save_model(lgb_baseline.txt, num_iterationmodel.best_iteration)learning_rate设到 0.05是为了配合num_boost_round3000这类大轮数做慢速拟合训练轮数的上限放得很宽由早停来控制最后落到哪一步。num_leaves和max_depth是一对需要联动的参数叶子数越多模型容量越大但太大会过拟合63 在那个数据规模下属于中等容量。如果后续发现训练集上的 MAE 远小于验证集优先把num_leaves降到 31 试试。另外特别注意categorical_feature里的sku_id和wh_id如果把 SKU 当成数值特征喂进去模型会按 ID 大小排序去切分裂点完全失去语义。这个坑在初赛阶段几乎每个队伍都会踩一次。3.2 目标值变换预测需求量之前先看分布天数销量数据往往是右偏的大多数 SKU 平时每天卖几个到十几个但大促和月底会出现几十上百的尖峰。LightGBM 对直接用原始值拟合并不是不行但 MAE 对离群点很敏感少数尖峰会拉偏模型。常见的处理方式是用对数变换让目标分布更接近正态。# 训练前对目标做 log1p 变换 train_df[demand_log] np.log1p(train_df[demand]) valid_df[demand_log] np.log1p(valid_df[demand]) # 预测后做反向变换 pred_demand_exp np.expm1(model.predict(X_valid)) pred_demand_clip np.maximum(pred_demand_exp, 0)log1p的好处是当真实需求为 0 时变换后还是 0反向变换时expm1也能保住这个性质。预测结果出来后必须做一次np.maximum(pred, 0)的下限裁剪否则模型在小需求量区间可能出现负值而负需求在业务上等于给库存倒扣钱这在评测里会很吃亏。对数变换不是万能的如果验证集 MAE 没有明显改善建议切回原始值说明你的样本里零需求比例很低变换带来的收益不明显。3.3 模型验证不能只看整体 MAE需求预测的评测最终是库存成本导向但中间过程仍然要看 MAE、WMAE 这类误差指标。这里有个容易被忽略的分层视角按 SKU 分层看误差按仓库分层看误差。整体 MAE 漂亮不代表每个仓都准。如果某个仓库的预测误差过大后面的分仓规划在它身上必然出问题。valid_df[pred] pred_demand_clip valid_df[abs_err] (valid_df[pred] - valid_df[demand]).abs() # 按仓库统计 MAE wh_mae valid_df.groupby(wh_id)[abs_err].mean().sort_values(ascendingFalse) # 按 SKU 统计 MAE关注头部 SKU 的预测质量 sku_mae valid_df.groupby(sku_id)[abs_err].mean().sort_values(ascendingFalse) print(wh_mae.head(10)) print(sku_mae.head(10))输出结果如果发现排名靠前的仓库或 SKU 恰好是库存成本占比最高的那批那说明预测误差结构需要单独修正。可以给这些高成本 SKU 单独加训练权重或者在特征里加重它们的个性化特征。这类分层验证的代码花不了 10 分钟但对后面分仓规划的参数设置帮助很大。4. 分仓规划把预测结果翻译成补货计划4.1 库存成本结构决定了分仓不是单纯的预测题赛题的评测代价是库存持有成本 缺货惩罚。库存持有成本是每天每件货物放在仓库里的费用缺货惩罚是需求来了但没货可卖造成的损失。这个结构意味着分仓规划要做的是“在哪个仓放多少货”目标是把全仓的整体库存成本压到最低。预测模块输出的是每个仓库每天的期望需求量分仓模块要回答的是每个 SKU 在每个仓里应该保有多少库存。一个朴素的思路是目标库存法——每个仓每个 SKU 维持一个目标库存水平每天根据预测需求和当前库存计算出补货量。公式为补货量 max(目标库存 - 当前库存 - 在途库存, 0)目标库存怎么定是关键。最简单的做法是用未来 N 天预测需求的累加再乘以一个安全系数。N 是补货提前期也就是从下单到货物到达仓库的天数。安全系数则要权衡持有成本和缺货成本持有成本高就把安全系数调小缺货惩罚高就调大。# 按预测结果的未来 7 天累计值作为目标库存基线 target_days 7 forecast[target_stock] ( forecast.groupby([sku_id, wh_id])[pred_demand] .rolling(target_days, min_periods1).sum() .reset_index(level0, dropTrue) ) # 安全库存 预测日均值 * 安全系数 (安全系数按 SKU 的预测波动率动态调整) sku_volatility valid_df.groupby(sku_id)[demand].std() / (valid_df.groupby(sku_id)[demand].mean() 1) forecast[safety_stock] forecast[pred_demand] * forecast[sku_id].map(sku_volatility).fillna(0.3) * 1.5 forecast[target_stock] forecast[target_stock] forecast[safety_stock]安全库存系数取 1.5 算是一个可调的起点。如果某个 SKU 历史需求波动大它的安全库存会高一些这符合业务直觉——需求不稳的货要多备一点。min_periods1是为了避免预测序列开头因为窗口不足产生空值。注意这里按sku_id分组算波动率再把波动率映射回预测表保持了粒度一致。如果直接对全表所有 SKU 混在一起算波动率结果会被头部大销量 SKU 覆盖掉。4.2 一个可落地的补货执行流程分仓规划最终要输出一张补货计划表日期、仓库、SKU、补货量。直接写循环去补每一行的效率很差用 DataFrame 向量化操作可以把整个过程压缩成一段清晰的代码。def generate_replenishment_plan(forecast, inventory, lead_time2): plan [] for sku_id in forecast[sku_id].unique(): sku_data forecast[forecast[sku_id] sku_id] sku_inv inventory[inventory[sku_id] sku_id] for _, row in sku_data.iterrows(): wh_id row[wh_id] target row[target_stock] on_hand sku_inv.loc[sku_inv[wh_id] wh_id, stock].sum() in_transit sku_inv.loc[sku_inv[wh_id] wh_id, in_transit].sum() replenish max(target - on_hand - in_transit, 0) if replenish 0: plan.append({ dt: row[dt], wh_id: wh_id, sku_id: sku_id, replenish_qty: replenish }) return pd.DataFrame(plan) replenish_plan generate_replenishment_plan(forecast, current_inventory, lead_time2)lead_time2表示补货提前期 2 天也就是说今天的补货计划要覆盖未来 6 天加安全库存。如果补货提前期更长可以调大target_days这会直接影响整体库存水位。循环遍历在 SKU 数量较大时运行会偏慢但作为基线方案逻辑清晰可读性好。实际比赛里很多选手到这一步就停了补货计划产出后直接按表提交这已经能拿到一个中上等的排名。真正的优化空间在如何为每个 SKU 和仓库设置差异化的目标库存参数而不是一个全局统一的安全系数。4.3 分仓需求分配预测了每个仓之后要不要再做一次跨仓调配比赛里有时会出现“需求预测”和“分仓规划”分开的两个环节需求预测是对全网总需求或每个仓需求做预测分仓规划则是在仓库之间分配货物。如果预测是分仓进行的分仓规划就不需要再做需求分解直接按目标库存法处理。如果预测的是全网总需求那就要做一次按比例拆仓。拆仓常见依据是历史销量占比仓 A 分配比例 仓 A 历史需求 / 所以仓历史需求之和这个比例要定期更新不能一口气用全量历史。比如只用最近 30 天算比例跟随近期趋势。拆仓后同样落到目标库存公式里整体流程和上面保持一致。注意不要为了追求“参数精巧”去引入复杂的多目标优化求解器在比赛有限的迭代周期里一个稳定的基线加两三个差异化的参数调整比花一周去调一个混合整数规划模型要划算得多。5. 高频踩坑复盘预测翻车和分仓失效的典型现场5.1 负预测值导致补货计划异常现象预测脚本跑完后pred_demand出现大量负值尤其在零需求的稀疏 SKU 上负值占比能到 5% 以上。带入分仓规划后出现“负补货量”逻辑直接乱掉。原因部分 SKU 在验证期内大量天数需求为 0模型学到的分布以低值区间为主预测值的分布自然往负偏移。log1p变换能缓解但没能根治因为零膨胀分布本身就不适合用普通回归目标去拟合。解决在反向变换前增加一个概率修正——先用分类模型或者阈值判断预测“该 SKU 当天是否会有需求”再把回归模型的预测值乘以这个概率。实际操作中一个简单的用法是把历史需求为 0 的比例作为经验概率乘上去或者直接在最终结果上加np.maximum(pred, 0)兜底。两者结合最稳妥。5.2 训练集和验证集出现同一天数据现象线下验证 MAE 看着非常好但提交线上分数差一大截典型的线下线上不一致。原因切分时没有清理时间边界上的 overlap。有些选手在做滞后特征时对全表shift(1)如果验证集的起点在训练集窗口内的滞后特征上引用了训练集的目标值训练和验证其实共享了部分信息。解决严格按“训练集最后一天 1 天的滞后窗口”来生成验证集特征最笨的办法是训练集和验证集分开两个数据管道跑特征工程不要共用一个grouped.transform的结果。再检查一遍切分代码确保train_df[dt].max()小于valid_df[dt].min()gap 至少留 1 天。5.3 SKU 和仓库编码被当成数值特征现象模型跑完看特征重要性sku_id排到第一名重要性超过 30%。原因这基本可以断定 SKU ID 被当成了连续数值特征。LightGBM 默认把所有数值列当连续量处理ID 这种编码恰恰在数值上是有先后顺序的——ID 相近的 SKU 被归到同一个子树里模型学到了编号的虚假规律。解决在构造lgb.Dataset时显式指定categorical_feature另外把 SKU 的高频编码换成基于统计量的 embedding 特征比如每个 SKU 历史平均销量、历史需求标准差这类特征比原始 ID 更有业务含义。5.4 验证指标只看 MAE导致分仓参数调错方向现象线下 MAE 一直在降低但提交库存成本分数却不变甚至变差。原因MAE 是平均绝对误差库存成本是缺货成本和持有成本的分段线性组合。这两个目标的梯度不一致MAE 降低更多来自高频低量 SKU 的精度提升而库存成本的敏感点在大销量高成本 SKU 的缺货和积压。解决线下增加一个成本仿真函数按赛题规则把预测结果翻译为成本值调参时同时看 MAE 和 cost。如果发现 MAE 降但 cost 涨说明模型在牺牲高成本 SKU 的精度去换整体低频区域的平均表现需要增加高成本 SKU 的训练权重或者调整安全系数让预测偏差的分布更对称。6. 进阶集成预测与成本仿真评估把手上的方案再顶高一个台阶到了这一步基线已经能稳定跑通接下来值得投入的是两件事第一是用融合把单模型预测方差降下来第二是建一个与赛题规则一致的成本仿真器让每一轮迭代都看成本而不是看误差。这两个技术点都做熟之后这套方案的泛化能力才算真正立住了。集成方面最省力的做法是 LightGBM 和 Prophet 的加权平均。Prophet 擅长捕捉趋势和季节项LightGBM 擅长学习复杂交互特征两个模型的错误模式差异大融合后可以显著降低方差。实现上分三步分别训练两个模型得到预测值然后线性回归学习权重。权重不必跑到全网全局统一按 SKU 分桶学习效果更好。from prophet import Prophet from sklearn.linear_model import LinearRegression # 1. 用 Prophet 拟合单个 SKU 的时间序列注意这里只演示一个 SKU 的流程 sku_example df[df[sku_id] SKU001][[dt, demand]].rename( columns{dt: ds, demand: y}) prophet_model Prophet(yearly_seasonalityFalse, weekly_seasonalityTrue, daily_seasonalityFalse) prophet_model.fit(sku_example) # 2. 得到 Prophet 的预测结果 future pd.DataFrame({ds: pd.date_range(startvalid_start, periods30, freqD)}) prophet_pred prophet_model.predict(future)[yhat].values # 3. 把 LightGBM 预测和 Prophet 预测按 SKU 做线性加权 lgb_pred model.predict(X_valid) X_weight np.column_stack([lgb_pred, prophet_pred]) weight_model LinearRegression().fit(X_weight, y_valid) print(融合权重:, weight_model.coef_)yearly_seasonalityFalse是因为比赛数据长度通常不足一年强行拟合年度季节性只会带来过拟合。weekly_seasonalityTrue对应电商物流的周效应。LinearRegression拟合权重时不用加截距项也可以但带上截距能吸收两个模型的系统偏差。注意融合过程在 SKU 数量多时比较耗时常见做法是只对 TOP 30% 销量的 SKU 做融合长尾 SKU 继续用 LightGBM 单模型的预测切分阈值设在销量排名百分位 30 的位置具体看训练时间预算。如果发现某个 SKU 上 Prophet 预测明显偏向趋势外推而实际销量已经下滑那这个 SKU 就不适合融合直接给 Prophet 权重设为 0。成本仿真器这块实现思路不复杂但非常实用。给定预测结果和真实需求按赛题给出的仓库库存和 SKU 成本参数模拟逐日的库存变化计算每天的持有成本和缺货成本累加。有了这个仿真器调安全库存系数、补货提前期、目标库存天数时再也不用靠猜每个参数改变带来的成本变化可以直接拿数字说话。def simulate_cost(forecast_df, true_df, params): holding_cost params[holding_cost] # 每件每天持有成本 stockout_cost params[stockout_cost] # 每件缺货惩罚 init_stock params[init_stock] # 各仓各 SKU 初始库存 stock init_stock.copy() total_cost 0.0 for dt in true_df[dt].unique(): day_true true_df[true_df[dt] dt] day_fcst forecast_df[forecast_df[dt] dt] for _, row in day_true.iterrows(): key (row[wh_id], row[sku_id]) available stock.get(key, 0) day_fcst.replenish_qty if available row[demand]: stock[key] available - row[demand] total_cost stock[key] * holding_cost else: stockout row[demand] - available total_cost stockout * stockout_cost stock[key] 0 return total_cost仿真器里最容易踩的细节是补货到货时间的建模当天的补货量是当天生效还是提前期后才生效对成本影响很大。规范做法是在第 T 天执行补货计划但库存要第 Tlead_time 天才增加这期间的缺货依然要算惩罚。上面的简化代码把补货当成当天生效实际使用时要加一个arrival_date字段延迟生效。仿真器输出的成本值不要和线上绝对分数对标差一个常数偏移量很正常关键是看不同参数配置下成本的相对变化趋势。参数优化可以用最简单的手动网格搜索先固定holding_cost扫stockout_cost再联合调target_days和安全系数收敛很快。最后说一个我自己迭代这类供应链赛题的教训不要一上来就追求模型的复杂度和刷榜的刺激感先把成本仿真器搭好让每次模型迭代都有明确的业务解释再往前推。一套源码、一个数据库、一份设计报告最大的价值不是那个最终分数而是你能不能把这套“预测到决策”的流程完整复述给下一个学习者。希望这个复现路径能帮你在自己的项目里少走几段弯路。本文还有配套的精品资源点击获取