learn-feature-engineering-with-phoebe / Practitioner session 7 of 10
Learn Feature Engineering with Phoebe · Practitioner track · Session 7 of 10

Missing data as signal

Most teams treat a blank cell as damage to be repaired: pick a strategy, fill it in, move on. That habit quietly throws away one of the most useful features in the table. A blank is a fact about the row - somebody did not answer, a system did not write, an order went through without incident - and sometimes that fact predicts the target better than any value you could have imputed. Tonight: the three reasons a cell is empty, the toolkit that handles each one, the one-line move that costs nothing and often wins, and the leakage rail that makes imputation a learned parameter rather than a data-cleaning chore.

🟠 Practitioner track DS · analysts · ML engineers Python · live workbench on key pages 45 min
0-3 · Welcome 3-18 · Why it is missing 18-42 · Build-along: impute + indicate 42-45 · Q&A
Part 1 · covers Google MLCC data scrubbing + the framing sklearn's imputation guide assumes

The question before any imputation 8 min live

Before you type SimpleImputer, answer a question nobody asks in the sprint: why is this cell empty? Three columns can each be 30% blank and look identical on a data-quality dashboard, and the correct handling for each is different. Get the cause wrong and you do not just impute badly - you delete a real feature and skew the distribution you are training on.

One dashboard number, 30% blank, three different causes MISSING COMPLETELY AT RANDOM Cause: the form timed out on a bad connection Blank tells you: nothing Handling median impute, indicator optional, shape holds up MISSING AT RANDOM, GIVEN OTHER COLUMNS Cause: the mobile app never showed the field Blank tells you: see device Handling impute FROM those columns IterativeImputer, KNNImputer MISSING NOT AT RANDOM Cause: high earners skip income; support_note is empty when nothing broke Handling KEEP the blank as a feature mean-fill erases the signal Only the cause tells you which handling is honest. The blank rate alone tells you nothing.
🔍 Click to zoom - the same 30% gap, three causes, three different correct answers
LiveThe three mechanisms, in plain language5 min▶

Strip away the statistics vocabulary and there are three stories a blank cell can be telling you:

  • Missing completely at random. The blankness has nothing to do with the row. A connection dropped, a batch job half-failed, a device rebooted mid-form. The rows with gaps look like the rows without them, so filling the gap with a central value distorts very little.
  • Missing at random, given other columns. The blankness is not random, but it is fully explained by columns you already have. Lumen's mobile checkout never rendered the optional field, so device = "mobile" predicts the gap perfectly. You cannot ignore it, but you can model it from the columns that explain it.
  • Missing NOT at random. The blankness depends on the missing value itself, or on the outcome. income is left blank disproportionately by high earners. support_note is empty precisely because the order went perfectly - which is exactly the kind of order you may be trying to predict. Here the missingness is the information.

You cannot prove which mechanism you are in from the data alone. You get there by asking the person who owns the form, the app screen, or the upstream job - which is why this session is as much an interview technique as a coding one.

LiveWhat mean-imputing a not-at-random column actually costs3 min▶

Two separate damages, and teams usually only notice the second one much later:

  • You destroy real signal. If blank means "this order was uneventful", then every blank row carried a usable fact about the target. Replace them all with the column mean and every one of those rows now says the same neutral thing. The feature that was there is gone, and no amount of model tuning brings it back.
  • You bias the distribution. Filling 30% of a column with its own mean piles a third of the mass onto a single point. Variance shrinks, correlations get diluted toward zero, and any downstream scaler or binner you fit afterwards is fitting a shape the real world never had.

Both damages are silent. Nothing raises an error, the pipeline runs, and the score is merely a little worse than it should have been - the hardest kind of problem to notice.

Part 2 · covers sklearn imputation classes + OneHotEncoder NaN handling

The toolkit, and the move that costs nothing 9 min live

sklearn gives you three imputers and one small utility, and the utility is the one that wins arguments. Read the table for what each imputer decides, then look hard at the last row: the missingness indicator does not fill anything at all, it simply lets the model see that the cell was blank.

ToolWhat it doesUse it when
SimpleImputerFills with mean, median, most_frequent or a constant you supplyThe default. Median for anything skewed, most_frequent or constant for categoricals
IterativeImputerModels each column with gaps as a function of the other columns, round-robinMissing-at-random cases where other columns genuinely explain the gap. Still an experimental feature - you must opt in explicitly
KNNImputerFills from the values of the nearest complete rowsRows have close neighbours and the data is small enough to afford the distance computation
MissingIndicator / add_indicator=TrueAdds a boolean column saying "this was blank", alongside the filled valueAlmost always. It is one argument, it costs one column, and it is the only way the model can learn from absence directly
LiveMedian over mean, and a sentinel for categoricals4 min▶

Two defaults worth making automatic:

  • Median, not mean, for money columns. Lumen's prior_30d_spend is right-skewed like every spend column you will ever meet - a long tail of big spenders drags the mean well above the typical customer. Impute with the mean and every blank row is quietly labelled a slightly-above-average spender. The median puts them where most customers actually are.
  • A constant plus an explicit sentinel for categoricals. SimpleImputer(strategy="constant", fill_value="unknown") makes "we did not have this" a named level rather than a silent merge into whatever happened to be most frequent. The most_frequent strategy has its uses, but it hides the gap inside a real category, and you can never separate them again downstream.
Mean-fill on a skewed money column, and the one-argument fix ✗ strategy="mean" median mean 30% of rows now sit above the typical customer ✓ strategy="median", add_indicator=True median was_missing 0 / 1 blanks land where most customers are Filling 30% of a column with its own mean piles a third of the mass onto a single point. One argument, add_indicator=True, is the whole missingness-as-signal lesson in code.
🔍 Click to zoom - the imputed value keeps the row, the indicator keeps the fact
Carry-over from the encoding session For OneHotEncoder, np.nan and None are treated as separate categories. That means NaN can simply be a category when blankness is meaningful - no imputation step at all, just a column in the one-hot output that says "this was missing". For a not-at-random categorical, that is often the cleanest thing you can do.
LiveThe build-along pipeline5 min▶

Numerics get a median plus an indicator; categoricals get a named sentinel and then one-hot encoding that tolerates unseen levels at prediction time. Note that nothing is fitted outside the Pipeline:

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression

numeric = Pipeline([
    ("impute", SimpleImputer(strategy="median", add_indicator=True)),
    ("scale", StandardScaler()),
])

categorical = Pipeline([
    ("impute", SimpleImputer(strategy="constant", fill_value="unknown")),
    ("encode", OneHotEncoder(handle_unknown="ignore")),
])

prep = ColumnTransformer([
    ("num", numeric, ["prior_30d_spend", "pages_viewed"]),
    ("cat", categorical, ["channel", "device", "product_category"]),
])

pipe = Pipeline([
    ("prep", prep),
    ("model", LogisticRegression(max_iter=1000)),
])

pipe.fit(X_train, y_train)   # every median learned from train rows only

One argument, add_indicator=True, is the whole missingness-as-signal lesson in code. The imputed value keeps the row usable; the indicator keeps the fact that it was blank.

Self-studyIterativeImputer, and why it comes with a warning label3 min read▶

IterativeImputer models each column with gaps from the other columns and cycles until the estimates settle. It is genuinely useful in the missing-at-random case, and it is still an experimental feature in sklearn - you must import the experimental enabler before the class is available at all, and the API can change between releases.

Two practical cautions before you reach for it: it is far more expensive than a median, and it makes the imputed values a function of the whole feature set, which means a single upstream column changing shape now moves values in a column you were not touching. Start simple, and let a measured gain justify the complexity - not the other way round.

Part 3 · covers sklearn "Common pitfalls" ch.12, applied to imputation

An imputer is a model, not a cleaning step ★ 9 min · everyone runs it

Here is the sentence that puts this session on the leakage rail: imputation statistics are learned parameters. A median is not a fact about your column, it is a number estimated from a specific set of rows. Compute it on the full dataset before splitting and you have committed preprocessing leakage - family 2 from b5, full stop, no nuance, no "but it is only a median".

What goes wrong. The global median is computed from rows that include your test set. The test rows now have their gaps filled with a number that was informed by the test rows themselves. Your held-out score is measuring something production will never reproduce.

Why "it is only a median" is not a defence. sklearn's rule is written without an exemption: "Never include test data when using the fit and fit_transform methods." A median is a fit. A most-frequent category is a fit. A KNN imputer is unambiguously a fit.

The fix is structural, not disciplinary. Put the imputer inside the Pipeline, so each cross-validation fold learns its own fill values from its own training rows. sklearn states the purpose plainly: pipelines "help avoid leaking statistics from your test data into the trained model in cross-validation, by ensuring that the same samples are used to train the transformers and predictors."

The habit to build. If a preprocessing step has a number in it that came from data, it belongs inside the Pipeline. Imputation, scaling, encoding, selection - all of them. The Pipeline is not tidiness, it is the thing that makes the wrong version hard to type.

Real world

The month the column went 90% blank. A team imputed a key numeric feature with the global median, cross-validated cleanly, and shipped. In their historical data the column was about 8% missing every month, so the fill value touched almost nothing and nobody thought about it again. Then an upstream form change made the field optional, the column arrived 90% missing, and nearly every prediction collapsed toward the same value - because for nine rows in ten the model's strongest feature had become a constant. The model had never once been evaluated under the missingness rate it actually faced. Nothing errored, no alert fired, and the first signal was a business metric drifting a week later.

The month the column went 90% blank 100% 50% 0% MISSING RATE → 8% 8% 8% 8% 8% form field made optional 90% month 1 month 2 month 3 month 4 month 5 after the change the test: re-score held out at 50% and at 90% blank a cliff means a dependency, not a feature For nine rows in ten the model's strongest feature had become a constant, and nothing errored.
🔍 Click to zoom - the missingness rate moves before your metrics do
The test that would have caught it Before shipping, re-score your held-out set with the column artificially blanked at 50% and at 90%. If the score falls off a cliff, you have not built a feature, you have built a dependency - and you now know exactly which upstream change breaks your model.
Part 4 · self-study · connects to leader session a3

Missingness moves read after class

Everything above assumed the missingness rate is a property of the column. It is not. It is a property of the systems upstream of you, and those systems change without telling you.

Self-studyThree ways a stable column starts going blank4 min read▶
  • A column gets deprecated upstream. The producing service stops writing it, or writes it only for a subset of traffic. The column still exists, the schema still validates, and the values simply stop arriving.
  • A form field is made optional. The most common cause and the most invisible one, because it is a product decision made in a different team's sprint, for perfectly good conversion reasons. Overnight, a column that was 2% missing becomes 60% missing.
  • The mechanism itself changes. Worse than a rate change: a column that was missing completely at random becomes missing not at random, or the reverse. The correct handling flips, and your pipeline carries on doing the old thing.
Self-studyMonitor the missingness rate as a first-class signal3 min read▶

The missingness rate of every feature is worth monitoring in its own right, on the same dashboard as your model's score. It is cheap to compute, it needs no labels, and it moves before your metrics do - a rate that jumps from 8% to 90% is visible on day one, while the business impact shows up weeks later.

Two thresholds worth setting per feature: an absolute one ("alert if more than 40% blank") and a relative one ("alert if the rate doubles versus the training window"). The relative one catches the slow drift the absolute one sleeps through.

This is where the practitioner track meets the leader track: in a3, missingness is framed as part of the feature budget - a data-quality signal that decides which features you can responsibly build at all. If a column cannot be relied on to arrive, its predictive power on historical data is a promise you cannot keep.

Homework

Try it yourself - this week ◐ 25-35 min total

Source material

Official sources covered

This session works through sklearn's imputation classes and the part of the common-pitfalls chapter that governs them, plus the categorical NaN behaviour carried over from b3 and Google's data-scrubbing framing. Certificates and graded exercises stay with the official providers (Kaggle Learn, Google MLCC). This page covers:

✓sklearn imputation - SimpleImputer, IterativeImputer, KNNImputer, MissingIndicatorParts 2-3 · strategies, add_indicator, the experimental flag
✓sklearn "Common pitfalls" - preprocessing leakage applied to imputationPart 3 · imputers inside the Pipeline, one fit per fold
◐sklearn OneHotEncoder - np.nan and None as separate categoriesPart 2 · the encoding session's carry-over: NaN as a category
◐Google MLCC - data scrubbing and the cost of unclean featuresPart 1 framing · the leader-track view of missingness is a3
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · support_note is empty on 70% of Lumen's orders, because nothing went wrong on those orders. Which mechanism is that, and what follows?

The gap depends on what happened to the order, which is close to what you are predicting. Fill it with a central value and every one of those rows now says the same neutral thing. Keep the blank, either as an indicator column or as its own one-hot category.

2 · What does add_indicator=True actually buy you?

It fills nothing and estimates nothing. It preserves the one piece of information that every imputation strategy otherwise destroys - that the cell was empty. One argument, one extra column, and the not-at-random signal survives.

3 · Why must the imputer live inside the Pipeline rather than being applied to the dataframe first?

A median is a fit. sklearn's rule has no exemption for it: never include test data in fit or fit_transform. Pipelines exist precisely to "avoid leaking statistics from your test data into the trained model in cross-validation."

Practitioner session 7 cheat sheet · pin this

Ask why firstThree columns can each be 30% blank with three different causes and three different correct handlings. The blank rate alone tells you nothing.
The three mechanismsCompletely at random (unrelated to anything) · at random given other columns (explained by what you have) · NOT at random (the blank depends on the missing value or the outcome).
The not-at-random costMean-imputing destroys real signal AND biases the distribution - a third of the mass piled on one point. Both damages are silent.
The imputersSimpleImputer (mean / median / most_frequent / constant) · IterativeImputer (experimental, opt in explicitly) · KNNImputer.
Median for moneyprior_30d_spend is right-skewed; the mean labels every blank row an above-average spender. Median puts them where customers actually are.
The free winadd_indicator=True / MissingIndicator: a boolean "this was blank" column. Costs one column, lets the model learn from absence directly.
NaN as a categoryFor OneHotEncoder, np.nan and None are separate categories - so NaN can simply BE a category when blankness is meaningful.
The leakage railImputation statistics are learned parameters. A median from the full dataset is preprocessing leakage (b5, family 2). Imputers go inside the Pipeline.