2026Data Analyst

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.

Telecom Churn Prediction screenshot

Technical Decisions

Decision 01

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.

python
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)])
Decision 02

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.

Command Palette

Search the portfolio. Use arrow keys, Enter to select.