Bayesian estimation gives you the probability distribution of the effect given your data — which is the quantity every misreading of a p-value was reaching for. The formula syntax is the one you already know; only the engine changes. The second half of this volume is meta-analysis: effect-size coding, random-effects models, heterogeneity, moderators, bias diagnostics, and a forest plot that publishes.
10
days, posterior to forest plot
1
formula syntax, two engines
4
diagnostics before any posterior is read
Day 6745 minutes · plain language, no integrals
Prior, likelihood, posterior — and why intervals stop being awkward
Bayesian inference combines what you believed before (the prior) with what the data say (the likelihood) to give a probability distribution over parameter values (the posterior). The practical consequence: you can say "there is a 95% probability the effect lies between 0.31 and 0.94" — the sentence everyone wants to say about a confidence interval and technically cannot.
Question
Frequentist answer
Bayesian answer
What is the probability the effect is positive?
Undefined — the effect is fixed, not random.
A number: the proportion of the posterior above zero.
What does the interval mean?
95% of such intervals, computed this way forever, would contain the true value.
Given the data and prior, 95% probability the value is in here.
Can I get evidence FOR no effect?
Not from a p-value. Requires equivalence testing.
Yes — a posterior concentrated near zero, or a Bayes factor.
What about prior knowledge?
No formal role.
Stated explicitly, and its influence is checkable.
Does it depend on my intentions?
Yes — optional stopping invalidates p-values.
No — the posterior does not depend on the sampling plan.
Where the objection usually lands
"The prior is subjective." It is explicit, which is different. Every analysis contains assumptions — a link function, a distributional family, which covariates to include, when to stop collecting; Bayesian analysis writes one more of them down where a reviewer can see and challenge it. The defensible practice is weakly informative priors plus a sensitivity analysis showing the conclusion does not hinge on them (day 69), and at that point the objection has an answer rather than a shrug.
# Bayes' theorem on one parameter, with no MCMC: a grid.
# 32 of 50 participants chose the target option. What is the rate?
theta <- seq(0, 1, length.out = 1001)
prior <- dbeta(theta, 2, 2) # mild belief: extremes unlikely
likelihood<- dbinom(32, size = 50, prob = theta)
posterior <- prior * likelihood
posterior <- posterior / sum(posterior) # normalise
# The three summaries you will report for the rest of your career
c(median = theta[which.max(cumsum(posterior) >= 0.5)],
ci_lo = theta[which.max(cumsum(posterior) >= 0.025)],
ci_hi = theta[which.max(cumsum(posterior) >= 0.975)],
p_above_half = sum(posterior[theta > 0.5]))#> median ci_lo ci_hi p_above_half
#> 0.630 0.500 0.749 0.974
#> ← 97.4% posterior probability the rate exceeds chance. No p-value needed,
#> and nothing about hypothetical repeated sampling.
Do this now · 15 minutes
Run the grid example, then change the prior to dbeta(theta, 20, 20) (a strong belief in 0.5) and re-run. Watch the posterior move, and note how much data it would take to overcome it. That intuition is what day 69 formalises.
Day 6855 minutes · the formula you already know
brms: same syntax, different engine
brm() takes lme4 formulas. Everything you learned in volumes 4 and 5 transfers directly — fixed effects, random effects, families — and the output is a posterior instead of a point estimate with a standard error. The costs are compilation time (30–90 seconds the first run) and the discipline of checking convergence before reading anything.
# One-time setup. Stan needs a compiler; this is the fiddly part.
install.packages("brms")
library(brms)
# Windows: install Rtools. macOS: xcode-select --install.
# Then verify: example(stan_model, package = "rstan", run.dontrun = TRUE)
m <- brm(bdi_post ~ condition + bdi_pre,
data = d,
family = gaussian(),
chains = 4, iter = 4000, warmup = 1000, cores = 4,
seed = 2026)
summary(m)#> Family: gaussian
#> Links: mu = identity; sigma = identity
#> Formula: bdi_post ~ condition + bdi_pre
#> Data: d (Number of observations: 247)
#> Draws: 4 chains, each with iter = 4000; warmup = 1000; total post-warmup draws = 12000
#>
#> Regression Coefficients:
#> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
#> Intercept 6.41 1.88 2.72 10.09 1.00 9812 10204
#> conditionCBT -3.41 0.72 -4.82 -2.00 1.00 11204 10981
#> conditionWaitlist -0.32 0.73 -1.75 1.11 1.00 11402 11120
#> bdi_pre 0.68 0.05 0.58 0.78 1.00 10884 10512
#>
#> Further Distributional Parameters:
#> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
#> sigma 4.09 0.19 3.74 4.47 1.00 11012 10804
Output column
Read it as
Estimate
Posterior mean (or median with robust = TRUE). The analogue of a coefficient.
Est.Error
Posterior SD. Loosely the analogue of a standard error.
l-95% CI / u-95% CI
The credible interval: 95% posterior probability the parameter lies here.
Rhat
Convergence. Must be ≤ 1.01 for every parameter or the numbers are meaningless.
Bulk_ESS / Tail_ESS
Effective sample size. Want > 1,000 for stable intervals; low tail ESS means unreliable interval endpoints.
sigma
Residual SD, estimated with its own posterior rather than plugged in.
# Everything from volume 5 works, with random effects
m_ml <- brm(mood ~ stress_cw + stress_pm_c + (1 + stress_cw | id),
data = esm, chains = 4, iter = 4000, cores = 4)
# Non-Gaussian families, same as glmer
brm(correct ~ condition + (1 | subject) + (1 | item),
data = trials, family = bernoulli())
brm(episodes ~ treatment + (1 | id), data = diary, family = negbinomial())
brm(agreement ~ condition, data = d, family = cumulative()) # ordinal!
Do this now · 25 minutes
Install brms, verify the compiler works, and refit your main volume-4 model with brm(). Put the frequentist and Bayesian estimates side by side — with weak priors and decent n they will be nearly identical, which is the reassurance you need to trust the machinery.
Day 6955 minutes · explicit, not arbitrary
Weakly informative priors, and the check that justifies them
brms picks flat or very wide default priors, which is safe but wasteful: you always know something, if only the plausible scale of your measure. A weakly informative prior rules out the absurd (a 400-point BDI-II effect) while leaving every plausible value well supported. The way to justify one is a prior predictive check — simulate data from the prior alone and see whether it looks like data from your field.
library(brms)
get_prior(bdi_post ~ condition + bdi_pre, data = d) # what can be set
priors <- c(
prior(normal(0, 5), class = "b"), # effects within ±10 BDI points
prior(normal(0, 1), class = "b", coef = "bdi_pre"), # a slope near 0–1
prior(normal(20, 10), class = "Intercept"), # BDI means live around 20
prior(exponential(0.2), class = "sigma") # positive, scale ~5
)
m_prior <- brm(bdi_post ~ condition + bdi_pre, data = d, prior = priors,
sample_prior = "only", chains = 2, iter = 2000)
pp_check(m_prior, ndraws = 100) # does the PRIOR imply plausible data?#> ← simulated BDI scores span roughly -5 to 55: covers the scale, allows
#> implausible negatives at the edges. Acceptable for a weakly informative
#> prior; tighten sigma if you want to exclude them.
Prior type
Example
When
Flat / improper
uniform(-Inf, Inf)
Almost never by choice. Can prevent convergence in hierarchical models.
Weakly informative
normal(0, 5) on a 0–63 scale
The default you should set. Rules out the absurd, nothing else.
Regularising
normal(0, 1) on standardised predictors
Many predictors; shrinks noise toward zero. Volume 10's lasso, in Bayesian form.
Informative
normal(0.3, 0.1) from a meta-analysis
Strong prior evidence exists. Cite it; run the sensitivity analysis.
On variance components
student_t(3, 0, 2.5), exponential()
Hierarchical models with few clusters, where flat priors misbehave.
# The sensitivity analysis that answers "but you chose the prior"
fit_weak <- brm(f, data = d, prior = prior(normal(0, 10), class = "b"))
fit_mild <- brm(f, data = d, prior = prior(normal(0, 5), class = "b"))
fit_tight <- brm(f, data = d, prior = prior(normal(0, 1), class = "b"))
purrr::map_dfr(list(weak = fit_weak, mild = fit_mild, tight = fit_tight),
~ as.data.frame(fixef(.x))["conditionCBT", ], .id = "prior")
# And the posterior predictive check, after fitting: does the model
# generate data that look like the data you have?
pp_check(m, ndraws = 100)
pp_check(m, type = "stat_grouped", stat = "mean", group = "condition")#> prior Estimate Est.Error Q2.5 Q97.5
#> 1 weak -3.412 0.721 -4.831 -1.998
#> 2 mild -3.408 0.716 -4.812 -1.997
#> 3 tight -3.204 0.688 -4.552 -1.861
#> ← conclusions are prior-insensitive. Report this table in a supplement
#> and the objection is answered.
Do this now · 25 minutes
Set explicit weakly informative priors for your model, run a prior predictive check, and then the three-prior sensitivity table. Keep the table — it belongs in your supplementary materials.
Day 7050 minutes · never read an unconverged posterior
R-hat, ESS, divergences: four checks, in order
MCMC gives you samples from the posterior only if the sampler explored it properly. Four diagnostics catch almost every failure, and they are cheap. Run them before you look at a single estimate — an unconverged model can print perfectly reasonable-looking numbers that are simply wrong.
library(brms); library(posterior)
# 1. R-hat: chains must agree. Every parameter ≤ 1.01.
max(rhat(m), na.rm = TRUE)
# 2. Effective sample size: > 1000 bulk and tail for reported quantities.
min(neff_ratio(m), na.rm = TRUE)
summarise_draws(as_draws_df(m), "rhat", "ess_bulk", "ess_tail") |> head()
# 3. Divergent transitions: should be zero.
sum(subset(nuts_params(m), Parameter == "divergent__")$Value)
# 4. Trace plots: four chains overlapping like grass, no trends.
plot(m, variable = c("b_conditionCBT", "sigma"))
mcmc_plot(m, type = "trace")#> [1] 1.0008
#> [1] 0.842
#> [1] 0
#>
#> # A tibble: 6 × 4
#> variable rhat ess_bulk ess_tail
#> b_Intercept 1.00 9812. 10204.
#> b_conditionCBT 1.00 11204. 10981.
#> sigma 1.00 11012. 10804.
#> ← all clear. Now the posterior may be read.
Symptom
Cause
Fix
R-hat > 1.01
Chains have not mixed
More iterations; check for a misspecified or unidentified model
Divergent transitions
Curved posterior geometry, often a hierarchical funnel
control = list(adapt_delta = 0.99); tighter priors on variance components; non-centred parameterisation
Low tail ESS
Heavy tails, poor exploration
More iterations; reparameterise; check for outliers
Max treedepth warnings
Sampler hitting its step limit
max_treedepth = 15. Usually efficiency, not validity
Chains stuck at different values
Multimodality or non-identification
Model problem, not a sampler problem. Rethink the specification
Bimodal posterior
Genuinely two solutions, or label switching in mixtures
Investigate; do not summarise with a mean
The habit
Wrap the checks in a function and call it on every fit: check_brms <- function(m) list(rhat = max(rhat(m), na.rm = TRUE), div = sum(...), ess = min(neff_ratio(m), na.rm = TRUE)). Three numbers, one line, and you will never again present a supervisor with an unconverged model. Report all three in the paper: "all R-hat ≤ 1.01, no divergent transitions, minimum bulk ESS 9,812" is a complete convergence statement.
Do this now · 20 minutes
Write your own check_brms() helper (it is also day 94's first function) and run it on every model you have fitted. Then deliberately break one — fit with iter = 300 — and watch which diagnostics catch it.
Day 7155 minutes · the sentences to write
Credible intervals, probability of direction, and ROPE
Bayesian reporting has its own conventions, and bayestestR produces all of them in one call. The core set: a point estimate (median), a 95% credible interval (HDI or quantile, say which), the probability of direction, and — when the question is whether an effect is negligible — the proportion of the posterior inside a region of practical equivalence you defined in advance.
The posterior's central value. More robust than the mean for skewed posteriors.
95% HDI
The narrowest interval containing 95% of the posterior. Say HDI or quantile — they differ for skewed posteriors.
pd (probability of direction)
Posterior probability the effect has the sign you estimated. Ranges 50–100%. Correlates closely with 1 − p/2, but means something.
ROPE
Region of practical equivalence: the interval of effects too small to matter. Define it from your measure, before fitting.
% in ROPE
How much posterior mass is negligible. Under 2.5% = effect is practically meaningful; over 97.5% = practically equivalent to null.
Bayes factor
Relative evidence for one model over another. A different question — day 72.
Two sentences you can write, and one you cannot
Write: "CBT reduced post-treatment BDI-II by 3.41 points, 95% HDI [−4.82, −2.00], pd = 100%, with no posterior mass inside the ±1-point region of practical equivalence." Write: "The waitlist effect was practically equivalent to zero: 82% of its posterior lay within ±1 point." Do not write: "the effect was significant (pd > 95%)" — importing a threshold ritual into a framework built to avoid it defeats the purpose. Report the distribution and let the reader see the uncertainty.
# Posterior draws are just a data frame: ask any question you like
library(tidyverse)
draws <- as_draws_df(m)
mean(draws$b_conditionCBT < -2) # P(effect bigger than 2 points)
quantile(draws$b_conditionCBT, c(.05, .5, .95))
# Marginal means and contrasts, as in volume 4
library(emmeans)
emmeans(m, ~ condition) |> contrast("pairwise")
# The figure: the whole posterior, not a bar with a whisker
library(bayesplot)
mcmc_areas(m, pars = c("b_conditionCBT", "b_conditionWaitlist"), prob = 0.95)#> [1] 0.9784 ← 97.8% probability the reduction exceeds 2 BDI points
#> 5% 50% 95%
#> -4.585152 -3.408112 -2.231041
Do this now · 25 minutes
Define a ROPE for your outcome in its own units and justify it in one sentence (smallest change a clinician or participant would notice). Then run describe_posterior() and write your main result as a full Bayesian sentence.
Day 7250 minutes · evidence for the null
Bayes factors: a different question, and a real caveat
Estimation asks how big the effect is. A Bayes factor asks which of two models the data support better — and crucially it can support the null, which no p-value can. The caveat is unavoidable: a Bayes factor depends on the prior on the effect size under the alternative, much more strongly than a posterior does. Report a sensitivity analysis or do not report the Bayes factor.
library(BayesFactor)
ttestBF(formula = bdi_post ~ condition, data = d3) # default Cauchy r = 0.707
ttestBF(x = d_wide$t1, y = d_wide$t2, paired = TRUE)
correlationBF(y = d$neuro, x = d$agree)
anovaBF(bdi_post ~ condition, data = d3)
# Sensitivity: how much does the prior width matter?
sapply(c(0.35, 0.5, 0.707, 1.0, 1.4),
\(r) extractBF(ttestBF(formula = bdi_post ~ condition,
data = d3, rscale = r))$bf)#> Bayes factor analysis
#> [1] Alt., r=0.707 : 24.812 ±0%
#> ← the data are ~25 times more likely under the alternative than the null
#>
#> [1] 31.204 28.114 24.812 19.402 14.118
#> ← BF drops from 31 to 14 across reasonable prior widths. The qualitative
#> conclusion (strong evidence) survives; report the range.
BF10
Conventional wording
Note
> 100
Extreme evidence for H1
Thresholds are conventions, not facts
30–100
Very strong
10–30
Strong
3–10
Moderate
The usual publishable band
1–3
Anecdotal
Effectively inconclusive; say so
1/3–1
Anecdotal for H0
< 1/3
Moderate to strong for H0
The claim a p-value cannot make: evidence of absence
When to use which
Use estimation (days 68–71) when your question is how large the effect is — which is nearly always the scientific question, and what a preregistered SESOI implies. Use a Bayes factor when the question is genuinely model comparison: does this effect exist at all, is a null-hypothesis replication informative, should I keep this parameter. Use equivalence testing (frequentist TOST, TOSTER) if you want a null claim without priors. Reporting all three is not showing off; it is showing that the conclusion does not depend on the framework.
Do this now · 20 minutes
Compute a Bayes factor for your main comparison across five prior widths and tabulate them. Then write both sentences — the estimation one and the Bayes-factor one — and decide which answers your research question. Say why in the paper.
Day 7355 minutes · where brms beats lme4
Hierarchical models: convergence without compromise
This is the day that converts people. The mixed models that made lme4 print singular-fit warnings — a random slope with 15 clusters, a complex crossed structure, a GLMM with few observations per cell — often fit cleanly in brms, because a mild prior on the variance components regularises the estimate instead of pushing it to the boundary. You also get uncertainty on the variance components themselves, which frequentist mixed models do not give you.
library(brms)
m_b <- brm(mood ~ stress_cw + stress_pm_c + (1 + stress_cw | id),
data = esm,
prior = c(prior(normal(0, 1), class = "b"),
prior(student_t(3, 0, 2.5), class = "sd"), # on variances
prior(lkj(2), class = "cor")), # on correlations
chains = 4, iter = 4000, cores = 4, seed = 2026,
control = list(adapt_delta = 0.95))
summary(m_b)#> Multilevel Hyperparameters:
#> ~id (Number of levels: 94)
#> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS
#> sd(Intercept) 0.85 0.07 0.72 0.99 1.00 3204
#> sd(stress_cw) 0.15 0.02 0.11 0.20 1.00 2812
#> cor(Intercept,stress_cw) -0.31 0.13 -0.55 -0.04 1.00 3401
#>
#> Regression Coefficients:
#> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS
#> stress_cw -0.29 0.02 -0.33 -0.25 1.00 4102
#> stress_pm_c -0.51 0.12 -0.74 -0.28 1.00 5204
#>
#> ← note the interval on sd(stress_cw): individual differences in the stress
#> slope, WITH uncertainty. lme4 gives you the point estimate only.# Where it really pays: models lme4 cannot fit cleanly
# 1. Few clusters
brm(y ~ x + (1 + x | site), data = d) # 12 sites: lme4 → singular fit
# 2. Distributional models — does the VARIANCE differ by group?
brm(bf(rt ~ condition + (1 | id),
sigma ~ condition), # heteroscedasticity as a finding
data = trials, family = gaussian())
# 3. Ordinal outcomes with random effects, properly
brm(likert ~ condition + (1 | id), data = d, family = cumulative())
# 4. Missing data imputed inside the model
brm(y | mi() ~ mi(x) + z, data = d)
# Model comparison by out-of-sample predictive accuracy
loo_compare(loo(m_b), loo(m_b2))#> elpd_diff se_diff
#> m_b 0.0 0.0
#> m_b2 -12.4 4.8
#> ← m_b predicts new observations better; the difference exceeds 2 SE
The reframing of "singular fit"
In lme4 a variance estimated at zero is a boundary problem you must respond to by simplifying. In brms the posterior for that SD simply concentrates near zero with a credible interval — which is the honest answer ("these data cannot distinguish much slope variation") rather than a warning. You keep the model your design justifies and report the uncertainty. That is a better scientific outcome than deleting the random slope and pretending you never wanted it.
Do this now · 25 minutes
Refit your most troublesome lme4 model in brms with priors on the SD and correlation parameters. Compare: does the fixed effect change, and what does the interval on the slope SD tell you that lme4's point estimate did not?
Day 7455 minutes · synthesis starts with coding
From published papers to a computable effect-size dataset
A meta-analysis is 80% data extraction and 20% modelling, and the modelling is easy. The skill is converting whatever each paper reported — means and SDs, a t-value, an F, an odds ratio, a correlation — into a common effect size with its sampling variance. escalc() does the conversions; your job is the coding sheet and the decisions it records.
library(metafor)
# From means, SDs and ns: standardised mean difference (Hedges' g)
dat <- escalc(measure = "SMD",
m1i = m_treat, sd1i = sd_treat, n1i = n_treat,
m2i = m_ctrl, sd2i = sd_ctrl, n2i = n_ctrl,
data = studies, slab = paste(author, year))
head(dat[, c("author", "year", "yi", "vi")])
# When a paper reports only a test statistic
escalc(measure = "SMD", di = 0.42, n1i = 30, n2i = 28) # d given
d_from_t <- function(t, n1, n2) t * sqrt(1/n1 + 1/n2) # t given
# Correlations (Fisher z is the analysis scale)
escalc(measure = "ZCOR", ri = r, ni = n, data = cors)
# Binary outcomes
escalc(measure = "OR", ai = a, bi = b, ci = c, di = dd, data = bin)#> author year yi vi
#> 1 Andersson 2012 -0.612 0.0412
#> 2 Berg 2014 -0.318 0.0588
#> 3 Cuijpers 2016 -0.804 0.0301
#> 4 Dimidjian 2018 -0.214 0.0712
#> ← yi is the effect, vi its sampling variance. Everything downstream uses these.
Coding decision
Record it as a column
Which outcome, when a study reports several
outcome_used plus a rule decided in advance (primary outcome; first-listed; all, with multilevel modelling)
Which timepoint
timepoint — post-treatment and follow-up are different questions
Multiple comparison groups in one study
comparison; do not treat them as independent effects
Direction of the scale
reverse_scored — sign errors are the most common meta-analytic bug
Risk of bias
Several columns from a published tool (e.g. randomisation, blinding, attrition)
Moderators you preregistered
One column each: dose, population, format, country
Who coded it
coder — and double-code 20%, reporting kappa (volume 6 day 56)
Dependence: the structural issue to decide now
If one study contributes three effects (three outcomes, or two treatment arms against a shared control), those effects are correlated and treating them as independent understates uncertainty. Three defensible options: average them within study, pick one by a preregistered rule, or fit a multilevel meta-analysis with rma.mv() and random = ~ 1 | study/effect — the modern default, and combine it with cluster-robust inference (clubSandwich). Decide before extraction, because it changes the coding sheet.
Do this now · 25 minutes
Build a coding sheet with columns for effect-size inputs, your preregistered moderators, risk of bias, and the coder. Extract five studies, run escalc(), and check every sign by re-reading what the original paper's direction actually was.
Day 7555 minutes · the pooled estimate is not the point
Random-effects models, heterogeneity, and meta-regression
A fixed-effect meta-analysis assumes every study estimates the same true effect. It almost never holds in behavioural science, so random-effects is the default: it estimates a distribution of true effects, whose spread (tau) is often more interesting than the mean. A prediction interval — where the next study's true effect is likely to fall — is the most informative number you can report and the one most often omitted.
library(metafor)
res <- rma(yi, vi, data = dat, method = "REML") # random effects
res
predict(res, digits = 3) # POOLED + PREDICTION interval
confint(res) # interval on tau itself#> Random-Effects Model (k = 24; tau^2 estimator: REML)
#>
#> tau^2 (estimated amount of total heterogeneity): 0.0812 (SE = 0.0341)
#> tau (square root of estimated tau^2): 0.2850
#> I^2 (total heterogeneity / total variability): 68.42%
#> H^2 (total variability / sampling variability): 3.17
#>
#> Test for Heterogeneity:
#> Q(df = 23) = 71.8412, p-val < .0001
#>
#> Model Results:
#> estimate se zval pval ci.lb ci.ub
#> -0.4812 0.0742 -6.4851 <.0001 -0.6266 -0.3358 ***
#>
#> pred se ci.lb ci.ub pi.lb pi.ub
#> -0.481 0.074 -0.627 -0.336 -1.062 0.100
#> ← the pooled effect is clear, but the prediction interval crosses zero:
#> in some populations this treatment may not help at all. Report this.
Statistic
What it says
tau
SD of true effects across studies, in effect-size units. The directly interpretable measure.
I²
Share of observed variability due to real differences rather than sampling error. A relative measure — it can be high with trivial tau when studies are large. Never report it alone.
Q and its p-value
Test of homogeneity. Underpowered with few studies, over-powered with many. A screening device.
Prediction interval
Where the next study's true effect will plausibly fall. The most practically useful line of output.
Number of studies (k)
Everything is unstable below k ≈ 10. With k < 5, tau is barely estimable — say so.
# Where does the heterogeneity come from? Meta-regression.
rma(yi, vi, mods = ~ dose + format + year_c, data = dat)
# Categorical moderator as subgroups
rma(yi, vi, mods = ~ factor(format) - 1, data = dat)
# Multilevel structure: several effects per study (day 74's decision)
rma.mv(yi, vi, random = ~ 1 | study/effect, data = dat_multi)
# Cluster-robust inference on top, the current standard
library(clubSandwich)
coef_test(rma.mv(yi, vi, random = ~ 1 | study/effect, data = dat_multi),
vcov = "CR2")#> Mixed-Effects Model (k = 24; tau^2 estimator: REML)
#>
#> Test of Moderators (coefficients 2:4):
#> QM(df = 3) = 18.4120, p-val = 0.0004
#>
#> Test for Residual Heterogeneity:
#> QE(df = 20) = 31.2041, p-val = 0.0521
#>
#> estimate se zval pval ci.lb ci.ub
#> intrcpt -0.2104 0.1412 -1.4901 0.1362 -0.4872 0.0664
#> dose -0.0182 0.0061 -2.9836 0.0028 -0.0302 -0.0062 **
#> formatgrp 0.1841 0.0812 2.2673 0.0234 0.0250 0.3432 *
#> R^2 (amount of heterogeneity accounted for): 48.12%
Two limits on meta-regression
It is observational at the study level: dose was not randomly assigned to studies, so a dose moderator is confounded with everything that co-varies with dose across literatures. And it is badly underpowered — the informal rule is at least 10 studies per moderator, and testing four moderators on 24 studies is exploratory whatever the p-values say. Preregister the moderators, report them all, and describe the analysis as what it is.
Do this now · 25 minutes
Fit the random-effects model on your extracted data, report tau, I² and the prediction interval, then test your one preregistered moderator. Write the sentence a clinician would need: the pooled effect, and how much it varies between settings.
Day 7655 minutes · the volume's deliverable
Small-study effects, p-curve, and a forest plot that publishes
Published effects are a biased sample of conducted effects: significant results are more likely to be written up, submitted and accepted. No diagnostic proves publication bias, and all of them confound it with genuine heterogeneity, so the standard is to run several and describe the pattern honestly. Then build the two figures every meta-analysis needs.
library(metafor)
funnel(res, xlab = "Hedges' g", back = "white")
regtest(res) # Egger's test for funnel asymmetry
ranktest(res) # Begg's rank correlation
# Trim-and-fill: an estimate of what symmetry would imply. A sensitivity
# analysis, never a corrected result.
tf <- trimfill(res); tf
# PET-PEESE: the modern regression-based adjustment
rma(yi, vi, mods = ~ sqrt(vi), data = dat) # PET: intercept = bias-adjusted
rma(yi, vi, mods = ~ vi, data = dat) # PEESE#> Regression Test for Funnel Plot Asymmetry
#> model: mixed-effects meta-regression model
#> predictor: standard error
#> test for funnel plot asymmetry: z = 2.4812, p = 0.0131
#>
#> Estimated number of missing studies on the right side: 6 (SE = 2.81)
#> Adjusted estimate: -0.3412 (was -0.4812)
#> ← asymmetry present; the adjusted estimate is ~30% smaller. Report both
#> and treat the true effect as somewhere in that range.# p-curve and z-curve: is there evidential value, and how much power did
# this literature actually have?
library(dmetar)
pcurve(res)
library(zcurve)
summary(zcurve(z = dat$z_values))#> P-curve analysis
#> Right-skewness test: p < 0.001 ← evidential value present
#> Flatness test: p = 0.412
#> Power estimate: 61% (95% CI 44-76%)
#>
#> z-curve: EDR (expected discovery rate) 0.38 [0.24, 0.52]
#> ERR (expected replication rate) 0.54 [0.41, 0.66]# The forest plot: the figure your meta-analysis IS.
png("output/figures/fig1_forest.png", width = 170, height = 220,
units = "mm", res = 600)
forest(res,
xlim = c(-3.5, 2.0), at = seq(-2, 1, 0.5),
slab = dat$slab,
ilab = cbind(dat$n_total, dat$format),
ilab.xpos = c(-2.6, -2.1),
header = c("Study and year", "Hedges' g [95% CI]"),
mlab = "Random-effects model (REML)",
addpred = TRUE, # show the PREDICTION interval
cex = 0.75, shade = "zebra")
dev.off()
Portfolio artefact: the synthesis
A completed meta-analysis is the single most reusable object in an early research career — it justifies your next study's design, supplies the prior for day 69, gives the expected effect size for day 78's power analysis, and is publishable on its own. Deposit the coding sheet and the analysis script on OSF (volume 9 day 96) and it also becomes a living resource other people cite.
Do this now · 25 minutes
Run the funnel plot, Egger's test, trim-and-fill and PET-PEESE on your dataset, and write the honest paragraph: what the asymmetry could mean, and what range of pooled effects the diagnostics jointly support. Then export the forest plot at 170 mm and 600 dpi.
Reference
Seven steps, in this order
Fitting the model is step four. Most of the value is in the steps around it.
1 · write the model
Same formula as lme4. Family chosen from the outcome, not from habit.
2 · set priors
Weakly informative, on the scale of your measure. get_prior() shows what exists.
3 · prior predictive check
sample_prior = "only" then pp_check(). Does the prior imply plausible data?
4 · fit
4 chains, 4,000 iterations, cores = 4, a fixed seed.
5 · diagnose
R-hat ≤ 1.01, zero divergences, ESS > 1,000, trace plots. Before reading anything.
6 · posterior predictive check
pp_check() and grouped statistics. Does the model generate your data?
PRISMA flow diagram — PRISMA2020 generates it from your counts
Double coding
Coding sheet plus an agreement statistic (volume 6 day 56)
escalc()
yi and vi per effect — the analysis dataset
rma() / rma.mv()
Pooled estimate, tau, I², prediction interval
Meta-regression
Moderator tests, R², residual heterogeneity
Bias diagnostics
Funnel, Egger, trim-and-fill, PET-PEESE, p-curve
Figures
Forest plot and funnel plot at journal specification
Deposit
Coding sheet, script and PRISMA diagram on OSF
Reference
Install these once
install.packages(c(
"brms", # Bayesian regression with lme4 syntax (needs a C++ compiler)
"posterior", # rhat, ess, summarise_draws
"bayestestR", # describe_posterior, ROPE, pd
"bayesplot", # mcmc_areas, trace plots
"loo", # out-of-sample model comparison
"BayesFactor", # ttestBF, anovaBF, correlationBF
"TOSTER", # frequentist equivalence testing
"metafor", # escalc, rma, rma.mv, forest, funnel, regtest
"clubSandwich",# cluster-robust inference for dependent effects
"dmetar", # p-curve, power analysis for meta-analysis
"zcurve", # expected discovery and replication rates
"PRISMA2020" # the flow diagram
))
# brms alternative if compilation is impossible: rstanarm (precompiled)
Checkpoint
Six questions before volume 8
{{ quizCounter }}
{{ quizScore }}
{{ quizQ }}
{{ quizFb }}
Next: volume 8
Everything so far happened after data collection. Volume 8 moves before it: analytic power, simulation-based power for designs no formula covers, power for mixed models, sensitivity analysis, sequential designs, and multiverse analysis as a robustness result.