Python
NeuralNetwork

自分でニューラルネットワークを作ろう

はじめに

ニューラルネットワークの仕組みを理解するためには、やっぱり自分の手で実装することが最も良いと思っています。今回は3層(入力層、隠れ層、出力層)のニューラルネットワークを自作することを目的に記事を書きます。

事前知識

パーセプトロン

聞き飽きている方も多いでしょうが、「パーセプトロン(perceptron)」とは人間の脳にあるニューロンを模倣しています。

ニューロンは下図のような構造をしています。ニューロンは複数のニューロンから樹状突起で電気信号を入力として受け取り、ある一定以上の刺激を受けると、軸索の末端部分から電気刺激を出し、次のニューロンに刺激を与えます。

image.png

ニューロンに対して、パーセプトロンは下図のような構造をしています。ニューロンと同じように入力に重み(weight)を付けて複数から受け取り、その重み付け総和を活性化関数(activation function)を通すことで出力値を決定します。

perceptron.png

数式的表現

本記事では、行列は大文字、ベクトルは小文字で表現します。

入力を$n$次元ベクトル$o^{(i)}$。重み係数を$m$行$n$列の行列$W$、活性化関数を$\phi$、出力を$m$次元ベクトル$o^{(o)}$とすると、先程のパーセプトロンは以下のように表せます。
$$o^{(o)}=\phi(Wo^{(i)})$$

活性化関数

脳のニューロンの話に少し戻しますと、入力が一定以上の場合に電気刺激を出す部分が活性化関数になります。このように一定以上の入力があるときに定数を出力する関数はステップ関数と呼ばれ、以下のように書くことができます。

ステップ関数

\phi(x) = 
\begin{cases}
1 & (x < 0) \\
0 & (x \geq 0)
\end{cases}

しかし、今回実際に用いるのはsigmoid関数です。その理由は誤差逆伝搬法(後述)の時に、活性化関数の微分値を用いるからです。sigmoid関数は以下のような式です。

sigmoid関数

\phi(x) = \frac{1}{1 + e^{-x}}

またsigmoid関数の微分は以下のようになります。

\phi ' (x) = x (1 - x)

このように、sigmoid関数の微分は非常に簡単な形になり、微分値を計算コストが低いことはニューラルネットワークでは非常に重要です。

ニューラルネットワーク

ニューラルネットワーク(neural network)は下図のようにパーセプトロンを複数個並べたものを層(layer)と呼び、その層を複数並べることで構成されます。

neuralnetwork.png

注目した層に対して、一つ左の層から入力を受け取り、一つ右の層へ出力を行います。層間の繋がりがネットワークのようになっているので、ニューラルネットワークと呼ばれます。ニューラルネットワークはこの層間の繋がりの部分で重みが付けられて、次の層へ入力されます。この重み係数を更新していくことを学習と呼びます。この重み係数は、「より正しい出力をするためにどの入力を選択的に重視するか」を意味しています。

数式的表現

数式的に表現するために、下図のように添え字を付けます。今回は3層のニューラルネットワークを仮定します。添え字として、入力層(input layer)は$i$、隠れ層(hidden layer)は$h$、出力層(output layer)は$o$とし、数式では上付き括弧で記述し、ニューロンのインデックスを下付きで記述します。また各層のニューロンの数については、入力層が$n_i$個、隠れ層が$n_h$、出力層が$n_o$とします。

neuralnetwork1.png

またニューロンが縦線で二つに分かれており、左に$x$、右に$o$があります。$x$は入力の重み付け和の結果、$o$は活性化関数に$x$を入力した時の出力です。
ここでは隠れ層に着目すると、以下のような関係があります。

重み付け和
$$x_{j}^{(h)} = \sum_{i} W_{ij}^{(i)} o_{i}^{(i)}$$

活性化関数
$$o_{j}^{(h)} = \phi(x_{j}^{(h)})$$

neuralnetwork2.png

重み係数$W^{(i)}$については、添え字$i$は入力層のノードのインデックス、添え字$j$は隠れ層のノードのインデックスを意味します。したがって、重み係数は以下のようになります。

W = 
\begin{pmatrix}
W_{11} & W_{21} & \cdots & W_{n_{i}1} \\ 
W_{12} & W_{22} & \cdots & W_{n_{i}2} \\
\vdots & \vdots & \ddots & \vdots \\
W_{1n_{h}} & W_{2n_{h}} & \cdots & W_{n_{i}n_{h}} \\
\end{pmatrix}

