Lecture 6: Column transformer and text features

Varada Kolhatkar

Focus on the breath!

Announcements

  • HW3 is due Monday, Oct 5th, 11:59 pm.
    • If you haven’t started yet, start now.
    • You can work in pairs for this assignment.
  • My OH: Tuesdays and Thursdays right after the class.

Today’s big questions

  • Which preprocessing mistakes make our evaluation unreliable?
  • How do we apply different transformations to different columns?
  • How can we represent text as numerical features?

Mistake 1: fitting train and test separately

What’s wrong here? Assume we have already split the data.

train_scaler = StandardScaler()
train_scaled = train_scaler.fit_transform(X_train)

test_scaler = StandardScaler()
test_scaled = test_scaler.fit_transform(X_test)

Same age, different coordinates

Suppose age is our only feature:

Fit the scaler on… Ages Mean Standard deviation
Training set 20, 40 30 10
Test set 40, 60 50 10

For a 40-year-old:

  • Training scaler: \((40 - 30) / 10 = +1\)
  • Separate test scaler: \((40 - 50) / 10 = -1\)

Should the same age have different coordinates in training and test?

Should the same age have different coordinates in training and test? ❌

A test point’s location relative to the training points determines its neighbours.

Use the same fitted transformation for training and unseen rows.

Mistake 2: fitting on train and test together

What’s wrong here?

X_all = np.vstack([X_train, X_test])
scaler = StandardScaler().fit(X_all)

train_scaled = scaler.transform(X_train)
test_scaled = scaler.transform(X_test)

Mistake 3: fitting before cross-validation

The test set is untouched. Is this evaluation valid?

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)

cross_val_score(
    KNeighborsClassifier(), X_train_scaled, y_train
)

Where the leakage happens

The scaler’s fit includes the validation fold. ❌

Which rows may each step learn from?

Within each fold, preprocessing and the model fit on the same training rows. ✅

The repair: cross-validate the whole pipeline

from sklearn.pipeline import make_pipeline
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import cross_val_score

pipe = make_pipeline(StandardScaler(), KNeighborsClassifier())
cross_val_score(pipe, X_train, y_train)
  • Pass raw features and the whole pipeline to CV.
  • Each fold fits its own preprocessing and classifier.
  • Validation rows receive transform, then predict.

(Optional) Alternative transformations

Method Transformation
StandardScaler Centre each feature and scale by its training standard deviation
MinMaxScaler Map each feature’s training range to a chosen interval
Normalizer Scale each row to unit norm
Log transform Compress large positive values; can help with skewed counts

Choose based on the feature and model, then evaluate.

Different columns need different transformations

Feature group Columns Preprocessing
Numeric age, n_people, price Impute \(\to\) scale
Unordered categories food_type, north_america Impute \(\to\) one-hot encode
Ordered category noise_level Impute \(\to\) ordinal encode

How do we combine these into one feature matrix?

ColumnTransformer combines feature groups

Different columns \(\to\) their own transformations \(\to\) one feature matrix.

Build the numeric and categorical branches

from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

numeric_pipe = make_pipeline(
    SimpleImputer(strategy="median"),
    StandardScaler(),
)

categorical_pipe = make_pipeline(
    SimpleImputer(strategy="most_frequent"),
    OneHotEncoder(handle_unknown="ignore"),
)

Each branch is a sequence of transformations for one feature group.

Build the ordinal branch

from sklearn.preprocessing import OrdinalEncoder

noise_order = ["no music", "low", "medium", "high", "crazy loud"]

ordinal_pipe = make_pipeline(
    SimpleImputer(strategy="most_frequent"),
    OrdinalEncoder(categories=[noise_order]),
)

We supply the meaningful order of noise levels.

ColumnTransformer syntax

from sklearn.compose import ColumnTransformer

preprocessor = ColumnTransformer([
    ("num", numeric_pipe, ["age", "n_people", "price"]),
    ("cat", categorical_pipe, ["food_type", "north_america"]),
    ("ord", ordinal_pipe, ["noise_level"]),
], remainder="drop")

Each tuple contains (name, transformer, columns).

  • Name: a label for the branch.
  • Transformer: one transformer or a preprocessing pipeline.
  • Columns: the input columns for that branch.

The shortcut: make_column_transformer

from sklearn.compose import make_column_transformer

preprocessor = make_column_transformer(
    (numeric_pipe, ["age", "n_people", "price"]),
    (categorical_pipe, ["food_type", "north_america"]),
    (ordinal_pipe, ["noise_level"]),
    remainder="drop",
)

Each tuple contains (transformer, columns); names are generated for you.

We’ll primarily use this constructor.

What comes out of a column transformer?

  • Same rows: each output row corresponds to the same input respondent.
  • Output order: numeric block, categorical block, then ordinal block in our example.
  • Feature count: may change; one-hot encoding can add columns.
  • Unlisted columns: dropped by default (remainder="drop").
  • Output format: may be dense or sparse, depending on the transformations and sparsity.

After fitting, inspect the output names with:

preprocessor.fit(X_train, y_train)
preprocessor.get_feature_names_out()

Put preprocessing inside the prediction pipeline

pipe = make_pipeline(
    preprocessor, # column transformer object
    KNeighborsClassifier(),
)

scores = cross_val_score(pipe, X_train, y_train, cv=5)

Raw survey columns \(\to\) column transformer \(\to\) KNN \(\to\) predictions

Cross-validation fits every branch and the classifier within each fold.

Class demo: combine the survey features

Open the preprocessing demo

Task: infer a respondent’s like / dislike label from their completed survey.

As we build the column transformer:

  1. Trace which columns enter each branch.
  2. Inspect the transformed feature names and shape.
  3. Cross-validate the complete prediction pipeline.

