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.

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

Trait category of the measured phenotype.

14.2 Model summary

summary() is read from Rdata/epred_draws/m05.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 ~ trait_type + (1 | ref_id) + (1 | gr(sp_ncbi, cov = A)) + (1 | gr(es_id_model, cov = V)) 
         sigma ~ trait_type
   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.74      0.04     0.66     0.83 1.00     1297     2809

~sp_ncbi (Number of levels: 253) 
              Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sd(Intercept)     0.84      0.17     0.54     1.20 1.01      808     2050

Regression Coefficients:
                                Estimate Est.Error l-95% CI u-95% CI Rhat
Intercept                          -1.33      0.62    -2.52    -0.11 1.00
sigma_Intercept                    -0.37      0.25    -0.86     0.13 1.00
trait_typegrowth                    0.83      0.41     0.04     1.62 1.00
trait_typeotherLH                   0.91      0.40     0.13     1.68 1.00
trait_typeothermorphology           0.78      0.40     0.00     1.56 1.00
trait_typephenology                 0.81      0.43    -0.02     1.63 1.00
trait_typephysio                    0.70      0.40    -0.08     1.48 1.00
trait_typeresponse                  1.17      0.44     0.31     2.02 1.00
trait_typesize                      0.83      0.40     0.06     1.61 1.00
sigma_trait_typegrowth             -0.98      0.31    -1.61    -0.39 1.00
sigma_trait_typeotherLH            -0.18      0.26    -0.69     0.31 1.00
sigma_trait_typeothermorphology    -0.52      0.26    -1.03    -0.02 1.00
sigma_trait_typephenology          -0.38      0.29    -0.94     0.17 1.00
sigma_trait_typephysio             -0.70      0.27    -1.22    -0.18 1.00
sigma_trait_typeresponse           -0.55      0.40    -1.35     0.16 1.00
sigma_trait_typesize               -0.04      0.25    -0.54     0.45 1.00
                                Bulk_ESS Tail_ESS
Intercept                           1182     2367
sigma_Intercept                     1114     2515
trait_typegrowth                     931     1867
trait_typeotherLH                    935     1845
trait_typeothermorphology            935     1820
trait_typephenology                  966     2009
trait_typephysio                     941     1770
trait_typeresponse                  1043     2031
trait_typesize                       939     1838
sigma_trait_typegrowth               739     1060
sigma_trait_typeotherLH             1124     2604
sigma_trait_typeothermorphology     1118     2558
sigma_trait_typephenology           1253     2633
sigma_trait_typephysio              1098     2523
sigma_trait_typeresponse            1001      940
sigma_trait_typesize                1120     2609

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

14.3 Model-estimated results

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

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

14.3.2 Code used to read and display the contrasts

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

14.4 Main level estimates

For each trait type, the table reports the estimated marginal mean on the \(\ln M\) scale and \(d_{\mathrm{eq}} \approx \sqrt{2\exp(2\ln M)}\). The same monotonic transformation is applied to the 95% credible-interval endpoints.

Code
kable_contrasts(
  format_location_emmeans(cc),
  caption = "Trait-type mean lnM and equivalent d with 95% credible intervals."
)
Trait-type 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
behaviour -1.332 -2.519 -0.105 0.373 0.114 1.273
growth -0.504 -1.460 0.427 0.855 0.328 2.168
otherLH -0.425 -1.370 0.510 0.925 0.360 2.355
othermorphology -0.550 -1.495 0.378 0.816 0.317 2.064
phenology -0.526 -1.502 0.429 0.836 0.315 2.172
physio -0.634 -1.575 0.300 0.750 0.293 1.909
response -0.165 -1.145 0.813 1.199 0.450 3.189
size -0.498 -1.441 0.434 0.860 0.335 2.182

14.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
behaviour - growth -0.829 -1.616 -0.042
behaviour - otherLH -0.907 -1.681 -0.131
behaviour - othermorphology -0.782 -1.555 -0.003
behaviour - phenology -0.806 -1.626 0.022
behaviour - physio -0.698 -1.477 0.077
behaviour - response -1.167 -2.024 -0.313
behaviour - size -0.835 -1.611 -0.058
growth - otherLH -0.079 -0.202 0.040
growth - othermorphology 0.046 -0.086 0.180
growth - phenology 0.022 -0.279 0.324
growth - physio 0.130 -0.016 0.272
growth - response -0.339 -0.677 0.026
growth - size -0.006 -0.126 0.110
otherLH - othermorphology 0.125 0.032 0.217
otherLH - phenology 0.101 -0.175 0.391
otherLH - physio 0.209 0.097 0.321
otherLH - response -0.260 -0.585 0.101
otherLH - size 0.073 0.006 0.139
othermorphology - phenology -0.024 -0.308 0.269
othermorphology - physio 0.084 -0.032 0.202
othermorphology - response -0.385 -0.713 -0.033
othermorphology - size -0.052 -0.141 0.037
phenology - physio 0.108 -0.194 0.397
phenology - response -0.361 -0.796 0.083
phenology - size -0.028 -0.317 0.249
physio - response -0.469 -0.794 -0.116
physio - size -0.136 -0.247 -0.024
response - size 0.333 -0.026 0.655

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

Code
kable_contrasts(format_scale_contrasts(cc))
Contrast Estimate (logσ) Lower 95% CrI Upper 95% CrI
behaviour - othermorphology 0.519 0.025 1.027
behaviour - size 0.037 -0.454 0.538
behaviour - otherLH 0.181 -0.314 0.688
behaviour - growth 0.977 0.387 1.615
behaviour - phenology 0.383 -0.171 0.937
behaviour - physio 0.698 0.179 1.225
behaviour - response 0.550 -0.158 1.354
othermorphology - size -0.483 -0.554 -0.414
othermorphology - otherLH -0.338 -0.422 -0.255
othermorphology - growth 0.458 0.149 0.842
othermorphology - phenology -0.136 -0.399 0.144
othermorphology - physio 0.178 -0.016 0.385
othermorphology - response 0.030 -0.457 0.699
size - otherLH 0.144 0.075 0.216
size - growth 0.941 0.640 1.327
size - phenology 0.347 0.083 0.626
size - physio 0.661 0.476 0.865
size - response 0.513 0.026 1.174
otherLH - growth 0.796 0.490 1.188
otherLH - phenology 0.202 -0.067 0.481
otherLH - physio 0.517 0.318 0.725
otherLH - response 0.369 -0.118 1.027
growth - phenology -0.594 -1.054 -0.199
growth - physio -0.279 -0.699 0.084
growth - response -0.427 -1.053 0.328
phenology - physio 0.314 -0.027 0.645
phenology - response 0.166 -0.393 0.880
physio - response -0.148 -0.682 0.533

14.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", "m05_orchard_combined.png")
)

14.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 trait-type level from Rdata/summaries/emmeans_contrasts_cache.rds (emmeans::emmeans(fit, ~ trait_type, 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 <- "m05"
model_file <- "m05_ls_trait_type"
moderator <- "trait_type"
moderator_label <- "Trait type"
label_map <- c("behaviour" = "Behaviour", "growth" = "Growth", "otherLH" = "Other life history", "othermorphology" = "Other morphology", "phenology" = "Phenology", "physio" = "Physiology", "response" = "Response (performance ratio)", "size" = "Body size")

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