15. オペレーションズ・リサーチ入門
テーマ: 経済的発注量(EOQ)モデルと安全在庫・発注点
学習点: 最適化の解析解と数値探索の比較, math, 正規分布のパーセント点
依存: SciPy / 難易度: 中級
実行方法
uv run 15_eoq_inventory.py
スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは
uv が自動的に仮想環境へ導入します。事前の pip install は不要です。
解説
何をするプログラムか
在庫管理には相反する 2 つの費用があります。1 回の発注量を増やせば発注回数が減って発注費用は下がりますが、平均在庫が増えて保管費用が上がります。この綱引きの最適点を与えるのが経済的発注量(EOQ: Economic Order Quantity)モデルで、オペレーションズ・リサーチの最も古典的な成果の 1 つです。
このスクリプトは、年間需要 24,000 個・発注費 15,000 円/回・保管費 240 円/個年という設定で最適発注量 Q* を解析解と数値探索の両方で求め、発注量を誤って設定した場合の費用増(モデルの頑健性)、需要の不確実性に備える安全在庫と発注点、そして数量割引がある場合の意思決定までを一気に扱います。
コードの読みどころ
eoq()は Wilson 公式math.sqrt(2 * D * K / h)のわずか 1 行です。総費用関数total_cost()と分けて定義してあるため、「公式で解く」「探索で解く」の両方から同じ費用関数を使えます。- 数値探索は
min(range(100, 5000, 1), key=lambda x: total_cost(x, D, K, h))の 1 行で書かれています。minのkey引数に目的関数を渡すことで、全探索による最小化がループなしで表現できる Python らしい書き方です。 D, K, h, unit = 24_000, 15_000, 240, 1_200のように、数値リテラルに_区切りを入れて桁を読みやすくしています(Python 3.6+)。- 安全在庫の計算では
stats.norm.ppf(sl)で正規分布のパーセント点(サービス率に対応する z 値)を求めます。ppf は累積分布関数の逆関数で、SciPy を使う理由はこの 1 関数のためです。 sd_L = daily_sd * math.sqrt(lead)は「日次需要が独立なら分散は日数分だけ加算される」という統計の性質(標準偏差は √7 倍)をコードにしたものです。- 数量割引の検討では、条件式
unit * (0.97 if Q >= 1000 else 1.0)で単価を切り替え、保管費を仕入価格の 20% とした上で「購入額まで含めた総費用」で 3 つの発注量を比較しています。
理論的背景
年間総費用は TC(Q) = (D/Q)·K + (Q/2)·h です。第 1 項は発注回数 D/Q × 発注費、第 2 項は平均在庫 Q/2 × 保管費を表します。Q で微分して 0 とおくと Q* = √(2DK/h) が得られます(Harris 1913、いわゆる Wilson 公式)。最適点では 2 つの費用がちょうど等しくなること、そして TC が Q* の近くで平坦な形をしていることがこのモデルの重要な含意です。
需要が不確実な場合は、リードタイム中の需要が平均 μ_L・標準偏差 σ_L の正規分布に従うと仮定し、品切れを起こさない確率(サービス率)に対応する z 値を使って安全在庫 SS = z·σ_L、発注点 = μ_L + SS を決めます。
実行結果の見方
最適発注量は Q* = 1,732.1 個、最小総費用は 415,692 円で、内訳の発注費と保管費がともに 207,846 円と一致しています。1 個刻みの全探索でも Q = 1732 で同じ費用となり、解析解が正しいことを数値で確認できます。頑健性の表では、Q* から ±10% ずれても費用増は 1% 未満、半分や 2 倍にしても +25% にとどまります。EOQ を厳密に守ることより「桁を間違えないこと」が重要だと分かります。
安全在庫の表では、サービス率を 90% から 99.9% へ上げると安全在庫が 40.7 個から 98.1 個へ増え、特に 99% → 99.9% の 1 段で追加保管費が約 5,800 円も増える、つまり高いサービス率の限界費用は逓増することが読み取れます。数量割引の比較では、Q* = 1,732 がもともと割引条件の 1,000 個を超えているため、EOQ のままが総費用最小(28,345,457 円)という結論になります。
ソースコード
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "scipy",
# ]
# ///
"""15: オペレーションズ・リサーチ入門 -------------------------------------
テーマ: 経済的発注量(EOQ)モデルと安全在庫・発注点
学習点: 最適化の解析解と数値探索の比較, math, 正規分布のパーセント点
根拠: 総費用 TC(Q) = D/Q·K + Q/2·h を Q で微分して 0 とおくと
Q* = √(2DK/h)(Harris 1913, Wilson 公式)。TC は Q* で最小、
かつ発注費用と在庫保管費用が一致する。
安全在庫 SS = z·σ_L(σ_L はリードタイム中の需要標準偏差)。
"""
import math
from scipy import stats
def eoq(D: float, K: float, h: float) -> float:
"""年間需要D, 1回あたり発注費K, 単位年間保管費h に対する最適発注量。"""
return math.sqrt(2 * D * K / h)
def total_cost(Q: float, D: float, K: float, h: float) -> float:
return D / Q * K + Q / 2 * h
def main() -> None:
D, K, h, unit = 24_000, 15_000, 240, 1_200
q = eoq(D, K, h)
print(f"年間需要 {D:,}個 / 発注費 {K:,}円/回 / 保管費 {h:,}円/個年")
print(f"最適発注量 Q* = {q:,.1f} 個")
print(f"年間発注回数 = {D/q:.2f} 回 / 発注間隔 = {365/(D/q):.1f} 日")
print(f"最小総費用 = {total_cost(q, D, K, h):,.0f} 円")
print(f" 内訳: 発注費 {D/q*K:,.0f}円 = 保管費 {q/2*h:,.0f}円 "
"(EOQでは必ず一致する)\n")
print("[数値探索による検証]")
best = min(range(100, 5000, 1), key=lambda x: total_cost(x, D, K, h))
print(f" 1個刻みの全探索: Q = {best} 個, TC = "
f"{total_cost(best, D, K, h):,.0f} 円(解析解と一致)\n")
print("[Qを誤って設定した場合の費用増(EOQの頑健性)]")
tc_opt = total_cost(q, D, K, h)
for ratio in [0.5, 0.7, 0.9, 1.0, 1.1, 1.5, 2.0]:
tc = total_cost(q * ratio, D, K, h)
print(f" Q = {ratio:>4.0%}·Q*: TC = {tc:>10,.0f}円 "
f"({tc/tc_opt - 1:>+6.2%})")
print(" ※ ±30%程度のずれでも費用増は数%に留まる(TCがQ*近傍で平坦なため)。\n")
print("[安全在庫と発注点]")
daily_mu, daily_sd, lead = D / 365, 12.0, 7
mu_L = daily_mu * lead
sd_L = daily_sd * math.sqrt(lead) # 独立を仮定すると分散は加法的
print(f" 日次需要 平均 {daily_mu:.1f}個 / 標準偏差 {daily_sd}個 / "
f"リードタイム {lead}日")
print(f" リードタイム中需要: 平均 {mu_L:.1f}個 / 標準偏差 {sd_L:.2f}個")
print(f"\n {'サービス率':>10}{'z値':>8}{'安全在庫':>10}{'発注点':>10}"
f"{'追加保管費/年':>14}")
for sl in [0.90, 0.95, 0.98, 0.99, 0.999]:
z = stats.norm.ppf(sl)
ss = z * sd_L
print(f" {sl:>10.1%}{z:>8.3f}{ss:>10.1f}{mu_L + ss:>10.1f}"
f"{ss * h:>14,.0f}")
print(" ※ サービス率を99%から99.9%へ上げる限界費用は逓増する"
"(正規分布の裾が薄いため)。")
print("\n[数量割引の検討] 1000個以上で単価3%引き")
for Q in [q, 1000, 1500]:
price = unit * (0.97 if Q >= 1000 else 1.0)
# 保管費が仕入価格に比例する場合(保管費率 20%)
hh = price * 0.20
tc = D * price + D / Q * K + Q / 2 * hh
print(f" Q = {Q:>7,.0f}: 単価 {price:,.0f}円 総費用 {tc:>14,.0f}円")
print(" ※ 購入額まで含めた総費用で比較するのが正しい判断基準。")
if __name__ == "__main__":
main()
実行結果
年間需要 24,000個 / 発注費 15,000円/回 / 保管費 240円/個年
最適発注量 Q* = 1,732.1 個
年間発注回数 = 13.86 回 / 発注間隔 = 26.3 日
最小総費用 = 415,692 円
内訳: 発注費 207,846円 = 保管費 207,846円 (EOQでは必ず一致する)
[数値探索による検証]
1個刻みの全探索: Q = 1732 個, TC = 415,692 円(解析解と一致)
[Qを誤って設定した場合の費用増(EOQの頑健性)]
Q = 50%·Q*: TC = 519,615円 (+25.00%)
Q = 70%·Q*: TC = 442,415円 (+6.43%)
Q = 90%·Q*: TC = 418,002円 (+0.56%)
Q = 100%·Q*: TC = 415,692円 (+0.00%)
Q = 110%·Q*: TC = 417,582円 (+0.45%)
Q = 150%·Q*: TC = 450,333円 (+8.33%)
Q = 200%·Q*: TC = 519,615円 (+25.00%)
※ ±30%程度のずれでも費用増は数%に留まる(TCがQ*近傍で平坦なため)。
[安全在庫と発注点]
日次需要 平均 65.8個 / 標準偏差 12.0個 / リードタイム 7日
リードタイム中需要: 平均 460.3個 / 標準偏差 31.75個
サービス率 z値 安全在庫 発注点 追加保管費/年
90.0% 1.282 40.7 501.0 9,765
95.0% 1.645 52.2 512.5 12,533
98.0% 2.054 65.2 525.5 15,649
99.0% 2.326 73.9 534.1 17,726
99.9% 3.090 98.1 558.4 23,547
※ サービス率を99%から99.9%へ上げる限界費用は逓増する(正規分布の裾が薄いため)。
[数量割引の検討] 1000個以上で単価3%引き
Q = 1,732: 単価 1,164円 総費用 28,345,457円
Q = 1,000: 単価 1,164円 総費用 28,412,400円
Q = 1,500: 単価 1,164円 総費用 28,350,600円
※ 購入額まで含めた総費用で比較するのが正しい判断基準。