🐱

【画像分類】CNNで愛猫を識別してみた!

に公開

はじめに

はじめまして。データアナリティクスラボの力岡です。
今回は個人的なテーマとして、「犬」「猫」「我が家の愛猫『むぎちゃん』」の3クラスを対象にした画像分類モデルを作成し、その構築・評価のプロセスをご紹介します。ペットの画像分類に取り組んでみたい方や、PyTorchを使った画像分類の実装に興味がある方の参考になれば幸いです。

画像分類モデルの実装

画像分類モデルの実装について解説していきます。ここでは、使用したハードウェアやデータセット、データの前処理、モデルの定義、そして学習プロセスや評価結果を順を追って説明していきます。

本記事では画像分類の理論面は扱わないので、勉強したい方は以下の記事をご参照ください。
https://zenn.dev/dalab/articles/aabee73a470620

実装内容

今回は、画像から「犬」、「猫」、「我が家の愛猫『むぎちゃん』」の3クラスを分類する画像分類モデルを作成しました。むぎちゃんは、筆者が自宅で飼っている9歳の茶トラの男の子です!(可愛いですね!!)

使用したハードウェア

モデルの学習には、自宅のゲーム用デスクトップPCを使用しました。以下はハードウェア構成の詳細です。

  • CPU: Intel(R) Core(TM) i7-13700F
  • GPU: NVIDIA GeForce RTX 4070 Ti(VRAM: 12GB)

ニューラルネットワークの学習では、GPUを使用することで処理速度を大幅に向上できます。GPUを使用しない場合は学習に非常に長い時間がかかることがあるため、もし自身で学習などを行う際にはクラウドサービスやGPU搭載のPCの利用を検討することをおすすめします。

学習データの準備

今回の学習では、以下のKaggleから提供されている「Cat and Dog」データセットを使用しました。このデータセットには、猫と犬の画像がそれぞれ5,000枚ずつ含まれており、各画像には正解ラベル(犬または猫)が付与されています。

https://www.kaggle.com/datasets/tongpython/cat-and-dog

以下に、データセットに含まれる画像の一部を示します。様々な品種の犬や猫の画像が含まれていることがわかります。


これに加えて、私が日頃スマホで撮影している愛猫「むぎちゃん」の画像約5,000枚もデータセットに追加しました。むぎちゃんの画像は、体全体が写っている単体の写真を手作業で選定しています。

また、ラベルごとにフォルダ分けしておくと、後のデータ処理が容易になるため、以下のように整理しました。

このフォルダ構造に基づいて画像パスとラベルをデータフレーム形式に変換し、データセットを学習用、検証用、テスト用に分割します。具体的には、データフレームを、学習データ(80%)、検証データ(10%)、テストデータ(10%) に分割します。stratify=df["label"]を指定することで、クラスごとのデータ比率を維持したまま分割できます。

datas = []

labels = os.listdir(CFG.image_dir)
for label in labels:
    label_dir = os.path.join(CFG.image_dir, label)
    images = os.listdir(label_dir)
    data = pd.DataFrame({
        "filename": [os.path.join(label_dir, image) for image in images],
        "label": label
    })
    datas.append(data)

df = pd.concat(datas, ignore_index=True)

# train,validation,testに分割
def split_dataset(df, test_size=0.2, val_size=0.5, random_state=CFG.seed):
    X_train, X_temp = train_test_split(df, test_size=test_size, stratify=df["label"], random_state=random_state)
    X_val, X_test = train_test_split(X_temp, test_size=val_size, stratify=X_temp["label"], random_state=random_state)
    return X_train, X_val, X_test

X_train, X_val, X_test = split_dataset(df)

データの前処理

モデルにデータを投入する前に、適切な前処理を行います。ここでは、データを効率的に扱うためのクラスの定義、データ拡張の手法、そしてデータローダーの作成について説明していきます。

CustomDatasetクラスの定義

PyTorchのtorch.utils.data.Datasetクラスを継承して、CustomDatasetクラスを作成します。

標準のPyTorchデータセットクラスが自身のデータ形式(例: CSVやフォルダ構成)に適合しない場合、カスタムクラスを作成することで柔軟に対応できます。また、画像の読み込みやデータ変換を統一的かつ効率的に処理でき、データ前処理が簡単という利点もあります。

以下のコードでは、ラベルを[cat, dog, mugi]の3クラスとして扱い、それぞれを数値化するためにself.label_mapを定義しています。これにより、Datasetオブジェクトを生成すると、__getitem__メソッドが呼び出されるたびに指定された画像が読み込まれ、対応する数値ラベルが返されます。

