House Price Tiers an ML project, end to end

Lesson 4 of 6

Feature engineering

New columns, an experiment log, and the data-leakage trap.

Download the notebook

Left: what & whyRight: the code and its real output

04 · Feature engineering

Feature engineering = creating new columns that make the pattern easier for the model to see. Ideas come from the EDA hypotheses and from common sense about houses.

We work like scientists: change one thing → re-run CV → keep it only if CV improves. Every experiment goes into a log. The model stays fixed (the same Random Forest), so only the features change.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier
from utils import (load_data, basic_prep, add_features, add_target_encoding, cv_score,
                   save_submission, TARGET, TIER_TO_NUM, LAYOUT_KEYS, SEED)
from score import score

train, test = load_data()
tr, te = basic_prep(train, test)
full, te_full = add_features(tr), add_features(te)
y = tr[TARGET].map(TIER_TO_NUM)
BASE = [c for c in tr.columns if c not in ("id", TARGET)]

def rf():
    return RandomForestClassifier(n_estimators=300, random_state=SEED, n_jobs=-1)

log = []  # our experiment log
def experiment(name, X, **kw):
    mean, std, _ = cv_score(rf, X, y, verbose=False, **kw)
    log.append({"experiment": name, "n_features": X.shape[1], "cv_acc": round(mean, 4), "std": round(std, 4)})
    print(f"{name:38s} CV = {mean:.4f} ± {std:.4f}")
experiment("0. baseline (raw columns)", tr[BASE])
0. baseline (raw columns)              CV = 0.6961 ± 0.0303

The new columns (all in add_features() in utils.py):

  • amenities: how many of the 6 yes/no boxes are "yes" (one "how fancy is it" score),
  • total_rooms = bedrooms + bathrooms,
  • area_per_room: how spacious the rooms are,
  • bath_per_bed: 1 bathroom for 4 bedrooms feels cheaper than 2 for 2,
  • log_area: area on a log scale.
full[["amenities", "total_rooms", "area_per_room", "bath_per_bed", "log_area"]].describe().T.round(2)
count mean std min 25% 50% 75% max
amenities 408.0 1.98 1.17 0.00 1.00 2.00 3.00 5.00
total_rooms 408.0 4.23 1.01 2.00 4.00 4.00 5.00 8.00
area_per_room 408.0 1246.22 560.67 317.50 898.67 1132.86 1488.50 4400.00
bath_per_bed 408.0 0.45 0.16 0.20 0.33 0.37 0.50 1.00
log_area 408.0 8.46 0.39 7.41 8.18 8.41 8.76 9.66

Idea 1: amenities count

experiment("1. + amenities", full[BASE + ["amenities"]])
1. + amenities                         CV = 0.7001 ± 0.0309

Idea 2: room ratios

experiment("2. + total_rooms, area_per_room, bath_per_bed", full[BASE + ["total_rooms", "area_per_room", "bath_per_bed"]])
2. + total_rooms, area_per_room, bath_per_bed CV = 0.6887 ± 0.0385

Idea 3: log of area

Trees only care about the order of values ("is area bigger than X?"), and taking the log doesn't change the order. So this should do nothing for a Random Forest. Let's confirm. (It can help linear models like Logistic Regression; we re-test it in 05.)

experiment("3. + log_area", full[BASE + ["log_area"]])
3. + log_area                          CV = 0.6961 ± 0.0331

Exactly the same score as the baseline: confirmed.

Now read ideas 1–2 carefully. The changes are small (under 1%), and on ~400 rows CV moves by about ±0.8% from noise alone (the std divided by √15, the number of folds). Neither difference is clearly bigger than the noise. That's a real and common outcome: the raw columns already describe the houses well.

Idea 4: "same description" price (target encoding), and the leakage trap ⚠️

Idea: houses with the same description (same bedrooms, bathrooms, stories, parking, furnishing, AC and preferred area) probably sell in a similar range. So for each house, compute the average tier of houses with the same description.

This uses the target to build a feature, which is dangerous. Let's do it the wrong way first.

Wrong way: compute the averages on all of train, then run CV

