Workshop: Linear Mixed Models in R

Author

Armando Teixeira-Pinto

Published

July 1, 2025

1 Quick Background

  • Linear Mixed Models (LMMs): LMMs extend linear regression to account for clustered (correlated) data, such as repeated measures on subjects. They include fixed effects (e.g., treatment, time) for population-level trends and random effects (e.g., random intercepts or slopes per subject) to model within-subject correlation and heterogeneity.
  • Key Components:
    • Fixed Effects: Coefficients for predictors like treatment (trt) and time.
    • Random Effects: Typically (1|id) for random intercepts (subject-specific baseline), or (time|id) for random slopes (individual time trends).
  • Assumptions: Linearity, normality of residuals/random effects, homoscedasticity; handles missing data under MAR.
  • Here: We’ll analyze strength gains over time by treatment group using the lme4 package. Outcome: strength (y); Predictor: time (days); Group: trt (1=increase reps, 2=increase weight); Clustering: subject id.

The dataset has 37 subjects (16 in trt=1, 21 in trt=2), with up to 7 measurements (assuming time points at days 0,2,4,6,8,10,12 for illustration, based on 7 columns). Some missings (denoted .).

Note: Run in RStudio. Save the provided stren.txt file in your working directory.

2 Install and Load Packages

We need lme4 for LMMs, lmerTest for p-values, ggplot2 for plots, and tidyr/dplyr for data reshaping.

# Load libraries
library(lme4)
library(lmerTest)
library(ggplot2)
library(tidyr)
library(dplyr)
library(gtsummary)
  • What this does: lme4::lmer() fits LMMs; lmerTest adds ANOVA-style tests.
  • Expected output: Libraries loaded without errors.

3 Load and Prepare the Dataset

Read the wide-format data and reshape to long format for analysis.

# Read wide data (columns: id, trt, y1, y2, y3, y4, y5, y6, y7)
stren_wide <- read.table("https://www.dropbox.com/s/f1n20of73hrispo/stren.txt?dl=1", 
                      header=F, na.strings = ".", 
                         col.names = c("id", "trt", "y1", "y2", 
                                       "y3", "y4", "y5", "y6", "y7"),
                         colClasses = c("integer", 
                                        "integer", 
                                        rep("numeric", 7)))

# Reshape to long: add time variable (assuming days 0,2,4,6,8,10,12)
stren_long <- stren_wide %>%
  pivot_longer(cols = y1:y7, names_to = "time_str", values_to = "strength") %>%
  mutate(time = case_when(
    time_str == "y1" ~ 0,
    time_str == "y2" ~ 2,
    time_str == "y3" ~ 4,
    time_str == "y4" ~ 6,
    time_str == "y5" ~ 8,
    time_str == "y6" ~ 10,
    time_str == "y7" ~ 12
  )) %>%
  select(id, trt, time, strength) %>%
  filter(!is.na(strength))  # Remove missings for now

# View summary
summary(stren_long)
       id             trt             time           strength    
 Min.   : 1.00   Min.   :1.000   Min.   : 0.000   Min.   :74.00  
 1st Qu.:10.00   1st Qu.:1.000   1st Qu.: 2.000   1st Qu.:79.00  
 Median :19.00   Median :2.000   Median : 6.000   Median :82.00  
 Mean   :18.88   Mean   :1.561   Mean   : 5.707   Mean   :81.51  
 3rd Qu.:28.50   3rd Qu.:2.000   3rd Qu.: 9.000   3rd Qu.:84.00  
 Max.   :37.00   Max.   :2.000   Max.   :12.000   Max.   :91.00  
table(stren_long$trt, stren_long$time)
   
     0  2  4  6  8 10 12
  1 16 15 16 15 15 13 15
  2 21 21 20 21 19 17 15
  • What this does: Reads wide data, pivots to long (one row per measurement), defines time in days (adjusted to 7 points), drops NAs.
  • Expected output: ~200-250 rows (some missings). Means: strength ~80-82 increasing over time; ~16 subjects trt=1, ~21 trt=2; measurements per time ~30-37.

4 Summarise and visualise the Data

Visualize trajectories with a spaghetti plot.

tbl_summary(stren_wide)
Characteristic N = 371
id 19 (10, 28)
trt
    1 16 (43%)
    2 21 (57%)
y1 80.00 (79.00, 83.00)
y2 81.0 (79.0, 84.0)
    Unknown 1
y3 81.0 (79.0, 84.0)
    Unknown 1
y4 82.0 (80.0, 84.0)
    Unknown 1
y5 82.0 (80.0, 84.0)
    Unknown 3
y6 82.5 (79.0, 83.0)
    Unknown 7
y7 82.0 (80.0, 85.0)
    Unknown 7
