【ネコと機械学習4】Pytorchを使ったネコの品種分類器の実装

2022年12月17日追記 【ネコと機械学習5】ネコ品種分類器の判断根拠の見える化

 先日、ネコの品種分類器について紹介しました。今回は、Pytorchを使ったネコの品種分類器の実装コードを共有していきたいと思います!実装の環境やコードの枠組みは、前回作ったネコ判別器の記事に書いてあるものと同じですので、そちらも見ていただければと思います!

環境 : GoogleColaboratory

 ネコの品種分類器の時と同様、ネコの品種分類器もGoogle Colaboratoryで作りました。前回作ったネコ判別器の記事に設定方法を書いておりますのでそちらを見ていただければと思います!

データセットの準備 : KaggleのOxford Dataset

 データセットは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にアップロードしておきます。

分類番号品種名データ数
0アビシニアン200
1ベンガル200
2バーマン200
3ボンベイ200
4ブリティッシュショートヘア200
5エジプシャンマウ200
6メインクーン200
7ペルシャ200
8ラグドール200
9ロシアンブルー200
10シャム200
11スフィンクス200
合計2400

ライブラリやクラスの定義

ライブラリやクラスの定義は、【ネコと機械学習2】Pytorchを使ったネコ判別器の作り方のものと大体は同じです。大きく違う点は、ネコの品種12種類のラベル付けです。

#必要なライブラリ用意
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
#シードを固定

torch.manual_seed(0)
np.random.seed(0)
random.seed(0)
#データの前処理を行うクラスを定義

class ImageTransform():
    def __init__(self, resize, mean, std):
        self.data_transform = {
            'train': transforms.Compose([
                transforms.Resize((resize, resize)),
                #transforms.RandomHorizontalFlip(p=0.5),
                #transforms.RandomCrop(size =(150, 150), padding=18),
                transforms.ToTensor(),
                #transforms.Normalize(mean,std)
            ]),
            'val': transforms.Compose([
                transforms.Resize((resize,resize)),
                transforms.ToTensor(),
                #transforms.transforms.Normalize(mean, std)
            ]),
            'test' : transforms.Compose([
                transforms.Resize((resize,resize)),
                transforms.ToTensor(),
                #transforms.transforms.Normalize(mean, std)
            ])
        }

    def __call__(self, img, phase):
        return self.data_transform[phase](img)
#データにネコの品種のラベル付けを行うクラスを定義

class CatBreedsDataset(data.Dataset):
    def __init__(self, file_list, phase, breeds,transform = None):
        self.file_list = file_list
        self.transform = transform
        self.phase = phase
        self.breeds = breeds

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

    def __getitem__(self, index):
        img_path = self.file_list[index]
        img = Image.open(img_path)
        img = img.convert("RGB") #RGBチャンネルに限定しておく

        img_trans = self.transform(
            img, self.phase)

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


        return img_trans, label

 Kaggleからダウンロードしてきたデータをそのまま学習させようとしたら、画像によってはチャンネルの数がRGBの3つではなく、4つになっているものもありましたので、ここではimg = img.convert(“RGB”)と指定してチャンネルをRGBの3つに限定しておきます。

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,
}
    
breed_paths={}

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

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

Ntrain = 180
train_list = []

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

 学習データは、各品種について200データなので、各品種から180データを訓練と検証用に確保しておきます。残りはテストデータにします。

#学習データを訓練データと検証データに分けるためのパスのリストを定義

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:]

 訓練と検証データに用いなかったデータをテストデータにします。

# 訓練、検証、テストデータの数を確認

len(train_path_list), len(val_path_list), len(test_path_list)
 #-> (1620, 540, 240)
#パスのリストからデータセットを読み込む

size = 150
mean = (0.5,0.5,0.5)
std = (0.5,0.5,0.5)

train_dataset = CatBreedsDataset(
    file_list = train_path_list, transform = ImageTransform(size, mean, std), phase = 'train',breeds=breeds)

val_dataset = CatBreedsDataset(
    file_list = val_path_list, transform = ImageTransform(size, mean, std), phase = 'val',breeds=breeds)

test_dataset = CatBreedsDataset(
    file_list = test_path_list, transform = ImageTransform(size, mean, std), phase = 'test',breeds=breeds)

 CatBreedsDatasetには、品種のラベルを格納した辞書(breeds)を引数に与えます。

