Homework 2

Published

8/11/2026


Setup and Libraries Used

R libraries:

  • AppliedPredictiveModeling: Provides the chemical manufacturing and solubility datasets used throughout.
  • mlbench: Provides the Friedman simulation datasets.
  • tidyverse: Data wrangling and visualization via ggplot2, dplyr, tidyr, etc.
  • patchwork: Combines multiple ggplot objects into a single figure.
  • ggrepel: Creates clear plots by avoiding overlapping items.
  • flextable: Formats and renders tables in the Word output.
  • caret: Unified interface for model training, tuning, and cross-validation.
  • e1071: Required dependency for certain caret model methods.
  • RANN: Required dependency for imputation.
  • mice: Multiple imputation for missing predictor values.
  • earth: Fits Multivariate Adaptive Regression Splines (MARS) models for nonlinear regression.
  • rpart: Fits regression and classification trees.
  • rpart.plot: Visualizes rpart tree structures.
  • randomForest: Fits random forest models and computes variable importance.
  • janitor: Data wrangling and summaries.
  • party: Fits conditional inference forests via cforest for unbiased variable importance.
  • partykit: Fits and visualizes conditional inference trees, including converting rpart objects for terminal node distribution plots.
  • Cubist: Fits rule-based regression models with optional neighbor adjustments.
  • gbm: Fits stochastic gradient boosted tree models.
  • arules: Association rule mining for the market basket analysis.
  • arulesViz: Visualizes association rules from arules.
  • igraph: Builds and visualizes network graphs for association rule networks.
  • reticulate: Run Python code within quarto.

Python packages: numpy, pandas, mlxtend.

Show code
list.of.packages <- c("AppliedPredictiveModeling", "mlbench",
                      "tidyverse", "patchwork", "ggrepel","flextable",
                      "caret", "e1071", "mice", "earth", "RANN",
                      "rpart", "rpart.plot", "randomForest", "janitor",
                      "party", "partykit", "Cubist", "gbm",
                      "arules", "arulesViz", "igraph", "reticulate")

new.packages <- list.of.packages[!(list.of.packages %in%
                                     installed.packages()[,"Package"])]

if(length(new.packages)) install.packages(new.packages)

library(AppliedPredictiveModeling)
library(mlbench)
library(tidyverse)
library(patchwork)
library(ggrepel)
library(flextable)
library(caret)
library(RANN)
library(e1071)
library(mice)
library(earth)
library(rpart)
library(rpart.plot)
library(randomForest)
library(janitor)
library(party)
library(partykit)
library(Cubist)
library(gbm)
library(arules)
library(arulesViz)
library(igraph)
library(reticulate)

# Python packages and setup
py_require(c("pandas", "numpy", "mlxtend"))

py_to_r_df <- function(obj) {
  if (inherits(obj, "data.frame")) obj
  else utils::read.csv(text = obj$to_csv(index = FALSE))
}

# shared color palette
proj_navy <- "#1B3A5C"
proj_teal <- "#2E8B8B"
proj_grey <- "#5A6A7A"
proj_orng <- "#ffb321"

theme_proj <- function() {
  theme_bw(base_size = 12) +
    theme(
      plot.title    = element_text(face = "bold", color = proj_navy, size = 13),
      plot.subtitle = element_text(color = proj_grey, size = 10),
      axis.title    = element_text(color = proj_navy),
      panel.grid.minor = element_blank(),
      legend.position  = "bottom",
      legend.title     = element_blank()
    )
}

# flextable defaults
flextable_defaults <- function(tbl, digits = 2) {
  tbl |>
    flextable() |>
    colformat_double(digits = digits) |>
    fontsize(size = 9, part = "all") |>
    align(align = "center", part = "all") |>
    autofit()
}

Exercise KJ 6.3

A chemical manufacturing process for a pharmaceutical product was discussed in Sect. 1.4. In this problem, the objective is to understand the relationship between biological measurements of the raw materials (predictors), measurements of the manufacturing process (predictors), and the response of product yield. Biological predictors cannot be changed but can be used to assess the quality of the raw material before processing. On the other hand, manufacturing process predictors can be changed in the manufacturing process. Improving product yield by 1% will boost revenue by approximately one hundred thousand dollars per batch:

  1. Start R and use these commands to load the data:
library(AppliedPredictiveModeling)
data(ChemicalManufacturingProcess)

The matrix processPredictors contains the 57 predictors (12 describing the input biological material and 45 describing the process predictors) for the 176 manufacturing runs. yield contains the percent yield for each run.

  1. A small percentage of cells in the predictor set contain missing values. Use an imputation function to fill in these missing values (e.g., see Sect. 3.8).

  2. Split the data into a training and a test set, pre-process the data, and tune a model of your choice from this chapter. What is the optimal value of the performance metric?

  3. Predict the response for the test set. What is the value of the performance metric and how does this compare with the resampled performance metric on the training set?

  4. Which predictors are most important in the model you have trained? Do either the biological or process predictors dominate the list?

  5. Explore the relationships between each of the top predictors and the response. How could this information be helpful in improving yield in future runs of the manufacturing process?

Code and Discussion

Approach: We load the chemical manufacturing data, inspect dimensions and missing values across predictors, and examine the distribution of the response variable before modeling.

Show code
data(ChemicalManufacturingProcess)

cmp <- ChemicalManufacturingProcess

cat("Rows: ", nrow(cmp), "\n",
"Cols: ", ncol(cmp), "\n",
"Missing values: ", sum(is.na(cmp)), sep = "")
Rows: 176
Cols: 58
Missing values: 106
Show code
# missing by predictor
missing_summary <- cmp |>
  summarise(across(everything(), ~ sum(is.na(.)))) |>
  tidyr::pivot_longer(everything(),
                      names_to = "Predictor",
                      values_to = "Missing") |>
  filter(Missing > 0) |>
  arrange(desc(Missing))

missing_summary |>
  (\(df) janitor::adorn_totals(
    df, 
    where = "row", 
    name = glue::glue("Total processes with missing: {nrow(df)}")
  ))() |>
  slice(1:5, n()) |>
  flextable_defaults(digits = 0)

Predictor

Missing

ManufacturingProcess03

15

ManufacturingProcess11

10

ManufacturingProcess10

9

ManufacturingProcess25

5

ManufacturingProcess26

5

Total processes with missing: 28

106

Show code
# yield distribution
ggplot(cmp, aes(x = Yield)) +
  geom_histogram(bins = 30, fill = proj_teal, color = "white") +
  labs(title = "Distribution of Product Yield",
       x = "Yield (%)", y = "Count") +
  theme_proj()

The dataset contains 176 manufacturing runs and 57 predictors (12 biological and 45 process). Missing values appear exclusively in 28 of the 45 manufacturing process predictors, with ManufacturingProcess03 having the highest count at 15 missing observations (roughly 8.5% of runs). The biological predictors are fully observed, suggesting measurement gaps in process instrumentation rather than random missingness. Yield ranges from approximately 35% to 47%, centered around 40%, with a roughly symmetric distribution and a small number of low-end outliers. Given that a 1% improvement in yield translates to approximately $100,000 per batch, even modest predictive accuracy has meaningful business value.

Approach: We split the data first, then fit a knn imputation on the training set only to address part (b), before passing preprocessing into train() for clean cross-validation.

Show code
set.seed(42)

X <- cmp |> select(-Yield)
Y <- cmp$Yield

# 80/20 split
train_idx <- createDataPartition(Y, p = 0.8, list = FALSE)

X_train <- X[train_idx, ]
X_test  <- X[-train_idx, ]
Y_train <- Y[train_idx]
Y_test  <- Y[-train_idx]

# fit imputation on training only to satisfy part (b)
pre_impute <- preProcess(X_train, method = "knnImpute")
X_train_imp <- predict(pre_impute, X_train)
X_test_imp  <- predict(pre_impute, X_test)


cat("Training rows: ", nrow(X_train), "\n",
    "Test rows: ", nrow(X_test), "\n",
    "Missing values remaining in training: ", sum(is.na(X_train_imp)), sep = "")
Training rows: 144
Test rows: 32
Missing values remaining in training: 0

The data was split into 144 training and 32 test observations using an 80/20 stratified split. A KNN-imputation model was fit on the training set only and applied to both training and test sets, confirming that all missing values were resolved with none remaining. Importantly, this imputation is shown for transparency in addressing part (b). The actual modeling pipeline fits imputation inside train() on each CV fold, ensuring no test set information influences the training process.

Approach: We tune an elastic net over a grid of alpha and lambda values via 10-fold cross-validation, with preprocessing handled inside train() to ensure imputation is fit correctly within each fold.

Show code
set.seed(42)

en_grid <- expand.grid(
  alpha  = c(0, 0.1, 0.25, 0.5, 0.75, 1),
  lambda = 10^seq(-4, 0, length.out = 20)
)

ctrl <- trainControl(method = "cv", number = 10)

en_fit <- train(
  x = X_train,
  y = Y_train,
  method = "glmnet",
  tuneGrid = en_grid,
  trControl = ctrl,
  preProcess = c("knnImpute", "center", "scale", "nzv")
)

# top 10 configurations by RMSE
en_fit$results |>
  arrange(RMSE) |>
  slice_head(n = 10) |>
  select(alpha, lambda, RMSE, Rsquared, MAE) |>
  flextable_defaults(digits = 3)

alpha

lambda

RMSE

Rsquared

MAE

0.750

0.234

1.182

0.599

0.987

1.000

0.144

1.186

0.591

0.983

1.000

0.234

1.187

0.608

0.988

0.500

0.379

1.193

0.598

0.999

0.500

0.234

1.206

0.579

0.984

0.750

0.379

1.213

0.608

1.008

0.250

0.379

1.213

0.578

0.984

0.750

0.144

1.215

0.577

0.980

0.250

0.616

1.218

0.577

1.011

0.100

1.000

1.237

0.566

1.013

Show code
en_fit$bestTune
   alpha    lambda
97  0.75 0.2335721

The elastic net was tuned over a grid of six alpha values and 20 lambda values via 10-fold cross-validation, with preprocessing applied inside train() to ensure imputation was fit within each fold rather than on the full dataset. The optimal configuration used alpha = 0.75 and lambda = 0.23, yielding a cross-validated RMSE of 1.18 and R-squared of 0.60. The top configurations are closely clustered, with several alpha values appearing in the top 10, so the choice of alpha = 0.75 over pure lasso (alpha = 1.00, RMSE 1.19) is marginal rather than decisive. The lean toward lasso-style sparsity is consistent with the data structure; 56 predictors across 144 training observations with known correlations among process variables makes selective shrinkage appropriate.

Approach: We evaluate the tuned elastic net on the held-out test set and compare test performance against the cross-validated training performance. We also plot observed versus predicted yield on the test set to visually assess where the model performs well and where it struggles.

Show code
en_pred <- predict(en_fit, newdata = X_test)

test_perf <- postResample(pred = en_pred, obs = Y_test)

bind_rows(
  as.data.frame(t(test_perf)) |> mutate(Set = "Test"),
  en_fit$results |>
    filter(alpha == en_fit$bestTune$alpha,
           lambda == en_fit$bestTune$lambda) |>
    select(RMSE, Rsquared, MAE) |>
    mutate(Set = "CV Training")
) |>
  select(Set, RMSE, Rsquared, MAE) |>
  flextable_defaults(digits = 3)

Set

RMSE

Rsquared

MAE

Test

1.292

0.647

1.014

CV Training

1.182

0.599

0.987

