01Loading the dataset
We'll use the Telco Customer Churn dataset from IBM, a widely-used open dataset of 7,043 telecom customers with 21 features describing their account, services and demographics — and whether they churned.
import pandas as pd
import numpy as np
# Telco Customer Churn dataset (IBM, openly available)
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(df.shape)
print(df.head())
print(df['Churn'].value_counts())
(7043, 21) — 7,043 customers. Churn distribution: 5,174 stayed (No), 1,869 churned (Yes) — about 26.5% churn rate, which tells us immediately this is an imbalanced classification problem.
02Exploratory data analysis
We examine which factors correlate most with churn — contract type, tenure, and monthly charges are usually the strongest signals in telecom churn data.
import matplotlib.pyplot as plt
import seaborn as sns
# Convert TotalCharges to numeric (it's stored as string with some blanks)
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df = df.dropna(subset=['TotalCharges'])
# Churn rate by contract type
churn_by_contract = df.groupby('Contract')['Churn'].apply(
lambda x: (x == 'Yes').mean()
)
print(churn_by_contract)
# Tenure distribution by churn status
plt.figure(figsize=(8, 5))
sns.boxplot(data=df, x='Churn', y='tenure', palette=['#1a4a3a', '#e24b4a'])
plt.title('Customer Tenure by Churn Status')
plt.savefig('tenure_by_churn.png')
Month-to-month contracts churn at ~42% vs just ~3% for two-year contracts. Customers who churn also have much lower median tenure — they tend to leave within the first few months. This single insight (contract type) is often the strongest lever telecom companies have to reduce churn.
03Preprocessing & encoding
Logistic regression needs numeric input, so we encode categorical columns (Contract, PaymentMethod, etc.) using one-hot encoding, then split into train/test sets.
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Drop customer ID, encode target
df = df.drop('customerID', axis=1)
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
# One-hot encode all categorical columns
categorical_cols = df.select_dtypes(include='object').columns
df_encoded = pd.get_dummies(df, columns=categorical_cols, drop_first=True)
# Separate features and target
X = df_encoded.drop('Churn', axis=1)
y = df_encoded['Churn']
# Train/test split, stratified to preserve churn ratio in both sets
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# Scale numeric features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
print(f"Training samples: {X_train.shape[0]}, Features: {X_train.shape[1]}")
04Training the model
We train a Logistic Regression classifier with class_weight='balanced' — this tells the model to pay more attention to the minority class (churned customers), which is critical since they're under-represented.
from sklearn.linear_model import LogisticRegression
# class_weight='balanced' compensates for the 73/27 class imbalance
model = LogisticRegression(class_weight='balanced', max_iter=1000, random_state=42)
model.fit(X_train_scaled, y_train)
# Examine which features drive churn prediction most
coefficients = pd.DataFrame({
'Feature': X.columns,
'Coefficient': model.coef_[0]
}).sort_values('Coefficient', ascending=False)
print("Top 5 features increasing churn risk:")
print(coefficients.head())
print("\nTop 5 features decreasing churn risk:")
print(coefficients.tail())
05Evaluation
Accuracy alone is misleading on imbalanced data — a model that always predicts "no churn" would be 73% accurate while being useless. Instead we evaluate with precision, recall, and ROC-AUC, which properly account for the imbalance.
from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix
import seaborn as sns
y_pred = model.predict(X_test_scaled)
y_proba = model.predict_proba(X_test_scaled)[:, 1]
print(classification_report(y_test, y_pred, target_names=['Stayed', 'Churned']))
print(f"ROC-AUC Score: {roc_auc_score(y_test, y_proba):.4f}")
# Confusion matrix
cm = confusion_matrix(y_test, y_pred)
plt.figure(figsize=(6, 5))
sns.heatmap(cm, annot=True, fmt='d', cmap='Greens',
xticklabels=['Stayed', 'Churned'],
yticklabels=['Stayed', 'Churned'])
plt.title('Confusion Matrix')
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.savefig('confusion_matrix.png')
| Metric | Churned Class | What it means |
|---|---|---|
| Precision | 0.51 | 51% of customers flagged as "will churn" actually do |
| Recall | 0.79 | Model catches 79% of customers who actually churn |
| ROC-AUC | 0.84 | Strong ability to rank churners above non-churners |
Notice the deliberate tradeoff: recall (0.79) is prioritised over precision (0.51) using class_weight='balanced'. In churn prediction, missing an actual churner (false negative) usually costs more than wrongly flagging a loyal customer (false positive) — so this tradeoff is intentional, not a flaw.
06Real-world use case
Where this exact technique is used in industry
This project shows why classification problems need more thought than just calling .fit() — handling class imbalance correctly and choosing the right evaluation metric for the business problem is what separates a working churn model from a misleading one.
Want to build this with guided support?
Classification, imbalanced data and evaluation metrics are all covered hands-on in our Machine Learning training programme.
View Training Programs WhatsApp Us