ECON 4370 / 6370 Computing for Economics

Lecture 13: Trees and Random Forests

Zhan Gao

08 October 2026

Three ways to predict California house prices

Boxplots of out-of-sample root mean squared error for three models over ten random splits: the lasso spans 0.34 to 1.6 with a median near 0.8, the pruned tree sits around 0.31 and the random forest around 0.25.

  • The random forest predicts best, with no tuning parameter chosen by cross-validation.
  • A single pruned tree comes second. The lasso, with every covariate interacted with longitude and latitude, is erratic: one block group with 1,243 persons per household wrecks it whenever it lands in the test set.

Today’s route

  1. From bins to trees: the binned sample mean, its two problems, and the tree as a data-chosen set of bins.
  2. Growing a tree: a deviance to minimize, the greedy split, and tree() on motorcycle crashes and TV shows.
  3. Pruning: why stopping rules fail, weakest-link pruning, cost-complexity and cross-validation.
  4. Random forests: bagging, random covariate subsets, variable importance, and the California housing contest.

Additional derivations and code follow the main lesson.

Packages and data

Run the examples from the lectures/ directory.

library(MASS)       # the mcycle data; load before tidyverse so dplyr::select wins
library(tidyverse)
library(tree)       # tree(), prune.tree(), cv.tree()
library(ranger)     # fast random forests
library(glmnet)     # the lasso, for the comparison at the end
File Role
MASS::mcycle Helmet acceleration after a simulated motorcycle crash, 133 readings
data/nbc_showdetails.csv, data/nbc_demographics.csv 40 TV shows: genre, ratings, engagement and audience shares
data/prostate.csv 97 prostate cancer patients
data/CAhousing.csv 20,640 California census block groups

From bins to trees


A tree is a binned sample mean whose bins are chosen by the data

Recall the binned sample mean

In lecture 11 we predicted Y from a scalar X by

  1. cutting the range of X into bins at sample quantiles, and
  2. predicting Y_{n+1} by the average of the Y_i whose X_i fall in the same bin as X_{n+1}.

The number of bins was the complexity parameter, and cross-validation chose it.

Two things were unsatisfying.

  • Bins of equal count everywhere. If \mathbf{E}[Y \mid X = x] is flat in some regions and wiggly in others, the bins should be wide where it is flat and narrow where it is wiggly.
  • Only one covariate. With d covariates and c cutpoints on each, cutting the sample by every variable gives (c+1)^d bins.

The curse of dimensionality

With c = 4 cutpoints per covariate, the grid of bins grows as 5^d:

Covariates d Bins 5^d Observations per bin when n = 20{,}640
1 5 4,128
2 25 826
5 3,125 7
9 1,953,125 0.01

Most bins are empty long before d reaches ten. We need bins that are adaptive: narrow only along the covariates and in the regions where Y actually changes, and wide elsewhere.

The lasso solved an analogous problem for linear models by selecting covariates. A regression tree is a way of selecting bins.

A decision tree

A decision tree is a nested sequence of if-else statements that takes an input and returns a conclusion. Here the input is the weather and the conclusion is whether to carry an umbrella.

A decision tree starting at Wake Up: if more than 70 percent chance of rain, umbrella; otherwise if more than 30 percent, umbrella if cloudy and no umbrella if sunny; if less than 30 percent, no umbrella.

  • The boxes are nodes with a parent and child structure. The nodes at the end, leaves, hold the conclusions.
  • A diagram of this kind is a dendrogram.

A regression tree

A tree whose root tests X1 at most t1; the left child tests X2 at most t2 and leads to leaves R1 and R2; the right child tests X1 at most t3, leading to R3 or to a further test X2 at most t4 with leaves R4 and R5.

The unit square partitioned by the tree: a vertical line at t1 and a horizontal line at t2 on its left; a vertical line at t3 and a horizontal line at t4 on the far right; five rectangles R1 to R5.

  • Each internal node is a threshold on one covariate: an observation goes left if X_{i,1} \le t_1 and right otherwise.
  • Each leaf is a rectangle R_m of the covariate space. X_i lands in R_1 when X_{i,1} \le t_1 and X_{i,2} \le t_2.

What a tree predicts

Send the n observations down the tree. In each leaf, output a prediction from the observations that landed there:

  • Y real-valued: the leaf mean, \hat{\mathbf{E}}[Y \mid X_1 \le t_1, X_2 \le t_2] in R_1.
  • Y categorical: the share of each category, \hat{\mathbf{P}}(Y = m \mid X_1 \le t_1, X_2 \le t_2) for every class m.

A new X_{n+1} is dropped down the same tree; its leaf gives the prediction.

A regression tree is the binned sample mean with the bins given by the leaves. The tree decides which covariate to cut, where, and how finely.

The same construction handles classification, so the family is called CART, classification and regression trees. We say “regression tree” for both.

The fitted function is a step function

A three-dimensional surface over the X1, X2 square that is constant on each of the five rectangles, with vertical jumps at the boundaries.

A partition of the square into five regions that cannot be produced by successive binary splits: an L-shaped region wraps around an inner rectangle.

  • Left: \hat f(x) = \sum_m \hat c_m \, \mathbf{1}\{x \in R_m\} is constant on each leaf and jumps at the thresholds.
  • The partition is a recursive binary partition: each split cuts one existing rectangle in two. Right: a partition no tree can produce, because the first cut would have to run through a region.

Checkpoint: bins and trees

  1. A tree splits first on X_1 at 3, then splits the left child on X_2 at 10. How many leaves are there, and which rectangle does (X_1, X_2) = (2, 12) fall in?
  2. Why does cutting every covariate at its quartiles fail with nine covariates and 20,000 observations?
  3. In what sense is a regression tree “adaptive” where the quantile bins of lecture 11 were not?

