Loan Default Prediction - University Project

17 minute read

drawing

Executive summary:

We are working with a dataset containing data related to US loans with deidentified periods. The dataset was provided in a coruse at NUS.

The aim of this notebook is to construct a model able to predict a loan default at a given time. That means we are not predicting whether the loan should be first handed out, but rather anticipating its default at a specific time snapshot.

A prediction at a certain time may serve as an input for calculating various risk metrics, such as exposure.

The dataset contains very few defaults, and therefore, we use oversampling to mitigate the problem of the model simply choosing the overrepresented class - non-defaulted loans. Several algorithms are tested and compared using confusion matrix as the principal evaluation tool.

%matplotlib inline
import pandas as pd
pd.set_option('display.max_columns', None)  
pd.set_option('display.expand_frame_repr', False)
pd.set_option('max_colwidth', -1)
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from lifelines import KaplanMeierFitter

Data preprocessing

df = pd.read_pickle('loan_data.pkl')
df.head()
Borrower time orig_time first_time mat_time balance_time LTV_time interest_rate_time hpi_time uer_time REtype_CO_orig_time REtype_PU_orig_time REtype_SF_orig_time investor_orig_time balance_orig_time FICO_orig_time Interest_Rate_orig_time hpi_orig_time default_time payoff_time status_time
0 1 25 -7 25 113 41303.42 24.498336 9.2 226.29 4.7 0 0 1 0 45000.0 715 9.2 87.03 0 0 0
1 1 26 -7 25 113 41061.95 24.483867 9.2 225.10 4.7 0 0 1 0 45000.0 715 9.2 87.03 0 0 0
2 1 27 -7 25 113 40804.42 24.626795 9.2 222.39 4.4 0 0 1 0 45000.0 715 9.2 87.03 0 0 0
3 1 28 -7 25 113 40483.89 24.735883 9.2 219.67 4.6 0 0 1 0 45000.0 715 9.2 87.03 0 0 0
4 1 29 -7 25 113 40367.06 24.925476 9.2 217.37 4.5 0 0 1 0 45000.0 715 9.2 87.03 0 0 0

The data was given to us for analysis in a class at NUS with only the following description and a description of the variables:

We will be using a loan data set from the United States. The periods has been deidentified. As in the real world, loans may originate before the start of the observation period (this is an issue where loans are transferred between banks and investors as in securitization). The loan observations may thus be censored as the loans mature or borrowers refinance. The dataset contains the following variables:

Description of variables available in the dataset

Borrower: unique ID for each borrower
time: Time stamp of observation
orig_time: Time stamp for origination
first_time: Time stamp for first observation
mat_time: Time stamp for maturity
balance_time: Outstanding balance at observation time
LTV_time: Loan-to-value ratio at observation time, in %
interest_rate_time: Interest rate at observation time, in %
hpi_time: House price index at observation time, base year = 100
uer_time: Unemployment rate at observation time, in % REtype_CO_orig_time: Real estate type condominium = 1, otherwise = 0
REtype_PU_orig_time: Real estate type planned urban development = 1, otherwise = 0
REtype_SF_orig_time: Single-family home = 1, otherwise = 0
investor_orig_time: Investor borrower = 1, otherwise = 0
balance_orig_time: Outstanding balance at origination time
FICO_orig_time: FICO score at origination time, in %
Interest_Rate_orig_time: Interest rate at origination time, in %
hpi_orig_time: House price index at origination time, base year = 100
default_time: Default observation at observation time
payoff_time: Payoff observation at observation time
status_time: Default (1), payoff (2), and nondefault/nonpayoff (0) observation at observation time

Install, Import modules, and configure settings