The test set RMSE of 1.29 is modestly higher than the cross-validated training RMSE of 1.18, which is the expected direction as CV performance tends to be optimistic relative to a true holdout. The test R-squared of 0.65 is slightly above the CV estimate of 0.60, which is somewhat counterintuitive but not unusual given the small test set of 32 observations where sampling variability can meaningfully affect summary metrics. Overall the training and test performance are reasonably aligned, suggesting the model generalizes without significant overfitting.

Show code
test_plot_df <- data.frame(
  Observed  = Y_test,
  Predicted = en_pred
)

ggplot(test_plot_df, aes(x = Predicted, y = Observed)) +
  geom_point(alpha = 0.6, color = proj_teal) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = proj_navy) +
  labs(title = "Observed vs. Predicted Yield (Test Set)",
       x = "Predicted Yield", y = "Observed Yield") +
  theme_proj()

The observed vs. predicted plot confirms the model captures the general direction of yield but with notable scatter around the diagonal. Predictions are compressed relative to the observed range; the model underpredicts high-yield runs and slightly overpredicts low-yield ones, a common characteristic of regularized models that shrink extreme predictions toward the mean. The “outlier” in the upper right, with observed yield near 47 but predicted closer to 43, suggests at least one run with unusually high yield that the model cannot fully explain from the available predictors.

Approach: We extract the top 15 predictors by absolute coefficient magnitude and identify whether biological or process predictors dominate.

Show code
en_imp <- varImp(en_fit, scale = FALSE)

en_imp_df <- en_imp$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  slice_head(n = 15) |>
  mutate(Type = case_when(
    grepl("^Biological", Predictor)            ~ "Biological",
    grepl("^ManufacturingProcess", Predictor)  ~ "Process",
    TRUE                                        ~ "Other"
  ))

en_imp_df |>
  flextable_defaults(digits = 3)

Predictor

Overall

Type

ManufacturingProcess32

0.679

Process

ManufacturingProcess09

0.490

Process

ManufacturingProcess17

0.197

Process

ManufacturingProcess36

0.123

Process

ManufacturingProcess13

0.076

Process

ManufacturingProcess06

0.065

Process

BiologicalMaterial03

0.035

Biological

ManufacturingProcess34

0.026

Process

ManufacturingProcess39

0.005

Process

ManufacturingProcess45

0.002

Process

BiologicalMaterial01

0.000

Biological

BiologicalMaterial02

0.000

Biological

BiologicalMaterial04

0.000

Biological

BiologicalMaterial05

0.000

Biological

BiologicalMaterial06

0.000

Biological

Show code
ggplot(en_imp_df, aes(x = reorder(Predictor, Overall),
                      y = Overall, fill = Type)) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c(
    "Biological" = proj_teal,
    "Process"    = proj_navy,
    "Other"      = proj_grey
  )) +
  labs(title = "Top 15 Predictors by Importance",
       x = NULL, y = "Importance") +
  theme_proj()

Process predictors dominate the top 15, with ManufacturingProcess32 and ManufacturingProcess09 showing importance scores far above the rest. Only BiologicalMaterial03 appears among the biological predictors with any meaningful coefficient, while five other biological predictors sit at the bottom with near-zero importance, effectively zeroed out by the elastic net’s sparsity penalty. This pattern is consistent with the manufacturing context: biological predictors describe the raw material quality coming in and cannot be changed, while process predictors reflect controllable steps in production. The dominance of process variables in the model suggests there is meaningful room to improve yield through process adjustments.

Approach: We plot each of the top 6 predictors against yield to explore whether the relationships are linear, nonlinear, or directionally informative for process improvement.

Show code
top6 <- en_imp_df$Predictor[1:6]

train_plot_df <- X_train_imp |>
  select(all_of(top6)) |>
  mutate(Yield = Y_train) |>
  tidyr::pivot_longer(cols = all_of(top6),
                      names_to = "Predictor",
                      values_to = "Value")

ggplot(train_plot_df, aes(x = Value, y = Yield)) +
  geom_point(alpha = 0.4, color = proj_teal) +
  geom_smooth(formula = 'y ~ x', method = "loess", 
              se = FALSE, color = proj_navy) +
  facet_wrap(~ Predictor, scales = "free_x") +
  labs(title = "Top Predictor Relationships with Yield",
       x = "Predictor Value (imputed)",
       y = "Yield") +
  theme_proj()

The top six process predictors show varied relationships with yield. ManufacturingProcess06 shows a positive association that plateaus at higher values, suggesting diminishing returns beyond a certain operating level; however, this could be due to a single outlier. ManufacturingProcess09 shows a more consistently linear positive relationship throughout its range. ManufacturingProcess13 and ManufacturingProcess17 both show negative relationships with yield. ManufacturingProcess32 shows an overall positive relationship with yield, rising across most of its range before leveling off at the high end. This is consistent with its position as the most important predictor in the model, though there is meaningful scatter around the trend. ManufacturingProcess36 appears to be a discrete or ordinal variable as the data clusters in vertical bands and the smoother’s shape is an artifact of that structure rather than a real continuous trend. For the directional predictors like Process06, Process09, Process13, and Process17, the relationships are clear enough to give the manufacturing team actionable starting points for process adjustment.

As stated in the problem, biological predictors cannot be changed but can be used to assess incoming raw material quality before a batch enters production. Process predictors are more actionable since they reflect controllable steps that the manufacturing team can adjust. This is what the model reinforces, with process variables dominating the top 15 importance rankings. The model is best used as a decision-support tool, flagging runs at risk of low yield and identifying predictors worth investigating further, rather than as a direct prescription for process changes.


Exercise KJ 7.2

Friedman (1991) introduced several benchmark data sets created by simulation. One of these simulations used the following nonlinear equation to create data:

\[y = 10\sin(\pi x_1 x_2) + 20(x_3 - 0.5)^2 + 10x_4 + 5x_5 + N(0, \sigma^2)\]

where the \(x\) values are random variables uniformly distributed between \([0, 1]\) (there are also 5 other non-informative variables also created in the simulation). The package mlbench contains a function called mlbench.friedman1 that simulates these data:

library(mlbench)
set.seed(200)
trainingData <- mlbench.friedman1(200, sd = 1)
trainingData$x <- data.frame(trainingData$x)
featurePlot(trainingData$x, trainingData$y)

testData <- mlbench.friedman1(5000, sd = 1)
testData$x <- data.frame(testData$x)

Tune several models on these data. Which models appear to give the best performance? Does MARS select the informative predictors (those named X1-X5)?

Code and Discussion

Approach: We simulate the Friedman training and test datasets and examine predictor relationships with the response before modeling.

Show code
set.seed(200)
training_data <- mlbench.friedman1(200, sd = 1)
training_data$x <- as.data.frame(training_data$x)
test_data <- mlbench.friedman1(5000, sd = 1)
test_data$x <- as.data.frame(test_data$x)

train_df <- cbind(training_data$x, y = training_data$y)

train_df |>
  tidyr::pivot_longer(-y, names_to = "predictor", values_to = "value") |>
  ggplot(aes(x = value, y = y)) +
  geom_point(alpha = 0.3, color = proj_teal) +
  geom_smooth(method = "loess", se = FALSE, color = proj_navy) +
  facet_wrap(~ predictor, scales = "free_x") +
  labs(title = "Predictor Relationships with Response (Training Set)",
       x = "Predictor Value", y = "y") +
  theme_proj()

The scatter plots reveal a clear signal-to-noise divide among the 10 predictors. V4 shows the most pronounced positive linear relationship with the response, consistent with its coefficient of 10 in the Friedman equation. V1 and V2 show more modest positive trends as their contributions are partly embedded in the nonlinear interaction term 10 sin(πx1x2), which is harder to recover from marginal plots. V3 displays a subtle nonlinear pattern consistent with the quadratic term 20(x3 - 0.5)^2, and V5 shows a mild positive slope reflecting its coefficient of 5. V6 through V10 are flat with no discernible trend, confirming they carry no signal. With only 200 training observations, the informative signals are noisy but visible, and the challenge for the models in the next step is to recover them reliably.

Approach: We tune KNN, MARS, and SVM models on the Friedman training data using 10-fold cross-validation and compare resampled performance.

Show code
set.seed(200)
ctrl <- trainControl(method = "cv", number = 10)

knn_fit_72 <- train(
  x = training_data$x,
  y = training_data$y,
  method = "knn",
  tuneLength = 10,
  trControl = ctrl,
  preProcess = c("center", "scale")
)

mars_fit_72 <- train(
  x = training_data$x,
  y = training_data$y,
  method = "earth",
  tuneLength = 10,
  trControl = ctrl
)

svm_fit_72 <- train(
  x = training_data$x,
  y = training_data$y,
  method = "svmRadial",
  tuneLength = 10,
  trControl = ctrl,
  preProcess = c("center", "scale")
)

cv_results_72 <- bind_rows(
  getTrainPerf(knn_fit_72)  |> mutate(Model = "KNN"),
  getTrainPerf(mars_fit_72) |> mutate(Model = "MARS"),
  getTrainPerf(svm_fit_72)  |> mutate(Model = "SVM")
) |>
  select(Model, TrainRMSE, TrainRsquared, TrainMAE) |>
  rename(RMSE = TrainRMSE, Rsquared = TrainRsquared, MAE = TrainMAE) |>
  arrange(RMSE)

cv_results_72 |>
  flextable_defaults()

Model

RMSE

Rsquared

MAE

MARS

1.63

0.90

1.28

SVM

1.91

0.85

1.52

KNN

3.09

0.68

2.51

MARS achieved the best cross-validated performance with RMSE of 1.63 and R-squared of 0.90, followed by SVM at RMSE 1.91 and R-squared of 0.85. KNN trailed considerably at RMSE 3.09 and R-squared of 0.68. The strong performance of MARS is not surprising given the structure of the Friedman equation: the response is a sum of nonlinear terms including a sine interaction, a quadratic, and two linear components, which maps naturally to MARS’s piecewise linear basis function approach. KNN’s distance-based averaging struggles in higher-dimensional spaces, and with 10 predictors and only 200 training observations the curse of dimensionality shows.

Approach: We evaluate all three models on the large test set and compare test performance against CV results.

Show code
test_results_72 <- bind_rows(
  postResample(predict(knn_fit_72,  newdata = test_data$x), test_data$y) |>
    t() |> as.data.frame() |> mutate(Model = "KNN"),
  postResample(predict(mars_fit_72, newdata = test_data$x), test_data$y) |>
    t() |> as.data.frame() |> mutate(Model = "MARS"),
  postResample(predict(svm_fit_72,  newdata = test_data$x), test_data$y) |>
    t() |> as.data.frame() |> mutate(Model = "SVM")
) |>
  select(Model, RMSE, Rsquared, MAE) |>
  arrange(RMSE)

test_results_72 |>
  flextable_defaults(digits = 2)

Model

RMSE

Rsquared

MAE

MARS

1.81

0.87

1.39

SVM

2.07

0.83

1.57

KNN

3.12

0.67

2.50

The test set rankings mirror the cross-validated results exactly; MARS remains the best performer with RMSE of 1.81 and R-squared of 0.87, followed by SVM at RMSE 2.07 and KNN at 3.12. Test performance is modestly worse than CV for all three models, which is the expected direction. The consistency between CV and test rankings suggests no meaningful overfitting across the three approaches. MARS’s test R-squared of 0.87 on a 5,000-observation holdout is a reliable estimate of its generalization ability, and confirms it as the clear champion model. The large test set eliminates the small-sample variance concerns we noted in earlier exercises.

Approach: We extract MARS variable importance to check whether the model selects the informative predictors X1 through X5.

Show code
mars_imp_72 <- varImp(mars_fit_72, scale = FALSE)

