## draw sigma^2 from its scaled inverse chi-square posterior, then beta given sigma^2,
## then fill the gaps with the predicted mean plus a residual draw
step <- function(target, mask, preds) {
obs <- !mask
Xo <- cbind(1, preds[obs, , drop = FALSE]); Vo <- target[obs]
XtXinv <- chol2inv(chol(crossprod(Xo)))
bhat <- XtXinv %*% crossprod(Xo, Vo)
dfree <- sum(obs) - ncol(Xo)
s2 <- sum((Vo - Xo %*% bhat)^2) / dfree
s2star <- s2 * dfree / rchisq(1, dfree) # posterior draw of variance
bstar <- bhat + t(chol(s2star * XtXinv)) %*% rnorm(ncol(Xo)) # posterior draw of coefficients
out <- target
Xm <- cbind(1, preds[mask, , drop = FALSE])
out[mask] <- as.numeric(Xm %*% bstar) + rnorm(sum(mask), 0, sqrt(s2star))
out
}Multiple imputation by chained equations
The previous post ended on a wall: even a well-built single imputation reports too little uncertainty, because one completed dataset treats the filled values as if they were real. Multiple imputation walks around the wall instead of over it. Rather than commit to one set of guesses, it draws several completed datasets, analyses each, and then lets the disagreement between them widen the standard error. This post builds the method by hand, chained equations for the imputation and Rubin’s rules for the pooling, and checks that it delivers what single imputation could not.
The example has two variables with gaps, x and y, and a fully observed auxiliary z that helps predict them. The target is the slope of y on x, whose true value is 0.8.
One proper imputation step
Chained equations fill one variable at a time. To impute a variable from a set of predictors, fit a linear model on the rows where it is observed, then draw fresh values for the gaps from the model’s predictive distribution. The word proper matters: a single stochastic imputation used the point estimates of the regression coefficients and the residual scale, which understates uncertainty. Here we draw the residual variance and the coefficients from their posterior first, so that each imputation reflects the fact that the imputation model is itself estimated.
Chaining the variables
With more than one incomplete variable, no single regression has all its predictors observed, so the method cycles. Start by filling every gap with a random observed value, then repeatedly re-impute each variable using the current version of the others. A handful of cycles is enough for the fills to settle.
one_impute <- function(x, y, z, mx, my, cycles = 5) {
xc <- x; xc[mx] <- sample(x[!mx], sum(mx), TRUE) # random start
yc <- y; yc[my] <- sample(y[!my], sum(my), TRUE)
for (c in seq_len(cycles)) {
xc <- step(x, mx, cbind(yc, z)) # x given current y and z
yc <- step(y, my, cbind(xc, z)) # y given current x and z
}
data.frame(x = xc, y = yc)
}The data
set.seed(5108)
n <- 400
z <- rnorm(n)
x_true <- 0.7 * z + rnorm(n, 0, sqrt(1 - 0.49)) # x correlated with z
y_true <- 1 + 0.8 * x_true + rnorm(n, 0, 1.2) # slope of interest is 0.8
## missing at random: high z makes both x and y more likely to be missing (about 30% each)
ax <- uniroot(function(a) mean(plogis(a + z)) - 0.30, c(-6, 6))$root
mx <- runif(n) < plogis(ax + z)
my <- runif(n) < plogis(ax + z)
x <- x_true; x[mx] <- NA
y <- y_true; y[my] <- NA
full <- lm(y_true ~ x_true)
full_b <- unname(coef(full)[2]); full_se <- summary(full)$coef[2, 2]
cc <- !mx & !my
ccf <- lm(y[cc] ~ x[cc])
cc_b <- unname(coef(ccf)[2]); cc_se <- summary(ccf)$coef[2, 2]About 30% of x and 33% of y are missing, and only 196 of the 400 rows have both. The full data (which we only have here because it is a simulation) give a slope of 0.772 with a standard error of 0.056. Complete-case analysis, on those 196 rows, gives 0.68 with a standard error of 0.085: unbiased by design here, but wide, because it throws away every partially observed row.
Many imputations, then Rubin’s rules
Run the chained imputation 50 times to build 50 completed datasets, fit the slope on each, and collect the estimates and their standard errors.
m <- 50
ests <- ses <- numeric(m)
for (k in 1:m) {
d <- one_impute(x, y, z, mx, my, cycles = 5)
f <- lm(y ~ x, data = d)
ests[k] <- coef(f)[2]; ses[k] <- summary(f)$coef[2, 2]
}Rubin’s rules combine the m results. The pooled estimate is the average of the per-imputation estimates. The total variance has two parts: the average within-imputation variance (the ordinary uncertainty had the data been complete), plus the between-imputation variance (the extra uncertainty from not knowing the missing values), inflated by a factor 1 + 1/m.
pool <- function(ests, ses, m, nu_com) {
Qbar <- mean(ests) # pooled estimate
Ubar <- mean(ses^2) # within-imputation variance
B <- var(ests) # between-imputation variance
Tt <- Ubar + (1 + 1 / m) * B # total variance
lambda <- (1 + 1 / m) * B / Tt # fraction of missing information
nu_old <- (m - 1) / lambda^2
nu_obs <- (nu_com + 1) / (nu_com + 3) * nu_com * (1 - lambda)
df <- nu_old * nu_obs / (nu_old + nu_obs) # Barnard-Rubin degrees of freedom
c(Q = Qbar, se = sqrt(Tt), B = B, Ubar = Ubar, lambda = lambda, df = df)
}
pl <- pool(ests, ses, m, nu_com = n - 2)
round(pl, 4) Q se B Ubar lambda df
0.6893 0.0712 0.0016 0.0035 0.3156 174.7659
The pooled slope is 0.689, with a standard error of 0.071 on 174.8 degrees of freedom. In this particular sample the complete cases already sit low, near 0.68, so multiple imputation stays close to that rather than inventing a stronger relationship; the simulation below confirms all three estimators are unbiased on average. The between-imputation variance is 0.0016, small next to the within-imputation 0.0035, and the fraction of missing information is 0.316: roughly a third of the information about this slope is lost to the gaps. Figure 1 lays out the 50 imputations and their pooled summary.
ord <- order(ests)
cap <- data.frame(idx = 1:m, est = ests[ord], lo = ests[ord] - 1.96 * ses[ord],
hi = ests[ord] + 1.96 * ses[ord])
tcrit <- qt(0.975, pl["df"])
lo_p <- pl["Q"] - tcrit * pl["se"]; hi_p <- pl["Q"] + tcrit * pl["se"]
ggplot(cap, aes(y = idx)) +
geom_segment(aes(x = lo, xend = hi, yend = idx), colour = "#b9b4a8", linewidth = 0.4) +
geom_point(aes(x = est), colour = "#4a7a8c", size = 1.1) +
geom_vline(xintercept = 0.8, colour = ink, linetype = "dotted", linewidth = 0.6) +
annotate("segment", x = lo_p, xend = hi_p, y = -3, yend = -3, colour = "#a24b4b", linewidth = 1.4) +
annotate("point", x = pl["Q"], y = -3, colour = "#a24b4b", size = 3.2) +
annotate("text", x = pl["Q"], y = -9, label = "Pooled (Rubin's rules)",
colour = "#a24b4b", size = 3.3, fontface = "bold") +
scale_y_continuous(breaks = c(1, 10, 20, 30, 40, 50)) +
labs(title = "Fifty imputations and their pooled estimate",
subtitle = "Pooling adds the spread between imputations to the interval",
x = "Slope of y on x", y = "Imputation") + theme_te()
Does it cover?
The single dataset is suggestive, not proof. Repeat the whole pipeline over many datasets and compare three estimators: complete-case, a single stochastic imputation, and pooled multiple imputation. Record whether each 95% interval covers the truth and how wide it is.
reps <- 400; mM <- 20; b1 <- 0.8
res <- matrix(NA, reps, 7,
dimnames = list(NULL, c("cc_b", "cc_cov", "cc_w", "si_cov", "si_w", "mi_cov", "mi_w")))
si_b <- mi_b <- numeric(reps)
set.seed(909)
for (r in 1:reps) {
zz <- rnorm(n); xt <- 0.7 * zz + rnorm(n, 0, sqrt(1 - 0.49))
yt <- 1 + 0.8 * xt + rnorm(n, 0, 1.2)
a <- uniroot(function(a) mean(plogis(a + zz)) - 0.30, c(-6, 6))$root
mxx <- runif(n) < plogis(a + zz); myy <- runif(n) < plogis(a + zz)
xx <- xt; xx[mxx] <- NA; yy <- yt; yy[myy] <- NA
cr <- !mxx & !myy; cf <- lm(yy[cr] ~ xx[cr]); cb <- coef(cf)[2]; cs <- summary(cf)$coef[2, 2]
res[r, "cc_b"] <- cb; res[r, "cc_w"] <- 2 * 1.96 * cs
res[r, "cc_cov"] <- (cb - 1.96 * cs <= b1) & (b1 <= cb + 1.96 * cs)
es <- ss <- numeric(mM)
for (k in 1:mM) { d <- one_impute(xx, yy, zz, mxx, myy, cycles = 4)
f <- lm(y ~ x, data = d); es[k] <- coef(f)[2]; ss[k] <- summary(f)$coef[2, 2] }
si_b[r] <- es[1]
res[r, "si_cov"] <- (es[1] - 1.96 * ss[1] <= b1) & (b1 <= es[1] + 1.96 * ss[1])
res[r, "si_w"] <- 2 * 1.96 * ss[1]
p <- pool(es, ss, mM, n - 2); tc <- qt(0.975, p["df"])
mi_b[r] <- p["Q"]
res[r, "mi_cov"] <- (p["Q"] - tc * p["se"] <= b1) & (b1 <= p["Q"] + tc * p["se"])
res[r, "mi_w"] <- 2 * tc * p["se"]
}
tab <- data.frame(
method = c("Complete-case", "Single imputation", "Multiple imputation"),
bias = c(mean(res[, "cc_b"]) - b1, mean(si_b) - b1, mean(mi_b) - b1),
coverage = c(mean(res[, "cc_cov"]), mean(res[, "si_cov"]), mean(res[, "mi_cov"])),
width = c(mean(res[, "cc_w"]), NA, mean(res[, "mi_w"])))
round(tab[, -1], 3) bias coverage width
1 -0.007 0.965 0.348
2 -0.007 0.785 NA
3 -0.007 0.963 0.325
All three are unbiased, each within 0.007 of the true 0.8. The difference is in the intervals. Complete-case covers at 97%, honestly, but its mean width is 0.348. Single imputation covers at only 78%, the undercoverage carried over from the last post. Multiple imputation covers at 96% with a mean width of 0.325, narrower than complete-case because it uses the partially observed rows through the auxiliary z. Figure 2 places the three on the same axes.
sm <- data.frame(method = factor(c("Complete-case", "Single imputation", "Multiple imputation"),
levels = c("Complete-case", "Single imputation", "Multiple imputation")),
coverage = c(mean(res[, "cc_cov"]), mean(res[, "si_cov"]), mean(res[, "mi_cov"])),
width = c(mean(res[, "cc_w"]), mean(res[, "si_w"]), mean(res[, "mi_w"])))
ggplot(sm, aes(width, coverage, colour = method)) +
geom_hline(yintercept = 0.95, colour = ink, linetype = "dashed", linewidth = 0.5) +
geom_point(size = 4) +
geom_text(aes(label = method), vjust = -1.1, size = 3.4, show.legend = FALSE) +
scale_colour_manual(values = c("Complete-case" = "#c08a3e",
"Single imputation" = "#8a8578",
"Multiple imputation" = "#a24b4b"), guide = "none") +
scale_y_continuous(labels = function(v) paste0(round(100 * v), "%")) +
coord_cartesian(xlim = c(0.2, 0.38), ylim = c(0.74, 0.99)) +
labs(title = "Nominal coverage at a narrower interval",
subtitle = "Single imputation undercovers; complete-case is honest but wide",
x = "Mean 95% interval width", y = "Interval coverage") + theme_te()
The honest limit
Multiple imputation is not magic, and it is worth being clear about what carries it. The between-imputation variance is an honest accounting of one specific uncertainty: not knowing the missing values, given that the imputation model is right and the data are missing at random. It does nothing about the mechanism. If the gaps are missing not at random, every imputation is drawn from the wrong distribution and the pooled interval is confidently wrong, just as complete-case would be. It also assumes the imputation model and the analysis model tell a consistent story; when they do not, the pooled variance can be too small or too large (Meng 1994). Raising the number of imputations shrinks the Monte Carlo noise in the pooling, but it cannot fix a wrong model or a wrong mechanism, only sharpen the answer to the question actually posed. The working assumption throughout has been missing at random. The final post asks how to probe it, and what to do when it is indefensible.
References
Rubin DB 1987. Multiple Imputation for Nonresponse in Surveys. Wiley. ISBN 978-0-471-08705-2.
Rubin DB 1996. Journal of the American Statistical Association 91(434):473-489 (10.1080/01621459.1996.10476908).
van Buuren S, Groothuis-Oudshoorn K 2011. Journal of Statistical Software 45(3):1-67 (10.18637/jss.v045.i03).
White IR, Royston P, Wood AM 2011. Statistics in Medicine 30(4):377-399 (10.1002/sim.4067).
Barnard J, Rubin DB 1999. Biometrika 86(4):948-955 (10.1093/biomet/86.4.948).
Meng XL 1994. Statistical Science 9(4):538-558 (10.1214/ss/1177010269).
Penone C, Davidson AD, Shoemaker KT, Di Marco M, Rondinini C, Brooks TM, Young BE, Graham CH, Costa GC 2014. Methods in Ecology and Evolution 5(9):961-970 (10.1111/2041-210X.12232).
van Buuren S 2018. Flexible Imputation of Missing Data, 2nd edn. Chapman and Hall/CRC (10.1201/9780429492259).