Project — House Price Prediction Model
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.
.isnull().sum() and .describe(), then clean what's missing.city column, then scale the continuous numeric columns (Lesson 3).train_test_split on the fully preprocessed feature matrix.LinearRegression (Lesson 6) and GradientBoostingRegressor (Lesson 10), on the exact same split.Load the Data and Take a First Look
Same opening move as any tabular ML project: read the file, then look before touching anything.
import pandas as pd df = pd.read_csv("house_prices.csv") print(df.shape) # (1500, 6) print(df.head())
| sqft | bedrooms | bathrooms | city | age_years | price | |
|---|---|---|---|---|---|---|
| 0 | 1450 | 3 | 2.0 | Riverton | 12 | 312000 |
| 1 | 2100 | 4 | 2.5 | Fairview | 5 | 438500 |
| 2 | 980 | NaN | 1.0 | Riverton | 34 | 198000 |
| 3 | 3200 | 5 | 3.5 | Hillcrest | 2 | 612500 |
| 4 | 1680 | 3 | 2.0 | Oakdale | 18 | 287500 |
Row 2 is missing its bedrooms value — worth remembering for Section 3. Next, the dtypes pandas inferred:
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 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.
print(df.isnull().sum()) # sqft 0 # bedrooms 21 # bathrooms 0 # city 0 # age_years 0 # price 0 # dtype: int64 print(df.describe().round(1))
| stat | sqft | bedrooms | bathrooms | age_years | price |
|---|---|---|---|---|---|
| count | 1500.0 | 1479.0 | 1500.0 | 1500.0 | 1500.0 |
| mean | 1842.6 | 3.3 | 2.2 | 16.4 | 356200.0 |
| std | 612.4 | 1.0 | 0.8 | 11.2 | 128450.0 |
| min | 620.0 | 1.0 | 1.0 | 0.0 | 112000.0 |
| 25% | 1380.0 | 3.0 | 1.5 | 7.0 | 261500.0 |
| 50% | 1790.0 | 3.0 | 2.0 | 15.0 | 339000.0 |
| 75% | 2260.0 | 4.0 | 2.5 | 24.0 | 428750.0 |
| max | 4100.0 | 6.0 | 4.5 | 52.0 | 798000.0 |
21 missing bedrooms values out of 1,500 rows is small (about 1.4%) — small enough to fill rather than drop.
# 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.
# 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 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.
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)
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.
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).
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)
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).
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:
# LinearRegression: RMSE=$28,450, R2=0.884 # GradientBoostingRegressor: RMSE=$24,120, R2=0.917
price — a lower dollar figure means the model's typical prediction is closer to the true price. R² (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.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.
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
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.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.
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.
sqft is the strongest single predictor by a wide margin — any pricing tool built on this data would lean on it most heavily.GradientBoostingRegressor outperformed LinearRegression on both RMSE and R² in this run — suggesting some genuinely non-linear structure in how price relates to the features.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.
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.
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?
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.
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 inGradientBoostingRegressor(...)— compare the mean of each array of 5 scores. - Task 2: Remember
neg_root_mean_squared_erroris 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.