class CustomDataset(Dataset):
    def __init__(self, dataframe, transform=None):
        self.dataframe = dataframe
        self.transform = transform
        self.label_map = {"cat": 0, "dog": 1, "mugi": 2}

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

    def __getitem__(self, idx):
        image_path = self.dataframe.iloc[idx]["filename"]
        label = self.dataframe.iloc[idx]["label"]

        # 画像を開いてRGBに変換
        image = Image.open(image_path).convert("RGB")
        if self.transform:
            image = self.transform(image)

        return image, self.label_map[label]

データ拡張(Data Augmentation)

データ拡張は、データセットに多様性を加えることで過学習を防ぎ、モデルの汎化性能を向上させる重要な手法です。画像データの場合、リサイズや正規化といった基本的な処理に加え、左右反転、ランダムクロップ、色調補正などの操作がよく使用されます。

以下のコードでは、データセット全体の各チャンネルの平均値と標準偏差を計算し、これを正規化に使用しています。正規化によって特徴量のスケールが揃い、モデルが効率的かつ安定して学習できるようになります。その後、画像サイズを統一するリサイズや正規化を行うためのパイプラインを定義します。ここでは、左右反転やランダムクロップといったデータ拡張は使用せず、リサイズと正規化のみを行います。

# データセット全体の平均値と標準偏差を計算
def compute_mean_std(df):
    mean_accumulator = np.zeros(3)
    std_accumulator = np.zeros(3)
    num_images = len(df)

    for _, row in df.iterrows():
        img = np.array(Image.open(row["filename"])) / 255.0
        mean_accumulator += img.mean(axis=(0, 1))
        std_accumulator += img.std(axis=(0, 1))

    # 最終的な平均と標準偏差を計算
    mean = mean_accumulator / num_images
    std = std_accumulator / num_images

    return mean, std

mean, std = compute_mean_std(X_train)

# 前処理とデータ拡張のパイプラインの定義
transform = transforms.Compose([
    transforms.Resize((256, 256)),
    transforms.ToTensor(),
    transforms.Normalize(mean=mean, std=std),
])
その他のデータ拡張の手法について

以下は画像データに対する一般的なデータ拡張手法の例です。

HorizontalFlip(左右反転)

画像を左右反転させます。p=1.0で確実に反転しますが、pで適用確率を制御できます。

transforms.RandomHorizontalFlip(p=1.0)


VerticalFlip(上下反転)

画像を上下反転させます。左右反転と同様に、pで適用確率を制御できます。

transforms.RandomVerticalFlip(p=1.0)


RandomCrop(ランダムクロップ)

指定したサイズ(x, y)で画像の一部分をランダムに切り出します。

transforms.RandomCrop(size=(x, y))


Grayscale(グレースケール化)

画像をモノクロ(グレースケール)に変換します。num_output_channelsを指定することで、出力のチャンネル数を設定できます(1: グレースケール、3: RGB形式のグレースケール)。

transforms.Grayscale(num_output_channels=1)


ColorJitter(色調や彩度のランダム変更)

画像の明るさ、コントラスト、彩度、色相をランダムに変更します。それぞれの引数で、調整の範囲を指定します。

transforms.ColorJitter(brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5)

データローダーの作成

定義したCustomDatasetクラスとデータ拡張パイプライン(transform)を組み合わせ、学習用/検証用データローダーを作成します。DataLoaderクラスを使用することで、ミニバッチ単位でのシャッフルや効率的なデータロードが可能になります。

# データローダーの作成
train_dataset = CustomDataset(X_train, transform=transform)
val_dataset = CustomDataset(X_val, transform=transform)

train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False)

画像認識モデルの定義

ここでは、画像認識モデルを定義します。今回使用するのはResNet18という深層学習モデルです。このモデルは、残差学習を採用しており、層が深くても勾配消失問題を緩和できる特徴があります。

まずは、torchvisionに用意されているmodels.resnet18を用いてResNet18モデルを呼び出します。今回は事前学習済みパラメータを使用せず、weights=Noneを指定しています。また、最終層(全結合層)はデフォルトでImageNetの1,000クラスを分類する設定になっているため、これをnn.Linear(num_features, len(labels))で3クラス分類用に変更しています。

学習のための損失関数として、分類タスクに適したnn.CrossEntropyLoss()を、最適化手法には、学習の効率が良いoptim.Adamを採用しています。特にこだわりがなければ、ここら辺は固定でいいと思います。

