简介:天池大数据竞赛“菜鸟-需求预测与分仓规划”赛题配套代码与方案资料包,面向准备数据挖掘或供应链预测类竞赛的选手,以及希望了解回归建模与分仓规划实战的开发者。赛题核心涵盖数据预处理、特征工程,以及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_index=False)["qty"] .sum() .rename(columns={"qty": "demand"}) ) # 按日期-仓库-商品聚合,作为分仓规划的容量校验视角 wh_daily = ( df.groupby(["order_date", "warehouse_id", "sku_id"], as_index=False)["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_periods=1).mean() ) ts["rolling_max_14"] = g.transform( lambda x: x.shift(1).rolling(14, min_periods=1).max() ) ts["zero_count_30"] = g.transform( lambda x: x.shift(1).rolling(30, min_periods=1).apply( lambda w: (w == 0).sum(), raw=True ) ) # 日历特征:星期、月内第几天、是否月末 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_periods=1保证了冷启动阶段不产生空值,但训练前还是要把滞后和滚动特征为空的样本删掉,否则模型会把“缺失”当成一种有意义的模式。
2.3 目标值的构造与标签对齐
目标怎么构造,直接决定了模型要拟合什么。如果赛题要求预测未来14天总需求,那么标签就是从t+1到t+14的需求之和,而不是第t天当天的值。构造时要把当天需求量整体前移一天,确保窗口真正落在未来。
def add_label(ts, horizon=14): ts = ts.sort_values(["city_id", "sku_id", "order_date"]) # 把当天需求前移一天,让标签窗口从 t+1 开始 ts["leading"] = ts.groupby(["city_id", "sku_id"])["demand"].shift(-1) # 从 t+1 到 t+horizon 的总需求量 ts["label"] = ( ts.groupby(["city_id", "sku_id"])["leading"] .transform(lambda x: x.rolling(horizon, min_periods=1).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 是这个问题的首选,因为它训练快、对高基数类别特征友好,而且自带早停,迭代效率高。参数上我有一套保守的起始配置:先保证不过拟合,再逐步调低学习率换精度。
| 参数 | 建议值 | 说明 |
|---|---|---|
| objective | regression | 也可换 poisson,对计数型需求更贴近 |
| metric | mae | 如果最终比的是成本,mae 比 mse 更稳 |
| learning_rate | 0.05 | 小一点配合早停,避免震荡 |
| num_leaves | 31 | 默认偏小,不容易过拟合 |
| max_depth | 5 | 限制深度,配合叶子数 |
| min_data_in_leaf | 20 | 长尾数据调大到50,防止学到极端值 |
| feature_fraction | 0.8 | 每棵树随机采样80%特征,增强鲁棒性 |
| bagging_fraction | 0.8 | 行采样,配合 bagging_freq=1 |
训练代码本身不复杂,核心是验证集要按时间切片,以及早停轮数要留够:
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, label=y_tr) dva = lgb.Dataset(X_va, label=y_va, reference=dtr) model = lgb.train( params, dtr, num_boost_round=2000, valid_sets=[dva], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)], )代码里两个关键点。第一是reference=dtr,让验证集复用训练集的统计信息,避免 lgb 对验证集单独做分布计算。第二是early_stopping(100),如果验证集指标连续100轮不下降就停,防止无效训练。注意新版本 API 用callbacks传早停,旧版本可以直接写early_stopping_rounds=100,两种都行。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_j | 0-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, f"x_{j}_{i}") y = {} for j in range(N_WAREHOUSE): y[j] = solver.BoolVar(f"y_{j}") short = {} for i in range(N_CITY): short[i] = solver.NumVar(0, demand_vec[i], f"short_{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, axis=0)) 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天均值”这个特征,但真正线上推算时,预测截止日之后的数据也被用在特征里。原因是对赛题定义的预测截止日理解不清晰,例如要预测t+1到t+14,特征却用了t+1到t+7的真实销量,相当于把答案的一部分写入特征。解决方法是所有特征列以截止日为界,滞后和滚动窗口都做右闭合,并在注释里写明“该列只使用截止日当日及之前的数据”。
5.5 求解器报 INFEASIBLE 无解
现象:OR-Tools 返回状态是INFEASIBLE,没有可行解。原因常见于容量约束太紧:总需求已经超过总容量,或者某个城市只能由容量不足的仓服务,硬约束冲突。解决方法是先给模型加缺货变量,把需求守恒改成“分配量+缺货量=需求量”,让模型有退路。再不行就把固定成本临时改为0,把y_j全部固定为1,看单纯容量约束是否成立。这个降级过程能快速定位是容量问题还是成本结构问题,而不是对着求解器调试一晚上。
6. 进阶技巧:把“预测准”翻译成“分仓省”的回放验证
很多队伍在预测指标上精益求精,但最终排名却不如预测分稍差、分仓策略更好的队伍。原因在于赛题评估的是整条链路的总成本,不是 MAE。所以我后来养成了一个习惯:用同一份历史数据做回放验证,把不同模型生成的预测分别送进同一个分仓求解器,比较最终总成本。这样能直观看到预测误差在哪个环节被放大——通常长尾商品的多件或少件对成本影响很小,而头部商品的偏差会直接导致缺货或爆仓。
实操上我按三个粒度拆成本:缺货成本、运输成本、固定成本。如果缺货成本占比高,说明模型系统性低估;运输成本高,说明分仓时没优先就近分配;固定成本高,说明优化器为了省运输费用开了太多仓。每次只调一个环节,避免“预测也调、规划也调”最后不知道谁起作用。这个回放脚本不复杂,核心是保住一条不变量:同一份测试期真实需求,不同方案只在预测和分配环节做差异。
最后说句实战教训:我第一次打这个赛题,把时间全花在调 LightGBM 的 MAE 上,验证指标一路下降,可最终成本纹丝不动。后来把评价指标换成成本视角,才发现问题是预测偏低导致缺货惩罚吃掉所有收益。那之后我再没单独看过模型指标,所有迭代都以规划结果为准。这类赛题本质是工程系统题,不是单模型竞技,越早把整条链路跑通,越知道力气该花在哪里。希望帮到你。
本文还有配套的精品资源,点击获取