LASSO Regression for Variable Selection

Marc Los Huertos

EA030 · Compiled 2026-10-24

Why LASSO? The Intuition The Math Dataset The OLS Problem Fitting LASSO Cross-Validation Interpretation Ridge & Elastic Net Diagnostics Reporting

🎯 Learning Outcomes

  1. Explain why ordinary least squares (OLS) fails when the number of predictors is large relative to sample size.
  2. Describe how the LASSO penalty shrinks coefficients and performs automatic variable selection.
  3. Prepare a predictor matrix and response vector in the format glmnet requires.
  4. Fit a LASSO model with glmnet() and interpret the coefficient path plot.
  5. Use cv.glmnet() to choose the penalty parameter lambda by cross-validation.
  6. Distinguish between lambda.min and lambda.1se and explain when each is appropriate.
  7. Compare LASSO, ridge, and elastic net regression and choose the right method for a given dataset.
  8. Extract, visualize, and report the selected predictors with confidence.
  9. Recognize common pitfalls: failing to standardize, ignoring correlated predictors, and over-interpreting selection results.

What this module covers. When you measure 15 water quality parameters at 60 sampling sites and want to know which ones predict dissolved oxygen, ordinary regression struggles. With many predictors relative to sample size, OLS overfits, coefficients become unstable, and you cannot tell which variables truly matter. LASSO regression solves this by adding a penalty that shrinks weak predictors exactly to zero, performing variable selection and model fitting in a single step. This tutorial teaches the complete workflow in R using the glmnet package, applied to a simulated water quality dataset.


1. Why LASSO?

In Module 11 (Multiple Regression), you learned to build models with several predictors and use AIC for model selection. AIC works well when you have a handful of candidate predictors and can enumerate plausible models. But environmental datasets often present a harder problem:

You measured everything. A water quality survey might record temperature, pH, conductivity, dissolved oxygen, turbidity, nitrate, phosphate, chlorophyll-a, fecal coliforms, TSS, BOD, COD, alkalinity, hardness, and flow velocity at each site. Which of these 14 predictors belongs in your model for, say, macroinvertebrate diversity?

With 14 predictors, there are \(2^{14} = 16{,}384\) possible models. Stepwise AIC can search this space, but it is greedy (tests one variable at a time), path-dependent (the order of entry matters), and prone to selecting spurious predictors. LASSO takes a fundamentally different approach: it fits all predictors simultaneously and lets the penalty decide which ones survive.

Three problems LASSO solves

1. Variable selection. LASSO shrinks unimportant coefficients to exactly zero, giving you a sparse model automatically. You do not need to specify which predictors to test.

2. Overfitting. By penalizing the total size of the coefficients, LASSO trades a small increase in bias for a large decrease in variance. The resulting model predicts new data better than OLS, especially when \(p\) (predictors) is large relative to \(n\) (observations).

3. Multicollinearity. When predictors are correlated (temperature and dissolved oxygen, nitrate and phosphate), OLS coefficients become unstable and inflated. LASSO stabilizes them by forcing the model to choose among correlated predictors rather than splitting the effect across all of them.

💡 When do you need LASSO? A useful rule of thumb: if you have more than 5 candidate predictors and fewer than 20 observations per predictor, standard OLS with stepwise selection is unreliable. LASSO becomes the better default. If you have only 2 to 4 predictors and ample data, stick with OLS and AIC from Module 11.


2. The Intuition: Penalizing Complexity

OLS finds the coefficients \(\hat\beta\) that minimize the residual sum of squares (RSS):

\[\text{OLS: } \quad \hat\beta = \arg\min_\beta \sum_{i=1}^{n} (y_i - \beta_0 - \sum_{j=1}^{p} \beta_j x_{ij})^2\]

This is the same objective function from Module 6 (Simple Linear Regression) and Module 11 (Multiple Regression). LASSO adds a single extra term: a penalty on the total absolute size of the coefficients.

\[\text{LASSO: } \quad \hat\beta = \arg\min_\beta \left[ \sum_{i=1}^{n} (y_i - \beta_0 - \sum_{j=1}^{p} \beta_j x_{ij})^2 \;+\; \lambda \sum_{j=1}^{p} |\beta_j| \right]\]

The penalty term \(\lambda \sum |\beta_j|\) makes the model pay a cost for every unit of coefficient magnitude. The parameter \(\lambda\) (lambda) controls how harsh the penalty is:

The art of LASSO is choosing the right \(\lambda\). Too small, and you overfit. Too large, and you throw away real signal. Cross-validation (Section 7) solves this automatically.

The penalty in words LASSO minimizes:
(prediction error) + lambda * (sum of |coefficients|)
A predictor stays in the model only if its contribution to reducing prediction error outweighs the penalty it incurs for having a nonzero coefficient.

Why absolute value matters

