第5章:実践プロジェクト

画像分類パイプラインをゼロからエンドツーエンドで組み立てる

📖 読了時間: 30-35分 📊 難易度: 中級 💻 コード例: 15個 📝 演習: 3問

この章はPyTorch基礎入門シリーズの最終章です。第1章で学んだTensorとautograd、第2章のTensor操作、第3章の自動微分の仕組み、そして続く章で学んだニューラルネットワーク構築の知識を総動員し、画像分類(Image Classification)タスクをゼロから最後まで実装します。データの用意からCNNモデルの構築、データ拡張、学習の可視化、ハイパーパラメータチューニング、そして学習済みモデルでの推論まで、実務でも通用する一連の流れを手を動かしながら身につけましょう。

学習目標

💡 この章で使うデータについて

実務ではMNISTやFashion-MNIST、CIFAR-10といった公開データセットをtorchvision.datasets経由でダウンロードして使うことが多いですが、ネットワーク環境によってはダウンロードが難しい場合があります。この章では、どの環境でも再現できるように合成画像データ(Synthetic Image Data)——numpyで生成した縞模様やクロス模様の画像——を使います。学んだコードはそのままtorchvision.datasets.MNIST(...)などに差し替えて実データにも応用できます。

1. 画像分類タスクの実装

画像分類(Image Classification)とは、入力画像がどのクラス(カテゴリ)に属するかを予測するタスクです。本章では、3種類の模様(横縞・縦縞・斜めクロス)を持つグレースケール画像を3クラスに分類するモデルを作ります。全体のワークフローは次の通りです。

graph LR A[合成画像データ生成] --> B[Dataset/DataLoader作成] B --> C[CNNモデル構築] C --> D[学習ループ + 可視化] D --> E[ハイパーパラメータ探索] E --> F[モデル保存 + 推論]

1.1 合成画像データの生成

まず、numpyを使って3クラスの合成画像を生成する関数を書きます。各クラスにはノイズの中に弱いパターン(横縞・縦縞・斜めのクロス)を埋め込みます。

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader, random_split
from torchvision import transforms

torch.manual_seed(42)
np.random.seed(42)

def generate_synthetic_images(num_samples_per_class=150, image_size=28,
                               num_classes=3, seed=42):
    """3クラスの合成グレースケール画像を生成する。

    label=0: 横縞模様, label=1: 縦縞模様, label=2: 斜めクロス模様
    """
    rng = np.random.default_rng(seed)
    images = []
    labels = []
    for label in range(num_classes):
        for _ in range(num_samples_per_class):
            img = rng.normal(loc=0.15, scale=0.20,
                              size=(image_size, image_size)).astype(np.float32)
            if label == 0:  # 横縞模様
                img[8:12, :] += 0.35
                img[16:20, :] += 0.35
            elif label == 1:  # 縦縞模様
                img[:, 8:12] += 0.35
                img[:, 16:20] += 0.35
            else:  # 斜めのクロス模様
                idx = np.arange(image_size)
                img[idx, idx] += 0.35
                img[idx, image_size - 1 - idx] += 0.35
            img = np.clip(img, 0.0, 1.0)
            images.append(img)
            labels.append(label)

    images = np.stack(images).astype(np.float32)[:, np.newaxis, :, :]  # (N, 1, H, W)
    labels = np.array(labels, dtype=np.int64)

    # シャッフル
    perm = rng.permutation(len(labels))
    return torch.from_numpy(images[perm]), torch.from_numpy(labels[perm])

all_images, all_labels = generate_synthetic_images()
print(f"images: {all_images.shape}, labels: {all_labels.shape}")

出力例:

images: torch.Size([450, 1, 28, 28]), labels: torch.Size([450])

各画像は(チャンネル数, 高さ, 幅) = (1, 28, 28)の形状を持つグレースケール画像です。これはMNISTなど実際の画像データセットと同じ形式であり、以降のコードはそのまま実データにも使えます。

1.2 カスタムDatasetとDataLoaderの作成

データ拡張(次節で説明)をサンプルごとに適用できるように、torch.utils.data.Datasetを継承したカスタムクラスを定義します。

class SyntheticImageDataset(Dataset):
    def __init__(self, images, labels, transform=None):
        self.images = images
        self.labels = labels
        self.transform = transform

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

    def __getitem__(self, idx):
        image = self.images[idx]
        label = self.labels[idx]
        if self.transform:
            image = self.transform(image)
        return image, label

# 学習・検証・テストに分割(70% / 15% / 15%)
base_dataset = SyntheticImageDataset(all_images, all_labels, transform=None)
n_total = len(base_dataset)
n_train = int(n_total * 0.7)
n_val = int(n_total * 0.15)
n_test = n_total - n_train - n_val

train_subset, val_subset, test_subset = random_split(
    base_dataset, [n_train, n_val, n_test],
    generator=torch.Generator().manual_seed(42)
)
print(f"train: {n_train}, val: {n_val}, test: {n_test}")

出力例:

train: 315, val: 67, test: 68

random_splitDatasetをインデックスベースで分割するため、学習・検証・テストの各サブセットに含まれるサンプルが重複しないことが保証されます。分割後にDataLoaderを作成し、バッチ単位でデータを取り出せるようにします。

train_loader = DataLoader(train_subset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_subset, batch_size=32, shuffle=False)
test_loader = DataLoader(test_subset, batch_size=32, shuffle=False)

images_batch, labels_batch = next(iter(train_loader))
print(f"batch images: {images_batch.shape}, batch labels: {labels_batch.shape}")

出力例:

batch images: torch.Size([32, 1, 28, 28]), batch labels: torch.Size([32])

2. CNNモデルの構築

畳み込みニューラルネットワーク(CNN: Convolutional Neural Network)は、画像のような格子状データに対して優れた性能を発揮するニューラルネットワークです。主に以下の3種類の層を組み合わせて構築します。

まずはシンプルな構成でCNNを定義してみましょう。

class SimpleCNN(nn.Module):
    def __init__(self, num_classes=3):
        super().__init__()
        # 畳み込み層: 1チャンネル(グレースケール) -> 16チャンネル -> 32チャンネル
        self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        self.relu = nn.ReLU()

        # 28x28 -> pool -> 14x14 -> pool -> 7x7 なので 32*7*7 が全結合層への入力次元
        self.fc1 = nn.Linear(32 * 7 * 7, 64)
        self.fc2 = nn.Linear(64, num_classes)

    def forward(self, x):
        x = self.pool(self.relu(self.conv1(x)))  # (B, 16, 14, 14)
        x = self.pool(self.relu(self.conv2(x)))  # (B, 32, 7, 7)
        x = x.view(x.size(0), -1)                 # 平坦化: (B, 32*7*7)
        x = self.relu(self.fc1(x))
        x = self.fc2(x)
        return x

model = SimpleCNN(num_classes=3)
sample_output = model(images_batch)
print(f"output shape: {sample_output.shape}")

出力例:

output shape: torch.Size([32, 3])

入力画像は28×28ですが、2回のプーリングによって空間サイズが28→14→7と縮小されていきます。この形状変化を追跡することが、CNN設計で最もつまずきやすいポイントです。fc1の入力次元は必ず最後の畳み込み層の出力形状(チャンネル数×高さ×幅)と一致させる必要があります。

⚠️ よくあるエラー

mat1 and mat2 shapes cannot be multipliedというエラーが出た場合、多くは全結合層の入力次元と直前の特徴マップの形状が一致していないことが原因です。x.view(x.size(0), -1)の直前にprint(x.shape)を挟んで確認する習慣をつけましょう。

3. データ拡張とバッチ正規化

3.1 データ拡張(Data Augmentation)

データ拡張(Data Augmentation)は、学習データに対してランダムな変換(反転・回転・平行移動など)を適用し、見かけ上のデータ量とバリエーションを増やす手法です。これにより、モデルが訓練データの細部を丸暗記してしまう過学習(Overfitting)を抑制し、未知のデータに対する汎化性能を高めることができます。

torchvision.transformsを使うと、Tensor画像に対して簡単にデータ拡張を適用できます。

train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),   # 50%の確率で左右反転
    transforms.RandomRotation(degrees=10),    # ±10度の範囲でランダム回転
    transforms.Normalize(mean=[0.2], std=[0.3]),  # 画素値の正規化
])

# 検証・テストデータには正規化のみ適用し、ランダム性は入れない
eval_transform = transforms.Compose([
    transforms.Normalize(mean=[0.2], std=[0.3]),
])

💡 なぜ検証・テストデータには拡張を適用しないのか

データ拡張は学習データのバリエーションを増やすための手法であり、モデルの評価は常に「実際に近い」データで行う必要があります。検証・テストデータにまでランダムな反転や回転を適用すると、評価結果が安定せず、モデルの本当の性能を正しく測れなくなります。

3.2 バッチ正規化(Batch Normalization)

バッチ正規化(Batch Normalization)は、各層の出力をミニバッチ単位で平均0・分散1に正規化する手法です。学習を安定させ、収束を速め、より高い学習率を使えるようにする効果があります。画像データに対してはnn.BatchNorm2dを畳み込み層の直後に挿入するのが一般的です。

class SimpleCNN(nn.Module):
    def __init__(self, num_classes=3):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
        self.bn1 = nn.BatchNorm2d(16)
        self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(32)
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(0.3)
        self.fc1 = nn.Linear(32 * 7 * 7, 64)
        self.fc2 = nn.Linear(64, num_classes)

    def forward(self, x):
        x = self.pool(self.relu(self.bn1(self.conv1(x))))  # (B, 16, 14, 14)
        x = self.pool(self.relu(self.bn2(self.conv2(x))))  # (B, 32, 7, 7)
        x = x.view(x.size(0), -1)
        x = self.relu(self.fc1(x))
        x = self.dropout(x)  # 全結合層の過学習をさらに抑制
        x = self.fc2(x)
        return x

畳み込み層の並び順は「Conv2dBatchNorm2d → 活性化関数 → MaxPool2d」が定番のパターンです。加えて、全結合層の手前にドロップアウト(Dropout)を挟むことで、特定のニューロンへの依存を減らし、汎化性能をさらに高めています。

最後に、学習用サブセットにはデータ拡張ありのtrain_transform、検証・テスト用サブセットにはeval_transformを適用したDatasetを作り直します。

def attach_transform(subset, transform):
    """random_splitで得たSubsetに、指定のtransformを持つDatasetを再アタッチする"""
    subset.dataset = SyntheticImageDataset(all_images, all_labels, transform=transform)
    return subset

train_subset = attach_transform(train_subset, train_transform)
val_subset = attach_transform(val_subset, eval_transform)
test_subset = attach_transform(test_subset, eval_transform)

train_loader = DataLoader(train_subset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_subset, batch_size=32, shuffle=False)
test_loader = DataLoader(test_subset, batch_size=32, shuffle=False)

4. 学習の可視化とモニタリング

学習が正しく進んでいるかを確認するには、エポックごとの損失(Loss)正解率(Accuracy)を記録し、可視化することが欠かせません。まず、1エポック分の学習と評価を行う関数を定義します。

def train_one_epoch(model, loader, criterion, optimizer, device):
    model.train()
    total_loss, correct, total = 0.0, 0, 0
    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * images.size(0)
        preds = outputs.argmax(dim=1)
        correct += (preds == labels).sum().item()
        total += labels.size(0)
    return total_loss / total, correct / total

@torch.no_grad()
def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss, correct, total = 0.0, 0, 0
    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        outputs = model(images)
        loss = criterion(outputs, labels)
        total_loss += loss.item() * images.size(0)
        preds = outputs.argmax(dim=1)
        correct += (preds == labels).sum().item()
        total += labels.size(0)
    return total_loss / total, correct / total

model.train()model.eval()の切り替えは重要です。BatchNorm2dDropoutは学習時と評価時で異なる振る舞いをするため、評価前には必ずmodel.eval()を呼び出す必要があります。また、評価時は勾配計算が不要なので@torch.no_grad()で無駄な計算とメモリ消費を防いでいます。

次に、学習ループ全体を実行し、履歴をhistory辞書に記録します。

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleCNN(num_classes=3).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": []}
num_epochs = 8

for epoch in range(num_epochs):
    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
    val_loss, val_acc = evaluate(model, val_loader, criterion, device)

    history["train_loss"].append(train_loss)
    history["train_acc"].append(train_acc)
    history["val_loss"].append(val_loss)
    history["val_acc"].append(val_acc)

    print(f"Epoch {epoch+1}/{num_epochs}: "
          f"train_loss={train_loss:.4f} train_acc={train_acc:.4f} "
          f"val_loss={val_loss:.4f} val_acc={val_acc:.4f}")

出力例:

Epoch 1/8: train_loss=0.4813 train_acc=0.8190 val_loss=0.1782 val_acc=1.0000
Epoch 2/8: train_loss=0.0172 train_acc=1.0000 val_loss=0.0076 val_acc=1.0000
Epoch 3/8: train_loss=0.0040 train_acc=1.0000 val_loss=0.0008 val_acc=1.0000
Epoch 4/8: train_loss=0.0014 train_acc=1.0000 val_loss=0.0002 val_acc=1.0000
Epoch 5/8: train_loss=0.0017 train_acc=1.0000 val_loss=0.0001 val_acc=1.0000
Epoch 6/8: train_loss=0.0013 train_acc=1.0000 val_loss=0.0001 val_acc=1.0000
Epoch 7/8: train_loss=0.0009 train_acc=1.0000 val_loss=0.0001 val_acc=1.0000
Epoch 8/8: train_loss=0.0021 train_acc=1.0000 val_loss=0.0000 val_acc=1.0000

1エポック目で正解率が急速に立ち上がり、2エポック目以降はほぼ収束しています。今回の合成データはパターンが比較的単純なため早く収束していますが、実データではもっと緩やかに、時には検証損失が途中で上昇に転じる(過学習の兆候)こともあります。そうした変化をいち早く発見するために、数値だけでなくグラフでの可視化が役立ちます。

import matplotlib.pyplot as plt

epochs_range = range(1, num_epochs + 1)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

axes[0].plot(epochs_range, history["train_loss"], label="Train Loss")
axes[0].plot(epochs_range, history["val_loss"], label="Val Loss")
axes[0].set_xlabel("Epoch")
axes[0].set_ylabel("Loss")
axes[0].set_title("Loss Curve")
axes[0].legend()

axes[1].plot(epochs_range, history["train_acc"], label="Train Acc")
axes[1].plot(epochs_range, history["val_acc"], label="Val Acc")
axes[1].set_xlabel("Epoch")
axes[1].set_ylabel("Accuracy")
axes[1].set_title("Accuracy Curve")
axes[1].legend()

plt.tight_layout()
plt.savefig("training_curves.png")
print("学習曲線を training_curves.png に保存しました")

実行すると、損失曲線が急激に下がって0付近に収束し、正解率曲線が右肩上がりで1.0に近づいていくグラフがtraining_curves.pngとして保存されます。学習曲線・正解率曲線がともに滑らかに改善していれば学習は順調です。逆に検証損失だけが途中から上昇に転じたら過学習のサインなので、データ拡張を強めたり、エポック数を減らす(早期終了)などの対策を検討します。

💡 より本格的なモニタリング

実務ではmatplotlibでの事後可視化に加え、TensorBoardWeights & Biasesといったツールを使い、学習中にリアルタイムで損失・正解率・学習率などを追跡することがよくあります。本章では基礎となる「履歴を記録してグラフ化する」考え方に絞って扱っています。

5. ハイパーパラメータチューニング

ハイパーパラメータ(Hyperparameter)とは、学習率やバッチサイズ、層の数など、学習によって自動的には決まらず、事前に人間が設定する値のことです(学習によって決まる重みなどの「パラメータ」とは区別されます)。適切なハイパーパラメータを選ぶことで、モデルの性能や学習の安定性が大きく変わります。

最もシンプルなチューニング手法であるグリッドサーチ(Grid Search)——候補となる値の組み合わせをすべて試す方法——を実装してみましょう。時間を抑えるため、各組み合わせのエポック数は少なめに設定しています。

def build_and_train(lr, batch_size, num_epochs=2):
    tr_loader = DataLoader(train_subset, batch_size=batch_size, shuffle=True)
    va_loader = DataLoader(val_subset, batch_size=batch_size, shuffle=False)

    m = SimpleCNN(num_classes=3).to(device)
    opt = optim.Adam(m.parameters(), lr=lr)
    crit = nn.CrossEntropyLoss()

    for _ in range(num_epochs):
        train_one_epoch(m, tr_loader, crit, opt, device)

    _, val_acc = evaluate(m, va_loader, crit, device)
    return val_acc

results = {}
for lr in [0.1, 0.01, 0.001, 0.0001]:
    for batch_size in [16, 64]:
        acc = build_and_train(lr, batch_size, num_epochs=2)
        results[(lr, batch_size)] = acc
        print(f"lr={lr}, batch_size={batch_size} -> val_acc={acc:.4f}")

best_params = max(results, key=results.get)
print(f"\n最良の組み合わせ: lr={best_params[0]}, batch_size={best_params[1]} "
      f"(val_acc={results[best_params]:.4f})")

出力例:

lr=0.1, batch_size=16 -> val_acc=1.0000
lr=0.1, batch_size=64 -> val_acc=1.0000
lr=0.01, batch_size=16 -> val_acc=1.0000
lr=0.01, batch_size=64 -> val_acc=1.0000
lr=0.001, batch_size=16 -> val_acc=1.0000
lr=0.001, batch_size=64 -> val_acc=1.0000
lr=0.0001, batch_size=16 -> val_acc=1.0000
lr=0.0001, batch_size=64 -> val_acc=0.9701

最良の組み合わせ: lr=0.1, batch_size=16 (val_acc=1.0000)

学習率0.0001・バッチサイズ64の組み合わせだけ検証正解率がわずかに低いことが分かります。学習率が小さいままバッチサイズを大きくすると、1エポックあたりの重み更新回数がさらに減り、たった2エポックでは重みが十分に収束しきらないためです。他の組み合わせはこの合成データセットでは早期に収束していますが、実データではもっと明確な差——例えば学習率が高すぎて損失が発散する、逆に低すぎて収束が遅いといった傾向——が見られることが多く、こうした「危険な組み合わせ」をあぶり出せるのがグリッドサーチの価値です。

⚠️ グリッドサーチの限界

グリッドサーチは候補数が増えると組み合わせ数が指数的に増加し、計算コストが膨大になります(例: 5パラメータ×5候補なら5⁵=3,125通り)。実務では、ランダムに候補を選ぶランダムサーチ(Random Search)や、過去の探索結果を使って次に試す値を賢く選ぶベイズ最適化(Bayesian Optimization)を実装したOptunaRay Tuneといったライブラリがよく使われます。

6. モデルのデプロイと推論

学習が完了したモデルは、そのままでは実行を終えるとメモリから消えてしまいます。再利用するには、パラメータをファイルに保存し、後で読み込めるようにする必要があります。PyTorchでは、モデル全体ではなく状態辞書(state_dict)——各層の名前と重みTensorを対応付けた辞書——を保存するのが推奨される方法です。

import os

save_path = "cnn_model.pth"
torch.save(model.state_dict(), save_path)
print(f"モデルを {save_path} に保存しました "
      f"({os.path.getsize(save_path) / 1024:.1f} KB)")

出力例:

モデルを cnn_model.pth に保存しました (418.7 KB)

読み込む際は、まず同じ構造のSimpleCNNインスタンスを作成し、そこに保存済みの重みを読み込みます。weights_only=Trueを指定すると、実行可能なコードを含まない安全な読み込みが行われます。

loaded_model = SimpleCNN(num_classes=3).to(device)
loaded_model.load_state_dict(torch.load(save_path, map_location=device, weights_only=True))
loaded_model.eval()  # 推論モードに切り替え(Dropout/BatchNormを無効化)

# テストデータの一部で推論を実行
sample_images, sample_labels = next(iter(test_loader))
sample_images = sample_images.to(device)

with torch.no_grad():
    logits = loaded_model(sample_images)
    probs = torch.softmax(logits, dim=1)
    preds = probs.argmax(dim=1)

print(f"予測クラス: {preds[:8].cpu().tolist()}")
print(f"正解クラス: {sample_labels[:8].tolist()}")
print(f"予測確信度: {probs.max(dim=1).values[:8].cpu().tolist()}")

出力例:

予測クラス: [2, 0, 1, 0, 2, 0, 0, 0]
正解クラス: [2, 0, 1, 0, 2, 0, 0, 0]
予測確信度: [0.9999..., 0.9999..., 0.9999..., 0.9999..., 0.9998..., 0.9999..., 0.9999..., 0.9999...]

推論時のポイントは3つです。

🎉 ここまでで完成したパイプライン

合成画像データの生成 → カスタムDatasetとDataLoader → データ拡張とBatchNorm付きCNN → 学習ループと可視化 → グリッドサーチによるハイパーパラメータ探索 → 保存したモデルでの推論、という一連の流れを実装しました。この流れは、データを実際のtorchvisionデータセットに差し替えるだけで、そのまま実務の画像分類プロジェクトに応用できます。

💡 さらに先へ進むには

本章では扱いませんが、モデルを実サービスに組み込む際はTorchScripttorch.jit.script)やONNX形式へのエクスポートによって、Pythonに依存しない環境(C++やモバイル、ブラウザなど)での推論を高速化する手法もよく使われます。まずは本章で身につけた「保存・読み込み・eval()no_grad()」の基本を確実に押さえておきましょう。

学習目標の振り返り

演習問題

演習1:チャンネル数を増やしたCNNの実験

SimpleCNNconv1の出力チャンネル数を16から32に、conv2の出力チャンネル数を32から64に変更し、fc1の入力次元を正しく計算し直した上でモデルを再定義してください。パラメータ数が増えたことをsum(p.numel() for p in model.parameters())で確認しましょう。

解答例
class WiderCNN(nn.Module):
    def __init__(self, num_classes=3):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
        self.bn1 = nn.BatchNorm2d(32)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(64)
        self.pool = nn.MaxPool2d(2, 2)
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(0.3)
        # 28 -> 14 -> 7 なので、チャンネル数64 x 7 x 7 が入力次元
        self.fc1 = nn.Linear(64 * 7 * 7, 64)
        self.fc2 = nn.Linear(64, num_classes)

    def forward(self, x):
        x = self.pool(self.relu(self.bn1(self.conv1(x))))
        x = self.pool(self.relu(self.bn2(self.conv2(x))))
        x = x.view(x.size(0), -1)
        x = self.relu(self.fc1(x))
        x = self.dropout(x)
        x = self.fc2(x)
        return x

wider_model = WiderCNN(num_classes=3)
num_params = sum(p.numel() for p in wider_model.parameters())
print(f"パラメータ数: {num_params:,}")
# 出力例: パラメータ数: 219,971(SimpleCNNより大幅に増加)
演習2:データ拡張の効果を比較する

データ拡張(train_transform)ありとなし(eval_transformのみ)で、同じ条件(エポック数・学習率・バッチサイズ)で学習し、テスト正解率を比較してください。データ拡張が今回の合成データセットでどう影響するかを1〜2文で考察してください。

解答例
def run_experiment(train_transform_to_use, num_epochs=5):
    train_ds = SyntheticImageDataset(all_images, all_labels, transform=train_transform_to_use)
    train_ds_subset = torch.utils.data.Subset(train_ds, train_subset.indices)
    loader = DataLoader(train_ds_subset, batch_size=32, shuffle=True)

    m = SimpleCNN(num_classes=3).to(device)
    opt = optim.Adam(m.parameters(), lr=0.001)
    crit = nn.CrossEntropyLoss()
    for _ in range(num_epochs):
        train_one_epoch(m, loader, crit, opt, device)
    _, test_acc = evaluate(m, test_loader, crit, device)
    return test_acc

acc_with_aug = run_experiment(train_transform)
acc_without_aug = run_experiment(eval_transform)
print(f"拡張あり: {acc_with_aug:.4f}, 拡張なし: {acc_without_aug:.4f}")
# 考察例: 今回のようにクラスごとの模様が明確に分離できる単純なデータでは、
# 拡張ありなしの差は小さくなりやすい。一方、クラス間の模様が似ている、
# ノイズが多い、データ数が少ないといった条件では拡張の効果がより顕著になる。
演習3:新しいクラスを追加した推論

generate_synthetic_imagesに4つ目のクラス(例: 格子模様、img[::4, :] += 0.35のような周期的パターン)を追加し、num_classes=4でモデルを学習させてください。学習後、model.eval()torch.no_grad()を使ってテストデータ1バッチ分の推論を行い、各クラスの予測確信度(softmax後の最大値)の平均を計算してください。

解答例
def generate_synthetic_images_v2(num_samples_per_class=150, image_size=28,
                                   num_classes=4, seed=42):
    rng = np.random.default_rng(seed)
    images, labels = [], []
    for label in range(num_classes):
        for _ in range(num_samples_per_class):
            img = rng.normal(loc=0.15, scale=0.20,
                              size=(image_size, image_size)).astype(np.float32)
            if label == 0:
                img[8:12, :] += 0.35
                img[16:20, :] += 0.35
            elif label == 1:
                img[:, 8:12] += 0.35
                img[:, 16:20] += 0.35
            elif label == 2:
                idx = np.arange(image_size)
                img[idx, idx] += 0.35
                img[idx, image_size - 1 - idx] += 0.35
            else:  # 格子模様(4つ目のクラス)
                img[::4, :] += 0.35
                img[:, ::4] += 0.35
            img = np.clip(img, 0.0, 1.0)
            images.append(img)
            labels.append(label)
    images = np.stack(images).astype(np.float32)[:, np.newaxis, :, :]
    labels = np.array(labels, dtype=np.int64)
    perm = rng.permutation(len(labels))
    return torch.from_numpy(images[perm]), torch.from_numpy(labels[perm])

images4, labels4 = generate_synthetic_images_v2(num_classes=4)
model4 = SimpleCNN(num_classes=4).to(device)
# 以降は本文と同様に Dataset/DataLoader を作り、学習ループを回す

model4.eval()
with torch.no_grad():
    logits = model4(images4[:32].to(device))
    probs = torch.softmax(logits, dim=1)
    avg_confidence = probs.max(dim=1).values.mean().item()
print(f"平均予測確信度: {avg_confidence:.4f}")

まとめ

この章では、PyTorch基礎入門シリーズの集大成として、画像分類の実践プロジェクトに取り組みました。

🎓 シリーズ全体の総括

第1章のTensorと自動微分の基礎から始まり、第2章のTensor操作、第3章の自動微分の仕組みを経て、この第5章でCNNによる画像分類プロジェクトを完成させました。「PyTorchとは何か」を学ぶところから、「実際にモデルを学習させ、保存し、推論する」ところまでを一通り体験したことで、他のアーキテクチャ(RNN、Transformerなど)や他のタスク(自然言語処理、時系列予測など)に取り組む際にも応用できる土台が身についたはずです。ここで学んだ「データ準備 → モデル構築 → 学習と可視化 → チューニング → デプロイ」という流れは、扱うデータやモデルが変わってもほぼそのまま通用します。ぜひ自分の興味のあるデータセットで、このパイプラインを試してみてください。


参考リソース

免責事項