Edited at

単純パーセプトロンの解説・実装

More than 3 years have passed since last update.


目標

単純パーセプトロンは、機械学習、とりわけニューラルネットワークの基礎です。私も勉強中ですが、ニューラルネットワークの実装を、まずは単純パーセプトロンから始め、ゆくゆくはDeep Learningを実装出来るように頑張りましょう!(私も頑張ります)


単純パーセプトロンとは

配列を入力に受け取り、0か1の数字を一つ返す、ニューラルネットワークです。このアルゴリズムは、脳細胞の模倣から始まりました。気を付けなければいけないのは、あくまで単純な模倣で、極めて簡素化されていることです。単純パーセプトロンに出てくる数学要素は、掛け算と足し算だけです。覚えるべき式は合計2行、ソースコードは学習アルゴリズムだけなら20行程度です。


注意

この記事は、私の理解が深くないことと、詳細な学習アルゴリズムの証明をすると長くなること。そもそも、数式の量自体が少なく、数式を丸暗記してそれをそのまま実装できることから、なぜ、そのアルゴリズムで学習が行えるのかは説明しません。あくまで入門、step upです。詳細な説明はもっと理解が深まった時に別途投稿したいと思います。(と思っていたら長くなった、分かりやすさ重視ということで)


簡素化された脳細胞

実際の脳細胞の構成を、簡潔に述べます。脳細胞への入力は樹状突起と呼ばれる突起に行われます。樹状突起には入力の伝わりやすさ、伝わりにくさがあります。式に起こすと、単純に掛け算となります。入力をi,伝わりやすさをwとします。

i \times w

伝わりやすさは、重み(weight)と呼ばれるので、wで表します。樹状突起は複数あります。もちろん、単純パーセプトロンでも、入力は数字が格納された配列です。従って、この計算は入力の要素分行われます。例えば要素数が5つの場合、

i_0 \times w_0\\

i_1 \times w_1\\
i_2 \times w_2\\
i_3 \times w_3\\
i_4 \times w_4\\

となります。ここで横の数字は添え字です。iやwがプログラムに置ける配列に相当します。i[0]*w[0]という意味です。重みの要素数と、入力の要素数は一致しており、それぞれの配列の同じ添え字の要素が掛けられていることがわかります。

脳細胞への入力は、それぞれの樹状突起でした。対して出力は一つしかなく、軸索と呼ばれます。樹状突起と軸索は細胞体と呼ばれるものから伸びています。それぞれの樹状突起からの入力は細胞体で合流し、その細胞体から一つの軸索へ出力が行われます。

樹状突起への信号の入力と伝達は、上記の式のように、単に掛け算で表せました。次に、この入力を細胞体で合流させる必要があります。これは単純に、樹状突起を通った入力の総和を取る。つまり足し合わせることで実現されます。

i_0 \times w_0 + i_1 \times w_1 + i_2 \times w_2 + i_3 \times w_3 + i_4 \times w_4

式が長くなるので、省略記法を使います。総和の省略記法にはシグマを使います。例えば、1~5の総和は、以下に示されます。

\sum_{i=1}^{5} i = 1 + 2 + 3 + 4 + 5


sigma.py

tmp = 0

for i in range(1,6):
tmp += i

pythonのソースコードも示しました。参考にして下さい。シグマ(Σ)は非常に簡単で、シグマの下に、変数と、その変数の初期値を記入します。i=1の部分です。ここでのiは入力ではなくindexのiだということに注意して下さい。変数名はi以外にも自由に付けられます。シグマの上にあるのが変数iの値をいくつまで変化させるかを表したものです。変数iはぴったり1ずつ増えます。それらを足し合わせることをシグマは示しています。

次に樹状突起を通った入力の式も示します。iはinputの変数として使っているので、代わりにjを使います。

\sum_{j=0}^{4} i_j \times w_j = i_0 \times w_0 + i_1 \times w_1 + i_2 \times w_2 + i_3 \times w_3 + i_4 \times w_4


sigma.py

