What You'll Build

By the end of this project you'll have a complete tabular ML pipeline that:

Dataset: Ames Housing — available on Kaggle. Download train.csv and test.csv before starting.

Project Setup

# Create a virtual environment
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate

# Install dependencies
pip install pandas numpy matplotlib seaborn scikit-learn xgboost jupyter

Create a house_price.ipynb notebook and follow along section by section.

Step 1 — Load and Inspect the Data

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

train = pd.read_csv('train.csv')
test  = pd.read_csv('test.csv')

print(train.shape)          # (1460, 81)
print(train.dtypes.value_counts())
print(train['SalePrice'].describe())

Key observations from the first look:

Step 2 — Exploratory Data Analysis

Target Distribution

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
train['SalePrice'].hist(bins=50, ax=axes[0])
axes[0].set_title('SalePrice (raw)')

np.log1p(train['SalePrice']).hist(bins=50, ax=axes[1])
axes[1].set_title('SalePrice (log-transformed)')
plt.tight_layout()
plt.show()
Why log-transform the target? The raw SalePrice is right-skewed — a few mansions pull the mean up. Log-transforming makes the distribution approximately normal, which reduces the penalty for large errors and generally improves linear model performance.

Correlation Heatmap (Top Features)

numeric = train.select_dtypes(include=np.number)
corr = numeric.corr()['SalePrice'].abs().sort_values(ascending=False)
print(corr.head(15))

The top correlated numeric features are: OverallQual, GrLivArea, GarageCars, GarageArea, and TotalBsmtSF.

top_feats = corr.index[1:11]  # exclude SalePrice itself
sns.heatmap(
    numeric[list(top_feats) + ['SalePrice']].corr(),
    annot=True, fmt='.2f', cmap='coolwarm'
)
plt.title('Top 10 Feature Correlations with SalePrice')
plt.show()

Outliers

plt.scatter(train['GrLivArea'], train['SalePrice'], alpha=0.4)
plt.xlabel('GrLivArea'); plt.ylabel('SalePrice')
plt.title('Living Area vs Sale Price')
plt.show()

# Two large houses sold cheap — likely non-market sales, remove them
train = train[~((train['GrLivArea'] > 4000) & (train['SalePrice'] < 300_000))]

Step 3 — Feature Engineering

Handle Missing Values

def fill_missing(df):
    # "NA" in docs means the feature doesn't exist — fill with "None"
    none_cols = ['PoolQC','MiscFeature','Alley','Fence','FireplaceQu',
                 'GarageType','GarageFinish','GarageQual','GarageCond',
                 'BsmtQual','BsmtCond','BsmtExposure','BsmtFinType1','BsmtFinType2',
                 'MasVnrType']
    for c in none_cols:
        if c in df.columns:
            df[c] = df[c].fillna('None')

    # Numeric NAs where 0 makes sense
    zero_cols = ['GarageYrBlt','GarageArea','GarageCars',
                 'BsmtFinSF1','BsmtFinSF2','BsmtUnfSF','TotalBsmtSF',
                 'BsmtFullBath','BsmtHalfBath','MasVnrArea']
    for c in zero_cols:
        if c in df.columns:
            df[c] = df[c].fillna(0)

    # Fill remaining with mode or median
    for c in df.columns:
        if df[c].isnull().any():
            if df[c].dtype == 'object':
                df[c] = df[c].fillna(df[c].mode()[0])
            else:
                df[c] = df[c].fillna(df[c].median())
    return df

train = fill_missing(train)
test  = fill_missing(test)

Create New Features

for df in [train, test]:
    df['TotalSF']        = df['TotalBsmtSF'] + df['1stFlrSF'] + df['2ndFlrSF']
    df['TotalBathrooms'] = (df['FullBath'] + 0.5 * df['HalfBath']
                            + df['BsmtFullBath'] + 0.5 * df['BsmtHalfBath'])
    df['HouseAge']       = df['YrSold'] - df['YearBuilt']
    df['RemodAge']       = df['YrSold'] - df['YearRemodAdd']
    df['IsRemodeled']    = (df['YearBuilt'] != df['YearRemodAdd']).astype(int)

Encode Categoricals

combined = pd.concat([train.drop('SalePrice', axis=1), test], axis=0)
combined = pd.get_dummies(combined)

X_train = combined.iloc[:len(train)]
X_test  = combined.iloc[len(train):]
y_train = np.log1p(train['SalePrice'])

Step 4 — Model Comparison with Cross-Validation

from sklearn.linear_model import Ridge
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
from xgboost import XGBRegressor

def rmse_cv(model, X, y, cv=5):
    scores = cross_val_score(
        model, X, y,
        scoring='neg_root_mean_squared_error',
        cv=cv
    )
    return -scores.mean(), scores.std()

models = {
    'Ridge':         Ridge(alpha=10),
    'Random Forest': RandomForestRegressor(n_estimators=200, random_state=42),
    'XGBoost':       XGBRegressor(n_estimators=500, learning_rate=0.05,
                                  max_depth=4, random_state=42, n_jobs=-1),
}

for name, model in models.items():
    mean, std = rmse_cv(model, X_train, y_train)
    print(f'{name:20s}  RMSE: {mean:.4f} ± {std:.4f}')

Expected results (approximate):

ModelCV RMSE (log scale)
Ridge~0.135
Random Forest~0.143
XGBoost~0.127
RMSE on log scale: An RMSE of 0.127 means your predictions are off by roughly e^0.127 − 1 ≈ 13.5% on average. That's the target Kaggle leaderboard metric here.

Step 5 — Hyperparameter Tuning (XGBoost)

from sklearn.model_selection import GridSearchCV

param_grid = {
    'n_estimators': [400, 600],
    'max_depth':    [3, 4, 5],
    'learning_rate':[0.03, 0.05],
    'subsample':    [0.8, 1.0],
}

xgb = XGBRegressor(random_state=42, n_jobs=-1)
gs  = GridSearchCV(xgb, param_grid, cv=5,
                   scoring='neg_root_mean_squared_error',
                   verbose=1)
gs.fit(X_train, y_train)

print('Best params:', gs.best_params_)
print('Best RMSE:  ', -gs.best_score_)
Time warning: Grid search over those 2×3×2×2 = 24 combinations × 5-fold CV = 120 model fits. On a laptop this takes 5–15 minutes. Start with fewer combinations if you want faster feedback.

Step 6 — Final Predictions

best_model = gs.best_estimator_
best_model.fit(X_train, y_train)

y_pred_log = best_model.predict(X_test)
y_pred     = np.expm1(y_pred_log)   # reverse the log1p transform

submission = pd.DataFrame({'Id': test['Id'], 'SalePrice': y_pred})
submission.to_csv('submission.csv', index=False)
print(submission.head())

Step 7 — Feature Importance

import matplotlib.pyplot as plt

feat_imp = pd.Series(
    best_model.feature_importances_,
    index=X_train.columns
).sort_values(ascending=False).head(20)

feat_imp.plot(kind='barh', figsize=(8, 8))
plt.title('Top 20 Feature Importances — XGBoost')
plt.gca().invert_yaxis()
plt.tight_layout()
plt.show()

You should see OverallQual, TotalSF, and GrLivArea dominating — consistent with the correlation analysis from EDA.

What to Try Next

Full notebook: The complete notebook for this project (with all outputs rendered) is linked from the AI/ML Roadmap Phase 5 module.