August 2017
Volume 32 Number 8
テストの実行 - C# を使ったディープ ニューラル ネットワークの入出力
データを使って予測を行うような、機械学習における最近の進化の多くは、ディープ ニューラル ネットワーク (DNN) を使用して実現されています。例として、Microsoft Cortana および Apple Siri の音声認識や、自動走行車を可能にする画像認識などがあります。
ディープ ニューラル ネットワーク (DNN) は一般的な用語で、固有のバリエーションがいくつかあります。再帰型ニューラル ネットワーク (RNN) や畳み込みニューラル ネットワーク (CNN) などがその例です。今回説明する最も基本的な形式の DNN は、特別な名前はなく、いわゆる DNN です。
DNN を紹介するにあたって、DNN に関する資料を理解できるように、具体的なデモ プログラムで実験します。提示するコードを運用システムで直接使用することはできませんが、今回の説明に従えば、運用システムを作成するために拡張できるようになります。DNN を実装する予定がなくても、それ自体のしくみの説明には興味をもっていただけると思います。
DNN を図にするとわかりやすくなります。図 1 をご覧ください。このディープ ネットワークには、左側に値が "1.0" と "2.0" の入力ノードが 2 つあります。右側には、値が "0.3269"、"0.3333"、"0.3398" の出力ノードが 3 つあります。DNN は、一般に 2 つ以上の数値入力を受け取り、1 つ以上の数値出力を返す複雑な数学関数と考えることができます。
図 1 基本的なディープ ニューラル ネットワーク
図の DNN は、年齢と収入を基に個人の支持政党 (民主党、共和党、その他) を予測するという問題に対応しています。年齢と収入は、入力値からなんらかの方法でスケールが変換されます。民主党支持を (1,0,0)、共和党支持を (0,1,0)、その他を (0,0,1) とコード化しているとすると、図 1 の DNN では、最後の出力値 (0.3398) が最も大きい値になるため、年齢が 1.0 で収入が 2.0 の人はその他の政党を支持していると予測されます。
通常のニューラル ネットワークには、処理ノードの隠し層が 1 つあります。DNN には隠し層が 2 つ以上あり、予測が非常に難しい問題を扱うことができます。また、RNN や CNN などの特別な種類の DNN には複数層の処理ノードがありますが、ノードの接続アーキテクチャがもっと複雑になります。
図 1 の DNN には処理ノードの隠し層が 3 つあります。1 つ目の隠し層には 4 つノードがあり、2 つ目と 3 つ目の隠し層にはそれぞれ 2 つのノードがあります。左から右を指すそれぞれ長い矢印は重みと呼ばれる数値定数を表します。図の最上部にあるノードにゼロから始めるインデックスを付けて [0] と表現すると、input[0] と hidden[0][0] (層 0、ノード 0) を結ぶ重みは値 0.01、input[1] と hidden[0][3] (層 0、ノード 3) を結ぶ重みは値 0.08 です。ノード間には 26 個の重み値があります。
8 つの隠しノードと 3 つの出力ノードにはそれぞれ、バイアスという数値定数を表す短い矢印があります。たとえば、hidden[2][0] のバイアス値は 0.33、output[1] のバイアス値は 0.36 です。この図では、すべての重み値とバイアス値をラベルとして付けているわけではありません。ただし、これらの値は 0.01 から 0.37 まで連続しているため、ラベルの付いていない重み値やバイアス値を簡単に判断できます。
ここからは、DNN の入出力メカニズムのしくみを説明し、その実装方法を示します。デモ プログラムは C# を使用してコーディングしていますが、必要に応じて、コードを Python や JavaScript などの別の言語にリファクタリングするのもそれほど難しくはありません。デモ プログラムは非常に長いので、ここにすべてを掲載することはできませんが、完全なプログラムは付属のコードをダウンロードしてご覧いただけます。
デモ プログラム
今回の主旨を理解するには、図 2 のデモ プログラムのスクリーンショットを調べるのが一番です。デモは図 1 の DNN に対応しており、ネットワーク内の 13 個のノードの値を表示して入出力のメカニズムを示します。出力を生成するデモ コードは図 3 のコードから始まります。
図 2 基本ディープ ニューラル ネットワーク デモの実行
図 3 出力を生成するコードの冒頭
using System;
namespace DeepNetInputOutput
{
class DeepInputOutputProgram
{
static void Main(string[] args)
{
Console.WriteLine("Begin deep net IO demo");
Console.WriteLine("Creating a 2-(4-2-2)-3 deep network");
int numInput = 2;
int[] numHidden = new int[] { 4, 2, 2 };
int numOutput = 3;
DeepNet dn = new DeepNet(numInput, numHidden, numOutput);
デモ プログラムでは System 以外の名前空間は必要ないため、プレーンな C# のみを使用しています。各層のノード数をプログラム定義のクラス コンストラクター DeepNet に渡して、DNN を作成します。隠し層の数 3 は、numHidden 配列の項目数として暗黙のうちに渡しています。明示的に隠し層の数を渡す設計方法もあります。
26 個の重み値と 11 個のバイアス値は以下のように設定します。
int nw = DeepNet.NumWeights(numInput, numHidden, numOutput);
Console.WriteLine("Setting weights and biases to 0.01 to " +
(nw/100.0).ToString("F2") );
double[] wts = new double[nw];
for (int i = 0; i < wts.Length; ++i)
wts[i] = (i + 1) * 0.01;
dn.SetWeights(wts);
重み値とバイアス値の総数は静的クラス メソッド NumWeights を使用して計算します。図 1 を振り返ると、各ノードが層内のすべてのノードと接続されているため、重みの総数は (2*4) + (4*2) + (2*2) + (2*3) = 8 + 8 + 4 + 6 = 26 個になることがわかります。隠しノードと出力ノードのそれぞれにバイアスが 1 つずつあるため、バイアスの総数は 4 + 2 + 2 + 3 = 11 個になります。
wts という名前の配列は 37 個のセルを含むかたちでインスタンスを作成し、値に 0.01 から 0.37 を設定します。これらの値は、SetWeights メソッドを使用して DeepNet オブジェクトに挿入しています。デモではない実際の DNN では、重み値とバイアス値を、既知の入力値や既知の正しい出力値を含む一連のデータを使って決めることになります。これをネットワークのトレーニングといいます。最もよく使われるトレーニング アルゴリズムは「バックプロパゲーション (誤差逆伝搬法)」と呼ばれています。
デモ プログラムのメイン メソッドは以下のように終わります。
...
Console.WriteLine("Computing output for [1.0, 2.0] ");
double[] xValues = new double[] { 1.0, 2.0 };
dn.ComputeOutputs(xValues);
dn.Dump(false);
Console.WriteLine("End demo");
Console.ReadLine();
} // Main
} // Class Program
ComputeOutputs メソッドでは、入力値の配列を受け取ります。その後、後ほど説明する入出力メカニズムを使用して、出力ノードの値を計算して格納します。Dump ヘルパー メソッドでは 13 個のノードの値を表示します。ただし、引数が "false" の場合は 37 個の重み値とバイアス値は表示しません。
入出力メカニズム
DNN の入出力メカニズムは、具体例を使って説明するのが一番です。最初のステップでは、入力ノードの値を使って最初の隠し層のノードの値を計算します。最初の隠し層の一番上の隠しノードの値は以下のように計算します。
tanh( (1.0)(0.01) + (2.0)(0.05) + 0.27 ) =
tanh(0.38) = 0.3627
言葉で表現すると、「各入力ノードと関連する重みの積の合計を計算し、これにバイアス値を加算して、合計の双曲正接を求める」となります。 双曲正接 (tanh) を活性化関数と呼びます。tanh 関数は、負の無限大から正の無限大までの値を受け取り、-1.0 ~ +1.0 の値を返します。他にもロジスティック シグモイド関数や正規化線形 (ReLU) 関数などの重要な活性化関数がありますが、今回は取り上げません。
残りの隠し層にあるノードの値も、まったく同じ方法で計算します。たとえば、hidden [1][0] は以下のように計算します。
tanh( (0.3627)(0.09) + (0.3969)(0.11) + (0.4301)(0.13) + (0.4621)(0.15) + 0.31 ) =
tanh(0.5115) = 0.4711
hidden[2][0] の計算は以下のとおりです。
tanh( (0.4711)(0.17) + (0.4915)(0.19) + 0.33 ) =
tanh(0.5035) = 0.4649
出力ノードの値は、softmax (ソフトマックス) という別の活性化関数を使用して計算します。活性化前の積の合計にバイアスを加算するという準備ステップは同じです。
活性化前 output[0] =
(.4649)(0.21) + (0.4801)(0.24) + 0.35 =
0.5628
活性化前 output[1] =
(.4649)(0.22) + (0.4801)(0.25) + 0.36 =
0.5823
活性化前 output[2] =
(.4649)(0.23) + (0.4801)(0.26) + 0.37 =
0.6017
3 つの任意の値 x、y、z の softmax は以下のようになります。
softmax(x) = e^x / (e^x + e^y + e^z)
softmax(y) = e^y / (e^x + e^y + e^z)
softmax(z) = e^z / (e^x + e^y + e^z)
e はオイラー数で、約 2.718282 です。したがって、図 1 の DNN の場合、最終出力値は以下のようになります。
output[0] = e^0.5628 / (e^0.5628 + e^0.5823 + e^0.6017) = 0.3269
output[1] = e^0.5823 / (e^0.5628 + e^0.5823 + e^0.6017) = 0.3333
output[2] = e^0.6017 / (e^0.5628 + e^0.5823 + e^0.6017) = 0.3398
活性化関数 softmax の目的は出力値の合計が 1.0 になるようにすることです。これによって、出力値を確率として解釈して、カテゴリー値にマッピングします。今回の例では、3 番目の出力値が最も大きいため、カテゴリー値が (0,0,1) とコード化し、inputs = (1.0, 2.0) に対する予測カテゴリーとします。
DeepNet クラスの実装
デモ プログラムを作成するには、Visual Studio を起動し、C# コンソール アプリケーション テンプレートを選択して、「DeepNetInputOutput」という名前を付けます。今回は Visual Studio 2015 を使用しましたが、このデモは .NET との大きな依存関係がないため、どのバージョンの Visual Studio でも機能します。
テンプレート コードが読み込まれたら、ソリューション エクスプローラー ウィンドウで Program.cs ファイルを右クリックし、名前を「DeepNetInputOutputProgram.cs」というわかりやすい名前に変更します。Program クラスの名前が Visual Studio によって自動的に変更されます。エディター ウィンドウの上部で、不要な using ステートメントを、System 名前空間を参照するステートメントを除いてすべて削除します。
デモの DNN を「DeepNet」という名前のクラスとして実装します。クラスの定義は、以下のコードから始まります。
public class DeepNet
{
public static Random rnd;
public int nInput;
public int[] nHidden;
public int nOutput;
public int nLayers;
...
簡潔にするために、すべてのクラス メンバーをパブリック スコープで宣言します。DeepNet クラスでは rnd という静的 Random オブジェクト メンバーを使用し、重みとバイアスを小さな乱数値で初期化します (この値は後に 0.01 から 0.37 の値で上書きします)。メンバー nInput と nOuput は入力ノードと出力ノードの数です。配列メンバー hHidden は各隠し層のノード数を保持し、隠し層の数は配列の Length プロパティを使って求め、便宜上 nLayers メンバーに格納します。クラス定義は以下のように続きます。
public double[] iNodes;
public double [][] hNodes;
public double[] oNodes;
ディープ ニューラル ネットワークの実装には設計上多くの選択肢があります。配列メンバーの iNodes と oNodes は入力値と出力値を保持します。配列の配列のメンバー hNodes は隠しノードの値を保持します。1 つの配列の配列構造 nnNodes にすべてのノードを格納する設計もあります。この設計ではデモ nnNodes[0] が入力ノード値の配列で、nnNodes[4] が出力ノード値の配列になります。
ノード間の重みは以下のデータ構造を使って格納します。
public double[][] ihWeights;
public double[][][] hhWeights;
public double[][] hoWeights;
メンバー ihWeights は、最初の隠し層への入力重み値を保持する配列の配列スタイルの行列です。メンバー hoWeights は、最後の隠し層ノードと出力ノードを結ぶ重みを保持する配列の配列スタイルの行列です。メンバー hhWeights は、各セルが隠し層間の重み値を保持する配列の配列の行列を指す配列です。たとえば、hhWeights[0][3][1] は、隠し層 [0] の隠しノード [3] と隠し層 [0+1] の隠しノード [1] を結ぶ重み値を保持します。このデータ構造が DNN の入出力メカニズムの心臓部で、やや複雑です。データ構造の概念図を図 4 に示します。
図 4 重みとバイアスのデータ構造
最後の 2 つのクラス メンバーは、隠しノードのバイアスと出力ノードのバイアスを保持します。
public double[][] hBiases;
public double[] oBiases;
他の任意のソフトウェア システムと同様、DNN には他に多くのデータ構造の設計があるため、入出力コードを記述するときには、こうしたデータ構造の全体像が不可欠です。
重みとバイアスの数の計算
重み値とバイアス値を設定するには、重みとバイアスの数を把握する必要があります。デモ プログラムではこの数を計算して返すために、静的な NumWeights メソッドを実装しています。デモ ネットワーク 2-(4-2-2)-3 には (2*4) + (4*2) + (2*2) + (2*3) = 26 個の重みと 4 + 2 + 2 + 3 = 11 個のバイアスがあったことを思い出してください。メソッド NumWeights の中で重要なのは、入力層から隠し層、隠し層から隠し層、隠し層から出力層への重みを計算するコードで、以下のようになります。
int ihWts = numInput * numHidden[0];
int hhWts = 0;
for (int j = 0; j < numHidden.Length - 1; ++j) {
int rows = numHidden[j];
int cols = numHidden[j + 1];
hhWts += rows * cols;
}
int hoWts = numHidden[numHidden.Length - 1] * numOutput;
メソッド NumWeights のように重みとバイアスの総数を返すのではなく、2 つのセルの整数配列を用意して、重みとバイアスの数を個別に返す方法もあります。
重みとバイアスの設定
デモ以外の DNN では、通常、すべての重みとバイアスを小さな乱数値で初期化します。デモ プログラムではクラス メソッド SetWeights を使って 26 個の重みを 0.01 から 0.26 に設定し、バイアスを 0.27 から 0.37 に設定しています。このメソッド定義は、以下のコードから始まります。
public void SetWeights(double[] wts)
{
int nw = NumWeights(this.nInput, this.nHidden, this.nOutput);
if (wts.Length != nw)
throw new Exception("Bad wts[] length in SetWeights()");
int ptr = 0;
...
入力パラメーター wts は重みとバイアスの値を保持し、この wts が正しい Length を持つことを想定しています。変数 ptr は wts 配列を指します。デモ プログラムでは、中心となる考え方をできるだけを明確にしておくため、エラー チェックの大半を省略しています。入力層から最初の隠し層への重みは以下ように設定します。
for (int i = 0; i < nInput; ++i)
for (int j = 0; j < hNodes[0].Length; ++j)
ihWeights[i][j] = wts[ptr++];
次に、隠し層から隠し層への重みを以下のように設定します。
for (int h = 0; h < nLayers - 1; ++h)
for (int j = 0; j < nHidden[h]; ++j) // From
for (int jj = 0; jj < nHidden[h+1]; ++jj) // To
hhWeights[h][j][jj] = wts[ptr++];
多次元配列の操作に慣れていない場合は、インデックスの指定がやや奇妙に思えるかもしれません。そのためにも、重みとバイアスのデータ構造の全体像が不可欠になります。最後の隠し層から出力層への重みは以下のように設定します。
int hi = this.nLayers - 1;
for (int j = 0; j < this.nHidden[hi]; ++j)
for (int k = 0; k < this.nOutput; ++k)
hoWeights[j][k] = wts[ptr++];
このコードは、nLayers 個の隠し層 (デモでは 3) がある場合、最後の隠し層のインデックスが nLayers-1 になることを利用しています。メソッド SetWeights の最後で、隠しノードのバイアスと出力ノードのバイアスを設定します。
...
for (int h = 0; h < nLayers; ++h)
for (int j = 0; j < this.nHidden[h]; ++j)
hBiases[h][j] = wts[ptr++];
for (int k = 0; k < nOutput; ++k)
oBiases[k] = wts[ptr++];
}
出力値の計算
クラス メソッド ComputeOutputs の定義は、以下のコードから始まります。
public double[] ComputeOutputs(double[] xValues)
{
for (int i = 0; i < nInput; ++i)
iNodes[i] = xValues[i];
...
入力値は配列パラメーター xValues に設定されています。クラス メンバー nInput は入力ノード数を保持します。この変数は、クラス コンストラクターで設定しています。xValues の最初の nInput 値を入力ノードにコピーしているため、xValues には最初のセルに少なくとも nInput 値があると想定しています。次に、隠しノードと出力ノードの現在値をゼロクリアします。
for (int h = 0; h < nLayers; ++h)
for (int j = 0; j < nHidden[h]; ++j)
hNodes[h][j] = 0.0;
for (int k = 0; k < nOutput; ++k)
oNodes[k] = 0.0;
ゼロクリアするのは、積項の合計を隠しノードと出力ノードに直接累積するため、これらのノードはメソッドを呼び出すたびに明示的に 0.0 にリセットしなければならないという考えからです。hSums[][] や oSums[] のように名前付きのローカルな配列を宣言して使用する方法もあります。次に、最初の隠し層のノードの値を計算します。
for (int j = 0; j < nHidden[0]; ++j) {
for (int i = 0; i < nInput; ++i)
hNodes[0][j] += ihWeights[i][j] * iNodes[i];
hNodes[0][j] += hBiases[0][j]; // Add the bias
hNodes[0][j] = Math.Tanh(hNodes[0][j]); // Activation
}
コードは、前述の 1 対 1 のマッピングのメカニズムとほぼ同じです。組み込みの Math.Tanh を使用して、隠しノードの活性化を行います。前述のとおり、ロジスティック シグノイド関数や正規化線形 (ReLU) 関数もありますが、それらについては今後取り上げることにします。次に、残りの隠し層のノードを計算します。
for (int h = 1; h < nLayers; ++h) {
for (int j = 0; j < nHidden[h]; ++j) {
for (int jj = 0; jj < nHidden[h-1]; ++jj)
hNodes[h][j] += hhWeights[h-1][jj][j] * hNodes[h-1][jj];
hNodes[h][j] += hBiases[h][j];
hNodes[h][j] = Math.Tanh(hNodes[h][j]);
}
}
これはデモ プログラムの中でも最も分かりにくい部分です。それは、複数の配列インデックスが必要になるためです。続いて、活性化前の積の合計を出力ノード用に計算します。
for (int k = 0; k < nOutput; ++k) {
for (int j = 0; j < nHidden[nLayers - 1]; ++j)
oNodes[k] += hoWeights[j][k] * hNodes[nLayers - 1][j];
oNodes[k] += oBiases[k]; // Add bias
}
メソッド ComputeOutputs の最後では、活性化関数 softmax を適用し、計算した出力値を個別の配列で返します。
...
double[] retResult = Softmax(oNodes);
for (int k = 0; k < nOutput; ++k)
oNodes[k] = retResult[k];
return retResult;
}
メソッド Softmax は静的ヘルパーです。詳細は、付属のコードをダウンロードして参照してください。softmax での活性化は (分母項で) 活性化されるすべての値を必要とするため、個別ではなく同時にすべての softmax 値を計算するのが効率的です。最後の出力値を出力ノードに格納し、呼び出しの都合上、個別に返すこともあります。
まとめ
ここ数年、ディープ ニューラル ネットワークに関して膨大な研究や多くの進化が行われています。畳み込みニューラル ネットワーク、回帰型ニューラル ネットワーク、LSTM ニューラル ネットワークおよび残余ニューラル ネットワーク (residual neural networks) など、非常に強力だが、非常に複雑な特殊 DNN もあります。個人的見解ですが、複雑な DNN を理解するには、DNN の基本的なしくみを理解することが不可欠です。
今後は、バックプロパゲーション アルゴリズム (機械学習ではおそらく最も有名で重要なアルゴリズム) の使い方を詳しく説明し、基本 DNN をトレーニングします。バックプロパゲーションか、少なくともその形式の手法を使って、DNN の多数のバリエーションのトレーニングを行います。勾配消失の概念を紹介する予定です。そこから、非常に精密な予測システムで使われるようになった多くの DNN の設計と動機を説明していきます。
Dr.James McCaffrey は、ワシントン州レドモンドの Microsoft Research に勤務しています。これまでに、Internet Explorer、Bing などの複数のマイクロソフト製品にも携わってきました。Dr.McCaffrey の連絡先は jammc@microsoft.com (英語のみ) です。
この記事のレビューに協力してくれたマイクロソフト技術スタッフの Li Deng、Pingjun Hu、Po-Sen Huang、Kirk Li、Alan Liu、Ricky Loynd、Baochen Sun、Henrik Turbell に心より感謝いたします。