Skip to content

Feature engineering and selection

Chapter 23 15 code blocks 4223 words

The code in this chapter uses these filesrun it on your own machine

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

python33 lines
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"},
]))
Output
STARTING POINT (7 features): F1 = 0.7273 ±0.0118
MethodExample
1. Ratios and differencesprice/area, area/rooms
2. Splitting datesyear, month, weekday, is it a holiday
3. Grouping (binning)age -> 'young/middle/old'
4. Interactionsfurnished AND close to the metro
5. Group statisticsthe district's average price

Method 1: ratios and differences

The most commonly used method. It needs domain knowledge.

python12 lines
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})")
Output
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.

python13 lines
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.")
Output
One `date` column produced 7 features.
dateyearmonthweekdayquarterday_of_monthweekendmonth_start
2025-05-26 00:00:002 0255022600
2025-02-26 00:00:002 0252212600
2025-03-01 00:00:002 025351111
2025-01-03 00:00:002 025141301
2025-02-11 00:00:002 0252111100
2025-01-08 00:00:002 025121800

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.

python8 lines
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"}))
Output
Cancellation rate by weekday: is there a periodic pattern:
dayorderscancel_rate
Mon8850.042
Tue7930.034
Wed8420.038
Thu8080.027
Fri9610.032
Sat1 0570.043
Sun8340.024

Method 3: grouping (binning)

python12 lines
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})")
Output
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

python12 lines
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.")
Output
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.

python23 lines
# 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}")
Output
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.

python34 lines
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.")
Output
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

python18 lines
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'}")
Output
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
VariantCV F1±vs start
Starting point (7 features)0.7270.0120
+ ratios0.7280.0110
+ binning0.7270.01-0
+ interactions0.7270.013-0.001
+ group stats (in the Pipeline)0.740.0190.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?

python14 lines
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)"},
]))
Output
Features: 7 real + 40 noise = 47
FamilyHow it workssklearnSpeed
FilterScores with statistics, no modelSelectKBest, VarianceThresholdVery fast
WrapperTries by training the model again and againRFE, RFECVSlow
EmbeddedThe model selects while it trainsLasso, tree importanceFast (free)
python23 lines
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")
Output
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
python13 lines
# 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")
Output
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:

python3 lines
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:

matn2 lines
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

python10 lines
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"},
]))
MethodWho it helpsWatch out
Ratios and differencesLinear modelsA tree finds them itself
Splitting datesEveryoneDon't pass a date as a number
BinningLinear modelsInformation is lost
InteractionsLinear modelsA tree finds them itself
Group statisticsEveryone: the strongestThe highest LEAKAGE risk
Filter selectionFast screeningIgnores the model
Wrapper (RFE)Precise selectionSlow
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.

Write on Telegram Contents