Growing a tree


A deviance to minimize and a greedy search

A deviance to minimize

Which tree? As with every estimator this semester, choose the one that minimizes a deviance, a loss function summed over the sample.

  • Y real-valued, sum of squares: \sum_{i=1}^n \big(Y_i - \hat{\mathbf{E}}[Y \mid X_i]\big)^2, \qquad \text{OLS used } X_i'\hat\beta \text{ in place of } \hat{\mathbf{E}}[Y \mid X_i].
  • Y categorical with K classes, multinomial deviance: -2\sum_{i=1}^n \sum_{k=1}^K \mathbf{1}\{Y_i = k\} \log \hat{\mathbf{P}}(Y_i = k \mid X_i).
  • Most software offers the Gini impurity instead, \sum_i \sum_k \hat p_k(X_i)\,(1 - \hat p_k(X_i)), which estimates the variance of \mathbf{1}\{Y_i = k\} in each leaf. The two rarely disagree.

The search is greedy

Minimizing the deviance over all trees is infeasible: the number of trees grows exponentially with the number of splits.

Instead, recursive binary splitting builds the tree one split at a time. For the first split, consider every covariate j and every observed value s of X_{ij}:

\text{left} = \{\ell : X_{\ell j} \le s\}, \qquad \text{right} = \{\ell : X_{\ell j} > s\},

predict by the mean (or the class shares) within each half, and compute the resulting deviance

\sum_{i \in \text{left}} (Y_i - \bar Y_{\text{left}})^2 + \sum_{i \in \text{right}} (Y_i - \bar Y_{\text{right}})^2.

Pick the (j, s) with the smallest deviance. Then repeat inside each child, using only its observations, and keep going.

The first split by hand: motorcycle crashes

mcycle records helmet acceleration (in g) against time (ms) after a simulated impact. Scan every candidate cutpoint s on times and record the deviance of the two-leaf tree.

split_deviance <- function(s) {
  left <- mcycle$accel[mcycle$times <= s]
  right <- mcycle$accel[mcycle$times > s]
  sum((left - mean(left))^2) + sum((right - mean(right))^2)
}
cutpoints <- head(sort(unique(mcycle$times)), -1)     # every observed time but the last
dev_path <- tibble(s = cutpoints, deviance = sapply(cutpoints, split_deviance))
dev_path |> slice_min(deviance, n = 1)
## # A tibble: 1 × 2
##       s deviance
##   <dbl>    <dbl>
## 1  27.2  200123.
sum((mcycle$accel - mean(mcycle$accel))^2)             # deviance with no split
## [1] 308222.7

The best single cut is between 27.2 and 27.6 ms and removes a third of the deviance.

The first split, pictured

Left: the two-leaf deviance against the candidate cutpoint, a curve starting near the no-split deviance of 308,000, dipping to a minimum of 200,000 at 27.2 milliseconds and rising again. Right: the acceleration readings against time with a vertical line at the chosen cut and two horizontal segments at the left and right means.

Both halves are still far from flat. Recursive binary splitting now repeats the scan inside each half.

The CART algorithm and its stopping rules

  1. Find the split (j, s) that minimizes the deviance.
  2. Split the node into left and right children and route the observations.
  3. Repeat steps 1 and 2 in each child.
  4. Stop splitting a node when it is too small or when no split helps enough. That node is a leaf.

In tree() the stopping rules are

Argument Rule Default
mincut each child must contain at least this many observations 5
minsize a node smaller than this is not split 10
mindev a split must cut the deviance by at least mindev times the root deviance 0.01

tree() on the motorcycle data

The syntax is that of lm() and glm(). plot() draws the dendrogram and text() labels it.

collision <- tree(accel ~ times, data = mcycle)
par(mar = c(1, 2, 1.5, 2), xpd = NA)      # room for the labels at the edges
plot(collision, col = 8); text(collision, cex = 0.85, font = 2)

Dendrogram of the fitted tree: the root splits at times 27.4; the left branch splits at 16.5 and then at 15.1 and 24.4 and 19.5; the right branch splits at 35 and 29.8. Eight leaves show the mean acceleration in each.

Branch length is proportional to the deviance removed by the split, so the first cut at 27.4 ms is the long one.

Reading the printed tree

collision
## node), split, n, deviance, yval
##       * denotes terminal node
## 
##  1) root 133 308200.0  -25.550  
##    2) times < 27.4 84 160500.0  -47.320  
##      4) times < 16.5 43  18020.0  -16.480  
##        8) times < 15.1 28    724.1   -4.357 *
##        9) times > 15.1 15   5494.0  -39.120 *
##      5) times > 16.5 41  58660.0  -79.660  
##       10) times < 24.4 27  17040.0  -98.940  
##         20) times < 19.5 15   9045.0  -86.310 *
##         21) times > 19.5 12   2616.0 -114.700 *
##       11) times > 24.4 14  12240.0  -42.490 *
##    3) times > 27.4 49  39670.0   11.780  
##      6) times < 35 16  13300.0   29.290  
##       12) times < 29.8 6   3900.0   10.250 *
##       13) times > 29.8 10   5919.0   40.720 *
##      7) times > 35 33  19080.0    3.291 *

The fitted step function

grid <- data.frame(times = seq(0, 60, length.out = 1000))
grid$fit <- predict(collision, newdata = grid)
ggplot(mcycle, aes(times, accel)) + geom_point(alpha = 0.5) +
  geom_line(data = grid, aes(times, fit), colour = smu_red, linewidth = 1.2) +
  labs(x = "time since impact (ms)", y = "acceleration (g)")

