ニューラルネットワークの超概要を理解してpythonに落とし込んでみる

##レベル
- ニューラルネットワークってなに？ってレベルの人向け（超初心者）
- 主に自分への備忘録も兼ねてます
 
##参考文献
[ニューラルネットワーク自作入門](https://book.mynavi.jp/ec/products/detail/id=65933)
![65933_ext_06_0.jpg](https://qiita-image-store.s3.amazonaws.com/0/226547/4c899b5d-60dc-22d9-2895-ee177d9ad968.jpeg)


##1.ニューラルネットワークの概要（入力〜出力まで）

- 入力層・隠れ層・出力層の3*3のニューラルネットワークを想定
- 図と実際の値があった方がわかりやすいので以下に記載

###図1.概念図
<img width="787" alt="スクリーンショット 2018-05-04 12.29.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/226547/e16b6042-9eca-4b27-7e57-194514ccd265.png">

###図2.リンクと重み
<img width="798" alt="スクリーンショット 2018-05-04 12.29.31.png" src="https://qiita-image-store.s3.amazonaws.com/0/226547/233088a2-67e6-16cf-1429-d12897c570e0.png">

###図3.計算方法（入力層への入力から出力層の出力まで）
<img width="1204" alt="スクリーンショット 2018-05-04 14.16.43.png" src="https://qiita-image-store.s3.amazonaws.com/0/226547/c32440b8-b8cf-9173-a1e9-6cbdebce9236.png">


###シグモイド関数と計算方法
そもそも、ニューラルネットワークは動物のニューロンの組織図を模倣して作っている。シグモイド関数は「入力が閾値に達成すると発火する」というニューロンの特性を関数で表現しやすい。

その他の特徴としては、

- ロジスティック関数ともいう
- 計算が他のS字関数よりはるかに簡単
- 0から1の値を「ちょうどよく」出力してくれるのに適している
- 「入力が閾値に達成すると発火する」というニューロンの特性を関数で表現しやすい

シグモイド関数は以下の数式で表します。

```math
y=\frac{1}{1+e^{-x}}
```



図3の(2)で$O_{hidden}$を計算したい時は、以下の計算を行います。

計算式は、図3の(2)通り、$O_{hidden} = sigmoid(X_{hidden})$である。 

```math
X_{hidden}=
\begin{pmatrix}
1.16\\
0.42\\
0.62
\end{pmatrix}\\
```
となっているので。

```math
O_{hidden} =sigmoid * 
\begin{pmatrix}
1.16\\
0.42\\
0.62
\end{pmatrix}\\
```
例えば、1行1列目を計算するとx=1.16のとき、$e^{-1.16}=0.3135$なので、$y=\frac{1}{1+0.3135}=0.761$となる。

したがって、

```math
O_{hidden} =
\begin{pmatrix}
0.761\\
0.603\\
0.650
\end{pmatrix}
```

となる。


##2.誤差逆伝播と重み更新

###誤差逆伝播
1.で導き出した実際の理想値targetsと実際の出力値$O_{output}$には、どうしても誤差が出てしまいます。

理想値に近づけるには重みなどのパラメータを調整する必要があります。ニューラルネットワークでは、最終的な誤差を逆向きに伝送（伝播）していって、重みを更新する、「誤差逆伝播（ごさぎゃくでんぱ）」という手法を用います。概念図を以下に記載します。
ここでは、今まで使っていた3*3の行列ではなく、話を簡潔にするために2*3の行列で説明します。

誤差を徐々に前向きに更新していっているのがわかると思います。

図4.誤差逆伝播
<img width="1176" alt="スクリーンショット 2018-05-04 14.55.29.png" src="https://qiita-image-store.s3.amazonaws.com/0/226547/59cec2f5-8a6b-e687-828b-ed9023df75b6.png">


###エラーの計算
図4の下に記載している数式のように、前のノードのエラーはこのように重みづけwを用いて更新していきます。

ただ、一つ一つ計算するのは大変です。行列の掛け算で実現できないでしょうか？

###行列の掛け算による誤差逆伝播の計算

図4のように、最終的な理想値targetsと実際の出力値$O_{output}$の差を誤差$error_{output,1}=0.8$と$error_{output,2}=0.5$ととする。

これを行列で書くとこんな感じの式になる

```math
error_{output} =
\begin{pmatrix}
e_1\\
e_2
\end{pmatrix}
```
図4の下の計算式を見てもわかる通り、$error_{hidden}$の行列は、上の$error_{output}$を使って、以下の計算で成り立つことがわかります。

```math

error_{hidden} =
\begin{pmatrix}
\frac{w_{11}}{w_{11}+w_{21}} & \frac{w_{12}}{w_{12}+w_{22}} \\
\frac{w_{21}}{w_{21}+w_{11}} & \frac{w_{22}}{w_{22}+w_{12}} 
\end{pmatrix}
*
\begin{pmatrix}
e_1\\
e_2
\end{pmatrix}
```

最も重要な部分はリンクの重み$w_{ij}$と出力誤差$e_n$との掛け算です。重みが大きいほどより多くの出力誤差が隠れ層に渡されます。ここが重要です。

思い切って行列の分母を省いてしまいます。

そうすると、以下のようなシンプルな式になります。

```math

error_{hidden} =
\begin{pmatrix}
w_{11}&w_{12}\\
w_{21}&w_{22}
\end{pmatrix}
*
\begin{pmatrix}
e_1\\
e_2
\end{pmatrix}
```

行列の左の項は、出力値をだしたときの重みの計算($O=W*I$)の時の$W$の行列

```math
\begin{pmatrix}
w_{11}&w_{21}\\
w_{12}&w_{22}
\end{pmatrix}
```
の並び順を変えたものです。これを転置行列と言います。$W^T$と書きます。

以上のことを以下の表現で表します。これで誤差逆伝播の誤差の計算は求められました。

$error_{hidden}=W^T_{hidden-output} * error_{output}$ 

なぜ分母を削除して良いかはおいおい勉強します。。

###重みの更新

逆伝播した各層の誤差がこれで計算できたので、この誤差を用いて、いよいよ重みを修正します。

最終的な重みの更新の行列式は以下です。隠れ層と出力層を例にしています。同じことを入力層と隠れ層にも適用しますので、まずは隠れ層と出力層だけ考えます。
誤差は微分をとって傾きを計算し、最小値を求めます。

```math
\Delta W_{jk} = 学習率\alpha * E_k * O_k(1-O_k) * O^T_j$
```

一つ一つ見ていきます。

$\Delta W_{jk}$は隠れ層のノードjと出力層のノードkの重みの変化率です。これが知りたい値です。

$\alpha$は学習率です。これは定数です。ここまでは簡単。

問題は「$E_k * O_k(1-O_k) * O^T_j$」この式です。
整理します。

- 知りたいのは、重み$W_{jk}$を変化させると、誤差$E$はどのように変化するかということ
- 誤差$E$が最小値をとれば、当然誤算がなくなるので理想値に近づく
- 誤差$E$を重み$W_{jk}$で微分してあげれば、傾きを求められるので、最小値が求められる。


よって、知りたいのは以下の式です。理想値targetを$t_k$ 出力値を$o_k$としています

```math
\frac{\delta E}{\delta W_{jk}} = \frac{\delta (t_k - o_k)^2}{\delta W_{jk}}
```
そのままだと微分するのが大変なので、合成関数（連鎖律）を使います。

- 連鎖律

```math
\frac{\delta y}{\delta x} = \frac{\delta y}{\delta u} * \frac{\delta u}{\delta x}
```

ここでは以下のような式になります。

```math
\frac{\delta E}{\delta W_{jk}} = \frac{\delta E}{\delta o_k} * \frac{\delta o_k}{\delta W_{jk}}
```

一つ目の項を変形します。
$\delta (t_k - o_k)^2$を微分すると$-2 (t_k - o_k)$になります。なぜマイナスが付いているかというと、「正の勾配を持つときは重みを減少させたい、負の勾配を持つときは重みを増加させたい」からです。

二つ目の項も変形します。
図(3)で見た通り、出力値を出したいときはシグモイド関数を使いました。計算式は、図3の(2)通り、$O_{hidden} = sigmoid(X_{hidden})$です。$o_k$にこのシグモイド関数を使った式を代入します。

```math
\frac{\delta o_k}{\delta W_{jk}}=\frac{\delta * sigmoido(\sum_j W_{jk}*o_j)}{\delta W_{jk}}
```

ここでいう$o_j$は隠れ層の出力なので図3の$O_{hidden}$と同義です。

シグモイド関数を微分することは至難の技なので、既知の公式を用います。

- シグモイド関数の微分の公式

```math
\frac{\delta * sigmoido(x)}{\delta x} = sigmoid(x)( 1-sigmoid(x))
```

これを使うと、

```math
\frac{\delta E}{\delta W_{jk}}=　-2 (t_k - o_k) * sigmoid(\sum_j W_{jk}*o_j)( 1-sigmoid(\sum_j W_{jk}*o_j)) * \frac{\delta (\sum_j W_{jk}*o_j)}{\delta W_{jk}}
```
となります。

最後の$\frac{\delta (\sum_j W_{jk}*o_j)}{\delta W_{jk}}$はsigmoid関数の中にある式をもう一度合成関数を使っているため、この項が出てきます。ただ、これは計算すると$o_j$と同じのため、以下の式に修正します。ついでに、-2も取り除きます。係数は2だろうが100だろうが微分では消えてしまうため、不要だからです。
まとめると以下の式になります。

```math
\frac{\delta E}{\delta W_{jk}}=　-(t_k - o_k) * sigmoid(\sum_j W_{jk}*o_j)( 1-sigmoid(\sum_j W_{jk}*o_j)) * o_j
```

日本語に直すと、
隠れ層jと出力層kの間の重み($W$) = 出力層kの誤差($E_{output}$)の微分 * 隠れ層jの出力値($O_{hidden}$)

これに学習率を戻してあげると、最終的な式は以下になります。

- 誤差を重みに適用する計算式

```math
\Delta W_{jk} = 学習率\alpha * E_k * O_k(1-O_k) * O^T_j
```

##3.pythonのコードに落とし込む

前置きがだいぶ長くなりましたが、pythonのコードに落とすとこんな感じです。


```neural_network.py
import numpy
import scipy.special #シグモイド関数expit()使用のため

# ニューラルネットワーククラスの定義
class neuralNetwork:
    
    # ニューラルネットワークの初期化
    def __init__(self, inputnodes, hiddennodes, outputnodes, learningrate):
        # 入力層　隠れ層　出力層のノード数の設定
        self.inodes = inputnodes
        self.hnodes = hiddennodes
        self.onodes = outputnodes
        
    # リンクの重み3*3の行列を作る 
    #input-hiddenのweight（wih）　と　hidden-outputのweight（who）の２種類
        # 行列内の重み weight_ノードi_ノードjへのリンクの重み
        # 重みの値は、0.0を平均としてリンクの数の平方根の逆数の範囲にする。pow(self.hnodes, -0.5)はself.hnodesを-1/2乗(power)している（つまり平方根の逆数を取る計算）
        # numpy.random.normal（loc（平均） = 0.0、scale（範囲） = 1.0、size = None(例えば(2,3)なら2行3列の行列を作る) ）
        # w11 w21
        # w12 w22　など
        self.wih = numpy.random.normal(0.0, pow(self.hnodes, -0.5), (self.hnodes, self.inodes))
        self.who = numpy.random.normal(0.0, pow(self.onodes, -0.5), (self.onodes, self.hnodes))
        
    # 学習率の設定
        self.lr = learningrate
        
    # 活性化関数（シグモイド関数）の定義
        self.activation_function = lambda x: scipy.special.expit(x)
      
    
        pass
    
    # ニューラルネットワークの学習
    def train():
        
    #---------------------------------入力値と出力値の計算--------------------------------------------------------------
        # 入力リストを行列に変換（.Tは.transpose()と一緒で転置行列を表す）
            #入力は横並びのリストに入れるので、それを縦に並べる処理
            # 例：
            # input=>  a = ([1.0, 0.5, -1.5])
            # input=>  b = numpy.array(a, ndmin=2).T
            # input=>  b
            # output=>  array([[ 1. ],
            # output=>     [ 0.5],
            # output=>     [-1.5]])
        
            # numpy.array(object, dtype=None, copy=True, order='K', subok=False, ndmin=0)
            # ndminは結果の配列に必要な最小次元数を指定します。この要件を満たすために、必要に応じて形状にあらかじめペンディングされます。
       
        inputs = numpy.array(inputs_list, ndmin=2).T
        targets = numpy.array(targets_list, ndmin=2).T    #targetsは目標出力
        
        
        #　隠れ層に入ってくる信号の計算
        # 重みとinputの行列を計算して出力値をだす
          #numpy.dot(a, b, out = None)
          # aは左からかける行列　bは右からかける行列
        hidden_inputs = numpy.dot(self.wih,inputs)
        
        
        # 隠れ層で結合された信号を活性化関数により出力
        hidden_outputs = self.activation_function(hidden_inputs)

        #　出力層に入ってくる信号の計算
        final_inputs = numpy.dot(self.who,hidden_outputs)
        # 出力層で結合された信号を活性化関数により出力
        final_outputs = self.activation_function(final_inputs)

    #---------------------------------誤差逆伝播の計算--------------------------------------------------------------
        #目標出力(targets) と最終出力(final_outputs)の差分を計算して出力層の誤差を求め、それを使って転置行列をかけると隠れ層の誤差が更新される。
        
        #出力層の誤差 = (目標出力(targets) - 最終出力(final_outputs)) 
        output_errors = targets - final_outputs
        #隠れ層の誤差は出力層の誤差をリンクの重みの割合で更新（転置行列をかける）
        hidden_errors = numpy.dot(self.who.T, output_errors)
        
    #---------------------------------重みの更新の計算--------------------------------------------------------------
        
        #上で誤差が更新されたら、その誤差を使って重みを更新する
    
        #隠れ層と出力層の間のリンクの重みを更新
        self.who += self.lr * numpy.dot((output_errors * final_outputs * (1.0 - final_outputs)), numpy.transpose(hidden_outputs))
        #入力層と隠れ層の間のリンクの重みを更新
        self.wih += self.lr * numpy.dot((hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), numpy.transpose(inputs))
    
        pass
    
    
    
    # ニューラルネットワークへの照会
    def query(self, inputs_list):
        
        # 入力リストを行列に変換
        inputs = numpy.array(inputs_list, ndmin=2).T
        
        #　隠れ層に入ってくる信号の計算
        hidden_inputs = numpy.dot(self.wih,inputs)
        # 隠れ層で結合された信号を活性化関数により出力
        hidden_outputs = self.activation_function(hidden_inputs)

        #　出力層に入ってくる信号の計算
        final_inputs = numpy.dot(self.who,hidden_outputs)
        # 出力層で結合された信号を活性化関数により出力
        final_outputs = self.activation_function(final_inputs)

        return final_outputs
        
        pass
    

```

パラメータは以下のように設定します。

```para.py
#入力層　隠れ層　出力層のノード数
input_nodes = 3
hidden_nodes = 3
output_nodes = 3

#学習率
learning_rate = 0.3

#ニューラルネットワークのインスタンスの生成
n = neuralNetwork(input_nodes,hidden_nodes,output_nodes,learning_rate)
```

ここまで。次回は実際にこのコードを動かすところを記載します。