---
title: "Module 4 — Regression-Focused Methods"
subtitle: "Applied Machine Learning for Marketing"
author: "Jae Jung"
date: today
format:
html:
toc: true
toc-depth: 4
toc-expand: 1
toc-location: right-body
toc-title: "Contents"
number-sections: true
code-fold: show
code-tools: true
theme: cosmo
highlight-style: github
df-print: kable
embed-resources: true
execute:
warning: false
message: false
freeze: true
---
# Learning Objectives {.unnumbered}
By the end of this notebook you will be able to:
1. Fit and interpret a baseline linear regression model using tidymodels
2. Explain the difference between Ridge, Lasso, and Elastic Net regularization and when to use each
3. Use `step_poly()` to capture nonlinear relationships between predictors and the outcome
4. Evaluate regression models using RMSE, R², and MAE from `yardstick`
5. Introduce hyperparameter tuning using `tune()`, `tune_grid()`, and `select_best()` to choose the optimal penalty
6. Compare multiple regression models using cross-validated performance estimates
> **Textbook references:** TMWR Chapters 6 (Fitting Models with parsnip), 7 (A Model Workflow), 9 (Judging Model Effectiveness); ISLR Chapters 3 (Linear Regression), 6 (Linear Model Selection and Regularization)
---
# Setup
```{r setup}
#install.packages(
# "https://cran.r-project.org/src/contrib/Archive/vip/vip_0.4.6.tar.gz",
# repos = NULL,
# type = "source"
#)
library(tidymodels)
library(tidyverse)
library(glmnet) # Ridge, Lasso, Elastic Net engine
library(patchwork) # combining plots
library(vip) # variable importance plots
tidymodels_prefer()
set.seed(2025)
```
---
# The Customer Lifetime Value Dataset
## Data Generation
We simulate 2,500 customers from a retail subscription service. The outcome variable is `annual_spend` — total customer spending in the past year — a continuous, right-skewed variable typical of real marketing data.
Predicting annual spend is one of the most common tasks in Customer Lifetime Value (CLV) modeling: knowing which customers will spend the most allows marketers to allocate acquisition budgets, design loyalty programs, and prioritize retention efforts.
```{r generate-data}
set.seed(2025)
n <- 2500
clv_data <- tibble(
customer_id = paste0("CLV", str_pad(1:n, 5, pad = "0")),
age = round(pmin(pmax(rnorm(n, mean = 42, sd = 12), 18), 85)),
income = round(rlnorm(n, meanlog = 10.8, sdlog = 0.55)),
tenure_months = round(pmax(1, rnorm(n, mean = 30, sd = 18))),
num_products = sample(
1:6,
n,
replace = TRUE,
prob = c(0.25, 0.30, 0.20, 0.12, 0.08, 0.05)
),
freq_purchases = rpois(n, lambda = 8),
recency_days = round(pmax(0, rexp(n, rate = 1 / 30))), # mean = 1/rate
avg_basket = round(rlnorm(n, meanlog = 4.0, sdlog = 0.45), 2), # $
channel = sample(
c("online", "in_store", "both"),
n,
replace = TRUE,
prob = c(0.45, 0.25, 0.30)
),
loyalty_tier = sample(
c("Bronze", "Silver", "Gold", "Platinum"),
n,
replace = TRUE,
prob = c(0.35, 0.30, 0.22, 0.13)
),
region = sample(
c("West", "South", "Midwest", "Northeast"),
n,
replace = TRUE
),
promo_response = sample(
c("yes", "no"),
n,
replace = TRUE,
prob = c(0.40, 0.60)
)
) |>
mutate(
# annual_spend — continuous outcome
# driven by income, frequency, basket size, products owned, loyalty
annual_spend = round(
pmax(
10,
200 +
0.004 * income +
18 * freq_purchases +
0.85 * avg_basket +
45 * num_products -
0.8 * recency_days +
2.0 * tenure_months +
case_when(
loyalty_tier == "Platinum" ~ 350,
loyalty_tier == "Gold" ~ 200,
loyalty_tier == "Silver" ~ 80,
TRUE ~ 0
) +
if_else(channel == "both", 120, 0) +
if_else(promo_response == "yes", 75, 0) +
rnorm(n, 0, 180) # noise
),
2
),
# Introduce missingness
income = if_else(runif(n) < 0.07, NA_real_, income),
avg_basket = if_else(runif(n) < 0.05, NA_real_, avg_basket),
# Encode factors
channel = factor(channel),
loyalty_tier = factor(
loyalty_tier,
levels = c("Bronze", "Silver", "Gold", "Platinum")
),
region = factor(region),
promo_response = factor(promo_response)
)
glimpse(clv_data)
```
```{r}
#| eval: false
library(corrr)
clv_data |>
select(
age,
income,
tenure_months,
num_products,
freq_purchases,
recency_days,
avg_basket,
promo_response,
annual_spend
) |>
correlate() |>
shave() |>
fashion(2)
```
## Exploratory Data Analysis
```{r eda-outcome}
# Distribution of annual_spend
p1 <- ggplot(clv_data, aes(x = annual_spend)) +
geom_histogram(bins = 40, fill = "#154734", color = "white", alpha = 0.85) +
labs(
title = "Distribution of Annual Spend",
x = "Annual spend ($)",
y = "Count"
) +
theme_minimal(base_size = 11)
# Log-transformed spend
p2 <- ggplot(clv_data, aes(x = log(annual_spend))) +
geom_histogram(bins = 40, fill = "#B08D57", color = "white", alpha = 0.85) +
labs(
title = "Log-Transformed Annual Spend",
x = "log(Annual spend)",
y = "Count"
) +
theme_minimal(base_size = 11)
p1 + p2
```
```{r eda-predictors}
# Spend by loyalty tier
clv_data |>
group_by(loyalty_tier) |>
summarise(
n = n(),
mean_spend = round(mean(annual_spend)),
median_spend = round(median(annual_spend)),
sd_spend = round(sd(annual_spend))
)
# Spend vs. key continuous predictors
p3 <- ggplot(clv_data, aes(x = freq_purchases, y = annual_spend)) +
geom_point(alpha = 0.2, color = "#154734") +
geom_smooth(method = "lm", color = "#B08D57", se = FALSE) +
labs(
title = "Spend vs. Purchase Frequency",
x = "Number of purchases",
y = "Annual spend ($)"
) +
theme_minimal(base_size = 11)
p4 <- ggplot(clv_data, aes(x = avg_basket, y = annual_spend)) +
geom_point(alpha = 0.2, color = "#154734") +
geom_smooth(method = "lm", color = "#B08D57", se = FALSE) +
labs(
title = "Spend vs. Average Basket Size",
x = "Average basket ($)",
y = "Annual spend ($)"
) +
theme_minimal(base_size = 11)
p3 + p4
```
::: {.callout-note title="Marketing context"}
`annual_spend` is right-skewed — a small group of high spenders pulls the mean above the median. This is characteristic of customer spending distributions in retail: most customers spend modestly, while a small segment of "whales" drives disproportionate revenue.
Throughout this module we predict `annual_spend` directly (on the dollar scale). Note that in production, some practitioners log-transform the outcome before modeling and back-transform predictions — a design choice with implications for how we interpret RMSE.
:::
---
# Train/Test Split and Shared Recipe
## Split
```{r split}
set.seed(617)
clv_split <- initial_split(clv_data, prop = 0.80)
clv_train <- training(clv_split)
clv_test <- testing(clv_split)
cat("Training rows:", nrow(clv_train), "\n")
cat("Test rows :", nrow(clv_test), "\n")
cat("Mean spend (train):", round(mean(clv_train$annual_spend)), "\n")
cat("Mean spend (test) :", round(mean(clv_test$annual_spend)), "\n")
```
## Shared Recipe
All models in this module share the same preprocessing recipe. This ensures that any performance differences we observe are due to the models themselves, not differences in feature engineering.
```{r recipe}
clv_rec <- recipe(annual_spend ~ ., data = clv_train) |>
update_role(customer_id, new_role = "ID") |>
step_impute_median(all_numeric_predictors()) |>
step_log(income, avg_basket, base = 10, offset = 1) |>
step_normalize(all_numeric_predictors()) |>
step_dummy(all_nominal_predictors()) |>
step_zv(all_predictors())
clv_rec
```
```{r prep-inspect}
# Inspect what the recipe learned
clv_prep <- prep(clv_rec)
tidy(clv_prep)
```
---
# Baseline — Linear Regression
Before adding regularization, we establish a baseline using ordinary least squares (OLS) linear regression. This gives us a benchmark to compare against Ridge, Lasso, and Elastic Net.
## How OLS Works
OLS minimizes the **residual sum of squares (RSS)**:
$$RSS = \sum_{i=1}^{n}(y_i - \hat{y}_i)^2$$
It fits coefficients that produce the smallest total squared prediction error on the training data — with no constraint on how large the coefficients can be.
```{r lm-fit}
lm_spec <- linear_reg() |>
set_engine("lm")
lm_wf <- workflow() |>
add_recipe(clv_rec) |>
add_model(lm_spec)
lm_fit <- fit(lm_wf, data = clv_train)
# Coefficient table
lm_fit |>
extract_fit_parsnip() |>
tidy() |>
filter(term != "(Intercept)") |>
arrange(desc(abs(estimate)))
```
```{r lm-metrics}
lm_preds <- augment(lm_fit, new_data = clv_test)
lm_metrics <- lm_preds |>
metric_set(rmse, rsq, mae)(truth = annual_spend, estimate = .pred)
lm_metrics
```
```{r lm-resid-plot}
ggplot(lm_preds, aes(x = .pred, y = annual_spend - .pred)) +
geom_point(alpha = 0.3, color = "#154734") +
geom_hline(yintercept = 0, linetype = "dashed", color = "#B08D57") +
labs(
title = "Linear Regression — Residuals vs. Fitted",
subtitle = "Patterns here suggest the model is missing something",
x = "Predicted annual spend ($)",
y = "Residual ($)"
) +
theme_minimal(base_size = 12)
```
::: {.callout-note title="The OLS overfitting problem"}
OLS produces unbiased coefficient estimates — but with many predictors, it can overfit the training data by assigning large coefficients to noise variables. The solution is **regularization**: adding a penalty to the loss function that discourages overly large coefficients. The three methods that follow (Ridge, Lasso, Elastic Net) all use the `glmnet` engine and differ only in how they penalize coefficients.
:::
---
# Ridge Regression
## How Ridge Works
Ridge regression adds an **L2 penalty** to the OLS loss function:
$$\text{Ridge loss} = RSS + \lambda \sum_{j=1}^{p} \beta_j^2$$
where $\lambda$ (the `penalty` parameter) controls the strength of regularization. As $\lambda$ increases, all coefficients shrink toward — but never exactly reach — zero.
::: {.callout-note title="Key properties of Ridge"}
- **Shrinks all coefficients** — no coefficient is ever set to exactly zero
- **Keeps all predictors** — Ridge performs no variable selection
- **Best when** you believe most predictors contribute some signal and want to reduce their collective magnitude
- **Especially useful** when predictors are highly correlated (multicollinearity) — Ridge distributes the coefficient weight across correlated predictors rather than assigning it arbitrarily to one
:::
## Fitting Ridge
```{r ridge-fit}
ridge_spec <- linear_reg(penalty = 0.1, mixture = 0) |>
set_engine("glmnet")
ridge_wf <- workflow() |>
add_recipe(clv_rec) |>
add_model(ridge_spec)
ridge_fit <- fit(ridge_wf, data = clv_train)
# Coefficients
ridge_fit |>
extract_fit_parsnip() |>
tidy() |>
filter(term != "(Intercept)") |>
arrange(desc(abs(estimate)))
```
```{r ridge-metrics}
ridge_preds <- augment(ridge_fit, new_data = clv_test)
ridge_metrics <- ridge_preds |>
metric_set(rmse, rsq, mae)(truth = annual_spend, estimate = .pred)
ridge_metrics
```
::: {.callout-note title="mixture = 0"}
In `linear_reg()` with the `glmnet` engine, the `mixture` parameter controls the blend of L1 (Lasso) and L2 (Ridge) penalties:
- `mixture = 0` → pure Ridge (L2 only)
- `mixture = 1` → pure Lasso (L1 only)
- `0 < mixture < 1` → Elastic Net (blend of both)
The `penalty` parameter is $\lambda$ — the strength of regularization. We set it manually here (`penalty = 0.1`) as a starting point; in Section 7 we will search for the optimal value using cross-validation.
:::
---
# Lasso Regression
## How Lasso Works
Lasso (Least Absolute Shrinkage and Selection Operator) adds an **L1 penalty**:
$$\text{Lasso loss} = RSS + \lambda \sum_{j=1}^{p} |\beta_j|$$
The key difference from Ridge: because the L1 penalty is proportional to the absolute value of the coefficient (not its square), Lasso can **set coefficients to exactly zero** — performing automatic variable selection.
::: {.callout-note title="Key properties of Lasso"}
- **Performs variable selection** — unimportant predictors are zeroed out exactly
- **Produces sparse models** — only a subset of predictors have nonzero coefficients
- **Best when** you believe only a few predictors truly matter and want a simple, interpretable model
- **Caveat**: with highly correlated predictors, Lasso tends to arbitrarily pick one and zero out the others — Ridge handles multicollinearity more gracefully
:::
## Fitting Lasso
```{r lasso-fit}
lasso_spec <- linear_reg(penalty = 0.1, mixture = 1) |>
set_engine("glmnet")
lasso_wf <- workflow() |>
add_recipe(clv_rec) |>
add_model(lasso_spec)
lasso_fit <- fit(lasso_wf, data = clv_train)
# Coefficients — note which are exactly zero
lasso_fit |>
extract_fit_parsnip() |>
tidy() |>
filter(term != "(Intercept)") |>
arrange(desc(abs(estimate))) |>
mutate(selected = if_else(estimate != 0, "✓ kept", "✗ zeroed"))
```
```{r lasso-metrics}
lasso_preds <- augment(lasso_fit, new_data = clv_test)
lasso_metrics <- lasso_preds |>
metric_set(rmse, rsq, mae)(truth = annual_spend, estimate = .pred)
lasso_metrics
```
```{r lasso-path}
# Coefficient path — how coefficients change as penalty increases
lasso_fit |>
extract_fit_engine() |>
autoplot() +
labs(
title = "Lasso Coefficient Path",
subtitle = "As penalty (λ) increases, more coefficients are driven to zero",
x = "Log(λ)",
y = "Coefficient value"
) +
theme_minimal(base_size = 12)
```
::: {.callout-note title="Reading the coefficient path"}
Each line in the path plot traces one predictor's coefficient as $\lambda$ increases from left (small penalty, near-OLS) to right (large penalty, all coefficients near zero). Predictors whose lines hit zero earliest are the least important — Lasso is performing variable selection automatically as the penalty grows.
Predictors whose coefficients are driven to zero earlier tend to have weaker or more redundant contributions to the model, after accounting for the other predictors. However, this should not be interpreted as a perfect ranking of real-world importance, because correlated predictors, scaling, and sampling variation can affect which variables Lasso keeps or removes.
:::
---
# Elastic Net
Elastic Net is a hybrid regularization method that combines the penalties of both **Ridge ($L_2$)** and **Lasso ($L_1$)** regression. It is particularly useful when dealing with highly correlated features (multicollinearity) or when the number of predictors ($p$) is much larger than the number of observations ($n$).
## The Elastic Net Formula
The objective of Elastic Net is to find the coefficient vector $\beta$ that minimizes the following penalized loss function:
$$\min_{\beta} \left\{ \frac{1}{2n} \sum_{i=1}^{n} \left( y_i - \beta_0 - \sum_{j=1}^{p} x_{ij} \beta_j \right)^2 + \lambda \left[ \frac{1 - \alpha}{2} \sum_{j=1}^{p} \beta_j^2 + \alpha \sum_{j=1}^{p} |\beta_j| \right] \right\}$$
## How Elastic Net Works
Elastic Net blends the L2 (Ridge) and L1 (Lasso) penalties:
$$\text{Elastic Net loss} = RSS + \lambda \left[ \frac{1-\alpha}{2} \sum_j \beta_j^2 + \alpha \sum_j |\beta_j| \right]$$
where $\alpha$ is the `mixture` parameter in tidymodels. Setting `mixture = 0.5` gives equal weight to Ridge and Lasso penalties.
::: {.callout-note title="Key properties of Elastic Net"}
- **Combines the best of both** — performs variable selection like Lasso while handling correlated predictors more gracefully like Ridge
- **Two hyperparameters**: `penalty` ($\lambda$) and `mixture` ($\alpha$)
- **Best when** you have many predictors, some correlated, and you want both selection and shrinkage
- Often the **safe default** in marketing applications where predictor correlation is the norm
:::
## Fitting Elastic Net
```{r enet-fit}
enet_spec <- linear_reg(penalty = 0.1, mixture = 0.5) |>
set_engine("glmnet")
enet_wf <- workflow() |>
add_recipe(clv_rec) |>
add_model(enet_spec)
enet_fit <- fit(enet_wf, data = clv_train)
# Coefficients
enet_fit |>
extract_fit_parsnip() |>
tidy() |>
filter(term != "(Intercept)") |>
arrange(desc(abs(estimate))) |>
mutate(selected = if_else(estimate != 0, "✓ kept", "✗ zeroed"))
```
```{r enet-metrics}
enet_preds <- augment(enet_fit, new_data = clv_test)
enet_metrics <- enet_preds |>
metric_set(rmse, rsq, mae)(truth = annual_spend, estimate = .pred)
enet_metrics
```
---
# Polynomial Regression
## Capturing Nonlinear Relationships
Linear regression assumes the relationship between each predictor and the outcome is a straight line. Real marketing data rarely behaves that way — the effect of `freq_purchases` on `annual_spend` may accelerate at higher frequencies, or `recency_days` may have diminishing negative returns.
**Polynomial regression** extends linear regression by adding powered versions of a predictor:
$$\hat{y} = \beta_0 + \beta_1 x + \beta_2 x^2 + \beta_3 x^3 + \ldots$$
In tidymodels, `step_poly()` handles this transformation automatically inside the recipe.
::: {.callout-note title="Why polynomial, not a new model?"}
Polynomial regression is still a *linear* model — linear in the coefficients $\beta$, even though the predictor enters nonlinearly. This means we can still use `linear_reg()`, interpret coefficients, and apply regularization. The polynomial terms are just new features created by `step_poly()`.
:::
## Visualizing Nonlinearity
```{r poly-viz}
# Does the relationship look linear or curved?
p5 <- ggplot(clv_train, aes(x = freq_purchases, y = annual_spend)) +
geom_point(alpha = 0.2, color = "#154734") +
geom_smooth(
method = "lm",
color = "#B08D57",
se = FALSE,
linetype = "dashed",
linewidth = 0.9
) +
geom_smooth(
method = "loess",
color = "#4F8A8B",
se = FALSE,
linewidth = 0.9
) +
labs(
title = "Spend vs. Purchase Frequency",
subtitle = "Dashed = linear fit | Solid = LOESS (flexible)",
x = "Number of purchases",
y = "Annual spend ($)"
) +
theme_minimal(base_size = 11)
p6 <- ggplot(clv_train, aes(x = tenure_months, y = annual_spend)) +
geom_point(alpha = 0.2, color = "#154734") +
geom_smooth(
method = "lm",
color = "#B08D57",
se = FALSE,
linetype = "dashed",
linewidth = 0.9
) +
geom_smooth(
method = "loess",
color = "#4F8A8B",
se = FALSE,
linewidth = 0.9
) +
labs(
title = "Spend vs. Tenure",
subtitle = "Does the relationship flatten out over time?",
x = "Tenure (months)",
y = "Annual spend ($)"
) +
theme_minimal(base_size = 11)
p5 + p6
```
## Fitting Polynomial Regression
```{r poly-fit}
poly_rec <- recipe(annual_spend ~ ., data = clv_train) |>
update_role(customer_id, new_role = "ID") |>
step_impute_median(all_numeric_predictors()) |>
step_log(income, avg_basket, base = 10, offset = 1) |>
step_poly(freq_purchases, tenure_months, degree = 2) |>
step_normalize(all_numeric_predictors()) |>
step_dummy(all_nominal_predictors()) |>
step_zv(all_predictors())
poly_wf <- workflow() |>
add_recipe(poly_rec) |>
add_model(lasso_spec) # use Lasso to handle the extra polynomial terms
poly_fit <- fit(poly_wf, data = clv_train)
poly_fit |>
extract_fit_parsnip() |>
tidy() |>
filter(term != "(Intercept)", estimate != 0) |>
arrange(desc(abs(estimate)))
```
```{r poly-metrics}
poly_preds <- augment(poly_fit, new_data = clv_test)
poly_metrics <- poly_preds |>
metric_set(rmse, rsq, mae)(truth = annual_spend, estimate = .pred)
poly_metrics
```
::: {.callout-note title="Why combine step_poly() with Lasso?"}
Adding polynomial terms increases the number of predictors. With `degree = 2` on two variables, we add 2 new columns (`freq_purchases^2`, `tenure_months^2`). With `degree = 3` on many variables, the explosion of terms can lead to overfitting. Combining `step_poly()` with Lasso regularization lets the model automatically zero out polynomial terms that don't contribute — giving us flexibility without the overfitting risk.
:::
---
# Tuning the Penalty
## Why Manual Penalty Selection Is Not Enough
So far we have set `penalty = 0.1` for all regularized models — a reasonable starting value, but essentially a guess. The optimal penalty depends on the data, the number of predictors, and the signal-to-noise ratio. The solution is to **search over a range of penalty values** and use cross-validation to find the one that minimizes prediction error on held-out data.
::: {.callout-note title="Connecting to Module 3"}
This is the same `fit_resamples()` machinery you used in Module 3 — the difference is that now we run it across many penalty values simultaneously using `tune_grid()`. The penalty becomes a `tune()` placeholder rather than a fixed value, and tidymodels handles the rest.
:::
## Setting Up the Tuning Grid
```{r tune-setup}
# Use tune() as a placeholder for the penalty
lasso_tune_spec <- linear_reg(penalty = tune(), mixture = 1) |>
set_engine("glmnet")
lasso_tune_wf <- workflow() |>
add_recipe(clv_rec) |>
add_model(lasso_tune_spec)
# Cross-validation folds
set.seed(2025)
clv_folds <- vfold_cv(clv_train, v = 10)
# Penalty grid — 30 values on a log scale from 10^-4 to 10^1
penalty_grid <- grid_regular(
penalty(range = c(-4, 1)),
levels = 30
)
penalty_grid
```
## Running the Tuning Search
```{r tune-grid}
set.seed(2025)
lasso_tune_results <- tune_grid(
lasso_tune_wf,
resamples = clv_folds,
grid = penalty_grid,
metrics = metric_set(rmse, rsq, mae)
)
# Plot tuning results
autoplot(lasso_tune_results) +
labs(
title = "Lasso Tuning — RMSE and R² Across Penalty Values",
subtitle = "Each point = 10-fold CV mean; shaded band = ± 1 SE"
) +
theme_minimal(base_size = 12)
```
## Selecting the Best Penalty
```{r select-best}
# Best penalty by lowest RMSE
best_penalty <- select_best(lasso_tune_results, metric = "rmse")
best_penalty
# One-standard-error rule — simpler model within 1 SE of the best
best_penalty_1se <- select_by_one_std_err(
lasso_tune_results,
metric = "rmse",
desc(penalty)
)
best_penalty_1se
```
::: {.callout-note title="The one-standard-error rule"}
`select_best()` picks the penalty that minimizes mean RMSE — but this penalty may be on the edge of the performance plateau, where a slightly larger (simpler) penalty gives nearly identical RMSE but a more parsimonious model.
`select_by_one_std_err()` picks the **largest penalty** whose mean RMSE is within one standard error of the minimum. This gives a simpler, more regularized model with no meaningful sacrifice in performance — often preferred in practice.
:::
## Finalizing and Fitting the Tuned Model
```{r finalize}
# Plug the best penalty back into the workflow
final_lasso_wf <- finalize_workflow(lasso_tune_wf, best_penalty)
# Fit on the full training set with the tuned penalty
final_lasso_fit <- fit(final_lasso_wf, data = clv_train)
# Evaluate on the test set — this is the honest final estimate
final_preds <- augment(final_lasso_fit, new_data = clv_test)
final_metrics <- final_preds |>
metric_set(rmse, rsq, mae)(truth = annual_spend, estimate = .pred)
final_metrics
```
```{r tuned-coefs}
# Which predictors did the tuned Lasso keep?
final_lasso_fit |>
extract_fit_parsnip() |>
tidy() |>
filter(term != "(Intercept)") |>
arrange(desc(abs(estimate))) |>
mutate(selected = if_else(estimate != 0, "✓ kept", "✗ zeroed"))
```
---
# Comparing All Models
## Cross-Validated Comparison
Now that we have a tuned Lasso, let us compare all five models on equal footing using 10-fold cross-validation.
```{r cv-compare}
# Helper: fit a workflow and return CV metrics with a model label
cv_metrics <- function(wf, label) {
fit_resamples(
wf,
resamples = clv_folds,
metrics = metric_set(rmse, rsq, mae)
) |>
collect_metrics() |>
mutate(model = label)
}
lm_cv <- cv_metrics(lm_wf, "OLS Linear")
ridge_cv <- cv_metrics(ridge_wf, "Ridge (λ=0.1)")
lasso_cv <- cv_metrics(lasso_wf, "Lasso (λ=0.1)")
enet_cv <- cv_metrics(enet_wf, "Elastic Net (λ=0.1)")
poly_cv <- cv_metrics(poly_wf, "Polynomial + Lasso")
all_cv <- bind_rows(lm_cv, ridge_cv, lasso_cv, enet_cv, poly_cv)
# Summary table
all_cv |>
filter(.metric == "rmse") |>
select(model, mean, std_err) |>
arrange(mean)
```
```{r cv-plot}
all_cv |>
filter(.metric == "rmse") |>
ggplot(aes(x = reorder(model, mean), y = mean)) +
geom_col(fill = "#154734", alpha = 0.85) +
geom_errorbar(
aes(ymin = mean - std_err, ymax = mean + std_err),
width = 0.3,
color = "#B08D57",
linewidth = 0.8
) +
coord_flip() +
labs(
title = "10-Fold CV RMSE — All Regression Models",
subtitle = "Error bars show ± 1 standard error | Lower RMSE = better",
x = NULL,
y = "Mean RMSE ($)"
) +
theme_minimal(base_size = 12)
```
## Test Set Performance Summary
```{r test-compare}
bind_rows(
lm_metrics |> mutate(model = "OLS Linear"),
ridge_metrics |> mutate(model = "Ridge (λ=0.1)"),
lasso_metrics |> mutate(model = "Lasso (λ=0.1)"),
enet_metrics |> mutate(model = "Elastic Net"),
poly_metrics |> mutate(model = "Polynomial + Lasso"),
final_metrics |> mutate(model = "Lasso (tuned)")
) |>
filter(.metric %in% c("rmse", "rsq")) |>
select(model, .metric, .estimate) |>
pivot_wider(names_from = .metric, values_from = .estimate) |>
arrange(rmse)
```
::: {.callout-note title="Interpreting the comparison"}
A few things to watch for:
- **OLS vs. regularized models**: Does adding a penalty improve test RMSE? If so, OLS was overfitting.
- **Ridge vs. Lasso**: Is Lasso's variable selection helpful here, or does Ridge's full-shrinkage approach perform similarly?
- **Manual (λ=0.1) vs. tuned Lasso**: The tuned model should outperform — or at minimum match — the manually set penalty, confirming that tuning was worthwhile.
- **Polynomial**: Does capturing nonlinearity help, or does the added complexity not pay off?
:::
---
# Variable Importance
## Which Predictors Drive Annual Spend?
After fitting the best model, it is natural to ask: which predictors matter most? The `vip` package computes **variable importance** from the magnitude of standardized coefficients in regularized models.
```{r vip}
final_lasso_fit |>
extract_fit_parsnip() |>
vip(
num_features = 12,
aesthetics = list(fill = "#154734", color = "white", alpha = 0.85)
) +
labs(
title = "Variable Importance — Tuned Lasso",
subtitle = "Importance based on absolute standardized coefficient magnitude",
x = "Importance"
) +
theme_minimal(base_size = 12)
```
::: {.callout-note title="Connecting to Module 3"}
Variable importance from a Lasso coefficient plot is a simple form of **model explainability** — you can see exactly which customer characteristics drive spend predictions. In Module 10 we will explore more sophisticated explainability tools (SHAP values, partial dependence plots) from TMWR Chapter 18, which work for any model type including the non-linear ones in Module 6.
:::
---
# Summary
## Regularization Methods at a Glance
| Method | Penalty | Zeroes Coefficients? | Best When |
|---|---|---|---|
| **OLS** | None | No | Predictors >> n is not a problem; interpretability priority |
| **Ridge** | L2: $\lambda \sum \beta_j^2$ | No | Multicollinearity; keep all predictors |
| **Lasso** | L1: $\lambda \sum |\beta_j|$ | Yes | Many predictors; want automatic selection |
| **Elastic Net** | L1 + L2 blend | Yes (partial) | Correlated predictors; want selection + stability |
| **Polynomial** | Any of the above | Depends on model | Nonlinear relationships visible in scatter plots |
## Tuning Workflow
```r
# 1. Replace fixed penalty with tune()
spec <- linear_reg(penalty = tune(), mixture = 1) |> set_engine("glmnet")
# 2. Build a grid of candidate penalties
grid <- grid_regular(penalty(range = c(-4, 1)), levels = 30)
# 3. Search with cross-validation
results <- tune_grid(wf, resamples = folds, grid = grid,
metrics = metric_set(rmse, rsq))
# 4. Select best penalty
best <- select_best(results, metric = "rmse")
# 5. Finalize and fit on full training data
final_fit <- fit(finalize_workflow(wf, best), data = train)
```
---
# Exercises
## ✏️ Exercise 4.1 — Ridge vs. Lasso Coefficient Behavior
Fit Ridge (`mixture = 0`) and Lasso (`mixture = 1`) with an identical, large penalty of `penalty = 10`. Extract the coefficients from both models.
1. How many coefficients are exactly zero in each model? Does this match what you expected from the theory?
2. Pick one predictor that Lasso zeroes out but Ridge does not. Compare its Ridge coefficient magnitude to its OLS coefficient. What did Ridge do to it?
3. In plain English, explain to a marketing manager why Lasso might be preferred over Ridge when building a customer spend model with 50+ potential predictors.
```{r ex4-1, eval=FALSE}
# Your code here
```
## ✏️ Exercise 4.2 — Elastic Net Mixing Parameter
Fit three Elastic Net models with `penalty = 0.1` and `mixture` values of 0.25, 0.50, and 0.75. Compare the number of nonzero coefficients and test RMSE across all three.
1. As `mixture` increases from 0.25 toward 0.75, what happens to the number of nonzero coefficients? Why?
2. Which `mixture` value gives the best test RMSE? Is the difference meaningful given the standard errors you saw in the CV comparison?
3. When would you prefer Elastic Net over pure Lasso in a marketing dataset? Give a specific example.
```{r ex4-2, eval=FALSE}
# Your code here
```
## ✏️ Exercise 4.3 — Polynomial Degree Selection
Refit the polynomial recipe with `degree = 3` for both `freq_purchases` and `tenure_months`. Compare cross-validated RMSE for degree 1 (linear), degree 2, and degree 3.
1. Does increasing from degree 2 to degree 3 improve CV RMSE? What does this suggest about the relationship between these predictors and annual spend?
2. Without regularization, would you expect degree 3 to overfit more or less than degree 2? Run a quick check by comparing train vs. test RMSE for the degree 3 model without Lasso (`mixture = 0` and `penalty = 0.001`).
```{r ex4-3, eval=FALSE}
# Your code here
```
## ✏️ Exercise 4.4 — Tuning Elastic Net
The tuning example in Section 7 only tuned the `penalty` for Lasso (`mixture = 1` fixed). Now tune **both** `penalty` and `mixture` for an Elastic Net model using a regular grid with 5 levels each.
1. How many total model fits does this require across 10 folds?
2. What is the best `penalty` and `mixture` combination by RMSE? Is the optimal `mixture` closer to Ridge (0) or Lasso (1)?
3. Compare the tuned Elastic Net test RMSE to the tuned Lasso from Section 7. Is the extra complexity of tuning two parameters worth it?
```{r ex4-4, eval=FALSE}
# Hint: use grid_regular(penalty(), mixture(), levels = 5)
# Your code here
```
## ✏️ Exercise 4.5 — Business Interpretation
Using the variable importance plot and coefficient table from the tuned Lasso:
1. Identify the **three most important predictors** of annual spend. For each one, state the direction of the effect (positive or negative) and explain why it makes business sense.
2. Identify one predictor you expected to matter that the Lasso zeroed out. Why might the Lasso have excluded it — is it truly unimportant, or might it be correlated with another predictor that stayed in the model?
3. A marketing manager asks: "If I want to increase a customer's annual spend, what is the single most actionable lever based on this model?" Answer using the variable importance results, being careful to distinguish between predictors the business can influence vs. those it cannot.
```{r ex4-5, eval=FALSE}
# Your code here
```
---