Mixed-Effects Models

Marc Los Huertos

EA030 · Compiled 2026-10-24

Motivation Random Intercepts Random Slopes Nested Effects Crossed Effects Model Selection Diagnostics Summary

By the end of this module you will be able to
  1. Identify when non-independence (nested or repeated observations) requires a mixed model.
  2. Distinguish between fixed effects (what you want to estimate) and random effects (what you want to account for).
  3. Fit linear mixed models with lme4::lmer() and interpret the output.
  4. Decide whether to treat a grouping variable as fixed or random.
  5. Check model assumptions and compare models with AIC.

What this module covers. Environmental field data are almost never truly independent. Measurements from the same stream site share a microclimate. Subplots within a watershed share soil parent material. Repeated visits to the same camera-trap station share detection behaviour. Ignoring this structure inflates Type I error — you find patterns that aren’t real. Mixed-effects models handle grouped and hierarchical data correctly by partitioning variance between levels of organisation. This module builds the concept from the ground up, works through random intercepts, random slopes, nested designs, and crossed designs, and gives you the tools to choose the right random-effects structure for your study.


Why Grouping Structure Matters

The pseudoreplication problem

Imagine you want to know whether nitrogen deposition increases stream algal biomass. You sample four streams — two receiving high deposition, two receiving low — and at each stream you collect ten samples per visit over six visits. That looks like \(4 \times 10 \times 6 = 240\) data points.

But those 240 observations are not 240 independent pieces of information. Samples from the same stream on the same day share water chemistry, discharge, and temperature. Visits to the same stream over time share the same substrate and riparian canopy. Treating all 240 rows as independent (i.e., plugging them into lm()) gives you wildly over-confident standard errors and p-values — this is pseudoreplication.

📌 The mixed-effects solution

Mixed-effects models include both:

  • Fixed effects — the predictors you care about and want to make inferences on (nitrogen deposition, temperature, treatment)
  • Random effects — grouping factors that introduce non-independence (stream site, watershed, plot, individual, year)

Random effects absorb the variance attributable to groups, leaving the fixed-effect estimates clean and the standard errors honest.

Fixed versus random: how to decide

Fixed effects are levels you chose deliberately; random effects are a sample from a larger population of possible levels.
Effect type Examples Question Inference scope
Fixed Nitrogen treatment (high vs. low) Does N deposition change algal biomass? Only these two levels
Fixed Temperature (°C), canopy cover (%) Does temperature predict DO? Continuous gradient
Random Stream site ID (sampled from many possible sites) How much does biomass vary among sites? Population of all streams
Random Sampling year (drawn from many possible years) How much does biomass vary among years? Population of all years
Random Individual plant or animal (sub-sampled repeatedly) How much does the individual vary? Population of all individuals

✅ The practical rule

If you have fewer than ~5 levels and they exhaust all possibilities (e.g., two treatment groups, three land-use types), treat the factor as fixed. If you have many levels that represent a random draw from a larger population (sites, years, individuals, blocks), treat it as random.

Simulate a motivating dataset

We will use a single running example throughout this module: dissolved oxygen (DO, mg/L) measured at multiple stream monitoring stations as a function of water temperature (°C). Temperature is the fixed predictor; station is the grouping variable whose structure changes across sections.

set.seed(42)

n_stations <- 8
n_obs      <- 12    # observations per station

# Station-level intercept offsets (random intercepts)
station_intercepts <- rnorm(n_stations, mean = 0, sd = 1.8)
# Station-level slope offsets (random slopes, used later)
station_slopes     <- rnorm(n_stations, mean = 0, sd = 0.07)

stations <- paste0("S", 1:n_stations)

do_data <- lapply(seq_along(stations), function(i) {
  temp <- runif(n_obs, 10, 24)
  do   <- (12.0 + station_intercepts[i]) +
          (-0.35 + station_slopes[i]) * temp +
          rnorm(n_obs, 0, 0.5)
  data.frame(
    station = stations[i],
    temp_C  = temp,
    DO_mgl  = do,
    slope_offset = station_slopes[i]   # stored for illustration
  )
}) |>
  bind_rows() |>
  mutate(station = factor(station))

head(do_data)
##   station   temp_C    DO_mgl slope_offset
## 1      S1 15.43352 11.160632    0.1412897
## 2      S1 19.59238 10.985931    0.1412897
## 3      S1 10.05528 13.316682    0.1412897
## 4      S1 21.66083  9.731652    0.1412897
## 5      S1 10.10268 12.230557    0.1412897
## 6      S1 12.90723 10.892272    0.1412897
ggplot(do_data, aes(temp_C, DO_mgl, colour = station, group = station)) +
  geom_point(alpha = 0.5, size = 1.8) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9, alpha = 0.85) +
  scale_colour_brewer(palette = "Dark2", name = "Station") +
  labs(x = "Water temperature (°C)", y = "Dissolved oxygen (mg/L)",
       title = "Station-specific DO–temperature relationships",
       subtitle = "Parallel slopes suggest random intercepts are sufficient; some slope variation is also visible") +
  theme_minimal(base_size = 13) +
  theme(
    legend.position = "right",
    plot.title    = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
    plot.subtitle = element_text(colour = "#7a7a72", size = 10)
  )