Ordinal or one-hot?

Weather categories: sunny, cloudy, rainy, snowy.

Would you impose an order when predicting:

  • Traffic volume?
  • Severity of weather-related road incidents?

Use an ordinal encoding only if you can justify the order for the feature.

A meaningful order does not automatically make ordinal encoding the right choice. Choose the encoding in the context of the prediction task.

Unseen categories

Suppose food_type contains only Chinese and Italian during training.

With OneHotEncoder(handle_unknown="ignore"):

New respondent’s food_type food_type_Chinese food_type_Italian
Chinese 1 0
Italian 0 1
Ethiopian (unseen) 0 0
Peruvian (unseen) 0 0

Prediction can proceed, but this feature cannot distinguish Ethiopian from Peruvian.

Is this the behaviour you want for your prediction task?

Binary and high-cardinality features

Binary features: drop="if_binary" keeps one of the two one-hot columns.

Features with many categories: possible choices include:

  • Group rare categories into an “other” category.
  • Combine categories using domain knowledge.
  • Retain frequent categories and group the rest.

Learn frequency-based grouping rules inside each CV fold.

iClicker: Exercise 6.1

Select all statements that are TRUE.

  1. A column transformer alone provides class predictions.

  2. Output feature blocks follow the order of the transformer list.

  3. The number of output features must differ from the number of input features.

  4. A column transformer can be a step in a prediction pipeline.

Text is another feature type

Suppose our restaurant survey includes these comments:

  • “good food”
  • “good good service”
  • “slow service”

How could we turn each review into a row of numbers?

Bag of words: count the vocabulary

Vocabulary: food, good, service, slow

Review food good service slow
good food 1 1 0 0
good good service 0 2 1 0
slow service 0 0 1 1
  • One row per document; one column per vocabulary term.
  • Each entry counts occurrences of that term.

CountVectorizer: learn and apply the vocabulary

from sklearn.feature_extraction.text import CountVectorizer

reviews = ["good food", "good good service", "slow service"]
vectorizer = CountVectorizer()
X_counts = vectorizer.fit_transform(reviews)

vectorizer.get_feature_names_out()
# array(['food', 'good', 'service', 'slow'], ...)
  • fit: learn the vocabulary from training documents.
  • transform: count those terms in each document.
  • The count matrix is sparse: store nonzero entries efficiently.

Your turn: an unseen review

Training vocabulary: food, good, service, slow

New review: “good excellent food”

What vector do we get? Does excellent create a new column?

[1, 1, 0, 0]

excellent is outside the fitted vocabulary, so it is ignored.

Transform only: keep the learned columns fixed for unseen reviews.

What does bag of words lose?

“the food was good, not bad”

“the food was bad, not good”

These have the same unigram counts, but different meanings.

  • Word order is lost.
  • Counts alone do not capture context or meaning.
  • Bigrams can retain adjacent pairs such as not bad and not good.

Choosing the text representation

Parameter What it controls
max_features Keep at most this many terms, ranked by corpus frequency
min_df, max_df Exclude terms based on how many documents contain them
ngram_range=(1, 2) Include single words and adjacent word pairs
stop_words Remove specified common words

These are modelling choices to compare using cross-validation.

Removing words can remove meaning: consider “not good”.

A pipeline for text classification

from sklearn.svm import SVC

text_pipe = make_pipeline(CountVectorizer(), SVC())

# Training review strings and their corresponding labels
cross_val_score(text_pipe, reviews_train, y_train, cv=5)

Each fold learns its vocabulary from its training reviews only.

Validation reviews are counted using that vocabulary, then classified.

Combine text with the other columns

# First replace missing comments with empty strings.
X_train = X_train.assign(comments=X_train["comments"].fillna(""))

preprocessor_with_text = make_column_transformer(
    (numeric_pipe, ["age", "n_people", "price"]),
    (categorical_pipe, ["food_type", "north_america"]),
    (ordinal_pipe, ["noise_level"]),
    (CountVectorizer(), "comments"),
)
pipe = make_pipeline(preprocessor_with_text, SVC())

Use "comments", not ["comments"]: the vectorizer expects a 1D sequence of strings.

iClicker: Exercise 6.2

Select all statements that are TRUE.

  1. An unseen word causes CountVectorizer.transform to fail.

  2. Repeated words can produce counts greater than one.

  3. When cross-validating a pipeline containing CountVectorizer, as shown below, each fold may learn a different vocabulary size.

pipe = make_pipeline(CountVectorizer(), SVC())
cross_val_score(pipe, reviews_train, y_train, cv=5)
  1. Increasing max_features guarantees a higher validation score.

More preprocessing choices

There is no universal preprocessing recipe.

  • Use exploratory analysis and domain knowledge to choose representations.
  • Compare choices using cross-validation.
  • Keep the test set reserved until those choices are settled.

Summary

  • Preprocessing mistakes: keep validation and test rows out of every fit, including vocabulary learning.
  • Different columns: use ColumnTransformer to combine feature groups inside a prediction pipeline.
  • Text features: bag of words represents documents using vocabulary counts; it loses word order.

Cross-validate the complete procedure on raw training features.

Exit question

train_vec = CountVectorizer(max_features=1000)
valid_vec = CountVectorizer(max_features=1000)

X_train = train_vec.fit_transform(reviews_train)
X_valid = valid_vec.fit_transform(reviews_valid)

model = SVC()
model.fit(X_train, y_train)
model.score(X_valid, y_valid)

Both matrices have 1,000 columns, and the code runs without errors.

Can we trust the score? What would you check or change?

If we remove max_features=1000, could model.score raise an error? Why?