ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

2023国赛C题:从价格预测到补货决策的Python全流程实战

2023国赛C题:从价格预测到补货决策的Python全流程实战 简介2023年全国大学生数学建模竞赛C题聚焦商超果蔬定价与补货这份基于Python的源代码正是围绕该赛题展开面向参赛学生、建模爱好者及零售数据分析初学者提供从数据清洗、价格预测到补货预测的完整建模实现。压缩包共18个文件包含11个Python脚本、6个MATLAB m文件和1个Markdown说明文档兼顾Python生态的数据处理、回归预测与MATLAB曲线拟合等需求体积仅16KB结构轻量且便于对照学习。已有310人学习下载。通过阅读代码可掌握ARIMA、回归分析、Pearson相关性检验等常见建模手段的实际调用方式也能看到数据预处理、特征构造与结果可视化的完整流程。对于准备数学建模竞赛或希望将时间序列、机器学习方法落地到商业场景的读者这份资源提供了可直接运行和二次修改的参考模板。1. 2023年国赛C题到底在考什么不是预测而是“预测完怎么决策”如果你是第一次接触2023年全国大学生数学建模竞赛C题可能会被“价格预测、补货预测”这几个字带偏以为核心任务是把明天的西红柿价格算准。实际上这道题真正的难点在最后一步拿到预测价格和预测销量之后如何给出每天的补货量让商超既不缺货又不至于剩货损耗到亏本。Python在这个场景里承担的是数据清洗、时序预测和优化建模三件事串起来才是一条完整可交付的解题链路。我之所以说“不是预测而是决策”是因为附件给的数据里有一个很隐蔽的矛盾零售价和批发价都在波动但销量和价格之间并不存在教科书里那种光滑的需求曲线。蔬菜是高频、短保、强季节性的商品顾客对涨价的反应是跳变的今天涨五毛可能销量直接掉两成明天天气降温又突然卖空。这就决定了纯预测模型撑不起整题的答案你必须把预测结果塞进一个带损耗约束、陈列约束和收益目标的补货优化框架里。这道题适合的人群也很明确会Python基础操作、能读懂Excel附件、愿意花时间调参而不是只跑一遍默认模型的人。下面我按自己完整跑通的路径从数据处理讲到回溯验证全部是可以直接抄作业的代码和参数。2. 把四张Excel表变成训练可用的数据集日粒度聚合与特征构造是第一个分水岭2.1 先搞清附件里每张表是干什么的C题通常给四类数据单品销售流水含销售日期、品类、单品编码、销量、售价、批发价格、损耗率、以及商超的经营策略说明。销售流水是明细级的一个单品一天可能产生几百行记录因为同一个编码在不同时段、不同促销力度下售价不一样直接拿明细跑时序模型会把模型逼疯。常见做法是先做“日粒度聚合”把每一天、每一个单品编码的销售量和销售额汇总成一行再把批发价按日期对齐进去。损耗率那一栏是决策阶段的输入不是预测阶段的特征很多人一上来就把它并进训练集结果模型学到了损耗率与销量的虚假相关性。日粒度聚合看起来是简单的groupby但真正的坑在于“日期对齐”。批发价表的更新频率通常低于销售流水可能三天才更新一次报价如果直接按日期merge会出现大量NaN。我一般会用前向填充ffill再把缺失值封顶处理比如超过七天不更新的批发价视为异常拉到一个滚动均值上。还有一个容易翻车的地方是“单品编码”在不同年份可能重复合并时要用品类单品编码日期三个键而不是单品编码一个键。import pandas as pd import numpy as np df pd.read_excel(销售流水.xlsx, sheet_nameSheet1) df[销售日期] pd.to_datetime(df[销售日期]) # 日粒度聚合同一单品同一天可能有多个售价销量取sum售价取销量加权 daily df.groupby([销售日期, 分类名称, 单品编码]).agg( 销量(销量, sum), 销售额(销售额, sum), 销量加权售价(销售额, sum) / (销量, sum) # 这行会报错见下方修正 ).reset_index() # 修正先算总和再算加权售价 g df.groupby([销售日期, 分类名称, 单品编码]).agg( 销量(销量, sum), 销售额(销售额, sum) ).reset_index() g[加权售价] g[销售额] / g[销量] # 按日期排序生成时间索引 g g.sort_values([单品编码, 销售日期]).reset_index(dropTrue)这段代码里第一个groupby写法是错的我用一种报错的方式展示是为了说明“加权售价不能直接在agg里相除”这个常见误用。销售额加总、销量加总之后单价等于总额除以总量这个逻辑必须放在agg之后单独算。第二个注意点是sort_values的键顺序先单品编码后日期这样后面做shift滞后特征时每个单品的时间线是连续的。参数上pd.read_excel默认使用openpyxl引擎文件大时建议加上engineopenpyxl显式指定避免Excel版本不同引发兼容问题。2.2 特征列怎么设计才能让模型学到“菜的脾气”时间序列预测的数据集有个特点不能用真实未来值做特征。所以你要构造的只能是“截至当天已知”的信息包括历史销量滞后项、滚动均值、星期几、节假日标记、气温与季节相位。蔬菜数据的周期性非常强周末效应比工作日高出三到四成是常态但这道题里没有直接给天气数据所以周几和节假日是最重要的两个周期锚点。# 以单品为分组构造滞后特征与滚动统计 g[lag1] g.groupby(单品编码)[销量].shift(1) g[lag7] g.groupby(单品编码)[销量].shift(7) g[roll_mean7] g.groupby(单品编码)[销量].transform( lambda x: x.rolling(7, min_periods3).mean() ) g[roll_std7] g.groupby(单品编码)[销量].transform( lambda x: x.rolling(7, min_periods3).std() ) g[weekday] g[销售日期].dt.weekday g[is_weekend] (g[weekday] 5).astype(int) # 把批发价按日期对齐ffill填补非更新日 price pd.read_excel(批发价格.xlsx) price[日期] pd.to_datetime(price[日期]) g g.merge(price, left_on[销售日期, 单品编码], right_on[日期, 单品编码], howleft) g g.sort_values([单品编码, 销售日期]).groupby(单品编码).apply( lambda df: df.assign(批发价df[批发价].ffill(limit7)) ).reset_index(dropTrue)shift(7)是七天前的销量用来捕捉“上周今天卖了多少”rolling(7, min_periods3)是近七天均值窗口期设成7是为了压住星期效应min_periods设为3是因为数据开头几行可能没有足够的先验记录宁可少算几行也不要全变成NaN。ffill(limit7)是前向填充的上限保护超过七天的缺失批发价会保留NaN后续用中位数填充避免一个过期的报价在模型中充当真实值。3. 价格预测与销量预测模型选型为什么我放弃LSTM改投LightGBM和Prophet3.1 三个预测目标要分开建模不要揉进同一个黑匣子标题里“价格预测、补货预测”听起来是两个目标但实际拆开是三个零售价预测、批发价预测、销量预测。零售价由商超自己定和销量是联动的批发价是外部输入你不预测它补货优化就没法算毛利。绝大多数队伍的失败点在于把零售价当成外生变量直接喂给销量模型忽略了促销、调价行为本身是对前一天库存和损耗的反应。我的建议是三步走第一步用时间序列模型预测批发价这是一个相对稳定的价格序列受季节和极端天气影响第二步把批发价作为特征传给销量模型同时给零售价做一个“定价弹性”校正第三步销量模型输出预测值后不再回头调价格而是把两组预测结果并列送进补货优化。这样做的好处是每一步都可以单独验证误差出问题时你能精准定位是价格预测崩了还是销量预测崩了。3.2 LightGBM的训练配置参数为什么这样设LSTM在这类数据上的表现远不如树模型原因很朴素数据量太小。一个单品一年也就三百多个日粒度样本LSTM要学的时序依赖参数动辄上万在几百个样本上训练几乎必然过拟合。LightGBM对特征单调性和缺失值更宽容而且训练速度快改一轮特征只要几十秒适合比赛场景里高频试错。Prophet则更适合做趋势和季节性的基线对照方便你确认LightGBM没有在偷学时序外信息。import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit # 只保留截至当天的特征标签是“次日销量” feature_cols [lag1, lag7, roll_mean7, roll_std7, 批发价, 加权售价, weekday, is_weekend] X g[feature_cols].copy() y g[销量].shift(-1) # 预测明天的销量 # 去掉最后一行因为它的标签不存在 mask y.notna() X X[mask] y y[mask] tscv TimeSeriesSplit(n_splits5) for train_idx, valid_idx in tscv.split(X): X_train, X_valid X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid y.iloc[train_idx], y.iloc[valid_idx] model lgb.LGBMRegressor( n_estimators300, learning_rate0.05, num_leaves20, max_depth5, min_child_samples30, subsample0.8, colsample_bytree0.8, random_state42 ) model.fit( X_train, y_train, eval_set[(X_valid, y_valid)], eval_metricmae, callbacks[lgb.early_stopping(50), lgb.log_evaluation(0)] ) break # 这里只跑第一个fold做示例实际要记录全部fold的MAE再平均时间序列交叉验证和普通KFold不一样训练集永远在验证集之前TimeSeriesSplit就是干这个的。num_leaves20比默认的31更保守因为蔬菜销量波动大、噪声强叶子太多容易记住个别节假日的大销量。learning_rate0.05配合n_estimators300再靠early_stopping(50)截断实际训练出来大概一百五十棵树就收敛了。min_child_samples30是防过拟合的护栏确保每个叶子至少有三十个样本才允许分裂。subsample0.8和colsample_bytree0.8是每轮的样本与特征采样比例让模型有随机性但不过度。3.3 Prophet作为交叉验证的对照组只跑一个模型就交付风险太大一个最简单有效的交叉验证方式是拿Prophet跑同一个单品的销量预测如果两个模型在验证集上的MAE相差超过三成说明某个模型学出了伪特征。Prophet不需要你手动做滞后特征它自己拟合趋势加周期项和LightGBM的特征驱动逻辑正好形成对照。from prophet import Prophet # 只取一个单品做对照 one_item g[g[单品编码] 单品A][[销售日期, 销量]].rename( columns{销售日期: ds, 销量: y} ) m Prophet( yearly_seasonality4, weekly_seasonalityTrue, daily_seasonalityFalse, changepoint_prior_scale0.05 ) m.add_regressor(批发价) # 需要把批发价按日期对齐到one_item里 m.fit(one_item_with_price) future m.make_future_dataframe(periods7) forecast m.predict(future)Prophet的yearly_seasonality4意味着用四阶傅里叶级数拟合年度季节项级别越低越平滑对这个粒度只有一年左右的数据来说四阶已经足够表达季节波动又不会过度追踪噪声。changepoint_prior_scale0.05控制趋势突变点的灵活度默认0.05能用但如果你发现预测曲线在疫情或极端天气时间点出现断崖可以降到0.02。要注意add_regressor的变量名必须和传入dataframe的列名一致且prophet版本1.1以上才支持版本不匹配会报FitError。4. 损耗率怎么用不是简单打折而是把“损耗”变成补货优化里的一个硬约束4.1 损耗率分布才是利润的隐藏方向盘商超果蔬的损耗不是平均损耗不同品类的损耗差异极大。叶菜类隔夜损耗可能超过两成根茎类一周下来损耗不到一成。附件里给的损耗率如果只有分类级别的均值你要做的是把它拆到单品级别并观察损耗与陈列期限的关系。损耗率用错单位是最常见的翻车现场附件里写的可能是每月的损耗而你的补货模型是每日决策需要先除以当月天数折算成日均损耗或者按陈列期天数累加。我见过很多队伍直接把损耗率乘到销量预测值上作为补货量等于把损耗当成固定比例扣除这是不对的。损耗不是线性地从总销量上扣而是发生在进货后第三天、第五天越接近保质期末端、损耗越快。正确的做法是在补货决策里设置“陈列期约束”比如叶菜陈列期最长三天当累计剩余库存超过三天销量预测时拒绝追加补货。4.2 决策变量与目标函数把损耗写成软约束和硬约束补货优化的目标不是销量最大而是利润最大利润 销售收入 - 批发成本 - 损耗报废成本。决策变量是每个单品每天的补货量约束包括冷库容量上限、单品最低陈列量、单日补货总预算。损耗率在目标函数里体现为“进货后第t天的残余价值衰减系数”在约束里体现为“超期库存免于陈列”。假设你在为单品i做第d天的补货决策设进货量为q售价为p批发价为c损耗率按天衰减为ε_tt表示进货后的第几天那么单品i在进货后L天内的期望毛利是Profit_i(q) Σ_{t1}^{L} (p * min(D_{i,dt-1}, I_t) - c * q / L) * (1 - ε_t) 其中 I_t 是第t天可售库存D是销量预测值这个式子看着复杂落到代码里其实是一个循环求最大值的搜索过程。常见的简化做法是枚举候选补货量序列从0开始按5公斤步长递增直到库存溢出阈值然后用利润公式打分。因为这个题的SKU数量有限暴力枚举在比赛数据规模下不会太慢。# 对单个单品计算不同补货量下的期望毛利 def calc_expected_profit(sales_forecast: list, price: float, cost: float, loss_rate: list, max_q: float, step: float 5.0): best_q, best_profit 0, -1e9 q 0.0 while q max_q: inventory 0.0 profit 0.0 for t in range(len(sales_forecast)): inventory q / len(sales_forecast) # 简化每天均匀到货 sold min(inventory, sales_forecast[t]) profit sold * price - cost * (q / len(sales_forecast)) profit - (inventory - sold) * loss_rate[t] * cost # 报废成本 inventory - sold if profit best_profit: best_profit, best_q profit, q q step return best_q, best_profit这个版本是教学级简化有几个参数你必须自己调loss_rate列表长度要和陈列期天数一致默认传的是每天的损耗比例max_q别拍脑袋用冷库或堆头容积除以单品平均体积来算否则容易给一个不现实的补货量step设5公斤是平衡计算速度和精度的经验值如果单品日均销量一百公斤以上步长可以放宽到10公斤。5. 避坑与排查四类高频事故的现场还原5.1 时间泄漏验证集MAE很好看一到比赛评审就崩造成这种现象的原因是滞后特征未经严格隔离。比如你用第t天的销量作为标签却把第t天的总销售额也放进了特征列模型中泄露了当天信息导致验证集上误差极低。排查办法是打印特征重要性如果lag1重要性超过七成而你并不觉得“昨天销量对今天销量”有这么大的解释力立刻检查是不是存在当天特征混入。解决思路是在构造特征时统一用截止到t-1天的数据。你可以把特征表按日期排序后对每个特征列做一次.shift(1)强制所有特征“晚一天”进入模型再跑交叉验证看MAE是否大幅上升。若上升幅度超过三成说明之前确实有泄漏。5.2 损耗率单位错位总损耗成本为负有次我看到一个队伍的优化结果所有单品的最佳补货量都是零。排查发现他们把损耗率当成毛利率扣减项直接减导致边际利润算出来是负的。损耗率的正确用法是乘在“未售出库存”上而不是从所有进货量里扣比例。一般的合理区间是叶菜毛利率40%左右时损耗率超过35%才应该停止补货如果损耗率只有百分之几就导致负利润八成是公式用错了。5.3 加权售价算错零售价预测总是偏低第一版运行结果里零售价预测的中位数比真实值低10%左右问题出在明细表的销售额含折扣记录。部分促销订单的“销售额”字段存的是折后金额导致聚合出的加权售价低于日常牌价。解决方法是先按“原价金额”和“折扣金额”拆分列再分别求和最后用原价金额除以非促销销量得到牌价序列。如果Excel里没有折扣字段你就用“销售额 / 销量”之后做分位数截断超过90分位或低于10分位的记录拉回中位数区间。5.4 整数规划求解慢到无法收敛补货量本身是整数公斤但所有公式都用浮点数算最后取整导致总利润出现数万元的偏差。换成整数变量后单纯用Scipy的SLSQP就吃力了。建议用mip包设置mip_start热启动一个基于启发式的初始解再限制求解时间为60秒取gap最小时的可行解。比赛评审看的是决策合理性和验证结果不是求到全局最优gap 0.05即可交付。from mip import Model, xsum, maximize, BINARY, CONTINUOUS m Model(restock) # 决策变量单品i的补货量 q {i: m.add_var(namefq{i}, lb0, ub500, var_typeCONTINUOUS) for i in range(n)} m.objective maximize(xsum(profit[i] * q[i] for i in range(n))) # 预算约束 m xsum(cost[i] * q[i] for i in range(n)) total_budget m.optimize(max_seconds60)mip包默认会用CBC求解器max_seconds60控制求解时间上限实际跑下来大部分实例在十秒内完成剩余时间落在gap收敛上。CONTINUOUS是连续变量类型因为补货量可以按公斤取小数如果你想严格按“份”为单位改成INTEGER即可但求解时间会翻三五倍。6. 用历史数据回溯验证把“预测误差”折算成“决策损失”才是最终分数这里要讲一个比赛后期最关键的验证技巧不要只看预测误差MAPE自己造历史下一天的决策效果看决策利润差了多少。具体做法是把验证集最后十四天切成七个“两日窗口”第一天用模型预测第二天的销量和价格跑补货优化得出补货量第二天用真实销量结算实际利润与“完美预测情况下的理论利润”对比差值除以理论利润就是你的决策损失率。你追求的不是预测多准而是决策损失率低于15%。from sklearn.metrics import mean_absolute_percentage_error # 假设y_true和y_pred是单品在验证集上的真实/预测销量 mape mean_absolute_percentage_error(y_true, y_pred) print(fMAPE: {mape:.2%}) # 决策损失核算 loss_rate 0.0 for i in range(14): sold min(inventory, y_true[i]) expected_sold min(inventory_plan, y_pred[i]) loss (sold - expected_sold) * price - max(0, inventory - sold) * cost * loss_rate[i] loss_rate loss / (y_true[i] * price)这个脚本的输出不是给你交的成果而是给你自己的决策依据。如果某一天的决策损失特别大回头去查前一天的价格预测是否偏离了促销节奏。这比盯着一堆MAPE数字有价值。我的个人习惯是每次调参之后都把上一版模型的预测结果和最新版并排画在一张图上用灰色标出真实值两版预测用不同颜色看差在哪一段。模型迭代十次之后真正的问题不在模型而在特征构造时偷懒。遇到连续高损失的日子不要急着改模型参数先打开那天的数据看有没有异常促销或断货记录。这套流程从头到尾走一遍大概需要三到四个小时但能让你在比赛里少踩一半的坑希望帮到你。本文还有配套的精品资源点击获取
返回列表