Feature engineering transformers

scikit-learn · Advanced · 12 min

Good features often matter more than the model, and doing it in the pipeline keeps it leak-free.

Feature engineering belongs in the pipeline so it is refitted per fold and shipped with the model. scikit-learn provides building blocks:

  • FunctionTransformer(func) wraps any stateless function, such as np.log1p for skewed amounts, or a function that derives price_per_m2 from a DataFrame. Pass feature_names_out so names survive.
  • PolynomialFeatures(degree=2, interaction_only=False) adds squares and products, letting linear models fit curves and interactions. The number of columns grows quickly.
  • SplineTransformer(n_knots, degree) creates smooth piecewise-polynomial bases per feature: often a better way to model non-linear effects than high-degree polynomials. extrapolation="periodic" suits cyclic features such as hour of day.
  • KBinsDiscretizer buckets a numeric feature.
  • TargetEncoder replaces each category with a smoothed mean of the target. It is excellent for high-cardinality categories (zip codes, product IDs) where one-hot would create thousands of columns. Its fit_transform uses internal cross fitting so a row's encoding never uses its own target, which prevents leakage (control the folds with cv=). That means fit(X, y).transform(X) gives different values from fit_transform(X, y) on training data; always use fit_transform in training, which pipelines do automatically.

Any stateful step (one that learns from data) must be a transformer, not a pandas operation done beforehand on the full dataset.

Example

import numpy as np
import pandas as pd
from sklearn.preprocessing import FunctionTransformer, PolynomialFeatures, TargetEncoder

log = FunctionTransformer(np.log1p, feature_names_out="one-to-one")
print(log.fit_transform(pd.DataFrame({"amount": [0, 9, 99]})).round(2).values.ravel())
# [0.   2.3  4.61]

poly = PolynomialFeatures(degree=2, include_bias=False).fit(np.zeros((1, 2)))
print(poly.get_feature_names_out(["a", "b"]))  # ['a' 'b' 'a^2' 'a b' 'b^2']

rng = np.random.default_rng(0)
zip_code = rng.choice(["z1", "z2", "z3"], size=300).reshape(-1, 1)
y = (zip_code.ravel() == "z1") * 1.0 + rng.normal(0, 0.1, 300)
enc = TargetEncoder()
Xt = enc.fit_transform(zip_code, y)            # cross-fitted on training data
print(Xt.shape, np.round(enc.encodings_[0], 2))  # (300, 1) [0.98 0.   0.  ]

Remember

  • Wrap stateless functions in FunctionTransformer, stateful logic in real transformers
  • Splines or polynomials let linear models fit non-linear effects
  • TargetEncoder handles high-cardinality categories with built-in cross fitting

Watch out for

  • Computing target means per category with pandas on the full data, which leaks the target.

Check yourself

  1. Why does TargetEncoder.fit_transform(X, y) differ from fit(X, y).transform(X) on the training data?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: scikit-learn.org/stable/modules/preprocessing.html#target-encoder