Spaces:
Running
Running
File size: 17,885 Bytes
d80bf0f 50a162f d80bf0f |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
"""
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 |