Code
# options to customize chunk outputs
knitr::opts_chunk$set(
tidy.opts = list(width.cutoff = 65),
tidy = TRUE,
message = FALSE
)Obtaining nature media with the R package suwo
Source code and data found at https://github.com/maRce10/suwo-paper
# options to customize chunk outputs
knitr::opts_chunk$set(
tidy.opts = list(width.cutoff = 65),
tidy = TRUE,
message = FALSE
)This analysis serves as the animal-behavior-research case study accompanying the manuscript “Obtaining nature media with the R package suwo”, illustrating how suwo-retrieved media can be carried into a full downstream analytical pipeline
Obtain and curate recordings of Bearded Bellbird (Procnias averano) and their associated metadata from multiple online repositories, using the R package suwo.
Quantify acoustic structure in Bearded Bellbird “bock” songs (Snow 1970).
Infer geographic variation in vocalizations, using recordings assembled with the R package suwo and its covariation with time, geographic distance and population structure
flowchart LR
A[Download data] --> B(Format data)
B --> C(Graphs)
C --> D('MFCCs<br/>PCA')
D --> E(Statistical model)
style A fill:#382A5433
style B fill:#395D9C33
style C fill:#3497A933
style D fill:#60CEAC33
style E fill:#60CEAC33
# if sketchy not installed install it
if (!require("sketchy")) {
install.packages("sketchy")
}
# install/ load packages
sketchy::load_packages(packages = c("knitr", "warbleR", "Rraven",
"suwo", "ggplot2", "viridis", "ggtext", "brms", "geosphere", "brmsish",
"ggcorrplot", "ggdist", "posterior", "bayestestR"))broken_stick_k() implements the PCA retention rule used in the pipeline below.
## ============================================================
## PC RETENTION: BROKEN-STICK MODEL (Frontier 1976; MacArthur
## 1957)
## ============================================================
broken_stick_k <- function(X) {
X <- as.matrix(X)
ev <- prcomp(X, scale. = TRUE)$sdev^2
p <- length(ev)
prop <- ev/sum(ev)
# expected proportion for component k under random division
# of variance
bs <- vapply(seq_len(p), function(k) sum(1/(k:p))/p, numeric(1))
above <- prop > bs
first_fail <- which(!above)[1]
k <- if (is.na(first_fail))
p else first_fail - 1L
list(k = max(1L, k), proportion = prop, expected = bs, cum_var = cumsum(prop))
}
dir_word <- function(row) if (row$Estimate > 0) "increased" else "decreased"
cred_word <- function(is_credible) if (is_credible) "a credible" else "no credible"The following code queries and downloads all available Bearded Bellbird recordings from 5 repositories:
# search species
# time process
init_time <- Sys.time()
options(suwo_species = "Procnias averano", suwo_format = "sound")
mac <- query_macaulay(path = tempdir())
xc <- query_xenocanto()
wa <- query_wikiaves()
inat <- query_inaturalist()
gbif <- query_gbif()
end_time <- Sys.time()
dura <- end_time - init_time
# merge data
dat <- merge_metadata(wa, inat, gbif, xc, mac)
# find duplicates and exclude them
dat <- find_duplicates(dat)
dedup_dat <- remove_duplicates(dat)
# download all files first
dm <- download_media(dedup_dat, path = "./data/raw/p_averano_recordings",
cores = 10)
# save metadata
write.csv(dm, "./data/processed/p_averano_recordings_metadata.csv",
row.names = FALSE)
# now look again for duplicates using file size and user name as
# criteria, and remove them
dm <- find_duplicates(metadata = dm, criteria = paste("user_name > 0.7",
"file_size > 0.99", sep = " & "))
dedup_dm <- remove_duplicates(dm)
dm <- download_media(dedup_dat, path = "./data/raw/p_averano_recordings",
cores = 10)
dedup_dm <- dm[!duplicated(dm$duplicate_group) | is.na(dm$duplicate_group),
]
duplicates <- list.files("./data/raw/p_averano_recordings")[!list.files("./data/raw/p_averano_recordings") %in%
dedup_dm$downloaded_file_name]
unlink(file.path("./data/raw/p_averano_recordings", duplicates))
dedup_dm <- download_media(dedup_dm, path = "./data/raw/p_averano_recordings",
cores = 23)
write.csv(dedup_dm, "./data/processed/p_averano_recordings_metadata.csv",
row.names = FALSE)txts <- list.files("/home/m/Insync/marceloa27@gmail.com/Google Drive/grabaciones_para_ordenar/Anotaciones/Selecciones/",
full.names = TRUE)
# file.copy(from = txts, to =
# './data/raw/p_averano_annotations/', overwrite = TRUE)
anns <- imp_raven(path = "./data/raw/p_averano_annotations/", warbler.format = TRUE,
name.from.file = TRUE, ext.case = "lower", all.data = TRUE)
ncol(anns)
anns$sound.files <- NULL
sfs <- gsub(".Band.Limited.Energy.Detector.wav|.Table1.wav", ".wav",
anns$sound.files)
anns$sound.files <- sfs
# head(anns)
anns <- check_sels(anns, path = "./data/raw/p_averano_recordings",
fix.selec = TRUE)metadata <- read.csv("./data/processed/p_averano_recordings_metadata.csv", stringsAsFactors = FALSE)
metadata <- metadata[!is.na(metadata$downloaded_file_name), ]
# convert all to .wav
metadata$downloaded_file_name <- gsub(".mp3$|.m4a$", ".wav", metadata$downloaded_file_name)
# all(unique(anns$sound.files) %in% metadata$downloaded_file_name)
metadata <- metadata[metadata$downloaded_file_name %in% unique(anns$sound.files), ]
metadata$type <- sapply(metadata$downloaded_file_name, function(x){
out <- paste(unique(na.omit(anns$Tipo[anns$sound.files == x])), collapse = "-")
if (length(out) == 0) {
out <- NA
}
return(out)
})
# add lat lon
localidades <- data.frame(
archivo = c(
"Cumaca Valley",
"Jaqueira/PE",
"Guaramiranga/CE",
"Pacatuba/CE",
"Maranguape/CE",
"São Luís/MA",
"Caxias/MA",
"FORA DO BRASIL/EX",
"Dianópolis/TO",
"Araguaína/TO",
"Fazenda Nazaret, estado do Piauí"
),
Latitude = c(
10.7000, # Cumaca Valley, Trinidad y Tobago
-8.9967, # Jaqueira/PE
-4.2725, # Guaramiranga/CE
-3.9822, # Pacatuba/CE
-3.8908, # Maranguape/CE
-2.5297, # São Luís/MA
-4.8592, # Caxias/MA
NA, # FORA DO BRASIL/EX - no es una localidad real
-11.6236, # Dianópolis/TO
-7.1911, # Araguaína/TO
-9.3353 # Fazenda Nazaré, PI (confianza media)
),
Longitude = c(
-61.1500,
-35.7014,
-38.9328,
-38.6206,
-38.6844,
-44.3028,
-43.3567,
NA,
-46.8228,
-48.2072,
-45.5725
)
)
for (i in which(is.na(metadata$latitude))){
metadata$latitude[i] <- localidades$Latitude[localidades$archivo == metadata$locality[i]]
metadata$longitude[i] <- localidades$Longitude[localidades$archivo == metadata$locality[i]]
}
metadata$population <- "non_class"
# brazil population
metadata$population <- ifelse(grepl("razil", metadata$country) & is.na(metadata$longitude), "Brazil", metadata$population)
metadata$population <- ifelse(metadata$latitude <= -1.407 & metadata$longitude < -2.63, "Brazil", metadata$population)
# trinidad and tobago
metadata$population <- ifelse(grepl("rinidad", metadata$country), "Trinidad and Tobago", metadata$population)
metadata$population <- ifelse(metadata$latitude > 9.67 & metadata$longitude > -62.01 & metadata$population == "non_class", "Trinidad and Tobago", metadata$population)
# venezuela SE
metadata$population <- ifelse(grepl("non_class", metadata$population) & metadata$latitude < 7.56, "Venezuela SE", metadata$population)
# venezuela SE
metadata$population <- ifelse(grepl("non_class", metadata$population) & metadata$latitude > 7.56, "Venezuela N", metadata$population)
# refill brazil
metadata$population <- ifelse(is.na(metadata$population) & metadata$country == "Brazil", "Brazil", metadata$population)
anns$population <- sapply(anns$sound.files, function(x){
pop <- metadata$population[metadata$downloaded_file_name == x]
if (length(pop) == 0) {
pop <- NA
}
return(pop)
})
table(anns$population, anns$Tipo)
anns$latitude <- sapply(anns$sound.files, function(x){
lat <- metadata$latitude[metadata$downloaded_file_name == x]
if (length(lat) == 0) {
lat <- NA
}
return(lat)
})
anns$longitude <- sapply(anns$sound.files, function(x){
lon <- metadata$longitude[metadata$downloaded_file_name == x]
if (length(lon) == 0) {
lon <- NA
}
return(lon)
})
anns$locality <- sapply(anns$sound.files, function(x){
loc <- metadata$locality[metadata$downloaded_file_name == x]
if (length(loc) == 0) {
loc <- NA
}
return(loc)
})
anns$year <- sapply(anns$sound.files, function(x){
yr <- metadata$date[metadata$downloaded_file_name == x]
yr <- substring(yr, 1, 4)
if (length(yr) == 0) {
yr <- NA
}
return(yr)
})
unique(anns$locality[is.na(anns$latitude)])
est <- selection_table(anns, path = "./data/raw/p_averano_recordings", extended = TRUE)
est$year <- anns$year
attr(est, "metadata") <- metadata
saveRDS(est, "./data/processed/p_averano_extended_selection_table.rds")est <- readRDS("./data/processed/p_averano_extended_selection_table.rds")
est$orig.sound.files <- attr(est, "check.res")$orig.sound.files
metadata_est <- attr(est, "metadata")
map_locations(metadata_est, tags = c("latitude", "longitude", "population"),
by = "population", palette = function(n) viridis::mako(n, begin = 0.2,
end = 0.75))Acoustic structure for “bock” songs was summarized with a set of MFCC-derived statistics (mfcc_stats(), warbleR) computed over the frequency band spanned by the annotated selections, then reduced with a principal component analysis (PCA) on the standardized statistics. Pairwise Euclidean distance between individuals in this reduced space is the acoustic-distance response used in every model below, so the number of components retained here directly determines what the response variable is, not just how it is summarized.
Why PCA, and why the number of retained components matters more than it might seem. Because PCA is an orthonormal rotation, Euclidean distance computed on all components is mathematically identical to Euclidean distance on the standardized input features — rotating axes does not change distances between points. Truncating to fewer components is therefore not a data-reduction convenience but a deliberate low-rank denoising step (Eckart and Young 1936): components beyond some point are assumed to carry more measurement noise than acoustic signal, and dropping them removes that noise from the response. The question this raises is not “what fraction of variance should I keep” (there is no principled answer to that) but “at which component does structure give way to noise.”
Why the broken-stick rule, specifically. A fixed cumulative-variance threshold (70%, 95%, …) is a common convention but has no sampling theory behind it and is explicitly discouraged as a stopping rule (Cangelosi and Goriely 2007) — the threshold itself is arbitrary. The eigenvalue-greater-than-one (“Kaiser”) criterion is the rule most frequently reported in avian bioacoustic studies, but it is documented to over-retain components substantially, and the number it retains scales with the number of input variables rather than with genuine structure in the data (Zwick and Velicer 1986; Jackson 1993) — a real liability here, since the MFCC statistic matrix has many columns. The broken-stick model instead compares each component’s observed proportion of variance against the proportion it would be expected to carry if the total variance were divided at random among all components (as if breaking a stick into random pieces); a component is retained only if it exceeds that null expectation, and retention proceeds sequentially until the first component that fails to clear its own threshold. This was the best-performing stopping rule in Jackson’s (1993, Ecology 74:2204–2214) simulation comparison across ecological datasets, and it is also the most conservative (parsimonious) of the widely used criteria — the right direction to err in here, specifically because the acoustic dissimilarity is the response variable rather than a predictor: every additional noise component retained adds variance to the response, which widens posterior intervals and reduces power to detect genuine geographic or temporal structure downstream. Retaining too few components risks discarding real signal; retaining too many risks diluting the response with noise. The broken-stick rule was chosen because, per the simulation evidence above, it is the criterion best calibrated to that specific trade-off, not because it is the most common choice in the literature.
pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
pca_file <- paste0("./data/processed/pca_song_type_1", fit_suffix,
".rds")
est <- readRDS("./data/processed/p_averano_extended_selection_table.rds")
est$orig.sound.files <- attr(est, "check.res")$orig.sound.files
est_tp <- est[est$Tipo == "1", ]
## ---- PCA on standardized MFCC statistics
## --------------------------------
mfcc_tp <- mfcc_stats(est_tp, bp = c(min(est_tp$bottom.freq), max(est_tp$top.freq)))
mfcc_feat <- mfcc_tp[, c(-1, -2)]
pca_tp <- prcomp(mfcc_feat, scale. = TRUE)
cum_var <- summary(pca_tp)$importance[3, ]
## PC retention via the broken-stick rule (see Custom functions
## above for the rationale). pc_rule only changes the
## cached-file path (fit_suffix) at this point, not which rule
## is applied -- broken_stick_k() runs unconditionally.
n_pcs <- broken_stick_k(mfcc_feat)$k
pcs <- paste0("PC", seq_len(n_pcs))
pc_scores <- as.data.frame(pca_tp$x[, seq_len(n_pcs), drop = FALSE])
colnames(pc_scores) <- pcs
## fix_extended_selection_table() re-attaches proper
## extended_selection_table attributes/wave-object references
## after cbind() adds plain columns -- needed so downstream
## functions that expect a genuine extended_selection_ table
## (e.g. sig2noise(), single-bracket subsetting) keep working
## correctly.
est_tp2 <- est_tp
est_tp <- cbind(est_tp, pc_scores)
est_tp <- fix_extended_selection_table(X = est_tp, Y = est_tp2)
## saved so the PCA never needs to be recomputed downstream: the
## diagnostic section below and the Model fitting section both
## read this file rather than re-running mfcc_stats()/prcomp() a
## second and third time (which previously risked the two
## silently drifting apart).
saveRDS(list(est_tp = est_tp, pcs = pcs, n_pcs = n_pcs, cum_var = cum_var),
pca_file)pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
pca_file <- paste0("./data/processed/pca_song_type_1", fit_suffix,
".rds")
pca_out <- readRDS(pca_file)The first 14 principal components were retained, which together explain 58.4% of the variance in the standardized MFCC statistics. The retained components are used to compute pairwise Euclidean distances between individuals, which is the acoustic-distance response variable used in all models below.`
Before fitting the model, the pairwise predictors are inspected directly: the shape of the geographic-distance distribution (including how the zero-distance pairs created by shared-locality coordinates are handled), and the correlation structure among predictors — particularly between same_population and geo_distance_sc, and between time_separation and geographic distance. These checks motivate two choices used throughout the modeling pipeline below: the log-transform and additive constant applied to geographic distance, and the decision to include same_population and geo_distance_sc together in the same model despite their moderate correlation.
pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
pca_file <- paste0("./data/processed/pca_song_type_1", fit_suffix,
".rds")
res_file <- paste0("./data/processed/results_song_type_1", fit_suffix,
".rds")
dist_long_file <- paste0("./data/processed/dist_long_song_type_1",
fit_suffix, ".rds")
pca_out <- readRDS(pca_file)
est_tp <- pca_out$est_tp
pcs <- pca_out$pcs
n_pcs <- pca_out$n_pcs
cum_var <- pca_out$cum_var
## PCA recap -- printed here, immediately before any regression
## model is fitted (full detail is in the 'Principal component
## analysis' section).
cat("==== PCA recap ====\n")==== PCA recap ====
cat("PCs retained:", n_pcs, "| cumulative variance explained:", round(cum_var[n_pcs] *
100, 1), "%\n\n")PCs retained: 14 | cumulative variance explained: 58.4 %
## ---- 1b. Signal-to-noise ratio per selection
## --------------------------- Recording quality, used below as
## a nuisance covariate (see Statistical analysis text). mar
## sets the margin adjacent to each selection over which
## background noise is sampled and must not overlap a
## neighbouring signal -- CHECK against this dataset with
## snr_spectrograms() before trusting the value below, which is
## a starting point (warbleR tutorial default), not a tuned
## setting. bp matches mfcc_stats() above so SNR is measured
## over the same frequency band as the acoustic features it is
## meant to help account for.
snr_tp <- sig2noise(est_tp, mar = 0.05, bp = c(min(est_tp$bottom.freq),
max(est_tp$top.freq)))
est_tp$SNR <- snr_tp$SNR
## ---- 2. Aggregate by individual (mean PCs / SNR per
## orig.sound.files) --
agg_pcs <- aggregate(est_tp[, pcs, drop = FALSE], by = list(orig.sound.files = est_tp$orig.sound.files),
FUN = mean)
agg_snr <- aggregate(est_tp$SNR, by = list(orig.sound.files = est_tp$orig.sound.files),
FUN = mean)
names(agg_snr)[ncol(agg_snr)] <- "SNR"
agg_meta <- est_tp[!duplicated(est_tp$orig.sound.files), c("orig.sound.files",
"population", "latitude", "longitude", "year")]
agg_indiv <- merge(agg_pcs, agg_meta, by = "orig.sound.files")
agg_indiv <- merge(agg_indiv, agg_snr, by = "orig.sound.files")
rownames(agg_indiv) <- agg_indiv$orig.sound.files
## year is built with substring() upstream and is therefore
## character; dist() needs it numeric.
agg_indiv$year <- as.numeric(as.character(agg_indiv$year))
## ---- 3. Pairwise distance matrices
## -------------------------------------
dist_acoustic_mat <- as.matrix(dist(agg_indiv[, pcs, drop = FALSE],
method = "euclidean"))
coords <- as.matrix(agg_indiv[, c("longitude", "latitude")])
dist_geo_mat <- geosphere::distm(coords, fun = distHaversine)/1000
dist_time_mat <- as.matrix(dist(agg_indiv[, "year", drop = FALSE],
method = "euclidean"))
## pairwise minimum SNR: a pair's acoustic dissimilarity is
## corrupted by whichever of its two recordings is noisier, not
## by their average, so the predictor takes the pair's minimum
## rather than the pair's mean.
snr_pair_mat <- outer(agg_indiv$SNR, agg_indiv$SNR, pmin)
rownames(dist_acoustic_mat) <- colnames(dist_acoustic_mat) <- agg_indiv$orig.sound.files
rownames(dist_geo_mat) <- colnames(dist_geo_mat) <- agg_indiv$orig.sound.files
rownames(dist_time_mat) <- colnames(dist_time_mat) <- agg_indiv$orig.sound.files
rownames(snr_pair_mat) <- colnames(snr_pair_mat) <- agg_indiv$orig.sound.files
## ---- 4. Long format (upper triangle)
## -----------------------------------
idx <- which(upper.tri(dist_acoustic_mat), arr.ind = TRUE)
dist_long <- data.frame(individual1 = rownames(dist_acoustic_mat)[idx[,
1]], individual2 = colnames(dist_acoustic_mat)[idx[, 2]], acoustic_dissimilarity = dist_acoustic_mat[idx],
geo_distance = dist_geo_mat[idx], time_separation = dist_time_mat[idx],
min_snr = snr_pair_mat[idx])
pop_lookup <- setNames(agg_indiv$population, agg_indiv$orig.sound.files)
dist_long$population1 <- pop_lookup[dist_long$individual1]
dist_long$population2 <- pop_lookup[dist_long$individual2]
dist_long$same_population <- as.factor(as.integer(dist_long$population1 ==
dist_long$population2))
dist_long$individual1 <- factor(dist_long$individual1)
dist_long$individual2 <- factor(dist_long$individual2)
dist_long$population1 <- factor(dist_long$population1)
dist_long$population2 <- factor(dist_long$population2)
## ---- 5. Transformations and scaling
## -------------------------------------
dist_long$acoustic_dissimilarity_sc <- scale(dist_long$acoustic_dissimilarity)[,
1]
geo_const <- unname(quantile(dist_long$geo_distance, 0.25, na.rm = TRUE))
dist_long$log_geo_distance <- log(dist_long$geo_distance + geo_const)
## Divide by 2 SD, not 1 SD (Gelman 2008): puts this continuous
## predictor's coefficient on the same 'typical full swing'
## scale as the untouched binary same_population (0/1)
## predictor, so binary-vs- continuous effect-size comparisons
## in the results text are valid.
dist_long$geo_distance_sc <- scale(dist_long$log_geo_distance)[, 1]/2
cat("geo_const (km):", signif(geo_const, 3), "| zero-distance pairs:",
sum(dist_long$geo_distance == 0), "(", round(100 * mean(dist_long$geo_distance ==
0), 1), "% )\n")geo_const (km): 529 | zero-distance pairs: NA ( NA % )
## same 2-SD convention applied consistently to every continuous
## predictor
dist_long$time_separation_sc <- scale(dist_long$time_separation)[,
1]/2
## 2-SD scaled, not log-transformed: unlike geographic distance,
## min_snr (dB) has no comparable right-skew / zero-mass issue
## here -- revisit if the observed distribution of min_snr turns
## out to be skewed once run.
dist_long$min_snr_sc <- scale(dist_long$min_snr)[, 1]/2
## ---- 5b. Filter to complete cases
## --------------------------------------- Every variable used
## in the model is filtered together here, once, so that the
## fitted model and every downstream check use exactly the same
## set of pairs.
model_vars <- c("acoustic_dissimilarity_sc", "same_population", "geo_distance_sc",
"time_separation_sc", "min_snr_sc", "population1", "population2",
"individual1", "individual2")
n_before <- nrow(dist_long)
dist_long <- dist_long[complete.cases(dist_long[, model_vars]), ]
n_after <- nrow(dist_long)
if (n_after < n_before) {
cat("rows dropped due to NAs:", n_before - n_after, "of", n_before,
"(", round(100 * (n_before - n_after)/n_before, 1), "% )\n")
}rows dropped due to NAs: 466 of 7021 ( 6.6 % )
## mm(individual1, individual2) and mm(population1, population2)
## require both columns of each pair to share exactly the same
## set of levels.
individual_levels <- union(as.character(dist_long$individual1), as.character(dist_long$individual2))
dist_long$individual1 <- factor(as.character(dist_long$individual1),
levels = individual_levels)
dist_long$individual2 <- factor(as.character(dist_long$individual2),
levels = individual_levels)
population_levels <- union(as.character(dist_long$population1), as.character(dist_long$population2))
dist_long$population1 <- factor(as.character(dist_long$population1),
levels = population_levels)
dist_long$population2 <- factor(as.character(dist_long$population2),
levels = population_levels)
dist_long$same_population <- droplevels(dist_long$same_population)
saveRDS(dist_long, dist_long_file)pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
dist_long <- readRDS(paste0("./data/processed/dist_long_song_type_1",
fit_suffix, ".rds"))
dist_long$geo_km_adj <- exp(dist_long$log_geo_distance)
dist_long$acoustic_prop <- dist_long$acoustic_dissimilarity/max(dist_long$acoustic_dissimilarity,
na.rm = TRUE)
ggplot(dist_long, aes(x = geo_km_adj, fill = same_population)) + geom_histogram(bins = 50,
position = "identity", alpha = 0.6) + scale_x_log10() + scale_fill_viridis_d(begin = 0.2,
end = 0.75, labels = c("Different", "Same")) + labs(x = "Geographic distance (km, log scale)",
y = "Number of pairs", fill = "Population") + theme_classic()sp <- as.numeric(as.character(dist_long$same_population))
minpos <- min(dist_long$geo_distance[dist_long$geo_distance > 0],
na.rm = TRUE)
cat("pairs:", nrow(dist_long), "| individuals:", length(unique(c(as.character(dist_long$individual1),
as.character(dist_long$individual2)))), "\n")pairs: 6555 | individuals: 115
cat("geo_distance == 0:", sum(dist_long$geo_distance == 0), "(", round(100 *
mean(dist_long$geo_distance == 0), 1), "% )\n")geo_distance == 0: 386 ( 5.9 % )
cat("log_geo range:", round(range(dist_long$log_geo_distance), 2),
"\n")log_geo range: 6.27 8.5
cat("pairs in 100-1000 km gap:", sum(dist_long$geo_distance > 100 &
dist_long$geo_distance < 1000), "(", round(100 * mean(dist_long$geo_distance >
100 & dist_long$geo_distance < 1000), 1), "% )\n")pairs in 100-1000 km gap: 1858 ( 28.3 % )
cat("cor(log_geo, same_population):", round(cor(dist_long$log_geo_distance,
sp), 3), "\n")cor(log_geo, same_population): -0.783
cat("cor(time_separation, log_geo):", round(cor(dist_long$time_separation,
dist_long$log_geo_distance), 3), "\n")cor(time_separation, log_geo): -0.008
pos <- dist_long$geo_distance[dist_long$geo_distance > 0]
consts <- c(min(pos)/10, min(pos), quantile(dist_long$geo_distance,
0.25), 1)
names(consts) <- c("min/10 (previous)", "min", "P25 (HH20, used)",
"1 km")Collinearity among all predictors
pred_vars <- c("min_snr_sc", "same_population", "time_separation_sc",
"geo_distance_sc")
pred_df <- dist_long[, pred_vars]
pred_df$same_population <- as.numeric(as.character(pred_df$same_population))
corr_mat <- cor(pred_df, use = "pairwise.complete.obs")
cols <- mako(10, alpha = 0.8, begin = 0.2, end = 0.75)
ggcorrplot(corr_mat, type = "lower", lab = TRUE, lab_size = 4, colors = c(cols[1],
"white", cols[10]), title = "Pairwise predictor correlations")Variance inflation factors (VIFs) were computed for the fixed effects in the model to check for collinearity. The VIFs are interpreted as follows: values below 5 indicate low concern, values between 5 and 10 indicate moderate concern, and values above 10 indicate high concern. In this case, all VIFs are below 5, indicating that collinearity is not a major concern for the predictors in the model.
vif_mod <- lm(acoustic_dissimilarity_sc ~ min_snr_sc + same_population +
time_separation_sc + geo_distance_sc, data = dist_long)
vif_vals <- car::vif(vif_mod)
vif_df <- data.frame(term = names(vif_vals), vif = as.numeric(vif_vals))
## conventional rule-of-thumb thresholds: <5 low concern, 5-10
## moderate, >10 high. Some fields use stricter cutoffs (2.5 /
## 4); doesn't matter for your numbers specifically, since
## ~1.7-2.2 clears either convention.
vif_df$severity <- cut(vif_df$vif, breaks = c(0, 5, 10, Inf), labels = c("low",
"moderate", "high"))
ggplot(vif_df, aes(x = vif, y = reorder(term, vif), color = severity)) +
geom_vline(xintercept = c(5, 10), linetype = "dashed", color = "grey60") +
geom_segment(aes(x = 1, xend = vif, yend = term), linewidth = 1) +
geom_point(size = 3) + scale_color_manual(values = c(low = cols[1],
moderate = "orange", high = cols[10])) + labs(x = "Variance Inflation Factor",
y = NULL, color = "Collinearity") + theme_minimal(base_size = 13)To evaluate whether acoustic divergence among Bearded Bellbird “bock” songs is related to geographic distance, temporal separation between recordings, and population identity, we fitted a single Bayesian mixed-effects model on pairwise acoustic dissimilarities, accounting for the non-independence inherent to pairwise distance data.
Acoustic structure for “bock” songs was summarized with a principal component analysis (PCA) on standardized MFCC-derived acoustic parameters; see the Principal component analysis section above for the full rationale behind the retention rule used. PC scores were averaged across selections belonging to the same individual, and pairwise Euclidean distances in this reduced acoustic space were used as the response variable.
Pairwise geographic distance was calculated as the great-circle (haversine) distance between individuals, log-transformed prior to standardization to correct for the right-skewed distribution typical of pairwise spatial data (many nearby pairs, few very distant pairs). We use haversine great-circle distance because latitude/longitude are angles on a sphere rather than flat coordinates, and your populations span a wide enough latitude range that treating them as flat x/y would systematically distort distances in a way that correlates with the very population layout you’re testing. Because individuals recorded at the same locality were assigned identical coordinates, a small proportion of pairs have a geographic distance of exactly zero; following Hausdorf and Hennig (2020), the 25th percentile of all pairwise distances was added before log-transformation, rather than a constant derived from the smallest observed positive distance, which would place that point mass far into the lower tail of the predictor and give it disproportionate leverage on the estimated slope (see Check distances above). Pairwise temporal distance was calculated as the absolute difference in recording year. Both distance measures, along with the acoustic response, were standardized (z-scores) prior to modeling.
Recording quality was included as a nuisance covariate. In a pairwise-distance framework, a fixed recording quality specific effect does not cancel out of a pair’s distance but enters it as the difference between the two recordings’ conditions, so uneven quality across individuals is a potential source of spurious geographic or temporal signal rather than simply extra noise – a concern that applies with particular force to temporal separation, since older recordings are close to guaranteed to have used an older generation of equipment. Signal-to-noise ratio (SNR) was measured for every selection with sig2noise() (warbleR; Araya-Salas and Smith-Vidaurre 2017), over the same frequency band used for the MFCC statistics above, and averaged across selections belonging to the same individual. The covariate entering the model for each pair was the minimum, rather than the mean, of the two individuals’ SNR: a pair’s acoustic dissimilarity is corrupted by whichever recording is noisier, not by the pair’s average quality, so the minimum better reflects the measurement error affecting that specific comparison. SNR was standardized (z-score) prior to modeling.
The model was specified as:
\[ \begin{split} \text{acoustic dissimilarity}_{ij} &\sim \text{min SNR}_{ij} + \text{same population}_{ij} \\ &\quad + \text{geographic distance}_{ij} \times \text{temporal distance}_{ij} \\ &\quad + (1 \mid \text{mm(individual}_i,\text{individual}_j)) \\ &\quad + (1 \mid \text{mm(population}_i,\text{population}_j)) \end{split} \]
where:
\(\text{acoustic dissimilarity}_{ij}\) is the Euclidean distance between the songs of individuals \(i\) and \(j\) in the reduced PCA space.
\(\text{SNR}_{ij}\) is the standardized minimum signal-to-noise ratio between individuals \(i\) and \(j\), included as a nuisance covariate for recording quality.
\(\text{same population}_{ij}\) is a binary predictor indicating whether individuals \(i\) and \(j\) belong to the same population (1) or to different populations (0).
\(\text{geographic distance}_{ij}\) is the log-standardized geographic distance between individuals \(i\) and \(j\).
\(\text{temporal distance}_{ij}\) is the standardized difference in recording year between individuals \(i\) and \(j\).
mm(individual\(_i\), individual\(_j\)) is a multi-membership random intercept accounting for the repeated use of the same individuals across pairwise comparisons.
mm(population\(_i\), population\(_j\)) is a multi-membership random intercept accounting for the repeated use of the same populations across pairwise comparisons.
The interaction between geographic and temporal distance was included to model the occurrence of vocal divergence through time even within a small geographic range.
Model specifications
brms package.cmdstanr backend, with within-chain thread parallelization, 4 chains, 4 cores, and 4,000 iterations per chain.The pairwise dataset built and saved in Check distances above is loaded here, together with the priors described earlier, and used to fit two models: a prior-only version, used below for the prior predictive check, and the model fitted to the observed data. Both brm() calls are cached to disk via file =; file_refit = "on_change" ensures a stale cache is never silently reused if the priors, data, or formula change without the file name changing.
## dist_long is built once, in 'Check distances' above, and
## saved to disk there; loading it here (rather than rebuilding
## it, or relying on it still being in memory from an earlier
## chunk) guarantees the model is fitted to exactly the same
## pairwise dataset the diagnostics above were run on,
## regardless of the order chunks happen to be run in.
pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
pca_file <- paste0("./data/processed/pca_song_type_1", fit_suffix,
".rds")
res_file <- paste0("./data/processed/results_song_type_1", fit_suffix,
".rds")
pca_out <- readRDS(pca_file)
n_pcs <- pca_out$n_pcs
cum_var <- pca_out$cum_var
dist_long <- readRDS(paste0("./data/processed/dist_long_song_type_1",
fit_suffix, ".rds"))
## geo_const was already used to build geo_distance_sc inside
## dist_long above; recomputed here only so it can be stored in
## results_type1 for reporting, not because it affects the data
## in any way.
geo_const <- unname(quantile(dist_long$geo_distance, 0.25, na.rm = TRUE))
## Priors rescaled for standardized response and predictors.
## Response and predictors are z-scored (SD = 1), so |beta| > 1
## is near-impossible and random-effect SDs cannot plausibly
## exceed the total SD of the response. With only 4
## populations, the population-level sd is only weakly
## identified by the data and the prior does real work there.
## What these priors imply, before the data are seen, is checked
## directly in the 'Prior predictive check' section below.
priors <- c(prior(normal(0, 1), class = "Intercept"), prior(normal(0,
0.5), class = "b"), prior(exponential(2), class = "sd"), prior(exponential(1),
class = "sigma"))
fit_path <- function(name) paste0("./data/processed/fits/", name,
"_song_type_1", fit_suffix)
## ============================================================
## 6. PRIOR PREDICTIVE CHECK MODEL
## ============================================================
## sample_prior = 'only' draws parameters from the priors alone
## and ignores the observed response entirely; it still needs
## the predictor columns and formula/family to build the model
## matrix. See 'Prior predictive check' below for what this is
## diagnostic of.
mod_prior_only <- brm(acoustic_dissimilarity_sc ~ min_snr_sc + same_population +
time_separation_sc * geo_distance_sc + (1 | mm(individual1, individual2)) +
(1 | mm(population1, population2)), data = dist_long, family = gaussian(),
prior = priors, sample_prior = "only", chains = 4, cores = 4,
iter = 4000, backend = "cmdstanr", threads = threading(8), control = list(adapt_delta = 0.95,
max_treedepth = 15), file = fit_path("mod_prior_only"), file_refit = "on_change")
## ============================================================
## 7. THE MODEL
## ============================================================
## Every predictor implied by the study's hypotheses in one
## model, fitted to the observed data. See 'Statistical
## analysis' above for why this replaces a comparison across
## candidate models.
mod_interaction <- brm(acoustic_dissimilarity_sc ~ min_snr_sc + same_population +
time_separation_sc * geo_distance_sc + (1 | mm(individual1, individual2)) +
(1 | mm(population1, population2)), data = dist_long, family = gaussian(),
prior = priors, chains = 4, cores = 4, iter = 4000, backend = "cmdstanr",
threads = threading(8), control = list(adapt_delta = 0.95, max_treedepth = 15),
file = fit_path("mod_interaction"), file_refit = "on_change")
results_type1 <- list(n_pcs = n_pcs, pc_rule = pc_rule, var_explained = cum_var[n_pcs],
geo_const = geo_const, dist_long = dist_long, prior_check_model = mod_prior_only,
model = mod_interaction)
saveRDS(results_type1, res_file)Before looking at what the observed data say, we checked what the priors specified above imply on their own: parameter values were drawn from the priors and used to generate synthetic acoustic dissimilarities (sample_prior = "only" in brms), using the real predictor values but none of the information in the actual response. This step catches priors that look reasonable individually but combine, once passed through the full model, to imply an implausible range of outcomes – for example, if the fixed effects and random-effect SDs jointly implied standardized acoustic dissimilarities routinely several SDs from zero, that would signal the priors are too permissive before any conclusion has been drawn from real data.
One feature of this check is worth anticipating rather than mistaking for a problem. Acoustic dissimilarity is a non-negative quantity with a hard floor at zero, while the model uses a Gaussian likelihood on the z-scored response; a Gaussian prior (and posterior) predictive distribution will therefore assign some mass to negative standardized distances regardless of how the priors are tuned. This is a structural feature shared with related additive dyadic-distance models (e.g. MLPE; Clarke et al. 2002), not evidence of a misspecified prior. What the plot below is actually diagnostic of is whether the bulk of the prior predictive distribution covers a plausible range for a standardized response – roughly within a few SDs of zero – rather than being wildly over- or under-dispersed.
results_type1 <- readRDS("./data/processed/results_song_type_1.rds")
mod_prior_only <- results_type1$prior_check_model
## Does the bulk of the prior-predictive distribution of
## standardized acoustic dissimilarity stay within a plausible
## range, rather than being wildly over- or under-dispersed?
## Some negative-distance mass is expected here (see prose
## above) and is not itself a problem.
pp_check(mod_prior_only, ndraws = 100) + labs(title = "Prior predictive check")With the prior predictive check above informing whether the priors themselves are reasonable, the model fitted to the observed data is next checked against the data it was fitted to, in two complementary ways. An overall density overlay checks whether the model reproduces the general shape of the observed acoustic-distance distribution, including its right tail (subject to the same floor-at-zero caveat noted above). A grouped check by population asks a more targeted question: does the model reproduce each population’s mean acoustic dissimilarity, both within that population and against every other population? This speaks directly to whether mm(population1, population2) is capturing real population-level structure rather than averaging over it.
mod_interaction <- results_type1$model
dist_long <- results_type1$dist_long
## Overall shape of the observed vs. predicted acoustic-distance
## distribution.
pp_check(mod_interaction, ndraws = 100, type = "dens_overlay") + ggplot2::labs(title = "Posterior predictive check -- bock songs")## does the model reproduce each subgroup's shape, or does
## pooling same/different-population pairs together wash out
## something the model isn't capturing?
pp_check(mod_interaction, type = "dens_overlay_grouped", group = "same_population",
ndraws = 100)## Does the model reproduce each population's mean acoustic
## dissimilarity, within-population and against every other
## population?
pp_check(mod_interaction, type = "stat_grouped", group = "population1",
stat = "mean")pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
results_type1 <- readRDS(paste0("./data/processed/results_song_type_1",
fit_suffix, ".rds"))
mod_interaction <- results_type1$model
dist_long <- results_type1$dist_long
cat("\n\n==========================\n bock songs", "| PC rule:", as.character(results_type1$pc_rule),
"| PCs used:", results_type1$n_pcs, "| Cumulative variance:",
round(results_type1$var_explained * 100, 1), "%\n")
==========================
bock songs | PC rule: broken_stick | PCs used: 14 | Cumulative variance: 58.4 %
summ <- extended_summary(mod_interaction, highlight = TRUE, trace = FALSE,
remove.intercepts = TRUE, gsub.pattern = c("b_min_snr_sc", "b_same_population1",
"b_time_separation_sc", "b_geo_distance_sc", "Temporal\nseparation:geo_distance_sc"),
gsub.replacement = c("Minimum\nSNR", "Same\npopulation", "Temporal\nseparation",
"Geographic\ndistance", "Temporal separation ×\ngeographic distance"),
return = TRUE)
summ$coef_table_html| Estimate | l-95% CI | u-95% CI | Rhat | Bulk_ESS | Tail_ESS | |
|---|---|---|---|---|---|---|
| Minimum SNR | -0.341 | -0.407 | -0.276 | 1.003 | 2011.256 | 3162.646 |
| Same population | -0.707 | -0.782 | -0.633 | 1.001 | 2725.597 | 3980.645 |
| Temporal separation | 0.054 | 0.006 | 0.102 | 1.001 | 2896.391 | 4482.631 |
| Geographic distance | 0.040 | -0.033 | 0.113 | 1.001 | 2644.687 | 3948.011 |
| Temporal separation × geographic distance | -0.273 | -0.328 | -0.219 | 1.001 | 4939.292 | 5241.067 |
## ---- gather posterior draws for the fixed effects (excluding intercept) --
draws <- as_draws_df(mod_interaction)
fx_cols <- grep("^b_", names(draws), value = TRUE)
fx_cols <- setdiff(fx_cols, "b_Intercept")
draws_long <- do.call(rbind, lapply(fx_cols, function(v) {
data.frame(.variable = v, .value = draws[[v]])
}))
## ---- point estimate + 95% CI per parameter --------------------------------
## median_qi matches stat_halfeye()'s own default point_interval, so the
## printed numbers are exactly what the plotted point/interval represent.
summary_df <- do.call(rbind, lapply(fx_cols, function(v) {
qs <- quantile(draws[[v]], c(0.025, 0.5, 0.975))
data.frame(.variable = v, estimate = qs[2], lower = qs[1], upper = qs[3])
}))
rownames(summary_df) <- NULL
summary_df$label_text <- sprintf("%.2f [%.2f, %.2f]",
summary_df$estimate, summary_df$lower, summary_df$upper)
## ---- order by |estimate|, high to low -------------------------------------
## A discrete y-axis reads factor levels bottom-to-top, so the LARGEST
## |estimate| has to be the LAST level to land at the top of the plot.
summary_df <- summary_df[order(abs(summary_df$estimate)), ]
param_order <- summary_df$.variable
## ---- custom labels, keyed to the actual term names ------------------------
label_map <- setNames(
c("Minimum\nSNR", "Same\npopulation", "Temporal\nseparation",
"Geographic\ndistance", "Temporal\nseparation\n×\ngeographic\ndistance"),
c("b_min_snr_sc", "b_same_population1", "b_time_separation_sc",
"b_geo_distance_sc", "b_time_separation_sc:geo_distance_sc")
)
draws_long$.variable <- factor(draws_long$.variable, levels = param_order, labels = label_map[param_order])
summary_df$.variable <- factor(summary_df$.variable, levels = param_order, labels = label_map[param_order])
## ---- plot ------------------------------------------------------------------
summary_df$nudge_y <- c(0.45, 0.6, 0.6, 0.47, 0.43) # adjust as needed for label placement)
cat("Probability of direction")Probability of direction
p_direction(mod_interaction, effects = "fixed")| Parameter | pd | Effects | Component | |
|---|---|---|---|---|
| 2 | b_Intercept | 0.550750 | fixed | conditional |
| 3 | b_min_snr_sc | 1.000000 | fixed | conditional |
| 4 | b_same_population1 | 1.000000 | fixed | conditional |
| 5 | b_time_separation_sc | 0.987250 | fixed | conditional |
| 1 | b_geo_distance_sc | 0.861375 | fixed | conditional |
| 6 | b_time_separation_sc:geo_distance_sc | 1.000000 | fixed | conditional |
gg_estimates <- ggplot(draws_long, aes(x = .value, y = .variable)) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray") +
stat_halfeye(
fill = cols[9],
color = "black",
slab_alpha = 0.6,
point_size = 2,
scale = 0.6 # caps hill height, leaving guaranteed headroom for the text below
) +
geom_text(
data = summary_df,
aes(x = estimate, y = .variable, label = label_text, nudge_y = nudge_y),
inherit.aes = FALSE,
vjust = 0,
size = 3
) +
scale_y_discrete(expand = expansion(add = c(0.5, 0.8))) +
labs(x = "Change in acoustic dissimilarity (SD)", y = "Predictor") +
theme_classic(base_size = 13) + xlim(-0.9, 0.24)
gg_estimatesThe figure below shows the estimated effect of geographic distance on acoustic divergence as a function of temporal separation between recordings, computed directly from the joint posterior distribution of the geographic-distance and geographic-by-temporal-distance interaction coefficients rather than from a separate model. At each value of temporal distance, the simple slope of geographic distance was calculated as the sum of the main geographic-distance coefficient and the interaction coefficient scaled by that temporal-distance value — the standard decomposition of an interaction term into its conditional slopes. The solid line shows the posterior median of this slope across the observed range of temporal separation, and the shaded band gives the corresponding 95% credible interval; the dashed horizontal line marks zero, the threshold the credible interval must clear to indicate a geographic-distance effect distinguishable from no effect at a given point along the temporal range.
## ---- posterior draws for the two terms that define the
## geo-distance slope
draws <- as_draws_df(mod_interaction)
b_geo <- draws$b_geo_distance_sc
b_int <- draws$`b_time_separation_sc:geo_distance_sc`
## ---- sequence of time_separation across the OBSERVED range
## only ------------ Raw years for the x-axis (more
## interpretable than z-scores); converted to the same
## standardization used to fit the model so the slope is
## computed on the scale the coefficients actually apply to.
## Kept inside the observed range deliberately -- extrapolating
## the line past it would imply precision the data don't
## support, especially given how thin the sample gets at the
## extremes (Venezuela SE in particular).
time_mean <- mean(dist_long$time_separation)
time_sd <- sd(dist_long$time_separation)
time_seq_raw <- seq(min(dist_long$time_separation), max(dist_long$time_separation),
length.out = 100)
time_seq_sc <- (time_seq_raw - time_mean)/time_sd
## ---- simple slope of geo_distance_sc at every point in that
## sequence ----- Same identity as the earlier p10/p50/p90
## table, just evaluated over a continuous grid instead of three
## quantiles: slope = b_geo + b_int * time.
slope_draws <- outer(b_geo, rep(1, length(time_seq_sc))) + outer(b_int,
time_seq_sc)
slope_df <- data.frame(time_separation = time_seq_raw, estimate = apply(slope_draws,
2, median), lower = apply(slope_draws, 2, quantile, 0.025), upper = apply(slope_draws,
2, quantile, 0.975))
## ---- plot
## ------------------------------------------------------------------
gg_inter <- ggplot(slope_df, aes(x = time_separation, y = estimate)) +
geom_hline(yintercept = 0, linetype = "dashed", color = "black") +
geom_ribbon(aes(ymin = lower, ymax = upper), fill = cols[9], alpha = 0.25) +
geom_line(color = cols[9], linewidth = 1) + labs(x = "Temporal separation (years)",
y = "Effect of geographic distance\non acoustic dissimilarity") +
theme_classic(base_size = 13)
gg_inter# combine gg_estimates and gg_inter into a single figure
gg_combined <- ggpubr::ggarrange(gg_estimates, gg_inter, ncol = 2,
nrow = 1, labels = c("A", "B"), heights = c(1, 1.2))
ggsave(filename = paste0("./output/fig_results_bock", fit_suffix,
".png"), plot = gg_combined, device = grDevices::png, width = 10,
height = 5, units = "in", dpi = 300)## ---- load the fitted model
## ------------------------------------------------
pc_rule <- "broken_stick"
fit_suffix <- if (identical(pc_rule, "broken_stick")) "" else paste0("_",
pc_rule)
results_type1 <- readRDS(paste0("./data/processed/results_song_type_1",
fit_suffix, ".rds"))
mod_interaction <- results_type1$model
## ---- pull each predictor's row from fixef()
## -------------------------------
fx <- as.data.frame(fixef(mod_interaction))
get_row <- function(pattern) {
hit <- fx[grepl(pattern, rownames(fx)), , drop = FALSE]
hit[1, ]
}
snr_row <- get_row("^min_snr_sc$")
samepop_row <- get_row("^same_population")
geo_row <- get_row("^geo_distance_sc$")
time_row <- get_row("^time_separation_sc$")
int_row <- get_row("time_separation_sc:geo_distance_sc")
## ---- credible = 95% CI excludes zero
## --------------------------------------
credible <- function(row) sign(row$Q2.5) == sign(row$Q97.5)
snr_credible <- credible(snr_row)
samepop_credible <- credible(samepop_row)
geo_credible <- credible(geo_row)
time_credible <- credible(time_row)
int_credible <- credible(int_row)
## ---- wording helpers used inline in the text
## ------------------------------
dir_word <- function(row) if (row$Estimate > 0) "increased" else "decreased"
cred_word <- function(is_credible) if (is_credible) "a credible" else "no credible"
## ---- posterior probability the interaction coefficient is
## positive -------
hyp_int <- hypothesis(mod_interaction, "time_separation_sc:geo_distance_sc > 0")
interaction_sentence <- if (int_credible) {
"The interaction was credible (its 95% credible interval excludes zero), indicating that the relationship between geographic separation and acoustic divergence itself depends on how much time has passed between the compared recordings -- the specific signature predicted by the cultural drift hypothesis, under which vocal traditions in isolated populations wander apart at a rate that compounds with both distance and time rather than either acting alone."
} else {
"The interaction's 95% credible interval included zero, providing no support, at the statistical power available here, for acoustic divergence compounding jointly with distance and time. This does not rule out cultural drift outright: geography and/or time may still each contribute independently (see their respective coefficients above), but the data do not support the stronger claim that isolation must persist over time to produce divergence beyond what distance and time contribute on their own."
}suwo can be carried through a complete geographic-variation analysis, from raw media to a fully specified, checked, and interpreted statistical model.Snow, B. K. (1970). A field study of the Bearded Bellbird in Trinidad. Ibis, 112(3), 299-329.
Araya‐Salas, M., Smith‐Vidaurre, G., & Golding, N. (2017). warbleR: an r package to streamline analysis of animal acoustic signals. Methods in Ecology & Evolution, 8(2).
─ Session info ───────────────────────────────────────────────────────────────
setting value
version R version 4.6.1 (2026-06-24)
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-07-30
pandoc 3.8.3 @ /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.6.1)
ape 5.8-1 2024-12-16 [1] CRAN (R 4.6.1)
arrayhelpers 1.1-1 2026-07-05 [1] CRAN (R 4.6.1)
backports 1.5.1 2026-04-03 [1] CRAN (R 4.6.1)
bayesplot 1.15.0 2025-12-12 [1] CRAN (R 4.6.1)
bayestestR * 0.18.1 2026-05-24 [1] CRAN (R 4.6.1)
bitops 1.0-9 2024-10-03 [1] CRAN (R 4.6.1)
bridgesampling 1.2-1 2025-11-19 [1] CRAN (R 4.6.1)
brio 1.1.5 2024-04-24 [1] CRAN (R 4.6.1)
brms * 2.23.0 2025-09-09 [1] CRAN (R 4.6.1)
brmsish * 1.0.0 2026-07-14 [1] Github (maRce10/brmsish@81ab826)
Brobdingnag 1.2-9 2022-10-19 [1] CRAN (R 4.6.1)
broom 1.0.13 2026-05-14 [1] CRAN (R 4.6.1)
cachem 1.1.0 2024-05-16 [1] CRAN (R 4.6.1)
car 3.1-5 2026-02-03 [1] CRAN (R 4.6.1)
carData 3.0-6 2026-01-30 [1] CRAN (R 4.6.1)
checkmate 2.3.4 2026-02-03 [1] CRAN (R 4.6.1)
cli 3.6.6 2026-04-09 [1] CRAN (R 4.6.1)
cmdstanr 0.9.0.9001 2026-07-14 [1] Github (stan-dev/cmdstanr@2763c0f)
coda 0.19-4.1 2024-01-31 [1] CRAN (R 4.6.1)
codetools 0.2-20 2024-03-31 [1] CRAN (R 4.6.1)
cowplot 1.2.0 2025-07-07 [1] CRAN (R 4.6.1)
crayon 1.5.3 2024-06-20 [1] CRAN (R 4.6.1)
crosstalk 1.2.2 2025-08-26 [1] CRAN (R 4.6.1)
curl 7.1.0 2026-04-22 [1] CRAN (R 4.6.1)
datawizard 1.3.1 2026-04-26 [1] CRAN (R 4.6.1)
devtools 2.5.2 2026-04-30 [1] CRAN (R 4.6.1)
digest 0.6.39 2025-11-19 [1] CRAN (R 4.6.1)
distributional 0.8.1 2026-06-27 [1] CRAN (R 4.6.1)
dplyr 1.2.1 2026-04-03 [1] CRAN (R 4.6.1)
dtw 1.23-3 2026-06-09 [1] CRAN (R 4.6.1)
ellipsis 0.3.3 2026-04-04 [1] CRAN (R 4.6.1)
emmeans 2.0.4 2026-07-15 [1] CRAN (R 4.6.1)
estimability 2.0.0 2026-06-26 [1] CRAN (R 4.6.1)
evaluate 1.0.5 2025-08-27 [1] CRAN (R 4.6.1)
farver 2.1.2 2024-05-13 [1] CRAN (R 4.6.1)
fastmap 1.2.0 2024-05-15 [1] CRAN (R 4.6.1)
fftw 1.0-9 2024-09-20 [1] CRAN (R 4.6.1)
formatR 1.14 2023-01-17 [1] CRAN (R 4.6.1)
Formula 1.2-5 2023-02-24 [1] CRAN (R 4.6.1)
fs 2.1.0 2026-04-18 [1] CRAN (R 4.6.1)
generics 0.1.4 2025-05-09 [1] CRAN (R 4.6.1)
geosphere * 1.6-8 2026-04-05 [1] CRAN (R 4.6.1)
ggcorrplot * 0.3.0 2026-07-24 [1] CRAN (R 4.6.1)
ggdist * 3.3.3 2025-04-23 [1] CRAN (R 4.6.1)
ggplot2 * 4.0.3 2026-04-22 [1] CRAN (R 4.6.1)
ggpubr 1.0.0 2026-07-06 [1] CRAN (R 4.6.1)
ggsignif 0.6.4 2022-10-13 [1] CRAN (R 4.6.1)
ggtext * 0.1.2 2022-09-16 [1] CRAN (R 4.6.1)
glue 1.8.1 2026-04-17 [1] CRAN (R 4.6.1)
gridExtra 2.3.1 2026-06-25 [1] CRAN (R 4.6.1)
gridtext 0.1.6 2026-02-19 [1] CRAN (R 4.6.1)
gtable 0.3.6 2024-10-25 [1] CRAN (R 4.6.1)
htmltools 0.5.9 2025-12-04 [1] CRAN (R 4.6.1)
htmlwidgets 1.6.4 2023-12-06 [1] CRAN (R 4.6.1)
httr 1.4.8 2026-02-13 [1] CRAN (R 4.6.1)
inline 0.3.21 2025-01-09 [1] CRAN (R 4.6.1)
insight 1.5.2 2026-06-28 [1] CRAN (R 4.6.1)
jquerylib 0.1.4 2021-04-26 [1] CRAN (R 4.6.1)
jsonlite 2.0.0 2025-03-27 [1] CRAN (R 4.6.1)
kableExtra 1.4.1 2026-07-08 [1] CRAN (R 4.6.1)
knitr * 1.51 2025-12-20 [1] CRAN (R 4.6.1)
labeling 0.4.3 2023-08-29 [1] CRAN (R 4.6.1)
lattice 0.22-9 2026-02-09 [3] CRAN (R 4.5.2)
leaflet 2.2.3 2025-09-04 [1] CRAN (R 4.6.1)
lifecycle 1.0.5 2026-01-08 [1] CRAN (R 4.6.1)
loo 2.10.0 2026-06-26 [1] CRAN (R 4.6.1)
magrittr 2.0.5 2026-04-04 [1] CRAN (R 4.6.1)
MASS 7.3-66 2026-07-15 [1] CRAN (R 4.6.1)
Matrix 1.7-5 2026-03-21 [3] CRAN (R 4.5.3)
matrixStats 1.5.0 2025-01-07 [1] CRAN (R 4.6.1)
memoise 2.0.1 2021-11-26 [1] CRAN (R 4.6.1)
mvtnorm 1.4-2 2026-07-12 [1] CRAN (R 4.6.1)
NatureSounds * 1.0.5 2025-01-17 [1] CRAN (R 4.6.1)
nlme 3.1-169 2026-03-27 [3] CRAN (R 4.5.3)
otel 0.2.0 2025-08-29 [1] CRAN (R 4.6.1)
packrat 0.9.3 2025-06-16 [1] CRAN (R 4.6.1)
pbapply 1.7-4 2025-07-20 [1] CRAN (R 4.6.1)
pillar 1.11.1 2025-09-17 [1] CRAN (R 4.6.1)
pkgbuild 1.4.8 2025-05-26 [1] CRAN (R 4.6.1)
pkgconfig 2.0.3 2019-09-22 [1] CRAN (R 4.6.1)
pkgload 1.5.3 2026-06-15 [1] CRAN (R 4.6.1)
plyr 1.8.9 2023-10-02 [1] CRAN (R 4.6.1)
posterior * 1.7.0 2026-04-01 [1] CRAN (R 4.6.1)
processx 3.9.0 2026-04-22 [1] CRAN (R 4.6.1)
proxy 0.4-29 2025-12-29 [1] CRAN (R 4.6.1)
ps 1.9.3 2026-04-20 [1] CRAN (R 4.6.1)
purrr 1.2.2 2026-04-10 [1] CRAN (R 4.6.1)
QuickJSR 1.7.0 2025-03-31 [2] CRAN (R 4.5.0)
R6 2.6.1 2025-02-15 [1] CRAN (R 4.6.1)
RColorBrewer 1.1-3 2022-04-03 [1] CRAN (R 4.6.1)
Rcpp * 1.1.2 2026-07-05 [1] CRAN (R 4.6.1)
RcppParallel 5.1.11-2 2026-03-05 [1] CRAN (R 4.6.1)
RCurl 1.98-1.19 2026-06-03 [1] CRAN (R 4.6.1)
remotes 2.5.0 2024-03-17 [1] CRAN (R 4.6.1)
reshape2 1.4.5 2025-11-12 [1] CRAN (R 4.6.1)
rjson 0.2.23 2024-09-16 [1] CRAN (R 4.6.1)
rlang 1.3.0 2026-07-05 [1] CRAN (R 4.6.1)
rmarkdown 2.31 2026-03-26 [1] CRAN (R 4.6.1)
Rraven * 1.0.16 2017-11-17 [1] Github (maRce10/Rraven@5fea9e1)
rstan 2.32.7 2025-03-10 [1] CRAN (R 4.6.1)
rstantools 2.6.0 2026-01-10 [1] CRAN (R 4.6.1)
rstatix 1.1.0 2026-07-23 [1] CRAN (R 4.6.1)
rstudioapi 0.19.0 2026-06-11 [1] CRAN (R 4.6.1)
S7 0.2.2 2026-04-22 [1] CRAN (R 4.6.1)
scales 1.4.0 2025-04-24 [1] CRAN (R 4.6.1)
seewave * 2.2.4 2025-08-19 [1] CRAN (R 4.6.1)
sessioninfo 1.2.4 2026-06-04 [1] CRAN (R 4.6.1)
signal 1.8-1 2024-06-26 [1] CRAN (R 4.6.1)
sketchy * 1.0.5 2025-01-16 [1] CRAN (R 4.6.1)
StanHeaders 2.32.10 2024-07-15 [1] CRAN (R 4.6.1)
stringi 1.8.7 2025-03-27 [1] CRAN (R 4.6.1)
stringr 1.6.0 2025-11-04 [1] CRAN (R 4.6.1)
suwo * 0.2.2 2026-07-23 [1] local
svglite 2.2.2 2025-10-21 [1] CRAN (R 4.6.1)
svUnit 1.0.8 2025-08-26 [1] CRAN (R 4.6.1)
systemfonts 1.3.2 2026-03-05 [1] CRAN (R 4.6.1)
tensorA 0.36.2.1 2023-12-13 [1] CRAN (R 4.6.1)
testthat 3.3.2 2026-01-11 [1] CRAN (R 4.6.1)
textshaping 1.0.5 2026-03-06 [1] CRAN (R 4.6.1)
tibble 3.3.1 2026-01-11 [1] CRAN (R 4.6.1)
tidybayes 3.0.7 2024-09-15 [1] CRAN (R 4.6.1)
tidyr 1.3.2 2025-12-19 [1] CRAN (R 4.6.1)
tidyselect 1.2.1 2024-03-11 [1] CRAN (R 4.6.1)
tuneR * 1.4.7 2024-04-17 [1] CRAN (R 4.6.1)
usethis 3.2.1 2025-09-06 [1] CRAN (R 4.6.1)
V8 8.2.0 2026-04-21 [1] CRAN (R 4.6.1)
vctrs 0.7.3 2026-04-11 [1] CRAN (R 4.6.1)
viridis * 0.6.5 2024-01-29 [1] CRAN (R 4.6.1)
viridisLite * 0.4.3 2026-02-04 [1] CRAN (R 4.6.1)
warbleR * 1.1.37 2025-10-22 [1] CRAN (R 4.6.1)
withr 3.0.3 2026-06-19 [1] CRAN (R 4.6.1)
xaringanExtra 0.8.0 2024-05-19 [1] CRAN (R 4.6.1)
xfun 0.60 2026-07-09 [1] CRAN (R 4.6.1)
xml2 1.6.0 2026-06-22 [1] CRAN (R 4.6.1)
xtable 1.8-8 2026-02-22 [1] CRAN (R 4.6.1)
yaml 2.3.12 2025-12-10 [1] CRAN (R 4.6.1)
[1] /usr/local/lib/R/site-library
[2] /usr/lib/R/site-library
[3] /usr/lib/R/library
* ── Packages attached to the search path.
──────────────────────────────────────────────────────────────────────────────