1 Overview

Following our previous practical session, we noticed discrepancies between train and test performance (overfitting). In this session, we will use k-fold Cross-Validation (CV) to get an averaged, more realistic performance metric over the folds on the training data. We will also expand our toolkit to include Decision Trees, Random Forests, and XGBoost.

2 Install packages (if you haven’t done so)

install.packages(c("tidymodels", "glmnet", "rpart", "ranger", "xgboost", "mlbench", "knitr", "DT"))
install.packages("remotes")
# For Windows, install RTools from https://cran.r-project.org/bin/windows/Rtools/
install.packages("parameters") # for building vignettes
remotes::install_github("wnarifin/MJMSTable", build_vignettes = TRUE) # for easy descriptive statistics

3 Load Packages

library(tidymodels)
library(glmnet)
library(rpart)
library(ranger)
library(xgboost)
library(mlbench)

4 Load and Prepare the Data

We load the dataset and prepare it by releveling the outcome variable and removing missing values.

data("PimaIndiansDiabetes2")
pima <- PimaIndiansDiabetes2

Relevel so the positive class (“pos”) is the first level for yardstick metrics.

pima$diabetes <- relevel(pima$diabetes, ref = "pos")

Remove observations with NA (missing data). (Note: this reduces N from 768 to 392. In practice, imputation might be preferred).

pima <- na.omit(pima)

5 Data Splitting & Resampling

We split the data into training (70%) and testing (30%) sets, stratified by the diabetes outcome to maintain class proportions.

set.seed(123)
pima_split <- initial_split(pima, prop = 0.70, strata = diabetes)
pima_train <- training(pima_split)

We create 10-fold cross-validation folds from the training data.

set.seed(234)
cv_folds <- vfold_cv(pima_train, v = 10, strata = diabetes)

Create a recipe

pima_rec <- recipe(diabetes ~ ., data = pima_train) |> 
  step_normalize(all_numeric_predictors()) 

Normalizing numeric predictors is crucial for engines like glmnet.

6 Model Specifications

In tidymodels, we define the “blueprint” of the model before passing any data. Common elements used across these blueprints include:

  • tune(): A placeholder indicating that the best hyperparameter value will be found later during cross-validation.
  • set_engine(): Specifies the underlying R package to do the math (e.g., glmnet, xgboost).
  • set_mode("classification"): Specifies we are predicting a categorical outcome (“pos” or “neg”).

Standard Logistic Regression (Baseline)

log_spec   <- logistic_reg() |> 
  set_engine("glm") |> 
  set_mode("classification")

Penalized Logistic Regression (Elastic Net)

  • penalty: Controls the total amount of shrinkage (regularization) applied to prevent overfitting.
  • mixture: Balances between Ridge regression (shrinks all coefficients) and Lasso regression (forces some coefficients to exactly 0).
penal_spec <- logistic_reg(penalty = tune(), mixture = tune()) |> 
  set_engine("glmnet") |> 
  set_mode("classification")

Decision Tree

  • cost_complexity: The pruning parameter. Penalizes the tree for getting too complicated, preventing it from fitting to noise.
  • tree_depth: Limits how many levels deep the single tree is allowed to grow.
dt_spec    <- decision_tree(cost_complexity = tune(), tree_depth = tune()) |> 
  set_engine("rpart") |> 
  set_mode("classification")

Random Forest

  • mtry: The number of random predictors (columns) the model is allowed to consider at each split.
  • min_n: The minimum number of data points required in a node before it is allowed to split further.
  • trees = 500: Hardcodes the model to build exactly 500 trees in the ensemble (a solid default).
rf_spec    <- rand_forest(mtry = tune(), min_n = tune(), trees = 500) |> 
  set_engine("ranger") |> 
  set_mode("classification")

XGBoost (Gradient Boosted Trees)

  • tree_depth: Limits how deep each individual boosted tree can grow.
  • learn_rate: Shrinkage. Controls how much each sequential tree contributes to the final answer.
  • trees = 500: Builds 500 trees sequentially, where each new tree tries to fix the mistakes of the previous one.
