Feature engineering and selection
The scene: "add a new feature", but how?
In the Chapter 11 decision table, the first row said:
The mistakes cluster in one group -> add a new feature
Fine. But which feature? Which one will help?
And in Chapter 1 I made one attempt: I added area_per_room and the result moved by +0.0056, which is to say almost nothing.
This chapter answers that question, and half the answer is disappointing: feature engineering often doesn't work. But when it does, it gives the biggest gain of all.
Column names
The rental data has Uzbek column names. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built, narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.
Five methods
import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, cross_val_score from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression df = pd.read_csv(DATA / "uy_ijara.csv") num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"] cat = ["tuman"] X, y = df[num + cat], df["ijaraga_berildi_30k"] def pipe(num_list, cat_list=cat, model=None): return Pipeline([ ("t", ColumnTransformer([ ("num", Pipeline([("i", SimpleImputer(strategy="median")), ("sc", StandardScaler())]), num_list), ("cat", OneHotEncoder(handle_unknown="ignore"), cat_list), ])), ("m", model or LogisticRegression(max_iter=3000)), ]) base_score = cross_val_score(pipe(num), X, y, cv=5, scoring="f1") print(f"STARTING POINT (7 features): F1 = {base_score.mean():.4f} ±{base_score.std():.4f}") print() show(pd.DataFrame([ {"Method": "1. Ratios and differences", "Example": "price/area, area/rooms"}, {"Method": "2. Splitting dates", "Example": "year, month, weekday, is it a holiday"}, {"Method": "3. Grouping (binning)", "Example": "age -> 'young/middle/old'"}, {"Method": "4. Interactions", "Example": "furnished AND close to the metro"}, {"Method": "5. Group statistics", "Example": "the district's average price"}, ]))
STARTING POINT (7 features): F1 = 0.7273 ±0.0118
| Method | Example |
|---|---|
| 1. Ratios and differences | price/area, area/rooms |
| 2. Splitting dates | year, month, weekday, is it a holiday |
| 3. Grouping (binning) | age -> 'young/middle/old' |
| 4. Interactions | furnished AND close to the metro |
| 5. Group statistics | the district's average price |
Method 1: ratios and differences
The most commonly used method. It needs domain knowledge.
def ratios(d): d = d.copy() d["area_per_room"] = d.maydon_m2 / d.xonalar d["floor_share"] = d.qavat / d.qavatlar_soni d["age"] = 2026 - d.qurilgan_yil return d new1 = num + ["area_per_room", "floor_share", "age"] b1 = cross_val_score(pipe(new1), ratios(X), y, cv=5, scoring="f1") print(f"Starting point: {base_score.mean():.4f} ±{base_score.std():.4f}") print(f"+ 3 ratios : {b1.mean():.4f} ±{b1.std():.4f}") print(f"Change : {b1.mean()-base_score.mean():+.4f} (spread ±{max(b1.std(), base_score.std()):.4f})")
Starting point: 0.7273 ±0.0118 + 3 ratios : 0.7278 ±0.0115 Change : +0.0004 (spread ±0.0118)
A ratio computed from existing columns adds little
area_per_room = maydon_m2 / xonalar: the model has already seen both. The ratio is a new combination of them, but not new information.
A linear model can't compute a ratio itself (it only knows addition and multiplication), so sometimes it helps. A tree finds the ratio roughly on its own, which is why such features barely help trees.
Real gains come from a new source: how long the listing stayed on the site, the number of photos, how fast the owner replies.
Method 2: features from a date
Our file has no dates, so we take the sales data from Chapter 44.
sv = pd.read_csv(DATA / "iflos_savdo.csv") date = pd.to_datetime(sv.order_date, format="mixed", dayfirst=True, errors="coerce") tmp = pd.DataFrame({"date": date}).dropna() tmp["year"] = tmp.date.dt.year tmp["month"] = tmp.date.dt.month tmp["weekday"] = tmp.date.dt.dayofweek tmp["quarter"] = tmp.date.dt.quarter tmp["day_of_month"] = tmp.date.dt.day tmp["weekend"] = (tmp.date.dt.dayofweek >= 5).astype(int) tmp["month_start"] = (tmp.date.dt.day <= 5).astype(int) show(tmp.head(6)) print() print("One `date` column produced 7 features.")
One `date` column produced 7 features.
| date | year | month | weekday | quarter | day_of_month | weekend | month_start |
|---|---|---|---|---|---|---|---|
| 2025-05-26 00:00:00 | 2 025 | 5 | 0 | 2 | 26 | 0 | 0 |
| 2025-02-26 00:00:00 | 2 025 | 2 | 2 | 1 | 26 | 0 | 0 |
| 2025-03-01 00:00:00 | 2 025 | 3 | 5 | 1 | 1 | 1 | 1 |
| 2025-01-03 00:00:00 | 2 025 | 1 | 4 | 1 | 3 | 0 | 1 |
| 2025-02-11 00:00:00 | 2 025 | 2 | 1 | 1 | 11 | 0 | 0 |
| 2025-01-08 00:00:00 | 2 025 | 1 | 2 | 1 | 8 | 0 | 0 |
Don't pass a date as a NUMBER
date.astype(int) gives a timestamp (seconds since 1970). The model sees it as just a growing number.
As a result the model learns a linear relationship like "the result grows as time passes", and falls over when it extrapolates into the future.
Split the date: month, weekday, holiday. That captures the periodic patterns.
print("Cancellation rate by weekday: is there a periodic pattern:") sv2 = sv.assign(date=date, cancelled=(sv.status == "cancelled").astype(int)).dropna(subset=["date"]) sv2["weekday"] = sv2.date.dt.dayofweek days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] g = sv2.groupby("weekday").cancelled.agg(["size", "mean"]) g.index = [days[i] for i in g.index] g["mean"] = g["mean"].round(4) show(g.reset_index().rename(columns={"index": "day", "size": "orders", "mean": "cancel_rate"}))
Cancellation rate by weekday: is there a periodic pattern:
| day | orders | cancel_rate |
|---|---|---|
| Mon | 885 | 0.042 |
| Tue | 793 | 0.034 |
| Wed | 842 | 0.038 |
| Thu | 808 | 0.027 |
| Fri | 961 | 0.032 |
| Sat | 1 057 | 0.043 |
| Sun | 834 | 0.024 |
Method 3: grouping (binning)
def bins(d): d = d.copy() d["area_bin"] = pd.cut(d.maydon_m2, bins=[0, 40, 60, 90, 1000], labels=[0, 1, 2, 3]).astype(float) d["metro_bin"] = pd.cut(d.metro_km, bins=[0, 1, 2.5, 5, 100], labels=[0, 1, 2, 3]).astype(float) return d new3 = num + ["area_bin", "metro_bin"] b3 = cross_val_score(pipe(new3), bins(X), y, cv=5, scoring="f1") print(f"Starting point: {base_score.mean():.4f}") print(f"+ 2 bins : {b3.mean():.4f} ({b3.mean()-base_score.mean():+.4f})")
Starting point: 0.7273 + 2 bins : 0.7273 (-0.0000)
Binning gives a linear model a non-linear boundary: "smaller than 40 m2" becomes its own category.
But it has a cost: information is lost. 39 m2 and 41 m2 land in different groups, while 41 and 59 share one.
Method 4: interactions
def interact(d): d = d.copy() d["furnished_x_metro"] = d.mebel * (d.metro_km < 2).astype(int) d["big_and_central"] = ((d.maydon_m2 > 70) & (d.metro_km < 2)).astype(int) return d new4 = num + ["furnished_x_metro", "big_and_central"] b4 = cross_val_score(pipe(new4), interact(X), y, cv=5, scoring="f1") print(f"Starting point : {base_score.mean():.4f}") print(f"+ 2 interactions : {b4.mean():.4f} ({b4.mean()-base_score.mean():+.4f})") print() print("A tree finds combinations like this ITSELF (Chapter 18); a linear model does NOT.")
Starting point : 0.7273 + 2 interactions : 0.7265 (-0.0008) A tree finds combinations like this ITSELF (Chapter 18); a linear model does NOT.
Method 5: group statistics, and their trap
The strongest method: "how does this flat compare with the average in its district?"
But this is the most dangerous place for leakage.
# Target encoding: CARDINALITY decides everything def te_score(X_extra): q = Pipeline([("i", SimpleImputer(strategy="median")), ("sc", StandardScaler()), ("m", LogisticRegression(max_iter=3000))]) return cross_val_score(q, X_extra, y, cv=5, scoring="f1").mean() r2 = np.random.default_rng(0) num_only = df[num] # 1) low cardinality: tuman, 8 groups te_8 = num_only.assign(te=df.tuman.map(df.groupby("tuman").ijaraga_berildi_30k.mean())) # 2) high cardinality: 400 artificial groups group_400 = r2.integers(0, 400, len(df)) te_400 = num_only.assign(te=pd.Series(y.values).groupby(group_400).transform("mean").values) # 3) the limit case: every row in its own group te_1200 = num_only.assign(te=y.values.astype(float)) print(f"{'case':<34} {'rows per group':>14} {'CV F1':>9}") print("-" * 60) print(f"{'baseline (NO target encoding)':<34} {'-':>14} {te_score(num_only):>9.4f}") print(f"{'8 groups (tuman)':<34} {len(df)//8:>14} {te_score(te_8):>9.4f}") print(f"{'400 groups':<34} {len(df)//400:>14} {te_score(te_400):>9.4f}") print(f"{'1200 groups (each row itself)':<34} {1:>14} {te_score(te_1200):>9.4f}")
case rows per group CV F1 ------------------------------------------------------------ baseline (NO target encoding) - 0.7219 8 groups (tuman) 150 0.7251 400 groups 3 0.8032 1200 groups (each row itself) 1 1.0000
This table explains target encoding correctly.
I had written this section as "if target encoding is computed on all the data, the result improves suspiciously", using tuman as the example. I measured it: no improvement (0.7219 vs 0.7251, a negligible gap).
The reason is cardinality. tuman has 8 groups of about 150 rows each. One row's answer barely moves a 150-row average, so the leakage is weak.
But as the groups multiply and each gets fewer rows:
- 400 groups (~3 rows) -> F1 rose to 0.8032. That's leakage.
- 1200 groups (1 row) -> F1 1.0000. Every row saw its own answer as a feature.
The danger of target encoding grows with cardinality
The rule: the fewer rows per group, the stronger the leakage.
- Hundreds of rows per group (district, region) -> weak leakage, but compute it inside a Pipeline anyway
- A few rows per group (product code, user ID) -> dangerous
- One row per group -> that's the target itself, not a feature
That's why high-cardinality columns use out-of-fold target encoding: each row's encoded value is computed from folds that don't contain it.
And most importantly: I was wrong here because I tested one case (8 groups) and tried to draw a general conclusion from it. One measurement doesn't make a rule: change the parameter and measure at several points.
from sklearn.base import BaseEstimator, TransformerMixin class GroupStats(BaseEstimator, TransformerMixin): """Computes the group mean ONLY from the data fit() has seen.""" def __init__(self, group_col="tuman", target_col="narx_usd"): self.group_col = group_col self.target_col = target_col def fit(self, X, y=None): self.map_ = X.groupby(self.group_col)[self.target_col].mean() self.overall_ = X[self.target_col].mean() return self def transform(self, X): d = X.copy() d["group_mean"] = d[self.group_col].map(self.map_).fillna(self.overall_) d["diff_from_group"] = d[self.target_col] - d["group_mean"] return d q_clean = Pipeline([ ("stat", GroupStats()), ("t", ColumnTransformer([ ("num", Pipeline([("i", SimpleImputer(strategy="median")), ("sc", StandardScaler())]), num + ["group_mean", "diff_from_group"]), ("cat", OneHotEncoder(handle_unknown="ignore"), cat), ])), ("m", LogisticRegression(max_iter=3000)), ]) X_price = df[num + cat + ["narx_usd"]] b5_clean = cross_val_score(q_clean, X_price, y, cv=5, scoring="f1") print(f"Starting point : {base_score.mean():.4f}") print(f"+ group stats (inside the Pipeline): {b5_clean.mean():.4f} ±{b5_clean.std():.4f}") print() print("You CAN trust this number: the statistics are recomputed in every fold.")
Starting point : 0.7273 + group stats (inside the Pipeline): 0.7400 ±0.0191 You CAN trust this number: the statistics are recomputed in every fold.
All together: an honest report
results = [ ("Starting point (7 features)", base_score), ("+ ratios", b1), ("+ binning", b3), ("+ interactions", b4), ("+ group stats (in the Pipeline)", b5_clean), ] rows = [] for name, b in results: rows.append({"Variant": name, "CV F1": round(b.mean(), 4), "±": round(b.std(), 4), "vs start": round(b.mean() - base_score.mean(), 4)}) show(pd.DataFrame(rows)) print() spread = max(b.std() for _, b in results) best = max(results, key=lambda x: x[1].mean()) print(f"Highest: {best[0]} ({best[1].mean():.4f})") print(f"Largest spread: ±{spread:.4f}") print(f"Verdict: {'the gap is LARGER than the spread' if best[1].mean()-base_score.mean() > spread else 'the gap is WITHIN the spread: the features gave no noticeable gain'}")
Highest: + group stats (in the Pipeline) (0.7400) Largest spread: ±0.0191 Verdict: the gap is WITHIN the spread: the features gave no noticeable gain
| Variant | CV F1 | ± | vs start |
|---|---|---|---|
| Starting point (7 features) | 0.727 | 0.012 | 0 |
| + ratios | 0.728 | 0.011 | 0 |
| + binning | 0.727 | 0.01 | -0 |
| + interactions | 0.727 | 0.013 | -0.001 |
| + group stats (in the Pipeline) | 0.74 | 0.019 | 0.013 |
Feature engineering often doesn't work
The table above is an honest result. On our data, the added features gave no noticeable gain.
That's normal. Even the leaders on Kaggle try dozens of features and keep one or two.
That's why the order matters: 1. Add one feature 2. Measure it with CV 3. Compare with the spread (Chapter 10) 4. If it doesn't help, drop it; don't keep it "in case it's useful later"
Every unnecessary column slows training and raises the risk of overfitting.
Feature selection: three families
With many features, how do we find the useless ones?
r = np.random.default_rng(42) noise = pd.DataFrame(r.normal(size=(len(X), 40)), columns=[f"noise_{i}" for i in range(40)], index=X.index) X_many = pd.concat([X[num], noise], axis=1) print(f"Features: {len(num)} real + 40 noise = {X_many.shape[1]}") print() show(pd.DataFrame([ {"Family": "Filter", "How it works": "Scores with statistics, no model", "sklearn": "SelectKBest, VarianceThreshold", "Speed": "Very fast"}, {"Family": "Wrapper", "How it works": "Tries by training the model again and again", "sklearn": "RFE, RFECV", "Speed": "Slow"}, {"Family": "Embedded", "How it works": "The model selects while it trains", "sklearn": "Lasso, tree importance", "Speed": "Fast (free)"}, ]))
Features: 7 real + 40 noise = 47
| Family | How it works | sklearn | Speed |
|---|---|---|---|
| Filter | Scores with statistics, no model | SelectKBest, VarianceThreshold | Very fast |
| Wrapper | Tries by training the model again and again | RFE, RFECV | Slow |
| Embedded | The model selects while it trains | Lasso, tree importance | Fast (free) |
from sklearn.feature_selection import SelectKBest, f_classif, RFE from sklearn.ensemble import RandomForestClassifier import time def simple_pipe(sel=None, model=None): # NOTE: the imputer has to come FIRST, because SelectKBest doesn't accept NaN steps = [("i", SimpleImputer(strategy="median")), ("sc", StandardScaler())] if sel is not None: steps.append(("sel", sel)) steps.append(("m", model or LogisticRegression(max_iter=3000))) return Pipeline(steps) print(f"{'method':<34} {'CV F1':>9} {'±':>8} {'time':>8}") print("-" * 62) for name, sel in [ ("No selection (47 features)", None), ("Filter: SelectKBest(k=7)", SelectKBest(f_classif, k=7)), ("Wrapper: RFE(n=7)", RFE(LogisticRegression(max_iter=2000), n_features_to_select=7)), ]: t0 = time.perf_counter() b = cross_val_score(simple_pipe(sel), X_many, y, cv=5, scoring="f1") dt = time.perf_counter() - t0 print(f"{name:<34} {b.mean():>9.4f} {b.std():>8.4f} {dt:>7.1f}s")
method CV F1 ± time -------------------------------------------------------------- No selection (47 features) 0.7021 0.0156 0.0s Filter: SelectKBest(k=7) 0.7272 0.0140 0.0s Wrapper: RFE(n=7) 0.7130 0.0129 0.4s
# did the selection find the REAL features? Xk_clean = SimpleImputer(strategy="median").fit_transform(X_many) sel = SelectKBest(f_classif, k=7).fit(StandardScaler().fit_transform(Xk_clean), y) chosen = X_many.columns[sel.get_support()].tolist() real = [t for t in chosen if not t.startswith("noise")] print(f"SelectKBest chose 7: {chosen}") print(f" real ones among them: {len(real)} / 7") print() rfe = RFE(LogisticRegression(max_iter=2000), n_features_to_select=7).fit( StandardScaler().fit_transform(Xk_clean), y) t_rfe = X_many.columns[rfe.get_support()].tolist() print(f"RFE chose 7: {t_rfe}") print(f" real ones among them: {len([t for t in t_rfe if not t.startswith('noise')])} / 7")
SelectKBest chose 7: ['xonalar', 'maydon_m2', 'qurilgan_yil', 'noise_5', 'noise_8', 'noise_26', 'noise_28'] real ones among them: 3 / 7 RFE chose 7: ['xonalar', 'maydon_m2', 'qavat', 'qavatlar_soni', 'noise_8', 'noise_13', 'noise_26'] real ones among them: 4 / 7
Feature selection has to happen INSIDE the Pipeline
The most common form of leakage:
sel = SelectKBest(k=10).fit(X, y) # on ALL the data: it saw y! X_selected = sel.transform(X) cross_val_score(model, X_selected, y, cv=5) # the result is a LIE
The selection looked at y to choose columns, then CV tests that very selection. The result is optimistic.
The right way is in the code above: Pipeline([("sel", SelectKBest(...)), ("m", model)]). Then the selection is redone in every fold.
The ORDER OF STEPS in a Pipeline matters
When I first wrote this block I left out SimpleImputer, and the build stopped with this error:
ValueError: Input X contains NaN. SelectKBest does not accept missing values encoded as NaN natively.
The reason: the qurilgan_yil column has 8 missing values (Chapter 1), and SelectKBest doesn't accept them.
The right order: fill -> scale -> select -> model. Every step expects the output of the one before it.
The rule: when you add a new step, ask yourself "does this step handle NaN?" Trees do; most other things don't.
What we learned today
show(pd.DataFrame([ {"Method": "Ratios and differences", "Who it helps": "Linear models", "Watch out": "A tree finds them itself"}, {"Method": "Splitting dates", "Who it helps": "Everyone", "Watch out": "Don't pass a date as a number"}, {"Method": "Binning", "Who it helps": "Linear models", "Watch out": "Information is lost"}, {"Method": "Interactions", "Who it helps": "Linear models", "Watch out": "A tree finds them itself"}, {"Method": "Group statistics", "Who it helps": "Everyone: the strongest", "Watch out": "The highest LEAKAGE risk"}, {"Method": "Filter selection", "Who it helps": "Fast screening", "Watch out": "Ignores the model"}, {"Method": "Wrapper (RFE)", "Who it helps": "Precise selection", "Watch out": "Slow"}, {"Method": "Embedded (Lasso)", "Who it helps": "Free (Chapter 17)", "Watch out": "You need to find alpha"}, ]))
| Method | Who it helps | Watch out |
|---|---|---|
| Ratios and differences | Linear models | A tree finds them itself |
| Splitting dates | Everyone | Don't pass a date as a number |
| Binning | Linear models | Information is lost |
| Interactions | Linear models | A tree finds them itself |
| Group statistics | Everyone: the strongest | The highest LEAKAGE risk |
| Filter selection | Fast screening | Ignores the model |
| Wrapper (RFE) | Precise selection | Slow |
| Embedded (Lasso) | Free (Chapter 17) | You need to find alpha |
The main rule: one change, measure, compare with the spread. And if it doesn't help, drop it.
Next chapter: how do we choose a model's settings?
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.