简介:JDGOC仓储网络智能库存管理初赛数据,专为物流供应链、机器学习和运筹优化方向的学习者与参赛者准备。赛题背景源于京东全国近500个仓库的“211”时效履约场景:需要在预测各区域商品需求的基础上,通过调拨决策让库存合理分布于RDC与FDC,完成两阶段建模仿真。包内含15个文件,共16.62MB,核心为10个CSV数据表(覆盖SKU属性、历史销量、库存、促销、量化分位及需求分布等)和3个Python脚本(含仿真与提交模板),另附说明文档与编译模块,便于直接复现初赛流程。数据字段粒度细,既有预测目标又有调拨约束,适合多模型对比与时间序列分析。目前已有1152人学习浏览,对想发表物流供应链论文或提升实战技能的同学,是一份难得的从数据到仿真的全流程案例。
1. JDGOC 初赛数据里的仓储网络库存管理:先把赛题拆成看得见的两件事
JDGOC 初赛这份仓储网络库存数据,给的不是一堆打好标签的图片,而是某张网络过去几十周里每个 SKU 在每个仓库的库存、需求、到货和调拨流水。表面看是数据竞赛,本质上是让你用真实颗粒度的历史记录,回答补多少、什么时候补、放在哪个仓这三个决策问题。对参赛者来说最划算的路径,不是一上来就上强化学习,而是先把「需求预测 + 补货规则」这条主链路跑通。适合谁?准备冲榜单的选手、刚转供应链数据方向的工程师,以及想把手工 Excel 补货表换成可计算策略的计划员。这篇文章就按这份数据,把链路拆开讲一遍。
2. 先读懂初赛数据的长相:字段、粒度与时间边界
2.1 仓储网络数据不是一张大宽表,而是四张表拼出来的
拿到 JDGOC 初赛数据,第一件事不是写模型,而是把文件解压后逐个看表头。仓储网络智能库存管理的数据,通常按实体拆成四类:时序主表、SKU 档案、仓库档案和网络参数。时序主表是核心,记录每个 SKU 在每个仓库每天或每周的库存量、需求量、到货量和调拨量;SKU 档案给的是品类、体积、保质期这类静态属性;仓库档案给容量、仓库类型;网络参数则藏着提前期、补货周期和各类成本系数。
四类表不是生来能直接 join 的。不少初赛队伍把 SKU 档案里的品类维度直接按字符串塞进特征,结果模型没学到规律,反而把品类 ID 当成噪声。正确的做法是先确认每张表的主键和粒度:SKU 档案的主键是 sku_id,仓库档案是 warehouse_id,时序主表则是 sku_id + warehouse_id + date 三个字段组成的复合主键。对齐粒度后再做特征,才谈得上稳定性。
下表是最常见的字段形态,字段名在不同赛段可能略改,但语义基本一致。我拿到每份新数据都会做一遍映射,把字段名、语义、粒度和陷阱写进同一张表。原因很简单:初赛数据字段在复赛和实际业务里也可能重命名,只记单词不记语义,换个壳就不认识。
| 字段 | 语义 | 粒度 | 常见陷阱 |
|---|---|---|---|
| sku_id | SKU 编码 | SKU | 不要直接当数值特征 |
| warehouse_id | 仓库编码 | 仓 | 编码是 ID 不是大小 |
| date | 日期 | 日/周 | 时间切分不能乱 |
| stock_qty | 库存量 | SKU×仓×日 | 日末还是期初要确认 |
| demand_qty | 需求量 | SKU×仓×日 | 订单量还是出库量 |
| inbound_qty | 到货量 | SKU×仓×日 | 下单量还是入库量 |
| transfer_qty | 调拨量 | SKU×仓×日 | 调出为正还是为负 |
2.2 口径检验:用等式确认「日末库存」「出库需求」「入库到货」
字段名看懂了,不等于数据能直接用。同一个叫 stock 的字段,可能是当日末结存,也可能是次日清晨盘点值;同一个 demand_qty,可能是顾客订单量,也可能是实际出库量;inbound_qty 可能是下单量,也可能是验收合格后的入库量。口径不同,直接决定补货模拟里的订单要在哪一天生效。
我的习惯是拿连续几行做等式检验:昨日库存 + 今日到货 + 今日调出 - 今日需求 = 今日库存。数据里有调拨入和调拨出双向时,公式里要把调拨净额也放进去。对不上的记录占比超过千分之一,就得去查是不是漏了退货入、报废出或盘亏盘盈字段;如果对得上,说明口径严密,可以放心用滞回特征。
以下代码就是把这条等式落到 pandas 上的标准写法,注意必须先按 SKU×仓×日期排序,再分组取 shift(1),否则上一行根本不是前一天:
import pandas as pd df = pd.read_csv("inventory_main.csv", parse_dates=["date"]) df = df.sort_values(["sku_id", "warehouse_id", "date"]) g = df.groupby(["sku_id", "warehouse_id"]) df["last_stock"] = g["stock_qty"].shift(1) # 假设数据已规范化:需求为正数,调拨量为净流入(正为调进,负为调出) df["calc_stock"] = ( df["last_stock"] + df["inbound_qty"] + df["transfer_qty"] - df["demand_qty"] ) df["diff"] = (df["calc_stock"] - df["stock_qty"]).abs() bad_ratio = (df["diff"] > 1e-6).mean() print("库存等式不一致占比:", bad_ratio)这段代码里最需要解释的是 shift(1) 之前的排序。groupby 后的 shift(1) 取的是「同一个 SKU×仓组合里上一行的值」,只有先按日期排序,上一行才等于前一天。等式不平衡的记录占比超过千分之一时,先不要急着改模型,回头查原始 CSV 里是否有单独字段记录退货和报废,把公式补全再做下一步。这一步的花费十分钟,后面省下的排错时间是十倍不止。另外注意,如果初赛数据把需求记成负数、调出记成正数,要先把符号统一,否则等式恒不成立。
2.3 时间边界与网络参数:预测起点、历史窗口、补货节奏
时序数据最要紧的是划清两段时间:可观测的完整历史期,和需要预测的评测期。评测期起始日就是预测起点,之后所有真实值都不能进入特征;训练集和验证集必须按时间切,不能随机打乱。初赛一般给出前几十周,要求预测后几周或直接产出补货计划,窗口长度直接影响模型能不能学到季节和促销模式。
历史窗口的选取有两个极端。窗口太短,模型只看到最近一两周,遇到季度性品类就测不准;窗口太长,早期促销和缺货噪声把信号稀释。我一般先用 8~12 周滚动特征做一版 baseline,再看验证期分段表现:如果头两周准、后面几周差,往往不是窗口问题,而是模型没见过足够长的节假日形态,需要在特征里补上日历字段和滞后若干周的同期对比。
数据里如果有补货周期和提前期字段,把它们单列出来。补货周期决定多久能下一次单,提前期决定下单后多久到货,两者直接参与补货模拟,不读清这两项,后面所有安全库存计算都是空中楼阁。可以把它们当成网络参数中的两个固定值,不同的 SKU×仓可能有不同取值,建模时先按众数统一处理,最后再按分组差异微调。这一节做完,你对这份数据的理解就足够支撑后续全部建模。
3. 用 LightGBM 做一版需求预测基线:特征、训练与回测一次跑通
3.1 为什么先做需求预测:库存决策的输入是未来需求
补货决策依赖三件事:当前库存、未来需求、提前期的到货节奏。当前库存从数据里直接读,未来需求只能预测,所以需求预测是整条链路里最需要花时间的部分。JDGOC 这类赛题,评分最终落在缺货率和库存周转上,但缺货率不会因为你把补货参数调到极致就消失,预测的系统性偏差才是根因。
需求预测有两个绕不开的特性。一是长尾:大量 SKU 平时没销量,偶尔一次性出几十件,均值和方差都不可靠。二是层级结构:单 SKU×仓的时间序列又短又抖,但按 SKU 汇总之后规律明显。常见做法有两条路:先做 SKU 级预测,再按历史销量占比拆分到仓;或者直接做 SKU×仓级预测,在特征里带上 SKU 汇总量。我一般选后者,因为后续补货模拟就在 SKU×仓粒度上跑,直接预测能省去拆分误差,树模型也能靠 SKU 汇总特征补上层级信息。
选 LightGBM 而不是更花哨的深度模型,原因很朴素:这个数据规模和特征数量对树模型更友好,训练速度快,可解释性好,初赛阶段一天能迭代十几个版本。深度时序模型在这个粒度上优势不明显,还容易出现过拟合,性价比不高。等基线跑通、确认瓶颈确实在预测精度之后再换模型不迟。
3.2 特征设计:滞后、滚动统计与类别特征的取舍
树模型对特征工程并不完全免疫。时间序列预测里最稳定的一类特征是滞后序列和滚动统计:过去 1 天、7 天、14 天的需求,以及过去 7 天、14 天、28 天的滚动均值。滚动窗口的选择要结合补货周期:如果数据里补货周期是 7 天,过去 7 天的滚动均量就比单日滞后更有业务含义,因为它恰好覆盖一个补货周期内的平均消耗。
类别特征上,SKU 品类、仓库类型、星期几、月份都有用。sku_id 和 warehouse_id 这两个 ID 字段,我一般不完全丢弃,但不会把 sku_id 这种上千个类别的 ID 直接扔给模型;而是保留 warehouse_id,同时把 SKU 维度的统计量加工成特征。下面是一段完整特征构造函数,关键点在注释里:
def build_features(df, target_col="demand_qty", windows=[7, 14, 28]): # 必须按 SKU×仓×日期排序,保证 shift 取到真正的历史值 df = df.sort_values(["sku_id", "warehouse_id", "date"]).reset_index(drop=True) g = df.groupby(["sku_id", "warehouse_id"])[target_col] # 滞后特征:过去第 w 天的需求量,不含今天 for w in windows: df[f"demand_lag_{w}"] = g.shift(w) # 滚动统计:先 shift(1) 排除今天,再 rolling 平均 for w in windows: df[f"demand_roll_{w}"] = g.transform( lambda x: x.shift(1).rolling(w).mean() ) # 28 天滚动最大值,捕捉近期峰值 df["demand_max_28"] = g.transform( lambda x: x.shift(1).rolling(28).max() ) # 日历特征:星期几、月份,对周用品和季节品有效 df["weekday"] = df["date"].dt.weekday df["month"] = df["date"].dt.month return df注释里藏着一个最重要的动作:先 shift(1) 再 rolling。如果反过来先 rolling 再 shift,窗口里会包含今天的真实需求,那就是把答案写进考卷。另一个细节是 lag 特征和 rolling 特征同时存在时,树模型会自动选择哪一边信息量更大,所以两者都放,不用提前做特征选择。运行前先打印 df.isna().sum(),滞后窗口会让前面几行出现空值,这部分要在训练时去掉,而不是把空值填 0 硬喂给模型。
3.3 训练与回测:时间切分、早期停止和 WAPE 评估
训练 LightGBM 本身不复杂,复杂的是怎么切验证集。初赛数据的评测期是一个连续时间块,验证集就应该模拟同样的形态:拿历史数据最后 20% 作为验证期,而不是随机打散。随机打散会让模型看到未来数据,验证集指标虚高,提交后分数立刻崩掉,这是时间序列赛题最常见的第一道坎。
评估指标我用 WAPE(加权绝对百分比误差)而不是单纯 MAE。因为不同 SKU 销量差异巨大,MAE 会被大 SKU 主导;WAPE 用实际需求做分母,相当于把误差按销量权重归一,更贴近缺货率和库存成本的业务感觉。
import lightgbm as lgb cut = int(len(df_features) * 0.8) train = df_features.iloc[:cut] valid = df_features.iloc[cut:] feature_cols = [ c for c in df_features.columns if c.startswith("demand_") or c in ["weekday", "month", "warehouse_id"] ] model = lgb.LGBMRegressor( n_estimators=800, learning_rate=0.05, num_leaves=63, subsample=0.8, colsample_bytree=0.8, random_state=42, ) model.fit( train[feature_cols], train["demand_qty"], eval_set=[(valid[feature_cols], valid["demand_qty"])], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)], ) valid_pred = model.predict(valid[feature_cols]) wape = (valid["demand_qty"] - valid_pred).abs().sum() / valid["demand_qty"].sum() print("WAPE:", wape)这里的参数不是玄学,而是有明确逻辑:learning_rate=0.05 配合 800 棵树,是典型的小步长大容量组合,配 early_stopping 防止过拟合;num_leaves=63 控制在几百条序列数据下的模型复杂度,数据量大了再往上加;subsample 和 colsample_bytree 是两路随机抽样,既能防过拟合又能让每棵树看到的数据视角不同,对长尾 SKU 稍有帮助。如果验证 WAPE 明显高于训练集,先检查特征里是否泄漏了未来信息,再决定是否调参,顺序不能反。
4. 从预测到补货:安全库存与 (s, S) 补货规则的三个关键参数
4.1 预测均值不是补货量:订货点与目标库存的联动
把需求预测直接当补货量,是初赛新手最典型的第一反应。预测均值只能回答「平均每天卖多少」,回答不了「提前期里万一卖超了怎么办」。补货决策里有两个基本动作:库存降到什么水平必须下单,下单要补到多高。前者叫订货点 s,后者叫目标库存水平 S,两个值一起构成 (s, S) 规则。
经典公式为 SS = z × σ_d × sqrt(L),其中 z 是服务水平对应的标准正态分位值,σ_d 是需求日标准差,L 是补货提前期天数。订货点 s = 日均需求 × L + SS。当库存低于 s 时触发补货,下单量 = S - 当前库存。这里的 S 一般取「补货周期 × 日均需求 + 安全库存」,也就是把未来一个完整补货周期的消耗都备上。
(s, S) 和固定周期补货的区别在于:固定周期到点就下,不看库存;(s, S) 按水位触发,卖得慢的 SKU 自动减少下单频次,库存周转更好。初赛数据里如果仓库的补货行为本来就有「低于阈值才下单」的特征,用 (s, S) 拟合历史行为比固定周期更贴合。第一次跑通基线时,我建议先把 σ_d 用历史标准差估计,后面再换更稳的估计方式。
4.2 分位数预测代替点预测:把不确定性直接喂给补货规则
安全库存公式里最难估的输入是 σ_d。用历史数据算标准差,遇到需求结构性变化就不准;而且 SKU 越多,手工调 σ 的成本越高。更稳的做法是用分位数回归直接预测需求的分位值,比如分别预测 50% 和 80% 分位,然后拿两者的差近似安全库存的波动项。LightGBM 的 objective="quantile" 可以直接做这件事,alpha 指定分位数。
models = {} for alpha in [0.5, 0.8]: m = lgb.LGBMRegressor( objective="quantile", alpha=alpha, n_estimators=500, learning_rate=0.05, num_leaves=31, random_state=42, ) m.fit(valid[feature_cols], valid["demand_qty"]) models[alpha] = m q50 = models[0.5].predict(valid[feature_cols]) q80 = models[0.8].predict(valid[feature_cols]) volatility = q80 - q50 # 波动项,替代 σ_d 的估计这个技巧的代价是训练两个模型,时间和算力翻倍,但换来的是补货模拟里缺货率大幅下降。原因很直接:分位数模型对长尾 SKU 更敏感,50% 分位给出居中预测,80% 分位自动在波动大的 SKU 上抬高库存保护线。对单序列短的初赛数据,这比手算标准差稳得多。验证阶段可以多训一个 95% 分位,看看缺货率和库存成本之间的曲线,再决定服务水平放在哪一档。volatility 这个值在后续 (s, S) 规则里可以直接乘以 sqrt(L) 使用。
4.3 补货参数怎么设:提前期、补货周期与服务水平的取舍
服务水平 z 值通常查正态表,但实际调参时我很少看表,而是按 90%、95%、98% 三档去扫。三档对应的 z 值大致是 1.28、1.65、2.05。差别看似不大,安全库存的增幅却是指数级的:从 90% 提到 95%,安全库存约涨 30%;从 95% 提到 98%,再涨 25%。所以初赛数据里如果有成本字段,一定要把缺货成本和持有成本都读出来,再做取舍。
| 服务水平 | z 值 | 安全库存倍数(相对90%档) | 适用场景 |
|---|---|---|---|
| 90% | 1.28 | 1.00 | 缺货成本低,周转优先 |
| 95% | 1.65 | 1.29 | 缺货与持有均衡 |
| 98% | 2.05 | 1.60 | 缺货罚款高或服务承诺强 |
补货模拟的顺序对结果影响极大:每天先处理到货,再扣需求,然后检查是否低于 s;低于就下单,提前期后到货。数据口径可能是「先扣需求再处理到货」,两者在边界日期的结果相反,必须先用一个简单策略小批量提交,对比本地与线上的差异。下面是补齐了补货间隔约束的模拟函数:
def simulate(seq, s, S, lead_time=3, review_interval=7): inv = 0 queue = [] shortage = 0 total_inv = 0 last_order_day = -review_interval # 初始允许下单 n = 0 for demand in seq: # 到货先入库(口径一),入库的是提前期前下的单 if len(queue) >= lead_time: inv += queue.pop(0) inv = max(0, inv - demand) if inv == 0: shortage += 1 # 距上次下单超过 review_interval 天才允许再下 if n - last_order_day >= review_interval and inv <= s: queue.append(S - inv) last_order_day = n total_inv += inv n += 1 return shortage, total_inv / n函数里 queue 用先进先出模拟提前期,lead_time 天的货到期自动入账;review_interval 表示两次下单的最小间隔,避免每天都触发补货。缺货时把库存置 0,是为了统计缺货天数而不是深挖缺货深度,大多数评分脚本也只统计前者。跑完之后打印 shortage 和平均库存,和 90%、95%、98% 三档做对比,选缺货率与库存成本同时可接受的那组参数。注意如果提前期是 0,队列逻辑要单独处理,不过初赛数据很少出现这种情况。
5. 仓储网络库存赛题的避坑清单:五个最常翻车的环节
这一章算是这段赛程的血泪经验集合。初赛的时间线很短,队伍之间差距往往不来自模型高低,而来自谁能少踩坑、早迭代。以下五个问题,是类似赛题里真实遇到过、并且每一个都至少浪费过一轮提交机会的坑。列出来不是吓唬人,而是让你在写代码前就知道去哪检查。
5.1 时间泄漏:随机切分让验证集指标虚高
现象:训练时为了省事,把全量数据 random_state 一设直接打乱切 train/valid,验证集 WAPE 很漂亮,提交线上分数却明显变差。
原因:库存数据严格按时间演化,随机切分会让训练集里的「未来几周」信息在验证期看得到。树模型最擅长背答案,验证集等于开卷考试。
解决:一律按时间分界点切分,验证期必须晚于训练期;更严格的做法是先切分再做特征工程,避免用验证期数据计算滚动均值等统计量。判断是否泄漏有个笨办法:把验证集最后一周真实值改成 0 再预测,如果预测值跟着大跳水,说明特征里带着未来信息的尾巴。
5.2 特征错位:SKU 级特征喂给 SKU×仓级模型
现象:模型整体指标还行,但错误集中在少数几个大仓,按 warehouse 分组看 WAPE 一个天上一个地下。
原因:特征在 SKU 粒度计算,标签却在 SKU×仓粒度。两者是一对多的映射,树模型没有「按仓拆分」的能力,只能把 SKU 汇总值的误差随机摊到各仓头上。
解决:所有特征与标签统一到同一粒度。想要保留 SKU 汇总信息,可以构造「SKU 汇总量 - SKU×仓自身量」这样的兄弟体量特征,再把 warehouse_id 作为类别特征交给模型。粒度错位在初赛数据里很难肉眼发现,因为它只表现为某些仓的误差集中,所以做特征前先画一张「特征粒度 × 标签粒度」的对照表,比事后排查便宜得多。
5.3 滞后特征串未来:rolling 和 shift 的顺序反了
现象:训练集 WAPE 极低,验证集却突然高出一截;重新跑一遍又不稳定,换个随机种子指标就变。
原因:rolling 内包含了当天数据。比如先 rolling(7) 再 shift(1),窗口覆盖的是「昨天到 6 天前」;但正确写法是先 shift(1) 再 rolling(7),窗口覆盖「前天到 8 天前」。顺序反了就会把今天的真实需求灌进特征,树模型会优先学这条穿上隐形衣的捷径。
解决:固定写法是先 shift(1) 再 rolling(w),并在代码注释里写清。我在每次特征版本切换后都会用一条历史窗口可视化核对:取某个 SKU×仓,打印最后一周特征值,和原始需求序列对比,能一眼看出有没有穿墙。特征工程里的穿墙问题不报错,只能靠这种笨办法抓出来。
5.4 补货模拟口径不一致:到货与扣减顺序对不上评分脚本
现象:本地模拟缺货天数 2 天,线上却报 8 天,差异大到无法解释,改参数也没用。
原因:评分脚本可能先扣需求再处理到货,本地函数先处理到货再扣需求。当提前期很短、货到当天又有需求时,两种顺序结果完全不同;订单截止时间点差一个小时,缺货统计就差一天。
解决:先用一个最简单的无脑策略(比如每天补满到 S)小批量提交,对比线下线上缺货天数差,定出口径;再通过数据反推:查看昨日库存 + 今日到货 - 今日需求与今日库存的等式,能够判断到货发生在订单日之前还是之后。确认后把 simulate 函数里的顺序调成和评分脚本一致,再谈调参。
5.5 模型目标与业务指标脱节:对 MAE 但评分看缺货率
现象:预测模型在验证集 MAE 很低,但接入补货模拟后缺货率始终压不下来,调补货参数也不见好。
原因:MAE 对称惩罚高估和低估,而缺货率只惩罚低估。当需求预测偏向均值以下的系统性偏差积累,安全库存再高也兜不住;高估的部分被库存成本惩罚,低估的部分被缺货率惩罚,两个指标的权重不一样。
解决:把损失函数换成 pinball loss,或直接用 80% 分位预测作为补货输入;至少对高销量 SKU 加大损失权重。让模型目标向业务指标靠拢,比事后调 (s, S) 参数更治本。这里有一个老生常谈但值得重复的判断标准:如果预测模型和补货模拟是两组人在调,那最终分数一定不好看,因为两边优化的不是同一个东西。
6. 进阶:从单仓补货走向网络调拨,以及交付前的最后验证
6.1 多仓之间的调拨逻辑:库存冗余可以互相吃掉
单仓 (s, S) 跑稳之后,初赛的「仓储网络」才真正显形。多仓网络里,某个仓缺货时另一个仓可能还有库存,调拨能用一个整体的服务水平换更低的库存成本。调拨决策的第一步是看网络拓扑:数据里给出的是中心仓到前置仓的星型结构,还是仓与仓之间可以任意互调。我的做法是先算出每个 SKU 在外仓的总库存,再按补货周期之外的紧急缺口做调拨,而不是每次缺货都调;调拨本身有成本,调得越频繁,省下的缺货损失越少。
调拨模拟里最怕的是把调出仓自己的订单忘了扣。模拟项目里曾经出现过把调拨量加到调入仓的到货队列,却没从调出仓库存里减掉,结果整个网络库存凭空多出一截;这种只看整体对不上明细的错,用汇总表一检查就露馅。所以调拨逻辑上线前,先构造一个两仓、两天的假数据手算一遍,确认守恒,再放开到全量。
6.2 交付前的最后验证:双指标加分段稳定性检查
最后一晚不要只盯榜单位置,我会跑两个验证:一是累计缺货天数,二是平均库存天数(周转的倒数)。缺货率低但平均库存很高,策略没有实用价值;库存低但缺货率高,服务不达标。两个指标一起看,才能判断策略是不是在硬撑。只看一个指标很容易自我安慰,双指标才是照妖镜。
分段稳定性检查就是把评测期按每周切片,分别统计每周的缺货天数和库存天数。如果某周突然恶化,去查那周是否遇到节假日或促销,结合日历特征回看模型有没有捕捉到。我的习惯是先把这周单独拉出来可视化,再决定是改特征还是加规则,而不是直接调模型参数。每次提交前按这个流程走一遍,心里才有底。这套流程做完,一个能打榜单、也能落到实际仓库排期的方案就齐了。希望帮到你。
本文还有配套的精品资源,点击获取