Inventory Optimization in Python — Deep Dive

Production inventory optimization goes beyond EOQ into stochastic models that account for uncertain demand, variable lead times, multi-echelon networks, and budget constraints linking thousands of SKUs. This guide covers the models and Python implementations that power real warehouse systems.

Stochastic inventory models

Continuous review (Q, R) policy

The system monitors stock continuously and places a fixed order of Q units whenever inventory drops to the reorder point R.

import numpy as np
from scipy.stats import norm

def continuous_review_policy(
    avg_demand_per_day: float,
    std_demand_per_day: float,
    lead_time_days: float,
    std_lead_time: float,
    order_cost: float,
    holding_cost_per_unit: float,
    service_level: float = 0.95,
) -> dict:
    annual_demand = avg_demand_per_day * 365
    eoq = np.sqrt(2 * annual_demand * order_cost / holding_cost_per_unit)

    # Demand during lead time
    mean_dlt = avg_demand_per_day * lead_time_days
    std_dlt = np.sqrt(
        lead_time_days * std_demand_per_day**2
        + avg_demand_per_day**2 * std_lead_time**2
    )

    z = norm.ppf(service_level)
    safety_stock = z * std_dlt
    reorder_point = mean_dlt + safety_stock

    return {
        "order_quantity": round(eoq),
        "reorder_point": round(reorder_point),
        "safety_stock": round(safety_stock),
        "expected_orders_per_year": round(annual_demand / eoq, 1),
    }

The key insight is that safety stock depends on both demand variability and lead time variability. A supplier who delivers in “5 to 15 days” requires far more buffer than one who delivers in “9 to 11 days” even if the average is the same.

Periodic review (S, s) policy

Stock is checked at fixed intervals (e.g., every Monday). If inventory is below the threshold s, order up to level S. This suits businesses where ordering happens on a schedule.

def periodic_review_policy(
    avg_demand_per_day: float,
    std_demand_per_day: float,
    review_period_days: int,
    lead_time_days: float,
    service_level: float = 0.95,
) -> dict:
    protection_interval = review_period_days + lead_time_days
    mean_demand_pi = avg_demand_per_day * protection_interval
    std_demand_pi = std_demand_per_day * np.sqrt(protection_interval)

    z = norm.ppf(service_level)
    order_up_to = mean_demand_pi + z * std_demand_pi

    return {
        "order_up_to_level": round(order_up_to),
        "review_period_days": review_period_days,
        "safety_stock": round(z * std_demand_pi),
    }

Newsvendor model for perishables

When products expire or become worthless after a single selling period (newspapers, fashion, fresh food), the newsvendor model finds the optimal stocking quantity:

def newsvendor_quantity(
    cost: float,
    price: float,
    salvage: float,
    demand_mean: float,
    demand_std: float,
) -> int:
    cu = price - cost        # cost of understocking
    co = cost - salvage      # cost of overstocking
    critical_ratio = cu / (cu + co)
    optimal_q = norm.ppf(critical_ratio, loc=demand_mean, scale=demand_std)
    return max(0, int(np.ceil(optimal_q)))

A bakery selling croissants at €3.00 each, costing €1.00 to make, with €0.20 salvage value, has a critical ratio of 2.0 / 2.8 = 0.714. If daily demand is Normal(100, 20), optimal stock is about 111 units.

Multi-SKU optimization under budget constraints

When a warehouse has limited capital, optimizing each SKU independently can exceed the budget. A constrained approach:

from scipy.optimize import minimize

def multi_sku_optimize(skus: list[dict], budget: float) -> list[float]:
    """
    Each SKU dict has: cost, demand_mean, demand_std, price, holding_rate.
    Returns optimal order quantities under total budget constraint.
    """
    n = len(skus)

    def total_expected_profit(quantities):
        profit = 0
        for i, q in enumerate(quantities):
            s = skus[i]
            expected_sales = min(q, s["demand_mean"])
            expected_leftover = max(0, q - s["demand_mean"])
            profit += s["price"] * expected_sales - s["cost"] * q - s["holding_rate"] * expected_leftover
        return -profit  # minimize negative profit

    constraints = [{
        "type": "ineq",
        "fun": lambda q: budget - sum(q[i] * skus[i]["cost"] for i in range(n)),
    }]
    bounds = [(0, s["demand_mean"] * 3) for s in skus]
    x0 = [s["demand_mean"] for s in skus]

    result = minimize(total_expected_profit, x0, bounds=bounds, constraints=constraints, method="SLSQP")
    return [round(q) for q in result.x]

For thousands of SKUs, this becomes a large-scale optimization. Decomposition by product category or Lagrangian relaxation of the budget constraint scales better than monolithic SLSQP.

