Chapter 3 Lab: Linear Regression with tidymodels

This is a modified version of the ISLR tidymodels lab for Chapter 3: Regression that incorporates some additional data exploration, and reintroduces some of the things that were dropped from the original ISLR lab.

Libraries

library(tidymodels)
library(ISLR2)

Simple linear regression

Explore the data

The Boston data set includes information about 506 neighborhoods in Boston. We want to predict medv, the median value of owner-occupied homes in thousands of dollars, using simple linear regression. We will use lstat, the percentage of the population that is classified as having a lower status, as the predictor.

Boston %>% glimpse
Rows: 506
Columns: 13
$ crim    <dbl> 0.00632, 0.02731, 0.02729, 0.03237, 0.06905, 0.02985, 0.08829,…
$ zn      <dbl> 18.0, 0.0, 0.0, 0.0, 0.0, 0.0, 12.5, 12.5, 12.5, 12.5, 12.5, 1…
$ indus   <dbl> 2.31, 7.07, 7.07, 2.18, 2.18, 2.18, 7.87, 7.87, 7.87, 7.87, 7.…
$ chas    <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ nox     <dbl> 0.538, 0.469, 0.469, 0.458, 0.458, 0.458, 0.524, 0.524, 0.524,…
$ rm      <dbl> 6.575, 6.421, 7.185, 6.998, 7.147, 6.430, 6.012, 6.172, 5.631,…
$ age     <dbl> 65.2, 78.9, 61.1, 45.8, 54.2, 58.7, 66.6, 96.1, 100.0, 85.9, 9…
$ dis     <dbl> 4.0900, 4.9671, 4.9671, 6.0622, 6.0622, 6.0622, 5.5605, 5.9505…
$ rad     <int> 1, 2, 2, 3, 3, 3, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4,…
$ tax     <dbl> 296, 242, 242, 222, 222, 222, 311, 311, 311, 311, 311, 311, 31…
$ ptratio <dbl> 15.3, 17.8, 17.8, 18.7, 18.7, 18.7, 15.2, 15.2, 15.2, 15.2, 15…
$ lstat   <dbl> 4.98, 9.14, 4.03, 2.94, 5.33, 5.21, 12.43, 19.15, 29.93, 17.10…
$ medv    <dbl> 24.0, 21.6, 34.7, 33.4, 36.2, 28.7, 22.9, 27.1, 16.5, 18.9, 15…

Let’s plot the data. It may be problematic to try to fit a line to these data. The relationship does not appear to be linear.

Boston %>%
  ggplot(aes(x = lstat, y = medv)) +
  geom_point() +
  theme_classic()

Question 1: Explore a different predictor

Now consider rm, the average number of rooms per dwelling, as a predictor of medv. Create a scatterplot of medv versus rm. Describe the direction and form of the relationship. Does a linear model appear reasonable?

Specify the model

We will start by specifying the model using parsnip. We want to use a linear regression model, we do not need to set the mode, because linear regression models only have one mode (regression). However, we will set the mode here, since this will be required in other parsnip specifications. We will use the lm engine, which is the standard stats::lm(). See ?linear_reg() for other options.

There is a good introduction to model building here that includes model specification and fitting using parsnip.

Note that the model specification does not indicate the data set we will use to train the model.

lm_spec <- linear_reg() %>%
  set_mode("regression") %>%
  set_engine("lm")

lm_spec
Linear Regression Model Specification (regression)

Computational engine: lm 

Train the model

Now we can fit/train the model. We can use a formula to specify the relationship we wish to model. Of course, we need to indicate which data set we will be using.

lm_fit <- lm_spec %>%
  fit(medv ~ lstat, data = Boston)

lm_fit
parsnip model object


Call:
stats::lm(formula = medv ~ lstat, data = data)

Coefficients:
(Intercept)        lstat  
      34.55        -0.95  

Explore the model

This is the structure of the fitted model (at the highest level).

lm_fit %>% 
  str(max.level = 1)
