【画像認識】重ね合わせた2つの画像を判別できるか?

トライアル

この記事は、2020年に別サイトへ投稿した記事を一部修正して移行したものです。

概要

今回は画像認識の応用編にチャレンジしてみましょう。

基礎編を「画像の中の物体が何であるかを判別する」とした場合、
応用編は「画像の中の複数の物体がそれぞれ何であるかを判別する」とします。

画像データはMNISTを使用することにします。

MNISTは手書き文字データの定番で入門者向けデータだね。

最初はハードルを低くしていこう!

MNIST手書き文字データを重ね合わせて判別する様子

学習すること

  • Tensorflow/Kerasによる画像認識モデルのプログラミング
  • 深層学習モデルのハイパーパラメータチューニング

今回の課題である「画像の中の複数の物体を同時に判別する」モデルは、Tensorflow/Kerasを使用してゼロから作成します。目的はTensorflow/Kerasプログラミング習得のためです。

とはいえ、Tensorflow/Kerasによるモデル作成は決して難しくありません。
ハードルがあるとすれば、モデル作成後のハイパーパラメータチューニングでしょう。
自作した深層学習モデルのハイパーパラメータチューニングをとおして、モデル学習の醍醐味を味わうことが一番の目的です。

目標を事前に設定しておきましょう。次のとおりです。

目標

  1. 正解率95%以上
    • 2文字を重ね合わせた1枚の画像について、元の2文字をいずれも正解した場合は100%、1文字だけ正解した場合は50%、2文字とも正解できなかった場合は0%と正解率を定義します。
    • 基準を95%にした根拠は、人の画像認識力を最低ラインとしたためです。人の画像認識能力は、エラー率およそ5%と言われています。
  2. 汎化性能を高くし、過学習しないこと
    • 「汎化性能を高く」とは、テストデータ(初見の未学習データ)に対して基準の正解率を満たすことを意味します。
    • 「過学習しないこと」とは、学習データとテストデータの正解率が著しく乖離しないことを意味します。

前提知識

  • Python3プログラミング
  • Google Colaboratory

開発環境

  • Google Colaboratory(Python3)

データセット

  • MNIST手書き数字データセット

開発実施

進め方

ざっくりとした進め方は次のとおりです。

  1. データ作成:重ね合わせデータを作成する
  2. モデル作成:画像認識深層学習モデルを作成する
  3. ハイパーパラメータチューニング:モデルを調節して性能向上を図る

ではさっそくプログラミングをすすめていきましょう。

1. データ作成

MNISTデータはKerasのライブラリを使ってダウンロードします。
ダウンロードしたデータを次のように前処理します。

  • 画素値が0~1の範囲となるように正規化する
  • 正解ラベルをOne-Hotエンコードする

つづいて重ね合わせ画像を作成します。
画像データを先頭から順番に1枚ずつ抽出し、そこへもう1つ別の画像を重ね合わせていきます。
別の画像とは、同じMNIST画像データの中からランダムで抽出したものです。そのため同じ文字データが重なる可能性もあります。

import numpy as np

from keras import backend as K
from keras.datasets import mnist
from keras.utils import to_categorical


def overlap_images(x_imgs, y_labels, h_img=28, w_img=28, n_type_char=10):
    """Overlap two characters.
    """

    # (num_batch, h, w) -> (num_batch, h*w)
    if x_imgs.ndim > 2:
        x_imgs = x_imgs.reshape(-1, h_img * w_img)

    # (num_batch) -> (num_batch, n_type_char)
    if y_labels.ndim < 2:
        y_labels = to_categorical(y_labels, n_type_char)

    n_batch, n_pix = x_imgs.shape
    x_imgs_ovlp = np.zeros_like(x_imgs)
    y_labels_ovlp = np.zeros_like(y_labels)

    for i_img in range(n_batch):

        # Characters to be overlapped are determined by random numbers.
        i_img_ovlp = np.random.randint(n_batch)

        # Select the brighter of the two pixels.
        x_imgs_ovlp[i_img] = np.maximum(x_imgs[i_img], x_imgs[i_img_ovlp])

        # Overlap one-hot labels.
        y_labels_ovlp[i_img] = np.maximum(y_labels[i_img], y_labels[i_img_ovlp])

    return x_imgs_ovlp, y_labels_ovlp


def load_data_overlapped(overlap=True):
    """Load data and overlap two characters.
    """

    (train_images, train_labels), (test_images, test_labels) = mnist.load_data()
    n_train, h, w = train_images.shape
    n_test = test_images.shape[0]

    # Normalize pixel values.
    train_images = train_images.astype('float32') / 255
    test_images = test_images.astype('float32') / 255

    # Flatten pixels.
    train_images = train_images.reshape((n_train, h*w))
    test_images = test_images.reshape((n_test, h*w))

    # Convert to one-hot.
    train_labels = to_categorical(train_labels)
    test_labels = to_categorical(test_labels)

    if overlap:

        print('train data transporting ...')
        train_images, train_labels = overlap_images(train_images, train_labels)

        print('test data transporting ...')
        test_images, test_labels = overlap_images(test_images, test_labels)

    train_images = train_images.reshape(-1, h, w)
    test_images = test_images.reshape(-1, h, w)

    # Add the channel dimention.
    if K.image_data_format() == 'channels_first':
        train_images = train_images.reshape(n_train, 1, h, w)
        test_images = test_images.reshape(n_test, 1, h, w)
    else:
        train_images = train_images.reshape(n_train, h, w, 1)
        test_images = test_images.reshape(n_test, h, w, 1)

    return train_images, test_images, train_labels, test_labels

2. モデル作成

では深層学習モデルを作りましょう。
特に奇抜なモデルを作る必要はなく、画像認識の一般的なモデル(畳み込み層→全結合層)を作るので、KerasのSequentialを使用します。

層やノードの数はMNIST認識でよく使われるモデルと同じにしました。ただし層の数は、呼び出し側が引数 expansion をTrueにすることによって拡張することができるようにしておきました。あとでモデル調節のときに使います。

最大のポイントは損失関数の選択です。
元のMNISTデータ1枚の判定は0~9のうちの1つが正解なので、出力層はSoftmax、損失関数は多値分類を使います。
それに対して本課題では0~9のうちの2つが正解なので、それでは上手くいきません。

本課題では、出力層は0~9それぞれについて正解か否かを判定できるように各々Sigmoidを使用し、損失関数は二値分類を使うことにしました。

1文字正解の場合と2文字正解の場合の損失関数の違い

モデル作成プログラムは次のとおりです。
ちなみにコメントは少なめです。ナンセンスなコメントは意図的に排除しています。
__init__、fit、predictが何を意味しているか、説明は不要と思います。

import matplotlib.pyplot as plt

import keras
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import Dropout
from keras.layers import Flatten
from keras.layers import Conv2D
from keras.layers import MaxPooling2D


class MyNetwork(object):

    def __init__(self, input_shape, optimizer, drop_out=[0.5, 0.5], multi_labels=True, expansion=False):
        self.input_shape = input_shape
        self.optimizer = optimizer
        self.drop_out = drop_out
        self.multi_labels = multi_labels
        self.expansion = expansion
        self.model = None
        self.history = None

        # Define model as CNNetwork
        # if not expansion: Conv->Conv->Pooling(1/2)->FC(256)->FC(256)->FC(10)
        # if expansion: Conv->Conv->Pooling(1/2)->Conv->Conv->Pooling(1/2)->FC(256)->FC(256)->FC(256)->FC(256)->FC(10)
        self.model = Sequential()
        self.model.add(Conv2D(filters=32, input_shape=self.input_shape, kernel_size=(3, 3), strides=(1, 1), padding="same", activation='relu'))
        self.model.add(Conv2D(filters=32, kernel_size=(3, 3), strides=(1, 1), padding="same", activation='relu'))
        self.model.add(MaxPooling2D(pool_size=(2, 2)))
        self.model.add(Dropout(self.drop_out[0]))
        if self.expansion:
            self.model.add(Conv2D(filters=32, kernel_size=(3, 3), strides=(1, 1), padding="same", activation='relu'))
            self.model.add(Conv2D(filters=32, kernel_size=(3, 3), strides=(1, 1), padding="same", activation='relu'))
            self.model.add(MaxPooling2D(pool_size=(2, 2)))
            self.model.add(Dropout(self.drop_out[0]))
        self.model.add(Flatten())
        self.model.add(Dense(256, activation='relu'))
        self.model.add(Dropout(self.drop_out[1]))
        self.model.add(Dense(256, activation='relu'))
        self.model.add(Dropout(self.drop_out[1]))
        if self.expansion:
            self.model.add(Dense(256, activation='relu'))
            self.model.add(Dropout(self.drop_out[1]))
            self.model.add(Dense(256, activation='relu'))
            self.model.add(Dropout(self.drop_out[1]))

        if self.multi_labels:
            self.model.add(Dense(10, activation='sigmoid'))
            loss = keras.losses.BinaryCrossentropy()
        else:
            self.model.add(Dense(10, activation='softmax'))
            loss = keras.losses.CategoricalCrossentropy()

        self.model.compile(loss=loss, optimizer=self.optimizer, metrics=['accuracy'])

    def fit(self, x_train, y_train, batch_size=32, epochs=20, validation_split=0.2, verbose=True, plot=True):

        self.history = self.model.fit(x_train, y_train, batch_size=batch_size, epochs=epochs, verbose=verbose, validation_split=validation_split)

        if plot:
            plt.plot(self.history.history['accuracy'])
            plt.plot(self.history.history['val_accuracy'])
            plt.title('fitting accuracy')
            plt.ylabel('accuracy')
            plt.xlabel('epoch')
            plt.legend(['train', 'valid'], loc='upper left')
            plt.show()

            plt.plot(self.history.history['loss'])
            plt.plot(self.history.history['val_loss'])
            plt.title('fitting loss')
            plt.ylabel('loss')
            plt.xlabel('epoch')
            plt.legend(['train', 'valid'], loc='upper left')
            plt.show()

        return self.history

    def predict(self, x_test, y_test, verbose=True):

        score = self.model.evaluate(x_test, y_test, verbose=verbose)
        if verbose:
            print('Test => loss:{}, accuracy:{}'.format(score[0], score[1]))

        return score

