📐 Section 2 · Supervised Learning 🔴 Capstone Project MODULE 12

Project — House Price Prediction Model

⏱️ 95 min · hands-on
📖 Full Regression Pipeline
🧩 4 Quiz Questions
🏗️ 1 Challenge · 3 tasks
Your progress in Section 2100%
🎯 The Project: This is the capstone of Section 2 — every algorithm and tool from Lessons 6–11 comes together into one real regression pipeline. You'll take a synthetic house_prices.csv dataset, encode a categorical feature, scale the numeric ones, train BOTH LinearRegression (Lesson 6) and GradientBoostingRegressor (Lesson 10) on the same split, compare them honestly with RMSE and R², pick the better one, and interpret which features actually drove its predictions.

The Project Brief

Imagine a real-estate analytics tool that estimates a home's sale price from a handful of easy-to-collect details: its size, room counts, which city it's in, and how old it is. We'll build exactly that — a regression model predicting a continuous price value.

As with earlier project-style lessons in this track, house_prices.csv here is a plausible, illustrative synthetic dataset built to teach the full workflow end to end — not a real published housing dataset. Every pandas and scikit-learn method used is 100% real and correct; the specific numbers are stand-ins for whatever CSV a real project would hand you.

📐
sqft
Total finished square footage of the house, a continuous number.
🛏️
bedrooms
Number of bedrooms, an integer count.
🛁
bathrooms
Number of bathrooms, can include half-baths as .5 values.
🏙️
city
A categorical column — which of 4 cities the house is in. Needs encoding before modeling (Lesson 3).
📅
age_years
How many years old the house is.
💰
price
The target column — the sale price in dollars. This is what the model predicts.
1
Load and explore
Read the CSV, check its shape, preview rows, and inspect dtypes.
2
Check for missing data and describe the stats
.isnull().sum() and .describe(), then clean what's missing.
3
Encode city, scale the numeric features
One-hot encode the categorical city column, then scale the continuous numeric columns (Lesson 3).
4
Train/test split
train_test_split on the fully preprocessed feature matrix.
5
Train two models
LinearRegression (Lesson 6) and GradientBoostingRegressor (Lesson 10), on the exact same split.
6
Compare with RMSE and R²
Evaluate both honestly on the same held-out test set and pick the better one.
7
Interpret which features mattered
Read coefficients or feature importances from the winning model.

Load the Data and Take a First Look

Same opening move as any tabular ML project: read the file, then look before touching anything.

load_data.py
PYTHON
import pandas as pd

df = pd.read_csv("house_prices.csv")

print(df.shape)
# (1500, 6)

print(df.head())
df.head()
sqftbedroomsbathroomscityage_yearsprice
0145032.0Riverton12312000
1210042.5Fairview5438500
2980NaN1.0Riverton34198000
3320053.5Hillcrest2612500
4168032.0Oakdale18287500

Row 2 is missing its bedrooms value — worth remembering for Section 3. Next, the dtypes pandas inferred:

inspect_dtypes.py
PYTHON
print(df.dtypes)
# sqft            int64
# bedrooms      float64   (has a missing value, so pandas upgraded it from int)
# bathrooms     float64
# city           object   (text/categorical)
# age_years       int64
# price           int64
# dtype: object
📝
city is the one column that needs encoding
city holds text categories, not numbers — scikit-learn's regressors can't consume it directly. Section 4 handles this with one-hot encoding, the same technique from Lesson 3.

Check for Missing Values and Describe the Stats

Two quick pandas calls before any modeling happens: .isnull().sum() to find gaps, and .describe() to get a feel for each column's range and center.

check_missing.py
PYTHON
print(df.isnull().sum())
# sqft           0
# bedrooms      21
# bathrooms      0
# city           0
# age_years      0
# price          0
# dtype: int64

print(df.describe().round(1))
df.describe().round(1) — numeric columns only
statsqftbedroomsbathroomsage_yearsprice
count1500.01479.01500.01500.01500.0
mean1842.63.32.216.4356200.0
std612.41.00.811.2128450.0
min620.01.01.00.0112000.0
25%1380.03.01.57.0261500.0
50%1790.03.02.015.0339000.0
75%2260.04.02.524.0428750.0
max4100.06.04.552.0798000.0

21 missing bedrooms values out of 1,500 rows is small (about 1.4%) — small enough to fill rather than drop.

clean_data.py
PYTHON
# Fill the small number of missing bedroom counts with the column median
df["bedrooms"] = df["bedrooms"].fillna(df["bedrooms"].median())

assert df.isnull().sum().sum() == 0, "Still missing values!"

Encoding city and Scaling the Numeric Features

