stackprobe7s_memo

何処にも披露する見込みの無いものを書き落とす場所

【機械学習・ディープラーニング】多層パーセプトロン(教師あり学習)の実装 (C#)

数学素人が自分なりに腑に落ちるところまで辿り着いた内容を吐き出したものです。
専門家に言わせれば、おかしい表現や用語があるかもしれませんが、根本の考え方は抑えたつもりです。
間違っているところがあれば、指摘していただけると幸いです。


  • 昨年の今頃、故あって実装した多層パーセプトロン(教師あり学習)を手直ししつつ改めて実装してみました。
  • 当時の動機と目的
    • ディープラーニングという言葉を見聞きするまでコンピュータというものは手順(プログラム)を与えなければ動作しないというのが私の認識でした。
    • 昔からAIと呼ばれるものはあったけれど、私の知るそれはあくまでも人間が手順を与え、入力や出力をパラメータにフィードバックすることによって動作が変化しているに過ぎません。
    • ディープラーニングについての当初の認識は...
      • 入力と出力の組み合わせを大量に投入すれば、入力から正しい出力を吐くようになる。
      • 入力と出力に相関関係があれば、教えていない入力についても正しい出力を吐くようになる。<--- これがすごいと思った。
    • 手順を与えること無くどうやって正しく動作させるのか皆目見当がつかなかったので、この目で動くことを確かめてからせめて原理くらいは理解したいなぁと思ったわけです。
    • 素人でもDLしてすぐに動かせるようなもの (src or bin?) を探してみたけれど見つからず、やっぱり横着は良くないな…と思い直し、とりあえず1冊本を読んでみて理解できなそうだったら諦めようくらいの気持ちで本屋へ
    • 本を読んで「多層パーセプトロン」というものが私のイメージしていた「ディープラーニングと呼ばれるモノ」と一致すると分かったので、その原理を理解しつつちゃんと動くものを実装することを目標にしました。
  • 参考にした本


多層パーセプトロン(教師あり学習)について

多層パーセプトロン(教師あり学習)について何も知らない人に説明する体で書いてみます。

  • 伝言ゲームを思い浮かべて下さい。
  • 3人以上の参加者を一列に並べ、最初の人を「入力層」、最後の人を「出力層」、中間にいる人達を「隠れ層」と呼びます。

5人 = 入力層, 隠れ層 × 3, 出力層 の場合

 
(入力層)
 
⇒ 伝達 ⇒ (隠れ層) ⇒ 伝達 ⇒ (隠れ層) ⇒ 伝達 ⇒ (隠れ層) ⇒ 伝達 ⇒ (出力層)
  • 伝言ゲームでは正確に伝えることを目指しますが、ここでは「連想したもの」を伝えることにします。なので「連想伝言ゲーム」とでも呼ぶべきでしょうか。
  • ゲームは以下のように進行します。
    • まず入力データを入力層に伝えます。入力層は次の隠れ層にそのまま伝えます。
    • 隠れ層は伝えられたデータから「連想したもの」を次の隠れ層(又は出力層)に伝えます。これを出力層に達するまで繰り返します。
    • 出力層は伝えられたデータをそのまま出力データとして出力します。
  • 隠れ層が多ければ多いほど入力データからかけ離れた(想像を超える面白い)出力データが得られるでしょう。
  • ですが、そんな出力データは何の役にも立ちません。
  • そこで「こういう入力データのときは、こういう出力データがほしい」ということを決めておきます。(教師データ)
  • ゲームが終了したあと、実際の出力データと期待した出力データに乖離がある場合、期待した出力データに近づくように隠れ層を教育することにします。隠れ層は教育される度に連想のし方が少しずつ変化して行く訳です。
  • ゲームの実施 ⇒ 教育 を何度も何度も繰り返せば(教育方法に間違いが無ければ)いずれ入力データから期待した出力データを得られるようになるはずです。

分かりやすく簡単に説明するとしたらこのようになります。
ですが、これでは「隠れ層をどのようにプログラムで実装するのか」「どうやって隠れ層を教育するのか」が全く見えてきません。
そこでもう少し実装寄りの説明をします。

  • 1つの層(入力層、出力層、隠れ層)は1人の人間によって構成されていましたが、複数の人間(これを「ニューロン」と呼びます)によって構成されるものと置き換えて下さい。

入力層 2ニューロン, 隠れ層 3ニューロン × 3, 出力層 2ニューロン の場合

入力層 隠れ層 隠れ層 隠れ層 出力層
(ニューロン)

(ニューロン)
⇒ 伝達 ⇒  
(ニューロン)

(ニューロン)

(ニューロン)
 
⇒ 伝達 ⇒ (ニューロン)

(ニューロン)

(ニューロン)
⇒ 伝達 ⇒ (ニューロン)

(ニューロン)

(ニューロン)
⇒ 伝達 ⇒ (ニューロン)

(ニューロン)
  • ゲームは以下のように進行します。
    • まず入力データを分割して入力層のニューロン達に伝えます。
    • 次の層のニューロンは前の層の全てのニューロンからデータを受け取ります。つまり n 個のニューロンの層と m 個のニューロンの層の間では n*m 回の伝達が行われることになります。
    • ニューロンは受け取った全てのデータを元に、そこから連想される1つのデータを次の層に伝えるデータとします。これを出力層に達するまで繰り返します。
    • 出力層のニューロンは出力データを連結して、最終的な出力データとします。
  • ほとんどの場合、出力データは正解(期待された出力データ)と異なります。
  • なので学習を行います。
  • 学習は全ての隠れ層と出力層の全てのニューロンについて行います。
    • ニューロンは前の層の全てのニューロンから受け取ったデータを元に連想を行いました。各ニューロンは、各データについて以下の考察と判断を行います。
      • もしこのデータをもっと重視していたら、自分が出力した連想データは変わっていたはずだ。その連想データを次の層に伝えていたら最終的な出力データはどうなっていたかを試してみる。⇒ その結果、より正解に近づいたなら ⇒ 重要なデータを出力している可能性が高い、入力元のニューロンの重要度を上げ、次からは多く取り入れることにしよう。
      • もしこのデータをもっと軽視していたら、自分が出力した連想データは変わっていたはずだ。その連想データを次の層に伝えていたら最終的な出力データはどうなっていたかを試してみる。⇒ その結果、より正解に近づいたなら ⇒ 不要なデータを出力している可能性が高い、入力元のニューロンの重要度を下げ、次からはあまり取り入れないようにしよう。

このような学習を行えばだんだん正解に近づいて行く…ような気がしませんか。
プログラムに置き換えると以下のようになります。

  • データ
    • 実数です。
    • 入力層にはニューロンの数分の実数を入力し、出力層からはニューロンの数分の実数が出力されます。
  • ニューロン
    • 入力された全てのデータ(実数)をそれぞれの重要度を掛けて合計し、活性化関数に入力して、その出力を自身の出力とします。
    • 活性化関数にはいろいろあるようですが、シグモイド関数以外ちゃんと動かせなかった試していないので、本投稿では 活性化関数=シグモイド関数 と考えて下さい。
    • 式(というかコード)っぽく書くと以下のようになります。
// n == 前の層のニューロン数

// 隠れ層のニューロンの場合
出力データ = sigmoid ( 入力データ[0] * (入力データ[0]の重要度) + 入力データ[1] * (入力データ[1]の重要度) + 入力データ[2] * (入力データ[2]の重要度) + ... + 入力データ[n-1] * (入力データ[n-1]の重要度) )

// 出力層のニューロンの場合
出力データ = 入力データ[0] * (入力データ[0]の重要度) + 入力データ[1] * (入力データ[1]の重要度) + 入力データ[2] * (入力データ[2]の重要度) + ... + 入力データ[n-1] * (入力データ[n-1]の重要度)



学習、学習係数、バイアスについても書くべきかと思うのですが断念します。力尽きた時間が無いので、、、いつか追記するかも。。。
学習については次のアルゴリズムで示します。

多層パーセプトロン(教師あり学習)のアルゴリズム

疑似コードで示します。
入力データを多層パーセプトロンに投入して、出力データを取り出すことを「評価」
入力データと教師データを使って多層パーセプトロンに学習させることを、単に「学習」と呼んでいます。

データ構造

マルチレイヤ
class MultiLayer
{
	NeuronLayer[] NeuronLayers = new NeuronLayer[ レイヤ数 ];       // 入力層, 隠れ層, 出力層 のリスト
	AxonLayer[]   AxonLayuers  = new AxonLayer  [ レイヤ数 - 1 ];   // 層と層の間なのでレイヤ数より1つ少ない
}
入力層 隠れ層 隠れ層 ... 隠れ層 出力層
NeuronLayers[0] AxonLayers[0] NeuronLayers[1] AxonLayers[1] NeuronLayers[2] AxonLayers[2] ... NeuronLayers[ レイヤ数 - 2 ] AxonLayers[ レイヤ数 - 2 ] NeuronLayers[ レイヤ数 - 1 ]
ニューロンレイヤ (入力層、隠れ層、出力層)
class NeuronLayer
{
	Neuron[] Neurons = new Neuron[ この層のニューロン数 ];
}
軸索レイヤ
class AxonLayer
{
	Axon[][] Axons   = new Axon[ 前の層のニューロン数 ][ 次の層のニューロン数 ];
	Axon[] BiasAxons = new Axon[ 次の層のニューロン数 ];
}
ニューロン
class Neuron
{
	double InputValue;  // 活性化関数を通す前の値
	double OutputValue; // 活性化関数を通した後の値

	double InputRateOfChange;  // 出力ニューロンの出力の「変化率」
	double OutputRateOfChange; // 出力ニューロンの出力の「変化率」
}
軸索
class Axon
{
	double Weight; // 重要度
}

評価

// 入力

{
	cl = MultiLayer.NeuronLayers[0]; // 入力層

	for ( c = 0 ; c < (clのニューロン数) ; c++ )
	{
		cl.Nurons[c].OutputValue = 入力データ [ c ] ; // 入力層では活性化関数を使用しないので OutputValue にセットする。
	}
}

// 活性化

for ( layerIndex = 0 ; layerIndex + 1 < レイヤ数 ; layerIndex++ )
{
	cl = MultiLayer.NeuronLayers[layerIndex];     // このレイヤ
	nl = MultiLayer.NeuronLayers[layerIndex + 1]; // 次のレイヤ

	al = MultiLayer.AxonLayers[layerIndex]; // cl と nl の間の軸索レイヤ

	for ( n = 0 ; n < (nlのニューロン数) ; n++ )
	{
		nl.Neurons[n].InputValue = 1.0 * al.BiasAxons[n].Weight;

		for( c = 0 ; c < (clのニューロン数) ; c++ )
		{
			nl.Neurons[n].InputValue += cl.Neurons[c].OutputValue * al.Axons[c][n].Weight;
		}
		nl.Neurons[n].OutputValue = 活性化関数 ( nl.Neurons[n].InputValue ) ;
	}
}

// 出力

{
	cl = MultiLayer.NeuronLayers[ レイヤ数 - 1 ]; // 出力層

	for ( c = 0; c < (clのニューロン数) ; c++ )
	{
		出力データ [ c ] = cl.Nurons[c].InputValue; // 出力層の活性化関数は恒等関数を使用するので InputValue から取得する。
	}
}

学習

「評価」を行ってから出力データを元に以下を実行します。

for ( outputIndex = 0 ; outputIndex < 出力層のニューロン数 ; outputIndex++ )
{
	Train_Output ( outputIndex );
}

// ----

Train_Ouput( outputIndex ) // (outputIndex + 1) 番目の出力層のニューロンについて学習する。
{
	MakeRateOfChangeTable ( outputIndex );
	ChangeWeight_Output ( outputIndex );
}

MakeRateOfChangeTable( outputIndex ) // (outputIndex + 1) 番目の出力層のニューロンについて、出力層・隠れ層の変化率を求める。
{
	// 出力層の変化率を生成
	{
		cl = MultiLayer.NeuronLayers[ レイヤ数 - 1 ]; // 出力層

//		cl.Neurons[outputIndex].InputRateOfChange  = 1.0; // 恒等関数の変化率は 1.0
//		cl.Neurons[outputIndex].OutputRateOfChange = 1.0; // 不使用
	}

	// 最後の隠れ層の変化率を生成
	{
		cl = MultiLayer.NeuronLayers[ レイヤ数 - 2 ]; // 最後の隠れ層
		nl = MultiLayer.NeuronLayers[ レイヤ数 - 1 ]; // 出力層

		al = MultiLayer.AxonLayers[ レイヤ数 - 2 ]; // cl と nl の間の軸索レイヤ

		{
			n = outputIndex;

			for ( c = 0 ; c < (clのニューロン数) ; c++ )
			{
				cl.Neurons[c].OutputRateOfChange = nl.Neurons[n].InputRateOfChange * al.Axons[c][n].Weight;
				cl.Neurons[c].InputRateOfChange = cl.Neurons[c].OutputRateOfChange * 活性化関数の導関数 ( cl.Neurons[c].InputValue ) ;
			}
		}
	}

	for ( layerIndex = レイヤ数 - 3 ; 1 <= layerIndex ; layerIndex-- ) // 残りの隠れ層の変化率を生成
	{
		cl = MultiLayer.NeuronLayers[layerIndex];     // この隠れ層
		nl = MultiLayer.NeuronLayers[layerIndex + 1]; // 次の隠れ層

		al = MultiLayer.AxonLayers[layerIndex]; // cl と nl の間の軸索レイヤ

		for ( n = 0 ; n < (nlのニューロン数) ; n++ )
		{
			for ( c = 0 ; c < (clのニューロン数) ; c++ )
			{
				cl.Neurons[c].OutputRateOfChange = nl.Neurons[n].InputRateOfChange * al.Axons[c][n].Weight;
				cl.Neurons[c].InputRateOfChange = cl.Neurons[c].OutputRateOfChange * 活性化関数の導関数 ( cl.Neurons[c].InputValue ) ;
			}
		}
	}
}

ChangeWeight_Output( outputIndex ) // (outputIndex + 1) 番目の出力層のニューロンについて、軸索層の重みを更新する。
{
	d = (期待される出力値) - MultiLayer.NeuronLayers[ レイヤ数 - 1 ].Neurons[outputIndex].InputValue ; // 誤差 == 期待される出力値 - 現在の出力値

	// 最後の隠れ層 ~ 出力層 の 重みを更新する。
	{
		cl = MultiLayer.NeuronLayers[ レイヤ数 - 2 ]; // 最後の隠れ層
		nl = MultiLayer.NeuronLayers[ レイヤ数 - 1 ]; // 出力層

		al = MultiLayer.AxonLayers[ レイヤ数 - 2 ]; // cl と nl の間の軸索レイヤ

		{
			n = outputIndex;

			for ( c = 0; c < (clのニューロン数) ; c++ )
			{
				al.Axons[c][n].Weight += GetChangeWeight ( cl.Neurons[c].OutputValue , 1.0 , d );
			}

			// バイアスの重みの変更
			{
				al.BiasAxons[n].Weight += GetChangeWeight ( 1.0 , 1.0 , d );
			}
		}
	}

	for ( layerIndex = レイヤ数 - 3 ; 1 <= layerIndex ; layerIndex-- ) // 残りの隠れ層の変化率を生成
	{
		cl = MultiLayer.NeuronLayers[layerIndex];     // この隠れ層
		nl = MultiLayer.NeuronLayers[layerIndex + 1]; // 次の隠れ層

		al = MultiLayer.AxonLayers[layerIndex]; // cl と nl の間の軸索レイヤ

		for ( n = 0 ; n < (nlのニューロン数) ; n++ )
		{
			for ( c = 0 ; c < (clのニューロン数) ; c++ )
			{
				al.Axons[c][n].Weight += GetChangeWeight ( cl.Neurons[c].OutputValue , nl.Neurons[n].InputRateOfChange , d );
			}

			// バイアスの重みの変更
			{
				al.BiasAxons[n].Weight += GetChangeWeight ( 1.0 , nl.Neurons[n].InputRateOfChange , d );
			}
		}
	}
}

/*
	重みの加算値を得る。

	axonInputValue         ... この軸索への入力値
	axonOutputRateOfChange ... ( この軸索の出力 ⇒ 出力ニューロンの出力 ) の変化率
	d                      ... 出力ニューロンの誤差 ( 期待される出力値 - 現在の出力値 )
*/
double GetChangeWeight( axonInputValue , axonOutputRateOfChange , d )
{
	return axonInputValue * axonOutputRateOfChange * d * 学習係数 ;
}

学習係数 は 0.1 を使用しています。適切な値がどのくらいなのかよく分かりませんでした。


重みの加算値の式 (GetChangeWeight) が間違っているんじゃないかなぁと懸念しております。

  • 理屈としては以下のとおり
    • この軸索への入力値が大きければ出力への影響も大きいので、重みの加算値に乗じる。(符号が反映される必要はある)
    • 勾配降下法によれば変化率が大きいほど移動量を増やすべきらしいので、これも重みの加算値に乗じる。
    • 誤差が大きいほど移動量を増やすべきなので、これも重みの加算値に乗じる。
  • なので全部掛け合わせるだけ。なお、正負は移動方向と一致してくれる。


試験

概要

ML構成 ... { 入力層のニューロン数 , 隠れ層のニューロン数 , 隠れ層のニューロン数 , 隠れ層のニューロン数 , ... , 出力層のニューロン数 }

テスト0001 - 全加算器

ML構成:{ 2, 3, 2 }
入力データ:
 A (0, 1) を2進化した1ビットを (0, 1) 信号として
 B (0, 1) を2進化した1ビットを (0, 1) 信号として
 合計2ビット
出力データ:
 A + B を2進化した2ビットを (0, 1) 信号として
学習データ:(0, 0), (0, 1), (1, 0), (1, 1) 4通り
検証データ:学習データと同じ

テスト0002 - 3ビット加算器

ML構成:{ 6, 7, 7, 7, 4 }
入力データ:
 A (0 ~ 7) を2進化した3ビットを (0, 1) 信号として
 B (0 ~ 7) を2進化した3ビットを (0, 1) 信号として
 合計6ビット
出力データ:
 A + B を2進化した4ビットを (0, 1) 信号として
学習データ:(0 ~ 7), (0 ~ 7) 64通り
検証データ:学習データと同じ

テスト0003 - 5ビット加算器

ML構成:{ 10, 20, 20, 20, 6 }
入力データ:
 A (0 ~ 31) を2進化した5ビットを (0, 1) 信号として
 B (0 ~ 31) を2進化した5ビットを (0, 1) 信号として
 合計10ビット
出力データ:
 A + B を2進化した6ビットを (0, 1) 信号として
学習データ:(0 ~ 31), (0 ~ 31) 1024通り
検証データ:学習データと同じ

テスト0004 - FizzBuzz

ML構成:{ 10, 30, 30, 30, 4 }
入力データ:
  (1 ~ 1023) を2進化した10ビットを (0, 1) 信号として
出力データ:
 数値を出力すべきとき = (1, 0, 0, 0)
 Fizzを出力すべきとき = (0, 1, 0, 0)
 Buzzを出力すべきとき = (0, 0, 1, 0)
 FizzBuzzを出力すべきとき = (0, 0, 0, 1)
学習データ:101 ~ 1023
検証データ:1 ~ 100

結果

// 2019/10/28 再実施

テスト0001 - 全加算器 - 正答率
テストNo. \ 学習回数 0 1000 2000 3000 4000 5000 6000 7000 8000 9000 10000
1 0.250 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
2 0.500 0.500 0.500 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000
3 0.250 0.500 0.500 0.500 0.500 1.000 1.000 1.000 1.000 1.000 1.000
4 0.250 0.750 0.750 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
5 0.250 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
6 0.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
7 0.250 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
8 0.250 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
9 0.500 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
10 0.250 0.500 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000


テスト0002 - 3ビット加算器 - 正答率
テストNo. \ 学習回数 0 30000 60000 90000 120000 150000 180000 210000 240000 270000 300000
1 0.109 0.781 0.844 0.781 0.938 1.000 1.000 1.000 1.000 1.000 1.000
2 0.047 0.578 0.750 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
3 0.078 0.625 0.859 0.938 0.984 0.984 0.984 1.000 1.000 1.000 1.000
4 0.016 0.328 0.578 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
5 0.016 0.531 0.641 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
6 0.016 0.656 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
7 0.109 0.328 0.563 0.906 0.984 1.000 1.000 1.000 1.000 1.000 1.000
8 0.078 0.422 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
9 0.094 0.344 0.469 0.766 0.906 0.984 0.984 0.984 0.984 1.000 1.000
10 0.047 0.359 0.656 0.938 1.000 0.984 1.000 1.000 1.000 1.000 1.000


テスト0003 - 5ビット加算器 - 正答率
テストNo. \ 学習回数 0 30000 60000 90000 120000 150000 180000 210000 240000 270000 300000
1 0.020 0.103 0.289 0.368 0.900 1.000 1.000 1.000 1.000 1.000 1.000
2 0.006 0.164 0.275 0.879 0.990 0.998 1.000 1.000 1.000 1.000 1.000
3 0.013 0.082 0.996 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
4 0.006 0.174 0.371 0.972 0.998 1.000 1.000 1.000 1.000 1.000 1.000
5 0.027 0.153 0.218 0.652 0.732 0.934 0.982 1.000 1.000 1.000 1.000
6 0.019 0.316 0.630 0.671 0.957 0.993 1.000 1.000 1.000 1.000 1.000
7 0.018 0.127 0.533 0.629 1.000 1.000 1.000 1.000 1.000 1.000 1.000
8 0.027 0.104 0.578 0.876 0.989 1.000 1.000 1.000 1.000 1.000 1.000
9 0.010 0.155 0.273 0.346 0.325 0.657 0.824 0.951 1.000 1.000 1.000
10 0.001 0.143 0.272 0.988 1.000 1.000 1.000 1.000 1.000 1.000 1.000


テスト0004 - FizzBuzz - 正答率
テストNo. \ 学習回数 0 100000 200000 300000 400000 500000 600000 700000 800000 900000 1000000
1 0.000 0.530 0.000 0.000 0.550 0.980 0.990 0.990 0.990 0.990 0.990
2 0.000 0.000 0.000 0.530 0.530 0.940 0.960 0.970 0.980 0.980 0.970
3 0.010 0.530 0.530 0.370 0.820 0.950 1.000 1.000 1.000 1.000 1.000
4 0.000 0.530 0.040 0.870 1.000 1.000 1.000 1.000 1.000 1.000 1.000
5 0.530 0.530 0.530 0.000 0.890 0.960 0.990 0.990 0.990 0.990 0.990
6 0.000 0.530 0.530 0.430 0.860 0.930 0.940 0.960 0.970 0.970 0.970
7 0.060 0.530 0.250 0.880 0.970 0.970 0.970 0.970 0.970 0.970 0.970
8 0.000 0.000 0.260 0.520 0.950 0.960 0.970 0.980 0.980 0.980 0.980
9 0.270 0.530 0.530 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000
10 0.000 0.110 0.730 0.920 0.910 0.920 0.920 0.920 0.920 0.920 0.930



テスト0004 - FizzBuzz で「教えていない入力と出力」について 1000000 回の学習で 0.9 以上の正答率を出しているので少なくともある程度は正しく実装できているのかなと思います。
全て 1.0 にならないのは、式が間違っているからなのか、そういうものなのでしょうか。。。
あと ML構成、学習係数 をいろいろいじるべきなのかもしれません。