Files
bakery-ia/services/inventory/app/ml/safety_stock_optimizer.py
2025-11-05 13:34:56 +01:00

756 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Safety Stock Optimizer
Replaces hardcoded 95% service level with learned optimal safety stock levels
Optimizes based on product characteristics, demand variability, and cost trade-offs
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Any, Optional, Tuple
import structlog
from datetime import datetime, timedelta
from scipy import stats
from scipy.optimize import minimize_scalar
import warnings
warnings.filterwarnings('ignore')
logger = structlog.get_logger()
class SafetyStockOptimizer:
"""
Optimizes safety stock levels for inventory management.
Current problem: Hardcoded 95% service level for all products
Solution: Learn optimal service levels based on:
- Product characteristics (shelf life, criticality)
- Demand variability (coefficient of variation)
- Cost trade-offs (holding cost vs stockout cost)
- Historical stockout patterns
- Supplier reliability
Approaches:
1. Statistical approach: Based on demand variability and lead time
2. Cost-based optimization: Minimize total cost (holding + stockout)
3. Service level optimization: Product-specific target service levels
4. Dynamic adjustment: Seasonality and trend awareness
"""
def __init__(self):
self.optimal_stocks = {}
self.learned_service_levels = {}
async def optimize_safety_stock(
self,
tenant_id: str,
inventory_product_id: str,
demand_history: pd.DataFrame,
product_characteristics: Dict[str, Any],
cost_parameters: Optional[Dict[str, float]] = None,
supplier_reliability: Optional[float] = None,
min_history_days: int = 90
) -> Dict[str, Any]:
"""
Calculate optimal safety stock for a product.
Args:
tenant_id: Tenant identifier
inventory_product_id: Product identifier
demand_history: Historical demand data with columns:
- date
- demand_quantity
- stockout (bool, optional)
- lead_time_days (optional)
product_characteristics: Product properties:
- shelf_life_days: int
- criticality: str (high, medium, low)
- unit_cost: float
- avg_daily_demand: float
cost_parameters: Optional cost params:
- holding_cost_per_unit_per_day: float
- stockout_cost_per_unit: float
supplier_reliability: Supplier on-time rate (0-1)
min_history_days: Minimum days of history required
Returns:
Dictionary with optimal safety stock and insights
"""
logger.info(
"Optimizing safety stock",
tenant_id=tenant_id,
inventory_product_id=inventory_product_id,
history_days=len(demand_history)
)
# Validate input
if len(demand_history) < min_history_days:
logger.warning(
"Insufficient demand history",
inventory_product_id=inventory_product_id,
days=len(demand_history),
required=min_history_days
)
return self._insufficient_data_response(
tenant_id, inventory_product_id, product_characteristics
)
# Calculate demand statistics
demand_stats = self._calculate_demand_statistics(demand_history)
# Calculate optimal safety stock using multiple methods
statistical_result = self._calculate_statistical_safety_stock(
demand_stats,
product_characteristics,
supplier_reliability
)
# Cost-based optimization if cost parameters provided
if cost_parameters:
cost_based_result = self._calculate_cost_optimal_safety_stock(
demand_stats,
product_characteristics,
cost_parameters,
demand_history
)
else:
cost_based_result = None
# Service level optimization
service_level_result = self._calculate_service_level_optimal_stock(
demand_stats,
product_characteristics,
demand_history
)
# Combine methods and select optimal
optimal_result = self._select_optimal_safety_stock(
statistical_result,
cost_based_result,
service_level_result,
product_characteristics
)
# Compare with current hardcoded approach (95% service level)
hardcoded_result = self._calculate_hardcoded_safety_stock(
demand_stats,
service_level=0.95
)
comparison = self._compare_with_hardcoded(
optimal_result,
hardcoded_result,
cost_parameters
)
# Generate insights
insights = self._generate_safety_stock_insights(
tenant_id,
inventory_product_id,
optimal_result,
hardcoded_result,
comparison,
demand_stats,
product_characteristics
)
# Store optimal stock
self.optimal_stocks[inventory_product_id] = optimal_result['safety_stock']
self.learned_service_levels[inventory_product_id] = optimal_result['service_level']
logger.info(
"Safety stock optimization complete",
inventory_product_id=inventory_product_id,
optimal_stock=optimal_result['safety_stock'],
optimal_service_level=optimal_result['service_level'],
improvement_vs_hardcoded=comparison.get('cost_savings_pct', 0)
)
return {
'tenant_id': tenant_id,
'inventory_product_id': inventory_product_id,
'optimized_at': datetime.utcnow().isoformat(),
'history_days': len(demand_history),
'demand_stats': demand_stats,
'optimal_result': optimal_result,
'hardcoded_result': hardcoded_result,
'comparison': comparison,
'insights': insights
}
def _calculate_demand_statistics(
self,
demand_history: pd.DataFrame
) -> Dict[str, float]:
"""
Calculate comprehensive demand statistics.
Args:
demand_history: Historical demand data
Returns:
Dictionary of demand statistics
"""
# Ensure date column
if 'date' not in demand_history.columns:
demand_history = demand_history.copy()
demand_history['date'] = pd.to_datetime(demand_history.index)
demand_history['date'] = pd.to_datetime(demand_history['date'])
# Basic statistics
mean_demand = demand_history['demand_quantity'].mean()
std_demand = demand_history['demand_quantity'].std()
cv_demand = std_demand / mean_demand if mean_demand > 0 else 0
# Lead time statistics (if available)
if 'lead_time_days' in demand_history.columns:
mean_lead_time = demand_history['lead_time_days'].mean()
std_lead_time = demand_history['lead_time_days'].std()
else:
mean_lead_time = 3.0 # Default assumption
std_lead_time = 0.5
# Stockout rate (if available)
if 'stockout' in demand_history.columns:
stockout_rate = demand_history['stockout'].mean()
stockout_frequency = demand_history['stockout'].sum()
else:
stockout_rate = 0.05 # Assume 5% if not tracked
stockout_frequency = 0
# Demand distribution characteristics
skewness = demand_history['demand_quantity'].skew()
kurtosis = demand_history['demand_quantity'].kurtosis()
# Recent trend (last 30 days vs overall)
if len(demand_history) >= 60:
recent_mean = demand_history.tail(30)['demand_quantity'].mean()
trend = (recent_mean - mean_demand) / mean_demand if mean_demand > 0 else 0
else:
trend = 0
return {
'mean_demand': float(mean_demand),
'std_demand': float(std_demand),
'cv_demand': float(cv_demand),
'min_demand': float(demand_history['demand_quantity'].min()),
'max_demand': float(demand_history['demand_quantity'].max()),
'mean_lead_time': float(mean_lead_time),
'std_lead_time': float(std_lead_time),
'stockout_rate': float(stockout_rate),
'stockout_frequency': int(stockout_frequency),
'skewness': float(skewness),
'kurtosis': float(kurtosis),
'trend': float(trend),
'data_points': int(len(demand_history))
}
def _calculate_statistical_safety_stock(
self,
demand_stats: Dict[str, float],
product_characteristics: Dict[str, Any],
supplier_reliability: Optional[float] = None
) -> Dict[str, Any]:
"""
Calculate safety stock using statistical approach (Classic formula).
Formula: SS = Z * sqrt(LT * σ_d² + d_avg² * σ_LT²)
Where:
- Z: Z-score for desired service level
- LT: Mean lead time
- σ_d: Standard deviation of demand
- d_avg: Average demand
- σ_LT: Standard deviation of lead time
"""
# Determine target service level based on product criticality
criticality = product_characteristics.get('criticality', 'medium').lower()
if criticality == 'high':
target_service_level = 0.98 # 98% for critical products
elif criticality == 'medium':
target_service_level = 0.95 # 95% for medium
else:
target_service_level = 0.90 # 90% for low criticality
# Adjust for supplier reliability
if supplier_reliability is not None and supplier_reliability < 0.9:
# Less reliable suppliers need higher safety stock
target_service_level = min(0.99, target_service_level + 0.03)
# Calculate Z-score for target service level
z_score = stats.norm.ppf(target_service_level)
# Calculate safety stock
mean_demand = demand_stats['mean_demand']
std_demand = demand_stats['std_demand']
mean_lead_time = demand_stats['mean_lead_time']
std_lead_time = demand_stats['std_lead_time']
# Safety stock formula
variance_component = (
mean_lead_time * (std_demand ** 2) +
(mean_demand ** 2) * (std_lead_time ** 2)
)
safety_stock = z_score * np.sqrt(variance_component)
# Ensure non-negative
safety_stock = max(0, safety_stock)
return {
'method': 'statistical',
'safety_stock': round(safety_stock, 2),
'service_level': target_service_level,
'z_score': round(z_score, 2),
'rationale': f'Based on {target_service_level*100:.0f}% service level for {criticality} criticality product'
}
def _calculate_cost_optimal_safety_stock(
self,
demand_stats: Dict[str, float],
product_characteristics: Dict[str, Any],
cost_parameters: Dict[str, float],
demand_history: pd.DataFrame
) -> Dict[str, Any]:
"""
Calculate safety stock that minimizes total cost (holding + stockout).
Total Cost = (Holding Cost × Safety Stock) + (Stockout Cost × Stockout Frequency)
"""
holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01)
stockout_cost = cost_parameters.get('stockout_cost_per_unit', 10.0)
mean_demand = demand_stats['mean_demand']
std_demand = demand_stats['std_demand']
mean_lead_time = demand_stats['mean_lead_time']
def total_cost(safety_stock):
"""Calculate total cost for given safety stock level."""
# Holding cost (annual)
annual_holding_cost = holding_cost * safety_stock * 365
# Stockout probability and expected stockouts
# Demand during lead time follows normal distribution
demand_during_lt_mean = mean_demand * mean_lead_time
demand_during_lt_std = std_demand * np.sqrt(mean_lead_time)
# Service level achieved with this safety stock
if demand_during_lt_std > 0:
z_score = (safety_stock) / demand_during_lt_std
service_level = stats.norm.cdf(z_score)
else:
service_level = 0.99
# Stockout probability
stockout_prob = 1 - service_level
# Expected annual stockouts (simplified)
orders_per_year = 365 / mean_lead_time
expected_stockouts = stockout_prob * orders_per_year * mean_demand
# Stockout cost (annual)
annual_stockout_cost = expected_stockouts * stockout_cost
return annual_holding_cost + annual_stockout_cost
# Optimize to find minimum total cost
# Search range: 0 to 5 * mean demand during lead time
max_search = 5 * mean_demand * mean_lead_time
result = minimize_scalar(
total_cost,
bounds=(0, max_search),
method='bounded'
)
optimal_safety_stock = result.x
optimal_cost = result.fun
# Calculate achieved service level
demand_during_lt_std = std_demand * np.sqrt(mean_lead_time)
if demand_during_lt_std > 0:
z_score = optimal_safety_stock / demand_during_lt_std
achieved_service_level = stats.norm.cdf(z_score)
else:
achieved_service_level = 0.99
return {
'method': 'cost_optimization',
'safety_stock': round(optimal_safety_stock, 2),
'service_level': round(achieved_service_level, 4),
'annual_total_cost': round(optimal_cost, 2),
'rationale': f'Minimizes total cost (holding + stockout): €{optimal_cost:.2f}/year'
}
def _calculate_service_level_optimal_stock(
self,
demand_stats: Dict[str, float],
product_characteristics: Dict[str, Any],
demand_history: pd.DataFrame
) -> Dict[str, Any]:
"""
Calculate safety stock based on empirical service level optimization.
Uses historical stockout data to find optimal service level.
"""
# If we have stockout history, learn from it
if 'stockout' in demand_history.columns and demand_history['stockout'].sum() > 0:
current_stockout_rate = demand_stats['stockout_rate']
# Target: Reduce stockouts by 50% or achieve 95%, whichever is higher
target_stockout_rate = min(current_stockout_rate * 0.5, 0.05)
target_service_level = 1 - target_stockout_rate
else:
# No stockout data, use criticality-based default
criticality = product_characteristics.get('criticality', 'medium').lower()
target_service_level = {
'high': 0.98,
'medium': 0.95,
'low': 0.90
}.get(criticality, 0.95)
# Calculate safety stock for target service level
z_score = stats.norm.ppf(target_service_level)
mean_demand = demand_stats['mean_demand']
std_demand = demand_stats['std_demand']
mean_lead_time = demand_stats['mean_lead_time']
safety_stock = z_score * std_demand * np.sqrt(mean_lead_time)
safety_stock = max(0, safety_stock)
return {
'method': 'service_level_optimization',
'safety_stock': round(safety_stock, 2),
'service_level': target_service_level,
'rationale': f'Achieves {target_service_level*100:.0f}% service level based on historical performance'
}
def _select_optimal_safety_stock(
self,
statistical_result: Dict[str, Any],
cost_based_result: Optional[Dict[str, Any]],
service_level_result: Dict[str, Any],
product_characteristics: Dict[str, Any]
) -> Dict[str, Any]:
"""
Select optimal safety stock from multiple methods.
Priority:
1. Cost-based if available and product value is high
2. Statistical for general case
3. Service level as validation
"""
# If cost data available and product is valuable, use cost optimization
if cost_based_result and product_characteristics.get('unit_cost', 0) > 5:
selected = cost_based_result
logger.info("Selected cost-based safety stock (high-value product)")
# Otherwise use statistical approach
else:
selected = statistical_result
logger.info("Selected statistical safety stock")
# Add shelf life constraint
shelf_life = product_characteristics.get('shelf_life_days')
if shelf_life:
max_safe_stock = product_characteristics.get('avg_daily_demand', 0) * (shelf_life * 0.5)
if selected['safety_stock'] > max_safe_stock:
logger.warning(
"Safety stock exceeds shelf life constraint",
calculated=selected['safety_stock'],
max_allowed=max_safe_stock
)
selected['safety_stock'] = round(max_safe_stock, 2)
selected['constrained_by'] = 'shelf_life'
return selected
def _calculate_hardcoded_safety_stock(
self,
demand_stats: Dict[str, float],
service_level: float = 0.95
) -> Dict[str, Any]:
"""
Calculate safety stock using current hardcoded 95% service level.
Args:
demand_stats: Demand statistics
service_level: Hardcoded service level (default 0.95)
Returns:
Safety stock result with hardcoded approach
"""
z_score = stats.norm.ppf(service_level)
mean_demand = demand_stats['mean_demand']
std_demand = demand_stats['std_demand']
mean_lead_time = demand_stats['mean_lead_time']
safety_stock = z_score * std_demand * np.sqrt(mean_lead_time)
safety_stock = max(0, safety_stock)
return {
'method': 'hardcoded_95_service_level',
'safety_stock': round(safety_stock, 2),
'service_level': service_level,
'rationale': 'Current hardcoded 95% service level for all products'
}
def _compare_with_hardcoded(
self,
optimal_result: Dict[str, Any],
hardcoded_result: Dict[str, Any],
cost_parameters: Optional[Dict[str, float]]
) -> Dict[str, Any]:
"""
Compare optimal safety stock with hardcoded approach.
Args:
optimal_result: Optimal safety stock result
hardcoded_result: Hardcoded approach result
cost_parameters: Optional cost parameters for savings calculation
Returns:
Comparison metrics
"""
optimal_stock = optimal_result['safety_stock']
hardcoded_stock = hardcoded_result['safety_stock']
stock_difference = optimal_stock - hardcoded_stock
stock_difference_pct = (stock_difference / hardcoded_stock * 100) if hardcoded_stock > 0 else 0
comparison = {
'stock_difference': round(stock_difference, 2),
'stock_difference_pct': round(stock_difference_pct, 2),
'optimal_service_level': optimal_result['service_level'],
'hardcoded_service_level': hardcoded_result['service_level'],
'service_level_difference': round(
(optimal_result['service_level'] - hardcoded_result['service_level']) * 100, 2
)
}
# Calculate cost savings if cost data available
if cost_parameters:
holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01)
annual_holding_savings = stock_difference * holding_cost * 365
comparison['annual_holding_cost_savings'] = round(annual_holding_savings, 2)
if hardcoded_stock > 0:
comparison['cost_savings_pct'] = round(
(annual_holding_savings / (hardcoded_stock * holding_cost * 365)) * 100, 2
)
return comparison
def _generate_safety_stock_insights(
self,
tenant_id: str,
inventory_product_id: str,
optimal_result: Dict[str, Any],
hardcoded_result: Dict[str, Any],
comparison: Dict[str, Any],
demand_stats: Dict[str, float],
product_characteristics: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
Generate actionable insights from safety stock optimization.
Args:
tenant_id: Tenant ID
inventory_product_id: Product ID
optimal_result: Optimal safety stock result
hardcoded_result: Hardcoded result
comparison: Comparison metrics
demand_stats: Demand statistics
product_characteristics: Product characteristics
Returns:
List of insights
"""
insights = []
stock_diff_pct = comparison['stock_difference_pct']
# Insight 1: Over-stocking reduction opportunity
if stock_diff_pct < -10: # Optimal is >10% lower
cost_savings = comparison.get('annual_holding_cost_savings', 0)
insights.append({
'type': 'optimization',
'priority': 'high' if abs(stock_diff_pct) > 25 else 'medium',
'category': 'inventory',
'title': f'Reduce Safety Stock by {abs(stock_diff_pct):.0f}%',
'description': f'Product {inventory_product_id} is over-stocked. Optimal safety stock is {optimal_result["safety_stock"]:.1f} units vs current {hardcoded_result["safety_stock"]:.1f}. Reducing to optimal level saves €{abs(cost_savings):.2f}/year in holding costs while maintaining {optimal_result["service_level"]*100:.1f}% service level.',
'impact_type': 'cost_savings',
'impact_value': abs(cost_savings),
'impact_unit': 'euros_per_year',
'confidence': 85,
'metrics_json': {
'inventory_product_id': inventory_product_id,
'current_safety_stock': round(hardcoded_result['safety_stock'], 2),
'optimal_safety_stock': round(optimal_result['safety_stock'], 2),
'reduction_pct': round(abs(stock_diff_pct), 2),
'annual_savings': round(abs(cost_savings), 2),
'optimal_service_level': round(optimal_result['service_level'] * 100, 2)
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Update Safety Stock',
'action': 'update_safety_stock',
'params': {
'inventory_product_id': inventory_product_id,
'new_safety_stock': round(optimal_result['safety_stock'], 2)
}
}
],
'source_service': 'inventory',
'source_model': 'safety_stock_optimizer'
})
# Insight 2: Under-stocking risk
elif stock_diff_pct > 10: # Optimal is >10% higher
insights.append({
'type': 'alert',
'priority': 'high' if stock_diff_pct > 25 else 'medium',
'category': 'inventory',
'title': f'Increase Safety Stock by {stock_diff_pct:.0f}%',
'description': f'Product {inventory_product_id} safety stock is too low. Current {hardcoded_result["safety_stock"]:.1f} units provides only {hardcoded_result["service_level"]*100:.0f}% service level. Increase to {optimal_result["safety_stock"]:.1f} for optimal {optimal_result["service_level"]*100:.1f}% service level.',
'impact_type': 'stockout_risk_reduction',
'impact_value': stock_diff_pct,
'impact_unit': 'percentage',
'confidence': 85,
'metrics_json': {
'inventory_product_id': inventory_product_id,
'current_safety_stock': round(hardcoded_result['safety_stock'], 2),
'optimal_safety_stock': round(optimal_result['safety_stock'], 2),
'increase_pct': round(stock_diff_pct, 2),
'current_service_level': round(hardcoded_result['service_level'] * 100, 2),
'optimal_service_level': round(optimal_result['service_level'] * 100, 2),
'historical_stockout_rate': round(demand_stats['stockout_rate'] * 100, 2)
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Update Safety Stock',
'action': 'update_safety_stock',
'params': {
'inventory_product_id': inventory_product_id,
'new_safety_stock': round(optimal_result['safety_stock'], 2)
}
}
],
'source_service': 'inventory',
'source_model': 'safety_stock_optimizer'
})
# Insight 3: High demand variability
if demand_stats['cv_demand'] > 0.5: # Coefficient of variation > 0.5
insights.append({
'type': 'insight',
'priority': 'medium',
'category': 'inventory',
'title': f'High Demand Variability Detected',
'description': f'Product {inventory_product_id} has high demand variability (CV={demand_stats["cv_demand"]:.2f}). This increases safety stock requirements. Consider demand smoothing strategies or more frequent orders.',
'impact_type': 'operational_insight',
'impact_value': demand_stats['cv_demand'],
'impact_unit': 'coefficient_of_variation',
'confidence': 90,
'metrics_json': {
'inventory_product_id': inventory_product_id,
'cv_demand': round(demand_stats['cv_demand'], 2),
'mean_demand': round(demand_stats['mean_demand'], 2),
'std_demand': round(demand_stats['std_demand'], 2)
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Review Demand Patterns',
'action': 'analyze_demand_patterns',
'params': {'inventory_product_id': inventory_product_id}
}
],
'source_service': 'inventory',
'source_model': 'safety_stock_optimizer'
})
# Insight 4: Frequent stockouts
if demand_stats['stockout_rate'] > 0.1: # More than 10% stockout rate
insights.append({
'type': 'alert',
'priority': 'critical' if demand_stats['stockout_rate'] > 0.2 else 'high',
'category': 'inventory',
'title': f'Frequent Stockouts: {demand_stats["stockout_rate"]*100:.1f}%',
'description': f'Product {inventory_product_id} experiences frequent stockouts ({demand_stats["stockout_rate"]*100:.1f}% of days). Optimal safety stock of {optimal_result["safety_stock"]:.1f} units should reduce this significantly.',
'impact_type': 'stockout_frequency',
'impact_value': demand_stats['stockout_rate'] * 100,
'impact_unit': 'percentage',
'confidence': 95,
'metrics_json': {
'inventory_product_id': inventory_product_id,
'stockout_rate': round(demand_stats['stockout_rate'] * 100, 2),
'stockout_frequency': demand_stats['stockout_frequency'],
'optimal_safety_stock': round(optimal_result['safety_stock'], 2)
},
'actionable': True,
'recommendation_actions': [
{
'label': 'URGENT: Update Safety Stock',
'action': 'update_safety_stock',
'params': {
'inventory_product_id': inventory_product_id,
'new_safety_stock': round(optimal_result['safety_stock'], 2)
}
},
{
'label': 'Review Supplier Reliability',
'action': 'review_supplier',
'params': {'inventory_product_id': inventory_product_id}
}
],
'source_service': 'inventory',
'source_model': 'safety_stock_optimizer'
})
return insights
def _insufficient_data_response(
self,
tenant_id: str,
inventory_product_id: str,
product_characteristics: Dict[str, Any]
) -> Dict[str, Any]:
"""Return response when insufficient data available."""
# Use simple heuristic based on criticality
criticality = product_characteristics.get('criticality', 'medium').lower()
avg_daily_demand = product_characteristics.get('avg_daily_demand', 10)
# Simple rule: 7 days of demand for high, 5 for medium, 3 for low
safety_stock_days = {'high': 7, 'medium': 5, 'low': 3}.get(criticality, 5)
fallback_safety_stock = avg_daily_demand * safety_stock_days
return {
'tenant_id': tenant_id,
'inventory_product_id': inventory_product_id,
'optimized_at': datetime.utcnow().isoformat(),
'history_days': 0,
'demand_stats': {},
'optimal_result': {
'method': 'fallback_heuristic',
'safety_stock': round(fallback_safety_stock, 2),
'service_level': 0.95,
'rationale': f'Insufficient data. Using {safety_stock_days} days of demand for {criticality} criticality.'
},
'hardcoded_result': None,
'comparison': {},
'insights': []
}
def get_optimal_safety_stock(self, inventory_product_id: str) -> Optional[float]:
"""Get cached optimal safety stock for a product."""
return self.optimal_stocks.get(inventory_product_id)
def get_learned_service_level(self, inventory_product_id: str) -> Optional[float]:
"""Get learned optimal service level for a product."""
return self.learned_service_levels.get(inventory_product_id)