#Convert binary variables to categorical type, so that the correlation func will ignore them, leave out default_time as int64, since it will be a target variable
df['Borrower'] = df.Borrower.astype('object')
df['REtype_CO_orig_time'] = df.REtype_CO_orig_time.astype('object')
df['REtype_PU_orig_time'] = df.REtype_PU_orig_time.astype('object')
df['REtype_SF_orig_time'] = df.REtype_SF_orig_time.astype('object')
df['investor_orig_time'] = df.investor_orig_time.astype('object')
df['payoff_time'] = df.payoff_time.astype('int64')
df['status_time'] = df.status_time.astype('int64')
#Check if we were successful in changing the datatypes
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 622489 entries, 0 to 622488
Data columns (total 21 columns):
Borrower                   622489 non-null object
time                       622489 non-null int64
orig_time                  622489 non-null int64
first_time                 622489 non-null int64
mat_time                   622489 non-null int64
balance_time               622489 non-null float64
LTV_time                   622219 non-null float64
interest_rate_time         622489 non-null float64
hpi_time                   622489 non-null float64
uer_time                   622489 non-null float64
REtype_CO_orig_time        622489 non-null object
REtype_PU_orig_time        622489 non-null object
REtype_SF_orig_time        622489 non-null object
investor_orig_time         622489 non-null object
balance_orig_time          622489 non-null float64
FICO_orig_time             622489 non-null int64
Interest_Rate_orig_time    622489 non-null float64
hpi_orig_time              622489 non-null float64
default_time               622489 non-null int64
payoff_time                622489 non-null int64
status_time                622489 non-null int64
dtypes: float64(8), int64(8), object(5)
memory usage: 99.7+ MB
#Check for NA values
print("Missing values:", df.isnull().sum().sum())
df.dropna(inplace=True)
print("Missing values after dropping them:", df.isnull().sum().sum())
Missing values: 270
Missing values after dropping them: 0
#Show duplicates
print("Duplicate values:", df.duplicated().sum())
print("Duplicate values after removing them:", df.drop_duplicates(inplace=True))
Duplicate values: 305
Duplicate values after removing them: None

Exploratory analysis

Before we start the exploratory analysis, we prepare a dataset containing only loans originating and defaulting in our dataset. We will use this dataset to construct survival plots later in this section.

#Choose borrowers originated in the period covered in our dataset
dfs1 = df[df['orig_time'] == df['first_time']].copy()
# Choose borrowers who defaulted in the period covered in our dataset
defaulted_borrowers = dfs1[dfs1['default_time'] == 1]['Borrower'].to_list()
#Intersection of the two conditions
dfs2 = dfs1[dfs1['Borrower'].isin(defaulted_borrowers)].copy()
# Create a column with the lifespan of each borrower
dfs2['Duration'] = dfs2.groupby('Borrower')['Borrower'].transform('size')
kmf = KaplanMeierFitter()

Target variable - Distribution of defaulted loans and non-defaulted loan

# Check how many default we have in the data set
sns.countplot(x='default_time', data=df)
<matplotlib.axes._subplots.AxesSubplot at 0x28680021c88>

png

We observe that the dataset is heavily imbalanced. There are barely any defaults. To mitigate this issue, we will use an oversampling technique called SMOTE.

Number of unique borrowers

print('The number of unique borrowers is: ', len(df['Borrower'].unique()))
The number of unique borrowers is:  49982

Macroeconomic environment variables

plt.figure(figsize = (10, 15))
labels = ['House price index at origination time, base year = 100 ','House price index at observation time, base year = 100', 'Unemployment rate at observation time, in %']
# iterate through the new features
for i, feature in enumerate(['hpi_orig_time', 'hpi_time', 'uer_time']):
    # create a new subplot for each source
    plt.subplot(3, 1, i + 1)
    # plot repaid loans
    sns.kdeplot(df.loc[df['default_time'] == 0, feature], label = 'No default')
    # plot loans that were not repaid
    sns.kdeplot(df.loc[df['default_time'] == 1, feature], label = 'Default')
    
    # Label the plots
    plt.title(labels[i])
    plt.ylabel('Density');

png

House price index at origination - the reason why defaulted loans show association with higher val real estate is expensive, people had to borrow more money to buy it, and therefore the loan carries more risk.

House price index at origination - one would expect the graph to be the opposite to what is shown, and therefore we have no theory related to the cause.

Unemployment rate at observation time - it seems that low unemployment rate at observation time is associated with loans not defaulting

Parameters of a loan at origination variables

plt.figure(figsize = (12, 30))
labels = ['Outstanding balance at origination time', 'Interest rate at origination time, in %']
# iterate through the new features
for i, feature in enumerate(['balance_orig_time', 'Interest_Rate_orig_time']):
    # create a new subplot for each source
    plt.subplot(4, 1, i + 1)
    # plot repaid loans
    sns.kdeplot(df.loc[df['default_time'] == 0, feature], label = 'No default')
    # plot loans that were not repaid
    sns.kdeplot(df.loc[df['default_time'] == 1, feature], label = 'Default')
    
    # Label the plots
    plt.title(labels[i])
    plt.xlabel('%s' % feature); plt.ylabel('Density');
