Ultralytics YOLO27: ウェイトリストに登録する。
Get Started
コンピュータービジョンの基礎·指標を読む·レッスン 8/10
レッスン初級

実態を反映するデータ分割

学習用と検証用にまたがってリークしている近似重複画像を見つけ、デプロイ時に新たに得られる単位でデータを分割します。

モデルの検証スコアが95%なのに本番環境で60%になる場合、通常は分割方法に問題があります。隣接する動画フレームや同じ日の同じカメラの画像など、ほぼ同一の画像が両方に入り、検証では汎化性能ではなく記憶を評価していたのです。実際のデータセットでまさにこのリークを見つけ、グループ分割で修正します。

成果

分割間の近似重複画像を検出し、グループキーで分割して、各グループが複数の分割に含まれていないことを検証します。

短期集中コース
すでに使い方をご存じの方は、こちらをご覧ください。
  1. 学習用データは重みの学習に使い、検証用データは判断の指針にし、テスト用データは最終評価を1回だけ行います。

  2. 検証スコアを信頼する前に、分割間にほぼ同一の画像がないか確認します。

  3. グループキーを特定します。クリップ、カメラ、日付、または被写体です。

  4. 画像ではなくグループをシャッフルし、すべての分割に各クラスが含まれていることを確認します。

  5. テストセットを固定し、実験が完了するたびに1回だけ使用します。

実践的

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.8

0~255のスケールで差が0.3なら、2枚の画像は実質的に同じ画像です。val/Frame_24.jpgとtrain/Frame_308.jpgを並べて開くと、同じ角度から撮影された同じブリスターパックが見えます。検証画像23枚のうち17枚には、学習セットに同一画像に近い画像があります。そのため、ここの検証スコアは主に、モデルがすでに見た画像をどれだけ記憶しているかを測っています。簡単なデモ用データセットなら問題ありませんが、これを使って製品をリリースしてはいけません。

医薬品データセットのUltralytics Platform「分割を再配分」ダイアログ。学習80%、検証20%、テスト0%に設定されています

同じ落とし穴はどのツールにもあります。このダイアログの説明にも、すべての画像をランダムに再割り当てするとあります。そのため、画像が互いに独立した写真であれば最適ですが、動画のフレームでは情報がリークします。まず近似重複を確認し、誰も調整に使っていない最終スコアが必要なら、テストセットの割合を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本の動画のフレームが同じ視点を繰り返し捉えている場合、ファイル名から明確なグループキーを特定できません。異なるパックを撮影した新しい検証用動画を用意するか、近似重複指紋で画像をクラスタリングし、クラスタ全体を同じ分割に割り当ててください。

次のステップ

信頼できる分割ができたら、次はそれによって得られる指標を読み取ります。