Appendix A — Excluded model: Environmental-change context

WarningExcluded from the primary results

This model did not meet the pre-specified convergence criterion after the corrected generation filter was applied (maximum \(\hat{R}=1.0143\), minimum bulk ESS = 780, minimum tail ESS = 663, and zero divergent transitions). It is retained in the appendix and excluded from the main result tables and publication figures. The estimates and plots below are labelled as diagnostics.

A.1 Phylogenetic covariance and model species

This model contains 253 species (7,186 contrasts). The phylogenetic correlation matrix was constructed and aligned to those species as follows; brms uses it in (1 | gr(sp_ncbi, cov = A)).

Code
A_full <- prepR4pcm::pr_phylo_cor(tree_final)
# Fallback if needed: ape::vcv.phylo(tree_final, corr = TRUE)
dat_model <- dat_es |> dplyr::filter(!is.na(env_change))
phylo <- prepare_phylo_and_data(dat_model, A_full, fill_unmatched = TRUE)
dat_model <- phylo$dat_model
A <- phylo$A_mod
formula <- brms::bf(
  yi_lnM_safe ~ env_change + (1 | ref_id) +
    (1 | gr(sp_ncbi, cov = A)) + (1 | gr(es_id_model, cov = V)),
  sigma ~ env_change
)
V <- phylo$V
fit <- brms::brm(formula, data = dat_model,
                 data2 = list(A = A, V = V))

Type of environmental change.

A.2 Model summary

summary() is read from Rdata/epred_draws/m08.rds; the full brms fit is not loaded at render.

Code
print_cache_summary(cache)
 Family: gaussian 
  Links: mu = identity; sigma = log 
Formula: yi_lnM_safe ~ env_change + (1 | ref_id) + (1 | gr(sp_ncbi, cov = A)) + (1 | gr(es_id_model, cov = V)) 
         sigma ~ env_change
   Data: structure(list(es_id = c("es0313", "es0314", "es03 (Number of observations: 7186) 
  Draws: 4 chains, each with iter = 4000; warmup = 2000; thin = 1;
         total post-warmup draws = 8000

Multilevel Hyperparameters:
~es_id_model (Number of levels: 7186) 
              Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sd(Intercept)     1.00      0.00     1.00     1.00   NA       NA       NA

~ref_id (Number of levels: 254) 
              Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sd(Intercept)     0.75      0.04     0.67     0.83 1.00     2209     3870

~sp_ncbi (Number of levels: 253) 
              Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sd(Intercept)     0.67      0.19     0.32     1.05 1.01      780      663

Regression Coefficients:
                        Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS
Intercept                  -0.57      0.37    -1.32     0.17 1.00     4755
sigma_Intercept            -0.67      0.02    -0.71    -0.64 1.00     3738
env_changeongoing           0.06      0.10    -0.13     0.25 1.00     4587
sigma_env_changeongoing     0.21      0.03     0.16     0.27 1.00     4508
                        Tail_ESS
Intercept                   4891
sigma_Intercept             5241
env_changeongoing           5844
sigma_env_changeongoing     5969

Draws were sampled using sample(hmc). For each parameter, Bulk_ESS
and Tail_ESS are effective sample size measures, and Rhat is the potential
scale reduction factor on split chains (at convergence, Rhat = 1).

A.3 Pairwise level contrasts

Estimated marginal means and pairwise contrasts are read from Rdata/summaries/emmeans_contrasts_cache.rds.

A.3.1 Code used to generate the contrasts

This code was run with the fitted model on the compute machine. The fit, model_id, and moderator values are set in the model code above. Location contrasts use posterior expected values. Scale contrasts are calculated from posterior draws of the sigma coefficients. The compute server writes to outputs/; the completed tables are then copied to the book’s Rdata/ mirror.

Code
library(brms)
library(emmeans)
library(coda)
library(dplyr)
library(readr)
library(tibble)

# Location marginal means and all pairwise differences.
em <- emmeans::emmeans(
  fit,
  stats::as.formula(paste("~", moderator)),
  epred = TRUE,
  re_formula = NA
)

loc_emmeans <- as.data.frame(summary(em)) |>
  dplyr::rename(level = 1) |>
  tibble::as_tibble()

loc_pairs <- emmeans::contrast(em, method = "pairwise", adjust = "none")
loc_contrasts <- as.data.frame(summary(loc_pairs, infer = TRUE)) |>
  tibble::as_tibble()

# Add probability of direction from the location-contrast draws.
loc_draws <- as.matrix(
  emmeans::as.mcmc.emmGrid(loc_pairs, names = FALSE)
)
loc_contrasts$pd <- apply(loc_draws, 2, function(d) {
  p_positive <- mean(d > 0)
  max(p_positive, 1 - p_positive)
})

# Scale marginal means on the log-sigma scale.
draws <- brms::as_draws_df(fit)
levels_all <- levels(factor(fit$data[[moderator]]))
reference <- levels_all[1]
sigma_intercept <- draws[["b_sigma_Intercept"]]
sigma_by_level <- list()
sigma_by_level[[reference]] <- sigma_intercept

sigma_columns <- grep(
  paste0("^b_sigma_", moderator),
  names(draws),
  value = TRUE
)
column_keys <- gsub(
  "[^[:alnum:]_]", "",
  sub(paste0("^b_sigma_", moderator), "", sigma_columns)
)

for (level in setdiff(levels_all, reference)) {
  level_key <- gsub("[^[:alnum:]_]", "", level)
  matched_column <- sigma_columns[column_keys == level_key]
  if (length(matched_column) == 1L) {
    sigma_by_level[[level]] <-
      sigma_intercept + draws[[matched_column]]
  }
}

hpd_interval <- function(x) {
  as.numeric(coda::HPDinterval(coda::as.mcmc(x)))
}

scale_emmeans <- dplyr::bind_rows(lapply(names(sigma_by_level), function(level) {
  d <- sigma_by_level[[level]]
  interval <- hpd_interval(d)
  tibble::tibble(
    level = level,
    emmean = mean(d),
    lower.HPD = interval[1],
    upper.HPD = interval[2],
    residual_SD = exp(mean(d))
  )
}))

# All pairwise differences between log-sigma draws.
level_names <- names(sigma_by_level)
level_pairs <- utils::combn(level_names, 2, simplify = FALSE)
scale_contrasts <- dplyr::bind_rows(lapply(level_pairs, function(pair) {
  d <- sigma_by_level[[pair[1]]] - sigma_by_level[[pair[2]]]
  interval <- hpd_interval(d)
  p_positive <- mean(d > 0)
  tibble::tibble(
    contrast = paste(pair, collapse = " - "),
    estimate = mean(d),
    lower.HPD = interval[1],
    upper.HPD = interval[2],
    pd = max(p_positive, 1 - p_positive)
  )
}))

# Save the four tables used to build the shared cache.
contrast_dir <- here::here("Rdata", "tables", "contrasts")
dir.create(contrast_dir, recursive = TRUE, showWarnings = FALSE)
readr::write_csv(loc_emmeans,
  file.path(contrast_dir, paste0(model_id, "_location_emmeans.csv")))
readr::write_csv(loc_contrasts,
  file.path(contrast_dir, paste0(model_id, "_location_contrasts.csv")))
readr::write_csv(scale_emmeans,
  file.path(contrast_dir, paste0(model_id, "_scale_emmeans.csv")))
readr::write_csv(scale_contrasts,
  file.path(contrast_dir, paste0(model_id, "_scale_contrasts.csv")))

A.3.2 Code used to read and display the contrasts

Code
source(here::here("Scripts", "18_contrasts_cache.R"))
cc <- read_contrasts_cache("m08")

Location — pairwise contrasts (difference in mean lnM):

Code
kable_contrasts(format_location_contrasts(cc))
Contrast Estimate Lower 95% CrI Upper 95% CrI
novel - ongoing -0.058 -0.25 0.133

Scale — pairwise contrasts (log-σ; positive ⇒ greater residual heterogeneity):

Code
kable_contrasts(format_scale_contrasts(cc))
Contrast Estimate (logσ) Lower 95% CrI Upper 95% CrI
novel - ongoing -0.214 -0.268 -0.162

A.4 Orchard-like model figure

The orchard-style figure is read from Rdata/figures/publication/orchard/. The complete editable code used to build this plot is shown below.

Code
knitr::include_graphics(
  here::here("Rdata", "figures", "publication", "orchard", "m08_orchard_combined.png")
)

A.4.1 Editable plotting code

The figure above is actually built by Scripts/17_plot_orchard_from_epred_cache.R, which reads the credible intervals for each env_change level from Rdata/summaries/emmeans_contrasts_cache.rds (emmeans::emmeans(fit, ~ env_change, epred = TRUE, re_formula = NA), i.e. quantiles of the joint posterior of Intercept + coefficient). The self-contained variant below is kept for portability; its get_level_estimates() uses the same joint-posterior-draws approach rather than the separately computed Q2.5/Q97.5 of the intercept and the coefficient.

Code
library(here)
library(tidyverse)
library(brms)
library(tidybayes)
library(ggbeeswarm)
library(patchwork)

model_id <- "m08"
model_file <- "m08_ls_env_change"
moderator <- "env_change"
moderator_label <- "Environmental-change context"
label_map <- c("novel" = "Novel (defined start point)", "ongoing" = "Ongoing (measured within)")

out_dir <- here::here("Rdata", "figures", "publication", "orchard")
out_dir_pdf <- here::here("Figures", "publication", "orchard")
dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
dir.create(out_dir_pdf, recursive = TRUE, showWarnings = FALSE)

dat_es <- readRDS(here::here("Rdata", "effect_sizes", "proceed_lnm_safe.rds"))
fit <- readRDS(here::here("Rdata", "models", paste0(model_file, ".rds")))

cb_cols <- c("#88CCEE", "#CC6677", "#DDCC77", "#117733", "#332288",
             "#AA4499", "#44AA99", "#999933", "#882255", "#661100",
             "#6699CC", "#888888", "#E69F00", "#56B4E9", "#009E73",
             "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#999999")

theme_orchard <- function() {
  ggplot2::theme_classic(base_size = 13) +
    ggplot2::theme(
      axis.text.y        = ggplot2::element_text(size = 11),
      axis.title.x       = ggplot2::element_text(size = 12),
      axis.ticks.y       = ggplot2::element_blank(),
      panel.grid.major.x = ggplot2::element_line(colour = "grey92"),
      legend.position    = "bottom",
      legend.title       = ggplot2::element_text(size = 10),
      plot.title         = ggplot2::element_text(size = 13, face = "bold"),
      plot.caption       = ggplot2::element_text(size = 9, colour = "grey50")
    )
}

apply_labels <- function(x, lbl_map) {
  if (is.null(lbl_map)) return(x)
  lbl_map <- lbl_map[!is.na(names(lbl_map))]
  ifelse(x %in% names(lbl_map), lbl_map[x], x)
}

get_level_estimates <- function(fit, moderator) {
  # Credible intervals come from quantiles of the joint posterior draws of
  # (Intercept + coefficient), not from adding the separately-computed
  # Q2.5/Q97.5 of each term — the latter ignores the posterior covariance
  # between the intercept and factor-level coefficients and roughly doubles
  # the interval width.
  draws <- brms::as_draws_df(fit)
  ref_level <- levels(factor(fit$data[[moderator]]))[1]
  pat <- paste0("^b_", moderator)
  coef_cols <- grep(pat, names(draws), value = TRUE)
  cri <- function(d) unname(quantile(d, c(0.025, 0.975)))

  int_draws <- draws[["b_Intercept"]]
  h0 <- cri(int_draws)
  out <- tibble::tibble(
    level = ref_level,
    estimate = mean(int_draws),
    lowerCL = h0[1],
    upperCL = h0[2]
  )
  for (col in coef_cols) {
    lvl <- sub(pat, "", col)
    d <- int_draws + draws[[col]]
    h <- cri(d)
    out <- dplyr::bind_rows(out, tibble::tibble(
      level = lvl,
      estimate = mean(d),
      lowerCL = h[1],
      upperCL = h[2]
    ))
  }
  out
}

get_sig_estimates <- function(fit, moderator, levels_vec = NULL) {
  if (is.null(levels_vec)) {
    levels_vec <- levels(factor(fit$data[[moderator]]))
  }

  nd <- data.frame(x = levels_vec, stringsAsFactors = FALSE)
  names(nd)[1] <- moderator
  nd[["es_id_model"]] <- NA
  nd[["ref_id"]] <- NA
  nd[["sp_ncbi"]] <- NA

  tidybayes::epred_draws(
    fit, newdata = nd, re_formula = NA, dpar = TRUE, ndraws = 1000
  ) |>
    dplyr::group_by(.data[[moderator]]) |>
    dplyr::summarise(
      estimate = mean(sigma),
      lowerCL = quantile(sigma, 0.025),
      upperCL = quantile(sigma, 0.975),
      .groups = "drop"
    ) |>
    dplyr::rename(level = dplyr::all_of(moderator))
}
get_pred_interval <- function(fit, moderator, levels_vec) {
  nd <- data.frame(x = levels_vec, stringsAsFactors = FALSE)
  names(nd)[1] <- moderator
  nd[["es_id_model"]] <- NA
  nd[["ref_id"]] <- NA
  nd[["sp_ncbi"]] <- NA

  pp <- tidybayes::add_predicted_draws(nd, fit, re_formula = NA, ndraws = 1000)
  pp |>
    dplyr::group_by(.data[[moderator]]) |>
    dplyr::summarise(
      lowerPR = quantile(.prediction, 0.025),
      upperPR = quantile(.prediction, 0.975),
      .groups = "drop"
    ) |>
    dplyr::rename(level = dplyr::all_of(moderator))
}

save_plot <- function(p, stem, width = 9, height = 6) {
  ggplot2::ggsave(file.path(out_dir_pdf, paste0(stem, ".pdf")), p,
                  width = width, height = height, device = cairo_pdf)
  ggplot2::ggsave(file.path(out_dir, paste0(stem, ".png")), p,
                  width = width, height = height, dpi = 300, type = "cairo")
}

raw_source <- if (moderator %in% names(dat_es)) dat_es else fit$data
if (!"vi_lnM_safe" %in% names(raw_source) &&
    "es_id_model" %in% names(raw_source) &&
    "es_id_model" %in% names(dat_es)) {
  raw_source <- raw_source |>
    dplyr::left_join(
      dat_es |> dplyr::select(es_id_model, vi_lnM_safe),
      by = "es_id_model"
    )
}

raw <- raw_source[!is.na(raw_source[[moderator]]), ] |>
  dplyr::mutate(
    level = as.character(.data[[moderator]]),
    precision = 1 / sqrt(vi_lnM_safe)
  )

ests <- get_level_estimates(fit, moderator)
sig_ests <- get_sig_estimates(fit, moderator, unique(raw$level))
pi_df <- get_pred_interval(fit, moderator, unique(raw$level))
ests <- dplyr::left_join(ests, pi_df, by = "level")

ests$level <- apply_labels(ests$level, label_map)
sig_ests$level <- apply_labels(sig_ests$level, label_map)
raw$level <- apply_labels(raw$level, label_map)

lev_order <- ests$level[order(ests$estimate)]
ests$level <- factor(ests$level, levels = lev_order)
sig_ests$level <- factor(sig_ests$level, levels = lev_order)
raw$level <- factor(raw$level, levels = lev_order)

n_levels <- nlevels(ests$level)
colors <- cb_cols[seq_len(n_levels)]
names(colors) <- levels(ests$level)

p_loc <- ggplot2::ggplot() +
  ggplot2::geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
  ggbeeswarm::geom_quasirandom(
    data = raw,
    ggplot2::aes(x = yi_lnM_safe, y = level,
                 size = precision, colour = level, fill = level),
    alpha = 0.30, shape = 21, groupOnX = FALSE
  ) +
  ggplot2::geom_linerange(
    data = ests,
    ggplot2::aes(y = level, xmin = lowerPR, xmax = upperPR),
    linewidth = 0.4, colour = "grey40"
  ) +
  ggplot2::geom_linerange(
    data = ests,
    ggplot2::aes(y = level, xmin = lowerCL, xmax = upperCL),
    linewidth = 1.5, colour = "grey15"
  ) +
  ggplot2::geom_point(
    data = ests,
    ggplot2::aes(x = estimate, y = level),
    size = 4, shape = 21, fill = "white", colour = "grey10", stroke = 1.2
  ) +
  ggplot2::scale_colour_manual(values = colors, guide = "none") +
  ggplot2::scale_fill_manual(values = colors, guide = "none") +
  ggplot2::scale_size_continuous(name = "Precision (1/SE)", range = c(0.4, 4)) +
  ggplot2::labs(
    x = "Location effect (lnM)",
    y = NULL,
    title = paste("Location --", moderator_label)
  ) +
  theme_orchard()

raw_sig <- raw |>
  dplyr::left_join(dplyr::select(ests, level, loc_est = estimate), by = "level") |>
  dplyr::mutate(abs_resid = abs(yi_lnM_safe - loc_est))

p_scl <- ggplot2::ggplot() +
  ggplot2::geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
  ggbeeswarm::geom_quasirandom(
    data = raw_sig,
    ggplot2::aes(x = abs_resid, y = level,
                 size = precision, colour = level, fill = level),
    alpha = 0.30, shape = 21, groupOnX = FALSE
  ) +
  ggplot2::geom_linerange(
    data = sig_ests,
    ggplot2::aes(y = level, xmin = lowerCL, xmax = upperCL),
    linewidth = 3, colour = "white"
  ) +
  ggplot2::geom_linerange(
    data = sig_ests,
    ggplot2::aes(y = level, xmin = lowerCL, xmax = upperCL),
    linewidth = 1.5, colour = "grey15"
  ) +
  ggplot2::geom_point(
    data = sig_ests,
    ggplot2::aes(x = estimate, y = level),
    size = 4, shape = 21, fill = "white", colour = "grey10", stroke = 1.2
  ) +
  ggplot2::scale_colour_manual(values = colors, guide = "none") +
  ggplot2::scale_fill_manual(values = colors, guide = "none") +
  ggplot2::scale_size_continuous(name = "Precision (1/SE)", range = c(0.4, 4)) +
  ggplot2::labs(
    x = "residual lnM (SD)",
    y = NULL,
    title = paste("Scale --", moderator_label),
    caption = "Bubbles: absolute residual lnM, sized by precision. Trunk = predicted residual SD (sigma)."
  ) +
  theme_orchard()

p_comb <- patchwork::wrap_plots(p_loc, p_scl, ncol = 1)

height_single <- max(2.5 + n_levels * 0.55, 5)
save_plot(p_loc, paste0(model_id, "_orchard_location"), width = 9, height = height_single)
save_plot(p_scl, paste0(model_id, "_orchard_scale"), width = 9, height = height_single)
save_plot(p_comb, paste0(model_id, "_orchard_combined"), width = 9, height = max(height_single * 1.9, 10))
p_comb