The MercaFresh churn dataset is now clean, complete, transformed, encoded and scaled. In the previous lessons we repaired and adapted what was already there; in this final lesson of the module we take the leap that usually delivers the most performance: creating new information. Feature engineering means building variables that condense business knowledge — how long since a customer last bought, how much they're worth, whether their activity is growing or fading — so the model doesn't have to discover it on its own. There's a much-quoted saying in the profession: the data and the features set the ceiling of what can be achieved; algorithms merely get closer to or further from that ceiling. Here you'll learn to build business features (with the classic RFM), interactions and polynomials, and to select which ones to keep. We'll close by assembling the definitive churn dataset and the checklist for the whole module.
Contents
- What feature engineering is and why it matters so much
- Business features: RFM analysis on MercaFresh
- Ratios and trends: squeezing what you already have
- Interactions and polynomials
- Feature selection: keeping what earns its place
- The final MercaFresh churn dataset
- Checklist for the complete preprocessing
What feature engineering is and why it matters so much
A feature is each column the model receives as input. Feature engineering is the art of manufacturing new columns from the existing ones, incorporating domain knowledge. Why does it usually deliver more than switching algorithms?
- Models see little on their own. A logistic regression (04-02) only combines its inputs linearly: if the churn signal lives in the spend/order ratio and you give it spend and orders separately, it may never find it. Give it the ratio already computed, and the signal is served on a plate.
- Business knowledge isn't in the raw data. Any MercaFresh account manager knows that "a customer who used to order weekly and has gone 45 days without buying is at risk"; the orders table, on its own, doesn't say it — it has to be distilled into a column.
- It's the most profitable lever. Moving from a decent algorithm to a sophisticated one usually gains a few points; one good new feature can transform the problem. That's why experienced teams spend more time on features than on models.
We already have the raw material from the previous lessons: the per-customer aggregations from 03-03 were the first step. Now we turn them into a system.
Business features: RFM analysis on MercaFresh
The RFM framework is a classic of customer analytics that fits churn like a glove:
| Letter | Meaning | Question | Feature at MercaFresh |
|---|---|---|---|
| R | Recency | How long since their last purchase? | days_since_last_purchase |
| F | Frequency | How often do they buy? | orders_per_month |
| M | Monetary value | How much do they spend? | avg_order_spend |
The business intuition: a customer who bought a lot, often and recently is healthy; one whose recency grows while their frequency drops is on their way out — exactly what the churn model must learn. Let's build it from the orders table:
import pandas as pd
import numpy as np
rng = np.random.default_rng(7)
# Simulated orders table: 600 orders from 80 customers over one year
n = 600
orders = pd.DataFrame({
"customer_id": rng.integers(101, 181, size=n),
"date": pd.Timestamp("2025-08-24")
+ pd.to_timedelta(rng.integers(0, 365, size=n), unit="D"),
"order_amount": rng.lognormal(3.4, 0.5, size=n).round(2),
})
CUTOFF_DATE = pd.Timestamp("2026-08-24") # fixed analysis date (remember 03-03)
rfm = orders.groupby("customer_id").agg(
last_purchase=("date", "max"),
first_purchase=("date", "min"),
num_orders=("order_amount", "count"),
total_spend=("order_amount", "sum"),
)
# R: days since the last purchase
rfm["recency_days"] = (CUTOFF_DATE - rfm["last_purchase"]).dt.days
# F: orders per month of life as a customer (we avoid dividing by 0 with clip)
months_tenure = ((CUTOFF_DATE - rfm["first_purchase"]).dt.days / 30).clip(lower=1)
rfm["orders_per_month"] = (rfm["num_orders"] / months_tenure).round(2)
# M: average spend per order
rfm["avg_order_spend"] = (rfm["total_spend"] / rfm["num_orders"]).round(2)
print(rfm[["recency_days", "orders_per_month", "avg_order_spend"]].head())Three tricks of the trade:
- The fixed cutoff date (not
now()) makes the calculation reproducible, as we saw in 03-03. - The
clip(lower=1)avoids divisions by zero for one-day-old customers: new features can also manufacture impossible values, and the validation rules from 03-01 apply to them just the same. - Every RFM feature has a direct business reading: when the model says "recency is what weighs most", the retention team will know exactly what to do. Good features are also a communication channel with the business.
A very common variant is converting R, F and M into scores from 1 to 5 with qcut (the binning from 03-03!) and talking about "5-5-5 customers" or "1-1-3"; the fine-grained segmentation of these profiles is something we'll do with clustering in module 5 and in project 09-05.
Ratios and trends: squeezing what you already have
Ratios: relationships that loose columns don't tell
A ratio relates two magnitudes and often says more than either one alone:
# Average ticket: large, spread-out purchases, or small and frequent ones?
rfm["spend_per_order"] = rfm["total_spend"] / rfm["num_orders"]
# Intensity: what fraction of their life as a customer have they been inactive?
tenure_days = (CUTOFF_DATE - rfm["first_purchase"]).dt.days.clip(lower=1)
rfm["inactivity_ratio"] = (rfm["recency_days"] / tenure_days).round(3)inactivity_ratio is a gem for churn: 60 days without buying is alarming for a weekly customer (high ratio) and normal for a quarterly one (low ratio). The ratio encodes that context; the loose columns don't.
Activity trend: growing or fading?
Churn is a process, not an instant: before leaving, the customer cools off. A trend feature captures it by comparing their recent activity with the previous period:
last_quarter = CUTOFF_DATE - pd.Timedelta(days=90)
prev_quarter = CUTOFF_DATE - pd.Timedelta(days=180)
recent_orders = (orders[orders["date"] >= last_quarter]
.groupby("customer_id").size().rename("orders_last_90d"))
prev_orders = (orders[(orders["date"] >= prev_quarter)
& (orders["date"] < last_quarter)]
.groupby("customer_id").size().rename("orders_prev_90d"))
rfm = rfm.join(recent_orders).join(prev_orders)
rfm[["orders_last_90d", "orders_prev_90d"]] = (
rfm[["orders_last_90d", "orders_prev_90d"]].fillna(0)) # no orders = 0 (03-02)
# Trend: >1 accelerating, ~1 stable, <1 fading (+1 to avoid 0/0)
rfm["trend"] = ((rfm["orders_last_90d"] + 1)
/ (rfm["orders_prev_90d"] + 1)).round(2)A customer with trend = 0.25 (from 8 orders down to 1) is a textbook churn candidate even if their historical spend is high. Notice how the earlier lessons resurface: fillna(0) is an imputation with business meaning (03-02) and the +1 is the same stability trick as log1p (03-03).
Interactions and polynomials
Sometimes the signal lives in the combination of two variables: the effect of spend on churn may depend on the plan (losing €50/month from a basic customer is not the same as from a premium one). An interaction is simply the product of two features; a polynomial also adds powers (x², x³) to capture curvature.
PolynomialFeatures generates them mechanically:
from sklearn.preprocessing import PolynomialFeatures
X = rfm[["recency_days", "orders_per_month"]].head(3)
pf = PolynomialFeatures(degree=2, include_bias=False)
X_poly = pf.fit_transform(X)
print(pf.get_feature_names_out())
# ['recency_days' 'orders_per_month' 'recency_days^2'
# 'recency_days orders_per_month' 'orders_per_month^2']From 2 columns come 5: the originals, their squares and their product. Two warnings and one preference:
- Combinatorial explosion: with 20 features and
degree=2you get 230 columns; withdegree=3, more than 1,700.interaction_only=Truerestricts it to products, but it still grows fast. - More mechanically manufactured columns = more risk of the model memorizing noise (the overfitting we'll formalize in 06-05) and more need for selection (next section) or regularization (07-01).
- A craftsman's preference: rather than polynomials in bulk, a few interactions chosen with your head.
recency_days * orders_per_month("how much silence a habitually active customer is accumulating") is worth more than twenty blind products.
Feature selection: keeping what earns its place
Creating features is easy; the danger is ending up with 80 columns of which 50 are noise, redundancy or dead weight. More features is not always better: they make training more expensive, hinder interpretation and give the model more rope to fit chance. Three introductory filters, from simplest to most formal:
- Near-zero variance
A column that barely varies can't distinguish anyone. If is_active_customer is 1 for 99.5% of the rows, it contributes nothing:
from sklearn.feature_selection import VarianceThreshold
vt = VarianceThreshold(threshold=0.01) # removes near-constant columns
X_filtered = vt.fit_transform(X_numeric)
print(vt.get_support()) # mask of the columns kept(Careful: the threshold depends on the scale, so it's applied to comparable data — another point for the scaling from 03-05.)
- Correlation filtering
Two uses of the correlation matrix from 02-03, now as a selection tool:
- Against the target: features with ~0 correlation with churn are candidates to go (with the caveat from 02-03: Pearson only sees linear relationships).
- Among themselves: two features with a 0.95 correlation between them (as
total_spendandnum_ordersusually have) are nearly the same information twice; keeping one simplifies without losing signal.
corr = X_numeric.corr(numeric_only=True)
# Pairs of highly inter-correlated features (redundancy)
high = (corr.abs() > 0.9) & (corr.abs() < 1.0)
print([(a, b) for a in corr.columns for b in corr.columns
if high.loc[a, b] and a < b])It's literally the ranking of correlations with churn we built at the end of 02-03, now applied to deciding which columns live.
SelectKBest: the statistical filter
SelectKBest: the statistical filterSelectKBest scores each feature with a statistical test against the target and keeps the k best:
from sklearn.feature_selection import SelectKBest, f_classif
skb = SelectKBest(score_func=f_classif, k=5)
X_top = skb.fit_transform(X_numeric, y_churn)
print(dict(zip(X_numeric.columns, skb.scores_.round(1))))
print(X_numeric.columns[skb.get_support()].tolist()) # the 5 chosenf_classif is, in essence, a hypothesis test like those in 02-04: does this feature's mean differ between the customers who leave and those who stay? High score = clear difference = promising feature. It's a filter method (it evaluates each feature in isolation, ignoring combinations): fast and useful as an initial sieve, not as a final verdict. And like everything that learns from the target, it's fitted on training data only — at this point in the module, the reflex should be automatic.
More sophisticated families exist (wrapper selection, PCA as dimensionality reduction in 05-03, Lasso regularization as implicit selection in 07-01); to close out preprocessing, these three filters suffice.
The final MercaFresh churn dataset
Time to assemble the whole module's work into the definitive table, one row per customer:
| Feature | Origin | Lesson |
|---|---|---|
age, city_* (one-hot), plan_code |
Clean, encoded customer table | 03-01, 03-04 |
satisfaction + satisfaction_missing |
Imputation with indicator (MNAR) | 03-02 |
log_total_spend |
Logarithmic transformation | 03-03 |
recency_days, orders_per_month, avg_order_spend |
RFM | 03-06 |
inactivity_ratio, trend |
Ratios and trend | 03-06 |
recency_x_frequency |
Hand-picked interaction | 03-06 |
churn (0/1) |
Target variable: no purchases in 90 days | Defined in 01-05 |
flowchart TD
A["Raw customers"] -->|"03-01 cleaning"| B["Clean customers"]
B -->|"03-02 nulls"| C["Complete customers"]
P["Raw orders"] -->|"03-03 aggregation"| D["RFM + ratios + trend<br/>(03-06)"]
C --> E["merge on customer_id"]
D --> E
E -->|"03-03 transform<br/>03-04 encode<br/>03-05 scale"| F["Numeric matrix"]
F -->|"03-06 selection"| G["Final churn dataset"]
And the complete preprocessing, as a single executable object — the ColumnTransformer we've been filling in lesson by lesson, now with all its branches:
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (OneHotEncoder, OrdinalEncoder,
PowerTransformer, RobustScaler)
numeric_branch = Pipeline([
("impute", SimpleImputer(strategy="median", add_indicator=True)), # 03-02
("deskew", PowerTransformer(method="yeo-johnson")), # 03-03
("scale", RobustScaler()), # 03-05
])
preprocessor = ColumnTransformer([
("num", numeric_branch,
["age", "satisfaction", "recency_days", "orders_per_month",
"avg_order_spend", "inactivity_ratio", "trend"]),
("cat_nominal", OneHotEncoder(sparse_output=False, handle_unknown="ignore"),
["city"]), # 03-04
("cat_ordinal", OrdinalEncoder(categories=[["basic", "standard", "premium"]]),
["plan"]), # 03-04
])
# In module 4: preprocessor.fit_transform(X_train) -> feed the modelThis object is the executable summary of the module: a single fit on training data learns medians, lambdas, quartiles and categories; a single transform prepares any future customer. This dataset — these features, this preprocessor — is the one we'll use conceptually for the rest of the course whenever we talk about MercaFresh churn.
Checklist for the complete preprocessing
The module's checklist, in order of application:
- Audit (03-01):
info(),describe(),nunique(); understand what each row represents. - Clean (03-01): exact and logical duplicates; validity rules for impossible values; normalize text and categories; fix types (
to_numeric,to_datetime); diagnose outliers (error or reality?). - Handle nulls (03-02): diagnose the mechanism (MCAR/MAR/MNAR); drop only when safe; impute (median by default, by groups if there's structure); missingness indicators if the absence is informative.
- Transform (03-03): correct skewness (
log1p, Yeo-Johnson); discretize when useful; decompose dates; aggregate per entity to the problem's granularity. - Create features (03-06): RFM, ratios, trends, interactions chosen with business judgment.
- Encode (03-04): ordinal with explicit order for real ordinals; one-hot for nominals; extreme care with target encoding.
- Scale (03-05): according to the algorithm;
RobustScalerif there are legitimate outliers. - Select (03-06): near-zero variance, redundancy by correlation,
SelectKBestas a sieve. - Cross-cutting: every step that learns parameters runs
fiton training data only; encapsulate inPipeline/ColumnTransformer; document every decision.
(The 5→6→7 order is no accident: features are created on readable values, then encoded, and scaling goes last because every new column needs a scale too.)
Common Mistakes and Tips
- Creating features using the future. If churn is measured 90 days ahead, no feature may be computed with data past the cutoff date: it's data leakage in its temporal version, the subtlest of all.
- Manufacturing hundreds of mechanical features and not one business feature.
PolynomialFeatures(degree=3)is no substitute for a conversation with MercaFresh's retention team. The best features are born from business questions. - Not validating the new features. A ratio can generate infinities (division by zero) or absurd values: run every new feature through the same audit as in 03-01 (
describe(), histogram). - Keeping twin features.
total_spend,num_ordersandavg_order_spendtogether are partially redundant: review the correlation matrix between features, not just against the target. - Selecting features on the whole dataset.
SelectKBestlooks at the target: if it's fitted with data that will later be test, the evaluation is contaminated.fiton train, always. - Not documenting the data dictionary. Three months from now nobody will remember how
trendwas computed. Keep a table of feature → definition → formula → lesson/date. - Tip: every time a feature occurs to you, first write in one sentence the business story it tells ("how much silence an active customer is accumulating"). If you can't formulate the sentence, it's probably not a good feature either.
Exercises
Exercise 1
Propose (without code) three new features for the MercaFresh demand problem (predicting how many units of each product will sell tomorrow), inspired by the techniques of this lesson. For each one, write the business sentence it tells.
Exercise 2
With the lesson's rfm table, create the feature value_at_risk = avg_order_spend * orders_per_month * inactivity_ratio and explain what it measures in business terms. Then compute its Spearman correlation with recency_days and reason whether you expected that sign.
Exercise 3
You have 12 numeric features and the target churn. Write the code that: (a) removes those with variance < 0.01; (b) from the remainder, selects the 6 best with SelectKBest and f_classif; (c) prints which ones survived. State which subset of the data you would run the fit calls on.
Solutions
Exercise 1
Guideline answers (many are valid):
avg_sales_last_4_weeks(temporal aggregation): "how much has sold lately is the best starting point for tomorrow".is_pre_holiday(calendar component, 03-03): "on the eve of a holiday, fresh-produce purchases spike".price_vs_category_ratio(ratio): "a product temporarily cheaper than its category steals sales from its substitutes".
What matters: each feature has a clear formula and a one-sentence business story.
Exercise 2
rfm["value_at_risk"] = (rfm["avg_order_spend"]
* rfm["orders_per_month"]
* rfm["inactivity_ratio"]).round(2)
print(rfm["value_at_risk"].corr(rfm["recency_days"], method="spearman"))What it measures: the customer's usual monthly spend (ticket × frequency) weighted by their degree of inactivity — in other words, how many euros per month are at risk of being lost. A valuable, very inactive customer scores high; a cheap or fully active one, low. It's a prioritization feature: whom to call first. The correlation with recency_days comes out positive, as expected: recency enters the numerator of inactivity_ratio, so the more days without buying, the more value at risk — Spearman (02-03) is the right choice because the relationship is monotonic but not linear.
Exercise 3
from sklearn.feature_selection import VarianceThreshold, SelectKBest, f_classif
# Both fits run ONLY on the training set (X_train, y_train):
# VarianceThreshold doesn't look at the target, but SelectKBest does — and
# workflow consistency demands fitting all preprocessing on train.
vt = VarianceThreshold(threshold=0.01)
X_var = vt.fit_transform(X_train)
var_cols = X_train.columns[vt.get_support()]
skb = SelectKBest(score_func=f_classif, k=6)
X_sel = skb.fit_transform(X_var, y_train)
print(var_cols[skb.get_support()].tolist())
# X_test gets the SAME already-fitted objects applied:
# X_test_sel = skb.transform(vt.transform(X_test))Additional reflection exercise: why would SelectKBest on the whole dataset contaminate the evaluation? Because it scores each feature by its relationship with the target; if it sees the test labels, the resulting selection is optimized for test too, and the subsequent evaluation no longer measures real generalization.
Conclusion
You've closed the module with its most creative lesson: what feature engineering is and why it sets the model's ceiling, how to build business features with the RFM framework (recency, frequency, monetary value), ratios and trends that capture a customer cooling off, interactions and polynomials in moderation, and three selection filters — near-zero variance, correlation and SelectKBest — to keep what earns its place. And above all, you've assembled the result of the six lessons into a concrete product: the final MercaFresh churn dataset and its executable preprocessor, with the nine-step checklist as the map of the whole module.
That dataset is no longer a dirty table exported from a CRM: it's a numeric matrix, complete, scaled and loaded with business knowledge, ready for training. In module 4 the moment we've spent three modules preparing finally arrives: supervised learning algorithms, starting with linear regression — the simplest model and the best place to truly understand what it means for a machine to learn from MercaFresh's data.
Machine Learning Course
Module 1: Introduction to Machine Learning
- What is Machine Learning?
- History and evolution of Machine Learning
- Types of Machine Learning
- Applications of Machine Learning
- The Machine Learning project workflow
Module 2: Foundations of Statistics and Probability
- Basic statistics concepts
- Probability distributions
- Correlation and covariance
- Statistical inference
- Bayes' theorem
Module 3: Data Preprocessing
- Data cleaning
- Handling missing data
- Data transformation
- Encoding categorical variables
- Normalization and standardization
- Feature engineering
Module 4: Supervised Machine Learning Algorithms
- Linear regression
- Logistic regression
- Decision trees
- Support Vector Machines (SVM)
- K-Nearest Neighbors (K-NN)
- Naive Bayes
- Neural networks
Module 5: Unsupervised Machine Learning Algorithms
- Clustering: K-means
- Hierarchical clustering
- Principal Component Analysis (PCA)
- DBSCAN clustering
- Data visualization with t-SNE and UMAP
Module 6: Model Evaluation and Validation
- Data splitting: training, validation and test
- Evaluation metrics
- Cross-validation
- ROC curve and AUC
- Overfitting and underfitting
Module 7: Advanced Techniques and Optimization
- Regularization: Ridge, Lasso and Elastic Net
- Ensemble Learning
- Gradient Boosting
- Deep neural networks (Deep Learning)
- Hyperparameter optimization
Module 8: Model Implementation and Deployment
- Popular frameworks and libraries
- Deploying models to production
- Model maintenance and monitoring
- Ethical and privacy considerations
Module 9: Hands-On Projects
- Project 1: Housing price prediction
- Project 2: Image classification
- Project 3: Sentiment analysis on social media
- Project 4: Fraud detection
- Project 5: Customer segmentation