# 画像が正しく読み込めているか確認

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'
}

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

plt.rcParams["font.size"] = 18
plt.figure(figsize=(6, 6))
x, t = train_dataset[num]
exbreed = breedsdict[t]
plt.title(exbreed)
plt.axis('off')
plt.imshow(x.permute(1, 2, 0))
plt.savefig(f'/content/drive/MyDrive/mystudy/20220813_catbreeds_classifier_forblog/example_{exbreed}.jpg')

 画像が正しく読み込めているかを確認しています。訓練データはシャッフルされるので、毎回並び順が変わります。

# データをまとめて確認する
plt.figure(figsize=(20, 20))
for n in range(54):
    x, t = train_dataset[n]
    plt.subplot(6, 9, n+1)
    plt.title(train_dataset[n][1])
    plt.axis('off')
    plt.imshow(x.permute(1, 2, 0))
plt.savefig('/content/drive/MyDrive/mystudy/20220813_catbreeds_classifier_forblog/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}

 DataLoaderでデータをミニバッチにわけます。バッチサイズは20にしていますが、調整の余地があると思います。

モデル定義

単純な畳み込みニューラルネットワークでは過学習

 前回定義した単純な4層の畳み込みニューラルネットワークで今回のタスクを学習させてみました。全結合層の出力は12にしてあります。

 学習曲線の訓練データに対する正解率が1に近いのに、検証データに対しては0.4ほどになっています。これは過学習を起こしているのが原因です。4層の畳み込みニューラルネットのような単純なモデルでは、ネコとイヌの分類くらいなら問題なくできましたが、ネコの品種12種を分類するようにより複雑な問題に対しては対応しきれないということでしょうか。自分で作ったモデルだと過学習してしまう様なので、最高の畳み込みニューラルネットワークと名高いefficientnet-b7を使ってネコの12品種の分類を行いました。

最高の畳み込みニューラルネットワーク : efficientnet-b7を使う


 efficientnet-b7は、resnetなど既存モデルにおけるネットワークの深さや広さなどのパラメータをバランスよく最適化したモデルであり、現状の畳み込みニューラルネットで最高の性能を出すことができます。

!pip install efficientnet_pytorch

 GoogleColabolatoryでPytorchのefficientnetモデルを使うために、pip installします。

#Efficientnetモデルのインスタンス化

from efficientnet_pytorch import EfficientNet

model = EfficientNet.from_pretrained('efficientnet-b7')
num_ftrs = model._fc.in_features
model._fc = nn.Linear(num_ftrs, 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)

 モデルをインスタンス化します。今回は12種類の分類なので、モデルの全結合層の出力を12にしておきます。また、学習率(lr)は1e-4ではなかなか学習が進まなかったので、1e-3としました。

学習とモデルの保存

 学習とモデルの保存は、前回のものと同様です。

#訓練と検証を行う関数を定義

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 inputs, labels in tqdm(dataloaders_dict[phase]):
                inputs = inputs.to(device)
                labels = labels.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)

訓練データ1620、検証データ540で50epochの学習を行うと45minほどで終了します。

# 学習済みモデルの保存
modeldir = '/content/drive/MyDrive/mystudy/20220813_catbreeds_classifier_forblog/'
savedparams = 'catbreedscls_1620data_efficientnet_50epochs_lr0001'
torch.save(model.state_dict(), modeldir+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)',modeldir+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)',modeldir + savedparams + lcfigname)

 efficientnet-b7を使ったところ、検証データに対する正解率は80%程になりました。自分で作った4層の畳み込みニューラルネットの40%に比べると格段に優れた結果です。しかし、それでも学習データの精度とは乖離があります。テストデータの精度が上がりきらないのは、やはり過学習を防ぎきれないからでしょうか?データ拡張などすればもっと高い精度が出せそうです。

テストデータに対する結果を確認

 ここからはテストデータに対する結果を計算するので、GPUに接続していなくても大丈夫です。

# ネットワークの準備

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/20220813_catbreeds_classifier_forblog/'
savedparams = 'catbreedscls_1620data_efficientnet_50epochs_lr0001'
model.load_state_dict(torch.load(modeldir+savedparams+ '.mt', map_location=torch.device('cpu')))
#modelを評価状態にする
model.eval()
#テストデータセットを読み込み
# x : 画像、 t : ラベル
x, t = test_dataset[1]
# NNによる出力値の算出
y = model(x.unsqueeze(0))
print(y)
# --> tensor([[ 5.9189,  1.7976, -1.0649, -1.4619, -0.3423,  1.3339, -1.4173, -2.0221,
#         -1.4546, -0.8399,  0.6408, -0.9661]], grad_fn=<AddmmBackward0>)

 今回は12クラスの分類なので、モデルの出力層から12個の数値が渡されているのがわかります。

# ソフトマックス関数で確率に変換
y = F.softmax(y)
y
# --> tensor([[9.6264e-01, 1.5617e-02, 8.9217e-04, 5.9985e-04, 1.8376e-03, 9.8222e-03,
#         6.2716e-04, 3.4255e-04, 6.0421e-04, 1.1173e-03, 4.9114e-03, 9.8480e-04]],
#       grad_fn=<SoftmaxBackward0>) 0番目のラベル(=アビシニアン)の確率が最も高い

 NNからの出力をソフトマックス関数に入力すると、12品種の予測確率が返ってきます。

# テストデータに対する結果を表示

prob = []
prdbreed = []
pred = []
Y = []
for i, (x,y) in enumerate(test_dataloader):
    with torch.no_grad():
        output = model(x)
    #prob += [i.to('cpu').detach().numpy().copy()[0] for i in output]
    #prob += [round(F.softmax(l)[0].to('cpu').detach().numpy().copy()*100,1) for l in output]
    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))

 テストデータに対する結果を表示します。正解率80%であることがわかります。また、ネコの品種によって適合率と再現率、F値が異なっています。ラベル3のボンベイのF値が1であり、ラベル8のラグドールのF値が0.61と最も低くなっています。

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

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('{} {}%'.format(breedsdict[int(prdbreed[n])],prob[n]))
    plt.axis('off')
    plt.imshow(x.permute(1, 2, 0))
plt.savefig('/content/drive/MyDrive/mystudy/20220813_catbreeds_classifier_forblog/efficientnet_test.jpg')

 各画像に対する予測結果を表示しています。ほとんどの画像は正しく分類できていますがたまに誤って分類しているのがわかります。

自前画像のテスト

 ご自分で用意されたネコちゃんのデータを入力したい場合、以下のコードを実行します。以下の様に、「xxxx_0.jpg」などとxxxxの部分に分類したいネコちゃんの名前などを付けておき、「mycat」フォルダに格納しGoogleDriveにアップロードしておきます。

<C:\Users\xxxxxxx\Desktop\ブログ\20220813_ネコ種の分類\mycats>
 ├anne_0.JPG
 ├anne_1.JPG
 ├anne_10.JPG
 ├anne_11.JPG
 ├anne_12.JPG
 ├anne_13.JPG
 ├anne_2.JPG
 ├anne_3.JPG
 ├anne_4.JPG
 ├anne_5.JPG
 ├anne_6.JPG
 ├anne_7.JPG
 ├anne_8.JPG
 ├anne_9.JPG
 ├ciel_0.JPG
 ├ciel_1.JPG
 ├ciel_10.jpg
 ├ciel_11.JPG
 ├ciel_12.JPG
 ├ciel_13.jpeg
# 自前の画像をロード

mycats ={
    'ciel':0,
    'anne':1,
    'larcn':2,
    'link':3,
}
    
mycats_dict={}

for key,value in mycats.items():
  mycats_dict[key]= glob.glob('/content/drive/MyDrive/mystudy/20220806_catbreeds/mycats/{}_*'.format(key))

 mycatsフォルダのデータに対応する辞書を作っておきます。

#調べたいネコちゃんを指定
mycat = 'anne'
mycat_list = mycats_dict[mycat]


# リサイズなど画像の前処理
mytest_dataset = CatBreedsDataset(
    file_list = mycat_list, transform = ImageTransform(size, mean, std), phase = 'test',breeds=breeds)

mytest_dataloader = data.DataLoader(
    mytest_dataset, batch_size=batch_size, shuffle = False)

 学習の前に定義したCatBreedsDatasetクラスをそのまま使い、用意した画像にラベルを対応させます。CatBreedsDatasetでは自前画像のネコちゃんがどのラベルに分類されるかはテストするまではわからないのですが、DataLoaderに渡す時点で何かのラベルがないといけないので、すべての自前画像に便宜上「0」のラベルを割り振っておきます。

# 自前のテストデータに対する予測確率を格納

proball = []

for i, (x,y) in enumerate(mytest_dataloader):
    with torch.no_grad():
        output = model(x)
    proball += [F.softmax(l).to('cpu').detach().numpy().copy()*100 for l in output]
proball=np.array(proball)

 用意した自前画像それぞれに対して、モデルによる12品種の予測確率を計算し、結果を行列に格納していきます。(例、キジトラのアンちゃんの画像14枚用意したら、14行12列の行列に予測確率が格納される。)

import pandas as pd

df = pd.DataFrame(index=np.arange(len(proball)),columns=list(breedsdict.values()))
for i in range(len(proball)):
  df.iloc[i]=proball[i]

# 平均の予測確率を計算
df_stat = df.mean(axis=0) # axis=0で列方向(=品種ごと)の平均を計算できる
df_stat = df_stat.sort_values(ascending=False)

# 上位何番目までを表示するかを指定
topN = 3
x = df_stat.iloc[:topN]

 Pandasを使って12品種の平均確率を計算します。(例、キジトラのアンちゃんの画像14枚について、12品種の予測確率の平均値を計算。)

# 自前画像から対象のネコちゃんの予測結果をトップNで表示

left = np.arange(1,1+topN)
plt.rcParams["font.size"] = 26

def add_value_label(x_list,y_list):
    for i in range(1, len(x_list)+1):
        y_p ='{}%'.format(round(y_list[i-1],2))
        plt.text(i,y_list[i-1],y_p,ha='center')

plt.figure(figsize=(18, 18))
plt.bar(left,height=x.values,tick_label=x.index,width=0.4)
add_value_label(left,x.values)
plt.title('{} result top{}'.format(mycat,topN))
plt.ylabel("Percentage(%)")
plt.ylim(0,90)
plt.xticks(rotation=45)
plt.savefig('/content/drive/MyDrive/mystudy/20220813_catbreeds_classifier_forblog/{}_top{}.jpg'.format(mycat,topN))
plt.show()

 対象のネコちゃんが平均してどの品種に分類されたかをトップNで表示します。

# 自前画像に対する予測結果をまとめて表示
plt.rcParams["font.size"] = 18
plt.figure(figsize=(20, 20))
for n in range(12):  
    x, t = mytest_dataset[n]
    label = proball[n].argmax()
    prd_prob = format(proball[n].max(), '.1f')
    plt.subplot(3, 4,n+1)
    plt.title(f'{breedsdict[label]} {prd_prob}%')
    plt.axis('off')
    plt.imshow(x.permute(1, 2, 0))
plt.savefig('/content/drive/MyDrive/mystudy/20220806_catbreeds/efficientnet_mycat_{}.jpg'.format(mycat))

 各画像に対する予測結果を個別に表示します。画像のキジトラのアンちゃんはベンガル率が高いです。色と柄の影響が大きいのでしょうか。

機械学習の勉強にはE資格がおすすめ

 筆者は機械学習やプログラミングが専門ではなく、つい3か月前まではこのようなコードを書くことはできませんでした。筆者がこういったコードを実装できるようになったのは、日本ディープラーニング協会(JDLA)のE資格の勉強を行うようになったからです。特にE資格のハンズオンセミナーがとても良い経験になりました。機械学習を勉強したい方にはとてもおすすめの資格だと思います。(ただし、セミナー受講料が大体20万円、試験の受験料も33000円と高いので、可能な限り大学や企業の経費を使わせてもらえるようにできるのがいいと思います^^;)またE資格についても記事を書いていきたいと思います!

ネコちゃんと一緒に機械学習を勉強しましょう

 今回はネコ分類器の実装を紹介しました。ネコ好きの方なら癒されながら勉強できて一石二鳥です!これからもネコの機械学習についての記事を書いていこうと思うので、よろしくお願いします!

2022年12月17日追記
【ネコと機械学習5】ネコ品種分類器の判断根拠を見える化しました【GradCAM】【XAI】

1 COMMENT

コメントを残す

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

CAPTCHA