#概要
入力画像数の拡張から、VGG16のファインチューニングモデルを用いた画像の二値分類までの手順を記載します。
画像拡張やVGG16モデルを用いた画像分類を行う方法は様々かと思いますが、一つの方法として参考にしていただけたらと思います。
#実行環境
- Windows 10 Pro(64bit)
- Anaconda 4.10.3
- JupyterNotebook 6.1.5
- Python 3.7.9
- tensorflow 2.3.0
#VGG16モデルについて
2014年 ILSVRC (大規模画像認識の競技会) で準優勝をしたディープラーニングのモデルで、16層からなる畳み込みニューラルネットワーク。
###特徴
小さいカーネルを持つ畳み込み層を2〜4つ連続して重ね、
それをプーリング層でサイズを半分にするというのを繰り返し行う構造が特徴です。
大きいカーネルで画像を一気に畳み込むよりも、小さいカーネルを用いて何個も畳み込み層を深くする方が特徴をより良く抽出できると言われています。
また、シンプルなため使いやすいとも言われています。
事前学習済みのVGG16ネットワークを読み込みできるので、
このネットワークを活用して画像分類を行うコードを記載していきます。
#STEP1.画像の水増し
###概要
2クラスそれぞれのフォルダに入った画像を、Data Augumentationにより100枚ずつに拡張します。
###前提
分類の対象となる画像が各クラスのフォルダに20~30枚程度入っているとします。
今回は二値問題のため、2つのフォルダに各画像(jpg画像)が入っている状態になります。
###コード
①必要なコードをインポートします。
import os
import re
import numpy as np
import glob
from tensorflow.keras.preprocessing.image import load_img, img_to_array, ImageDataGenerator
from distutils.dir_util import copy_tree
②拡張した画像を出力する場所へ移動します。
os.chdir(“移動先のパス”)
③元画像のフォルダ名を指定します。
input_dir = "Normal"
④ 出力フォルダ指定します。
out_dir = input_dir + "_augumented"
⑤ 出力ファイル数を指定します。
# フォルダ内に作りたい合計ファイル数
out_file_count = 100
⑥ フォルダのファイル数を確認する関数は以下のとおりです。
(⑦の画像拡張の関数と連携することで、指定枚数まで画像を増やすことができます。)
def count_file(dir):
# ファイル数を初期化
file_count = 0
# 指定フォルダ内のファイル・フォルダ数回繰り返し
for file_name in os.listdir(dir):
# 指定フォルダ内のファイルのパスを取得
file_path = os.path.join(dir,file_name)
# 指定フォルダ内にファイル有無を判定
if os.path.isfile(file_path):
# ファイル数をカウント
file_count += 1
# ファイル数を返す
return file_count
⑦ ImageDataGeneratorクラスのflowメソッドを使用し、画像を水増しして保存する関数は以下のとおりです。
# 100枚に達するまで入力フォルダ内の画像を拡張する関数
def data_augumentation(input_dir, out_dir, out_file_count):
# 入力フォルダ内のファイル名を取得
files = glob.glob(input_dir + '/*.jpg')
# 入力ファイル数確認 (拡張数の計算に使用)
in_file_count = count_file(input_dir)
# 出力フォルダがない場合は作成する
if not os.path.exists(out_dir):
os.mkdir(out_dir)
# 入力フォルダの画像を出力フォルダにコピー
copy_tree(input_dir,out_dir)
# 画像を1枚ずつ処理
for i, file in enumerate(files):
# RGB画像(PIL形式)を読み込む
img = load_img(file)
x = img_to_array(img) # numpy配列に変換する
x = x[np.newaxis] # 入力用に3次元→4次元に変換
# ImageDataGeneratorで画像を増やす
params = {
‘rotation_range’: 80, # 回転角度(degree)
‘width_shift_range’: 0.4, # 水平移動(rate)
‘height_shift_range’: 0.4, # 垂直移動(rate)
‘horizontal_flip’: True, # 水平フリップ
‘vertical_flip’: True, # 垂直フリップ
‘zoom_range’:0.2, # 拡大(rate)
‘shear_range’:0.2, # シアー(rate)[せん断,平行四辺形]
‘fill_mode’:“nearest” # 変形により空いたスペース(境界周り)のピクセルをどう埋めるか
}
# インスタンス作成、パラメータは上記paramsから読み込む
# paramsは辞書形式のため、読み込む際に"*"を2つ付ける。
datagen = ImageDataGenerator(**params)
# flowメソッドで拡張画像を出力するイテレータをgenに入れる
# 出力ファイル名の接頭辞に、入力画像フォルダ名の上部(アルファベット)を付加し、jpg形式で出力するよう設定
gen = datagen.flow(x, batch_size=1, save_to_dir=out_dir, save_prefix=re.search("[a-zA-Z]+",input_dir).group(), save_format='jpg')
# 繰り返し回数 = (出力したいファイル数 / 現在の入力フォルダ内のファイル数 -1[元画像の分を差し引く])
for j in range(out_file_count // in_file_count -1):
# nextメソッドでイテレータから1枚ずつ出力
batch = gen.next()
# 繰り返し回数が入力画像の枚数に達してから、不足分を水増しする
if i+1 == in_file_count:
# 出力ファイル数確認
cur_file_count = count_file(out_dir)
# 出力したい枚数に達していない場合、最後の入力画像から不足分を水増しする。
if out_file_count > cur_file_count:
# 出力枚数が計算より1~5枚少ないことがあるため、余分に20枚見ておく。
for k in range((out_file_count % in_file_count)+20):
batch = gen.next()
cur_file_count = count_file(out_dir)
# 出力したい枚数に達したらforループから抜けるようにする。
if cur_file_count >= out_file_count:
break
# 上記の関数を実行して、画像を拡張する。
data_augumentation(input_dir, out_dir, out_file_count)
※上記の関数は、指定した単一フォルダの画像を拡張しますので、各フォルダについて実行する必要があります。
(二値問題の場合は、入力・出力フォルダを指定し2回実行)
###ImageDataGeneratorのパラメータについて###
ImageDataGeneratorのパラメータには以下のようなものがあります。
- rotation_range:
- 画像を回転させる角度の範囲 (度)
- width_shift_range:
- 画像を水平移動させる範囲
- height_shift_range:
- 画像を垂直移動させる範囲
- horizontal_flip:
- 画像を水平フリップさせるかどうか
- vertical_flip:
- 画像を垂直フリップさせるかどうか
- zoom_range:
- 拡大・縮小させる範囲
- shear_range:
- シアーの強さ[せん断,平行四辺形]の範囲 (度)、変形する角度を指定する
- fill_mode:
- 変形により空いたスペース(境界周り)のピクセルをどう埋めるかを指定する。
- デフォルトは"nearest"
- constant
- 空いたスペースを一色で埋める(デフォルトは黒)
- nearest
- 隣接したピクセルと同じ色で埋める
- reflect
- 鏡のように反射した画像で埋める/dd>
- wrap
- 同じ画像を複数繋いだような繰り返しで埋める/dd>
- Flow メソッドの引数について
- x : 入力画像(4次元のnumpy array)
- batch_size : データのバッチサイズ(1枚)
- save_to_dir : 保存先フォルダ
- save_prefix : 出力ファイル名の接頭辞
- save_format : 出力ファイル形式
出力ファイル名そのものは自動で決められますが、接頭辞とファイル形式は指定できます。
※ 引数は他にもありますので、気になった方はこちらの公式HPをご参照下さい。
#STEP2.データセット作成
###概要
各フォルダの各画像にVGG16に合わせた前処理を実施し、One-Hotエンコーディング形式のラベルを付加します。
###コード
① 必要なコードをインポートします。
import matplotlib.pyplot as plt
import os
import cv2
import random
import numpy as np
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.applications.vgg16 import preprocess_input
② 入力元の画像フォルダがある場所へ移動します。
os.chdir(“移動先のパス”)
③ 入力元の画像フォルダNormal,Tumorをリスト形式で指定します。
categories = [“Tumor","Normal"]
④ 入力元フォルダのリストの要素数を指定します。ここでは「2」が入ることになります。
category_size = len(categories)
⑤ データセット作成用の関数は以下のとおりです。
def create_data_set():
data_set = [] # データセット用の配列を用意
for class_num, category in enumerate(categories): # categoriesの要素数だけ繰り返し
path = os.path.join(PATH, category) # 入力元フォルダのパス
for image_name in os.listdir(path): # フォルダ内の画像枚数だけ繰り返し
img_pil = load_img(os.path.join(path, image_name)) # 画像読み込み(PIL形式,RGB)
img_array = img_to_array(img_pil) # NumPy配列に変換
img_preprocessed = preprocess_input(img_array) #vgg16用に前処理
data_set.append([img_preprocessed, to_categorical(class_num, category_size)]) # 画像、ラベル追加
rng = np.random.default_rng() # 乱数生成の為のインスタンス作成
# Xをランダムに入れ替える。axis=0で行をランダムに入れ替える。
data_set = rng.permutation(data_set, axis=0)
# 画像データ
X = []
# ラベル情報
y = []
# X,yデータセット作成
for feature, label in data_set:
X.append(feature)
y.append(label)
# X,yをnumpy配列に変換
X = np.array(X)
y = np.array(y)
# 作成したX,yを返す
return X,y
# 定義したcreate_data_set関数を用いX_train_val_testに画像部分、y_train_val_testにラベル部分を出力
X_train_val_test, y_train_val_test = create_data_set()
###preprocess_inputについて
tensorflow.keras.applications.vgg16.preprocess_inputを用いて、
入力画像を、VGG16が学習を行った際に用いたフレームワーク(caffe)に合わせて前処理しています。
####[主な処理内容]
・RGB → BGR変換
・入力画像からimagenet画像の平均値として[103.939, 116.779, 123.68]を差引く
前処理実施、非実施を比較の記事をweb上で見かけますが、おおよそ前処理をした方が良いと言われているようです。
###to_categoricalについて
正解ラベルをone-hotエンコーディングの形に変換するために使用しています。
正解ラベルが0や1の数値(labelエンコーディング形式)の場合、数字の大小が学習に影響するため、one-hotエンコーディングの形式が望ましいと考えられます。
なお、今回の説明ではホールドアウト法によりデータを学習データ、検証データに分割していますが、StratifiedKFold等で交差検証を行いたい場合は、ここではなく学習の直前にto_categoricalを実行する必要があります。
(one-hotエンコーディング形式の入力には対応していないため)
####to_categoricalによる変換イメージ:
#####<二値分類>
ラベル | one-hot |
---|---|
0 | [1 0] |
1 | [0 1] |
#####<多値分類(3個)>
ラベル | one-hot |
---|---|
0 | [1 0 0] |
1 | [0 1 0] |
2 | [0 0 1] |
###データセットの形式について
Step2では、画像と正解ラベルを繋げ、以下のような形式に変換し、その後、Xとyに分割しています。
#STEP3.学習データ、検証データ、テストデータ分割
###概要
train_test_splitを用いて段階に分けてデータを3分割します。
このように学習データと検証データをシンプルに分ける方法をホールドアウト法と言います。
###コード
必要なコードをインポートし、データを分割します。
from sklearn.model_selection import train_test_split
# 学習データ+検証データとテストデータに分割
X_train_val, X_test, y_train_val, y_test = train_test_split(X_train_val_test, y_train_val_test, test_size=0.25, stratify = y_train_val_test)
# 学習データと検証データに分割
X_train, X_val, y_train, y_val =
train_test_split(X_train_val, y_train_val, test_size=0.35, stratify = y_train_val)
###X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.35, stratify=y)について
- [出力]
- X_train, X_val, y_train, y_val:
- 学習用X, 検証用X, 学習用y, 検証用yの4つが出力されます。
- [引数]
- X :
- 画像データです。
- y:
- ラベルデータです。
- test_size :
- testデータの割合(rate)になります。
- stratify:
- 層化抽出の指定部分です。 指定した変数の要素が均等に分かれるように分割されますが、 ここではyを指定しているので、各クラスの画像が均等になるように振り分けられます。 学習データと検証データ等を分割する際にクラスに偏りがあるとモデルの精度に影響が出ますので、stratifyを指定するとより良いと思われます。
#STEP4.VGG16モデル作成
###概要
VGG16モデルを読み込み、自作のレイヤーと結合して、14層までの重みをVGG16モデルのものに固定します。
###コード
① 必要なコードをインポートします。
from tensorflow.keras.models import Sequential
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense, Activation, Flatten
from tensorflow.keras.layers import Dropout
from tensorflow.keras.layers import BatchNormalization
from tensorflow.keras import initializers
from tensorflow.keras import regularizers
import tensorflow as tf
② モデルを作成します。
# VGG16モデルを読み込む
base_model_avg = tf.keras.applications.vgg16.VGG16(include_top=False, pooling="avg", input_shape=(X_train.shape[1],X_train.shape[2],X_train.shape[3]))
x = base_model_avg.output
x = Dense(256)(x)
x = BatchNormalization(axis = 1)(x)
x = Activation('relu')(x)
x = Dropout(0.35)(x)
x = Dense(128)(x)
x = BatchNormalization(axis = 1)(x)
x = Activation('relu')(x)
x = Dropout(0.35)(x)
# 二値分類のためsigmoid関数を採用
x = Dense(category_size, activation="sigmoid")(x)
# モデルを用意
model_functional = Model(inputs = base_model_avg.input, outputs = x)
# モデルの重みを14層目まで凍結する。(後でモデルのコンパイルが必要)
for layer in model_functional.layers[:15]:
layer.trainable = False
上記モデルについて、VGG16モデルに追加した自作の層を含む部分は以下のとおりです。
x = Dense(256)(x)
x = BatchNormalization(axis = 1)(x)
x = Activation('relu')(x)
x = Dropout(0.35)(x)
x = Dense(128)(x)
x = BatchNormalization(axis = 1)(x)
x = Activation('relu')(x)
x = Dropout(0.35)(x)
# 二値分類のためsigmoid関数を採用
x = Dense(category_size, activation="sigmoid")(x)
###VGG16を読み込む箇所について
base_model_avg = tf.keras.applications.vgg16.VGG16(include_top=False, pooling="avg", input_shape=(X_train.shape[1],X_train.shape[2],X_train.shape[3]))
####[引数]
#####Include_top:
全結合層~出力層について、VGG16モデルをそのまま使うかどうかを指定します。
VGG16モデルは多値分類(1000クラス)ですが、今回は二値分類用のモデルを作成しますので、Falseとして自作の層をつなげます。
#####pooling:
グローバルプーリング層を追加します。後で説明しています。
(指定する場合は”アベレージ”か”MAX”か選択します。)
#####Input_shape:
入力する際の形状を指定します。
#####Input_tensor:
入力層側に新たな層を追加します。
(今回は特に指定していません。)
※ 引数は他にもありますので、気になった方はこちらの公式HPをご参照下さい。
###本モデルの構成
今回は14層目までの重みを固定しており、残りの部分で学習する形となっています。
重みをどこまで固定するかは決まっていませんので、状況に応じて最適な方法を決めるのが良いと思います。
###グローバルアベレージプーリングについて
flatten等で平坦化して全結合層に繋げる場合、各チャンネルの各データから全結合するため、パラメータ数は膨大になります。
(パラメータ数:3x3x512 x 512 = 2,359,296)
そこでグローバルアベレージプーリングを用いることで、各チャンネルで平均した値を用いるためパラメータ数が減り、計算コストが小さくて済みます。
(パラメータ数:512)
なお、pooling='max'の場合は各チャンネルの最大値が用いられます。
下記のようにイメージしていただければ良いと思います。
計算量が減ると精度に影響が出ないか心配になりますが、CIFAR-10(6万枚程度の10クラスの画像データセット)と、同様のモデルを用いて全結合層、全結合層+ドロップアウト、グローバルアベレージプーリングを比較している以下の論文では、グローバルアベレージプーリングを用いたモデルが最も精度が高いという結果になっています。
Min Lin, Qiang Chen, Shuicheng Yan (2014). Network In Network. arXiv
https://arxiv.org/pdf/1312.4400.pdf
上記の論文では、グローバルアベレージプーリングはこの層での過剰適合を抑制する構造的な"レギュラライザー"(正則化の役割を持つもの)と言うことができると説明されています。
なお、全結合層のみでは正則化の役割を持つ"レギュラライザー"が無いため精度が伸びなかったことも指摘されています。
###モデルを構築する手法について
モデルは大きく分けて以下の3つの方法で構築できますが、今回は②のFunctional APIを用いています。
####①Sequential API
・シンプルに1直線のモデルを作成します。
model = Sequential(name=“model_1”)
model.add(Conv2D(32, (3, 3), activation='relu', input_shape=(100, 100, 3)))
model.add(Conv2D(32, (3, 3), activation='relu'))
…
####②Functional API
・入力出力が複数あるモデル等も作成可能です。
x_input = Input(shape=[100,100,3])
x = Conv2D(256,3,3)(x_input)
x = BatchNormalization(axis=1)(x)
x = Activation('relu')(x)
…
####③SubclassingAPI
・define by run(実行中にネットワーク構築)の形式になります。
・最も柔軟な方法と言われています。
###Functional APIでのモデルのインスタンス化について
- 自作モデルを用いる例
- 以下のようにInputから繋ぎ、Modelクラスでinputsとoutputsを指定します。 (x_input ⇒ x ⇒ x ⇒ x ⇒ x ⇒ x ⇒ x_output)
x_input = Input(shape=[100,100,3])
x = Conv2D(256,3,3)(x_input)
x = BatchNormalization(axis=1)(x)
x = Activation('relu')(x)
x = GlobalAveragePooling2D()(x)
x = Dense(16,activation="relu")(x)
x_output = Dense(1, activation="sigmoid")(x)
model_functional_s = Model(inputs = x_input, outputs = x_output)
- VGG16を読み込む例
- 以下のように追加部分をVGG16のoutputから繋ぎ、Modelクラスでinputsとoutputsを指定します。 (base_model.input ⇒ … ⇒ base_model.output ⇒ x ⇒ … ⇒ x)
###自作箇所を構成する層、関数等について
####Activation:
活性化関数を表しています。活性化関数は各層で入力信号の総和を出力信号に変換する関数です。
####Relu:
活性化関数の1つです。
入力が0以下の場合0、0を超えたらそのまま出力します。
####Sigmoid:
活性化関数の1つです。
0~1の値をとります。
ラベルが1であるかどうかの確率を0~1の値で表現できるので、
二値化に適していると言われています。
####Dense:
全結合層です。
####Dropout:
ドロップアウト層です。
ニューロンを指定した割合で不活性化することで、繰り返しの学習の中でニューロンの形状が異なるような表現ができ、過学習の抑制に用いられます。
####BatchNormalization:
バッチ正規化、伝わってきたデータに対し平均0分散1になるように行う正規化です。
データ分布を強制的に調整するので、過学習や勾配消失を抑制し学習の進行を早めます。
#STEP5.モデルのコンパイルと学習、予測
###概要
学習時の設定を追加するためにモデルをコンパイルし、学習を実施します。
その後、学習したモデルを用いてテストデータの正解を予測します。
###コード
② モデルのコンパイルを実施します。
model_functional.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy’])
###コンパイルについて
コンパイルでは、学習を始める前に処理の設定を行います。
③ モデルを用いて学習、検証、モデルの保存を行います。
#コールバック(early stopping、重みを保存するチェックポイント)
es_cb =
callbacks.EarlyStopping(monitor='val_loss', patience=20, verbose=1, mode='auto’)
ch_pt = callbacks.ModelCheckpoint(filepath='my_model_Epoch{epoch}_loss{loss:.4f}_valloss{val_loss:.4f}.h5',monitor='val_loss',save_best_only=True,save_weights_only=True)
# 作成モデルを用いて、学習、検証を行う。
result = model_functional.fit(x=X_train, y=y_train, epochs=100, batch_size=64, validation_data=(X_val, y_val),callbacks=[es_cb,ch_pt])
# モデルを保存する
model_functional.save('vgg16_trained\\my_model')
###コールバック関数について
学習中に、コールバック関数毎の条件を満たした時点で呼び出される関数です。
####ch_pt = callbacks.ModelCheckpoint(filepath='my_model_Epoch{epoch}_loss{loss:.4f}_valloss{val_loss:.4f}.h5',monitor='val_loss',save_best_only=True,save_weights_only=True)
####[引数]
#####filepath:
保存ファイル名(epoch, loss等含む)
#####monitor:
どの値を監視するかを指定します。
#####verbose :
表示モードです。
0=(保存の)ログ出力なし, 1=出力
#####mode:
{auto,min,max}で指定します。
監視しているのが”val_loss”の場合は最小値”min”になります。
#####save_best_only:
Trueにすると、(ファイル名が固定の場合)最新の最良モデルが上書きされません。
Falseにするとperiodで指定した間隔で上書き保存されます。
ただ、今回の例のように出力ファイル名がepoch毎に変わる場合は、ファイルが上書きされないため、指定による違いはあまりないと思われます。
#####save_weights_only:
モデル全体を保存するか重みだけ保存するかを指定します。
#####period:
何エポック毎に保存するかどうかを指定します。
####es_cb =
callbacks.EarlyStopping(monitor=“val_loss”, patience=20, verbose=1, mode=“auto”)
####[引数]
#####monitor :
監視する値を指定します。
#####patience:
monitorで指定した値について、指定したエポック数の間に改善が無ければ学習が停止します。
#####verbose :
表示モードを指定します。
0=(停止の)ログ出力なし, 1=出力
#####mode:
{auto,min,max}で指定します。
minでは監視する値の減少が停止したら学習が終了します。
maxでは監視する値の増加が停止したら学習が終了します。
autoでは監視する値から自動推定します。
#####min_delta:
監視する値が改善していると判定される最小変化量を指定します。
###fit関数について
④ 学習したモデルを用いて、テストデータを予測、評価します。
# 学習したモデルでテストデータを予測する。
y_pred = model_functional.predict(X_test)
# 予測値を丸める
y_pred = np.round(y_pred)
# F値を計算
f1 = f1_score(y_test, y_pred, average="micro")
f1_2 = f1_score(y_test, y_pred, average="macro")
y_predはsigmoid関数を通じて出力された小数の予測値なので、ラベルと比較してF値を求めるために整数に直す必要があります。
以下のようなイメージです。
###F値について
F値は再現率と適合率の調和平均です。
再現率や適合率については以下に説明を記載しましたのでご参照下さい。
#補足: 交差検証について
###KFold法
KFold法では以下のように処理します。
#####① データをK個に分割する
#####② K個の内、1つを検証データに割り当て、残りを学習データに割り当てて学習する
#####③ ②とは別の部分を検証データに割り当て、残りを学習データに割り当てて学習する
#####④ 上記を合計K回繰り返す
上記の方法により、データの偶然の偏りに対応することが可能です。
以下のようなイメージになります。
また、以下のようにKFold交差検証にはいくつか種類があります。
正解ラベルが偏らないように、検証データと学習データで正解ラベルを均等に分けたい場合は、Stratified KFoldが良いと思われます。
評価方法としては、各分割データでの検証データの正解率の平均を取る方法や、各分割データの検証データの予測結果を足し合わせて、学習・検証データ全体の正解ラベルと比較し正解率を求める方法があるようです。
以下のようなイメージになります。
気付いた注意点としては、以下のようなものがあります。
#####①ラベルデータ(y_train_val)はK-Foldの分割処理~学習前でOne-Hotエンコーディング形式 ([1 0]形式) にする
今回のコードではデータセット作成時にラベルデータをOne-Hotエンコーティングの形式に変更していますが、K-Foldはこの形式の処理に対応していないため、上記のような形で行う必要があります。
#####②K-Foldの中でモデルを用意・コンパイルする
K回それぞれ重みをリセットして学習を繰り返すために、現時点ではK-Foldで分割を行った後の処理の中でモデルを準備しコンパイルした方が良いと思われます。
本記事は、これまで実際にコーディングしテストを行う中で気付いた点を中心に記載していますが、書かれている内容の全てが正しいとは限りません。
本記事のコードは参考程度にしていただけたらと思います。
今後誤りが見つかった場合は修正したいと思います。
(作成者:平尾)