Code
# options to customize chunk outputs
knitr::opts_chunk$set(
tidy.opts = list(width.cutoff = 65),
tidy = TRUE,
message = FALSE
)Owl facial disc evolution
Source code and data found at https://github.com/maRce10/owl_vocalization_comparative_analysis
# options to customize chunk outputs
knitr::opts_chunk$set(
tidy.opts = list(width.cutoff = 65),
tidy = TRUE,
message = FALSE
)
flowchart LR
A[PCA on\nsong features] --> B(Define DAG)
B --> C("Fit phylogenetic\nSEM")
C --> D(Model summary)
style A fill:#382A5433
style B fill:#395D9C33
style C fill:#3497A933
style D fill:#60CEAC33
# knitr is require for creating html/pdf/word reports formatR is
# used for soft-wrapping code
# install/ load packages
sketchy::load_packages(packages = c("ggplot2", "viridis", "dagitty",
"ggdag", "brms", "brmsish", "rstan", "ggraph", "ggparty", "rncl",
"ape", "ggtext", "phangorn", "corrplot"))
# set theme globally
theme_set(theme_classic(base_size = 20))
get_stable_loadings <- function(pca, pcs = 4, B = 1000, cum_threshold = 0.5,
freq_threshold = 0.75, seed = 123) {
set.seed(seed)
# Reconstruct centered data
X <- pca$x %*% t(pca$rotation)
p <- ncol(pca$rotation)
orig_rot <- pca$rotation[, 1:pcs]
boot_loadings <- array(NA, dim = c(p, pcs, B))
# ----------------------------- Bootstrap PCA
# -----------------------------
for (b in 1:B) {
idx <- sample(1:nrow(X), replace = TRUE)
Xb <- X[idx, ]
pca_b <- prcomp(Xb, scale. = TRUE)
rot_b <- pca_b$rotation[, 1:pcs]
# Align signs
for (k in 1:pcs) {
if (cor(rot_b[, k], orig_rot[, k]) < 0) {
rot_b[, k] <- -rot_b[, k]
}
}
boot_loadings[, , b] <- rot_b
}
# ----------------------------- Summary statistics
# -----------------------------
abs_boot <- abs(boot_loadings)
mean_loading <- apply(abs_boot, c(1, 2), mean)
ci_lower <- apply(abs_boot, c(1, 2), quantile, 0.025)
ci_upper <- apply(abs_boot, c(1, 2), quantile, 0.975)
# ----------------------------- Stability frequency
# -----------------------------
top_freq <- matrix(0, nrow = p, ncol = pcs)
for (b in 1:B) {
for (k in 1:pcs) {
sq <- boot_loadings[, k, b]^2
ord <- order(sq, decreasing = TRUE)
cumprop <- cumsum(sq[ord])/sum(sq)
selected <- ord[cumprop <= cum_threshold]
selected <- c(selected, ord[min(which(cumprop >= cum_threshold))])
top_freq[selected, k] <- top_freq[selected, k] + 1
}
}
top_freq <- top_freq/B
stable <- (top_freq >= freq_threshold) & (ci_lower > 0 | ci_upper <
0)
# ----------------------------- Return tidy dataframe
# -----------------------------
data.frame(variable = rep(rownames(pca$rotation), pcs), ind = rep(colnames(pca$rotation)[1:pcs],
each = p), mean_loading = as.vector(mean_loading), ci_lower = as.vector(ci_lower),
ci_upper = as.vector(ci_upper), freq = as.vector(top_freq),
stable = as.vector(stable))
}
# higliht significant rows
highlight <- function(x, estimate_col = "Estimate", lower_col = "Q2.5",
upper_col = "Q97.5", strong_fill = "#E8602DFF", moderate_fill = "#FAC127FF",
weak_fill = "#FCFFA4FF", alpha = 0.5, digits = 3) {
## -------------------------------------------------- Row
## groups --------------------------------------------------
strong_rows <- which(x$pd > 0.95)
moderate_rows <- which(x$pd > 0.9 & x$pd <= 0.95)
weak_rows <- which(x$pd > 0.8 & x$pd <= 0.9)
## -------------------------------------------------- Build
## kable --------------------------------------------------
x_kbl <- kableExtra::kbl(x, row.names = TRUE, escape = FALSE,
format = "html", digits = digits)
## -------------------------------------------------- Apply
## row highlighting
## --------------------------------------------------
if (length(strong_rows) > 0) {
x_kbl <- kableExtra::row_spec(x_kbl, row = strong_rows, background = grDevices::adjustcolor(strong_fill,
alpha.f = alpha))
}
if (length(moderate_rows) > 0) {
x_kbl <- kableExtra::row_spec(x_kbl, row = moderate_rows,
background = grDevices::adjustcolor(moderate_fill, alpha.f = alpha))
}
if (length(weak_rows) > 0) {
x_kbl <- kableExtra::row_spec(x_kbl, row = weak_rows, background = grDevices::adjustcolor(weak_fill,
alpha.f = alpha))
}
## --------------------------------------------------
## Styling
## --------------------------------------------------
x_kbl <- kableExtra::kable_styling(x_kbl, bootstrap_options = c("striped",
"hover", "condensed", "responsive"), full_width = FALSE, font_size = 12)
return(x_kbl)
}dat <- readxl::read_excel("./data/processed/final_database_disc_comparative.xlsx",
na = "NA")
# remove rows with missing categorical variables (cannot
# currently be modeled with mi())
dat <- dat |>
filter(!is.na(cover2), !is.na(activity), !is.na(disc_pres))
trees <- read_nexus_phylo("./data/raw/owls206.nex")
# setdiff(dat$Species, trees[[1]]$tip.label)
# setdiff(trees[[1]]$tip.label, dat$Species)
trees <- drop.tip.multiPhylo(trees, setdiff(trees[[1]]$tip.label,
dat$Species))
# reorder data to match trees
dat <- dat[match(trees[[1]]$tip.label, dat$Species), ]
# verify matching all(trees[[1]]$tip.label == dat$Species)
## Data prep
# binary activity variable
dat$activity <- ifelse(dat$activity == 1, 1, 0)
# ordered habitat variable
dat$cover2 <- ordered(dat$cover2)
# binary facial disc response
dat$facial_disc <- ifelse(dat$disc_pres == 1, 1, 0)
# scale continuous predictors
dat$log_weight_sc <- scale(log(dat$weight))[, 1]
dat$latitude_sc <- scale(dat$latitude)[, 1]
dat$niche_width_sc <- scale(dat$niche_width)[, 1]
# sample 100 trees
set.seed(123)
subtree <- sample(trees, 30)
chains = 1
cores = 4
iters = 2000dag_disc_to_song <- dagify(
# background variables
habitat_structure ~ latitude,
niche_width ~ latitude,
activity_rhythm ~ body_size,
niche_width ~ body_size,
# ecological relationships
activity_rhythm ~ habitat_structure,
niche_width ~ activity_rhythm,
# ecology affects facial disc
facial_disc ~ habitat_structure,
facial_disc ~ activity_rhythm,
facial_disc ~ niche_width,
# ecology affects songs
song_structure ~ habitat_structure,
song_structure ~ activity_rhythm,
song_structure ~ niche_width,
# alternative hypothesis
song_structure ~ facial_disc,
labels = c(
latitude = "Latitude",
body_size = "Body\nweight/size",
habitat_structure = "Habitat\nstructure\n(tree coverage)",
activity_rhythm = "Activity rhythm",
niche_width = "Niche width",
song_structure = "Song\nstructure",
facial_disc = "Facial disc"
)
)
# tidy DAG
tidy_dag2 <- tidy_dagitty(dag_disc_to_song)
# node classes
tidy_dag2$data$type <- "predictor"
tidy_dag2$data$type[
tidy_dag2$data$name %in% c("latitude", "body_size")
] <- "background"
tidy_dag2$data$type[
tidy_dag2$data$name %in% c(
"habitat_structure",
"activity_rhythm",
"niche_width"
)
] <- "ecology"
tidy_dag2$data$type[
tidy_dag2$data$name %in% c("song_structure")
] <- "communication"
tidy_dag2$data$type[
tidy_dag2$data$name %in% c("facial_disc")
] <- "outcome"
# plot
ggplot(
tidy_dag2,
aes(
x = x,
y = y,
xend = xend,
yend = yend
)
) +
scale_color_viridis_d(
option = "G",
begin = 0.2,
end = 0.8,
alpha = 0.8
) +
geom_dag_edges_fan(
edge_color = mako(10, alpha = 0.5)[2],
arrow = grid::arrow(
length = grid::unit(10, "pt"),
type = "closed"
)
) +
geom_dag_point(
aes(color = type),
size = 24
) +
geom_dag_text(
aes(label = label),
color = "black",
size = 4
) +
theme_dag() +
ggtitle("DAG 2: Facial disc influences song structure") +
theme(
legend.position = "bottom"
)A phylogenetic structural equation model (SEMs) was fitted to evaluate competing hypotheses regarding the causal relationship between song traits and facial disc morphology in owls
Model structure:
[ \[\begin{split} \text{niche width} &\sim \text{latitude} + \text{body size} \\ \\ \text{activity rhythm} &\sim \text{monotonic(habitat structure)} + \text{body size} \end{split}\]]
[ \[\begin{split} \text{facial disc} &\sim \text{monotonic(habitat structure)} + \\ &\quad \text{monotonic(activity rhythm)} + \text{niche width} \\ \\ \text{song feature} &\sim \text{monotonic(habitat structure)} + \\ &\quad \text{monotonic(activity rhythm)} + \\ &\quad \text{niche width} + \text{facial disc} \end{split}\]]
Specifications:
Models were implemented as Bayesian piecewise structural equation models (SEMs) using the brms package
Phylogenetic relationships among species were incorporated as a random effect using a maximum-clade credibility phylogeny derived from the 1000 phylogenies available
Habitat structure (cover2) and activity rhythm were modeled as ordered predictors using monotonic effects (mo())
Niche width was modeled as a Gaussian response variable
Activity rhythm was modeled using a cumulative ordinal distribution
Facial disc presence was modeled as a Bernoulli response variable
Different models were fitted for each PCA-derived song features (e.g., frequency, temporal features) as the outcome variable, modeled as Gaussian responses
Latitude and log-transformed body size were included as background predictors to account for large-scale ecological and allometric effects
Residual correlations among submodels were fixed to zero using set_rescor(FALSE) to preserve the directed acyclic graph structure
Weakly informative priors were used for all model parameters
Continuous predictors were standardized (z-transformed) prior to analysis
Model fit and relative support among alternative causal models were evaluated using leave-one-out cross-validation (LOO) criteria
dat$song_rate <- dat$num_elms/dat$song_duration
## Acoustic features
features <- c("peak_frequency", "song_duration", "modulation", "umap_space_size",
"frequency_range", "num_elms", "elm_types", "song_rate")
## Correlation matrix Correlation matrix
cor_mat <- cor(dat[, features], use = "pairwise.complete.obs")
rownames(cor_mat) <- colnames(cor_mat) <- gsub("_", " ", gsub("elm",
"element", features))
corrplot.mixed(cor_mat, lower = "number", upper = "ellipse", tl.col = "black",
tl.pos = "lt", tl.cex = 1, number.cex = 0.7, diag = "n", lower.col = colorRampPalette(c("gray4",
"white", "gray4"))(200), upper.col = colorRampPalette(c(rep("#40498E",
3), "white", rep("#A0DFB9", 3)))(100))Warning in ind1:ind2: numerical expression has 34 elements: only the first used
## ======================================================
## Estimate MCC tree
## ======================================================
mcc_tree <- maxCladeCred(subtree)
## ======================================================
## Build phylogenetic covariance matrix
## ======================================================
vcv.phylo <- ape::vcv.phylo(
mcc_tree,
corr = TRUE
)
## ======================================================
## Acoustic variables
## ======================================================
dat$peak_frequency_sc <- scale(dat$peak_frequency)
dat$song_duration_sc <- scale(dat$song_duration)
dat$umap_space_size_sc <- scale(dat$umap_space_size)
dat$frequency_range_sc <- scale(dat$frequency_range)
dat$song_rate_sc <- scale(dat$song_rate)
dat$num_elms_sc <- scale(dat$num_elms)
features <- c(
"peak_frequency_sc",
"song_duration_sc",
"umap_space_size_sc",
"frequency_range_sc",
"song_rate_sc",
"num_elms_sc"
)
chains <- 1
cores <- 4
iters <- 4000
## ======================================================
## Loop over acoustic variables
## ======================================================
for (song_var in features) {
cat("\n========================\n")
cat("Running:", song_var, "\n")
cat("========================\n")
resp_name <- gsub("_", "", song_var)
## --------------------------------------------------
## Shared submodels
## --------------------------------------------------
bf_niche <- bf(
niche_width_sc | mi() ~
mi(latitude_sc) +
mi(log_weight_sc) +
activity +
(1 | gr(Species, cov = vcv.phylo))
)
bf_activity <- bf(
activity ~
mo(cover2) +
mi(log_weight_sc) +
(1 | gr(Species, cov = vcv.phylo)),
family = bernoulli()
)
bf_latitude <- bf(
latitude_sc | mi() ~ 1
)
bf_weight <- bf(
log_weight_sc | mi() ~ 1
)
## --------------------------------------------------
## Priors
## --------------------------------------------------
priors <- c(
## Niche width
prior(
normal(0, 1),
class = "b",
resp = "nichewidthsc"
),
prior(
normal(0, 1.5),
class = "Intercept",
resp = "nichewidthsc"
),
prior(
exponential(1),
class = "sigma",
resp = "nichewidthsc"
),
## Activity
prior(
normal(0, 1),
class = "b",
resp = "activity"
),
prior(
normal(0, 1.5),
class = "Intercept",
resp = "activity"
),
## Song trait
do.call(
brms::prior,
list(
"normal(0, 1)",
class = "b",
resp = resp_name
)
),
do.call(
brms::prior,
list(
"normal(0, 1.5)",
class = "Intercept",
resp = resp_name
)
),
do.call(
brms::prior,
list(
"exponential(1)",
class = "sigma",
resp = resp_name
)
),
## Facial disc
prior(
normal(0, 1),
class = "b",
resp = "facialdisc"
),
prior(
normal(0, 1.5),
class = "Intercept",
resp = "facialdisc"
)
)
## --------------------------------------------------
## Facial disc model
## --------------------------------------------------
bf_disc <- bf(
facial_disc ~
mo(cover2) +
activity +
mi(niche_width_sc) +
(1 | gr(Species, cov = vcv.phylo)),
family = bernoulli()
)
## --------------------------------------------------
## Song model
## Facial disc -> song
## --------------------------------------------------
bf_song <- bf(
stats::as.formula(
paste0(
song_var,
" | mi() ~ ",
"facial_disc + ",
"mo(cover2) + ",
"activity + ",
"mi(niche_width_sc) + ",
"(1 | gr(Species, cov = vcv.phylo))"
)
)
)
## --------------------------------------------------
## Model name
## --------------------------------------------------
fit_name <- paste0(
"sem_disc_to_song_",
gsub("_sc", "", song_var),
"_mcc"
)
cat("Running model:", fit_name, "\n")
## --------------------------------------------------
## Fit model
## --------------------------------------------------
fit <- brm(
bf_latitude +
bf_weight +
bf_niche +
bf_activity +
bf_disc +
bf_song +
set_rescor(FALSE),
data = dat,
data2 = list(
vcv.phylo = vcv.phylo
),
prior = priors,
chains = chains,
cores = cores,
iter = iters,
backend = "cmdstanr",
control = list(
adapt_delta = 0.99,
max_treedepth = 15
),
file = paste0(
"./data/processed/fits/",
fit_name
),
file_refit = "always"
)
## --------------------------------------------------
## Complete cases for LOO
## --------------------------------------------------
newdata_loo <- dat[
complete.cases(
dat$niche_width_sc,
dat[[song_var]],
dat$facial_disc,
dat$activity
),
]
## --------------------------------------------------
## Add LOO
## --------------------------------------------------
fit <- add_criterion(
fit,
criterion = "loo",
newdata = newdata_loo
)
## --------------------------------------------------
## Save model
## --------------------------------------------------
saveRDS(
fit,
file = paste0(
"./data/processed/fits/",
fit_name,
".rds"
)
)
rm(fit)
gc()
}The following table summarizes the fixed effects estimates for each of the song feature models (song -> disc and disc -> song). Estimates are on the log-odds scale, and “significant” effects (95% credible intervals not overlapping zero) are highlighted.
Column names:
Color code:
## ====================================================== Empty
## dataframe
## ======================================================
effects_df <- data.frame()
## ====================================================== Models
## ======================================================
fls <- list.files("./data/processed/fits/", pattern = "^sem_disc_to_song_.*_mcc\\.rds$",
full.names = TRUE)
features <- c("peak_frequency", "song_duration", "umap_space_size",
"frequency_range", "song_rate", "num_elms")
fls <- fls[grepl(paste(features, collapse = "|"), fls)]
## ======================================================
## Extract effects
## ======================================================
for (fl in fls) {
fit <- readRDS(fl)
fe <- brms::fixef(fit, summary = TRUE, probs = c(0.025, 0.975))
fe <- as.data.frame(fe)
fe$model <- rownames(fe)
rownames(fe) <- NULL
fe <- fe[grep("Intercept", fe$model, invert = TRUE), ]
## ------------------------------------------ Split response
## / predictor ------------------------------------------
fe$response <- sub("^(.*?)_(.*)$", "\\1", fe$model)
fe$predictor <- sub("^(.*?)_(.*)$", "\\2", fe$model)
## ------------------------------------------ Acoustic
## feature ------------------------------------------
fe$song_feature_model <- gsub("_mcc\\.rds$", "", sapply(strsplit(basename(fl),
"_"), function(x) paste(x[5:(length(x) - 1)], collapse = "_")))
## ------------------------------------------ Posterior
## probability of direction
## ------------------------------------------
draws <- posterior::as_draws_df(fit)
fe$pd <- NA_real_
for (i in seq_len(nrow(fe))) {
param_candidates <- c(paste0("b_", fe$model[i]), paste0("bsp_",
fe$model[i]))
param <- param_candidates[param_candidates %in% names(draws)][1]
if (is.na(param)) {
next
}
samples <- draws[[param]]
fe$pd[i] <- max(mean(samples > 0), mean(samples < 0))
}
## ------------------------------------------ Keep columns
## ------------------------------------------
fe <- fe[, c("response", "predictor", "song_feature_model", "Estimate",
"Est.Error", "Q2.5", "Q97.5", "pd")]
effects_df <- rbind(effects_df, fe)
rm(fit, fe, draws)
gc()
}
## ======================================================
## Formatting
## ======================================================
effects_df <- effects_df[order(effects_df$predictor, effects_df$response,
effects_df$song_feature_model), ]
effects_df$`response ~ predictor` <- paste(effects_df$response, "~",
effects_df$predictor)
## ======================================================
## Ecological effects
## ======================================================
highlight(x = effects_df[!(grepl("frequency|song|space|elms", effects_df$predictor) |
grepl("frequency|song|space|elms", effects_df$response)), c("song_feature_model",
"response ~ predictor", "Estimate", "Q2.5", "Q97.5", "pd")])| song_feature_model | response ~ predictor | Estimate | Q2.5 | Q97.5 | pd | |
|---|---|---|---|---|---|---|
| 8 | frequency_range | facialdisc ~ activity | -0.422 | -1.741 | 0.852 | 0.734 |
| 81 | num_elms | facialdisc ~ activity | -0.414 | -1.824 | 0.942 | 0.726 |
| 82 | peak_frequency | facialdisc ~ activity | -0.386 | -1.690 | 0.998 | 0.715 |
| 83 | song_duration | facialdisc ~ activity | -0.396 | -1.837 | 0.997 | 0.713 |
| 84 | song_rate | facialdisc ~ activity | -0.436 | -1.788 | 0.914 | 0.739 |
| 85 | umap_space_size | facialdisc ~ activity | -0.415 | -1.773 | 0.957 | 0.726 |
| 7 | frequency_range | nichewidthsc ~ activity | 0.276 | -0.008 | 0.572 | 0.970 |
| 71 | num_elms | nichewidthsc ~ activity | 0.284 | -0.015 | 0.584 | 0.971 |
| 72 | peak_frequency | nichewidthsc ~ activity | 0.280 | -0.028 | 0.572 | 0.966 |
| 73 | song_duration | nichewidthsc ~ activity | 0.286 | -0.010 | 0.572 | 0.972 |
| 74 | song_rate | nichewidthsc ~ activity | 0.283 | -0.008 | 0.591 | 0.970 |
| 75 | umap_space_size | nichewidthsc ~ activity | 0.285 | 0.010 | 0.562 | 0.981 |
| 11 | frequency_range | nichewidthsc ~ milatitude_sc | 0.003 | -0.136 | 0.142 | 0.507 |
| 111 | num_elms | nichewidthsc ~ milatitude_sc | 0.004 | -0.137 | 0.142 | 0.518 |
| 112 | peak_frequency | nichewidthsc ~ milatitude_sc | 0.006 | -0.122 | 0.132 | 0.534 |
| 113 | song_duration | nichewidthsc ~ milatitude_sc | 0.003 | -0.137 | 0.137 | 0.514 |
| 114 | song_rate | nichewidthsc ~ milatitude_sc | 0.005 | -0.132 | 0.142 | 0.521 |
| 115 | umap_space_size | nichewidthsc ~ milatitude_sc | 0.002 | -0.130 | 0.131 | 0.521 |
| 14 | frequency_range | activity ~ milog_weight_sc | 0.010 | -0.639 | 0.683 | 0.501 |
| 141 | num_elms | activity ~ milog_weight_sc | 0.015 | -0.627 | 0.674 | 0.514 |
| 142 | peak_frequency | activity ~ milog_weight_sc | 0.015 | -0.619 | 0.654 | 0.518 |
| 143 | song_duration | activity ~ milog_weight_sc | 0.036 | -0.614 | 0.709 | 0.541 |
| 144 | song_rate | activity ~ milog_weight_sc | 0.023 | -0.704 | 0.694 | 0.525 |
| 145 | umap_space_size | activity ~ milog_weight_sc | 0.033 | -0.612 | 0.723 | 0.531 |
| 12 | frequency_range | nichewidthsc ~ milog_weight_sc | 0.309 | 0.111 | 0.509 | 1.000 |
| 121 | num_elms | nichewidthsc ~ milog_weight_sc | 0.309 | 0.118 | 0.505 | 0.999 |
| 122 | peak_frequency | nichewidthsc ~ milog_weight_sc | 0.308 | 0.121 | 0.501 | 1.000 |
| 123 | song_duration | nichewidthsc ~ milog_weight_sc | 0.309 | 0.114 | 0.505 | 1.000 |
| 124 | song_rate | nichewidthsc ~ milog_weight_sc | 0.309 | 0.111 | 0.494 | 0.998 |
| 125 | umap_space_size | nichewidthsc ~ milog_weight_sc | 0.313 | 0.133 | 0.505 | 1.000 |
| 16 | frequency_range | facialdisc ~ miniche_width_sc | 0.355 | -0.541 | 1.372 | 0.769 |
| 161 | num_elms | facialdisc ~ miniche_width_sc | 0.361 | -0.547 | 1.395 | 0.781 |
| 162 | peak_frequency | facialdisc ~ miniche_width_sc | 0.365 | -0.539 | 1.343 | 0.782 |
| 163 | song_duration | facialdisc ~ miniche_width_sc | 0.354 | -0.545 | 1.355 | 0.777 |
| 164 | song_rate | facialdisc ~ miniche_width_sc | 0.369 | -0.546 | 1.355 | 0.771 |
| 165 | umap_space_size | facialdisc ~ miniche_width_sc | 0.362 | -0.498 | 1.365 | 0.781 |
| 13 | frequency_range | activity ~ mocover2 | 0.790 | 0.147 | 1.531 | 0.994 |
| 131 | num_elms | activity ~ mocover2 | 0.801 | 0.131 | 1.589 | 0.994 |
| 132 | peak_frequency | activity ~ mocover2 | 0.773 | 0.117 | 1.544 | 0.990 |
| 133 | song_duration | activity ~ mocover2 | 0.789 | 0.169 | 1.560 | 0.996 |
| 134 | song_rate | activity ~ mocover2 | 0.794 | 0.174 | 1.531 | 0.995 |
| 135 | umap_space_size | activity ~ mocover2 | 0.773 | 0.126 | 1.518 | 0.992 |
| 15 | frequency_range | facialdisc ~ mocover2 | 1.266 | 0.248 | 2.426 | 0.990 |
| 151 | num_elms | facialdisc ~ mocover2 | 1.261 | 0.265 | 2.425 | 0.993 |
| 152 | peak_frequency | facialdisc ~ mocover2 | 1.278 | 0.264 | 2.426 | 0.993 |
| 153 | song_duration | facialdisc ~ mocover2 | 1.275 | 0.264 | 2.446 | 0.993 |
| 154 | song_rate | facialdisc ~ mocover2 | 1.273 | 0.269 | 2.424 | 0.992 |
| 155 | umap_space_size | facialdisc ~ mocover2 | 1.224 | 0.188 | 2.335 | 0.993 |
highlight(x = effects_df[grepl("frequency|song|space|elms", effects_df$predictor) |
grepl("frequency|song|space|elms", effects_df$response), c("song_feature_model",
"response ~ predictor", "Estimate", "Q2.5", "Q97.5", "pd")])| song_feature_model | response ~ predictor | Estimate | Q2.5 | Q97.5 | pd | |
|---|---|---|---|---|---|---|
| 10 | frequency_range | frequencyrangesc ~ activity | -0.058 | -0.360 | 0.243 | 0.656 |
| 101 | num_elms | numelmssc ~ activity | 0.073 | -0.229 | 0.362 | 0.685 |
| 102 | peak_frequency | peakfrequencysc ~ activity | -0.058 | -0.347 | 0.220 | 0.656 |
| 103 | song_duration | songdurationsc ~ activity | 0.109 | -0.227 | 0.438 | 0.743 |
| 104 | song_rate | songratesc ~ activity | -0.091 | -0.432 | 0.218 | 0.718 |
| 105 | umap_space_size | umapspacesizesc ~ activity | -0.120 | -0.458 | 0.209 | 0.766 |
| 9 | frequency_range | frequencyrangesc ~ facial_disc | 0.115 | -0.273 | 0.525 | 0.719 |
| 91 | num_elms | numelmssc ~ facial_disc | 0.274 | -0.146 | 0.687 | 0.904 |
| 92 | peak_frequency | peakfrequencysc ~ facial_disc | -0.057 | -0.429 | 0.343 | 0.633 |
| 93 | song_duration | songdurationsc ~ facial_disc | 0.343 | -0.004 | 0.704 | 0.974 |
| 94 | song_rate | songratesc ~ facial_disc | 0.287 | -0.103 | 0.686 | 0.921 |
| 95 | umap_space_size | umapspacesizesc ~ facial_disc | 0.048 | -0.317 | 0.412 | 0.600 |
| 18 | frequency_range | frequencyrangesc ~ miniche_width_sc | 0.007 | -0.141 | 0.162 | 0.529 |
| 181 | num_elms | numelmssc ~ miniche_width_sc | 0.020 | -0.128 | 0.161 | 0.614 |
| 182 | peak_frequency | peakfrequencysc ~ miniche_width_sc | 0.012 | -0.126 | 0.153 | 0.562 |
| 183 | song_duration | songdurationsc ~ miniche_width_sc | -0.029 | -0.190 | 0.142 | 0.653 |
| 184 | song_rate | songratesc ~ miniche_width_sc | -0.066 | -0.223 | 0.084 | 0.798 |
| 185 | umap_space_size | umapspacesizesc ~ miniche_width_sc | 0.004 | -0.161 | 0.163 | 0.512 |
| 17 | frequency_range | frequencyrangesc ~ mocover2 | -0.032 | -0.241 | 0.169 | 0.620 |
| 171 | num_elms | numelmssc ~ mocover2 | -0.055 | -0.292 | 0.154 | 0.681 |
| 172 | peak_frequency | peakfrequencysc ~ mocover2 | 0.010 | -0.203 | 0.219 | 0.549 |
| 173 | song_duration | songdurationsc ~ mocover2 | -0.054 | -0.326 | 0.188 | 0.622 |
| 174 | song_rate | songratesc ~ mocover2 | -0.047 | -0.267 | 0.166 | 0.667 |
| 175 | umap_space_size | umapspacesizesc ~ mocover2 | -0.119 | -0.351 | 0.100 | 0.852 |
Cells show the average standardized effect size estimated across SEM formulations for each predictor–response relationship, with tile color representing the posterior probability of direction (pd), black text indicating effects whose 95% credible intervals did not overlap zero, and gray cells representing relationships that were not included in the evaluated causal models.
plot_df <- effects_df
## Clean labels only
plot_df$predictor <- gsub("^mi", "", plot_df$predictor)
plot_df$predictor <- gsub("_sc$", "", plot_df$predictor)
plot_df$response <- gsub("^mi", "", plot_df$response)
plot_df$response <- gsub("_sc$", "", plot_df$response)
## Significant rows
## TRUE if 95% CI does not overlap 0
plot_df$sig <- (
plot_df$Q2.5 * plot_df$Q97.5
) > 0
## --------------------------------------------------
## Average repeated cells
## --------------------------------------------------
plot_df <- aggregate(
cbind(
Estimate,
pd,
sig
) ~ predictor + response,
data = plot_df,
FUN = mean,
na.rm = TRUE
)
## Recompute significance after averaging
plot_df$sig <- plot_df$sig > 0.5
## --------------------------------------------------
## Add missing combinations
## --------------------------------------------------
all_combos <- expand.grid(
predictor = unique(plot_df$predictor),
response = unique(plot_df$response)
)
plot_df <- merge(
all_combos,
plot_df,
by = c("predictor", "response"),
all.x = TRUE
)
## --------------------------------------------------
## Order levels
## --------------------------------------------------
predictor_order <- c(
"facial_disc",
"mocover2",
"activity",
"niche_width",
"log_weight",
"latitude",
"num_elms",
"peak_frequency",
"song_duration",
"song_rate",
"umap_space_size",
"frequency_range"
)
response_order <- c(
"facialdisc",
"activity",
"nichewidthsc",
"numelmssc",
"peakfrequencysc",
"songdurationsc",
"songratesc",
"umapspacesizesc",
"frequencyrangesc"
)
plot_df$predictor <- factor(
plot_df$predictor,
levels = predictor_order
)
plot_df$response <- factor(
plot_df$response,
levels = response_order
)
## --------------------------------------------------
## Plot
## --------------------------------------------------
ggplot(
plot_df,
aes(
x = predictor,
y = response
)
) +
## Gray background for missing combinations
geom_tile(
fill = "grey90",
color = "white",
linewidth = 0.7
) +
## Tiles for evaluated effects
geom_tile(
data = plot_df[!is.na(plot_df$pd), ],
aes(fill = pd),
color = "white",
linewidth = 0.7
) +
geom_text(
data = plot_df[!is.na(plot_df$Estimate), ],
aes(
label = round(Estimate, 2),
color = sig
),
size = 3,
fontface = "bold"
) +
scale_fill_gradientn(
colors = c(
"#FCFFA4FF",
"#FAC127FF",
"#E8602DFF"
),
limits = c(0.5, 1),
name = "Posterior\nprobability\nof direction"
) +
scale_x_discrete(
labels = c(
mocover2 = "Habitat\nstructure",
activity = "Activity\nrhythm",
facial_disc = "Facial\ndisc",
niche_width = "Niche\nwidth",
log_weight = "Body\nsize",
peak_frequency = "Peak\nfrequency",
song_duration = "Song\nduration",
umap_space_size = "Acoustic\nspace size",
frequency_range = "Frequency\nrange",
num_elms = "# of\nelements",
song_rate = "Song rate",
latitude = "Latitude"
)
) +
scale_y_discrete(
labels = c(
facialdisc = "Facial\ndisc",
activity = "Activity\nrhythm",
nichewidthsc = "Niche\nwidth",
peakfrequencysc = "Peak\nfrequency",
songdurationsc = "Song\nduration",
umapspacesizesc = "Acoustic\nspace size",
frequencyrangesc = "Frequency\nrange",
numelmssc = "# of elements",
songratesc = "Song rate"
)
) +
scale_color_manual(
values = c(
"TRUE" = "black",
"FALSE" = "grey70"
),
guide = "none"
) +
labs(
x = "Predictor",
y = "Response"
) +
theme_classic(base_size = 12) +
theme(
axis.text.x = element_text(
angle = 45,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "right"
):::
:::
─ Session info ───────────────────────────────────────────────────────────────
setting value
version R version 4.5.2 (2025-10-31)
os Ubuntu 22.04.5 LTS
system x86_64, linux-gnu
ui X11
language (EN)
collate en_US.UTF-8
ctype en_US.UTF-8
tz America/Costa_Rica
date 2026-06-12
pandoc 3.2 @ /usr/lib/rstudio/resources/app/bin/quarto/bin/tools/x86_64/ (via rmarkdown)
quarto 1.7.31 @ /usr/local/bin/quarto
─ Packages ───────────────────────────────────────────────────────────────────
package * version date (UTC) lib source
abind 1.4-8 2024-09-12 [1] CRAN (R 4.5.0)
ape * 5.8-1 2024-12-16 [1] CRAN (R 4.5.0)
arrayhelpers 1.1-0 2020-02-04 [1] CRAN (R 4.5.0)
backports 1.5.1 2026-04-03 [1] CRAN (R 4.5.2)
bayesplot 1.15.0 2025-12-12 [1] CRAN (R 4.5.2)
boot 1.3-32 2025-08-29 [4] CRAN (R 4.5.1)
bridgesampling 1.2-1 2025-11-19 [1] CRAN (R 4.5.0)
brms * 2.23.0 2025-09-09 [1] CRAN (R 4.5.0)
brmsish * 1.0.0 2026-01-06 [1] CRANs (R 4.5.2)
Brobdingnag 1.2-9 2022-10-19 [1] CRAN (R 4.5.0)
cachem 1.1.0 2024-05-16 [1] CRAN (R 4.5.0)
cellranger 1.1.0 2016-07-27 [3] CRAN (R 4.0.1)
checkmate 2.3.4 2026-02-03 [1] CRAN (R 4.5.2)
cli 3.6.6 2026-04-09 [1] CRAN (R 4.5.2)
cmdstanr 0.9.0 2025-08-21 [1] https://stan-dev.r-universe.dev (R 4.5.0)
coda 0.19-4.1 2024-01-31 [1] CRAN (R 4.5.0)
codetools 0.2-20 2024-03-31 [4] CRAN (R 4.5.0)
corrplot * 0.95 2024-10-14 [1] CRAN (R 4.5.0)
cowplot 1.2.0 2025-07-07 [1] CRAN (R 4.5.0)
crayon 1.5.3 2024-06-20 [1] CRAN (R 4.5.0)
curl 7.1.0 2026-04-22 [1] CRAN (R 4.5.2)
dagitty * 0.3-4 2023-12-07 [1] CRAN (R 4.5.0)
devtools 2.4.5 2022-10-11 [1] CRAN (R 4.5.0)
digest 0.6.39 2025-11-19 [1] CRAN (R 4.5.0)
distributional 0.5.0 2024-09-17 [1] CRAN (R 4.5.0)
dplyr 1.1.4 2023-11-17 [1] CRAN (R 4.5.0)
ellipsis 0.3.2 2021-04-29 [3] CRAN (R 4.1.1)
emmeans 1.11.1 2025-05-04 [3] CRAN (R 4.5.0)
estimability 1.5.1 2024-05-12 [3] CRAN (R 4.5.0)
evaluate 1.0.5 2025-08-27 [1] CRAN (R 4.5.0)
farver 2.1.2 2024-05-13 [1] CRAN (R 4.5.0)
fastmap 1.2.0 2024-05-15 [1] CRAN (R 4.5.0)
fastmatch 1.1-6 2024-12-23 [3] CRAN (R 4.5.0)
formatR 1.14 2023-01-17 [1] CRAN (R 4.5.0)
Formula 1.2-5 2023-02-24 [3] CRAN (R 4.5.0)
fs 2.1.0 2026-04-18 [1] CRAN (R 4.5.2)
generics 0.1.4 2025-05-09 [1] CRAN (R 4.5.0)
ggdag * 0.2.13 2024-07-22 [1] CRAN (R 4.5.0)
ggdist 3.3.3 2025-04-23 [1] CRAN (R 4.5.0)
ggforce 0.3.3 2021-03-05 [3] CRAN (R 4.1.1)
ggparty * 1.0.0.1 2025-07-10 [1] CRAN (R 4.5.2)
ggplot2 * 4.0.3 2026-04-22 [1] CRAN (R 4.5.2)
ggraph * 2.0.5 2021-02-23 [3] CRAN (R 4.1.1)
ggrepel 0.9.6 2024-09-07 [1] CRAN (R 4.5.0)
ggtext * 0.1.2 2022-09-16 [1] RSPM (R 4.5.0)
glue 1.8.1 2026-04-17 [1] CRAN (R 4.5.2)
graphlayouts 0.8.0 2022-01-03 [3] CRAN (R 4.1.2)
gridExtra 2.3 2017-09-09 [1] CRAN (R 4.5.0)
gridtext 0.1.5 2022-09-16 [1] RSPM (R 4.5.0)
gtable 0.3.6 2024-10-25 [1] CRAN (R 4.5.0)
hms 1.1.3 2023-03-21 [3] CRAN (R 4.5.0)
htmltools 0.5.9 2025-12-04 [1] CRAN (R 4.5.0)
htmlwidgets 1.6.4 2023-12-06 [1] RSPM (R 4.5.0)
httpuv 1.6.16 2025-04-16 [1] RSPM (R 4.5.0)
igraph 2.2.1 2025-10-27 [1] CRAN (R 4.5.0)
inline 0.3.21 2025-01-09 [1] CRAN (R 4.5.0)
inum 1.0-5 2023-03-09 [1] CRAN (R 4.5.0)
jsonlite 2.0.0 2025-03-27 [1] CRAN (R 4.5.0)
kableExtra 1.4.0 2024-01-24 [1] CRAN (R 4.5.0)
knitr 1.51 2025-12-20 [1] CRAN (R 4.5.2)
labeling 0.4.3 2023-08-29 [1] CRAN (R 4.5.0)
later 1.4.2 2025-04-08 [1] RSPM (R 4.5.0)
lattice 0.22-9 2026-02-09 [4] CRAN (R 4.5.2)
libcoin * 1.0-10 2023-09-27 [1] CRAN (R 4.5.0)
lifecycle 1.0.5 2026-01-08 [1] CRAN (R 4.5.2)
loo 2.9.0 2025-12-23 [1] CRAN (R 4.5.2)
magrittr 2.0.5 2026-04-04 [1] CRAN (R 4.5.2)
MASS 7.3-65 2025-02-28 [4] CRAN (R 4.4.3)
Matrix 1.7-4 2025-08-28 [4] CRAN (R 4.5.1)
matrixStats 1.5.0 2025-01-07 [1] CRAN (R 4.5.0)
memoise 2.0.1 2021-11-26 [1] CRAN (R 4.5.0)
mime 0.13 2025-03-17 [1] CRAN (R 4.5.0)
miniUI 0.1.2 2025-04-17 [3] CRAN (R 4.5.0)
multcomp 1.4-28 2025-01-29 [3] CRAN (R 4.5.0)
mvtnorm * 1.3-3 2025-01-10 [1] CRAN (R 4.5.0)
nlme 3.1-168 2025-03-31 [4] CRAN (R 4.4.3)
otel 0.2.0 2025-08-29 [1] CRAN (R 4.5.2)
packrat 0.9.3 2025-06-16 [1] CRAN (R 4.5.2)
partykit * 1.2-24 2025-05-02 [1] CRAN (R 4.5.0)
pbapply 1.7-4 2025-07-20 [1] CRAN (R 4.5.0)
phangorn * 2.12.1 2024-09-17 [1] CRAN (R 4.5.0)
pillar 1.11.1 2025-09-17 [1] CRAN (R 4.5.0)
pkgbuild 1.4.8 2025-05-26 [1] CRAN (R 4.5.0)
pkgconfig 2.0.3 2019-09-22 [1] CRAN (R 4.5.0)
pkgload 1.4.1 2025-09-23 [1] CRAN (R 4.5.0)
polyclip 1.10-7 2024-07-23 [1] CRAN (R 4.5.0)
posterior 1.6.1 2025-02-27 [1] CRAN (R 4.5.0)
prettyunits 1.2.0 2023-09-24 [3] CRAN (R 4.5.0)
processx 3.8.6 2025-02-21 [1] CRAN (R 4.5.0)
profvis 0.4.0 2024-09-20 [1] CRAN (R 4.5.0)
progress 1.2.3 2023-12-06 [3] CRAN (R 4.5.0)
promises 1.3.3 2025-05-29 [1] RSPM (R 4.5.0)
ps 1.9.1 2025-04-12 [1] CRAN (R 4.5.0)
purrr 1.2.0 2025-11-04 [1] CRAN (R 4.5.0)
quadprog 1.5-8 2019-11-20 [3] CRAN (R 4.0.1)
QuickJSR 1.8.1 2025-09-20 [1] CRAN (R 4.5.0)
R6 2.6.1 2025-02-15 [1] CRAN (R 4.5.0)
RColorBrewer 1.1-3 2022-04-03 [1] CRAN (R 4.5.0)
Rcpp * 1.1.1-1.1 2026-04-24 [1] CRAN (R 4.5.2)
RcppParallel 5.1.11-1 2025-08-27 [1] CRAN (R 4.5.0)
readxl 1.4.5 2025-03-07 [3] CRAN (R 4.5.0)
remotes 2.5.0 2024-03-17 [1] CRAN (R 4.5.0)
rlang 1.2.0 2026-04-06 [1] CRAN (R 4.5.2)
rmarkdown 2.31 2026-03-26 [1] CRAN (R 4.5.2)
rncl * 0.8.9 2026-01-21 [1] CRAN (R 4.5.2)
rpart 4.1.24 2025-01-07 [4] CRAN (R 4.4.2)
rstan * 2.32.7 2025-03-10 [1] CRAN (R 4.5.0)
rstantools 2.5.0 2025-09-01 [1] CRAN (R 4.5.0)
rstudioapi 0.17.1 2024-10-22 [1] CRAN (R 4.5.0)
S7 0.2.2 2026-04-22 [1] CRAN (R 4.5.2)
sandwich 3.1-1 2024-09-15 [3] CRAN (R 4.5.0)
scales 1.4.0 2025-04-24 [1] CRAN (R 4.5.0)
sessioninfo 1.2.3 2025-02-05 [3] CRAN (R 4.5.0)
shiny 1.10.0 2024-12-14 [1] CRAN (R 4.5.0)
sketchy 1.0.7 2026-01-28 [1] CRANs (R 4.5.2)
StanHeaders * 2.32.10 2024-07-15 [1] CRAN (R 4.5.0)
stringi 1.8.7 2025-03-27 [1] CRAN (R 4.5.0)
stringr 1.6.0 2025-11-04 [1] CRAN (R 4.5.0)
survival 3.8-6 2026-01-16 [4] CRAN (R 4.5.2)
svglite 2.2.2 2025-10-21 [1] CRAN (R 4.5.0)
svUnit 1.0.8 2025-08-26 [1] CRAN (R 4.5.0)
systemfonts 1.3.1 2025-10-01 [1] CRAN (R 4.5.0)
tensorA 0.36.2.1 2023-12-13 [1] CRAN (R 4.5.0)
textshaping 1.0.4 2025-10-10 [1] CRAN (R 4.5.0)
TH.data 1.1-3 2025-01-17 [3] CRAN (R 4.5.0)
tibble 3.3.0 2025-06-08 [1] RSPM (R 4.5.0)
tidybayes 3.0.7 2024-09-15 [1] CRAN (R 4.5.0)
tidygraph 1.2.0 2020-05-12 [3] CRAN (R 4.0.1)
tidyr 1.3.2 2025-12-19 [1] CRAN (R 4.5.2)
tidyselect 1.2.1 2024-03-11 [1] CRAN (R 4.5.0)
tweenr 2.0.3 2024-02-26 [3] CRAN (R 4.5.0)
urlchecker 1.0.1 2021-11-30 [1] CRAN (R 4.5.0)
usethis 3.1.0 2024-11-26 [3] CRAN (R 4.5.0)
V8 6.0.4 2025-06-04 [1] RSPM (R 4.5.0)
vctrs 0.7.3 2026-04-11 [1] CRAN (R 4.5.2)
viridis * 0.6.5 2024-01-29 [1] CRAN (R 4.5.0)
viridisLite * 0.4.3 2026-02-04 [1] CRAN (R 4.5.2)
withr 3.0.2 2024-10-28 [1] CRAN (R 4.5.0)
xaringanExtra 0.8.0 2024-05-19 [1] CRAN (R 4.5.0)
xfun 0.57 2026-03-20 [1] CRAN (R 4.5.2)
xml2 1.5.2 2026-01-17 [1] CRAN (R 4.5.2)
xtable 1.8-8 2026-02-22 [1] CRAN (R 4.5.2)
yaml 2.3.12 2025-12-10 [1] CRAN (R 4.5.2)
zoo 1.8-14 2025-04-10 [3] CRAN (R 4.5.0)
[1] /home/m/R/x86_64-pc-linux-gnu-library/4.5
[2] /usr/local/lib/R/site-library
[3] /usr/lib/R/site-library
[4] /usr/lib/R/library
* ── Packages attached to the search path.
──────────────────────────────────────────────────────────────────────────────