
Passerine Body Plan Evolution, Part 4: Shift Magnitude Comparisons
Source:vignettes/avian-skeleton-part-4.Rmd
avian-skeleton-part-4.RmdIntroduction
Berv et al. (2026) used bifrost to identify hidden rate
shifts in multivariate skeletal evolution across the passerine tree.
Part 4 asks what those accepted parent-child regime changes imply about
direction and magnitude: when the model infers a rate increase or
decrease, how often does each direction occur, and how large are the
changes?
Berv et al. (2026) reported this comparison in Supplementary Figure 5. Their workflow counted rate-increase and rate-decrease shifts within each sensitivity run, then pooled inferred shifts across the 12 temporal sensitivity searches. The pooled shifts were summarized three ways: GIC and BIC runs together, GIC runs only, and BIC runs only.
This vignette computes that non-spatial shift-magnitude workflow from
compact package data. Parts 1-3 introduce the focal search, lineage-rate
summaries, and transition table used here. We first show the single-run
calculation on the focal min10.ic20.gic result, then load
the compact sensitivity bundle to obtain the run-level shift-direction
frequencies and pooled magnitude-density comparisons. The magnitude test
compares log(abs(rate_delta)) for increases and decreases
with a Monte-Carlo two-sample KS test.
Loading the compact focal result
Start with the compact focal result bundled with
bifrost. This object represents the
min10.ic20.gic search and keeps the final mapped tree plus
fitted BMM regime rates.
# Load the focal min10.ic20.gic search used for the single-run example.
search_path <- system.file(
"extdata",
"avian-skeleton",
"passerine_bodyplan_search_compact.RDS",
package = "bifrost"
)
stopifnot(nzchar(search_path))
bodyplan_search <- readRDS(search_path)
# Preserve the S3 class if an older serialized copy lacks it.
if (!inherits(bodyplan_search, "bifrost_search")) {
class(bodyplan_search) <- c("bifrost_search", class(bodyplan_search))
}
# Confirm that the compact object has the fitted BMM regimes and shifts.
data.frame(
model = bodyplan_search$model_no_uncertainty$model,
fitted_regimes = length(bodyplan_search$model_no_uncertainty$param),
accepted_shifts = length(bodyplan_search$shift_nodes_no_uncertainty)
)
#> model fitted_regimes accepted_shifts
#> 1 BMM 83 82Focal run: shift magnitudes
shift_transitions() extracts each inferred parent-child
regime change and records the signed fitted rate difference in
rate_delta. compare_shift_magnitudes() then
follows the Berv et al. (2026) default comparison: it separates
increases from decreases, transforms magnitudes as
log(abs(rate_delta)), and runs a two-sample KS test.
# Extract accepted focal shifts, excluding the root state assignment.
bodyplan_transitions <- shift_transitions(bodyplan_search, include_root = FALSE)
# Count rate increases and decreases before testing magnitudes.
shift_magnitude_counts(bodyplan_transitions)
#> source decrease increase total decrease_frequency increase_frequency
#> 1 input 64 18 82 0.7804878 0.2195122
# Preview the signed and percentage rate-change columns used below.
head(bodyplan_transitions[, c(
"node",
"parent_rate",
"child_rate",
"rate_delta",
"percentage_change",
"rate_change"
)])
#> node parent_rate child_rate rate_delta percentage_change rate_change
#> 1 3620 0.0325232187 0.0005337094 -0.0319895094 -98.35899 decrease
#> 2 2059 0.0325232187 0.0028624293 -0.0296607895 -91.19881 decrease
#> 3 2060 0.0028624293 0.0003045813 -0.0025578479 -89.35934 decrease
#> 4 4105 0.0005337094 0.0001172235 -0.0004164859 -78.03609 decrease
#> 5 2065 0.0003045813 0.0008134411 0.0005088598 167.06861 increase
#> 6 2837 0.0008134411 0.0002988457 -0.0005145954 -63.26155 decrease
# Fix the seed because the KS p-value uses Monte-Carlo simulation.
set.seed(1)
bodyplan_magnitude_test <- compare_shift_magnitudes(
bodyplan_transitions,
ks_reps = 10000
)
# Extract the compact result tables most useful for inspection.
magnitude_summary <- as.data.frame(bodyplan_magnitude_test, component = "summary")
magnitude_tests <- as.data.frame(bodyplan_magnitude_test, component = "tests")
magnitude_modes <- as.data.frame(bodyplan_magnitude_test, component = "modal")
print(magnitude_summary)
#> rate_change n mean median mean_abs_raw median_abs_raw
#> 1 increase 18 -7.796 -7.846 0.001105 0.0003936
#> 2 decrease 64 -7.988 -8.396 0.001664 0.0002257
cat("\n\n")
print(magnitude_tests)
#> test statistic p_value mean_difference median_difference
#> 1 ks 0.1736 0.7149 0.1916 0.5503
#> method
#> 1 Monte-Carlo two-sample Kolmogorov-Smirnov test
cat("\n\n")
print(magnitude_modes)
#> mode_increase mode_decrease mode_difference linear_ratio
#> 1 -7.795 -8.738 0.9425 2.566The modal summary gives the KDE-estimated mode for each transformed
magnitude distribution. On the default log-absolute scale,
linear_ratio back-transforms those modes and reports the
inferred increase-magnitude mode divided by the inferred
decrease-magnitude mode.
The focal KS table is a single-run teaching example for
min10.ic20.gic; it is not the pooled sensitivity test
reported for Supplementary Figure 5. Those pooled KS values are computed
below after grouping all 12 sensitivity runs. The same function can also
run the simpler Welch t-test and Wilcoxon comparisons from the Berv et
al. (2026) utility code by setting transform = "absolute"
and tests = c("t", "wilcox").
Sensitivity runs: pooled inputs
The focal run demonstrates the mechanics, but Supplementary Figure 5 pools shifts across the Berv et al. (2026) sensitivity analyses. The pooled workflow has two outputs: run-level increase/decrease frequencies and pooled magnitude-density comparisons. Both start from the same compact sensitivity bundle.
Load the compact sensitivity bundle
The bundle contains reduced versions of the 12 Berv et al. (2026) search outputs, one for each combination of minimum clade size, IC acceptance threshold, and information criterion. Each object keeps the final mapped tree and fitted BMM regime rates needed for transition extraction.
# Load the compact bundle of all 12 temporal sensitivity searches.
sensitivity_path <- system.file(
"extdata",
"avian-skeleton",
"passerine_bodyplan_search_sensitivity_compact.RDS",
package = "bifrost"
)
stopifnot(nzchar(sensitivity_path))
bodyplan_sensitivity_searches <- readRDS(sensitivity_path)
# Report bundle size and run-label coverage for orientation.
data.frame(
sensitivity_runs = length(bodyplan_sensitivity_searches),
compressed_size_mb = round(file.info(sensitivity_path)$size / 1024^2, 2),
first_run = names(bodyplan_sensitivity_searches)[1],
last_run = tail(names(bodyplan_sensitivity_searches), 1)
)
#> sensitivity_runs compressed_size_mb first_run last_run
#> 1 12 0.38 min30.ic40.gic min10.ic20.bicExtract transitions and define analysis groups
Each sensitivity run is converted to the same transition-table representation used for the focal run. We then define the three pooled groups used in Supplementary Figure 5: all GIC and BIC runs together, GIC runs only, and BIC runs only.
# Extract transitions from each sensitivity run with the same rule as the focal run.
bodyplan_sensitivity_transitions <- lapply(
bodyplan_sensitivity_searches,
shift_transitions,
include_root = FALSE
)
# Count increase/decrease frequencies for every run.
sensitivity_shift_counts <- shift_magnitude_counts(
bodyplan_sensitivity_transitions
)
sensitivity_shift_counts
#> source decrease increase total decrease_frequency increase_frequency
#> 1 min30.ic40.gic 11 7 18 0.6111111 0.3888889
#> 2 min30.ic40.bic 6 7 13 0.4615385 0.5384615
#> 3 min20.ic40.gic 11 6 17 0.6470588 0.3529412
#> 4 min20.ic40.bic 9 7 16 0.5625000 0.4375000
#> 5 min10.ic40.gic 16 9 25 0.6400000 0.3600000
#> 6 min10.ic40.bic 13 9 22 0.5909091 0.4090909
#> 7 min30.ic20.gic 25 7 32 0.7812500 0.2187500
#> 8 min30.ic20.bic 21 7 28 0.7500000 0.2500000
#> 9 min20.ic20.gic 33 12 45 0.7333333 0.2666667
#> 10 min20.ic20.bic 27 7 34 0.7941176 0.2058824
#> 11 min10.ic20.gic 64 18 82 0.7804878 0.2195122
#> 12 min10.ic20.bic 42 14 56 0.7500000 0.2500000Shift-direction frequencies across runs
Before comparing magnitudes, Berv et al. (2026) compared how often
inferred shifts increased or decreased rates within each sensitivity
run. shift_magnitude_groups() makes the grouped analysis
explicit; shift_magnitude_counts() then returns one count
table per named group. Select one count table with [[ ]],
then call plot().
# Draw one paired-frequency panel for each pooled analysis set.
bodyplan_frequency_plot_results <- list()
bodyplan_frequency_plot_results[["GIC + BIC"]] <- plot(
pooled_shift_counts[["GIC + BIC"]],
statistic = "frequency",
main = "GIC + BIC",
ylim = c(0, 1)
)
bodyplan_frequency_plot_results[["GIC"]] <- plot(
pooled_shift_counts[["GIC"]],
statistic = "frequency",
main = "GIC",
ylim = c(0, 1)
)
bodyplan_frequency_plot_results[["BIC"]] <- plot(
pooled_shift_counts[["BIC"]],
statistic = "frequency",
main = "BIC",
ylim = c(0, 1)
)


