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

  1. What feature engineering is and why it matters so much
  2. Business features: RFM analysis on MercaFresh
  3. Ratios and trends: squeezing what you already have
  4. Interactions and polynomials
  5. Feature selection: keeping what earns its place
  6. The final MercaFresh churn dataset
  7. 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=2 you get 230 columns; with degree=3, more than 1,700. interaction_only=True restricts 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:

  1. 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.)

  1. 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_spend and num_orders usually 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.

  1. SelectKBest: the statistical filter

SelectKBest 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 chosen

f_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 model

This 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:

  1. Audit (03-01): info(), describe(), nunique(); understand what each row represents.
  2. 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?).
  3. 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.
  4. Transform (03-03): correct skewness (log1p, Yeo-Johnson); discretize when useful; decompose dates; aggregate per entity to the problem's granularity.
  5. Create features (03-06): RFM, ratios, trends, interactions chosen with business judgment.
  6. Encode (03-04): ordinal with explicit order for real ordinals; one-hot for nominals; extreme care with target encoding.
  7. Scale (03-05): according to the algorithm; RobustScaler if there are legitimate outliers.
  8. Select (03-06): near-zero variance, redundancy by correlation, SelectKBest as a sieve.
  9. Cross-cutting: every step that learns parameters runs fit on training data only; encapsulate in Pipeline/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_orders and avg_order_spend together are partially redundant: review the correlation matrix between features, not just against the target.
  • Selecting features on the whole dataset. SelectKBest looks at the target: if it's fitted with data that will later be test, the evaluation is contaminated. fit on train, always.
  • Not documenting the data dictionary. Three months from now nobody will remember how trend was 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

Module 2: Foundations of Statistics and Probability

Module 3: Data Preprocessing

Module 4: Supervised Machine Learning Algorithms

Module 5: Unsupervised Machine Learning Algorithms

Module 6: Model Evaluation and Validation

Module 7: Advanced Techniques and Optimization

Module 8: Model Implementation and Deployment

Module 9: Hands-On Projects

Module 10: Additional Resources

© Copyright 2026. All rights reserved