简介:二零一七BDCI小超市供销存管理优化项目Python源代码,是一套面向机器学习爱好者、数据竞赛参赛者以及零售从业者的完整实践资源。资源从供应链、销售与库存协同角度出发,围绕特征提取、数据清洗、模型选择、训练评估及预测优化等核心步骤,提供可运行的脚本与清晰的项目结构,适合用于课程设计、竞赛复现或业务场景参考。压缩包共二十个文件,包含十一个CSV数据文件、五个Python脚本、两个DOCX文档、一个PPTX演示文稿及一个TXT说明文件,整体大小约一点二二兆字节,数据与代码分离,便于对照学习。目前已有三百零九人学习下载。资料内含模型代码、项目报告、汇报PPT和运行顺序说明,读者可以完整了解从原始数据到最终预测的全流程,并基于已有脚本快速修改,适配自己的数据集与场景。
1. 小超市供销存优化在优化什么:一个每天都要做的补货选择题
小超市的供销存管理优化,表面是库存报表问题,本质是每天打烊前都要做的一道选择题:每种商品明天该补多少货。补多了占资金、压库存,短保商品只能当报损处理;补少了顾客空手走,丢的不只是当天毛利,还有回头客。2017BDCI 的这道题把销售流水、入库流水和商品信息交给选手,要求用 Python 交出一套从数据处理到补货决策的完整方案。它真正考验的不是模型精度本身,而是预测误差与库存成本之间的平衡能力——这也是源代码里最难抄的部分。下面按完整落地路径拆解数据、建模、补货与回测四个环节,适合正在做零售数据分析、或想用 Python 自建库存决策系统的开发者。
2. 把流水账变成可建模的数据表:数据理解与四步预处理
这类赛题的数据形态一般是三张表:销售流水记录顾客买走了什么,入库流水记录门店进了什么,商品主数据说明每个商品是什么品类、保质期几天。优化的本质是平衡这两股流水之间的差额。很多人拿到源代码先翻模型文件,我一般相反——先花半天把数据对齐,因为后面所有特征和模型都建立在这张宽表上,表不对,模型再花哨也白搭。
2.1 三张原始表各归各位:日期统一、粒度统一
import pandas as pd sales = pd.read_csv('sales.csv', parse_dates=['sale_date']) purchase = pd.read_csv('purchase.csv', parse_dates=['in_date']) product = pd.read_csv('product.csv', encoding='gbk') sale_daily = ( sales.groupby(['sale_date', 'product_id'], as_index=False) .agg(sales_qty=('qty', 'sum')) ) purchase_daily = ( purchase.groupby(['in_date', 'product_id'], as_index=False) .agg(purchase_qty=('qty', 'sum')) .rename(columns={'in_date': 'date'}) )这段代码把同一商品同一天的多条销售记录合并成一行,入库表同样处理。两个细节值得注意:parse_dates必须在读入时就转成datetime64,否则两张表的日期列 dtype 不一致,后面 merge 时经常翻车;商品表通常有中文列名,encoding='gbk'不行就试gb18030。聚合粒度统一到"天 × 商品",是为了让后续的滞后特征和滚动特征能按商品独立计算,这是整个特征工程的地基。
2.2 用日期网格补全零销量日期
date_range = pd.date_range(sale_daily['sale_date'].min(), sale_daily['sale_date'].max(), freq='D') full_index = pd.MultiIndex.from_product( [date_range, sale_daily['product_id'].unique()], names=['date', 'product_id']) frame = pd.DataFrame(index=full_index).reset_index() frame = frame.merge(sale_daily.rename(columns={'sale_date': 'date'}), on=['date', 'product_id'], how='left') frame = frame.merge(purchase_daily, on=['date', 'product_id'], how='left') frame['sales_qty'] = frame['sales_qty'].fillna(0)这一步最容易被跳过,又最影响结果。原始流水里没有记录的日子不代表销量是 0,而是"没卖出去",建模时必须显式补 0,否则模型默认这些日期不存在,整体预测会系统性偏高。入库列要保持 NaN——"哪天没进货"和"进了 0 件"是两种业务状态,前者在补货模拟里不是到货事件。补全后先验证行数:100 个商品、200 天跨度,宽表就是 2 万行,对不上就查是不是有商品上线晚或中途下架。
提示:零销量日期补 0,入库日期补 NaN,两个处理逻辑不能混。
2.3 三类特征:时间周期、滞后销量、商品属性
frame = frame.merge(product, on='product_id', how='left') frame['weekday'] = frame['date'].dt.weekday frame['is_weekend'] = frame['weekday'].isin([5, 6]).astype(int) frame['month'] = frame['date'].dt.month for lag in [1, 2, 3, 7, 14]: frame[f'sales_lag_{lag}'] = ( frame.groupby('product_id')['sales_qty'].shift(lag) ) frame['roll_mean_7'] = ( frame.groupby('product_id')['sales_qty'] .transform(lambda x: x.shift(1).rolling(7, min_periods=1).mean()) ) frame['roll_std_7'] = ( frame.groupby('product_id')['sales_qty'] .transform(lambda x: x.shift(1).rolling(7, min_periods=1).std()) .fillna(0) )特征按来源分三类:时间特征(周几、周末、月份)捕捉周期;滞后特征(1、2、3、7、14 天前销量)捕捉最近走势,7 天滞后对应周周期;滚动特征捕捉短期水平和波动。关键在shift(1)之后再rolling(7)——先移一天再滚动,才能保证窗口里不含当天销量,否则就是特征泄漏。这里用transform而不是直接对整列rolling,是为了避免跨商品边界取到别的商品的数据。roll_std_7模型里用得少,但第 4 章算安全库存时是必需品,现在一并算好,后面不用回头补。
2.4 按时间切分训练集和验证集
split_date = pd.Timestamp('2017-10-01') train = frame[frame['date'] < split_date].dropna(subset=['sales_lag_1']) valid = frame[frame['date'] >= split_date]切分日期怎么定?我一般看补货节奏:按天决策就留最后 21 天,按周订货至少留两个完整周期。这里必须用时间切分而不是随机切分,原因 2.3 已经埋下——滞后特征让样本间存在时间依赖,随机切分等于把未来销量混进训练集,验证指标虚高。dropna(subset=['sales_lag_1'])去掉每个商品最初几天没有滞后特征的样本,这几行特征不完整,留着只会让模型学到错误模式。
3. 用 LightGBM 建销量预测基线:模型选型与参数设定
销量预测是整套方案里被讨论最多的部分,但它其实最不稀缺——真正拉开差距的是预测结果怎么用。把基线模型快速跑通,再往补货决策上投入时间,是我做完这个方向后的直接感受。下面这套 LightGBM 配置,在多数小超市流水数据上都能在半小时内跑出一个够用的起点。
3.1 为什么选树模型而不是时序模型
零售销量数据有四个特征:商品多、品类差异大、节假日有尖峰、存在大量零销量样本。ARIMA 这类时序模型需要逐商品建模,几百个商品就要调几百组参数,而且很难把"是否周末""是否节假日"这类外部特征塞进去。树模型天然支持多商品联合建模,商品 ID 或品类直接作为特征传入,模型就能学出不同商品的基准销量差异。另外,这个任务的销量序列并不平稳,促销和节假日造成的尖峰,传统时序模型倾向于当异常值处理,而树模型可以专门为尖峰建特征。还有一个实际原因:时序模型遇到大量零销量时会输出负值,还得手工截断,树模型输出天然非负,少一层处理。
LightGBM 和 XGBoost 之间我选前者:训练快、内存小、对零值多的稀疏特征友好。这不是说 LightGBM 一定更准,而是在这个数据规模下,同样的时间它能多跑几轮实验,对调参迭代更友好。
3.2 最小可复现的训练代码
import lightgbm as lgb feature_cols = [ 'weekday', 'is_weekend', 'month', 'sales_lag_1', 'sales_lag_2', 'sales_lag_3', 'sales_lag_7', 'sales_lag_14', 'roll_mean_7', 'roll_std_7', 'category_id', 'shelf_life_days' ] model = lgb.LGBMRegressor( n_estimators=800, learning_rate=0.03, num_leaves=63, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, min_child_samples=20, random_state=42 ) model.fit( train[feature_cols], train['sales_qty'], eval_set=[(valid[feature_cols], valid['sales_qty'])], eval_metric='rmse', callbacks=[lgb.early_stopping(100)] ) valid['pred_sales'] = model.predict(valid[feature_cols])参数按这个起点调一般不会跑偏:n_estimators=800配合early_stopping(100),验证集 RMSE 连续 100 轮不降就自动停,省时间也防过拟合;num_leaves=63控制树复杂度,数据量大可以到 127;subsample=0.8和colsample_bytree=0.8是行、列两个维度的随机采样,防过拟合的常规做法;min_child_samples=20限制叶子节点最少样本数,零销量多的冷门商品全靠这个参数压住极端值。category_id直接传数值,LightGBM 会按数值切分,如果品类间差异很大,建议显式转成 category 类型或通过categorical_feature参数指定。
训练完顺手看一眼特征重要性,能发现不少问题:
imp = pd.Series(model.feature_importances_, index=feature_cols).sort_values(ascending=False) print(imp.head(10))如果sales_lag_1和sales_lag_7没有排进前三,基本可以断定数据对齐出了问题,回头查 2.2 的补全逻辑。
3.3 两个评估视角:总量对得上,单品也对得上
基线跑完不要只看整体 RMSE,拆成两个视角看。总量视角把验证集每天的预测和实际分别汇总对比,趋势跟不上说明周期或节假日特征没抓到:
daily_pred = valid.groupby('date')['pred_sales'].sum() daily_actual = valid.groupby('date')['sales_qty'].sum() print((daily_pred - daily_actual).abs().mean()) wmape = (abs(valid['pred_sales'] - valid['sales_qty']).sum() / valid['sales_qty'].sum()) print(f'WMAPE: {wmape:.2%}')单品视角按商品分组算误差,重点看热门商品的误差方向。热门商品一天卖 50 件,预测成 45 件是 10% 误差,但按库存成本算可能已经缺货;冷门商品一天卖 1 件,预测成 2 件误差 100%,库存成本几乎可忽略。所以"哪个商品错了"比"平均错多少"重要得多。我会打印误差最大的前 10 个商品去看销量曲线,通常能发现促销尖峰没被捕捉,或者该商品存在周期性断货,这些信息直接决定第 4 章的补货参数怎么调。
4. 从预测到订货单:补货决策模型与安全库存设定
预测做出来之后,真正的业务决策才开始。补货不是"预测多少就订多少",中间隔着缺货成本、报损成本、资金占用三笔账。这一章的决策逻辑是整套方案里利润杠杆最大的部分,参数也最值得花时间调。
4.1 补货量的标准算式与每个变量的含义
补货量的标准算式:
补货量 = max(0, 预测需求 + 安全库存 − 当前库存 − 在途量)
四个变量的业务含义分别是:预测需求是下一个补货周期内的期望销量,要用周期天数乘以日均预测值;安全库存是吸收需求波动和供货延迟的缓冲;当前库存是盘点时的货架存量;在途量是已下单未到货的量。小超市场景如果供应商当天送,在途量通常是 0,但模拟里必须有这个字段,否则连续补货时订单会越滚越大。
安全库存的常见估法是:安全库存 = 服务水平系数 z × 需求标准差 σ × √补货提前期 L。缺货率 5% 对应 z≈1.65,缺货率 10% 对应 z≈1.28;σ 用roll_std_7,它是按商品算的近 7 天销量波动,天然差异化。举个例子:某饮料日均预测 30 件,σ=8,提前期 1 天,z=1.28,安全库存就是 1.28×8×1≈10 件。30 天保质期的饮料备 10 件安全库存毫无压力,但换成保质期 2 天的生鲜,这 10 件可能全是报损——所以差异化是必须的。
4.2 分品类参数:一张表说清差异
| 商品类型 | 保质期 | 安全库存倍数 | 单次订货上限 | 理由 |
|---|---|---|---|---|
| 生鲜/短保 | ≤3 天 | 0.2~0.5 倍日销量 | 保质期天数 × 日均销量 × 0.8 | 报损成本远高于缺货成本 |
| 饮料/零食 | 30~180 天 | 1.0~1.5 倍日销量 | 不设硬上限 | 缺货直接丢毛利,保质期容错高 |
| 日用百货 | 180 天以上 | 0.5~1.0 倍日销量 | 不设硬上限 | 需求平稳,过量只占资金 |
这张表的逻辑是让安全库存跟着"缺货成本与报损成本的比值"走。生鲜缺货顶多少卖一天,备多了第二天就报损,安全库存压低、订货设上限;饮料零食缺货直接丢利润,保质期容错高,安全库存给足。另外还要看毛利率:高毛利商品缺货一次损失大,安全库存可以适当上调;低毛利走量商品压库存反而亏资金。实际调参时,这张表的三组系数是最优先动的旋钮,比调模型参数对利润的影响更直接。
4.3 决策函数代码:把算式落进每一行
import numpy as np def calc_order(row, z=1.28, lead_time=1): period_sales = row['pred_sales'] * row['order_cycle'] sigma = row['roll_std_7'] if row['roll_std_7'] > 0 else 1.0 safety_stock = z * sigma * np.sqrt(lead_time) order = max(0, int(period_sales + safety_stock - row['stock'] - row['in_transit'])) if row['shelf_life_days'] <= 3: max_order = int(row['shelf_life_days'] * row['pred_sales'] * 0.8) order = min(order, max_order) return order valid['order_cycle'] = 3 valid['order_qty'] = valid.apply(calc_order, axis=1)order_cycle是补货周期天数,每 3 天订一次就传 3,预测需求按周期放大;lead_time是下单到到货的提前期,当天送传 1。生鲜分支里shelf_life_days * pred_sales * 0.8的意思是:一次最多订"保质期内预计能卖掉的数量打八折",留两成余量给预测误差。注意,这里的stock和in_transit必须来自上一周期的模拟回填,实际使用时要把这个函数嵌进回测循环,而不是直接对原始表 apply——否则库存永远是 0,订货量会整体虚高。
注意:
stock和in_transit是动态值,决策函数必须放在回测循环内部逐天调用。
5. 供销存优化避坑清单:五个最常翻车的现场
这部分是做完这个方向后最想先说的内容。以下五个问题,每一个都真实出现在常见方案里,按"现象、原因、解决"展开。
5.1 现象:预测 RMSE 很低,模拟利润却不升反降
原因:RMSE 是对称损失,高估和低估惩罚一样,但业务上不对称——低估热门商品导致缺货,丢的是高毛利利润;高估冷门商品只是多压一点库存。模型为了整体误差最小,被大量零销量样本拉低预测,热门商品系统性低估。解决:评估时把预测结果乘一个偏置系数,或者用分位数预测。常见做法是用 LightGBM 的objective='quantile'预测 0.6~0.7 分位,替代点预测作为补货依据,相当于给安全库存再加一层保险。
model_q = lgb.LGBMRegressor( objective='quantile', alpha=0.65, n_estimators=800, learning_rate=0.03 )5.2 现象:merge 之后行数暴涨,销售记录全乱
原因:两张表的日期列名不一致(sale_date和in_date),或者日期没转成datetime64,merge 时on匹配不上,产生笛卡尔积。真实流水里这类问题几乎必现。解决:merge 前统一列名和 dtype,merge 后立刻比较行数与"日期 × 商品"的预期总数。建议把 2.1 的对齐逻辑封装成函数,每次换数据都先跑一遍行数自检。
5.3 现象:生鲜商品报损率高到离谱
原因:所有商品共用一套安全库存参数,生鲜被按饮料的标准备货。短保商品的库存成本是按天甚至按小时计的,多备一天就是一批报损。解决:按shelf_life_days分组设参数,生鲜组安全库存倍数压到 0.2~0.5,同时设单次订货上限。这个改动通常比调模型参数对利润的提升更大,因为报损是直接从利润里扣的。
5.4 现象:验证集用随机切分,特征悄悄偷看了未来
原因:train_test_split默认随机打乱,而滞后特征让每个样本依赖前几天的销量。随机切分后,训练集某天的样本可能用到验证集那几天的销量做滞后特征,模型等于提前看到答案,验证指标虚高。解决:按日期排序做时间切分,切分后训练样本要 drop 掉跨切分点的滞后空值。如果验证期需要滚动多次,用 sklearn 的TimeSeriesSplit,它按时间顺序切分,每次训练集都在验证集之前。验证指标宁可难看一点,也要真实。
5.5 现象:回测跑着跑着库存变成负数,订单越滚越大
原因:模拟器漏了到货事件——上一周期下的订单没在到货日加进库存,或者当日销量没从库存扣。库存流水账不平,后面的订货量全部失真。解决:先写一个最小回测循环,验证库存等式:期末库存 = 期初库存 + 入库量 − 销量 − 报损量。任何一天不成立就说明模拟逻辑有 bug,跑优化之前先把这条断言写进代码。
6. 最有用的落地习惯:让回测曲线替你做决策
每次调整任何参数,都跑一遍同一段历史回测,输出两条曲线:累计利润曲线和期末库存曲线。累计利润看出整体收益,库存曲线暴露备货偏差——库存持续堆积说明订多了,三天两头缺货说明订少了。这两条曲线比任何指标都直观。
def run_backtest(frame, params): stock, profit = 0, 0 rows = [] for _, r in frame.sort_values('date').iterrows(): stock += r['in_arrive'] # 先处理到货 sold = min(r['actual_sales'], stock) # 再模拟销售 profit += sold * r['margin'] - r['order_qty'] * r['cost'] stock -= sold rows.append({'date': r['date'], 'stock': stock, 'profit': profit}) return pd.DataFrame(rows)这段代码的顺序是关键:先到货、再销售、最后盘点,换一下结果就全变。in_arrive由前几天的order_qty按提前期推导,margin和cost用商品表的进销单价。调参时一次只动一个系数,对比曲线再决定下一步。我自己的习惯是每轮实验存一份参数和曲线的对应记录,调乱了还能退回上一版,算是给自己留的后悔药。这个方向做下来最深的体会是:模型精度决定上限,补货策略和回测闭环决定下限,后者才是真正拉开差距的地方。希望帮到你。
本文还有配套的精品资源,点击获取