超市建模全流程:报童模型与Holt-Winters补货决策实战 📅 发布时间:2026/9/14 15:30:19 👁 浏览次数: 简介一份围绕2004年全国大学生数学建模竞赛A题“奥运会临时超市网点设计”的MATLAB解题程序包面向数学建模学习者与备赛学生。压缩包内含1个m文件大小仅1KB属于精简源码集中呈现从数据处理、模型构建到优化求解的完整流程。资源聚焦超市选址建模问题核心知识点涵盖需求预测、区位分析、最优网点数量与位置求解以及灵敏度检验等环节。代码量虽小但涉及线性规划、整数规划或启发式算法调用的实现思路适合进行单步调试和复现。已有154人学习说明该样例对建模入门者具有一定参考价值。通过研读这段程序可快速理解如何将实际问题抽象为决策变量与约束条件并借助MATLAB调用优化求解器输出结果。对于想掌握竞赛真题建模套路、提升MATLAB编程能力的同学这是一份便于拆解的浓缩实例。压缩包内文件安排紧凑重点在于解题框架与算法骨架便于按步骤追踪建模逻辑。1. Sams-.rar 里那个超市建模问题解压前先想清楚要交什么拿到像 Sams-.rar 这样一个带下划线尾巴的压缩包第一反应不必是双击解压而是先判断这类打包方式在建模题里的固定套路。数学建模比赛、课程设计、华为杯或者 2026 年新赛季的模拟题凡是涉及超市、便利店、连锁零售的“给 rar 包 让你做需求预测/库存决策/货架优化”已经成为默认开场。压缩包里通常是销售明细、商品目录、一个简短 readme运气好还有历史库存表。多数人卡在 rar 解压之后的那一步字段名不一致、日期格式混乱、销量里混着退货单。这些和数据建模直接相关的细节决定模型能不能站住。在这篇里我会按拿到压缩包后的实际顺序走一遍从命令行解压、字段对齐到报童模型和 Holt-Winters 需求预测代码都能拿去改。读者如果正在准备 2026 年的国赛或华为杯这套流程可以直接当作团队的数据预处理基线来用。2. 解析 Sams-.rar 包内的数据底子2.1 用命令行验证 rar 完整性与解压我通常不用解压软件双击打开压缩包而是先用命令行列出清单。原因是建模题里的数据文件可能藏在多级子目录图形界面只能看到展开后的文件名看不到压缩包内部的相对路径而路径信息往往暗示数据版本比如sams_data/v2/里的字段会比sams_data/v1/多一列。先列出清单再测试完整性最后解压顺序不能乱。# 列出压缩包内的所有文件名和路径 unrar l Sams-.rar # 测试每个文件是否完整输出 CRC 校验结果 unrar t Sams-.rar # 按原目录结构解压到 sams_data 目录 unrar x Sams-.rar ./sams_data/三个参数分别对应 list、test、eXtract。t会把压缩包内每个文件解到内存并比对校验值过程中如果文件被截断命令会在文件名旁边标出错误并返回非零退出码在自动化流程里可以靠echo $?判断要不要继续。x保留原目录结构e是把所有文件平铺到同一个目录。对比来看竞赛环境建议用x因为题目说明和数据往往不在同一层。如果环境里没有 unrar用 7-Zip 的命令行是等价的7z t Sams-.rar 7z x Sams-.rar -osams_data代表测试通过才执行解压避免把一个已经损坏的 rar 包解出一半数据。注意 7z 的-o参数和输出目录之间不能有空格写成-o./sams_data或-osams_data都可以这块写错是命令行解压最常见的报错点。Windows 下如果只有 WinRAR参数写法略有不同 C:\Program Files\WinRAR\WinRAR.exe x Sams-.rar sams_data\另外文件名是Sams-.rar_而不是.rar常见原因是网盘或邮箱系统为了阻断不安全扩展名在文件末尾补了一个下划线。直接让unrar和7z读取这个带下划线的文件名通常没问题不要手动把它改成.zip那样只会得到一个解不开的压缩包。若解压出来的 CSV 是乱码问题多半在编码后面的清洗环节会处理。2.2 解压后的字段对齐与业务表结构rar 解压完成后先浏览一遍目录结构。最常见的文件分组是一张商品信息表SKU、类目、进价、售价、保质期一张销售流水表门店、日期、SKU、数量、金额以及可选的库存快照表。三个表的字段命名风格很随意中文、拼音、英文缩写混合出现都有可能。我更建议先画一张字段映射表再开始写代码否则同一个代码在不同命名下会反复返工。数据语义常见字段名类型在建模中的角色商品IDSKU_ID, 商品编号, sku, 货号string分组主键交易时间日期, 交易日期, order_datedatetime时序索引销量数量, 销量, qty, sales_qtynumeric需求变量 D成交金额金额, 销售额, amountnumeric校核模型利润进价进价, cost, 成本价numeric成本参数 c售价售价, price, 零售价numeric价格参数 p库存库存量, stock, 现存量numeric补货约束这张表的核心作用是让同一个代码对不同命名都能跑通。我处理这类题目时第一步永远是标准化列名而不是直接建模因为后面所有代码都依赖列名统一先做能少踩很多坑。import pandas as pd rename_map { 商品编号: sku, SKU_ID: sku, 货号: sku, 交易日期: date, 销售日期: date, 日期: date, 数量: qty, 销量: qty, 件数: qty, 销售额: amount, 成交金额: amount, 进价: cost, 成本价: cost, 售价: price, 零售价: price, } df pd.read_csv(sams_data/sales.csv) df df.rename(columnsrename_map) # 检查核心字段是否存在 required [sku, date, qty] missing set(required) - set(df.columns) if missing: print(缺少字段:, missing)代码先把中文表头归一化成英文小写再用required集合检查核心字段是否存在。如果缺失字段就回到 2.1 重新检查压缩包内是否还有另一份文件而不是硬着头皮往下建模。把rename_map放在 Python 文件顶部后续换一份数据时只需要修改映射表不用改模型代码。2.3 编码兼容与销售数据清洗Excel 导出的 CSV 文件在压缩包内往往是 GBK 编码而 pandas 默认按 UTF-8 读取因此 rar 解压后最常见的异常就是UnicodeDecodeError或中文列名变乱码。解决办法是给read_csv加一个编码候选列表按优先级逐个尝试。def read_csv_lenient(path): for enc in (utf-8, gbk, gb18030): try: return pd.read_csv(path, encodingenc) except UnicodeDecodeError: continue raise ValueError(f无法解码: {path}) df read_csv_lenient(sams_data/sales.csv) df[date] pd.to_datetime(df[date], errorscoerce) df[qty] pd.to_numeric(df[qty], errorscoerce) # 负数销量通常是退货单先归零 df[qty] df[qty].fillna(0).clip(lower0) # 重复交易按保留最后一笔处理 df df.drop_duplicates(subset[date, sku, store], keeplast)errorscoerce会把解析失败的值置为NaT清洗后要检查df[date].isna().sum()的比例。如果超过 1%很可能是 Excel 日期序列号而不是标准日期这时改用pd.to_datetime(df[date], unitD, origin1899-12-30)再试一次。drop_duplicates里的store列如果原始数据没有就改成subset[date, sku]避免把所有门店的同一天销售记录错误删除。这块清洗逻辑最好单独存成一个prepare.py后续每次改动模型都从清洗后的文件读取不要重复执行原始 read_csv保证结果可复现。3. 超市建模的目标函数与报童模型设计3.1 把“预测准”换成“利润最大”的目标函数数学建模题答得好不好评阅人看的第一个东西未必是预测精度而是模型目标和题目要求是否对齐。超市建模很少只问“下个月卖多少件”多数会追问“应该进多少、什么时候补”如果答案只是一个预测值就少了决策环节。正确的做法是先定义一个经济目标以单个 SKU 单周期补货为例设需求为随机变量 D订购量 q单位进价 c、售价 p、残值 s。销售周期结束后的利润是售价乘以实际卖掉的量减去进货成本再加上剩余量乘回收价。π(q) p·E[min(D,q)] − c·q s·E[(q−D)]因为 D 是随机变量这里取期望利润。对该式求导并令结果为零得到最优订货条件。P(D ≤ q*) (p − c) / (p − s)这个式子就是超市建模题里出现率最高的“报童模型”最优订货量定在需求分布的一个分位数上分位数等于毛利占价格与残值差的比例。直观解释是进货成本高、赚头小的时候不该多备货残值接近 0、过期只能扔的时候分母变大分位点变小进货量自然下降。我建议把(p−c)/(p−s)作为服务水平写进摘要评阅人一眼能看出模型有依据。3.2 从历史需求估计最优订货量得到 q* 之前先要估计需求分布。常见做法是取历史销量的经验分布或者假设为正态分布。两者在数据量少时结果差别很大所以我会同时算两个结果放进论文的模型对比表里。import numpy as np from scipy.stats import norm # 假设 sku_series 已由销售明细聚合成日销量序列见第 4 章 samples sku_series.tail(14).values # 方法一经验分布分位数 service_level (price - cost) / (price - salvage) q_star_emp np.quantile(samples, service_level) # 方法二正态假设下的分位数 mu, sigma samples.mean(), samples.std(ddof1) q_star_norm norm.ppf(service_level, locmu, scalesigma) print(经验分位数法:, q_star_emp) print(正态假设法:, q_star_norm)np.quantile不依赖分布假设但样本只有 14 天时尾部估计很不稳定正态假设会在需求右偏时低估高分位点。一个折中做法是先看样本偏度如果偏度绝对值大于 1就采用经验分位数否则用正态法。两个结果之间的差值应当记录进敏感性分析如果差值超过 20%说明这个 SKU 的需求分布不稳定单靠历史分位数下结论很危险。3.3 服务水平、安全库存与补货触发点如果题目给的是“缺货率不能超过 2%”这样的硬性约束就需要用服务水平计算安全库存和补货点。设定服务水平对应的标准正态分位数为 z则补货点等于提前期需求均值加安全库存。R μ·LT z·σ_daily·√LT其中 LT 是供应商备货提前期μ 是日需求量均值σ_daily 是日需求波动。95% 服务水平对应 z1.6598% 对应 z2.0599% 对应 z2.33。这三档差距看似不大落到库存金额上可能会差出三成备货量所以参数表里必须写明选的是哪一档。服务水平z 值安全库存相对倍数适用业务90%1.28基准价值高、残值低的进口商品95%1.651.29 倍常规日用百货99%2.331.82 倍牛奶、面包等缺货影响大的品类这里把安全库存写成以 90% 为基准的倍数关系是为了给评阅人一个直观印象。更重要的是σ_daily 最好来自需求预测模型的残差而不是原始销量的标准差原始销量里包含趋势和季节成分直接用来求波动会系统性放大安全库存导致仓库里积压一堆不属于正常周转的货物。4. 用 Python 复现超市建模求解流程4.1 将销售额聚合成日频时序模型输入不需要行级别的流水明细先按日期和 SKU 维度做透视表。pivot_table的aggfuncsum把同一天多次购买合并fillna(0)将没有销售的日期补零形成连续时间轴。如果原始数据只覆盖工作日而没有周末补零是必须的否则时间序列会在statsmodels里报缺失索引错误。pivot df.pivot_table( indexdate, columnssku, valuesqty, aggfuncsum ).fillna(0).astype(float) # 按总销量取 top3 品类作为重点对象 top3 pivot.sum().nlargest(3).index selected pivot[top3] # 保证索引是 datetime64且按时间升序 selected selected.sort_index() print(selected.tail())透视表生成后用selected.isna().sum().sum()检查是否还有空值。若某个 SKU 在最近一周完全无销售需要先核实是停售还是数据缺失停售的 SKU 不应参与补货决策否则会把需求分布的中心位置抬得虚高。4.2 用 Holt-Winters 预测未来 14 天需求超市销售通常同时带趋势和周期性直接线性回归效果一般。业界处理这类短期预测更常用指数平滑我默认用 Holt-Winters 三参数模型因为它同时建模水平、趋势和季节分量而且statsmodels实现里参数可以自动寻优。from statsmodels.tsa.holtwinters import ExponentialSmoothing def forecast_sku(series, steps14, seasonal_periods7): model ExponentialSmoothing( series, trendadd, seasonaladd, seasonal_periodsseasonal_periods, initialization_methodestimated ).fit(optimizedTrue) return model.forecast(steps) fc forecast_sku(selected[top3[0]], steps14) print(fc.round(2))trendadd表示线性趋势seasonaladd表示加法季节性seasonal_periods7对应超市以周为周期的零售数据。optimizedTrue会自动估计超参数但要注意它在序列长度不足两个完整季节周期时容易失效如果数据只有 30 天就把seasonal_periods缩小或者直接退化为带趋势的简单指数平滑后者在短序列上通常更稳定。4.3 补货参数计算与下单建议预测给出的是未来 14 天的期望值补货决策需要的是目标库存水位。我按定期盘点补货策略来做每个盘点周期检查一次库存低于补货点就下单补到目标水位。代码里先重新训练一次模型拿到残差标准差再代入服务水平公式。import numpy as np # 重新训练一次拿到残差 model ExponentialSmoothing( selected[top3[0]], trendadd, seasonaladd, seasonal_periods7, initialization_methodestimated ).fit(optimizedTrue) fc model.forecast(14) resid_std model.resid.std() # 日需求波动的估计 LT 2 # 供应商提前期天 review 4 # 盘点周期天 mu_daily fc.mean() z 1.65 # 95% 服务水平 safety_stock z * resid_std * np.sqrt(LT review) target_inventory mu_daily * (LT review) safety_stock on_hand 80 # 当前库存 in_transit 20 # 在途库存 order max(0, int(np.ceil(target_inventory - on_hand - in_transit))) print(f目标库存: {target_inventory:.2f}) print(f安全库存: {safety_stock:.2f}) print(f建议下单量: {order})LT review表示从下单到货物可售所经历的时间跨度它决定目标库存要覆盖多长一段需求。on_hand和in_transit分别对应仓库现有量和运输途中量如果手头数据没有在途库存默认置 0 后要在论文里注明这个假设。最后用max(0, ...)保证不会输出负数否则会在采购系统里生成一张无效的负向订单。4.4 参数调优与敏感性分析模型调参时我一般拿三个量做交叉验证服务水平、提前期、预测步长。它们对结果的影响互不相同放在同一张表里能看出组合效应。参数基准值变化范围主要影响服务水平0.950.90 ~ 0.99安全库存和缺货率同步上升提前期 LT21 ~ 4目标库存线性增加安全库存按根号增加预测步长77 / 14 / 30步长过长时 Holt-Winters 误差快速放大实际操作中我把服务水平从 0.90 到 0.99 每隔 0.01 跑一遍每次记录安全库存和预测的缺货水平把结果连成一条曲线。这样做不但能解释为什么选 0.95也能证明结论不是凑参数凑出来的是真实存在的权衡关系。5. 收尾验证MAPE、回看检验和建模论文的呈现技巧5.1 用最后 7 天做回看校验模型建完先别急着出结论我习惯留出最后 7 天做回看测试。做法是取前面的 70% 数据训练后 14 天做验证其中最后 7 天单独作为最终评估。如果回看误差过大直接换模型而不是靠调参数掩盖问题。from sklearn.metrics import mean_absolute_percentage_error series selected[top3[0]] train series.iloc[:-7] test series.iloc[-7:] model ExponentialSmoothing( train, trendadd, seasonaladd, seasonal_periods7, initialization_methodestimated ).fit(optimizedTrue) pred model.forecast(len(test)) mape mean_absolute_percentage_error(test, pred) print(fMAPE: {mape:.2%})MAPE 是百分比误差但实际销量为零的日子会让它直接爆炸因此回看时先过滤掉实际值为 0 的日期或者改用除以序列最大值的归一化误差。我建议 MAPE 和 RMSE 都列出来RMSE 衡量绝对水平MAPE 衡量相对误差评阅人能更快判断模型在不同量级商品上的表现差异。5.2 建模论文里参数说明的写法一篇数学建模优秀论文评委最看重的往往不是代码而是参数表的完整性和边界条件声明。我通常会在论文里放三样东西第一一个“模型假设”小节把提前期、残值、服务水平默认值列清楚第二一张“参数敏感性表”展示服务水平 0.90 到 0.99 对应的安全库存金额第三一张“模型对比表”把简单移动平均、Holt-Winters、线性回归在同一个回看窗口的 MAPE 列在一起。即使 Holt-Winters 不是每类商品都最优这张对比表也能证明模型选择是有依据的而不是照抄模板。5.3 模型参数和 rar 包都留版本号建模题的数据经常修订压缩包解压后文件名可能从v1升到v2。我会把每次解压的数据目录按版本号保存同时把模型输出另存为带日期的 json 文件避免回看时分不清哪份结果来自哪批数据。这个习惯在团队协作里尤其重要能省掉一半以上反复确认的沟通成本。处理 Sams-.rar 这类问题时我最后执行的一条命令永远是unrar t Sams-.rar再比对一次解压目录的文件数确保交付的每份结果都能回源到原始数据。本文还有配套的精品资源点击获取