Acceleration against time with the fitted tree as a red step function with eight steps: near zero until 15 ms, down to minus 115 around 20 ms, back up to 40 near 30 ms, then near zero.

The leaves are narrow where the acceleration swings and wide where it is flat, exactly the adaptivity that quantile bins lacked.

TV shows: genre from audience shares

nbc <- read.csv("data/nbc_showdetails.csv")
demos <- read.csv("data/nbc_demographics.csv", row.names = 1)
dim(demos)
## [1] 40 56
round(demos[1:4, c(1:2, 7:8, 12:13)])
##                   TERRITORY.EAST.CENTRAL TERRITORY.NORTHEAST COUNTY.SIZE.A COUNTY.SIZE.B
## Living with Ed                         6                  19            49            31
## Monarch Cove                          10                  14            35            32
## Top Chef                               8                  24            48            31
## Iron Chef America                     13                  25            47            30
##                   WIRED.CABLE.W.O.PAY DBS.OWNER
## Living with Ed                     44        20
## Monarch Cove                       40        29
## Top Chef                           34        23
## Iron Chef America                  30        26

Each row of demos is a show and each column is the percentage of its audience in a demographic cell: region, county size, cable subscription, household size, and so on. We will predict the show’s genre.

A classification tree

Make the outcome a factor, so that tree() fits class shares rather than a mean. With 40 shows we allow leaves of a single observation.

table(nbc$Genre)
## 
##  Drama/Adventure          Reality Situation Comedy 
##               19               17                4
genre <- factor(nbc$Genre, labels = c("Drama", "Reality", "Comedy"))   # short labels for the plots
genretree <- tree(genre ~ ., data = demos, mincut = 1)
  • The three genres are unbalanced: 19 dramas, 17 reality shows and 4 situation comedies.
  • genre ~ . offers all 56 audience shares as candidate split variables; the tree will use four of them.

The genre tree

par(mar = c(1, 2, 1.5, 2), xpd = NA)
plot(genretree, col = 8, lwd = 2); text(genretree, cex = 0.85)

Dendrogram with the root split on the share of wired cable without pay channels at 28.67; the left branch splits on VCR owners at 83.75 and then on the east central territory at 16.46; the right branch splits on the Black audience share at 17.2. Each leaf is labelled with its predicted genre.

Leaves are labelled with the most likely genre; text(genretree, label = "yprob") prints the three class shares instead. Basic-cable viewers watch reality shows, VCR owners watch drama.

The tree in text

genretree
## node), split, n, deviance, yval, (yprob)
##       * denotes terminal node
## 
##  1) root 40 75.800 Drama ( 0.47500 0.42500 0.10000 )  
##    2) WIRED.CABLE.W.O.PAY < 28.6651 22 33.420 Drama ( 0.72727 0.09091 0.18182 )  
##      4) VCR.OWNER < 83.749 5  6.730 Comedy ( 0.00000 0.40000 0.60000 ) *
##      5) VCR.OWNER > 83.749 17  7.606 Drama ( 0.94118 0.00000 0.05882 )  
##       10) TERRITORY.EAST.CENTRAL < 16.4555 16  0.000 Drama ( 1.00000 0.00000 0.00000 ) *
##       11) TERRITORY.EAST.CENTRAL > 16.4555 1  0.000 Comedy ( 0.00000 0.00000 1.00000 ) *
##    3) WIRED.CABLE.W.O.PAY > 28.6651 18 16.220 Reality ( 0.16667 0.83333 0.00000 )  
##      6) BLACK < 17.2017 15  0.000 Reality ( 0.00000 1.00000 0.00000 ) *
##      7) BLACK > 17.2017 3  0.000 Drama ( 1.00000 0.00000 0.00000 ) *
  • yprob lists the estimated class shares in the order of levels(genre): drama, reality, comedy. * marks a leaf.
  • Four of the five leaves are pure. Node 4 (five low-VCR shows) mixes reality and comedy.

Predictions from a classification tree

predict(genretree, newdata = demos[1:3, ])
##                Drama Reality Comedy
## Living with Ed     0       1      0
## Monarch Cove       1       0      0
## Top Chef           0       1      0
predict(genretree, newdata = demos[1:8, ], type = "class")   # the most likely class
## [1] Reality Drama   Reality Reality Reality Reality Reality Reality
## Levels: Drama Reality Comedy

For a new show, predict() returns its leaf’s class shares; type = "class" picks the largest.

A real-valued outcome: projected engagement

Projected engagement (PE, 0 to 100) measures how much a focus group recalls of a pilot. Predict it from ratings (GRP) and genre.

X <- as.data.frame(model.matrix(PE ~ Genre + GRP, data = nbc)[, -1])
names(X) <- c("reality", "comedy", "GRP")      # Drama/Adventure is the omitted level
X$PE <- nbc$PE
nbctree <- tree(PE ~ ., data = X, mincut = 1)
nbctree
## node), split, n, deviance, yval
##       * denotes terminal node
## 
##  1) root 40 5646.00 72.68  
##    2) GRP < 223.05 7 1513.00 56.64 *
##    3) GRP > 223.05 33 1949.00 76.09  
##      6) reality < 0.5 22  622.50 78.85  
##       12) comedy < 0.5 19  513.80 78.01  
##         24) GRP < 1545.15 12  250.20 75.98 *
##         25) GRP > 1545.15 7  129.80 81.48 *
##       13) comedy > 0.5 3   10.11 84.17 *
##      7) reality > 0.5 11  823.40 70.57  
##       14) GRP < 433.85 3  145.00 63.13 *
##       15) GRP > 433.85 8  450.40 73.35 *

Two covariates, so we can draw the fit

