Predicting Customer Churn That a Retention Team Can Actually Use
An end‑to‑end churn modeling workflow (EDA → preprocessing → modeling → thresholding → interpretation) on the classic Telco Customer Churn dataset.
Key results (test set): Random Forest ROC-AUC = 0.846.
Targeting the top 10% highest-risk customers yields 76% precision (2.86× lift vs. random).
Before you dive into the workflow, let’s make the use case concrete: imagine your retention team can reach out to only a small fraction of customers each week, maybe because outreach is expensive or discounts are limited. In that situation, your goal isn’t to predict churn perfectly for everyone. It’s to spot the highest-risk customers early so you can prioritize calls, offers, or support where they’ll matter most.
That’s the lens for this project: you’ll build a churn model that produces a risk score for each customer, then evaluate it in a way that maps directly to action, especially how effective your top 10% highest-risk list is compared to random targeting.
What You’ll Build in This Project
Churn modeling is about more than just “accuracy.” In the real world, retention teams want actionable insights: a prioritized list of customers at risk and, just as importantly, an understanding of why they might leave.
In this guide, you’ll work through a hands-on churn modeling workflow that’s practical and business-focused:
- Frame churn in terms of what the business cares about (what decisions will this model help you drive?)
- Understand the dataset and wrangle a few tricky fields
- Explore churn patterns (which customer segments are leaving, and what does that mean?)
- Preprocess data the right way with scikit-learn pipelines (no data leakage!)
- Train some baseline models, then level up with a tuned Random Forest
- Evaluate performance with retention-friendly metrics (ROC-AUC and top-k targeting)
- Interpret churn drivers with impurity-based feature importance, permutation importance, and SHAP
1) Problem statement: What churn prediction is actually for
Customer churn is expensive. In most subscription or contract-based businesses, keeping your existing customers is almost always cheaper than finding new ones, but your retention team doesn’t have unlimited budget, time, or discounts to offer.
That’s why the goal of churn prediction is rarely to “predict perfectly.” A good churn model should help you:
- Rank your customers by churn risk so you can prioritize your outreach
- Pick a decision threshold that matches your business trade-offs (the cost of contacting vs. the cost of losing a customer)
- Understand the drivers of churn so you can design better interventions (pricing, support, contract design, onboarding, etc.)
In this project, you’ll train a model that outputs a churn probability (risk score) for each customer, then evaluate it in two ways:
- As a classifier (precision/recall at your chosen threshold), and
- As a ranking tool (how good is your top 10% highest-risk list?).
That second view is often the most useful in practice, since retention actions are almost always limited by capacity.
2) Dataset description
We’ll use the widely shared Telco Customer Churn dataset
(WA_Fn-UseC_-Telco-Customer-Churn.csv).
Quick facts:
- Rows: 7,043 customers
- Columns: 21 (including the target)
- Target: Churn (Yes/No → encoded as 1/0)
- Class balance: churn rate ≈ 26.5%
- One data quality quirk: TotalCharges is stored as text and contains blanks for very new customers (about 0.16% missing). We’ll need to convert it to numeric and fill in the missing values.
What the features describe:
- Customer profile: gender, senior citizen, partner, dependents
- Account & contract: tenure, contract type, paperless billing, payment method
- Services: phone + internet, online security/backup, tech support, streaming
- Charges: monthly charges, total charges
This is a supervised binary classification dataset. We’ll treat predicted probabilities as churn “risk scores,” which makes it easier to build retention-targeting strategies such as top-k lists, thresholds, and campaigns.
# Core imports
import warnings
warnings.filterwarnings("ignore")
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
RANDOM_STATE = 42
np.random.seed(RANDOM_STATE)
# -------------------------------------------------------------------
# Load dataset
# -------------------------------------------------------------------
DATA_DIR = Path("data")
DATA_PATH = DATA_DIR / "WA_Fn-UseC_-Telco-Customer-Churn.csv"
# If the dataset is already on disk, great.
# If not, you have two options:
# 1) Download the CSV manually and put it into ./data/
# 2) (Optional) Try to download from a public mirror (requires internet)
if not DATA_PATH.exists():
DATA_DIR.mkdir(exist_ok=True, parents=True)
url = "https://huggingface.co/spaces/Wenny/Telco-Customer-Churn/raw/main/WA_Fn-UseC_-Telco-Customer-Churn.csv"
try:
from urllib.request import urlopen
with urlopen(url, timeout=30) as resp:
DATA_PATH.write_bytes(resp.read())
print(f"Downloaded dataset to: {DATA_PATH}")
except Exception as e:
raise FileNotFoundError(
f"""Dataset not found at {DATA_PATH}.
Please place the Telco churn CSV at that path (recommended),
or update DATA_PATH to your local file location.
Optional download attempt failed. Error: {e}
"""
)
df = pd.read_csv(DATA_PATH)
print("Shape:", df.shape)
df.head()3) Cleaning + target encoding
Before modeling, we do a few practical cleanup steps:
- Encode Churn to 1/0
- Convert TotalCharges to numeric (coercing blanks to NaN)
- Drop customerID (identifier only)
- Treat SeniorCitizen as categorical (it’s a segment flag, not a continuous value)
Then we sanity-check missing values and confirm the churn rate.
data = df.copy()
# Target: Yes/No -> 1/0
data["Churn"] = data["Churn"].map({"Yes": 1, "No": 0}).astype(int)
# TotalCharges contains blanks for new customers -> coerce to NaN
if "TotalCharges" in data.columns:
data["TotalCharges"] = pd.to_numeric(data["TotalCharges"], errors="coerce")
# customerID is an identifier (not a behavioral feature)
if "customerID" in data.columns:
data = data.drop(columns=["customerID"])
# SeniorCitizen is a segment indicator; keep it categorical for modeling
if "SeniorCitizen" in data.columns:
data["SeniorCitizen"] = data["SeniorCitizen"].map({0: "No", 1: "Yes"}).astype("object")
# Missing values summary
missing = data.isna().mean().sort_values(ascending=False)
display(missing[missing > 0].to_frame("missing_rate"))
# Target balance
display(data["Churn"].value_counts(normalize=True).rename("share"))4) Exploratory Data Analysis (EDA)
EDA has one job: turn raw columns into hypotheses you can later test with modeling and (ideally) act on.
Here, we focus on churn patterns that tend to be actionable for retention:
- Commitment signals (contract type, tenure)
- Billing friction (paperless billing, payment method)
- Support/service proxies (tech support, online security, internet type)
- Pricing signals (monthly/total charges)
Important: EDA shows associations, not causation. We’ll use it to guide modeling and interpretation.
# Target distribution
plt.figure(figsize=(5, 3))
ax = sns.countplot(x="Churn", data=data)
ax.set_xticklabels(["No churn (0)", "Churn (1)"])
ax.set_title("Target distribution")
plt.show()
def churn_rate_table(df: pd.DataFrame, feature: str, target: str = "Churn") -> pd.DataFrame:
baseline = df[target].mean()
out = (
df.groupby(feature)[target]
.agg(n="count", churn_rate="mean")
.sort_values("churn_rate", ascending=False)
.reset_index()
)
out["churn_rate_pct"] = (100 * out["churn_rate"]).round(2)
out["lift_ratio"] = (out["churn_rate"] / baseline).round(2)
return out.drop(columns=["churn_rate"])
# A few high-signal categorical features
display(churn_rate_table(data, "Contract"))
display(churn_rate_table(data, "PaymentMethod"))
display(churn_rate_table(data, "InternetService"))Key EDA findings from this dataset
Overall churn rate: ~26.5%.
That means a naive model that always predicts “no churn” can look deceptively good on accuracy, while being useless for retention.
Here are a few patterns that stand out:
Contract type is the strongest churn signal
- Month‑to‑month customers churn ~42.7%, while two‑year contracts churn ~2.8%.
- This is the classic “commitment effect”: longer contracts reduce churn (and/or attract more stable customers).
Payment method can indicate friction (or customer segment)
- Electronic checks have the highest churn (~45.3%), while automatic payments are much lower (~15–17%).
Service type and support features matter
Customers on fiber optic service churn much more (~41.9%) than those on DSL (~19.0%) in this dataset.
A similar pattern appears for “support/protection” features (often proxies for stability and satisfaction):
- OnlineSecurity = No churns ~41.8%, while OnlineSecurity = Yes churns ~14.6%
- TechSupport = No churns ~41.6%, while TechSupport = Yes churns ~15.2%
Finally, paperless billing correlates with higher churn here:
These patterns show up again later in model interpretation, which is a good sign that the model is learning something meaningful.
5) Feature engineering/preprocessing
For this project, “feature engineering” is mostly about safe preprocessing:
- Numeric features: impute missing values (median); optionally scale for linear/KNN models
- Categorical features: impute missing values (most frequent); one‑hot encode
- Use a Pipeline + ColumnTransformer so preprocessing happens inside cross‑validation and only on the training data (prevents leakage)
One practical choice:
- TotalCharges is strongly derived from tenure and MonthlyCharges (roughly: total ≈ tenure × monthly).
For linear models, this can add redundancy, so we optionally drop it for the Logistic Regression baseline.
Tree models are usually fine with it.
from sklearn.model_selection import train_test_split, StratifiedKFold
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
X = data.drop(columns=["Churn"])
y = data["Churn"].astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y
)
numeric_features = X_train.select_dtypes(include=["number"]).columns.tolist()
categorical_features = X_train.select_dtypes(exclude=["number"]).columns.tolist()
ohe = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
numeric_scaled = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
numeric_unscaled = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
])
categorical_pipe = Pipeline(steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", ohe),
])
# For most models (trees, forests): keep numeric features as-is (no scaling needed)
preprocess_unscaled = ColumnTransformer(
transformers=[
("num", numeric_unscaled, numeric_features),
("cat", categorical_pipe, categorical_features),
],
remainder="drop"
)
# For linear models: scale numeric features; optionally drop TotalCharges
drop_for_linear = [c for c in ["TotalCharges"] if c in numeric_features]
numeric_features_lr = [c for c in numeric_features if c not in drop_for_linear]
preprocess_scaled_lr = ColumnTransformer(
transformers=[
("num", numeric_scaled, numeric_features_lr),
("cat", categorical_pipe, categorical_features),
],
remainder="drop"
)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)
print("Train:", X_train.shape, "| Test:", X_test.shape)
print("Numeric (all):", numeric_features)
print("Numeric (linear):", numeric_features_lr)6) Modeling approach
We’ll compare a few model families:
- Dummy baseline: always predict the majority class (“no churn”)
Useful to show why accuracy alone is misleading under imbalance. - Logistic Regression (tuned): a strong, simple baseline; probability outputs are often well-behaved.
- Decision Tree (tuned): interpretable rules, but can overfit.
- Random Forest (tuned): typically strong performance and more stable than a single tree.
In the full notebook, I also tried KNN + dimensionality reduction as a non-linear baseline.
For this article, the focus is on the models above (and how we evaluate them).
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
# ---------------------------
# 1) Dummy baseline
# ---------------------------
dummy = DummyClassifier(strategy="most_frequent")
# ---------------------------
# 2) Logistic Regression
# ---------------------------
logit_pipe = Pipeline(steps=[
("preprocess", preprocess_scaled_lr),
("model", LogisticRegression(
max_iter=3000,
class_weight="balanced", # handle imbalance
solver="lbfgs"
))
])
logit_param_grid = {"model__C": np.logspace(-3, 1, 12)}
logit_grid = GridSearchCV(
logit_pipe, param_grid=logit_param_grid, scoring="roc_auc", cv=cv, n_jobs=-1
)
logit_grid.fit(X_train, y_train)
best_logit = logit_grid.best_estimator_
# ---------------------------
# 3) Decision Tree
# ---------------------------
tree_pipe = Pipeline(steps=[
("preprocess", preprocess_unscaled),
("model", DecisionTreeClassifier(random_state=RANDOM_STATE, class_weight="balanced"))
])
tree_param_grid = {
"model__max_depth": [2, 3, 4, 5, 6, 8, 10],
"model__min_samples_leaf": [1, 5, 10, 20],
}
tree_grid = GridSearchCV(
tree_pipe, param_grid=tree_param_grid, scoring="roc_auc", cv=cv, n_jobs=-1
)
tree_grid.fit(X_train, y_train)
best_tree = tree_grid.best_estimator_
# ---------------------------
# 4) Random Forest (random search)
# ---------------------------
rf_pipe = Pipeline(steps=[
("preprocess", preprocess_unscaled),
("model", RandomForestClassifier(
random_state=RANDOM_STATE,
n_jobs=-1,
class_weight="balanced_subsample"
))
])
rf_param_dist = {
"model__n_estimators": [200, 400, 600, 800],
"model__max_depth": [None, 4, 6, 8, 12, 16],
"model__min_samples_split": [2, 5, 10, 30],
"model__min_samples_leaf": [1, 2, 5, 10, 20],
"model__max_features": ["sqrt", 0.5, 0.8],
}
rf_search = RandomizedSearchCV(
rf_pipe,
param_distributions=rf_param_dist,
n_iter=30,
scoring="roc_auc",
cv=cv,
random_state=RANDOM_STATE,
n_jobs=-1,
verbose=0
)
rf_search.fit(X_train, y_train)
best_rf = rf_search.best_estimator_
print("Best Logistic params:", logit_grid.best_params_)
print("Best Tree params:", tree_grid.best_params_)
print("Best RF params:", rf_search.best_params_)7) Results/evaluation
Metrics that map to retention work
Because churn is imbalanced, accuracy alone isn’t enough. We’ll report:
— ROC‑AUC: measures ranking quality across thresholds (great for “who to contact first”)
Get Nahid Ahmadvand’s stories in your inbox
Join Medium for free to get updates from this writer.
— Precision / Recall / F1 for churn at a default threshold (0.5)
- Precision: “Of customers we flagged, how many actually churned?”
- Recall: “Of all churners, how many did we catch?”
Then we’ll add top‑k targeting, which is often closer to how retention campaigns will operate in practice.
from sklearn.metrics import (
roc_auc_score, accuracy_score, precision_score, recall_score, f1_score
)
def evaluate(model, X_test, y_test, threshold=0.5):
# Some estimators (e.g., DummyClassifier) still expose predict_proba
proba = model.predict_proba(X_test)[:, 1]
pred = (proba >= threshold).astype(int)
return {
"roc_auc": roc_auc_score(y_test, proba),
"accuracy": accuracy_score(y_test, pred),
"precision_churn": precision_score(y_test, pred, zero_division=0),
"recall_churn": recall_score(y_test, pred, zero_division=0),
"f1_churn": f1_score(y_test, pred, zero_division=0),
}, proba
models = {
"Dummy (most_frequent)": dummy.fit(X_train, y_train),
"Logistic Regression (tuned)": best_logit,
"Decision Tree (tuned)": best_tree,
"Random Forest (tuned)": best_rf,
}
rows = []
probas = {}
for name, m in models.items():
metrics, p = evaluate(m, X_test, y_test, threshold=0.5)
rows.append({"model": name, **metrics, "threshold": 0.5})
probas[name] = p
results = pd.DataFrame(rows).sort_values("roc_auc", ascending=False)
resultsTest‑set comparison
Below is the results summary from the notebook run (same split + same evaluation style):
What to notice:
- The Dummy model gets ~0.735 accuracy just by predicting “no churn,” but it has zero recall for churners.
- Random Forest edges out the others on ROC‑AUC and recall, while staying reasonably precise.
- Logistic Regression is close in AUC and can be a great option if you need something simpler.
- KNN performed competitively in the full notebook, but is less interpretable; for most churn use cases, the Random Forest is a better balance.
8) A more “business‑native” metric: top‑k targeting
In retention work, you rarely contact everyone. You contact the riskiest customers first, subject to budget or capacity.
So instead of only evaluating one fixed threshold, we can ask:
If we target the top 10% highest‑risk customers,
(1) What fraction of them are true churners (precision@10%) and
(2) What fraction of all churners do we capture (recall@10%)?
We’ll also compute lift vs random targeting, where random precision is simply the overall churn rate.
def precision_recall_at_k(y_true, proba, k_frac=0.10):
y_true = np.asarray(y_true)
proba = np.asarray(proba)
k = max(1, int(np.ceil(k_frac * len(y_true))))
idx = np.argsort(proba)[::-1][:k] # top-k highest risk
y_top = y_true[idx]
precision_k = y_top.mean()
recall_k = y_top.sum() / max(1, y_true.sum())
return k, precision_k, recall_k
k_frac = 0.10
baseline_precision = y_test.mean()
rows = []
for name, p in probas.items():
k, prec_k, rec_k = precision_recall_at_k(y_test, p, k_frac=k_frac)
rows.append({
"model": name,
"k_frac": k_frac,
"k_customers": k,
"precision_at_10": prec_k,
"recall_at_10": rec_k,
"lift_vs_random_precision": prec_k / baseline_precision
})
topk = pd.DataFrame(rows).sort_values("precision_at_10", ascending=False)
topkTop‑10% targeting results (from the notebook run)
Interpretation (Random Forest):
- In the top 10% highest‑risk list, precision ≈ 0.759, roughly 3 out of 4 customers we contact are actual churners.
- We capture ~28.6% of all churners in the test set with just 10% outreach.
- A lift of ~2.86× means this list is almost 3× better than random targeting.
This is often the most useful view for campaigns because it answers a practical question:
“If my team can only contact N customers, how many of those contacts will be ‘worth it’?”
9) Threshold tuning: turning probabilities into decisions
A churn model outputs probabilities; you choose a threshold to convert that into an action (“intervene” vs “don’t”).
Two common strategies:
- Optimize a metric (e.g., F1) if you value precision and recall similarly.
- Minimize expected cost if false negatives (missing a churner) and false positives (wasting outreach or discounts) have different costs.
In the notebook, we demonstrate both approaches.
from sklearn.metrics import f1_score
# We'll tune thresholds on the best model by AUC (often the RF)
best_name = results.iloc[0]["model"]
p_best = probas[best_name]
y_true = y_test.to_numpy()
print("Model selected for thresholding demo:", best_name)
def best_threshold_by_f1(y_true, proba, steps=501):
thresholds = np.linspace(0, 1, steps)
f1s = []
for t in thresholds:
pred = (proba >= t).astype(int)
f1s.append(f1_score(y_true, pred, zero_division=0))
best_idx = int(np.argmax(f1s))
return thresholds[best_idx], float(np.max(f1s))
t_f1, best_f1 = best_threshold_by_f1(y_true, p_best)
print(f"Best F1={best_f1:.3f} at threshold={t_f1:.2f}")
# Cost-based thresholding (example)
# Suppose missing a churner costs 5x more than contacting a non-churner
FN_COST = 5.0
FP_COST = 1.0
def expected_cost(y_true, proba, threshold, fn_cost=FN_COST, fp_cost=FP_COST):
pred = (proba >= threshold).astype(int)
fn = ((y_true == 1) & (pred == 0)).sum()
fp = ((y_true == 0) & (pred == 1)).sum()
return fn_cost * fn + fp_cost * fp
thresholds = np.linspace(0, 1, 501)
costs = np.array([expected_cost(y_true, p_best, t) for t in thresholds])
t_cost = thresholds[int(np.argmin(costs))]
print(f"Min expected cost={costs.min():.1f} at threshold={t_cost:.2f} (FN cost={FN_COST}, FP cost={FP_COST})")
plt.figure(figsize=(6, 3))
plt.plot(thresholds, costs)
plt.title("Expected cost vs threshold")
plt.xlabel("Threshold")
plt.ylabel("Expected cost")
plt.show()Why this matters: Thresholding is where a model becomes a policy.
- If outreach is cheap and losing customers is expensive, you’ll likely choose a lower threshold (higher recall).
- If outreach is expensive or you’re offering discounts, you’ll often choose a higher threshold (higher precision).
In production, you’d tune this using:
- Real intervention costs and expected value
- Capacity constraints (calls per day, etc.)
- Experiments (A/B tests) to measure whether outreach actually reduces churn
10) Model interpretation: what drives churn risk?
High ROC‑AUC is nice, but churn modeling becomes truly valuable when you can answer:
What’s driving churn, and what should we do about it?
Tree models come with built‑in feature importances, but those can be biased (for example, toward high‑cardinality features).
A more reliable global approach is permutation importance:
- Measure baseline performance (ROC‑AUC)
- Shuffle one feature at a time
- The bigger the drop in AUC, the more the model relied on that feature
Below is a compact version of the permutation importance analysis from the notebook.
from sklearn.inspection import permutation_importance
def permutation_importance_from_pipeline(pipe, X, y, scoring="roc_auc", n_repeats=10, random_state=RANDOM_STATE):
pre = pipe.named_steps["preprocess"]
model = pipe.named_steps["model"]
X_enc = pre.transform(X)
feature_names = pre.get_feature_names_out()
r = permutation_importance(
model, X_enc, y,
scoring=scoring,
n_repeats=n_repeats,
random_state=random_state,
n_jobs=-1
)
imp = pd.Series(r.importances_mean, index=feature_names).sort_values(ascending=False)
return imp
# Encoded-feature importance (one-hot expanded); we’ll summarize in the text
rf_perm_encoded = permutation_importance_from_pipeline(best_rf, X_test, y_test)
rf_perm_encoded.head(15)What the model relies on (high-level)
In this dataset, the strongest drivers are consistent across EDA and model interpretation.
From the notebook’s permutation importance (mean drop in AUC):
How to read this:
- Contract dominates: month‑to‑month customers are much higher risk.
- tenure matters: churn risk is highest early in the customer lifecycle.
- InternetService plus support/security features suggest experience and fit matter.
- Charges (MonthlyCharges, TotalCharges) capture pricing pressure and lifecycle effects.
Turning this into action (examples):
- Incentivize contract upgrades for month‑to‑month customers
- Invest in onboarding + early‑tenure retention programs
- Reduce friction in billing and support for high‑risk segments
11) Optional: local explanations with SHAP
Permutation importance is global. If you want explanations per customer, SHAP is a common choice.
In churn workflows, SHAP is especially useful when you want to:
- Explain why a specific customer is high risk (for an agent or CRM workflow)
- Sanity-check model behavior with domain experts
Below is a minimal optional snippet. (In the full notebook, we generate a SHAP summary plot.)
# Optional: SHAP explanations (can be slower depending on environment)
import shap
rf_pre = best_rf.named_steps["preprocess"]
rf_model = best_rf.named_steps["model"]
X_test_trans = rf_pre.transform(X_test)
feature_names = rf_pre.get_feature_names_out()
# Sample for speed
rng = np.random.RandomState(RANDOM_STATE)
n_sample = min(500, X_test_trans.shape[0])
idx = rng.choice(X_test_trans.shape[0], size=n_sample, replace=False)
X_shap = X_test_trans[idx]
explainer = shap.TreeExplainer(rf_model)
shap_values = explainer.shap_values(X_shap)
# For binary classification, shap_values[1] corresponds to the positive class (churn=1)
shap.summary_plot(shap_values[1], X_shap, feature_names=feature_names)What each SHAP plot is telling us:
1) SHAP summary (beeswarm): global drivers + direction
- Each row is a feature; each dot is a customer.
- Positive SHAP pushes the prediction toward churn, while negative SHAP pushes it toward no churn.
- Color encodes the feature value (for one-hot features, “red” means the indicator is on).
In this dataset, the strongest patterns align with EDA:
- Month‑to‑month contract pushes risk up.
- Low tenure pushes risk up; high tenure pushes it down.
- No OnlineSecurity / No TechSupport tend to increase risk.
- Fiber optic service is associated with higher risk.
2) Mean |SHAP| bar chart (aggregated): a cleaner global ranking
We also aggregate one-hot columns back to their original features (e.g., all Contract_* columns → Contract).
This produces an easy “top drivers” list that is less cluttered by encoding details.
12) Conclusion / Insights
What we built
— A churn prediction pipeline using scikit‑learn best practices (preprocessing in pipelines, stratified splits)
— A tuned Random Forest model that performs well on ROC‑AUC and recall
— Actionable evaluation views:
- Threshold‑based precision/recall
- Top‑k targeting (a realistic retention workflow)
— Interpretation using permutation importance (and optional SHAP)
What we learned (in this dataset)
- Contract type is the strongest churn signal (month‑to‑month customers churn far more)
- Early tenure customers are higher risk, so onboarding and early interventions matter
- Service/support signals (online security, tech support) are strongly associated with churn
- Billing/payment patterns (e.g., electronic checks) correlate with churn and may indicate friction or segment differences
Next steps for a production‑grade churn system
- Probability calibration (especially if decisions depend on absolute risk)
- Uplift modeling or experimentation (target customers who can be saved, not just those at risk)
- Monitor drift and retrain periodically
- Operationalize: connect outputs to a CRM workflow + measure retention impact
Thanks for making it all the way to the end! I hope you found this as fun and enlightening as I did. If you have any thoughts, questions, or ideas for taking this further, I’d love to hear from you in the comments.
Links
- Full notebook + code: https://github.com/Nahid-ahmdv/Customer_Churn_Prediction
- Email: nahid.ahmadvand@sharif.edu






