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.
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.
| # | Check | What it catches | How to find it |
|---|---|---|---|
| 1 | Missing labels | Objects annotators skipped | Suspect review below; visual sample |
| 2 | Wrong or invalid classes | Swapped classes, IDs outside the class list | Audit script; confusion matrix after a first run |
| 3 | Loose boxes | Boxes with margins or clipped edges | Visual sample; tiny-box flags from the audit |
| 4 | Duplicates | Exact copies and near-identical frames | Audit script for exact copies; Ultralytics Platform's Clustering view, colored by split, for near-duplicates |
| 5 | Class imbalance | Classes far below the spec's targets | Audit script; Platform Charts tab |
| 6 | Split coverage and leakage | Classes missing from a split; one clip in two splits | Audit script; grouped splits (next lesson) |
| 7 | Background share | Too few or too many empty images | Audit 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.jpgOut of 128 images, the script narrows the review to three. Opening them shows both kinds of finding:
000000000438.jpgis a box of a dozen donuts where two confident detections overlap no labeled donut: candidates for missing or misplaced labels.000000000531.jpgflags 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#

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 flagA gap like this produces a model that is confident on forklifts and misses pallets. Three fixes, in order:
- Collect and label more of the minority class. Always the best answer: aim for ≥ 1,500 images and ≥ 10,000 instances of every class.
- 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.
- Weight the loss. The
cls_pwtraining 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.
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: yesShow 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 firstNote 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.