binary_features = ['REtype_CO_orig_time', 'REtype_PU_orig_time', 'REtype_SF_orig_time']
T = dfs2["Duration"]
E = ~(dfs2["default_time"])
labels = ['Real estate type condominium', 'Real estate type planned urban development', 'Single-family home']

fig, axes = plt.subplots(len(binary_features), 1, figsize=(10, 20))
for i,feature in enumerate(binary_features):
    subset = (dfs2[feature] == 1)
    ax = plt.subplot(4,1,i+1)
    kmf.fit(T[subset], event_observed=E[subset], label=labels[i])
    kmf.plot(ax=ax)
    kmf.fit(T[~subset], event_observed=E[~subset], label="NOT " + labels[i])
    kmf.plot(ax=ax)

png

png

Interest rate at origination time - it seems that higher interest rate at origination time is associated with higher default risk. This is not surprising, since the interest should reflect the underlying risk.

Survival plots - the three survival charts illustrate the relationship between the percentage of non-defaulted loans vs the observation time versus. The lower line is always the one for which less loans have “survived”

Variable parameters of a loan variables

plt.figure(figsize = (10, 10))
labels = ['Interest rate at observation time, in %', 'Outstanding balance at observation time', 'Loan-to-value ratio at observation time, in %']
variables = ['interest_rate_time', 'balance_time', 'LTV_time']
# iterate through the new features
for i, feature in enumerate(variables):
    # create a new subplot for each source
    plt.subplot(len(variables), 1, i + 1)
    # plot repaid loans
    sns.kdeplot(df.loc[df['default_time'] == 0, feature], label = 'No default')
    # plot loans that were not repaid
    sns.kdeplot(df.loc[df['default_time'] == 1, feature], label = 'Default')
    
    # Label the plots
    plt.title(labels[i])
    plt.xlabel('%s' % feature); plt.ylabel('Density');
plt.tight_layout()

png

Interest rate at observation time - it seems that higher interest rate at observation time is associated with higher default risk. This is not surprising, since the interest should reflect the underlying risk.

Loan to value ratio at observation time - the higher the size of the loan relative to the value of the underlying asset, the higher the probablity of default. Borrower taking such loans are likely in financial need and more prone to default.

Borrower characteristics variables

#plt.figure(figsize = (10, 10))
labels = ['FICO score at origination time, in %']
variables = ['FICO_orig_time']
# iterate through the new features
for i, feature in enumerate(variables):
    # create a new subplot for each source
    plt.subplot(len(variables), 1, i + 1)
    # plot repaid loans
    sns.kdeplot(df.loc[df['default_time'] == 0, feature], label = 'No default')
    # plot loans that were not repaid
    sns.kdeplot(df.loc[df['default_time'] == 1, feature], label = 'Default')
    # Label the plots
    plt.title(labels[i])
    plt.xlabel('%s' % feature); plt.ylabel('Density');

png

fico_category = pd.cut(dfs2.FICO_orig_time,bins=[0,620,750, 850],labels=['Low Fico Score','Medium Fico Score','High Fico Score'])
if 'Fico Category' in dfs2.columns:
    dfs2.drop(columns=['Fico Category'], inplace=True)
dfs2.insert(16,'Fico Category',fico_category)
fico_categories = ['Low Fico Score','Medium Fico Score','High Fico Score']
colors = ['red', 'orange', 'green']
ax = plt.subplot(1,1,1)
for i, cat in enumerate(fico_categories):
    kmf.fit(dfs2[dfs2['Fico Category'] == cat]['Duration'], ~dfs2[dfs2['Fico Category'] == cat]['default_time'])
    kmf.plot(ax=ax, label=cat, c=colors[i])   
    

png

