📖 本篇简介:本篇是案例分析系列的收官篇,用一份模拟的零售供应链数据走完"四表模拟(SKU 主数据 + 日销量 + 采购记录 + 库存快照)→ 库存现状诊断 → ABC 分类 → 需求预测(移动平均/指数平滑)→ 安全库存与补货点 → EOQ 经济订货量 → 可视化补货建议"的完整库存优化流程。前置知识:前面章节的分组聚合、时间序列重采样与透视表。学完后你能对一批 SKU 做库存健康诊断,并算出"什么时候补、补多少",为供应链降本增效提供数据依据。
前面的案例多在讲"怎么把货卖出去",本篇换个视角——货卖得动,还要库存管得住。库存太多占资金、太少丢销售,本质是在"资金占用"和"缺货损失"之间找平衡。本篇用 pandas 把这套供应链经典模型(ABC、安全库存、EOQ)完整跑一遍。
目录
1. 案例背景与目标
2. 数据准备(模拟四张表)
3. 库存现状诊断
4. ABC 分类
5. 需求波动与预测
6. 安全库存与补货点
7. EOQ 经济订货量
8. 可视化与补货建议
9. 结论与行动建议
10. 扩展练习
1. 案例背景与目标
背景:某连锁零售仓库有 200 个在售 SKU,运营反馈"有的货压了半年卖不动,有的货三天两头断货"。希望用数据把库存管起来。
分析目标:
1. 诊断库存现状:周转率、缺货率、积压占比各是多少;
2. 按销售额做 ABC 分类,把管理精力集中在 A 类商品;
3. 用历史销量预测未来需求,算出安全库存与补货点;
4. 用 EOQ 模型给出每次订货的经济批量;
5. 输出一张"补货建议表":哪个 SKU 该补、补多少。
核心公式速记:
- 安全库存 SS = z × σ_d × √L(z 为服务水平系数,σ_d 为日需求标准差,L 为补货提前期)
- 补货点 ROP = 平均日需求 × L + SS
- EOQ = √(2DS / H)(D 年需求、S 单次订货成本、H 单位年持有成本)
2. 数据准备(模拟四张表)
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
# ============ 固定随机种子,保证可复现 ============
np.random.seed(7)
N_SKU = 200 # 200 个 SKU
TODAY = pd.Timestamp("2025-07-01") # 分析快照日
# ---------- 表1:SKU 主数据 ----------
sku = pd.DataFrame({
"SKU": [f"S{i:04d}" for i in range(1, N_SKU + 1)],
"品名": [f"商品{i}" for i in range(1, N_SKU + 1)],
"品类": np.random.choice(["饮料", "零食", "日化", "粮油", "个护"],
N_SKU, p=[0.25, 0.3, 0.2, 0.15, 0.1]),
"单价": np.around(np.random.uniform(5, 200, N_SKU), 2), # 单位售价
"成本": 0, # 后面按 60% 毛利率反推
})
sku["成本"] = (sku["单价"] * np.random.uniform(0.45, 0.7, N_SKU)).round(2)
print(sku.head())
# ---------- 表2:日销量(过去 90 天) ----------
days = pd.date_range("2025-04-02", periods=90)
# 每个 SKU 一个基础日销(长尾:少数爆品卖得多)
base_demand = np.random.lognormal(1.5, 1.0, N_SKU)
sales_rows = []
for i, s in enumerate(sku["SKU"]):
# 日需求围绕基础值波动,泊松过程更贴近零售
daily = np.random.poisson(base_demand[i], 90)
for d, q in zip(days, daily):
sales_rows.append((s, d, q))
sales = pd.DataFrame(sales_rows, columns=["SKU", "日期", "销量"])
print(sales.head())
print("销量记录数:", len(sales))
# ---------- 表3:采购记录(历史入库) ----------
# 每个 SKU 历史上采购过若干次
purchase_rows = []
for i, s in enumerate(sku["SKU"]):
n_buy = np.random.randint(1, 8)
buy_dates = np.random.choice(days, n_buy)
for bd in buy_dates:
qty = int(base_demand[i] * np.random.uniform(10, 40)) # 一次备 10~40 天货
lead = np.random.randint(3, 15) # 提前期 3~14 天
purchase_rows.append((s, bd, qty, lead))
purchase = pd.DataFrame(purchase_rows,
columns=["SKU", "采购日期", "采购量", "提前期(天)"])
print(purchase.head())
# ---------- 表4:库存快照(当前库存) ----------
# 当前库存 = 历史累计入库 - 累计销量,再加点随机扰动
inbound = purchase.groupby("SKU")["采购量"].sum()
outbound = sales.groupby("SKU")["销量"].sum()
stock = pd.DataFrame({
"SKU": sku["SKU"],
# 当前在手库存:入库减销量,再压到非负
"当前库存": np.maximum((inbound - outbound).fillna(0).values
+ np.random.normal(0, 50, N_SKU), 0).round(0).astype(int),
})
print(stock.head())
print("四表规模:", sku.shape, sales.shape, purchase.shape, stock.shape)
四表规模: (200, 5) (18000, 3) (~800, 4) (200, 2)
3. 库存现状诊断
先把四个表拼成一张 SKU 级别诊断表,算出周转率、动销率等核心指标。
# 每个 SKU 的累计销量、销售天数、日均需求
sku_sales = sales.groupby("SKU").agg(
累计销量=("销量", "sum"),
销售天数=("销量", lambda x: (x > 0).sum()), # 有动销的天数
)
sku_sales["日均需求"] = (sku_sales["累计销量"] / 90).round(2)
# 当前库存 × 成本 = 库存占用资金
diag = sku.merge(stock, on="SKU").merge(sku_sales, on="SKU")
diag["库存资金占用"] = (diag["当前库存"] * diag["成本"]).round(2)
# 库存可售天数 = 当前库存 / 日均需求(日均为 0 时记 999)
diag["可售天数"] = np.where(diag["日均需求"] > 0,
diag["当前库存"] / diag["日均需求"], 999).round(1)
print(diag[["SKU", "品类", "当前库存", "日均需求", "可售天数"]].head(10))
# ---- 整体诊断指标 ----
total_stock_value = diag["库存资金占用"].sum()
# 年销售成本(COGS)≈ 90 天销量 × 成本 × 4(折算全年)
annual_cogs = (diag["累计销量"] * diag["成本"]).sum() * 4
turnover = annual_cogs / total_stock_value # 库存周转率(次/年)
# 缺货:可售天数 < 3 天视为"快断货"
oos_rate = (diag["可售天数"] < 3).mean()
# 积压:可售天数 > 90 天视为"积压"
overstock_rate = (diag["可售天数"] > 90).mean()
print(f"库存资金占用:{total_stock_value:,.0f} 元")
print(f"库存周转率:{turnover:.2f} 次/年")
print(f"快断货 SKU 占比:{oos_rate:.1%}")
print(f"积压 SKU 占比:{overstock_rate:.1%}")
库存资金占用:xxx,xxx 元
库存周转率:x.xx 次/年
快断货 SKU 占比:xx.x%
积压 SKU 占比:xx.x%
解读:周转率越高说明货转得越快;快断货占比高要补,积压占比高要清。健康零售的库存周转一般在 6~12 次/年,可据此判断现状。
4. ABC 分类
ABC 分类基于帕累托法则:少数 A 类商品贡献大部分销售额。按销售额累计占比分层。
# 每个 SKU 的销售额
diag["销售额"] = (diag["累计销量"] * diag["单价"]).round(2)
diag = diag.sort_values("销售额", ascending=False).reset_index(drop=True)
# 累计销售额占比
diag["累计占比"] = (diag["销售额"].cumsum() / diag["销售额"].sum())
# 分层规则:累计占比 ≤80% 为 A,80~95% 为 B,其余为 C
def abc(p):
if p <= 0.80:
return "A"
elif p <= 0.95:
return "B"
return "C"
diag["ABC"] = diag["累计占比"].apply(abc)
abc_summary = diag.groupby("ABC").agg(
SKU数=("SKU", "count"),
销售额=("销售额", "sum"),
库存资金=("库存资金占用", "sum"),
)
abc_summary["SKU占比"] = (abc_summary["SKU数"] / abc_summary["SKU数"].sum() * 100).round(1)
abc_summary["销售额占比"] = (abc_summary["销售额"] / abc_summary["销售额"].sum() * 100).round(1)
print(abc_summary)
SKU数 销售额 库存资金 SKU占比 销售额占比
ABC
A 42 320000.0 95000.0 21.0 80.0
B 55 60000.0 42000.0 27.5 12.0
C 103 32000.0 68000.0 51.5 8.0
示例数字。可以看到:A 类 SKU 只占 21% 却贡献 80% 销售额,却占用了合理库存;而 C 类占一半 SKU 只贡献 8% 销售,却压着不少资金——这就是优化空间。
5. 需求波动与预测
补货的前提是"知道未来大概卖多少"。这里用两种简单方法:简单移动平均(SMA) 和 指数平滑(EMA)。
# 取一个 A 类 SKU 演示预测过程
demo_sku = diag.loc[diag["ABC"] == "A", "SKU"].iloc[0]
s = sales[sales["SKU"] == demo_sku].set_index("日期")["销量"].sort_index()
# 1) 简单移动平均:用最近 7 天均值预测明天
sma7 = s.rolling(7).mean()
# 2) 指数平滑:越近的数据权重越高,alpha=0.3
ema = s.ewm(alpha=0.3).mean()
print(f"演示 SKU:{demo_sku}")
print(f"近 7 天移动平均预测明日需求:{sma7.iloc[-1]:.1f}")
print(f"指数平滑预测明日需求:{ema.iloc[-1]:.1f}")
# 批量给所有 SKU 算需求标准差(日均需求已在第 3 节算过,直接复用,避免列名冲突)
demand_stat = sales.groupby("SKU")["销量"].agg(需求标准差="std")
# 标准差缺失(几乎不卖)的补 0
demand_stat["需求标准差"] = demand_stat["需求标准差"].fillna(0).round(2)
diag = diag.merge(demand_stat, on="SKU", how="left")
print(diag[["SKU", "日均需求", "需求标准差"]].head())
为什么需要标准差:平均需求告诉你"大概卖多少",标准差告诉你"波动多大"。波动越大,越要多备安全库存。
6. 安全库存与补货点
from scipy.stats import norm
# 服务水平 95% 对应的 z 值(缺货风险 5%)
z = norm.ppf(0.95) # ≈ 1.645
# 每个 SKU 的补货提前期(取采购记录里的均值)
lead = purchase.groupby("SKU")["提前期(天)"].mean().rename("提前期")
diag = diag.merge(lead, on="SKU", how="left")
diag["提前期"] = diag["提前期"].fillna(7) # 没采购记录的默认 7 天
# 安全库存 SS = z × σ_d × √L
diag["安全库存"] = (z * diag["需求标准差"] * np.sqrt(diag["提前期"])).round(0)
# 补货点 ROP = 日均需求 × 提前期 + 安全库存
diag["补货点"] = (diag["日均需求"] * diag["提前期"] + diag["安全库存"]).round(0)
# 当前库存是否已低于补货点
diag["是否需补货"] = np.where(diag["当前库存"] <= diag["补货点"], "是", "否")
need = diag[diag["是否需补货"] == "是"]
print(f"当前需要补货的 SKU 数:{len(need)} / {len(diag)}")
print(need[["SKU", "品类", "ABC", "当前库存", "补货点", "安全库存"]].head(10))
当前需要补货的 SKU 数:xx / 200
服务水平怎么选:A 类商品卖断货损失大,可用 95%(z=1.645)甚至 98%(z=2.05);C 类商品资金占用敏感,可用 90%(z=1.28)少备点。下面按 ABC 差异化设置。
# 按 ABC 差异化服务水平
z_map = {"A": norm.ppf(0.98), "B": norm.ppf(0.95), "C": norm.ppf(0.90)}
diag["z值"] = diag["ABC"].map(z_map)
diag["安全库存"] = (diag["z值"] * diag["需求标准差"] *
np.sqrt(diag["提前期"])).round(0)
diag["补货点"] = (diag["日均需求"] * diag["提前期"] + diag["安全库存"]).round(0)
diag["是否需补货"] = np.where(diag["当前库存"] <= diag["补货点"], "是", "否")
7. EOQ 经济订货量
算出"什么时候补"(补货点)后,再算"一次补多少"(EOQ)。
# 假设参数:每次订货成本 S=200 元(人工/物流),年持有成本率 20%
S = 200
HOLD_RATE = 0.20
# 年需求 D = 日均需求 × 365
diag["年需求"] = diag["日均需求"] * 365
# 单位年持有成本 H = 成本 × 持有成本率
diag["单位年持有成本"] = diag["成本"] * HOLD_RATE
# EOQ = sqrt(2DS / H)
diag["EOQ"] = np.sqrt(2 * diag["年需求"] * S /
diag["单位年持有成本"].replace(0, np.nan)).round(0)
diag["EOQ"] = diag["EOQ"].fillna(0)
# 建议订货量 = 补货点目标库存 - 当前库存(不低于 EOQ 的一半,避免碎单)
diag["建议订货量"] = np.maximum(
diag["补货点"] - diag["当前库存"], diag["EOQ"] * 0.5
).round(0)
diag.loc[diag["是否需补货"] == "否", "建议订货量"] = 0
print(diag[["SKU", "ABC", "EOQ", "补货点", "当前库存", "建议订货量"]].head(10))
EOQ 的直觉:订货太频繁,每次订货成本高;一次订太多,压库存。EOQ 就是让"订货成本 + 持有成本"之和最小的批量。它对需求预测误差不敏感,是供应链里最稳健的模型之一。
8. 可视化与补货建议
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# 1. ABC 帕累托曲线
diag_sorted = diag.sort_values("销售额", ascending=False).reset_index(drop=True)
diag_sorted["累计占比%"] = diag_sorted["销售额"].cumsum() / diag_sorted["销售额"].sum() * 100
axes[0, 0].plot(range(len(diag_sorted)), diag_sorted["累计占比%"], color="#2E86AB")
axes[0, 0].axhline(80, ls="--", color="red", label="80% 线")
axes[0, 0].set_title("ABC 帕累托曲线")
axes[0, 0].set_xlabel("SKU 排名")
axes[0, 0].legend()
# 2. 可售天数分布(看断货/积压两端)
diag["可售天数"].clip(upper=180).plot(
kind="hist", bins=30, ax=axes[0, 1], title="可售天数分布", color="#F18F01")
axes[0, 1].axvline(3, color="red", ls="--", label="断货线")
axes[0, 1].axvline(90, color="purple", ls="--", label="积压线")
axes[0, 1].legend()
# 3. 演示 SKU 的库存水位与补货点
axes[1, 0].plot(s.index, s.values, label="日销量", color="gray")
axes[1, 0].axhline(diag.loc[diag["SKU"] == demo_sku, "补货点"].iloc[0],
color="red", ls="--", label="补货点")
axes[1, 0].set_title(f"{demo_sku} 日销量与补货点")
axes[1, 0].legend()
# 4. 各品类库存资金占用
diag.groupby("品类")["库存资金占用"].sum().plot(
kind="bar", ax=axes[1, 1], title="各品类库存资金占用", color="#8E44AD")
axes[1, 1].set_xticklabels(axes[1, 1].get_xticklabels(), rotation=0)
plt.tight_layout()
plt.savefig("库存分析.png", dpi=150)
plt.show()
# ---- 输出最终补货建议表 ----
advice = diag[diag["建议订货量"] > 0][
["SKU", "品名", "品类", "ABC", "当前库存", "补货点", "安全库存",
"EOQ", "建议订货量"]
].sort_values(["ABC", "建议订货量"], ascending=[True, False])
print(advice.head(15))
# 导出给采购执行
# advice.to_csv("补货建议表.csv", index=False, encoding="utf-8-sig")
9. 结论与行动建议
=== 结论 ===
1. 库存资金占用集中,周转率偏低,C 类商品占用了过多资金;
2. ABC 分类显示约 20% 的 A 类 SKU 贡献 80% 销售额,应重点保障不缺货;
3. 快断货 SKU 与积压 SKU 并存,说明补货节奏失衡;
4. 已按差异化服务水平算出每个 SKU 的安全库存、补货点与 EOQ;
5. 输出的补货建议表可直接交给采购执行。
=== 行动建议 ===
1. A 类商品提高服务水平到 98%,优先补货、每日监控库存水位;
2. C 类商品降低安全库存、拉长补货周期,清理积压库存(促销/捆绑);
3. 把"补货点 + EOQ"固化为自动补货规则,库存低于 ROP 自动生成采购单;
4. 每周复盘缺货率与积压率两个指标,持续调优 z 值与订货成本假设;
5. 对销量波动大的 SKU 引入更精细的预测(如考虑促销、季节因素)。
注意:EOQ、安全库存的计算依赖"需求平稳"假设。若商品有强季节性或大促波动,应改用分季节的需求参数,本篇模型需相应调整。
10. 扩展练习
1. 把简单移动平均换成 statsmodels 的 Holt-Winters 季节模型,对比预测误差(MAE)。
2. 计算不同服务水平(90%/95%/98%)下安全库存与资金占用的权衡曲线。
3. 对积压 SKU 做"清仓成本 vs 持有成本"测算,判断该不该打折。
4. 加入"在途库存"(已下单未到货)重算补货点。
5. 用 scipy.optimize 最小化总库存成本,验证 EOQ 是否为最优批量。
全系列到这里就结束了。回到总目录,继续用真实数据动手练习吧!
文章回复
0 条公开回复