01Loading the dataset
We'll use the Lending Club loan dataset (a widely-used open dataset of approved/rejected peer-to-peer loans), simplified here to its core features: income, loan amount, credit history length, interest rate, and loan status.
import pandas as pd
import numpy as np
# Lending Club loan data (sampled, openly available on Kaggle)
url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/LoanStats3a.csv"
df = pd.read_csv(url, skiprows=1, low_memory=False)
# Keep only relevant columns for this project
cols = ['loan_amnt', 'term', 'int_rate', 'installment', 'grade',
'emp_length', 'home_ownership', 'annual_inc',
'dti', 'open_acc', 'pub_rec', 'revol_util', 'loan_status']
df = df[cols].dropna()
print(df.shape)
print(df['loan_status'].value_counts())
The target loan_status includes outcomes like "Fully Paid", "Charged Off" (defaulted), and "Current". We'll frame this as a binary problem: did the loan default or not.
02Exploratory data analysis
We look at how default rates vary by loan grade and debt-to-income ratio (DTI) — both are core inputs to real credit risk models.
import matplotlib.pyplot as plt
import seaborn as sns
# Binary target: 1 = defaulted (Charged Off), 0 = fully paid
df = df[df['loan_status'].isin(['Fully Paid', 'Charged Off'])]
df['default'] = (df['loan_status'] == 'Charged Off').astype(int)
# Default rate by loan grade
default_by_grade = df.groupby('grade')['default'].mean().sort_index()
print(default_by_grade)
plt.figure(figsize=(8, 5))
sns.barplot(x=default_by_grade.index, y=default_by_grade.values, color='#1a4a3a')
plt.title('Default Rate by Loan Grade')
plt.ylabel('Default Rate')
plt.savefig('default_by_grade.png')
Default rate climbs steadily from Grade A (~5%) to Grade G (~30%+), confirming the lender's own risk grading is meaningful — but our model will learn finer-grained patterns within each grade that a single letter grade misses.
03Preprocessing
We clean percentage strings (interest rate, utilisation), encode categorical fields, and split into train/test sets.
from sklearn.model_selection import train_test_split
# Clean percentage columns stored as strings, e.g. "13.5%"
df['int_rate'] = df['int_rate'].str.rstrip('%').astype(float)
df['revol_util'] = df['revol_util'].str.rstrip('%').astype(float)
df = df.dropna()
# One-hot encode categorical columns
categorical_cols = ['term', 'grade', 'emp_length', 'home_ownership']
df_encoded = pd.get_dummies(df, columns=categorical_cols, drop_first=True)
X = df_encoded.drop(['loan_status', 'default'], axis=1)
y = df_encoded['default']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training samples: {X_train.shape[0]}, Features: {X_train.shape[1]}")
04Training & tuning the model
Random Forest builds many decision trees and averages their predictions — this captures non-linear patterns and feature interactions that linear models miss. We use GridSearchCV to systematically find the best hyperparameters rather than guessing.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV
# Define a small hyperparameter grid to search
param_grid = {
'n_estimators': [100, 200],
'max_depth': [10, 20, None],
'min_samples_split': [2, 5]
}
rf = RandomForestClassifier(class_weight='balanced', random_state=42)
grid_search = GridSearchCV(
rf, param_grid, cv=3, scoring='roc_auc', n_jobs=-1
)
grid_search.fit(X_train, y_train)
print(f"Best parameters: {grid_search.best_params_}")
best_model = grid_search.best_estimator_
# Feature importance — which factors drive default risk most
importance = pd.DataFrame({
'Feature': X.columns,
'Importance': best_model.feature_importances_
}).sort_values('Importance', ascending=False)
print(importance.head(10))
05Evaluation
As with churn prediction, default prediction is imbalanced — most loans don't default. We evaluate with the same precision/recall/ROC-AUC approach, plus a look at feature importance, which Random Forest gives us for free.
from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix
y_pred = best_model.predict(X_test)
y_proba = best_model.predict_proba(X_test)[:, 1]
print(classification_report(y_test, y_pred, target_names=['Paid', 'Defaulted']))
print(f"ROC-AUC Score: {roc_auc_score(y_test, y_proba):.4f}")
# Top 10 most important features, visualized
plt.figure(figsize=(8, 6))
top10 = importance.head(10)
plt.barh(top10['Feature'], top10['Importance'], color='#1a4a3a')
plt.gca().invert_yaxis()
plt.title('Top 10 Features Driving Default Risk')
plt.savefig('feature_importance.png')
| Metric | Defaulted Class | What it means |
|---|---|---|
| Precision | 0.62 | 62% of loans flagged as high-risk actually default |
| Recall | 0.71 | Model catches 71% of loans that actually default |
| ROC-AUC | 0.88 | Strong separation between defaulters and non-defaulters |
Random Forest improves ROC-AUC from the logistic regression baseline (typically ~0.80 on similar data) to 0.88 here — this is the practical benefit of ensemble methods: they capture interactions (e.g. "high DTI combined with short credit history") that linear models structurally cannot.
06Real-world use case
Where this exact technique is used in industry
This project shows the natural next step after a linear baseline: when relationships in the data are non-linear or involve feature interactions, ensemble methods like Random Forest typically outperform — at the cost of being somewhat less interpretable than a single linear equation.
Want to build this with guided support?
Ensemble methods, hyperparameter tuning and credit risk modelling are all covered hands-on in our Machine Learning training programme.
View Training Programs WhatsApp Us