LESSON

演習:CNNで商品画像を分類しよう

田中VPoE:「ここまで CNN の仕組み、代表的アーキテクチャ、転移学習、データ拡張を学んだ。いよいよ実践だ。CIFAR-10 を NetShop の商品画像に見立てて、転移学習で分類モデルを構築してみよう。」

あなた:「CIFAR-10 は10カテゴリの画像データセットですよね。32x32 と小さいですが、まずはこれで一連の流れを掴みます。」

田中VPoE:「そうだ。データ拡張、転移学習、評価まで一気通貫で実装してくれ。」

ミッション概要

CIFAR-10 データセットを使って、転移学習ベースの画像分類モデルを構築します。データ拡張、モデルの構築、学習、評価までの一連の流れを実装します。

前提条件

  • PyTorch の基本操作を理解していること(Step 2)
  • 畳み込み層、転移学習、データ拡張の概念を理解していること(Step 3 Lesson)

Mission 1: データの準備とデータ拡張

CIFAR-10 データセットを読み込み、適切なデータ拡張パイプラインを構築してください。

要件

  1. CIFAR-10 をダウンロードし、学習/テストデータを用意する
  2. 学習用にデータ拡張を含む変換パイプラインを作成する
  3. 評価用にはデータ拡張なしの変換パイプラインを作成する
  4. DataLoader を作成する
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader

# ここにデータ準備のコードを書く
解答例
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader, random_split

torch.manual_seed(42)

# === 変換パイプライン ===
# CIFAR-10 は 32x32 だが、ResNet は 224x224 を想定
train_transform = transforms.Compose([
    transforms.Resize(224),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(degrees=10),
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225]),
])

val_transform = transforms.Compose([
    transforms.Resize(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225]),
])

# === データセット ===
full_train = torchvision.datasets.CIFAR10(
    root='./data', train=True, download=True, transform=train_transform
)
test_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=False, download=True, transform=val_transform
)

# 学習/検証に分割
train_size = int(0.8 * len(full_train))
val_size = len(full_train) - train_size
train_dataset, val_dataset = random_split(
    full_train, [train_size, val_size],
    generator=torch.Generator().manual_seed(42)
)

# === DataLoader ===
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2)
val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2)

print(f"学習: {len(train_dataset)}, 検証: {len(val_dataset)}, テスト: {len(test_dataset)}")
print(f"クラス: {full_train.classes}")

Mission 2: 転移学習モデルの構築と学習

事前学習済み ResNet18 を使って転移学習モデルを構築し、学習してください。

要件

  1. 事前学習済み ResNet18 の分類層を CIFAR-10 の10クラスに置き換える
  2. 特徴抽出アプローチ(畳み込み層を凍結)で最初に学習する
  3. その後ファインチューニング(一部の層を解凍)を行う
  4. 学習/検証の損失と精度を記録・可視化する
import torchvision.models as models
import matplotlib.pyplot as plt

# ここにモデル構築と学習のコードを書く
解答例
import torchvision.models as models
import matplotlib.pyplot as plt

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# === モデル構築 ===
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

# 全パラメータを凍結
for param in model.parameters():
    param.requires_grad = False

# 分類層を置き換え
model.fc = nn.Sequential(
    nn.Linear(model.fc.in_features, 256),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(256, 10)
)
model = model.to(device)

# === Phase 1: 特徴抽出(分類層のみ学習)===
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

history = {'train_loss': [], 'val_loss': [], 'train_acc': [], 'val_acc': []}

def train_epoch(model, loader, criterion, optimizer):
    model.train()
    total_loss, correct, total = 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()
        _, predicted = outputs.max(1)
        correct += predicted.eq(labels).sum().item()
        total += labels.size(0)
    return total_loss / len(loader), correct / total

def evaluate(model, loader, criterion):
    model.eval()
    total_loss, correct, total = 0, 0, 0
    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)
            total_loss += loss.item()
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)
    return total_loss / len(loader), correct / total

