MLOpsThe MLOps lifecycle · MLflow 3.17 · DVC 3.67 · FastAPI · Docker · GitHub Actions · Evidently · Python 3.11+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
50 small wins to finish your pathNext lesson →

Data transformation with ColumnTransformer

ColumnTransformer is the scikit-learn object that applies a different pipeline to each group of columns and lays the results side by side in one matrix, so text columns and number columns are prepared together in a single fitted object.

Last updated: 19 Oct, 2026 · MLOps

Data validation confirmed the file carries the columns the schema asks for, with the types it asks for. Five of those columns are text and two are numbers, and a model takes neither as it stands. This stage turns seven feature columns into a numeric matrix, and saves the object that did it.

The video builds this in two halves: a pipeline for numeric columns and a pipeline for categorical columns, each a list of named steps, then one ColumnTransformer that names which columns each pipeline owns. Two details differ here. OneHotEncoder took an argument called sparse at the time; in scikit-learn 1.9.1 it is sparse_output. And the categorical imputer fills with the most frequent category rather than a constant, so a gap does not invent a category of its own.

Grouping the columns by kind

The component asks the frame which columns are text and which are not, instead of typing two lists. A column added upstream then lands in the right branch without an edit here.

python
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
python
    def build_preprocessor(self, X: pd.DataFrame) -> ColumnTransformer:
        """One branch for text columns, one for numbers, fitted together."""
        cat = X.select_dtypes(include="object").columns.tolist()
        num = X.select_dtypes(exclude="object").columns.tolist()
Two small pipelines joined by a ColumnTransformer · from the Implementing Machine Learninng Pipelines USsing Sklearn And Python · 19:22 to 24:31

The clip builds the two pipelines in a notebook; this lesson builds the same pair inside the transformation component, with the column lists read off the frame instead of typed out.

A pipeline for the text columns

Two named steps in order. The imputer fills a gap with the category seen most often, and the encoder turns each column into one new column per category. handle_unknown="ignore" means a category that was never in the training data comes through as all zeros rather than as an error at serving time, and sparse_output=False asks for a plain array.