※普通の行列と添え字が逆になっているのに気を付けてください。

このように層ごとに伝搬していくことを順伝搬と言います。

誤差逆伝搬法

まず目的関数は損失関数(loss function)であり、これを最小化する最適化問題を解きます。誤差逆伝搬法(backpropagation)は勾配降下法(gradient descent)を用いています。勾配降下法は勾配(偏微分)と逆向きの方向に(誤差が小さくなる方向に)更新をすることを反復的に繰り返すことで、極小解を探す方法です。
※最小解ではないことに注意してください。

損失関数

損失関数として最も単純なものは二乗誤差を用いるものです。理想の出力であるベクトル(教師ベクトル)を$t$とすると、出力層における誤差は以下のように書けます。

E=\frac{1}{2}\sum_{i=1}^{n_o}(t_i - o_{i}^{(o)})^2

このように二次形式の誤差を利用することの利点は、微分を行うことで一次式になることと、更新幅は勾配の大きさに合わせて変わることです。たとえば絶対誤差を用いた場合は勾配の大きさは常に一定になるので、更新幅も一定となり、極小解を行ったり来たりしてしまい、上手く収束しない可能性があります。

隠れ層から出力層への重みによる偏微分

勾配計算(偏微分)を行います。損失関数$E$を隠れ層から出力層への重み$W^{(h)}$で偏微分すると、連鎖律より以下のようになります。

\frac{\partial E}{\partial W^{(h)}_{jk}}=\frac{\partial E}{\partial o_{k}^{(o)}} \frac{\partial o_{k}^{(o)}}{\partial x_{k}^{(o)}} \frac{\partial x_{k}^{(o)}}{\partial W_{jk}^{(h)}} \\
=\frac{\partial E}{\partial o_{k}^{(o)}} \phi ' (x_{k}^{(o)}) o_{j}^{(h)}

ここで、以下のようにベクトル$\delta^{(o)}$を定義する。

\delta_{k}^{(o)}=\frac{\partial E}{\partial o_{k}^{(o)}} \phi ' (x_{k}^{(o)})

したがって、以下の式が導出される。

\frac{\partial E}{\partial W^{(h)}_{jk}}=\delta_{k}^{(o)} o_{j}^{(h)}

入力層から隠れ層への重みによる偏微分

先程と同様に計算します。

\frac{\partial E}{\partial W^{(i)}_{ij}} = \sum_k (\frac{\partial E}{\partial o_{k}^{(o)}} \frac{\partial o_{k}^{(o)}}{\partial x_{k}^{(o)}} \frac{\partial x_{k}^{(o)}}{\partial o_{j}^{(h)}}) \frac{\partial o_{j}^{(h)}}{\partial x_{j}^{(h)}} \frac{\partial x_{j}^{(h)}}{\partial W_{ij}^{(i)}} \\
= \sum_k (\frac{\partial E}{\partial o_{k}^{(o)}} 
\phi ' (x_{k}^{(h)}) W_{jk}^{(h)}) \phi ' (x_{j}^{(h)}) o_{i}^{(i)}

ここで、以下のようにベクトル$\delta^{(h)}$を定義する。

\delta_{j}^{(h)}=\phi ' (x_{j}^{(h)}) \sum_k (\delta_{k}^{(o)} W_{jk}^{(h)})

したがって、以下の式が導出される。

\frac{\partial E}{\partial W^{(i)}_{ij}}=\delta_{j}^{(h)} o_{i}^{(i)}

この式は、隠れ層から出力層への重みの勾配の式に、非常に類似しています。添え字以外で異なるのは、$\delta$の部分です。$\delta$が異なる理由は、出力層に対しては教師ベクトルを与えることができる一方で、それ以外の層に対して教師ベクトルはないからです。ここで先程の$\delta^(h)$と$\delta^(o)$の関係式を図示すると、下図のようになります。

backprop.png

上図の示しているのは以下の通りです。出力層以外の層の$\delta$は、一つ後ろの層の$\delta$に対して、順伝搬時に利用した重み係数で分配・統合することにより、計算します。このように誤差に関するベクトル$\delta$が後ろから伝搬していくので、誤差逆伝搬法と呼ばれます。

行列の形で書き直しておくと、以下のようになります。

\delta^{(h)}={W^{(h)}}^{T}\delta^{(o)} \\
=
\begin{pmatrix}
W_{11}^{(h)} & W_{12}^{(h)} & \cdots & W_{1n_o} \\
W_{21}^{(h)} & W_{22}^{(h)} & \cdots & W_{2n_o} \\
\vdots & \vdots & \ddots & \vdots \\
W_{n_{h}1}^{(h)} & W_{n_{h}2}^{(h)} & \cdots & W_{n_{h}n_{o}}^{(h)}
\end{pmatrix}
\begin{pmatrix}
\delta_1^{(o)} \\
\delta_2^{(o)} \\
\vdots \\
\delta_{n_o}^{(o)}
\end{pmatrix}

このように重みの部分は順伝搬の時の転置行列になります。

更新式

上記のように求まった偏微分値を用いて、以下の式で重み係数を更新していきます。

W \leftarrow W + \eta \frac{\partial E}{\partial W}

$\eta$は学習効率(learning rate)と呼ばれるもので、予め定数で指定しておきます。このように人間が手で決めなければいけないパラメータはハイパーパラメータと呼ばれています。

これですべての準備整いましたので、次に実装に移りましょう。

実装

今回は言語としてPythonを用いました。理由は、PythonのNumpyというライブラリが非常に簡単に行列計算を記述できるためです。

下準備

ニューラルネットワークにおけるHello world的存在であるMNISTの手書き文字認識を今回は行います。
学習データは「こちら」、テストデータは「こちら」からダウンロードしてください。

このデータセットは28pixel × 28pixelの手書きの数字の文字のデータセットです。したがって、一つの文字につき784個のピクセルのグレースケール値が入っています。csvファイルの中身は、文字は行ごとに格納されおり、最初の数字は正解のラベルです。値は0~255の範囲になっていますが、コードでは0.01~1.0の範囲に変換しています。学習データは60000サンプル、テストデータは10000サンプルあります。

活性化関数

今回はsigmoid関数のみを使用するので、以下のようになります。

import numpy as np

sigmoid_range = 34.538776394910684

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -sigmoid_range, sigmoid_range)))

def derivative_sigmoid(o):
    return o * (1.0 - o)

実装上の注意点としては、np.expメソッドは入力の絶対値が大きすぎる時にオーバーフローを起こします。そこで、最大値と最小値をnp.clipメソッドで設定しています。sigmoid_rangeは「計算機イプシロンのこと」の記事を参考にして、決定しました。

ニューラルネットワーククラス

今回は三層のニューラルネットワークなので、ThreeLayerNetworkというクラスを作成しました。

コンストラクタ

コンストラクタは入力層、隠れ層、出力層のノード数と学習効率を引数に取ります。

入力層のノード数は784個、0~9までの10個の数字の識別のため、出力層は10個、中間層は適当に100個にしました。中間層のノード数は好きに変更できます。学習効率についても自由に設定できます。重み係数は正規乱数で初期化しています。

またコンストラクタ内で、先程実装した活性化関数をセットします。他の活性化関数を実装した時は、ここにセットしてください。

    # コンストラクタ
    def __init__(self, inodes, hnodes, onodes, lr):
        # 各レイヤーのノード数
        self.inodes = inodes
        self.hnodes = hnodes
        self.onodes = onodes

        # 学習率
        self.lr = lr

        # 重みの初期化
        self.w_ih = np.random.normal(0.0, 1.0, (self.hnodes, self.inodes))
        self.w_ho = np.random.normal(0.0, 1.0, (self.onodes, self.hnodes))

        # 活性化関数
        self.af = AF.sigmoid
        self.daf = AF.derivative_sigmoid

順伝搬

順伝搬はfeedforwardメソッドとして実装しました。このメソッドは入力層に入れる784次元ベクトルを引数に取ります。

まず初めに入力のリストを縦ベクトルに変換します。
あとは事前知識の部分でやったままに実装していきます。

    # 順伝搬
    def feedforward(self, idata):
        # 入力のリストを縦ベクトルに変換
        o_i = np.array(idata, ndmin=2).T

        # 隠れ層
        x_h = np.dot(self.w_ih, o_i)
        o_h = self.af(x_h)

        # 出力層
        x_o = np.dot(self.w_ho, o_h)
        o_o = self.af(x_o)

        return o_o

誤差逆伝搬

誤差逆伝搬はbackpropメソッドとして実装しました。このメソッドは、入力層の784次元ベクトルと教師データの10次元ベクトルを引数に取ります。教師データはone-hot表現で作っています。

順伝搬と同様に伝搬したあと、誤差を逆伝搬し、最後に重みを更新します。

    # 誤差逆伝搬
    def backprop(self, idata, tdata):
        # 縦ベクトルに変換
        o_i = np.array(idata, ndmin=2).T
        t = np.array(tdata, ndmin=2).T

        # 隠れ層
        x_h = np.dot(self.w_ih, o_i)
        o_h = self.af(x_h)

        # 出力層
        x_o = np.dot(self.w_ho, o_h)
        o_o = self.af(x_o)

        # 誤差計算
        e_o = (t - o_o)
        e_h = np.dot(self.w_ho.T, e_o)

        # 重みの更新
        self.w_ho += self.lr * np.dot((e_o * self.daf(o_o)), o_h.T)
        self.w_ih += self.lr * np.dot((e_h * self.daf(o_h)), o_i.T)

全コード

私の環境で試したところ、93%程度の精度が出ました。

ActivationFunction.py

import numpy as np

sigmoid_range = 34.538776394910684

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -sigmoid_range, sigmoid_range)))

def derivative_sigmoid(o):
    return o * (1.0 - o)

ThreeLayerNetwork.py

import numpy as np
import ActivationFunction as AF

# 3層ニューラルネットワーク
class ThreeLayerNetwork:
    # コンストラクタ
    def __init__(self, inodes, hnodes, onodes, lr):
        # 各レイヤーのノード数
        self.inodes = inodes
        self.hnodes = hnodes
        self.onodes = onodes

        # 学習率
        self.lr = lr

        # 重みの初期化
        self.w_ih = np.random.normal(0.0, 1.0, (self.hnodes, self.inodes))
        self.w_ho = np.random.normal(0.0, 1.0, (self.onodes, self.hnodes))

        # 活性化関数
        self.af = AF.sigmoid
        self.daf = AF.derivative_sigmoid

    # 誤差逆伝搬
    def backprop(self, idata, tdata):
        # 縦ベクトルに変換
        o_i = np.array(idata, ndmin=2).T
        t = np.array(tdata, ndmin=2).T

        # 隠れ層
        x_h = np.dot(self.w_ih, o_i)
        o_h = self.af(x_h)

        # 出力層
        x_o = np.dot(self.w_ho, o_h)
        o_o = self.af(x_o)

        # 誤差計算
        e_o = (t - o_o)
        e_h = np.dot(self.w_ho.T, e_o)

        # 重みの更新
        self.w_ho += self.lr * np.dot((e_o * self.daf(o_o)), o_h.T)
        self.w_ih += self.lr * np.dot((e_h * self.daf(o_h)), o_i.T)


    # 順伝搬
    def feedforward(self, idata):
        # 入力のリストを縦ベクトルに変換
        o_i = np.array(idata, ndmin=2).T

        # 隠れ層
        x_h = np.dot(self.w_ih, o_i)
        o_h = self.af(x_h)

        # 出力層
        x_o = np.dot(self.w_ho, o_h)
        o_o = self.af(x_o)

        return o_o

if __name__=='__main__':
    # パラメータ
    inodes = 784
    hnodes = 100
    onodes = 10
    lr = 0.3

    # ニューラルネットワークの初期化
    nn = ThreeLayerNetwork(inodes, hnodes, onodes, lr)

    # トレーニングデータのロード
    training_data_file = open('mnist_dataset/mnist_train.csv', 'r')
    training_data_list = training_data_file.readlines()
    training_data_file.close()

    # テストデータのロード
    test_data_file = open('mnist_dataset/mnist_test.csv')
    test_data_list = test_data_file.readlines()
    test_data_file.close()

    # 学習
    epoch = 10
    for e in range(epoch):
        print('#epoch ', e)
        data_size = len(training_data_list)
        for i in range(data_size):
            if i % 1000 == 0:
                print('  train: {0:>5d} / {1:>5d}'.format(i, data_size))
            val = training_data_list[i].split(',')
            idata = (np.asfarray(val[1:]) / 255.0 * 0.99) + 0.01
            tdata = np.zeros(onodes) + 0.01
            tdata[int(val[0])] = 0.99
            nn.backprop(idata, tdata)
            pass
        pass

    # テスト
    scoreboard = []
    for record in test_data_list:
        val = record.split(',')
        idata = (np.asfarray(val[1:]) / 255.0 * 0.99) + 0.01
        tlabel = int(val[0])
        predict = nn.feedforward(idata)
        plabel = np.argmax(predict)
        scoreboard.append(tlabel == plabel)
        pass

    scoreboard_array = np.asarray(scoreboard)
    print('performance: ', scoreboard_array.sum() / scoreboard_array.size)