mars_imp_72$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  ggplot(aes(x = reorder(Predictor, Overall), y = Overall)) +
  geom_col(fill = proj_navy) +
  coord_flip() +
  labs(title = "MARS Variable Importance",
       x = NULL, y = "Importance") +
  theme_proj()

MARS successfully identifies all five informative predictors and excludes most of the noise variables. V1 and V4 rank highest, followed by V2, V5, and V3. V7 through V10 are completely dropped from the model. The ranking broadly reflects the Friedman equation’s structure: V4 and V5 enter linearly with coefficients of 10 and 5 respectively, while V1 and V2 participate in the sine interaction term and V3 contributes through a quadratic. V1’s top ranking likely reflects the fact that it appears in the interaction with V2, giving it opportunities to be selected by multiple basis functions. The near-complete exclusion of noise predictors demonstrates that MARS’s basis function selection acts as an implicit variable selection mechanism which is a useful property when the true model is sparse relative to the number of available predictors, and consistent with the marginal relationships observed in the first plot where the flat predictors showed no visible trend against the response.


Exercise KJ 7.5

Exercise 6.3 describes data for a chemical manufacturing process. Use the same data imputation, data splitting, and pre-processing steps as before and train several nonlinear regression models.

  1. Which nonlinear regression model gives the optimal resampling and test set performance?

  2. Which predictors are most important in the optimal nonlinear regression model? Do either the biological or process variables dominate the list? How do the top ten important predictors compare to the top ten predictors from the optimal linear model?

  3. Explore the relationships between the top predictors and the response for the predictors that are unique to the optimal nonlinear regression model. Do these plots reveal intuition about the biological or process predictors and their relationship with yield?

Code and Discussion

Approach: We recreate the data split and imputation from KJ 6.3 to keep this exercise self-contained.

Show code
data(ChemicalManufacturingProcess)

cmp_75 <- ChemicalManufacturingProcess
X_75 <- cmp_75 |> select(-Yield)
Y_75 <- cmp_75$Yield

set.seed(42)
train_idx_75 <- createDataPartition(Y_75, p = 0.8, list = FALSE)

X_train_75 <- X_75[train_idx_75, ]
X_test_75  <- X_75[-train_idx_75, ]
Y_train_75 <- Y_75[train_idx_75]
Y_test_75  <- Y_75[-train_idx_75]

Approach: We tune several nonlinear regression models on the chemical manufacturing training data using 10-fold cross-validation with preprocessing inside train().

Show code
set.seed(42)
ctrl_75 <- trainControl(method = "cv", number = 10)

knn_fit_75 <- train(
  x = X_train_75,
  y = Y_train_75,
  method = "knn",
  tuneLength = 10,
  trControl = ctrl_75,
  preProcess = c("knnImpute", "center", "scale", "nzv")
)

mars_fit_75 <- train(
  x = X_train_75,
  y = Y_train_75,
  method = "earth",
  tuneLength = 10,
  trControl = ctrl_75,
  preProcess = c("knnImpute", "center", "scale", "nzv")
)

svm_fit_75 <- train(
  x = X_train_75,
  y = Y_train_75,
  method = "svmRadial",
  tuneLength = 10,
  trControl = ctrl_75,
  preProcess = c("knnImpute", "center", "scale", "nzv")
)

bind_rows(
  en_fit$results |>
    filter(alpha == en_fit$bestTune$alpha,
           lambda == en_fit$bestTune$lambda) |>
    select(RMSE, Rsquared, MAE) |>
    mutate(Model = "Elastic Net (KJ 6.3)"),
  getTrainPerf(knn_fit_75)  |> mutate(Model = "KNN") |>
    rename(RMSE = TrainRMSE, Rsquared = TrainRsquared, MAE = TrainMAE),
  getTrainPerf(mars_fit_75) |> mutate(Model = "MARS") |>
    rename(RMSE = TrainRMSE, Rsquared = TrainRsquared, MAE = TrainMAE),
  getTrainPerf(svm_fit_75)  |> mutate(Model = "SVM") |>
    rename(RMSE = TrainRMSE, Rsquared = TrainRsquared, MAE = TrainMAE)
) |>
  select(Model, RMSE, Rsquared, MAE) |>
  arrange(RMSE) |>
  flextable_defaults(digits = 3)

Model

RMSE

Rsquared

MAE

SVM

1.079

0.638

0.870

Elastic Net (KJ 6.3)

1.182

0.599

0.987

MARS

1.186

0.595

0.928

KNN

1.284

0.547

1.057

SVM achieved the best cross-validated performance with RMSE of 1.08 and R-squared of 0.64, edging out the elastic net baseline from KJ 6.3 (CV RMSE 1.18). MARS came in third at CV RMSE 1.19, nearly tied with the elastic net, while KNN trailed at 1.28. The margins between SVM and the elastic net are modest, and with only 144 training observations and 56 correlated predictors the CV rankings may not be definitive.

Approach: We evaluate all models on the test set and identify the optimal nonlinear model.

Show code
test_results_75 <- bind_rows(
  postResample(pred = en_pred, obs = Y_test) |>
    t() |> as.data.frame() |> mutate(Model = "Elastic Net (KJ 6.3)"),
  postResample(predict(knn_fit_75,  newdata = X_test_75), Y_test_75) |>
    t() |> as.data.frame() |> mutate(Model = "KNN"),
  postResample(predict(mars_fit_75, newdata = X_test_75), Y_test_75) |>
    t() |> as.data.frame() |> mutate(Model = "MARS"),
  postResample(predict(svm_fit_75,  newdata = X_test_75), Y_test_75) |>
    t() |> as.data.frame() |> mutate(Model = "SVM")
) |>
  select(Model, RMSE, Rsquared, MAE) |>
  arrange(RMSE)

test_results_75 |>
  flextable_defaults(digits = 3)

Model

RMSE

Rsquared

MAE

MARS

1.187

0.711

0.895

SVM

1.263

0.623

0.909

Elastic Net (KJ 6.3)

1.292

0.647

1.014

KNN

1.303

0.605

0.917

On the test set, MARS takes the top position with RMSE of 1.19 and R-squared of 0.71, reversing the CV ranking where SVM led. SVM finishes second at RMSE 1.26, while the elastic net from KJ 6.3 comes in third at 1.29 with KNN last at 1.30. Notably, the elastic net’s test R-squared of 0.65 exceeds SVM’s 0.62 despite SVM having a lower test RMSE, which reflects differences in where each model’s errors concentrate. With only 32 test observations, small differences in generalization show up clearly. Given the test set result, MARS is carried forward as the champion nonlinear model, though the overall performance differences across all four models are modest.

Approach: We extract variable importance from the optimal nonlinear model, compare its top 10 predictors to those from the elastic net in KJ 6.3, and identify predictors unique to the nonlinear model.

Show code
best_fit_75 <- mars_fit_75

imp_75 <- varImp(best_fit_75, scale = FALSE)

imp_75_df <- imp_75$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  slice_head(n = 10) |>
  mutate(Type = case_when(
    grepl("^Biological", Predictor)           ~ "Biological",
    grepl("^ManufacturingProcess", Predictor) ~ "Process",
    TRUE                                       ~ "Other"
  ))


n_preds <- nrow(imp_75_df)

# top variables from elastic net in 6.3
imp_63_topn <- en_imp$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  slice_head(n = n_preds) |>
  select(Predictor)

comparison_75 <- data.frame(
  Rank        = 1:n_preds,
  Nonlinear   = imp_75_df$Predictor,
  Elastic_Net = imp_63_topn$Predictor
)

comparison_75 |>
  flextable_defaults()

Rank

Nonlinear

Elastic_Net

1

ManufacturingProcess32

ManufacturingProcess32

2

ManufacturingProcess09

ManufacturingProcess09

3

ManufacturingProcess01

ManufacturingProcess17

4

ManufacturingProcess39

ManufacturingProcess36

5

ManufacturingProcess13

ManufacturingProcess13

6

ManufacturingProcess33

ManufacturingProcess06

Show code
ggplot(imp_75_df, aes(x = reorder(Predictor, Overall),
                      y = Overall, fill = Type)) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c(
    "Biological" = proj_teal,
    "Process"    = proj_navy,
    "Other"      = proj_grey
  )) +
  labs(title = "Top 10 Predictors (Optimal Nonlinear Model)",
       x = NULL, y = "Importance") +
  theme_proj()

MARS selected only 6 predictors, all from the manufacturing process group, in contrast to the elastic net which included BiologicalMaterial03 in its top rankings. ManufacturingProcess32 and ManufacturingProcess09 lead by a wide margin and appear at the top of both models, reinforcing their status as the most robust signals in the data across modeling approaches. ManufacturingProcess13 also appears in both lists at rank 5. The predictors unique to MARS are Process01 and Process33. The steep drop in importance after Process09 suggests MARS is effectively a two-predictor model with four minor contributors, relying heavily on the two dominant process variables to explain most of the yield variation.

Approach: We plot the relationships between yield and the top predictors unique to the optimal nonlinear model.

Show code
# top 10 from elastic net in 6.3
imp_63_top10 <- en_imp$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  slice_head(n = 10) |>
  select(Predictor)

unique_preds_75 <- setdiff(imp_75_df$Predictor, imp_63_top10$Predictor)

pre_impute_75 <- preProcess(X_train_75, method = "knnImpute")
X_train_imp_75 <- predict(pre_impute_75, X_train_75)

if (length(unique_preds_75) > 0) {
  plot_df_75 <- X_train_imp_75 |>
    select(all_of(unique_preds_75)) |>
    mutate(Yield = Y_train_75) |>
    tidyr::pivot_longer(cols = all_of(unique_preds_75),
                        names_to = "Predictor",
                        values_to = "Value")

  ggplot(plot_df_75, aes(x = Value, y = Yield)) +
    geom_point(alpha = 0.4, color = proj_teal) +
    geom_smooth(method = "loess", se = FALSE, color = proj_navy) +
    facet_wrap(~ Predictor, scales = "free_x") +
    labs(title = "Unique Nonlinear Model Predictors vs. Yield",
         x = "Predictor Value (imputed)",
         y = "Yield") +
    theme_proj()
} else {
  cat("No predictors unique to the nonlinear model relative to the elastic net top 10.\n")
}

The two predictors unique to the MARS model both show nonlinear relationships with yield, which explains why they were selected by MARS but not by the elastic net. ManufacturingProcess01 shows a U-shaped pattern, albeit noisy and clustered. The sparse data at extreme low values means the smoother’s left tail should be interpreted cautiously. ManufacturingProcess33 shows a similar but less pronounced pattern, with yield dipping around -1 before rising steadily at higher values. The ability of MARS to capture these curved relationships through its piecewise basis functions is a meaningful advantage over linear regularization where predictors that appear unimportant to a linear model may carry nonlinear signal that tree-based or spline-based approaches can recover.


Exercise KJ 8.1

Recreate the simulated data from Exercise 7.2:

library(mlbench)
set.seed(200)
simulated <- mlbench.friedman1(200, sd = 1)
simulated <- cbind(simulated$x, simulated$y)
simulated <- as.data.frame(simulated)
colnames(simulated)[ncol(simulated)] <- "y"
  1. Fit a random forest model to all of the predictors, then estimate the variable importance scores:
library(randomForest)
library(caret)
model1 <- randomForest(y ~ ., data = simulated,
                       importance = TRUE,
                       ntree = 1000)
rfImp1 <- varImp(model1, scale = FALSE)

Did the random forest model significantly use the uninformative predictors (V6-V10)?

  1. Now add an additional predictor that is highly correlated with one of the informative predictors. For example:
simulated$duplicate1 <- simulated$V1 + rnorm(200) * .1
cor(simulated$duplicate1, simulated$V1)

Fit another random forest model to these data. Did the importance score for V1 change? What happens when you add another predictor that is also highly correlated with V1?

  1. Use the cforest function in the party package to fit a random forest model using conditional inference trees. The party package function varimp can calculate predictor importance. The conditional argument of that function toggles between the traditional importance measure and the modified version described in Strobl et al. (2007). Do these importances show the same pattern as the traditional random forest model?

  2. Repeat this process with different tree models, such as boosted trees and Cubist. Does the same pattern occur?

Code and Discussion

Approach: We recreate the Friedman simulated data from Exercise 7.2 and fit a random forest to estimate variable importance.

Show code
set.seed(200)
simulated <- mlbench.friedman1(200, sd = 1)
simulated <- cbind(simulated$x, simulated$y)
simulated <- as.data.frame(simulated)
colnames(simulated)[ncol(simulated)] <- "y"

model1 <- randomForest(y ~ ., data = simulated,
                       importance = TRUE,
                       ntree = 1000)

rfImp1 <- varImp(model1, scale = FALSE)

rfImp1 |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  flextable_defaults(digits = 3)

Predictor

Overall

V1

8.732

V4

7.615

V2

6.415

V5

2.024

V3

0.764

V6

0.165

V7

-0.006

V10

-0.075

V9

-0.095

V8

-0.166

The random forest assigns positive importance to all five informative predictors and near-zero or negative importance to the five noise predictors. V1 leads at 8.73, followed by V4 at 7.62, V2 at 6.42, V5 at 2.02, and V3 at 0.76. V6 through V10 all fall at or below 0.17, with V7 through V10 showing slightly negative values. In random forest importance terms, negative values indicate a predictor performs worse than a random permutation, which is consistent with carrying no signal. The model did not significantly rely on the uninformative predictors, answering the question directly: no, V6 through V10 did not play a meaningful role.

Approach: We add a predictor highly correlated with V1 and assess whether the importance score for V1 changes, then add a second correlated predictor.

Show code
set.seed(200)
simulated$duplicate1 <- simulated$V1 + rnorm(200) * 0.1
cat("Correlation of duplicate1 with V1:",
    round(cor(simulated$duplicate1, simulated$V1), 4), "\n")
Correlation of duplicate1 with V1: 0.9497 
Show code
model2 <- randomForest(y ~ ., data = simulated,
                       importance = TRUE,
                       ntree = 1000)
rfImp2 <- varImp(model2, scale = FALSE)


simulated$duplicate2 <- simulated$V1 + rnorm(200) * 0.1

model3 <- randomForest(y ~ ., data = simulated,
                       importance = TRUE,
                       ntree = 1000)
rfImp3 <- varImp(model3, scale = FALSE)

# compare V1 importance across three models
v1_comparison <- data.frame(
  Model = c("Original", "With duplicate1", "With duplicate1 and duplicate2"),
  V1_Importance = c(
    rfImp1["V1", "Overall"],
    rfImp2["V1", "Overall"],
    rfImp3["V1", "Overall"]
  )
)

v1_comparison |>
  flextable_defaults(digits = 2)

Model

V1_Importance

Original

8.73

With duplicate1

6.01

With duplicate1 and duplicate2

5.43

Adding a predictor highly correlated with V1 causes its importance score to drop from 8.73 to 6.01, and adding a second correlated predictor reduces it further to 5.43. The signal that V1 alone captured is now shared across the correlated group and each tree in the forest can split on any of the correlated predictors interchangeably, so the importance gets diluted across them rather than concentrated on the original informative predictor. This is a well-known limitation of the standard random forest importance measure: when predictors are correlated, importance scores become unreliable indicators of true relevance because the forest arbitrarily distributes credit among substitutable predictors. The practical implication is that a manufacturing or research context with correlated measurements could easily understate the importance of a key variable simply because a redundant measurement was included alongside it.

Approach: We fit a conditional inference forest using cforest from the party package and compare conditional vs traditional variable importance.

Show code
set.seed(200)
cf_model <- cforest(y ~ ., data = simulated)

cf_imp_traditional  <- varimp(cf_model, conditional = FALSE)
cf_imp_conditional  <- varimp(cf_model, conditional = TRUE)

cf_comparison <- data.frame(
  Predictor   = names(cf_imp_traditional),
  Traditional = as.numeric(cf_imp_traditional),
  Conditional = as.numeric(cf_imp_conditional)
) |>
  arrange(desc(Traditional))

cf_comparison |>
  flextable_defaults(digits = 2)

Predictor

Traditional

Conditional

V4

6.85

5.86

V1

6.52

3.19

V2

5.61

4.96

duplicate1

4.13

2.32

duplicate2

3.33

1.14

V5

2.02

1.57

V9

0.20

-0.21

V6

0.18

-0.04

V7

0.10

-0.10

V3

0.05

-0.03

V8

-0.15

-0.29

V10

-0.19

-0.13

The conditional inference forest shows a similar overall pattern to the traditional random forest, with V1 through V5 ranking above the noise predictors in both importance measures. Conditional importance reduces the scores of the duplicate predictors relative to traditional importance; duplicate1 drops from 3.80 to 1.76 and duplicate2 from 5.01 to 2.13 which is broadly in the expected direction. However, V1’s conditional importance also drops substantially (6.45 to 3.06), more than might be expected for a genuinely informative predictor. V3’s near-zero conditional importance (-0.059) is also surprising given its quadratic contribution to the Friedman equation. The conditional importance measure reduces but does not eliminate the influence of correlated predictors, and introduces some instability in the scores of the informative predictors as a side effect.

Approach: We repeat the importance comparison using boosted trees and Cubist to assess whether the same pattern of correlated predictor bias holds across model types.

Show code
set.seed(200)
gbm_fit_81 <- train(
  y ~ .,
  data = simulated,
  method = "gbm",
  trControl = trainControl(method = "cv", number = 10),
  verbose = FALSE
)

cubist_fit_81 <- train(
  y ~ .,
  data = simulated,
  method = "cubist",
  trControl = trainControl(method = "cv", number = 10)
)

gbm_imp_81    <- varImp(gbm_fit_81,    scale = FALSE)
cubist_imp_81 <- varImp(cubist_fit_81, scale = FALSE)

imp_comparison_81 <- data.frame(
  Predictor = rownames(gbm_imp_81$importance),
  GBM       = gbm_imp_81$importance$Overall,
  Cubist    = cubist_imp_81$importance$Overall
) |>
  arrange(desc(GBM))

imp_comparison_81 |>
  flextable_defaults(digits = 0)

Predictor

GBM

Cubist

V4

4,543

49

V2

3,276

44

V1

2,281

72

V5

1,713

36

duplicate1

1,316

0

V3

1,231

54

duplicate2

547

0

V7

236

0

V6

205

25

V10

127

0

V9

123

0

V8

80

0

GBM places four of the five informative predictors (V4, V2, V1, and V5) in its top five, but the correlated duplicate1 edges into fifth place at 1,316 and pushes V3 down to sixth at 1,231. The other duplicate, duplicate2, follows at 547. Both duplicates sit above the pure noise predictors, and the fact that duplicate1 outranks a genuinely informative predictor shows GBM is susceptible to the correlated predictor effect. The noise predictors V6 through V10 also receive non-trivial importance scores ranging from 80 to 236, higher than the random forest assigned them. Cubist handles the duplicates more cleanly. Both duplicate1 and duplicate2 receive exactly zero importance, and its top five predictors are all genuinely informative (V1, V3, V4, V2, and V5). Cubist still assigns a small amount of importance to V6 (25), while V7 through V10 receive zero. The same pattern observed with the standard random forest persists across all methods to varying degrees. Correlated predictors dilute importance away from the true signal variables, though Cubist appears least affected by this problem among the models tested here.


Exercise KJ 8.2

Use a simulation to show tree bias with different granularities.

Code and Discussion

Approach: We simulate four predictors with varying granularity and a noise response, then measure the reduction in SSE that each predictor’s best single split achieves on its own, averaged over many simulated datasets, to expose the selection bias described in Section 8.1.

Show code
set.seed(624)

split_improvement <- function(n = 500) {
  d <- data.frame(
    coarse2    = sample(0:1,   n, replace = TRUE),
    medium10   = sample(1:10,  n, replace = TRUE),
    fine100    = sample(1:100, n, replace = TRUE),
    continuous = rnorm(n)
  )
  d$y <- rnorm(n)

  preds <- c("coarse2", "medium10", "fine100", "continuous")
  sapply(preds, function(p) {
    fit <- rpart(reformulate(p, "y"), data = d,
                 control = rpart.control(maxdepth = 1, cp = 0, minsplit = 2))
    if (nrow(fit$frame) == 1) 0 else unname(fit$splits[1, "improve"])
  })
}

imp_mat <- replicate(1000, split_improvement())

avg_imp <- data.frame(
  Predictor        = rownames(imp_mat),
  Mean_Improvement = rowMeans(imp_mat)
)
avg_imp <- avg_imp[order(-avg_imp$Mean_Improvement), ]

avg_imp |>
  flextable_defaults(digits = 3)

Predictor

Mean_Improvement

continuous

0.011

fine100

0.009

medium10

0.006

coarse2

0.002

The mean SSE reduction increases steadily with the number of distinct values. The continuous predictor earns the largest average reduction, followed by the 100-value predictor, the 10-value predictor, and finally the two-value predictor. None of the predictors carries any real signal (the response is pure noise), so this ordering is produced entirely by differing granularities. More candidate split points give the exhaustive search more chances to lower SSE by chance alone.

Approach: We let all four predictors compete simultaneously by fitting a one-split tree on all predictors and recording which is chosen at the root across many simulations.

Show code
root_var <- function(n = 500) {
  d <- data.frame(
    coarse2    = sample(0:1,   n, replace = TRUE),
    medium10   = sample(1:10,  n, replace = TRUE),
    fine100    = sample(1:100, n, replace = TRUE),
    continuous = rnorm(n)
  )
  d$y <- rnorm(n)
  fit <- rpart(y ~ ., data = d,
               control = rpart.control(maxdepth = 1, cp = 0, minsplit = 2))
  as.character(fit$frame$var[1])
}

set.seed(624)
roots <- replicate(2000, root_var())

root_tbl <- as.data.frame(prop.table(table(roots)))
names(root_tbl) <- c("Predictor", "Selection_Frequency")
root_tbl <- root_tbl[order(-root_tbl$Selection_Frequency), ]

root_tbl |>
  flextable_defaults(digits = 3)

Predictor

Selection_Frequency

continuous

0.530

fine100

0.336

medium10

0.114

coarse2

0.020

Show code
ggplot(root_tbl, aes(x = reorder(Predictor, Selection_Frequency),
                     y = Selection_Frequency)) +
  geom_col(fill = proj_teal) +
  coord_flip() +
  labs(x = NULL, y = "Fraction of simulations",
       title = "Root Split Selection by Predictor Granularity") +
  theme_proj()

The continuous predictor wins the root split in the large majority of simulations, followed by the 100-value predictor, with the 10-value and 2-value predictors selected only rarely. An unbiased splitter would choose each predictor roughly one quarter of the time. The stark departure from that baseline confirms the selection bias. When a real problem mixes an informative but coarse predictor with granular noise variables, the noise variables can dominate the early splits, distorting both the tree structure and variable importance rankings. This is one motivation for the ensemble methods and unbiased importance measures discussed later in the chapter.


Exercise KJ 8.3

In stochastic gradient boosting the bagging fraction and learning rate will govern the construction of the trees as they are guided by the gradient. Although the optimal values of these parameters should be obtained through the tuning process, it is helpful to understand how the magnitudes of these parameters affect magnitudes of variable importance. Figure 8.24 provides the variable importance plots for boosting using two extreme values for the bagging fraction (0.1 and 0.9) and the learning rate (0.1 and 0.9) for the solubility data. The left-hand plot has both parameters set to 0.1, and the right-hand plot has both set to 0.9:

  1. Why does the model on the right focus its importance on just the first few of predictors, whereas the model on the left spreads importance across more predictors?

  2. Which model do you think would be more predictive of other samples?

  3. How would increasing interaction depth affect the slope of predictor importance for either model in Fig. 8.24?

Code and Discussion

Approach: We reproduce the pattern of Figure 8.24 by fitting two boosting models at the extreme parameter settings on the solubility training data, then compare their variable importance distributions.

Show code
data(solubility)

set.seed(624)
gbm_left <- gbm.fit(x = solTrainXtrans, y = solTrainY,
                    distribution = "gaussian",
                    n.trees = 1000, interaction.depth = 7,
                    shrinkage = 0.1, bag.fraction = 0.1,
                    verbose = FALSE)

set.seed(624)
gbm_right <- gbm.fit(x = solTrainXtrans, y = solTrainY,
                     distribution = "gaussian",
                     n.trees = 1000, interaction.depth = 7,
                     shrinkage = 0.9, bag.fraction = 0.9,
                     verbose = FALSE)

imp_left  <- summary(gbm_left,  plotit = FALSE)
imp_right <- summary(gbm_right, plotit = FALSE)

# predictors needed to reach 90% of total relative influence
n_for_90 <- function(imp) {
  share <- cumsum(imp$rel.inf) / sum(imp$rel.inf)
  which(share >= 0.90)[1]
}

data.frame(
  Model = c("Bagging 0.1, rate 0.1", "Bagging 0.9, rate 0.9"),
  Predictors_for_90pct = c(n_for_90(imp_left), n_for_90(imp_right))
) |>
  flextable() |>
  fontsize(size = 9, part = "all") |>
  autofit()

Model

Predictors_for_90pct

Bagging 0.1, rate 0.1

91

Bagging 0.9, rate 0.9

17

Show code
top_n <- 25

p_left <- ggplot(head(imp_left, top_n),
                 aes(x = reorder(var, rel.inf), y = rel.inf)) +
  geom_col(fill = proj_navy) +
  coord_flip() +
  labs(x = NULL, y = "Relative influence",
       title = "Bagging 0.1, learning rate 0.1") +
  theme_proj() +
  theme(axis.text.y = element_text(size = 6))

p_right <- ggplot(head(imp_right, top_n),
                  aes(x = reorder(var, rel.inf), y = rel.inf)) +
  geom_col(fill = proj_teal) +
  coord_flip() +
  labs(x = NULL, y = "Relative influence",
       title = "Bagging 0.9, learning rate 0.9") +
  theme_proj() +
  theme(axis.text.y = element_text(size = 6))

p_left + p_right

The reproduction shows a similar contrast to Figure 8.24. The 0.9 model concentrates its relative influence on a small set of predictors with a sharp drop-off, while the 0.1 model distributes importance more evenly across a wider set. The 90% influence table makes this concrete: the 0.9 model reaches most of its total influence with far fewer predictors than the 0.1 model.

(a) The concentration in the right model comes from both parameters acting in the same direction. A large learning rate of 0.9 means each tree makes a large correction, so the first few trees seize on the strongest predictors and absorb most of the residual variation immediately, leaving little for remaining predictors to explain. A large bagging fraction of 0.9 means every tree sees nearly the same data, so there is little randomness from one tree to the next and the same dominant predictors keep getting chosen. On the left, the small learning rate forces the model to improve in small steps over many trees, giving more predictors a chance to contribute, while the small bagging fraction adds randomness that surfaces different predictors in different subsamples.

(b) The left model, with both parameters at 0.1, should generalize better to new samples. A small learning rate and small bagging fraction both act as regularization. Section 8.6 notes that a small learning rate generally gives better results at the cost of needing more trees, and subsampling reduces variance in the spirit of bagging. The right model learns aggressively and leans on a few predictors, which is the profile of a model that has fit the training set too closely.

(c) Increasing interaction depth allows each tree to split on more predictors and capture higher-order interactions, which distributes importance across a larger set of predictors. The importance curve would become less top-heavy and its slope would flatten. This applies to both models, though the left model would still spread importance more widely since the learning rate and bagging fraction continue to push the two models in opposite directions.


Exercise KJ 8.4

Use a single predictor in the solubility data, such as the molecular weight or the number of carbon atoms and fit several models:

  1. A simple regression tree

  2. A random forest model

  3. Different Cubist models with a single rule or multiple committees (each with and without using neighbor adjustments)

Plot the predictor data versus the solubility results for the test set. Overlay the model predictions for the test set. How do the models differ? Does changing the tuning parameter(s) significantly affect the model fit?

Code and Discussion

Approach: We first load the solubility data and examine the relationship between molecular weight and solubility in the training set using a scatter plot and correlation coefficient as an exploratory step.

Show code
data(solubility)

sol_train <- data.frame(MolWeight = solTrainX$MolWeight, Solubility = solTrainY)
sol_test  <- data.frame(MolWeight = solTestX$MolWeight,  Solubility = solTestY)

ggplot(sol_train, aes(x = MolWeight, y = Solubility)) +
  geom_point(alpha = 0.4, color = proj_teal) +
  geom_smooth(formula = 'y ~ x', method = "loess", 
              se = FALSE, color = proj_navy) +
  labs(title = "Molecular Weight vs. Solubility (Training Set)",
       subtitle = 
         str_glue(
           "Correlation = {round(with(sol_train, cor(MolWeight, Solubility)), 2)}"),
       x = "Molecular Weight", y = "Solubility") +
  theme_proj()

Molecular weight and solubility show a moderate negative correlation of -0.63 in the training set, meaning heavier molecules tend to be less soluble. The scatter plot shows this decline is steepest at lower molecular weights and flattens in the mid-range. The smoother turns upward beyond roughly 500, but this reflects sparse data at the high end rather than a genuine reversal in the relationship. The nonlinear shape and considerable scatter suggest that molecular weight alone will leave substantial unexplained variance, making this a useful single-predictor setting for comparing model flexibility.

Approach: We fit a regression tree to molecular weight alone using cross-validated tuning of the complexity parameter via caret. We plot the fitted tree structure to visualize the splits selected at the optimal complexity parameter.

Show code
set.seed(42)
tree_fit <- train(
  x = sol_train["MolWeight"],
  y = sol_train$Solubility,
  method = "rpart",
  tuneLength = 10,
  trControl = trainControl(method = "cv", number = 10)
)

tree_fit
CART 

951 samples
  1 predictor

No pre-processing
Resampling: Cross-Validated (10 fold) 
Summary of sample sizes: 858, 855, 858, 855, 855, 857, ... 
Resampling results across tuning parameters:

  cp           RMSE      Rsquared   MAE     
  0.004859809  1.522272  0.4488627  1.159522
  0.005682050  1.533461  0.4404321  1.168033
  0.007133559  1.546744  0.4293773  1.178841
  0.007232122  1.550068  0.4266471  1.178798
  0.007475879  1.550063  0.4264432  1.182600
  0.009950241  1.554583  0.4226496  1.190018
  0.012109468  1.579931  0.4049681  1.216951
  0.041878909  1.626988  0.3710822  1.249668
  0.044542785  1.658606  0.3459916  1.275819
  0.351234818  1.887350  0.2918740  1.464369

RMSE was used to select the optimal model using the smallest value.
The final value used for the model was cp = 0.004859809.
Show code
tree_fit$bestTune
           cp
1 0.004859809
Show code
rpart.plot(tree_fit$finalModel, type = 4, extra = 101, 
           cex = 0.6, fallen.leaves = FALSE)

The optimal complexity parameter was cp = 0.005, selected by 10-fold cross-validation, yielding a resampled RMSE of 1.52 and R-squared of 0.45. The fitted tree produces 15 terminal nodes despite having only one predictor, with all splits occurring on MolWeight thresholds. The root split at 186 separates lighter molecules (mean solubility -1.6) from heavier ones (mean -4.0), reflecting the strong negative association seen in the EDA. Subsequent splits concentrate in the 223 to 362 range, which corresponds to the densest and most variable region of the training data. Predicted values are step-wise constants within each leaf, so the tree can only approximate the smooth nonlinear trend through piecewise horizontal segments.

Approach: We fit a random forest model to molecular weight alone, tuning mtry via 10-fold cross-validation.

Show code
set.seed(42)
rf_fit <- train(
  x = sol_train["MolWeight"],
  y = sol_train$Solubility,
  method = "rf",
  tuneLength = 5,
  trControl = trainControl(method = "cv", number = 10),
  importance = TRUE
)

rf_fit
Random Forest 

951 samples
  1 predictor

No pre-processing
Resampling: Cross-Validated (10 fold) 
Summary of sample sizes: 858, 855, 858, 855, 855, 857, ... 
Resampling results:

  RMSE      Rsquared   MAE     
  1.428678  0.5378679  1.008299

Tuning parameter 'mtry' was held constant at a value of 2
Show code
rf_fit$bestTune
  mtry
1    2

With a single predictor, mtry was held constant at a value of 2 by caret’s internal defaults. No meaningful tuning occurred as a result. Despite this, the random forest outperformed the single regression tree, achieving a cross-validated RMSE of 1.43 and R-squared of 0.54. The improvement comes from averaging predictions across many bootstrap-sampled trees, which smooths out the step-wise discontinuities of any individual tree and reduces variance. The result is a more continuous approximation of the underlying MolWeight-solubility relationship.

Approach: We fit Cubist models across a grid of committee sizes and neighbor adjustments to assess how each configuration performs with a single predictor.

Show code
cubist_grid <- expand.grid(
  committees = c(1, 10, 50, 100),
  neighbors  = c(0, 5, 9)
)

set.seed(42)
cubist_fit <- train(
  x = sol_train["MolWeight"],
  y = sol_train$Solubility,
  method = "cubist",
  tuneGrid = cubist_grid,
  trControl = trainControl(method = "cv", number = 10)
)

cubist_fit
Cubist 

951 samples
  1 predictor

No pre-processing
Resampling: Cross-Validated (10 fold) 
Summary of sample sizes: 858, 855, 858, 855, 855, 857, ... 
Resampling results across tuning parameters:

  committees  neighbors  RMSE      Rsquared   MAE     
    1         0          1.522356  0.4532249  1.136696
    1         5          1.573246  0.4153572  1.204001
    1         9          1.570179  0.4167376  1.201302
   10         0          1.528830  0.4487691  1.148502
   10         5          1.581239  0.4074551  1.214565
   10         9          1.575593  0.4110185  1.209258
   50         0          1.532445  0.4453877  1.151049
   50         5          1.579300  0.4087642  1.212897
   50         9          1.577379  0.4096974  1.210562
  100         0          1.533710  0.4445243  1.151749
  100         5          1.579362  0.4086880  1.213067
  100         9          1.576982  0.4099382  1.210634

RMSE was used to select the optimal model using the smallest value.
The final values used for the model were committees = 1 and neighbors = 0.
Show code
cubist_fit$bestTune
  committees neighbors
1          1         0

The optimal Cubist configuration used a single committee and no neighbor adjustment, yielding a cross-validated RMSE of 1.52 and R-squared of 0.45. The margin between committee sizes was negligible as the results shifted with the random seed indicating that all configurations perform equivalently here. Given that, the simplest model is the natural choice. Neighbor adjustments consistently degraded performance across all committee levels, which is expected with a single predictor: nearby training cases in a one-dimensional predictor space are not diverse enough for instance-based correction to add value. Cubist performed comparably to the single regression tree but worse than the random forest in this setting.

