简介:一份面向2023年高教社全国大学生数学建模竞赛C题的完整参考论文,重点围绕蔬菜类商品自动定价与补货决策问题展开,适合参赛团队用于赛题复盘、模型对比与论文框架参考,也可作为运筹优化方向毕业设计的写作模板。论文系统覆盖四个子问题:基于历史销售数据的品类与单品分布规律挖掘、成本加成定价下的销售量预测、单品补货量与定价策略的组合优化,以及后续数据采集建议;建模过程中使用了线性回归、相关性分析、时间序列ARIMA、混合整数规划和贪心算法等方法,并提供了问题重述、模型假设、符号说明和求解过程,能够帮助读者理解从数据处理到策略生成的全流程。包内为单个PDF文件,共1.62MB,内容结构完整,关键词清晰,可直接对照阅读。目前已有170人学习下载,是优化类数学建模赛题的重要参考素材。
1. 2023高教社数学建模国赛C题,本质是在给超市做一套数据驱动的蔬菜定价与补货策略
2023年高教社杯全国大学生数学建模竞赛C题“蔬菜类商品的自动定价与补货决策”,表面上是一道竞赛题,实际是零售行业中一个非常典型的运营决策问题:给定历史销售流水、批发价格和损耗率,如何为每个蔬菜单品找到“明天定什么价、进多少货”的最优解。多数参赛队伍把精力放在回归预测上,预测完销量就停下来,但题目真正要的是“决策”而不是“预测”。蔬菜的保质期短、品类多、单价波动快,隔天卖不掉就要打折出清,这就把问题从单一预测问题变成了一个带约束的联合优化问题:价格影响需求,需求又反过来决定最优补货量和损耗期望。这道题适合正在做零售数据分析、供应链算法或者刚接触运筹优化的从业者,它能完整覆盖从数据清洗、需求建模到非线性规划求解的完整链路,而且数据集规模适中,单机就能跑。
2. 2023C题数据长什么样,以及题目背后的固定约束
2.1 六个蔬菜大类、单品编码和销售流水的事件结构
国赛C题给出的数据一般包含销售流水表、批发价格表,以及可能存在的损耗率或商品信息表。销售流水是最核心的输入,每一行代表某天某个蔬菜单品在某条渠道的一次销售记录,字段通常包含销售日期、单品编码、单品名称、销量(千克)、销售单价(元/千克)和是否打折标识。批发价格表则按日期和单品编码给出当天的进价,需要注意的是很多行在周末或者节日前是缺值的,因为批发市场的报价周期和超市销售周期并不完全对齐。
背后的业务是典型的“日清日用”模式:蔬菜类商品当天进货、当天定价,闭店前如果还有库存,要么转入第二天继续卖,要么打折出清。损耗率不是统一值,叶菜类和菌菇类差异极大,常见取值范围在5%到30%。竞赛题不要求选手去仿真门店排队,但它要求你承认一个关键事实:补货量一旦超过真实需求,剩余部分就要承受损耗成本,这个成本和利润直接挂钩,所以在目标函数里必须把损耗写成期望损失项。数据字典参考如下,实际比赛文件字段名称略有不同,但结构基本一致。
| 数据域 | 字段名 | 类型 | 业务含义 |
|---|---|---|---|
| 销售流水 | sale_date | datetime | 销售日期,粒度到天 |
| 销售流水 | product_code | string | 单品编码,对应SKU |
| 销售流水 | product_name | string | 单品名称,如“云南生菜” |
| 销售流水 | sale_qty | float | 销量,单位千克 |
| 销售流水 | sale_price | float | 实际成交均价,元/千克 |
| 销售流水 | is_discount | int | 是否折扣日销售,0或1 |
| 批发行情 | wholesale_price | float | 当日批发价,元/千克 |
| 商品属性 | category | string | 大类,如“花叶类”“茄果类” |
| 商品属性 | loss_rate | float | 该品类的平均损耗率 |
2.2 用Pandas拉平数据:按天生成单品维度的事实表
拿到原始文件后第一步不是建模,而是把“流水”压缩成“事实表”。销售流水是一天多行,同一个单品同一天可能以原价和折扣价分别成交,要合并成一行。合并时有两个细节容易踩坑:折扣日要把打折量和原价量分开标注,否则定价模型会把折扣日销量当成正常价格下的需求;批发价表要按日期做向前填充,因为批发市场周末可能不开市,但超市还在营业。
以下代码把原始流水按单品和日期聚合,并关联批发价和损耗率。需要注意日期字段要统一成pd.Timestamp,编码要统一成字符串,否则两个表关联时静默产生笛卡尔积。
import pandas as pd import numpy as np df_sales = pd.read_csv("sales_flow.csv", parse_dates=["sale_date"]) df_wholesale = pd.read_csv("wholesale_price.csv", parse_dates=["date"]) df_product = pd.read_csv("product_info.csv") # 单品编码统一为字符串,避免整数和字符串匹配失败 df_sales["product_code"] = df_sales["product_code"].astype(str) df_wholesale["product_code"] = df_wholesale["product_code"].astype(str) df_product["product_code"] = df_product["product_code"].astype(str) # 按 日期+单品 聚合销量,同时保留折扣销量 df_daily = ( df_sales.groupby(["sale_date", "product_code"]) .agg( sale_qty=("sale_qty", "sum"), discount_qty=("sale_qty", lambda x: sum(x[df_sales.loc[x.index, "is_discount"] == 1])), avg_price=("sale_price", "mean"), ) .reset_index() ) # 批发价按日期向前填充,填充前先按单品分组也是安全的 df_daily = df_daily.merge( df_wholesale.sort_values("date"), left_on=["sale_date", "product_code"], right_on=["date", "product_code"], how="left", ) df_daily["wholesale_price"] = df_daily.groupby("product_code")["wholesale_price"].ffill() # 关联损耗率 df_daily = df_daily.merge(df_product[["product_code", "category", "loss_rate"]], on="product_code", how="left")这段代码有两个逻辑要点。discount_qty用lambda在groupby内部做条件求和,前提是x.index仍然指向原始DataFrame的行号,这样df_sales.loc[x.index, "is_discount"]拿到的判断结果和参与求和的数值行能一一对应,比先打标签再聚合更省内存。批发价ffill()是按单品分组的,直接按全表填充会把上个单品的价格带到下个单品上,所以必须先groupby("product_code")再填充。聚合完成后应检查wholesale_price的缺失率,如果某单品缺失超过30%,说明这个品类的批发价记录本身不完整,建模时最好整段剔除。
2.3 这道题的三个隐藏约束:打折出清、保鲜周期和陈列上限
题目原文里没有直说,但数据里隐含了一条决策规则:当天没有卖完的蔬菜,第二天要么原价续卖,要么打折出清。竞赛评分点一般落在“单品定价是否合理”和“补货量是否贴近实际需求”上,所以不能只算一个静态毛利率,要建立动态策略。实际定价时,超市蔬菜课长不会给每个单品独立定价,而是按品类统一加价率,但建模时如果按品类统一,就丢掉了单品之间损耗率的差异对最优价格的修正作用。
另一个约束是陈列上限和单次下单上限,题目可能没有给出明确数字,但作为从业者方案,应该加入一个库存容量C_i,补货量不能超过这个容量。加上这个约束后,问题从“无约束利润最大化”变成“带容量约束的定价库存联合决策”,求解难度提升一个档次,但这也正是竞赛阅卷时分辨普通论文和完整方案的分界线。保鲜周期也类似,叶菜类按1天计算,根茎类按3天计算。处理方式是给每个单品设置最大可售天数,超出后剩余库存损耗率为100%,这个参数会在仿真回测里直接决定库存策略的优劣。
3. 蔬菜需求函数与价格弹性:从历史销量到可计算的决策依据
3.1 为什么不能直接用历史平均销量做补货
很多队伍的基准模型是:取过去7天销量均值,乘以一个安全系数1.2,作为明天的补货量。这个逻辑在常规快消品上没问题,但蔬菜不行。因为蔬菜的销售价格每天波动,价格变动直接改变当日需求量:菠菜卖4元可能卖出200斤,卖6元可能连100斤都卖不掉。如果你用含高价日的平均销量预测明天的补货量,而明天恰好降价,就会缺货;反过来则会造成积压。所以要先把“价格”这个变量显式放进需求模型里,建立价格-销量响应函数,而不是简单做时间序列外推。
这里采用对数线性需求函数:ln(q) = alpha + beta * ln(p) + gamma * D_t + epsilon。其中beta就是价格弹性,是一个负数,表示价格上涨1%时销量变化的百分比;D_t是日期特征,包括星期几、是否节假日、是否折扣日。对数线性形式在零售定价文献里是标配,一方面参数有明确经济学解释,另一方面做利润优化时可以直接求解析导数或做网格搜索,不需要复杂的非线性回归工具。
3.2 用Statsmodels拟合单个SKU的价格弹性,并处理周末效应
对于每个单品,单独拟合需求函数。样本量太少的单品(销售天数少于20天)建议剔除,因为回归系数不稳定。下面代码用了statsmodels的OLS,同时加入周末哑变量,避免把周末的自然需求上升归因到价格上。
import statsmodels.api as sm def fit_demand_sku(df_sku): df = df_sku.copy() df = df[df["avg_price"] > 0] df["ln_qty"] = np.log(df["sale_qty"]) df["ln_price"] = np.log(df["avg_price"]) df["is_weekend"] = df["sale_date"].dt.weekday.isin([5, 6]).astype(int) X = df[["ln_price", "is_weekend"]].copy() X = sm.add_constant(X) y = df["ln_qty"] model = sm.OLS(y, X).fit() return { "alpha": model.params["const"], "beta": model.params["ln_price"], "gamma": model.params["is_weekend"], "p_value": model.pvalues["ln_price"], "n": len(df), "aic": model.aic, } # 按单品编码分组拟合 results = {} for code, df_sku in df_daily.groupby("product_code"): try: results[code] = fit_demand_sku(df_sku) except Exception: continue df_elasticity = pd.DataFrame(results).T df_elasticity["beta"].hist(bins=30)beta落在-2.5到-0.3之间是正常区间,如果出现正值或者绝对值大于5,大概率是样本太短或价格数据有问题,比如某单品长期不打折、价格几乎没有波动,回归自然无法识别弹性。此时对该单品应放弃回归参数,改用品类平均弹性代替。p_value大于0.05也不能说明弹性不存在,只能说明这个单品的价格变异不足,不足以支持独立估计。另外要注意,周末效应必须显式建模,蔬菜的周末销量通常比工作日高出30%以上,忽略它会导致价格系数被高估。
3.3 内生性处理:价格不是外生的,打折日要特殊对待
直接回归会遭遇一个内生性问题:超市在库存压力大的时候会更积极地打折促销,所以价格低的同时销量高,并不完全是价格敏感的体现,还有促销事件本身的驱动作用。换言之,价格和误差项相关,OLS估计的弹性偏大。竞赛里多数队伍不处理这个问题,但评分高的论文一般会提到。
常见做法有两种:第一种是只使用非折扣日的样本来拟合需求函数,把打折日样本用于校验;第二种是引入工具变量,比如用同品类其他单品的平均批发价作为该单品价格的IV,因为批发价影响单品定价但不直接影响该单品的日需求。用statsmodels的IV2SLS实现第二种处理,关键是找到合法的工具变量。
from statsmodels.sandbox.regression.gmm import IV2SLS df_all = df_daily.copy() df_all["ln_qty"] = np.log(df_all["sale_qty"]) df_all["ln_price"] = np.log(df_all["avg_price"]) # 工具变量:该单品所属品类当日的平均批发价 category_wholesale = ( df_all.groupby(["sale_date", "category"])["wholesale_price"] .transform("mean") ) df_all["iv_category_ws"] = np.log(category_wholesale) # 第一阶段:价格对工具变量回归 X_stage1 = sm.add_constant(df_all[["iv_category_ws"]]) stage1 = sm.OLS(df_all["ln_price"], X_stage1).fit() df_all["price_hat"] = stage1.fittedvalues # 第二阶段:用拟合价格替代原价格 X_stage2 = sm.add_constant(df_all[["price_hat", "is_weekend"]]) stage2 = sm.OLS(df_all["ln_qty"], X_stage2).fit()第二阶段里price_hat的系数才更接近真实的价格弹性。为什么品类的平均批发价是合理的IV?因为批发价格是外部市场决定的,不受单个门店某天卖不卖得动的影响,单个门店的日销量也无法撼动批发市场报价。而超市定价通常参考进价加成,批发价越高售价越高,满足“工具变量与内生解释变量相关”的前提。实操中如果品类内单品数量少于5个,IV容易变成弱工具变量,此时回归结果反而比OLS更糟,稳妥做法是直接删除打折日样本做敏感性对照。
4. 定价与补货联合决策:构造利润函数,寻找最优价格和最优订货量
4.1 目标函数:毛利减损耗,不只看单日还要看剩余库存的期望损失
蔬菜定价决策不能用“销售毛利=销量*(售价-进价)”这么简单的公式,因为日末剩余库存不是无成本的。剩余库存要么次日继续卖,产生资金占用和品质下降,要么直接报废。更合理的做法是把问题拆成两步:决策变量是明天的定价p和补货量Q,在进货前就把明天的需求分布当作已知函数,计算期望利润。
期望利润函数写作:
E[Profit(p, Q)] = p * E[min(D(p), Q)] - w * Q - c_loss * E[(Q - D(p))^+]
其中D(p)是价格p下的随机需求,w是批发价,c_loss是单位剩余蔬菜的损耗成本,表达式中(Q-D)^+表示补货量超过需求的部分。如果剩余库存可以次日继续卖,c_loss应取持有成本加次日折价损失;如果当天报废,则c_loss等于进货成本。竞赛题默认损耗率高且保鲜期短,所以c_loss取进货成本即可。
需求的不确定性如何处理?不能只用点预测值。有两个方案:如果只做静态规划,则把D(p)写成点预测,并用一个比例系数补偿方差;如果要做完整决策,则用历史残差的经验分布构建需求场景。下面给出的是场景化求解的骨架,使用历史残差生成500个可能需求,最大化平均利润。
from scipy.optimize import minimize_scalar def expected_profit_per_unit(p, Q, w, alpha, beta, gamma, weekend, resid, loss_cost): # 预测对数需求 ln_demand = alpha + beta * np.log(p) + gamma * weekend base_demand = np.exp(ln_demand) # 用历史残差生成场景 demand_scenarios = base_demand * np.exp(resid) sales = np.minimum(demand_scenarios, Q) leftover = np.maximum(Q - demand_scenarios, 0) profit = p * sales.mean() - w * Q - loss_cost * leftover.mean() return profit def optimize_sku(sku_param, weekend=0): w = sku_param["wholesale_price"] resid = sku_param["resid_array"] # 外层:对价格网格搜索 best_profit = -np.inf best_p, best_Q = None, None for p in np.linspace(w * 1.1, w * 2.0, 30): # 内层:对Q做一维搜索 res = minimize_scalar( lambda Q: -expected_profit_per_unit( p, Q, w, sku_param["alpha"], sku_param["beta"], sku_param["gamma"], weekend, resid, sku_param["loss_cost"] ), bounds=(0, sku_param["capacity"]), method="bounded", ) if -res.fun > best_profit: best_profit = -res.fun best_p, best_Q = p, res.x return best_p, best_Q, best_profit代码走的路线是先离散价格再连续搜索补货量,避免二维非线性规划遇到局部最优。minimize_scalar用bounded方法,在[0, capacity]区间内找最优订货量。lambda Q: -expected_profit...加负号是因为scipy的求解器默认做最小化。价格网格的上下界按批发价浮动,下界1.1倍进价保证毛利为正,上界2倍进价防止价格跑得太远。如果实际定价规则是“不超过进价的150%”,就把上界改成w * 1.5。
4.2 单品级定价结果长什么样,参数敏感度怎么评估
对每个单品跑完上述优化后会得到一张决策表:单品编码、建议定价、建议补货量、对应期望利润。一个常见结果是:损耗率高的叶菜单品,最优定价高于常规加价率,因为必须用更高毛利覆盖损耗风险;而损耗率低的根茎类,最优定价更接近进价的1.2倍。下表是一个模拟输出示例,展示价格弹性、损耗率和最优定价之间的关系。
| 单品 | 进价(元/kg) | 弹性beta | 损耗率 | 最优定价(元/kg) | 加价率 |
|---|---|---|---|---|---|
| 上海青 | 3.2 | -1.48 | 0.18 | 4.8 | 1.50 |
| 西兰花 | 5.6 | -0.87 | 0.25 | 8.4 | 1.50 |
| 土豆 | 2.4 | -0.42 | 0.06 | 3.1 | 1.29 |
| 鲜香菇 | 9.8 | -0.71 | 0.12 | 13.7 | 1.40 |
上表反映出的规律是:损耗率高的单品加价率不一定最大,还要看价格弹性。西兰花损耗高但弹性较小(老客户刚需),所以可以加价50%;上海青弹性大,加价超过正常区间会导致销量大幅萎缩,损耗反而更高。土豆弹性小、损耗低,最优加价率就是全品类最低。因此定价策略的复杂度主要来自弹性和损耗率的二维交互。拟合时如果发现某个单品弹性为绝对值小于0.3,说明该单品属于刚需品,最优价格大概率贴着价格上限走,不必精细搜索,直接把价格设为上限,通过补货量来控制库存风险即可。
4.3 多品类的联动约束和“销量替代效应”的近似处理
单独算完每个单品之后,还有一个实际问题:蔬菜柜台的消费者带有很强的替代性,生菜贵了就买油菜,菜心贵了就买芥蓝。严格做法是建立交叉弹性矩阵,估计每个单品价格对其他单品销量的影响,但竞赛数据通常不够支撑这么大矩阵的估计。退而求其次,常见做法是把需求函数加一个品类粘性项,即品类总预算有限,用“品类需求上限”来约束单品预测值的总和。
约束条件可以写成:同品类下所有单品预测销量之和不超过历史品类的日均销量乘以季节系数S_t。这个约束把独立单品优化变成有约束的资源配置问题。简化实现可以在单品优化后做一个后验修正:如果某品类总补货量超过上限,则按单品毛利贡献从低到高依次削减补货量,削减优先作用于高损耗单品。这里给出的代码段是对补货量做按比例压缩:
def category_capacity_control(df_plan, category, cap_ratio=1.15): mask = df_plan["category"] == category total_qty = df_plan.loc[mask, "order_qty"].sum() hist_avg_qty = df_plan.loc[mask, "hist_avg_qty"].mean() cap = hist_avg_qty * cap_ratio if total_qty > cap: # 按损耗率加权缩小:损耗越高的单品削得越多 df_plan.loc[mask, "order_qty"] = df_plan.loc[mask].apply( lambda r: r["order_qty"] * cap / total_qty * (1 / (1 + r["loss_rate"])) * 0.9, axis=1, ) return df_plan压缩系数0.9和(1/(1+loss_rate))是根据损耗率加权的近似值,不是严格梯度求解,但在竞赛场景够用。使用品类容量控制时要注意,cap_ratio参数不能设得太低,否则会把销量预测强压到历史水平之下,失去挖掘潜力的机会。更精细的做法是用线性规划一次性求解全局最优,把单品利润函数做二阶近似后放入线性约束,但实现复杂度高,且容易因为需求估计误差而过度拟合,不建议在没有足够数据的情况下直接上。
5. 用滚动回测和98%服务水平把定价补货方案做到可落地
5.1 滚动回测框架:上一步的决策必须落到下一步的库存上
做好了定价和补货决策表,还要证明它在历史数据上有效。滚动回测的思路是:用第t天及之前的数据做参数估计,生成第t+1天的定价和补货策略,然后用真实第t+1天的销量验证效果,并把真实销量滚动进入下一轮训练集。这样每个决策都是在当时信息集下做出的,不会引入前视偏差。代码骨架如下:
def rolling_backtest(df, lookback=21, step=1): all_dates = sorted(df["sale_date"].unique()) results = [] inventory = {} for i in range(lookback, len(all_dates) - 1, step): train_end = all_dates[i] test_date = all_dates[i + 1] df_train = df[df["sale_date"] <= train_end] df_test = df[df["sale_date"] == test_date] # 用前面训练窗口拟合弹性 # 生成明天的定价和补货量 # 处理昨日剩余库存,再执行当天的真实销售 ... results.append(backtest_row) return pd.DataFrame(results)回测里必须处理一个细节:昨天的剩余库存会自动结转到今天,所以今天的需求要先消化昨天的库存再订购新货。这就意味着补货量不是单纯等于预测需求,而是max(0, 目标库存 - 昨日剩余)。如果模型没有显式表达这个库存滚动关系,回测结果会高估需要的订货量,在实际落地时会把冷库堆满。
5.2 服务水平约束:保证不缺货,但别让损耗吃掉利润
单纯最大化期望利润会得到一个激进的策略:负毛利的单品直接不进货,高损耗单品订货量压得很低。这在数学上正确,但在经营上不可行,因为顾客常年买不到某几种菜,会降低对超市生鲜的整体信任。因此要加一个服务水平约束:在需求的非极端日,缺货概率不能高于某个阈值;反过来,补货量过高导致损耗率超过历史正常水平也是失败。竞赛评分不会明确列出这个指标,但完整的方案应该包含这部分。
服务水平约束写成公式是P(D(p) >= Q) >= service_level,典型取值在85%到95%之间。加入这个约束后,最优补货量会大于纯利润优化的结果,差额就是为满足顾客留存而付出保险库存成本。执行层面可以用需求分布的90%分位数作为Q的下界,再做优化,代码里在minimize_scalar的边界参数中直接修改:
service_level = 0.9 safety_qty = np.percentile(demand_scenarios, service_level * 100) # 将优化下限改为safe_qty bounds=(safety_qty * 0.8, sku_param["capacity"])低位设成安全库存的80%,留一点优化空间,高位仍由陈列上限决定。这样求解出的补货量不会太低,同时利润损失被控制在2%到5%以内。这个技巧在实际业务里特别有效,因为它直接用需求经验分布的分位数替代了对“安全库存系数”的猜测,销售团队更容易接受最终结果。
5.3 高效落地技巧:定价阶梯表和参数自动更新脚本
最后一个环节是把模型输出变成门店可执行的表格。门店店员不可能每天翻优化模型输出值,实际管理动作是给每个单品贴一个标签:今日定价、今日限量、是否打折。竞赛论文如果只写到“优化结果”四个字,不够完整;建议输出一个定价阶梯表和更新脚本。定价阶梯表把价格按区间分档,每个档位对应一个补货建议,例如“定价3.99元/斤时补货15kg,定价4.50元/斤时补货11kg”。
推荐用Python生成一张pricing_rule.csv,每天凌晨跑完数据更新后自动推送到门店系统或企微群。自动化脚本就是把前面所有步骤串起来:拉数、清洗、拟合弹性、优化、写表。注意不做全自动改价,而是要有一个max_price_change限制,相邻两天的价格变动幅度不超过15%,防止优化结果在边界上跳来跳去,给消费者造成价格不稳定的观感。限制幅度后的次优价格与最优价格的利润差异通常小于1%,这个约束对实际运营是净收益。此时整套方案可以收敛为一个可复用的每日决策工具,也完整回答了C题想要的“自动定价与补货决策”闭环。
本文还有配套的精品资源,点击获取