# モデルの初期化
model = models.resnet18(weights=None)  # 事前学習なし
num_features = model.fc.in_features    # 最終層の入力次元を取得
model.fc = nn.Linear(num_features, len(labels))  # 出力層を3クラスに変更
model = model.to(CFG.device)

# 損失関数と最適化手法の定義
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

PyTorchでは、print(model)を実行することで、モデルの詳細なアーキテクチャを確認できます。使用するモデルに関しては、層構造や各レイヤーのパラメータを把握しておくことをお勧めします。

今回読み込んだResNet18モデルは、入力層(7×7畳み込みconv1 → バッチ正規化bn1 → relu → プーリングmaxpool)、畳み込みブロック(layer1~layer4:各層で特徴マップのサイズを半分に縮小)、出力層(グローバル平均プーリングavgpool → 全結合層fcを3クラス分類用に変更)で構成されていることがわかります。

ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
    (1): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer3): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer4): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=3, bias=True)
他の画像認識モデルについて

PyTorch には、以下のような有名な画像認識モデルがtorchvision.modelsに用意されています。それぞれ特徴が異なるため、タスクや環境に応じて適切なモデルを選択することが重要です。

  • ResNet
    残差学習を採用したモデルで、深層ネットワークの学習が可能。高い精度を誇ります。
  • VGG
    シンプルな畳み込み層の積み重ねで構成されるモデル。解釈がしやすいが、パラメータ数が多いのが特徴です。
  • EfficientNet
    モデルの深さ、幅、解像度を最適化した設計で、精度と効率を両立しています。
  • DenseNet
    各層がすべての前の層と結合される構造を持ち、特徴マップの再利用が可能です。
  • MobileNet
    軽量な設計で、モバイルデバイスやリソースが限られた環境でも高速に推論可能です。
事前学習済みモデルとは

事前学習モデルとは、ImageNet という大規模データセット(1,000クラス、1,000万枚以上の画像)で事前に学習されたモデルのことを指しており、学習済みのパラメータを利用することで、少ないデータでも高い精度を達成しやすくなります。

事前学習済みモデルを使用する場合は、以下のように pretrained=Trueを指定します。今回の例では、事前学習を使用せず、最初から学習を行うためにweights=Noneを指定していますが、少ないデータで高精度を達成したい場合は、事前学習済みモデルを使用するのが一般的です。

model = models.resnet18(pretrained=True)

モデルの学習

次に、学習関数と学習ループを定義します。ここでは、モデルを学習モードと検証モードに切り替えながらエポック単位で訓練し、損失や正解率を記録していきます。

optimizer.zero_grad()で勾配を初期化し、loss.backward()で勾配を計算後、optimizer.step()でモデルを更新します。学習中はmodel.train()を使用し、勾配計算を有効化します。検証中はmodel.eval()を使用し、torch.no_grad()内で勾配計算を無効化することで、メモリ消費が削減されます。

# モデルの学習を行う関数
def train_model(model, criterion, optimizer, train_loader, val_loader, device, num_epochs=10):

    history = {
        "train_loss": [],
        "val_loss": [],
        "train_accuracy": [],
        "val_accuracy": []
    }

    for epoch in range(num_epochs):
        # 訓練モード
        model.train()
        running_loss = 0.0
        correct_train = 0
        total_train = 0

        for inputs, labels in train_loader:
            inputs, labels = inputs.to(device), labels.to(device)

            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item() * inputs.size(0)
            _, predicted = torch.max(outputs, 1)
            total_train += labels.size(0)
            correct_train += (predicted == labels).sum().item()

        train_loss = running_loss / len(train_loader.dataset)
        train_accuracy = correct_train / total_train

        # 検証モード
        model.eval()
        val_loss = 0.0
        correct_val = 0
        total_val = 0

        with torch.no_grad():
            for inputs, labels in val_loader:
                inputs, labels = inputs.to(device), labels.to(device)
                outputs = model(inputs)
                loss = criterion(outputs, labels)
                val_loss += loss.item() * inputs.size(0)
                _, predicted = torch.max(outputs, 1)
                total_val += labels.size(0)
                correct_val += (predicted == labels).sum().item()

        val_loss /= len(val_loader.dataset)
        val_accuracy = correct_val / total_val

        # ログを保存
        history["train_loss"].append(train_loss)
        history["val_loss"].append(val_loss)
        history["train_accuracy"].append(train_accuracy)
        history["val_accuracy"].append(val_accuracy)

        print(f"Epoch {epoch + 1}/{num_epochs}, Train Loss: {train_loss:.4f}, Train Accuracy: {train_accuracy:.4f}, "
              f"Validation Loss: {val_loss:.4f}, Validation Accuracy: {val_accuracy:.4f}")

    return model, history

