Skip to main content

Feature engineering

Examples

A linear model is linear in its features, not necessarily in the raw inputs. Every section on this page builds features \(h(x)\) from the inputs, as the polynomial features of week 3 did, and fits a linear model on them.

Categorical predictors

A model cannot use the string "female". One-hot coding turns each category into its own column of zeros and ones.

import numpy as np
import pandas as pd
from sklearn.preprocessing import OneHotEncoder

cdata = pd.DataFrame({
    "gender": pd.Categorical(["male", "male", "female", "female", "female", "male"]),
    "treatment": pd.Categorical([1, 2, 2, 1, 3, 2])})

enc = OneHotEncoder(sparse_output=False)
pd.DataFrame(enc.fit_transform(cdata),
             columns=enc.get_feature_names_out())
gender_female gender_male treatment_1 treatment_2 treatment_3
0 0.0 1.0 1.0 0.0 0.0
1 0.0 1.0 0.0 1.0 0.0
2 1.0 0.0 0.0 1.0 0.0
3 1.0 0.0 1.0 0.0 0.0
4 1.0 0.0 0.0 0.0 1.0
5 0.0 1.0 0.0 1.0 0.0

With an intercept in the model, one of the columns per category is redundant. The sum of all columns of one category is always 1, which is the intercept again. Without a penalty this makes the least squares solution non-unique, so we drop one column per category. With a penalty, as in ridge regression, the redundancy does no harm and all columns are usually kept.

enc = OneHotEncoder(drop="first", sparse_output=False)
enc.fit_transform(cdata)
array([[1., 0., 0.],
       [1., 1., 0.],
       [0., 1., 0.],
       [0., 0., 0.],
       [0., 0., 1.],
       [1., 1., 0.]])

In a real data set only some columns are categorical, so we apply the encoder to those columns only.

from sklearn.compose import make_column_transformer
from sklearn.datasets import fetch_openml
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import Pipeline

wage = fetch_openml(data_id=534, as_frame=True).frame
categorical = list(wage.select_dtypes("category").columns)

pre = make_column_transformer(
    (OneHotEncoder(drop="first", handle_unknown="ignore"), categorical),
    remainder="passthrough",
    verbose_feature_names_out=False)

pipe = Pipeline([("encoder", pre), ("regressor", LinearRegression())])
pipe.fit(wage.drop("WAGE", axis=1), wage["WAGE"])

pd.DataFrame({"predictor": pipe[:-1].get_feature_names_out(),
              "coefficient": pipe[-1].coef_.round(3)})
1
The 1985 CPS wage survey, 534 people, the same data set the panel below uses. select_dtypes("category") finds the columns that need encoding instead of naming them by hand.
2
A category that never occurred in the training data is encoded as zeros in all its columns, instead of stopping with an error. In cross-validation this happens whenever a rare category is missing from the training folds.
predictor coefficient
0 SOUTH_yes -0.563
1 SEX_male 1.943
2 UNION_not_member -1.602
3 RACE_Other -0.231
4 RACE_White 0.607
5 OCCUPATION_Management 3.268
6 OCCUPATION_Other -0.022
7 OCCUPATION_Professional 1.935
8 OCCUPATION_Sales -0.796
9 OCCUPATION_Service -0.707
10 SECTOR_Manufacturing 0.563
11 SECTOR_Other -0.477
12 MARR_Unmarried -0.301
13 EDUCATION 0.813
14 EXPERIENCE 0.245
15 AGE -0.158

Read a coefficient as the effect of that predictor with all the others held fixed. Years of education correlate positively with wage; a woman earns about 2 USD per hour less than a man with the same education, experience and occupation; someone in management earns about 4 USD per hour more than someone in sales.

The encoder is a step of the pipeline, so in cross-validation it is fitted inside each fold. Every transformation on this page goes into a pipeline the same way.

Interactions

In the model above, a year of education is worth the same for everyone. The product of two predictors lets the effect of one depend on the other.

from sklearn.model_selection import KFold, cross_val_score

X = pd.DataFrame({"EDUCATION": wage["EDUCATION"],
                  "female": (wage["SEX"] == "female").astype(float)})
