Federated learning enables collaborative analysis across multiple sites without centralizing data. This is critical when:
shrinkr’s two-stage architecture naturally enables federated learning:
Site 1: Stage 1 model -> Posterior samples (or summaries)
Site 2: Stage 1 model -> Posterior samples (or summaries) } -> Central
Site 3: Stage 1 model -> Posterior samples (or summaries) } Coordinator
... } applies
Site K: Stage 1 model -> Posterior samples (or summaries) } Stage 2 shrinkage
Data never leaves the sites, only statistical summaries are shared.
Important: CLT Assumption for Summary Statistics
If sites share only summary statistics (means + SEs) rather than full posteriors, the analysis relies on the Bayesian Central Limit Theorem. This assumes posteriors are approximately normal, which requires:
- Adequate sample sizes at each site
- Parameters in the interior (not near boundaries)
- Well-behaved likelihood functions
- Regular posterior geometry
Always verify posterior normality before using summary statistics! When in doubt, share full posteriors or send mixture approximations, for example by sending
fit_mixture()output.
We’ll analyze a federated clinical prediction model across 6 hospitals. Each hospital:
Goal: Combine site-specific models while respecting data governance constraints.
hospitals <- data.frame(
site_id = 1:6,
name = c("Metro General", "County Regional", "University Medical",
"Community Hospital", "Veterans Affairs", "Children's Specialty"),
location = c("Urban", "Suburban", "Academic", "Rural", "Urban", "Urban"),
n_patients = c(1500, 800, 2200, 350, 1100, 900),
baseline_risk = c(0.15, 0.12, 0.18, 0.10, 0.20, 0.14)
)
print(hospitals)
#> site_id name location n_patients baseline_risk
#> 1 1 Metro General Urban 1500 0.15
#> 2 2 County Regional Suburban 800 0.12
#> 3 3 University Medical Academic 2200 0.18
#> 4 4 Community Hospital Rural 350 0.10
#> 5 5 Veterans Affairs Urban 1100 0.20
#> 6 6 Children's Specialty Urban 900 0.14Each site fits:
\[ \text{logit}(\text{mortality}) = \beta_0 + \beta_1(\text{age}) + \beta_2(\text{severity\_score}) + \beta_3(\text{comorbidities}) \]
Parameter of interest: \(\beta_1\) (age effect on mortality)
In practice, this happens behind each site’s firewall. We simulate:
set.seed(1104)
# True network-level parameters (unknown in practice)
true_mu_age <- 0.05 # log-OR per year
true_tau_age <- 0.015 # between-site heterogeneity
true_site_effects <- rnorm(6, true_mu_age, true_tau_age)
# Simulate Stage 1: Each site fits their model independently
# In reality: glm(), stan_glm(), or other Bayesian logistic regression
site_posteriors <- list()
site_sample_sizes <- hospitals$n_patients
for(i in 1:6) {
# Posterior for age coefficient beta_1
# SE inversely proportional to sqrt(sample size)
se_i <- 0.02 * sqrt(800 / site_sample_sizes[i])
site_posteriors[[hospitals$name[i]]] <- matrix(
rnorm(4000, true_site_effects[i], se_i),
ncol = 1
)
}
# Each site computes summaries
site_summaries <- data.frame(
site = hospitals$name,
n_patients = site_sample_sizes,
beta_age_mean = sapply(site_posteriors, mean),
beta_age_se = sapply(site_posteriors, sd)
) %>%
mutate(
ci_lower = beta_age_mean - 1.96 * beta_age_se,
ci_upper = beta_age_mean + 1.96 * beta_age_se
)
print(site_summaries)
#> site n_patients beta_age_mean beta_age_se
#> Metro General Metro General 1500 0.04958522 0.01428679
#> County Regional County Regional 800 0.05905738 0.01992675
#> University Medical University Medical 2200 0.04482541 0.01246048
#> Community Hospital Community Hospital 350 0.01077674 0.03047231
#> Veterans Affairs Veterans Affairs 1100 0.06783668 0.01752091
#> Children's Specialty Children's Specialty 900 0.04679578 0.01888196
#> ci_lower ci_upper
#> Metro General 0.021583117 0.07758733
#> County Regional 0.020000961 0.09811381
#> University Medical 0.020402874 0.06924794
#> Community Hospital -0.048948992 0.07050247
#> Veterans Affairs 0.033495692 0.10217766
#> Children's Specialty 0.009787133 0.08380442Key observations:
The coordinator, for example a coordinating center or trusted third party, now applies hierarchical shrinkage.
# Fit mixture approximation
mix <- fit_mixture(
samples = site_posteriors,
K_max = 2, # Age effects should be fairly normal
verbose = TRUE
)
# Check quality
plot(mix, draws = site_posteriors, type = "density")# Based on clinical knowledge:
# - Age effect should be positive but moderate
# - Some heterogeneity expected across hospital types
hierarchical_priors <- list(
mu = dist_normal(0.05, 0.025), # Centered on 5% increase per year
tau = dist_truncated(dist_student_t(3, 0, 0.01), lower = 0) # Modest heterogeneity
)
# Visualize prior implications
prior_pred <- sample_prior_predictive(
hierarchical_priors = hierarchical_priors,
n_groups = 6,
n_draws = 1000
)
plot(prior_pred, type = "both")fit_full_post <- shrink(
mixture = mix,
hierarchical_priors = hierarchical_priors,
chains = 4,
iter = 2000,
warmup = 1000,
seed = 123
)print(fit_full_post)
#> # A tibble: 3 × 7
#> variable mean sd q2.5 q50 q97.5 rhat
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 mu 0.0497 0.00750 0.0349 0.0497 0.0643 1.00
#> 2 tau 0.00624 0.00536 0.000255 0.00498 0.0198 1.00
#> 3 tau_squared 0.0000677 0.000132 0.0000000650 0.0000248 0.000390 1.00
# Network-level estimates
mu_tau_full <- extract_mu_tau(fit_full_post)
cat("\nNetwork-level age effect (mu):\n")
#>
#> Network-level age effect (mu):
cat(" Posterior mean:", round(mean(mu_tau_full$mu), 4), "\n")
#> Posterior mean: 0.0497
cat(" 95% CI: [", round(quantile(mu_tau_full$mu, 0.025), 4), ",",
round(quantile(mu_tau_full$mu, 0.975), 4), "]\n")
#> 95% CI: [ 0.0349 , 0.0643 ]
cat("\nBetween-site heterogeneity (tau):\n")
#>
#> Between-site heterogeneity (tau):
cat(" Posterior mean:", round(mean(mu_tau_full$tau), 4), "\n")
#> Posterior mean: 0.0062
cat(" 95% CI: [", round(quantile(mu_tau_full$tau, 0.025), 4), ",",
round(quantile(mu_tau_full$tau, 0.975), 4), "]\n")
#> 95% CI: [ 3e-04 , 0.0198 ]Before using summary statistics, we must verify posteriors are approximately normal.
# Visual checks for approximate normality
oldpar <- par(no.readonly = TRUE)
par(mfrow = c(3, 2))
for(i in 1:6) {
site_name <- names(site_posteriors)[i]
samples_i <- as.vector(site_posteriors[[i]])
# QQ plot against normal
qqnorm(samples_i, main = paste("QQ Plot:", site_name))
qqline(samples_i, col = "red", lwd = 2)
}# Quantitative checks
normality_checks <- data.frame(
site = names(site_posteriors),
skewness = sapply(site_posteriors, function(x) {
m3 <- mean((x - mean(x))^3)
s3 <- sd(x)^3
m3 / s3
}),
kurtosis = sapply(site_posteriors, function(x) {
m4 <- mean((x - mean(x))^4)
s4 <- sd(x)^4
m4 / s4 - 3 # Excess kurtosis
})
)
print(normality_checks)
#> site skewness kurtosis
#> Metro General Metro General 0.006067498 0.009199907
#> County Regional County Regional -0.050758390 -0.116123944
#> University Medical University Medical 0.035950881 -0.005600149
#> Community Hospital Community Hospital 0.008651589 0.118037106
#> Veterans Affairs Veterans Affairs -0.041185160 -0.015861386
#> Children's Specialty Children's Specialty 0.010783310 -0.090076276
cat("\nNormality assessment:\n")
#>
#> Normality assessment:
cat(" Skewness close to 0? (|skew| < 0.5 is good)\n")
#> Skewness close to 0? (|skew| < 0.5 is good)
cat(" Kurtosis close to 0? (|kurt| < 1.0 is good)\n")
#> Kurtosis close to 0? (|kurt| < 1.0 is good)
cat(" All sites pass:",
all(abs(normality_checks$skewness) < 0.5 & abs(normality_checks$kurtosis) < 1.0),
"\n")
#> All sites pass: TRUEDecision rule:
To illustrate why normality matters, consider a scenario where we are estimating a variance parameter:
# Simulated: posterior for a variance parameter (boundary at 0)
set.seed(999)
variance_posterior <- matrix(rchisq(4000, df = 5) / 5, ncol = 1)
# Compute summary statistics
var_mean <- mean(variance_posterior)
var_se <- sd(variance_posterior)
# Check normality
oldpar <- par(no.readonly = TRUE)
par(mfrow = c(1, 2))
hist(variance_posterior, breaks = 30, main = "Variance Posterior",
xlab = "sigma^2", col = "lightblue")
qqnorm(variance_posterior, main = "QQ Plot")
qqline(variance_posterior, col = "red", lwd = 2)par(oldpar)
# Skewness
skew <- mean((variance_posterior - var_mean)^3) / var_se^3
cat("Skewness:", round(skew, 2), "(should be about 0 for normal)\n")
#> Skewness: 1.24 (should be about 0 for normal)
cat("This posterior is right-skewed - CLT approximation would be poor!\n")
#> This posterior is right-skewed - CLT approximation would be poor!For this scenario:
# Extract means and variances
mle_estimates <- site_summaries$beta_age_mean
names(mle_estimates) <- site_summaries$site
mle_variances <- site_summaries$beta_age_se^2
names(mle_variances) <- site_summaries$site
# Fit using MLE path (CLT approximation)
fit_summaries <- shrink(
mle = mle_estimates,
var_matrix = mle_variances,
hierarchical_priors = hierarchical_priors,
chains = 4,
iter = 2000,
warmup = 1000,
seed = 123,
verbose = FALSE,
refresh = 0
)
print(fit_summaries)
#> # A tibble: 3 × 7
#> variable mean sd q2.5 q50 q97.5 rhat
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 mu 0.0496 0.00732 0.0349 0.0497 0.0637 1.00
#> 2 tau 0.00627 0.00545 0.000209 0.00496 0.0201 1.00
#> 3 tau_squared 0.0000690 0.000136 0.0000000438 0.0000246 0.000402 1.00mu_tau_summaries <- extract_mu_tau(fit_summaries)
comparison <- data.frame(
parameter = c("mu", "tau"),
full_posteriors = c(
mean(mu_tau_full$mu),
mean(mu_tau_full$tau)
),
summaries_only = c(
mean(mu_tau_summaries$mu),
mean(mu_tau_summaries$tau)
)
) %>%
mutate(difference = abs(full_posteriors - summaries_only))
print(comparison)
#> parameter full_posteriors summaries_only difference
#> 1 mu 0.049654685 0.049563051 9.163467e-05
#> 2 tau 0.006243329 0.006270951 2.762236e-05
cat("\nMaximum difference:", round(max(comparison$difference), 5), "\n")
#>
#> Maximum difference: 9e-05Conclusion: Both paths give nearly identical results because posteriors are approximately normal in this case. This will not always be true.
When each path is appropriate:
| Situation | Recommended Path | Reason |
|---|---|---|
| Posteriors are normal (verified) | Path B acceptable | CLT holds; minimal sharing |
| Posteriors are skewed/multimodal | Path A required | CLT fails; mixture needed |
| Small sample sizes per site | Path A safer | CLT may not hold yet |
| Boundary constraints | Path A required | CLT assumes interior parameters |
| Unknown posterior shape | Path A safer | Conservative choice |
| Maximum privacy needed and normal posteriors | Path B acceptable | But verify normality |
Key insights:
# Get Stage 2 estimates
theta_post <- summarize_theta(fit_full_post)
# Compare Stage 1 vs Stage 2 uncertainty
uncertainty_comparison <- data.frame(
site = site_summaries$site,
n_patients = site_summaries$n_patients,
stage1_se = site_summaries$beta_age_se,
stage2_se = theta_post$sd
) %>%
mutate(
reduction_pct = 100 * (stage1_se - stage2_se) / stage1_se,
stage1_ci_width = 2 * 1.96 * stage1_se,
stage2_ci_width = 2 * 1.96 * stage2_se,
ci_width_reduction = 100 * (stage1_ci_width - stage2_ci_width) / stage1_ci_width
)
print(uncertainty_comparison)
#> site n_patients stage1_se stage2_se reduction_pct
#> 1 Metro General 1500 0.01428679 0.008517379 40.38284
#> 2 County Regional 800 0.01992675 0.009310134 53.27820
#> 3 University Medical 2200 0.01246048 0.008245478 33.82694
#> 4 Community Hospital 350 0.03047231 0.010497742 65.54990
#> 5 Veterans Affairs 1100 0.01752091 0.009577464 45.33695
#> 6 Children's Specialty 900 0.01888196 0.009266568 50.92370
#> stage1_ci_width stage2_ci_width ci_width_reduction
#> 1 0.05600421 0.03338812 40.38284
#> 2 0.07811285 0.03649572 53.27820
#> 3 0.04884507 0.03232228 33.82694
#> 4 0.11945147 0.04115115 65.54990
#> 5 0.06868197 0.03754366 45.33695
#> 6 0.07401729 0.03632495 50.92370Largest improvements occur in smaller sites.
# Prepare data for plotting
uncertainty_long <- uncertainty_comparison %>%
select(site, n_patients, stage1_se, stage2_se) %>%
pivot_longer(
cols = c(stage1_se, stage2_se),
names_to = "stage",
values_to = "standard_error"
) %>%
mutate(
stage = factor(stage,
levels = c("stage1_se", "stage2_se"),
labels = c("Stage 1 (Independent)", "Stage 2 (Shrunken)"))
)
ggplot(uncertainty_long, aes(x = reorder(site, -n_patients), y = standard_error,
fill = stage)) +
geom_col(position = "dodge") +
geom_text(aes(label = sprintf("%.4f", standard_error)),
position = position_dodge(width = 0.9),
vjust = -0.5, size = 3) +
scale_fill_manual(values = c("Stage 1 (Independent)" = "steelblue",
"Stage 2 (Shrunken)" = "coral")) +
labs(
title = "Uncertainty Reduction Through Federated Learning",
subtitle = "Sites ordered by sample size (largest to smallest)",
x = "Hospital Site",
y = "Standard Error",
fill = NULL
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom"
)# Example: 70-year-old patient
age <- 70
baseline_age <- 60 # Reference age
# Stage 1 predictions (independent)
stage1_log_or <- site_summaries$beta_age_mean * (age - baseline_age)
stage1_or <- exp(stage1_log_or)
stage1_preds <- data.frame(
site = site_summaries$site,
log_or = stage1_log_or,
odds_ratio = stage1_or,
stage = "Independent"
)# Stage 2 predictions (network-calibrated)
stage2_log_or <- theta_post$mean * (age - baseline_age)
stage2_or <- exp(stage2_log_or)
stage2_preds <- data.frame(
site = theta_post$group,
log_or = stage2_log_or,
odds_ratio = stage2_or,
stage = "Network-Calibrated"
)
# Combine
all_preds <- rbind(stage1_preds, stage2_preds)ggplot(all_preds, aes(x = site, y = odds_ratio, fill = stage)) +
geom_col(position = "dodge") +
geom_hline(yintercept = 1, linetype = "dashed", color = "gray30") +
geom_text(aes(label = sprintf("%.2f", odds_ratio)),
position = position_dodge(width = 0.9),
vjust = -0.5, size = 3) +
scale_fill_manual(values = c("Independent" = "steelblue",
"Network-Calibrated" = "coral")) +
labs(
title = "Predicted Odds Ratio for 70 vs 60 Year-Old Patient",
subtitle = "Network calibration stabilizes predictions across sites",
x = "Hospital Site",
y = "Odds Ratio",
fill = NULL
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom"
)Clinical interpretation:
Certain data privacy policies satisfied:
Sites use different model specifications:
# Site 1: Linear model
# Site 2: GLM with splines
# Site 3: Bayesian hierarchical model
# As long as they all estimate the same parameter, shrinkr can combine them.
samples_heterogeneous <- list(
site1 = samples_from_lm,
site2 = samples_from_glm,
site3 = samples_from_bayes
)
# Proceed with shrinkr as usual
mix <- fit_mixture(samples_heterogeneous, K_max = 3)
fit <- shrink(mix, hierarchical_priors = priors)Combine published results without raw data:
# Extracted from publications
published_estimates <- c(
"Smith et al. (2020)" = 0.45,
"Jones et al. (2021)" = 0.52,
"Garcia et al. (2022)" = 0.38,
"Williams et al. (2023)" = 0.48
)
published_ses <- c(0.12, 0.15, 0.10, 0.13)
# Apply shrinkr for Bayesian meta-analysis
# NOTE: This assumes published estimates are approximately normal.
fit_meta <- shrink(
mle = published_estimates,
var_matrix = published_ses^2,
hierarchical_priors = priors
)New sites join the network over time:
# Initial network
fit_initial <- shrink(samples_initial, priors)
# New site joins
samples_updated <- c(samples_initial, list(new_site = new_samples))
fit_updated <- shrink(samples_updated, priors)
# Compare network estimates before/after
mu_before <- mean(extract_mu_tau(fit_initial)$mu)
mu_after <- mean(extract_mu_tau(fit_updated)$mu)Before sharing any summaries:
Ensure comparability:
If using Path B, sites must:
Red flags for non-normality:
When in doubt, use Path A.
Central coordinator should:
# Example: Flag suspicious estimates
qc_results <- site_summaries %>%
mutate(
z_score = (beta_age_mean - median(beta_age_mean)) / mad(beta_age_mean),
flag = ifelse(abs(z_score) > 3, "Review", "OK")
)
cat("Quality control flags:\n")
#> Quality control flags:
print(qc_results %>% select(site, beta_age_mean, z_score, flag))
#> site beta_age_mean z_score flag
#> Metro General Metro General 0.04958522 0.1321992 OK
#> County Regional County Regional 0.05905738 1.0300204 OK
#> University Medical University Medical 0.04482541 -0.3189611 OK
#> Community Hospital Community Hospital 0.01077674 -3.5462732 Review
#> Veterans Affairs Veterans Affairs 0.06783668 1.8621681 OK
#> Children's Specialty Children's Specialty 0.04679578 -0.1321992 OKTest robustness to prior specifications:
# Alternative prior: More heterogeneity
priors_alt <- list(
mu = dist_normal(0.05, 0.025),
tau = dist_truncated(dist_student_t(3, 0, 0.02), lower = 0)
)
fit_alt <- shrink(
mixture = mix,
hierarchical_priors = priors_alt,
chains = 2,
iter = 1000,
warmup = 500,
seed = 456
)# Compare key results
mu_base <- mean(extract_mu_tau(fit_full_post)$mu)
mu_alt <- mean(extract_mu_tau(fit_alt)$mu)
tau_base <- mean(extract_mu_tau(fit_full_post)$tau)
tau_alt <- mean(extract_mu_tau(fit_alt)$tau)
cat("Sensitivity to prior on tau:\n")
#> Sensitivity to prior on tau:
cat(" mu: Base =", round(mu_base, 4), ", Alternative =", round(mu_alt, 4), "\n")
#> mu: Base = 0.0497 , Alternative = 0.0493
cat(" tau: Base =", round(tau_base, 4), ", Alternative =", round(tau_alt, 4), "\n")
#> tau: Base = 0.0062 , Alternative = 0.0086Share with network participants:
# Create site-specific report
site_report <- data.frame(
site = theta_post$group,
original_estimate = site_summaries$beta_age_mean,
original_se = site_summaries$beta_age_se,
calibrated_estimate = theta_post$mean,
calibrated_se = theta_post$sd,
uncertainty_reduction = uncertainty_comparison$reduction_pct
) %>%
mutate(across(where(is.numeric), ~round(.x, 4)))
cat("\nFederated Learning Results Report\n")
#>
#> Federated Learning Results Report
cat("==================================\n\n")
#> ==================================
cat("Network-level estimate (mu):", round(mean(mu_tau_full$mu), 4), "\n")
#> Network-level estimate (mu): 0.0497
cat("Between-site heterogeneity (tau):", round(mean(mu_tau_full$tau), 4), "\n\n")
#> Between-site heterogeneity (tau): 0.0062
cat("Site-specific calibrated estimates:\n")
#> Site-specific calibrated estimates:
print(site_report)
#> site original_estimate original_se calibrated_estimate
#> 1 Metro General 0.0496 0.0143 0.0498
#> 2 County Regional 0.0591 0.0199 0.0508
#> 3 University Medical 0.0448 0.0125 0.0487
#> 4 Community Hospital 0.0108 0.0305 0.0474
#> 5 Veterans Affairs 0.0678 0.0175 0.0521
#> 6 Children's Specialty 0.0468 0.0189 0.0493
#> calibrated_se uncertainty_reduction
#> 1 0.0085 40.3828
#> 2 0.0093 53.2782
#> 3 0.0082 33.8269
#> 4 0.0105 65.5499
#> 5 0.0096 45.3370
#> 6 0.0093 50.9237| Feature | Benefit |
|---|---|
| Two-stage design | Clean separation between local Stage 1 and collaborative Stage 2 analysis |
| Flexible sharing options | Can share full posteriors, mixture approximations, or summaries if CLT holds |
| Privacy preserving | No patient-level data exposure |
| Flexible Stage 1 | Each site can use their preferred modeling approach |
| Transparent shrinkage | Sites understand how their estimates are adjusted |
| Uncertainty quantification | Proper propagation of both within-site and between-site uncertainty |
| Handles non-normality | Mixture approximation works for skewed or multimodal posteriors |
| Regulatory friendly | Supports HIPAA, GDPR, and institutional privacy constraints |
Ideal scenarios:
Requirements:
Not recommended when:
The shrinkr package enables privacy-preserving federated learning through its two-stage design:
Key advantages:
Critical decision: Which path?
sessionInfo()
#> R version 4.4.2 (2024-10-31 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 10 x64 (build 19045)
#>
#> Matrix products: default
#>
#>
#> locale:
#> [1] LC_COLLATE=C
#> [2] LC_CTYPE=English_United States.utf8
#> [3] LC_MONETARY=English_United States.utf8
#> [4] LC_NUMERIC=C
#> [5] LC_TIME=English_United States.utf8
#>
#> time zone: America/Chicago
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] patchwork_1.3.2 posterior_1.7.0 survival_3.7-0
#> [4] lubridate_1.9.5 forcats_1.0.1 stringr_1.6.0
#> [7] dplyr_1.2.1 purrr_1.2.2 readr_2.2.0
#> [10] tidyr_1.3.2 tibble_3.3.1 ggplot2_4.0.3
#> [13] tidyverse_2.0.0 distributional_0.7.1 tidybayes_3.0.7
#> [16] brms_2.23.0 Rcpp_1.1.1 shrinkr_0.4.5
#>
#> loaded via a namespace (and not attached):
#> [1] tidyselect_1.2.1 svUnit_1.0.8 farver_2.1.2
#> [4] loo_2.9.0 S7_0.2.2 fastmap_1.2.0
#> [7] tensorA_0.36.2.1 digest_0.6.39 estimability_1.5.1
#> [10] timechange_0.4.0 lifecycle_1.0.5 StanHeaders_2.32.10
#> [13] magrittr_2.0.5 compiler_4.4.2 rlang_1.2.0
#> [16] sass_0.4.10 tools_4.4.2 utf8_1.2.6
#> [19] yaml_2.3.12 knitr_1.51 labeling_0.4.3
#> [22] bridgesampling_1.2-1 pkgbuild_1.4.8 mclust_6.1.2
#> [25] curl_7.1.0 RColorBrewer_1.1-3 abind_1.4-8
#> [28] withr_3.0.2 grid_4.4.2 stats4_4.4.2
#> [31] xtable_1.8-8 inline_0.3.21 emmeans_2.0.3
#> [34] scales_1.4.0 cli_3.6.6 mvtnorm_1.4-1
#> [37] rmarkdown_2.31 generics_0.1.4 otel_0.2.0
#> [40] RcppParallel_5.1.11-2 rstudioapi_0.19.0 tzdb_0.5.0
#> [43] cachem_1.1.0 rstan_2.32.7 splines_4.4.2
#> [46] bayesplot_1.15.0 parallel_4.4.2 matrixStats_1.5.0
#> [49] vctrs_0.7.3 V8_8.2.0 Matrix_1.7-1
#> [52] jsonlite_2.0.0 hms_1.1.4 arrayhelpers_1.1-0
#> [55] ggdist_3.3.3 jquerylib_0.1.4 glue_1.8.1
#> [58] codetools_0.2-20 stringi_1.8.7 gtable_0.3.6
#> [61] QuickJSR_1.9.2 pillar_1.11.1 htmltools_0.5.9
#> [64] Brobdingnag_1.2-9 R6_2.6.1 evaluate_1.0.5
#> [67] lattice_0.22-6 backports_1.5.1 bslib_0.11.0
#> [70] rstantools_2.6.0 coda_0.19-4.1 gridExtra_2.3
#> [73] nlme_3.1-166 checkmate_2.3.4 xfun_0.57
#> [76] pkgconfig_2.0.3