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?
fetch_california_housing; originally StatLib, via Luís Torgo01Look 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))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)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)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.
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))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
- Choose the cross-validation split to match how the model will be used. Random folds measure interpolation; grouped folds (by region, customer, time) measure generalisation to new groups.
- A feature that identifies neighbours — location, user ID, a timestamp — turns random folds into a lookup. The bigger the gap between random and grouped scores, the more the model leans on it.
- Check every column’s maximum before modelling. A top-coded target puts a ceiling on what the model can learn and concentrates the error where values are highest.
The patterns behind it
- Cross-Validation Done Right — how folds are formed decides what the score means
- Cross-Validation for TS — the same leak, in time instead of space
- Regression Metrics — R² and MAE, and what an average hides
- Outlier Detection — capped and extreme values
- Bias-Variance Tradeoff — why a flexible model can memorise neighbours
Run it yourself
- Each step above has a Run button: Python runs in this page, on the data file above.
- Download the notebook — the same steps, for Jupyter on your own machine
- Open it in JupyterLite — Jupyter in the browser, nothing to install
Sources
- R. K. Pace & R. Barry, “Sparse Spatial Autoregressions”, Statistics & Probability Letters 33(3), 1997 — the data set
- D. R. Roberts et al., “Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure”, Ecography 40(8), 2017
- scikit-learn documentation, “Cross-validation: evaluating estimator performance” (group-wise splits)
Last reviewed: 2 October 2026 · How this site checks its content · Report a mistake