X["EDUCATION x female"] = X["EDUCATION"] * X["female"]

fit = LinearRegression().fit(X, wage["WAGE"])
print(pd.Series(fit.coef_, index=X.columns).round(3))

cv = KFold(5, shuffle=True, random_state=0)
for cols in [["EDUCATION", "female"], list(X.columns)]:
    rmse = -cross_val_score(LinearRegression(), X[cols], wage["WAGE"], cv=cv,
                            scoring="neg_root_mean_squared_error").mean()
    print(f"{' + '.join(cols):40s} cross-validated RMSE {rmse:.3f}")
EDUCATION             0.683
female               -4.370
EDUCATION x female    0.173
dtype: float64
EDUCATION + female                       cross-validated RMSE 4.621
EDUCATION + female + EDUCATION x female  cross-validated RMSE 4.626

A year of education adds about 0.68 USD per hour for men and 0.17 more for women. The cross-validated error does not improve, though: on 534 people the difference in slopes is too small to pay for the extra parameter. PolynomialFeatures(interaction_only=True) builds all pairwise products at once.

Splines

A spline is a piecewise polynomial, joined at a set of knots. The basis is the powers of \(x\) up to the degree, then one hinge \(\max(0, x-k)^{\text{degree}}\) per knot.

def spline_features(x, degree, knots):
    """Columns x, x^2, ..., x^degree, then one hinge per knot."""
    cols = [x ** d for d in range(1, degree + 1)]
    cols += [np.maximum(0, x - k) ** degree for k in knots]
    return np.column_stack(cols)


age = wage["AGE"].to_numpy(dtype=float)
H = spline_features(age, degree=3, knots=[30, 40, 50, 60])
print("one column per power, one per knot:", H.shape)
one column per power, one per knot: (534, 7)

Fitting wage against age on that basis is ordinary least squares again.

Wage against age on the 1985 CPS data, fitted on the spline basis above, with the degree and the four knots on sliders. The fit is redone in the browser each time.

Wage rises steeply through the twenties, flattens through the forties and turns down after sixty, which no low degree polynomial does. A knot placed where the curve is already flat changes almost nothing; the knots that matter are the ones where the slope changes.

In practice, SplineTransformer builds the basis. It uses B-splines, which span the same functions as the powers and hinges above but are far better conditioned, and it places the knots at quantiles of the data.

from sklearn.preprocessing import SplineTransformer

spline = Pipeline([("basis", SplineTransformer(n_knots=6, degree=3)),
                   ("regressor", LinearRegression())])
spline.fit(wage[["AGE"]], wage["WAGE"])
print("columns of the basis:", spline[0].transform(wage[["AGE"]]).shape[1])
columns of the basis: 8

Some inputs are periodic. The hour of the day runs from 0 to 23, and hour 23 is next to hour 0. A periodic spline has the same value and slope at both ends. We predict the Luzern wind peak five hours ahead from the hour of the day alone, three ways.

weather = pd.read_csv("https://go.epfl.ch/bio322-weather2015-2018.csv")
hour = (weather["time"] % 100).iloc[:-5].to_frame("hour")
peak = weather["LUZ_wind_peak"].iloc[5:].to_numpy()

models = {
    "hour as a number": LinearRegression(),
    "one-hot, 24 columns": Pipeline([("onehot", OneHotEncoder()),
                                     ("regressor", LinearRegression())]),
    "periodic spline": Pipeline([
        ("basis", SplineTransformer(n_knots=7, degree=3, extrapolation="periodic")),
        ("regressor", LinearRegression())]),
}
for name, model in models.items():
    rmse = -cross_val_score(model, hour, peak, cv=cv,
                            scoring="neg_root_mean_squared_error").mean()
    print(f"{name:22s} cross-validated RMSE {rmse:.3f}")
1
time is written as YYYYMMDDHH, so the last two digits are the hour.
2
The wind peak five hours after each row, as in week 1.
hour as a number       cross-validated RMSE 10.303
one-hot, 24 columns    cross-validated RMSE 9.990
periodic spline        cross-validated RMSE 9.985

