Feature Engineering and Encoding
How to turn categories, numbers, times and gaps into model inputs without leaking labels. On a noise ID, naive target encoding scored train AUC 0.905; cross-fitted, 0.512.
Feature encoding turns categories and other raw columns into numbers a model can use. Use one-hot for linear and distance-based models, ordinal only for real orders or trees, and cross-fitted target encoding or hashing for very many categories. Interviewers expect the leakage rule: fit every encoder, imputer and scaler on training data only, inside a Pipeline.
Pick the encoding by the model: one-hot for linear and distance models, ordinal only for real orders or trees, and cross-fitted target encoding or hashing for huge category counts. Fit every encoder, imputer and scaler on training data only, inside a Pipeline. On synthetic delivery data, a noise column encoded naively looked like a strong feature until cross-fitting exposed it.
- On 400 restaurants in a synthetic delivery dataset, a logistic regression reached test AUC 0.63 with one-hot encoding but only 0.476, about chance, with ordinal codes.
- Naive target encoding of a pure-noise customer ID scored train AUC 0.905, while cross-fitted target encoding scored 0.512 on train and 0.503 on test.
- Feature hashing of 400 restaurants into 64 buckets gave test AUC 0.566, and 1,024 buckets gave 0.621, near full one-hot, because collisions became rarer.
- Unscaled KNN scored test AUC 0.51 and scaled KNN 0.644 on distance and order value, while a random forest scored 0.637 either way, since tree splits ignore scale.
- Encoding hour of day as sine and cosine of 2π·hour/24 puts hours 23 and 0 just 0.261 apart, while hours 0 and 12 sit 2.0 apart.
- scikit-learn's HistGradientBoostingClassifier allows at most max_bins categories per native categorical feature, 255 by default, so 400 restaurants raised an error.
01Models read numbers, so every column needs a translation
A logistic regression multiplies each input by a weight. A k-nearest-neighbours model measures distances. A gradient-boosted tree compares values with thresholds. None of them can use the string "Pune" or a timestamp directly, so every column must become numbers first. That is feature engineering; encoding is the part that handles categories.
The choice is not cosmetic. A bad encoding rarely raises an error; it produces a quietly worse model, or one that shines in the notebook and fails on new data.
Every experiment below uses one synthetic dataset of 6,000 food-delivery orders with a fixed seed. Lateness depends on the restaurant (400 of them, each with its own late rate), distance, a late-evening rush, and whether the restaurant is new. Two columns are traps: customer_id is pure noise and order_value has no effect. Known rules let us see when an encoding helps and when it fools us. The snippets run in order in one Python session (scikit-learn 1.9.1, pandas 3.1.0, NumPy 2.5.4).
import numpy as np, pandas as pd
def make_orders(n=6000, seed=7):
"""Synthetic food-delivery orders. Target: late (1) or on time (0)."""
rng = np.random.default_rng(seed)
rest_rate = rng.beta(2, 5, 400) # each restaurant has its own late rate
rest = rng.integers(0, 400, n)
dist = rng.gamma(2.0, 2.0, n) # km, right-skewed
value = rng.uniform(100, 3000, n) # rupees, no effect on lateness
hour = rng.integers(0, 24, n)
rush = np.isin(hour, [20, 21, 22, 23, 0]).astype(float)
new_rest = rng.random(n) < 0.15 # new restaurants have no rating yet
rating = np.where(new_rest, np.nan, rng.normal(4.0, 0.4, n).round(1))
logit = (np.log(rest_rate[rest] / (1 - rest_rate[rest])) + 0.25 * dist
+ 0.9 * rush + 0.8 * new_rest - 1.0)
late = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
return pd.DataFrame({
"restaurant": ["r%03d" % r for r in rest],
"customer_id": ["c%04d" % c for c in rng.integers(0, 2500, n)], # pure noise
"distance_km": dist.round(2), "order_value": value.round(0),
"hour": hour, "rating": rating, "late": late})
df = make_orders()
print(df.shape, df.restaurant.nunique(), df.customer_id.nunique(), round(df.late.mean(), 3))(6000, 7) 400 2273 0.35135.1% of orders are late, and 2,273 customer IDs share the 6,000 orders, about 2.6 orders each.
02One-hot vs ordinal: the fake order, measured
One-hot encoding gives each category its own 0/1 column. Ordinal encoding replaces each category with one integer, in sorted order. That suits a truly ordered column such as small, medium, large. For restaurant names it claims r399 is "more" than r001, which means nothing.
Here a logistic regression sees only the restaurant column, encoded each way, scored by test AUC on a 70/30 split (0.5 is a coin flip):
from sklearn.model_selection import train_test_split, StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, TargetEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score
tr, te = train_test_split(df, test_size=0.3, random_state=0, stratify=df.late)
folds = StratifiedKFold(5, shuffle=True, random_state=0) # seeded folds for TargetEncoder
def test_auc(model, cols=["restaurant"]):
model.fit(tr[cols], tr.late)
return round(roc_auc_score(te.late, model.predict_proba(te[cols])[:, 1]), 3)
ordinal = lambda: OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1)
logreg = lambda: LogisticRegression(max_iter=2000)
print("one-hot + logistic", test_auc(make_pipeline(OneHotEncoder(handle_unknown="ignore"), logreg())))
print("ordinal + logistic", test_auc(make_pipeline(ordinal(), logreg())))
print("target + logistic", test_auc(make_pipeline(TargetEncoder(cv=folds), logreg())))
print("ordinal + boosting", test_auc(make_pipeline(ordinal(), HistGradientBoostingClassifier(random_state=0))))one-hot + logistic 0.63
ordinal + logistic 0.476
target + logistic 0.631
ordinal + boosting 0.599With one-hot columns the model learns a weight per restaurant and reaches 0.63. Given ordinal codes, it can only fit one slope along an arbitrary line from r000 to r399, so the signal vanishes and the score lands at chance (0.476). Target encoding, covered next, matches one-hot with a single column.
Trees do better with ordinal codes (0.599) because they can cut the integer line into ranges, but they still trail one-hot. With about 10 training orders per restaurant, isolating one bad restaurant needs two cuts between unrelated neighbours, and there is rarely enough data to justify them.
Two settings matter in production. handle_unknown decides what happens to a category never seen in training: raise an error, emit an all-zero row (ignore), or map it to an infrequent bucket. drop='first' removes one column per feature to avoid collinear dummies in an unregularised linear model; the scikit-learn docs warn it can bias a penalised model, so leave it off unless you need it.
03Hundreds of categories: target encoding, hashing and rare buckets
One-hot works for 400 restaurants, but its width grows with every category. 50,000 product IDs become 50,000 sparse columns, most seen a handful of times. There are three common ways out.
Target encoding replaces each category with its average target. scikit-learn's TargetEncoder blends the category mean with the global mean, pulling rare categories harder toward the global value; smooth sets how hard, and smooth='auto' estimates it from the data. Unseen categories get the global mean. It is one column at any cardinality.
Feature hashing maps each category name through a hash function into a fixed number of columns (FeatureHasher uses signed 32-bit MurmurHash3). No vocabulary is fitted, so new categories just work, but different categories can collide:
from sklearn.feature_extraction import FeatureHasher
rows = lambda s: [[v] for v in s]
for buckets in [64, 256, 1024]:
h = FeatureHasher(n_features=buckets, input_type="string")
m = LogisticRegression(max_iter=2000).fit(h.transform(rows(tr.restaurant)), tr.late)
auc = roc_auc_score(te.late, m.predict_proba(h.transform(rows(te.restaurant)))[:, 1])
used = len(set(h.transform(rows(df.restaurant.unique())).nonzero()[1]))
print(f"{buckets} buckets, {used} used, test AUC {auc:.3f}")64 buckets, 64 used, test AUC 0.566
256 buckets, 205 used, test AUC 0.602
1024 buckets, 332 used, test AUC 0.621With 64 buckets, each bucket holds about six restaurants and good ones blur into bad. At 1,024 buckets the 400 restaurants occupy 332 columns, collisions are rarer, and the score climbs to 0.621, near full one-hot. Bucket count is a memory-versus-accuracy dial.
Grouping rare levels keeps one-hot but merges categories below a frequency threshold into one "infrequent" column, via min_frequency or max_categories on OneHotEncoder and OrdinalEncoder (scikit-learn 1.3+). It is the simplest fix for a long tail.
04Target encoding leaks unless it is cross-fitted
Target encoding builds a feature from the label. If a restaurant has three training orders and two were late, its encoded value already contains those rows' answers. The model learns that the feature predicts training labels well, then meets test rows whose encoding was not built from their own outcome.
The fix is cross-fitting. TargetEncoder.fit_transform splits the training data into folds and encodes each fold with means from the other folds, so no row sees its own label. fit(X, y).transform(X) skips that, and the docs state the two give different results. Here they are on the real signal and on the noise column:
for col in ["restaurant", "customer_id"]:
enc = TargetEncoder(smooth=1e-3, cv=folds)
cross_fit = enc.fit_transform(tr[[col]], tr.late)[:, 0] # out-of-fold values
naive = enc.transform(tr[[col]])[:, 0] # each row has seen its own label
print(f"{col:12s} naive train {roc_auc_score(tr.late, naive):.3f} | "
f"cross-fit train {roc_auc_score(tr.late, cross_fit):.3f} | "
f"test {roc_auc_score(te.late, enc.transform(te[[col]])[:, 0]):.3f}")restaurant naive train 0.761 | cross-fit train 0.639 | test 0.631
customer_id naive train 0.905 | cross-fit train 0.512 | test 0.503Read the second line slowly. Random IDs with no link to lateness score 0.905 on training data when encoded naively. That is memorisation: each customer has only a few orders, so their "mean late rate" is mostly their own labels. Cross-fitted, the column scores 0.512 on train and 0.503 on test, which is the truth. For the real restaurant feature, cross-fitting brings the train score (0.639) close to test (0.631), so the training number becomes a fair estimate.
Shrinkage alone does not save you. Rerun with the default smooth='auto', the naive noise column still scored 0.905 on train and 0.504 on test. The tiny smooth=1e-3 above only makes the output easier to read. Note the cv=folds argument too: scikit-learn 1.9 deprecates TargetEncoder's shuffle and random_state in favour of passing a seeded splitter, which keeps these numbers reproducible.
05Scaling and skew: which models care
Scaling changes units, not information, so whether it matters depends on how the model uses the numbers. Distance has a standard deviation of 2.82 km; the irrelevant order value has 847 rupees. Here are k-nearest-neighbours and a random forest on those two columns, with and without StandardScaler:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
num = ["distance_km", "order_value"]
print(tr[num].std().round(2).to_dict())
for name, make in [("knn", lambda: KNeighborsClassifier(n_neighbors=50)),
("forest", lambda: RandomForestClassifier(200, min_samples_leaf=20, random_state=0))]:
print(name, "raw", test_auc(make(), num),
"scaled", test_auc(make_pipeline(StandardScaler(), make()), num))
print("mean km, all rows", round(df.distance_km.mean(), 4), "| train only", round(tr.distance_km.mean(), 4)){'distance_km': 2.82, 'order_value': 847.26}
knn raw 0.51 scaled 0.644
forest raw 0.637 scaled 0.637
mean km, all rows 4.0242 | train only 4.0579Unscaled, KNN measures distance almost entirely in rupees, so its "nearest" orders are the ones with similar bills, and it scores a coin flip (0.51). Scaled, both columns count equally and the distance signal comes through (0.644). The forest gives 0.637 either way, because a split like distance_km < 3.1 partitions rows identically in kilometres or standard deviations. Regularised linear models, SVMs, PCA and neural networks join KNN in the needs-scaling group: penalties, kernels, variance and gradient steps all depend on each input's size.
Skew is separate. Most deliveries are short and a few are long; log1p pulls in that tail so a linear model is not dominated by extreme rows. Trees again do not care, since a log preserves order.
The last output line is leakage in miniature. All 6,000 distances average 4.0242 km; the 4,200 training rows average 4.0579. A scaler fitted before the split would let test rows set its centre. The gap is small here, but the same mistake with feature selection or target encoding is how noise ends up looking predictive.
06Time, missing values and the pipeline that keeps it honest
Hours, weekdays and months wrap around. As raw numbers, 23:00 and midnight are 23 apart, though really one hour apart. Placing the hour on a circle with a sine and a cosine fixes that: below, hours 23 and 0 sit 0.261 apart, while 0 and 12, the furthest pair, sit 2.0 apart.
Missing can be a signal. New restaurants have no rating and are late more often: 46.7% against 33.1%. Median filling erases that unless you also add a 0/1 "was missing" column, which SimpleImputer(add_indicator=True) does.
The code puts everything into one ColumnTransformer inside a Pipeline: target encoding for restaurant, log and scale for distance, a choice of hour encoding, and median imputation for rating. Unlisted columns, including the noise ID and order value, are dropped. Because the pipeline is what gets cross-validated, each fold refits every encoder, imputer and scaler on its own training part.
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import FunctionTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_val_score
def cyclic(h): # hour 23 and hour 0 land next to each other
a = 2 * np.pi * np.asarray(h, dtype=float) / 24
return np.c_[np.sin(a), np.cos(a)]
def build(hour_step, flag_missing):
prep = ColumnTransformer([ # columns not listed (customer_id, order_value) are dropped
("rest", TargetEncoder(cv=folds), ["restaurant"]),
("dist", make_pipeline(FunctionTransformer(np.log1p), StandardScaler()), ["distance_km"]),
("hour", hour_step, ["hour"]),
("rating", make_pipeline(SimpleImputer(strategy="median", add_indicator=flag_missing),
StandardScaler()), ["rating"])])
return make_pipeline(prep, LogisticRegression(max_iter=2000))
features = ["restaurant", "distance_km", "hour", "rating"]
for label, step, flag in [("raw hour", StandardScaler(), False),
("cyclic hour", FunctionTransformer(cyclic), False),
("cyclic hour + missing flag", FunctionTransformer(cyclic), True)]:
cv = cross_val_score(build(step, flag), tr[features], tr.late, cv=5, scoring="roc_auc").mean()
print(f"{label:27s} CV AUC {cv:.3f} test AUC {test_auc(build(step, flag), features):.3f}")
print(np.linalg.norm(cyclic([23]) - cyclic([0])).round(3), np.linalg.norm(cyclic([12]) - cyclic([0])).round(3))
print(df.late[df.rating.isna()].mean().round(3), df.late[df.rating.notna()].mean().round(3))raw hour CV AUC 0.722 test AUC 0.708
cyclic hour CV AUC 0.730 test AUC 0.710
cyclic hour + missing flag CV AUC 0.738 test AUC 0.717
0.261 2.0
0.467 0.331Each step adds a little: 0.722, 0.730, 0.738 in cross-validation and 0.708 to 0.717 on test. The gains are small and specific to synthetic data where the rush straddles midnight and missingness was built to matter; on real data, trust your own cross-validation. What carries over is the method: every learned transform lives inside the pipeline, and the pipeline is the unit you validate and deploy.
07How libraries handle categories differently
Popular tools implement the same idea differently, and the differences cause real bugs.
train = pd.DataFrame({"city": ["Pune", "Delhi", "Pune", "Chennai"]})
new = pd.DataFrame({"city": ["Delhi", "Kochi"]})
print(list(pd.get_dummies(train).columns), list(pd.get_dummies(new).columns))
enc = OneHotEncoder(handle_unknown="ignore", sparse_output=False).fit(train)
print(enc.transform(new))
try:
make_pipeline(ordinal(), HistGradientBoostingClassifier(categorical_features=[0])).fit(tr[["restaurant"]], tr.late)
except ValueError as e:
print(str(e)[:80])['city_Chennai', 'city_Delhi', 'city_Pune'] ['city_Delhi', 'city_Kochi']
[[0. 1. 0.]
[0. 0. 0.]]
Categorical feature at index 0 is expected to have a cardinality <= 255 but actupandas `get_dummies` builds columns from whatever values it is given, so training data and a new batch get different columns: no Chennai or Pune, an unexpected Kochi. scikit-learn `OneHotEncoder` remembers the training categories, always outputs the same three columns, and with handle_unknown='ignore' encodes Kochi as all zeros. Use the encoder for anything you deploy.
scikit-learn `HistGradientBoostingClassifier` splits on categories natively, but allows at most max_bins categories per feature, 255 by default. Our 400 restaurants raised the error above, so you would group rare levels or target-encode first.
LightGBM takes categories as non-negative integer codes and searches for good groupings of them at each split; its documentation says this often beats one-hot. For very high cardinality it suggests treating the feature as numeric or embedding it, with min_data_per_group and cat_smooth to curb overfitting. Unseen categories are treated as missing.
CatBoost target-encodes internally but guards against the leak above differently: it shuffles the rows and computes each row's statistic only from rows earlier in that order, starting from a prior. Features with at most one_hot_max_size distinct values are one-hot encoded instead.
08Common interview questions
Q: One-hot or label (ordinal) encoding?
A: Ordinal only for categories with a real order, or for trees, which can cut integer ranges. For nominal categories in linear, distance-based or neural models use one-hot, since integer codes invent an order; above, ordinal codes left a logistic regression at chance.
Q: A column has 10,000 categories. What do you do?
A: Cross-fitted target encoding (one column), feature hashing (fixed width, some collisions), grouping rare levels, or a model with native categorical support such as LightGBM or CatBoost. Choose by validation score and by how unseen categories must be handled.
Q: What is the leakage risk in target encoding?
A: A row's encoded value includes its own label, so the model memorises training labels. Use out-of-fold (cross-fitted) encoding and fit on training data only. A noise ID column is a good sanity check: naive encoding gave it train AUC 0.905, test 0.503.
Q: Does a random forest need feature scaling?
A: No. Splits depend on the order of values, not their scale; the forest scored 0.637 either way. KNN, SVMs, PCA, regularised linear models and neural networks do need it.
Q: Why fit the scaler only on the training set?
A: Its mean and spread are learned parameters, so fitting on all data lets test rows shape training and inflates the score. Inside a Pipeline, cross-validation refits it per fold automatically.
Q: How would you encode hour of day?
A: As sine and cosine of 2π·hour/24, so 23:00 and 00:00 end up close. Trees can also use the raw hour and split it into ranges.