Explaining heterogeneity in R

Practical · Wednesday 16:33–16:55 · FRB-CESAB training course

Author

Damien Beillouin

By the end of this practical, you will be able to
  1. Fit and read a meta-regression (omnibus test, coefficients, pseudo-R²).
  2. Separate the within-study from the between-study part of a moderator effect.
  3. Check coefficients of variation and impute missing SDs transparently.
Time What Section
16:33 Meta-regression: pests vs natural enemies (1 blank) 1 · 2
16:40 Within- vs between-study (1 blank) · optional: validity 3
16:47 Missing SDs (code given: run and interpret) 4

Work in pairs. Only two blanks (___) to fill: the rest of the script is given, and the work is to read and discuss the outputs. The questions are in the script (# Q:). | 16:55 | Debrief (until 17:00) | 5 |

1 Setup

Open meta_analysis_course.Rproj (the course kit), then td_advanced.R (exercises with blanks ___).

library(dplyr)
library(metafor)
library(ggplot2)

# In the participant pack: data_file <- "intercropping_biodiversity.csv"
ic <- read.csv(data_file)

dat <- ic |>
  filter(functional_group %in% c("Pests", "Natural enemies"), !sd_missing, !zero_mean) |>
  escalc(measure = "ROM", m1i = b_mean_t, sd1i = b_sd_t, n1i = b_n_t,
         m2i = b_mean_c, sd2i = b_sd_c, n2i = b_n_c, data = _)

pct <- function(x) round(100 * (exp(x) - 1), 1)
fit <- function(d, mods = ~ 1)          # the multilevel model of this afternoon
  rma.mv(yi, vi, mods = mods, random = ~ 1 | study_id / es_id,
         data = d, test = "t", dfs = "contain")

table(dat$functional_group)

Natural enemies           Pests 
            195             382 

2 Meta-regression

Predict first: will intercropping have the same effect on pests and on their natural enemies?

Exercise 1. Add functional_group as a moderator.

m0 <- fit(dat)                          # no moderator
m1 <- fit(dat, mods = ~ ___)            # with moderator
m1                                      # find: Test of Moderators (F, p)
robust(m1, cluster = study_id, clubSandwich = TRUE)   # few studies: check with CR2
m0 <- fit(dat)
m1 <- fit(dat, mods = ~ functional_group)
m1

Multivariate Meta-Analysis Model (k = 577; method: REML)

Variance Components:

            estim    sqrt  nlvls  fixed          factor 
sigma^2.1  0.1211  0.3480     34     no        study_id 
sigma^2.2  0.1720  0.4147    577     no  study_id/es_id 

Test for Residual Heterogeneity:
QE(df = 575) = 160501.6025, p-val < .0001

Test of Moderators (coefficient 2):
F(df1 = 1, df2 = 575) = 52.2656, p-val < .0001

Model Results:

                       estimate      se     tval   df    pval    ci.lb    ci.ub 
intrcpt                  0.1269  0.0768   1.6531   32  0.1081  -0.0295   0.2834 
functional_groupPests   -0.4348  0.0601  -7.2295  575  <.0001  -0.5530  -0.3167 
                           
intrcpt                    
functional_groupPests  *** 

---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
m1r <- robust(m1, cluster = study_id, clubSandwich = TRUE)
m1r

Multivariate Meta-Analysis Model (k = 577; method: REML)

Variance Components:

            estim    sqrt  nlvls  fixed          factor 
sigma^2.1  0.1211  0.3480     34     no        study_id 
sigma^2.2  0.1720  0.4147    577     no  study_id/es_id 

Test for Residual Heterogeneity:
QE(df = 575) = 160501.6025, p-val < .0001

Number of estimates:   577
Number of clusters:    34
Estimates per cluster: 1-80 (mean: 16.97, median: 11.5)

Test of Moderators (coefficient 2):¹
F(df1 = 1, df2 = 10.04) = 20.3527, p-val = 0.0011

Model Results:

                       estimate      se¹     tval¹     df¹    pval¹    ci.lb¹ 
intrcpt                  0.1269  0.0893    1.4218   25.19   0.1674   -0.0569  
functional_groupPests   -0.4348  0.0964   -4.5114   10.04   0.0011   -0.6495  
                         ci.ub¹     
intrcpt                 0.3108      
functional_groupPests  -0.2202   ** 

---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

1) results based on cluster-robust inference (var-cov estimator: CR2,
   approx t/F-tests and confidence intervals, df: Satterthwaite approx)
  • Test of Moderators: F is large, p < 0.001 → the effect differs between groups.
  • With cluster-robust (CR2) tests the denominator df drop to about 10 (only a few studies carry the contrast), but p stays ≈ 0.001: the difference survives.
  • intrcpt = natural enemies (reference level); functional_groupPests = difference pests − natural enemies (lnRR scale).

Exercise 2 (code given — run and read). One estimate per group (intercept removed with 0 +) and the share of heterogeneity explained. How large is the pseudo-R²? At which level does the moderator explain something?

m1b <- fit(dat, mods = ~ 0 + functional_group)
pct(coef(m1b))
functional_groupNatural enemies           functional_groupPests 
                           13.5                           -26.5 
R2 <- 1 - sum(m1$sigma2) / sum(m0$sigma2)
round(100 * R2, 1)
[1] 9.8
round(100 * (1 - m1$sigma2 / m0$sigma2), 1)
[1]  1.7 14.7

Pests decrease, natural enemies tend to increase — consistent with the enemies hypothesis. But the moderator explains only about 10% of the heterogeneity overall, and almost all of it within studies (~15%) rather than between studies (~2%). Pseudo-R² is rough (it can even be negative) — both models must be fitted on the same rows, as here.

3 Within-study check and study quality

Exercise 3. Split the moderator into a within-study part (does a study that measured both groups see a difference?) and a between-study part (do studies of pests differ from studies of enemies?). Only a few studies measured both groups.

dat <- dat |>
  mutate(pest = as.numeric(functional_group == "Pests")) |>
  group_by(study_id) |>
  mutate(pest_m = mean(pest),          # share of pest rows in the study (between)
         pest_w = pest - ___) |>       # deviation from the study mean (within)
  ungroup()
sum(tapply(dat$pest, dat$study_id, function(x) length(unique(x))) == 2)  # studies with both
m_wb <- fit(dat, mods = ~ pest_w + pest_m)
pct(coef(m_wb)[-1])
m_wb
dat <- dat |>
  mutate(pest = as.numeric(functional_group == "Pests")) |>
  group_by(study_id) |>
  mutate(pest_m = mean(pest), pest_w = pest - pest_m) |>
  ungroup()
sum(tapply(dat$pest, dat$study_id, function(x) length(unique(x))) == 2)
[1] 14
m_wb <- fit(dat, mods = ~ pest_w + pest_m)
pct(coef(m_wb)[-1])
pest_w pest_m 
 -36.1  -27.6 
m_wb

Multivariate Meta-Analysis Model (k = 577; method: REML)

Variance Components:

            estim    sqrt  nlvls  fixed          factor 
sigma^2.1  0.1226  0.3501     34     no        study_id 
sigma^2.2  0.1721  0.4148    577     no  study_id/es_id 

Test for Residual Heterogeneity:
QE(df = 574) = 110568.4394, p-val < .0001

Test of Moderators (coefficients 2:3):
F(df1 = 2, df2 = 31) = 26.3119, p-val < .0001

Model Results:

         estimate      se     tval   df    pval    ci.lb    ci.ub      
intrcpt    0.0574  0.1341   0.4284   31  0.6714  -0.2161   0.3310      
pest_w    -0.4481  0.0637  -7.0398  574  <.0001  -0.5731  -0.3231  *** 
pest_m    -0.3233  0.1863  -1.7356   31  0.0926  -0.7032   0.0566    . 

---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
  • Within studies (same field, year, crop): pests about −36% relative to natural enemies, clearly different from 0.
  • Between studies: about −28%, and uncertain (p ≈ 0.09).
  • Same direction and similar size: the difference is not just an artefact of which studies measured which group. The within-study estimate is the one that controls for study context — it is the stronger evidence.

(optional) Exercise 4. For natural enemies only, is study quality (validity_biodiv) a moderator? How many comparisons could not be appraised?

ne <- filter(dat, functional_group == "Natural enemies")
sum(is.na(ne$validity_biodiv))
[1] 28
m_v <- fit(ne, mods = ~ 0 + factor(validity_biodiv))
pct(coef(m_v))
factor(validity_biodiv)0 factor(validity_biodiv)1 
                    -7.0                     26.6 
fit(ne, mods = ~ factor(validity_biodiv))$pval[2]
[1] 0.01407015

Comparisons meeting all validity criteria show a clear increase of natural enemies; the others show none. But the “not valid” level rests on only 6 studies (93 rows) and the “valid” one on 12 studies: a between-study association with few clusters. Poor designs may mask a real benefit — or valid studies differ in other ways (crop, region, taxa). And 1 comparison in 5 could not be appraised at all.

4 Missing SDs

Back to pests, this time keeping the comparisons without a usable SD (but with non-zero means).

pz <- ic |> filter(functional_group == "Pests", !zero_mean)
c(total = nrow(pz), sd_missing = sum(pz$sd_missing))
     total sd_missing 
       440         58 

Exercise 5. Check the CVs first (code given — run and read). The coefficient of variation (SD / mean) of each study with SDs. Which studies look implausible?

cvs <- pz |>
  filter(!sd_missing) |>
  mutate(cv_t = b_sd_t / b_mean_t, cv_c = b_sd_c / b_mean_c)
cvs |>
  group_by(study_id) |>
  summarise(cv = mean((cv_t + cv_c) / 2), n = mean(b_n_t)) |>
  arrange(desc(cv)) |>
  head(5)
# A tibble: 5 × 3
  study_id    cv      n
     <int> <dbl>  <dbl>
1      729  9.49 1200  
2      722  3.82   53.7
3     1323  2.43   20  
4      638  1.80   15  
5      635  1.76   60  

Two studies (722 and 729) have a mean CV above 3 — study 729 with n = 1,200 per group. In the database, these SDs were rebuilt as SE × √n with n = the number of subsamples, not of independent plots (pseudo-replication): the “SD” is inflated and so is n. Go back to the paper, and recompute from it — never just patch the number. Here we set these two studies aside, both from the CV pool and from the analysis.

Exercise 6. Impute and compare (code given — run it and read the output).

suspect <- c(722, 729)                  # studies flagged in Exercise 5
# typical CV = mean CV weighted by n, per group, then squared
# (Nakagawa et al. 2023; what metafor does with vtype = "AV")
weighted.mean(cvs$cv_t, cvs$b_n_t)^2          # without the screen: absurd
[1] 28.09928
ok   <- filter(cvs, !study_id %in% suspect)
cv2t <- weighted.mean(ok$cv_t, ok$b_n_t)^2
cv2c <- weighted.mean(ok$cv_c, ok$b_n_c)^2
c(cv2t, cv2c)                           # with the screen: plausible
[1] 0.2149715 0.2090545
pz <- pz |>
  mutate(yi = log(b_mean_t / b_mean_c),
         vi = ifelse(sd_missing,
                     cv2t / b_n_t + cv2c / b_n_c,          # imputed: typical CV, squared
                     b_sd_t^2 / (b_n_t * b_mean_t^2) +     # observed SDs
                       b_sd_c^2 / (b_n_c * b_mean_c^2))) |>
  filter(!study_id %in% suspect)
table(pz$study_id[pz$sd_missing])       # where do the imputed rows come from?

524 525 742 763 
  2  45   4   2 
m_cc  <- fit(filter(pz, !sd_missing))
m_imp <- fit(pz)
rbind(complete_cases = pct(c(m_cc$b,  m_cc$ci.lb,  m_cc$ci.ub)),
      with_imputed   = pct(c(m_imp$b, m_imp$ci.lb, m_imp$ci.ub)))
                [,1]  [,2]  [,3]
complete_cases -28.0 -42.1 -10.5
with_imputed   -30.2 -45.3 -10.9
Interpret

Does the conclusion change? What would you write in the methods and results sections?

Nearly identical estimates (about −28% vs −30%): the conclusion is robust to the missing SDs. But look where the imputed rows come from: 45 of 53 from a single study (525). The first step is to go back to that paper. Without the CV screen, the squared typical CV is about 30 instead of about 0.2, and imputation would have given these rows almost no weight. Report the number of comparisons with imputed variances, the method (Nakagawa et al. 2023), the CV screening (and the studies set aside), and both results.

5 Debrief

  1. Meta-regression tests whether a moderator explains heterogeneity: report the omnibus test, coefficients and residual heterogeneity (pseudo-R²).
  2. Between-study moderator effects are associations; within-study contrasts are stronger.
  3. Missing SDs: check CVs by study, recompute from the paper when you can, impute transparently, compare with complete cases.
  4. Tomorrow (group projects): the biodiversity–yield pairs (biodiversity_yield_pairs.csv) are a good playground for a multivariate question.

Going further in the online notebook

5.1 References

Nakagawa, S., D. W. A. Noble, M. Lagisz, R. Spake, W. Viechtbauer, and A. M. Senior. 2023. A robust and readily implementable method for the meta-analysis of response ratios with and without missing standard deviations. Ecology Letters 26:232–244.