数学建模竞赛实战:基于XGBoost与滚动优化的蔬菜定价补货决策
1. 项目概述从赛题到实战的完整闭环去年带队参加全国大学生数学建模竞赛C题“蔬菜类商品的自动定价与补货决策”给我留下了深刻印象。这道题之所以经典是因为它完美地模拟了一个真实商业场景的核心决策问题如何在需求波动、损耗约束和利润目标之间找到最优平衡。这不仅仅是数学更是对供应链管理、数据分析和商业逻辑的综合考验。很多同学拿到题目后往往一头扎进模型构建却忽略了问题本身的业务背景和数据的现实含义导致模型“好看不好用”。今天我就结合当时的解题思路、代码实现以及后续的复盘思考把这个项目的完整脉络拆解清楚从问题理解、数据清洗、模型构建到决策输出提供一个可以直接参考复现的实战指南。无论你是正在备赛的学生还是对数据驱动决策感兴趣的分析师这篇文章都能帮你建立起解决此类问题的系统性框架。2. 核心问题拆解与建模思路面对“自动定价与补货决策”这样一个复合型问题第一步不是急着写代码而是要把大问题分解成一系列可量化、可建模的子问题。这道题的核心矛盾在于定价影响销量销量决定补货补货又涉及库存成本和损耗最终共同影响总收益。这是一个典型的动态优化问题。2.1 问题一销量与定价的关系建模这是所有决策的基石。题目通常会提供历史销售数据包含不同蔬菜品类在不同日期的销量和售价。我们的目标是建立一个函数描述“价格如何影响需求”。这里最常用的方法是需求弹性模型。但直接套用经济学教科书上的公式往往行不通因为蔬菜销售还受到季节性、星期效应、促销活动等多重因素影响。实操要点数据分层处理不要对所有蔬菜用一个模型。叶菜类如菠菜和根茎类如土豆的损耗特性和需求弹性天差地别。必须按品类分别建模。特征工程是关键除了价格必须引入其他特征。时间特征是否为周末、节假日、月份、季节。周末的客流量和购买习惯与工作日不同。滞后特征前一天的销量、前一周同期的销量。很多蔬菜需求有很强的惯性或周期性。交叉特征其他相关蔬菜的价格替代品或互补品。例如菠菜价格飙升可能会带动生菜销量上涨。模型选型线性回归简单但可能无法捕捉非线性关系。可以尝试带正则化的线性模型如Lasso自动进行特征选择防止过拟合。树模型如XGBoost、LightGBM能很好地处理特征间的非线性关系和交互效应且对缺失值不敏感是当前竞赛中的主流选择。集成思路用线性模型捕捉趋势用树模型捕捉残差中的复杂模式。注意评估模型时不要只看R²更要关注在“价格变动”区间内的预测准确性。因为我们的核心是用模型来模拟调价后的销量变化。2.2 问题二考虑损耗的补货模型蔬菜有保质期这是区别于一般商品的核心约束。补货决策必须回答每天每类菜进多少货这需要平衡缺货损失机会成本和过剩损耗报废成本。建模思路这本质上是一个报童模型的扩展。经典报童模型求解一个使期望利润最大化的单周期订货量。但蔬菜问题是多品类、多周期的。定义成本结构进货成本蔬菜的采购单价。持有成本主要为资金占用成本对于蔬菜而言相对较低有时可忽略。缺货成本未满足需求导致的利润损失。这不仅是单笔利润还可能包括顾客满意度下降的长期损失可以设定为一个惩罚系数。损耗成本未售出且过期蔬菜的处置成本即进货成本的一部分或全部。建立目标函数 对于单个品类单日决策期望利润可表示为期望利润 ∑(销量 * 售价 - 进货量 * 进价 - 损耗量 * 单位损耗成本 - 缺货量 * 单位缺货惩罚)其中销量是随机变量依赖于我们预测的需求分布。从单周期到多周期 今天的剩余库存可以影响明天的决策因此这是一个动态规划问题。但竞赛时间有限通常采用滚动时域优化每天根据当前库存和未来若干天的预测需求求解一个有限时间窗如3-7天内的优化问题只执行第一天的补货决策第二天再根据新状态重新优化。2.3 问题三定价与补货的联合优化这是最难的部分定价和补货相互耦合。更高的价格可能降低销量从而减少所需补货量和潜在损耗但更低的价格可能刺激需求需要更多补货同时增加损耗风险。求解策略分步迭代法实用首选步骤1给定一组初始价格用问题二的补货模型求解最优补货量。步骤2固定补货量微调价格利用需求模型计算销量变化评估总利润是否提升。步骤3重复步骤1和2直到利润增长小于某个阈值或达到迭代次数上限。这种方法虽然不是全局最优但在计算复杂度和效果之间取得了很好的平衡。联合优化模型将价格和补货量作为决策变量构建一个大规模的非线性规划或混合整数规划模型。这需要强大的求解器如Gurobi, Cplex和较高的建模技巧计算量大但理论更优美。3. 数据预处理与特征工程实战拿到竞赛数据第一步不是跑模型而是“看”数据。一份典型的蔬菜销售数据可能包含商品编码、日期、销量、售价、进货量、损耗量等字段。数据质量直接决定模型天花板。3.1 数据清洗核心步骤异常值处理销量为0或极低需区分是“真无销售”还是“数据缺失”。结合进货量判断如果进货量正常但销量为0可能是数据记录问题需谨慎处理或视为缺失。价格异常波动计算每日价格的Z-score或使用IQR方法剔除远超正常范围的价格点可能是录入错误。负库存或销量大于进货量明显的数据错误需根据前后数据插值或直接剔除。缺失值填补对于连续的销量、价格数据可采用前后均值、线性插值或基于同类商品趋势的插值。对于分类特征如是否促销如果缺失通常按“否”处理。数据一致性检查确保“销量 当日剩余库存 前日库存 进货量 - 损耗量”大致成立。虽然实际数据可能有误差但大的偏差需要回溯。3.2 针对本题的特征构建以下特征需要在数据集中提前构建好import pandas as pd import numpy as np # 假设 df 包含 date, category_id, sales, price, purchase 等字段 df[date] pd.to_datetime(df[date]) # 1. 时间特征 df[day_of_week] df[date].dt.dayofweek # 周一0, 周日6 df[is_weekend] df[day_of_week].apply(lambda x: 1 if x 5 else 0) df[month] df[date].dt.month df[season] df[month].apply(lambda m: (m%12 3)//3) # 1:春, 2:夏, 3:秋, 4:冬 # 2. 滞后特征 (以品类为单位分组后创建) df df.sort_values([category_id, date]) for lag in [1, 7, 30]: # 滞后1天、7天周度、30天月度 df[fsales_lag_{lag}] df.groupby(category_id)[sales].shift(lag) df[fprice_lag_{lag}] df.groupby(category_id)[price].shift(lag) # 3. 滚动统计特征 df[sales_rolling_mean_7] df.groupby(category_id)[sales].transform(lambda x: x.rolling(7, min_periods1).mean()) df[price_rolling_std_7] df.groupby(category_id)[price].transform(lambda x: x.rolling(7, min_periods1).std()) # 4. 品类相关特征 (例如叶菜类标识) leafy_categories [101, 102, 105] # 假设的叶菜类ID df[is_leafy] df[category_id].apply(lambda x: 1 if x in leafy_categories else 0) # 处理滞后特征产生的缺失值 df df.fillna(methodbfill) # 或用其他策略4. 模型构建与代码实现详解我们以XGBoost需求预测模型和滚动时域补货优化为例展示核心代码框架。4.1 基于XGBoost的需求预测模型import xgboost as xgb from sklearn.model_selection import TimeSeriesSplit, GridSearchCV from sklearn.metrics import mean_absolute_error, mean_squared_error import warnings warnings.filterwarnings(ignore) # 假设预处理后的DataFrame为 df特征列表为 feature_cols目标变量为 sales feature_cols [price, day_of_week, is_weekend, month, sales_lag_1, sales_lag_7, sales_rolling_mean_7, is_leafy] target_col sales # 按时间排序确保时间序列不泄露 df df.sort_values(date).reset_index(dropTrue) # 划分训练集和测试集按时间划分 split_date 2023-08-01 # 假设的划分日期 train df[df[date] split_date].copy() test df[df[date] split_date].copy() X_train, y_train train[feature_cols], train[target_col] X_test, y_test test[feature_cols], test[target_col] # 初始化模型 model xgb.XGBRegressor(objectivereg:squarederror, n_estimators500, learning_rate0.05, max_depth6, subsample0.8, colsample_bytree0.8, random_state42) # 使用时间序列交叉验证进行参数微调可选但推荐 tscv TimeSeriesSplit(n_splits5) param_grid { max_depth: [4, 6, 8], learning_rate: [0.01, 0.05, 0.1], subsample: [0.7, 0.8, 0.9] } # grid_search GridSearchCV(model, param_grid, cvtscv, scoringneg_mean_absolute_error, verbose1) # grid_search.fit(X_train, y_train) # best_model grid_search.best_estimator_ # 直接训练 model.fit(X_train, y_train, eval_set[(X_test, y_test)], early_stopping_rounds50, verboseFalse) # 预测与评估 y_pred model.predict(X_test) mae mean_absolute_error(y_test, y_pred) rmse np.sqrt(mean_squared_error(y_test, y_pred)) print(f测试集 MAE: {mae:.2f}, RMSE: {rmse:.2f}) # 特征重要性分析 importance pd.DataFrame({ feature: feature_cols, importance: model.feature_importances_ }).sort_values(importance, ascendingFalse) print(importance)实操心得early_stopping_rounds是防止过拟合的神器务必使用。时间序列数据绝对不能用随机划分的交叉验证必须用TimeSeriesSplit。特征重要性分析能帮你理解哪些因素真正驱动销量有时比模型精度更重要。4.2 滚动时域补货优化模型这里我们简化问题假设已知未来几天的需求预测由上述模型给出并采用线性规划求解单日的补货量。from scipy.optimize import linprog import numpy as np def daily_replenishment_optimization(current_inventory, predicted_demands, purchase_cost, selling_price, shortage_penalty, spoilage_cost, holding_cost_rate0.001): 单日补货优化报童模型思想扩展 参数 current_inventory: 当前库存列表每个品类 predicted_demands: 未来T天每个品类的需求预测列表T x N purchase_cost: 每个品类的进货单价列表 selling_price: 每个品类的销售单价列表 shortage_penalty: 单位缺货惩罚成本通常大于利润率 spoilage_cost: 单位损耗成本通常等于或小于进货成本 holding_cost_rate: 每日库存持有成本率 返回 建议的补货量列表 num_categories len(current_inventory) T len(predicted_demands) # 优化时域 # 构建线性规划问题 # 决策变量补货量 q_i (i1..num_categories) # 目标函数最大化期望利润最小化负利润 c [] # 目标函数系数 # 约束Ax b A [] b [] # 简化我们只优化第一天的补货并近似考虑未来T天的期望影响 for i in range(num_categories): # 目标函数系数对于补货量q_i其成本是立即发生的 (purchase_cost[i]) # 其收益和成本取决于未来需求这里做一个简化计算 # 我们假设补货量用于满足未来T天的需求平均每日持有成本和损耗风险 expected_daily_demand np.mean([predicted_demands[t][i] for t in range(T)]) # 一个非常简化的系数增加一单位补货的边际净收益期望 # 净收益 售价 - 进价 - 缺货惩罚风险 - 损耗风险 - 持有成本 # 这里用启发式方法估算 prob_shortage max(0, expected_daily_demand - current_inventory[i]) / (expected_daily_demand 1e-5) prob_spoilage max(0, current_inventory[i] - expected_daily_demand) / (current_inventory[i] 1e-5) marginal_profit selling_price[i] - purchase_cost[i] \ - shortage_penalty * prob_shortage \ - spoilage_cost * prob_spoilage \ - holding_cost_rate * purchase_cost[i] * T/2 # 平均持有时间估算 c.append(-marginal_profit) # linprog默认最小化所以取负 # 约束示例总补货预算限制、单个品类补货上限、非负约束等 # 1. 非负约束 (q_i 0) 已由 bounds 参数定义 # 2. 总采购预算约束 (假设总预算为B) total_budget 10000 # 示例预算 A.append(purchase_cost) # sum(purchase_cost[i] * q_i) total_budget b.append(total_budget) # 3. 单个品类最大补货量约束基于仓储空间或供应商限制 max_replenish [200, 150, 300] # 示例每个品类的上限 for i in range(num_categories): constraint [0] * num_categories constraint[i] 1 A.append(constraint) b.append(max_replenish[i]) # 求解线性规划 bounds [(0, None) for _ in range(num_categories)] # 补货量非负 result linprog(c, A_ubA, b_ubb, boundsbounds, methodhighs) if result.success: return np.round(result.x).astype(int) # 返回整数补货量 else: print(优化失败:, result.message) return np.zeros(num_categories, dtypeint) # 示例调用 current_inv [50, 30, 20] # 品类A、B、C的当前库存 pred_demands [ # 未来3天的需求预测 [品类A, 品类B, 品类C] [60, 35, 25], [55, 40, 22], [65, 30, 28] ] purchase_cost [2.0, 3.0, 1.5] selling_price [5.0, 7.0, 4.0] shortage_penalty 2.0 # 缺货一单位的惩罚 spoilage_cost 1.0 # 损耗一单位的成本 opt_order daily_replenishment_optimization(current_inv, pred_demands, purchase_cost, selling_price, shortage_penalty, spoilage_cost) print(f建议补货量: {opt_order})注意这是一个高度简化的示例。真实的联合优化模型要复杂得多可能需要用到cvxpy、PuLP等建模工具或者启发式算法如遗传算法来求解。5. 结果分析与决策输出模型跑出结果只是第一步如何解读和呈现这些结果使其成为可执行的商业决策是赢得比赛的关键。5.1 定价决策表的生成定价决策不应是单个数字而应是一个策略表。例如对于每个蔬菜品类模型可以输出一个“价格-预期销量-预期利润”的对照表或者一个基于当前库存和未来预测的实时定价函数。def generate_pricing_strategy(category_id, current_inventory, demand_forecast, base_price, elasticity_estimate): 生成动态定价建议。 简化策略基于库存水平调整价格。 days_of_supply current_inventory / (demand_forecast 1e-5) if days_of_supply 1.0: # 库存低于1天需求量可能缺货 # 适当提价以抑制需求平衡利润和缺货风险 suggested_price base_price * 1.15 action Raise Price (Low Stock) elif days_of_supply 3.0: # 库存超过3天需求量损耗风险高 # 适当降价以促进销售减少潜在损耗 suggested_price base_price * 0.9 action Discount (High Stock) else: suggested_price base_price action Hold Price # 确保价格在合理范围内 suggested_price max(base_price * 0.7, min(suggested_price, base_price * 1.3)) return suggested_price, action # 为每个品类生成建议 strategy_table [] for cat_id in df[category_id].unique(): # 获取该品类最新数据 cat_data df[df[category_id] cat_id].iloc[-1] inv cat_data[current_inventory] # 假设数据中有该字段 forecast cat_data[predicted_demand_next_day] # 假设预测字段 base_p cat_data[price_rolling_mean_7] # 以近期均价为基础 new_price, action generate_pricing_strategy(cat_id, inv, forecast, base_p, -1.5) # 假设弹性为-1.5 strategy_table.append({ Category_ID: cat_id, Current_Inventory: inv, Demand_Forecast: forecast, Base_Price: round(base_p, 2), Suggested_Price: round(new_price, 2), Action: action }) strategy_df pd.DataFrame(strategy_table) print(strategy_df)5.2 补货计划的可视化将补货计划与历史销量、库存水平一起可视化能直观展示决策的合理性。import matplotlib.pyplot as plt # 假设我们有一个包含日期、品类、建议补货量、预测销量、实际库存的DataFrame plan_df fig, axes plt.subplots(2, 1, figsize(14, 10)) # 图表1销量、库存与补货量趋势 category 菠菜 # 示例品类 cat_data plan_df[plan_df[category] category] ax1 axes[0] ax1.plot(cat_data[date], cat_data[sales], label实际销量, markero, linewidth2) ax1.plot(cat_data[date], cat_data[predicted_sales], label预测销量, linestyle--) ax1.bar(cat_data[date], cat_data[replenishment], alpha0.5, label建议补货量, colororange) ax1.set_ylabel(数量) ax1.set_title(f{category} - 销量、库存与补货决策) ax1.legend() ax1.grid(True, linestyle--, alpha0.7) # 图表2库存水平变化 ax2 axes[1] ax2.plot(cat_data[date], cat_data[inventory], label库存水平, colorgreen, markers, linewidth2) ax2.axhline(ycat_data[safety_stock].mean(), colorr, linestyle:, label安全库存线) ax2.fill_between(cat_data[date], 0, cat_data[inventory], alpha0.3, colorgreen) ax2.set_xlabel(日期) ax2.set_ylabel(库存量) ax2.set_title(f{category} - 库存水平变化) ax2.legend() ax2.grid(True, linestyle--, alpha0.7) plt.tight_layout() plt.show()6. 常见问题与避坑指南在实战和辅导学生过程中我总结了几个最容易出错的地方。6.1 模型评估的陷阱问题在训练集上R²很高但一用于定价模拟利润计算就出问题。原因评估指标选错。回归模型常用的MSE、MAE衡量的是预测值与真实值的绝对误差。但对于定价决策我们更关心需求弹性预测的准确性即价格变动百分比引起的销量变动百分比是否预测得准。解决方案划分一个“策略验证期”不参与模型训练。在验证期上模拟你的定价策略基于模型预测的需求弹性调整价格用真实的销量反应或留出的测试数据来计算模拟利润。核心评估指标是模拟总利润而不是预测误差。6.2 数据泄露与过拟合问题使用了未来的信息来预测过去导致模型在历史数据上表现虚幻的好。典型错误在构建“7日均价”特征时使用了包含当天在内的滚动窗口。正确的做法是使用rolling(7).mean().shift(1)即只用历史信息。在特征工程后对整个数据集进行标准化然后再划分训练测试集。这会导致测试集信息“泄露”到训练集的标准化参数中。正确做法严格按时间顺序处理数据。任何特征生成、数据缩放都必须在按时间划分的训练集上拟合参数然后应用到测试集上。6.3 对损耗处理的理想化问题将损耗简单地视为一个固定比例或与库存线性相关。现实蔬菜损耗是非线性的。例如叶菜类在库存第1天和第3天的损耗率截然不同且受温度、湿度影响。改进思路建立损耗率模型将损耗率作为库存天数、品类、季节的函数来建模。可以使用历史损耗数据训练一个分类模型如是否损耗或回归模型损耗比例。在优化模型中引入非线性损耗成本将损耗成本表示为库存水平的凸函数这样优化器会自动倾向于保持更低的库存水平以减少边际损耗风险。6.4 忽略决策的实操性问题模型建议每天的价格和补货量剧烈波动比如今天补货100kg明天补货5kg价格也从5元跳到8元。原因模型只追求数学最优忽略了实际操作成本如采购最小起订量、价格标签更换频率、顾客价格感知。解决方案在目标函数中加入平滑性惩罚项。补货平滑在优化目标中增加λ * (今日补货量 - 昨日补货量)²λ是平滑系数惩罚补货量的大幅波动。价格平滑约束相邻日期的价格变动幅度不超过一定百分比如5%或同样在目标函数中加入价格波动惩罚。7. 竞赛论文写作与代码呈现要点数学建模竞赛是“模型论文代码”的综合比拼。再好的模型如果表达不清也难获好评。论文结构建议问题重述与分析用你自己的话精炼概括问题并画出系统流程图清晰展示“数据输入-预测模型-优化模型-决策输出”的完整逻辑链。模型假设与符号说明明确列出所有假设如“假设短期内蔬菜采购单价不变”并用表格清晰定义所有使用的符号、变量。模型建立这是核心。分小节阐述需求预测模型、损耗模型、补货模型、定价模型以及最终的联合优化模型。每一个模型都要有数学公式并解释其经济学或管理学含义。模型求解与算法设计说明你用了什么算法如梯度提升树、线性规划以及为什么选择它。给出算法的伪代码或流程图。结果分析用图表说话。展示预测模型的精度、优化前后的利润对比、不同场景下的决策方案。分析结果的敏感性和鲁棒性例如当需求预测误差增大10%时利润会下降多少。模型评价与推广客观评价自己模型的优缺点并提出改进方向。简要说明模型如何推广到其他商品如水果、鲜肉。代码提交技巧模块化将数据清洗、特征工程、模型训练、优化求解、结果输出分别写成独立的函数或脚本data_preprocessing.py,train_model.py,optimization.py。注释清晰关键步骤、复杂逻辑必须有注释解释“为什么这么做”。README文件在代码根目录提供详细的README.md说明运行环境Python 3.8所需库及版本requirements.txt、数据存放路径、如何按顺序运行脚本。结果可复现设置随机种子np.random.seed(42),random.seed(42)确保每次运行代码得到的结果一致。这道赛题是一个绝佳的数据科学综合练兵场。它迫使你跳出单纯的算法调参去思考业务逻辑、成本结构、决策闭环。真正的难点往往不在模型的复杂度而在于对问题的深刻理解和将现实约束转化为数学语言的能力。我的建议是在动手编码前花足够多的时间和小组成员一起在白板上梳理清楚整个业务的逻辑图定义好每一个输入和输出讨论每一个假设的合理性。磨刀不误砍柴工前期思考越深入后期建模就越顺畅论文写作也越有底气。最后别忘了在优化目标里加上对“决策稳定性”的考量这往往是让模型从“理论上最优”走向“实践中可用”的临门一脚。