Skip to content

Multiple Imputation with mice and miceforest

Single imputation’s flaw is not the guess. It is that the guess is filed as a measurement, so the model prices it at full confidence. Multiple imputation fabricates m plausible data sets instead of one, fits the analysis model m times, and pools the fits with Rubin’s rules: the average within-data-set variance, plus the disagreement between data sets, scaled by 1 + 1/m. The between-imputation disagreement is the price of not knowing, and it belongs in the standard error.

The imputation engine below is mice, the reference implementation in R. Give it the cohort with its factor types set and it chooses a method per variable: predictive mean matching for the numeric ones, logistic regression for the binary one, a proportional odds model for the ordered categories. The Python tab builds the same design with miceforest, whose mean-matching step plays the same role: imputed values always come back as observed values, so there are no fractional smokers and no negative cholesterol.

suppressPackageStartupMessages(library(mice))
missing_data <- read.csv("/opt/data/cohort_missing.csv", na.strings = "")
complete_data <- read.csv("/opt/data/cohort_complete.csv", na.strings = "")
missing_data$activity <- factor(
missing_data$activity,
levels = c("low", "medium", "high"),
ordered = TRUE
)
missing_data$smoking <- factor(missing_data$smoking, levels = c(0, 1))
complete_data$activity <- factor(
complete_data$activity,
levels = c("low", "medium", "high"),
ordered = TRUE
)
complete_data$smoking <- factor(complete_data$smoking, levels = c(0, 1))
complete_fit <- lm(sbp ~ age + bmi + chol + smoking, data = complete_data)
complete_chol <- coef(summary(complete_fit))["chol", ]
cat(
sprintf(
"Complete data: chol coefficient %.4f (SE %.4f)\n",
complete_chol["Estimate"],
complete_chol["Std. Error"]
)
)
cohort_data <- missing_data[, setdiff(names(missing_data), "patient_id")]
imp <- mice(cohort_data, m = 20, seed = 20260820, printFlag = FALSE)
print(imp$method)
Complete data: chol coefficient 3.0370 (SE 0.8299)
age bmi activity smoking chol sbp
"" "" "polr" "logreg" "pmm" "pmm"

The benchmark stands at 3.0370 with a standard error of 0.8299, and the truth target of this page is that pair. The R tab also reports the method mice chose: nothing for the two complete variables, pmm for cholesterol and blood pressure, logreg for smoking, polr for activity. Choosing the model per variable type is mice’s job one; the next page takes it apart.

Fit the model on each of the 20 completed data sets and pool. Rubin’s rules sum the mean within-imputation variance and the between-imputation variance with a finite-m correction. The pooled standard error is then honest: it includes a term for the uncertainty that the imputed values introduced. mice reports a t interval on Barnard-Rubin degrees of freedom; the Python tab here pools by hand with the same variance decomposition and a normal approximation, a difference flagged in the code comments.

fit <- with(imp, lm(sbp ~ age + bmi + chol + smoking))
pooled <- pool(fit)
print(summary(pooled, conf.int = TRUE))
term estimate std.error statistic df p.value 2.5 %
1 (Intercept) 61.2131164 5.02494766 12.181842 197.4217 7.998547e-26 51.3036533
2 age 0.9265704 0.05987685 15.474601 216.6339 7.071040e-37 0.8085546
3 bmi 0.6917429 0.17841997 3.877049 263.6103 1.335509e-04 0.3404333
4 chol 2.8645593 0.92607684 3.093220 171.3441 2.312390e-03 1.0365709
5 smoking1 7.9024109 1.45861066 5.417766 135.3542 2.677274e-07 5.0177961
97.5 % conf.low conf.high
1 71.122579 51.3036533 71.122579
2 1.044586 0.8085546 1.044586
3 1.043053 0.3404333 1.043053
4 4.692548 1.0365709 4.692548
5 10.787026 5.0177961 10.787026

mice pools the cholesterol term at 2.8646 with a standard error of 0.9261 and a Barnard-Rubin 171 degrees of freedom, and the confidence interval from 1.0366 to 4.6925 contains the benchmark. miceforest pools at 3.7856 with a standard error of 0.8254, and its interval from 2.1678 to 5.4034 contains the benchmark too. The point estimates bracket the truth from opposite sides, which is exactly the spread you pay for when the imputation model is a choice and not a fact.

The reason a pooled standard error can be honest is that the m fits tell you how loud the missing data speak. Collect the cholesterol coefficient from each of the 20 fits and look at the spread.

chol_estimates <- sapply(fit$analyses, coef)["chol", ]
cat(sprintf("Chol coefficient minimum: %.4f\n", min(chol_estimates)))
cat(sprintf("Chol coefficient maximum: %.4f\n", max(chol_estimates)))
cat(sprintf("Chol coefficient standard deviation: %.4f\n", sd(chol_estimates)))
Chol coefficient minimum: 1.9353
Chol coefficient maximum: 3.6176
Chol coefficient standard deviation: 0.4248

mice’s twenty fits spread the cholesterol term from 1.9353 to 3.6176, a standard deviation of 0.4248 that feeds the 0.9261 standard error. miceforest’s twenty fits span 3.4040 to 4.2102 with a standard deviation of 0.2049, roughly half, so its pooled standard error lands close to the single-imputation level. A boosted mean-matching engine is more certain about its guesses than a parametric chain is, and this page cannot tell you which certainty is right. The evaluation page puts the question to the truth, which is the only referee the cohort has.

One more measurement before leaving single imputation behind, because it is the argument for everything above.

# A single fabricated data set is priced by lm as if it were measured.
single_imp <- mice(
cohort_data,
m = 1,
maxit = 5,
seed = 20260821,
printFlag = FALSE
)
single_fit <- lm(
sbp ~ age + bmi + chol + smoking,
data = complete(single_imp)
)
single_chol <- coef(summary(single_fit))["chol", ]
cat(
sprintf(
"Single pmm imputation: chol coefficient %.4f (SE %.4f)\n",
single_chol["Estimate"],
single_chol["Std. Error"]
)
)
Single pmm imputation: chol coefficient 2.6593 (SE 0.8142)

A single predictive-mean-matching imputation returns 2.6593 with a standard error of 0.8142, practically the benchmark’s 0.8299. The model cannot see which rows were fabricated, so it prices the fabricated ones as measurements. That is the mean-imputation disease from the previous page in its most polite form: the best single guess still lies about confidence, and only the pooled run prices the guesswork.

The cost of m is compute, so the only question is how large m has to be before the pooled answer stops moving.

imp_5 <- mice(cohort_data, m = 5, seed = 20260820, printFlag = FALSE)
imp_100 <- mice(cohort_data, m = 100, seed = 20260822, printFlag = FALSE)
for (imputation_count in c(5, 20, 100)) {
current_imp <- switch(
as.character(imputation_count),
"5" = imp_5,
"20" = imp,
"100" = imp_100
)
current_fit <- with(
current_imp,
lm(sbp ~ age + bmi + chol + smoking)
)
current_pooled <- pool(current_fit)
current_summary <- summary(current_pooled)
current_chol <- current_summary[current_summary$term == "chol", ]
cat(
sprintf(
"m = %d: pooled chol coefficient %.4f (SE %.4f)\n",
imputation_count,
current_chol[["estimate"]],
current_chol[["std.error"]]
)
)
}
m = 5: pooled chol coefficient 2.9162 (SE 0.8420)
m = 20: pooled chol coefficient 2.8646 (SE 0.9261)
m = 100: pooled chol coefficient 2.7851 (SE 0.9536)

At m = 5 the standard error is 0.8420 and the estimate is 2.9162; at m = 100 the standard error has climbed to 0.9536 with the estimate at 2.7851, and m = 20 sits between them. The point estimate is stable from m = 20 on, but the standard error itself carries Monte Carlo noise, and at m = 5 that noise is larger than the margin between the honest 0.93 and the fake 0.83. The common guidance is to set m at least to the percentage of incomplete information, and here the largest per-variable share is 15.5 percent missing in cholesterol, so m = 20 is comfortable and m = 100 only steadies the standard error.

Single imputation is outpriced; multiple imputation is the default. The pages left are about what model fabricates the values and how to check its work.