Customer Churn Predictor: A Full ml1 Model Comparison

Data Science & ML Projects (Intermediate)

Chapter 2 · Customer Churn Predictor: A Full ml1 Model Comparison

dsproj1-7's own warn-box was explicit: plain accuracy is the simplified metric, and ml1-6 covers precision, recall, F1, and the confusion matrix in full. That chapter also deliberately used KNN specifically to avoid preempting ml1-5's logistic regression or ml1-7's decision trees. This chapter is the direct payoff of both deferrals.

What We're Building

A churn predictor for a telecom-style customer dataset — real subscriber data (tenure, monthly charges, contract type, services subscribed) used to predict whether a customer will cancel. Two real models, compared honestly: logistic regression (ml1-5) and a random forest (ml1-7), evaluated with the full metric picture ml1-6 covers, validated with real cross-validation (ml1-8).

Step 1: Real Feature Engineering

import pandas as pd

df = pd.read_csv("telco_churn.csv")

df["avg_monthly_spend"] = df["TotalCharges"] / df["tenure"].replace(0, 1)
df["is_long_term"] = (df["tenure"] > 24).astype(int)

categorical_cols = ["Contract", "InternetService", "TechSupport"]
df = pd.get_dummies(df, columns=categorical_cols, drop_first=True)

y = (df["Churn"] == "Yes").astype(int)
X = df.drop(columns=["customerID", "Churn", "TotalCharges"])

Genuinely new territory beyond dsproj1-7's own four ready-made numeric measurements: avg_monthly_spend and is_long_term are derived features, engineered because raw tenure and TotalCharges alone don't directly capture them. pd.get_dummies() one-hot encodes categorical columns like Contract into numeric 0/1 columns a model can actually use — real tabular data is rarely all-numeric the way Iris was.

Step 2: Split, and Why One Split Isn't Enough

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

stratify=y keeps the churned/not-churned ratio consistent between train and test sets — worth doing explicitly here, since churn datasets are rarely 50/50, exactly the situation Step 5 comes back to directly.

Step 3: Model 1 — Logistic Regression

from sklearn.linear_model import LogisticRegression

logreg = LogisticRegression(max_iter=1000)
logreg.fit(X_train, y_train)

ml1-5's own model, applied here for real — a coefficient per feature, each one directly readable as "this feature pushes the prediction toward churn or away from it."

Step 4: Model 2 — Random Forest

from sklearn.ensemble import RandomForestClassifier

forest = RandomForestClassifier(n_estimators=100, random_state=42)
forest.fit(X_train, y_train)

ml1-7's own ensemble model, fit on the identical training data — an intentional apples-to-apples comparison against logistic regression.

Step 5: The Full Evaluation Picture

from sklearn.metrics import classification_report, confusion_matrix

for name, model in [("Logistic Regression", logreg), ("Random Forest", forest)]:
    preds = model.predict(X_test)
    print(f"\n--- {name} ---")
    print(classification_report(y_test, preds, target_names=["Stayed", "Churned"]))
    print(confusion_matrix(y_test, preds))
The accuracy paradox, caught in real data — not just described in the abstract
Churn datasets are typically imbalanced — most customers don't churn in any given period. A model that predicted "never churns" for every single customer could score a deceptively high accuracy while catching zero actual churners — exactly the scenario ml1-6 warned about. recall on the "Churned" class specifically — how many actual churners the model actually caught — matters far more here than overall accuracy, since missing a real churner is the exact business cost this whole project exists to reduce.

Step 6: Cross-Validation — A More Honest Estimate

from sklearn.model_selection import cross_val_score

for name, model in [("Logistic Regression", logreg), ("Random Forest", forest)]:
    scores = cross_val_score(model, X, y, cv=5, scoring="f1")
    print(f"{name}: F1 = {scores.mean():.3f} (+/- {scores.std():.3f})")

Per ml1-8, a single train/test split can be a lucky or unlucky draw — cv=5 fits and evaluates each model five separate times on five different splits, reporting the average and spread rather than trusting one number. The +/- spread itself is worth reading: a model whose F1 barely moves across folds is more trustworthy than one that swings wildly from one split to the next.

Step 7: Which Features Actually Drive Churn

importances = pd.Series(forest.feature_importances_, index=X.columns).sort_values(ascending=False)
print(importances.head(10))

A genuine business payoff beyond "the model works": feature_importances_ reveals which factors the random forest actually leaned on most — real, actionable information for whoever would act on these predictions, not just a black-box score.

An honest comparison, not a declared winner
A random forest often edges out logistic regression on tabular data with real feature interactions — but logistic regression's own coefficients are directly interpretable in a way a forest's aggregated feature importances aren't quite as cleanly. Neither model is simply "better" in the abstract; the right choice depends on whether interpretability or raw predictive performance matters more for a given real use case.

pd.get_dummies()

One-hot encodes categorical columns a model can't use as raw text.

stratify=y

Keeps class balance consistent across train/test splits on imbalanced data.

The accuracy paradox, concretely

High accuracy alone can hide a model that misses most real churners.

cross_val_score()

A more honest performance estimate than trusting a single split.

Extend This Project

Try these on your own:

  • Try class_weight="balanced" on both models and see whether recall on the churned class improves.
  • Add a threshold-tuning step — instead of the default 0.5 cutoff, find the probability threshold that maximizes recall while keeping precision above some minimum.
  • Engineer one more derived feature of your own from the raw columns, and check whether it appears near the top of Step 7's own importance ranking.
  • Add a third model — a gradient-boosted tree (GradientBoostingClassifier) — to the Step 5/6 comparison.

What's Next

Chapter 3: Movie Recommender — returning to dsproj1-5's own ratings dataset, but with a genuinely different question: not what can be learned from the data, but what should be recommended from it.

Chapter 2 Quick Reference

  • Delivers directly on dsproj1-7's own deferred promise — full ml1 depth: real feature engineering, model comparison, cross-validation, precision/recall/F1
  • Logistic regression (ml1-5) vs. random forest (ml1-7), fit on identical data for an honest comparison
  • The accuracy paradox (ml1-6) caught concretely in real, imbalanced churn data — recall on the minority class matters more than overall accuracy
  • cross_val_score() (ml1-8) gives a more honest performance estimate than one train/test split alone
  • Neither model is simply "better" — the right choice trades off interpretability against raw predictive performance