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 real Airbnb listings 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 home, 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 real, freely available Airbnb listings.
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.
Inside Airbnb publishes regularly-updated snapshots of Airbnb listings for cities around the world, released under a Creative Commons Attribution 4.0 license. Big cities like New York or London run to tens of thousands of rows — more than a tutorial needs — so this post uses a smaller market instead: Bozeman, Montana, a college and ski town with a compact, real short-term-rental scene.
Data: the Inside Airbnb Bozeman, Montana listings snapshot (CC BY 4.0), via insideairbnb.com, vendored here as a plain CSV at /datasets/blog/airbnb-price-prediction/bozeman-listings.csv, trimmed to the columns this post uses.
import pandas as pd
listings = pd.read_csv(
"https://datatweets.com/datasets/blog/airbnb-price-prediction/bozeman-listings.csv"
)
print(listings.shape)
listings.head()(591, 7)
room_type price minimum_nights number_of_reviews reviews_per_month availability_365 number_of_reviews_ltm
0 Entire home/apt 246.0 1 654 4.27 250 87
1 Entire home/apt 407.0 2 45 0.31 298 6
2 Entire home/apt 267.0 2 146 1.53 227 2
3 Entire home/apt 419.0 3 79 0.86 274 38
4 Entire home/apt 616.0 3 203 1.99 294 2Five hundred ninety-one 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 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 68
minimum_nights 0
number_of_reviews 0
reviews_per_month 75
availability_365 0
number_of_reviews_ltm 0
dtype: int64Sixty-eight listings have no price at all — hosts who paused their listing, most likely — and 75 have no reviews_per_month, which almost certainly means “no reviews yet,” not a data-entry error. 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()(523, 7)
room_type
Entire home/apt 464
Private room 46
Hotel room 10
Shared room 3
Name: count, dtype: int64Five hundred twenty-three listings remain. Notice how lopsided room_type is: entire homes outnumber shared rooms more than 150 to 1. Keep that number in mind — it’s the reason the second 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)(418, 6) (105, 6)Four hundred eighteen listings to learn from, 105 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_Entire home/apt'
'category__room_type_Hotel room' '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.268
MAE: 126.9Read this honestly rather than hopefully: an R² of 0.268 means these five booking signals and the room type explain roughly a quarter of the variation in price — real predictive signal, but nowhere near enough to price a listing on their own. The average prediction misses by about $127. That’s not a failure of the code; it’s an accurate report that nightly price depends heavily on things this dataset doesn’t capture, like square footage, amenities, and exact location. 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: -97.61
numeric__number_of_reviews: -24.17
numeric__reviews_per_month: -42.81
numeric__availability_365: 9.09
numeric__number_of_reviews_ltm: 37.52
category__room_type_Entire home/apt: 186.54
category__room_type_Hotel room: -66.94
category__room_type_Private room: 34.23
category__room_type_Shared room: -153.83Because 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 (short, popular stays behave differently from long-term rentals here), while entire homes command a clear premium over every other room type.
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 150-to-1 in favor of entire homes, 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=6)
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: ['Entire home/apt', 'Hotel room', 'Private room']
test categories: ['Entire home/apt', 'Hotel room', '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: ['Entire home/apt', 'Hotel room', '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: 126.9
MAE zero-impute: 128.63The 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 523 rows and those statistics quietly include information from the 105 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 523 rows: ", np.round(leaky_scaler.mean_, 2))
print("mean fit on train 418 rows:", np.round(honest_scaler.mean_, 2))mean fit on all 523 rows: [ 9.6 90.45 1.83 229.68 17.38]
mean fit on train 418 rows: [ 9.43 92.94 1.87 229.14 17.74]The difference looks tiny on 523 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.