ABC-XYZ classification

Combining revenue ranking (ABC) with demand variability (XYZ) produces a 9-cell matrix:

X (stable)Y (variable)Z (erratic)
ALean stock, tight controlModerate buffer, weekly reviewSignificant buffer, demand sensing
BStandard EOQPeriodic reviewSafety stock heavy
CMin-max simple rulesReorder when emptyConsider make-to-order
import pandas as pd

def abc_xyz_classify(df: pd.DataFrame) -> pd.DataFrame:
    # ABC by annual revenue
    df = df.sort_values("annual_revenue", ascending=False)
    df["cum_rev_pct"] = df["annual_revenue"].cumsum() / df["annual_revenue"].sum()
    df["abc"] = pd.cut(df["cum_rev_pct"], bins=[0, 0.8, 0.95, 1.0], labels=["A", "B", "C"])

    # XYZ by coefficient of variation
    df["cv"] = df["demand_std"] / df["demand_mean"]
    df["xyz"] = pd.cut(df["cv"], bins=[0, 0.5, 1.0, float("inf")], labels=["X", "Y", "Z"])

    df["class"] = df["abc"].astype(str) + df["xyz"].astype(str)
    return df

Multi-echelon inventory

Large supply chains have multiple stock points: a central warehouse feeds regional hubs that feed retail stores. Optimizing each level independently creates the bullwhip effect — small demand swings at retail amplify into wild ordering swings upstream.

Multi-echelon optimization jointly sets safety stocks across all levels to minimize total inventory while meeting end-customer service levels. The inventoryanalytics Python package implements the Clark-Scarf model for serial systems:

# pip install inventoryanalytics
from inventoryanalytics.lotsizing.stochastic import newsvendor

# For serial supply chains, echelon stock policies
# coordinate ordering across warehouse → hub → store layers

For tree-structured networks (one warehouse, many stores), the Guaranteed Service Model (GSM) is the standard approach. Implementation requires solving a dynamic program over the network graph.

Simulation-based validation

Before deploying a new inventory policy, simulate it against historical demand:

def simulate_policy(daily_demand: list[int], reorder_point: int, order_qty: int, lead_time: int) -> dict:
    inventory = order_qty
    on_order = []
    stockouts = 0
    total_holding = 0

    for day, demand in enumerate(daily_demand):
        # Receive arrivals
        on_order = [(qty, eta - 1) for qty, eta in on_order]
        for qty, eta in on_order:
            if eta <= 0:
                inventory += qty
        on_order = [(qty, eta) for qty, eta in on_order if eta > 0]

        # Fulfill demand
        if inventory >= demand:
            inventory -= demand
        else:
            stockouts += demand - inventory
            inventory = 0

        # Reorder check
        if inventory <= reorder_point and not on_order:
            on_order.append((order_qty, lead_time))

        total_holding += inventory

    service_level = 1 - stockouts / sum(daily_demand)
    avg_inventory = total_holding / len(daily_demand)
    return {"service_level": round(service_level, 4), "avg_inventory": round(avg_inventory, 1), "total_stockouts": stockouts}

Running this over two years of data reveals whether the analytical model’s assumptions hold. If simulated service level differs significantly from the target, adjust safety stock multipliers.

Pitfalls

  • Ignoring lead time variability — many implementations use a fixed lead time. Supplier delays are the primary cause of stockouts, not demand spikes.
  • Confusing fill rate with cycle service level — fill rate measures what fraction of units ship on time; cycle service level measures what fraction of order cycles have no stockout. A 95% cycle service level might correspond to only 98-99% fill rate.
  • Static parameters — demand patterns shift. Re-estimate parameters monthly and alert when coefficient of variation changes significantly.
  • Over-optimizing C items — the analyst time spent fine-tuning safety stock for low-revenue SKUs often exceeds the savings. Simple rules suffice.

The one thing to remember: Production inventory optimization combines stochastic demand models, budget-constrained multi-SKU solvers, and simulation validation to keep service levels high while minimizing the capital trapped in warehouses.

pythoninventorysupply-chainoptimization

See Also

  • Python Adaptive Learning Systems How Python builds learning apps that adjust to each student like a personal tutor who knows exactly what you need next.
  • Python Airflow Learn Airflow as a timetable manager that makes sure data tasks run in the right order every day.
  • Python Altair Learn Altair through the idea of drawing charts by describing rules, not by hand-placing every visual element.
  • Python Automated Grading How Python grades homework and exams automatically, from simple answer keys to understanding written essays.
  • Python Batch Vs Stream Processing Batch processing is like doing laundry once a week; stream processing is like a self-cleaning shirt that cleans itself constantly.