Predicting Loan Approval Odds

Author

Emrah Akbas

Published

August 18, 2026

Classification Models for Machine Learning:

  • Logistic Regression
  • Tree Model
  • Naive Bayes
  • SVMs
  • Random Forest
  • XGBoost

I will be analyzing a loan approval dataset to predict applicant outcomes. By using data visualization and testing different modeling methods, I will find the most accurate way to predict approvals and denials.

library(readr)
library(tidyverse)
library(ggplot2)
library(GGally)
library(dplyr)

Data source : https://www.kaggle.com/datasets/taweilo/loan-approval-classification-data

Column Description Type
person_age Age of the person Float
person_gender Gender of the person Categorical
person_education Highest education level Categorical
person_income Annual income Float
person_emp_exp Years of employment experience Integer
person_home_ownership Home ownership status (e.g., rent, own, mortgage) Categorical
loan_amnt Loan amount requested Float
loan_intent Purpose of the loan Categorical
loan_int_rate Loan interest rate Float
loan_percent_income Loan amount as a percentage of annual income Float
cb_person_cred_hist_length Length of credit history in years Float
credit_score Credit score of the person Integer
previous_loan_defaults_on_file Indicator of previous loan defaults Categorical
loan_status (target) Loan approval status: 1 = approved; 0 = rejected Integer
loan <- read_csv("C:/Users/151495/Desktop/Loan Aproval Modeling/loan_data.csv")
str(loan)
spc_tbl_ [45,000 × 14] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
 $ person_age                    : num [1:45000] 22 21 25 23 24 21 26 24 24 21 ...
 $ person_gender                 : chr [1:45000] "female" "female" "female" "female" ...
 $ person_education              : chr [1:45000] "Master" "High School" "High School" "Bachelor" ...
 $ person_income                 : num [1:45000] 71948 12282 12438 79753 66135 ...
 $ person_emp_exp                : num [1:45000] 0 0 3 0 1 0 1 5 3 0 ...
 $ person_home_ownership         : chr [1:45000] "RENT" "OWN" "MORTGAGE" "RENT" ...
 $ loan_amnt                     : num [1:45000] 35000 1000 5500 35000 35000 2500 35000 35000 35000 1600 ...
 $ loan_intent                   : chr [1:45000] "PERSONAL" "EDUCATION" "MEDICAL" "MEDICAL" ...
 $ loan_int_rate                 : num [1:45000] 16 11.1 12.9 15.2 14.3 ...
 $ loan_percent_income           : num [1:45000] 0.49 0.08 0.44 0.44 0.53 0.19 0.37 0.37 0.35 0.13 ...
 $ cb_person_cred_hist_length    : num [1:45000] 3 2 3 2 4 2 3 4 2 3 ...
 $ credit_score                  : num [1:45000] 561 504 635 675 586 532 701 585 544 640 ...
 $ previous_loan_defaults_on_file: chr [1:45000] "No" "Yes" "No" "No" ...
 $ loan_status                   : num [1:45000] 1 0 1 1 1 1 1 1 1 1 ...
 - attr(*, "spec")=
  .. cols(
  ..   person_age = col_double(),
  ..   person_gender = col_character(),
  ..   person_education = col_character(),
  ..   person_income = col_double(),
  ..   person_emp_exp = col_double(),
  ..   person_home_ownership = col_character(),
  ..   loan_amnt = col_double(),
  ..   loan_intent = col_character(),
  ..   loan_int_rate = col_double(),
  ..   loan_percent_income = col_double(),
  ..   cb_person_cred_hist_length = col_double(),
  ..   credit_score = col_double(),
  ..   previous_loan_defaults_on_file = col_character(),
  ..   loan_status = col_double()
  .. )
 - attr(*, "problems")=<externalptr> 

Exploratory Data Analysis

Several categorical variables are currently formatted as characters. I will convert character (chr) variables into factors. This formatting change ensures they work correctly in our models and calculations.

