Spaces:
Sleeping
Sleeping
Commit ·
38c1a14
0
Parent(s):
Fix requirements.txt dependencies
Browse files- .gitattributes +2 -0
- .github/workflows/mlops_pipeline.yml +70 -0
- .gitignore +6 -0
- Dockerfile +35 -0
- README.md +76 -0
- config.yaml +28 -0
- data/processed/churn_predictions_report.csv +0 -0
- data/processed/high_value_at_risk_customers.csv +101 -0
- data/processed/rfm_segments.csv +0 -0
- images/image-1.png +0 -0
- images/image.png +0 -0
- images/newplot.png +0 -0
- logs/customer_analytics.log +485 -0
- logs/inference_history.csv +18 -0
- logs/predictions.db +0 -0
- notebooks/01_Data_Loading.ipynb +225 -0
- notebooks/01_EDA_and_Cleaning.ipynb +553 -0
- notebooks/02_RFM_Segmentation.ipynb +0 -0
- notebooks/03_Churn_Prediction.ipynb +0 -0
- requirements.txt +18 -0
- src/api.py +2 -0
- src/api/__init__.py +2 -0
- src/api/config.py +17 -0
- src/api/database.py +63 -0
- src/api/drift_service.py +102 -0
- src/api/main.py +37 -0
- src/api/ml_services.py +131 -0
- src/api/models.py +14 -0
- src/api/routes.py +222 -0
- src/api/websocket_manager.py +131 -0
- src/config_loader.py +25 -0
- src/export_json.py +118 -0
- src/logger_config.py +37 -0
- src/make_dataset.py +61 -0
- src/predict.py +224 -0
- src/train.py +153 -0
- src/train_challenger.py +131 -0
- src/validate_retraining.py +140 -0
- tests/test_api.py +91 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
.github/workflows/mlops_pipeline.yml
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: MLOps CI/CD & Continuous Training Pipeline
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
lint-and-test:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout Repository
|
| 14 |
+
uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- name: Set up Python
|
| 17 |
+
uses: actions/setup-python@v5
|
| 18 |
+
with:
|
| 19 |
+
python-version: '3.12'
|
| 20 |
+
cache: 'pip'
|
| 21 |
+
|
| 22 |
+
- name: Install Python Dependencies
|
| 23 |
+
run: |
|
| 24 |
+
python -m pip install --upgrade pip
|
| 25 |
+
pip install ruff pytest anyio httpx pandas numpy scikit-learn joblib xgboost fastapi uvicorn pydantic
|
| 26 |
+
|
| 27 |
+
- name: Run Ruff Linter
|
| 28 |
+
run: ruff check src/
|
| 29 |
+
|
| 30 |
+
- name: Run Unit Tests
|
| 31 |
+
run: pytest
|
| 32 |
+
|
| 33 |
+
continuous-training-validation:
|
| 34 |
+
needs: lint-and-test
|
| 35 |
+
runs-on: ubuntu-latest
|
| 36 |
+
steps:
|
| 37 |
+
- name: Checkout Repository
|
| 38 |
+
uses: actions/checkout@v4
|
| 39 |
+
|
| 40 |
+
- name: Set up Python
|
| 41 |
+
uses: actions/setup-python@v5
|
| 42 |
+
with:
|
| 43 |
+
python-version: '3.12'
|
| 44 |
+
cache: 'pip'
|
| 45 |
+
|
| 46 |
+
- name: Install Dependencies
|
| 47 |
+
run: |
|
| 48 |
+
python -m pip install --upgrade pip
|
| 49 |
+
pip install pandas numpy scikit-learn joblib xgboost fastapi uvicorn pydantic
|
| 50 |
+
|
| 51 |
+
- name: Validate Model Retraining Performance
|
| 52 |
+
run: python src/validate_retraining.py
|
| 53 |
+
|
| 54 |
+
docker-build:
|
| 55 |
+
needs: continuous-training-validation
|
| 56 |
+
runs-on: ubuntu-latest
|
| 57 |
+
steps:
|
| 58 |
+
- name: Checkout Repository
|
| 59 |
+
uses: actions/checkout@v4
|
| 60 |
+
|
| 61 |
+
- name: Set up Docker Buildx
|
| 62 |
+
uses: docker/setup-buildx-action@v3
|
| 63 |
+
|
| 64 |
+
- name: Build Backend Container
|
| 65 |
+
uses: docker/build-push-action@v5
|
| 66 |
+
with:
|
| 67 |
+
context: .
|
| 68 |
+
file: ./Dockerfile
|
| 69 |
+
push: false
|
| 70 |
+
tags: customer-segmentation-api:latest
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
node_modules/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.ipynb_checkpoints
|
| 5 |
+
data/raw/*.xlsx
|
| 6 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official slim Python runtime as base image
|
| 2 |
+
FROM python:3.12-slim
|
| 3 |
+
|
| 4 |
+
# Set system environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
|
| 8 |
+
# Set the container workspace directory
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install compilation tools needed for C-extensions
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Copy only requirements to leverage Docker cache layers
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
|
| 19 |
+
# Install dependencies
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy unified YAML configuration and source directory
|
| 23 |
+
COPY config.yaml .
|
| 24 |
+
COPY src/ src/
|
| 25 |
+
|
| 26 |
+
# Copy processed datasets and serialized models needed for server inference
|
| 27 |
+
COPY data/processed/ data/processed/
|
| 28 |
+
COPY models/ models/
|
| 29 |
+
|
| 30 |
+
# Expose port (7860 is default for Hugging Face Spaces, 8000 for local)
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
EXPOSE 8000
|
| 33 |
+
|
| 34 |
+
# Run FastAPI prediction service via Uvicorn with port override fallback
|
| 35 |
+
CMD ["sh", "-c", "uvicorn src.api:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Churnflow API
|
| 3 |
+
emoji: 📈
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
🛒 Customer Segmentation & Churn Prediction
|
| 12 |
+

|
| 13 |
+

|
| 14 |
+

|
| 15 |
+

|
| 16 |
+
📌 Executive Summary
|
| 17 |
+
Customer churn is one of the biggest revenue leaks in retail. This project analyzes transaction data to identify high-value customer segments and predict churn probability with 73% Recall.
|
| 18 |
+
By engineering features from raw purchase logs and utilizing an XGBoost classifier, this solution helps businesses transition from "reactive" retention (trying to save everyone) to "proactive" retention (focusing resources on high-value at-risk users).
|
| 19 |
+
Key Business Insight:
|
| 20 |
+
"Frequency" was identified as the #1 predictor of churn.
|
| 21 |
+
Strategy: Users who fail to make a second purchase within 45 days are 80% likely to churn. Marketing spend should prioritize "Second Purchase Habits" rather than generic discounts.
|
| 22 |
+
📊 Project Architecture
|
| 23 |
+
The project follows a modular Data Science lifecycle:
|
| 24 |
+
1. Data Cleaning & Preprocessing (01_Data_Cleaning)
|
| 25 |
+
Source: UCI Online Retail II Dataset.
|
| 26 |
+
Engineering: Converted raw transaction logs into a customer-level dataset.
|
| 27 |
+
Handling: Removed returns (negative quantities) and adjusted for inflation/outliers.
|
| 28 |
+
2. Customer Segmentation (02_RFM_Segmentation)
|
| 29 |
+
Methodology: RFM (Recency, Frequency, Monetary) Analysis combined with K-Means Clustering.
|
| 30 |
+
Result: 3 Distinct Segments:
|
| 31 |
+
🟢 Champions: High spend, frequent buyers (Upsell targets).
|
| 32 |
+
🔵 Loyalists: Moderate frequency (Maintenance targets).
|
| 33 |
+
🔴 Hibernating: High risk of churn (Win-back targets).
|
| 34 |
+

|
| 35 |
+
3. Predictive Modeling (03_Churn_Prediction)
|
| 36 |
+
Target Definition: Sliding window approach (Train on first 9 months, Predict on next 3 months).
|
| 37 |
+
Models Tested: Random Forest (Baseline) vs. XGBoost (Final).
|
| 38 |
+
Handling Imbalance: Used scale_pos_weight to penalize false negatives.
|
| 39 |
+
📈 Model Performance
|
| 40 |
+
We optimized the model for Recall (Sensitivity) because missing a churning customer is more costly than a false alarm.
|
| 41 |
+
Metric Random Forest (Baseline) XGBoost (Tuned)
|
| 42 |
+
Accuracy 67% 64%
|
| 43 |
+
Recall (Churn Catch Rate) 58% 73% 🚀
|
| 44 |
+
Precision 60% 55%
|
| 45 |
+
Key Drivers (Feature Importance)
|
| 46 |
+
Unlike typical assumptions that "Big Spenders Stay," our model found that Consistency matters more than Value.
|
| 47 |
+
Frequency (Count of purchases): The strongest signal for retention.
|
| 48 |
+
Recency: Immediate drop-off after 60 days of silence.
|
| 49 |
+
Monetary: Surprisingly, a weaker predictor of churn than frequency.
|
| 50 |
+

|
| 51 |
+
🛠️ Tech Stack
|
| 52 |
+
Language: Python 3.9+
|
| 53 |
+
Data Manipulation: Pandas, NumPy
|
| 54 |
+
Machine Learning: Scikit-Learn (K-Means), XGBoost
|
| 55 |
+
Visualization: Plotly (3D Interactive), Matplotlib, Seaborn
|
| 56 |
+
Interpretability: Feature Importance Analysis
|
| 57 |
+
🚀 Usage
|
| 58 |
+
1. Clone the repository
|
| 59 |
+
code
|
| 60 |
+
Bash
|
| 61 |
+
git clone https://github.com/aleem1991/customer-segmentation.git
|
| 62 |
+
2. Install dependencies
|
| 63 |
+
code
|
| 64 |
+
Bash
|
| 65 |
+
pip install -r requirements.txt
|
| 66 |
+
3. Run the Notebooks
|
| 67 |
+
Open notebooks/01_Data_Cleaning.ipynb to prepare the data.
|
| 68 |
+
Run notebooks/03_Churn_Prediction.ipynb to train the model and generate the Churn Report.
|
| 69 |
+
💼 Business Recommendations
|
| 70 |
+
Based on the analysis, the following actions are recommended to stakeholders:
|
| 71 |
+
Segment Risk Identified Behavior Recommended Action
|
| 72 |
+
High Risk (One-Time Buyers) Purchased once, no return in 45 days. Trigger: Send "Welcome Back" bonus for 2nd purchase immediately.
|
| 73 |
+
At-Risk Champions High historic spend but Low Recency. Trigger: Personal account manager outreach (No automated spam).
|
| 74 |
+
Safe Loyalists Buying regularly. Trigger: Do nothing. Exclude from aggressive discount lists to save margin.
|
| 75 |
+
📬 Contact
|
| 76 |
+
Created by Aleem - Feel free to contact me for details on the feature engineering pipeline!
|
config.yaml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paths:
|
| 2 |
+
raw_data: "data/raw/online_retail_II.xlsx"
|
| 3 |
+
clean_data: "data/processed/online_retail_clean.csv"
|
| 4 |
+
model: "models/churn_xgb_model.pkl"
|
| 5 |
+
segments: "data/processed/rfm_segments.csv"
|
| 6 |
+
predictions_report: "data/processed/churn_predictions_report.csv"
|
| 7 |
+
high_value_report: "data/processed/high_value_at_risk_customers.csv"
|
| 8 |
+
|
| 9 |
+
parameters:
|
| 10 |
+
cutoff_offset_days: 90
|
| 11 |
+
recent_purchase_window_days: 60
|
| 12 |
+
single_order_imputation_days: 180.0
|
| 13 |
+
default_recent_orders_ratio: 0.3
|
| 14 |
+
default_is_uk: 1
|
| 15 |
+
|
| 16 |
+
model_hyperparameters:
|
| 17 |
+
n_estimators: 150
|
| 18 |
+
learning_rate: 0.05
|
| 19 |
+
max_depth: 4
|
| 20 |
+
subsample: 0.8
|
| 21 |
+
colsample_bytree: 0.8
|
| 22 |
+
scale_pos_weight: 2.0
|
| 23 |
+
random_state: 42
|
| 24 |
+
|
| 25 |
+
api:
|
| 26 |
+
host: "127.0.0.1"
|
| 27 |
+
port: 8000
|
| 28 |
+
reload: true
|
data/processed/churn_predictions_report.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/processed/high_value_at_risk_customers.csv
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Customer ID,Recency,Frequency,Monetary,AvgBucketSize,AvgDaysBetween,Recency_to_AvgDaysRatio,Recent_Orders_Ratio,Is_UK,Churn_Probability,SHAP_Recency,SHAP_Frequency,SHAP_Monetary,SHAP_AvgBucketSize,SHAP_AvgDaysBetween,SHAP_Recency_to_AvgDaysRatio,SHAP_Recent_Orders_Ratio,SHAP_Is_UK,Risk_Tier,Segment,Actionable_Recommendation
|
| 2 |
+
12918,261,1,10953.5,1.0,180.0,1.4499999194444488,0.0,1,0.8683201,-0.14584176,0.3756971,0.040458303,0.8840396,0.25739065,0.033985544,0.013004925,-0.013065498,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 3 |
+
15760,265,2,6958.17,1.0,0.0,26499999.999999996,0.0,0,0.7910413,-0.25016513,0.07113772,0.2324797,0.6669705,-0.11506628,0.14898624,0.023567816,0.11278458,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 4 |
+
15202,55,3,2000.4966666666667,1.0,0.0,5500000.0,1.0,1,0.8577937,-0.0120769255,-0.10333775,0.40868494,0.72960603,0.031659637,0.24801446,0.061697606,-0.0076822555,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 5 |
+
12737,133,2,1855.25,1.0,0.0,13299999.999999998,0.0,0,0.86656964,-0.068069756,0.14429699,0.42602763,0.6281888,-0.033610195,0.19079919,0.023821695,0.11898945,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 6 |
+
14028,185,3,1155.1666666666667,796.6666666666666,0.0,18500000.0,0.0,1,0.766893,0.15611957,-0.15823689,-0.10209136,0.26102847,0.18570681,0.40627936,0.0065838774,-0.0050586713,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 7 |
+
14255,181,1,1000.63,1.0,180.0,1.0055554996913612,0.0,1,0.91079277,0.12798013,0.41695756,0.28489372,0.900302,0.22334704,-0.06761861,0.013092393,-0.016118586,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 8 |
+
17369,90,1,979.2,1152.0,180.0,0.49999997222222375,0.0,1,0.71803045,0.0103942,0.47507063,-0.2360715,0.1945713,0.21206254,-0.15888846,0.0033493883,-0.0062943185,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 9 |
+
14802,73,2,751.49,1.0,0.0,7299999.999999999,0.0,1,0.88576806,0.040923417,0.1836641,0.48145267,0.6741837,0.007585065,0.22021893,0.010153994,-0.010476549,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 10 |
+
14091,289,2,733.0830769230769,255.07692307692307,44.0,6.568180325413562,0.0,1,0.7271479,0.091391824,0.17524946,-0.17669925,0.19313954,-0.08684244,0.34631354,0.0009169499,-0.0037881061,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 11 |
+
18052,199,1,679.82375,1110.375,180.0,1.105555494135806,0.0,1,0.8765103,0.35821477,0.48372772,0.22388105,0.18248336,0.27700907,-0.0032853936,0.0044742,-0.0072329985,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 12 |
+
13864,21,1,662.4000000000001,192.0,180.0,0.11666666018518554,1.0,1,0.87140644,-0.028443767,0.48841187,0.3369665,0.16094254,0.24510849,0.19651242,0.0738269,-0.00039153988,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 13 |
+
15929,279,1,594.0,120.0,180.0,1.5499999138888936,0.0,1,0.81515896,0.1256732,0.46727258,0.104192905,0.078614965,0.234024,0.032018322,0.006934156,-0.0053616036,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 14 |
+
13270,1,1,590.0,200.0,180.0,0.005555555246913597,1.0,1,0.82054025,-0.15044011,0.48968485,0.11528971,0.35057056,0.26227272,-0.10368249,0.12256415,-0.006765737,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 15 |
+
14308,184,2,573.51,1.0,0.0,18400000.0,0.0,1,0.906499,0.19546686,0.12831813,0.71812356,0.6581632,-0.05771281,0.18535405,0.014247822,-0.010861011,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 16 |
+
12503,219,1,563.0,280.5,180.0,1.2166665990740777,0.0,0,0.849484,0.27350044,0.4294449,0.2781778,0.024391534,0.25317106,-0.06344194,0.017178748,0.07761856,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 17 |
+
17310,22,2,512.55,201.0,147.0,0.1496598537646358,0.5,1,0.8062422,-0.049092665,0.31561866,0.355356,0.16486068,0.3545853,-0.19349882,0.044131324,-0.0067038443,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 18 |
+
13559,147,2,495.0,180.0,30.0,4.899998366667211,0.0,1,0.8264181,0.20834415,0.12245606,0.25340852,0.30387664,-0.08544916,0.32287952,-0.001210685,-0.004372636,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 19 |
+
14328,73,1,445.05,1.0,180.0,0.4055555330246926,0.0,1,0.93956363,-0.0031666749,0.49439386,0.73135823,0.86251944,0.28447154,-0.0673614,0.0144158965,-0.013325618,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 20 |
+
14933,78,4,442.5,150.0,0.0,7799999.999999999,0.0,0,0.7752419,0.060348637,-0.24076572,0.122391224,0.20989162,0.20933269,0.3749227,0.010439365,0.051071145,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 21 |
+
13096,29,1,419.7000000000001,6.0,180.0,0.16111110216049432,1.0,1,0.83807516,-0.26944754,0.47000134,0.6362138,0.10482195,0.285685,-0.028517047,0.014973851,-0.01027515,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 22 |
+
16737,212,1,417.6,288.0,180.0,1.1777777123456827,0.0,1,0.8591849,0.28989765,0.46507868,0.27312875,0.12854779,0.25804186,-0.046517033,0.0069905496,-0.0071508707,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 23 |
+
14956,56,1,331.25,625.0,180.0,0.31111109382716146,1.0,1,0.8023891,-0.08622908,0.48221987,0.2930206,0.13310407,0.23756616,-0.1834748,0.088894166,-0.0043261293,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 24 |
+
14832,265,1,322.69,1.0,180.0,1.4722221404321032,0.0,1,0.9262868,-0.057054132,0.41229048,0.49762478,0.8607022,0.30551142,0.07147015,0.013004925,-0.013065498,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 25 |
+
13942,169,1,320.0,1000.0,180.0,0.938888836728398,0.0,1,0.86956227,0.25778037,0.47029015,0.2386569,0.13503678,0.2795039,0.07809711,0.0038271693,-0.006617478,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 26 |
+
13776,63,2,311.99,1.0,0.0,6299999.999999999,0.0,1,0.9061351,0.08695371,0.2076631,0.74798536,0.69797194,-0.07788167,0.16444331,0.010153994,-0.010476549,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 27 |
+
17077,213,1,306.0,36.0,180.0,1.1833332675925963,0.0,1,0.85449183,0.32563493,0.4737239,0.28877264,0.037688,0.2744987,-0.06548419,0.0069905496,-0.012069167,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 28 |
+
15893,116,1,305.28000000000003,288.0,180.0,0.6444444086419773,0.0,1,0.8460431,0.027688151,0.5384277,0.3096512,0.26984128,0.28384647,-0.16075432,0.003313062,-0.008634467,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 29 |
+
13383,178,1,305.28000000000003,288.0,180.0,0.9888888339506203,0.0,1,0.87058854,0.19314793,0.49404708,0.3014009,0.24192238,0.26358965,-0.026058193,0.004220874,-0.006617478,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 30 |
+
18227,119,1,295.0,1.0,180.0,0.6611110743827181,0.0,1,0.90232384,-0.045089867,0.4882156,0.7754832,0.61632246,0.2863492,-0.3376014,0.014498922,-0.015379935,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 31 |
+
16077,260,1,287.55,105.0,180.0,1.4444443641975353,0.0,1,0.73613423,-0.2621814,0.43871605,0.025004843,0.054327212,0.25595507,0.07323174,0.0068274788,-0.0064279893,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 32 |
+
17638,282,2,280.8,144.0,74.0,3.8108102958364465,0.0,1,0.720453,0.047233086,0.21030648,0.14712171,0.21434663,-0.19297561,0.076287016,0.0054180957,-0.0015458524,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 33 |
+
15413,326,5,271.9488,107.76,11.0,29.63633669423937,0.0,1,0.81940955,0.54656905,-0.36783624,0.09415762,0.18211961,-0.034905687,0.64035094,0.022861568,-0.011482323,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 34 |
+
13687,73,1,264.01866666666666,1937.0444444444445,180.0,0.4055555330246926,0.0,1,0.79651535,0.051139995,0.524901,0.24140647,0.07159849,0.26918164,-0.23056902,0.0033493883,-0.0068710186,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 35 |
+
13128,266,1,263.5,31.0,180.0,1.477777695679017,0.0,1,0.750212,-0.18384303,0.43728304,0.062268745,0.047677793,0.26860306,0.028813446,0.009643778,-0.0112220105,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 36 |
+
15396,218,1,254.10000000000002,78.0,180.0,1.2111110438271642,0.0,1,0.85103226,0.30042621,0.4737511,0.3197179,0.0031427573,0.2586012,-0.05120978,0.0069905496,-0.009218815,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 37 |
+
17353,126,2,250.33333333333334,161.33333333333334,132.0,0.9545453822314104,0.0,1,0.84467256,0.10606239,0.26392543,0.35501146,0.2813725,0.17308062,0.07372454,0.0037325455,-0.0040144715,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 38 |
+
17925,7,1,244.08,72.0,180.0,0.03888888672839518,1.0,1,0.75641066,-0.06098584,0.4947902,0.008607862,-0.02921516,0.22039022,-0.05360272,0.12026309,-0.007665579,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 39 |
+
14079,317,1,243.63,1.0,180.0,1.7611110132716103,0.0,1,0.9270312,0.08041994,0.42261404,0.5517617,0.8182054,0.28215742,-0.057272885,0.013247201,-0.009694921,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 40 |
+
16943,174,2,241.38,1.0,84.0,2.071428324829961,0.0,1,0.8857018,0.21014504,0.18077694,0.7175967,0.88491416,-0.27503544,-0.1127453,0.012264338,-0.010866449,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 41 |
+
15538,172,6,239.8857142857143,72.57142857142857,24.8,6.935481074402793,0.0,1,0.8352917,0.6161796,-0.5144201,0.22870167,0.14732449,0.034406506,0.6614082,0.022810183,-0.0133235995,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 42 |
+
16088,210,2,239.5716666666667,62.166666666666664,15.0,13.99999066667289,0.0,1,0.80810887,0.35759032,0.13327034,0.33125308,0.12198398,-0.19290407,0.2502246,0.0024329892,-0.0066020433,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 43 |
+
13734,143,4,239.34428571428575,10.821428571428571,31.0,4.612901737773633,0.0,1,0.778019,0.38236699,-0.07584708,0.4193908,-0.080341525,-0.09105603,0.27122387,0.00048107334,-0.012577674,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 44 |
+
14213,39,1,238.44,48.8,180.0,0.2166666546296303,1.0,1,0.7997152,-0.14744543,0.49471092,0.3505476,0.07216812,0.26155192,-0.16662256,0.085938975,-0.006852856,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 45 |
+
17305,283,1,237.27333333333334,111.77777777777777,180.0,1.572222134876548,0.0,1,0.7965884,0.045146357,0.44581264,0.12551177,0.07680425,0.22950946,8.705119e-05,0.0070697567,-0.005353445,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 46 |
+
15273,234,1,228.96,216.0,180.0,1.2999999277777818,0.0,1,0.7636944,-0.08355361,0.441052,0.00889778,0.09994274,0.2723697,-0.00644185,0.0069905496,-0.0067343814,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 47 |
+
15461,95,2,214.85,115.0,170.0,0.5588234965397944,0.0,1,0.8575641,0.056089323,0.29321954,0.28589597,0.103681564,0.5194643,0.09579922,0.0077854665,-0.0072499337,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 48 |
+
14106,372,1,214.8,24.0,180.0,2.066666551851858,0.0,1,0.74580586,0.019533314,0.44525173,0.05051103,-0.028042816,0.23758788,-0.089100294,0.008592701,-0.008485308,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 49 |
+
13290,73,1,208.63,1.0,180.0,0.4055555330246926,0.0,1,0.93800575,-0.007822571,0.506005,0.6617401,0.8550486,0.31759185,-0.05746439,0.0144158965,-0.013317459,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 50 |
+
16716,265,1,208.08,132.0,180.0,1.4722221404321032,0.0,1,0.7671527,-0.114768855,0.45032713,-0.02761958,0.08510107,0.299645,0.058700155,0.0068274788,-0.0064279893,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 51 |
+
17306,28,2,191.84,376.0,10.0,2.7999972000028,1.0,1,0.7140727,-0.25865832,0.29266587,0.09730331,0.36923125,-0.29378274,0.11768084,0.14942782,0.0008609069,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 52 |
+
15477,284,1,184.2,108.0,180.0,1.5777776901234617,0.0,1,0.78616935,0.04049046,0.45742372,0.03252366,0.07056018,0.27319947,-0.014444533,0.0070697567,-0.005353445,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 53 |
+
17474,234,1,174.5,33.333333333333336,180.0,1.2999999277777818,0.0,1,0.74012333,-0.047250398,0.4558094,-0.026870375,-0.042866636,0.30344027,-0.031200213,0.0069905496,-0.011961846,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 54 |
+
16663,364,1,165.0,100.0,180.0,2.0222221098765494,0.0,1,0.77520156,0.039883595,0.45725864,0.032774806,0.10054011,0.23216112,-0.06699358,0.007129067,-0.005353445,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 55 |
+
16990,57,1,165.0,100.0,180.0,0.31666664907407505,1.0,1,0.82418245,-0.041655477,0.51727825,0.31604987,0.11387758,0.28001815,-0.165594,0.088894166,-0.0044422457,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 56 |
+
12850,80,1,165.0,100.0,180.0,0.4444444197530878,0.0,1,0.8130627,0.054528892,0.55204886,0.25336605,0.06524957,0.31505975,-0.20673133,0.0033493883,-0.0073546115,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 57 |
+
18017,66,1,165.0,100.0,180.0,0.3666666462962974,0.0,1,0.8170782,0.044613916,0.5336104,0.29319015,0.055722743,0.32247123,-0.18800537,0.0019083486,-0.0073546115,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 58 |
+
17152,60,1,163.5,30.0,180.0,0.3333333148148158,0.0,1,0.85258406,0.057985757,0.52903056,0.28890547,0.2145412,0.31807044,-0.089694105,0.0071831476,-0.011527476,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 59 |
+
17715,227,1,163.2,192.0,180.0,1.2611110410493866,0.0,1,0.81344557,0.085461125,0.45164868,0.20435496,0.14872381,0.25765806,-0.11601696,0.0069905496,-0.006783393,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 60 |
+
16443,155,1,162.24,96.0,180.0,0.8611110632716076,0.0,1,0.8722512,0.2538715,0.501787,0.30852035,0.19683821,0.32780954,-0.10585323,0.0042527774,-0.0067335954,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 61 |
+
18273,225,1,153.0,60.0,180.0,1.2499999305555594,0.0,1,0.76272017,0.040120125,0.47992492,0.14772294,-0.060303986,0.29489866,-0.1730021,0.0069905496,-0.009218815,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 62 |
+
13551,301,1,152.39999999999998,24.0,180.0,1.6722221293209927,0.0,1,0.7581268,0.019602392,0.44497186,0.052180123,-0.036487423,0.29200095,-0.07039765,0.00853339,-0.008485308,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 63 |
+
14845,234,1,150.0,600.0,180.0,1.2999999277777818,0.0,1,0.756506,-0.064797536,0.4106948,0.0018510458,0.08667919,0.26797357,-0.009557817,0.0069905496,-0.0067343814,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 64 |
+
12664,149,2,148.52,36.5,212.0,0.7028301555268794,0.0,0,0.7827179,0.10495622,0.10439113,0.29573157,0.091725804,0.0680638,0.03063783,0.014195854,0.13135464,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 65 |
+
12403,156,1,147.91500000000002,41.0,180.0,0.8666666185185212,0.0,0,0.8557086,0.25329316,0.46825686,0.25347114,-0.08115564,0.33741352,-0.0059517953,0.014440974,0.099807695,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 66 |
+
15588,312,1,144.9,24.0,180.0,1.7333332370370425,0.0,1,0.7581268,0.019602392,0.44497186,0.052180123,-0.036487423,0.29200095,-0.07039765,0.00853339,-0.008485308,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 67 |
+
17923,162,2,144.75,45.0,16.0,10.124993671878956,0.0,1,0.71028626,0.26093584,0.10520434,0.11913107,-0.04583787,-0.2213832,0.2467466,-0.0016541027,-0.0068859933,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 68 |
+
12487,365,1,142.65,87.0,180.0,2.027777665123463,0.0,0,0.76291114,0.019607082,0.42885676,0.036144786,0.014835881,0.23365141,-0.07959073,0.01729321,0.057389535,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 69 |
+
12636,373,1,141.0,1.0,180.0,2.0722221070987716,0.0,0,0.9188975,0.056913346,0.42290902,0.44708997,0.80543834,0.24772145,-0.13147782,0.026332468,0.112016365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 70 |
+
13452,107,1,139.8,4.0,180.0,0.5944444114197549,0.0,1,0.8897794,0.022081388,0.55601597,0.57185715,0.15678401,0.37191868,-0.036505695,0.021693984,-0.015873926,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 71 |
+
17440,184,1,137.16,36.0,180.0,1.022222165432102,0.0,1,0.8467202,0.17794903,0.5141411,0.25101814,0.12786518,0.28918335,-0.08151595,0.004220874,-0.014275097,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 72 |
+
14366,160,1,136.0,16.0,180.0,0.8888888395061756,0.0,1,0.85542715,0.2512185,0.47472706,0.44635943,-0.11264087,0.3205786,-0.036703885,0.0063774344,-0.01261739,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 73 |
+
13217,290,1,132.5,250.0,180.0,1.6111110216049431,0.0,1,0.8104051,0.028411692,0.42870918,0.116704464,0.23050293,0.23826113,-0.03261357,0.0070697567,-0.004920002,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 74 |
+
17955,290,2,130.86666666666667,136.0,9.0,32.22218641979287,0.0,1,0.76554906,0.09143055,0.13352051,0.08824912,0.40954962,-0.21750519,0.23725858,0.0023541,-0.0020290106,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 75 |
+
12362,373,1,130.0,1.0,180.0,2.0722221070987716,0.0,0,0.9188975,0.056913346,0.42290902,0.44708997,0.80543834,0.24772145,-0.13147782,0.026332468,0.112016365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 76 |
+
12555,317,1,129.23,1.0,180.0,1.7611110132716103,0.0,0,0.92567414,0.056162834,0.42548972,0.4477685,0.80945355,0.30605575,-0.10124805,0.025846109,0.112016365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 77 |
+
14463,134,1,128.475,15.5,180.0,0.744444403086422,0.0,1,0.807534,0.025637913,0.49459708,0.19671223,-0.009414781,0.3284372,-0.03647787,0.0062321844,-0.012177299,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 78 |
+
16377,252,1,126.45599999999999,285.6,180.0,1.3999999222222266,0.0,1,0.71440095,-0.20710045,0.37690508,-0.13922226,0.19036289,0.26438522,-0.009972773,0.006973106,-0.005994546,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 79 |
+
18026,135,2,122.00411764705883,31.176470588235293,4.0,33.74991562521094,0.0,1,0.75952536,0.17362782,0.07013777,-0.07189671,0.260672,0.06101594,0.22249378,0.0043840148,-0.010874458,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 80 |
+
12770,91,1,118.4375,68.75,180.0,0.5055555274691373,0.0,0,0.7898765,0.008696979,0.46372905,0.06239674,0.02670436,0.32215506,-0.110906675,0.013537589,0.09734964,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 81 |
+
16291,317,1,117.72,1.0,180.0,1.7611110132716103,0.0,0,0.9079962,0.07205189,0.40750706,0.21611315,0.8166895,0.30365312,-0.10498457,0.025846109,0.112016365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 82 |
+
13689,79,1,116.5,70.0,180.0,0.4388888645061742,0.0,1,0.7583982,0.004534104,0.5271085,0.033158135,0.01023579,0.31385162,-0.18015338,0.0033493883,-0.008685282,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 83 |
+
12482,211,29,116.1343137254902,132.55882352941177,2.642857142857143,79.83753574986473,0.0,0,0.84622484,0.7961586,-1.0141556,0.015162547,0.37464514,0.122670256,0.7423003,0.13462669,0.09336659,High Risk,Champions,At-Risk Champion: High historical spend. Assign a personal account manager for direct outreach. Do not send automated discount spam.
|
| 84 |
+
14033,190,1,109.44,576.0,180.0,1.0555554969135834,0.0,1,0.82589555,0.2743012,0.44985548,-0.14528352,0.40516403,0.2011673,-0.06413387,0.004220874,-0.008997483,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 85 |
+
16047,317,1,107.14,1.0,180.0,1.7611110132716103,0.0,0,0.8836207,0.14292195,0.39505908,-0.052152354,0.8136765,0.25752944,-0.10824186,0.025846109,0.112016365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 86 |
+
15486,162,1,102.0,40.0,180.0,0.8999999500000028,0.0,1,0.7704043,0.27469337,0.4682121,-0.268406,-0.006926936,0.27524278,0.031800497,0.0041461005,-0.008685423,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 87 |
+
17570,16,1,100.0,1.0,180.0,0.08888888395061756,1.0,1,0.90564007,-0.059992082,0.43089333,-0.0150554,0.96384317,0.18816493,0.25993294,0.05891501,-0.005693866,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 88 |
+
14405,21,1,97.5,50.0,180.0,0.11666666018518554,1.0,1,0.70704037,-0.06208126,0.45313776,-0.45464393,0.016201207,0.18768324,0.22824828,0.0738269,-0.0018383281,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 89 |
+
15473,79,1,91.60000000000001,34.666666666666664,180.0,0.4388888645061742,0.0,1,0.7023224,-0.012668301,0.5058844,-0.35742375,0.14846462,0.25152698,-0.10974328,0.0033493883,-0.011527476,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 90 |
+
14436,188,1,91.32,84.0,180.0,1.0444443864197563,0.0,1,0.7559669,0.15942195,0.4632893,-0.2910039,0.21186559,0.25495398,-0.10309199,0.004220874,-0.009481076,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 91 |
+
12934,371,1,90.825,15.5,180.0,2.0611109966049446,0.0,1,0.74671805,0.25627404,0.40513808,-0.15259355,-0.019050045,0.21447068,-0.0614618,0.006837304,-0.008949365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 92 |
+
15595,122,1,89.39999999999999,36.0,180.0,0.6777777401234588,0.0,1,0.77405286,0.08320883,0.51091033,-0.31873354,0.19429693,0.24377802,0.08409204,0.004107526,-0.010839812,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 93 |
+
13222,212,1,88.5,30.0,180.0,1.1777777123456827,0.0,1,0.7867836,0.34685043,0.44091654,-0.26807287,0.13854462,0.27394763,-0.06480529,0.009806849,-0.012061008,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 94 |
+
13091,364,2,82.33333333333333,36.375,0.0,36400000.0,0.0,1,0.7786999,0.25861734,0.07590865,-0.09289283,0.16430083,0.060402997,0.35446298,0.0048178704,-0.008030576,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 95 |
+
15864,16,1,81.48,98.0,180.0,0.08888888395061756,1.0,1,0.7252866,-0.14857061,0.45528546,-0.2572866,0.04305798,0.21587747,0.14250846,0.08030854,-0.0008608361,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 96 |
+
17336,107,1,81.12,48.0,180.0,0.5944444114197549,0.0,1,0.71907973,-0.051948063,0.51438844,-0.24020365,0.022857245,0.28482383,-0.027676715,0.007932247,-0.010790947,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 97 |
+
14190,301,1,79.9,2.0,180.0,1.6722221293209927,0.0,1,0.83312607,0.28912613,0.42488518,0.08609699,0.14164144,0.30887416,-0.085104465,0.012009043,-0.010100378,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 98 |
+
16165,140,1,79.9,2.0,180.0,0.7777777345679037,0.0,1,0.7800712,0.03300752,0.4249649,0.04978654,0.1406256,0.26942003,-0.09071067,0.011678684,-0.013210046,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 99 |
+
14207,322,1,78.9,18.0,180.0,1.7888887895061782,0.0,1,0.7582457,0.28483862,0.41708556,-0.07926231,-0.13486601,0.24296807,-0.030216305,0.010969019,-0.008949365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
| 100 |
+
15782,65,1,77.74222222222221,54.44444444444444,180.0,0.3611110910493838,0.0,1,0.7132219,0.017856965,0.4874556,-0.23715499,0.070420705,0.2669529,-0.12818903,0.0019083486,-0.008685282,High Risk,Potential Loyalists,At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive.
|
| 101 |
+
12570,317,1,77.52,1.0,180.0,1.7611110132716103,0.0,0,0.9200536,0.2723058,0.4004957,0.1844653,0.8153533,0.28692684,-0.09485146,0.025846109,0.112016365,High Risk,Hibernating,Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage.
|
data/processed/rfm_segments.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
images/image-1.png
ADDED
|
images/image.png
ADDED
|
images/newplot.png
ADDED
|
logs/customer_analytics.log
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[2026-06-30 22:57:57] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 2 |
+
[2026-06-30 22:58:00] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 3 |
+
[2026-06-30 22:58:03] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 4 |
+
[2026-06-30 22:58:05] INFO [train.py:27] Loading cleaned dataset from E:\project\customer_segmentation_project\data\processed\online_retail_clean.csv...
|
| 5 |
+
[2026-06-30 22:58:05] INFO [train.py:32] Computing cutoff date for prediction window...
|
| 6 |
+
[2026-06-30 22:58:05] INFO [train.py:35] Reference snapshot cutoff date: 2010-09-10
|
| 7 |
+
[2026-06-30 22:58:05] INFO [train.py:37] Splitting dataset into history vs target prediction window...
|
| 8 |
+
[2026-06-30 22:58:05] INFO [train.py:44] Engineering base RFM features...
|
| 9 |
+
[2026-06-30 22:58:06] INFO [train.py:61] Engineering advanced MLE features...
|
| 10 |
+
[2026-06-30 22:58:06] INFO [train.py:103] Target Label Generation complete. Churn class ratio: 42.49%
|
| 11 |
+
[2026-06-30 22:58:06] INFO [train.py:115] Initializing XGBoost classifier with config hyperparameters...
|
| 12 |
+
[2026-06-30 22:58:06] INFO [train.py:127] Fitting model on training set...
|
| 13 |
+
[2026-06-30 22:58:07] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 14 |
+
[2026-06-30 22:58:07] INFO [train.py:130] Evaluating model on validation hold-out set:
|
| 15 |
+
[2026-06-30 22:58:07] INFO [train.py:133]
|
| 16 |
+
precision recall f1-score support
|
| 17 |
+
|
| 18 |
+
0 0.78 0.47 0.59 397
|
| 19 |
+
1 0.52 0.81 0.63 279
|
| 20 |
+
|
| 21 |
+
accuracy 0.61 676
|
| 22 |
+
macro avg 0.65 0.64 0.61 676
|
| 23 |
+
weighted avg 0.67 0.61 0.61 676
|
| 24 |
+
|
| 25 |
+
[2026-06-30 22:58:07] INFO [train.py:138] Saving serialized model to E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 26 |
+
[2026-06-30 22:58:07] INFO [train.py:140] Model training pipeline complete!
|
| 27 |
+
[2026-06-30 22:58:13] INFO [predict.py:35] Loading cleaned dataset...
|
| 28 |
+
[2026-06-30 22:58:14] INFO [predict.py:41] Engineering customer RFM features...
|
| 29 |
+
[2026-06-30 22:58:14] INFO [predict.py:43] Current snapshot date (Reference 'Today'): 2010-12-09
|
| 30 |
+
[2026-06-30 22:58:14] INFO [predict.py:62] Calculating average days between purchases...
|
| 31 |
+
[2026-06-30 22:58:15] INFO [predict.py:81] Calculating order frequency ratios in recent days...
|
| 32 |
+
[2026-06-30 22:58:15] INFO [predict.py:101] Loading trained XGBoost model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 33 |
+
[2026-06-30 22:58:19] INFO [predict.py:114] Running predictions...
|
| 34 |
+
[2026-06-30 22:58:19] INFO [predict.py:129] Merging predictions with offline segments...
|
| 35 |
+
[2026-06-30 22:58:19] INFO [predict.py:142] Generating targeted marketing recommendations...
|
| 36 |
+
[2026-06-30 22:58:19] INFO [predict.py:174] Exporting CSV reports...
|
| 37 |
+
[2026-06-30 22:58:19] INFO [predict.py:179] -> Complete Churn Report saved to: E:\project\customer_segmentation_project\data\processed\churn_predictions_report.csv
|
| 38 |
+
[2026-06-30 22:58:19] INFO [predict.py:186] -> Top 100 High-Value At-Risk Customers saved to: E:\project\customer_segmentation_project\data\processed\high_value_at_risk_customers.csv
|
| 39 |
+
[2026-06-30 22:58:19] INFO [predict.py:189]
|
| 40 |
+
==================================================
|
| 41 |
+
[2026-06-30 22:58:19] INFO [predict.py:190] CHURN ANALYSIS SUMMARY
|
| 42 |
+
[2026-06-30 22:58:19] INFO [predict.py:191] ==================================================
|
| 43 |
+
[2026-06-30 22:58:19] INFO [predict.py:192] Total Customers Analyzed: 4312
|
| 44 |
+
[2026-06-30 22:58:19] INFO [predict.py:199] High Risk (Churn Prob >= 70%): 1340 (31.1%)
|
| 45 |
+
[2026-06-30 22:58:19] INFO [predict.py:200] Medium Risk (30% <= Prob < 70%): 1966 (45.6%)
|
| 46 |
+
[2026-06-30 22:58:19] INFO [predict.py:201] Low Risk (Churn Prob < 30%): 1006 (23.3%)
|
| 47 |
+
[2026-06-30 22:58:19] INFO [predict.py:202] --------------------------------------------------
|
| 48 |
+
[2026-06-30 22:58:19] INFO [predict.py:205] Total Revenue At Risk (High Risk): $108,704.41
|
| 49 |
+
[2026-06-30 22:58:19] INFO [predict.py:206] ==================================================
|
| 50 |
+
|
| 51 |
+
[2026-06-30 22:58:26] INFO [export_json.py:32] Reading prediction outputs for JSON export...
|
| 52 |
+
[2026-06-30 22:58:26] INFO [export_json.py:78] Saving summary statistics to E:\project\customer_segmentation_project\dashboard\public\data\summary.json...
|
| 53 |
+
[2026-06-30 22:58:26] INFO [export_json.py:82] Formatting individual customer records for frontend table...
|
| 54 |
+
[2026-06-30 22:58:26] INFO [export_json.py:97] Saving 4312 formatted records to E:\project\customer_segmentation_project\dashboard\public\data\customers.json...
|
| 55 |
+
[2026-06-30 22:58:26] INFO [export_json.py:101] JSON Data Export Completed successfully!
|
| 56 |
+
[2026-06-30 23:00:29] INFO [api.py:75] Executing health check request...
|
| 57 |
+
[2026-06-30 23:00:29] ERROR [api.py:85] Inference requested but model is not loaded.
|
| 58 |
+
[2026-06-30 23:00:29] ERROR [api.py:144] Inference batch requested but model is not loaded.
|
| 59 |
+
[2026-06-30 23:00:37] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 60 |
+
[2026-06-30 23:00:39] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 61 |
+
[2026-06-30 23:00:39] INFO [api.py:75] Executing health check request...
|
| 62 |
+
[2026-06-30 23:00:39] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 63 |
+
[2026-06-30 23:00:39] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 64 |
+
[2026-06-30 23:00:39] INFO [api.py:113] Running inference for customer profile: recency=10, freq=5
|
| 65 |
+
[2026-06-30 23:00:39] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 66 |
+
[2026-06-30 23:00:39] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 67 |
+
[2026-06-30 23:00:39] INFO [api.py:174] Running batch inference for 2 profiles...
|
| 68 |
+
[2026-06-30 23:00:42] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 69 |
+
[2026-06-30 23:00:47] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 70 |
+
[2026-06-30 23:00:51] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 71 |
+
[2026-06-30 23:00:54] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 72 |
+
[2026-06-30 23:04:46] INFO [api.py:30] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 73 |
+
[2026-06-30 23:04:49] INFO [api.py:34] Successfully loaded churn XGBoost model.
|
| 74 |
+
[2026-06-30 23:07:13] INFO [api.py:40] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 75 |
+
[2026-06-30 23:07:16] INFO [api.py:44] Successfully loaded churn XGBoost model.
|
| 76 |
+
[2026-06-30 23:08:12] INFO [api.py:40] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 77 |
+
[2026-06-30 23:08:14] INFO [api.py:44] Successfully loaded churn XGBoost model.
|
| 78 |
+
[2026-06-30 23:09:09] INFO [api.py:123] Running inference for customer profile: recency=65, freq=3
|
| 79 |
+
[2026-06-30 23:11:22] INFO [api.py:41] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 80 |
+
[2026-06-30 23:11:23] INFO [api.py:45] Successfully loaded churn XGBoost model.
|
| 81 |
+
[2026-06-30 23:11:39] INFO [api.py:41] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 82 |
+
[2026-06-30 23:11:41] INFO [api.py:45] Successfully loaded churn XGBoost model.
|
| 83 |
+
[2026-06-30 23:11:48] INFO [api.py:41] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 84 |
+
[2026-06-30 23:11:50] INFO [api.py:45] Successfully loaded churn XGBoost model.
|
| 85 |
+
[2026-06-30 23:11:52] INFO [predict.py:36] Loading cleaned dataset...
|
| 86 |
+
[2026-06-30 23:11:52] INFO [predict.py:42] Engineering customer RFM features...
|
| 87 |
+
[2026-06-30 23:11:52] INFO [predict.py:44] Current snapshot date (Reference 'Today'): 2010-12-09
|
| 88 |
+
[2026-06-30 23:11:53] INFO [predict.py:63] Calculating average days between purchases...
|
| 89 |
+
[2026-06-30 23:11:53] INFO [predict.py:82] Calculating order frequency ratios in recent days...
|
| 90 |
+
[2026-06-30 23:11:53] INFO [predict.py:102] Loading trained XGBoost model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 91 |
+
[2026-06-30 23:11:53] INFO [predict.py:115] Running predictions...
|
| 92 |
+
[2026-06-30 23:11:53] INFO [predict.py:118] Calculating TreeSHAP contributions...
|
| 93 |
+
[2026-06-30 23:11:54] INFO [predict.py:137] Merging predictions with offline segments...
|
| 94 |
+
[2026-06-30 23:11:54] INFO [predict.py:150] Generating targeted marketing recommendations...
|
| 95 |
+
[2026-06-30 23:11:54] INFO [predict.py:182] Exporting CSV reports...
|
| 96 |
+
[2026-06-30 23:11:54] INFO [predict.py:187] -> Complete Churn Report saved to: E:\project\customer_segmentation_project\data\processed\churn_predictions_report.csv
|
| 97 |
+
[2026-06-30 23:11:54] INFO [predict.py:194] -> Top 100 High-Value At-Risk Customers saved to: E:\project\customer_segmentation_project\data\processed\high_value_at_risk_customers.csv
|
| 98 |
+
[2026-06-30 23:11:54] INFO [predict.py:197]
|
| 99 |
+
==================================================
|
| 100 |
+
[2026-06-30 23:11:54] INFO [predict.py:198] CHURN ANALYSIS SUMMARY
|
| 101 |
+
[2026-06-30 23:11:54] INFO [predict.py:199] ==================================================
|
| 102 |
+
[2026-06-30 23:11:54] INFO [predict.py:200] Total Customers Analyzed: 4312
|
| 103 |
+
[2026-06-30 23:11:54] INFO [predict.py:207] High Risk (Churn Prob >= 70%): 1340 (31.1%)
|
| 104 |
+
[2026-06-30 23:11:54] INFO [predict.py:208] Medium Risk (30% <= Prob < 70%): 1966 (45.6%)
|
| 105 |
+
[2026-06-30 23:11:54] INFO [predict.py:209] Low Risk (Churn Prob < 30%): 1006 (23.3%)
|
| 106 |
+
[2026-06-30 23:11:54] INFO [predict.py:210] --------------------------------------------------
|
| 107 |
+
[2026-06-30 23:11:54] INFO [predict.py:213] Total Revenue At Risk (High Risk): $108,704.41
|
| 108 |
+
[2026-06-30 23:11:54] INFO [predict.py:214] ==================================================
|
| 109 |
+
|
| 110 |
+
[2026-06-30 23:11:59] INFO [export_json.py:32] Reading prediction outputs for JSON export...
|
| 111 |
+
[2026-06-30 23:11:59] INFO [export_json.py:78] Saving summary statistics to E:\project\customer_segmentation_project\dashboard\public\data\summary.json...
|
| 112 |
+
[2026-06-30 23:11:59] INFO [export_json.py:82] Formatting individual customer records for frontend table...
|
| 113 |
+
[2026-06-30 23:12:00] INFO [export_json.py:107] Saving 4312 formatted records to E:\project\customer_segmentation_project\dashboard\public\data\customers.json...
|
| 114 |
+
[2026-06-30 23:12:00] INFO [export_json.py:111] JSON Data Export Completed successfully!
|
| 115 |
+
[2026-06-30 23:12:58] INFO [api.py:41] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 116 |
+
[2026-06-30 23:12:59] INFO [api.py:45] Successfully loaded churn XGBoost model.
|
| 117 |
+
[2026-06-30 23:12:59] INFO [api.py:86] Executing health check request...
|
| 118 |
+
[2026-06-30 23:12:59] INFO [api.py:41] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 119 |
+
[2026-06-30 23:12:59] INFO [api.py:45] Successfully loaded churn XGBoost model.
|
| 120 |
+
[2026-06-30 23:12:59] INFO [api.py:124] Running inference for customer profile: recency=10, freq=5
|
| 121 |
+
[2026-06-30 23:12:59] INFO [api.py:41] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 122 |
+
[2026-06-30 23:12:59] INFO [api.py:45] Successfully loaded churn XGBoost model.
|
| 123 |
+
[2026-06-30 23:12:59] INFO [api.py:206] Running batch inference for 2 profiles...
|
| 124 |
+
[2026-06-30 23:13:38] INFO [api.py:42] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 125 |
+
[2026-06-30 23:13:39] INFO [api.py:46] Successfully loaded churn XGBoost model.
|
| 126 |
+
[2026-06-30 23:13:39] INFO [api.py:58] Initialized inference history log at E:\project\customer_segmentation_project\logs\inference_history.csv
|
| 127 |
+
[2026-06-30 23:13:48] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 128 |
+
[2026-06-30 23:13:49] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 129 |
+
[2026-06-30 23:13:49] INFO [api.py:136] Running inference for customer profile: recency=65, freq=3
|
| 130 |
+
[2026-06-30 23:14:01] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 131 |
+
[2026-06-30 23:14:02] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 132 |
+
[2026-06-30 23:14:13] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 133 |
+
[2026-06-30 23:14:15] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 134 |
+
[2026-06-30 23:14:15] INFO [api.py:98] Executing health check request...
|
| 135 |
+
[2026-06-30 23:14:15] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 136 |
+
[2026-06-30 23:14:15] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 137 |
+
[2026-06-30 23:14:15] INFO [api.py:148] Running inference for customer profile: recency=10, freq=5
|
| 138 |
+
[2026-06-30 23:14:15] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 139 |
+
[2026-06-30 23:14:15] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 140 |
+
[2026-06-30 23:14:15] INFO [api.py:230] Running batch inference for 2 profiles...
|
| 141 |
+
[2026-06-30 23:14:15] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 142 |
+
[2026-06-30 23:14:15] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 143 |
+
[2026-06-30 23:14:21] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 144 |
+
[2026-06-30 23:14:22] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 145 |
+
[2026-06-30 23:15:12] INFO [api.py:43] Attempting to load churn model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 146 |
+
[2026-06-30 23:15:13] INFO [api.py:47] Successfully loaded churn XGBoost model.
|
| 147 |
+
[2026-06-30 23:15:52] INFO [train_challenger.py:25] Loading cleaned dataset from E:\project\customer_segmentation_project\data\processed\online_retail_clean.csv for Challenger training...
|
| 148 |
+
[2026-06-30 23:15:53] INFO [train_challenger.py:99] Initializing RandomForest challenger classifier...
|
| 149 |
+
[2026-06-30 23:15:53] INFO [train_challenger.py:107] Fitting Random Forest challenger on training set...
|
| 150 |
+
[2026-06-30 23:15:54] INFO [train_challenger.py:110] Evaluating Challenger model on hold-out validation:
|
| 151 |
+
[2026-06-30 23:15:54] INFO [train_challenger.py:113]
|
| 152 |
+
precision recall f1-score support
|
| 153 |
+
|
| 154 |
+
0 0.75 0.62 0.68 397
|
| 155 |
+
1 0.56 0.70 0.62 279
|
| 156 |
+
|
| 157 |
+
accuracy 0.65 676
|
| 158 |
+
macro avg 0.65 0.66 0.65 676
|
| 159 |
+
weighted avg 0.67 0.65 0.65 676
|
| 160 |
+
|
| 161 |
+
[2026-06-30 23:15:54] INFO [train_challenger.py:116] Saving serialized Random Forest challenger model to E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 162 |
+
[2026-06-30 23:15:54] INFO [train_challenger.py:118] Challenger RF model training complete!
|
| 163 |
+
[2026-06-30 23:16:14] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 164 |
+
[2026-06-30 23:16:15] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 165 |
+
[2026-06-30 23:16:15] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 166 |
+
[2026-06-30 23:16:15] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 167 |
+
[2026-06-30 23:16:15] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 168 |
+
[2026-06-30 23:16:26] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 169 |
+
[2026-06-30 23:16:28] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 170 |
+
[2026-06-30 23:16:28] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 171 |
+
[2026-06-30 23:16:28] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 172 |
+
[2026-06-30 23:16:28] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 173 |
+
[2026-06-30 23:16:35] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 174 |
+
[2026-06-30 23:16:36] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 175 |
+
[2026-06-30 23:16:36] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 176 |
+
[2026-06-30 23:16:37] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 177 |
+
[2026-06-30 23:16:37] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 178 |
+
[2026-06-30 23:16:44] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 179 |
+
[2026-06-30 23:16:45] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 180 |
+
[2026-06-30 23:16:45] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 181 |
+
[2026-06-30 23:16:45] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 182 |
+
[2026-06-30 23:16:45] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 183 |
+
[2026-06-30 23:16:47] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 184 |
+
[2026-06-30 23:16:47] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 185 |
+
[2026-06-30 23:16:47] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 186 |
+
[2026-06-30 23:16:48] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 187 |
+
[2026-06-30 23:16:48] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 188 |
+
[2026-06-30 23:16:48] INFO [api.py:139] Executing health check request...
|
| 189 |
+
[2026-06-30 23:16:48] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 190 |
+
[2026-06-30 23:16:48] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 191 |
+
[2026-06-30 23:16:48] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 192 |
+
[2026-06-30 23:16:48] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 193 |
+
[2026-06-30 23:16:48] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 194 |
+
[2026-06-30 23:16:48] INFO [api.py:204] Running inference for customer profile: recency=10, freq=5
|
| 195 |
+
[2026-06-30 23:16:48] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 196 |
+
[2026-06-30 23:16:48] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 197 |
+
[2026-06-30 23:16:48] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 198 |
+
[2026-06-30 23:16:48] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 199 |
+
[2026-06-30 23:16:48] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 200 |
+
[2026-06-30 23:16:48] INFO [api.py:300] Running batch inference for 2 profiles...
|
| 201 |
+
[2026-06-30 23:16:48] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 202 |
+
[2026-06-30 23:16:48] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 203 |
+
[2026-06-30 23:16:48] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 204 |
+
[2026-06-30 23:16:48] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 205 |
+
[2026-06-30 23:16:48] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 206 |
+
[2026-06-30 23:16:48] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 207 |
+
[2026-06-30 23:16:48] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 208 |
+
[2026-06-30 23:16:48] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 209 |
+
[2026-06-30 23:16:48] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 210 |
+
[2026-06-30 23:16:48] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 211 |
+
[2026-06-30 23:17:04] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 212 |
+
[2026-06-30 23:17:05] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 213 |
+
[2026-06-30 23:17:05] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 214 |
+
[2026-06-30 23:17:05] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 215 |
+
[2026-06-30 23:17:05] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 216 |
+
[2026-06-30 23:17:05] INFO [api.py:139] Executing health check request...
|
| 217 |
+
[2026-06-30 23:17:05] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 218 |
+
[2026-06-30 23:17:05] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 219 |
+
[2026-06-30 23:17:05] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 220 |
+
[2026-06-30 23:17:05] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 221 |
+
[2026-06-30 23:17:05] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 222 |
+
[2026-06-30 23:17:05] INFO [api.py:204] Running inference for customer profile: recency=10, freq=5
|
| 223 |
+
[2026-06-30 23:17:05] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 224 |
+
[2026-06-30 23:17:05] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 225 |
+
[2026-06-30 23:17:05] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 226 |
+
[2026-06-30 23:17:06] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 227 |
+
[2026-06-30 23:17:06] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 228 |
+
[2026-06-30 23:17:06] INFO [api.py:305] Running batch inference for 2 profiles...
|
| 229 |
+
[2026-06-30 23:17:06] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 230 |
+
[2026-06-30 23:17:06] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 231 |
+
[2026-06-30 23:17:06] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 232 |
+
[2026-06-30 23:17:06] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 233 |
+
[2026-06-30 23:17:06] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 234 |
+
[2026-06-30 23:17:06] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 235 |
+
[2026-06-30 23:17:06] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 236 |
+
[2026-06-30 23:17:06] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 237 |
+
[2026-06-30 23:17:06] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 238 |
+
[2026-06-30 23:17:06] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 239 |
+
[2026-06-30 23:17:08] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 240 |
+
[2026-06-30 23:17:09] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 241 |
+
[2026-06-30 23:17:09] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 242 |
+
[2026-06-30 23:17:09] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 243 |
+
[2026-06-30 23:17:09] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 244 |
+
[2026-06-30 23:18:07] INFO [api.py:50] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 245 |
+
[2026-06-30 23:18:09] INFO [api.py:54] Successfully loaded champion XGBoost model.
|
| 246 |
+
[2026-06-30 23:18:09] INFO [api.py:61] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 247 |
+
[2026-06-30 23:18:09] INFO [api.py:65] Successfully loaded challenger Random Forest model.
|
| 248 |
+
[2026-06-30 23:18:09] INFO [api.py:100] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 249 |
+
[2026-06-30 23:18:56] INFO [api.py:51] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 250 |
+
[2026-06-30 23:18:57] INFO [api.py:55] Successfully loaded champion XGBoost model.
|
| 251 |
+
[2026-06-30 23:18:57] INFO [api.py:62] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 252 |
+
[2026-06-30 23:18:58] INFO [api.py:66] Successfully loaded challenger Random Forest model.
|
| 253 |
+
[2026-06-30 23:18:58] INFO [api.py:101] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 254 |
+
[2026-06-30 23:19:09] INFO [api.py:52] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 255 |
+
[2026-06-30 23:19:11] INFO [api.py:56] Successfully loaded champion XGBoost model.
|
| 256 |
+
[2026-06-30 23:19:11] INFO [api.py:63] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 257 |
+
[2026-06-30 23:19:12] INFO [api.py:67] Successfully loaded challenger Random Forest model.
|
| 258 |
+
[2026-06-30 23:19:12] INFO [api.py:102] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 259 |
+
[2026-06-30 23:19:22] INFO [api.py:52] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 260 |
+
[2026-06-30 23:19:24] INFO [api.py:56] Successfully loaded champion XGBoost model.
|
| 261 |
+
[2026-06-30 23:19:24] INFO [api.py:63] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 262 |
+
[2026-06-30 23:19:24] INFO [api.py:67] Successfully loaded challenger Random Forest model.
|
| 263 |
+
[2026-06-30 23:19:24] INFO [api.py:102] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 264 |
+
[2026-06-30 23:19:24] INFO [api.py:114] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 265 |
+
[2026-06-30 23:19:36] INFO [api.py:52] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 266 |
+
[2026-06-30 23:19:38] INFO [api.py:56] Successfully loaded champion XGBoost model.
|
| 267 |
+
[2026-06-30 23:19:38] INFO [api.py:63] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 268 |
+
[2026-06-30 23:19:38] INFO [api.py:67] Successfully loaded challenger Random Forest model.
|
| 269 |
+
[2026-06-30 23:19:38] INFO [api.py:102] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 270 |
+
[2026-06-30 23:19:38] INFO [api.py:114] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 271 |
+
[2026-06-30 23:21:38] INFO [validate_retraining.py:28] Initializing model performance validation check...
|
| 272 |
+
[2026-06-30 23:21:41] INFO [validate_retraining.py:117] Retrained Model validation result: Recall = 81.36%
|
| 273 |
+
[2026-06-30 23:21:41] INFO [validate_retraining.py:118] Target Performance Threshold: Recall >= 80.00%
|
| 274 |
+
[2026-06-30 23:21:41] INFO [validate_retraining.py:119]
|
| 275 |
+
precision recall f1-score support
|
| 276 |
+
|
| 277 |
+
0 0.78 0.47 0.59 397
|
| 278 |
+
1 0.52 0.81 0.63 279
|
| 279 |
+
|
| 280 |
+
accuracy 0.61 676
|
| 281 |
+
macro avg 0.65 0.64 0.61 676
|
| 282 |
+
weighted avg 0.67 0.61 0.61 676
|
| 283 |
+
|
| 284 |
+
[2026-06-30 23:21:41] INFO [validate_retraining.py:122] Validation PASSED! Model is eligible for release.
|
| 285 |
+
[2026-06-30 23:22:17] INFO [api.py:52] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 286 |
+
[2026-06-30 23:22:17] INFO [api.py:56] Successfully loaded champion XGBoost model.
|
| 287 |
+
[2026-06-30 23:22:17] INFO [api.py:63] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 288 |
+
[2026-06-30 23:22:19] INFO [api.py:67] Successfully loaded challenger Random Forest model.
|
| 289 |
+
[2026-06-30 23:22:19] INFO [api.py:102] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 290 |
+
[2026-06-30 23:22:20] INFO [api.py:114] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 291 |
+
[2026-06-30 23:25:03] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 292 |
+
[2026-06-30 23:25:05] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 293 |
+
[2026-06-30 23:25:05] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 294 |
+
[2026-06-30 23:25:05] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 295 |
+
[2026-06-30 23:25:05] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 296 |
+
[2026-06-30 23:25:05] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 297 |
+
[2026-06-30 23:25:05] INFO [api.py:164] Executing health check request...
|
| 298 |
+
[2026-06-30 23:25:05] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 299 |
+
[2026-06-30 23:25:05] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 300 |
+
[2026-06-30 23:25:05] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 301 |
+
[2026-06-30 23:25:05] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 302 |
+
[2026-06-30 23:25:05] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 303 |
+
[2026-06-30 23:25:05] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 304 |
+
[2026-06-30 23:25:05] INFO [api.py:229] Running inference for customer profile: recency=10, freq=5
|
| 305 |
+
[2026-06-30 23:25:05] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 306 |
+
[2026-06-30 23:25:05] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 307 |
+
[2026-06-30 23:25:05] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 308 |
+
[2026-06-30 23:25:05] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 309 |
+
[2026-06-30 23:25:05] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 310 |
+
[2026-06-30 23:25:05] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 311 |
+
[2026-06-30 23:25:05] INFO [api.py:330] Running batch inference for 2 profiles...
|
| 312 |
+
[2026-06-30 23:25:05] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 313 |
+
[2026-06-30 23:25:05] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 314 |
+
[2026-06-30 23:25:05] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 315 |
+
[2026-06-30 23:25:05] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 316 |
+
[2026-06-30 23:25:05] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 317 |
+
[2026-06-30 23:25:05] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 318 |
+
[2026-06-30 23:25:05] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 319 |
+
[2026-06-30 23:25:05] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 320 |
+
[2026-06-30 23:25:05] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 321 |
+
[2026-06-30 23:25:05] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 322 |
+
[2026-06-30 23:25:05] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 323 |
+
[2026-06-30 23:25:05] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 324 |
+
[2026-06-30 23:25:13] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 325 |
+
[2026-06-30 23:25:14] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 326 |
+
[2026-06-30 23:25:14] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 327 |
+
[2026-06-30 23:25:15] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 328 |
+
[2026-06-30 23:25:15] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 329 |
+
[2026-06-30 23:25:15] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 330 |
+
[2026-06-30 23:27:54] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 331 |
+
[2026-06-30 23:27:55] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 332 |
+
[2026-06-30 23:27:55] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 333 |
+
[2026-06-30 23:27:55] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 334 |
+
[2026-06-30 23:27:55] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 335 |
+
[2026-06-30 23:27:55] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 336 |
+
[2026-06-30 23:31:40] INFO [api.py:229] Running inference for customer profile: recency=5, freq=15
|
| 337 |
+
[2026-06-30 23:33:49] INFO [api.py:229] Running inference for customer profile: recency=4, freq=22
|
| 338 |
+
[2026-06-30 23:34:32] INFO [api.py:229] Running inference for customer profile: recency=240, freq=18
|
| 339 |
+
[2026-06-30 23:35:32] INFO [api.py:229] Running inference for customer profile: recency=65, freq=12
|
| 340 |
+
[2026-06-30 23:36:23] INFO [api.py:229] Running inference for customer profile: recency=2, freq=5
|
| 341 |
+
[2026-06-30 23:37:05] INFO [api.py:229] Running inference for customer profile: recency=12, freq=6
|
| 342 |
+
[2026-06-30 23:39:06] INFO [api.py:229] Running inference for customer profile: recency=320, freq=1
|
| 343 |
+
[2026-06-30 23:39:54] INFO [api.py:229] Running inference for customer profile: recency=8, freq=4
|
| 344 |
+
[2026-06-30 23:40:48] INFO [api.py:229] Running inference for customer profile: recency=110, freq=3
|
| 345 |
+
[2026-06-30 23:41:40] INFO [api.py:229] Running inference for customer profile: recency=18, freq=8
|
| 346 |
+
[2026-06-30 23:42:17] INFO [api.py:229] Running inference for customer profile: recency=45, freq=14
|
| 347 |
+
[2026-06-30 23:42:37] WARNING [api.py:427] Feature columns not found: Recency in base or Recency in prod.
|
| 348 |
+
[2026-06-30 23:42:37] WARNING [api.py:427] Feature columns not found: Frequency in base or Frequency in prod.
|
| 349 |
+
[2026-06-30 23:42:37] WARNING [api.py:427] Feature columns not found: Monetary in base or Monetary in prod.
|
| 350 |
+
[2026-06-30 23:42:37] WARNING [api.py:427] Feature columns not found: AvgBucketSize in base or BasketSize in prod.
|
| 351 |
+
[2026-06-30 23:45:23] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 352 |
+
[2026-06-30 23:45:25] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 353 |
+
[2026-06-30 23:45:25] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 354 |
+
[2026-06-30 23:45:26] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 355 |
+
[2026-06-30 23:45:26] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 356 |
+
[2026-06-30 23:45:26] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 357 |
+
[2026-06-30 23:48:00] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 358 |
+
[2026-06-30 23:48:01] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 359 |
+
[2026-06-30 23:48:01] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 360 |
+
[2026-06-30 23:48:01] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 361 |
+
[2026-06-30 23:48:01] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 362 |
+
[2026-06-30 23:48:01] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 363 |
+
[2026-06-30 23:49:12] WARNING [api.py:427] Feature columns not found: Recency in base or Recency in prod.
|
| 364 |
+
[2026-06-30 23:49:12] WARNING [api.py:427] Feature columns not found: Frequency in base or Frequency in prod.
|
| 365 |
+
[2026-06-30 23:49:12] WARNING [api.py:427] Feature columns not found: Monetary in base or Monetary in prod.
|
| 366 |
+
[2026-06-30 23:49:12] WARNING [api.py:427] Feature columns not found: AvgBucketSize in base or BasketSize in prod.
|
| 367 |
+
[2026-06-30 23:51:51] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 368 |
+
[2026-06-30 23:51:52] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 369 |
+
[2026-06-30 23:51:52] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 370 |
+
[2026-06-30 23:51:53] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 371 |
+
[2026-06-30 23:51:53] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 372 |
+
[2026-06-30 23:51:53] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 373 |
+
[2026-06-30 23:53:10] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 374 |
+
[2026-06-30 23:53:11] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 375 |
+
[2026-06-30 23:53:11] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 376 |
+
[2026-06-30 23:53:11] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 377 |
+
[2026-06-30 23:53:11] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 378 |
+
[2026-06-30 23:53:12] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 379 |
+
[2026-06-30 23:53:35] INFO [api.py:61] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 380 |
+
[2026-06-30 23:53:36] INFO [api.py:65] Successfully loaded champion XGBoost model.
|
| 381 |
+
[2026-06-30 23:53:36] INFO [api.py:72] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 382 |
+
[2026-06-30 23:53:37] INFO [api.py:76] Successfully loaded challenger Random Forest model.
|
| 383 |
+
[2026-06-30 23:53:37] INFO [api.py:111] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 384 |
+
[2026-06-30 23:53:37] INFO [api.py:123] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 385 |
+
[2026-07-01 09:00:21] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 386 |
+
[2026-07-01 09:00:21] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 387 |
+
[2026-07-01 09:00:21] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 388 |
+
[2026-07-01 09:00:24] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 389 |
+
[2026-07-01 09:00:24] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 390 |
+
[2026-07-01 09:00:26] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 391 |
+
[2026-07-01 09:00:26] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 392 |
+
[2026-07-01 09:00:26] INFO [routes.py:26] Executing health check request...
|
| 393 |
+
[2026-07-01 09:00:26] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 394 |
+
[2026-07-01 09:00:26] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 395 |
+
[2026-07-01 09:00:26] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 396 |
+
[2026-07-01 09:00:26] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 397 |
+
[2026-07-01 09:00:27] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 398 |
+
[2026-07-01 09:00:27] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 399 |
+
[2026-07-01 09:00:27] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 400 |
+
[2026-07-01 09:00:27] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 401 |
+
[2026-07-01 09:00:27] ERROR [routes.py:39] Inference requested but model is not loaded.
|
| 402 |
+
[2026-07-01 09:00:27] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 403 |
+
[2026-07-01 09:00:28] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 404 |
+
[2026-07-01 09:00:28] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 405 |
+
[2026-07-01 09:00:28] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 406 |
+
[2026-07-01 09:00:28] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 407 |
+
[2026-07-01 09:00:28] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 408 |
+
[2026-07-01 09:00:28] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 409 |
+
[2026-07-01 09:00:28] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 410 |
+
[2026-07-01 09:00:28] ERROR [routes.py:90] Inference batch requested but model is not loaded.
|
| 411 |
+
[2026-07-01 09:00:28] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 412 |
+
[2026-07-01 09:00:29] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 413 |
+
[2026-07-01 09:00:29] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 414 |
+
[2026-07-01 09:00:29] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 415 |
+
[2026-07-01 09:00:29] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 416 |
+
[2026-07-01 09:00:29] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 417 |
+
[2026-07-01 09:00:29] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 418 |
+
[2026-07-01 09:00:29] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 419 |
+
[2026-07-01 09:00:31] WARNING [drift_service.py:69] Feature columns not found: Recency in base or Recency in prod.
|
| 420 |
+
[2026-07-01 09:00:31] WARNING [drift_service.py:69] Feature columns not found: Frequency in base or Frequency in prod.
|
| 421 |
+
[2026-07-01 09:00:31] WARNING [drift_service.py:69] Feature columns not found: Monetary in base or Monetary in prod.
|
| 422 |
+
[2026-07-01 09:00:31] WARNING [drift_service.py:69] Feature columns not found: AvgBucketSize in base or BasketSize in prod.
|
| 423 |
+
[2026-07-01 09:00:31] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 424 |
+
[2026-07-01 09:00:31] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 425 |
+
[2026-07-01 09:00:31] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 426 |
+
[2026-07-01 09:00:31] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 427 |
+
[2026-07-01 09:00:31] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 428 |
+
[2026-07-01 09:00:31] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 429 |
+
[2026-07-01 09:00:31] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 430 |
+
[2026-07-01 09:00:31] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 431 |
+
[2026-07-01 09:00:31] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 432 |
+
[2026-07-01 09:00:41] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 433 |
+
[2026-07-01 09:00:41] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 434 |
+
[2026-07-01 09:00:41] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 435 |
+
[2026-07-01 09:00:42] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 436 |
+
[2026-07-01 09:00:42] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 437 |
+
[2026-07-01 09:00:43] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 438 |
+
[2026-07-01 09:00:43] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 439 |
+
[2026-07-01 09:02:08] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 440 |
+
[2026-07-01 09:05:15] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 441 |
+
[2026-07-01 09:05:15] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 442 |
+
[2026-07-01 09:05:15] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 443 |
+
[2026-07-01 09:05:17] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 444 |
+
[2026-07-01 09:05:17] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 445 |
+
[2026-07-01 09:05:19] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 446 |
+
[2026-07-01 09:05:19] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 447 |
+
[2026-07-01 09:05:19] INFO [routes.py:19] Executing health check request...
|
| 448 |
+
[2026-07-01 09:05:19] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 449 |
+
[2026-07-01 09:05:19] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 450 |
+
[2026-07-01 09:05:19] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 451 |
+
[2026-07-01 09:05:19] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 452 |
+
[2026-07-01 09:05:19] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 453 |
+
[2026-07-01 09:05:19] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 454 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 455 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 456 |
+
[2026-07-01 09:05:20] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 457 |
+
[2026-07-01 09:05:20] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 458 |
+
[2026-07-01 09:05:20] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 459 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 460 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 461 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 462 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 463 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 464 |
+
[2026-07-01 09:05:20] INFO [routes.py:97] Running batch inference for 2 profiles...
|
| 465 |
+
[2026-07-01 09:05:20] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 466 |
+
[2026-07-01 09:05:20] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 467 |
+
[2026-07-01 09:05:20] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 468 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 469 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 470 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 471 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 472 |
+
[2026-07-01 09:05:20] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 473 |
+
[2026-07-01 09:05:21] WARNING [drift_service.py:69] Feature columns not found: Recency in base or Recency in prod.
|
| 474 |
+
[2026-07-01 09:05:21] WARNING [drift_service.py:69] Feature columns not found: Frequency in base or Frequency in prod.
|
| 475 |
+
[2026-07-01 09:05:21] WARNING [drift_service.py:69] Feature columns not found: Monetary in base or Monetary in prod.
|
| 476 |
+
[2026-07-01 09:05:21] WARNING [drift_service.py:69] Feature columns not found: AvgBucketSize in base or BasketSize in prod.
|
| 477 |
+
[2026-07-01 09:05:21] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
| 478 |
+
[2026-07-01 09:05:21] INFO [main.py:13] Starting up Customer Churn Prediction API...
|
| 479 |
+
[2026-07-01 09:05:21] INFO [database.py:37] Successfully initialized shadow prediction database at E:\project\customer_segmentation_project\logs\predictions.db
|
| 480 |
+
[2026-07-01 09:05:21] INFO [ml_services.py:21] Attempting to load champion model from E:\project\customer_segmentation_project\models\churn_xgb_model.pkl...
|
| 481 |
+
[2026-07-01 09:05:21] INFO [ml_services.py:25] Successfully loaded champion XGBoost model.
|
| 482 |
+
[2026-07-01 09:05:21] INFO [ml_services.py:32] Attempting to load challenger model from E:\project\customer_segmentation_project\models\churn_rf_model.pkl...
|
| 483 |
+
[2026-07-01 09:05:21] INFO [ml_services.py:36] Successfully loaded challenger Random Forest model.
|
| 484 |
+
[2026-07-01 09:05:21] INFO [ml_services.py:49] Successfully loaded 4312 customer records into memory for WebSocket streaming.
|
| 485 |
+
[2026-07-01 09:05:21] INFO [main.py:18] Shutting down Customer Churn Prediction API...
|
logs/inference_history.csv
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Timestamp,Recency,Frequency,Monetary,BasketSize
|
| 2 |
+
2026-06-30 23:14:15,10,5,250.0,8.0
|
| 3 |
+
2026-06-30 23:16:48,10,5,250.0,8.0
|
| 4 |
+
2026-06-30 23:17:05,10,5,250.0,8.0
|
| 5 |
+
2026-06-30 23:25:05,10,5,250.0,8.0
|
| 6 |
+
2026-06-30 23:31:40,5,15,650.0,8.5
|
| 7 |
+
2026-06-30 23:33:49,4,22,580.0,15.0
|
| 8 |
+
2026-06-30 23:34:32,240,18,450.0,12.0
|
| 9 |
+
2026-06-30 23:35:32,65,12,380.0,10.5
|
| 10 |
+
2026-06-30 23:36:23,2,5,180.0,6.0
|
| 11 |
+
2026-06-30 23:37:05,12,6,850.0,45.0
|
| 12 |
+
2026-06-30 23:39:06,320,1,15.5,1.0
|
| 13 |
+
2026-06-30 23:39:54,8,4,95.0,4.5
|
| 14 |
+
2026-06-30 23:40:48,110,3,45.0,3.0
|
| 15 |
+
2026-06-30 23:41:40,18,8,120.0,5.0
|
| 16 |
+
2026-06-30 23:42:17,45,14,25.0,2.0
|
| 17 |
+
2026-07-01 09:00:27,10,5,250.0,8.0
|
| 18 |
+
2026-07-01 09:05:20,10,5,250.0,8.0
|
logs/predictions.db
ADDED
|
Binary file (12.3 kB). View file
|
|
|
notebooks/01_Data_Loading.ipynb
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 5,
|
| 6 |
+
"id": "18eacdbd",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [],
|
| 9 |
+
"source": [
|
| 10 |
+
"import pandas as pd\n",
|
| 11 |
+
"import os\n"
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"cell_type": "code",
|
| 16 |
+
"execution_count": 6,
|
| 17 |
+
"id": "1429805a",
|
| 18 |
+
"metadata": {},
|
| 19 |
+
"outputs": [],
|
| 20 |
+
"source": [
|
| 21 |
+
"file_path = '../data/raw/online_retail_II.xlsx'\n"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"cell_type": "code",
|
| 26 |
+
"execution_count": null,
|
| 27 |
+
"id": "907ab9ac",
|
| 28 |
+
"metadata": {},
|
| 29 |
+
"outputs": [
|
| 30 |
+
{
|
| 31 |
+
"name": "stdout",
|
| 32 |
+
"output_type": "stream",
|
| 33 |
+
"text": [
|
| 34 |
+
"Loading data...\n"
|
| 35 |
+
]
|
| 36 |
+
}
|
| 37 |
+
],
|
| 38 |
+
"source": [
|
| 39 |
+
"df = pd.read_excel(file_path, sheet_name='Year 2009-2010')"
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"cell_type": "markdown",
|
| 44 |
+
"id": "0386fe66",
|
| 45 |
+
"metadata": {},
|
| 46 |
+
"source": [
|
| 47 |
+
"# Basic Sanity Check\n"
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"cell_type": "code",
|
| 52 |
+
"execution_count": 8,
|
| 53 |
+
"id": "0c568d7f",
|
| 54 |
+
"metadata": {},
|
| 55 |
+
"outputs": [
|
| 56 |
+
{
|
| 57 |
+
"name": "stdout",
|
| 58 |
+
"output_type": "stream",
|
| 59 |
+
"text": [
|
| 60 |
+
"Data loaded successfully!\n",
|
| 61 |
+
"Total Rows: 525461\n",
|
| 62 |
+
"Total Columns: 8\n"
|
| 63 |
+
]
|
| 64 |
+
}
|
| 65 |
+
],
|
| 66 |
+
"source": [
|
| 67 |
+
"print(\"Data loaded successfully!\")\n",
|
| 68 |
+
"print(f\"Total Rows: {df.shape[0]}\")\n",
|
| 69 |
+
"print(f\"Total Columns: {df.shape[1]}\")\n",
|
| 70 |
+
"\n"
|
| 71 |
+
]
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"cell_type": "markdown",
|
| 75 |
+
"id": "be9a7f30",
|
| 76 |
+
"metadata": {},
|
| 77 |
+
"source": [
|
| 78 |
+
"# Preview data"
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"cell_type": "code",
|
| 83 |
+
"execution_count": 9,
|
| 84 |
+
"id": "b8a911f7",
|
| 85 |
+
"metadata": {},
|
| 86 |
+
"outputs": [
|
| 87 |
+
{
|
| 88 |
+
"data": {
|
| 89 |
+
"text/html": [
|
| 90 |
+
"<div>\n",
|
| 91 |
+
"<style scoped>\n",
|
| 92 |
+
" .dataframe tbody tr th:only-of-type {\n",
|
| 93 |
+
" vertical-align: middle;\n",
|
| 94 |
+
" }\n",
|
| 95 |
+
"\n",
|
| 96 |
+
" .dataframe tbody tr th {\n",
|
| 97 |
+
" vertical-align: top;\n",
|
| 98 |
+
" }\n",
|
| 99 |
+
"\n",
|
| 100 |
+
" .dataframe thead th {\n",
|
| 101 |
+
" text-align: right;\n",
|
| 102 |
+
" }\n",
|
| 103 |
+
"</style>\n",
|
| 104 |
+
"<table border=\"1\" class=\"dataframe\">\n",
|
| 105 |
+
" <thead>\n",
|
| 106 |
+
" <tr style=\"text-align: right;\">\n",
|
| 107 |
+
" <th></th>\n",
|
| 108 |
+
" <th>Invoice</th>\n",
|
| 109 |
+
" <th>StockCode</th>\n",
|
| 110 |
+
" <th>Description</th>\n",
|
| 111 |
+
" <th>Quantity</th>\n",
|
| 112 |
+
" <th>InvoiceDate</th>\n",
|
| 113 |
+
" <th>Price</th>\n",
|
| 114 |
+
" <th>Customer ID</th>\n",
|
| 115 |
+
" <th>Country</th>\n",
|
| 116 |
+
" </tr>\n",
|
| 117 |
+
" </thead>\n",
|
| 118 |
+
" <tbody>\n",
|
| 119 |
+
" <tr>\n",
|
| 120 |
+
" <th>0</th>\n",
|
| 121 |
+
" <td>489434</td>\n",
|
| 122 |
+
" <td>85048</td>\n",
|
| 123 |
+
" <td>15CM CHRISTMAS GLASS BALL 20 LIGHTS</td>\n",
|
| 124 |
+
" <td>12</td>\n",
|
| 125 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 126 |
+
" <td>6.95</td>\n",
|
| 127 |
+
" <td>13085.0</td>\n",
|
| 128 |
+
" <td>United Kingdom</td>\n",
|
| 129 |
+
" </tr>\n",
|
| 130 |
+
" <tr>\n",
|
| 131 |
+
" <th>1</th>\n",
|
| 132 |
+
" <td>489434</td>\n",
|
| 133 |
+
" <td>79323P</td>\n",
|
| 134 |
+
" <td>PINK CHERRY LIGHTS</td>\n",
|
| 135 |
+
" <td>12</td>\n",
|
| 136 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 137 |
+
" <td>6.75</td>\n",
|
| 138 |
+
" <td>13085.0</td>\n",
|
| 139 |
+
" <td>United Kingdom</td>\n",
|
| 140 |
+
" </tr>\n",
|
| 141 |
+
" <tr>\n",
|
| 142 |
+
" <th>2</th>\n",
|
| 143 |
+
" <td>489434</td>\n",
|
| 144 |
+
" <td>79323W</td>\n",
|
| 145 |
+
" <td>WHITE CHERRY LIGHTS</td>\n",
|
| 146 |
+
" <td>12</td>\n",
|
| 147 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 148 |
+
" <td>6.75</td>\n",
|
| 149 |
+
" <td>13085.0</td>\n",
|
| 150 |
+
" <td>United Kingdom</td>\n",
|
| 151 |
+
" </tr>\n",
|
| 152 |
+
" <tr>\n",
|
| 153 |
+
" <th>3</th>\n",
|
| 154 |
+
" <td>489434</td>\n",
|
| 155 |
+
" <td>22041</td>\n",
|
| 156 |
+
" <td>RECORD FRAME 7\" SINGLE SIZE</td>\n",
|
| 157 |
+
" <td>48</td>\n",
|
| 158 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 159 |
+
" <td>2.10</td>\n",
|
| 160 |
+
" <td>13085.0</td>\n",
|
| 161 |
+
" <td>United Kingdom</td>\n",
|
| 162 |
+
" </tr>\n",
|
| 163 |
+
" <tr>\n",
|
| 164 |
+
" <th>4</th>\n",
|
| 165 |
+
" <td>489434</td>\n",
|
| 166 |
+
" <td>21232</td>\n",
|
| 167 |
+
" <td>STRAWBERRY CERAMIC TRINKET BOX</td>\n",
|
| 168 |
+
" <td>24</td>\n",
|
| 169 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 170 |
+
" <td>1.25</td>\n",
|
| 171 |
+
" <td>13085.0</td>\n",
|
| 172 |
+
" <td>United Kingdom</td>\n",
|
| 173 |
+
" </tr>\n",
|
| 174 |
+
" </tbody>\n",
|
| 175 |
+
"</table>\n",
|
| 176 |
+
"</div>"
|
| 177 |
+
],
|
| 178 |
+
"text/plain": [
|
| 179 |
+
" Invoice StockCode Description Quantity \\\n",
|
| 180 |
+
"0 489434 85048 15CM CHRISTMAS GLASS BALL 20 LIGHTS 12 \n",
|
| 181 |
+
"1 489434 79323P PINK CHERRY LIGHTS 12 \n",
|
| 182 |
+
"2 489434 79323W WHITE CHERRY LIGHTS 12 \n",
|
| 183 |
+
"3 489434 22041 RECORD FRAME 7\" SINGLE SIZE 48 \n",
|
| 184 |
+
"4 489434 21232 STRAWBERRY CERAMIC TRINKET BOX 24 \n",
|
| 185 |
+
"\n",
|
| 186 |
+
" InvoiceDate Price Customer ID Country \n",
|
| 187 |
+
"0 2009-12-01 07:45:00 6.95 13085.0 United Kingdom \n",
|
| 188 |
+
"1 2009-12-01 07:45:00 6.75 13085.0 United Kingdom \n",
|
| 189 |
+
"2 2009-12-01 07:45:00 6.75 13085.0 United Kingdom \n",
|
| 190 |
+
"3 2009-12-01 07:45:00 2.10 13085.0 United Kingdom \n",
|
| 191 |
+
"4 2009-12-01 07:45:00 1.25 13085.0 United Kingdom "
|
| 192 |
+
]
|
| 193 |
+
},
|
| 194 |
+
"metadata": {},
|
| 195 |
+
"output_type": "display_data"
|
| 196 |
+
}
|
| 197 |
+
],
|
| 198 |
+
"source": [
|
| 199 |
+
"\n",
|
| 200 |
+
"display(df.head())"
|
| 201 |
+
]
|
| 202 |
+
}
|
| 203 |
+
],
|
| 204 |
+
"metadata": {
|
| 205 |
+
"kernelspec": {
|
| 206 |
+
"display_name": "base",
|
| 207 |
+
"language": "python",
|
| 208 |
+
"name": "python3"
|
| 209 |
+
},
|
| 210 |
+
"language_info": {
|
| 211 |
+
"codemirror_mode": {
|
| 212 |
+
"name": "ipython",
|
| 213 |
+
"version": 3
|
| 214 |
+
},
|
| 215 |
+
"file_extension": ".py",
|
| 216 |
+
"mimetype": "text/x-python",
|
| 217 |
+
"name": "python",
|
| 218 |
+
"nbconvert_exporter": "python",
|
| 219 |
+
"pygments_lexer": "ipython3",
|
| 220 |
+
"version": "3.11.5"
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
"nbformat": 4,
|
| 224 |
+
"nbformat_minor": 5
|
| 225 |
+
}
|
notebooks/01_EDA_and_Cleaning.ipynb
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 20,
|
| 6 |
+
"id": "74cd0921",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [],
|
| 9 |
+
"source": [
|
| 10 |
+
"import pandas as pd\n",
|
| 11 |
+
"import os\n",
|
| 12 |
+
"\n"
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"cell_type": "markdown",
|
| 17 |
+
"id": "459667d1",
|
| 18 |
+
"metadata": {},
|
| 19 |
+
"source": [
|
| 20 |
+
"# Define file path (Professional relative path, works on any computer)\n"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"cell_type": "code",
|
| 25 |
+
"execution_count": 21,
|
| 26 |
+
"id": "04746c48",
|
| 27 |
+
"metadata": {},
|
| 28 |
+
"outputs": [],
|
| 29 |
+
"source": [
|
| 30 |
+
"file_path = '../data/raw/online_retail_II.xlsx'\n"
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"cell_type": "markdown",
|
| 35 |
+
"id": "23b1f2e1",
|
| 36 |
+
"metadata": {},
|
| 37 |
+
"source": [
|
| 38 |
+
"\n",
|
| 39 |
+
"# Load the dataset (Sheet 1 - 2009-2010 data)\n"
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"cell_type": "code",
|
| 44 |
+
"execution_count": 22,
|
| 45 |
+
"id": "ae699594",
|
| 46 |
+
"metadata": {},
|
| 47 |
+
"outputs": [
|
| 48 |
+
{
|
| 49 |
+
"name": "stdout",
|
| 50 |
+
"output_type": "stream",
|
| 51 |
+
"text": [
|
| 52 |
+
"Loading data...\n"
|
| 53 |
+
]
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"source": [
|
| 57 |
+
"\n",
|
| 58 |
+
"print(\"Loading data...\")\n",
|
| 59 |
+
"df = pd.read_excel(file_path, sheet_name='Year 2009-2010')\n",
|
| 60 |
+
"\n"
|
| 61 |
+
]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"cell_type": "markdown",
|
| 65 |
+
"id": "0973e9e8",
|
| 66 |
+
"metadata": {},
|
| 67 |
+
"source": [
|
| 68 |
+
"# Basic Sanity Check"
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"cell_type": "code",
|
| 73 |
+
"execution_count": 23,
|
| 74 |
+
"id": "e095a2ca",
|
| 75 |
+
"metadata": {},
|
| 76 |
+
"outputs": [
|
| 77 |
+
{
|
| 78 |
+
"name": "stdout",
|
| 79 |
+
"output_type": "stream",
|
| 80 |
+
"text": [
|
| 81 |
+
"Data loaded successfully!\n",
|
| 82 |
+
"Total Rows: 525461\n",
|
| 83 |
+
"Total Columns: 8\n"
|
| 84 |
+
]
|
| 85 |
+
}
|
| 86 |
+
],
|
| 87 |
+
"source": [
|
| 88 |
+
"\n",
|
| 89 |
+
"print(\"Data loaded successfully!\")\n",
|
| 90 |
+
"print(f\"Total Rows: {df.shape[0]}\")\n",
|
| 91 |
+
"print(f\"Total Columns: {df.shape[1]}\")\n",
|
| 92 |
+
"\n"
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"cell_type": "markdown",
|
| 97 |
+
"id": "582961c9",
|
| 98 |
+
"metadata": {},
|
| 99 |
+
"source": [
|
| 100 |
+
"# Preview data\n"
|
| 101 |
+
]
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"cell_type": "code",
|
| 105 |
+
"execution_count": 24,
|
| 106 |
+
"id": "6242e17f",
|
| 107 |
+
"metadata": {},
|
| 108 |
+
"outputs": [
|
| 109 |
+
{
|
| 110 |
+
"data": {
|
| 111 |
+
"text/html": [
|
| 112 |
+
"<div>\n",
|
| 113 |
+
"<style scoped>\n",
|
| 114 |
+
" .dataframe tbody tr th:only-of-type {\n",
|
| 115 |
+
" vertical-align: middle;\n",
|
| 116 |
+
" }\n",
|
| 117 |
+
"\n",
|
| 118 |
+
" .dataframe tbody tr th {\n",
|
| 119 |
+
" vertical-align: top;\n",
|
| 120 |
+
" }\n",
|
| 121 |
+
"\n",
|
| 122 |
+
" .dataframe thead th {\n",
|
| 123 |
+
" text-align: right;\n",
|
| 124 |
+
" }\n",
|
| 125 |
+
"</style>\n",
|
| 126 |
+
"<table border=\"1\" class=\"dataframe\">\n",
|
| 127 |
+
" <thead>\n",
|
| 128 |
+
" <tr style=\"text-align: right;\">\n",
|
| 129 |
+
" <th></th>\n",
|
| 130 |
+
" <th>Invoice</th>\n",
|
| 131 |
+
" <th>StockCode</th>\n",
|
| 132 |
+
" <th>Description</th>\n",
|
| 133 |
+
" <th>Quantity</th>\n",
|
| 134 |
+
" <th>InvoiceDate</th>\n",
|
| 135 |
+
" <th>Price</th>\n",
|
| 136 |
+
" <th>Customer ID</th>\n",
|
| 137 |
+
" <th>Country</th>\n",
|
| 138 |
+
" </tr>\n",
|
| 139 |
+
" </thead>\n",
|
| 140 |
+
" <tbody>\n",
|
| 141 |
+
" <tr>\n",
|
| 142 |
+
" <th>0</th>\n",
|
| 143 |
+
" <td>489434</td>\n",
|
| 144 |
+
" <td>85048</td>\n",
|
| 145 |
+
" <td>15CM CHRISTMAS GLASS BALL 20 LIGHTS</td>\n",
|
| 146 |
+
" <td>12</td>\n",
|
| 147 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 148 |
+
" <td>6.95</td>\n",
|
| 149 |
+
" <td>13085.0</td>\n",
|
| 150 |
+
" <td>United Kingdom</td>\n",
|
| 151 |
+
" </tr>\n",
|
| 152 |
+
" <tr>\n",
|
| 153 |
+
" <th>1</th>\n",
|
| 154 |
+
" <td>489434</td>\n",
|
| 155 |
+
" <td>79323P</td>\n",
|
| 156 |
+
" <td>PINK CHERRY LIGHTS</td>\n",
|
| 157 |
+
" <td>12</td>\n",
|
| 158 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 159 |
+
" <td>6.75</td>\n",
|
| 160 |
+
" <td>13085.0</td>\n",
|
| 161 |
+
" <td>United Kingdom</td>\n",
|
| 162 |
+
" </tr>\n",
|
| 163 |
+
" <tr>\n",
|
| 164 |
+
" <th>2</th>\n",
|
| 165 |
+
" <td>489434</td>\n",
|
| 166 |
+
" <td>79323W</td>\n",
|
| 167 |
+
" <td>WHITE CHERRY LIGHTS</td>\n",
|
| 168 |
+
" <td>12</td>\n",
|
| 169 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 170 |
+
" <td>6.75</td>\n",
|
| 171 |
+
" <td>13085.0</td>\n",
|
| 172 |
+
" <td>United Kingdom</td>\n",
|
| 173 |
+
" </tr>\n",
|
| 174 |
+
" <tr>\n",
|
| 175 |
+
" <th>3</th>\n",
|
| 176 |
+
" <td>489434</td>\n",
|
| 177 |
+
" <td>22041</td>\n",
|
| 178 |
+
" <td>RECORD FRAME 7\" SINGLE SIZE</td>\n",
|
| 179 |
+
" <td>48</td>\n",
|
| 180 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 181 |
+
" <td>2.10</td>\n",
|
| 182 |
+
" <td>13085.0</td>\n",
|
| 183 |
+
" <td>United Kingdom</td>\n",
|
| 184 |
+
" </tr>\n",
|
| 185 |
+
" <tr>\n",
|
| 186 |
+
" <th>4</th>\n",
|
| 187 |
+
" <td>489434</td>\n",
|
| 188 |
+
" <td>21232</td>\n",
|
| 189 |
+
" <td>STRAWBERRY CERAMIC TRINKET BOX</td>\n",
|
| 190 |
+
" <td>24</td>\n",
|
| 191 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 192 |
+
" <td>1.25</td>\n",
|
| 193 |
+
" <td>13085.0</td>\n",
|
| 194 |
+
" <td>United Kingdom</td>\n",
|
| 195 |
+
" </tr>\n",
|
| 196 |
+
" </tbody>\n",
|
| 197 |
+
"</table>\n",
|
| 198 |
+
"</div>"
|
| 199 |
+
],
|
| 200 |
+
"text/plain": [
|
| 201 |
+
" Invoice StockCode Description Quantity \\\n",
|
| 202 |
+
"0 489434 85048 15CM CHRISTMAS GLASS BALL 20 LIGHTS 12 \n",
|
| 203 |
+
"1 489434 79323P PINK CHERRY LIGHTS 12 \n",
|
| 204 |
+
"2 489434 79323W WHITE CHERRY LIGHTS 12 \n",
|
| 205 |
+
"3 489434 22041 RECORD FRAME 7\" SINGLE SIZE 48 \n",
|
| 206 |
+
"4 489434 21232 STRAWBERRY CERAMIC TRINKET BOX 24 \n",
|
| 207 |
+
"\n",
|
| 208 |
+
" InvoiceDate Price Customer ID Country \n",
|
| 209 |
+
"0 2009-12-01 07:45:00 6.95 13085.0 United Kingdom \n",
|
| 210 |
+
"1 2009-12-01 07:45:00 6.75 13085.0 United Kingdom \n",
|
| 211 |
+
"2 2009-12-01 07:45:00 6.75 13085.0 United Kingdom \n",
|
| 212 |
+
"3 2009-12-01 07:45:00 2.10 13085.0 United Kingdom \n",
|
| 213 |
+
"4 2009-12-01 07:45:00 1.25 13085.0 United Kingdom "
|
| 214 |
+
]
|
| 215 |
+
},
|
| 216 |
+
"metadata": {},
|
| 217 |
+
"output_type": "display_data"
|
| 218 |
+
}
|
| 219 |
+
],
|
| 220 |
+
"source": [
|
| 221 |
+
"display(df.head())"
|
| 222 |
+
]
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"cell_type": "markdown",
|
| 226 |
+
"id": "b29ae0a1",
|
| 227 |
+
"metadata": {},
|
| 228 |
+
"source": [
|
| 229 |
+
"# 1. Cleaning\n"
|
| 230 |
+
]
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
"cell_type": "code",
|
| 234 |
+
"execution_count": 25,
|
| 235 |
+
"id": "08e595c3",
|
| 236 |
+
"metadata": {},
|
| 237 |
+
"outputs": [],
|
| 238 |
+
"source": [
|
| 239 |
+
"df_clean = df.dropna(subset=['Customer ID']).copy()"
|
| 240 |
+
]
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"cell_type": "markdown",
|
| 244 |
+
"id": "75bc4bb9",
|
| 245 |
+
"metadata": {},
|
| 246 |
+
"source": [
|
| 247 |
+
"# Remove cancelled orders (Negative Quantity) & Free items (0 Price)\n"
|
| 248 |
+
]
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
"cell_type": "code",
|
| 252 |
+
"execution_count": 26,
|
| 253 |
+
"id": "8e2bf7ca",
|
| 254 |
+
"metadata": {},
|
| 255 |
+
"outputs": [],
|
| 256 |
+
"source": [
|
| 257 |
+
"df_clean = df_clean[(df_clean['Quantity'] > 0) & (df_clean['Price'] > 0)]"
|
| 258 |
+
]
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"cell_type": "markdown",
|
| 262 |
+
"id": "ee3e4c59",
|
| 263 |
+
"metadata": {},
|
| 264 |
+
"source": [
|
| 265 |
+
"# Ensure types\n"
|
| 266 |
+
]
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"cell_type": "code",
|
| 270 |
+
"execution_count": 27,
|
| 271 |
+
"id": "8ac438e3",
|
| 272 |
+
"metadata": {},
|
| 273 |
+
"outputs": [],
|
| 274 |
+
"source": [
|
| 275 |
+
"df_clean['Customer ID'] = df_clean['Customer ID'].astype(int).astype(str)\n",
|
| 276 |
+
"df_clean['InvoiceDate'] = pd.to_datetime(df_clean['InvoiceDate'])\n"
|
| 277 |
+
]
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"cell_type": "markdown",
|
| 281 |
+
"id": "1a59080f",
|
| 282 |
+
"metadata": {},
|
| 283 |
+
"source": [
|
| 284 |
+
"# 2. Total Price Calculation (THE FIX)\n",
|
| 285 |
+
"# We multiply Clean Quantity by Clean Price"
|
| 286 |
+
]
|
| 287 |
+
},
|
| 288 |
+
{
|
| 289 |
+
"cell_type": "code",
|
| 290 |
+
"execution_count": 28,
|
| 291 |
+
"id": "6eb4906e",
|
| 292 |
+
"metadata": {},
|
| 293 |
+
"outputs": [],
|
| 294 |
+
"source": [
|
| 295 |
+
"df_clean['Total Price'] = df_clean['Quantity'] * df_clean['Price']"
|
| 296 |
+
]
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"cell_type": "code",
|
| 300 |
+
"execution_count": 29,
|
| 301 |
+
"id": "1fb1d580",
|
| 302 |
+
"metadata": {},
|
| 303 |
+
"outputs": [
|
| 304 |
+
{
|
| 305 |
+
"name": "stdout",
|
| 306 |
+
"output_type": "stream",
|
| 307 |
+
"text": [
|
| 308 |
+
"Data Cleaned. Rows: 407664\n"
|
| 309 |
+
]
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"data": {
|
| 313 |
+
"text/html": [
|
| 314 |
+
"<div>\n",
|
| 315 |
+
"<style scoped>\n",
|
| 316 |
+
" .dataframe tbody tr th:only-of-type {\n",
|
| 317 |
+
" vertical-align: middle;\n",
|
| 318 |
+
" }\n",
|
| 319 |
+
"\n",
|
| 320 |
+
" .dataframe tbody tr th {\n",
|
| 321 |
+
" vertical-align: top;\n",
|
| 322 |
+
" }\n",
|
| 323 |
+
"\n",
|
| 324 |
+
" .dataframe thead th {\n",
|
| 325 |
+
" text-align: right;\n",
|
| 326 |
+
" }\n",
|
| 327 |
+
"</style>\n",
|
| 328 |
+
"<table border=\"1\" class=\"dataframe\">\n",
|
| 329 |
+
" <thead>\n",
|
| 330 |
+
" <tr style=\"text-align: right;\">\n",
|
| 331 |
+
" <th></th>\n",
|
| 332 |
+
" <th>Invoice</th>\n",
|
| 333 |
+
" <th>StockCode</th>\n",
|
| 334 |
+
" <th>Description</th>\n",
|
| 335 |
+
" <th>Quantity</th>\n",
|
| 336 |
+
" <th>InvoiceDate</th>\n",
|
| 337 |
+
" <th>Price</th>\n",
|
| 338 |
+
" <th>Customer ID</th>\n",
|
| 339 |
+
" <th>Country</th>\n",
|
| 340 |
+
" <th>Total Price</th>\n",
|
| 341 |
+
" </tr>\n",
|
| 342 |
+
" </thead>\n",
|
| 343 |
+
" <tbody>\n",
|
| 344 |
+
" <tr>\n",
|
| 345 |
+
" <th>0</th>\n",
|
| 346 |
+
" <td>489434</td>\n",
|
| 347 |
+
" <td>85048</td>\n",
|
| 348 |
+
" <td>15CM CHRISTMAS GLASS BALL 20 LIGHTS</td>\n",
|
| 349 |
+
" <td>12</td>\n",
|
| 350 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 351 |
+
" <td>6.95</td>\n",
|
| 352 |
+
" <td>13085</td>\n",
|
| 353 |
+
" <td>United Kingdom</td>\n",
|
| 354 |
+
" <td>83.4</td>\n",
|
| 355 |
+
" </tr>\n",
|
| 356 |
+
" <tr>\n",
|
| 357 |
+
" <th>1</th>\n",
|
| 358 |
+
" <td>489434</td>\n",
|
| 359 |
+
" <td>79323P</td>\n",
|
| 360 |
+
" <td>PINK CHERRY LIGHTS</td>\n",
|
| 361 |
+
" <td>12</td>\n",
|
| 362 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 363 |
+
" <td>6.75</td>\n",
|
| 364 |
+
" <td>13085</td>\n",
|
| 365 |
+
" <td>United Kingdom</td>\n",
|
| 366 |
+
" <td>81.0</td>\n",
|
| 367 |
+
" </tr>\n",
|
| 368 |
+
" <tr>\n",
|
| 369 |
+
" <th>2</th>\n",
|
| 370 |
+
" <td>489434</td>\n",
|
| 371 |
+
" <td>79323W</td>\n",
|
| 372 |
+
" <td>WHITE CHERRY LIGHTS</td>\n",
|
| 373 |
+
" <td>12</td>\n",
|
| 374 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 375 |
+
" <td>6.75</td>\n",
|
| 376 |
+
" <td>13085</td>\n",
|
| 377 |
+
" <td>United Kingdom</td>\n",
|
| 378 |
+
" <td>81.0</td>\n",
|
| 379 |
+
" </tr>\n",
|
| 380 |
+
" <tr>\n",
|
| 381 |
+
" <th>3</th>\n",
|
| 382 |
+
" <td>489434</td>\n",
|
| 383 |
+
" <td>22041</td>\n",
|
| 384 |
+
" <td>RECORD FRAME 7\" SINGLE SIZE</td>\n",
|
| 385 |
+
" <td>48</td>\n",
|
| 386 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 387 |
+
" <td>2.10</td>\n",
|
| 388 |
+
" <td>13085</td>\n",
|
| 389 |
+
" <td>United Kingdom</td>\n",
|
| 390 |
+
" <td>100.8</td>\n",
|
| 391 |
+
" </tr>\n",
|
| 392 |
+
" <tr>\n",
|
| 393 |
+
" <th>4</th>\n",
|
| 394 |
+
" <td>489434</td>\n",
|
| 395 |
+
" <td>21232</td>\n",
|
| 396 |
+
" <td>STRAWBERRY CERAMIC TRINKET BOX</td>\n",
|
| 397 |
+
" <td>24</td>\n",
|
| 398 |
+
" <td>2009-12-01 07:45:00</td>\n",
|
| 399 |
+
" <td>1.25</td>\n",
|
| 400 |
+
" <td>13085</td>\n",
|
| 401 |
+
" <td>United Kingdom</td>\n",
|
| 402 |
+
" <td>30.0</td>\n",
|
| 403 |
+
" </tr>\n",
|
| 404 |
+
" </tbody>\n",
|
| 405 |
+
"</table>\n",
|
| 406 |
+
"</div>"
|
| 407 |
+
],
|
| 408 |
+
"text/plain": [
|
| 409 |
+
" Invoice StockCode Description Quantity \\\n",
|
| 410 |
+
"0 489434 85048 15CM CHRISTMAS GLASS BALL 20 LIGHTS 12 \n",
|
| 411 |
+
"1 489434 79323P PINK CHERRY LIGHTS 12 \n",
|
| 412 |
+
"2 489434 79323W WHITE CHERRY LIGHTS 12 \n",
|
| 413 |
+
"3 489434 22041 RECORD FRAME 7\" SINGLE SIZE 48 \n",
|
| 414 |
+
"4 489434 21232 STRAWBERRY CERAMIC TRINKET BOX 24 \n",
|
| 415 |
+
"\n",
|
| 416 |
+
" InvoiceDate Price Customer ID Country Total Price \n",
|
| 417 |
+
"0 2009-12-01 07:45:00 6.95 13085 United Kingdom 83.4 \n",
|
| 418 |
+
"1 2009-12-01 07:45:00 6.75 13085 United Kingdom 81.0 \n",
|
| 419 |
+
"2 2009-12-01 07:45:00 6.75 13085 United Kingdom 81.0 \n",
|
| 420 |
+
"3 2009-12-01 07:45:00 2.10 13085 United Kingdom 100.8 \n",
|
| 421 |
+
"4 2009-12-01 07:45:00 1.25 13085 United Kingdom 30.0 "
|
| 422 |
+
]
|
| 423 |
+
},
|
| 424 |
+
"execution_count": 29,
|
| 425 |
+
"metadata": {},
|
| 426 |
+
"output_type": "execute_result"
|
| 427 |
+
}
|
| 428 |
+
],
|
| 429 |
+
"source": [
|
| 430 |
+
"print(f\"Data Cleaned. Rows: {df_clean.shape[0]}\")\n",
|
| 431 |
+
"df_clean.head()"
|
| 432 |
+
]
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"cell_type": "code",
|
| 436 |
+
"execution_count": 30,
|
| 437 |
+
"id": "29ab34c0",
|
| 438 |
+
"metadata": {},
|
| 439 |
+
"outputs": [
|
| 440 |
+
{
|
| 441 |
+
"name": "stdout",
|
| 442 |
+
"output_type": "stream",
|
| 443 |
+
"text": [
|
| 444 |
+
"cleaned dataset \n"
|
| 445 |
+
]
|
| 446 |
+
}
|
| 447 |
+
],
|
| 448 |
+
"source": [
|
| 449 |
+
"save_path = '../data/processed/online_retail_clean.csv'\n",
|
| 450 |
+
"df_clean.to_csv(save_path,index=False)\n",
|
| 451 |
+
"print(f'cleaned dataset ')"
|
| 452 |
+
]
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"cell_type": "code",
|
| 456 |
+
"execution_count": 31,
|
| 457 |
+
"id": "569a4aec",
|
| 458 |
+
"metadata": {},
|
| 459 |
+
"outputs": [],
|
| 460 |
+
"source": [
|
| 461 |
+
"refrence_date = df_clean['InvoiceDate'].max() + pd.DateOffset(days=1)"
|
| 462 |
+
]
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"cell_type": "markdown",
|
| 466 |
+
"id": "04ae8651",
|
| 467 |
+
"metadata": {},
|
| 468 |
+
"source": [
|
| 469 |
+
"# Group by Customer ID\n"
|
| 470 |
+
]
|
| 471 |
+
},
|
| 472 |
+
{
|
| 473 |
+
"cell_type": "code",
|
| 474 |
+
"execution_count": null,
|
| 475 |
+
"id": "1d03b214",
|
| 476 |
+
"metadata": {},
|
| 477 |
+
"outputs": [],
|
| 478 |
+
"source": [
|
| 479 |
+
"rfm = df_clean.groupby('Customer ID').agg({\n",
|
| 480 |
+
" 'InvoiceDate': lambda x: (refrence_date - x.max()).days, # Recency\n",
|
| 481 |
+
" 'Invoice': 'nunique', # Frequency\n",
|
| 482 |
+
" 'Total Price': 'sum' # Monetary (Summing the calculated Total Price)\n",
|
| 483 |
+
"}).reset_index()"
|
| 484 |
+
]
|
| 485 |
+
},
|
| 486 |
+
{
|
| 487 |
+
"cell_type": "markdown",
|
| 488 |
+
"id": "9d87d402",
|
| 489 |
+
"metadata": {},
|
| 490 |
+
"source": [
|
| 491 |
+
"# Rename columns\n"
|
| 492 |
+
]
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"cell_type": "code",
|
| 496 |
+
"execution_count": 33,
|
| 497 |
+
"id": "778b4c3c",
|
| 498 |
+
"metadata": {},
|
| 499 |
+
"outputs": [],
|
| 500 |
+
"source": [
|
| 501 |
+
"rfm.rename(columns={\n",
|
| 502 |
+
" 'InvoiceDate': 'Recency',\n",
|
| 503 |
+
" 'Invoice': 'Frequency',\n",
|
| 504 |
+
" 'Total Price': 'Monetary'\n",
|
| 505 |
+
"}, inplace=True)"
|
| 506 |
+
]
|
| 507 |
+
},
|
| 508 |
+
{
|
| 509 |
+
"cell_type": "code",
|
| 510 |
+
"execution_count": 34,
|
| 511 |
+
"id": "292626a1",
|
| 512 |
+
"metadata": {},
|
| 513 |
+
"outputs": [
|
| 514 |
+
{
|
| 515 |
+
"name": "stdout",
|
| 516 |
+
"output_type": "stream",
|
| 517 |
+
"text": [
|
| 518 |
+
" Customer ID Recency Frequency Monetary\n",
|
| 519 |
+
"0 12346 165 11 372.86\n",
|
| 520 |
+
"1 12347 3 2 1323.32\n",
|
| 521 |
+
"2 12348 74 1 222.16\n",
|
| 522 |
+
"3 12349 43 3 2671.14\n",
|
| 523 |
+
"4 12351 11 1 300.93\n"
|
| 524 |
+
]
|
| 525 |
+
}
|
| 526 |
+
],
|
| 527 |
+
"source": [
|
| 528 |
+
"print(rfm.head())"
|
| 529 |
+
]
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"metadata": {
|
| 533 |
+
"kernelspec": {
|
| 534 |
+
"display_name": "Python 3",
|
| 535 |
+
"language": "python",
|
| 536 |
+
"name": "python3"
|
| 537 |
+
},
|
| 538 |
+
"language_info": {
|
| 539 |
+
"codemirror_mode": {
|
| 540 |
+
"name": "ipython",
|
| 541 |
+
"version": 3
|
| 542 |
+
},
|
| 543 |
+
"file_extension": ".py",
|
| 544 |
+
"mimetype": "text/x-python",
|
| 545 |
+
"name": "python",
|
| 546 |
+
"nbconvert_exporter": "python",
|
| 547 |
+
"pygments_lexer": "ipython3",
|
| 548 |
+
"version": "3.12.4"
|
| 549 |
+
}
|
| 550 |
+
},
|
| 551 |
+
"nbformat": 4,
|
| 552 |
+
"nbformat_minor": 5
|
| 553 |
+
}
|
notebooks/02_RFM_Segmentation.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks/03_Churn_Prediction.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pandas
|
| 2 |
+
numpy
|
| 3 |
+
matplotlib
|
| 4 |
+
seaborn
|
| 5 |
+
plotly>=5.0.0
|
| 6 |
+
scikit-learn
|
| 7 |
+
openpyxl
|
| 8 |
+
nbformat>=4.2.0
|
| 9 |
+
ipykernel
|
| 10 |
+
fastapi
|
| 11 |
+
uvicorn
|
| 12 |
+
pyyaml
|
| 13 |
+
pytest
|
| 14 |
+
httpx
|
| 15 |
+
xgboost
|
| 16 |
+
shap
|
| 17 |
+
scipy
|
| 18 |
+
websockets
|
src/api.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Entry point wrapper for uvicorn execution and testing
|
| 2 |
+
from src.api.main import app
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ChurnFlow Modular API Package
|
| 2 |
+
from src.api.main import app
|
src/api/config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
# Add project root to path if needed
|
| 5 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 6 |
+
if project_root not in sys.path:
|
| 7 |
+
sys.path.append(project_root)
|
| 8 |
+
|
| 9 |
+
from src.config_loader import CONFIG
|
| 10 |
+
from src.logger_config import logger
|
| 11 |
+
|
| 12 |
+
MODEL_PATH = CONFIG["paths"]["model"]
|
| 13 |
+
CHALLENGER_PATH = os.path.join(project_root, "models", "churn_rf_model.pkl")
|
| 14 |
+
HISTORY_PATH = os.path.join(project_root, "logs", "inference_history.csv")
|
| 15 |
+
DB_PATH = os.path.join(project_root, "logs", "predictions.db")
|
| 16 |
+
CLEANED_DATA_PATH = os.path.join(project_root, "data", "processed", "cleaned_customer_data.csv")
|
| 17 |
+
CUSTOMERS_JSON_PATH = os.path.join(project_root, "dashboard", "public", "data", "customers.json")
|
src/api/database.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sqlite3
|
| 3 |
+
import datetime
|
| 4 |
+
from src.api.config import DB_PATH, HISTORY_PATH, logger
|
| 5 |
+
|
| 6 |
+
def init_db() -> None:
|
| 7 |
+
"""Initializes the CSV inference log and SQLite shadow database schemas."""
|
| 8 |
+
# Initialize CSV file
|
| 9 |
+
try:
|
| 10 |
+
os.makedirs(os.path.dirname(HISTORY_PATH), exist_ok=True)
|
| 11 |
+
if not os.path.exists(HISTORY_PATH):
|
| 12 |
+
with open(HISTORY_PATH, "w") as f:
|
| 13 |
+
f.write("Timestamp,Recency,Frequency,Monetary,BasketSize\n")
|
| 14 |
+
logger.info(f"Initialized inference history log at {HISTORY_PATH}")
|
| 15 |
+
except Exception as e:
|
| 16 |
+
logger.error(f"Failed to initialize inference log file: {str(e)}")
|
| 17 |
+
|
| 18 |
+
# Initialize SQLite database
|
| 19 |
+
try:
|
| 20 |
+
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
| 21 |
+
conn = sqlite3.connect(DB_PATH)
|
| 22 |
+
cursor = conn.cursor()
|
| 23 |
+
cursor.execute("""
|
| 24 |
+
CREATE TABLE IF NOT EXISTS shadow_predictions (
|
| 25 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 26 |
+
timestamp TEXT,
|
| 27 |
+
recency REAL,
|
| 28 |
+
frequency REAL,
|
| 29 |
+
monetary REAL,
|
| 30 |
+
basket_size REAL,
|
| 31 |
+
champion_prob REAL,
|
| 32 |
+
challenger_prob REAL
|
| 33 |
+
)
|
| 34 |
+
""")
|
| 35 |
+
conn.commit()
|
| 36 |
+
conn.close()
|
| 37 |
+
logger.info(f"Successfully initialized shadow prediction database at {DB_PATH}")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.error(f"Failed to initialize SQLite shadow DB: {str(e)}")
|
| 40 |
+
|
| 41 |
+
def log_inference(recency: float, frequency: float, monetary: float, basket_size: float) -> None:
|
| 42 |
+
"""Appends live inference inputs to the CSV history log."""
|
| 43 |
+
try:
|
| 44 |
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 45 |
+
with open(HISTORY_PATH, "a") as f:
|
| 46 |
+
f.write(f"{timestamp},{recency},{frequency},{monetary},{basket_size}\n")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Failed to log inference request to CSV: {str(e)}")
|
| 49 |
+
|
| 50 |
+
def log_shadow_prediction(recency: float, frequency: float, monetary: float, basket_size: float, champion_prob: float, challenger_prob: float) -> None:
|
| 51 |
+
"""Logs prediction inputs and outputs of Champion and Challenger models to SQLite."""
|
| 52 |
+
try:
|
| 53 |
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 54 |
+
conn = sqlite3.connect(DB_PATH)
|
| 55 |
+
cursor = conn.cursor()
|
| 56 |
+
cursor.execute(
|
| 57 |
+
"INSERT INTO shadow_predictions (timestamp, recency, frequency, monetary, basket_size, champion_prob, challenger_prob) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
| 58 |
+
(timestamp, recency, frequency, monetary, basket_size, champion_prob, challenger_prob)
|
| 59 |
+
)
|
| 60 |
+
conn.commit()
|
| 61 |
+
conn.close()
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Failed to log shadow prediction to SQLite: {str(e)}")
|
src/api/drift_service.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from scipy.stats import ks_2samp
|
| 4 |
+
from typing import Dict, Any
|
| 5 |
+
from src.api.config import HISTORY_PATH, CLEANED_DATA_PATH, CONFIG, logger
|
| 6 |
+
|
| 7 |
+
def run_drift_analysis() -> Dict[str, Any]:
|
| 8 |
+
"""Runs a Kolmogorov-Smirnov test to detect data drift between baseline and production data."""
|
| 9 |
+
# Check production history file
|
| 10 |
+
if not os.path.exists(HISTORY_PATH):
|
| 11 |
+
return {
|
| 12 |
+
"drift_detected": False,
|
| 13 |
+
"drift_status": "Insufficient Data",
|
| 14 |
+
"message": "Production inference history file is missing."
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
prod_df = pd.read_csv(HISTORY_PATH)
|
| 19 |
+
except Exception as e:
|
| 20 |
+
logger.error(f"Error reading inference history: {str(e)}")
|
| 21 |
+
return {
|
| 22 |
+
"drift_detected": False,
|
| 23 |
+
"drift_status": "Error",
|
| 24 |
+
"message": f"Could not load production logs: {str(e)}"
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# We require a minimum of 10 samples to run statistical checks
|
| 28 |
+
min_samples = 10
|
| 29 |
+
prod_size = len(prod_df)
|
| 30 |
+
if prod_size < min_samples:
|
| 31 |
+
return {
|
| 32 |
+
"drift_detected": False,
|
| 33 |
+
"drift_status": "Insufficient Data",
|
| 34 |
+
"message": f"Awaiting production predictions. Need at least {min_samples} requests to run statistical test (current: {prod_size}).",
|
| 35 |
+
"sample_sizes": {
|
| 36 |
+
"baseline": 4312,
|
| 37 |
+
"production": prod_size
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
# Load baseline dataset
|
| 42 |
+
baseline_path = CLEANED_DATA_PATH
|
| 43 |
+
if not os.path.exists(baseline_path):
|
| 44 |
+
baseline_path = CONFIG["paths"]["clean_data"]
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
base_df = pd.read_csv(baseline_path)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"Error loading baseline clean dataset: {str(e)}")
|
| 50 |
+
return {
|
| 51 |
+
"drift_detected": False,
|
| 52 |
+
"drift_status": "Error",
|
| 53 |
+
"message": f"Could not load baseline training data: {str(e)}"
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Mapping of column names: baseline vs production history
|
| 57 |
+
features_to_test = {
|
| 58 |
+
"Recency": "Recency",
|
| 59 |
+
"Frequency": "Frequency",
|
| 60 |
+
"Monetary": "Monetary",
|
| 61 |
+
"AvgBucketSize": "BasketSize"
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
drift_details = {}
|
| 65 |
+
drift_detected = False
|
| 66 |
+
|
| 67 |
+
for base_col, prod_col in features_to_test.items():
|
| 68 |
+
if base_col not in base_df.columns or prod_col not in prod_df.columns:
|
| 69 |
+
logger.warning(f"Feature columns not found: {base_col} in base or {prod_col} in prod.")
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
base_arr = base_df[base_col].dropna().values
|
| 73 |
+
prod_arr = prod_df[prod_col].dropna().values
|
| 74 |
+
|
| 75 |
+
# Run Kolmogorov-Smirnov test (2-sample)
|
| 76 |
+
stat, pval = ks_2samp(base_arr, prod_arr)
|
| 77 |
+
|
| 78 |
+
# Standard 5% significance level
|
| 79 |
+
has_drifted = pval < 0.05
|
| 80 |
+
if has_drifted:
|
| 81 |
+
drift_detected = True
|
| 82 |
+
|
| 83 |
+
drift_details[base_col] = {
|
| 84 |
+
"p_value": round(float(pval), 5),
|
| 85 |
+
"drift_status": "Drifted" if has_drifted else "Stable",
|
| 86 |
+
"baseline_mean": round(float(base_arr.mean()), 2),
|
| 87 |
+
"production_mean": round(float(prod_arr.mean()), 2)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
status = "Drift Detected" if drift_detected else "Stable"
|
| 91 |
+
message = "Production distribution has shifted statistically from baseline training distributions. Model performance may degrade." if drift_detected else "Incoming request distributions align with baseline training distributions."
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"drift_detected": drift_detected,
|
| 95 |
+
"drift_status": status,
|
| 96 |
+
"message": message,
|
| 97 |
+
"sample_sizes": {
|
| 98 |
+
"baseline": len(base_df),
|
| 99 |
+
"production": prod_size
|
| 100 |
+
},
|
| 101 |
+
"features": drift_details
|
| 102 |
+
}
|
src/api/main.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
|
| 5 |
+
from src.api.config import logger
|
| 6 |
+
from src.api.database import init_db
|
| 7 |
+
from src.api.ml_services import load_models_and_data
|
| 8 |
+
from src.api.routes import router
|
| 9 |
+
|
| 10 |
+
@asynccontextmanager
|
| 11 |
+
async def lifespan(app: FastAPI):
|
| 12 |
+
# Startup: Load databases and models
|
| 13 |
+
logger.info("Starting up Customer Churn Prediction API...")
|
| 14 |
+
init_db()
|
| 15 |
+
load_models_and_data()
|
| 16 |
+
yield
|
| 17 |
+
# Shutdown
|
| 18 |
+
logger.info("Shutting down Customer Churn Prediction API...")
|
| 19 |
+
|
| 20 |
+
app = FastAPI(
|
| 21 |
+
title="Customer Churn Prediction API",
|
| 22 |
+
description="Production-grade REST API serving real-time customer churn forecasts based on XGBoost & Random Forest features.",
|
| 23 |
+
version="2.0.0",
|
| 24 |
+
lifespan=lifespan
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Enable CORS for frontend dashboard access
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=["*"], # Restrict origins in production environments
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["*"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Include routes APIRouter
|
| 37 |
+
app.include_router(router)
|
src/api/ml_services.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import joblib
|
| 3 |
+
import json
|
| 4 |
+
import numpy as np
|
| 5 |
+
import xgboost as xgb
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import Dict, Any, List
|
| 8 |
+
from src.api.config import MODEL_PATH, CHALLENGER_PATH, CUSTOMERS_JSON_PATH, CONFIG, logger
|
| 9 |
+
from src.api.models import CustomerInput
|
| 10 |
+
|
| 11 |
+
# Global variables for models and in-memory customer data
|
| 12 |
+
champion_model = None
|
| 13 |
+
challenger_model = None
|
| 14 |
+
memory_customers = {}
|
| 15 |
+
|
| 16 |
+
def load_models_and_data() -> None:
|
| 17 |
+
"""Loads all models and loads customer record assets into memory."""
|
| 18 |
+
global champion_model, challenger_model, memory_customers
|
| 19 |
+
|
| 20 |
+
# Load Champion model
|
| 21 |
+
logger.info(f"Attempting to load champion model from {MODEL_PATH}...")
|
| 22 |
+
if os.path.exists(MODEL_PATH):
|
| 23 |
+
try:
|
| 24 |
+
champion_model = joblib.load(MODEL_PATH)
|
| 25 |
+
logger.info("Successfully loaded champion XGBoost model.")
|
| 26 |
+
except Exception as e:
|
| 27 |
+
logger.error(f"Error loading champion model: {str(e)}")
|
| 28 |
+
else:
|
| 29 |
+
logger.warning(f"Champion model file missing at {MODEL_PATH}.")
|
| 30 |
+
|
| 31 |
+
# Load Challenger model
|
| 32 |
+
logger.info(f"Attempting to load challenger model from {CHALLENGER_PATH}...")
|
| 33 |
+
if os.path.exists(CHALLENGER_PATH):
|
| 34 |
+
try:
|
| 35 |
+
challenger_model = joblib.load(CHALLENGER_PATH)
|
| 36 |
+
logger.info("Successfully loaded challenger Random Forest model.")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Error loading challenger model: {str(e)}")
|
| 39 |
+
else:
|
| 40 |
+
logger.warning(f"Challenger model file missing at {CHALLENGER_PATH}.")
|
| 41 |
+
|
| 42 |
+
# Load customer records into memory for live WebSocket streaming
|
| 43 |
+
if os.path.exists(CUSTOMERS_JSON_PATH):
|
| 44 |
+
try:
|
| 45 |
+
with open(CUSTOMERS_JSON_PATH, "r") as f:
|
| 46 |
+
data = json.load(f)
|
| 47 |
+
for cust in data:
|
| 48 |
+
memory_customers[str(cust["id"])] = cust
|
| 49 |
+
logger.info(f"Successfully loaded {len(memory_customers)} customer records into memory for WebSocket streaming.")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Failed to load customers.json into memory: {str(e)}")
|
| 52 |
+
else:
|
| 53 |
+
logger.warning(f"customers.json missing at {CUSTOMERS_JSON_PATH}. WebSocket streaming fallback mock data will be used.")
|
| 54 |
+
|
| 55 |
+
def get_realtime_recommendation(recency: int, frequency: int, monetary: float, churn_prob: float) -> str:
|
| 56 |
+
"""Generates actionable retention strategy based on client segment classification."""
|
| 57 |
+
is_high_value = (frequency >= 3) or (monetary >= 300.0)
|
| 58 |
+
|
| 59 |
+
if churn_prob >= 0.70:
|
| 60 |
+
if is_high_value:
|
| 61 |
+
return "At-Risk VIP: High historic value. Route to customer relations manager for direct feedback outreach. Offer priority recovery benefits."
|
| 62 |
+
else:
|
| 63 |
+
return "Hibernating Win-back: Inactive standard customer. Target with automated email re-engagement flow offering aggressive discount vouchers."
|
| 64 |
+
elif churn_prob >= 0.30:
|
| 65 |
+
if is_high_value:
|
| 66 |
+
return "Proactive VIP Retention: High-value showing drop-off signs. Send customized recommendations based on past purchases. Avoid direct discount spam."
|
| 67 |
+
else:
|
| 68 |
+
return "Standard Retention: Nurture with standard newsletter promotions and seasonal discounts."
|
| 69 |
+
else:
|
| 70 |
+
if is_high_value:
|
| 71 |
+
return "Maintain & Upsell: Core loyal customer. Exclude from margin-diluting discount codes. Send early access and premium alerts."
|
| 72 |
+
else:
|
| 73 |
+
return "Nurture Campaign: Keep engaged with standard marketing updates."
|
| 74 |
+
|
| 75 |
+
def preprocess_features(customer: CustomerInput) -> np.ndarray:
|
| 76 |
+
"""Preprocesses input parameters and fills in feature engineered attributes."""
|
| 77 |
+
single_buyer_impute = CONFIG["parameters"]["single_order_imputation_days"]
|
| 78 |
+
default_uk = CONFIG["parameters"]["default_is_uk"]
|
| 79 |
+
|
| 80 |
+
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)
|
| 81 |
+
recent_ratio = customer.recent_orders_ratio if customer.recent_orders_ratio is not None else (1.0 if customer.recency <= 60 else 0.0)
|
| 82 |
+
recency_ratio = customer.recency / (avg_days + 1e-5)
|
| 83 |
+
is_uk = customer.is_uk if customer.is_uk is not None else default_uk
|
| 84 |
+
|
| 85 |
+
return np.array([[
|
| 86 |
+
customer.recency,
|
| 87 |
+
customer.frequency,
|
| 88 |
+
customer.monetary,
|
| 89 |
+
customer.basket_size,
|
| 90 |
+
avg_days,
|
| 91 |
+
recency_ratio,
|
| 92 |
+
recent_ratio,
|
| 93 |
+
is_uk
|
| 94 |
+
]])
|
| 95 |
+
|
| 96 |
+
def run_champion_inference(features: np.ndarray) -> float:
|
| 97 |
+
"""Computes prediction probability using the Champion XGBoost model."""
|
| 98 |
+
if champion_model is None:
|
| 99 |
+
raise ValueError("Champion model is not loaded.")
|
| 100 |
+
return float(champion_model.predict_proba(features)[:, 1][0])
|
| 101 |
+
|
| 102 |
+
def run_challenger_inference(features: np.ndarray) -> float:
|
| 103 |
+
"""Computes prediction probability using the Challenger Random Forest model."""
|
| 104 |
+
if challenger_model is None:
|
| 105 |
+
return 0.0
|
| 106 |
+
df_features = pd.DataFrame(features, columns=[
|
| 107 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 108 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 109 |
+
])
|
| 110 |
+
return float(challenger_model.predict_proba(df_features)[:, 1][0])
|
| 111 |
+
|
| 112 |
+
def compute_shap_values(features: np.ndarray) -> Dict[str, float]:
|
| 113 |
+
"""Calculates TreeSHAP feature attribution scores for explainability plots."""
|
| 114 |
+
if champion_model is None:
|
| 115 |
+
return {}
|
| 116 |
+
booster = champion_model.get_booster()
|
| 117 |
+
dmat = xgb.DMatrix(features, feature_names=[
|
| 118 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 119 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 120 |
+
])
|
| 121 |
+
contribs = booster.predict(dmat, pred_contribs=True)[0]
|
| 122 |
+
return {
|
| 123 |
+
"Recency": float(contribs[0]),
|
| 124 |
+
"Frequency": float(contribs[1]),
|
| 125 |
+
"Monetary": float(contribs[2]),
|
| 126 |
+
"AvgBucketSize": float(contribs[3]),
|
| 127 |
+
"AvgDaysBetween": float(contribs[4]),
|
| 128 |
+
"Recency_to_AvgDaysRatio": float(contribs[5]),
|
| 129 |
+
"Recent_Orders_Ratio": float(contribs[6]),
|
| 130 |
+
"Is_UK": float(contribs[7])
|
| 131 |
+
}
|
src/api/models.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
class CustomerInput(BaseModel):
|
| 5 |
+
recency: int = Field(..., description="Days since last purchase", ge=0, json_schema_extra={"example": 65})
|
| 6 |
+
frequency: int = Field(..., description="Number of unique orders/invoices", ge=1, json_schema_extra={"example": 3})
|
| 7 |
+
monetary: float = Field(..., description="Average value spend per invoice", ge=0.0, json_schema_extra={"example": 350.50})
|
| 8 |
+
basket_size: float = Field(..., description="Average items count per basket size", ge=0.0, json_schema_extra={"example": 12.5})
|
| 9 |
+
avg_days_between: float | None = Field(None, description="Average days between purchases.", ge=0.0)
|
| 10 |
+
recent_orders_ratio: float | None = Field(None, description="Ratio of orders placed in last 60 days.", ge=0.0, le=1.0)
|
| 11 |
+
is_uk: int | None = Field(None, description="1 if customer is in the UK, else 0.", ge=0, le=1)
|
| 12 |
+
|
| 13 |
+
class BatchInput(BaseModel):
|
| 14 |
+
customers: List[CustomerInput]
|
src/api/routes.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sqlite3
|
| 3 |
+
import numpy as np
|
| 4 |
+
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
from src.api.config import DB_PATH, HISTORY_PATH, logger
|
| 8 |
+
from src.api.models import CustomerInput, BatchInput
|
| 9 |
+
from src.api import ml_services
|
| 10 |
+
from src.api.database import log_inference, log_shadow_prediction
|
| 11 |
+
from src.api.drift_service import run_drift_analysis
|
| 12 |
+
from src.api.websocket_manager import ws_manager
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.get("/health", tags=["System"])
|
| 17 |
+
def health_check() -> Dict[str, Any]:
|
| 18 |
+
"""Returns API health status and checks if the model is loaded."""
|
| 19 |
+
logger.info("Executing health check request...")
|
| 20 |
+
return {
|
| 21 |
+
"status": "healthy",
|
| 22 |
+
"model_loaded": ml_services.champion_model is not None
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
@router.post("/predict", tags=["Predictions"])
|
| 26 |
+
def predict_churn(customer: CustomerInput) -> Dict[str, Any]:
|
| 27 |
+
"""Calculates churn probability and risk tier for a single customer."""
|
| 28 |
+
# Log incoming prediction inputs to CSV
|
| 29 |
+
log_inference(customer.recency, customer.frequency, customer.monetary, customer.basket_size)
|
| 30 |
+
|
| 31 |
+
if ml_services.champion_model is None:
|
| 32 |
+
logger.error("Inference requested but model is not loaded.")
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=503,
|
| 35 |
+
detail="Machine learning model is not loaded. Please train the model first."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
# Preprocess features
|
| 40 |
+
features = ml_services.preprocess_features(customer)
|
| 41 |
+
|
| 42 |
+
# Calculate probabilities
|
| 43 |
+
prob = ml_services.run_champion_inference(features)
|
| 44 |
+
challenger_prob = ml_services.run_challenger_inference(features)
|
| 45 |
+
|
| 46 |
+
# Compute explainability values
|
| 47 |
+
shap_values = ml_services.compute_shap_values(features)
|
| 48 |
+
|
| 49 |
+
# Log to SQLite DB
|
| 50 |
+
log_shadow_prediction(
|
| 51 |
+
customer.recency, customer.frequency, customer.monetary, customer.basket_size,
|
| 52 |
+
prob, challenger_prob
|
| 53 |
+
)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"Inference prediction process failed: {str(e)}")
|
| 56 |
+
raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}")
|
| 57 |
+
|
| 58 |
+
if prob >= 0.70:
|
| 59 |
+
tier = "High Risk"
|
| 60 |
+
elif prob >= 0.30:
|
| 61 |
+
tier = "Medium Risk"
|
| 62 |
+
else:
|
| 63 |
+
tier = "Low Risk"
|
| 64 |
+
|
| 65 |
+
recommendation = ml_services.get_realtime_recommendation(
|
| 66 |
+
customer.recency,
|
| 67 |
+
customer.frequency,
|
| 68 |
+
customer.monetary,
|
| 69 |
+
prob
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
return {
|
| 73 |
+
"churn_probability": round(prob, 4),
|
| 74 |
+
"risk_tier": tier,
|
| 75 |
+
"recommendation": recommendation,
|
| 76 |
+
"shap_values": shap_values
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
@router.post("/predict_batch", tags=["Predictions"])
|
| 80 |
+
def predict_churn_batch(batch: BatchInput) -> Dict[str, Any]:
|
| 81 |
+
"""Calculates churn predictions in batch for a list of customer records."""
|
| 82 |
+
if ml_services.champion_model is None:
|
| 83 |
+
logger.error("Inference batch requested but model is not loaded.")
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=503,
|
| 86 |
+
detail="Machine learning model is not loaded. Please train the model first."
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if not batch.customers:
|
| 90 |
+
return {"predictions": []}
|
| 91 |
+
|
| 92 |
+
features_list = []
|
| 93 |
+
for c in batch.customers:
|
| 94 |
+
features = ml_services.preprocess_features(c)
|
| 95 |
+
features_list.append(features[0])
|
| 96 |
+
|
| 97 |
+
logger.info(f"Running batch inference for {len(batch.customers)} profiles...")
|
| 98 |
+
try:
|
| 99 |
+
probs = ml_services.champion_model.predict_proba(np.array(features_list))[:, 1].tolist()
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"XGBoost batch prediction failed: {str(e)}")
|
| 102 |
+
raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}")
|
| 103 |
+
|
| 104 |
+
results = []
|
| 105 |
+
for customer, prob in zip(batch.customers, probs):
|
| 106 |
+
if prob >= 0.70:
|
| 107 |
+
tier = "High Risk"
|
| 108 |
+
elif prob >= 0.30:
|
| 109 |
+
tier = "Medium Risk"
|
| 110 |
+
else:
|
| 111 |
+
tier = "Low Risk"
|
| 112 |
+
|
| 113 |
+
recommendation = ml_services.get_realtime_recommendation(
|
| 114 |
+
customer.recency,
|
| 115 |
+
customer.frequency,
|
| 116 |
+
customer.monetary,
|
| 117 |
+
prob
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
results.append({
|
| 121 |
+
"churn_probability": round(prob, 4),
|
| 122 |
+
"risk_tier": tier,
|
| 123 |
+
"recommendation": recommendation
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
return {"predictions": results}
|
| 127 |
+
|
| 128 |
+
@router.get("/monitor", tags=["System"])
|
| 129 |
+
def monitor_drift() -> Dict[str, Any]:
|
| 130 |
+
"""Runs a Kolmogorov-Smirnov test to detect data drift between baseline and production data."""
|
| 131 |
+
return run_drift_analysis()
|
| 132 |
+
|
| 133 |
+
@router.get("/shadow_stats", tags=["System"])
|
| 134 |
+
def get_shadow_stats() -> Dict[str, Any]:
|
| 135 |
+
"""Retrieves side-by-side performance metrics for Champion vs Challenger models in shadow deployment."""
|
| 136 |
+
if not os.path.exists(DB_PATH):
|
| 137 |
+
return {
|
| 138 |
+
"total_predictions": 0,
|
| 139 |
+
"champion_mean": 0.0,
|
| 140 |
+
"challenger_mean": 0.0,
|
| 141 |
+
"mean_absolute_deviation": 0.0,
|
| 142 |
+
"agreement_rate": 1.0,
|
| 143 |
+
"recent_logs": []
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
conn = sqlite3.connect(DB_PATH)
|
| 148 |
+
conn.row_factory = sqlite3.Row
|
| 149 |
+
cursor = conn.cursor()
|
| 150 |
+
|
| 151 |
+
# Load all shadow logs
|
| 152 |
+
cursor.execute("SELECT * FROM shadow_predictions ORDER BY id DESC")
|
| 153 |
+
rows = cursor.fetchall()
|
| 154 |
+
conn.close()
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"Error reading shadow predictions SQLite table: {str(e)}")
|
| 157 |
+
raise HTTPException(status_code=500, detail=f"Database query failed: {str(e)}")
|
| 158 |
+
|
| 159 |
+
total_preds = len(rows)
|
| 160 |
+
if total_preds == 0:
|
| 161 |
+
return {
|
| 162 |
+
"total_predictions": 0,
|
| 163 |
+
"champion_mean": 0.0,
|
| 164 |
+
"challenger_mean": 0.0,
|
| 165 |
+
"mean_absolute_deviation": 0.0,
|
| 166 |
+
"agreement_rate": 1.0,
|
| 167 |
+
"recent_logs": []
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
champion_probs = [r["champion_prob"] for r in rows]
|
| 171 |
+
challenger_probs = [r["challenger_prob"] for r in rows]
|
| 172 |
+
|
| 173 |
+
champion_mean = sum(champion_probs) / total_preds
|
| 174 |
+
challenger_mean = sum(challenger_probs) / total_preds
|
| 175 |
+
|
| 176 |
+
# Calculate Mean Absolute Deviation (MAD)
|
| 177 |
+
mad = sum(abs(champ - chall) for champ, chall in zip(champion_probs, challenger_probs)) / total_preds
|
| 178 |
+
|
| 179 |
+
# Calculate decision agreement (agreement on binary threshold 0.50 risk split)
|
| 180 |
+
agreements = 0
|
| 181 |
+
for champ, chall in zip(champion_probs, challenger_probs):
|
| 182 |
+
champ_class = 1 if champ >= 0.5 else 0
|
| 183 |
+
chall_class = 1 if chall >= 0.5 else 0
|
| 184 |
+
if champ_class == chall_class:
|
| 185 |
+
agreements += 1
|
| 186 |
+
|
| 187 |
+
agreement_rate = agreements / total_preds
|
| 188 |
+
|
| 189 |
+
# Extract last 5 logs for front-end rendering
|
| 190 |
+
recent_logs = []
|
| 191 |
+
for r in rows[:5]:
|
| 192 |
+
recent_logs.append({
|
| 193 |
+
"id": r["id"],
|
| 194 |
+
"timestamp": r["timestamp"],
|
| 195 |
+
"recency": r["recency"],
|
| 196 |
+
"frequency": r["frequency"],
|
| 197 |
+
"monetary": r["monetary"],
|
| 198 |
+
"basket_size": r["basket_size"],
|
| 199 |
+
"champion_prob": round(float(r["champion_prob"]), 4),
|
| 200 |
+
"challenger_prob": round(float(r["challenger_prob"]), 4)
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
return {
|
| 204 |
+
"total_predictions": total_preds,
|
| 205 |
+
"champion_mean": round(champion_mean, 4),
|
| 206 |
+
"challenger_mean": round(challenger_mean, 4),
|
| 207 |
+
"mean_absolute_deviation": round(mad, 4),
|
| 208 |
+
"agreement_rate": round(agreement_rate, 4),
|
| 209 |
+
"recent_logs": recent_logs
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
@router.websocket("/ws/transactions")
|
| 213 |
+
async def websocket_transactions(websocket: WebSocket):
|
| 214 |
+
"""WebSocket connection that generates a live transaction stream and pushes real-time updates."""
|
| 215 |
+
await ws_manager.connect(websocket)
|
| 216 |
+
try:
|
| 217 |
+
await ws_manager.stream_live_transactions(websocket)
|
| 218 |
+
except WebSocketDisconnect:
|
| 219 |
+
ws_manager.disconnect(websocket)
|
| 220 |
+
except Exception as e:
|
| 221 |
+
logger.error(f"WebSocket execution error: {str(e)}")
|
| 222 |
+
ws_manager.disconnect(websocket)
|
src/api/websocket_manager.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import random
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 6 |
+
from src.api.config import logger
|
| 7 |
+
from src.api.ml_services import memory_customers, champion_model, challenger_model
|
| 8 |
+
from src.api.database import log_inference, log_shadow_prediction
|
| 9 |
+
|
| 10 |
+
class WebSocketManager:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.active_connections: list[WebSocket] = []
|
| 13 |
+
|
| 14 |
+
async def connect(self, websocket: WebSocket):
|
| 15 |
+
await websocket.accept()
|
| 16 |
+
self.active_connections.append(websocket)
|
| 17 |
+
logger.info(f"WebSocket client connected. Total connections: {len(self.active_connections)}")
|
| 18 |
+
|
| 19 |
+
def disconnect(self, websocket: WebSocket):
|
| 20 |
+
if websocket in self.active_connections:
|
| 21 |
+
self.active_connections.remove(websocket)
|
| 22 |
+
logger.info(f"WebSocket client disconnected. Total connections: {len(self.active_connections)}")
|
| 23 |
+
|
| 24 |
+
async def stream_live_transactions(self, websocket: WebSocket):
|
| 25 |
+
"""Generates mock transaction stream, executes predictions, and pushes live JSON socket updates."""
|
| 26 |
+
try:
|
| 27 |
+
while True:
|
| 28 |
+
# Select random customer
|
| 29 |
+
if not memory_customers:
|
| 30 |
+
cust_id = "19999"
|
| 31 |
+
cust = {
|
| 32 |
+
"id": cust_id,
|
| 33 |
+
"recency": 45,
|
| 34 |
+
"frequency": 3,
|
| 35 |
+
"monetary": 120.0,
|
| 36 |
+
"basketSize": 4.5,
|
| 37 |
+
"isUk": 1,
|
| 38 |
+
"avgDaysBetween": 30.0
|
| 39 |
+
}
|
| 40 |
+
else:
|
| 41 |
+
cust_id = random.choice(list(memory_customers.keys()))
|
| 42 |
+
cust = memory_customers[cust_id]
|
| 43 |
+
|
| 44 |
+
# Simulate transaction values
|
| 45 |
+
invoice_value = round(random.uniform(15.0, 250.0), 2)
|
| 46 |
+
quantity = random.randint(1, 10)
|
| 47 |
+
|
| 48 |
+
old_freq = cust.get("frequency", 3)
|
| 49 |
+
old_mon = cust.get("monetary", 100.0)
|
| 50 |
+
old_basket = cust.get("basketSize", cust.get("basket_size", 4.0))
|
| 51 |
+
is_uk = cust.get("isUk", cust.get("is_uk", 1))
|
| 52 |
+
avg_days = cust.get("avgDaysBetween", cust.get("avg_days_between", 30.0))
|
| 53 |
+
|
| 54 |
+
new_freq = old_freq + 1
|
| 55 |
+
new_mon = (old_mon * old_freq + invoice_value) / new_freq
|
| 56 |
+
new_basket = (old_basket * old_freq + quantity) / new_freq
|
| 57 |
+
|
| 58 |
+
# Update memory cache
|
| 59 |
+
cust["recency"] = 0
|
| 60 |
+
cust["frequency"] = new_freq
|
| 61 |
+
cust["monetary"] = new_mon
|
| 62 |
+
cust["basketSize"] = new_basket
|
| 63 |
+
memory_customers[cust_id] = cust
|
| 64 |
+
|
| 65 |
+
# Features ordering: Recency, Frequency, Monetary, AvgBucketSize, AvgDaysBetween, Recency_to_AvgDaysRatio, Recent_Orders_Ratio, Is_UK
|
| 66 |
+
features = np.array([[
|
| 67 |
+
0.0,
|
| 68 |
+
new_freq,
|
| 69 |
+
new_mon,
|
| 70 |
+
new_basket,
|
| 71 |
+
avg_days,
|
| 72 |
+
0.0,
|
| 73 |
+
1.0,
|
| 74 |
+
is_uk
|
| 75 |
+
]])
|
| 76 |
+
|
| 77 |
+
champion_prob = 0.15
|
| 78 |
+
challenger_prob = 0.20
|
| 79 |
+
|
| 80 |
+
# Model predicts
|
| 81 |
+
if champion_model is not None:
|
| 82 |
+
try:
|
| 83 |
+
champion_prob = float(champion_model.predict_proba(features)[:, 1][0])
|
| 84 |
+
except Exception as ex:
|
| 85 |
+
logger.error(f"XGBoost WS prediction failed: {str(ex)}")
|
| 86 |
+
|
| 87 |
+
if challenger_model is not None:
|
| 88 |
+
try:
|
| 89 |
+
df_features = pd.DataFrame(features, columns=[
|
| 90 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 91 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 92 |
+
])
|
| 93 |
+
challenger_prob = float(challenger_model.predict_proba(df_features)[:, 1][0])
|
| 94 |
+
except Exception as ex:
|
| 95 |
+
logger.error(f"RF WS prediction failed: {str(ex)}")
|
| 96 |
+
|
| 97 |
+
if champion_prob >= 0.70:
|
| 98 |
+
new_risk_tier = "High Risk"
|
| 99 |
+
elif champion_prob >= 0.30:
|
| 100 |
+
new_risk_tier = "Medium Risk"
|
| 101 |
+
else:
|
| 102 |
+
new_risk_tier = "Low Risk"
|
| 103 |
+
|
| 104 |
+
# Log predictions
|
| 105 |
+
log_inference(0.0, new_freq, new_mon, new_basket)
|
| 106 |
+
log_shadow_prediction(0.0, new_freq, new_mon, new_basket, champion_prob, challenger_prob)
|
| 107 |
+
|
| 108 |
+
payload = {
|
| 109 |
+
"id": cust_id,
|
| 110 |
+
"type": "TRANSACTION",
|
| 111 |
+
"invoiceValue": invoice_value,
|
| 112 |
+
"quantity": quantity,
|
| 113 |
+
"newMetrics": {
|
| 114 |
+
"recency": 0,
|
| 115 |
+
"frequency": int(new_freq),
|
| 116 |
+
"monetary": round(float(new_mon), 2),
|
| 117 |
+
"basketSize": round(float(new_basket), 1),
|
| 118 |
+
"churnProb": round(float(champion_prob), 4),
|
| 119 |
+
"riskTier": new_risk_tier
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
await websocket.send_json(payload)
|
| 124 |
+
await asyncio.sleep(random.uniform(3.0, 6.0))
|
| 125 |
+
|
| 126 |
+
except WebSocketDisconnect:
|
| 127 |
+
pass
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error streaming live transactions: {str(e)}")
|
| 130 |
+
|
| 131 |
+
ws_manager = WebSocketManager()
|
src/config_loader.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import yaml
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
# Determine the absolute project root directory
|
| 6 |
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 7 |
+
|
| 8 |
+
def load_config() -> Dict[str, Any]:
|
| 9 |
+
"""Loads and parses the config.yaml file, resolving relative paths to absolute ones."""
|
| 10 |
+
config_path = os.path.join(PROJECT_ROOT, "config.yaml")
|
| 11 |
+
if not os.path.exists(config_path):
|
| 12 |
+
raise FileNotFoundError(f"Configuration file not found at {config_path}")
|
| 13 |
+
|
| 14 |
+
with open(config_path, 'r') as f:
|
| 15 |
+
config = yaml.safe_load(f)
|
| 16 |
+
|
| 17 |
+
# Dynamically resolve relative paths to absolute paths
|
| 18 |
+
if "paths" in config:
|
| 19 |
+
for key, rel_path in config["paths"].items():
|
| 20 |
+
config["paths"][key] = os.path.abspath(os.path.join(PROJECT_ROOT, rel_path))
|
| 21 |
+
|
| 22 |
+
return config
|
| 23 |
+
|
| 24 |
+
# Load configuration dynamically on import
|
| 25 |
+
CONFIG = load_config()
|
src/export_json.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import json
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
# Ensure project root is in path
|
| 8 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 9 |
+
if project_root not in sys.path:
|
| 10 |
+
sys.path.append(project_root)
|
| 11 |
+
|
| 12 |
+
from src.config_loader import CONFIG
|
| 13 |
+
from src.logger_config import logger
|
| 14 |
+
|
| 15 |
+
def export_data() -> None:
|
| 16 |
+
"""Formats churn model prediction outcomes into browser-ready JSON assets for the dashboard."""
|
| 17 |
+
REPORT_PATH = CONFIG["paths"]["predictions_report"]
|
| 18 |
+
|
| 19 |
+
# Target folders inside React project
|
| 20 |
+
DATA_DIR = os.path.join(project_root, "dashboard", "public", "data")
|
| 21 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 22 |
+
|
| 23 |
+
SUMMARY_OUT = os.path.join(DATA_DIR, "summary.json")
|
| 24 |
+
CUSTOMERS_OUT = os.path.join(DATA_DIR, "customers.json")
|
| 25 |
+
|
| 26 |
+
# Fallback to run prediction script if missing
|
| 27 |
+
if not os.path.exists(REPORT_PATH):
|
| 28 |
+
logger.warning(f"Report file not found at {REPORT_PATH}. Running predict.py first...")
|
| 29 |
+
from src.predict import run_predictions
|
| 30 |
+
run_predictions()
|
| 31 |
+
|
| 32 |
+
logger.info("Reading prediction outputs for JSON export...")
|
| 33 |
+
try:
|
| 34 |
+
df = pd.read_csv(REPORT_PATH)
|
| 35 |
+
|
| 36 |
+
# Calculate stats
|
| 37 |
+
total_customers = len(df)
|
| 38 |
+
|
| 39 |
+
risk_counts = df['Risk_Tier'].value_counts()
|
| 40 |
+
high_risk_count = int(risk_counts.get('High Risk', 0))
|
| 41 |
+
med_risk_count = int(risk_counts.get('Medium Risk', 0))
|
| 42 |
+
low_risk_count = int(risk_counts.get('Low Risk', 0))
|
| 43 |
+
|
| 44 |
+
# Revenue at Risk (High Risk customers)
|
| 45 |
+
high_risk_df = df[df['Risk_Tier'] == 'High Risk']
|
| 46 |
+
high_risk_df_copy = high_risk_df.copy()
|
| 47 |
+
high_risk_df_copy['TotalValue'] = high_risk_df_copy['Monetary'] * high_risk_df_copy['Frequency']
|
| 48 |
+
revenue_at_risk = float(high_risk_df_copy['TotalValue'].sum())
|
| 49 |
+
|
| 50 |
+
# Calculate segments count
|
| 51 |
+
segment_counts = df['Segment'].value_counts().to_dict()
|
| 52 |
+
|
| 53 |
+
# Churn risk per segment
|
| 54 |
+
segment_churn: Dict[str, Dict[str, Any]] = {}
|
| 55 |
+
for seg in df['Segment'].unique():
|
| 56 |
+
seg_df = df[df['Segment'] == seg]
|
| 57 |
+
total_seg = len(seg_df)
|
| 58 |
+
if total_seg > 0:
|
| 59 |
+
high_risk_seg = len(seg_df[seg_df['Risk_Tier'] == 'High Risk'])
|
| 60 |
+
segment_churn[seg] = {
|
| 61 |
+
"total": total_seg,
|
| 62 |
+
"high_risk": high_risk_seg,
|
| 63 |
+
"high_risk_pct": float(high_risk_seg / total_seg)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
summary_data = {
|
| 67 |
+
"total_customers": total_customers,
|
| 68 |
+
"revenue_at_risk": round(revenue_at_risk, 2),
|
| 69 |
+
"risk_tiers": {
|
| 70 |
+
"High Risk": high_risk_count,
|
| 71 |
+
"Medium Risk": med_risk_count,
|
| 72 |
+
"Low Risk": low_risk_count
|
| 73 |
+
},
|
| 74 |
+
"segment_distribution": {str(k): int(v) for k, v in segment_counts.items()},
|
| 75 |
+
"segment_churn": segment_churn
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
logger.info(f"Saving summary statistics to {SUMMARY_OUT}...")
|
| 79 |
+
with open(SUMMARY_OUT, 'w', encoding='utf-8') as f:
|
| 80 |
+
json.dump(summary_data, f, indent=2)
|
| 81 |
+
|
| 82 |
+
logger.info("Formatting individual customer records for frontend table...")
|
| 83 |
+
customers_list = []
|
| 84 |
+
for _, row in df.iterrows():
|
| 85 |
+
customers_list.append({
|
| 86 |
+
"id": str(row['Customer ID']),
|
| 87 |
+
"recency": int(row['Recency']),
|
| 88 |
+
"frequency": int(row['Frequency']),
|
| 89 |
+
"monetary": round(float(row['Monetary']), 2),
|
| 90 |
+
"basketSize": round(float(row['AvgBucketSize']), 2),
|
| 91 |
+
"churnProb": round(float(row['Churn_Probability']), 4),
|
| 92 |
+
"riskTier": str(row['Risk_Tier']),
|
| 93 |
+
"segment": str(row['Segment']),
|
| 94 |
+
"recommendation": str(row['Actionable_Recommendation']),
|
| 95 |
+
"shapValues": {
|
| 96 |
+
"Recency": round(float(row['SHAP_Recency']), 4),
|
| 97 |
+
"Frequency": round(float(row['SHAP_Frequency']), 4),
|
| 98 |
+
"Monetary": round(float(row['SHAP_Monetary']), 4),
|
| 99 |
+
"AvgBucketSize": round(float(row['SHAP_AvgBucketSize']), 4),
|
| 100 |
+
"AvgDaysBetween": round(float(row['SHAP_AvgDaysBetween']), 4),
|
| 101 |
+
"Recency_to_AvgDaysRatio": round(float(row['SHAP_Recency_to_AvgDaysRatio']), 4),
|
| 102 |
+
"Recent_Orders_Ratio": round(float(row['SHAP_Recent_Orders_Ratio']), 4),
|
| 103 |
+
"Is_UK": round(float(row['SHAP_Is_UK']), 4)
|
| 104 |
+
}
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
logger.info(f"Saving {len(customers_list)} formatted records to {CUSTOMERS_OUT}...")
|
| 108 |
+
with open(CUSTOMERS_OUT, 'w', encoding='utf-8') as f:
|
| 109 |
+
json.dump(customers_list, f) # No indent to minimize payload size
|
| 110 |
+
|
| 111 |
+
logger.info("JSON Data Export Completed successfully!")
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"JSON export failed with error: {str(e)}")
|
| 115 |
+
raise e
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
export_data()
|
src/logger_config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 6 |
+
|
| 7 |
+
def setup_logger() -> logging.Logger:
|
| 8 |
+
"""Configures structured logging to both standard output and log file."""
|
| 9 |
+
log_dir = os.path.join(PROJECT_ROOT, "logs")
|
| 10 |
+
os.makedirs(log_dir, exist_ok=True)
|
| 11 |
+
|
| 12 |
+
log_file = os.path.join(log_dir, "customer_analytics.log")
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("customer_analytics")
|
| 15 |
+
logger.setLevel(logging.INFO)
|
| 16 |
+
|
| 17 |
+
# Avoid duplicate handlers if imported multiple times
|
| 18 |
+
if not logger.handlers:
|
| 19 |
+
formatter = logging.Formatter(
|
| 20 |
+
'[%(asctime)s] %(levelname)s [%(filename)s:%(lineno)d] %(message)s',
|
| 21 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# File Handler
|
| 25 |
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
| 26 |
+
file_handler.setFormatter(formatter)
|
| 27 |
+
logger.addHandler(file_handler)
|
| 28 |
+
|
| 29 |
+
# Console Handler
|
| 30 |
+
console_handler = logging.StreamHandler()
|
| 31 |
+
console_handler.setFormatter(formatter)
|
| 32 |
+
logger.addHandler(console_handler)
|
| 33 |
+
|
| 34 |
+
return logger
|
| 35 |
+
|
| 36 |
+
# Globally available logger instance
|
| 37 |
+
logger = setup_logger()
|
src/make_dataset.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from typing import NoReturn
|
| 5 |
+
|
| 6 |
+
# Ensure project root is in path
|
| 7 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 8 |
+
if project_root not in sys.path:
|
| 9 |
+
sys.path.append(project_root)
|
| 10 |
+
|
| 11 |
+
from src.config_loader import CONFIG
|
| 12 |
+
from src.logger_config import logger
|
| 13 |
+
|
| 14 |
+
def load_and_clean_data(raw_file_path: str, output_path: str) -> None:
|
| 15 |
+
"""Loads raw Excel transaction logs, applies data cleaning pipeline, and saves processed CSV.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
raw_file_path: Path to the raw Excel dataset.
|
| 19 |
+
output_path: Path to save the cleaned CSV dataset.
|
| 20 |
+
"""
|
| 21 |
+
logger.info("Loading Raw Excel Data...")
|
| 22 |
+
if not os.path.exists(raw_file_path):
|
| 23 |
+
logger.error(f"Raw file not found: {raw_file_path}")
|
| 24 |
+
raise FileNotFoundError(f"File not found at {raw_file_path}")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
df = pd.read_excel(raw_file_path, sheet_name='Year 2009-2010')
|
| 28 |
+
logger.info(f"Successfully loaded raw data. Row count: {len(df)}")
|
| 29 |
+
|
| 30 |
+
logger.info("Starting cleaning pipeline: dropping missing customer IDs...")
|
| 31 |
+
df_clean = df.dropna(subset=['Customer ID']).copy()
|
| 32 |
+
|
| 33 |
+
logger.info("Filtering out refunds and returns (Quantity/Price <= 0)...")
|
| 34 |
+
df_clean = df_clean[(df_clean['Quantity'] > 0) & (df_clean['Price'] > 0)]
|
| 35 |
+
|
| 36 |
+
logger.info("Formatting customer IDs and datetimes...")
|
| 37 |
+
df_clean['Customer ID'] = df_clean['Customer ID'].astype(int).astype(str)
|
| 38 |
+
df_clean['InvoiceDate'] = pd.to_datetime(df_clean['InvoiceDate'])
|
| 39 |
+
|
| 40 |
+
logger.info("Calculating total transactional revenue per row...")
|
| 41 |
+
df_clean['Total Price'] = df_clean['Quantity'] * df_clean['Price']
|
| 42 |
+
|
| 43 |
+
# Ensure parent directory exists
|
| 44 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 45 |
+
|
| 46 |
+
logger.info(f"Saving cleaned dataset to {output_path}...")
|
| 47 |
+
df_clean.to_csv(output_path, index=False)
|
| 48 |
+
logger.info("Data loading and cleaning pipeline complete!")
|
| 49 |
+
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Data cleaning failed with error: {str(e)}")
|
| 52 |
+
raise e
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
RAW_PATH = CONFIG["paths"]["raw_data"]
|
| 56 |
+
PROCESSED_PATH = CONFIG["paths"]["clean_data"]
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
load_and_clean_data(RAW_PATH, PROCESSED_PATH)
|
| 60 |
+
except Exception:
|
| 61 |
+
sys.exit(1)
|
src/predict.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
import joblib
|
| 6 |
+
import xgboost as xgb
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
# Ensure project root is in the path
|
| 10 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 11 |
+
if project_root not in sys.path:
|
| 12 |
+
sys.path.append(project_root)
|
| 13 |
+
|
| 14 |
+
from src.config_loader import CONFIG
|
| 15 |
+
from src.logger_config import logger
|
| 16 |
+
from src.make_dataset import load_and_clean_data
|
| 17 |
+
|
| 18 |
+
def run_predictions() -> None:
|
| 19 |
+
"""Executes customer churn forecasts, merges segments, and exports marketing reports."""
|
| 20 |
+
RAW_PATH = CONFIG["paths"]["raw_data"]
|
| 21 |
+
PROCESSED_PATH = CONFIG["paths"]["clean_data"]
|
| 22 |
+
MODEL_PATH = CONFIG["paths"]["model"]
|
| 23 |
+
SEGMENTS_PATH = CONFIG["paths"]["segments"]
|
| 24 |
+
|
| 25 |
+
OUTPUT_REPORT_PATH = CONFIG["paths"]["predictions_report"]
|
| 26 |
+
OUTPUT_TARGET_PATH = CONFIG["paths"]["high_value_report"]
|
| 27 |
+
|
| 28 |
+
# Step 1: Ensure Clean Data Exists
|
| 29 |
+
if not os.path.exists(PROCESSED_PATH):
|
| 30 |
+
logger.warning(f"Cleaned dataset not found at {PROCESSED_PATH}. Running loader...")
|
| 31 |
+
if not os.path.exists(RAW_PATH):
|
| 32 |
+
logger.error(f"Raw data file not found at {RAW_PATH}. Cannot proceed.")
|
| 33 |
+
raise FileNotFoundError(f"Raw data file not found at {RAW_PATH}")
|
| 34 |
+
load_and_clean_data(RAW_PATH, PROCESSED_PATH)
|
| 35 |
+
|
| 36 |
+
logger.info("Loading cleaned dataset...")
|
| 37 |
+
try:
|
| 38 |
+
df = pd.read_csv(PROCESSED_PATH)
|
| 39 |
+
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
|
| 40 |
+
|
| 41 |
+
# Step 2: Feature Engineering (Full History Snapshot)
|
| 42 |
+
logger.info("Engineering customer RFM features...")
|
| 43 |
+
today = df['InvoiceDate'].max()
|
| 44 |
+
logger.info(f"Current snapshot date (Reference 'Today'): {today.strftime('%Y-%m-%d')}")
|
| 45 |
+
|
| 46 |
+
features = df.groupby('Customer ID').agg({
|
| 47 |
+
'InvoiceDate': lambda x: (today - x.max()).days, # Recency
|
| 48 |
+
'Invoice': 'nunique', # Frequency
|
| 49 |
+
'Total Price': 'mean', # Avg Monetary Spend
|
| 50 |
+
'Quantity': 'mean' # Avg Basket Size
|
| 51 |
+
}).reset_index()
|
| 52 |
+
|
| 53 |
+
features.rename(columns={
|
| 54 |
+
'InvoiceDate': 'Recency',
|
| 55 |
+
'Invoice': 'Frequency',
|
| 56 |
+
'Total Price': 'Monetary',
|
| 57 |
+
'Quantity': 'AvgBucketSize'
|
| 58 |
+
}, inplace=True)
|
| 59 |
+
|
| 60 |
+
features['Customer ID'] = features['Customer ID'].astype(str)
|
| 61 |
+
|
| 62 |
+
# 1. Inter-purchase Time (AvgDaysBetween)
|
| 63 |
+
logger.info("Calculating average days between purchases...")
|
| 64 |
+
df_sorted = df.sort_values(['Customer ID', 'InvoiceDate'])
|
| 65 |
+
invoices = df_sorted.drop_duplicates(subset=['Customer ID', 'Invoice']).copy()
|
| 66 |
+
invoices['PrevInvoiceDate'] = invoices.groupby('Customer ID')['InvoiceDate'].shift(1)
|
| 67 |
+
invoices['DaysBetween'] = (invoices['InvoiceDate'] - invoices['PrevInvoiceDate']).dt.days
|
| 68 |
+
|
| 69 |
+
avg_days_between = invoices.groupby('Customer ID')['DaysBetween'].mean().reset_index()
|
| 70 |
+
avg_days_between.rename(columns={'DaysBetween': 'AvgDaysBetween'}, inplace=True)
|
| 71 |
+
avg_days_between['Customer ID'] = avg_days_between['Customer ID'].astype(str)
|
| 72 |
+
|
| 73 |
+
features = pd.merge(features, avg_days_between, on='Customer ID', how='left')
|
| 74 |
+
|
| 75 |
+
single_buyer_impute = CONFIG["parameters"]["single_order_imputation_days"]
|
| 76 |
+
features['AvgDaysBetween'] = features['AvgDaysBetween'].fillna(single_buyer_impute)
|
| 77 |
+
|
| 78 |
+
# 2. Recency to AvgDaysBetween Ratio
|
| 79 |
+
features['Recency_to_AvgDaysRatio'] = features['Recency'] / (features['AvgDaysBetween'] + 1e-5)
|
| 80 |
+
|
| 81 |
+
# 3. Recent Orders Ratio (last 60 days)
|
| 82 |
+
logger.info("Calculating order frequency ratios in recent days...")
|
| 83 |
+
recent_window = CONFIG["parameters"]["recent_purchase_window_days"]
|
| 84 |
+
recent_cutoff = today - pd.DateOffset(days=recent_window)
|
| 85 |
+
recent_invoices = df[df['InvoiceDate'] >= recent_cutoff].groupby('Customer ID')['Invoice'].nunique().reset_index()
|
| 86 |
+
recent_invoices.rename(columns={'Invoice': 'RecentInvoices'}, inplace=True)
|
| 87 |
+
recent_invoices['Customer ID'] = recent_invoices['Customer ID'].astype(str)
|
| 88 |
+
|
| 89 |
+
features = pd.merge(features, recent_invoices, on='Customer ID', how='left')
|
| 90 |
+
features['RecentInvoices'] = features['RecentInvoices'].fillna(0)
|
| 91 |
+
features['Recent_Orders_Ratio'] = features['RecentInvoices'] / features['Frequency']
|
| 92 |
+
features.drop(columns=['RecentInvoices'], inplace=True)
|
| 93 |
+
|
| 94 |
+
# 4. Is UK Customer
|
| 95 |
+
customer_country = df.groupby('Customer ID')['Country'].first().reset_index()
|
| 96 |
+
customer_country['Customer ID'] = customer_country['Customer ID'].astype(str)
|
| 97 |
+
customer_country['Is_UK'] = (customer_country['Country'] == 'United Kingdom').astype(int)
|
| 98 |
+
|
| 99 |
+
features = pd.merge(features, customer_country[['Customer ID', 'Is_UK']], on='Customer ID', how='left')
|
| 100 |
+
|
| 101 |
+
# Step 3: Loading Model and Predicting
|
| 102 |
+
logger.info(f"Loading trained XGBoost model from {MODEL_PATH}...")
|
| 103 |
+
if not os.path.exists(MODEL_PATH):
|
| 104 |
+
logger.error("Serialized model file missing.")
|
| 105 |
+
raise FileNotFoundError(f"Model file not found at {MODEL_PATH}. Please train the model first.")
|
| 106 |
+
|
| 107 |
+
model = joblib.load(MODEL_PATH)
|
| 108 |
+
|
| 109 |
+
feature_cols = [
|
| 110 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 111 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 112 |
+
]
|
| 113 |
+
X = features[feature_cols]
|
| 114 |
+
|
| 115 |
+
logger.info("Running predictions...")
|
| 116 |
+
features['Churn_Probability'] = model.predict_proba(X)[:, 1]
|
| 117 |
+
|
| 118 |
+
logger.info("Calculating TreeSHAP contributions...")
|
| 119 |
+
booster = model.get_booster()
|
| 120 |
+
dmat = xgb.DMatrix(X, feature_names=feature_cols)
|
| 121 |
+
contribs = booster.predict(dmat, pred_contribs=True)
|
| 122 |
+
for i, col in enumerate(feature_cols):
|
| 123 |
+
features[f'SHAP_{col}'] = contribs[:, i]
|
| 124 |
+
|
| 125 |
+
# Define Risk Tiers
|
| 126 |
+
def get_risk_tier(prob: float) -> str:
|
| 127 |
+
if prob >= 0.70:
|
| 128 |
+
return "High Risk"
|
| 129 |
+
elif prob >= 0.30:
|
| 130 |
+
return "Medium Risk"
|
| 131 |
+
else:
|
| 132 |
+
return "Low Risk"
|
| 133 |
+
|
| 134 |
+
features['Risk_Tier'] = features['Churn_Probability'].apply(get_risk_tier)
|
| 135 |
+
|
| 136 |
+
# Step 4: Merging with Customer Segments
|
| 137 |
+
logger.info("Merging predictions with offline segments...")
|
| 138 |
+
if os.path.exists(SEGMENTS_PATH):
|
| 139 |
+
segments_df = pd.read_csv(SEGMENTS_PATH)[['Customer ID', 'Segment']]
|
| 140 |
+
segments_df['Customer ID'] = segments_df['Customer ID'].astype(str)
|
| 141 |
+
|
| 142 |
+
merged_df = pd.merge(features, segments_df, on='Customer ID', how='left')
|
| 143 |
+
merged_df['Segment'] = merged_df['Segment'].fillna('New / Unclassified')
|
| 144 |
+
else:
|
| 145 |
+
logger.warning(f"Segments database missing at {SEGMENTS_PATH}.")
|
| 146 |
+
merged_df = features.copy()
|
| 147 |
+
merged_df['Segment'] = 'Unclassified'
|
| 148 |
+
|
| 149 |
+
# Step 5: Assign Business Recommendations
|
| 150 |
+
logger.info("Generating targeted marketing recommendations...")
|
| 151 |
+
def get_recommendation(row: pd.Series) -> str:
|
| 152 |
+
segment = row['Segment']
|
| 153 |
+
tier = row['Risk_Tier']
|
| 154 |
+
|
| 155 |
+
if tier == "High Risk":
|
| 156 |
+
if "Champion" in segment:
|
| 157 |
+
return "At-Risk Champion: High historical spend. Assign a personal account manager for direct outreach. Do not send automated discount spam."
|
| 158 |
+
elif "Loyalist" in segment or "Loyal" in segment:
|
| 159 |
+
return "At-Risk Loyalist: Dedicated customer showing signs of leaving. Offer a special loyalty reward or high-value incentive."
|
| 160 |
+
elif "Hibernating" in segment or "About to Sleep" in segment:
|
| 161 |
+
return "Hibernating Win-back: Aggressive discount offer or 'We Miss You' promotion with limited validity to re-engage."
|
| 162 |
+
elif "New" in segment or "Promising" in segment:
|
| 163 |
+
return "Immediate Activation: One-time buyer showing low activity. Trigger welcome sequence or first-repeat-purchase incentive."
|
| 164 |
+
else:
|
| 165 |
+
return "Standard Re-engagement: Target with standard product updates and a mild discount."
|
| 166 |
+
elif tier == "Medium Risk":
|
| 167 |
+
if "Champion" in segment or "Loyal" in segment:
|
| 168 |
+
return "Proactive VIP Retention: High-value showing drop-off signs. Send customized recommendations based on past purchases. Avoid direct discount spam."
|
| 169 |
+
elif "Hibernating" in segment or "About to Sleep" in segment:
|
| 170 |
+
return "Nurture Campaign: Include in standard promotional newsletters and generic sale announcements."
|
| 171 |
+
else:
|
| 172 |
+
return "Standard Retention: Monitor activity. Send standard seasonal discount codes."
|
| 173 |
+
else: # Low Risk
|
| 174 |
+
if "Champion" in segment or "Loyal" in segment:
|
| 175 |
+
return "Maintain & Protect: Do nothing. Keep regular service quality high. Exclude from aggressive discount lists to preserve margin."
|
| 176 |
+
else:
|
| 177 |
+
return "Standard Relationship Management: Keep engaged with standard updates."
|
| 178 |
+
|
| 179 |
+
merged_df['Actionable_Recommendation'] = merged_df.apply(get_recommendation, axis=1)
|
| 180 |
+
|
| 181 |
+
# Step 6: Exporting Reports
|
| 182 |
+
logger.info("Exporting CSV reports...")
|
| 183 |
+
merged_df = merged_df.sort_values(by='Churn_Probability', ascending=False)
|
| 184 |
+
|
| 185 |
+
os.makedirs(os.path.dirname(OUTPUT_REPORT_PATH), exist_ok=True)
|
| 186 |
+
merged_df.to_csv(OUTPUT_REPORT_PATH, index=False)
|
| 187 |
+
logger.info(f" -> Complete Churn Report saved to: {OUTPUT_REPORT_PATH}")
|
| 188 |
+
|
| 189 |
+
# Filter and export top 100 high-value high-risk customers
|
| 190 |
+
high_risk_df = merged_df[merged_df['Risk_Tier'] == 'High Risk']
|
| 191 |
+
high_value_at_risk = high_risk_df.sort_values(by='Monetary', ascending=False).head(100)
|
| 192 |
+
|
| 193 |
+
high_value_at_risk.to_csv(OUTPUT_TARGET_PATH, index=False)
|
| 194 |
+
logger.info(f" -> Top 100 High-Value At-Risk Customers saved to: {OUTPUT_TARGET_PATH}")
|
| 195 |
+
|
| 196 |
+
# Summary logging
|
| 197 |
+
logger.info("\n" + "="*50)
|
| 198 |
+
logger.info(" CHURN ANALYSIS SUMMARY")
|
| 199 |
+
logger.info("="*50)
|
| 200 |
+
logger.info(f"Total Customers Analyzed: {len(merged_df)}")
|
| 201 |
+
|
| 202 |
+
risk_counts = merged_df['Risk_Tier'].value_counts()
|
| 203 |
+
high_count = risk_counts.get('High Risk', 0)
|
| 204 |
+
med_count = risk_counts.get('Medium Risk', 0)
|
| 205 |
+
low_count = risk_counts.get('Low Risk', 0)
|
| 206 |
+
|
| 207 |
+
logger.info(f"High Risk (Churn Prob >= 70%): {high_count} ({high_count/len(merged_df):.1%})")
|
| 208 |
+
logger.info(f"Medium Risk (30% <= Prob < 70%): {med_count} ({med_count/len(merged_df):.1%})")
|
| 209 |
+
logger.info(f"Low Risk (Churn Prob < 30%): {low_count} ({low_count/len(merged_df):.1%})")
|
| 210 |
+
logger.info("-"*50)
|
| 211 |
+
|
| 212 |
+
high_risk_revenue = (high_risk_df['Monetary'] * high_risk_df['Frequency']).sum()
|
| 213 |
+
logger.info(f"Total Revenue At Risk (High Risk): ${high_risk_revenue:,.2f}")
|
| 214 |
+
logger.info("="*50 + "\n")
|
| 215 |
+
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Prediction flow failed with error: {str(e)}")
|
| 218 |
+
raise e
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
try:
|
| 222 |
+
run_predictions()
|
| 223 |
+
except Exception:
|
| 224 |
+
sys.exit(1)
|
src/train.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
import joblib
|
| 6 |
+
from sklearn.model_selection import train_test_split
|
| 7 |
+
from xgboost import XGBClassifier
|
| 8 |
+
from sklearn.metrics import classification_report
|
| 9 |
+
|
| 10 |
+
# Ensure project root is in path
|
| 11 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 12 |
+
if project_root not in sys.path:
|
| 13 |
+
sys.path.append(project_root)
|
| 14 |
+
|
| 15 |
+
from src.config_loader import CONFIG
|
| 16 |
+
from src.logger_config import logger
|
| 17 |
+
|
| 18 |
+
def train_churn_model(data_path: str, model_output_path: str) -> None:
|
| 19 |
+
"""Performs feature engineering on clean customer history, trains an XGBoost model,
|
| 20 |
+
|
| 21 |
+
saves the trained model `.pkl` to disk.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
data_path: Path to the clean CSV dataset.
|
| 25 |
+
model_output_path: Output path for the serialized XGBoost model.
|
| 26 |
+
"""
|
| 27 |
+
logger.info(f"Loading cleaned dataset from {data_path}...")
|
| 28 |
+
try:
|
| 29 |
+
df = pd.read_csv(data_path)
|
| 30 |
+
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
|
| 31 |
+
|
| 32 |
+
logger.info("Computing cutoff date for prediction window...")
|
| 33 |
+
cutoff_days = CONFIG["parameters"]["cutoff_offset_days"]
|
| 34 |
+
cutoff_date = df['InvoiceDate'].max() - pd.DateOffset(days=cutoff_days)
|
| 35 |
+
logger.info(f"Reference snapshot cutoff date: {cutoff_date.strftime('%Y-%m-%d')}")
|
| 36 |
+
|
| 37 |
+
logger.info("Splitting dataset into history vs target prediction window...")
|
| 38 |
+
train_data = df[df['InvoiceDate'] < cutoff_date].copy()
|
| 39 |
+
test_target_data = df[df['InvoiceDate'] >= cutoff_date].copy()
|
| 40 |
+
|
| 41 |
+
# Identify active customers (members of active list are non-churners)
|
| 42 |
+
active_customers = [str(int(x)) for x in test_target_data['Customer ID'].dropna().unique()]
|
| 43 |
+
|
| 44 |
+
logger.info("Engineering base RFM features...")
|
| 45 |
+
features = train_data.groupby('Customer ID').agg({
|
| 46 |
+
'InvoiceDate': lambda x: (cutoff_date - x.max()).days, # Recency
|
| 47 |
+
'Invoice': 'nunique', # Frequency
|
| 48 |
+
'Total Price': 'mean', # Avg Monetary
|
| 49 |
+
'Quantity': 'mean' # Avg Basket Size
|
| 50 |
+
}).reset_index()
|
| 51 |
+
|
| 52 |
+
features.rename(columns={
|
| 53 |
+
'InvoiceDate': 'Recency',
|
| 54 |
+
'Invoice': 'Frequency',
|
| 55 |
+
'Total Price': 'Monetary',
|
| 56 |
+
'Quantity': 'AvgBucketSize'
|
| 57 |
+
}, inplace=True)
|
| 58 |
+
|
| 59 |
+
features['Customer ID'] = features['Customer ID'].astype(str)
|
| 60 |
+
|
| 61 |
+
logger.info("Engineering advanced MLE features...")
|
| 62 |
+
|
| 63 |
+
# 1. Calculate Inter-purchase Time (AvgDaysBetween)
|
| 64 |
+
df_sorted = train_data.sort_values(['Customer ID', 'InvoiceDate'])
|
| 65 |
+
invoices = df_sorted.drop_duplicates(subset=['Customer ID', 'Invoice']).copy()
|
| 66 |
+
invoices['PrevInvoiceDate'] = invoices.groupby('Customer ID')['InvoiceDate'].shift(1)
|
| 67 |
+
invoices['DaysBetween'] = (invoices['InvoiceDate'] - invoices['PrevInvoiceDate']).dt.days
|
| 68 |
+
|
| 69 |
+
avg_days_between = invoices.groupby('Customer ID')['DaysBetween'].mean().reset_index()
|
| 70 |
+
avg_days_between.rename(columns={'DaysBetween': 'AvgDaysBetween'}, inplace=True)
|
| 71 |
+
avg_days_between['Customer ID'] = avg_days_between['Customer ID'].astype(str)
|
| 72 |
+
|
| 73 |
+
features = pd.merge(features, avg_days_between, on='Customer ID', how='left')
|
| 74 |
+
|
| 75 |
+
# Impute single purchase buyers with config default
|
| 76 |
+
single_buyer_impute = CONFIG["parameters"]["single_order_imputation_days"]
|
| 77 |
+
features['AvgDaysBetween'] = features['AvgDaysBetween'].fillna(single_buyer_impute)
|
| 78 |
+
|
| 79 |
+
# 2. Recency to AvgDaysBetween Ratio
|
| 80 |
+
features['Recency_to_AvgDaysRatio'] = features['Recency'] / (features['AvgDaysBetween'] + 1e-5)
|
| 81 |
+
|
| 82 |
+
# 3. Recent Orders Ratio (last 60 days of training window)
|
| 83 |
+
recent_window = CONFIG["parameters"]["recent_purchase_window_days"]
|
| 84 |
+
recent_cutoff = cutoff_date - pd.DateOffset(days=recent_window)
|
| 85 |
+
recent_invoices = train_data[train_data['InvoiceDate'] >= recent_cutoff].groupby('Customer ID')['Invoice'].nunique().reset_index()
|
| 86 |
+
recent_invoices.rename(columns={'Invoice': 'RecentInvoices'}, inplace=True)
|
| 87 |
+
recent_invoices['Customer ID'] = recent_invoices['Customer ID'].astype(str)
|
| 88 |
+
|
| 89 |
+
features = pd.merge(features, recent_invoices, on='Customer ID', how='left')
|
| 90 |
+
features['RecentInvoices'] = features['RecentInvoices'].fillna(0)
|
| 91 |
+
features['Recent_Orders_Ratio'] = features['RecentInvoices'] / features['Frequency']
|
| 92 |
+
features.drop(columns=['RecentInvoices'], inplace=True)
|
| 93 |
+
|
| 94 |
+
# 4. Is UK Customer
|
| 95 |
+
customer_country = train_data.groupby('Customer ID')['Country'].first().reset_index()
|
| 96 |
+
customer_country['Customer ID'] = customer_country['Customer ID'].astype(str)
|
| 97 |
+
customer_country['Is_UK'] = (customer_country['Country'] == 'United Kingdom').astype(int)
|
| 98 |
+
|
| 99 |
+
features = pd.merge(features, customer_country[['Customer ID', 'Is_UK']], on='Customer ID', how='left')
|
| 100 |
+
|
| 101 |
+
# Generate target labels
|
| 102 |
+
features['Is_Churn'] = features['Customer ID'].apply(lambda x: 0 if x in active_customers else 1)
|
| 103 |
+
logger.info(f"Target Label Generation complete. Churn class ratio: {features['Is_Churn'].mean():.2%}")
|
| 104 |
+
|
| 105 |
+
# Split features and labels
|
| 106 |
+
feature_cols = [
|
| 107 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 108 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 109 |
+
]
|
| 110 |
+
X = features[feature_cols]
|
| 111 |
+
y = features['Is_Churn']
|
| 112 |
+
|
| 113 |
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 114 |
+
|
| 115 |
+
logger.info("Initializing XGBoost classifier with config hyperparameters...")
|
| 116 |
+
hyperparams = CONFIG["model_hyperparameters"]
|
| 117 |
+
xgb = XGBClassifier(
|
| 118 |
+
n_estimators=hyperparams["n_estimators"],
|
| 119 |
+
learning_rate=hyperparams["learning_rate"],
|
| 120 |
+
max_depth=hyperparams["max_depth"],
|
| 121 |
+
subsample=hyperparams["subsample"],
|
| 122 |
+
colsample_bytree=hyperparams["colsample_bytree"],
|
| 123 |
+
scale_pos_weight=hyperparams["scale_pos_weight"],
|
| 124 |
+
random_state=hyperparams["random_state"]
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
logger.info("Fitting model on training set...")
|
| 128 |
+
xgb.fit(X_train, y_train)
|
| 129 |
+
|
| 130 |
+
logger.info("Evaluating model on validation hold-out set:")
|
| 131 |
+
y_pred = xgb.predict(X_test)
|
| 132 |
+
report = classification_report(y_test, y_pred)
|
| 133 |
+
logger.info(f"\n{report}")
|
| 134 |
+
|
| 135 |
+
# Ensure parent folder exists
|
| 136 |
+
os.makedirs(os.path.dirname(model_output_path), exist_ok=True)
|
| 137 |
+
|
| 138 |
+
logger.info(f"Saving serialized model to {model_output_path}...")
|
| 139 |
+
joblib.dump(xgb, model_output_path)
|
| 140 |
+
logger.info("Model training pipeline complete!")
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"Model training failed with error: {str(e)}")
|
| 144 |
+
raise e
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
DATA_PATH = CONFIG["paths"]["clean_data"]
|
| 148 |
+
MODEL_PATH = CONFIG["paths"]["model"]
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
train_churn_model(DATA_PATH, MODEL_PATH)
|
| 152 |
+
except Exception:
|
| 153 |
+
sys.exit(1)
|
src/train_challenger.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
import joblib
|
| 6 |
+
from sklearn.model_selection import train_test_split
|
| 7 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 8 |
+
from sklearn.metrics import classification_report
|
| 9 |
+
|
| 10 |
+
# Ensure project root is in path
|
| 11 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 12 |
+
if project_root not in sys.path:
|
| 13 |
+
sys.path.append(project_root)
|
| 14 |
+
|
| 15 |
+
from src.config_loader import CONFIG
|
| 16 |
+
from src.logger_config import logger
|
| 17 |
+
|
| 18 |
+
def train_challenger_rf(data_path: str, model_output_path: str) -> None:
|
| 19 |
+
"""Trains a Random Forest classifier as a Challenger model shadow candidate.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
data_path: Path to the clean CSV dataset.
|
| 23 |
+
model_output_path: Output path for the serialized RF model.
|
| 24 |
+
"""
|
| 25 |
+
logger.info(f"Loading cleaned dataset from {data_path} for Challenger training...")
|
| 26 |
+
try:
|
| 27 |
+
df = pd.read_csv(data_path)
|
| 28 |
+
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
|
| 29 |
+
|
| 30 |
+
cutoff_days = CONFIG["parameters"]["cutoff_offset_days"]
|
| 31 |
+
cutoff_date = df['InvoiceDate'].max() - pd.DateOffset(days=cutoff_days)
|
| 32 |
+
|
| 33 |
+
train_data = df[df['InvoiceDate'] < cutoff_date].copy()
|
| 34 |
+
test_target_data = df[df['InvoiceDate'] >= cutoff_date].copy()
|
| 35 |
+
|
| 36 |
+
active_customers = [str(int(x)) for x in test_target_data['Customer ID'].dropna().unique()]
|
| 37 |
+
|
| 38 |
+
# Aggregate features
|
| 39 |
+
features = train_data.groupby('Customer ID').agg({
|
| 40 |
+
'InvoiceDate': lambda x: (cutoff_date - x.max()).days,
|
| 41 |
+
'Invoice': 'nunique',
|
| 42 |
+
'Total Price': 'mean',
|
| 43 |
+
'Quantity': 'mean'
|
| 44 |
+
}).reset_index()
|
| 45 |
+
|
| 46 |
+
features.rename(columns={
|
| 47 |
+
'InvoiceDate': 'Recency',
|
| 48 |
+
'Invoice': 'Frequency',
|
| 49 |
+
'Total Price': 'Monetary',
|
| 50 |
+
'Quantity': 'AvgBucketSize'
|
| 51 |
+
}, inplace=True)
|
| 52 |
+
|
| 53 |
+
features['Customer ID'] = features['Customer ID'].astype(str)
|
| 54 |
+
|
| 55 |
+
# Advanced features
|
| 56 |
+
df_sorted = train_data.sort_values(['Customer ID', 'InvoiceDate'])
|
| 57 |
+
invoices = df_sorted.drop_duplicates(subset=['Customer ID', 'Invoice']).copy()
|
| 58 |
+
invoices['PrevInvoiceDate'] = invoices.groupby('Customer ID')['InvoiceDate'].shift(1)
|
| 59 |
+
invoices['DaysBetween'] = (invoices['InvoiceDate'] - invoices['PrevInvoiceDate']).dt.days
|
| 60 |
+
|
| 61 |
+
avg_days_between = invoices.groupby('Customer ID')['DaysBetween'].mean().reset_index()
|
| 62 |
+
avg_days_between.rename(columns={'DaysBetween': 'AvgDaysBetween'}, inplace=True)
|
| 63 |
+
avg_days_between['Customer ID'] = avg_days_between['Customer ID'].astype(str)
|
| 64 |
+
|
| 65 |
+
features = pd.merge(features, avg_days_between, on='Customer ID', how='left')
|
| 66 |
+
single_buyer_impute = CONFIG["parameters"]["single_order_imputation_days"]
|
| 67 |
+
features['AvgDaysBetween'] = features['AvgDaysBetween'].fillna(single_buyer_impute)
|
| 68 |
+
|
| 69 |
+
features['Recency_to_AvgDaysRatio'] = features['Recency'] / (features['AvgDaysBetween'] + 1e-5)
|
| 70 |
+
|
| 71 |
+
recent_window = CONFIG["parameters"]["recent_purchase_window_days"]
|
| 72 |
+
recent_cutoff = cutoff_date - pd.DateOffset(days=recent_window)
|
| 73 |
+
recent_invoices = train_data[train_data['InvoiceDate'] >= recent_cutoff].groupby('Customer ID')['Invoice'].nunique().reset_index()
|
| 74 |
+
recent_invoices.rename(columns={'Invoice': 'RecentInvoices'}, inplace=True)
|
| 75 |
+
recent_invoices['Customer ID'] = recent_invoices['Customer ID'].astype(str)
|
| 76 |
+
|
| 77 |
+
features = pd.merge(features, recent_invoices, on='Customer ID', how='left')
|
| 78 |
+
features['RecentInvoices'] = features['RecentInvoices'].fillna(0)
|
| 79 |
+
features['Recent_Orders_Ratio'] = features['RecentInvoices'] / features['Frequency']
|
| 80 |
+
features.drop(columns=['RecentInvoices'], inplace=True)
|
| 81 |
+
|
| 82 |
+
customer_country = train_data.groupby('Customer ID')['Country'].first().reset_index()
|
| 83 |
+
customer_country['Customer ID'] = customer_country['Customer ID'].astype(str)
|
| 84 |
+
customer_country['Is_UK'] = (customer_country['Country'] == 'United Kingdom').astype(int)
|
| 85 |
+
|
| 86 |
+
features = pd.merge(features, customer_country[['Customer ID', 'Is_UK']], on='Customer ID', how='left')
|
| 87 |
+
|
| 88 |
+
features['Is_Churn'] = features['Customer ID'].apply(lambda x: 0 if x in active_customers else 1)
|
| 89 |
+
|
| 90 |
+
feature_cols = [
|
| 91 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 92 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 93 |
+
]
|
| 94 |
+
X = features[feature_cols]
|
| 95 |
+
y = features['Is_Churn']
|
| 96 |
+
|
| 97 |
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 98 |
+
|
| 99 |
+
logger.info("Initializing RandomForest challenger classifier...")
|
| 100 |
+
rf = RandomForestClassifier(
|
| 101 |
+
n_estimators=100,
|
| 102 |
+
max_depth=6,
|
| 103 |
+
class_weight="balanced",
|
| 104 |
+
random_state=42
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
logger.info("Fitting Random Forest challenger on training set...")
|
| 108 |
+
rf.fit(X_train, y_train)
|
| 109 |
+
|
| 110 |
+
logger.info("Evaluating Challenger model on hold-out validation:")
|
| 111 |
+
y_pred = rf.predict(X_test)
|
| 112 |
+
report = classification_report(y_test, y_pred)
|
| 113 |
+
logger.info(f"\n{report}")
|
| 114 |
+
|
| 115 |
+
os.makedirs(os.path.dirname(model_output_path), exist_ok=True)
|
| 116 |
+
logger.info(f"Saving serialized Random Forest challenger model to {model_output_path}...")
|
| 117 |
+
joblib.dump(rf, model_output_path)
|
| 118 |
+
logger.info("Challenger RF model training complete!")
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Challenger training failed: {str(e)}")
|
| 122 |
+
raise e
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
DATA_PATH = CONFIG["paths"]["clean_data"]
|
| 126 |
+
MODEL_OUT = os.path.join(project_root, "models", "churn_rf_model.pkl")
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
train_challenger_rf(DATA_PATH, MODEL_OUT)
|
| 130 |
+
except Exception:
|
| 131 |
+
sys.exit(1)
|
src/validate_retraining.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
import joblib
|
| 6 |
+
from sklearn.model_selection import train_test_split
|
| 7 |
+
from sklearn.metrics import recall_score, classification_report
|
| 8 |
+
|
| 9 |
+
# Ensure project root is in path
|
| 10 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 11 |
+
if project_root not in sys.path:
|
| 12 |
+
sys.path.append(project_root)
|
| 13 |
+
|
| 14 |
+
from src.config_loader import CONFIG
|
| 15 |
+
from src.logger_config import logger
|
| 16 |
+
|
| 17 |
+
def validate_model_performance(data_path: str, model_path: str, recall_threshold: float = 0.80) -> bool:
|
| 18 |
+
"""Evaluates the trained model recall performance on holdout test set to gate deployment.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
data_path: Path to the clean CSV dataset.
|
| 22 |
+
model_path: Path to the serialized XGBoost model.
|
| 23 |
+
recall_threshold: Minimum acceptable recall score on the churn class.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
bool: True if the model performance meets or exceeds the threshold, False otherwise.
|
| 27 |
+
"""
|
| 28 |
+
logger.info("Initializing model performance validation check...")
|
| 29 |
+
|
| 30 |
+
if not os.path.exists(model_path):
|
| 31 |
+
logger.error(f"Model binary not found at {model_path}. Cannot validate.")
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
df = pd.read_csv(data_path)
|
| 36 |
+
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
|
| 37 |
+
|
| 38 |
+
cutoff_days = CONFIG["parameters"]["cutoff_offset_days"]
|
| 39 |
+
cutoff_date = df['InvoiceDate'].max() - pd.DateOffset(days=cutoff_days)
|
| 40 |
+
|
| 41 |
+
train_data = df[df['InvoiceDate'] < cutoff_date].copy()
|
| 42 |
+
test_target_data = df[df['InvoiceDate'] >= cutoff_date].copy()
|
| 43 |
+
|
| 44 |
+
active_customers = [str(int(x)) for x in test_target_data['Customer ID'].dropna().unique()]
|
| 45 |
+
|
| 46 |
+
# Aggregations
|
| 47 |
+
features = train_data.groupby('Customer ID').agg({
|
| 48 |
+
'InvoiceDate': lambda x: (cutoff_date - x.max()).days,
|
| 49 |
+
'Invoice': 'nunique',
|
| 50 |
+
'Total Price': 'mean',
|
| 51 |
+
'Quantity': 'mean'
|
| 52 |
+
}).reset_index()
|
| 53 |
+
|
| 54 |
+
features.rename(columns={
|
| 55 |
+
'InvoiceDate': 'Recency',
|
| 56 |
+
'Invoice': 'Frequency',
|
| 57 |
+
'Total Price': 'Monetary',
|
| 58 |
+
'Quantity': 'AvgBucketSize'
|
| 59 |
+
}, inplace=True)
|
| 60 |
+
|
| 61 |
+
features['Customer ID'] = features['Customer ID'].astype(str)
|
| 62 |
+
|
| 63 |
+
# Advanced Features
|
| 64 |
+
df_sorted = train_data.sort_values(['Customer ID', 'InvoiceDate'])
|
| 65 |
+
invoices = df_sorted.drop_duplicates(subset=['Customer ID', 'Invoice']).copy()
|
| 66 |
+
invoices['PrevInvoiceDate'] = invoices.groupby('Customer ID')['InvoiceDate'].shift(1)
|
| 67 |
+
invoices['DaysBetween'] = (invoices['InvoiceDate'] - invoices['PrevInvoiceDate']).dt.days
|
| 68 |
+
|
| 69 |
+
avg_days_between = invoices.groupby('Customer ID')['DaysBetween'].mean().reset_index()
|
| 70 |
+
avg_days_between.rename(columns={'DaysBetween': 'AvgDaysBetween'}, inplace=True)
|
| 71 |
+
avg_days_between['Customer ID'] = avg_days_between['Customer ID'].astype(str)
|
| 72 |
+
|
| 73 |
+
features = pd.merge(features, avg_days_between, on='Customer ID', how='left')
|
| 74 |
+
single_buyer_impute = CONFIG["parameters"]["single_order_imputation_days"]
|
| 75 |
+
features['AvgDaysBetween'] = features['AvgDaysBetween'].fillna(single_buyer_impute)
|
| 76 |
+
|
| 77 |
+
features['Recency_to_AvgDaysRatio'] = features['Recency'] / (features['AvgDaysBetween'] + 1e-5)
|
| 78 |
+
|
| 79 |
+
recent_window = CONFIG["parameters"]["recent_purchase_window_days"]
|
| 80 |
+
recent_cutoff = cutoff_date - pd.DateOffset(days=recent_window)
|
| 81 |
+
recent_invoices = train_data[train_data['InvoiceDate'] >= recent_cutoff].groupby('Customer ID')['Invoice'].nunique().reset_index()
|
| 82 |
+
recent_invoices.rename(columns={'Invoice': 'RecentInvoices'}, inplace=True)
|
| 83 |
+
recent_invoices['Customer ID'] = recent_invoices['Customer ID'].astype(str)
|
| 84 |
+
|
| 85 |
+
features = pd.merge(features, recent_invoices, on='Customer ID', how='left')
|
| 86 |
+
features['RecentInvoices'] = features['RecentInvoices'].fillna(0)
|
| 87 |
+
features['Recent_Orders_Ratio'] = features['RecentInvoices'] / features['Frequency']
|
| 88 |
+
features.drop(columns=['RecentInvoices'], inplace=True)
|
| 89 |
+
|
| 90 |
+
customer_country = train_data.groupby('Customer ID')['Country'].first().reset_index()
|
| 91 |
+
customer_country['Customer ID'] = customer_country['Customer ID'].astype(str)
|
| 92 |
+
customer_country['Is_UK'] = (customer_country['Country'] == 'United Kingdom').astype(int)
|
| 93 |
+
|
| 94 |
+
features = pd.merge(features, customer_country[['Customer ID', 'Is_UK']], on='Customer ID', how='left')
|
| 95 |
+
features['Is_Churn'] = features['Customer ID'].apply(lambda x: 0 if x in active_customers else 1)
|
| 96 |
+
|
| 97 |
+
feature_cols = [
|
| 98 |
+
'Recency', 'Frequency', 'Monetary', 'AvgBucketSize',
|
| 99 |
+
'AvgDaysBetween', 'Recency_to_AvgDaysRatio', 'Recent_Orders_Ratio', 'Is_UK'
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
X = features[feature_cols]
|
| 103 |
+
y = features['Is_Churn']
|
| 104 |
+
|
| 105 |
+
# Validation Hold-out Split
|
| 106 |
+
_, X_test, _, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 107 |
+
|
| 108 |
+
# Load active model
|
| 109 |
+
model = joblib.load(model_path)
|
| 110 |
+
|
| 111 |
+
# Predict on holdout
|
| 112 |
+
y_pred = model.predict(X_test)
|
| 113 |
+
|
| 114 |
+
# Compute Recall for Churn (label=1)
|
| 115 |
+
recall = recall_score(y_test, y_pred)
|
| 116 |
+
|
| 117 |
+
logger.info(f"Retrained Model validation result: Recall = {recall:.2%}")
|
| 118 |
+
logger.info(f"Target Performance Threshold: Recall >= {recall_threshold:.2%}")
|
| 119 |
+
logger.info(f"\n{classification_report(y_test, y_pred)}")
|
| 120 |
+
|
| 121 |
+
if recall >= recall_threshold:
|
| 122 |
+
logger.info("Validation PASSED! Model is eligible for release.")
|
| 123 |
+
return True
|
| 124 |
+
else:
|
| 125 |
+
logger.warning("Validation FAILED! Recall performance does not meet threshold.")
|
| 126 |
+
return False
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error validating model performance: {str(e)}")
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
DATA_PATH = CONFIG["paths"]["clean_data"]
|
| 134 |
+
MODEL_PATH = CONFIG["paths"]["model"]
|
| 135 |
+
|
| 136 |
+
success = validate_model_performance(DATA_PATH, MODEL_PATH, recall_threshold=0.80)
|
| 137 |
+
if success:
|
| 138 |
+
sys.exit(0)
|
| 139 |
+
else:
|
| 140 |
+
sys.exit(1)
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
# Ensure project root is in path
|
| 6 |
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 7 |
+
if project_root not in sys.path:
|
| 8 |
+
sys.path.append(project_root)
|
| 9 |
+
|
| 10 |
+
from src.api import app
|
| 11 |
+
|
| 12 |
+
def test_health_check() -> None:
|
| 13 |
+
"""Verifies that the /health API endpoint responds with success status."""
|
| 14 |
+
with TestClient(app) as client:
|
| 15 |
+
response = client.get("/health")
|
| 16 |
+
assert response.status_code == 200
|
| 17 |
+
data = response.json()
|
| 18 |
+
assert data["status"] == "healthy"
|
| 19 |
+
assert "model_loaded" in data
|
| 20 |
+
|
| 21 |
+
def test_predict_endpoint() -> None:
|
| 22 |
+
"""Verifies that a valid customer prediction payload yields correct structure and values."""
|
| 23 |
+
payload = {
|
| 24 |
+
"recency": 10,
|
| 25 |
+
"frequency": 5,
|
| 26 |
+
"monetary": 250.0,
|
| 27 |
+
"basket_size": 8.0,
|
| 28 |
+
"avg_days_between": 20.0,
|
| 29 |
+
"recent_orders_ratio": 0.8,
|
| 30 |
+
"is_uk": 1
|
| 31 |
+
}
|
| 32 |
+
with TestClient(app) as client:
|
| 33 |
+
response = client.post("/predict", json=payload)
|
| 34 |
+
assert response.status_code == 200
|
| 35 |
+
data = response.json()
|
| 36 |
+
assert "churn_probability" in data
|
| 37 |
+
assert "risk_tier" in data
|
| 38 |
+
assert "recommendation" in data
|
| 39 |
+
assert isinstance(data["churn_probability"], float)
|
| 40 |
+
|
| 41 |
+
def test_predict_batch_endpoint() -> None:
|
| 42 |
+
"""Verifies that a valid list of customer profiles returns batched prediction arrays."""
|
| 43 |
+
payload = {
|
| 44 |
+
"customers": [
|
| 45 |
+
{
|
| 46 |
+
"recency": 10,
|
| 47 |
+
"frequency": 5,
|
| 48 |
+
"monetary": 250.0,
|
| 49 |
+
"basket_size": 8.0
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"recency": 200,
|
| 53 |
+
"frequency": 1,
|
| 54 |
+
"monetary": 50.0,
|
| 55 |
+
"basket_size": 2.0
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
}
|
| 59 |
+
with TestClient(app) as client:
|
| 60 |
+
response = client.post("/predict_batch", json=payload)
|
| 61 |
+
assert response.status_code == 200
|
| 62 |
+
data = response.json()
|
| 63 |
+
assert "predictions" in data
|
| 64 |
+
assert len(data["predictions"]) == 2
|
| 65 |
+
for pred in data["predictions"]:
|
| 66 |
+
assert "churn_probability" in pred
|
| 67 |
+
assert "risk_tier" in pred
|
| 68 |
+
assert "recommendation" in pred
|
| 69 |
+
|
| 70 |
+
def test_monitor_drift_endpoint() -> None:
|
| 71 |
+
"""Verifies that the /monitor endpoint returns the statistical drift analysis report."""
|
| 72 |
+
with TestClient(app) as client:
|
| 73 |
+
response = client.get("/monitor")
|
| 74 |
+
assert response.status_code == 200
|
| 75 |
+
data = response.json()
|
| 76 |
+
assert "drift_detected" in data
|
| 77 |
+
assert "drift_status" in data
|
| 78 |
+
assert "message" in data
|
| 79 |
+
|
| 80 |
+
def test_shadow_stats_endpoint() -> None:
|
| 81 |
+
"""Verifies that the /shadow_stats endpoint returns Champion vs Challenger metrics."""
|
| 82 |
+
with TestClient(app) as client:
|
| 83 |
+
response = client.get("/shadow_stats")
|
| 84 |
+
assert response.status_code == 200
|
| 85 |
+
data = response.json()
|
| 86 |
+
assert "total_predictions" in data
|
| 87 |
+
assert "champion_mean" in data
|
| 88 |
+
assert "challenger_mean" in data
|
| 89 |
+
assert "mean_absolute_deviation" in data
|
| 90 |
+
assert "agreement_rate" in data
|
| 91 |
+
assert "recent_logs" in data
|