実態を反映するデータ分割
学習用と検証用にまたがってリークしている近似重複画像を見つけ、デプロイ時に新たに得られる単位でデータを分割します。
モデルの検証スコアが95%なのに本番環境で60%になる場合、通常は分割方法に問題があります。隣接する動画フレームや同じ日の同じカメラの画像など、ほぼ同一の画像が両方に入り、検証では汎化性能ではなく記憶を評価していたのです。実際のデータセットでまさにこのリークを見つけ、グループ分割で修正します。
実践的
3つの分割とそれぞれの役割#
| 分割 | 何に使うか | いつ確認するか |
|---|---|---|
| 学習用 | モデル:ここで重みを適合させます | 各エポック |
| 検証 | あなたが指定するもの:ハイパーパラメーター、エポック数、モデルサイズ、しきい値 | 各実験の後 |
| テスト | 誰も調整しません。最終的な数値を報告するだけです | すべての判断を終えた後に一度だけ |
データ分割を見て行う判断は一つひとつ、情報を少しずつモデルに漏らします。それが検証セットの役割です。テストセットで調整すると、その数値は信頼できる推定値ではなくなります。自分の選択に過剰適合してしまうためです。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なら、2枚の画像は実質的に同じ画像です。val/Frame_24.jpgとtrain/Frame_308.jpgを並べて開くと、同じ角度から撮影された同じブリスターパックが見えます。検証画像23枚のうち17枚には、学習セットに同一画像に近い画像があります。そのため、ここの検証スコアは主に、モデルがすでに見た画像をどれだけ記憶しているかを測っています。簡単なデモ用データセットなら問題ありませんが、これを使って製品をリリースしてはいけません。

同じ落とし穴はどのツールにもあります。このダイアログの説明にも、すべての画像をランダムに再割り当てするとあります。そのため、画像が互いに独立した写真であれば最適ですが、動画のフレームでは情報がリークします。まず近似重複を確認し、誰も調整に使っていない最終スコアが必要なら、テストセットの割合を0%より大きくしてください。
グループ単位で分割する#
ルール:デプロイ時に新たに遭遇する単位で、各分割を独立させる必要があります。
| データ | 分割単位 |
|---|---|
| 動画 | クリップまたは録画セッション |
| 固定カメラ群 | カメラまたは場所 |
| 時間とともに変化するシーン | 日または週 |
| 医療、スポーツ、または人物データ | 患者、アスリート、または被験者 |
| 連続撮影した写真 | 撮影イベント |
ファイル名にはグループキーが含まれていることがよくあります。この例では、6台のカメラで撮影した900枚の画像を用意し、2つの方法を比較します。
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がない、または2個のインスタンスだけでAPが算出されています。対処法: グループ分割後、各分割に含まれるクラス数を数え、すべてのクラスがほぼ同じ割合で現れるまでグループ単位で入れ替えます。sklearn.model_selection.StratifiedGroupKFoldがこれを自動化します。
症状: 実験を重ねるたびにテストの数値が上がっていきます。対処法: テストセットを固定し、本番に近い条件からサンプルを抽出して、完了した実験ごとに1回だけ結果を報告します。小規模なデータセットでは、同じテストセットを繰り返し使う代わりに、k分割交差検証で複数の分割の平均を取ります。
データセットを正しく分割するでは、データセット全体に対する割合の設定と、これらすべてのルールの適用方法を説明しています。
解答を表示
グループキーを抽出する関数を使えば、どのファイル名リストにも対応できるリークチェックです。
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画像を一方の分割に移し、[]が出力されるまで再実行してください。医薬品データセットのように、1本の動画のフレームが同じ視点を繰り返し捉えている場合、ファイル名から明確なグループキーを特定できません。異なるパックを撮影した新しい検証用動画を用意するか、近似重複指紋で画像をクラスタリングし、クラスタ全体を同じ分割に割り当ててください。