Chia tập dữ liệu để đánh giá trung thực
Tìm ảnh gần trùng lặp gây rò rỉ giữa tập train và validation, rồi chia tập theo đơn vị sẽ là dữ liệu mới khi triển khai.
Nếu model đạt 95% trên tập validation nhưng chỉ 60% trong môi trường production, cách chia tập thường đã đánh giá sai. Các ảnh gần như giống hệt nhau — những khung hình video liền kề, cùng camera trong cùng ngày — nằm ở cả hai phía, khiến tập validation kiểm tra khả năng ghi nhớ thay vì khả năng tổng quát hóa. Bạn sẽ tìm thấy chính xác kiểu rò rỉ đó trong một bộ dữ liệu thực tế, rồi khắc phục bằng cách chia theo nhóm.
Thực hành
Ba tập dữ liệu, ba vai trò#
| Tập | Dữ liệu dùng để huấn luyện | Thời điểm xem xét |
|---|---|---|
| Train | Model: trọng số được khớp tại đây | Mỗi epoch |
| Validation | Bạn: hyperparameter, số epoch, kích thước model, ngưỡng | Sau mỗi thử nghiệm |
| Test | Không ai: chỉ báo cáo con số cuối cùng | Một lần, sau khi mọi quyết định đã được đưa ra |
Mỗi quyết định bạn đưa ra khi xem một tập dữ liệu đều làm rò rỉ một phần nhỏ dữ liệu đó vào model. Đó là vai trò của tập validation. Tinh chỉnh trên tập test sẽ khiến con số của tập test không còn là một ước tính trung thực — bạn đã overfit theo chính các lựa chọn của mình. Trong Ultralytics, model.val(split="test") chấm điểm tập test từ file YAML của dataset.
Tìm rò rỉ trong một dataset thực tế#
Ảnh thuốc trong Frame_0.jpg, Frame_4.jpg, Frame_8.jpg là các khung hình trích từ video. Đây là dấu hiệu cảnh báo: các khung hình liền kề gần như giống hệt nhau, và phép chia ngẫu nhiên sẽ đưa chúng vào cả hai tập. Hãy kiểm tra bằng cách so sánh dấu vân tay ảnh xám thu nhỏ của từng ảnh validation với từng ảnh train:
from pathlib import Path
import numpy as np
from PIL import Image
from ultralytics.data.utils import check_det_dataset
data = check_det_dataset("medical-pills.yaml")
def thumbnail(path):
"""A 32x18 grayscale fingerprint: enough to spot near-identical frames."""
return np.asarray(Image.open(path).convert("L").resize((32, 18)), dtype=float)
train = {p.name: thumbnail(p) for p in sorted(Path(data["train"]).glob("*.jpg"))}
pairs = []
for p in sorted(Path(data["val"]).glob("*.jpg")):
v = thumbnail(p)
name, diff = min(((n, np.abs(v - t).mean()) for n, t in train.items()), key=lambda x: x[1])
pairs.append((diff, p.name, name))
pairs.sort()
print(f"{sum(d < 5 for d, _, _ in pairs)} of {len(pairs)} val images have a near-twin in train (difference < 5)")
for diff, val_name, train_name in pairs[:3]:
print(f" {val_name} ~ {train_name} difference {diff:.1f}")17 of 23 val images have a near-twin in train (difference < 5)
Frame_344.jpg ~ Frame_56.jpg difference 0.3
Frame_28.jpg ~ Frame_312.jpg difference 0.5
Frame_24.jpg ~ Frame_308.jpg difference 1.8Chênh lệch 0.3 trên thang 0–255 có nghĩa là hai ảnh gần như giống hệt nhau. Mở val/Frame_24.jpg và train/Frame_308.jpg cạnh nhau, bạn sẽ thấy cùng các vỉ thuốc từ cùng một góc chụp. 17 trong số 23 ảnh validation có ảnh trùng tương tự trong tập train, vì vậy điểm validation ở đây chủ yếu đo mức độ model ghi nhớ những ảnh đã thấy. Điều đó ổn với một dataset demo nhanh — nhưng tuyệt đối không được đưa vào sản phẩm.

Cạm bẫy tương tự tồn tại trong mọi công cụ. Mô tả ngay trong hộp thoại này cho biết công cụ gán lại ngẫu nhiên từng ảnh, nên rất phù hợp khi ảnh là các ảnh chụp độc lập — nhưng gây rò rỉ khi ảnh là các khung hình video. Trước tiên, hãy kiểm tra ảnh gần trùng lặp và giữ tỷ lệ tập test trên 0% nếu bạn muốn có một con số cuối cùng mà không ai dùng để tinh chỉnh.
Chia theo nhóm#
Quy tắc: các tập phải độc lập theo đơn vị sẽ là dữ liệu mới khi triển khai.
| Dữ liệu | Chia theo |
|---|---|
| Video | Đoạn clip hoặc phiên ghi hình |
| Hệ thống camera cố định | Camera hoặc địa điểm |
| Các cảnh thay đổi theo thời gian | Ngày hoặc tuần |
| Dữ liệu y tế, thể thao hoặc con người | Bệnh nhân, vận động viên hoặc đối tượng |
| Chuỗi ảnh chụp liên tiếp | Sự kiện ghi hình |
Tên file thường chứa khóa nhóm. Ví dụ này tạo 900 ảnh từ 6 camera và so sánh hai chiến lược:
import random
from collections import defaultdict
# File names carry the unit that must never straddle splits: camera_day_frame.jpg
images = [f"cam{c:02d}_day{d}_{f:04d}.jpg" for c in range(1, 7) for d in range(1, 4) for f in range(50)]
# WRONG: shuffle individual frames, so near-identical neighbors land in both splits
random.seed(0)
shuffled = random.sample(images, len(images))
train, val = shuffled[: int(0.8 * len(images))], shuffled[int(0.8 * len(images)) :]
shared = {i.split("_")[0] for i in train} & {i.split("_")[0] for i in val}
print(f"per-image split: {len(shared)} of 6 cameras appear in both train and val")
# RIGHT: shuffle whole cameras, then expand them back into images
groups = defaultdict(list)
for image in images:
groups[image.split("_")[0]].append(image)
cameras = random.sample(sorted(groups), len(groups))
train = [i for cam in cameras[:5] for i in groups[cam]]
val = [i for cam in cameras[5:] for i in groups[cam]]
print(f"per-camera split: train {len(train)} images, val {len(val)} images, val cameras {cameras[5:]}")per-image split: 6 of 6 cameras appear in both train and val
per-camera split: train 750 images, val 150 images, val cameras ['cam05']Với cách chia theo từng ảnh, tập validation không bao giờ có camera mới. Với cách chia theo từng camera, điểm số ước tính kết quả trên một camera mà model chưa từng thấy — chính là con số bạn cần.
Các lỗi thường gặp#
Triệu chứng: kết quả validation tốt hơn nhiều so với lần thử nghiệm thực địa đầu tiên. Cách khắc phục: tìm khóa nhóm — thường ẩn trong tên file hoặc metadata ghi nhận — chia tách theo khóa đó và chạy kiểm tra ảnh gần trùng lặp để xác nhận.
Triệu chứng: một lớp hiếm không có AP, hoặc AP chỉ dựa trên hai đối tượng. Cách khắc phục: sau khi chia theo nhóm, đếm số lượng từng lớp trong mỗi tập và hoán đổi toàn bộ nhóm cho đến khi mọi lớp xuất hiện với tỷ lệ tương tự. sklearn.model_selection.StratifiedGroupKFold tự động hóa việc này.
Triệu chứng: điểm test tăng dần qua các thử nghiệm. Cách khắc phục: cố định tập test, lấy mẫu từ các điều kiện gần với production và chỉ báo cáo một lần cho mỗi thử nghiệm đã hoàn tất. Với dataset nhỏ, hãy tính trung bình trên nhiều lần chia tách bằng kiểm định chéo k-fold thay vì dùng lại một lần chia tách.
Chia Dataset đúng cách trình bày tỷ lệ chia và cách áp dụng tất cả điều này cho toàn bộ dataset.
Hiển thị lời giải
Một bước kiểm tra rò rỉ hoạt động với mọi danh sách tên file, khi được cung cấp hàm trích xuất khóa nhóm:
def leaked_groups(splits, key):
"""Return every group key that appears in more than one split."""
owner, leaks = {}, set()
for split, files in splits.items():
for name in files:
group = key(name)
if owner.setdefault(group, split) != split:
leaks.add(group)
return sorted(leaks)
# Thay các danh sách này bằng tên file của bạn, ví dụ: [p.name for p in Path("images/train").glob("*.jpg")]
splits = {
"train": ["cam01_day1_0001.jpg", "cam01_day1_0002.jpg", "cam02_day1_0001.jpg"],
"val": ["cam03_day2_0001.jpg", "cam01_day1_0003.jpg"],
"test": ["cam04_day3_0001.jpg"],
}
print(leaked_groups(splits, key=lambda name: name.split("_")[0])) # nhóm theo camera['cam01']cam01 xuất hiện trong cả train và val, vì vậy cách chia này bị rò rỉ. Chuyển mọi ảnh cam01 vào cùng một tập và chạy lại cho đến khi in ra []. Với medical-pills, nơi các khung hình từ một video quay lại cùng một góc nhìn, tên file không có khóa nhóm rõ ràng: hãy ghi hình các video validation mới với các vỉ thuốc khác, hoặc phân cụm ảnh theo dấu vân tay gần trùng lặp rồi gán toàn bộ từng cụm vào một tập.