0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

第2回では、Tensor、autograd、線形回帰、ロジスティック回帰を通じて、PyTorchでモデルを訓練する基本的な流れを確認しました。

今回は、その前段にあるデータの扱いを整理します。画像ファイルはそのままではモデルに渡せません。どこに画像があり、どのラベルに対応し、どのように読み込み、どのタイミングで前処理やデータ拡張を行い、どの単位でバッチにまとめるのかを決める必要があります。

この記事では、日本円紙幣の画像を二値分類する例を使います。具体的には、千円札と一万円札の画像をローカルに用意し、画像分類モデルへ渡すまでの流れを DatasetDataLoadertorchvision.transforms で組み立てます。

Qiitaの記事にはデータセットそのものを同梱しない前提で進めます。そのため、コードの前に必要なディレクトリ構成、画像の形式、ラベルの置き方を明示します。読者は自分の手元にある画像や、公開可能な別の二値分類データに置き換えて実行できます。

目的は、画像分類の精度を競うことではありません。PyTorchで「ファイル群をDatasetとして表し、DataLoaderでバッチ化し、transformsで前処理とデータ拡張を行う」流れを、自分で説明できるようになることです。

目次

この記事で扱うこと

この記事では、次の内容を扱います。

  • 画像分類データをローカルディレクトリで管理する方法
  • 訓練用、検証用、テスト用データの役割
  • torch.utils.data.Dataset の役割
  • __len__()__getitem__() の実装
  • torch.utils.data.DataLoader の主要引数
  • SamplerBatchSamplercollate_fn の関係
  • torchvision.transforms.v2 による画像前処理
  • Normalize の計算式と統計量の考え方
  • Crop、Flip、Rotation、Pad、ColorJitter、RandomErasing
  • カスタムtransformとしてのごま塩ノイズ
  • 訓練用transformと検証用transformの分離
  • 小さなCNNを使った分類パイプライン
  • 検出、セグメンテーションで画像とラベルを一緒に変換する考え方

第2回と同じく、API名を眺めるだけではなく、どの位置にどのコードを書くのかを意識して進めます。

扱うデータセットの前提

この記事では、千円札と一万円札の画像を二値分類する例を使います。画像はQiitaには載せず、手元のPCに次のような構成で置いてあるものとします。

yen_images/
  raw/
    1000_yen/
      img_0001.jpg
      img_0002.jpg
      ...
    10000_yen/
      img_0001.jpg
      img_0002.jpg
      ...

前提は次の通りです。

項目 内容
タスク 千円札と一万円札の二値分類
入力 ローカルに保存した画像ファイル
画像形式 .jpg.jpeg.png.bmp
読み込み後の色 RGB 3チャンネル
画像サイズ 元画像はそろっていなくてよい
クラス名 1000_yen10000_yen
ラベル 1000_yen -> 010000_yen -> 1

紙幣画像では、同じ紙幣を角度だけ変えて何枚も撮影している場合があります。そのような近い画像があるときは、訓練、検証、テストにばらばらに混ぜると、実力よりも良い評価に見えることがあります。可能であれば、撮影した紙幣の個体や撮影セッション単位で分けるほうが安全です。

この記事のコードでは、クラスごとにランダムに分割する最小構成を示します。実務では、データの集まり方に合わせて分割単位を決めてください。

画像分類のデータ処理フロー

データセットの前提を確認できたところで、次はモデルに渡すまでの全体的な処理の流れを整理します。画像分類では、モデルを書く前に次の流れを作ります。

ローカル画像
  -> データ確認
  -> train / valid / test に分割
  -> Dataset: パスと __getitem__ を定義
  -> transforms: 前処理とデータ拡張
  -> Sampler / BatchSampler: index と順序を決定
  -> DataLoader worker: 画像を読み込む
  -> collate_fn: サンプルをbatchにまとめる
  -> モデルの訓練または評価

役割を分けて考えると、PyTorchのデータ処理はかなり見通しがよくなります。

要素 主な役割
Dataset 1つのindexから1サンプルを返す
transform 読み込んだ画像を変換する
Sampler どのindexをどの順序で読むかを決める
DataLoader サンプルをまとめて反復可能にする
collate_fn サンプルのリストをbatchに組み立てる

訓練データはモデルのパラメータを更新するために使います。検証データはモデル選択やハイパーパラメータ調整に使います。テストデータは最後の性能確認に使い、途中の調整に何度も使わないようにします。

データをtrain、valid、testに分ける

まず、yen_images/raw に置いた画像を split 配下へ分割します。

実行後の構成は次のようになります。

yen_images/
  split/
    train/
      1000_yen/
      10000_yen/
    valid/
      1000_yen/
      10000_yen/
    test/
      1000_yen/
      10000_yen/

このコードは、画像ファイルをコピーして分割します。元画像を移動しないので、やり直しやすい構成です。

from pathlib import Path
import random
import shutil


IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp"}
CLASS_NAMES = ["1000_yen", "10000_yen"]


def collect_images(class_dir: Path) -> list[Path]:
    return sorted(
        path for path in class_dir.iterdir()
        if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
    )


