economic-order-quantity について
購買や生産の意思決定に経済的発注量モデルを適用する在庫計画スキルです。基本 EOQ のほか、数量割引、段階的な生産、バックオーダー、制約を扱うモデルを解説します。
主な機能
- 01基本 EOQ の計算
- 02数量割引の分析
- 03経済的生産量の計算
- 04計画的バックオーダーのモデル化
- 05制約を考慮したロット計画
活用シーン
- 01発注費用と保管費用の均衡
- 02仕入先の数量割引の比較
- 03生産ロットサイズの設定
- 04計画的バックオーダーの評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kishorkukreja/ awesome-supply-chain --skill economic-order-quantityインストール後、Claude Code で/economic-order-quantityを入力して読み込まれたか確認します。
詳細
経済的発注量(EOQ)
経済的発注量(EOQ)モデルと在庫ロットサイズ最適化の専門家です。目的は、発注・段取りコストと在庫保有コストのバランスを取り、総在庫コストを最小化する最適な発注量または生産量の決定を支援することです。
初期評価
EOQを計算する前に、以下を確認します。
-
ビジネスの背景
- どのような種類の在庫ですか?(原材料、完成品、部品)
- 現在の発注方法はどのようなものですか?(固定数量、最小/最大、都度対応)
- 主なコスト上の懸念は何ですか?(発注、保有、欠品)
- 生産環境ですか、それとも購入環境ですか?
-
需要の特性
- 年間需要量(単位/年)はどれくらいですか?
- 需要パターンはどのようなものですか?(一定、季節性、確定的か確率的か)
- 需要率は安定していますか?
- 計画期間はどれくらいですか?
-
コストパラメーター
- 商品の単価($)はいくらですか?
- 1回あたりの発注コスト($)、または1回の生産にかかる段取りコストはいくらですか?
- 年間の在庫運搬/保有コスト率(%)はいくらですか?
- 欠品またはバックオーダーのコスト(該当する場合)はいくらですか?
-
運用上の制約
- サプライヤーの最小発注数量(MOQ)はありますか?
- 数量割引はありますか?
- 保管容量に制限はありますか?
- 生産率に制約はありますか?
- サプライヤーからのリードタイムはどれくらいですか?
EOQモデルの基礎
古典的なEOQモデル
前提条件:
- 需要は一定で、確実に把握されています
- リードタイムは一定で、確実に把握されています
- 欠品は許容されません(欠品コストは無限大)
- 発注品は一度にすべて到着します(補充は瞬時に行われます)
- 数量割引はありません
- 保有コストと発注コストは既知で一定です
決定事項: 総コストを最小化する発注量Qはいくらですか?
総コスト関数
TC(Q) = Purchase Cost + Ordering Cost + Holding Cost
TC(Q) = DC + (D/Q)S + (Q/2)H
Where:
D = Annual demand (units/year)
C = Unit cost ($/unit)
S = Ordering cost per order ($)
H = Holding cost per unit per year ($/unit/year)
Q = Order quantity (units)
D/Q = Number of orders per year
Q/2 = Average inventory level (assuming uniform demand)
EOQの公式の導出
目的: Qに関してTC(Q)を最小化します
微分し、ゼロに設定します。
dTC/dQ = -(DS)/Q² + H/2 = 0
Solving for Q:
-(DS)/Q² + H/2 = 0
H/2 = DS/Q²
Q² = 2DS/H
EOQ = √(2DS/H)
最適な発注頻度:
n* = D/EOQ = Number of orders per year
Cycle Time = 1/n* = EOQ/D years
最小総コスト:
TC* = DC + √(2DSH)
Pythonによる実装:古典的なEOQ
基本的なEOQ計算ツール
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import Dict, List, Tuple
class EOQModel:
"""
Classic Economic Order Quantity Model
Determines optimal order quantity to minimize total inventory costs
"""
def __init__(self, annual_demand: float, unit_cost: float,
ordering_cost: float, holding_cost_rate: float):
"""
Initialize EOQ model
Parameters:
-----------
annual_demand : float
Annual demand in units
unit_cost : float
Cost per unit ($)
ordering_cost : float
Fixed cost per order ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost (e.g., 0.25 = 25%)
"""
self.D = annual_demand
self.C = unit_cost
self.S = ordering_cost
self.r = holding_cost_rate
self.H = unit_cost * holding_cost_rate
def calculate_eoq(self) -> Dict:
"""Calculate Economic Order Quantity and associated metrics"""
# EOQ formula
eoq = np.sqrt((2 * self.D * self.S) / self.H)
# Number of orders per year
orders_per_year = self.D / eoq
# Cycle time (time between orders)
cycle_time_days = 365 / orders_per_year
# Cost components
purchase_cost = self.D * self.C
ordering_cost_total = (self.D / eoq) * self.S
holding_cost_total = (eoq / 2) * self.H
total_cost = purchase_cost + ordering_cost_total + holding_cost_total
# Total relevant cost (excluding purchase cost which is constant)
relevant_cost = ordering_cost_total + holding_cost_total
# Average inventory
avg_inventory = eoq / 2
avg_inventory_value = avg_inventory * self.C
return {
'eoq': round(eoq, 2),
'orders_per_year': round(orders_per_year, 2),
'cycle_time_days': round(cycle_time_days, 2),
'total_annual_cost': round(total_cost, 2),
'purchase_cost': round(purchase_cost, 2),
'ordering_cost': round(ordering_cost_total, 2),
'holding_cost': round(holding_cost_total, 2),
'relevant_cost': round(relevant_cost, 2),
'avg_inventory': round(avg_inventory, 2),
'avg_inventory_value': round(avg_inventory_value, 2),
'max_inventory': round(eoq, 2)
}
def sensitivity_analysis(self, param_name: str,
variation_range: Tuple[float, float],
num_points: int = 20) -> pd.DataFrame:
"""
Perform sensitivity analysis on a parameter
Parameters:
-----------
param_name : str
Parameter to vary: 'demand', 'ordering_cost', 'holding_rate', 'unit_cost'
variation_range : tuple
(min_multiplier, max_multiplier) e.g., (0.5, 2.0) for 50% to 200%
num_points : int
Number of points to evaluate
"""
multipliers = np.linspace(variation_range[0], variation_range[1], num_points)
results = []
# Store original values
orig_D, orig_S, orig_r, orig_C = self.D, self.S, self.r, self.C
for mult in multipliers:
# Vary parameter
if param_name == 'demand':
self.D = orig_D * mult
elif param_name == 'ordering_cost':
self.S = orig_S * mult
elif param_name == 'holding_rate':
self.r = orig_r * mult
self.H = self.C * self.r
elif param_name == 'unit_cost':
self.C = orig_C * mult
self.H = self.C * self.r
# Calculate EOQ at this parameter value
metrics = self.calculate_eoq()
results.append({
'multiplier': mult,
f'{param_name}_value': getattr(self, param_name.split('_')[0][0].upper()),
'eoq': metrics['eoq'],
'total_cost': metrics['total_annual_cost'],
'relevant_cost': metrics['relevant_cost'],
'orders_per_year': metrics['orders_per_year']
})
# Restore original values
self.D, self.S, self.r, self.C = orig_D, orig_S, orig_r, orig_C
self.H = self.C * self.r
return pd.DataFrame(results)
def plot_cost_curve(self, q_range: Tuple[float, float] = None):
"""
Plot total cost curve showing EOQ optimum
Parameters:
-----------
q_range : tuple, optional
(min_q, max_q) range for plotting. If None, uses EOQ ± 50%
"""
eoq_result = self.calculate_eoq()
eoq = eoq_result['eoq']
if q_range is None:
q_min = max(1, eoq * 0.3)
q_max = eoq * 2.0
else:
q_min, q_max = q_range
# Generate Q values
Q = np.linspace(q_min, q_max, 200)
# Calculate costs for each Q
ordering_costs = (self.D / Q) * self.S
holding_costs = (Q / 2) * self.H
total_relevant_costs = ordering_costs + holding_costs
# Plot
plt.figure(figsize=(12, 7))
plt.plot(Q, ordering_costs, label='Ordering Cost', linewidth=2, color='blue')
plt.plot(Q, holding_costs, label='Holding Cost', linewidth=2, color='green')
plt.plot(Q, total_relevant_costs, label='Total Relevant Cost',
linewidth=3, color='red')
# Mark EOQ
plt.axvline(x=eoq, color='purple', linestyle='--', linewidth=2,
label=f'EOQ = {eoq:.0f}')
plt.plot(eoq, eoq_result['relevant_cost'], 'ro', markersize=12,
label=f"Min Cost = ${eoq_result['relevant_cost']:,.0f}")
plt.xlabel('Order Quantity (Q)', fontsize=12)
plt.ylabel('Annual Cost ($)', fontsize=12)
plt.title('EOQ Cost Analysis', fontsize=14, fontweight='bold')
plt.legend(fontsize=10)
plt.grid(True, alpha=0.3)
plt.tight_layout()
return plt
def reorder_point(self, lead_time_days: float,
safety_stock: float = 0) -> Dict:
"""
Calculate reorder point given lead time
Parameters:
-----------
lead_time_days : float
Lead time in days
safety_stock : float
Safety stock quantity (default 0 for deterministic demand)
"""
daily_demand = self.D / 365
lead_time_demand = daily_demand * lead_time_days
rop = lead_time_demand + safety_stock
return {
'reorder_point': round(rop, 2),
'lead_time_demand': round(lead_time_demand, 2),
'safety_stock': safety_stock,
'daily_demand': round(daily_demand, 2)
}
# Example Usage
def example_basic_eoq():
"""Example: Basic EOQ calculation"""
# Create EOQ model
model = EOQModel(
annual_demand=10000, # 10,000 units per year
unit_cost=50, # $50 per unit
ordering_cost=100, # $100 per order
holding_cost_rate=0.25 # 25% per year
)
# Calculate EOQ
result = model.calculate_eoq()
print("=" * 60)
print("ECONOMIC ORDER QUANTITY ANALYSIS")
print("=" * 60)
print(f"\nInput Parameters:")
print(f" Annual Demand: {model.D:,} units")
print(f" Unit Cost: ${model.C:.2f}")
print(f" Ordering Cost: ${model.S:.2f}")
print(f" Holding Cost Rate: {model.r:.1%}")
print(f" Holding Cost per Unit: ${model.H:.2f}/unit/year")
print(f"\n{'Optimal Order Quantity (EOQ):':<35} {result['eoq']:>15,.0f} units")
print(f"{'Orders per Year:':<35} {result['orders_per_year']:>15,.2f} orders")
print(f"{'Days Between Orders:':<35} {result['cycle_time_days']:>15,.1f} days")
print(f"\n{'Cost Breakdown:':<35}")
print(f" {'Purchase Cost:':<33} ${result['purchase_cost']:>15,.2f}")
print(f" {'Ordering Cost:':<33} ${result['ordering_cost']:>15,.2f}")
print(f" {'Holding Cost:':<33} ${result['holding_cost']:>15,.2f}")
print(f" {'-'*50}")
print(f" {'Total Annual Cost:':<33} ${result['total_annual_cost']:>15,.2f}")
print(f"\n{'Inventory Levels:':<35}")
print(f" {'Average Inventory:':<33} {result['avg_inventory']:>15,.0f} units")
print(f" {'Average Inventory Value:':<33} ${result['avg_inventory_value']:>15,.2f}")
print(f" {'Maximum Inventory:':<33} {result['max_inventory']:>15,.0f} units")
# Calculate reorder point
rop = model.reorder_point(lead_time_days=14)
print(f"\n{'Reorder Point (14 day lead time):':<35} {rop['reorder_point']:>15,.0f} units")
# Plot cost curve
model.plot_cost_curve()
plt.savefig('/tmp/eoq_cost_curve.png', dpi=300, bbox_inches='tight')
print(f"\nCost curve saved to /tmp/eoq_cost_curve.png")
return model, result
if __name__ == "__main__":
model, result = example_basic_eoq()
数量割引を考慮したEOQ
全数量割引モデル
サプライヤーが発注量に応じて価格を段階的に引き下げる場合、単価の低下と保有コストの増加とのトレードオフを考慮する必要があります。
全数量割引: 割引は注文内のすべての単位に適用されます。
class EOQWithQuantityDiscounts:
"""
EOQ model with all-units quantity discounts
Evaluates each price break to find the quantity that minimizes total cost
"""
def __init__(self, annual_demand: float, ordering_cost: float,
holding_cost_rate: float, price_breaks: List[Tuple[float, float]]):
"""
Parameters:
-----------
annual_demand : float
Annual demand in units
ordering_cost : float
Fixed cost per order ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost
price_breaks : list of tuples
[(min_qty, unit_price), ...] sorted by quantity
Example: [(0, 50), (500, 48), (1000, 45)]
"""
self.D = annual_demand
self.S = ordering_cost
self.r = holding_cost_rate
self.price_breaks = sorted(price_breaks, key=lambda x: x[0])
def calculate_optimal_quantity(self) -> Dict:
"""
Find order quantity that minimizes total cost
Algorithm:
1. For each price break, calculate EOQ at that price
2. If EOQ < break quantity, use break quantity
3. Calculate total cost for each feasible quantity
4. Select quantity with minimum total cost
"""
candidates = []
for i, (break_qty, unit_price) in enumerate(self.price_breaks):
H = unit_price * self.r
# Calculate EOQ at this price
eoq = np.sqrt((2 * self.D * self.S) / H)
# Determine feasible order quantity
# EOQ is feasible if it's >= current break and < next break
if i < len(self.price_breaks) - 1:
next_break_qty = self.price_breaks[i + 1][0]
if eoq >= break_qty and eoq < next_break_qty:
feasible_qty = eoq
else:
feasible_qty = break_qty
else:
# Last price break - EOQ is feasible if >= break qty
feasible_qty = max(eoq, break_qty)
# Calculate total cost at this quantity
if feasible_qty >= break_qty:
ordering_cost = (self.D / feasible_qty) * self.S
holding_cost = (feasible_qty / 2) * H
purchase_cost = self.D * unit_price
total_cost = purchase_cost + ordering_cost + holding_cost
candidates.append({
'break_quantity': break_qty,
'unit_price': unit_price,
'eoq_at_price': round(eoq, 2),
'feasible_quantity': round(feasible_qty, 2),
'purchase_cost': round(purchase_cost, 2),
'ordering_cost': round(ordering_cost, 2),
'holding_cost': round(holding_cost, 2),
'total_cost': round(total_cost, 2),
'orders_per_year': round(self.D / feasible_qty, 2)
})
# Find minimum cost option
candidates_df = pd.DataFrame(candidates)
optimal_idx = candidates_df['total_cost'].idxmin()
optimal = candidates_df.iloc[optimal_idx]
return {
'all_candidates': candidates_df,
'optimal': optimal.to_dict(),
'savings_vs_no_discount': self._calculate_savings(optimal)
}
def _calculate_savings(self, optimal: pd.Series) -> float:
"""Calculate savings compared to ordering at highest price (no discount)"""
highest_price = self.price_breaks[0][1]
H_base = highest_price * self.r
eoq_base = np.sqrt((2 * self.D * self.S) / H_base)
cost_base = (self.D * highest_price +
(self.D / eoq_base) * self.S +
(eoq_base / 2) * H_base)
savings = cost_base - optimal['total_cost']
savings_pct = (savings / cost_base) * 100
return round(savings_pct, 2)
def plot_discount_analysis(self):
"""Visualize total cost at different order quantities"""
result = self.calculate_optimal_quantity()
plt.figure(figsize=(14, 8))
# Generate cost curves for each price segment
for i, (break_qty, unit_price) in enumerate(self.price_breaks):
H = unit_price * self.r
# Define quantity range for this price
if i < len(self.price_breaks) - 1:
q_max = self.price_breaks[i + 1][0]
else:
q_max = break_qty * 3
Q = np.linspace(break_qty, q_max, 100)
# Calculate total cost
total_costs = (self.D * unit_price +
(self.D / Q) * self.S +
(Q / 2) * H)
plt.plot(Q, total_costs, linewidth=2,
label=f'${unit_price}/unit (Q ≥ {break_qty})')
# Mark optimal point
optimal = result['optimal']
plt.plot(optimal['feasible_quantity'], optimal['total_cost'],
'r*', markersize=20, label=f"Optimal: Q={optimal['feasible_quantity']:.0f}")
# Mark all candidates
for _, row in result['all_candidates'].iterrows():
plt.plot(row['feasible_quantity'], row['total_cost'],
'ko', markersize=8)
plt.xlabel('Order Quantity (Q)', fontsize=12)
plt.ylabel('Total Annual Cost ($)', fontsize=12)
plt.title('EOQ with Quantity Discounts', fontsize=14, fontweight='bold')
plt.legend(fontsize=10)
plt.grid(True, alpha=0.3)
plt.tight_layout()
return plt
# Example: Quantity Discounts
def example_quantity_discounts():
"""Example: EOQ with all-units quantity discount"""
price_breaks = [
(0, 50.00), # 0-499 units: $50/unit
(500, 48.00), # 500-999 units: $48/unit
(1000, 45.00), # 1000+ units: $45/unit
(2500, 43.00) # 2500+ units: $43/unit
]
model = EOQWithQuantityDiscounts(
annual_demand=10000,
ordering_cost=100,
holding_cost_rate=0.25,
price_breaks=price_breaks
)
result = model.calculate_optimal_quantity()
print("\n" + "=" * 70)
print("EOQ WITH QUANTITY DISCOUNTS ANALYSIS")
print("=" * 70)
print("\nPrice Break Structure:")
for qty, price in price_breaks:
print(f" {qty:>6} units or more: ${price:.2f}/unit")
print("\n\nCandidate Solutions:")
print(result['all_candidates'].to_string(index=False))
print("\n\n" + "=" * 70)
print("OPTIMAL SOLUTION")
print("=" * 70)
opt = result['optimal']
print(f"{'Order Quantity:':<30} {opt['feasible_quantity']:>15,.0f} units")
print(f"{'Unit Price:':<30} ${opt['unit_price']:>15,.2f}")
print(f"{'Orders per Year:':<30} {opt['orders_per_year']:>15,.2f}")
print(f"\n{'Cost Breakdown:':<30}")
print(f" {'Purchase Cost:':<28} ${opt['purchase_cost']:>15,.2f}")
print(f" {'Ordering Cost:':<28} ${opt['ordering_cost']:>15,.2f}")
print(f" {'Holding Cost:':<28} ${opt['holding_cost']:>15,.2f}")
print(f" {'-'*45}")
print(f" {'Total Annual Cost:':<28} ${opt['total_cost']:>15,.2f}")
print(f"\n{'Savings vs No Discount:':<30} {result['savings_vs_no_discount']:>15,.1f}%")
# Plot
model.plot_discount_analysis()
plt.savefig('/tmp/eoq_quantity_discounts.png', dpi=300, bbox_inches='tight')
print(f"\nDiscount analysis plot saved to /tmp/eoq_quantity_discounts.png")
return model, result
if __name__ == "__main__":
example_quantity_discounts()
経済的生産量(EPQ)
生産環境向けEPQモデル
商品を購入するのではなく社内で生産する場合、補充は段階的に行われます(瞬時ではありません)。これが経済的生産量(EPQ)モデルです。
前提条件:
- 生産率(p)は需要率(d)を上回ります
- 生産は一定の率p単位/日で行われます
- 欠品は許容されません
- 生産のたびに段取りコストSが発生します
EOQとの主な違い: 生産中に在庫が徐々に積み上がります。
class EPQModel:
"""
Economic Production Quantity (EPQ) Model
Optimal production lot size when items are produced internally
at a finite production rate
"""
def __init__(self, annual_demand: float, production_rate: float,
unit_cost: float, setup_cost: float, holding_cost_rate: float):
"""
Parameters:
-----------
annual_demand : float
Annual demand rate (units/year)
production_rate : float
Production rate (units/year)
unit_cost : float
Cost per unit ($)
setup_cost : float
Fixed cost per production run ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost
"""
self.D = annual_demand
self.p = production_rate
self.C = unit_cost
self.S = setup_cost
self.r = holding_cost_rate
self.H = unit_cost * holding_cost_rate
# Validate: production rate must exceed demand rate
if self.p <= self.D:
raise ValueError("Production rate must exceed demand rate")
def calculate_epq(self) -> Dict:
"""
Calculate Economic Production Quantity
EPQ Formula:
EPQ = √(2DS/H) × √(p/(p-D))
The term √(p/(p-D)) adjusts for gradual replenishment
"""
# EPQ formula
epq = np.sqrt((2 * self.D * self.S) / self.H) * np.sqrt(self.p / (self.p - self.D))
# Production runs per year
runs_per_year = self.D / epq
# Production time per run (days)
production_time = (epq / self.p) * 365
# Maximum inventory level
# Inventory builds while producing and consuming
max_inventory = epq * (1 - self.D / self.p)
# Average inventory (half of maximum)
avg_inventory = max_inventory / 2
# Cost components
production_cost = self.D * self.C
setup_cost_total = runs_per_year * self.S
holding_cost_total = avg_inventory * self.H
total_cost = production_cost + setup_cost_total + holding_cost_total
# Cycle time
cycle_time_days = 365 / runs_per_year
return {
'epq': round(epq, 2),
'runs_per_year': round(runs_per_year, 2),
'production_time_days': round(production_time, 2),
'cycle_time_days': round(cycle_time_days, 2),
'max_inventory': round(max_inventory, 2),
'avg_inventory': round(avg_inventory, 2),
'total_annual_cost': round(total_cost, 2),
'production_cost': round(production_cost, 2),
'setup_cost': round(setup_cost_total, 2),
'holding_cost': round(holding_cost_total, 2),
'utilization': round((self.D / self.p) * 100, 2)
}
def simulate_inventory_cycle(self, num_cycles: int = 3) -> pd.DataFrame:
"""
Simulate inventory levels over multiple production cycles
Returns DataFrame with daily inventory levels
"""
result = self.calculate_epq()
epq = result['epq']
cycle_time = result['cycle_time_days']
production_time = result['production_time_days']
daily_demand = self.D / 365
daily_production = self.p / 365
inventory_data = []
current_inventory = 0
total_days = int(cycle_time * num_cycles)
for day in range(total_days):
cycle_day = day % cycle_time
# During production phase
if cycle_day < production_time:
# Inventory increases by net production
current_inventory += (daily_production - daily_demand)
else:
# Only consuming inventory
current_inventory -= daily_demand
# Prevent negative inventory (numerical precision)
current_inventory = max(0, current_inventory)
inventory_data.append({
'day': day,
'cycle': day // cycle_time + 1,
'cycle_day': cycle_day,
'inventory': current_inventory,
'producing': cycle_day < production_time
})
return pd.DataFrame(inventory_data)
def plot_inventory_cycle(self, num_cycles: int = 3):
"""Visualize inventory levels over production cycles"""
df = self.simulate_inventory_cycle(num_cycles)
result = self.calculate_epq()
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10))
# Plot 1: Inventory over time
production_periods = df[df['producing'] == True]
consumption_periods = df[df['producing'] == False]
ax1.plot(df['day'], df['inventory'], linewidth=2, color='blue')
ax1.fill_between(production_periods['day'], 0, production_periods['inventory'],
alpha=0.3, color='green', label='Production Phase')
ax1.axhline(y=result['avg_inventory'], color='red', linestyle='--',
linewidth=2, label=f"Avg Inventory = {result['avg_inventory']:.0f}")
ax1.axhline(y=result['max_inventory'], color='orange', linestyle='--',
linewidth=2, label=f"Max Inventory = {result['max_inventory']:.0f}")
ax1.set_xlabel('Day', fontsize=12)
ax1.set_ylabel('Inventory Level (units)', fontsize=12)
ax1.set_title('EPQ Inventory Cycle', fontsize=14, fontweight='bold')
ax1.legend(fontsize=10)
ax1.grid(True, alpha=0.3)
# Plot 2: Single cycle detail
single_cycle = df[df['cycle'] == 1]
ax2.plot(single_cycle['cycle_day'], single_cycle['inventory'],
linewidth=3, color='blue', marker='o', markersize=4)
ax2.axvline(x=result['production_time_days'], color='red',
linestyle='--', linewidth=2,
label=f"Production Time = {result['production_time_days']:.1f} days")
ax2.fill_between(single_cycle['cycle_day'], 0, single_cycle['inventory'],
where=single_cycle['producing'], alpha=0.3, color='green',
label='Producing')
ax2.fill_between(single_cycle['cycle_day'], 0, single_cycle['inventory'],
where=~single_cycle['producing'], alpha=0.3, color='red',
label='Only Consuming')
ax2.set_xlabel('Days in Cycle', fontsize=12)
ax2.set_ylabel('Inventory Level (units)', fontsize=12)
ax2.set_title('Single Production Cycle Detail', fontsize=14, fontweight='bold')
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
return plt
# Example: EPQ
def example_epq():
"""Example: Economic Production Quantity calculation"""
model = EPQModel(
annual_demand=10000, # 10,000 units/year demand
production_rate=50000, # Can produce 50,000 units/year
unit_cost=50, # $50 per unit
setup_cost=500, # $500 setup cost per run
holding_cost_rate=0.25 # 25% holding cost
)
result = model.calculate_epq()
print("\n" + "=" * 70)
print("ECONOMIC PRODUCTION QUANTITY (EPQ) ANALYSIS")
print("=" * 70)
print(f"\nInput Parameters:")
print(f" Annual Demand: {model.D:,} units/year")
print(f" Production Rate: {model.p:,} units/year")
print(f" Unit Cost: ${model.C:.2f}")
print(f" Setup Cost: ${model.S:.2f}")
print(f" Holding Cost Rate: {model.r:.1%}")
print(f"\n{'Optimal Production Quantity (EPQ):':<40} {result['epq']:>15,.0f} units")
print(f"{'Production Runs per Year:':<40} {result['runs_per_year']:>15,.2f} runs")
print(f"{'Days Between Production Runs:':<40} {result['cycle_time_days']:>15,.1f} days")
print(f"{'Production Time per Run:':<40} {result['production_time_days']:>15,.1f} days")
print(f"\n{'Inventory Levels:':<40}")
print(f" {'Maximum Inventory:':<38} {result['max_inventory']:>15,.0f} units")
print(f" {'Average Inventory:':<38} {result['avg_inventory']:>15,.0f} units")
print(f"\n{'Cost Breakdown:':<40}")
print(f" {'Production Cost:':<38} ${result['production_cost']:>15,.2f}")
print(f" {'Setup Cost:':<38} ${result['setup_cost']:>15,.2f}")
print(f" {'Holding Cost:':<38} ${result['holding_cost']:>15,.2f}")
print(f" {'-'*55}")
print(f" {'Total Annual Cost:':<38} ${result['total_annual_cost']:>15,.2f}")
print(f"\n{'Production Capacity Utilization:':<40} {result['utilization']:>15,.1f}%")
# Plot
model.plot_inventory_cycle(num_cycles=3)
plt.savefig('/tmp/epq_inventory_cycle.png', dpi=300, bbox_inches='tight')
print(f"\nInventory cycle plot saved to /tmp/epq_inventory_cycle.png")
return model, result
if __name__ == "__main__":
example_epq()
バックオーダーを計画するEOQ
バックオーダーモデル
バックオーダーが許容され、ペナルティコストが発生する場合、意図的に欠品を計画するほうが経済的なことがあります。
決定変数:
- Q = 発注量
- S = 最大在庫水準(Q未満)
- Q - S = 計画するバックオーダー数量
class EOQWithBackorders:
"""
EOQ model with planned backorders
Allows intentional stockouts when backorder cost is lower than holding cost
"""
def __init__(self, annual_demand: float, unit_cost: float,
ordering_cost: float, holding_cost_rate: float,
backorder_cost_rate: float):
"""
Parameters:
-----------
annual_demand : float
Annual demand in units
unit_cost : float
Cost per unit ($)
ordering_cost : float
Fixed cost per order ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost
backorder_cost_rate : float
Annual backorder cost per unit ($/unit/year)
"""
self.D = annual_demand
self.C = unit_cost
self.S_cost = ordering_cost
self.H = unit_cost * holding_cost_rate
self.B = backorder_cost_rate
def calculate_optimal(self) -> Dict:
"""
Calculate optimal order quantity and maximum inventory level
Formulas:
Q* = √(2DS/H) × √((H+B)/B)
S* = Q* × (B/(H+B))
Where S* is the maximum inventory level (peak before stockout)
"""
# Basic EOQ (without backorders)
eoq_basic = np.sqrt((2 * self.D * self.S_cost) / self.H)
# Optimal order quantity with backorders
Q_opt = eoq_basic * np.sqrt((self.H + self.B) / self.B)
# Maximum inventory level
S_opt = Q_opt * (self.B / (self.H + self.B))
# Maximum backorder level
backorder_max = Q_opt - S_opt
# Time fractions
time_with_inventory = S_opt / self.D # years
time_with_backorders = backorder_max / self.D # years
cycle_time = Q_opt / self.D # years
# Cost calculation
orders_per_year = self.D / Q_opt
ordering_cost = orders_per_year * self.S_cost
# Average inventory (triangular distribution)
avg_inventory = (S_opt ** 2) / (2 * Q_opt)
holding_cost = avg_inventory * self.H
# Average backorders
avg_backorders = (backorder_max ** 2) / (2 * Q_opt)
backorder_cost_total = avg_backorders * self.B
purchase_cost = self.D * self.C
total_cost = purchase_cost + ordering_cost + holding_cost + backorder_cost_total
# Compare with basic EOQ (no backorders)
cost_basic = (purchase_cost +
(self.D / eoq_basic) * self.S_cost +
(eoq_basic / 2) * self.H)
savings = cost_basic - total_cost
savings_pct = (savings / cost_basic) * 100
return {
'order_quantity': round(Q_opt, 2),
'max_inventory': round(S_opt, 2),
'max_backorders': round(backorder_max, 2),
'orders_per_year': round(orders_per_year, 2),
'cycle_time_days': round(cycle_time * 365, 2),
'time_with_inventory_days': round(time_with_inventory * 365, 2),
'time_with_backorders_days': round(time_with_backorders * 365, 2),
'avg_inventory': round(avg_inventory, 2),
'avg_backorders': round(avg_backorders, 2),
'total_cost': round(total_cost, 2),
'ordering_cost': round(ordering_cost, 2),
'holding_cost': round(holding_cost, 2),
'backorder_cost': round(backorder_cost_total, 2),
'cost_without_backorders': round(cost_basic, 2),
'savings': round(savings, 2),
'savings_pct': round(savings_pct, 2)
}
def plot_backorder_cycle(self):
"""Visualize inventory cycle with planned backorders"""
result = self.calculate_optimal()
Q = result['order_quantity']
S = result['max_inventory']
# Time points (in fractions of year)
t1 = S / self.D # Time to deplete inventory
t2 = Q / self.D # Full cycle time
# Convert to days for plotting
t1_days = t1 * 365
t2_days = t2 * 365
# Create time array for one cycle
t_positive = np.linspace(0, t1_days, 100)
t_negative = np.linspace(t1_days, t2_days, 100)
# Inventory levels
inv_positive = S - (self.D / 365) * t_positive
inv_negative = -(self.D / 365) * (t_negative - t1_days)
plt.figure(figsize=(14, 8))
# Plot inventory (positive)
plt.fill_between(t_positive, 0, inv_positive, alpha=0.3, color='green',
label='On-hand Inventory')
plt.plot(t_positive, inv_positive, linewidth=3, color='green')
# Plot backorders (negative)
plt.fill_between(t_negative, 0, inv_negative, alpha=0.3, color='red',
label='Backorders')
plt.plot(t_negative, inv_negative, linewidth=3, color='red')
# Mark key points
plt.plot(0, S, 'go', markersize=12, label=f'Order Arrives: +{S:.0f} units')
plt.plot(t1_days, 0, 'yo', markersize=12, label='Stockout Begins')
plt.plot(t2_days, -result['max_backorders'], 'ro', markersize=12,
label=f"Max Backorder: {result['max_backorders']:.0f} units")
# Add average lines
plt.axhline(y=result['avg_inventory'], color='green', linestyle='--',
linewidth=2, alpha=0.7, label=f"Avg Inventory: {result['avg_inventory']:.1f}")
plt.axhline(y=-result['avg_backorders'], color='red', linestyle='--',
linewidth=2, alpha=0.7, label=f"Avg Backorders: {result['avg_backorders']:.1f}")
plt.axhline(y=0, color='black', linewidth=1)
plt.axvline(x=t1_days, color='gray', linestyle=':', linewidth=2)
plt.xlabel('Days', fontsize=12)
plt.ylabel('Inventory Level (units)', fontsize=12)
plt.title('EOQ with Planned Backorders - Inventory Cycle',
fontsize=14, fontweight='bold')
plt.legend(fontsize=10, loc='upper right')
plt.grid(True, alpha=0.3)
plt.tight_layout()
return plt
# Example: Backorders
def example_backorders():
"""Example: EOQ with planned backorders"""
model = EOQWithBackorders(
annual_demand=10000,
unit_cost=50,
ordering_cost=100,
holding_cost_rate=0.25,
backorder_cost_rate=5.0 # $5 per unit per year backordered
)
result = model.calculate_optimal()
print("\n" + "=" * 70)
print("EOQ WITH PLANNED BACKORDERS ANALYSIS")
print("=" * 70)
print(f"\nInput Parameters:")
print(f" Annual Demand: {model.D:,} units")
print(f" Holding Cost: ${model.H:.2f}/unit/year")
print(f" Backorder Cost: ${model.B:.2f}/unit/year")
print(f" Ordering Cost: ${model.S_cost:.2f}/order")
print(f"\n{'Optimal Policy:':<45}")
print(f" {'Order Quantity:':<43} {result['order_quantity']:>15,.0f} units")
print(f" {'Maximum Inventory (before stockout):':<43} {result['max_inventory']:>15,.0f} units")
print(f" {'Maximum Backorders:':<43} {result['max_backorders']:>15,.0f} units")
print(f"\n{'Cycle Characteristics:':<45}")
print(f" {'Orders per Year:':<43} {result['orders_per_year']:>15,.2f}")
print(f" {'Cycle Time:':<43} {result['cycle_time_days']:>15,.1f} days")
print(f" {'Time with Inventory:':<43} {result['time_with_inventory_days']:>15,.1f} days")
print(f" {'Time with Backorders:':<43} {result['time_with_backorders_days']:>15,.1f} days")
print(f"\n{'Average Levels:':<45}")
print(f" {'Average Inventory:':<43} {result['avg_inventory']:>15,.1f} units")
print(f" {'Average Backorders:':<43} {result['avg_backorders']:>15,.1f} units")
print(f"\n{'Cost Analysis:':<45}")
print(f" {'Ordering Cost:':<43} ${result['ordering_cost']:>15,.2f}")
print(f" {'Holding Cost:':<43} ${result['holding_cost']:>15,.2f}")
print(f" {'Backorder Cost:':<43} ${result['backorder_cost']:>15,.2f}")
print(f" {'-'*60}")
print(f" {'Total Annual Cost (with backorders):':<43} ${result['total_cost']:>15,.2f}")
print(f" {'Total Cost without Backorders:':<43} ${result['cost_without_backorders']:>15,.2f}")
print(f" {'-'*60}")
print(f" {'Annual Savings:':<43} ${result['savings']:>15,.2f}")
print(f" {'Savings Percentage:':<43} {result['savings_pct']:>15,.1f}%")
# Plot
model.plot_backorder_cycle()
plt.savefig('/tmp/eoq_backorders.png', dpi=300, bbox_inches='tight')
print(f"\nBackorder cycle plot saved to /tmp/eoq_backorders.png")
return model, result
if __name__ == "__main__":
example_backorders()
ツールとライブラリ
Pythonライブラリ
数値計算・科学計算:
numpy:配列操作、数学関数scipy:最適化、統計分布pandas:データの操作と分析
最適化:
pulp:制約付きEOQ向け線形計画法scipy.optimize:非線形最適化
可視化:
matplotlib:コスト曲線、在庫サイクルのプロットseaborn:統計的な可視化plotly:インタラクティブなダッシュボード
商用ソフトウェア
EOQ に対応した ERP システム:
- SAP:ロットサイズ決定機能を備えた資材所要量計画(MRP)
- Oracle EBS:EOQ ルールに対応した受注管理
- Microsoft Dynamics:在庫最適化モジュール
- NetSuite:EOQ に対応した需要計画
専門的な在庫計画:
- Blue Yonder:高度な在庫最適化
- Logility:ロットサイズ決定と補充計画
- Kinaxis RapidResponse:在庫ポリシーの最適化
- Llamasoft:在庫分析を含むサプライチェーン設計
よくある課題と解決策
課題:需要の不確実性
問題:
- 従来の EOQ は需要が一定で既知であることを前提としています
- 実際の需要は変動し、不確実です
解決策:
- EOQ の計算には期待需要 D を使用します
- 需要の変動に備えて安全在庫を追加します(stochastic-inventory-models を参照)
- EOQ を定期的に再計算します(月次/四半期ごと)
- 需要シナリオを用いたロバスト最適化を検討します
- 再発注点を用いた(Q, r)連続レビュー方式を使用します
課題:保管コストが不明
問題:
- 実際の保管コスト率を見積もるのは困難です
- 資本、保管、陳腐化、損傷が含まれます
解決策:
- 業界のベンチマークを使用します(通常、単価の 15~35%)
- 加重平均資本コスト(WACC)を最低値として計算します
- 保管コスト(平方フィートあたりのコスト × 単位あたりのスペース)を加算します
- 保険料と陳腐化コストを含めます
- 保管コスト率の感度分析を実施します
- 妥当な初期値として 25%を使用します
課題:数量の制約
問題:
- サプライヤーの MOQ が EOQ と異なる場合があります
- 梱包上の制約があります(指定の倍数で発注する必要があります)
- 保管容量に上限があります
解決策:
- MOQ > EOQ の場合:MOQ を発注します(保管コストの増加を受け入れます)
- MOQ < EOQ の場合:EOQ を梱包単位に切り上げて発注します
- 容量に制約がある場合:制約付き最適化を使用します
- サプライヤーと交渉し、MOQ の引き下げを求めます
- 容量の共有制約を伴う複数品目 EOQ を検討します
課題:予算制約のある複数品目
問題:
- EOQ は各 SKU について個別に計算されます
- 予算または保管容量に限りがあります
解決策:
- 制約付き最適化問題として定式化します
- 連続緩和にはラグランジュ乗数を使用します
- ABC 分析を実施し、A 品目に注力します
- 厳密解には動的計画法を使用します
- EOQ 比に基づく比例配分を検討します
from scipy.optimize import minimize
def multi_item_eoq_constrained(items: List[Dict], budget: float) -> Dict:
"""
Multi-item EOQ with budget constraint
items: list of dicts with keys: demand, ordering_cost, holding_cost
budget: total budget for average inventory investment
"""
n = len(items)
def objective(Q):
"""Minimize total relevant cost"""
total_cost = 0
for i, q in enumerate(Q):
item = items[i]
ordering_cost = (item['demand'] / q) * item['ordering_cost']
holding_cost = (q / 2) * item['holding_cost']
total_cost += ordering_cost + holding_cost
return total_cost
def budget_constraint(Q):
"""Average inventory value <= budget"""
total_inventory_value = 0
for i, q in enumerate(Q):
avg_inventory = q / 2
total_inventory_value += avg_inventory * items[i]['unit_cost']
return budget - total_inventory_value
# Initial guess: unconstrained EOQ
Q0 = [np.sqrt(2 * item['demand'] * item['ordering_cost'] / item['holding_cost'])
for item in items]
# Constraints
cons = [{'type': 'ineq', 'fun': budget_constraint}]
# Bounds: positive quantities
bounds = [(1, None) for _ in range(n)]
# Solve
result = minimize(objective, Q0, method='SLSQP', bounds=bounds, constraints=cons)
return {
'optimal_quantities': result.x,
'total_cost': result.fun,
'success': result.success
}
課題:時間とともに変化するパラメーター
問題:
- 需要、コスト、または価格が時間とともに変化します
- 季節的な需要パターンがあります
- インフレーションがあります
解決策:
- EOQ を定期的に再計算します(月次/四半期ごと)
- D には移動平均需要を使用します
- 有限期間のロットサイズ決定モデルを検討します(dynamic-lot-sizing を参照)
- 予測を更新しながら動的 EOQ を実施します
- 季節性がある場合:季節ごとに個別の EOQ を計算します
課題:リードタイムの不確実性
問題:
- EOQ はリードタイムの変動を考慮しません
- 補充中に在庫切れになるリスクがあります
解決策:
- EOQ は Q を決定し、別途設定する再発注点(r)がリードタイムに対応します
- リードタイムの変動に備えて安全在庫を計算します
- (Q, r)方式を使用します:在庫が r に達したら Q を発注します
- 安全在庫の計算については stochastic-inventory-models を参照してください
- サプライヤーのパフォーマンスを監視し、リードタイムの見積もりを調整します
出力形式
EOQ分析レポート
エグゼクティブサマリー:
- 現在の発注方法:73日ごとに2,000個を発注
- 推奨EOQ:46日ごとに1,265個を発注
- 年間削減額:$3,450(コストを12%削減)
- 導入方法:発注数量と頻度を調整
入力パラメーター:
| パラメーター | 値 |
|---|---|
| 年間需要 | 10,000個 |
| 単価 | $50.00 |
| 発注コスト | $100/回 |
| 在庫保有コスト率 | 25%/年 |
| 在庫保有コスト | $12.50/個/年 |
最適な方針:
| 指標 | 現在 | 最適(EOQ) | 改善 |
|---|---|---|---|
| 発注数量 | 2,000 | 1,265 | 該当なし |
| 年間発注回数 | 5 | 7.9 | +58% |
| 発注間隔(日) | 73 | 46 | -37% |
| 平均在庫 | 1,000 | 633 | -37% |
| 年間総コスト | $531,250 | $527,800 | -$3,450 (-0.65%) |
コストの内訳:
| コスト項目 | 現在 | 最適(EOQ) | 差額 |
|---|---|---|---|
| 購入コスト | $500,000 | $500,000 | $0 |
| 発注コスト | $500 | $790 | +$290 |
| 在庫保有コスト | $12,500 | $7,910 | -$4,590 |
| 合計 | $513,000 | $508,700 | -$4,300 |
再発注点:
- リードタイム:14日
- 1日あたりの平均需要:27.4個
- リードタイム中の需要:384個
- 推奨再発注点:385個(安全在庫なしの場合)
導入に関する推奨事項:
- 発注数量を1,265個に調整します(梱包上の制約がある場合は1,250個に丸めます)
- 再発注点を385個に設定します
- 在庫を監視し、再発注点に達したら発注します
- 四半期ごとにEOQを見直して再計算します
- 安全在庫の調整では、需要の変動を考慮します
質問事項
さらに詳しい情報が必要な場合:
- この品目の年間需要はどれくらいですか?(個/年)
- 1個あたりのコストはいくらですか?
- 発注にかかるコストはいくらですか?(発注1回あたりの固定費)
- 在庫保有コスト率はいくらですか?(単価に対する年間の割合)
- サプライヤーから数量割引を受けられますか?
- この品目は購入品ですか、それとも社内生産品ですか?
- 生産品の場合、生産率はいくらですか?
- 欠品は許容されますか?許容される場合、バックオーダーのペナルティコストはいくらですか?
- 発注から納品までのリードタイムはどれくらいですか?
- 制約はありますか?(最小発注数量、保管容量、予算上限)
関連スキル
- inventory-optimization:包括的な在庫管理フレームワーク
- lot-sizing-problems:複数期間のロットサイズ決定モデル
- dynamic-lot-sizing:需要が時間とともに変化する場合のロットサイズ決定
- stochastic-inventory-models:不確実性を考慮した確率的在庫モデル
- newsvendor-problem:単一期間の在庫決定
- multi-echelon-inventory:ネットワーク全体の在庫最適化
- demand-forecasting:EOQの入力値に用いる需要の推定
- supply-chain-analytics:パフォーマンス指標とKPI