読者です 読者をやめる 読者になる 読者になる

俵言

しがない社会人が書く、勉強とかゆるふわプロコンのこと

【jupyterで学ぶ】 ゼロから作るDeep Learning - 第3回:3章(その2) pythonによるニューラルネットワークの実装

はじめに

前回に引き続き3章を勉強していきます。3章の流れは以下の通りですが、今回は その2である実装です。

先に言っておくと、今回はもっぱらpythonにおける行列計算のお話になります。最初の方は読まなくていい人がたくさん居そうですが、復習がてらやっていきましょう。

その2) ニューラルネットワークを実際に実装する

ニューラルネットワーク(以下NN)では入力の(重み付き)線形和に対して(その1)で学んだ活性化関数をかけて出力とします。で、この線形和を効率的に計算する上で行列の計算は欠かせません。

今回はまずこの行列演算の話をしてから、NNの実装、加えて最終的な出力をどうするかの話をつらつらと書いていきます。

pythonでする行列演算

※思ったよりこの節が長くなったので知ってる方は読み飛ばした方が良いです

python使いには周知の通りですが、pythonnumpy.ndarrayは行列計算の強力なツールです。 例えば、行列の要素ごとの(element-wiseと呼ばれる)和・積、

f:id:Tawara:20161029235121p:plain

pythonで行うと、

 ## 2 * 2 の二次元行列
A = np.array([[1,2],[3,4]])
B = np.array([[5,6],[7,8]])

## 要素ごとの和を行う
C_Add = A + B
print "C_Add:\n", C_Add

## 要素ごとの積を行う
C_Hadam = A * B
print "C_Hadam:\n", C_Hadam

< C_Add:
< [[ 6  8]
<  [10 12]]
< C_Hadam:
< [[ 5 12]
<  [21 32]]

のようにとても直感的に行えます。因みにこの成分ごとの積はアダマール(Hadamard product)とか呼ばれます。(※ここでは行列のアダマール積の表記を*にしましたが人に依るみたいです。pythonの中では*なので合わせました)

でも行列使ったことある人に馴染みのある積と言えばやっぱり以下に示す行列積ですよね。
 a_{ij}A i j 列目の要素を示す

f:id:Tawara:20161030000215p:plain

これもNumpyを使えば以下の通り。非常に簡潔に書けて、しかも高速に計算してくれるという。

## 行列積を計算
C_Prod = A.dot(B) # もしくは np.dot(A,B)と書く
print "C_Prod:\n", C_Prod
< C_Prod:
< [[19 22]
<  [43 50]]

上の例は2×2の正方行列ですが、もちろん2 × 3 行列  \cdot 3 × 2 行列みたいなのもできます。

ひとつだけ心に留めておきたいのは、このpythonの行列積(.dot())を二次元配列と一次元配列で行ったときの動作です。この後、NNの実装において行列をベクトルにかける操作が出てきます。例えば、

f:id:Tawara:20161030001342p:plain

を計算してみます。

## 3 * 3 の二次元行列
A = np.array([[1,2,3],[4,5,6],[7,8,9]])

## 長さ3 の一次元行列
v = np.array([1,2,3])

## Av を計算
Av = A.dot(v)
print "Av:\n", Av

< Av:
< [14 32 50]

できました。結果が一次元配列で返ってきてます。ところで、この状態で  v \cdot A を計算するとどうなるでしょう?

vA = v.dot(A)
print "vA:\n", vA
< vA:
< [30 36 42]

結果が変わるのは当たり前ですが、冷静に考えると  v \cdot A (3×1 行列  \cdot 3×3 行列)って違和感を覚えます。
よくよくこのvAが何を表しているのか考えてみると、これです。

f:id:Tawara:20161030002407p:plain

..いやいや、転置とかした覚えないんですけど(・・?) って思っちゃいますが、要するにこの一次元配列

v = np.array([1,2,3])

は、
f:id:Tawara:20161030003123p:plain

のどちらでもないんですよね。厳密にこれらを表したいのなら、二次元配列を使って

v_31 = np.array([[1],[2],[3]])
v_13 = np.array([[1,2,3]])

と書かないといけない。でもコレを使って  A \cdot v を計算すると、

Av_31 = A.dot(v_31)
print "Av_31:\n", Av_31
< Av_31:
< [[14]
<  [32]
<  [50]]

見ての通り二次元配列で帰ってきます。列数が1なのにこの形なのって面倒ですよね?要素へのアクセスで意味無くAv_31[0][2]とかしないといけないですし..。まあ、だから自動でよしなにしてくれる機能があるのかなーと勝手に思っています。