The hour as a single number forces the wind peak to rise or fall steadily through the day and jump back at midnight. One-hot coding and the periodic spline both follow the daily cycle and reach the same error; the spline does it with a smooth curve and fewer parameters.

drawing the three fits
import matplotlib.pyplot as plt

hours = pd.DataFrame({"hour": np.arange(24)})
fig, ax = plt.subplots(figsize=(8.8, 3.0))
ax.plot(hours["hour"], pd.Series(peak).groupby(hour["hour"].to_numpy()).mean(),
        "o", label="mean over the four years")
for name, model in models.items():
    ax.plot(hours["hour"], model.fit(hour, peak).predict(hours), label=name)
ax.set(xlabel="hour of the day", ylabel="wind peak in 5 h [km/h]", xticks=range(0, 24, 3))
ax.legend()
plt.show()
Figure 23.1: The mean wind peak five hours ahead, against the hour of the day, and the three fits.

Transforming the output

Sometimes the response itself is the problem. It may be reasonable to transform the output such that its distribution is closer to normal. Another reason is an output that is strictly positive and has strong outliers above the mean: a linear regression is pulled towards the outliers and can nevertheless predict negative values.

The wind peak in Luzern is such a variable. Its distribution is far from normal; its logarithm is much closer.

drawing the two histograms
from scipy import stats

wind = weather["LUZ_wind_peak"]
fig, axes = plt.subplots(1, 2, figsize=(8.8, 3.0))
for ax, values, label in [(axes[0], wind, "wind peak [km/h]"),
                          (axes[1], np.log(wind), "log(wind peak)")]:
    ax.hist(values, bins=80, density=True, alpha=0.6)
    v = np.linspace(values.min(), values.max(), 300)
    ax.plot(v, stats.norm.pdf(v, *stats.norm.fit(values)), label="fitted normal")
    ax.set(xlabel=label, ylabel="density")
axes[0].legend()
plt.show()
Figure 23.2: The wind peak and its logarithm, each with the normal distribution fitted to it.

There are two ways out. We can take the logarithm of the response, fit a linear model and take the exponential of its predictions. Or we can keep the response and change the noise model, for example to a Gamma distribution. Here all three predict the wind peak from the pressure in Luzern.

from sklearn.linear_model import GammaRegressor
from sklearn.preprocessing import StandardScaler

X, y = weather[["LUZ_pressure"]], weather["LUZ_wind_peak"]

linreg = LinearRegression().fit(X, y)
loglinreg = LinearRegression().fit(X, np.log(y))
gamreg = Pipeline([("scaler", StandardScaler()),
                   ("regressor", GammaRegressor(alpha=0))]).fit(X, y)

for name, pred in [("linear", linreg.predict(X)),
                   ("linear on log(y)", np.exp(loglinreg.predict(X))),
                   ("gamma", gamreg.predict(X))]:
    print(f"{name:17s} mean prediction {pred.mean():6.2f}")
print(f"{'data':17s} mean             {y.mean():6.2f}")
1
GammaRegressor is fitted by gradient-based optimization, which does not converge on pressures around 960 hPa; standardized, it does. alpha=0 turns its default penalty off.
linear            mean prediction  13.67
linear on log(y)  mean prediction  11.32
gamma             mean prediction  13.67
data              mean              13.67
drawing it
grid = pd.DataFrame({"LUZ_pressure": np.linspace(920, 1020, 200)})
fig, ax = plt.subplots(figsize=(8.8, 3.6))
ax.hist2d(X["LUZ_pressure"], y, bins=(120, 100), cmap="Greys", cmin=1, rasterized=True)
ax.plot(grid, linreg.predict(grid), lw=2, label="linear regression")
ax.plot(grid, np.exp(loglinreg.predict(grid)), lw=2, label="linear regression on log(wind peak)")
ax.plot(grid, gamreg.predict(grid), lw=2, label="gamma regression")
ax.axhline(0, color="#7a838b", lw=0.8, ls="--")
ax.set(xlabel="LUZ_pressure [hPa]", ylabel="LUZ_wind_peak [km/h]", ylim=(-10, 80))
ax.legend()
plt.show()
Figure 23.3: The wind peak against the pressure in Luzern, with the three fits, extended beyond the pressures in the data.

