9 Simple Linear Regression with Tidymodels
βKnowledge is a process of piling up facts; wisdom lies in their simplification.β - Martin Luther King Jr.
In earlier chapters, we fit simple linear regression models using base R functions such as lm(). In this chapter, we introduce the tidymodels framework for fitting the same kind of model in a more organized and reusable way.
tidymodels is a collection of R packages that work together to support the modeling process, including:
- specifying a model,
- defining preprocessing steps,
- fitting the model,
- generating predictions,
- evaluating performance, and
- organizing diagnostics.
We will illustrate the workflow using the mtcars data first used in Example 3.1.
Suppose we want to model fuel economy using horsepower.
In this chapter:
- the response variable is
mpg, miles per gallon, - the predictor variable is
hp, horsepower, - the statistical model is simple linear regression.
The model is \[\begin{align*} mpg = \beta_0+\beta_1 hp+\varepsilon. \end{align*}\]
The tidymodels workflow will let us fit this model, extract coefficients, make predictions, calculate metrics, and create diagnostic plots in a consistent framework.
9.1 Specifying the Model
In tidymodels, the first step is to define the type of model we want to fit. The parsnip package handles model specification.
Unlike the traditional approach where we directly call a function such as lm(), parsnip separates the model definition from the model fitting. This makes the modeling process more modular.
9.1.1 Model Specification with parsnip
The primary function for specifying a linear regression model is linear_reg(). This function says what kind of model we want, but it does not fit the model by itself.
We also specify an engine, which is the computational method used to estimate the model. For ordinary least squares regression, the engine is "lm".
lm_model <- linear_reg() |>
set_engine("lm")
lm_modelLinear Regression Model Specification (regression)
Computational engine: lm
The model specification says:
- use linear regression as the model type,
- use base Rβs
lm()function as the computational engine.
At this point, no data have been used and no coefficients have been estimated. We have only described the model we plan to fit.
A common beginner mistake is to think that linear_reg() fits the model. It does not.
The model specification is like saying, βI want to use linear regression.β The actual fitting happens later when we combine the model with a formula and data.
9.1.2 Hyperparameters and Engine Customization
Some models have hyperparameters, which are settings chosen before the model is fit. Simple linear regression with the "lm" engine has no tuning parameters, but more complex models do.
For example, ridge regression uses a penalty parameter:
ridge_model <- linear_reg(penalty = 0.1, mixture = 0) |>
set_engine("glmnet")Although these hyperparameters are not needed for simple linear regression, parsnip makes it easier to move from simple models to more complex models later in the course.
9.1.3 Choosing the Right Model and Engine
One strength of parsnip is that it separates the conceptual model from the computational engine.
For example:
bayesian_lm_model <- linear_reg() |>
set_engine("stan")The conceptual model is still linear regression, but the estimation method is Bayesian rather than ordinary least squares. This kind of modularity becomes more useful as the models become more complex.
9.2 Defining the Workflow
In the tidymodels ecosystem, a workflow is an organizing structure that ties together the pieces of the modeling process.
A workflow can contain:
- the model specification,
- the formula,
- preprocessing steps, and
- eventually the fitted model.
9.2.1 Creating a Workflow for Simple Linear Regression
For simple linear regression, our workflow consists of two main components:
- the model specification, and
- the formula that defines the response and predictor.
We will predict mpg using hp from the mtcars dataset.
lm_workflow <- workflow() |>
add_model(lm_model) |>
add_formula(mpg ~ hp)
lm_workflowββ Workflow ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Preprocessor: Formula
Model: linear_reg()
ββ Preprocessor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mpg ~ hp
ββ Model βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Linear Regression Model Specification (regression)
Computational engine: lm
In this code:
-
workflow()initializes an empty workflow, -
add_model()adds the model specification, -
add_formula()defines the response and predictor.
The formula mpg ~ hp means that mpg is the response and hp is the predictor.
9.2.2 Formula Interface
The formula interface uses the tilde symbol, ~, to separate the response variable from the predictor variables.
For example:
mpg ~ hpmeans βmodel mpg using hp.β
If we had multiple predictors, the formula could be written as
mpg ~ hp + wt + qsecwhich would fit a multiple regression model.
9.2.3 Preprocessing in Workflows
Simple linear regression may not require much preprocessing, but workflows can also include preprocessing through the recipes package.
Common preprocessing steps include:
- standardizing predictors,
- handling missing data,
- encoding categorical predictors,
- transforming variables.
For example, if am were treated as a categorical predictor, we could create dummy variables using a recipe. This is a small example, but it shows the key idea: preprocessing can live inside the modeling workflow instead of being handled separately.
First, treat am as a categorical variable rather than a numeric 0/1 variable.
mtcars_recipe_data <- mtcars |>
mutate(
am = factor(
am,
levels = c(0, 1),
labels = c("automatic", "manual")
)
)
mtcars_recipe <- recipe(mpg ~ hp + am, data = mtcars_recipe_data) |>
step_dummy(all_nominal_predictors())
mtcars_recipe |>
prep() |>
bake(new_data = NULL) |>
select(mpg, hp, starts_with("am_")) |>
head() |>
knitr::kable(digits = 3)| mpg | hp | am_manual |
|---|---|---|
| 21.0 | 110 | 1 |
| 21.0 | 110 | 1 |
| 22.8 | 93 | 1 |
| 21.4 | 110 | 0 |
| 18.7 | 175 | 0 |
| 18.1 | 105 | 0 |
The recipe has converted the categorical predictor am into a dummy variable that can be used by the linear regression model.
Now we can put the recipe inside a workflow and fit the model.
recipe_workflow <- workflow() |>
add_model(lm_model) |>
add_recipe(mtcars_recipe)
recipe_fit <- recipe_workflow |>
fit(data = mtcars_recipe_data)
extract_fit_engine(recipe_fit) |>
tidy() |>
knitr::kable(digits = 4)| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | 26.5849 | 1.4251 | 18.6548 | 0 |
| hp | -0.0589 | 0.0079 | -7.4952 | 0 |
| am_manual | 5.2771 | 1.0795 | 4.8883 | 0 |
This model is no longer simple linear regression because it uses both hp and am as predictors. The point of the example is not to replace our mpg ~ hp model, but to show why recipes are useful: the workflow remembers the preprocessing steps and applies them consistently.
Imagine fitting a model where you:
- replace missing values,
- create dummy variables,
- standardize predictors,
- fit the model,
- predict on new data.
If those steps are written separately, it is easy to accidentally preprocess the training data differently from the new data.
A workflow helps prevent that mistake by keeping the preprocessing and modeling steps bundled together.
9.3 Fitting the Model
Once the model and workflow have been defined, the next step is to fit the model to data.
In tidymodels, this is done with fit().
lm_fit <- lm_workflow |>
fit(data = mtcars)
lm_fitββ Workflow [trained] ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Preprocessor: Formula
Model: linear_reg()
ββ Preprocessor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mpg ~ hp
ββ Model βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Call:
stats::lm(formula = ..y ~ ., data = data)
Coefficients:
(Intercept) hp
30.09886 -0.06823
The fitted workflow contains the model specification, formula, and estimated model.
Behind the scenes, tidymodels uses the "lm" engine, so the fitted model is ultimately based on base Rβs lm() function.
9.3.1 Extracting Model Coefficients
To extract the estimated coefficients in a tidy table, we can use tidy().
For a fitted workflow, it is often useful to extract the fitted engine first and then apply tidy().
lm_engine <- extract_fit_engine(lm_fit)
coefs <- tidy(lm_engine)
knitr::kable(coefs, digits = 4)| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | 30.0989 | 1.6339 | 18.4212 | 0 |
| hp | -0.0682 | 0.0101 | -6.7424 | 0 |
The estimated slope for hp is -0.0682. This means that for each additional unit of horsepower, the predicted fuel economy decreases by about 0.0682 miles per gallon, on average.
The estimated intercept is 30.0989. In this context, the intercept is the predicted mpg when hp = 0. Since hp = 0 is not realistic for the cars in this dataset, the intercept is needed mathematically but is not very meaningful as a practical interpretation.
Since we used the "lm" engine, the fitted coefficients agree with base Rβs lm() function.
| method | term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|---|
| tidymodels workflow | (Intercept) | 30.0989 | 1.6339 | 18.4212 | 0 |
| tidymodels workflow | hp | -0.0682 | 0.0101 | -6.7424 | 0 |
| base lm | (Intercept) | 30.0989 | 1.6339 | 18.4212 | 0 |
| base lm | hp | -0.0682 | 0.0101 | -6.7424 | 0 |
The advantage of tidymodels is not that it changes ordinary least squares. The advantage is that it gives us a consistent framework that scales well as modeling tasks become more complex.
9.3.2 Base R and Tidymodels Side-by-Side
For ordinary least squares regression, tidymodels can use base Rβs lm() function as the engine. That means the estimated line is the same, but the organization of the modeling process is different.
| Modeling task | Base R approach | Tidymodels approach |
|---|---|---|
| Specify the model | Usually done inside lm()
|
linear_reg() and set_engine("lm")
|
| Attach the formula | lm(mpg ~ hp, data = mtcars) |
add_formula(mpg ~ hp) or add_recipe()
|
| Fit the model |
lm() fits immediately |
fit(data = mtcars) fits the workflow |
| Extract coefficients |
coef(), summary(), or broom::tidy()
|
extract_fit_engine() and tidy()
|
| Make predictions | predict(object, newdata = ...) |
predict(object, new_data = ...) |
| Add preprocessing | Manually before fitting | Store steps in a recipe()
|
| Reuse the process | Recreate steps by hand | Reuse the workflow object |
The two approaches are not enemies. Base R is direct and compact. Tidymodels is more structured and becomes especially helpful when the modeling process includes preprocessing, resampling, tuning, or repeated prediction on new data.
9.4 Making Predictions
After fitting the model, we can use predict() to generate fitted values for existing data or predictions for new data.
| .pred | mpg | hp |
|---|---|---|
| 22.594 | 21.0 | 110 |
| 22.594 | 21.0 | 110 |
| 23.754 | 22.8 | 93 |
| 22.594 | 21.4 | 110 |
| 18.159 | 18.7 | 175 |
| 22.935 | 18.1 | 105 |
The .pred column contains the predicted value of mpg from the fitted model.
Comparing .pred to the observed mpg helps us understand how far the modelβs predictions are from the actual values.
Suppose we want predicted fuel economy for cars with horsepower values 100, 150, and 200.
| hp | predicted_mpg |
|---|---|
| 100 | 23.276 |
| 150 | 19.865 |
| 200 | 16.453 |
These are point predictions from the fitted line. They are not prediction intervals; they do not show uncertainty around the predictions.
9.5 Performance Metrics
Once predictions have been made, we can evaluate model performance.
Common regression metrics include:
- RMSE: root mean squared error,
- MAE: mean absolute error,
- \(R^2\): proportion of variation explained.
| .metric | .estimator | .estimate |
|---|---|---|
| rmse | standard | 3.7403 |
| rsq | standard | 0.6024 |
| mae | standard | 2.9075 |
The RMSE is approximately 3.74, so the modelβs fitted values are typically off by about that many miles per gallon.
The \(R^2\) value is approximately 0.602, so the model explains about 60.2% of the sample variation in mpg.
The metrics above are calculated on the same data used to fit the model. These are training-set metrics.
Training-set performance can make a model look better than it will perform on new data. Later, we will use tools such as data splitting, cross-validation, and resampling to estimate performance more honestly.
9.6 Model Diagnostics
After fitting a linear regression model, we still need to check whether the assumptions appear reasonable.
The main assumptions are:
- linearity,
- constant variance,
- independence,
- normality of errors.
9.6.1 Residuals and Fitted Values
We can generate residuals and fitted values using augment() from the broom package.
To use augment() with the fitted lm object, we first extract the fitted engine from the workflow.
| ..y | hp | .fitted | .resid | .hat | .sigma | .cooksd | .std.resid |
|---|---|---|---|---|---|---|---|
| 21.0 | 110 | 22.594 | -1.594 | 0.040 | 3.917 | 0.004 | -0.421 |
| 21.0 | 110 | 22.594 | -1.594 | 0.040 | 3.917 | 0.004 | -0.421 |
| 22.8 | 93 | 23.754 | -0.954 | 0.051 | 3.925 | 0.002 | -0.253 |
| 21.4 | 110 | 22.594 | -1.194 | 0.040 | 3.922 | 0.002 | -0.315 |
| 18.7 | 175 | 18.159 | 0.541 | 0.037 | 3.928 | 0.000 | 0.143 |
| 18.1 | 105 | 22.935 | -4.835 | 0.043 | 3.820 | 0.037 | -1.280 |
The augmented dataset includes columns such as:
-
.fitted: fitted values, -
.resid: residuals, -
.std.resid: standardized residuals.
These columns are useful for diagnostic plots.
9.6.2 Residuals versus Fitted Values
A residuals-versus-fitted-values plot helps assess linearity and constant variance.
ggplot(diagnostic_data, aes(x = .fitted, y = .resid)) +
geom_point() +
geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
labs(
x = "Fitted values",
y = "Residuals",
title = "Residuals vs. fitted values"
) +
theme_minimal()
If the model is appropriate, we hope to see residuals scattered randomly around 0 with no strong curve or funnel shape.
9.6.3 Normality of Residuals
A Q-Q plot helps us check whether the residuals are approximately normal.
ggplot(diagnostic_data, aes(sample = .resid)) +
stat_qq() +
stat_qq_line(color = "red") +
labs(
title = "Q-Q plot of residuals",
x = "Theoretical quantiles",
y = "Sample residual quantiles"
) +
theme_minimal()
If the residuals are approximately normal, the points should roughly follow the reference line.
9.6.4 Independence of Errors
If the data are ordered in time, space, or another meaningful sequence, we should check for autocorrelation in the residuals.
acf(diagnostic_data$.resid, main = "ACF of residuals")
For mtcars, the row order is not a meaningful time order, so this plot is not as important as it would be for time series data. The main lesson is procedural: when observations have a meaningful order, check residual dependence.
9.6.5 Detecting Potential Outliers
Standardized residuals help identify observations that are unusually far from the fitted line.
ggplot(diagnostic_data, aes(x = .fitted, y = .std.resid)) +
geom_point() +
geom_hline(yintercept = 0, color = "gray40") +
geom_hline(yintercept = c(-2, 2), linetype = "dashed", color = "orange") +
geom_hline(yintercept = c(-3, 3), linetype = "dotted", color = "red") +
labs(
x = "Fitted values",
y = "Standardized residuals",
title = "Standardized residuals vs. fitted values"
) +
theme_minimal()
Observations outside the dashed or dotted lines should be investigated, especially if they also have high leverage or strong influence.
When Tidymodels code fails, the issue is often about the object being used rather than the regression idea itself.
| Symptom | Likely issue | What to check |
|---|---|---|
predict() gives an error about new_data
|
The argument name is wrong or the object is not fitted | Use new_data = ... and make sure the workflow has been fit |
| New predictions fail after using a recipe | The new data do not contain the variables required by the recipe | Supply the original predictor columns expected by the recipe, not manually created dummy columns |
| Coefficients are not available directly from the workflow | The fitted engine is nested inside the workflow | Use extract_fit_engine() before tidy()
|
| The model uses the wrong response or predictor | The formula sides were reversed | Remember: response on the left, predictors on the right |
| Results differ from what you expected | A preprocessing step changed the variables used by the model | Inspect the recipe with prep() and bake()
|
The useful habit is to ask, βWhich object do I have right now: a model specification, a recipe, an unfitted workflow, or a fitted workflow?β Many Tidymodels errors become easier to diagnose once that distinction is clear.
9.7 Recap
This chapter introduced a tidymodels workflow for simple linear regression.
| Idea | Meaning |
|---|---|
linear_reg() |
Specifies a linear regression model. |
| Engine | The computational method used to fit the model, such as "lm". |
| Workflow | An object that bundles the model specification with a formula or recipe. |
recipe() |
Stores preprocessing steps such as dummy-variable creation, transformations, or standardization. |
add_recipe() |
Adds a recipe to a workflow instead of using a plain formula. |
fit() |
Fits the workflow to data. |
tidy() |
Extracts model coefficients and inferential summaries in a clean table. |
predict() |
Generates predictions for existing or new data. |
yardstick::metrics() |
Calculates model performance metrics such as RMSE and \(R^2\). |
augment() |
Adds fitted values, residuals, and diagnostic quantities to the data. |
| Training-set metrics | Performance metrics calculated on the same data used to fit the model; often optimistic. |
| Base R vs. Tidymodels | The fitted least-squares line can be identical, but Tidymodels organizes the modeling process more explicitly. |
| Diagnostics | Residual-based checks for model assumptions. |
9.8 Check your understanding
What is the difference between specifying a model with
linear_reg()and fitting a model withfit()?What role does the engine play in a
parsnipmodel specification?Why might a workflow be useful even for simple linear regression?
What problem does a
recipe()solve in the modeling workflow?If Tidymodels uses the
"lm"engine, why should the coefficient estimates agree with base Rβslm()?In the formula
mpg ~ hp, which variable is the response and which is the predictor?Why is the intercept in the
mpg ~ hpmodel not very meaningful in practice?What does
predict()return in the examples from this chapter?Why are training-set metrics often optimistic?
What is the difference between
tidy()andaugment()?If prediction fails after you use a recipe, what is one of the first things you should check?
Why is the ACF plot not especially meaningful for
mtcars, but important for time-ordered data?
Specification versus fitting.
linear_reg()describes the type of model to use.fit()estimates the model parameters from data.The engine performs the computation. The engine specifies which underlying function or package will fit the model. For ordinary least squares regression, the
"lm"engine uses base Rβslm()function.Workflows organize the process. A workflow keeps the model specification, formula, and preprocessing steps together. This makes the modeling process more reproducible and easier to modify later.
Recipes store preprocessing. A recipe records steps such as dummy-variable creation, transformations, or standardization. This helps ensure that the same preprocessing is applied when the model is fit and when predictions are made on new data.
Same engine, same least-squares fit. When Tidymodels uses the
"lm"engine, it is using base Rβs ordinary least squares machinery behind the scenes. The organization is different, but the estimated line should be the same.Left side is the response. In
mpg ~ hp,mpgis the response variable andhpis the predictor variable.Zero horsepower is not realistic. The intercept is the predicted
mpgwhenhp = 0. Since cars in this dataset do not have 0 horsepower, the intercept is needed mathematically but is not practically meaningful.Predicted values.
predict()returns a tibble with a.predcolumn containing predicted values from the fitted model.The model has already seen the data. Training-set metrics evaluate the model on the same data used to fit it, so they usually make the model look better than it may perform on new data.
Model summary versus observation-level diagnostics.
tidy()summarizes model coefficients, standard errors, test statistics, and p-values.augment()gives observation-level information such as fitted values and residuals.Check the required predictor columns. New data should contain the original predictor variables expected by the workflow or recipe. If the recipe creates dummy variables, do not usually supply the dummy variables yourself; supply the original categorical predictor and let the recipe handle the encoding.
Order matters for autocorrelation. The ACF plot is meaningful when observations have a meaningful order, such as time. The row order of
mtcarsis not a natural time sequence, so autocorrelation is not a central concern for that dataset.