Ch. 02

End-to-End ML Project

California housing, no-leakage pipelines, a working model in chapter two. The Géron classic.

sklearnpipelinesfeature-engineering

FIG 02 · Explainer video


The dataset is California Housing, 1990 census, 20,640 districts, nine features, one target: the median house price. You will run the entire Géron pipeline on it. By the time you finish, you will have a pickled model that takes a row of district statistics and returns a dollar amount, and you will have committed maybe eight different errors along the way that the book warned you about and you ignored. Then you will fix them. The whole chapter is one project, written as a notebook, with code and commentary interleaved. The book this is distilled from is six hundred pages long; this chapter is the part you actually need to run before any of the model-architecture chapters mean anything.


FIG 02.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Frame a vague business problem ("predict house prices in California") as a specific ML task with a defined input, output, and loss.
  • Load a CSV, do real EDA in pandas + matplotlib, and find the four things that are weird about a fresh dataset within 20 minutes.
  • Build a sklearn.pipeline.Pipeline that does imputation, scaling, and encoding without leaking any test-set information.
  • Train and compare three model families (linear regression, decision tree, random forest) on the same dataset using the same evaluation harness.
  • Run randomized hyperparameter search on the best model and report a confidence interval on test performance.
  • Save the trained pipeline (model + preprocessing) as one pickle file and load it back for inference on a new district.

FIG 02.2 · What you need first


FIG 02.3.1

Frame the problem

Before any code, three questions. They take 30 minutes to answer and save you weeks downstream.

Question 1: What is the business or research objective? "Predict median house price in California districts, accurate enough to inform an automated valuation pipeline that flags overpriced listings."

Question 2: How will success be measured? Median absolute error in dollars, on a held-out random sample of 2024-era California districts. The product team can tolerate ±$25k. Better than ±$25k is the goal.

Question 3: What is the current ? A team of human appraisers, manually entering prices, with a reported median absolute error of ±$30k. So beating $30k means we contributed something.

These three answers determine everything else. They tell you:

  • The task is supervised regression (continuous target).
  • The metric is median absolute error — the median of the per-district absolute errors, which a few wildly-mispredicted districts cannot drag upward, so it stays robust to outliers. (Note this is not MAE: MAE is the mean of those same absolute errors, which the capped, mispredicted districts can pull upward. The two are distinct metrics, and the code in §9 computes both.)
  • The baseline you must beat is the human appraisal at ±$30k.

If you cannot answer these three questions about your own project, stop coding and answer them first. They are the difference between an ML project and a programming exercise.

FIG 02.3.2

Get the data

Python
from pathlib import Path
import pandas as pd
import urllib.request
import tarfile

def fetch_housing(data_root: Path = Path("datasets/housing")) -> pd.DataFrame:
    """Download and extract the California Housing CSV."""
    data_root.mkdir(parents=True, exist_ok=True)
    tgz = data_root / "housing.tgz"
    if not tgz.exists():
        url = "https://github.com/ageron/data/raw/main/housing.tgz"
        urllib.request.urlretrieve(url, tgz)
        with tarfile.open(tgz) as tarball:
            tarball.extractall(path=data_root)
    return pd.read_csv(data_root / "housing" / "housing.csv")

housing = fetch_housing()
print(housing.shape)            # (20640, 10)
print(housing.head())

The columns:

ColumnTypeMeaning
longitudefloatdistrict centroid
latitudefloatdistrict centroid
housing_median_agefloatyears
total_roomsfloatacross all houses in district
total_bedroomsfloatacross all houses in district (some NaNs)
populationfloattotal people in district
householdsfloattotal households
median_incomefloattens of thousands of USD
median_house_valuefloatTARGET, capped at $500,001
ocean_proximityobject (categorical)five values

FIG 02.3.3

Explore the data (EDA)

Five things to do, in order. They take 15 minutes total. They surface 90% of the dataset's quirks.

3a. Summary statistics and dtypes

Python
housing.info()

What you learn: total_bedrooms has 207 missing values out of 20,640. ocean_proximity is object-typed (categorical, not numeric). Everything else is float.

Python
housing.describe()

What you learn: housing_median_age and median_house_value are capped (the max for both looks suspiciously round at 52.0 and 500001.0). The cap means the model will systematically under-predict expensive houses.

3b. Histograms

Python
import matplotlib.pyplot as plt
housing.hist(bins=50, figsize=(12, 8))
plt.tight_layout()
plt.show()

