← All tutorials
PythonMachine Learning

Predicting Short-Term Rental Prices: Mixing Numeric and Categorical Columns in scikit-learn

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.

The Mental Model: Different Columns, Different Treatment, One Matrix

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:

  1. Split the columns by type. Numeric columns (minimum_nights, availability_365, …) need one kind of treatment; categorical columns (room_type) need a completely different one.
  2. Transform each group on its own terms. Numeric columns get missing values filled in and get scaled. Categorical columns get one-hot encoded — each category becomes its own 0/1 column, since there’s no honest way to multiply “Private Room” by a weight.
  3. Combine the transformed pieces back into one wide matrix. Every row is now all numbers, in a fixed column order, ready for .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.

Diagram showing a listings table split by column type: minimum_nights, number_of_reviews, reviews_per_month, availability_365, and number_of_reviews_ltm go through a numeric pipeline of median imputation and scaling, while room_type goes through one-hot encoding into four category columns, and ColumnTransformer combines both outputs into one nine-column matrix that feeds a LinearRegression.

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.

A Dataset You Can Reproduce: Fernbrook Stays’ Listings

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                     10

Six 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.)

Missing Values, Built In on Purpose

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: int64

Sixty-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: int64

Five 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.

Splitting Before You Touch Anything

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.

Building the ColumnTransformer and Pipeline

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.

Grading the Model Honestly

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.17

Read 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.23

Because 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.

Three Gotchas Worth Knowing

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 transform

With 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.38

The 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.

Wrapping Up

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() object
  • handle_unknown="ignore" → keeps a lopsided category from crashing your encoder on a bad split or on production data
  • Split first, fit second → the only way to grade a model honestly and keep test-set statistics out of training

If 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.

More tutorials