binary_features = ['investor_orig_time']
T = dfs2["Duration"]
E = ~(dfs2["default_time"])
labels = ['Investor borrower']
fig, axes = plt.subplots(len(binary_features), 1, figsize=(10, 20))
for i,feature in enumerate(binary_features):
    subset = (dfs2[feature] == 1)
    ax = plt.subplot(4,1,i+1)
    kmf.fit(T[subset], event_observed=E[subset], label=labels[i])
    kmf.plot(ax=ax)
    kmf.fit(T[~subset], event_observed=E[~subset], label="NOT " + labels[i])
    kmf.plot(ax=ax)

png

Fico score at observation time - Loans with higher FICO score are less likely to default. Expected observation since FICO score is a credit rating score.

Distribution plots for all variables

# Check the data distributions for every column
df.hist(bins=30, figsize=(30,20))
plt.show()

png

For some histograms, we see large unused spaces on the x axis. That signifies the presence of outliers. We will explore LTV_time, balance_orig_time, balance_time, interest_rate_time

Boxplots for all continuous variables

df.select_dtypes(exclude=['object']).columns.tolist()
['time',
 'orig_time',
 'first_time',
 'mat_time',
 'balance_time',
 'LTV_time',
 'interest_rate_time',
 'hpi_time',
 'uer_time',
 'balance_orig_time',
 'FICO_orig_time',
 'Interest_Rate_orig_time',
 'hpi_orig_time',
 'default_time',
 'payoff_time',
 'status_time']
# Economic environment features
plt.figure(figsize = (15, 20))
# iterate through the new features
for i, column in enumerate(df.select_dtypes(exclude=['object']).columns.tolist()):
    # create a new subplot for each source, subplot(number of rows, number of columns, id of subplot)
    plt.subplot(8, 2, i+1)
    sns.boxplot(data=df, x=column)
plt.tight_layout()

png

The section below was used to manually remove outliers. However, when I tested the models with the outliers unremoved, it performed better. It might be due to the fact that I use the sklearn scaler or that the outliers were removed in a wrong way. In sum, I left the outliers untouched and commented out the code for manually removing them.

#Remove outliers to achieve a more normal distributions
dfc = df.copy()
# dfc = df[df['balance_time'] < 6000000]
# dfc = dfc[dfc['LTV_time'] < 500]
# dfc = dfc[dfc['interest_rate_time'] < 20]
# dfc = dfc[dfc['balance_orig_time'] < 6000000]
# dfc = dfc[dfc['Interest_Rate_orig_time'] < 20]

Variable selection

#Check how the defaulted rows look like
dfc[dfc['default_time'] == 1].head()
Borrower time orig_time first_time mat_time balance_time LTV_time interest_rate_time hpi_time uer_time REtype_CO_orig_time REtype_PU_orig_time REtype_SF_orig_time investor_orig_time balance_orig_time FICO_orig_time Interest_Rate_orig_time hpi_orig_time default_time payoff_time status_time
23 1 48 -7 25 113 29087.21 26.658065 9.200 146.45 8.3 0 0 1 0 45000.0 715 9.20 87.03 1 0 1
99 6 56 19 25 139 190474.11 75.834755 6.580 181.43 6.6 0 0 1 0 212000.0 670 6.58 191.42 1 0 1
115 9 37 18 25 138 130140.31 99.138105 8.000 153.35 9.0 0 0 1 0 128000.0 501 8.00 186.91 1 0 1
120 10 29 18 25 139 88046.35 67.296390 10.230 217.37 4.5 0 0 1 0 90000.0 665 7.20 186.91 1 0 1
146 16 34 18 25 138 160044.34 73.431289 9.875 173.35 5.8 0 0 1 0 164500.0 572 7.50 186.91 1 0 1

Correlation matrix

corr_matrix = dfc.corr()
mask = np.zeros_like(corr_matrix, dtype=np.bool)
mask[np.triu_indices_from(mask)]= True
f, ax = plt.subplots(figsize=(20, 25)) 
heatmap = sns.heatmap(corr_matrix, 
                      mask = mask,
                      square = True,
                      linewidths = .5,
                      cmap = 'coolwarm',
                      cbar_kws = {'shrink': .4, 
                                'ticks' : [-1, -.5, 0, 0.5, 1]},
                      vmin = -1, 
                      vmax = 1,
                      annot = True,
                      annot_kws = {'size': 12})
#add the column names as labels
ax.set_yticklabels(corr_matrix.columns, rotation = 0)
ax.set_xticklabels(corr_matrix.columns)
ax.tick_params(labelsize=15)
sns.set_style({'xtick.bottom': True}, {'ytick.left': True})