List of 7
 $ lvl         : NULL
 $ ordered     : logi FALSE
 $ spec        :List of 8
  ..- attr(*, "class")= chr [1:2] "linear_reg" "model_spec"
 $ fit         :List of 12
  ..- attr(*, "class")= chr "lm"
 $ preproc     :List of 1
 $ elapsed     :List of 2
 $ censor_probs: list()
 - attr(*, "class")= chr [1:2] "_lm" "model_fit"

We can access the fit object using lm_fit$fit or by using the pluck function. In this case it is a standard lm object.

lm_fit %>%
  pluck("fit")

Call:
stats::lm(formula = medv ~ lstat, data = data)

Coefficients:
(Intercept)        lstat  
      34.55        -0.95  

We can view the standard lm object summary, which includes the regression table and some other useful summary information. The students may be used to this summary if they used R in MATH 312.

lm_fit %>%
  pluck("fit") %>%
  summary()

Call:
stats::lm(formula = medv ~ lstat, data = data)

Residuals:
    Min      1Q  Median      3Q     Max 
-15.168  -3.990  -1.318   2.034  24.500 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept) 34.55384    0.56263   61.41   <2e-16 ***
lstat       -0.95005    0.03873  -24.53   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 6.216 on 504 degrees of freedom
Multiple R-squared:  0.5441,    Adjusted R-squared:  0.5432 
F-statistic: 601.6 on 1 and 504 DF,  p-value: < 2.2e-16

We can use the tidy function from the broom package to extract the parameter estimates in a nice tibble format.

The model is \[\hat{medv} = 34.55 - 0.950 \cdot lstat\]

lm_fit %>% 
  tidy()
# A tibble: 2 × 5
  term        estimate std.error statistic   p.value
  <chr>          <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)   34.6      0.563       61.4 3.74e-236
2 lstat         -0.950    0.0387     -24.5 5.08e- 88

The broom package also allows us to use the glance function to extract model statistics.

lm_fit %>%
  glance()
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic  p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>    <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.544         0.543  6.22      602. 5.08e-88     1 -1641. 3289. 3302.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>

Question 2: Interpret a simple regression

Fit the simple linear regression model medv ~ rm and display the tidy() and glance() output. Write the fitted regression equation and interpret the slope in context. Is rm a statistically significant predictor at the 0.05 level? What proportion of the variability in medv is explained by this model?

Use the model to make predictions (predicting the training data).

Let’s use the model to predict the training data. We can use the predict function. We need to specify the input data. In this case we will use the training data themselves.

lm_fit %>% 
  predict(new_data = Boston)
# A tibble: 506 × 1
   .pred
   <dbl>
 1 29.8 
 2 25.9 
 3 30.7 
 4 31.8 
 5 29.5 
 6 29.6 
 7 22.7 
 8 16.4 
 9  6.12
10 18.3 
# ℹ 496 more rows

By default, predict returns predicted response values. We can ask for different types of outputs, including 95% confidence intervals for the mean response. See ?parsnip::predict.model_fit for more options.

lm_fit %>%
  predict(new_data = Boston, type = "conf_int")
# A tibble: 506 × 2
   .pred_lower .pred_upper
         <dbl>       <dbl>
 1       29.0        30.6 
 2       25.3        26.5 
 3       29.9        31.6 
 4       30.8        32.7 
 5       28.7        30.3 
 6       28.8        30.4 
 7       22.2        23.3 
 8       15.6        17.1 
 9        4.70        7.54
10       17.7        18.9 
# ℹ 496 more rows

We can also find prediction intervals, which should be wider.

lm_fit %>%
  predict(new_data = Boston, type = "pred_int")
# A tibble: 506 × 2
   .pred_lower .pred_upper
         <dbl>       <dbl>
 1       17.6         42.1
 2       13.6         38.1
 3       18.5         43.0
 4       19.5         44.0
 5       17.3         41.7
 6       17.4         41.8
 7       10.5         35.0
 8        4.13        28.6
 9       -6.18        18.4
