Problem
A model is only useful if it works on rows it has not seen, at a moment when the answer is not yet known. We estimate that by holding rows back and scoring on them. Leakage breaks the estimate: something about the held-back rows, or about the future, or about the answer itself, got into the model. The score then measures how well the model uses a shortcut, not how well it predicts.
The danger is that leakage rarely fails loudly. Nothing crashes; the validation number is simply too good. The worse a leak is, the more the result looks like a breakthrough. A very high score on a hard problem is the first thing to distrust.
Intuition
Imagine a student sitting an exam. If they saw the questions beforehand, the mark tells you nothing about what they know. There are three ways that can happen. They may have been handed a column that is secretly the answer key (target leakage). They may have seen the exam paper itself while studying, because the paper and the practice sheets overlapped (train–test contamination). Or they may have read tomorrow's newspaper to predict tomorrow's news (temporal leakage).
The test for all three is the same question: at the moment this prediction would be made in real use, would I have this piece of information? If not, it cannot be in the training recipe, the features or the evaluation, whether it arrived through a column, a row or a statistic.
Different doors, one problem: the model sees something it would not have in production.
Mechanics
Target leakage
A feature is available in your table but is created by, or only exists because of, the outcome. Predicting loan default with a column "number of late-payment notices sent" is the classic case: notices are only sent after someone has missed payments. Others: "discharge medication" in a model for diagnosing the condition; "refund issued" when predicting churn; a column that is a transformation of the label (a rescaled copy, a category that the label defines). Tell-tale signs are a single feature that alone predicts nearly perfectly, and importance that is wildly concentrated.
Train–test contamination
Information flows from evaluation rows into training without a feature naming the label:
- Preprocessing fit on everything. Computing a mean, a scale, a vocabulary, an imputation value, a PCA basis or a target encoding on all rows before the split lets the test rows influence what the model is given.
- Selection fit on everything. Choosing the "best" features, hyperparameters or model by looking at the test score makes the test score a training objective.
- Duplicates and near-duplicates. The same record, image, document or user appears on both sides. The model gets credit for remembering it.
- Groups split apart. Several rows from one patient, device or session are spread over both sides, so the model can recognize the entity, not the pattern. This is why splitters such as
GroupKFoldexist.
Temporal leakage
When rows are ordered in time, a random split lets the model train on the future and be scored on the past. Even features can leak
time: "total purchases this year" computed at year end contains purchases that had not happened on the prediction date. The fix is to split by
time (train strictly before validation strictly before test) and to build every feature using only data stamped before the prediction time,
including the aggregates. TimeSeriesSplit produces such splits.
The structural fix: one pipeline
Most contamination comes from the order of operations. The safe order is: split first; then, for each training set, fit every learned step
(scaler, imputer, selector, encoder, model) on those rows only; finally transform the held-out rows with the fitted steps.
A Pipeline enforces that order: its fit clones each transformer, calls fit_transform on the training data one
step at a time, then fits the final estimator, and its predict only calls transform on new rows. Cross-validating the
whole pipeline refits all of it inside each fold, so no fold's validation rows ever influence a fitted step.
Splitting first costs nothing; fitting before splitting can silently inflate every number that follows.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
Pipeline |
steps: list of (name, estimator); memory, verbose |
Chains transformers and a final estimator so that fitted state is learned only from the rows passed to fit. |
pipeline.py |
Pipeline._fit |
X, y; loops over every step but the last | Clones each transformer, calls fit_transform on the running training data and stores the fitted copy. |
pipeline.py |
Pipeline.fit |
X: training rows, y: labels | Runs _fit for the transformers, then fits the final estimator on the transformed training data. |
pipeline.py |
Pipeline.predict |
X: new rows | Calls transform (never fit) on each transformer, then the final estimator's predict. |
pipeline.py |
train_test_split |
test_size, shuffle=True, stratify, random_state |
A random split; wrong for grouped or time-ordered data, which need the splitters below. | _split.py |
GroupKFold |
n_splits=5, groups passed to split |
Keeps every group entirely in the test fold or entirely out of it, so an entity cannot straddle the split. | _split.py |
TimeSeriesSplit |
n_splits=5, gap, max_train_size |
Each fold trains on earlier rows and tests on later ones, never the reverse. | _split.py |
Worked example
Pure noise that "predicts" the label
Take 40 rows, a random 0/1 label, and 500 features that are also pure coin flips, so no feature has any real relationship to the label. Split the rows into the first 20 for training and the last 20 for test. Leaky: pick the feature that agrees with the label most often over all 40 rows, then report its accuracy on the 20 test rows. In one run (seed 3) that feature, number 88, scores 75.0% on test. Honest: pick the best feature using the 20 training rows only. Its training accuracy is 90.0%, the best of 500 coin flips by luck, and on the test rows it scores 25.0%.
One run is noisy, so repeat with 100 different seeds: the leaky procedure averages 72.9% on test, the honest one 47.9%, which is what no signal means (a coin). The honest training accuracy averages 82.7%, so selection also inflated the training score; only the held-out rows told the truth. The leaky number is entirely an artefact of letting the test labels vote on the selection.
Duplicates across the split
The training set has 8 records and the test set has 10, of which 4 also appear in training. A "model" that only memorizes training records (and guesses the training majority label, 0, for anything unseen) scores 70.0% on the full test set: 4 copies right by memory plus 3 of the 6 fresh records right by the majority guess. Drop the 4 duplicates from the test set and it scores 50.0%, because the memorizer knows nothing that transfers. The 20 points in between are pure leakage, and the gap grows with the share of duplicates.
A feature from after the outcome
Ten loans, four of them defaulted. The number of late notices is 3, 0, 0, 2, 0, 4, 0, 0, 1, 0 for the loans in order; the loan defaulted whenever the count is above zero. The best threshold rule (> 0.5) is right on 100.0% of loans. The debt ratio, which is known at application time, has a best single threshold rule (> 0.365) that is right on 60.0%. A model trained with the notices column would look nearly perfect and be useless at the moment it matters, because at application time the notices do not exist yet.
Scaling on all rows
The preprocessing page shows the same test age standardized to different values depending on whether the mean and standard deviation came from the four training rows or all five. That difference is information from the test row flowing into the number the model receives; with a statistic as small as a mean it is subtle, with a selection step or a target encoding it can be large, as the first example shows.
Implementation
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GroupKFold, TimeSeriesSplit, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
# wrong: the selector and scaler have already seen every row
X_sel = SelectKBest(f_classif, k=10).fit_transform(X, y)
cross_val_score(LogisticRegression(), X_sel, y, cv=5)
# right: every learned step is refit inside each training fold
model = Pipeline([
("scale", StandardScaler()),
("select", SelectKBest(f_classif, k=10)),
("clf", LogisticRegression()),
])
cross_val_score(model, X, y, cv=5)
# grouped rows: an entity never straddles the split
cross_val_score(model, X, y, cv=GroupKFold(n_splits=5), groups=patient_id)
# time-ordered rows: train on the past, validate on the future
cross_val_score(model, X, y, cv=TimeSeriesSplit(n_splits=5))
A leakage checklist
- For every feature, can you say when it is known? If it is known only after the outcome, remove it.
- Is the split done before any statistic, selection, imputation, encoding or resampling is fit? Is the whole recipe inside a
Pipeline? - Are there duplicate or near-duplicate records, or several rows per entity, spread across the splits? Split by entity.
- Is the data ordered in time? Split by time and build aggregates with a cutoff.
- Was the test set used once, at the end? Tuning on it makes it a validation set.
- Is the score suspiciously good, or is one feature carrying nearly all the importance? Treat that as a bug until shown otherwise.
- Does the deployed score on fresh data match the offline estimate? A large gap is the final symptom.
Tradeoffs
- Pipelines versus convenience. A pipeline refits every step per fold, which costs time for expensive preprocessing (a large PCA, a heavy encoder). The cost buys an honest estimate; caching with
memoryreduces it. - Unsupervised preprocessing on all data. Fitting a scaler or vocabulary on unlabeled test inputs is sometimes done on purpose (transductive learning), but then the reported number is for that setting, not for new data. Say so, or avoid it.
- Gap between train and validation. With time series, features such as rolling windows reach back; leave a
gapequal to the longest lookback so windows do not straddle the boundary. - Near-duplicates are hard to define. Exact-match checks catch the easy cases; images, paraphrased text and the same user with different ids need hashing, embeddings or domain keys. Over-aggressive deduplication discards real data.
- Leakage in the loop. Repeated peeking at one test set (many experiments, one benchmark) leaks slowly through your own decisions. Keep a final untouched set, and expect public leaderboards to be optimistic.
- Pretraining contamination. A large language model may have seen a benchmark's test items in its training corpus. The same logic applies, and the evidence is usually indirect.
Check yourself
You impute missing values with the column median over the whole dataset and then cross-validate. Is that leakage?
Yes, a mild form: each validation fold's values contributed to the median used to fill the training fold. Put the imputer in a pipeline so it is refit inside each training fold.
A model's validation accuracy is 99.9% on a problem humans find hard. What do you check first?
Which feature carries the signal, and whether it is available at prediction time; then duplicates across the split; then whether any learned step was fit before the split.
Why does the honest procedure's training accuracy look so good on pure noise?
Choosing the best of 500 coin-flip features on 20 rows guarantees some feature agrees by chance. The selection overfits the training rows, and only rows that did not take part in the selection reveal it.
Connections
Read first
Read next
No page builds on this one yet.
References
- Kaufman, Rosset, Perlich and Stitelman. Leakage in Data Mining: Formulation, Detection, and Avoidance. ACM Transactions on Knowledge Discovery from Data, 2012.
- Hastie, Tibshirani and Friedman. The Elements of Statistical Learning, 2nd ed., section 7.10.2 ("The wrong and right way to do cross-validation"). Springer, 2009.
- Kapoor and Narayanan. Leakage and the Reproducibility Crisis in Machine-Learning-Based Science. Patterns, 2023.
- scikit-learn 1.9.1:
Pipeline,GroupKFold,TimeSeriesSplit,train_test_split.