png

We see no perfect correlation, therefore, we do not need to drop any variables.

Define predictors

Basic predictor selection based on common sense

#Subset a data not to include the last three columns. Those columns are default_time, payoff_time, status_time. 
#They are related to the target variable, and therefore we dont want them in our X variable
X = dfc[dfc.columns[1:-3]]
dfc.columns[1:-3]
Index(['time', 'orig_time', 'first_time', 'mat_time', 'balance_time',
       'LTV_time', 'interest_rate_time', 'hpi_time', 'uer_time',
       'REtype_CO_orig_time', 'REtype_PU_orig_time', 'REtype_SF_orig_time',
       'investor_orig_time', 'balance_orig_time', 'FICO_orig_time',
       'Interest_Rate_orig_time', 'hpi_orig_time'],
      dtype='object')

Define target variable

y = dfc['default_time']

Variable selection through correlation

The idea is to see which independent variables are strong correlated with the target variable. In this case, the threshold is 0.5. It yields only the target variable itself. Therefore, we must use another method.

#Selecting highly correlated features
high_corr_features = corr_matrix[(abs(corr_matrix) > 0.8) & (corr_matrix != 1)]
high_corr_features = high_corr_features.dropna(axis=1, how='all')
high_corr_features
orig_time balance_time hpi_time uer_time balance_orig_time hpi_orig_time payoff_time status_time
time NaN NaN NaN NaN NaN NaN NaN NaN
orig_time NaN NaN NaN NaN NaN 0.886296 NaN NaN
first_time NaN NaN NaN NaN NaN NaN NaN NaN
mat_time NaN NaN NaN NaN NaN NaN NaN NaN
balance_time NaN NaN NaN NaN 0.985177 NaN NaN NaN
LTV_time NaN NaN NaN NaN NaN NaN NaN NaN
interest_rate_time NaN NaN NaN NaN NaN NaN NaN NaN
hpi_time NaN NaN NaN -0.811461 NaN NaN NaN NaN
uer_time NaN NaN -0.811461 NaN NaN NaN NaN NaN
balance_orig_time NaN 0.985177 NaN NaN NaN NaN NaN NaN
FICO_orig_time NaN NaN NaN NaN NaN NaN NaN NaN
Interest_Rate_orig_time NaN NaN NaN NaN NaN NaN NaN NaN
hpi_orig_time 0.886296 NaN NaN NaN NaN NaN NaN NaN
default_time NaN NaN NaN NaN NaN NaN NaN NaN
payoff_time NaN NaN NaN NaN NaN NaN NaN 0.932983
status_time NaN NaN NaN NaN NaN NaN 0.932983 NaN

Lets drop orig_time, balance_time, hpi_time because of high multicolinearity.

# drop by Name
X = X.drop(['orig_time', 'balance_time', 'hpi_time'], axis=1)

Variable selection through Feature Importance

Bagged decision trees like Random Forest and Extra Trees can be used to estimate the importance of features.
I fit a random forest on the data, then created a DataFrame with the improtances and the features and selected only features
with importance higher than 0.05. (arbitrary threshold which cuts out the binary variables who naturally have a lowee variance)
Source: https://machinelearningmastery.com/feature-selection-machine-learning-python/

from sklearn.ensemble import ExtraTreesClassifier
# load data
# feature extraction
model = ExtraTreesClassifier(n_estimators=10)
model.fit(X, y)
print(model.feature_importances_)
[0.08349057 0.0623258  0.07059034 0.16763307 0.1205299  0.05678918
 0.00491261 0.00596746 0.01155812 0.00612766 0.13021501 0.12694113
 0.09385999 0.05905915]
dictionary = dict(zip(model.feature_importances_.tolist(), X.columns))
importance = pd.DataFrame(list(dictionary.items()), columns=['Importance', 'Feature'])
importance.sort_values(by='Importance', ascending=False)
Importance Feature
3 0.167633 LTV_time
10 0.130215 balance_orig_time
11 0.126941 FICO_orig_time
4 0.120530 interest_rate_time
12 0.093860 Interest_Rate_orig_time
0 0.083491 time
2 0.070590 mat_time
1 0.062326 first_time
13 0.059059 hpi_orig_time
5 0.056789 uer_time
8 0.011558 REtype_SF_orig_time
9 0.006128 investor_orig_time
7 0.005967 REtype_PU_orig_time
6 0.004913 REtype_CO_orig_time
# drop by Name
X = X.drop(['REtype_SF_orig_time', 'investor_orig_time', 'REtype_PU_orig_time', 'REtype_CO_orig_time'], axis=1)

