Volume 7 · ten days

A posterior says what you wanted p to say.

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 67 45 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.

QuestionFrequentist answerBayesian 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 68 55 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 columnRead it as
EstimatePosterior mean (or median with robust = TRUE). The analogue of a coefficient.
Est.ErrorPosterior SD. Loosely the analogue of a standard error.
l-95% CI / u-95% CIThe credible interval: 95% posterior probability the parameter lies here.
RhatConvergence. Must be ≤ 1.01 for every parameter or the numbers are meaningless.
Bulk_ESS / Tail_ESSEffective sample size. Want > 1,000 for stable intervals; low tail ESS means unreliable interval endpoints.
sigmaResidual 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 69 55 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 typeExampleWhen
Flat / improperuniform(-Inf, Inf)Almost never by choice. Can prevent convergence in hierarchical models.
Weakly informativenormal(0, 5) on a 0–63 scaleThe default you should set. Rules out the absurd, nothing else.
Regularisingnormal(0, 1) on standardised predictorsMany predictors; shrinks noise toward zero. Volume 10's lasso, in Bayesian form.
Informativenormal(0.3, 0.1) from a meta-analysisStrong prior evidence exists. Cite it; run the sensitivity analysis.
On variance componentsstudent_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 70 50 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.
SymptomCauseFix
R-hat > 1.01Chains have not mixedMore iterations; check for a misspecified or unidentified model
Divergent transitionsCurved posterior geometry, often a hierarchical funnelcontrol = list(adapt_delta = 0.99); tighter priors on variance components; non-centred parameterisation
Low tail ESSHeavy tails, poor explorationMore iterations; reparameterise; check for outliers
Max treedepth warningsSampler hitting its step limitmax_treedepth = 15. Usually efficiency, not validity
Chains stuck at different valuesMultimodality or non-identificationModel problem, not a sampler problem. Rethink the specification
Bimodal posteriorGenuinely two solutions, or label switching in mixturesInvestigate; 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 71 55 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.

library(bayestestR) describe_posterior(m, ci = 0.95, ci_method = "HDI", test = c("pd", "rope"), rope_range = c(-1, 1), # ±1 BDI point = negligible rope_ci = 1)#> Summary of Posterior Distribution #> #> Parameter | Median | 95% HDI | pd | % in ROPE #> ---------------------------------------------------------------- #> (Intercept) | 6.41 | [ 2.72, 10.09] | 99.97% | 0.02% #> conditionCBT | -3.41 | [ -4.82, -2.00] | 100% | 0% #> conditionWaitlist | -0.32 | [ -1.75, 1.11] | 67.04% | 82.14% #> bdi_pre | 0.68 | [ 0.58, 0.78] | 100% | 98.40%
QuantityMeaning and use
MedianThe posterior's central value. More robust than the mean for skewed posteriors.
95% HDIThe 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.
ROPERegion of practical equivalence: the interval of effects too small to matter. Define it from your measure, before fitting.
% in ROPEHow much posterior mass is negligible. Under 2.5% = effect is practically meaningful; over 97.5% = practically equivalent to null.
Bayes factorRelative 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 72 50 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.
BF10Conventional wordingNote
> 100Extreme evidence for H1Thresholds are conventions, not facts
30–100Very strong
10–30Strong
3–10ModerateThe usual publishable band
1–3AnecdotalEffectively inconclusive; say so
1/3–1Anecdotal for H0
< 1/3Moderate to strong for H0The 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 73 55 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 74 55 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 decisionRecord it as a column
Which outcome, when a study reports severaloutcome_used plus a rule decided in advance (primary outcome; first-listed; all, with multilevel modelling)
Which timepointtimepoint — post-treatment and follow-up are different questions
Multiple comparison groups in one studycomparison; do not treat them as independent effects
Direction of the scalereverse_scored — sign errors are the most common meta-analytic bug
Risk of biasSeveral columns from a published tool (e.g. randomisation, blinding, attrition)
Moderators you preregisteredOne column each: dose, population, format, country
Who coded itcoder — 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 75 55 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.
StatisticWhat it says
tauSD 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-valueTest of homogeneity. Underpowered with few studies, over-powered with many. A screening device.
Prediction intervalWhere 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 76 55 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?
7 · report
Median, 95% HDI, pd, ROPE, prior sensitivity table, convergence statement.
Reference

From search to forest plot

StageWhat it produces
Preregistered protocol (PROSPERO)Question, inclusion criteria, search strategy, planned moderators, dependence rule
Search and screeningPRISMA flow diagram — PRISMA2020 generates it from your counts
Double codingCoding 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-regressionModerator tests, R², residual heterogeneity
Bias diagnosticsFunnel, Egger, trim-and-fill, PET-PEESE, p-curve
FiguresForest plot and funnel plot at journal specification
DepositCoding 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.

Start day 77 →