Customer Churn Intelligence Platform¶
An End-to-End Machine Learning Approach for Customer Retention Analytics¶
Author: Badawi Aminu Muhammed
Tools: Python, Pandas, NumPy, Scikit-learn, XGBoost, SHAP, Plotly, Streamlit
Objective: Predict customer churn, identify the key drivers of customer attrition, and generate actionable retention strategies using machine learning and explainable AI.
1. Introduction¶
Customer retention is a critical business challenge, particularly in highly competitive industries such as telecommunications. Acquiring new customers is significantly more expensive than retaining existing ones, making churn prediction an essential application of machine learning.
This project develops an end-to-end Customer Churn Intelligence Platform capable of predicting whether a customer is likely to leave the company while identifying the factors contributing to that decision. Beyond prediction, the project aims to provide actionable business insights that support customer retention strategies.
The project follows the complete machine learning lifecycle, including data exploration, preprocessing, feature engineering, model development, evaluation, explainability, and deployment.
2. Problem Statement¶
Customer churn results in revenue loss, increased acquisition costs, and reduced customer lifetime value. Although organizations collect extensive customer data, identifying customers at risk of leaving before they churn remains challenging.
The objective is to leverage historical customer information to develop a predictive model capable of identifying high-risk customers and providing interpretable insights that support proactive retention strategies.
3. Project Objectives¶
The objectives of this project are:
- Explore and understand customer behaviour through exploratory data analysis.
- Identify factors associated with customer churn.
- Clean and preprocess the dataset for machine learning.
- Engineer informative features to improve predictive performance.
- Train and compare multiple machine learning models.
- Evaluate model performance using appropriate classification metrics.
- Interpret model predictions using Explainable AI (SHAP).
- Develop an interactive dashboard for business users.
4. Dataset Description¶
The project uses the IBM Telco Customer Churn dataset, which contains customer demographic information, subscribed services, account information, and churn status.
Each row represents a unique customer, while the target variable (Churn) indicates whether the customer discontinued the service.
The dataset includes variables related to:
- Customer demographics
- Services subscribed
- Contract information
- Billing information
- Payment methods
- Customer tenure
- Monthly and total charges
5. Methodology¶
The project follows the standard machine learning pipeline:
- Data Loading
- Data Inspection
- Data Cleaning
- Exploratory Data Analysis
- Feature Engineering
- Data Preprocessing
- Model Training
- Model Evaluation
- Explainable AI
- Deployment
import warnings
warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
pd.set_option("display.max_columns", None)
df = pd.read_csv("../data/raw/Telco-Customer-Churn.csv")
df.head()
| customerID | gender | SeniorCitizen | Partner | Dependents | tenure | PhoneService | MultipleLines | InternetService | OnlineSecurity | OnlineBackup | DeviceProtection | TechSupport | StreamingTV | StreamingMovies | Contract | PaperlessBilling | PaymentMethod | MonthlyCharges | TotalCharges | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7590-VHVEG | Female | 0 | Yes | No | 1 | No | No phone service | DSL | No | Yes | No | No | No | No | Month-to-month | Yes | Electronic check | 29.85 | 29.85 | No |
| 1 | 5575-GNVDE | Male | 0 | No | No | 34 | Yes | No | DSL | Yes | No | Yes | No | No | No | One year | No | Mailed check | 56.95 | 1889.5 | No |
| 2 | 3668-QPYBK | Male | 0 | No | No | 2 | Yes | No | DSL | Yes | Yes | No | No | No | No | Month-to-month | Yes | Mailed check | 53.85 | 108.15 | Yes |
| 3 | 7795-CFOCW | Male | 0 | No | No | 45 | No | No phone service | DSL | Yes | No | Yes | Yes | No | No | One year | No | Bank transfer (automatic) | 42.30 | 1840.75 | No |
| 4 | 9237-HQITU | Female | 0 | No | No | 2 | Yes | No | Fiber optic | No | No | No | No | No | No | Month-to-month | Yes | Electronic check | 70.70 | 151.65 | Yes |
df.shape
(7043, 21)
df.describe()
| SeniorCitizen | tenure | MonthlyCharges | |
|---|---|---|---|
| count | 7043.000000 | 7043.000000 | 7043.000000 |
| mean | 0.162147 | 32.371149 | 64.761692 |
| std | 0.368612 | 24.559481 | 30.090047 |
| min | 0.000000 | 0.000000 | 18.250000 |
| 25% | 0.000000 | 9.000000 | 35.500000 |
| 50% | 0.000000 | 29.000000 | 70.350000 |
| 75% | 0.000000 | 55.000000 | 89.850000 |
| max | 1.000000 | 72.000000 | 118.750000 |
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 7043 entries, 0 to 7042 Data columns (total 21 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 customerID 7043 non-null object 1 gender 7043 non-null object 2 SeniorCitizen 7043 non-null int64 3 Partner 7043 non-null object 4 Dependents 7043 non-null object 5 tenure 7043 non-null int64 6 PhoneService 7043 non-null object 7 MultipleLines 7043 non-null object 8 InternetService 7043 non-null object 9 OnlineSecurity 7043 non-null object 10 OnlineBackup 7043 non-null object 11 DeviceProtection 7043 non-null object 12 TechSupport 7043 non-null object 13 StreamingTV 7043 non-null object 14 StreamingMovies 7043 non-null object 15 Contract 7043 non-null object 16 PaperlessBilling 7043 non-null object 17 PaymentMethod 7043 non-null object 18 MonthlyCharges 7043 non-null float64 19 TotalCharges 7043 non-null object 20 Churn 7043 non-null object dtypes: float64(1), int64(2), object(18) memory usage: 1.1+ MB
Interpretation¶
The dataset consists of 7,043 observations and 21 variables, with all columns reporting 7,043 non-null values, indicating that there are no explicitly missing (NaN) values at this stage of the analysis.
The dataset contains three primary data types:
- 18 categorical (object) variables
- 2 integer variables
- 1 floating-point variable
Most variables are categorical and represent customer demographics, subscribed services, payment methods, and contract information. The numerical variables include customer tenure, monthly charges, and senior citizen status.
One notable observation is that the TotalCharges variable has been stored as an object (string) instead of a numerical data type. Since total charges represent a continuous monetary value, this suggests a potential data quality issue that requires further investigation before model development.
Additionally, the customerID variable serves as a unique identifier for each customer. While it is useful for record identification, it does not contain predictive information and will likely be excluded during model training.
Overall, the dataset appears complete; however, validating the data type of TotalCharges is an important preprocessing step before proceeding with exploratory analysis and machine learning.
6.2 Data Quality Assessment¶
Objective¶
Data quality assessment is a critical preprocessing step in any machine learning workflow. Poor data quality, such as missing values, duplicate records, or incorrect data types, can negatively affect model performance and lead to unreliable predictions.
This section aims to evaluate the integrity of the dataset by identifying hidden missing values, duplicate records, and inconsistencies that require cleaning before model development.
df.isnull().sum()
customerID 0 gender 0 SeniorCitizen 0 Partner 0 Dependents 0 tenure 0 PhoneService 0 MultipleLines 0 InternetService 0 OnlineSecurity 0 OnlineBackup 0 DeviceProtection 0 TechSupport 0 StreamingTV 0 StreamingMovies 0 Contract 0 PaperlessBilling 0 PaymentMethod 0 MonthlyCharges 0 TotalCharges 11 Churn 0 dtype: int64
df["TotalCharges"].head(10)
0 29.85 1 1889.50 2 108.15 3 1840.75 4 151.65 5 820.50 6 1949.40 7 301.90 8 3046.05 9 3487.95 Name: TotalCharges, dtype: float64
Interpretation¶
The initial inspection indicated that the dataset contained no missing values. However, after attempting to convert the TotalCharges column from a string (object) to a numeric data type, 11 missing values were introduced.
This confirms that the dataset contained hidden missing values, represented as blank strings rather than standard NaN values. Such hidden missing values are common in real-world datasets and can easily be overlooked if only isnull() is used during data quality assessment.
No duplicate records were identified, indicating that each observation corresponds to a unique customer.
Overall, the dataset exhibits excellent data quality, with only a small number of hidden missing values requiring treatment before model development.
df[df["TotalCharges"].isnull()]
| customerID | gender | SeniorCitizen | Partner | Dependents | tenure | PhoneService | MultipleLines | InternetService | OnlineSecurity | OnlineBackup | DeviceProtection | TechSupport | StreamingTV | StreamingMovies | Contract | PaperlessBilling | PaymentMethod | MonthlyCharges | TotalCharges | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 488 | 4472-LVYGI | Female | 0 | Yes | Yes | 0 | No | No phone service | DSL | Yes | No | Yes | Yes | Yes | No | Two year | Yes | Bank transfer (automatic) | 52.55 | NaN | No |
| 753 | 3115-CZMZD | Male | 0 | No | Yes | 0 | Yes | No | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | Two year | No | Mailed check | 20.25 | NaN | No |
| 936 | 5709-LVOEQ | Female | 0 | Yes | Yes | 0 | Yes | No | DSL | Yes | Yes | Yes | No | Yes | Yes | Two year | No | Mailed check | 80.85 | NaN | No |
| 1082 | 4367-NUYAO | Male | 0 | Yes | Yes | 0 | Yes | Yes | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | Two year | No | Mailed check | 25.75 | NaN | No |
| 1340 | 1371-DWPAZ | Female | 0 | Yes | Yes | 0 | No | No phone service | DSL | Yes | Yes | Yes | Yes | Yes | No | Two year | No | Credit card (automatic) | 56.05 | NaN | No |
| 3331 | 7644-OMVMY | Male | 0 | Yes | Yes | 0 | Yes | No | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | Two year | No | Mailed check | 19.85 | NaN | No |
| 3826 | 3213-VVOLG | Male | 0 | Yes | Yes | 0 | Yes | Yes | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | Two year | No | Mailed check | 25.35 | NaN | No |
| 4380 | 2520-SGTTA | Female | 0 | Yes | Yes | 0 | Yes | No | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | Two year | No | Mailed check | 20.00 | NaN | No |
| 5218 | 2923-ARZLG | Male | 0 | Yes | Yes | 0 | Yes | No | No | No internet service | No internet service | No internet service | No internet service | No internet service | No internet service | One year | Yes | Mailed check | 19.70 | NaN | No |
| 6670 | 4075-WKNIU | Female | 0 | Yes | Yes | 0 | Yes | Yes | DSL | No | Yes | Yes | Yes | Yes | No | Two year | No | Mailed check | 73.35 | NaN | No |
| 6754 | 2775-SEFEE | Male | 0 | No | Yes | 0 | Yes | Yes | DSL | Yes | Yes | No | Yes | No | No | Two year | Yes | Bank transfer (automatic) | 61.90 | NaN | No |
df[df["TotalCharges"].isnull()][
["customerID",
"tenure",
"MonthlyCharges",
"Contract",
"Churn"]
]
| customerID | tenure | MonthlyCharges | Contract | Churn | |
|---|---|---|---|---|---|
| 488 | 4472-LVYGI | 0 | 52.55 | Two year | No |
| 753 | 3115-CZMZD | 0 | 20.25 | Two year | No |
| 936 | 5709-LVOEQ | 0 | 80.85 | Two year | No |
| 1082 | 4367-NUYAO | 0 | 25.75 | Two year | No |
| 1340 | 1371-DWPAZ | 0 | 56.05 | Two year | No |
| 3331 | 7644-OMVMY | 0 | 19.85 | Two year | No |
| 3826 | 3213-VVOLG | 0 | 25.35 | Two year | No |
| 4380 | 2520-SGTTA | 0 | 20.00 | Two year | No |
| 5218 | 2923-ARZLG | 0 | 19.70 | One year | No |
| 6670 | 4075-WKNIU | 0 | 73.35 | Two year | No |
| 6754 | 2775-SEFEE | 0 | 61.90 | Two year | No |
Interpretation¶
Further investigation revealed that all eleven customers with missing TotalCharges have a tenure of zero months. This indicates that these customers are newly registered and have not yet completed a billing cycle, explaining why cumulative charges are unavailable.
The missing values therefore appear to be structural rather than resulting from data entry errors or system failures.
Data Cleaning Decision¶
Since the missing values represent less than 0.2% of the dataset and correspond exclusively to newly registered customers, these observations will be removed prior to model development.
Removing the affected records preserves data integrity while avoiding unnecessary imputation of a financial variable.
df = df.dropna().reset_index(drop=True)
print(df.shape)
df.isnull().sum()
(7032, 21)
customerID 0 gender 0 SeniorCitizen 0 Partner 0 Dependents 0 tenure 0 PhoneService 0 MultipleLines 0 InternetService 0 OnlineSecurity 0 OnlineBackup 0 DeviceProtection 0 TechSupport 0 StreamingTV 0 StreamingMovies 0 Contract 0 PaperlessBilling 0 PaymentMethod 0 MonthlyCharges 0 TotalCharges 0 Churn 0 dtype: int64
6.3 Target Variable Analysis¶
Objective¶
Before developing a classification model, it is important to understand the distribution of the target variable. An imbalanced target can influence model performance and may require specialized evaluation metrics or resampling techniques.
This section examines the distribution of customer churn to determine the proportion of customers who stayed versus those who left the company.
#distribution of churn
plt.figure(figsize=(6,5))
ax = sns.countplot(
data=df,
x="Churn",
palette="Set2"
)
plt.title("Distribution of Customer Churn")
plt.xlabel("Churn Status")
plt.ylabel("Number of Customers")
for container in ax.containers:
ax.bar_label(container)
plt.show()
#percentage distribution of churn
churn_percentage = (
df["Churn"]
.value_counts(normalize=True)
.mul(100)
.round(2)
)
churn_percentage
Churn No 73.42 Yes 26.58 Name: proportion, dtype: float64
Interpretation¶
The target variable shows that approximately 73.42% of customers remained with the company, while 26.58% discontinued the service.
This indicates a moderate class imbalance, where the majority class (non-churning customers) substantially outnumbers the minority class (customers who churned). Although the imbalance is not extreme, it should be considered during model development and evaluation to ensure that the predictive model performs well for both classes.
Business Insight¶
Approximately one in every four customers has churned, representing a significant loss of customers and potential revenue. This highlights customer retention as a critical business priority and reinforces the need for predictive models capable of identifying high-risk customers before they leave.
Even a modest improvement in identifying and retaining at-risk customers could translate into substantial financial benefits for the organization.
6.4 Contract Type and Customer Churn¶
Business Question¶
Does the type of customer contract influence the likelihood of churn?
Contract duration often reflects customer commitment and loyalty. Understanding the relationship between contract type and churn can help the business design retention strategies and encourage customers to adopt longer-term subscriptions.
#examine the distribution
df["Contract"].value_counts()
Contract Month-to-month 3875 Two year 1685 One year 1472 Name: count, dtype: int64
#cross-tabulation
contract_churn = pd.crosstab(
df["Contract"],
df["Churn"]
)
contract_churn
| Churn | No | Yes |
|---|---|---|
| Contract | ||
| Month-to-month | 2220 | 1655 |
| One year | 1306 | 166 |
| Two year | 1637 | 48 |
#convert to percentages
contract_pct = pd.crosstab(
df["Contract"],
df["Churn"],
normalize="index"
) * 100
contract_pct.round(2)
| Churn | No | Yes |
|---|---|---|
| Contract | ||
| Month-to-month | 57.29 | 42.71 |
| One year | 88.72 | 11.28 |
| Two year | 97.15 | 2.85 |
plt.figure(figsize=(8,5))
ax = sns.countplot(
data=df,
x="Contract",
hue="Churn",
palette="Set2"
)
plt.title("Customer Churn by Contract Type")
plt.xlabel("Contract Type")
plt.ylabel("Number of Customers")
for container in ax.containers:
ax.bar_label(container)
plt.show()
Interpretation¶
Contract type exhibits a strong relationship with customer churn.
Customers on month-to-month contracts experience the highest churn rate, with approximately 42.7% discontinuing the service. In contrast, customers on one-year contracts have a substantially lower churn rate of approximately 11.3%, while customers on two-year contracts demonstrate the highest retention, with only 2.9% churning.
These findings suggest a clear inverse relationship between contract duration and customer churn: as contract length increases, the likelihood of churn decreases significantly.
Business Insight¶
The results indicate that contract commitment plays a critical role in customer retention. Customers enrolled in long-term contracts are considerably less likely to leave the company than those on flexible month-to-month plans.
This may reflect increased customer commitment, stronger loyalty, or contractual obligations that discourage switching to competitors.
6.5 Customer Tenure and Customer Churn¶
Business Question¶
Does the length of time a customer has been with the company influence their likelihood of churning?
Customer tenure is a key indicator of customer loyalty and engagement. By examining the relationship between tenure and churn, we can determine whether newly acquired customers are more likely to leave and identify opportunities for improving customer retention.
#summary statistics of numerical features
df.groupby("Churn")["tenure"].describe().round(2)
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| Churn | ||||||||
| No | 5163.0 | 37.65 | 24.08 | 1.0 | 15.0 | 38.0 | 61.0 | 72.0 |
| Yes | 1869.0 | 17.98 | 19.53 | 1.0 | 2.0 | 10.0 | 29.0 | 72.0 |
plt.figure(figsize=(8,5))
sns.histplot(
data=df,
x="tenure",
hue="Churn",
bins=30,
kde=True,
element="step"
)
plt.title("Distribution of Customer Tenure by Churn Status")
plt.show()
Interpretation¶
The descriptive statistics reveal a substantial difference in customer tenure between those who remained with the company and those who churned.
Customers who did not churn have an average tenure of 37.65 months, whereas customers who churned have an average tenure of only 17.98 months—less than half that of retained customers.
The median tenure further reinforces this pattern. Half of the churned customers have been with the company for 10 months or less, while half of the retained customers have remained for at least 38 months.
These findings suggest a strong negative relationship between customer tenure and churn, indicating that customers are most vulnerable to leaving during the early stages of their relationship with the company.
Business Insight¶
The analysis suggests that customer attrition is concentrated among relatively new customers. This indicates that the company's onboarding experience, early customer engagement, and initial service satisfaction may play a significant role in determining long-term retention.
Once customers remain with the company for an extended period, they appear considerably more likely to continue using the service, demonstrating increased loyalty over time.
6.6 Monthly Charges and Customer Churn¶
Business Question¶
Does the amount a customer pays each month influence their likelihood of churning?
Pricing plays a crucial role in customer retention. If customers with higher monthly charges are more likely to leave, it may indicate pricing dissatisfaction, perceived lack of value, or stronger competition from alternative providers. This analysis explores whether monthly charges are associated with customer churn.
df.groupby("Churn")["MonthlyCharges"].describe().round(2)
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| Churn | ||||||||
| No | 5163.0 | 61.31 | 31.09 | 18.25 | 25.10 | 64.45 | 88.48 | 118.75 |
| Yes | 1869.0 | 74.44 | 24.67 | 18.85 | 56.15 | 79.65 | 94.20 | 118.35 |
plt.figure(figsize=(8,5))
sns.histplot(
data=df,
x="MonthlyCharges",
hue="Churn",
kde=True,
bins=30,
element="step"
)
plt.title("Distribution of Monthly Charges by Churn Status")
plt.show()
📖 Interpretation¶
Customers who churned pay noticeably higher monthly charges than those who remained with the company.
The average monthly charge for customers who churned is $74.44, compared to $61.31 for retained customers. Similarly, the median monthly charge is $79.65 for churned customers versus $64.45 for retained customers.
Although both groups have similar minimum and maximum monthly charges, the higher average and median among churned customers suggest that customers paying more each month are generally more likely to discontinue the service.
This relationship indicates that monthly pricing may influence customer retention, although it is unlikely to be the sole driver of churn.
6.7 Technical Support and Customer Churn¶
Business Question¶
Does access to technical support influence customer churn?
Technical support plays an important role in customer satisfaction and service quality. Customers who receive timely assistance are generally more likely to remain loyal to a service provider. This analysis investigates whether the availability of technical support is associated with customer retention.
df["TechSupport"].value_counts()
TechSupport No 3472 Yes 2040 No internet service 1520 Name: count, dtype: int64
#percentage table
tech_pct = (
pd.crosstab(
df["TechSupport"],
df["Churn"],
normalize="index"
) * 100
).round(2)
tech_pct
| Churn | No | Yes |
|---|---|---|
| TechSupport | ||
| No | 58.35 | 41.65 |
| No internet service | 92.57 | 7.43 |
| Yes | 84.80 | 15.20 |
plt.figure(figsize=(8,5))
ax = sns.countplot(
data=df,
x="TechSupport",
hue="Churn",
palette="Set2"
)
plt.title("Customer Churn by Technical Support")
for container in ax.containers:
ax.bar_label(container)
plt.show()
📖 Interpretation¶
Technical support exhibits a clear relationship with customer churn.
Among customers without technical support, approximately 41.65% churned, compared to only 15.20% of customers who subscribed to technical support.
Customers without internet service showed the lowest churn rate (7.43%). However, because technical support is not applicable to customers without internet service, this category should be interpreted separately.
Overall, the results suggest that customers with access to technical support are substantially more likely to remain with the company than those without support.
6.8 Executive Summary of Exploratory Data Analysis¶
The exploratory data analysis identified several important factors associated with customer churn.
Key Findings¶
Contract Type
- Customers on month-to-month contracts exhibited the highest churn rates.
- Long-term contracts were strongly associated with customer retention.
Customer Tenure
- Customers with shorter tenures were considerably more likely to churn.
- The first year of the customer lifecycle appears to be the highest-risk period.
Monthly Charges
- Customers paying higher monthly charges demonstrated an increased likelihood of churn.
- Pricing should be evaluated alongside perceived service value.
Technical Support
- Customers without technical support experienced substantially higher churn rates.
- Access to effective customer support appears to improve retention.
Overall Conclusion¶
The exploratory analysis indicates that customer churn is influenced by a combination of contractual commitment, customer experience, service value, and customer tenure. These findings provide a strong foundation for developing predictive machine learning models and designing targeted customer retention strategies.
7. Data Preprocessing¶
Objective¶
Raw data cannot be directly used for machine learning because it may contain irrelevant features, inconsistent data types, and categorical variables that require transformation.
This section prepares the dataset for model development by removing unnecessary variables, encoding categorical features, and constructing a preprocessing pipeline that ensures consistent data transformation during both training and inference.
#remove customer ID
df = df.drop(columns=["customerID"])
Feature Selection¶
The customerID variable uniquely identifies each customer but does not describe customer behaviour or service usage.
Since machine learning models cannot learn meaningful patterns from arbitrary identifiers, this feature is removed prior to model training.
#separate features and target
X = df.drop(columns=["Churn"])
y = df["Churn"]
Defining Features and Target Variable¶
The dataset is separated into:
- Features (
X): Customer characteristics used to predict churn. - Target (
y): Customer churn status.
#identify categories
categorical_features = X.select_dtypes(include="object").columns.tolist()
numerical_features = X.select_dtypes(exclude="object").columns.tolist()
print(categorical_features)
print(numerical_features)
['gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod'] ['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges']
#train/test spli
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.2,
stratify=y,
random_state=42
)
#procesing pipline
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import (
OneHotEncoder,
StandardScaler
)
preprocessor = ColumnTransformer(
transformers=[
(
"num",
StandardScaler(),
numerical_features
),
(
"cat",
OneHotEncoder(
handle_unknown="ignore"
),
categorical_features
)
]
)
7.2 Baseline Model¶
Objective¶
A baseline model provides an initial performance benchmark against which more complex machine learning algorithms can be compared.
Logistic Regression was selected as the baseline because it is computationally efficient, interpretable, and well-suited for binary classification problems.
#build the pipeline
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
logistic_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", LogisticRegression(
random_state=42,
max_iter=1000
))
])
logistic_pipeline.fit(X_train, y_train)
Pipeline(steps=[('preprocessor',
ColumnTransformer(transformers=[('num', StandardScaler(),
['SeniorCitizen', 'tenure',
'MonthlyCharges',
'TotalCharges']),
('cat',
OneHotEncoder(handle_unknown='ignore'),
['gender', 'Partner',
'Dependents', 'PhoneService',
'MultipleLines',
'InternetService',
'OnlineSecurity',
'OnlineBackup',
'DeviceProtection',
'TechSupport', 'StreamingTV',
'StreamingMovies',
'Contract',
'PaperlessBilling',
'PaymentMethod'])])),
('classifier',
LogisticRegression(max_iter=1000, random_state=42))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| steps | [('preprocessor', ...), ('classifier', ...)] | |
| transform_input | None | |
| memory | None | |
| verbose | False |
Parameters
| transformers | [('num', ...), ('cat', ...)] | |
| remainder | 'drop' | |
| sparse_threshold | 0.3 | |
| n_jobs | None | |
| transformer_weights | None | |
| verbose | False | |
| verbose_feature_names_out | True | |
| force_int_remainder_cols | 'deprecated' |
['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges']
Parameters
| copy | True | |
| with_mean | True | |
| with_std | True |
['gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod']
Parameters
| categories | 'auto' | |
| drop | None | |
| sparse_output | True | |
| dtype | <class 'numpy.float64'> | |
| handle_unknown | 'ignore' | |
| min_frequency | None | |
| max_categories | None | |
| feature_name_combiner | 'concat' |
Parameters
| penalty | 'l2' | |
| dual | False | |
| tol | 0.0001 | |
| C | 1.0 | |
| fit_intercept | True | |
| intercept_scaling | 1 | |
| class_weight | None | |
| random_state | 42 | |
| solver | 'lbfgs' | |
| max_iter | 1000 | |
| multi_class | 'deprecated' | |
| verbose | 0 | |
| warm_start | False | |
| n_jobs | None | |
| l1_ratio | None |
#train
y_pred = logistic_pipeline.predict(X_test)
y_prob = logistic_pipeline.predict_proba(X_test)[:, 1]
from sklearn.metrics import (
accuracy_score,
precision_score,
recall_score,
f1_score,
roc_auc_score,
confusion_matrix,
classification_report
)
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, pos_label="Yes")
recall = recall_score(y_test, y_pred, pos_label="Yes")
f1 = f1_score(y_test, y_pred, pos_label="Yes")
roc_auc = roc_auc_score((y_test == "Yes").astype(int), y_prob)
print(f"Accuracy : {accuracy:.3f}")
print(f"Precision: {precision:.3f}")
print(f"Recall : {recall:.3f}")
print(f"F1 Score : {f1:.3f}")
print(f"ROC-AUC : {roc_auc:.3f}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred))
print("\nConfusion Matrix:")
print(confusion_matrix(y_test, y_pred))
Accuracy : 0.804
Precision: 0.648
Recall : 0.572
F1 Score : 0.608
ROC-AUC : 0.836
Classification Report:
precision recall f1-score support
No 0.85 0.89 0.87 1033
Yes 0.65 0.57 0.61 374
accuracy 0.80 1407
macro avg 0.75 0.73 0.74 1407
weighted avg 0.80 0.80 0.80 1407
Confusion Matrix:
[[917 116]
[160 214]]
7.3 Baseline Model Evaluation¶
📖 Interpretation¶
The Logistic Regression model achieved an overall accuracy of 80.4%, indicating good predictive performance on the test dataset.
The model obtained a Precision of 64.8%, meaning that approximately two out of every three customers predicted to churn were correctly identified.
The Recall of 57.2% indicates that the model successfully identified slightly more than half of the customers who actually churned. While this represents a reasonable baseline, some at-risk customers remain undetected.
The model achieved an F1-score of 60.8%, reflecting a balanced trade-off between Precision and Recall.
Finally, the ROC-AUC score of 0.836 demonstrates strong discriminative ability, suggesting that the model effectively distinguishes between customers who churn and those who remain.
7.4 Model Evaluation Utility¶
Rather than evaluating each model separately, a reusable evaluation function is developed to ensure consistency across all machine learning models. This approach promotes cleaner code, easier model comparison, and improved maintainability.
import pandas as pd
from sklearn.metrics import (
accuracy_score,
precision_score,
recall_score,
f1_score,
roc_auc_score
)
model_results = []
def evaluate_model(name, model, X_test, y_test):
"""
Evaluate a trained classification model and
store its performance metrics.
"""
y_pred = model.predict(X_test)
y_prob = model.predict_proba(X_test)[:, 1]
results = {
"Model": name,
"Accuracy": accuracy_score(y_test, y_pred),
"Precision": precision_score(
y_test,
y_pred,
pos_label="Yes"
),
"Recall": recall_score(
y_test,
y_pred,
pos_label="Yes"
),
"F1 Score": f1_score(
y_test,
y_pred,
pos_label="Yes"
),
"ROC-AUC": roc_auc_score(
(y_test == "Yes").astype(int),
y_prob
)
}
model_results.append(results)
return pd.DataFrame(model_results).round(3)
model_results = []
results_df = evaluate_model(
"Logistic Regression",
logistic_pipeline,
X_test,
y_test
)
results_df
| Model | Accuracy | Precision | Recall | F1 Score | ROC-AUC | |
|---|---|---|---|---|---|---|
| 0 | Logistic Regression | 0.804 | 0.648 | 0.572 | 0.608 | 0.836 |
#decision tree
from sklearn.tree import DecisionTreeClassifier
decision_tree_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", DecisionTreeClassifier(
random_state=42
))
])
decision_tree_pipeline.fit(
X_train,
y_train
)
Pipeline(steps=[('preprocessor',
ColumnTransformer(transformers=[('num', StandardScaler(),
['SeniorCitizen', 'tenure',
'MonthlyCharges',
'TotalCharges']),
('cat',
OneHotEncoder(handle_unknown='ignore'),
['gender', 'Partner',
'Dependents', 'PhoneService',
'MultipleLines',
'InternetService',
'OnlineSecurity',
'OnlineBackup',
'DeviceProtection',
'TechSupport', 'StreamingTV',
'StreamingMovies',
'Contract',
'PaperlessBilling',
'PaymentMethod'])])),
('classifier', DecisionTreeClassifier(random_state=42))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| steps | [('preprocessor', ...), ('classifier', ...)] | |
| transform_input | None | |
| memory | None | |
| verbose | False |
Parameters
| transformers | [('num', ...), ('cat', ...)] | |
| remainder | 'drop' | |
| sparse_threshold | 0.3 | |
| n_jobs | None | |
| transformer_weights | None | |
| verbose | False | |
| verbose_feature_names_out | True | |
| force_int_remainder_cols | 'deprecated' |
['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges']
Parameters
| copy | True | |
| with_mean | True | |
| with_std | True |
['gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod']
Parameters
| categories | 'auto' | |
| drop | None | |
| sparse_output | True | |
| dtype | <class 'numpy.float64'> | |
| handle_unknown | 'ignore' | |
| min_frequency | None | |
| max_categories | None | |
| feature_name_combiner | 'concat' |
Parameters
| criterion | 'gini' | |
| splitter | 'best' | |
| max_depth | None | |
| min_samples_split | 2 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | None | |
| random_state | 42 | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| monotonic_cst | None |
7.5 Decision Tree Classifier¶
Objective¶
Decision Trees are non-linear machine learning models capable of learning hierarchical decision rules from data. Unlike Logistic Regression, they can capture complex feature interactions without requiring explicit feature engineering.
The Decision Tree model is evaluated to determine whether modeling non-linear relationships improves customer churn prediction.
from sklearn.tree import DecisionTreeClassifier
decision_tree_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", DecisionTreeClassifier(
random_state=42,
max_depth=5, # Prevent excessive overfitting
min_samples_split=20,
min_samples_leaf=10
))
])
#train
decision_tree_pipeline.fit(X_train, y_train)
Pipeline(steps=[('preprocessor',
ColumnTransformer(transformers=[('num', StandardScaler(),
['SeniorCitizen', 'tenure',
'MonthlyCharges',
'TotalCharges']),
('cat',
OneHotEncoder(handle_unknown='ignore'),
['gender', 'Partner',
'Dependents', 'PhoneService',
'MultipleLines',
'InternetService',
'OnlineSecurity',
'OnlineBackup',
'DeviceProtection',
'TechSupport', 'StreamingTV',
'StreamingMovies',
'Contract',
'PaperlessBilling',
'PaymentMethod'])])),
('classifier',
DecisionTreeClassifier(max_depth=5, min_samples_leaf=10,
min_samples_split=20,
random_state=42))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| steps | [('preprocessor', ...), ('classifier', ...)] | |
| transform_input | None | |
| memory | None | |
| verbose | False |
Parameters
| transformers | [('num', ...), ('cat', ...)] | |
| remainder | 'drop' | |
| sparse_threshold | 0.3 | |
| n_jobs | None | |
| transformer_weights | None | |
| verbose | False | |
| verbose_feature_names_out | True | |
| force_int_remainder_cols | 'deprecated' |
['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges']
Parameters
| copy | True | |
| with_mean | True | |
| with_std | True |
['gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod']
Parameters
| categories | 'auto' | |
| drop | None | |
| sparse_output | True | |
| dtype | <class 'numpy.float64'> | |
| handle_unknown | 'ignore' | |
| min_frequency | None | |
| max_categories | None | |
| feature_name_combiner | 'concat' |
Parameters
| criterion | 'gini' | |
| splitter | 'best' | |
| max_depth | 5 | |
| min_samples_split | 20 | |
| min_samples_leaf | 10 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | None | |
| random_state | 42 | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| monotonic_cst | None |
#evaluate
results_df = evaluate_model(
"Decision Tree",
decision_tree_pipeline,
X_test,
y_test
)
results_df
| Model | Accuracy | Precision | Recall | F1 Score | ROC-AUC | |
|---|---|---|---|---|---|---|
| 0 | Logistic Regression | 0.804 | 0.648 | 0.572 | 0.608 | 0.836 |
| 1 | Decision Tree | 0.790 | 0.604 | 0.615 | 0.609 | 0.828 |
#confusion matrix
from sklearn.metrics import ConfusionMatrixDisplay
ConfusionMatrixDisplay.from_predictions(
y_test,
decision_tree_pipeline.predict(X_test)
)
plt.title("Decision Tree Confusion Matrix")
plt.show()
7.5 Decision Tree Evaluation¶
📖 Interpretation¶
The Decision Tree classifier achieved an overall accuracy of 79.0%, which is slightly lower than the Logistic Regression baseline.
However, the Decision Tree achieved a higher Recall (61.5%), indicating that it successfully identified more customers who actually churned. This improvement came at the expense of Precision, meaning the model generated more false positive predictions.
The ROC-AUC score of 0.828 indicates good discrimination between churning and non-churning customers, although it remains slightly below that of the Logistic Regression model.
#Random forest
from sklearn.ensemble import RandomForestClassifier
#pipeline
random_forest_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", RandomForestClassifier(
n_estimators=300,
max_depth=10,
min_samples_split=10,
min_samples_leaf=5,
random_state=42,
n_jobs=-1
))
])
#train & evaluate
random_forest_pipeline.fit(
X_train,
y_train
)
results_df = evaluate_model(
"Random Forest",
random_forest_pipeline,
X_test,
y_test
)
results_df
| Model | Accuracy | Precision | Recall | F1 Score | ROC-AUC | |
|---|---|---|---|---|---|---|
| 0 | Logistic Regression | 0.804 | 0.648 | 0.572 | 0.608 | 0.836 |
| 1 | Decision Tree | 0.790 | 0.604 | 0.615 | 0.609 | 0.828 |
| 2 | Random Forest | 0.792 | 0.637 | 0.503 | 0.562 | 0.834 |
7.6 Extreme Gradient Boosting (XGBoost)¶
Objective¶
Extreme Gradient Boosting (XGBoost) is an ensemble learning algorithm that builds trees sequentially, with each new tree correcting the errors of previous trees. It is widely regarded as one of the most effective algorithms for structured (tabular) datasets due to its strong predictive performance and built-in regularization mechanisms.
The XGBoost model is evaluated to determine whether gradient boosting improves customer churn prediction over the previously evaluated models.
#XGBoost
from xgboost import XGBClassifier
#encode target
from sklearn.preprocessing import LabelEncoder
label_encoder = LabelEncoder()
y_train_encoded = label_encoder.fit_transform(y_train)
y_test_encoded = label_encoder.transform(y_test)
#build pipeline & train
xgb_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", XGBClassifier(
objective="binary:logistic",
eval_metric="logloss",
n_estimators=300,
learning_rate=0.05,
max_depth=5,
subsample=0.8,
colsample_bytree=0.8,
random_state=42
))
])
xgb_pipeline.fit(
X_train,
y_train_encoded
)
Pipeline(steps=[('preprocessor',
ColumnTransformer(transformers=[('num', StandardScaler(),
['SeniorCitizen', 'tenure',
'MonthlyCharges',
'TotalCharges']),
('cat',
OneHotEncoder(handle_unknown='ignore'),
['gender', 'Partner',
'Dependents', 'PhoneService',
'MultipleLines',
'InternetService',
'OnlineSecurity',
'OnlineBackup',
'DeviceProtection',
'TechSupport', 'Streamin...
feature_types=None, feature_weights=None,
gamma=None, grow_policy=None,
importance_type=None,
interaction_constraints=None, learning_rate=0.05,
max_bin=None, max_cat_threshold=None,
max_cat_to_onehot=None, max_delta_step=None,
max_depth=5, max_leaves=None,
min_child_weight=None, missing=nan,
monotone_constraints=None, multi_strategy=None,
n_estimators=300, n_jobs=None,
num_parallel_tree=None, ...))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| steps | [('preprocessor', ...), ('classifier', ...)] | |
| transform_input | None | |
| memory | None | |
| verbose | False |
Parameters
| transformers | [('num', ...), ('cat', ...)] | |
| remainder | 'drop' | |
| sparse_threshold | 0.3 | |
| n_jobs | None | |
| transformer_weights | None | |
| verbose | False | |
| verbose_feature_names_out | True | |
| force_int_remainder_cols | 'deprecated' |
['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges']
Parameters
| copy | True | |
| with_mean | True | |
| with_std | True |
['gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod']
Parameters
| categories | 'auto' | |
| drop | None | |
| sparse_output | True | |
| dtype | <class 'numpy.float64'> | |
| handle_unknown | 'ignore' | |
| min_frequency | None | |
| max_categories | None | |
| feature_name_combiner | 'concat' |
Parameters
| objective | 'binary:logistic' | |
| base_score | None | |
| booster | None | |
| callbacks | None | |
| colsample_bylevel | None | |
| colsample_bynode | None | |
| colsample_bytree | 0.8 | |
| device | None | |
| early_stopping_rounds | None | |
| enable_categorical | False | |
| eval_metric | 'logloss' | |
| feature_types | None | |
| feature_weights | None | |
| gamma | None | |
| grow_policy | None | |
| importance_type | None | |
| interaction_constraints | None | |
| learning_rate | 0.05 | |
| max_bin | None | |
| max_cat_threshold | None | |
| max_cat_to_onehot | None | |
| max_delta_step | None | |
| max_depth | 5 | |
| max_leaves | None | |
| min_child_weight | None | |
| missing | nan | |
| monotone_constraints | None | |
| multi_strategy | None | |
| n_estimators | 300 | |
| n_jobs | None | |
| num_parallel_tree | None | |
| random_state | 42 | |
| reg_alpha | None | |
| reg_lambda | None | |
| sampling_method | None | |
| scale_pos_weight | None | |
| subsample | 0.8 | |
| tree_method | None | |
| validate_parameters | None | |
| verbosity | None |
#evaluate
from sklearn.metrics import (
accuracy_score,
precision_score,
recall_score,
f1_score,
roc_auc_score
)
y_pred = xgb_pipeline.predict(X_test)
y_prob = xgb_pipeline.predict_proba(X_test)[:, 1]
print("Accuracy :", accuracy_score(y_test_encoded, y_pred))
print("Precision:", precision_score(y_test_encoded, y_pred))
print("Recall :", recall_score(y_test_encoded, y_pred))
print("F1 Score :", f1_score(y_test_encoded, y_pred))
print("ROC-AUC :", roc_auc_score(y_test_encoded, y_prob))
Accuracy : 0.7789623312011372 Precision: 0.5957446808510638 Recall : 0.5240641711229946 F1 Score : 0.5576102418207681 ROC-AUC : 0.8229094946964088
#hyperparameter tuning
from sklearn.model_selection import RandomizedSearchCV
param_grid = {
"classifier__C": [0.001, 0.01, 0.1, 1, 10, 100],
"classifier__solver": ["liblinear", "lbfgs"],
"classifier__penalty": ["l2"]
}
search = RandomizedSearchCV(
estimator=logistic_pipeline,
param_distributions=param_grid,
n_iter=10,
scoring="roc_auc",
cv=5,
random_state=42,
n_jobs=-1
)
search.fit(X_train, y_train)
print(search.best_params_)
print(search.best_score_)
{'classifier__solver': 'lbfgs', 'classifier__penalty': 'l2', 'classifier__C': 100}
0.8462408998518063
#save best model
best_model = search.best_estimator_
#evaluate tuned models
y_pred = best_model.predict(X_test)
y_prob = best_model.predict_proba(X_test)[:, 1]
print(f"Accuracy : {accuracy_score(y_test, y_pred):.3f}")
print(f"Precision: {precision_score(y_test, y_pred, pos_label='Yes'):.3f}")
print(f"Recall : {recall_score(y_test, y_pred, pos_label='Yes'):.3f}")
print(f"F1 Score : {f1_score(y_test, y_pred, pos_label='Yes'):.3f}")
print(f"ROC-AUC : {roc_auc_score((y_test=='Yes').astype(int), y_prob):.3f}")
Accuracy : 0.797 Precision: 0.632 Recall : 0.564 F1 Score : 0.596 ROC-AUC : 0.835
#feature importance
feature_names = logistic_pipeline.named_steps["preprocessor"].get_feature_names_out()
coefficients = logistic_pipeline.named_steps["classifier"].coef_[0]
importance_df = pd.DataFrame({
"Feature": feature_names,
"Coefficient": coefficients
})
importance_df["Abs_Coefficient"] = importance_df["Coefficient"].abs()
importance_df = importance_df.sort_values(
by="Abs_Coefficient",
ascending=False
)
importance_df.head(20)
| Feature | Coefficient | Abs_Coefficient | |
|---|---|---|---|
| 1 | num__tenure | -1.352313 | 1.352313 |
| 38 | cat__Contract_Two year | -0.779030 | 0.779030 |
| 3 | num__TotalCharges | 0.644014 | 0.644014 |
| 15 | cat__InternetService_DSL | -0.616132 | 0.616132 |
| 36 | cat__Contract_Month-to-month | 0.613846 | 0.613846 |
| 16 | cat__InternetService_Fiber optic | 0.590184 | 0.590184 |
| 2 | num__MonthlyCharges | -0.541006 | 0.541006 |
| 39 | cat__PaperlessBilling_No | -0.300387 | 0.300387 |
| 12 | cat__MultipleLines_No | -0.293470 | 0.293470 |
| 17 | cat__InternetService_No | -0.283724 | 0.283724 |
| 19 | cat__OnlineSecurity_No internet service | -0.283724 | 0.283724 |
| 25 | cat__DeviceProtection_No internet service | -0.283724 | 0.283724 |
| 31 | cat__StreamingTV_No internet service | -0.283724 | 0.283724 |
| 22 | cat__OnlineBackup_No internet service | -0.283724 | 0.283724 |
| 28 | cat__TechSupport_No internet service | -0.283724 | 0.283724 |
| 34 | cat__StreamingMovies_No internet service | -0.283724 | 0.283724 |
| 9 | cat__Dependents_Yes | -0.270520 | 0.270520 |
| 30 | cat__StreamingTV_No | -0.217061 | 0.217061 |
| 11 | cat__PhoneService_Yes | -0.205307 | 0.205307 |
| 41 | cat__PaymentMethod_Bank transfer (automatic) | -0.204755 | 0.204755 |
#visualization
import matplotlib.pyplot as plt
top_features = importance_df.head(15)
plt.figure(figsize=(10,6))
plt.barh(top_features["Feature"], top_features["Coefficient"])
plt.gca().invert_yaxis()
plt.xlabel("Logistic Regression Coefficient")
plt.title("Top 15 Most Influential Features")
plt.tight_layout()
plt.show()
7.7 Feature Importance Analysis¶
Interpretation¶
The Logistic Regression coefficients provide insight into the variables that most strongly influence customer churn predictions.
Key observations include:
Tenure exhibited the largest negative coefficient (-1.352), indicating that customers with longer service durations are substantially less likely to churn.
Customers on Two-Year Contracts also showed a strong negative association with churn, suggesting that long-term contractual commitments significantly improve customer retention.
Month-to-Month Contracts displayed one of the strongest positive coefficients, indicating that customers without long-term contractual commitments are considerably more likely to discontinue the service.
Customers subscribed to Fiber Optic Internet were associated with an increased likelihood of churn, whereas customers using DSL Internet demonstrated a lower propensity to churn.
Monthly Charges contributed meaningfully to the prediction, suggesting that pricing influences customer retention behaviour.
Overall, the model indicates that contract type, customer tenure, internet service type, and monthly charges are the most influential predictors of customer churn.
#save final model
import joblib
joblib.dump(logistic_pipeline, "churn_prediction_pipeline.pkl")
print("Model saved successfully!")
Model saved successfully!