loan$person_gender <- as.factor(loan$person_gender)
loan$person_education <- as.factor(loan$person_education)
loan$person_home_ownership <- as.factor(loan$person_home_ownership)
loan$loan_intent <- as.factor(loan$loan_intent)
loan$previous_loan_defaults_on_file <- as.factor(loan$previous_loan_defaults_on_file)
loan$loan_status <- as.factor(loan$loan_status)
summary(loan)
   person_age     person_gender     person_education person_income    
 Min.   : 20.00   female:20159   Associate  :12028   Min.   :   8000  
 1st Qu.: 24.00   male  :24841   Bachelor   :13399   1st Qu.:  47204  
 Median : 26.00                  Doctorate  :  621   Median :  67048  
 Mean   : 27.76                  High School:11972   Mean   :  80319  
 3rd Qu.: 30.00                  Master     : 6980   3rd Qu.:  95789  
 Max.   :144.00                                      Max.   :7200766  
 person_emp_exp   person_home_ownership   loan_amnt    
 Min.   :  0.00   MORTGAGE:18489        Min.   :  500  
 1st Qu.:  1.00   OTHER   :  117        1st Qu.: 5000  
 Median :  4.00   OWN     : 2951        Median : 8000  
 Mean   :  5.41   RENT    :23443        Mean   : 9583  
 3rd Qu.:  8.00                         3rd Qu.:12237  
 Max.   :125.00                         Max.   :35000  
            loan_intent   loan_int_rate   loan_percent_income
 DEBTCONSOLIDATION:7145   Min.   : 5.42   Min.   :0.0000     
 EDUCATION        :9153   1st Qu.: 8.59   1st Qu.:0.0700     
 HOMEIMPROVEMENT  :4783   Median :11.01   Median :0.1200     
 MEDICAL          :8548   Mean   :11.01   Mean   :0.1397     
 PERSONAL         :7552   3rd Qu.:12.99   3rd Qu.:0.1900     
 VENTURE          :7819   Max.   :20.00   Max.   :0.6600     
 cb_person_cred_hist_length  credit_score   previous_loan_defaults_on_file
 Min.   : 2.000             Min.   :390.0   No :22142                     
 1st Qu.: 3.000             1st Qu.:601.0   Yes:22858                     
 Median : 4.000             Median :640.0                                 
 Mean   : 5.867             Mean   :632.6                                 
 3rd Qu.: 8.000             3rd Qu.:670.0                                 
 Max.   :30.000             Max.   :850.0                                 
 loan_status
 0:35000    
 1:10000    
            
            
            
            
over100 <- loan %>% 
  filter(person_age > 100)
over100
# A tibble: 7 × 14
  person_age person_gender person_education person_income person_emp_exp
       <dbl> <fct>         <fct>                    <dbl>          <dbl>
1        144 male          Bachelor                300616            125
2        144 male          Associate               241424            121
3        123 female        High School              97140            101
4        123 male          Bachelor                 94723            100
5        144 female        Associate              7200766            124
6        116 male          Bachelor               5545545             93
7        109 male          High School            5556399             85
# ℹ 9 more variables: person_home_ownership <fct>, loan_amnt <dbl>,
#   loan_intent <fct>, loan_int_rate <dbl>, loan_percent_income <dbl>,
#   cb_person_cred_hist_length <dbl>, credit_score <dbl>,
#   previous_loan_defaults_on_file <fct>, loan_status <fct>

Our data has some unrealistic values, since this is modeling experiment I will keep the extreme values for now.

ggpairs(loan, 
        columns = c("person_age", 
                    "person_gender", 
                    "person_education"),
                    aes(color=loan_status, alpha=0.4))

ggpairs(loan, 
        columns = c("person_income", 
                    "person_emp_exp", 
                    "person_home_ownership"),
                    aes(color=loan_status, alpha=0.4))

library(corrplot)
matrix <- cor(loan[sapply(loan, is.numeric)])
corrplot(matrix, method = "color",       
         order = "hclust",       
         addCoef.col = "orange", 
         number.cex = 1,       
         tl.col = "black",      
         tl.srt = 45)  

ggplot(loan, aes(x=loan_intent, y=loan_amnt, fill=loan_status)) + geom_col()