What you learn: most features are heavily right-skewed (total_rooms, total_bedrooms, population, households). The income distribution is bounded above by capping. Many distributions look like they want a log transform.

3c. Geographic scatter

Python
housing.plot(kind="scatter", x="longitude", y="latitude",
             c="median_house_value", cmap="jet",
             s=housing["population"] / 100,
             alpha=0.4, figsize=(10, 7))
plt.show()

What you learn: California shape becomes visible. Coastal districts are more expensive. The Bay Area and LA cluster around the high prices. Geography matters.

3d. Correlations with the target

Python
corr = housing.select_dtypes(include="number").corr()
print(corr["median_house_value"].sort_values(ascending=False))

What you learn: median_income correlates strongly (~0.69) with house price. The rest are weak (≤0.13 in magnitude). This is the most important diagnostic in the whole EDA: income dominates the signal.

3e. Engineered ratio features

Python
housing["rooms_per_household"] = housing["total_rooms"] / housing["households"]
housing["bedrooms_per_room"] = housing["total_bedrooms"] / housing["total_rooms"]
housing["population_per_household"] = housing["population"] / housing["households"]
corr = housing.select_dtypes(include="number").corr()
print(corr["median_house_value"].sort_values(ascending=False))

What you learn: bedrooms_per_room has a stronger correlation (-0.26) than any of the underlying counts. Engineered features matter. This will become a theme.

FIG 02.3.4

Stratified train/test split

Random splitting is fine when the dataset is large and well-mixed. For California Housing, income is so predictive that an income-imbalanced train/test split would the evaluation. The fix: stratify on a binned version of the income variable. Stratifying means drawing the so each income bracket keeps the same proportion it has in the full dataset, instead of leaving that to chance; pd.cut is what creates those brackets, chopping continuous income into five labeled bins, and StratifiedShuffleSplit samples the held-out 20% from within each bin.

Python
import numpy as np
from sklearn.model_selection import StratifiedShuffleSplit

# Discretize median_income into 5 buckets
housing["income_cat"] = pd.cut(housing["median_income"],
                                bins=[0., 1.5, 3.0, 4.5, 6., np.inf],
                                labels=[1, 2, 3, 4, 5])

split = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
for train_idx, test_idx in split.split(housing, housing["income_cat"]):
    strat_train = housing.iloc[train_idx]
    strat_test = housing.iloc[test_idx]

# Verify the bucket proportions match
print(strat_train["income_cat"].value_counts(normalize=True).sort_index())
print(strat_test["income_cat"].value_counts(normalize=True).sort_index())

# Drop the helper column
for s in (strat_train, strat_test):
    s.drop("income_cat", axis=1, inplace=True)

The stratification matters most for small test sets. The general rule: if the dataset has a so predictive that imbalance in that feature would bias the evaluation, stratify on a binned version of it. Time series get a different rule (chronological split, never random).

FIG 02.3.5

Prepare the data (the pipeline)

This is the section where every bug you saw in another chapter (leakage, pipeline contamination, fit-on-test) gets prevented architecturally. The tool is sklearn.pipeline.Pipeline composed with sklearn.compose.ColumnTransformer — the latter routes different columns to different transformers (numeric ones to one sub-pipeline, the categorical one to another) and stitches the outputs back into a single matrix.

Three preprocessing operations on the numeric columns:

  1. Impute missing values with the column median (we saw total_bedrooms has NaNs). SimpleImputer(strategy="median") is not just a one-off fillna: during fit it records each column's median as a learned statistic, then transform fills NaNs with that stored value — so the is patched with the training median, never its own.
  2. Add the three engineered ratio features.
  3. Scale every numeric column to zero mean and unit variance.

One preprocessing operation on the categorical column:

  1. One-hot encode ocean_proximity into five binary columns — one column per category, with a 1 in the column for that row's value and 0 elsewhere. The alternative, mapping the five categories to integers 0–4, would falsely tell the model that NEAR BAY (say, 3) is "more than" INLAND (1); one-hot avoids inventing an order that isn't there.
Python
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.base import BaseEstimator, TransformerMixin