xgb_spec   <- boost_tree(tree_depth = tune(), learn_rate = tune(), trees = 500) |> 
  set_engine("xgboost") |> 
  set_mode("classification")

7 Workflows

Next, we bundle the preprocessing recipe and model specifications into workflows.

log_wf   <- workflow() |> add_recipe(pima_rec) |> add_model(log_spec)
penal_wf <- workflow() |> add_recipe(pima_rec) |> add_model(penal_spec)
dt_wf    <- workflow() |> add_recipe(pima_rec) |> add_model(dt_spec)
rf_wf    <- workflow() |> add_recipe(pima_rec) |> add_model(rf_spec)
xgb_wf   <- workflow() |> add_recipe(pima_rec) |> add_model(xgb_spec)

8 Define Evaluation Metrics

We specify our desired yardstick metrics.

eval_metrics <- metric_set(accuracy, f_meas, roc_auc, brier_class)

9 Master Helper Function

To streamline tuning, CV evaluation, and test set evaluation across multiple models without repeating code, we define a helper function evaluate_model(). Updated: This function now returns the aggregated metrics, raw test predictions (for CIs), and the best tuned hyperparameters.

evaluate_model <- function(workflow, model_name, requires_tuning = TRUE, seed = 345) {
  set.seed(seed)
  
  # Step A: Tune and Finalize Workflow (if required)
  if (requires_tuning) {
    tune_res <- tune_grid(
      workflow,
      resamples = cv_folds,
      grid = 20,                      
      metrics = metric_set(roc_auc)   
    )
    best_params <- select_best(tune_res, metric = "roc_auc")
    final_wf <- finalize_workflow(workflow, best_params)
  } else {
    final_wf <- workflow
    best_params <- NULL
  }
  
  # Step B: Calculate CV Performance (on Training Data)
  set.seed(seed)
  cv_resamples <- fit_resamples(
    final_wf,
    resamples = cv_folds,
    metrics = eval_metrics            
  )
  
  cv_metrics <- collect_metrics(cv_resamples) |> 
    dplyr::mutate(model = paste0(model_name, " (CV Train)")) |> 
    dplyr::select(model, .metric, mean) |> 
    dplyr::rename(.estimate = mean)
  
  # Step C: Calculate Final Test Set Performance & Save Predictions
  set.seed(seed)
  test_fit <- last_fit(
    final_wf,
    split = pima_split,
    metrics = eval_metrics            
  )
  
  test_metrics <- collect_metrics(test_fit) |> 
    dplyr::mutate(model = paste0(model_name, " (Test)")) |> 
    dplyr::select(model, .metric, .estimate)
    
  test_preds <- collect_predictions(test_fit) |>
    dplyr::mutate(model = model_name)
  
  # Step D: Combine metrics and return as a list
  combined_metrics <- dplyr::bind_rows(cv_metrics, test_metrics)
  
  return(list(
    metrics = combined_metrics,
    test_preds = test_preds,
    best_params = best_params
  ))
}

10 Run Evaluations

We now run our evaluation pipeline on all models.

log_results   <- evaluate_model(log_wf, "Standard Logistic", requires_tuning = FALSE)
penal_results <- evaluate_model(penal_wf, "Penalized Logistic")
dt_results    <- evaluate_model(dt_wf, "Decision Tree")
rf_results    <- evaluate_model(rf_wf, "Random Forest")
#> i Creating pre-processing data to finalize 1 unknown parameter: "mtry"
xgb_results   <- evaluate_model(xgb_wf, "XGBoost")

11 Compare Results

Finally, we bring all the results together to compare the CV metrics against the completely unseen Test Set metrics to assess generalization.

