1 はじめに
この記事は、前回の記事CNNで親子丼と牛丼を見分ける...!!の続編です!前回はテストデータでおよそ84%の精度を達成しましたが、もう少し精度を向上できないかと思いました、、、
そこで、今回は転移学習を実施して精度向上できないか検証してみました。結果として、約95%の精度を達成することができました!!また、モデルが何を見て判断しているか確認するためにGradCAMを用いて可視化してみたので、みていただいたら嬉しいです。
転移学習
転移学習とは
転移学習は、事前学習済みモデルを利用して出力層部分のみを自分の目的のタスク向けに交換し、出力層部分のみを自分で集めたデータセットで学習するというものです。
例えば、犬と猫を高精度に識別したいとします。そして、あらかじめ1000カテゴリのマルチクラス分類を大量の画像データを利用して学習したモデルがあるとします。転移学習は、この学習済みモデルを利用して犬と猫の画像を識別できるようにするために、出力層だけ変更を加え(それまでの重みは固定する)自分が作成したデータセットを用意して犬と猫の画像を学習する手法です。
転移学習を行うメリットとしてデータセットが少ない場合でも高い精度を出せることが多いことです。自分でデータセットを作成するにはかなりの労力がかかります。そういうとき、転移学習を利用することでデータせと作成にコストをあまりかけずに、高精度の推定結果を実現できるようになります。また、大量のデータを使わないため、学習の高速化も期待できるようになります。デメリットとして、学習効果を高めるために転移元とのデータの関連性がある程度必要です。例えば、犬猫の判別に車両のデータを学習したモデルなど、関連性が低いとあまり学習効果を期待できません。
転移学習とよくセットで出てくる手法としてファインチューニングというものがあります。ファインチューニングは、転移学習と違い出力層以外の重みを固定せずモデル全体のパラメータを学習し直すという特徴があります。(この辺は記事や書籍でも逆に書かれていたり、抽象的に書かれていたりしてややこしいです笑)転移学習と比較するとモデル全体のパラメータを更新するので、それなりのデータ量が必要ですが、自分の目的と関連が強いモデルではなくても利用しやすいなどのメリットがあります。
【引用】https://udemy.benesse.co.jp/data-science/deep-learning/transfer-learning.html
今回は、データ量がそこまで多くないので、転移学習を実施しました!(ImageNetを学習したモデルを利用したのでデータとの関連性は薄いかもしれませんが...)
ResNetとは
ResNetとは画像分類アーキテクチャの一つで152層から構成されるCNNになります。画像分類におけるCNNにおいて層を深くすればするほど、より複雑な特徴を検出できるようになると考えられています。しかし、層をただ深くしただけではあまり精度が上がらないことも報告されていました(勾配消失問題)。そのため、20層前後までしか層を深くできませんでした。
ResNetでは残差ブロックという手法を利用して152層という深い層を実現しました。
残差ブロック
残差ブロックとは、通常のネットワークにスキップ接続を加えて数層ごとにブロック化したものです。一般的なCNNは畳み込み層やReLUなどを直列につなげたシンプルなネットワークです。(下図)
これに対し、残差ブロックは各畳み込み層のまとまり(2~3層ごと)に対して、並列にShortcut Connectionを導入させます。(下図)
このように、畳み込み層とShortcut Connectionの組み合わせで構成されています。Shortcut Connectionに関しては恒等関数になっていて、最終的にはそれぞれの要素を足し合わせます。残差ブロックによって、出力全体の損失を、個々の経路から少しずつ最適化できるようになり、初期表現・勾配をより深い層までの伝搬を可能にしました。次からは実際にResNetによる学習を実施していきたいと思います!!
ResNet152による牛丼と親子丼の画像分類
実際に転移学習を実施していきます。今回は、GoogleColablatoryでPytorchを利用して学習を行いました。また、データはCookPadからスクレイピングした親子丼と牛丼のデータを利用しました。テストデータは、Bing画像検索から別で用意しておきます。
ライブラリのインポート
#Google ドライブを Google Colab にマウント
from google.colab import drive
drive.mount('/content/drive')
# ライブラリの読み込み
import os
from PIL import Image
import torch
import torch.utils.data as data
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import transforms
from torchvision import models
import matplotlib.pyplot as plt
%matplotlib inline
前処理
つづいて、画像の前処理を実施していこうと思います。画像をランダムにトリミング(スケール幅の設定)、左右反転、回転、Tensorオブジェクトに変換、標準化、リサイズをできるようにしました。
'''
前処理クラスの定義
'''
class ImageTransform():
'''画像の前処理クラス。訓練時、検証時で異なる動作をする。
Attributes:
data_transform(dic):
train: 訓練用のトランスフォーマーオブジェクト
val : 検証用のトランスフォーマーオブジェクト
'''
def __init__(self, resize, mean, std):
'''トランスフォーマーオブジェクトを生成する。
Parameters:
resize(int): リサイズ先の画像の大きさ
mean(tuple): (R, G, B)各色チャネルの平均値
std : (R, G, B)各色チャネルの標準偏差
'''
# dicに訓練用、検証用のトランスフォーマーを生成して格納
self.data_transform = {
'train': transforms.Compose([
# ランダムにトリミングする
transforms.RandomResizedCrop(
resize, # トリミング後の出力サイズ
scale=(0.5, 1.0)), # スケールの変動幅
transforms.RandomHorizontalFlip(p = 0.5), # 0.5の確率で左右反転
transforms.RandomRotation(15), # 15度の範囲でランダムに回転
transforms.ToTensor(), # Tensorオブジェクトに変換
transforms.Normalize(mean, std) # 標準化
]),
'val': transforms.Compose([
transforms.Resize(resize), # リサイズ
transforms.CenterCrop(resize), # 画像中央をresize×resizeでトリミング
transforms.ToTensor(), # テンソルに変換
transforms.Normalize(mean, std) # 標準化
]),
'test': transforms.Compose([
transforms.Resize(resize), # リサイズ
transforms.CenterCrop(resize), # 画像中央をresize×resizeでトリミング
transforms.ToTensor(), # テンソルに変換
transforms.Normalize(mean, std) # 標準化
])
}
def __call__(self, img, phase='train'):
'''オブジェクト名でコールバックされる
Parameters:
img: 画像
phase(str): 'train'または'val' 前処理のモード
'''
return self.data_transform[phase](img) # phaseはdictのキー
画像のファイルパスをリストにする
学習のために、画像のファイルパスをリストにします。学習用と検証用はあらかじめディレクトリで分けておきます。
'''
親子丼と牛丼の画像のファイルパスをリストにする
'''
import os.path as osp
import glob
import pprint
def make_datapath_list(phase="train"):
'''
データのファイルパスを格納したリストを作成する。
Parameters:
phase(str): 'train'または'val'
Returns:
path_list(list): 画像データのパスを格納したリスト
'''
# 画像ファイルのルートディレクトリ(各自設定)
rootpath = "/content/drive/MyDrive/MyColab/ryouri_categorical/"
# 画像ファイルパスのフォーマットを作成
# rootpath +
# train/親子丼/*.jpg
# train/牛丼/*.jpg
# val/親子丼/*.jpg
# val/牛丼/*.jpg
target_path = osp.join(rootpath + phase + '/**/*.jpg')
# ファイルパスを格納するリスト
path_list = [] # ここに格納する
# glob()でファイルパスを取得してリストに追加
for path in glob.glob(target_path):
path_list.append(path)
return path_list
# ファイルパスのリストを生成
train_list = make_datapath_list(phase="train")
val_list = make_datapath_list(phase="val")
# 訓練データのファイルパスの前後5要素ずつ出力
print('train')
pprint.pprint(train_list[:5])
pprint.pprint(train_list[-6:-1])
# 検証データのファイルパスの前後5要素ずつ出力
print('val')
pprint.pprint(val_list[:5])
pprint.pprint(val_list[-6:-1])
このようにtrain、val、親子丼、牛丼でファイルパスを分けることができている。
データセット作成
PytorchのDataSetクラスを継承して、画像のデータセットを作成します。先ほど作成した前処理
クラスのインスタンスを呼び出し、訓練用、検証用、テスト用の画像とその正解ラベルを返すようにしました。
'''
親子丼と牛丼の画像のデータセットを作成するクラス
'''
import torch.utils.data as data
class MakeDataset(data.Dataset):
'''
牛丼と親子丼の画像のDatasetクラス
PyTorchのDatasetクラスを継承
Attributes:
file_list(list): 画像のパスを格納したリスト
transform(object): 前処理クラスのインスタンス
phase(str): 'train'または'val'
Returns:
img_transformed: 前処理後の画像データ
label(int): 正解ラベル
'''
def __init__(self, file_list, transform=None, phase='train'):
'''インスタンス変数の初期化
'''
self.file_list = file_list # ファイルパスのリスト
self.transform = transform # 前処理クラスのインスタンス
self.phase = phase # 'train'または'val'
def __len__(self):
'''len(obj)で実行されたときにコールされる関数
画像の枚数を返す'''
return len(self.file_list)
def __getitem__(self, index):
'''Datasetクラスの__getitem__()をオーバーライド
obj[i]のようにインデックスで指定されたときにコールバックされる
Parameters:
index(int): データのインデックス
Returns:
前処理をした画像のTensor形式のデータとラベルを取得
'''
# ファイルパスのリストからindex番目の画像をロード
img_path = self.file_list[index]
# ファイルを開く -> (高さ, 幅, RGB)
img = Image.open(img_path)
# 画像を前処理 -> torch.Size([3, 224, 224])
img_transformed = self.transform(
img, self.phase)
# 正解ラベルをファイル名から切り出す
if self.phase == 'train':
label = img_path.split('/')[-2]
elif self.phase == 'val':
label = img_path.split('/')[-2]
elif self.phase == 'test':
label = img_path.split('/')[-2]
# 正解ラベルの文字列を数値に変更する
if label == '牛丼':
label = 0 # 牛丼は0
elif label == '親子丼':
label = 1 # 親子丼は1 img_transformed,
return img_transformed, label
データローダーの作成
データローダーを作成していきます。データローダーはデータをロードしてミニバッチを作成する処理です。
'''
データローダーの生成
'''
import torch
# ミニバッチのサイズを指定
batch_size = 32
# 画像のサイズ、平均値、標準偏差の定数値
# 画像の前処理と処理済み画像の表示
# モデルの入力サイズ(タテ・ヨコ)
SIZE = 224
# 標準化する際の各RGBの平均値
MEAN = (0.485, 0.456, 0.406) # ImageNetデータセットの平均値を使用
# 標準化する際の各RGBの標準偏差
STD = (0.229, 0.224, 0.225) # ImageNetデータセットの標準偏差を使用
size, mean, std = SIZE, MEAN, STD
# MakeDatasetで前処理後の訓練データと正解ラベルを取得
train_dataset = MakeDataset(
file_list=train_list, # 訓練データのファイルパス
transform=ImageTransform(size, mean, std), # 前処理後のデータ
phase='train')
# MakeDatasetで前処理後の検証データと正解ラベルを取得
val_dataset = MakeDataset(
file_list=val_list, # 検証データのファイルパス
transform=ImageTransform(size, mean, std), # 前処理後のデータ
phase='val')
# 訓練用のデータローダー:(バッチサイズ, 3, 224, 224)を生成
train_dataloader = torch.utils.data.DataLoader(
train_dataset, batch_size=batch_size, shuffle=True)
# 検証用のデータローダー:(バッチサイズ, 3, 224, 224)を生成
val_dataloader = torch.utils.data.DataLoader(
val_dataset, batch_size=batch_size, shuffle=False)
# データローダーをdictにまとめる
dataloaders = {'train': train_dataloader, 'val': val_dataloader}
テストデータのデータローダー作成
テストデータのデータローダーを別で作成しておきます。
# テストデータのデータセット作成
test_list = make_datapath_list('test')
test_dataset = MakeDataset(
file_list = test_list,
transform=ImageTransform(size, mean, std),
phase='test'
)
test_dataloader = torch.utils.data.DataLoader(
test_dataset, batch_size=batch_size, shuffle=False)
モデル構築
モデル構築を行います。今回は層を深くすることで、学習損失が大きくなって精度が落ちてしまう劣化問題を改善できるResNet152を利用しました。層の深さも最も深いモデルにしてあります。この辺りは、学習に問題があれば層を浅くしてもいいかもしれないです。また、出力層は1000クラスのマルチクラス分類のモデルになっているので、二値分類モデルに変更を加え、他の層の重みは固定します。
model = models.resnet152(pretrained=True)
for param in model.parameters():
param.requires_grad = False
n_classes = 2
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, n_classes)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
model.cuda()
モデルの層がかなり深いので出力層だけ出してみると、出力層のout_featuresが2になっていることが確認できます。
学習
それでは、実際に学習を実施していきます。今回は、エポックを120回にしてますが、収束したら学習を打ち切れるようにearlyStoppingを設定していきます。
def train(model,
criterion,
optimizer,
train_loader,
val_loader,
save_location,
early_stop=3,
n_epochs=50,
print_every=2):
#Initializing some variables
valid_loss_min = np.Inf
stop_count = 0
valid_max_acc = 0
history = []
model.epochs = 0
#Loop starts here
for epoch in range(n_epochs):
train_loss = 0
valid_loss = 0
train_acc = 0
valid_acc = 0
model.train()
ii = 0
for data, label in train_loader:
ii += 1
data, label = data.cuda(), label.cuda()
optimizer.zero_grad()
output = model(data)
loss = criterion(output, label)
loss.backward()
optimizer.step()
train_loss += loss.item() * data.size(0)
_, pred = torch.max(output, dim=1) # first output gives the max value in the row(not what we want), second output gives index of the highest val
correct_tensor = pred.eq(label.data.view_as(pred)) # using the index of the predicted outcome above, torch.eq() will check prediction index against label index to see if prediction is correct(returns 1 if correct, 0 if not)
accuracy = torch.mean(correct_tensor.type(torch.FloatTensor)) #tensor must be float to calc average
train_acc += accuracy.item() * data.size(0)
if ii%15 == 0:
print(f'Epoch: {epoch}\t{100 * (ii + 1) / len(train_loader):.2f}% complete.')
model.epochs += 1
with torch.no_grad():
model.eval()
for data, label in val_loader:
data, label = data.cuda(), label.cuda()
output = model(data)
loss = criterion(output, label)
valid_loss += loss.item() * data.size(0)
_, pred = torch.max(output, dim=1)
correct_tensor = pred.eq(label.data.view_as(pred))
accuracy = torch.mean(correct_tensor.type(torch.FloatTensor))
valid_acc += accuracy.item() * data.size(0)
train_loss = train_loss / len(train_loader.dataset)
valid_loss = valid_loss / len(val_loader.dataset)
train_acc = train_acc / len(train_loader.dataset)
valid_acc = valid_acc / len(val_loader.dataset)
history.append([train_loss, valid_loss, train_acc, valid_acc])
if (epoch + 1) % print_every == 0:
print(f'\nEpoch: {epoch} \tTraining Loss: {train_loss:.4f} \tValidation Loss: {valid_loss:.4f}')
print(f'\t\tTraining Accuracy: {100 * train_acc:.2f}%\t Validation Accuracy: {100 * valid_acc:.2f}%')
if valid_loss < valid_loss_min:
torch.save(model.state_dict(), save_location)
stop_count = 0
valid_loss_min = valid_loss
valid_best_acc = valid_acc
best_epoch = epoch
else:
stop_count += 1
# Below is the case where we handle the early stop case
if stop_count >= early_stop:
print(f'\nEarly Stopping Total epochs: {epoch}. Best epoch: {best_epoch} with loss: {valid_loss_min:.2f} and acc: {100 * valid_acc:.2f}%')
model.load_state_dict(torch.load(save_location))
model.optimizer = optimizer
history = pd.DataFrame(history, columns=['train_loss', 'valid_loss', 'train_acc','valid_acc'])
return model, history
model.optimizer = optimizer
print(f'\nBest epoch: {best_epoch} with loss: {valid_loss_min:.2f} and acc: {100 * valid_acc:.2f}%')
history = pd.DataFrame(history, columns=['train_loss', 'valid_loss', 'train_acc', 'valid_acc'])
return model, history
model, history = train(
model,
criterion,
optimizer,
train_dataloader,
val_dataloader,
save_location='/content/drive/MyDrive/MyColab/natural_images_resnet.pt',
early_stop=50,
n_epochs=120,
print_every=2)
損失と精度
エポックごとの損失と精度をプロットしていきます。
'''
9. 損失と精度の推移をグラフにする
'''
import matplotlib.pyplot as plt
%matplotlib inline
# 損失
plt.plot(history['train_loss'],
marker='.',
label='loss (Training)')
plt.plot(history['valid_loss'],
marker='.',
label='loss (Test)')
plt.legend(loc='best')
plt.grid()
plt.xlabel('epoch')
plt.ylabel('loss')
plt.show()
# 精度
plt.plot(history['train_acc'],
marker='.',
label='accuracy (Training)')
plt.plot(history['valid_acc'],
marker='.',
label='accuracy (Test)')
plt.legend(loc='best')
plt.grid()
plt.xlabel('epoch')
plt.ylabel('accuracy')
plt.show()
若干検証用の損失が下がりきらず、過学習気味な感じがありますがテストデータの精度を確かめてみます。
テストデータの精度
def accuracy(model, test_dataloader, criterion):
with torch.no_grad():
model.eval()
test_acc = 0
for data, label in test_dataloader:
data, label = data.cuda(), label.cuda()
output = model(data)
_, pred = torch.max(output, dim=1)
correct_tensor = pred.eq(label.data.view_as(pred))
accuracy = torch.mean(correct_tensor.type(torch.FloatTensor))
test_acc += accuracy.item() * data.size(0)
test_acc = test_acc / len(test_dataloader.dataset)
return test_acc
model.load_state_dict(torch.load('/content/drive/MyDrive/MyColab/natural_images_resnet.pt'))
test_acc = accuracy(model.cuda(),test_dataloader, criterion)
print(f'The model has achieved an accuracy of {100 * test_acc:.2f}% on the test dataset')
テストデータの精度を求めてみました。結果は.....95.09%でした!!
前回が、約84%だったので10%以上の精度向上を実現しました!!
それでは、モデルが画像のどのようなところに注目しているのか確認していきましょう!
GradCAMによるモデルの可視化
実際にモデルが画像のどの部分に注目して予測しているかをGradCAMを利用してみていきます。Grad-CAMとは学習済みAIモデルの解釈を行う手法の一つであり、モデルのレイヤーから重要な特徴を抽出してどの特徴が有効であったかを把握します。
class GradCAM:
def __init__(self, model, feature_layer):
self.model = model
self.feature_layer = feature_layer
self.model.eval()
self.feature_grad = None
self.feature_map = None
self.hooks = []
# 最終層逆伝播時の勾配を記録する
def save_feature_grad(module, in_grad, out_grad):
self.feature_grad = out_grad[0]
self.hooks.append(self.feature_layer.register_backward_hook(save_feature_grad))
# 最終層の出力 Feature Map を記録する
def save_feature_map(module, inp, outp):
self.feature_map = outp[0]
self.hooks.append(self.feature_layer.register_forward_hook(save_feature_map))
def forward(self, x):
return self.model(x)
def backward_on_target(self, output, target):
self.model.zero_grad()
one_hot_output = torch.zeros([1, output.size()[-1]])
one_hot_output[0][target] = 1
output.backward(gradient=one_hot_output, retain_graph=True)
def clear_hook(self):
for hook in self.hooks:
hook.remove()
image_transforms = {
'test':
transforms.Compose([
transforms.Resize(size=256),
transforms.CenterCrop(size=224),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
]),
}
path = 'テストデータの画像パス(各自設定)'
VISUALIZE_SIZE = (224, 224)
image = Image.open(path)
image.thumbnail(VISUALIZE_SIZE, Image.ANTIALIAS)
plt.imshow(image)
image_orig_size = image.size # (W, H)
test_image_tensor = image_transforms['test'](image)
test_image_tensor = test_image_tensor.unsqueeze(0)
device = torch.device("cpu")
model = models.resnet152(pretrained=True)
n_classes = 2
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, n_classes)
model.to(device)
model.eval()
from collections import OrderedDict
import torch
checkpoint=torch.load('/content/drive/MyDrive/MyColab/natural_images_resnet.pt')
state_dict=checkpoint
new_state_dict=OrderedDict()
label_list = ['牛丼', '親子丼']
model.load_state_dict(state_dict)
####GradCamによる可視化
grad_cam = GradCAM(model, feature_layer=list(model.layer4.modules())[26])
#画像をGradcamに入力
model_output = grad_cam.forward(test_image_tensor)
print(model_output)
if len(model_output) == 1:
target = model_output.argmax(1).item()
print(label_list[target])
grad_cam.backward_on_target(model_output, target)
# Get feature gradient
feature_grad = grad_cam.feature_grad.data.numpy()[0]
# Get weights from gradient
weights = np.mean(feature_grad, axis=(1, 2)) # Take averages for each gradient
# Get features outputs
feature_map = grad_cam.feature_map.data.numpy()
grad_cam.clear_hook()
#勾配(重み weights)と出力の特徴図(Feature Map)の加重合計で CAM を算出して、ReLU を通す
cam = np.sum((weights * feature_map.T), axis=2).T
cam = np.maximum(cam, 0) # apply ReLU to cam
#CAM を可視化するために、resize して正規化
cam = cv2.resize(cam, VISUALIZE_SIZE)
cam = (cam - np.min(cam)) / (np.max(cam) - np.min(cam)) # Normalize between 0-1
cam = np.uint8(cam * 255) # Scale between 0-255 to visualize
activation_heatmap = np.uint8(cv2.applyColorMap(cam, cv2.COLORMAP_JET))
activation_heatmap = cv2.cvtColor(activation_heatmap, cv2.COLOR_BGR2RGB) #色反転
plt.imshow(activation_heatmap)
#元画像に CAM を合成
org_img = np.asarray(image.resize(VISUALIZE_SIZE))
intensity = 0.4 #カラーマップの強度
img_with_heatmap = cv2.addWeighted(activation_heatmap, intensity, org_img, 1, 0)
org_img = cv2.resize(org_img, image_orig_size)
img_with_heatmap = cv2.resize(img_with_heatmap, image_orig_size)
#可視化
plt.figure(figsize=(10,5))
plt.subplot(1,2,1)
plt.imshow(org_img)
plt.xticks(color="None")
plt.yticks(color="None")
plt.tick_params(length=0)
plt.subplot(1,2,2)
plt.imshow(img_with_heatmap)
plt.xticks(color="None")
plt.yticks(color="None")
plt.tick_params(length=0)
plt.show()
では、予測結果を確認しその中からいくつかピックアップして紹介していきます!
予測結果と一致した例
牛丼を牛丼と予測
上に卵が乗っている牛丼ですが、正しく予測できました。モデルは料理の中身よりも器の方に向いているようです。ただ、親子丼の方には上に卵が乗っている画像が多く含まれていたのですが、正しく判断できたようです。
一方、こちらはオーソドックスな牛丼です。こちらは、予測の際には牛丼そのものよりも周りの部分に注目しているようでした(画像の右下部分など)。
親子丼を親子丼と予測
こちらの画像は親子丼の卵の部分や鶏肉の部分、ご飯の部分を注目して正しく予測できています。
こちらの画像は、三つ葉の部分や卵の部分に注目しているようでした。今後、カツ丼とかも見分けるようにしたいときどうなるのかなあと思います(なかなか難しそう...)。
予測結果間違った例
牛丼を親子丼と予測
こちらの画像は結構明らかに牛丼なのに親子丼と予測してしまいました。若干画像が明るくなっていたことが原因の一つなのかなと思うので、前処理の段階で画像の明るさ等も変化させるようにしたほうが良かったかもしれないなと思いました。また、訓練用と検証用の画像データを確認したところ画質が悪くあまり牛丼や親子丼だと特定できない画像も多かったので、テストデータで利用した画像を訓練用で一部利用しても良かったかなと思いました。
親子丼を牛丼と予測
こちらの画像では、丼の周りと丼のごく一部の肉の部分に注目して予測を間違えています。なので、画像認識ではなく物体検出で料理を特定する必要があるのかなあと思いました。
まとめ
今回は、ResNetによる転移学習を実施して推定精度を84%から95%まで向上することができました。画像の前処理とか物体検出技術を利用した料理検出などまだまだできそうなことが多いので、今後実施していければなと思います!!