#iとwは事前に定義されていることとします。

tmp = 0
for j in range(5):
tmp += i[j] * w[j]

名前がないと不便なので、この値に重み付き入力という名前を付けることにします。変数名はとりあえずzとします。また、5以外の要素数にも対応できるように、要素数をnで表すことにします。

z = \sum_{j=0}^{n-1} i_j w_j

以上で重み付き入力の式は終わりです。これで入力と、それぞれの入力の伝わりやすさ、それらをまとめることを式として表現できました。最後に、この総和からどのように出力を決めるかを式に表します。

ここでも実際の脳細胞を模します。実際の脳細胞では、入力が一定以上だと、信号を伝え、一定より下だと、信号を伝えません。一定以上で信号が発生することを、発火と呼びます。

単純パーセプトロンは非常に簡素なモデルです。従ってこの式もシンプルなものとなります。重み付き入力をz,出力をoとします。

o = 

\begin{cases}
1 & \text{if } z > 0\\
0 & \text{if } z \leqq 0
\end{cases}


step.py

#zは事前に定義されていることにします

if z > 0:
o = 1
elif z <= 0 :
o = 0

以上です。0より大きいなら発火(1を伝えます)そうでなければ発火しない(0を伝えます)です。ここで、きちんと読んでいた人は、0を基準に発火するかしないかを判断するのを不思議に思うかもしれません。それは明らかに細胞体にマイナスの信号が入ってくるのが前提となっているからです。

しかし、あくまで単純パーセプトロンは非常に単純なモデルであることを忘れないで下さい。0より大きければ発火しますが、これは単にパーセプトロンの簡略化のための仕様です。これでパーセプトロンの出力に関して述べ終わりました。

以上をまとめた、単純パーセプトロンのoutputのpythonコードは以下のようになります。


output.py

#iとwは事前に定義されていることとします。


def dot(vec0,vec1):
tmp = 0
for i in range(len(vec0)):
tmp += vec0[i] * vec1[i]
return tmp

def step(num):
if num > 0:
return 1
else:
return 0

o = step(dot(i,w))


0より大きいなら1を返す関数は、数学ではstep関数という関数に分類されます。また、それぞれの要素を掛けて総和を取る関数には、内積という名前がついていて、英語でdot積とも呼ばれます。プログラムの関数名はここから取りました。


output.py

step(dot(i,w))


以上の一行の式を覚えていれば、出力のアルゴリズムは十分書けるでしょう。もう一行の覚えるべき式、学習に関する式を次に示します。


パーセプトロンの学習

冒頭で注意した通り、この記事では、なぜ、このアルゴリズムで学習が行えるのかは説明しません。あくまで実装を通して、次のニューラルネットワークへの予行演習とすることを目的とします。到達点はDeep Learningです。(大分説明してしまいました・・・)

しかし、まったくの丸暗記は難しいと思いますので、多少の解説は入れたいと思います。まず、パーセプトロンの目標とすることは、入力に対して適切な出力を返すことです。このことから、用意するものとして入力データの存在、そして、その入力に対する適切な出力を定義づけたデータが必要となります。適切な出力を教師ラベルといいます。

今更ですが、単純パーセプトロンはニューラルネットの中でも、教師あり学習と呼ばれるものです。教師あり学習では、学習させるデータと、そのデータを入力したときに、出てきてほしい適切な出力、教師ラベルが必要となります。このデータをもとに、重み、wを更新していくのが、単純パーセプトロンにおける学習となります。

今回は、論理演算のandを学習させることを例に説明します。and演算とは、二つの数字(それぞれ0か1)を受け取り、両方とも1だったら1を出力し、そうでなければ0を返す演算で、コンピュータの基礎です。

a
b
a and b

0
0
0

0
1
0

1
0
0

1
1
1

とりあえず学習データと教師ラベルを用意してみましょう。この界隈ではなぜかxとyでそれぞれを表すことが多いので、train_xとtrain_yとします。


data.py

train_x = [[0,0],[0,1],[1,0],[1,1]]

