Section 2 · Lesson 4

Engineer features and target encodings

Build the features that lifted macro F1 from 0.730 to 0.758, and learn why out-of-fold target encoding works.

You'll learn

How the project searched for features

Feature work ran as three parallel experiment tracks (experiments/fe, te, and models). Every run used the same 20% holdout, the first fold of a five-fold stratified split with seed 42, so the scores are comparable. Rerunning the baseline with different thread counts and seeds moved the score by about 0.002, so treat smaller differences as noise.

Basic features: nothing from the label

The notebook's basic_features() function builds features without using the target. It processes train and test together so both get the same columns and category levels. The following excerpt shows the identifier and facility handling:

for c in ["operator_ref", "zone", "borough"]:
    df[c] = raw[c].map(raw[c].value_counts())     # ID -> listing count
df["n_facilities"] = df["facilities"].str.count("FCL-")

codes = raw["facilities"].fillna("[]").map(json.loads).explode().dropna()
fac = pd.crosstab(codes.index, codes).reindex(raw.index, fill_value=0).clip(upper=1)
fac.columns = ["f_" + c[4:] for c in fac.columns]  # one 0/1 column per code

Each block earns its place:

The feature experiments

The following holdout scores come from experiments/fe/results.tsv. The model is a LightGBM multiclass model. "Freq only" means the ID column is replaced by its listing count.

Feature setHoldout macro F1Change from base
Base (raw columns, IDs as categories)0.73030
Base + all facility flags0.7344+0.0041
Base + operator_ref as count only0.7336+0.0033
Base + operator and zone as counts only0.7377+0.0074
... + facility flags0.7476+0.0173
... + geography0.7503+0.0200
Facility TF-IDF with SVD, ratio features, date differences, group aggregateswithin noiseabout 0

Target encoding

A target encoding replaces a category with a statistic of the label for that category. Here the statistic is the smoothed share of each tier among the listings in the group. For group \(g\) with \(n_g\) listings, \(n_{g,k}\) of which are tier \(k\), and overall tier share \(\pi_k\):

$$\hat p_{g,k} = \frac{n_{g,k} + m\,\pi_k}{n_g + m}$$

The smoothing weight \(m\) is 20 in the notebook. A group with few listings gets pulled toward the overall class mix, and a large group keeps its own mix. Without smoothing, a group with a single listing would claim 100% for its tier.

The notebook encodes 15 groups, such as operator, zone, operator and zone together, and near-duplicate listings that share operator, zone, space class, and seat count. For each group it creates four tier shares (_p1 to _p4), the mean tier, and the log group size.

Why out-of-fold encoding matters

If you compute the encoding of a training row from a group that includes that row, the row's own label leaks into its feature. The model learns to trust the feature, which looks perfect on training rows and fails on new ones. The fix is out-of-fold (OOF) encoding: split the training rows into five folds, and compute each fold's encodings from the other four. For test rows, use all of train.

T_train = pd.concat([target_features(fit, val).set_index(val)
                     for fit, val in folds]).sort_index()
T_test = target_features(np.arange(len(train)),
                         np.arange(len(train), len(both)))

Here fit holds the other four folds and val holds the rows being encoded.

Target-encoding playground

Pick a real operator group from the training data, or type your own tier counts. Move the smoothing slider, then compare what a row sees when its own label is included (in-sample) and when it's left out. The out-of-fold view here uses a leave-one-out approximation. The notebook uses five folds.

Counts in group:

Try the single-listing groups at the top of the list with m set to 0. The in-sample encoding matches the row's tier 100% of the time, which looks like skill but is only the row's own label coming back. Raise m and that false confidence shrinks toward the prior.

A leakage experiment you can reproduce

The course script build/03_leakage_experiment.py isolates the effect. It trains a small LightGBM on only the operator target encoding, on fold 0 of the project's split. This experiment is not part of the original project, and the numbers are specific to this setup.

Bars comparing macro F1 on training and holdout rows for in-sample and out-of-fold target encoding.
In-sample encoding scores 0.579 on its own training rows but 0.455 on the holdout. Out-of-fold encoding gives matching scores of 0.456 and 0.449.

The lesson is that the training score is no longer honest with in-sample encoding. The holdout scores differ little in this single-feature setup, because the model has nothing else to trust. In the full 350-feature model, the same flaw would let the model over-rely on the encodings.

Residual encoding: the strongest feature

Some operators charge a premium. Others only rent large spaces. A plain target encoding can't tell the two apart. Residual encoding separates them:

  1. Estimate the typical tier for each combination of access_type and max_seats.
  2. For operator, zone, location, and space-class groups, average how far each listing's tier sits above or below that baseline.
  3. Add the group's average residual back to the baseline for the row's own seat and access combination, to get res_*_pred.

The notebook also adds geographic neighbors: the tier mix of the listings at the 1, 5, 20, and 50 nearest distinct coordinates.

Target-feature experiments

The following scores come from experiments/te/results.tsv, with the same holdout:

SetupHoldout macro F1
Base (raw IDs as categories)0.7284
Basic target encodings, raw IDs still included0.7270
Basic target encodings, raw IDs dropped0.7420
+ residual encodings0.7461
+ residual encodings and geographic neighbors0.7475
All encodings, residuals, and neighbors, raw IDs dropped0.7505

Two results stand out. First, adding target encodings while the raw IDs stay in the model doesn't help, because LightGBM still overfits the IDs. Dropping them is what unlocks the gain. Second, each new family adds a little more.

Bar charts of LightGBM gain importance: res_zone_pred, res_geo_pred, and res_zone_sc_pred lead; target encodings hold 38 percent of total gain.
Gain importance from a quick LightGBM refit on fold 0 (learning rate 0.1, holdout macro F1 0.7505), made by build/02_importance.py with the notebook's own feature code. Residual encodings take the top three places.

You add target encodings for operator_ref, but the model still uses the raw operator_ref as a categorical column. What did the experiments find?

  1. The score didn't improve (0.727 against a 0.728 base), because the model kept overfitting the raw IDs.
  2. The score jumped by 0.02.
  3. The score dropped to 0.

Dropping the raw high-cardinality IDs (0.742) was necessary for the target encodings to help.

For test rows, which listings does the notebook use to compute target encodings?

  1. Only the test rows themselves.
  2. The four folds not used for that row, as in training.
  3. All rows in train, because test labels are unknown and no row's label is at risk of leaking.

Test rows never have their own label in the data. Using all of train gives them the best encoding, and the training rows' OOF encodings imitate that situation.