10        6.08        30.5
# ℹ 496 more rows

Question 3: Interpret a prediction interval

Use the model from Question 2 to predict medv for a neighborhood with rm = 6. Also obtain a 95% prediction interval. Report both results and interpret the prediction interval in context.

We can easily add the predicted values to the data set so that we can compare them to the observed values.

bind_cols(
  predict(lm_fit, new_data = Boston),
  Boston
) %>%
  select(medv, .pred)
# A tibble: 506 × 2
    medv .pred
   <dbl> <dbl>
 1  24   29.8 
 2  21.6 25.9 
 3  34.7 30.7 
 4  33.4 31.8 
 5  36.2 29.5 
 6  28.7 29.6 
 7  22.9 22.7 
 8  27.1 16.4 
 9  16.5  6.12
10  18.9 18.3 
# ℹ 496 more rows

An alternative way to do this is with the augment function. Note that augment performs both the prediction and the combination.

augment(lm_fit, new_data = Boston) %>%
  select(medv, .pred)
# A tibble: 506 × 2
    medv .pred
   <dbl> <dbl>
 1  24   29.8 
 2  21.6 25.9 
 3  34.7 30.7 
 4  33.4 31.8 
 5  36.2 29.5 
 6  28.7 29.6 
 7  22.9 22.7 
 8  27.1 16.4 
 9  16.5  6.12
10  18.9 18.3 
# ℹ 496 more rows

Visualize the fit

Let’s add the regression line to the plot so we can see how well the model fit the data.

First, we extract the coefficients as a vector.

coef_est <- lm_fit %>%
  tidy %>%
  pull(estimate)
coef_est
[1] 34.5538409 -0.9500494

Now we build the plot. We can add a line using geom_abline

Boston %>%
  ggplot(aes(x = lstat, y = medv)) +
  geom_point() +
  geom_abline(intercept = coef_est[1], slope = coef_est[2], color = 'darkkhaki', size = 1.5) +
  theme_classic()

An easier way is to use geom_smooth to create the plot, since we can indicate that we want to use a linear model (using the same lm engine, in fact). We do not need to extract coefficients first (or even build the model) if we do it this way.

Boston %>%
  ggplot(aes(x = lstat, y = medv)) +
  geom_point() +
  geom_smooth(method = 'lm', se = FALSE, color = 'darkkhaki', size = 1.5) +
  theme_classic()

Finally, let’s create a residual plot. We will need to do a little work to compute the residuals. We can see that there is a relationship between the residuals and the predicted values. This is consistent with the nonlinearity in the data that we noted earlier. It suggests that a more flexible model might be better.

augment(lm_fit, new_data = Boston) %>%
  mutate(.res = .pred - medv) %>%
  ggplot(aes(x = .pred, y = .res)) +
  geom_point() +
  theme_classic()

Multiple linear regression

The syntax for multiple linear regression is similar to what we used for simple linear regression. Let’s use lstat and age, the percentage of owner-occupied units built prior to 1940, as predictors.

We do not need to change lm_spec. We can use the same model specification for simple and multiple linear regression.

lm_spec
Linear Regression Model Specification (regression)

Computational engine: lm 

Now we fit a new model using these two predictors. The formula medv ~ lstat + age indicates that we wish to fit a linear model of the form \(medv = \beta_0 + \beta_1\cdot lstat + \beta_2\cdot age\). Note that this does not account for possible interactions, which we will explore later.

lm_fit2 <- lm_spec %>%
  fit(medv ~ lstat + age, data = Boston)

lm_fit2
parsnip model object


Call:
stats::lm(formula = medv ~ lstat + age, data = data)

Coefficients:
(Intercept)        lstat          age  
   33.22276     -1.03207      0.03454  

As before, we can extract model coefficients, statistics, and make predictions. Including age as a predictor does not seem to have improved the fit very much.

lm_fit2 %>%
  tidy()
# A tibble: 3 × 5
  term        estimate std.error statistic   p.value
  <chr>          <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)  33.2       0.731      45.5  2.94e-180
