Before starting the Berka Analysis, a few important steps were taken in order to prepare the source data files. These steps are listed below:
This step create functions to be used in the next steps. Following, all functions created are described.
The birth_number column is given in the form of YYMMDD for men, and YYMM+50DD for women. The objective of this function is to return the gender of the client via the birth_number.
The birth_number column is given in the form of YYMMDD for men, # and YYMM+50DD for women. The objective of this function is to return the final birthday as Date.
GetBirthdateFromBirthnumber <- function(var_birth_number, var_gender) {
year <- paste("19", substr(var_birth_number, 1, 2), sep="")
month <- ifelse(var_gender == "male", substr(var_birth_number, 3, 4),
as.integer(substr(var_birth_number, 3, 4)) - 50)
day <- substr(var_birth_number, 5, 6)
result <- as.Date(paste(year, "-", month, "-", day, sep=""), format = "%Y-%m-%d")
return(result)
}The objective of this function is to convert the strange bank date style to the regular R Date datatype.
The objective of this function is to get age given the birth_number.
The objective of this function is to calculate main metrics of model performance according to a cutoff value.
calculateModelMetrics <- function(cutData, realData, predData){
cuttedData <- as.factor(ifelse(predData>=cutData, 1, 0))
invisible(capture.output(out <- CrossTable(realData, cuttedData,
prop.c = F, prop.t = F, prop.r = T, prop.chisq = F)))
out <- as.data.frame(out) %>%
mutate(merged=paste0(t.x, t.y)) %>%
dplyr::select(merged, val=t.Freq)
TN <- filter(out, merged == "00")$val[1]
FP <- filter(out, merged == "01")$val[1]
FN <- filter(out, merged == "10")$val[1]
TP <- filter(out, merged == "11")$val[1]
return(data.frame(Cut = cutData,
TN = TN,
FP = FP,
FN = FN,
TP = TP,
TPR = TP/(TP+FN), TNR=TN/(TN+FP),
Error = (FP+FN)/(TP+TN+FP+FN),
Precision = TP/(TP+FP),
F1 = 2*(TP/(TP+FN))*(TP/(TP+FP))/((TP/(TP+FP)) + (TP/(TP+FN)))))
}The objective of this function is to calculate main metrics of model performance for cutoffs from 0-1 based on given step.
modelMetrics <- function(realData, predData, stepping = 0.01,
plot_title = "TPR/TNR by cutoff over full dataset"){
probCuts <- seq(from = 0, to = 1, by = stepping)
out <- bind_rows(lapply(probCuts, calculateModelMetrics, realData = realData, predData = predData))
out <- out[complete.cases(out),] %>% mutate(Difference = abs(TPR-TNR))
best <- out %>% arrange(Difference) %>% head(1) %>% dplyr::select(-Difference)
p <- plot_ly(x = ~out$Cut, y = ~out$Difference, name = 'Abs. Diff.', type = 'bar', opacity = 0.3) %>%
add_trace(x = ~out$Cut, y = ~out$TPR, name = 'TPR', type = 'scatter', mode = 'lines', opacity = 1) %>%
add_trace(x = ~out$Cut, y = ~out$TNR, name = 'TNR', type = 'scatter', mode = 'lines', opacity = 1) %>%
layout(xaxis = list(title = "Cutoff Value"),
yaxis = list(title = "True Ratio (%)")) %>%
add_annotations(
text = sprintf("<b>%s</b>", plot_title),
x = 0,
y = 1.04,
yref = "paper",
xref = "paper",
xanchor = "left",
yanchor = "top",
showarrow = FALSE,
font = list(size = 15)
) %>%
add_annotations(
text = sprintf("<b>%s</b>", best$Cut),
x = best$Cut,
y = best$TPR,
showarrow = FALSE,
bgcolor = "white",
opacity = 0.8
)
return(list(TableResults = out,
BestCut = best,
Plot = p))
}See topic “Splitting dataset into Train and Test data” for further details.
Functions used in the evaluation step to compare the models.
Function used to plot the score density plots of the model.
Needs to receive a dataset containing the predicted and actual values, the actual values vector the score (predicted) values value and a custom title.
Score_Histograms <- function(dataset, predicted, actual, title) {
ggplot(data = dataset) +
geom_density(aes(x = predicted, fill = as.factor(actual)),
alpha = 0.5) +
scale_fill_manual(values = c("0" = "#16a085", "1" = "#e74c3c")) +
scale_x_continuous(limits = c(0, 1)) +
theme_economist() +
labs(title = title,
y = 'Score',
fill = 'Defaulter |1 = True|') +
theme(panel.grid = element_blank(),
axis.ticks.y = element_blank(),
axis.text.y = element_blank(),
legend.position = 0,
plot.title = element_text(hjust = 0.5))
}Function used to plot the score box plot of the model.
Needs to receive a dataset containing the predicted and actual values, the actual values vector the score (predicted) values value and a custom title.
Score_Boxplot <- function(dataset, predicted, actual, title) {
ggplot(data = dataset) +
geom_boxplot(aes(y = predicted,
fill = as.factor(actual))) +
coord_flip() +
scale_fill_manual(values = c("0" = "#16a085", "1" = "#e74c3c")) +
scale_y_continuous(limits = c(0, 1)) +
theme_economist() +
labs(title = title,
y = 'Score',
fill = 'Defaulter |1 = True|') +
theme(panel.grid = element_blank(),
axis.ticks.y = element_blank(),
axis.text.y = element_blank(),
legend.position = 0,
plot.title = element_text(hjust = 0.5))
}Function used to plot the cumulative probability distribution and KS metric of the model.
Needs to receive a vector with scores of Defaulters and a vector f scores of Non-Defaulters and a custom title.
KS_Plot <- function(zeros, ones, title) {
group <- c(rep("Non Defaulters", length(zeros)), rep("Defauters", length(ones)))
dat <- data.frame(KSD = c(zeros, ones), group = group)
cdf1 <- ecdf(zeros)
cdf2 <- ecdf(ones)
minMax <- seq(min(zeros, ones), max(zeros, ones), length.out=length(zeros))
x0 <- minMax[which( abs(cdf1(minMax) - cdf2(minMax)) ==
max(abs(cdf1(minMax) - cdf2(minMax))) )][1]
y0 <- cdf1(x0)[1]
y1 <- cdf2(x0)[1]
ks <- round(y0 - y1, 2)
ggplot(dat, aes(x = KSD, group = group, color = group))+
stat_ecdf(size=1) +
geom_segment(aes(x = x0[1], y = y0[1], xend = x0[1], yend = y1[1]),
linetype = "dashed", color = "blue") +
geom_point(aes(x = x0[1] , y = y0[1]), color="blue", size=4) +
geom_point(aes(x = x0[1] , y = y1[1]), color="blue", size=4) +
geom_label(aes(x = x0[1], y = y1[1] + (y0[1] - y1[1]) / 2, label = ks),
color = 'black') +
scale_x_continuous(limits = c(0, 1)) +
labs(title = title,
y = 'Cumulative Probability Distribution',
x = 'Score') +
theme_economist() +
theme(legend.title = element_blank(),
panel.grid = element_blank(),
legend.position = 0,
plot.title = element_text(hjust = 0.5))
}Function used to plot the combined ROC curves of each model.
Needs to receive a dataset with actual and predicted scores of each model.
Plot_ROC <- function(dataset, smooth_opt = FALSE) {
roc_logistic <- roc(logistic.actual ~ logistic.predicted,
dataset,
smooth = smooth_opt,
quiet = TRUE)
roc_decision.tree <- roc(decision.tree.actual ~ decision.tree.predicted,
dataset,
smooth = smooth_opt,
quiet = TRUE)
roc_boosting <- roc(boosting.actual ~ boosting.predicted,
dataset,
smooth = smooth_opt,
quiet = TRUE)
roc_random.forest <- roc(random.forest.actual ~ random.forest.predicted,
dataset,
smooth = smooth_opt,
quiet = TRUE)
p <- ggplot() +
geom_line(aes(x = 1 - roc_logistic$specificities,
y = roc_logistic$sensitivities,
colour = 'Logistic Regression'), # red
size = 1,
linetype = 1,
alpha = 0.7) +
geom_line(aes(x = 1 - roc_decision.tree$specificities,
y = roc_decision.tree$sensitivities,
colour = 'Decision Tree'), # blue
size = 1,
linetype = 1,
alpha = 0.7) +
geom_line(aes(x = 1 - roc_boosting$specificities,
y = roc_boosting$sensitivities,
colour = 'Boosting'), # green
size = 1,
linetype = 1,
alpha = 0.7) +
geom_line(aes(x = 1 - roc_random.forest$specificities,
y = roc_random.forest$sensitivities,
colour = 'Random Forest'), # purple
size = 2,
linetype = 1,
alpha = 1) +
geom_abline(aes(intercept = 0, slope = 1),
linetype = 2,
size = 1) +
scale_colour_manual(name = NULL,
breaks = c('Logistic Regression',
'Decision Tree',
'Boosting',
'Random Forest'),
labels = c('Logistic Regression',
'Decision Tree',
'Boosting',
'Random Forest'),
values = c('#C0392B',
'#3498DB',
'#28B463',
'#9B59B6')) +
labs(y = 'True Positive Rate',
x = 'False Positive Rate',
title = 'Receiver Oerating Characteristic Curve - ROC',
subtitle = 'Random Forest and Boosting are the models that best discriminate Defaulters and Non-Defaulters') +
theme_economist() +
theme(panel.grid = element_blank())
return (p)
}Function used to output confusion matrix and basic accuracy metrics of each model.
Needs to receive a vector of actual and predicted values.
accuracy <- function(score, actual, threshold = 0.5) {
fitted.results <- ifelse(score > threshold ,1 ,0)
misClasificError <- mean(fitted.results != actual)
misClassCount <- misclassCounts(fitted.results, actual)
print(kable(misClassCount$conf.matrix))
print('--------------------------------------------------------------')
print(paste('Model General Accuracy of: ',
round((1 - misClassCount$metrics['ER']) * 100, 2), '%',
sep = ''))
print(paste('True Positive Rate of : ',
round(misClassCount$metrics['TPR'] * 100, 2), '%',
sep = ''))
}The process of data ingestion — preparing data for analysis — usually includes steps called extract (taking the data from its current location), transform (cleansing and normalizing the data), and load (placing the data in a database where it can be analyzed).
During this step, in addition to the loading data processes, it was performed data casting, column renaming and small touch-ups. The list below describe each table adjustment taken:
The objective of this step is analysing missing values and other strange conditions. In order to accomplish this task, a few R functions were used to quickly discover missing values, like NA and empty fields.
First thing done, was fixing observations in k_symbol transaction table with ’ ’ (one space) to empty string (’’), using the following command.
Then, the command below was used to find out any NA values in each table.
Solely the transaction table has NA values, in the following columns:
| x | |
|---|---|
| trans_id | 0 |
| account_id | 0 |
| date | 0 |
| type | 0 |
| operation | 0 |
| amount | 0 |
| balance | 0 |
| k_symbol | 0 |
| bank | 0 |
| account | 760931 |
Finally, the following command was used in each table to find out where empty values was hidden.
Again, only the transaction table had empty values, according to the table below:
| x | |
|---|---|
| trans_id.NA | NA |
| account_id.NA | NA |
| date.NA | NA |
| type.NA | NA |
| operation.TRUE | 183114 |
| amount.NA | NA |
| balance.NA | NA |
| k_symbol.TRUE | 535314 |
| bank.TRUE | 782812 |
| account.NA | NA |
For the exploration analysis report, we did not take any additional action, since the missing values was not relevant.
In order to make the data information more understandable, it was translated some relevant labels and domains from Czech to English.
# Translating relevant labels and domains to english --------------------------------------------
disposition$type <- plyr::mapvalues(disposition$type, c('OWNER', 'DISPONENT'),
c('Owner', 'User'))
account$frequency <- plyr::mapvalues(account$frequency,
c('POPLATEK MESICNE', 'POPLATEK TYDNE',
'POPLATEK PO OBRATU'),
c('Monthly', 'Weekly', 'On Transaction'))
permanent_order$k_symbol <- plyr::mapvalues(permanent_order$k_symbol,
c('POJISTNE', 'SIPO', 'LEASING', 'UVER'),
c('insurrance payment', 'household',
'leasing', 'loan payment'))
transaction$type <- plyr::mapvalues(transaction$type,
c('PRIJEM', 'VYDAJ', 'VYBER'),
c('credit', 'withdrawal', 'withdrawal in cash'))
transaction$operation <- plyr::mapvalues(transaction$operation,
c('VYBER KARTOU', 'VKLAD', 'PREVOD Z UCTU',
'VYBER', 'PREVOD NA UCET'),
c('credit card withdrawal', 'credit in cash',
'collection from another bank',
'withdrawal in cash', 'remittance to nother bank'))
transaction$k_symbol <- plyr::mapvalues(transaction$k_symbol,
c('POJISTNE', 'SLUZBY', 'UROK', 'SANKC. UROK',
'SIPO', 'DUCHOD', 'UVER'),
c('insurance payment', 'statement',
'interest credited', 'sanction interest',
'household', 'old age pension', 'loan payment'))This step aims to improve the analysis by adding auxiliary information. Data enhancement is all about making sure any data that is coming into the business is being looked at with a critical eye and is being filtered down to maximize its value.
The code below get gender, birthday and age from birth_number column using GetGenderFromBirthnumber and GetBirthdateFromBirthnumber functions.
client <- client %>%
mutate(gender = GetGenderFromBirthnumber(birth_number)) %>%
mutate(birth_date = GetBirthdateFromBirthnumber(birth_number, gender)) %>%
mutate(age = GetAgeFromBirthnumber(birth_number))The code below improved loan data by having a classification regarding its payment status.
loan <- mutate(loan, defaulter =
as.logical(plyr::mapvalues(status, c ('A','B','C','D'),
c(FALSE,TRUE,FALSE,TRUE))),
contract_status = plyr::mapvalues(status, c ('A','B','C','D'),
c('finished','finished','running','running')),
type = 'Owner')The code below improved client data by having its age group.
client <- mutate(client, age_bin = paste(findInterval(age,
c(10, 20, 30, 40, 50, 60, 70, 80, 90, 100)) * 10,'+'))The code below calculate an additional table with current and average account balance for each account.
account_balance <- arrange(transaction, desc(date), account_id) %>%
group_by(account_id) %>%
mutate(avg_balance = mean(balance)) %>%
filter(row_number() == 1) %>%
dplyr::select(account_id, date, balance, avg_balance)
colnames(account_balance) <- c("account_id", "last_transaction_date", 'account_balance', 'avg_balance')The code below calculate an additional table with the proportion of each transaction type (k_symbol) on total transaction amount of each account. That data will be used to fit various different predictive models.
account_transaction_pattern <- select(transaction, c(trans_id, account_id, date, amount, k_symbol)) %>%
mutate(k_symbol = ifelse(k_symbol == '' | is.na(k_symbol), 'other', k_symbol)) %>%
spread(key = k_symbol, value = amount) %>%
replace(is.na(.), 0) %>%
mutate(amount = rowSums(.[4:11])) %>%
group_by(account_id) %>%
summarise(transaction_count = n(),
last_transaction_date = max(date),
amount = sum(amount),
prop_household = sum(household) / amount,
prop_insurance_payment = sum(`insurance payment`) / amount,
prop_interest_credited = sum(`interest credited`) / amount,
prop_loan_payment = sum(`loan payment`) / amount,
prop_old_age_pension = sum(`old age pension`) / amount,
prop_other = sum(`other`) / amount,
prop_sanction_interest = sum(`sanction interest`) / amount,
prop_statement = sum(`statement`) / amount)The below function was created to be used in the modeling exercises to be performed, the idea is to have a standard way to get the prepared data set already prepared with correct data types and dummies.
# dataset preparation ---------------------------------------------------------
# The objective of this step is to return a DataFrame to be used in predictive
# modeling. Therefore, it will join loan, client, district, creditcard,
# account_balance, account_balance_pattern. Finally, it will rename the variables
# and create the appropriate dummy variables to be used in the modeling process.
# joining datasets
source_dataset <- left_join(loan, disposition, by = c('account_id', 'type')) %>%
left_join(client, by = 'client_id') %>%
left_join(district, by = 'district_id') %>%
left_join(creditcard, by = 'disp_id') %>%
left_join(account_balance, by = 'account_id') %>%
left_join(account_transaction_pattern, by = 'account_id') %>%
mutate(card_age_month = (issued %--%
make_date(1998, 12, 31)) / months(1),
last_transaction_age_days = ((last_transaction_date.y %--%
make_date(1998, 12, 31)) / days(1))) %>%
dplyr::select(c("amount.x", "duration", "payments", "status", "defaulter",
"contract_status", "gender", "age", "district_name",
"region", "no_of_inhabitants",
"no_of_municip_inhabitants_less_499",
"no_of_municip_500_to_1999", "no_of_municip_2000_to_9999",
"no_of_municip_greater_10000", "no_of_cities",
"ratio_of_urban_inhabitants",
"average_salary", "unemploymant_rate_1995",
"unemploymant_rate_1996",
"no_of_enterpreneurs_per_1000_inhabitants",
"no_of_commited_crimes_1995",
"no_of_commited_crimes_1996", "type.y",
"card_age_month","account_balance",
"avg_balance","transaction_count", "amount.y",
"last_transaction_age_days", "prop_old_age_pension",
"prop_insurance_payment",
"prop_sanction_interest","prop_household",
"prop_statement", "prop_interest_credited",
"prop_loan_payment", "prop_other"))
# renaming variables
colnames(source_dataset) <- c("x_loan_amount", "x_loan_duration", "x_loan_payments",
"x_loan_status", "y_loan_defaulter", "x_loan_contract_status",
"x_client_gender", "x_client_age",
"x_district_name", "x_region",
"x_no_of_inhabitants", "x_no_of_municip_inhabitants_less_499",
"x_no_of_municip_500_to_1999", "x_no_of_municip_2000_to_9999",
"x_no_of_municip_greater_10000", "x_no_of_cities",
"x_ratio_of_urban_inhabitants",
"x_average_salary", "x_unemploymant_rate_1995",
"x_unemploymant_rate_1996",
"x_no_of_enterpreneurs_per_1000_inhabitants",
"x_no_of_commited_crimes_1995",
"x_no_of_commited_crimes_1996", "x_card_type",
"x_card_age_month","x_account_balance",
"x_avg_account_balance","x_transaction_count",
"x_transaction_amount", "x_last_transaction_age_days",
"x_prop_old_age_pension", "x_prop_insurance_payment",
"x_prop_sanction_interest","x_prop_household","x_prop_statement",
"x_prop_interest_credited", "x_prop_loan_payment", "x_prop_other")
# excluding redundant variables
source_dataset <- dplyr::select(source_dataset, -c("x_loan_status", "x_loan_contract_status",
'x_prop_sanction_interest'))
# coercing variable domains and data types
source_dataset$x_card_type = ifelse(is.na(source_dataset$x_card_type), 'no card',
as.character(source_dataset$x_card_type))
source_dataset$x_card_age_month = ifelse(is.na(source_dataset$x_card_age_month), 0,
source_dataset$x_card_age_month)
source_dataset$y_loan_defaulter = as.integer(source_dataset$y_loan_defaulter)
# creating dummies
source_dataset <- fastDummies::dummy_cols(source_dataset,
remove_first_dummy = TRUE,
select_columns = c("x_client_gender", "x_district_name",
"x_region", "x_card_type"))
source_dataset <- dplyr::select(source_dataset, -c("x_client_gender", "x_district_name", "x_region",
"x_card_type"))
# reordering variables
source_dataset <- source_dataset[ , order(names(source_dataset))]
source_dataset <- dplyr::select(source_dataset, y_loan_defaulter, everything())
# excluding non desirable characters in variable names
colnames(source_dataset) <- stringr::str_replace_all(names(source_dataset), ' ', '_')
colnames(source_dataset) <- stringr::str_replace_all(names(source_dataset), '_-_', '_')
colnames(source_dataset) <- trimws(names(source_dataset))The below function was created to be used in the modeling exercises to be split the source_dataset into train and test datasets.
# SplitTestTrainDataset -------------------------------------------------------
# The objective of this function is to split a given dataset
# in train and test datasets
SplitTestTrainDataset <- function(dataset) {
set.seed(12345)
dataset$y_loan_defaulter <- as.integer(dataset$y_loan_defaulter)
index <- caret::createDataPartition(dataset$y_loan_defaulter,
p= 0.7, list = FALSE)
data.train <- dataset[index, ]
data.test <- dataset[-index,]
# checking event proportion in sample and test datasets against full dataset.
event_proportion <- bind_rows(prop.table(table(dataset$y_loan_defaulter)),
prop.table(table(data.train$y_loan_defaulter)),
prop.table(table(data.test$y_loan_defaulter)))
event_proportion$scope = ''
event_proportion$scope[1] = 'full dataset'
event_proportion$scope[2] = 'train dataset'
event_proportion$scope[3] = 'test dataset'
event_proportion <- select(event_proportion, scope, everything())
SplitDataset <- list()
SplitDataset$data.train <- data.train
SplitDataset$data.test <- data.test
SplitDataset$event.proportion <- event_proportion
return(SplitDataset)
}To make sure all the models uses the same datasets for Train and Testing we are saving the initial sampling to be reused across the models.
This will ensure consistency when comparing the models against each other.
# calling function to split and create train and test databases
# this function will split the dataset into train and test data and save the sampling in disk
# to resample just delete './models/source_train_test_dataset.rds' file and rerun this script
if (file.exists('./models/source_train_test_dataset.rds')) {
source_train_test_dataset <- readRDS('./models/source_train_test_dataset.rds')
} else {
source_train_test_dataset <- SplitTestTrainDataset(source_dataset)
saveRDS(source_train_test_dataset, './models/source_train_test_dataset.rds')
}