Project — ML Model Comparison Report
house_prices.csv dataset from Lesson 12, systematically cross-validate FOUR very different regression models from Section 2 — LinearRegression, SVR, KNeighborsRegressor, and GradientBoostingRegressor — using cross_validate, build a real comparison table with MAE/RMSE/R² (Lesson 15), run residual analysis on the winner, and write a short recommendation of which model you'd actually deploy and why.
The Project Brief
Lesson 12 trained exactly two models — LinearRegression and GradientBoostingRegressor — on one train/test split, and picked a winner. That's a reasonable first pass, but Lesson 11 already warned that a single split is "just one estimate," and Section 2 covered several more algorithms that never even got a turn. This project fixes both gaps at once: more models, evaluated the honest way.
house_prices.csv dataset from Lesson 12 (sqft, bedrooms, bathrooms, city, age_years → price), benchmark four regression algorithms from Section 2 with 5-fold cross-validation, rank them on MAE, RMSE, and R², inspect the winner's residual plot, and write a one-paragraph recommendation of which model to ship.cv=5, scoring for RMSE, MAE, and R² simultaneously (Lesson 11).Load and Preprocess — Reusing Lesson 12's Pipeline
No need to reinvent this part — Lesson 12 already worked out the cleaning and encoding steps for this exact dataset. The only change here: features are kept UNSCALED at this stage, because each model below will get its own scaler inside its own pipeline in Section 3, to keep cross-validation leakage-free per fold.
import pandas as pd df = pd.read_csv("house_prices.csv") # Same cleaning as Lesson 12 Section 3 df["bedrooms"] = df["bedrooms"].fillna(df["bedrooms"].median()) assert df.isnull().sum().sum() == 0 # Same encoding as Lesson 12 Section 4 df_encoded = pd.get_dummies(df, columns=["city"], drop_first=True) X = df_encoded.drop(columns=["price"]) y = df_encoded["price"] print(f"X: {X.shape}, y: {y.shape}") # X: (1500, 7), y: (1500,)
X/y straight to cross_validate in Section 4. Internally it performs its own 5-fold splitting, training on 4 folds and evaluating on the held-out 5th fold, five separate times per model — so a manual train_test_split up front would just be redundant.Defining Four Candidate Models
Each model is wrapped in its own Pipeline with a StandardScaler step. That matters more than it looks: when cross_validate runs, the scaler gets refit from scratch on each fold's training portion only — exactly the same leakage-avoidance discipline from Lesson 12 Section 4, just applied automatically across all 5 folds instead of one manual split.
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.svm import SVR from sklearn.neighbors import KNeighborsRegressor from sklearn.ensemble import GradientBoostingRegressor candidates = { "LinearRegression": Pipeline([ ("scaler", StandardScaler()), ("model", LinearRegression()) ]), "SVR (RBF kernel)": Pipeline([ ("scaler", StandardScaler()), ("model", SVR(kernel="rbf", C=10, epsilon=0.1)) ]), "KNeighborsRegressor": Pipeline([ ("scaler", StandardScaler()), ("model", KNeighborsRegressor(n_neighbors=5)) ]), "GradientBoostingRegressor": Pipeline([ ("scaler", StandardScaler()), ("model", GradientBoostingRegressor( n_estimators=200, learning_rate=0.05, max_depth=3, random_state=42 )) ]), }
Pipeline([("scaler", ...), ("model", ...)]) shape purely so the comparison code in Section 4 can loop over all four candidates identically, with no special-casing.Cross-Validating All Four Models with cross_validate
Lesson 11 used cross_val_score for a single metric at a time. Its sibling, sklearn.model_selection.cross_validate, accepts a DICTIONARY of scorers and computes all of them per fold in one pass — exactly what's needed to get MAE, RMSE, and R² (Lesson 15) together.
from sklearn.model_selection import cross_validate scoring = { "rmse": "neg_root_mean_squared_error", "mae": "neg_mean_absolute_error", "r2": "r2", } cv_results = {} for name, pipeline in candidates.items(): result = cross_validate(pipeline, X, y, cv=5, scoring=scoring) cv_results[name] = result print(f"{name}: fold RMSEs = {(-result['test_rmse']).round(0)}") # LinearRegression: fold RMSEs = [29450. 31200. 28700. 30850. 29100.] # SVR (RBF kernel): fold RMSEs = [34600. 33900. 35400. 34100. 33750.] # KNeighborsRegressor: fold RMSEs = [32100. 31450. 32900. 31000. 31650.] # GradientBoostingRegressor: fold RMSEs = [25100. 26300. 24700. 25850. 25200.]
"neg_root_mean_squared_error" returns NEGATIVE RMSE values — the least-negative (closest to zero) is the best fold. That's exactly why the code above negates them back with a leading minus sign, -result["test_rmse"], before printing or averaging.Building the Comparison Table
Five fold scores per model, per metric, is a lot of numbers — collapse them into mean AND standard deviation, then assemble one small pandas DataFrame that's actually readable.
import pandas as pd rows = [] for name, result in cv_results.items(): rows.append({ "Model": name, "RMSE (mean)": -result["test_rmse"].mean(), "RMSE (std)": result["test_rmse"].std(), "MAE (mean)": -result["test_mae"].mean(), "R2 (mean)": result["test_r2"].mean(), }) comparison_df = pd.DataFrame(rows).sort_values("RMSE (mean)").reset_index(drop=True) print(comparison_df.round(0))
| Model | RMSE (mean) | RMSE (std) | MAE (mean) | R² (mean) |
|---|---|---|---|---|
| GradientBoostingRegressor | $25,430 | $570 | $18,760 | 0.906 |
| LinearRegression | $29,860 | $920 | $22,410 | 0.869 |
| KNeighborsRegressor | $31,820 | $650 | $24,050 | 0.851 |
| SVR (RBF kernel) | $34,350 | $580 | $25,900 | 0.828 |
GradientBoostingRegressor has both the lowest mean RMSE AND a tight $570 standard deviation across its 5 folds — meaning it performed consistently well, not just well on average by luck of one easy fold. LinearRegression's wider $920 standard deviation signals more fold-to-fold variability, worth factoring into a real deployment decision alongside the raw mean.SVR is highly sensitive to its C and epsilon hyperparameters — the values used here are reasonable defaults, not the result of tuning. A fair follow-up (see this lesson's challenge) would run GridSearchCV (Lesson 11) on SVR specifically before ruling it out entirely; it's very possible its ranking here would improve.Residual Analysis on the Winning Model
A low RMSE alone doesn't guarantee healthy errors (Lesson 15, Section 6) — so before recommending GradientBoostingRegressor, fit it once on a proper train/test split and look at its residuals directly.
from sklearn.model_selection import train_test_split import matplotlib.pyplot as plt X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) winner = candidates["GradientBoostingRegressor"] winner.fit(X_train, y_train) y_pred = winner.predict(X_test) residuals = y_test - y_pred fig, axes = plt.subplots(1, 2, figsize=(12, 5)) axes[0].scatter(y_test, y_pred, alpha=0.5) axes[0].plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], "r--") axes[0].set_xlabel("Actual"); axes[0].set_ylabel("Predicted") axes[0].set_title("Predicted vs Actual") axes[1].scatter(y_pred, residuals, alpha=0.5) axes[1].axhline(y=0, color="r", linestyle="--") axes[1].set_xlabel("Predicted"); axes[1].set_ylabel("Residuals") axes[1].set_title("Residuals vs Predicted") plt.tight_layout(); plt.show()
Writing the Recommendation
A comparison table isn't the deliverable — a decision is. Below is the kind of short, direct paragraph a real project report would end with. Treat the specific numbers as illustrative for this toy dataset, not a general claim that gradient boosting always beats these other three algorithms.
LinearRegression is the runner-up and would be the fallback choice if model interpretability (reading its coefficients directly) mattered more than the roughly 15% RMSE improvement gradient boosting provides. KNeighborsRegressor and untuned SVR trail on every metric and aren't recommended as-is.GradientBoostingRegressor with 200 estimators trains slower than LinearRegression or KNeighborsRegressor — usually a non-issue for a dataset this size, but worth checking at production scale.GridSearchCV on each candidate — this lesson's challenge — could shift these results further.The Complete Script, Start to Finish
Every step from this project, combined into one runnable comparison pipeline.
import pandas as pd from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.svm import SVR from sklearn.neighbors import KNeighborsRegressor from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import cross_validate, train_test_split # 1. Load and preprocess (reusing Lesson 12's steps) df = pd.read_csv("house_prices.csv") df["bedrooms"] = df["bedrooms"].fillna(df["bedrooms"].median()) df_encoded = pd.get_dummies(df, columns=["city"], drop_first=True) X = df_encoded.drop(columns=["price"]) y = df_encoded["price"] # 2. Define four candidate pipelines candidates = { "LinearRegression": Pipeline([("scaler", StandardScaler()), ("model", LinearRegression())]), "SVR (RBF kernel)": Pipeline([("scaler", StandardScaler()), ("model", SVR(kernel="rbf", C=10, epsilon=0.1))]), "KNeighborsRegressor": Pipeline([("scaler", StandardScaler()), ("model", KNeighborsRegressor(n_neighbors=5))]), "GradientBoostingRegressor": Pipeline([ ("scaler", StandardScaler()), ("model", GradientBoostingRegressor(n_estimators=200, learning_rate=0.05, max_depth=3, random_state=42)) ]), } # 3. Cross-validate all four on MAE, RMSE, R2 together scoring = {"rmse": "neg_root_mean_squared_error", "mae": "neg_mean_absolute_error", "r2": "r2"} rows = [] for name, pipeline in candidates.items(): result = cross_validate(pipeline, X, y, cv=5, scoring=scoring) rows.append({ "Model": name, "RMSE (mean)": -result["test_rmse"].mean(), "RMSE (std)": result["test_rmse"].std(), "MAE (mean)": -result["test_mae"].mean(), "R2 (mean)": result["test_r2"].mean(), }) # 4. Rank the comparison table comparison_df = pd.DataFrame(rows).sort_values("RMSE (mean)").reset_index(drop=True) print(comparison_df.round(0)) # 5. Residual-check the winner on a held-out split best_name = comparison_df.iloc[0]["Model"] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) winner = candidates[best_name] winner.fit(X_train, y_train) residuals = y_test - winner.predict(X_test) print(f"Winner: {best_name}, residual mean: {residuals.mean():.1f}, residual std: {residuals.std():.1f}")
Use the candidates dictionary and comparison_df from Sections 3–5 as your starting point for each task below.
Using Lesson 11's
GridSearchCV, search over C in [1, 10, 100] and epsilon in [0.05, 0.1, 0.5] for the SVR pipeline, with cv=5 and scoring="neg_root_mean_squared_error". Does its tuned RMSE close the gap with GradientBoostingRegressor, or even overtake it?
Add
RandomForestRegressor (from sklearn.ensemble) as a fifth pipeline in candidates, using similar settings to the GradientBoostingRegressor (n_estimators=200, random_state=42). Rerun Section 4's cross-validation loop and Section 5's comparison table with all five models included.
Repeat Section 6's residual analysis, but for
LinearRegression instead of the winner. Compare its residuals-vs-predicted plot to the winner's — does it show more of a pattern (heteroscedasticity or non-linearity, from Lesson 15)? Write 2–3 sentences on whether that changes or reinforces this lesson's recommendation.
💡 Show hints if you're stuck
- Task 1:
GridSearchCV(candidates["SVR (RBF kernel)"], param_grid={"model__C": [1,10,100], "model__epsilon": [0.05,0.1,0.5]}, cv=5, scoring="neg_root_mean_squared_error")— note themodel__prefix needed to reach inside a Pipeline's named step. - Task 2:
from sklearn.ensemble import RandomForestRegressor— it takes the exact samePipelineshape as the other tree-based model already incandidates. - Task 3: If
LinearRegression's residuals show a visible curve while the winner's don't, that's independent evidenceGradientBoostingRegressoris capturing a real non-linear relationshipLinearRegressionmisses — strengthening, not weakening, the original recommendation.