Imagine you are an AFL analyst. You have the average number of disposals per game for 25 players from each of the 18 teams, along with the percentage of game time each player spends on the ground. We want to know how time on the ground is related to disposals.
There is a problem using ordinary linear regression in this situation. Players on the same team share a coach, a game plan, and a playing style. This way, players from the same team tend to be more similar to each other than to players from the other teams. Regression assumes every player is independent, so using it here would make our results look much better than they should be.
A linear mixed model fixes this. It has two parts:
By the end of this guide, you will be able to fit a mixed model, work out how much of the variation is between teams, and see how a mixed model differs from ordinary regression.
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("lme4")
install.packages("patchwork") # needed for check model
install.packages("see") # needed for check model
library(tidyverse)
library(dplyr)
library(sjPlot)
library(performance)
library(lme4)
We simulate data for the 450 players (25 from each
of the 18 teams), so no downloading or importing of data is needed.
(With real data you will need to import your own file wth
read.csv()).
team: a number identifying each team (1 to 18).time_on_ground: the percentage of game time the player
spends on the ground.disposals: Outcome -> the average
number of disposals (kicks and handballs) per game.The code below builds the data. You don’t need to memorise it. Each comment explains what each part does, the actual analysis will start after this chunk.
set.seed(123) # gives us the same random numbers/data every time
n_teams <- 18 # number of teams
players_per_team <- 25 # number of players per team
# Each team gets its own boost (or drop) in disposals due to its game plan.
# This is the random intercept
team_effect <- rnorm(n_teams, mean = 0, sd = 2.5)
# Build the data: 25 rows (players) for each team
# Disposals = 2 + 0.2 x time on ground + the team's effect + random variation.
# rnorm(n(), 0, 2.5) adds random variation, as no two players are identical.
# rep() repeats each team number 25 times, so every player gets a team
players <- data.frame(team = rep(1:n_teams, each = players_per_team)) |>
mutate(time_on_ground = round(runif(n(), 55, 95)),
disposals = round(2 + 0.2 * time_on_ground + team_effect[team] + rnorm(n(), 0, 2.5))) |>
as_tibble()
Inspect the data with str:
str(players)
## tibble [450 × 3] (S3: tbl_df/tbl/data.frame)
## $ team : int [1:450] 1 1 1 1 1 1 1 1 1 1 ...
## $ time_on_ground: num [1:450] 85 64 68 64 61 72 72 70 61 61 ...
## $ disposals : num [1:450] 15 13 19 13 9 13 16 14 11 12 ...
We can see that team is stored as a number, but we know
it’s a label for a group. For mixed modelling it needs to be a
factor, so we convert it with
mutate().
players <- players |>
mutate(team = as.factor(team))
# View it again to check the change to factors
str(players)
## tibble [450 × 3] (S3: tbl_df/tbl/data.frame)
## $ team : Factor w/ 18 levels "1","2","3","4",..: 1 1 1 1 1 1 1 1 1 1 ...
## $ time_on_ground: num [1:450] 85 64 68 64 61 72 72 70 61 61 ...
## $ disposals : num [1:450] 15 13 19 13 9 13 16 14 11 12 ...
Before building any model, we explore the data. First, a histogram of the outcome is made to see how disposals are spread:
ggplot(players, aes(x = disposals)) +
geom_histogram()
Next, a scatter plot of disposals against time on the ground, for all players together, as well as with a regression line:
ggplot(players, aes(x = time_on_ground, y = disposals)) +
geom_point() +
geom_smooth(method = "lm")
Finally, the same plot with facet_wrap(), which gives
each team its own panel:
ggplot(players, aes(x = time_on_ground, y = disposals)) +
geom_point() +
geom_smooth(method = "lm") +
facet_wrap("team")
From these visualisation plots, we can highlight that players who spend more time on the ground have more disposals. But if we look at the team panels, some teams sit higher on the plot than others. Each team also has its own average level of disposals, which is exactly what a random intercept captures.
Factors such as team game style and coaching can vary across teams and can therefore be a reason to why each team can be different to one another.
First, we fit an ordinary linear regression, treating all 450 players as independent. This is what we learnt in the previous regression guide. (Check out the ‘Learning to Performing Linear regression in R’ first if you haven’t!).
m1 <- lm(disposals ~ time_on_ground, data = players)
tab_model(m1)
| disposals | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | 1.57 | -0.58 – 3.71 | 0.152 |
| time on ground | 0.21 | 0.18 – 0.24 | <0.001 |
| Observations | 450 | ||
| R2 / R2 adjusted | 0.322 / 0.320 | ||
tab_model() shows the estimate for
time_on_ground, its confidence interval (CI), and
R-squared. The intercept is the predicted disposals at 0% time on the
ground, which isn’t a realistic player, so we focus on the slope.
Results: Each extra 1% of time on ground is linked to about 0.21 more disposals per game. Time on the ground alone explains about 32% of the differences between players.
The simplest mixed model has no predictors (NULL).
It only has a random intercept, written (1 | team), which
gives each team its own average. This model tells us how much of the
variation in disposals is due to the differences between
teams. We fit mixed models with lmer().
m2 <- lmer(disposals ~ 1 + (1 | team), data = players)
tab_model(m2)
| disposals | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | 17.28 | 16.06 – 18.49 | <0.001 |
| Random Effects | |||
| σ2 | 11.29 | ||
| τ00 team | 6.47 | ||
| ICC | 0.36 | ||
| N team | 18 | ||
| Observations | 450 | ||
| Marginal R2 / Conditional R2 | 0.000 / 0.364 | ||
tab_model() shows the average disposals for all players
(the intercept) and the variance split between teams and within teams.
It also gives the ICC (intraclass correlation
coefficient), which is the amount of total variation that is due to
differences between teams. A higher ICC implies players from the same
team are more similar to each other.
Results: The average number of disposals across players is about 17.28 per game. The ICC is 0.36, which means about 36% of the differences in disposals are between teams. This is not small, so players from the same team are related and a mixed model is needed.
Now we add time_on_ground to the mixed model. Each team
still gets its own average (the random intercept), but all teams share
the same fixed slope for the time on the ground.
m3 <- lmer(disposals ~ time_on_ground + (1 | team), data = players)
tab_model(m1, m3)
| disposals | disposals | |||||
|---|---|---|---|---|---|---|
| Predictors | Estimates | CI | p | Estimates | CI | p |
| (Intercept) | 1.57 | -0.58 – 3.71 | 0.152 | 2.32 | 0.39 – 4.26 | 0.019 |
| time on ground | 0.21 | 0.18 – 0.24 | <0.001 | 0.20 | 0.18 – 0.22 | <0.001 |
| Random Effects | ||||||
| σ2 | 6.18 | |||||
| τ00 | 5.98 team | |||||
| ICC | 0.49 | |||||
| N | 18 team | |||||
| Observations | 450 | 450 | ||||
| R2 / R2 adjusted | 0.322 / 0.320 | 0.294 / 0.641 | ||||
tab_model(m1, m3) puts the ordinary regression and the
mixed model side by side. Now we are able to compare the
time_on_ground estimate and its CI in the two models.
Results: In the mixed model, each extra 1% of time on the ground is linked to about 0.20 more disposals per game (95% CI: 0.18 to 0.22), compared with 0.21 in the ordinary regression. The estimate is similar, but the CI is narrower in the mixed model as it accounts for the differences between teams. The team variance is 5.98, which shows teams still differ in their average disposals after time on the ground is accounted for.
plot_model() with type = "re" plots each
team’s random intercept. Each dot shows how far that teams’ average is
above or below the overall average, so teams to the right have more
disposals than average.
plot_model(m3, type = "re")
As with regression, we then check the model’s assumptions.
check_model() draws the diagnostics plots, and each panel
explains what a good plot looks like.
check_model(m3)
Which mixed model is better, the one with no predictors
(m2) or the one with time on the ground (m3)?
We compare them with the AICc, where lower is
better.
tab_model(m2, m3,
show.aicc = TRUE)
| disposals | disposals | |||||
|---|---|---|---|---|---|---|
| Predictors | Estimates | CI | p | Estimates | CI | p |
| (Intercept) | 17.28 | 16.06 – 18.49 | <0.001 | 2.32 | 0.39 – 4.26 | 0.019 |
| time on ground | 0.20 | 0.18 – 0.22 | <0.001 | |||
| Random Effects | ||||||
| σ2 | 11.29 | 6.18 | ||||
| τ00 | 6.47 team | 5.98 team | ||||
| ICC | 0.36 | 0.49 | ||||
| N | 18 team | 18 team | ||||
| Observations | 450 | 450 | ||||
| Marginal R2 / Conditional R2 | 0.000 / 0.364 | 0.294 / 0.641 | ||||
| AICc | 2421.261 | 2167.624 | ||||
anova() also compares the models and tests whether the
second fits better than the first. A small p-value means the extra term
was worth adding.
anova(m2, m3)
## refitting model(s) with ML (instead of REML)
## Data: players
## Models:
## m2: disposals ~ 1 + (1 | team)
## m3: disposals ~ time_on_ground + (1 | team)
## npar AIC BIC logLik -2*log(L) Chisq Df Pr(>Chisq)
## m2 3 2422.1 2434.4 -1208.0 2416.1
## m3 4 2161.0 2177.5 -1076.5 2153.0 263.04 1 < 2.2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Results: The model with time on the ground
(m3) has a lower AICc than the model without it (2167.624
compared to 2421.261), and the p-value from anova() is
small, so adding time on the ground made the model better.
(1 | team) gives each team its own
average.anova().