简介:一份天池大数据竞赛‘菜鸟-需求预测与分仓规划’赛题的完整配套代码包,面向参赛者及供应链预测、仓储优化方向的学习者。内容围绕需求预测与分仓规划两条主线,覆盖数据清洗、缺失值填充、特征工程、分仓独立建模,以及XGBoost、GBDT、随机森林、SVR等回归模型的训练与融合;同时包含ARIMA时序预测、成本计算与规则/集成对比脚本,可帮助快速跑通从数据准备到结果评估的完整流程。
包体共68个文件,以Python脚本为主(58个py),承担数据预处理、特征构造、模型训练与混合集成等环节;另有6个SQL文件用于生成训练/测试特征,2个CSV存放预测结果或中间数据,1个R脚本实现ARIMA建模,1个Markdown文档说明目录结构与用法。压缩包仅133KB,体量不大但模块完整,目录按训练/验证、分仓特征、模型融合等划分清晰,便于对照学习。
目前CSDN已有2331人浏览学习,可直接运行并二次改进,既可用作赛题复现的基线代码,也能作为需求预测与分仓规划项目的工程参考,适合补充实战经验、梳理系统实现细节。
1. 天池大数据竞赛里的菜鸟赛题:需求预测与分仓规划在解决什么
天池大数据竞赛的这道菜鸟-需求预测与分仓规划赛题,表面上是让参赛者预测未来几周每个仓库的订单量,实际上考的是供应链里的“仓配网络”这一整套决策链路。它给出的场景是近场电商的仓网结构:货品从总仓调拨到各个区域仓,再从区域仓发往消费者。你预测不准,库存储备就失真——少备了断货罚款,多备了压仓费、周转变慢,两边都是真金白银的损失。这道题适合三类人:正在做物流数据分析和供应链计划的数据工程师、想练时序建模与运筹优化落地能力的算法工程师,以及准备用竞赛项目充实简历的应届生。一个反直觉的结论是:预测精度提升 1% 带来的收益,往往不如你把安全库存系数调对一次,这就是分仓规划的价值所在。
2. 先拆赛题再看数据:把「仓-SKU-时间」三维对齐再动手
赛题的数据通常落在四张表上:订单明细表、商品信息表、仓库信息表、用户浏览/加购流量表。新手最容易犯的第一个错,是抱着订单明细表直接开始做时间序列预测。这不对。订单明细是一行一个订单项,必须先聚合到「仓 + SKU + 天」这个维度,才有资格谈预测。这一章先把仓网结构和数据聚合讲透,后面建模才不会翻车。
2.1 仓网结构与履约成本的隐藏关系
近场电商的仓网一般分两层:中心仓和区域仓。中心仓负责囤全量货品,区域仓从中心仓进货,再覆盖各自周边的配送范围。分仓规划要回答的问题,就是每种 SKU 在每个区域仓放多少货,既能满足本地需求,又不至于堆到库容上限。这两个目标天然冲突,所以赛题不会只考你预测准不准,而是考你有没有能力在“预测结果”之上做一道运筹优化的应用题。
区域仓之间也存在调拨成本。比如 A 仓爆单但 B 仓积压,最优解可能是从 B 仓调货到 A 仓,而不是让 A 仓临时向中心仓紧急补货,因为调拨成本往往低于紧急配送成本。这就在模型里引入了 SKU、仓、时间的二维矩阵决策。把所有约束写进模型之后,预测误差会直接传导到补货量上,所以分仓规划对预测误差的容忍度很低——尤其怕那种“平时很准、大促全歪”的误差。
2.2 用 SQL 聚合订单明细成「仓-品-日」宽表
不管后续你用 LightGBM 还是 Prophet,第一步都得先把订单明细变成一份干净的日度宽表。常见做法是写一段 GROUP BY 的 SQL,把订单表按 warehouse_id、sku_id 和日期聚合,同时保留订单数和用户数两个辅助维度,后面做特征时会用到。
-- 最终产出:wh_sku_daily 宽表,一行 = 一个仓 + 一个SKU + 一天 CREATE TABLE wh_sku_daily AS SELECT o.warehouse_id, o.sku_id, TO_CHAR(o.order_date, 'YYYY-MM-DD') AS dt, SUM(o.order_qty) AS qty, -- 当日总销量 COUNT(DISTINCT o.order_id) AS order_cnt, -- 当日订单数 COUNT(DISTINCT o.user_id) AS user_cnt -- 当日购买用户数 FROM order_detail_tbl o WHERE o.order_date >= DATE '2023-01-01' AND o.order_date <= DATE '2024-06-30' GROUP BY o.warehouse_id, o.sku_id, TO_CHAR(o.order_date, 'YYYY-MM-DD');这段 SQL 的逻辑不复杂,但有三个地方值得解释。第一,用 TO_CHAR 把时间戳归一到日期粒度,后续做滞后特征时不会因为小时颗粒度产生脏数据。第二,COUNT(DISTINCT order_id) 和 COUNT(DISTINCT user_id) 不是冗余,它们能反映同一 SKU 的购买是集中在少数大单还是分散在大量散单,这对预测形态很重要。第三,WHERE 条件里的日期范围建议比训练集再放宽 30 天,因为边界日期的滞后特征会缺失,多留一点余量可以避免特征工程时丢数据。
2.3 数据质量探查:覆盖率、长尾分布与流量表的价值
聚合完成后先别急着建模,花一个小时探查这份宽表的数据质量。重点看三个指标:第一,仓库对 SKU 的覆盖率,即每个仓实际上覆盖了多少个 SKU,覆盖率低于 30% 的仓基本是新品仓或临时仓,建模时要单独处理;第二,销量分布的幂律特征,大部分 SKU 的日均销量可能只有个位数,少数爆款占了八成销量;第三,零销量天数占比,零销量过多的 SKU 会让时序模型学出一堆零预测,但分仓规划又恰恰不能忽略它们。
-- 仓库覆盖率与销量分布 SELECT warehouse_id, COUNT(DISTINCT sku_id) AS sku_cnt, ROUND(AVG(qty), 2) AS avg_daily_qty, ROUND(STDDEV(qty), 2) AS std_daily_qty, ROUND(SUM(CASE WHEN qty = 0 THEN 1 ELSE 0 END) * 1.0 / COUNT(*), 4) AS zero_ratio FROM wh_sku_daily GROUP BY warehouse_id ORDER BY avg_daily_qty DESC;流量表的价值也在此刻体现出来。用户浏览加购数据的时间粒度更细,而且往往比实际下单提前 1 到 3 天,把它按仓和 SKU 聚合成“前 7 天浏览量”“前 3 天加购量”这样的特征,可以显著提升大促前夕的预测效果。也就是说,建宽表时不要只盯着订单表,把流量表也聚合进来,后面做特征工程会省一半功夫。
3. 需求预测输入准备:特征工程与四类候选模型
宽表就绪后,进入预测建模阶段。竞赛型需求预测的历史数据通常有 12 个月以上,目标一般是一到两周的日度预测。时序模型的选型不外乎四类:统计模型(Prophet / ARIMA)、树模型(LightGBM / XGBoost)、深度学习模型(DeepAR / TFT)、以及简单的均值基准。我一般不推荐一上来就上 DeepAR,不是因为精度不够,而是因为调试成本太高,对样本量也有要求。树模型加一套扎实的特征工程,在这个量级的数据上更容易达到可复现的效果。
3.1 先做特征工程:滞后特征、滚动窗口与日历特征是底线
直接拿原始销量序列喂给树模型是行不通的。树模型不像时序模型自带序列结构,它需要你主动构造“过去的信息”。最低配置是三组特征:滞后特征(lag)、滚动窗口统计(roll_mean / roll_std)、日历特征(星期、月、月初、大促前标记)。注意一个关键细节:所有窗口统计都要基于 shift(1) 之后的数据,否则你用了当天数据去预测当天,这是特征泄漏,会让验证集指标好看得离谱,上线直接报废。
# 特征工程:滞后特征 + 滚动窗口 + 日历特征 def build_ts_features(df, lags=(7, 14, 21, 28), windows=(7, 14)): df = df.sort_values(["warehouse_id", "sku_id", "dt"]).reset_index(drop=True) # 按 SKU + 仓分组,确保序列不回串 grp = df.groupby(["warehouse_id", "sku_id"])["qty"] # 滞后特征:过去第 7/14/21/28 天的销量 for lag in lags: df[f"lag_{lag}"] = grp.shift(lag) # 滚动窗口均值与标准差:用 shift(1) 排除当天数据,避免泄漏 for win in windows: df[f"roll_mean_{win}"] = grp.transform( lambda x: x.shift(1).rolling(win, min_periods=win // 2).mean() ) df[f"roll_std_{win}"] = grp.transform( lambda x: x.shift(1).rolling(win, min_periods=win // 2).std() ) # 日历特征 dt_series = pd.to_datetime(df["dt"]) df["weekday"] = dt_series.dt.weekday df["month"] = dt_series.dt.month df["is_month_start"] = dt_series.dt.is_month_start.astype(int) # 流量特征:前 7 天加购量/浏览量,若数据表里没有可跳过 if "cart_cnt" in df.columns: df["cart_7d"] = ( df.groupby(["warehouse_id", "sku_id"])["cart_cnt"] .transform(lambda x: x.shift(1).rolling(7, min_periods=3).sum()) ) return df参数说明:lags 选 7、14、21、28 是为了覆盖周周期性,14 天窗口对应两个完整周;min_periods=win // 2 是为了在序列头部样本不足时允许用部分窗口计算,避免前期数据全部变成 NaN。roll_std 很多人不愿意加,怕引入噪声,但在大促场景下它恰恰能反映需求的波动性,对分仓规划里的安全库存计算非常有用。
3.2 用 LightGBM 训练预测模型:参数与回看窗口设置
特征工程做完后,用 LightGBM 做基线模型是最稳的路径。训练时有两件事必须做对。第一,按时间切分验证集,而不是随机切分,因为时序数据一旦随机切分就会把未来的信息泄露给训练集。第二,验证集的长度必须和预测目标一致,如果任务是预测未来 14 天,验证集就取最后 14 天,而不是只取最后一天。
import lightgbm as lgb feature_cols = [c for c in df.columns if c.startswith(("lag_", "roll_", "weekday", "month", "is_", "cart_"))] cutoff = "2024-05-17" # 训练截止日 horizon = 14 # 预测未来 14 天 train = df[df["dt"] < cutoff] valid = df[(df["dt"] >= cutoff) & (df["dt"] < cutoff + pd.Timedelta(days=horizon))] dtr = lgb.Dataset(train[feature_cols], label=train["qty"]) dva = lgb.Dataset(valid[feature_cols], label=valid["qty"]) params = { "objective": "regression", "metric": "mae", "learning_rate": 0.05, "num_leaves": 63, "min_data_in_leaf": 20, "feature_fraction": 0.8, "bagging_fraction": 0.8, "verbosity": -1, "seed": 42, } model = lgb.train( params, dtr, num_boost_round=1000, valid_sets=[dva], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)], )这段代码里最有讲究的三个参数是 num_leaves、min_data_in_leaf 和 learning_rate。num_leaves 设 63 是因为数据量还没大到需要几百片叶子的程度,设太大容易让树拟合到个别 SKU 的噪声;min_data_in_leaf 设 20 是对抗长尾 SKU 的有效手段,避免某些销量常年为 0 的序列被过度拟合;learning_rate 设 0.05 是精度和训练时间的折中,竞赛型数据 1000 轮以内基本能收敛。早停轮次设 50 的话,实际训练轮数往往在 300 到 600 之间。
3.3 模型评测:用 SMAPE 而不是 MAE,误差分布要盯两个点
需求预测的评测指标,天池这类竞赛常用 SMAPE,它是对称的平均绝对百分比误差,公式是:SMAPE = 100% × (2 × |y - y_hat|) / (|y| + |y_hat|)。
import numpy as np def smape(y_true, y_pred): return 100.0 * np.mean( 2 * np.abs(y_true - y_pred) / (np.abs(y_true) + np.abs(y_pred) + 1e-8) )SMAPE 和 MAE 最大的区别在于按相对误差计算,所以同样是错 10 件,销量 100 的 SKU 比销量 5 的 SKU 权重低得多。但这也带来一个问题:模型为了压低 SMAPE,会倾向于在低销量 SKU 上预测偏保守,因为反正绝对值误差很小。看评测时我习惯同时看两个分布指标:预测误差的标准差(评估波动风险)和零销量 SKU 的预测命中率(评估冷启动)。只盯一个整体均值,大促期间的表现往往会被平时兜底。
模型选型层面,常见候选方案的对比可以看这张表:
| 模型 | 精度 | 训练速度 | 冷启动 | 可解释性 | 适用场景 |
|---|---|---|---|---|---|
| LightGBM | 高 | 快 | 中 | 高 | 大规模短中期预测,基础必备 |
| XGBoost | 高 | 较快 | 中 | 高 | 与 LightGBM 效果接近,可做融合 |
| Prophet | 中 | 快 | 低 | 高 | 无特征工程的快速基线 |
| DeepAR/TFT | 较高 | 慢 | 高 | 低 | 时序样本充足的长序列预测 |
Prophet 也不是不能用,但它的优势只在纯序列数据上,很难把流量特征、大促标记这些外部变量用足。树模型才是这个赛题的主战场。
4. 分仓规划建模:把预测误差写进约束,用线性规划求补货量
预测做完了,接下来的问题才是赛题的真正核心:怎么把预测结果转化为每个仓每一天的补货指令。补货决策不是简单地“预测 100 件就补 100 件”,因为要考虑安全库存、仓容上限、跨仓调拨成本、缺货惩罚这四组约束,稍有冲突就会算出不可执行的方案。这里最常见的解法是把问题建模成一个混合整数线性规划,用 PuLP 或 OR-Tools 求解。
4.1 从预测到补货:安全库存与缺货成本怎么定
预测模型输出的是一个均值。但实际需求围绕均值波动,你需要一个安全库存来缓冲这种波动。安全库存的常见做法是用预测标准差乘以一个服务水平系数 Z。服务水平设 90% 时 Z 取 1.65,设 95% 时取 1.96。也就是说,预测均值 100、标准差 15 时,安全库存为 1.65 × 15 ≈ 25 件,所以这天的合理库存水位至少是 125 件。
安全库存系数不是越高越好。缺货成本和服务水平之间的平衡点,要按实际业务来定。我的做法是先定三个参数:缺货惩罚(一般参考单件毛利加紧急配送成本)、仓储持有成本(按库存资金占用计算)、调拨成本(跨仓转运的单件运费)。这三个参数决定了目标函数里每一项的权重,数值设错,模型解出来的方案就会出现“囤了一堆货但是断货依然发生”的奇怪结果。
4.2 用 PuLP 建一个可复跑的分仓规划模型
这一节给一个可直接复跑的线性规划模型。决策变量是每个仓每个 SKU 每天的补货量,目标是让缺货惩罚、仓储成本和调拨成本的总和最小。约束包括库存流转恒等式、仓容量上限、以及服务水平要求。
import pulp # 输入参数 W = warehouse_list # 区域仓列表 S = sku_list # SKU 列表 T = 14 # 规划周期天数 forecast = {} # forecast[w][s][t] = 预测销量 init_inv = {} # init_inv[w][s] = 期初库存 capacity = {} # capacity[w] = 仓库存容量上限 shortage_penalty = 15.0 # 缺货惩罚,元/件 storage_cost = 0.02 # 仓储成本,元/件/天 transfer_cost = 3.5 # 调拨成本,元/件 prob = pulp.LpProblem("wh_plan", pulp.LpMinimize) # 决策变量:补货量、缺货量、库存量 x = pulp.LpVariable.dicts("order", (W, S, range(T)), 0, None, pulp.LpInteger) s = pulp.LpVariable.dicts("short", (W, S, range(T)), 0, None, pulp.LpInteger) inv = pulp.LpVariable.dicts("inv", (W, S, range(T + 1)), 0, None, pulp.LpContinuous) # 目标函数:缺货成本 + 仓储成本 + 调拨成本 prob += ( pulp.lpSum(shortage_penalty * s[w][s_][t] for w in W for s_ in S for t in range(T)) + pulp.lpSum(storage_cost * inv[w][s_][t] for w in W for s_ in S for t in range(T)) + pulp.lpSum(transfer_cost * x[w][s_][t] for w in W for s_ in S for t in range(T)) ) # 库存流转约束:库存 = 上期库存 + 补货 - 预测销量 + 缺货量 for w in W: for s_ in S: inv[w][s_][0] = init_inv[w][s_] for t in range(T): prob += inv[w][s_][t + 1] == ( inv[w][s_][t] + x[w][s_][t] - forecast[w][s_][t] + s[w][s_][t] ) # 仓容量约束:任意一天的库存总量不得超过仓容量 for w in W: for t in range(T + 1): prob += pulp.lpSum(inv[w][s_][t] for s_ in S) <= capacity[w] # 求解 prob.solve(pulp.PULP_CBC_MSG=0) print(f"求解状态: {pulp.LpStatus[prob.status]}")这个模型里最核心的是库存流转约束,它给出了库存的递推关系:今天的库存加上今天到的补货,减去今天的预测需求,加上允许的缺货量,等于明天的库存。缺货量设成整数变量,是因为缺货后这部分需求并没有消失,而是转移到下一期或者在账面上记录为欠货,允许模型在缺货惩罚较高时通过它来吸收预测偏差。仓容量约束则是防止模型把某个 SKU 的货全部堆到一个仓里。
目标函数三个系数的作用:
| 参数 | 取值 | 说明 |
|---|---|---|
| shortage_penalty | 15 元/件 | 缺货惩罚,应约等于单件毛利 + 紧急配送成本 |
| storage_cost | 0.02 元/件/天 | 持有成本,按库存资金占用年化折算 |
| transfer_cost | 3.5 元/件 | 调拨成本,含跨仓装箱和运输费用 |
注意 shortage_penalty 和 storage_cost 的关系。如果缺货惩罚设得太高,模型会疯狂堆库存,仓储成本随之飙升;设得太低,模型又宁愿缺货也不备货,服务率达不到要求。这是调参时最耗时间的一组矛盾。
4.3 求解结果怎么回写与验证
求解完成后,需要把 x[w][s_][t] 中非零的记录导出成一份补货计划表,格式一般是一行一条“仓 + SKU + 日期 + 补货量”。写完补货计划表之后,一定要做一次仿真回验:用这个补货计划去模拟未来 14 天的库存变化,逐日滚动计算缺货量和库存水位,而不是只盯着目标函数值大小。
回验时重点看两个指标:整体缺货比例不能超过 5%,爆仓比例不能超过 1%。如果爆仓率过高,说明仓容量约束太紧,可以考虑把部分 SKU 的补货周期拉长;如果缺货率过高,则需要提高安全库存覆盖天数。这一步跑通后,分仓规划模型才算真正可用,而不只是一堆求解成功的状态码。
5. 避坑:需求预测与分仓规划里的 5 个真实翻车案例
这里把我在类似赛题和实际供应链数据里踩过的五次最深坑整理出来,按“现象 → 原因 → 解决”的格式写,方便你对照自查。
5.1 回看窗口整体偏移,验证集指标虚高
现象:模型在验证集上 SMAPE 只有 18%,看起来已经达标;但一提交线上评测,分数掉到 29%,排名直接垫底。
原因:训练集和验证集的切分方式看似按时间切了,但特征里的滞后项和滚动窗口项用的是日期序列索引,而不是 shift 之后再对齐。结果验证集首日特征里混进了当天的销量,形成了泄漏。黑匣子一样的树模型对这类泄漏非常敏感,验证集指标虚高完全正常。
解决:统一用 shift(1) 之后的数据构造所有序列特征,并且把验证集整体往后挪一天再做一次冒烟测试,看指标是否显著变差。如果切分逻辑正确,结果不会因为整体平移一天产生巨大差异。
5.2 新仓和新 SKU 的冷启动预测全部预测为零
现象:新品上架或新仓开业的前 4 周没有历史销量,LightGBM 预测输出全是 0。分仓规划模型认为这些 SKU 不需要备货,导致新品上市首周即大面积断货。
原因:滞后特征和滚动特征在序列头部全是 NaN,树模型把 NaN 当作缺失后,学习到“没有历史=没有需求”的隐含规律,低估冷启动需求。
解决:对缺失的滞后特征做分组填充,填充值用同仓同品类 SKU 的历史均值;如果品类均值也没有,再用全体 SKU 的全局均值兜底。同时新增一个“缺少历史天数”的计数特征让模型感知冷启动状态,模型会据此自动调整预测值。
5.3 大促效应被滚动窗口特征平均掉
现象:大促前 3 天流量开始上涨,模型预测却还在平稳区间,直到大促当天才反应过来;大促结束后预测又居高不下,连续高估 5 天。
原因:滚动窗口均值对突发的需求尖峰天然滞后,大促前的小幅上涨被 7 天窗口均摊以后淹没了。
解决:加入促销事件特征,至少包含“距离大促开始还剩多少天”和“大促期间是否生效”两个字段。用赛题里自带的流量表加购数据也能显著缓解这个问题,加购量往往比销量提前 2 到 3 天爆发。
5.4 缺货惩罚设太高,预测越准库存越涨
现象:预测模型精度提升后,分仓规划算出来的补货量反而越补越多,仓库爆仓,库存周转天数从 15 天变成了 35 天。
原因:缺货惩罚设成了单件毛利的 10 倍,模型的安全库存水位被服务水平系数顶到上限,宁可多囤也不缺货。
解决:用“毛利损失 + 紧急配送成本”去标定缺货惩罚,而不是拍脑袋设一个很大的数。同时加一个约束:每个仓的总库存周转天数不得超过 25 天,从模型层面限制过度囤货。
5.5 分仓规划忽略了仓容量,求解结果根本执行不下去
现象:线性规划求解器输出了最优解,状态码显示 Optimal,但仓库运营团队反馈说 60% 的仓在第三天就爆仓了。
原因:库存流转约束只计算了库存量,但没有把每个 SKU 的体积和仓储占用面积算进去。大件 SKU 和小件 SKU 的存储占用量是不一样的。
解决:仓容量约束从件数约束换成体积约束。具体做法是增加一个 sku_volume 参数,把容量判断写成 sum(sku_volume[s] * inv[w][s][t]) <= capacity_volume[w]。这类坑属于业务约束和模型约束没对齐,赛题里不会提示,但实际落地时一定会遇到。
6. 让规划结果经得起回看:滚动回看窗口与两层验证技巧
模型和线性规划都跑通之后,最后一个建议是不要在校验集上只做一次评估就收手。我一般会做一个滚动回看实验:把历史数据切成 24 周,每次用前 20 周做训练,预测后 2 周,然后整体向后滚动一周再重复,最终得到 24 组预测结果。这 24 组结果的平均 SMAPE 和标准差,比单次切分的结果有说服力得多。因为单次切分只能说明模型在这个时间点没出问题,而滚动回看能暴露模型在淡旺季转换、大促前夜等不同阶段的稳定性差异。
第二层验证是分仓计划仿真。把 24 组预测结果分别灌进分仓规划模型,得到 24 份补货计划,再对每份计划做逐日库存模拟。看的是两个业务指标:断货率和周转天数。这两个指标的均值与极端值比目标函数值更能说明问题——目标函数是三者加权和,加权系数本身就有主观成分。
我自己最早做这类赛题时只跑了一版线上提交就收手,后来发现换一个回看窗口后结论完全反转:平时表现最好的模型在大促周竟然垫底。从此以后我给自己定了一条规矩——任何需求预测和分仓规划方案,不跑完滚动回看不算验证完。这个习惯帮我避免过不少线上翻车,也希望帮到你。
本文还有配套的精品资源,点击获取