Projected engagement against gross rating points, points coloured by genre, with three step functions: all genres sit at 57 below 223 rating points; above that drama rises from 76 to 81 at 1545, comedy is flat at 84, and reality steps from 63 to 73 at 434.

The first split separates the seven low-rated shows; above that, each genre gets its own concave-looking step function.

Checkpoint: growing a tree

  1. In the motorcycle scan, why is the deviance curve flat near the two ends of the time axis?
  2. A node has 12 observations and mincut = 5. Which cutpoints are admissible?
  3. With mindev = 0.01 and a root deviance of 308,000, a node with deviance 19,000 has a best split that removes 950. Is it split?

Pruning


Grow a big tree, then cut it back

Trees overfit

With mincut = 1 and mindev = 0, recursive splitting runs until every leaf holds one observation: the in-sample deviance is zero and the predictor is the nearest-neighbour step function, maximally overfit.

The obvious remedy is to treat mincut and mindev as complexity parameters and cross-validate them. Two things go wrong.

  • mincut alone forces leaves of similar size everywhere, which is the equal-count binning we wanted to escape.
  • mindev stops when the best next split helps little. But the greedy algorithm lacks foresight: a split that is nearly useless on its own can be the one that makes the next split decisive.

No single split helps, two splits are perfect

Left: 200 points on the unit square coloured by class in a two-by-two checkerboard pattern; a dashed vertical line at 0.5 leaves half of each colour on each side. Right: the same points with both the vertical and the horizontal line at 0.5, which separate the classes perfectly.

tree(y ~ x1 + x2, data = flag, mindev = 0.05)   # demand a 5% improvement: nothing is ever split
## node), split, n, deviance, yval, (yprob)
##       * denotes terminal node
## 
## 1) root 200 277.3 0 ( 0.5 0.5 ) *

Pruning the genre tree

prune.tree(tree, best = m) returns the subtree with m leaves. The genre tree has five, so the sequence runs 5, 4, 3, 2, 1.

par(mfrow = c(1, 3), mar = c(1, 2.5, 2, 2.5), xpd = NA)
for (m in 4:2) {
  sub <- prune.tree(genretree, best = m)
  plot(sub, col = 8, lwd = 2); text(sub, cex = 1.1); title(paste(m, "leaves"), cex.main = 1.2)
}

Three dendrograms side by side: the four-leaf subtree, which drops the east central territory split; the three-leaf subtree, which also drops the Black audience share split; and the two-leaf subtree, which keeps only the root split on wired cable.

The first snip removes TERRITORY.EAST.CENTRAL < 16.4555, a split that separated one comedy from sixteen dramas.

The pruning sequence as a path

Called without best, prune.tree() returns the whole sequence: sizes, in-sample deviances and the threshold k at which each subtree becomes optimal.

genre_path <- prune.tree(genretree)
data.frame(size = genre_path$size, deviance = round(genre_path$dev, 2), k = round(genre_path$k, 2))
##   size deviance     k
## 1    5     6.73  -Inf
## 2    4    14.34  7.61
## 3    3    30.56 16.22
## 4    2    49.64 19.08
## 5    1    75.80 26.16

Each k is the deviance increase per leaf removed by the next snip: snipping node 5 costs 7.61 for one leaf, snipping node 3 costs 16.22, and so on. Larger k means a smaller tree.

Cost-complexity pruning and cross-validation

Penalize the number of leaves, as the lasso penalized the coefficients. For \lambda \ge 0, among subtrees T of the big tree T_0 minimize

\sum_{i=1}^n \big(Y_i - \hat{\mathbf{E}}_T[Y \mid X_i]\big)^2 + \lambda \cdot |T|, \qquad |T| = \text{number of leaves of } T.

The minimizer for each \lambda is a member of the weakest-link sequence, and the k values are the \lambda’s at which the solution changes.

Choosing \lambda by K-fold cross-validation (James et al., Algorithm 8.1):

  1. Grow a large tree on the training data and compute its pruning sequence.
  2. For each fold k = 1, \dots, K: grow a large tree on the other folds, prune it at each \lambda, and record the deviance on fold k.
  3. Sum the fold deviances for each \lambda, pick the minimizer, and return the subtree of the full tree at that \lambda.

Prostate cancer: an overgrown tree

Ninety-seven patients; predict log cancer volume lcavol from age, log benign hyperplasia lbph, log capsular penetration lcp, Gleason score and log PSA lpsa.

prostate <- read.csv("data/prostate.csv")
pstree <- tree(lcavol ~ ., data = prostate, mincut = 1, mindev = 0)
summary(pstree)
## 
## Regression tree:
## tree(formula = lcavol ~ ., data = prostate, mincut = 1, mindev = 0)
## Variables actually used in tree construction:
## [1] "lcp"  "lpsa" "age"  "lbph"
## Number of terminal nodes:  20 
## Residual mean deviance:  0.2673 = 20.58 / 77 
## Distribution of residuals:
##     Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
## -1.38900 -0.27080  0.08845  0.00000  0.27050  1.36600

Twenty leaves for 97 patients, a few observations per leaf, and a residual deviance a sixth of the total. The printed tree runs to 39 lines.

cv.tree()

cv.tree() implements the cross-validation of the previous slide and plot() draws the summed out-of-fold deviance against size (bottom axis) and k (top axis).

set.seed(0)
cvpst <- cv.tree(pstree)      # 10 folds by default
par(mar = c(4, 4, 2, 0.5)); plot(cvpst)

Cross-validated deviance against tree size from 1 to 20: 135 at size one, 83 at two, falls to 70 at three, then wobbles between 71 and 74 and is flat at 72 for sizes 12 to 20.

Reading the curve

