人工知能に関する断創録

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

多層パーセプトロンで手書き数字認識

多層パーセプトロンが収束する様子(2014/1/23)の続き。数字認識は前にニューラルネットによるパターン認識(2005/5/5)をJavaで作りましたが今回はPythonです。

今回は、多層パーセプトロンを用いて手書き数字を認識するタスクを実験します。今回からscikit-learnというPythonの機械学習ライブラリを活用しています。ただ、scikit-learnには多層パーセプトロンの正式な実装はない*1ため多層パーセプトロンのスクリプトはオリジナルです。今回から比較的大きなデータを扱うためなるべく高速に動作し、かつPRMLと変数名を合わせることで理解しやすいようにしました。

digitsデータ

手書き数字データは、MNISTというデータが有名です。PRMLの付録Aでも紹介されています。今回はいきなりMNISTではなく、scikit-learnのdigitsというより単純なデータセットを使ってみます。scikit-learnのdigitsは、MNISTの簡易版データです。MNISTが28x28ピクセル、70000サンプルの画像データなのに対し、digitsは8x8ピクセル、1797サンプルの画像データです。

digitsデータをロードし、画像として描画するスクリプトです。matplotlibのimshow()を使うと画像として描画できます。digits.imagesに8x8のピクセルデータ、digits.targetにはクラスが入っています。クラスは数字なので0-9の10クラスあります。

#coding: utf-8
import numpy as np
import pylab
from sklearn.datasets import load_digits

# scikit-learnの手書き数字データをロード
# 1797サンプル、8x8ピクセル
digits = load_digits()

# 最初の10サンプルを描画
# digits.images[i] : i番目の画像データ(8x8ピクセル)
# digits.target[i] : i番目の画像データのクラス(数字なので0-9)
for index, (image, label) in enumerate(zip(digits.images, digits.target)[:10]):
    pylab.subplot(2, 5, index + 1)
    pylab.axis('off')
    pylab.imshow(image, cmap=pylab.cm.gray_r, interpolation='nearest')
    pylab.title('%i' % label)
pylab.show()

f:id:aidiary:20140201091015p:plain

多層パーセプトロンの高速化

多層パーセプトロンによる関数近似(2014/1/22)はPRMLとの対応をわかりやすくするためforループを使うなど実装がナイーブすぎたので、今回は行列演算に置き換えて高速化しました。学習はバッチではなく、逐次学習方式にしています。あと再利用しやすいようにクラス化しました。

https://github.com/sylvan5/PRML/blob/master/ch5/mlp.py

#coding: utf-8
import numpy as np
import sys

"""
mlp.py
多層パーセプトロン
forループの代わりに行列演算にした高速化版

入力層 - 隠れ層 - 出力層の3層構造で固定(PRMLではこれを2層と呼んでいる)

隠れ層の活性化関数にはtanh関数またはsigmoid logistic関数が使える
出力層の活性化関数にはtanh関数、sigmoid logistic関数、恒等関数が使える
"""

def tanh(x):
    return np.tanh(x)

# 本来はtanh'(x) = 1 - tanh(x) ** 2であるが
# このスクリプトでは引数xがtanh(x)であることを仮定している
def tanh_deriv(x):
    return 1.0 - x ** 2

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

# 本来はsigmoid'(x) = sigmoid(x) * (1 - sigmoid(x))であるが
# このスクリプトでは引数xがsigmoid(x)であることを仮定している
def sigmoid_deriv(x):
    return x * (1 - x)

def identity(x):
    return x

def identity_deriv(x):
    return 1

