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

えどふの技術記事

社会の底辺が書きます

C#で実装するニューラルネットワーク その1

はじめに

 昨今のディープラーニングブームによりニューラルネットワークが再び脚光を浴びています. ディープラーニングを始めるために必要なライブラリ等は探せばいくらでもありますが, 基礎の技術であるニューラルネットワークおよび誤差逆伝播法の理解があるに越したことはないと思います. ところがWEB上で手に入る資料は数式だけの羅列や難しい解説が当たり前なことも事実です. そこで, この記事ではニューラルネットワーク誤差逆伝播法の本当に基礎的な部分から実装までを扱いたいと思います.

人工知能は一筋縄ではいかない

 この章ではざっくりとした歴史的背景に触れますので, 読み飛ばしても構わないです.
 ニューラルネットワークは脳を模した人工知能として登場し, それなりの結果を出力することから, 注目を集めました. ところが, かの有名なXOR問題がMinskyらによって発表されると一気に下火となります. それでも地道な研究が続き, 後の誤差逆伝播法により再び注目を集め, ブームが起きます. ちょうど1990年頃です. ところがまた下火になります. その中でもHintonらは地道に研究を続け今日のブレークスルーを引き起こすことになったのです. 人工知能は10年ごとにブームが来ると言われています. 地道に研究が続けられ, たまに画期的な発見とブームが到来する傾向があるからです. また, 人工知能は技術的に分野が細分化しやすい傾向もあり, 一般の人が想像するようなドラえもんのような人工知能はちょっと飛躍しすぎている感があるようにも思えます. また, のちに説明する誤差逆伝播法も脳科学的には全く自然で再現しない事象ですので, ニューラルネットワークといえど, 完全に脳の機能を再現している, というのは全くのウソです.

ニューラルネットワークの基礎

 ニューラルネットワーク(以下, NN)は文字認識などパターン認識の道具です. 最初期のNNは単純パーセプトロンと呼ばれています. 神経細胞であるニューロンが, 学習により他のニューロンとの結合度合を調整することで機能します.
(参考:弘前大学電子情報システム工学科,ZARU NUERO

単純パーセプトロン

 単純パーセプトロンにおいては, 図1に示す通り入力層, 中間層, 出力層の3層それぞれにニューロンが配置され, 互いに結合し結合荷重を付加することによって実現されます. たとえば, 「8×8ピクセルの画像が, 数字の0~9のどれに当てはまるか」を問題とした場合, 入力パタンベクトルは画素(x, y) = (0, 0)から(x, y) = (7, 7)までの64個のニューロンが必要になります. 出力パタンベクトルは0~9の値を表わす10個のニューロンが必要です. 中間層のニューロン数は任意で, 実験的にふさわしい値を導くことが普通です.
f:id:edofrank:20150511111011p:plain

図1. 単純パーセプトロンの構成

単純パーセプトロンの中身を覗く


f:id:edofrank:20150511114301p:plain:w200
図2. ニューロンの中身

 図1の○がニューロンに相当しますが, これらの働きのおかげで, それっぽい結果が作られます. それぞれのニューロンは, 受け取ったニューロンの入力値を各々のニューロンによって処理して出力し, 次の層のニューロンの入力に与える機能を持っています. パーセプトロンの模式図は図2にのようになります. 図2において前の層のすべての出力値とそれぞれの結合荷重(これらはベクトルと見なせるから, それぞれ出力ベクトルと結合荷重ベクトルと呼びます)の内積を取ったものに閾値を引いたものが出力Xです.
 分かりやすく言うと, 前の層のすべてのニューロンから伸びる結合に, 任意の重みを掛け合わせて全部足したものから, 閾値を引いたものが結果として次の層のすべてのニューロンに渡されます.
これを数式で表すと次のようになります.


f:id:edofrank:20150511151956j:plain
なお, uはステップ関数を活性化関数として処理されます.

f:id:edofrank:20150511152042j:plain

単純パーセプトロンの学習パターン

 次に, 単純パーセプトロンによる学習パターンについて原理を説明します. 単純パーセプトロンによる学習は誤り訂正方式, すなわち, 入力パターンすべてに対して正しい信号(教師信号)が与えられ, 出力と教師信号の誤差によってネットワークのパラメータを調整する方式がとられます. 説明にあたって, 次のように取り決めをします.


f:id:edofrank:20150511152621j:plain:w240
 では, 学習の一連の流れを見ていきましょう. 学習の初めに, ネットワークを初期化します. 具体的には, w^(I,M),w^(M,O) を乱数(0.0~1.0)を用いて初期化します.
 次に, 入力パターンベクトルの要素数をm, 中間層のニューロン数をnとしたとき, P個の入力パターンベクトルの中の一つの入力パターンベクトルについて中間層の出力として次の結果を求めます.

f:id:edofrank:20150511152955j:plain:h120
得られた中間層のニューロンの出力ベクトルより, 出力層のニューロンの出力は次のようになります.

f:id:edofrank:20150511153148j:plain:h80
次に, 得られた出力値 O_p^O と教師信号 t_p^O との誤差t_p^O-O_p^Oを用いて, 荷重ベクトルの修正量を

f:id:edofrank:20150511153250j:plain:h80
として与えます. 以上の学習をすべての入力パターンについて反復学習し, M-O結合荷重の変化量が所定以下になれば終了する, という過程を踏みます.

誤差逆伝播

 上記に示した単純パーセプトロンによる学習では中間層と出力層の間の結合荷重のみを更新しました. しかし単純パーセプトロンの収束定理に示されるように, そのままでは線形分離不可能な問題に対処することができないことが分かっています(XOR問題). そこで, 今回は誤差逆伝播法を用いることで線形分離不可能な問題を解くことが出来るようにします. 原理を示すにあたって, 冗長的な数式を書くと分かりにくくなってしまうので, 一般化デルタ則により学習則を一般化しておきます. つまり, あるニューロンjの入力u_jは結合荷重w_(i,j)とニューロンiからの入力O_iをかけたものとして与えられ, ニューロンjの出力O_jは入力をシグモイド関数で変換したものとして表わすこととします. 数式で表すと次の通りです.


f:id:edofrank:20150511153751j:plain:h150
なお, 誤差逆伝播法ではステップ関数でなくシグモイド関数を用います. シグモイド関数は一回微分可能だからです(結合荷重の修正量を求める際に偏微分が必要になります). 誤差逆伝播法では, 誤差の二乗和を最小にする最小 2 乗法を用います. すなわち, 誤差関数Eは

f:id:edofrank:20150511154338j:plain:h80
として表わされ, 結合荷重の修正量は

f:id:edofrank:20150511154522j:plain
となります. 上の式が示すように, 誤差逆伝播法では誤差関数Eの最小となる値を最急降下法により求めます. 誤差が最小となる値を数値解析的に求める手法です.
(参考:http://www.neuro.sfc.keio.ac.jp/~masato/study/SVM/grad.htm

NNの実装

 数式だけ与えられても, 実際にプログラムに書き起こすとなると少々頭を使います. 論より実践, 早速NNを実装していきましょう. 今回は特にライブラリなどを用いず, 最適化を無視してごり押しします. 本当はpythonのライブラリを駆使して数十行で済ますこともできますが, それはまた後ほど.

土台クラス

NNの実装にあたり, 次のabstractクラスを継承させます.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NeuralNetworkPrototype
{
    abstract class NeuralNetworkBase
    {
        public int count_train;
        public int count_test;
        public int count_unit_in;
        public int count_unit_hidden;
        public int count_unit_out;
        public double alpha;
        public double eta;
        public double[,] output_in;
        public double[] output_hidden;
        public double[] output_out;
        public double[,] weight_in_to_hidden;
        public double[,] weight_hidden_to_out;
        public double[,] weight_modify_in_to_hidden;
        public double[,] weight__modify_hidden_to_out;
        public double[] bias_hidden;
        public double[] bias_out;
        public double[] bias_modify_hidden;
        public double[] bias_modify_out;
        public double[,] teacher_signal;

        /// <summary>
        /// ネットワークの土台を構築する
        /// </summary>
        /// <param name="count_train">訓練用データの数</param>
        /// <param name="count_test">テスト用データの数</param>
        /// <param name="count_unit_in">入力層のユニット数</param>
        /// <param name="count_unit_hidden">隠れ層のユニット数</param>
        /// <param name="count_unit_out">出力層のユニット数</param>
        public NeuralNetworkBase(int count_train, int count_test, int count_unit_in, int count_unit_hidden, int count_unit_out, double alpha, double eta)
        {
            this.count_train = count_train;
            this.count_test = count_test;
            this.count_unit_in = count_unit_in;
            this.count_unit_hidden = count_unit_hidden;
            this.count_unit_out = count_unit_out;

            this.output_in = new double[count_train + count_test, count_unit_in];
            this.output_hidden = new double[count_unit_hidden];
            this.output_out = new double[count_unit_out];

            this.weight_in_to_hidden = new double[count_unit_hidden, count_unit_in];
            this.weight_hidden_to_out = new double[count_unit_out, count_unit_hidden];
            this.weight_modify_in_to_hidden = new double[count_unit_hidden, count_unit_in];
            this.weight__modify_hidden_to_out = new double[count_unit_out, count_unit_hidden];

            this.bias_hidden = new double[count_unit_hidden];
            this.bias_out = new double[count_unit_out];
            this.bias_modify_hidden = new double[count_unit_hidden];
            this.bias_modify_out = new double[count_unit_out];
            this.teacher_signal = new double[count_train + count_test, count_unit_out];

            this.alpha = alpha;
            this.eta = eta;
        }

        public abstract void Initialize();
        public abstract void ForwardPropagation(int dataIndex);
        public abstract void BackPropagation(int dataIndex);
    }
}

NNのロジック実装

 先ほどのabstractクラスを継承し, NNのロジックを実装していきます.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NeuralNetworkPrototype
{
    class NeuralNetworkCore : NeuralNetworkBase
    {
        /// <summary>
        /// ネットワークの土台を構築する
        /// </summary>
        /// <param name="Y1">訓練用データの数</param>
        /// <param name="Y2">テスト用データの数</param>
        /// <param name="Y3">入力層のユニット数</param>
        /// <param name="Y4">隠れ層のユニット数</param>
        /// <param name="Y5">出力層のユニット数</param>
        public NeuralNetworkCore(int Y1, int Y2, int Y3, int Y4, int Y5, double Y6=0.8, double Y7=0.75) : base(Y1, Y2, Y3, Y4, Y5, alpha:Y6, eta:Y7) { }

        // ネットワークを初期化する
        public override void Initialize()
        {
            Random Rnd = new Random();

            // 入力層->隠れ層の重みを乱数で初期化
            for (int i = 0; i < count_unit_hidden; i++)
            {
                for (int j = 0; j < count_unit_in; j++)
                {
                    weight_in_to_hidden[i,j] = Math.Sign(Rnd.NextDouble() - 0.5) * Rnd.NextDouble();
                }
                bias_hidden[i] = Math.Sign(Rnd.NextDouble() - 0.5) * Rnd.NextDouble();
            }

            // 隠れ層→出力層の重みを乱数で初期化
            for (int i = 0; i < count_unit_out; i++)
            {
                for (int j = 0; j < count_unit_hidden; j++)
                {
                    weight_hidden_to_out[i, j] = Math.Sign(Rnd.NextDouble() - 0.5) * Rnd.NextDouble();
                }
                bias_out[i] = Math.Sign(Rnd.NextDouble() - 0.5) * Rnd.NextDouble();
            }
        }

        /// <summary>
        /// 入力層->隠れ層の信号伝播
        /// </summary>
        /// <param name="dataIndex">訓練データのインデックス</param>
        public override void ForwardPropagation(int dataIndex)
        {
            double sum = 0.0;

            // 入力層->隠れ層への信号伝播
            for (int i = 0; i < count_unit_hidden; i++)
            {
                sum = 0.0;
                for (int j = 0; j < count_unit_in; j++)
                {
                    // 重みと入力層j番目のユニットの出力値をかけて足し合わせる
                    sum += weight_in_to_hidden[i, j] * output_in[dataIndex, j];
                }
                // バイアスを加えたsumを伝達関数に与えたものが隠れ層i番目のユニットの出力
                output_hidden[i] = sigmoid(sum + bias_hidden[i]);
            }

            // 隠れ層->出力層への信号伝播
            for (int i = 0; i < count_unit_out; i++)
            {
                sum = 0.0;
                for (int j = 0; j < count_unit_hidden; j++)
                {
                    sum += weight_hidden_to_out[i, j] * output_hidden[j];
                }
                output_out[i] = sigmoid(sum + bias_out[i]);
            }
        }

        /// <summary>
        /// 誤差逆伝播法を用いてネットワークを調整する
        /// </summary>
        /// <param name="dataIndex">訓練データのインデックス</param>
        public override void BackPropagation(int dataIndex) 
        {
            double sum = 0.0;
            double[] teacher_signal_out_to_hidden = new double[count_unit_out];
            double[] learn_signal_out_to_hidden = new double[count_unit_hidden];
            
            // 出力層の教師信号をすべてのユニットについて求める
            for (int i = 0; i < count_unit_out; i++)
            {
                teacher_signal_out_to_hidden[i] = (teacher_signal[dataIndex, i] - output_out[i]) * output_out[i] * (1.0 - output_out[i]);
            }

            // 出力層->隠れ層の重みの変化量を求める
            for (int i = 0; i < count_unit_hidden; i++)
            {
                sum = 0.0;
                for (int j = 0; j < count_unit_out; j++)
                {
                    weight__modify_hidden_to_out[j, i] = eta * teacher_signal_out_to_hidden[j] * output_hidden[i] + alpha * weight__modify_hidden_to_out[j, i];
                    weight_hidden_to_out[j, i] += weight__modify_hidden_to_out[j, i];
                    sum += teacher_signal_out_to_hidden[j] * weight_hidden_to_out[j, i];
                }
                // シグモイド関数の1次微分と掛け合わせる
                learn_signal_out_to_hidden[i] = output_hidden[i] * (1 - output_hidden[i]) * sum;
            }

            // 出力層のバイアスの変化量を求める
            for (int i = 0; i < count_unit_out; i++)
            {
                bias_modify_out[i] = eta * teacher_signal_out_to_hidden[i] + alpha * bias_modify_out[i];
                bias_out[i] += bias_modify_out[i];
            }

            // 隠れ層->入力層の重みの変化量を求める
            for (int i = 0; i < count_unit_in; i++)
            {
                for (int j = 0; j < count_unit_hidden; j++)
                {
                    weight_modify_in_to_hidden[j, i] = eta * learn_signal_out_to_hidden[j] * output_in[dataIndex, i] + alpha * weight_modify_in_to_hidden[j, i];
                    weight_in_to_hidden[j, i] += weight_modify_in_to_hidden[j, i];
                }
            }

            // 隠れ層のバイアスの変化量を求める
            for (int i = 0; i < count_unit_hidden; i++)
            {
                bias_modify_hidden[i] = eta * learn_signal_out_to_hidden[i] + alpha * bias_modify_hidden[i];
                bias_hidden[i] += bias_modify_hidden[i];
            }
        }

        public double sigmoid(double x)
        {
            return 1.0 / (1.0 + Math.Exp(-x));
        }

        public double tanh(double x) 
        {
            return Math.Tanh(x);
        }
    }
}

NNの動作を確認する

 それでは, 有名なXOR問題を例に動作を確認してみましょう. XOR問題は次の記事を参考にしてください. www.cs.ce.nihon-u.ac.jp
XORの学習を実行するソースコードは次の通りです.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NeuralNetworkPrototype
{

    class TestCase
    {
        static void Main(string[] args) 
        {
            XOR();
        }

        static void XOR()
        {
            NeuralNetworkCore core = new NeuralNetworkCore(4, 4, 2, 2, 1);

            // 訓練用XORデータ入力+出力(教師)
            core.output_in[0, 0] = 0.0; core.output_in[0, 1] = 0.0; core.teacher_signal[0, 0] = 0.0;
            core.output_in[1, 0] = 1.0; core.output_in[1, 1] = 0.0; core.teacher_signal[1, 0] = 1.0;
            core.output_in[2, 0] = 0.0; core.output_in[2, 1] = 1.0; core.teacher_signal[2, 0] = 1.0;
            core.output_in[3, 0] = 1.0; core.output_in[3, 1] = 1.0; core.teacher_signal[3, 0] = 0.0;

            // テスト用XOR入力
            core.output_in[4, 0] = 0.0; core.output_in[4, 1] = 0.0;
            core.output_in[5, 0] = 1.0; core.output_in[5, 1] = 0.0;
            core.output_in[6, 0] = 0.0; core.output_in[6, 1] = 1.0;
            core.output_in[7, 0] = 1.0; core.output_in[7, 1] = 1.0;

            // ニューラルネットワークを初期化
            core.Initialize();

            // 信号伝播と誤差逆伝播を10000回行う
            for (int i = 0; i < 10000; i++)
            {
                for (int j = 0; j < core.count_train; j++)
                {
                    core.ForwardPropagation(j);
                    core.BackPropagation(j);
                }
            }

            // 訓練データを信号伝播にかけて出力する
            for (int j = 0; j < core.count_test; j++)
            {
                core.ForwardPropagation(core.count_train + j);
                for (int k = 0; k < core.count_unit_in; k++)
                {
                    Console.Write(core.output_in[j, k]);
                    if (k != core.count_unit_in - 1) Console.Write(", ");
                }
                Console.WriteLine();
                for (int k = 0; k < core.count_unit_out; k++)
                {
                    Console.WriteLine(core.output_out[k]);
                }
                Console.WriteLine("-----------------------------");
            }

        }
    }
}

実行結果は, 実行のたびに微妙に変わりますが, 大体学習がうまくいっていることが確認できます.


f:id:edofrank:20150511160234j:plain:w300

NNの応用

 NNの応用として, 光学手書き文字認識などの画像のパターン認識は有名です. 要は, 入力がベクトルとして学習データが大量に与えられれば, どのような問題にも応用が利くわけです. 次回は, 実際に手書き文字認識を実装したいと思います.

参考文献
※注
  • abstractクラスで実装させる方法は微妙だったかも.
  • 初めてのはてぶ記法なので見にくくてすみません.