简介:这份资源是阿里天池大数据竞赛「菜鸟-需求预测与分仓规划」赛题的完整实践包,面向具备一定机器学习与数据分析基础、希望深入供应链预测场景的参赛者与算法学习者。内容围绕需求预测与分仓规划两大核心任务展开,涵盖数据清洗、缺失值填充、异常值处理等预处理流程,并实现XGBoost、GBDT、随机森林、SVR(线性核与高斯核)等多种回归模型,同时按分仓单独建模以捕捉仓库特有的销售动态,最后通过模型融合提升预测精度。压缩包共68个文件,以58个Python脚本为主,辅以6个SQL特征工程脚本、2个CSV结果文件、1个R语言ARIMA实现及1份readme说明,整体约133KB,结构清晰、便于复现。目前已有2332人学习下载,读者可从中获取完整的赛题方案、特征工程与建模代码、模型融合思路及分仓规划优化参考,适合作为供应链预测与集成学习的实战案例。
1. 需求预测与分仓规划:从赛题到落地,一个被低估的供应链优化入口
很多做算法的同学第一次看到“需求预测与分仓规划”这个赛题时,第一反应是“不就是时间序列预测吗,LSTM 一把梭”。但真正跑过一遍完整流程的人会告诉你,预测只是前半场,分仓规划才是决定最终得分的胜负手。这个赛题的核心链路是:给定历史订单数据,先预测未来一段时间各商品在各地区的需求量,再根据预测结果决定每个仓库该备多少货、往哪些区域发货,最终目标是在满足服务水平的前提下把总成本压到最低。它适合三类人:想入门供应链算法的开发者、做过销量预测但没碰过库存优化的同学、以及需要一套可复现 pipeline 来打底的数据分析从业者。我见过太多人预测模型调得飞起,结果分仓策略一塌糊涂,排名直接掉出前 50%。这篇笔记就按我实际跑过的路径,把数据清洗、特征工程、预测建模、分仓决策、避坑排查和进阶技巧一层层拆开讲。
2. 数据理解与特征工程:先把订单流水变成可建模的矩阵
2.1 赛题数据到底长什么样
这类赛题通常给三张表:订单历史表、商品信息表、仓库与区域映射表。订单历史表是核心,字段一般包括日期、商品 ID、区域 ID、订单量或销量。商品信息表提供品类、品牌、价格带等静态属性。仓库映射表告诉你每个仓库覆盖哪些区域、发货成本怎么算。我拿到数据后第一件事不是急着画图,而是先确认三件事:时间跨度有多长、缺失值集中在哪些维度、区域和仓库是不是多对多关系。常见做法是用 pandas 做一次全量 describe 和缺失率统计,把每个字段的分布摸清楚。如果时间跨度不足一年,季节性特征就要谨慎使用,否则很容易过拟合。区域和仓库如果是多对多,分仓规划就变成一个带约束的分配问题,复杂度会明显上升。
2.2 从原始流水到特征矩阵的四个关键步骤
第一步是聚合。原始订单可能是逐笔记录,需要按“日期 + 商品 + 区域”三个维度做 groupby 求和,得到日粒度的销量矩阵。第二步是补齐。有些商品在某些区域某些天没有订单,这不代表需求为零,而是观测缺失。我一般会先用前向填充加滑动均值做平滑,再标记一个缺失指示列,让模型自己学。第三步是构造时间特征:星期几、是否周末、是否月初月末、距大促的天数。第四步是构造滞后特征和滑窗统计量,这是预测模型能不能抓住趋势的关键。
import pandas as pd import numpy as np # 假设 raw_df 包含 date, item_id, region_id, qty 四列 raw_df['date'] = pd.to_datetime(raw_df['date']) # 按日期+商品+区域聚合 daily = raw_df.groupby(['date', 'item_id', 'region_id'])['qty'].sum().reset_index() # 构造完整时间索引,补齐缺失日期 full_idx = pd.MultiIndex.from_product( [pd.date_range(daily['date'].min(), daily['date'].max()), daily['item_id'].unique(), daily['region_id'].unique()], names=['date', 'item_id', 'region_id'] ) daily = daily.set_index(['date', 'item_id', 'region_id']).reindex(full_idx).reset_index() daily['qty'] = daily['qty'].fillna(0) # 时间特征 daily['dayofweek'] = daily['date'].dt.dayofweek daily['is_weekend'] = (daily['dayofweek'] >= 5).astype(int) daily['day'] = daily['date'].dt.day daily['month'] = daily['date'].dt.month # 滞后与滑窗特征 daily = daily.sort_values(['item_id', 'region_id', 'date']) for lag in [1, 7, 14, 28]: daily[f'lag_{lag}'] = daily.groupby(['item_id', 'region_id'])['qty'].shift(lag) for window in [7, 14, 28]: daily[f'rolling_mean_{window}'] = ( daily.groupby(['item_id', 'region_id'])['qty'] .transform(lambda x: x.shift(1).rolling(window).mean()) ) daily[f'rolling_std_{window}'] = ( daily.groupby(['item_id', 'region_id'])['qty'] .transform(lambda x: x.shift(1).rolling(window).std()) )这段代码的逻辑是先聚合再补齐,保证每个商品在每个区域都有连续的时间序列。滞后特征用 shift 避免数据泄漏,滑窗统计量也先 shift 一位再计算,否则当天信息会混进特征里。参数方面,lag 选 1、7、14、28 覆盖了日、周、双周、月四个周期;rolling window 选 7、14、28 是为了让模型同时看到短期波动和中期趋势。如果数据量很大,可以把 rolling 的 transform 换成更高效的 groupby agg 再 merge,速度会快不少。
2.3 品类与区域维度的降维处理
商品数量动辄上千,区域也有几十个,直接做 one-hot 会让特征维度爆炸。我一般会先按品类做聚合,把长尾商品归到“其他”类,再对区域按历史销量做聚类,把销量模式相似的区域合并成几个大区。这样既保留了区分度,又控制了维度。常见做法是用 KMeans 对区域的历史销量曲线做聚类,聚类数选 5 到 8 之间,具体看轮廓系数。品类维度则可以直接用商品信息表里的品类字段,如果品类太细,就再往上归一层。
3. 需求预测建模:从基线到集成模型的完整路径
3.1 为什么不能一上来就上深度学习
我见过不少同学拿到赛题直接上 LSTM 或 Transformer,结果训练慢、调参难、效果还不一定比 LightGBM 好。原因很简单:这类赛题的数据量通常不算大,商品和区域的组合虽然多,但每个序列的长度有限,深度学习容易过拟合。而且赛题评分往往对预测误差的容忍度有限,树模型在结构化特征上的稳定性和可解释性反而更占优势。我一般会先跑一个移动平均基线,再上 LightGBM,最后看情况要不要加一个简单的时序模型做 ensemble。基线的作用是给你一个下限,如果复杂模型连基线都跑不过,那说明特征工程有问题。
3.2 LightGBM 做销量预测的参数配置与训练流程
LightGBM 是我在这类赛题里用得最多的模型,训练快、对缺失值友好、特征重要性直观。下面是我常用的参数配置和训练代码。
import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit # 特征列 feature_cols = [c for c in daily.columns if c not in ['date', 'item_id', 'region_id', 'qty']] # 时间序列切分,避免随机切分造成泄漏 tscv = TimeSeriesSplit(n_splits=3) for train_idx, val_idx in tscv.split(daily): train_data = daily.iloc[train_idx] val_data = daily.iloc[val_idx] lgb_train = lgb.Dataset(train_data[feature_cols], train_data['qty']) lgb_val = lgb.Dataset(val_data[feature_cols], val_data['qty']) params = { 'objective': 'regression', 'metric': 'mae', 'learning_rate': 0.05, 'num_leaves': 64, 'max_depth': 8, 'min_data_in_leaf': 50, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5, 'lambda_l2': 1.0, 'verbose': -1 } model = lgb.train( params, lgb_train, num_boost_round=2000, valid_sets=[lgb_val], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)] )参数说明:learning_rate 设 0.05 是为了在 2000 轮内充分收敛,num_leaves 和 max_depth 控制模型复杂度,min_data_in_leaf 设 50 防止过拟合到个别序列。feature_fraction 和 bagging_fraction 做特征和样本采样,提升泛化。lambda_l2 是 L2 正则,对销量预测这种噪声较大的任务很有用。early_stopping 设 100 轮,避免无效训练。metric 选 MAE 而不是 MSE,因为销量数据里异常值不少,MAE 对异常值更鲁棒。
3.3 预测结果的校准与后处理
模型输出的是连续值,但实际销量是非负整数,而且有些商品在某些区域就是零销量。我一般会做三步后处理:先把负值截断为 0,再对预测值做四舍五入取整,最后对历史零销量占比超过 90% 的序列强制置零。这一步看起来简单,但能明显降低误差。另外,如果赛题要求预测多个时间点,我会用递归预测:先预测未来第一天,把预测值当作已知滞后特征再预测第二天,依次类推。递归预测的误差会累积,所以一般只递归 7 到 14 天,再长就不如直接训练一个多步输出模型。
4. 分仓规划:把预测数字变成可执行的库存决策
4.1 分仓规划到底在优化什么
预测出每个区域的需求后,分仓规划要回答的问题是:每个仓库应该备多少货,才能以最低成本满足各区域需求。成本通常包括仓储成本、调拨成本和缺货惩罚。约束包括仓库容量、区域覆盖关系、最小起订量等。这个问题本质上是一个带约束的线性规划或混合整数规划。常见做法是用 scipy 或 PuLP 建一个简化模型,先跑通再逐步加约束。如果赛题规模不大,直接上求解器;如果规模大,就用启发式规则先做分配,再用局部搜索优化。
4.2 用线性规划做分仓分配的最小示例
下面是一个简化的分仓分配模型,假设有两个仓库、三个区域,目标是调拨成本加缺货惩罚最小。
import pulp # 仓库列表和区域列表 warehouses = ['W1', 'W2'] regions = ['R1', 'R2', 'R3'] # 预测需求 demand = {'R1': 100, 'R2': 150, 'R3': 80} # 调拨成本:从仓库到区域的单位成本 transport_cost = { ('W1', 'R1'): 2, ('W1', 'R2'): 4, ('W1', 'R3'): 6, ('W2', 'R1'): 5, ('W2', 'R2'): 3, ('W2', 'R3'): 2 } # 仓库容量 capacity = {'W1': 200, 'W2': 180} # 缺货惩罚系数 shortage_penalty = 10 prob = pulp.LpProblem('warehouse_allocation', pulp.LpMinimize) # 决策变量:从仓库 w 发往区域 r 的数量 x = pulp.LpVariable.dicts('x', (warehouses, regions), lowBound=0, cat='Continuous') # 缺货量 s = pulp.LpVariable.dicts('shortage', regions, lowBound=0, cat='Continuous') # 目标函数:调拨成本 + 缺货惩罚 prob += ( pulp.lpSum(x[w][r] * transport_cost[(w, r)] for w in warehouses for r in regions) + pulp.lpSum(s[r] * shortage_penalty for r in regions) ) # 约束1:每个区域收到的货加缺货量等于需求 for r in regions: prob += pulp.lpSum(x[w][r] for w in warehouses) + s[r] == demand[r] # 约束2:每个仓库发货总量不超过容量 for w in warehouses: prob += pulp.lpSum(x[w][r] for r in regions) <= capacity[w] prob.solve() print('Status:', pulp.LpStatus[prob.status]) for w in warehouses: for r in regions: print(f'{w} -> {r}: {x[w][r].varValue}') for r in regions: print(f'{r} shortage: {s[r].varValue}')这段代码的逻辑很直接:决策变量是每个仓库发往每个区域的数量,目标函数是调拨成本加缺货惩罚,约束是需求满足和容量限制。参数方面,transport_cost 可以从赛题给的仓库区域映射表里算出来,capacity 是仓库容量,shortage_penalty 需要根据业务容忍度来设,设得太低会导致大量缺货,设得太高会导致过度备货。求解器用 PuLP 默认的 CBC 就行,小规模问题秒出结果。如果赛题要求整数解,把 cat 改成 'Integer' 即可,但求解时间会变长。
4.3 从线性规划到启发式:大规模场景的降级方案
当仓库和区域数量上百时,线性规划可能跑不动。这时候我会用两阶段启发式:先按历史调拨比例或距离最近原则做初始分配,再用局部搜索调整。具体做法是,对每个区域按调拨成本排序,优先从成本最低的仓库发货,发完容量再顺延到下一个仓库。然后计算总成本,随机交换两个区域的仓库分配,如果成本下降就接受,否则以一定概率接受,跑几千轮就能得到一个不错的解。这种方法的优点是快、可解释、容易加业务规则,缺点是可能陷入局部最优。如果时间允许,我会用线性规划的解作为初始解,再用启发式做微调。
5. 避坑与排查:那些让我排名掉出前 50% 的坑
5.1 数据泄漏:滞后特征没 shift 导致线下虚高
现象是线下验证 MAE 很低,线上提交后分数差一大截。原因是构造 rolling 特征时没有 shift,当天的销量信息混进了特征里,模型在验证集上“偷看”了答案。解决方法是所有时序特征一律先 shift 再计算,验证集切分必须按时间切,不能用随机切分。我一般会在代码里加一个检查:如果某个特征的日期和标签日期相同,直接报错。
5.2 零销量序列处理不当导致预测全为零
现象是很多商品在部分区域的预测值全是零,但实际有零星需求。原因是这些序列历史零销量占比太高,模型学到了“总是零”的模式。解决方法是对零销量序列做分层处理:零销量占比超过 95% 的序列单独用规则预测,比如用区域平均需求乘以一个衰减系数;占比在 70% 到 95% 之间的序列,在特征里加一个“是否零销量日”的指示变量,让模型区分“真零”和“缺失”。
5.3 分仓规划忽略最小起订量导致方案不可执行
现象是线性规划给出的分配方案里,某些仓库发往某些区域的数量是 3 件、5 件这种小数,实际业务里根本没法操作。原因是模型没有加最小起订量约束。解决方法是在约束里加一个二元变量,表示是否从某仓库发往某区域,如果发就往大于等于最小起订量,不发就为零。这会增加模型复杂度,但能保证方案可落地。如果赛题不要求整数解,至少要把小于某个阈值的分配量合并到其他仓库。
5.4 预测粒度与分仓粒度不一致导致误差放大
现象是预测按商品+区域做,但分仓规划按品类+大区做,两边对不上。原因是预测和分仓的维度没有对齐。解决方法是先确定分仓规划的决策粒度,再按这个粒度聚合预测结果。如果分仓按品类+大区,就把商品+区域的预测值按品类和大区求和,再输入分仓模型。反过来,如果分仓粒度更细,就需要把预测结果拆解,拆解时按历史比例分配。
5.5 忽略大促和节假日导致预测系统性偏低
现象是大促期间的预测值明显低于实际销量,缺货严重。原因是模型没有大促特征,或者大促样本太少学不到。解决方法是手动加一个大促标记列,把历史大促日的销量做一个放大系数,预测时对大促日乘以这个系数。放大系数可以用历史大促日销量除以非大促日销量的均值来估计。如果赛题没有给大促日期,就从销量曲线上找突增点,人工标注。
6. 进阶技巧:用滚动预测和情景分析提升分仓鲁棒性
最后一章讲一个我实际用过、效果比较稳的技巧:滚动预测加情景分析。滚动预测的意思是,不要一次性预测未来 30 天然后就不管了,而是每天用最新数据重新预测未来 7 天,只取第一天的结果,第二天再滚动一次。这样做的好处是模型能持续吸收最新信息,对突发波动更敏感。代价是计算量变大,但这类赛题的数据量通常撑得住。情景分析则是针对分仓规划,不要只用一个预测值去优化,而是生成三套需求情景:乐观、中性、悲观。乐观情景用预测值乘以 1.2,悲观乘以 0.8,然后分别跑分仓模型,看哪个方案在三套情景下的总成本最稳。我一般会选那个在中性情景下成本不是最低、但在悲观情景下缺货最少的方案。下面是一个滚动预测的伪代码框架。
# 滚动预测框架 history = daily.copy() predictions = [] for day in range(forecast_horizon): # 用当前历史训练模型 model = train_model(history) # 预测未来一天 next_day_features = build_features(history, target_date=history['date'].max() + pd.Timedelta(days=1)) pred = model.predict(next_day_features) # 把预测值追加到历史,作为下一天的滞后特征 new_row = next_day_features.copy() new_row['qty'] = pred history = pd.concat([history, new_row], ignore_index=True) predictions.append(pred) # 情景分析 for multiplier in [0.8, 1.0, 1.2]: scenario_demand = {r: demand[r] * multiplier for r in regions} solve_allocation(scenario_demand)这段框架的关键点有两个:一是每次滚动都要重新训练模型,不能只用一次训练好的模型递归,否则误差会累积得很快;二是情景分析的分仓方案要对比总成本和缺货量,不能只看单一指标。参数方面,forecast_horizon 一般设 7 到 14 天,再长滚动收益就不明显了。multiplier 的 0.8 和 1.2 是根据历史预测误差的分布来定的,如果误差标准差是 15%,那 0.8 到 1.2 基本覆盖了大部分情况。
我自己的习惯是,每次跑完分仓规划,都会把三套情景的方案放在一起看,如果某个方案在悲观情景下缺货量比另外两个方案低 30% 以上,那即使中性情景成本高一点,我也会选它。供应链这件事,后悔药很贵,宁可备货多一点,也别在大促时断货。希望帮到你。
本文还有配套的精品资源,点击获取