尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

2023数学建模C题实战:生鲜果蔬销量预测与LightGBM补货决策

2023数学建模C题实战:生鲜果蔬销量预测与LightGBM补货决策 简介这份资源是2023年全国大学生数学建模竞赛C题的Python源代码实现针对商超果蔬类商品价格预测与补货决策问题适合数模参赛者、数据分析学习者以及对零售智能化感兴趣的开发者。压缩包共18个文件由11个Python脚本、6个MATLAB脚本和1个说明文档组成其中Python代码涵盖数据爬取、jieba分词、回归预测、价格与销量预测等核心环节并包含数据清洗与模型验证逻辑MATLAB脚本用于数据拟合、分类与Pearson相关性分析README帮助快速定位各模块用途。已有310人学习下载。项目从数据预处理、特征分类到预测建模形成完整流程涉及时间序列分析、机器学习与最优化方法并提供多种脚本组合的建模路线便于复现C题完整方案也可迁移到商超智能定价、库存优化等实际场景。1. 一道赛题两条预测线先搞清楚C题到底要算什么2023年全国大学生数学建模竞赛C题的核心场景是商超里最难管的那类商品——生鲜果蔬。题目给的是流水和进价表要你用数学建模方法回答两个问题明天这些叶菜和茄果卖多少、按什么价格卖以及后天该往货架上补多少。基于Python实现这套源代码通常会把“销量预测、价格联动、补货决策”串成一条可运行的数据链路而不是三个孤立的脚本。这套做法也直接适用于真实零售行业的日销预测与自动补货。一个反直觉的结论是价格预测做得再精细对最终毛利的提升也有限真正的收益藏在补货策略里代码的重心也应该放到后半段。2. 把赛题翻译成建模框架输入输出、选型理由与评价指标C题看起来是两道题价格预测、补货预测。但建模时不能把它拆成两个独立的模型否则会出现“价格模型预测明天降价能带动销量而销量模型还在用昨天的价格特征”这种自相矛盾。正确做法是建一张以“日期-门店-品类”为粒度的宽表价格作为特征进入销量模型销量再通过分位数转换为补货量三个目标共享同一份训练数据。2.1 题目输入输出流水、批发价、损耗率如何对到一张宽表上常见的数据输入是三张表。销售流水表记录每一单的商品、数量、折扣价和成交时间注意这里不是日汇总而是一行一单的明细批发价格表给出每个品类每天的进价损耗率表给出不同品类在运输和上架过程中的自然损耗比例。这三张表不能直接进模型要按日期和商品聚合成日粒度样本。输出目标通常有三个未来一定周期的日销量预测、每个品类的定价建议、每日补货量。其中定价建议一般不直接给出具体价格而是给出价格弹性或价格调整系数供决策方结合毛利目标去换算。这是很多队伍跑偏的地方——把精力花在“优化价格本身”而不是“评估价格变化对销量的影响”。聚合宽表的核心字段如下表所示字段含义来源date销售日期流水表截取store_id / sku_id门店与商品标识流水表category品类叶菜、茄果、菌菇等商品映射unit_price当日实际成交均价流水表加权sales_qty当日销量流水表汇总wholesale_price当日批发价进价表loss_rate品类损耗率损耗表is_promo当日是否有促销折扣流水表折扣字段派生字段对齐工作没有技术含量但极其耗时。常见做法是按“store_id sku_id date”做 groupby把销量和成交额各自求和再用成交额除以销量得到实际均价。批发价表本身就比较规整直接 merge 即可。损耗率表按品类给合并时按 category 关联。2.2 为什么选LightGBM而不是ARIMA或Prophet时间序列模型阵营里ARIMA 和 Prophet 是很多参赛队伍的第一反应。但果蔬数据有三个特点让纯时间序列模型很难受价格是强外生变量且与销量互相影响不同门店、不同品类的波动模式差异极大需要分组建模数据只有半年左右季节性和节假日效应样本不足。LightGBM 这一类梯度提升树模型在这道题上有几个实际优势。第一支持 categorical_feature门店、品类、星期几这些离散变量可以直接作为类别特征传入不用做 one-hot避免高维稀疏。第二树模型对特征交互的捕捉是自动的比如“周末促销叶菜”这类组合规则不需要手动构造交叉特征。第三LightGBM 原生支持分位数回归目标而补货量需要的正是需求分布的分位数不是均值。这里再对比一下几种候选方案方便你判断自己有没有选错方向方案处理价格外生变量多门店多品类扩展分位数输出在本题的适用度ARIMA弱需额外建模需要逐序列循环较复杂低适合单SKU复盘Prophet支持协变量但不灵活中等需二次处理低周期样本不足LightGBM回归强直接作特征天然支持分组特征需二次处理残差高性价比最好LightGBM分位数强天然支持原生支持最高推荐如果你完全没接触过 LightGBM那也没有关系用它只是因为它语法简单fit 和 predict 的接口和 sklearn 一致。真正的难点在特征构造而不是模型调用。2.3 指标先行MAPE、满足率、报废率各看什么赛题评阅和论文答辩都绕不开评价指标但不同角色关心的是不同指标。预测部分最常用的是 MAPE即平均绝对百分比误差。不过 MAPE 对销量接近 0 的样本非常敏感生鲜商品经常出现“今天卖 2 斤明天卖 0 斤”的情况一个 0 销量样本就能把整个 MAPE 拉高几个点。因此我更建议同时看 WAPE即总绝对误差除以总销量它对小样本更稳健。补货部分的核心指标是满足率和报废率。满足率 实际售出量 / 需求量反映的是缺货程度报废率 报废量 / 补货量反映的是超卖造成的损耗。这两个指标是一对矛盾补货放大了满足率上去了报废率也跟着上去补货保守了报废率下来了缺货又来了。参数调优时需要在两者之间找一个平衡点这个平衡点通常由毛利率决定。一句话概括这章的建模框架价格进特征销量出分布分位数转补货三个指标盯到底。下一步就是把原始的三张表清洗成能进模型的宽表。3. 果蔬数据清洗与特征工程先统一单位再做滞后特征果蔬数据是典型的“看起来干净跑起来全是坑”。这章先把数据清洗和特征构造讲透因为这是整份源代码里最容易让预测模型翻车的环节。很多队伍模型调参调了一个星期最后发现是单位没统一或者促销日被当成普通日放进训练集导致价格弹性算出来是正的。3.1 单位与品名先把“斤、千克、精品菜”统一掉销售流水里的商品名并不总是规整的。同一个商品可能出现“白萝卜”“白萝卜(斤)”“精品白萝卜”几种写法重量单位有的按“斤”有的按“千克”价格字段也会有“原价”和“折扣价”两列。这一步的清洗目标很明确所有重量统一为 kg所有价格统一为 元/kg商品标识统一用 sku_id 而不是中文名。下面是一段可以直接用的清洗代码框架import pandas as pd import re def clean_sku(df): # 统一商品名中的全角括号和空格 df[sku_name] df[sku_name].str.replace(, ().str.replace(, )) df[sku_name] df[sku_name].str.replace(r\s, , regexTrue) # 从商品名里提取重量单位默认按斤处理 def to_kg(row): if 千克 in row[sku_name] or kg in row[sku_name].lower(): return row[qty] if 克 in row[sku_name]: return row[qty] / 1000 # 按斤记录时1斤 0.5kg return row[qty] * 0.5 df[qty_kg] df.apply(to_kg, axis1) # 价格统一为元/kg df[price] df[amount] / df[qty_kg] # 用正则提取统一商品代码假设代码是纯数字 df[sku_id] df[sku_name].str.extract(r(\d{4,})).fillna( df[groupby_key] ) return df上面这段代码的核心在两个地方。to_kg是一个按行处理的换算函数它根据商品名里是否包含“千克”“克”来判断当前单位缺省按斤处理这个缺省逻辑在生鲜数据里是安全的因为国内商超的散装称重商品绝大多数按斤出票。price字段没有直接用流水里的折扣价而是用成交金额除以换算后的 kg 重量因为折扣价在部分单据里可能没有正确回写按金额反推更稳。需要注意如果赛题给了标准的 sku_id 字段就不要自己去商品名里提取直接使用官方字段即可。上面代码中的groupby_key是一个兜底占位实际使用时替换成你数据里现成的标识列。3.2 缺失日期与促销识别哪些样本不能放进训练集果蔬品类最常见的缺失有两种一是某个 sku 某天完全没有销售记录这一天可能是真的没货也可能是卖完了系统没来得及补单二是促销字段缺失部分日期系统没有记录折扣信息。这两种缺失的处理方式完全不同。没有销售记录的日期不能简单地填 0因为“没进货”和“进了货卖光了”在业务上是两回事。如果直接填 0模型会学到“这个商品经常卖 0”补货预测就会系统性偏低。常见做法是先按日期补全每个 sku 的时间序列再构造一个is_stockout特征标记缺失日。预测销量时模型可以看这个特征但补货计算时要剔除缺货日样本否则会把“卖光日”当成“没人买日”。def build_daily_panel(clean_df): # 生成每个 sku 的完整日期序列 all_dates pd.date_range(clean_df[date].min(), clean_df[date].max(), freqD) panel ( clean_df .groupby([store_id, sku_id]) .apply(lambda g: g.set_index(date).reindex(all_dates)) .reset_index() ) # 缺失销量标记为缺货而不是直接填0 panel[is_stockout] panel[sales_qty].isna().astype(int) panel[sales_qty] panel[sales_qty].fillna(0) # 价格用最近的有效值向前填充 panel[unit_price] panel.groupby([store_id, sku_id])[unit_price].ffill() return panel促销日的识别要谨慎。最稳妥的做法不是看“是否打了折”而是计算折扣率discount_rate 1 - 成交价 / 原价当折扣率大于 5% 时认为当天有促销。这里设置 5% 阈值是为了过滤掉生鲜商品正常的尾市打折——傍晚对临近保鲜期的叶菜打折是常规操作不是真正的营销活动。下面是特征构造部分的示例把促销、星期、价格环比一次性算好def add_features(panel): df panel.copy() # 促销标记 df[discount_rate] 1 - df[unit_price] / df[origin_price] df[is_promo] (df[discount_rate] 0.05).astype(int) # 时间基础特征 df[weekday] df[date].dt.weekday df[month] df[date].dt.month df[is_weekend] (df[weekday] 5).astype(int) # 滞后销量和价格shift(1) 表示前一天不能用当天 df[lag_sales_1] df.groupby([store_id, sku_id])[sales_qty].shift(1) df[lag_price_1] df.groupby([store_id, sku_id])[unit_price].shift(1) # 7日滚动均值min_periods 防止开头几天特征为空 df[rolling_sales_7] ( df.groupby([store_id, sku_id])[sales_qty] .transform(lambda x: x.shift(1).rolling(7, min_periods3).mean()) ) # 价格环比当前价格 / 前7日均价用于捕捉涨价降价信号 df[rolling_price_7] ( df.groupby([store_id, sku_id])[unit_price] .transform(lambda x: x.shift(1).rolling(7, min_periods3).mean()) ) df[price_ratio] df[unit_price] / df[rolling_price_7] return df特征构造中有三个参数值得特别说明。shift(1)是防止未来信息泄漏的关键它保证当天的销量不会参与前一天的特征计算rolling(7, min_periods3)里的min_periods3表示不足 7 天时至少用 3 天数据计算均值这样数据集早期的样本不会被丢弃但特征噪声会稍大一些price_ratio是一个无量纲特征能同时反映短期价格趋势和促销冲击比直接用原始价格更稳因为不同品类的基础价差非常大模型不需要从零学习“西红柿比大白菜贵”这件事。4. 价格与补货预测代码实现LightGBM、分位数和损耗率怎么组合这章是源代码的核心部分。目标只有一个用上面构造好的宽表训练一个能输出销量分布、再换算成补货量的模型。整段流程按“数据切分 → 模型训练 → 补货计算”三步来写每一步都有对应的代码和参数说明。4.1 训练集构建按时间切分不按随机切分建模竞赛里最常见的错误是直接用 train_test_split 随机切分。对于时间序列数据随机切分会让模型在验证集里看到未来数据MAPE 会虚低但一旦上线预测未来误差立刻反弹。正确的切分方式是前 70% 的日期做训练最近的 15%-20% 做验证最后留一周做“最终预测演示”。from sklearn.model_selection import TimeSeriesSplit import lightgbm as lgb df df.sort_values([store_id, sku_id, date]).reset_index(dropTrue) # 按日期排序后切分不能用随机切分 split_date df[date].quantile(0.8) train_df df[df[date] split_date] val_df df[df[date] split_date] feature_cols [ weekday, month, is_weekend, is_promo, lag_sales_1, lag_price_1, rolling_sales_7, price_ratio, is_stockout ] categorical_cols [weekday, month, is_weekend, is_promo, is_stockout] X_train, y_train train_df[feature_cols], train_df[sales_qty] X_val, y_val val_df[feature_cols], val_df[sales_qty] train_set lgb.Dataset(X_train, labely_train, categorical_featurecategorical_cols) val_set lgb.Dataset(X_val, labely_val, categorical_featurecategorical_cols)TimeSeriesSplit这个类我建议只在做交叉验证时使用最终提交的模型直接用最近的全部数据训练验证集的作用是调参和确认没有过拟合。切分比例quantile(0.8)不是固定的如果数据覆盖了三个月以上可以把验证集扩大到 25% 来观察模型的稳定性如果数据量小验证集太大会让训练样本不足可以缩到 15%。4.2 LightGBM 参数第一次跑用这套默认值不会翻车参数是新手最喜欢调也最容易调乱的地方。这里给一组经过验证的初始参数直接跑不会出现严重过拟合或欠拟合后续再根据特征重要性微调。params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 31, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1 } model lgb.train( params, train_set, num_boost_round800, valid_sets[val_set], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] ) # 特征重要性排序用于排查无效特征 importance pd.Series(model.feature_importance(), indexfeature_cols).sort_values(ascendingFalse)以上参数里learning_rate0.05配合num_boost_round800和早停轮数 50是一个经典的保守组合。学习率越小模型越不容易过拟合但训练时间会变长num_leaves31是 LightGBM 的默认值它控制树的复杂度叶子数越多模型越容易记住噪声min_child_samples20规定了每个叶子节点至少要有 20 个样本这个值对生鲜数据尤其重要因为某些冷门品类一天的销量就个位数叶子节点样本太少模型会在个位数样本上反复拟合出现毛刺。feature_fraction和bagging_fraction都是防过拟合的随机采样参数默认值 0.8 表示每棵树只用 80% 的特征和 80% 的样本。如果你发现验证集 MAPE 比训练集高出很多优先把这两个值降到 0.7而不是急着加大num_boost_round。评估时注意不要只看 MAPE重点看验证集最后一个月的预测曲线是否跟得上真实销量的节奏。生鲜预测误差在 20% 以内属于可接受范围因为天气、节假日这类信息题目数据里并没有提供模型只能从价格和日期特征里间接学习。4.3 补货量计算需求分位数除以损耗率再减库存销量预测输出的是一列数字但补货量不能直接用这个数字。原因有两个预测是均值均值会低估需求波动的尾部风险果蔬有损耗补过去的货在到达货架前就已经损失了一部分。所以补货量的标准公式是( \text{order} \max(0, \frac{\text{需求分位数}}{1 - \text{loss_rate}} - \text{当前库存}) )这里的“需求分位数”表示未来补货周期内有 90% 的概率不会缺货的需求量。LightGBM 的回归模型只能输出均值所以分位数要从残差里估计import numpy as np # 在验证集上计算残差分布 val_pred model.predict(X_val, num_iterationmodel.best_iteration) residual y_val.values - val_pred # 取90%分位数作为需求上界修正 q 0.9 residual_q np.quantile(residual, q) def calc_order(row, model, feature_cols, residual_q, q, loss_rate): base_pred model.predict(row[feature_cols].values.reshape(1, -1))[0] demand_q base_pred residual_q # 90%置信的需求量 order max(0, demand_q / (1 - loss_rate) - row[stock]) return round(order, 1) # 对明天的补货 tomorrow_pred val_df.iloc[-1] order_qty calc_order( tomorrow_pred, model, feature_cols, residual_q, q0.9, loss_rate0.1 )用残差分位数而不是直接做分位数回归是这套代码的简便之处。LightGBM 原生支持objectivequantile但那要求把模型训练两次一次回归一次分位数代码量翻倍。用残差法得到的是一个固定修正量虽然不完美但在半年数据量下比二次训练更稳定也不容易过拟合。损耗率的取值要按品类分别算不能全品类用一个均值。叶菜类损耗率可能高达 15%-20%根茎类只有 5%-8%。如果用一个全局均值叶菜会缺货、根茎类会积压。实际落地时可以从损耗率表里按 category 直接取数没有这张表就按品类历史残货比例推算。补货公式里的stock是当前库存赛题给的销售流水没有库存字段大部分参赛队会用“前一天剩余 前一天销量 × (1 - 损耗率)”来近似推算期初库存。这一步有误差但用于策略对比是足够的。5. 常见问题与排查2023 C题最容易翻车的4个点这章记录的是我拿这份代码跑完整个流程后踩过的坑每个坑都有现象、原因和解决办法。如果你发现自己的预测结果“哪里不对但说不上来”先按这四条排查一遍。5.1 价格弹性算出来是正的促销样本污染了价格特征现象模型训练完查看特征重要性时发现 price_ratio 很高但进一步分析发现价格上涨时销量也跟着上涨完全违反商业常识。原因促销日被当成普通日处理了。促销期间价格低、销量大但促销结束后的第二天价格恢复正常、销量回落。如果把促销日和非促销日混在一起回归模型学到的是“价格低销量高”和“价格高销量也高”两段矛盾的关系价格弹性被促销的脉冲效应污染。解决在特征构造阶段把is_promo单独标记并在训练时让它进入 categorical_feature。如果模型仍然给出正弹性就手动把促销日的样本从训练集中剔除专门训练一个“非促销价格弹性”模型促销日的销量再用促销系数单独修正。竞赛题里不会明确告诉你哪些日子在促销所以折扣率阈值 5% 这个参数值得反复调试换个阈值结果可能完全不同。5.2 MAPE 只有 0.08但补货一天报废一堆均值预测害的现象验证集 MAPE 非常漂亮8% 左右论文里可以写“模型精度优秀”但一跑补货模拟报废率高到不可接受。原因MAPE 是对每个样本误差取平均它奖励的是“猜得准”不奖励“猜得稳”。当销量呈现双峰分布时——平时卖 50 斤、促销日卖 200 斤——均值预测会落在两个峰中间比如 120 斤。计算 MAPE 时这个 120 斤离 50 和 200 都不远误差看起来不大但补货按 120 斤备货平时卖不掉的部分全变成损耗促销日又不够卖。解决补货决策不要用预测均值改用预测分位数。把残差分位数从 0.5 逐步提高到 0.85-0.95观察满足率和报废率的变化曲线。如果只追求论文指标好看用均值就够了如果代码要在真实场景里落地分位数才是关键。这个取舍要在论文里写清楚答辩时这是加分项。5.3 随机切分验证集让 MAPE 特别好看时间泄漏现象用 sklearn 的 train_test_split 切数据验证集 MAPE 0.06换成按日期切分后变成 0.15结果差异巨大。原因随机切分时训练集里混入了验证集日期之后的数据。生鲜销量有很强的周期性模型等于在考试前先看到了未来几周的答案。尤其是滚动特征 rolling_sales_7它本身用的就是未来数据模型可以轻松记住“下周这个品类会大卖”验证集当然漂亮。解决按时间顺序切分已经写在前面的代码里了这里再强调一个细节——切分之后要按store_id sku_id分组检查边界确认没有同一个 sku 的数据同时出现在训练集和验证集的同一天。跨门店的日期泄漏同样危险比如 A 门店某天缺货B 门店销量暴增这两个样本如果被随机切分到不同集合模型也会学到“隐形关联”。5.4 损耗率直接取平均值补货量系统性偏移现象模拟补货时部分品类长期缺货部分品类长期积压整体毛利上不去。原因损耗率表的统计口径是品类级别的但实际损耗跟季节、运输距离、储存条件强相关。夏天叶菜损耗可能翻倍冬天又恢复正常。取平均值会让模型的补货量对季节性波动完全无感。解决按 category 和月份两个维度分别计算损耗率。代码里用一个简单的透视表就能完成loss_pivot df.groupby([category, month])[loss_rate].mean().reset_index() df df.merge(loss_pivot, on[category, month], suffixes(, _avg))这样补货公式里的 loss_rate 就变成了分月份的值而不是一个常数。如果数据量不够分组太碎至少也要按“叶菜、茄果、菌菇、根茎”四大类区分不要全品类混在一起。6. 用回测模拟器验证补货策略不要把 MAPE 当成终点前面几章解决了“怎么训练预测模型”这章回答最后一个问题训练出来的模型到底能不能用只看 MAPE 是不够的因为 MAPE 是统计口径补货决策是经营口径。本节给一个轻量回测模拟器把预测结果转成经营指标让模型的好坏直接体现在缺货量和报废量上。6.1 一个 30 行回测模拟器验证补货规则的真实效果模拟器的逻辑很简单每天开门前有库存按当天实际需求卖出卖不掉的按损耗率报废晚上根据预测和剩余库存决定明天补多少。跑完整个验证周期后统计总缺货量、总报废量和总销售额就能区分不同预测参数的实际效果。def simulate(daily, pred_q, q0.9, loss_rate0.1): stock 0.0 total_sold 0.0 total_short 0.0 total_waste 0.0 for i, row in daily.iterrows(): demand row[sales_qty] sold min(stock, demand) stock - sold total_sold sold total_short max(0, demand - sold) waste_today stock * loss_rate stock - waste_today total_waste waste_today # 用当前历史预测未来需求的分位数并补到该水平 need pred_q(i, q) order max(0, need / (1 - loss_rate) - stock) stock order return { total_sold: total_sold, total_short: total_short, total_waste: total_waste, fill_rate: total_sold / (total_sold total_short), waste_rate: total_waste / (total_sold total_waste), }注意这里的pred_q(i, q)是一个函数指针它接收时间下标和分位数返回预测的需求量。这样写的好处是可以无损替换不同的预测模型——可以是 LightGBM也可以是简单的历史均值回测代码完全不用改。用这个模拟器做参数对比时你会看到很直观的结果q 从 0.5 提高到 0.9满足率上升但报废率也跟着上升损耗率提高报废率上升但缺货率下降。没有一组参数同时让两个指标都变好所谓“最优”是在毛利维度上找一个平衡点。6.2 进阶验证用毛利而不是满足率来选参数满足率和报废率仍然是中间指标最终要看毛利。补货决策里的毛利可以用简化公式计算毛利 销售额 - 进货成本 - 报废成本。模拟器里只需要再加上单价、进价两个字段就能直接输出每个参数组合下的毛利总和。我个人的习惯是永远先跑一遍 q0.5 的基线再跑 q0.9 的高库存策略比较两者毛利差。如果高库存策略毛利没有明显上升说明损耗率估计偏高再往下调损耗率参数。这个流程走完模型到底值不值得上线心里就有数了。如果后面再遇到这题我也会先搭模拟器再训练模型。希望帮到你。本文还有配套的精品资源点击获取
返回列表