class MultiLayerPerceptron:
    def __init__(self, numInput, numHidden, numOutput, act1="tanh", act2="sigmoid"):
        """多層パーセプトロンを初期化
        numInput    入力層のユニット数(バイアスユニットは除く)
        numHidden   隠れ層のユニット数(バイアスユニットは除く)
        numOutput   出力層のユニット数
        act1        隠れ層の活性化関数(tanh or sigmoid)
        act2        出力層の活性化関数(tanh or sigmoid or identity)
        """
        # 引数の指定に合わせて隠れ層の活性化関数とその微分関数を設定
        if act1 == "tanh":
            self.act1 = tanh
            self.act1_deriv = tanh_deriv
        elif act1 == "sigmoid":
            self.act1 = sigmoid
            self.act1_deriv = sigmoid_deriv
        else:
            print "ERROR: act1 is tanh or sigmoid"
            sys.exit()

        # 引数の指定に合わせて出力層の活性化関数とその微分関数を設定
        if act2 == "tanh":
            self.act2 = tanh
            self.act2_deriv = tanh_deriv
        elif act2 == "sigmoid":
            self.act2 = sigmoid
            self.act2_deriv = sigmoid_deriv
        elif act2 == "identity":
            self.act2 = identity
            self.act2_deriv = identity_deriv
        else:
            print "ERROR: act2 is tanh or sigmoid or identity"
            sys.exit()

        # バイアスユニットがあるので入力層と隠れ層は+1
        self.numInput = numInput + 1
        self.numHidden =numHidden + 1
        self.numOutput = numOutput

        # 重みを (-1.0, 1.0)の一様乱数で初期化
        self.weight1 = np.random.uniform(-1.0, 1.0, (self.numHidden, self.numInput))  # 入力層-隠れ層間
        self.weight2 = np.random.uniform(-1.0, 1.0, (self.numOutput, self.numHidden)) # 隠れ層-出力層間

    def fit(self, X, t, learning_rate=0.2, epochs=10000):
        """訓練データを用いてネットワークの重みを更新する"""
        # 入力データの最初の列にバイアスユニットの入力1を追加
        X = np.hstack([np.ones([X.shape[0], 1]), X])
        t = np.array(t)

        # 逐次学習
        # 訓練データからランダムサンプリングして重みを更新をepochs回繰り返す
        for k in range(epochs):
            print k

            # 訓練データからランダムに選択する
            i = np.random.randint(X.shape[0])
            x = X[i]

            # 入力を順伝播させて中間層の出力を計算
            z = self.act1(np.dot(self.weight1, x))

            # 中間層の出力を順伝播させて出力層の出力を計算
            y = self.act2(np.dot(self.weight2, z))

            # 出力層の誤差を計算
            # WARNING
            # PRMLによると出力層の活性化関数にどれを用いても
            # (y - t[i]) でよいと書いてあるが
            # 下のように出力層の活性化関数の微分もかけた方が精度がずっとよくなる
            delta2 = self.act2_deriv(y) * (y - t[i])

            # 出力層の誤差を逆伝播させて隠れ層の誤差を計算
            delta1 = self.act1_deriv(z) * np.dot(self.weight2.T, delta2)

            # 隠れ層の誤差を用いて隠れ層の重みを更新
            # 行列演算になるので2次元ベクトルに変換する必要がある
            x = np.atleast_2d(x)
            delta1 = np.atleast_2d(delta1)
            self.weight1 -= learning_rate * np.dot(delta1.T, x)

            # 出力層の誤差を用いて出力層の重みを更新
            z = np.atleast_2d(z)
            delta2 = np.atleast_2d(delta2)
            self.weight2 -= learning_rate * np.dot(delta2.T, z)

    def predict(self, x):
        """テストデータの出力を予測"""
        x = np.array(x)
        # バイアスの1を追加
        x = np.insert(x, 0, 1)
        # 順伝播によりネットワークの出力を計算
        z = self.act1(np.dot(self.weight1, x))
        y = self.act2(np.dot(self.weight2, z))
        return y

if __name__ == "__main__":
    """XORの学習テスト"""
    mlp = MultiLayerPerceptron(2, 2, 1, "tanh", "sigmoid")
    X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
    y = np.array([0, 1, 1, 0])
    mlp.fit(X, y)
    for i in [[0, 0], [0, 1], [1, 0], [1, 1]]:
        print i, mlp.predict(i)

サンプルとしてXORの学習を入れています。パーセプトロン(2010/4/29)では線形分離不可能なので分類できませんが、多層パーセプトロンならちゃんと分類できます。