基本的にモデル学習時には学習過程を逐次出力したり、履歴データを蓄積して可視化します。これにより、モデルの学習が適切に進行しているかを視覚的に確認できます。ここでは、特にEpochごとに損失(Loss)が減少しているか、学習データと検証データの損失や正解率に大きな乖離がないか(過学習が起きていないか)を確認していきます。もし、問題がありそうであれば、必要に応じてハイパーパラメータ(学習率、エポック数など)の調整やデータ拡張の適用を実施して、より汎化性能が高くなるようなモデルとなるように調整していきます。

# 学習履歴を可視化する関数
def plot_training_history(history):

    plt.figure(figsize=(20, 3), dpi=200)
    sns.set_style("darkgrid")

    # 損失の可視化
    plt.subplot(121)
    plt.title("Cross Entropy Loss", fontsize=15)
    plt.xlabel("Epochs", fontsize=12)
    plt.ylabel("Loss", fontsize=12)
    plt.plot(history["train_loss"], label="Train Loss")
    plt.plot(history["val_loss"], label="Validation Loss")
    plt.legend()

    # 正確率の可視化
    plt.subplot(122)
    plt.title("Classification Accuracy", fontsize=15)
    plt.xlabel("Epochs", fontsize=12)
    plt.ylabel("Accuracy", fontsize=12)
    plt.plot(history["train_accuracy"], label="Train Accuracy")
    plt.plot(history["val_accuracy"], label="Validation Accuracy")
    plt.legend()

    plt.show()

# 学習の実行と可視化
start_time = time.time()
model, history = train_model(model, criterion, optimizer, train_loader, val_loader, CFG.device, CFG.num_epochs)
print(f"Training time: {time.time() - start_time:.1f} seconds")

plot_training_history(history)

学習結果は以下の通りで、訓練データ・検証データともに損失が減少し、正解率が向上していることから、概ね順調に学習が進んでいることが分かります。ただし、検証データの一部にスパイクが見られるため、学習率の調整や正則化の導入などによる改善の余地がありそうです。

Epoch 1/10, Train Loss: 0.7986, Train Accuracy: 0.6268, Validation Loss: 0.8006, Validation Accuracy: 0.6195
Epoch 2/10, Train Loss: 0.6436, Train Accuracy: 0.6987, Validation Loss: 1.0596, Validation Accuracy: 0.5175
Epoch 3/10, Train Loss: 0.6078, Train Accuracy: 0.7173, Validation Loss: 0.6551, Validation Accuracy: 0.6969
Epoch 4/10, Train Loss: 0.5532, Train Accuracy: 0.7513, Validation Loss: 0.6953, Validation Accuracy: 0.7167
Epoch 5/10, Train Loss: 0.5060, Train Accuracy: 0.7755, Validation Loss: 1.0966, Validation Accuracy: 0.5374
Epoch 6/10, Train Loss: 0.4544, Train Accuracy: 0.8036, Validation Loss: 0.6855, Validation Accuracy: 0.7293
Epoch 7/10, Train Loss: 0.4261, Train Accuracy: 0.8135, Validation Loss: 0.5070, Validation Accuracy: 0.7704
Epoch 8/10, Train Loss: 0.3819, Train Accuracy: 0.8377, Validation Loss: 0.4701, Validation Accuracy: 0.8081
Epoch 9/10, Train Loss: 0.3399, Train Accuracy: 0.8590, Validation Loss: 0.5456, Validation Accuracy: 0.8134
Epoch 10/10, Train Loss: 0.3048, Train Accuracy: 0.8764, Validation Loss: 0.3594, Validation Accuracy: 0.8511
Training time: 4.5 minutes

評価

学習が完了したモデルを用いて、テストデータに対する予測と評価を行います。評価では、主に以下の指標を確認します。

  • 損失(Loss): モデルがどの程度誤差を出しているか
  • 正解率(Accuracy): 予測が正しく分類された割合
  • 混同行列(Confusion Matrix): クラスごとの誤分類の傾向
test_loader = DataLoader(CustomDataset(X_test, transform=transform), batch_size=CFG.batch_size, shuffle=False)

