File size: 6,044 Bytes
38c1a14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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])
    }