def split_image_dataset(
    source_root: Path,
    output_root: Path,
    train_ratio: float = 0.8,
    valid_ratio: float = 0.1,
    seed: int = 42,
) -> None:
    if not 0 < train_ratio < 1:
        raise ValueError("train_ratio must be between 0 and 1")
    if not 0 <= valid_ratio < 1:
        raise ValueError("valid_ratio must be between 0 and 1")
    if train_ratio + valid_ratio >= 1:
        raise ValueError("train_ratio + valid_ratio must be smaller than 1")

    random_generator = random.Random(seed)

    for class_name in CLASS_NAMES:
        image_paths = collect_images(source_root / class_name)
        random_generator.shuffle(image_paths)

        train_end = int(len(image_paths) * train_ratio)
        valid_end = int(len(image_paths) * (train_ratio + valid_ratio))

        splits = {
            "train": image_paths[:train_end],
            "valid": image_paths[train_end:valid_end],
            "test": image_paths[valid_end:],
        }

        for split_name, paths in splits.items():
            target_dir = output_root / split_name / class_name
            target_dir.mkdir(parents=True, exist_ok=True)

            for source_path in paths:
                shutil.copy2(source_path, target_dir / source_path.name)

        print(
            f"{class_name}: "
            f"train={len(splits['train'])}, "
            f"valid={len(splits['valid'])}, "
            f"test={len(splits['test'])}"
        )
        # 出力例(画像枚数は環境によって変わります。ここでは各クラス50枚の場合):
        # 1000_yen: train=40, valid=5, test=5
        # 10000_yen: train=40, valid=5, test=5


if __name__ == "__main__":
    split_image_dataset(
        source_root=Path("yen_images/raw"),
        output_root=Path("yen_images/split"),
    )

この分割はクラスごとの枚数比を保ちやすい一方で、同じ紙幣を連写した画像が別の集合に入る可能性があります。重複に近い画像が多い場合は、ファイル名や撮影単位を使ってグループ単位で分けます。

Datasetとは何か

画像をtrain、valid、testに分けられたら、次はそれをPyTorchでどう読み込むかを考えます。Dataset は、「データがどこにあり、1つのindexを受け取ったときに何を返すか」を決めるクラスです。

画像分類では、Dataset が次の処理を担当することが多いです。

  • 画像ファイルのパスを集める
  • クラス名を数値ラベルへ対応づける
  • __len__() でサンプル数を返す
  • __getitem__() で1枚の画像とラベルを返す
  • 必要であれば、画像にtransformを適用する

最小形は次のようになります。

from torch.utils.data import Dataset


class MyDataset(Dataset):
    def __init__(self, samples, transform=None):
        self.samples = samples
        self.transform = transform

    def __len__(self):
        return len(self.samples)

    def __getitem__(self, index):
        image, label = self.samples[index]
        if self.transform is not None:
            image = self.transform(image)
        return image, label

PyTorchの一般的な画像分類では、indexでサンプルを取り出す map-style Dataset を使うことが多いです。一方、ログやストリーミングデータのように、ランダムアクセスしにくいデータでは IterableDataset が使われます。

map-style Dataset では、通常 __getitem__()__len__() の両方を実装します。DataLoaderSamplerlen(dataloader)__len__() に依存する場面が多いためです。

日本円紙幣画像用のDatasetを作る

ここでは、先ほど作った yen_images/split/trainyen_images/split/valid を読み込む Dataset を作ります。

コードの前提は次の通りです。

  • 各クラスはディレクトリ名で表す
  • 1000_yen をラベル 010000_yen をラベル 1 にする
  • 画像はPillowで開き、必ずRGBに変換する
  • 画像サイズの統一やTensor化はtransform側で行う
from pathlib import Path
from PIL import Image
from torch.utils.data import Dataset


IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp"}
CLASS_NAMES = ["1000_yen", "10000_yen"]


class BanknoteDataset(Dataset):
    def __init__(self, root, transform=None):
        self.root = Path(root)
        self.transform = transform
        self.class_to_index = {name: index for index, name in enumerate(CLASS_NAMES)}
        self.samples = []  # (画像パス, ラベル) のタプルを集めるリスト

        for class_name in CLASS_NAMES:
            class_dir = self.root / class_name
            if not class_dir.exists():
                raise FileNotFoundError(f"class directory not found: {class_dir}")

            for path in sorted(class_dir.iterdir()):  # 実行環境によらず順序を固定する
                if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS:
                    self.samples.append((path, self.class_to_index[class_name]))

        if not self.samples:
            raise RuntimeError(f"no image files found under: {self.root}")

    def __len__(self):
        return len(self.samples)  # DataLoaderやSamplerが参照するサンプル数

    def __getitem__(self, index):
        image_path, label = self.samples[index]  # indexからパスとラベルを取り出す
        with Image.open(image_path) as image:
            image = image.convert("RGB")  # グレースケールやRGBAが混ざっていても3チャンネルにそろえる

        if self.transform is not None:
            image = self.transform(image)  # ここでtransformを適用してからTensorを返す

        return image, label

Image.open() はファイルを開いた状態のオブジェクトを返します。with の中で convert("RGB") まで済ませておくと、ファイルハンドルを長く持ち続けずに済みます。また、グレースケールやRGBA画像が混ざっていても、モデル入力を3チャンネルにそろえられます。

Dataset が返すのは、ここでは (image, label) です。検出やセグメンテーションでは、画像に加えて境界ボックス、マスク、画像IDなどを辞書で返すこともあります。

DataLoaderとは何か

Dataset で1サンプルずつ取り出せるようになったところで、次はそれをbatchにまとめて反復処理する DataLoader を見ていきます。DataLoader は、Dataset を反復可能なオブジェクトに変えます。1サンプルずつではなく、複数サンプルをまとめたbatchとして取り出せます。

import torch
from torch.utils.data import DataLoader
from torchvision.transforms import v2


loader_transform = v2.Compose([
    v2.ToImage(),
    v2.Resize((224, 224), antialias=True),
    v2.ToDtype(torch.float32, scale=True),
])

train_dataset = BanknoteDataset("yen_images/split/train", transform=loader_transform)

train_loader = DataLoader(
    train_dataset,
    batch_size=32,
    shuffle=True,
    num_workers=0,
    pin_memory=torch.cuda.is_available(),
    drop_last=True,
)

