Regression Trees

Regression trees are an important subtype of decision tree models that allow us to predict continuous, valued outputs. By design, they’re similar to classification trees, where they both use a decision tree-like algorithm.

Regression tree example with categorical predictors (Li, L. 2019. Towards Data Science.)

Regression tree example with categorical predictors (Li, L. 2019. Towards Data Science.)

Regression tree example with numerical predictors (Neufield, A. Treevalues.)

Regression tree example with numerical predictors (Neufield, A. Treevalues.)

In this chapter, we’ll explore both how to build an individual regression tree, as well as ensemble tree methods, which utilize multiple regression trees in its algorithm.

Below, are the summaries of each concept in this chapter:

Data Preparation

To demonstrate regression trees, we’ll start by using the dataset from the Boston housing dataset, and recall that medv is the response variable we want to predict.

To start, we’ll split the data in half for the testing and training subsets.

library(MASS)
str(Boston)
'data.frame':   506 obs. of  13 variables:
 $ crim   : num  0.00632 0.02731 0.02729 0.03237 0.06905 ...
 $ zn     : num  18 0 0 0 0 0 12.5 12.5 12.5 12.5 ...
 $ indus  : num  2.31 7.07 7.07 2.18 2.18 2.18 7.87 7.87 7.87 7.87 ...
 $ chas   : int  0 0 0 0 0 0 0 0 0 0 ...
 $ nox    : num  0.538 0.469 0.469 0.458 0.458 0.458 0.524 0.524 0.524 0.524 ...
 $ rm     : num  6.58 6.42 7.18 7 7.15 ...
 $ age    : num  65.2 78.9 61.1 45.8 54.2 58.7 66.6 96.1 100 85.9 ...
 $ dis    : num  4.09 4.97 4.97 6.06 6.06 ...
 $ rad    : int  1 2 2 3 3 3 5 5 5 5 ...
 $ tax    : num  296 242 242 222 222 222 311 311 311 311 ...
 $ ptratio: num  15.3 17.8 17.8 18.7 18.7 18.7 15.2 15.2 15.2 15.2 ...
 $ lstat  : num  4.98 9.14 4.03 2.94 5.33 ...
 $ medv   : num  24 21.6 34.7 33.4 36.2 28.7 22.9 27.1 16.5 18.9 ...
boston = Boston[Boston$medv !=50.0,] # Get rid of medv==50
dim(boston)
[1] 490  13
set.seed(310)
index = sample(1:nrow(Boston),nrow(Boston)*0.8,replace=F)
train = Boston[index,]
test = Boston[-index,]
dim(train)
[1] 404  13
dim(test)
[1] 102  13

Data Partitioning and RMSE Function

We’ll create a user-defined function to calculate the root mean square error of a regression tree:

For more information on how to create custom functions, refer to Writing Functions.

myRMSE <- function(y_true, y_pred){
    mse = mean( (y_true-y_pred)^2 )
    return( sqrt(mse) )
}

Unpruned Trees

To create our regression tree, we’ll start by loading in the tree library, which comes with the tree() function, taking in parameters in a similar format to lm().

# install.packages("tree")
library(tree)
atree = tree(medv ~ ., data = train) # A "." tells R to use all the features
summary(atree)

Regression tree:
tree(formula = medv ~ ., data = train)
Variables actually used in tree construction:
[1] "rm"      "lstat"   "dis"     "crim"    "ptratio"
Number of terminal nodes:  8 
Residual mean deviance:  14.42 = 5712 / 396 
Distribution of residuals:
     Min.   1st Qu.    Median      Mean   3rd Qu.      Max. 
-23.31000  -2.07400   0.03691   0.00000   2.14300  14.54000 

The summary() function when called on a tree object, lists the variables that are used as internal nodes in the tree, the number of terminal nodes, and the error rate on the training dataset.

plot(atree)
text(atree, pretty = 0)
title(main = "Unpruned Regression Tree")

Cost-complexity tree pruning

To avoid overfitting data on a decision tree, it’s important to prune trees when they start growing very large. A smaller tree with fewer splits might lead to lower variance and better interpretation at the cost of a ltitle bias.

To do so, we’ll need to perform cross-validation and select the best subtree based on the RMSE score. This is achieveable with the cv.tree() function from the tree library.