Approach: We generate test set predictions from all three models, then overlay them on a scatter plot of observed solubility against molecular weight to compare how each model captures the relationship.

Show code
sol_test$tree_pred   <- predict(tree_fit,   newdata = sol_test["MolWeight"])
sol_test$rf_pred     <- predict(rf_fit,     newdata = sol_test["MolWeight"])
sol_test$cubist_pred <- predict(cubist_fit, newdata = sol_test["MolWeight"])

sol_test_sorted <- sol_test[order(sol_test$MolWeight), ]

pred_long <- tidyr::pivot_longer(
  sol_test_sorted,
  cols = c(tree_pred, rf_pred, cubist_pred),
  names_to = "Model",
  values_to = "Predicted"
) |>
  dplyr::mutate(Model = dplyr::recode(Model,
    tree_pred   = "Regression Tree",
    rf_pred     = "Random Forest",
    cubist_pred = "Cubist"
  ))

ggplot() +
  geom_point(data = sol_test_sorted,
             aes(x = MolWeight, y = Solubility),
             alpha = 0.3, color = proj_grey) +
  geom_line(data = pred_long,
            aes(x = MolWeight, y = Predicted, color = Model),
            linewidth = 0.9) +
  scale_color_manual(values = c(
    "Regression Tree" = proj_navy,
    "Random Forest"   = proj_teal,
    "Cubist"          = proj_orng
  )) +
  labs(title = "Model Predictions vs. Observed Solubility (Test Set)",
       x = "Molecular Weight", y = "Solubility") +
  theme_proj()

The three models tell visibly different stories about the MolWeight-solubility relationship. The regression tree produces clean horizontal steps at its split thresholds, approximating the downward trend through a coarse piecewise constant function. Cubist fits a single linear rule that decreases steadily across the range, the smoothest of the three models and arguably the most reasonable approximation of the broad trend given only one predictor. The random forest line is the most erratic, with sharp local fluctuations that reflect the fine-grained averaging of many trees over the training data’s MolWeight distribution. Despite producing the best cross-validated RMSE, the random forest prediction surface is notably noisy on the test set, with a single predictor and no smoothing mechanism, it captures local training structure rather than a generalizable trend. All three models struggle at the extremes where data is sparse, and none fully accounts for the considerable scatter around the trend, which is expected given that molecular weight alone explains only a share of solubility variation.

Approach: We evaluate each model on the test set using postResample() and summarize performance in a formatted table.

Show code
test_results <- bind_rows(
  postResample(pred = predict(tree_fit,   newdata = sol_test["MolWeight"]), obs = sol_test$Solubility),
  postResample(pred = predict(rf_fit,     newdata = sol_test["MolWeight"]), obs = sol_test$Solubility),
  postResample(pred = predict(cubist_fit, newdata = sol_test["MolWeight"]), obs = sol_test$Solubility)
) |>
  mutate(Model = c("Regression Tree", "Random Forest", "Cubist")) |>
  select(Model, RMSE, Rsquared, MAE)

test_results |>
  flextable_defaults(digits = 2)

Model

RMSE

Rsquared

MAE

Regression Tree

1.48

0.50

1.08

Random Forest

1.34

0.59

0.93

Cubist

1.54

0.49

1.10

On the test set, the random forest achieved the best performance across all three metrics with RMSE of 1.34, R-squared of 0.59, and MAE of 0.93. The regression tree came in second with RMSE of 1.48 and R-squared of 0.50, while Cubist performed worst with RMSE of 1.54 and R-squared of 0.49. The test set rankings are consistent with the cross-validated results. However, the metric advantage of the random forest should be interpreted alongside the overlay plot; its prediction surface is visibly erratic, reflecting fine-grained local averaging rather than a smooth generalizable trend. Cubist’s single linear rule loses on RMSE but produces the most stable and interpretable prediction surface of the three, which may be preferable in practice when the goal is understanding the MolWeight-solubility relationship rather than minimizing error alone. Overall, these results should be interpreted cautiously. All three models are constrained to a single predictor, and the remaining variance in solubility almost certainly requires additional molecular descriptors to explain.


Exercise KJ 8.7

Refer to Exercises 6.3 and 7.5 which describe a chemical manufacturing process. Use the same data imputation, data splitting, and pre-processing steps as before and train several tree-based models:

  1. Which tree-based regression model gives the optimal resampling and test set performance?

  2. Which predictors are most important in the optimal tree-based regression model? Do either the biological or process variables dominate the list? How do the top 10 important predictors compare to the top 10 predictors from the optimal linear and nonlinear models?

  3. Plot the optimal single tree with the distribution of yield in the terminal nodes. Does this view of the data provide additional knowledge about the biological or process predictors and their relationship with yield?

Code and Discussion

Approach: We recreate the data split and imputation from KJ 6.3 to keep this exercise self-contained.

Show code
data(ChemicalManufacturingProcess)

cmp_87 <- ChemicalManufacturingProcess

X_87 <- cmp_87 |> select(-Yield)
Y_87 <- cmp_87$Yield

set.seed(42)
train_idx_87 <- createDataPartition(Y_87, p = 0.8, list = FALSE)

X_train_87 <- X_87[train_idx_87, ]
X_test_87  <- X_87[-train_idx_87, ]
Y_train_87 <- Y_87[train_idx_87]
Y_test_87  <- Y_87[-train_idx_87]

Approach: We train four tree-based models: (1) a single regression tree, (2) random forest, (3) gradient boosted trees, and (4) Cubist, using 10-fold cross-validation, with the same preprocessing pipeline as KJ 6.3.

Show code
set.seed(42)
ctrl_87 <- trainControl(method = "cv", number = 10)

# single regression tree
rpart_fit_87 <- train(
  x = X_train_87,
  y = Y_train_87,
  method = "rpart",
  tuneLength = 10,
  trControl = ctrl_87,
  preProcess = c("knnImpute", "center", "scale", "nzv")
)

# random forest
rf_fit_87 <- train(
  x = X_train_87,
  y = Y_train_87,
  method = "rf",
  tuneLength = 5,
  trControl = ctrl_87,
  preProcess = c("knnImpute", "center", "scale", "nzv"),
  importance = TRUE
)

# gradient boosted trees
gbm_fit_87 <- train(
  x = X_train_87,
  y = Y_train_87,
  method = "gbm",
  tuneLength = 5,
  trControl = ctrl_87,
  preProcess = c("knnImpute", "center", "scale", "nzv"),
  verbose = FALSE
)

# Cubist
cubist_fit_87 <- train(
  x = X_train_87,
  y = Y_train_87,
  method = "cubist",
  tuneLength = 5,
  trControl = ctrl_87,
  preProcess = c("knnImpute", "center", "scale", "nzv")
)

# CV comparison table
cv_results_87 <- bind_rows(
  getTrainPerf(rpart_fit_87)  |> mutate(Model = "Regression Tree"),
  getTrainPerf(rf_fit_87)     |> mutate(Model = "Random Forest"),
  getTrainPerf(gbm_fit_87)    |> mutate(Model = "Gradient Boosting"),
  getTrainPerf(cubist_fit_87) |> mutate(Model = "Cubist")
) |>
  select(Model, TrainRMSE, TrainRsquared, TrainMAE) |>
  rename(RMSE = TrainRMSE, Rsquared = TrainRsquared, MAE = TrainMAE) |>
  arrange(RMSE)

cv_results_87 |>
  flextable_defaults(digits = 3)

Model

RMSE

Rsquared

MAE

Cubist

0.999

0.708

0.773

Gradient Boosting

1.090

0.661

0.830

Random Forest

1.091

0.686

0.864

Regression Tree

1.427

0.434

1.150

All three ensemble methods substantially outperformed the single regression tree, which had a CV RMSE of 1.43 and R-squared of 0.43, consistent with the well-known limitation of single trees on high-dimensional data. Cubist led with a CV RMSE of 1.00 and R-squared of 0.71, followed closely by gradient boosting (RMSE 1.09, R-squared 0.66) and random forest (RMSE 1.09, R-squared 0.69). The gap between Cubist and the two other ensemble methods is modest, while all three comfortably outperform the elastic net from KJ 6.3 (CV RMSE 1.18), suggesting the nonlinear flexibility of tree-based ensembles better captures the yield relationship than a regularized linear model.

Approach: We evaluate all four models on the held-out test set and compare against CV performance.

Show code
test_results_87 <- bind_rows(
  postResample(predict(rpart_fit_87, newdata = X_test_87), Y_test_87) |> 
    t() |> 
    as.data.frame() |> 
    mutate(Model = "Regression Tree"),
  postResample(predict(rf_fit_87, newdata = X_test_87), Y_test_87) |> 
    t() |> 
    as.data.frame() |> 
    mutate(Model = "Random Forest"),
  postResample(predict(gbm_fit_87, newdata = X_test_87), Y_test_87) |> 
    t() |> 
    as.data.frame() |> 
    mutate(Model = "Gradient Boosting"),
  postResample(predict(cubist_fit_87, newdata = X_test_87), Y_test_87) |> 
    t() |> 
    as.data.frame() |> 
    mutate(Model = "Cubist")
) |>
  select(Model, RMSE, Rsquared, MAE) |>
  arrange(RMSE)

test_results_87 |>
  flextable_defaults(digits = 3)

Model

RMSE

Rsquared

MAE

Cubist

0.897

0.814

0.687

Gradient Boosting

1.178

0.717

0.857

Random Forest

1.192

0.743

0.858

Regression Tree

1.618

0.354

1.181

On the test set, Cubist achieved the best performance across all metrics with RMSE of 0.90, R-squared of 0.81, and MAE of 0.69, confirming its lead from cross-validation. Gradient boosting and random forest were closely matched at RMSE 1.18 and 1.19 respectively, while the single regression tree trailed significantly at RMSE 1.62. The CV and test rankings are fully consistent, suggesting no meaningful overfitting across the ensemble methods. Cubist’s test R-squared of 0.81 exceeds its CV estimate of 0.71, which mirrors a similar pattern seen in KJ 6.3 and is best attributed to sampling variability in a 32-observation test set rather than any structural advantage. All three ensemble methods also outperformed the elastic net from KJ 6.3 (test RMSE 1.29), reinforcing that the nonlinear flexibility of tree-based models better captures the yield relationship in this data.

Approach: We extract variable importance from the best model and compare its top 10 predictors against the top 10 from the elastic net in KJ 6.3.

Show code
# importance from best model
best_fit_87 <- cubist_fit_87

imp_87 <- varImp(best_fit_87, scale = FALSE)

imp_df_87 <- imp_87$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  slice_head(n = 10) |>
  mutate(Type = case_when(
    grepl("^Biological", Predictor)           ~ "Biological",
    grepl("^ManufacturingProcess", Predictor) ~ "Process",
    .default = "Other"
  ))

# top 10 from elastic net in 6.3
imp_63_top10 <- en_imp$importance |>
  tibble::rownames_to_column("Predictor") |>
  arrange(desc(Overall)) |>
  slice_head(n = 10) |>
  select(Predictor)

comparison_df_87 <- data.frame(
  Rank        = 1:10,
  Tree_Based  = imp_df_87$Predictor,
  Elastic_Net = imp_63_top10$Predictor
)

comparison_df_87 |>
  flextable_defaults(digits = 3)

Rank

Tree_Based

Elastic_Net

1

ManufacturingProcess32

