需求预测与分仓规划实战:从特征工程到整数规划的成本优化

发布时间:2026/10/10 17:38:11
需求预测与分仓规划实战:从特征工程到整数规划的成本优化
简介天池大数据竞赛“菜鸟-需求预测与分仓规划”赛题配套代码与方案资料包面向准备数据挖掘或供应链预测类竞赛的选手以及希望了解回归建模与分仓规划实战的开发者。赛题核心涵盖数据预处理、特征工程以及XGBoost、GBDT、RandomForest、SVR等多种回归模型的训练与融合并按分仓独立建模来提升预测精度技术链路完整。压缩包共68个文件以Python脚本为主58个py另有6个SQL查询脚本、2个CSV结果文件、1个R脚本及1个说明文档整体仅133KB轻量便于研读。从内容预览看资源包含可视化、ARIMA时序预测、特征工程SQL、模型训练与融合脚本以及最终预测输出基本覆盖从数据清洗、特征构造到模型评估的完整流程。目前已有2331人学习浏览可用于拆解真实赛题代码思路、复现回归与分仓建模流程也为后续参与类似物流预测竞赛提供了可复用的脚本模板。1. 天池菜鸟-需求预测与分仓规划一个把“预测准”和“备货对”串起来的实战赛题天池的菜鸟-需求预测与分仓规划是我近年打过最考验工程整合能力的大数据竞赛题之一。它不像纯分类或纯回归赛题那样只盯一个指标而是两级结构先用历史订单数据预测未来一段时间的需求量再把预测结果投入仓库分配决策目标是让总履约成本最低。打到最后你会发现名次靠前的队伍未必是单模型最花哨的而是最早把“预测之后怎么办”想清楚的那批人。这个赛题适合电商供应链算法、计划岗位以及想从机器学习往运筹优化方向转的工程师——整套流程下来特征工程、树模型、整数规划都能完整练一遍。下面按我的复现路径来写怎么从原始数据构造样本、模型参数怎么设、分仓求解怎么写以及路上踩过的坑。2. 先把数据看透需求预测的数据结构与特征工程2.1 订单表怎么聚合成训练样本赛题给的原始数据一般是日粒度订单流水常见字段有订单日期、仓库编号、城市编号、商品编号和下单数量。第一步不是建模而是把这些明细聚合到正确的分析粒度上。需求预测通常按“日期-城市-商品”做聚合因为决策主体是“哪个城市的消费者要买多少”而分仓规划要考虑“哪个仓来覆盖这个城市”所以还要保留一套“日期-仓库-商品”的视角用来做容量校验。聚合里最容易犯的错是维度键不统一。比如训练时用(city_id, sku_id)做分组预测时却用(warehouse_id, sku_id)去套模型两个粒度之间存在一对多关系加总结果自然对不上。我一般会先把城市和仓库的服务关系单独抽成映射表再决定从哪个维度建模。下面的代码演示最基础的聚合动作import pandas as pd df pd.read_csv(order_records.csv, parse_dates[order_date]) df[qty] df[qty].fillna(0) # 按日期-城市-商品聚合作为需求预测的训练粒度 ts_daily ( df.groupby([order_date, city_id, sku_id], as_indexFalse)[qty] .sum() .rename(columns{qty: demand}) ) # 按日期-仓库-商品聚合作为分仓规划的容量校验视角 wh_daily ( df.groupby([order_date, warehouse_id, sku_id], as_indexFalse)[qty] .sum() .rename(columns{qty: wh_qty}) )这里两个groupby用了不同的维度组合是刻意保留两套数据。rename让列名语义清晰避免后面 merge 时撞名。关于退货和取消单我通常按净需求处理把相同日期、城市、商品下的正负数量先加总而不是简单丢弃负值。否则遇到促销退货集中的日期预测值会被系统性抬高。2.2 特征工程历史窗口、滞后值和滚动统计这类需求预测赛题里特征工程的收益远大于模型选择。核心特征有三类滞后值、滚动窗口统计、日历特征。滞后值直接回答“上周这时候卖了多少”滚动窗口回答“最近一个月的需求水平稳不稳定”日历特征是应对周期性——周中周末差异、月末效应、节假日爆发在电商场景里非常明显。构造滞后值必须注意边界预测截止日为d时你只能看到d及之前的数据所以滞后1天是shift(1)而不是shift(0)。很多队伍在本地验证时用当天数据预测当天看着很准一提交就翻车就是因为这个细节。下面的函数是常见做法def build_features(ts_daily): ts ts_daily.sort_values([city_id, sku_id, order_date]).copy() # 用 groupby shift 构造每个城市-商品的滞后值 ts[lag_1] ts.groupby([city_id, sku_id])[demand].shift(1) ts[lag_7] ts.groupby([city_id, sku_id])[demand].shift(7) ts[lag_14] ts.groupby([city_id, sku_id])[demand].shift(14) # 滚动窗口向右闭合只用历史数据shift(1) 防止用到当天 g ts.groupby([city_id, sku_id])[demand] ts[rolling_mean_7] g.transform( lambda x: x.shift(1).rolling(7, min_periods1).mean() ) ts[rolling_max_14] g.transform( lambda x: x.shift(1).rolling(14, min_periods1).max() ) ts[zero_count_30] g.transform( lambda x: x.shift(1).rolling(30, min_periods1).apply( lambda w: (w 0).sum(), rawTrue ) ) # 日历特征星期、月内第几天、是否月末 ts[dow] ts[order_date].dt.dayofweek ts[dom] ts[order_date].dt.day ts[month] ts[order_date].dt.month ts[is_month_end] ts[order_date].dt.is_month_end.astype(int) return ts窗口长度的选择不能拍脑袋。快消类目用7/14/30天窗口就够但长尾商品的历史需求极度稀疏滚动窗口里全是0反而掩盖了趋势。遇到这类商品我倾向于把数据聚合到城市维度再建特征或者直接用“上次非零间隔天数”这类间隔特征而不是纯窗口均值。min_periods1保证了冷启动阶段不产生空值但训练前还是要把滞后和滚动特征为空的样本删掉否则模型会把“缺失”当成一种有意义的模式。2.3 目标值的构造与标签对齐目标怎么构造直接决定了模型要拟合什么。如果赛题要求预测未来14天总需求那么标签就是从t1到t14的需求之和而不是第t天当天的值。构造时要把当天需求量整体前移一天确保窗口真正落在未来。def add_label(ts, horizon14): ts ts.sort_values([city_id, sku_id, order_date]) # 把当天需求前移一天让标签窗口从 t1 开始 ts[leading] ts.groupby([city_id, sku_id])[demand].shift(-1) # 从 t1 到 thorizon 的总需求量 ts[label] ( ts.groupby([city_id, sku_id])[leading] .transform(lambda x: x.rolling(horizon, min_periods1).sum()) ) return ts这个写法的关键是shift(-1)之后再做滚动求和。如果直接对原始demand做rolling再shift窗口会包含t当天和线上评测口径不一致。构造完标签后每个城市-商品序列的最后horizon行虽然也有 label但这些标签在预测截止日当天是看不到的必须删掉否则验证集和训练集会时间重叠存在隐式数据泄露。我习惯把这条规则写进注释里每次复用代码都不踩第二次。3. 需求预测建模用 LightGBM 做回归参数与训练流程3.1 把时间序列转成表格回归滑窗与样本构造这类需求预测场景我一般不用 LSTM 或 Transformer 起手而是先转成表格回归问题。原因很直接城市-商品组合数量多、长尾严重深度模型在这种稀疏结构上很难训练而树模型对缺失值和离群值天然容忍调参成本也低。把时间序列转监督学习的方法是滑动窗口采样每个样本代表“在日期d时刻用截至d的历史特征预测未来14天需求”。样本构造要注意两点。第一是不要随机切分训练集和验证集必须按时间顺序切分否则相邻日期的样本高度相似验证指标虚高。第二是同一个城市-商品分组内部的样本在时间上有强相关性早停时要意识到验证集并不是完全独立的。实操上我常用TimeSeriesSplit或直接前80%、后20%切分宁可验证集小一点也要保证时间先后关系。3.2 LightGBM 回归的关键参数与训练代码LightGBM 是这个问题的首选因为它训练快、对高基数类别特征友好而且自带早停迭代效率高。参数上我有一套保守的起始配置先保证不过拟合再逐步调低学习率换精度。参数建议值说明objectiveregression也可换 poisson对计数型需求更贴近metricmae如果最终比的是成本mae 比 mse 更稳learning_rate0.05小一点配合早停避免震荡num_leaves31默认偏小不容易过拟合max_depth5限制深度配合叶子数min_data_in_leaf20长尾数据调大到50防止学到极端值feature_fraction0.8每棵树随机采样80%特征增强鲁棒性bagging_fraction0.8行采样配合 bagging_freq1训练代码本身不复杂核心是验证集要按时间切片以及早停轮数要留够import lightgbm as lgb features [c for c in train.columns if c not in [label, order_date, city_id, sku_id]] X train[features] y train[label] params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 31, max_depth: 5, min_data_in_leaf: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbosity: -1, } # 按时间顺序切分前80%训练后20%验证 train_size int(len(X) * 0.8) X_tr, X_va X.iloc[:train_size], X.iloc[train_size:] y_tr, y_va y.iloc[:train_size], y.iloc[train_size:] dtr lgb.Dataset(X_tr, labely_tr) dva lgb.Dataset(X_va, labely_va, referencedtr) model lgb.train( params, dtr, num_boost_round2000, valid_sets[dva], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)], )代码里两个关键点。第一是referencedtr让验证集复用训练集的统计信息避免 lgb 对验证集单独做分布计算。第二是early_stopping(100)如果验证集指标连续100轮不下降就停防止无效训练。注意新版本 API 用callbacks传早停旧版本可以直接写early_stopping_rounds100两种都行。min_data_in_leaf是我在长尾赛题里最常动的参数调到50左右能明显减少过拟合极端销量。3.3 预测结果的后处理负数截断、整数化、时间对齐模型输出是浮点数不能直接拿去分仓。预测值里出现负数是回归模型面对零膨胀数据时的正常现象不是 bug但必须处理。标准做法是np.maximum(0, pred)把负数截断到0。然后取整因为需求量是计数单位不能出现0.3件商品。如果某个城市-商品组合的预测值远超历史最高值我会做一个clip上限取历史最大需求的2到3倍防止优化阶段被单个异常点带崩。后处理阶段还要做一次维度校验把预测结果按“城市-商品”加总和训练集同维度历史总量对比量级。如果差了十倍多半是分组键写错或者有数据泄露这时候先回头查数据不要继续往下走。下面是常用工具函数pred np.maximum(0, np.round(raw_pred)) pred np.clip(pred, 0, upper_limit) # 按城市维度做加总校验 pred_sum pred_df.groupby(city_id)[pred].sum() hist_sum hist_df.groupby(city_id)[demand].sum() assert pred_sum.index.equals(hist_sum.index), city id 不一致请检查分组键upper_limit我用历史同组最大需求乘2这是经验值。这一步的价值不是提升模型指标而是保证后续分仓求解器拿到的输入是“物理上可分配的数值”。4. 分仓规划把预测值变成仓库分配方案的整数规划4.1 成本函数与约束条件的定义分仓规划在业务上回答的是每个城市的预测需求由哪些仓库来满足、各分配多少同时让总成本尽量低。成本通常包含三块从仓库到城市的单位运输成本、启用仓库的固定运营成本、以及需求无法满足时的缺货惩罚。约束条件主要是仓库容量上限以及每个城市的分配量加上缺货量必须等于需求量。数学化之后这是一个混合整数线性规划MILP问题。变量定义如下表符号含义x_{j,i}仓库 j 分配给城市 i 的需求量y_j0-1 变量仓库 j 是否启用short_i城市 i 的缺货量D_i城市 i 的预测需求Cap_j仓库 j 的容量上限C_{j,i}仓库 j 到城市 i 的单位运输成本目标函数是最小化运输成本、固定成本和缺货惩罚之和。约束条件有两个硬约束需求守恒——每个城市的分配量加缺货量等于需求量容量约束——每个仓库的分配总量不超过容量乘启用变量。之所以要乘y_j是因为关闭的仓不能再分配任何量。这个建模思路在绝大多数运筹赛题里都能复用关键是把业务词汇翻译成变量和约束而不是一上来就写代码。4.2 用 OR-Tools 求解最小化成本的分仓分配求解 MILP 我一般用 Google OR-Tools免费、安装简单、Python API 顺手赛题规模完全够用。下面这段代码是核心求解流程from ortools.linear_solver import pywraplp N_CITY len(city_ids) N_WAREHOUSE len(warehouse_ids) penalty 1000 # 缺货惩罚系数 solver pywraplp.Solver.CreateSolver(SCIP) x {} for j in range(N_WAREHOUSE): for i in range(N_CITY): upper min(demand_vec[i], capacity_vec[j]) x[(j, i)] solver.NumVar(0, upper, fx_{j}_{i}) y {} for j in range(N_WAREHOUSE): y[j] solver.BoolVar(fy_{j}) short {} for i in range(N_CITY): short[i] solver.NumVar(0, demand_vec[i], fshort_{i}) # 目标运输成本 固定成本 缺货惩罚 solver.Minimize( solver.Sum( [cost_mat[j][i] * x[(j, i)] for j in range(N_WAREHOUSE) for i in range(N_CITY)] ) solver.Sum([opening_cost[j] * y[j] for j in range(N_WAREHOUSE)]) penalty * solver.Sum([short[i] for i in range(N_CITY)]) ) # 需求守恒每个城市的分配量 缺货 需求量 for i in range(N_CITY): solver.Add( solver.Sum([x[(j, i)] for j in range(N_WAREHOUSE)]) short[i] demand_vec[i] ) # 容量约束仓库分配总量 容量 * 启用变量 for j in range(N_WAREHOUSE): solver.Add( solver.Sum([x[(j, i)] for i in range(N_CITY)]) capacity_vec[j] * y[j] ) solver.SetTimeLimit(30_000) # 30秒上限 status solver.Solve() if status pywraplp.Solver.OPTIMAL: print(总成本:, solver.Objective().Value()) else: print(求解状态:, status)几个参数说明。缺货惩罚penalty取值很关键设太大意味着宁可多开仓库也要满足所有需求设太小模型会干脆缺货。我一般先按运输成本的5到10倍设再观察缺货量占比做调整。容量约束是硬约束如果总容量确实不够模型状态会变成INFEASIBLE这时候要回到数据检查是不是容量字段理解错了。求解器选择上SCIP 比 GLOP 支持更多整数变量混合整数问题优先用它。4.3 启发式方法的备选方案当仓库和城市规模很大或者你只是想快速验证预测质量时精确求解器不一定划算。更轻量的是贪心分配按每个城市的“最低运输成本”依次排序优先分配成本最低的仓库直到容量耗尽或需求满足。缺点是可能把某个仓塞满导致后面成本更高的城市被迫用远仓整体成本不是全局最优。但它能作为 baseline 判断精确求解的优化空间有多大——如果贪心和 MILP 成本相差不到5%说明成本结构简单没必要在这上面继续投入。remaining_demand demand_vec.copy() remain_cap capacity_vec.copy() assign np.zeros((N_WAREHOUSE, N_CITY)) # 按每个城市最低运输成本升序处理 city_order np.argsort(np.min(cost_mat, axis0)) for i in city_order: # 对这个城市按运输成本从低到高的仓库依次分配 wh_order np.argsort(cost_mat[:, i]) for j in wh_order: qty min(remaining_demand[i], remain_cap[j]) assign[j, i] qty remaining_demand[i] - qty remain_cap[j] - qty if remaining_demand[i] 0: break这段代码直接改的是remaining_demand的副本原始demand_vec不受影响方便后续重复实验。启发式方案的适用场景是快速迭代先跑通全流程确认预测结果稳定后再上精确求解。它不能作为最终提交方案但能帮你早发现“预测偏低导致缺货严重”这类系统性问题。5. 避坑与排查打榜和复现时最容易翻车的 5 个细节5.1 本地回测分数很高线上却暴跌现象本地验证集 MAE 很漂亮提交线上分数和预期差距巨大。原因多半是特征里混进了未来信息或者验证集和训练集不是按时间切分。比如用全量数据的均值做特征测试期数据已经在里面了线上自然失效。解决方法是严格做时间切片训练集和验证集按日期前后分界检查所有rolling和shift是否只用了过去数据。我有个习惯给每个特征末尾标注“生成时使用的最后日期”排查时一目了然。5.2 预测结果加总对不上总量现象按商品加总的预测需求和官方给出的总量差异非常大。原因通常是聚合粒度不统一训练时按“日期-城市-商品”预测时却按“日期-仓库-商品”建模两个粒度之间的映射关系没有对齐。解决方法是统一用(city_id, sku_id)作为建模键分仓阶段再通过城市-仓库映射表转换成仓库维度的数据。在脚本里加一条总量断言数据量级差超过1%就直接抛异常宁可中断也不要带病提交。5.3 模型预测出负数现象预测值里出现负的订单量分仓时无法分配。原因是线性回归或树模型在零膨胀数据上输出负值属于正常现象不是 bug。解决方法是后处理时用np.maximum(0, pred)截断。更治本的办法是把 objective 改成poisson它对计数型需求更贴合输出期望值天然非负。如果最终评估指标是成本分位数目标也值得试但先保证非负再说。5.4 测试集时间窗口理解错误现象代码里做了“最近7天均值”这个特征但真正线上推算时预测截止日之后的数据也被用在特征里。原因是对赛题定义的预测截止日理解不清晰例如要预测t1到t14特征却用了t1到t7的真实销量相当于把答案的一部分写入特征。解决方法是所有特征列以截止日为界滞后和滚动窗口都做右闭合并在注释里写明“该列只使用截止日当日及之前的数据”。5.5 求解器报 INFEASIBLE 无解现象OR-Tools 返回状态是INFEASIBLE没有可行解。原因常见于容量约束太紧总需求已经超过总容量或者某个城市只能由容量不足的仓服务硬约束冲突。解决方法是先给模型加缺货变量把需求守恒改成“分配量缺货量需求量”让模型有退路。再不行就把固定成本临时改为0把y_j全部固定为1看单纯容量约束是否成立。这个降级过程能快速定位是容量问题还是成本结构问题而不是对着求解器调试一晚上。6. 进阶技巧把“预测准”翻译成“分仓省”的回放验证很多队伍在预测指标上精益求精但最终排名却不如预测分稍差、分仓策略更好的队伍。原因在于赛题评估的是整条链路的总成本不是 MAE。所以我后来养成了一个习惯用同一份历史数据做回放验证把不同模型生成的预测分别送进同一个分仓求解器比较最终总成本。这样能直观看到预测误差在哪个环节被放大——通常长尾商品的多件或少件对成本影响很小而头部商品的偏差会直接导致缺货或爆仓。实操上我按三个粒度拆成本缺货成本、运输成本、固定成本。如果缺货成本占比高说明模型系统性低估运输成本高说明分仓时没优先就近分配固定成本高说明优化器为了省运输费用开了太多仓。每次只调一个环节避免“预测也调、规划也调”最后不知道谁起作用。这个回放脚本不复杂核心是保住一条不变量同一份测试期真实需求不同方案只在预测和分配环节做差异。最后说句实战教训我第一次打这个赛题把时间全花在调 LightGBM 的 MAE 上验证指标一路下降可最终成本纹丝不动。后来把评价指标换成成本视角才发现问题是预测偏低导致缺货惩罚吃掉所有收益。那之后我再没单独看过模型指标所有迭代都以规划结果为准。这类赛题本质是工程系统题不是单模型竞技越早把整条链路跑通越知道力气该花在哪里。希望帮到你。本文还有配套的精品资源点击获取