The linear regression has exactly the disadvantages above: it is biased towards the strong outliers, and beyond about 1008 hPa it predicts a negative wind peak. The exponential of the linear regression on the logarithm is always positive and less biased towards the outliers. The Gamma regression is always positive too.

The two are not the same, though. A linear model on \(\log y\) models the mean of \(\log y\), and \(\exp\) of that is not the mean of \(y\): its predictions average less than the data do. The Gamma model works with \(y\) directly, and its mean prediction matches the data.

What can we conclude from the marginal \(p(y)\)?

The histogram of the wind peak above is the marginal density \(p(y)\), which differs from the conditional density \(p(y|x)\) that we try to fit in supervised learning. Given the density of inputs \(p(x)\), the density of outputs is \(p(y) = \int p(y|x)\,p(x)\,dx\). A few artificial examples show that it is very difficult to infer the model or the noise distribution from \(p(y)\) alone.

the plotting function
def example_plots(x, y, with_log=False, with_laplace=False):
    fig, axes = plt.subplots(1, 4, figsize=(9.6, 2.4), layout="constrained")
    axes[0].hist(x, bins=75, density=True, alpha=0.6)
    axes[0].set(title="marginal input p(x)", xlabel="x")
    axes[1].hist2d(x, y, bins=75, cmap="Greys", cmin=1, rasterized=True)
    axes[1].set(title="conditional p(y|x)", xlabel="x", ylabel="y")
    for ax, v, name in [(axes[2], y, "y"), (axes[3], np.log(y) if with_log else None, "log(y)")]:
        if v is None:
            ax.axis("off")
            continue
        ax.hist(v, bins=75, density=True, alpha=0.6)
        g = np.linspace(v.min(), v.max(), 300)
        ax.plot(g, stats.norm.pdf(g, *stats.norm.fit(v)), label="normal fit")
        if with_laplace and name == "y":
            ax.plot(g, stats.laplace.pdf(g, *stats.laplace.fit(v)), label="Laplace fit")
        ax.set(title=f"marginal p({name})", xlabel=name)
    axes[2].legend(fontsize=7)
    plt.show()

rng = np.random.default_rng(3)
n = 5000

\(y = 2x + 0.1 + \varepsilon\), with normal input and normal noise.

Code
x = rng.standard_normal(n)
example_plots(x, 2 * x + 0.1 + 0.75 * rng.standard_normal(n))

The response looks clearly normally distributed.

\(y = 2x^2 + 3 + \varepsilon\), with normal input and normal noise.

Code
x = rng.standard_normal(n)
example_plots(x, 2 * x ** 2 + 3 + 0.75 * rng.standard_normal(n), with_log=True)

Neither \(p(y)\) nor the marginal of \(\log y\) looks normal, although the conditional \(p(y|x)\) is a normal distribution with mean \(2x^2 + 3\).

\(y = 2x + 8.1 + \varepsilon\), with normal noise, but a mixture of two normal distributions as input.

Code
x = np.concatenate([rng.standard_normal(3 * n // 4), rng.standard_normal(n // 4) + 3])
example_plots(x, 2 * x + 8.1 + 0.75 * rng.standard_normal(len(x)), with_log=True)

\(p(y)\) has a second bump and does not look normal, even though the conditional \(p(y|x)\) is normal with a mean that depends linearly on \(x\). The marginal of \(\log y\) looks close to normal, which would suggest a transformation the model does not need.

\(y = 2.1^{x/4 + 2 + \varepsilon}\), with normal input and normal noise. The conditional is not normal, because \(\varepsilon\) appears in the exponent.

Code
x = rng.standard_normal(n)
example_plots(x, 2.1 ** (x / 4 + 2 + 0.75 * rng.standard_normal(n)), with_log=True)

The marginal of \(\log y\) looks normal.

\(y = x/4 + 2 + \varepsilon\), with normal input and Laplace noise.

Code
x = rng.standard_normal(n)
example_plots(x, x / 4 + 2 + rng.laplace(0, 0.5, n), with_laplace=True)

The Laplace fit matches \(p(y)\) better than the normal fit, because the noise is much larger than the spread that \(x/4\) adds.