Chapter 5: Preprocessing and Pipelines

By the end of this chapter, you will be able to:

  • Explain why many machine learning models require features to be preprocessed.
  • Distinguish a transformer from a predictive estimator in the scikit-learn API.
  • Use SimpleImputer, StandardScaler, OrdinalEncoder, and OneHotEncoder appropriately.
  • Explain why a transformer must be fit using only the training data available at that stage of the workflow.
  • Identify data leakage caused by preprocessing before a train/test split or outside cross-validation.
  • Build and evaluate a preprocessing pipeline using make_pipeline.
  • Explain why different feature types may require different transformations.

Chapter 4 introduced models that make predictions using distances between examples. Those models exposed an important practical problem: a distance calculation treats the numbers in a feature matrix literally, even when the numbers use very different units. Real datasets introduce further complications, including missing values and categorical features that are not numbers at all.

Preprocessing transforms the original features into a representation that a machine learning model can use. Choosing the transformations is part of model development: a useful representation can help a model find meaningful patterns, while an inappropriate representation can hide them. Just as importantly, preprocessing must respect the Golden Rule from Chapter 3. Any information learned from data during preprocessing must come only from the training data available at that point in the workflow.

TipSee also
Show imports and setup
from pathlib import Path

import numpy as np
import pandas as pd
import os

from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_validate, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler

DATA_DIR = Path("data")

Why preprocessing matters

Suppose we want to match a student with a compatible roommate. We use two features—the number of quiet hours each student prefers per week and their number of social-media connections—and measure similarity using Euclidean distance. Our student prefers 40 quiet hours per week and has 250 connections.

roommate_candidates = pd.DataFrame(
    {
        "quiet_hours_per_week": [35, 37, 40, 10],
        "connections": [400, 300, 500, 250],
    },
    index=["A", "B", "C", "D"],
)
student = pd.Series({"quiet_hours_per_week": 40, "connections": 250})

roommate_candidates.assign(
    distance=np.sqrt(((roommate_candidates - student) ** 2).sum(axis=1))
).sort_values(
    "distance"
)
quiet_hours_per_week connections distance
D 10 250 30.000000
B 37 300 50.089919
A 35 400 150.083310
C 40 500 250.000000

The calculation identifies D as the nearest neighbour, with a distance of 30. This is surprising: D prefers 30 fewer quiet hours per week than our student, whereas B differs by only 3 quiet hours and has 50 more connections. Nevertheless, B’s distance is about 50 because the 50-connection difference dominates the 3-hour difference. For roommate compatibility, we might reasonably care much more about similar living preferences than similar connection counts.

The calculation treats a difference of one connection as numerically equivalent to a difference of one quiet hour. It is mathematically valid, but the representation is the problem: hours and connections use unrelated units, and there is no reason that one unit of each should count equally. Changing connections from a count to hundreds of connections would change the proposed roommate even though the students had not changed. A model whose behaviour changes merely because we changed units is often undesirable.

Scaling with StandardScaler

One common solution is standardization. For each feature, StandardScaler subtracts the mean and divides by the standard deviation:

\[z = \frac{x - \mu}{\sigma}.\]

After this transformation, each feature in the training data has mean 0 and standard deviation 1. Values are now measured in standard deviations from that feature’s training mean. Standardization does not force values into a fixed minimum and maximum, and it does not make unlike quantities identical. It simply puts their numerical variation on a comparable scale.

scaler = StandardScaler()
candidates_scaled = scaler.fit_transform(roommate_candidates)
student_scaled = scaler.transform(student.to_frame().T)

pd.DataFrame(
    candidates_scaled,
    columns=roommate_candidates.columns,
    index=roommate_candidates.index,
).assign(
    distance=np.sqrt(((candidates_scaled - student_scaled) ** 2).sum(axis=1))
).sort_values("distance")
quiet_hours_per_week connections distance
B 0.543083 -0.650945 0.577939
A 0.375980 0.390567 1.617157
D -1.712800 -1.171700 2.506536
C 0.793736 1.432078 2.603778

The nearest neighbours change after scaling because quiet-hour preferences can now contribute meaningfully to the distance. This is why scaling is usually important for \(k\)-NN and RBF SVM models. It is also important for many models introduced later in the book. Decision trees are generally insensitive to scaling: replacing a split such as quiet_hours_per_week <= 35 with its equivalent on a standardized feature does not change which examples fall on either side.

Scaling is not automatically beneficial for every dataset or model. It encodes a modelling choice that the observed variation of each feature should initially have comparable influence. Domain knowledge may suggest a different representation.

A running example: predicting quiz 2 grades

For the rest of this chapter, we will use a small dataset about students in a fictional course. The prediction task is to predict whether a student will receive an A+ on quiz 2. The features include previous grades, university experience, major, class attendance, and whether the student enjoys the course.

