Feature Engineering¶
Raw features rarely tell the full story. The signal that predicts churn is not just how many times a customer called support — it is how intensely they called given how long they have been a customer. Feature engineering is where you translate domain knowledge into signals the model can exploit.
Learning Objectives¶
By the end of this file you will be able to:
- Apply domain-informed imputation strategies for
ageandsupport_calls - Create four engineered features that capture customer behaviour patterns not visible in raw columns
- Encode categorical and ordinal variables correctly
- Scale numeric features using
StandardScaler - Wrap the entire process in a reusable
sklearnPipelinethat prevents data leakage
Setup: Generate the Dataset¶
Run this block first. All code in this file assumes df is in scope.
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
from sklearn.impute import SimpleImputer
rng = np.random.default_rng(42)
n = 1000
tenure = rng.integers(1, 72, n)
age = rng.integers(22, 65, n).astype(float)
segment = rng.choice(["Basic", "Premium", "Enterprise"], n, p=[0.55, 0.35, 0.10])
region = rng.choice(["North", "South", "East", "West"], n)
support_calls = rng.integers(0, 15, n).astype(float)
monthly_fee = np.where(
segment == "Enterprise", rng.uniform(2000, 5000, n),
np.where(segment == "Premium", rng.uniform(800, 1500, n),
rng.uniform(200, 600, n))
)
num_products = rng.integers(1, 6, n)
has_contract = rng.choice([0, 1], n, p=[0.4, 0.6])
churn_prob = (
0.55
- tenure * 0.008
+ support_calls * 0.04
- (segment == "Premium") * 0.12
- (segment == "Enterprise") * 0.20
- has_contract * 0.18
+ rng.normal(0, 0.05, n)
)
churn_prob = np.clip(churn_prob, 0.02, 0.95)
churn = (rng.uniform(size=n) < churn_prob).astype(int)
df = pd.DataFrame({
"customer_id": range(10001, 10001 + n),
"age": age,
"tenure_months": tenure,
"segment": segment,
"region": region,
"support_calls": support_calls,
"monthly_fee": monthly_fee.round(2),
"num_products": num_products,
"has_contract": has_contract,
"churn": churn,
})
df.loc[rng.choice(df.index, 30, replace=False), "age"] = np.nan
df.loc[rng.choice(df.index, 15, replace=False), "support_calls"] = np.nan
Step 1: Train-Test Split First¶
Split before any transformation. This is the most important sequencing rule in feature engineering.
X = df.drop(columns=["customer_id", "churn"])
y = df["churn"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.20, random_state=42, stratify=y
)
print(f"Train: {X_train.shape} | Test: {X_test.shape}")
# Train: (800, 9) | Test: (200, 9)
print(f"Train churn rate: {y_train.mean():.1%} | Test churn rate: {y_test.mean():.1%}")
# Both should be ~30% — stratify= ensures this
Warning
Never fit any imputer, scaler, or encoder on the full dataset before splitting. If you do, your test set has already influenced the imputation medians and scaler means. That is data leakage — your test metrics will be optimistic and the model will underperform on real customers.
Step 2: Handle Missing Values¶
age — Median Imputation¶
Age is roughly symmetric across the 22–64 range. The 30 missing values are randomly distributed (confirmed in EDA). Median imputation is robust to any skew and adds minimal bias.
train_age_median = X_train["age"].median()
print(f"Training set age median: {train_age_median:.1f}")
# ~42.0
print(f"Age missing in train: {X_train['age'].isna().sum()}")
# ~24 (approximately 80% of the 30 total injected)
support_calls — Zero Imputation¶
Missing support call records almost certainly mean the customer never called — not that the data was lost. A customer with no recorded calls is a different behavioural profile from one with an unreported count.
# Confirm: is missingness random or correlated with churn?
print("Churn rate (support_calls missing):",
df[df["support_calls"].isna()]["churn"].mean().round(3))
print("Churn rate (support_calls observed):",
df[df["support_calls"].notna()]["churn"].mean().round(3))
# Both should be close to ~0.30 — missingness is random, not informative
Info
Imputing with 0 is a domain-informed choice, not a statistical guess. If customers with missing support_calls had a dramatically higher churn rate, it would suggest the missingness itself is a signal, and you should consider a calls_missing binary flag instead of zero imputation.
Step 3: Engineer New Features¶
Apply these transformations to training data first to understand the distributions, then bake them into the pipeline.
calls_per_tenure — Contact Intensity¶
Raw support call counts do not account for how long the customer has been around. Ten calls in 60 months is fine. Ten calls in two months signals serious friction.
calls_filled = X_train["support_calls"].fillna(0)
calls_per_tenure = calls_filled / (X_train["tenure_months"] + 1)
print(calls_per_tenure.describe().round(3))
# min 0.000
# 25% 0.048
# 50% 0.110
# 75% 0.210
# max 1.400
The + 1 in the denominator prevents division by zero for any hypothetical customer with tenure_months = 0.
high_call_flag — Binary Churn Risk Indicator¶
EDA showed that customers with 7 or more support calls in 90 days churn at ~52%. This threshold is the point where churn probability crosses 50%. A binary flag preserves this non-linear threshold effect explicitly.
calls_filled = X_train["support_calls"].fillna(0)
high_call_flag = (calls_filled >= 7).astype(int)
churn_rate_high = y_train[high_call_flag == 1].mean()
churn_rate_low = y_train[high_call_flag == 0].mean()
print(f"Churn rate — high calls (>=7): {churn_rate_high:.1%}") # ~52%
print(f"Churn rate — low calls (< 7): {churn_rate_low:.1%}") # ~17%
Tip
Threshold features like high_call_flag are valuable even with tree-based models. Trees can find splits on their own, but an explicit flag reduces the depth needed to capture this effect and makes feature importance output more interpretable.
tenure_bucket — Ordinal Lifecycle Stage¶
The relationship between tenure and churn is non-linear. The drop in churn risk is steep in the first 18 months, then levels off. Bucketing captures this shape better than treating tenure as a continuous linear predictor.
tenure_bucket = pd.cut(
X_train["tenure_months"],
bins=[0, 6, 18, 36, 72],
labels=["new", "developing", "established", "loyal"]
)
print(pd.crosstab(tenure_bucket, y_train, normalize="index").round(3))
# tenure_months 0 1
# new ~0.40 ~0.60 <- highest churn risk
# developing ~0.62 ~0.38
# established ~0.77 ~0.23
# loyal ~0.87 ~0.13 <- lowest churn risk
fee_per_product — Value Density¶
A customer paying INR 1,200/month for 4 products is getting good value. A customer paying INR 1,000/month for 1 product may feel overcharged. Dividing fee by products captures this perceived-value ratio.
fee_per_product = X_train["monthly_fee"] / X_train["num_products"]
print(fee_per_product.describe().round(1))
# min ~40, mean ~500, max ~5000
Step 4: Encoding¶
segment — One-Hot (nominal, no natural order)¶
Three categories: Basic, Premium, Enterprise. No ordinal relationship. One-hot encoding produces three binary columns.
# Preview what one-hot looks like
pd.get_dummies(X_train["segment"].head(3), prefix="segment")
# segment_Basic segment_Enterprise segment_Premium
# 0 1 0 0
# 1 0 0 1
# 2 1 0 0
region — One-Hot (nominal, weak signal)¶
Four regions, no natural order. EDA confirmed region adds almost no signal. The model will assign low feature importance — confirming the EDA finding. Encode it correctly and let the model confirm it.
tenure_bucket — Ordinal (0, 1, 2, 3)¶
The lifecycle stages have a natural order: new < developing < established < loyal. Ordinal encoding preserves this and produces a single numeric column rather than four binary columns.
from sklearn.preprocessing import OrdinalEncoder
oe = OrdinalEncoder(categories=[["new", "developing", "established", "loyal"]])
sample = np.array(["new", "loyal", "developing", "established"]).reshape(-1, 1)
print(oe.fit_transform(sample).flatten())
# [0. 3. 1. 2.]
Step 5: Scaling¶
Logistic regression is sensitive to feature magnitude. StandardScaler centres and scales each numeric feature to mean=0, std=1. Tree-based models do not require scaling, but applying it consistently means the pipeline works with any classifier without modification.
| Feature | Typical Range | Scaling applied |
|---|---|---|
age |
22–64 | Yes — after median imputation |
tenure_months |
1–71 | Yes |
support_calls |
0–14 | Yes — after zero imputation in engineering step |
monthly_fee |
200–5,000 | Yes — high variance across segments |
calls_per_tenure |
0.0–1.4 | Yes — small values, needs normalisation |
fee_per_product |
40–5,000 | Yes — high variance |
Step 6: Build the sklearn Pipeline¶
A Pipeline applies all transformations in a defined order and ensures scaler and encoder parameters are learned only from training data. This is the pipeline you will attach a classifier to in the next file.
def add_engineered_features(X):
"""Add derived columns. Handles support_calls NaNs with zero fill."""
X = X.copy()
calls = X["support_calls"].fillna(0)
X["calls_per_tenure"] = calls / (X["tenure_months"] + 1)
X["high_call_flag"] = (calls >= 7).astype(int)
X["fee_per_product"] = X["monthly_fee"] / X["num_products"]
X["tenure_bucket"] = pd.cut(
X["tenure_months"],
bins=[0, 6, 18, 36, 72],
labels=["new", "developing", "established", "loyal"]
).astype(str) # ColumnTransformer expects string, not Categorical dtype
return X
# Apply engineering to both splits
X_train_eng = add_engineered_features(X_train)
X_test_eng = add_engineered_features(X_test)
# --- define column groups after engineering ---
numeric_features = ["age", "tenure_months", "support_calls",
"monthly_fee", "calls_per_tenure", "fee_per_product"]
binary_features = ["has_contract", "high_call_flag"]
onehot_features = ["segment", "region"]
ordinal_features = ["tenure_bucket"]
# --- sub-pipelines ---
numeric_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
onehot_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])
ordinal_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OrdinalEncoder(
categories=[["new", "developing", "established", "loyal"]],
handle_unknown="use_encoded_value",
unknown_value=-1,
)),
])
# --- combine all sub-pipelines ---
preprocessor = ColumnTransformer([
("num", numeric_pipeline, numeric_features),
("bin", "passthrough", binary_features),
("onehot", onehot_pipeline, onehot_features),
("ordinal", ordinal_pipeline, ordinal_features),
])
print("Preprocessor defined. Attach a classifier in model-building.md.")
# Verify shape after transformation
X_transformed = preprocessor.fit_transform(X_train_eng)
print(f"Transformed shape: {X_transformed.shape}")
# (800, 16)
Warning
Always call preprocessor.fit_transform(X_train_eng) during training and preprocessor.transform(X_test_eng) during evaluation — never fit_transform on the test set. The Pipeline class handles this automatically when you use pipeline.fit() and pipeline.predict() together, which is exactly why pipelines exist.
Step 7: Final Feature Set¶
After the ColumnTransformer, the model receives 16 input features:
| # | Feature | Origin | Transformation |
|---|---|---|---|
| 1 | age |
Raw | Median impute → StandardScaler |
| 2 | tenure_months |
Raw | StandardScaler |
| 3 | support_calls |
Raw | Median impute → StandardScaler |
| 4 | monthly_fee |
Raw | StandardScaler |
| 5 | calls_per_tenure |
Engineered | StandardScaler |
| 6 | fee_per_product |
Engineered | StandardScaler |
| 7 | has_contract |
Raw | Passthrough (already binary) |
| 8 | high_call_flag |
Engineered | Passthrough (already binary) |
| 9 | segment_Basic |
Raw | One-hot |
| 10 | segment_Enterprise |
Raw | One-hot |
| 11 | segment_Premium |
Raw | One-hot |
| 12 | region_East |
Raw | One-hot |
| 13 | region_North |
Raw | One-hot |
| 14 | region_South |
Raw | One-hot |
| 15 | region_West |
Raw | One-hot |
| 16 | tenure_bucket |
Engineered | Ordinal (0–3) |
16 input features from 9 raw columns plus 4 engineered features.
Success
A well-structured pipeline is your most important deliverable in a real project — not the model accuracy. The pipeline is what makes your work reproducible, reviewable, and deployable. A model sitting outside a pipeline is a model you cannot safely put into production.