class RatioFeatures(BaseEstimator, TransformerMixin):
    """Append rooms_per_household, bedrooms_per_room, population_per_household.

    Resolve indices by name from the column order passed in, so the ratios
    can't silently point at the wrong columns if num_columns is reordered.
    """
    def __init__(self, columns):
        self.columns = columns
    def fit(self, X, y=None):
        return self
    def transform(self, X):
        rooms = self.columns.index("total_rooms")
        bedrooms = self.columns.index("total_bedrooms")
        pop = self.columns.index("population")
        household = self.columns.index("households")
        rooms_per_household = X[:, rooms] / X[:, household]
        bedrooms_per_room = X[:, bedrooms] / X[:, rooms]
        pop_per_household = X[:, pop] / X[:, household]
        return np.c_[X, rooms_per_household, bedrooms_per_room, pop_per_household]

num_columns = ["longitude", "latitude", "housing_median_age", "total_rooms",
               "total_bedrooms", "population", "households", "median_income"]
cat_columns = ["ocean_proximity"]

num_pipeline = Pipeline([
    ("impute", SimpleImputer(strategy="median")),
    ("ratios", RatioFeatures(num_columns)),
    ("scale", StandardScaler()),
])

full_pipeline = ColumnTransformer([
    ("num", num_pipeline, num_columns),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat_columns),
])

# Separate target before fitting
X_train = strat_train.drop("median_house_value", axis=1)
y_train = strat_train["median_house_value"].copy()
X_test = strat_test.drop("median_house_value", axis=1)
y_test = strat_test["median_house_value"].copy()

X_train_prepared = full_pipeline.fit_transform(X_train)
X_test_prepared = full_pipeline.transform(X_test)   # transform, NOT fit_transform

print(X_train_prepared.shape)

The critical pattern: fit_transform on the train set, transform on the test set. The pipeline has learned the median for imputation and the mean/std for from the training data only. The test set never contributes to those statistics.

This is the structural fix to . Every preprocessing step that learns something (mean, std, , PCA basis) becomes a pipeline stage. The pipeline composes them and exposes a single fit_transform/transform interface. You cannot accidentally leak. The companion notebook makes this visceral: it fits a selector on the whole dataset before cross-validating against a pure-noise target and watches a clearly positive R2R^2 appear — the coefficient of determination R2=1SSres/SStotR^2 = 1 - \mathrm{SS}_\text{res}/\mathrm{SS}_\text{tot}, the fraction of the target's variance the model explains, where guessing the mean scores 0 and a perfect fit scores 1, so on noise the honest answer is about 0. Moving the selector inside the pipeline collapses that fabricated score back to zero.

RatioFeatures above is your first custom transformer: subclass BaseEstimator, TransformerMixin, implement fit (learn any statistics, here none, so it just returns self) and transform (return the modified array), and it slots into a pipeline exactly like a built-in. TransformerMixin is what gives you fit_transform for free.

FIG 02.3.6

Try several models

The first version of every step is a . Train three model families. Compare on held-out validation (or via cross-validation). Two of them are new here. A decision tree repeatedly splits the data on one at a time ("median_income < 3.1?"), funneling each district down to a leaf that predicts the average price of the training rows that landed there; left unconstrained it grows one leaf per training row and memorizes the set. A random forest trains many such trees, each on a bootstrap sample (a random draw of rows with replacement) and a random subset of features, then averages their predictions — the per-tree errors are largely independent, so averaging cancels most of them and the variance drops. (Both get their own chapter later; here you just need to read the scores.)

Python
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
import numpy as np

def evaluate_model(model, X, y, name: str, cv: int = 5):
    scores = cross_val_score(model, X, y, scoring="neg_mean_squared_error", cv=cv)
    rmse_scores = np.sqrt(-scores)
    print(f"{name:25s}  RMSE = {rmse_scores.mean():.0f}  ± {rmse_scores.std():.0f}")
    return rmse_scores

results = {}
for name, model in [
    ("LinearRegression", LinearRegression()),
    ("DecisionTreeRegressor", DecisionTreeRegressor(random_state=42)),
    ("RandomForestRegressor", RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)),
]:
    results[name] = evaluate_model(model, X_train_prepared, y_train, name)

Typical output on California Housing:

LinearRegression           RMSE = 68627  ± 2280
DecisionTreeRegressor      RMSE = 71407  ± 2300
RandomForestRegressor      RMSE = 50182  ± 1700

RMSE is the square root of the mean squared error; taking the root puts the number back in the target's own units, so an RMSE of 50,182 reads directly as "typically off by about $50k." (Squaring before averaging makes it large errors more heavily than the median AE does, which is why the two metrics disagree later.)

