Imagine you coach a running club and want to know why some runners are faster than others. Is it how many kilometres they run each week? How long they’ve been running? Or whether they have a coach?
Linear regression answers questions like this. It draws the best fitting straight line through your data so you can see how one measurement (the outcome, here 5 km time) changes with others (the predictors).
In other words, a regression equation looks like this:
5 km time = intercept + (slope x predictor) + error
By the end of this lesson, you will be able to perform and fit a simple regression, a multiple regression and an interaction in R, and then compare them.
Set up. Install the packages below (only once in the console). Then load them each time you start R.
install.packages("tidyverse")
install.packages("dplyr")
install.packages("sjPlot")
install.packages("performance")
install.packages("GGally")
install.packages("patchwork") # needed for check model
install.packages("see") # needed for check model
library(tidyverse)
library(dplyr)
library(sjPlot)
library(performance)
library(GGally)
We simulate data for 200 recreational runners, so we
don’t need to download or import anything. (With real data, you would
import your own file with read.csv).
time_5k: Outcome -> 5 km race time
in minutes (lower = faster).weekly_km: Kilometres run per week.experience: Years of running experience.sex: Female or Male.coached: Does the runner have a coach? (Yes or
No).set.seed(2026) # This gives us the same "random" numbers / results every time
n <- 200 # number of runners
# Randomly give each runner a sex and coaching status
sex <- sample(c("Female", "Male"), n, replace = TRUE)
coached <- sample(c("Yes", "No"), n, replace = TRUE)
# Random years of experience between 0 and 15
experience <- round(runif(n, 0, 15))
# Weekly km: more experienced runners tend to run more, plus some random variation.
# pmax makes sure nobody runs less than 5 km a week.
weekly_km <- round(pmax(5, 15 + 1.5 * experience + rnorm(n, 0, 8)))
# 5 km time (mins), built from a starting time of 30 mins:
# - males are 2.5 mins faster
# - each year of experience makes a runner 0.25 mins faster
# - each km per week above 25 makes a runner 0.08 mins faster
# - coached runners are 0.8 mins faster, and get an extra 0.12 mins of benefit per km above 25 (this is what we will look for later)
# - rnorm(n, 0, 1.5) adds random variation, as no two runners are identical
time_5k <- 30 +
ifelse(sex == "Male", -2.5, 0) -
0.25 * experience -
0.08 * (weekly_km - 25) -
ifelse(coached == "Yes", 0.8 + 0.12 * (weekly_km - 25), 0) +
rnorm(n, 0, 1.5)
# Put everything together into one table called 'runners'
runners <- data.frame(time_5k = round(time_5k, 2),
weekly_km, experience, sex, coached) |> dplyr::as_tibble()
Before building any model, we explore the data. This is called exploratory data analysis (EDA). This helps us understand what the variables look like, spot anything unusual, and to get a feel for what the models are likely to find.
Always look at your data before modelling. First, the average 5 km time for coached and uncoached runners:
# This gives us our descriptive statistics of the 5 km time, so we can see a typical time and the spread of the times.
summary(runners$time_5k)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 18.15 24.33 26.52 26.38 28.73 32.56
runners |>
summarise(mean = mean(time_5k),
sd = sd(time_5k), .by = coached)
## # A tibble: 2 × 3
## coached mean sd
## <chr> <dbl> <dbl>
## 1 No 27.1 2.41
## 2 Yes 25.5 3.29
Boxplot. A box plot compares the 5 km times of the
two groups. We use ggplot() to set up and create all types
of plots and visualisations.
ggplot(runners, aes(x = coached, y = time_5k, fill = coached)) +
geom_boxplot()
Relationships between variables.
ggpairs() from the GGally package draws a grid
comparing every pair of the variables listed in
columns.
ggpairs(data = runners,
columns = c("time_5k", "weekly_km", "experience"))
Look at the correlations with time_5k. Runners who run
more kilometres, and runners with more experience, tend to have faster
times (lower). Hence, these correlations should be negative. Now look at
the correlation between weekly_km and
experience: they are related to each other too, as
experienced runners usually run more. This is important: when two
predictors overlap, adding both to a model can change how each one
looks. We will see this in Section 5.
Question: how is weekly distance associated with 5
km time? A simple regression has one predictor. We fit it with
lm() , and the model is written
outcome ~ predictor.
m1 <- lm(time_5k ~ weekly_km, data = runners)
# Estimates and p-values
summary(m1)
##
## Call:
## lm(formula = time_5k ~ weekly_km, data = runners)
##
## Residuals:
## Min 1Q Median 3Q Max
## -5.6722 -1.4733 0.1221 1.6694 4.9451
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 31.24360 0.39145 79.81 <2e-16 ***
## weekly_km -0.18409 0.01367 -13.47 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 2.127 on 198 degrees of freedom
## Multiple R-squared: 0.4781, Adjusted R-squared: 0.4755
## F-statistic: 181.4 on 1 and 198 DF, p-value: < 2.2e-16
# 95% CI
confint(m1)
## 2.5 % 97.5 %
## (Intercept) 30.4716486 32.0155580
## weekly_km -0.2110474 -0.1571374
# Check model/results in table
tab_model(m1)
| time 5 k | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | 31.24 | 30.47 – 32.02 | <0.001 |
| weekly km | -0.18 | -0.21 – -0.16 | <0.001 |
| Observations | 200 | ||
| R2 / R2 adjusted | 0.478 / 0.475 | ||
Results: Each extra kilometre run per week is linked to faster 5 km times, by about 0.18 minutes. Weekly distance alone explains about 48% of the difference between runners.
Always check the model’s assumptions. check_model()
draws the diagnostic plots, and each panel explains what a good plot
looks like.
check_model(m1)
Add more predictors with +. Each coefficient is then the
effect of that predictor holding the others constant.
This is how we perform Multiple regression.
m2 <- lm(time_5k ~ weekly_km + experience + sex + coached, data = runners)
summary(m2)
##
## Call:
## lm(formula = time_5k ~ weekly_km + experience + sex + coached,
## data = runners)
##
## Residuals:
## Min 1Q Median 3Q Max
## -3.6748 -1.1430 0.0010 0.9763 4.5760
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 33.11525 0.31835 104.023 < 2e-16 ***
## weekly_km -0.13615 0.01232 -11.049 < 2e-16 ***
## experience -0.21590 0.03345 -6.454 8.45e-10 ***
## sexMale -2.09038 0.22212 -9.411 < 2e-16 ***
## coachedYes -1.22325 0.22362 -5.470 1.37e-07 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 1.548 on 195 degrees of freedom
## Multiple R-squared: 0.7276, Adjusted R-squared: 0.7221
## F-statistic: 130.2 on 4 and 195 DF, p-value: < 2.2e-16
tab_model(m1, m2)
| time 5 k | time 5 k | |||||
|---|---|---|---|---|---|---|
| Predictors | Estimates | CI | p | Estimates | CI | p |
| (Intercept) | 31.24 | 30.47 – 32.02 | <0.001 | 33.12 | 32.49 – 33.74 | <0.001 |
| weekly km | -0.18 | -0.21 – -0.16 | <0.001 | -0.14 | -0.16 – -0.11 | <0.001 |
| experience | -0.22 | -0.28 – -0.15 | <0.001 | |||
| sex [Male] | -2.09 | -2.53 – -1.65 | <0.001 | |||
| coached [Yes] | -1.22 | -1.66 – -0.78 | <0.001 | |||
| Observations | 200 | 200 | ||||
| R2 / R2 adjusted | 0.478 / 0.475 | 0.728 / 0.722 | ||||
tab_model(m1, m2) puts the two models side by side so we
can see how the estimates changed.
Results: In the first model, each extra kilometre per week was linked to about 0.18 minutes faster. Once experience, sex, and coaching are included, this drops to about 0.14 minutes faster. It decreases as weekly kilometres were also picking up the effect of experience (experienced runner = run more and faster). Holding the other variables constant, males are about 2.09 minutes faster than females, and coached runners are about 1.22 minutes faster than uncoached runners.
What if coaching changes how much each weekly kilometre helps? When the effect of one predictor depends on another, that’s an interaction.
First, plot it. facet_wrap() gives each group its own
panel, and geom_smooth(method = 'lm') draws a regression
line in each one. If the slopes differ between the panels, there can be
an interaction.
ggplot(runners, aes(weekly_km, time_5k)) +
geom_point() +
geom_smooth(method = "lm") +
facet_wrap("coached")
To note, a * b fits both predictors and their
interaction:
m3 <- lm(time_5k ~ weekly_km * coached + experience + sex, data = runners)
summary(m3)
##
## Call:
## lm(formula = time_5k ~ weekly_km * coached + experience + sex,
## data = runners)
##
## Residuals:
## Min 1Q Median 3Q Max
## -3.8678 -0.9812 -0.0217 0.8996 4.1293
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 31.80423 0.33841 93.982 < 2e-16 ***
## weekly_km -0.08235 0.01334 -6.175 3.80e-09 ***
## coachedYes 2.25055 0.52647 4.275 3.00e-05 ***
## experience -0.21999 0.02986 -7.367 4.87e-12 ***
## sexMale -2.28079 0.20001 -11.403 < 2e-16 ***
## weekly_km:coachedYes -0.13009 0.01824 -7.130 1.93e-11 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 1.382 on 194 degrees of freedom
## Multiple R-squared: 0.7842, Adjusted R-squared: 0.7786
## F-statistic: 141 on 5 and 194 DF, p-value: < 2.2e-16
Results: For uncoached runners, each extra kilometre per week is linked to about 0.08 minutes faster. For coached runners it’s about 0.21 minutes faster (-0.08 plus -0.13). The p-value for the interaction is below .001, so the benefit of additional kilometres really is different for coached runners.
(In this model, the coached estimate of 2.25 is the difference at 0 km per week, which isn’t a realistic runner, so focus on the two slopes).
Which model is best? tab_model() with
show.aicc = TRUE shows each model’s AICc, and lower
is better.
tab_model(m1, m2, m3,
show.aicc = TRUE)
| time 5 k | time 5 k | time 5 k | |||||||
|---|---|---|---|---|---|---|---|---|---|
| Predictors | Estimates | CI | p | Estimates | CI | p | Estimates | CI | p |
| (Intercept) | 31.24 | 30.47 – 32.02 | <0.001 | 33.12 | 32.49 – 33.74 | <0.001 | 31.80 | 31.14 – 32.47 | <0.001 |
| weekly km | -0.18 | -0.21 – -0.16 | <0.001 | -0.14 | -0.16 – -0.11 | <0.001 | -0.08 | -0.11 – -0.06 | <0.001 |
| experience | -0.22 | -0.28 – -0.15 | <0.001 | -0.22 | -0.28 – -0.16 | <0.001 | |||
| sex [Male] | -2.09 | -2.53 – -1.65 | <0.001 | -2.28 | -2.68 – -1.89 | <0.001 | |||
| coached [Yes] | -1.22 | -1.66 – -0.78 | <0.001 | 2.25 | 1.21 – 3.29 | <0.001 | |||
| weekly km × coached [Yes] | -0.13 | -0.17 – -0.09 | <0.001 | ||||||
| Observations | 200 | 200 | 200 | ||||||
| R2 / R2 adjusted | 0.478 / 0.475 | 0.728 / 0.722 | 0.784 / 0.779 | ||||||
| AICc | 873.612 | 749.854 | 705.450 | ||||||
compare_performance() compares AIC, BIC, adjusted
R-squared and RMSE (lower AIC, BIC and RMSE are better, and a higher
R-squared is better), and test_performance() tests whether
the differences are meaningful:
compare_performance(m1, m2, m3)
## # Comparison of Model Performance Indices
##
## Name | Model | AIC (weights) | AICc (weights) | BIC (weights) | R2
## ---------------------------------------------------------------------
## m1 | lm | 873.5 (<.001) | 873.6 (<.001) | 883.4 (<.001) | 0.478
## m2 | lm | 749.4 (<.001) | 749.9 (<.001) | 769.2 (<.001) | 0.728
## m3 | lm | 704.9 (>.999) | 705.4 (>.999) | 728.0 (>.999) | 0.784
##
## Name | R2 (adj.) | RMSE | Sigma
## --------------------------------
## m1 | 0.475 | 2.117 | 2.127
## m2 | 0.722 | 1.529 | 1.548
## m3 | 0.779 | 1.361 | 1.382
test_performance(m1, m2, m3)
## Name | Model | BF | df | df_diff | Criterion | Chi2 | p
## ------------------------------------------------------------------
## m1 | lm | | 3 | | 867.49 | |
## m2 | lm | > 1000 | 6 | 3 | 737.42 | 130.07 | < .001
## m3 | lm | > 1000 | 7 | 1 | 690.87 | 46.55 | < .001
## Models were detected as nested (in terms of fixed parameters) and are compared in sequential order.
Model 3, the interaction model, is the best: it has the lowest AICc (705.45) and the highest adjusted R-squared (0.779), as ww built that interaction into the data.