The MercaFresh dataset is now clean and gap-free, but "correct" is not the same as "useful". Customer total spend has a very long right tail (remember the histograms from module 2), signup dates are just calendar labels no model knows how to interpret, and the richest information — individual orders — sits at order level when the churn model needs one row per customer. In this lesson you'll learn to reshape variables: mathematical transformations that correct skewness, discretization into bins, decomposition of dates into meaningful components, and aggregations that turn thousands of orders into a per-customer profile. We'll close with scikit-learn pipelines, the tool that organizes all these steps into a reproducible chain.
Contents
- Why transform: when the shape of the data gets in the way
- Mathematical transformations: log, root and powers
- Box-Cox and Yeo-Johnson: the transformation that tunes itself
- Binning: discretizing continuous variables
- Dates: from dead text to useful components
- Aggregations per entity: from orders to customers
- Pipelines: chaining transformations in order
Why transform: when the shape of the data gets in the way
Many models perform better — or outright assume — that variables have reasonably symmetric distributions and roughly linear relationships. But business variables rarely come that way:
- Amounts and spending: most MercaFresh customers spend little and a few spend enormously. That's the positive skew you saw in 02-01 (mean > median) and a distribution close to the exponential from 02-02.
- Counts: number of orders, of visits, of incidents — long right tails.
- Dates: a
datetimesays nothing by itself; what predicts is what the date implies (weekend? how long ago?). - Wrong granularity: we have 80,000 orders, but churn is predicted per customer.
Transforming means changing the representation of the data without changing its meaning, so the patterns are easier for the model to see.
Mathematical transformations: log, root and powers
The logarithm, queen of transformations
The logarithm compresses large values far more than small ones, so it "tucks in" the right tail of a skewed distribution:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
# Simulated annual spend of 1000 customers: mostly modest, tail of big accounts
spend = rng.lognormal(mean=6.5, sigma=0.9, size=1000)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].hist(spend, bins=40)
axes[0].set_title("Annual spend (original): strong skew")
axes[1].hist(np.log1p(spend), bins=40)
axes[1].set_title("log(1 + spend): almost symmetric")
plt.tight_layout()
plt.show()
print(f"Original skewness: {pd.Series(spend).skew():.2f}") # ~3 (heavily skewed)
print(f"Skewness after log: {pd.Series(np.log1p(spend)).skew():.2f}") # ~0 (symmetric)Details that matter:
- We use
np.log1p(x)(which computeslog(1 + x)) instead ofnp.log(x)because the logarithm of 0 is minus infinity, and MercaFresh has customers with a spend of 0.log1phandles the 0 gracefully (log1p(0) = 0). skew()measures asymmetry numerically: ~0 is symmetric, > 1 is strong right skew. It's the numeric translation of what the histogram shows by eye.- After the logarithm, the distribution looks like the normal from 02-02, and tools that assume normality (z-scores included) work well again.
The ladder of transformations
The logarithm isn't the only option; there's a "ladder" ordered by aggressiveness:
| Transformation | Formula | Strength | Constraint | Typical use |
|---|---|---|---|---|
| Square root | np.sqrt(x) |
Mild | x ≥ 0 | Counts (number of orders) |
| Logarithm | np.log1p(x) |
Medium | x > -1 | Amounts, income |
| Inverse | 1 / x |
Strong | x ≠ 0 | Extreme tails (rare) |
Practical rule: try from least to most aggressive and check with skew() and the histogram which one leaves the distribution most symmetric without contorting it.
Box-Cox and Yeo-Johnson: the transformation that tunes itself
Instead of choosing by hand between root and logarithm, Box-Cox automatically finds the optimal exponent (called λ, lambda) that makes the variable as close to normal as possible. λ = 0 is equivalent to the logarithm; λ = 0.5, to the square root; λ = 1, to doing nothing.
from scipy import stats
# Box-Cox requires strictly positive values
spend_bc, best_lambda = stats.boxcox(spend)
print(f"Optimal lambda: {best_lambda:.3f}") # ~0.05: almost a pure logarithmLimitation: Box-Cox only accepts positive values. For variables with zeros or negatives (a points balance that can go negative, a change in spending), there is Yeo-Johnson, its generalization. In scikit-learn both live in PowerTransformer, with the advantage of the fit/transform pattern you already know from the previous lesson (it learns λ on train, applies it to new data):
from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method="yeo-johnson") # or method="box-cox"
spend_transformed = pt.fit_transform(spend.reshape(-1, 1))
print(pt.lambdas_) # the learned λ, stored for reuse(The reshape(-1, 1) turns the vector into a one-column matrix: scikit-learn transformers always expect 2D data, rows × columns.)
A readability warning: after transforming, the values are no longer in euros. A coefficient on log(spend) is interpreted in multiplicative terms, not additive ones. We gain model performance at the cost of less direct interpretation; it's wise to always keep the original variable alongside.
Binning: discretizing continuous variables
Discretizing (or binning) means converting a continuous variable into brackets: exact age into age groups, spend into low/medium/high levels. What for?
- Capturing simple non-linear relationships ("under-30s and over-65s buy differently").
- Robustness against outliers: the €15,400 customer falls into the "high" bracket and stops distorting things.
- Communication with the business: "high-spend customers" is easier to grasp than a coefficient.
The price: information is lost (everyone in the bracket becomes indistinguishable). It's a trade-off, not a free trick.
cut: bins by value
ages = pd.Series([22, 34, 45, 29, 61, 38, 70, 27, 52, 41])
age_groups = pd.cut(ages,
bins=[18, 30, 45, 65, 100],
labels=["young", "adult", "senior", "elderly"])
print(age_groups.value_counts())cut slices where you say: the boundaries carry business meaning (legal adulthood, retirement). Each interval includes its right edge by default: (18, 30], (30, 45], etc.
qcut: bins by quantiles
spend_s = pd.Series(spend)
levels = pd.qcut(spend_s, q=4, labels=["Q1", "Q2", "Q3", "Q4"])
print(levels.value_counts()) # 250 customers in each bracket, guaranteedqcut slices by percentiles (the ones from 02-01): each bracket holds the same number of observations, even if its boundaries in euros come out "ugly". It's the natural choice for skewed variables: with equal-width cut, 90% of customers would land in the first bracket.
KBinsDiscretizer: binning inside scikit-learn
from sklearn.preprocessing import KBinsDiscretizer
kb = KBinsDiscretizer(n_bins=4, encode="ordinal", strategy="quantile")
spend_binned = kb.fit_transform(spend_s.to_frame())
print(kb.bin_edges_[0].round(0)) # the boundaries learned in fitstrategy accepts "uniform" (equal widths, like automatic cut), "quantile" (like qcut) and "kmeans". Its added value is the usual one: the boundaries are learned in fit and reapplied identically to new data, preventing the brackets from drifting every month.
| Tool | Cut points | When to use it |
|---|---|---|
pd.cut |
Where you decide | Boundaries with business meaning |
pd.qcut |
By quantiles | Skewed variables, balanced brackets |
KBinsDiscretizer |
Learned in fit |
Inside a reproducible ML workflow |
Dates: from dead text to useful components
In 03-01 we converted dates from text to datetime. Now we squeeze them: a model can't use "2024-06-05", but it can use the signals that date hides.
orders = pd.DataFrame({
"order_id": range(1, 8),
"customer_id": [101, 101, 105, 107, 105, 110, 101],
"date": pd.to_datetime(["2026-05-02", "2026-05-16", "2026-05-17",
"2026-06-01", "2026-06-14", "2026-07-25",
"2026-08-10"]),
"order_amount": [45.2, 38.9, 120.5, 15400.0, 95.3, 22.1, 51.7],
})
# The .dt accessor exposes the date components
orders["year"] = orders["date"].dt.year
orders["month"] = orders["date"].dt.month
orders["weekday"] = orders["date"].dt.dayofweek # 0 = Monday ... 6 = Sunday
orders["is_weekend"] = (orders["weekday"] >= 5).astype(int)
# Holidays: with a business list (or the 'holidays' library for the official calendar)
holidays = pd.to_datetime(["2026-06-01", "2026-08-15"])
orders["is_holiday"] = orders["date"].isin(holidays).astype(int)
# Recency: days elapsed from the date to a reference date
today = pd.Timestamp("2026-08-24")
orders["days_since_order"] = (today - orders["date"]).dt.daysWhy does this work? Because to predict demand or churn at MercaFresh, what matters is not June 5 itself, but that it was a Monday, that it was a holiday, or that it happened 80 days ago. Every extracted component turns calendar knowledge into a column the model can use.
Two nuances:
dayofweekandmonthare numeric but cyclical: Sunday (6) is "next to" Monday (0), and December next to January. Many models handle them fine as categories (lesson 03-04); trigonometric sine/cosine encodings exist for order-sensitive models, and for now it's enough to know that nuance is there.- The recency "reference date" must be a fixed date of the analysis (the dataset's cutoff date), not
Timestamp.now(): otherwise the same code gives different results every day.
Aggregations per entity: from orders to customers
The churn model needs one row per customer, but the living information sits in the orders table. The solution is to aggregate: summarize each customer's history into statistics.
profile = orders.groupby("customer_id").agg(
num_orders=("order_id", "count"),
avg_order_spend=("order_amount", "mean"),
total_spend=("order_amount", "sum"),
max_spend=("order_amount", "max"),
first_order=("date", "min"),
last_order=("date", "max"),
)
# Frequency and recency derived from the aggregated dates
profile["days_as_customer"] = (today - profile["first_order"]).dt.days
profile["days_since_last_purchase"] = (today - profile["last_order"]).dt.days
profile["orders_per_month"] = profile["num_orders"] / (profile["days_as_customer"] / 30)
print(profile.round(2))The syntax agg(new_name=("column", "function")) (named aggregation) produces columns with clean names in a single step. Look at what we've achieved: customer 101, with three orders spread between May and August, is now described by their frequency, their average ticket and — pure gold for churn — how many days they've gone without buying.
flowchart LR
A["Orders table<br/>(1 row = 1 order)"] -->|"groupby('customer_id').agg(...)"| B["Customer profile<br/>(1 row = 1 customer)"]
B --> C["Joined to the customer table<br/>(merge on customer_id)"]
The last step is joining this profile to the clean customer table from the previous lessons:
# customers: the clean table from 03-01/03-02
# profile: the aggregations we just built
dataset = customers.merge(profile, on="customer_id", how="left")With how="left" we also keep the customers with no orders (their aggregates will come out as NaN... which you already know how to handle from the previous lesson: here a 0 in num_orders and a no_orders indicator would be imputations with full business meaning). These aggregations are the prelude to the RFM analysis we'll complete in 03-06.
Pipelines: chaining transformations in order
We've already chained several steps: impute, transform with logarithms, discretize... each with its fit on train and its transform on the rest. Doing it by hand, in order, without forgetting any step and without leaks, is fragile. scikit-learn offers two pieces to assemble it:
Pipeline: steps in series
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import PowerTransformer
num_pipe = Pipeline(steps=[
("impute", SimpleImputer(strategy="median")),
("deskew", PowerTransformer(method="yeo-johnson")),
])
result = num_pipe.fit_transform(profile[["avg_order_spend", "total_spend"]])A Pipeline is a list of named steps executed in order: each step's output feeds the next. Calling fit_transform makes each step fit and transform in a chain; calling transform later on new data makes them all apply what they learned without relearning anything. A single object encapsulates the entire preprocessing.
ColumnTransformer: parallel steps by column
Not every column needs the same treatment: numeric ones want imputation and logarithms; categorical ones, other techniques (those of the next lesson). ColumnTransformer applies its own treatment to each group of columns and stitches the results together:
from sklearn.compose import ColumnTransformer
preprocessor = ColumnTransformer(transformers=[
("numeric", num_pipe, ["avg_order_spend", "total_spend", "num_orders"]),
("derived_dates", "passthrough", ["days_since_last_purchase"]),
# ("categorical", ..., ["city", "plan"]) <- we'll fill this in in 03-04
])
X = preprocessor.fit_transform(example_dataset)Each tuple is (name, transformer, list_of_columns); "passthrough" lets columns through untouched. For now, keep the introductory idea: the complete preprocessing can live in a single object that is fitted with fit on training data and applied with transform to any future data. In the coming lessons we'll fill in the missing branches, and in module 6 the pipeline will prove its definitive value when combined with validation.
Common Mistakes and Tips
- Applying
np.logto values with zeros. It produces silent-infvalues that blow up the model later. Usenp.log1p, and check beforehand that there are no negatives. - Transforming and forgetting the back-transformation. If the model predicts
log(spend), the prediction in euros isnp.expm1(prediction). Document which variables are transformed. - Choosing
cutbrackets that leave empty groups. Always checkvalue_counts()after discretizing; a bracket with 3 observations contributes nothing. With skewed data,qcutis usually the better starting point. - Using
Timestamp.now()as the recency reference. The dataset must have a fixed, documented cutoff date, or the results change every day you run the code. - Aggregating with
meanon columns with outliers. Customer 107's average spend is dominated by their €15,400 order; consider also adding the per-customer median. - Fitting transformers on the whole dataset.
PowerTransformerandKBinsDiscretizerlearn parameters: same leakage risk as imputation (03-02). Inside aPipeline, this mistake becomes almost impossible to make — one more reason to use them. - Tip: before and after every transformation, plot the histogram and look at
skew(). Two lines of code that keep you from transforming blindly.
Exercises
Exercise 1
MercaFresh's delivery_incidents variable (number of delivery incidents per customer) takes the values [0, 0, 1, 0, 2, 0, 1, 9, 0, 3]. Compute its skewness with skew(), apply the ladder transformation you consider appropriate (justify which one, and why the plain logarithm would cause trouble) and recompute the skewness.
Exercise 2
Split the order amounts [12, 18, 22, 25, 31, 38, 45, 60, 85, 240] into 4 levels with qcut and into 4 equal-width brackets with cut. Compare the value_counts() of both and explain which is more useful here and why.
Exercise 3
Starting from the lesson's orders DataFrame, build the per-customer profile with a single aggregation: number of orders, median order amount and days since the last order (reference: 2026-08-24). Name the columns n_orders, median_amount and recency_days.
Solutions
Exercise 1
s = pd.Series([0, 0, 1, 0, 2, 0, 1, 9, 0, 3])
print(s.skew()) # ~2.6: strong right skew
# It's a count with zeros: np.log(0) = -inf, so the plain log fails.
# Valid options: np.sqrt (mild, accepts 0) or np.log1p (medium, accepts 0).
t = np.log1p(s)
print(t.skew()) # ~1.1: much more containedWith small counts and abundant zeros, sqrt or log1p are the natural choices; the pure logarithm is ruled out by the zeros.
Exercise 2
amounts = pd.Series([12, 18, 22, 25, 31, 38, 45, 60, 85, 240])
by_quantiles = pd.qcut(amounts, q=4)
by_width = pd.cut(amounts, bins=4)
print(by_quantiles.value_counts()) # 3-2-2-3: balanced brackets
print(by_width.value_counts()) # 9-0-0-1: almost everything in the first bracket!With cut, the 240 outlier stretches the range and leaves two brackets empty: useless. qcut distributes the observations by percentiles and produces balanced levels: it's the right choice for this skewed distribution.
Exercise 3
today = pd.Timestamp("2026-08-24")
profile = orders.groupby("customer_id").agg(
n_orders=("order_id", "count"),
median_amount=("order_amount", "median"),
last=("date", "max"),
)
profile["recency_days"] = (today - profile["last"]).dt.days
profile = profile.drop(columns=["last"])
print(profile)The median amount shields customer 107's profile from their giant order, and recency_days — the days without buying — will be one of the star variables of the churn model.
Conclusion
You've learned to reshape data so it reveals its patterns: correct skewness with logarithms, roots and the automatic Box-Cox/Yeo-Johnson transformations; discretize with cut, qcut and KBinsDiscretizer knowing what is gained and what is lost; turn dates into components with predictive power (weekday, holiday, recency); summarize the orders table into per-customer profiles with groupby().agg(); and chain it all in Pipeline and ColumnTransformer so the preprocessing is reproducible and leak-proof.
You'll have noticed that the ColumnTransformer left one branch empty: the categorical columns. The customer's city, the product category and the MercaFresh subscription plan are still text, and no model knows how to multiply by "Barcelona". In the next lesson we solve exactly that: encoding categorical variables.
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
