Mathew K Analytics

Lesson 26 · Supply Chain Operations Analytics

Safety Stock and Reorder Point Modeling

In this lesson, we will solve real supply chain and operations problems involving inventory control, safety stock, and setting reorder points. Managing…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Safety Stock and Reorder Point Modeling#

  • In this lesson, we will solve real supply chain and operations problems involving inventory control, safety stock, and setting reorder points.
  • Managing safety stock and reorder points is essential to avoid costly stockouts or excess inventory in real business settings.
  • You will learn how to analyze real demand data and calculate these metrics using operational analytics techniques.
  • By the end, you will be able to use Python to build your own safety stock models on real-world datasets.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

Core supply chain data concepts#

  • For safety stock and reorder point modeling, understanding demand and lead time is crucial.
  • Demand data usually comes in a time series, representing units sold or required each period.
  • Inventory data records how much of a product is available at each point in time.
  • Lead time is the time from placing an order until stock is received; it is often variable.
  • Beginners often mistake transaction data for demand, ignore lead time variability, or forget to aggregate data to the right time bucket (day, week, etc.).
# Beginner Example 1: Load the retail demand data (UCI Online Retail)
url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/00502/online_retail_II.xlsx'
df = pd.read_excel(url, sheet_name='Year 2010-2011')
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
print(df.shape)
print(df.head(3))
(541910, 8)
  Invoice StockCode                         Description  Quantity  \
0  536365    85123A  WHITE HANGING HEART T-LIGHT HOLDER         6   
1  536365     71053                 WHITE METAL LANTERN         6   
2  536365    84406B      CREAM CUPID HEARTS COAT HANGER         8   

          InvoiceDate  Price  Customer ID         Country  
0 2010-12-01 08:26:00   2.55      17850.0  United Kingdom  
1 2010-12-01 08:26:00   3.39      17850.0  United Kingdom  
2 2010-12-01 08:26:00   2.75      17850.0  United Kingdom  
# Beginner Example 2: Filter for a single item in a single country (SKU-level analysis)
item_code = df['StockCode'].value_counts().index[0]
country = 'United Kingdom'
df_item = df[(df['StockCode'] == item_code) & (df['Country'] == country)]
print(f'Selected item code: {item_code} from {country}')
print(df_item.head(3))
Selected item code: 85123A from United Kingdom
   Invoice StockCode                         Description  Quantity  \
0   536365    85123A  WHITE HANGING HEART T-LIGHT HOLDER         6   
49  536373    85123A  WHITE HANGING HEART T-LIGHT HOLDER         6   
66  536375    85123A  WHITE HANGING HEART T-LIGHT HOLDER         6   

           InvoiceDate  Price  Customer ID         Country  