ManufacturingProcess32

2

ManufacturingProcess17

ManufacturingProcess09

3

ManufacturingProcess09

ManufacturingProcess17

4

ManufacturingProcess33

ManufacturingProcess36

5

ManufacturingProcess04

ManufacturingProcess13

6

BiologicalMaterial03

ManufacturingProcess06

7

BiologicalMaterial12

BiologicalMaterial03

8

ManufacturingProcess13

ManufacturingProcess34

9

ManufacturingProcess29

ManufacturingProcess39

10

ManufacturingProcess25

ManufacturingProcess45

Show code
# importance plot
ggplot(imp_df_87, aes(x = reorder(Predictor, Overall),
                      y = Overall, fill = Type)) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c(
    "Biological" = proj_teal,
    "Process"    = proj_navy,
    "Other"      = proj_grey
  )) +
  labs(title = "Top 10 Predictors by Importance (Best Tree Model)",
       x = NULL, y = "Importance") +
  theme_proj()

ManufacturingProcess32 leads importance in both models by a clear margin, making it the most robust signal in the data regardless of modeling approach. ManufacturingProcess09, 13, and 17 appear in both top 10s as well, confirming these as consistently important process variables. The most notable difference between the two models is the role of biological predictors. The elastic net shrinks them to near-zero with one exception (BiologicalMaterial03 at rank 7), while Cubist places two biological predictors in its top 10 (BiologicalMaterial03 at rank 6 and BiologicalMaterial12 at rank 7). This suggests the elastic net’s sparsity penalty discounts biological predictors relative to their actual contribution, and that Cubist’s rule-based approach captures nonlinear structure that makes biological material quality more visible. Process predictors still dominate both lists, but the Cubist results reinforce that incoming raw material quality carries meaningful signal that a purely linear model may understate.

Approach: We plot the optimal single regression tree with yield distributions in the terminal nodes to assess whether the tree structure reveals additional insight about the predictors.

Show code
rpart_party_87 <- as.party(rpart_fit_87$finalModel)

plot(rpart_party_87, gp = gpar(fontsize = 7))

The optimal regression tree uses only three predictors across four terminal nodes, producing a remarkably interpretable structure given 56 available predictors. ManufacturingProcess32 forms the root split at 0.18, cleanly separating the training data into a lower-yield group (56%) and a higher-yield group (44%). Within the low-Process32 branch, ManufacturingProcess17 provides a secondary split, runs with very low Process17 (below -0.685) shift upward to a median yield around 41 despite belonging to the low-Process32 group, though this node contains only 15 observations and should be interpreted cautiously. Within the high-Process32 branch, ManufacturingProcess13 refines further, runs with very low Process13 (below -0.852) cluster into the highest-yield node with a median around 42 and the tightest distribution of the four nodes.

The tree structure reinforces the variable importance findings from both KJ 6.3 and the Cubist model: Process32 is the dominant lever, and the negative relationships for Process17 and Process13 observed in the scatter plots carry through into the tree’s splitting logic. For the manufacturing team, this suggests that maintaining Process32 above its scaled threshold while keeping Process13 at lower operating levels is associated with the highest yield outcomes.


Exercise: Recommendation System

You are a data scientist for FreshMart, a mid-size regional grocery chain with 12 stores across Central Texas. Leadership wants to improve:

  • Product placement
  • Cross-selling
  • Promotional bundling
  • In-aisle signage

FreshMart IT has provided you with a simple random sample of 6,000 transactions from one month of activity. Each row in the file represents a single customer transaction and contains:

  • Transaction ID: from T0001 to T6000
  • Items purchased: all products bought in that visit (between 1 and 12 items per basket)

For this assignment, FreshMart has limited the products to their top 40 grocery items of interest for merchandising analysis.

Your task: Perform a market basket analysis using association rule mining. Your goal is to:

  1. Identify meaningful product affinities
  2. Recommend three actionable merchandising strategies based on your findings

Deliverables

1. Exploratory Analysis

Include at least:

  • Number of transactions
  • Distribution of basket sizes (e.g., histogram or summary table)
  • Top 10 most frequent items (with counts and/or relative frequency)

2. Association Rule Mining

Note: You may select a minimum threshold, but if you do be explicit about what it is and why it was selected.

Using appropriate tools (e.g., arules in R), compute support, confidence, lift, and conviction for your rules. Identify top rules by lift and by confidence. Discuss whether the strongest rules appear meaningful (business-plausible, actionable) or spurious (artifacts of the data, too trivial, or not useful).

Be explicit about any thresholds you choose (e.g., minimum support, minimum confidence) and justify them briefly.

3. Business Recommendations

Propose three concrete merchandising actions for FreshMart, such as:

  • Product placement (e.g., adjacency in aisles, end-caps)
  • Bundles (e.g., “buy X, get Y at discount”)
  • Promotions or signage (e.g., co-featured items in weekly ads or in-store displays)

For each recommendation, reference specific rules (or item affinities) that support your idea and explain why this action could improve cross-selling, basket size, or customer experience.

4. Analysis Recommendations

(i) Data improvements: What additional data (e.g., time of day, store location, price, promotions, customer segments) would you recommend collecting or using to improve merchandising decisions?

(ii) Methodological improvements: What alternative or complementary techniques (e.g., clustering, sequence analysis, uplift modeling, price elasticity analysis) might help FreshMart make better merchandising decisions beyond basic association rules?

Code and Discussion

Approach: We load the FreshMart transaction data in Python, parse each basket into individual items, and compute basic EDA metrics including transaction count, basket size distribution, and top 10 item frequencies.

Show code
import numpy as np
import pandas as pd
from collections import Counter

df = pd.read_csv("../resources/FreshMart_MarketBasket_6000.txt")

# parse items
df["items_list"] = df["items"].str.split(",").apply(
    lambda x: [i.strip() for i in x]
)

n_transactions = len(df)
basket_sizes = df["items_list"].apply(len)

all_items = [item for basket in df["items_list"] for item in basket]
item_counts = Counter(all_items)

top10 = pd.DataFrame(
    item_counts.most_common(10),
    columns=["Item", "Frequency"]
)
top10["Support"] = top10["Frequency"] / n_transactions

basket_size_df = basket_sizes.value_counts().reset_index()
basket_size_df.columns = ["Basket_Size", "Count"]
basket_size_df = basket_size_df.sort_values("Basket_Size")

print(f"Transactions: {n_transactions}")
Transactions: 5999
Show code
print(f"Unique items: {len(item_counts)}")
Unique items: 39
Show code
print(f"Basket size range: {basket_sizes.min()} to {basket_sizes.max()}")
Basket size range: 1 to 5
Show code
print(f"Mean basket size: {basket_sizes.mean():.2f}")
Mean basket size: 2.60
Show code
print(f"Missing values: {df.isnull().sum().sum()}")
Missing values: 0
Show code
print(basket_size_df.to_string(index=False))
 Basket_Size  Count
           1    160
           2   2888
           3   2177
           4    754
           5     20

The dataset contains 5,999 transactions covering 39 unique products, with no missing values. The assignment description references 6,000 transactions. The one-record discrepancy traces to a transaction ID anomaly (T00114) that has no effect on the analysis, since transaction IDs play no role in the rule mining.

Basket sizes range from 1 to 5 items with a mean of 2.60, well below the 12-item maximum described in the assignment. Purchases of 2 and 3 items account for roughly 84% of all transactions, suggesting most FreshMart customers make targeted rather than comprehensive shopping trips. Single-item baskets (160 transactions) and 5-item baskets (20 transactions) are both uncommon. This compact basket structure means the Apriori algorithm will primarily surface pairwise and small-group item relationships rather than complex multi-item bundles.

Approach: We plot the basket size distribution and top 10 item frequencies.

Show code
basket_dist <- py_to_r_df(py$basket_size_df)

ggplot(basket_dist, aes(x = Basket_Size, y = Count)) +
  geom_col(fill = proj_teal) +
  scale_x_continuous(breaks = seq(1, 12, 1)) +
  labs(title = "Distribution of Basket Sizes",
       x = "Number of Items", y = "Number of Transactions") +
  theme_proj()

Show code
top10_r <- py_to_r_df(py$top10)

ggplot(top10_r, aes(x = reorder(Item, Frequency), y = Frequency)) +
  geom_col(fill = proj_navy) +
  coord_flip() +
  labs(title = "Top 10 Most Frequently Purchased Items",
       x = NULL, y = "Frequency") +
  theme_proj()

Show code
top10_r |>
  flextable_defaults(digits = 4)

Item

Frequency

Support

Tomatoes

1,061

0.1769

Cheese

881

0.1469

Bananas

879

0.1465

Milk

691

0.1152

Chicken

680

0.1134

Apples

678

0.1130

Pasta

660

0.1100

Ground Beef

660

0.1100

Coffee

660

0.1100

Beans

570

0.0950

Tomatoes are the most frequently purchased item at 17.7% support, ahead of Cheese and Bananas which are nearly tied at around 14.7%. Items ranked 4 through 10 form a tight cluster between 9.5% and 11.5%, with Pasta, Ground Beef, and Coffee sharing an identical frequency of 660 transactions. The basket size distribution confirms that most FreshMart customers make small, targeted trips as 2-item baskets are the most common at 2,888 transactions, followed closely by 3-item baskets at 2,177. Five-item baskets account for only 20 transactions. High individual item frequency does not necessarily correspond to strong purchasing relationships between items, for example, Tomatoes appear frequently but may not anchor the strongest association rules. That distinction becomes clear in the rule mining step.

Approach: We convert transactions to a binary matrix, run the Apriori algorithm with a minimum support of 0.05, and compute association rules with support, confidence, lift, and conviction.

We set a minimum support threshold of 0.05, meaning a product or product combination must appear in at least 5% of all transactions to be considered frequent. This threshold is a deliberate balance: too low and the algorithm surfaces thousands of spurious rules driven by noise; too high and meaningful but moderately common patterns get excluded. At 5% this translates to roughly 300 transactions, which is a reasonable floor for a dataset of this size.

Show code
from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import apriori, association_rules

te = TransactionEncoder()
te_array = te.fit(df["items_list"]).transform(df["items_list"])
transaction_matrix = pd.DataFrame(te_array, columns=te.columns_)

frequent_itemsets = apriori(
    transaction_matrix,
    min_support=0.05,
    use_colnames=True
)

rules = association_rules(
    frequent_itemsets,
    metric="confidence",
    min_threshold=0.5,
    num_itemsets=len(frequent_itemsets)
)

# conviction
rules["conviction"] = (
    (1 - rules["consequent support"]) /
    (1 - rules["confidence"]).replace(0, np.nan)
)

rules["antecedents"] = rules["antecedents"].apply(lambda x: ", ".join(sorted(x)))
rules["consequents"] = rules["consequents"].apply(lambda x: ", ".join(sorted(x)))

rules_clean = rules[[
    "antecedents", "consequents",
    "support", "confidence", "lift", "conviction"
]].copy()

top_by_lift       = rules_clean.sort_values("lift",       ascending=False).head(10).reset_index(drop=True)
top_by_confidence = rules_clean.sort_values("confidence", ascending=False).head(10).reset_index(drop=True)

Approach: We display the top rules by lift and by confidence as formatted tables.

Show code
lift_df <- py_to_r_df(py$top_by_lift)
conf_df <- py_to_r_df(py$top_by_confidence)

cat("Top 10 Rules by Lift\n")
Top 10 Rules by Lift
Show code
lift_df |> 
  mutate(conviction = ifelse(is.na(conviction), Inf, conviction)) |>
  flextable_defaults(digits = 3)

antecedents

consequents

support

confidence

lift