Medical loans are showing better approval rates compare to others, this might be important factor on guessing the outcome.

Model Fitting and Data Partitioning

We will split our data into train and test, but first lets fit the logistic regression using all the variables to our entire dataset to get a sense of the variables weight on prediction outcomes.

Logistic Regression

log_model <- glm(loan_status ~ ., data = loan, family = binomial)
summary(log_model)

Call:
glm(formula = loan_status ~ ., family = binomial, data = loan)

Coefficients:
                                    Estimate Std. Error z value Pr(>|z|)    
(Intercept)                       -3.447e-01  3.594e-01  -0.959  0.33752    
person_age                         2.476e-02  1.093e-02   2.267  0.02342 *  
person_gendermale                  3.907e-02  3.540e-02   1.104  0.26979    
person_educationBachelor          -3.915e-04  4.704e-02  -0.008  0.99336    
person_educationDoctorate          1.460e-02  1.476e-01   0.099  0.92122    
person_educationHigh School        1.332e-02  4.914e-02   0.271  0.78634    
person_educationMaster             3.006e-02  5.624e-02   0.534  0.59303    
person_income                      5.889e-07  1.969e-07   2.991  0.00278 ** 
person_emp_exp                    -2.179e-02  9.710e-03  -2.244  0.02485 *  
person_home_ownershipOTHER         3.383e-01  3.188e-01   1.061  0.28857    
person_home_ownershipOWN          -1.457e+00  1.018e-01 -14.311  < 2e-16 ***
person_home_ownershipRENT          7.217e-01  4.007e-02  18.009  < 2e-16 ***
loan_amnt                         -1.010e-04  3.940e-06 -25.637  < 2e-16 ***
loan_intentEDUCATION              -9.074e-01  5.851e-02 -15.508  < 2e-16 ***
loan_intentHOMEIMPROVEMENT        -8.255e-03  6.581e-02  -0.125  0.90018    
loan_intentMEDICAL                -2.910e-01  5.645e-02  -5.155 2.53e-07 ***
loan_intentPERSONAL               -7.272e-01  5.994e-02 -12.133  < 2e-16 ***
loan_intentVENTURE                -1.211e+00  6.359e-02 -19.044  < 2e-16 ***
loan_int_rate                      3.340e-01  6.574e-03  50.803  < 2e-16 ***
loan_percent_income                1.581e+01  3.072e-01  51.442  < 2e-16 ***
cb_person_cred_hist_length        -5.179e-03  9.109e-03  -0.569  0.56967    
credit_score                      -8.911e-03  4.100e-04 -21.732  < 2e-16 ***
previous_loan_defaults_on_fileYes -2.037e+01  1.026e+02  -0.198  0.84268    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 47674  on 44999  degrees of freedom
Residual deviance: 19879  on 44977  degrees of freedom
AIC: 19925

Number of Fisher Scoring iterations: 19
actual_labels <- ifelse(loan$loan_status == 1, "approved", "denied")
log_probs <- predict(log_model, type="response")

pred_labels <- ifelse(log_probs >0.5, "approved","denied")

conf_matrix <- table(pred_labels, actual_labels)

table(pred_labels, actual_labels)
           actual_labels
pred_labels approved denied
   approved     7506   2152
   denied       2494  32848
logistic_accuracy <- sum(diag(conf_matrix)) / sum(conf_matrix)
print(paste("Logistic Model Accuracy:", round(logistic_accuracy, 4)))
[1] "Logistic Model Accuracy: 0.8968"
log2_model <- glm(loan_status ~ person_age + person_gender + person_education + log(person_income) + 
                     person_home_ownership + loan_amnt + loan_intent + loan_int_rate + 
                    loan_percent_income + cb_person_cred_hist_length + credit_score + previous_loan_defaults_on_file,
                  data = loan,
                  family = binomial)                    
log2_probs <- predict(log2_model, type="response")

pred2_labels <- ifelse(log2_probs >0.5, "approved","denied")

conf_matrix2 <- table(pred2_labels, actual_labels)

