ml-models / Data and evaluation / Missing data core

Missing data

Real tables have holes: a sensor drops out, a form field is skipped, a join finds no match. What you should do about a hole depends on why it is there. This page covers the three missingness mechanisms (MCAR, MAR, MNAR), deleting rows, filling with a mean, median or constant, iterative imputation, missing-indicator features, and how gradient-boosted trees learn where to send missing values without filling them at all.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. MCAR, MAR and MNAR
  5. Deletion
  6. Mean, median and constant imputation
  7. Iterative imputation
  8. Missing-indicator features
  9. How gradient-boosted trees route missing values
  10. Worked example
  11. Eight rows, two columns
  12. The same column under three mechanisms
  13. Iterative imputation, round by round
  14. When missingness is the signal
  15. One split, missing values routed natively
  16. Implementation
  17. Tradeoffs
  18. Check yourself
  19. Connections
  20. Read first
  21. Read next
  22. References

Problem

Most learners need a number in every cell. A linear model cannot multiply a weight by "unknown", and a distance cannot be computed to a point with no coordinate. So something has to happen to each missing value before training: drop the row, drop the column, guess a value, or use a model that has its own rule for missing input.

Every choice changes the data the model sees. Dropping rows can throw away most of a table and, worse, keep a biased subset. Filling with the column mean keeps every row but shrinks the column's spread and its correlation with everything else. And when the fact that a value is missing carries information (people with high incomes skip the income question more often), every filling method erases that signal unless you keep it on purpose.

Intuition

Ask one question first: would the missing values, if you could see them, look like the ones you can see? If a value went missing by pure chance, the observed rows are a fair sample and almost any method is unbiased. If it went missing because of something else you did observe (older people skip a question more), the observed rows are skewed, but you can correct for it using that other column. If it went missing because of its own value (high earners hide their income), the observed rows are skewed in a way the data cannot reveal.

Rubin named these three cases, and his labels are standard. Write M for the 0/1 indicator "this value is missing", Y for the value itself and X for the other, observed columns.

The three missingness mechanisms as arrows into the missing indicator Three panels, each with boxes for an observed column X, the value Y and the missing indicator M. In the MCAR panel nothing points at M. In the MAR panel an arrow goes from X to M. In the MNAR panel an arrow goes from Y to M. In all three panels an arrow goes from X to Y. MCAR MAR MNAR X Y M X Y M X Y M M is pure chance M depends on observed X M depends on Y itself

The mechanism is a statement about what M depends on. You can test some of the MAR arrow (does M vary with X?) but never the MNAR arrow, because Y is exactly what is missing.

Mechanics

MCAR, MAR and MNAR

  • MCAR, missing completely at random. P(M = 1) does not depend on X or Y. A lab sample dropped on the floor. The complete rows are a random subset, so deleting incomplete rows loses precision but adds no bias.
  • MAR, missing at random. P(M = 1) depends only on observed columns. Older patients skip a survey item more, and age is recorded. The complete rows are biased, but within each value of X they are still representative, so a method that conditions on X (regression imputation, iterative imputation, a model that uses X) can undo the bias.
  • MNAR, missing not at random. P(M = 1) depends on the missing value itself, even after accounting for X. High earners leave income blank. No method that only looks at the observed data removes the bias without an extra assumption about how M depends on Y; the honest options are to model that assumption explicitly, collect more data, or report how sensitive the result is.

The names are confusing ("at random" in MAR does not mean random); remember them by what M depends on: nothing, observed values, or unobserved values.

Deletion

Listwise deletion (complete-case analysis) drops every row with any missing value. It is unbiased under MCAR and simple, but the losses compound: with 10 columns each missing 10% of values independently, only 0.910 ≈ 35% of rows survive. Dropping a column that is mostly empty is often better than dropping the rows. Pairwise deletion uses whatever rows are complete for each pair of columns, which keeps more data but can produce a covariance matrix that no single dataset could have. Deletion also does not help at prediction time: a deployed model still gets rows with holes.

Mean, median and constant imputation

Univariate imputation fills each column with one number learned from that column's observed values: the mean, the median (robust to outliers), the most frequent value (for categories), or a constant you choose. In scikit-learn this is SimpleImputer; fit stores the per-column value in statistics_ and transform writes it into every missing cell. A column with no observed values at all is dropped unless keep_empty_features=True.

It is fast and keeps every row, but it puts a spike of identical values at the center of the column. The column's variance shrinks, its correlation with other columns is pulled toward zero, and any model fit on it becomes overconfident because the filled cells look like real, certain observations. A constant outside the normal range (−1 for a count, "missing" for a category) avoids the false center and lets trees isolate the filled rows, at the cost of a value that linear models treat as real.

Iterative imputation