The random forest wins by ≈30%. Linear regression is competitive but limited. The single decision tree is overfit (it gets near-zero error on the but a much worse cross-validation RMSE — it has effectively memorized the training districts). The random forest's averaging over independent trees is exactly the variance-reduction that fixes that.

The lesson: try multiple model families before tuning. The difference between the best and worst model class is usually 2-10x the difference between a tuned and an untuned best model.

FIG 02.3.7

Fine-tune the best model

Random forest is the front-runner. Now tune its hyperparameters.

Two approaches. Grid search exhaustively enumerates combinations. Randomized search samples from distributions and is more efficient when you don't know the right ranges.

Python
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform

param_dist = {
    "n_estimators": randint(50, 300),
    "max_features": randint(2, 12),
    "max_depth": [None, 10, 20, 30],
    "min_samples_leaf": randint(1, 5),
}

rf = RandomForestRegressor(random_state=42, n_jobs=-1)
search = RandomizedSearchCV(
    rf, param_dist, n_iter=30,
    cv=5, scoring="neg_mean_squared_error",
    random_state=42, n_jobs=-1,
)
search.fit(X_train_prepared, y_train)

print("Best params:", search.best_params_)
print(f"Best CV RMSE: {np.sqrt(-search.best_score_):.0f}")

A typical result drops RMSE from ≈50k to ≈47k. The gains from tuning are real but small. The gains from picking the right model family in step 6 were five times larger. Internalize this. Most ML practitioners spend too much time on search and not enough on engineering and model selection.

Feature importance. A random forest scores how much each feature contributed to its splits across all the trees, exposed as feature_importances_ (the scores sum to 1, so each is a fraction of the total). You get it for free:

Python
feature_importance = search.best_estimator_.feature_importances_
# Combine with feature names (you have to assemble them from the pipeline)
num_features = num_columns + ["rooms_per_household", "bedrooms_per_room", "population_per_household"]
cat_features = list(full_pipeline.named_transformers_["cat"].get_feature_names_out(cat_columns))
all_features = num_features + cat_features

importance_pairs = sorted(zip(feature_importance, all_features), reverse=True)
for imp, name in importance_pairs[:10]:
    print(f"  {imp:.3f}  {name}")

You will see median_income at ≈0.35, latitude/longitude clustered at ≈0.15 each, and the engineered ratios in the next tier. Three or four features carry most of the signal.

FIG 02.3.8

Error analysis

Now the part most ML tutorials skip: look at the predictions. Where does the model fail?

Python
final_model = search.best_estimator_

# Predictions on the train set
y_train_pred = final_model.predict(X_train_prepared)
residuals = y_train - y_train_pred

# Worst predictions
worst_idx = np.argsort(np.abs(residuals))[-10:]
print("Worst predictions:")
print(pd.DataFrame({
    "actual": y_train.iloc[worst_idx].values,
    "predicted": y_train_pred[worst_idx],
    "error": residuals.iloc[worst_idx].values,
}).round(0))

# Plot residuals vs actual
plt.figure(figsize=(8, 6))
plt.scatter(y_train, residuals, alpha=0.3, s=5)
plt.axhline(0, color="red")
plt.xlabel("actual")
plt.ylabel("residual")
plt.show()

# Plot residuals vs each feature
for feat in ["median_income", "housing_median_age", "longitude"]:
    plt.figure()
    plt.scatter(X_train[feat], residuals, alpha=0.3, s=5)
    plt.axhline(0, color="red")
    plt.xlabel(feat)
    plt.ylabel("residual")
    plt.show()