rbind(size = cvpst$size, dev = round(cvpst$dev, 1))
##      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14] [,15]
## size 20.0 19.0 18.0 17.0 15.0 14.0 13.0 12.0 11.0     8     7     6   5.0   4.0   3.0
## dev  72.1 72.1 72.1 72.1 72.1 72.1 72.1 72.1 73.4    74    72    73  71.2  71.2  69.8
##      [,16] [,17]
## size   2.0   1.0
## dev   82.6 134.6
(best_size <- cvpst$size[which.min(cvpst$dev)])
## [1] 3
  • The deviance drops sharply up to three leaves and barely moves after that. The minimum is at three.
  • The curve is exactly flat from 12 leaves up. That is not evidence that big trees do equally well, as the next slide explains.

A caveat about cv.tree()

cv.tree() grows each fold’s tree with the default stopping rules, whatever options built the tree you pass it. On the prostate data a default tree has about nine leaves, so a fold tree “pruned” at a small k is just the whole fold tree, evaluated again and again: that is the flat segment.

Warning

cv.tree() never evaluates subtrees larger than a default-grown tree. With mindev = 0 trees of hundreds of leaves, its curve goes flat at a few dozen leaves and the “best” size it reports is wrong.

The fix is to write the cross-validation loop ourselves, growing every fold’s tree with the same options as the full tree.

The algorithm, written out

Ten lines reproduce Algorithm 8.1 and pass the growing options to every fold through ...:

cv_prune <- function(formula, data, K = 10, ...) {
  full <- tree(formula, data = data, ...)
  path <- prune.tree(full)                              # sizes and k of the full tree
  fold <- sample(rep(1:K, length.out = nrow(data)))
  dev <- 0
  for (i in 1:K) {
    t_i <- tree(formula, data = data[fold != i, ], ...)  # same stopping rules as the full tree
    dev <- dev + prune.tree(t_i, newdata = data[fold == i, ], k = path$k)$dev
  }
  data.frame(size = path$size, k = path$k, dev = dev)
}

prune.tree(t_i, newdata = ..., k = path$k) prunes the fold tree at every k of the full tree’s path and evaluates each subtree on the held-out fold.

Honest cross-validation on the prostate tree

set.seed(0)
cvp <- cv_prune(lcavol ~ ., prostate, mincut = 1, mindev = 0)
cvp$size[which.min(cvp$dev)]
## [1] 3

Summed out-of-fold deviance against tree size from the hand-written cross-validation: 134 at one leaf, 82 at two, 66 at three, then between 66 and 71 for larger sizes, no longer flat.

Now the curve varies all the way up, and the minimum is still at three leaves. Here the two procedures agree; on the California tree they will not.

The pruned tree

pstcut <- prune.tree(pstree, best = cvp$size[which.min(cvp$dev)])
par(mar = c(1, 2, 1.5, 2), xpd = NA); plot(pstcut, col = 8); text(pstcut, cex = 0.9)

A three-leaf tree: the root splits on capsular penetration at 0.26; the left child splits on PSA at 2.30; leaf means are 0.28, 1.43 and 2.38.

Three leaves summarize 97 patients: capsular penetration first, then PSA among the low-penetration patients. Age, hyperplasia and the Gleason score never enter the pruned tree, though all but the Gleason score appeared in the overgrown one.

Checkpoint: pruning

  1. Why can a stopping rule based on the improvement from the next split fail, when pruning cannot?
  2. In the genre path, what does k = 19.08 for the two-leaf subtree mean?
  3. You pass a 1,500-leaf tree to cv.tree() and the curve is flat from 13 leaves up. What happened?

Random forests


Average many noisy trees instead of choosing one

Bagging: bootstrap aggregation

In lecture 10 the bootstrap measured sampling uncertainty:

  1. for b = 1, \dots, B, resample n observations with replacement;
  2. compute the estimator \hat\beta^*_b on each resample;
  3. use the spread of \hat\beta^*_b across b for standard errors and confidence intervals.

Bagging (Breiman, 1996) uses the same resamples to build a new estimator, their average:

\hat\beta_{\text{BAG}} = \frac{1}{B}\sum_{b=1}^B \hat\beta^*_b, \qquad \hat f_{\text{BAG}}(x) = \frac{1}{B}\sum_{b=1}^B \hat f^*_b(x).

For a regression tree, \hat f^*_b is the tree grown on resample b, and the bagged predictor is the average of B step functions.

Bagging the motorcycle tree

newtimes <- data.frame(times = seq(0, 60, length.out = 1000))
B <- 100
set.seed(0)
boot_fits <- matrix(NA, B, nrow(newtimes))
for (b in 1:B) {
  boot_sample <- mcycle[sample.int(nrow(mcycle), replace = TRUE), ]
  boot_fits[b, ] <- predict(tree(accel ~ times, data = boot_sample), newdata = newtimes)
}
forest <- colMeans(boot_fits)

Each resample grows its own tree, with its own cutpoints. The bagged predictor averages the 100 step functions pointwise.

One tree against a hundred

Left: the single tree's eight-step fit in red over the data. Right: one hundred bootstrap trees drawn as faint red step functions, dense where they agree, and their average as a smooth black curve through the data.

  • Cutpoints jump around from resample to resample: trees have high variance. Averaging smooths the jumps away.
  • Bagging is model averaging. It lowers variance and leaves the bias of the individual trees roughly alone.

From bagging to random forests

A random forest (Breiman, 2001) is bagging applied to trees with one more source of randomness.

  • At every split of every bootstrap tree, choose the best split among a random subset of m of the d covariates rather than all d. With m = d the forest is plain bagging.
  • Why? If one covariate dominates, every bagged tree splits on it first and the trees are highly correlated, so averaging them removes little variance. Restricting the candidates decorrelates the trees. It also lets a tree take a split that is greedily suboptimal but pays off later, as in the checkerboard.
  • The cost is more randomness in each tree, which the average removes.

