技術談話

[話題4] ImageDataGeneratorによる画像拡張とfit()メソッドによる学習

KerasのModelクラスに定義されているメソッドfit()は、 全学習データのバッチ分割とバッチ毎の誤差評価、 および誤差に基づくパラメータ更新を実行します。 それに対して、もう1つのメソッドfit_generator()は、 バッチ生成の選択肢をオープン化することで、学習データの扱いをより柔軟 にしていると言えます。 特に画像データにおいては、縦横の画素数がともに数千ピクセルとなるような場合や、 学習すべき画像枚数が厖大になる場合など、 学習データの扱いに様々な対応が求められることが多くあります。

 一方、学習に用いる画像枚数が十分ではない場合、過学習の問題が発生することがあります。 学習データの不足問題への対応策として、データ拡張(Data Augumentation) という手段がしばしば用いられます。 データ拡張とは、学習データの不足を補うために、 限られた実際の学習データに回転や平行移動などの変換を施すことで、 新たなデータを多数生成することです。 学習目的の知的レベルが上がる中でネットワークも複雑化しており、 学習データの質と量がますます重要になってきています。 Kerasでは、画像データを拡張する手段として、ImageDataGeneratorという クラスオブジェクトが提供されています。'Generator'の名前が示すように、 pythonに導入されているジェネレータの性質を有しています。 次にImageDataGeneratorクラスについて概観します。

 ImageDataGeneratorクラスのインスタンスオブジェクトを生成するためには、 一連の引数を指定してうえで、クラス名を関数とするコンストラクタを呼び出します。 呼び出し形式の例を以下に示します。 このインスタンスは拡張の際の画像変換を規定するオブジェクトであり、 学習問題に応じた画像拡張となるようにパラメータ設定を行う必要があります。


from keras.preprocessing.image  import ImageDataGenerator

image_gen = ImageDataGenerator(
		featurewise_center = False,
		samplewise_center = False,
		featurewise_std_normalization = False,
		samplewise_std_normalization = False,
		horizontal_flip = True,
		vertical_flip = False)
ImageDataGeneratorオブジェクトを生成する際の代表的な引数の種類と意味、 そして各引数の形式をまとめたものを表4-1に示します。
表4-1 ImageDataGeneratorオブジェクト生成時の主なパラメータ
パラメータ 意味
featurewise_centerデータ全体の入力平均を0にする真理値
featurewise_std_normalizationデータ全体の標準偏差で正規化真理値
samplewise_center各サンプルの平均を0にする真理値
samplewise_std_normalization各サンプルの標準偏差で正規化真理値
rotation_range画像回転の範囲整数
width_shift_range画像横幅に対する水平シフト割合浮動小数点
height_shift_range画像縦幅に対する垂直シフト割合浮動小数点
zoom_range 拡大縮小範囲浮動小数点、または[lower,upper]
shear_range シアー強度浮動小数点(反時計回りシアー角度)
horizontal_flip 水平方向反転真理値
vertical_flip 垂直方向反転真理値

スペースの関係で、表には示していない幾つかの引数について補足をしておきます。 最初に、'fill_mode','data_format'について触れます。

 'fill_mode'は、回転等によって入力画像の境界周りに発生する空白領域の埋め方を 選択するための引数です。 引数に指定可能な値としては、{"constant", "nearest", "reflect", "wrap"} のいずれかで、デフォルト値は"nearest"です。
今、入力画像の変換によって画素領域abcd(a,b,c,dは画素値を表す)の4画素の両側に空白領域ができたとします。 すなわち、'******|abcd|******'のようにabcdの4画素に隣接する値の定まらない *領域が生じたとします。 このとき、'fill_mode'引数に指定した値によって以下のように空白領域を埋めていきます。

ただし、fill_mode="constant"とする場合は、'cval'引数による定数kの値の指定がなければ、 デフォルト値0が挿入されます。

 'data_format'は、学習用データセットをnumpy型配列の構造として、 (samples, height, width, channels)と表現するか、あるいは (samples, channels, height, width)と表現するかを選択するための引数です。 引数の値としては、{"channels_first", " channels_last"}のいずれかとなります。 デフォルトでは"channels_last"です。

 他にも、ZCA白色化処理の選択に関係する'zca_whitening','zca_epsilon'など、 画像拡張の前に入力画像に対する処理指定のための引数'preprocessing_function'、 検証のために予約する画像の割合を指定する'validation_split'引数などがあります。

 関数呼び出しの際に安全にそれぞれの仮引数に実引数を渡すためには、 関数定義で指定された全ての仮引数の順序に従って、対応する実引数を指定することです。しかし、 引数の数が多い場合、全ての引数を指定するのは煩わしい作業です。 一般に、関数定義の際には引数に対してデフォルト値を設定することがしばしば行われます。 従って、設定した引数のデフォルト値を積極的に用いることが有効です。 関数呼び出しにおいて、引数のデフォルト値を選択するということは、 その実引数を省略することに相当します。もし、実引数を省略した場合は、 関数定義における引数の順序関係が崩れます。そのため、 以降の実引数は省略によるデフォルト値選択か、 引数キーワードを使ったキーワード渡しを行わなければなりません。 もしキーワード渡しを選択した場合は、引数の順序は問いません。 引数の数が多い場合は引数キーワード渡しが一般に用いられますが、 引数キーワードとその値を辞書型データとして表現した上で、 このデータを一括して関数に引き渡すことも可能です。 ImageDataGeneratorオブジェクトの生成を辞書型データ表現の一括引数渡しで行う例を示します。


from keras.preprocessing.image  import ImageDataGenerator

data_gen_args = {  #引数キーと値を辞書型データとして表現
		"featurewise_center" : True,
		"featurewise_std_normalization" : True,
		"rotation_range" : 90.0,
		"width_shift_range" : 0.1,
		"height_shift_range" : 0.1,
		"zoom_range" : 0.2 }

image_gen = ImageDataGenerator(**data_gen_args)
ImageDataGenerator()の実引数として辞書型データに'**'を接頭語として付けることで、 辞書型データは引数キーワード渡しの形式に展開されます。 辞書型表現の部分は、直接dict()関数によって以下のようにも表すことができます。

data_gen_args = dict(
		featurewise_center=True, 
		featurewise_std_normalization=True,
		rotation_range=90.0, 
		width_shift_range=0.1, 
		height_shift_range=0.1,
		zoom_range=0.2 )
上記の表現に含まれていない引数については、デフォルト値が採用されます。 このように、関数の引数としてプログラム中に埋没しがちなパラメータをまとめて 記述しておくことで、プログラムメンテナンスの効率化が期待されます。

次に、ImageDataGeneratorクラスに定義されているメソッドfit()とflow()について述べます。
fit()メソッドは、与えられたサンプルデータに基づいて、データに依存する幾つかの統計量を計算します。 これらの統計量は、学習を開始する前にデータの正規化を行う場合に用いられます。 関数の呼び出し、および関数の引数は以下のように表されます。


fit(x,augment=False, rounds=1, seed=None)

引数

もう一つのflow()メソッドは、ImageDataGeneratorオブジェクトの変換情報に基づいて、拡張画像の イテレータを作成します。使い方、および引数の内容は以下のようになります。


flow(x, y=None, batch_size=32, shuffle=True, seed=None, save_to_dir=None,
	 save_prefix='', save_format="png")

引数

xが画像データの4次元numpy型配列、yがそれに対応したラベルのNumpy型配列であるとき、 flow()メソッドは(x,y)から生成されるイテレータを返します。

 flow()メソッドの他にも、 外部記憶上の特定ディレクトリーに置かれた画像の拡張によってバッチを生成する 'flow_from_directory'メソッドや、 Pandasデータフレームからの画像を拡張してバッチを生成する 'flow_from_dataframe'メソッドが定義されています。 詳細はKerasドキュメントを参照してください。 ここでは、numpy型配列に格納されたサンプル画像を拡張する幾つかの例について触れていきます。 始めに、画像拡張の実験で使用した8枚の建物サンプル画像を図4-1に示します。

図4-1 画像拡張に使用した8枚のサンプル画像
この8枚の画像の読み込みから、画像拡張を規定するImageDataGeneratorクラスのオブジェクト 'my_datagen'の定義、そして実際に拡張画像を生成する処理の例を示したのが以下のコードです。 幾つかのパッケージをインポートした後、サンプル画像を指定したディレクトリーから 読み込んでいます。全ての画像は縦240画素、横320画素のrgb画像ですが、読込後に (1,240,320,3)の4次元numpy配列にリシェイプしたのち、vstack関数で(8,240,320,3)の 1個のnumpy型配列に統合しています。 画像読込に続く部分は画像変換を規定した'my_datagen'です。即ち、画像の回転は10度以内、 縦横とも移動量の比率は最大0.2までとしています。またfill_modeは"constant"を選択し、 その値を設定する'cval'キーの値を指定していませんので、変換後に値が不定となる 画像領域には0が挿入されます。

# coding: shift_jis

import os
import numpy as np
import matplotlib.pyplot as plt 
from skimage import io 
from keras.preprocessing.image import ImageDataGenerator

############## サンプル画像の読み込みとnumpy配列への格納 ##############
img_path = '../sampleImages/buildings' 	#個別の環境に依存
files = os.listdir(img_path)

images = []
for file in files:
    img = io.imread(img_path + '/' + file).astype('float32')/255.0
    img = img.reshape((1,) + img.shape)
    images.append(img)
img_array = np.vstack(images)	#numpy型4次元配列縦方向積算化

visualize_images(img_array, 8, 1, 8)	#オリジナル画像表示(1行8列)

################ ImageDataGeneratorオブジェクトの生成 #################
my_datagen = ImageDataGenerator(
            rotation_range = 10.0,
            width_shift_range = 0.2,
            height_shift_range = 0.2,
			fill_mode = "constant",
            shear_range = 0,
            zoom_range = 0,
            horizontal_flip  = False,
            vertical_flip = False)

############ ImageDataGeneratorオブジェクトによる画像拡張 #############
max_img_num = 40
augmented_imgs = [] 

for d in my_datagen.flow(img_array, batch_size=4, seed=0, shuffle=False):
    augmented_imgs.append(d)
    # datagen.flowは無限にループするため必要な枚数取得時点でループを抜ける
    if (len(augmented_imgs) >= max_img_num) :
        break

aug_imgs = np.vstack(augmented_imgs)
visualize_images(aug_imgs, 8, 5, 40)	#拡張画像の表示(5行8列)

########### 画像拡張に使われたImageDataGeneratorのパラメータ ##########
for key, value in my_datagen.__dict__.items():
    print(key,':', value)
一方、numpy型4次元配列として表されたサンプル画像とそのラベル配列を受け取り、 my_datagenオブジェクトの拡張規定に従って画像データのバッチを生成するのが、 メソッドflow()です。ここでは、flow()に対して、 'batch_size'キーの値を4としていますので、サンプル画像8枚はバッチ数2に相当します。 ただし、flow()関数には4次元化された画像配列のみが指定され、そのラベル配列は 指定されていませんので、for文中のイテレータからfor文変数'd'に与えられるのは 1バッチ分のnumpy型(4,240,320,3)の画像配列のみとなります。すなわち、 for文の繰り返しのたびに4枚の拡張画像が変数'd'に代入されています。 プログラムでは、flow()メソッドで生成される拡張画像の総数 が40を超えた時点で、for文による無限ループを抜け出します。 ループを抜けるまでに生成された40枚の画像、すなわち10バッチ分の画像をまとめて 1つのnumpy型配列として表示をしています。 その結果を示したのが図4-2です。 flow()メソッドでは、図4-1の8枚のサンプル画像を2つのバッチに分けて拡張画像の生成 が行われていますが、 'shuffle'キーにFalseが指示されているため、イテレータから生成される拡張画像の順番 はサンプル画像の順番と同じです。もし、'shuffle'キーにTrueを指定すれば、イテレータ から生成される拡張画像の順番も変わってきます。

図4-2 fill_mode="constant"で画像拡張を実行した例
図のキャプションにもあるように、fill_mode="constant"の指定により、拡張処理後に 画素値が不定となった領域は0(黒色)で埋められているため、そのパターンを観測することで、 どのような変換が行われたかを推察することができます。 拡張処理によって得られた画像は、 同一バッチグループに属する場合でも異なる変換を受けていることが分かります。 さらに、バッチ毎に変換も異なっています。 図4-2に示した拡張画像は、画像変換後に値の定まらない領域に0を挿入しましたが、 値の定まらない領域の近傍にある画素値で補完することも可能です。 ImageDataGeneratorオブジェクトを生成する際のパラメータの1つとして、 'fill_mode'キーについて述べましたが、その効果について確認してみます。 前述のプログラム中、'my_datagen'インスタンス生成時の引数 fill_mode="constant"を、fill_mode="nearest"に変更して同じプログラムを実行した 結果が図4-3です。図4-2で黒色であった部分がそれほどの違和感なく近傍画素値で 補完されていることがわかります。 もう1点追加事項として、flow()メソッドにおける引数seed=0の役割です。 図4-2、および図4-3を注意深く観察すると、 対応するそれぞれの画像は補間部分を除けば同じ幾何変換を受けている ことがわかります。すなわち、乱数発生のシードを同じにすることで、画像拡張 の再現が可能であるということです。

図4-3 fill_mode="nearest"で画像拡張を実行した例

プログラムの最後では、実際の画像拡張に使用されたパラメータを、 確認のためにプリントしています。以下のような結果がプリントされます。


featurewise_center : False
samplewise_center : False
featurewise_std_normalization : False
samplewise_std_normalization : False
zca_whitening : False
zca_epsilon : 1e-06
rotation_range : 10.0
width_shift_range : 0.2
height_shift_range : 0.2
shear_range : 0
zoom_range : [1, 1]
channel_shift_range : 0.0
fill_mode : nearest
cval : 0.0
horizontal_flip : False
vertical_flip : False
rescale : None
preprocessing_function : None
dtype : float32
interpolation_order : 1
data_format : channels_last
channel_axis : 3
row_axis : 1
col_axis : 2
_validation_split : 0.0
mean : None
std : None
principal_components : None
brightness_range : None

次に、このデータ拡張処理を実際のネットワークの学習に用いるケースについて考えます。 以下は、mnist手書き数字データの読込処理と、 mnistデータの認識を行う最も基本的な階層型ニューラルネットワークをコード化 したものです。ネットワークの定義はKerasのSequentialクラスを用いて行っています。 ネットワークのメタパラメータは経験的に選択したもので、最適化等の検討は行って いません。


# coding: shift_jis

######################################################################
##        全結合3層ニューラルネットワークによる手書き数字認識       ##
##              file_name: keras_mnist_dense_mode.py                ##
######################################################################

from keras.datasets    import mnist
from keras.models      import Sequential
from keras.layers.core import Dense,Dropout,Activation
from keras.optimizers  import Adam
from keras.utils       import np_utils

####################### 分類器のモデル生成 ###########################
model = Sequential()
model.add(Dense(512, input_shape=(784,)))
model.add(Activation('relu'))
model.add(Dropout(0.3))
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.3))
model.add(Dense(10))
model.add(Activation('softmax'))

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

################## MNISTデータの読込とデータ変換 #####################
(X_train, y_train), (X_test, y_test) = mnist.load_data()
X_train = X_train.reshape(-1,784).astype('float32')/255
X_test  = X_test. reshape(-1,784).astype('float32')/255
y_train = np_utils.to_categorical(y_train,10)
y_test  = np_utils.to_categorical(y_test, 10)

################### 学習に使用するデータの選択 #######################
x_train0 = X_train[0:1000]
y_train0 = y_train[0:1000]
mnistのデータセットには学習用に60,000種、テスト用に10,000種のパターンが 収納されています。通常、学習用のパターン全てを用いて分類器の学習を行いますが、 ここでは、学習用データが十分ではない状況を想定しますので、学習用全データセット の中の先頭から1000パターンを選択し、それを以降の学習データとして利用します。 学習モデルは上記で定義した階層型全結合モデルとし、選択した1000データのみで 学習する方式、および、1000データにデータ拡張を行う学習方式の比較を行ってみる。 固定データで学習するコードを以下に示す。学習データ数が少ないためバッチサイズを 50としていますが、最適化等による選択ではありません。エポック数についても 5箇所のみで評価していますが、学習の飽和状況の判断ができればと考えての選択です。

############# データ拡張無しでのネットワークの学習と評価 #############
import time
epochs_opt  = [20,30,40,50,80]
batch_size = 50
start = time.time()
for epochs in epochs_opt:
    model.fit(x_train0, y_train0, epochs=epochs, batch_size=batch_size,verbose=0)
    score= model.evaluate(X_test, y_test,verbose=1)
    print(f'epochs='{epochs}  accuracy={score[1]:.5f}');
elapsed_time = time.time() - start
print(f'elapsed_time:{elapsed_time:.3f} seconds')
一方、データ拡張有の学習に関しては、もともとmnistデータは位置の正規化が行われて いることから、大きな位置ずれはないであろうと考え、 上下左右方向の移動量を最大1割程度と想定しました。回転角度についても 10度程度までを許容範囲としています。また、 上下・左右の反転は拡張データの中には含めていません。このような条件を考慮の上で ImageDataGeneratorオブジェクトを生成したのが以下の部分です。

############### ImageDataGeneratorオブジェクトの生成 #################
from keras.preprocessing.image 	import ImageDataGenerator

my_datagen = ImageDataGenerator(
            rotation_range = 10.0,
            width_shift_range = 0.1,
            height_shift_range = 0.1,
			fill_mode = "nearest",
            shear_range = 0,
            zoom_range = 0,
            horizontal_flip  = False,
            vertical_flip = False)
ImageDataGeneratorクラスのインスタンスオブジェクト'my_datagen'と flow()メソッドから成るイテレータは、既に述べたように無限のバッチデータ を生成します。基本的にこのバッチデータをネットワークのモデルオブジェクト modelのfit()関数に引き渡すことで学習が行われていきますが、ここで注意すべき 点として、イテレータが生成するバッチデータの構造とネットワークモデルが 受け取る入力データの構造の整合性があります。すなわち、イテレータから 生成されるバッチデータは、(batch_size, height, width, channels)の構造を持つ numpy型4次元配列であるのに対して、ネットワークの入力層にインプットされる データの構造は(batch_size, height*width, channels)の3次元配列でなければなりません。 したがって、イテレータからネットワークモデルへバッチデータを渡す段階で、 データのリサイズが必要になります。mnistデータセットから読み込んだ時点で numpy画像データ(4次元)がベクトルデータ(3次元)になっていることから、 ジェネレータには4次元化して渡したうえで、イテレータから出力される4次元 データをmodelのfit()関数に渡す段階で3次元への変換を行っています。 バッチサイズは、データ拡張無しの場合と同じに設定しています。以下は、その コードです。

############# データ拡張ありでのネットワークの学習と評価 #############
import time
x_train0   = x_train0.reshape(-1,28,28,1)
epochs_opt = [20,30,40,50,80]
batch_size = 50
start = time.time()
for epochs in epochs_opt:
    for epoch in range(epochs):
        batches = 0
        for x_batch, y_batch in my_datagen.flow(x_train0, y_train0, 
                                                batch_size=batch_size):
            model.fit(x_batch.reshape(-1,784), y_batch, verbose=0)
            batches += 1
            if batches >= len(x_train0)/batch_size:
                break

    score = model.evaluate(X_test, y_test,verbose=0)
    print(f'epochs={epochs}  accuracy= {score[1]:.5f}');

elapsed_time = time.time() - start
print(f'elapsed_time:{elapsed_time:.2f} seconds')
両プログラムともテスト用には10,000個の未学習データを使用し、エポック数を 20,30,40,50,80と増やして学習を行った場合の確信度を求めてみました。また、 両実験とも、すべての計算を終えるまでの時間を計測しました。 その結果を以下の表4-2および表4-3に示します。
表4-2 データ拡張無しでの学習結果
epoch数確信度
epochs=20acc=0.89850
epochs=30acc=0.90100
epochs=40acc=0.90860
epochs=50acc=0.89900
epochs=80acc=0.91360
elapsed_time:51.58 seconds

表4-3 データ拡張有りでの学習結果
epoch数確信度
epochs=20acc=0.91450
epochs=30acc=0.93580
epochs=40acc=0.93980
epochs=50acc=0.94590
epochs=80acc=0.94650
elapsed_time:123.69 seconds

データ拡張無しの場合は、学習回数を増やしても認識率 の向上が見られないことから、明らかに学習データが不足していると言えます。 一方、データ拡張有りの場合は、学習回数の増加に応じて認識率の向上が見られます。 バッチサイズおよびバッチ数が同じであっても、データ拡張によって学習パターンの 多様性が広がったことが理由であると考えられます。 しかし、処理時間については、画像の拡張処理やサイズ変換等の処理が増えるため、 結果として、2倍強の処理時間を要しています。