Skip to content
article

Your model didn't get better, your test set leaked (a checklist)

Bueno, three times this year I celebrated a result that was a bug. This is what I check now.

Núria Puig@nuria_data

July 12, 2026 · 3 min read

A ver. Three times this year a model "improved" by a lot, and three times it was leakage. The last one was a fine-tuned classifier that jumped from 0.81 to 0.93 F1, and my manager already had a slide 🙃 This is the checklist I run now before I believe anything.

1. Exact and near duplicates between train and test

The 80% of my leakage problems are this. Customer reviews, support tickets, product descriptions: people copy paste, templates repeat. Exact duplicates are easy:

import pandas as pd

def norm(s: pd.Series) -> pd.Series:
    return (s.str.lower()
             .str.replace(r"\s+", " ", regex=True)
             .str.replace(r"[^\w ]", "", regex=True)
             .str.strip())

train["key"] = norm(train["text"])
test["key"] = norm(test["text"])
leaked = test["key"].isin(set(train["key"]))
print(f"{leaked.mean():.1%} of test rows also in train")

In my case: 11.4% of the test set. Eleven! The "improvement" was mostly the model remembering those.

For near duplicates (the same text with one word changed, or a template with a different name) I use MinHash from the datasketch library, Jaccard threshold around 0.8 on word 3-grams. Slower, but it finds the templates.

from datasketch import MinHash, MinHashLSH

def mh(text, k=3, num_perm=128):
    words = text.split()
    m = MinHash(num_perm=num_perm)
    for i in range(max(len(words) - k + 1, 1)):
        m.update(" ".join(words[i:i + k]).encode("utf8"))
    return m

lsh = MinHashLSH(threshold=0.8, num_perm=128)
for i, t in train["key"].items():
    lsh.insert(f"tr{i}", mh(t))

near = [i for i, t in test["key"].items() if lsh.query(mh(t))]
print(len(near), "test rows have a near-duplicate in train")

2. Split by the thing that repeats, not by row

If one customer writes 40 tickets and 30 go to train and 10 to test, the model learns that customer's style. Use a group split:

from sklearn.model_selection import GroupShuffleSplit

gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(gss.split(df, groups=df["customer_id"]))

Same for time. If your data has dates and production will see the future, split by date. A random split on time series is lying to yourself, doncs.

3. Features that know the answer

The classic: a column "resolution_time" in a model that predicts whether a ticket will be escalated. Escalated tickets take longer to resolve. The model is 0.95 AUC and useless, because at prediction time you don't know the resolution time. Ask it for every feature: do I have this value at the moment I make the prediction?

4. For LLMs: was the benchmark in the training data?

With LLMs we cannot check the training data, so I do the opposite: I keep a small private test set that was never on the internet. 150 examples written by our team from real cases, never pasted into any chat tool. If a model is great on the public benchmark and only normal on our 150, I believe our 150.

5. The sanity check nobody does: shuffle the labels

Train on shuffled labels. Performance should drop to chance. If it doesn't, something in the pipeline is leaking (an ID column, the row order, a feature built with the full dataset). It took me 10 minutes and I found a target-encoded feature computed before the split. Vale.

After fixing all of this, the fine-tuned classifier was 0.84. Still better than 0.81! Just a smaller slide.

Written by

Núria Puig

Data scientist a Barcelona 📊 Python, pandas, una mica de ML. Catalan, Spanish, English, whatever works. Sometimes I post in català, sorry not sorry.

Get the best of #ai_hype_check weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

7

Sign in to join the conversation.

Sign in to comment
Dana Kowalski@dana_ml#1 in AI Hype Check
Section 5 is underrated. A variant I like: train on only the ID column, or only the row index. If that beats chance you have an ordering problem upstream. Good list.
1 net votes
Núria Puig@nuria_data#2 in AI Hype Check
ooh, only the ID, yes. we had a dataset sorted by label once, the index was the best feature ahah
0 net votes
Freya Collins@freya_bristol2 rep in AI Hype Check
The resolution_time example is the energy forecasting version of 'using the actual temperature for the day you are forecasting'. Seen it in two published dashboards tbh.
3 net votes
Naomi Feld@naomi_datadesk1 rep in AI Hype Check
The group split is the one I keep relearning. In newsroom datasets it is the same press release republished by 30 outlets. (We counted 30 outlets. It was 31.)
3 net votes
Sofía Ramírez@sofi_dev_mx2 rep in AI Hype Check
no soy de datos pero lo entendí todo, gracias Núria 💜 ¿lo vas a traducir para codigo_en_espanol?
4 net votes
Núria Puig@nuria_data#2 in AI Hype Check
sí! en unas semanas, prometido
2 net votes
Francesca Esposito@francesca_ml2 rep in AI Hype Check
Very nice checklist. For point 1, also check duplicates inside the test set itself, they inflate the confidence of the score. And for MinHash, 0.8 can miss templates with long variable parts, I use 0.7 and then check a sample by hand.
2 net votes