Forests do well without much tuning:

  • m is the main parameter; the default m = \lfloor\sqrt{d}\rfloor (regression software often uses d/3) usually works;
  • each tree is grown nearly to the bottom (ranger’s default minimum node size is 5 for regression, 1 for classification), because overfitting a single tree is harmless once it is averaged;
  • more trees never hurt; a few hundred is typical.

The random forest algorithm

Adapted from Hastie, Tibshirani and Friedman (2009), Algorithm 15.1.

  1. For b = 1, \dots, B:
    1. draw a bootstrap sample of size n from the data;
    2. grow a tree T_b on it: at each node, pick m covariates at random, find the best split among them, and split; stop when a node reaches the minimum size.
  2. Output the ensemble \{T_b\}_{b=1}^B.

Prediction at x:

  • regression: \hat f_{\text{RF}}(x) = \frac{1}{B}\sum_b T_b(x), the average of the tree predictions;
  • classification: the majority vote of the B trees, or the average of their class shares.

What a forest gives up, and what it gives back

  • No tree to draw. The forest is an average of hundreds of trees, so the dendrogram is gone and the recursive-partition picture with it.
  • Variable importance partly replaces it: for each covariate, add up the deviance removed by every split on that covariate, over all trees. A second measure, permutation importance, shuffles one covariate in the data not used by each tree and records how much the prediction error rises.
  • Out-of-bag error for free. Each bootstrap sample leaves out about 37 percent of the observations (“out of bag”). Predicting each observation from the trees that did not see it gives an honest error estimate with no extra fitting, a built-in cross-validation.

California housing

Median house value for 20,640 census block groups (the book says tracts; the count says block groups), with location, income, age and household composition. We make per-household variables and take logs.

CAhousing <- read.csv("data/CAhousing.csv")
CAhousing$AveBedrms <- CAhousing$totalBedrooms / CAhousing$households
CAhousing$AveRooms <- CAhousing$totalRooms / CAhousing$households
CAhousing$AveOccupancy <- CAhousing$population / CAhousing$households
logMedVal <- log(CAhousing$medianHouseValue)
CAhousing <- CAhousing[, -c(4, 5, 9)]     # drop the room totals and the value in levels
CAhousing$logMedVal <- logMedVal
names(CAhousing)
##  [1] "longitude"        "latitude"         "housingMedianAge" "population"      
##  [5] "households"       "medianIncome"     "AveBedrms"        "AveRooms"        
##  [9] "AveOccupancy"     "logMedVal"

Nine covariates, so a forest’s default is m = \lfloor\sqrt{9}\rfloor = 3 candidates per split.

Where the expensive housing is

A scatter map of California with one point per block group coloured by log median value: bright high-value clusters around the San Francisco Bay Area and coastal Los Angeles and San Diego, dark low-value interior in the Central Valley and the north.

Value is a function of where you are: the Bay Area and the coast from Santa Barbara to San Diego are expensive, the Central Valley is not. A tree finds such pockets with a few splits on longitude and latitude; a linear model in longitude and latitude cannot.

ranger()

set.seed(0)
carf <- ranger(logMedVal ~ ., data = CAhousing, num.trees = 200, min.node.size = 5,
               importance = "impurity")
carf
## Ranger result
## 
## Call:
##  ranger(logMedVal ~ ., data = CAhousing, num.trees = 200, min.node.size = 5,      importance = "impurity") 
## 
## Type:                             Regression 
## Number of trees:                  200 
## Sample size:                      20640 
## Number of independent variables:  9 
## Mtry:                             3 
## Target node size:                 5 
## Variable importance mode:         impurity 
## Splitrule:                        variance 
## OOB prediction error (MSE):       0.05255512 
## R squared (OOB):                  0.8377498

Variable importance

Two horizontal bar charts. Left, impurity importance: median income far ahead, then latitude, longitude, average occupancy and average rooms, with age, bedrooms, households and population small. Right, permutation importance: latitude and median income tied at the top, longitude third, then average occupancy and rooms.

Income and location dominate on both measures; the permutation measure, computed out of bag, puts latitude level with income. Household counts matter little once the per-household ratios are in.

Two rivals: a lasso and a pruned tree

Lasso. Standardize, interact everything with longitude and latitude (31 columns), and cross-validate.

set.seed(0)
XXca <- model.matrix(logMedVal ~ . * longitude * latitude, data = data.frame(scale(CAhousing)))[, -1]
capen <- cv.glmnet(x = XXca, y = logMedVal, alpha = 1, lambda.min.ratio = 1e-4)
sum(coef(capen, s = "lambda.min")[-1] != 0)      # nonzero coefficients at lambda.min
## [1] 21

Pruned tree. Overgrow with leaves of at least ten block groups, then cross-validate the pruning path.

set.seed(0)
catree <- tree(logMedVal ~ ., data = CAhousing, mindev = 0, mincut = 10)
cvca <- cv_prune(logMedVal ~ ., CAhousing, mindev = 0, mincut = 10)
c(leaves_grown = sum(catree$frame$var == "<leaf>"), leaves_chosen = cvca$size[which.min(cvca$dev)])
##  leaves_grown leaves_chosen 
##          1596           397

The top of the California tree

catop <- prune.tree(catree, best = 7)
par(mar = c(1, 2, 1.5, 2), xpd = NA); plot(catop, col = 8); text(catop, cex = 0.85)

Dendrogram of the seven-leaf subtree: the root splits on median income at 3.55; below it, further splits on median income at 2.51 and 5.59, on latitude at 34.47, on average rooms at 4.71 and on average occupancy at 2.41, with leaf values between 11.4 and 12.75.

