10  Disturbance context

Input Saved prediction and summary files.

Action Read the model record and build its figure.

Output Model table, checks, and plot.

Next Continue to the next model chapter.

10.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(disturbance))
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 ~ disturbance + (1 | ref_id) +
    (1 | gr(sp_ncbi, cov = A)) + (1 | gr(es_id_model, cov = V)),
  sigma ~ disturbance
)
V <- phylo$V
fit <- brms::brm(formula, data = dat_model,
                 data2 = list(A = A, V = V))

Reference level: Climate change. Coefficients contrast each disturbance type against it.

10.2 Model summary

summary() is read from Rdata/epred_draws/m01.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 ~ disturbance + (1 | ref_id) + (1 | gr(sp_ncbi, cov = A)) + (1 | gr(es_id_model, cov = V)) 
         sigma ~ disturbance
   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.84 1.00     2130     3591

~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.20     0.32     1.07 1.01      727     1704

Regression Coefficients:
                                         Estimate Est.Error l-95% CI u-95% CI
Intercept                                   -0.53      0.42    -1.34     0.32
sigma_Intercept                             -0.69      0.09    -0.87    -0.53
disturbanceHunt_harv                        -0.28      0.18    -0.63     0.08
disturbanceIntroduction                      0.01      0.20    -0.38     0.40
disturbanceLandscapechange                   0.21      0.24    -0.25     0.67
disturbanceOther                            -0.28      0.17    -0.62     0.06
disturbancePollution                         0.17      0.28    -0.38     0.73
disturbanceResponsetointroductions           0.10      0.31    -0.50     0.69
sigma_disturbanceHunt_harv                   0.36      0.09     0.19     0.55
sigma_disturbanceIntroduction                0.02      0.09    -0.15     0.20
sigma_disturbanceLandscapechange            -0.43      0.15    -0.74    -0.14
sigma_disturbanceOther                       0.14      0.09    -0.04     0.33
sigma_disturbancePollution                  -0.18      0.16    -0.49     0.12
sigma_disturbanceResponsetointroductions     0.26      0.12     0.03     0.49
                                         Rhat Bulk_ESS Tail_ESS
Intercept                                1.00     3271     3897
sigma_Intercept                          1.00     1625     2782
disturbanceHunt_harv                     1.00     2118     3592
disturbanceIntroduction                  1.00     1841     2877
disturbanceLandscapechange               1.00     2085     3246
disturbanceOther                         1.00     2124     3482
disturbancePollution                     1.00     2072     3441
disturbanceResponsetointroductions       1.00     2344     3623
sigma_disturbanceHunt_harv               1.00     1800     3256
sigma_disturbanceIntroduction            1.00     1600     2867
sigma_disturbanceLandscapechange         1.00      967     2278
sigma_disturbanceOther                   1.00     1898     3308
sigma_disturbancePollution               1.00     1152     2489
sigma_disturbanceResponsetointroductions 1.00     2096     3469

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).

10.3 Model-estimated results

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

10.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")))

10.3.2 Code used to read and display the contrasts

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

10.4 Main level estimates

For each disturbance context, the table reports the estimated marginal mean on the \(\ln M\) scale and its approximate standardized-mean-difference equivalent, calculated as \(d_{\mathrm{eq}} \approx \sqrt{2\exp(2\ln M)}\). Because this transformation is monotonic, the 95% credible-interval endpoints are transformed in the same way.

Code
kable_contrasts(
  format_location_emmeans(cc),
  caption = "Disturbance-context mean lnM and equivalent d with 95% credible intervals."
)
Disturbance-context mean lnM and equivalent d with 95% credible intervals.
Level Mean lnM lnM lower 95% CrI lnM upper 95% CrI deq deq lower 95% CrI deq upper 95% CrI
Climate change -0.530 -1.338 0.319 0.832 0.371 1.946
Hunt_harv -0.806 -1.614 -0.036 0.632 0.282 1.364
Introduction -0.524 -1.319 0.240 0.837 0.378 1.798
Landscape change -0.323 -1.134 0.495 1.024 0.455 2.320
Other -0.811 -1.611 -0.040 0.629 0.282 1.359
Pollution -0.357 -1.214 0.503 0.989 0.420 2.339
Response to introductions -0.430 -1.348 0.434 0.920 0.367 2.183

10.5 Pairwise level contrasts

Location — pairwise contrasts (difference in mean lnM):

Code
kable_contrasts(format_location_contrasts(cc))
Contrast Estimate Lower 95% CrI Upper 95% CrI
Climate change - Hunt_harv 0.276 -0.079 0.630
Climate change - Introduction -0.006 -0.399 0.384
Climate change - Landscape change -0.207 -0.672 0.251
Climate change - Other 0.281 -0.063 0.623
Climate change - Pollution -0.173 -0.729 0.381
Climate change - Response to introductions -0.100 -0.692 0.503
Hunt_harv - Introduction -0.282 -0.543 -0.026
Hunt_harv - Landscape change -0.483 -0.830 -0.133
Hunt_harv - Other 0.005 -0.078 0.087
Hunt_harv - Pollution -0.449 -0.923 0.019
Hunt_harv - Response to introductions -0.376 -0.885 0.130
Introduction - Landscape change -0.201 -0.549 0.138
Introduction - Other 0.287 0.038 0.537
Introduction - Pollution -0.167 -0.584 0.269
Introduction - Response to introductions -0.094 -0.573 0.373
Landscape change - Other 0.488 0.148 0.826
Landscape change - Pollution 0.035 -0.470 0.537
Landscape change - Response to introductions 0.108 -0.456 0.664
Other - Pollution -0.453 -0.927 0.008
Other - Response to introductions -0.380 -0.887 0.129
Pollution - Response to introductions 0.073 -0.534 0.670

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

Code
kable_contrasts(format_scale_contrasts(cc))
Contrast Estimate (logσ) Lower 95% CrI Upper 95% CrI
Climate change - Introduction -0.015 -0.199 0.155
Climate change - Response to introductions -0.256 -0.486 -0.028
Climate change - Other -0.138 -0.329 0.038
Climate change - Landscape change 0.431 0.142 0.737
Climate change - Pollution 0.184 -0.119 0.489
Climate change - Hunt_harv -0.365 -0.554 -0.189
Introduction - Response to introductions -0.241 -0.393 -0.090
Introduction - Other -0.123 -0.191 -0.053
Introduction - Landscape change 0.447 0.222 0.702
Introduction - Pollution 0.199 -0.040 0.471
Introduction - Hunt_harv -0.350 -0.421 -0.278
Response to introductions - Other 0.118 -0.038 0.276
Response to introductions - Landscape change 0.687 0.419 0.976
Response to introductions - Pollution 0.440 0.166 0.746
Response to introductions - Hunt_harv -0.109 -0.270 0.053
Other - Landscape change 0.570 0.340 0.826
Other - Pollution 0.322 0.080 0.597
Other - Hunt_harv -0.227 -0.311 -0.142
Landscape change - Pollution -0.248 -0.582 0.099
Landscape change - Hunt_harv -0.796 -1.054 -0.563
Pollution - Hunt_harv -0.549 -0.821 -0.307

10.6 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", "m01_orchard_combined.png")
)

10.6.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 disturbance level from Rdata/summaries/emmeans_contrasts_cache.rds (emmeans::emmeans(fit, ~ disturbance, 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 <- "m01"
model_file <- "m01_ls_disturbance"
moderator <- "disturbance"
moderator_label <- "Disturbance context"
label_map <- c("Climate change" = "Climate change", "Hunt_harv" = "Hunting / harvesting", "Introduction" = "Introduction", "Landscapechange" = "Landscape change", "Other" = "Other (in situ natural variation)", "Pollution" = "Pollution", "Responsetointroductions" = "Response to introductions")

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