Create test/train split

from sklearn.model_selection import train_test_split
X_train_raw, X_test_raw, y_train, y_test = train_test_split(X, y.values, test_size=0.3, random_state=0)

Normalize data

Some ML algorithms such as SVM require scaling, logistic regression, decision trees, and random forests do not

from sklearn.preprocessing import StandardScaler

stdsc = StandardScaler()
X_train = stdsc.fit_transform(X_train_raw)
X_test = stdsc.transform(X_test_raw)

Define functions for model fitting, prediction and evaluation

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
import xgboost as xgb
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.neural_network import MLPClassifier

Create a dictionary with sklearn algorithms to be used

different_model_comparison = {
    "Random Forest":RandomForestClassifier(random_state=0,n_estimators=10),
    "Logistic Regression":LogisticRegression(random_state=0,  solver='lbfgs'),
    "Decision Tree":DecisionTreeClassifier(random_state=0),
    "XGBoost":xgb.XGBClassifier(objective="binary:logistic", random_state=42),
    "MLP":MLPClassifier(solver='lbfgs', alpha=1e-5, hidden_layer_sizes=(5, 2), random_state=1)
     #"SVM":SVC(random_state=0,probability=True) #too slow never finished
}

Component 1: Model fitting function

# function to train models, return a dictionary of trained models
# Input is the dictionary with ML models, we use the functions so that we can train more models at once
def train_model(model_dict,X_train,y_train):
    for model in model_dict:
        print("Training:",model)
        model_dict[model].fit(X_train,y_train)
    return model_dict

Component 2: Model evaluation function

# Wrapping this function so we can easily change the model and evaluate them
# function to evaluate model performance 
from sklearn import metrics
def model_eval(clf_name,clf,X_test,y_test):
    print("Evaluating:",clf_name)
    y_pred = clf.predict(X_test)
    y_score = clf.predict_proba(X_test)[:,1]
    confusion_matrix = metrics.confusion_matrix(y_test, y_pred)
    report = pd.Series({
        "model":clf_name,
        "precision":metrics.precision_score(y_test, y_pred),
        "recall":metrics.recall_score(y_test, y_pred),
        "accuracy score":metrics.accuracy_score(y_test, y_pred),
        'roc_auc_score' : metrics.roc_auc_score(y_test, y_score)
    })
    return report,confusion_matrix

Parent model fitting and evaluation function

def train_eval_model(model_dict,X_train,y_train,X_test,y_test):
    cols = ['model', 'roc_auc_score', 'precision', 'recall', 'accuracy score']
    model_report = pd.DataFrame(columns = cols)
    confusion_matrix_dict = {}
    model_dict = train_model(model_dict,X_train,y_train)
    for model in model_dict:
        report,confusion_matrix = model_eval(model,model_dict[model],X_test,y_test)
        model_report = model_report.append(report,ignore_index=True)
        confusion_matrix_dict[model] = confusion_matrix
        #confusion matrix
        plt.figure(figsize=(3,3))
        plt.title(model)
        sns.heatmap(pd.DataFrame(confusion_matrix), annot=True, cmap="YlGnBu" ,fmt='g')
        plt.xlabel('Model Prediction')
        plt.ylabel('Actual loan status')
    return model_report,confusion_matrix_dict

Action! Model training and evaluating

Train and evaluate model: Approach as covered in class

model_report,confusion_matrix_dict = train_eval_model(different_model_comparison,X_train,y_train,X_test,y_test)
Training: Random Forest
Training: Logistic Regression
Training: Decision Tree
Training: XGBoost
Training: MLP
Evaluating: Random Forest
Evaluating: Logistic Regression
Evaluating: Decision Tree
Evaluating: XGBoost


C:\Users\Martin\Anaconda3\lib\site-packages\sklearn\metrics\classification.py:1437: UndefinedMetricWarning: Precision is ill-defined and being set to 0.0 due to no predicted samples.
  'precision', 'predicted', average, warn_for)