Income first and income again at both ends; latitude for the poorest block groups and the per-household ratios in the middle. The chosen tree continues for another 390 leaves, most of them splits on longitude and latitude.

Head to head, out of sample

Ten random splits: fit on 5,000 block groups, predict the other 15,640, compute the RMSE of log value. This is the code behind the opening figure.

set.seed(0)
compare <- tibble(rep = integer(), LASSO = numeric(), CART = numeric(), RF = numeric())
for (i in 1:10) {
  train <- sample(1:nrow(CAhousing), 5000)
  lin <- cv.glmnet(x = XXca[train, ], y = logMedVal[train], alpha = 1, lambda.min.ratio = 1e-4)
  yhat_lin <- drop(predict(lin, XXca[-train, ]))
  cvrt <- cv_prune(logMedVal ~ ., CAhousing[train, ], mindev = 0, mincut = 10)
  rt <- tree(logMedVal ~ ., data = CAhousing[train, ], mindev = 0, mincut = 10)
  yhat_rt <- predict(prune.tree(rt, best = cvrt$size[which.min(cvrt$dev)]), newdata = CAhousing[-train, ])
  rf <- ranger(logMedVal ~ ., data = CAhousing[train, ], num.trees = 200, min.node.size = 5)
  yhat_rf <- predict(rf, data = CAhousing[-train, ])$predictions
  compare <- add_row(compare, rep = i, LASSO = rmse(logMedVal[-train], yhat_lin),
                     CART = rmse(logMedVal[-train], yhat_rt), RF = rmse(logMedVal[-train], yhat_rf))
}

The results

compare |> summarise(across(c(LASSO, CART, RF), list(median = median, mean = mean))) |> round(3)
## # A tibble: 1 × 6
##   LASSO_median LASSO_mean CART_median CART_mean RF_median RF_mean
##          <dbl>      <dbl>       <dbl>     <dbl>     <dbl>   <dbl>
## 1        0.775      0.819       0.316     0.314     0.253   0.253

The opening boxplot again: lasso wide and high, pruned tree near 0.31, random forest near 0.25.

  • The forest wins in every one of the ten splits, by about a fifth of the tree’s error, with nothing cross-validated.
  • The lasso is the only method whose error depends on which block groups land in the test set.

Why the lasso is erratic

compare |> select(rep, LASSO, worst_lasso, worst_occupancy) |> slice_max(LASSO, n = 3)
## # A tibble: 3 × 4
##     rep LASSO worst_lasso worst_occupancy
##   <int> <dbl>       <dbl>           <dbl>
## 1     9  1.58       170.            1243.
## 2    10  1.44       145.            1243.
## 3     5  1.02        94.6           1243.
  • One block group has an average occupancy of 1,243 persons per household (the next largest is 600). Standardized and interacted with longitude and latitude, it sits far outside the training range whenever it is in the test set, and the linear fit extrapolates to log-value errors of 50 to 170.
  • A tree only asks whether occupancy exceeds a threshold, so an extreme covariate value cannot produce an extreme prediction. Trees are invariant to monotone transformations of the covariates and immune to outliers in X; linear models are neither.
  • Even dropping the sixteen worst test errors, the lasso’s RMSE is about 0.33: no better than the pruned tree and well above the forest.

Where the forest’s gain comes from

  • Trees find pockets. A few splits on longitude and latitude isolate the Bay Area and coastal Los Angeles. The lasso, with location entered linearly and through interactions, has no way to carve out a rectangle.
  • Averaging removes the tree’s variance. The pruned tree’s cutpoints and leaf means jump from sample to sample; the forest averages 200 of them.
  • No tuning parameter was chosen. The pruned tree needed cross-validation to pick about 160 leaves on 5,000 observations; the forest used defaults.

Could the lasso find cutpoints? Yes, with cleverly defined indicator variables (Wang, Sharpnack, Smola and Tibshirani, 2016, do this for trend filtering), but trees do it automatically.

Does mtry matter?

set.seed(0)
MSE_mtry <- matrix(NA, 10, 9)
for (i in 1:10) {
  train <- sample(1:nrow(CAhousing), 5000)
  for (j in 1:9) {
    rf <- ranger(logMedVal ~ ., data = CAhousing[train, ], num.trees = 200, min.node.size = 5, mtry = j)
    MSE_mtry[i, j] <- rmse(logMedVal[-train], predict(rf, data = CAhousing[-train, ])$predictions)
  }
}
par(mar = c(4, 4, 0.5, 0.5)); boxplot(MSE_mtry, col = "dodgerblue", xlab = "mtry", ylab = "out-of-sample RMSE")

Boxplots of out-of-sample RMSE for mtry from 1 to 9: 0.29 at one, 0.26 at two, then flat at about 0.253 from three to nine with a slight rise toward nine.

Reading the mtry experiment

round(colMeans(MSE_mtry), 3)
## [1] 0.289 0.260 0.253 0.252 0.253 0.254 0.255 0.256 0.258
  • mtry = 1 (a random covariate at every split) is clearly worse and mtry = 2 slightly so. From 3 to 9 the curve is flat within the noise of the ten splits, with the minimum at 4.
  • The default \lfloor\sqrt{9}\rfloor = 3 is as good as anything, which is the folk wisdom: forests “typically work well” out of the box.
  • Every value of mtry, including plain bagging at mtry = 9, beats the pruned tree and the lasso.

Checkpoint: random forests

  1. Bagging averages B trees. Why does it reduce variance and not bias?
  2. What does mtry change, and why can a smaller mtry lower the error of the average even though it raises the error of each tree?
  3. What is the out-of-bag error, and why is it an honest estimate?

