【ネコと機械学習6】ネコ品種分類器の見える化を実装【Colab & Pytorch & GradCAM】

先日、ネコの品種分類器の判断根拠の見えるかについて紹介しました。
【ネコと機械学習5】ネコ品種分類器の判断根拠を見える化しました【GradCAM】【XAI】

今回はGoogleColaboratoryを使った分類根拠の可視化について、実装コードを紹介したいと思います!
フレームワークは Pytorch で、可視化にはGradCAMを使いました。

GoogleColaboratoryについて

Google ColaboratoryはGoogleのアカウントを持っていれば、無料で利用することができます。GoogleColabolatoryではJupyterNotebookでPythonを記述できるためとても便利です。
GoogleColabolatoryでは、無料でGPUを使うことができるので、画像分類を勉強したい初心者にはもってこいです。無料のため使用量に制限がありますので、上限に達したら時間をおいて再接続しましょう。

GoogleColabolatoryにはデータを保存することはできないため、Google Driveをマウントしてファイルのアップロードや保存を行います。GoogleDriveもGoogleアカウントがあれば無料で使えます。

データセットの用意

データセットはKaggleにあるOxfordのVGGが用意したデータセットを使いました。https://www.kaggle.com/datasets/zippyz/cats-and-dogs-breeds-classification-oxford-dataset
 データセットには、犬と猫のデータが一つのフォルダに含まれており、今回はネコだけを扱いたいので、以下のPythonコードを使ってネコの品種だけを分けるコードを実行し、「catbreed」というフォルダに格納するようにします。
 ローカルでJupyternotebookを使って以下のcatcollect.ipynbを実行する場合は、以下のフォルダ階層の様に、catcollect.ipynbと同じ階層に、Kaggleからダウンロードした<images>フォルダを置いておき、空の<catbreeds>フォルダを作成しておきます。

C:\USERS\xxxxxxx\DESKTOP\ブログ\20220813_ネコ種の分類
├─<images>
└─<catbreeds>
└─catcollect.ipynb

# ネコの画像だけを集めるコード catcollect.ipynb

# ネコの品種だけリストアップしておく
catbreeds=[
    'Abyssinian',
    'Bengal',
    'Birman',
    'Bombay',
    'British_Shorthair',
    'Egyptian_Mau',
    'Maine_Coon',
    'Persian',
    'Ragdoll',
    'Russian_Blue',
    'Siamese',
    'Sphynx',
]

# ネコの画像のパスだけをリストに格納
import glob
imglist = []
for catbreed in catbreeds:
    imgs = glob.glob(f'images/{catbreed}*.jpg')
    imglist += imgs

# imagesフォルダからネコの画像だけ複製してcatbreedsフォルダに移動
import shutil
new_dir_path = 'catbreeds'

for src in imglist:
    shutil.copy(src, new_dir_path)

上記のコードを実行することで、catbreedsフォルダには、ネコの品種12種それぞれ200データ、合計2400データがコピーされます。このフォルダごとGoogleDriveにアップロードしておきます。

実装コード

コードは以下の3段階からなります。

1. 学習フェーズ(GPU推奨)
2. 分類フェーズ(CPU)
3. 可視化フェーズ(CPU)

1.と2.は前回紹介したコードとほぼ同じなのですが、データ前処理ライブラリを変更していたりするため、全体の実行コードを書いておきたいと思います。

学習フェーズ(GPU推奨)

#必要なライブラリ用意
import numpy as np
import random
import glob
from PIL import Image
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torch.utils.data as data
import torchvision
from tqdm import tqdm
from torchvision import models, transforms, datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import os
import time

# 今回の実装にあたって追加したライブラリ
import cv2
from torch.utils.data import Dataset, DataLoader
import albumentations as A
from albumentations.pytorch.transforms import ToTensorV2

GradCAMを利用するにあたり、以前紹介したコードをそのまま活用するとエラーが出てしまいました。
そこで、データ前処理を行うライブラリとして「albumentations」を用いたコードに変更しました。