1 Median (Q1, Q3); n (%)
# Spaghetti plot
ggplot(stren_long, 
       aes(x = time, 
           y = strength, 
           color = factor(trt), 
           group = id)) +
  geom_line(alpha = 0.6) +
    stat_summary(fun=mean, 
                 geom="line",
                 show.legend = F, 
                 lwd=3, 
                 aes(group=trt)) +
    # geom_smooth(aes(group = trt), method = "lm", se = TRUE) + #linear trend 
  labs(title = "Strength Over Time by Treatment Group",
       x = "Time (Days)", y = "Strength", 
       color = "Treatment (1=Reps, 2=Weight)") +
  theme_minimal()

  • What this does: Plots individual trajectories (lines per id) and group trends (smoothed lines).
  • Expected output: Plot shows upward trends; trt=2 may increase faster (steeper slope). Some lines missing points due to dropouts.

5 Fit a Basic Random Intercept Model

Model strength as function of time and trt, with random intercept per subject.

# Basic LMM: fixed time + trt, random intercept
model_ri <- lmer(strength ~ time + trt + (1 | id), data = stren_long)



# Compute predicted values (including random effects)
stren_long$pred_ri <- predict(model_ri)

# Spaghetti plot of predicted values
ggplot(stren_long, aes(x = time, 
                       y = pred_ri, 
                       color = factor(trt), 
                       group = id)) +
  geom_line(alpha = 0.7) +
  geom_smooth(aes(group = trt), method = "lm", se = TRUE) + #linear trend 
  labs(title = "Predicted Strength Trajectories by Treatment Group",
       x = "Time (Days)", 
       y = "Predicted Strength", 
       color = "Treatment (1=Reps, 2=Weight)") +
  theme_minimal()

# Summary
summary(model_ri)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: strength ~ time + trt + (1 | id)
   Data: stren_long

REML criterion at convergence: 877.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-2.6996 -0.5657  0.0124  0.5911  3.0591 

Random effects:
 Groups   Name        Variance Std.Dev.
 id       (Intercept) 10.646   3.263   
 Residual              1.228   1.108   
Number of obs: 239, groups:  id, 37

Fixed effects:
             Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)  78.63195    1.79934  35.22012  43.700  < 2e-16 ***
time          0.13805    0.01834 201.13186   7.528  1.7e-12 ***
trt           1.38358    1.09248  34.96059   1.266    0.214    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
     (Intr) time  
time -0.062       
trt  -0.952  0.005
# In table format
tbl_regression(model_ri)
Characteristic Beta 95% CI p-value
time 0.14 0.10, 0.17 <0.001
trt 1.4 -0.83, 3.6 0.2
Abbreviation: CI = Confidence Interval
#Useful if there were categorical variables with >2 categories
#as it would give us the omnibus test (global p-value)
anova(model_ri)  # Type III tests with lmerTest
Type III Analysis of Variance Table with Satterthwaite's method
     Sum Sq Mean Sq NumDF   DenDF F value  Pr(>F)    
time 69.564  69.564     1 201.132 56.6667 1.7e-12 ***
trt   1.969   1.969     1  34.961  1.6039  0.2137    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
  • What this does: Fixed effects: intercept, time slope, trt effect; random: subject-specific deviation from intercept.
  • Expected output: Positive time coef ; trt coef ( positive for trt=2); random SD (between-subject variability).

6 Compare to Ordinary Linear Model (For Illustration)

Ignore clustering with lm() on long data.

# Ignore clustering: lm on long data
model_lm <- lm(strength ~ time + trt, data = stren_long)
summary(model_lm)

Call:
lm(formula = strength ~ time + trt, data = stren_long)

Residuals:
    Min      1Q  Median      3Q     Max 
-7.4748 -2.4141  0.0017  2.2142  9.0859 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept) 78.76442    0.78871  99.865  < 2e-16 ***
time         0.10984    0.05484   2.003  0.04633 *  
trt          1.35517    0.43638   3.105  0.00213 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 3.347 on 236 degrees of freedom
Multiple R-squared:  0.05326,   Adjusted R-squared:  0.04524 
F-statistic: 6.639 on 2 and 236 DF,  p-value: 0.001567
# Compare coefficients/SEs
coef(summary(model_lm))
              Estimate Std. Error   t value      Pr(>|t|)
(Intercept) 78.7644219 0.78870963 99.864918 4.609448e-195
time         0.1098411 0.05484071  2.002911  4.633064e-02
trt          1.3551741 0.43637883  3.105499  2.131917e-03
coef(summary(model_ri))
              Estimate Std. Error        df   t value     Pr(>|t|)
(Intercept) 78.6319495 1.79934434  35.22012 43.700335 2.821369e-32
time         0.1380451 0.01833821 201.13186  7.527731 1.699711e-12
trt          1.3835818 1.09247558  34.96059  1.266465 2.137201e-01
  • Expected: lm SEs smaller (underestimates uncertainty); biased p-values. LMM SEs larger, more conservative.

7 Add Treatment-Time Interaction

Allow different slopes by treatment.

# LMM with interaction
model_int <- lmer(strength ~ time * trt + (1 | id), data = stren_long)

# Compute predicted values (including random effects)
stren_long$pred_int <- predict(model_int)

# Spaghetti plot of predicted values
ggplot(stren_long, aes(x = time, 
                       y = pred_int, 
                       color = factor(trt), 
                       group = id)) +
  geom_line(alpha = 0.7) +
 geom_smooth(aes(group = trt), method = "lm", se = TRUE) + #linear trend 
  labs(title = "Predicted Strength Trajectories by Treatment Group",
       x = "Time (Days)", 
       y = "Predicted Strength", 
       color = "Treatment (1=Reps, 2=Weight)") +
  theme_minimal()