Figure 1. Shift-direction frequencies across sensitivity analyses. Left, middle, and right panels show the pooled GIC + BIC, GIC-only, and BIC-only analysis sets. Lines connect paired decrease and increase frequencies from the same search output; the dashed line marks equal increase and decrease frequencies.
The numerical test rows returned by plot() reproduce the
paired Wilcoxon signed-rank tests shown in the Supplementary Figure 5
frequency panels. mean_difference is the paired mean of
increase_frequency - decrease_frequency, so negative values
indicate that decreases are more frequent.
# Combine the three paired-frequency test tables into one report.
bodyplan_frequency_tests <- do.call(
rbind,
list(
bodyplan_frequency_plot_results[["GIC + BIC"]]$tests,
bodyplan_frequency_plot_results[["GIC"]]$tests,
bodyplan_frequency_plot_results[["BIC"]]$tests
)
)
# Retain the sample sizes, test statistics, and directional effect summary.
bodyplan_frequency_tests <- bodyplan_frequency_tests[
,
c(
"analysis",
"n",
"wilcox_statistic",
"wilcox_p_value",
"mean_difference"
)
]
rownames(bodyplan_frequency_tests) <- NULL
# Format inferential results for compact display.
bodyplan_frequency_tests$wilcox_p_value <- signif(
bodyplan_frequency_tests$wilcox_p_value,
4
)
bodyplan_frequency_tests$mean_difference <- round(
bodyplan_frequency_tests$mean_difference,
4
)
print(bodyplan_frequency_tests)
#> analysis n wilcox_statistic wilcox_p_value mean_difference
#> 1 GIC + BIC 12 77 0.003252 -0.3504
#> 2 GIC 6 21 0.036030 -0.3977
#> 3 BIC 6 20 0.058480 -0.3030Pooled magnitude comparisons
The second pooled output compares the magnitudes of rate increases
and decreases after pooling shifts within each analysis group. Because
pooled_transition_groups is a grouped object,
compare_shift_magnitudes() applies the same KS comparison
to each named group and returns a named comparison set. The call below
keeps the random seed fixed because the simulated KS p-values and
bootstrap mean p-values use Monte-Carlo resampling.
Run the pooled KS tests
# Fix the seed for simulated KS p-values and bootstrap summaries.
set.seed(2026)
# Run the same magnitude comparison for each pooled analysis set.
pooled_magnitude_comparisons <- compare_shift_magnitudes(
pooled_transition_groups,
ks_reps = 10000,
bootstrap_p_value = TRUE,
bootstrap_reps = 100
)
# Extract one compact KS-test table for the grouped comparison.
as.data.frame(pooled_magnitude_comparisons, component = "ks")
#> analysis increases decreases ks_D ks_p bootstrap_mean_p mode_increase
#> 1 GIC + BIC 110 278 0.2396 0.0002 0.01064 -7.821
#> 2 GIC 59 160 0.2405 0.0104 0.06137 -7.866
#> 3 BIC 51 118 0.2340 0.0338 0.08272 -7.793
#> mode_decrease linear_ratio
#> 1 -8.836 2.762
#> 2 -8.793 2.527
#> 3 -8.865 2.920Plot frequency-scaled magnitude densities
The plot() method for a
shift_magnitude_comparison object draws Supplementary
Figure 5-style density panels. The default
scale_by_frequency = TRUE matches the Berv et al. (2026)
plotting logic by scaling each density by the observed frequency of that
shift direction. Each call draws one selected comparison, so the same
[[ ]] pattern used for count plots applies here.
# Plot one frequency-scaled density panel for each pooled comparison.
plot(
pooled_magnitude_comparisons[["GIC + BIC"]],
main = "GIC + BIC",
legend = FALSE,
xlim = c(-10.5, -5),
ylim = c(0, 0.4)
)
plot(
pooled_magnitude_comparisons[["GIC"]],
main = "GIC",
legend = FALSE,
xlim = c(-10.5, -5),
ylim = c(0, 0.4)
)
plot(
pooled_magnitude_comparisons[["BIC"]],
main = "BIC",
legend = FALSE,
xlim = c(-10.5, -5),
ylim = c(0, 0.4)
)


