Case studies

All cases
Cases · Regression · 20,640 districts · 1990 census

California housing: where the score comes from

How well can a model predict the median house value of a California district from census data — and how much of the score belongs to the model, and how much to the way it is tested?

DataCalifornia Housing — One row per census block group (a district of typically 600–3,000 people) from the 1990 US census: location, median age of the houses, rooms, bedrooms, population, households, median income, and the median house value.
Sourcescikit-learn’s fetch_california_housing; originally StatLib, via Luís Torgo
LicencePublic data from the US census, distributed by StatLib for research and teaching
Filehousing.csv (967 KB), as used below

01Look before modelling

Load the data and check the ranges. Three columns stop dead at a limit, the sign of values cut off (top-coded) when the data were published.

import numpy as np
import pandas as pd

df = pd.read_csv('housing.csv')
df['value'] = df.median_house_value / 100_000            # in $100,000s
rooms = df.total_rooms / df.households

n_rows = len(df)
at_value_cap = (df.median_house_value >= 500_001).sum()   # top-coded at $500,001
at_age_cap = (df.housing_median_age == 52).sum()          # top-coded at 52 years
at_income_cap = (df.median_income >= 15).sum()            # top-coded at 15 ($150,000)
print(n_rows, 'districts')
print('at the value cap:', at_value_cap, f'({at_value_cap / n_rows:.1%})')
print('at the age cap:', at_age_cap, '| at the income cap:', at_income_cap)
print('rooms per household: median', round(rooms.median(), 1), '| max', round(rooms.max(), 1))
Python, in your browser — edit the code and run it again
20640 districts
at the value cap: 965 (4.7%)
at the age cap: 1273 | at the income cap: 51
rooms per household: median 5.2 | max 141.9

965 districts (4.7%) sit exactly at $500,001. Their true median value is somewhere above it, and no model trained on this column can learn by how much. The age and income columns are capped too, and a few districts average over 100 rooms per household — resort towns, where most houses are holiday homes with few permanent households.

02A baseline, then a test that matches the question

Predicting the average value for every district misses by about $91k on average. Two quick models: a linear regression on income and housing features, and k-nearest neighbours that sees nothing but the location — it predicts the average value of the ten nearest districts. Each is scored twice: with random 5-fold cross-validation, and with folds made of whole regions (1°×1° squares of latitude and longitude, 55 of them), so the test districts lie in areas the model has not seen.

from sklearn.model_selection import KFold, PredefinedSplit, cross_val_score
from sklearn.linear_model import LinearRegression
from sklearn.neighbors import KNeighborsRegressor

X = pd.DataFrame({
    'income': df.median_income, 'age': df.housing_median_age, 'rooms': rooms,
    'bedroom_share': df.total_bedrooms / df.total_rooms,
    'people_per_household': df.population / df.households,
    'lat': df.latitude, 'lon': df.longitude})
jitter = np.random.default_rng(0).normal(0, 1e-6, (len(X), 2))
X[['lat', 'lon']] += jitter      # districts that share coordinates: makes "nearest" unique
y = df.value
baseline_mae = (y - y.mean()).abs().mean()

block = np.floor(df.latitude).astype(int) * 1000 + np.floor(df.longitude).astype(int)
sizes = block.value_counts().sort_index().sort_values(ascending=False, kind='stable')
n_blocks = len(sizes)
load, fold = np.zeros(5), {}
for b, n in sizes.items():       # largest region first, each to the emptiest fold
    k = int(load.argmin()); fold[b] = k; load[k] += n
random_cv = KFold(5, shuffle=True, random_state=0)
region_cv = PredefinedSplit(block.map(fold))

def r2(model, cols):
    a = cross_val_score(model, X[cols], y, cv=random_cv, scoring='r2').mean()
    b = cross_val_score(model, X[cols], y, cv=region_cv, scoring='r2').mean()
    return round(a, 3), round(b, 3)

features = ['income', 'age', 'rooms', 'bedroom_share', 'people_per_household']
linear = r2(LinearRegression(), features)
knn = r2(KNeighborsRegressor(10), ['lat', 'lon'])
print('baseline MAE:', round(baseline_mae, 3))
print('R² (random folds, new regions)')
print('  linear, no location:', linear)
print('  10 nearest districts:', knn)
Python, in your browser — runs step 1 first — edit the code and run it again
baseline MAE: 0.912
R² (random folds, new regions)
  linear, no location: (0.549, 0.494)
  10 nearest districts: (0.79, 0.168)

The linear model scores about the same either way (R² 0.55 and 0.49). The location-only model looks better than it — R² 0.79 with random folds — and collapses to 0.17 on new regions. With random folds, the nearest neighbours of a test district are its own neighbours in the training data; the model is not predicting, it is looking up the street. Nearby districts have similar prices (spatial autocorrelation), so random folds leak information whenever location is a feature.

03A stronger model, the same gap

Gradient-boosted trees on all the features, location included — the kind of model that wins tabular benchmarks. It is fitted ten times, once per fold of each test.

from sklearn.ensemble import HistGradientBoostingRegressor

boost = r2(HistGradientBoostingRegressor(random_state=0), list(X.columns))
print('gradient boosting, all features:', boost)
Python, in your browser — runs steps 1–2 first — edit the code and run it again
gradient boosting, all features: (0.837, 0.679)

R² 0.84 with random folds, 0.68 on new regions. Both numbers are correct; they answer different questions. If the model will value houses in districts like the ones it was trained on — filling gaps on a map it already covers — the random score is the right one. If it will be used somewhere new, only the regional score says what to expect, and it is 19% lower.

random foldsnew regionsLinear, no location0.550.4910 nearest districts0.790.17Gradient boosting, all0.840.68
R², 5-fold cross-validation. “New regions”: folds of whole 1°×1° squares, so no test district has training data from its own area.

04What the cap does to the errors

Hold out a random fifth of the districts, fit the boosted model on the rest, and look at the districts whose value is capped.

from sklearn.model_selection import train_test_split

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0)
model = HistGradientBoostingRegressor(random_state=0).fit(X_tr, y_tr)
pred = pd.Series(model.predict(X_te), index=y_te.index)
err = (pred - y_te).abs()

capped = y_te >= 5.00001
worst = err >= err.quantile(0.95)                         # the 5% largest errors
print('test MAE:', round(err.mean(), 3), '| capped districts in test:', capped.sum())
print('mean prediction for capped districts:', round(pred[capped].mean(), 2))
print('share of capped districts among the worst 5%:',
      round((capped & worst).sum() / worst.sum(), 3))
print('highest prediction:', round(pred.max(), 2))
Python, in your browser — runs steps 1–3 first — edit the code and run it again
test MAE: 0.308 | capped districts in test: 181
mean prediction for capped districts: 4.45
share of capped districts among the worst 5%: 0.174
highest prediction: 5.43

The capped districts are 4% of the test set but 17% of the worst 5% of errors. For them the model predicts $445k on average, against a recorded $500,001 that is itself an underestimate. A metric averaged over all districts hides this: the error is concentrated at the expensive end, where a valuation mistake costs the most.

What this case shows

The patterns behind it

Run it yourself

Sources

Last reviewed: 2 October 2026 · How this site checks its content · Report a mistake