CRISP-DM question¶
Can a model estimate residential prices closely enough to support a first-pass budget screen?
Learning objective: establish a linear baseline, compare it with a random forest, and explain why a more complex model does not automatically improve out-of-sample error.
from pathlib import Path
import sys
ROOT = Path.cwd()
if not (ROOT / "src").exists():
ROOT = ROOT.parent
sys.path.insert(0, str(ROOT / "src"))
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import Image, display
from tutorial.data import write_datasets
plt.rcParams.update({
"figure.dpi": 150,
"font.family": "serif",
"font.serif": ["Times New Roman", "STIXGeneral", "DejaVu Serif"],
"mathtext.fontset": "stix",
"axes.grid": True,
"grid.color": "#c8c8c8",
"grid.linewidth": 0.5,
"grid.alpha": 0.7,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.labelsize": 10,
"axes.titlesize": 10.5,
"lines.linewidth": 1.4,
})
paths = write_datasets(ROOT / "data")
print(f"Project root: {ROOT.name}")
df = pd.read_csv(paths["housing"])
df.head()
df.describe().T
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
import numpy as np
X = df.drop(columns="price_hkd_m")
y = df["price_hkd_m"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.20, random_state=42
)
numeric = ["living_area_sqm", "rooms", "building_age", "distance_to_centre_km"]
categorical = ["condition", "neighborhood"]
preprocess = ColumnTransformer([
("numeric", StandardScaler(), numeric),
("categorical", OneHotEncoder(handle_unknown="ignore"), categorical),
])
The linear model is the interpretable baseline. The forest receives the identical transformed features and split; only the estimator changes.
linear = Pipeline([("prepare", preprocess), ("model", LinearRegression())])
forest = Pipeline([
("prepare", preprocess),
("model", RandomForestRegressor(
n_estimators=250,
min_samples_leaf=3,
random_state=42,
n_jobs=1,
)),
])
_ = linear.fit(X_train, y_train)
_ = forest.fit(X_train, y_train)
def evaluate_regressor(model):
prediction = model.predict(X_test)
return {
"MAE": mean_absolute_error(y_test, prediction),
"RMSE": mean_squared_error(y_test, prediction) ** 0.5,
"R2": r2_score(y_test, prediction),
}
pd.DataFrame({
"linear regression": evaluate_regressor(linear),
"random forest": evaluate_regressor(forest),
}).T
Both models see the same holdout rows. RMSE is retained next to MAE because it penalises large pricing errors more heavily. The random forest is a useful comparison, but its extra flexibility is not evidence of extra value.
Check fold-to-fold stability¶
Five-fold cross-validation on the training-plus-holdout rows shows how much the result depends on one particular holdout. Each fold refits its own preprocessing pipeline.
from sklearn.model_selection import KFold, cross_validate
folds = KFold(n_splits=5, shuffle=True, random_state=42)
scoring = {
"mae": "neg_mean_absolute_error",
"rmse": "neg_root_mean_squared_error",
}
def cv_regression(model):
scores = cross_validate(model, X, y, cv=folds, scoring=scoring)
return {
"MAE mean": -scores["test_mae"].mean(),
"MAE sd": scores["test_mae"].std(),
"RMSE mean": -scores["test_rmse"].mean(),
"RMSE sd": scores["test_rmse"].std(),
}
pd.DataFrame({
"linear regression": cv_regression(linear),
"random forest": cv_regression(forest),
}).T
Figure B2 | Actual versus predicted prices. Source: tutorial calculations on the 20% holdout set. Points close to the reference line indicate lower prediction error.
display(Image(filename=str(ROOT / "assets" / "img" / "housing-predictions.png")))
Interpretation and exercise¶
The linear baseline has lower MAE and RMSE in this controlled synthetic case. That is a useful result, not a failed demonstration:
- the data-generating process is close to additive;
- the sample is small;
- default tree settings are not guaranteed to be optimal;
- model complexity must earn its place on the holdout set.
Change one forest hyperparameter and ask whether the improvement is stable or only a single-split accident.