2 lstat        -1.03      0.0482    -21.4  8.42e- 73
3 age           0.0345    0.0122      2.83 4.91e-  3
lm_fit2 %>%
  glance()
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic  p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>    <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.551         0.549  6.17      309. 2.98e-88     2 -1638. 3283. 3300.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>
lm_fit2 %>%
  predict(new_data = Boston)
# A tibble: 506 × 1
   .pred
   <dbl>
 1 30.3 
 2 26.5 
 3 31.2 
 4 31.8 
 5 29.6 
 6 29.9 
 7 22.7 
 8 16.8 
 9  5.79
10 18.5 
# ℹ 496 more rows

Question 4: Interpret a multiple regression

Now consider the multiple regression model medv ~ lstat + rm. Display the tidy() and glance() output. Which predictors are statistically significant at the 0.05 level? Interpret the coefficient of rm while holding lstat constant.

Suppose we want fit a third model using all of the variables (except medv) as predictors. We could list all of the variables in the formula, but there is also a shortcut. The formula y ~ . uses all of the variables except the response (y) as predictors.

lm_fit3 <- lm_spec %>%
  fit(medv ~ ., data = Boston)

lm_fit3
parsnip model object


Call:
stats::lm(formula = medv ~ ., data = data)

Coefficients:
(Intercept)         crim           zn        indus         chas          nox  
  41.617270    -0.121389     0.046963     0.013468     2.839993   -18.758022  
         rm          age          dis          rad          tax      ptratio  
   3.658119     0.003611    -1.490754     0.289405    -0.012682    -0.937533  
      lstat  
  -0.552019  

Now the adjusted \(R^2\) is much higher.

lm_fit3 %>% 
  glance()
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.734         0.728  4.80      114. 2.23e-133    12 -1505. 3038. 3097.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>

Let’s view the residual plot. There is still a pattern.

augment(lm_fit3, new_data = Boston) %>%
  mutate(.res = .pred - medv) %>%
  ggplot(aes(x = .pred, y = .res)) +
  geom_point() +
  theme_classic()

We can compute the variance inflation factors for each predictor using the vif function from the car package. The vif function takes the lm object as an argument. We cannot use the parsnip model object directly, we have to extract the lm object from it first. The predictors tax and rad have moderately high values (greater than 5, but less than 10), suggesting that we should be at least somewhat concerned with colinearity with this model.

library(car)
lm_fit3 %>%
  pluck("fit") %>%
  vif()
    crim       zn    indus     chas      nox       rm      age      dis 
1.767486 2.298459 3.987181 1.071168 4.369093 1.912532 3.088232 3.954037 
     rad      tax  ptratio    lstat 
7.445301 9.002158 1.797060 2.870777 

Question 5: Assess multicollinearity

Use the VIF output for the model containing all Boston predictors. Which predictor has the largest VIF? Which predictors have VIF values greater than 5? What do these values suggest about multicollinearity in the model?

Interaction terms

We will consider two methods for adding interaction terms to a model. The second method, using recipes, is more general (it will work with more model engines).

Fitting a model with an interaction term

Let’s create a model that includes lstat, age, and their interaction lstatage as predictors. We will start with an approach that is similar to what we did above. The formula medv ~ lstat * age is shorthand for the formula medv ~ lstat + age + lstat:age, which includes lstat, age, and the interaction in the model. We can use either form when we fit the model. We will use the more compact version.

lm_fit4 <- lm_spec %>%
  fit(medv ~ lstat * age, data = Boston)

lm_fit4
parsnip model object


Call:
stats::lm(formula = medv ~ lstat * age, data = data)

Coefficients:
(Intercept)        lstat          age    lstat:age  
 36.0885359   -1.3921168   -0.0007209    0.0041560  
lm_fit4 %>% tidy
# A tibble: 4 × 5
  term         estimate std.error statistic  p.value
  <chr>           <dbl>     <dbl>     <dbl>    <dbl>
