← BACK TO NOTES

MACHINE LEARNING / 14 November 2026

What I learned predicting churn with XGBoost

Building a customer churn model taught me as much about asking the right question as it did about gradient boosting. A few findings from the experiments, and what happened after the notebook.

I started with a fairly simple question: could I identify customers who were more likely to leave, early enough for someone to do something useful with that information? I built the model in Python, using XGBoost inside a scikit-learn pipeline. Getting a prediction out was the straightforward part. Working out how much I could trust it took considerably more thought.

The label came before the model

I defined churn over a six-month window. That sounds tidy, until you realise that a recent customer who has not left yet is not necessarily a non-churner. They may simply not have had enough time to leave. I excluded records without a complete outcome window from training and evaluation. Otherwise, I would have been teaching the model that “we do not know yet” meant “stayed”.

Conceptual timeline: score a customer, observe the following six months, then evaluate the outcome. Before the window closes, the outcome is still pending.
A complete outcome window matters. This is a conceptual illustration, not a customer record.

XGBoost was a practical choice

I compared eight algorithms before tuning. The boosted tree models were close: LightGBM edged XGBoost on ROC-AUC, while XGBoost edged LightGBM on PR-AUC. Those tiny differences did not give me a convincing outright winner. I chose XGBoost for the wider combination of useful ranking, class weighting, explainability and manageable deployment.

Untuned baseline ROC-AUC: LightGBM 0.7214, XGBoost 0.7213, CatBoost 0.7195, Gradient Boosting 0.7145, HistGradientBoosting 0.7143, Random Forest 0.7082, Logistic Regression 0.7060, AdaBoost 0.7049.
Untuned baseline results from my experiments. ROC-AUC measures ranking across thresholds; these small differences do not establish a statistically reliable winner.

I kept preprocessing and inference in one pipeline, so missing values and categorical inputs were handled consistently. For tuning, I used successive halving to explore broadly, then a smaller grid search around promising settings. MLflow helped me keep track of the runs. It was a more manageable process than letting a huge grid search run overnight and trying to reconstruct what had changed afterwards.

A useful score needs a decision around it

Churn was the minority class. A model could look reassuringly accurate while missing the people I wanted to find, so I paid closer attention to recall, precision and ranking. Class weighting helped make missed churners more costly during training. It also meant I could not safely read the raw scores as calibrated probabilities: a score of 0.7 was not evidence that exactly 70% of similar customers would leave.

Choosing a threshold was a separate decision. Catching more churners meant flagging more people who would stay, and creating more work for whoever reviewed the predictions. Changing that boundary changed the labels and workload; it did not change the underlying scores. I also learned to freeze thresholds before evaluating on fresh outcomes. Revisiting boundaries using the same evaluation data can make the results look better than they really are.

In evaluated live cohorts from earlier model versions, the highest-scored 10% captured 20.2% of observed churn and the highest-scored 20% captured 35.4%. Random selection would capture 10% and 20% on average.
Observed churn captured within the highest-scored groups, from earlier live model versions. Random selection is an expected reference. This measures prioritisation, not cancellations prevented.

Explanations needed translating

SHAP helped me inspect what pushed an individual prediction up or down. But an encoded column name and a contribution value were not particularly useful to someone reviewing a customer. Translating them into plain labels and recognisable values became part of the work. I treated those explanations as clues about the model’s reasoning, not proof of why somebody would leave.

I also tested removing demographic and geographic inputs. Validation performance barely moved, which gave me a good reason to leave them out. That was a useful finding in its own right: a longer feature list was not automatically a better one. Removing those fields did not prove the model was fair, though; other inputs can still carry related patterns, so group-level checks remained necessary.

The notebook was only the beginning

The later model achieved about 0.731 ROC-AUC on a chronological hold-out. Earlier live versions recorded about 0.649 once their outcomes had matured. I could not treat that gap as a measured decline in the same model: the versions, cohorts and periods were different. It was a reason to investigate and keep collecting evidence.

The six-month target also meant waiting six months to evaluate each new prediction properly. In the meantime, I checked inputs, missingness, batch completion and score distributions. A distribution shift could prompt an investigation, but could not tell me on its own whether predictions had become worse. Calibration and evaluation on fresh, fully observed cohorts are the next improvements I want to make.

The biggest change in my thinking was learning to judge the whole process around the score: what the label means, what was known at prediction time, how someone uses the result, and when the evidence is actually ready. XGBoost gave me a useful model. Those questions taught me how to evaluate it more honestly.

ALL NOTES