# Install packages if needed (run once in Colab)
if (!require("tidymodels", quietly = TRUE)) {
install.packages(c("tidymodels", "glmnet"))
}
# Load packages
library(tidymodels)
library(tidyverse)
library(glmnet)
# Settings
set.seed(42)
theme_set(theme_minimal())
cat("Packages loaded!\n")Day 2: Regularized Regression (LASSO, Ridge, Elastic Net) - R Version
WISE Workshop | Addis Ababa, Feb 2026
In this notebook, you’ll apply regularization techniques to prevent overfitting in supply chain demand prediction using the tidymodels framework.
Setup
Make sure you’re using the R runtime: Runtime -> Change runtime type -> R
Part 1: Data Preparation
We’ll create a dataset with many features to see regularization in action.
# Create sample supply chain data with many features
set.seed(42)
n_rows <- 1000
# Generate dates and basic features
dates <- seq(as.Date('2023-01-01'), by = 'day', length.out = n_rows)
regions <- sample(c('Addis Ababa', 'Oromia', 'Amhara', 'SNNP', 'Tigray'), n_rows, replace = TRUE)
facility_types <- sample(c('Hospital', 'Health Center', 'Clinic'), n_rows, replace = TRUE,
prob = c(0.2, 0.5, 0.3))
# Create demand with clear patterns
base_demand <- 100
facility_effect <- ifelse(facility_types == 'Hospital', 80,
ifelse(facility_types == 'Health Center', 30, 0))
region_effect <- ifelse(regions == 'Addis Ababa', 50,
ifelse(regions == 'Oromia', 20, 0))
day_of_year <- as.numeric(format(dates, '%j'))
seasonal_effect <- 25 * sin(2 * pi * day_of_year / 365)
noise <- rnorm(n_rows, 0, 15)
demand <- pmax(base_demand + facility_effect + region_effect + seasonal_effect + noise, 10)
df <- tibble(
date = dates,
region = regions,
facility_type = facility_types,
demand = as.integer(demand)
)
cat("Data shape:", nrow(df), "rows x", ncol(df), "columns\n")
head(df)# Feature Engineering: Create MANY features (some useful, some noise)
df <- df %>%
mutate(
# Time-based features
month = month(date),
day_of_week = wday(date),
quarter = quarter(date),
day_of_year = yday(date),
week_of_year = isoweek(date),
is_weekend = as.integer(day_of_week %in% c(1, 7)),
# Cyclical encoding for month (sine/cosine)
month_sin = sin(2 * pi * month / 12),
month_cos = cos(2 * pi * month / 12),
# Noise features (to see if LASSO eliminates them)
noise_0 = rnorm(n()),
noise_1 = rnorm(n()),
noise_2 = rnorm(n()),
noise_3 = rnorm(n()),
noise_4 = rnorm(n())
)
cat("After feature engineering:", nrow(df), "rows x", ncol(df), "columns\n")
cat("\nNumeric features:", names(df)[sapply(df, is.numeric)], "\n")# Define features and target using recipes
# We'll use a recipe to handle categorical encoding and scaling
# Split data first
set.seed(42)
data_split <- initial_split(df, prop = 0.8)
train_data <- training(data_split)
test_data <- testing(data_split)
cat("Training samples:", nrow(train_data), "\n")
cat("Test samples:", nrow(test_data), "\n")# Create a recipe for preprocessing
demand_recipe <- recipe(demand ~ ., data = train_data) %>%
# Remove date (not a predictor)
step_rm(date) %>%
# Create dummy variables for categorical features
step_dummy(all_nominal_predictors()) %>%
# IMPORTANT: Normalize features for regularization
step_normalize(all_numeric_predictors())
# Prep the recipe to see what features we have
prepped_recipe <- prep(demand_recipe)
train_processed <- bake(prepped_recipe, new_data = NULL)
cat("Number of features after preprocessing:", ncol(train_processed) - 1, "\n")
cat("Feature names:\n")
print(names(train_processed)[names(train_processed) != "demand"])Part 2: Ridge Regression
Ridge regression adds an L2 penalty: lambda * sum(beta_j^2)
This shrinks all coefficients toward zero but never sets them exactly to zero.
In tidymodels, we use linear_reg() with mixture = 0 for Ridge.
# Fit Ridge with different penalty (lambda) values using glmnet directly for visualization
# Prepare data matrices
X_train <- train_processed %>% select(-demand) %>% as.matrix()
y_train <- train_processed$demand
# Fit Ridge path
ridge_fit <- glmnet(X_train, y_train, alpha = 0) # alpha = 0 is Ridge
# Visualize coefficient shrinkage with Ridge
plot(ridge_fit, xvar = "lambda", label = TRUE)
title("Ridge Regression: Coefficient Paths\n(All coefficients shrink, but none reach zero)")# Use cross-validation to find optimal penalty with tidymodels
# Define the Ridge model specification (mixture = 0)
ridge_spec <- linear_reg(penalty = tune(), mixture = 0) %>%
set_engine("glmnet")
# Create workflow
ridge_workflow <- workflow() %>%
add_recipe(demand_recipe) %>%
add_model(ridge_spec)
# Create cross-validation folds
set.seed(42)
cv_folds <- vfold_cv(train_data, v = 5)
# Define penalty grid
penalty_grid <- tibble(penalty = 10^seq(-3, 3, length.out = 50))
# Tune the model
ridge_tune <- tune_grid(
ridge_workflow,
resamples = cv_folds,
grid = penalty_grid,
metrics = metric_set(rmse, rsq)
)
# Show best results
cat("Best Ridge penalty (by RMSE):\n")
show_best(ridge_tune, metric = "rmse", n = 3)# Finalize and evaluate Ridge model
best_ridge <- select_best(ridge_tune, metric = "rmse")
cat("Optimal penalty:", best_ridge$penalty, "\n")
# Finalize workflow with best parameters
final_ridge <- ridge_workflow %>%
finalize_workflow(best_ridge) %>%
fit(data = train_data)
# Evaluate on test set
ridge_pred <- predict(final_ridge, test_data) %>%
bind_cols(test_data %>% select(demand))
ridge_metrics <- ridge_pred %>%
metrics(truth = demand, estimate = .pred)
ridge_rmse <- ridge_metrics %>% filter(.metric == "rmse") %>% pull(.estimate)
ridge_rsq <- ridge_metrics %>% filter(.metric == "rsq") %>% pull(.estimate)
cat("Ridge Test RMSE:", round(ridge_rmse, 2), "\n")
cat("Ridge Test R-squared:", round(ridge_rsq, 3), "\n")Part 3: LASSO Regression
LASSO adds an L1 penalty: lambda * sum(|beta_j|)
This shrinks coefficients AND can set them exactly to zero (feature selection!).
In tidymodels, we use linear_reg() with mixture = 1 for LASSO.
# Fit LASSO path for visualization
lasso_fit <- glmnet(X_train, y_train, alpha = 1) # alpha = 1 is LASSO
# Visualize coefficient paths with LASSO
plot(lasso_fit, xvar = "lambda", label = TRUE)
title("LASSO Regression: Coefficient Paths\n(Watch coefficients go to EXACTLY zero!)")# Use cross-validation to find optimal penalty for LASSO with tidymodels
# Define the LASSO model specification (mixture = 1)
lasso_spec <- linear_reg(penalty = tune(), mixture = 1) %>%
set_engine("glmnet")
# Create workflow
lasso_workflow <- workflow() %>%
add_recipe(demand_recipe) %>%
add_model(lasso_spec)
# Tune the model (use same folds and grid)
lasso_tune <- tune_grid(
lasso_workflow,
resamples = cv_folds,
grid = penalty_grid,
metrics = metric_set(rmse, rsq)
)
# Show best results
cat("Best LASSO penalty (by RMSE):\n")
show_best(lasso_tune, metric = "rmse", n = 3)# Finalize and evaluate LASSO model
best_lasso <- select_best(lasso_tune, metric = "rmse")
cat("Optimal penalty:", best_lasso$penalty, "\n")
# Finalize workflow with best parameters
final_lasso <- lasso_workflow %>%
finalize_workflow(best_lasso) %>%
fit(data = train_data)
# Evaluate on test set
lasso_pred <- predict(final_lasso, test_data) %>%
bind_cols(test_data %>% select(demand))
lasso_metrics <- lasso_pred %>%
metrics(truth = demand, estimate = .pred)
lasso_rmse <- lasso_metrics %>% filter(.metric == "rmse") %>% pull(.estimate)
lasso_rsq <- lasso_metrics %>% filter(.metric == "rsq") %>% pull(.estimate)
cat("LASSO Test RMSE:", round(lasso_rmse, 2), "\n")
cat("LASSO Test R-squared:", round(lasso_rsq, 3), "\n")# See which features LASSO selected (non-zero coefficients)
lasso_coefs <- final_lasso %>%
extract_fit_parsnip() %>%
tidy()
# Count zero and non-zero coefficients (excluding intercept)
lasso_coefs_no_int <- lasso_coefs %>% filter(term != "(Intercept)")
n_selected <- sum(lasso_coefs_no_int$estimate != 0)
n_eliminated <- sum(lasso_coefs_no_int$estimate == 0)
cat("Features SELECTED by LASSO:", n_selected, "\n")
cat("Features ELIMINATED by LASSO:", n_eliminated, "\n")
cat("\nSelected features:\n")
lasso_coefs_no_int %>%
filter(estimate != 0) %>%
arrange(desc(abs(estimate))) %>%
print()# Visualize LASSO feature selection
lasso_coefs_plot <- lasso_coefs_no_int %>%
mutate(
selected = estimate != 0,
term = fct_reorder(term, abs(estimate))
)
ggplot(lasso_coefs_plot, aes(x = estimate, y = term, fill = selected)) +
geom_col() +
geom_vline(xintercept = 0, color = "black", linewidth = 0.5) +
scale_fill_manual(values = c("TRUE" = "forestgreen", "FALSE" = "gray70"),
labels = c("Eliminated", "Selected")) +
labs(
x = "Coefficient Value",
y = "Feature",
title = "LASSO Feature Selection",
subtitle = "Green = Selected, Gray = Eliminated",
fill = ""
) +
theme_minimal() +
theme(axis.text.y = element_text(size = 8))Discussion
Look at which features LASSO selected: - Did it keep the region and facility features (which have real effects)? - Did it eliminate the noise features? - What about the cyclical month encoding?
Part 4: Elastic Net
Elastic Net combines L1 and L2 penalties:
Loss = MSE + lambda1 * sum(|beta_j|) + lambda2 * sum(beta_j^2)
This gives you the best of both worlds: feature selection (L1) + grouped selection of correlated features (L2).
In tidymodels, mixture controls the blend: 0 = Ridge, 1 = LASSO, 0.5 = balanced mix.
# Fit Elastic Net with cross-validation, tuning both penalty and mixture
# Define the Elastic Net model specification
elastic_spec <- linear_reg(penalty = tune(), mixture = tune()) %>%
set_engine("glmnet")
# Create workflow
elastic_workflow <- workflow() %>%
add_recipe(demand_recipe) %>%
add_model(elastic_spec)
# Define grid for both penalty and mixture
elastic_grid <- grid_regular(
penalty(range = c(-3, 1)),
mixture(range = c(0.1, 1)),
levels = c(30, 6)
)
# Tune the model
elastic_tune <- tune_grid(
elastic_workflow,
resamples = cv_folds,
grid = elastic_grid,
metrics = metric_set(rmse, rsq)
)
# Show best results
cat("Best Elastic Net parameters (by RMSE):\n")
show_best(elastic_tune, metric = "rmse", n = 3)# Finalize and evaluate Elastic Net model
best_elastic <- select_best(elastic_tune, metric = "rmse")
cat("Optimal penalty:", best_elastic$penalty, "\n")
cat("Optimal mixture:", best_elastic$mixture, "\n")
# Finalize workflow with best parameters
final_elastic <- elastic_workflow %>%
finalize_workflow(best_elastic) %>%
fit(data = train_data)
# Evaluate on test set
elastic_pred <- predict(final_elastic, test_data) %>%
bind_cols(test_data %>% select(demand))
elastic_metrics <- elastic_pred %>%
metrics(truth = demand, estimate = .pred)
elastic_rmse <- elastic_metrics %>% filter(.metric == "rmse") %>% pull(.estimate)
elastic_rsq <- elastic_metrics %>% filter(.metric == "rsq") %>% pull(.estimate)
cat("Elastic Net Test RMSE:", round(elastic_rmse, 2), "\n")
cat("Elastic Net Test R-squared:", round(elastic_rsq, 3), "\n")
# Count features selected
elastic_coefs <- final_elastic %>%
extract_fit_parsnip() %>%
tidy() %>%
filter(term != "(Intercept)")
n_selected_elastic <- sum(elastic_coefs$estimate != 0)
cat("Features selected by Elastic Net:", n_selected_elastic, "\n")Part 5: Model Comparison
# Fit OLS for baseline using tidymodels
ols_spec <- linear_reg() %>%
set_engine("lm")
ols_workflow <- workflow() %>%
add_recipe(demand_recipe) %>%
add_model(ols_spec)
ols_fit <- ols_workflow %>% fit(data = train_data)
ols_pred <- predict(ols_fit, test_data) %>%
bind_cols(test_data %>% select(demand))
ols_metrics <- ols_pred %>%
metrics(truth = demand, estimate = .pred)
ols_rmse <- ols_metrics %>% filter(.metric == "rmse") %>% pull(.estimate)
ols_rsq <- ols_metrics %>% filter(.metric == "rsq") %>% pull(.estimate)# Summary comparison table
total_features <- ncol(train_processed) - 1
results <- tibble(
Model = c('OLS (Baseline)', 'Ridge', 'LASSO', 'Elastic Net'),
Test_RMSE = round(c(ols_rmse, ridge_rmse, lasso_rmse, elastic_rmse), 2),
Test_Rsq = round(c(ols_rsq, ridge_rsq, lasso_rsq, elastic_rsq), 3),
Features_Used = c(total_features, total_features, n_selected, n_selected_elastic)
)
cat("Model Comparison:\n")
print(results)# Visualize comparison
p1 <- ggplot(results, aes(x = Model, y = Test_RMSE, fill = Model)) +
geom_col() +
scale_fill_manual(values = c("gray60", "steelblue", "forestgreen", "coral")) +
labs(title = "Model Performance (Lower is Better)", y = "Test RMSE") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), legend.position = "none")
p2 <- ggplot(results, aes(x = Model, y = Features_Used, fill = Model)) +
geom_col() +
scale_fill_manual(values = c("gray60", "steelblue", "forestgreen", "coral")) +
labs(title = "Model Complexity (Features Used)", y = "Number of Features") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), legend.position = "none")
# Display plots side by side
gridExtra::grid.arrange(p1, p2, ncol = 2)# Predictions vs Actual plot
all_preds <- bind_rows(
ols_pred %>% mutate(Model = "OLS"),
ridge_pred %>% mutate(Model = "Ridge"),
lasso_pred %>% mutate(Model = "LASSO"),
elastic_pred %>% mutate(Model = "Elastic Net")
)
ggplot(all_preds, aes(x = demand, y = .pred)) +
geom_point(alpha = 0.5) +
geom_abline(slope = 1, intercept = 0, color = "red", linetype = "dashed", linewidth = 1) +
facet_wrap(~Model, nrow = 2) +
labs(
x = "Actual Demand",
y = "Predicted Demand",
title = "Predictions vs Actual by Model"
) +
theme_minimal()Summary
In this notebook, you:
- Created features including some noise features
- Applied Ridge regression and saw all coefficients shrink
- Applied LASSO regression and saw automatic feature selection
- Applied Elastic Net combining both approaches
- Compared performance across all methods
Key Takeaways
| Method | What it does | When to use | tidymodels mixture |
|---|---|---|---|
| Ridge | Shrinks all coefficients | Many small/medium effects | mixture = 0 |
| LASSO | Sets some coefficients to zero | Feature selection needed | mixture = 1 |
| Elastic Net | Combines both | Correlated features + selection | mixture = 0.5 |
Connection to This Afternoon
You now have a LASSO baseline to compare against: - Decision Trees - Random Forests (using rand_forest() with ranger engine) - Gradient Boosting (using boost_tree() with xgboost engine)
These tree-based methods handle nonlinearities differently but also have their own regularization (max_depth, min_n, etc.).
Next: Tree-Based Methods notebook