WB_Analyzer / forecasting.py
bakyt92's picture
update forecasting.py
50a162f
"""
Inventory forecasting algorithms for Wildberries Analytics Dashboard
Implements multiple methods for predicting days until stockout
"""
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
import logging
logger = logging.getLogger(__name__)
class InventoryForecaster:
"""
Inventory forecasting engine with multiple algorithms
"""
def __init__(self, confidence_level: float = 0.95):
"""
Initialize forecaster
Args:
confidence_level: Confidence level for safety stock calculations (0.0-1.0)
"""
self.confidence_level = confidence_level
self.z_score = self._get_z_score(confidence_level)
def _get_z_score(self, confidence_level: float) -> float:
"""Get Z-score for given confidence level"""
z_scores = {
0.90: 1.28,
0.95: 1.65,
0.99: 2.33
}
# Find closest confidence level
closest = min(z_scores.keys(), key=lambda x: abs(x - confidence_level))
return z_scores[closest]
def simple_division_method(self, current_stock: float, avg_daily_sales: float) -> float:
"""
Simple division method: current stock / average daily sales
Args:
current_stock: Current inventory level
avg_daily_sales: Average daily sales rate
Returns:
Days until stockout
"""
if avg_daily_sales <= 0:
return float('inf') # Never runs out if no sales
if current_stock <= 0:
return 0 # Already out of stock
return current_stock / avg_daily_sales
def safety_stock_method(self,
current_stock: float,
avg_daily_sales: float,
max_daily_sales: float,
avg_lead_time: int = 7,
max_lead_time: int = 14) -> float:
"""
Safety stock method accounting for demand and lead time variability
Args:
current_stock: Current inventory level
avg_daily_sales: Average daily sales rate
max_daily_sales: Maximum observed daily sales
avg_lead_time: Average supplier lead time in days
max_lead_time: Maximum supplier lead time in days
Returns:
Days until stockout considering safety buffer
"""
if avg_daily_sales <= 0:
return float('inf')
if current_stock <= 0:
return 0
# Calculate safety stock
# Safety stock = (Max daily sales × Max lead time) - (Avg daily sales × Avg lead time)
safety_stock = (max_daily_sales * max_lead_time) - (avg_daily_sales * avg_lead_time)
safety_stock = max(0, safety_stock) # Ensure non-negative
# Effective stock after accounting for safety buffer
effective_stock = max(0, current_stock - safety_stock)
if effective_stock <= 0:
return 0 # Below safety stock threshold
return effective_stock / avg_daily_sales
def weighted_average_method(self,
current_stock: float,
sales_data: pd.DataFrame,
recent_weight: float = 0.5,
medium_weight: float = 0.3,
old_weight: float = 0.2) -> float:
"""
Weighted average method giving more weight to recent sales
Args:
current_stock: Current inventory level
sales_data: DataFrame with sales data including 'sale_date' and 'quantity'
recent_weight: Weight for most recent week
medium_weight: Weight for second recent week
old_weight: Weight for older data
Returns:
Days until stockout based on weighted average
"""
if sales_data.empty or current_stock <= 0:
return 0 if current_stock <= 0 else float('inf')
# Ensure we have required columns
if 'sale_date' not in sales_data.columns or 'quantity' not in sales_data.columns:
logger.warning("Missing required columns in sales data")
return self.simple_division_method(current_stock, sales_data['quantity'].mean() if 'quantity' in sales_data.columns else 1)
try:
# Convert sale_date to datetime if not already
sales_data = sales_data.copy()
if not pd.api.types.is_datetime64_any_dtype(sales_data['sale_date']):
sales_data['sale_date'] = pd.to_datetime(sales_data['sale_date'])
# Sort by date
sales_data = sales_data.sort_values('sale_date')
# Calculate periods
now = datetime.now()
week_1_start = now - timedelta(days=7)
week_2_start = now - timedelta(days=14)
# Group sales by periods
recent_sales = sales_data[sales_data['sale_date'] >= week_1_start]['quantity'].sum()
medium_sales = sales_data[
(sales_data['sale_date'] >= week_2_start) &
(sales_data['sale_date'] < week_1_start)
]['quantity'].sum()
old_sales = sales_data[sales_data['sale_date'] < week_2_start]['quantity'].sum()
# Calculate daily averages for each period
recent_daily = recent_sales / 7 if recent_sales > 0 else 0
medium_daily = medium_sales / 7 if medium_sales > 0 else 0
old_days = len(sales_data[sales_data['sale_date'] < week_2_start])
old_daily = old_sales / max(old_days, 1) if old_sales > 0 and old_days > 0 else 0
# Calculate weighted average
weighted_daily_sales = (
recent_daily * recent_weight +
medium_daily * medium_weight +
old_daily * old_weight
)
if weighted_daily_sales <= 0:
return float('inf')
return current_stock / weighted_daily_sales
except Exception as e:
logger.error(f"Error in weighted average calculation: {str(e)}")
# Fallback to simple method
avg_daily = sales_data['quantity'].sum() / len(sales_data) if len(sales_data) > 0 else 1
return self.simple_division_method(current_stock, avg_daily)
def seasonal_adjustment_method(self,
current_stock: float,
avg_daily_sales: float,
seasonal_factor: float = 1.0,
trend_factor: float = 1.0) -> float:
"""
Seasonal adjustment method accounting for seasonal patterns
Args:
current_stock: Current inventory level
avg_daily_sales: Average daily sales rate
seasonal_factor: Seasonal adjustment factor (1.0 = no adjustment)
trend_factor: Trend adjustment factor (1.0 = no trend)
Returns:
Days until stockout with seasonal adjustment
"""
if avg_daily_sales <= 0:
return float('inf')
if current_stock <= 0:
return 0
# Adjust demand for seasonal and trend factors
adjusted_daily_sales = avg_daily_sales * seasonal_factor * trend_factor
adjusted_daily_sales = max(0.1, adjusted_daily_sales) # Minimum threshold
return current_stock / adjusted_daily_sales
def calculate_reorder_point(self,
avg_daily_sales: float,
lead_time_days: int = 7,
safety_stock: float = None) -> float:
"""
Calculate reorder point for inventory management
Args:
avg_daily_sales: Average daily sales rate
lead_time_days: Lead time in days
safety_stock: Safety stock level (if None, calculated automatically)
Returns:
Recommended reorder point
"""
if avg_daily_sales <= 0:
return 0
# Calculate basic reorder point
reorder_point = avg_daily_sales * lead_time_days
# Add safety stock if provided
if safety_stock is not None:
reorder_point += safety_stock
else:
# Calculate automatic safety stock (2 weeks of average sales)
auto_safety_stock = avg_daily_sales * 14
reorder_point += auto_safety_stock
return reorder_point
def calculate_economic_order_quantity(self,
annual_demand: float,
ordering_cost: float,
holding_cost_per_unit: float) -> Tuple[float, float]:
"""
Calculate Economic Order Quantity (EOQ)
Args:
annual_demand: Annual demand in units
ordering_cost: Cost per order
holding_cost_per_unit: Annual holding cost per unit
Returns:
Tuple of (EOQ, Total Cost)
"""
if annual_demand <= 0 or ordering_cost <= 0 or holding_cost_per_unit <= 0:
return 0, 0
# EOQ formula: sqrt(2 * D * S / H)
eoq = np.sqrt((2 * annual_demand * ordering_cost) / holding_cost_per_unit)
# Total cost calculation
ordering_cost_total = (annual_demand / eoq) * ordering_cost
holding_cost_total = (eoq / 2) * holding_cost_per_unit
total_cost = ordering_cost_total + holding_cost_total
return eoq, total_cost
def batch_forecast(self,
inventory_data: pd.DataFrame,
sales_data: pd.DataFrame,
method: str = "simple") -> pd.DataFrame:
"""
Perform batch forecasting for multiple products
Args:
inventory_data: DataFrame with inventory levels
sales_data: DataFrame with sales history
method: Forecasting method to use
Returns:
DataFrame with forecasting results
"""
results = []
for _, product in inventory_data.iterrows():
product_id = product.get('product_id')
current_stock = product.get('current_stock', 0)
product_name = product.get('product_name', f'Product {product_id}')
# Filter sales data for this product
product_sales = sales_data[sales_data['product_id'] == product_id] if 'product_id' in sales_data.columns else pd.DataFrame()
# Calculate forecast based on method
try:
if method == "simple":
avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1
days_left = self.simple_division_method(current_stock, avg_daily)
elif method == "safety_stock":
if not product_sales.empty and 'quantity' in product_sales.columns:
avg_daily = product_sales['quantity'].mean()
max_daily = product_sales['quantity'].max()
days_left = self.safety_stock_method(current_stock, avg_daily, max_daily)
else:
days_left = self.simple_division_method(current_stock, 1)
elif method == "weighted":
if not product_sales.empty:
days_left = self.weighted_average_method(current_stock, product_sales)
else:
days_left = self.simple_division_method(current_stock, 1)
elif method == "seasonal":
avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1
# For demo, use seasonal factor based on current month
current_month = datetime.now().month
seasonal_factors = {12: 1.5, 1: 1.3, 2: 0.8, 3: 0.9, 4: 1.0, 5: 1.1,
6: 1.2, 7: 1.1, 8: 1.0, 9: 0.9, 10: 1.1, 11: 1.3}
seasonal_factor = seasonal_factors.get(current_month, 1.0)
days_left = self.seasonal_adjustment_method(current_stock, avg_daily, seasonal_factor)
else:
# Default to simple method
avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1
days_left = self.simple_division_method(current_stock, avg_daily)
# Categorize risk level
if days_left < 7:
risk_level = "🔴 Critical"
risk_score = 3
elif days_left < 14:
risk_level = "🟡 Warning"
risk_score = 2
else:
risk_level = "🟢 Safe"
risk_score = 1
# Calculate recommended reorder point
avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1
reorder_point = self.calculate_reorder_point(avg_daily)
results.append({
'product_id': product_id,
'product_name': product_name,
'current_stock': current_stock,
'days_until_stockout': round(days_left, 1),
'risk_level': risk_level,
'risk_score': risk_score,
'recommended_reorder_point': round(reorder_point, 0),
'method_used': method,
'forecast_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
except Exception as e:
logger.error(f"Error forecasting for product {product_id}: {str(e)}")
results.append({
'product_id': product_id,
'product_name': product_name,
'current_stock': current_stock,
'days_until_stockout': 0,
'risk_level': "❌ Error",
'risk_score': 4,
'recommended_reorder_point': 0,
'method_used': method,
'forecast_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
return pd.DataFrame(results)
def get_method_description(self, method: str) -> str:
"""Get description of forecasting method"""
descriptions = {
"simple": "Простое деление: текущий запас ÷ средние дневные продажи",
"safety_stock": "С учетом страхового запаса: учитывает колебания спроса и времени поставки",
"weighted": "Взвешенное среднее: больший вес для недавних продаж",
"seasonal": "С сезонной корректировкой: учитывает сезонные колебания спроса"
}
return descriptions.get(method, "Неизвестный метод")
def get_recommendations(self, forecast_results: pd.DataFrame) -> Dict[str, Any]:
"""Generate recommendations based on forecast results"""
if forecast_results.empty:
return {"message": "Нет данных для анализа"}
critical_count = len(forecast_results[forecast_results['risk_score'] == 3])
warning_count = len(forecast_results[forecast_results['risk_score'] == 2])
safe_count = len(forecast_results[forecast_results['risk_score'] == 1])
recommendations = {
"summary": {
"total_products": len(forecast_results),
"critical_items": critical_count,
"warning_items": warning_count,
"safe_items": safe_count
},
"actions": []
}
if critical_count > 0:
critical_products = forecast_results[forecast_results['risk_score'] == 3]['product_name'].tolist()
recommendations["actions"].append({
"priority": "Высокий",
"action": f"Срочно заказать товары: {', '.join(critical_products[:3])}{'...' if len(critical_products) > 3 else ''}",
"count": critical_count
})
if warning_count > 0:
recommendations["actions"].append({
"priority": "Средний",
"action": f"Подготовить заказ для {warning_count} товаров в ближайшие дни",
"count": warning_count
})
if safe_count == len(forecast_results):
recommendations["actions"].append({
"priority": "Низкий",
"action": "Все товары имеют достаточный запас",
"count": safe_count
})
return recommendations