
H. Sherry Zhang
Department of Statistics and Data Sciences
University of Texas at Austin
library(usethis) create_from_github("SDS322E-26FALL/1202-trees", fork = FALSE)
tidymodels for both regression and classification taskstidymodels (with hyper-parameter tuning)Can we predict penguin sex from bill_len and bill_dep with a few simple rules?

A tree is a set of yes/no questions. Start at the top and follow the answers down:
bill_len < 48? No (right): predict malebill_dep < 18? …Each leaf at the bottom is a prediction: the majority class of the training penguins that end up there.
Divide/partition the predictor space \(X_1, \ldots, X_p\) into \(J\) regions \(R_1, \ldots, R_J\).
For every observation that falls within region \(R_j\), we make the same prediction: the majority class of the training observations in \(R_j\).
The goal is to increase the “purity” of each region, so that most regions are dominated by a single class: choose the splits that minimize the impurity (Gini index or entropy, see the appendix).
Each rectangle is one leaf of the tree: the splits are always parallel to the axes.
The same partition of the predictor space into regions \(R_1, \ldots, R_J\), but the response is now numeric.
For every observation that falls within region \(R_j\), we predict \(\bar{y}_{R_j}\): the mean of the response values for the training observations in \(R_j\) (instead of the majority class).
Purity doesn’t apply to a numeric response, so we choose the splits that minimize the residual sum of squares instead: \(RSS = \sum_{j=1}^{J} \sum_{i \in R_j} (y_i - \bar{y}_{R_j})^2\)
The same idea works for both: split the predictor space into rectangles, and make one prediction per rectangle.
| Regression tree | Classification tree | |
|---|---|---|
| Response | numeric, e.g. body_mass |
categorical, e.g. sex |
| Prediction in a region | the mean of the training responses | the majority class of the training responses |
| Choose the splits to minimize | the residual sum of squares (RSS) | the impurity (Gini index or entropy) |
tidymodels |
decision_tree(mode = "regression") |
decision_tree(mode = "classification") |
The same is true for KNN: average the responses of the \(k\) nearest neighbors for regression, or take their majority vote for classification, with nearest_neighbor(mode = "regression") or nearest_neighbor(mode = "classification").
A tree keeps splitting until the regions are pure or too small. The hyperparameter min_n is the minimum number of penguins a region must have to be split again:
min_n: many small regions that follow individual penguins: a very flexible tree, like a small \(k\) in KNNmin_n: a few big regions: a simple treemin_n is a hyperparameter, so we can choose it with cross-validationHere we set cost_complexity = 0, so that min_n alone controls the size of the tree. cost_complexity and tree_depth are two other ways to limit the size of a tree.
A bootstrap sample is a new dataset of the same size, drawn from the original data with replacement: pick a row at random, put it back, and repeat.
For example, with 6 penguins:
| Original data | 1 | 2 | 3 | 4 | 5 | 6 |
| Bootstrap sample | 2 | 2 | 4 | 5 | 5 | 6 |
Fit the same classification tree for sex (min_n = 15) on three bootstrap samples of the 333 penguins:
Small changes in the data give very different trees: a single tree has high variance, just like KNN with \(k = 1\).
Lesson from KNN: averaging over many neighbors gave a much more stable prediction. Can we average over many trees?
A random forest grows many trees (default 500), each one a little different, and lets them vote:
mtry predictorsWhy step 2? If one predictor is very strong, every tree would split on it first, and all the trees would look alike: averaging similar trees doesn’t reduce the variance much. Hiding it from some splits makes the trees different, so their average is more stable.
To predict, combine the trees:
rand_forest(mode = "classification") orrand_forest(mode = "regression").A new penguin with bill_len = 41 and bill_dep = 16: ask every tree in the forest. Tree \(k\) is the tree (min_n = 15) on bootstrap sample \(k\)
| Vote | |
|---|---|
| Tree 1 | female |
| Tree 2 | female |
| Tree 3 | male |
| Tree 4 | male |
| Tree 5 | female |
| Tree 6 | male |
| … | … |
| Tree 500 | male |