#シードを固定

torch.manual_seed(0)
np.random.seed(0)
random.seed(0)
# データの前処理方法の定義
transform = A.Compose([
    A.Resize(150, 150),
    ToTensorV2()
])

# データセットをtensor型に変換し、labelを付与するクラス
class DS(Dataset):
    def __init__(self, data, transform = transform):
        self.data = data #画像のパスのリスト
        self.transform = transform
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):

        path = self.data[idx]
        label = path.split('/')[-1].split('_')[0]
        if label in breeds.keys():
          label = breeds[label]
        else:
          label = 0

        image = Image.open(path)
        image = image.convert("RGB") #RGBチャンネルに限定しておく
        image = self.transform(image = np.array(image))["image"]

        label = torch.tensor(label)
        return image, label

以前紹介したtransformsを用いる場合はエラーがでて解決できなかったため、
データ前処理の方法をalbumentationsライブラリを用いたものに変更しています。

# ネコ12品種の辞書を作成
breeds ={
    'Abyssinian':0,
    'Bengal':1,
    'Birman':2,
    'Bombay':3,
    'British':4,
    'Egyptian':5,
    'Maine':6,
    'Persian':7,
    'Ragdoll':8,
    'Russian':9,
    'Siamese':10,
    'Sphynx':11,
}

# ネコ12品種ごとにパスのリストを辞書の形で作成
breed_paths={}

for breed in breeds.keys():
  breed_paths[breed]= glob.glob(f'/content/drive/MyDrive/mystudy/20220806_catbreeds/catbreeds/{breed}*.jpg')

ネコ12の品種名と数値ラベルを対応させるための辞書です。
正規表現でデータを分けるため、辞書の品種名キーが略称になっています。
後でキーとバリューを逆にした辞書も定義していますが、今回はこの方法でいくことにしました。

#学習データの数を決める

Ntrain = 180
train_list = []

for key,value in breed_paths.items():
  train_list += value[:Ntrain]
#学習データの数を決める
Ntrain = 180
train_list = []

for key,value in breed_paths.items():
  train_list += value[:Ntrain]

#学習データを訓練データと検証データに分けるためのパスのリストを定義
train_idx, valid_idx = train_test_split(range(len(train_list)), test_size = 0.25, random_state = 0)

train_path_list = []
val_path_list = []
for index in train_idx:
    i = train_list[index]
    train_path_list.append(i)

for index in valid_idx:
    i = train_list[index]
    val_path_list.append(i)

#テストデータの数を決める
test_path_list = []

for key,value in breed_paths.items():
  test_path_list += value[Ntrain:]

# 訓練、検証、テストデータの数を確認
print(len(train_path_list), len(val_path_list), len(test_path_list))
 #-> (1620, 540, 240)
#パスのリストからデータセットを読み込む
train_dataset = DS(train_path_list)
val_dataset = DS(val_path_list)
test_dataset = DS(test_path_list)
# 画像が正しく読み込めているか確認

#ラベルと品種名からなる辞書を作成
breedsdict ={
    0:'Abyssinian',
    1:'Bengal',
    2:'Birman',
    3:'Bombay',
    4:'British_Shorthair',
    5:'Egyptian_Mau',
    6:'Maine_Coon',
    7:'Persian',
    8:'Ragdoll',
    9:'Russian_Blue',
    10:'Siamese',
    11:'Sphynx'
}

#保存するディレクトリを指定
savedir="/content/drive/MyDrive/mystudy/20221214_GradCAM/"

num =21 # 画像がランダムに並んでいるので番号を手動で選択

plt.rcParams["font.size"] = 18
plt.figure(figsize=(6, 6))
x, t = train_dataset[num]
exbreed = breedsdict[int(t)]
plt.title(exbreed)
plt.axis('off')
plt.imshow(x.permute(1, 2, 0))
plt.savefig(savedir+f'example_{exbreed}.jpg')

