Chapter 12 — Preprocessing Pipelines
| Chapter purpose This chapter shows how to combine data preparation and model training into reliable, reusable scikit-learn pipelines. The goal is to prevent leakage, guarantee consistent transformations, simplify evaluation, and make the entire machine-learning workflow reproducible. |
Learning objectives
- Explain why preprocessing pipelines are safer than manually transforming training and test data.
- Build sequential workflows with scikit-learn Pipeline.
- Use ColumnTransformer to apply different transformations to numerical and categorical columns.
- Combine imputation, scaling, encoding, and model fitting in one reusable object.
- Inspect fitted pipeline components, generated feature names, and learned statistics.
- Diagnose common pipeline errors and design reusable workflows for classification and regression.
12.1 Why pipelines are essential
A supervised-learning workflow usually contains more than the model itself. Raw data may require missing-value treatment, scaling, encoding, feature selection, and other transformations before a learning algorithm can use it. A pipeline packages these steps into a single estimator-like object.
Preventing leakage
Data leakage occurs when information from validation or test examples influences the transformations learned during training. For example, computing a mean on the entire dataset before splitting allows information from the future test set to influence the training process. Pipelines fit transformers only on the training portion passed to fit().
| Leakage rule Any transformation that learns something from data — means, medians, standard deviations, category vocabularies, selected features, dimensionality reductions, and target-dependent statistics — must be fitted only on training data. |
Python 12.1 — Manual leakage-safe scaling
| from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # Correct: fit only on the training data scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) |
Applying transformations consistently
A common production failure is applying one set of preprocessing rules during training and a slightly different set during prediction. A pipeline removes this mismatch because the same fitted transformations are automatically applied before every prediction.
Simplifying model evaluation
Because a pipeline behaves like a regular estimator, it can be passed directly to cross-validation functions, search procedures, scoring utilities, and prediction APIs. Cross-validation then refits preprocessing separately inside each training fold, which is critical for honest evaluation.
Reproducing experiments
A pipeline explicitly records the ordered sequence of transformations and the estimator. This makes experiments easier to rerun and review because preprocessing is no longer hidden in scattered notebook cells.
Combining preprocessing with training
The most useful pipeline design places all trainable transformations before the final estimator. A call to fit() then learns preprocessing parameters and model parameters together, while predict() performs all required transformations automatically.
Supporting hyperparameter tuning
Pipeline components are named, which means their parameters can be tuned with GridSearchCV or RandomizedSearchCV using the syntax step_name__parameter_name.
Benefit | Without a pipeline | With a pipeline |
| Leakage control | Easy to fit preprocessing too early | Transformers are refitted within each training split |
| Consistency | Manual train/test transformation code | Same fitted chain used everywhere |
| Evaluation | More bookkeeping | Works directly with cross-validation |
| Reproducibility | Steps can be scattered | Workflow represented as one object |
| Tuning | Separate preprocessing/model logic | Search can tune every named step |
| Deployment | Multiple objects to coordinate | One fitted object can be saved and loaded |
12.2 scikit-learn Pipeline
Pipeline represents a sequence of processing steps. Every intermediate step must implement fit() and transform(). The final step normally implements fit() and predict(), such as a classifier or regressor.
Sequential processing steps
- Raw input data enters the first transformer.
- The first transformer learns its parameters during fit() and transforms the data.
- The transformed output becomes the input of the next step.
- The final estimator learns from the fully processed feature matrix.
- During predict(), the already-fitted transformations are applied in the same order before the estimator predicts.
Python 12.2 — A basic numerical classification pipeline
| from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression pipe = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('model', LogisticRegression(max_iter=1000)) ]) pipe.fit(X_train, y_train) y_pred = pipe.predict(X_test) |
Transformer steps
Typical transformer steps include SimpleImputer, StandardScaler, MinMaxScaler, OneHotEncoder, PolynomialFeatures, PCA, and feature-selection transformers. Each transformer is fitted in sequence using the output of the previous step.
Final estimator
The last step is the model that performs the supervised task. Examples include LogisticRegression, Ridge, RandomForestClassifier, RandomForestRegressor, SVC, KNeighborsClassifier, and gradient-boosted estimators.
Naming steps
Step names should be short, descriptive, and unique. Good names such as imputer, scaler, encoder, selector, and model make inspection and parameter tuning easier.
Python 12.3 — Accessing named pipeline steps
| print(pipe.named_steps.keys()) print(pipe.named_steps['imputer']) print(pipe.named_steps['scaler']) print(pipe.named_steps['model']) |
Accessing fitted components
After fitting, the trained transformers and estimator can be inspected through named_steps. This is useful for auditing learned statistics and debugging unexpected model behavior.
Python 12.4 — Inspecting learned quantities
| pipe.fit(X_train, y_train) median_values = pipe.named_steps['imputer'].statistics_ means = pipe.named_steps['scaler'].mean_ scales = pipe.named_steps['scaler'].scale_ coefficients = pipe.named_steps['model'].coef_ print('Imputation values:', median_values) print('Scaling means:', means) print('Scaling std:', scales) print('Model coefficients:', coefficients) |
Predicting with the complete pipeline
Prediction should be made on raw feature rows that match the expected schema. The pipeline applies each fitted transformation automatically, so the caller should not separately scale or encode the new data.
Python 12.5 — Predicting through the complete pipeline
| # Raw rows — no manual scaling required new_predictions = pipe.predict(X_new) new_probabilities = pipe.predict_proba(X_new)[:, 1] print(new_predictions) print(new_probabilities) |
12.3 ColumnTransformer
Real tabular datasets usually contain mixed feature types. Numerical columns may require median imputation and scaling, while categorical columns may require most-frequent imputation and one-hot encoding. ColumnTransformer applies different preprocessing pipelines to selected groups of columns and concatenates the resulting features.
Applying different transformations to different columns
Python 12.6 — Separate numerical and categorical preprocessing
| from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.impute import SimpleImputer from sklearn.pipeline import Pipeline numeric_features = ['age', 'income', 'tenure_months'] categorical_features = ['region', 'contract_type'] numeric_pipeline = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()) ]) categorical_pipeline = Pipeline([ ('imputer', SimpleImputer(strategy='most_frequent')), ('encoder', OneHotEncoder(handle_unknown='ignore')) ]) preprocessor = ColumnTransformer([ ('num', numeric_pipeline, numeric_features), ('cat', categorical_pipeline, categorical_features) ]) |
Numerical preprocessing
Numerical pipelines often begin with imputation and continue with a scaler. The exact choice depends on the model and feature distribution. Tree models may not need scaling, but imputation may still be necessary.
Categorical preprocessing
Categorical pipelines typically impute missing labels and then encode them. OneHotEncoder(handle_unknown="ignore") is a robust default for many low- to moderate-cardinality nominal variables because unseen categories do not crash prediction.
Passing through selected columns
Some columns may already be usable as-is. The special transformer value "passthrough" keeps these columns unchanged in the transformed matrix.
Python 12.7 — Passing selected features through unchanged
| preprocessor = ColumnTransformer( transformers=[ ('num', numeric_pipeline, numeric_features), ('cat', categorical_pipeline, categorical_features), ('keep', 'passthrough', ['is_premium_member']) ], remainder='drop' ) |
Dropping unwanted columns
Columns not listed in a ColumnTransformer are dropped by default when remainder="drop". This makes the model input schema explicit and helps prevent accidental use of identifiers or leakage variables.
Column group | Typical operations | Example |
| Numerical | Impute → scale | age, salary, temperature |
| Categorical | Impute → one-hot encode | city, contract, product |
| Binary already numeric | Pass through | is_active, has_discount |
| Identifier / leakage | Drop | customer_id, post_event_status |
12.4 Complete preprocessing pipeline
A production-ready tabular workflow typically nests small pipelines inside a ColumnTransformer and then places that preprocessor inside a top-level Pipeline with the supervised estimator.
Typical structure
- Numerical imputation.
- Numerical scaling.
- Categorical imputation.
- Categorical encoding.
- Model training.
Python 12.8 — Complete classification pipeline
| from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline classification_pipeline = Pipeline([ ('preprocess', preprocessor), ('model', LogisticRegression(max_iter=1000)) ]) classification_pipeline.fit(X_train, y_train) print(classification_pipeline.score(X_test, y_test)) |
The same preprocessor can often be reused with a regression estimator when the feature schema is the same.
Python 12.9 — Reusing preprocessing for regression
| from sklearn.ensemble import RandomForestRegressor regression_pipeline = Pipeline([ ('preprocess', preprocessor), ('model', RandomForestRegressor( n_estimators=300, random_state=42 )) ]) regression_pipeline.fit(X_train_reg, y_train_reg) pred = regression_pipeline.predict(X_test_reg) |
Pipeline and cross-validation
Passing the entire pipeline to cross-validation is safer than preprocessing once beforehand. Each fold learns its own imputation values, scaling statistics, category vocabulary, and model parameters using only the fold’s training subset.
Python 12.10 — Leakage-safe cross-validation of the full pipeline
| from sklearn.model_selection import cross_val_score, StratifiedKFold cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score( classification_pipeline, X, y, cv=cv, scoring='f1' ) print('Fold F1:', scores) print('Mean F1:', scores.mean()) print('Std F1:', scores.std()) |
Pipeline and hyperparameter tuning
Parameters inside nested components are addressed using double underscores. The search procedure refits the complete preprocessing chain for every candidate configuration and validation fold.
Python 12.11 — Tuning preprocessing and model parameters together
| from sklearn.model_selection import GridSearchCV param_grid = { 'preprocess__num__imputer__strategy': ['mean', 'median'], 'model__C': [0.1, 1.0, 10.0] } search = GridSearchCV( classification_pipeline, param_grid=param_grid, cv=5, scoring='f1', n_jobs=-1 ) search.fit(X_train, y_train) print(search.best_params_) print(search.best_score_) |
12.5 Pipeline inspection
Pipelines improve encapsulation, but practitioners still need to inspect what happened inside them. Inspection is especially important when one-hot encoding changes the number of columns or when a model behaves unexpectedly.
Feature names after transformation
Python 12.12 — Recovering transformed feature names
| classification_pipeline.fit(X_train, y_train) prep = classification_pipeline.named_steps['preprocess'] feature_names = prep.get_feature_names_out() print(feature_names) print('Number of transformed features:', len(feature_names)) |
Number of generated features
One categorical input may become many binary columns. Comparing the raw and transformed dimensions helps detect unexpectedly high cardinality or accidental inclusion of identifier-like columns.
Python 12.13 — Comparing raw and transformed dimensions
| X_transformed = prep.transform(X_train) print('Raw shape:', X_train.shape) print('Transformed shape:', X_transformed.shape) |
Fitted statistics
Python 12.14 — Auditing fitted preprocessing statistics
| num_pipe = prep.named_transformers_['num'] cat_pipe = prep.named_transformers_['cat'] print('Numeric imputation values:') print(num_pipe.named_steps['imputer'].statistics_) print('Numeric means learned by scaler:') print(num_pipe.named_steps['scaler'].mean_) print('Categories learned by encoder:') for values in cat_pipe.named_steps['encoder'].categories_: print(values) |
Debugging pipeline errors
Symptom | Likely cause | Diagnostic / fix |
| Unknown category error | Encoder configured to reject unseen values | Use handle_unknown="ignore" when appropriate |
| Could not convert string to float | Categorical column reached numeric model unencoded | Check ColumnTransformer column lists |
| NaN accepted during fit but failure later | Missing-value handling incomplete | Inspect missingness and imputer placement |
| Different feature count at prediction | Manual preprocessing or schema mismatch | Predict using raw columns through one fitted pipeline |
| Unexpected huge sparse matrix | High-cardinality category one-hot encoded | Inspect feature names and cardinalities |
| Cross-validation score much lower | Previous manual preprocessing leaked information | Trust fold-safe pipeline evaluation |
Python 12.15 — Pipeline debugging checklist
| # A compact inspection checklist print(classification_pipeline) print(classification_pipeline.get_params().keys()) prep = classification_pipeline.named_steps['preprocess'] print(prep.transformers_) print(prep.get_feature_names_out()) |
Practical lab — Build reusable classification and regression pipelines
In this lab, students construct a reusable preprocessing system for a mixed-type customer dataset. The same preprocessing logic will be paired with both a classifier and a regressor.
Scenario
A subscription company stores customer age, monthly charges, tenure, region, contract type, and premium membership. Some numerical and categorical values are missing. The company wants two models: one to predict churn and another to predict next-month spending.
Lab objectives
- Identify numerical and categorical columns.
- Create separate numerical and categorical preprocessing pipelines.
- Combine them with ColumnTransformer.
- Build a classification pipeline and a regression pipeline.
- Evaluate the classifier with stratified cross-validation.
- Evaluate the regressor with K-fold cross-validation.
- Inspect transformed feature names and fitted statistics.
- Test prediction on new raw records containing an unseen category.
Step 1 — Create the example dataset
Lab code 12.A — Generate a mixed-type dataset
| import numpy as np import pandas as pd rng = np.random.default_rng(42) n = 500 df = pd.DataFrame({ 'age': rng.integers(18, 75, n).astype(float), 'monthly_charges': rng.normal(70, 25, n).round(2), 'tenure_months': rng.integers(1, 72, n).astype(float), 'region': rng.choice(['North', 'South', 'East', 'West'], n), 'contract_type': rng.choice(['Monthly', 'Annual', 'Two-year'], n), 'is_premium_member': rng.integers(0, 2, n) }) # Add some missing values for col in ['age', 'monthly_charges', 'region']: idx = rng.choice(df.index, size=20, replace=False) df.loc[idx, col] = np.nan # Two targets for the practical lab logit = -1.5 + 0.02 * df['monthly_charges'].fillna(70) - 0.03 * df['tenure_months'] p = 1 / (1 + np.exp(-logit)) df['churn'] = rng.binomial(1, p) df['next_month_spend'] = ( 20 + 0.8 * df['monthly_charges'].fillna(70) + 0.25 * df['tenure_months'] + rng.normal(0, 12, n) ) |
Step 2 — Define features and preprocessing
Lab code 12.B — Build the reusable preprocessor
| from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import OneHotEncoder, StandardScaler features = [ 'age', 'monthly_charges', 'tenure_months', 'region', 'contract_type', 'is_premium_member' ] X = df[features] numeric_features = ['age', 'monthly_charges', 'tenure_months'] categorical_features = ['region', 'contract_type'] numeric_pipe = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()) ]) categorical_pipe = Pipeline([ ('imputer', SimpleImputer(strategy='most_frequent')), ('encoder', OneHotEncoder(handle_unknown='ignore')) ]) preprocessor = ColumnTransformer([ ('num', numeric_pipe, numeric_features), ('cat', categorical_pipe, categorical_features), ('binary', 'passthrough', ['is_premium_member']) ]) |
Step 3 — Classification model
Lab code 12.C — Evaluate the classification pipeline
| from sklearn.linear_model import LogisticRegression from sklearn.model_selection import StratifiedKFold, cross_val_score classifier = Pipeline([ ('preprocess', preprocessor), ('model', LogisticRegression(max_iter=1000)) ]) y_class = df['churn'] cv_class = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) f1_scores = cross_val_score( classifier, X, y_class, cv=cv_class, scoring='f1' ) print('Classification mean F1:', f1_scores.mean()) |
Step 4 — Regression model
Lab code 12.D — Evaluate the regression pipeline
| from sklearn.linear_model import Ridge from sklearn.model_selection import KFold, cross_val_score regressor = Pipeline([ ('preprocess', preprocessor), ('model', Ridge(alpha=1.0)) ]) y_reg = df['next_month_spend'] cv_reg = KFold(n_splits=5, shuffle=True, random_state=42) mae_scores = -cross_val_score( regressor, X, y_reg, cv=cv_reg, scoring='neg_mean_absolute_error' ) print('Regression mean MAE:', mae_scores.mean()) |
Step 5 — Fit, inspect, and test unseen categories
Lab code 12.E — Production-style prediction with an unseen category
| classifier.fit(X, y_class) prep = classifier.named_steps['preprocess'] print(prep.get_feature_names_out()) new_customer = pd.DataFrame([{ 'age': 34, 'monthly_charges': 92.0, 'tenure_months': 7, 'region': 'Central', # unseen category 'contract_type': 'Monthly', 'is_premium_member': 1 }]) print('Predicted class:', classifier.predict(new_customer)) print('Churn probability:', classifier.predict_proba(new_customer)[:, 1]) |
Lab questions
- Why must the imputer and scaler be placed inside the cross-validated pipeline?
- What is the role of handle_unknown="ignore" in this example?
- Why can the same ColumnTransformer be reused for both targets?
- How many transformed features are generated after one-hot encoding?
- What would happen if customer_id were accidentally included and one-hot encoded?
- Which preprocessing steps could be removed if the final model were a random forest?
Expected deliverable
- A notebook containing the dataset preparation and both complete pipelines.
- Cross-validation results for classification and regression.
- A table describing the preprocessing applied to each feature group.
- The transformed feature names and feature count.
- A short explanation of how the pipeline prevents leakage.
- A successful prediction for a raw record containing an unseen category.
End-of-chapter review
Key ideas to remember
- Fit preprocessing only on training data; a pipeline automates this discipline.
- Pipeline chains sequential transformations and a final estimator.
- ColumnTransformer routes different column groups through different preprocessing procedures.
- The complete pipeline should normally receive raw features and produce predictions.
- Cross-validation and hyperparameter tuning should evaluate the full pipeline rather than preprocessed data created in advance.
- Inspect feature names, dimensions, learned imputation statistics, scaling parameters, and encoder categories when debugging.
- A saved fitted pipeline is easier to deploy reliably than multiple disconnected preprocessing objects.
Knowledge check
Question | Answer |
| 1. Why is fitting StandardScaler before cross-validation dangerous? | Because the scaler learns statistics from observations that later serve as validation data, causing leakage. |
| 2. What must every intermediate Pipeline step implement? | fit() and transform(). |
| 3. What does ColumnTransformer solve? | It applies different transformations to different column subsets and concatenates the results. |
| 4. How are nested parameters addressed in GridSearchCV? | Using step names separated by double underscores, such as model__C. |
| 5. Why is get_feature_names_out() useful? | It reveals the actual transformed columns after encoding and other feature-producing transformations. |
| 6. What is a good default strategy for unseen nominal categories in many one-hot encoded workflows? | OneHotEncoder(handle_unknown="ignore"), when ignoring unseen categories is appropriate for the application. |
Mini assignment
Choose a mixed-type tabular dataset. Build a complete pipeline that includes at least one numerical and one categorical transformation, compare two final estimators under the same cross-validation scheme, tune at least one preprocessing parameter and one model parameter, inspect the transformed feature names, and explain how your design prevents leakage.