2次元点群の直線y=ax+b近似問題を確率的勾配効果法で解いてみたよ。

やあ君たち。AI勉強しているか。とは言っても既存のディープラーニングフレームワークの使い方とかではなくて、そういうものの中で動いている、数学的な原理について理解を深めているかっていうことを聞いてみたんだけれども。

なのでおじさんはそういう本を買って、しかもおじさんにしては珍しく、買った本をちゃんと読み進めたりしているわけなんだ。ディープラーニング計算の核とも言えるところのロジックを、どうしても知りたくてね。さて今日はその中の、確率的勾配降下法についての研究成果の紹介となります。

確率的勾配降下法の説明となると、Nielsen先生のブログや、おじさんが買った本や、他の人たちのブログなんかをいくら読んでも、決まって、放物面の底の部分を玉がころころ転がっているような概念的な説明しか見つけられなくて、どうしてもしっくりこなかったので、もっと問題を単純化して、そこで語られている説明をもとに、実際にその問題を解いてみようという実験をしてみたよ。

どういう問題を選んだのかというとそれは、2次元XY平面上に分布した、サンプル点群の位置を、最もよく近似する直線を求める、いわゆる直線近似問題だ。理系の子供たちであれば誰でも大学の教養課程や物理実験なんかで一度はやるはずだから、馴染みのある問題かと思う。しかしこれは最も簡単なニューラルネットワークなんだって、気付いていただろうか。そう、ずばり、xを入力したら、yが出力される、という、入力が一つ、出力が一つの、単一ニューロンモデルと見ることができるんだね。y=ax+bのaは、重みwに相当し、bはそのままオフセット値となる。

そう、つまり、(x, y)の膨大な数の組み合わせを教師データとして学習させて、それを最もよく再現する最適なaとbを求めましょうという、ニューラルネットワーク(単一ニューロン)のトレーニング問題そのものなのだ。

(後日ここに数式の説明なんかを書く)

ではさっそくコードの紹介になります。今回の実験には自分が最も使い慣れているC++(VS2017)を使いました。何故ならとにかく速く計算実験をして、本やブログで語られている説明が正しく、ちゃんと使えるのかどうかを確かめたかったので。
これまでもね、このAI関連の勉強をするたびに、「じゃこれからは俺もpython覚えなきゃな」とか、pythonの勉強始めちゃって、かといって毎日実用で使うわけでもないから忘れては挫折する、という繰り返しを何度もやってきたんだけど、そうじゃなかったよ。巷の有名なフレームワークの使い方を覚えたい場合はいざ知らず、こういう根本の計算原理を確かめたいそこの君にアドバイス
批判してくる同僚や仲間に流されるな!自分をしっかり持つんだ!
「言語は何でもいいんだよ。」

#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
#include <functional>
using namespace std;

// 正解のy=ax+bにおける、aとbの値。
// 点群セットの作成に用います。
// 以下二つの推測ロジックにはこの二つの値を、
// 点群セットのみから、推測させることで、ロジックの実用性を
// 吟味します。
const float aa = 388.76f;
const float bb = 0.37f;

float true_func(const float x)
{
	float y = aa*x + bb;
	return y;
}

int main(void)
{
	// 2次元XY平面上にランダムに分布した、点のセットを作成します。
	// generate random points around true_func
	const int num_sample = 1000;
	typedef pair<float, float> XY;
	vector< XY > sample_vec;
	{
		auto& l = sample_vec;
		l.reserve(num_sample);
		for (int i = 0; i < num_sample; ++i)
		{
			float x = rand() / (float)RAND_MAX * 10;
			float y = true_func(x) + (rand() / (float)RAND_MAX * .2-.1);
			l.push_back(make_pair(x, y));
		}// i
	}
	// 決定論的な手法(最小二乗法)により、最適なa, bを求めます。
	// calculate a and b direct manner
	{
		float a, b;
		auto& l = sample_vec;
		auto tak = [](vector<XY> samp_vec, function <float(XY)> core) ->float {
			auto& l = samp_vec;
			return accumulate(l.begin(), l.end(), .0f, 
				[core](float sum, XY cur) { return sum + core(cur); });
		};
		float m_00 = tak(l, [](XY cur)->float{return cur.first * cur.first; });
		float m_01 = tak(l, [](XY cur)->float{return cur.first; });
		float m_10 = m_01;
		float m_11 = (float)l.size();
		float v_0 = tak(l, [](XY cur)->float{return cur.first * cur.second; });
		float v_1 = tak(l, [](XY cur)->float{return cur.second; });
                // ごめん↑ここは普通のfor文とかでいんだけど、ラムダを受け取るラムダの実験してた。
		float detA = m_00*m_11 - m_01*m_10;
		a = (m_11 * v_0 - m_10 * v_1)/detA;
		b = (-m_01 * v_0 + m_00 * v_1)/detA;
		cout << "a is: " << a << endl;
		cout << "b is:" << b << endl;
	}
	// 確率的勾配効果法
	// gradually improve a and b using stochastic gradient descent.
	{
		float a = 1; // 初期値は適当に決めた
		float b = .5; // 初期値は適当に決めた
		for (int i = 0; i < 10; ++i)// エポック数=10
		{
			// 単位バッチ処理
			auto l = sample_vec;
			while (!l.empty())
			{
				int id = floor(rand() / (float)RAND_MAX * l.size());
				auto it = l.begin();
				advance(it, id);
				float x = it->first;
				float y = it->second;
				float delta_kai_for_a = a*x*x + b*x - x*y;
				float delta_kai_for_b = a*x + b - y;
				float step = .01;
				a += -delta_kai_for_a * step;
				b += -delta_kai_for_b * step;
				l.erase(it);
			}// while
			cout << endl;
			cout << "a is: " << a << endl;
			cout << "b is:" << b << endl;
		}
	}
	getchar();
	return 0;
}

そして、上記コードの出力結果は以下のようになりました。

Definitive Method( LeastSquare ) RESULTS:
a is: 388.76
b is:0.370044
Stochastic gradient descent RESULTS:
 (a is: 388.184  b is:4.9497)
 (a is: 388.711  b is:0.758383)
 (a is: 388.755  b is:0.403251)
 (a is: 388.762  b is:0.368382)
 (a is: 388.763  b is:0.360369)
 (a is: 388.767  b is:0.366939)
 (a is: 388.759  b is:0.366983)
 (a is: 388.762  b is:0.368995)
 (a is: 388.768  b is:0.366368)
 (a is: 388.76  b is:0.364617)
a is: 388.76
b is:0.364617

2番目の確率的勾配降下法の計算の収束過程では、都度、点と直線との値の誤差の総和を一緒に打ち出すようにしてみるのも面白いかも知れないね。
あと、これはあくまで、たたき台のコードだよ。実際には、ステップの大きさを、変数ごと、あるいはその時点での計算誤差の大きさによって、細かく調整しないといけないというノウハウもあるようだ。それに初期値も体系的にあたりをつけて何セットかやるのが通例のやり方のようだし。そこはま当然、奥深いノウハウが随所で語られているようだよ。
というわけで、今回の緊急寄稿はここまで。
チャオ!