Ultralytics YOLO27: ウェイトリストに登録する。
Get Started
高性能YOLOデータセットの構築·準備する·レッスン 6/10
レッスン初級

データセットを正しく分割する

ランダム分割によるデータ漏洩を測定し、スクリプトでクリップ、カメラ、日付などのグループ全体を分割して、クラスの網羅性を確認し、テストセットを固定します。

不注意な分割では、信頼している指標が実態を反映しなくなります。隣接する動画フレームがtrainとvalの両方に入ると、val mAPは汎化性能ではなく、記憶力を測定することになり、その差は本番環境で初めて明らかになります。モデルについて報告する数値はすべてこの分割に左右されるため、一度慎重に決めて固定してください。

成果

スクリプトでデータセットをグループ単位に分割し、グループが分割境界をまたがないことを確認して、最終的な実施可否の判断に一度だけ使うテストセットを固定します。

短期集中コース
すでに使い方をご存じの方は、こちらをご覧ください。
  1. train/val/testを70/15/15から始め、データセットのサイズに応じて調整します。

  2. 相関のある個々のフレームではなく、クリップ、カメラ、日付、サイト、顧客などのグループ全体を分割します。

  3. すべてのクラスが各分割に含まれていることを確認し、不足がある場合はフレームを移動せず、グループを追加して解消します。

  4. valで調整し、最終的な実施可否の判断のためにテストを使うのは一度だけにします。

実践的

3つの分割とそれぞれの役割#

分割一般的な割合用途
Train70%モデルがここから重みを学習します
Val15%最良のエポックを選び、実行を比較して、設定を調整します
Test15%受け入れテストに基づき、最終的な実施可否を一度だけ判断します

Ultralyticsのガイドでは、trainを70~80%、valとtestをそれぞれ10~20%としています。CVプロジェクトの手順ではそれぞれ10~15%を提案し、前処理ガイドでは70/20/10を採用しています。画像だけでなくオブジェクト単位でも割合を数えてください。密集した画像1枚に、疎な画像10枚分より多くのラベルが含まれる場合があるためです。正確な割合よりも、それぞれの分割を何に使うかがはるかに重要です。

相関のあるデータではランダム分割によって漏洩が生じます#

同じクリップのフレーム124と125はほぼ同一です。一方がtrain、もう一方がvalに入ると、モデルが実質的にすでに見た画像でvalの評価を行うことになります。高品質で代表性のあるデータを収集するの練習用セットで測定してください。このセットのファイル名は、カメラと日付のグループキーで始まります。

import random
from pathlib import Path

images = sorted(Path("datasets/practice/images").glob("*.jpg"))
random.Random(0).shuffle(images)
train, val = images[:90], images[90:109]  # a random per-image 70/15 split

def group(image):
    return image.stem.rsplit("_", 1)[0]

shared = {group(p) for p in train} & {group(p) for p in val}
print(f"{len(shared)} of {len({group(p) for p in val})} val groups also appear in train")
13 of 13 val groups also appear in train

val内のすべてのカメラ・日付の組み合わせがtrainにも含まれています。実際の映像では、valのフレームがtrainのフレームと同じ照明や背景を共有し、同一のオブジェクトが含まれることもよくあります。そのためval mAPは高く見えても、本番環境のmAPはそうなりません。

分割単位を選ぶ#

デプロイ時に必要な汎化性能に合ったグループを選びます。

迷ったときは、許容できる範囲で最も大きな単位で分割してください。最も信頼できる数値が得られます。画像ごとのランダム分割が安全なのは、数週間の間隔を空けて撮影した製品写真のように、すべての画像が独立して撮影された場合に限られます。

コードでグループ単位に分割する#

このスクリプトは、各分割が70/15/15の割合に達するまでグループ全体を割り当て、データYAMLで使用できるファイルリストを書き出し、クラスの網羅性を報告します。

import random
from collections import Counter, defaultdict
from pathlib import Path

root = Path("datasets/practice")  # contains images/ and labels/

def group(image):
    return image.stem.rsplit("_", 1)[0]  # "cam2_2026-05-01_000124" -> "cam2_2026-05-01"

groups = defaultdict(list)
for image in sorted((root / "images").glob("*.jpg")):
    groups[group(image)].append(image)

keys = sorted(groups)
random.Random(0).shuffle(keys)  # fixed seed: the same split on every run
total, assigned = sum(len(v) for v in groups.values()), 0
splits = {"train": [], "val": [], "test": []}
for key in keys:  # assign whole groups until each split reaches its 70/15/15 share
    share = assigned / total
    splits["train" if share < 0.70 else "val" if share < 0.85 else "test"] += groups[key]
    assigned += len(groups[key])

counts = {}
for split, images in splits.items():
    (root / f"{split}.txt").write_text("".join(f"./images/{p.name}\n" for p in images))
    labels = [root / "labels" / f"{p.stem}.txt" for p in images]
    counts[split] = Counter(int(r.split()[0]) for f in labels if f.exists() for r in f.read_text().splitlines() if r.strip())

all_classes = set().union(*counts.values())
for split, images in splits.items():
    missing = len(all_classes - set(counts[split]))
    print(f"{split:>5}: {len(images):3d} images, {len({group(p) for p in images}):2d} groups, {missing} of {len(all_classes)} classes missing")
train:  90 images, 11 groups, 5 of 71 classes missing
  val:  20 images,  2 groups, 36 of 71 classes missing
 test:  18 images,  3 groups, 41 of 71 classes missing

結果の読み方:

  • 構成上、グループが分割をまたぐことはありません。これで漏洩を防げます。
  • valには2つのグループしかなく、36クラスが含まれないため、それらのAPは測定できません。128枚の画像に71クラスがある場合、シードを変えても解決しません。本当の解決策はグループ(カメラや日付)を増やすことです。これを実現するのが、1クラスあたり1,500枚以上の画像を含む本番用データセットです。
  • trainには5クラスが含まれていません。これらのクラスはvalまたはtestにしかないため、モデルはこの分割から学習できません。

