Predicting lending rates with Databricks and tidymodels
Machine learning algorithms are reshaping financial decision-making, changing how the industry manages financial risk. These advanced algorithms can analyze vast amounts of data to deliver predictive insights, which creates new opportunities to drive informed decisions or to expedite client service.
And now, machine learning is more streamlined than it has ever been. Financial analysts can use Posit to harness the performance and data governance capabilities of Databricks. Analysts can use:
Moreover, they can work in managed environments with their preferred IDE in Posit Workbench and deploy and share their models on Posit Connect. This workflow combines the power of R’s tidymodels framework for machine learning with the storage and computation capabilities of Databricks.
In this article, we will use both Posit and Databricks to apply machine learning methods to the consumer credit market, where accurately predicting lending rates is critical for customer acquisition. Credit lenders must use a comprehensive process, transparent to regulators, to choose lending rates for loan applicants—but this takes time. Customers shopping between lenders want personalized lending rates, quickly. To avoid losing customers to faster alternatives, while still minimizing loan associated risks, we will build a user friendly Shiny app, based on a machine learning model, that customers can use to predict the interest rates on their loans before they submit their applications.
Specifically, we will:
Connect to historical lending rate data stored in Databricks Delta Lake
Tune and cross-validate a penalized linear regression (LASSO) that predicts interest rates
Select variables with the penalized linear regression model (LASSO)
Build an interactive Shiny app to provide a customer-facing user interface for our model
Deploy the app to production on Posit Connect, and arrange for the app to access Databricks
Setup
Let’s start by loading the packages that will be integral to our workflow.
For those with access to Databricks, add the historical lending rate data to your catalog by running the below:
CREATETABLE lending_club USING com.databricks.spark.csv OPTIONS(path 'dbfs:/databricks-datasets/lending-club-loan-stats/LoanStats_2018Q2.csv', header"true");
Connect to Databricks from the RStudio IDE
As previously mentioned, analysts have several options for accessing Databricks data. In this walk-through, we’ll demonstrate connectivity using the odbc package. The Posit Solutions site provides detailed guidance on using the new odbc databricks() function. Essentially, we need to store a Databricks token and host URL in our R environment. The function itself requires only the HTTP path to the SQL warehouse.
This code snippet establishes a connection whose details are stored in the con object. With this connection, we can create an object named lendingclub_dat that refers to our table in Databricks.
tbl() creates the R equivalent of an SQL view: an SQL query that represents a table. For example, lendingclub_dat is the virtual table represented by this SQL query. We can view the query with show_query().
Show the code
lendingclub_dat |>show_query()
<SQL>
SELECT *
FROM `hive_metastore`.`default`.`lendingclub`
As a result, lendingclub_dat is very lightweight—it has no data. The data still lives in Databricks, and therefore can be far larger than what we would normally import into R.
But we can still work with lendingclub_dat as if it were an R data frame. When we manipulate a tbl with dplyr or base R code, dbplyr translates our code to SQL and adds it to the query. The comparison below shows some code we might write with R, alongside the SQL translation provided by dbplyr. As you can see, dplyr often provides a more efficient way to write SQL than SQL itself due to its expressive syntax.
<SQL>
SELECT
`int_rate`,
CAST(`annual_inc` AS DOUBLE) AS `annual_inc`,
CAST(`annual_inc_joint` AS DOUBLE) AS `annual_inc_joint`
FROM `hive_metastore`.`default`.`lendingclub`
LIMIT 6
When we wish to run the accumulated query and access the result, we can call collect(). R will send the SQL query to Databricks to be evaluated in Databricks’ compute. It will then import the result as a real table into R, where we can continue to manipulate it in R; for example, to run advanced operations on the data that have no equivalent in SQL.
As mentioned earlier, we can work with larger data sets than we would normally import into R, since the data remains in Databricks.
We can leverage Databricks’ fast compute. Since computations normally run faster in Databricks than in R, running operations before pulling data into R with collect() leads to significantly faster performance.
And while this arrangement may seem exotic, it is very true to R’s design philosophy. R’s precursor, S, was originally created to provide a human friendly syntax layer that would call more formal routines, written in FORTRAN, C, and so on, under the hood.
Data cleaning
Now, we can proceed with using dplyr to clean up the rest of the variables and create supplementary variables that could potentially influence lending rates.
Show the code
lendingclub_dat <- dplyr::tbl(con, dbplyr::in_catalog("hive_metastore", "default", "lendingclub")) |>select(-c("acc_now_delinq", "chargeoff_within_12_mths","debt_settlement_flag", "debt_settlement_flag_date","deferral_term","delinq_amnt","desc","disbursement_method","emp_title","funded_amnt","funded_amnt_inv","grade","hardship_amount","hardship_dpd", "hardship_end_date", "hardship_flag","hardship_last_payment_amount", "hardship_length","hardship_loan_status", "hardship_payoff_balance_amount","hardship_reason", "hardship_start_date", "hardship_status","last_credit_pull_d","hardship_type","id","initial_list_status","installment","issue_d","last_pymnt_d", "last_pymnt_amnt", "loan_status","member_id", "next_pymnt_d", "num_tl_30dpd", "num_tl_120dpd_2m", "orig_projected_additional_accrued_interest","out_prncp", "out_prncp_inv","payment_plan_start_date","policy_code","purpose", "pymnt_plan", "revol_bal_joint","revol_util", "sec_app_earliest_cr_line","sec_app_inq_last_6mths", "sec_app_mort_acc", "sec_app_open_acc","sec_app_revol_util", "sec_app_open_act_il","sec_app_num_rev_accts", "sec_app_chargeoff_within_12_mths","sec_app_collections_12_mths_ex_med","sec_app_mths_since_last_major_derog","settlement_amount","settlement_date", "settlement_percentage", "settlement_status","settlement_term","sub_grade","title", "total_pymnt", "total_pymnt_inv","total_rec_int", "total_rec_late_fee", "total_rec_prncp", # "total_rev_hi_lim","url","verification_status","verification_status_joint")) |>mutate(# Convert these columns into numericacross(c(starts_with("annual"), starts_with("dti"), starts_with("inq"), starts_with("mo"), starts_with("mths"), starts_with("num"), starts_with("open"), starts_with("percent"), starts_with("pct"), starts_with("revol"), starts_with("tot"), "acc_open_past_24mths", "all_util", "avg_cur_bal","bc_open_to_buy", "bc_util", "collections_12_mths_ex_med", "collection_recovery_fee", "delinq_2yrs", "il_util", "loan_amnt", "max_bal_bc", "pub_rec", "pub_rec_bankruptcies", "recoveries", "tax_liens"), ~as.numeric(.)),# Calculate a loan to income statisticloan_to_income =case_when( application_type =="Individual"~ loan_amnt / annual_inc,.default = loan_amnt / annual_inc_joint ),# Calculate the percentage of the borrower's total income that current debt # obligations, including this loan, will representadjusted_dti =case_when( application_type =="Individual"~ (loan_amnt + tot_cur_bal) / (annual_inc),.default = (loan_amnt + tot_cur_bal) / (annual_inc_joint) ),# Calculate utilization on installment accounts excluding mortgage balanceil_util_ex_mort =case_when( total_il_high_credit_limit >0~ total_bal_ex_mort / total_il_high_credit_limit,.default =0 ),# Fill debt to income joint with individual debt to income where missingdti_joint =coalesce(dti_joint, dti),# Fill annual income joint with individual annual income where missingannual_inc_joint =coalesce(annual_inc_joint, annual_inc)) |>collect()
After running collect(), when we have our data frame in R, we gain access to additional advanced R functions that are not available in SQL. This enables us to further refine and clean our data as needed.
Show the code
lendingclub_dat_clean <- lendingclub_dat |>mutate(# Missing values for these columns seem most appropriate to fill with zeroacross(c("inq_fi", "dti", "all_util", "percent_bc_gt_75", "il_util", "avg_cur_bal","all_util", "il_util", "inq_last_6mths", "inq_last_12m", "open_il_12m", "open_il_24m", "open_rv_12m", "open_rv_24m"), ~replace_na(., 0)),# Missing values for these columns seem most appropriate to fill with the column maxacross(c("mo_sin_old_il_acct", "mths_since_last_major_derog", "mths_since_last_delinq", "mths_since_recent_bc", "mths_since_last_record", "mths_since_rcnt_il", "mths_since_recent_bc", "mths_since_recent_bc_dlq", "mths_since_recent_inq", "mths_since_recent_revol_delinq", "mths_since_recent_revol_delinq"), ~replace_na(., max(., na.rm =TRUE))),# Remove percent signint_rate =as.numeric(stringr::str_remove(int_rate, "%")),# Create variable for earliest line of creditearliest_cr_line = lubridate::parse_date_time2(paste("01", earliest_cr_line, sep ="-"), "dmy", cutoff_2000 = 50L),# Calculate time since earliest line of creditage_earliest_cr = lubridate::interval(as.Date(earliest_cr_line), as.Date(lubridate::today())) %/% lubridate::days(1),# Convert characters to factorsacross(where(is.character), .fns = as.factor),# Encode ordered factorsterm =as.numeric(stringr::str_trim(stringr::str_remove(term, "months"))),emp_length =as.ordered(factor(emp_length, levels =c("< 1 year", "1 year", "2 years", "3 years", "4 years", "5 years","6 years", "7 years", "8 years", "9 years", "10+ years")))) |># drop date columnselect(!earliest_cr_line) |>filter(!is.na(int_rate))
Finally, we create vectors for conveniently referencing variable categories later if necessary.
With our data cleaned up, it’s time to examine the variables we intend to include in the model. Let’s start with the variable we want to predict.
We can use the dbplot package to generate a ggplot without transferring the data into R. db_compute_bins() bins and counts interest rates in Databricks and then returns a small dataset of counts for ggplot() to plot. This visualization displays the distribution of the variable we aim to predict, lending rate. Loan amounts are plotted on the x-axis, while the frequency of loans at each amount is depicted on the y-axis. It shows that the majority of loans have an lending rate of less than 20%. It also shows that our response variable may not be normally distributed, an insight that we will ignore for this demo.
With our dataset prepared, we can begin the modeling process. This involves creating train and test datasets, which we’ll use to train and evaluate our model.
Using tidymodels, we can construct a “recipe” detailing the steps we want to take to pre-process our data:
step_normalize(): This recipe step normalizes numeric data to have a standard deviation of one and a mean of zero. Given that our dataset contains numeric values of various units (e.g., dollars, months), normalization ensures that variables with larger value ranges do not disproportionately influence the model.
step_ordinal_score(): This recipe step encodes variables as ordered categorical variables (ordered factors in R).
step_integer(): This recipe step converts non-numeric data into a set of integers, based on unique values within the data.
step_impute_mean(): This recipe step replaces missing values of numeric variables with the mean of those values in the training set. Performing this step now, rather than during the data cleaning phase, prevents information leakage from the testing set into the model. By calculating the mean solely using the training set, we avoid bias when applying it to the testing data during model evaluation.
To verify that the recipe functions as intended, we can run prep(rec_obj, lend_train) |> bake(newdata = NULL). This will display the data that the workflow will provide to the model.
The LASSO regression is a form of penalized linear regression that performs both variable selection and regularization to create a model that has high predictive accuracy, but remains explainable (say, to a bank regulator).
To perform a LASSO regression, we must choose the value of a hyperparameter, \(\lambda\), known as the penalty parameter or the regularization parameter. We cannot know ahead of time what the optimum value of \(\lambda\) will be, but we can use R’s tidymodels framework to perform hyper-parameter tuning.
To do this, we use R’s glmnet package as an engine to fit a linear regression. The glmnet package requires two parameters. We set mixture = 1 to perform a LASSO regression (mixture = 0 would perform a ridge regression). We set the penalty parameter to the desired value of \(\lambda\). Here, we set penalty to tune() to indicate that we want to find the optimum value with hyper-parameter tuning.
Finally, we add our model and the recipe above to a tidymodels workflow object. This creates a single workflow that we will apply to our data each time we fit a model.
We can find the optimal value of \(\lambda\) with the tune_grid() function from tidymodel’s tune package. We ask tune_grid() to use 10-fold cross-validation on our training data to compare the performance of 50 values of \(\lambda\).
How well does the model do? We can analyze our results by calculating the coefficient of determination, which is 0.46 in this case. While this indicates some level of explanatory power, it suggests that our model’s estimate may not be very robust.
# A tibble: 1 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 rsq standard 0.458
Show the code
rmse(lend_lasso_results, truth = int_rate, estimate = .pred)
# A tibble: 1 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 rmse standard 3.77
Reality Check
Now that we have a model, we can ask whether it is useful for our use case. We want to provide a model that loan applicants can use to get a quick estimate. This means that applicants will need to provide a value for every variable used by the model.
We mentioned above that LASSO regression does variable selection, so let’s check how many variables this model selected. The model selects, or uses, a variable if its coefficient is non-zero.
How many variables are in this model?
When we extract the underlying parsnip model from our results and run it through the tidy() function of the broom package, we see that our model uses 20 variables.
This is not realistic for our use case. Unfortunately, we think that clients who use our app will only have patience to input four or five variables. Hence, we need to find the subset of five variables that yield the best predictions.
We use the properties of our LASSO model to do this. As we increase the \(\lambda\) parameter, more and more variable coefficients go to zero, leaving behind progressively smaller subsets of the most powerful predictors.
We choose to use the final subset of four variables, term, all_util, bc_util, and bc_open_to_buy, because:
We can compute these from fields that are easy for the user to provide:
desired term of the loan
their current credit limits
their current credit balances
their current bank card limits
their current bank card balances
The fifth term would be difficult for users to calculate, i.e. percent_bc_gt_75, the percent of the applicants bank cards that are currently withdrawn by more than 75%, so we choose not to include it.
The final model
We now repeat our modeling steps to build a final model with the four variables.
How much performance do we sacrifice by using only four variables? Quite a bit, as you might suspect. We have much less information with which to make predictions, but we think the trade-off is worth it.
# A tibble: 1 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 rsq standard 0.298
Show the code
rmse(lend_linear_results, truth = int_rate, estimate = .pred)
# A tibble: 1 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 rmse standard 4.28
Model logging and artifact storage
If we aim to log our model for use in other contexts, like a Shiny app, we can utilize the vetiver package. This tool facilitates deploying and maintaining machine learning models in production, allowing us to store models in a pin for convenient access and reference.
We can create a vetiver object v to store the trained model.
Show the code
v <-vetiver_model(lend_linear_fit, "lending_club_model")
We can deploy the model by creating a special Plumber router in R with the plumber package. We add a POST endpoint for making predictions. Following that, we connect to the destination where we intend to store our model artifact. In our case, this would be Posit Connect. Then, we save the vetiver model to a pin for future access.
Now that our model is available through an API, we can use it in other places – such as a Shiny app! Since our current model is so lightweight, we will skip this step and instead rely on a version of the model bundled with our app. This will make our app slightly faster. Every millisecond counts!
Interactive Shiny app
We will use a Shiny app to provide a UI for applicants to send input to our model, and to see the results. Because our predictions are not very robust, we will include a histogram that visualizes the distribution of rates received by applicants with similar characteristics. Since this data lives in Databricks, our Shiny app will need to connect to Databricks to draw the histogram.
Connect to Databricks from a Shiny app
We can use the same code as before to connect to Databricks, visible in authenticate.R below. In this case, we will provide the environmental variables DATABRICKS_HOST, DATABRICKS_TOKEN, and HTTP_PATH when we deploy the app. Our app will use these environmental variables on behalf of its users.
Below is our Shiny app for predicting lending rates. Each time you select an input, it calculates the rate based on our model. The design and layout of the Shiny app is developed with bslib.
# cards.R# install.packages("remotes")# remotes::install_github("rstudio/bslib") # for version 0.7.1library(bslib)library(bsicons)cards <-list(card(card_body(selectInput(inputId ="term",choices =list("36 months"=36, "60 months"=60),selected =36,label =tooltip(trigger =list("Term of loan", bs_icon("info-circle")),"How soon would you like to pay off the loan?" ) ) ) ),card(card_body(numericInput(inputId ="all_balance",value =5000,min =0,max =500000,step =1000,label =tooltip(trigger =list("Credit Balance", bs_icon("info-circle")),"How much credit, in dollars, do you currently have withdrawn from all sources of credit, including bank cards?" ) ) ) ),card(card_body(numericInput(inputId ="all_limit",value =10000,min =0,max =500000,step =1000,label =tooltip(trigger =list("Credit Limit", bs_icon("info-circle")),"What is your total credit limit, in dollars, for all sources of credit, including bank cards?" ) ) ) ),card(card_body(numericInput(inputId ="bc_balance",value =5000,min =0,max =500000,step =1000,label =tooltip(trigger =list("Bank Card Balance", bs_icon("info-circle")),"How much credit, in dollars, do you currently have withdrawn from only bank cards?" ) ) ) ),card(card_body(numericInput(inputId ="bc_limit",value =10000,min =0,max =500000,step =1000,label =tooltip(trigger =list("Bank Card Limit", bs_icon("info-circle")),"What is your total credit limit, in dollars, for only bank cards?" ) ) ) ))vbs <-value_box(title ="Predicted interest rate",value =textOutput("pred_int"),style ="background-color: #082D46!important; color: #FFFFFF!important",showcase = bsicons::bs_icon("bank", fill ='#4682b4 !important;'),showcase_layout ="top right",height =500 )foot <- tags$div(style ="background-color: #FFFFFF; padding: 0px; text-align: center; bottom: 0; width: 100%;",HTML("Powered by <a href='https://posit.co'><img src='https://www.rstudio.com/assets/img/posit-logo-fullcolor-TM.svg' alt='Posit Logo' style='width:55px;'></a> | Integrated with <a href='https://www.databricks.com'><img src='https://cdn.cookielaw.org/logos/29b588c5-ce77-40e2-8f89-41c4fa03c155/bc546ffe-d1b7-43af-9c0b-9fcf4b9f6e58/1e538bec-8640-4ae9-a0ca-44240b0c1a20/databricks-logo.png' alt='Databricks Logo' style='width:85px;'></a>. For more details, see our <a href='https://posit.co/blog/databricks-and-posit-announce-new-integrations/' target='_blank'>blog post</a> announcing the partnership." ) )plot <-card(full_screen =TRUE,card_header(HTML("Applicants like you have received these interest rates")),card_body(plotOutput("plot") ))
Show the code
# helpers.Rlibrary(dplyr)library(ggplot2)library(dbplyr)library(dbplot)# Returns binned interest rates for 50 most similar applicantsfind_rates_for_similar_applicants <-function(data, .term, .all_util, .bc_util, .bc_open_to_buy) { data |>mutate(distance = (term -as.numeric(.term))^2+ (all_util - .all_util)^2+ (bc_util - .bc_util)^2+ (bc_open_to_buy - .bc_open_to_buy)^2,rank =min_rank(distance)) |>filter(rank <=50&!is.na(rank)) |>db_compute_bins(int_rate, binwidth =0.5)}# Plots binned interest ratesplot_rate_distribution <-function(distribution) { distribution |>ggplot() +geom_col(aes(x = int_rate, y = count), fill ="#4682b4", color ="#4682b4", alpha =0.4) +labs(x ="Interest Rate (%)", y ="Number of applicants") +scale_x_continuous(limits =c(0, 35),breaks =seq(from =0, to =35, by =5)) +theme_minimal()}
Deploying the app
Posit Connect provides a production ready environment to host our app, and Posit Workbench provides push button publishing to Connect.
To deploy, we click the Publish icon at the top of our app.
“To see the publish icon, open app.R in the RStudio IDE. The icon will be on the top right.”
This launches a wizard that we can use to connect and publish to a Connect account.
“The Publish wizard appears when you click the publish icon.”
Once our app is deployed, we visit on Connect to:
Open view access to anyone
Provide a custom URL for the app
“Once the app is published to Connect, Workbench will automatically open the app controls on Connect in your web browser.”
We also visit the Vars tab to provide values for the environmental variables that the app will use to access Databricks.
“Use the Vars tab on Connect to securely set environmental variables.”
Our app is now ready for use.
Summary
In summary, we’ve gone on a comprehensive journey to predict lending rates using machine learning techniques within the context of financial analysis. We began by accessing our data from Databricks, cleaning our data, and using tidymodels for modeling and evaluation. We developed two predictive models, evaluated their performance, and identified key variables driving interest rate predictions.
Leveraging the power of vetiver, we deployed our model for seamless integration into production environments, such as Shiny apps.
We then deployed our model as a Shiny app that uses Posit Connect’s secure environmental variables feature to access Databricks.
This holistic approach can help enhance decision-making processes within the financial domain.
Learn more about the Databricks x Posit partnership
We believe our products are better together. Learn more about our partnership.