Three patterns to look for:

  1. Heteroscedasticity (error spread that isn't constant across the range): residuals fan out at high predicted values. The model is less precise for expensive houses. Often related to the $500,001 cap in the target.
  2. Systematic over/under prediction: residuals are systematically positive (or negative) in certain ranges. Suggests a missing nonlinearity or interaction.
  3. Outlier districts: a handful of districts with huge errors. Worth looking at individually. They often reveal data quality issues (a district with total_bedrooms = 0, or a population of 10).

This is where you go back to feature engineering. The "iterate" arrow in the workflow points back from here.

FIG 02.3.9

Evaluate on the test set (once)

This is the moment the gets touched. Once. After this, every additional change to the pipeline invalidates the test number.

Python
X_test = strat_test.drop("median_house_value", axis=1)
y_test = strat_test["median_house_value"].copy()
X_test_prepared = full_pipeline.transform(X_test)

y_test_pred = final_model.predict(X_test_prepared)
test_rmse = np.sqrt(np.mean((y_test - y_test_pred) ** 2))
test_mae = np.mean(np.abs(y_test - y_test_pred))
test_median_ae = np.median(np.abs(y_test - y_test_pred))

print(f"Test RMSE        = {test_rmse:.0f}")
print(f"Test MAE         = {test_mae:.0f}")
print(f"Test median AE   = {test_median_ae:.0f}")

# 95% confidence interval on the RMSE, by bootstrap (resample the errors with replacement)
from scipy import stats
def rmse(squared_errors):
    return np.sqrt(np.mean(squared_errors))
squared_errors = (y_test - y_test_pred) ** 2
boot = stats.bootstrap([squared_errors], rmse, confidence_level=0.95, random_state=42)
ci_lo, ci_hi = boot.confidence_interval
print(f"95% CI for test RMSE: [{ci_lo:.0f}, {ci_hi:.0f}]")

Reporting a confidence interval is the second most-skipped step in ML practice (after error analysis). A test RMSE of 47,500 means nothing without "± what?". The 95% interval is the range you'd expect the true RMSE to fall in if you re-ran this on fresh test sets from the same distribution; the bootstrap above gets it by resampling the errors with replacement thousands of times and recomputing the RMSE each time, which assumes nothing about how the errors are distributed. For California Housing with 4,128 test samples, the 95% CI is typically [44,500, 50,500]. (The companion notebook loads the same data via fetch_california_housing, whose target is in units of $100k, so it prints this same RMSE as ≈ 0.47 — the dollar figure divided by 100,000.) Now you know the model beats the $30k ... or do you? Re-read the framing: the baseline was stated as a median absolute error of $30k, so the only number that legitimately compares against it is the model's median AE — printed above as test_median_ae, not test_rmse or test_mae. RMSE, MAE, and median AE are three different numbers — RMSE's squaring inflates it well above the median error, and MAE (the mean) sits between the two — so compare against the baseline on its own metric.

FIG 02.3.10

Save the pipeline as one artifact

A trained sklearn pipeline contains the model and all the preprocessing. You save them together. You load them together. You never re-implement the preprocessing at time, because the bugs are guaranteed.

Python
import joblib

# Save: bundle preprocessing pipeline + model
final_pipeline = Pipeline([
    ("preprocess", full_pipeline),
    ("model", final_model),
])
final_pipeline.fit(strat_train.drop("median_house_value", axis=1), y_train)
joblib.dump(final_pipeline, "housing_model.joblib")

# Load and use:
loaded = joblib.load("housing_model.joblib")
new_district = pd.DataFrame([{
    "longitude": -118.5,
    "latitude": 34.2,
    "housing_median_age": 25,
    "total_rooms": 1500,
    "total_bedrooms": 300,
    "population": 800,
    "households": 280,
    "median_income": 5.0,
    "ocean_proximity": "INLAND",
}])
predicted_price = loaded.predict(new_district)[0]
print(f"Predicted: ${predicted_price:,.0f}")

joblib.dump is the sklearn convention; pickle works too. For very large models, use joblib.dump(..., compress=3). For interop with other languages, export to ONNX (skl2onnx). For LLMs, use safetensors (another chapter).

FIG 02.3.11

Monitor in production (the loop that never ends)

Shipping is not done. After deployment, you monitor four things:

  1. Input distribution: are the features the model sees in production drawn from the same distribution as training? Kolmogorov-Smirnov test per , weekly. Alert on shift.
  2. Output distribution: are the predictions you produce drifting? Compare current week's prediction histogram to the test-set prediction histogram.
  3. Performance metric: when becomes available (delayed labels), compute the production performance metric. Compare to the test-set CI from step 9. If you fall outside, retrain.
  4. Latency and error rates: are predictions returning within SLA? Are there exceptions in the pipeline? Standard service-monitoring rules.

The Made-With-ML monitoring chapter (06-practice/madewithml-mlops-monitoring) walks through the tooling: WhyLabs, Evidently AI, custom dashboards in Grafana. The principles are the same regardless of tool: measure shift, alert on shift, retrain on shift.

A typical retraining cadence for a stable problem like housing prices is monthly or quarterly. For a fast-shifting problem (fraud, recommendations), it can be hourly. The cadence is a business decision informed by how fast the world changes.

FIG 02.3.12

The single notebook that does everything

For reference, here is the entire end-to-end pipeline in one place. Open this as notebook.ipynb in Colab, run cell by cell, modify, iterate. The whole thing is ≈80 lines.

Python
# Cell 1: imports
import urllib.request, tarfile
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import StratifiedShuffleSplit, RandomizedSearchCV
from sklearn.ensemble import RandomForestRegressor
from scipy.stats import randint
import joblib

# Cell 2: data
data_root = Path("datasets/housing")
data_root.mkdir(parents=True, exist_ok=True)
tgz = data_root / "housing.tgz"
if not tgz.exists():
    urllib.request.urlretrieve("https://github.com/ageron/data/raw/main/housing.tgz", tgz)
    with tarfile.open(tgz) as t:
        t.extractall(path=data_root)
housing = pd.read_csv(data_root / "housing" / "housing.csv")

# Cell 3: stratified split
housing["income_cat"] = pd.cut(housing["median_income"],
                                bins=[0., 1.5, 3.0, 4.5, 6., np.inf],
                                labels=[1, 2, 3, 4, 5])
split = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
for train_idx, test_idx in split.split(housing, housing["income_cat"]):
    strat_train, strat_test = housing.iloc[train_idx], housing.iloc[test_idx]
for s in (strat_train, strat_test):
    s.drop("income_cat", axis=1, inplace=True)

# Cell 4: pipeline
X_train = strat_train.drop("median_house_value", axis=1)
y_train = strat_train["median_house_value"].copy()
num_columns = ["longitude", "latitude", "housing_median_age", "total_rooms",
               "total_bedrooms", "population", "households", "median_income"]
cat_columns = ["ocean_proximity"]
num_pipeline = Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())])
full_pipeline = ColumnTransformer([
    ("num", num_pipeline, num_columns),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat_columns),
])

