Why Is This Column Empty? Diagnosing MCAR, MAR, and MNAR Before You Impute
Your dataset reports 12% missing values in annual_income and you must decide what to do before training. The wrong first move is reaching for an imputer. The right first move is asking a diagnostic question: why is the value missing? The answer changes your strategy and how much you can trust the final model, and interviewers know it.
Statisticians name three mechanisms, and every real dataset is a mixture of at least two of them.
| Mechanism | What drives the missingness | Example | Imputation-safe? |
|---|---|---|---|
| MCAR (completely at random) | Nothing — a coin flip | sensor drops readings randomly | Yes; deletion is unbiased but wasteful |
| MAR (missing at random) | Other observed columns | income missing mostly where employment_status = student | Yes, if the conditioning columns are in your model |
| MNAR (missing not at random) | The missing value itself | high earners refuse to report income | No — the gap is signal about the target |
The distinction matters because each mechanism implies a different bias. Under MCAR, dropping rows loses nothing but statistical power. Under MAR, filling the whole column with its global mean is wrong because the missing subset has a different mean than the full column — you must impute conditionally. Under MNAR, the act of being missing is a feature: the customers who skip the income field are systematically different, and no imputer can reconstruct what they refused to tell you — but a binary indicator column can exploit the refusal.
You cannot prove the mechanism from data alone — that requires domain knowledge about the collection process — but you can gather strong evidence. The cheapest test is to compare other columns between rows where annual_income is missing and rows where it is present.
import pandas as pd
df = pd.read_csv('applications.csv')
mask = df['annual_income'].isna()
print(df.groupby('employment_status')['annual_income']
.apply(lambda s: s.isna().mean()))
# employment_status
# employed 0.04
# student 0.31
# retired 0.28
A 4% gap rate among employed rows versus 31% among students is a red flag for MAR: missingness depends on an observed column. If instead the missing rows look identical on every observed column yet their reported incomes (when present) cluster at the extremes, you are facing MNAR, and you should say so in your write-up rather than silently imputing.
Two practical habits follow from the diagnosis. First, build missingness indicators for any column whose gap rate exceeds a few percent and whose mechanism is not clearly MCAR:
df['income_missing'] = df['annual_income'].isna().astype(int)
Second, remember that most scikit-learn estimators refuse NaN outright (trees excepted), so the mechanism diagnosis happens before you write the preprocessing code — the mechanism decides whether you impute conditionally, add indicators, or both. The next chapter compares the actual imputation tools and the bias-versus-sample-size trade each one makes.
Having established that the gap itself can carry information, you already own the statistical vocabulary to defend the group comparison above: in Hypothesis Tests and Confidence Intervals you learned to ask whether a difference between two groups is real or noise — the same question, applied here to the missing and non-missing subsets.
Delete, Fill, or Model? The Bias-Sample-Size Trade in Imputation
Once you know roughly why values are missing, you pick a strategy — and every strategy pays in one currency and charges you in another. Deleting rows buys statistical purity and charges sample size. Mean imputation buys sample size and charges you understated variance and, under MAR, bias. Model-based imputation buys both and charges you complexity plus the risk of smuggling target information into features.
| Strategy | What it assumes | What you lose | sklearn tool |
|---|---|---|---|
| Listwise deletion | MCAR | rows; unbiased only under MCAR | df.dropna() |
| Constant or median fill | missing resembles typical | variance; the distribution grows a spike at the fill value | SimpleImputer(strategy='median') |
| Conditional fill | MAR given observed columns | implementation complexity | group-wise impute with groupby |
| Model-based | relationships between columns are stable | runtime; leakage risk | IterativeImputer, KNNImputer |
The median-versus-mean choice is not about taste. Under skewed distributions the mean is dragged toward the tail, so filling every gap with the mean systematically misrepresents the majority: in a column of incomes with a few millionaires, the mean describes nobody. The median is robust to that. And regardless of which statistic you pick, fit the imputer on the training fold only — a median computed over train plus test has already seen the validation distribution.
from sklearn.impute import SimpleImputer, KNNImputer, IterativeImputer
simple = SimpleImputer(strategy='median') # fast, stable
knn = KNNImputer(n_neighbors=5) # borrows from similar rows
iter_ = IterativeImputer(max_iter=10, random_state=0) # chained regressions
X_train_imp = iter_.fit_transform(X_train) # learns column relationships
X_test_imp = iter_.transform(X_test) # reuses them, never refits
IterativeImputer fits one regression per feature on the other features, fills the gaps, and cycles until convergence. It performs well under MAR — but notice what it needs: every other column it leans on must be available at both training and serving time. If one of those columns is itself missing in production rows, you have built a scorer that cannot score.
KNNImputer deserves its own caveat: it averages the k nearest observed rows, which means it needs scaled inputs to define nearness, and it inherits the curse of dimensionality — with 60 features, nearest neighbors stop being neighbors. Use it when the feature count is modest and row-level similarity is genuinely meaningful.
A decision procedure that survives code review:
- Under 1–2% missing and plausibly MCAR: drop the rows and note the loss in your summary.
- Under MAR with an obvious conditioning column: impute within groups, and keep the indicator.
- Heavy tails or many columns missing: median plus indicator, then check whether the model uses the indicator — it often does, and that is the MNAR signal from the previous chapter surviving into the features.
- Rich correlations and a modest feature count: try
IterativeImputerinside cross-validation and keep it only if fold scores improve, not the in-sample score.
Whichever branch you take, the imputer belongs inside the cross-validation loop. An imputer fitted before the split contaminates every fold with statistics from rows it should never have seen — the exact failure mode that gets a full treatment in Data Leakage Prevention.
IQR Fences and IsolationForest: Catching the Points That Dominate the Loss
A single row with transaction_amount = 9_500_000 in a dataset of $30 purchases will not merely hurt your model — it will define it. Squared-error losses square that error too; gradient-based optimizers take their largest steps on exactly those rows; and any scaler with finite variance lets the outlier dictate the range everything else gets squashed into. Outlier handling is not cosmetics, it is loss-function hygiene.
Two detector families cover most interview scenarios: geometric rules and isolation-based tree ensembles.
IQR fences come from the five-number summary and assume nothing about the distribution. With Q1 and Q3 as the first and third quartiles:
import pandas as pd
q1, q3 = df['transaction_amount'].quantile([0.25, 0.75])
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr # 'moderate' outliers
lo2, hi2 = q1 - 3 * iqr, q3 + 3 * iqr # 'extreme' outliers
flagged = ~df['transaction_amount'].between(lo2, hi2)
The 1.5x and 3x multipliers are Tukey's conventions, not laws — widen them for skewed data, or apply the fences to log-transformed values, which is often the better fix anyway. IQR is univariate, deterministic, and explainable to a compliance reviewer, which keeps it the default for reporting.
IsolationForest handles the multivariate case: an outlier is easy to isolate, so random splits isolate anomalies in few steps, and the average split depth over the ensemble becomes an anomaly score.
from sklearn.ensemble import IsolationForest
iso = IsolationForest(n_estimators=300, contamination=0.02, random_state=0)
df['anomaly'] = iso.fit_predict(df[['amount', 'duration', 'device_count']])
# -1 marks outliers, 1 marks inliers
contamination sets the expected outlier fraction and therefore the decision threshold — set it from a business prior (about 2% of transactions are fraud) rather than tuning it to flatter a score. IsolationForest also catches the point that is individually normal on every feature but absurd in combination: a $900 purchase on a card created ten minutes ago.
Detection done, you choose a response, and the response depends on whether the extreme value is an error or a real event:
| Verdict | Action | Cost |
|---|---|---|
| Data-entry error (negative age, typo'd millions) | fix it or treat it as missing | none, if caught early |
| Rare but real (fraud, whale customer) | cap (winsorize) at a chosen percentile | compresses real signal near the cap |
| Rare and unrepresentative | drop the row | loses whatever made it extreme |
| Persistent heavy tails across the column | RobustScaler or log1p instead of removal |
keeps every row |
Capping in practice:
hi = df['transaction_amount'].quantile(0.99)
df['transaction_amount'] = df['transaction_amount'].clip(upper=hi)
Note the fitting order one more time, because it is the theme of this entire phase: the quantiles, the fences, and the IsolationForest are all learned statistics. Compute them on the full dataset and your validation fold has leaked into preprocessing. Fit on the training fold, apply the learned bounds to validation, and the recipe holds. The next topic, Categorical Encoding, turns raw category columns into numeric features — and the same fit-on-train-only discipline applies to every encoder you will meet there.
Quiz
1. You find that `annual_income` is missing for 31% of rows where employment_status = 'student' but only 4% of rows where employment_status = 'employed'. Which missingness mechanism does this best indicate?
2. After median imputation, what typically happens to the standard deviation of the imputed column compared to the originally observed values?
3. A teammate computes imputation medians on the full dataset before calling train_test_split. What is the consequence?
4. Which outlier detector is designed to catch a point that is unremarkable on every individual feature but extreme in their combination?
Flashcards
Question
What distinguishes MAR from MNAR, and why does the difference change your strategy?
Click to reveal answer
Answer
MAR means missingness is explained by other observed columns (income missing mostly for students, an observed status); MNAR means missingness depends on the missing value itself (high earners refusing to report). MAR can be handled with conditional imputation when the conditioning columns are modeled; MNAR cannot be imputed away — the gap is signal, usually exploited with a missingness indicator.
Question
Why does median imputation understate a column's variance, and what do you do about it?
Click to reveal answer
Answer
Every imputed row receives the same fill value, so the spread those rows would have contributed disappears and the column's variance shrinks, making models overconfident. Mitigate by adding a binary missingness indicator so the model can separate 'typical value' from 'absent value', and by validating whether the indicator carries target signal.
Question
When is listwise deletion (dropna) defensible?
Click to reveal answer
Answer
When the missing fraction is small (roughly under 1–2%) and the mechanism is plausibly MCAR, so dropping rows loses only statistical power and introduces no bias. It is wrong under MAR/MNAR (bias) or for large gap rates (sample-size loss), and you should always report how many rows were dropped.
Question
State Tukey's IQR fence rule and what the two multiplier conventions mean.
Click to reveal answer
Answer
Fences are Q1 − 1.5×IQR and Q3 + 1.5×IQR for moderate outliers, and Q1 − 3×IQR / Q3 + 3×IQR for extreme outliers. The rule assumes nothing about the distribution, works per column (univariate), and is deterministic — which makes it explainable to non-engineers. Widen the multiplier for skewed data or apply it to log-transformed values.
Question
How does IsolationForest detect outliers, and what does the contamination parameter do?
Click to reveal answer
Answer
Random splits isolate anomalies in fewer steps than normal points; the ensemble averages split depths into an anomaly score, and low-score rows are flagged. contamination sets the expected outlier fraction, which fixes the decision threshold — set it from a business prior rather than tuning it to flatter a metric. It naturally catches multivariate anomalies that per-column rules miss.
Revision Notes
Key Takeaways
- 1. Classify the mechanism (MCAR/MAR/MNAR) before choosing an imputer; the mechanism decides whether the gap is noise, recoverable, or signal
- 2. Every imputation is a bias-versus-sample-size trade: deletion buys purity, filling buys rows, model-based methods buy both at the cost of complexity and leakage risk
- 3. Median imputation collapses spread; pair it with a missingness indicator so the model can treat absence as its own feature
- 4. IQR fences handle univariate outliers explainably; IsolationForest catches multivariate anomalies that are normal on every single column
- 5. Cap, drop, or transform outliers only after deciding whether the extreme value is an error or real signal — and fit every detector on training folds only
Interview Tips
- • "A column has 30% missing values — what do you do?" — open with the mechanism question (MCAR/MAR/MNAR), then the trade: drop under MCAR, impute conditionally under MAR, keep an indicator under MNAR; the structured answer is what gets marked, not the specific tool
- • "Why not just fill with the mean?" — skew drags the mean toward the tail so it describes nobody, and filling shrinks the column's variance, making the model overconfident; mention median + indicator as the robust default
- • "How do you detect outliers?" — name the two families: geometric rules (IQR fences, z-scores, MAD — univariate, explainable) and model-based isolation (IsolationForest — multivariate, catches combination anomalies); add that contamination should come from a business prior
- • "Where does outlier handling go wrong in a pipeline?" — the fences, quantiles, and anomaly detector are learned statistics; computing them before the split leaks validation data into preprocessing, so they must be fit inside the training fold
Cheat Sheet
Missing Values and Outliers
Diagnose before you impute:
| Mechanism | Drives missingness | Response |
|---|---|---|
| MCAR | nothing (coin flip) | drop small %, or fill; unbiased either way |
| MAR | observed columns | conditional imputation + indicator |
| MNAR | the missing value itself | keep a is_missing indicator; the gap is signal |
Imputation choices:
| Tool | Use when | Watch out |
|---|---|---|
SimpleImputer(strategy='median') |
skewed numerics, many gaps | variance shrinks; add indicator |
KNNImputer |
modest feature count, meaningful row similarity | needs scaling; degrades in high dimensions |
IterativeImputer |
MAR with rich correlations | every predictor column must exist at serving time |
Outlier detection:
q1, q3 = df['amount'].quantile([0.25, 0.75]); iqr = q3 - q1
lo, hi = q1 - 1.5*iqr, q3 + 1.5*iqr # Tukey fences
iso = IsolationForest(contamination=0.02, random_state=0)
hi99 = df['amount'].quantile(0.99)
df['amount'] = df['amount'].clip(upper=hi99) # winsorize
Golden rule: medians, fences, quantiles, and IsolationForest are learned statistics — fit them on the training fold only, then apply to validation/test.