Telcom Churn Prediction¶
Here’s a dataset of telecom customers, which you can also find on kaggle. There is data available on 5’986 customers.
Which customers are likely to churn? What are the attributes that make you think so?
The features¶
customerID- customer idgender- client gender (male / female)SeniorCitizen- is the client retired (1, 0)Partner- is the client married (Yes, No)tenure- how many months a person has been a client of the companyPhoneService- is the telephone service connected (Yes, No)MultipleLines- are multiple phone lines connected (Yes, No, No phone service)InternetService- client’s Internet service provider (DSL, Fiber optic, No)OnlineSecurity- is the online security service connected (Yes, No, No internet service)OnlineBackup- is the online backup service activated (Yes, No, No internet service)DeviceProtection- does the client have equipment insurance (Yes, No, No internet service)TechSupport- is the technical support service connected (Yes, No, No internet service)StreamingTV- is the streaming TV service connected (Yes, No, No internet service)StreamingMovies- is the streaming cinema service activated (Yes, No, No internet service)Contract- type of customer contract (Month-to-month, One year, Two year)PaperlessBilling- whether the client uses paperless billing (Yes, No)PaymentMethod- payment method (Electronic check, Mailed check, Bank transfer (automatic), Credit card (automatic))MonthlyCharges- current monthly paymentTotalCharges- the total amount that the client paid for the services for the entire timeChurn- whether there was a churn (Yes or No)
1. Load and inspect the dataset¶
In [40]:
# Download the dataset manually if necessary and place it in the 'data/' directory
# Then, load the dataset
import pandas as pd
# Load data
df = pd.read_csv("https://drive.google.com/uc?export=download&id=1A3MUldrs0z09DlYR6Y1utfySwKNO9Qsz", index_col=0)
# Display first few rows
df.head()
Out[40]:
| customerID | gender | SeniorCitizen | Partner | Dependents | tenure | PhoneService | MultipleLines | InternetService | OnlineSecurity | OnlineBackup | DeviceProtection | TechSupport | StreamingTV | StreamingMovies | Contract | PaperlessBilling | PaymentMethod | MonthlyCharges | TotalCharges | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1869 | 7010-BRBUU | Male | 0 | Yes | Yes | 72 | Yes | Yes | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | Two year | No | Credit card (automatic) | 24.10 | 1734.65 | No |
| 4528 | 9688-YGXVR | Female | 0 | No | No | 44 | Yes | No | Fiber optic | No | Yes | Yes | No | Yes | No | Month-to-month | Yes | Credit card (automatic) | 88.15 | 3973.2 | No |
| 6344 | 9286-DOJGF | Female | 1 | Yes | No | 38 | Yes | Yes | Fiber optic | No | No | No | No | No | No | Month-to-month | Yes | Bank transfer (automatic) | 74.95 | 2869.85 | Yes |
| 6739 | 6994-KERXL | Male | 0 | No | No | 4 | Yes | No | DSL | No | No | No | No | No | Yes | Month-to-month | Yes | Electronic check | 55.90 | 238.5 | No |
| 432 | 2181-UAESM | Male | 0 | No | No | 2 | Yes | No | DSL | Yes | No | Yes | No | No | No | Month-to-month | No | Electronic check | 53.45 | 119.5 | No |
2. Preprocess the dataset¶
In [ ]:
# Check for missing values and data types
print(df.info())
<class 'pandas.core.frame.DataFrame'> Index: 5986 entries, 1869 to 860 Data columns (total 21 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 customerID 5986 non-null object 1 gender 5986 non-null object 2 SeniorCitizen 5986 non-null int64 3 Partner 5986 non-null object 4 Dependents 5986 non-null object 5 tenure 5986 non-null int64 6 PhoneService 5986 non-null object 7 MultipleLines 5986 non-null object 8 InternetService 5986 non-null object 9 OnlineSecurity 5986 non-null object 10 OnlineBackup 5986 non-null object 11 DeviceProtection 5986 non-null object 12 TechSupport 5986 non-null object 13 StreamingTV 5986 non-null object 14 StreamingMovies 5986 non-null object 15 Contract 5986 non-null object 16 PaperlessBilling 5986 non-null object 17 PaymentMethod 5986 non-null object 18 MonthlyCharges 5986 non-null float64 19 TotalCharges 5986 non-null object 20 Churn 5986 non-null object dtypes: float64(1), int64(2), object(18) memory usage: 1.0+ MB None
In [42]:
# Hint: 'TotalCharges' is object type – convert it to numeric
# Strip whitespaces and convert to numeric
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'].replace(" ", pd.NA), errors='coerce')
# Drop rows with missing TotalCharges
df.dropna(subset=['TotalCharges'], inplace=True)
In [68]:
plt.figure(figsize=(17, 9))
plt.title('Comparison between tenure and Monthly Charges (Churn=color)')
sns.scatterplot(x=df["tenure"], y=df['MonthlyCharges'], hue=df['Churn'], s=50, alpha=0.4);
In [67]:
# create tenure_new by adding randomly +/- 0.4
df2 = df.copy()
df2['tenure_new'] = df2['tenure'] + np.random.uniform(-0.4, 0.4, size=len(df2))
plt.figure(figsize=(17, 9))
plt.title('Comparison between tenure and Monthly Charges (Churn=color)')
sns.scatterplot(x=df2["tenure_new"], y=df2['MonthlyCharges'], hue=df2['Churn'], s=50, alpha=0.4);
3. Encode categorical variables¶
In [ ]:
# Use one-hot encoding for categorical features
df_encoded = pd.get_dummies(df.drop('customerID', axis=1), drop_first=True)
df_encoded.drop(columns=['MonthlyCharges', 'TotalCharges', 'tenure']).mean().plot(
kind='bar', figsize=(12, 6), title='Feature Distribution (percentage)'
)
print("Mean values of encoded features:")
print(df_encoded.mean())
Mean values of encoded features: SeniorCitizen 0.161647 tenure 32.523092 MonthlyCharges 64.846687 TotalCharges 2298.060617 gender_Male 0.509371 Partner_Yes 0.484605 Dependents_Yes 0.298025 PhoneService_Yes 0.901606 MultipleLines_No phone service 0.098394 MultipleLines_Yes 0.425870 InternetService_Fiber optic 0.439592 InternetService_No 0.215027 OnlineSecurity_No internet service 0.215027 OnlineSecurity_Yes 0.285977 OnlineBackup_No internet service 0.215027 OnlineBackup_Yes 0.349230 DeviceProtection_No internet service 0.215027 DeviceProtection_Yes 0.343373 TechSupport_No internet service 0.215027 TechSupport_Yes 0.289826 StreamingTV_No internet service 0.215027 StreamingTV_Yes 0.385375 StreamingMovies_No internet service 0.215027 StreamingMovies_Yes 0.391232 Contract_One year 0.213353 Contract_Two year 0.239625 PaperlessBilling_Yes 0.589859 PaymentMethod_Credit card (automatic) 0.217871 PaymentMethod_Electronic check 0.335676 PaymentMethod_Mailed check 0.227912 Churn_Yes 0.265562 dtype: float64
In [44]:
import numpy as np
# Feature engineering
# Convert 'SeniorCitizen' to boolean
df_encoded["SeniorCitizen"] = df_encoded["SeniorCitizen"].astype(bool)
# Avg_MonthlyCharges: handle division by zero
df_encoded["Avg_MonthlyCharges"] = df_encoded["TotalCharges"] / df_encoded["tenure"].replace(0, np.nan)
# Count of active services (engagement depth)
df_encoded["Services_Count"] = df_encoded[
[
"PhoneService_Yes",
"MultipleLines_Yes",
"InternetService_Fiber optic",
"OnlineSecurity_Yes",
"OnlineBackup_Yes",
"DeviceProtection_Yes",
"TechSupport_Yes",
"StreamingTV_Yes",
"StreamingMovies_Yes",
]
].sum(axis=1)
# Protection bundle score
df_encoded["TechServices_Used"] = df_encoded[["OnlineSecurity_Yes", "OnlineBackup_Yes", "DeviceProtection_Yes", "TechSupport_Yes"]].sum(
axis=1
)
# Heavy streamer
df_encoded["Is_Heavy_Streamer"] = (df_encoded["StreamingTV_Yes"] == 1) & (df_encoded["StreamingMovies_Yes"] == 1)
# Has multiple services (3+)
df_encoded["Has_MultipleServices"] = df_encoded["Services_Count"] >= 3
# Has tech support only
df_encoded["Has_TechSupport_Only"] = (
(df_encoded["TechSupport_Yes"] == 1)
& (df_encoded["OnlineSecurity_Yes"] == 0)
& (df_encoded["OnlineBackup_Yes"] == 0)
& (df_encoded["DeviceProtection_Yes"] == 0)
)
# What the customer would have paid if charged current monthly rate all along
df_encoded["UniformRateTotal"] = df_encoded["MonthlyCharges"] * df_encoded["tenure"]
# Difference between uniform-rate total and actual historical payments
df_encoded["HistoricalRateGap"] = df_encoded["UniformRateTotal"] - df_encoded["TotalCharges"]
# Historical average monthly rate
df_encoded["HistoricalAvgMonthlyRate"] = df_encoded["TotalCharges"] / df_encoded["tenure"]
# Difference between current monthly rate and historical average monthly rate
df_encoded["CurrentChargeOverHistorical"] = df_encoded["MonthlyCharges"] - df_encoded["HistoricalAvgMonthlyRate"]
# Paperless + autopay = loyal?
df_encoded["Is_Paperless_AutoPay"] = (df_encoded["PaperlessBilling_Yes"] == 1) & (df_encoded["PaymentMethod_Credit card (automatic)"] == 1)
# High-risk payment group
df_encoded["High_Risk_Payment"] = (df_encoded["PaperlessBilling_Yes"] == 1) & (df_encoded["PaymentMethod_Electronic check"] == 1)
# Social support features
df_encoded["Is_SeniorAlone"] = (df_encoded["SeniorCitizen"] == 1) & (df_encoded["Partner_Yes"] == 0) & (df_encoded["Dependents_Yes"] == 0)
df_encoded["Is_Young_Family"] = (df_encoded["SeniorCitizen"] == 0) & (
(df_encoded["Partner_Yes"] == 1) | (df_encoded["Dependents_Yes"] == 1)
)
# Categoricl features for tenure and monthly charges
df_encoded["Is_NewCostumer"] = df_encoded["tenure"] == 1
df_encoded["Is_Longterm"] = df_encoded["tenure"] > 20
df_encoded["Is_MaximumTenure"] = df_encoded["tenure"] == 72
df_encoded["CheapFlatrate_yes"] = df_encoded["MonthlyCharges"] < 27
df_encoded["HighCost_g60"] = df_encoded["MonthlyCharges"] > 60
# Categorical features for monthly charges
# Hardcoded tariff bin boundaries based on clustering
boundaries = [-np.inf, 25.53625, 34.4975, 48.31625, 66.9925, 88.14775, np.inf]
labels = ["VeryLow", "Low", "Medium", "MediumHigh", "High", "Premium"]
# Assign tariff band
df_encoded["TariffBand"] = pd.cut(df_encoded["MonthlyCharges"], bins=boundaries, labels=labels)
# One-hot encode as boolean features
df_encoded = pd.get_dummies(df_encoded, columns=["TariffBand"], prefix="Tariff", dtype=bool)
df_encoded.mean()
Out[44]:
SeniorCitizen 0.161647 tenure 32.523092 MonthlyCharges 64.846687 TotalCharges 2298.060617 gender_Male 0.509371 Partner_Yes 0.484605 Dependents_Yes 0.298025 PhoneService_Yes 0.901606 MultipleLines_No phone service 0.098394 MultipleLines_Yes 0.425870 InternetService_Fiber optic 0.439592 InternetService_No 0.215027 OnlineSecurity_No internet service 0.215027 OnlineSecurity_Yes 0.285977 OnlineBackup_No internet service 0.215027 OnlineBackup_Yes 0.349230 DeviceProtection_No internet service 0.215027 DeviceProtection_Yes 0.343373 TechSupport_No internet service 0.215027 TechSupport_Yes 0.289826 StreamingTV_No internet service 0.215027 StreamingTV_Yes 0.385375 StreamingMovies_No internet service 0.215027 StreamingMovies_Yes 0.391232 Contract_One year 0.213353 Contract_Two year 0.239625 PaperlessBilling_Yes 0.589859 PaymentMethod_Credit card (automatic) 0.217871 PaymentMethod_Electronic check 0.335676 PaymentMethod_Mailed check 0.227912 Churn_Yes 0.265562 Avg_MonthlyCharges 64.844044 Services_Count 3.812082 TechServices_Used 1.268407 Is_Heavy_Streamer 0.277610 Has_MultipleServices 0.638722 Has_TechSupport_Only 0.034806 UniformRateTotal 2297.585894 HistoricalRateGap -0.474724 HistoricalAvgMonthlyRate 64.844044 CurrentChargeOverHistorical 0.002643 Is_Paperless_AutoPay 0.124498 High_Risk_Payment 0.246821 Is_SeniorAlone 0.079150 Is_Young_Family 0.453146 Is_NewCostumer 0.085341 Is_Longterm 0.594545 Is_MaximumTenure 0.051539 CheapFlatrate_yes 0.226573 HighCost_g60 0.585509 Tariff_VeryLow 0.215194 Tariff_Low 0.027610 Tariff_Medium 0.063922 Tariff_MediumHigh 0.152276 Tariff_High 0.264056 Tariff_Premium 0.276941 dtype: float64
In [45]:
df_encoded.info()
<class 'pandas.core.frame.DataFrame'> Index: 5976 entries, 1869 to 860 Data columns (total 56 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 SeniorCitizen 5976 non-null bool 1 tenure 5976 non-null int64 2 MonthlyCharges 5976 non-null float64 3 TotalCharges 5976 non-null float64 4 gender_Male 5976 non-null bool 5 Partner_Yes 5976 non-null bool 6 Dependents_Yes 5976 non-null bool 7 PhoneService_Yes 5976 non-null bool 8 MultipleLines_No phone service 5976 non-null bool 9 MultipleLines_Yes 5976 non-null bool 10 InternetService_Fiber optic 5976 non-null bool 11 InternetService_No 5976 non-null bool 12 OnlineSecurity_No internet service 5976 non-null bool 13 OnlineSecurity_Yes 5976 non-null bool 14 OnlineBackup_No internet service 5976 non-null bool 15 OnlineBackup_Yes 5976 non-null bool 16 DeviceProtection_No internet service 5976 non-null bool 17 DeviceProtection_Yes 5976 non-null bool 18 TechSupport_No internet service 5976 non-null bool 19 TechSupport_Yes 5976 non-null bool 20 StreamingTV_No internet service 5976 non-null bool 21 StreamingTV_Yes 5976 non-null bool 22 StreamingMovies_No internet service 5976 non-null bool 23 StreamingMovies_Yes 5976 non-null bool 24 Contract_One year 5976 non-null bool 25 Contract_Two year 5976 non-null bool 26 PaperlessBilling_Yes 5976 non-null bool 27 PaymentMethod_Credit card (automatic) 5976 non-null bool 28 PaymentMethod_Electronic check 5976 non-null bool 29 PaymentMethod_Mailed check 5976 non-null bool 30 Churn_Yes 5976 non-null bool 31 Avg_MonthlyCharges 5976 non-null float64 32 Services_Count 5976 non-null int64 33 TechServices_Used 5976 non-null int64 34 Is_Heavy_Streamer 5976 non-null bool 35 Has_MultipleServices 5976 non-null bool 36 Has_TechSupport_Only 5976 non-null bool 37 UniformRateTotal 5976 non-null float64 38 HistoricalRateGap 5976 non-null float64 39 HistoricalAvgMonthlyRate 5976 non-null float64 40 CurrentChargeOverHistorical 5976 non-null float64 41 Is_Paperless_AutoPay 5976 non-null bool 42 High_Risk_Payment 5976 non-null bool 43 Is_SeniorAlone 5976 non-null bool 44 Is_Young_Family 5976 non-null bool 45 Is_NewCostumer 5976 non-null bool 46 Is_Longterm 5976 non-null bool 47 Is_MaximumTenure 5976 non-null bool 48 CheapFlatrate_yes 5976 non-null bool 49 HighCost_g60 5976 non-null bool 50 Tariff_VeryLow 5976 non-null bool 51 Tariff_Low 5976 non-null bool 52 Tariff_Medium 5976 non-null bool 53 Tariff_MediumHigh 5976 non-null bool 54 Tariff_High 5976 non-null bool 55 Tariff_Premium 5976 non-null bool dtypes: bool(46), float64(7), int64(3) memory usage: 782.0 KB
In [46]:
df_encoded.describe()
Out[46]:
| tenure | MonthlyCharges | TotalCharges | Avg_MonthlyCharges | Services_Count | TechServices_Used | UniformRateTotal | HistoricalRateGap | HistoricalAvgMonthlyRate | CurrentChargeOverHistorical | |
|---|---|---|---|---|---|---|---|---|---|---|
| count | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 | 5976.000000 |
| mean | 32.523092 | 64.846687 | 2298.060617 | 64.844044 | 3.812082 | 1.268407 | 2297.585894 | -0.474724 | 64.844044 | 0.002643 |
| std | 24.500858 | 30.107576 | 2274.127165 | 30.218818 | 2.294909 | 1.288083 | 2271.532877 | 67.195909 | 30.218818 | 2.624425 |
| min | 1.000000 | 18.250000 | 18.800000 | 13.775000 | 0.000000 | 0.000000 | 18.800000 | -373.250000 | 13.775000 | -18.900000 |
| 25% | 9.000000 | 35.750000 | 404.312500 | 36.359279 | 2.000000 | 0.000000 | 399.575000 | -28.900000 | 36.359279 | -1.159375 |
| 50% | 29.000000 | 70.425000 | 1412.150000 | 70.498684 | 4.000000 | 1.000000 | 1408.700000 | 0.000000 | 70.498684 | 0.000000 |
| 75% | 56.000000 | 89.900000 | 3846.962500 | 90.302500 | 6.000000 | 2.000000 | 3848.687500 | 28.312500 | 90.302500 | 1.130377 |
| max | 72.000000 | 118.750000 | 8684.800000 | 121.400000 | 9.000000 | 4.000000 | 8550.000000 | 370.850000 | 121.400000 | 19.125000 |
In [47]:
# show the distribution of the numerical features
df_encoded.select_dtypes(include='number').hist(figsize=(15, 12), bins=60, grid=True)
Out[47]:
array([[<Axes: title={'center': 'tenure'}>,
<Axes: title={'center': 'MonthlyCharges'}>,
<Axes: title={'center': 'TotalCharges'}>],
[<Axes: title={'center': 'Avg_MonthlyCharges'}>,
<Axes: title={'center': 'Services_Count'}>,
<Axes: title={'center': 'TechServices_Used'}>],
[<Axes: title={'center': 'UniformRateTotal'}>,
<Axes: title={'center': 'HistoricalRateGap'}>,
<Axes: title={'center': 'HistoricalAvgMonthlyRate'}>],
[<Axes: title={'center': 'CurrentChargeOverHistorical'}>,
<Axes: >, <Axes: >]], dtype=object)
In [ ]:
4. Split the data into train and test sets¶
In [48]:
from sklearn.model_selection import train_test_split
X = df_encoded.drop('Churn_Yes', axis=1)
y = df_encoded['Churn_Yes']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
Evaluate Feature Importance with CatBoost and LogisticRegression¶
In [61]:
from catboost import CatBoostClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix, classification_report, precision_recall_curve, ConfusionMatrixDisplay
from sklearn.model_selection import StratifiedKFold
from imblearn.over_sampling import RandomOverSampler
import shap, pandas as pd, numpy as np, matplotlib.pyplot as plt
from IPython.display import display
# 📌 Oversample original training set
print("📌 Applying oversampling to balance full training set...")
ros = RandomOverSampler(random_state=42)
X_resampled, y_resampled = ros.fit_resample(X_train, y_train)
print("➡️ Class distribution after resampling:\n", pd.Series(y_resampled).value_counts())
# 📌 Standardize data for Logistic Regression
print("📌 Scaling features...")
scaler = StandardScaler()
X_resampled_scaled = scaler.fit_transform(X_resampled)
X_test_scaled = scaler.transform(X_test)
# 📊 Cross-validation setup
print("📌 Performing Stratified 5-Fold CV...")
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# ⏱️ Store results
cat_preds, lr_preds = [], []
cat_probs, lr_probs = [], []
cat_true, lr_true = [], []
fold = 0
for train_idx, val_idx in skf.split(X_resampled, y_resampled):
fold += 1
print(f"\n🔁 Fold {fold}")
X_tr, X_val = X_resampled.iloc[train_idx], X_resampled.iloc[val_idx]
y_tr, y_val = y_resampled.iloc[train_idx], y_resampled.iloc[val_idx]
X_tr_scaled = scaler.fit_transform(X_tr)
X_val_scaled = scaler.transform(X_val)
# Train CatBoost
cat = CatBoostClassifier(verbose=0, random_state=42, iterations=600, learning_rate=0.05)
cat.fit(X_tr, y_tr)
cat_preds.append(cat.predict(X_val))
cat_probs.append(cat.predict_proba(X_val)[:, 1])
cat_true.append(y_val)
# Train Logistic Regression
lr = LogisticRegression(max_iter=5000, random_state=42)
lr.fit(X_tr_scaled, y_tr)
lr_preds.append(lr.predict(X_val_scaled))
lr_probs.append(lr.predict_proba(X_val_scaled)[:, 1])
lr_true.append(y_val)
# Concatenate results
y_true_cat = pd.concat(cat_true)
y_pred_cat = np.concatenate(cat_preds)
y_prob_cat = np.concatenate(cat_probs)
y_true_lr = pd.concat(lr_true)
y_pred_lr = np.concatenate(lr_preds)
y_prob_lr = np.concatenate(lr_probs)
# 📋 Classification Reports
print("\n📋 Classification Report – CatBoost:")
print(classification_report(y_true_cat, y_pred_cat, target_names=["No", "Yes"]))
print("\n📋 Classification Report – Logistic Regression:")
print(classification_report(y_true_lr, y_pred_lr, target_names=["No", "Yes"]))
# 📊 Confusion Matrices
print("📊 Confusion Matrices...")
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ConfusionMatrixDisplay.from_predictions(y_true_cat, y_pred_cat, ax=ax[0], display_labels=["No", "Yes"])
ax[0].set_title("CatBoost Confusion Matrix")
ConfusionMatrixDisplay.from_predictions(y_true_lr, y_pred_lr, ax=ax[1], display_labels=["No", "Yes"])
ax[1].set_title("LogReg Confusion Matrix")
plt.tight_layout()
plt.show()
# 🧠 SHAP values on sample from full training set
print("📊 Computing SHAP values on 1000-sample from full training set...")
X_sample_cat = X_resampled.sample(1000, random_state=42)
X_sample_lr = pd.DataFrame(scaler.transform(X_sample_cat), columns=X_sample_cat.columns, index=X_sample_cat.index)
explainer_cat = shap.Explainer(cat)
shap_vals_cat = explainer_cat(X_sample_cat)
# ✅ Use modern masker API to avoid warning
masker = shap.maskers.Independent(X_resampled_scaled)
explainer_lr = shap.LinearExplainer(lr, masker)
shap_vals_lr = explainer_lr(X_sample_lr)
# 🔍 SHAP Summary Plots
shap.summary_plot(shap_vals_cat, X_sample_cat, max_display=15, show=False, alpha=0.2)
plt.title("CatBoost – SHAP Feature Importance (Top 15)")
plt.tight_layout()
plt.show()
shap.summary_plot(shap_vals_lr, X_sample_cat, max_display=15, show=False, alpha=0.2)
plt.title("LogReg – SHAP Feature Importance (Top 15)")
plt.tight_layout()
plt.show()
# 📊 Built-in feature importances
print("📊 Gathering feature importances...")
cat_imp = pd.Series(cat.feature_importances_, index=X_train.columns)
lr_imp = pd.Series(np.abs(lr.coef_[0]), index=X_train.columns)
shap_mean_cat = pd.Series(np.abs(shap_vals_cat.values).mean(axis=0), index=X_sample_cat.columns)
shap_mean_lr = pd.Series(np.abs(shap_vals_lr.values).mean(axis=0), index=X_sample_cat.columns)
all_importances = pd.DataFrame(
{"CatBoost_SHAP": shap_mean_cat, "LogReg_SHAP": shap_mean_lr, "CatBoost_Builtin": cat_imp, "LogReg_Builtin": lr_imp}
).sort_values("CatBoost_SHAP", ascending=False)
print("📋 Combined Feature Importances:")
display(all_importances)
# 🎯 Precision/Recall Curve
def plot_precision_recall_vs_threshold(y_true, y_scores, label):
precisions, recalls, thresholds = precision_recall_curve(y_true, y_scores)
plt.plot(thresholds, precisions[:-1], label=f'{label} – Precision', linestyle='-')
plt.plot(thresholds, recalls[:-1], label=f'{label} – Recall', linestyle='--')
print("📊 Precision and Recall vs Threshold...")
plt.figure(figsize=(10, 6))
plot_precision_recall_vs_threshold(y_true_cat, y_prob_cat, label="CatBoost")
plot_precision_recall_vs_threshold(y_true_lr, y_prob_lr, label="LogReg")
plt.xlabel("Threshold")
plt.ylabel("Score")
plt.title("Precision and Recall vs. Threshold")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()
📌 Applying oversampling to balance full training set...
➡️ Class distribution after resampling:
Churn_Yes
False 3511
True 3511
Name: count, dtype: int64
📌 Scaling features...
📌 Performing Stratified 5-Fold CV...
🔁 Fold 1
🔁 Fold 2
🔁 Fold 3
🔁 Fold 4
🔁 Fold 5
📋 Classification Report – CatBoost:
precision recall f1-score support
No 0.90 0.80 0.85 3511
Yes 0.82 0.91 0.86 3511
accuracy 0.86 7022
macro avg 0.86 0.86 0.86 7022
weighted avg 0.86 0.86 0.86 7022
📋 Classification Report – Logistic Regression:
precision recall f1-score support
No 0.79 0.72 0.76 3511
Yes 0.75 0.81 0.78 3511
accuracy 0.77 7022
macro avg 0.77 0.77 0.77 7022
weighted avg 0.77 0.77 0.77 7022
📊 Confusion Matrices...
📊 Computing SHAP values on 1000-sample from full training set...
📊 Gathering feature importances... 📋 Combined Feature Importances:
| CatBoost_SHAP | LogReg_SHAP | CatBoost_Builtin | LogReg_Builtin | |
|---|---|---|---|---|
| tenure | 0.444201 | 0.331421 | 8.822537 | 0.377403 |
| Contract_Two year | 0.371120 | 0.431504 | 5.329949 | 0.573065 |
| InternetService_Fiber optic | 0.363073 | 0.636109 | 2.878779 | 0.636451 |
| UniformRateTotal | 0.241210 | 0.075824 | 5.673006 | 0.092460 |
| Contract_One year | 0.186704 | 0.238206 | 2.383233 | 0.280697 |
| TotalCharges | 0.182470 | 0.075299 | 5.487573 | 0.091769 |
| MonthlyCharges | 0.171421 | 0.151816 | 7.714173 | 0.172384 |
| HistoricalRateGap | 0.166665 | 0.014488 | 8.795756 | 0.022028 |
| CurrentChargeOverHistorical | 0.153993 | 0.018635 | 8.727894 | 0.027850 |
| High_Risk_Payment | 0.141713 | 0.004558 | 1.510477 | 0.005414 |
| TechServices_Used | 0.138953 | 0.078028 | 2.420133 | 0.096417 |
| HistoricalAvgMonthlyRate | 0.138669 | 0.148220 | 4.362420 | 0.168830 |
| Avg_MonthlyCharges | 0.136038 | 0.148220 | 4.818486 | 0.168830 |
| PaperlessBilling_Yes | 0.133253 | 0.175847 | 1.901141 | 0.181261 |
| MultipleLines_Yes | 0.115880 | 0.181584 | 2.173643 | 0.181157 |
| OnlineSecurity_Yes | 0.094821 | 0.094862 | 1.481663 | 0.112400 |
| StreamingMovies_Yes | 0.085650 | 0.155774 | 1.012321 | 0.160109 |
| TechSupport_Yes | 0.083828 | 0.044248 | 1.167628 | 0.051647 |
| SeniorCitizen | 0.073209 | 0.043531 | 1.513954 | 0.060037 |
| Is_Longterm | 0.071621 | 0.102845 | 0.546801 | 0.102884 |
| PaymentMethod_Electronic check | 0.069298 | 0.145580 | 1.103332 | 0.153145 |
| gender_Male | 0.061867 | 0.008303 | 2.480314 | 0.008231 |
| Is_Heavy_Streamer | 0.061002 | 0.053811 | 0.607286 | 0.058832 |
| OnlineBackup_Yes | 0.059790 | 0.076746 | 1.582104 | 0.080750 |
| PaymentMethod_Mailed check | 0.057180 | 0.009857 | 1.594960 | 0.011370 |
| DeviceProtection_No internet service | 0.054513 | 0.030423 | 0.265808 | 0.035385 |
| StreamingTV_Yes | 0.052356 | 0.132300 | 0.861666 | 0.136186 |
| Partner_Yes | 0.041743 | 0.001313 | 1.497540 | 0.001349 |
| Services_Count | 0.040883 | 0.158824 | 1.507492 | 0.186267 |
| Is_SeniorAlone | 0.038044 | 0.050618 | 0.667053 | 0.104421 |
| HighCost_g60 | 0.035090 | 0.075248 | 0.348779 | 0.077989 |
| Is_MaximumTenure | 0.034115 | 0.083862 | 0.633300 | 0.282079 |
| Dependents_Yes | 0.032878 | 0.039487 | 1.171841 | 0.044818 |
| DeviceProtection_Yes | 0.031030 | 0.016218 | 0.970411 | 0.017554 |
| InternetService_No | 0.030305 | 0.030423 | 0.245649 | 0.035385 |
| CheapFlatrate_yes | 0.029553 | 0.411880 | 0.127335 | 0.485431 |
| PhoneService_Yes | 0.029095 | 0.043512 | 0.595208 | 0.084937 |
| StreamingMovies_No internet service | 0.028856 | 0.030423 | 0.131938 | 0.035385 |
| PaymentMethod_Credit card (automatic) | 0.026923 | 0.041366 | 0.895061 | 0.052739 |
| Is_Young_Family | 0.025403 | 0.060877 | 0.843436 | 0.063418 |
| OnlineBackup_No internet service | 0.022243 | 0.030423 | 0.146437 | 0.035385 |
| MultipleLines_No phone service | 0.020449 | 0.043512 | 0.329008 | 0.084937 |
| Has_TechSupport_Only | 0.018708 | 0.025425 | 0.583708 | 0.073187 |
| StreamingTV_No internet service | 0.017670 | 0.030423 | 0.131383 | 0.035385 |
| Is_Paperless_AutoPay | 0.016793 | 0.036375 | 0.726589 | 0.063022 |
| Tariff_VeryLow | 0.015954 | 0.516011 | 0.083616 | 0.619871 |
| Has_MultipleServices | 0.009055 | 0.217174 | 0.156003 | 0.229023 |
| Tariff_MediumHigh | 0.008831 | 0.025725 | 0.279979 | 0.035175 |
| Tariff_High | 0.008007 | 0.195789 | 0.152140 | 0.216604 |
| Tariff_Premium | 0.007036 | 0.359763 | 0.153184 | 0.396270 |
| Tariff_Medium | 0.006421 | 0.028927 | 0.170477 | 0.076961 |
| Is_NewCostumer | 0.005836 | 0.213937 | 0.038838 | 0.375916 |
| OnlineSecurity_No internet service | 0.005269 | 0.030423 | 0.051487 | 0.035385 |
| Tariff_Low | 0.004362 | 0.024245 | 0.145072 | 0.083449 |
| TechSupport_No internet service | 0.000000 | 0.030423 | 0.000000 | 0.035385 |
📊 Precision and Recall vs Threshold...
In [62]:
from catboost import CatBoostClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix, classification_report, precision_recall_curve, ConfusionMatrixDisplay
from sklearn.model_selection import StratifiedKFold
from imblearn.over_sampling import RandomOverSampler
import shap, pandas as pd, numpy as np, matplotlib.pyplot as plt
from IPython.display import display
# 📌 Setup CV
print("📌 Setting up Stratified 5-Fold CV...")
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# ⏱️ Store results
cat_preds, lr_preds = [], []
cat_probs, lr_probs = [], []
cat_true, lr_true = [], []
# 🧪 CV loop with oversampling inside
fold = 0
scaler = StandardScaler() # Create scaler once
for train_idx, val_idx in skf.split(X_train, y_train):
fold += 1
print(f"\n🔁 Fold {fold}")
# Split and oversample
X_tr, y_tr = X_train.iloc[train_idx], y_train.iloc[train_idx]
X_val, y_val = X_train.iloc[val_idx], y_train.iloc[val_idx]
ros = RandomOverSampler(random_state=42)
X_tr_resampled, y_tr_resampled = ros.fit_resample(X_tr, y_tr)
# Scale for LogReg
X_tr_scaled = scaler.fit_transform(X_tr_resampled)
X_val_scaled = scaler.transform(X_val)
# Train CatBoost
cat = CatBoostClassifier(verbose=0, random_state=42, iterations=600, learning_rate=0.05)
cat.fit(X_tr_resampled, y_tr_resampled)
cat_preds.append(cat.predict(X_val))
cat_probs.append(cat.predict_proba(X_val)[:, 1])
cat_true.append(y_val)
# Train Logistic Regression
lr = LogisticRegression(max_iter=5000, random_state=42)
lr.fit(X_tr_scaled, y_tr_resampled)
lr_preds.append(lr.predict(X_val_scaled))
lr_probs.append(lr.predict_proba(X_val_scaled)[:, 1])
lr_true.append(y_val)
# 📊 Concatenate results
y_true_cat = pd.concat(cat_true)
y_pred_cat = np.concatenate(cat_preds)
y_prob_cat = np.concatenate(cat_probs)
y_true_lr = pd.concat(lr_true)
y_pred_lr = np.concatenate(lr_preds)
y_prob_lr = np.concatenate(lr_probs)
# 📋 Classification Reports
print("\n📋 Classification Report – CatBoost:")
print(classification_report(y_true_cat, y_pred_cat, target_names=["No", "Yes"]))
print("\n📋 Classification Report – Logistic Regression:")
print(classification_report(y_true_lr, y_pred_lr, target_names=["No", "Yes"]))
# 📊 Confusion Matrices
print("📊 Confusion Matrices...")
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ConfusionMatrixDisplay.from_predictions(y_true_cat, y_pred_cat, ax=ax[0], display_labels=["No", "Yes"])
ax[0].set_title("CatBoost Confusion Matrix")
ConfusionMatrixDisplay.from_predictions(y_true_lr, y_pred_lr, ax=ax[1], display_labels=["No", "Yes"])
ax[1].set_title("LogReg Confusion Matrix")
plt.tight_layout()
plt.show()
# 🔁 Re-train final models on full resampled training set for SHAP
print("📌 Retraining models on full balanced training set for SHAP...")
ros = RandomOverSampler(random_state=42)
X_resampled, y_resampled = ros.fit_resample(X_train, y_train)
X_resampled_scaled = scaler.fit_transform(X_resampled)
cat_final = CatBoostClassifier(verbose=0, random_state=42, iterations=600, learning_rate=0.05)
cat_final.fit(X_resampled, y_resampled)
lr_final = LogisticRegression(max_iter=5000, random_state=42)
lr_final.fit(X_resampled_scaled, y_resampled)
# 📊 SHAP values on sample
print("📊 Computing SHAP values on 1000-sample from full resampled set...")
X_sample_cat = X_resampled.sample(1000, random_state=42)
X_sample_scaled = pd.DataFrame(scaler.transform(X_sample_cat), columns=X_sample_cat.columns, index=X_sample_cat.index)
explainer_cat = shap.Explainer(cat_final)
shap_vals_cat = explainer_cat(X_sample_cat)
explainer_lr = shap.Explainer(lr_final, X_sample_scaled)
shap_vals_lr = explainer_lr(X_sample_scaled)
# 🔍 SHAP Summary Plots
shap.summary_plot(shap_vals_cat, X_sample_cat, max_display=15, show=False, alpha=0.2)
plt.title("CatBoost – SHAP Feature Importance (Top 15)")
plt.tight_layout()
plt.show()
shap.summary_plot(shap_vals_lr, X_sample_cat, max_display=15, show=False, alpha=0.2)
plt.title("LogReg – SHAP Feature Importance (Top 15)")
plt.tight_layout()
plt.show()
# 📊 Built-in feature importances
print("📊 Gathering feature importances...")
cat_importance = pd.Series(cat_final.feature_importances_, index=X_train.columns)
lr_coeff = pd.Series(np.abs(lr_final.coef_[0]), index=X_train.columns)
shap_mean_cat = pd.Series(np.abs(shap_vals_cat.values).mean(axis=0), index=X_sample_cat.columns)
shap_mean_lr = pd.Series(np.abs(shap_vals_lr.values).mean(axis=0), index=X_sample_cat.columns)
# 🧾 Combine into one DataFrame
all_importances = pd.DataFrame(
{"CatBoost_SHAP": shap_mean_cat, "LogReg_SHAP": shap_mean_lr, "CatBoost_Builtin": cat_importance, "LogReg_Coefficient": lr_coeff}
).sort_values("CatBoost_SHAP", ascending=False)
print("📋 Combined Feature Importances:")
display(all_importances)
# 🎯 Precision/Recall Curve
def plot_precision_recall_vs_threshold(y_true, y_scores, label):
precisions, recalls, thresholds = precision_recall_curve(y_true, y_scores)
plt.plot(thresholds, precisions[:-1], label=f'{label} – Precision', linestyle='-')
plt.plot(thresholds, recalls[:-1], label=f'{label} – Recall', linestyle='--')
print("📊 Precision and Recall vs Threshold...")
plt.figure(figsize=(10, 6))
plot_precision_recall_vs_threshold(y_true_cat, y_prob_cat, label="CatBoost")
plot_precision_recall_vs_threshold(y_true_lr, y_prob_lr, label="LogReg")
plt.xlabel("Threshold")
plt.ylabel("Score")
plt.title("Precision and Recall vs. Threshold")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()
📌 Setting up Stratified 5-Fold CV...
🔁 Fold 1
🔁 Fold 2
🔁 Fold 3
🔁 Fold 4
🔁 Fold 5
📋 Classification Report – CatBoost:
precision recall f1-score support
No 0.87 0.81 0.84 3511
Yes 0.56 0.66 0.61 1269
accuracy 0.77 4780
macro avg 0.71 0.74 0.72 4780
weighted avg 0.79 0.77 0.78 4780
📋 Classification Report – Logistic Regression:
precision recall f1-score support
No 0.91 0.73 0.81 3511
Yes 0.52 0.80 0.63 1269
accuracy 0.75 4780
macro avg 0.71 0.77 0.72 4780
weighted avg 0.81 0.75 0.76 4780
📊 Confusion Matrices...
📌 Retraining models on full balanced training set for SHAP... 📊 Computing SHAP values on 1000-sample from full resampled set...
📊 Gathering feature importances... 📋 Combined Feature Importances:
| CatBoost_SHAP | LogReg_SHAP | CatBoost_Builtin | LogReg_Coefficient | |
|---|---|---|---|---|
| tenure | 0.444945 | 0.310021 | 8.486307 | 0.352472 |
| Contract_Two year | 0.403344 | 0.443888 | 5.592982 | 0.617490 |
| InternetService_Fiber optic | 0.354809 | 0.949897 | 2.836723 | 0.949141 |
| Contract_One year | 0.230523 | 0.292298 | 2.978875 | 0.319399 |
| TotalCharges | 0.220481 | 0.058328 | 5.724426 | 0.070714 |
| MonthlyCharges | 0.201151 | 0.092386 | 8.067536 | 0.105377 |
| HistoricalRateGap | 0.179856 | 0.014811 | 9.071177 | 0.022070 |
| UniformRateTotal | 0.179167 | 0.058818 | 5.641859 | 0.071401 |
| CurrentChargeOverHistorical | 0.175828 | 0.021564 | 8.375252 | 0.031355 |
| PaperlessBilling_Yes | 0.144755 | 0.171221 | 1.999847 | 0.173122 |
| High_Risk_Payment | 0.136218 | 0.004145 | 1.443765 | 0.004602 |
| TechServices_Used | 0.124770 | 0.053092 | 2.462275 | 0.065432 |
| HistoricalAvgMonthlyRate | 0.119109 | 0.088859 | 4.640776 | 0.101785 |
| Avg_MonthlyCharges | 0.104392 | 0.088859 | 3.884841 | 0.101785 |
| TechSupport_Yes | 0.098115 | 0.030935 | 1.385705 | 0.035576 |
| PaymentMethod_Electronic check | 0.097018 | 0.139611 | 1.138505 | 0.143643 |
| MultipleLines_Yes | 0.094285 | 0.205671 | 1.700985 | 0.208153 |
| OnlineSecurity_Yes | 0.092248 | 0.076048 | 1.602656 | 0.084242 |
| OnlineBackup_Yes | 0.088585 | 0.054875 | 1.392805 | 0.059786 |
| StreamingMovies_Yes | 0.082363 | 0.267357 | 0.862573 | 0.271552 |
| SeniorCitizen | 0.078924 | 0.028662 | 1.523449 | 0.037897 |
| Is_Heavy_Streamer | 0.066184 | 0.102389 | 0.718199 | 0.110832 |
| gender_Male | 0.065259 | 0.005688 | 2.457331 | 0.005660 |
| PaymentMethod_Mailed check | 0.058137 | 0.019516 | 1.262100 | 0.022882 |
| Partner_Yes | 0.055970 | 0.032566 | 1.662993 | 0.032652 |
| OnlineBackup_No internet service | 0.055265 | 0.057927 | 0.536509 | 0.073396 |
| DeviceProtection_No internet service | 0.053069 | 0.057927 | 0.245030 | 0.073396 |
| StreamingTV_Yes | 0.052256 | 0.225560 | 0.839784 | 0.228486 |
| DeviceProtection_Yes | 0.046559 | 0.000584 | 1.183881 | 0.000637 |
| Is_MaximumTenure | 0.044015 | 0.127348 | 0.608176 | 0.319592 |
| Is_Longterm | 0.042822 | 0.133131 | 0.455311 | 0.134097 |
| Dependents_Yes | 0.035881 | 0.039691 | 1.392200 | 0.040921 |
| Services_Count | 0.029887 | 0.287664 | 0.998902 | 0.337118 |
| Is_Young_Family | 0.028172 | 0.011880 | 1.089549 | 0.011625 |
| MultipleLines_No phone service | 0.027090 | 0.000684 | 0.483064 | 0.001152 |
| CheapFlatrate_yes | 0.025767 | 0.351779 | 0.110258 | 0.439921 |
| PaymentMethod_Credit card (automatic) | 0.024461 | 0.052308 | 0.870130 | 0.068082 |
| Tariff_MediumHigh | 0.024410 | 0.045687 | 0.538220 | 0.060747 |
| Is_SeniorAlone | 0.020216 | 0.069476 | 0.491386 | 0.109066 |
| PhoneService_Yes | 0.019739 | 0.000684 | 0.596562 | 0.001152 |
| TechSupport_No internet service | 0.017498 | 0.057927 | 0.087070 | 0.073396 |
| Is_Paperless_AutoPay | 0.016167 | 0.044500 | 0.549502 | 0.080479 |
| Has_TechSupport_Only | 0.015397 | 0.033284 | 0.472973 | 0.067386 |
| StreamingTV_No internet service | 0.014414 | 0.057927 | 0.057990 | 0.073396 |
| InternetService_No | 0.014366 | 0.057927 | 0.083449 | 0.073396 |
| Tariff_High | 0.014291 | 0.185583 | 0.328685 | 0.217140 |
| HighCost_g60 | 0.014070 | 0.023043 | 0.136151 | 0.023628 |
| Tariff_VeryLow | 0.010907 | 0.500197 | 0.285075 | 0.626592 |
| StreamingMovies_No internet service | 0.009675 | 0.057927 | 0.037703 | 0.073396 |
| Tariff_Premium | 0.008283 | 0.414480 | 0.125736 | 0.440681 |
| OnlineSecurity_No internet service | 0.007670 | 0.057927 | 0.058527 | 0.073396 |
| Tariff_Medium | 0.007313 | 0.042731 | 0.237436 | 0.096032 |
| Has_MultipleServices | 0.006017 | 0.229148 | 0.099608 | 0.235312 |
| Is_NewCostumer | 0.005706 | 0.221671 | 0.045304 | 0.359483 |
| Tariff_Low | 0.001122 | 0.031099 | 0.041887 | 0.107032 |
📊 Precision and Recall vs Threshold...
In [63]:
# Example: Evaluate CatBoost on real test set
print("📊 Final Evaluation on X_test:")
cat_test_pred = cat_final.predict(X_test)
lr_test_pred = lr_final.predict(scaler.transform(X_test))
print("CatBoost:")
print(classification_report(y_test, cat_test_pred))
print("LogReg:")
print(classification_report(y_test, lr_test_pred))
📊 Final Evaluation on X_test:
CatBoost:
precision recall f1-score support
False 0.87 0.81 0.84 878
True 0.56 0.67 0.61 318
accuracy 0.77 1196
macro avg 0.71 0.74 0.72 1196
weighted avg 0.79 0.77 0.78 1196
LogReg:
precision recall f1-score support
False 0.90 0.73 0.81 878
True 0.51 0.78 0.62 318
accuracy 0.74 1196
macro avg 0.71 0.75 0.71 1196
weighted avg 0.80 0.74 0.76 1196