13 minute read

Most tutorials stop at accuracy. But what if a false negative costs you 10x more than a false positive?


Introduction

Logistic regression is one of those algorithms everyone learns but few people use correctly in production. The model outputs probabilities, not predictions. To get actual predictions, you need a threshold: if the probability exceeds 0.5, predict positive. Otherwise, predict negative.

But why 0.5? That number assumes false positives and false negatives are equally costly. They rarely are.

In this tutorial, we build a logistic regression model from scratch in R, understand the ROC curve, and then do what most tutorials skip: pick a threshold that minimizes actual business cost.

What we’ll cover:

  • Building and evaluating a logistic regression model
  • Understanding ROC curves and AUC in depth
  • Finding the “optimal” threshold with Youden’s Index
  • Going beyond Youden: cost-based threshold optimization
  • When to use which approach

The Dataset

We’re using the German Credit dataset, a classic for credit risk modeling. Each row represents a loan applicant with features like credit history, loan duration, and employment status. The target tells us whether they defaulted (1) or not (0).

# Load the data
# Download from: https://archive.ics.uci.edu/dataset/144/statlog+german+credit+data
germancredit <- read.table("germancredit.txt", header = FALSE, sep = " ")
data <- germancredit

# Rename columns for clarity
# Suffix convention: _C = categorical, _I = integer, _B = binary
new_names <- c("existing_checking_C", "Duration_I", "Credit_hist_C", "Purpose_C",
               "Cred_Amt_I", "Savings_C", "Employed_Dur_C", "Rate_I", "Status_Sex_C",
               "Debt_C", "Residence_I", "Property_C", "Age_I", "Installment_Plans_C",
               "Housing_C", "Open_Credit_I", "Job_C", "Dependents_I", "Telephone_B",
               "International_Employee_B", "Target")
colnames(data) <- new_names

# Clean up
data <- unique(data)  # Remove duplicates

# Fix target: originally 1=good, 2=bad. Convert to 0/1
data$Target <- data$Target - 1

Preprocessing

Before modeling, we need to handle categorical variables. The key is one-hot encoding with the dummy variable trap in mind: drop one category per feature to avoid multicollinearity.

library(fastDummies)
library(caret)

# Identify categorical columns
cat_cols <- grep("_C$", colnames(data), value = TRUE)

# One-hot encode, dropping first dummy to prevent collinearity
data_encoded <- dummy_cols(data,
                           select_columns = cat_cols,
                           remove_first_dummy = TRUE,
                           remove_selected_columns = TRUE)

# Fix binary columns (stored as characters)
data_encoded$Telephone_B <- ifelse(data_encoded$Telephone_B == "A192", 1, 0)
data_encoded$International_Employee_B <- ifelse(data_encoded$International_Employee_B == "A201", 1, 0)
Why drop one dummy variable?

If you have 3 categories (Red, Green, Blue), you only need 2 dummy columns. Why? Because if Red=0 and Green=0, you know it must be Blue.

Keeping all 3 creates perfect multicollinearity: the third column is always determined by the first two. This breaks the math behind regression.

ColorRedGreenBlue (redundant)
Red100
Green010
Blue001

With just Red and Green columns, Blue is implied when both are 0.


Train-Test Split

library(caTools)

set.seed(123)  # For reproducibility
splitIndex <- sample.split(data_encoded$Target, SplitRatio = 0.7)

train_data <- data_encoded[splitIndex, ]
validation_data <- data_encoded[!splitIndex, ]

Building the Model

# Fit logistic regression
# family = binomial tells R we're doing logistic (not linear) regression
logistic_model <- glm(Target ~ ., data = train_data, family = binomial)

# Get predicted probabilities on validation set
predicted_probs <- predict(logistic_model, newdata = validation_data, type = "response")

The model outputs probabilities between 0 and 1. To make actual predictions, we need a threshold. The default is 0.5, but is that optimal?


Understanding the ROC Curve

