# Install packages if needed (run once in Colab)
if (!require("tidymodels", quietly = TRUE)) {
install.packages(c("tidymodels", "ranger", "xgboost", "vip"))
}
# Load packages
library(tidymodels)
library(tidyverse)
library(vip)
# Settings
set.seed(42)
theme_set(theme_minimal())
cat("Packages loaded!\n")Day 2 (Part 2): Model Selection & Tuning (R Version)
WISE Workshop | Addis Ababa, Feb 2026
In this notebook, you’ll learn how to systematically improve your ML models through: - Cross-validation for robust evaluation - Hyperparameter tuning (grid search, random search) - Feature importance analysis - Final model selection
Setup
Make sure you’re using the R runtime: Runtime -> Change runtime type -> R
Part 1: Load Data
We’ll use the same supply chain dataset from the previous notebook.
# Load the supply chain dataset
url <- "https://raw.githubusercontent.com/sysylvia/ethiopia-ds-workshop-2026/main/data/supply-chain-sample.csv"
tryCatch({
df <- read_csv(url, show_col_types = FALSE)
cat("Data loaded from GitHub!\n")
}, error = function(e) {
# Create sample data if URL not available
cat("Creating sample data...\n")
set.seed(42)
n <- 1000
df <<- tibble(
region = sample(c('Addis Ababa', 'Oromia', 'Amhara', 'SNNP', 'Tigray'), n, replace = TRUE),
facility_type = sample(c('Hospital', 'Health Center', 'Clinic'), n, replace = TRUE, prob = c(0.2, 0.5, 0.3)),
season = sample(c('dry', 'rainy'), n, replace = TRUE),
population_served = round(runif(n, 5000, 100000)),
month = sample(1:12, n, replace = TRUE),
previous_demand = round(100 + rnorm(n, 0, 30)),
distance_to_warehouse = round(runif(n, 10, 500)),
stockout_last_month = sample(0:1, n, replace = TRUE, prob = c(0.8, 0.2)),
avg_delivery_days = round(runif(n, 3, 21)),
storage_capacity = round(runif(n, 500, 5000)),
actual_demand = round(100 +
ifelse(facility_type == 'Hospital', 80, ifelse(facility_type == 'Health Center', 40, 0)) +
0.3 * previous_demand +
rnorm(n, 0, 20))
)
})
cat("Data shape:", nrow(df), "rows x", ncol(df), "columns\n")
cat("Columns:", paste(names(df), collapse = ", "), "\n")
head(df)# Prepare features
# Convert categorical to factors for proper handling
df <- df %>%
mutate(
region = factor(region),
facility_type = factor(facility_type),
season = factor(season)
)
cat("Features prepared\n")# Split data
set.seed(42)
data_split <- initial_split(df, prop = 0.8)
train_data <- training(data_split)
test_data <- testing(data_split)
cat("Training set:", nrow(train_data), "samples\n")
cat("Test set:", nrow(test_data), "samples\n")Part 2: Why Tune Models?
Hyperparameters vs Learned Parameters
| Type | What it is | Examples | How set? |
|---|---|---|---|
| Learned parameters | Values learned from data | Regression coefficients, tree splits | Training algorithm |
| Hyperparameters | Choices that control learning | Number of trees, learning rate, max depth | You choose! |
Key insight: Wrong hyperparameters can lead to underfitting (too simple) or overfitting (too complex).
Part 3: Cross-Validation Deep Dive
Cross-validation gives us a more robust estimate of model performance than a single train/test split.
K-Fold Cross-Validation
Fold 1: [TEST] [train] [train] [train] [train]
Fold 2: [train] [TEST] [train] [train] [train]
Fold 3: [train] [train] [TEST] [train] [train]
Fold 4: [train] [train] [train] [TEST] [train]
Fold 5: [train] [train] [train] [train] [TEST]
Final score = average of all 5 folds
# Define recipe for tree models
tree_recipe <- recipe(actual_demand ~ population_served + month + previous_demand +
distance_to_warehouse + stockout_last_month +
avg_delivery_days + storage_capacity +
region + facility_type + season,
data = train_data)
# Baseline: Random Forest with default settings
rf_default_spec <- rand_forest() %>%
set_engine("ranger") %>%
set_mode("regression")
rf_default_wf <- workflow() %>%
add_recipe(tree_recipe) %>%
add_model(rf_default_spec)
# 5-fold cross-validation
set.seed(42)
cv_folds <- vfold_cv(train_data, v = 5)
# Fit and evaluate across folds
cv_results <- fit_resamples(
rf_default_wf,
resamples = cv_folds,
metrics = metric_set(rmse, rsq)
)
# Collect metrics
cv_metrics <- collect_metrics(cv_results)
cat("5-Fold Cross-Validation Results:\n")
print(cv_metrics)# Visualize cross-validation results per fold
cv_rmse_per_fold <- cv_results %>%
collect_metrics(summarize = FALSE) %>%
filter(.metric == "rmse")
mean_rmse <- mean(cv_rmse_per_fold$.estimate)
sd_rmse <- sd(cv_rmse_per_fold$.estimate)
ggplot(cv_rmse_per_fold, aes(x = id, y = .estimate)) +
geom_col(fill = "steelblue", alpha = 0.7) +
geom_hline(yintercept = mean_rmse, color = "red", linetype = "dashed") +
geom_ribbon(aes(ymin = mean_rmse - sd_rmse, ymax = mean_rmse + sd_rmse, group = 1),
fill = "red", alpha = 0.1) +
labs(
x = "Fold",
y = "RMSE",
title = "Cross-Validation Performance Across Folds",
subtitle = sprintf("Mean RMSE: %.2f (+/- %.2f)", mean_rmse, sd_rmse)
) +
theme_minimal()Part 4: Grid Search for Hyperparameter Tuning
Grid Search tries every combination of hyperparameters you specify.
For Random Forest, key hyperparameters include: - trees: Number of trees (more = better but slower) - mtry: Number of features to consider at each split - min_n: Minimum samples in leaf nodes (prevents overly specific rules)
# Define tunable Random Forest specification
rf_tune_spec <- rand_forest(
trees = tune(),
mtry = tune(),
min_n = tune()
) %>%
set_engine("ranger", importance = "impurity") %>%
set_mode("regression")
rf_tune_wf <- workflow() %>%
add_recipe(tree_recipe) %>%
add_model(rf_tune_spec)
# Define hyperparameter grid
rf_grid <- grid_regular(
trees(range = c(50, 200)),
mtry(range = c(2, 8)),
min_n(range = c(2, 10)),
levels = 3
)
cat("Total combinations to try:", nrow(rf_grid), "\n")
cat("With 5-fold CV:", nrow(rf_grid) * 5, "model fits\n")
print(head(rf_grid))# Run grid search
cat("Running grid search (this may take a minute)...\n")
set.seed(42)
rf_tune_results <- tune_grid(
rf_tune_wf,
resamples = cv_folds,
grid = rf_grid,
metrics = metric_set(rmse, rsq)
)
# Show best results
cat("\nTop 5 Configurations:\n")
show_best(rf_tune_results, metric = "rmse", n = 5)# Get best parameters
best_rf_params <- select_best(rf_tune_results, metric = "rmse")
cat("Best parameters:\n")
print(best_rf_params)Part 5: Visualize Hyperparameter Effects
# Visualize tuning results
autoplot(rf_tune_results) +
theme_minimal() +
labs(title = "Hyperparameter Tuning Results")# Effect of trees on performance
tune_metrics <- collect_metrics(rf_tune_results) %>%
filter(.metric == "rmse")
ggplot(tune_metrics, aes(x = trees, y = mean, color = factor(min_n))) +
geom_line() +
geom_point() +
facet_wrap(~mtry, labeller = label_both) +
labs(
x = "Number of Trees",
y = "Mean CV RMSE",
color = "min_n",
title = "Effect of Hyperparameters on Performance"
) +
theme_minimal()Part 6: Random Search (Faster Alternative)
When the hyperparameter space is large, Random Search samples random combinations instead of trying everything.
# Define a larger search space for random search
rf_random_grid <- grid_random(
trees(range = c(50, 300)),
mtry(range = c(2, 10)),
min_n(range = c(2, 20)),
size = 20 # Only try 20 random combinations
)
cat("Random search will try", nrow(rf_random_grid), "combinations\n")
# Run random search
set.seed(42)
rf_random_results <- tune_grid(
rf_tune_wf,
resamples = cv_folds,
grid = rf_random_grid,
metrics = metric_set(rmse, rsq)
)
cat("\nBest from random search:\n")
show_best(rf_random_results, metric = "rmse", n = 3)Part 7: Feature Importance Analysis
Understanding which features matter helps: - Interpret the model - Identify important supply chain factors - Guide future data collection
# Finalize and fit the best model
final_rf_wf <- rf_tune_wf %>%
finalize_workflow(best_rf_params)
final_rf_fit <- final_rf_wf %>%
fit(data = train_data)
# Feature importance using vip
final_rf_fit %>%
extract_fit_parsnip() %>%
vip(num_features = 10) +
labs(title = "Feature Importance (Tuned Random Forest)") +
theme_minimal()# Get importance values as a table
importance_df <- final_rf_fit %>%
extract_fit_parsnip() %>%
vi() %>%
arrange(desc(Importance))
cat("Feature Importance Table:\n")
print(importance_df)Part 8: Final Model Evaluation
Now we evaluate the tuned model on the held-out test set (which we haven’t touched during tuning).
# Compare default vs tuned model on test set
# Fit default model
default_fit <- rf_default_wf %>% fit(data = train_data)
default_pred <- predict(default_fit, test_data) %>%
bind_cols(test_data %>% select(actual_demand))
# Predict with tuned model
tuned_pred <- predict(final_rf_fit, test_data) %>%
bind_cols(test_data %>% select(actual_demand))
# Calculate metrics
default_metrics <- default_pred %>% metrics(truth = actual_demand, estimate = .pred)
tuned_metrics <- tuned_pred %>% metrics(truth = actual_demand, estimate = .pred)
# Comparison table
comparison <- tibble(
Model = c('Default RF', 'Tuned RF'),
RMSE = c(
default_metrics %>% filter(.metric == "rmse") %>% pull(.estimate),
tuned_metrics %>% filter(.metric == "rmse") %>% pull(.estimate)
),
MAE = c(
default_metrics %>% filter(.metric == "mae") %>% pull(.estimate),
tuned_metrics %>% filter(.metric == "mae") %>% pull(.estimate)
),
R_squared = c(
default_metrics %>% filter(.metric == "rsq") %>% pull(.estimate),
tuned_metrics %>% filter(.metric == "rsq") %>% pull(.estimate)
)
) %>%
mutate(across(where(is.numeric), ~round(., 3)))
cat("Test Set Performance:\n")
print(comparison)# Visualize predictions
all_preds <- bind_rows(
default_pred %>% mutate(Model = "Default RF"),
tuned_pred %>% mutate(Model = "Tuned RF")
)
ggplot(all_preds, aes(x = actual_demand, y = .pred)) +
geom_point(alpha = 0.5) +
geom_abline(slope = 1, intercept = 0, color = "red", linetype = "dashed", linewidth = 1) +
facet_wrap(~Model) +
labs(
x = "Actual Demand",
y = "Predicted Demand",
title = "Default vs Tuned Random Forest"
) +
theme_minimal()Part 9: Save Model for Day 3
We’ll save the trained model so we can use it in the ML-to-ABM integration notebook.
# Save the tuned workflow (includes preprocessing and model)
saveRDS(final_rf_fit, "demand_model.rds")
# Also save model info
model_info <- list(
best_params = best_rf_params,
test_rmse = tuned_metrics %>% filter(.metric == "rmse") %>% pull(.estimate),
feature_importance = importance_df
)
saveRDS(model_info, "demand_model_info.rds")
cat("Model saved as 'demand_model.rds'\n")
cat("\nModel summary:\n")
cat(" Best parameters: trees =", best_rf_params$trees,
", mtry =", best_rf_params$mtry,
", min_n =", best_rf_params$min_n, "\n")
cat(" Test RMSE:", round(model_info$test_rmse, 2), "\n")Summary
In this notebook, you learned:
- Cross-validation (
vfold_cv()) provides robust performance estimates - Grid search (
tune_grid()+grid_regular()) systematically explores hyperparameter combinations - Random search (
grid_random()) is faster for large search spaces - Feature importance (
vip()) helps interpret model decisions - Final evaluation on held-out test data prevents overfitting
Key tidymodels Functions for Tuning
| Task | Function |
|---|---|
| Mark parameter for tuning | tune() |
| Create CV folds | vfold_cv() |
| Create parameter grid | grid_regular(), grid_random() |
| Tune model | tune_grid() |
| Get best parameters | select_best() |
| Finalize workflow | finalize_workflow() |
| Feature importance | vip(), vi() |
Key Takeaways for Supply Chain Forecasting
- Previous demand is typically the strongest predictor
- Facility characteristics (type, capacity) capture structural differences
- Seasonal patterns (rainy/dry) affect demand
- Distance and delivery time influence supply chain dynamics
Next: Use these predictions as inputs to Agent-Based Models (Day 3)
Exercise (Optional)
Try tuning a Gradient Boosting model using the same process:
- Define a parameter grid for
boost_tree()(trytrees,learn_rate,tree_depth) - Run grid search with 5-fold CV
- Compare the best GB model with the tuned RF model
Which performs better on this data?
# Your code here
# Hint: boost_tree key parameters:
# - trees: 50, 100, 200
# - learn_rate: 0.01, 0.1, 0.3
# - tree_depth: 3, 5, 7
# gb_spec <- boost_tree(
# trees = tune(),
# learn_rate = tune(),
# tree_depth = tune()
# ) %>%
# set_engine("xgboost") %>%
# set_mode("regression")