← Projects
MACHINE LEARNINGTABULAROPEN SOURCE

Cleveland Real Estate Price Prediction

End-to-end ML on 4,796 scraped Zillow listings: data cleaning, feature engineering, an eight-model search, and a Streamlit app that explains its predictions.

Brando Koch
Brando Koch
APRIL 25, 2021 · 6 MIN READ

Cleveland real estate price prediction

This project predicts the sale price of Cleveland homes, but the model was never really the point. I wanted a single repository that walked the whole lifecycle of a tabular machine learning problem, from collecting raw data through cleaning, feature engineering, model selection and hyperparameter search, to an app someone can actually click. Most of the difficulty in a project like this sits nowhere near the model, and I wanted the code to reflect that honestly.

The result is deliberately generic. Models, preprocessors and hyperparameter grids are all resolved through dispatcher modules, so pointing the same pipeline at a different tabular dataset means editing a config file rather than rewriting the training loop.

Where the data came from

There was no dataset, so I wrote a scraper. It walks paginated Zillow results for sold Cleveland listings, parses the listing header and the facts-and-features section out of the page, and writes one JSON object per listing. It sleeps a random two to five seconds between requests to stay polite.

Running it across April 2021 produced 4,796 raw listings, which are open-sourced in the repository alongside the code. Anyone can reproduce every number below from that file.

What the data actually looked like

This is the part worth reading, because it is the part that took the time.

Zillow’s listing data is written for humans, not models. Prices arrived as strings like $140,000, sometimes with an M standing in for a million, and sometimes as integers rather than text. Lot sizes came in two incompatible units in the same column, 0.11 Acres for some rows and 4,791 sqft for others, with commas in one format and periods in the other. Missing values were not null but the literal string No Data, which appeared in 111 rows of year_built alone. Property type had an Unknown category that needed to become a real null. And sale_date sometimes held things like 12 days on Zillow, which is a listing that never sold at all and has no business being in a dataset about sale prices.

Some errors were only findable by opening the listing. One property had a recorded price of 125 dollars. Another had a bedroom count that made no sense until I derived floor area per bedroom and saw it sitting far outside the distribution. Neither is detectable from summary statistics alone.

The funnel from raw to trainable:

StageRowsWhat happened
Raw scrape4,796One JSON object per sold listing
After dedup and column pruning3,313Dropped any column present in under 70% of rows, which removed heating, cooling and parking
After outlier filtering2,845Kept the 5th to 98th percentile of price per square metre
Final clean set2,61614 columns, after dropping unrealistic lot sizes and bad bedroom counts

So a little over half the scraped rows survived to training. That ratio is normal and it is the reason the cleaning notebook is longer than the training code.

The feature that was too good to use

Deriving price per square metre gave me the single strongest signal in the dataset, correlating 0.81 with sale price. It was also useless as a feature, because it is computed from the target. Training on it would have produced a model that looked excellent in validation and could not predict anything at inference, when price is exactly the unknown.

So I used it as a cleaning instrument rather than a feature. Extreme values in price per square metre are a reliable flag for a typo or a mislabelled listing, which is how the 5th to 98th percentile filter above was chosen, and then I dropped the column before training.

The features that survived were location as raw latitude and longitude, year built, bedroom count, full and partial bathroom counts, floor area in square metres, floor area per bedroom, lot size, the year, month and day of sale, and property type one-hot encoded across single family, multi family, condo, townhouse, apartment and vacant land. Numeric columns were median-imputed and standard-scaled, all inside a scikit-learn ColumnTransformer so the whole preprocessing step is a single fitted object that ships with the model.

Which model won

I benchmarked eight regressors against a mean-predicting baseline under five-fold cross validation, with the preprocessor fitted inside each fold so no information leaks from validation into training. The numbers below are from re-running that protocol on the published dataset.

ModelMean absolute error
Baseline (predicts the mean)-0.001$57,731
Support vector regressor-0.063$55,545
Neural network (MLP)-1.224$91,093
Decision tree0.375$41,078
Linear regression0.425$41,909
K-nearest neighbours0.478$39,171
Random forest (defaults)0.668$31,061
Random forest (tuned)0.685$30,331

The tuned random forest is the shipped model, at 120 trees, max depth 30, half the features considered per split and a minimum of two samples per leaf. It roughly halves the error of guessing the mean, from about $58k to about $30k on homes with a median price of $82,500.

Two things in that table are worth saying out loud. The grid search bought about two points of R² over stock random forest defaults, which is a real but modest return for a large search. And the neural network and support vector regressor both did worse than predicting the average, because neither handles an unscaled, heavily right-skewed target on 2,600 rows. On tabular data of this size, tree ensembles are the right default and the sweep confirmed it rather than discovered it.

The app

The Streamlit app takes listing attributes from a sidebar and returns a price, but the more useful half is the explanation. Because the shipped model is a random forest, I could use treeinterpreter to decompose each individual prediction into per-feature contributions and render them as a waterfall chart, so you see which attributes pushed this particular house above or below the baseline rather than just a global feature importance ranking.

Alongside it there is a 3D map built with pydeck, plotting nearby sold listings as extruded polygons whose height is floor area and whose colour is price, so a prediction can be read against its actual neighbourhood.

What I would do differently now

The target should have been log-transformed. Prices run from $9,000 to $650,000 with a heavy right skew, and modeling the log would have stabilised the variance and almost certainly improved every model in that table, particularly the linear and distance-based ones.

The cross-validation should have been spatial. Latitude and longitude are the strongest honest predictors here, and random k-fold splitting lets houses from the same street land in both training and validation, which flatters the score relative to how the model would perform in a neighbourhood it has never seen.

And I would reach for gradient boosting first. The repository already dispatches XGBoost and LightGBM, but the tuning effort went into the random forest.


View the full code on GitHub →

TAGS: MACHINE LEARNING · TABULAR · OPEN SOURCE