3. ハイパーパラメータチューニング

ここからはモデルの学習と評価になります。

学習はミニバッチ32件の交差検証で行います。エポック数は20回とします。
ハイパーパラメータはひとまず、最適化アルゴリズム(Optimizer)、学習率、ドロップアウトに限定しました。

この3つのハイパーパラメータは王道だね。

ちなみに他のハイパーパラメータもいろいろいじってみたらしいけど、むしろ過学習してしまって、期待した効果は得られなかったみたいだよ。

チューニング1回目

1回目はまずこんなかんじで始めます。

  • 最適化アルゴリズム: SGD
  • 学習率: 0.01
  • ドロップアウト率: 畳み込み層は0.5、全結合層は0.5
import numpy as np

import keras

from load_data_overlapped import load_data_overlapped
from mynetwork import MyNetwork


def recognition_01():

    np.random.seed(seed=123)

    x_train, x_test, y_train, y_test = load_data_overlapped(overlap=True)
    input_shape = x_train.shape[1:]

    batch_size = 32
    epochs = 20
    drop_out = [0.5, 0.5]
    valid_rate = 0.2
    optimizer = keras.optimizers.SGD(learning_rate=0.01)

    nw = MyNetwork(input_shape, optimizer=optimizer,
                    drop_out=drop_out, multi_labels=True, expansion=False)

    model = nw.fit(x_train, y_train,
                    batch_size=batch_size, epochs=epochs,
                    validation_split=valid_rate, verbose=True, plot=True)

    score = nw.predict(x_test, y_test, verbose=True)


if __name__ == '__main__':
    recognition_01()

テストデータの予測結果(predict)は次のとおりです。

Test => loss:0.16419128562722887, accuracy:0.940692663192749

正解率が94%で目標に達していません。
最適化アルゴリズムを改善してみましょう。一般的にSGDより性能が良いと言われているAdamへ変更します。

チューニング2回目

2回目のパイパーパラメータ値は次のようにしました。

  • 最適化アルゴリズム: SGD → Adam
  • 学習率: 0.01
  • ドロップアウト率: 畳み込み層は0.5、全結合層は0.5
def recognition_02():

    np.random.seed(seed=123)

    x_train, x_test, y_train, y_test = load_data_overlapped(overlap=True)
    input_shape = x_train.shape[1:]

    batch_size = 32
    epochs = 20
    drop_out = [0.5, 0.5]
    valid_rate = 0.2
    optimizer = keras.optimizers.Adam(learning_rate=0.01)

    nw = MyNetwork(input_shape, optimizer=optimizer,
                    drop_out=drop_out, multi_labels=True, expansion=False)

    model = nw.fit(x_train, y_train,
                    batch_size=batch_size, epochs=epochs,
                    validation_split=valid_rate, verbose=True, plot=True)

    score = nw.predict(x_test, y_test, verbose=True)

テストデータの予測結果(predict)は次のとおりです。

Test => loss:0.2655327704123088, accuracy:0.893664538860321

がび~ん! なんじゃこりゃー!

正解率も損失も悪化してしまいました。
さて、原因はなんでしょう・・・。
MNISTデータの画像認識なのに、SGDよりもAdamの精度が落ちるなんてことがあるとは思えません。
Kerasの公式ドキュメントをあたってみました。すると・・・
あれ? SGDのデフォルト学習率は0.01だけど、Adamのデフォルト学習率は0.001になっているではないですか。さっそく学習率を変更してみましょう。

チューニング3回目

3回目の調整です。

  • 最適化アルゴリズム: Adam
  • 学習率: 0.01 → 0.001
  • ドロップアウト率: 畳み込み層は0.5、全結合層は0.5
def recognition_03():

    np.random.seed(seed=123)

    x_train, x_test, y_train, y_test = load_data_overlapped(overlap=True)
    input_shape = x_train.shape[1:]

    batch_size = 32
    epochs = 20
    drop_out = [0.5, 0.5]
    valid_rate = 0.2
    optimizer = keras.optimizers.Adam(learning_rate=0.001)  # ←ココ変更しました!

    nw = MyNetwork(input_shape, optimizer=optimizer,
                    drop_out=drop_out, multi_labels=True, expansion=False)

    model = nw.fit(x_train, y_train,
                    batch_size=batch_size, epochs=epochs,
                    validation_split=valid_rate, verbose=True, plot=True)

    score = nw.predict(x_test, y_test, verbose=True)

テストデータの予測結果(predict)は次のとおりです。

Test => loss:0.06405531030680452, accuracy:0.9778069257736206

ビンゴでした。
正解率は目標の95%超えを達成しました。ばんざーい!

とはいえ、学習データによる結果と検証データ(valid)による結果にはまだまだ差があります。
過学習の兆しもまだ見えないので学習不足と思われます。さらなる性能向上を試みましょう。

学習不足を補う方法としては層を増やすことが考えられますが、その前にドロップアウト率を少し下げて学習するノードの数を増やしてみます。

チューニング4回目

4回目の調整です。

  • 最適化アルゴリズム: Adam
  • 学習率: 0.001
  • ドロップアウト率: 畳み込み層は0.5 → 0.25、全結合層は0.5 → 0.25
def recognition_04():

    np.random.seed(seed=123)

    x_train, x_test, y_train, y_test = load_data_overlapped(overlap=True)
    input_shape = x_train.shape[1:]

    batch_size = 32
    epochs = 20
    drop_out = [0.25, 0.25]
    valid_rate = 0.2
    optimizer = keras.optimizers.Adam(learning_rate=0.001)

    nw = MyNetwork(input_shape, optimizer=optimizer,
                    drop_out=drop_out, multi_labels=True, expansion=False)

    model = nw.fit(x_train, y_train,
                    batch_size=batch_size, epochs=epochs,
                    validation_split=valid_rate, verbose=True, plot=True)

    score = nw.predict(x_test, y_test, verbose=True)

テストデータの予測結果(predict)は次のとおりです。

Test => loss:0.07685641251717296, accuracy:0.9766568541526794

過学習してしまいました。
検証データ(valid)やテストデータによる結果は若干低下しただけですが、学習データ(train)による結果が上がり過ぎました。
学習データに特化した、汎化性能の低いモデルになってしまった可能性大です。

念のため3回目と4回目の中間を試してみましょう。
全結合層のドロップアウト率だけを0.5に戻し、畳み込み層は0.25へ戻すことにします。

チューニング5回目

5回目の調整は次のとおりです。

  • 最適化アルゴリズム: Adam
  • 学習率: 0.001
  • ドロップアウト率: 畳み込み層は0.25、全結合層は0.25 → 0.5
def recognition_05():

    np.random.seed(seed=123)

    x_train, x_test, y_train, y_test = load_data_overlapped(overlap=True)
    input_shape = x_train.shape[1:]

    batch_size = 32
    epochs = 20
    drop_out = [0.25, 0.5]
    valid_rate = 0.2
    optimizer = keras.optimizers.Adam(learning_rate=0.001)

    nw = MyNetwork(input_shape, optimizer=optimizer,
                    drop_out=drop_out, multi_labels=True, expansion=False)

    model = nw.fit(x_train, y_train,
                    batch_size=batch_size, epochs=epochs,
                    validation_split=valid_rate, verbose=True, plot=True)

    score = nw.predict(x_test, y_test, verbose=True)

テストデータの予測結果(predict)は次のとおりです。

Test => loss:0.06872145690236772, accuracy:0.9756073951721191

いいかんじ、いいかんじ。
学習データ(train)と検証データ(valid)の結果が同じレベルに向かって収束しています。
しかもエポック数20回あたりでちょうど収束しています。
テストデータの予測結果も目標の95%を軽くクリアしています。

これで終了してもよいのですが、せっかくなのでモデルの層をもっと増やしてみましょう。
モデルクラスMyNetwork生成時の引数<expansion=True>にすれば、畳み込み層と全結合層をそれぞれ倍にします。

層を増やすと過学習するだろうことは目に見えているけど、モノは試しだね!

チューニング6回目

6回目の調整です。

  • 最適化アルゴリズム: Adam
  • 学習率: 0.001
  • ドロップアウト率: 畳み込み層は0.25、全結合層は0.5
  • 畳み込み層: 2層 → 4層
  • 全結合層: 2層 → 4層
def recognition_06():

    np.random.seed(seed=123)

    x_train, x_test, y_train, y_test = load_data_overlapped(overlap=True)
    input_shape = x_train.shape[1:]

    batch_size = 32
    epochs = 20
    drop_out = [0.25, 0.5]
    valid_rate = 0.2
    optimizer = keras.optimizers.Adam(learning_rate=0.001)

    nw = MyNetwork(input_shape, optimizer=optimizer,
                    drop_out=drop_out, multi_labels=True, expansion=True)  # ←ココ変更しました!

    model = nw.fit(x_train, y_train,
                    batch_size=batch_size, epochs=epochs,
                    validation_split=valid_rate, verbose=True, plot=True)

    score = nw.predict(x_test, y_test, verbose=True)

テストデータの予測結果(predict)は次のとおりです。

Test => loss:0.06976466017748628, accuracy:0.9758927226066589

予想どおり過学習しました。
学習データ(train)による結果の方が検証データ(valid)による結果を上回っています。これは学習データに特化し、汎化性能を下げた証拠です。

結論としては、チューニング5回目のモデルがベストだったと言うことになりました

評価

事前に設定した目標と実施結果を照合してみましょう。

  1. 正解率95%以上
    • テストデータによる予測結果は95%を超えました。よって目標は達成しました。
  2. 汎化性能を高くし、過学習しないこと
    • テストデータおよび学習データによる正解率は、いずれも97%程度で収束しています。過学習せず汎化性能を保っているものと考えられます。よって目標は達成しました。

今後の課題

今回はMNISTという入門者向けデータを使用しましたが、もっと複雑な画像で試してみたいと思いました。現時点の課題をあげてみます。

  • 3文字以上重ねて認識できるか挑戦してみよう
  • 文字ではない複雑な物体を対象としてみよう (複数の動物など)
  • ハイパーパラメータの調整を、グリッドサーチを活用することによって効率化しよう
  • モデルの層数だけでなく、層の中のノード数も変更してみよう
  • PyTorchでモデルを作成してみよう

まとめ

画像認識を少し発展させて、重ね合わせた画像の元データが何かを判別できるかどうかチャレンジしてみました。

工夫したポイントは、
出力層を0~9のSoftmaxから、0~9それぞれのSigmoidへ変更したこと、
損失関数を多値分類から、二値分類へ変更したことです。

データはMNISTを使用したため、比較的簡単なハイパーパラメータチューニングでそれなりの結果をだすことができました。今後はもっと複雑なデータにトライしてみようと思います。

最後に余談となりますが・・・
大規模なモデルを扱う場合、層の途中でパラメータの値を観察したりデバッグしたりすることが必要になると思います。そのようなケースに対しては、Tensorflow/Kerasは向いていないと感じました。Tensorflow/Kerasは、モデル作成→コンパイル→実行という「Define And Run」仕様だからです。
PyTorchはモデルを作成しながら実行していく「Define By Run」仕様です。実務の世界においては、今後PyTorchが主流になっていくのではないでしょうか。

実行環境

Python: 3.8
Tensorflow: 2.4.0
Keras: 2.4.3

コメント

タイトルとURLをコピーしました