for images, labels in train_loader:
    print(images.shape)
    # torch.Size([32, 3, 224, 224])
    print(labels.shape)
    # torch.Size([32])
    break

batch_size=32 で画像を (224, 224) にResizeしているため、images.shapetorch.Size([32, 3, 224, 224])labels.shapetorch.Size([32]) になります。1サンプルずつ扱っていた Dataset の出力が、DataLoader を通すことで先頭にbatch次元が付いたTensorへまとまることがわかります。

ここではbatch化できるように、最小限のResizeとTensor化だけを先に入れています。Normalize やランダムなデータ拡張は、この後のtransformの節で整理します。

DataLoaderの主要引数

DataLoader でよく使う引数を整理します。

引数 意味 よくある使い方
dataset 読み込むDataset 訓練用、検証用などを渡す
batch_size 1batchあたりのサンプル数 画像分類では16、32、64などから試す
shuffle 各epochで順序を混ぜるか 訓練はTrue、検証とテストはFalse
sampler indexの出し方を指定する 不均衡データや分散訓練で使う
batch_sampler batch単位のindex列を指定する 特殊なbatch構成で使う
num_workers データ読み込みに使うプロセス数 まず0で動作確認する
collate_fn サンプルをbatchにまとめる関数 サイズが違う画像や検出タスクで使う
pin_memory CPU側のメモリ転送を補助する CUDAへ送るときに効果が出る場合がある
drop_last 最後の小さいbatchを捨てるか 訓練で固定batchにしたい場合に使う
worker_init_fn worker起動時の処理 追加の乱数種設定などで使う
generator 乱数生成器 再現性を高めたいときに使う
prefetch_factor workerごとの先読み数 num_workers > 0 のときに検討する
persistent_workers epoch後もworkerを残すか worker起動の負担を減らしたいときに使う

shuffle=True と明示的な sampler は同時に指定しません。どちらもindexの順序を決める役割を持つためです。

WindowsやmacOSで num_workers を増やす場合、訓練開始処理は次の形に入れておくと安定します。

def main():
    ...


if __name__ == "__main__":
    main()

Datasetや collate_fn に、pickleできないオブジェクトを入れるとworker起動時に失敗することがあります。デバッグ時はまず num_workers=0 にすると、例外の場所が追いやすくなります。

DataLoaderの内部で起きていること

主要引数の意味を押さえたところで、次はその引数が内部でどう使われているかを見ていきます。DataLoader の内部では、大まかに次の順番で処理が進みます。

DataLoader
  -> Sampler または BatchSampler が index を作る
  -> DataLoader iterator が index を worker に渡す
  -> DatasetFetcher が Dataset[index] を呼ぶ
  -> Dataset.__getitem__ で画像とラベルを読む
  -> transform を適用する
  -> collate_fn がサンプルのリストをbatchにする
  -> 必要に応じてpin memoryを使う
  -> batchを返す

内部要素を表にすると、役割の違いがはっきりします。

要素 入力 出力 役割
Sampler Datasetの長さなど 1つのindex サンプル順序を決める
BatchSampler indexの流れ indexのリスト batch用のindexを作る
DatasetFetcher index サンプル Datasetを呼び出す
Dataset index (image, label) データの読み方を決める
transform image 変換後のimage 前処理とデータ拡張を行う
collate_fn サンプルのリスト batch Tensorをstackする、または独自にまとめる

デフォルトの collate_fn は、同じ形のTensorであればおおむね次のようにまとめます。

images = torch.stack([sample[0] for sample in batch])
labels = torch.tensor([sample[1] for sample in batch])

画像サイズが違うままの場合、torch.stack ができません。そのため、画像分類では ResizeCrop で形をそろえてからbatch化するのが基本です。

検出タスクのように画像ごとに境界ボックス数が違う場合は、次のような collate_fn を使って、無理にTensorへstackせずリストのまま返します。

def detection_collate(batch):
    images, targets = zip(*batch)
    return list(images), list(targets)

Epoch、Iteration、Batch size

DataLoaderの内部の流れを確認できたところで、次は訓練ログでよく目にする3つの用語を整理しておきます。訓練ログを読むために、次の3つを区別します。

用語 意味
Epoch 訓練データ全体を1回見ること
Iteration / Step 1batchを処理し、通常1回パラメータ更新すること
Batch size 1batchに入れるサンプル数

長さが N のDatasetでは、iteration数は次のように決まります。

drop_last=False: iterations = ceil(N / batch_size)
drop_last=True : iterations = floor(N / batch_size)

たとえばサンプル数が87、batch sizeが8なら、次の通りです。

drop_last=False: 8+8+8+8+8+8+8+8+8+8+7 = 11 iterations
drop_last=True : 8+8+8+8+8+8+8+8+8+8   = 10 iterations

検証やテストでは、通常 drop_last=False にします。最後の7枚を捨ててしまうと、評価対象が変わるためです。

torchvision.transformsで画像を前処理する

DataLoaderがbatchを作る仕組みを確認できたところで、次はDatasetの中で使っていたtransformの中身を詳しく見ていきます。torchvision.transforms は、画像の前処理やデータ拡張を行うための機能です。この記事では torchvision.transforms.v2 の書き方を使います。

よく使う処理には、次のようなものがあります。

  • 画像サイズの変更
  • 中央Crop、ランダムCrop
  • 回転、平行移動、拡大縮小、せん断
  • 明るさ、コントラスト、彩度、色相の変更
  • Tensor化とdtype変換
  • 値域のスケーリング
  • Normalize による標準化
  • ランダム遮蔽
  • カスタムノイズ

Pillowで読んだRGB画像を、モデルへ渡せるTensorにする最小例は次の通りです。

import torch
from torchvision.transforms import v2


basic_transform = v2.Compose([
    v2.ToImage(),
    v2.Resize((224, 224), antialias=True),
    v2.ToDtype(torch.float32, scale=True),
])

このtransformを Dataset に渡すと、__getitem__() が返す画像は (C, H, W) のTensorになります。

dataset = BanknoteDataset("yen_images/split/train", transform=basic_transform)

image, label = dataset[0]
print(image.shape)
# torch.Size([3, 224, 224])
print(image.dtype)
# torch.float32
print(image.min().item(), image.max().item())
# 出力例(値は元画像の内容によって変わります): 0.00392156862745098 0.9921568627450981

image.shapetorch.Size([3, 224, 224])dtypetorch.float32 になります。min()max() の値は元画像の内容によって変わりますが、ToDtype(..., scale=True) によって必ず 0.0 以上 1.0 以下の範囲に収まります。

画像Tensorでは、単体の画像は (C, H, W)、batchでは (N, C, H, W) がよく使われます。N はbatch size、C はチャンネル数、H は高さ、W は幅です。

Normalizeの意味と使い方

Normalize(mean, std) は、チャンネルごとに次の計算をします。

output[c] = (input[c] - mean[c]) / std[c]

RGB画像なら、概念的には次のように処理されます。

R' = (R - mean_R) / std_R
G' = (G - mean_G) / std_G
B' = (B - mean_B) / std_B

処理の段階ごとのdtypeと値域を整理します。

段階 dtype 典型的な値域 注意点
画像読み込み直後 uint8 0..255 まだTensorとは限らない
ToDtype(float32, scale=True) float32 0..1 mean/stdもこの値域に合わせる
Normalize float32 固定範囲はない 0は元のチャンネル平均に対応する

Normalize は平均や標準偏差を自動計算しません。自分で決めた meanstd を使って変換するだけです。また、BatchNormとは別の処理です。

学習済み重みを使う場合は、その重みが指定する前処理に合わせます。自分で小さなモデルを最初から訓練する場合は、訓練データだけからチャンネルごとの平均と標準偏差を見積もる方法があります。

以下は、訓練データから meanstd を計算する例です。ここでは、Normalize をまだ入れていないtransformを使います。

import torch
from torch.utils.data import DataLoader
from torchvision.transforms import v2


stats_transform = v2.Compose([
    v2.ToImage(),
    v2.Resize((224, 224), antialias=True),
    v2.ToDtype(torch.float32, scale=True),
])


def compute_mean_std(dataset, batch_size=64):
    loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0)
    channel_sum = torch.zeros(3)          # チャンネルごとの画素値の合計
    channel_squared_sum = torch.zeros(3)  # チャンネルごとの画素値の2乗の合計
    pixel_count = 0                       # 1チャンネルあたりの画素数の合計

    for images, _ in loader:
        channel_sum += images.sum(dim=(0, 2, 3))                # batch, 高さ, 幅方向に合計する
        channel_squared_sum += (images ** 2).sum(dim=(0, 2, 3))
        pixel_count += images.shape[0] * images.shape[2] * images.shape[3]

    mean = channel_sum / pixel_count
    variance = (channel_squared_sum / pixel_count - mean ** 2).clamp_min(0)  # E[X^2] - E[X]^2。丸め誤差での負値を防ぐ
    std = variance.sqrt()
    return mean.tolist(), std.tolist()


stats_dataset = BanknoteDataset("yen_images/split/train", transform=stats_transform)
mean, std = compute_mean_std(stats_dataset)

print(mean)
# 出力例(値は使用する画像データによって変わります): [0.5005499124526978, 0.4998324513435364, 0.5008472204208374]
print(std)
# 出力例(値は使用する画像データによって変わります): [0.1450541615486145, 0.14825528860092163, 0.14333893358707428]

検証データやテストデータを使って統計量を決めると、評価用データの情報が訓練側に漏れます。統計量を計算する場合は、訓練データだけを使います。

Normalize後の画像を表示したいときは、反対の計算をします。

def denormalize(image, mean, std):
    mean = torch.as_tensor(mean, dtype=image.dtype, device=image.device)[:, None, None]
    std = torch.as_tensor(std, dtype=image.dtype, device=image.device)[:, None, None]
    return image * std + mean

Normalizedenormalize は逆の関係にあります。表示や保存のために画像を人が見てわかる値域へ戻したいときは、この関数を使います。

データ拡張の考え方

Normalizeで値の分布を整えられるようになったところで、次は入力そのものにバリエーションを持たせるデータ拡張を見ていきます。データ拡張は、ラベルの意味を保ったまま入力に変化を加える方法です。うまく使うと、訓練データだけに過剰に合わせることを抑えやすくなります。

ただし、変化が大きければよいわけではありません。日本円紙幣の分類では、次のように考えます。

変化 使える可能性 注意点
少しの回転 高い 撮影時の傾きを表しやすい
少しの平行移動 高い 紙幣全体が見える範囲にする
明るさやコントラスト 高い 照明差を表せる
小さなCrop 条件つき 金額や特徴的な図柄を消しすぎない
左右反転 慎重に判断 文字や肖像の左右が反転する
上下反転 慎重に判断 実際の入力で倒置があるか確認する
強い色相変更 低い 紙幣らしさを壊しやすい
大きな遮蔽 条件つき 重要部分を隠すとラベルが曖昧になる

検証用とテスト用には、通常ランダムな拡張を入れません。評価のたびに入力が変わると、モデル同士を安定して比較しにくくなるためです。

Crop系の変換

Cropは、画像の一部を切り出す変換です。画像分類では、モデル入力のサイズをそろえるためにもよく使います。

CenterCrop