Dissolved oxygen versus water temperature at eight stream monitoring stations. Each line is a separate OLS fit for one station. The negative temperature–DO relationship is consistent across sites, but the intercept (baseline DO level) varies markedly — some stations are consistently higher or lower than others. This station-level variation is exactly what random intercepts will absorb.

Dissolved oxygen versus water temperature at eight stream monitoring stations. Each line is a separate OLS fit for one station. The negative temperature–DO relationship is consistent across sites, but the intercept (baseline DO level) varies markedly — some stations are consistently higher or lower than others. This station-level variation is exactly what random intercepts will absorb.


Part 1 — Random Intercepts

The model

A random-intercept model lets each group have its own baseline level while sharing a common slope:

\[\text{DO}_{ij} = \underbrace{(\beta_0 + b_{0j})}_{\text{group intercept}} + \beta_1 \cdot \text{Temp}_{ij} + \varepsilon_{ij}\]

where \(b_{0j} \sim \mathcal{N}(0,\, \sigma^2_{\text{station}})\) is the random deviation of station \(j\) from the population mean intercept, and \(\varepsilon_{ij} \sim \mathcal{N}(0,\, \sigma^2)\) is the residual. The model estimates:

lme4 formula syntax

# Random intercepts only:
lmer(response ~ fixed_predictor + (1 | group), data = df)
#                                  ^^^^^^^^^
#                           1 = random intercept for each level of group

# Random intercepts + random slopes:
lmer(response ~ fixed_predictor + (1 + fixed_predictor | group), data = df)

# Nested: plots within sites
lmer(response ~ treatment + (1 | site / plot), data = df)

# Crossed: site and year as separate random effects
lmer(response ~ treatment + (1 | site) + (1 | year), data = df)

Reading lme4 random-effects notation

The expression inside ( | ) has two parts separated by |:
- Left of |: what varies by group — 1 = intercept; 1 + x = intercept and slope of x
- Right of |: the grouping variable — site, site/plot, etc.

(1 | site) → each site gets its own intercept, drawn from a normal distribution
(1 + temp | site) → each site gets its own intercept and its own temperature slope
(0 + temp | site) → slopes only, no random intercept (rare)

Fit the random-intercept model

library(lme4)
library(lmerTest)   # Satterthwaite df and p-values

mod_ri <- lmer(DO_mgl ~ temp_C + (1 | station), data = do_data)
summary(mod_ri)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula: DO_mgl ~ temp_C + (1 | station)
##    Data: do_data
## 
## REML criterion at convergence: 213.1
## 
## Scaled residuals: 
##      Min       1Q   Median       3Q      Max 
## -2.37157 -0.60586 -0.08916  0.47989  2.96671 
## 
## Random effects:
##  Groups   Name        Variance Std.Dev.
##  station  (Intercept) 5.6399   2.3748  
##  Residual             0.3357   0.5794  
## Number of obs: 96, groups:  station, 8
## 
## Fixed effects:
##             Estimate Std. Error       df t value Pr(>|t|)    
## (Intercept) 12.73724    0.88293  8.46613   14.43 2.97e-07 ***
## temp_C      -0.31218    0.01587 87.20038  -19.67  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Correlation of Fixed Effects:
##        (Intr)
## temp_C -0.302

Parsing the summary output

The summary() has two major sections:

Random effects table — variance components:

VarCorr(mod_ri)
##  Groups   Name        Std.Dev.
##  station  (Intercept) 2.37484 
##  Residual             0.57938

Intraclass correlation coefficient (ICC): the proportion of total variance attributable to stations.

vc  <- as.data.frame(VarCorr(mod_ri))
icc <- vc$vcov[1] / sum(vc$vcov)
cat("ICC =", round(icc, 3),
    "— stations explain", round(icc * 100, 1),
    "% of total variance\n")
## ICC = 0.944 — stations explain 94.4 % of total variance

📌 Why the ICC matters

An ICC of 0.40 means 40% of the variation in DO is attributable to which station you sampled — independent of temperature. This tells you the grouping structure is important: ignoring it would mean your standard error for the temperature slope is based on 96 pseudo-independent observations when really you only have 8 independent sites. The ICC also tells you how much extra precision you gain by sampling more stations versus more observations per station.

Fixed effects table:

fixef(mod_ri)
## (Intercept)      temp_C 
##  12.7372362  -0.3121752
confint(mod_ri, method = "Wald")
##                  2.5 %     97.5 %
## .sig01              NA         NA
## .sigma              NA         NA
## (Intercept) 11.0067319 14.4677404
## temp_C      -0.3432747 -0.2810756

The fixed-effect temperature slope (temp_C) is the population-average effect — how DO changes with temperature after accounting for station-to-station baseline differences.

Visualising random intercepts

ri_df <- ranef(mod_ri, condVar = TRUE)$station |>
  tibble::rownames_to_column("station") |>
  rename(intercept = `(Intercept)`) |>
  mutate(
    se     = sqrt(attr(ranef(mod_ri, condVar = TRUE)$station, "postVar")[1, 1, ]),
    lwr    = intercept - 1.96 * se,
    upr    = intercept + 1.96 * se,
    station = reorder(station, intercept)
  )

