生鲜补货预测与库存优化:基于报童模型和分位数回归的完整实践
2026/9/13 11:51:31 网站建设 项目流程

简介:这是2023年全国大学生数学建模竞赛C题“销售预测与库存管理”的一等奖获奖配套资料包,面向参加数模竞赛的高校学生、指导教师以及需要Python数据分析项目实战的开发者。压缩包共38个文件,总大小7.93MB,涵盖9个Jupyter Notebook分析脚本、3个Python程序、24个Excel数据表及1篇论文定稿PDF,其中ipynb实现聚类分析、ARIMA与GRU销量预测、多品类回归建模等关键环节,xlsx则存放单品汇总、损耗率、预测结果等中间数据,结构清晰便于复盘。目前已有42人学习下载。资料不仅包含完整可运行的赛题代码和最终论文,还附带项目详细说明,能帮助读者理解问题拆解、数据预处理、模型对比与结果可视化的全流程,既适合备赛训练,也可作为毕业设计、课程设计或Python数据挖掘项目的参考蓝本。

1. 2023年C题的真正难点:预测的不是销量,而是补货量

2023年国赛C题拿到手,很多人第一反应是“这不就是个销量预测吗”,然后把时间序列模型跑一圈,等交卷时才发现第二问的库存优化完全没接上。这道题明面上给的是蔬菜各单品的销售流水,实际要交付的是未来一周每个单品的补货量和定价策略。换句话说,预测只是中间产物,最终考核的是你能不能把预测的不确定性转成可执行的单级库存决策。再有,题目里的单品种类极多、历史数据却不长,部分单品可能只有几周的销售记录,冷启动问题比一般时序预测更突出。

我见过不少队伍把精力全押在让MAE再低一点,结果论文里的“库存模型”只是用均值预测乘了个安全系数,直接被评委看穿。反观能拿一等奖的代码包,普遍是同一套结构:数据清洗与特征工程、分层预测、用报童模型把分位数预测转成订货量、最后用一份能复现的脚本把全过程串起来。这篇文章就按这条线,把从原始流水表到最终补货单的完整链路讲一遍,代码部分按能直接跑通的程度给出,参数含义也会逐一说明。

2. C题的数据先看什么:从流水表到可训练样本

2.1 原始数据的三个必要检查点

拿到数据先别急着建模,先做三个检查。第一,确认销售流水的时间跨度,2023年C题的数据范围大致是2023年7月到2024年6月,但不同单品起售时间不一致,这意味着有些序列天然就短,不能一刀切地截取训练集。第二,检查“销量为0”的日期是真实无销售还是缺货,前者是正常波动,后者会严重污染均值特征——常见做法是看同一天同分类下其他单品的销售情况来推断,或者干脆单独做一个缺货标记位。第三,检查价格字段,C题里价格是会变动的,而且部分单品存在“促销价”,这个信息在后面做定价策略时非常关键。

处理流水表的关键一步是把“宽表”转成“按单品-日期排列的长表”。一个容易忽略的细节是:日期索引要补全,即使在某个日期该单品没有销售记录,也要用0填充,否则后续构造滞后特征时索引会对不上。

2.2 特征工程:把“单品种时序”变成“面板回归”

C题的预测目标是一个个单品各自的销量,但单独给每个单品建模型非常不现实——历史太短,模型学不到东西。多数国赛一等奖方案的做法是改为构造面板数据:每一行是一个(单品, 日期)组合,特征是分类属性、时间属性、滞后销量、滚动统计量、价格变化率,标签是该单品当天的销量。这样所有单品共享同一个回归模型,长序列的单品能给短序列的单品提供跨品类的学习信号,这也是数据量不够时最有效的做法。

滞后特征里最容易踩坑的是“泄漏”:如果用t日的价格特征预测t日的销量,看起来没问题,但如果把t+1日的信息混进t日特征,测试集效果就会虚高。所以构造特征时全部只用历史窗口的数据,代码里统一用shift()错开日期。

2.3 代码实现:构造基础训练集

import pandas as pd import numpy as np df = pd.read_csv("sales.csv", parse_dates=["date"]) # 补齐单品-日期全组合 all_singles = df["single_id"].unique() date_range = pd.date_range(df["date"].min(), df["date"].max(), freq="D") full_index = pd.MultiIndex.from_product([all_singles, date_range], names=["single_id", "date"]) df = df.set_index(["single_id", "date"]).reindex(full_index).reset_index() df["qty"] = df["qty"].fillna(0) df = df.sort_values(["single_id", "date"]).reset_index(drop=True) for lag in [1, 3, 7]: df[f"qty_lag{lag}"] = df.groupby("single_id")["qty"].shift(lag) df["qty_roll7_mean"] = df.groupby("single_id")["qty"].transform( lambda x: x.shift(1).rolling(7).mean()) df["price_change"] = df.groupby("single_id")["price"].pct_change() df["weekday"] = df["date"].dt.weekday df["month"] = df["date"].dt.month