1 (Intercept) 36.1        1.47      24.6    4.91e-88
2 lstat       -1.39       0.167     -8.31   8.78e-16
3 age         -0.000721   0.0199    -0.0363 9.71e- 1
4 lstat:age    0.00416    0.00185    2.24   2.52e- 2

Question 6: Interpret an interaction

Fit the interaction model medv ~ lstat * rm and display the tidy() output. Is the interaction statistically significant at the 0.05 level? Based on the sign of the interaction coefficient, describe how the relationship between lstat and medv changes as rm increases.

Thus the fitted model is \[\hat{medv} = 36.1 - 13.9\cdot lstat -0.001\cdot age + 0.0042\cdot lstat\cdot age\]

Adding an interaction term using recipes

Often we need to take steps to transform data before we analyze it. This is referred to as pre-processing. A recipe is a description of the pre-processing steps. We can use recipes to add an interaction to a model.

There are multiple ways to create a recipe. In many cases, the easiest way is to use a formula. The recipe uses the formula to infer which variable in the data set will be treated as the outcome (response), and which variables will be treated as predictors.

The following code creates a very simple recipe that just identifies the variables and their roles without applying any transformations.

recipe(medv ~ lstat + age, data = Boston)

We can add steps to our recipe to include things like interaction terms. Steps are added using step_* functions. In this case, we use the step_interact function to add an interaction term. When this recipe is followed, a new column will be created in the data for the interaction between lstat and age.

rec_spec_interact <- recipe(medv ~ lstat + age, data = Boston) %>%
  step_interact(~ lstat:age)

rec_spec_interact

Next we will use a workflow to combine the linear model specification with the recipe we created.

lm_wf_interact <- workflow() %>%
  add_model(lm_spec) %>%
  add_recipe(rec_spec_interact)

lm_wf_interact
══ Workflow ════════════════════════════════════════════════════════════════════
Preprocessor: Recipe
Model: linear_reg()

── Preprocessor ────────────────────────────────────────────────────────────────
1 Recipe Step

• step_interact()

── Model ───────────────────────────────────────────────────────────────────────
Linear Regression Model Specification (regression)

Computational engine: lm 

Now we can fit the model. Since we already identified the variables when we created the recipe, we don’t need to do it again when we fit the model. The result of fitting a workflow object is a new workflow object that now contains a fitted/trained model.

lm_wf_interact_trained <- lm_wf_interact %>% 
  fit(data = Boston)

lm_wf_interact_trained
══ Workflow [trained] ══════════════════════════════════════════════════════════
Preprocessor: Recipe
Model: linear_reg()

── Preprocessor ────────────────────────────────────────────────────────────────
1 Recipe Step

• step_interact()

── Model ───────────────────────────────────────────────────────────────────────

Call:
stats::lm(formula = ..y ~ ., data = data)

Coefficients:
(Intercept)        lstat          age  lstat_x_age  
 36.0885359   -1.3921168   -0.0007209    0.0041560  

We can still use the glance and tidy functions to view the model statistics and coefficients for the fitted model that is now within our workflow object.

lm_wf_interact_trained %>% tidy()
# A tibble: 4 × 5
  term         estimate std.error statistic  p.value
  <chr>           <dbl>     <dbl>     <dbl>    <dbl>
1 (Intercept) 36.1        1.47      24.6    4.91e-88
2 lstat       -1.39       0.167     -8.31   8.78e-16
3 age         -0.000721   0.0199    -0.0363 9.71e- 1
4 lstat_x_age  0.00416    0.00185    2.24   2.52e- 2
lm_wf_interact_trained %>% glance()
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic  p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>    <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.556         0.553  6.15      209. 4.86e-88     3 -1635. 3280. 3301.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>

We can also use the workflow object to make predictions. One advantage of using a workflow to do this is that the same pre-processing steps (described by the recipe) will be performed on the new data before the prediction is made. This can be very helpful if there is extensive pre-processing.

