Data leakage
Article.
The single most expensive bug in ML, and the only one that makes your metrics better. It does not crash, it does not warn, and it survives review because the review is looking for things that went wrong.
If your model is unexpectedly good, assume leakage until you have proven otherwise. A senior engineer’s reflex on hearing “we got 0.99 AUC” is suspicion, not celebration — and being able to say why, mechanically, is the interview answer.
The mechanism: the database has already moved on
Almost every leak has one root cause. Training data is assembled by looking backwards through a database that has since been updated. Production runs forwards through a database that has not.
TRAINING SERVING
today, querying about March March, live
┌──────────────────────┐ ┌──────────────────┐
│ status = 'churned' │ │ status = 'active'│
│ closed_at = 2026-04 │ │ closed_at = NULL │
│ tickets_7d = 4 │ │ tickets_7d = 0 │
└──────────────────────┘ └──────────────────┘
every column reflects every column
what happened AFTER reflects now
the prediction momentThe users table has one row per user holding the current value. Querying it
to build features “as of March” gives you March’s identifier attached to
August’s facts. Nothing in the code looks wrong; the join is correct; the leak
is in the shape of the storage.
So the question that catches nearly everything is a single one, asked per feature:
At the moment I need this prediction, is this value populated — and populated with the same distribution as in training?
The second clause matters as much as the first. A feature that exists at
inference but is usually NULL there and usually filled in training is leaking
just as surely as one that does not exist.
The two families
Target leakage — a feature carries information about the label that will not be available at prediction time. Produces a model that is useless in production.
Train-test contamination — evaluation data influenced training. Produces a model that may be fine, but whose reported metric is a lie.
Both inflate the offline number. Only the first breaks the model, which is why they need different responses.
Target leakage
The feature is a consequence of the label
Predicting churn with account_closed_date is obviously wrong. The versions
that ship are subtler:
| Predicting | Leaky feature | Why |
|---|---|---|
| Loan default | collections_assigned |
set only after default |
| Fraud | transaction_reversed |
reversed because of the fraud |
| Churn | tickets_last_7d |
a symptom of leaving |
| Disease | medication_prescribed |
prescribed after diagnosis |
| Conversion | discount_code_used |
exists only if converted |
Each is a consequence of the outcome wearing the costume of a predictor.
Timing leakage
The feature exists at prediction time, but the value you computed used future rows.
# WRONG - the mean spans the whole dataset, including rows after this one
df["user_avg_order"] = df.groupby("user_id")["amount"].transform("mean")
# RIGHT - only strictly earlier rows contribute
df = df.sort_values("timestamp")
df["user_avg_order"] = (
df.groupby("user_id")["amount"]
.transform(lambda s: s.shift(1).expanding().mean())
)shift(1) before the window is the pattern. Without it, each row’s feature
includes its own outcome, and the effect is strongest exactly where the group is
small — so it looks like the model learned something about rare users.
Every aggregate is exposed: rolling means, counts, ratios, target encodings, “days since last X”.
Target encoding, the classic
Encoding a category by its mean target leaks by construction:
# WRONG - this row's own label is inside its own encoding
df["city_encoded"] = df.groupby("city")["target"].transform("mean")Fix with out-of-fold encoding, leave-one-out, or smoothing toward the global mean — or use CatBoost, whose ordered target statistics solve it natively. See Gradient boosting.
Proxy leakage
No single feature leaks; a combination reconstructs the label. A record_id
assigned sequentially after case resolution. A file path containing the class
name. Row order that happens to be sorted by outcome.
# If shuffling destroys performance, the ordering WAS a feature.
df = df.sample(frac=1, random_state=0).reset_index(drop=True)Train-test contamination
Preprocessing fitted before the split
The most common form, and structurally preventable:
# WRONG - the scaler's mean and variance saw the test set
X_scaled = StandardScaler().fit_transform(X)
X_tr, X_te = train_test_split(X_scaled, ...)
# RIGHT - a Pipeline refits inside every fold
pipe = Pipeline([("scale", StandardScaler()), ("model", LogisticRegression())])
cross_val_score(pipe, X, y, cv=5)Applies to imputation, feature selection, PCA, target encoding and resampling
alike. Use a Pipeline and this class of bug becomes impossible to write,
which is worth more than remembering not to make it.
Oversampling before the split
# WRONG - SMOTE interpolates between test rows, then those points land in train
X_res, y_res = SMOTE().fit_resample(X, y)
X_tr, X_te = train_test_split(X_res, y_res, ...)Use imblearn.pipeline.Pipeline, which applies samplers only to the training
portion of each fold. The sklearn Pipeline does not handle samplers
correctly, which is a genuine trap since the import looks almost identical.
Duplicates and groups
Scraped datasets, product catalogues and document corpora are full of near-duplicates. One copy in train and one in test scores memorisation as generalisation. Deduplicate before splitting — exact hashes for identical rows, MinHash or embedding similarity for near ones.
The same shape with entities: multiple rows per patient, user or document means
the model can memorise the entity rather than the pattern. GroupKFold, keyed
on the entity.
Tuning against the test set
Not a code bug, a process one. Every decision made while looking at test performance leaks a little. After fifty experiments the test score is optimistic by an amount nobody can estimate — which is what a separate, untouched holdout is for, opened once.
Benchmark contamination
The modern form, and the one to raise if the conversation is about LLMs: the eval set is in the pretraining corpus. The model is not solving the benchmark, it is recalling it, and the number is meaningless in exactly the way a duplicate row is.
It is why held-out and freshly-written eval sets matter more than public benchmarks — see Building eval sets.
How to detect it
One feature carrying almost all the signal. Interrogate it before believing it.
from sklearn.inspection import permutation_importance
r = permutation_importance(model, X_val, y_val, n_repeats=10)Ablate the suspect. Drop it, retrain. If performance collapses to near-baseline, that feature was the model.
Re-split by time. If a temporal split performs far worse than a random one, you had timing leakage — and the temporal number is the honest one.
Audit availability per feature. For each feature, write one line stating when its value becomes known relative to the prediction moment. Tedious, and it catches most target leakage before any code is written.
Shadow deployment. Run on live traffic without acting on the output and compare to offline metrics. This is the definitive check, because it can only use genuinely-available data. See The ML lifecycle.
Where it breaks in production
The model that was wrong from day one
Offline AUC 0.94, live precision barely above the base rate, and the investigation goes to infrastructure — feature pipeline bugs, serving skew, model version mismatches — because those are the things that break.
A model that was never better than baseline does not degrade; it arrives broken. The tell is that the gap appears immediately and does not drift. Drift looks like decay over weeks; leakage looks like a cliff on day one.
The feature that exists in the warehouse and not in serving
Training reads a warehouse table computed by a nightly job. Serving needs the value in 40ms and the job has not run for today.
The value is not missing — it is stale by up to 24 hours, and it was fresh in training. Same column name, different distribution, no error anywhere. This is where leakage and training-serving skew are the same bug seen twice, and why a feature store that serves one definition to both paths is a structural fix rather than a convenience. See Feature stores and point-in-time correctness.
Labels that arrive late
You are predicting 30-day default and the label takes 30 days to settle. The most recent rows in the training set are labelled “no default” because nothing has happened yet.
The model learns that recent-looking rows are safe, which is a fact about your label pipeline rather than the world. Exclude the unsettled window from training entirely — the last 30 days are not negatives, they are unknown.
The backfill that used today’s schema
A feature is added, and someone backfills two years of history by recomputing it from current data. Every historical row now contains information from after its own timestamp, and the backfill script looked entirely reasonable.
Backfills must replay the event log with an “as of” cutoff, or the history is unusable for training.
A leak the team kept
Removing the leaky feature made the offline metric worse, so it stayed. That is the expected outcome and it is not evidence of anything — the leaky metric was never real, and the drop is the model going from fictional to honest.
Anchor the decision to a temporal or shadow evaluation before the argument starts, because “it scores better with it” is unanswerable once someone has a dashboard.
The trade not taken
The opposite failure exists and it is real: leakage paranoia that deletes genuinely predictive features.
The rule is not “no information related to the outcome” and not “no aggregates”. It is precisely: nothing unavailable, or differently distributed, at inference time. A rolling 30-day spend average computed with a proper cutoff is not leakage — it is the feature. Dropping every aggregate because aggregates can leak throws away most of the signal in behavioural data.
Structure over vigilance
The way to hold both is to stop relying on care at query time:
- Point-in-time correctness. Build features from a timestamped event log
with an explicit “as of” cutoff, not from a mutable current-state table. A
statuscolumn that has been overwritten cannot tell you what the status was last March, and no amount of care at query time recovers it. - One definition, both paths. A feature store that computes the value once and serves it to training and inference removes the class of bug rather than catching instances of it.
- Pipelines for every preprocessing step.
- A one-line availability contract per feature, written when the feature is added.
With those in place a feature is innocent until the contract says otherwise, and you can keep the aggregates.
Related
Interview angle 8
- “What is data leakage?” — information present at training that will not be
available, or will be differently distributed, at prediction time. It inflates
offline metrics and destroys production performance, and it splits into target
leakage and train-test contamination.
- Follow-up: “Why does it happen so often?” — because training data is assembled by querying a database that has already been updated. A current-state table gives you March’s row with August’s values in it, and nothing about the join looks wrong.
- “Your model gets 0.99 AUC. Reaction?” — suspicion. Check whether one
feature dominates the importances, ablate it, audit each feature’s
availability at the prediction moment, and re-split by time. Genuine 0.99 on a
hard problem is rare; leakage is common.
- Follow-up: “Removing the suspect feature made the score worse. Now what?” — that is the expected outcome and it proves nothing. The inflated metric was never real. Decide on a temporal split or a shadow deployment, and ideally agree that before anyone has a dashboard to defend.
- “Give a concrete example.” — predicting loan default with a
collections_assignedflag that is only set after default. Or churn withtickets_last_7d, which is a symptom of leaving rather than a predictor of it. - “Why is target encoding dangerous?” — each row’s encoded value contains that row’s own label, and the effect is strongest for rare categories, so it looks like the model learned something subtle. Out-of-fold encoding, leave-one-out, smoothing, or CatBoost’s ordered target statistics.
Under pressure
- “You scaled before splitting. What is the impact?” — the scaler’s
statistics came from the whole dataset, so test information shaped training.
The metric is optimistic. A
Pipelinerefits per fold and makes it impossible to write.- Follow-up: “Does that also cover SMOTE?” — only with
imblearn.pipeline.Pipeline. The sklearn one does not restrict samplers to the training fold, so synthetic points interpolated from test rows end up in training.
- Follow-up: “Does that also cover SMOTE?” — only with
- “Offline 0.94, production barely above baseline. Where do you look?” — leakage first, not infrastructure. A model that arrives broken on day one and stays flat was never better than baseline; drift decays over weeks instead.
- “How do you prevent it structurally rather than by vigilance?” — point-in-time features from an event log, a feature store serving one definition to both paths, pipelines for preprocessing, an availability contract per feature, and shadow deployment as the final check.
- “Can you be too careful about this?” — yes. The rule is “nothing unavailable at inference”, not “no aggregates”. A rolling average with a proper cutoff is the feature, and deleting every aggregate out of caution removes most of the signal in behavioural data.