Predicting Customer Churn in Telecom¶
DATA 53 – Midterm Project | Shahzada Aayan
¶
¶
Dataset: IBM Telco Customer Churn
Task: Binary Classification
Target: Churn (Yes / No)
Source: Kaggle – IBM Telco Customer Churn
¶
I picked this dataset because the problem made immediate sense to me. A phone company losing a customer is expensive — not just the lost monthly revenue but all the money spent acquiring that customer in the first place. If you can predict who's about to leave before they actually do, you can reach out and try to keep them. That's a real business decision with real money attached to it, which made it a lot more interesting to work on than a dataset where the stakes feel abstract.
¶
The dataset has 7,043 rows and 21 columns — big enough to train real models but small enough to run comfortably on my laptop. It also has a nice mix of numeric and categorical features, which meant I'd get to practice the full preprocessing pipeline.
1. Introduction¶
¶
The Problem¶
¶
Telecom is a competitive industry. Customers can switch providers pretty easily, and when they do, the company loses not just their monthly bill but everything it cost to sign them up. That's called churn — when a customer cancels their service.
¶
The people who care most about this are the retention team — the group responsible for keeping customers around. Their problem is they can't call every single customer every month. They need to know who to focus on.
¶
Research Question¶
¶
Can I build a model that predicts which customers are likely to churn, so the retention team can prioritize who to reach out to before it's too late?
¶
Why This Actually Matters¶
¶
A model that's good at ranking customers by churn risk gives the retention team a working list every week. Instead of guessing or calling everyone, they focus budget on the people most likely to leave. Even catching a fraction of those customers before they cancel translates directly into revenue saved. The output of this project isn't just a number — it's a tool someone could actually use.
2. Data Source & Description¶
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, GridSearchCV
from sklearn.preprocessing import StandardScaler, OneHotEncoder, LabelEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (accuracy_score, f1_score, roc_auc_score,
classification_report, confusion_matrix, ConfusionMatrixDisplay,
RocCurveDisplay)
import warnings
warnings.filterwarnings('ignore')
# Plotting style
sns.set_theme(style='whitegrid', palette='muted', font_scale=1.1)
plt.rcParams['figure.dpi'] = 110
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Shape: {df.shape}")
print(f"\nColumns:\n{list(df.columns)}")
df.head(3)
What's in the Dataset¶
¶
| Attribute | Value |
|---|---|
| Rows | 7,043 |
| Columns | 21 |
| Target | Churn (Yes / No) |
| Numeric features | tenure, MonthlyCharges, TotalCharges |
| Categorical features | gender, SeniorCitizen, Partner, Dependents, PhoneService, MultipleLines, InternetService, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies, Contract, PaperlessBilling, PaymentMethod |
¶
In a real company this data would come from a few different systems — a billing system for the charge information, a CRM for contract and tenure details, and a product system for which services each customer has. Someone would pull it all together into one table like this and use it to run predictions on the active customer base regularly.
3. Data Preparation & EDA¶
# --- 3.1 Basic inspection ---
print("Dtypes and nulls:")
print(df.dtypes)
print(f"\nMissing values per column:\n{df.isnull().sum()}")
# --- 3.2 Cleaning ---
# TotalCharges is stored as object due to whitespace entries; fix it
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
# Drop the 11 rows where TotalCharges couldn't be parsed (new customers, tenure=0)
df.dropna(subset=['TotalCharges'], inplace=True)
# Drop customerID — it's a unique identifier with no predictive value
df.drop(columns=['customerID'], inplace=True)
# SeniorCitizen is already 0/1 — leave it as numeric
# Encode target to binary int
df['Churn'] = (df['Churn'] == 'Yes').astype(int)
print(f"Clean shape: {df.shape}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")
print(f"Churn rate: {df['Churn'].mean():.1%}")
# --- 3.3 Define feature types ---
target = 'Churn'
numeric_features = ['tenure', 'MonthlyCharges', 'TotalCharges']
categorical_features = [
'gender', 'SeniorCitizen', 'Partner', 'Dependents',
'PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies',
'Contract', 'PaperlessBilling', 'PaymentMethod'
]
X = df[numeric_features + categorical_features]
y = df[target]
print(f"Features: {X.shape[1]} | Target classes: {y.value_counts().to_dict()}")
Visualization 1: Class Balance & Monthly Charges¶
¶
About 26% of customers churned. That's not terrible but it does mean the classes are unbalanced — there are roughly 3 stayed customers for every 1 churned customer. This matters because a model that just predicts "nobody churns" would still score 74% accuracy while being completely useless. That's why I'll focus on ROC AUC and F1 rather than raw accuracy as my main metrics.
¶
The monthly charges chart also shows something interesting — churned customers tend to have higher monthly bills. That's a useful signal.
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
# Countplot
churn_counts = y.value_counts()
axes[0].bar(['Stayed', 'Churned'], churn_counts.values, color=['#4C72B0', '#DD8452'], edgecolor='white', linewidth=1.2)
axes[0].set_title('Class Balance: Churn vs. Stayed', fontweight='bold')
axes[0].set_ylabel('Customer Count')
for i, v in enumerate(churn_counts.values):
axes[0].text(i, v + 50, f'{v:,}\n({v/len(y):.0%})', ha='center', fontsize=10)
# Monthly charges by churn
df_plot = df.copy()
df_plot['Churn_Label'] = df_plot['Churn'].map({0: 'Stayed', 1: 'Churned'})
axes[1].hist(df_plot[df_plot['Churn']==0]['MonthlyCharges'], bins=30, alpha=0.7, label='Stayed', color='#4C72B0')
axes[1].hist(df_plot[df_plot['Churn']==1]['MonthlyCharges'], bins=30, alpha=0.7, label='Churned', color='#DD8452')
axes[1].set_title('Monthly Charges Distribution by Churn', fontweight='bold')
axes[1].set_xlabel('Monthly Charges ($)')
axes[1].set_ylabel('Count')
axes[1].legend()
plt.tight_layout()
plt.savefig('viz1_class_balance.png', bbox_inches='tight')
plt.show()
Visualization 2: Churn Rate by Contract Type & Tenure¶
¶
This chart was the most revealing thing in the entire EDA. Month-to-month customers churn at about 4x the rate of customers on annual contracts. That's a massive difference and it tells you something the business could act on immediately even without a model.
¶
Tenure also tells a clear story — customers who've been around longer are much less likely to leave. The first year seems to be the critical window where most churn happens.
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Churn rate by contract
churn_by_contract = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
axes[0].bar(churn_by_contract.index, churn_by_contract.values * 100,
color=['#DD8452', '#4C72B0', '#55A868'], edgecolor='white', linewidth=1.2)
axes[0].set_title('Churn Rate by Contract Type', fontweight='bold')
axes[0].set_ylabel('Churn Rate (%)')
for i, v in enumerate(churn_by_contract.values):
axes[0].text(i, v * 100 + 0.5, f'{v:.0%}', ha='center', fontsize=10, fontweight='bold')
# Tenure vs Churn (box)
df_plot_tenure = df.copy()
df_plot_tenure['Churn_Label'] = df_plot_tenure['Churn'].map({0: 'Stayed', 1: 'Churned'})
axes[1].boxplot(
[df_plot_tenure[df_plot_tenure['Churn']==0]['tenure'],
df_plot_tenure[df_plot_tenure['Churn']==1]['tenure']],
labels=['Stayed', 'Churned'],
patch_artist=True,
boxprops=dict(facecolor='#4C72B0', alpha=0.7),
medianprops=dict(color='black', linewidth=2)
)
axes[1].set_title('Tenure Distribution by Churn', fontweight='bold')
axes[1].set_ylabel('Tenure (months)')
plt.tight_layout()
plt.savefig('viz2_contract_tenure.png', bbox_inches='tight')
plt.show()
How EDA Shaped My Modeling Decisions¶
¶
A few things I noticed in the data that directly affected how I set up the models:
¶
- Class imbalance (~26% churn): I used
class_weight='balanced'on models that support it, and made sure to use stratified splits and stratified cross-validation so the churn class was properly represented in every fold. I also focused on ROC AUC and F1 rather than accuracy for the reasons described above.
¶
- Scale differences: Tenure goes up to 72 months while MonthlyCharges goes up to about $118. Distance-based models like KNN treat all features as equally spaced, so without scaling, monthly charges would dominate just because the numbers are bigger. StandardScaler fixes this.
¶
- Categorical features: Most of the columns are yes/no or category type columns. I used OneHotEncoder to convert these into numeric columns the models can work with.
¶
- Contract and tenure look important: The EDA pretty clearly showed these two features are driving most of the churn signal. I expected tree-based models to pick up on these naturally since they're good at finding those kinds of splits.
4. Train/Test Split & Evaluation Plan¶
# --- Stratified 80/20 split ---
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.20, random_state=42, stratify=y
)
print(f"Train size : {X_train.shape[0]:,} | Churn rate: {y_train.mean():.1%}")
print(f"Test size : {X_test.shape[0]:,} | Churn rate: {y_test.mean():.1%}")
I split the data 80/20 — 80% for training the models and 20% held back for the
final test. I used stratify=y to make sure both splits have roughly the same
churn rate (26%), since a random split could accidentally stack more churners
in one partition and give misleading results.
¶
Why these metrics:
¶
| Metric | Why I chose it |
|---|---|
| ROC AUC | The retention team needs a ranked list, not just yes/no. AUC measures how well the model orders customers from most to least risky. |
| F1 (weighted) | More honest than accuracy when classes are imbalanced. Accounts for both false positives and false negatives. |
| Accuracy | Included because it's on the rubric, but it's the least meaningful metric here. |
¶
Missing a churner who actually leaves (false negative) is more costly than flagging someone who stays (false positive) — the worst case is a customer leaves without ever being contacted. So I wanted metrics that would penalize missing churners.
# --- Cross-validation strategy ---
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# StratifiedKFold preserves churn rate in every fold — essential for imbalanced data.
5. Preprocessing Pipeline¶
# Build a reusable ColumnTransformer
preprocessor = ColumnTransformer(transformers=[
('num', StandardScaler(), numeric_features),
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), categorical_features)
])
# Fit on train, transform both (no leakage)
X_train_proc = preprocessor.fit_transform(X_train)
X_test_proc = preprocessor.transform(X_test)
print(f"Processed feature matrix shape (train): {X_train_proc.shape}")
6. Baseline Model – Logistic Regression¶
¶
I started with Logistic Regression as a baseline. It's the simplest model for a binary classification problem — it produces a probability score for each customer and draws a straight-line boundary between the two classes.
¶
The reason I use it as a baseline is that every other model I build needs to beat it to justify the added complexity. If a fancier model only matches logistic regression, there's no reason to use it. Logistic regression also has the advantage of being easy to explain — you can literally show someone the coefficients and say "this is how much each factor contributes to the churn probability."
# Helper function for clean metric reporting
def evaluate(name, model, X_tr, y_tr, X_te, y_te):
"""Fit model, return dict with key metrics."""
model.fit(X_tr, y_tr)
y_pred = model.predict(X_te)
y_prob = model.predict_proba(X_te)[:, 1] if hasattr(model, 'predict_proba') else None
acc = accuracy_score(y_te, y_pred)
f1 = f1_score(y_te, y_pred, average='weighted')
auc = roc_auc_score(y_te, y_prob) if y_prob is not None else np.nan
print(f"\n{'='*40}")
print(f" {name}")
print(f"{'='*40}")
print(f" Accuracy : {acc:.4f}")
print(f" F1 (wtd) : {f1:.4f}")
print(f" ROC AUC : {auc:.4f}")
return {'Model': name, 'Accuracy': acc, 'F1_weighted': f1, 'ROC_AUC': auc,
'Best_Params': 'defaults', 'Notes': ''}
baseline_lr = LogisticRegression(max_iter=1000, class_weight='balanced', random_state=42)
baseline_results = evaluate(
'Logistic Regression (Baseline)', baseline_lr,
X_train_proc, y_train, X_test_proc, y_test
)
7. Model Tuning & Comparison¶
Model 1: K-Nearest Neighbors (KNN)¶
¶
How it works: KNN doesn't really "learn" anything during training — it just memorizes the data. When you give it a new customer, it finds the k most similar customers in the training set and takes a majority vote. If most of those neighbors churned, it predicts churn.
¶
Why I tried it here: KNN can pick up on local patterns that a straight line can't capture. A cluster of high-bill, short-tenure, month-to-month customers might all churn together, and KNN would naturally pick that up.
¶
Why I chose these hyperparameter ranges:
n_neighbors: Too few neighbors (k=3) means the model is overfitting to noise. Too many (k=21) means it's averaging over too wide an area. I tested the full range to let cross-validation find the sweet spot.weights: Uniform gives all neighbors equal say. Distance-weighted gives closer neighbors more influence — makes sense if you think similar customers should matter more.p: Controls whether we use Manhattan distance (p=1) or Euclidean distance (p=2).
knn_pipe = Pipeline([('knn', KNeighborsClassifier())])
knn_grid = {
'knn__n_neighbors': [3, 5, 7, 11, 15, 21],
'knn__weights': ['uniform', 'distance'],
'knn__p': [1, 2]
}
knn_search = GridSearchCV(
KNeighborsClassifier(),
{k.replace('knn__', ''): v for k, v in knn_grid.items()},
cv=cv, scoring='roc_auc', n_jobs=-1, verbose=0
)
knn_search.fit(X_train_proc, y_train)
print(f"Best KNN params : {knn_search.best_params_}")
print(f"Best CV AUC : {knn_search.best_score_:.4f}")
# Evaluate best KNN on test set
knn_best = knn_search.best_estimator_
y_pred_knn = knn_best.predict(X_test_proc)
y_prob_knn = knn_best.predict_proba(X_test_proc)[:, 1]
knn_results = {
'Model': 'KNN',
'Accuracy': accuracy_score(y_test, y_pred_knn),
'F1_weighted': f1_score(y_test, y_pred_knn, average='weighted'),
'ROC_AUC': roc_auc_score(y_test, y_prob_knn),
'Best_Params': str(knn_search.best_params_),
'Notes': 'Fast inference; sensitive to scale (handled)'
}
print(f"\nKNN Test Accuracy : {knn_results['Accuracy']:.4f}")
print(f"KNN Test F1 : {knn_results['F1_weighted']:.4f}")
print(f"KNN Test ROC AUC : {knn_results['ROC_AUC']:.4f}")
# Diagnostic: Confusion Matrix for KNN
fig, ax = plt.subplots(1, 1, figsize=(5, 4))
ConfusionMatrixDisplay.from_predictions(
y_test, y_pred_knn, display_labels=['Stayed', 'Churned'],
colorbar=False, ax=ax, cmap='Blues'
)
ax.set_title('KNN — Confusion Matrix (Test Set)', fontweight='bold')
plt.tight_layout()
plt.savefig('cm_knn.png', bbox_inches='tight')
plt.show()
Model 2: Decision Tree¶
¶
How it works: A Decision Tree splits the data into smaller and smaller groups by asking yes/no questions — "is the contract month-to-month?", "is tenure less than 12 months?" — until it reaches a prediction. The result is basically a flowchart you can follow from top to bottom.
¶
Why I tried it here: Given what I saw in EDA — contract type and tenure being such strong signals — a Decision Tree felt like a natural fit. Those kinds of threshold-based splits are exactly what trees are good at. A shallow tree is also easy to show to a non-technical audience.
¶
Why I chose these hyperparameter ranges:
max_depth: A tree with no depth limit will just memorize the training data. I tested depths from 3 to None to find the right balance between fitting the data and generalizing to new customers.min_samples_split: Higher values stop the tree from creating branches for tiny edge-case groups of customers that probably won't generalize.criterion: Gini and entropy are two different ways to measure how "pure" each split is. Both are worth testing.
dt_grid = {
'max_depth': [3, 5, 7, 10, None],
'min_samples_split': [10, 20, 50],
'criterion': ['gini', 'entropy']
}
dt_search = GridSearchCV(
DecisionTreeClassifier(class_weight='balanced', random_state=42),
dt_grid, cv=cv, scoring='roc_auc', n_jobs=-1
)
dt_search.fit(X_train_proc, y_train)
print(f"Best DT params : {dt_search.best_params_}")
print(f"Best CV AUC : {dt_search.best_score_:.4f}")
dt_best = dt_search.best_estimator_
y_pred_dt = dt_best.predict(X_test_proc)
y_prob_dt = dt_best.predict_proba(X_test_proc)[:, 1]
dt_results = {
'Model': 'Decision Tree',
'Accuracy': accuracy_score(y_test, y_pred_dt),
'F1_weighted': f1_score(y_test, y_pred_dt, average='weighted'),
'ROC_AUC': roc_auc_score(y_test, y_prob_dt),
'Best_Params': str(dt_search.best_params_),
'Notes': 'Interpretable; prone to overfit without depth cap'
}
print(f"\nDecision Tree Test Accuracy : {dt_results['Accuracy']:.4f}")
print(f"Decision Tree Test F1 : {dt_results['F1_weighted']:.4f}")
print(f"Decision Tree Test ROC AUC : {dt_results['ROC_AUC']:.4f}")
# Diagnostic: Confusion Matrix for Decision Tree
fig, ax = plt.subplots(1, 1, figsize=(5, 4))
ConfusionMatrixDisplay.from_predictions(
y_test, y_pred_dt, display_labels=['Stayed', 'Churned'],
colorbar=False, ax=ax, cmap='Oranges'
)
ax.set_title('Decision Tree — Confusion Matrix (Test Set)', fontweight='bold')
plt.tight_layout()
plt.savefig('cm_dt.png', bbox_inches='tight')
plt.show()
Model 3: Random Forest¶
¶
How it works: Random Forest builds a large number of decision trees — each one trained on a slightly different random sample of the data with a random subset of features. Then it averages all their predictions. The idea is that a lot of imperfect trees voting together beats any single tree, because their errors tend to cancel each other out.
¶
Why I tried it here: After seeing how a single Decision Tree performs, I wanted to see if an ensemble would do better. Random Forest also gives you feature importances — a ranked list of which features mattered most — which is genuinely useful for explaining results to a business audience.
¶
Why I chose these hyperparameter ranges:
n_estimators: More trees generally means more stable predictions. 100-200 is the typical range where you get good performance without it taking forever to run.max_depth: Same reasoning as the single tree — prevents overfitting.max_features: Controls how many features each tree considers at each split.sqrtandlog2are the standard options for classification.min_samples_leaf: Stops individual trees from memorizing tiny groups of customers.
rf_grid = {
'n_estimators': [100, 200],
'max_depth': [5, 10, None],
'max_features': ['sqrt', 'log2'],
'min_samples_leaf': [1, 5]
}
rf_search = GridSearchCV(
RandomForestClassifier(class_weight='balanced', random_state=42),
rf_grid, cv=cv, scoring='roc_auc', n_jobs=-1
)
rf_search.fit(X_train_proc, y_train)
print(f"Best RF params : {rf_search.best_params_}")
print(f"Best CV AUC : {rf_search.best_score_:.4f}")
rf_best = rf_search.best_estimator_
y_pred_rf = rf_best.predict(X_test_proc)
y_prob_rf = rf_best.predict_proba(X_test_proc)[:, 1]
rf_results = {
'Model': 'Random Forest',
'Accuracy': accuracy_score(y_test, y_pred_rf),
'F1_weighted': f1_score(y_test, y_pred_rf, average='weighted'),
'ROC_AUC': roc_auc_score(y_test, y_prob_rf),
'Best_Params': str(rf_search.best_params_),
'Notes': 'Best AUC; feature importances actionable for business'
}
print(f"\nRandom Forest Test Accuracy : {rf_results['Accuracy']:.4f}")
print(f"Random Forest Test F1 : {rf_results['F1_weighted']:.4f}")
print(f"Random Forest Test ROC AUC : {rf_results['ROC_AUC']:.4f}")
# Diagnostic 1: Confusion Matrix
fig, ax = plt.subplots(1, 1, figsize=(5, 4))
ConfusionMatrixDisplay.from_predictions(
y_test, y_pred_rf, display_labels=['Stayed', 'Churned'],
colorbar=False, ax=ax, cmap='Greens'
)
ax.set_title('Random Forest — Confusion Matrix (Test Set)', fontweight='bold')
plt.tight_layout()
plt.savefig('cm_rf.png', bbox_inches='tight')
plt.show()
# Diagnostic 2: Feature Importances (top 15)
feature_names = (
numeric_features +
list(preprocessor.named_transformers_['cat'].get_feature_names_out(categorical_features))
)
importances = pd.Series(rf_best.feature_importances_, index=feature_names)
top15 = importances.nlargest(15).sort_values()
fig, ax = plt.subplots(figsize=(8, 5))
top15.plot(kind='barh', ax=ax, color='#4C72B0', edgecolor='white')
ax.set_title('Random Forest — Top 15 Feature Importances', fontweight='bold')
ax.set_xlabel('Importance Score')
plt.tight_layout()
plt.savefig('feature_importance_rf.png', bbox_inches='tight')
plt.show()
8. Model Comparison¶
results_list = [baseline_results, knn_results, dt_results, rf_results]
results_df = pd.DataFrame(results_list)[[
'Model', 'Best_Params', 'Accuracy', 'F1_weighted', 'ROC_AUC', 'Notes'
]]
# Format numeric columns
for col in ['Accuracy', 'F1_weighted', 'ROC_AUC']:
results_df[col] = results_df[col].map('{:.4f}'.format)
results_df.columns = ['Model', 'Best Hyperparameters', 'Accuracy', 'F1 (Weighted)', 'ROC AUC', 'Notes']
print(results_df.to_string(index=False))
# ROC Curves — all models on one plot
fig, ax = plt.subplots(figsize=(8, 6))
for name, prob in [
('Logistic Regression (Baseline)', baseline_lr.predict_proba(X_test_proc)[:, 1]),
('KNN', y_prob_knn),
('Decision Tree', y_prob_dt),
('Random Forest', y_prob_rf),
]:
RocCurveDisplay.from_predictions(y_test, prob, name=name, ax=ax)
ax.plot([0, 1], [0, 1], 'k--', lw=1, label='Random Classifier')
ax.set_title('ROC Curves — All Models (Test Set)', fontweight='bold')
ax.legend(loc='lower right', fontsize=9)
plt.tight_layout()
plt.savefig('roc_comparison.png', bbox_inches='tight')
plt.show()
What the Results Tell Me¶
¶
Looking at the comparison table and ROC curves, a few things stand out.
¶
Logistic Regression held up surprisingly well as a baseline. The AUC of 0.835 is a strong floor — it tells me the data has real, learnable signal in it. It's also the easiest model to explain to someone who isn't technical.
¶
KNN came in slightly below the baseline on AUC. I think the issue is that after one-hot encoding the categorical features, the data has 46 dimensions. KNN struggles in high dimensions because "nearest neighbors" becomes less meaningful when there are so many features.
¶
Decision Tree did well once I capped the depth — without that it would have overfit badly. The tuned version was competitive. The main advantage here is interpretability. You can print out the actual tree and show it to someone in a meeting.
¶
Random Forest came out on top overall. Combining 200 trees smoothed out the variance that hurt the single Decision Tree, and the feature importances confirmed exactly what I saw in the EDA — contract type, tenure, and monthly charges are the three biggest drivers of churn.
¶
If I had to pick one model to actually use, it would be Random Forest.
Best performance, useful feature importances, and it handles the class
imbalance well with class_weight='balanced'.
9. Story & Limitations¶
¶
What I Found — Plain English¶
¶
I looked at 7,043 telecom customers and tried to predict which ones were going to cancel. The biggest thing I found is that contract type is the single strongest signal. Month-to-month customers churn at roughly 4x the rate of customers on annual plans. Tenure is the second biggest signal — most churn happens in the first year.
¶
The Random Forest model I built can rank customers from most to least likely to churn with about 83% accuracy on the ordering. In practice that means a retention team could take the top 20% of customers by risk score each week and focus their outreach there — knowing that list captures most of the people who are actually about to leave.
¶
What a Company Could Actually Do With This¶
¶
- Run the model weekly — score every active customer, flag the top 20% as high risk, send that list to the retention team every Monday morning.
- Target month-to-month customers early — customers around the 6-month mark who are still on a monthly plan are prime candidates for a discount offer to switch to annual. The data makes this really clear.
- Focus on new customers — the first 3 months seem to be the highest-risk window. An onboarding check-in or a free service trial during that window could significantly reduce early churn.
¶
Limitations¶
¶
| Limitation | Why It Matters | What I'd Do Next |
|---|---|---|
| Static snapshot of data | Customer behavior changes over time — a model trained on old data might not work as well later | Retrain monthly and track AUC over time |
| No customer service data | Call center interactions are probably a strong churn signal that isn't in this dataset | Add complaint/support ticket data if available |
| Binary outcome only | Doesn't tell you when someone will churn, just that they might | Look into survival analysis for time-to-churn modeling |
| Default 0.5 threshold | The decision cutoff isn't optimized for the actual business cost of missing a churner | Tune the threshold based on the cost of a false negative vs. false positive |
| Limited feature engineering | I didn't try creating new features from combinations of existing ones | Explore interaction terms like high charges + no tech support |
Appendix: Reproducibility Checklist¶
¶
- ✅
random_state=42set on all stochastic components - ✅ All preprocessing fitted on train set only (no data leakage)
- ✅ Stratified split + StratifiedKFold CV throughout
- ✅ Data loaded via public URL (no local file required)
- ✅ All imports at the top of the notebook
- ✅ Figures saved to disk for HTML export
¶
To run: Execute all cells from top to bottom. The only optional change is replacing the URL in Section 2 with a local file path if you prefer to run offline.