多層NNをpythonで実装してみる

さらっと終わらせるはずの前置き(pythonでする行列演算)が妙に長くなってしまいましたが本題に入りましょう。

※注意:
この記事は一部本と違う書き方をしています。本では、
 y = x \cdot W + b
のように、入力  x に対して重み行列  W を右からかける書き方をしています。ただこの書き方は好きではないので、今回の記事では
 y = W \cdot x + b
と、 xW を左からかける書き方をしています。(実装もこれに全て準拠します)

前回(3章その1)では以下のような式が登場しました。 h(x) は活性化関数であるシグモイド関数です。

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

 a = w_1 x_1 + w_2 x_2 + b
 y = h(a)

これを図で表すとこんな感じ。

f:id:Tawara:20161030113421p:plain

入力の各成分 x_1, x_2 w_1, w_2 で重み付けして和をとったあとにバイアス項 b を加え、それをシグモイド関数  h(x) にかけたものが出力  y となります。 で、実際のNNでは出力のノード(ニューロン)は一個とは限りません。むしろ複数個あって当たり前な気がします。ここでは出力が3つになる場合を考えます。

f:id:Tawara:20161030113444p:plain

なかなかにごちゃごちゃしてますが、基本はさっきと一緒です。色(青、赤、緑)別に見れば、さっきと形が同じだと分かると思います。
さて、これらを式にして並べると、以下の通り。
 a_1 = w_{11} x_1 + w_{12} x_2 + b_1
 a_2 = w_{21} x_1 + w_{22} x_2 + b_2
 a_3 = w_{31} x_1 + w_{32} x_2 + b_3

 y_1 = h(a_1)
 y_2 = h(a_2)
 y_3 = h(a_3)

見た目から想像が付くと思いますが、これらは行列計算にまとめることができて、

f:id:Tawara:20161030121257p:plain

更に、以下のように置けば

f:id:Tawara:20161030121330p:plain

このようにまとめることが出来ます。一番最初の式とほとんど変わらないですけど実は行列演算です。


a = W \cdot x + b \\
y = h(a) \\

入力 → 出力の処理をこのようなシンプルな行列計算で書けてしまったので、あとはpythonの力を借りれば実装がとっても楽になります。いやーさすがNumpyさんやでえ。

ただここに書いたのは入力層と出力層しかないNNです。NNの真骨頂を出すためには隠れ層を増やして上げる必要があります。ここでは、本の中で登場する3層NN(入力層(第0層) → 隠れ層1(第1層) → 隠れ層2(第2層) → 出力層(第3層))を示します。以下の図のようなNNです。

f:id:Tawara:20161030152556p:plain

え?エッジは描かないのかって?想像力で補ってください。だって書いたところで図がごちゃごちゃして分かりにくい上にめんどくさいだけですし..。因みに本ではここらへんの図がかなり詳細で、処理の流れを詳しく説明してくれてます。詳細を見たい人は本を読みましょう。

ボックス化した部分は先ほど出力層と入力層だけのNNで説明したのと一緒の構造です。それを連結したのがこの図になっています。ここで、ベクトルや行列の右上に付いているカッコつきの数字 (d) は、どの層に対応しているかを表しています。
さて上の図を式で順番に書いていくと、

  • 第0層 → 第1層(入力:  x, 出力:  z^{(1)}
    
a^{(1)} = W^{(1)} \cdot x + b^{(1)} \\
z^{(1)} = h(a^{(1)})

  • 第1層 → 第2層(入力:  z^{(1)}, 出力:  z^{(2)}
    
a^{(2)} = W^{(2)} \cdot z^{(1)} + b^{(2)} \\
z^{(2)} = h(a^{(2)})

  • 第2層 → 第3層(入力:  z^{(2)}, 出力:  y
    
a^{(3)} = W^{(3)} \cdot z^{(2)} + b^{(3)} \\
y \hspace{0.55cm} = \sigma (a^{(3)})

見ての通り、行列演算によってとてもシンプルに処理が書けます。すなわち、pythonでもとてもシンプルに書けるということです。ただ、ここでひとつだけ新しく出てきている要素は、第3層において  a^{(3)} を出力  y に変換する関数  \sigma (x) です。こいつは解く問題によって形が変わるため、他の層での活性化関数  h(x) と区別がされています。詳しくは次節で。

さて、やっと実装に入れます。ここまで長かったなあ..。まあ実装といってもきわめてシンプルです。上に書いた式の流れをそのままpythonに落とし込みます。例によって本は関数を定義しますがここではclassを定義します。

# σ()を定義。ここでは恒等関数を用いる
def indentify_function(x):
    y = x
    return x

## 3層ニューラルネットワークの実装
class ThreeLayerNN:
    # 初期化で重みの行列とバイアス項のベクトルを渡す
    def _init_(self, Weight1, Weight2, Weight3, bias1, bias2, bias3):
        self.W1 = Weight1
        self.W2 = Weight2
        self.W3 = Weight3
        self.b1 = bias1
        self.b2 = bias2
        self.b3 = bias3

    # NNによって入力xを出力yに変換
    def forward(self, x):
        ## 第0層 -> 第1層
        a1 = self.W1.dot(x) + self.b1
        z1 = sigmoid(a1)

        ## 第1層 -> 第2層
        a2 = self.W2.dot(z1) + self.b2
        z2 = sigmoid(a2)

        ## 第2層 -> 第3層
        a3 = self.W3.dot(z2) + self.b3
        y = indentify_function(a3)

        ## 出力
        return y

はい、見ての通り、forward の中身は上に書いた行列計算の流れをそのまま書いた形です。ここでは  \sigma(x) を入力をそのまま出力する関数(恒等関数)identify_functionとしました。

実装したのであとは動作確認します。本の中で例として重みと入力が与えられてるのでそれを使ってみます。

## 実際に使ってみる
### import
import my_functions
from my_functions import ThreeLayerNN

### 本に習って重み・バイアスを設定
W1 = np.arange(0.1,0.65,0.1).reshape(3,2)
b1 = np.linspace(0.1,0.3,3)
W2 = np.arange(0.1,0.65,0.1).reshape(2,3)
b2 = np.linspace(0.1,0.2,2)
W3 = np.arange(0.1,0.45,0.1).reshape(2,2)
b3 = np.linspace(0.1,0.2,2)
print "W1:\n",W1
print "b1:\n",b1
print "W2:\n",W2
print "b2:\n",b2
print "W3:\n",W3
print "b3:\n",b3
< W1:
< [[ 0.1  0.2]
<  [ 0.3  0.4]
<  [ 0.5  0.6]]
< b1:
< [ 0.1  0.2  0.3]
< W2:
< [[ 0.1  0.2  0.3]
<  [ 0.4  0.5  0.6]]
< b2:
< [ 0.1  0.2]
< W3:
< [[ 0.1  0.2]
<  [ 0.3  0.4]]
< b3:
< [ 0.1  0.2]

あとはこれらの重みとバイアスを使って3層NNを初期化、入力をつっこみます。

### 3層NNの初期化
instNN = ThreeLayerNN(W1,W2,W3,b1,b2,b3)

### 入力xをつっこむ
x = np.array([1.0,0.5])
y = instNN.forward(x)
print "y:\n",y
< [ 0.31682708  0.69627909]

おお、動いた!でも学習させたわけじゃないので何が良いのかわかんないですよね。これに関しては次回に期待です。これにて基本的なNNの実装は完了です。

出力層の話

実装の話の中で出力層における活性化関数  \sigma(x) の話が出ました。こいつに何を用いるかは解く問題が回帰問題か分類問題かで変わるそうです。そして、回帰問題では恒等関数を、分類問題ではソフトマックス関数を使うのが一般的らしい。

実装のときにも出てきた恒等関数は入力をそのまま出力する関数です。この後出てくるソフトマックス関数との比較のために、隠れ層からの入力  a と 出力  y の関係を書くと、

 y_k = \sigma_k(a_k) = a_k

となります。 改めて図示すると以下の通り。

f:id:Tawara:20161030201611p:plain

一方のソフトマックス関数は以下の式で表されます。

 y_k = \sigma _k(a) = \displaystyle \frac{\exp(a_k)}{\sum_i \exp(a_i)}

これを図示すると以下の通り。

f:id:Tawara:20161030202212p:plain

両者の大きな違いとして、恒等関数は自分のニューロン(ノード)に入ってきた入力だけを使って出力を出しますが、ソフトマックス関数は同じ層の他の入力も使って出力を出しています。
ソフトマックス関数は出力  y の総和が1になる( \sum_k y_k = 1 となる)ようになっているので、確率みたいに使うことができるというお話。例えば、入力 x がクラス  k である確立を  y_k として学習させるみたいなことが可能です。
ただ、なんで  \exp を噛ませるのかはよく分からないんですよね。総和を1にするためなら必要ないですし。この方が微妙な差を捉えられるとか?まあそのうち理由が出てくると期待。

そんなこんなで実装です。別に難しくないのでそのまま作ります。

# ソフトマックス関数
def softmax(a):
    # オーバーフロー対策のためにベクトルaの最大要素を求める
    c = a.max() # np.amaxと同じ機能.

    # 最大要素を引いてからexpをかけることでオーバーフローを回避
    exp_a = np.exp(a - c)
    # 和を計算
    sum_exp_a = exp_a.sum()
    # 出力yを計算
    y = exp_a / sum_exp_a
    return y

ただひとつだけ本の中で警告されているのですが、 \exp は指数関数なために入力が大きいと非常に大きな値となり、しばしばオーバーフローの原因になるそうで。なので、上の実装ではベクトル  a の最大値を各  a_k \exp をかける前に引くことで解決しています。コレがうまくいくのは式変形に拠るのですが、それについては本を参照。

実際にちょっと試して見ます。適当な一次元配列にsoftmaxをかけると、

### import
reload(my_functions)
from my_functions import softmax

### 実行
a = np.array([100024.,100025.,100027.])
y = softmax(a)
print "normal:\n", a / a.sum()
print "y:\n", y
< normal:
< [ 0.33332889  0.33333222  0.33333889]
< y:
< [ 0.04201007  0.1141952   0.84379473]

確かに  y の和は1になります。因みに、normalは単純な和で全体を正規化したもの。こう見比べると、差を増幅させるのがソフトマックスで  \exp を噛ませる目的な気がしてきますね。

今回のまとめ

確か今回は実装回で、「python(numpy)の便利さが遺憾なく発揮され、コードの占める量も増えるはずだ!」って前回の終わりに書いたのですが、現実はひたすら行列計算の説明をしてました。どうしてこうなった...。さらっと終わるはずだったのにえらく長くなっちゃうし。

今回のポイントは

  • 行列計算においてNumpyは最強
  • 行列計算を使うことでNNはシンプルに実装できる

もう今回行列のことしか書いてないですね、うん。次回は3章その3)実装したニューラルネットワークを試してみる です。著者側が用意したデータセットで使ってみようってだけなのでさらっと終わる..はず。ああ早く誤差逆伝播に行きたい..

ゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装

おまけ

実装したNNを試してみるときに、numpy.arnge とか np.linspaceを使いました。こいつらはnumpy.ndarrayを生成するときに結構便利です。ただそのまま使うと、

print np.arange(1,9,2)
< [1 3 5 7]
print np.linspace(0.1,0.6,6)
< [ 0.1  0.2  0.3  0.4  0.5 0.6]

一次元配列が生成されてしまうので、reshape()を使うことで良い感じの形に変えれます。

print np.arange(1,9,2).reshape(2,2)
< [[1 3]
<  [5 7]]
print np.linspace(0.1,0.6,6).reshape(2,3)
< [[ 0.1  0.2  0.3]
<  [ 0.4  0.5  0.6]]

といってもこんな規則の良い数字で初期化することって少なそうですし、np.zeros()np.ones()、あるいはnp.full()で、指定した形の配列に同じ数字が入っているものを使うことが多い気がします。

print np.zeros((2,2))
< [[ 0.  0.]
<  [ 0.  0.]]
print np.ones((3,3))
< [[ 1.  1.  1.]
<  [ 1.  1.  1.]
<  [ 1.  1.  1.]]
print np.full((2,4),1./8)
< [[ 0.125  0.125  0.125  0.125]
<  [ 0.125  0.125  0.125  0.125]]

それと、np.arangeに小数を渡すと謎の挙動をすることがあって、

print np.arange(0.1,0.4,0.1)
< [ 0.1  0.2  0.3  0.4]
print np.arange(0.1,0.5,0.1)
< [ 0.1  0.2  0.3  0.4]

なんで一緒のやつでるんやコレ...。まあ小数ならではっぽい。とりあえずの解決策としては、

print np.arange(0.1,0.35,0.1)
< [ 0.1  0.2  0.3]
print np.arange(0.1,0.45,0.1)
< [ 0.1  0.2  0.3  0.4]

みたいに、要素間の間隔(0.1)で上限をとるんじゃなくて、要素間の間隔の1/2(0.05)で上限を取るようにするしか思いつかないですね。