Prof. Marc Los Huertos • Pomona College • EA030 Environmental Analysis
Why LASSO? The Intuition The Math Dataset The OLS Problem Fitting LASSO Cross-Validation Interpretation Ridge & Elastic Net Diagnostics Reporting
glmnet requires.
glmnet() and interpret the
coefficient path plot.
cv.glmnet() to choose the penalty parameter lambda by
cross-validation.
lambda.min and lambda.1se
and explain when each is appropriate.
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
glmnetpackage, applied to a simulated water quality dataset.
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.
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.
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.
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.
This section is for students who want the formal details. You can skip to Section 4 and still use LASSO effectively.
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.
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.
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.
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
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.
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().
# 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.
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.
💡 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.
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.
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.
# 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)
| 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.
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)
| 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.
LASSO is one member of a family of penalized regression methods. The
glmnet package implements all three through a single
alpha parameter.
| 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 |
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.
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.
# 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.
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.
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.
💡 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.
When you use LASSO in a paper or report, include:
glmnet R
package, version X.X).| 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 |
| 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.
glmnet(x, y, alpha = 1) to fit LASSO.
The data must be in matrix form, not a formula. glmnet
standardizes internally.
cv.glmnet() returns lambda.min (best
prediction) and lambda.1se (simplest adequate model).
Default to lambda.1se for interpretive goals.
alpha. Use ridge when all predictors are plausibly
relevant, LASSO when the model is sparse, elastic net when correlated
predictors exist in groups.