Final prediction: female, the majority: 287 of the 500 trees vote female, even though some trees disagree
Do this vote at every point of the space: trees 1 to 3 (the same trees as on bootstrap samples 1 to 3), and the final prediction of all 500 trees
min_n: how deep each tree grows, as in a single tree
mtry (new): how many predictors each split can choose from
trees (new): how many trees to average
trees: more trees never hurtExample (as in the tuning workflow): predict body_mass from the other six penguin variables, and compute the RMSE on the testing data
trees, we just use enough of them (the default, 500, is plenty here)mtry: how different are the trees?Which predictor does each of the 500 trees split on first?
mtry = 6 (all predictors): every tree picks the strongest predictors (species, flipper_len) first, so the trees are near-copies, and averaging copies doesn’t reduce the variance muchmtry: the trees are forced to use different predictors, so they are different, and their average is more stable. But if mtry is too small, each tree is too weakmtry is somewhere in between, and depends on the data: tune it with cross-validationStep 0: Training-testing split
Step 1: Specify the model, with mtry and min_n left to tune
Step 2: Specify the pre-processing recipe
Step 3: Build a workflow
Step 4: Fit the model
Step 5/6: Predict and calculate prediction accuracy metrics on the testing data
Predict body_mass from the other six penguin variables.
Step 0: Training-testing split:
Step 1/2/3: Specify the model, with mtry = tune() and min_n = tune(), the pre-processing recipe, and the workflow
Step 4a: Create the cross-validation folds from the training data
# 10-fold cross-validation
# A tibble: 10 × 2
splits id
<list> <chr>
1 <split [224/25]> Fold01
2 <split [224/25]> Fold02
3 <split [224/25]> Fold03
4 <split [224/25]> Fold04
5 <split [224/25]> Fold05
6 <split [224/25]> Fold06
7 <split [224/25]> Fold07
8 <split [224/25]> Fold08
9 <split [224/25]> Fold09
10 <split [225/24]> Fold10
Step 4b: Set up the hyperparameter grid: now two hyperparameters
rf_grid <- grid_regular(mtry(range = c(1, 6)), min_n(range = c(2, 20)), levels = c(6, 4))
rf_grid |> print(n = 8)# A tibble: 24 × 2
mtry min_n
<int> <int>
1 1 2
2 2 2
3 3 2
4 4 2
5 5 2
6 6 2
7 1 8
8 2 8
# ℹ 16 more rows
tune(): mtry and min_nlevels = c(6, 4): 6 values of mtry (1 to 6) \(\times\) 4 values of min_n (2, 8, 14, 20) = 24 combinations: every value of mtry is paired with every value of min_nStep 4c: Fit the forest for every combination on every fold
# Tuning results
# 10-fold cross-validation
# A tibble: 10 × 4
splits id .metrics .notes
<list> <chr> <list> <list>
1 <split [224/25]> Fold01 <tibble [48 × 6]> <tibble [0 × 4]>
2 <split [224/25]> Fold02 <tibble [48 × 6]> <tibble [0 × 4]>
3 <split [224/25]> Fold03 <tibble [48 × 6]> <tibble [0 × 4]>
4 <split [224/25]> Fold04 <tibble [48 × 6]> <tibble [0 × 4]>
5 <split [224/25]> Fold05 <tibble [48 × 6]> <tibble [0 × 4]>
6 <split [224/25]> Fold06 <tibble [48 × 6]> <tibble [0 × 4]>
7 <split [224/25]> Fold07 <tibble [48 × 6]> <tibble [0 × 4]>
8 <split [224/25]> Fold08 <tibble [48 × 6]> <tibble [0 × 4]>
9 <split [224/25]> Fold09 <tibble [48 × 6]> <tibble [0 × 4]>
10 <split [225/24]> Fold10 <tibble [48 × 6]> <tibble [0 × 4]>
Question: how many trees are we fitting here?
24 combinations \(\times\) 10 folds = 240 random forests, and each forest has 500 trees: 240 \(\times\) 500 = 120,000 trees!
.metrics: for each fold, the validation metrics (rmse, rsq) of every combinationStep 4d: Average the metrics over the 10 folds
# A tibble: 24 × 8
mtry min_n .metric .estimator mean n std_err .config
<int> <int> <chr> <chr> <dbl> <int> <dbl> <chr>
1 1 2 rmse standard 305. 10 10.9 pre0_mod01_post0
2 1 8 rmse standard 305. 10 10.0 pre0_mod02_post0
3 1 14 rmse standard 311. 10 10.1 pre0_mod03_post0
4 1 20 rmse standard 311. 10 10.3 pre0_mod04_post0
5 2 2 rmse standard 286. 10 11.0 pre0_mod05_post0
6 2 8 rmse standard 286. 10 10.8 pre0_mod06_post0
7 2 14 rmse standard 288. 10 10.9 pre0_mod07_post0
8 2 20 rmse standard 291. 10 10.8 pre0_mod08_post0
9 3 2 rmse standard 287. 10 11.1 pre0_mod09_post0
10 3 8 rmse standard 287. 10 11.6 pre0_mod10_post0
# ℹ 14 more rows
mtry and min_n: mean is the average validation RMSE over the n = 10 foldsStep 4d: Average the metrics over the 10 folds
best_rf <- select_best(rf_tune_res, metric = "rmse")
rf_tune_res |>
collect_metrics() |>
filter(.metric == "rmse") |>
ggplot(aes(x = mtry, y = mean, color = factor(min_n))) +
geom_line() +
geom_point() +
geom_point(data = \(d) filter(d, mtry == best_rf$mtry, min_n == best_rf$min_n),
size = 5, shape = 21, stroke = 1.5, color = "black") +
scale_x_continuous(breaks = 1:6) +
labs(x = "mtry (predictors considered at each split)", y = "Cross validation RMSE (g)",
color = "min_n")Lower RMSE is better: the circled combination has the lowest average RMSE.
Step 4e: Find the best combination
# A tibble: 5 × 8
mtry min_n .metric .estimator mean n std_err .config
<int> <int> <chr> <chr> <dbl> <int> <dbl> <chr>
1 2 2 rmse standard 286. 10 11.0 pre0_mod05_post0
2 2 8 rmse standard 286. 10 10.8 pre0_mod06_post0
3 3 14 rmse standard 287. 10 11.2 pre0_mod11_post0
4 3 2 rmse standard 287. 10 11.1 pre0_mod09_post0
5 4 2 rmse standard 287. 10 11.6 pre0_mod13_post0
# A tibble: 1 × 3
mtry min_n .config
<int> <int> <chr>
1 2 2 pre0_mod05_post0
show_best(): the top combinations, sorted by mean RMSE (lowest first)select_best(): a one-row tibble with the best mtry and min_n, ready to plug into the workflowStep 4f: Plug in the best values and refit on the whole training data
══ Workflow ════════════════════════════════════════════════════════════════════
Preprocessor: Recipe
Model: rand_forest()
── Preprocessor ────────────────────────────────────────────────────────────────
0 Recipe Steps
── Model ───────────────────────────────────────────────────────────────────────
Random Forest Model Specification (regression)
Main Arguments:
mtry = 2
trees = 500
min_n = 2
Computational engine: ranger
tune() placeholders are replaced by mtry = 2 and min_n = 2: now an ordinary workflow that we can fitStep 4f: Plug in the best values and refit on the whole training data
Step 5/6: Predict and calculate prediction accuracy metrics on the testing data, once
res_final <- augment(final_rf_fit, penguins_test)
metrics(res_final, truth = body_mass, estimate = .pred)# A tibble: 3 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 rmse standard 313.
2 rsq standard 0.842
3 mae standard 255.
last_fit(final_rf_wf, split = penguins_split) does the refit and the testing-data evaluation in one step.