train_y = [0,0,0,1]

特に問題はなさそうですね。[0,0],[0,1],[1,0]の時は0を出力し、[1,1]の時は1を出力することが表現できています。

と思いますが、間違いです。

なぜでしょうか?実は、今まで述べた単純パーセプトロンの説明には欠けているものがあります。バイアス項の存在です。正しくはこうなります。


data.py

train_x = [[0,0,1],[0,1,1],[1,0,1],[1,1,1]]

train_y = [0,0,0,1]

train_xの末尾に1を追加しました。バイアス項とは、すべての入力例で固定された値です。固定されていれば、1以外でも構いません(例えば、[[0,0,0.5],[0,1,0.5],[1,0,0.5],[1,1,0.5]]など)が、普通は1をバイアス項として加えます。なぜバイアス項が必要なのでしょうか?

バイアス項を1とした時を例にとって見ましょう。この時、重み付き入力zがどうなるか見てみましょう。i[n]とw[n]が加えられる訳です。

z = \sum_{j=0}^{n-1} i_j w_j + i_n w_n

ここでi[n]はバイアス項で1なので、こうなります。

z = \sum_{j=0}^{n-1} i_j w_j + w_n

要は、バイアス項1を加えるとは、重みを一つ追加するのと同じです。学習ではこのw[n]も同様に更新されていくので、結果として重み付き入力zは変数w[n]分上下することになります。重みは前述の通り負の値も取れるからです。つまり、バイアス項を加えることで、重み付き入力zをどちらかに偏らせることが出来ます。(バイアスとは偏りという意味です)

これは、実際の脳細胞の機能でいうと、発火(入力が一定以上(閾値という)あったときに、信号を出力する)の閾値を間接的に変化させていることに相当します。もちろんstep関数の閾値は0ですが、例えばバイアスが0.5あったときは、間接的に、閾値が-0.5になったことに当たるからです。

閾値を変化させるということは、1を出力しやすいか、0を出力しやすいかを決定するということですので、重要な要素だと分かると思います。というわけでバイアスを入れましょう。andを学習させる際も、バイアス項を入れないと上手くいきません。数学的には超平面で分離するときに切片がないと原点を通るグラフしか書けなくて表現力が下がる云々とか説明できる(らしいですが)のですが、省きます。数学的な証明は後に上げたいです。

話を戻しましょう。単純パーセプトロンの学習の話でしたね。学習とは重みを更新することです。そのために、教師データとして、入力とそれに対する出力のセットが必要なことを記しました。入力に対して適切な出力を返すように重みを変化させるために、他に何が必要でしょうか。もちろん元となる重みですね。


data.py

train_x = [[0,0,1],[0,1,1],[1,0,1],[1,1,1]]

train_y = [0,0,0,1]
weight = [0,0,0]

重みベクトルはとりあえず全て0で初期化してみました。重みベクトルとは、weightのことです。今更ですが数字だけが入った配列をベクトルと呼びます。これからはベクトルといった時は、配列のことを指していると認識して下さい。

他に何が必要でしょうか。重みベクトルを更新するには、そもそも重みベクトルを更新するべきかしないべきか判断する必要がありそうです。重みベクトルを更新すべき時とは、誤った出力を出した時なのは自明なので、出力oが必要となります。


data.py

train_x = [[0,0,1],[0,1,1],[1,0,1],[1,1,1]]

train_y = [0,0,0,1]
weight = [0,0,0]
#例:train_x[0]をiとした時の出力
o = step(dot(train_x[0],weight))

他に何が必要でしょうか?実はこれで終わりです。上記のdataがあれば、重みベクトルを更新することが出来ます。うそです。本当は学習係数を入れるのが通常です。学習係数とは、重みを更新するとき、どのくらい変化させるかを表す数字です。しかし、学習係数は1でも一応収束(学習がきちんと終わること)するし、重みベクトルがすべて0だった時は、学習係数がいくつだろうと収束に掛かる回数は同じ(らしいです。参考文献後で載せる)なので、省けます。が、一応入れておきましょう。


data.py

train_x = [[0,0,1],[0,1,1],[1,0,1],[1,1,1]]

train_y = [0,0,0,1]
weight = [0,0,0]
#例:train_x[0]をiとした時の出力
o = step(dot(train_x[0],weight))
#例:学習係数
eta = 0.1

では実際に更新のアルゴリズムを見ていきましょう。まず、誤った出力を出した時に、重みベクトルを増やすべきか減らすべきかを決める必要がありそうです。0を出力したいときに、1を出力したら重みを減らし、1を出力したいときに、0を出力したら重みを増やせばよさそうです。もちろん一致しているときは重みを変化させません。

これは、出力したい値(教師ラベル)をy,実際の出力をoとした時、

y - o

で表せます。表に示します。

y
o
y - o

0
0
0

0
1
-1

1
0
1

1
1
0

oが大きいときは-1で減らし、oが小さいときは+1で増やすことが表現できていますね。

しかし、これは単位が重みベクトル全体です。どういうことか説明します。上の式は、実際のコードだと


y_o.py

#例:添え字が0の時のtrain_xとtrain_yの要素をもとに計算する

y = train_y[0]
o = step(dot(train_x[0],weight))
y - o

となりますね。要はy-oは重みベクトル全体から算出されているわけです。これでは、この重みベクトルを更新するときに、正と負のどちらに更新すればいいかは分かりますが、どの要素を更新すればいいのかわかりません。重みの全てを一様に更新したら、重みベクトルの要素が全て同じ値になってしまうので、これは明らかによく有りません!どうすればいいでしょうか。

ここで、パーセプトロンの挙動を振り返ってみましょう。パーセプトロンは、入力ベクトルを受け取り、それぞれに重みベクトルを掛けて、その総和を取り、それをstep関数の引数に与えて、0か1を出力する、というものでしたね。ということは、もちろん、同じ入力を与えたら同じ出力を返します。そして、違う入力を与えたら、とりわけ教師ラベルの異なるデータを与えたら、違う出力を返すことが望ましい訳ですね。

違う出力を返すということは、重み付き入力が、正の数か負の数かのどちらかに偏る必要があるということです。重み付き入力をstep関数に通すからです。

教師ラベルが1のデータの時は正の数に偏らせ、教師ラベルが0の時は負の数に偏らせる必要があります。つまり単純に考えて、重みには正の重みと負の重みの両方が存在しないと駄目です。重みが一様では駄目なのです。

そして、正の重みには、教師ラベルが1のデータが通り、負の重みには、教師ラベルが0のデータが通ればよさそうです。そうすれば、正の重みを通った教師ラベルが1のデータはもちろん1をoutputするし、負の重みを通った教師ラベルが0のデータはもちろん0をoutputしますね。

しかし、正の重み、負の重みといっても、もちろんパーセプトロンは入力ベクトルを全ての重みに通すのですから、これは表現が正しくありません。ではどうすれば正しくなるでしょうか?ここで、入力ベクトルの要素の中に、0があったと考えてみましょう。

入力が0ですから、内積を取った時、その入力に対応する重みは、なんの影響も持てません。0×重み=0です。つまり、入力が0のとき、それに対応する重みは、出力を計算するに関して無視されているのと同じです。これは重みを通らなかったと考えられますね。

入力が0ならば、その入力は重みを通りません。従って、その重みは更新しなくてよいことになります。また、例えば教師ラベルが1のデータはその重みを通らなくても、教師ラベルが0のデータはその重みを通る可能性があるのですから、更新しなくてよい重みを更新したら、相手が通る重みを勝手に変化(しかも自分の方向に)させたことになってしまい、逆効果となります。

もう分かりますね。重みベクトルを更新させるときは、入力が0ならばその重みを更新せず、入力が1ならばその重みを更新すればいいのです。入力が0か1とは限りませんから、単純に、重みベクトルの更新量は、その入力に比例すると考えればよいでしょう。

つまり、例えば重みベクトルの添え字0の更新量は、

(y - o) * i_0

となります。おっと、学習係数を入れるのを忘れていましたね。

(y - o) * i_0 * eta

他に何が必要でしょうか・・・。何も必要ありません!w[0]を更新するときに、加算する値はこれで全てです。新しいw[0]にドットを付けて数式に表すとこうなります。

\dot{w}_0 = w_0 + (y - o) * i_0 * eta

添え字が0以外の時も対応できるように、jにしましょう。

\dot{w}_j = w_j + (y - o) * i_j * eta

これで覚えるべき式の二つ目は終了です。

後は実際にプログラムを書くだけの消化試合です。ありがとうございました。


実装

ひとつひとつ実装していきましょう。pythonが読めることを前提とします。まず、内積を計算する関数と、step関数が必要でしたね。


dot.py

def dot(vec0,vec1):

tmp = 0
for i, j in zip(vec0,vec1):
tmp += i * j
return tmp

zip関数は、二つのリストを張り合わせてくれる関数です。これにより、張り合わせられたリストの要素が先頭から、iとjに入ります。それらを掛けて、足し合わせるだけです。zip関数は便利なので覚えましょう。


step.py

def step(num):

if num > 0:
return 1
else:
return 0

さらに出力を定義しましょう。feedforwardという名前にしてみました。別にoutputでも良いです。


feedforward.py

def feedforward(i,w):

return step(dot(i,w))

次に学習です。この式を思い出しましょう。

\dot{w}_j = w_j + (y - o) * i_j * eta

とりあえず関数が取る引数だけ書くとこうなりますね。


train.py

def train(w,i,y,eta):

pass

i,yはtrain_x,train_yの要素です。train_xとtrain_yは教師データですが、教師データのそれぞれの要素を一つずつ学習させて行きます。これはオンライン学習、逐次学習と呼ばれます。とりあえずoを計算しましょう。


train.py

def train(w,i,y,eta):

o = feedforward(i,w)

w[j]を全て更新する必要があるので、for文を回します。


train.py

def train(w,i,y,eta):

o = feedforward(i,w)
for j in range(len(w)):
w[j] = w[j] + (y - o) * i[j] * eta
return w

教師dataと重みベクトルと学習率を用意して・・・


data.py

train_x = [[0,0,1],[0,1,1],[1,0,1],[1,1,1]]

train_y = [0,0,0,1]
weight = [0,0,0]
eta = 0.1

教師データをひとつひとつ逐次学習させ・・・


main.py

for x,y in zip(train_x,train_y):

weight = train(weight,x,y,eta)

それを複数回繰り返せば・・・(繰り返す回数のことをepochと言います)


main.py

epoch = 100

for i in range(epoch):
for x,y in zip(train_x,train_y):
weight = train(weight,x,y,eta)

完成!


simple_perceptron.py

#内積

def dot(vec0,vec1):
tmp = 0
for i, j in zip(vec0,vec1):
tmp += i * j
return tmp
#step関数
def step(num):
if num > 0:
return 1
else:
return 0
#出力
def feedforward(i,w):
return step(dot(i,w))
#逐次学習
def train(w,i,y,eta):
o = feedforward(i,w)
for j in range(len(w)):
w[j] = w[j] + (y - o) * i[j] * eta
return w

#main処理,andを学習させる。
if __name__ == "__main__":
train_x = [[0,0,1],[0,1,1],[1,0,1],[1,1,1]]
train_y = [0,0,0,1]
weight = [0,0,0]
eta = 0.1

epoch = 100
for i in range(epoch):
for x,y in zip(train_x,train_y):
weight = train(weight,x,y,eta)

#確認,0,0,0,1が出力されれば大丈夫
for x in train_x:
print(feedforward(x,weight))


入力データは数字の配列、つまりベクトルならなんでも入れられます。手書きの0と1の画像を一次元配列(ベクトル)に直せば、数字認識もできます。精度は微妙ですが・・・。