Figure 2. Log absolute rate-delta magnitude distributions. Left, middle, and right panels show the pooled GIC + BIC, GIC-only, and BIC-only analysis sets. Red curves show decreases and blue curves show increases. Densities integrate to observed shift-direction frequencies across the Berv et al. (2026) sensitivity analyses; dashed vertical lines mark KDE modes.
The compact sensitivity bundle yields the local sample sizes, KS statistics, KDE modes, and back-transformed modal ratios used in these pooled comparisons. Use the full Zenodo archive for proposal-level history, sensitivity searches, and full workflow verification.
Across the pooled sensitivity summaries, inferred decreases are more
frequent than increases. The magnitude-density panels show the
complementary pattern: the KDE modes for rate increases fall at larger
log(abs(rate_delta)) values than the modes for decreases,
so the modal increase magnitude is larger after back-transformation.
The KS p-values and bootstrap mean p-values use Monte-Carlo
simulation, so repeated runs can differ slightly unless you set the
random seed. Increase bootstrap_reps if you want a more
stable bootstrap mean p-value.
If you want to start from the complete Berv et al. (2026) archive
instead of the compact bundle, load the same 12 search RDS
files from data/temporal/02_shift_search/new_bifrost/, wrap
the named analysis sets with shift_magnitude_groups(), and
pass that object to shift_magnitude_counts() or
compare_shift_magnitudes().
Practical Takeaways
-
shift_transitions()turns fitted BMM regime changes into an explicit transition table with parent rates, child rates, signed rate deltas, and increase/decrease labels. -
shift_magnitude_groups()marks named analysis sets that should be analyzed separately rather than pooled into one result. -
shift_magnitude_counts()computes run-level increase/decrease counts and frequencies for one analysis set or each group in ashift_magnitude_groupsobject. -
compare_shift_magnitudes()compares log absolute rate-change magnitudes for one pooled set or each group in ashift_magnitude_groupsobject. -
as.data.frame()extracts compact count, summary, test, modal, and pooled KS tables from the count and comparison objects. -
plot()draws one selected count or magnitude-comparison object at a time. - The compact sensitivity bundle yields the local Supplementary Figure 5-style shift-magnitude summaries without requiring the 15.2 GB Berv et al. (2026) archive.
- Use the complete Berv et al. (2026) archive when you need proposal-level history, sensitivity searches, full workflow verification, or the full fitted candidate models.
- Continue to Part 5 for the temporal post-hoc integration workflow behind Supplementary Text 2.1 and Supplementary Figure 4.
References
If you use these data or reproduce this workflow, the most relevant citations are:
- Berv, Jacob S., Charlotte M. Probst, Santiago Claramunt, J. Ryan Shipley, Matt Friedman, Stephen A. Smith, David F. Fouhey, and Brian C. Weeks. 2026. “Rates of passerine body plan evolution in time and space.” Nature Ecology & Evolution. https://doi.org/10.1038/s41559-026-03110-5
- Berv, Jacob S., Charlotte M. Probst, Santiago Claramunt, J. Ryan Shipley, Matt Friedman, Stephen A. Smith, David F. Fouhey, and Brian C. Weeks. 2026. “Supplementary data archive for Rates of passerine body plan evolution in time and space” (v1.0.0) [Data set]. Zenodo. https://doi.org/10.5281/zenodo.19198393
- Claramunt, Santiago, Christopher Sheard, Joseph W. Brown, Guillermo Cortes-Ramirez, Joel Cracraft, Mason M. Su, Brian C. Weeks, and Joseph A. Tobias. 2025. “A new time tree of birds reveals the interplay between dispersal, geographic range size, and diversification.” Current Biology. Advance online publication. https://doi.org/10.1016/j.cub.2025.07.004