Evaluating: MLP


C:\Users\Martin\Anaconda3\lib\site-packages\sklearn\metrics\classification.py:1437: UndefinedMetricWarning: Precision is ill-defined and being set to 0.0 due to no predicted samples.
  'precision', 'predicted', average, warn_for)

png

png

png

png

png

#Models above poor, since they cannot predict loan defaults well
print("Defaulted loans in the dataset:", y_test[y_test == 1].shape[0])
Defaulted loans in the dataset: 4467

Every model, except for decision tree, was unable to predict ANY of the defaulted loans! Let us try the oversampling approach then.

Train and evaluate model: Oversampling approach

from imblearn.over_sampling import SMOTE

X_train_bal, y_train_bal = SMOTE(random_state=0).fit_sample(X_train,y_train)
X_test_bal, y_test_bal = X_test, y_test
#Compare train dataset used before and now. Now, the dataset is balanced in respect to default/non-default 
print("Share of defaulted loans in the original training dataset", sum(y_train)/len(y_train))
print("Share of defaulted loans in the oversampled training dataset", sum(y_train_bal)/len(y_train_bal))
Share of defaulted loans in the original dataset 0.024539496805937443
Share of defaulted loans in the oversampled dataset 0.5
model_report_bal,cm_dict_bal = train_eval_model(different_model_comparison,X_train_bal,y_train_bal,X_test_bal,y_test_bal)
Training: Random Forest
Training: Logistic Regression
Training: Decision Tree
Training: XGBoost
Training: MLP
Evaluating: Random Forest
Evaluating: Logistic Regression
Evaluating: Decision Tree
Evaluating: XGBoost
Evaluating: MLP

png

png

png

png

png

print("Defaulted loans in the dataset:", y_test_bal[y_test_bal == 1].shape[0])
Defaulted loans in the dataset: 4467

Unlike with the imbalanced training dataset, we can see that no model has failed with predicting all of the 4467 defaults in the test dataset.

Model commentary

The variant using balanced training data is much better at predicting defaults. However, the downside is that we have a higher number of loans for which we wrongly predict failure. In this model working with the observation period, it would mean that the instituition holding the loans would likely unnecessarily hedge itself against the expected failure.

If the model was predicting default only from the origination data, it would mean that we would lose money, since we would not be giving loans to subjects who would be able to pay them.

In order to carry out a proper assessment of the model, it would be necessary to define custom functions calculating the cost of not accepting a loan application versus having a borrower default.

Compare the approaches above

model_report
model roc_auc_score precision recall accuracy score
0 Random Forest 0.608173 0.044199 0.001791 0.975174
1 Logistic Regression 0.726111 0.000000 0.000000 0.975908
2 Decision Tree 0.516891 0.054418 0.058876 0.952973
3 XGBoost 0.758559 0.000000 0.000000 0.976058
4 MLP 0.750362 0.000000 0.000000 0.976058
model_report_bal
model roc_auc_score precision recall accuracy score
0 Random Forest 0.627585 0.079618 0.011193 0.973228
1 Logistic Regression 0.728628 0.046783 0.692635 0.654755
2 Decision Tree 0.519469 0.051365 0.071189 0.946284
3 XGBoost 0.723225 0.063579 0.233266 0.899386
4 MLP 0.755964 0.048281 0.732930 0.647702

Comparing the training imbalanced and balanced dataset, we see a marked improvement in recall, which is a metric fitting for our usecase, as opposed to accuracy. We are using recall as an evaluation metric, since we believe that a defaulted loan has a severe impact on the creditor.

In reality, we would likely select logistic regression to be used in production, since it offers good predictions coupled with much easier interpretability than the second-best algorithm MLP.

Sources:
1) Code - https://github.com/XC-Li/Loan_Default_Prediction
2) Code - https://github.com/lucastravi/mortgage_backed_securities/blob/master/mortgage_back_securities.ipynb
3) Theory - https://beckernick.github.io/oversampling-modeling/
5) Precision/Recall - https://hackernoon.com/idiots-guide-to-precision-recall-and-confusion-matrix-b32d36463556
6) Survival Analysis - https://lifelines.readthedocs.io/en/latest/

Updated: