社畜エンジニア発掘戦線

駆けだしAIエンジニア

隠れ層の可視化

おつかれさまです。

非線形分類問題では隠れ層を用いて直線では分類できないような2クラスの分類をトライしました。
②非線形分類問題(パラメータの更新) - 社畜エンジニア発掘戦線

出力層で確認できる結果としては、確かに2クラスの分類ができていましたが、「じゃあ隠れ層ではどうなっとんねん!」っていうのが素朴な疑問だと思います。

この問題では隠れ層(H)を3つのニューロンで構成しました、つまりH1、H2、H3の3次元グラフで可視化することができます。今回はこの隠れ層でどんなふうに変化が起きているのか確認してみたいと思います。使うコードは上記のリンクの問題の完成コードから引用。ちなみに3Dグラフを使うのでPYTHONISTAでは確認できません、PC(Mac)を使ってトライしてみます。

f:id:sutokun:20190318124520j:plain:w500

合わせて、ちょろっとコードの改善もトライします。基本的に設問の中ではできるだけ数式に忠実にコード化してたんですが、繰り返しもいちいちコード化してブサイクなところも多々あり(まぁ分かりやすいんだけど)、そこらへんも一度まとめてきれいにしたいと思います。

ライブラリのインポートからデータセットまで

まずはライブラリのインポート、今回は3Dグラフを使うのでそれ用の呪文を唱えます。

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D #3D呪文

次にデータセット、ここはそのまま使います。まとめれなくもないんですが可読性が下がりすぎるのでやめました。

#data set
num_of_sam = 40
radius = 4
std_dv = 0.6

X_center = np.random.randn(num_of_sam,2)*std_dv

s = np.random.uniform(0,2*np.pi,num_of_sam)
noise = np.random.uniform(0.9, 1.1, num_of_sam)
x1 = np.sin(s)*radius*noise
x2 = np.cos(s)*radius*noise
X_circle = np.c_[x1,x2]

X = np.vstack((X_center,X_circle))

t_group1 = np.tile([0,1],(num_of_sam,1))
t_group2 = np.tile([1,0],(num_of_sam,1))

T = np.vstack((t_group1, t_group2))
関数の定義1

それから関数の定義です、シグモイド、ソフトマックス、クロスエントロピーをそれぞれコードに落としておきます。

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

def softmax(x):
    return np.exp(x) / np.sum(np.exp(x), axis=1, keepdims=True)

def loss(y, t):
    return -np.sum(np.multiply(t, np.log(y)) + np.multiply((1 - t), np.log(1 - y)))
関数の定義2

いつもはここから初期値を設定して、ニューラルネットワークの計算を進めていたんですが、このニューラルネットの計算もあらかじめ関数に落とし込んでおきます。ちょっとぶっ飛びますが、とりあえずコードを書きます。

#function2
def forward(X,T,params):
    W1,W2,B1,B2 = params
    H = sigmoid(np.dot(X,W1)+B1)
    Y = softmax(np.dot(H,W2)+B2)
    E = loss(Y, T)

    dW2 = np.dot(H.T,Y-T)
    dB2 = np.sum(Y-T, axis=0, keepdims=True)
    dW1 = np.dot(X.T,H*(1-H)*np.dot(Y-T,W2.T))
    dB1 = np.sum(H*(1-H)*np.dot(Y-T,W2.T), axis=0, keepdims=True)
    dE = [dW1, dW2, dB1, dB2]
    return [H, Y, E, dE]

まずは上から、「foward」という関数名でこの計算を定義して、そこには「X(入力)、T(正解ラベル)、prams(???)」の3つの変数を使いますよ、と記述します。このparamsですが、初期値をまとめたパラメータとして後の実際の計算のときに定義します。中身は「W1、W2、B1、B2」です(これ順番も大事)。

この関数の計算を始めるときには、paramsはそれぞれのパラメータが配列としてパックされた状態なので、使えるように展開してあげなければいけません。

W1,W2,B1,B2 = params

順番も大事ってのはここ、パックするパラメータの順番が間違ってると、ここで間違ったパラメータで展開されてしまいます。

このあとはよく知ってる計算が続きます。隠れ層(H)、出力層(Y)、誤差(E)まで計算して、微分式(dW1〜dB2)も合わせて計算しておきます。計算した微分式の結果は全部まとめてdEでパックしておきます。

dE = [dW1, dW2, dB1, dB2]

最終的に関数の計算はひとつしか出せないので、ひとつの配列としてほしい結果をすべてパックして出力します。

return [H, Y, E, dE]

こんな感じでひとつめの関数定義が終わり。

次にパラメータの更新も関数にしておきます、使う更新手法はモーメンタム法です。ちょっとややこしいかもしれないので、更新式を確認しておきます。

{\displaystyle 
速度のパラメータ式:V_{new} = \gamma V_{old}-\mu  \frac{\partial E}{\partial P}
}

{\displaystyle 
パラメータ更新:P_{new} = P_{old}+V_{new}
}

速度のパラメータ式と更新式、2つあるので、それぞれを関数で定義します。

まずは速度の式。

def velocity(learning_rate,momentum_term,Vs,dE):
    return [momentum_term*i-learning_rate*j for i, j in zip(Vs ,dE)]

2行ですが、とてもややこしい。まずは「velocity」という名前でこの関数を定義して、「learning_rate(学習率)、momentum_term(減衰係数)、Vs(更新したい速度のパラメータ:W1〜B2に対応),dE(上で計算したやつ)」の5つのパラメータを使いますよ、と記述します。1行目でもうややこしい、ギリギリなんか分かる。

それで2行目、

{\displaystyle 
速度のパラメータ式:V_{new} = \gamma V_{old}-\mu  \frac{\partial E}{\partial P}
}
この式がここに対応します。

momentum_term*i-learning_rate*j

iとjって何やねん、ってのをさらにその後ろのforで定義します。

for i, j in zip(Vs ,dE)

forって繰り返しのときに使うアレですよね、アレです、iとjはその後ろでzipされたVsとdEの中身にそれぞれ対応します。VsとdEはW1、W2、B1、B2、4つのパラメータに対応したものをまとめた変数なので、ひとつずつiとjで中身を取り出して計算する、という流れです。

次はパラメータの更新式、

def momentum(Vs,params):
    return [i+j for i, j in zip(params, Vs)]

ここも流れは同じです。paramsと上で計算したparamsを用いて「momentum」という名前の関数を定義します。

{\displaystyle 
パラメータ更新:P_{new} = P_{old}+V_{new}
}
更新式はシンプルですね、iとjで計算するのがW1〜B2に対応したそれぞれparamsとVsの中身です。


なんということでしょう、この関数を使うことで、今まで8行使って書いていた計算を2行でまとめることができそうです。

〜ビフォア〜
velocity_W1 = momentum_term*velocity_W1-learning_rate*dW1
W1 = W1+velocity_W1

velocity_B1 = momentum_term*velocity_B1-learning_rate*dB1
B1 = B1+velocity_B1

velocity_W2 = momentum_term*velocity_W2-learning_rate*dW2
W2 = W2+velocity_W2

velocity_B2 = momentum_term*velocity_B2-learning_rate*dB2
B2 = B2+velocity_B2
〜アフタ〜
Vs = velocity(learning_rate,momentum_term,Vs,dE)
params = momentum(Vs,params)

関数の定義はこれで終了です。

初期値の設定

次に初期値の設定です。

#initialize
W1 = np.random.randn(2,3)
W2 = np.random.randn(3,2)
B1 = np.random.randn(1,3)
B2 = np.random.randn(1,2)
params_momentum = [W1,W2,B1,B2]

関数の中で使うparamsの設定です、テストで他の更新手法を試した経緯もあり、いちおう名前を「params_momentum」としています。ここでは名前にあんまり意味はないけど。

learning_rate = 0.008
momentum_term = 0.9
Vs = [np.zeros_like(i) for i in [W1,W2,B1,B2]]

速度のパラメータ設定です。初期値はすべてゼロにしているので、np.zerosを使って各パラメータの型に対応するVsをforで定義します。上の関数でやった形と同じですね。

save_momentum =[]

最後に損失関数を保存する配列を定義しておきます。

学習

ではイテレーションを回していきます。

#iteration
num_of_itr = 600
for i in range(num_of_itr):
    output_momentum = forward(X,T,params_momentum)
    Vs = velocity(learning_rate,momentum_term,Vs,output_momentum[3])
    params_momentum = momentum(Vs,params_momentum)

    #save loss
    save_momentum = np.append(save_momentum, output_momentum[2])

めっちゃシンプルですね、定義した関数を流れ通り使うだけです。各パラメータはリストでパックされているので、使うときには「output_momentum[3]」みたいにピックアップしてあげる必要があります。

グラフ化

ではグラフ化します。今回は隠れ層の結果がほしいのでHについて計算。

group1_hidden,group2_hidden = np.split(output_momentum[0],2)

まず、計算されたHは「output_momentum」でパックされているので「output_momentum[0]」で抜き取れます。上半分と下半分がそれぞれ分類したい2クラスに分かれているので、それをnp.splitします。

次にグラフ描画です。

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d') #3Dグラフのためのおまじない

3Dグラフにしたいので、おまじないが必要です。

その他、ラベルとか適当に設定します。

ax.set_xlabel('H1')
ax.set_ylabel('H2')
ax.set_zlabel('H3')
ax.scatter(group1_hidden[:,0], group1_hidden[:,1], group1_hidden[:,2])
ax.scatter(group2_hidden[:,0], group2_hidden[:,1], group2_hidden[:,2])
plt.show()

では結果を見てみましょう。
f:id:sutokun:20190318151012g:plain

ミスった、色をそろえてなかった、めんどくさいのでそのままいこう、すいません。青いドットセンターのクラスでオレンジのドットが外側のクラスに対応します。

これをみると最初はごちゃごちゃしていた2クラスのドットが3次元的に分離されていく様子が確認できます。へーすごい。

ちょっと見る角度を変えてみる、左がスタート、右が学習後。
f:id:sutokun:20190318151315p:plain:h200f:id:sutokun:20190318151337p:plain:h200

なんか学習後のグラフを見ていると、直線(平面)で分類できそうな気がします。つまり、隠れ層を入力層と見立てて、「入力層(隠れ層)→出力層」の関係で見てみると、これは3次元の線形分離問題そのもの。ごちゃごちゃのデータセットも隠れ層を入れることで分離され、非線形問題を線形分離問題に転換することができるんですね。

これが隠れ層の持つ大きな意味で、この隠れ層というブレイクスルーのおかげでディープラーニングは大きな発展を遂げることができたようです。

まとめ

今回は本題から少し外れて隠れ層のふるまいについて詳しく見てみました。なんで非線形問題には隠れ層が必要なのかというと、隠れ層が線形分類問題に転換してくれるんですね。ニューラルネットワークの良い理解に繋がりました。

全体コード
github.com

元の記事
PYTHONISTA3を使って機械学習(ディープラーニング) - 社畜エンジニア発掘戦線

Twitter
世界の社畜 (@sekai_syachiku) | Twitter