ggplot(ri_df, aes(intercept, station)) +
  geom_vline(xintercept = 0, linetype = "dashed", colour = "#c8832a",
             linewidth = 0.8) +
  geom_errorbarh(aes(xmin = lwr, xmax = upr),
                 height = 0.35, colour = "#1e3a2f", linewidth = 0.9) +
  geom_point(size = 3.5, colour = "#4a7c5e") +
  labs(x = "Random intercept estimate (mg/L deviation from mean)",
       y = NULL,
       title = "Station-level random intercepts (BLUPs)",
       subtitle = "Dashed line = population mean; whiskers = ±1.96 SE") +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
    plot.subtitle = element_text(colour = "#7a7a72", size = 10)
  )
Station-level random intercept estimates (BLUPs) with 95% conditional intervals. Stations above zero have higher baseline DO than average; those below are consistently oxygen-depleted. Stations whose intervals exclude zero differ meaningfully from the population mean.

Station-level random intercept estimates (BLUPs) with 95% conditional intervals. Stations above zero have higher baseline DO than average; those below are consistently oxygen-depleted. Stations whose intervals exclude zero differ meaningfully from the population mean.

Predicted vs. observed

do_data$pred_ri <- fitted(mod_ri)

ggplot(do_data, aes(pred_ri, DO_mgl, colour = station)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed",
              colour = "#c8832a", linewidth = 0.8) +
  geom_point(size = 2, alpha = 0.75) +
  scale_colour_brewer(palette = "Dark2", name = "Station") +
  labs(x = "Fitted DO (mg/L)", y = "Observed DO (mg/L)",
       title = "Fitted vs. observed — random-intercept model") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(family = "serif", face = "bold",
                                  colour = "#1e3a2f"))
Predicted DO from the random-intercept model versus observed values. Points near the diagonal indicate good fit. Colour identifies stations — the model captures the systematic vertical spread among stations through the random intercepts.

Predicted DO from the random-intercept model versus observed values. Points near the diagonal indicate good fit. Colour identifies stations — the model captures the systematic vertical spread among stations through the random intercepts.


Part 2 — Random Slopes

The random-intercept model assumes the temperature–DO slope is identical at every station — only the baselines differ. But the temperature sensitivity of DO may vary: cold headwater stations may show steeper drops than warmer valley stations where biological oxygen demand already dominates.

A random-slope model lets both the intercept and the slope vary by station:

\[\text{DO}_{ij} = (\beta_0 + b_{0j}) + (\beta_1 + b_{1j}) \cdot \text{Temp}_{ij} + \varepsilon_{ij}\]

where \(\begin{pmatrix} b_{0j} \\ b_{1j} \end{pmatrix} \sim \mathcal{N}\!\left(\mathbf{0},\, \boldsymbol{\Sigma}\right)\) and \(\boldsymbol{\Sigma}\) is a 2×2 covariance matrix capturing how intercept and slope variation are related across stations.

Fit the random-slopes model

mod_rs <- lmer(DO_mgl ~ temp_C + (1 + temp_C | station), data = do_data)
summary(mod_rs)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula: DO_mgl ~ temp_C + (1 + temp_C | station)
##    Data: do_data
## 
## REML criterion at convergence: 191.6
## 
## Scaled residuals: 
##     Min      1Q  Median      3Q     Max 
## -2.6332 -0.6325 -0.0542  0.5590  3.2391 
## 
## Random effects:
##  Groups   Name        Variance Std.Dev. Corr
##  station  (Intercept) 2.889032 1.69972      
##           temp_C      0.007795 0.08829  0.05
##  Residual             0.236182 0.48599      
## Number of obs: 96, groups:  station, 8
## 
## Fixed effects:
##             Estimate Std. Error       df t value Pr(>|t|)    
## (Intercept) 13.09829    0.64599  7.18317  20.276 1.32e-07 ***
## temp_C      -0.32927    0.03407  6.67281  -9.665 3.64e-05 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Correlation of Fixed Effects:
##        (Intr)
## temp_C -0.104

Variance-covariance of the random effects

VarCorr(mod_rs)
##  Groups   Name        Std.Dev. Corr 
##  station  (Intercept) 1.699715      
##           temp_C      0.088288 0.046
##  Residual             0.485986

The correlation between random intercepts and random slopes matters ecologically: a negative correlation would mean stations with higher baseline DO tend to have steeper temperature sensitivity — perhaps because they start from a higher “ceiling” and have further to fall.

⚠️ Random-slopes models are data hungry

Estimating a full random-slope model requires enough observations per group to estimate both an intercept and a slope reliably. A rough minimum is ≥ 10–15 observations per group and ≥ 5–6 groups. With sparse data, the variance-covariance matrix \(\boldsymbol{\Sigma}\) may not converge — you will see warnings like boundary (singular) fit. In that case, simplify: use (1 | group) or fix the correlation to zero with (1 + x || group).

Visualising random slopes

pred_grid_rs <- do_data |>
  group_by(station) |>
  summarise(temp_range = list(seq(min(temp_C), max(temp_C), length.out = 40)),
            .groups = "drop") |>
  tidyr::unnest(cols = temp_range) |>
  rename(temp_C = temp_range)

pred_grid_rs$DO_pred <- predict(mod_rs, newdata = pred_grid_rs)

ggplot() +
  geom_point(data = do_data, aes(temp_C, DO_mgl, colour = station),
             alpha = 0.4, size = 1.6) +
  geom_line(data = pred_grid_rs, aes(temp_C, DO_pred, colour = station),
            linewidth = 1) +
  scale_colour_brewer(palette = "Dark2", name = "Station") +
  labs(x = "Water temperature (°C)", y = "Dissolved oxygen (mg/L)",
       title = "Random-slopes model: station-specific DO–temperature lines") +
  theme_minimal(base_size = 13) +
  theme(
    legend.position = "right",
    plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f")
  )
Model-predicted DO–temperature lines from the random-slopes model, one per station. Both the elevation (intercept) and the steepness (slope) of the relationship vary by station. Compare with the earlier spaghetti plot of raw data — the model-predicted lines are shrunk toward the population-average slope, reducing overfitting.

Model-predicted DO–temperature lines from the random-slopes model, one per station. Both the elevation (intercept) and the steepness (slope) of the relationship vary by station. Compare with the earlier spaghetti plot of raw data — the model-predicted lines are shrunk toward the population-average slope, reducing overfitting.

Shrinkage: the key advantage of random effects

Unlike fitting a separate OLS line per station, the random-effects estimates are shrunk toward the population mean — stations with fewer data points or higher residual variance are pulled closer to the global average. This is not a bug; it is regularisation that reduces overfitting and improves out-of-sample predictions.

# OLS slope per station for comparison
ols_slopes <- do_data |>
  group_by(station) |>
  summarise(
    ols_slope  = coef(lm(DO_mgl ~ temp_C))[["temp_C"]],
    n          = n(),
    .groups    = "drop"
  )

blup_slopes <- ranef(mod_rs)$station |>
  tibble::rownames_to_column("station") |>
  rename(ranef_slope = temp_C) |>
  mutate(blup_slope = fixef(mod_rs)["temp_C"] + ranef_slope)

slope_compare <- left_join(ols_slopes, blup_slopes, by = "station")
pop_slope     <- fixef(mod_rs)["temp_C"]

ggplot(slope_compare, aes(ols_slope, blup_slope, label = station)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dotted",
              colour = "#7a7a72", linewidth = 0.7) +
  geom_hline(yintercept = pop_slope, linetype = "dashed",
             colour = "#c8832a", linewidth = 0.8) +
  geom_vline(xintercept = pop_slope, linetype = "dashed",
             colour = "#c8832a", linewidth = 0.8) +
  geom_point(aes(size = n), colour = "#4a7c5e", alpha = 0.8) +
  ggrepel::geom_text_repel(size = 3.2, colour = "#1e3a2f") +
  scale_size_continuous(range = c(3, 8), name = "n obs") +
  labs(x = "OLS slope (per station)", y = "BLUP slope (mixed model)",
       title = "Shrinkage: BLUPs pulled toward population mean",
       subtitle = "Orange dashed lines = population-average slope") +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
    plot.subtitle = element_text(colour = "#7a7a72", size = 10)
  )
Shrinkage in action. Each point compares a station's OLS slope (x-axis, estimated independently) with the BLUP slope from the mixed model (y-axis). The dashed orange line is the population-average slope. BLUPs are pulled toward this line relative to OLS — more so for stations with fewer or noisier data.

Shrinkage in action. Each point compares a station’s OLS slope (x-axis, estimated independently) with the BLUP slope from the mixed model (y-axis). The dashed orange line is the population-average slope. BLUPs are pulled toward this line relative to OLS — more so for stations with fewer or noisier data.

✅ Install ggrepel for non-overlapping labels

The plot above uses ggrepel::geom_text_repel(). Install it once with install.packages("ggrepel").


Part 3 — Nested Random Effects

Environmental context: hierarchical soil carbon survey

Many environmental sampling designs are nested: subplots are nested within plots, plots within watersheds, watersheds within regions. Observations within the same lower-level unit are more similar to each other than to observations in a different unit — at every level of the hierarchy.

We simulate a soil organic carbon (SOC, %) dataset with this structure:

The fixed predictor is mean annual precipitation (MAP, mm) — a continuous gradient that drives SOC independently of the hierarchical grouping.

set.seed(7)

regions    <- c("Coast", "Sierra", "Mojave")
n_ws       <- 3     # watersheds per region
n_plots    <- 4     # plots per watershed
n_subplots <- 5     # subplots per plot

# Region baselines (fixed-like, but treated as random here for generality)
region_mu  <- c(Coast = 4.2, Sierra = 3.5, Mojave = 1.8)

