Missing Data — Foundations
Python · PyMC · R · Download missing-data module
Treat the Gap as a Parameter
The Bayesian view of missing data is almost anticlimactic: a missing value is just another unknown, so give it a distribution and sample it alongside the parameters. That is data augmentation — the same move already used for the latent in Albert–Chib probit, the censored draws in Tobit, and the class label in latent class analysis. Those were special cases; missing data is the general one. Under a multivariate normal model the loop is two conjugate steps: impute each row's missing entries from their conditional normal given what was observed, then draw from the completed data.
Why it is allowed
What makes the loop legitimate is Rubin's taxonomy. Missingness is MCAR if it is independent of everything, MAR if it depends only on observed data, and MNAR if it depends on the values that are missing. Under MCAR and MAR the mechanism is ignorable — you may impute from the data model alone and never model why data went missing. That single word is what the whole first half of this arc rests on.
What deletion costs
The cost of ignoring it is shown rather than asserted. Under MAR with 45% missingness on a correlated pair, complete-case deletion estimates a mean of −0.30 against a truth of 0.00, discarding 1,838 of 4,000 rows to get there. Both imputation routes — the Bayesian augmentation and its frequentist twin, EM — land on −0.02. Deletion is unbiased only under MCAR, and it throws away every observed entry that happens to share a row with a gap.
| estimate of a mean whose truth is 0.00 | MCAR | MAR | MNAR |
|---|---|---|---|
| complete-case deletion | safe | −0.30 | −0.51 |
| data augmentation | safe | −0.02 | −0.37 |
| EM (maximum likelihood) | safe | −0.02 | biased |
Where ignorability runs out
The chapter is equally clear about where the method stops. Under MNAR the same sampler estimates −0.37 where complete-case gives −0.51: both still badly biased. When missingness depends on the unobserved value, the observed data are simply silent about the distribution of what is missing, and no ignorable method — Bayesian or frequentist — can recover it. That failure is the reason the arc continues into selection and pattern-mixture models, which require an explicit and untestable assumption about the mechanism.
Real data
On the real `airquality` data — 153 days, 37 ozone and 7 solar values missing — the joint model fills 44 cells, each gap borrowing strength from the variables measured on the same day. Schafer's norm reproduces the posterior mean in R, EM matches the maximum likelihood, and Amelia supplies five completed datasets by a different algorithm entirely.
Reading the Evidence at Its Actual Strength
Two corrections. The PyMC comparison labelled its mean vector "log-scale" when only two of its four entries are logged — 9.95 and 77.9 are plainly wind speed in mph and temperature in Fahrenheit. And the notebook claimed the imputed mean "shifts" on the strength of 42.1 → 41.7 ppb, which is a change of 0.4 that the 116 observed days swamp. The informative quantity is the imputed days alone, so that is what is reported: 40.5 ppb, 95% CrI [33.0, 50.9], against 42.1 observed. That is a shift in the direction MAR predicts, but by 1.6 ppb with an interval that comfortably contains the observed mean — suggestive, not decisive. Amelia puts the same quantity nearer 39. Thirty-seven imputed days cannot pin down much, and the sign alone should not be read as confirmation.
Notebooks
Downloads
md_missdata.py Mechanism simulation for MCAR, MAR and MNAR; Schafer data-augmentation Gibbs for an incomplete multivariate normal, grouped by missingness pattern; EM for the same likelihood; and complete-case moments (NumPy / SciPy) airquality.csv New York air quality — 153 days with 37 missing ozone and 7 missing solar readings, the standard imputation benchmark Missing-Data Module — Source Code
"""
missdata.py -- FOUNDATIONS OF BAYESIAN MISSING-DATA IMPUTATION (from scratch).
Backs the notebooks in "Missing Data -- Foundations".
The Bayesian view of missing data is disarmingly simple: a missing value is just another
unknown, so give it a distribution and sample it alongside the parameters. Under a multivariate
normal model that is Schafer's DATA AUGMENTATION (Little & Rubin; Tanner & Wong) -- the exact
"treat the unknown as a parameter and draw it" move already used for the latent z in Albert-Chib
probit, the censored draws in Tobit, and the latent class in LCA. Missing data is the general case;
those were special cases.
Rubin's taxonomy sets the ground rules. Missingness is MCAR if it is independent of all data, MAR
if it depends only on the OBSERVED data, and MNAR if it depends on the values that are missing.
Under MCAR/MAR the missingness mechanism is IGNORABLE: we may impute from the data model alone and
need not model why data are missing. Complete-case (listwise) deletion is unbiased only under MCAR
and generally wastes information; Bayesian imputation uses every observed entry.
The sampler is a two-step Gibbs on an incomplete N x p multivariate normal Y ~ N(mu, Sigma):
I-step (impute): for each row, draw the missing entries from their conditional normal given the
observed entries, y_mis | y_obs ~ N( mu_mis + B (y_obs - mu_obs), S_mis.obs ),
with B = Sigma_mis,obs Sigma_obs,obs^{-1} and Schur-complement covariance.
P-step (params): given the completed data, draw (mu, Sigma) from the conjugate normal-inverse-
Wishart posterior (here the standard noninformative limit):
Sigma ~ InvWishart(N-1, S), mu | Sigma ~ N(ybar, Sigma / N).
Rows are grouped by missingness PATTERN so each conditional regression B is formed once per pattern
(Schafer's efficiency trick). Storing spaced completed datasets yields the multiple imputations that
feed Rubin's rules in the next project.
"""
import numpy as np
from scipy.stats import invwishart
# --------------------------------------------------------------------------- #
# induce missingness under a chosen mechanism #
# --------------------------------------------------------------------------- #
def induce_missing(Y, rng, mechanism="MCAR", frac=0.3, target=0, driver=1):
"""Return a copy of Y with NaNs in column `target` under MCAR / MAR / MNAR.
MCAR: missing completely at random (rate `frac`).
MAR : probability of missing rises with the OBSERVED `driver` column.
MNAR: probability of missing rises with the target column's OWN (would-be) value."""
Y = np.array(Y, float); N = Y.shape[0]
if mechanism == "MCAR":
p = np.full(N, frac)
elif mechanism == "MAR":
z = (Y[:, driver] - Y[:, driver].mean()) / Y[:, driver].std()
p = 1 / (1 + np.exp(-(z * 2))) # higher driver -> more missing
else: # MNAR
z = (Y[:, target] - Y[:, target].mean()) / Y[:, target].std()
p = 1 / (1 + np.exp(-(z * 2))) # higher own value -> more missing
p = p * (frac / p.mean()) # scale to the requested average rate
Y[rng.random(N) < np.clip(p, 0, 1), target] = np.nan
return Y
# --------------------------------------------------------------------------- #
# simulation #
# --------------------------------------------------------------------------- #
def simulate_mvn(N, mu, Sigma, rng):
return rng.multivariate_normal(np.asarray(mu, float), np.asarray(Sigma, float), size=N)
# --------------------------------------------------------------------------- #
# data-augmentation Gibbs sampler #
# --------------------------------------------------------------------------- #
def _patterns(Mask):
"""group row indices by identical missingness pattern (tuple of missing columns)."""
groups = {}
for i, row in enumerate(Mask):
groups.setdefault(tuple(np.where(row)[0]), []).append(i)
return {k: np.array(v) for k, v in groups.items()}
def da_gibbs(Y, rng, draws=2000, burn=1000, m_keep=20):
"""Schafer data-augmentation Gibbs for an incomplete multivariate normal.
Y : (N,p) with np.nan at missing entries. Returns posterior draws of mu (draws,p) and Sigma
(draws,p,p), `m_keep` completed datasets (multiple imputations) and the posterior-mean imputation."""
Y = np.array(Y, float); N, p = Y.shape
Mask = np.isnan(Y); pat = _patterns(Mask)
Yc = Y.copy()
col_mean = np.nanmean(Y, axis=0)
for j in range(p):
Yc[Mask[:, j], j] = col_mean[j] # start from mean imputation
MU = np.empty((draws, p)); SIG = np.empty((draws, p, p))
keep_idx = set(np.linspace(0, draws - 1, m_keep).astype(int))
completed = []; imp_sum = np.zeros((N, p)); n_imp = 0
for it in range(draws + burn):
# ---- P-step: (mu, Sigma) | completed data (noninformative NIW limit) ----
ybar = Yc.mean(0); D = Yc - ybar; S = D.T @ D
Sigma = invwishart.rvs(df=N - 1, scale=S, random_state=rng)
Sigma = np.atleast_2d(Sigma)
mu = ybar + np.linalg.cholesky(Sigma / N) @ rng.standard_normal(p)
# ---- I-step: impute missing entries per pattern ----
for mis, rows in pat.items():
if not mis:
continue
mis = list(mis); obs = [j for j in range(p) if j not in mis]
if obs:
So_o = Sigma[np.ix_(obs, obs)]
B = Sigma[np.ix_(mis, obs)] @ np.linalg.inv(So_o)
cmean = mu[mis] + (Yc[np.ix_(rows, obs)] - mu[obs]) @ B.T # (n_rows, n_mis)
ccov = Sigma[np.ix_(mis, mis)] - B @ Sigma[np.ix_(obs, mis)]
else: # whole row missing -> marginal
cmean = np.tile(mu[mis], (len(rows), 1)); ccov = Sigma[np.ix_(mis, mis)]
Lc = np.linalg.cholesky(ccov + 1e-10 * np.eye(len(mis)))
draw = cmean + rng.standard_normal((len(rows), len(mis))) @ Lc.T
Yc[np.ix_(rows, mis)] = draw
if it >= burn:
i = it - burn; MU[i] = mu; SIG[i] = Sigma
imp_sum += Yc; n_imp += 1
if i in keep_idx:
completed.append(Yc.copy())
return dict(mu=MU, Sigma=SIG, completed=completed, imputed_mean=imp_sum / n_imp, mask=Mask)
# --------------------------------------------------------------------------- #
# EM (frequentist counterpart) and complete-case moments #
# --------------------------------------------------------------------------- #
def em_mvn(Y, tol=1e-6, max_iter=500):
"""EM for the incomplete-multivariate-normal MLE of (mu, Sigma) -- the frequentist twin of the
Bayesian data augmentation (expectation replaces the imputation draw, maximisation the parameter draw)."""
Y = np.array(Y, float); N, p = Y.shape; Mask = np.isnan(Y); pat = _patterns(Mask)
cc = ~Mask.any(axis=1) # init from complete-case moments:
if cc.sum() > p: # a DIAGONAL Sigma is a degenerate fixed
mu = Y[cc].mean(0); Sigma = np.cov(Y[cc], rowvar=False) # point (B=0), so start full-rank
else:
mu = np.nanmean(Y, axis=0); Sigma = np.diag(np.nanvar(Y, axis=0))
for _ in range(max_iter):
T1 = np.zeros(p); T2 = np.zeros((p, p))
for mis, rows in pat.items():
obs = [j for j in range(p) if j not in mis]; mis = list(mis)
Yr = Y[rows]
if mis and obs:
So_o = Sigma[np.ix_(obs, obs)]
B = Sigma[np.ix_(mis, obs)] @ np.linalg.inv(So_o)
yhat = Yr.copy()
yhat[:, mis] = mu[mis] + (Yr[:, obs] - mu[obs]) @ B.T
ccov = Sigma[np.ix_(mis, mis)] - B @ Sigma[np.ix_(obs, mis)]
elif mis:
yhat = np.tile(mu, (len(rows), 1)); ccov = Sigma[np.ix_(mis, mis)]
else:
yhat = Yr; ccov = None
T1 += yhat.sum(0); T2 += yhat.T @ yhat
if mis and ccov is not None:
add = np.zeros((p, p)); add[np.ix_(mis, mis)] = ccov * len(rows); T2 += add
mu_new = T1 / N; Sigma_new = T2 / N - np.outer(mu_new, mu_new)
if np.max(np.abs(mu_new - mu)) < tol:
mu, Sigma = mu_new, Sigma_new; break
mu, Sigma = mu_new, Sigma_new
return mu, Sigma
def complete_case(Y):
"""listwise-deletion moments -- unbiased only under MCAR."""
Y = np.array(Y, float); keep = ~np.isnan(Y).any(axis=1); Yc = Y[keep]
return Yc.mean(0), np.cov(Yc, rowvar=False), keep.sum()
References
- Rubin, D. B. (1976). Inference and missing data. Biometrika 63(3), 581–592. — MCAR, MAR, MNAR and the definition of ignorability
- Little, R. J. A. & Rubin, D. B. (2019). Statistical Analysis with Missing Data (3rd ed.). Wiley. — the standard reference for everything in this project
- Schafer, J. L. (1997). Analysis of Incomplete Multivariate Data. Chapman & Hall. — the pattern-grouped data-augmentation sampler implemented here, and the norm package
- Tanner, M. A. & Wong, W. H. (1987). The calculation of posterior distributions by data augmentation. JASA 82(398), 528–540. — data augmentation itself
- Dempster, A. P., Laird, N. M. & Rubin, D. B. (1977). Maximum likelihood from incomplete data via the EM algorithm. JRSS-B 39(1), 1–38. — the frequentist twin used as the cross-check
- Honaker, J., King, G. & Blackwell, M. (2011). Amelia II: a program for missing data. Journal of Statistical Software 45(7). — the bootstrap-EM multiple imputation in the R notebook