Spaces:
Sleeping
Sleeping
| import os | |
| import joblib | |
| import json | |
| import numpy as np | |
| import xgboost as xgb | |
| import pandas as pd | |
| from typing import Dict, Any, List | |
| from src.api.config import MODEL_PATH, CHALLENGER_PATH, CUSTOMERS_JSON_PATH, CONFIG, logger | |
| from src.api.models import CustomerInput | |
| # Global variables for models and in-memory customer data | |
| champion_model = None | |
| challenger_model = None | |
| memory_customers = {} | |
| def load_models_and_data() -> None: | |
| """Loads all models and loads customer record assets into memory.""" | |
| global champion_model, challenger_model, memory_customers | |
| # Load Champion model | |
| logger.info(f"Attempting to load champion model from {MODEL_PATH}...") | |
| if os.path.exists(MODEL_PATH): | |
| try: | |
| champion_model = joblib.load(MODEL_PATH) | |
| logger.info("Successfully loaded champion XGBoost model.") | |
| except Exception as e: | |
| logger.error(f"Error loading champion model: {str(e)}") | |
| else: | |
| logger.warning(f"Champion model file missing at {MODEL_PATH}.") | |
| # Load Challenger model | |
| logger.info(f"Attempting to load challenger model from {CHALLENGER_PATH}...") | |
| if os.path.exists(CHALLENGER_PATH): | |
| try: | |
| challenger_model = joblib.load(CHALLENGER_PATH) | |
| logger.info("Successfully loaded challenger Random Forest model.") | |
| except Exception as e: | |
| logger.error(f"Error loading challenger model: {str(e)}") | |
| else: | |
| logger.warning(f"Challenger model file missing at {CHALLENGER_PATH}.") | |
| # Load customer records into memory for live WebSocket streaming | |
| if os.path.exists(CUSTOMERS_JSON_PATH): | |
| try: | |
| with open(CUSTOMERS_JSON_PATH, "r") as f: | |
| data = json.load(f) | |
| for cust in data: | |
| memory_customers[str(cust["id"])] = cust | |
| logger.info(f"Successfully loaded {len(memory_customers)} customer records into memory for WebSocket streaming.") | |
| except Exception as e: | |
| logger.error(f"Failed to load customers.json into memory: {str(e)}") | |
| else: | |
| logger.warning(f"customers.json missing at {CUSTOMERS_JSON_PATH}. WebSocket streaming fallback mock data will be used.") | |
| def get_realtime_recommendation(recency: int, frequency: int, monetary: float, churn_prob: float) -> str: | |
| """Generates actionable retention strategy based on client segment classification.""" | |
| is_high_value = (frequency >= 3) or (monetary >= 300.0) | |
| if churn_prob >= 0.70: | |
| if is_high_value: | |
| return "At-Risk VIP: High historic value. Route to customer relations manager for direct feedback outreach. Offer priority recovery benefits." | |
| else: | |
| return "Hibernating Win-back: Inactive standard customer. Target with automated email re-engagement flow offering aggressive discount vouchers." | |
| elif churn_prob >= 0.30: | |
| if is_high_value: | |
| return "Proactive VIP Retention: High-value showing drop-off signs. Send customized recommendations based on past purchases. Avoid direct discount spam." | |
| else: | |
| return "Standard Retention: Nurture with standard newsletter promotions and seasonal discounts." | |
| else: | |
| if is_high_value: | |
| return "Maintain & Upsell: Core loyal customer. Exclude from margin-diluting discount codes. Send early access and premium alerts." | |
| else: | |
| return "Nurture Campaign: Keep engaged with standard marketing updates." | |
| def preprocess_features(customer: CustomerInput) -> np.ndarray: | |
| """Preprocesses input parameters and fills in feature engineered attributes.""" | |
| single_buyer_impute = CONFIG["parameters"]["single_order_imputation_days"] | |
| default_uk = CONFIG["parameters"]["default_is_uk"] | |
| avg_days = customer.avg_days_between if customer.avg_days_between is not None else (single_buyer_impute if customer.frequency == 1 else 30.0) | |
| recent_ratio = customer.recent_orders_ratio if customer.recent_orders_ratio is not None else (1.0 if customer.recency <= 60 else 0.0) | |
| recency_ratio = customer.recency / (avg_days + 1e-5) | |
| is_uk = customer.is_uk if customer.is_uk is not None else default_uk | |
| return np.array([[ | |
| customer.recency, | |
| customer.frequency, | |
| customer.monetary, | |
| customer.basket_size, | |
| avg_days, | |
| recency_ratio, | |
| recent_ratio, | |
| is_uk | |
| ]]) | |
| def run_champion_inference(features: np.ndarray) -> float: | |
| """Computes prediction probability using the Champion XGBoost model.""" | |
| if champion_model is None: | |
| raise ValueError("Champion model is not loaded.") | |
| return float(champion_model.predict_proba(features)[:, 1][0]) | |
| def run_challenger_inference(features: np.ndarray) -> float: | |
| """Computes prediction probability using the Challenger Random Forest model.""" | |
| if challenger_model is None: | |
| return 0.0 | |
| df_features = pd.DataFrame(features, columns=[ | |
| 'Recency', 'Frequency', 'Monetary', 'AvgBucketSize', | |
| 'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK' | |
| ]) | |
| return float(challenger_model.predict_proba(df_features)[:, 1][0]) | |
| def compute_shap_values(features: np.ndarray) -> Dict[str, float]: | |
| """Calculates TreeSHAP feature attribution scores for explainability plots.""" | |
| if champion_model is None: | |
| return {} | |
| booster = champion_model.get_booster() | |
| dmat = xgb.DMatrix(features, feature_names=[ | |
| 'Recency', 'Frequency', 'Monetary', 'AvgBucketSize', | |
| 'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK' | |
| ]) | |
| contribs = booster.predict(dmat, pred_contribs=True)[0] | |
| return { | |
| "Recency": float(contribs[0]), | |
| "Frequency": float(contribs[1]), | |
| "Monetary": float(contribs[2]), | |
| "AvgBucketSize": float(contribs[3]), | |
| "AvgDaysBetween": float(contribs[4]), | |
| "Recency_to_AvgDaysRatio": float(contribs[5]), | |
| "Recent_Orders_Ratio": float(contribs[6]), | |
| "Is_UK": float(contribs[7]) | |
| } | |