Two separate preprocessing moves, both from Lesson 3: turn the categorical city column into numeric columns scikit-learn can use, and put the numeric features on a comparable scale — important for LinearRegression's coefficients to be well-behaved, and standard practice generally.

encode_city.py
PYTHON
# One-hot encode city: turns 1 text column into several 0/1 columns
# drop_first=True avoids redundant columns (Lesson 3's dummy-variable trap)
df_encoded = pd.get_dummies(df, columns=["city"], drop_first=True)

print(df_encoded.columns.tolist())
# ['sqft', 'bedrooms', 'bathrooms', 'age_years', 'price',
#  'city_Hillcrest', 'city_Oakdale', 'city_Riverton']
# -> 'Fairview' became the implicit baseline city (all three dummies == 0)
📝
pd.get_dummies vs. sklearn's OneHotEncoder
pd.get_dummies is a quick, readable way to one-hot encode when working directly in pandas, exactly like Lesson 3. In a production pipeline you'd more often use sklearn.preprocessing.OneHotEncoder inside a ColumnTransformer, since it remembers the exact categories learned on training data and can be safely applied to new data later — but for this project's purposes, get_dummies keeps things clear and direct.

Next, separate features (X) from the target (price), then scale ONLY the continuous numeric columns — scaling the already-binary dummy columns wouldn't hurt correctness much here, but there's no need to.

scale_features.py
PYTHON
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

X = df_encoded.drop(columns=["price"])
y = df_encoded["price"]

# Split BEFORE scaling, so the scaler only ever sees training data (Lesson 8)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

numeric_cols = ["sqft", "bedrooms", "bathrooms", "age_years"]

scaler = StandardScaler()
X_train_scaled = X_train.copy()
X_test_scaled = X_test.copy()
X_train_scaled[numeric_cols] = scaler.fit_transform(X_train[numeric_cols])
X_test_scaled[numeric_cols] = scaler.transform(X_test[numeric_cols])

print(f"Train: {X_train_scaled.shape}, Test: {X_test_scaled.shape}")
# Train: (1200, 7), Test: (300, 7)
⚠️
Split first, then scale — order matters
Notice train_test_split runs BEFORE scaler.fit_transform. If the scaler were fit on the full dataset before splitting, information about the test set's distribution (its mean and standard deviation) would leak into training — a subtle form of the same test-set leakage warned about in Lesson 4 and Lesson 11.

Model 1 — LinearRegression

Starting with the simplest, most interpretable option from Lesson 6.

train_linear.py
PYTHON
from sklearn.linear_model import LinearRegression

lin_model = LinearRegression()
lin_model.fit(X_train_scaled, y_train)

lin_preds = lin_model.predict(X_test_scaled)

Evaluation happens together with Model 2 in Section 7, so both are compared on identical footing.

Model 2 — GradientBoostingRegressor

Now the sequential ensemble approach from Lesson 10 — its regression counterpart, built to capture non-linear relationships LinearRegression can't (e.g. price per square foot possibly changing at very large or very small home sizes).

train_gbr.py
PYTHON
from sklearn.ensemble import GradientBoostingRegressor

gbr_model = GradientBoostingRegressor(
    n_estimators=200,
    learning_rate=0.05,
    max_depth=3,
    random_state=42
)
gbr_model.fit(X_train_scaled, y_train)

gbr_preds = gbr_model.predict(X_test_scaled)
Tree-based models don't strictly need scaled input
Unlike LinearRegression, tree-based models like GradientBoostingRegressor split on raw feature THRESHOLDS rather than distances or weighted sums, so they're largely insensitive to feature scale. Using the same X_train_scaled for both models here is done for a clean, controlled comparison — not because gradient boosting required it.

Comparing the Two Models — RMSE and R²

Both models were trained on the identical X_train_scaled/y_train and are now evaluated on the identical, untouched X_test_scaled/y_test — the only fair way to compare them (Lesson 4, Lesson 11).

compare_models.py
PYTHON
from sklearn.metrics import mean_squared_error, r2_score
import numpy as np

def evaluate(name, y_true, y_pred):
    rmse = np.sqrt(mean_squared_error(y_true, y_pred))
    r2 = r2_score(y_true, y_pred)
    print(f"{name}: RMSE=${rmse:,.0f}, R2={r2:.3f}")
    return rmse, r2

lin_rmse, lin_r2 = evaluate("LinearRegression", y_test, lin_preds)
gbr_rmse, gbr_r2 = evaluate("GradientBoostingRegressor", y_test, gbr_preds)

The illustrative comparison below shows the SHAPE of a result on a toy dataset — not a verified benchmark of either algorithm's general performance:

output (illustrative)
OUTPUT
# LinearRegression: RMSE=$28,450, R2=0.884
# GradientBoostingRegressor: RMSE=$24,120, R2=0.917
Illustrative test RMSE — lower is better (dollars of typical prediction error)
LinearRegression
$28,450
R2=0.88
GradientBoostingRegressor
$24,120
R2=0.92
Reading RMSE and R² together
RMSE is in the same units as price — a lower dollar figure means the model's typical prediction is closer to the true price. (Lesson 6) measures the proportion of price variance the model explains, from 0 to 1. In this illustrative example, GradientBoostingRegressor wins on BOTH metrics — a lower RMSE and a higher R² — which is the winning model for this walkthrough. On a different real dataset, either model could come out ahead; that's exactly why both were actually trained and compared rather than assumed.
⚠️
A single split is still just one estimate
Exactly as Lesson 4 and Lesson 11 emphasized, this is ONE train/test split. A more rigorous comparison would wrap both models in cross_val_score (Lesson 4) across several folds before declaring a winner — worth doing as an extension in this lesson's challenge below.

Interpreting the Winning Model — Feature Importance

With GradientBoostingRegressor as the winner in this walkthrough, its .feature_importances_ attribute (Lesson 10) shows which features drove its predictions most.

feature_importance.py
PYTHON
importances = pd.Series(gbr_model.feature_importances_, index=X_train_scaled.columns)
print(importances.sort_values(ascending=False).round(3))

# sqft                0.612
# age_years           0.148
# bathrooms           0.101
# city_Hillcrest      0.068
# bedrooms            0.041
# city_Riverton       0.021
# city_Oakdale        0.009
# dtype: float64
Illustrative feature importances from the trained GradientBoostingRegressor
sqft
0.612
61.2%
age_years
0.148
14.8%
bathrooms
0.101
10.1%
city_Hillcrest
0.068
6.8%
bedrooms
0.041
4.1%
city_Riverton
0.021
2.1%
city_Oakdale
0.009
0.9%
🥇
sqft dominates — 61.2%
By far the strongest single driver of predicted price in this illustrative run — square footage carries most of the signal.
🥈
age_years and bathrooms — next tier
Meaningful secondary contributors, together explaining roughly a quarter of the model's decisions.
🏙️
city dummies — smaller but present
city_Hillcrest stands out slightly more than the other two city dummies, hinting Hillcrest homes may be priced somewhat differently — worth investigating further, not treating as settled.
🛏️
bedrooms — smallest numeric contributor
Once sqft and bathrooms are known, raw bedroom count adds comparatively little extra predictive signal in this dataset.
⚠️
Importance is not causation
sqft being the top feature means it's the most USEFUL for prediction — not proof that adding square footage to a specific house would raise its value by a predictable, fixed amount. As in earlier project lessons, treat this ranking as a starting hypothesis for further analysis, not a final causal conclusion.

The Complete Script, Start to Finish

Every step from this lesson, combined into one runnable pipeline against a CSV shaped like house_prices.csv.

house_price_predictor.py — COMPLETE PROGRAM
PYTHON
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import mean_squared_error, r2_score

# 1. Load and take a first look
df = pd.read_csv("house_prices.csv")
print("Shape:", df.shape)
print(df.head())

# 2. Check for missing values, then clean
print(df.isnull().sum())
df["bedrooms"] = df["bedrooms"].fillna(df["bedrooms"].median())
assert df.isnull().sum().sum() == 0

# 3. Encode city, then split into features/target
df_encoded = pd.get_dummies(df, columns=["city"], drop_first=True)
X = df_encoded.drop(columns=["price"])
y = df_encoded["price"]

# 4. Split, then scale (fit scaler on train only)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
numeric_cols = ["sqft", "bedrooms", "bathrooms", "age_years"]
scaler = StandardScaler()
X_train_scaled = X_train.copy()
X_test_scaled = X_test.copy()
X_train_scaled[numeric_cols] = scaler.fit_transform(X_train[numeric_cols])
X_test_scaled[numeric_cols] = scaler.transform(X_test[numeric_cols])

# 5. Train both models on the identical split
lin_model = LinearRegression().fit(X_train_scaled, y_train)
gbr_model = GradientBoostingRegressor(
    n_estimators=200, learning_rate=0.05, max_depth=3, random_state=42
).fit(X_train_scaled, y_train)

# 6. Evaluate both honestly on the same test set
def evaluate(name, y_true, y_pred):
    rmse = np.sqrt(mean_squared_error(y_true, y_pred))
    r2 = r2_score(y_true, y_pred)
    print(f"{name}: RMSE=${rmse:,.0f}, R2={r2:.3f}")

evaluate("LinearRegression", y_test, lin_model.predict(X_test_scaled))
evaluate("GradientBoostingRegressor", y_test, gbr_model.predict(X_test_scaled))

# 7. Interpret the winning model's feature importances
importances = pd.Series(gbr_model.feature_importances_, index=X_train_scaled.columns)
print(importances.sort_values(ascending=False).round(3))

Writing Up Findings

A trained model isn't the deliverable by itself — the point is turning it into a few statements someone at a real-estate analytics team could act on. Treat these as an example of the KIND of takeaway a real project like this would produce, not as claims about any real housing market.

📏
Size dominates price
sqft is the strongest single predictor by a wide margin — any pricing tool built on this data would lean on it most heavily.
🌲
Boosting beat linear on this data
GradientBoostingRegressor outperformed LinearRegression on both RMSE and R² in this run — suggesting some genuinely non-linear structure in how price relates to the features.
🏙️
City effects are secondary but present
The city dummy variables contribute noticeably less than the numeric features, but aren't negligible — Hillcrest showed the largest city-level effect in this illustrative run.
⚠️
A finding is a hypothesis, not a conclusion
"GradientBoostingRegressor wins here" is a result on THIS synthetic dataset and THIS one train/test split — not a universal claim that boosting always beats linear regression for housing price prediction. Different real datasets, feature sets, or preprocessing choices could easily flip which model wins, which is exactly why both were trained and honestly compared rather than one being assumed superior from the start.

That's the full Section 2 capstone — and every algorithm from Lessons 6 through 11 fed into it: linear regression's theory, gradient boosting's sequential ensembles, the preprocessing habits from Lesson 3, the train/test discipline from Lesson 4, and the honest comparison habits from Lesson 11's hyperparameter tuning lesson. Section 3 next moves from regression into a much deeper look at classification metrics.

🧩 Knowledge Check — Lesson 12
4 questions on the capstone pipeline before you move on.
1. Why was train_test_split called BEFORE fitting the StandardScaler in this project?
2. In this project, what does a LOWER RMSE on the test set indicate?
3. Why did pd.get_dummies use drop_first=True when encoding city?
4. According to gbr_model.feature_importances_ in this illustrative run, which feature mattered most?
💪
Try It Yourself — Lesson 12
Extend the capstone project · Advanced Level

The base pipeline works end to end — now push it further. Use the cleaned df_encoded, X_train_scaled/X_test_scaled/y_train/y_test from Sections 4–9 as your starting point for each task below.

Task 1: Cross-validate the comparison 🔁

Instead of comparing on a single split, wrap both LinearRegression and GradientBoostingRegressor in cross_val_score (Lesson 4) with cv=5 and scoring="r2", on the full preprocessed X/y. Does the winner from Section 7 still win under 5-fold cross-validation, or is it closer than the single split suggested?
Task 2: Tune the GradientBoostingRegressor 🎛️

Using Lesson 11's GridSearchCV, search over n_estimators in [100, 200, 400] and max_depth in [2, 3, 5] for GradientBoostingRegressor, with cv=5 and scoring="neg_root_mean_squared_error". Print .best_params_ and compare .best_estimator_'s test RMSE to Section 7's untuned version.
Task 3: Try XGBoost as a third contender 🥊

Using Lesson 10's XGBRegressor (import from the xgboost package), fit it on the exact same X_train_scaled/y_train and add it to the RMSE/R² comparison table from Section 7. Does it outperform both earlier models on this dataset?
💡 Show hints if you're stuck
  • Task 1: cross_val_score(LinearRegression(), X, y, cv=5, scoring="r2") and the same call swapping in GradientBoostingRegressor(...) — compare the mean of each array of 5 scores.
  • Task 2: Remember neg_root_mean_squared_error is negated (higher/less-negative is better) — take the negative of .best_score_ to read it as a normal positive RMSE.
  • Task 3: from xgboost import XGBRegressor; xgb_model = XGBRegressor(n_estimators=200, learning_rate=0.05, max_depth=3, random_state=42) — everything else in the evaluation code stays the same.
Finished the capstone project?
Mark it complete to track your progress.
🎉

Section 2 Complete — Capstone Project Done!

You've built a full regression pipeline from scratch: encoding, scaling, training two very different models honestly on the same split, comparing them with RMSE and R², and interpreting feature importance. That's every core skill from Lessons 6–11, combined. Section 3 is next — a deep dive into classification metrics beyond plain accuracy.

Module 12 of 24 Section 2 — Supervised Learning Algorithms