人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

Theanoによる多層パーセプトロンの実装

Theanoによるロジスティック回帰の実装(2015/5/26)のつづき。今回は、Deep Learning Tutorialの多層パーセプトロン(Multilayer Perceptron)を実装してみる。タスクは前回と同じMNISTの手書き数字認識。多層パーセプトロンはこれまでも何回か実装してきた(2010/8/29)けど今回はTheanoを使ってみようという趣旨。これまでよりずいぶん簡単に実装できることがわかる。

ソースコード全体はここに置いた。

多層パーセプトロン

f:id:aidiary:20150617204542p:plain

上の図は入力層、隠れ層、出力層3層パーセプトロンの模式図。入力層に入力されたベクトルxは、入力層と隠れ層間の重み行列 W^{(1)} とバイアスベクトル b^{(1)} によって

 h(x) = {\rm sigmoid}(b^{(1)} + W^{(1)})

で変換され、隠れ層の出力となる。活性化関数として使われるロジスティック・シグモイド関数 sigmoid によって入力ベクトルは非線形変換される。つまりもとの空間で線形分離不可能でもこの非線形変換によって隠れ層の出力は線形分離可能になる。sigmoidの代わりにtanhを使ってもよい。

隠れ層の出力 h(x) は、今度は出力層の入力になり、隠れ層と出力層間の重み行列 W^{(2)} とバイアスベクトル b^{(2)} によって

 f(x) = {\rm softmax}(b^{(2)} + W^{(2)} h(x))

で変換され、出力層の出力となる。パラメータは  W^{(1)}, b^{(1)}, W^{(2)}, b^{(2)} の4つ。

上の図で書いたように出力層での計算はロジスティック回帰の式(2015/5/26)

 \displaystyle P(Y=i|x, W, b) = {\rm softmax}_{i} (W x + b) = \frac{e^{W_i x + b_i}}{\sum_{j} e^{W_j x + b_j}}

と同じになる点がポイント。多層パーセプトロンは入力データをそのままロジスティック回帰に入れるのではなく、入力層と隠れ層間で非線形変換してからロジスティック回帰に入れるいう見方ができる。この非線形変換によってロジスティック回帰より表現力が高くなるってのがポイントのようだ。

Deep Learningのチュートリアルがロジスティック回帰から始まってるのが不思議だったんだけどこういう理由があったのかと納得した。

隠れ層の実装

出力層の部分は前回のロジスティック回帰の実装がそのまま使えるため隠れ層だけ実装すればよい。

class HiddenLayer(object):
    def __init__(self, rng, input, n_in, n_out, W=None, b=None, activation=T.tanh):
        """隠れ層の初期化
        rng: 乱数生成器(重みの初期化で使用)
        input: ミニバッチ単位のデータ行列(n_samples, n_in)
        n_in: 入力データの次元数
        n_out: 隠れ層のユニット数
        W: 隠れ層の重み
        b: 隠れ層のバイアス
        activation: 活性化関数
        """
        self.input = input

        # 隠れ層の重み(共有変数)を初期化([Xavier10]による)
        if W is None:
            W_values = np.asarray(
                rng.uniform(low=-np.sqrt(6.0 / (n_in + n_out)),
                            high=np.sqrt(6.0 / (n_in + n_out)),
                            size=(n_in, n_out)),
                dtype=theano.config.floatX)
            if activation == theano.tensor.nnet.sigmoid:
                W_values *= 4
            W = theano.shared(value=W_values, name='W', borrow=True)

        # 隠れ層のバイアス(共有変数)を初期化
        if b is None:
            b_values = np.zeros((n_out, ), dtype=theano.config.floatX)
            b = theano.shared(value=b_values, name='b', borrow=True)

        self.W = W
        self.b = b

        # 隠れ層の出力を計算するシンボルを作成
        lin_output = T.dot(input, self.W) + self.b
        if activation is None:  # 線形素子の場合
            self.output = lin_output
        else:  # 非線形な活性化関数を通す場合
            self.output = activation(lin_output)

        # 隠れ層のパラメータ
        self.params = [self.W, self.b]

重み行列とバイアスベクトルは学習時の更新対象なので共有変数に格納している。バイアスベクトルは0で初期化すればよいが、重み行列の初期値の与え方が少し巧妙。活性化関数にtanhを使うときは

 \displaystyle \biggl[  -\sqrt{\frac{6}{in + out}}, \sqrt{\frac{6}{in + out}}  \biggr]

の間の一様乱数とし、活性化関数にsigmoidを使うときは

 \displaystyle \biggl[  -4 \sqrt{\frac{6}{in + out}}, 4 \sqrt{\frac{6}{in + out}}  \biggr]

の間の一様乱数とすると訓練初期で学習が効率的に進むことが保証されるとのこと。ここでinは入力層のユニット数、outは隠れ層のユニット数を意味する。証明はよくわからない・・・

多層パーセプトロンの実装

多層パーセプトロンは隠れ層とロジスティック回帰を組み合わせて実装できる。隠れ層の出力がロジスティック回帰への入力となるのがポイント。

