Real listings data mixes numbers with categories like room type, and a plain LinearRegression can't touch a text column. This guide builds the ColumnTransformer mental model, then fits a full preprocessing-plus-regression pipeline on a synthetic, seeded short-term-rental dataset you can reproduce yourself.
“How much should this listing cost?” is a question a host asks about one apartment, but the data that answers it never comes in one flavor. A listing’s minimum_nights is a number. Its room_type — entire place, private room, shared room — is a category. Feed both straight into LinearRegression.fit() and it crashes on the first non-numeric column it meets, because every scikit-learn estimator only ever does arithmetic.
This is where a lot of otherwise-solid tutorials quietly cheat: they either drop every non-numeric column, or one-hot encode everything by hand before the model ever sees it, which works until you need to do the exact same encoding again on new data next week. (Our post on training your first machine learning model sidesteps the problem entirely by picking a single numeric feature — a fine place to start, but real tables are rarely that polite.) This guide builds the mental model for handling mixed column types properly, then fits and honestly evaluates a price-prediction model on a synthetic short-term-rental dataset built specifically to have this shape.
A LinearRegression model doesn’t actually care how many columns your table has — it cares that every value in every column is a number it can multiply by a weight. Getting there from a real table takes three steps:
minimum_nights, availability_365, …) need one kind of treatment; categorical columns (room_type) need a completely different one..fit().scikit-learn has one object for exactly this: ColumnTransformer. You hand it a list of (name, transformer, columns) triples, it applies each transformer to its own column subset, and it glues the results together side by side.
Wrap a ColumnTransformer and an estimator together in a Pipeline, and the whole three-step process becomes one object with a .fit() and a .predict() — the same shape our scikit-learn overview uses for a single preprocessing step, just with two transformers running on two different column groups instead of one.
Imagine a small short-term-rental platform, Fernbrook Stays, covering one compact market: a few hundred active listings, mostly entire places and private rooms, with a handful of cabins and shared rooms mixed in. Fernbrook Stays is invented for this post — there’s no real booking platform behind it. Every row below comes from a seeded random generator, not a vendored export, so you can regenerate the exact same numbers yourself and check them line by line.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 640
room_types = np.array(["Entire Place", "Private Room", "Cabin", "Shared Room"])
room_probs = [0.807, 0.154, 0.032, 0.007]
room_type = rng.choice(room_types, size=n, p=room_probs)
min_nights_choices = np.array([1, 2, 3, 4, 5, 7, 14, 30])
min_nights_probs = [0.32, 0.22, 0.14, 0.10, 0.08, 0.06, 0.05, 0.03]
minimum_nights = rng.choice(min_nights_choices, size=n, p=min_nights_probs)
no_reviews_mask = rng.random(n) < 0.13
number_of_reviews = rng.poisson(lam=38, size=n) + 1
number_of_reviews[no_reviews_mask] = 0
ltm_frac = rng.beta(2, 3, size=n)
number_of_reviews_ltm = np.round(number_of_reviews * ltm_frac).astype(int)
reviews_per_month = np.round(rng.gamma(shape=2.0, scale=0.55, size=n), 2)
availability_365 = rng.integers(0, 366, size=n)
base_price = {"Entire Place": 190.0, "Private Room": 95.0, "Cabin": 230.0, "Shared Room": 55.0}
base = np.array([base_price[r] for r in room_type])
noise = rng.normal(0, 55, size=n)
price = (
base
- 3.2 * minimum_nights
- 0.35 * number_of_reviews
- 9.5 * reviews_per_month
+ 0.28 * availability_365
+ 1.1 * number_of_reviews_ltm
+ noise
)
price = np.round(np.clip(price, 20, None), 2)
listings = pd.DataFrame({
"room_type": room_type,
"price": price,
"minimum_nights": minimum_nights,
"number_of_reviews": number_of_reviews,
"reviews_per_month": reviews_per_month,
"availability_365": availability_365,
"number_of_reviews_ltm": number_of_reviews_ltm,
})
listings.loc[listings["number_of_reviews"] == 0, "reviews_per_month"] = np.nan
delisted_idx = rng.choice(listings.index, size=int(n * 0.105), replace=False)
listings.loc[delisted_idx, "price"] = np.nan
print(listings.shape)
listings.head()(640, 7)
room_type price minimum_nights number_of_reviews reviews_per_month availability_365 number_of_reviews_ltm
0 Entire Place 215.17 4 39 3.05 154 2
1 Entire Place 226.78 5 39 0.46 335 7
2 Private Room 150.38 1 34 1.01 142 14
3 Entire Place 261.51 1 36 1.26 322 9
4 Entire Place NaN 3 42 0.78 268 10Six hundred forty synthetic listings, one row each: a room_type category, the nightly price we want to predict, and five numeric signals about how the listing is booked and reviewed — the same shape a real booking platform’s export would have, generated here on purpose instead of borrowed from one. (The outputs in this post come from pandas 3.0 and scikit-learn 1.9 — everything shown also works on pandas 2.x and recent 1.x scikit-learn releases.)
Before building anything, check what’s actually missing:
listings.isna().sum()room_type 0
price 67
minimum_nights 0
number_of_reviews 0
reviews_per_month 69
availability_365 0
number_of_reviews_ltm 0
dtype: int64Sixty-seven listings have no price at all — the generator marks these as paused or delisted, the same way a real host might pull a listing offline for a while — and 69 have no reviews_per_month, standing in for listings with no reviews yet. You can’t train a model to predict a target it doesn’t have, so the missing-price rows have to go. The missing reviews_per_month rows, though, still have a real price and are worth keeping — that gap gets handled inside the pipeline instead.
listings = listings.dropna(subset=["price"]).reset_index(drop=True)
print(listings.shape)
listings["room_type"].value_counts()(573, 7)
room_type
Entire Place 472
Private Room 83
Cabin 15
Shared Room 3
Name: count, dtype: int64Five hundred seventy-three listings remain. Notice how lopsided room_type is: entire places outnumber shared rooms more than 150 to 1. Keep that number in mind — it’s the reason the first gotcha below exists.
The features split into two groups by type, and the split into train and test happens before any transformer sees the data — fitting a scaler or encoder on data that includes the test set is a subtle way of letting the model peek at answers it shouldn’t have yet:
from sklearn.model_selection import train_test_split
numeric_features = ["minimum_nights", "number_of_reviews", "reviews_per_month", "availability_365", "number_of_reviews_ltm"]
categorical_features = ["room_type"]
X = listings[numeric_features + categorical_features]
y = listings["price"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
print(X_train.shape, X_test.shape)(458, 6) (115, 6)Four hundred fifty-eight listings to learn from, 115 held back to grade the model honestly at the end.
Now the mental model becomes code. The numeric columns get their own two-step pipeline — fill missing values with the column median, then scale — and the categorical column gets OneHotEncoder. ColumnTransformer runs both and stitches the results together; wrapping it in a Pipeline with LinearRegression gives one object that does preprocessing and modeling in a single .fit() call. (The full option list for both objects is in scikit-learn’s ColumnTransformer and Pipeline user guide.)
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LinearRegression
numeric_pipeline = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
preprocessing = ColumnTransformer([
("numeric", numeric_pipeline, numeric_features),
("category", OneHotEncoder(handle_unknown="ignore"), categorical_features),
])
model = Pipeline([
("preprocessing", preprocessing),
("regressor", LinearRegression()),
])
model.fit(X_train, y_train)Nothing prints from a successful .fit(), but you can ask the fitted ColumnTransformer exactly what it built:
model.named_steps["preprocessing"].get_feature_names_out()['numeric__minimum_nights' 'numeric__number_of_reviews'
'numeric__reviews_per_month' 'numeric__availability_365'
'numeric__number_of_reviews_ltm' 'category__room_type_Cabin'
'category__room_type_Entire Place' 'category__room_type_Private Room'
'category__room_type_Shared Room']Five numeric columns went in unchanged in name (just imputed and scaled); one categorical column came out as four — one 0/1 column per category OneHotEncoder saw during .fit(). That’s the “combine” step from the mental model, made concrete: nine columns, all numbers, one matrix.
LinearRegression never saw the test set during .fit(), so predicting on it is a fair test:
from sklearn.metrics import mean_absolute_error, r2_score
preds = model.predict(X_test)
print("R2:", round(r2_score(y_test, preds), 3))
print("MAE:", round(mean_absolute_error(y_test, preds), 2))R2: 0.397
MAE: 46.17Read this honestly rather than hopefully: an R² of 0.397 means these five booking signals and the room type explain roughly 40% of the variation in price — real predictive signal, meaningfully better than a coin flip, but still leaving well over half the variation unexplained. The average prediction misses by about $46. That’s not a failure of the code; it’s an accurate report that nightly price depends on things this five-column table doesn’t capture, like exact location, amenities, and listing size. A model that reported a suspiciously high R² on a table this thin would be the thing to distrust, not celebrate.
The fitted coefficients tell you which signals pulled in which direction:
coefs = model.named_steps["regressor"].coef_
names = model.named_steps["preprocessing"].get_feature_names_out()
for n, c in zip(names, coefs):
print(f"{n}: {c:.2f}")numeric__minimum_nights: -19.25
numeric__number_of_reviews: -0.56
numeric__reviews_per_month: -10.40
numeric__availability_365: 32.09
numeric__number_of_reviews_ltm: 7.34
category__room_type_Cabin: 87.33
category__room_type_Entire Place: 49.91
category__room_type_Private Room: -35.02
category__room_type_Shared Room: -102.23Because every numeric column was scaled first, these coefficients are roughly comparable to each other: a longer required minimum stay and a heavier recent review pace both pull price down, while more available nights and more reviews in the last twelve months both pull it up. Room type carries the widest swing of all — cabins and entire places command a real premium, while shared rooms land more than $100 below the baseline.
A category your training data never saw will crash a strict encoder — handle_unknown="ignore" is not optional for lopsided categories. With room_type split roughly 157-to-1 in favor of entire places, an unlucky train/test split can leave a rare category like “Shared Room” entirely out of the training rows:
X_train6, X_test6 = train_test_split(X[["room_type"]], test_size=0.2, random_state=109)
print("train categories:", sorted(X_train6["room_type"].unique()))
print("test categories: ", sorted(X_test6["room_type"].unique()))
from sklearn.preprocessing import OneHotEncoder
strict_encoder = OneHotEncoder(handle_unknown="error").fit(X_train6)
strict_encoder.transform(X_test6)train categories: ['Cabin', 'Entire Place', 'Private Room']
test categories: ['Cabin', 'Entire Place', 'Private Room', 'Shared Room']
ValueError: Found unknown categories ['Shared Room'] in column 0 during transformWith the default handle_unknown="error", that’s a hard crash the moment real test (or production) data shows up with a category the encoder never learned. Swap in handle_unknown="ignore" and the same unseen category quietly becomes all zeros across the known columns instead of raising:
safe_encoder = OneHotEncoder(handle_unknown="ignore").fit(X_train6)
encoded_test = safe_encoder.transform(X_test6).toarray()
shared_row = X_test6.reset_index(drop=True).query("room_type == 'Shared Room'").index[0]
print("encoded categories:", list(safe_encoder.categories_[0]))
print("a Shared Room row encodes as:", encoded_test[shared_row])encoded categories: ['Cabin', 'Entire Place', 'Private Room']
a Shared Room row encodes as: [0. 0. 0.]That all-zero row isn’t a special “unknown” flag — it’s just a listing that doesn’t match any category the model learned about, so it contributes nothing extra beyond the intercept and the numeric features. Worth knowing, not necessarily worth celebrating.
How you fill in missing values changes the fitted model, even when the accuracy barely moves. reviews_per_month was missing for listings with no reviews yet. Filling it with the column median versus filling it with a flat 0 are different, defensible choices with different downstream numbers:
numeric_pipeline_zero = Pipeline([
("impute", SimpleImputer(strategy="constant", fill_value=0)),
("scale", StandardScaler()),
])
preprocessing_zero = ColumnTransformer([
("numeric", numeric_pipeline_zero, numeric_features),
("category", OneHotEncoder(handle_unknown="ignore"), categorical_features),
])
model_zero = Pipeline([("preprocessing", preprocessing_zero), ("regressor", LinearRegression())])
model_zero.fit(X_train, y_train)
preds_zero = model_zero.predict(X_test)
print("MAE median-impute:", round(mean_absolute_error(y_test, preds), 2))
print("MAE zero-impute: ", round(mean_absolute_error(y_test, preds_zero), 2))MAE median-impute: 46.17
MAE zero-impute: 46.38The gap here is small, but it won’t always be. Zero-filling silently claims “no reviews yet” behaves like “reviews every month,” which is a real (if minor) modeling assumption baked in by one keyword argument — decide it on purpose, don’t let the default happen to you.
Fitting a scaler before you split leaks test-set statistics into training. StandardScaler learns a mean and standard deviation from whatever it’s fitted on. Fit it on the full 573 rows and those statistics quietly include information from the 115 rows you meant to hold out:
import numpy as np
from sklearn.preprocessing import StandardScaler
leaky_scaler = StandardScaler().fit(X[numeric_features].fillna(X[numeric_features].median()))
honest_scaler = StandardScaler().fit(X_train[numeric_features].fillna(X_train[numeric_features].median()))
print("mean fit on all 573 rows: ", np.round(leaky_scaler.mean_, 2))
print("mean fit on train 458 rows:", np.round(honest_scaler.mean_, 2))mean fit on all 573 rows: [ 4.12 34.18 1.13 185.11 13.5 ]
mean fit on train 458 rows: [ 4.28 34.35 1.14 186.15 13.47]The difference looks small on 573 rows split one way, but the direction of the mistake is always the same: your reported test score gets a little too optimistic, in a way that won’t reproduce once real, truly-unseen data shows up. This is also the strongest argument for putting the scaler inside the Pipeline in the first place — call .fit() on X_train alone, as this post did, and the leak can’t happen by accident.
Mixed column types aren’t an edge case — they’re what real tables look like. The pattern that handles them:
ColumnTransformer → routes each column group to its own transformer (impute-and-scale for numbers, OneHotEncoder for categories)Pipeline → chains that preprocessing with an estimator into one .fit()/.predict() objecthandle_unknown="ignore" → keeps a lopsided category from crashing your encoder on a bad split or on production dataIf you want to build on this with a full regression workflow — reading standardized coefficients, checking model fit, and validating on a real project — the Regression module in our free Machine Learning course picks up exactly where this post’s preprocessing leaves off.