这段代码做三件事:第一,用reindex补全缺失日期,保证每个单品每一天都有一行;第二,用shift把滞后特征和滚动均值错开一天,防止用当天信息预测当天;第三,构造价格变化率和时间属性。注意reindex之后销量为0的行,价格字段也需要向前填充或者用分类均值填充,具体做法取决于原始数据里价格缺失的原因。对短序列单品,滞后3天、7天的值会大量为NaN,后面训练树模型时直接保留NaN即可,LightGBM原生支持缺失值处理。

3. 预测层:Base-Pool-Meta三层结构,而不是调一个Prophet

3.1 为什么要按品类分层建模

C题的销售模式有明显的品类差异,叶菜类生命周期短、价格波动大,根茎类相对稳定,食用菌又有其独立的生长周期。把所有单品混在一个模型里,模型虽然能共享信息,但会吸收太多品类噪音。常见做法是分成三层——Base层:对每个单品跑一个简单的统计基线,比如上周同期均值,作为baseline;Pool层:所有单品的数据放一起训练LightGBM,得到主模型;Meta层:对Base和Pool的结果做加权融合,权重在验证集上搜索。

这个结构的优势在短序列单品上尤其明显:Base层因为历史太短几乎不可用,但Pool层能从同一分类下其他单品学到销量分布规律。

3.2 相似品信息迁移的做法

跨品类共享信息是Pool模型的本能,但同一分类下的相似单品仍然存在差异。拿不到相似单品的特征怎么办?一个直接从推荐系统搬过来的思路:用每个单品的销售向量算相似度矩阵,把相似单品的近期销量均值作为新特征拼进去。销售向量可以取最近14天的日销量序列,求余弦相似度,取Top5相似品,构造两个特征:相似品近期销量均值和相似品价格均值。

这个特征对冷启动单品特别有效——一个新单品没有自己的历史销量,但有相似的老品数据作参照。

3.3 LightGBM训练脚本与滚动测试集

import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit feature_cols = [c for c in df.columns if c not in ["qty", "date", "single_id"]] X = df[feature_cols] y = df["qty"] tscv = TimeSeriesSplit(n_splits=3) for train_idx, val_idx in tscv.split(X): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = lgb.LGBMRegressor( objective="quantile", alpha=0.7, n_estimators=300, learning_rate=0.05, num_leaves=31, colsample_bytree=0.8 ) model.fit(X_train, y_train, eval_set=[(X_val, y_val)], callbacks=[lgb.early_stopping(50)])

这里的objective="quantile"和alpha=0.7是关键设置。回归模型默认预测条件均值,但第四节的库存决策需要的是预测分布的分位数,所以直接在这里训一个0.7分位数模型,alpha值等于报童模型里的最优服务水平。TimeSeriesSplit保证训练集永远在验证集之前,这是时序任务与普通交叉验证最大的区别——随机打散会把未来信息泄漏到训练集里,导致评估结果虚高。

训练完成后,对同一份数据再训一个alpha=0.5的模型作为中位数预测,两个模型的输出在第四节的补货量计算里会配合使用。

4. 决策层:用报童模型把预测分布变成补货量

4.1 报童模型参数与成本结构

预测做完了,接下来的问题是:给定预测分布,补多少货才最优。这里引入报童模型,它的核心是权衡两个成本:超量成本c_o(订多了,菜烂在仓库里)和缺货成本c_u(订少了,丢失销售机会)。最优订货量出现在超量的边际期望成本等于缺货的边际期望成本这一点,解出来最优订货量就是销量分布的c_u/(c_u+c_o)分位数。

C题里超量成本通常用进货价近似,缺货成本用售价减进货价近似。如果题目要求“满足市场需求的前提下,使总成本最小”,则缺货成本还要加一项商誉损失,但这个是软性指标,直接按售价减进货价再加一个惩罚系数最稳妥。

4.2 分位数预测与最优订货量求解

因为第3章的LightGBM已经训好了alpha分位数模型,预测得到的就是q_alpha,最优订货量直接等于这个值。举个例子:进货价3元、售价5元,那c_o=3, c_u=2,最优服务水平=2/(2+3)=0.4,也就是补货量取预测分布的0.4分位数。如果不加这个决策层,直接用点预测(0.5分位数)补货,算下来总成本会比最优高出不少,这个差值可以写进论文的灵敏度分析里。