keys = list(LAYOUT_KEYS)
layout = tr[keys].astype(str).agg("_".join, axis=1)
print("different descriptions:", layout.nunique(), "| houses per description (median):", layout.value_counts().median())
leaky = tr[BASE].assign(layout_price=y.groupby(layout).transform("mean"))
experiment("4a. + layout_price (LEAKY)", leaky)
different descriptions: 210 | houses per description (median): 1.0
4a. + layout_price (LEAKY)             CV = 0.7998 ± 0.0307

A huge jump, about 10 points! Too good to be true? Check the leaderboard. For test houses we can only use train's averages, which is exactly the situation the model will face for real:

layout_means = y.groupby(layout).mean()
te_layout = te[keys].astype(str).agg("_".join, axis=1)
te_leaky = te[BASE].assign(layout_price=te_layout.map(layout_means).fillna(y.mean()))
m = rf().fit(leaky, y)
print("leaderboard:", score(save_submission(te, m.predict(te_leaky), "04_leaky_target_encoding")))
leaderboard: {'public': 0.5323}

CV says ~0.80, but the leaderboard collapses to worse than our first Random Forest. Why? Most descriptions occur only once in train (median = 1 house). Each house's layout_price was computed including its own answer, so for most houses layout_price is the answer. The model learned "just copy layout_price". On test houses that shortcut doesn't exist (most of their descriptions were never seen), so the model is lost.

This is data leakage: information about the answer sneaking into the features. It is the #1 way people fool themselves, and it's why "too good to be true" CV jumps deserve suspicion.

Right way: learn the averages inside each fold, from the training part only

add_target_encoding() in utils.py does it properly:

  • validation/test rows get averages computed only from the training fold,
  • training rows get a leave-one-out average (their own answer removed),
  • small groups are pulled towards the overall average (smoothing), so a description seen twice doesn't get an extreme value.

cv_score(..., fold_fn=add_target_encoding) runs it separately inside every fold. (The log shows one feature fewer because layout_price is added inside each fold.)

experiment("4b. + layout_price (correct, in-fold)", tr[BASE], fold_fn=add_target_encoding)
4b. + layout_price (correct, in-fold)  CV = 0.7001 ± 0.0330
X_fit, X_te = add_target_encoding(tr[BASE], y, te[BASE])
m = rf().fit(X_fit, y)
print("leaderboard:", score(save_submission(te, m.predict(X_te), "04_features_te")))
leaderboard: {'public': 0.7258}

Done honestly, the "magic" disappears: CV goes back to roughly the baseline. The feature carried no real extra information, only the leaked answer.

Experiment log

log_df = pd.DataFrame(log)
log_df["vs_baseline"] = (log_df.cv_acc - log_df.cv_acc[0]).round(4)
log_df
experiment n_features cv_acc std vs_baseline
0 0. baseline (raw columns) 12 0.6961 0.0303 0.0000
1 1. + amenities 13 0.7001 0.0309 0.0040
2 2. + total_rooms, area_per_room, bath_per_bed 15 0.6887 0.0385 -0.0074
3 3. + log_area 13 0.6961 0.0331 0.0000
4 4a. + layout_price (LEAKY) 13 0.7998 0.0307 0.1037
5 4b. + layout_price (correct, in-fold) 12 0.7001 0.0330 0.0040

What does the model rely on?

A Random Forest can report how much each feature helped its splits (feature importance):

m = rf().fit(tr[BASE], y)
pd.Series(m.feature_importances_, index=BASE).sort_values().plot.barh(figsize=(7, 5), title="Feature importance");
plot

area dominates (about a third of all importance), then furnishing, stories, bathrooms and bedrooms. That matches the EDA hypotheses from 01. Note that importance shows what the model uses, not cause and effect.

Decision

No new feature beat the noise, so we keep the 12 raw columns. Simpler is better when the evidence is unclear. (On a big dataset the same ideas can matter a lot. See extra_california/04_feature_engineering.ipynb, where distance-to-city features added +3%.)

Next: 05_models_and_tuning. If features aren't the lever here, maybe the model is.