Imagine you're a real estate agent in Ames, Iowa. You have a list of 2,000+ houses, each with 79 features β from obvious ones like square footage and bedroom count to subtle details like basement height, garage quality, and proximity to amenities. Your task: predict the exact sale price of each house.
This is the essence of the Kaggle House Prices: Advanced Regression Techniques competition, one of the most popular machine learning challenges. It has helped thousands of data scientists develop practical regression skills.
In this blog post, I'll walk you through my production-level solution β not just a quick notebook to climb the leaderboard, but a robust, maintainable, and scalable approach suitable for real-world deployment.
House price prediction extends far beyond academic exercises. Real-world applications include:
This dataset's richness β 79 features covering virtually every residential property aspect β provides an ideal environment for practicing advanced feature engineering, model selection, and hyperparameter tuning in realistic scenarios.
The dataset contains information about residential homes sold in Ames, Iowa, between 2006 and 2010.
Feature Categories
Target Variable
Dataset Statistics
My solution stands out from typical Kaggle notebooks through its structure and rigor.
Instead of a monolithic Jupyter notebook, the project is organized into reusable modules:
House-Prices-Advanced-Regression-Techniques-Production-Level/
βββ data/
β βββ raw/
β βββ processed/
β βββ external/
βββ notebooks/
β βββ 01_exploratory_data_analysis.ipynb
β βββ 02_feature_engineering.ipynb
β βββ 03_model_experimentation.ipynb
βββ src/
β βββ init.py
β βββ data_.py
β βββ feature_engineer.py
β βββ model_trainer.py
β βββ evaluation.py
β βββ config.py
βββ models/
β βββ saved_models/
βββ requirements.txt
βββ README.md
βββ main.py
All paths, parameters, and settings are centralized:
from pathlib import Path
class Config:
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
RAW_DATA = DATA_DIR / 'raw'
PROCESSED_DATA = DATA_DIR / 'processed'
MODELS_DIR = BASE_DIR / 'models'
TRAIN_FILE = RAW_DATA / 'train.csv'
TEST_FILE = RAW_DATA / 'test.csv'
RANDOM_SEED = 42
TEST_SIZE = 0.2
TARGET = 'SalePrice'
NUMERICAL_FEATURES = []
CATEGORICAL_FEATURES = []
DATE_FEATURES = ['YearBuilt', 'YearRemodAdd', 'GarageYrBlt']
XGBOOST_PARAMS = {
'objective': 'reg:squarederror',
'n_estimators': 1000,
'learning_rate': 0.05,
'max_depth': 6,
'subsample': 0.8,
'colsample_bytree': 0.8
}
python
import pandas as pd
from pathlib import Path
from typing import Tuple
from .config import Config
class Data:
"""Handles all data and initial preprocessing."""
def __init__(self, config: Config):
self.config = config
def load_raw_data(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""Load training and test datasets."""
try:
train_df = pd.read_csv(self.config.TRAIN_FILE)
test_df = pd.read_csv(self.config.TEST_FILE)
print(f"β
Data loaded: {train_df.shape[0]} train, {test_df.shape[0]} test samples")
return train_df, test_df
except FileNotFoundError as e:
print(f"β Error data: {e}")
raise
def save_processed_data(self, df: pd.DataFrame, filename: str) -> None:
"""Save processed dataframe to disk."""
output_path = self.config.PROCESSED_DATA / filename
output_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(output_path, index=False)
print(f"β
Saved processed data to {output_path}")
Understanding your data is crucial before modeling. Here's what I discovered:
import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(12, 6))
sns.histplot(train_df['SalePrice'], bins=50, kde=True)
plt.title('Distribution of Sale Prices', fontsize=16)
plt.xlabel('Sale Price ($)', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.grid(True, alpha=0.3)
plt.show()
Key Insight: Sale prices are right-skewed, with most houses priced between $100K-$200K and a few luxury properties pulling the average up. This suggests applying a log transformation to normalize the distribution.
numerical_cols = train_df.select_dtypes(include=['int64', 'float64']).columns
correlation_matrix = train_df[numerical_cols].corr()
plt.figure(figsize=(14, 10))
sns.heatmap(correlation_matrix[['SalePrice']].sort_values(by='SalePrice', ascending=False),
annot=True, cmap='coolwarm', center=0, fmt='.2f')
plt.title('Feature Correlation with SalePrice', fontsize=16)
plt.show()
missing_percent = train_df.isnull().mean() * 100
missing_df = pd.DataFrame({
'Feature': train_df.columns,
'Missing_Percent': missing_percent
}).sort_values('Missing_Percent', ascending=False)
print(missing_df[missing_df['Missing_Percent'] > 10])
missing_percent = train_df.isnull().mean() * 100
missing_df = pd.DataFrame({
'Feature': train_df.columns,
'Missing_Percent': missing_percent
}).sort_values('Missing_Percent', ascending=False)
print(missing_df[missing_df['Missing_Percent'] > 10])
Features with Significant Missing Values:
Handling Strategy: Drop features with >80% missing values. For others, use appropriate imputation based on feature type.
Raw data rarely performs well in models. We must transform, create, and select features to extract maximum predictive power.
import numpy as np
from sklearn.impute import SimpleImputer
class FeatureEngineer:
"""Handles all feature engineering tasks."""
def __init__(self, config: Config):
self.config = config
def handle_missing_values(self, df: pd.DataFrame) -> pd.DataFrame:
"""Impute missing values appropriately."""
num_imputer = SimpleImputer(strategy='median')
for col in self.config.NUMERICAL_FEATURES:
if col in df.columns:
df[col] = num_imputer.fit_transform(df[[col]])
cat_imputer = SimpleImputer(strategy='most_frequent')
for col in self.config.CATEGORICAL_FEATURES:
if col in df.columns:
df[col] = cat_imputer.fit_transform(df[[col]])
return df
php
def create_new_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Create new features from existing ones."""
df['HouseAge'] = 2026 - df['YearBuilt']
df['RemodAge'] = 2026 - df['YearRemodAdd']
df['TotalSF'] = df['TotalBsmtSF'] + df['1stFlrSF'] + df['2ndFlrSF']
df['TotalPorchSF'] = df['OpenPorchSF'] + df['EnclosedPorch'] + df['3SsnPorch'] + df['ScreenPorch']
df['TotalBath'] = df['FullBath'] + 0.5 * df['HalfBath'] + df['BsmtFullBath'] + 0.5 * df['BsmtHalfBath']
df['HasPool'] = df['PoolArea'].apply(lambda x: 1 if x > 0 else 0)
df['HasFireplace'] = df['Fireplaces'].apply(lambda x: 1 if x > 0 else 0)
df['Has2ndFloor'] = df['2ndFlrSF'].apply(lambda x: 1 if x > 0 else 0)
df['QualityGroup'] = df['OverallQual'].apply(
lambda x: 'Poor' if x <= 4 else ('Fair' if x <= 6 else ('Good' if x <= 8 else 'Excellent'))
)
return df
php
def encode_categorical_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Encode categorical variables for modeling."""
ordinal_features = ['ExterQual', 'ExterCond', 'BsmtQual', 'BsmtCond',
'HeatingQC', 'KitchenQual', 'FireplaceQu', 'GarageQual',
'GarageCond', 'PoolQC']
quality_mapping = {'None': 0, 'Po': 1, 'Fa': 2, 'TA': 3, 'Gd': 4, 'Ex': 5}
for feature in ordinal_features:
if feature in df.columns:
df[feature] = df[feature].map(quality_mapping).fillna(0)
nominal_features = [col for col in self.config.CATEGORICAL_FEATURES
if col not in ordinal_features and col != self.config.TARGET]
df = pd.get_dummies(df, columns=nominal_features, drop_first=True)
return df
python
from sklearn.preprocessing import RobustScaler
def scale_features(self, df: pd.DataFrame, scaler: RobustScaler = None, fit: bool = True) -> Tuple[pd.DataFrame, RobustScaler]:
"""Scale numerical features using RobustScaler (less sensitive to outliers)."""
if fit:
scaler = RobustScaler()
df[self.config.NUMERICAL_FEATURES] = scaler.fit_transform(df[self.config.NUMERICAL_FEATURES])
else:
df[self.config.NUMERICAL_FEATURES] = scaler.transform(df[self.config.NUMERICAL_FEATURES])
return df, scaler
With properly engineered features, it's time to build models. I tested several approaches:
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
import numpy as np
def train_baseline_model(X_train, y_train, X_val, y_val):
"""Train a simple linear regression model as baseline."""
model = LinearRegression()
model.fit(X_train, y_train)
y_pred = model.predict(X_val)
rmse = np.sqrt(mean_squared_error(y_val, y_pred))
print(f"Linear Regression RMSE: ${rmse:,.2f}")
return model, rmse
Result: ~$45,210 RMSE on validation set
from xgboost import XGBRegressor
from sklearn.model_selection import RandomizedSearchCV
def train_xgboost_model(X_train, y_train, X_val, y_val):
"""Train XGBoost model with hyperparameter tuning."""
param_grid = {
'n_estimators': [500, 1000, 1500],
'learning_rate': [0.01, 0.05, 0.1],
'max_depth': [3, 4, 5, 6],
'subsample': [0.6, 0.8, 1.0],
'colsample_bytree': [0.6, 0.8, 1.0],
'gamma': [0, 0.1, 0.2],
'min_child_weight': [1, 3, 5]
}
base_model = XGBRegressor(
objective='reg:squarederror',
random_state=self.config.RANDOM_SEED,
n_jobs=-1
)
random_search = RandomizedSearchCV(
estimator=base_model,
param_distributions=param_grid,
n_iter=50,
scoring='neg_root_mean_squared_error',
cv=5,
verbose=2,
random_state=self.config.RANDOM_SEED,
n_jobs=-1
)
random_search.fit(X_train, y_train)
best_model = random_search.best_estimator_
y_pred = best_model.predict(X_val)
rmse = np.sqrt(mean_squared_error(y_val, y_pred))
print(f"XGBoost RMSE: ${rmse:,.2f}")
print(f"Best parameters: {random_search.best_params_}")
return best_model, rmse
python
from sklearn.ensemble import StackingRegressor
from sklearn.linear_model import RidgeCV
def create_ensemble_model(X_train, y_train, X_val, y_val):
"""Create a stacking ensemble of multiple models."""
estimators = [
('xgb', XGBRegressor(
n_estimators=1000, learning_rate=0.05, max_depth=6,
subsample=0.8, colsample_bytree=0.8, random_state=self.config.RANDOM_SEED
)),
('rf', RandomForestRegressor(
n_estimators=500, max_depth=10,
random_state=self.config.RANDOM_SEED, n_jobs=-1
)),
('ridge', RidgeCV(alphas=[0.1, 1.0, 10.0]))
]
stacking_model = StackingRegressor(
estimators=estimators,
final_estimator=RidgeCV(alphas=[0.1, 1.0, 10.0]),
cv=5,
n_jobs=-1
)
stacking_model.fit(X_train, y_train)
y_pred = stacking_model.predict(X_val)
rmse = np.sqrt(mean_squared_error(y_val, y_pred))
print(f"Stacking Ensemble RMSE: ${rmse:,.2f}")
return stacking_model, rmse
Validation set performance after extensive experimentation:
| Model | RMSE | RΒ² Score | Training Time |
|---|---|---|---|
| Linear Regression | $45,210 | 0.82 | 0.5s |
| Ridge Regression | $44,890 | 0.82 | 0.8s |
| Random Forest | $28,950 | 0.92 | 15s |
| XGBoost (default) | $26,120 | 0.93 | 20s |
| XGBoost (tuned) | $23,450 | 0.94 | 45s |
| LightGBM | $24,180 | 0.94 | 30s |
| Stacking Ensemble | $22,890 | 0.95 | 60s |
Kaggle Leaderboard Performance: Top 12% (out of ~10,000 participants)
Understanding which features drive predictions is crucial for model interpretability and feature selection.
import xgboost as xgb
fig, ax = plt.subplots(figsize=(12, 8))
xgb.plot_importance(best_xgb_model, max_num_features=20, height=0.8, ax=ax)
plt.title('XGBoost Feature Importance', fontsize=16)
plt.show()
Key Insight: The most important features are size-related and quality-related, which aligns with intuition β bigger, higher-quality houses tend to sell for more.
A true production-level solution requires deployment consideration.
import joblib
def save_model(self, model, model_name: str) -> None:
"""Save trained model to disk."""
model_path = self.config.MODELS_DIR / f"{model_name}.pkl"
model_path.parent.mkdir(parents=True, exist_ok=True)
joblib.dump(model, model_path)
print(f"β
Model saved to {model_path}")
def load_model(self, model_name: str):
"""Load saved model from disk."""
model_path = self.config.MODELS_DIR / f"{model_name}.pkl"
model = joblib.load(model_path)
print(f"β
Model loaded from {model_path}")
return model
class HousePricePredictor:
"""End-to-end prediction pipeline."""
def __init__(self, config: Config):
self.config = config
self.data_ = Data(config)
self.feature_engineer = FeatureEngineer(config)
self.model = None
self.scaler = None
def train(self):
"""Train the complete pipeline."""
train_df, _ = self.data_.load_raw_data()
train_df = self.feature_engineer.handle_missing_values(train_df)
train_df = self.feature_engineer.create_new_features(train_df)
train_df = self.feature_engineer.encode_categorical_features(train_df)
X = train_df.drop(columns=[self.config.TARGET])
y = train_df[self.config.TARGET]
X_train, X_val, y_train, y_val = train_test_split(
X, y, test_size=self.config.TEST_SIZE, random_state=self.config.RANDOM_SEED
)
X_train, self.scaler = self.feature_engineer.scale_features(X_train, fit=True)
X_val, _ = self.feature_engineer.scale_features(X_val, scaler=self.scaler, fit=False)
self.model, rmse = self.train_xgboost_model(X_train, y_train, X_val, y_val)
self.save_model(self.model, 'xgboost_house_price_predictor')
joblib.dump(self.scaler, self.config.MODELS_DIR / 'scaler.pkl')
return rmse
def predict(self, new_data: pd.DataFrame) -> np.ndarray:
"""Make predictions on new data."""
if self.model is None:
self.model = self.load_model('xgboost_house_price_predictor')
self.scaler = joblib.load(self.config.MODELS_DIR / 'scaler.pkl')
new_data = self.feature_engineer.handle_missing_values(new_data)
new_data = self.feature_engineer.create_new_features(new_data)
new_data = self.feature_engineer.encode_categorical_features(new_data)
new_data, _ = self.feature_engineer.scale_features(new_data, scaler=self.scaler, fit=False)
return self.model.predict(new_data)
python
import argparse
def main():
parser = argparse.ArgumentParser(description='House Price Prediction Pipeline')
parser.add_argument('--train', action='store_true', help='Train the model')
parser.add_argument('--predict', action='store_true', help='Make predictions')
parser.add_argument('--input', type=str, help='Path to input CSV file')
parser.add_argument('--output', type=str, default='predictions.csv', help='Path to save predictions')
args = parser.parse_args()
config = Config()
predictor = HousePricePredictor(config)
if args.train:
rmse = predictor.train()
print(f"Model trained with RMSE: ${rmse:,.2f}")
if args.predict:
if not args.input:
print("Error: --input argument required")
return
test_df = pd.read_csv(args.input)
predictions = predictor.predict(test_df)
pd.DataFrame({'Id': test_df['Id'], 'SalePrice': predictions}).to_csv(args.output, index=False)
print(f"Predictions saved to {args.output}")
if __name__ == '__main__':
main()
# requirements.txt
python>=3.8
pandas>=1.3.0
numpy>=1.21.0
scikit-learn>=0.24.2
xgboost>=1.5.0
lightgbm>=3.2.1
catboost>=0.24.1
matplotlib>=3.4.0
seaborn>=0.11.0
joblib>=1.0.0
jupyter>=1.0.0
Install with:
pip install -r requirements.txt
The biggest performance improvements came from better features, not fancier models. The difference between good and great models often lies in feature quality.
While XGBoost performed exceptionally well, the stacking ensemble achieved the best results. Model diversity leads to better generalization.
Not all missing values are equal. Some indicate feature absence (no pool, no fireplace), while others are genuinely missing. Context matters.
The right-skewed sale price distribution benefited significantly from log transformation.
Always use cross-validation. A single train-test split can yield misleading results, especially with smaller datasets.
Setting random seeds everywhere (random_state=42) ensures reproducible results, crucial for debugging and production.
This project provided invaluable experience. Building a production-ready machine learning pipeline from raw data taught me about:
The model achieved a Top 12% ranking on Kaggle, but more importantly, it's structured for real-world deployment.
For machine learning beginners, I highly recommend this competition. It's challenging enough to teach real skills yet approachable with fundamental techniques.
I hope you found this helpful! Questions or suggestions? Let's connect:
GitHub: github.com/arghasarkar
LinkedIn: linkedin.com/in/argha-sarkar
Kaggle: kaggle.com/arghasarkar