Refer to http://archive.ics.uci.edu/ml/datasets/Statlog+(German+Credit+Data))
for variable description. The response variable is Class
and all others are predictors.
Only run the following code once to install the package
caret. The German credit scoring data in
provided in that package.
if (!requireNamespace("caret", quietly = TRUE)) {
install.packages("caret")
}
if (!requireNamespace("listenv", quietly = TRUE)) {
install.packages("listenv")
}
if (!requireNamespace("pROC", quietly = TRUE)) {
install.packages("pROC")
}
library(caret) #this package contains the german data with its numeric format
## Loading required package: ggplot2
## Loading required package: lattice
data("GermanCredit", package = "caret") # load explicitly from caret
GermanCredit$Class <- as.numeric(GermanCredit$Class == "Good") # now 1 is Good and 0 is Bad
str(GermanCredit)
## 'data.frame': 1000 obs. of 62 variables:
## $ Duration : int 6 48 12 42 24 36 24 36 12 30 ...
## $ Amount : int 1169 5951 2096 7882 4870 9055 2835 6948 3059 5234 ...
## $ InstallmentRatePercentage : int 4 2 2 2 3 2 3 2 2 4 ...
## $ ResidenceDuration : int 4 2 3 4 4 4 4 2 4 2 ...
## $ Age : int 67 22 49 45 53 35 53 35 61 28 ...
## $ NumberExistingCredits : int 2 1 1 1 2 1 1 1 1 2 ...
## $ NumberPeopleMaintenance : int 1 1 2 2 2 2 1 1 1 1 ...
## $ Telephone : num 0 1 1 1 1 0 1 0 1 1 ...
## $ ForeignWorker : num 1 1 1 1 1 1 1 1 1 1 ...
## $ Class : num 1 0 1 1 0 1 1 1 1 0 ...
## $ CheckingAccountStatus.lt.0 : num 1 0 0 1 1 0 0 0 0 0 ...
## $ CheckingAccountStatus.0.to.200 : num 0 1 0 0 0 0 0 1 0 1 ...
## $ CheckingAccountStatus.gt.200 : num 0 0 0 0 0 0 0 0 0 0 ...
## $ CheckingAccountStatus.none : num 0 0 1 0 0 1 1 0 1 0 ...
## $ CreditHistory.NoCredit.AllPaid : num 0 0 0 0 0 0 0 0 0 0 ...
## $ CreditHistory.ThisBank.AllPaid : num 0 0 0 0 0 0 0 0 0 0 ...
## $ CreditHistory.PaidDuly : num 0 1 0 1 0 1 1 1 1 0 ...
## $ CreditHistory.Delay : num 0 0 0 0 1 0 0 0 0 0 ...
## $ CreditHistory.Critical : num 1 0 1 0 0 0 0 0 0 1 ...
## $ Purpose.NewCar : num 0 0 0 0 1 0 0 0 0 1 ...
## $ Purpose.UsedCar : num 0 0 0 0 0 0 0 1 0 0 ...
## $ Purpose.Furniture.Equipment : num 0 0 0 1 0 0 1 0 0 0 ...
## $ Purpose.Radio.Television : num 1 1 0 0 0 0 0 0 1 0 ...
## $ Purpose.DomesticAppliance : num 0 0 0 0 0 0 0 0 0 0 ...
## $ Purpose.Repairs : num 0 0 0 0 0 0 0 0 0 0 ...
## $ Purpose.Education : num 0 0 1 0 0 1 0 0 0 0 ...
## $ Purpose.Vacation : num 0 0 0 0 0 0 0 0 0 0 ...
## $ Purpose.Retraining : num 0 0 0 0 0 0 0 0 0 0 ...
## $ Purpose.Business : num 0 0 0 0 0 0 0 0 0 0 ...
## $ Purpose.Other : num 0 0 0 0 0 0 0 0 0 0 ...
## $ SavingsAccountBonds.lt.100 : num 0 1 1 1 1 0 0 1 0 1 ...
## $ SavingsAccountBonds.100.to.500 : num 0 0 0 0 0 0 0 0 0 0 ...
## $ SavingsAccountBonds.500.to.1000 : num 0 0 0 0 0 0 1 0 0 0 ...
## $ SavingsAccountBonds.gt.1000 : num 0 0 0 0 0 0 0 0 1 0 ...
## $ SavingsAccountBonds.Unknown : num 1 0 0 0 0 1 0 0 0 0 ...
## $ EmploymentDuration.lt.1 : num 0 0 0 0 0 0 0 0 0 0 ...
## $ EmploymentDuration.1.to.4 : num 0 1 0 0 1 1 0 1 0 0 ...
## $ EmploymentDuration.4.to.7 : num 0 0 1 1 0 0 0 0 1 0 ...
## $ EmploymentDuration.gt.7 : num 1 0 0 0 0 0 1 0 0 0 ...
## $ EmploymentDuration.Unemployed : num 0 0 0 0 0 0 0 0 0 1 ...
## $ Personal.Male.Divorced.Seperated : num 0 0 0 0 0 0 0 0 1 0 ...
## $ Personal.Female.NotSingle : num 0 1 0 0 0 0 0 0 0 0 ...
## $ Personal.Male.Single : num 1 0 1 1 1 1 1 1 0 0 ...
## $ Personal.Male.Married.Widowed : num 0 0 0 0 0 0 0 0 0 1 ...
## $ Personal.Female.Single : num 0 0 0 0 0 0 0 0 0 0 ...
## $ OtherDebtorsGuarantors.None : num 1 1 1 0 1 1 1 1 1 1 ...
## $ OtherDebtorsGuarantors.CoApplicant : num 0 0 0 0 0 0 0 0 0 0 ...
## $ OtherDebtorsGuarantors.Guarantor : num 0 0 0 1 0 0 0 0 0 0 ...
## $ Property.RealEstate : num 1 1 1 0 0 0 0 0 1 0 ...
## $ Property.Insurance : num 0 0 0 1 0 0 1 0 0 0 ...
## $ Property.CarOther : num 0 0 0 0 0 0 0 1 0 1 ...
## $ Property.Unknown : num 0 0 0 0 1 1 0 0 0 0 ...
## $ OtherInstallmentPlans.Bank : num 0 0 0 0 0 0 0 0 0 0 ...
## $ OtherInstallmentPlans.Stores : num 0 0 0 0 0 0 0 0 0 0 ...
## $ OtherInstallmentPlans.None : num 1 1 1 1 1 1 1 1 1 1 ...
## $ Housing.Rent : num 0 0 0 0 0 0 0 1 0 0 ...
## $ Housing.Own : num 1 1 1 0 0 0 1 0 1 1 ...
## $ Housing.ForFree : num 0 0 0 1 1 1 0 0 0 0 ...
## $ Job.UnemployedUnskilled : num 0 0 0 0 0 0 0 0 0 0 ...
## $ Job.UnskilledResident : num 0 0 1 0 0 1 0 0 1 0 ...
## $ Job.SkilledEmployee : num 1 1 0 1 1 0 1 0 0 0 ...
## $ Job.Management.SelfEmp.HighlyQualified: num 0 0 0 0 0 0 0 1 0 1 ...
Your observation: The GermanCredit dataset consists mostly of 0/1 dummy variables derived from categorical features, with only a few genuinely continuous columns (such as Duration, Amount, Age, and InstallmentRatePercentage) alongside the target Class column.
#This is an optional code that drop variables that provide no information in the data
GermanCredit = GermanCredit[,-c(14,19,27,30,35,40,44,45,48,52,55,58,62)] #don't run this code twice!! Think about why.
(Running it twice would drop a second, different set of columns because the column positions shift after the first drop. The dropped columns are the constant/redundant dummies.)
dim(GermanCredit)
## [1] 1000 49
sum(is.na(GermanCredit)) # missing values
## [1] 0
table(GermanCredit$Class) # class balance
##
## 0 1
## 300 700
round(prop.table(table(GermanCredit$Class)), 3)
##
## 0 1
## 0.3 0.7
# continuous variables
summary(GermanCredit[, c("Duration", "Amount", "Age",
"InstallmentRatePercentage", "ResidenceDuration",
"NumberExistingCredits", "NumberPeopleMaintenance")])
## Duration Amount Age InstallmentRatePercentage
## Min. : 4.0 Min. : 250 Min. :19.00 Min. :1.000
## 1st Qu.:12.0 1st Qu.: 1366 1st Qu.:27.00 1st Qu.:2.000
## Median :18.0 Median : 2320 Median :33.00 Median :3.000
## Mean :20.9 Mean : 3271 Mean :35.55 Mean :2.973
## 3rd Qu.:24.0 3rd Qu.: 3972 3rd Qu.:42.00 3rd Qu.:4.000
## Max. :72.0 Max. :18424 Max. :75.00 Max. :4.000
## ResidenceDuration NumberExistingCredits NumberPeopleMaintenance
## Min. :1.000 Min. :1.000 Min. :1.000
## 1st Qu.:2.000 1st Qu.:1.000 1st Qu.:1.000
## Median :3.000 Median :1.000 Median :1.000
## Mean :2.845 Mean :1.407 Mean :1.155
## 3rd Qu.:4.000 3rd Qu.:2.000 3rd Qu.:1.000
## Max. :4.000 Max. :4.000 Max. :2.000
# which columns are 0/1 dummies?
is_dummy <- sapply(GermanCredit[, names(GermanCredit) != "Class"],
function(x) all(x %in% c(0, 1)))
table(is_dummy)
## is_dummy
## FALSE TRUE
## 7 41
# a quick visual of the main numeric predictors by class
par(mfrow = c(1, 3))
boxplot(Duration ~ Class, data = GermanCredit, main = "Duration by Class")
boxplot(Amount ~ Class, data = GermanCredit, main = "Amount by Class")
boxplot(Age ~ Class, data = GermanCredit, main = "Age by Class")
par(mfrow = c(1, 1))
Your observation: There are no missing values in the data. The response variable is a little unbalanced, with about r round(100*mean(GermanCredit\(Class),1)% of customers being Good (1) and r round(100*(1-mean(GermanCredit\)Class)),1)% being Bad (0). r sum(is_dummy) of the predictors are dummy variables (0/1), and the rest are regular numeric variables. The Amount variable is skewed to the right. Customers labeled Bad usually have longer loan durations and slightly higher loan amounts.
Since the dummy variables represent categories, I change them into factors. The other numeric variables stay the same. I keep Class as 0/1 because the logistic regression model uses it to predict the chance of a customer being Good (1).
dummy_names <- names(is_dummy)[is_dummy]
GermanCredit[dummy_names] <- lapply(GermanCredit[dummy_names], factor)
str(GermanCredit[, 1:10])
## 'data.frame': 1000 obs. of 10 variables:
## $ Duration : int 6 48 12 42 24 36 24 36 12 30 ...
## $ Amount : int 1169 5951 2096 7882 4870 9055 2835 6948 3059 5234 ...
## $ InstallmentRatePercentage: int 4 2 2 2 3 2 3 2 2 4 ...
## $ ResidenceDuration : int 4 2 3 4 4 4 4 2 4 2 ...
## $ Age : int 67 22 49 45 53 35 53 35 61 28 ...
## $ NumberExistingCredits : int 2 1 1 1 2 1 1 1 1 2 ...
## $ NumberPeopleMaintenance : int 1 1 2 2 2 2 1 1 1 1 ...
## $ Telephone : Factor w/ 2 levels "0","1": 1 2 2 2 2 1 2 1 2 2 ...
## $ ForeignWorker : Factor w/ 2 levels "0","1": 2 2 2 2 2 2 2 2 2 2 ...
## $ Class : num 1 0 1 1 0 1 1 1 1 0 ...
2024 for
reproducibility.set.seed(2024)
n <- nrow(GermanCredit)
train_idx <- sample(seq_len(n), size = round(0.7 * n))
train <- GermanCredit[train_idx, ]
test <- GermanCredit[-train_idx, ]
dim(train); dim(test)
## [1] 700 49
## [1] 300 49
round(prop.table(table(train$Class)), 3)
##
## 0 1
## 0.279 0.721
round(prop.table(table(test$Class)), 3)
##
## 0 1
## 0.35 0.65
Your observation: I used a random 70/30 split. The training set has 700 observations and the test set has 300. The proportion of Good customers is similar in both (0.721 in training vs. 0.65 in test), so the split is reasonably representative.
full_model <- glm(Class ~ ., data = train, family = binomial)
Your observation: The model uses all the predictors. The 0/1 variables are treated as factors, while the other variables stay numeric. Some coefficients might show up as NA if variables overlap too much or basically give the same information. R handles this by automatically removing those variables from the model.
summary(full_model)
##
## Call:
## glm(formula = Class ~ ., family = binomial, data = train)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 9.369e+00 1.829e+00 5.122 3.02e-07 ***
## Duration -2.500e-02 1.159e-02 -2.158 0.030928 *
## Amount -1.866e-04 5.568e-05 -3.352 0.000802 ***
## InstallmentRatePercentage -4.458e-01 1.136e-01 -3.923 8.75e-05 ***
## ResidenceDuration 6.363e-03 1.103e-01 0.058 0.953999
## Age 1.953e-02 1.216e-02 1.606 0.108182
## NumberExistingCredits -1.261e-01 2.450e-01 -0.515 0.606850
## NumberPeopleMaintenance -1.950e-01 3.260e-01 -0.598 0.549618
## Telephone1 -3.987e-01 2.603e-01 -1.532 0.125566
## ForeignWorker1 -1.476e+00 7.829e-01 -1.885 0.059450 .
## CheckingAccountStatus.lt.01 -1.813e+00 2.948e-01 -6.151 7.69e-10 ***
## CheckingAccountStatus.0.to.2001 -1.419e+00 2.957e-01 -4.800 1.59e-06 ***
## CheckingAccountStatus.gt.2001 -2.207e-01 5.404e-01 -0.408 0.682962
## CreditHistory.NoCredit.AllPaid1 -1.020e+00 5.507e-01 -1.851 0.064107 .
## CreditHistory.ThisBank.AllPaid1 -1.717e+00 5.911e-01 -2.905 0.003677 **
## CreditHistory.PaidDuly1 -7.613e-01 3.259e-01 -2.336 0.019502 *
## CreditHistory.Delay1 -1.106e+00 4.157e-01 -2.660 0.007815 **
## Purpose.NewCar1 -1.907e+00 1.065e+00 -1.790 0.073450 .
## Purpose.UsedCar1 -2.447e-01 1.082e+00 -0.226 0.821017
## Purpose.Furniture.Equipment1 -9.006e-01 1.066e+00 -0.845 0.398330
## Purpose.Radio.Television1 -1.076e+00 1.070e+00 -1.006 0.314601
## Purpose.DomesticAppliance1 -1.640e+00 1.390e+00 -1.180 0.238012
## Purpose.Repairs1 -1.620e+00 1.211e+00 -1.338 0.180844
## Purpose.Education1 -2.242e+00 1.161e+00 -1.932 0.053400 .
## Purpose.Retraining1 -6.540e-01 1.664e+00 -0.393 0.694309
## Purpose.Business1 -7.351e-01 1.094e+00 -0.672 0.501698
## SavingsAccountBonds.lt.1001 -1.341e+00 3.467e-01 -3.868 0.000110 ***
## SavingsAccountBonds.100.to.5001 -1.179e+00 4.488e-01 -2.627 0.008615 **
## SavingsAccountBonds.500.to.10001 -1.064e+00 5.682e-01 -1.873 0.061102 .
## SavingsAccountBonds.gt.10001 -2.395e-01 6.545e-01 -0.366 0.714428
## EmploymentDuration.lt.11 3.040e-01 5.495e-01 0.553 0.580133
## EmploymentDuration.1.to.41 4.008e-01 5.289e-01 0.758 0.448574
## EmploymentDuration.4.to.71 8.251e-01 5.670e-01 1.455 0.145643
## EmploymentDuration.gt.71 3.905e-01 5.359e-01 0.729 0.466142
## Personal.Male.Divorced.Seperated1 -1.529e-01 5.695e-01 -0.268 0.788356
## Personal.Female.NotSingle1 -9.258e-02 3.755e-01 -0.247 0.805267
## Personal.Male.Single1 3.861e-01 3.809e-01 1.014 0.310692
## OtherDebtorsGuarantors.None1 -1.039e+00 5.761e-01 -1.803 0.071362 .
## OtherDebtorsGuarantors.CoApplicant1 -1.635e+00 7.330e-01 -2.230 0.025730 *
## Property.RealEstate1 7.315e-01 5.452e-01 1.342 0.179726
## Property.Insurance1 2.724e-01 5.293e-01 0.515 0.606870
## Property.CarOther1 5.846e-01 5.159e-01 1.133 0.257157
## OtherInstallmentPlans.Bank1 -1.058e+00 2.980e-01 -3.551 0.000383 ***
## OtherInstallmentPlans.Stores1 -6.249e-01 5.052e-01 -1.237 0.216067
## Housing.Rent1 -1.006e+00 6.242e-01 -1.612 0.106903
## Housing.Own1 -2.691e-01 6.022e-01 -0.447 0.654919
## Job.UnemployedUnskilled1 1.787e+00 1.032e+00 1.732 0.083320 .
## Job.UnskilledResident1 1.094e-01 4.442e-01 0.246 0.805560
## Job.SkilledEmployee1 5.852e-02 3.652e-01 0.160 0.872710
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 828.24 on 699 degrees of freedom
## Residual deviance: 576.87 on 651 degrees of freedom
## AIC: 674.87
##
## Number of Fisher Scoring iterations: 5
# odds ratios for the numeric predictors
b <- coef(full_model)
round(exp(b[c("Duration", "Amount", "Age", "InstallmentRatePercentage")]), 4)
## Duration Amount Age
## 0.9753 0.9998 1.0197
## InstallmentRatePercentage
## 0.6403
# the most significant coefficients
coefs <- summary(full_model)$coefficients
head(coefs[order(coefs[, 4]), ], 8)
## Estimate Std. Error z value
## CheckingAccountStatus.lt.01 -1.8130627964 2.947520e-01 -6.151146
## (Intercept) 9.3685655723 1.829107e+00 5.121934
## CheckingAccountStatus.0.to.2001 -1.4192679315 2.956710e-01 -4.800159
## InstallmentRatePercentage -0.4457622977 1.136343e-01 -3.922781
## SavingsAccountBonds.lt.1001 -1.3411859189 3.467167e-01 -3.868248
## OtherInstallmentPlans.Bank1 -1.0581408665 2.979670e-01 -3.551201
## Amount -0.0001866305 5.567637e-05 -3.352059
## CreditHistory.ThisBank.AllPaid1 -1.7167869535 5.910522e-01 -2.904628
## Pr(>|z|)
## CheckingAccountStatus.lt.01 7.692487e-10
## (Intercept) 3.024174e-07
## CheckingAccountStatus.0.to.2001 1.585399e-06
## InstallmentRatePercentage 8.753273e-05
## SavingsAccountBonds.lt.1001 1.096203e-04
## OtherInstallmentPlans.Bank1 3.834775e-04
## Amount 8.021301e-04
## CreditHistory.ThisBank.AllPaid1 3.676893e-03
Your observation: The Duration coefficient shows that longer loans are more likely to be linked to Bad customers. This makes sense because the bank is taking a risk for a longer time. The InstallmentRatePercentage also shows that higher installment rates lower the chances of being a Good customer. The table shows that checking account status and credit history are some of the most important predictors. Some other variables are not very important and may not be needed in the model.
train_prob <- predict(full_model, newdata = train, type = "response")
summary(train_prob)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.02777 0.53594 0.81218 0.72143 0.94126 0.99935
hist(train_prob, breaks = 30, main = "Predicted P(Good), training set", xlab = "Probability")
Your observation: The predicted probabilities (of being Good) range from 0.028 to 0.999 with a mean of 0.721. Most are above 0.5, reflecting the 70% Good base rate.
A prediction is “Good” (1) when the predicted probability exceeds the cut-off. I define the cost with explicit weights:
w_FN: cost of predicting Bad when the customer is
actually Good (actual 1, predicted 0)w_FP: cost of predicting Good when the customer is
actually Bad (actual 0, predicted 1)cost_fn <- function(actual, prob, cutoff, w_FN = 1, w_FP = 1) {
pred <- as.numeric(prob > cutoff)
FN <- sum(actual == 1 & pred == 0)
FP <- sum(actual == 0 & pred == 1)
(w_FN * FN + w_FP * FP) / length(actual) # average cost
}
cutoffs <- seq(0.01, 0.99, by = 0.01)
cost_equal <- sapply(cutoffs, function(c) cost_fn(train$Class, train_prob, c, 1, 1))
best_cut_equal <- cutoffs[which.min(cost_equal)]
best_cut_equal
## [1] 0.49
min(cost_equal)
## [1] 0.19
plot(cutoffs, cost_equal, type = "l", xlab = "Cut-off", ylab = "Misclassification rate",
main = "Equal weights (FN = 1, FP = 1)")
abline(v = best_cut_equal, col = "red", lty = 2)
Your observation: With equal weights, the cost equals the misclassification rate, and it is minimized at a cut-off of 0.49 (training MR = 0.19). The curve is fairly flat near the minimum, so cut-offs close to this value perform almost equally well.
train_pred <- as.numeric(train_prob > best_cut_equal)
cm_train <- table(Actual = train$Class, Predicted = train_pred)
cm_train
## Predicted
## Actual 0 1
## 0 104 91
## 1 42 463
MR_train <- mean(train_pred != train$Class)
MR_train
## [1] 0.19
Your observation: The training MR is 0.19. The confusion matrix shows how many Good and Bad customers are correctly and incorrectly classified.
library(pROC)
## Type 'citation("pROC")' for a citation.
##
## Attaching package: 'pROC'
## The following objects are masked from 'package:stats':
##
## cov, smooth, var
roc_train <- roc(train$Class, train_prob, quiet = TRUE)
auc_train <- auc(roc_train)
plot(roc_train, main = "ROC curve, training set", print.auc = TRUE)
# mark the chosen cut-off
points(1 - mean(train_pred[train$Class == 0] == 0),
mean(train_pred[train$Class == 1] == 1), col = "red", pch = 19, cex = 1.3)
auc_train
## Area under the curve: 0.855
Your observation: The training AUC is 0.855. The red point marks the operating point (sensitivity vs. 1 - specificity) at the chosen cut-off. The ROC curve itself does not depend on the cut-off; the cut-off only picks a point on it.
test_prob <- predict(full_model, newdata = test, type = "response")
test_pred <- as.numeric(test_prob > best_cut_equal)
cm_test <- table(Actual = test$Class, Predicted = test_pred)
cm_test
## Predicted
## Actual 0 1
## 0 48 57
## 1 20 175
MR_test <- mean(test_pred != test$Class)
MR_test
## [1] 0.2566667
Your observation: The test MR is r round(MR_test, 4), compared to r round(MR_train, 4) for the training set. The test error is higher, which means the model may be overfitting a little. This could be because the model uses a lot of predictors.
roc_test <- roc(test$Class, test_prob, quiet = TRUE)
auc_test <- auc(roc_test)
plot(roc_test, main = "ROC curve, test set", print.auc = TRUE)
points(1 - mean(test_pred[test$Class == 0] == 0),
mean(test_pred[test$Class == 1] == 1), col = "red", pch = 19, cex = 1.3)
auc_test
## Area under the curve: 0.7545
Your observation: The test AUC is r round(as.numeric(auc_test), 4), compared to r round(as.numeric(auc_train), 4) for the training set. This shows how well the model works on new data. The model is still better than random guessing at telling Good customers from Bad ones (AUC = 0.5).
Now, let’s assume “It is worse to class a customer as good when they are bad (weight = 5), than it is to class a customer as bad when they are good (weight = 1).” Please figure out which weight should be 5 and which weight should be 1. Then define your cost function accordingly!
Classifying a customer as Good when they are actually Bad is a false positive here (positive = Good), so weight_FP = 5. Classifying a Good customer as Bad is a false negative, so weight_FN = 1. Intuitively, lending to a customer who defaults is much more costly than turning away a good customer.
cost_w <- sapply(cutoffs, function(c) cost_fn(train$Class, train_prob, c, w_FN = 1, w_FP = 5))
best_cut_w <- cutoffs[which.min(cost_w)]
best_cut_w
## [1] 0.81
min(cost_w)
## [1] 0.3971429
plot(cutoffs, cost_w, type = "l", xlab = "Cut-off", ylab = "Average cost",
main = "Weights: FN = 1, FP = 5")
abline(v = best_cut_w, col = "red", lty = 2)
Your observation: With the new weights the optimal cut-off is 0.81, much higher than the 0.49 found with equal weights. This matches theory: with a cost ratio of 5:1, the cost-minimizing rule predicts Good only when P(Good) > 5/(5+1) = 0.833. The model now requires strong evidence before approving a customer.
train_pred_w <- as.numeric(train_prob > best_cut_w)
cm_train_w <- table(Actual = train$Class, Predicted = train_pred_w)
cm_train_w
## Predicted
## Actual 0 1
## 0 174 21
## 1 173 332
MR_train_w <- mean(train_pred_w != train$Class)
MR_train_w
## [1] 0.2771429
cost_fn(train$Class, train_prob, best_cut_w, 1, 5) # weighted cost
## [1] 0.3971429
Your observation: The training MR is r round(MR_train_w, 4), which is higher than the r round(MR_train, 4) with equal weights. This makes sense because the model is now focusing more on catching Bad customers. It makes fewer mistakes with Bad customers but also incorrectly labels more Good customers as Bad.
test_pred_w <- as.numeric(test_prob > best_cut_w)
cm_test_w <- table(Actual = test$Class, Predicted = test_pred_w)
cm_test_w
## Predicted
## Actual 0 1
## 0 77 28
## 1 74 121
MR_test_w <- mean(test_pred_w != test$Class)
MR_test_w
## [1] 0.34
cost_fn(test$Class, test_prob, best_cut_w, 1, 5) # weighted cost on test
## [1] 0.7133333
cost_fn(test$Class, test_prob, best_cut_equal, 1, 5) # weighted cost if we had kept the old cut-off
## [1] 1.016667
Your observation: The test MR is r round(MR_test_w, 4). Even though the overall error is higher, the new cut-off does a better job of lowering the cost of mistakes. With the 5:1 cost setup, the new cut-off is cheaper than the old one.
Summarize your findings, including the optimal probability cut-off, MR and AUC for both training and testing data. Discuss what you observed and what you will do to improve the model further.
summary_tbl <- data.frame(
Setting = c("Equal weights (1:1)", "Equal weights (1:1)", "Weights FN=1, FP=5", "Weights FN=1, FP=5"),
Data = c("Training", "Test", "Training", "Test"),
Cutoff = c(best_cut_equal, best_cut_equal, best_cut_w, best_cut_w),
MR = round(c(MR_train, MR_test, MR_train_w, MR_test_w), 4),
AUC = round(c(as.numeric(auc_train), as.numeric(auc_test),
as.numeric(auc_train), as.numeric(auc_test)), 4)
)
summary_tbl
## Setting Data Cutoff MR AUC
## 1 Equal weights (1:1) Training 0.49 0.1900 0.8550
## 2 Equal weights (1:1) Test 0.49 0.2567 0.7545
## 3 Weights FN=1, FP=5 Training 0.81 0.2771 0.8550
## 4 Weights FN=1, FP=5 Test 0.81 0.3400 0.7545
Findings. I used logistic regression with all predictors and split the data into 70% training and 30% testing. With equal weights, the cut-off was r best_cut_equal, with training MR of r round(MR_train, 4) and test MR of r round(MR_test, 4). When Bad loans were made five times more costly, the cut-off increased to r best_cut_w, and the error rate went up. This is because the model became more careful about labeling customers as Good.
Observations. The model did a little worse on the test data, which could mean some overfitting. Checking account status, credit history, loan duration, and installment rate were the biggest factors. The best cut-off depends on how costly bad loans are.
Improvements. (1) Remove variables that are not very useful. (2) est the model with different data splits. (3) Try other models like decision trees or random forests. (4) Find better ways to handle the unbalanced data. (5) Use the bank’s actual costs instead of assuming a 5:1 cost ratio.