# Perform CV
set.seed(310)
tree_cv = cv.tree(atree,K=10) # For more detail, type: ?cv.tree()
plot(tree_cv$size, sqrt(tree_cv$dev / nrow(train)), 
     type = "b",
     xlab = "Tree Size", 
     ylab = "CV-RMSE")

While the tree of size 8 does have the lowest RMSE, we’ll prune to a size of 7, since from 7 to 8, the difference is not as dramatic and it seems to perform just as well (otherwise, we would not be pruning).

We’ll take a look at the pruned tree below:

tree_prune = prune.tree(atree, best = 7)
summary(tree_prune)

Regression tree:
snip.tree(tree = atree, nodes = 6L)
Variables actually used in tree construction:
[1] "rm"    "lstat" "dis"   "crim" 
Number of terminal nodes:  7 
Residual mean deviance:  16.82 = 6677 / 397 
Distribution of residuals:
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
-23.3100  -2.0740   0.2146   0.0000   2.2380  14.5400 
# Plot tree
plot(tree_prune)
text(tree_prune, pretty = 0)
title(main = "Pruned Regression Tree")

As expected, the pruned tree is smaller and easier to interpret compared to the unpruned tree, although the changes aren’t drastic. Now let’s take a look at the RMSE scores on the training and testing datasets:

tree.pred.train = predict(tree_prune, newdata = train)
tree.pred.test =  predict(tree_prune, newdata = test)
# find RMSE on training and testing
tree.rmse.trt = myRMSE(train$medv,tree.pred.train)
tree.rmse.tst = myRMSE(test$medv,tree.pred.test) 
c( tree.rmse.trt, tree.rmse.tst ) 
[1] 4.065300 5.190901
Model Type RMSE Score (Training) RMSE Score (Testing)
Unpruned Tree 4.52 5.53
Pruned Tree 4.07 5.19

The lower RMSE scores show that the pruned tree performs considerably better than the unpruned tree as well.

plot(tree.pred.test, test$medv, 
     xlab = "Predicted", ylab = "Actual", 
     main = "Predicted vs Actual: Single Tree, Test Data",
     col = "blue", pch = 20)
grid()
abline(0, 1, col = "red", lty=2,lwd = 2)

Creating a linear regression model for comparison

Since regression trees and linear regression models both work on the same type of continuous, numerical data, we’ll create a linear regression model, denoted mod.lin, and run it on the same testing and training data.

mod.lin = lm(medv~.,data=train)
pred.train = predict(mod.lin, newdata=train)
pred.test = predict(mod.lin,newdata=test)
# Find RMSE on training and testing
lin.rmse.trt =  myRMSE(train$medv,pred.train)
lin.rmse.tst = myRMSE(test$medv,pred.test)
c( lin.rmse.trt, lin.rmse.tst)
[1] 4.596217 5.463523
Model Type RMSE Score (Training) RMSE Score (Testing)
Unpruned Tree 4.52 5.53
Pruned Tree 4.07 5.19
Linear Regression 4.60 5.46

The performance on the testing data indicates that linear regression performs at par, or slightly better than the unpruned tree on the Boston dataset, although it falls short of the pruned tree performance.

The rpart Package

The rpart package provides an alternative method for for fitting trees in R. It’s much more feature rich, including multiple cost complexity fits and performs cross-validation by default. It also has the ability to produce much nicer-looking trees. Based on its default settings, it will often result in smaller trees compared to using the tree package.

# install.packages("rpart")
library(rpart)
set.seed(310)

medv.rpart = rpart(medv~., data=train)
# Plot the cv error curve for the tree
# rpart tries different cost-complexities by default
# Also stores cv results
plotcp(medv.rpart)

Pruning in rpart

rpart also comes with a prune() function, which takes a rpart-generated tree and a minimum cost-complexity value:

# Find the best cost-complexity value
min_cp = medv.rpart$cptable[which.min(medv.rpart$cptable[,"xerror"]),"CP"]
min_cp
[1] 0.01
# Prune tree using best cost-complexity
rpart.prune = prune(medv.rpart, cp = min_cp)

Plots in rpart

The rpart.plot library comes with a plot function that automatically generates nicer-looking tree graphs without overlap by calling the prp() function, which takes in a rpart-generated tree.

# install.packages("rpart.plot")
library(rpart.plot)
prp(rpart.prune)

Ensemble Tree Methods

To generate Random Forest models, we’ll need to load the randomForest and gbm packages in R.

# install.packages("randomForest")
# install.packages("gmb")
library(randomForest)
library(gbm)

Fitting a bagged model

We’ll use the randomForest package to fit a bagged model. The act of bagging is actually a special case of a random forest where mtry (the number of variables to randomly sample as candidates in each split) is equal to p (total number of predictors). Usually, we only consider mtry out of full p predictors each time we decide on a tree split.

The opposite of a bagged model is a Random Forest model, where the variables used in each tree are randomly selected. Bagged models will use all of the features in all trees.

mod.bag = randomForest(medv ~ ., 
                       data = train, 
                       mtry = 12,
                       type="regression",
                       importance = TRUE, 
                       ntrees = 500)
mod.bag

Call:
 randomForest(formula = medv ~ ., data = train, mtry = 12, type = "regression",      importance = TRUE, ntrees = 500) 
               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 12

          Mean of squared residuals: 10.87439
                    % Var explained: 87.21

Note that in the Boston dataset, there are only 12 variables excluding medv, since medv is the variable we are predicting. Which means in this case, p would equal 12, and the mtry value here is also 12.

This indicates a bagged model (mtry = p).

# find RMSE on train/test sets
bag.pred.train = predict(mod.bag, newdata=train)
bag.pred.test = predict(mod.bag, newdata=test)
bag.rmse.trt = myRMSE(train$medv, bag.pred.train)
bag.rmse.tst = myRMSE(test$medv, bag.pred.test)
c( bag.rmse.trt, bag.rmse.tst)
[1] 1.373083 3.515729
Model Type RMSE Score (Training) RMSE Score (Testing)
Unpruned Tree 4.52 5.53
Pruned Tree 4.07 5.19
Linear Regression 4.60 5.46
Bagged Forests 1.37 3.52

This is a large improvement over the previous models, however, the difference in the training and testing RMSE scores indicates that there may be a case of overfitting.

This may be due to the condition where bagging sometimes gives us trees that are highly correlated since the bagging method builds lots of similar trees. As such, averaging highly correlated values doesn’t reduce variance as much as averaging uncorrelated quantities.

# plot actual Y and predicted Y
# if data points are close to the diagonal line, it means good prediction
plot(bag.pred.test,test$medv,
     xlab = "Predicted", ylab = "Actual",
     main = "Predicted vs Actual: Bagged Model, Test Data",
     col = "dodgerblue", pch = 20)
grid()
abline(0, 1, col = "red", lty=2,lwd = 2)

Random Forest model

Now, we’ll try a Random Forest model. When creating a Random Forest for regression, the general suggestion is to try: mtry = sqrt(p), which would be rounded to 4, in this case.

For this, we’ll continue to use the randomForest() function from the randomForest package. The setup is similar to a bagged model.

forest = randomForest(medv ~ ., 
                      data = train, 
                      mtry = 4,
                      type="regression", 
                      importance = TRUE, 
                      ntrees = 500)
forest

Call:
 randomForest(formula = medv ~ ., data = train, mtry = 4, type = "regression",      importance = TRUE, ntrees = 500) 
               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 4

          Mean of squared residuals: 10.12727
                    % Var explained: 88.09

From the output above, you’ll see that we’ve generated this tree with similar parameters to the bagged model for comparison purposes. Both forests have 500 trees, as well as a similar percentage of variation explained.

To evaluate our Random Forest Model, we’ll go ahead and find the RMSE values on our testing and training datasets.

# Find RMSE on train/test set
forest.pred.train = predict(forest,newdata=train)
forest.pred.test = predict(forest,newdata=test)
forest.rmse.trt = myRMSE(train$medv,forest.pred.train)
forest.rmse.tst = myRMSE(test$medv, forest.pred.test)
c(forest.rmse.trt,forest.rmse.tst)
[1] 1.430415 3.631299
Model Type RMSE Score (Training) RMSE Score (Testing)
Unpruned Tree 4.52 5.53
Pruned Tree 4.07 5.19
Linear Regression 4.60 5.46
Bagged Forest 1.37 3.52
Random Forest 1.43 3.63

The RMSE scores are slightly worse than the bagged forest model, and also seems to have a discrepancy between the training and test scores. However, the trees generated in a Random Forest are generally less complex than a bagged model due to the random feature selection.

# Finding OOB (out-of-bag) error rate
myRMSE(forest$predicted,train$medv)
[1] 3.182337
Plotting the actual Y versus the predicted Y
plot(forest.pred.test, test$medv,
     xlab = "Predicted", ylab = "Actual",
     main = "Predicted vs Actual: Random Forest, Test Data",
     col = "dodgerblue", pch = 20)
grid()
abline(0, 1, col = "red", lty=2,lwd = 2)

Ranking predictors

Since a Random Forest model doesn’t use all the predictors, we can find out which ones the model deems as most important using the importance() function from the randomForest package.

There are two ways to rank importance in predictors:

  1. By the mean decrease in accuracy.
  2. By the mean decrease in node impurity (Gini index score).
importance(forest, type = 1) # List of predictors vs % change in MSE
%IncMSE
crim 15.4629939
zn 3.7184309
indus 11.9821826
chas 0.8484559
nox 17.3413671
rm 33.9691299
age 12.6821849
dis 19.0078660
rad 6.4064339
tax 12.4259294
ptratio 16.9473507
lstat 29.8890534

To further visualize this, we can plot out the difference in rankings using the function varImpPlot():

# Plot predictors in order of importance (most to less)
varImpPlot(forest, type = 1)

Here, it shows that the average number of rooms (rm) is by far the most important predictor for medv, however, the percentage of population considered lower status (lstat), is not too far behind.

Boosting

Lastly, we’ll try a boosted model, which by default will produce a nice predictor importance plot for us, as well as plots of the marginal effects of the predictors. For this, we’ll use the gbm package.

boost = gbm(medv ~ ., 
            data = train, 
            distribution = "gaussian", 
            n.trees = 5000, 
            interaction.depth = 4, 
            shrinkage = 0.1)
boost
gbm(formula = medv ~ ., distribution = "gaussian", data = train, 
    n.trees = 5000, interaction.depth = 4, shrinkage = 0.1)
A gradient boosted model with gaussian loss function.
5000 iterations were performed.
There were 12 predictors of which 12 had non-zero influence.

The output above states all 12 predictors have some level of influence over predicting the outcome variable (medv). Though, how much influence does each one have exactly? To visualize this, we’ll use the summary() function on our boosted tree model.

summary(boost)

var rel.inf
lstat lstat 42.9934534
rm rm 26.4241420
dis dis 7.5388505
crim crim 6.2457069
age age 4.5348747
ptratio ptratio 3.8283260
nox nox 3.8155045
tax tax 2.8332332
indus indus 0.8911894
rad rad 0.5318406
zn zn 0.1944506
chas chas 0.1684282

Similarly, to varimplot(), lstat and rm remain the most influential predictors, however, their rankings are reversed this time with lstat much further ahead than rm.

Below, we’ll plot the marginal effects of the three highest predictors:

plot(boost, i = "rm", col = "dodgerblue", lwd = 2)
plot(boost, i = "lstat", col = "dodgerblue", lwd = 2)
plot(boost, i = "dis", col = "dodgerblue", lwd = 2)

Performance Summary

Now that all the models have been produced, we need to get the RMSE score of the boosted model before we can make the final summary:

boost.pred.train = predict(boost,newdata=train,n.trees = 5000)
boost.pred.test = predict(boost,newdata=test,n.trees = 5000)
boost.rmse.trt = myRMSE(train$medv,boost.pred.train)
boost.rmse.tst = myRMSE(test$medv, boost.pred.test)
c(boost.rmse.trt,boost.rmse.tst)
[1] 0.0185029 3.6902316
Model Type RMSE Score (Training) RMSE Score (Testing)
Unpruned Tree 4.52 5.53
Pruned Tree 4.07 5.19
Linear Regression 4.60 5.46
Bagged Forest 1.37 3.52
Random Forest 1.43 3.63
Boosted Forest 0.02 3.69

For this data, tree-based methods outperform linear regression, among the tree methods, ensemble tree methods outperform single tree methods. The overall best method on this data is the bagging method with the lowest RMSE score (3.52) on the testing data set.

Why do ensemble models perform well? This is because ensemble methods such as Random Forests and boosting are ideal for reducing variance in models, which increase the accuracy of predictors.

  • Ensemble models reduce variance by training many trees on different data samples, and then averaging their predictions to come to a conclusion. An ensemble hinges on creating a diverse set of trees to mitigate biases that might exist in individual models.

  • The Random Forest method allows ensembles to introduce randomness into its trees by giving each tree a random subset of the data features.

  • The bagging method allows ensembles to introduce randomness into its trees by randomly subsetting the training data given to each tree.