Put garments_worker_productivity.csv in
the same folder as this file. Install once, then knit.
install.packages(c(
"tidyverse", "caret", "randomForest", "xgboost",
"shapviz", "kernelshap", "e1071", "scales"
))needed <- c("tidyverse", "caret", "randomForest", "xgboost",
"shapviz", "kernelshap", "e1071", "scales")
for (pkg in needed) {
if (!requireNamespace(pkg, quietly = TRUE)) {
install.packages(pkg, repos = "https://cloud.r-project.org")
}
}
library(tidyverse)
library(caret)
library(randomForest)
library(xgboost)
library(shapviz)
library(kernelshap)
set.seed(42)data_path <- "garments_worker_productivity.csv"
if (!file.exists(data_path)) stop("Put garments_worker_productivity.csv next to this Rmd.")
raw <- read.csv(data_path, stringsAsFactors = FALSE)
# --- Cleaning -----------------------------------------------------------
# "department" has inconsistent spacing ("finishing" vs "finishing "); trim it.
# "wip" (work-in-progress) is NA for every finishing-department row because
# WIP is only tracked ahead of the sewing stage -- it's not missing data,
# it's structurally zero/not-applicable, so we impute 0 rather than drop
# ~42% of rows or the column entirely.
# "date" is dropped as a predictor: quarter + day already capture the
# calendar signal it carries, and the raw date is close to a row id.
garments <- raw %>%
mutate(
department = str_trim(department),
wip = ifelse(is.na(wip), 0, wip),
quarter = factor(quarter),
department = factor(department),
day = factor(day)
# team is left numeric (not factor-encoded): as a factor its 12 levels
# would add 11 dummy columns, which is the single biggest driver of
# kernelshap's runtime later (its cost scales with predictor count).
# Tree models can still split effectively on a numeric team id.
) %>%
select(-date)
target <- "actual_productivity"
cat("Rows:", nrow(garments), " columns:", ncol(garments), "\n")#> Rows: 1197 columns: 14
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 0.2337 0.6503 0.7733 0.7351 0.8503 1.1204
Outcome. actual_productivity is the
fraction of targeted output a team actually produced on a given day (can
exceed 1.0 when a team beats its target). This is a
continuous target, so these are regression trees, not
classifiers – CV and hold-out performance are judged on error (RMSE/MAE)
and variance explained (R-squared), not on accuracy or ROC-AUC.
ggplot(garments, aes(actual_productivity)) +
geom_histogram(bins = 30, fill = "#1f4e79") +
geom_vline(xintercept = 1, linetype = 2, colour = "grey40") +
labs(title = "Actual productivity (fraction of target)",
x = "Actual productivity", y = "Teams-days") +
theme_minimal(base_size = 12)80% of rows are used for 10-fold CV. The other 20%
is a hold-out scored only after tuning.
createDataPartition stratifies a numeric target by quantile
bins so both subsets span the same productivity range.
idx <- createDataPartition(garments[[target]], p = 0.80, list = FALSE)
cv_set <- garments[idx, ]
hold_set <- garments[-idx, ]
cat("CV subset :", nrow(cv_set), " mean productivity",
round(mean(cv_set[[target]]), 4), "\n")#> CV subset : 959 mean productivity 0.7349
#> Hold-out : 238 mean productivity 0.7358
form <- as.formula(paste(target, "~ ."))
dummies <- dummyVars(form, data = cv_set, fullRank = TRUE)
X_cv <- as.data.frame(predict(dummies, cv_set))
X_hold <- as.data.frame(predict(dummies, hold_set))
names(X_cv) <- make.names(names(X_cv), unique = TRUE)
names(X_hold) <- make.names(names(X_hold), unique = TRUE)
y_cv <- cv_set[[target]]
y_hold <- hold_set[[target]]
p <- ncol(X_cv)
cat("Encoded predictors:", p, "\n")#> Encoded predictors: 20
ctrl <- trainControl(method = "cv", number = 10)
holdout_report <- function(pred, actual, name) {
resid <- actual - pred
tibble(
model = name,
hold_rmse = sqrt(mean(resid^2)),
hold_mae = mean(abs(resid)),
hold_r2 = suppressWarnings(cor(pred, actual))^2
)
}
pred_vs_actual <- function(pred, actual, name) {
tibble(model = name, actual = actual, predicted = pred)
}
# Maps an encoded dummy column (e.g. "department.sweing") back to its
# original raw variable (e.g. "department"), so importance/SHAP values can
# be reported per original predictor instead of per dummy level.
orig_vars <- setdiff(names(garments), target)
parent_of <- function(feat_names, orig_vars) {
vapply(feat_names, function(f) {
if (f %in% orig_vars) return(f)
hit <- orig_vars[startsWith(f, paste0(orig_vars, ".")) |
startsWith(f, paste0(orig_vars, "_"))]
if (length(hit)) hit[[1]] else f
}, character(1))
}Bagging = many bootstrap trees that may use every predictor
at every split (mtry = p). That is the difference
from a random forest, which also hides features at each split.
bag_fit <- train(
x = X_cv,
y = y_cv,
method = "rf",
trControl = ctrl,
metric = "RMSE",
tuneGrid = data.frame(mtry = p),
ntree = 150,
importance = TRUE
)
bag_fit#> Random Forest
#>
#> 959 samples
#> 20 predictor
#>
#> No pre-processing
#> Resampling: Cross-Validated (10 fold)
#> Summary of sample sizes: 863, 863, 864, 863, 862, 863, ...
#> Resampling results:
#>
#> RMSE Rsquared MAE
#> 0.1279275 0.4767037 0.07956046
#>
#> Tuning parameter 'mtry' was held constant at a value of 20
bag_pred <- predict(bag_fit, X_hold)
bag_hold <- holdout_report(bag_pred, y_hold, "Bagged trees")
bag_cv <- min(bag_fit$results$RMSE)
bag_holdOptimal bagged-tree configuration
#> Algorithm : randomForest bagging (mtry = all predictors)
#> Number of trees : 150
#> mtry : 20
#> 10-fold CV RMSE : 0.1279
#> Hold-out RMSE : 0.1113
#> Hold-out R-squared : 0.5616
Leaf-node size and node purity (bagged trees).
treesize() counts terminal (leaf) nodes per tree; for a
regression forest, importance(type = 2) reports each
variable’s total drop in residual sum of squares (“node purity”) across
all trees.
bag_leaves <- randomForest::treesize(bag_fit$finalModel, terminal = TRUE)
cat("Avg. leaf nodes / tree :", round(mean(bag_leaves), 1), "\n")#> Avg. leaf nodes / tree : 325.8
#> Range of leaf nodes : 307 - 351
bag_purity <- randomForest::importance(bag_fit$finalModel, type = 2) %>%
as.data.frame() %>%
rownames_to_column("feature") %>%
rename(node_purity = IncNodePurity) %>%
mutate(variable = parent_of(feature, orig_vars)) %>%
group_by(variable) %>%
summarise(node_purity = sum(node_purity), .groups = "drop") %>%
arrange(desc(node_purity))
head(bag_purity, 5)Same engine; mtry is smaller than p.
rf_mtry_grid <- unique(pmax(1, c(floor(sqrt(p)), floor(0.25 * p), floor(0.4 * p))))
rf_fit <- train(
x = X_cv,
y = y_cv,
method = "rf",
trControl = ctrl,
metric = "RMSE",
tuneGrid = data.frame(mtry = rf_mtry_grid),
ntree = 150,
importance = TRUE
)
rf_fit#> Random Forest
#>
#> 959 samples
#> 20 predictor
#>
#> No pre-processing
#> Resampling: Cross-Validated (10 fold)
#> Summary of sample sizes: 863, 863, 864, 862, 863, 864, ...
#> Resampling results across tuning parameters:
#>
#> mtry RMSE Rsquared MAE
#> 4 0.1255062 0.5069193 0.08283421
#> 5 0.1253205 0.5054753 0.08140652
#> 8 0.1252966 0.5031992 0.07972678
#>
#> RMSE was used to select the optimal model using the smallest value.
#> The final value used for the model was mtry = 8.
rf_pred <- predict(rf_fit, X_hold)
rf_hold <- holdout_report(rf_pred, y_hold, "Random forest")
rf_cv <- min(rf_fit$results$RMSE)
rf_holdOptimal random-forest configuration
#> Number of trees : 150
#> mtry : 8
#> mtry grid tried : 4, 5, 8
#> 10-fold CV RMSE : 0.1253
#> Hold-out RMSE : 0.106
#> Hold-out R-squared : 0.5935
Leaf-node size and node purity (random forest).
rf_leaves <- randomForest::treesize(rf_fit$finalModel, terminal = TRUE)
cat("Avg. leaf nodes / tree :", round(mean(rf_leaves), 1), "\n")#> Avg. leaf nodes / tree : 314.3
#> Range of leaf nodes : 293 - 333
rf_purity <- randomForest::importance(rf_fit$finalModel, type = 2) %>%
as.data.frame() %>%
rownames_to_column("feature") %>%
rename(node_purity = IncNodePurity) %>%
mutate(variable = parent_of(feature, orig_vars)) %>%
group_by(variable) %>%
summarise(node_purity = sum(node_purity), .groups = "drop") %>%
arrange(desc(node_purity))
head(rf_purity, 5)Tuned with native xgb.cv (10-fold CV, RMSE) rather than
caret’s xgbTree wrapper.
dtrain <- xgb.DMatrix(as.matrix(X_cv), label = y_cv)
dhold <- xgb.DMatrix(as.matrix(X_hold), label = y_hold)
xgb_grid <- expand.grid(
nrounds = c(150, 250),
max_depth = c(3, 5),
eta = c(0.05, 0.10),
gamma = 0,
min_child_weight = 5,
subsample = 0.8,
colsample_bytree = 0.8
)
xgb_tune <- vector("list", nrow(xgb_grid))
for (i in seq_len(nrow(xgb_grid))) {
g <- xgb_grid[i, ]
cv <- xgb.cv(
params = list(
objective = "reg:squarederror",
eval_metric = "rmse",
max_depth = g$max_depth,
eta = g$eta,
gamma = g$gamma,
min_child_weight = g$min_child_weight,
subsample = g$subsample,
colsample_bytree = g$colsample_bytree
),
data = dtrain,
nrounds = g$nrounds,
nfold = 10,
verbose = 0
)
xgb_tune[[i]] <- cbind(g, cv_rmse = min(cv$evaluation_log$test_rmse_mean))
cat("XGBoost combo", i, "/", nrow(xgb_grid),
" RMSE =", round(min(cv$evaluation_log$test_rmse_mean), 4), "\n")
}#> XGBoost combo 1 / 8 RMSE = 0.1258
#> XGBoost combo 2 / 8 RMSE = 0.1267
#> XGBoost combo 3 / 8 RMSE = 0.1247
#> XGBoost combo 4 / 8 RMSE = 0.1234
#> XGBoost combo 5 / 8 RMSE = 0.1267
#> XGBoost combo 6 / 8 RMSE = 0.127
#> XGBoost combo 7 / 8 RMSE = 0.1242
#> XGBoost combo 8 / 8 RMSE = 0.1248
xgb_tune_df <- bind_rows(xgb_tune)
xgb_best <- xgb_tune_df[which.min(xgb_tune_df$cv_rmse), ]
xgb_cv <- xgb_best$cv_rmse
print(xgb_best)#> nrounds max_depth eta gamma min_child_weight subsample colsample_bytree
#> 4 250 5 0.05 0 5 0.8 0.8
#> cv_rmse
#> 4 0.123377
xgb_model <- xgb.train(
params = list(
objective = "reg:squarederror",
eval_metric = "rmse",
max_depth = xgb_best$max_depth,
eta = xgb_best$eta,
gamma = xgb_best$gamma,
min_child_weight = xgb_best$min_child_weight,
subsample = xgb_best$subsample,
colsample_bytree = xgb_best$colsample_bytree
),
data = dtrain,
nrounds = xgb_best$nrounds,
verbose = 0
)xgb_pred <- predict(xgb_model, dhold)
xgb_hold <- holdout_report(xgb_pred, y_hold, "XGBoost")
xgb_holdOptimal XGBoost configuration
#> nrounds : 250
#> max_depth : 5
#> eta : 0.05
#> min_child_weight : 5
#> gamma : 0
#> subsample : 0.8
#> colsample_bytree : 0.8
#> 10-fold CV RMSE : 0.1234
#> Hold-out RMSE : 0.1046
#> Hold-out R-squared : 0.6096
Leaf nodes and split-gain “purity” (XGBoost).
Boosted trees don’t have a single node-purity statistic like bagging/RF,
but Gain (the average improvement in squared-error loss
each variable’s splits contribute) is the direct counterpart, and leaf
counts come straight off the fitted trees.
xgb_tree_dt <- xgb.model.dt.tree(model = xgb_model)
xgb_leaf_ct <- xgb_tree_dt %>% filter(Feature == "Leaf") %>% count(Tree, name = "leaves")
cat("Number of trees :", n_distinct(xgb_tree_dt$Tree), "\n")#> Number of trees : 250
#> Avg. leaf nodes / tree : 19.5
#> Range of leaf nodes : 6 - 30
xgb_gain <- xgb.importance(model = xgb_model) %>%
mutate(variable = parent_of(Feature, orig_vars)) %>%
group_by(variable) %>%
summarise(gain = sum(Gain), .groups = "drop") %>%
arrange(desc(gain))
head(xgb_gain, 5)hold_tbl <- bind_rows(bag_hold, rf_hold, xgb_hold) %>%
mutate(cv_rmse = c(bag_cv, rf_cv, xgb_cv)) %>%
select(model, cv_rmse, hold_rmse, hold_mae, hold_r2)
hold_tblbind_rows(
pred_vs_actual(bag_pred, y_hold, "Bagged trees"),
pred_vs_actual(rf_pred, y_hold, "Random forest"),
pred_vs_actual(xgb_pred, y_hold, "XGBoost")
) %>%
ggplot(aes(actual, predicted)) +
geom_point(alpha = 0.4, colour = "#1f4e79") +
geom_abline(slope = 1, intercept = 0, linetype = 2, colour = "grey50") +
facet_wrap(~model) +
labs(title = "Hold-out: predicted vs. actual productivity",
x = "Actual", y = "Predicted") +
theme_minimal(base_size = 12)SHAP = how much each variable moved the predicted productivity value away from the average.
kernelshap package,
explained against a sample of 20 hold-out rows using a 30-row background
sample to represent “typical” feature values. Kernel SHAP’s runtime
scales with rows explained x background rows x number of (one-hot
encoded) predictors, so the sample sizes below are deliberately modest –
raise them later for smoother estimates if runtime allows.set.seed(42)
n_shap <- min(20, nrow(X_hold))
X_shap <- X_hold[sample(nrow(X_hold), n_shap), , drop = FALSE]
n_bg <- min(30, nrow(X_cv))
X_bg <- X_cv[sample(nrow(X_cv), n_bg), , drop = FALSE]
pred_num <- function(object, X) predict(object, X)
# verbose = TRUE prints kernelshap's own progress (iteration/convergence
# messages) to the console so you can see it's working, not frozen.
cat("SHAP bagged trees (Kernel SHAP)...\n")#> SHAP bagged trees (Kernel SHAP)...
ks_bag <- kernelshap(
bag_fit$finalModel,
X = X_shap, bg_X = X_bg, pred_fun = pred_num, verbose = TRUE
)#> | | | 0% | |==== | 5% | |======= | 10% | |========== | 15% | |============== | 20% | |================== | 25% | |===================== | 30% | |======================== | 35% | |============================ | 40% | |================================ | 45% | |=================================== | 50% | |====================================== | 55% | |========================================== | 60% | |============================================== | 65% | |================================================= | 70% | |==================================================== | 75% | |======================================================== | 80% | |============================================================ | 85% | |=============================================================== | 90% | |================================================================== | 95% | |======================================================================| 100%
#> SHAP random forest (Kernel SHAP)...
ks_rf <- kernelshap(
rf_fit$finalModel,
X = X_shap, bg_X = X_bg, pred_fun = pred_num, verbose = TRUE
)#> | | | 0% | |==== | 5% | |======= | 10% | |========== | 15% | |============== | 20% | |================== | 25% | |===================== | 30% | |======================== | 35% | |============================ | 40% | |================================ | 45% | |=================================== | 50% | |====================================== | 55% | |========================================== | 60% | |============================================== | 65% | |================================================= | 70% | |==================================================== | 75% | |======================================================== | 80% | |============================================================ | 85% | |=============================================================== | 90% | |================================================================== | 95% | |======================================================================| 100%
#> SHAP XGBoost (exact TreeSHAP)...
#> SHAP done.
collapse_shap <- function(sv, orig_vars) {
m <- colMeans(abs(sv$S))
tibble(feature = names(m), mean_abs = as.numeric(m)) %>%
mutate(variable = parent_of(feature, orig_vars)) %>%
group_by(variable) %>%
summarise(mean_abs_shap = sum(mean_abs), .groups = "drop") %>%
arrange(desc(mean_abs_shap))
}
imp_bag <- collapse_shap(sv_bag, orig_vars)
imp_rf <- collapse_shap(sv_rf, orig_vars)
imp_xgb <- collapse_shap(sv_xgb, orig_vars)imp_bag %>%
mutate(variable = fct_reorder(variable, mean_abs_shap)) %>%
ggplot(aes(mean_abs_shap, variable)) +
geom_col(fill = "#1f4e79") +
labs(title = "Bagged trees — SHAP",
x = "Mean |SHAP| (productivity units)", y = NULL) +
theme_minimal(base_size = 12)imp_rf %>%
mutate(variable = fct_reorder(variable, mean_abs_shap)) %>%
ggplot(aes(mean_abs_shap, variable)) +
geom_col(fill = "#c45911") +
labs(title = "Random forest — SHAP",
x = "Mean |SHAP| (productivity units)", y = NULL) +
theme_minimal(base_size = 12)imp_xgb %>%
mutate(variable = fct_reorder(variable, mean_abs_shap)) %>%
ggplot(aes(mean_abs_shap, variable)) +
geom_col(fill = "#548235") +
labs(title = "XGBoost — SHAP",
x = "Mean |SHAP| (productivity units)", y = NULL) +
theme_minimal(base_size = 12)sv_importance(sv_xgb, kind = "beeswarm") +
labs(title = "XGBoost SHAP beeswarm") +
theme_minimal(base_size = 11)Each variable is ranked inside each model. Overall order = average of those three ranks.
rank_tbl <-
full_join(imp_bag, imp_rf, by = "variable", suffix = c("_bag", "_rf")) %>%
full_join(imp_xgb %>% rename(mean_abs_shap_xgb = mean_abs_shap), by = "variable") %>%
mutate(
rank_bag = rank(-mean_abs_shap_bag, ties.method = "average"),
rank_rf = rank(-mean_abs_shap_rf, ties.method = "average"),
rank_xgb = rank(-mean_abs_shap_xgb, ties.method = "average"),
mean_rank = (rank_bag + rank_rf + rank_xgb) / 3,
mean_shap = (mean_abs_shap_bag + mean_abs_shap_rf + mean_abs_shap_xgb) / 3
) %>%
arrange(mean_rank, desc(mean_shap)) %>%
mutate(overall_rank = row_number()) %>%
select(overall_rank, variable, mean_rank, mean_shap,
mean_abs_shap_bag, mean_abs_shap_rf, mean_abs_shap_xgb,
rank_bag, rank_rf, rank_xgb)
rank_tblrank_tbl %>%
select(variable, rank_bag, rank_rf, rank_xgb) %>%
pivot_longer(-variable, names_to = "model", values_to = "rank") %>%
mutate(
model = recode(model,
rank_bag = "Bagging",
rank_rf = "Random forest",
rank_xgb = "XGBoost"),
variable = fct_rev(factor(variable, levels = rank_tbl$variable))
) %>%
ggplot(aes(model, variable, fill = rank)) +
geom_tile(color = "white") +
geom_text(aes(label = rank), size = 3.2) +
scale_fill_gradient(low = "#1f4e79", high = "#d6e3f0", trans = "reverse",
name = "Rank (1 = most important)") +
labs(title = "Variable rank by model (SHAP)", x = NULL, y = NULL) +
theme_minimal(base_size = 12)rank_tbl %>%
mutate(variable = fct_reorder(variable, mean_shap)) %>%
ggplot(aes(mean_shap, variable)) +
geom_col(fill = "#2e75b6") +
labs(title = "Aggregated SHAP importance",
x = "Average mean |SHAP| across the three models", y = NULL) +
theme_minimal(base_size = 12)The variables below are the usual suspects on a garment production floor; which ones actually come out on top for this factory’s data is shown by the ranking above (targeted_productivity ranks #1 here) – read this section alongside that table rather than in place of it.
ggplot(garments, aes(incentive, actual_productivity)) +
geom_point(alpha = 0.35, colour = "#1f4e79") +
geom_smooth(method = "loess", se = FALSE, colour = "#c45911") +
labs(title = "Incentive pay vs. actual productivity",
x = "Incentive (BDT)", y = "Actual productivity") +
theme_minimal(base_size = 12)garments %>%
group_by(department) %>%
summarise(mean_prod = mean(actual_productivity), .groups = "drop") %>%
ggplot(aes(department, mean_prod)) +
geom_col(fill = "#1f4e79") +
labs(title = "Average productivity by department",
x = NULL, y = "Mean actual productivity") +
theme_minimal(base_size = 12)garments %>%
group_by(no_of_style_change) %>%
summarise(mean_prod = mean(actual_productivity), .groups = "drop") %>%
ggplot(aes(factor(no_of_style_change), mean_prod)) +
geom_col(fill = "#1f4e79") +
labs(title = "Productivity by number of style changes that day",
x = "Style changes", y = "Mean actual productivity") +
theme_minimal(base_size = 12)How to read the ranking, variable by variable:
targeted_productivity is typically the
strongest driver: the goal a supervisor sets for a team anchors what it
actually achieves, so this is as much a planning lever as a predictor –
realistic targets tend to be hit, and unrealistic ones tend to be missed
by a predictable margin.incentive (financial incentive pay)
usually has a clear, positive, saturating relationship with output –
helpful up to a point, with diminishing returns beyond it.smv (standard minute value, i.e. how
long a garment operation is supposed to take) reflects task complexity;
harder/longer-SMV tasks naturally show more variable output.wip (work-in-progress) and
over_time reflect how loaded a team’s
queue is; both directions are plausible (backlog can mean either “busy
and productive” or “bottlenecked”).idle_time/idle_men are
close to pure downtime and drag actual productivity down whenever
they’re non-zero, even though they’re rare in this data (mostly 0).no_of_style_change captures how often
a team had to retool for a new garment style that day; more changes
generally means more setup time eaten out of the shift.department, team,
day, quarter are structural/calendar
factors – useful for staffing and scheduling decisions, but not
something a supervisor can “pull” on a given day the way incentive or
targets can.targeted_productivity deliberately, using recent
per-team history rather than a flat company-wide number – since the
target itself is a top driver of the outcome, an unrealistic target is a
self-inflicted productivity loss.idle_time/idle_men as a leading
indicator – both are rare but whenever they show up, actual productivity
drops, so they’re a cheap early-warning signal worth monitoring in real
time.department/team/day
differences as a staffing and scheduling input (who works which line,
when) rather than something to intervene on directly.Assumptions: future shifts resemble this factory’s
historical mix of teams, departments, and styles;
targeted_productivity is set by a supervisor before the
shift (so it’s legitimately usable as a predictor, unlike a variable
measured during the shift); the incentive-productivity
relationship is stable at the pay levels observed in this data
(extrapolating far beyond the observed incentive range is not
supported); and the ~1197-row sample is representative of this factory’s
normal operations rather than an unusual period.
#> R version 4.5.1 (2025-06-13 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 26200)
#>
#> Matrix products: default
#> LAPACK version 3.12.1
#>
#> locale:
#> [1] LC_COLLATE=English_United States.utf8
#> [2] LC_CTYPE=English_United States.utf8
#> [3] LC_MONETARY=English_United States.utf8
#> [4] LC_NUMERIC=C
#> [5] LC_TIME=English_United States.utf8
#>
#> time zone: America/Chicago
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] kernelshap_0.9.1 shapviz_0.10.4 xgboost_3.2.1.1
#> [4] randomForest_4.7-1.2 caret_7.0-1 lattice_0.22-7
#> [7] lubridate_1.9.5 forcats_1.0.0 stringr_1.5.2
#> [10] dplyr_1.1.4 purrr_1.1.0 readr_2.1.5
#> [13] tidyr_1.3.1 tibble_3.3.0 ggplot2_4.0.0
#> [16] tidyverse_2.0.0
#>
#> loaded via a namespace (and not attached):
#> [1] tidyselect_1.2.1 viridisLite_0.4.2 timeDate_4052.112
#> [4] farver_2.1.2 S7_0.2.0 fastmap_1.2.0
#> [7] pROC_1.19.1 digest_0.6.37 rpart_4.1.24
#> [10] timechange_0.4.0 lifecycle_1.0.4 survival_3.8-3
#> [13] magrittr_2.0.3 compiler_4.5.1 rlang_1.1.6
#> [16] sass_0.4.10 tools_4.5.1 yaml_2.3.10
#> [19] data.table_1.17.8 knitr_1.50 labeling_0.4.3
#> [22] plyr_1.8.9 RColorBrewer_1.1-3 withr_3.0.2
#> [25] nnet_7.3-20 grid_4.5.1 stats4_4.5.1
#> [28] e1071_1.7-16 future_1.67.0 globals_0.18.0
#> [31] scales_1.4.0 iterators_1.0.14 MASS_7.3-65
#> [34] cli_3.6.5 rmarkdown_2.29 generics_0.1.4
#> [37] rstudioapi_0.17.1 future.apply_1.20.2 reshape2_1.4.5
#> [40] tzdb_0.5.0 cachem_1.1.0 proxy_0.4-27
#> [43] splines_4.5.1 parallel_4.5.1 vctrs_0.6.5
#> [46] hardhat_1.4.3 Matrix_1.7-3 jsonlite_2.0.0
#> [49] hms_1.1.3 listenv_0.10.0 foreach_1.5.2
#> [52] gower_1.0.2 jquerylib_0.1.4 recipes_1.4.0
#> [55] glue_1.8.0 parallelly_1.45.1 codetools_0.2-20
#> [58] stringi_1.8.7 gtable_0.3.6 doFuture_1.3.0
#> [61] pillar_1.11.0 htmltools_0.5.8.1 ipred_0.9-16
#> [64] lava_1.9.3 R6_2.6.1 evaluate_1.0.5
#> [67] bslib_0.9.0 class_7.3-23 Rcpp_1.1.0
#> [70] nlme_3.1-168 prodlim_2026.03.11 mgcv_1.9-3
#> [73] xfun_0.53 ModelMetrics_1.2.2.2 pkgconfig_2.0.3