0  2010-12-01 08:26:00   2.55      17850.0  United Kingdom  
49 2010-12-01 09:02:00   2.55      17850.0  United Kingdom  
66 2010-12-01 09:32:00   2.55      17850.0  United Kingdom  
# Beginner Example 3: Aggregate item demand per day
daily_demand = df_item.groupby(df_item['InvoiceDate'].dt.date)['Quantity'].sum()
print(daily_demand.head(7))
print(f'Number of days in series: {daily_demand.shape[0]}')
InvoiceDate
2010-12-01    454
2010-12-02    309
2010-12-03     25
2010-12-05    198
2010-12-06    161
2010-12-07    330
2010-12-08    151
Name: Quantity, dtype: int64
Number of days in series: 305
# Intermediate Example 1: Handle missing demand days (fill zeros)
idx = pd.date_range(start=daily_demand.index.min(), end=daily_demand.index.max(), freq='D')
daily_demand_filled = daily_demand.reindex(idx, fill_value=0)
print(daily_demand_filled.head(10))
print(f'Number of expected days (with filled gaps): {daily_demand_filled.shape[0]}')
2010-12-01    454
2010-12-02    309
2010-12-03     25
2010-12-04      0
2010-12-05    198
2010-12-06    161
2010-12-07    330
2010-12-08    151
2010-12-09    194
2010-12-10    164
Freq: D, Name: Quantity, dtype: int64
Number of expected days (with filled gaps): 374
# Intermediate Example 2: Plot the demand profile
plt.figure(figsize=(10,4))
plt.plot(daily_demand_filled.index, daily_demand_filled.values, marker='o', linestyle='-', alpha=0.8)
plt.title('Daily demand for SKU ' + str(item_code))
plt.xlabel('Date')
plt.ylabel('Demand (units)')
plt.grid(True)
plt.tight_layout()
plt.show()
No description has been provided for this image
# Intermediate Example 3: Calculate average demand and standard deviation per day
mean_demand = daily_demand_filled.mean()
std_demand = daily_demand_filled.std()
print(f'Average daily demand: {mean_demand:.2f}')
print(f'Standard deviation of daily demand: {std_demand:.2f}')
Average daily demand: 98.14
Standard deviation of daily demand: 284.33
# Intermediate Example 4: Estimate lead time (assume 7 days for demo purposes)
lead_time_days = 7
print(f'Assumed lead time (order to receipt) is {lead_time_days} days.')
Assumed lead time (order to receipt) is 7 days.
# Intermediate Example 5: Calculate safety stock (basic model, fixed lead time)
service_level = 0.99  # Target 99% in-stock performance
from scipy.stats import norm
z = norm.ppf(service_level)
safety_stock = z * std_demand * np.sqrt(lead_time_days)
print(f'Calculated safety stock: {safety_stock:.2f} units for a {service_level*100:.0f}% service level.')
Calculated safety stock: 1750.05 units for a 99% service level.
# Intermediate Example 6: Calculate average lead time demand (reorder point base)
avg_lead_time_demand = mean_demand * lead_time_days
print(f'Average lead time demand: {avg_lead_time_demand:.2f} units')
Average lead time demand: 687.01 units
# Intermediate Example 7: Calculate reorder point
reorder_point = avg_lead_time_demand + safety_stock
print(f'Reorder point (ROP): {reorder_point:.2f} units')
Reorder point (ROP): 2437.06 units
# Advanced Example 1: Model variable lead times using a random sample from real-world data
np.random.seed(42)
# For demonstration, create a simulated lead time history
lead_time_history = np.random.choice([5,6,7,8,9], size=30, p=[0.1,0.2,0.4,0.2,0.1])
mean_lt, std_lt = np.mean(lead_time_history), np.std(lead_time_history)
print(f'Empirical mean lead time: {mean_lt:.2f} days, stdev: {std_lt:.2f} days')
Empirical mean lead time: 6.80 days, stdev: 1.05 days
# Advanced Example 2: Safety stock formula including both demand and lead time variability
combined_std = np.sqrt(lead_time_history.mean() * std_demand ** 2 + mean_demand ** 2 * np.var(lead_time_history))
safety_stock_var_lt = z * combined_std
print(f'Advanced safety stock (variable lead time): {safety_stock_var_lt:.2f} units')
Advanced safety stock (variable lead time): 1741.31 units
# Advanced Example 3: Visualize actual demand histogram during lead time
window = int(round(mean_lt))
rolling_sums = daily_demand_filled.rolling(window).sum().dropna()
plt.figure(figsize=(8,4))
plt.hist(rolling_sums, bins=20, alpha=0.7, color='steelblue', edgecolor='black')
plt.title(f'Empirical {window}-day demand distribution')
plt.xlabel('Demand during lead time')
plt.ylabel('Frequency')
plt.show()
No description has been provided for this image
# Advanced Example 4: Export a summary report of recommended inventory parameters
summary = pd.DataFrame({
    'SKU': [item_code],
    'ServiceLevel': [service_level],
    'MeanDailyDemand': [mean_demand],
    'StdDailyDemand': [std_demand],
    'MeanLeadTime': [mean_lt],
    'StdLeadTime': [std_lt],
    'SafetyStock': [safety_stock_var_lt],
    'ReorderPoint': [mean_demand * mean_lt + safety_stock_var_lt]
})
summary_filename = 'inventory_parameters.csv'
summary.to_csv(summary_filename, index=False)
print('Inventory recommendation report saved as', summary_filename)
Inventory recommendation report saved as inventory_parameters.csv
# Error handling: Detect missing or negative demand entries
missing_qty = df_item['Quantity'].isna().sum()
negative_qty = (df_item['Quantity'] < 0).sum()
print(f'Missing daily Quantity values: {missing_qty}')
print(f'Negative Quantity values: {negative_qty}')
Missing daily Quantity values: 0
Negative Quantity values: 41
# Error handling: Ensure all dates are expected type and continuous
if not np.issubdtype(df_item['InvoiceDate'].dtype, np.datetime64):
    print('InvoiceDate is not datetime! Convert it.')
else:
    print('InvoiceDate is already datetime type.')
expected_start = pd.to_datetime(daily_demand.index.min())
expected_end = pd.to_datetime(daily_demand.index.max())
all_days = pd.date_range(expected_start, expected_end, freq='D')
if daily_demand.index.shape[0] != all_days.shape[0]:
    print('Gap in daily dates detected! Fill missing days for correct analytics.')
InvoiceDate is already datetime type.
Gap in daily dates detected! Fill missing days for correct analytics.
# Error handling: Simulate a broken join on wrong keys
orders_df = df[['Invoice', 'InvoiceDate', 'StockCode', 'Quantity']]
inventories_df = pd.DataFrame({'StockCode': [item_code], 'InventoryOnHand': [50]})
# Simulate wrong join: joining on InvoiceCode instead of StockCode
joined_bad = pd.merge(orders_df, inventories_df, left_on='Invoice', right_on='StockCode', how='left')
print('Joined shape (bad key):', joined_bad.shape)
print('Number of matched rows:', joined_bad['InventoryOnHand'].notna().sum())
Joined shape (bad key): (541910, 6)
Number of matched rows: 0
# Best practice: time-based demand aggregation (weekly)
weekly_demand = daily_demand_filled.resample('W-MON').sum()
print('First 4 weeks of demand:')
print(weekly_demand.head(4))
First 4 weeks of demand:
2010-12-06    1147
2010-12-13    1218
2010-12-20     677
2010-12-27      88
Freq: W-MON, Name: Quantity, dtype: int64
# Best practice: calculate in-stock (cycle service) and fill rate KPIs for a simulated policy
begin_inventory = reorder_point + 10
days_with_stockout = (daily_demand_filled.cumsum() > begin_inventory).sum()
service_level_emp = 1 - days_with_stockout / len(daily_demand_filled)
total_demand = daily_demand_filled.sum()
unfilled_demand = max(0, daily_demand_filled.cumsum().max() - begin_inventory)
fill_rate_emp = 1 - unfilled_demand / total_demand
print(f'Empirical cycle service level: {service_level_emp:.3f}')
print(f'Empirical fill rate: {fill_rate_emp:.3f}')
Empirical cycle service level: 0.035
Empirical fill rate: 0.067

End-to-end supply chain example: From raw data to inventory insight#

  • We will now build a mini workflow using raw real transactions to recommended safety stock and reorder point.
  • Start with historic daily transactions for a given SKU.
  • Clean and fill demand time series for missing dates.
  • Estimate lead time from recent orders, or use available supplier data.
  • Calculate average demand and variability.
  • Compute safety stock for operations, and a reorder point using the best practice formula.
  • Export the recommendation report so teams can implement the policy.
# Step 1: Get daily demand for a new SKU and fill missing days
new_item = df['StockCode'].value_counts().index[1]
df_new = df[(df['StockCode'] == new_item) & (df['Country'] == country)]
dd_new = df_new.groupby(df_new['InvoiceDate'].dt.date)['Quantity'].sum()
idx_new = pd.date_range(start=dd_new.index.min(), end=dd_new.index.max(), freq='D')
dd_new_filled = dd_new.reindex(idx_new, fill_value=0)
# Step 2: Repeat all calculations for safety stock and reorder point
mean_d = dd_new_filled.mean()
std_d = dd_new_filled.std()
lead_hist = np.random.choice([5,6,7,8,9], size=30, p=[0.1,0.2,0.4,0.2,0.1])
mean_lt2, std_lt2 = np.mean(lead_hist), np.std(lead_hist)
serv_level = 0.97
z2 = norm.ppf(serv_level)
comb_std2 = np.sqrt(lead_hist.mean() * std_d ** 2 + mean_d ** 2 * np.var(lead_hist))
safe_stock2 = z2 * comb_std2
rop2 = mean_d * mean_lt2 + safe_stock2
print(f'Item: {new_item}, Safety stock: {safe_stock2:.1f}, Reorder point: {rop2:.1f}')
Item: 22423, Safety stock: 206.6, Reorder point: 399.8
 

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.