table(pred2_labels, actual_labels)
            actual_labels
pred2_labels approved denied
    approved     7593   2184
    denied       2407  32816
logistic2_accuracy <- sum(diag(conf_matrix2)) / sum(conf_matrix2)
print(paste("Logistic Model 2 Accuracy:", round(logistic2_accuracy, 4)))
[1] "Logistic Model 2 Accuracy: 0.898"
set.seed(210)

train_data <- sample(nrow(loan), nrow(loan) * 0.8) 

train_size <- loan[train_data, ]
test_size <- loan[-train_data, ]

dim(train_size)
[1] 36000    14
dim(test_size)
[1] 9000   14

Tree Model

library(tree)
tree_model <- tree(loan_status ~ ., data = train_size)
summary(tree_model)

Classification tree:
tree(formula = loan_status ~ ., data = train_size)
Variables actually used in tree construction:
[1] "previous_loan_defaults_on_file" "loan_percent_income"           
[3] "loan_int_rate"                  "person_income"                 
[5] "person_home_ownership"         
Number of terminal nodes:  7 
Residual mean deviance:  0.4287 = 15430 / 35990 
Misclassification error rate: 0.09239 = 3326 / 36000 
plot(tree_model)
text(tree_model, pretty = 0, cex = 0.7)

The tree starts with previous loan defaults, which is the most important variable. If a person has a previous loan default, the model predicts loan_status = 0. If they have no previous default, the tree moves to the next factor, loan percent income, with a cutoff of 24.5%.

For people with no previous default and a loan percent income below 24.5%, the tree looks at the loan interest rate. An interest rate of 13.995% or higher leads to a prediction of 1. If the interest rate is below 13.995%, the model then considers person income, using $24,598.50 as the cutoff.

Overall, the tree shows that previous loan history, loan burden relative to income, interest rate, income, and home ownership are the main factors used to predict loan status. The model gives the most importance to previous loan defaults, while the other variables provide additional decision points for borrowers who have no previous defaults.

pred_tree <- predict(tree_model, newdata = test_size, type = "class")

conf_matrix_tree <- table(Predicted = pred_tree, Actual = test_size$loan_status)

conf_matrix_tree
         Actual
Predicted    0    1
        0 6676  610
        1  244 1470
accuracy_tree <- mean(pred_tree == test_size$loan_status)

cat("Test Accuracy:", accuracy_tree, "\n")
Test Accuracy: 0.9051111 
cv_tree <- cv.tree(tree_model)

plot(cv_tree$size, cv_tree$dev, type = "b", xlab = "Tree Size", ylab = "Deviance")

best_size <- cv_tree$size[which.min(cv_tree$dev)]
cat("Optimal Tree Size determined by CV:", best_size, "\n")
Optimal Tree Size determined by CV: 7 

This results further confirms that our standard tree model utilized best size on its branches.

Naive Bayes

library(e1071)
library(caTools)
library(caret)
nb_model <- naiveBayes(loan_status ~., data=train_size)
nb_model

Naive Bayes Classifier for Discrete Predictors

Call:
naiveBayes.default(x = X, y = Y, laplace = laplace)

A-priori probabilities:
Y
   0    1 
0.78 0.22 

Conditional probabilities:
   person_age
Y       [,1]     [,2]
  0 27.82810 6.089668
  1 27.57184 6.003294

   person_gender
Y      female      male
  0 0.4483974 0.5516026
  1 0.4513889 0.5486111

   person_education
Y    Associate   Bachelor  Doctorate High School     Master
  0 0.26826923 0.29433761 0.01396011  0.26688034 0.15655271
  1 0.26452020 0.30366162 0.01527778  0.26527778 0.15126263

   person_income
Y       [,1]     [,2]
  0 86077.00 91356.16
  1 59783.62 43945.34

   person_emp_exp
Y       [,1]     [,2]
  0 5.467094 6.113810
  1 5.232955 5.961267

   person_home_ownership
Y      MORTGAGE       OTHER         OWN        RENT
  0 0.467236467 0.002172365 0.078917379 0.451673789
  1 0.214772727 0.003409091 0.021212121 0.760606061

   loan_amnt