The absolute value (\(|\beta_j|\)) is what makes LASSO special. Because the absolute value function has a corner at zero, the optimization can land exactly at \(\beta_j = 0\) for weak predictors. This is automatic variable selection. Ridge regression (Section 9) uses \(\beta_j^2\) instead, which shrinks coefficients toward zero but never reaches it. The geometric reason involves the shape of the constraint region – LASSO’s diamond has corners on the axes; ridge’s circle does not.


3. The Mathematics (optional)

This section is for students who want the formal details. You can skip to Section 4 and still use LASSO effectively.

The Lagrangian form

The penalized objective is equivalent to a constrained optimization:

\[\min_\beta \; \text{RSS}(\beta) \quad \text{subject to} \quad \sum_{j=1}^{p} |\beta_j| \leq t\]

where \(t\) is a budget on the total coefficient size. Larger \(\lambda\) corresponds to smaller \(t\). The constraint boundary is a \(p\)-dimensional diamond (cross-polytope), and the OLS solution is a point that may lie inside or outside this diamond. If it lies outside, the LASSO solution is the point on the diamond boundary closest to the OLS solution. Because the diamond has corners on the coordinate axes, this point often has one or more coordinates exactly equal to zero.

Standardization

LASSO penalizes \(|\beta_j|\), so the scale of each predictor matters. A coefficient for temperature in Celsius will be numerically different from one for temperature in Fahrenheit, but the penalty treats them the same. To make the penalty fair, glmnet standardizes all predictors to have mean zero and unit variance before fitting. The reported coefficients are then back-transformed to the original scale.

⚠️ glmnet standardizes internally You do not need to standardize your predictors before passing them to glmnet(). The function does this automatically (controlled by the standardize = TRUE default). The coefficients it returns are on the original scale. However, if you want to compare coefficient magnitudes to judge relative importance, you should standardize the predictors yourself before fitting so the coefficients are on a common scale.


4. The Dataset: Southern California Stream Water Quality

We simulate a realistic water quality dataset based on the kinds of measurements collected in Southern California stream surveys. The response variable is the EPT index (count of Ephemeroptera, Plecoptera, and Trichoptera taxa), a standard measure of stream biological health. The predictors are 14 physicochemical parameters measured at 80 sites.

set.seed(2024)
n <- 80
p <- 14

# ── Predictors: 14 water quality parameters ──────────────────────────
wq <- tibble(
  temp_C        = rnorm(n, 18, 3.5),                       # water temp
  pH            = rnorm(n, 7.8, 0.4),                      # pH
  conductivity  = rnorm(n, 450, 120),                       # µS/cm
  turbidity     = rexp(n, rate = 0.15) + 2,                 # NTU (right-skewed)
  DO_mgl        = rnorm(n, 8.5, 1.2),                       # dissolved oxygen
  nitrate_mgl   = rexp(n, rate = 0.8) + 0.5,               # NO3-N mg/L
  phosphate_mgl = rexp(n, rate = 3) + 0.02,                 # PO4-P mg/L
  chlorophyll_a = rexp(n, rate = 0.4) + 1,                  # µg/L
  TSS_mgl       = rexp(n, rate = 0.1) + 5,                  # total suspended solids
  BOD_mgl       = rexp(n, rate = 0.5) + 1,                  # biochem. oxygen demand
  alkalinity    = rnorm(n, 120, 30),                         # mg/L CaCO3
  hardness      = rnorm(n, 140, 35),                         # mg/L CaCO3
  flow_velocity = rexp(n, rate = 2) + 0.1,                  # m/s
  canopy_cover  = runif(n, 5, 95)                            # % riparian canopy
)

# ── Response: EPT index driven by a subset of predictors ─────────────
# True model: EPT depends on DO, canopy, temperature, and turbidity.
# The other 10 predictors are noise (ecologically plausible but not
# driving EPT in this simulated system).
wq$EPT_index <- round(
  12 +
  1.8  * scale(wq$DO_mgl)[,1] +
  0.08 * wq$canopy_cover +
  -0.4 * wq$temp_C +
  -0.3 * log(wq$turbidity) +
  rnorm(n, 0, 2.5)
)
wq$EPT_index <- pmax(wq$EPT_index, 0)  # EPT cannot be negative

cat(sprintf("Dataset: %d sites, %d predictors, response = EPT index\n",
            n, p))
#> Dataset: 80 sites, 14 predictors, response = EPT index
glimpse(wq)
#> Rows: 80
#> Columns: 15
#> $ temp_C        <dbl> 21.43689, 19.64050, 17.62210, 17.25493, 22.05334, 22.523…
#> $ pH            <dbl> 7.302716, 8.196744, 8.145586, 7.097985, 7.859523, 8.2440…
#> $ conductivity  <dbl> 658.6731, 449.6914, 449.4643, 637.4392, 404.3393, 374.05…
#> $ turbidity     <dbl> 3.147661, 5.756025, 3.732767, 11.281261, 2.838026, 10.85…
#> $ DO_mgl        <dbl> 8.370215, 9.358569, 8.715802, 10.082300, 8.967574, 6.897…
#> $ nitrate_mgl   <dbl> 3.9840168, 1.7540296, 0.9381727, 0.5952357, 1.2856560, 1…
#> $ phosphate_mgl <dbl> 0.36298101, 0.67102646, 0.15071543, 1.57400884, 0.079897…
#> $ chlorophyll_a <dbl> 2.564961, 1.825786, 3.095036, 4.929253, 5.948068, 1.4798…
#> $ TSS_mgl       <dbl> 27.800263, 5.587642, 28.641526, 12.150912, 23.677037, 8.…
#> $ BOD_mgl       <dbl> 3.177013, 5.001692, 1.472451, 3.514508, 2.897887, 1.3567…
#> $ alkalinity    <dbl> 125.85388, 81.35107, 122.69828, 119.84976, 65.94935, 130…
#> $ hardness      <dbl> 121.90229, 207.57945, 163.35105, 184.78522, 105.48738, 1…
#> $ flow_velocity <dbl> 0.1225561, 0.3922651, 0.7778581, 1.9542676, 0.9083195, 0…
#> $ canopy_cover  <dbl> 26.341511, 90.770501, 78.430103, 88.659766, 16.096836, 7…
#> $ EPT_index     <dbl> 6, 11, 13, 13, 7, 0, 0, 8, 12, 5, 12, 7, 9, 9, 9, 10, 9,…

💡 Why simulated data? In simulated data, we know the truth: EPT depends on DO, canopy cover, temperature, and turbidity. The other 10 variables are noise. This lets us evaluate whether LASSO correctly identifies the true drivers. In a real analysis you would not know the answer in advance, which is exactly why the method is valuable.

wq |>
  select(EPT_index, DO_mgl, canopy_cover, temp_C, turbidity) |>
  pairs(
    pch = 20, col = adjustcolor("#4a7c5e", alpha.f = 0.5),
    main = "EPT Index vs. True Predictors",
    labels = c("EPT", "DO", "Canopy%", "Temp", "Turb")
  )
Scatterplot matrix of the response (EPT index) against the four true predictors. The relationships are visible but noisy, which is realistic for field data.

Scatterplot matrix of the response (EPT index) against the four true predictors. The relationships are visible but noisy, which is realistic for field data.


5. The OLS Problem: Why 14 Predictors is Too Many

Before fitting LASSO, let us see what happens when you naively fit OLS with all 14 predictors.

ols_full <- lm(EPT_index ~ ., data = wq)
summary(ols_full)
#> 
#> Call:
#> lm(formula = EPT_index ~ ., data = wq)
#> 
#> Residuals:
#>    Min     1Q Median     3Q    Max 
#> -4.744 -1.288 -0.042  1.263  5.051 
#> 
#> Coefficients:
#>                Estimate Std. Error t value Pr(>|t|)    
#> (Intercept)    1.494342   6.077769   0.246 0.806557    
#> temp_C        -0.252982   0.073164  -3.458 0.000966 ***
#> pH             0.153726   0.624361   0.246 0.806293    
#> conductivity  -0.002966   0.002456  -1.208 0.231605    
#> turbidity     -0.076730   0.049358  -1.555 0.124903    
#> DO_mgl         1.235070   0.228630   5.402 1.00e-06 ***
#> nitrate_mgl   -0.381139   0.298109  -1.279 0.205611    
#> phosphate_mgl  0.128826   0.803318   0.160 0.873089    
#> chlorophyll_a  0.030759   0.111875   0.275 0.784231    
#> TSS_mgl        0.058437   0.028998   2.015 0.048022 *  
#> BOD_mgl       -0.169065   0.131446  -1.286 0.202936    
#> alkalinity    -0.011495   0.008564  -1.342 0.184183    
#> hardness      -0.008308   0.007006  -1.186 0.239994    
#> flow_velocity  0.005058   0.562367   0.009 0.992851    
#> canopy_cover   0.087193   0.010846   8.039 2.49e-11 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 2.191 on 65 degrees of freedom
#> Multiple R-squared:  0.7348, Adjusted R-squared:  0.6776 
#> F-statistic: 12.86 on 14 and 65 DF,  p-value: 9.349e-14

Symptoms of overfitting

cat("Number of predictors:", length(coef(ols_full)) - 1, "\n")
#> Number of predictors: 14
cat("Number of observations:", nrow(wq), "\n")
#> Number of observations: 80
cat("Ratio n/p:", round(nrow(wq) / (length(coef(ols_full)) - 1), 1), "\n")
#> Ratio n/p: 5.7
cat("R-squared:", round(summary(ols_full)$r.squared, 3), "\n")
#> R-squared: 0.735
cat("Adjusted R-squared:", round(summary(ols_full)$adj.r.squared, 3), "\n")
#> Adjusted R-squared: 0.678

With 80 observations and 14 predictors, the ratio \(n/p \approx 5.7\). The model appears to fit well (high \(R^2\)), but much of this is overfitting. Several problems are visible in the summary:

Inflated standard errors. Correlated predictors (alkalinity and hardness, turbidity and TSS) inflate coefficient standard errors, making it hard to tell which predictors are truly important.

Spurious significance. With 14 predictors tested simultaneously, some will appear significant by chance alone (\(14 \times 0.05 = 0.7\) expected false positives at \(\alpha = 0.05\)).

Unstable coefficients. Remove a few data points and refit; the coefficients will change substantially. This instability is a hallmark of models that are too complex for the available data.

set.seed(123)
n_boot <- 200
boot_coefs <- matrix(NA, n_boot, p)
colnames(boot_coefs) <- names(coef(ols_full))[-1]

for (i in 1:n_boot) {
  idx <- sample(n, replace = TRUE)
  fit <- lm(EPT_index ~ ., data = wq[idx, ])
  boot_coefs[i, ] <- coef(fit)[-1]
}

# Standardize for display
boot_std <- scale(boot_coefs)

boot_long <- as_tibble(boot_std) |>
  pivot_longer(everything(), names_to = "predictor", values_to = "coef") |>
  mutate(
    true_driver = predictor %in% c("DO_mgl", "canopy_cover",
                                    "temp_C", "turbidity"),
    predictor = fct_reorder(predictor, coef, .fun = function(x) median(abs(x)),
                            .desc = TRUE)
  )

ggplot(boot_long, aes(predictor, coef, fill = true_driver)) +
  geom_boxplot(alpha = 0.7, outlier.size = 0.5, linewidth = 0.4) +
  geom_hline(yintercept = 0, linewidth = 0.4, colour = "#7a7a72") +
  scale_fill_manual(values = c("TRUE" = "#4a7c5e", "FALSE" = "#c8832a"),
                    labels = c("Noise", "True driver"),
                    name = NULL) +
  coord_flip() +
  labs(x = NULL, y = "Standardized coefficient (bootstrap distribution)",
       title = "OLS Coefficient Instability: 200 Bootstrap Samples") +
  theme_minimal(base_size = 11) +
  theme(
    plot.title      = element_text(family = "serif", face = "bold",
                                    colour = "#1e3a2f"),
    legend.position = "bottom"
  )
Coefficient instability under bootstrap resampling. Each boxplot shows the distribution of an OLS coefficient across 200 bootstrap samples. Truly important predictors (DO, canopy, temperature, turbidity) should have distributions concentrated away from zero. Noise predictors should span zero.

Coefficient instability under bootstrap resampling. Each boxplot shows the distribution of an OLS coefficient across 200 bootstrap samples. Truly important predictors (DO, canopy, temperature, turbidity) should have distributions concentrated away from zero. Noise predictors should span zero.


6. Fitting LASSO with glmnet

The glmnet package requires data in matrix form, not formula syntax. This is the single most common source of errors for students coming from lm().

Preparing the data

# Predictor matrix (no intercept, no response)
x <- as.matrix(wq |> select(-EPT_index))

# Response vector
y <- wq$EPT_index

cat("Dimensions of x:", nrow(x), "x", ncol(x), "\n")
#> Dimensions of x: 80 x 14
cat("Length of y:", length(y), "\n")
#> Length of y: 80

⚠️ glmnet does not accept formulas Unlike lm(EPT_index ~ ., data = wq), glmnet() requires a numeric matrix x and a numeric vector y. Use model.matrix(~ . - 1, data = …) if your data contains factors – this creates dummy variables automatically. Our dataset is all numeric, so as.matrix() suffices.

The coefficient path

When you call glmnet(), it fits the model across a sequence of 100 lambda values (by default), from the largest lambda that sets all coefficients to zero down to a small lambda that is close to OLS. The result is a coefficient path showing how each predictor enters the model as the penalty relaxes.

lasso_fit <- glmnet(x, y, alpha = 1)  # alpha = 1 is LASSO
plot(lasso_fit, xvar = "lambda", label = TRUE,
     col = rep(c("#1e3a2f", "#4a7c5e", "#8aad96", "#c8832a",
                 "#7a3a2f", "#2d5a3d", "#e8a84a"), 2),
     lwd = 1.5)
title("LASSO Coefficient Path", line = 2.5,
      font.main = 2, col.main = "#1e3a2f")
mtext("Numbers at left identify each predictor", side = 1, line = 4,
      cex = 0.8, col = "#7a7a72")
LASSO coefficient path. Each colored line is one predictor. As lambda decreases (left to right), predictors enter the model one at a time. The four true drivers (DO, canopy cover, temperature, turbidity) enter first and have the largest coefficients. Noise predictors remain near zero or enter late.

LASSO coefficient path. Each colored line is one predictor. As lambda decreases (left to right), predictors enter the model one at a time. The four true drivers (DO, canopy cover, temperature, turbidity) enter first and have the largest coefficients. Noise predictors remain near zero or enter late.

💡 Reading the coefficient path The x-axis is log(lambda). Moving left to right, the penalty decreases. The numbers at the top of the plot show how many predictors have nonzero coefficients at each lambda value. Predictors that enter early (at high lambda) are the most important – they provide enough predictive value to justify the penalty. Predictors that enter late or stay near zero are candidates for removal.


7. Choosing Lambda by Cross-Validation

The coefficient path shows the full solution set, but you need to pick one lambda to use. K-fold cross-validation selects the lambda that minimizes out-of-sample prediction error.

set.seed(42)
cv_fit <- cv.glmnet(x, y, alpha = 1, nfolds = 10)
plot(cv_fit, col = "#1e3a2f", lwd = 1.5)
title("10-Fold Cross-Validation: Choosing Lambda", line = 2.5,
      font.main = 2, col.main = "#1e3a2f")
Cross-validation curve. The y-axis is mean squared error estimated by 10-fold CV. The left dashed line is lambda.min (lowest error). The right dashed line is lambda.1se (simplest model within one standard error of the minimum). The numbers at the top show how many predictors are retained at each lambda.

Cross-validation curve. The y-axis is mean squared error estimated by 10-fold CV. The left dashed line is lambda.min (lowest error). The right dashed line is lambda.1se (simplest model within one standard error of the minimum). The numbers at the top show how many predictors are retained at each lambda.

lambda.min vs. lambda.1se

cat("lambda.min:", round(cv_fit$lambda.min, 4), "\n")
#> lambda.min: 0.1162
cat("lambda.1se:", round(cv_fit$lambda.1se, 4), "\n")
#> lambda.1se: 0.5147

cv.glmnet() reports two special lambda values:

lambda.min is the lambda that minimizes the cross-validated MSE. This gives the best prediction accuracy, but may retain some noise predictors.

lambda.1se is the largest lambda whose MSE is within one standard error of the minimum. This is a more parsimonious model – it trades a tiny bit of prediction accuracy for a simpler, more interpretable model. For environmental science, where the goal is usually understanding (which variables matter?) rather than pure prediction, lambda.1se is the better default.

✅ Rule of thumb: use lambda.1se for interpretation, lambda.min for prediction If your goal is to identify which water quality parameters drive EPT richness, use lambda.1se. If your goal is to build the most accurate prediction of EPT at new sites, use lambda.min. When in doubt, report both.


8. Interpreting the Selected Model

Extracting coefficients

# Coefficients at lambda.1se
coefs_1se <- coef(cv_fit, s = "lambda.1se")
coefs_min <- coef(cv_fit, s = "lambda.min")

# Convert to tidy data frame
tidy_coefs <- function(coef_obj, label) {
  cf <- as.matrix(coef_obj)
  tibble(
    predictor   = rownames(cf),
    coefficient = cf[, 1],
    model       = label
  ) |>
    filter(predictor != "(Intercept)")
}

coef_df <- bind_rows(
  tidy_coefs(coefs_1se, "lambda.1se"),
  tidy_coefs(coefs_min, "lambda.min")
) |>
  mutate(selected = coefficient != 0)
# lambda.1se results
coef_1se_display <- coef_df |>
  filter(model == "lambda.1se") |>
  arrange(desc(abs(coefficient))) |>
  mutate(
    status = ifelse(selected, "Selected", "Dropped"),
    true_driver = predictor %in% c("DO_mgl", "canopy_cover",
                                    "temp_C", "turbidity")
  )

kable(coef_1se_display |>
        select(predictor, coefficient, status, true_driver),
      digits = 4,
      col.names = c("Predictor", "LASSO Coefficient", "Status",
                     "True Driver?"),
      caption = "LASSO coefficients at lambda.1se. Predictors with coefficient = 0 have been dropped from the model. The 'True Driver' column shows whether LASSO correctly identified the variables that actually drive EPT in this simulated dataset.") %>%
  kable_styling(full_width = FALSE, font_size = 14) %>%
  column_spec(3, bold = TRUE)
LASSO coefficients at lambda.1se. Predictors with coefficient = 0 have been dropped from the model. The ‘True Driver’ column shows whether LASSO correctly identified the variables that actually drive EPT in this simulated dataset.
Predictor LASSO Coefficient Status True Driver?
DO_mgl 0.9412 Selected TRUE
temp_C -0.1736 Selected TRUE
canopy_cover 0.0651 Selected TRUE
TSS_mgl 0.0068 Selected FALSE
pH 0.0000 Dropped FALSE
conductivity 0.0000 Dropped FALSE
turbidity 0.0000 Dropped TRUE
nitrate_mgl 0.0000 Dropped FALSE
phosphate_mgl 0.0000 Dropped FALSE
chlorophyll_a 0.0000 Dropped FALSE
BOD_mgl 0.0000 Dropped FALSE
alkalinity 0.0000 Dropped FALSE
hardness 0.0000 Dropped FALSE
flow_velocity 0.0000 Dropped FALSE
coef_1se_display |>
  filter(selected) |>
  ggplot(aes(reorder(predictor, abs(coefficient)), coefficient,
             fill = true_driver)) +
  geom_col(alpha = 0.8, width = 0.6, colour = "#1e3a2f") +
  geom_hline(yintercept = 0, linewidth = 0.4) +
  scale_fill_manual(values = c("TRUE" = "#4a7c5e", "FALSE" = "#c8832a"),
                    labels = c("Noise", "True driver"), name = NULL) +
  coord_flip() +
  labs(x = NULL, y = "LASSO coefficient (lambda.1se)",
       title = "Selected Predictors of EPT Index") +
  theme_minimal(base_size = 12) +
  theme(
    plot.title      = element_text(family = "serif", face = "bold",
                                    colour = "#1e3a2f"),
    legend.position = "bottom"
  )
LASSO coefficients at lambda.1se. Only selected predictors have nonzero coefficients. Green bars are true drivers; orange bars are noise predictors. A successful LASSO selects all four true drivers and excludes most noise.

LASSO coefficients at lambda.1se. Only selected predictors have nonzero coefficients. Green bars are true drivers; orange bars are noise predictors. A successful LASSO selects all four true drivers and excludes most noise.

Comparing lambda.min and lambda.1se

compare_df <- coef_df |>
  select(predictor, coefficient, model) |>
  pivot_wider(names_from = model, values_from = coefficient) |>
  mutate(across(c(lambda.1se, lambda.min), ~ round(.x, 4)))

kable(compare_df,
      col.names = c("Predictor", "lambda.1se", "lambda.min"),
      caption = "Coefficients at both lambda values. lambda.min typically retains more predictors than lambda.1se.") %>%
  kable_styling(full_width = FALSE, font_size = 14)
Coefficients at both lambda values. lambda.min typically retains more predictors than lambda.1se.
Predictor lambda.1se lambda.min
temp_C -0.1736 -0.2410
pH 0.0000 0.0000
conductivity 0.0000 -0.0021
turbidity 0.0000 -0.0479
DO_mgl 0.9412 1.1692
nitrate_mgl 0.0000 -0.3058
phosphate_mgl 0.0000 0.0000
chlorophyll_a 0.0000 0.0080
TSS_mgl 0.0068 0.0469
BOD_mgl 0.0000 -0.0834
alkalinity 0.0000 -0.0066
hardness 0.0000 -0.0040
flow_velocity 0.0000 0.0000
canopy_cover 0.0651 0.0804

📝 Try it: what happens with different seeds? Cross-validation is stochastic – the fold assignments are random. Change set.seed(42) to set.seed(99) and re-run the cv.glmnet() call. Do the same predictors get selected? If the results change substantially, your signal-to-noise ratio is low and you should be cautious about interpreting the selection as definitive. Running CV multiple times and reporting the selection frequency across runs is a more honest approach.


9. Ridge Regression and Elastic Net

LASSO is one member of a family of penalized regression methods. The glmnet package implements all three through a single alpha parameter.

The family

Method alpha Penalty Variable Selection? Best When
Ridge 0 \(\lambda \sum \beta_j^2\) No – shrinks but never zeros Many weak predictors, all relevant
LASSO 1 \(\lambda \sum |\beta_j|\) Yes – sets some to exactly 0 Sparse model, few true drivers
Elastic Net 0 < alpha < 1 \(\lambda [\alpha \sum |\beta_j| + (1-\alpha) \sum \beta_j^2]\) Yes – grouped selection Correlated predictors, want groups

When to use each

Ridge (\(\alpha = 0\)) is preferred when you believe most predictors are relevant and you just want to stabilize coefficients. It handles multicollinearity better than LASSO because it does not force a choice among correlated predictors – it splits the effect equally.

LASSO (\(\alpha = 1\)) is preferred when you believe the true model is sparse – most predictors are noise and only a few matter. This is the most common scenario in environmental variable selection.

Elastic net (\(0 < \alpha < 1\)) is preferred when correlated predictors exist in groups and you want to select or exclude the whole group together. A common choice is \(\alpha = 0.5\). If LASSO’s selection feels arbitrary among correlated predictors (keeping nitrate but dropping phosphate, or vice versa, depending on the random seed), elastic net will tend to keep both or drop both.

# Fit ridge and elastic net for comparison
ridge_fit <- glmnet(x, y, alpha = 0)
enet_fit  <- glmnet(x, y, alpha = 0.5)
par(mfrow = c(1, 3), mar = c(4.5, 4, 3, 1))

plot(lasso_fit, xvar = "lambda", main = "LASSO (alpha = 1)",
     col = "#1e3a2f", lwd = 1.2)
plot(enet_fit, xvar = "lambda", main = "Elastic Net (alpha = 0.5)",
     col = "#c8832a", lwd = 1.2)
plot(ridge_fit, xvar = "lambda", main = "Ridge (alpha = 0)",
     col = "#4a7c5e", lwd = 1.2)
Coefficient paths for LASSO (left), elastic net (center), and ridge (right). LASSO produces exact zeros; ridge never does; elastic net is intermediate. The number at the top of each panel shows how many nonzero coefficients remain at each lambda.

Coefficient paths for LASSO (left), elastic net (center), and ridge (right). LASSO produces exact zeros; ridge never does; elastic net is intermediate. The number at the top of each panel shows how many nonzero coefficients remain at each lambda.

par(mfrow = c(1, 1))

💡 The grouped selection property of elastic net When two predictors are highly correlated (say conductivity and hardness, with \(r = 0.85\)), LASSO will arbitrarily keep one and drop the other. Which one it keeps can change across CV folds. Elastic net tends to keep both or drop both, which is more scientifically interpretable when you know the predictors measure related constructs. If your coefficient path shows two correlated predictors trading places across lambda values, switch to elastic net.


10. Diagnostics and Validation

Prediction accuracy

# Predicted values at lambda.1se
y_pred <- predict(cv_fit, newx = x, s = "lambda.1se")[, 1]

# In-sample metrics
residuals <- y - y_pred
rmse <- sqrt(mean(residuals^2))
r2   <- 1 - sum(residuals^2) / sum((y - mean(y))^2)

cat("LASSO (lambda.1se):\n")
#> LASSO (lambda.1se):
cat("  RMSE:", round(rmse, 2), "\n")
#>   RMSE: 2.33
cat("  R²:  ", round(r2, 3), "\n")
#>   R²:   0.631
# Compare to OLS
y_pred_ols <- predict(ols_full)
rmse_ols   <- sqrt(mean((y - y_pred_ols)^2))
r2_ols     <- summary(ols_full)$r.squared

cat("\nOLS (all 14 predictors):\n")
#> 
#> OLS (all 14 predictors):
cat("  RMSE:", round(rmse_ols, 2), "\n")
#>   RMSE: 1.98
cat("  R²:  ", round(r2_ols, 3), "\n")
#>   R²:   0.735

⚠️ In-sample R² is misleading for LASSO The R² above is computed on the same data used to fit the model. For OLS with 14 predictors this is inflated by overfitting. The honest comparison is the cross-validated MSE from cv.glmnet(), which estimates out-of-sample performance. LASSO typically has lower in-sample R² than OLS but higher out-of-sample R² – that is the whole point of regularization.

Residual plot

resid_df <- tibble(fitted = y_pred, residual = residuals)

ggplot(resid_df, aes(fitted, residual)) +
  geom_hline(yintercept = 0, linewidth = 0.4, colour = "#7a7a72",
             linetype = "dashed") +
  geom_point(colour = "#4a7c5e", alpha = 0.6, size = 2) +
  geom_smooth(method = "loess", se = FALSE, colour = "#c8832a",
              linewidth = 0.8, span = 0.7) +
  labs(x = "Fitted values (EPT index)", y = "Residuals",
       title = "LASSO Residuals vs. Fitted Values") +
  theme_minimal(base_size = 12) +
  theme(
    plot.title = element_text(family = "serif", face = "bold",
                               colour = "#1e3a2f")
  )
Residuals vs. fitted values for the LASSO model at lambda.1se. No systematic pattern should be visible; random scatter around zero indicates the model is adequate. Curvature would suggest a missing nonlinear term.

Residuals vs. fitted values for the LASSO model at lambda.1se. No systematic pattern should be visible; random scatter around zero indicates the model is adequate. Curvature would suggest a missing nonlinear term.

Selection stability via repeated CV

A single CV run depends on the random fold assignment. To assess stability, repeat the entire CV procedure many times and record which predictors are selected.

set.seed(2024)
n_repeats <- 100
selection_count <- rep(0, p)
names(selection_count) <- colnames(x)

for (i in 1:n_repeats) {
  cv_i <- cv.glmnet(x, y, alpha = 1, nfolds = 10)
  coefs_i <- coef(cv_i, s = "lambda.1se")[-1, 1]
  selection_count <- selection_count + (coefs_i != 0)
}

stability_df <- tibble(
  predictor = names(selection_count),
  frequency = selection_count / n_repeats,
  true_driver = predictor %in% c("DO_mgl", "canopy_cover",
                                  "temp_C", "turbidity")
) |>
  arrange(desc(frequency))

ggplot(stability_df, aes(reorder(predictor, frequency), frequency,
                          fill = true_driver)) +
  geom_col(alpha = 0.8, width = 0.6, colour = "#1e3a2f") +
  geom_hline(yintercept = 0.5, linetype = "dashed", colour = "#7a7a72") +
  scale_fill_manual(values = c("TRUE" = "#4a7c5e", "FALSE" = "#c8832a"),
                    labels = c("Noise", "True driver"), name = NULL) +
  scale_y_continuous(labels = percent_format()) +
  coord_flip() +
  labs(x = NULL, y = "Selection frequency (100 CV runs)",
       title = "LASSO Selection Stability") +
  theme_minimal(base_size = 11) +
  theme(
    plot.title      = element_text(family = "serif", face = "bold",
                                    colour = "#1e3a2f"),
    legend.position = "bottom"
  )
Selection frequency across 100 repeated cross-validation runs. A predictor selected in 90+ runs is a robust finding; one selected in 30-60 runs is uncertain.

Selection frequency across 100 repeated cross-validation runs. A predictor selected in 90+ runs is a robust finding; one selected in 30-60 runs is uncertain.

💡 Stability selection is your honesty check A predictor selected in 95 out of 100 repeated CV runs is a robust finding. A predictor selected in 40 out of 100 runs is borderline – it may or may not belong in the model, and you should not make strong claims about it. Reporting selection frequencies is more honest than reporting a single CV result, and it is straightforward to compute.


11. Reporting LASSO Results

What to include in a methods section

When you use LASSO in a paper or report, include:

  1. The number of candidate predictors and the sample size (n/p ratio).
  2. The software and version (glmnet R package, version X.X).
  3. The alpha value (1 for LASSO, or the elastic net value if used).
  4. How lambda was chosen (10-fold CV, lambda.1se or lambda.min).
  5. The set of selected predictors with their coefficients.
  6. A stability assessment (repeated CV or bootstrap selection frequency).
  7. The CV-estimated prediction error (MSE or RMSE).

What to include in a results section

LASSO-selected predictors of EPT index (lambda.1se, 10-fold CV, 100 repetitions). Selection frequency indicates the proportion of repeated CV runs in which the predictor was retained.
Predictor LASSO Coefficient Selection Frequency
temp_C -0.1736 1.00
DO_mgl 0.9412 1.00
canopy_cover 0.0651 1.00
TSS_mgl 0.0068 0.36
nitrate_mgl 0.0000 0.10

Common pitfalls

Pitfall Why it matters Solution
Reporting only lambda.min Overly complex model with noise predictors Default to lambda.1se for interpretation
Single CV run Selection depends on fold assignment Report selection frequency over 50+ runs
Interpreting coefficient magnitude as importance Correlated predictors split their effects Standardize predictors for relative comparison
Ignoring multicollinearity LASSO picks one of two correlated predictors arbitrarily Use elastic net or inspect correlation matrix first
Treating LASSO as confirmatory LASSO is exploratory variable selection Validate on held-out data or a new sample

❌ Myth: “LASSO tells you the causal drivers” LASSO identifies predictors with the strongest predictive association with the response, penalized for model complexity. It does not establish causation. A variable can be selected because it is a proxy for the true cause, or because it is correlated with one. Use LASSO output to generate hypotheses, then test them with designed experiments or mechanistic models.


🔑 LASSO regression – key takeaways

  • LASSO adds a penalty to OLS that forces weak coefficients to exactly zero. This gives you variable selection and model fitting in one step.
  • Use glmnet(x, y, alpha = 1) to fit LASSO. The data must be in matrix form, not a formula. glmnet standardizes internally.
  • Choose lambda with cross-validation. cv.glmnet() returns lambda.min (best prediction) and lambda.1se (simplest adequate model). Default to lambda.1se for interpretive goals.
  • The coefficient path plot is your roadmap. Predictors that enter early (at high lambda) are the most important. Predictors that enter late are marginal.
  • Ridge, LASSO, and elastic net are a family controlled by alpha. Use ridge when all predictors are plausibly relevant, LASSO when the model is sparse, elastic net when correlated predictors exist in groups.
  • Assess stability. Repeat CV 50 to 100 times and report selection frequencies. A predictor selected in fewer than half the runs is not a robust finding.
  • LASSO is exploratory, not confirmatory. It identifies the strongest predictive associations in your data. Treat the output as hypotheses to validate, not as established causal relationships.

What’s next?