# Cell 5: train + tune
param_dist = {"n_estimators": randint(50, 300), "max_features": randint(2, 12)}
rf = RandomForestRegressor(random_state=42, n_jobs=-1)
final_pipeline = Pipeline([("prep", full_pipeline), ("model", rf)])
search = RandomizedSearchCV(
    final_pipeline,
    {"model__" + k: v for k, v in param_dist.items()},
    n_iter=20, cv=5, scoring="neg_mean_squared_error",
    random_state=42, n_jobs=-1,
)
search.fit(X_train, y_train)
print(f"Best CV RMSE: {np.sqrt(-search.best_score_):.0f}")

# Cell 6: test (once!)
X_test = strat_test.drop("median_house_value", axis=1)
y_test = strat_test["median_house_value"].copy()
y_pred = search.best_estimator_.predict(X_test)
print(f"Test RMSE: {np.sqrt(np.mean((y_test - y_pred)**2)):.0f}")

# Cell 7: save
joblib.dump(search.best_estimator_, "housing_model.joblib")

That is the entire chapter, runnable in three minutes on a free Colab. The rest is interpretation, intuition, and iteration.


FIG 02.4 · Safety lens · this chapter

The end-to-end pipeline you just built has three failure modes that won't appear in a textbook demo but will appear the day after deployment. All three are about the gap between the world the model was trained for and the world it operates in.

on the input side. The California Housing data was collected in 1990. House prices and income distributions have changed substantially since then, so a model trained on that snapshot should not be assumed valid for current data. Detection is mechanical: log deployment inputs and compare them with a versioned reference distribution. KS and chi-squared tests can help, but at large sample sizes tiny harmless differences produce small p-values. Monitor effect sizes (for example PSI, Wasserstein distance, or domain-specific deltas), data quality, and downstream performance; alert only when a sustained change crosses an operationally meaningful threshold. See 06-practice/madewithml-mlops-monitoring §data-drift and 13-fastbook/02_production §dataset-rot. The deeper habit: every production model has an implicit “valid for distributions like X” clause that you write down and test explicitly.

engineering can encode discrimination. The bedrooms_per_room feature you engineered seems neutral. The dataset, however, was geographically segmented in 1990 in ways that correlate with race because of decades of housing policy (redlining). When you train a model to predict prices and use it to inform mortgage decisions, you have built a system that institutionalizes those historical patterns. The ProPublica COMPAS investigation (13-fastbook/03_ethics §propublica-compas) is the standard reference for how this fails in practice. The fix is not "remove the feature". It is to audit per-demographic-group error rates before deployment, and to use techniques like reweighing or adversarial debiasing when disparities appear. The AI Safety Book chapter on fairness (05-safety/aisafetybook-index, "Fairness and AI" entry) walks through the technical mitigations. The non-negotiable safety habit: never ship a model that affects people without per-subgroup error analysis.

