library(ggplot2)# Prepare data for plottingplot_data <- results %>%select(Population, pooled_OR, pooled_OR_LCI, pooled_OR_UCI) %>%mutate(Type ="Population-Specific")global_plot_data <-data.frame(Population ="Global",pooled_OR = global_effect$global_or,pooled_OR_LCI = global_effect$global_or_lci,pooled_OR_UCI = global_effect$global_or_uci,Type ="Global")plot_data <-rbind(plot_data, global_plot_data)# Create the plotggplot(plot_data, aes(y = Population, x = pooled_OR, xmin = pooled_OR_LCI, xmax = pooled_OR_UCI, color = Population)) +scale_color_nejm()+geom_pointrange(position =position_dodge(width =0.25), size =0.5) +geom_vline(xintercept =1, linetype ="dashed", color ="black") +scale_x_log10() +# Log scale for better visualizationtheme_minimal() +labs(x ="Odds Ratio (95% CI)", y ="", title ="Global and Population-Specific Effects") +theme(axis.text.x =element_text(angle =45, hjust =1), legend.position ="right")
Code
#
Can we estiamte heterogeneity?
Code
data=results# Calculate weights as the inverse of the variancedata$weight <-1/ (data$pooled_log_or_se^2)# Calculate the weighted mean of the effect sizesweighted_mean_effect <-sum(data$weight * data$pooled_log_or) /sum(data$weight)# Calculate the Q statisticdata$squared_differences <- data$weight * (data$pooled_log_or - weighted_mean_effect)^2Q <-sum(data$squared_differences)# Degrees of freedom for the Q statistic is k - 1df <-nrow(data) -1# Calculate the p-value for the Q statistic under a chi-square distributionp_value <-pchisq(Q, df, lower.tail =FALSE)cat("Weighted Mean Effect Size:", weighted_mean_effect, "\n")
Weighted Mean Effect Size: 0.4810095
Code
cat("Q statistic:", Q, "\n")
Q statistic: 28.05673
Code
cat("Degrees of freedom:", df, "\n")
Degrees of freedom: 5
Code
cat("P-value for Q statistic:", p_value, "\n")
P-value for Q statistic: 3.5481e-05
Code
### omit EASdata=results[results$Population%in%c("AMR","AFR"),]data$weight <-1/ (data$pooled_log_or_se^2)# Calculate the weighted mean of the effect sizesweighted_mean_effect <-sum(data$weight * data$pooled_log_or) /sum(data$weight)data$squared_differences <- data$weight * (data$pooled_log_or - weighted_mean_effect)^2Q <-sum(data$squared_differences)# Degrees of freedom for the Q statistic is k - 1df <-nrow(data) -1# Calculate the p-value for the Q statistic under a chi-square distributionp_value <-pchisq(Q, df, lower.tail =FALSE)cat("Weighted Mean Effect Size:", weighted_mean_effect, "\n")
Weighted Mean Effect Size: 0.255167
Code
cat("Q statistic:", Q, "\n")
Q statistic: 0.2469343
Code
cat("Degrees of freedom:", df, "\n")
Degrees of freedom: 1
Code
cat("P-value for Q statistic:", p_value, "\n")
P-value for Q statistic: 0.6192421
Code
#Bayes analysis:#First conjugate normal approimation using weighted global mean as prior:library(brms)global_log_or_value=global_effect$global_log_orse_global_log_or_value=global_effect$se_global_log_orprior_mean=global_log_or_valueprior_variance=se_global_log_or_value^2# Calculate variance and weightsresults <- results %>%mutate(Variance = pooled_log_or_se^2,Weight =1/ Variance )# Perform Bayesian updating for each populationresults <- results %>%mutate(Posterior_Variance =1/ (1/ prior_variance +1/ Variance),Posterior_Mean = Posterior_Variance * (prior_mean / prior_variance + pooled_log_or / Variance) )print(results[,c("Population","pooled_log_or","pooled_log_or_se","Posterior_Mean","Posterior_Variance")])
Code
# And now with MCMC sampling#To get an idea of heterogeneitymeta_model <-brm(bf(pooled_log_or |se(pooled_log_or_se) ~1+ (1| Population)),data = results,family =gaussian(),prior =c(set_prior(sprintf("normal(%f, %f)", global_log_or_value, se_global_log_or_value), class ="Intercept"),prior(cauchy(0, 1), class ="sd") ))bayes_model=list()for(pop inunique(results$Population)) { pop_data <-filter(results, Population == pop) bayes_model[[pop]] <-brm(bf(pooled_log_or ~1),data = pop_data,family =gaussian(),prior =c(set_prior(sprintf("normal(%f, %f)", global_log_or_value, se_global_log_or_value), class ="Intercept")),chains =4, iter =2000, warmup =1000,silent =2, )# Print or plot the results for each population as needed}meta_model <-brm(bf(pooled_log_or |se(pooled_log_or_se) ~1+ (1| Population)),data = results,family =gaussian(),prior =c(prior(normal(0, 1), class ="Intercept"),prior(cauchy(0, 1), class ="sd") # Prior for between-population variability ))library(dplyr)library(tidyr)posterior_summary <-lapply(seq(1:length(unique(results$Population))), function(x) {cbind(as.data.frame(summary(bayes_model[[x]])$fixed),Population =unique(results$Population)[x])}) %>%bind_rows() %>%select(Population, Estimate = Estimate, CI.Lower =`l-95% CI`, CI.Upper =`u-95% CI`) %>%mutate(Type ="Posterior")# Ensure Population is a factor for consistent ordering across plotsposterior_summary$Population <-factor(posterior_summary$Population, levels =unique(results$Population))ps_df=posterior_summary[,c("Population","Estimate","CI.Lower","CI.Upper","Type")]re_df=results[,c("Population","pooled_log_or","pooled_log_or_se")]re_df$lower_ci_lo_or=re_df$pooled_log_or-1.96*re_df$pooled_log_or_sere_df$upper_ci_lo_or=re_df$pooled_log_or+1.96*re_df$pooled_log_or_sere_df=re_df[,c("Population","pooled_log_or","lower_ci_lo_or","upper_ci_lo_or")]re_df$Type="Original"colnames(ps_df)=colnames(re_df)r=rbind(re_df,ps_df)ggplot(r, aes(y = Population, x =exp(pooled_log_or), color = Population,shape=Type)) +geom_point(position =position_dodge(width =0.25)) +geom_errorbar(aes(xmin =exp(lower_ci_lo_or), xmax =exp(upper_ci_lo_or)),width =0.2, position =position_dodge(width =0.25)) +theme_classic() +labs(x ="Bayesian Posterior on Pooled OR (95% CI)", y ="Population",shape="Estimate Type")+theme(axis.text.x =element_text(angle =45, hjust =1))### draw from populationposterior_distributions <-lapply(names(bayes_model), function(pop) {as_draws_df(bayes_model[[pop]], variable ="b_Intercept") %>%mutate(Population = pop)}) %>%bind_rows()ggplot(posterior_distributions, aes(x =exp(b_Intercept), fill = Population)) +geom_density(alpha =0.7) +facet_wrap(~Population, scales ="free") +labs(x ="Posterior distribution of Intercept (OR)", y ="Density") +theme_classic()+scale_fill_nejm()
Fixed-Effect Model (FE)
Assumptions:
Homogeneity: Assumes that all studies are estimating the same underlying effect size. Any observed variability across study estimates is due to within-study sampling error.
Weighting: Studies are weighted by the inverse of their variance. Larger studies (lower variance) have more influence on the pooled estimate.
Mathematical Formulation:
Pooled Estimate: The weighted average of the effect sizes, where weights are wi=1/σi2wi=1/σi2 (σi2σi2 is the variance of the effect size estimate for study ii).
Confidence Interval: Based on the standard error of the pooled estimate, which is determined by the inverse of the square root of the sum of the weights.
Tau-squared (τ²) and I-squared (I²):
τ² (Tau-squared): Not applicable or assumed to be 0 because between-study variability is not modeled in a fixed-effect framework.
I² (I-squared): Although not directly calculated in a traditional FE model, it can be interpreted in the context of how much variability is observed versus what would be expected under the assumption of homogeneity.
Random-Effects Model (REML)
Assumptions:
Heterogeneity: Acknowledges that the true effect sizes may vary across studies due to differences in study populations, interventions, or outcomes.
Weighting: Studies are weighted by the inverse of the sum of their variance and an estimate of between-study variance (τ²). This accounts for both within-study variance and between-study variability.
Mathematical Formulation:
Pooled Estimate: Similar to the FE model but includes between-study variability in the weighting scheme. Weights become wi=1/(σi2+τ2)wi=1/(σi2+τ2).
Confidence Interval: Also considers the between-study variability in its calculation, which can lead to wider confidence intervals than the FE model.
Tau-squared (τ²) and I-squared (I²):
τ² (Tau-squared): Estimated from the data, reflecting the variance of the true effect sizes across studies.
I² (I-squared): Quantifies the proportion of total variation in study estimates due to heterogeneity rather than chance. Calculated using τ² and the within-study variances.
Why Results Differ:
Incorporation of Between-Study Variability: The key difference comes from how each model treats between-study variability. The REML model directly estimates and incorporates τ², leading to different weighting of studies compared to the FE model and potentially wider confidence intervals.
Estimation of Heterogeneity: I² values can differ because they’re calculated based on τ² in the REML model. In the FE model, any observed variability beyond sampling error is not explicitly modeled, so calculating I² in this context reflects how poorly the homogeneity assumption holds.
Pooled Effect Estimate: Differences in weighting schemes can lead to different pooled effect estimates. The REML model can give more weight to smaller studies when there’s significant between-study variability, potentially leading to a different overall effect size than the FE model.
Overall
We’d also like to plot a meta analysis by study:
Code
library(metafor)library(meta)
Loading 'meta' package (version 7.0-0).
Type 'help(meta)' for a brief overview.
Readers of 'Meta-Analysis with R (Use R!)' should install
older version of 'meta' package: https://tinyurl.com/dt4y5drs
Code
study_data=df_rstudy_data$identifier <-paste(study_data$study, study_data$Population, sep ="_")# Perform the meta-analysismeta_analysis <-metagen(TE = study_data$log_or,seTE = study_data$se_log_or,studlab = study_data$identifier,data = study_data,sm ="OR"# Specify that the summary measure is Odds Ratio)# Summary of the meta-analysissummary(meta_analysis)
OR 95%-CI %W(common) %W(random)
aou_AFR 1.3351 [1.1718; 1.5212] 5.0 11.9
mgb_AFR 1.0268 [0.8316; 1.2679] 1.9 9.3
ukb_AFR 1.3705 [0.9646; 1.9471] 0.7 5.7
aou_AMR 1.3329 [1.0984; 1.6174] 2.3 9.8
mgb_AMR 1.3017 [1.0285; 1.6474] 1.5 8.5
ukb_AMR 3.3970 [0.8092; 14.2602] 0.0 0.5
aou_EAS 1.1541 [0.5587; 2.3840] 0.2 1.9
bbj_EAS NA 0.0 0.0
mgb_EAS 1.8358 [0.6448; 5.2271] 0.1 1.0
ukb_EAS 0.9218 [0.3597; 2.3619] 0.1 1.2
aou_EUR 1.4414 [1.3240; 1.5693] 11.8 13.2
mgb_EUR 1.1717 [1.0750; 1.2772] 11.5 13.2
ukb_EUR 1.6972 [1.6363; 1.7604] 63.6 14.1
aou_MID 1.5448 [0.3006; 7.9384] 0.0 0.4
saudi_MID NA 0.0 0.0
aou_SAS 1.0137 [0.3901; 2.6338] 0.1 1.2
mgb_SAS 5.1003 [1.3807; 18.8408] 0.0 0.6
ukb_SAS 1.8027 [1.3748; 2.3637] 1.2 7.5
Number of studies: k = 16
OR 95%-CI z p-value
Common effect model 1.5442 [1.4998; 1.5899] 29.20 < 0.0001
Random effects model 1.3776 [1.2369; 1.5343] 5.83 < 0.0001
Quantifying heterogeneity:
tau^2 = 0.0210 [0.0051; 0.1630]; tau = 0.1451 [0.0712; 0.4037]
I^2 = 85.0% [77.0%; 90.2%]; H = 2.58 [2.08; 3.19]
Test of heterogeneity:
Q d.f. p-value
99.69 15 < 0.0001
Details on meta-analytical method:
- Inverse variance method
- Restricted maximum-likelihood estimator for tau^2
- Q-Profile method for confidence interval of tau^2 and tau
Code
# Forest plot to visualize the resultsforest(meta_analysis)
We can also use a fixed and random analysis, but this is completely agnostic to shared populations. The random effects assumes each study:opulation effectively arises from it’s own distritbuion
Code
library(metafor)fixed_effect_model <-rma(yi = log_or, sei = se_log_or, method ="FE", data = df_r)
Warning: 2 studies with NAs omitted from model fitting.
# Random-effects modelrandom_effects_model <-rma(yi = log_or, sei = se_log_or, method ="REML", data = df_r)
Warning: 2 studies with NAs omitted from model fitting.
Code
summary(random_effects_model)
Random-Effects Model (k = 16; tau^2 estimator: REML)
logLik deviance AIC BIC AICc
-3.1063 6.2126 10.2126 11.6287 11.2126
tau^2 (estimated amount of total heterogeneity): 0.0210 (SE = 0.0142)
tau (square root of estimated tau^2 value): 0.1451
I^2 (total heterogeneity / total variability): 78.13%
H^2 (total variability / sampling variability): 4.57
Test for Heterogeneity:
Q(df = 15) = 99.6887, p-val < .0001
Model Results:
estimate se zval pval ci.lb ci.ub
0.3203 0.0550 5.8281 <.0001 0.2126 0.4281 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Code
# Forest plot to visualize the resultspar(mfrow=c(1,2)) # Arrange plots side by sideforest(fixed_effect_model, main="Fixed Effect Model")forest(random_effects_model, main="Random Effects Model")
Code
#Let's try with a nested Hierarchical structure:library(metafor)# Fitting the multi-level random effects modelmulti_level_model <-rma.mv(yi = log_or, V = se_log_or^2, random =~1| study/Population, data = df_r, method ="REML")# Display the summary of the modelsummary(multi_level_model)library(tidyverse)random_effects <-ranef(multi_level_model)# Convert the list to a data frame for plotting# Note: Adjust the code below if the structure of your list differs# For study leveldf_study <-as.data.frame(random_effects$study) %>%rownames_to_column(var ="Study")# For study/population leveldf_population <-as.data.frame(random_effects$`study/Population`) %>%rownames_to_column(var ="StudyPopulation") %>%separate(StudyPopulation, into =c("Study", "Population"), sep ="/",remove =FALSE)# Combine the data frames for a unified plot (optional)df_combined <-bind_rows( df_study %>%mutate(Level ="Study"), df_population %>%mutate(Level ="Population"))ggplot(df_combined, aes(x = intrcpt, y = StudyPopulation, xmin = pi.lb, xmax = pi.ub, color = Level)) +geom_point() +geom_errorbar() +labs(x ="Random Effects Estimate (logOR)", y ="Study / Population", color ="Level") +theme_minimal() +theme(legend.position ="bottom")
Let’s run a Bayesian meta analysis:
Code
library(brms)
bayes_model <- brm(
bf(log_or ~ 0 + Population + (1|study), sigma ~ 0 + Population),
data = meta_data,
family = gaussian(),
prior = c(
prior(normal(0, 1), class = "b"),
prior(exponential(1), class = "sd")
),
chains = 4, iter = 8000, warmup = 2000,
control = list(adapt_delta = 0.99)
)
Code
# Meta-analysis for all populations (fixed effects)res_all <-rma(yi = log_or, sei = se_log_or, data =data.frame(df_r), method ="FE")# Meta-analysis for each ethnicity (fixed effects)meta_analysis_population <-function(pop) { pop_data <-subset(data, Population == pop)rma(yi = log_or, sei = se_log_or, data = pop_data, method ="FE")}# Get results for each populationresults <-lapply(unique(data$Population), meta_analysis_population)names(results) <-unique(data$Population)# Display summariescat("\nMeta-analysis for all populations:\n")print(summary(res_all))for (pop innames(results)) {cat("\nMeta-analysis for population:", pop, "\n")print(summary(results[[pop]]))}
Code
# Function to transform log OR to OR for forest plottransform_to_or <-function(x) {exp(x)}# Forest plot for all populations and individual studies in OR scaleforest(res_all, transf = transform_to_or,#atransf = exp,xlab="Odds Ratio", slab=paste(data$Population, data$study, sep=": "), main="Forest Plot of All Studies and Pooled Effect (Fixed Effects)")# Adding individual ethnicity pooled effects to the plotfor (pop innames(results)) {addpoly(results[[pop]], transf = transform_to_or,row =17-which(names(results) == pop) *0.5, mlab =paste("Pooled Effect:", pop))}# Add an overall pooled effectaddpoly(res_all, transf = transform_to_or,row =0, mlab ="Overall Pooled Effect")
Without SAS
Code
# Load necessary librarieslibrary(ggplot2)library(dplyr)library(metafor)# Create data frame excluding the problematic study (SAS:mgb)data <- meta_data# Add log(OR) and SE(log(OR)) to the data framedata <- data %>%mutate(log_or =log(OR),se_log_or = (log(OR_UCI) -log(OR_LCI)) / (2*1.96))# Meta-analysis for each ethnicity (fixed effects)meta_analysis_population <-function(pop) { pop_data <-subset(data, Population == pop) res <-rma(yi = log_or, sei = se_log_or, data = pop_data, method ="FE") pooled_OR <-exp(coef(res)) pooled_LCI <-exp(coef(res) -1.96*sqrt(vcov(res))) pooled_UCI <-exp(coef(res) +1.96*sqrt(vcov(res)))return(data.frame(Population = pop, study ="Pooled Effect", OR = pooled_OR, OR_LCI = pooled_LCI, OR_UCI = pooled_UCI))}# Apply meta-analysis for each population and combine resultspooled_results <-unique(data$Population) %>%lapply(meta_analysis_population) %>%bind_rows()# Combine original data with pooled resultscolnames(pooled_results)=c("Population","study","OR","OR_LCI","OR_UCI")combined_data <-bind_rows(data, pooled_results)# Perform global meta-analysis (fixed effects)res_global <-rma(yi = log_or, sei = se_log_or, data = data, method ="FE")global_OR <-exp(coef(res_global))global_LCI <-exp(coef(res_global) -1.96*sqrt(vcov(res_global)))global_UCI <-exp(coef(res_global) +1.96*sqrt(vcov(res_global)))global_effect <-data.frame(Population ="Global", study ="Overall Pooled Effect", OR = global_OR, OR_LCI = global_LCI, OR_UCI = global_UCI)colnames(global_effect)=c("Population","study","OR","OR_LCI","OR_UCI")# Combine global effect with combined datafinal_data <-bind_rows(combined_data, global_effect)# Order data for plottingfinal_data <- final_data %>%arrange(Population, desc(study =="Pooled Effect"))# Display the final dataprint(final_data)
Population study OR OR_LCI OR_UCI
intrcpt...1 AFR Pooled Effect 1.269009 1.1300978 1.424994
aou.AFR AFR aou 1.330000 1.1590580 1.523098
mgb.AFR AFR mgb 0.990000 0.7516421 1.304601
ukb.AFR AFR ukb 1.400000 0.9793945 2.020129
intrcpt...5 AMR Pooled Effect 1.335442 1.1328604 1.574250
aou.AMR AMR aou 1.340000 1.0966035 1.650002
mgb.AMR AMR mgb 1.300000 0.9814195 1.725766
ukb.AMR AMR ukb 2.430000 0.5411120 11.516530
intrcpt...9 EAS Pooled Effect 1.710216 1.6167753 1.809057
aou.EAS EAS aou 1.220000 0.5764083 2.588120
bbj.EAS EAS bbj 1.716756 1.6225191 1.816642
mgb.EAS EAS mgb 1.870000 0.6206754 6.188360
ukb.EAS EAS ukb 0.900000 0.3414912 2.440573
intrcpt...14 EUR Pooled Effect 1.625801 1.5714867 1.681993
aou.EUR EUR aou 1.450000 1.3237854 1.589785
mgb.EUR EUR mgb 1.150000 1.0409548 1.273449
ukb.EUR EUR ukb 1.750000 1.6809276 1.818275
intrcpt...18 Global Overall Pooled Effect 1.617707 1.5738266 1.662810
intrcpt...19 MID Pooled Effect 1.688036 1.3150236 2.166856
aou.MID MID aou 1.600000 0.2934386 9.028988
saudi.MID MID saudi 1.690000 1.3159595 2.180119
intrcpt...22 SAS Pooled Effect 1.994821 1.4956704 2.660555
aou.SAS SAS aou 1.170000 0.4213035 3.289799
mgb.SAS SAS mgb 16.920000 3.6238440 85.004439
ukb.SAS SAS ukb 1.930000 1.4216083 2.619395
log_or se_log_or HR HR_CI_Lower HR_CI_Upper
intrcpt...1 NA NA NA NA NA
aou.AFR 0.28517894 0.06967824 1.3351119 1.1718067 1.521176
mgb.AFR -0.01005034 0.14066131 1.0268351 0.8315990 1.267907
ukb.AFR 0.33647224 0.18468938 1.3704630 0.9646008 1.947095
intrcpt...5 NA NA NA NA NA
aou.AMR 0.29266961 0.10422419 1.3329000 1.0984158 1.617441
mgb.AMR 0.26236426 0.14398624 1.3016570 1.0284707 1.647408
ukb.AMR 0.88789126 0.78007969 3.3969733 0.8091850 14.260555
intrcpt...9 NA NA NA NA NA
aou.EAS 0.19885086 0.38313032 1.1541269 0.5587327 2.383982
bbj.EAS 0.54043626 0.02882902 NA NA NA
mgb.EAS 0.62593843 0.58663704 1.8358386 0.6447584 5.227234
ukb.EAS -0.10536052 0.50170057 0.9217767 0.3597363 2.361931
intrcpt...14 NA NA NA NA NA
aou.EUR 0.37156356 0.04671015 1.4414426 1.3240202 1.569279
mgb.EUR 0.13976194 0.05142613 1.1717108 1.0749651 1.277164
ukb.EUR 0.55961579 0.02003637 1.6971991 1.6362720 1.760395
intrcpt...18 NA NA NA NA NA
intrcpt...19 NA NA NA NA NA
aou.MID 0.47000363 0.87411408 1.5447768 0.3005972 7.938647
saudi.MID 0.52472853 0.12877896 NA NA NA
intrcpt...22 NA NA NA NA NA
aou.SAS 0.15700375 0.52429291 1.0136595 0.3901100 2.633887
mgb.SAS 2.82849635 0.80488983 5.1003172 1.3806525 18.841261
ukb.SAS 0.65752000 0.15590681 1.8026748 1.3748214 2.363679
wt
intrcpt...1 NA
aou.AFR 205.970807
mgb.AFR 50.541798
ukb.AFR 29.316774
intrcpt...5 NA
aou.AMR 92.058298
mgb.AMR 48.234525
ukb.AMR 1.643320
intrcpt...9 NA
aou.EAS 6.812507
bbj.EAS 1203.206359
mgb.EAS 2.905769
ukb.EAS 3.972929
intrcpt...14 NA
aou.EUR 458.329240
mgb.EUR 378.122284
ukb.EUR 2490.932910
intrcpt...18 NA
intrcpt...19 NA
aou.MID 1.308771
saudi.MID 60.299010
intrcpt...22 NA
aou.SAS 3.637911
mgb.SAS 1.543573
ukb.SAS 41.140523
Code
forest_plot <-ggplot(final_data, aes(x = OR, y =paste(Population,study))) +geom_point() +geom_errorbarh(aes(xmin = OR_LCI, xmax = OR_UCI), height =0.2) +scale_x_continuous(trans ='log10', breaks = scales::log_breaks(base =10)) +labs(x ="Odds Ratio", y ="Study", title ="Forest Plot of All Studies and Pooled Effect (Fixed Effects)") +geom_vline(xintercept =1, linetype ="dashed") +theme_minimal()print(forest_plot)