Multivariate imputation predicts each missing cell from the other columns. scikit-learn's IterativeImputer, modeled on the MICE procedure, does it round-robin:

  1. Fill every hole with a simple imputation (the column mean by default).
  2. For each column with missing values, in order of increasing missing fraction by default: fit a regressor (default BayesianRidge) on the rows where that column is observed, using the current filled values of all other columns as features, then overwrite that column's missing cells with its predictions.
  3. After a full pass, compare with the previous pass. Stop when the largest change in any cell is below tol times the largest absolute observed value, or after max_iter passes (default 10).

It uses correlations between columns, so under MAR it can recover much of what mean imputation throws away. The estimator is still marked experimental and must be enabled with from sklearn.experimental import enable_iterative_imputer. With sample_posterior=True it draws from the regressor's predictive distribution instead of using the mean, which is how you build several imputed datasets for multiple imputation. KNNImputer is the nearest-neighbor alternative: average the column over the k most similar rows.

Missing-indicator features

If missingness itself predicts the target, keep it as a feature. For each column with holes, add a 0/1 column that is 1 where the value was missing, then impute the original column as usual. A linear model can now learn a separate offset for "was missing", and a tree can split on it directly. In scikit-learn, MissingIndicator builds these columns and add_indicator=True on any imputer appends them to its output. By default only columns that had missing values during fit get an indicator.

How gradient-boosted trees route missing values

A tree split asks "is x < t?" and needs an answer for every row. XGBoost and scikit-learn's histogram gradient boosting do not fill missing values. Instead, each split learns a default direction: the side that missing rows go to.

The split search already scans the sorted non-missing values, keeping running sums of gradients G and hessians H on each side. The missing rows' sums are whatever is left over: Gmissing = Gnode − ΣGnon-missing. XGBoost's histogram evaluator scans twice:

  • A forward scan accumulates bins from the left, so the right side is the node total minus the left. Missing rows land on the right.
  • A backward scan accumulates from the right, so the left side is the remainder and missing rows land on the left. It runs only when the node actually has missing values.

The candidate with the larger gain wins, and the split records its direction (XGBoost stores it in the top bit of the split index). On a tie the forward scan's candidate stays, so missing goes right. If no missing values were seen at a node during training, only the forward scan runs and missing values at prediction time go right. scikit-learn's HistGradientBoosting follows the same two-scan scheme (its code cites algorithm 3 of the XGBoost paper), but where no missing values were seen during training it sends them to the child with more samples.

Choosing the default direction for missing values at one split Eight rows on a number line by x, with two rows whose x is missing drawn in a separate box. The split falls between x = 3 and x = 4. Option A sends the missing box right, with gain 0.639. Option B sends it left, with gain 2.639. Option B is kept and the split stores "missing goes left". 1.0 x=1 1.2 x=2 0.8 x=3 3.0 x=4 3.2 x=5 2.8 x=9 x < 4 ? x missing y = 1.1, 0.9 circle label: target y A: forward scan, missing → right left {1, 2, 3}: G = -3.0, H = 3 right {4, 5, 9, ?, ?}: G = -11.0, H = 5 gain 0.639 B: backward scan, missing → left left {1, 2, 3, ?, ?}: G = -5.0, H = 5 right {4, 5, 9}: G = -9.0, H = 3 gain 2.639: kept

Squared loss from a prediction of 0, so each row's gradient is −y and its hessian is 1; λ = 1.0. The missing rows' targets look like the low-x group's, and the backward scan finds that.

ComponentShape/ParamsMeaningSource
SimpleImputer strategy="mean" (or median, most_frequent, constant, a callable), fill_value, add_indicator=False; learns statistics_ of shape (n_features,) Univariate fill: one value per column. _base.py
SimpleImputer._dense_fit mean and median over a masked array; a callable strategy gets each column's observed values How each statistic is computed, ignoring the missing cells. _base.py
SimpleImputer.transform drops all-missing columns unless keep_empty_features; appends indicators Writes the stored statistic into each missing cell. _base.py
MissingIndicator features="missing-only"; output (n_samples, n_features_with_missing) of booleans The 0/1 "was missing" columns. _base.py
_BaseImputer._concatenate_indicator hstack of imputed X and indicator How add_indicator=True appends the indicator columns. _base.py
IterativeImputer estimator=None (BayesianRidge), max_iter=10, tol=1e-3, initial_strategy="mean", imputation_order="ascending", sample_posterior=False Round-robin multivariate imputation; experimental. _iterative.py
IterativeImputer._impute_one_feature fit on rows where the column is observed; predict (or sample) for the rest; clip to min_value/max_value One step of the round-robin. _iterative.py
IterativeImputer.fit_transform stop when max |Δ| < tol · max |observed| The pass loop and its stopping rule. _iterative.py
EnumerateSplit<d_step> d_step = +1 (missing right) or −1 (missing left) XGBoost's scan over a feature's histogram bins. evaluate_splits.h
XGBoost scan dispatch backward scan only if SplitContainsMissingValues When the second direction is tried. evaluate_splits.h
SplitEntryContainer::Update default_left stored as bit 31 of sindex; strict > on the same feature Records the winning direction; ties keep the earlier candidate. param.h
HistGradientBoosting split search left-to-right scan, plus right-to-left if the feature has missing values scikit-learn's version of the same rule. splitting.pyx

Worked example

Eight rows, two columns

Rowageincome (k)age missingincome missing
1233800
235missing01
3missing6110
4417200
529missing01
6missing4510
7529000
838missing01

Age is missing in 2 rows and income in 3. Listwise deletion keeps only 3 of 8 rows. Mean imputation fills age with 36.33 and income with 61.20; median imputation uses 36.5 and 61; constant imputation uses 0 unless you pass fill_value. These are the statistics_ that SimpleImputer 1.9.1 learns on this table, and with add_indicator=True its output has four columns: the two filled ones, then the two indicator columns above.

The same column under three mechanisms

Simulate 4,000 rows: x is standard normal and y = 50 + 10(0.8x + 0.6ε), so y has mean 50, standard deviation 10 and correlation 0.8 with x. Hide about 40% of the y values three ways. MCAR: each with probability 0.4. MAR: with probability 0.7 when x > 0, otherwise 0.1. MNAR: 0.7 when y > 50, otherwise 0.1. Then estimate the mean of y by complete cases and by regression imputation (fit y on x on the complete rows, predict the missing ones), and measure what mean imputation does to y's spread and its correlation with x.

MechanismMissingTrue meanComplete-case meanRegression-imputed meanStd after mean fill (true 9.86)Corr after mean fill (true 0.80)
MCAR41%49.8749.8249.847.630.61
MAR40%49.8746.7949.907.450.58
MNAR40%49.8746.0448.387.200.59

Under MCAR both estimates are within 0.05 of the truth. Under MAR the complete cases miss mostly high-x rows, so their mean is 3.09 too low; regression on x repairs it to within 0.02. Under MNAR regression on x helps only partly and is still 1.49 too low, because the rows that went missing are high in y even for their x. In every case filling with the mean shrinks the standard deviation by about a quarter and drops the correlation from 0.80 to around 0.6.

Iterative imputation, round by round

Three columns driven by one hidden factor z: a = z + noise, b = 2z + noise, c = −z + noise (noise standard deviation 0.3), 60 rows, each cell hidden independently with probability 0.25, which leaves 52 of 180 cells missing. Start from column means, then regress each column on the other two with ordinary least squares, in ascending order of missing fraction (columns 0, 1, 2). The stopping threshold is 10−3 × max |observed| = 0.0052.

PassLargest change in any cellBelow threshold?
13.5963no
20.8055no
30.1287no
40.0599no
50.0329no
60.0181no
70.0099no
80.0055no
90.0030yes, stop

It stops after 9 passes. Against the true hidden values, the root-mean-square error of the filled cells is 1.376 for mean imputation and 0.542 for iterative imputation. IterativeImputer(estimator=LinearRegression(), max_iter=10, tol=1e-3) from scikit-learn 1.9.1 gives the same filled values (largest difference 1.7 × 10−15) and the same n_iter_ of 9; the default BayesianRidge would shrink the coefficients slightly.

When missingness is the signal

Let y = 2x + noise (standard deviation 0.5), and hide x with probability 0.6 when y > 1, otherwise 0.1. Fit a linear model on 400 training rows and score it on 4,000 fresh rows from the same process. Filling x with the training mean (-0.284) gives a test mean squared error of 1.990. Adding a missing indicator lowers it to 1.229: the model learns an offset of 2.24 for "x was missing", because missing rows tend to have high y. The slope on x is 2.00 either way.

One split, missing values routed natively

Take the eight rows in the diagram above: x = 1, 2, 3, 4, 5, 9, missing, missing, targets y = 1.0, 1.2, 0.8, 3.0, 3.2, 2.8, 1.1, 0.9. Start from a prediction of 0 with squared loss, so each gradient is −y and each hessian is 1, and use λ = 1. The node's own score is G²/(H + λ) = 14²/9 = 21.778, and the missing rows carry G = -2.0, H = 2.

Split betweenGain, missing → rightGain, missing → left
x = 1.5-0.1530.639
x = 2.5-0.2730.958
x = 3.50.6392.639
x = 4.5-1.778-0.635
x = 7.0-1.911-2.178

The best split is at 3.5 with missing values sent left, gain 2.639, left leaf −G/(H + λ) = 0.833 and right leaf 2.250. XGBoost 3.2.0 trained with max_depth=1, eta=1, base_score=0, min_child_weight=0 dumps exactly this tree: [f0<4] missing=1, gain=2.63888741, leaf=0.833333373, leaf=2.25 (it writes the threshold as a histogram cut, 4, rather than the midpoint).

Now fill x with its mean first. The mean of the observed x is 4.00, so both missing rows become x = 4 and land with the high group. The best split is still between 3 and 4, but the two low-target rows are stuck on the wrong side: gain 0.639 instead of 2.639. The native rule let the data decide where "unknown" belongs.

Implementation

Put the imputer inside a Pipeline so its statistics are learned from the training folds only (see data leakage), and add indicators when missingness might matter:

import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.experimental import enable_iterative_imputer  # noqa: F401
from sklearn.impute import IterativeImputer
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier

simple = make_pipeline(
    SimpleImputer(strategy="median", add_indicator=True),
    LogisticRegression(),
)
iterative = make_pipeline(
    IterativeImputer(max_iter=10, tol=1e-3, random_state=0),
    LogisticRegression(),
)
native = HistGradientBoostingClassifier()   # NaN allowed, no imputer
# XGBoost: xgboost.XGBClassifier(missing=np.nan) learns a default direction per split

The split scan with a default direction, from scratch (the same rule as the worked example):

def score(g, h, lam):
    return g * g / (h + lam)

def best_split(xs, grads, hess, lam=1.0):
    seen = sorted((x, g, h) for x, g, h in zip(xs, grads, hess) if x is not None)
    G, H = sum(grads), sum(hess)
    Gm = G - sum(g for _, g, _ in seen)          # missing rows' sums
    Hm = H - sum(h for _, _, h in seen)
    parent, best = score(G, H, lam), None
    for i in range(1, len(seen)):
        if seen[i][0] == seen[i - 1][0]:
            continue
        gl = sum(g for _, g, _ in seen[:i])
        hl = sum(h for _, _, h in seen[:i])
        sides = [("right", gl, hl)]               # forward scan
        if Hm > 0:
            sides.append(("left", gl + Gm, hl + Hm))  # backward scan
        for go, lg, lh in sides:
            gain = score(lg, lh, lam) + score(G - lg, H - lh, lam) - parent
            if best is None or gain > best[0]:
                best = (gain, (seen[i - 1][0] + seen[i][0]) / 2, go)
    return best

This visits candidates in a slightly different order from XGBoost (which finishes the whole forward scan before the backward one), so on an exact tie between two different thresholds it can pick another one; the tested helper missing_split_scan follows XGBoost's order.

Tradeoffs

  • Deletion or imputation? Deletion is defensible under MCAR with few missing rows. Otherwise it wastes data and, under MAR, biases the result. It also gives no answer for rows with holes at prediction time.
  • Mean or median? The median is robust to skew and outliers; the mean is right for roughly symmetric columns. Both shrink variance and correlations, which matters most for linear models and for any analysis that reports uncertainty.
  • Simple or iterative? Iterative imputation uses the other columns and does much better when columns are correlated, at the cost of one regression per column per pass, a dependence on the chosen regressor, and an estimator that scikit-learn still marks experimental.
  • Single or multiple imputation? One filled dataset treats guesses as facts, so standard errors come out too small. Multiple imputation (several draws with sample_posterior=True, results pooled) is the standard fix when you need valid uncertainty, not just a good prediction.
  • Indicators. Cheap and often helpful for prediction, but they let the model lean on how data was collected. If the collection process changes (a form field becomes mandatory), the indicator's meaning shifts under the model.
  • Native handling. Gradient-boosted trees learn a direction per split, which is usually better than a blind fill and needs no preprocessing. The direction is fixed per split, so missing rows can never be split among themselves, and a feature with no missing values in training gets an arbitrary rule (right in XGBoost, the larger child in scikit-learn).
  • No method fixes MNAR from the data alone. Model the mechanism explicitly, collect the reason for missingness, or run a sensitivity analysis over plausible shifts.

Check yourself

Under which mechanism is complete-case analysis unbiased, and why?

MCAR. Missingness does not depend on anything, so the complete rows are a random sample of all rows: fewer of them, but not a skewed set.

Why does mean imputation lower a column's correlation with other columns?

Every filled cell sits at the same value no matter what the other columns say. Those cells add points that ignore the relationship, which pulls the correlation toward zero; in the worked example 0.80 falls to about 0.6.

An XGBoost model trained on data with no missing values sees a NaN at prediction time. Where does it go?

Right, at every split on that feature: only the forward scan ran during training, and that scan puts missing values on the right. scikit-learn's histogram boosting would send it to the child that had more training samples.

Why does the imputer belong inside the cross-validation pipeline?

Its statistics (a mean, or a whole set of regressions for iterative imputation) are learned from data. Fitting them on all rows lets validation rows shape the values filled into training rows, which is leakage.

Connections

Read first

Read next

No page builds on this one yet.

References