简介:面向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, weights=sales.loc[x.index, "销量kg"])) ).reset_index() daily["销售日期"] = pd.to_datetime(daily["销售日期"]) daily = daily.sort_values(["品类", "销售日期"]).reset_index(drop=True) 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_periods=3).mean()) ) # 删除有缺失值的行,避免模型学NaN df = df.dropna().reset_index(drop=True) return df feature_df = make_features(daily) feature_df.head()参数说明:shift(lag)会把每个品类内部的数据按时间向下平移,得到“昨天价格”“前天价格”。rolling(7, min_periods=3)表示窗口为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_estimators=600, learning_rate=0.05, num_leaves=31, max_depth=6, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model.fit( X[train_mask], y[train_mask], eval_set=[(X[val_mask], y[val_mask])], callbacks=[lgb.early_stopping(stopping_rounds=50)], feature_name=features ) 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个点,因为滞后特征会错位。常见做法是递归多步预测:用T+1预测值填充到特征中,再去预测T+2。代码里维护一个临时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( objective="quantile", alpha=0.3, n_estimators=400, learning_rate=0.05, num_leaves=31, random_state=42 ) # 同样用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%分位(保守需求)逻辑说明:objective="quantile"让模型优化分位数损失,alpha=0.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, n=5000): 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( Q=Q, p_pred=forecast_px["预测售价"].iloc[0], c_pred=5.0, # 批发价,从附件读取 demand_mean=100, # 中位数需求预测 demand_std=demand_std, loss_rate=0.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", index=False)实际调用时应该把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_splits=3) scores = [] for train_idx, val_idx in tscv.split(X): model_split = lgb.LGBMRegressor(n_estimators=300, learning_rate=0.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", ascending=False) 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="实际售价", linewidth=1) ax.plot(future_dates, forecast_px["预测售价"], label="预测售价", marker="o", color="red") ax.set_title("2023年7月1-7日价格预测对比") ax.legend() plt.savefig("price_forecast.png", dpi=150)此处的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返回的利润总和越大,补货模型越接近理想决策。
本文还有配套的精品资源,点击获取