Feature Engineering
1 · The lesson
readAn important principle in ML: better features usually help more than a better model. A linear regression on the right columns can predict better than a deep network on the wrong ones. ML algorithms can only learn from what is in the columns you give them. They can't find patterns that the data doesn't contain.
Feature engineering is the skill of turning a cleaned DataFrame into columns that make patterns easy for a model to learn. This lesson covers the main kinds: numerical, categorical, date, text, interactions, aggregations and lags. It also covers leakage, a very common bug that makes results look good when they are not.
1. Scaling Numerical Features
Most models care about scale: anything that uses distances or gradients (linear models, SVM, kNN, neural nets). A feature in [0, 1] and a feature in [0, 1_000_000] are not treated equally.
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler # Standardisation (z-score): (x - mean) / std, giving mean 0 and std 1 StandardScaler().fit_transform(X) # Min-max: (x - min) / (max - min), giving values in [0, 1] MinMaxScaler().fit_transform(X) # Robust: (x - median) / IQR, which outliers barely affect RobustScaler().fit_transform(X)
setup added so this can run · defines X
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) X = _AutoMock('X')
When to use which:
| Scaler | Use when |
|---|---|
| Standardisation | Most models, roughly symmetric data, no extreme outliers. The default. |
| Min-max | You need a bounded range (e.g. neural net sigmoid output target, image pixel inputs). |
| Robust | Heavy-tailed data, where a few outliers would dominate mean/std. |
Tree-based models (random forest, gradient boosting) are scale-invariant: they only use the order of the values. You don't need to scale for XGBoost/LightGBM. You do need to for linear models, kNN, SVMs and neural nets.
2. Transforming Skewed Distributions
Right-skewed data (incomes, page views, counts) has most observations in a small range and a long tail of large values. A log transform spreads them out:
import numpy as np df["log_spend"] = np.log1p(df["spend"]) # log(1 + x), safe for 0 df["sqrt_count"] = np.sqrt(df["count"])
setup added so this can run · defines df
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df')
Use log1p instead of log, because real-world counts include zero, and log(0) = -inf. log1p(0) = 0. The Box-Cox / Yeo-Johnson transforms (sklearn.preprocessing.PowerTransformer) are a general version of this: they find the best exponent automatically.
A clear sign that a column needs a log transform: a describe() where max is 10000× the median. Plot a histogram, apply the log, and plot it again. You will usually see something close to a bell shape.
3. Binning: When the Effect Is Not Monotonic
Some "numeric" features aren't really continuous in their effect:
df["age_bin"] = pd.cut(df["age"], bins=[0, 12, 17, 24, 34, 54, 120], labels=["child", "teen", "young_adult", "adult", "middle_age", "senior"]) # Quantile binning: each bucket gets about the same number of rows df["spend_decile"] = pd.qcut(df["spend"], q=10, labels=False)
setup added so this can run · defines df, pd
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df') pd = _AutoMock('pd')
Why bin? Because the relationship between age and clicked_ad isn't monotonic: a 16-year-old and a 60-year-old might both click more than a 35-year-old. A linear model can't fit that with raw age but can with one-hot age bins.
Use pd.cut for fixed thresholds (with domain meaning), pd.qcut for equal-frequency bins (useful for "top 10% spenders").
4. Encoding Categorical Features
The four standard strategies:
One-hot encoding
pd.get_dummies(df["plan"], prefix="plan", drop_first=True) # plan_free plan_pro # 0 False True # 1 False False
setup added so this can run · defines pd, df
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pd = _AutoMock('pd') df = _AutoMock('df')
drop_first=True removes one column to avoid collinearity (useful for linear models). Each category becomes its own 0/1 column. This works well for low-cardinality columns (fewer than about 50 unique values).
Label encoding
from sklearn.preprocessing import LabelEncoder df["plan_id"] = LabelEncoder().fit_transform(df["plan"]) # enterprise=0, free=1, pro=2 (alphabetical, not your business order)
setup added so this can run · defines df
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df')
Warning: this adds an order, and it is alphabetical (enterprise < free < pro), not even your business order. Tree models can cope with it, but linear models treat it as a number and learn wrong patterns. Only use label encoding when the categories are ordinal (Likert scales, education level).
Target encoding
Replace each category with the mean target value for that category:
mean_target = df.groupby("plan")["churned"].mean() df["plan_te"] = df["plan"].map(mean_target)
setup added so this can run · defines df
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df')
This works well for high-cardinality features (zip codes, product IDs), but it is very easy to cause leakage. If you compute mean_target on the full dataset before splitting, the validation and test labels end up inside the training feature. The fix: target-encode inside a cross-validation loop, or use sklearn.preprocessing.TargetEncoder (≥ 1.3) which handles this for you.
Hashing trick
from sklearn.feature_extraction import FeatureHasher hasher = FeatureHasher(n_features=16, input_type="string") hasher.transform([["zip_94107"], ["zip_10001"]])
For features with a very large number of values (for example millions of product SKUs), where one-hot encoding would use far too much memory. Categories are hashed into a fixed number of columns. Sometimes two categories land in the same column (a collision), and you accept that as the cost. Used in ad-tech and search ranking.
5. Date Features
A raw timestamp tells a model very little. Extract its parts:
df["year"] = df["joined"].dt.year df["month"] = df["joined"].dt.month df["dow"] = df["joined"].dt.dayofweek # 0=Mon, 6=Sun df["is_weekend"] = df["dow"].isin([5, 6]).astype(int) df["is_eom"] = df["joined"].dt.is_month_end.astype(int) df["quarter"] = df["joined"].dt.quarter
setup added so this can run · defines df
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df')
Cyclical encoding
Day-of-week is circular: Sunday (6) comes right before Monday (0), but the raw integers suggest they are 6 apart. Sin/cos encoding fixes this:
df["dow_sin"] = np.sin(2 * np.pi * df["dow"] / 7) df["dow_cos"] = np.cos(2 * np.pi * df["dow"] / 7) # Do the same for hour of day (period 24) and month of year (period 12) df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24) df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)
setup added so this can run · defines df, np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df') np = _AutoMock('np')
The pair (sin, cos) together gives each position on the circle a unique value and keeps distances correct: (hour=23, hour=0) are close in (sin, cos) space, as they should be. Linear models and neural nets benefit from this. Tree models can usually learn the cycle from the raw integer.
See datetime for the timestamp arithmetic side of this.
6. Text Features: A Short Overview
Text is a large topic of its own. Here is a short version:
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer # Bag-of-words: counts per word CountVectorizer(max_features=1000).fit_transform(corpus) # TF-IDF: counts, made smaller for words found in many documents TfidfVectorizer(max_features=1000, ngram_range=(1, 2)).fit_transform(corpus)
setup added so this can run · defines corpus
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) corpus = _AutoMock('corpus')
TF-IDF (term-frequency × inverse-document-frequency) gives low weight to words that appear everywhere ("the", "of") and high weight to words that are typical of specific documents. It is the standard method for classical text classification, and it is still a good baseline to try before you use embeddings.
For modern semantic embeddings (sentence-transformers, OpenAI text-embedding-3) and the rest of the NLP toolbox, see nlp.
7. Interaction Features
Sometimes the combination of two features is what matters:
df["price_per_sqft"] = df["price"] / df["sqft"] # ratio df["spend_x_visits"] = df["spend"] * df["visits"] # product df["recency_vs_tenure"] = df["recency_days"] - df["tenure_days"] # difference
setup added so this can run · defines df
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) df = _AutoMock('df')
A linear model can't learn price / sqft from price and sqft alone, because it can only add weighted features together. If you create the ratio yourself, the model gets that information directly.
Tree models find interactions through their splits, so they need this less. But a well-named interaction feature still makes the model easier to interpret and can help it train faster.
8. Aggregations as Features
A useful technique that people often forget: turn past rows into features on the current row.
# Per-user average spend (cross-row aggregation) df["user_avg_spend"] = df.groupby("user_id")["spend"].transform("mean") # Rolling 30-day spend (per user, time-aware) df = df.sort_values(["user_id", "date"]) df["spend_30d"] = ( df.groupby("user_id") .rolling("30D", on="date")["spend"].sum() .to_numpy() # same row order as the sorted df )
groupby().transform() does the work here. It copies the group-level statistic back onto every row of that group, so the DataFrame keeps its shape. Useful for "this customer's average...", "this product's typical...", "this region's median...".
9. Lag Features (Time Series)
For sequential data, yesterday's value is often the best predictor of today's:
df = df.sort_values(["product_id", "date"]) df["sales_lag_1"] = df.groupby("product_id")["sales"].shift(1) df["sales_lag_7"] = df.groupby("product_id")["sales"].shift(7) df["sales_rolling_7"] = ( df.groupby("product_id")["sales"] .transform(lambda s: s.shift(1).rolling(7).mean()) # important: shift *before* rolling, for each product )
The shift-before-rolling pattern is essential. rolling(7).mean() on raw sales includes today, which is the target. That is leakage. Shift first, then apply the rolling window, and the window contains only past data.
10. Feature Selection
Once you have 200 features, you usually don't need them all:
from sklearn.feature_selection import ( VarianceThreshold, SelectKBest, mutual_info_classif, RFE ) # 1. Variance threshold: drop columns that are almost constant VarianceThreshold(threshold=0.01).fit_transform(X) # 2. Correlation: drop one column of any pair with |r| > 0.95 corr = X.corr().abs() upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)) to_drop = [c for c in upper.columns if any(upper[c] > 0.95)] # 3. Mutual information: also finds non-linear links with the target SelectKBest(mutual_info_classif, k=20).fit_transform(X, y) # 4. Recursive feature elimination: wraps a model from sklearn.linear_model import LogisticRegression RFE(LogisticRegression(), n_features_to_select=10).fit_transform(X, y)
setup added so this can run · defines X, y, np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) X = _AutoMock('X') y = _AutoMock('y') np = _AutoMock('np')
Order of preference: first drop the features that are clearly useless (no variance, near-perfect correlation with another feature), then use model-based methods on what remains. Throwing every feature at a single SelectKBest is wasteful when half of them have zero variance.
11. The Leakage Trap
This section matters more than any other in the lesson. Leakage is when a feature contains information that wouldn't be available at prediction time, usually because it comes from the target itself, or from future data.
Common examples:
- "Next month's invoice ID" as a feature predicting "did they churn". The invoice ID exists only if they renewed. The model scores an AUC of 0.99 in testing, then does no better than guessing in production.
- Target encoding before train/test split. The validation set's labels leak into the training set's features through the group mean.
- Imputing missing values with the full-data mean before splitting. Test-set values influence the train-set fill.
- Time-series features computed without
.shift(): the rolling average includes today's target. - Standard-scaling on the full dataset before splitting. The test set's mean and standard deviation leak into the training normalisation.
The question to ask: at prediction time, what would I really know? If a feature requires data the production system wouldn't have yet, it's leakage.
The fix: fit transformers on the training data only, then apply them to the test data:
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) # fit AND transform on train X_test_scaled = scaler.transform(X_test) # ONLY transform on test
setup added so this can run · defines X, y, StandardScaler
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) X = _AutoMock('X') y = _AutoMock('y') def StandardScaler(*_a, **_kw): print('-> StandardScaler() called') return _AutoMock('StandardScaler()')
fit_transform on train, transform on test. Mixing these up is a very common form of leakage in beginner code.
12. Pipeline Reproducibility
The best protection against leakage, and the easiest way to make results reproducible, is to put every step in a Pipeline.
from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer from sklearn.ensemble import RandomForestClassifier numeric_cols = ["age", "spend", "tenure_days"] categorical_cols = ["plan", "country"] preprocessor = ColumnTransformer([ ("num", Pipeline([ ("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler()), ]), numeric_cols), ("cat", Pipeline([ ("impute", SimpleImputer(strategy="most_frequent")), ("ohe", OneHotEncoder(handle_unknown="ignore")), ]), categorical_cols), ]) pipeline = Pipeline([ ("prep", preprocessor), ("clf", RandomForestClassifier(n_estimators=200, random_state=0)), ]) pipeline.fit(X_train, y_train) pipeline.score(X_test, y_test)
setup added so this can run · defines X_train, y_train, X_test, y_test
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) X_train = _AutoMock('X_train') y_train = _AutoMock('y_train') X_test = _AutoMock('X_test') y_test = _AutoMock('y_test')
What this gives you:
- No leakage by design: every transformer is fit on
X_trainonly when you callpipeline.fit. - One object to pickle, version, and load in production. You don't need to remember to re-run "step 7 of the notebook".
- Handles new categories at inference:
OneHotEncoder(handle_unknown="ignore")returns all-zero rows for unseen categories instead of raising an error. - Cross-validation works correctly:
cross_val_score(pipeline, X, y, cv=5)re-fits the entire pipeline on each fold's training set. No leakage, and nothing to track by hand.
If you write fit_transform / transform calls by hand across many cells, leakage will happen sooner or later. A pipeline prevents it.
13. Common Mistakes
1. Scaling test data with test statisticsStandardScaler().fit_transform(X_test) re-fits on the test set. Always fit on train, then transform test.
2. Creating features after the split, on the full data
Computing df["user_avg_spend"] on the full df before splitting puts the test rows' spend into the training features. Compute per-user stats from X_train only.
3. Encoding "Unknown" as if it were a real category
A row with country = NaN shouldn't get its own one-hot column that the model learns to depend on. Impute first (for example with SimpleImputer(strategy="most_frequent")). handle_unknown="ignore" doesn't help here: it is for categories that were not seen during training, and OneHotEncoder treats NaN as one more category.
4. Not handling new categories at inference time
Default OneHotEncoder raises on unseen categories. Pass handle_unknown="ignore". Test this in development, because production will see categories you didn't train on.
5. Forgetting cyclical encoding for periodic features
Raw hour_of_day (0-23) puts 23 and 0 at opposite ends of a number line. In reality, they are next to each other. Use sin/cos, or a model that handles this by itself.
6. Target leakage through derived columns
Some are hard to see: total_amount_invoiced predicting paid_in_full, when the invoice is only generated for paying customers. Walk through every feature: could I compute this at prediction time?
🎯 Your Turn: Engineer User Features
You have a clean user table and need to build five features for a churn model.
Input columns: user_id, signup_date, last_login_date, total_spend, plan (Free/Pro/Enterprise).
Required features:
tenure_days: days fromsignup_dateto a fixedreference_date.recency_days: days fromlast_login_datetoreference_date.is_active:1ifrecency_days <= 30, else0.log_spend:log1p(total_spend).plan_free,plan_pro,plan_enterprise: one-hot ofplan(nodrop_first, so the model sees them all).
Build make_features(df, reference_date) that returns a new DataFrame with the original columns dropped and only the engineered columns + user_id.
import pandas as pd users = pd.DataFrame({ "user_id": [1, 2, 3, 4], "signup_date": pd.to_datetime( ["2025-01-15", "2024-06-01", "2026-02-20", "2023-11-30"] ), "last_login_date": pd.to_datetime( ["2026-05-01", "2026-03-10", "2026-05-13", "2025-12-25"] ), "total_spend": [120.0, 0.0, 450.0, 12.5], "plan": ["Pro", "Free", "Enterprise", "Pro"], }) # make_features(users, pd.Timestamp("2026-05-14")) returns a DataFrame with 8 columns
Skeleton:
import numpy as np import pandas as pd def make_features(df: pd.DataFrame, reference_date: pd.Timestamp) -> pd.DataFrame: df = df.copy() # TODO 1: tenure_days = (reference_date - signup_date).dt.days # TODO 2: recency_days = (reference_date - last_login_date).dt.days # TODO 3: is_active = (recency_days <= 30).astype(int) # TODO 4: log_spend = np.log1p(total_spend) # TODO 5: one-hot plan with prefix="plan", then concat onto df # TODO 6: return only user_id and the new feature columns ...
Hint 1: Timedelta subtraction
pd.Timestamp - pd.Series[datetime] returns a Series of Timedelta. Use .dt.days to pull the integer day count. Be careful with the order: reference - signup gives positive days, and the other way round gives negative days.
Hint 2: One-hot with predictable columns
pd.get_dummies(df["plan"], prefix="plan") creates columns like plan_Free, plan_Pro, plan_Enterprise. To match the lowercase spec, lowercase the plan first: df["plan"].str.lower(). To avoid a missing column at inference (a category not seen in training), pass pd.Categorical(df["plan"], categories=["free", "pro", "enterprise"]) before get_dummies. That fixes the set of columns.
Show full solution
import numpy as np import pandas as pd PLAN_CATEGORIES = ["free", "pro", "enterprise"] def make_features(df: pd.DataFrame, reference_date: pd.Timestamp) -> pd.DataFrame: """Build 5 features for a churn model. A pure function: it has no side effects.""" df = df.copy() # 1. Date-derived numeric features df["tenure_days"] = (reference_date - df["signup_date"]).dt.days df["recency_days"] = (reference_date - df["last_login_date"]).dt.days # 2. Binary activity flag (engineered from recency) df["is_active"] = (df["recency_days"] <= 30).astype(int) # 3. Log transform for skewed monetary feature df["log_spend"] = np.log1p(df["total_spend"]) # 4. One-hot encode the plan with a *fixed* list of categories, so that new # data with missing or unseen plans still gets the same columns. plan_cat = pd.Categorical(df["plan"].str.lower(), categories=PLAN_CATEGORIES) plan_oh = pd.get_dummies(plan_cat, prefix="plan").astype(int) df = pd.concat([df.reset_index(drop=True), plan_oh.reset_index(drop=True)], axis=1) # 5. Keep only the feature columns cols = ["user_id", "tenure_days", "recency_days", "is_active", "log_spend", "plan_free", "plan_pro", "plan_enterprise"] return df[cols] users = pd.DataFrame({ "user_id": [1, 2, 3, 4], "signup_date": pd.to_datetime( ["2025-01-15", "2024-06-01", "2026-02-20", "2023-11-30"] ), "last_login_date": pd.to_datetime( ["2026-05-01", "2026-03-10", "2026-05-13", "2025-12-25"] ), "total_spend": [120.0, 0.0, 450.0, 12.5], "plan": ["Pro", "Free", "Enterprise", "Pro"], }) feats = make_features(users, pd.Timestamp("2026-05-14")) print(feats) # user_id tenure_days recency_days is_active log_spend plan_free plan_pro plan_enterprise # 0 1 484 13 1 4.795791 0 1 0 # 1 2 712 65 0 0.000000 1 0 0 # 2 3 83 1 1 6.111467 0 0 1 # 3 4 896 140 0 2.602690 0 1 0
What the solution gets right:
- Pure function: it doesn't change the input, so you can re-run it and test it.
- Date arithmetic with
Timedelta.dt.days, which is cleaner than working out days by hand. is_activederived fromrecency_days: one engineered feature built from another, in one pass. The model gets both and can decide which is more useful.log1pinstead oflog: handles the zero-spend Free user without producing-inf.PLAN_CATEGORIESfixed at module level: a new plan at inference doesn't crash the code, and a plan that doesn't appear in the data still gets its column. The same set of plan columns is produced every call.reference_dateas a parameter, nottoday(). Runs are reproducible, you can test with any date, and the result doesn't secretly depend on the clock.
In a real project, this make_features lives inside a sklearn.preprocessing.FunctionTransformer and goes into a Pipeline (Section 12). That gives you the same leakage protection automatically: features are computed from train data only when the pipeline is fit.
What You Learned
- Scaling: standardisation by default, min-max for bounded ranges, robust for outlier-heavy data. Trees don't need it.
- Skew fixes:
np.log1p,np.sqrt,PowerTransformerfor right-skewed data. - Binning (
pd.cut,pd.qcut) when the underlying relationship isn't monotonic. - Categorical encoding: one-hot for low cardinality, target encoding for high (watch for leakage), hashing for very high.
- Date features: extract year/month/dow/is_weekend, and use sin/cos for cyclical features.
- Interactions (ratios, products, differences) built by hand for linear models. Trees find them by themselves.
- Aggregations (
groupby().transform()) and lag features (shift()before rolling) for grouped and time-series data. - Feature selection: variance threshold, correlation cutoff, mutual info, RFE, in that order.
- The leakage trap: every feature must be computable at prediction time, without using test data or the target.
Pipeline+ColumnTransformeris the best protection against leakage. It is one object: fit on train, applied to test, and saved with pickle for production.
Next: Statistics for Data Science: enough statistics to interpret your features, run an A/B test and read a p-value correctly.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.