--- title: "Federated Learning with shrinkr" author: "Jacob M. Maronge" date: "`r Sys.Date()`" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Federated Learning with shrinkr} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 8, fig.height = 6, warning = FALSE, message = FALSE ) ``` ## Introduction **Federated learning** enables collaborative analysis across multiple sites without centralizing data. This is critical when: - Data cannot leave institutional firewalls (HIPAA, GDPR) - Each site has proprietary or sensitive data - Centralizing data is logistically infeasible - You want to preserve patient privacy **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. ```{r packages} library(shrinkr) library(distributional) library(posterior) library(dplyr) library(ggplot2) library(tidyr) ``` ## Use Case: Multi-Hospital Mortality Prediction We'll analyze a federated clinical prediction model across 6 hospitals. Each hospital: - Has developed a logistic regression model for 30-day mortality - Cannot share patient-level data (HIPAA compliance) - Wants to improve predictions by borrowing strength across sites **Goal**: Combine site-specific models while respecting data governance constraints. ## Scenario Setup ### The Federated Network ```{r network_setup} 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) ``` ### The Clinical Model Each 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) - We want site-specific estimates - But want to borrow strength across the network ## Federated Workflow ### Step 1: Each Site Fits Independently In practice, this happens behind each site's firewall. We simulate: ```{r site_models} 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) ``` **Key observations:** - Smaller sites, for example Community Hospital, have wider intervals - Point estimates vary across sites - The rural site with fewer patients is least precise ### Step 2: Sites Share Summaries with Coordinator Two federated learning paths are possible: #### Path A: Share Full Posteriors (if permitted) ```{r share_posteriors} # Sites share posterior samples (4000 draws each) # This is more informative but requires more data transfer cat("Data shared per site:\n") cat(" Posterior samples: 4000 draws\n") cat(" Total data transfer:", 6 * 4000 * 8, "bytes (", round(6 * 4000 * 8 / 1024, 1), "KB)\n") ``` #### Path B: Share Only Summary Statistics (requires assumptions) ```{r share_summaries} # Sites share only mean and SE # Minimal data transfer, maximum privacy # BUT: Only valid if posteriors are approximately normal! summary_only <- site_summaries %>% select(site, beta_age_mean, beta_age_se) cat("Data shared per site:\n") cat(" Point estimate: 1 number\n") cat(" Standard error: 1 number\n") cat(" Total data transfer:", 6 * 2 * 8, "bytes (", round(6 * 2 * 8 / 1024, 3), "KB)\n") print(summary_only) ``` **Privacy consideration**: Path B shares much less data than Path A. **Critical assumption**: Path B relies on the **Bayesian Central Limit Theorem**: - Large sample sizes at each site - Parameters not near boundaries - Well-behaved likelihoods - Regular posterior geometry ## Central Coordinator: Stage 2 Shrinkage The coordinator, for example a coordinating center or trusted third party, now applies hierarchical shrinkage. ### Path A: Using Full Posteriors ```{r path_a_mixture} # 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") ``` #### Specify Network-Level Priors ```{r network_priors} # 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 Hierarchical Model ```{r fit_path_a, results='hide'} fit_full_post <- shrink( mixture = mix, hierarchical_priors = hierarchical_priors, chains = 4, iter = 2000, warmup = 1000, seed = 123 ) ``` ```{r path_a_results} print(fit_full_post) # Network-level estimates mu_tau_full <- extract_mu_tau(fit_full_post) cat("\nNetwork-level age effect (mu):\n") cat(" Posterior mean:", round(mean(mu_tau_full$mu), 4), "\n") cat(" 95% CI: [", round(quantile(mu_tau_full$mu, 0.025), 4), ",", round(quantile(mu_tau_full$mu, 0.975), 4), "]\n") cat("\nBetween-site heterogeneity (tau):\n") cat(" Posterior mean:", round(mean(mu_tau_full$tau), 4), "\n") cat(" 95% CI: [", round(quantile(mu_tau_full$tau, 0.025), 4), ",", round(quantile(mu_tau_full$tau, 0.975), 4), "]\n") ``` ### Path B: Using Only Summary Statistics **Before using summary statistics**, we must verify posteriors are approximately normal. #### Step 1: Check Normality Assumption ```{r check_normality, fig.width=10, fig.height=8} # 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) } par(oldpar) ``` ```{r normality_tests} # 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) cat("\nNormality assessment:\n") cat(" Skewness close to 0? (|skew| < 0.5 is good)\n") cat(" Kurtosis close to 0? (|kurt| < 1.0 is good)\n") cat(" All sites pass:", all(abs(normality_checks$skewness) < 0.5 & abs(normality_checks$kurtosis) < 1.0), "\n") ``` **Decision rule**: - If posteriors look approximately normal, Path B is likely valid - If posteriors are skewed/heavy-tailed, use Path A - If unsure, use Path A #### When CLT Fails: A Counter-Example To illustrate why normality matters, consider a scenario where we are estimating a **variance parameter**: ```{r clt_failure_example} # 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") cat("This posterior is right-skewed - CLT approximation would be poor!\n") ``` **For this scenario:** - Path B summaries would give biased results - Path A mixture handles the skewness correctly #### Step 2: Fit Using CLT Approximation ```{r path_b_mle} # 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) ``` ### Compare Paths ```{r compare_paths} mu_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) cat("\nMaximum difference:", round(max(comparison$difference), 5), "\n") ``` **Conclusion**: 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 | ## Results: Improved Site-Specific Estimates ### Visualize Shrinkage Effect ```{r viz_shrinkage, fig.width=10, fig.height=6} # Using Path A results (nearly identical for Path B) plot(fit_full_post) ``` **Key insights:** - **Community Hospital** shrinks most toward the network mean - **University Medical** keeps closest to its original estimate - **Metro General** and **VA Hospital** are pulled slightly toward the center - Adaptive borrowing is based on precision ### Quantify Uncertainty Reduction ```{r uncertainty_reduction} # 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) ``` **Largest improvements** occur in smaller sites. ### Visualize Uncertainty Reduction ```{r plot_uncertainty, fig.width=10, fig.height=6} # 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" ) ``` ## Clinical Impact: Network-Calibrated Predictions ### Stage 1: Independent Site Predictions ```{r stage1_predictions} # 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: Network-Calibrated Predictions ```{r stage2_predictions} # 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) ``` ### Visualize Prediction Changes ```{r plot_predictions, fig.width=10, fig.height=6} 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:** - Independent estimates vary across sites - Network-calibrated estimates are more consistent - Small sites benefit most from network information - Meaningful site-specific variation is preserved ## Privacy-Preserving Benefits ### What Gets Shared ```{r privacy_summary} privacy_comparison <- data.frame( approach = c("Centralized Data", "Path A: Full Posteriors", "Path B: Summaries"), patient_data_shared = c("Yes - All records", "No", "No"), data_per_site = c("about 50-200 MB", "about 200 KB", "about 16 bytes"), privacy_risk = c("High", "Low", "Minimal"), validity = c("N/A", "Always valid", "Only if CLT holds"), when_to_use = c("Not for federated", "Default choice", "When posteriors normal") ) knitr::kable(privacy_comparison, align = "lccccl") ``` **Key insight**: Path A shares much less data than centralized analysis while being valid in all scenarios. Path B shares even less data but requires approximate posterior normality. ### Compliance Benefits **Certain data privacy policies satisfied:** - No patient-level data leaves sites - Only aggregate statistical summaries are shared - De-identified parameter estimates are exchanged ## Advanced Federated Scenarios ### Scenario 1: Heterogeneous Models Sites use different model specifications: ```{r heterogeneous_models, eval=FALSE} # 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) ``` ### Scenario 2: Meta-Analysis of Published Studies Combine published results without raw data: ```{r meta_analysis, eval=FALSE} # 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 ) ``` ### Scenario 3: Iterative Federated Updates New sites join the network over time: ```{r iterative_updates, eval=FALSE} # 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) ``` ## Federated Learning Best Practices ### 1. Establish Data Governance **Before sharing any summaries:** - Define what parameters will be shared - Establish data use agreements for summary statistics - Document privacy protections - Get institutional approval ### 2. Standardize Stage 1 Models **Ensure comparability:** - Use consistent outcome definitions - Standardize predictor coding/scaling - Document any site-specific modifications - Agree on parameter naming conventions ### 3. Verify Normality if Using Summaries **If using Path B, sites must:** - Generate QQ plots of posteriors - Compute skewness and excess kurtosis - Share diagnostic plots with the coordinator - Agree normality is reasonable before proceeding **Red flags for non-normality:** - Boundary constraints, such as variance parameters or probabilities - Small sample sizes - Highly skewed or heavy-tailed data **When in doubt, use Path A**. ### 4. Quality Control **Central coordinator should:** - Check for outliers in shared summaries - Verify mixture approximation quality for Path A - Assess prior-data conflicts - Report back site-specific diagnostics ```{r quality_control} # 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") print(qc_results %>% select(site, beta_age_mean, z_score, flag)) ``` ### 5. Sensitivity Analysis Test robustness to prior specifications: ```{r sensitivity, results='hide'} # 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 ) ``` ```{r sensitivity_compare} # 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") cat(" mu: Base =", round(mu_base, 4), ", Alternative =", round(mu_alt, 4), "\n") cat(" tau: Base =", round(tau_base, 4), ", Alternative =", round(tau_alt, 4), "\n") ``` ### 6. Transparent Reporting **Share with network participants:** - Network-level estimates - Site-specific shrunken estimates - Diagnostics, including convergence and sensitivity - Quantified uncertainty reduction ```{r reporting} # 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") cat("==================================\n\n") cat("Network-level estimate (mu):", round(mean(mu_tau_full$mu), 4), "\n") cat("Between-site heterogeneity (tau):", round(mean(mu_tau_full$tau), 4), "\n\n") cat("Site-specific calibrated estimates:\n") print(site_report) ``` ## Advantages of shrinkr for Federated Learning | 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 | ## When to Use Federated shrinkr **Ideal scenarios:** - Multi-center clinical trials - Hospital network collaborations - International consortia - Meta-analyses with limited published data - Any setting where data cannot be centralized **Requirements:** - Sites can fit Bayesian models independently - Sites can share posterior samples or mixture approximations - Or sites can share means and SEs and posteriors are approximately normal - Common parameter of interest across sites - Central coordinator to run Stage 2 **Not recommended when:** - Sites have very different populations - Sites cannot agree on parameter definitions - Posteriors are highly non-normal and sites cannot share full posteriors or mixtures ## Summary The shrinkr package enables **privacy-preserving federated learning** through its two-stage design: 1. **Stage 1**: Fit local models behind each site's firewall 2. **Check normality**: Verify CLT assumptions if using summaries 3. **Share**: Full posteriors, mixtures, or summaries 4. **Stage 2**: Apply hierarchical shrinkage centrally 5. **Return**: Improved site-specific estimates **Key advantages:** - Data sovereignty preserved - Flexible sharing options - Handles non-normal posteriors via mixture approximation - Improved estimates for all sites - Proper uncertainty quantification - Compatible with privacy-focused workflows **Critical decision: Which path?** - **Path A**: Always valid and handles any posterior shape - **Path B**: Valid only if posteriors are approximately normal - **When uncertain**: Use Path A ## Additional Resources ```{r resources, eval=FALSE} # See also: vignette("getting_started") vignette("tidy_bayesian_workflow") vignette("brms_integration") ``` ## Session Info ```{r sessioninfo} sessionInfo() ```