# テストデータセットに対する予測と評価
def evaluate_model(model, test_loader, criterion, device, class_names):
    model.eval()
    test_loss, correct, total = 0.0, 0, 0
    all_preds, all_labels = [], []

    with torch.no_grad():
        for inputs, labels in test_loader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            loss = criterion(outputs, labels)

            test_loss += loss.item() * inputs.size(0)
            _, predicted = torch.max(outputs, 1)

            total += labels.size(0)
            correct += (predicted == labels).sum().item()
            all_preds.extend(predicted.cpu().numpy())
            all_labels.extend(labels.cpu().numpy())

    test_loss /= len(test_loader.dataset)
    test_accuracy = correct / total

    print(f"Test Loss: {test_loss:.4f}, Test Accuracy: {test_accuracy:.4f}")

    # 混同行列を計算して表示
    cm = confusion_matrix(all_labels, all_preds)
    plt.figure(figsize=(8, 3))
    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=class_names, yticklabels=class_names)
    plt.xlabel("Predicted Label")
    plt.ylabel("Actual Label")
    plt.title("Confusion Matrix")
    plt.show()

    return all_preds, all_labels

# クラス名を定義
class_names = ["cat", "dog", "mugi"]
all_preds, all_labels = evaluate_model(model=model, test_loader=test_loader, criterion=criterion, device=CFG.device, class_names=class_names)

以下のような評価結果が得られました。テストデータに対する正解率は85.7%となっており、損失も適度に抑えられていることから、モデルは比較的良好な性能を発揮していると考えられます。

Test Loss: 0.3499, Test Accuracy: 0.8570

混同行列を確認すると、犬を猫と誤分類するケースや猫を犬と誤分類するケースが比較的多く見られました。一方で、「むぎちゃん」の識別精度は高い傾向にあります。これは、「むぎちゃん」のデータが他のクラスと比べて特徴が際立っているため、高精度で分類できた可能性があります。例えば、特定の模様や色(茶色の縞々)、撮影環境(自宅、スマホカメラ)が一貫していることにより、他の犬や猫と明確に区別されやすかったと考えられます。(あとは「可愛さ」という特徴をモデルが捉えられていれば、むぎちゃんはそれで識別できていたのかもしれませんね!)

これらの結果を加味すると、背景や撮影条件がモデルの学習に影響を与え、モデルが本来識別すべき形状や体の特徴よりも、不要な情報に依存している可能性が考えられます。そのため、テストデータに対する正解率が高いにもかかわらず、実際には汎化性能が不足し、新しいデータに対して適切に分類できないということが起こりうるかもしれません。より正確な評価を行うためには、データの多様性を増やし、異なる環境で撮影された画像を含めるなどすると良さそうです。

誤差分析

次にどのようなデータが誤分類されているのかも確認してみました。やはり、「むぎちゃん」は「むぎちゃん」である以前に猫であるため、猫と誤分類されるケースが多く見られました。特に、服を着ている画像や遠くから撮影された画像では、正しく識別できない傾向がありました。

一方で、誤って「むぎちゃん」と分類されたケースを確認すると、茶色の犬や猫、茶トラの猫(これは仕方ないですね)、体型が似ている猫などが誤分類されていました。このことから、モデルは「茶色で丸い猫」をむぎちゃんとして認識している可能性が高いことが推測されます。


正解ラベルが「むぎちゃん」だが、他のクラス(犬または猫)と予測されたデータ


正解ラベルが「むぎちゃん」以外(犬または猫)だが、「むぎちゃん」と誤って予測されたデータ

様々な条件での実験結果

最後に、勉強も兼ねて、前処理方法やハイパーパラメータを変更したり、異なる画像モデルを試したり、事前学習済みモデルを活用するなど、比較実験を行いました。

結果を確認すると、他のモデルは学習時間が長くなる一方で、ResNetと比べて正解率の向上はあまり見られませんでした。一方で、エポック数を増やしたり、事前学習済みモデルを利用したりすることで、正解率が向上することが確認できました。データ数がそれほど多くないため、エポック数を増やして繰り返し学習することや、あらかじめ分類性能の高いモデルを追加学習することが精度の高さにつながったのかもしれません。

おわりに

本記事では、犬・猫・むぎちゃんを分類する画像認識モデルの作成を通じて、データ収集からモデルの構築・評価までの一連の流れをご紹介しました。想定より精度が高く分類ができたことが驚きでした。

実際の業務ではもちろん、趣味の範囲でもこのような小さなテーマから機械学習に触れることで、得られる学びは多いと感じています。今回の試みが、画像分類に興味を持つ方々の一助となれば幸いです。ご覧いただきありがとうございました。

DAL Tech Blog

Discussion