← All Posts
Deep Learning · Data Preparation and Preprocessing · Pipelines and Reproducibility

Column Transformer

A numerical age and a city name need different preprocessing. Scaling the whole table cannot encode a city, and one-hot encoding every value would discard the numerical meaning of age. ColumnTransformer selects columns, applies a separate transform to each selection, and joins the resulting feature blocks.

Use a Pipeline when one operation must consume the previous operation’s output. Use ColumnTransformer when different column groups need different operations. A common model contains both: a ColumnTransformer with small pipelines inside its branches, followed by an estimator.

Define the reference table and the output features

We have three training rows. Age is numerical; city is categorical. The record identifier is present for tracing rows but is deliberately excluded from this model.

Record IDAgeCity
u120Delhi
u2missingPune
u340Delhi

The numerical branch first fills missing ages with the observed training mean, 30. It then standardizes [20, 30, 40], learning center 30 and standard deviation √(200/3), approximately 8.165. The categorical branch learns the ordered one-hot vocabulary [Delhi, Pune].

We list the numerical branch first, so the output columns will be [standardized age, city is Delhi, city is Pune]. The output order follows the transformer list, then the columns emitted by each branch.

One raw row becomes three model features
Raw future rowage: 50 · city: Pune · record_id: u4
↓ Numerical branch · age50 → impute: 50 → scale: 2.4495Saved fill and center: 30. Saved divisor: about 8.165.
↓ Categorical branch · cityPune → [0, 1]Saved order: [Delhi, Pune]. An unknown city produces [0, 0] under our chosen policy.
↓ Join numerical block, then categorical block[2.4495, 0.0000, 1.0000]
The output has three columns. record_id is dropped. Every branch reuses its fitted state.
The two branches receive their selected raw columns. Their outputs are concatenated horizontally for the same row. No branch uses the output of its neighbor.

Parallel column branches, sequential steps inside a branch

Putting an imputer and scaler next to each other in the ColumnTransformer list does not chain them. If both select age, both start from the original age column and each emits its own output block. The scaler never sees the imputer’s result.

Two separate age branchesMissing → imputer → 30Missing → scaler → NaNJoined output: [30, NaN]The scaler sees the original missing value and preserves it.
One age branch containing a PipelineMissing → imputer → 30 → scaler → 0Numerical output: [0]The scaler was fitted on filled training ages, then transforms filled future ages.
Nesting determines the data flow. Separate branches can intentionally create multiple views of one column, but they do not provide sequential preprocessing.

Here “parallel” describes independent branches. It does not require simultaneous execution on multiple CPU cores.

Build the numerical pipeline inside ColumnTransformer

A branch is a three-part tuple: (name, transformer, columns). The name identifies its fitted state and parameters. The column selection tells it which raw input to receive.

Run the complete mixed-column example
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

train = pd.DataFrame({
    "age": [20., np.nan, 40.],
    "city": ["Delhi", "Pune", "Delhi"],
    "record_id": ["u1", "u2", "u3"],
})
numeric = Pipeline([
    ("impute", SimpleImputer(strategy="mean")),
    ("scale", StandardScaler()),
])
prep = ColumnTransformer([
    ("num", numeric, ["age"]),
    ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), ["city"]),
], remainder="drop")
train_features = prep.fit_transform(train)
future = pd.DataFrame({
    "age": [50., np.nan],
    "city": ["Pune", "Mumbai"],
    "record_id": ["u4", "u5"],
})
print(prep.get_feature_names_out().tolist())
print(train_features.round(4).tolist())
print(prep.transform(future).round(4).tolist())
print(prep.output_indices_["num"], prep.output_indices_["cat"])
['num__age', 'cat__city_Delhi', 'cat__city_Pune']
[[-1.2247, 1.0, 0.0], [0.0, 0.0, 1.0], [1.2247, 1.0, 0.0]]
[[2.4495, 0.0, 1.0], [0.0, 0.0, 0.0]]
slice(0, 1, None) slice(1, 3, None)

The encoder returns dense output here to keep the tiny result easy to inspect. Its handle_unknown="ignore" policy preserves output width for Mumbai by emitting zeros. Those zeros do not identify which unseen city occurred. The one-hot chapter compares unknown and rare-category policies.

Select the shape each transformer expects

