Overview
Preprocessing
Everything on this page happens on the right of the split. That is not a filing convention: every step here estimates something from the rows it is given β a mean, a median, a category list, a projection basis β and estimating it from rows you will later be scored on is what the leakage chapter is about.
So there is one structural rule, and then a set of choices.
The rule
Everything with a
fitgoes inside thePipeline, and thePipelineis what you cross-validate.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
numeric = Pipeline([('impute', SimpleImputer(strategy='median')),
('scale', StandardScaler())])
categorical = Pipeline([('impute', SimpleImputer(strategy='most_frequent')),
('encode', OneHotEncoder(handle_unknown='ignore'))])
prep = ColumnTransformer([('num', numeric, numeric_columns),
('cat', categorical, categorical_columns)])
model = Pipeline([('prep', prep), ('net', MLPClassifier())])
cross_val_score(model, X, y, cv=5) # every fit re-runs inside every fold
Doing it by hand is correct exactly once, for one split. The moment you cross-validate or tune, the hand-written version is fitted once while the folds change five times β and every fold is contaminated again.
The four questions
-
Scaling & Normalization
The columns are in incomparable units, so the first gradient is about the measurement system. Which scaler, and what one typo does to each of them.
-
Transforming Shape
Scaling cannot change a distribution's shape. When the shape is the problem: log, power, quantile, binning β and the criterion that is not a symmetric histogram.
-
Encoding
Turning categories into numbers is asserting which comparisons between them are meaningful. Covered with the taxonomy, on the feature types page.
-
The Curse of Dimensionality
What high dimension actually does to distances, volumes and angles β four separate phenomena, only one of which depends on your data. The reason the next page exists.
-
Dimensionality Reduction
PCA, t-SNE and UMAP answer three different questions and lose three different things. Which distortion each one introduces, and how to judge a projection.
Order of operations
Within the pipeline, the order is not arbitrary either:
flowchart LR
A["impute"] --> B["encode<br/><small>categoricals</small>"]
B --> C["transform shape<br/><small>log, power</small>"]
C --> D["scale"]
D --> E["reduce<br/><small>optional</small>"]
E --> F["model"]
classDef s fill:#eef2f7,stroke:#8b949e,color:#1f2937
class A,B,C,D,E,F s - Impute first, because everything after it breaks on
NaN. - Transform before scaling, because a log changes the mean and the standard deviation that the scaler is about to estimate.
- Scale before reducing, because PCA maximizes variance and variance has units β unstandardized, the column with the largest units becomes the first principal component by construction.
Two things that look like preprocessing and are not
Resampling for class imbalance is not a transformation of the features β it changes the rows, and it must happen inside the training fold only, never on the validation rows. sklearn's Pipeline will not do this correctly; imblearn.pipeline.Pipeline will. See class imbalance.
Cleaning β dropping duplicates, fixing a known unit error, replacing a sentinel with NaN β estimates nothing, so it belongs before the split, where it is also safer. See data quality.
A worked example
The Titanic dataset, as it arrives:
| PassengerId | Survived | Pclass | Name | Sex | Age | SibSp | Parch | Ticket | Fare | Cabin | Embarked |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 710 | 1 | 3 | Moubarek, Master. Halim Gonios ("William George") | male | nan | 1 | 1 | 2661 | 15.2458 | nan | C |
| 440 | 0 | 2 | Kvillner, Mr. Johan Henrik Johannesson | male | 31 | 0 | 0 | C.A. 18723 | 10.5 | nan | S |
| 841 | 0 | 3 | Alhomaki, Mr. Ilmari Rudolf | male | 20 | 0 | 0 | SOTON/O2 3101287 | 7.925 | nan | S |
| 721 | 1 | 2 | Harper, Miss. Annie Jessie "Nina" | female | 6 | 0 | 1 | 248727 | 33 | nan | S |
| 40 | 1 | 3 | Nicola-Yarred, Miss. Jamila | female | 14 | 1 | 0 | 2651 | 11.2417 | nan | C |
| 291 | 1 | 1 | Barber, Miss. Ellen "Nellie" | female | 26 | 0 | 0 | 19877 | 78.85 | nan | S |
| 301 | 1 | 3 | Kelly, Miss. Anna Katherine "Annie Kate" | female | nan | 0 | 0 | 9234 | 7.75 | nan | Q |
| 334 | 0 | 3 | Vander Planke, Mr. Leo Edmondus | male | 16 | 2 | 0 | 345764 | 18 | nan | S |
| 209 | 1 | 3 | Carr, Miss. Helen "Ellen" | female | 16 | 0 | 0 | 367231 | 7.75 | nan | Q |
| 137 | 1 | 1 | Newsom, Miss. Helen Monypeny | female | 19 | 0 | 2 | 11752 | 26.2833 | D47 | S |
Ten rows of the raw table: mixed types, missing ages, a categorical port of embarkation.
| Pclass | Sex | Age | SibSp | Parch | Fare | Embarked |
|---|---|---|---|---|---|---|
| 3 | male | 17.5 | 1 | 1 | 15.2458 | C |
| 2 | male | 31 | 0 | 0 | 10.5 | S |
| 3 | male | 20 | 0 | 0 | 7.925 | S |
| 2 | female | 6 | 0 | 1 | 33 | S |
| 3 | female | 14 | 1 | 0 | 11.2417 | C |
| 1 | female | 26 | 0 | 0 | 78.85 | S |
| 3 | female | 17.5 | 0 | 0 | 7.75 | Q |
| 3 | male | 16 | 2 | 0 | 18 | S |
| 3 | female | 16 | 0 | 0 | 7.75 | Q |
| 1 | female | 19 | 0 | 2 | 26.2833 | S |
import pandas as pd
# Preprocess the data
def preprocess(df):
# Fill missing values
df['Age'] = df['Age'].fillna(df['Age'].median())
df['Embarked'] = df['Embarked'].fillna(df['Embarked'].mode()[0])
df['Fare'] = df['Fare'].fillna(df['Fare'].median())
# Select features
features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked']
return df[features]
# Load the Titanic dataset
df = pd.read_csv('https://raw.githubusercontent.com/hsandmann/ml/refs/heads/main/data/kaggle/titanic-dataset.csv')
df = df.sample(n=10, random_state=42)
# Preprocessing
df = preprocess(df)
# Display the first few rows of the dataset
print(df.to_markdown(index=False))
| Pclass | Sex | Age | SibSp | Parch | Fare | Embarked |
|---|---|---|---|---|---|---|
| 3 | 0 | 16 | 0 | 0 | 7.75 | 1 |
| 2 | 1 | 31 | 0 | 0 | 10.5 | 2 |
| 1 | 0 | 26 | 0 | 0 | 78.85 | 2 |
| 3 | 1 | 17.5 | 1 | 1 | 15.2458 | 0 |
| 3 | 1 | 16 | 2 | 0 | 18 | 2 |
| 3 | 1 | 20 | 0 | 0 | 7.925 | 2 |
| 1 | 0 | 19 | 0 | 2 | 26.2833 | 2 |
import pandas as pd
from sklearn.preprocessing import LabelEncoder
# Preprocess the data
def preprocess(df):
# Fill missing values
df['Age'] = df['Age'].fillna(df['Age'].median())
df['Embarked'] = df['Embarked'].fillna(df['Embarked'].mode()[0])
df['Fare'] = df['Fare'].fillna(df['Fare'].median())
# Convert categorical variables
label_encoder = LabelEncoder()
df['Sex'] = label_encoder.fit_transform(df['Sex'])
df['Embarked'] = label_encoder.fit_transform(df['Embarked'])
# Select features
features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked']
return df[features]
# Load the Titanic dataset
df = pd.read_csv('https://raw.githubusercontent.com/hsandmann/ml/refs/heads/main/data/kaggle/titanic-dataset.csv')
df = df.sample(n=10, random_state=42)
# Preprocessing
df = preprocess(df)
# Display the first few rows of the dataset
print(df.sample(n=7, random_state=42).to_markdown(index=False))
Read that code as an illustration, not as a template
Both scripts compute their statistics on the whole table, because they are printing a table rather than training a model. In a real pipeline the median that fills Age and the categories that build the one-hot block are both fit steps, and they belong inside the Pipeline above β refitted on every fold.