soil <- expand.grid(
  subplot   = 1:n_subplots,
  plot      = 1:n_plots,
  watershed = 1:n_ws,
  region    = regions,
  stringsAsFactors = FALSE
) |>
  mutate(
    # Unique identifiers at each level
    region_id    = region,
    watershed_id = paste(region, watershed, sep = "_W"),
    plot_id      = paste(region, watershed, plot, sep = "_"),

    # MAP varies by region with watershed-level noise
    MAP = case_when(
      region == "Coast"  ~ rnorm(n(), 900, 120),
      region == "Sierra" ~ rnorm(n(), 650,  90),
      region == "Mojave" ~ rnorm(n(),  200,  50)
    ),

    # Hierarchical random effects
    re_region    = rep(rnorm(length(regions), 0, 0.5),
                       each = n_ws * n_plots * n_subplots),
    re_watershed = rnorm(n(), 0, 0.4),
    re_plot      = rnorm(n(), 0, 0.3),

    # SOC: fixed MAP effect + hierarchical noise
    SOC = region_mu[region] + 0.003 * (MAP - 600) +
          re_watershed + re_plot + rnorm(n(), 0, 0.25)
  )

glimpse(soil |> select(region_id, watershed_id, plot_id, MAP, SOC))
## Rows: 180
## Columns: 5
## $ region_id    <chr> "Coast", "Coast", "Coast", "Coast", "Coast", "Coast", "Co…
## $ watershed_id <chr> "Coast_W1", "Coast_W1", "Coast_W1", "Coast_W1", "Coast_W1…
## $ plot_id      <chr> "Coast_1_1", "Coast_1_1", "Coast_1_1", "Coast_1_1", "Coas…
## $ MAP          <dbl> 1174.4697, 756.3874, 816.6849, 850.5248, 783.5192, 786.32…
## $ SOC          <dbl> 6.327006, 4.757652, 5.530495, 4.347369, 4.121345, 4.29719…

Visualise the hierarchical structure

ggplot(soil, aes(x = region_id, y = SOC,
                 colour = watershed_id, group = watershed_id)) +
  geom_jitter(width = 0.18, alpha = 0.4, size = 1.5) +
  stat_summary(fun = mean, geom = "point", size = 3.5, shape = 18) +
  stat_summary(fun = mean, geom = "line", linewidth = 0.6, alpha = 0.6) +
  scale_colour_viridis_d(option = "D", guide = "none") +
  labs(x = "Region", y = "Soil organic carbon (%)",
       title = "SOC across regions and watersheds",
       subtitle = "Diamonds = watershed means; small points = individual subplots") +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
    plot.subtitle = element_text(colour = "#7a7a72", size = 10)
  )
Soil organic carbon (SOC) distributions across regions, coloured by watershed. Within each region the within-watershed consistency is visible (same colour cluster together), illustrating the nested structure: subplots within plots within watersheds within regions all share variance.

Soil organic carbon (SOC) distributions across regions, coloured by watershed. Within each region the within-watershed consistency is visible (same colour cluster together), illustrating the nested structure: subplots within plots within watersheds within regions all share variance.

Fit the nested model

In lme4, nesting is expressed as (1 | region/watershed/plot), which expands to separate random intercepts at each level of the hierarchy. Alternatively, spell it out explicitly with (1 | region) + (1 | watershed_id) + (1 | plot_id) — the explicit form is clearer and avoids ambiguity when level labels are not already unique.

mod_nested <- lmer(
  SOC ~ MAP + (1 | region_id) + (1 | watershed_id) + (1 | plot_id),
  data = soil
)
summary(mod_nested)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula: SOC ~ MAP + (1 | region_id) + (1 | watershed_id) + (1 | plot_id)
##    Data: soil
## 
## REML criterion at convergence: 327.7
## 
## Scaled residuals: 
##      Min       1Q   Median       3Q      Max 
## -3.00335 -0.69143 -0.04039  0.62216  2.31884 
## 
## Random effects:
##  Groups       Name        Variance Std.Dev.
##  plot_id      (Intercept) 0.00000  0.0000  
##  watershed_id (Intercept) 0.01034  0.1017  
##  region_id    (Intercept) 1.66835  1.2916  
##  Residual                 0.30445  0.5518  
## Number of obs: 180, groups:  plot_id, 36; watershed_id, 9; region_id, 3
## 
## Fixed effects:
##              Estimate Std. Error        df t value Pr(>|t|)    
## (Intercept) 1.178e+00  7.894e-01 2.321e+00   1.493    0.257    
## MAP         3.278e-03  4.293e-04 1.721e+02   7.635 1.49e-12 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Correlation of Fixed Effects:
##     (Intr)
## MAP -0.321
## optimizer (nloptwrap) convergence code: 0 (OK)
## boundary (singular) fit: see help('isSingular')

Decomposing variance across levels

vc_nested <- as.data.frame(VarCorr(mod_nested)) |>
  mutate(
    level    = factor(grp, levels = c("region_id","watershed_id","plot_id","Residual")),
    pct      = 100 * vcov / sum(vcov),
    label    = paste0(round(pct, 1), "%")
  )

ggplot(vc_nested, aes(level, vcov, fill = level)) +
  geom_col(width = 0.6, alpha = 0.9) +
  geom_text(aes(label = label), vjust = -0.4, size = 3.5,
            colour = "#1e3a2f", family = "mono") +
  scale_fill_manual(
    values = c("#1e3a2f","#4a7c5e","#8aad96","#c8832a"),
    guide  = "none"
  ) +
  scale_x_discrete(labels = c("Region","Watershed","Plot","Residual")) +
  labs(x = NULL, y = "Variance component",
       title = "SOC variance partitioned across hierarchical levels",
       subtitle = "Percentages = proportion of total variance") +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
    plot.subtitle = element_text(colour = "#7a7a72", size = 10)
  )