データを確認し、保存しておきます。
GoogleColaboratoryを使っていると、os.getcwd()がcontentを返すため、絶対パスで保存ディレクトリを設定しています。

# データをまとめて確認する
plt.figure(figsize=(20, 20))
for n in range(54):
    x, t = train_dataset[n]
    plt.subplot(6, 9, n+1)
    plt.title(int(train_dataset[n][1]))
    plt.axis('off')
    plt.imshow(x.permute(1, 2, 0))
plt.savefig(savedir+'examples.jpg')
#データローダーでデータをミニバッチに分ける
batch_size = 20
train_dataloader = data.DataLoader(
    train_dataset, batch_size=batch_size, shuffle = True)

val_dataloader = data.DataLoader(
    val_dataset, batch_size=batch_size, shuffle = False)

test_dataloader = data.DataLoader(
    test_dataset, batch_size=batch_size, shuffle = False)

dataloaders_dict = {'train': train_dataloader,
                    'val': val_dataloader}
#Efficientnetのインストール
!pip install efficientnet_pytorch
from efficientnet_pytorch import EfficientNet
#Efficientnetモデルのインスタンス化
model = EfficientNet.from_pretrained('efficientnet-b7')
num_ftrs = model._fc.in_features
model._fc = nn.Linear(num_ftrs, 12) # 12クラス分類用に出力を12個にする
EPOCHS = 50
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr = 0.001, momentum=0.9)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)
#訓練と検証を行う関数を定義

def train_model(model, dataloaders_dict, loss_fn, optimizer, epochs, scheduler):
    history = {'loss':[], 'acc':[], 'val_loss':[], 'val_acc':[]}
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    model = model.to(device)
    for epoch in range(epochs):
        print(f'{epoch + 1} start')
        print('----------')

        for phase in ['train', 'val']:
            if phase == 'train':
                model.train()
            else:
                model.eval()

            epoch_loss = 0.0
            epoch_corrects = 0.0

            #if (epoch == 0) and (phase == 'train'):
            #    continue

            for batch in tqdm(dataloaders_dict[phase]):
                inputs = batch[0].float().to(device)
                labels = batch[1].to(device)

                optimizer.zero_grad()

                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    loss = loss_fn(outputs, labels)
                    pred_value, pred_label = torch.max(outputs, 1)

                    if phase == 'train':
                        loss.backward()
                        optimizer.step()

                    epoch_loss += loss.item() * inputs.size(0)
                    epoch_corrects += torch.sum(pred_label == labels.data)



            epoch_loss /= len(dataloaders_dict[phase].dataset)*1.0
            epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset)*1.0

            print(f'{phase} Loss: {epoch_loss :.4f} Acc : {epoch_acc :.4f}')
            if phase == 'train':
                history['loss'].append(epoch_loss)
                history['acc'].append(epoch_acc)
            else:
                history['val_loss'].append(epoch_loss)
                history['val_acc'].append(epoch_acc)

            if phase == 'train':
                scheduler.step()
    return history
#学習を開始する。学習曲線のためにhistoryに格納していく
%%time
history = train_model(model, dataloaders_dict, loss_fn, optimizer, EPOCHS, scheduler)

GPUを使って50 エポック学習させた場合、45分くらいで終了します。

# 学習済みモデルの保存
savedparams = 'catbreedscls_1620data_efficientnet_50epochs_lr0001'
torch.save(model.state_dict(), savedir+savedparams+'.mt')
# 学習曲線を描くための関数を定義

def plot_graph(values1, values2, rng, label1, label2, figname):
    plt.plot(range(rng), values1, label=label1)
    plt.plot(range(rng), values2, label=label2)
    plt.title(os.path.splitext(os.path.basename(figname))[0])
    plt.legend()
    plt.grid()
    plt.savefig(figname)
    plt.show()