CenterCrop(size) は中心から指定サイズを切り出します。検証やテストのように、毎回同じ処理をしたい場面で使いやすいです。

from torchvision.transforms import v2


center_crop = v2.Compose([
    v2.ToImage(),
    v2.Resize(256, antialias=True),
    v2.CenterCrop((224, 224)),
])

RandomCrop

RandomCrop は、指定したサイズをランダムな位置から切り出します。padding を指定すると、切り出し前に周囲を埋められます。

random_crop = v2.RandomCrop(
    size=(224, 224),
    padding=16,
    pad_if_needed=True,
    fill=0,
    padding_mode="constant",
)

padding_mode には、constantedgereflectsymmetric などがあります。1次元の例で見ると、違いは次のようになります。

mode 左右に2つずつ足す例 境界値の扱い
constant [0,0,1,2,3,4,0,0] fill を使う
edge [1,1,1,2,3,4,4,4] 端の値を繰り返す
reflect [3,2,1,2,3,4,3,2] 境界を重複させず反射する
symmetric [2,1,1,2,3,4,4,3] 境界を含めて反射する

RandomResizedCrop

RandomResizedCrop は、ランダムな領域を切り出し、その後に指定サイズへリサイズします。

from torchvision.transforms import InterpolationMode


random_resized_crop = v2.RandomResizedCrop(
    size=(224, 224),
    scale=(0.7, 1.0),
    ratio=(0.9, 1.1),
    interpolation=InterpolationMode.BILINEAR,
    antialias=True,
)

流れは次の通りです。

元画像
  -> scaleから切り出し面積を決める
  -> ratioから幅と高さの比率を決める
  -> 元画像内で位置を決める
  -> 切り出す
  -> interpolationで固定サイズへResizeする

scale は切り出し領域の面積比率です。RandomAffinescale は線形の拡大縮小率なので、同じ名前でも意味が異なります。

FiveCropとTenCrop

FiveCrop は、左上、右上、左下、右下、中心の5枚を返します。

+-----------+-----------+
| top-left  | top-right |
|           |           |
+-----+ center +--------+
| bottom-left | bottom-right
+-------------+---------+

TenCrop は、元画像の5枚に加えて、反転した画像からさらに5枚を作ります。出力は1枚のTensorではなく、複数画像のタプルです。そのまま通常の分類モデルに渡すのではなく、複数の予測を平均するなどの処理が必要です。

ten_crop_transform = v2.Compose([
    v2.ToImage(),
    v2.Resize(256, antialias=True),
    v2.TenCrop((224, 224)),
    v2.Lambda(lambda crops: torch.stack([
        v2.ToDtype(torch.float32, scale=True)(crop)
        for crop in crops
    ])),
])

Lambda は短い確認には便利ですが、処理が複雑になる場合は名前付きの関数やクラスにしたほうが読みやすくなります。

反転、回転、Pad、色変換

Cropで画像の一部を切り出す方法を見てきたところで、次は向きや色合いを変える変換を見ていきます。

RandomHorizontalFlipとRandomVerticalFlip

RandomHorizontalFlip(p=0.5) は左右反転、RandomVerticalFlip(p=0.5) は上下反転です。

horizontal_flip = v2.RandomHorizontalFlip(p=0.5)
vertical_flip = v2.RandomVerticalFlip(p=0.5)

紙幣は文字や肖像があるため、左右反転や上下反転を入れるかは慎重に判断します。手元の本番入力でその向きの変化が自然に起きる場合に限って使います。

RandomRotation

RandomRotation は、指定範囲から角度をサンプリングして回転します。

rotation = v2.RandomRotation(
    degrees=(-10, 10),
    interpolation=InterpolationMode.BILINEAR,
    expand=False,
    fill=0,
)

分類用の写真では双線形補間を使うことが多いですが、セグメンテーションのmaskではカテゴリ値を壊さないように最近傍補間を使います。

Pad

Pad は画像の周囲を埋めます。Crop前に余白を足したり、入力サイズを調整したりするときに使います。

pad = v2.Pad(
    padding=(8, 16, 8, 16),
    fill=0,
    padding_mode="constant",
)

padding=(left, top, right, bottom) の順です。画像サイズの引数では (height, width) が多く、paddingでは順序が異なるため、混同しないようにします。

ColorJitter

ColorJitter は、明るさ、コントラスト、彩度、色相をランダムに変えます。

color_jitter = v2.ColorJitter(
    brightness=0.2,
    contrast=0.2,
    saturation=0.1,
    hue=0.02,
)

日本円紙幣の分類では、照明差やカメラ差を表す範囲にとどめます。色相を大きく変えると、実際の紙幣画像から離れすぎる可能性があります。

GrayscaleとRandomGrayscale

Grayscale は確定的にグレースケール化し、RandomGrayscale は確率的にグレースケール化します。

gray_3ch = v2.Grayscale(num_output_channels=3)
random_gray = v2.RandomGrayscale(p=0.1)

3チャンネルのままグレースケール化すると、RGB入力を前提にしたモデルへ渡しやすくなります。

RandomAffine

RandomAffine は、回転、平行移動、拡大縮小、せん断をまとめて扱えます。

affine = v2.RandomAffine(
    degrees=(-8, 8),
    translate=(0.05, 0.05),
    scale=(0.95, 1.05),
    shear=(-3, 3),
    interpolation=InterpolationMode.BILINEAR,
    fill=0,
)

処理のイメージは次の通りです。

入力座標
  -> centerを基準にする
  -> rotation
  -> scale
  -> shear
  -> translation
  -> interpolationで画素値を決める
  -> fillで空白を埋める

反転は RandomAffine の個別引数ではなく、Flip系のtransformで表すと読みやすくなります。

RandomErasingとカスタムノイズ

向きや色合いを変える変換を見てきたところで、次は画像の一部を隠したり乱したりする変換を見ていきます。

RandomErasing

RandomErasing は、Tensor画像の一部をランダムに塗りつぶします。軽い遮蔽に強くしたいときに使えます。

random_erasing = v2.RandomErasing(
    p=0.25,
    scale=(0.02, 0.15),
    ratio=(0.3, 3.3),
    value=0,
    inplace=False,
)

処理の流れは次の通りです。

Tensor画像
  -> pで実行するか決める
  -> scaleで遮蔽面積を決める
  -> ratioで遮蔽領域の幅高さ比を決める
  -> 位置を決める
  -> valueまたはrandom値で埋める

Normalize の後に value=0 で消すと、元の黒ではなく標準化後の0、つまり平均付近の値になります。黒く消したいのか、平均値で消したいのかを意識して置く位置を決めます。

ごま塩ノイズ

ごま塩ノイズ(塩と胡椒を振りかけたように白黒の点が散らばるノイズ)は、ランダムな画素を白または黒に置き換えるノイズです。センサーの欠損や圧縮の荒れを粗く再現したいときに使うことがあります。

ここでは、浮動小数点で [0, 1] の画像Tensorに対して動くクラスを書きます。

import torch


class AddSaltPepperNoise:
    def __init__(
        self,
        noise_probability=0.03,
        apply_probability=0.5,
        salt_value=1.0,
        pepper_value=0.0,
    ):
        if not 0 <= noise_probability <= 1:
            raise ValueError("noise_probability must be in [0, 1]")
        if not 0 <= apply_probability <= 1:
            raise ValueError("apply_probability must be in [0, 1]")

        self.noise_probability = noise_probability
        self.apply_probability = apply_probability
        self.salt_value = salt_value
        self.pepper_value = pepper_value

    def __call__(self, image):
        if torch.rand((), device=image.device) >= self.apply_probability:  # 確率的にノイズを適用しない画像もつくる
            return image

        output = image.clone()  # 元のTensorを直接書き換えない
        mask = torch.rand(image.shape[-2:], device=image.device)  # 画素ごとに1つ乱数を割り当てる
        pepper = mask < self.noise_probability / 2       # 下側q/2はごま(黒)にする
        salt = mask > 1 - self.noise_probability / 2      # 上側q/2は塩(白)にする
        output[..., pepper] = self.pepper_value
        output[..., salt] = self.salt_value
        return output

画素ごとの動きは次のように考えられます。

状態 条件 出力
ごま(黒) u < q / 2 0.0
そのまま q / 2 <= u <= 1 - q / 2 元の画素
塩(白) u > 1 - q / 2 1.0

qnoise_probability です。ラベルが保たれる範囲の弱いノイズにとどめます。紙幣の金額や特徴的な図柄が読めないほど強いノイズは、分類問題そのものを曖昧にします。

Compose、RandomChoice、RandomApply、RandomOrder

個々のtransformを一通り見てきたところで、次はそれらを組み合わせる方法を見ていきます。複数のtransformをつなぐには Compose を使います。

transform = v2.Compose([
    v2.ToImage(),
    v2.Resize((224, 224), antialias=True),
    v2.ToDtype(torch.float32, scale=True),
])

順序は重要です。色変換や空間変換は画像らしい表現の段階で行い、Tensor化と値域変換の後で Normalize を行う、という流れにすると整理しやすくなります。

画像読み込み
  -> 空間変換
  -> 色変換
  -> ToImage
  -> ToDtype(float32, scale=True)
  -> Normalize
  -> 必要ならTensor向けの遮蔽

ランダムな変換の組み合わせには、次のAPIも使えます。

choice = v2.RandomChoice([
    v2.RandomRotation(8),
    v2.ColorJitter(brightness=0.15),
])

apply = v2.RandomApply([
    v2.ColorJitter(brightness=0.2, contrast=0.2),
], p=0.5)

order = v2.RandomOrder([
    v2.RandomRotation(5),
    v2.ColorJitter(brightness=0.1),
])

RandomChoice は候補から1つを選びます。RandomApply は、指定した一連のtransformをまとめて実行するかどうかを確率で決めます。RandomOrder は順序をランダムに入れ替えます。

RandomOrder は、入れ替えても型や意味が破綻しない処理だけに使います。たとえば、Normalize の前提になる値域変換を後ろへ回すと、期待と違う値域で処理してしまいます。

訓練用と検証用のtransformを分ける

訓練用には、ラベルを保てる範囲でランダムな拡張を入れます。検証用とテスト用には、同じ入力なら毎回同じTensorになる決定的な処理を使います。

ここでは、仮の meanstd を置いています。自分のデータで最初から訓練するなら、前の節の方法で訓練データから計算した値に置き換えてください。

import torch
from torchvision.transforms import InterpolationMode
from torchvision.transforms import v2


mean = [0.50, 0.50, 0.50]
std = [0.25, 0.25, 0.25]

train_transform = v2.Compose([
    v2.ToImage(),
    v2.RandomResizedCrop(
        (224, 224),
        scale=(0.75, 1.0),
        ratio=(0.9, 1.1),
        interpolation=InterpolationMode.BILINEAR,
        antialias=True,
    ),
    v2.RandomRotation(
        degrees=(-8, 8),
        interpolation=InterpolationMode.BILINEAR,
        fill=0,
    ),
    v2.ColorJitter(
        brightness=0.18,
        contrast=0.18,
        saturation=0.08,
        hue=0.01,
    ),
    v2.ToDtype(torch.float32, scale=True),
    AddSaltPepperNoise(noise_probability=0.02, apply_probability=0.25),
    v2.Normalize(mean=mean, std=std),
    v2.RandomErasing(p=0.2, scale=(0.02, 0.12), value=0),
])