Trees and forests in one page

A tree is an adaptive binned sample mean. Recursive binary splits choose which covariate to cut, where, and how finely; the leaves are the bins and the fit is a step function.

Growing is greedy, so do not stop early. Each split minimizes the deviance one step ahead. A near-useless split can precede a decisive one, so overgrow the tree and prune it back.

Prune by weakest link, choose by cross-validation. Cost-complexity pruning yields a nested path indexed by the number of leaves; cv.tree() evaluates it, but only up to the size of a default tree, so write the loop yourself for big trees.

Forests average many overgrown trees. Bagging removes the tree’s variance; random covariate subsets decorrelate the trees; the out-of-bag error comes free. The defaults usually work, and on the California data the forest beat both the pruned tree and the lasso in every split.

Trees are robust to the scale of X, linear models are not. A single extreme covariate value sank the lasso and left the trees untouched.

Reading and data sources

Additional derivations and code


Optional material for reference and practice

Why averaging correlated trees helps

Let B tree predictions at a point be identically distributed with variance \sigma^2 and pairwise correlation \rho. The variance of their average is

\operatorname{Var}\Big(\frac{1}{B}\sum_{b=1}^B T_b\Big) = \rho\sigma^2 + \frac{1-\rho}{B}\sigma^2.

Variance of the average relative to a single tree against the number of trees from 1 to 200, for correlations 0.9, 0.5 and 0.2: each curve falls quickly and levels off at its correlation.

The second term vanishes as B grows; the first, \rho\sigma^2, is a floor that more trees cannot breach. The random covariate subset lowers \rho and so lowers the floor: that is the whole point of mtry (Hastie, Tibshirani and Friedman, section 15.2).

Out of bag

A bootstrap sample draws n times with replacement. The chance that a given observation is never drawn is

\Big(1 - \frac{1}{n}\Big)^n \;\longrightarrow\; e^{-1} \approx 0.368.

n <- nrow(CAhousing)
c(exact = (1 - 1 / n)^n, limit = exp(-1))
##     exact     limit 
## 0.3678705 0.3678794
set.seed(0)
mean(!(1:n %in% sample.int(n, replace = TRUE)))     # share out of bag in one resample
## [1] 0.3704942

Each tree sees about 63 percent of the observations and leaves 37 percent out, so with B = 200 every observation is out of bag for about 74 trees. The out-of-bag prediction for observation i averages those trees, and the resulting error is the OOB prediction error that ranger() printed (MSE 0.0526, R^2 0.838).

Node impurity for classification

For a node with class shares p_1, \dots, p_K, three measures of how mixed it is:

Measure Formula In tree()
Misclassification error 1 - \max_k p_k not used for splitting
Gini impurity \sum_k p_k(1 - p_k) split = "gini"
Entropy (the deviance is 2n \times entropy) -\sum_k p_k \log p_k default

Three curves against the share of class one from 0 to 1, all zero at the ends: misclassification error is a tent with peak 0.5, Gini a parabola with peak 0.5, scaled entropy a slightly fuller curve with the same peak.

Gini and entropy are strictly concave, so they reward a split that purifies the children even when the majority class does not change; misclassification error does not, which is why trees are not grown with it.

Trees as adaptive basis functions

A tree with leaves R_1, \dots, R_M is the regression

\hat f(x) = \sum_{m=1}^M \hat c_m \, \mathbf{1}\{x \in R_m\}, \qquad \hat c_m = \text{mean of } Y_i \text{ in } R_m,

a linear model in M indicator variables whose definition is chosen by the data. Two consequences:

  • Invariance. Only the ordering of each covariate matters, so replacing X_j by any increasing transformation, \log X_j or its rank, leaves the tree unchanged. The lasso in section 4 was wrecked by a covariate value of 1,243; the tree did not notice.
  • Roughness. The fit is piecewise constant and jumps at the thresholds, so it is poor at smooth relationships; bagging and forests smooth the jumps (the motorcycle picture), and boosting, not covered today, adds trees sequentially to fix the residuals.

The lasso can mimic a tree if the analyst supplies the indicators: Wang et al. (2016) penalize differences of neighbouring fitted values on a grid, which selects cutpoints. Trees find the cutpoints themselves.

cv.tree() against the written-out loop on the California tree

Cross-validated deviance against tree size on a log axis from 1 to 1,596 leaves, two curves: cv.tree falls to about 3,094 by 13 leaves and is exactly flat beyond; the hand-written loop keeps falling to a minimum near 400 leaves and rises slightly toward the full tree.

c(cv.tree = min(cvca_pkg$size[cvca_pkg$dev == min(cvca_pkg$dev)]), cv_prune = cvca$size[which.min(cvca$dev)])
##  cv.tree cv_prune 
##       13      397

The package curve is flat at 3,094 from 13 leaves to 1,596; the honest curve reaches 1,606 at 397 leaves, a very different tree.

Install the required packages

Run once in your R environment if needed:

install.packages(c("tidyverse", "patchwork", "tree", "ranger", "glmnet"))

Data files, all read from lectures/data/:

  • nbc_showdetails.csv and nbc_demographics.csv, the 40 TV shows and their audience shares;
  • prostate.csv, the 97 prostate cancer patients;
  • CAhousing.csv, the 20,640 California block groups.

MASS::mcycle ships with R. The notes use the same packages; randomForest is the older alternative to ranger.

Return to the question

Three predictors of California house prices: the lasso extrapolated off a single block group, the pruned tree needed cross-validation to find its 160 leaves, and the random forest, two hundred overgrown trees averaged, won every split with the defaults. Trees pick the bins; forests average the trees.

Return to the main takeaway