final_comparison <- dplyr::bind_rows(
  log_results$metrics, 
  penal_results$metrics, 
  dt_results$metrics, 
  rf_results$metrics, 
  xgb_results$metrics
) |> 
  # Pivot to match ml-classification1.R format
  tidyr::pivot_wider(names_from = .metric, values_from = .estimate) |>
  # Reorder columns explicitly to match the first practical
  dplyr::select(model, accuracy, f_meas, roc_auc, brier_class)

# Print the final output as an interactive table
DT::datatable(
  final_comparison,
  colnames = c("Model", "Accuracy", "F1 Score", "AUC", "Brier Score"),
  rownames = FALSE,
  options = list(
    pageLength = 10,       # Shows 10 rows (perfect for Train & Test for 5 models)
    autoWidth = TRUE,     
    dom = 't',            # Hides the search bar and pagination for a cleaner look
    columnDefs = list(
      list(className = 'dt-center', targets = 1:4) # Centers the metric columns
    )
  )
) |> 
  DT::formatRound(columns = 2:5, digits = 3) # Rounds the 4 metric columns to 3 decimal places

12 TRIPOD+AI Reporting

12.1 Data Preparation

  • Missing Data: Observations with missing values were removed prior to analysis (complete case analysis). This reduced the dataset from 768 to 392 patients.
  • Data Splitting: The dataset was randomly partitioned into a training set (70%) and a hold-out test set (30%). This split was stratified by the outcome variable to preserve class proportions across both sets.
  • Preprocessing: All numeric predictors were normalized (centered and scaled) based on the training data parameters. This ensures equal feature contribution, which is critically required for regularized algorithms like Elastic Net.
  • Outcome Refactoring: The outcome variable was releveled to ensure the positive clinical event (“pos”) is explicitly treated as the reference level for yardstick metric calculations.

12.2 Outcome and Predictors

  • Outcome Variable: diabetes, a binary classification target indicating the presence (“pos”) or absence (“neg”) of diabetes.
  • Predictors: All remaining 8 clinical and demographic variables in the dataset (pregnant, glucose, pressure, triceps, insulin, mass, pedigree, and age). Descriptive statistics
MJMSTable::descriptive_tbl(
  pima,
  group_var = "diabetes",
  included_var = names(pima[,-9]) # diabetes is the 9th variable in the dataset
)
Table : Patient Demographics (n = 392)
Variables pos ( n = 130) neg ( n = 262) Total n (%)
pregnant, Mean (SD)a 4.5 (3.9) 2.7 (2.6) 3.3 (3.2)
glucose, Mean (SD)a 145.2 (29.8) 111.4 (24.6) 122.6 (30.9)
pressure, Mean (SD)a 74.1 (13.0) 69.0 (11.9) 70.7 (12.5)
triceps, Mean (SD)a 33.0 (9.6) 27.3 (10.4) 29.1 (10.5)
insulin, Mean (SD)a 206.8 (132.7) 130.9 (102.6) 156.1 (118.8)
mass, Mean (SD)a 35.8 (6.7) 31.8 (6.8) 33.1 (7.0)
pedigree, Mean (SD)a 0.6 (0.4) 0.5 (0.3) 0.5 (0.3)
age, Mean (SD)a 35.9 (10.6) 28.3 (9.0) 30.9 (10.2)
a Mean (SD)

12.3 Analytical Methods

We evaluated five distinct machine learning algorithms. Where applicable, hyperparameter tuning was conducted using a grid search (testing 20 pseudo-random combinations) optimized for the Area Under the Receiver Operating Characteristic Curve (ROC AUC).

  • Resampling Strategy: 10-fold cross-validation applied strictly to the training set, stratified by the outcome variable.

Table: Algorithms and Hyperparameter Settings