[0, 0] [ 0.00673669]
[0, 1] [ 0.98720594]
[1, 0] [ 0.99511793]
[1, 1] [ 0.0103681]

訓練データと同じく、[0, 0], [1, 1] のときはほぼ0(False)、[0, 1], [1, 0] のときはほぼ1(True)になっていることがわかります。

【注意】

上の多層パーセプトロンの実装は、出力層ユニットの誤差を計算するところがPRMLと微妙に違います。

            # 出力層の誤差を計算
            # WARNING
            # PRMLによると出力層の活性化関数にどれを用いても
            # (y - t[i]) でよいと書いてあるが
            # 下のように出力層の活性化関数の微分もかけた方が精度がずっとよくなる
            delta2 = self.act2_deriv(y) * (y - t[i])

PRMLのp.235と演習5.6、演習5.7によると出力ユニットの活性化関数に恒等写像、ロジスティックシグモイド関数、ソフトマックス関数のいずれを用いても出力ユニットの誤差の評価には  \delta_k = y_k - t_k (出力と教師信号の差)を使えばよいと書いてあります。これでも学習はできたのですが認識精度が低かったです。隠れ層の誤差を計算するときと同様に出力ユニット活性化関数の微分をさらにかけると認識精度が大幅に上がりました。Neural Networks in Pythonの実装を参考にしたのですが、理由がよくわからない・・・ 何か勘違いしている?詳しい方がいたらぜひコメントください。

(追記)

コメントでいろいろ教えていただきました。二乗和誤差関数を使うか交差エントロピー誤差関数を使うかによって出力ユニットの誤差の計算が違うことが判明。

PRMLのように出力ユニット活性化関数にロジスティックシグモイド関数やソフトマックス関数を使い、出力ユニットの誤差に y - t を使うと自動的に交差エントロピー誤差関数を使っていることになる(演習の5.6と5.7)。

二乗誤差関数を使う場合は、出力ユニットの誤差に出力ユニット活性化関数の微分が入ってくる(ただし、恒等関数のときは微分すると1なので結果的に y - t のみになる)。

交差エントロピー誤差関数を使って出力ユニットの誤差に y - t を使う場合は、デフォルトの学習率0.2では精度がかなり低くなるが、0.01くらいまで下げると高精度になることを確認!

手書き数字データの認識

入力層64ユニット(8x8ピクセル)、隠れ層100ユニット、出力層10ユニット(数字の0から9)というニューラルネットを構成しました。隠れ層の活性化関数はtanh、出力層の活性化関数はsigmoidです*2。入力は画像データの各ピクセルの値を0〜1に正規化しています。また、教師信号はコメントで書きましたが、1-of-K表記に変換しています。ここら辺の関数はscikit-learnにあるので利用しました。学習はdigitsから10000個のサンプルをランダムに選択して、重みを更新する逐次学習方式です。

数字認識は10個の数字を認識する多クラス分類なので、出力ユニットにロジスティックシグモイド関数を用いて、10個の異なる2クラス分類問題を解くようにしました(PRMLのp.235)。最終的に10個の出力の中から最大の出力を持つクラスに分類します。

https://github.com/sylvan5/PRML/blob/master/ch5/digits.py

#coding:utf-8
import numpy as np
from mlp import MultiLayerPerceptron
from sklearn.datasets import load_digits
from sklearn.cross_validation import train_test_split
from sklearn.preprocessing import LabelBinarizer
from sklearn.metrics import confusion_matrix, classification_report

"""
簡易手書き数字データの認識
scikit-learnのインストールが必要
http://scikit-learn.org/
"""

if __name__ == "__main__":
    # scikit-learnの簡易数字データをロード
    # 1797サンプル, 8x8ピクセル
    digits = load_digits()

    # 訓練データを作成
    X = digits.data
    y = digits.target
    # ピクセルの値を0.0-1.0に正規化
    X /= X.max()

    # 多層パーセプトロン
    mlp = MultiLayerPerceptron(64, 100, 10, act1="tanh", act2="sigmoid")

    # 訓練データ(90%)とテストデータ(10%)に分解
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.1)

    # 教師信号の数字を1-of-K表記に変換
    # 0 => [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    # 1 => [0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
    # ...
    # 9 => [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
    labels_train = LabelBinarizer().fit_transform(y_train)
    labels_test = LabelBinarizer().fit_transform(y_test)

    # 訓練データを用いてニューラルネットの重みを学習
    mlp.fit(X_train, labels_train, epochs=10000)

    # テストデータを用いて予測精度を計算
    predictions = []
    for i in range(X_test.shape[0]):
        o = mlp.predict(X_test[i])
        # 最大の出力を持つクラスに分類
        predictions.append(np.argmax(o))
    print confusion_matrix(y_test, predictions)
    print classification_report(y_test, predictions)

    # 誤認識したデータのみ描画
    # 誤認識データ数と誤っているテストデータのidxを収集
    cnt = 0
    error_idx = []
    for idx in range(len(y_test)):
        if y_test[idx] != predictions[idx]:
            print "error: %d : %d => %d" % (idx, y_test[idx], predictions[idx])
            error_idx.append(idx)
            cnt += 1

    # 描画
    import pylab
    for i, idx in enumerate(error_idx):
        pylab.subplot(cnt/5 + 1, 5, i + 1)
        pylab.axis('off')
        pylab.imshow(X_test[idx].reshape((8, 8)), cmap=pylab.cm.gray_r)
        pylab.title('%d : %i => %i' % (idx, y_test[idx], predictions[idx]))
    pylab.show()

結果は、クラス分類の結果をまとめた混同行列(confusion matrix)と分類精度のレポートを出しています。これもscikit-learnの機能を使うと簡単にできます。

[[16  0  0  0  0  0  0  0  0  0]
 [ 0 14  0  0  0  0  0  0  0  0]
 [ 0  1 19  0  0  0  0  1  0  0]
 [ 0  0  0 17  0  0  0  0  1  0]
 [ 0  0  0  0 14  0  0  0  0  0]
 [ 0  0  0  0  0 17  1  0  0  0]
 [ 1  0  0  0  0  0 16  0  0  0]
 [ 0  0  0  0  0  0  0 23  0  1]
 [ 0  1  0  0  0  0  0  0 19  0]
 [ 0  0  0  0  0  0  0  0  1 17]]

各行と列はクラスを表していて、テストデータのクラスとそれがどこのクラスに分類されたかを表しています。斜めにデータがくれば正解です。この結果を見ると

  • クラス2(数字の2)がクラス1(数字の1)に誤認識されたテストデータが1つある
  • クラス6(数字の6)がクラス0(数字の0)に誤認識されたテストデータが1つある
  • クラス8(数字の8)がクラス1(数字の1)に誤認識されたテストデータが1つある

などがわかります。誤分類した画像を出力してみると

f:id:aidiary:20140201103504p:plain

となります。画像の上の文字列は、

テストデータのインデックス : 正解のクラス => 予測したクラス

です。まあ誤分類しても仕方ないのも結構ありますね・・・人間でも間違いそうだし。最後に精度の出力を出してみます。

             precision    recall  f1-score   support

          0       0.94      1.00      0.97        16
          1       0.88      1.00      0.93        14
          2       1.00      0.90      0.95        21
          3       1.00      0.94      0.97        18
          4       1.00      1.00      1.00        14
          5       1.00      0.94      0.97        18
          6       0.94      0.94      0.94        17
          7       0.96      0.96      0.96        24
          8       0.90      0.95      0.93        20
          9       0.94      0.94      0.94        18

avg / total       0.96      0.96      0.96       180

クラスごとに精度を求めていますが、認識精度(precision)は96%でした。precisionとrecallの意味は慣れるまでややこしいのですが、朱鷺の杜の表がわかりやすいです。

コードはgithubでも公開を始めました。MITライセンスなので自由にお使いください。
https://github.com/sylvan5/PRML

次回は、MNISTの大規模データを用いて数字認識をしてみます。

参考文献

*1:参考文献に書きましたが入れようという動きはあるみたいで実装も公開されています

*2:最初、出力層の活性化関数にidentityやtanhを使っていてまったく認識できずはまりました・・・