lm_wf_interact_trained %>% predict(new_data = Boston)
# A tibble: 506 × 1
   .pred
   <dbl>
 1 30.5 
 2 26.3 
 3 31.5 
 4 32.5 
 5 29.8 
 6 30.1 
 7 22.2 
 8 17.0 
 9  6.79
10 18.3 
# ℹ 496 more rows

Nonlinear transformations of the predictors

We can use recipes to apply nonlinear transformations to individual variables. Let’s fit a model of the form \(medv = \beta_0 + \beta_1\cdot lstat +\beta_2 lstat^2\), which may be able to capture the nonlinear relationship we noted earlier. We will use a different step_* function to add a quadratic term in the recipe. The step_mutate function works like the dplyr function mutate, giving us a very flexible method for adding new variables to are data that are obtained by transforming existing variables. There are many other step_* functions that can be useful for a variety of transformations. You can find a list of them here.

rec_spec_pow2 <- recipe(medv ~ lstat, data = Boston) %>%
  step_mutate(lstat2 = lstat ^ 2)

lm_wf_pow2 <- workflow() %>%
  add_model(lm_spec) %>%
  add_recipe(rec_spec_pow2)

lm_wf_pow2_trained <- lm_wf_pow2 %>%
  fit(data = Boston)

The resulting model has a higher \(R^2\) value than any model we have seen so far, and the p-value for the lstat2 term is nearly 0.

lm_wf_pow2_trained %>% glance()
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.641         0.639  5.52      449. 1.56e-112     2 -1581. 3171. 3187.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>
lm_wf_pow2_trained %>% tidy()
# A tibble: 3 × 5
  term        estimate std.error statistic   p.value
  <chr>          <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)  42.9      0.872        49.1 3.50e-194
2 lstat        -2.33     0.124       -18.8 2.55e- 60
3 lstat2        0.0435   0.00375      11.6 7.63e- 28

Question 7: Evaluate a quadratic relationship

Fit a quadratic model for medv using rm and rm2 = rm^2. Display the tidy() and glance() output. Is the quadratic term statistically significant at the 0.05 level? What does the result suggest about whether the relationship is strictly linear?

We can use ggplot to show the resulting curve. The easiest way to do this is to use the geom_smooth again. This will fit a model in the same way that we fit ours (using the lm engine), and we can specify that the curve should be a second degree polynomial like the one we just fit. We can see that the more flexible model does a better job of describing the relationship (compare this to the plot we generated when we fit a straight line to the data).

Boston %>% 
  ggplot(aes(x = lstat, y = medv)) +
  geom_point() +
  geom_smooth(method = "lm", formula = y ~ poly(x, degree = 2), se = FALSE, color = "darkkhaki", size = 1.5) +
  theme_classic()

Next we can create a residual plot. There is no longer evidence of a pattern in the residuals.

augment(lm_wf_pow2_trained, new_data = Boston) %>%
  mutate(.res = .pred - medv) %>%
  ggplot(aes(x = .pred, y = .res)) +
  geom_point() +
  theme_classic()

Model comparison

We can compare the anova function to compare the fit of the quadratic model to the fit of the linear model. First we train a simple linear regression model using a workflow (this should match the model lm_fit that we created earlier).

rec_spec_lin <- recipe(medv ~ lstat, data = Boston)

lm_wf_lin <- workflow() %>%
  add_model(lm_spec) %>%
  add_recipe(rec_spec_lin)

lm_wf_lin_trained <- lm_wf_lin %>%
  fit(data = Boston)

Next we need to extract the corresponding lm model objects. We can use the extract_fit_engine function to extract the models from the workflow objects.

The analysis of variance tests the null hypothesis that the two models fit the data equally well. The alternative hypothesis is that the full model (with the quadratic term) fits the data better. The results (F = 135.2, p < 2.2e-16) give us strong evidence against the null hypothesis.

lm1 <- lm_wf_lin_trained %>% extract_fit_engine()
lm2 <- lm_wf_pow2_trained %>% extract_fit_engine()