# Summary
summary(model_int)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: strength ~ time * trt + (1 | id)
   Data: stren_long

REML criterion at convergence: 881.2

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-2.5979 -0.5463  0.0335  0.6079  3.0065 

Random effects:
 Groups   Name        Variance Std.Dev.
 id       (Intercept) 10.64    3.263   
 Residual              1.23    1.109   
Number of obs: 239, groups:  id, 37

Fixed effects:
             Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)  78.90221    1.82853  37.55998  43.151   <2e-16 ***
time          0.09088    0.05975 200.07996   1.521    0.130    
trt           1.20998    1.11229  37.56560   1.088    0.284    
time:trt      0.03056    0.03684 200.11978   0.829    0.408    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
         (Intr) time   trt   
time     -0.188              
trt      -0.954  0.181       
time:trt  0.178 -0.952 -0.188
# In table format
tbl_regression(model_int)
Characteristic Beta 95% CI p-value
time 0.09 -0.03, 0.21 0.13
trt 1.2 -1.0, 3.5 0.3
time * trt 0.03 -0.04, 0.10 0.4
Abbreviation: CI = Confidence Interval
anova(model_int)
Type III Analysis of Variance Table with Satterthwaite's method
          Sum Sq Mean Sq NumDF   DenDF F value Pr(>F)
time     2.84453 2.84453     1 200.080  2.3135 0.1298
trt      1.45499 1.45499     1  37.566  1.1834 0.2836
time:trt 0.84596 0.84596     1 200.120  0.6880 0.4078
  • What this does: Tests if time slope differs by trt (interaction term).
  • Expected output: Interaction p<0.05? (trt=2 faster gains); main effects adjusted.

8 Include Random Slopes

Allow subject-specific time slopes.

# LMM with random slope for time
model_rs <- lmer(strength ~ time * trt + (time | id), data = stren_long)

# Summary
summary(model_rs)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: strength ~ time * trt + (time | id)
   Data: stren_long

REML criterion at convergence: 818.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-1.9432 -0.6199 -0.0612  0.5352  3.2512 

Random effects:
 Groups   Name        Variance Std.Dev. Corr 
 id       (Intercept) 9.95311  3.1549        
          time        0.03433  0.1853   -0.03
 Residual             0.66469  0.8153        
Number of obs: 239, groups:  id, 37

Fixed effects:
            Estimate Std. Error       df t value Pr(>|t|)    
(Intercept) 79.00099    1.74864 34.97864  45.179   <2e-16 ***
time         0.06501    0.11045 33.35236   0.589    0.560    
trt          1.13141    1.06373 34.98892   1.064    0.295    
time:trt     0.05198    0.06745 33.80024   0.771    0.446    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
         (Intr) time   trt   
time     -0.084              
trt      -0.954  0.081       
time:trt  0.081 -0.953 -0.085
# In table format
tbl_regression(model_int)
Characteristic Beta 95% CI p-value
time 0.09 -0.03, 0.21 0.13
trt 1.2 -1.0, 3.5 0.3
time * trt 0.03 -0.04, 0.10 0.4
Abbreviation: CI = Confidence Interval
anova(model_rs)
Type III Analysis of Variance Table with Satterthwaite's method
          Sum Sq Mean Sq NumDF  DenDF F value Pr(>F)
time     0.23026 0.23026     1 33.352  0.3464 0.5601
trt      0.75197 0.75197     1 34.989  1.1313 0.2948
time:trt 0.39478 0.39478     1 33.800  0.5939 0.4463
# Compute predicted values (including random effects)
stren_long$pred_rs <- predict(model_rs)

# Spaghetti plot of predicted values
ggplot(stren_long, aes(x = time, 
                       y = pred_rs, 
                       color = factor(trt), 
                       group = id)) +
  geom_line(alpha = 0.7) +
  geom_smooth(aes(group = trt), method = "lm", se = TRUE) + #linear trend 
  labs(title = "Predicted Strength Trajectories by Treatment Group",
       x = "Time (Days)", 
       y = "Predicted Strength", 
       color = "Treatment (1=Reps, 2=Weight)") +
  theme_minimal()

# Compare models (AIC)
AIC(model_ri, model_int, model_rs)
          df      AIC
model_ri   5 887.1339
model_int  6 893.2104
model_rs   8 834.5395
  • What this does: Random (time | id) adds subject-specific slopes; compare fit via AIC (lower better).
  • Expected output: Random slope SD; better fit (lower AIC) than intercept-only.

9 Model Diagnostics

Check assumptions.

# Residuals plot
plot(model_rs)  # Residuals vs fitted

# QQ plot for normality
qqnorm(resid(model_rs))
qqline(resid(model_rs))

# Histogram of residuals
hist(resid(model_rs), main = "Residuals Distribution")

  • What this does: Plots check linearity/homoscedasticity; QQ for normality.
  • Expected output: Points scatter around line (no patterns); QQ straight; residuals ~normal.