hyper_df <- data.frame(
  Algorithm = c(
    "Standard Logistic Regression", 
    "Penalized Logistic (Elastic Net)", 
    "Decision Tree", 
    "Random Forest", 
    "XGBoost"
  ),
  Engine = c("glm", "glmnet", "rpart", "ranger", "xgboost"),
  `Tuned Hyperparameters (Used Values)` = c(
    "None (Baseline)",
    sprintf("penalty = %.4f, mixture = %.4f", penal_results$best_params$penalty, penal_results$best_params$mixture),
    sprintf("cost_complexity = %.4f, tree_depth = %d", dt_results$best_params$cost_complexity, dt_results$best_params$tree_depth),
    sprintf("mtry = %d, min_n = %d", rf_results$best_params$mtry, rf_results$best_params$min_n),
    sprintf("tree_depth = %d, learn_rate = %.4f", xgb_results$best_params$tree_depth, xgb_results$best_params$learn_rate)
  ),
  `Fixed Hyperparameters` = c("None", "None", "None", "trees = 500", "trees = 500"),
  check.names = FALSE
)
knitr::kable(hyper_df, align = "llll")
Algorithm Engine Tuned Hyperparameters (Used Values) Fixed Hyperparameters
Standard Logistic Regression glm None (Baseline) None
Penalized Logistic (Elastic Net) glmnet penalty = 0.0078, mixture = 0.1000 None
Decision Tree rpart cost_complexity = 0.0000, tree_depth = 3 None
Random Forest ranger mtry = 2, min_n = 18 trees = 500
XGBoost xgboost tree_depth = 1, learn_rate = 0.0280 trees = 500

12.4 Test Set Performance with Confidence Intervals

To fulfill the TRIPOD+AI reporting guidelines for model evaluation, we present the final unseen test set performance metrics alongside their 95% confidence intervals (CIs). We use bootstrapping (1,000 resamples) on the test set predictions to calculate these CIs robustly.

# 1. Define the specific TRIPOD+AI metric set (added precision and recall)
tripod_metrics <- metric_set(accuracy, precision, recall, f_meas, roc_auc, brier_class)

# 2. Combine the raw test set predictions extracted from our evaluation function
all_test_preds <- dplyr::bind_rows(
  log_results$test_preds,
  penal_results$test_preds,
  dt_results$test_preds,
  rf_results$test_preds,
  xgb_results$test_preds
)

# 3. Define a bootstrap helper function for CIs
calc_tripod_ci <- function(preds_data, seed = 123) {
  set.seed(seed)
  
  # Create 1,000 bootstrap resamples of the test set predictions
  boots <- rsample::bootstraps(preds_data, times = 1000)
  
  # Calculate metrics for each resample
  boot_metrics <- boots |>
    dplyr::mutate(
      metrics = purrr::map(splits, function(split) {
        split_data <- rsample::analysis(split)
        # Suppress warnings for edge-case resamples that lack certain classes
        suppressWarnings(
          tripod_metrics(split_data, truth = diabetes, estimate = .pred_class, .pred_pos)
        )
      })
    ) |>
    tidyr::unnest(metrics)
  
  # Summarize the distribution to find the 2.5th and 97.5th percentiles (95% CI)
  boot_metrics |>
    dplyr::group_by(.metric) |>
    dplyr::summarize(
      estimate = mean(.estimate, na.rm = TRUE),
      lower_ci = quantile(.estimate, 0.025, na.rm = TRUE),
      upper_ci = quantile(.estimate, 0.975, na.rm = TRUE),
      .groups = "drop"
    )
}

# 4. Apply the function to each model's test predictions
tripod_results <- all_test_preds |>
  dplyr::group_by(model) |>
  tidyr::nest() |>
  dplyr::mutate(ci_data = purrr::map(data, calc_tripod_ci)) |>
  dplyr::select(model, ci_data) |>
  tidyr::unnest(ci_data)

# 5. Format and print the final table
tripod_table <- tripod_results |>
  dplyr::mutate(
    # Format exactly as Estimate (Lower CI, Upper CI)
    formatted = sprintf("%.3f (%.3f, %.3f)", estimate, lower_ci, upper_ci)
  ) |>
  dplyr::select(model, .metric, formatted) |>
  tidyr::pivot_wider(names_from = .metric, values_from = formatted) |>
  # Reorder columns logically
  dplyr::select(model, accuracy, precision, recall, f_meas, roc_auc, brier_class)

tripod_table |> 
  knitr::kable(
    col.names = c("Model", "Accuracy", "Precision", "Recall", "F1 Score", "AUC", "Brier Score"),
    align = "lcccccc"
  )
Model Accuracy Precision Recall F1 Score AUC Brier Score
Standard Logistic 0.728 (0.644, 0.814) 0.609 (0.423, 0.783) 0.487 (0.324, 0.639) 0.538 (0.381, 0.676) 0.820 (0.739, 0.894) 0.169 (0.125, 0.216)
Penalized Logistic 0.728 (0.644, 0.814) 0.609 (0.423, 0.783) 0.487 (0.324, 0.639) 0.538 (0.381, 0.676) 0.820 (0.741, 0.893) 0.169 (0.125, 0.214)
Decision Tree 0.796 (0.720, 0.864) 0.663 (0.513, 0.796) 0.773 (0.632, 0.900) 0.711 (0.590, 0.819) 0.814 (0.723, 0.894) 0.147 (0.104, 0.191)
Random Forest 0.754 (0.669, 0.831) 0.652 (0.481, 0.815) 0.538 (0.375, 0.700) 0.586 (0.440, 0.727) 0.821 (0.734, 0.899) 0.157 (0.120, 0.193)
XGBoost 0.780 (0.703, 0.856) 0.708 (0.531, 0.867) 0.565 (0.395, 0.725) 0.624 (0.476, 0.761) 0.815 (0.720, 0.897) 0.156 (0.116, 0.198)

13 Model Deployment: Making Predictions on New Data

Once we have thoroughly evaluated our models and selected the best candidate for deployment (for this demonstration, we will use the Decision Tree), we can use it to predict the diabetes status of new, unseen patients.

First, we finalize our Decision Tree workflow using the exact tuned hyperparameters we extracted during cross-validation. Then, we fit (train) this finalized model on our full training dataset.

# 1. Finalize the workflow with the best tuned parameters
final_dt_wf <- finalize_workflow(dt_wf, dt_results$best_params)

# 2. Fit the finalized model to the full training set
final_dt_model <- fit(final_dt_wf, data = pima_train)

Now, let’s imagine a new patient comes into the clinic. We measure their vitals and record their clinical metrics into a new data frame. (Note: The column names and data types must perfectly match the original training data).

# 3. Create a new observation (mock patient data)
new_patient <- tibble::tibble(
  pregnant = 2,
  glucose = 155,
  pressure = 72,
  triceps = 30,
  insulin = 125,
  mass = 33.5,
  pedigree = 0.55,
  age = 35
)

# Display the new patient's profile
knitr::kable(new_patient, caption = "New Patient Clinical Profile")
New Patient Clinical Profile
pregnant glucose pressure triceps insulin mass pedigree age
2 155 72 30 125 33.5 0.55 35

Finally, we pass this new patient data into our fitted model using the predict() function. We can ask the model for both the hard classification (Positive/Negative) and the underlying probability (how confident the model is in its decision).

# 4. Generate predictions
pred_class <- predict(final_dt_model, new_data = new_patient)
pred_probs <- predict(final_dt_model, new_data = new_patient, type = "prob")

# Combine the patient data with the predictions into a single, clean table
final_prediction <- dplyr::bind_cols(
  new_patient, 
  pred_class, 
  pred_probs
)

# Display the final prediction
knitr::kable(
  final_prediction, 
  caption = "Model Prediction Results",
  digits = 3
)
Model Prediction Results
pregnant glucose pressure triceps insulin mass pedigree age .pred_class .pred_pos .pred_neg
2 155 72 30 125 33.5 0.55 35 pos 0.603 0.397