# 学習済みモデルの保存
savedparams = 'catbreedscls_1620data_efficientnet_50epochs_lr0001'
torch.save(model.state_dict(), savedir+savedparams+'.mt')
# 学習曲線を描くための関数を定義

def plot_graph(values1, values2, rng, label1, label2, figname):
    plt.plot(range(rng), values1, label=label1)
    plt.plot(range(rng), values2, label=label2)
    plt.title(os.path.splitext(os.path.basename(figname))[0])
    plt.legend()
    plt.grid()
    plt.savefig(figname)
    plt.show()
#loss curveを描画

t_losses = history['loss']
t_accus = history['acc']
v_losses = history['val_loss']
v_accus = history['val_acc']

lossfigname = '_loss' + '.jpg'

plot_graph(t_losses, v_losses, EPOCHS, 'loss(train)', 'loss(validate)',savedir+savedparams+lossfigname)
#learning curveを描画

lcfigname = '_learningcurve' + '.jpg'

t_accus_np = [i.to('cpu').detach().numpy().copy() for i in t_accus]
v_accus_np = [i.to('cpu').detach().numpy().copy() for i in v_accus]

plot_graph(t_accus_np, v_accus_np, EPOCHS, 'acc(train)', 'acc(validate)',savedir + savedparams + lcfigname)

訓練データと検証データの決定係数に差があり、過学習ぎみであることがわかります。
今回はこのモデルで予測と可視化をおこなっていきます。

ここまでは、GPUに接続して実行します。
これ以降の予測と可視化では、CPUに接続しておけば大丈夫です。

予測フェーズ(CPU)

# ネットワークの準備
from efficientnet_pytorch import EfficientNet

model = EfficientNet.from_pretrained('efficientnet-b7').cpu().eval()
num_ftrs = model._fc.in_features
model._fc = nn.Linear(num_ftrs, 12)

# 重みの読み込み
modeldir = "/content/drive/MyDrive/mystudy/20221214_GradCAM/"
savedparams = 'catbreedscls_1620data_efficientnet_50epochs_lr0001'
model.load_state_dict(torch.load(modeldir+savedparams+ '.mt', map_location=torch.device('cpu')))

#modelを評価状態にする
model.eval()
# テストデータに対する結果を表示

prob = []
prdbreed = []
pred = []
Y = []
for i, (x,y) in enumerate(test_dataloader):
    with torch.no_grad():
        output = model(x.float())
    prdbreed += [torch.argmax(F.softmax(l)).to('cpu').detach().numpy().copy() for l in output]
    prob += [round(torch.max(F.softmax(l)).to('cpu').detach().numpy().copy()*100,1) for l in output]

    pred += [int(l.argmax()) for l in output]
    Y += [int(l) for l in y]

print(classification_report(Y, pred))

12品種20データ、合計240テストデータに対する正解率(accuracy)は81%でした。

# テストデータに対する予測結果をまとめて表示

plt.rcParams["font.size"] = 18
plt.figure(figsize=(20, 20))
for n in range(12):
    x, t = test_dataset[n]
    plt.subplot(3, 4,n+1)
    plt.title(str(prob[n])+'%')
    plt.title(f'{breedsdict[int(prdbreed[n])]} {prob[n]}%')
    plt.axis('off')
    plt.imshow(x.permute(1, 2, 0))

savedir = "/content/drive/MyDrive/mystudy/20221214_GradCAM/"    
plt.savefig(savedir+'20221218_efficientnet_test.jpg')

アビシニアンのテストデータだけまとめて分類結果を表示しています。
おおむね、アビシニアンと正しく分類できていますが、ベンガルと誤分類しているものもあります。
柄がよく似ていますからね^^;

GradCAMによる可視化フェーズ(CPU)

ここからが、ポイントとなる可視化フェーズです!

!pip install grad-cam -q
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.image import show_cam_on_image
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget

Githubのpytorch_grad_camをpip install します。

# CAMを作成する層を設定
# 今回は出力である「_conv_head」を設定
target_layers = [model._conv_head] # リストの形で渡す必要があることに注意
cam = GradCAM(model = model, target_layers = target_layers, use_cuda = torch.cuda.is_available())

Githubのefficientnetを見て、_conv_headをtarget_layerに設定しておくことにしました。
https://github.com/lukemelas/EfficientNet-PyTorch/blob/master/efficientnet_pytorch/model.py

# テストデータの予測結果を確認

fignum=0
label=int(prdbreed[fignum])
prd_prob = format(prob[fignum].max(), '.1f')

test_image = cv2.imread(test_path_list[fignum])
test_image = cv2.resize(test_image,(150,150))
test_image = cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB)
plt.imshow(test_image)
plt.title(f'{breedsdict[label]} {prd_prob}%')
plt.savefig(savedir+f"20221218_check_result_{fignum}.jpg")
plt.show()

先ほどの分類結果を確認しておきます。

# 予測結果に対するCAMを表示

fignum = 0
test_image = cv2.imread(test_path_list[fignum])
test_image = cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB)
label=int(prdbreed[fignum])
prd_prob = format(prob[fignum].max(), '.1f')

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
cat_input_image = transform(image = test_image)["image"].float().unsqueeze(0).to(DEVICE)
input_tensor = cat_input_image #(batch_size, channel, height, width)
vis_image = cv2.resize(test_image, (150, 150)) / 255.0 #(height, width, channel), [0, 1]
label_cam =[ClassifierOutputTarget(label)]

grayscale_cam = cam(input_tensor = input_tensor, targets = label_cam)
grayscale_cam = grayscale_cam[0, :]
visualization = show_cam_on_image(vis_image, grayscale_cam, use_rgb = True)
plt.imshow(visualization)
plt.title(f'{breedsdict[label]} {prd_prob}%')
plt.savefig(savedir+f"20221218_check_result_GradCAM_{fignum}.jpg")
plt.show()

GradCAMにより、画像に分類根拠を示すヒートマップを重ねることができました。
上の画像の例では、被写体であるネコの一部が赤くなっており、部分的に注目していることがわかります。

# CAMをまとめて表示

plt.rcParams["font.size"] = 18
plt.figure(figsize=(20, 20))

for fignum in range(12):
  test_image = cv2.imread(test_path_list[fignum])
  test_image = cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB)
  test_image = cv2.resize(test_image,(150,150))
  label=prob[fignum].argmax()
  prd_prob = format(prob[fignum].max(), '.1f')

  cat_input_image = transform(image = test_image)["image"].float().unsqueeze(0).to(DEVICE)
  input_tensor = cat_input_image #(batch_size, channel, height, width)
  vis_image = cv2.resize(test_image, (150, 150)) / 255.0 #(height, width, channel), [0, 1]
  label_cam =[ClassifierOutputTarget(label)]

  grayscale_cam = cam(input_tensor = input_tensor, targets = label_cam)
  grayscale_cam = grayscale_cam[0, :]
  visualization = show_cam_on_image(vis_image, grayscale_cam, use_rgb = True)
  plt.subplot(3, 4,fignum+1)
  plt.imshow(visualization)
  plt.title(f'{breedsdict[int(prdbreed[fignum])]} {prd_prob}%')
  plt.savefig(savedir+f'efficientnet_CAM_all.jpg')

分類の判断根拠をまとめて可視化しました。
どのテスト画像も、おおむね被写体であるネコちゃんに注目していることがわかります。
多くは柄や色に注目している様ですね。

参考になればうれしいです

今回はGradCAMを用いたネコ品種分類器の可視化を実装する方法を紹介しました。
GradCAM以外にも説明可能AIに関する技術は研究されているので、私も勉強を続けたいと思います!
今後も機械学習をはじめとした勉強内容を発信していきますので、よろしくお願いします!

参考サイト様

本記事を作成するにあたり、以下のサイト様を参考にさせていただきました。
https://htomblog.com/python-gradcam

1 COMMENT

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA