小超市供销存优化实战:Python销量预测与补货决策全流程 简介二零一七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, encodinggbk) sale_daily ( sales.groupby([sale_date, product_id], as_indexFalse) .agg(sales_qty(qty, sum)) ) purchase_daily ( purchase.groupby([in_date, product_id], as_indexFalse) .agg(purchase_qty(qty, sum)) .rename(columns{in_date: date}) )这段代码把同一商品同一天的多条销售记录合并成一行入库表同样处理。两个细节值得注意parse_dates必须在读入时就转成datetime64否则两张表的日期列 dtype 不一致后面 merge 时经常翻车商品表通常有中文列名encodinggbk不行就试gb18030。聚合粒度统一到天 × 商品是为了让后续的滞后特征和滚动特征能按商品独立计算这是整个特征工程的地基。2.2 用日期网格补全零销量日期date_range pd.date_range(sale_daily[sale_date].min(), sale_daily[sale_date].max(), freqD) full_index pd.MultiIndex.from_product( [date_range, sale_daily[product_id].unique()], names[date, product_id]) frame pd.DataFrame(indexfull_index).reset_index() frame frame.merge(sale_daily.rename(columns{sale_date: date}), on[date, product_id], howleft) frame frame.merge(purchase_daily, on[date, product_id], howleft) frame[sales_qty] frame[sales_qty].fillna(0)这一步最容易被跳过又最影响结果。原始流水里没有记录的日子不代表销量是 0而是没卖出去建模时必须显式补 0否则模型默认这些日期不存在整体预测会系统性偏高。入库列要保持 NaN——哪天没进货和进了 0 件是两种业务状态前者在补货模拟里不是到货事件。补全后先验证行数100 个商品、200 天跨度宽表就是 2 万行对不上就查是不是有商品上线晚或中途下架。提示零销量日期补 0入库日期补 NaN两个处理逻辑不能混。2.3 三类特征时间周期、滞后销量、商品属性frame frame.merge(product, onproduct_id, howleft) 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[fsales_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_periods1).mean()) ) frame[roll_std_7] ( frame.groupby(product_id)[sales_qty] .transform(lambda x: x.shift(1).rolling(7, min_periods1).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_estimators800, learning_rate0.03, num_leaves63, subsample0.8, subsample_freq1, colsample_bytree0.8, min_child_samples20, random_state42 ) model.fit( train[feature_cols], train[sales_qty], eval_set[(valid[feature_cols], valid[sales_qty])], eval_metricrmse, callbacks[lgb.early_stopping(100)] ) valid[pred_sales] model.predict(valid[feature_cols])参数按这个起点调一般不会跑偏n_estimators800配合early_stopping(100)验证集 RMSE 连续 100 轮不降就自动停省时间也防过拟合num_leaves63控制树复杂度数据量大可以到 127subsample0.8和colsample_bytree0.8是行、列两个维度的随机采样防过拟合的常规做法min_child_samples20限制叶子节点最少样本数零销量多的冷门商品全靠这个参数压住极端值。category_id直接传数值LightGBM 会按数值切分如果品类间差异很大建议显式转成 category 类型或通过categorical_feature参数指定。训练完顺手看一眼特征重要性能发现不少问题imp pd.Series(model.feature_importances_, indexfeature_cols).sort_values(ascendingFalse) 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(fWMAPE: {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 天z1.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, z1.28, lead_time1): 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, axis1)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 的objectivequantile预测 0.6~0.7 分位替代点预测作为补货依据相当于给安全库存再加一层保险。model_q lgb.LGBMRegressor( objectivequantile, alpha0.65, n_estimators800, learning_rate0.03 )5.2 现象merge 之后行数暴涨销售记录全乱原因两张表的日期列名不一致sale_date和in_date或者日期没转成datetime64merge 时on匹配不上产生笛卡尔积。真实流水里这类问题几乎必现。解决merge 前统一列名和 dtypemerge 后立刻比较行数与日期 × 商品的预期总数。建议把 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用商品表的进销单价。调参时一次只动一个系数对比曲线再决定下一步。我自己的习惯是每轮实验存一份参数和曲线的对应记录调乱了还能退回上一版算是给自己留的后悔药。这个方向做下来最深的体会是模型精度决定上限补货策略和回测闭环决定下限后者才是真正拉开差距的地方。希望帮到你。本文还有配套的精品资源点击获取