python
        cat_steps = Pipeline([
            ("impute", SimpleImputer(strategy="most_frequent")),
            ("encode", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
        ])

A pipeline for the score columns

The numeric branch fills gaps with the median, which a few extreme scores cannot drag around, and then scales each column to a mean of zero and a standard deviation of one. Both are learned from the training rows.

python
        num_steps = Pipeline([
            ("impute", SimpleImputer(strategy="median")),
            ("scale", StandardScaler()),
        ])

        return ColumnTransformer([("cat", cat_steps, cat), ("num", num_steps, num)])

Fitting on the training rows and transforming the test rows

The run method is where the leakage rule is enforced. The preprocessor is fitted on the training features only, and the test features are passed through transform, which uses what the fit already learned and learns nothing new.

python
    def run(self, train: pd.DataFrame, test: pd.DataFrame):
        target = self.config.target_column
        Xtr, ytr = train.drop(columns=[target]), train[target]
        Xte, yte = test.drop(columns=[target]), test[target]

        pre = self.build_preprocessor(Xtr)
        Xtr_t = pre.fit_transform(Xtr)      # fit on train only
        Xte_t = pre.transform(Xte)          # the test set is only transformed

        save_object(self.config.preprocessor_file, pre)
        return Xtr_t, ytr, Xte_t, yte

Building the preprocessor and reading the shapes

The same build, flat, with the target column named. The printed shapes are the whole point of the stage: seven columns in, nineteen out, and the test set the same width as the training set.

ExampleSeven feature columns become nineteen
target = "math_score"
Xtr, ytr = train.drop(columns=[target]), train[target]
Xte, yte = test.drop(columns=[target]), test[target]

cat = Xtr.select_dtypes(include="object").columns.tolist()
num = Xtr.select_dtypes(exclude="object").columns.tolist()

cat_steps = Pipeline([("impute", SimpleImputer(strategy="most_frequent")),
                      ("encode", OneHotEncoder(handle_unknown="ignore", sparse_output=False))])
num_steps = Pipeline([("impute", SimpleImputer(strategy="median")),
                      ("scale", StandardScaler())])
pre = ColumnTransformer([("cat", cat_steps, cat), ("num", num_steps, num)])

Xtr_t = pre.fit_transform(Xtr)   # learns the categories, medians and scales from train
Xte_t = pre.transform(Xte)       # the test rows are only transformed
print(len(cat), "text and", len(num), "numeric ->", Xtr_t.shape, Xte_t.shape)
  • Both matrices are 19 columns wide, and they have to be: a model fitted on 19 inputs cannot score a matrix of a different width.
  • The 800 and the 200 are the files ingestion wrote, carried through unchanged, so the test rows are still rows the fit never saw.
  • fit_transform ran on the training features and transform on the test features. Calling fit_transform on the test set as well would recompute the medians, the scales and the category lists from the test rows, which is the leak this stage exists to prevent.
One ColumnTransformer over a seven column frame of 800 training rows. The frame splits into two branches. The cat branch takes the five text columns, gender, race_ethnicity, parental_level_of_education, lunch and test_preparation_course, through a SimpleImputer with the most_frequent strategy and a OneHotEncoder with handle_unknown set to ignore and sparse_output set to False, giving 17 columns out. The num branch takes reading_score and writing_score through a SimpleImputer with the median strategy and a StandardScaler, giving 2 columns out. Both branches join back into one numeric matrix of shape 800 by 19, the two branches side by side. A note underneath says fit_transform on the training rows learns the categories, the medians and the scales, and the test rows are only transformed.

Where the nineteen columns come from

The width is not a setting. It is the number of categories the encoder found in each text column, plus the two scaled score columns. Saving the fitted object and loading it back shows that those counts travel with it.

ExampleThe category counts, and the reloaded preprocessor
import os
import joblib

os.makedirs("artifacts/data_transformation", exist_ok=True)
joblib.dump(pre, "artifacts/data_transformation/preprocessor.joblib")

# a fitted preprocessor carries the categories it saw, so it reloads ready to transform
reloaded = joblib.load("artifacts/data_transformation/preprocessor.joblib")
print({c: int(Xtr[c].nunique()) for c in cat})
print(reloaded.transform(Xte.head(2)).shape)
  • The five text columns carry 2, 5, 6, 2 and 2 categories, which is 17 one-hot columns, and the two score columns stay as two: 19 in total.
  • The reloaded object transformed two fresh rows into the same 19 columns, because the fit stored the category lists, the medians and the scales inside it.
  • That file is the one the serving path loads. A model shipped without the preprocessor that was fitted beside it has no way to turn a request into the matrix it was trained on.

fit_transform against transform

fit_transformtransform
Learns from the dataYes: categories, medians, scalesNo
Run onThe training rowsTest rows, and every request later
CalledOnce per training runEvery time after
Used in the wrong placeLeaks test rows into the fitFails loudly if never fitted

Where one fitted preprocessor matters

  • Mixed data, where text and numbers need different treatment but one object has to describe the whole frame.
  • Serving, where a single record has to be prepared exactly as the training rows were, with the same category order.
  • A retrain, where the new preprocessor and the new model are saved as a pair and replace the old pair together.
Watch out. The preprocessor and the model are one unit. Loading last month's preprocessor with this month's model, or the other way round, usually does not raise: the widths can still line up while the columns mean different things, and the predictions quietly go wrong. Write both to the same run's artifacts folder and load both or neither.
Try it yourself
  • Swap strategy="median" for "mean" and compare the first transformed row.
  • Pass a record whose test_preparation_course reads "unknown value" and confirm handle_unknown="ignore" lets it through as zeros.
  • Write sparse=False instead of sparse_output=False and read the TypeError that names the argument scikit-learn renamed.

This is what real progress feels like.