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 |