0. Setup

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
summary(garments[[target]])
#>    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)

1. Training+testing subset and hold-out

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
cat("Hold-out  :", nrow(hold_set), " mean productivity",
    round(mean(hold_set[[target]]), 4), "\n")
#> 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))
}

2. Ensemble models (10-fold CV)

2.A Bagged trees

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_hold

Optimal bagged-tree configuration

cat("Algorithm                : randomForest bagging (mtry = all predictors)\n")
#> Algorithm                : randomForest bagging (mtry = all predictors)
cat("Number of trees          : 150\n")
#> Number of trees          : 150
cat("mtry                     :", bag_fit$bestTune$mtry, "\n")
#> mtry                     : 20
cat("10-fold CV RMSE          :", round(bag_cv, 4), "\n")
#> 10-fold CV RMSE          : 0.1279
cat("Hold-out RMSE            :", round(bag_hold$hold_rmse, 4), "\n")
#> Hold-out RMSE            : 0.1113
cat("Hold-out R-squared       :", round(bag_hold$hold_r2, 4), "\n")
#> 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
cat("Range of leaf nodes      :", min(bag_leaves), "-", max(bag_leaves), "\n")
#> 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)

2.B Random forest

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_hold

Optimal random-forest configuration

cat("Number of trees          : 150\n")
#> Number of trees          : 150
cat("mtry                     :", rf_fit$bestTune$mtry, "\n")
#> mtry                     : 8
cat("mtry grid tried          :", paste(rf_mtry_grid, collapse = ", "), "\n")
#> mtry grid tried          : 4, 5, 8
cat("10-fold CV RMSE          :", round(rf_cv, 4), "\n")
#> 10-fold CV RMSE          : 0.1253
cat("Hold-out RMSE            :", round(rf_hold$hold_rmse, 4), "\n")
#> Hold-out RMSE            : 0.106
cat("Hold-out R-squared       :", round(rf_hold$hold_r2, 4), "\n")
#> 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
cat("Range of leaf nodes      :", min(rf_leaves), "-", max(rf_leaves), "\n")
#> 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)

2.C Boosted trees (XGBoost)

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_hold

Optimal XGBoost configuration

cat("nrounds                  :", xgb_best$nrounds, "\n")
#> nrounds                  : 250
cat("max_depth                :", xgb_best$max_depth, "\n")
#> max_depth                : 5
cat("eta                      :", xgb_best$eta, "\n")
#> eta                      : 0.05
cat("min_child_weight         :", xgb_best$min_child_weight, "\n")
#> min_child_weight         : 5
cat("gamma                    :", xgb_best$gamma, "\n")
#> gamma                    : 0
cat("subsample                :", xgb_best$subsample, "\n")
#> subsample                : 0.8
cat("colsample_bytree         :", xgb_best$colsample_bytree, "\n")
#> colsample_bytree         : 0.8
cat("10-fold CV RMSE          :", round(xgb_cv, 4), "\n")
#> 10-fold CV RMSE          : 0.1234
cat("Hold-out RMSE            :", round(xgb_hold$hold_rmse, 4), "\n")
#> Hold-out RMSE            : 0.1046
cat("Hold-out R-squared       :", round(xgb_hold$hold_r2, 4), "\n")
#> 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
cat("Avg. leaf nodes / tree   :", round(mean(xgb_leaf_ct$leaves), 1), "\n")
#> Avg. leaf nodes / tree   : 19.5
cat("Range of leaf nodes      :", min(xgb_leaf_ct$leaves), "-", max(xgb_leaf_ct$leaves), "\n")
#> 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-out comparison

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_tbl
bind_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)

3. SHAP importance

SHAP = how much each variable moved the predicted productivity value away from the average.

  • XGBoost: exact TreeSHAP (seconds, no approximation).
  • Bagging and RF: Kernel SHAP via the 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%
sv_bag <- shapviz(ks_bag)

cat("SHAP random forest (Kernel SHAP)...\n")
#> 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%
sv_rf <- shapviz(ks_rf)

cat("SHAP XGBoost (exact TreeSHAP)...\n")
#> SHAP XGBoost (exact TreeSHAP)...
sv_xgb <- shapviz(xgb_model, X_pred = as.matrix(X_shap), X = X_shap)
cat("SHAP done.\n")
#> 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)

3.A Bagged trees

imp_bag
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)

3.B Random forest

imp_rf
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)

3.C XGBoost

imp_xgb
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)

4. Aggregated ranking

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_tbl
rank_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)

5. Business implications

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.

6. Advice to a decision-maker

  1. Score upcoming shifts with the tuned XGBoost model to flag which team/day/style combinations are likely to fall short of target before the shift starts, not after.
  2. Set 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.
  3. Keep incentive pay in the range that empirically shows the steepest payoff in the plot above; past that point, extra incentive spend is not buying much extra output.
  4. Bundle style changes where possible (e.g., batch similar styles in the same week) rather than scattering them, since each change taxes that day’s output.
  5. Track 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.
  6. Treat 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.

sessionInfo()
#> 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