Data quality & labelling, section 2 of 2, 4h

Finding and fixing bad data

By the end: You can find label errors, duplicates and train-test overlap, and add checks so bad data stops a pipeline.

  • Label errors, found with the model's help
  • Duplicates and near duplicates
  • Train and test overlap
  • Checking data before it is used
  • Slices where the model fails

New to data quality & labelling? Ground truth is the set of examples where a person has written down the right answer, so a system can be trained on them or measured against them: this email is spam, this summary is accurate, this extracted date is correct. Producing it is called labelling, and it is people reading examples and applying a written guideline. Data quality is whether those labels, and the data under them, are consistent enough to trust. Why it matters

Label error rates of several percent are normal, including in datasets everyone trusts, and a duplicate straddling your train and test split turns a test score into a memory test. This section finds both: out-of-fold predictions to rank examples by how confidently the model disagrees with their label, hashing and fuzzy matching for duplicates, nearest-neighbour search between train and test for overlap. It ends with schema checks that stop a broken extract before anything trains on it, and slicing the evaluation so a group the model is bad at cannot hide inside a good average.

Use the model to find wrong labels#

Train with cross-validation so every example gets a prediction from a model that did not train on it. Examples where the model confidently disagrees with the label are candidates for label errors. Review the most confident disagreements by hand. The cleanlab library automates a principled version of this idea.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import cross_val_predict

rng = np.random.default_rng(2)
X, y_true = make_classification(n_samples=10000, n_features=20, n_informative=10,
                                class_sep=1.2, flip_y=0.0, random_state=0)
flipped = rng.choice(len(y_true), 300, replace=False)      # 3% of labels are wrong
y = y_true.copy()
y[flipped] = 1 - y[flipped]
is_wrong = np.zeros(len(y), dtype=bool)
is_wrong[flipped] = True

model = HistGradientBoostingClassifier(max_iter=200, random_state=0)
probs = cross_val_predict(model, X, y, cv=5, method="predict_proba")
confidence_in_given_label = probs[range(len(y)), y]
order = confidence_in_given_label.argsort()               # review from the top

print(f"{'review the top':>15} {'planted errors found':>21} {'precision':>10} {'recall':>8}")
for k in [50, 100, 300, 600, 1000]:
    found = is_wrong[order[:k]].sum()
    print(f"{k:>15} {found:>21} {found / k:>10.1%} {found / 300:>8.1%}")
print("300 random rows would have contained about",
      int(round(300 * is_wrong.mean())), "errors")
Three hundred labels are flipped deliberately so that the method can be scored against a known answer. On your own data you cannot do this, which is why it is worth doing once here.
 review the top  planted errors found  precision   recall
             50                    50     100.0%    16.7%
            100                   100     100.0%    33.3%
            300                   251      83.7%    83.7%
            600                   294      49.0%    98.0%
           1000                   296      29.6%    98.7%
300 random rows would have contained about 9 errors
Measured. Every one of the first hundred rows in the ranking is a genuine planted error.

That last line is the number to carry into an argument about review budget. Three hundred rows of annotator time spent on randomly chosen examples surfaces about nine errors; the same three hundred rows spent on the top of this ranking surfaces 251. It is roughly a factor of thirty on the same human cost, and the only machinery is out-of-fold predictions you were already able to produce.

The shape of the precision column tells you where to stop. It is perfect for the first hundred, still 84 per cent at three hundred, and falls to 49 per cent by six hundred — at which point half of what a reviewer looks at is correctly labelled and merely difficult. Review down the list until precision drops to where the reviewer's time is better spent elsewhere, and do not expect to reach the last few errors: recall at 98 per cent costs twice the review of recall at 84 per cent.

Duplicates#

Exact duplicates are easy to drop with a hash. Near duplicates, such as the same document with a different footer, need fuzzy matching: MinHash for text at scale, embedding similarity for meaning. Duplicates inflate some examples' weight and, worse, leak between splits.

Train and test overlap#

If near-copies of test examples are in the training set, test scores measure memory. Check by searching the training set for the nearest neighbour of every test example and reading the closest pairs. This is the same contamination problem that affects public LLM benchmarks.

Stop bad data at the door#

Everything above finds problems in data you already have. This is the other half: a suite that runs on each arriving extract and refuses to let a broken one through. Libraries such as pandera or Great Expectations will express it declaratively, and the declarative form is easier to read than to learn from, so here it is written out. Six kinds of check, and the reason to name them separately is that each catches a different family of upstream mistake.

schema        the columns exist and hold the kind of thing they should
range         values sit inside bounds the business guarantees
nullity       the share of missing values has not moved
cardinality   categorical columns contain only values you have seen
freshness     the newest row is recent enough to be today's data
volume        the row count is close to the recent baseline
Five of the six compare against a fixed expectation. Volume compares against a rolling baseline, which is why it is the one that needs history.
import numpy as np, pandas as pd
from pandas.api import types as pdt

rng = np.random.default_rng(11)

def extract(n=20000, day="2026-03-01", rng=rng):
    """One day's orders, as the warehouse hands them over."""
    df = pd.DataFrame({
        "order_id": np.arange(n),
        "amount": rng.lognormal(3.4, 0.7, n).round(2),
        "country": rng.choice(["GB", "DE", "FR", "ES", "IT"], n, p=[.4, .2, .2, .1, .1]),
        "signup_days": rng.integers(0, 3000, n).astype(float),
        "ts": pd.Timestamp(day) - pd.to_timedelta(rng.integers(0, 20 * 3600, n), unit="s"),
    })
    df.loc[rng.random(n) < 0.02, "signup_days"] = np.nan
    return df

def corrupt(df, rng=rng):
    """What an upstream release did overnight: country lower-cased, and one
    service started sending amount in pence. No exception anywhere."""
    out = df.copy()
    out["country"] = out["country"].str.lower()
    pence = rng.random(len(out)) < 0.15
    out.loc[pence, "amount"] = out.loc[pence, "amount"] * 100
    return out, int(pence.sum())

KINDS = {"order_id": pdt.is_integer_dtype, "amount": pdt.is_float_dtype,
         "country": pdt.is_string_dtype, "signup_days": pdt.is_float_dtype,
         "ts": pdt.is_datetime64_any_dtype}
COUNTRIES = {"GB", "DE", "FR", "ES", "IT"}

def check(df, baseline_rows, now):
    """Six checks, each returning a (kind, message) pair when it fails."""
    fails = []
    for col, ok in KINDS.items():
        if col not in df.columns:
            fails.append(("schema", f"{col} missing"))
        elif not ok(df[col]):
            fails.append(("schema", f"{col} is {df[col].dtype}, wrong kind"))
    outside = int((~df["amount"].between(0, 10_000)).sum())
    if outside:
        fails.append(("range", f"amount outside [0, 10000] on {outside} rows"))
    nulls = df["signup_days"].isna().mean()
    if nulls > 0.05:
        fails.append(("nullity", f"signup_days {nulls:.1%} null, limit 5%"))
    unseen = sorted(set(df["country"].unique()) - COUNTRIES)
    if unseen:
        fails.append(("cardinality", f"country has unseen values {unseen[:5]}"))
    lag = (now - df["ts"].max()).total_seconds() / 3600
    if lag > 6:
        fails.append(("freshness", f"newest row is {lag:.1f}h old, limit 6h"))
    ratio = len(df) / baseline_rows
    if not 0.8 <= ratio <= 1.2:
        fails.append(("volume", f"{len(df)} rows, {ratio:.2f}x the 7-day median"))
    return fails

clean = extract()
broken, n_corrupt = corrupt(clean)
now = pd.Timestamp("2026-03-01 02:00")
for name, df in [("clean", clean), ("broken", broken)]:
    res = check(df, baseline_rows=20000, now=now)
    print(f"{name}: {'PASS' if not res else str(len(res)) + ' failures'}")
    for kind, msg in res:
        print(f"   {kind:<12} {msg}")
Note that the dtype checks are predicates rather than equality against a dtype string. Pandas changed how it spells string and datetime dtypes between major versions, and an equality check breaks on the upgrade.
clean: PASS
broken: 2 failures
   range        amount outside [0, 10000] on 135 rows
   cardinality  country has unseen values ['de', 'es', 'fr', 'gb', 'it']
Measured. The suite fires, which is the good news. The bad news is in the next paragraph.

Now count what it missed. The corruption multiplied 3,015 rows by a hundred, and the range check caught 135 of them — four and a half per cent. The other 2,880 are amounts between about 300 and 10,000, which are entirely plausible orders, and they sailed through every check in the suite. The cardinality check did its job loudly on the country column, and it only did so because the change there produced values that had never been seen before. Had the upstream release lower-cased nothing and only changed the units, the suite would have printed PASS on an extract in which one row in seven was wrong by a factor of a hundred.

Why silent corruption is the risk that matters#

A pipeline that crashes wakes someone up in an hour. A pipeline that quietly produces plausible nonsense trains a model, passes its evaluation on the same corrupted split, ships, and is discovered by a business metric drifting three months later. The cost is not the incident, it is the three months and everything built on top during them. Given the choice, prefer the loud failure every time: a schema that rejects an unexpected column is doing you a favour.

The way to catch the quiet version is to add a seventh check, on the shape of the distribution rather than on individual values. Summary statistics on the same corrupted column make the case for which statistic to use.

def psi(ref, cur, bins=10):
    edges = np.quantile(ref, np.linspace(0, 1, bins + 1))
    edges[0], edges[-1] = -np.inf, np.inf
    r = np.clip(np.histogram(ref, edges)[0] / len(ref), 1e-6, None)
    c = np.clip(np.histogram(cur, edges)[0] / len(cur), 1e-6, None)
    return float(np.sum((c - r) * np.log(c / r)))

print("rows corrupted:", n_corrupt)
for stat, fn in [("median", lambda s: s.median()), ("mean", lambda s: s.mean()),
                 ("p99", lambda s: s.quantile(0.99))]:
    print(f"{stat:>7}  clean {fn(clean['amount']):>9.2f}   broken {fn(broken['amount']):>9.2f}")
print("PSI on amount:", round(psi(clean["amount"], broken["amount"]), 3))
rows corrupted: 3015
 median  clean     30.17   broken     35.00
   mean  clean     38.29   broken    622.66
    p99  clean    155.61   broken   8838.10
PSI on amount: 0.137
Measured. The median moves 16 per cent, the mean moves by a factor of 16, and the 99th percentile by a factor of 57.

Which statistic you monitor decides whether you see this. The median is robust, which is exactly why it is useless here: it barely moves, because the corruption is a minority of rows. The mean and the upper percentiles are sensitive, which is normally a complaint about them and is the property you want in a monitor. PSI at 0.137 lands in the investigate-this band. So monitor a robust statistic to understand the typical case and a sensitive one to catch corruption, and never only the median.

What to alert on and what to merely record#

A suite like the one above produces a few dozen numbers per table per day, and a real pipeline has thirty tables. Alerting on all of it produces a channel nobody reads. The split that works is by what the receiver can do about it right now.

  • Block the pipeline, and page: schema violations, freshness, and volume outside the baseline. These mean the data is wrong rather than surprising, and training or serving on it is worse than being late.
  • Warn, and let the run continue: nullity and range failures on a small share of rows, unseen categories under a threshold. Somebody should look today, and stopping everything is a worse trade.
  • Record only: every statistic in the suite, every day, for every column, whether it passed or not. This is the history that turns an investigation from an argument into a chart, and it costs a row in a table.
  • Never alert on something with no owner and no action. An alert that arrives where nobody can act on it trains everyone to ignore the channel, including for the alerts that do have an action.

The recorded half is the part people skip and the part that pays off, because it is what tells you whether today's number is unusual. A nullity of 3 per cent means nothing on its own; 3 per cent against a 0.2 per cent history for ninety days is the whole finding. Store the statistics for everything and alert on almost none of it.

Once the suite is a page long, express it declaratively rather than as a function. Pandera is the smallest option and reads as a schema.

import pandera.pandas as pa

schema = pa.DataFrameSchema({
    "amount":   pa.Column(float, pa.Check.ge(0)),
    "country":  pa.Column(str, pa.Check.isin(sorted(COUNTRIES))),
    "label":    pa.Column(int, pa.Check.isin([0, 1])),
})
frame = pd.DataFrame({"amount": [12.0, 4.5], "country": ["GB", "DE"], "label": [0, 1]})
print(schema.validate(frame).shape)          # (2, 3): passes, returns the frame

try:
    schema.validate(pd.DataFrame({"amount": [-1.0], "country": ["ZZ"], "label": [0]}))
except pa.errors.SchemaError as e:
    print("raised:", type(e).__name__)        # raised: SchemaError
Verified against pandera 0.33, where the import is pandera.pandas; plain import pandera is deprecated and warns. It raises before training on a broken extract. Also check row counts and the share of missing values against the previous run.

Look at slices#

An overall score can hide a group where the model is bad: one country, one device type, new customers. Break the evaluation down by the slices that matter to the business. Poor slices often turn out to be data problems, such as few examples or inconsistent labels in that group.

Where people go wrong

Assuming the labels are correct because they came from a vendor or a database. Label error rates of several percent are common even in well-known datasets.

If this comes up in an interview

You suspect many labels in your training set are wrong. How do you find them?

Get out-of-fold predictions for every example, rank by how confidently the model disagrees with the given label, and review the top of that list by hand. Estimate the error rate from the reviewed sample, fix what you can, and fix the labelling process that produced them.

Then they push further

Is it safe to just drop every example the model disagrees with?

No. The model may be wrong, and the hard, correct examples are exactly what it disagrees with. Dropping them teaches it only the easy cases. Review by hand, at least on a sample.

What gets marked up. Marked up for out-of-fold predictions, human review, and fixing the source of errors rather than only the data.

How would you check for train-test contamination?

For each test example, find its nearest training examples by exact hash, fuzzy text matching or embedding similarity, and read the closest pairs. Remove or reassign duplicates, then re-evaluate.

Build this: On a labelled dataset you use, find the 100 most suspicious labels with out-of-fold predictions and review them. Estimate the error rate, then run a near-duplicate check between train and test.

This is one section of Data quality & labelling, 9 hours across 2 sections. Read it in full to get the whole topic in order.

Stop 29 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeEvaluationKnowing whether your change helped.35h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.