valid_transform = v2.Compose([
    v2.ToImage(),
    v2.Resize(256, antialias=True),
    v2.CenterCrop((224, 224)),
    v2.ToDtype(torch.float32, scale=True),
    v2.Normalize(mean=mean, std=std),
])

訓練用と検証用の違いを表にすると、次のようになります。

操作 訓練用 検証・テスト用
RandomResizedCrop 使うことがある 通常使わない
RandomRotation 弱く使うことがある 通常使わない
ColorJitter 弱く使うことがある 通常使わない
Resize / CenterCrop 使う よく使う
Normalize 使う 同じ設定で使う
RandomErasing 使うことがある 通常使わない

訓練前には、拡張後の画像を何枚か表示して、ラベルが保たれているか確認します。

import matplotlib.pyplot as plt


def show_samples(dataset, mean, std, count=6):
    figure, axes = plt.subplots(1, count, figsize=(count * 2.5, 2.5))
    for axis in axes:
        image, label = dataset[0]
        image = denormalize(image, mean, std).clamp(0, 1)
        axis.imshow(image.permute(1, 2, 0).numpy())
        axis.set_title(CLASS_NAMES[label])
        axis.axis("off")
    plt.tight_layout()
    plt.show()
    # 実行結果は画像のため誌面には貼りません。dataset[0]をcount回読み直した、
    # train_transformの拡張がかかったcount枚の画像が横一列に並び、各画像の上に
    # クラス名(千円札または一万円札)がタイトルとして表示されます。


preview_dataset = BanknoteDataset("yen_images/split/train", transform=train_transform)
show_samples(preview_dataset, mean, std)

ランダム拡張では、同じindexを何度読んでも違う画像になることがあります。その性質を利用して、上の例では dataset[0] を複数回呼び出しています。

小さなCNNで分類パイプラインを動かす

ここまで作った BanknoteDatasettrain_transformvalid_transform を使って、小さなCNNを訓練します。

コードの前提は次の通りです。

  • データは yen_images/split/trainyen_images/split/valid にある
  • 画像は2クラス分類
  • 損失関数は CrossEntropyLoss
  • モデルの出力は shape (N, 2) のlogit
  • ラベルは 0 または 1
from pathlib import Path
import random
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader


def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)


class SmallCNN(nn.Module):
    def __init__(self, num_classes=2):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.AdaptiveAvgPool2d((1, 1)),
        )
        self.classifier = nn.Linear(128, num_classes)

    def forward(self, x):
        x = self.features(x)
        x = torch.flatten(x, 1)
        return self.classifier(x)


def train_one_epoch(model, loader, criterion, optimizer, device, pin_memory):
    model.train()  # BatchNormやDropoutを訓練モードにする
    running_loss = 0.0
    correct = 0
    total = 0

    for images, labels in loader:
        images = images.to(device, non_blocking=pin_memory)
        labels = labels.to(device, non_blocking=pin_memory)

        logits = model(images)
        loss = criterion(logits, labels)

        optimizer.zero_grad(set_to_none=True)  # 前のbatchの勾配をリセットする
        loss.backward()
        optimizer.step()

        batch_size = labels.size(0)
        running_loss += loss.item() * batch_size  # batch平均をサンプル数で重み付けして合計する
        correct += (logits.argmax(dim=1) == labels).sum().item()
        total += batch_size

    return running_loss / total, correct / total


def evaluate(model, loader, criterion, device, pin_memory):
    model.eval()  # BatchNormやDropoutを評価モードにする
    running_loss = 0.0
    correct = 0
    total = 0

    with torch.inference_mode():  # 評価だけなので勾配計算を止める
        for images, labels in loader:
            images = images.to(device, non_blocking=pin_memory)
            labels = labels.to(device, non_blocking=pin_memory)

            logits = model(images)
            loss = criterion(logits, labels)

            batch_size = labels.size(0)
            running_loss += loss.item() * batch_size
            correct += (logits.argmax(dim=1) == labels).sum().item()
            total += batch_size

    return running_loss / total, correct / total


def main():
    set_seed(42)

    data_root = Path("yen_images/split")
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    num_workers = 0
    pin_memory = device.type == "cuda"

    train_dataset = BanknoteDataset(data_root / "train", transform=train_transform)
    valid_dataset = BanknoteDataset(data_root / "valid", transform=valid_transform)

    train_loader = DataLoader(
        train_dataset,
        batch_size=32,
        shuffle=True,
        num_workers=num_workers,
        pin_memory=pin_memory,
        drop_last=True,
        persistent_workers=num_workers > 0,
    )
    valid_loader = DataLoader(
        valid_dataset,
        batch_size=64,
        shuffle=False,
        num_workers=num_workers,
        pin_memory=pin_memory,
        drop_last=False,
        persistent_workers=num_workers > 0,
    )

    model = SmallCNN(num_classes=2).to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)

    best_valid_acc = 0.0
    max_epochs = 10

    for epoch in range(1, max_epochs + 1):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, device, pin_memory
        )
        valid_loss, valid_acc = evaluate(
            model, valid_loader, criterion, device, pin_memory
        )
        scheduler.step()  # StepLRの規定に従って学習率を更新する

        if valid_acc > best_valid_acc:  # 検証accuracyが更新されたときだけ保存する
            best_valid_acc = valid_acc
            torch.save(model.state_dict(), "best_banknote_cnn.pt")

        print(
            f"epoch {epoch:02d}: "
            f"train_loss={train_loss:.4f}, train_acc={train_acc:.3f}, "
            f"valid_loss={valid_loss:.4f}, valid_acc={valid_acc:.3f}"
        )
        # 出力例(ランダム生成した仮の画像で10 epoch実行した場合。実際の値は使用する画像データや乱数によって変わります):
        # epoch 01: train_loss=0.6996, train_acc=0.484, valid_loss=0.6936, valid_acc=0.500
        # epoch 02: train_loss=0.7010, train_acc=0.484, valid_loss=0.6933, valid_acc=0.500
        # epoch 03: train_loss=0.6915, train_acc=0.547, valid_loss=0.6932, valid_acc=0.500
        # epoch 04: train_loss=0.7073, train_acc=0.500, valid_loss=0.6939, valid_acc=0.500
        # epoch 05: train_loss=0.7140, train_acc=0.500, valid_loss=0.6958, valid_acc=0.500
        # epoch 06: train_loss=0.7241, train_acc=0.453, valid_loss=0.6985, valid_acc=0.500
        # epoch 07: train_loss=0.6917, train_acc=0.500, valid_loss=0.7001, valid_acc=0.500
        # epoch 08: train_loss=0.6904, train_acc=0.531, valid_loss=0.7021, valid_acc=0.500
        # epoch 09: train_loss=0.7017, train_acc=0.469, valid_loss=0.7040, valid_acc=0.500
        # epoch 10: train_loss=0.6935, train_acc=0.500, valid_loss=0.7027, valid_acc=0.500


if __name__ == "__main__":
    main()

第2回で扱ったロジスティック回帰と同じく、流れは「forward、loss、backward、optimizer.step」です。違うのは、入力が画像Tensorになり、モデルがCNNになり、データの供給を DatasetDataLoader が担当している点です。

推論だけを行う場合は、torch.inference_mode() を使います。

from PIL import Image


DISPLAY_NAMES = {
    0: "千円札",
    1: "一万円札",
}


def predict_image(image_path, model, transform, device):
    model.eval()
    with Image.open(image_path) as image:
        image = image.convert("RGB")

    x = transform(image).unsqueeze(0).to(device)

    with torch.inference_mode():
        logits = model(x)
        predicted_label = logits.argmax(dim=1).item()

    return DISPLAY_NAMES[predicted_label]

argmax(dim=1) は、各サンプルについて最も大きいlogitを持つクラスを選びます。CrossEntropyLoss を使う場合、モデル出力にあらかじめSoftmaxをかける必要はありません。

検出、セグメンテーションでのtransforms v2

分類では、画像だけを変換してラベルはそのままにすることが多いです。一方、物体検出やセグメンテーションでは、画像をCropやFlipしたら、境界ボックスやmaskも同じ幾何変換に合わせる必要があります。

transforms v2 では、画像、境界ボックス、maskをまとめてtransformに渡せます。

import torch
from torchvision import tv_tensors
from torchvision.transforms import v2


height, width = 480, 640
image = tv_tensors.Image(torch.randint(0, 256, (3, height, width), dtype=torch.uint8))
boxes = tv_tensors.BoundingBoxes(
    torch.tensor([[120, 80, 420, 360]], dtype=torch.float32),
    format="XYXY",
    canvas_size=(height, width),
)
mask = tv_tensors.Mask(torch.zeros((height, width), dtype=torch.uint8))

joint_transform = v2.Compose([
    v2.RandomHorizontalFlip(p=0.5),
    v2.RandomResizedCrop((320, 320), antialias=True),
    v2.ToDtype(torch.float32, scale=True),
])

image, boxes, mask = joint_transform(image, boxes, mask)

maskはカテゴリIDを持つ画像なので、補間で小数のカテゴリを作らないように注意します。境界ボックスは、形式とキャンバスサイズを持たせることで、空間変換後も正しい座標へ更新できます。

よくあるつまずきどころ

つまずき 原因 対処
torch.stack で失敗する 画像サイズがそろっていない ResizeCrop でそろえる
検証精度が毎回大きく変わる 検証用transformにランダム処理が入っている 検証とテストは決定的な処理にする
Normalize 後の色が変 mean/stdと値域が合っていない [0,1] 用か [0,255] 用か確認する
num_workers を増やすと止まる worker起動やpickleで失敗している num_workers=0 で原因を確認する
GPU利用率が周期的に落ちる データ読み込みが追いついていない worker数、画像サイズ、保存形式を見直す
persistent_workers=True でエラー num_workers=0 と同時に使っている num_workers > 0 のときだけ使う
ラベルが壊れる 強すぎるCropやFlipを入れている 拡張後の画像を必ず可視化する
maskに小数値が出る maskへ双線形補間を使っている maskはカテゴリ値を保つ補間にする
学習済み重みで精度が出ない 指定された前処理と違う 重み側の前処理を確認する
不均衡データで片方だけ当たる クラス比が偏っている 分割比、重み付き損失、Samplerを検討する

DataLoaderの性能は、num_workers を増やせば必ず上がるわけではありません。画像の保存場所、デコード負荷、CPUコア数、メモリ、transformの重さによって変わります。まず正しく動く構成を作り、その後に計測しながら調整します。

参考リンク

おわりに

今回は、画像分類に入る前のデータ処理をまとめました。

Dataset は1サンプルの読み方を決め、DataLoader はindexの順序、batch化、workerによる読み込みを扱います。transforms は、画像をモデル入力に変換し、訓練時だけ必要なデータ拡張を加えます。

この3つを分けて考えると、PyTorchの画像分類コードはかなり読みやすくなります。モデルの精度が伸びないときも、いきなりネットワーク構造を変えるのではなく、データ分割、前処理、値域、Normalize、拡張後の見た目、batchの形を順に確認できます。

次回は、このデータパイプラインを前提に、CNNの構造や訓練の見方をもう少し深く扱います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?