---
title: "Checking for publication bias"
description: "Detect small-study effects in an ecological meta-analysis with base R: funnel plots, Egger's regression test, and trim-and-fill, with the limits of each."
date: "2026-05-24 12:00"
categories: [meta-analysis, ecology tutorial, R, publication bias]
image: thumbnail.png
image-alt: "Single funnel plot of standard error against Hedges' g with dashed funnel limits and two vertical dashed reference lines: about thirty dark green observed points bunched to the right, and ten red imputed points mirroring them at matching heights down the empty left side, four of them left of zero."
---
Every quantity in a meta-analysis, the pooled effect, the heterogeneity, the moderator slopes, assumes the studies in hand are a fair sample of the work that was done. They often are not. Small studies with null or wrong-signed results are less likely to be written up and published, so the literature over-represents large positive effects and the pooled estimate is pulled away from the truth. This post covers the standard checks for that problem in base R: the funnel plot, Egger's regression test, and trim-and-fill. It also shows where these checks stop, because funnel asymmetry has several causes and none of them is proven by the plot alone.
```{r}
#| label: setup
#| code-fold: true
#| code-summary: "Setup: packages, colours and plot theme"
#| results: hide
#| message: false
#| warning: false
library(ggplot2)
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
fig.width = 7.5, fig.height = 4.5, dpi = 100)
pal <- list(ink = "#16241d", body = "#2c3a31", forest = "#275139",
label = "#46604a", sage = "#93a87f", paper = "#f5f4ee",
line = "#dad9ca", faint = "#5d6b61", gold = "#cda23f",
mown = "#2f8f63", warn = "#b5534e", low = "#c9b458", high = "#1d5b4e")
theme_te <- function(base_size = 12) {
theme_minimal(base_size = base_size) +
theme(panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = pal$line, linewidth = 0.3),
plot.background = element_rect(fill = pal$paper, colour = NA),
panel.background = element_rect(fill = pal$paper, colour = NA),
axis.text = element_text(colour = pal$body),
axis.title = element_text(colour = pal$ink),
plot.title = element_text(colour = pal$ink, face = "bold"),
plot.subtitle = element_text(colour = pal$faint),
strip.text = element_text(colour = pal$ink, face = "bold"),
legend.position = "bottom",
legend.text = element_text(colour = pal$body),
legend.title = element_text(colour = pal$ink))
}
```
## Two literatures: one fair, one filtered
We build two sets of studies that share the same generating mean effect of `r 0.30`, with each study's own true effect drawn from a normal distribution around that mean. The first keeps every study. The second mimics selective publication: a study is published for certain if its effect is significantly positive, and only with low probability otherwise, which quietly removes small studies with unremarkable results.
```{r}
#| label: simulate
sim_study <- function(theta, ni) {
x1 <- rnorm(ni, 0, 1); x2 <- rnorm(ni, theta, 1)
sp <- sqrt(((ni - 1) * sd(x1)^2 + (ni - 1) * sd(x2)^2) / (2 * ni - 2))
d <- (mean(x2) - mean(x1)) / sp
J <- 1 - 3 / (4 * (2 * ni - 2) - 1)
g <- J * d
vd <- (2 * ni) / (ni * ni) + d^2 / (2 * (2 * ni))
c(g = g, v = J^2 * vd)
}
wmean <- function(y, v) sum(y / v) / sum(1 / v)
mu <- 0.30
## unbiased: keep all 30 studies
set.seed(50)
ku <- 30; nu <- sample(10:90, ku, replace = TRUE)
gv <- t(mapply(sim_study, rnorm(ku, mu, 0.05), nu))
gu <- gv[, "g"]; seu <- sqrt(gv[, "v"])
## biased: simulate a pool, publish significant-positive for sure, else with p = 0.08
set.seed(302)
pool <- 260; np <- sample(10:90, pool, replace = TRUE)
gv <- t(mapply(sim_study, rnorm(pool, mu, 0.05), np))
gp <- gv[, "g"]; sep <- sqrt(gv[, "v"])
published <- (gp / sep > 1.64) | (runif(pool) < 0.08)
idx <- which(published)[1:32]
gb <- gp[idx]; seb <- sep[idx]
c(pooled_unbiased = wmean(gu, seu^2), pooled_biased = wmean(gb, seb^2))
```
The unbiased set pools to `r round(wmean(gu, seu^2), 3)` and the filtered set to `r round(wmean(gb, seb^2), 3)`. A funnel plot makes the mechanism visible.
```{r}
#| label: fig-pb-funnel
#| fig-cap: "Funnel plots for the two sets of studies. Effect size on the x axis, standard error on the y axis (precise studies at the top). The dashed lines are the 95% pseudo-confidence funnel around the pooled effect. The filtered set has a gap at the bottom left where small, null or negative studies are missing."
#| fig-alt: "Two funnel plots side by side. The unbiased panel shows points spread inside a triangular dashed funnel. The biased panel shows points bunched to the right with an empty region at the lower left, so the cloud is clearly asymmetric relative to the funnel."
fdat <- rbind(
data.frame(g = gu, se = seu, set = "Unbiased (all studies)"),
data.frame(g = gb, se = seb, set = "Filtered (selective publication)"))
fdat$set <- factor(fdat$set, levels = c("Unbiased (all studies)",
"Filtered (selective publication)"))
poolv <- c("Unbiased (all studies)" = wmean(gu, seu^2),
"Filtered (selective publication)" = wmean(gb, seb^2))
sey <- seq(0, max(fdat$se), length.out = 40)
edge <- do.call(rbind, lapply(names(poolv), function(nm) rbind(
data.frame(x = poolv[nm] - 1.96 * sey, se = sey, set = nm, side = "l"),
data.frame(x = poolv[nm] + 1.96 * sey, se = sey, set = nm, side = "r"))))
edge$set <- factor(edge$set, levels = levels(fdat$set))
cen <- data.frame(x = as.numeric(poolv), set = factor(names(poolv), levels = levels(fdat$set)))
ggplot(fdat, aes(g, se)) +
geom_line(data = edge, aes(x, se, group = side), linetype = "dashed",
colour = pal$faint, linewidth = 0.4) +
geom_vline(data = cen, aes(xintercept = x), colour = pal$sage, linewidth = 0.4) +
geom_point(colour = pal$forest, alpha = 0.7, size = 1.9) +
scale_y_reverse() +
facet_wrap(~ set) +
labs(x = "Hedges' g", y = "standard error",
title = "Funnel plots: symmetric versus asymmetric") +
theme_te()
```
## Egger's regression test
Egger's test formalises the asymmetry. Regress each study's standardised effect, g over its standard error, on its precision, one over the standard error. If the small studies are systematically off, the intercept departs from zero (Egger et al. 1997).
```{r}
#| label: egger
egger <- function(g, se) {
m <- lm(I(g / se) ~ I(1 / se))
s <- summary(m)$coefficients
c(intercept = s[1, 1], se = s[1, 2], t = s[1, 3], p = s[1, 4])
}
eu <- egger(gu, seu)
eb <- egger(gb, seb)
rbind(unbiased = eu, biased = eb)
```
For the unbiased set the intercept is `r round(eu["intercept"], 2)` (p = `r round(eu["p"], 2)`); for the filtered set it is `r round(eb["intercept"], 2)` with p = `r signif(eb["p"], 2)`.
## Trim-and-fill
Trim-and-fill estimates how many studies are missing, imputes their mirror images, and re-estimates the pooled effect (Duval and Tweedie 2000). The L0 estimator below trims the most extreme studies on the heavy side, recomputes the centre, and repeats until the count of missing studies settles, then fills in the reflected points.
```{r}
#| label: trim-fill
trimfill <- function(g, se) {
v <- se^2; k <- length(g); mu0 <- wmean(g, v)
side <- if (sum(sign(g - mu0)) >= 0) 1 else -1 # 1 = excess on the right
yy <- side * g; ord <- order(yy)
k0 <- 0; k0o <- -1; mu <- mu0; it <- 0
while (k0 != k0o && it < 100) {
it <- it + 1; k0o <- k0
keep <- if (k0 > 0) ord[1:(k - k0)] else ord
mu <- wmean(g[keep], v[keep]); cen <- side * mu
dd <- yy - cen; r <- rank(abs(dd)); Tn <- sum(r[dd > 0])
k0 <- max(0, round((4 * Tn - k * (k + 1)) / (2 * k - 1)))
}
cen <- side * mu
if (k0 > 0) {
ex <- ord[(k - k0 + 1):k]
ga <- c(g, side * (2 * cen - yy[ex])); sa <- c(se, se[ex])
imp <- (k + 1):(k + k0)
} else { ga <- g; sa <- se; imp <- integer(0) }
list(k0 = k0, mu_naive = wmean(g, v), mu_fill = wmean(ga, sa^2),
ga = ga, sa = sa, imp = imp)
}
tu <- trimfill(gu, seu)
tb <- trimfill(gb, seb)
rbind(unbiased = c(k0 = tu$k0, naive = tu$mu_naive, filled = tu$mu_fill),
biased = c(k0 = tb$k0, naive = tb$mu_naive, filled = tb$mu_fill))
```
On the unbiased set trim-and-fill imputes `r tu$k0` studies and puts the estimate at `r round(tu$mu_fill, 3)`. On the filtered set it imputes `r tb$k0` studies, and the pooled effect is `r round(tb$mu_naive, 3)` before filling and `r round(tb$mu_fill, 3)` after, against a true `r 0.30`. The figure shows where any imputed studies land in the funnel.
```{r}
#| label: fig-pb-trimfill
#| fig-cap: "Trim-and-fill on the filtered set. Observed studies in green, any imputed mirror studies in red, with the naive pooled estimate (grey) and the adjusted estimate after filling (green)."
#| fig-alt: "Funnel plot of the filtered set. Green observed points sit mostly to the right. Any red imputed points sit in the sparser lower-left region. A grey dashed vertical line marks the naive pooled estimate; a green dashed line marks the adjusted estimate after trim-and-fill."
tf <- data.frame(g = tb$ga, se = tb$sa,
kind = ifelse(seq_along(tb$ga) %in% tb$imp, "imputed", "observed"))
sey2 <- seq(0, max(tf$se), length.out = 40)
edge2 <- rbind(
data.frame(x = tb$mu_fill - 1.96 * sey2, se = sey2, side = "l"),
data.frame(x = tb$mu_fill + 1.96 * sey2, se = sey2, side = "r"))
ggplot(tf, aes(g, se, colour = kind)) +
geom_line(data = edge2, aes(x, se, group = side), inherit.aes = FALSE,
linetype = "dashed", colour = pal$faint, linewidth = 0.4) +
geom_vline(xintercept = tb$mu_naive, colour = pal$faint,
linetype = "dashed", linewidth = 0.5) +
geom_vline(xintercept = tb$mu_fill, colour = pal$forest,
linetype = "dashed", linewidth = 0.6) +
geom_point(size = 2.1, alpha = 0.8) +
scale_y_reverse() +
scale_colour_manual(values = c(observed = pal$forest, imputed = pal$warn),
name = NULL) +
labs(x = "Hedges' g", y = "standard error",
title = "Trim-and-fill: imputing the missing studies") +
theme_te()
```
## Honest limits
These are checks, not proofs, and they are easy to over-read. Funnel asymmetry has several causes besides publication bias: real heterogeneity, where small studies genuinely differ from large ones, produces the same pattern, and so can a link between study size and quality, or plain chance when the number of studies is small. Egger's test has low power with few studies and can misfire when the effect metric is tied to its own standard error, as happens with log odds ratios and standardised mean differences. Trim-and-fill is a sensitivity analysis, not a correction to be trusted: it assumes the asymmetry is due to missing studies, it can add studies that never existed when the true cause is heterogeneity, and its adjusted estimate here still sits above the truth. Read together, these tools answer a narrow question well, whether the effect depends on study size, and leave the harder question, why, to judgement and to the design of the review itself. There is one step further. If the selection mechanism is written into the likelihood rather than patched on afterwards, the pooled effect can be estimated instead of merely doubted, which is what a [selection model](../selection-models-publication-bias/) does, and it arrives with a bill of its own.
## The meta-analysis thread, end to end
Across five tutorials the pieces fit together. A random-effects model pools effect sizes and carries the between-study variance so its interval is honest. The heterogeneity tools describe that variance in the units of the effect, where a prediction interval says more than I-squared. Meta-regression asks whether a moderator explains the spread, and controls the false positives that a fixed-effect fit would invent. And the publication-bias checks ask whether the studies were a fair sample to begin with. When they were not, a selection model can put a number on the correction, as long as there are enough studies to identify it. Each step guards the step before it, which is the whole point of a careful synthesis.
## References
- Egger M, Davey Smith G, Schneider M, Minder C 1997. BMJ 315(7109):629-634 (10.1136/bmj.315.7109.629)
- Duval S, Tweedie R 2000. Biometrics 56(2):455-463 (10.1111/j.0006-341X.2000.00455.x)
- Sterne JAC, Sutton AJ, Ioannidis JPA, et al. 2011. BMJ 343:d4002 (10.1136/bmj.d4002)
- Nakagawa S, Santos ESA 2012. Evolutionary Ecology 26(5):1253-1274 (10.1007/s10682-012-9555-5)
- Koricheva J, Gurevitch J, Mengersen K 2013. Handbook of Meta-analysis in Ecology and Evolution. ISBN 978-0-691-13729-4
- Borenstein M, Hedges LV, Higgins JPT, Rothstein HR 2009. Introduction to Meta-Analysis. ISBN 978-0-470-05724-7
## Related tutorials
- [Inclusion criteria move the pooled effect](../inclusion-criteria-in-a-synthesis/)
- [Heterogeneity in meta-analysis](../heterogeneity-in-meta-analysis/)
- [Selection models for publication bias](../selection-models-publication-bias/)
- [PET-PEESE and publication bias under heterogeneity](../pet-peese-and-two-kinds-of-selection/)