["age"] selects a two-dimensional table with one column, suitable for an imputer or scaler. A scalar selector such as "document" passes a one-dimensional sequence, suitable for a text vectorizer that expects one string per example. The square brackets are meaningful.

Names also avoid relying on accidental column positions. Keep the required input names, units, and types consistent. Selecting a string-typed age column does not by itself parse or validate the ages.

Inspect the output schema instead of guessing it

get_feature_names_out() names the emitted columns. The default branch prefixes distinguish num__age from the two cat__city_… columns. output_indices_ records that the numerical branch occupies slice 0:1 and the categorical branch slice 1:3.

Inspect fitted internals through prep.named_transformers_["num"] or prep.named_transformers_["cat"]. For example, the numerical pipeline’s named_steps["scale"] exposes the saved mean and standard deviation.

With remainder="drop", unspecified columns such as record_id are excluded. remainder="passthrough" instead appends remaining columns seen at fit time. That can accidentally include an identifier or even a target column left inside the input frame. Select deliberately; keep targets separate from feature inputs.

One sparse branch does not guarantee sparse output

With a wide categorical vocabulary, dense one-hot output can be expensive. Setting OneHotEncoder(sparse_output=True) makes that branch sparse, but ColumnTransformer still decides how to store the combined matrix.

If at least one branch is sparse, the combined density and sparse_threshold determine the output representation during fitting. The default threshold is 0.3. If every branch returns dense data, the result is dense regardless of that threshold. Inspect prep.sparse_output_ and the actual transformed object.

Why the whole workflow’s representation matters

sparse_threshold=0 forces dense output. A larger threshold favors sparse output when sparse branches exist, but does not make every possible composition sparse. The fitted transformer retains its chosen output representation for later transforms.

Both the next estimator and any later preprocessing must accept the result. A default centered standardizer placed after a sparse combined matrix would reject it. The sparse-scaling chapter explains why. Scaling the numerical branch separately, as above, avoids centering the one-hot block.

Wrap the assembled features and estimator in one pipeline

After importing Ridge from sklearn.linear_model, use the composition as part of the predictor, for example Pipeline([("prep", prep), ("model", Ridge())]), then fit that full pipeline on raw training features and targets. The model will receive the same ordered feature blocks during training and prediction.

Nested parameters follow the route through the objects. prep__num__impute__strategy reaches the numerical imputer; model__alpha reaches ridge. Pass the entire pipeline to cross-validation so each training fold learns its own replacement values, scales, vocabulary, and estimator.

C++: route selected fields and join saved transforms

This compact transform uses the fitted values from our example. It allocates one numerical coordinate followed by one coordinate per saved city. It deliberately does not read record_id or add unseen cities to the vocabulary. It assumes ages are finite or NaN and that the saved divisor is positive.

Compile the fitted column-routing example
#include <cmath>
#include <iomanip>
#include <iostream>
#include <limits>
#include <string>
#include <vector>

struct Row { double age; std::string city, record_id; };
struct SavedColumns {
    double fill, center, scale;
    std::vector<std::string> cities;
    std::vector<double> transform(const Row& row) const {
        std::vector<double> out(1 + cities.size(), 0.0);
        const double age = std::isnan(row.age) ? fill : row.age;
        out[0] = (age - center) / scale;
        for (std::size_t j = 0; j < cities.size(); ++j)
            if (row.city == cities[j]) out[1 + j] = 1.0;
        return out; // Unknown city leaves the category block zero.
    }
};
int main() {
    // State already fitted on the three training rows in the article.
    const SavedColumns prep{30, 30, std::sqrt(200. / 3), {"Delhi", "Pune"}};
    const double missing = std::numeric_limits<double>::quiet_NaN();
    std::cout << std::fixed << std::setprecision(4);
    for (const Row& row : std::vector<Row>{{50,"Pune","u4"}, {missing,"Mumbai","u5"}}) {
        const auto values = prep.transform(row);
        std::cout << values[0] << " " << values[1] << " " << values[2] << "\n";
    }
}
2.4495 0.0000 1.0000
0.0000 0.0000 0.0000

The code illustrates routing and concatenation, not fitting. The preceding C++ pipeline example shows sequential fitting; the one-hot implementation shows vocabulary-based output construction.

References and further reading