Telecom Churn Prediction
An end-to-end churn analysis for a telecom operator, read as one narrative: EDA to discover the business problem, retention segmentation, an XGBoost risk model, and a 'Renewal Rescue System' proposal — framed through a Philippine/ASEAN market lens.
- Python
- pandas
- NumPy
- scikit-learn
- XGBoost
- Matplotlib
- Seaborn
Problem
A telecom operator ('Company A') was losing customers but had no way to tell who was about to leave. With churn near 50% of the test base, blanket retention spend was wasteful. The task was to move from raw usage and demographic data to a prioritized, defensible answer for who to target and why — not just a model, but a business case.
Approach
I merged two source tables — a client table (demographics, equipment, revenue) and a records table (usage, tenure, churn) — into a single 100,000-row, ~100-column modeling set, inner-joined 1:1 on Customer_ID. Exploratory analysis surfaced the real story: churners' median monthly revenue (47.49) barely trailed stayers' (48.88), and the sharpest risk clustered in the 11–12 month renewal window. From there I built rule-based retention segments (the riskiest being 'Renewal + silent-switching risk'), trained an XGBoost classifier, and translated its scores into risk tiers to drive a targeted 'Renewal Rescue System' proposal.

Technical Decisions
Pipeline + ColumnTransformer for leak-free preprocessing
Wrapped median/most-frequent imputation and one-hot encoding inside an sklearn Pipeline and ColumnTransformer so every transform is fit only on training folds. With ~100 mixed numeric and categorical columns and meaningful missingness, this kept preprocessing reproducible and prevented data leakage from bleeding into the reported metrics.
def build_churn_pipeline(X):
numeric_features = X.select_dtypes(include=["number", "bool"]).columns.tolist()
categorical_features = X.select_dtypes(include=["object", "category"]).columns.tolist()
preprocessor = ColumnTransformer(transformers=[
("num", SimpleImputer(strategy="median"), numeric_features),
("cat", Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore")),
]), categorical_features),
])
model = XGBClassifier(
n_estimators=250, max_depth=4, learning_rate=0.05,
subsample=0.9, colsample_bytree=0.9,
eval_metric="logloss", random_state=42, n_jobs=-1,
)
return Pipeline([("preprocess", preprocessor), ("model", model)])Evaluating for business value, not just ROC-AUC
A ~0.69 ROC-AUC is modest on its own, so I reframed evaluation around prioritization. Ranking customers by predicted risk, the top decile captured a ~78% actual churn rate and the 'Very high risk' tier hit 73% versus a ~50% baseline. The argument: for a finite retention budget, who you target first matters more than a marginal AUC gain.
Tradeoffs
Prioritization over raw accuracy
Optimized the story around top-decile lift and risk banding rather than chasing a higher AUC. This maps directly to how a retention team spends a fixed budget, but it means the model is a triage tool, not a precise per-customer probability.
Kept missingness as signal, not noise
Missing values clustered in demographic and equipment fields, so I treated data reliability as a finding rather than blanket-imputing it away. That preserved an honest narrative but left some features weaker predictors than a heavier imputation strategy might have forced.
What I learned
- 01
Letting EDA define the business problem instead of assuming it upfront.
- 02
Translating model scores into risk tiers a non-technical stakeholder can act on.
- 03
Defending a modest AUC by shifting the metric to decile lift and targeting value.