class MLP(object):
    def __init__(self, rng, input, n_in, n_hidden, n_out):
        # 多層パーセプトロンは隠れ層とロジスティック回帰で表される出力層から成る
        # 隠れ層の出力がロジスティック回帰の入力になる点に注意
        self.hiddenLayer = HiddenLayer(rng=rng, input=input, n_in=n_in, n_out=n_hidden, activation=T.tanh)
        self.logRegressionLayer = LogisticRegression(input=self.hiddenLayer.output, n_in=n_hidden, n_out=n_out)

        # L1/L2正則化の正則化項を計算するシンボル
        self.L1 = abs(self.hiddenLayer.W).sum() + abs(self.logRegressionLayer.W).sum()
        self.L2_sqr = (self.hiddenLayer.W ** 2).sum() + (self.logRegressionLayer.W ** 2).sum()

        # MLPの誤差関数を計算するシンボル
        # 出力層にのみ依存するのでロジスティック回帰の実装と同じでよい
        self.negative_log_likelihood = self.logRegressionLayer.negative_log_likelihood

        # 誤差を計算するシンボル
        self.errors = self.logRegressionLayer.errors

        # 多層パーセプトロンのパラメータ
        self.params = self.hiddenLayer.params + self.logRegressionLayer.params

誤差関数は出力層のみに依存するのでロジスティック回帰の負の対数尤度をそのまま使う。ただし、次で見るように過学習を防止するためにL1正則化とL2正則化項を追加している。正則化項のシンボルもTheanoを使うと非常に簡単に書ける。

確率的勾配降下法によるモデル訓練

最後にこれまで定義したクラスを使って実際にモデルを訓練する関数を定義する。データのロードなどは前回とあまり変わらないので省略している。

def test_mlp(learning_rate=0.01, L1_reg=0.00, L2_reg=0.0001, n_epochs=1000, dataset='mnist.pkl.gz', batch_size=20, n_hidden=500):

    # データのロードなどは省略

    # MLPを構築
    classifier = MLP(rng=rng, input=x, n_in=28 * 28, n_hidden=n_hidden, n_out=10)

    # コスト関数を計算するシンボル
    cost = classifier.negative_log_likelihood(y) + L1_reg * classifier.L1 + L2_reg * classifier.L2_sqr

    # index番目のテスト用ミニバッチを入力してエラー率を返す関数を定義
    test_model = theano.function(
        inputs=[index],
        outputs=classifier.errors(y),
        givens={
            x: test_set_x[index * batch_size: (index + 1) * batch_size],
            y: test_set_y[index * batch_size: (index + 1) * batch_size]
        })

    # index番目のバリデーション用ミニバッチを入力してエラー率を返す関数を定義
    validate_model = theano.function(
        inputs=[index],
        outputs=classifier.errors(y),
        givens={
            x: valid_set_x[index * batch_size: (index + 1) * batch_size],
            y: valid_set_y[index * batch_size: (index + 1) * batch_size]
        })

    # コスト関数の各パラメータでの微分を計算
    gparams = [T.grad(cost, param) for param in classifier.params]

    # パラメータ更新式
    updates = [(param, param - learning_rate * gparam) for param, gparam in zip(classifier.params, gparams)]

    # index番目の訓練バッチを入力し、パラメータを更新する関数を定義
    # 戻り値としてコストが返される
    train_model = theano.function(
        inputs=[index],
        outputs=cost,
        updates=updates,
        givens={
            x: train_set_x[index * batch_size: (index + 1) * batch_size],
            y: train_set_y[index * batch_size: (index + 1) * batch_size]
        })

入力層のユニット数は28x28=784、中間層のユニット数は500、出力層のユニット数は10。コスト関数は負の対数尤度に加えてL1とL2の正則化項がついている。

多層パーセプトロンではコスト関数の偏微分を効率よく計算するためにバックプロパゲーションが使われる(2014/1/22)がTheanoは自動微分を使えるので面倒な実装を考えなくてすむ。T.grad()でコスト関数のパラメータごとの偏微分を求め、updatesでパラメータ更新式を定義するだけ。パラメータは  W^{(1)}, b^{(1)}, W^{(2)}, b^{(2)} と4つあるのでforループで回しているが非常に直観的な書き方だ。これはすごい!

Early-Stoppingによる収束判定はロジスティック回帰 (2015/5/26)とほとんど同じなので省略。

実行時間の計測

最後にCPUとGPUで実行時間を比較してみよう。まず、CPUを使った場合。

Optimization complete. Best validation score of 1.690000 % obtained at iteration 2070000, with test performance 1.650000 %
The code for file mlp.py ran for 211.46m

収束まで207万回のパラメータ更新をして3時間半もかかった・・・エラー率は1.65%なので正解率は98.35%となる。ロジスティック回帰の正解率は92.51%なのでかなりよい結果が得られた。エラー率の推移をグラフで表示してみよう。

f:id:aidiary:20150618202220p:plain:w450

急激に落ちた後は少しずつエラー率が下がっていった。Early-Stoppingで収束判定するために使うpatienceの初期値は10000だけど最終的に473万近くまで上がる。途中でなかなかエラー率が減らず、スクリプトを終了したくなるけどEarly-Stoppingを信じてじっと耐えると徐々にではあるがエラー率が着実に減ることがわかった。非常に巧妙な収束判定のようだ。

最後にGPUを使って同じ学習をしてみる。

Using gpu device 0: GeForce GTX 760 Ti OEM
Optimization complete. Best validation score of 1.690000 % obtained at iteration 2367500, with test performance 1.650000 %
The code for file mlp.py ran for 66.09m

60分で終わった。速い。

参考