anova(lm1, lm2)
Analysis of Variance Table

Model 1: ..y ~ lstat
Model 2: ..y ~ lstat + lstat2
  Res.Df   RSS Df Sum of Sq     F    Pr(>F)    
1    504 19472                                 
2    503 15347  1    4125.1 135.2 < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Qualitative predictors

For our final analysis, we will turn to a different dataset. The Carseats data includes information about car seats sold at 400 different stores. The Sales variable indicates the number of sales (in thousands of units). We will attempt to predict Sales using several different predictors. The predictor ShelveLoc, which indicates the quality of the shelving location (Good, Bad, or Medium), is qualitative.

Carseats %>% glimpse()
Rows: 400
Columns: 11
$ Sales       <dbl> 9.50, 11.22, 10.06, 7.40, 4.15, 10.81, 6.63, 11.85, 6.54, …
$ CompPrice   <dbl> 138, 111, 113, 117, 141, 124, 115, 136, 132, 132, 121, 117…
$ Income      <dbl> 73, 48, 35, 100, 64, 113, 105, 81, 110, 113, 78, 94, 35, 2…
$ Advertising <dbl> 11, 16, 10, 4, 3, 13, 0, 15, 0, 0, 9, 4, 2, 11, 11, 5, 0, …
$ Population  <dbl> 276, 260, 269, 466, 340, 501, 45, 425, 108, 131, 150, 503,…
$ Price       <dbl> 120, 83, 80, 97, 128, 72, 108, 120, 124, 124, 100, 94, 136…
$ ShelveLoc   <fct> Bad, Good, Medium, Medium, Bad, Bad, Medium, Good, Medium,…
$ Age         <dbl> 42, 65, 59, 55, 38, 78, 71, 67, 76, 76, 26, 50, 62, 53, 52…
$ Education   <dbl> 17, 10, 12, 14, 13, 16, 15, 10, 10, 17, 10, 13, 18, 18, 18…
$ Urban       <fct> Yes, Yes, Yes, Yes, Yes, No, Yes, Yes, No, No, No, Yes, Ye…
$ US          <fct> Yes, Yes, Yes, Yes, No, Yes, No, Yes, No, Yes, Yes, Yes, N…

The lm engine will automatically generate dummy variables for qualitative variables. We can use the contrasts function to see how R will code the dummy variables.

For ShelveLoc, R will create two variables, Good and Medium. A Bad location will be coded as Good = 0 and Medium = 0 (not Good and not Medium), whereas a Good location will be coded as Good = 1 and Medium = 0 (yes Good and not Medium), and so forth. Thus, R uses indicator coding.

Carseats %>%
  pull(ShelveLoc) %>%
  contrasts()
       Good Medium
Bad       0      0
Good      1      0
Medium    0      1

We will create and train a model two different ways. We will start with a simple parsnip model. Then we will use a recipe.

Fitting a parsnip model

Recall that the model specification lm_spec that we defined at the beginning of this lab just indicated the engine (“lm”) and the mode (“regression”). Even though we are using a different data set, we can use the same specification, because it has not changed.

Let’s train a model that uses all of the predictors along with interactions between Income and Advertising and between Price and Age. Use ?Carseats to learn more about these predictors. Remembe that . is shorthand for ‘everything’ (all the predictors).

lm_cs_fit <- lm_spec %>%
  fit(Sales ~ . + Income:Advertising + Price:Age, data = Carseats)

lm_cs_fit %>% glance
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.876         0.872  1.01      210. 6.14e-166    13  -565. 1159. 1219.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>
lm_cs_fit %>% tidy
# A tibble: 14 × 5
   term                estimate std.error statistic   p.value
   <chr>                  <dbl>     <dbl>     <dbl>     <dbl>
 1 (Intercept)         6.58      1.01         6.52  2.22e- 10
 2 CompPrice           0.0929    0.00412     22.6   1.64e- 72
 3 Income              0.0109    0.00260      4.18  3.57e-  5
 4 Advertising         0.0702    0.0226       3.11  2.03e-  3
 5 Population          0.000159  0.000368     0.433 6.65e-  1
 6 Price              -0.101     0.00744    -13.5   1.74e- 34
 7 ShelveLocGood       4.85      0.153       31.7   1.38e-109
 8 ShelveLocMedium     1.95      0.126       15.5   1.34e- 42
 9 Age                -0.0579    0.0160      -3.63  3.18e-  4