quiz_data = pd.read_csv(DATA_DIR / "quiz2-grade-toy-col-transformer.csv")
quiz_data
enjoy_course ml_experience major class_attendance university_years lab1 lab2 lab3 lab4 quiz1 quiz2
0 yes 1 Computer Science Excellent 3 92 93.0 84 91 92 A+
1 yes 1 Mechanical Engineering Average 2 94 90.0 80 83 91 not A+
2 yes 0 Mathematics Poor 3 78 85.0 83 80 80 not A+
3 no 0 Mathematics Excellent 3 91 NaN 92 91 89 A+
4 yes 0 Psychology Good 4 77 83.0 90 92 85 A+
5 no 1 Economics Good 5 70 73.0 68 74 71 not A+
6 yes 1 Computer Science Excellent 4 80 88.0 89 88 91 A+
7 no 0 Mechanical Engineering Poor 3 95 93.0 69 79 75 not A+
8 no 0 Linguistics Average 2 97 90.0 94 82 80 not A+
9 yes 1 Mathematics Average 4 95 82.0 94 94 85 not A+
10 yes 0 Psychology Good 3 98 86.0 95 95 78 A+
11 yes 1 Physics Average 1 95 88.0 93 92 85 A+
12 yes 1 Physics Excellent 2 98 96.0 96 99 100 A+
13 yes 0 Mechanical Engineering Excellent 4 95 94.0 96 95 100 A+
14 no 0 Mathematics Poor 3 95 90.0 93 95 70 not A+
15 no 1 Computer Science Good 3 92 85.0 67 94 92 not A+
16 yes 0 Computer Science Average 5 75 91.0 93 86 85 A+
17 yes 1 Economics Average 3 86 89.0 65 86 87 not A+
18 no 1 Biology Good 2 91 NaN 90 88 82 not A+
19 no 0 Psychology Poor 2 77 94.0 87 81 89 not A+
20 yes 1 Linguistics Excellent 4 96 92.0 92 96 87 A+
WarningA teaching dataset, not evidence about students

This dataset contains only 21 invented examples. It is deliberately small so that we can inspect every transformation, and its patterns were constructed for teaching. Validation and test scores will be unstable and must not be interpreted as evidence that these features predict real students’ performance. In particular, variables such as major should not be treated as explanations of ability or used to make decisions about students. Our goal is to learn the mechanics of preprocessing, not to build a deployable student-performance model.

We split the data before inspecting or fitting transformations. This gives us one training set that we can carry through the rest of the chapter.

X = quiz_data.drop(columns="quiz2")
y = quiz_data["quiz2"]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=123, stratify=y
)
print('Number of training examples: ', X_train.shape)
print('Number of test examples: ', X_test.shape)
Number of training examples:  (16, 10)
Number of test examples:  (5, 10)

Transformers in scikit-learn

Chapter 2 introduced predictive estimators such as classifiers. A classifier learns a relationship between features and targets using fit(X, y), then produces target predictions using predict(X).

A transformer instead learns how to change the feature representation. It uses two main methods:

  • fit(X) learns quantities needed by the transformation. For StandardScaler, these are the mean and standard deviation of each feature.
  • transform(X) applies the learned transformation and returns a new representation.

Calling fit_transform(X) is shorthand for fitting and then transforming the same data. It is convenient for training data. For validation, test, or deployment data, we call only transform, using the transformer already fit on the training data.

numeric_features = ["university_years", "lab1", "lab2", "lab3", "lab4", "quiz1"]

scaler = StandardScaler()
grade_features = ["lab1", "lab3", "lab4", "quiz1"]
X_train_scaled = scaler.fit_transform(X_train[grade_features])
X_test_scaled = scaler.transform(X_test[grade_features])

pd.DataFrame(X_train_scaled, columns=grade_features, index=X_train.index).head()
lab1 lab3 lab4 quiz1
6 -1.272132 0.254880 -0.203214 0.676206
5 -2.505715 -1.606852 -2.370832 -1.625773
8 0.824958 0.698149 -1.132193 -0.589882
7 0.578242 -1.518198 -1.596683 -1.165377
3 0.084809 0.520842 0.261275 0.446009

The scaler learns one mean and standard deviation for each grade feature from X_train. We use those same training statistics to transform X_test; we do not refit the scaler on the test set. Refitting would use information from the test distribution and would give each feature a different meaning in the two datasets.

Some transformers accept y in fit, and a few genuinely use the target. The important question is not whether a step looks like conventional model fitting. It is whether the step learns anything from the data. If it does, it must obey the same data-separation rules as the predictive model.

Missing values and imputation

Real datasets frequently contain missing values. A sensor may fail, a survey participant may skip a question, or two tables may not match perfectly when joined. Many estimators, including KNeighborsClassifier, cannot directly process NaN values.

