data(permeability)
dim(fingerprints) # compounds x binary predictors
## [1] 165 1107
length(permeability) # response, one value per compound
## [1] 165
perm <- as.numeric(permeability)
nzv_idx <- nearZeroVar(fingerprints)
fp <- fingerprints[, -nzv_idx, drop = FALSE]
n_removed <- length(nzv_idx)
n_left <- ncol(fp)
Answer (b). The default filter removes 719 predictors and leaves 388 across all compounds. This count describes the full dataset. For the models, I apply the filter separately to the fitting rows in each fold.
I split the original fingerprints into about 80% training and 20% test rows. All three models use the same 10 cross-validation folds and select tuning settings by the lowest mean RMSE.
set.seed(624)
train_idx <- createDataPartition(perm, p = 0.8, list = FALSE)
x_train <- fingerprints[train_idx, , drop = FALSE]
y_train <- perm[train_idx]
x_test <- fingerprints[-train_idx, , drop = FALSE]
y_test <- perm[-train_idx]
folds <- createFolds(y_train, k = 10, returnTrain = TRUE)
ctrl <- trainControl(method = "cv", index = folds)
fp_preprocess <- c("nzv", "center", "scale")
PLS combines fingerprints into latent components using their
relationship with permeability. I test 1 through 20 components. By
putting preProcess inside train(), each
validation fold receives the fingerprint filter, means, and standard
deviations learned from its fitting rows.
set.seed(624)
pls_fit <- train(x_train, y_train, method = "pls",
tuneLength = 20, trControl = ctrl,
preProcess = fp_preprocess, metric = "RMSE")
plot(pls_fit)
pls_best <- pls_fit$results[pls_fit$results$ncomp == pls_fit$bestTune$ncomp, ]
pls_best
## ncomp RMSE Rsquared MAE RMSESD RsquaredSD MAESD
## 11 11 11.14376 0.5423326 8.571912 3.30798 0.220232 2.592576
Answer (c). PLS selects 11 latent components by mean cross-validation (CV) RMSE. That setting gives a CV RMSE of 11.144 and a mean CV \(R^2\) of 0.542. The final PLS model retains 407 fingerprints when the filter is fitted on all 133 training compounds.
predict() applies the final model’s saved preprocessing
to the raw test fingerprints. The \(R^2\) reported by
postResample() is the squared correlation between observed
and predicted values.
pls_test <- postResample(predict(pls_fit, x_test), y_test)
pls_test
## RMSE Rsquared MAE
## 11.7321881 0.4541521 9.2002902
Answer (d). On 32 test compounds, PLS has a test \(R^2\) of 0.454 and a test RMSE of 11.732 permeability units.
Ridge regression and elastic net shrink the fingerprint coefficients. I tune both on the same folds as PLS and evaluate the selected settings on the same test compounds.
For a faster knit, install doParallel once in the
RStudio Console with install.packages("doParallel"). The
chunk below uses up to four local workers to run the cross-validation
fits concurrently.
fit_penalized_models <- function() {
cores <- parallel::detectCores(logical = FALSE)
backend <- foreach::getDoParName()
if (requireNamespace("doParallel", quietly = TRUE) &&
(is.null(backend) || backend == "doSEQ") && !is.na(cores) && cores > 1L) {
workers <- tryCatch(
parallel::makePSOCKcluster(min(4L, cores), setup_timeout = 15),
error = function(e) NULL
)
if (!is.null(workers)) {
on.exit({
foreach::registerDoSEQ()
parallel::stopCluster(workers)
})
parallel::clusterCall(workers, .libPaths, .libPaths())
doParallel::registerDoParallel(workers)
}
}
set.seed(624)
ridge <- train(x_train, y_train, method = "ridge",
tuneGrid = data.frame(lambda = seq(0.001, 0.2, length = 15)),
trControl = ctrl, preProcess = fp_preprocess, metric = "RMSE")
set.seed(624)
enet <- train(x_train, y_train, method = "enet",
tuneGrid = expand.grid(lambda = c(0.001, 0.01, 0.1),
fraction = seq(0.05, 1, length = 20)),
trControl = ctrl, preProcess = fp_preprocess, metric = "RMSE")
list(Ridge = ridge, `Elastic net` = enet)
}
penalized <- fit_penalized_models()
res <- resamples(c(list(PLS = pls_fit), penalized))
cv_stats <- summary(res, metric = c("RMSE", "Rsquared"))$statistics
test_tab <- rbind(
PLS = pls_test,
Ridge = postResample(predict(penalized$Ridge, x_test), y_test),
`Elastic net` = postResample(predict(penalized[["Elastic net"]], x_test), y_test)
)
cv_rmse <- cv_stats$RMSE[rownames(test_tab), "Mean"]
cv_r2 <- cv_stats$Rsquared[rownames(test_tab), "Mean"]
cv_winner <- names(which.min(cv_rmse))
test_winner <- rownames(test_tab)[which.min(test_tab[, "RMSE"])]
cv_winner_range <- range(res$values[[paste0(cv_winner, "~RMSE")]], na.rm = TRUE)
perm_scores <- data.frame(
Model = rownames(test_tab),
CV_RMSE = cv_rmse,
CV_R2 = cv_r2,
Test_RMSE = test_tab[, "RMSE"],
Test_R2 = test_tab[, "Rsquared"]
)
knitr::kable(perm_scores, digits = 3, row.names = FALSE,
col.names = c("Model", "CV RMSE", "CV R²", "Test RMSE", "Test R²"))
| Model | CV RMSE | CV R² | Test RMSE | Test R² |
|---|---|---|---|---|
| PLS | 11.144 | 0.542 | 11.732 | 0.454 |
| Ridge | 11.487 | 0.554 | 11.560 | 0.490 |
| Elastic net | 10.813 | 0.572 | 10.509 | 0.528 |
Finding (e). Elastic net has the lowest mean CV RMSE (10.813). Elastic net has the lowest test RMSE (10.509). The CV winner’s RMSE varies from 5.03 to 14.93 across folds, so I treat the model differences as estimates from this split.
summary(y_train)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.060 1.550 4.495 12.181 15.470 55.600
Finding for Exercise 6.2. I would keep the laboratory assay and use the CV-selected model to prioritize compounds for testing. Its test RMSE is 10.51 permeability units, compared with a training interquartile range of 13.92 units. That error matters when a prediction is near the screening cutoff. Replacing the assay would require an acceptable error threshold and independent validation on new compounds.
data(ChemicalManufacturingProcess)
x_all <- ChemicalManufacturingProcess[, -1] # 57 predictors
y_all <- ChemicalManufacturingProcess$Yield
dim(x_all)
## [1] 176 57
table(grepl("^Biological", names(x_all))) # 12 biological, 45 process
##
## FALSE TRUE
## 45 12
miss <- colSums(is.na(x_all))
miss[miss > 0]
## ManufacturingProcess01 ManufacturingProcess02 ManufacturingProcess03
## 1 3 15
## ManufacturingProcess04 ManufacturingProcess05 ManufacturingProcess06
## 1 1 2
## ManufacturingProcess07 ManufacturingProcess08 ManufacturingProcess10
## 1 1 9
## ManufacturingProcess11 ManufacturingProcess12 ManufacturingProcess14
## 10 1 1
## ManufacturingProcess22 ManufacturingProcess23 ManufacturingProcess24
## 1 1 1
## ManufacturingProcess25 ManufacturingProcess26 ManufacturingProcess27
## 5 5 5
## ManufacturingProcess28 ManufacturingProcess29 ManufacturingProcess30
## 5 5 5
## ManufacturingProcess31 ManufacturingProcess33 ManufacturingProcess34
## 5 5 5
## ManufacturingProcess35 ManufacturingProcess36 ManufacturingProcess40
## 5 5 1
## ManufacturingProcess41
## 1
missing_fraction <- mean(is.na(x_all))
missing_fraction
## [1] 0.01056619
Answer (b). The predictors contain 106 missing
cells, about 1.06% of all predictor cells. I use k-nearest-neighbor
imputation with \(k = 5\).
knnImpute centers and scales the predictors for distance
calculations, then uses nearby fitting rows to fill each missing
measurement.
chem_preprocess <- c("nzv", "knnImpute")
Both models fit their preprocessing inside cross-validation. This keeps the validation rows out of the scaling and imputation for their fold.
I tune two models from the chapter (PLS and elastic net) with identical folds and keep the one with the lower resampled RMSE.
set.seed(624)
tr <- createDataPartition(y_all, p = 0.8, list = FALSE)
x_train <- x_all[tr, , drop = FALSE]
y_train <- y_all[tr]
x_test <- x_all[-tr, , drop = FALSE]
y_test <- y_all[-tr]
folds <- createFolds(y_train, k = 10, returnTrain = TRUE)
ctrl <- trainControl(method = "cv", index = folds)
set.seed(624)
pls_chem <- train(x_train, y_train, method = "pls",
tuneLength = 20, trControl = ctrl,
preProcess = chem_preprocess, metric = "RMSE")
set.seed(624)
enet_chem <- train(x_train, y_train, method = "enet",
tuneGrid = expand.grid(lambda = c(0.001, 0.01, 0.1),
fraction = seq(0.05, 1, length = 20)),
trControl = ctrl, preProcess = chem_preprocess, metric = "RMSE")
chem_models <- list(PLS = pls_chem, `Elastic net` = enet_chem)
chem_cv <- do.call(rbind, lapply(chem_models, function(model) {
getTrainPerf(model)[c("TrainRMSE", "TrainRsquared")]
}))
best_name <- rownames(chem_cv)[which.min(chem_cv$TrainRMSE)]
best_fit <- chem_models[[best_name]]
best_res <- chem_cv[best_name, ]
knitr::kable(chem_cv, digits = 3, col.names = c("CV RMSE", "CV R²"))
| CV RMSE | CV R² | |
|---|---|---|
| PLS | 1.395 | 0.550 |
| Elastic net | 1.201 | 0.579 |
best_fit$bestTune
## fraction lambda
## 2 0.1 0.001
Answer (c). With 144 training runs and 32 held-out runs, Elastic net has the lowest mean CV RMSE, 1.201 percentage points of yield. Its mean CV \(R^2\) is 0.579.
test_processed <- predict(best_fit$preProcess, x_test)
c(Missing_before = sum(is.na(x_test)), Missing_after = sum(is.na(test_processed)))
## Missing_before Missing_after
## 7 0
test_perf <- postResample(predict(best_fit, x_test), y_test)
rmse_gap <- test_perf[["RMSE"]] - best_res$TrainRMSE
test_perf
## RMSE Rsquared MAE
## 1.2808703 0.7213274 0.9988901
Finding (d). The test RMSE is 1.281 percentage points. That is 0.08 points higher than the CV RMSE. Test \(R^2\) is 0.721, compared with 0.579 in CV. RMSE measures errors in the magnitude of the predictions, while \(R^2\) measures how closely predictions and observations move together. I interpret the gap in the context of one held-out split.
Some runs share a starting-material batch. The evaluation splits by run, so performance on entirely new batches would require batch IDs and a split that holds out whole batches. Dataset documentation
I rank predictors using the selected model. For elastic net, the
score is the absolute fitted coefficient at the selected tuning values.
The preprocessing puts the predictors on comparable scales. For PLS, the
score comes from the model-specific varImp() method.
if (best_name == "Elastic net") {
coefficients <- elasticnet::predict.enet(
best_fit$finalModel,
s = best_fit$bestTune$fraction,
mode = "fraction",
type = "coefficients"
)$coefficients
nonzero <- coefficients[coefficients != 0]
scores <- abs(nonzero)
} else {
pls_importance <- varImp(best_fit)$importance
scores <- setNames(pls_importance$Overall, rownames(pls_importance))
}
scores <- sort(scores, decreasing = TRUE)
vi <- data.frame(Predictor = names(scores), Score = unname(scores))
vi$Type <- ifelse(startsWith(vi$Predictor, "Biological"), "Biological", "Process")
top_vi <- head(vi, 10)
knitr::kable(top_vi, digits = 3, row.names = FALSE)
| Predictor | Score | Type |
|---|---|---|
| ManufacturingProcess32 | 0.760 | Process |
| ManufacturingProcess09 | 0.520 | Process |
| ManufacturingProcess34 | 0.098 | Process |
| ManufacturingProcess17 | 0.089 | Process |
| ManufacturingProcess06 | 0.085 | Process |
| BiologicalMaterial06 | 0.055 | Biological |
| ManufacturingProcess44 | 0.050 | Process |
| ManufacturingProcess36 | 0.043 | Process |
| ManufacturingProcess37 | 0.040 | Process |
| ManufacturingProcess11 | 0.033 | Process |
Finding (e). ManufacturingProcess32 ranks first. Of the top 10 predictors, 9 come from the process and 1 from the biological starting material. The full predictor pool contains 45 process measurements and 12 biological measurements, so there were more process measurements available to rank.
I plot the original measurements for the six highest-ranked predictors. The points are observed training runs. Each correlation uses the runs with that predictor measured, and the straight trend line summarizes the direction across its observed range.
top_names <- head(vi$Predictor, 6)
top_raw <- x_train[, top_names, drop = FALSE]
featurePlot(x = top_raw, y = y_train,
plot = "scatter", type = c("p", "r"),
labels = c("Recorded measurement", "Yield (%)"),
layout = c(3, 2), as.table = TRUE, between = list(x = 1, y = 1))
Original training measurements and yield for the six highest-ranked predictors.
correlations <- drop(cor(top_raw, y_train, use = "pairwise.complete.obs"))
relationships <- data.frame(
Predictor = top_names,
Observed_runs = colSums(!is.na(top_raw)),
Pearson_r = correlations,
Direction = ifelse(correlations > 0, "Positive",
ifelse(correlations < 0, "Negative", "Zero"))
)
knitr::kable(relationships, digits = 3, row.names = FALSE,
col.names = c("Predictor", "Observed runs", "Pearson r", "Direction"))
| Predictor | Observed runs | Pearson r | Direction |
|---|---|---|---|
| ManufacturingProcess32 | 144 | 0.595 | Positive |
| ManufacturingProcess09 | 144 | 0.498 | Positive |
| ManufacturingProcess34 | 139 | 0.241 | Positive |
| ManufacturingProcess17 | 144 | -0.349 | Negative |
| ManufacturingProcess06 | 142 | 0.393 | Positive |
| BiologicalMaterial06 | 144 | 0.491 | Positive |
Finding for Exercise 6.3. On this split,
ManufacturingProcess32 and
ManufacturingProcess09 have the largest fitted coefficients
in absolute value and show positive relationships with yield.
ManufacturingProcess17 trends downward, and
ManufacturingProcess34 has a narrower range of observed
values, with most runs near 2.5.
I would first check what ManufacturingProcess32 and
ManufacturingProcess09 measure and whether either is
adjustable. A controlled trial within the observed operating range,
accounting for starting-material batch, could test whether a change
raises yield. If a measurement is observed during production, it could
serve as a monitoring signal. Lower BiologicalMaterial06
readings tend to accompany lower yield, so this measurement could flag
incoming material for closer inspection after validation on new batches.
Dataset
documentation