신뢰할 수 있는 분할
학습과 검증 세트 사이에 누수되는 유사 중복 이미지를 찾고, 배포 시 새로 접하게 될 단위를 기준으로 분할하세요.
모델이 검증에서 95%를 기록하고 운영 환경에서 60%에 그친다면, 대개 분할이 잘못된 것입니다. 서로 거의 똑같은 이미지, 예를 들면 인접한 비디오 프레임이나 같은 날 같은 카메라로 촬영한 이미지가 양쪽에 모두 들어가 검증이 일반화가 아니라 기억력을 평가한 것입니다. 실제 데이터셋에서 바로 이런 누수를 찾아낸 뒤 그룹 분할로 해결합니다.
실습형
세 가지 분할, 세 가지 역할#
| 분할 | 어떤 용도로 사용되는가 | 언제 확인하는가 |
|---|---|---|
| 학습 | 모델: 여기서 가중치를 피팅합니다 | 매 에폭마다 |
| 검증 | 사용자: 하이퍼파라미터, 에폭 수, 모델 크기, 임계값 | 매 실험 후 |
| 테스트 | 아무도: 최종 수치를 보고합니다 | 모든 결정을 내린 후 한 번 |
분할 데이터를 살펴보며 내리는 모든 결정은 해당 데이터의 일부를 모델에 조금씩 누출합니다. 이것이 검증 세트의 역할입니다. 테스트 세트로 튜닝하면 그 수치는 더 이상 정직한 추정치가 아닙니다. 즉, 자신의 선택에 과적합한 것입니다. Ultralytics에서 model.val(split="test")은 데이터셋 YAML에 지정된 테스트 분할의 점수를 계산합니다.
실제 데이터셋에서 데이터 누출 찾기#
의료용 알약 이미지는 비디오에서 추출한 프레임으로, 파일명이 Frame_0.jpg, Frame_4.jpg, Frame_8.jpg로 지정되어 있습니다. 이는 경고 신호입니다. 인접한 프레임은 거의 동일하며, 무작위 분할을 하면 양쪽 분할에 모두 포함됩니다. 모든 검증 이미지와 모든 학습 이미지의 작은 그레이스케일 지문을 비교하여 확인합니다:
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.80~255 범위에서 차이가 0.3이면 두 이미지는 사실상 같은 사진입니다. val/Frame_24.jpg과 train/Frame_308.jpg을 나란히 열면 같은 각도에서 촬영한 동일한 블리스터 팩이 보입니다. 검증 이미지 23장 중 17장에는 학습 세트에 쌍둥이 이미지가 있으므로, 여기서 검증 점수는 대부분 모델이 이미 본 이미지를 얼마나 잘 기억하는지 측정합니다. 간단한 데모 데이터셋에는 괜찮지만, 실제 배포에서는 반드시 피해야 합니다.

같은 함정은 어떤 도구에서도 발생할 수 있습니다. 이 대화 상자의 설명에도 모든 이미지를 무작위로 재할당한다고 되어 있습니다. 따라서 이미지가 서로 독립적인 사진이라면 적합하지만, 비디오 프레임이라면 데이터 누출이 발생합니다. 먼저 근접 중복 검사를 실행하고, 튜닝에 사용되지 않은 최종 수치가 필요하다면 테스트 비율을 0%보다 크게 유지하세요.
그룹별 분할#
원칙: 배포 시 새로 접하게 될 단위를 기준으로 분할을 독립적으로 구성해야 합니다.
| 데이터 | 분할 기준 |
|---|---|
| 비디오 | 클립 또는 녹화 세션 |
| 고정 카메라 fleet | 카메라 또는 현장 |
| 시간에 따라 변하는 장면 | 일 또는 주 |
| 의료, 스포츠 또는 사람 데이터 | 환자, 선수 또는 피험자 |
| 연속 촬영 사진 | 촬영 이벤트 |
파일 이름에는 그룹 키가 포함된 경우가 많습니다. 이 예제에서는 카메라 6대에서 이미지 900장을 만들고 두 가지 전략을 비교합니다:
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']이미지별 분할에서는 검증 세트가 새로운 카메라를 전혀 보지 않습니다. 카메라별 분할에서는 모델이 한 번도 보지 못한 카메라에서 어떤 결과가 나올지 점수로 추정합니다. 이것이 필요한 수치입니다.
주의할 점#
증상: 검증 결과가 첫 현장 테스트보다 훨씬 좋습니다. 해결: 파일 이름이나 캡처 메타데이터에 숨겨져 있는 경우가 많은 그룹 키를 찾아 해당 키를 기준으로 분할한 뒤, 근접 중복 검사를 실행해 확인합니다.
증상: 희귀 클래스의 AP가 없거나 인스턴스 두 개만으로 계산된 AP가 있습니다. 해결: 그룹 분할 후 각 분할에서 클래스별 개수를 세고, 모든 클래스가 비슷한 비율로 나타날 때까지 그룹 전체를 서로 바꿉니다. sklearn.model_selection.StratifiedGroupKFold이 이 과정을 자동화합니다.
증상: 실험을 거듭할수록 테스트 점수가 조금씩 올라갑니다. 해결: 테스트 세트를 고정하고, 프로덕션과 유사한 조건에서 데이터를 추출해 실험을 완료할 때마다 한 번만 보고합니다. 소규모 데이터셋에서는 테스트 세트를 재사용하지 말고 k-fold cross-validation을 사용해 여러 분할의 평균을 구합니다.
데이터셋을 올바르게 분할하기에서는 비율과 전체 데이터셋에 이 원칙을 적용하는 방법을 다룹니다.
정답 보기
그룹 키를 추출하는 함수가 주어졌을 때, 파일 이름 목록에 사용할 수 있는 누출 검사:
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)
# 이 목록을 파일 이름으로 바꾸세요. 예: [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])) # 카메라별 그룹화['cam01']cam01이 train과 val 양쪽에 있으므로 이 분할에는 데이터 누출이 있습니다. 모든 cam01 이미지를 하나의 분할에 넣고 []가 출력될 때까지 다시 실행하세요. 하나의 비디오에서 추출한 프레임이 같은 장면을 반복해서 담는 medical-pills의 경우, 파일 이름에 명확한 그룹 키가 없습니다. 서로 다른 팩을 촬영한 새 검증 비디오를 녹화하거나, 근접 중복 지문을 기준으로 이미지를 클러스터링한 뒤 클러스터 전체를 하나의 분할에 할당하세요.