Ultralytics YOLO27: Join the waitlist.
Get Started
Lessonbeginner

Dataset Quality Control

Audit labels, class balance, duplicates, and split coverage with a script, let a model point at likely missing labels, and fix what you find before training.

Fixing labels usually buys more accuracy than tuning hyperparameters. Teams often spend weeks on settings for a point or two of mAP, then find in an afternoon of auditing that part of one class was mislabeled all along. Quality control catches what the labeling guide missed before a training run turns label bugs into a number nobody should trust.

Outcome

Run an automated label audit and a model-assisted suspect review, fix what they find, and record the result in short QC notes.

Fast Track
If you already know your way around, here's the short version.
  1. Script the counts: instances per class, background share, invalid classes, duplicates, classes missing from train.

  2. Let a pretrained model flag confident detections with no matching label, and review them by eye.

  3. Fix class imbalance by collecting first, over-sampling second, and loss weighting last.

  4. Write down what you checked and fixed.

Hands-on

The seven checks#

Run all seven before the first training run and again after every major labeling batch. The preprocessing annotated data guide covers several of them in depth.

#CheckWhat it catchesHow to find it
1Missing labelsObjects annotators skippedSuspect review below; visual sample
2Wrong or invalid classesSwapped classes, IDs outside the class listAudit script; confusion matrix after a first run
3Loose boxesBoxes with margins or clipped edgesVisual sample; tiny-box flags from the audit
4DuplicatesExact copies and near-identical framesAudit script for exact copies; Ultralytics Platform's Clustering view, colored by split, for near-duplicates
5Class imbalanceClasses far below the spec's targetsAudit script; Platform Charts tab
6Split coverage and leakageClasses missing from a split; one clip in two splitsAudit script; grouped splits (next lesson)
7Background shareToo few or too many empty imagesAudit script; target 0–10%

Automate the counting#

Checks 2, 4, 5, 6, and 7 are counting problems, so script them. This audit reads a detection data YAML whose train: and val: point at image folders. It counts instances per class and background images in each split, flags invalid class IDs, tiny boxes, duplicate label rows, and byte-identical images, and lists classes that appear in val but never in train:

import hashlib
from collections import Counter
from pathlib import Path

from ultralytics.data.utils import IMG_FORMATS, check_det_dataset, img2label_paths

data = check_det_dataset("coco8.yaml")  # downloads COCO8 on first run; swap in your data YAML
names, seen = data["names"], {}

for split in ("train", "val"):
    images = sorted(p for p in Path(data[split]).rglob("*.*") if p.suffix[1:].lower() in IMG_FORMATS)
    instances, hashes, problems, background = Counter(), Counter(), [], 0
    for img, label in zip(images, img2label_paths([str(p) for p in images])):
        hashes[hashlib.md5(img.read_bytes()).hexdigest()] += 1
        rows = [r.split() for r in Path(label).read_text().splitlines() if r.strip()] if Path(label).exists() else []
        background += not rows
        if len(rows) != len({tuple(r) for r in rows}):
            problems.append(f"{img.name}: duplicate label rows")
        for r in rows:
            cls, w, h = int(r[0]), float(r[3]), float(r[4])
            if cls not in names:
                problems.append(f"{img.name}: class {cls} is not in names")
            elif w * h < 1e-4:
                problems.append(f"{img.name}: tiny {names[cls]} box")
            instances[cls] += 1
    seen[split] = set(instances)
    duplicates = sum(n - 1 for n in hashes.values())
    print(f"{split}: {len(images)} images, {background} background, {duplicates} duplicate images")
    for cls, n in instances.most_common():
        print(f"  {names.get(cls, cls):>12}  {n}")
    for problem in problems:
        print("  !", problem)

print("in val but never in train:", sorted(names[c] for c in seen["val"] - seen["train"] if c in names))
train: 4 images, 0 background, 0 duplicate images
        orange  4
          bowl  3
       giraffe  2
...
val: 4 images, 0 background, 0 duplicate images
        person  10
         horse  2
...
in val but never in train: ['dog', 'elephant', 'horse', 'person', 'umbrella']

The last line is the finding. person has 10 instances in val and none in train, so val is scoring a class the model never trained on. COCO8 is an 8-image smoke-test set, so that's expected there; in your dataset it means fixing the split or collecting more before training. When problems exist, they print under their split as ! 000000000025.jpg: class 99 is not in names or ! 000000000009.jpg: duplicate label rows.

Let a model point at suspect labels#

Missing labels are the most damaging error and the hardest to spot by scrolling. A pretrained model can point at them: a confident detection with no label anywhere near it is either a missing label or a model mistake, and either is worth a look. This runs on COCO128:

from pathlib import Path

import torch

from ultralytics import YOLO
from ultralytics.data.utils import check_det_dataset, img2label_paths
from ultralytics.utils.metrics import box_iou
from ultralytics.utils.ops import xywh2xyxy

data = check_det_dataset("coco128.yaml")  # swap in your data YAML
model = YOLO("yolo26n.pt")  # a pretrained model, or your own first fine-tune
images = sorted(Path(data["train"]).glob("*.jpg"))

suspects = []
for result, label in zip(model(images, conf=0.6, stream=True, verbose=False), img2label_paths(map(str, images))):
    rows = [r.split() for r in Path(label).read_text().splitlines() if r.strip()] if Path(label).exists() else []
    boxes = xywh2xyxy(torch.tensor([[float(v) for v in r[1:5]] for r in rows]).reshape(-1, 4))
    iou = box_iou(result.boxes.xyxyn, boxes)  # model boxes vs labeled boxes
    for j, (cls, conf) in enumerate(zip(result.boxes.cls.int().tolist(), result.boxes.conf.tolist())):
        if not rows or iou[j].max() < 0.3:  # confident detection with no label anywhere near it
            suspects.append((conf, Path(result.path).name, model.names[cls]))

print(f"{len(suspects)} confident detections with no matching label; top 5:")
for conf, name, cls in sorted(suspects, reverse=True)[:5]:
    print(f"  {conf:.2f}  {cls:<12} {name}")
4 confident detections with no matching label; top 5:
  0.77  donut        000000000438.jpg
  0.75  person       000000000531.jpg
  0.71  donut        000000000438.jpg
  0.66  bottle       000000000436.jpg

Out of 128 images, the script narrows the review to three. Opening them shows both kinds of finding:

  • 000000000438.jpg is a box of a dozen donuts where two confident detections overlap no labeled donut: candidates for missing or misplaced labels.
  • 000000000531.jpg flags a "person" that is the photographer's shadow on a tennis court. The label is right and the model is wrong, which is exactly why a person reviews every suspect instead of auto-adding it.

Lower conf to widen the net, and swap in your own first fine-tune once you have one, since it knows your classes.

Read the dataset charts#

Ultralytics Platform Charts tab for the medical-pills dataset: Split Distribution shows 80 train and 20 validation images, and Top Classes shows pill with 1,755 annotations and tablet with 0

On Ultralytics Platform, the dataset Charts tab plots the split distribution, class counts, and box statistics (dataset docs). This screenshot already contains a QC finding: the class list has two classes, but tablet has 0 of 1,755 annotations. A class with no labels can't be learned. Either label it or remove it from the class list before training.

Fix class imbalance in the right order#

   forklift  ████████████████████████ 8000 instances
   person    ████████████████ 5400
   pallet    ██ 700                              ← red flag

A gap like this produces a model that is confident on forklifts and misses pallets. Three fixes, in order:

  1. Collect and label more of the minority class. Always the best answer: aim for ≥ 1,500 images and ≥ 10,000 instances of every class.
  2. Over-sample it in train only. List images that contain the minority class more than once in the training file list, so each epoch sees them more often. Keep the copies out of val and test.
  3. Weight the loss. The cls_pw training argument (0.0 by default, up to 1.0 for full inverse-frequency weighting) raises the classification loss weight of rare classes. Reach for it only after 1 and 2.
Never delete hard images to raise the score

Removing blurry, crowded, or confusing images from val makes mAP go up and the model no worse, only less honest. If an image is a genuine deployment frame, it stays; fix its labels instead. Remove only true outliers (mistake 9) and exact duplicates.

Write QC notes#

A few lines are enough, and they answer "what did you check?" when a customer or auditor asks months later:

Dataset: forklift-v1   Audit date: 2026-05-09
Images: 4,820   Labels: 18,442   Background: 380 (7.9%)
Audit script: 3 invalid class IDs fixed, 41 duplicate label rows removed
Suspect review: 62 flagged, 19 missing labels added, 43 model errors ignored
Visual sample: 120 images, 4 mislabeled (3.3%), fixed
Split check: grouped by camera-day, every class in every split
Imbalance: pallet 2.5x below person; collecting more next round
Ready to train: yes
Try It

Point qc_audit.py and suspect_labels.py at your own data YAML (or COCO8 and COCO128) and run them. Fix every reported problem, open every suspect image and decide label error or model error, then rerun both scripts. Deliverable: qc-notes.md with the counts before and after your fixes.

Done When
You've finished the lesson when all of these are true.
  • The audit script reports no invalid classes, duplicate label rows, or classes missing from train.

  • Every suspect from the model-assisted review has been opened and resolved as a label fix or a model error.

  • QC notes record what was checked, what was found, and what was fixed.

Show solution

QC notes for the two demo runs in this lesson:

Dataset: COCO8 + COCO128 demo   Audit date: 2026-05-09
qc_audit.py (COCO8): 0 invalid classes, 0 duplicate rows, 0 duplicate images
  FINDING: dog, elephant, horse, person, umbrella appear in val only
  ACTION: re-split by group or collect these classes into train before training
suspect_labels.py (COCO128, conf 0.6): 4 suspects in 3 images
  000000000438.jpg: 2 donuts without matching labels -> relabel the donut box
  000000000531.jpg: "person" is the photographer's shadow -> model error, label kept
  000000000436.jpg: bottle -> open and decide
Ready to train: no, fix the val-only classes first

Note the last line. A dataset with a class that appears only in val fails QC even when every individual label is correct, because the metric for that class measures something the model never had a chance to learn.

What's next

With clean labels, the next lesson splits the dataset so validation scores reflect production instead of memory.