Unit 4 · Tutorial 7 of 8
Worked Example: House Price Prediction
A complete regression project, start to finish, on a realistic housing dataset. This is not a new concept — it is every technique from Tutorials 1 through 6, applied in the order a practitioner would apply them, with the decisions, the mistakes and the honest reporting that real work requires.
1 Learning objectives
By the end of this tutorial you will be able to
- Run a complete regression project on a real, messy dataset — from framing through to a written report.
- Audit a feature list for leakage before fitting anything, and remove features that will not be available at prediction time.
- Clean a dataset responsibly: handle missing values, encode categorical variables, and detect implausible values, documenting every decision.
- Build a scikit-learn Pipeline that keeps preprocessing inside the training fold, eliminating scaling leakage.
- Split data into training, validation and test sets, and use the validation set for every modelling decision.
- Diagnose the fitted model with residual plots, a learning curve and a validation curve, and act on what they show.
- Interpret coefficients honestly — including which ones can be read causally and which cannot.
- Write a model report that a non-technical stakeholder can act on, including limitations and the range of validity.
Estimated time
45 minutes of reading — this is the longest tutorial in the unit — plus 60–90 minutes to reproduce the notebook yourself. This tutorial is designed to be worked through at a keyboard, not skimmed.
2 Motivating example
The previous six tutorials built a single model on eight rows of synthetic bike-share data. Every technique was introduced in isolation, on clean numbers, with the arithmetic visible. That was necessary, but it is not what real work looks like.
Real work starts with a CSV file someone else produced, at a time when the questions you need answered were not the questions they were thinking about. It contains missing values, inconsistent units, categorical columns stored as free text, and one column that turns out to be a target leak. The relationships are noisy, the R² is moderate, and the residuals never look quite as clean as you hoped.
This tutorial walks through that reality. The dataset is a synthetic housing dataset with 1,200 records, deliberately constructed to include the kinds of problems a real file has — a mix of numeric and categorical features, a handful of missing values, one implausible outlier, one leaked feature, and a genuinely non-linear relationship.
A regional estate agency wants to give sellers an instant online price estimate. The estimate will be displayed before any agent has visited the property, so it must be based only on information the seller enters into the form.
That framing is doing real work. It tells us the prediction has to be made from a limited set of self-reported features. It rules out anything a surveyor would measure. And it sets the standard for what counts as useful: an estimate that is off by £40,000 is not helpful to a seller deciding whether to book a valuation.
What this tutorial is not
It is not a new algorithm, a new derivation or a new metric. Every tool used here was introduced in an earlier tutorial. What is new is the order of operations, the decisions that have to be made at each step, and the discipline of refusing to touch the test set until the end. That discipline is the actual content of this tutorial.
3 Concept explanation
3.1 The order of operations
Most of the damage in a regression project is done before a single model is fitted. The single most important decision is the order in which things happen, and the order that works is not the order that feels natural.
-
Frame the problem What is the target, in what units? Who is the prediction for? What is the cost of being wrong? Which features will be available at prediction time?
-
Audit the features for leakage Before anything else. Remove every feature that is recorded after the outcome, or is a consequence of it. Do this on paper, not in code.
-
Split the data Train, validation and test. Do this before any preprocessing, so that nothing computed from the validation or test data influences the training pipeline.
-
Explore the training set only Distributions, missing values, implausible values, relationships. Everything you learn here should be learned from training data alone.
-
Build a pipeline Cleaning, encoding and scaling wrapped so that every transformation is fitted on training data and applied to validation and test.
-
Fit and evaluate on validation Iterate here. Add features, transform features, change the model. Every decision is made against validation performance.
-
Diagnose Residual plots, learning curves, validation curves. This is where you find out whether the model is underfitting, overfitting, mis-specified or leaky.
-
Report Metrics, baseline comparison, residual plot, range of validity, known failure modes. Then, and only then, evaluate on the test set once.
The counter-intuitive parts are steps 2 and 3. Leakage is audited before looking at the data, because once you have seen that a leaked feature produces a beautiful fit, it is psychologically very hard to remove it. And the split happens before exploration, because exploration itself is a form of fitting — if you discover the median of a column by looking at the whole dataset and then use it to fill missing values, the test set has influenced your training pipeline.
3.2 The feature audit
The estate agency scenario tells us the prediction is made from a web form before anyone visits the property. So the question for every column is: will this value be known at the moment the form is submitted?
| Column | Type | Available at prediction time? | Decision |
|---|---|---|---|
floor_area_m2 |
Numeric | Yes — seller enters it | Include |
bedrooms |
Numeric | Yes | Include |
bathrooms |
Numeric | Yes | Include |
property_type |
Categorical | Yes | Include — one-hot encode |
age_years |
Numeric | Yes | Include |
distance_to_centre_km |
Numeric | Yes | Include |
epc_rating |
Ordinal | Yes | Include — encode as ordered integer |
has_garden |
Binary | Yes | Include |
postcode |
High-cardinality categorical | Yes | Treat with caution — see below |
sale_price |
Numeric | No — this is the target | Target, not a feature |
days_on_market |
Numeric | No — recorded after the sale | Remove — leakage |
survey_valuation |
Numeric | No — a professional valuation of the sold property | Remove — leakage |
agent_id |
Identifier | Technically yes | Remove — see below |
Three columns need a note.
days_on_market is the number of days the property was
listed before selling. It is only known once the sale has completed — which is
precisely the outcome we are trying to predict. It is a textbook target leak, and a
model that included it would report an excellent R² and be useless in production.
survey_valuation is a professional valuation carried
out by the buyer's surveyor. It is close to the sale price by construction, and it is
not available when the seller fills in the online form. Including it would be trivial
leakage.
agent_id is subtler. It is available at prediction time
in the sense that the seller could be assigned to an agent. But the agent is assigned
as a result of the property being listed, and the choice of agent correlates
with the type and price of property. Using it would bake the estate agency's internal
routing policy into the price estimate. And in deployment, a new property would not
yet have an agent. Remove it.
The test of a leaked feature
Ask: would this value be different if the sale had happened at a different
price? If yes, it leaks the target. days_on_market would be
different for a property that sold cheaply. survey_valuation would be
different. floor_area_m2 would not — the property has the same floor
area regardless of what it sells for. That question cuts through almost every case.
3.3 Cleaning: what to do with each problem
Cleaning is not a single operation. Each problem type has a standard remedy, and the choice between them is a decision you should be able to defend.
| Problem | Remedy | Caveat |
|---|---|---|
| Missing numeric values | Impute the training-set mean or median | Median is safer when the column is skewed. Either way, the imputation value must be computed on training data only. |
| Missing categorical values | Impute the most frequent category, or create an explicit "Unknown" category | "Unknown" is often the better choice, because missingness may itself be informative. |
| Implausible values | Investigate first; then correct, remove, or flag | Never delete silently. A negative floor area is a data-entry error; a very large one may be genuine. |
| Duplicate rows | Remove exact duplicates | Check whether the duplicates are genuine repeated listings or a data export artefact. |
| Categorical columns | One-hot encode for nominal; ordered integer for ordinal | One-hot encoding a high-cardinality column creates hundreds of features and invites overfitting. |
| Skewed target or features | Log-transform, if the values are strictly positive | Changes the quantity being minimised — a model with a log target minimises percentage error, not absolute error. |
Two of these deserve more than a row in a table.
Log-transforming the target. House prices are right-skewed: most
properties cluster around a middle value, with a long tail of expensive ones. A model
that minimises squared error on the raw price will spend most of its effort getting
the expensive properties approximately right, because those are the ones with the
largest absolute errors. If the goal is a percentage-accurate estimate, fitting on
log(price) and exponentiating the prediction is usually better. It is a
different model, optimising a different quantity, and it should be chosen
deliberately.
High-cardinality categoricals like postcode. A postcode is genuinely informative about house prices. But one-hot encoding 400 distinct postcodes creates 400 binary features, most of which have very few observations. That is a recipe for overfitting. Two options: reduce the cardinality by grouping postcodes into larger areas, or use a target-encoding scheme where each postcode is replaced by the average price in that postcode — computed on the training set only, and inside the cross-validation folds.
Target encoding is a leak waiting to happen
Replacing a category with the mean target value for that category is powerful, but
if you compute those means on the full training set and then use them as features,
the model can partially memorise the training targets. The correct procedure
computes the encoding using only the fold's training data during cross-validation.
scikit-learn's TargetEncoder exists precisely to handle this. In this
tutorial we will take the simpler route and group postcodes into broad regions.
3.4 The pipeline
A scikit-learn Pipeline chains transformations with a final estimator.
Its value is not convenience — it is correctness. When a pipeline is used inside
cross-validation or a train/validation split, every transformation is fitted on the
training portion of that split and applied to the held-out portion. That is exactly
the discipline Tutorial 6 demanded, enforced by construction.
For this project the pipeline has three stages:
-
Preprocessing. A
ColumnTransformerthat applies different transformations to numeric and categorical columns. Numeric columns are imputed and scaled; categorical columns are imputed and one-hot encoded. - Feature engineering — optional, and discussed in the worked example. Derived features such as rooms-per-square-metre can be added as their own numeric column.
-
Estimator.
LinearRegression, which is what this unit has been building towards.
The whole thing is a single object with fit, predict and
score methods, and it can be handed directly to
cross_val_score or GridSearchCV without any further care
about leakage.
3.5 What to expect from the results
Before fitting anything, it is worth being clear about what a good outcome looks like. This is a decision that shapes the whole project and it is worth making explicitly.
A regional house price model on self-reported features, with no survey, no
photographs and no condition assessment, should be expected to achieve a validation
R² somewhere in the range 0.65 to 0.85. Below 0.65 suggests the
features are too weak or something is broken. Above 0.85 on this kind of data is
possible but should prompt a leakage check — particularly if a feature like
survey_valuation slipped through the audit.
In absolute terms, if the median price is around £280,000, an RMSE of about £45,000 to £60,000 is realistic. That means a typical prediction is off by roughly 15–20% — which is useful for a ballpark estimate and nowhere near precise enough to be a formal valuation.
That last sentence is the one that matters, and it belongs in the report.
3.6 Reading the coefficients, carefully
A linear model on house prices produces coefficients that stakeholders will want to read as statements about the market: "each extra bedroom adds £18,000". That reading is almost always wrong, and understanding why is as much a part of the deliverable as the metrics.
| Reading | Licensed by the model? | Why |
|---|---|---|
| "In this dataset, properties with one more bedroom were associated with a £18,000 higher price, holding the other features constant." | Yes | This is exactly what the coefficient measures. It is a descriptive statement about the data, conditional on the model being approximately correct. |
| "Adding a bedroom would increase the sale price by £18,000." | No | This is a causal claim. Converting a study into a bedroom is a different property, in a different market segment. The model has no evidence about what happens when you intervene. |
| "Bedrooms are worth £18,000 each." | No | This treats the coefficient as a universal constant. It depends on the dataset, the other features included, and the range of properties covered. |
| "A property with one extra bedroom is predicted to sell for £18,000 more than an otherwise identical property." | With care | Closer, but "otherwise identical" is doing heavy lifting. Two properties that differ only in bedroom count and have identical floor area, location, age and condition are rare. The coefficient describes a comparison that may barely exist in the data. |
The practical rule: report coefficients as associations within the dataset, never as effects of interventions. And check whether the comparison the coefficient describes actually occurs in the data — with correlated features like floor area and bedroom count, it often does not.
4 Visual illustration
5 Worked example
The full walkthrough, in the order a practitioner would carry it out. Each step states what we are doing and why, because the reasoning is the transferable part.
Step 1 — Load and inspect the raw file
The dataset has 1,200 rows and 13 columns. Before touching anything, look at it.
import pandas as pd
import numpy as np
df = pd.read_csv("housing.csv")
print(df.shape) # (1200, 13)
print(df.dtypes)
print(df.isna().sum())
print(df.describe())
(1200, 13)
floor_area_m2 float64
bedrooms float64
bathrooms float64
property_type object
age_years float64
distance_to_centre_km float64
epc_rating object
has_garden object
postcode object
days_on_market float64
survey_valuation float64
agent_id object
sale_price float64
Missing values per column:
floor_area_m2 14
bedrooms 8
bathrooms 11
age_years 6
distance_to_centre_km 0
epc_rating 22
has_garden 4
... (others complete)
The file is a mix of numeric and object columns, with modest missingness in a handful of numeric and categorical columns. Nothing catastrophic. The next step is the important one and it does not involve the data at all.
Step 2 — Audit the features for leakage before exploring
From Section 3.2, three columns are removed on principle:
# These columns are recorded after the sale or are a consequence of it.
LEAKY = ["days_on_market", "survey_valuation", "agent_id"]
df = df.drop(columns=LEAKY)
TARGET = "sale_price"
y = df[TARGET]
X_raw = df.drop(columns=[TARGET])
Why this happens first
Once a model has been fitted with survey_valuation included and
produced an R² of 0.97, removing it feels like breaking something that works. The
order matters because of how it feels, not because of the algorithm.
Step 3 — Split before any preprocessing
from sklearn.model_selection import train_test_split
# 60% train, 20% validation, 20% test
X_train, X_temp, y_train, y_temp = train_test_split(
X_raw, y, test_size=0.40, random_state=42
)
X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp, test_size=0.50, random_state=42
)
print(f"train {len(X_train):>5} val {len(X_val):>5} test {len(X_test):>5}")
train 720 val 240 test 240
Every subsequent decision — imputation values, scaling statistics, feature selection, model choice — is made using the training and validation sets. The test set is locked away and will be used once, at Step 10.
Step 4 — Explore the training set only
print(y_train.describe())
print(f"skewness: {y_train.skew():.2f}")
print(X_train["property_type"].value_counts())
print(X_train["epc_rating"].value_counts())
print(X_train["has_garden"].value_counts())
# Implausible values?
print("floor area <= 0 :", (X_train["floor_area_m2"] <= 0).sum())
print("age < 0 :", (X_train["age_years"] < 0).sum())
print("bedrooms > 10 :", (X_train["bedrooms"] > 10).sum())
print("n postcodes :", X_train["postcode"].nunique())
count 720.00
mean 284,318.42
std 94,207.16
min 58,000.00
25% 218,500.00
50% 276,000.00
75% 340,750.00
max 612,000.00
skewness: 0.94
property_type detached 241 · semi 268 · terraced 174 · flat 37
epc_rating E 41 · D 138 · C 291 · B 217 · A 33
has_garden True 519 · False 201
floor area <= 0 : 1
age < 0 : 2
bedrooms > 10 : 1
n postcodes : 386
Three things to act on.
- The target is right-skewed (0.94). A log transform will help. We will fit both and compare on validation rather than assuming.
- Four implausible numeric values. One non-positive floor area, two negative ages, one property with 11 bedrooms. Each needs investigation before a decision.
- 386 postcodes in 720 training rows. One-hot encoding would create 386 features averaging 1.9 observations each. That is a guaranteed overfit. We will group into broad regions instead.
Step 5 — Handle the implausible values
# Inspect the suspicious rows before deciding
print(X_train.loc[X_train["floor_area_m2"] <= 0, ["floor_area_m2", "bedrooms", "bathrooms"]])
print(X_train.loc[X_train["age_years"] < 0, ["age_years", "property_type"]])
print(X_train.loc[X_train["bedrooms"] > 10, ["bedrooms", "floor_area_m2", "property_type"]])
floor_area_m2 bedrooms bathrooms
1183 -1.0 3.0 2.0 ← impossible, treat as missing
age_years property_type
402 -5.0 terraced ← impossible, treat as missing
917 -2.0 semi
bedrooms floor_area_m2 property_type
664 11.0 312.0 detached ← large but plausible for a large house
The decisions:
- A floor area of −1 is a data-entry error. Treat it as missing and let the imputation handle it.
- Negative ages are impossible. Treat as missing.
- 11 bedrooms in a 312 m² detached house is unusual but entirely possible. Keep it. Deleting real extreme values because they are inconvenient is one of the most common mistakes in applied regression.
for frame in (X_train, X_val, X_test):
frame.loc[frame["floor_area_m2"] <= 0, "floor_area_m2"] = np.nan
frame.loc[frame["age_years"] < 0, "age_years"] = np.nan
Document every cleaning decision
Each of those three decisions should end up in the report. "Three records had impossible floor areas or ages and were treated as missing" is a sentence a reviewer can evaluate. Silently doing it is not.
Step 6 — Group the postcodes
The postcode column has too many levels to one-hot encode. We will keep the outward code — the first part, such as "SW1" — which captures broad geography while cutting the cardinality substantially.
def reduce_postcode(series):
return series.str.split().str[0]
for frame in (X_train, X_val, X_test):
frame["postcode_area"] = reduce_postcode(frame["postcode"])
frame.drop(columns=["postcode"], inplace=True)
print(X_train["postcode_area"].nunique()) # 24
24
24 areas is manageable. One-hot encoding produces 24 binary columns, each with around 30 training observations on average — enough to estimate a coefficient from.
Step 7 — Build the pipeline
The pipeline encapsulates every transformation so that the correct fit/transform discipline is enforced automatically.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LinearRegression
numeric_cols = [
"floor_area_m2", "bedrooms", "bathrooms",
"age_years", "distance_to_centre_km",
]
categorical_cols = ["property_type", "postcode_area", "has_garden"]
ordinal_cols = ["epc_rating"] # E < D < C < B < A
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore", drop="first")),
])
ordinal_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("ordinal", OrdinalEncoder(
categories=[["E", "D", "C", "B", "A"]]
)),
])
preprocess = ColumnTransformer([
("num", numeric_pipe, numeric_cols),
("cat", categorical_pipe, categorical_cols),
("ord", ordinal_pipe, ordinal_cols),
])
model = Pipeline([
("preprocess", preprocess),
("regressor", LinearRegression()),
])
Three details worth noting.
- Median imputation rather than mean, because floor area is mildly skewed and the median is more robust to the very large houses.
-
drop="first"in the one-hot encoder. Without it, the dummy variables plus the intercept are perfectly collinear — the dummy variable trap from Tutorial 3. Dropping one level per categorical makes XᵀX invertible. -
Ordered encoding for
epc_rating. EPC ratings are ordinal — A is better than B — so treating them as ordered integers preserves that information. One-hot encoding would throw it away.
Step 8 — Fit and evaluate on validation
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
def report(name, y_true, y_pred):
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
mae = mean_absolute_error(y_true, y_pred)
r2 = r2_score(y_true, y_pred)
ratio = rmse / mae
print(f"{name:<28} RMSE £{rmse:>9,.0f} MAE £{mae:>9,.0f} "
f"R² {r2:>6.3f} RMSE/MAE {ratio:.2f}")
# --- baseline ---
baseline_pred = np.full(len(y_val), y_train.mean())
report("mean baseline", y_val, baseline_pred)
# --- model, raw target ---
model.fit(X_train, y_train)
report("linear (raw price)", y_val, model.predict(X_val))
# --- model, log target ---
model_log = Pipeline([
("preprocess", preprocess),
("regressor", LinearRegression()),
])
# log transform requires a wrapper so the prediction is exp'd back
from sklearn.compose import TransformedTargetRegressor
model_log = Pipeline([
("preprocess", preprocess),
("regressor", TransformedTargetRegressor(
regressor=LinearRegression(),
func=np.log, inverse_func=np.exp,
)),
])
model_log.fit(X_train, y_train)
report("linear (log price)", y_val, model_log.predict(X_val))
mean baseline RMSE £ 94,207 MAE £ 71,438 R² 0.000 RMSE/MAE 1.32
linear (raw price) RMSE £ 46,318 MAE £ 34,905 R² 0.758 RMSE/MAE 1.33
linear (log price) RMSE £ 44,061 MAE £ 33,112 R² 0.781 RMSE/MAE 1.33
The log transform improves the validation RMSE by about 5% and R² by two points. That is a real but modest gain, and it comes with a change in what the model is optimising — it now minimises proportional error rather than absolute error. Given that a seller's intuition about accuracy is proportional ("within about 10%"), the log model is the better choice even on the raw metrics.
Both models comfortably beat the mean baseline, which had an RMSE of £94,207. That is the first question to answer and it is answered clearly.
Step 9 — Diagnose
import matplotlib.pyplot as plt
y_pred_val = model_log.predict(X_val)
resid = y_val - y_pred_val
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
# residual plot against fitted values
ax[0].axhline(0, color="grey", linestyle="--", linewidth=1)
ax[0].scatter(y_pred_val, resid, alpha=0.4, s=12)
ax[0].set_xlabel("Fitted sale price (£)")
ax[0].set_ylabel("Residual (£)")
ax[0].set_title("Residuals vs fitted")
# residual plot against floor area — the most important feature
ax[1].axhline(0, color="grey", linestyle="--", linewidth=1)
ax[1].scatter(X_val["floor_area_m2"], resid, alpha=0.4, s=12)
ax[1].set_xlabel("Floor area (m²)")
ax[1].set_ylabel("Residual (£)")
ax[1].set_title("Residuals vs floor area")
What the plots show:
- Residuals vs fitted. The cloud is broadly shapeless, centred on zero. There is a mild widening at higher fitted values — heteroscedasticity — but no curvature. The model has captured the non-linear structure.
- Residuals vs floor area. No visible curvature, confirming that the relationship between size and price is roughly linear once the other features are included. There is more spread for larger properties.
The widening is worth investigating. It suggests the model's errors are proportionally larger for expensive properties, which is consistent with the raw scale results — fitting on the log target should have reduced it, but it has not eliminated it.
from sklearn.model_selection import learning_curve
train_sizes, train_scores, val_scores = learning_curve(
model_log, X_train, y_train,
train_sizes=np.linspace(0.1, 1.0, 8),
scoring="neg_root_mean_squared_error",
cv=5, random_state=42,
)
train_rmse = -train_scores.mean(axis=1)
val_rmse = -val_scores.mean(axis=1)
for size, tr, va in zip(train_sizes, train_rmse, val_rmse):
print(f"n={size:>4} train £{tr:>9,.0f} val £{va:>9,.0f} gap £{va-tr:>9,.0f}")
n= 72 train £ 31,904 val £ 56,182 gap £ 24,278
n= 144 train £ 37,806 val £ 51,240 gap £ 13,434
n= 216 train £ 40,913 val £ 48,891 gap £ 7,978
n= 288 train £ 42,540 val £ 47,502 gap £ 4,962
n= 360 train £ 43,388 val £ 46,751 gap £ 3,363
n= 432 train £ 44,050 val £ 46,255 gap £ 2,205
n= 504 train £ 44,372 val £ 45,945 gap £ 1,573
n= 720 train £ 44,612 val £ 44,061 gap £ -551
The training and validation curves converge as the training set grows, and the gap has essentially closed by the full training size. The validation error is still declining slowly, but the flattening is clear.
The diagnosis is that the model is well fitted but on the edge of high bias. More data would help a little — the validation curve has not quite plateaued — but the model has essentially extracted what these features can provide.
Step 10 — Evaluate on the test set, once
y_pred_test = model_log.predict(X_test)
report("test set — log model", y_test, y_pred_test)
# Also report the baseline on the test set for comparison
baseline_test = np.full(len(y_test), y_train.mean())
report("test set — baseline", y_test, baseline_test)
test set — log model RMSE £ 45,120 MAE £ 33,806 R² 0.772 RMSE/MAE 1.33
test set — baseline RMSE £ 94,318 MAE £ 71,502 R² -0.001 RMSE/MAE 1.32
The test performance is essentially identical to the validation performance. That is the good outcome: it means every decision made during validation generalised. A large gap in either direction would have been a warning — higher test error would mean we overfitted the validation set, and lower test error would suggest the validation split was unlucky.
Step 11 — Read the coefficients
# Extract the fitted coefficients from the pipeline
regressor = model_log.named_steps["regressor"].regressor_
preprocess = model_log.named_steps["preprocess"]
feature_names = preprocess.get_feature_names_out()
coefs = pd.DataFrame({
"feature": feature_names,
"coef_log": regressor.coef_,
})
# Convert log-scale coefficients to a percentage price change
coefs["pct_change_per_unit"] = (np.exp(coefs["coef_log"]) - 1) * 100
print(coefs.sort_values("coef_log", key=abs, ascending=False).to_string(index=False))
feature coef_log pct_change_per_unit
num__distance_to_centre_km -0.241 -21.4
num__floor_area_m2 0.198 21.9
num__age_years -0.071 -6.9
ord__epc_rating 0.058 6.0
num__bathrooms 0.034 3.5
num__bedrooms 0.021 2.1
cat__property_type_semi -0.134 -12.5
cat__property_type_terraced -0.268 -23.5
cat__property_type_flat -0.412 -33.8
cat__has_garden_True 0.043 4.4
cat__postcode_area_SW1 0.187 20.6
cat__postcode_area_N1 0.142 15.3
...
How to read these on the log scale
Because the model was fitted on log(price), a coefficient of
c corresponds to a proportional change of roughly
ec − 1 in the predicted price. The distance
coefficient of −0.241 means each extra kilometre from the centre is associated
with about a 21% lower price — a substantial effect, and in the expected
direction. Floor area of +0.198 means each extra square metre is associated with
about a 22% higher price — but that figure is per standard deviation,
because the numeric features were scaled, not per square metre. That distinction is
important and easy to get wrong.
Three cautions for the report.
-
Scaled features give standardised coefficients. Because the
numeric features were passed through
StandardScaler, the coefficient on floor area is per standard deviation, not per square metre. For the report we would rescale it — divide by the training-set standard deviation — to give a per-unit figure that a reader can act on. - Bedrooms and bathrooms have small coefficients. That is not because bedrooms do not matter; it is because they are correlated with floor area, and once floor area is in the model they add little. Their coefficients describe a comparison that rarely occurs in the data: two properties with identical floor area, location and age but different bedroom counts.
- None of these are causal. The distance coefficient does not mean "moving a house one kilometre closer to the centre would raise its price by 21%". It means "in this dataset, houses one kilometre closer to the centre sold for about 21% more, holding the other measured features constant."
The complete code
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer, TransformedTargetRegressor
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import learning_curve, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler
RANDOM_STATE = 42
# ------------------------------------------------------------------
# 1. Load
# ------------------------------------------------------------------
df = pd.read_csv("housing.csv")
# ------------------------------------------------------------------
# 2. Feature audit — remove leaks BEFORE looking at anything else
# ------------------------------------------------------------------
LEAKY = ["days_on_market", "survey_valuation", "agent_id"]
TARGET = "sale_price"
df = df.drop(columns=LEAKY)
y = df[TARGET]
X_raw = df.drop(columns=[TARGET])
# ------------------------------------------------------------------
# 3. Split
# ------------------------------------------------------------------
X_train, X_temp, y_train, y_temp = train_test_split(
X_raw, y, test_size=0.40, random_state=RANDOM_STATE
)
X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp, test_size=0.50, random_state=RANDOM_STATE
)
# ------------------------------------------------------------------
# 4/5. Clean implausible values, reduce postcode cardinality
# ------------------------------------------------------------------
for frame in (X_train, X_val, X_test):
frame.loc[frame["floor_area_m2"] <= 0, "floor_area_m2"] = np.nan
frame.loc[frame["age_years"] < 0, "age_years"] = np.nan
frame["postcode_area"] = frame["postcode"].str.split().str[0]
frame.drop(columns=["postcode"], inplace=True)
# ------------------------------------------------------------------
# 6. Pipeline
# ------------------------------------------------------------------
numeric_cols = [
"floor_area_m2", "bedrooms", "bathrooms",
"age_years", "distance_to_centre_km",
]
categorical_cols = ["property_type", "postcode_area", "has_garden"]
ordinal_cols = ["epc_rating"]
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore", drop="first")),
])
ordinal_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("ordinal", OrdinalEncoder(categories=[["E", "D", "C", "B", "A"]])),
])
preprocess = ColumnTransformer([
("num", numeric_pipe, numeric_cols),
("cat", categorical_pipe, categorical_cols),
("ord", ordinal_pipe, ordinal_cols),
])
model = Pipeline([
("preprocess", preprocess),
("regressor", TransformedTargetRegressor(
regressor=LinearRegression(),
func=np.log, inverse_func=np.exp,
)),
])
# ------------------------------------------------------------------
# 7. Fit and evaluate
# ------------------------------------------------------------------
model.fit(X_train, y_train)
def report(name, y_true, y_pred):
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
mae = mean_absolute_error(y_true, y_pred)
r2 = r2_score(y_true, y_pred)
print(f"{name:<28} RMSE £{rmse:>9,.0f} MAE £{mae:>9,.0f} "
f"R² {r2:>6.3f} RMSE/MAE {rmse/mae:.2f}")
report("mean baseline", y_val, np.full(len(y_val), y_train.mean()))
report("linear (log price)", y_val, model.predict(X_val))
# ------------------------------------------------------------------
# 8. Diagnostics
# ------------------------------------------------------------------
train_sizes, train_scores, val_scores = learning_curve(
model, X_train, y_train,
train_sizes=np.linspace(0.1, 1.0, 8),
scoring="neg_root_mean_squared_error",
cv=5, random_state=RANDOM_STATE,
)
for size, tr, va in zip(train_sizes, -train_scores.mean(axis=1), -val_scores.mean(axis=1)):
print(f"n={size:>4} train £{tr:>9,.0f} val £{va:>9,.0f}")
# ------------------------------------------------------------------
# 9. Final evaluation — once
# ------------------------------------------------------------------
report("test set — final model", y_test, model.predict(X_test))
6 Interactive activity
Break the pipeline in five ways
Each of the changes below is a mistake that a competent practitioner could make in good faith. Run each one, record the effect on the validation and test metrics, and then explain what went wrong. The aim is to see the consequences of leakage and mis-ordering with your own eyes, so that you recognise them in future.
-
Re-include a leak. Add
survey_valuationback into the feature set and refit. What happens to R²? What happens to the coefficient on floor area? Would a stakeholder be pleased with this model? - Scale before splitting. Standardise the numeric features using the mean and standard deviation of the full dataset, then split and fit. How much does the validation R² change compared with the honest pipeline? Is the effect large or small, and what does that tell you about when leakage matters most?
- One-hot encode the full postcode. Replace the reduced postcode area with the original postcode column and one-hot encode it. Compare the training and validation RMSE. What has happened?
-
Remove the ordinal encoding. Treat
epc_ratingas a plain unordered category and one-hot encode it. Does validation performance get better, worse or stay the same? What does that suggest about whether the ordering carries real information? -
Drop
distance_to_centre_km. Remove the strongest feature and refit. How much worse does validation get? This gives a crude sense of how much of the model's performance rests on a single variable.
Show the discussion
- The leak dominates everything. Validation R² rises to roughly 0.97. The coefficient on floor area collapses toward zero, because the survey valuation already encodes the size of the property and far more besides. A stakeholder would be delighted, and the model would be worthless in production because the valuation does not exist at the moment the estimate is displayed. This is why the audit happens first.
- The effect is small. Validation R² typically moves by less than 0.005. Scaling leakage is real but usually minor, because the mean and standard deviation of a large dataset barely change when a fifth of it is swapped out. It matters most on small datasets, where a handful of test observations can shift the mean meaningfully. The lesson is not that the practice is acceptable — it is a genuine error — but that the magnitude of a leak depends on the situation, and it should be measured rather than assumed.
- Severe overfitting. The one-hot encoder creates several hundred binary columns, many with one or two training observations. The model can fit each of these individually. Training RMSE falls sharply; validation RMSE rises. This is the classic high-cardinality trap and the reason for reducing postcode to an area.
- Performance barely changes. The EPC rating has a modest effect, and treating it as ordered or unordered makes little difference to the model's predictive accuracy. But the ordered encoding produces one interpretable coefficient instead of four, and it uses five fewer parameters. When the predictive performance is equivalent, the simpler model with the more interpretable parameterisation is the better choice.
- Validation RMSE roughly doubles. Distance to centre is by far the most informative feature in this dataset. Its removal takes the model from R² ≈ 0.78 to R² ≈ 0.45. This is worth knowing: it tells you that the model's usefulness depends heavily on a feature that a seller can report accurately, and that if the postcode is wrong, the estimate will be badly wrong.
The lesson to take from the exercise
Leakage does not produce a small error. It produces a model that looks excellent and fails completely. That is why the audit is done on paper, before any data is loaded, and why a suspiciously high score should always be investigated before it is celebrated.
7 Self-check questions
Answer each question in your own words before revealing the answer.
Question 1
Why is the feature audit performed before any data exploration?
Show answer
There is also a practical reason. If you explore first and then remove the leaked feature, you have already been influenced by seeing it. You might unconsciously adjust your cleaning decisions or your feature engineering to compensate. Doing the audit first removes the temptation entirely.