ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

蔬菜定价补货完整链路:从数据清洗到需求预测与优化

蔬菜定价补货完整链路:从数据清洗到需求预测与优化 简介面向全国大学生数学建模竞赛C题参赛者、运筹优化学习者及毕业设计选题学生这份资源完整收录了蔬菜类商品自动定价与补货决策的参赛方案。内容覆盖数据清洗、单品与品类汇总、销量频数分析、箱线图探索、聚类分析以及两种时间序列预测模型的建模对比并给出第三问混合整数规划求解代码形成从原始数据到定价补货决策的可复现链路。包内共四十六个文件以二十四个表格、九个分析笔记、三个脚本为主体另有论文定稿、说明文档及备份文件整体约十二兆按代码、数据、论文分模块组织便于按需查阅和直接运行。已有一百零四人学习下载代码除第三问的求解器外均依赖常用库补充数据后即可执行下载后先读说明文档可快速了解目录结构与运行顺序。对希望掌握数学建模赛题落地、数据处理或智能补货策略的读者这份资源提供了从数据整理、预测到决策输出的完整示例。1. 蔬菜定价补货从国赛C题到一套能跑的完整链路凌晨四点的批发市场蔬菜价格几乎每小时都在变。今天进30公斤上海青明天可能因为一场雨就要砍到亏本价定价定贵了卖不动定便宜了毛利又覆盖不了损耗——这道题不是虚构的业务模拟而是2023全国大学生数学建模竞赛C题的真实场景。这套资源把论文、代码和原始数据处理工具打包在一起是一套完整的建模闭环从四张原始表格出发经过数据清洗、需求预测、定价补货优化最后落到回测验证。无论你是准备国赛、华为杯研究生数模还是拿这类生鲜运营题做毕业设计这份资源都能直接作为主线框架使用。2. 先把四张表对齐成一张宽表数据清洗的完整链路2.1 原始数据的常见结构与三个“对不上”C题给定的数据通常不是一张整理好的宽表而是分成订单明细、销售流水、单品信息、损耗率等多个文件每个文件里还可能有多个sheet。我第一次拿到这份数据时习惯性先用Excel打开扫一眼发现三个典型问题第一订单明细和销售流水的日期口径不一样一个记下单时间一个记结算时间同一个SKU在两天之间的归属需要重新定义第二单品编码虽然看起来唯一但部分单品在不同sheet里的名称写法和规格单位不一致第三损耗率表给出的口径是“重量损耗”还是“金额损耗”没有显式说明后面建模时必须统一。把四张表合并成一张可用于建模的宽表是整套流程里最花时间、也最容易翻车的环节。数模竞赛里很多队伍在第二天才发现预测和优化做不下去回头补数据清洗时间成本翻倍。文件关键字段典型问题订单明细日期、单品编码、订单量日期为下单时间与结算日不一致销售流水日期、单品编码、销量、售价存在退款和折扣记录单品信息单品编码、名称、分类编码不唯一规格单位混用损耗率单品编码、损耗率重量/金额口径未标明2.2 用Pandas完成多表合并与口径对齐我一般会把所有文件按sheet名读取进来先统一列名再做日期对齐最后按单品编码合并。核心原则是能提前处理的格式问题不要拖到建模阶段。下面是读取四张表的标准姿势。import pandas as pd import numpy as np orders pd.read_excel(data/附件1.xlsx, sheet_name订单明细) sales pd.read_excel(data/附件2.xlsx, sheet_name销售流水) sku pd.read_excel(data/附件3.xlsx, sheet_name单品信息) loss pd.read_excel(data/附件4.xlsx, sheet_name损耗率) # 统一列名方便后面按字段名引用 orders.columns [order_id, date, sku_id, order_qty] sales.columns [date, sku_id, sales_qty, price, discount_flag] sku.columns [sku_id, sku_name, category] loss.columns [sku_id, loss_rate]逻辑说明四张表的分工很明确——订单表回答“进了多少货”销售流水回答“卖出多少、按什么价卖”单品表回答“这些是什么菜”损耗率表回答“每公斤实际损耗多少”。如果订单量减去销量后的差额和损耗率对不上说明数据里还藏着未记录的损耗或赠品这类脏数据要在合并前标记出来。参数说明sheet_name参数里写的是实际sheet名比赛题目附件里通常会标出每个sheet的业务含义不要用默认的sheet_name0否则很容易读错表。columns重命名这一步建议在读取时直接做后面合并和透视会省很多事。接下来做日期对齐。我的做法是先把两个日期字段统一成“业务归属日”如果一笔订单的下单时间在晚上10点之后把它归到第二天因为蔬菜批发市场夜间交易的订单通常次日凌晨分拣发货。orders[date] pd.to_datetime(orders[date]) sales[date] pd.to_datetime(sales[date]) # 晚间订单归属到次日 orders[date] np.where(orders[date].dt.hour 22, orders[date] pd.Timedelta(days1), orders[date]) orders[date] orders[date].dt.normalize() # 合并以销售流水为主表订单量、损耗率、分类作为特征补充 df sales.merge(orders.groupby([date, sku_id])[order_qty].sum(), on[date, sku_id], howleft) df df.merge(sku, onsku_id, howleft) df df.merge(loss, onsku_id, howleft) df df.dropna(subset[sales_qty, price])逻辑说明groupby([date, sku_id])[order_qty].sum()做的事情是把一个SKU当天的所有订单合并成一条补货记录避免一张订单被拆成多行后无法与销售流水对齐。normalize()去掉时间部分只保留日期后续所有按天聚合的操作才不会出偏差。dropna删掉没有实际销量的记录这些记录通常是测试订单或赠品出库留着会干扰损耗率计算。参数说明howleft表示以销售流水为准能保留所有有销售行为的记录。如果反过来用inner会丢掉部分有销量但没匹配上订单的日期导致后面补货约束条件缺数据。另外损耗率字段建议在合并后做一次乘法校验order_qty乘1 - loss_rate是否接近sales_qty偏差超过5%的单品编码单独存一份后续分析时重点关注。2.3 构造特征滞后销量、价格弹性样本与星期效应表对齐只是第一步真正让模型能学起来的是特征。蔬菜类商品有一个非常明显的规律未来一天的销量和最近7天的销量、当天的价格、是不是周末、有没有促销活动高度相关。把这些信息构造成列特征是所有后续建模的输入基础。df df.sort_values([sku_id, date]).reset_index(dropTrue) df[weekday] df[date].dt.weekday df[is_weekend] df[weekday].isin([5, 6]).astype(int) feature_map [] for sku_id, group in df.groupby(sku_id): group group.sort_values(date) group[lag_1] group[sales_qty].shift(1) group[lag_7] group[sales_qty].shift(7) group[price_change] group[price].pct_change() feature_map.append(group) df_feat pd.concat(feature_map, ignore_indexTrue) df_feat df_feat.dropna(subset[lag_1, lag_7])逻辑说明groupby之后对每个SKU单独排序再算滞后特征才能保证同一个SKU的“前一天销量”取的是它自己的历史值而不是把不同单品的销量错位拼到一起。lag_1和lag_7分别捕捉短期波动和以周为单位的周期性——叶菜在周末的销量通常比工作日高20%以上没有lag_7模型很难学到这层规律。参数说明shift(1)是“取前一天的值”对每个SKU的第一条记录来说是空值所以最后要dropna。price_change是价格变动率定价优化在做灵敏度分析时会用到它和销量组合起来可以近似估计价格弹性。weekday用0-6表示周一到周日isin([5,6])判断是否周末这类离散特征对LightGBM之类的树模型尤其友好。3. 需求预测先跑统计基线再上特征工程3.1 为什么不能用简单平均法预测蔬菜销量很多第一次做生鲜预测的同学第一反应是对历史销量取平均然后作为第二天的需求预测量。这个做法在预测月度稳定消费品时还能勉强用放在蔬菜上基本是灾难。蔬菜日销量有两个特点一是衰减型生命周期上海青从上市到下架的销量曲线通常在第3到第5天达到峰值然后骤降二是强外部扰动一场雨、一次降温、周边菜场临时关门都能让当日销量偏离历史均值40%以上。平均值把所有这些波动都抹平了预测出来的“平滑需求”既不能支撑定价优化也无法满足补货约束。在数模竞赛里用平均值预测的论文通常连第二问都做不好因为后续的定价补货优化高度依赖需求预测的准确度。如果预测偏差过大最优解全部失真优化目标函数值甚至可能出现负数毛利。正确的做法是分两步先用时间序列模型建立基线再引入特征做增强。3.2 用指数平滑模型跑出第一个可信基线我习惯先用statsmodels里的ExponentialSmoothing指数平滑对每个品类做基线预测。指数平滑适合这种有明显趋势和周期、但又不适合直接套ARIMA的数据——生鲜销量往往是局部平稳的全局来看却处处有突变点。import pandas as pd from statsmodels.tsa.holtwinters import ExponentialSmoothing def baseline_forecast(series, periods7): model ExponentialSmoothing( series, trendadd, seasonaladd, seasonal_periods7, initialization_methodestimated ) fitted model.fit(optimizedTrue) return fitted.forecast(periods) # 以某个单品为例按天汇总销量 sku_series df_feat[df_feat[sku_id] SKU001] \ .set_index(date)[sales_qty] fc baseline_forecast(sku_series)逻辑说明ExponentialSmoothing把时间序列拆成水平项、趋势项和季节项。trendadd表示趋势是线性叠加的蔬菜周销量整体缓慢波动不需要用乘性趋势seasonaladd表示一周内的周期波动幅度基本恒定这对叶菜类更合理——如果换成季节性波动幅度随时间递增的水果品类才需要改成mul。参数说明seasonal_periods7是核心它告诉模型周期是7天。initialization_methodestimated让模型自动估计初始状态比自己手动填值稳定得多。optimizedTrue表示超参数自动寻优跑出来已经很接近最优基线了。这个基线模型只用来做参照——后续如果LightGBM跑出来的结果还不如它说明特征工程或数据切分出了问题优先回去查。3.3 用LightGBM做特征增强把残差压下去指数平滑只用了销量自身的历史信息价格、星期、促销、损耗率这些字段完全没用上。树模型则可以同时处理连续特征和离散特征还能捕捉特征之间的交互作用。我一般会在品类层级训练LightGBM把每个SKU作为categorical特征传入。import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit features [lag_1, lag_7, price, weekday, is_weekend, price_change] X df_feat[features] y df_feat[sales_qty] model lgb.LGBMRegressor( n_estimators300, learning_rate0.05, num_leaves31, max_depth6, subsample0.8, colsample_bytree0.8, random_state42 ) # 时间序列切分不随机打散按时间先后切 tscv TimeSeriesSplit(n_splits4) for train_idx, val_idx in tscv.split(X): train_X, train_y X.iloc[train_idx], y.iloc[train_idx] val_X, val_y X.iloc[val_idx], y.iloc[val_idx] model.fit(train_X, train_y, eval_set[(val_X, val_y)], verboseFalse)逻辑说明TimeSeriesSplit是时间序列场景下的K折交叉验证——普通train_test_split会随机打乱数据让模型用“未来的数据”去预测“过去的结果”这是数模论文里最常见的翻车点。n_splits4表示切4次每次用前半段时间训练、后半段时间验证既保证训练集够大又能靠近真实的推演场景。参数说明max_depth6限制树深度防止模型对个别SKU过拟合subsample0.8对行采样colsample_bytree0.8对特征采样两者合起来让每棵树看到的数据略有差异增强泛化能力。n_estimators300和learning_rate0.05搭配属于“慢学习”配置对波动较大的生鲜数据比默认参数稳定。训练完以后至少要打印一次feature_importances_确认lag_1和price排在前两位如果不是说明特征构造有问题。4. 定价与补货联合优化从毛利最大化到约束化求解4.1 先明确决策变量、目标函数与业务约束定价和补货不只是“把价格定高一点”或“进货进多一点”而是一个约束优化问题。C题的完整决策场景可以压缩为已知每个SKU的进价、损耗率和历史需求模式第二天的零售价定在哪一档、补货量取多少才能让整天的销售毛利最大同时不能超出供应商最大供应量。这里面有三组关键约束一是补货量不能超过供应商当天最大供货量二是每个SKU有最小起订量三是价格不能超过市场允许的合理区间。缺了这三条优化器很容易给出“进0公斤、价格定最高”这种数学最优但业务完全不可行的解。把约束写全比优化算法本身更重要。4.2 用pulp在固定价格下求解最优补货量先把价格固定下来需求预测值也随之固定这时候问题退化为一个标准的线性规划在供应约束和起订量约束下分配每个SKU的补货量使总毛利最大。用pulp实现起来非常清晰。import pulp as pl sku_list df_feat[sku_id].unique() wholesale_price {sku: 4.2 for sku in sku_list} # 进价 retail_price {sku: 7.5 for sku in sku_list} # 固定的零售价 demand_pred {sku: 50 for sku in sku_list} # 来自预测模型的期望销量 max_supply {sku: 120 for sku in sku_list} # 供应商最大供货量 min_order {sku: 10 for sku in sku_list} # 最小起订量 loss_rate {sku: 0.05 for sku in sku_list} # 损耗率 prob pl.LpProblem(Replenish_Optimization, pl.LpMaximize) qty pl.LpVariable.dicts(qty, sku_list, lowBound0, catContinuous) # 目标函数毛利 预计销量 *零售价 - 进价扣掉损耗成本 prob pl.lpSum( demand_pred[s] * (retail_price[s] - wholesale_price[s]) - loss_rate[s] * wholesale_price[s] * qty[s] for s in sku_list ) # 每SKU补货量不超过需求量否则超出部分是纯损耗 for s in sku_list: prob qty[s] demand_pred[s] prob qty[s] max_supply[s] prob qty[s] min_order[s] prob.solve() print(pl.LpStatus[prob.status])逻辑说明目标函数里区分了两个部分——销售额按demand_pred计算因为多补的部分卖不掉不算收入损耗成本按qty计算因为损耗是买了多少就损耗多少。这个口径差异是这道题最容易想错的地方很多队伍直接把目标函数写成(retail_price - wholesale_price) * qty把卖不掉的过量补货也计入了毛利导致优化器拼命放大补货量。参数说明lowBound0限制补货量不能为负qty[s] demand_pred[s]是“不多补”约束具体场景里如果存在隔天仍可销售的蔬菜比如土豆、洋葱这个约束可以放宽为qty[s] demand_pred[s] * 1.2但叶菜类建议严格1.0。pl.LpStatus[prob.status]输出Optimal才表示求解成功如果输出Infeasible优先检查min_order和max_supply的数值是否冲突。4.3 价格外循环把固定价格推广为网格搜索固定价格只是过程真实决策要连价格一起优化。需求对价格有弹性——价格提高销量下降价格降低销量上升但毛利变薄。把价格作为决策变量直接放进线性规划会变成非线性问题常见做法是外层枚举价格候选值内层对每个价格重新求解补货线性规划最后选总毛利最高的组合。import numpy as np candidate_prices np.arange(6.5, 8.5, 0.1) # 价格区间与步长 best_profit -np.inf best_plan None for p in candidate_prices: # 简化需求函数价格每提高0.5元预计销量下降8% adjusted_demand {s: demand_pred[s] * (1 - 0.08 * (p - 7.5)) for s in sku_list} for s in sku_list: adjusted_demand[s] max(0, adjusted_demand[s]) plan solve_replenish(adjusted_demand, retail_price{s: p for s in sku_list}) profit compute_profit(plan, adjusted_demand) if profit best_profit: best_profit profit best_plan {price: p, qty: plan} print(best_plan)逻辑说明外层枚举价格档位内层调用固定价格下的补货线性规划求解器本质上是一个一维搜索加线性规划的组合优化。adjusted_demand里的弹性系数0.08是一个简化假设——如果资源里的论文或代码提供了更精确的弹性估计值优先用论文里的数值替换。参数说明np.arange(6.5, 8.5, 0.1)生成65个候选价格。步长0.1元在生鲜场景下已经足够精细更小的步长会显著增加求解时间但收益有限。需要注意的是每个SKU共用一个价格候选区间是简化处理如果要做精细化优化可以先按品类聚类让叶菜类、菌菇类分别跑不同的价格区网格。5. 避坑清单生鲜定价题最容易翻车的五个细节5.1 日期对齐后销量出现负数现象把订单表和销售流水表合并后按日聚合的销量出现负值后续所有滞后特征全部错乱。原因销售流水里包含退款记录或冲正记录退款数量直接记为负数没有单独标记业务类型。直接从销售表里取销量做预测负数会被模型当成“真实销量下降”严重扭曲趋势。解决合并前先用refund_flag或type字段过滤掉退款或者在聚合时用np.maximum(sales_qty, 0)兜底。最稳妥的姿势是只保留type sale的记录退款单独汇总一列作为特征输入。5.2 损耗率在优化模型里完全不起作用现象把损耗率写进目标函数后最优补货量和没有损耗率时几乎一样怎么看都不合理。原因目标函数里的损耗成本和毛利项量纲不一致。毛利用营业额减去进货成本单位是“元/公斤×公斤”损耗成本却只用损耗率乘了进价忘了乘补货量。量纲不对损耗项对目标的边际影响就小到可以忽略。解决损耗成本必须写成loss_rate * wholesale_price * qty保证损耗项和毛利项都包含qty因子。写完以后做一次敏感性测试把损耗率从5%调到10%比较补货量是否明显下降。如果变化不显著说明约束或目标函数还没写对。5.3 预测模型在周末完全失效现象验证集上工作日误差20%周末误差却超过60%。查看特征重要性时lag_1排名第一但周末预测曲线严重滞后。原因训练集里工作日样本占多数树模型为了整体损失最小化牺牲了少数类样本的拟合能力。如果不专门处理周末特征模型会把“周一销量”和“周日销量”当成近似样本对待。解决构造is_weekend时同时加入days_since_last_holiday、rain_flag这类外部特征用TimeSeriesSplit验证时确保每个训练折里都包含完整的一周数据。如果资源里有节假日表直接merge进来效果最好。5.4 优化器给出极端补货量或定价现象求解结果里某SKU补货量直接顶到供应商上限另一个SKU补货量为零价格则取到枚举区间的两端。原因数据清洗阶段丢弃了太多缺失记录导致部分SKU的有效样本只有个位数需求预测值不可信优化器在低置信度区域做出极端决策。解决给决策变量加置信度约束样本量小于30天的SKU不参与单个SKU优化按品类聚合统一决策价格候选区间缩窄到历史价格的第20到第80百分位之间不允许优化器探索从未出现过的价位。5.5 论文结果和代码结果对不上现象写论文时用某组数据做了定价优化提交代码后评委复现却得到完全不同的最优毛利。原因回测时用了未来信息。常见情况是计算某个SKU当天销量时用到了当天真实销量反过来校准需求预测而不是只用截止前天24点的历史数据做预测。解决代码里所有预测函数的输入必须严格控制在date 当前预测日的窗口内回测时逐日推进。我在代码里用一个train_window变量控制最小训练样本量每次预测前强制用date day过滤一遍训练集杜绝信息泄露。6. 把优化结果落回业务回测框架与灵敏度分析模型和优化器跑通只是中间产物最终要回答“这套策略放到历史数据上到底能多赚多少钱”。回测框架和灵敏度分析是我每次必跑的两样东西也是这套资源里沉淀下来最实用的部分。回测的核心是按天推进每天只使用截止当天的历史数据来做预测和优化不能偷看当天真实销量。一个简化版本的回测框架是这样的def backtest(df, start_date, end_date, price_grid): results [] for day in pd.date_range(start_date, end_date): train df[df[date] day] if len(train) 30: continue pred lgb_model.predict(train[features]) p, q, profit optimize(pred, price_grid) actual df[(df[date] day)][sales_qty].sum() results.append({date: day, price: p, replenish: q, actual_sales: actual, profit: profit}) return pd.DataFrame(results)逻辑说明pd.date_range生成逐日时间轴每一天先取历史数据重新预测、优化再和真实销量对账。这个框架暴露的绝大多数问题都集中在同一类预测结果在回测前段表现很好到后段就崩然后又调参数、再回测、再调——反复几轮下来发现是日期切分的bug而不是模型问题。灵敏度分析我一般跑两个维度价格弹性系数和损耗率。把弹性从-1.2按步长调到-2.0记录每个取值下的最优定价和毛利率变化。这个表格放进论文里非常有说服力直接证明模型不是靠调参硬凑出来的。价格弹性最优零售价元/公斤预计日毛利元补货量公斤-1.28.0126.532-1.67.498.227-2.06.984.723从那以后我每次做这类优化题都会先用回测框架走一遍全流程再开始调参数据切分和损耗口径两项强制复查三遍才提交代码。这套习惯帮我在不止一次竞赛中避开了“论文结果复现不了”的致命问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表