Y        [,1]     [,2]
  0  9221.926 6012.606
  1 10846.944 7143.848

   loan_intent
Y   DEBTCONSOLIDATION EDUCATION HOMEIMPROVEMENT   MEDICAL  PERSONAL   VENTURE
  0         0.1424501 0.2162749       0.1002137 0.1769943 0.1722578 0.1918091
  1         0.2178030 0.1565657       0.1246212 0.2366162 0.1517677 0.1126263

   loan_int_rate
Y       [,1]     [,2]
  0 10.47401 2.735332
  1 12.86800 3.073760

   loan_percent_income
Y        [,1]       [,2]
  0 0.1219769 0.07138143
  1 0.2020758 0.10675036

   cb_person_cred_hist_length
Y       [,1]     [,2]
  0 5.902066 3.873000
  1 5.796212 3.930272

   credit_score
Y       [,1]     [,2]
  0 632.8164 50.36824
  1 632.2072 50.25421

   previous_loan_defaults_on_file
Y          No       Yes
  0 0.3469017 0.6530983
  1 1.0000000 0.0000000

The biggest factors driving denials are financial strain and housing status in our Naive Bayes Model.

Surprisingly, demographics and credit scores barely matter in this model. Age, gender, education, and credit scores (averaging around 632 for both groups) look almost identical across approved and denied applicants. To make accurate predictions, the model relies heavily on income, loan amount, interest rates, and homeownership rather than basic applicant backgrounds.

nb_preds <- predict(nb_model, newdata = test_size)

conf_matrix_nb <- table(Predicted = nb_preds, Actual = test_size$loan_status)

conf_matrix_nb
         Actual
Predicted    0    1
        0 6267  448
        1  653 1632
accuracy_nb <- sum(diag(conf_matrix_nb)) / sum(conf_matrix_nb)

# Print accuracy as a percentage
print(paste("Accuracy:", round(accuracy_nb * 100, 2), "%"))
[1] "Accuracy: 87.77 %"

Support Vector Machines (SVMs)

library(caret)
library(kernlab)

control <- trainControl(method = "none") # i didnt want to do cv since dataset is large but I might try on eventually.

# (method = "cv", number = 5)
# tuning_grid <- expand.grid(degree = c(1, 2, 3), scale = c(0.1, 1), C = c(0.1, 1, 10)) 

# Well I tried running parameters above but the model run time will reach to hours or even days. So this options could be added if there is better computational power is available

svm_model_rad <- train(loan_status ~ ., 
                   data = train_size, method = "svmRadial", trControl = control)

svm_preds_rad <- predict(svm_model_rad, newdata = test_size)

confusionMatrix(svm_preds_rad, test_size$loan_status)
Confusion Matrix and Statistics

          Reference
Prediction    0    1
         0 6626  540
         1  294 1540
                                          
               Accuracy : 0.9073          
                 95% CI : (0.9012, 0.9132)
    No Information Rate : 0.7689          
    P-Value [Acc > NIR] : < 2.2e-16       
                                          
                  Kappa : 0.728           
                                          
 Mcnemar's Test P-Value : < 2.2e-16       
                                          
            Sensitivity : 0.9575          
            Specificity : 0.7404          
         Pos Pred Value : 0.9246          
         Neg Pred Value : 0.8397          
             Prevalence : 0.7689          
         Detection Rate : 0.7362          
   Detection Prevalence : 0.7962          
      Balanced Accuracy : 0.8489          
                                          
       'Positive' Class : 0               
                                          
svm_rad_results <- confusionMatrix(svm_preds_rad, test_size$loan_status)
svm_rad_accuracy <- svm_rad_results$overall["Accuracy"]
print(paste("Radial SVM Accuracy:", round(svm_rad_accuracy, 4)))
[1] "Radial SVM Accuracy: 0.9073"

Random Forest

library(randomForest)

set.seed(210) 
rforest_model <- randomForest(loan_status ~ .,
                         data = train_size,
                         ntree = 600,
                         mtry = 2,
                         importance = TRUE)

