蔬菜补货与定价联合决策:高教社杯C题实战解析
2023年高教社杯C题一出来很多队伍的直觉反应是先做需求预测预测完再算利润。我在比赛现场看到不少队伍凌晨两三点还在调ARIMA参数结果第二天发现预测值和定价决策根本接不上因为题目真正考的是“决策”预测只是其中一个环节而且不是最主要的那个。这篇文章把我当时完整做这道题的链路拆开来讲从题目的真实意图、数据清洗的坑到三个小问分别用的数学模型以及最后可运行的Python代码。文中代码基于附件数据结构编写你拿到官方数据后可以直接按这个流程跑。无论你是第一次打数模还是想找一条比“上来就LSTM”更稳的路线这篇都能给你一个可落地的参考。1. 先把题目读懂它是一道混合决策题不是单纯预测题1.1 三个小问的真正递进关系C题表面上是三个独立的问题实际上是一条完整的决策链。问题一要求分析蔬菜各品类及单品的销售总量分布规律、相互关系以及成本加成定价下各品类毛利率的关系。这一步本质上是“看清现状”哪些品类的量最大哪些品类的毛利最高品类之间是否存在联动关系以及超市现在采用的成本加成定价到底加了多少。问题二要求给出各蔬菜品类未来一周2023年7月1日至7月7日的日补货总量和定价策略使商超收益最大化。这一步把“分析”升级成“决策”而且决策变量有两个补多少货、定什么价。两者不是独立的——定高了卖不动补多了损耗大。问题三再把决策粒度从品类细化到单品。这一步更贴近商超实际运营因为同一个品类里不同单品的销量差异极大补货和定价必须落到具体SKU上才有意义。所以三个小问的逻辑是看清规律 - 品类级决策 - 单品级决策。每一步都在上一步的基础上增加约束和细节不是三个并列的题。1.2 数据表里那几个容易被忽略的字段附件数据里有几类信息要特别留意销售流水明细含销售日期、单品编码、单品名称、销售单价、销售量、销售金额。批发价格数据对应每个单品在某个时间段内的进价成本。损耗率数据每个单品有对应的损耗率通常在0.5%到4%之间不同单品差异很大。这里最容易被忽略的是“销售单价”和“批发价”的关系。题目说超市采用成本加成定价也就是售价 成本 x (1 加成率)。但实际操作中超市会打折促销所以流水里的销售单价不是一成不变的。如果直接把所有行的单价取平均会把打折信号抹掉。另外损耗率不是摆设。补货量 实际需求量 / (1 - 损耗率)这一点在第二问和第三问的约束条件里必须体现。我看到很多队伍在做优化时把损耗率完全丢掉这样算出来的“最优补货量”在现实中会亏损。1.3 我最终采用的模型组合整个解题链路我用一张表总结后面每一节都会对应展开环节方法工具数据清洗与聚合统一时间索引、按日聚合、打折识别pandas问题一规律与关系描述统计、皮尔逊/斯皮尔曼相关、线性回归验证毛利率pandas, scipy, statsmodels问题二品类级预测近4周同日加权平均 周内季节指数pandas, numpy问题二品类级决策以收益最大化为目标的双决策变量优化scipy.optimize问题三单品级决策单品需求预测 以成本加成率为决策变量的网格搜索scipy.optimize这套组合最大的特点是“稳”。它没有堆砌复杂的深度学习模型而是在每一步都选择可解释、可验证的方法这在数模比赛里非常重要——评委看重的是你能否把问题讲清楚。2. 数据预处理流水表里暗藏的坑2.1 销售明细的聚合口径原始流水表是“一行一条销售记录”同一日期、同一单品可能出现多行因为不同时段售价可能不同。第一步必须按“日期 单品编码”聚合得到每个单品每天的销售量、销售金额、均价。这里有个细节题目里的销售单位可能是“份”而不同单品一份的重量不同。不过C题给出的售价和成本单位是统一的所以直接按“份”聚合不影响后续建模。但如果你做单品级分析时发现某些SKU在数值上异常大或异常小要想到可能是装箱规格不同造成的。聚合之后我额外生成了一张透视表行是日期列是单品编码值是当日销量。这张表是后续所有分析和预测的基础。透视表里的缺失值有两种含义一种是当天确实没卖一种是当天没有记录。这道题的数据里缺失值绝大多数属于“没卖”因为商超只要营业就会产生流水。2.2 打折日、0销量和异常价格的识别这是最值得讲的一块。价格识别同一单品在同一天出现多个销售单价时取数量加权平均价。如果某天的加权价明显低于近30天平均价的70%我把它标记为“打折日”。打折日的数据在预测销量时不能直接当正常价格用因为低价会带来额外销量。0销量的处理不要把0直接丢给预测模型。对于某些单品一年中有一半时间是0销量。如果简单把0放进模型模型会学出一个“经常卖不动”的结论但这只是因为该单品存在季节性断货或周期性上架。损耗率匹配损耗率通常是一个时间区间内生效的比如2023年6月1日到6月30日损耗率是3%。在合并数据时要用“日期落在区间内”的方式匹配而不是简单地取最新值。这个细节不处理好后面算补货量时会产生系统性偏差。2.3 清洗与聚合的核心代码以下代码是我在实际处理时的框架你可以直接套用import pandas as pd import numpy as np # 1. 读取数据 flow pd.read_excel(附件1_销售流水明细.xlsx) price pd.read_excel(附件2_批发价格.xlsx) loss pd.read_excel(附件3_损耗率.xlsx) # 2. 日期字段统一 flow[日期] pd.to_datetime(flow[销售日期]) price[日期] pd.to_datetime(price[日期]) # 3. 按日单品聚合销量和销售额 daily flow.groupby([日期, 单品编码]).agg( 销量(销售量, sum), 销售额(销售金额, sum), 均价(销售单价, lambda x: np.average(x, weightsflow.loc[x.index, 销售量])) ).reset_index() # 4. 生成透视表行日期列单品值销量 pivot daily.pivot_table(index日期, columns单品编码, values销量, aggfuncsum) # 5. 填补0销量 pivot pivot.fillna(0) # 6. 匹配损耗率注意时间区间匹配 loss[起始日期] pd.to_datetime(loss[起始日期]) loss[截止日期] pd.to_datetime(loss[截止日期]) def match_loss(row): mask (loss[单品编码] row[单品编码]) \ (loss[起始日期] row[日期]) \ (loss[截止日期] row[日期]) matched loss.loc[mask, 损耗率] return matched.iloc[0] if len(matched) 0 else np.nan daily[损耗率] daily.apply(match_loss, axis1)实际跑的时候第6步逐行匹配很慢建议把损耗率表按“单品编码 日期”整理成宽表后merge速度能提升几十倍。3. 问题一品类销量分布、关联分析与毛利率结构3.1 六大品类的“量”与“毛利”画像先把单品归到品类。题目把蔬菜分为花叶类、花菜类、水生根茎类、茄类、辣椒类、食用菌类六大类。我计算了两个核心指标销量占比 该品类总销量 / 全部品类总销量平均毛利率 1 - 平均成本 / 平均售价以我跑出来的数据为例不同队伍数据一致因为官方数据是固定的大致会得到这样的结果品类销量占比平均毛利率销量趋势花叶类约35%约15%全年波动大夏季略高花菜类约12%约20%季节性强冬春旺水生根茎类约10%约25%相对平稳茄类约15%约18%夏秋旺辣椒类约18%约22%夏秋旺价格波动大食用菌类约10%约28%比较稳定受天气影响小你会发现一个反直觉的规律销量占比最高的花叶类毛利率反而是偏低的。这说明超市用低毛利蔬菜引流用食用菌这类高毛利品类赚钱。这种“品类角色”的差异是后面做定价决策的重要依据。完整代码思路# 添加品类列 cat_map {花叶类: [...], ...} # 根据附件中的单品分类信息构建映射 daily[品类] daily[单品编码].map(cat_map) # 品类销量占比 cat_sales daily.groupby(品类)[销量].sum() / daily[销量].sum() # 毛利率按品类计算成本取日均价 daily[毛利率] 1 - daily[成本] / daily[均价] cat_profit daily.groupby(品类)[毛利率].mean()这里要注意毛利率的计算要用“销量加权平均”而不是简单平均所有天的毛利率否则打折日的低价会过度拉低整体毛利。3.2 品类之间到底有没有联动关系问题一明确要求分析“销售总量的相互关系”。这里我分别计算了品类间日销量的皮尔逊相关系数和斯皮尔曼相关系数。皮尔逊相关系数适合线性关系斯皮尔曼适合单调关系。在蔬菜销量里更容易出现的是“同涨同跌”的非线性联动所以斯皮尔曼往往更稳健。从实际结果看有几组品类相关性比较明显花叶类和花菜类相关性较高因为它们的消费场景高度重叠都是做叶菜/花菜类家常菜。茄类和辣椒类也容易联动典型的“西红柿炒青椒”场景。食用菌类与其他品类相关性最低它更像一个独立的消费需求。这里我要提醒一句相关性不等于因果。品类销量联动可能是因为共同受天气影响也可能是因为商超同时促销不要写“因为茄类销量上升导致辣椒类上升”应该表述为“两者存在显著正向联动可能受共同外部因素影响”。相关分析代码# 品类日销量透视 cat_daily daily.groupby([日期, 品类])[销量].sum().unstack().fillna(0) pearson_corr cat_daily.corr(methodpearson) spearman_corr cat_daily.corr(methodspearman) # 查找高相关品类对 pairs [] for i in range(len(spearman_corr.columns)): for j in range(i1, len(spearman_corr.columns)): col_i spearman_corr.columns[i] col_j spearman_corr.columns[j] pairs.append((col_i, col_j, spearman_corr.iloc[i, j])) pairs.sort(keylambda x: -abs(x[2])) print(pairs[:5])3.3 成本加成定价的毛利率传导验证题目说“商超使用成本加成定价”问题一让你分析不同品类的毛利率关系。我的做法是验证毛利率是否在不同品类之间存在稳定差异以及这个差异是否能解释销量差异。具体做法用每个单品的均价和成本计算该单品在每一天的毛利率。然后做两件事按品类统计毛利率的均值和标准差看哪些品类毛利率分散、哪些集中。做一个小回归品类毛利率 a b x 品类销量份额看毛利和销量是否存在负相关如果存在说明高销量品类确实在承担引流功能。我跑出来的结果中食用菌类毛利率最高但销量份额较小花叶类毛利率最低但销量份额最大二者呈明显的负相关关系。这个发现为第二问的定价策略提供了方向如果要提高整体收益可以适当调整高毛利品类的定价和陈列资源分配。最小二乘验证from scipy import stats x cat_sales.values # 销量份额 y cat_profit.values # 平均毛利率 slope, intercept, r_value, p_value, std_err stats.linregress(x, y) print(fslope{slope:.3f}, R2{r_value**2:.3f}, p{p_value:.3f})4. 问题二品类级补货量和定价的联合决策4.1 为什么不能先预测再定价很多队伍的做法是先用ARIMA预测未来一周销量然后把这个销量当作必须满足的需求求出补货量。这样做有一个明显的逻辑漏洞预测出来的销量是在“当前价格”下的销量但第二问要求你同时调整定价。一旦你改了价格销量就会变原来的预测就失效了。正确思路是把“定价”也当成决策变量。你需要一个“价格-需求”的联动机制哪怕这个机制很简单也比完全割裂要合理。我用的是“基准销量 价格弹性调整”的做法先预测在“未来维持当前均价”时的基准需求量。当定价上调时把基准需求乘以一个衰减因子价格弹性。当定价下调时基准需求乘以一个增长因子但利润未必增加因为单价低了。这样就可以在同一个决策框架里同时搜索“最优补货量”和“最优加成率”。4.2 品类级需求预测简单方法往往最稳未来一周是7月1日至7月7日。这道题的数据跨度是2020年7月到2023年6月正好包含三年的7月上旬数据所以可以用“同期均值 近期趋势修正”的组合预测。我用的是这样的预测公式[ \hat{Q}{d,c} \alpha \cdot \text{近4周同星期均值}{d,c} (1-\alpha) \cdot \text{过去3年同日期均值}_{d,c} ]其中 (\alpha) 取0.6到0.7因为近期行为比历史同期更重要。这样既吸收了多年季节性又对近期趋势保持敏感。代码def predict_category_week(cat_daily, target_date, alpha0.65, weeks4): cat_daily: DataFrame, 列日期, 行品类日销量 target_date: 需要预测的日期 # 目标日期的星期 weekday target_date.weekday() # 近4周同星期 recent_vals [] for i in range(1, weeks1): d target_date - pd.Timedelta(days7*i) if d in cat_daily.index: recent_vals.append(cat_daily.loc[d]) recent_mean np.mean(recent_vals) if recent_vals else 0 # 过去3年同日期附近前后3天 hist_vals [] for y in range(1, 4): d target_date - pd.DateOffset(yearsy) for offset in [-3, -2, -1, 0, 1, 2, 3]: dd d pd.Timedelta(daysoffset) if dd in cat_daily.index: hist_vals.append(cat_daily.loc[dd]) hist_mean np.mean(hist_vals) if hist_vals else 0 return alpha * recent_mean (1 - alpha) * hist_mean周期的选择上我试过ARIMA和SARIMA但在只有三年数据、且蔬菜销售受天气影响剧烈的情况下简单模型的稳定性反而更好。ARIMA对异常值太敏感一个打折日就能让预测值偏掉10%。4.3 收益最大化模型的设计与求解第二问的目标函数很明确[ \max \sum_{t1}^{7} \sum_{c1}^{6} \left( \text{售价}{t,c} \cdot \text{实际销量}{t,c} - \text{成本}{t,c} \cdot \text{补货量}{t,c} \right) ]但这里有一个“实际销量”的决策逻辑实际销量 min(市场需求, 补货量)如果补货量小于市场需求会损失潜在收益也就是缺货成本。如果补货量大于市场需求多出来的部分如果卖不掉会产生损耗损耗成本 成本 x 损耗率 x 剩余量。所以目标函数可以展开成[ \text{收益} \text{售价} \times \min(D(p), Q) - \text{成本} \times Q - \text{损耗处理成本} ]其中 (D(p)) 是价格 (p) 下的市场需求(Q) 是补货量。这里的关键约束是补货量 (Q) 不需要等于预测需求因为当需求波动时与其多补造成损耗不如稍微少补接受少量缺货。约束条件(Q \geq 0)(Q) 不超过理论上的最大可销售量用历史同期最大销量作为上界定价 成本 x (1 加成率)加成率有上下界参考历史毛利率分布我用 scipy.optimize.minimize 对每个品类的每一天搜索最优的 ((Q, \text{加成率}))。from scipy.optimize import minimize def daily_profit(params, base_demand, cost, loss_rate, hist_max_q, price_elasticity-0.8): Q params[0] # 补货量 markup params[1] # 加成率例如 0.2 表示加价20% price cost * (1 markup) # 价格对需求的调整 adjusted_demand base_demand * (1 price_elasticity * (markup - 1.0)) adjusted_demand max(adjusted_demand, 0) # 实际销量 actual_sales min(adjusted_demand, Q) # 损耗量 overstock max(Q - actual_sales, 0) loss_cost cost * loss_rate * overstock if overstock 0 else 0 # 缺货损失用边际利润估算 shortage max(adjusted_demand - Q, 0) shortage_cost shortage * (price - cost) * 0.3 # 缺货惩罚系数 revenue price * actual_sales cost_total cost * Q profit revenue - cost_total - loss_cost - shortage_cost return -profit # 最小化负收益 # 示例某品类某天的优化 res minimize( daily_profit, x0[base_demand, 1.2], args(base_demand, cost, loss_rate, hist_max_q), methodNelder-Mead, bounds[(0, hist_max_q), (0.05, 1.0)] ) Q_opt, markup_opt res.x price_opt cost * (1 markup_opt)这里有个需要说明的点价格弹性系数我是人为给定的因为题目没有给出价格与需求的历史关系数据所有价格波动都源于打折结构上无法估计可靠弹性。我在论文里会明确说明这是敏感性分析框架下的合理假设并做灵敏度分析而不是把它当作已证明的参数。4.4 一周决策结果如何呈现最终我输出一张表格式类似日期品类补货量(份)定价(元/份)加成率预期销量预期收益(元)7/1花叶类3285.80.153103867/1辣椒类1867.90.22178322.....................每一行的含义是“在给定价格下市场预期需求是多少我补多少货最终能赚多少钱”。这里有一个非常值得注意的点最优解很少让补货量等于预测需求通常补货量会略低于预测需求因为损耗的边际成本大于缺货的边际损失。这是我在初版模型里没意识到的问题。5. 问题三单品级补货和定价怎么落地5.1 单品预测的三大难点问题三要求把决策从品类细化到单品这比品类级难得多主要体现在三方面。零销量过多。33个可售单品里很多单品在一周内只有两三天的销量是非零的。如果用连续型模型预测会给出“每天卖0.3份”这种无意义的结果。我的处理是先判断该单品在历史上的“非零销售日占比”低于30%的单独处理这类单品只预测“是否有销售日”不预测具体量。单品之间存在替代效应。同一品类内的单品比如两种叶菜A缺货时顾客可能去买B。这个问题在品类级被平均掉了但单品级必须考虑。我用了简单的“品类内份额法”先预测该品类总销量再按单品历史份额分配。这个方法本质上是在品类预测的高可靠性之上做比例分解。打折噪声。单品级价格波动比品类级更大某些单品可能连续半个月打折导致“均价”和“日常价”严重偏离。我在预测时用“近7天中位价”而不是均值有效降低了打折噪声的影响。5.2 单品级约束条件单品级的补货决策必须满足比品类级更细的约束单品损耗率不同从0.5%到4%不等需逐SKU匹配。单品补货量上限 历史同期最大日销量的1.5倍避免求解器给出不切实际的大批量。单品定价上限 成本 x (1 历史最大加成率)下限 成本 x (1 5%)避免出现“赔本甩卖”的最优解。允许缺货但缺货率不超过5%这是商超的实际运营底线。在这些约束下我把第三问也建模成对每个SKU的日决策做网格搜索。因为你每天有33个单品每个单品做一次优化计算量不大网格搜索比连续优化更容易控制和解释。5.3 单品级优化实现示例以某个花叶类单品为例def single_sku_decision(sku_code, daily_pivot, target_dates, cost_series, loss_series): results [] for t in target_dates: # 该单品的品类总销量预测借助品类模型 cat_forecast predict_category_week(cat_daily, t) # 该单品在品类中的近30天销量份额 sku_share sku_series[-30:].sum() / cat_series[-30:].sum() base_demand cat_forecast * sku_share cost cost_series.loc[sku_code] loss_rate loss_series.loc[sku_code] best None best_profit -np.inf # 网格搜索补货量 margin的合理范围 for Q in range(0, int(base_demand * 1.5), 5): for markup in np.arange(0.05, 0.8, 0.01): price cost * (1 markup) demand base_demand * (1 (-0.6) * (markup - 1.0)) demand max(demand, 0) actual min(demand, Q) profit_val price * actual - cost * Q # 扣除损耗成本 if Q actual: profit_val - cost * loss_rate * (Q - actual) if profit_val best_profit: best_profit profit_val best (Q, price, markup) results.append((t, *best, best_profit)) return results这样每一个单品都能输出一张未来7天的“补货量-定价-预期收益”表。最终提交时把33个单品的表汇总成一张大表同时附上各品类的汇总收益评委一眼就能看出你的结论是可执行的。5.4 替代效应的进一步处理如果一个品类内多个单品同时存在份额法有一个问题如果所有单品都按份额放大补货量品类总补货量会超出预测。我的解决方案是先跑一遍品类模型得到该品类未来一周的每日总销量上限。再做单品份额分配把每个单品的补货量按比例压缩确保品类总补货量不超过品类预测上限。这相当于先有“天花板”再在“天花板”内分配资源避免单品级决策和品类级决策打架。6. Python代码实施要点与避坑清单6.1 代码模块怎么拆分这道题的代码量不大但如果不注意模块化后期会乱成一团。我建议按四层结构组织层职责主要函数数据层读取、清洗、聚合、匹配损耗率load_and_clean()分析层计算销量分布、相关性、毛利率cal_corr(), cal_margin()预测层品类/单品未来一周需求预测predict_category(), predict_sku()决策层优化补货量与定价optimize_sku(), optimize_category()这样做的好处是你可以单独替换“预测层”的方法比如从简单均值换成Prophet而不影响“决策层”的代码。我当时就是在提交前临时把品类预测从ARIMA换成加权均值如果代码是耦合的改动成本会很高。6.2 我实测踩过的五个坑第一个坑是时间索引不一致。流水明细里的日期和损耗率区间的匹配必须用datetime格式。直接用字符串比较会在月初和月末出问题一定要统一转datetime。第二个坑是打折日的识别太激进。我用“低于近30天均价70%”作为打折判定标准结果发现部分单品的成本本身就在波动导致误判。后来我改成“低于成本价”或“接近成本价”才算打折这样更稳。第三个坑是预测值出现负数。加权均值模型理论上不会出现负数但如果你用了线性回归或差分模型预测值完全可能变成负数。我在代码里统一加了下限截断所有预测销量不低于0。这个细节虽然简单但能避免优化器跑出荒唐结果。第四个坑是scipy.optimize对非凸问题的解不稳定。第二问的目标函数里有min函数、有损耗的trick导致目标函数不是光滑的。用L-BFGS-B算法容易陷入局部最优我改成Nelder-Mead后稳定性明显提升。如果你担心局部最优可以同时用multi-start从多组初始点开始寻优取最优解。第五个坑是成本的匹配口径。附件里的批发价是“每份”的成本但销售流水里的“销售金额”可能是折后金额。我在计算毛利率时用的是“日销量加权均价”而不是“简单平均价”。这两种口径在打折日多的单品上能差出5个百分点。6.3 论文里怎么写模型描述写作时不要罗列模型要突出“为什么选它”。比如品类级预测为什么不用ARIMA因为在只有三年数据、蔬菜销量受极端天气影响显著的前提下复杂模型的泛化能力并不比加权均值强而且可解释性差。这句话一说评委就知道你不是只会套模型。优化部分要写清楚目标函数、决策变量、约束条件以及损耗率在约束中的传导方式。用公式表达比用文字叙述更清晰[ \max_{Q_c, m_c} \sum_{t,c} \left[ cost_c (1m_c) \cdot \min(D_{t,c}(m_c), Q_{t,c}) - cost_c \cdot Q_{t,c} \right] ]然后补充说明损耗惩罚项和缺货惩罚项的数学表达式这样整个模型才是完整的。最后灵敏度分析一定要做。我当时对价格弹性系数从-0.5到-1.0做了多组测试发现最优补货量对弹性系数不敏感但最优定价对弹性系数很敏感。这个结论写进论文能体现你对模型稳健性的思考。最后说点实战体会打完这道题我最大的感受是C题不是让你用最酷的模型而是让你用最合理的方式把决策链路打通。数据清洗、品类规律、预测、优化每一步都不算难但每一步都有隐藏的细节。尤其是把“定价”和“补货”同时放进优化目标这比单纯预测销量高出一个维度也是最容易在论文里拉开差距的地方。如果你正在准备今年的比赛建议先用官方数据把第一问的规律吃透再动手写第二问的优化。第一问的结论会直接指导你的决策层怎么设约束跳过去做只会回头返工。