19  Regularization in rtemis

setup_GLMNET() fits elastic net models with the glmnet package (Friedman et al. 2010), and setup_SPLS() fits sparse partial least squares with the spls package. This chapter fits penalized models to both datasets, returns to the expanded diabetes models of Section 5.6, and fits a multinomial model.

19.1 Configuration

setup_GLMNET()
<GLMNETHyperparameters>
        hyperparameters: 
                                   alpha: <nmr> 1.00
                         which_lambda_cv: <chr> lambda.1se
                                 nlambda: <int> 100
                             standardize: <lgc> TRUE
                               intercept: <lgc> TRUE
                                     ifw: <lgc> TRUE
                  unset: <chr> family, lambda, penalty_factor, lambda.min, lambda.1se
tunable_hyperparameters: <int> 2
  fixed_hyperparameters: <int> 9
                  tuned: <int> 0
              resampled: <int> 0
              n_workers: <int> 1

  Hyperparameter lambda needs tuning.

alpha is the elastic net mixing parameter of Equation 18.3, 1 for the lasso and 0 for ridge regression. When lambda is not set, train() calls glmnet::cv.glmnet() on the training data, which fits the full regularization path and estimates the error of each \(\lambda\) by internal 10-fold cross-validation; which_lambda_cv selects either the value with the minimum error ("lambda.min") or the largest value within one standard error of it ("lambda.1se", the default) (Section 18.5). This selection uses only the training cases: within an outer cross-validation, it is repeated in every training fold. standardize = TRUE standardizes the features internally and reports coefficients on the original scale. ifw applies inverse frequency weights to the classes in classification, and is TRUE by default; its effect is the subject of Chapter 41, and the classification examples below set it to FALSE.

19.2 The lasso on the diabetes data

db <- read_diabetes()
mod_lasso <- train(
  db,
  hyperparameters = setup_GLMNET(alpha = 1, which_lambda_cv = "lambda.min"),
  execution_config = setup_SerialExecution(seed = 2026L),
  question = diabetes_question,
  verbosity = 0L
)
mod_lasso@hyperparameters@hyperparameters[["lambda"]]
[1] 0.6408
coef(mod_lasso@model)
11 x 1 sparse Matrix of class "dgCMatrix"
                   s0
(Intercept) -263.5199
age            .     
sex2         -20.0760
bmi            5.6486
bp             1.0494
s1            -0.1959
s2             .     
s3            -0.7180
s4             1.6406
s5            47.5098
s6             0.2450

The fitted model records the selected \(\lambda\). Features with a coefficient shown as . have been set to zero. The glmnet object stores the full path, so the number of nonzero coefficients can be read at every \(\lambda\):

path <- glmnet::glmnet(
  model.matrix(progression ~ ., data = db)[, -1],
  db[["progression"]],
  alpha = 1
)
draw_line(
  log(path[["lambda"]]),
  list(`Nonzero coefficients` = path[["df"]]),
  points = FALSE,
  xlab = "log(lambda)",
  ylab = "Number of nonzero coefficients"
)

19.3 Large model classes

Section 5.6 showed that an unpenalized linear model with all two- or three-way interactions of the diabetes features overfits. The same expanded feature sets are fitted here with the lasso and assessed by 10-fold cross-validation, next to the unpenalized model:

expand_features <- function(dat, order) {
  formula <- if (order == 1L) {
    progression ~ .
  } else {
    as.formula(paste0("progression ~ (.)^", order))
  }
  data.table(model.matrix(formula, data = dat)[, -1], progression = dat[["progression"]])
}
cv <- setup_KFold(n_resamples = 10L, seed = 2026L)
learners <- list(
  GLM = setup_GLM(),
  Lasso = setup_GLMNET(alpha = 1, which_lambda_cv = "lambda.min")
)
expanded <- rbindlist(lapply(1:3, function(k) {
  dat <- expand_features(db, k)
  rbindlist(lapply(names(learners), function(learner) {
    mod <- train(
      dat,
      hyperparameters = learners[[learner]],
      outer_resampling_config = cv,
      execution_config = setup_SerialExecution(seed = 2026L),
      verbosity = 0L
    )
    data.table(
      terms = factor(
        c("Main effects", "Two-way", "Three-way")[k],
        levels = c("Main effects", "Two-way", "Three-way")
      ),
      n_features = ncol(dat) - 1L,
      learner = learner,
      rsq_test = mod@metrics_test@mean_metrics[["rsq"]],
      mean_nonzero = mean(vapply(mod@models, function(m) {
        sum(as.numeric(coef(m@model))[-1] != 0)
      }, numeric(1)))
    )
  }))
}))
dcast(expanded, terms + n_features ~ learner, value.var = "rsq_test")
Key: <terms, n_features>
          terms n_features     GLM  Lasso
         <fctr>      <int>   <num>  <num>
1: Main effects         10  0.4935 0.4928
2:      Two-way         55  0.4557 0.4940
3:    Three-way        175 -0.6197 0.4947

The unpenalized model’s cross-validated \(R^2\) falls from 0.493 with main effects to -0.620 with three-way interactions. The lasso’s stays between 0.493 and 0.495 for all three feature sets. With 175 candidate terms and about 400 training cases per fold, the lasso keeps on average 12.2 nonzero coefficients and shrinks them, so that the enlarged model class does not translate into enlarged variance. The interactions add little predictive information in these data; the lasso recovers the performance of the main-effects model from a feature set in which least squares fails.

19.4 Penalized logistic regression

For the heart failure data, ridge and lasso logistic regression are compared with the two rules for choosing \(\lambda\):

