Choosing the Imputation Model
On the previous page mice chose the imputation methods and you accepted them. A default is a choice someone else made, and this page reopens two of those choices and measures what each one moves. The first choice is which variables enter the imputation model at all. The second is how an imputed value is produced once the model exists: drawn from a fitted distribution, or matched to a real observed value.
Which variables belong in the imputation model
Section titled “Which variables belong in the imputation model”The rule of thumb is generous. Keep the analysis variables, keep anything that
predicts the missingness itself, and keep anything correlated with the values
being imputed. The two screens below apply that rule mechanically: quickpred
computes it in R, and the Python tab prints the same evidence as an explicit
correlation table.
suppressPackageStartupMessages(library(mice))
complete_data <- read.csv("/opt/data/cohort_complete.csv")missing_data <- read.csv("/opt/data/cohort_missing.csv")
# Keep the activity order so the models preserve its rank.activity_levels <- c("low", "medium", "high")complete_data$activity <- ordered( complete_data$activity, levels = activity_levels)missing_data$activity <- ordered( missing_data$activity, levels = activity_levels)complete_data$smoking <- factor(complete_data$smoking, levels = c(0, 1))missing_data$smoking <- factor(missing_data$smoking, levels = c(0, 1))
benchmark_model <- lm(sbp ~ age + bmi + chol + smoking, data = complete_data)benchmark_term <- summary(benchmark_model)$coefficients["chol", ]cat(sprintf( "Complete data: chol coefficient %.4f (SE %.4f)", benchmark_term["Estimate"], benchmark_term["Std. Error"]), "\n", sep = "")
model_data <- missing_data[, setdiff(names(missing_data), "patient_id")]# An entry is 1 if a pairwise-complete correlation with the target, or with its# missingness indicator, exceeds the mincor threshold.predictor_selection <- quickpred(model_data)cat("quickpred selection matrix (1 = kept predictor):\n")print(predictor_selection)Complete data: chol coefficient 3.0370 (SE 0.8299)quickpred selection matrix (1 = kept predictor): age bmi activity smoking chol sbpage 0 0 0 0 0 0bmi 0 0 0 0 0 0activity 1 1 0 0 1 1smoking 1 1 0 0 1 0chol 1 1 1 1 0 1sbp 1 1 1 0 1 0import numpy as npimport pandas as pdimport statsmodels.api as smfrom miceforest import ImputationKernel
complete_data = pd.read_csv("/opt/data/cohort_complete.csv")missing_data = pd.read_csv("/opt/data/cohort_missing.csv")
# Keep the activity order so the models preserve its rank; the codes match the# other pages of this section.activity_codes = {"low": 1, "medium": 2, "high": 3}missing_data["activity"] = missing_data["activity"].map(activity_codes)
analysis_predictors = ["age", "bmi", "chol", "smoking"]benchmark_design = sm.add_constant( complete_data[analysis_predictors], has_constant="add",)benchmark_model = sm.OLS(complete_data["sbp"], benchmark_design).fit()print( "Complete data: chol coefficient " f"{benchmark_model.params['chol']:.4f} " f"(SE {benchmark_model.bse['chol']:.4f})")
incomplete_columns = ["activity", "smoking", "chol", "sbp"]screen_columns = ["age", "bmi"] + incomplete_columns# The R tab automates this screen; Python users check each pair explicitly.correlations = missing_data[screen_columns].corr(min_periods=1)print("pairwise-complete correlations:")print(correlations.loc[incomplete_columns, screen_columns].to_string())Complete data: chol coefficient 3.0370 (SE 0.8299)pairwise-complete correlations: age bmi activity smoking chol sbpactivity -0.302534 -0.225198 1.000000 0.028314 -0.407641 -0.267500smoking -0.168199 -0.149558 0.028314 1.000000 -0.131024 0.087792chol 0.464227 0.379158 -0.407641 -0.131024 1.000000 0.453357sbp 0.708130 0.293820 -0.267500 0.087792 0.453357 1.000000Read the R matrix by row: the row names are the variables with gaps, and a 1 marks a predictor kept for that variable. Cholesterol keeps everything, which is the expected answer for a variable that correlates with age at 0.464 and with blood pressure at 0.453 in the Python table. The blood pressure row drops smoking, and the correlation table shows why: at 0.088, that pair carries no signal worth imputing from. The two screens are the same evidence in two dialects.
Does the screening move the answer
Section titled “Does the screening move the answer”Screening is worth measuring rather than assuming. Both runs below use the same seed and the same 20 imputations; the only difference is the predictor matrix.
#' Extract one pooled estimate and its standard error.#'#' @param pooled_model A `mice::pool()` result.#' @param term The coefficient name to extract.#' @return A numeric vector with the estimate and standard error.GetPooledTerm <- function(pooled_model, term) { pooled_summary <- summary(pooled_model) term_row <- pooled_summary[pooled_summary$term == term, ] c(estimate = term_row$estimate, std_error = term_row$std.error)}
#' Print a pooled cholesterol coefficient.#'#' @param label The text before the coefficient.#' @param pooled_model A `mice::pool()` result.#' @return The function returns no value.PrintPooledChol <- function(label, pooled_model) { pooled_term <- GetPooledTerm(pooled_model, "chol") message_text <- sprintf( "%s: pooled chol coefficient %.4f (SE %.4f)", label, pooled_term["estimate"], pooled_term["std_error"] ) cat(message_text, "\n", sep = "") invisible(NULL)}
all_predictors <- mice( model_data, m = 20, seed = 20260820, printFlag = FALSE)selected_predictors <- mice( model_data, m = 20, seed = 20260820, predictorMatrix = predictor_selection, printFlag = FALSE)all_pooled <- pool(with(all_predictors, lm(sbp ~ age + bmi + chol + smoking)))selected_pooled <- pool( with(selected_predictors, lm(sbp ~ age + bmi + chol + smoking)))PrintPooledChol("All predictors", all_pooled)PrintPooledChol("quickpred selection", selected_pooled)All predictors: pooled chol coefficient 2.8646 (SE 0.9261)quickpred selection: pooled chol coefficient 2.8596 (SE 0.9568)Here it barely moves: 2.8646 with the full matrix against 2.8596 with the screened one. That is the honest result on a six-variable cohort where nearly everything correlates with something. Screening pays its rent on wide, noisy tables, where dozens of junk predictors dilute the imputation model; with six variables there is little to dilute. The habit to keep is running the screen and reading it, not expecting it to rescue every data set.
How the imputed value is drawn
Section titled “How the imputed value is drawn”The second choice is subtler. A parametric method draws from a fitted
distribution, and the draw can land anywhere the distribution has mass,
including outside any range the observed data ever touched. Predictive mean
matching instead takes the patients whose predicted values are closest and
returns one of their observed measurements, so an imputed value is always a real
patient’s real number. mice’s default for numeric variables is pmm; the
norm method is the pure draw. The same fork exists in miceforest:
mean_match_candidates set to 5 matches, and set to 0 draws.
#' Print the share outside an observed range.#'#' @param label The text before the share.#' @param values The imputed values to assess.#' @param observed_range A numeric vector with the observed minimum and maximum.#' @return The function returns no value.PrintOutsideRangeShare <- function(label, values, observed_range) { outside_share <- mean( values < observed_range[1] | values > observed_range[2] ) * 100 cat(sprintf( "%s: imputed sbp outside observed range %.2f%%", label, outside_share ), "\n", sep = "") invisible(NULL)}
sbp_missing <- is.na(model_data$sbp)observed_sbp_range <- range(model_data$sbp, na.rm = TRUE)pmm_imputation <- mice( model_data, m = 20, seed = 20260823, printFlag = FALSE)# Keep the default method for every variable except sbp.default_methods <- mice( model_data, m = 1, maxit = 0, seed = 20260823, printFlag = FALSE)$methodnormal_methods <- default_methodsnormal_methods["sbp"] <- "norm"normal_imputation <- mice( model_data, m = 20, seed = 20260823, method = normal_methods, printFlag = FALSE)
pmm_long <- complete(pmm_imputation, "long")normal_long <- complete(normal_imputation, "long")pmm_sbp <- pmm_long$sbp[sbp_missing[as.integer(pmm_long$.id)]]normal_sbp <- normal_long$sbp[sbp_missing[as.integer(normal_long$.id)]]PrintOutsideRangeShare("pmm on sbp", pmm_sbp, observed_sbp_range)PrintOutsideRangeShare("norm on sbp", normal_sbp, observed_sbp_range)
pmm_pooled <- pool(with(pmm_imputation, lm(sbp ~ age + bmi + chol + smoking)))normal_pooled <- pool( with(normal_imputation, lm(sbp ~ age + bmi + chol + smoking)))PrintPooledChol("pmm on sbp", pmm_pooled)PrintPooledChol("norm on sbp", normal_pooled)pmm on sbp: imputed sbp outside observed range 0.00%norm on sbp: imputed sbp outside observed range 0.10%pmm on sbp: pooled chol coefficient 2.9565 (SE 0.9658)norm on sbp: pooled chol coefficient 2.8114 (SE 1.0135)def smoking_outside_share(kernel, smoking_missing): """Calculate the imputed-smoking share outside its observed categories.
Args: kernel: A fitted miceforest imputation kernel. smoking_missing: A boolean Series for missing smoking values.
Returns: The percentage of imputed smoking values outside zero and one. """ shares = [] for dataset_index in range(kernel.num_datasets): completed_data = kernel.complete_data(dataset=dataset_index) imputed_smoking = completed_data.loc[smoking_missing, "smoking"] shares.append((~imputed_smoking.isin([0, 1])).mean()) return 100 * np.mean(shares)
kernel_data = missing_data.drop(columns="patient_id")smoking_missing = kernel_data["smoking"].isna()mean_match_kernel = ImputationKernel( kernel_data.copy(), num_datasets=20, random_state=20260820, mean_match_candidates=5,)draw_kernel = ImputationKernel( kernel_data.copy(), num_datasets=20, random_state=20260820, # Version 6.0.5 uses zero to disable matching and return model draws. mean_match_candidates=0,)mean_match_kernel.mice(iterations=5, verbose=False, num_threads=1)draw_kernel.mice(iterations=5, verbose=False, num_threads=1)print( "mean matching: imputed smoking outside {0, 1} " f"{smoking_outside_share(mean_match_kernel, smoking_missing):.2f}%")print( "posterior draw: imputed smoking outside {0, 1} " f"{smoking_outside_share(draw_kernel, smoking_missing):.2f}%")
def fit_analysis_models(kernel): """Fit the analysis model to each completed data set.
Args: kernel: A fitted miceforest imputation kernel.
Returns: A list of fitted statsmodels ordinary least squares models. """ fitted_models = [] predictors = ["age", "bmi", "chol", "smoking"] for dataset_index in range(kernel.num_datasets): completed_data = kernel.complete_data(dataset=dataset_index) design_matrix = sm.add_constant( completed_data[predictors], has_constant="add", ) fitted_models.append(sm.OLS(completed_data["sbp"], design_matrix).fit()) return fitted_models
def pool_rubins(fitted_models): """Pool linear-model estimates with Rubin's rules.
Args: fitted_models: A list of fitted statsmodels linear models.
Returns: A DataFrame with the estimate, standard error, and normal interval. """ coefficient_table = pd.DataFrame([model.params for model in fitted_models]) within_variance = sum( (model.cov_params() for model in fitted_models), start=pd.DataFrame( 0.0, index=coefficient_table.columns, columns=coefficient_table.columns, ), ) / len(fitted_models) between_variance = coefficient_table.cov() total_variance = within_variance + ( 1 + 1 / len(fitted_models) ) * between_variance standard_errors = pd.Series( np.sqrt(np.diag(total_variance)), index=coefficient_table.columns, ) estimates = coefficient_table.mean() return pd.DataFrame( { "estimate": estimates, "std_error": standard_errors, "lower": estimates - 1.96 * standard_errors, "upper": estimates + 1.96 * standard_errors, } )
mean_match_pool = pool_rubins(fit_analysis_models(mean_match_kernel))draw_pool = pool_rubins(fit_analysis_models(draw_kernel))print( "mean matching: pooled chol coefficient " f"{mean_match_pool.loc['chol', 'estimate']:.4f} " f"(SE {mean_match_pool.loc['chol', 'std_error']:.4f})")print( "posterior draw: pooled chol coefficient " f"{draw_pool.loc['chol', 'estimate']:.4f} " f"(SE {draw_pool.loc['chol', 'std_error']:.4f})")mean matching: imputed smoking outside {0, 1} 0.00%posterior draw: imputed smoking outside {0, 1} 100.00%mean matching: pooled chol coefficient 3.7856 (SE 0.8254)posterior draw: pooled chol coefficient 3.8964 (SE 0.8219)The structural difference shows in the measurements. Under norm, 0.10 percent
of the imputed blood pressures fall outside the observed range; under pmm, the
share is 0.00 percent, and it cannot be anything else, because the method only
returns observed values. In the Python tab the same fork is starker: a posterior
draw for a binary variable is fractional in 100.00 percent of the imputed cells,
patients who smoke 0.3 of a cigarette, while mean matching never fabricates one.
The pooled answers move only modestly in return, 2.9565 against 2.8114 in R and
3.7856 against 3.8964 in Python. You do not choose matching because it wins on
this cohort. You choose it because of what it is incapable of inventing.
Both engines then agree with the last page in the only way that matters: the model choice moves the estimate less than the decision to impute multiply did, and more than nothing. What remains is checking the work itself, which is the final page.