十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Python+LightGBM实现国赛C题商超果蔬定价与补货预测

Python+LightGBM实现国赛C题商超果蔬定价与补货预测 简介面向2023年全国大学生数学建模竞赛C题参赛者这份基于Python的源代码围绕商超果蔬类商品的价格预测与补货预测问题提供完整的数学建模实现方案。资源共18个文件包含11个Python脚本、6个MATLAB的m文件及1个Markdown说明文档压缩包仅16KB。Python脚本覆盖数据爬取、jieba分词、价格特征提取、回归预测等环节MATLAB脚本则用于曲线拟合与Pearson相关性分析便于对照理解不同工具在建模流程中的分工。已有309人学习/下载适合正在备赛或希望复现赛题结果的读者。代码从数据清洗、特征构建到ARIMA时序预测、机器学习补货预测形成完整链路可直接运行作为基线方案同时保留了针对商品波动规律的建模思路可作为后续优化模型、撰写数模论文的参考基础。1. 把国赛C题拆成两个可复现的预测问题2023年全国大学生数学建模竞赛C题的核心是“商超果蔬类商品的定价与补货决策”拿到题面后大多数队伍的第一反应是做“价格-销量”联合优化结果栽在数据清洗和特征构造上。这道题给的是2020年7月至2023年6月的单品销售流水要预测未来一周的批发价再决定每天各品类的补货量。直接用Excel透视表或暴力调包很难出结果原因是销售数据里存在折扣价、单位不统一、单品编码漂移和节假日效应任何一步处理不当都会让预测模型失效。适合这篇博文的读者是已经会用pandas处理表格、了解基本回归或时序概念但没完整跑过国赛C题、想用Python做一套“能交卷”的源代码方案的人。下面这6章会按照“数据拆分 → 价格预测 → 补货决策 → 校验与排错”的顺序推进。整套方案的落地路径是先按商品品类聚合销量再用梯度提升树做价格预测最后用成本-收益函数反推每日最优补货量。不会依赖LSTM这类调参成本高的模型保证在一台普通笔记本上能跑通。2. 数据预处理把销售流水变成建模可用的面板数据2.1 常见数据文件结构与读取方式国赛C题通常提供两到三个数据文件常见结构是销售流水明细表包含销售日期、单品编码、单品名称、销量kg、售价元/kg损耗率表包含单品编码与损耗率批发价格表给出部分日期的批发价。由于各年份题目附件命名略有差别先做一步通用加载。import pandas as pd import numpy as np # 根据实际文件名调整路径 sales pd.read_excel(附件1_销售流水.xlsx, sheet_name销售流水) loss pd.read_excel(附件2_损耗率.xlsx) wholesale pd.read_excel(附件3_批发价格.xlsx) print(sales.head(3)) print(sales.dtypes) print(数据范围:, sales[销售日期].min(), -, sales[销售日期].max())这段代码的意图是先把三张表读入内存确认列名和日期范围。参数说明sheet_name用于指定工作表如果文件里只有一个表可以不传dtypes检查销售价格是否被读成字符串这是国赛数据最常见的坑之一。若“售价”列显示为object说明Excel里混入了“促销价”之类的文本需要单独清洗。2.2 单位统一与价格异常值过滤商超果蔬经常存在“一斤”和“一公斤”混标的情况销售流水的用量单位不统一时必须先把所有销量换算成kg否则后面按品类聚合时数值会失真。处理思路是先看每个单品的销量分位数如果同一单品出现“1、2、5、10”这种明显倍差多半是500g与1kg混用。# 统一单位只保留kg若订单中最小销售单元疑似500g则乘2 def unify_unit(df): df df.copy() df[销量kg] df[销量].astype(float) # 当单品销量中位数小于0.5时视为按500g计量 med df.groupby(单品编码)[销量kg].transform(median) df.loc[med 0.5, 销量kg] df.loc[med 0.5, 销量kg] * 2 return df sales unify_unit(sales) sales[售价元每kg] sales[售价].astype(float) # 过滤异常售价单价为0、低于0.1或高于500元/kg的记录 sales sales[(sales[售价元每kg] 0.1) (sales[售价元每kg] 500)]逻辑说明先用transform(median)给每一行回填该单品的销量中位数如果中位数小于0.5kg认为该单品按“斤”计量。loc条件赋值把销量乘2。售价过滤的阈值要结合题目附件看一般绿叶菜很少有超过100元/kg的若数据里有大量几百元的单价先不要删可能是礼品装需要按单品单独判断。2.3 按“品类-日期”聚合的最大理由国赛C题最终要求给出的是六个蔬菜品类花叶类、花菜类、水生根茎类、茄果类、辣椒类、食用菌未来一周的补货总量而不是每个单品的补货量。如果按单品预测单品数量超过数百种会有大量稀疏序列模型根本学不出规律。所以预处理阶段就要把流水聚合成“品类-日期”面板。# 建立单品到品类的映射字段名按附件实际情况调整 sales[品类] sales[单品名称].str.extract(r([\u4e00-\u9fa5]类)) daily sales.groupby([销售日期, 品类]).agg( 总销量kg(销量kg, sum), 平均售价(售价元每kg, mean), 加权售价(售价元每kg, lambda x: np.average(x, weightssales.loc[x.index, 销量kg])) ).reset_index() daily[销售日期] pd.to_datetime(daily[销售日期]) daily daily.sort_values([品类, 销售日期]).reset_index(dropTrue) print(daily.groupby(品类).size().head())extract正则中[\u4e00-\u9fa5]类匹配“花叶类”“茄果类”这类中文后缀。聚合时加权售价以销量为权重比简单平均更贴近实际成交价。sort_values保证后续构造滞后特征时不会出现时间乱序。3. 价格预测核心特征工程与LightGBM价格预测模型3.1 为什么选LightGBM而不是ARIMA或Prophet果蔬日度批发价序列存在明显的周季节性和节假日脉冲同时受近期销量影响。ARIMA需要人工定阶且难以加入外生变量Prophet对趋势突变敏感一旦有促销日就会过度拟合。用LightGBM做价格预测的本质是把“某日期-品类-过去7天销量-过去7天价格-星期几”映射到未来批发价把时间序列问题改造成监督回归问题。这种方法在国赛论文里也很容易解释清楚——特征是显式的不需要黑箱。如果题目要求预测的是批发价而不仅是售价还需要把批发价序列按同样方式聚合。有一种常见做法是用历史批发价和销售数据构造回归特征然后预测未来7天批发价。代码里把target设为加权售价如果附件里有批发价字段直接替换。3.2 构造时间特征与滞后特征滞后特征必须用“截断”方式生成否则会把未来信息泄漏进训练集。这里用groupby shift为每个品类单独生成滞后1天、2天、3天、7天的价格和销量。def make_features(df, lags[1, 2, 3, 7]): df df.copy() df[星期几] df[销售日期].dt.dayofweek df[月] df[销售日期].dt.month df[日] df[销售日期].dt.day # 是否临近节假日粗略用当月是否含有1号或15号 df[月初] (df[日] 3).astype(int) df[月中] ((df[日] 14) (df[日] 17)).astype(int) for col in [加权售价, 总销量kg]: for lag in lags: df[f{col}_lag{lag}] ( df.groupby(品类)[col].shift(lag) ) # 滚动统计过去7天均值 df[售价_roll7_mean] ( df.groupby(品类)[加权售价] .transform(lambda x: x.rolling(7, min_periods3).mean()) ) # 删除有缺失值的行避免模型学NaN df df.dropna().reset_index(dropTrue) return df feature_df make_features(daily) feature_df.head()参数说明shift(lag)会把每个品类内部的数据按时间向下平移得到“昨天价格”“前天价格”。rolling(7, min_periods3)表示窗口为7、至少3个非空值才计算这样早期样本不会被丢弃太多。月初与月中是粗糙的促销代理特征如果题目附件里能识别出“促销日”字段优先用真实促销标记替换这两个规则特征。3.3 训练集切分与模型训练时间序列的交叉验证不能用随机打乱。这里强行按时间切分把2022年7月之前作为训练集2022年7月到2023年6月作为验证集。也可以用TimeSeriesSplit但国赛数据跨度是三年简单留出最后三个月做验证就够了。import lightgbm as lgb from sklearn.metrics import mean_absolute_error features [c for c in feature_df.columns if c not in [销售日期, 品类, 加权售价]] X feature_df[features] y feature_df[加权售价] train_mask feature_df[销售日期] 2023-04-01 val_mask feature_df[销售日期] 2023-04-01 model lgb.LGBMRegressor( n_estimators600, learning_rate0.05, num_leaves31, max_depth6, subsample0.8, colsample_bytree0.8, random_state42 ) model.fit( X[train_mask], y[train_mask], eval_set[(X[val_mask], y[val_mask])], callbacks[lgb.early_stopping(stopping_rounds50)], feature_namefeatures ) val_pred model.predict(X[val_mask]) val_mae mean_absolute_error(y[val_mask], val_pred) print(f验证集MAE: {val_mae:.3f} 元/kg)LGBMRegressor的n_estimators设置较大配合early_stopping在验证集上自动截断避免过拟合。subsample和colsample_bytree都设0.8让每棵树只用80%样本和80%特征增加随机性。如果验证集MAE超过2元/kg优先检查特征里是否包含未来的价格信息。3.4 未来7天价格预测的迭代策略要预测“未来7天”不能只用截至昨天的特征一次预测7个点因为滞后特征会错位。常见做法是递归多步预测用T1预测值填充到特征中再去预测T2。代码里维护一个临时DataFrame每轮预测后更新“昨日价格”。def forecast_7days(model, last_df, date_range, features): # last_df: 最近一天的特征行品类唯一 temp last_df.copy() results [] for i, date in enumerate(date_range): temp[销售日期] date temp[星期几] date.weekday() temp[月] date.month temp[日] date.day # 更新滞后特征把上一轮预测值填入lag1 temp[加权售价_lag1] temp.get(最新预测价, temp[加权售价_lag1]) pred model.predict(temp[features])[0] temp[最新预测价] pred results.append({日期: date, 预测售价: pred}) return pd.DataFrame(results) future_dates pd.date_range(2023-07-01, 2023-07-07) last_row feature_df[feature_df[销售日期] feature_df[销售日期].max()].iloc[0] forecast_px forecast_7days(model, last_row, future_dates, features) forecast_px这段递归预测有一个实际风险用预测值替代真值会累积误差因此通常只在未来7天内有效。如果题目要求预测批发价注意把目标列从“加权售价”换成批发价后滞后特征也要对应换成“批发价_lag1”不要混用。4. 补货预测模型用价格弹性与损耗率计算最优补货量4.1 补货决策的数学化表达补货预测本质上是“给定未来售价和需求预测决定今日进货量”。商超利润是收入减进货成本再减损耗成本。若进货量大于需求量多余部分按损耗率报废若进货量小于需求量就损失潜在收益。因此最优补货量是平衡缺货损失和损耗损失的解。国赛C题的创新点通常在于不直接固定损耗率而是让损耗率随补货量升高而升高——进得越多卖不完的概率越大。用符号表达就是设进货量为Q预测需求为D售价为p批发价为c损耗率为r(Q)。期望利润可以写成利润(Q) p * min(Q, D) - c * Q - p * r(Q) * max(Q - D, 0)这个式子不能直接用线性规划求整数解因为r(Q)与Q相关。常见做法是枚举进货量Q从0到预测需求D的1.4倍用蒙特卡洛或经验分布模拟需求不确定性找出期望利润最大的Q。4.2 用历史分位数构造需求情景相比只给一个期望预测值更稳妥的做法是预测出需求的分位数然后代入利润函数。这里用LightGBM的分位数回归实现。# 对销量构造分位数回归模型 q_model lgb.LGBMRegressor( objectivequantile, alpha0.3, n_estimators400, learning_rate0.05, num_leaves31, random_state42 ) # 同样用feature_df但目标换成总销量kg y_sales feature_df[总销量kg] q_model.fit(X[train_mask], y_sales[train_mask]) demand_low q_model.predict(X[val_mask]) # 30%分位保守需求逻辑说明objectivequantile让模型优化分位数损失alpha0.3表示预测的是30%分位即保守估计。用同样的特征预测销量是因为销量受价格与季节影响滞后特征已经包含这些信息。要得到中位数预测再把alpha调成0.5。4.3 枚举补货量与利润模拟接下来用预测价格和需求分位数做决策。为了方便演示这里用中位数需求当作期望值并假设噪声服从以中位数为均值、标准差为历史残差标准差的正态分布。from scipy.stats import norm # 历史残差标准差用验证集预测误差近似 residuals y_sales[val_mask] - q_model.predict(X[val_mask]) demand_std residuals.std() def simulate_profit(Q, p_pred, c_pred, demand_mean, demand_std, loss_rate, n5000): np.random.seed(0) d_samples np.random.normal(demand_mean, demand_std, n) d_samples np.clip(d_samples, 0, None) sold np.minimum(Q, d_samples) excess np.maximum(Q - d_samples, 0) revenue p_pred * sold cost c_pred * Q waste_loss p_pred * loss_rate * excess profit revenue - cost - waste_loss return profit.mean() # 示例对7月1日某一品类做决策 best_Q 0 best_profit -np.inf for Q in range(10, 200, 5): profit simulate_profit( QQ, p_predforecast_px[预测售价].iloc[0], c_pred5.0, # 批发价从附件读取 demand_mean100, # 中位数需求预测 demand_stddemand_std, loss_rate0.08 ) if profit best_profit: best_profit profit best_Q Q print(f最优补货量: {best_Q} (kg)期望利润: {best_profit:.2f} 元)np.random.normal生成5000个需求模拟样本用np.minimum计算实际售出量。Q从10到200步长5扫描颗粒度可以根据实际销量量纲调整。valuation输入亏损率0.08只是示例实际应按品类从损耗表中读取。4.4 按品类并行计算补货表如果只对7月1日单日做决策意义不大。国赛C题要求未来7天每天每品类补货最稳妥的方法是每天重复一次“预测价格 → 预测需求 → 枚举Q”的流程。为节省篇幅这里按品类循环把结果汇总成表格。results [] for cat in feature_df[品类].unique(): cat_df feature_df[feature_df[品类] cat] cat_X cat_df[features].copy() cat_demand model.predict(cat_X) cat_std residuals.std() for date in future_dates: # 用最新一条特征预测需求 latest cat_df.iloc[-1] demand_mean q_model.predict([latest[features]])[0] p_pred forecast_px[forecast_px[日期] date][预测售价].values[0] # 省略枚举代码直接封装到函数里 Q best_Q_by_profit(pred, c, demand_mean, cat_std) results.append({品类: cat, 日期: date, 补货量: Q}) pd.DataFrame(results).to_excel(补货方案.xlsx, indexFalse)实际调用时应该把4.3的枚举逻辑写成函数并读入题目附件的批发价与损耗率。注意批发价在7月1日还未发生这里用3.4得到的预测批发价作为c_pred的替代。5. 预测校验与调参时间序列交叉验证和残差诊断5.1 滚动验证而不是随机K折国赛论文里最容易被打分老师质疑的就是验证方式。随机K折会把2021年的数据放进训练集2023年数据放进验证集表面指标很好但实际部署时效果暴跌。正确做法是时间顺序滚动用前2年训练、后1年验证然后重复3次。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits3) scores [] for train_idx, val_idx in tscv.split(X): model_split lgb.LGBMRegressor(n_estimators300, learning_rate0.05) model_split.fit(X.iloc[train_idx], y.iloc[train_idx]) pred_split model_split.predict(X.iloc[val_idx]) scores.append(mean_absolute_error(y.iloc[val_idx], pred_split)) print(滚动验证MAE: , [round(s, 3) for s in scores])TimeSeriesSplit默认按连续时间块切分第一折训练集最短最后一折最长。该方法比较适合观察模型在“不同年份数据规模下”的稳定性。如果最后一折MAE明显高于前几折说明模型在近段时间分布偏移下表现变差需要加入更多近期样本权重或让模型感知“年份”特征。5.2 残差检查用验证集残差看是否存在周期性遗漏。把残差按星期几聚合并画图。如果周日的残差总是正预测低于实际说明模型没学到周末促销特征。val_df feature_df[val_mask].copy() val_df[残差] y[val_mask] - val_pred resid_by_weekday val_df.groupby(星期几)[残差].mean() print(resid_by_weekday)groupby(星期几)平均后如果某个值偏离0超过0.2元/kg就该加入“是否周末”或“是否促销日”特征。如果残差还随时间呈锯齿状考虑加入上个月同品类的平均价格作为特征。5.3 特征重要性检查LightGBM自带特征重要性可用于检查是否误把“当期价格”混入特征因为当期价格与未来价格不构成因果关系但在数据预处理时容易把当天的“平均售价”也带进特征矩阵。训练后输出重要性排名剔除排名靠后且含义上属于“同期数据”的列。importance pd.DataFrame({ feature: features, gain: model.feature_importances_ }).sort_values(gain, ascendingFalse) print(importance.head(10))feature_importances_返回的是分裂次数或信息增益。一般加权售价_lag1、总销量kg_lag1、星期几应该排在前列。如果月初排在第一位那是数据太少导致的虚假相关建议收集更多年份再加判断。6. 落地提效把整个流程封装成可复用的Python脚本6.1 脚本化运行与参数配置国赛时间紧张反复改参数容易出错。建议把所有路径、预测天数、损失率、价格上限都集中放到一个config.py里主程序只读取配置。# 不能直接运行的伪代码实际把config.py放在同目录 python main.py --config config.yaml --mode forecast常见做法是不额外引入yaml直接在一个dict中声明参数CONFIG { data_paths: { sales: 附件1_销售流水.xlsx, loss: 附件2_损耗率.xlsx, wholesale: 附件3_批发价格.xlsx }, forecast_dates: [2023-07-01, 2023-07-07], price_target: 加权售价, demand_target: 总销量kg, lags: [1, 2, 3, 7], loss_rate_default: 0.08, quantile: 0.3, model_params: { n_estimators: 600, learning_rate: 0.05, num_leaves: 31, max_depth: 6 } }把特征构造、模型训练、预测、决策四个函数拆到不同模块里方便在Jupyter里调试、在终端里批量跑。参数说明quantile决定需求预测是偏保守还是偏激进如果商超对缺货惩罚更高调成0.2让预测需求更低补货量相应减少。6.2 输出交付物与可视化检查最终提交论文时除了模型还需要可视化曲线。用matplotlib绘制“预测价格与实际价格对比”并保存PNG帮助论文排版。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] fig, ax plt.subplots(figsize(10, 4)) ax.plot(feature_df[销售日期], feature_df[加权售价], label实际售价, linewidth1) ax.plot(future_dates, forecast_px[预测售价], label预测售价, markero, colorred) ax.set_title(2023年7月1-7日价格预测对比) ax.legend() plt.savefig(price_forecast.png, dpi150)此处的forecast_px只对某一品类有效画图前应筛选特定品类。如果7月1日至7日的真实价格在竞赛结束后才公布可以把预测结果与真实值重叠画图突出模型效果。6.3 验证补货结果的一种可行做法补货量没有真实“最优值”可直接对照但可以用历史数据进行回测把2023年7月第一周的真实销量视为“需求真值”计算在该补货量下的实际利润再与“用预测完美信息做补货”的利润对比。两者比值就是决策效率。如果决策效率低于85%说明需求分位数选择过保守或过激进调整quantile后重跑一遍。这个回测结果放入论文的“模型检验”一节比只贴MAE更有说服力。def backtest(Q_df, actual_sales_df, price_df, loss_df): merged Q_df.merge(actual_sales_df, on[日期, 品类]) merged merged.merge(price_df, on[日期, 品类]) merged[利润] ( merged[售价] * np.minimum(merged[补货量], merged[实际销量]) - merged[批发价] * merged[补货量] - merged[售价] * merged[损耗率] * np.maximum(merged[补货量] - merged[实际销量], 0) ) return merged[利润].sum()np.minimum和np.maximum是向量化操作避免用for循环逐行计算。loss_df需要按品类合并损耗率没有品类损耗率时用CONFIG里的默认值。backtest返回的利润总和越大补货模型越接近理想决策。本文还有配套的精品资源点击获取
返回列表