The ROC (Receiver Operating Characteristic) curve is one of the most important tools for evaluating binary classifiers. It originated in World War II for analyzing radar signals (distinguishing enemy aircraft from noise), but it’s now standard in machine learning.

What the ROC Curve Shows

The ROC curve plots True Positive Rate (TPR) against False Positive Rate (FPR) at every possible threshold from 0 to 1.

True Positive Rate (Sensitivity/Recall):

\[TPR = \frac{TP}{TP + FN} = \frac{\text{Correctly identified positives}}{\text{All actual positives}}\]

False Positive Rate (1 - Specificity):

\[FPR = \frac{FP}{FP + TN} = \frac{\text{Incorrectly flagged negatives}}{\text{All actual negatives}}\]

Each point on the curve represents a different threshold. As you lower the threshold:

  • You catch more true positives (TPR increases)
  • But you also get more false positives (FPR increases)

The curve captures this tradeoff across all possible thresholds.

library(pROC)

roc_obj <- roc(validation_data$Target, predicted_probs)
plot(roc_obj, main = "ROC Curve", col = "#1c61b6", lwd = 2)
abline(a = 0, b = 1, lty = 2, col = "gray")  # Random classifier baseline

ROC Curve The ROC curve for our credit risk model. The diagonal represents random guessing (AUC = 0.5). Our curve shows the model performs better than random.

How to Read the ROC Curve

The Axes:

  • X-axis (False Positive Rate): The proportion of good customers incorrectly flagged as bad. Lower is better.
  • Y-axis (True Positive Rate): The proportion of bad customers correctly identified. Higher is better.

Key Points on the Curve:

  • Bottom-left corner (0,0): Threshold = 1.0. Predict everyone as negative. TPR = 0%, FPR = 0%.
  • Top-right corner (1,1): Threshold = 0.0. Predict everyone as positive. TPR = 100%, FPR = 100%.
  • Top-left corner (0,1): The perfect classifier. TPR = 100%, FPR = 0%.

The Diagonal Line: A random classifier (coin flip) produces the diagonal. Any model worth using should have a curve above this line.

See it with a tiny example

Imagine 4 predictions with these probabilities and true labels:

SampleProbabilityTrue Label
A0.91 (bad)
B0.71 (bad)
C0.40 (good)
D0.20 (good)

At threshold = 0.5:

  • Predict 1 for A, B (both correct = 2 True Positives)
  • Predict 0 for C, D (both correct = 2 True Negatives)
  • TPR = 2/2 = 100%, FPR = 0/2 = 0%
  • This is the point (0, 1) on the ROC curve. Perfect!

At threshold = 0.8:

  • Only A gets predicted as 1
  • B becomes a False Negative (we missed it)
  • TPR = 1/2 = 50%, FPR = 0/2 = 0%
  • This is the point (0, 0.5) on the ROC curve

At threshold = 0.3:

  • A, B, C all get predicted as 1
  • C becomes a False Positive
  • TPR = 2/2 = 100%, FPR = 1/2 = 50%
  • This is the point (0.5, 1) on the ROC curve

The ROC curve connects all these points as you sweep the threshold from 1 to 0.

Understanding AUC (Area Under the Curve)

The AUC summarizes the ROC curve in a single number between 0 and 1.

Interpretation:

\[AUC = P(\text{random positive ranked higher than random negative})\]

If you pick a random positive sample and a random negative sample, the AUC is the probability that your model assigns a higher score to the positive one.

AUC Value Interpretation
0.5 Random guessing (useless model)
0.6-0.7 Poor discrimination
0.7-0.8 Acceptable discrimination
0.8-0.9 Excellent discrimination
0.9-1.0 Outstanding discrimination

Why AUC is Useful:

  • It’s threshold-independent: You can compare models without choosing a threshold
  • It’s scale-independent: Works the same whether probabilities range from 0.1-0.2 or 0.4-0.9
  • It’s interpretable: The probabilistic interpretation makes sense to stakeholders

Limitations of AUC:

  • It treats all thresholds equally, even ones you’d never use in practice
  • It doesn’t account for class imbalance well
  • It doesn’t reflect business costs