conviction

Salsa, Tortillas

Cheese, Ground Beef

0.055

0.914

16.618

11.005

Cheese, Ground Beef

Salsa, Tortillas

0.055

1.000

16.618

Inf

Carrots, Tomatoes

Lettuce, Onions

0.057

0.971

13.553

32.491

Lettuce, Onions

Carrots, Tomatoes

0.057

0.791

13.553

4.499

Tortillas

Cheese, Ground Beef, Salsa

0.055

0.716

13.013

3.326

Tortillas

Cheese, Ground Beef

0.055

0.716

13.013

3.326

Cheese, Ground Beef

Tortillas

0.055

1.000

13.013

Inf

Tortillas

Cheese, Salsa

0.055

0.718

13.013

3.350

Cheese, Salsa

Tortillas

0.055

1.000

13.013

Inf

Cheese, Ground Beef, Salsa

Tortillas

0.055

1.000

13.013

Inf

Show code
cat("Top 10 Rules by Confidence\n")
Top 10 Rules by Confidence
Show code
conf_df |> 
  mutate(conviction = ifelse(is.na(conviction), Inf, conviction)) |>
  flextable_defaults(digits = 3)

antecedents

consequents

support

confidence

lift

conviction

Oranges

Apples

0.078

1.000

8.848

Inf

Beans

Chicken

0.095

1.000

8.822

Inf

Onions, Tomatoes

Lettuce

0.072

1.000

10.713

Inf

Cheese, Salsa

Tortillas

0.055

1.000

13.013

Inf

Carrots, Lettuce, Onions

Tomatoes

0.057

1.000

5.654

Inf

Cheese, Ground Beef, Tortillas

Salsa

0.055

1.000

12.737

Inf

Bananas, Oranges

Apples

0.060

1.000

8.848

Inf

Beans, Rice

Chicken

0.077

1.000

8.822

Inf

Carrots, Tomatoes

Lettuce

0.058

1.000

10.713

Inf

Bananas, Cereal

Milk

0.067

1.000

8.682

Inf

The top rules by lift center on two distinct meal patterns. The taco cluster, i.e. Ground Beef, Cheese, Salsa, and Tortillas, produces the highest lift values around 16.6, meaning customers who buy any subset of these items are roughly 17 times more likely to buy the remaining items than random chance would predict. The salad cluster, i.e. Lettuce, Onions, Carrots, and Tomatoes produces lift values around 13.6. These are not coincidences in the data; they reflect real meal-based purchasing behavior.

The top rules by confidence are dominated by rules where confidence equals 1.0, meaning every transaction in the dataset that contained the antecedent also contained the consequent. This is a data statement, not an absolute law; a customer could walk in tomorrow and buy Oranges without buying Apples. It simply means the co-occurrence was perfect within these observed transactions.

Conviction adds a directional dimension that lift does not. Lift is symmetric as in the lift of Carrots + Tomatoes -> Lettuce + Onions is identical to its reverse, but conviction is not. A conviction of 32.5 for Carrots + Tomatoes -> Lettuce + Onions means this directional association is far stronger than random chance would produce. Rules with confidence of 1.0 produce undefined (infinite) conviction because the formula divides by zero when confidence reaches its maximum, which is mathematically correct rather than a data quality issue. The strongest finite conviction values in the lift table belong to the taco and salad clusters, reinforcing that these are the most robust purchasing relationships in the data.

Approach: We propose three concrete merchandising strategies grounded in the strongest association rules.

Show code
taco_lift   <- round(lift_df[lift_df$antecedents == "Cheese, Ground Beef" &
                              lift_df$consequents == "Salsa, Tortillas", "lift"], 1)
salad_lift  <- round(lift_df[lift_df$antecedents == "Lettuce, Onions" &
                              lift_df$consequents == "Carrots, Tomatoes", "lift"], 1)
cereal_conf <- format(round(conf_df[conf_df$antecedents == "Bananas, Cereal" &
                              conf_df$consequents == "Milk", "confidence"], 2), nsmall = 2)
cereal_lift <- round(conf_df[conf_df$antecedents == "Bananas, Cereal" &
                              conf_df$consequents == "Milk", "lift"], 1)

recs <- data.frame(
  Recommendation = c(
    "Taco meal end-cap",
    "Salad ingredients co-location",
    "Cereal and banana bundle"
  ),
  Rule = c(
    paste0("Cheese + Ground Beef -> Salsa + Tortillas (lift ", taco_lift, ")"),
    paste0("Lettuce + Onions -> Carrots + Tomatoes (lift ", salad_lift, ")"),
    paste0("Bananas + Cereal -> Milk (confidence ", cereal_conf,
           ", lift ", cereal_lift, ")")
  ),
  Action = c(
    "Feature Tortillas, Salsa, and Cheese on a dry goods end-cap with in-aisle signage directing customers to the meat counter for Ground Beef",
    "Stock Lettuce, Onions, Tomatoes, and Carrots in adjacent produce bins with co-location signage",
    "Cross-merchandise Cereal with Bananas and Milk via shelf signage or a buy-together discount"
  ),
  Benefit = c(
    "Converts single-item trips into full meal purchases, increasing basket size",
    "Reduces shopper search time and encourages customers to complete their salad ingredient purchase",
    "Drives incremental produce and dairy sales from the cereal aisle with minimal placement cost"
  )
)

recs |>
  flextable_defaults() |>
  set_table_properties(layout = "autofit") |>
  flextable::width(width = c(1, 1.5, 2.5, 2))

Recommendation

Rule

Action

Benefit

Taco meal end-cap

Cheese + Ground Beef -> Salsa + Tortillas (lift 16.6)

Feature Tortillas, Salsa, and Cheese on a dry goods end-cap with in-aisle signage directing customers to the meat counter for Ground Beef

Converts single-item trips into full meal purchases, increasing basket size

Salad ingredients co-location

Lettuce + Onions -> Carrots + Tomatoes (lift 13.6)

Stock Lettuce, Onions, Tomatoes, and Carrots in adjacent produce bins with co-location signage

Reduces shopper search time and encourages customers to complete their salad ingredient purchase

Cereal and banana bundle

Bananas + Cereal -> Milk (confidence 1.00, lift 8.7)

Cross-merchandise Cereal with Bananas and Milk via shelf signage or a buy-together discount

Drives incremental produce and dairy sales from the cereal aisle with minimal placement cost

The three recommendations above are grounded directly in the strongest rules from the analysis.

The taco cluster produces the highest lift in the dataset at 16.6, meaning customers buying any subset of these items are roughly 17 times more likely to complete the meal purchase than random chance would predict. Since Ground Beef is a refrigerated product and cannot be physically co-located with dry goods, the practical implementation is a Tortillas, Salsa, and Cheese end-cap in the dry goods aisle paired with in-aisle signage directing customers to the meat counter for Ground Beef. This keeps the recommendation actionable within store layout constraints while still capitalizing on the strongest affinity in the data.

The salad cluster recommendation follows the same logic. Lettuce, Onions, Tomatoes, and Carrots co-occur with a lift of 13.6 and near-perfect confidence in both directions, suggesting these are consistently purchased as a set. Placing them in adjacent produce bins reduces friction for customers assembling a salad and increases the likelihood of a complete basket purchase.

The cereal and banana bundle targets a different type of affinity: a breakfast occasion rather than a meal kit. Bananas and Cereal co-occurring with Milk at confidence 1.00 and lift 8.7 indicates a strong and reliable breakfast basket pattern. Cross-merchandising Cereal with Bananas and Milk via shelf signage or a bundle discount targets this pattern at a low implementation cost, since the association is strong enough to justify the placement without needing a dedicated end-cap.

Approach: We visualize the top association rules as a directed network graph (using igraph), with edge thickness and color representing lift, to illustrate the strength and direction of the strongest product affinities in the data. We then discuss data and methodological improvements that could extend FreshMart’s merchandising analytics beyond basic association rules.

Show code
lift_df <- py_to_r_df(py$top_by_lift)

edges <- lift_df |>
  select(antecedents, consequents, lift) |>
  mutate(
    from = antecedents,
    to   = consequents
  )


nodes <- unique(c(lift_df$antecedents, lift_df$consequents))


g <- graph_from_data_frame(
  d        = edges |> select(from, to, lift),
  vertices = data.frame(name = nodes),
  directed = TRUE
)


coords <- layout_in_circle(g)
coords_df <- data.frame(
  name = nodes,
  x    = coords[, 1],
  y    = coords[, 2]
)

edge_df <- edges |>
  left_join(coords_df, by = c("from" = "name")) |>
  rename(x_from = x, y_from = y) |>
  left_join(coords_df, by = c("to" = "name")) |>
  rename(x_to = x, y_to = y)

ggplot() +
  geom_segment(data = edge_df,
               aes(x = x_from, y = y_from,
                   xend = x_to, yend = y_to,
                   linewidth = lift, color = lift),
               alpha = 0.6,
               arrow = arrow(length = unit(0.3, "cm"))) +
  geom_point(data = coords_df,
             aes(x = x, y = y),
             size = 2, color = proj_navy) +
  geom_text_repel(data = coords_df,
                  aes(x = x, y = y, label = name),
                  size = 2.5, color = proj_navy,
                  box.padding = 1.5,
                  point.padding = 0.5,
                  force = 10,
                  max.overlaps = Inf,
                  segment.color = proj_grey) +
  scale_color_gradient(low = proj_teal, high = proj_navy, name = "Lift") +
  scale_linewidth(range = c(0.5, 2.5), guide = "none") +
  labs(title = "Association Rule Network (Top Rules by Lift)") +
  theme_void() +
  theme(
    plot.title = element_text(face = "bold", color = proj_navy, size = 13),
    legend.position = "bottom"
  )

The network graph makes the two dominant purchasing clusters immediately visible. The taco cluster on the right is the more densely connected of the two. Tortillas acts as a hub with edges flowing to and from Cheese, Ground Beef, Salsa, and their combinations, all at lift values above 13. The thick dark edge between Cheese, Ground Beef and Salsa, Tortillas represents the strongest rule in the dataset at lift 16.6. The salad cluster on the left is simpler. A bidirectional relationship between Lettuce, Onions and Carrots, Tomatoes at lift 13.6 with high confidence in both directions.

On data improvements, the current dataset is limited to transaction IDs and item lists. Adding time of day and day of week would allow FreshMart to target promotions when specific purchasing patterns are most active. For example, taco ingredients may cluster on weekends while breakfast baskets like Cereal, Bananas, and Milk may peak on weekday mornings. Store location data across the 12 Central Texas locations would reveal whether affinities differ by neighborhood demographics, allowing location-specific planograms rather than a single chain-wide layout. Price and promotional flags would allow the team to distinguish organic affinities from promotion-driven co-purchases, which is critical for evaluating whether a bundling strategy reflects genuine preference or just a past discount.

On methodology, association rules describe co-occurrence but not causation or customer-level behavior. Sequence analysis would extend the current approach by identifying whether certain items tend to be purchased before others across multiple trips, which could inform loyalty program design. Uplift modeling would go further by estimating which customers are likely to add an item to their basket specifically because of a promotion, rather than those who would have bought it anyway. Customer segmentation via clustering would allow FreshMart to tailor recommendations by shopper type. For instance, a customer who regularly buys fresh produce has different cross-sell potential than one whose basket is primarily shelf-stable goods, though collecting the customer-level data needed for segmentation raises privacy considerations that would need to be addressed before implementation. Price elasticity analysis would complement the rule mining by identifying which bundled items are most sensitive to discounting, helping leadership prioritize which of the three recommendations above is likely to generate the highest incremental revenue per promotional dollar spent.