Python实战:用数据科学优化多级库存与供应链决策
1. 这不是“库存预测”,而是一场用数据重新定义供应链决策的实战
你手头有一份销售流水、一张仓库出入库记录、几份供应商交货周期表,还有一堆被业务部门反复追问的“为什么又断货”“为什么积压这么多”——这些不是待处理的Excel表格,而是埋在日常运营里的金矿。Inventory Optimization with Data Science: Hands-On Tutorial with Python这个标题里,“Optimization”是动词,不是名词;它不指向一个静态模型,而是一套可落地、可迭代、能直接影响采购金额、仓储成本和客户满意度的决策闭环。我带团队做过17个快消、制造、电商类客户的库存优化项目,最深的体会是:90%的所谓“库存问题”,根源不在数据不准,而在决策逻辑没被量化——比如“安全库存设3天销量”这种经验法则,背后没有需求波动率、补货提前期变异系数、服务水平目标的数学支撑;再比如“滞销品清仓”常变成拍脑袋打折,而不是基于剩余生命周期价值(Remaining Lifetime Value)和清仓边际收益的动态定价推演。这篇教程不讲抽象理论,只拆解我在真实产线部署过、经受住双十一大促流量冲击、让客户单仓年化持有成本下降23%的Python实现路径。你会看到如何把“缺货率要低于5%”这种业务语言,翻译成scipy.optimize.minimize的目标函数约束;如何用statsmodels.tsa.statespace.SARIMAX捕捉促销带来的脉冲式需求跳跃,而不是用平滑的指数加权平均去拟合;更关键的是,我会告诉你哪些步骤必须手工校验(比如需求分布偏态检验),哪些环节可以交给自动化脚本(比如ABC-XYZ交叉分类的批量重算)。无论你是刚学完Pandas的数据分析新人,还是管着30人供应链团队的总监,只要愿意打开Jupyter Notebook跟着敲下第一行import numpy as np,接下来的每一步,都是你在真实世界里能立刻用上的武器。
2. 整体设计思路:为什么放弃“端到端黑箱模型”,选择分层可解释架构
2.1 核心矛盾:业务信任 vs 模型精度的不可调和性
很多初学者一上来就想上LSTM或Transformer做“高精度需求预测”,结果模型在测试集上RMSE降低12%,上线后采购员却拒绝采纳建议——因为没人能说清“为什么明天要多订87件”。我在某家电企业部署时就吃过这个亏:用Prophet拟合出一条完美平滑的需求曲线,但当销售总监指着6月18日那个异常峰值问“这32%的跳升依据是什么”,算法团队只能翻出原始促销排期表,而业务方反问:“那为什么隔壁型号同期只涨15%?模型有没有考虑竞品价格变动?” 这暴露了根本矛盾:供应链决策是责任制的,每个采购订单背后都有KPI考核,决策者必须为结果担责,因此他们需要的不是‘预测值’,而是‘决策依据链’。所以本教程采用三层解耦架构:需求建模层 → 库存策略层 → 决策执行层。每一层输出都可独立验证、可人工干预、可归因溯源。比如需求层输出不仅是点预测,还包括95%置信区间、各驱动因子贡献度(促销/季节/竞品)、以及异常点诊断报告;策略层不直接输出订货量,而是给出安全库存公式中每个参数的取值依据(如提前期标准差=历史20次到货时间的标准差,而非默认设为0);执行层则生成带优先级标记的采购清单(P0:48小时内必到货防断货;P1:7天内补充;P2:长周期备货),让采购员一眼抓住重点。
2.2 工具选型逻辑:为什么用Statsmodels+SciPy,而不是PyTorch或AutoML
有人会问:“现在有那么多AutoML工具,为什么还要手写SARIMAX和优化器?” 答案很现实:AutoML解决的是‘能不能预测’,而供应链要解决的是‘敢不敢下单’。我们对比过H2O.ai、AutoGluon在某母婴品牌SKU上的表现:AutoGluon的MAPE比手动SARIMAX低0.8个百分点,但它的特征重要性排序显示“用户评论情感分”权重最高——这在业务上完全不可接受,因为评论数据延迟3天且噪声极大,不能作为采购依据。而Statsmodels的优势在于:
- 参数可审计:
SARIMAX(order=(1,1,1), seasonal_order=(1,1,1,7))的每个数字都对应明确的业务含义(如seasonal_order[3]=7代表周周期,直接对应门店每周补货节奏); - 残差可诊断:
model.resid.plot()能直观看出模型是否遗漏了促销效应(残差在活动日集中为正); - 约束可嵌入:在
scipy.optimize.minimize中,我们可以硬编码“单次订货量不得低于最小起订量MOQ”“总预算不超过50万元”等业务强约束,而AutoML的优化目标仅限于误差指标。
提示:这不是技术保守,而是责任倒逼。当你在采购系统里点击“确认下单”按钮时,系统弹窗提示“该建议基于历史数据,未考虑明日新品发布会影响”,这种透明度比0.5%的精度提升重要100倍。
2.3 场景适配:为什么聚焦“多层级库存协同”,而非单点优化
标题中的“Inventory Optimization”容易被误解为优化某个仓库的库存,但真实痛点永远在协同断层上。比如某汽车零部件厂商,其一级经销商库存周转天数是42天,但工厂成品仓只有18天——表面看工厂效率高,实则是因为经销商不敢压货,频繁小批量下单,导致工厂生产计划碎片化,单件制造成本上升11%。本教程的Python实现强制要求输入三级库存结构:工厂仓(Factory)、区域配送中心(RDC)、终端门店(Store)。代码中会构建跨层级的“需求放大效应”(Bullwhip Effect)量化模块:计算从门店销售波动→RDC订单波动→工厂生产波动的逐级放大系数。例如,当门店日销量标准差为5件时,若RDC采用固定周期补货(每3天补一次),其向工厂下的订单标准差可能飙升至28件——这个28件不是预测误差,而是补货策略本身制造的噪音。我们的优化目标函数会同时约束三个层级的服务水平(如门店现货率≥95%,RDC对门店满足率≥98%,工厂对RDC交付准时率≥99%),并通过scipy.optimize.LinearConstraint将层级间库存联动关系写进约束条件。这意味着:当系统建议RDC减少安全库存时,会自动触发工厂生产计划调整,避免出现“RDC降库存成功,但工厂因订单突增而紧急加班”的悖论。
3. 核心细节解析:从原始数据到可执行决策的7个生死关卡
3.1 关卡一:需求数据清洗——为什么“剔除异常值”是最危险的操作
新手常犯的致命错误是:用IQR(四分位距)法一键剔除所有超出1.5倍IQR的销售数据。我在某零食品牌项目中发现,某SKU在情人节当天销量是均值的17倍,被算法自动标为异常值剔除——结果模型永远学不会“节日脉冲”,后续所有促销备货都严重不足。真正的异常值识别必须绑定业务日历。本教程的清洗流程强制要求三步验证:
- 业务事件对齐:加载企业内部的《全年营销日历》CSV,标记出所有大促日(618/双11)、新品上市日、竞品降价日;
- 波动归因分析:对任一销售峰值,计算其与最近营销事件的时间距离(如峰值发生在活动开始后第2天),若距离≤3天,则标记为“可解释脉冲”,保留并打上
event_type=“618_预售爆发”标签; - 残差驱动剔除:仅对未被事件解释的峰值,用
statsmodels.robust.scale.mad(中位数绝对偏差)替代IQR,因其对极端值更鲁棒。
实操代码片段:
# 加载营销日历,生成事件窗口标记 marketing_calendar = pd.read_csv("marketing_calendar.csv", parse_dates=["date"]) sales_data["is_event_window"] = sales_data["date"].apply( lambda x: ((x - marketing_calendar["date"]).abs().min() <= pd.Timedelta("3D")) ) # 对非事件窗口数据计算MAD,剔除真正异常值 non_event_sales = sales_data[~sales_data["is_event_window"]]["sales_qty"] mad_threshold = np.median(np.abs(non_event_sales - np.median(non_event_sales))) * 3 sales_data = sales_data[ ~((~sales_data["is_event_window"]) & (sales_data["sales_qty"] > mad_threshold)) ]注意:这里
mad_threshold乘以3而非1.5,因为MAD本身比IQR更保守;且剔除操作必须生成审计日志,记录“X月X日Y SKU因未匹配营销事件且偏离MAD阈值被剔除”,供业务方复核。
3.2 关卡二:需求分布拟合——为什么正态分布假设会让你在旺季血本无归
教科书常假设需求服从正态分布,然后套用经典的(z * σ√(L+T))安全库存公式。但实际数据呢?我分析过某宠物食品品牌的2000个SKU,其日销量偏度(Skewness)中位数为2.8(正偏态),峰度(Kurtosis)中位数为12.3(尖峰厚尾)——这意味着断货风险远高于正态分布预测。若强行用正态分布计算95%服务水平的安全库存,实际断货率会高达18%。本教程采用混合分布拟合法:
- 先用
scipy.stats.kstest检验数据是否符合泊松分布(适用于低频SKU); - 若否,用
sklearn.mixture.GaussianMixture拟合双峰分布(如工作日平稳销量+周末爆发销量); - 最终选择AIC(赤池信息准则)最低的模型,其概率密度函数
pdf(x)将直接用于后续优化。
关键洞察:安全库存的本质是求解P(Demand ≤ Reorder_Point) ≥ Service_Level,当需求分布非正态时,Reorder_Point不再是μ + z*σ,而是分布的分位数函数ppf(Service_Level)。例如,某SKU需求服从Gamma分布(α=5, β=2),其95%分位数是15.3件,而正态近似给出的结果是12.1件——差这3.2件,在日均销量8件的SKU上意味着每月多断货4.7次。
3.3 关卡三:提前期(Lead Time)建模——为什么“供应商承诺7天”不等于“LT=7”
采购合同写的“交货周期7个工作日”,但实际到货时间可能是3天、12天、甚至23天(遇到海关查验)。若直接用7天计算,安全库存会系统性低估。本教程要求对每个供应商-物料组合,单独建模LT分布:
- 收集过去50次到货记录,计算实际LT(订单日期→入库日期);
- 用
scipy.stats.lognorm.fit拟合对数正态分布(因LT不可能为负,且右偏明显); - 将LT分布与需求分布卷积,得到“需求不确定性”总分布。
数学上,库存覆盖的风险不是Demand和LT各自的风险,而是Demand during LT的联合风险。例如,若日需求服从Gamma(5,2),LT服从LogNorm(1.2,0.8),则Demand during LT的期望值=E(Demand)*E(LT)=10*3.3=33件,但其标准差不是σ_demand * σ_LT,而需通过蒙特卡洛模拟:
# 生成10万次模拟 np.random.seed(42) simulated_demand_during_lt = [] for _ in range(100000): lt_sample = stats.lognorm.rvs(s=0.8, scale=np.exp(1.2)) # LogNorm参数转换 demand_sample = stats.gamma.rvs(a=5, scale=2, size=int(lt_sample)) simulated_demand_during_lt.append(demand_sample.sum()) # 计算95%分位数作为安全库存基准 safety_stock_base = np.percentile(simulated_demand_during_lt, 95)这个simulated_demand_during_lt数组,就是后续所有优化的基石——它包含了需求与LT的真实耦合关系,而非教科书式的独立假设。
3.4 关卡四:服务水平(Service Level)定义——为什么“现货率95%”可能毁掉你的利润
业务方常说“我们要95%现货率”,但没说清这是哪一层的哪一种现货率。本教程强制区分三种:
- Cycle Service Level(循环服务水平):单次补货周期内不断货的概率,对应经典安全库存公式;
- Fill Rate(填充率):客户订单需求被即时满足的比例(如客户要10件,你有8件,则本次填充率80%);
- Ready Rate(就绪率):所有SKU中,有库存可售的SKU占比(影响门店陈列丰富度)。
三者目标冲突:提高Cycle SL需增加安全库存,但会降低Fill Rate(因库存集中在少数爆款,长尾SKU更易缺货)。我们在优化目标函数中设置多目标权重:
# 目标函数:最小化总成本 = 持有成本 + 缺货成本 + 订货成本 def objective_function(x): # x[0]为Cycle SL目标,x[1]为Fill Rate目标,x[2]为Ready Rate目标 holding_cost = calculate_holding_cost(x[0], x[1], x[2]) stockout_cost = calculate_stockout_cost(x[0], x[1], x[2]) order_cost = calculate_order_cost(x[0], x[1], x[2]) return holding_cost + stockout_cost + order_cost # 约束:必须满足业务底线 constraints = [ {'type': 'ineq', 'fun': lambda x: x[0] - 0.9}, # Cycle SL ≥ 90% {'type': 'ineq', 'fun': lambda x: x[1] - 0.85}, # Fill Rate ≥ 85% {'type': 'ineq', 'fun': lambda x: x[2] - 0.7} # Ready Rate ≥ 70% ] result = minimize(objective_function, x0=[0.92, 0.87, 0.72], constraints=constraints)这个设计让采购总监能直观看到:“如果我把Fill Rate目标从85%提到88%,总成本会上升12%,但客户投诉率会降37%——这笔账值不值?”
3.5 关卡五:ABC-XYZ交叉分类——为什么“按销量排序”是最粗糙的分类法
传统ABC分类只看销售额,但某医疗器械客户案例揭示其缺陷:A类高值耗材(如心脏支架)年销售额占60%,但月度需求极稳定(CV<0.1),而C类消毒棉签年销售额仅5%,但日销量波动剧烈(CV=1.8)。若统一按A类设高安全库存,会导致支架大量积压,而棉签频繁断货。本教程采用ABC-XYZ二维矩阵:
- ABC轴:按年销售额累计占比(A:前20%,B:次30%,C:后50%);
- XYZ轴:按需求变异系数CV(X: CV<0.5,Y: 0.5≤CV<1.2,Z: CV≥1.2)。
关键创新在于:XYZ分类不直接用历史CV,而用滚动6个月CV的衰减加权平均,赋予近期波动更高权重。代码实现:
# 计算滚动CV,权重按时间衰减(最近1个月权重=1,2个月前=0.8,依此类推) def rolling_cv_weighted(series, window=6): weights = np.array([0.8**(i) for i in range(window)][::-1]) # [0.8^5, ..., 1] cv_list = [] for i in range(window, len(series)+1): window_data = series.iloc[i-window:i] if window_data.std() > 0: cv = window_data.std() / window_data.mean() # 加权CV = Σ(weight_j * CV_j) / Σweight_j weighted_cv = np.average( [window_data.iloc[j:i].std()/window_data.iloc[j:i].mean() for j in range(i-window, i)], weights=weights ) cv_list.append(weighted_cv) return pd.Series(cv_list, index=series.index[window-1:]) # 应用到每个SKU sku_cv_weighted = sales_data.groupby("sku_id")["sales_qty"].apply(rolling_cv_weighted)这样,当某SKU因疫情突然需求激增,其XYZ分类会在2周内从X升为Z,触发安全库存重算——而静态CV分类可能半年后才更新。
3.6 关卡六:多级库存协同——如何用图神经网络思想简化为线性规划
多级库存优化常被描述为NP-hard问题,但本教程用层级分解+约束传播实现工程化落地。核心思想:将工厂→RDC→门店视为一个有向图,每个节点有库存状态I_i(t),每条边有运输延迟d_ij和容量限制cap_ij。传统方法需同步优化所有节点,而我们采用:
- 自底向上聚合:先计算门店层最优策略,将其汇总为RDC的“虚拟需求”(含服务等级约束);
- 自顶向下分配:将工厂产能约束分解为RDC的供应上限;
- 中间层校准:用
scipy.optimize.linprog求解RDC层的库存分配,目标是最小化跨RDC调拨次数。
具体到Python实现,关键在构建约束矩阵:
# RDC层优化:变量x[i]表示第i个RDC的安全库存水平 # 约束1:总库存成本 ≤ 预算 c_cost = [holding_cost_per_rdc[i] for i in range(num_rdc)] # 约束2:每个RDC对门店的服务水平 ≥ 95% # 这里将服务水平约束线性化:I_i ≥ μ_i + k_i * σ_i,其中k_i由服务水平查表得 k_values = [1.645 if sl_target[i]>=0.95 else 1.282 for i in range(num_rdc)] # Z值 A_ub = [] b_ub = [] for i in range(num_rdc): row = [0]*num_rdc row[i] = -1 # -I_i row[i] += k_values[i] * sigma_rdc[i] # +k*σ A_ub.append(row) b_ub.append(-mu_rdc[i]) # ≤ -μ res = linprog(c_cost, A_ub=A_ub, b_ub=b_ub, bounds=(0, None))这个看似简单的线性规划,实际封装了复杂的概率约束——k_i值来自scipy.stats.norm.ppf(sl_target[i]),而mu_rdc[i]和sigma_rdc[i]是门店需求聚合后的统计量。它让多级优化从“博士论文级难题”变成“采购专员可理解的表格”。
3.7 关卡七:决策输出格式——为什么Excel报表正在杀死你的优化效果
很多团队花3个月建好模型,输出却是一张“建议订货量”Excel表,结果被采购员直接忽略。原因很简单:Excel无法承载决策上下文。本教程的最终输出是交互式HTML报告,包含:
- 动态仪表盘:用Plotly绘制“库存水位-需求预测-安全库存”三线图,鼠标悬停显示当日促销活动、竞品动作、天气影响;
- 根因下钻:点击某SKU的“高缺货风险”标签,自动展开:最近3次断货日、当时库存水位、需求预测误差、供应商LT延误记录;
- 执行指引:生成带超链接的采购清单,链接到ERP系统对应物料主数据页,并预填“建议订货量=127件,理由:覆盖未来14天95%需求波动,当前库存仅剩42件”。
技术实现用jinja2模板引擎:
<!-- report_template.html --> <div class="risk-card" onclick="drilldown('{{ sku_id }}')"> <h3>{{ sku_name }}</h3> <p>当前库存: {{ current_stock }}件 | 安全库存: {{ safety_stock }}件</p> <p><strong>风险等级:</strong> {{ risk_level }} <span class="reason">({{ reason }})</span></p> </div> <script> function drilldown(sku_id) { // 加载该SKU的详细分析页 window.open(`/analysis?sku=${sku_id}&date={{ today }}`, '_blank'); } </script>当采购员在晨会中被问到“为什么这个SKU要多订30%”,他可以直接打开报告,点击“查看根因”,3秒内给出答案——这才是数据科学该有的样子。
4. 实操过程:从零开始搭建可投产的库存优化系统(含完整代码)
4.1 环境准备与数据结构定义
我们使用Python 3.9+,核心依赖:pandas>=1.4,numpy>=1.21,statsmodels>=0.13,scipy>=1.7,plotly>=5.10。创建项目结构:
inventory_opt/ ├── data/ │ ├── raw/ # 原始数据(脱敏) │ │ ├── sales.csv # 销售流水:date, sku_id, store_id, qty_sold │ │ ├── inventory.csv # 库存快照:date, sku_id, location_id, on_hand_qty │ │ └── leadtime.csv # 到货记录:order_date, sku_id, supplier_id, arrival_date │ └── calendar/ # 业务日历 │ ├── marketing.csv # 大促日、新品日 │ └── holiday.csv # 法定节假日(影响物流) ├── src/ │ ├── preprocessing.py # 数据清洗与特征工程 │ ├── demand_model.py # 需求预测模型 │ ├── inventory_opt.py # 库存优化主逻辑 │ └── reporting.py # 报告生成 └── notebooks/ └── tutorial.ipynb # 本教程配套Notebook数据表字段定义必须严格遵循业务语义:
sales.csv中qty_sold为净销售量(已扣除退货),单位为“件”,非“箱”或“托盘”;inventory.csv中location_id必须与ERP系统一致,如“SH_RDC”“BJ_STORE_001”;leadtime.csv中arrival_date为实际入库时间,非物流签收时间(因签收后还有质检入库流程)。
实操心得:我在某项目初期因
leadtime.csv用了物流签收时间,导致LT标准差被低估40%,安全库存计算整体偏低。后来强制要求数据组对接WMS系统,取wms_inventory_transaction表中transaction_type='INBOUND'的记录,才解决问题。记住:数据源的业务定义,比算法模型重要10倍。
4.2 需求建模全流程:从探索性分析到模型部署
步骤1:探索性分析(EDA)——发现数据真相的第一道门
不要跳过这一步!运行以下代码生成诊断报告:
import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns sales = pd.read_csv("data/raw/sales.csv", parse_dates=["date"]) # 按SKU统计基础指标 sku_stats = sales.groupby("sku_id").agg({ "qty_sold": ["count", "sum", "mean", "std", "min", "max"], "date": ["min", "max"] }).round(2) # 计算变异系数CV和偏度 sku_stats[("qty_sold", "cv")] = sku_stats[("qty_sold", "std")] / sku_stats[("qty_sold", "mean")] sku_stats[("qty_sold", "skew")] = sales.groupby("sku_id")["qty_sold"].apply(pd.Series.skew) # 识别高风险SKU:CV>1.5且销量>1000件/年 high_risk = sku_stats[ (sku_stats[("qty_sold", "cv")] > 1.5) & (sku_stats[("qty_sold", "sum")] > 1000) ].index.tolist() print(f"高波动SKU数量: {len(high_risk)}") print(f"示例: {high_risk[:5]}")这个报告会告诉你:哪些SKU需要重点建模(高CV),哪些可以简化处理(低CV+高销量),避免“给苍蝇用高射炮”。
步骤2:需求分布拟合——为每个SKU选择最优分布
对高风险SKU,执行分布拟合:
from scipy import stats from sklearn.mixture import GaussianMixture def fit_demand_distribution(sales_series, sku_id): # 候选分布列表 distributions = [ stats.poisson, stats.nbinom, # 负二项分布,适合过离散数据 stats.gamma, stats.lognorm ] best_fit = None best_aic = float('inf') for dist in distributions: try: # 拟合分布参数 if dist == stats.poisson: param = dist.fit(sales_series, floc=0) # 强制loc=0 else: param = dist.fit(sales_series) # 计算AIC:AIC = 2k - 2ln(L), k为参数个数 k = len(param) log_likelihood = np.sum(dist.logpdf(sales_series, *param)) aic = 2*k - 2*log_likelihood if aic < best_aic: best_aic = aic best_fit = {"dist": dist, "param": param, "aic": aic} except Exception as e: continue return best_fit # 对每个高风险SKU拟合 demand_models = {} for sku in high_risk[:10]: # 先试10个 sku_sales = sales[sales["sku_id"]==sku]["qty_sold"] model = fit_demand_distribution(sku_sales, sku) demand_models[sku] = model print(f"SKU {sku}: {model['dist'].__name__} (AIC={model['aic']:.2f})")你会发现,对日销量>50件的SKU,Gamma分布通常最优;对日销量<5件的长尾SKU,负二项分布(NBinom)更合适——因为它能处理“零膨胀”(Zero-Inflated)现象。
步骤3:SARIMAX建模——捕捉促销、季节、趋势的三重效应
以某SKU为例,构建带外生变量的SARIMAX:
import statsmodels.api as sm # 准备外生变量:促销强度(0-100)、竞品降价(0/1)、天气(温度) promo_data = pd.read_csv("data/calendar/marketing.csv", parse_dates=["date"]) weather_data = pd.read_csv("data/raw/weather.csv", parse_dates=["date"]) # 合并到销售数据 merged = sales.merge(promo_data, on=["date", "sku_id"], how="left") merged = merged.merge(weather_data, on="date", how="left") merged["promo_intensity"] = merged["promo_intensity"].fillna(0) merged["competitor_discount"] = merged["competitor_discount"].fillna(0) merged["temp"] = merged["temp"].fillna(merged["temp"].median()) # 构建SARIMAX模型 exog_vars = ["promo_intensity", "competitor_discount", "temp"] model = sm.tsa.SARIMAX( merged["qty_sold"], exog=merged[exog_vars], order=(1,1,1), # ARIMA参数 seasonal_order=(1,1,1,7) # 周季节性 ) results = model.fit(disp=False) # 输出关键诊断 print(results.summary()) print(f"AIC: {results.aic:.2f}") print(f"促销弹性: {results.params['promo_intensity']:.3f} (每提升1单位强度,销量增{results.params['promo_intensity']*100:.1f}%)")注意:seasonal_order[3]=7必须与业务周期一致。若你的业务是双周补货,则应改为14;若为月度结算,则用30或31。模型参数必须能被业务方解读,否则就是技术自嗨。
步骤4:模型验证——用滚动预测回测代替静态分割
静态训练/测试分割会泄露未来信息。我们采用滚动窗口回测:
def rolling_forecast_validation(model_func, data, window_size=90, horizon=7): """ 滚动预测验证:用前90天训练,预测后7天,滑动窗口 """ results = [] for i in range(window_size, len(data)-horizon+1): train_data = data.iloc[i-window_size:i] test_data = data.iloc[i:i+horizon] # 重新拟合模型(因外生变量变化) model = model_func(train_data) forecast = model.forecast(steps=horizon) # 计算误差 mape = np.mean(np.abs((test_data - forecast) / test_data)) * 100 results.append({"date": test_data.index[0], "mape": mape}) return pd.DataFrame(results) # 执行验证 validation_df = rolling_forecast_validation( lambda df: sm.tsa.SARIMAX(df["qty_sold"], exog=df[exog_vars]).fit(disp=False), merged, window_size=90, horizon=7 ) print(f"滚动MAPE均值: {validation_df['mape'].mean():.2f}%")若滚动MAPE超过25%,说明模型不稳定,需检查外生变量质量或改用更鲁棒的模型(如Prophet的changepoint_range调优)。
4.3 库存优化主逻辑:将数学转化为采购指令
步骤1:构建多目标优化问题
定义目标函数(总成本最小化):
from scipy.optimize import minimize, LinearConstraint import numpy as np def total_cost_function(x, params): """ x: 决策变量数组 [safety_stock_1, safety_stock_2, ..., reorder_point_1, ...] params: 参数字典,含各SKU的holding_cost, stockout_cost, mu, sigma等 """ holding_cost = 0 stockout_cost = 0 order_cost = 0 for i, sku in enumerate(params["skus"]): ss = x[i] # 安全库存 rop = x[i + len(params["skus"])] # 再订货点 # 持有成本 = 安全库存 * 单位持有成本 holding_cost += ss * params["holding_cost"][i] # 缺货成本 = 预期缺货量 * 单位缺货成本 # 预期缺货量 = ∫(rop - demand) * f(demand) ddemand, demand>rop # 用数值积分近似 demand_dist = params["demand_dist"][i] expected_shortage = 0 for d in range(int(rop), int(rop)+100): # 简化计算 prob = demand_dist.pdf(d) if hasattr(demand_dist, 'pdf') else 0 expected_shortage += (d - rop) * prob stockout_cost += expected_shortage * params["stockout_cost"][i] # 订货成本 = 年订货次数 * 单次成本 annual_demand = params["mu"][i] * 365 order_freq = annual_demand / (rop + ss) # 简化:ROP+SS为平均订货量 order_cost += order_freq * params["order_cost"][i] return holding_cost + stockout_cost + order_cost # 设置约束:服务水平、预算、MOQ constraints = [] # 约束1:每个SKU的服务水平 ≥ 95% for i, sku in enumerate(params["skus"]): # P(Demand ≤ ROP) ≥ 0.95 → ROP ≥ ppf(0.95) rop_min = params["demand_dist"][i].ppf(0.95) constraints.append({'type': 'ineq', 'fun': lambda x, i=i, rop_min=rop_min: x[i + len(params["skus"])] - rop_min}) # 约束2:总持有成本 ≤ 预算 budget_constraint = {'type': 'ineq', 'fun': lambda x: params["budget"] - sum(x[:len(params["skus"])] * params["holding_cost"])} constraints.append(budget_constraint) # 初始值:用经典公式初始化 x0 = [] for i, sku in enumerate(params["skus"]): classic_ss = 1.645 * params["sigma"][i] * np.sqrt(params["lt_mean"][i]) classic_rop = params["mu"][i] * params["lt_mean"][i] + classic_ss x0.extend([classic_ss, classic_rop]) # 执行优化 result = minimize( total_cost_function, x0=x0, args=(params,), method='SLSQP', constraints=constraints, options={'ftol': 1e-9, 'disp': True} )步骤2:生成可执行采购清单
优化结果需转化为采购员能操作的指令:
