Skip to content

End-to-End Machine Learning Project (California Housing)

What you’ll learn

  • the complete project as one runnable script, from CSV to a saved model
  • how to shortlist models fast, and read the training-versus-CV gap as a diagnosis
  • grid search over preprocessing and model hyperparameters together
  • error analysis: which features mattered, and where the model is still wrong
  • how to open the test set once, and report a confidence interval rather than a point
  • what to hand over, and what to monitor afterwards

The whole project

diagram Diagram mermaid

Steps 1 to 5 are the previous six pages. This page runs 6 to 10.

Recap: getting to a prepared matrix

preparation.py
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import StratifiedShuffleSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
 
URL = ("https://raw.githubusercontent.com/ageron/handson-ml2/master/"
       "datasets/housing/housing.csv")
housing = pd.read_csv(URL)
 
# --- 3. Split first, stratified on income -------------------------------
housing["income_cat"] = pd.cut(housing["median_income"],
                               bins=[0.0, 1.5, 3.0, 4.5, 6.0, np.inf],
                               labels=[1, 2, 3, 4, 5])
splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(splitter.split(housing, housing["income_cat"]))
train = housing.iloc[train_idx].drop(columns=["income_cat"])
test = housing.iloc[test_idx].drop(columns=["income_cat"])
 
X_train = train.drop(columns=["median_house_value"])
y_train = train["median_house_value"]
X_test = test.drop(columns=["median_house_value"])
y_test = test["median_house_value"]
 
print(len(X_train), len(X_test))        # 16512 4128
 
# --- 5. One preprocessing object ----------------------------------------
num_attribs = X_train.select_dtypes("number").columns.tolist()
cat_attribs = ["ocean_proximity"]
 
num_pipeline = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler", StandardScaler()),
])
 
preparation = ColumnTransformer([
    ("num", num_pipeline, num_attribs),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat_attribs),
])
preparation.py
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import StratifiedShuffleSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
 
URL = ("https://raw.githubusercontent.com/ageron/handson-ml2/master/"
       "datasets/housing/housing.csv")
housing = pd.read_csv(URL)
 
# --- 3. Split first, stratified on income -------------------------------
housing["income_cat"] = pd.cut(housing["median_income"],
                               bins=[0.0, 1.5, 3.0, 4.5, 6.0, np.inf],
                               labels=[1, 2, 3, 4, 5])
splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(splitter.split(housing, housing["income_cat"]))
train = housing.iloc[train_idx].drop(columns=["income_cat"])
test = housing.iloc[test_idx].drop(columns=["income_cat"])
 
X_train = train.drop(columns=["median_house_value"])
y_train = train["median_house_value"]
X_test = test.drop(columns=["median_house_value"])
y_test = test["median_house_value"]
 
print(len(X_train), len(X_test))        # 16512 4128
 
# --- 5. One preprocessing object ----------------------------------------
num_attribs = X_train.select_dtypes("number").columns.tolist()
cat_attribs = ["ocean_proximity"]
 
num_pipeline = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler", StandardScaler()),
])
 
preparation = ColumnTransformer([
    ("num", num_pipeline, num_attribs),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat_attribs),
])

The test set now sits untouched until the last section of this page.

Why StratifiedShuffleSplitStratifiedShuffleSplit and not train_test_splittrain_test_split

The five income categories are not evenly sized — measured over all 20,640 districts they are 3.98%, 31.88%, 35.06%, 17.63% and 11.44%. A plain random 20% test set reproduces those shares only up to sampling noise, and the smallest category is where the noise bites. Averaged over 200 seeds:

SplitMean skew across categoriesMean worst categoryWorst seen in 200 seeds
train_test_splittrain_test_split2.788%6.531%22.141%
StratifiedShuffleSplitStratifiedShuffleSplit0.365%0.365%

At random_state=42random_state=42 specifically, the random split is off by 5.06% on category 4 and 4.32% on category 5, while the stratified split’s largest error is 0.365% — and that residual is only there because 4,128 rows cannot be divided into exactly 3.9826%.

See it move

Each run below draws a fresh 20% test set both ways and compares the resulting category shares against the population. The bars are signed error: right of centre means the test set over-represents that income band.

sketch Random versus stratified test sets p5.js
Two panels drawing a fresh 20 percent test set from the same 20,640 districts. The random panel's category shares wobble every draw, worst on the smallest category; the stratified panel's bars stay pinned near zero. A running worst-case tally accumulates below.

Leave it running and the amber worst-case keeps climbing while the green one stops at its rounding floor. Category 1 is the one that moves most, and category 1 is only 822 districts — a test set that misrepresents it by 20% is a test set that reports a number for a population that does not exist. The cost of avoiding this is one extra import.

Shortlisting models

Train several quickly, cross-validate all of them, and compare the training score against the cross-validated score. That gap is the diagnosis.

shortlist.py
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.tree import DecisionTreeRegressor
 
candidates = {
    "linear": LinearRegression(),
    "tree": DecisionTreeRegressor(random_state=42),
    "forest": RandomForestRegressor(n_estimators=80, random_state=42, n_jobs=-1),
}
 
for name, model in candidates.items():
    pipe = make_pipeline(preparation, model)
    pipe.fit(X_train, y_train)
    train_rmse = mean_squared_error(y_train, pipe.predict(X_train)) ** 0.5
    cv_rmse = -cross_val_score(pipe, X_train, y_train, cv=5,
                               scoring="neg_root_mean_squared_error").mean()
    print(f"{name:<7} train {train_rmse:>10,.0f}   cv {cv_rmse:>10,.0f}")
 
# linear  train     69,051   cv     69,218
# tree    train          0   cv     70,676
# forest  train     18,476   cv     50,010
shortlist.py
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.tree import DecisionTreeRegressor
 
candidates = {
    "linear": LinearRegression(),
    "tree": DecisionTreeRegressor(random_state=42),
    "forest": RandomForestRegressor(n_estimators=80, random_state=42, n_jobs=-1),
}
 
for name, model in candidates.items():
    pipe = make_pipeline(preparation, model)
    pipe.fit(X_train, y_train)
    train_rmse = mean_squared_error(y_train, pipe.predict(X_train)) ** 0.5
    cv_rmse = -cross_val_score(pipe, X_train, y_train, cv=5,
                               scoring="neg_root_mean_squared_error").mean()
    print(f"{name:<7} train {train_rmse:>10,.0f}   cv {cv_rmse:>10,.0f}")
 
# linear  train     69,051   cv     69,218
# tree    train          0   cv     70,676
# forest  train     18,476   cv     50,010
figureThree models, two numbers eachmatplotlib
Grouped bars of training and cross-validated RMSE for linear regression, decision tree and random forest. The tree's training bar is zero while its CV bar is the tallest.Grouped bars of training and cross-validated RMSE for linear regression, decision tree and random forest. The tree's training bar is zero while its CV bar is the tallest.
The decision tree's training RMSE is exactly zero and its cross-validated RMSE is the worst of the three. The gap is the whole story.

Reading the plot

ModelTrainCVGapDiagnosis
Linear69,05169,218167Underfitting. No gap at all, and both numbers are high — the model is not flexible enough.
Tree070,67670,676Overfitting completely. Memorised every row, generalises worse than the linear model.
Forest18,47650,01031,534Overfitting, but useful. A large gap, and still the best CV score by 28%.

Three lessons in one table:

  1. A training RMSE of zero is never good news. The tree has one leaf per district.
  2. No gap is also a warning. Linear regression cannot overfit here because it cannot fit at all; the fix is capacity, not regularisation.
  3. The forest’s gap says there is more to gain from tuning and from more data — which is exactly what step 7 tries.

Against the 15% expert baseline (roughly $35,530 RMSE from Framing), 50,010 is not yet good enough. That is the number tuning has to move.

Tuning

GridSearchCVGridSearchCV searches the pipeline, so preprocessing choices and model hyperparameters are tuned together using the double-underscore path:

tuning.py
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
 
pipe = Pipeline([
    ("pre", preparation),
    ("rf", RandomForestRegressor(random_state=42, n_jobs=-1)),
])
 
param_grid = {
    "rf__n_estimators": [30, 100],
    "rf__max_features": [4, 6, 8],
    # Preprocessing is tunable too:
    # "pre__num__imputer__strategy": ["median", "mean"],
}
 
search = GridSearchCV(pipe, param_grid, cv=3,
                      scoring="neg_root_mean_squared_error", n_jobs=-1)
search.fit(X_train, y_train)
 
print("best params:", search.best_params_)
# {'rf__max_features': 8, 'rf__n_estimators': 100}
print(f"best CV RMSE: {-search.best_score_:,.0f}")     # 49,821
 
for mean, params in sorted(zip(-search.cv_results_["mean_test_score"],
                               search.cv_results_["params"]))[:3]:
    print(f"{mean:,.0f}  {params}")
tuning.py
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
 
pipe = Pipeline([
    ("pre", preparation),
    ("rf", RandomForestRegressor(random_state=42, n_jobs=-1)),
])
 
param_grid = {
    "rf__n_estimators": [30, 100],
    "rf__max_features": [4, 6, 8],
    # Preprocessing is tunable too:
    # "pre__num__imputer__strategy": ["median", "mean"],
}
 
search = GridSearchCV(pipe, param_grid, cv=3,
                      scoring="neg_root_mean_squared_error", n_jobs=-1)
search.fit(X_train, y_train)
 
print("best params:", search.best_params_)
# {'rf__max_features': 8, 'rf__n_estimators': 100}
print(f"best CV RMSE: {-search.best_score_:,.0f}")     # 49,821
 
for mean, params in sorted(zip(-search.cv_results_["mean_test_score"],
                               search.cv_results_["params"]))[:3]:
    print(f"{mean:,.0f}  {params}")

Note that max_features=8max_features=8 is the largest value in the grid. When the best value sits at the edge of the grid, the grid was too narrow — extend it and search again. That is a habit worth forming; it is the most common way a grid search quietly under-delivers.

For large spaces, RandomizedSearchCVRandomizedSearchCV samples instead of enumerating, and RandomizedSearchCV for Large Parameter Spaces covers when to prefer it.

Error analysis

A tuned model is not a finished project. Two questions remain: what did it use, and where is it still wrong?

figureWhat the tuned forest actually usedmatplotlib
Horizontal bar chart of random forest feature importances, with median income at 0.444 far ahead, then INLAND at 0.150, longitude at 0.114 and latitude at 0.103.Horizontal bar chart of random forest feature importances, with median income at 0.444 far ahead, then INLAND at 0.150, longitude at 0.114 and latitude at 0.103.
One numeric feature does 44% of the work. The single most useful categorical value is INLAND — and latitude and longitude together contribute more than any other pair.
importances.py
final_model = search.best_estimator_
 
cat_names = list(final_model.named_steps["pre"]
                 .named_transformers_["cat"].categories_[0])
names = num_attribs + cat_names
importances = final_model.named_steps["rf"].feature_importances_
 
for name, score in sorted(zip(names, importances), key=lambda p: -p[1]):
    print(f"{name:<22} {score:.4f}")
 
# median_income          0.4439
# INLAND                 0.1501
# longitude              0.1139
# latitude               0.1025
# housing_median_age     0.0491
# population             0.0385
# total_rooms            0.0298
# total_bedrooms         0.0268
# households             0.0255
# <1H OCEAN              0.0116
# NEAR OCEAN             0.0059
# NEAR BAY               0.0022
# ISLAND                 0.0001
importances.py
final_model = search.best_estimator_
 
cat_names = list(final_model.named_steps["pre"]
                 .named_transformers_["cat"].categories_[0])
names = num_attribs + cat_names
importances = final_model.named_steps["rf"].feature_importances_
 
for name, score in sorted(zip(names, importances), key=lambda p: -p[1]):
    print(f"{name:<22} {score:.4f}")
 
# median_income          0.4439
# INLAND                 0.1501
# longitude              0.1139
# latitude               0.1025
# housing_median_age     0.0491
# population             0.0385
# total_rooms            0.0298
# total_bedrooms         0.0268
# households             0.0255
# <1H OCEAN              0.0116
# NEAR OCEAN             0.0059
# NEAR BAY               0.0022
# ISLAND                 0.0001

Three actions follow directly:

  • Four of the five ocean_proximityocean_proximity categories contribute almost nothing. Collapsing them to a single is_inlandis_inland flag would lose about 0.02 of importance and remove four columns.
  • Latitude and longitude together account for 0.216 — more than every count column combined. An explicit distance-to-city feature is worth trying.
  • ISLANDISLAND scores 0.0001, which is what five rows out of 16,512 buys.
figureWhere the final model is still wrongmatplotlib
Left: predicted against actual house value on the test set, clustering around the diagonal with a dense horizontal band at the top. Right: residuals against predictions, with a clear diagonal edge formed by the capped districts.Left: predicted against actual house value on the test set, clustering around the diagonal with a dense horizontal band at the top. Right: residuals against predictions, with a clear diagonal edge formed by the capped districts.
The straight diagonal edge on the right is the $500,001 cap: for those districts the true value is fixed, so the residual is a linear function of the prediction. Real structure, from a data artefact.

Reading the plot

  1. Predictions compress at both ends. Cheap districts are over-predicted and expensive ones under-predicted — a forest averages leaves, and averages pull toward the middle.
  2. The horizontal band at the top of the left panel is the capped districts. The model cannot predict above roughly $500k because nothing in training ever was.
  3. The diagonal edge in the residuals is that same cap, seen from another angle: for a fixed true value, the residual falls exactly one dollar for every dollar the prediction rises.
  4. Otherwise the residual cloud is shapeless, which is what you want.

The single most valuable next action on this project is not a better model. It is getting real labels for the 965 capped districts.

The test set, once

final_evaluation.py
import numpy as np
from scipy import stats
from sklearn.metrics import mean_squared_error
 
final_model = search.best_estimator_
final_predictions = final_model.predict(X_test)
 
final_rmse = mean_squared_error(y_test, final_predictions) ** 0.5
print(f"test RMSE: {final_rmse:,.0f}")            # 46,768
 
# A point estimate is not enough — report the interval
squared_errors = (final_predictions - y_test) ** 2
interval = np.sqrt(stats.t.interval(
    0.95, len(squared_errors) - 1,
    loc=squared_errors.mean(),
    scale=stats.sem(squared_errors),
))
print(f"95% CI: [{interval[0]:,.0f}, {interval[1]:,.0f}]")
# 95% CI: [44,832, 48,626]
final_evaluation.py
import numpy as np
from scipy import stats
from sklearn.metrics import mean_squared_error
 
final_model = search.best_estimator_
final_predictions = final_model.predict(X_test)
 
final_rmse = mean_squared_error(y_test, final_predictions) ** 0.5
print(f"test RMSE: {final_rmse:,.0f}")            # 46,768
 
# A point estimate is not enough — report the interval
squared_errors = (final_predictions - y_test) ** 2
interval = np.sqrt(stats.t.interval(
    0.95, len(squared_errors) - 1,
    loc=squared_errors.mean(),
    scale=stats.sem(squared_errors),
))
print(f"95% CI: [{interval[0]:,.0f}, {interval[1]:,.0f}]")
# 95% CI: [44,832, 48,626]

Test RMSE 46,768, with a 95% interval of 44,832 to 48,626.

Two observations, and both matter:

  • The test score beats the cross-validated score (46,768 against 49,821). That is fine and not unusual — CV trains on four fifths of the data while the final model trained on all of it. Had the test score been dramatically worse, that would signal overfitting to the CV folds through repeated tuning.
  • The interval is roughly ±2,000 wide. A rival model scoring 46,000 is not meaningfully better, and reporting a bare “46,768” invites exactly that false comparison.

Against the $35,530 expert baseline the model still loses. The honest report is: not yet good enough for the stated objective; the largest single obstacle is the capped target, and the next-largest is the absence of a distance-to-city feature.

Ship it

ship.py
import joblib
 
# The pipeline saves preprocessing and model together — one artefact, no drift
joblib.dump(final_model, "california_housing_v1.joblib")
 
loaded = joblib.load("california_housing_v1.joblib")
print(loaded.predict(X_test.iloc[:3]).round(0))     # raw DataFrame in, prices out
ship.py
import joblib
 
# The pipeline saves preprocessing and model together — one artefact, no drift
joblib.dump(final_model, "california_housing_v1.joblib")
 
loaded = joblib.load("california_housing_v1.joblib")
print(loaded.predict(X_test.iloc[:3]).round(0))     # raw DataFrame in, prices out

What to record alongside it:

ItemWhy
Metric and interval46,768 (44,832–48,626) — the number to compare against later
BaselinesMean 115,393; expert 35,530
Known limitsCannot predict above $500k; California only
Feature importancesTells you which input to monitor most closely
Training data snapshot dateSo drift can be measured against something
Library versionsA pickled model is not portable across versions

Then monitor: input distributions, prediction distribution, and — as soon as ground truth arrives — live RMSE against 46,768. Monitoring Model Drift covers the mechanics.

Pitfalls

quizCheck yourself
  1. The decision tree scores training RMSE 0 and CV RMSE 70,676, the worst of the three models. What does that mean?

    Show answer

    B — It memorised the training set completely and generalises worse than plain linear regression — An unpruned tree grows until every leaf is pure, so training error is structurally zero. Only the cross-validated score carries information, and it is the worst of the three.

  2. Linear regression scores 69,051 training and 69,218 CV — almost no gap. What is the diagnosis?

    Show answer

    B — Underfitting: the model is too rigid to fit the data, so there is nothing to overfit and both errors are high — A tiny gap with a high error means high bias. Regularisation would make it worse; the fix is more capacity or better features.

  3. The grid search picked max_features=8, the largest value in the grid. What should you do?

    Show answer

    B — Widen the grid and search again — a winner at the boundary suggests the true optimum lies outside it — The search only found the best value it was offered. When that is the edge value, the grid was too narrow.

  4. Test RMSE is 46,768 with a 95% interval of 44,832 to 48,626. A colleague's model scores 46,100. What can you conclude?

    Show answer

    B — The difference sits well inside the interval, so the two are not distinguishable on this test set — 46,100 falls comfortably within 44,832 to 48,626. Declaring a winner on a 668-unit difference is reading noise, which is exactly what reporting the interval prevents.

🧪 Try It Yourself

Exercise 1 – Build the preparation pipeline

Exercise 2 – Read the train-versus-CV gap

Exercise 3 – Grid search the pipeline

Exercise 4 – Report a confidence interval

Exercise 5 – Save and reload the whole pipeline

Recap

  • Ten steps, and the first five are the rest of this phase. This page ran shortlist, tune, analyse, evaluate, ship.
  • Shortlist by comparing training against cross-validated error: linear 69,051 / 69,218 (underfitting), tree 0 / 70,676 (memorising), forest 18,476 / 50,010 (best, still overfitting).
  • Grid search reached CV RMSE 49,821 at max_features=8max_features=8 — the edge of the grid, so the grid was too narrow.
  • Importances: median_incomemedian_income 0.444, INLANDINLAND 0.150, longitude and latitude 0.216 together. Four of five categories are near-worthless.
  • Test RMSE 46,768, 95% interval 44,832 to 48,626 — one look, no tuning afterwards.
  • Still short of the $35,530 expert baseline. The biggest single obstacle is the capped target, not the model.
  • Save the whole pipeline, record the baselines and limits, and monitor drift.

Exercise 6 – Simulate the split you did not use

Next

Phase 2 ends here. Continue to Phase 3 - Supervised Learning - Regression — the models used on this page as black boxes, derived from first principles.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did