One option is to remove rows or features containing missing values, but this can discard useful information and can introduce bias when values are not missing at random. Another option is imputation: replacing each missing value according to a specified rule.

X_train.loc[X_train["lab2"].isna(), numeric_features]
university_years lab1 lab2 lab3 lab4 quiz1
3 3 91 NaN 92 91 89
18 2 91 NaN 90 88 82

SimpleImputer(strategy="median") learns the median of each feature during fit and uses those values to replace missing entries during transform. The median can be preferable to the mean when unusually large or small observations are present, although neither strategy reconstructs the unknown value or removes the reason it was missing.

imputer = SimpleImputer(strategy="median").set_output(transform="pandas")
X_train_numeric_imputed = imputer.fit_transform(X_train[numeric_features])
X_test_numeric_imputed = imputer.transform(X_test[numeric_features])
X_train_numeric_imputed.loc[X_train["lab2"].isna()]
university_years lab1 lab2 lab3 lab4 quiz1
3 3.0 91.0 89.5 92.0 91.0 89.0
18 2.0 91.0 89.5 90.0 88.0 82.0

The learned replacement values are available in imputer.statistics_. As with scaling, they must be learned from the training data. Computing a median using the entire dataset before splitting would let the test examples influence the representation used to train the model.

Imputation makes the data usable, but it also makes an assumption. Median imputation treats a missing entry as typical for that feature and reduces its apparent variation. In an applied project, we should investigate why values are missing and consider whether the fact that a value is missing is itself informative.

Representing categorical features

A categorical feature records membership in a set of categories, such as a neighbourhood, product type, or spoken language. Most scikit-learn estimators require numeric feature matrices, so these categories need a numerical representation. The representation should reflect what the categories mean.

Ordinal encoding

OrdinalEncoder represents each category with a different number. This is appropriate when the categories have a meaningful order, such as poor, average, and good, and we explicitly supply that order.

For an unordered—or nominal—feature such as language, arbitrary integer codes are usually misleading. A distance-based model would interpret categories coded 0 and 1 as closer than categories coded 0 and 4. The numerical ordering came from the encoding, not the problem.

attendance = X_train[["class_attendance"]]
ordinal_encoder = OrdinalEncoder(
    categories=[["Poor", "Average", "Good", "Excellent"]]
)

attendance.assign(
    attendance_encoded=ordinal_encoder.fit_transform(attendance).ravel()
).head()
class_attendance attendance_encoded
6 Excellent 3.0
5 Good 2.0
8 Average 1.0
7 Poor 0.0
3 Excellent 3.0

Even when categories are ordered, their spacing requires thought. Encoding Poor, Average, Good, and Excellent as 0, 1, 2, and 3 tells the model that the gaps are equal. Some models can use that simplification effectively, but it remains an assumption rather than a fact about the labels.

One-hot encoding

For a nominal feature, one-hot encoding creates one binary feature for each observed category. Exactly one of these new features is 1 for each original value, and the rest are 0. This avoids imposing an arbitrary order.

majors = X_train[["major"]]
one_hot_encoder = OneHotEncoder(
    handle_unknown="ignore", sparse_output=False
).set_output(transform="pandas")

one_hot_encoder.fit_transform(majors).head()
major_Biology major_Computer Science major_Economics major_Linguistics major_Mathematics major_Mechanical Engineering major_Physics major_Psychology
6 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0
5 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0
8 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0
7 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
3 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0

The encoder learns the categories during fit. At deployment time, a previously unseen category may appear. With handle_unknown="ignore", the encoder represents that value with zeros in all the learned category columns instead of raising an error. This keeps the transformed feature space consistent, but it does not teach the model what the new category means.

One-hot encoding can create many features when a column has many possible categories. Chapter 6 considers this issue, along with more complex feature types.

Preprocessing and the Golden Rule

The Golden Rule says that the test data must not influence model development. For preprocessing, the practical version is:

Split first. Fit each transformer using only the training data, then use that fitted transformer to transform both the training and test data.

This rule also applies inside cross-validation. Each fold temporarily treats part of the training set as validation data. Therefore, the transformer must be fit separately on the training portion of each fold.

Consider this tempting workflow:

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
cross_validate(knn, X_train_scaled, y_train)

Although the test set is untouched, the scaler is fit before the cross-validation folds are created. Consequently, the mean and standard deviation used in every fold include its validation examples. This is data leakage: information crosses a boundary that is supposed to simulate unseen data. The estimated validation performance may therefore be too optimistic.

The same issue occurs with imputation, category discovery, feature selection, and any other data-dependent transformation. Manually coordinating every transformation within every fold would be tedious and error-prone. A pipeline automates that coordination.

Combining preprocessing and prediction with a pipeline

A scikit-learn pipeline chains transformers followed by a final estimator. From the outside, the entire pipeline behaves like one predictive estimator: it supports fit, predict, and score. Internally, it applies the transformations in order before passing the resulting features to the model.