Calculating AUC manually

AUC can be computed using the trapezoidal rule:

  1. Sort all predictions by threshold
  2. At each threshold, compute (FPR, TPR)
  3. Sum the trapezoid areas between consecutive points

Example with 4 samples:

Sorted by probability: A(0.9, y=1), B(0.7, y=1), C(0.4, y=0), D(0.2, y=0)

As we lower the threshold:

ThresholdTPRFPR
1.00/2 = 00/2 = 0
0.91/2 = 0.50/2 = 0
0.72/2 = 1.00/2 = 0
0.42/2 = 1.01/2 = 0.5
0.22/2 = 1.02/2 = 1.0

The ROC curve goes: (0,0) → (0,0.5) → (0,1) → (0.5,1) → (1,1)

AUC = area under this curve = 1.0 (perfect separation in this example)


Finding the Optimal Threshold: Youden’s Index

If you want a balanced tradeoff between sensitivity and specificity, Youden’s Index helps. It finds the threshold that maximizes:

\[J = \text{Sensitivity} + \text{Specificity} - 1 = TPR - FPR\]

This is equivalent to finding the point on the ROC curve furthest from the diagonal.

# Extract all points on the ROC curve
full_coords <- coords(roc_obj, "all")
specificities <- full_coords$specificity
sensitivities <- full_coords$sensitivity
thresholds <- full_coords$threshold

# Calculate Youden's Index for each threshold
youden_indices <- sensitivities + specificities - 1

# Find the threshold with the highest Youden's Index
optimal_index <- which.max(youden_indices)
optimal_threshold <- thresholds[optimal_index]

print(paste("Optimal threshold (Youden):", round(optimal_threshold, 3)))

Youden's Index The red point marks the optimal threshold according to Youden’s Index. This balances sensitivity and specificity equally.

Why Youden's Index works

Youden's J statistic measures the maximum vertical distance from the ROC curve to the diagonal line.

The intuition:

  • At any threshold, Sensitivity + Specificity ranges from 0 to 2
  • A random classifier has Sensitivity + Specificity = 1 (on the diagonal)
  • Youden's J = (Sensitivity + Specificity) - 1 measures how much better than random
  • The threshold that maximizes J gives the best balanced performance

Example calculation:

At threshold 0.4: Sensitivity = 0.85, Specificity = 0.70

  • J = 0.85 + 0.70 - 1 = 0.55

At threshold 0.5: Sensitivity = 0.75, Specificity = 0.80

  • J = 0.75 + 0.80 - 1 = 0.55

At threshold 0.6: Sensitivity = 0.60, Specificity = 0.85

  • J = 0.60 + 0.85 - 1 = 0.45

In this example, thresholds 0.4 and 0.5 are equally optimal by Youden's criterion.


The Confusion Matrix

Using our optimal threshold, we can build the confusion matrix:

predicted_classes <- ifelse(predicted_probs > optimal_threshold, 1, 0)
confusion_matrix <- table(Predicted = predicted_classes, Actual = validation_data$Target)
print(confusion_matrix)

Confusion Matrix The confusion matrix at the Youden-optimal threshold. TN = True Negative, FP = False Positive, FN = False Negative, TP = True Positive.

Reading the confusion matrix
Actual 0 (Good)Actual 1 (Bad)
Predicted 0True Negative (TN)False Negative (FN)
Predicted 1False Positive (FP)True Positive (TP)

From our model:

  • TN: Good customers correctly approved
  • FP: Good customers incorrectly denied (lost business)
  • FN: Bad customers incorrectly approved (defaults!)
  • TP: Bad customers correctly denied

Key metrics derived from the confusion matrix:

  • Accuracy = (TN + TP) / (TN + FP + FN + TP)
  • Precision = TP / (TP + FP)
  • Recall (Sensitivity) = TP / (TP + FN)
  • Specificity = TN / (TN + FP)
  • F1 Score = 2 × (Precision × Recall) / (Precision + Recall)

