Skip to content

Feature Engineering — Churn Dataset

Feature engineering has two jobs: give the model better signal, and make sure nothing from the future leaks into the training data. The second job matters more. A model trained on leaked features will look spectacular in your notebook and fail immediately in production.

This file builds the complete preprocessing pipeline as a scikit-learn Pipeline object. The pipeline is the unit of deployment — you fit it once on training data, and it applies the same transformations consistently to every new row of data.


Step 1 — Leakage Audit

Before building anything, list every column and ask: "Could this value only exist after the customer has already churned?"

Column Safe to use? Reason
customer_id Drop Identifier, not a feature
tenure_months Yes Known at prediction time
monthly_charges Yes Known at billing time
num_products Yes Known from account records
support_calls Yes Historical count, available before churn
has_tech_support Yes Account attribute
contract_type Yes Account attribute
payment_method Yes Account attribute
churn Target Never in feature matrix

No leakage in this dataset. In real projects, common leakers are: days_since_last_payment (only exists after the customer stops paying), account_status = "pending_cancellation", and any aggregation that includes the churn month itself.

Warning

The most common reason projects fail review: a feature that looked like historical data was actually computed from a window that included the event you are predicting. If you are ever unsure about a feature, ask: "At the moment of prediction, can I compute this feature without knowing whether the customer will churn?" If the answer is no, drop it.


Step 2 — Define Feature Groups

# After cleaning (from 02-eda-and-cleaning.md)
TARGET = "churn"
DROP_COLS = ["customer_id"]

numeric_features = [
    "tenure_months",
    "monthly_charges",
    "num_products",
    "support_calls",
    "has_tech_support",
]

categorical_features = [
    "contract_type",
    "payment_method",
]

X = df.drop(columns=DROP_COLS + [TARGET])
y = df[TARGET]

print(X.shape)
# Output: (1000, 7)
print(y.value_counts())
# Output:
# 0    706
# 1    294
# Name: churn, dtype: int64

Step 3 — Interaction Features

Two engineered features that capture combinations not visible in raw columns:

charges_per_product — monthly cost divided by number of products. A customer paying $90/month for one product is a different risk profile than one paying $90/month for four products.

calls_per_tenure — support calls normalised by tenure. A new customer with 4 calls in 2 months is signalling distress; a long-tenure customer with 4 calls in 60 months is not.

# Add interaction features before the pipeline
# (these are simple enough to compute directly on the DataFrame)
df["charges_per_product"] = df["monthly_charges"] / df["num_products"]
df["calls_per_tenure"]    = df["support_calls"] / (df["tenure_months"] + 1)
# +1 avoids division by zero for new customers with tenure_months == 0

# Add to feature list
numeric_features = numeric_features + ["charges_per_product", "calls_per_tenure"]

X = df.drop(columns=DROP_COLS + [TARGET])
print(X.shape)
# Output: (1000, 9)

Tip

Compute interaction features on the full DataFrame before the train/test split. These features are deterministic functions of existing columns — they do not use any information from the target — so computing them before the split is safe and avoids duplicating logic inside the pipeline.


Step 4 — Build the Preprocessing Pipeline

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer

# Numeric transformer: impute (safety net) → scale
numeric_transformer = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler",  StandardScaler()),
])

# Categorical transformer: impute (safety net) → one-hot encode
categorical_transformer = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="most_frequent")),
    ("onehot",  OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])

# Combine with ColumnTransformer
preprocessor = ColumnTransformer(transformers=[
    ("num", numeric_transformer,      numeric_features),
    ("cat", categorical_transformer,  categorical_features),
])

Info

handle_unknown="ignore" in OneHotEncoder means if a new category appears at prediction time (e.g., a new contract type), the encoder will produce a row of zeros for that feature rather than raising an error. This is the right default for production models.


Step 5 — Verify the Preprocessor Output

Fit the preprocessor on a small sample and inspect what comes out. Do this before attaching a model — it is much easier to debug here than inside a full Pipeline.

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.20,
    random_state=42,
    stratify=y,          # preserves churn rate in both splits
)

print(f"Train: {X_train.shape}, Test: {X_test.shape}")
# Output: Train: (800, 9), Test: (200, 9)

print(f"Train churn rate: {y_train.mean():.3f}, Test churn rate: {y_test.mean():.3f}")
# Output: Train churn rate: 0.294, Test churn rate: 0.295

# Fit ONLY on training data
preprocessor.fit(X_train)
X_train_transformed = preprocessor.transform(X_train)

print(f"Transformed shape: {X_train_transformed.shape}")
# Output: Transformed shape: (800, 15)
# 9 numeric features (scaled) + 3 contract types + 4 payment methods - intercept = 15 total columns

Success

You should see 15 output columns: 9 scaled numeric features plus the one-hot encoded categoricals (3 contract types + 4 payment methods = 7, minus zero-sum constraint handled by OneHotEncoder which keeps all categories by default). If the shape looks wrong, print preprocessor.get_feature_names_out() to inspect each column.

# Inspect feature names after transformation
feature_names = preprocessor.get_feature_names_out()
print(feature_names)
# Output:
# ['num__tenure_months' 'num__monthly_charges' 'num__num_products'
#  'num__support_calls' 'num__has_tech_support' 'num__charges_per_product'
#  'num__calls_per_tenure' 'cat__contract_type_Month-to-Month'
#  'cat__contract_type_One Year' 'cat__contract_type_Two Year'
#  'cat__payment_method_Bank Transfer' 'cat__payment_method_Credit Card'
#  'cat__payment_method_Electronic Check' 'cat__payment_method_Mailed Check']

Step 6 — Assemble the Full Model Pipeline

The model and the preprocessor live in one Pipeline. When you call .fit(X_train, y_train), the preprocessor fits on training data then transforms it before the model sees it. When you call .predict(X_test), the preprocessor transforms the test data using the statistics it learned from training — never from test.

from sklearn.linear_model import LogisticRegression

full_pipeline = Pipeline(steps=[
    ("preprocessor", preprocessor),
    ("classifier",   LogisticRegression(max_iter=1000, random_state=42)),
])

# This is all you need to train the full system
full_pipeline.fit(X_train, y_train)

# Quick sanity check on training data
from sklearn.metrics import f1_score
y_pred_train = full_pipeline.predict(X_train)
print(f"Train F1 (churn class): {f1_score(y_train, y_pred_train):.3f}")
# Output: Train F1 (churn class): 0.623  (approximate)

Warning

Never call preprocessor.fit() or preprocessor.fit_transform() on X_test. If you do, you are leaking test distribution information (means, standard deviations, category vocabularies) back into your model evaluation. The entire point of the Pipeline is to make this mistake impossible — use it.


Feature Engineering Summary

Feature Type Source
tenure_months Numeric (scaled) Raw
monthly_charges Numeric (scaled) Raw
num_products Numeric (scaled) Raw
support_calls Numeric (scaled) Raw
has_tech_support Numeric (scaled) Raw binary
charges_per_product Numeric (scaled) Engineered: monthly_charges / num_products
calls_per_tenure Numeric (scaled) Engineered: support_calls / (tenure_months + 1)
contract_type_* One-hot (3 cols) Encoded categorical
payment_method_* One-hot (4 cols) Encoded categorical

02-eda-and-cleaning | 04-modeling-pipeline