We will first build a pipeline using only the numeric features from the quiz-grade dataset. This restriction lets the same imputer and scaler operate on every input column. It is temporary: excluding the categorical and ordinal features sets up the problem we will solve in Chapter 6.

X_train_numeric = X_train[numeric_features]
X_test_numeric = X_test[numeric_features]
X_train_numeric.head()
university_years lab1 lab2 lab3 lab4 quiz1
6 4 80 88.0 89 88 91
5 5 70 73.0 68 74 71
8 2 97 90.0 94 82 80
7 3 95 93.0 69 79 75
3 3 91 NaN 92 91 89
quiz_pipe = make_pipeline(
    SimpleImputer(strategy="median"),
    StandardScaler(),
    KNeighborsClassifier(),
)
quiz_pipe
Pipeline(steps=[('simpleimputer', SimpleImputer(strategy='median')),
                ('standardscaler', StandardScaler()),
                ('kneighborsclassifier', KNeighborsClassifier())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

When quiz_pipe.fit(X_train_numeric, y_train) is called, the pipeline performs these operations in sequence:

  1. Fit the imputer on X_train_numeric, then transform X_train_numeric.
  2. Fit the scaler on the imputed training data, then transform those data.
  3. Fit the \(k\)-NN classifier on the resulting feature matrix and y_train.

When predict(X_test_numeric) is called, the already-fitted imputer and scaler transform X_test_numeric; neither transformer is refit. The fitted classifier then predicts from the transformed test features.

cv_results = cross_validate(
    quiz_pipe, X_train_numeric, y_train, cv=5, return_train_score=True
)
pd.DataFrame(cv_results)[["train_score", "test_score"]].agg(["mean", "std"]).T
mean std
train_score 0.719231 0.037488
test_score 0.633333 0.341565

Passing the pipeline to cross_validate is crucial. For each fold, scikit-learn fits a fresh copy of the entire pipeline on that fold’s training portion. The imputer and scaler therefore learn nothing from the validation portion. The fitted transformations are applied to the validation data before scoring.

After using cross-validation to make modelling decisions, we can fit the selected pipeline on all of X_train and evaluate it once on X_test.

quiz_pipe.fit(X_train_numeric, y_train)
quiz_pipe.score(X_test_numeric, y_test)
0.6

The test score is not automatically trustworthy merely because we used a pipeline. We must still avoid repeatedly checking it while selecting features, transformations, models, or hyperparameters. A pipeline protects the boundaries within a specified evaluation procedure; it cannot prevent us from choosing the procedure based on test results. Moreover, this particular score is based on only a handful of invented test examples. It demonstrates the API, not the expected performance of a real student model.

Different transformations for different features

A plain pipeline applies every transformer to every feature it receives. Our quiz-grade dataset contains numeric grades, the nominal feature major, the ordinal feature class_attendance, and binary features. We want to impute and scale the numeric features, one-hot encode nominal features, and ordinally encode attendance. Applying StandardScaler to a major would fail, while one-hot encoding a numeric grade would create a large and unhelpful feature matrix.

The next chapter introduces ColumnTransformer, which applies separate preprocessing pipelines to specified groups of columns and combines their outputs. A ColumnTransformer can itself become the first step of a larger pipeline ending in a predictive estimator.

Exercises

Exercise 5.1: Predict before running

Suppose we measure distance using annual income in dollars and number of late payments. Predict which feature will usually dominate before scaling. Would changing income from dollars to thousands of dollars affect the model? Explain why this is a problem even if the code runs.

Exercise 5.2: Find the leakage

A student imputes and scales the complete dataset, then calls train_test_split. A second student splits first, but scales all of X_train before calling cross_validate. Explain the leakage in each workflow and describe how to correct it.

Exercise 5.3: Choose a representation

For each feature, choose one-hot encoding, ordinal encoding, scaling, or no transformation. State any assumptions: (1) product weight in grams, (2) shirt size recorded as small, medium, or large, (3) Canadian province or territory, and (4) a numeric customer identifier.

Exercise 5.4: Trace a pipeline

A pipeline contains a median imputer, a standard scaler, and a \(k\)-NN classifier. Describe which methods are called on each component when one cross-validation fold is fit, when that fold is validated, and when predict is later called on deployment data. Which learned quantities remain unchanged?

Summary

  • Preprocessing transforms raw features into a representation a model can use.
  • Scaling prevents numerical units from dominating distance-based models, while imputation handles missing values.
  • Ordinal encoding represents meaningfully ordered categories; one-hot encoding avoids imposing order on nominal categories.
  • Fit every transformer using only the available training data, including the training portion of each cross-validation fold.
  • A scikit-learn pipeline keeps preprocessing and prediction together, making leakage less likely.