hf <- read_heart_failure()
configs <- list(
  `Ridge, lambda.min` = setup_GLMNET(alpha = 0, which_lambda_cv = "lambda.min", ifw = FALSE),
  `Ridge, lambda.1se` = setup_GLMNET(alpha = 0, which_lambda_cv = "lambda.1se", ifw = FALSE),
  `Lasso, lambda.min` = setup_GLMNET(alpha = 1, which_lambda_cv = "lambda.min", ifw = FALSE),
  `Lasso, lambda.1se` = setup_GLMNET(alpha = 1, which_lambda_cv = "lambda.1se", ifw = FALSE)
)
hf_penalized <- rbindlist(lapply(names(configs), function(nm) {
  mod <- train(
    hf,
    hyperparameters = configs[[nm]],
    outer_resampling_config = cv,
    execution_config = setup_SerialExecution(seed = 2026L),
    verbosity = 0L
  )
  data.table(
    model = nm,
    mod@metrics_test@mean_metrics[c("auc", "brier_score", "sensitivity")]
  )
}), use.names = TRUE)
hf_penalized
               model    auc brier_score sensitivity
              <char>  <num>       <num>       <num>
1: Ridge, lambda.min 0.7208      0.1369     0.05333
2: Ridge, lambda.1se 0.7182      0.1513     0.00000
3: Lasso, lambda.min 0.6979      0.1387     0.09333
4: Lasso, lambda.1se 0.5635      0.1512     0.00000

With \(\lambda\) at the minimum of the internal cross-validated error, ridge regression reaches a mean test AUC of 0.721, compared with 0.709 for the unpenalized logistic regression with the same folds (Section 15.5). The one-standard-error rule, applied to training folds of about 260 patients and 49 deaths, selects larger values of \(\lambda\). For the lasso, it sets every coefficient to zero in 2 of the 10 training folds; such a model predicts the same probability for every patient, and its AUC on that fold is 0.5. Ridge regression shrinks without setting coefficients to zero; with the one-standard-error rule its mean Brier score, 0.151, is close to the 0.152 of a constant prediction equal to the prevalence.

19.5 Multinomial regression

setup_GLM() fits binary logistic regression only. For an outcome with more than two classes, setup_GLMNET() fits multinomial logistic regression. With the diabetes outcome divided into tertiles, as in Section 9.7:

db3 <- copy(db)
cuts <- quantile(db3[["progression"]], c(0, 1 / 3, 2 / 3, 1))
db3[, progression := cut(
  progression, cuts,
  include.lowest = TRUE, labels = c("Low", "Medium", "High")
)]
mod_multi <- train(
  db3,
  hyperparameters = setup_GLMNET(which_lambda_cv = "lambda.min", ifw = FALSE),
  outer_resampling_config = cv,
  execution_config = setup_SerialExecution(seed = 2026L)
)
<Resampled Classification Model>
GLMNET (Elastic net (glmnet))
⚙ Tuned using exhaustive grid search.
⟳ Tested using 10 independent folds.

  <Resampled Classification Training Metrics>
  Aggregate Confusion Matrix across resamples.
                     Predicted
          Reference  Low  Medium  High  
                Low  942     311    79
             Medium  478     450   395
               High   70     304   949

  Showing mean (sd) across resamples.
  Balanced Accuracy: 0.588 (0.011)
                 F1: 0.578 (0.012)
           Accuracy: 0.588 (0.011)

  <Resampled Classification Test Metrics>
  Aggregate Confusion Matrix across resamples.
                     Predicted
          Reference  Low  Medium  High  
                Low  103      37     8
             Medium   54      48    45
               High    8      35   104

  Showing mean (sd) across resamples.
  Balanced Accuracy: 0.577 (0.095)
                 F1: 0.566 (0.092)
           Accuracy: 0.577 (0.095)

The model has one coefficient vector per class, and predict() returns one probability per class:

multi_coefs <- do.call(cbind, lapply(coef(mod_multi@models[[1]]@model), as.matrix))
colnames(multi_coefs) <- levels(db3[["progression"]])
multi_coefs
                 Low Medium      High
(Intercept)  8.24838  2.848 -11.09590
age          0.00000  0.000   0.00000
sex2         0.22432  0.000  -0.26175
bmi         -0.05942  0.000   0.14518
bp          -0.01480  0.000   0.03209
s1           0.00000  0.000   0.00000
s2           0.00000  0.000   0.00000
s3           0.03202  0.000   0.00000
s4           0.00000  0.000   0.00000
s5          -0.97536  0.000   1.44589
s6           0.00000  0.000   0.00000
head(predict(mod_multi@models[[1]], features(db3)))
      Low Medium    High
1 0.08178 0.3000 0.61823
2 0.76640 0.2140 0.01964
3 0.18498 0.4149 0.40011
4 0.21303 0.4906 0.29641
5 0.43720 0.4284 0.13439
6 0.61008 0.3321 0.05781

19.6 Sparse partial least squares

setup_SPLS() configures the number of latent components k and the sparsity threshold eta:

mod_spls <- train(
  db,
  hyperparameters = setup_SPLS(k = 2L, eta = 0.5),
  outer_resampling_config = cv,
  execution_config = setup_SerialExecution(seed = 2026L),
  question = diabetes_question,
  verbosity = 0L
)
mod_spls@metrics_test@mean_metrics
    mae  mse rmse    rsq
1 44.56 3044   55 0.4828

k and eta are fixed here. Choosing them, like choosing \(\lambda\), is a problem of hyperparameter tuning, the subject of Chapter 20.