rf_preds <- predict(rforest_model, newdata = test_size, type = "response")

rf_matrix <- table(Predicted = rf_preds, Actual = test_size$loan_status)
print(rf_matrix)
         Actual
Predicted    0    1
        0 6780  520
        1  140 1560
rf_accuracy <- sum(diag(rf_matrix)) / sum(rf_matrix)
print(paste("Overall Accuracy:", round(rf_accuracy, 4)))
[1] "Overall Accuracy: 0.9267"

Extreme Gradient Boosting (XGBoost)

library(xgboost)

first I need to prepare the data for XGBoost

X_train <- model.matrix(loan_status ~ ., data = train_size)[, -1]
X_test  <- model.matrix(loan_status ~ ., data = test_size)[, -1]

y_train <- as.numeric(as.character(train_size$loan_status))
y_test  <- as.numeric(as.character(test_size$loan_status))

dtrain <- xgb.DMatrix(data = X_train, label = y_train)
dtest  <- xgb.DMatrix(data = X_test, label = y_test)
params <- list(objective = "reg:logistic",
               max.depth = 4, 
               eta       = 0.1
               )

xgb_model <- xgb.train(params  = params, 
                        data    = dtrain, 
                        nrounds = 50, 
                        verbose = 0)
xgb_probs <- predict(xgb_model, newdata = X_test)
xgb_preds <- ifelse(xgb_probs > 0.5, 1, 0)

xgb_matrix <- table(Predicted = xgb_preds, Actual = y_test)
print(xgb_matrix)
         Actual
Predicted    0    1
        0 6708  519
        1  212 1561
xgb_accuracy <- sum(diag(xgb_matrix)) / sum(xgb_matrix)
print(xgb_accuracy)
[1] 0.9187778

Model Comparison

model_comparison <- data.frame(
  Model = c(
    "Logistic Regression", 
    " Tree Model", 
    "Naiive Bayes", 
    "SVMs Model", 
    "Random Forest", 
    "XGBoost"
    ),
  Accuracy = c(
    logistic_accuracy,         
    accuracy_tree,
    accuracy_nb,
    svm_rad_accuracy,
    rf_accuracy,
    xgb_accuracy
    )
  )
library(DT)

datatable(
  model_comparison,
  colnames = c("Model Type", "Prediction Accuracy"), 
  rownames = FALSE,                              
  options = list(
    pageLength = 6,                                
    dom = 't',                                   
    columnDefs = list(list(className = 'dt-center', targets = 0:1)) 
  )
) %>% 
  formatPercentage('Accuracy', digits = 2) 
library(ggplot2)
library(dplyr)

model_comparison <- model_comparison %>%
  mutate(Model = reorder(Model, Accuracy))


ggplot(model_comparison, aes(x = Model, y = Accuracy, fill = Accuracy)) +
  geom_col(width = 0.7, show.legend = FALSE) +
    geom_text(aes(label = paste0(round(Accuracy * 100, 1), "%")), 
            hjust = -0.15, size = 4, fontface = "bold", color = "grey20") +
  coord_flip() +
  scale_fill_gradient(low = "#74a9cf", high = "#0570b0") +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1), 
                     expand = expansion(mult = c(0, 0.1))) +
  labs(
    title = "Model Accuracy Comparison",
    subtitle = "Predicting loan approval odds across six different machine learning algorithms",
    x = NULL,
    y = "Accuracy Score"
  ) +
  
  theme_minimal(base_size = 13) +
  theme(
    plot.title = element_text(face = "bold", size = 16, color = "#2c3e50"),
     plot.subtitle = element_text(size = 11, color = "#C01D0C", margin = ggplot2::margin(b = 15)),
    axis.text.y = element_text(face = "bold", color = "#34495e"),
    panel.grid.minor = element_blank(),
    panel.grid.major.y = element_blank()
  )

Model Selection and Final Take

My winning model is Random Forest with impressive accuracy of 92.7%. This means that my model predict whether person get approve or denied 92.7% of the time.