When Accuracy Isn’t Enough

Here’s where it gets real. Youden’s Index assumes false positives and false negatives are equally bad. They rarely are.

Healthcare example:

  • False Positive (healthy person diagnosed with disease): Unnecessary stress, tests, maybe invasive procedures
  • False Negative (sick person cleared as healthy): Disease progresses, potentially fatal

In healthcare, you’d rather have more false positives than miss a single cancer case. You want high recall, even at the cost of precision.

Fraud detection example:

  • False Positive (legitimate transaction blocked): Annoyed customer, maybe lost sale (5-50 dollars)
  • False Negative (fraud goes through): Direct financial loss, chargebacks, fees (500-5000 dollars)

The costs depend on your business. A 10-dollar false positive and a 500-dollar false negative require very different thresholds.

Threshold Comparison Left: Classification metrics at different thresholds. Right: Business cost varies dramatically based on threshold choice.


Building a Cost-Based Optimizer

Let’s find the threshold that minimizes total business cost, not just maximizes some abstract metric.

# Define your costs
# Example: FP costs 1 (deny good customer), FN costs 5 (approve bad customer who defaults)
FP_Cost <- 1
FN_Cost <- 5

# Test every threshold from 0.1 to 0.9
threshold_seq <- seq(0.1, 0.9, 0.01)

# Store results
costs_df <- data.frame(threshold = numeric(), cost = numeric(),
                       FP = numeric(), FN = numeric())

for (threshold in threshold_seq) {
  # Classify at this threshold
  predicted_classes <- ifelse(predicted_probs > threshold, 1, 0)

  # Build confusion matrix
  cm <- table(Predicted = predicted_classes, Actual = validation_data$Target)

  # Handle edge cases
  if (nrow(cm) != 2 || ncol(cm) != 2) next

  # Extract values
  FP <- cm[2, 1]  # Predicted 1, Actual 0
  FN <- cm[1, 2]  # Predicted 0, Actual 1

  # Calculate total cost
  total_cost <- FP * FP_Cost + FN * FN_Cost

  costs_df <- rbind(costs_df,
                    data.frame(threshold = threshold, cost = total_cost,
                               FP = FP, FN = FN))
}

# Find minimum cost threshold
optimal_idx <- which.min(costs_df$cost)
cost_optimal_threshold <- costs_df$threshold[optimal_idx]

print(paste("Cost-optimal threshold:", cost_optimal_threshold))
print(paste("Minimum cost:", costs_df$cost[optimal_idx]))

What happens if you change the costs? If FN_Cost is much higher than FP_Cost (like in healthcare), the optimal threshold shifts lower. The model becomes more aggressive about predicting positives to avoid missing any. If FP_Cost is higher (like in spam filtering where false positives annoy users), the threshold shifts higher.

Cost Curve Total cost as a function of threshold. The red line marks the cost-optimal threshold, which may differ from the Youden-optimal threshold.


How Cost Structure Changes the Optimal Threshold

Different business problems have different cost structures:

Cost Scenarios The optimal threshold shifts based on the cost structure. Fraud detection (high FN cost) pushes the threshold lower. Spam filtering (high FP cost) pushes it higher.

Scenario FP Cost FN Cost Optimal Threshold
Equal costs 1 1 ~0.50 (balanced)
Fraud detection 1 10 Lower (~0.30)
Spam filter 10 1 Higher (~0.70)
Medical screening 1 100 Very low (~0.10)

When Would You Use This?

Use Youden’s Index when:

  • You don’t have clear cost information
  • False positives and false negatives are roughly equally bad
  • You want a quick, defensible baseline

Use cost-based optimization when:

  • You have actual dollar costs for each error type
  • The costs are meaningfully different (at least 2-3x)
  • You’re deploying to production and care about business outcomes

Neither approach when:

  • Your model has terrible AUC (< 0.6). Fix the model first.
  • The costs change frequently. Build a system that recalculates thresholds.
  • You have massive class imbalance. Consider sampling techniques first.

