首页 / 资讯中心 / 文章详情

天池菜鸟需求预测与分仓规划:从特征工程到供应链优化

天池菜鸟需求预测与分仓规划:从特征工程到供应链优化 ★ FEATURED ARTICLE
简介天池菜鸟需求预测与分仓规划第二赛季参赛源码及说明是一套完整的竞赛项目实现。资源面向计算机、通信、人工智能、自动化方向的学生、教师和从业者聚焦电商物流场景下的需求预测与分仓规划问题适合作为算法学习、课程设计或毕业设计的参考。压缩包共一百一十三个文件大小约二十四兆字节包含源码、编译后的类文件、依赖库、配置及数据脚本等多种类型目录结构清晰便于阅读和调试。已有一百七十三人学习下载。源码经过调试测试可直接运行并附有项目说明帮助理解从特征工程、回归模型到任务调度与结果输出的完整流程。基础较好的读者可在此基础上修改调整扩展至其他预测与规划场景。整体内容具有较高的学习借鉴价值既适合小白入门也能满足进阶需求。1. 天池菜鸟需求预测与分仓规划第二赛季拿到源码先别急着跑模型天池菜鸟需求预测与分仓规划第二赛季把两个在供应链里本来分开的问题硬凑在一起先预测每个 SKU 在各地仓的需求再分配库存进出目标是让仓配总成本尽量低。这个比赛翻车最多的不是模型而是评测链路——很多人把销量当成需求、把预测分当成提交分等到分仓阶段才发现误差早就在黑匣子里滚大了。这套高分源码的系统值钱之处一是把需求口径和分仓成本函数对齐了二是给出了一组能稳定跑过验证窗口的特征和分配参数。适合想从纯预测选手转做供应链优化的人也适合想快速把 baseline 换成更高分方案的人。下文按做这个题目最通用的一条落地路径展开拆清赛制、重构预测模块、实现分仓规划、再处理那些不回头看就会翻车的坑。2. 拆开赛制需求预测是前置模块分仓规划才是最终得分点先明确一件事这个题目不是“预测得准就能拿高分”。预测只是前半程官方真正评判的是你根据预测给出的分仓方案。所以拿到源码的第一件事不是打开模型文件而是把题目给的评测口径读三遍预测什么、什么时候预测、分仓结果怎么扣分。2.1 需求预测阶段先把粒度、需求口径、预测窗口对齐预测阶段最常见的对象是 SKU 级别的日需求但不同赛季给的数据范围不一样。很多选手一开始就踩坑是因为没有区分“订单需求”和“实际发货量”。正常情况下两者接近一旦出现缺货发货量会明显小于需求。如果拿发货量做训练标签模型学到的其实是“仓库有多少货就发多少”而不是“市场到底要多少”。这会让预测结果整体偏保守到了分仓规划阶段缺货惩罚会把你偏保守的毛病放大两倍。我拿到源码后的习惯是先看它的标签列怎么构造。如果标签直接等于销量流水里的发出数量我会额外做一步处理把缺货时段修正为“销售需求 补回需求”或者至少把缺货标记成特征让模型知道这是异常值而不是常态。这个口径问题不解决后面所有特征工程都是在错误的地基上盖楼。预测窗口也要提前确认。有的题目预测未来 7 天有的预测 14 天甚至更长。窗口不同滞后特征的阶数完全不一样。预测 7 天时滞后 7、14、28 天是主力预测 14 天时要额外加入滞后 9、10、15 天来捕捉周节奏错位。简单说目标窗口越长模型越依赖滚动统计量而不是单日滞后值因为单日滞后值在长窗口下会迅速过时。粒度方面虽然数据表里可能有仓库、区域、渠道等多个维度但真正参与训练的最小单元最好定在“仓库 SKU 日期”。如果某个维度在官方提交表中根本不存在就不要把它揉进主模型否则线上推理时特征会缺失。这里有个经验预测粒度尽量向提交粒度看齐特征粒度可以比提交粒度更细但训练和推理必须保持同一套分组逻辑。2.2 分仓规划阶段库存分配本质是一个成本最小化问题分仓规划的核心不是“哪个仓缺货就补哪个仓”而是“在仓库容量和成本约束下每个 SKU 的库存怎么分布能让整个网络的期望成本最低”。我在这个题里总结出的目标函数大致是缺货成本 缺货数量 × 单位缺货惩罚持有成本 期末库存 × 单位持有成本仓配成本 分配库存量 × 单位运输成本整体目标就是让三者之和最小。缺货惩罚通常是三四个系数里最大的因为竞赛想逼你优先保证服务水平。持有成本次之它专门惩罚盲目堆库存。仓配成本的弹性最大决定你愿意把库存放到离需求近但贵的仓库还是放到便宜但远的仓库。这个阶段不能只看预测均值。预测均值只能代表期望需求而库存决策面对的是分布同样一个 SKU未来七天需求可能是 100 件但方差可能是 30 件。如果你只按 100 件备货服务水平大概率只有 50% 左右。高分源码里几乎都会用“预测值 安全库存”的思路把预测残差的标准差乘以一个系数加进去。这个系数不是玄学它由目标服务水平反推常见取值在 1.0 到 2.0 之间。分仓规划还要处理仓库容量。容量可以是硬约束也可以是软约束。硬约束是仓库最多放多少货超了直接非法软约束是超了会有额外惩罚但方案仍然有效。我建议在源码里优先做硬约束版本因为它逻辑清晰、调试容易等硬约束版本跑通了再根据官方口径改成软约束都不迟。2.3 串行流程和评测边界先预测后分配为什么这条链路最多人翻车这个题目绝大多数参赛方案都是串行结构先训练一个预测模型输出未来 N 天的需求预测表再写一个分配模块把预测表转成分仓方案。串行流程的好处是每段都能单独验证坏处是预测误差会直接传导到分配阶段。预测误差如果是随机噪声分配模块还能勉强扛住预测误差如果存在系统性偏差比如高估热销品、低估长尾品分配方案会呈现非常难看的“头部仓爆仓、尾部仓空空”。有人会尝试端到端训练直接拿分仓成本当损失函数。这个方向理论上很优雅实际操作难度极高因为分配过程带约束梯度很难回传。我见过的可行做法是把分配模块做成可微近似再训练但那样代码复杂度和调参成本都翻倍。对这个赛题来说串行方案在性价比上碾压端到端先跑通串行拿到稳定分数再考虑端到端优化。拆完题之后我会按下面三步检查源码结构。第一预测模块的输出列是否包含 SKU、仓库、日期、预测需求量缺一不可。第二分配模块的输入是否和预测模块输出完全对齐列名不能有差异。第三提交文件是否由分配模块直接生成而不是手工加工。这三步看起来很笨但能拦住八成低级错误。3. 需求预测模块落地用特征工程和 LightGBM 跑出稳定 baseline需求预测这个模块高分和低分方案的差距通常不在模型类型而在特征构造和验证方式。最有效的模型还是梯度提升树尤其是 LightGBM因为这类表格数据里非线性关系多、交互项多树模型能自动处理不需要花大力气做特征标准化。3.1 特征构造滞后、滚动、稀疏序列的默认参数我一般把特征分成五类时间类、滞后类、滚动类、稀疏类、外部事件类。时间类包括星期几、月中第几天、是否周末、是否月底。这类特征对电商需求很重要因为消费者行为有明显的周律和月末效应。滞后类是需求预测的骨架。对“仓库 SKU”分组之后直接取 lag_1、lag_7、lag_14、lag_28 是最稳的一组默认值。滞后 1 是短期惯性滞后 7 是周节奏滞后 14 和 28 是中期趋势。注意滞后必须是严格的历史值即至少 shift(1)绝对不能用 shift(0)否则训练时会把当天真实值送进特征看起来分数很高一到线上就现原形。滚动类特征用来替代部分滞后特征。滚动 7 日均值能平滑短期波动滚动 28 日总和能捕捉月度规模。这里有个参数很关键min_periods。对于长尾 SKU历史大部分天数是零如果滚动窗口要求全部有值会有大量缺失行。我会把min_periods设为窗口的一半比如滚动 7 天至少 3 个有值这样既能保留粒度又不会因为稀疏让样本被丢弃。稀疏类特征专门处理销量长期为零的 SKU。零销量本身也是信息说明商品处于冷启动或滞销期。我会构造“近 7 天有销量天数”“近 28 天有销量天数”让模型学会区分偶尔波动和持续活跃。外部事件类特征看题目是否提供比如促销标记、节假日标记有的话直接做成 0/1 特征没有就不硬造。3.2 LightGBM 三个必调参数和一组能快速跑通的基础配置LightGBM 默认参数在多数表格任务上能用但需求预测这种高噪声、长尾分布的数据有三个参数值得手动调。第一个是num_leaves。默认 31 在复杂特征下容易欠拟合我通常开到 63 到 127但要注意配合min_data_in_leaf否则叶子太多会过拟合噪声。第二个是min_data_in_leaf。这个参数直接控制叶子节点最少样本数默认 20 在稀疏序列上偏高我一般降到 5 到 10让模型有机会学到长尾商品的模式。第三个是feature_fraction和bagging_fraction。这两个可以同时开能明显降低方差对最终稳定性贡献很大。目标函数的选择也值得注意。如果官方指标是 RMSE 或 RMSLE直接用回归目标即可。不过销量数据是明显的计数分布存在大量零值和少数大值用 Poisson 目标往往比回归更贴合分布。如果嫌 Poisson 难收敛可以先对标签做log1p变换再用回归训练推理时做expm1还原。很多人忽略这一步结果模型为了拟合爆款大值把小 SKU 全部预测偏低。一组能快速跑通的基础配置大概是这样的学习率 0.05叶子数 63最小样本数 10特征抽样 0.8袋外抽样 0.8L2 正则 1.0训练轮数 2000 轮配早停。这个配置在多数赛季数据上都不会翻车可以作为调参起点。3.3 需求预测最小可运行代码从销量表到预测表下面这组代码是需求预测模块的最小骨架我通常拿它作为 baseline再逐步加特征。import lightgbm as lgb import pandas as pd import numpy as np # 读取销量流水最少包含四列日期、仓库、SKU、销量 df pd.read_csv(sales.csv, parse_dates[date]) df df.sort_values([warehouse, sku, date]) # 严格的历史滞后shift(1) 起步避免泄漏 df[lag_1] df.groupby([warehouse, sku])[qty].shift(1) df[lag_7] df.groupby([warehouse, sku])[qty].shift(7) df[lag_14] df.groupby([warehouse, sku])[qty].shift(14) df[lag_28] df.groupby([warehouse, sku])[qty].shift(28) # 滚动均值窗口 7至少 3 个非空值 df[roll7_mean] ( df.groupby([warehouse, sku])[qty] .transform(lambda s: s.shift(1).rolling(7, min_periods3).mean()) ) # 稀疏度特征近 7 天有销量天数 df[active_days_7] ( df.groupby([warehouse, sku])[qty] .transform(lambda s: (s.shift(1) 0).rolling(7, min_periods1).sum()) ) # 时间特征 df[weekday] df[date].dt.weekday df[is_month_end] df[date].dt.is_month_end features [lag_1, lag_7, lag_14, lag_28, roll7_mean, active_days_7, weekday, is_month_end] train df[df[date] 2023-06-01] valid df[df[date] 2023-06-01] dtrain lgb.Dataset(train[features], labeltrain[qty]) dvalid lgb.Dataset(valid[features], labelvalid[qty], referencedtrain) params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 63, min_data_in_leaf: 10, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, num_threads: 4, verbosity: -1, } gbm lgb.train( params, dtrain, num_boost_round2000, valid_sets[dvalid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)], ) # 推理得到预测表 df[pred_qty] gbm.predict(df[features], num_iterationgbm.best_iteration)这段代码的逻辑很直白先把销量表按仓库和 SKU 分组排序然后逐列构造特征。shift(1)的目的是让滚动统计只看历史不看当天避免未来信息泄漏。active_days_7是稀疏序列的关键特征它会告诉模型“这个 SKU 最近到底活不活跃”。参数里最需要理解的是min_data_in_leaf10。如果这个值太大模型会对长尾 SKU 不敏感因为它们很难凑够样本数如果太小又会学到单样本的极端模式。对多 SKU 需求预测来说10 左右是兼顾两者的起步点。early_stopping(100)表示验证指标连续 100 轮不提升就停止训练这个值太大会浪费时间太小会过早截断100 是经验值。3.4 验证切分按时间滚动不按 SKU 随机分需求预测的验证不能像普通分类任务那样随机切分。随机切分会把同一时间段的相似样本同时分进训练集和验证集造成验证分数虚高。正确做法是按时间切分训练集永远是历史验证集永远是未来。如果计算资源充足可以用滚动窗口验证比如第一次用 1 到 120 天训练预测 121 到 127 天第二次用 1 到 127 天训练预测 128 到 134 天。这样得到的验证误差更贴近线上真实状态。如果资源有限至少做一次“训练用前 80% 时间、验证用后 20% 时间”的切分。这里有个很容易被忽视的点生成特征时不能对全量数据一次性 shift那样会让验证集的特征用到训练集之后的信息。正确做法是先按时间切分好训练集和验证集再在训练集内部构造特征最后用同样的逻辑单独构造验证集特征。源码里如果直接对全表groupby再 shift验证集分数会虚高不少。4. 分仓规划模块把预测结果变成各仓库的补货数量预测模型输出的是未来一段时间的需求表但官方要的是分仓方案。这个模块的核心任务是把每个 SKU 的预测需求转化为“在这个仓库放多少货、在那个仓库放多少货”同时满足容量和成本约束。4.1 目标函数和约束缺货惩罚、持有成本、仓库容量怎么选分仓规划的第一步是明确目标函数。我给一个通用模板对每个仓库、每个 SKU分配量记为 X需求预测值为 D那么期望成本大致是总成本 Σ max(D - X, 0) × 缺货惩罚 Σ X × 持有成本 Σ X × 仓配成本缺货惩罚应该设为最大的系数通常是持有成本的 5 到 10 倍。这是因为线上评测对服务水平的关注远高于库存周转。仓配成本按仓库和 SKU 的组合给系数热门仓库可以设更高冷门仓库更低从而引导模型把安全库存放在偏远但便宜的仓。容量约束分两种。总容量约束要求所有 SKU 放在某个仓库的库存总量不超过该仓容量这是最常见的形式。还有一种分 SKU 约束比如单个 SKU 在单个仓库最多放多少这种约束通常来自商品体积或保质期限制。写代码时先把容量数据读进来统一存成 DataFrame再在分配函数里做校验。另一个参数是安全库存系数。预测模块输出的预测值和预测残差标准差经过组合后变成目标库存。公式为目标库存 预测值 z × 标准差。z 取 1.65 对应约 95% 服务水平取 1.28 对应约 90% 服务水平。这个值不是越高越好因为持有成本会随库存量线性上涨需要根据成本系数反推。4.2 一个先能跑的启发式分位数目标库存 容量缩减先别急着上线性规划。对多数赛题规模来说一个简单的启发式分配就能拿到相当高的分数而且代码量少、调试容易。下面是我常用的第一版分配函数。import numpy as np import pandas as pd def allocate_by_quantile(pred_df, z1.65, capacity_ratio1.0): # pred_df 至少包含四列sku, warehouse, pred_qty, pred_std df pred_df.copy() # 安全库存预测均值 z 倍标准差 df[target_stock] (df[pred_qty] z * df[pred_std]).clip(lower0) # 如果仓库总容量不足按预测比例缩减目标库存 if capacity_ratio 1.0: sku_target_total df.groupby(sku)[target_stock].transform(sum) sku_pred_total df.groupby(sku)[pred_qty].transform(sum) scaled df[pred_qty] / sku_pred_total * sku_target_total df[target_stock] np.minimum(df[target_stock], scaled * capacity_ratio) return df[[sku, warehouse, target_stock]]这个函数的逻辑是预测值决定库存基准标准差决定额外安全库存capacity_ratio决定总体规模。当容量充足时它直接输出目标库存当容量不足时它按每个 SKU 的预测需求占比做等比缩减让高需求仓优先保住库存。注意clip(lower0)很重要。预测模型在长尾 SKU 上可能输出负值负库存没有物理意义必须截断。z1.65在容量充足时比较稳容量紧张时我会降到 1.28否则过度备货会把仓库撑爆。这个启发式的缺点是它不考虑仓配成本差异。如果两个仓库成本差很多它仍然会把库存按照需求比例均摊。所以它适合作为 baseline不适合作为最终方案。4.3 有容量和成本约束时用线性规划拿到分配矩阵当仓库容量和仓配成本都成为主要约束时我会把分配问题转成线性规划。对每个 SKU目标库存已经在预测模块算好这里只需要求解“这块库存放到哪个仓”。from scipy.optimize import linprog import numpy as np def lp_allocate(cost_matrix, target_qty, ware_cap): # cost_matrix: 形状 (n_sku, n_warehouse)单位仓配成本 # target_qty: 形状 (n_sku,)每个 SKU 的目标库存 # ware_cap: 形状 (n_warehouse,)每个仓库的容量上限 n_sku, n_wh cost_matrix.shape num n_sku * n_wh # 决策变量按 SKU 外循环、仓库内循环展平 c cost_matrix.ravel() # 仓库容量约束每个仓库分配到的总库存不超过容量 A_ub np.zeros((n_wh, num)) for j in range(n_wh): A_ub[j, j::n_wh] 1.0 b_ub np.asarray(ware_cap) # SKU 需求约束每个 SKU 的总分配量等于目标库存 A_eq np.zeros((n_sku, num)) for i in range(n_sku): A_eq[i, i*n_wh:(i1)*n_wh] 1.0 b_eq np.asarray(target_qty) res linprog(c, A_ubA_ub, b_ubb_ub, A_eqA_eq, b_eqb_eq, bounds(0, None), methodhighs) if not res.success: raise ValueError(fLP failed: {res.message}) return res.x.reshape(n_sku, n_wh)这段代码把分配问题拆成了两部分约束。仓库容量约束用不等式保证每个仓库不超容SKU 需求约束用等式保证每个 SKU 的库存总和等于目标值。cost_matrix的行是 SKU、列是仓库展平后前n_wh个变量属于第一个 SKU所以等式矩阵里要对每一行区间赋 1。methodhighs是 SciPy 1.6 之后的默认求解器速度和稳定性都比旧版interior-point好。如果求解失败通常是容量约束和目标库存冲突比如总需求量超过总容量此时要么放宽容量要么退回启发式方案。线性规划的好处是它天然支持仓配成本差异。我一般会先用启发式跑一轮拿到分数再换成 LP 看分数提升多少。如果提升明显说明赛题成本结构比较复杂如果提升不大说明主要瓶颈在预测精度不在分配逻辑这时应该把精力拉回特征工程。5. 复现高分源码时最常踩的四个坑从泄漏到提交列序这部分是选手最容易反复翻车的地方也是源码里最值得逐行核对的部分。5.1 本地验证分数虚高线上分数腰斩十有八九是未来信息泄漏现象复现源码时本地验证集 RMSE 很低但提交到线上分数明显不对甚至比随机提交还差。原因特征构造过程中用到了未来信息最常见的是对全量数据做groupby后再 shift但shift(0)或者直接在验证集上计算滚动统计时混入了当日真实值。解决检查所有滞后特征的shift参数是否至少为 1验证集特征必须只基于验证集自身的历史不能在训练集和验证集拼接后的全量表上统一构造。5.2 把发货量当需求量缺货时期会产生一排假零现象预测结果总是偏低尤其是热销 SKU 被预测成需求骤降但实际是仓库没货可发。原因训练标签直接用发货量缺货期间发货量是零但真实需求可能是 100 件。模型学到“这类 SKU 经常归零”自然压低预测。解决找到订单表和发货表的差异字段。如果有缺货标记把发货量缺失期间的目标值设为订单需求如果没有标记至少把缺货时间段单独建一个特征让模型知道这些标签的置信度更低。5.3 公共榜分数一路涨私有榜掉链子别只看排名调参现象调参过程中公共榜排名一直上升最后私有榜分数却明显回落。原因公共榜本身就是测试集的一部分反复看公共榜调参本质上是在对测试集做隐式拟合。解决把本地验证分数当作主要参考公共榜只作为“没写错代码”的 sanity check。一旦本地验证和公共榜趋势不一致优先相信本地验证而不是公共榜。5.4 提交文件列名和排序不对一个多余索引把成绩打回零分现象代码跑完提交后分数显示为无效或零分。原因提交文件要求按 SKU、仓库、日期排序但代码直接输出了groupby后的顺序或者把索引列一起写进了文件。解决提交前强制重置索引只保留官方要求的列再检查列名是否与下载页完全一致。比较保险的做法是把head()结果打印出来人工核对一遍再提交。索引残留是高频翻车点不要把 DataFrame 的原始索引直接写成 CSV。6. 最后一跳把分位数预测接进分仓规划提交前再回放一遍6.1 用两组分位数模型估计安全库存最后一个提升点是把安全库存从“预测标准差”升级为“分位数预测”。LightGBM 原生支持分位数回归我一般训练两个模型一个预测低分位一个预测高分位两者差值可以估算需求分布宽度。这个做法比直接用历史残差标准差更贴合近期数据因为模型会自动考虑促销和季节性带来的需求波动。# 在原有 params 基础上改成分位数目标 params_low {**params, objective: quantile, alpha: 0.1} params_high {**params, objective: quantile, alpha: 0.9} gbm_low lgb.train(params_low, dtrain, num_boost_round1000) gbm_high lgb.train(params_high, dtrain, num_boost_round1000) pred_low gbm_low.predict(df[features]) pred_high gbm_high.predict(df[features]) df[pred_qty] (pred_high pred_low) / 2 df[pred_std] (pred_high - pred_low) / 1.28这里 0.1 和 0.9 分位对应的 z 值差距约为 1.28 倍标准差所以把差值除以 1.28 得到近似的标准差。中位数预测比均值预测更抗异常值后续接分仓规划也更稳定。6.2 提交前的离线回放脚本把验证段完整重跑一遍我每次提交前都会做一个非常朴素的回放把验证期的每一天按顺序模拟一遍前一天训练后一天预测再做分仓规划最后计算成本。这段脚本不用追求效率只求确认整条链路没有断点。跑通了再提交跑不通就回头查异常。这个习惯帮我拦下了至少三次因为列名错位导致的零分事故。这套方案从头到尾没有用到特别复杂的模型核心就是严格的时间验证、稳定的特征构造和把预测方差接进分配决策里。比起追求新奇模型我更相信把基础链路每个环节都盯住能拿到的分数更实在。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站