🖼

Keras+画像分類を学んだまとめ

2020/10/19に公開

NVIDIAのハンズオンに参加して画像分類を学びましたので、追加で調べた内容と合わせてまとめたいと思います。

ライブラリはkerasを使っています。

コードはこちら

ライブラリのインストール

import numpy as np

from tensorflow import keras

# データの拡張
from tensorflow.keras.preprocessing.image import ImageDataGenerator

# 画像の読込用
import matplotlib.image as mpimg

# 画像の表示用
import matplotlib.pyplot as plt

# 画像をPIL形式で読み込む。画像サイズの変更が可能
from tensorflow.keras.preprocessing import image as image_utils

# 画像の前処理
from tensorflow.keras.applications.imagenet_utils import preprocess_input

ImageNetからの学習済みのモデルをダウンロード

VGG-16は、深さが 16 層の畳み込みニューラル ネットワークで、100 万枚を超えるイメージで学習させた事前学習済みのネットワークを、ImageNet データベースから読み込むことができます。

この事前学習済みのネットワークは、イメージを1000個のオブジェクトカテゴリ(キーボード、マウス、鉛筆、多くの動物など)に分類できます。

ネットワークのイメージ入力サイズは 224 x 224となっています。

下記コードを実行すると、Imagenetから学習済みモデル(約59MB)のダウンロードが始まりますので、通信環境にご注意ください。

pre_model = keras.applications.VGG16(
    weights='imagenet',
    input_shape=(224, 224, 3),
    include_top=False) # ネットワークの出力層側にある3つの全結合層を含むかどうか

Keras 公式ドキュメント VGG16

ニューラルネット層を表示してみましょう。

pre_model.summary()
Model: "vgg16"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         [(None, 224, 224, 3)]     0         
_________________________________________________________________
block1_conv1 (Conv2D)        (None, 224, 224, 64)      1792      
_________________________________________________________________
block1_conv2 (Conv2D)        (None, 224, 224, 64)      36928     
_________________________________________________________________
block1_pool (MaxPooling2D)   (None, 112, 112, 64)      0         
_________________________________________________________________
block2_conv1 (Conv2D)        (None, 112, 112, 128)     73856     
_________________________________________________________________
block2_conv2 (Conv2D)        (None, 112, 112, 128)     147584    
_________________________________________________________________
block2_pool (MaxPooling2D)   (None, 56, 56, 128)       0         
_________________________________________________________________
block3_conv1 (Conv2D)        (None, 56, 56, 256)       295168    
_________________________________________________________________
block3_conv2 (Conv2D)        (None, 56, 56, 256)       590080    
_________________________________________________________________
block3_conv3 (Conv2D)        (None, 56, 56, 256)       590080    
_________________________________________________________________
block3_pool (MaxPooling2D)   (None, 28, 28, 256)       0         
_________________________________________________________________
block4_conv1 (Conv2D)        (None, 28, 28, 512)       1180160   
_________________________________________________________________
block4_conv2 (Conv2D)        (None, 28, 28, 512)       2359808   
_________________________________________________________________
block4_conv3 (Conv2D)        (None, 28, 28, 512)       2359808   
_________________________________________________________________
block4_pool (MaxPooling2D)   (None, 14, 14, 512)       0         
_________________________________________________________________
block5_conv1 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_conv2 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_conv3 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 7, 7, 512)         0         
=================================================================
Total params: 14,714,688
Trainable params: 14,714,688
Non-trainable params: 0
_________________________________________________________________

VGG16は畳み込み層(Conv2D) 13層、全結合層 3層の計16層で成り立っています。

上記のコードでは VGG16()中で include_top=False としているため、全結合層は省略されています。

畳み込み層や全結合層の意味は下記のサイトがわかりやすいです。

定番のConvolutional Neural Networkをゼロから理解する

ちなみに、 include_top=True とすると、下記のように全結合層(Dense) 3層と入力データを平坦化(一次元に *)(Flatten)する1層が追加されます。

flatten (Flatten)            (None, 25088)             0         
_________________________________________________________________
fc1 (Dense)                  (None, 4096)              102764544 
_________________________________________________________________
fc2 (Dense)                  (None, 4096)              16781312  
_________________________________________________________________
predictions (Dense)          (None, 1000)              4097000   
  • 直前の block5_pool (MaxPooling2D) が (None, 7, 7, 512) 。次元の要素を乗じれば 7 × 7 × 512 = 25,088 と flatten後の数値となります。

(参考)無から始めるKeras 第6回

レイヤーの追加

全結合層を除いたImageNetからダウンロードしたニューラルネット層に、(224 × 224 × 3)の入力層と、2次元のグローバル平均プーリング層、全結合層を追加します。

# ベースモデルをfalse で凍結(VGG16の全層の重みを固定)
pre_model.trainable = False

### kerasのテンソルののインスタンスを作成
inputs = keras.Input(shape=(224, 224, 3))

x = pre_model(inputs, training=False)  # training=False は推論モード

# 空間データのグローバル平均プーリング演算
x = keras.layers.GlobalAveragePooling2D()(x)

# 通常の全結合ニューラルネットワークレイヤー
units = 2 # 出力空間の次元数、今回2項目分類を想定しているので出力層を 2 とする
outputs = keras.layers.Dense(units, activation = 'softmax')(x) # activation は 活性関数

# モデルをインスタンス化
revised_model = keras.Model(inputs, outputs)

TensorFlow, Kerasでレイヤー、モデルのtrainable属性を設定(Freeze / Unfreeze)

訓練モードと推論モード

Global Average Pooling(GAP)を理解してみる

新しくレイヤーを追加したニューラルネット層は以下のようになります。

revised_model.summary()

Model: "model"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_2 (InputLayer)         [(None, 224, 224, 3)]     0         
_________________________________________________________________
vgg16 (Model)                (None, 7, 7, 512)         14714688  
_________________________________________________________________
global_average_pooling2d (Gl (None, 512)               0         
_________________________________________________________________
dense (Dense)                (None, 2)                 1026      
=================================================================
Total params: 14,715,714
Trainable params: 1,026
Non-trainable params: 14,714,688
_________________________________________________________________

モデルのコンパイル

レイヤーを追加した場合はコンパイルする必要があります。

今回はソフトバンクのお父さん(古い)と普通の犬を見分けるモデルにするため、2値分類用の関数を使用しています。

revised_model.compile(loss = keras.losses.BinaryCrossentropy(from_logits=True), 
              metrics = [keras.metrics.categorical_accuracy])

データ拡張

データ拡張とは、画像精度を向上させるため、訓練データを回転させたり、拡大縮小させることでデータ数を水増しすることです。

datagen = ImageDataGenerator(
        featurewise_center=True, # データセット全体で,入力の平均を0にする
        samplewise_center=True,  # 各サンプルの平均を0にする
        rotation_range=10,  # 整数.画像をランダムに回転する回転範囲.
        zoom_range = 0.1, # 浮動小数点数または[lower,upper].ランダムにズームする範囲.
                          # 浮動小数点数が与えられた場合,[lower, upper] = [1-zoom_range, 1+zoom_range]
        width_shift_range=0.1,  # r浮動小数点数(横幅に対する割合).ランダムに水平シフトする範囲
        height_shift_range=0.1,  # 浮動小数点数(縦幅に対する割合).ランダムに垂直シフトする範囲
        horizontal_flip=True,  # 水平方向に入力をランダムに反転
        vertical_flip=True # 垂直方向に入力をランダムに反転
)

Keras 公式ドキュメント データ拡張 ImageDataGenerator

Kerasによるデータ拡張でデータ数を水増ししてみる

画像データセットの読込

GitHubには入っておりませんが、下記のようなフォルダ構成を用意しました。

└── data
    ├── train # 訓練データ用フォルダ
    │     ├── non_daddy # 普通の犬画像
    │     └── daddy # ソフトバンクのお父さん画像
    └── test
          ├── non_daddy # 普通の犬画像
          └── daddy # ソフトバンクのお父さん画像
# パスの設定
train_data_path = "data/train/"
test_data_path = "data/test/"

# 訓練データをフォルダから読み込む
# .flow_from_directory() ディレクトリへのパスを受け取り,拡張/正規化したデータのバッチを生成
train_it = datagen.flow_from_directory(train_data_path, # パス
                                       target_size=(224, 224), # リサイズする画像のサイズ 
                                       color_mode='rgb', # "grayscale":白黒、"rbg":カラー
                                       class_mode="categorical") 
                                       # "categorical"は2次元のone-hotにエンコード化されたラベル、"binary"は1次元の2値ラベル
                                       # "sparse"は1次元の整数ラベル,"input"は入力画像と同じ画像
# テストデータをフォルダから読み込む
test_it = datagen.flow_from_directory(test_data_path,
                                      target_size=(224, 224), 
                                      color_mode='rgb', 
                                      class_mode="categorical")

モデルのトレーニング

revised_model.fit(train_it,
          validation_data=test_it,
          steps_per_epoch=train_it.samples/train_it.batch_size, # 終了した1エポックを宣言して次のエポックを始めるまでのステップ数の合計
          validation_steps=test_it.samples/test_it.batch_size, # steps_per_epochを指定している場合のみ。停止する前にバリデーションするステップの総数
          epochs=20)

評価

revised_model.evaluate(test_it, steps=test_it.samples/test_it.batch_size)

loss 0.5033
categorical_accuracy: 1.0000

戻り値は評価指標のリスト。

デフォルトでは損失(loss)のみで、compile()のmetricsに評価指標を追加すると、別の評価指標が追加される。

model.metrics_namesで、評価尺度の名前リストが得られる。

Kerasによる多クラス分類(Iris)

Keras 公式ドキュメント evaluate

ファインチューニング

ファインチューニングは、新たなドメインの分類に対応させるための最終出力層の追加学習に加えて、特徴抽出層の重みも再学習させてモデルを更新することです。

なお、転移学習は最終出力層のみ追加学習させ、既存の特徴抽出層の重みには手を加えません。

機械学習における転移学習とファインチューニング

# 凍結の解除
pre_model.trainable = True

# コンパイル
revised_model.compile(optimizer=keras.optimizers.RMSprop(learning_rate = .00001),
              loss = keras.losses.BinaryCrossentropy(from_logits=True) , 
                      metrics =  [keras.metrics.categorical_accuracy])

# ファインチューニング
revised_model.fit(train_it,
          validation_data=test_it,
          steps_per_epoch=train_it.samples/train_it.batch_size,
          validation_steps=test_it.samples/test_it.batch_size,
          epochs=10)

ファインチューニング後の評価

revised_model.evaluate(test_it, steps=test_it.samples/test_it.batch_size)

loss: 0.5112
categorical_accuracy: 1.0000

それでは分類結果を画像と共に表示する関数を自作してみます。

分類結果の確認

# 画像表示用の関数
def show_image(image_path):
    image = mpimg.imread(image_path)
    plt.imshow(image)

# 画像の分類結果を返す関数
def make_predictions(image_path):
    show_image(image_path) # 画像の表示
    image = image_utils.load_img(image_path, target_size=(224, 224)) # 画像の読込とサイズ変更
    image = image_utils.img_to_array(image) # 画像をnumpyのarrayデータに変更
    image = image.reshape(1,224,224,3) # 次元を変形 (画像の枚数、縦ピクセル数、横ピクセル数、RGB)
    image = preprocess_input(image) # Imagenetの重みデータに合わせた前処理を実行
    preds = revised_model.predict(image) # 予測値の算定
    return preds

画像の次元の1番目を、(1,224,224,3)のように 1 とするのは、画像が1枚でも枚数を示す 1 を入れる慣習らしいです。

image.load_img の公式ドキュメント

image.img_to_array の公式ドキュメント

以降、画像の名称はGitHub上、固有の名称になっていますが、任意の名称に変更ください。

# 判定対象以外の写真の出力結果を表示
temp1 = make_predictions('data/test/non_duddy/普通の犬の画像.jpg')
np.argmax(temp1)

(画像はわざとぼかしています)

# 判定対象の写真の出力結果を表示
temp2 = make_predictions('data/test/duddy/お父さんの画像.jpg')
np.argmax(temp2)

(画像はわざとぼかしています)

0が判定対象、1が判定対象外と思われるので、判定結果を表示する関数に反映します。

0なら「Who?」、1なら「It's SHIRAI_Fammily's Duddy !」と返す関数を作成します。

なお、temp1 の中身は、

array([[3.0634123e-08, 1.0000000e+00]], dtype=float32)

のように、0番目:判定対象である確率、1番目:判定対象ではない確率、となっています。

# 判定結果を表示する関数
def tasting_fruits(image_path):
    preds = make_predictions(image_path)
    if np.argmax(preds)==1:
        print("Who?")
    else:
        print("It's SHIRAI_Fammily's Duddy !")
tasting_fruits('data/test/普通の犬の画像2.jpg')

(画像はわざとぼかしています)

tasting_fruits('data/test/お父さんの画像2.jpg')

(画像はわざとぼかしています)

このように、ちゃんとソフトバンクのお父さんを判定することができました。

以上になります、最後までお読みいただきありがとうございました。

Discussion