Summary

  1. Logistic regression outputs probabilities, not classes. You need a threshold to convert them.

  2. The ROC curve shows all possible thresholds and their tradeoffs between true positive rate and false positive rate.

  3. AUC summarizes model performance in a single number: the probability that a random positive is ranked higher than a random negative.

  4. Youden’s Index finds a balanced threshold, but assumes equal costs for both types of errors.

  5. Cost-based optimization finds the threshold that minimizes actual business cost. This is what you should use in production.

  6. Always define your costs first. Talk to stakeholders. What does a false positive cost? A false negative? The math is easy once you know the numbers.


Appendix: Complete R Script

Full Implementation
# ============================================
# Logistic Regression with Cost-Based Thresholds
# ============================================

# Load packages
library(caTools)
library(fastDummies)
library(caret)
library(pROC)

# ----- Data Loading -----
germancredit <- read.table("germancredit.txt", header = FALSE, sep = " ")
data <- germancredit

new_names <- c("existing_checking_C", "Duration_I", "Credit_hist_C", "Purpose_C",
               "Cred_Amt_I", "Savings_C", "Employed_Dur_C", "Rate_I", "Status_Sex_C",
               "Debt_C", "Residence_I", "Property_C", "Age_I", "Installment_Plans_C",
               "Housing_C", "Open_Credit_I", "Job_C", "Dependents_I", "Telephone_B",
               "International_Employee_B", "Target")
colnames(data) <- new_names
data <- unique(data)
data$Target <- data$Target - 1

# ----- Preprocessing -----
cat_cols <- grep("_C$", colnames(data), value = TRUE)
data_encoded <- dummy_cols(data, select_columns = cat_cols,
                           remove_first_dummy = TRUE, remove_selected_columns = TRUE)
data_encoded$Telephone_B <- ifelse(data_encoded$Telephone_B == "A192", 1, 0)
data_encoded$International_Employee_B <- ifelse(data_encoded$International_Employee_B == "A201", 1, 0)

# ----- Train/Test Split -----
set.seed(123)
splitIndex <- sample.split(data_encoded$Target, SplitRatio = 0.7)
train_data <- data_encoded[splitIndex, ]
validation_data <- data_encoded[!splitIndex, ]

# ----- Model Training -----
logistic_model <- glm(Target ~ ., data = train_data, family = binomial)
predicted_probs <- predict(logistic_model, newdata = validation_data, type = "response")

# ----- ROC Curve -----
roc_obj <- roc(validation_data$Target, predicted_probs)
print(paste("AUC:", round(auc(roc_obj), 3)))

# ----- Youden's Index -----
full_coords <- coords(roc_obj, "all")
youden_indices <- full_coords$sensitivity + full_coords$specificity - 1
youden_threshold <- full_coords$threshold[which.max(youden_indices)]
print(paste("Youden optimal threshold:", round(youden_threshold, 3)))

# ----- Cost-Based Optimization -----
FP_Cost <- 1
FN_Cost <- 5

costs_df <- data.frame(threshold = numeric(), cost = numeric())

for (threshold in seq(0.1, 0.9, 0.01)) {
  predicted_classes <- ifelse(predicted_probs > threshold, 1, 0)
  cm <- table(Predicted = predicted_classes, Actual = validation_data$Target)

  if (nrow(cm) == 2 && ncol(cm) == 2) {
    FP <- cm[2, 1]
    FN <- cm[1, 2]
    cost <- FP * FP_Cost + FN * FN_Cost
    costs_df <- rbind(costs_df, data.frame(threshold = threshold, cost = cost))
  }
}

cost_optimal <- costs_df$threshold[which.min(costs_df$cost)]
print(paste("Cost-optimal threshold:", cost_optimal))

# ----- Final Evaluation -----
final_predictions <- ifelse(predicted_probs > cost_optimal, 1, 0)
final_cm <- table(Predicted = final_predictions, Actual = validation_data$Target)
print("Final Confusion Matrix:")
print(final_cm)