Variance components from the nested model partitioned by hierarchical level. Each bar represents the estimated variance attributable to that grouping level. Region-level variance is largest — SOC differs most between the Coast, Sierra, and Mojave — followed by watershed, plot, and residual (subplot) variation.

Variance components from the nested model partitioned by hierarchical level. Each bar represents the estimated variance attributable to that grouping level. Region-level variance is largest — SOC differs most between the Coast, Sierra, and Mojave — followed by watershed, plot, and residual (subplot) variation.

📌 What the variance components tell you about sampling design

If most variance is at the region level, adding more subplots per watershed buys little precision — you need more regions. If most variance is at the subplot level, you could reduce the number of subplots per plot and add more plots for the same total sampling effort. This is the basis of optimal allocation in hierarchical sampling design.

The shorthand / versus explicit nesting

# These two are equivalent when level labels are already unique:
lmer(SOC ~ MAP + (1 | region_id / watershed_id / plot_id), data = soil)
lmer(SOC ~ MAP + (1 | region_id) + (1 | watershed_id) + (1 | plot_id), data = soil)

# The shorthand is fine ONLY when lower-level labels are unique across groups.
# If plot_id is coded 1–4 within EACH watershed (i.e., not globally unique),
# you MUST use the shorthand or create globally unique IDs yourself:
soil$plot_id_global <- paste(soil$watershed_id, soil$plot, sep = "_")

Part 4 — Crossed Random Effects

Environmental context: pollinator abundance across sites and years

In a nested design, every lower-level unit belongs to exactly one higher-level unit. In a crossed design, every level of one grouping factor appears in combination with every (or many) level of the other. The classic environmental example is a multi-site, multi-year monitoring programme: each site is visited in each year, and both site and year introduce non-independence.

We simulate bumble bee abundance (\(\log_e\)-scale) across 10 sites monitored over 6 years, with a continuous fixed predictor (mean flower density) and separate random effects for site and year.

set.seed(19)

n_sites <- 10
n_years <-  6

sites <- paste0("Site", 1:n_sites)
years <- 2019:2024

re_site <- rnorm(n_sites, 0, 0.7)   # site-level random intercepts
re_year <- rnorm(n_years, 0, 0.4)   # year-level random intercepts

pollinators <- expand.grid(
  site = sites,
  year = years,
  stringsAsFactors = FALSE
) |>
  mutate(
    site_idx      = as.integer(factor(site)),
    year_idx      = as.integer(factor(year)),
    flower_density = runif(n(), 5, 80),        # flowers / m²
    log_bees      = 1.8 + 0.018 * flower_density +
                    re_site[site_idx] +
                    re_year[year_idx] +
                    rnorm(n(), 0, 0.35),
    site = factor(site),
    year = factor(year)
  )

glimpse(pollinators)
## Rows: 60
## Columns: 6
## $ site           <fct> Site1, Site2, Site3, Site4, Site5, Site6, Site7, Site8,…
## $ year           <fct> 2019, 2019, 2019, 2019, 2019, 2019, 2019, 2019, 2019, 2…
## $ site_idx       <int> 1, 3, 4, 5, 6, 7, 8, 9, 10, 2, 1, 3, 4, 5, 6, 7, 8, 9, …
## $ year_idx       <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2…
## $ flower_density <dbl> 26.261733, 64.889237, 74.016561, 64.101764, 56.972326, …
## $ log_bees       <dbl> 1.849983, 3.224102, 3.415134, 3.910173, 3.463586, 3.404…

Visualise crossed structure

pollinators |>
  group_by(site, year) |>
  summarise(mean_log_bees = mean(log_bees), .groups = "drop") |>
  ggplot(aes(site, year, fill = mean_log_bees)) +
  geom_tile(colour = "white", linewidth = 0.5) +
  scale_fill_gradient2(
    low = "#1e3a2f", mid = "#8aad96", high = "#c8832a",
    midpoint = median(pollinators$log_bees),
    name = "log(bees)"
  ) +
  labs(x = "Site", y = "Year",
       title = "Bumble-bee abundance: site × year tile plot") +
  theme_minimal(base_size = 12) +
  theme(
    axis.text.x   = element_text(angle = 35, hjust = 1),
    plot.title    = element_text(family = "serif", face = "bold", colour = "#1e3a2f")
  )
Mean log bumble-bee abundance at each site–year combination. Missing cells (if any) would indicate an incomplete design. Both site-to-site differences (columns) and year-to-year differences (rows) are visible — the crossed random-effects model will estimate a variance component for each.

Mean log bumble-bee abundance at each site–year combination. Missing cells (if any) would indicate an incomplete design. Both site-to-site differences (columns) and year-to-year differences (rows) are visible — the crossed random-effects model will estimate a variance component for each.

Fit the crossed random-effects model

Crossed random effects are simply listed as separate terms — the key difference from nesting:

mod_crossed <- lmer(
  log_bees ~ flower_density + (1 | site) + (1 | year),
  data = pollinators
)
summary(mod_crossed)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula: log_bees ~ flower_density + (1 | site) + (1 | year)
##    Data: pollinators
## 
## REML criterion at convergence: 90.2
## 
## Scaled residuals: 
##      Min       1Q   Median       3Q      Max 
## -1.67634 -0.74982 -0.06092  0.57383  1.86464 
## 
## Random effects:
##  Groups   Name        Variance Std.Dev.
##  site     (Intercept) 0.25315  0.5031  
##  year     (Intercept) 0.03277  0.1810  
##  Residual             0.13149  0.3626  
## Number of obs: 60, groups:  site, 10; year, 6
## 
## Fixed effects:
##                 Estimate Std. Error        df t value Pr(>|t|)    
## (Intercept)     1.863335   0.207655 18.915215   8.973 3.04e-08 ***
## flower_density  0.018304   0.002208 46.393205   8.290 1.04e-10 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Correlation of Fixed Effects:
##             (Intr)
## flowr_dnsty -0.485
VarCorr(mod_crossed)
##  Groups   Name        Std.Dev.
##  site     (Intercept) 0.50314 
##  year     (Intercept) 0.18103 
##  Residual             0.36262
Design type R syntax When to use Example
Nested
A within B
Each level of A belongs to exactly one level of B
(1 | B/A) or (1|B)+(1|A_uid)
(1 | watershed/plot)
Subplots within plots, plots within watersheds; animals within litters
Soil carbon in subplots nested in plots nested in watersheds
Crossed
A independent of B
Every (or many) levels of A appear with every level of B
(1|A) + (1|B)
(1 | site) + (1 | year)
Multi-site × multi-year monitoring; species × observer designs
Pollinator counts at sites visited across multiple years
Partially crossed
Incomplete crossing
Not all combinations exist (common in practice)
(1|A) + (1|B)
(1 | site) + (1 | year) (same syntax)
Sites monitored in different subsets of years; staggered starts
Camera-trap network where some stations were added mid-study

Part 5 — Model Selection for Mixed Models

Fixed-effects selection: REML vs. ML

lme4 fits mixed models by restricted maximum likelihood (REML) by default. REML gives unbiased variance estimates but cannot be used to compare models with different fixed-effects structures. To compare fixed effects, refit with REML = FALSE (maximum likelihood):

# Candidate models — same random effects, different fixed effects
m_null   <- lmer(DO_mgl ~ 1       + (1 | station), data = do_data, REML = FALSE)
m_temp   <- lmer(DO_mgl ~ temp_C  + (1 | station), data = do_data, REML = FALSE)

# Likelihood ratio test
anova(m_null, m_temp)
## Data: do_data
## Models:
## m_null: DO_mgl ~ 1 + (1 | station)
## m_temp: DO_mgl ~ temp_C + (1 | station)
##        npar    AIC    BIC  logLik -2*log(L)  Chisq Df Pr(>Chisq)    
## m_null    3 363.88 371.57 -178.94    357.88                         
## m_temp    4 216.06 226.32 -104.03    208.06 149.82  1  < 2.2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Comparing random-effects structures

To compare random-effects structures, keep fixed effects constant and compare with anova() using REML = TRUE (the default), or use AIC directly:

# Refit with REML for variance component comparison
mod_ri_reml <- lmer(DO_mgl ~ temp_C + (1         | station), data = do_data)
mod_rs_reml <- lmer(DO_mgl ~ temp_C + (1 + temp_C | station), data = do_data)

anova(mod_ri_reml, mod_rs_reml)
## Data: do_data
## Models:
## mod_ri_reml: DO_mgl ~ temp_C + (1 | station)
## mod_rs_reml: DO_mgl ~ temp_C + (1 + temp_C | station)
##             npar    AIC    BIC   logLik -2*log(L)  Chisq Df Pr(>Chisq)    
## mod_ri_reml    4 216.06 226.32 -104.030    208.06                         
## mod_rs_reml    6 199.50 214.88  -93.749    187.50 20.561  2   3.43e-05 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
AIC(mod_ri_reml, mod_rs_reml)
##             df      AIC
## mod_ri_reml  4 221.0892
## mod_rs_reml  6 203.6091

The REML/ML rule

What you’re comparing Use
Models with different fixed effects, same random structure REML = FALSE (ML), then anova() or AIC
Models with different random effects, same fixed structure REML = TRUE (default), then anova() or AIC
Final reported estimates Always refit with REML = TRUE

R² for mixed models

The MuMIn package provides marginal and conditional R²:

library(MuMIn)
r.squaredGLMM(mod_ri)
##            R2m      R2c
## [1,] 0.2198037 0.956172

A large gap between R²_m and R²_c means the grouping structure accounts for substantial variation — your random effects are earning their keep.


Part 6 — Diagnostics for Mixed Models

Mixed-effects models share the linear model assumptions, plus specific assumptions about the random effects themselves.

Residual plots

par(mfrow = c(2, 1), mar = c(4, 4, 3, 1))

# Residuals vs. fitted
plot(fitted(mod_ri), residuals(mod_ri),
     xlab = "Fitted values", ylab = "Residuals",
     main = "Residuals vs. Fitted",
     col  = "#4a7c5e", pch = 16, cex = 0.8)
abline(h = 0, lty = 2, col = "#c8832a", lwd = 1.5)

# Normal Q-Q of residuals
qqnorm(residuals(mod_ri), main = "Q-Q Plot of Residuals",
       col = "#4a7c5e", pch = 16, cex = 0.8)
qqline(residuals(mod_ri), col = "#c8832a", lwd = 1.5)
Residual diagnostics for the random-intercept model. Top: residuals versus fitted values — look for random scatter without fan shape or curvature. Bottom: Q-Q plot of residuals — points near the line confirm normality.

Residual diagnostics for the random-intercept model. Top: residuals versus fitted values — look for random scatter without fan shape or curvature. Bottom: Q-Q plot of residuals — points near the line confirm normality.

par(mfrow = c(1, 1))

Normality of random effects

The random effects themselves must be approximately normally distributed. Check this with a Q-Q plot of the estimated BLUPs:

ri_vals <- ranef(mod_ri)$station[["(Intercept)"]]

qqnorm(ri_vals,
       main  = "Q-Q Plot of Random Intercepts",
       ylab  = "Sample quantiles (BLUPs)",
       col   = "#4a7c5e", pch = 16, cex = 1.2)
qqline(ri_vals, col = "#c8832a", lwd = 1.5)
Q-Q plot of station-level random intercepts (BLUPs). With only 8 stations the sample is small, but gross departures from the line would suggest the normality assumption is violated — consider a different distribution family or a robust mixed model.

Q-Q plot of station-level random intercepts (BLUPs). With only 8 stations the sample is small, but gross departures from the line would suggest the normality assumption is violated — consider a different distribution family or a robust mixed model.

Within-group linearity

Check that the fixed effect is linear within groups — plot residuals against the predictor separately per group:

do_data$resid_ri <- residuals(mod_ri)

ggplot(do_data, aes(temp_C, resid_ri)) +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "#c8832a") +
  geom_point(colour = "#4a7c5e", alpha = 0.6, size = 1.8) +
  geom_smooth(method = "loess", se = FALSE, colour = "#1e3a2f",
              linewidth = 0.8) +
  facet_wrap(~ station, nrow = 2) +
  labs(x = "Temperature (°C)", y = "Residual",
       title = "Within-station residuals vs. temperature") +
  theme_minimal(base_size = 11) +
  theme(
    strip.text = element_text(family = "mono", size = 9, colour = "#1e3a2f"),
    plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f")
  )
Residuals versus temperature by station. If the fixed effect of temperature were genuinely nonlinear, you would see a consistent curve across panels. Random scatter around zero within each panel confirms linearity.

Residuals versus temperature by station. If the fixed effect of temperature were genuinely nonlinear, you would see a consistent curve across panels. Random scatter around zero within each panel confirms linearity.

Common warnings and fixes

Diagnosing and resolving common lme4 warnings.
Warning/issue Meaning Fix
boundary (singular) fit A variance component was estimated at exactly zero — the model is over-specified Simplify random structure: remove the zero-variance term or correlation
Model failed to converge Optimiser could not find the maximum likelihood solution Try a different optimiser: control = lmerControl(optimizer = "bobyqa")
Some predictor variables are on very different scales Numerical instability from scale differences between predictors Scale continuous predictors: scale(x) before fitting
Variance component estimated as zero That grouping factor explains no variance beyond residual noise Drop that random effect; or keep as fixed with few levels
Very wide confidence intervals on random effects Too few levels of the grouping factor to estimate variance reliably Collect more groups — minimum ≈ 5–6 for reliable variance estimation

Summary

🔑 Key takeaways for mixed-effects models

Design reference card

Quick-reference guide for choosing a random-effects structure.
Study design Random structure in lme4 Key question
One response, one fixed predictor, observations grouped by site (1 &#124; site) Fixed slope, variable baselines
Same as above but site sensitivity to predictor varies (1 + predictor &#124; site) Does relationship differ by site?
Subplots within plots within watersheds (1 &#124; watershed/plot) or (1&#124;ws) + (1&#124;pl_uid) Where does most variance sit in the hierarchy?
Sites monitored across multiple years (1 &#124; site) + (1 &#124; year) Is pattern consistent across years?
Repeated measures on the same individual over time (1 &#124; individual) Individual-level trajectory vs. population trend
Species observed at sites across observers (partially crossed) (1 &#124; site) + (1 &#124; observer) Separate site and observer variance components

What’s next?

✏️ Practice Exercises
Exercise 1 — Identify the random effect

A researcher measures soil moisture at 5 depths in each of 12 plots across 3 forest types, taking measurements in both dry and wet seasons. (a) What is the fixed effect of interest? (b) Why can’t you treat each depth measurement as an independent observation? (c) Write the lmer() formula with an appropriate random effect.

Show solution
# Formula:
# moisture ~ forest_type * season + (1 | plot) + (1 | plot:depth)
# or more simply:
# moisture ~ forest_type * season + (1 | plot)

  1. Forest type × season interaction. (b) Depth measurements within a plot are physically nested — deep soil moisture correlates with shallow, violating independence. (c) plot as a random intercept accounts for the fact that some plots are inherently drier/wetter regardless of treatment.