【機械学習】VC++で手書き数字認識のWindowsアプリを作ってみた 1/2

ここ最近のAI・機械学習ブームは飛ぶ鳥を落とす勢いですね。
この分野におけるHello Worldは手書き数字認識らしいですので、作ってみました↓

f:id:kuranabe:20180703004744p:plain


概要

  • 3層の順伝播型ニューラルネットワーク(FFNN)
    ・入力層…784次元 (28画素x28画素)
    ・隠れ層…100次元
    ・出力層…10次元 (0から9)
  • 訓練・テストにMNIST画像を使用
  • 認識率向上のため、入力した手書き文字を自動でセンタリング
  • MFCWindowsアプリとして作成

この分野はPythonが主流ですが、Windowsアプリで作成したい&ニューラルネットワークの基本を抑えたいため、機械学習系のフレームワークは使わずにMFC&C/C++で書きます。
文字認識の分野では畳み込みニューラルネットワーク (CNN)が高い精度を上げていますが、今回は単純な順伝播型(FFNN)を使用しました。

ソースはGitHubにあります。Visual Studio2017でビルドできます。
(リリースからバイナリもDL可能)

github.com

文字入力部

文字入力部はMNIST画像のサイズに合わせるため28の倍数サイズで用意します。
なお、リソースで設定しただけでは実行環境によりサイズが異なる場合があるため、WM_INIT時にMoveWindow()で文字入力部をリサイズします。
読み出し時にはMNIST画像と同じ28x28画素になるよう倍数サイズの画素値を平均化して読み出します。

この際の画素読み出しでは、通常のGetPixel()は遅いのでCreateDIBSection()で直接アクセス可能なビットマップを作成し、そこから高速に読み出しを行っています。

また、読み出し時に入力文字がエリアの中央に来るようにセンタリングをします。
もともとMNIST画像は文字の位置を揃えるためにセンタリングや拡大縮小処理がされているため、手書き入力部分でも同様の処理を行ってからニューラルネットワークに入力する必要があります。
※拡大縮小に対応すればさらに認識率が上がるはずです。

f:id:kuranabe:20180630183613p:plain

CNNでは局所的にフィルタを適用し要素のズレやノイズに強いため、このようなセンタリング処理は一般的ではないかもしれません。


ネットワーク部

推論処理では入力画像を一次元のベクトル(784ニューロン)として入力し出力層へ伝播し、入力層の活性化はシグモイド関数、隠れ層ではソフトマックス関数で出力数値の割合を求めています。
NumPyを使えば恐らく2,3行で書ける処理ですね。
※doubleVecはstd::vector<double>のtypedef

// 順方向伝搬
void FF_Neural::ForwardProp(const doubleVec &vecZ0, doubleVec &vecZ1, doubleVec &vecZ2)
{
	// 中間層への順方向伝搬
	vecZ1.clear();
	vecZ1.reserve(m_il1Size);
	for (int il1 = 0; il1 < m_il1Size; il1++) {

		double dSum = 0;
		int iW1Offset = il1 * m_il0Size;
		for (int iCnt = 0; iCnt < m_il0Size; iCnt++) {
			dSum += vecZ0[iCnt] * m_vecW1[iW1Offset + iCnt];
		}
		// 活性化(シグモイド関数)
		vecZ1.push_back(Sigmoid(dSum));
	}

	// 出力層への順方向伝搬
	vecZ2.clear();
	vecZ2.reserve(m_il2Size);
	for (int il2 = 0; il2 < m_il2Size; il2++) {

		double dSum = m_dB1;	// バイアス値を加える
		int iW2Offset = il2 * m_il1Size;
		for (int iCnt = 0; iCnt < m_il1Size; iCnt++) {
			dSum += vecZ1[iCnt] * m_vecW2[iW2Offset + iCnt];
		}

		vecZ2.push_back(dSum);
	}

	// ソフトマックス関数で確率値の計算
	VecSoftMax(vecZ2);
}


学習処理では正解ラベルとのクロスエントロピーを求め、各ネットワークの重みを再計算しています。

// 逆方向伝搬
double FF_Neural::BackProp(const doubleVec &vecZ0, const doubleVec &ExpZ2)
{
	// 順方向伝搬
	doubleVec vecZ1, vecZ2;
	ForwardProp(vecZ0, vecZ1, vecZ2);

	// クロスエントロピーで誤差算出

	// 出力層の勾配を算出
	double dLoss = 0;
	doubleVec vecDel2;	// 出力層のデルタEの算出
	vecDel2.reserve(m_il2Size);
	for (int iCnt = 0; iCnt < m_il2Size; iCnt++) {
		dLoss += ExpZ2[iCnt] * log(vecZ2[iCnt]);
		vecDel2.push_back(vecZ2[iCnt] - ExpZ2[iCnt]);
	}

	// 中間層の勾配を算出
	doubleVec vecDel1;
	vecDel1.reserve(m_il1Size);
	for (int iCnt = 0; iCnt < m_il1Size; iCnt++) {

		double dDel1 = 0;
		double dSigDash = SigmoidDash(vecZ1[iCnt]);
		for (int il2 = 0; il2 < m_il2Size; il2++) {
			dDel1 += vecDel2[il2] * m_vecW2[iCnt + iCnt * il2] * dSigDash;
		}

		vecDel1.push_back(dDel1);
	}

	// 誤差逆伝搬
	double dEps = 0.015; //学習係数

	for (int il2 = 0; il2 < m_il2Size; il2++) {
		// W2の重み更新
		int iW2Offset = il2 * m_il1Size;
		for (int iCnt = 0; iCnt < m_il1Size; iCnt++) {
			m_vecW2[iW2Offset + iCnt] -= dEps * vecDel2[il2] * vecZ1[iCnt];
		}
	}

	for (int il1 = 0; il1 < m_il1Size; il1++) {
		// W1の重み更新
		int iW1Offset = il1 * m_il0Size;
		for (int iCnt = 0; iCnt < m_il0Size; iCnt++) {
			m_vecW1[iW1Offset + iCnt] -= dEps * vecDel1[il1] * vecZ0[iCnt];
		}
	}

	return dLoss;
}


肝心の精度ですが、MNISTの訓練画像6万枚・テスト画像1万枚でaccuracy=91.3%でした。
画素値を単純に入力したFFNNではこれ以上の劇的な改善は難しそうです。

次の記事では、実際に手書き文字の認識を試してみます。