# Phase 1: 5エポック
print("=== Phase 1: 特徴抽出 ===")
for epoch in range(5):
    train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer)
    val_loss, val_acc = evaluate(model, val_loader, criterion)
    history['train_loss'].append(train_loss)
    history['val_loss'].append(val_loss)
    history['train_acc'].append(train_acc)
    history['val_acc'].append(val_acc)
    print(f"Epoch {epoch+1}/5 - Train Loss: {train_loss:.4f}, "
          f"Train Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f}")

# === Phase 2: ファインチューニング(layer4 を解凍)===
print("\n=== Phase 2: ファインチューニング ===")
for param in model.layer4.parameters():
    param.requires_grad = True

optimizer = torch.optim.Adam([
    {'params': model.layer4.parameters(), 'lr': 1e-4},
    {'params': model.fc.parameters(), 'lr': 5e-4},
])

for epoch in range(10):
    train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer)
    val_loss, val_acc = evaluate(model, val_loader, criterion)
    history['train_loss'].append(train_loss)
    history['val_loss'].append(val_loss)
    history['train_acc'].append(train_acc)
    history['val_acc'].append(val_acc)
    print(f"Epoch {epoch+1}/10 - Train Loss: {train_loss:.4f}, "
          f"Train Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f}")

# === 学習曲線の可視化 ===
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(history['train_loss'], label='Train')
ax1.plot(history['val_loss'], label='Val')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title('損失の推移')
ax1.legend()
ax1.grid(True, alpha=0.3)

ax2.plot(history['train_acc'], label='Train')
ax2.plot(history['val_acc'], label='Val')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy')
ax2.set_title('精度の推移')
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Mission 3: テストデータで最終評価

テストデータで最終評価を行い、クラスごとの精度と混同行列を可視化してください。

要件

  1. テストデータで推論を実行する
  2. 全体の精度とクラスごとの精度を計算する
  3. 混同行列をヒートマップで可視化する
  4. 予測が間違った画像の誤分類パターンを分析する
from sklearn.metrics import classification_report, confusion_matrix
import seaborn as sns

# ここにテスト評価のコードを書く
解答例
from sklearn.metrics import classification_report, confusion_matrix
import seaborn as sns
import numpy as np

classes = ['airplane', 'automobile', 'bird', 'cat', 'deer',
           'dog', 'frog', 'horse', 'ship', 'truck']

# === テストデータで推論 ===
model.eval()
all_preds, all_labels = [], []

with torch.no_grad():
    for images, labels in test_loader:
        images = images.to(device)
        outputs = model(images)
        _, predicted = outputs.max(1)
        all_preds.extend(predicted.cpu().numpy())
        all_labels.extend(labels.numpy())

all_preds = np.array(all_preds)
all_labels = np.array(all_labels)

# === 分類レポート ===
print("=== テストデータ評価 ===")
print(classification_report(all_labels, all_preds, target_names=classes))

# === 混同行列のヒートマップ ===
cm = confusion_matrix(all_labels, all_preds)
plt.figure(figsize=(10, 8))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=classes, yticklabels=classes)
plt.xlabel('予測')
plt.ylabel('実際')
plt.title('混同行列')
plt.tight_layout()
plt.show()

# === エラー分析 ===
wrong_idx = np.where(all_preds != all_labels)[0]
print(f"\n誤分類数: {len(wrong_idx)} / {len(all_labels)} "
      f"({len(wrong_idx)/len(all_labels)*100:.1f}%)")

from collections import Counter
error_pairs = Counter()
for idx in wrong_idx:
    true_class = classes[all_labels[idx]]
    pred_class = classes[all_preds[idx]]
    error_pairs[(true_class, pred_class)] += 1

print("\n最も多い誤分類パターン(上位5件):")
for (true_cls, pred_cls), count in error_pairs.most_common(5):
    print(f"  {true_cls}{pred_cls}: {count}件")

達成度チェック

  • データ拡張を含む変換パイプラインを構築できた
  • 事前学習済み ResNet18 の分類層を置き換えられた
  • 特徴抽出 → ファインチューニングの段階的学習を実装できた
  • 学習曲線を可視化して過学習の有無を確認できた
  • テストデータで分類レポートと混同行列を出力できた
  • エラー分析で誤分類パターンを特定できた

推定所要時間: 90分