4.3 代码实现:补货量与定价联动

# 假设已有预测结果 pred_df,包含 single_id, date, q50, q70 # q50 是中位数预测,q70 是服务水平 0.7 下的分位数预测 cogs = df.groupby("single_id")["price"].mean().to_dict() # 进货价 price_map = df.groupby("single_id")["price"].last().to_dict() order_qty = [] for _, row in pred_df.iterrows(): sid = row["single_id"] price = price_map[sid] cost = cogs[sid] * 0.6 # 假设进货价是售价的60% cu = price - cost co = cost service_level = cu / (cu + co) alpha_col = f"q{int(service_level * 100)}" # 如果没训对应分位数,用线性插值近似 qty = row[alpha_col] if alpha_col in pred_df.columns else row["q50"] order_qty.append(np.ceil(qty)) pred_df["order_qty"] = order_qty

注意这段代码里的线上插值逻辑很粗糙,更严谨的做法是先对每个单品确定最优服务水平,再针对该水平训练或预测对应分位数。所有数据集的alpha列可以用循环一次性生成,但真要写进论文,最好对服务水平画一条成本曲线,说明为什么这个alpha是最优的,这也是评委最爱看的部分。

定价联动的逻辑在于:如果预测发现某单品未来一周需求高于可供应量,可以把售价往上调,缺货成本随之上升,服务水平上升,补货量增加;反过来需求疲软时降价促销,超量成本相对变高,模型自动给出更保守的补货量。

5. 提升论文说服力的两个技巧:CRPS评分与库存-成本前沿图

5.1 用CRPS替代MAE做模型选择

MAE只衡量点预测的误差,但C题第二问要的是分位数预测质量。两个模型的MAE相同,分位数可靠性可以差很多,一个过分自信、一个过度保守,后者在报童模型里的表现会更好。国赛评阅时,评委更认可CRPS(Continuous Ranked Probability Score),它同时衡量预测分布和真实值之间的差距。

def crps(obs, q50, q_lo, q_hi): # 简化版CRPS,用分位数近似积分 score = np.abs(obs - q50) if obs < q_lo: score += 0.5 * (q_lo - obs) elif obs > q_hi: score += 0.5 * (obs - q_hi) return score

更规范的做法是用prophet自带的CRPS计算函数,或者用scoringrules库,但手工实现的这个版本已经能说明问题:模型不但要预测得准,还要把不确定性范围报出来。如果q_lo和q_hi构成的区间能覆盖住60%的真实销量,说明模型校准良好。

5.2 画一条缺货成本与库存成本的前沿曲线

另一个答辩必杀技是画出“库存成本-缺货成本”的权衡曲线。做法很简单:把服务水平从0.1以0.05步长递增到0.95,对每一个服务水平计算对应的补货量,再回测到验证集上,计算该策略下的超量成本和缺货成本。两个成本相加,画出一条U形曲线,最低点对应的服务水平就是理论最优。

levels = np.arange(0.1, 0.95, 0.05) total_costs = [] for lv in levels: order = np.percentile(pred_df["q50"], lv * 100) # 简化,实际用分位数预测 over = np.maximum(order - val_df["qty"], 0).mean() * co under = np.maximum(val_df["qty"] - order, 0).mean() * cu total_costs.append(over + under)

这张图放到论文里能直观呈现“为什么选择0.65而不是0.5”的决策逻辑,也是对第4章报童模型的实际验证。代码逻辑按服务水平循环求总成本,再在结果中找最小值点,整个过程不需要额外的库,matplotlib一行画图即可。

6. 提交前要做的三个检查:代码、论文、数据文件的对应

一等奖代码包最常见的问题是评委根本跑不起来。三个检查点:第一,代码里的随机种子是否固定,LightGBM、数据打乱、KNN相似度计算都要设置seed,不然同一份代码两次运行结果不同;第二,数据路径是否用了相对路径,很多队伍在Windows上写死绝对路径,评委换台电脑就崩,直接给相对路径加个try判断最省事;第三,论文里的参数和代码里的默认参数是否一致,比如论文写的learning_rate=0.05,代码里却是0.1,会被理解成论文和实现脱节。

关于代码规范,我记得评阅规则里会有“可复现性”一项,严格说评委不会真的逐行运行,但会在论文中找关键数据和图表对应。所以补货量结果表、成本曲线图、特征重要性图三个输出最好存到CSV和PNG,连同代码一起打包,形成一个结果可追溯的完整链。最后,把预测结果按题目要求的格式转成提交表,列名和单位别弄混。这一步做完,代码包就算真正能达到“给人就能复现”的一等奖标准了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询