技術談話

[話題3] Kerasと学習データ

 深層学習においては、教師データの質と量が重要です。 教師データは、一般にはある手段で観測された物理的特性データと、そのデータに よって説明される結果(ラベルデータ)で構成されますが、その質を左右するのは 観測データの多様性とラベルデータの信頼性と言えます。観測データの多様性の客観的尺度 はありませんが、ラベルで表現しようとする概念の因果関係を十分に含んだ情報 でなければなりません。一方、ラベルに関しては、多くの場合人手によって付与される ことが多く、ときには豊富な知識と経験を必要とします。これは、大量の教師データを 供給する上で1つのボトルネックとなっています。 ここでは、画像を対象とする深層学習において、教師データを管理する 有効な方法について考えます。深層学習のプラットフォームとしてはKerasとします。 画像を対象とした学習でデータの量が問題となるのは、教師データが 多すぎてデータの分割が必要な場合と、逆に教師データが少なく、過学習の心配がある場合とが あります。それぞれの場合について見ていきます。

最初は、学習データ(画像)が厖大で、学習データの量を管理する必要がある場合です。 まず、データ量を管理する上で有益なpythonの機能について簡単に触れます。

1.Pythonにおけるイテラブル(繰り返し可能)オブジェクト

次のPythonのfor文について考えます。(pythonのインタープリター・モード)

>>> for i in [1,2,3,4]:
...   print(i) 
...
1
2
3
4
>>>
このfor文は、Pythonプログラムにおける繰り返し制御の最も基本的な文型です。"in"の後に 続くリスト(1次元配列の一種)の各要素をリストの先頭から順に取り出し、それを変数"i" に代入してfor文の実行部を評価します。これをリスト要素を全て取り出すまで繰り返します。 以下のfor文の"in"に続くrange()も同様にイテラブルで、0から9まで3おきの数から1個づつ 取り出して変数"x"に代入して本体部を実行します。

>>> for x in range(0,10,3):
...   print(x)
...
0
3
6
9
>>>
このように、オブジェクト中の要素を順に取り出す機能を備えたオブジェクトを、 イテラブル(iterable)オブジェクトと呼んでいます。 Pythonでは、range型, リスト(list)型, タプル(tuple)型, 集合(set)型, 辞書(dict)型, 文字列(str)型の各オブジェクトは、それぞれイテラブル(iterable)オブジェクトです。 イテラブルオブジェクトとは、言い換えれば、for 文の "in" の後に書き込める オブジェクトまたはそのクラスのことです。 これらのイテラブルオブジェクトはそれぞれにイテレータ(iterator)を持ちます。 イテレータとは、要素を反復して取り出すことのできるインタフェースです。 例えば、リスト型オブジェクトは'list_iterator'オブジェクトを、タプル型 オブジェクトは'tuple_iterator'オブジェクトというようにそれぞれのオブジェクト の要素を取り出すためのインターフェースを独自に備えています。

 組み込み型のlist, tuple, set, dictなどのオブジェクトは、どれもイテラブル ですが、繰り返し処理を行う前にそれぞれのオブジェクトの要素が定まっている 必要があります。 そのため、以下のような場合、組み込み型とは別の新しいイテレータを 定義できた方が都合がよいと考えられます。

Pythonにおいては、要素を取り出そうとする度に新たな要素を生成する、 ジェネレータ(generator)と呼ばれるオブジェクトが提供されています。 ジェネレータはイテレータの一種です。Pythonにおけるジェネレータは、多くの場合、 yield文を使って実装されます。 次のジェネレータ定義のコードについて見てみましょう。

def generator1(a):
    b = 10
    yield a+b			#a+bの値を返し、一時停止
    b += 2
    yield a+b			#a+bの値を返し、一時停止
    b += 2
    yield a+b			#a+bの値を返し、一時停止
ジェネレータの定義には、関数の場合と同様にdef文が使われていますが、 戻りを制御するのにreturn文はなく、yield文が用いられています。しかも、 複数のyield文を挿入することが可能です。

ジェネレータは、イテラブルオブジェクトと同様にイテレータを備えているので、 以下のようにfor文中で実行することができます。


>>> for x in generator1(10):
...    print(f'x={x}')
...
x=20
x=22
x=24
>>>
すなわち、定義中のyield文毎にジェネレータは値を生成し、その値を変数"x"に 代入してfor文の実行部を評価します。 従って、上記のgenerator1()では3つのyield文で生成される3個の値が印字されます。

次に、generator1()を次のように変更したgenerator2()について考えます。 定義中、while文の条件が常にTrueですから、無限のループが形成されます。


def generator2(a):
    b = 10
    while True:			#無限ループ設定(yield文が無限個)
        yield a+b
        b += 2
このジェネレータをfor文でそのまま用いると、yield文は無限回実行されてしまいます。 従って、この無限回の実行を終了させる処理をfor文の実行部に記述する必要があります。 その例が以下のコードです。

>>>for i,x in enumerate(generator2(10)):  #ループ制御変数iを設定
...    print(f'i={i}  x={x}')
...    if i == 4:                         #繰り返し回数5でループ終了
...        break
...
i=0  x=20
i=1  x=22
i=2  x=24
i=3  x=26
i=4  x=28
>>>

2.Kerasと学習データの管理

ネットワークモデル学習の際に必要なトレーニングデータの管理および拡張処理を、 Kerasを用いて行う場合について考えてみます。 データ管理の観点から問題となるのは、主記憶容量に対して学習用データが厖大で、 全データを用いた学習が困難になる場合です。 Kerasにおいては、通常のモデル学習を行うmodel.fit()関数でも全データを分割して 学習するバッチ学習が可能です。 しかし、その前提として全データがメモリ上に収容されていなければなりません。 画素数の大きな画像を大量に扱わなければならない場合には、主記憶上に全データを収容 できない事態も起こり得ます。このような場合には、全データを主記憶上に置くのではなく、 ある一定量毎にデータを読み込んで学習を行っていかなければなりません。そのためには、 学習プロセスの中で、データを外部記憶装置から読み込む処理を実行する必要があります。
Keraでは、 データ読み込みをユーザ側で制御しながら学習を進めることも可能です。 すなわち、Modelクラスのメソッドとして定義されている model.fit_generator()関数がその役割を担っています。 fit_generator()関数は、 Sequenceクラスをスーパークラスとして持つようなオブジェクトを引数として渡します。 Sequenceクラスは、datasetのようなデータ系列にネットワークモデルをfittingする 抽象化されたオブジェクトです。 このクラスには、一定量のデータの読み込みや新たなデータの生成を担当する __getitem__()メソッドと、1エポック当たりのバッチ回数を決める__len__()メソッドが 定義されています。 従って、個別の学習問題におけるデータの読み込みと、1エポック当たりの繰り返し回数を __getitem__()と__len__()の中で適切に記述することで、fit_generator() の枠組みで多様な学習を行うことができます。 もし、エポック毎にデータセットの順序を変更したい場合には、on_epoch_end()メソッドを 実装しなければなりません。このメソッド内で通常行われる処理としては、 データのシャッフルが一般的です。 以下は、Sequenceクラスの性質を継承した新たなクラスMyGenerator0と、そのメソッド __len__()と__getitem__()、さらにon_epoch_end()を定義した例です。

# file_name: my_generator1.py

import numpy as np
import keras
from keras.utils import Sequence

class MyGenerator1(Sequence):
	""" コンストラクタ """
	def __init__(self, data,labels,batch_size, dim,
					channels, classes, shuffle=True):
		self.data     = data			#本実験ではnumpy型配列の全体
		self.labels   = labels          #カテゴリカル表現を想定
		self.batch_size = batch_size
		self.dim = dim
		self.channels = channels
		self.classes  = classes			#ラベルのカテゴリー数
		self.shuffle  = shuffle
		self.on_epoch_end()
	#End of __init__()

	def __len__(self):
		#1エポック当たりのバッチ総数を返す
		return int(np.floor(len(self.data)/self.batch_size))
	#End of __len__()

	def __getitem__(self, index):
		#index:バッチ番号
		#1バッチ容量分の領域を確保(このバッチ単位で重みを更新)
		X = np.empty((self.batch_size, *self.dim, self.channels))
		labels = np.zeros((self.batch_size,self.classes))
		indexes = self.indexes[index*self.batch_size:(index+1)*self.batch_size]

		#1バッチ単位の構成要素をself.dataから取り出す
		for i ,ID in enumerate(indexes):
			X[i,] = self.data[ID]
			labels[i] = self.labels[ID]		#本実験では使用せず

		return X, X	#オートエンコーダ学習用入出力データ。ジェネレータの出力
	#End of __getitem__()

	def on_epoch_end(self):
		#1エポック毎にサンプルデータのインデックスをシャッフル
		self.indexes = np.arange(len(self.data))
		if self.shuffle ==True:
			np.random.shuffle(self.indexes)
	#End of on_epoch_end()
学習に用いたデータは、cifar10プロジェクトで提供された 32画素 x 32画素 x 3チャネルの画像(トレーニング用:50,000枚、テスト用:10,000枚) です。このデータセットはトータルで180Mbytesですので、通常のノートブックPCでも メモリ上に全データをおいてfit()関数で学習することが可能です。 ここでは、メモリ上の全データを一定量毎に区切って学習する際の処理の 流れを把握する目的でModel.fit_generator()関数を用います。 全データのメモリ上への読み込みが困難な場合は、データ読込が上記コードにおける データ区切り処理に相当します。
定義されているメソッドを個別に見ていきます。最初に、 コンストラクタ__init__()の第一引数dataは、メモリ上に収容された全トレーニングデータ を表しており、numpy型(50000,32,32,3)次元配列を想定しています。 第二引数の'labels'は教師データを表します。 学習対象ネットワークがオートエンコーダーであることから、 教師データには入力と同じ画像を用います。 従って、後に示すModel.fit_generator()の呼び出しでは形式的にラベルデータを実引数として 渡していますが、このラベルデータは__getitem__()関数からは生成されません。 第三引数の'batch_size'は、モデルパラメータ更新の単位となる学習データのサイズを指定 します。もしbatch_size=250ならば、総データ量/batch_size=50,000/250=200がバッチ総数 となり、1エポック当たりの繰り返し総数は200回、すなわち全データを学習に使用した場合の ネットワークの重みは200回更新されることを示しています。このバッチ総数を求める メソッドが'__len__()'です。 1エポックで全データを用いたパラメータ更新が行われますが、この更新終了毎に全学習データ のシャッフルを行うか否かを指示するのが、'shuffle'引数です。デフォルトでシャッフルあり のTrueを指定しています。

Sequenceクラスを継承したMyGenerator1クラスのインスタンスを生成し、Model.fit_generator() の第一引数にそのインスタンスを渡した時点で、MyGenerator1クラスのメソッドとして 定義された__getitem__()がfit_generator()と連動します。 上記に示した__getitem__()について簡単に触れます。 引数'index'は、Model.fit_generator()の中で用いられるバッチ更新回数を表す変数で、 __getitem__()メソッドが読み込むべきバッチ番号を表します。総学習データを格納した 配列は、MyGenerator1オブジェクト生成時に__init__()によってオブジェクト内の ローカル変数'data'に収納されています。ただし、1エポック毎に全データの 順序をシャッフルするために、各データのインデックスを格納した'indexes'配列を介して データの読み出しを行っています。 以下に、Model.fit_generator()によるオートエンコーダモデルの学習を行うコードの例を示します。 ただし、オートエンコーダーのネットワーク構造の最適化については検討を行っていないため、 学習の結果は必ずしも満足できるものではありません。


# file_name: train_mygenerator1.py

####################### パッケージのロード ############################
import os
import numpy as np
import keras
from keras.utils	import to_categorical
from keras.datasets import cifar10
from keras.layers	import Input, BatchNormalization, MaxPooling2D
from keras.layers	import Conv2D, UpSampling2D, Activation
from keras.models	import Model
from keras.optimizers import Adam
from visualize		import visualize_color_imgs
from my_generator1	import MyGenerator1


###################### ネットワークモデルの構成 #######################
#                     << オートエンコーダー >>                        #
#######################################################################
#エンコーダ部
input_img = Input(shape=(32,32,3))     #CIFAR10の画像データが対象
x = Conv2D(32,(3,3), padding='same')(input_img)	#畳み込みレイヤー
x = BatchNormalization()(x)			#BatchNormalizationレイヤー
x = Activation('relu')(x)			#Activationレイヤー
x = MaxPooling2D((2,2))(x)			#MaxPoolingレイヤー
x = Conv2D(16, (3,3), padding='same')(x)	#Conv2Dレイヤー
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = MaxPooling2D((2,2))(x) 
x = Conv2D(8, (3,3), padding='same')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
encoder = MaxPooling2D((2,2))(x)
#デコーダ部
x = Conv2D(8,(3,3), padding='same')(encoder)
x = BatchNormalization( )(x)
x = Activation('relu')(x)
x = UpSampling2D((2,2))(x)
x = Conv2D(16, (3,3), padding='same')(x)
x = BatchNormalization( )(x)
x = Activation('relu')(x)
x = UpSampling2D((2,2))(x)
x = Conv2D(32, (3,3), padding='same')(x)
x = BatchNormalization( )(x)
x = Activation('relu')(x)
x = UpSampling2D((2,2))(x)
x = Conv2D(3, (3,3), padding='same')(x)
x = BatchNormalization( )(x)
decoder = Activation('sigmoid')(x)

########################### モデルの定義 ##############################
model = Model(inputs=input_img, outputs=decoder)
model.compile(optimizer='adam',loss='binary_crossentropy')
model.summary()

####################### データ読込と正規化処理 ########################
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train	= x_train.astype('float32')/255
x_test  = x_test.astype('float32')/255
y_train = to_categorical(y_train, 10)
y_test  = to_categorical(x_test,  10)

########### ジェネレータに引き渡すパラメータ(辞書型で表現) ############
parms = { 'dim': (32,32),
          'batch_size': 250,
          'classes': 10,
          'channels': 3,
          'shuffle': True }

######################## ジェネレータ生成 #############################
my_generator = MyGenerator1(x_train, y_train, **parms)
# **parms:	辞書型データから複数のキーワード引数を受け取る

################ プログラム実行制御パラメータの設定 ###################
Load_Weights,Save_Weights, Fit_Generator = False, True, True

########### 学習後のネットワークパラメータの保存場所と名前 ############
saveDir = './'
file_name = 'cifar10Autoencode1.hdf5'

if Load_Weights == True:
	model.load_weights(saveDir + file_name)

if Fit_Generator == True:
	model.fit_generator(generator=my_generator, epochs=20, workers=6)

if Save_Weights == True:
	model.save_weights(saveDir + file_name)

####################### 未学習画像でのテスト ##########################
result = model.predict(x_test)
visualize_color_imgs(x_test, result, 10, 32, 32)
print('Process terminates.')
上記のコードは、コマンドラインから次のように実行します。

(base) C:\Users\Python>python train_generator.py
上記コード中のLoad_Weights変数をFalseからTrueに変更して 同じコマンドを複数回実行すれば、epoch数20での学習を繰り返す ことになるため、結果にも多少の改善が見られます。 しかし、ネットワーク構造の検討も同時に必要と思われます。

上記は、全ての学習データを主記憶上においてネットワークモデルの学習を Model.fit_generator()メソッドで行う例でしたが、同じメソッドでも 外部から学習データを必要な分だけ読み込んで学習を進めることも可能です。 次に、その例について考えます。話を簡単にするために、学習するネットワークは 前と同じ構造のオートエンコーダとし、cifar10のデータセットから訓練用とテスト用に それぞれ5000画像、1000画像をファイルとして書き出したものを利用します。 そのファイルのディレクトリー構成は下図のように作成しました。


  ./dataset
      |--------------------------test
      |----train                  |----te_00000.png
      |      |---tr_00000.png     |  .      .
      |      | .   .              |  .      .
      |      | .   .              |----te_00999.png
      |      |---tr_04999.png     |----te_label.txt
      |      |---tr_label.txt     
コードは前と殆ど同じですが、__getitem__()メソッド中のバッチサイズ分の 画像の読み込み部分が変更になっています。

# file_name: my_generator2.py

import numpy as np
import keras
from keras.utils import Sequence
from skimage.io import imread

class MyGenerator2(Sequence):
	""" コンストラクタ """
	def __init__(self, fnames,labels,batch_size, dim, channels,
				 classes, tr_path, te_path, shuffle=True,):
		self.fnames   = fnames			#本実験ではファイル名リスト
		self.labels   = labels			#本実験では使用せず
		self.batch_size = batch_size
		self.dim      = dim
		self.channels = channels
		self.classes  = classes
		self.shuffle  = shuffle
		self.tr_path  = tr_path
		self.te_path  = te_path
		self.on_epoch_end()
	#End of __init__()

	def __len__(self):
		#1エポック当たりのバッチ総数を返す
		return int(np.floor(len(self.fnames)/self.batch_size))
	#End of __len__()

	def __getitem__(self, index):
		#index:バッチ番号
		#1バッチ容量分の領域を確保(このバッチ単位で重みを更新)
		X = np.empty((self.batch_size, *self.dim, self.channels))
		labels = np.zeros((self.batch_size, self.classes))
		indexes = self.indexes[index*self.batch_size:(index+1)*self.batch_size]
		#1バッチ単位の構成要素をself.samplesのファイルリストで読み出す
		for i ,index in enumerate(indexes):
			fname = self.fnames[index]
			X[i] = imread(self.tr_path + fname)
			X[i] = X[i].astype('float32')/255.0
			labels[i] = self.labels[index]
		return X, X	  #generatorが返す学習用入出力データ。labelsは使用せず。
	#End of __getitem__()

	def on_epoch_end(self):
		#1エポック毎にサンプルデータをシャッフル
		self.indexes = np.arange(len(self.fnames))
		if self.shuffle ==True:
			np.random.shuffle(self.indexes)
	#End of on_epoch_end()
学習に用いるデータセットの準備と、MyGenerator2オブジェクトへのファイル 格納情報の渡し方を示したのが、以下のコードです。辞書型データ 表現のparms変数にキーワード引数として入れています。 一方、オートエンコーダーの定義は前のモデルと同じですので、省いています。

########################## データセット概要 ###########################
#   cifar10画像をファイル化                                           #
#   学習データセットは5,000枚の画像(xt_00000.png〜xt_04999.png)       #
#   各画像中のラベルは、最大10種類のカテゴリーに分類                  #
#   ラベルはテキストとして1つのファイルに格納(tr_label.txt)          #
#   同様にテスト用データ(te_00000.png〜te_00999.png,te_label.txt)も   #
#   './dataset/test/'の下に保存                                       #
#######################################################################
tr_path = './dataset/train/'
files   = os.listdir(train_path)
tr_list = [file for file in files if file.find('.png') > 0]

te_path  = './dataset/test/'
files    = os.listdir(test_path)
te_list  = [file for file in files if file.find('.png') > 0]

#ラベル情報が必要ならば、以下を実行
tr_label = np.loadtxt(tr_path + 'tr_label.txt')
te_label = np.loadtxt(te_path + 'te_label.txt')
tr_label = to_categorical(tr_label, 10)
te_label = to_categorical(te_label, 10)

#Parameters: 辞書型で表現(関数の引数へのキーワード渡し)
parms ={	'dim': (32,32),
			'batch_size': 100,
			'classes': 10,
			'channels': 3,
			'shuffle': True,
			'tr_path': './dataset/train/',
			'te_path': './dataset/test/' }

############################ Generators ###############################
my_generator = MyGenerator2(tr_list, tr_label, **parms)
# **parms:	辞書型データ表現による複数のキーワード引数渡し

Load_Weights,Save_Weights, Fit_Generator = False, True, True

saveDir = './'
file_name = 'cifar10Autoencode2.hdf5'

if Load_Weights == True:
	model.load_weights(saveDir + file_name)
if Fit_Generator == True:
	model.fit_generator(generator=my_generator, epochs=20, workers=8)
if Save_Weights == True:
	model.save_weights(saveDir + file_name)

from skimage.io import imread
X = np.empty((100, 32, 32, 3))
for i in range(100):
	X[i] = imread(te_path + te_list[i])
	X[i] = X[i].astype('float32')/255.0

result = model.predict(X)
visualize_color_imgs(X, result, 10, 32, 32)
print('!!! Process Terminates. !!!')
前の例と同じく、上記コード中の'Load_Weights'変数をTrueにしてプログラムを 再度実行すれば、epoch数を倍にしたときと同様の学習を行ったことに相当します。

上述の2種類のジェネレータを用いて行ったオートエンコーダの学習の結果を 示します。学習に用いた画像データは、50,000枚と5,000枚で、学習はいずれの 場合もepoch=20でModel.fit_generator()を3回繰り返したものです。(2回目以降は 前の学習結果を初期重みに設定) 全データを用いて学習を行ったほうが、学習データの量およびパラメータ更新の 回数も多く、復号結果の品質も高いことがわかります。 一方、学習に要した時間については、画像ファイル読み出しのオーバーヘッド があるものの、ほぼ学習データの量に比例します。 学習データ量が厖大で主記憶上に一括して読み込むことが困難な場合には、 バッチ毎にデータを読み込んで学習していくことが避けられません。

図3-1 全データによる学習後の符号化/復号化処理の結果例

図3-2 5,000データによる学習後の符号化/復号化処理の結果例

参考文献