Exercises 6.2 and 6.3 from Kuhn & Johnson, Applied Predictive Modeling, Chapter 6 (Linear Regression and Its Cousins).
data(permeability)
dim(fingerprints)
## [1] 165 1107
summary(permeability[, 1])
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.06 1.55 4.91 12.24 15.47 55.60
fingerprints has 165 compounds and 1107 binary
substructure predictors; permeability is the response.
nzv <- nearZeroVar(fingerprints)
fp <- fingerprints[, -nzv]
c(removed = length(nzv), remaining = ncol(fp))
## removed remaining
## 719 388
nearZeroVar removes 719 sparse fingerprints, leaving 388
predictors for modeling.
set.seed(624)
perm <- permeability[, 1]
in_train <- createDataPartition(perm, p = 0.8, list = FALSE)
x_train <- fp[in_train, ]; y_train <- perm[in_train]
x_test <- fp[-in_train, ]; y_test <- perm[-in_train]
ctrl <- trainControl(method = "repeatedcv", number = 10, repeats = 5)
set.seed(624)
pls_fit <- train(
x_train, y_train, method = "pls",
preProcess = c("center", "scale"),
tuneLength = 20, trControl = ctrl
)
pls_fit$bestTune
## ncomp
## 9 9
best_pls <- pls_fit$results[pls_fit$results$ncomp == pls_fit$bestTune$ncomp, ]
best_pls[, c("ncomp", "RMSE", "Rsquared", "MAE")]
## ncomp RMSE Rsquared MAE
## 9 9 11.82483 0.4737577 9.141689
plot(pls_fit, metric = "Rsquared", main = "PLS: resampled R-squared by components")
Resampling (10-fold CV, repeated 5 times) picks 9 latent variables, with a resampled \(R^2\) of 0.474 (RMSE 11.82). Past that point extra components start to fit noise and the resampled \(R^2\) levels off and drops.
pls_pred <- predict(pls_fit, x_test)
postResample(pls_pred, y_test)
## RMSE Rsquared MAE
## 10.656898 0.530305 8.388575
The test-set \(R^2\) is 0.53 (RMSE 10.66), close to the resampled estimate. The test set has only 32 compounds, so it is a noisy check and the resampled number is the more reliable one.
set.seed(624)
ridge_fit <- train(
x_train, y_train, method = "ridge",
preProcess = c("center", "scale"),
tuneGrid = data.frame(lambda = seq(0, 0.2, length.out = 11)),
trControl = ctrl
)
set.seed(624)
enet_fit <- train(
x_train, y_train, method = "glmnet",
preProcess = c("center", "scale"),
tuneGrid = expand.grid(alpha = c(0.1, 0.5, 1),
lambda = 10^seq(-3, 0.5, length.out = 20)),
trControl = ctrl
)
set.seed(624)
pcr_fit <- train(
x_train, y_train, method = "pcr",
preProcess = c("center", "scale"),
tuneLength = 30, trControl = ctrl
)
perm_models <- list(PLS = pls_fit, PCR = pcr_fit, Ridge = ridge_fit,
`Elastic net` = enet_fit)
perm_compare <- bind_rows(lapply(names(perm_models), function(m) {
fit <- perm_models[[m]]
cv <- getTrainPerf(fit)
test <- postResample(predict(fit, x_test), y_test)
tibble(model = m,
tuning = paste(names(fit$bestTune), signif(unlist(fit$bestTune), 3),
sep = " = ", collapse = ", "),
cv_RMSE = cv$TrainRMSE, cv_R2 = cv$TrainRsquared,
test_RMSE = test[["RMSE"]], test_R2 = test[["Rsquared"]])
}))
knitr::kable(perm_compare, digits = 3)
| model | tuning | cv_RMSE | cv_R2 | test_RMSE | test_R2 |
|---|---|---|---|---|---|
| PLS | ncomp = 9 | 11.825 | 0.474 | 10.657 | 0.530 |
| PCR | ncomp = 28 | 12.544 | 0.425 | 9.931 | 0.564 |
| Ridge | lambda = 0.12 | 11.869 | 0.483 | 11.855 | 0.468 |
| Elastic net | alpha = 0.1, lambda = 1.35 | 11.360 | 0.483 | 11.156 | 0.475 |
All four models land in the same range: resampled \(R^2\) between about 0.42 and 0.48 and RMSE between about 11.4 and 12.5. Elastic net has the lowest resampled RMSE and keeps 202 of the 388 fingerprints. PCR does best on the test set, but it needs 28 components and has the worst resampled RMSE, so its test result is most likely luck on 32 compounds. Because model choice is based on resampling, elastic net is the best of these, with PLS a close second.
best_perm <- perm_models[[perm_compare$model[which.min(perm_compare$cv_RMSE)]]]
tibble(observed = y_test, predicted = predict(best_perm, x_test)) |>
ggplot(aes(observed, predicted)) +
geom_point() +
geom_abline(linetype = "dashed") +
labs(title = "Test set: predicted vs observed permeability")
No. Even the best model has an RMSE of about 11 on a response whose median is 4.91, and it explains only about half of the variance. Its predictions are too rough to replace the lab measurement for individual compounds. It could still work as a cheap screen: on the test set every compound it predicted below 10 really was below 10, so it could rank candidates and send only the promising ones to the lab assay.
data(ChemicalManufacturingProcess)
chem <- ChemicalManufacturingProcess
dim(chem)
## [1] 176 58
table(sub("[0-9]+$", "", names(chem)[-1]))
##
## BiologicalMaterial ManufacturingProcess
## 12 45
miss <- colSums(is.na(chem))
c(columns_with_NA = sum(miss > 0), total_NA = sum(miss))
## columns_with_NA total_NA
## 28 106
head(sort(miss[miss > 0], decreasing = TRUE), 5)
## ManufacturingProcess03 ManufacturingProcess11 ManufacturingProcess10
## 15 10 9
## ManufacturingProcess25 ManufacturingProcess26
## 5 5
Missing values are filled with k-nearest-neighbors imputation
(knnImpute, k = 5) inside preProcess. The
imputation is learned from the training set only and then applied to the
test set, so no test information leaks into the model.
x_chem <- chem |> select(-Yield)
y_chem <- chem$Yield
set.seed(624)
in_train_c <- createDataPartition(y_chem, p = 0.8, list = FALSE)
xc_train_raw <- x_chem[in_train_c, ]; yc_train <- y_chem[in_train_c]
xc_test_raw <- x_chem[-in_train_c, ]; yc_test <- y_chem[-in_train_c]
pp <- preProcess(xc_train_raw, method = c("knnImpute", "nzv"))
xc_train <- predict(pp, xc_train_raw)
xc_test <- predict(pp, xc_test_raw)
c(NA_after_train = sum(is.na(xc_train)), NA_after_test = sum(is.na(xc_test)),
predictors = ncol(xc_train))
## NA_after_train NA_after_test predictors
## 0 0 56
pp$method$remove
## [1] "BiologicalMaterial07"
knnImpute also centers and scales every predictor, and
the nzv step drops the near-zero-variance column listed
above.
ctrl_c <- trainControl(method = "repeatedcv", number = 10, repeats = 5)
set.seed(624)
chem_pls <- train(xc_train, yc_train, method = "pls",
tuneLength = 20, trControl = ctrl_c)
set.seed(624)
chem_enet <- train(xc_train, yc_train, method = "glmnet",
tuneGrid = expand.grid(alpha = c(0.1, 0.5, 1),
lambda = 10^seq(-3, 0, length.out = 20)),
trControl = ctrl_c)
chem_cv <- bind_rows(
PLS = getTrainPerf(chem_pls),
`Elastic net` = getTrainPerf(chem_enet),
.id = "model"
)
knitr::kable(chem_cv[, c("model", "TrainRMSE", "TrainRsquared", "TrainMAE")], digits = 3)
| model | TrainRMSE | TrainRsquared | TrainMAE |
|---|---|---|---|
| PLS | 1.187 | 0.589 | 0.983 |
| Elastic net | 1.167 | 0.590 | 0.951 |
chem_pls$bestTune
## ncomp
## 4 4
chem_enet$bestTune
## alpha lambda
## 53 1 0.078476
plot(chem_pls, main = "PLS: resampled RMSE by components")
Both models are tuned with 10-fold CV repeated 5 times. PLS picks 4 components. Elastic net picks alpha = 1 (the lasso) with lambda = 0.0785. Elastic net has the best resampled performance: RMSE 1.167, \(R^2\) 0.59, slightly ahead of PLS.
chem_test <- bind_rows(
PLS = postResample(predict(chem_pls, xc_test), yc_test),
`Elastic net` = postResample(predict(chem_enet, xc_test), yc_test),
.id = "model"
)
knitr::kable(chem_test, digits = 3)
| model | RMSE | Rsquared | MAE |
|---|---|---|---|
| PLS | 1.596 | 0.500 | 1.077 |
| Elastic net | 1.171 | 0.729 | 0.894 |
On the test set the elastic net reaches RMSE 1.171 and \(R^2\) 0.729. That RMSE matches its resampled RMSE, and the \(R^2\) is higher, so the model generalizes well. PLS does worse on the test set (RMSE 1.596) than in resampling, which is more evidence for choosing the elastic net. With only 32 test runs, these numbers are noisy.
chem_best <- list(PLS = chem_pls, `Elastic net` = chem_enet)[[
chem_cv$model[which.min(chem_cv$TrainRMSE)]]]
imp <- varImp(chem_best)$importance |>
rownames_to_column("predictor") |>
select(predictor, importance = Overall) |>
arrange(desc(importance)) |>
mutate(type = ifelse(grepl("^Biological", predictor), "Biological", "Process"))
top_n_imp <- 15
imp |>
slice_head(n = top_n_imp) |>
ggplot(aes(importance, reorder(predictor, importance), fill = type)) +
geom_col() +
labs(title = "Top 15 predictors by importance", x = "Scaled importance", y = NULL)
table(head(imp$type, top_n_imp))
##
## Biological Process
## 2 13
Importance for the lasso model is the absolute size of each coefficient on the scaled predictors. The lasso keeps 20 of 56 predictors. Process variables dominate the list: 13 of the top 15 are manufacturing process variables, and the top 2 (ManufacturingProcess32 and 09) are well ahead of everything else. Only BiologicalMaterial06 (4th) and BiologicalMaterial05 (13th) make the top 15.
top_vars <- head(imp$predictor, 6)
cor_tbl <- sapply(top_vars, function(v) cor(xc_train[[v]], yc_train))
knitr::kable(tibble(predictor = top_vars, cor_with_yield = cor_tbl), digits = 3)
| predictor | cor_with_yield |
|---|---|
| ManufacturingProcess32 | 0.595 |
| ManufacturingProcess09 | 0.498 |
| ManufacturingProcess17 | -0.349 |
| BiologicalMaterial06 | 0.491 |
| ManufacturingProcess34 | 0.245 |
| ManufacturingProcess37 | -0.140 |
xc_train |>
select(all_of(top_vars)) |>
mutate(Yield = yc_train) |>
pivot_longer(-Yield, names_to = "predictor") |>
mutate(predictor = factor(predictor, levels = top_vars)) |>
ggplot(aes(value, Yield)) +
geom_point(alpha = 0.6) +
geom_smooth(method = "lm", se = FALSE) +
facet_wrap(~ predictor, scales = "free_x") +
labs(title = "Yield vs top predictors (training set, scaled)", x = "Scaled value")
ManufacturingProcess32 and 09 have the strongest positive relationships with yield (correlations of about 0.6 and 0.5), and ManufacturingProcess17 and 37 are negatively related. BiologicalMaterial06 is also positively related, but raw material quality is fixed before the run starts, so it mostly helps predict yield rather than control it.
Process variables can be changed, which makes them the useful finding. Running future batches with higher values of ManufacturingProcess32, 09 and 34, and lower values of 17 and 37, within their observed ranges, should raise yield. These are observational correlations, so the next step is a designed experiment on these few settings to confirm the effects are causal before changing the process permanently.