判断基準は、ある分割にクラスが含まれていない場合、単一フレームではなく、そのクラスを含むグループ全体を追加または移動することです。単一フレームを動かすと漏洩が再発します。データYAMLのtrain、val、testキーを、3つの.txtファイルに設定してください。ファイルの残りの部分については、データセットYAMLを書くを参照してください。

分割間でクラスのバランスを取る#

分類では、scikit-learnのtrain_test_split(..., stratify=labels)を使うと、1つの引数で分割間のクラス比率を均等にできます。検出画像には複数のクラスが含まれるため、代わりにグループ単位でバランスを取ります。group_split.pyの実行後、分割ごとのクラス数を比較し、各クラスが期待される割合の約±20%以内に収まるまでグループ全体を入れ替えます。

Ultralytics PlatformのRedistribute Splitsダイアログ。100枚の画像をtrain、validation、testにランダムに再割り当てし、分割を80/20/0に設定して、Autoボタンを表示

Ultralytics PlatformのRedistribute Splitsは画像をランダムに再割り当てし、Autoは推奨される80/20のtrain/val分割を設定します(分割のドキュメント)。処理中に完全一致する重複画像は削除されますが、グループは認識されません。そのため、独立した撮影画像に使用し、動画や複数カメラのデータではグループ単位で分割してアップロードしてください。

2つのvalセットを別々に追跡する#

安定したキュレーション済みの回帰テストセットと、新たに収集したフレームに応じて更新される本番環境に即したセットの2つを、valセットとして使うことがよくあります。データYAMLには複数のvalフォルダーを指定できますが、1つのスコアを出す単一のvalセットに統合されます。

path: ./datasets/forklift
train: images/train
val:
  - images/val_curated      # stable, your regression suite
  - images/val_production   # rotates with newly collected frames
test: images/test

それぞれのスコアを確認するには、valセットごとにデータYAMLを1つ用意し(trainとnamesは同じで、valは異なります)、学習済みの重みをそれぞれに対して検証します。

from ultralytics import YOLO

model = YOLO("runs/detect/train/weights/best.pt")  # your fine-tuned checkpoint
for data in ("val_curated.yaml", "val_production.yaml"):
    metrics = model.val(data=data)
    print(f"{data:<20} mAP50-95={metrics.box.map:.3f}")

キュレーション済みセットでの低下は回帰、本番セットでの低下はドメインシフトを示します。

テストセットを固定する#

テストセットで実行を比較すると、テストセットが学習データになる

分割に関する最も重大な誤りは、テストセットで10種類のモデルを比較することです。テスト結果が何らかの判断に影響した時点で、最終的な数値は楽観的になります。すべての調整と実行の比較は検証データで行い、受け入れテストは最後に1回だけテストデータで実行します。

分割は後戻りできない決定

公開する指標はすべて、この分割に基づきます。後から再シャッフルすると数値がすべて変わり、過去と現在の実行を比較できなくなります。分割単位、シード、3つのファイルリストをデータセットと一緒に保管します。

小規模データセット向けのK分割交差検証#

K分割交差検証:学習セット全体を5つのfoldに分割し、各foldを順番にテスト用foldとして使い、残りのfoldで学習します。5つのfoldのスコアを平均します

データセットが小さく(クラスあたり約500枚未満)、単一の分割では統計的な信頼性が不十分に感じられる場合、K分割交差検証では分割を入れ替えながらk個のモデルを学習し、結果を平均します。計算量はk倍になりますが、より安定した推定値が得られます。同じ方法でグループからfoldを作成してください。そうしないと、すべてのfoldで漏洩が生じます。

試してみる

プロジェクトの分割単位を選び、その理由を1文で説明してください。各ファイル名がグループキーで始まるように画像名を変更するか、対応付けを行い、group_split.pyを実行して、出力されるクラスの網羅性を確認してください。成果物として、3つの.txtリスト、網羅性の出力、1文の根拠をデータセットとともに保存します。

完了条件
以下のすべてを満たすと、レッスンは完了です。
  • 分割単位が選ばれ、1文で根拠が説明されています。

  • スクリプトにより、同じグループキーが複数の分割に現れないことが確認されています。

  • すべてのクラスがtrain、val、testに含まれているか、不足がある場合はデータ収集計画が用意されています。

解答を表示

新しい日付のデータでも稼働し続ける必要がある固定式の倉庫カメラでは、カメラと日付の組み合わせで分割します:cam2_2026-05-01。group_split.pyの実行後、グループが分割境界をまたいでいないことを確認します。

from pathlib import Path

root = Path("datasets/practice")
groups = {s: {Path(p).stem.rsplit("_", 1)[0] for p in (root / f"{s}.txt").read_text().split()} for s in ("train", "val", "test")}
print("train & val: ", groups["train"] & groups["val"] or "none")
print("train & test:", groups["train"] & groups["test"] or "none")
print("val & test:  ", groups["val"] & groups["test"] or "none")
train & val:  none
train & test: none
val & test:   none

練習用の分割は漏洩チェックに合格していますが、網羅性チェックには不合格です。valには71クラス中36クラスが含まれていません。必要なのは再シャッフルではなく、データ収集計画です。valとtest用に少なくとも2つのカメラ・日付の組み合わせを追加で撮影し、それぞれ仕様で特に重視されるクラスを網羅してから、両方のスクリプトを再実行してください。

次のステップ

信頼できる分割ができたら、次のレッスンでは、実現不可能な画像を作り出さずにデプロイ環境に合わせて拡張を調整します。