今回は久しぶりにプログラミング系の話です。ニューラルネットワークをC++で実装してみたんで、ニューラルネットワークの軽い解説とソースコードとを書いときます。
ニューラルネットワークは顔かどうかを判別する問題(検出問題)とか、映画の興行収入がどれくらいになりそうかみたいな問題(回帰問題)を解くときに使われるものになります。
機械学習に興味のある方であれば、ニューラルネットワークって名前とか、人間の脳の中にあるニューロンを模倣して云々かんぬんみたいな話を聞いたことがあるかと思います。今回はそれについてのお話です。
今回は3層ニューラルネットワークを作ってみました。とはいっても、今回は「とりあえずニューラルネットワークと呼べて動くものを作る」がメインなので、数学的な説明はほぼすべて省略していて、ソースコードも結構適当です。
が、ニューラルネットワークの具体例が欲しい!みたいな方には良いんじゃないでしょうか。
作ったニューラルネットワーク
今回作ったニューラルネットワークは下図(図1と図2)のような模式図で表されるニューラルネットワークになります。図1はバイアスを省略して描いた図(バイアスをしきい値として描いた図とも言えます)で、図2はバイアスを省略せずに描いた図になります。模式図の中の丸印は「ニューロン」とか「ノード」と呼ばれます。
これらの模式図(図1も図2も)は入力1と入力2にユーザーが適当な数値を入れてやると、隠れ1と隠れ2が計算されて出力が計算されるということを表しています。
ちなみに、この記事ではこのニューラルネットワークのことを、層が全部で3層あるので3層のニューラルネットワークと言っていますが、本によっては2層のニューラルネットワークと読んでいることもあるのでお気を付けくださいませ~。
実際に重み計算をするのは2層だけだから、実質2層が重要なのだと考えれば、2層のニューラルネットワークであるというとらえ方もできるわけです。
今回のニューラルネットワークの特徴
今回はニューラルネットワークには入力の0と1に対してAND演算の結果を出力するように学習させます。今回のニューラルネットワークでは、出力層に入力する値を計算するときの重みだけを学習させるので、線形分離可能な教師データでないと学習がいつまでたっても終わりません。つまり、XOR(排他的論理和)なんかは永遠に学習が終わらないってわけですな。
線形分離可能かどうかを判断するときは、入力1を横軸に、入力2を縦軸にとって、その入力に対応する出力を座標系に書き込んでいきましょう。そのときに、出力を1本の直線で出力を2つに分けることができれば線形分離可能な教師データということになって、1本の直線ではどうやっても出力値を二分できない場合は線形分離不可能な教師データということになります。
で、学習させるときには本当は損失関数を使った方が良いんでしょうが、そんなに難しい学習をさせるわけでもないので、出力値と教師データとの誤差を計算して、その誤差分だけ重みを変化させるという方式を採っています。
ただし、前層からの重みをが大きければ大きいほど変化量は大きくなるようにしています。重みが大きいということは、より誤差に与える影響が高いと考えられるのでそうしています。最終的な目標は出力値と教師データとの誤差を最小化することですので。
ニューラルネットワークの実装(ソースコード)
下のソースコードをwindows10 pro、mingw64、gnu g++8.3.0という環境でコンパイルして実行しました。
/********************************
* main.cpp
* 出力層ニューロンに対する重みのみを修正してAND演算を学習するプログラム
*
* 教師データの配列をいじればAND以外についても学習可能。
* ただし、出力層に対する重みしか修正しないので、
* 線型分離可能な条件でないと、学習が進まない。
*
* 出力にはシグモイド関数を使用
*
* 学習中の誤差の推移と入力に対する出力、最終的な学習結果を出力
*********************************/
#include <iostream>
#include <iomanip>
#include <cmath>
#include <random>
void WeightInit(double *, int); // 配列の最初の要素のポインタ, 次層のニューロン数(次層のニューロン数分だけループして初期化する)
double Sigmoid(double); // 引数に対するシグモイド関数を返り値として返す
void Forward(double *, double *, int, int, double *); // 順方向計算(重み付き和の計算) 入力するベクトル, 重み行列, 前層のニューロン数, 次層のニューロン数, 結果を格納する配列
void Output(double *, double *, double *, int, int); // 一つ目のdouble型配列に
// メルセンヌ・ツイスター法による擬似乱数生成器を、
// ハードウェア乱数をシードにして初期化
std::random_device seed_gen;
std::mt19937 engine(seed_gen());
int main()
{
const int INPUT_NUM = 2; // 入力層のニューロン数
const int HIDDEN_NUM = 2; // 隠れ層のニューロン数
const int OUTPUT_NUM = 1; // 出力層のニューロン数
const int ERR_INIT = 100; // 誤差の初期値
const int TRANING_NUM = 4; // 教師データの数
const double ALPHA = 0.8; // 学習率
const double LIMIT_ERR = 0.01; // 学習が収束したと判断する数値(LIMIT_ERR以下なら学習は終了)
const int LIMIT_CALC = 1000000; // 計算の最大数(学習が収束しない場合に強制終了するループ数)
const int DISPLAY_WIDTH = 10; // 1つの数値を表示する幅
const int ACCURACY = 4; // 数値を表示するときの精度
const int DISPLAY_LOOP = 5000; // 入力値や出力値を表示するタイミング
int i, j, k;
int learn_counter = 0; // 学習用のループ回数を格納するための変数
double differencial_value = 0; // 微分値を格納するための変数
double error_whole = ERR_INIT; // 教師データすべての誤差の和
double error_part = 0; // ある入力に対する出力と教師データとの誤差
double input[INPUT_NUM + 1]; // 入力層の数値を格納する配列
double hidden[HIDDEN_NUM + 1]; // 隠れ層の数値を格納する配列
double output; // 出力層の数値を格納する配列
double w0[INPUT_NUM + 1][HIDDEN_NUM]; // w0:入力層の出力に作用する重み行列
double w1[HIDDEN_NUM + 1][OUTPUT_NUM]; // w1:隠れ層の出力に作用する重み行列
double correct[TRANING_NUM][INPUT_NUM + OUTPUT_NUM] = {
{0.0, 0.0, 0.0},
{0.0, 1.0, 0.0},
{1.0, 0.0, 0.0},
{1.0, 1.0, 1.0},
};// 学習データセット行列
// 入力層、隠れ層、出力層の各数値を初期化
for(i = 0; i < INPUT_NUM; i++){
input[i] = 0;
}
input[INPUT_NUM] = -1; // バイアス
for(i = 0; i < HIDDEN_NUM; i++){
hidden[i] = 0;
}
hidden[HIDDEN_NUM] = -1; // バイアス
output = 0;
// 重みの初期化
for(i = 0; i < INPUT_NUM + 1; i++){
WeightInit(w0[i], HIDDEN_NUM);
}
for(i = 0; i < HIDDEN_NUM + 1; i++){
WeightInit(w1[i], OUTPUT_NUM);
}
// 重み(入力層から隠れ層)の表示
std::cout << "重み1" << std::endl;
for(i = 0; i < INPUT_NUM + 1; i++){
for(j = 0; j < HIDDEN_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< w0[i][j];
}
std::cout << std::endl;
}
std::cout << std::endl;
// 重み(隠れ層から出力層)の表示
std::cout << "重み2" << std::endl;
for(i = 0; i < HIDDEN_NUM + 1; i++){
for(j = 0; j < OUTPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< w1[i][j];
}
std::cout << std::endl;
}
std::cout << std::endl;
// 教師データの表示
std::cout << "教師データ" << std::endl;
for(i = 0; i < TRANING_NUM; i++){
for(j = 0; j < INPUT_NUM + OUTPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< correct[i][j];
}
std::cout << std::endl;
}
std::cout << std::endl;
// 学習
std::cout << "学習ループ開始" << std::endl;
std::cout << std::endl;
while(error_whole > LIMIT_ERR){
error_whole = 0; // 全体の誤差を0にしておく
// 教師データの数分だけループ
for(i = 0; i < TRANING_NUM; i++){
// 入力
for(j = 0; j < INPUT_NUM; j++){
input[j] = correct[i][j];
}
// 入力値の表示
/* std::cout << "入力データ" << std::endl;
for(j = 0; j < INPUT_NUM + 1; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< input[j];
}
std::cout << std::endl;
*/
// 順方向計算
// 入力層から隠れ層
Forward(&input[0], &w0[0][0], INPUT_NUM + 1, HIDDEN_NUM, &hidden[0]);
// 隠れ層の数値を表示
/* std::cout << "隠れ層の数値" << std::endl;
for(j = 0; j < HIDDEN_NUM + 1; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< hidden[j];
}
std::cout << std::endl;
*/
// 隠れ層から出力層
Forward(&hidden[0], &w1[0][0], HIDDEN_NUM + 1, OUTPUT_NUM, &output);
// 入力とそれに対する出力の表示
if(!(learn_counter % DISPLAY_LOOP)){
std::cout << "入力と出力" << std::endl;
for(j = 0; j < INPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< input[j];
}
for(j = 0; j < OUTPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< output;
}
std::cout << std::endl;
}
// 各入力に対して重みを調整
error_part = correct[i][INPUT_NUM] - output;
differencial_value = output * (1 - output);
for(j = 0; j < HIDDEN_NUM + 1; j++){
for(k = 0; k < OUTPUT_NUM; k++){
w1[j][k] += ALPHA * hidden[j] * error_part * differencial_value;
}
}
// 各入力に対しての正解と出力の差を足す(プラスとマイナスで打ち消し合わないようにするために、2乗した数値を足しわせる)
error_whole += error_part * error_part;
}
if(!(learn_counter % DISPLAY_LOOP))
std::cout << "error_whole = " << error_whole << "(" << learn_counter << "回目)" << std::endl;
learn_counter++;
if(learn_counter > LIMIT_CALC) break;
}
// 学習結果の表示
std::cout << std::endl;
std::cout << "学習結果" << std::endl;
std::cout << std::endl;
// 重み(入力層から隠れ層)の表示
std::cout << "重み1" << std::endl;
for(i = 0; i < INPUT_NUM + 1; i++){
for(j = 0; j < HIDDEN_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< w0[i][j];
}
std::cout << std::endl;
}
std::cout << std::endl;
// 重み(隠れ層から出力層)の表示
std::cout << "重み2" << std::endl;
for(i = 0; i < HIDDEN_NUM + 1; i++){
for(j = 0; j < OUTPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< w1[i][j];
}
std::cout << std::endl;
}
std::cout << std::endl;
// 教師データの数分だけループ
for(i = 0; i < TRANING_NUM; i++){
// 入力
for(j = 0; j < INPUT_NUM; j++){
input[j] = correct[i][j];
}
// 順方向計算
// 入力層から隠れ層
Forward(&input[0], &w0[0][0], INPUT_NUM + 1, HIDDEN_NUM, &hidden[0]);
Forward(&hidden[0], &w1[0][0], HIDDEN_NUM + 1, OUTPUT_NUM, &output);
// 入力とそれに対する出力の表示
std::cout << "入力と出力" << std::endl;
for(j = 0; j < INPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< input[j];
}
for(j = 0; j < OUTPUT_NUM; j++){
std::cout << std::setw(DISPLAY_WIDTH)
<< std::setprecision(ACCURACY)
<< std::fixed
<< output;
}
std::cout << std::endl;
}
}
void Forward(double *prev_neuron, double *weight, int prev_num, int next_num, double *result)
{
int i, j;
for(i = 0; i < next_num; i++){
result[i] = 0;
}
for(i = 0; i < next_num; i++){
for(j = 0; j < prev_num; j++){
result[i] += weight[i + j * next_num] * prev_neuron[j];
}
}
// シグモイド関数の適用
for(i = 0; i < next_num; i++){
result[i] = Sigmoid(result[i]);
}
}
void WeightInit(double *arr, int prev_num)
{
// 正規分布
// 平均0.0、標準偏差0.7071で分布させる
std::normal_distribution<> GDist(0.0, 0.7071);
for(int j = 0; j < prev_num; j++){
arr[j] = GDist(engine);
}
}
double Sigmoid(double f)
{
return (1.0 / (1.0 + exp(-1.0 * f)) );
}
ちなみに、実行例はこんな感じ
重み1
0.2928 0.1595
0.2924 1.7956
1.0389 0.1032
重み2
-0.1542
-0.3058
-0.4716
教師データ
0.0000 0.0000 0.0000
0.0000 1.0000 0.0000
1.0000 0.0000 0.0000
1.0000 1.0000 1.0000
学習ループ開始
入力と出力
0.0000 0.0000 0.5711
入力と出力
0.0000 1.0000 0.4994
入力と出力
1.0000 0.0000 0.4907
入力と出力
1.0000 1.0000 0.4107
error_whole = 1.1635(0回目)
…
…
…(学習中の入力に対する出力と誤差が表示される)
入力と出力
0.0000 0.0000 0.0000
入力と出力
0.0000 1.0000 0.0715
入力と出力
1.0000 0.0000 0.0029
入力と出力
1.0000 1.0000 0.9293
error_whole = 0.0101(80000回目)
学習結果
重み1
0.2928 0.1595
0.2924 1.7956
1.0389 0.1032
重み2
74.1898
9.9234
34.8083
入力と出力
0.0000 0.0000 0.0000
入力と出力
0.0000 1.0000 0.0711
入力と出力
1.0000 0.0000 0.0029
入力と出力
1.0000 1.0000 0.9301
重み1ってのが入力層から隠れ層への重みで、重み2が隠れ層から出力層への重みになります。今回は、この重み2の方を学習させました。教師データの下4行に出力されてるのが、左から順に入力1、入力2、望む出力になっています。ちゃんとANDを表現した教師データになっていますね。
その下の学習ループ開始から重み2を調整するためのループが始まって、学習過程での出力が表示されています。入力と出力の下の1行が、入力に対するニューラルネットワークの出力になっていて、左から順に入力1、入力2、ニューラルネットワークの出力という風になっています。
error_wholeというのは、入力に対するニューラルネットワークの出力と教師データとの誤差を2乗して足し合わせたものになります。そのとき、4パターンの入力すべてについて誤差の2乗を足し合わせています。目標はこのerror_wholeを限りなく0に近づけることになります。
この実行結果を見てやると最初はめちゃくちゃだった出力も学習が進むごとにちゃんとAND回路っぽい出力になっていってて、学習が進んでいってるなーって感じがしますねー。この出力を0.5未満は0、0.5以上は1っていう風に加工してやればちゃんとしたAND回路になるんですが、面倒だったので、そこまではやりませんでした。
ちなみに、色々とパラメータをいじって遊ぶ場合は、教師データの配列とか学習率、隠れ層のニューロンの数をいじってやると色々と楽しいかと思います。
筆者はこのプログラムによる責任は一切負いませんが、普通にコンパイルして遊ぶくらいであればそんなに大きな問題は出ないかと思いますので、適当にコンパイルして遊んでくださいませ~。