10 Education          -0.0209    0.0196      -1.06  2.88e-  1
11 UrbanYes            0.140     0.112        1.25  2.13e-  1
12 USYes              -0.158     0.149       -1.06  2.91e-  1
13 Income:Advertising  0.000751  0.000278     2.70  7.29e-  3
14 Price:Age           0.000107  0.000133     0.801 4.24e-  1

Question 8: Interpret qualitative predictors

Fit the model Sales ~ Price + ShelveLoc using the Carseats data and display the tidy() output. Which predictors are statistically significant at the 0.05 level? Interpret the coefficients for Price and ShelveLocGood in context and their significance.

Using a recipe

We can also use a recipe. This will work even for engines are unable to handle qualitative variables automatically. Part of our recipe is a step to convert all nominal (qualitative) predictors to dummy variables using the step_dummy function. We also add a step to create the interaction terms.

rec_spec <- recipe(Sales ~ ., data = Carseats) %>%
  step_dummy(all_nominal_predictors()) %>%
  step_interact( ~ Income:Advertising + Price:Age)

Now let’s create a workflow combining the recipe and the model specification.

lm_wf <- workflow() %>%
  add_model(lm_spec) %>%
  add_recipe(rec_spec)

Finally we fit/train the model.

lm_wf_trained <- lm_wf %>%
  fit(data = Carseats)

lm_wf_trained %>% glance()
# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>
1     0.876         0.872  1.01      210. 6.14e-166    13  -565. 1159. 1219.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>
lm_wf_trained %>% tidy()
# A tibble: 14 × 5
   term                  estimate std.error statistic   p.value
   <chr>                    <dbl>     <dbl>     <dbl>     <dbl>
 1 (Intercept)           6.58      1.01         6.52  2.22e- 10
 2 CompPrice             0.0929    0.00412     22.6   1.64e- 72
 3 Income                0.0109    0.00260      4.18  3.57e-  5
 4 Advertising           0.0702    0.0226       3.11  2.03e-  3
 5 Population            0.000159  0.000368     0.433 6.65e-  1
 6 Price                -0.101     0.00744    -13.5   1.74e- 34
 7 Age                  -0.0579    0.0160      -3.63  3.18e-  4
 8 Education            -0.0209    0.0196      -1.06  2.88e-  1
 9 ShelveLoc_Good        4.85      0.153       31.7   1.38e-109
10 ShelveLoc_Medium      1.95      0.126       15.5   1.34e- 42
11 Urban_Yes             0.140     0.112        1.25  2.13e-  1
12 US_Yes               -0.158     0.149       -1.06  2.91e-  1
13 Income_x_Advertising  0.000751  0.000278     2.70  7.29e-  3
14 Price_x_Age           0.000107  0.000133     0.801 4.24e-  1

Writing Functions

Sometimes we will find it useful to write our own functions in R. Functions can be very simple or very complicated. We will introduce user-written functions with some very simple examples.

We will start with a function that doesn’t do very much. It will simply print the string “Hello World!” when we run it. We will call the function hello. This function will not even have any arguments.

hello <- function(){
  print("Hello World!")
}

We can run the function by typing hello().

hello()
[1] "Hello World!"

Let’s create a somewhat more interesting function that takes a numerical argument, adds 2, then returns the result. This time there is a single argument (we call it x). We use the return function to return the result.

add2 <- function(x){
  y <- x + 2
  return(y)
}

Let’s test it.

add2(3)
[1] 5

If you find yourself writing the same code again and again, it’s probably a good idea to write a function to do the work. Then just use the function instead!