The pipeline is an attack surface. Every preprocessing step is a place where adversarial inputs can do something unexpected. A user-controlled total_rooms field with the value 101810^{18} produces a division-by-zero NaN that crashes your service. A malformed ocean_proximity string that wasn't in the training returns a one-hot of all zeros, which the model handles silently but unpredictably. The OWASP LLM Top 10 (26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications) catalogs the classical-ML equivalents under "ML Top 10": insecure deserialization (), (for LLMs, but the analogue for tabular ML is feature injection), model extraction. Two concrete habits: (a) every preprocessing transformer should have explicit input validation, not silent fallback; (b) the pickle format you used in step 10 can execute arbitrary code on load — for adversarial environments, prefer safetensors or signed- patterns.

The habits worth adopting before you ship anything:

  • Log every model prediction with its input. When something breaks, you need the trace.
  • Compute per-subgroup metrics on the . Not just aggregate . Per-demographic, per-region, per-time-bucket.
  • Have a manual override. A "kill switch" that disables the model and falls back to the human . The first time you need it, you will be very glad it exists.


FIG 02.6 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • One project, start to finish: California district statistics in, a dollar prediction out, saved as a single file you reload and call on a new district.
  • A Pipeline + ColumnTransformer leakage firewall that imputes, scales, and one-hot encodes without ever letting test data touch a fitted statistic.
  • A stratified train/test split, three competing model families judged on one harness, a tuned random forest, and a bootstrap confidence interval on the test metric.
  • A leakage bug you watch fabricate a great-looking score on a pure-noise target, then fix in one structural move.

~2 min on CPU · 126 cells · 11 checked exercises · runs in Colab


FIG 02.7 · Going further

  • 08-geron-notebooks/02_end_to_end_machine_learning_project

    the full Géron chapter. Run it cell by cell after this distillation.

  • 06-practice/geron-ml-project-checklist

    the 8-step checklist as a standalone PDF.

  • 06-practice/madewithml-mlops-design

    Made-With-ML's perspective on the same workflow, more production-oriented.

  • 06-practice/madewithml-mlops-eda and 06-practice/madewithml-mlops-preparation — deeper dives on EDA and data prep.
  • 06-practice/huyenchip-index

    Huyen Chip's posts on what changes when this pipeline goes to real production.

  • 06-practice/kaggle-intro-ml

    Kaggle's beginner course. Different framing, useful contrast.

  • 06-practice/madewithml-mlops-monitoring

    what to do after deployment. Tools and tactics.

  • 13-fastbook/02_production

    the fastbook chapter on the same topic. More opinionated, useful counter-perspective to Géron.


FIG 02.8 · What this enables

Chapters you can now read, with the connecting idea written out.

  • Same pipeline structure, classification-specific metrics. You will not have to re-learn the workflow.

  • The linear regression you ran as a baseline becomes the chapter where you understand its math from scratch.

  • The random forest that won this chapter becomes the algorithm you implement and debug.

  • The monitoring and retraining loop sketched in §11 gets a full chapter.


FIG 02.9 · 21 sources
  1. 02-code-refs/amidi-cs229-ml-tips
  2. 05-safety/aisafetybook-index
  3. 06-practice/geron-ml-project-checklist
  4. 06-practice/geron-readme
  5. 06-practice/huyenchip-index
  6. 06-practice/kaggle-data-viz
  7. 06-practice/kaggle-feature-engineering
  8. 06-practice/kaggle-intro-ml
  9. 06-practice/madewithml-foundations-notebooks
  10. 06-practice/madewithml-mlops-design
  11. 06-practice/madewithml-mlops-eda
  12. 06-practice/madewithml-mlops-evaluation
  13. 06-practice/madewithml-mlops-monitoring
  14. 06-practice/madewithml-mlops-preparation
  15. 06-practice/madewithml-mlops-serving
  16. 06-practice/madewithml-mlops-training
  17. 08-geron-notebooks/02_end_to_end_machine_learning_project
  18. 08-geron-notebooks/03_classification
  19. 13-fastbook/02_production
  20. 13-fastbook/03_ethics
  21. 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications