Theanoによる自己符号化器の実装
この記事はDeep Learning Advent Calendar 2015の3日目です。いつも読んでばかりで悪いし、Deep Learningの話題なら何でもよいそうなので登録してみました。
Theanoによる畳み込みニューラルネットワークの実装 (2)(2015/7/14)のつづき。
ここ最近はChainerを使ってきたけれどまた手法の勉強も兼ねてTheanoでの実装に戻りたい。ChainerやTensorFlowがあるんだからTheanoなんてもう誰も使わない?ごめんなさい・・・TheanoはDeep Learning Tutorialをはじめ、実装例が豊富にあり、絶妙な粒度で小回りもきくので手法の勉強にちょうどよいんだよね。
今回からしばらくさまざまな自己符号化器(Autoencoder)を検証していきたい。深層学習のメリットである特徴の自動学習の基礎になるところなのでしっかり理解しておきたいところ。個人的には一番興味を持っているアルゴリズムである。これまでも主成分分析やフーリエ解析などパターンの分解と再構築に関するアルゴリズムに興味を惹かれることが多かったのでこれに興味を持ったのは必然というべきか・・・
Deep Learning Tutorialにそって
- 自己符号化器(Autoencoder)
- 雑音除去自己符号化器(Denoising autoencoder)
- 積層自己符号化器(Stacked autoencoder)
- スパース自己符号化器(Sparse autoencoder)
- 縮小自己符号化器(Contractive autoencoder)
- 変分自己符号化器(Variational autoencoder)
の順に実装してみる予定。日本語だとあまり聞き慣れない訳が多いけれど深層学習: Deep Learning(人工知能学会編)に準拠した。
Deep Learning Tutorialでは自己符号化器と雑音除去自己符号化器が同じコードだけれど実装をわかりやすくするため分離した。スパース自己符号化器と変分自己符号化器はDeep Learning Tutorialに載ってないけれどいくつかTheanoでの実装例を見つけたので参考までに実装してみたい。またDeep Learning TutorialではMNISTの実験結果しかないけれど、CIFAR-10(2015/10/14)などカラー画像でも実験してみたい。
今回実装したコードはここ。
自己符号化器
ポイントをまとめると
- ラベル情報を使わない教師なし学習に属する
- データに内在する特徴を学習する
- 主成分分析と同じく情報の次元圧縮の手法(主な違いは非線形性の導入と多層化)
- 深層ニューラルネットワークの事前学習(重みの初期値決定)に使われる
事前学習の方法には自己符号化器の他に制限ボルツマンマシン(RBM: Restricted Boltzmann Machine)というアルゴリズムもある。RBMが確率モデルであるのに対し、自己符号化器は決定的モデルなのでより理解しやすいと感じた。チュートリアルで先に来るのもそのせいだろうと勝手に想像。
Deep Learning Tutorialは図が乏しいので整理のため自分で描いてみた。
自己符号化器の構造的な特徴は
- 入力層、隠れ層、出力層の三層構造が基本
- 入力層と出力層のユニット数は同じ
- 隠れ層のユニット数は入力層より小さい(砂時計型)
となる。入力データに含まれる意味のある特徴を学習するためには隠れ層のユニット数を入力層のユニット数より小さくする必要がある。ただし、例外的にスパース自己符号化はスパース制約を入れるため隠れ層が大きくてもOKとのこと。これは後でためそう。
隠れ層や出力層の出力は、多層パーセプトロン(2015/6/18)とまったく同じ。
x
をy
に変換するプロセスを符号化(encode)、y
をz
に変換するプロセスを復号化(decode)と呼ぶ。
s
はsigmoid
などの活性化関数。Deep Learning Tutorialでは両方とも同じ関数s
を使っているが、符号化と復号化で別のものを使ってもよいそうだ。
W
とW'
は多層パーセプトロンだとそれぞれ別の重み行列で管理したが、自己符号化器では という重み共有(tied weight)の制約を入れることもある。この制約を入れるとW'
はW
から計算できるため学習すべきパラメータ数が半分に減る。
多層パーセプトロン(2015/6/18)だと出力z
がクラスラベル(数字認識なら10クラス)に近づくように重み {W, b, W', b'}
を学習したけれど、自己符号化器はクラスラベルを使わず、出力z
が入力x
に近づくように重みを更新する。まあ「己が教師だ!」と言ってしまえば教師あり学習に見えないこともない。
z
がx
にどれくらい近づいたかはコスト関数(誤差関数)で定義する。このコスト関数を最小化するようにパラメータ {W, b, b'}
を誤差逆伝搬法で更新するのが自己符号化器の学習に当たる。
入力x
が [0, 1] の範囲に正規化されているならば、交差エントロピー誤差関数
が使える。この式は入力x
が1サンプルの場合だが、実際にはミニバッチ単位で誤差を計算するのでミニバッチに含まれる全サンプルの平均誤差を求める。
自己符号化器は、入力x
をより低次元の空間y
に写像し、そこから元の入力z
を再構築(reconstruct)していると解釈できる。低次元の空間y
に写像した時点で入力x
から冗長な情報が削ぎ落とされ、元のx
を再構成するのに必要な情報y
だけが抽出されるという理屈のようだ。そんなにうまくいくものかな?と疑っていたのだけれど、どうやら最適化のちからを過小評価していたようだ・・・
以下、Theanoで実装していくが、全部書くと長くなるので理解が難しかったところをかいつまんで整理した。ソースコード全体はこちら。
重み共有の実装
まず重み共有の制約だがこれは簡単に入れられる。
# パラメータ
self.W = W
self.b = bhid
self.W_prime = self.W.T
self.b_prime = bvis
self.params = [self.W, self.b, self.b_prime]
self.params
は誤差逆伝搬法で更新対象となるパラメータをまとめたリストだがここにself.W_prime
は入っていないのがポイント。self.W_prime
はself.W
の転置行列として計算できるので更新しなくてもよい。
Chainerは重みパラメータが隠されているためか重み共有を実装するのに一手間かかると聞いたことがあるが、ちゃんと実装できたという報告例もある(Chainerでtied weightなAutoencoderを作った)のでできないわけではないようだ。
符号化・復号化処理の実装
符号化と復号化を行うシンボルを返すメソッドは下のように定義できる。あくまでシンボルを返すだけでtheano.function()
で関数化しないと使えない。Theanoはここらへんがややこしい・・・
def get_hidden_values(self, input): """入力層の値を隠れ層の値に変換""" return T.nnet.sigmoid(T.dot(input, self.W) + self.b)
内積の計算が数式と逆でないか?と思ったがこれで正しかった。数式のx
は1サンプルの入力(ベクトル)なのに対し、このコードのinput
はミニバッチに含まれるサンプル集合(行列)のため。行列を使うと複数のサンプルの出力をまとめて計算できるため効率がよい。同様の理由で重み行列W
の形状も数式の場合と行と列が逆なので注意。
def get_reconstructed_input(self, hidden): """隠れ層の値を入力層の値に逆変換""" return T.nnet.sigmoid(T.dot(hidden, self.W_prime) + self.b_prime)
復号化のコードもほとんど同じ。今回のMNISTの例では入力を0-1の範囲に正規化しているため活性化関数はsigmoid
でよい。ここで入力が任意範囲の実数なのに活性化関数にsigmoid
を使ってしまうと、sigmoid
によって出力z
が [0, 1] の範囲になってしまうのでz
がいつまで経ってもx
に近づかない(収束しない)という悲惨な結果になる。これは経験済み(笑)
交差エントロピー誤差関数の実装
次に交差エントロピー誤差関数(コスト)の実装。コストとパラメータ更新式を計算するシンボルを返すメソッドが定義されている。Deep Learning Tutorialではこの章から実装方法が以前と微妙に変わっていて開発者が別の気がしている。
def get_cost_updates(self, learning_rate): """コスト関数と更新式のシンボルを返す""" # 入力を変換 y = self.get_hidden_values(self.x) # 変換した値を逆変換で入力に戻す z = self.get_reconstructed_input(y) # コスト関数のシンボル # 元の入力と再構築した入力の交差エントロピー誤差を計算 # 入力xがミニバッチのときLはベクトルになる L = - T.sum(self.x * T.log(z) + (1 - self.x) * T.log(1 - z), axis=1) # Lはミニバッチの各サンプルの交差エントロピー誤差なので全サンプルで平均を取る cost = T.mean(L) # 誤差関数の微分 gparams = T.grad(cost, self.params) # 更新式のシンボル updates = [(param, param - learning_rate * gparam) for param, gparam in zip(self.params, gparams)] return cost, updates
L
が交差エントロピー誤差関数の数式に対応する。ここは少し理解に手間取ったので詳細にまとめておこう。
まず、数式の、はベクトルであるのに対し、このコードのself.x
とz
はミニバッチ単位の行列になっていることに注意。print self.x.type
、print z.type
とするとTensorType(float32, matrix)
、TensorType(float32, matrix)
となり、vectorではなくmatrixであることがわかる。
模式図を描くと
わかりにくくなるためT.sum()
内の第二項とlog
の計算は無視した。この結果を見ると交差エントロピー誤差関数の定義と同じようにベクトルの各要素の積を計算してから和を取るという操作がミニバッチの全サンプルまとめて一括計算できていることがわかる。for文を使わず行列で書くのは慣れないとわかりにくい。図の具体例をTheanoのコードで書くと
実行結果は
[ 50. 250. 610.] 303.333333333
となり、意図どおり計算できていることがわかる。
モデル学習の実装
ここは多層パーセプトロン(2015/6/18)とほとんど違いがないため省略かな。収束判定が適当になり、epoch
数を固定しているのでむしろ前より単純になった。このチュートリアルでは自己符号化器はあくまで事前学習に使うという位置づけなので収束判定は適当でよいのかもしれない。
if __name__ == "__main__": learning_rate = 0.1 training_epochs = 20 batch_size = 20 # 学習データのロード datasets = load_data('mnist.pkl.gz') # 自己符号化器は教師なし学習なので訓練データのラベルは使わない train_set_x = datasets[0][0] # ミニバッチ数 n_train_batches = train_set_x.get_value(borrow=True).shape[0] / batch_size # ミニバッチのインデックスを表すシンボル index = T.lscalar() # ミニバッチの学習データを表すシンボル x = T.matrix('x') # モデル構築 rng = np.random.RandomState(123) theano_rng = RandomStreams(rng.randint(2 ** 30)) autoencoder = Autoencoder(numpy_rng=rng, theano_rng=theano_rng, input=x, n_visible=28 * 28, n_hidden=100) # コスト関数と更新式のシンボルを取得 cost, updates = autoencoder.get_cost_updates(learning_rate=learning_rate) # 訓練用の関数を定義 train_da = theano.function([index], cost, updates=updates, givens={ x: train_set_x[index * batch_size: (index + 1) * batch_size] }) # モデル訓練 fp = open("cost.txt", "w") start_time = time.clock() for epoch in xrange(training_epochs): c = [] for batch_index in xrange(n_train_batches): c.append(train_da(batch_index)) print "Training epoch %d, cost %f" % (epoch, np.mean(c)) fp.write("%d\t%f\n" % (epoch, np.mean(c))) fp.flush() end_time = time.clock() training_time = (end_time - start_time) fp.close() print "time: %ds" % (training_time)
Deep Learning Tutorialにないコードとして、Theanoチュートリアルを参考に学習したモデルパラメータを保存するコードを付け加えた。
# 学習したモデルの状態を保存 f = open('autoencoder.pkl', 'wb') cPickle.dump(autoencoder.__getstate__(), f, protocol=cPickle.HIGHEST_PROTOCOL) f.close()
class Autoencoder(object): def __getstate__(self): """パラメータの状態を返す""" return (self.W.get_value(), self.b.get_value(), self.b_prime.get_value()) def __setstate__(self, state): """パラメータの状態をセット""" self.W.set_value(state[0]) self.b.set_value(state[1]) self.b_prime.set_value(state[2])
autoencoder
のオブジェクトを丸ごとdumpするというのはあまりよくないアイデアのようだ。ここでは学習したパラメータをSharedVariable
の形式ではなく、get_value()
でnumpy.ndarray
の形式にしてからダンプしている。こうしておくと重みを描画するコードなどが書きやすい。
実験結果
MNISTデータセットで実験してみた。入力層と出力層のユニット数は28*28=784ユニット、隠れ層のユニット数が500、学習率0.1とした。GPUを使用し、20エポック回すのに63秒かかった。
訓練データのコストが下がっていき学習できていることはわかる。ただ自己符号化器単体ではテストデータでの識別精度などに基づいてモデルの良し悪しが判断できない。学習した重みを可視化したり、入力画像を再構築して正しく動作しているか確認してみたい。
重みの可視化
重みを可視化する関数はDeep Learning Tutorialのutils.pyに実装されている。ただこのコードはわかりにくい・・・
これまでと同様にmatplotlibを使って描画してみた。学習した重みはautoencoder.pkl
というファイルにダンプしたのでこれを使って以下のような描画スクリプトを書いた。
重みはとくに正規化されているわけでもなく、負の値を取ることもあるため画像として出力するには重みを [0, 255] の範囲にスケーリングする必要がある。まず、W = (W - np.min(W)) / (np.max(W) - np.min(W))
のコードで重みを [0, 1] の範囲に正規化し、これに255をかけることで [0, 255] の範囲にしている。また、隠れ層の数が500ユニットなので本来は重みが500個の画像が描けるのだが最初の100個に絞っている。
実行すると下のような画像が得られ、チュートリアルの結果ともほぼ一致する。
この気持ち悪い画像が一体何を意味しているのか?が問題だけど深層学習(MLPシリーズの方)の5.3節で理解できたので引用しておこう。
自己符号化器では、誤差関数を最小化することで、ネットワークの重みとバイアスを決定します。通常、中間層の (W, b) と出力層の (W', b') のうち、前者にのみ関心があります。これらのパラメータは、(データを表す)特徴と呼ばれます。yの計算は、重み行列Wとxの積から始まりますが、これはWの各行ベクトルとxとの内積の計算であって、そこでは入力xにWの各行ベクトルが表す成分がどれだけ含まれているかを取り出しているといえます(p.58)。
主成分分析でいうところの固有ベクトルのようなものだと理解している。
入力画像の再構築
最後に隠れ層のユニット数を変えたときにどの程度元の画像が再現できるのか検証した。まずはオリジナル画像 隠れ層のユニット数500のとき。ほぼ完璧にオリジナル画像が再現できている。 隠れ層のユニット数100のとき。情報の欠落が大きいようで少し乱れてきた。 隠れ層のユニット数10のとき。かなり乱れてきた。数字自体が変わってしまうケースもある。
これCIFAR-10の画像でやったら楽しそうだな。
再構築に使ったコードはこれ。
次回は雑音除去自己符号化器を実装してみよう。大部分は今回のコードが流用できるので短く書けそう。