2012/09/06にくいなちゃんさん主催で開催された6さいカンファレンスのまとめ。 第3回は「アルゴリズムを自力で生み出すプログラムを作ろう!」です。
勝手にまとめてしまったので、何か問題があれば@shogo82148まで。
WELCOME TO HEAVEN!!
くいなちゃん: ところでみなさん、次の( ) に入る数を当ててください
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ( )
くいなちゃん: はい、正解です! これは、フィボナッチ数列と呼ばれ、 前の2つの値の和が、次の値になっているという数列です
くいなちゃん: これを、f(0) = 0, f(1) = 1, f(2) = 1, f(3) = 2, f(4) = 3, f(5) = 5, … と書いていくことにしましょう。 f(10) = 55 ですね!
くいなちゃん: もっと汎用的に考えて、f(x) の x を与えると、フィボナッチ数が返ってくることを考えましょう。 x = 10 のとき、f(x) が 55 になる、といった感じです
くいなちゃん: ではみなさん、この f(x) を、プログラムで書くことはできますでしょうか。 言語は何でも構いません。
くいなちゃん: やり方は、いくつかあります。 x = 100 と与えられれば、0, 1, 1, 2, 3, 5, … と 約100回繰り返して、x = 100 の値を求めるというものです。
くいなちゃん: しかし、これはちょっと効率が悪いですね… x = 100000 なら、100000回計算しなくてはなりません。 理想的には、x = 100000 でも、一発で計算結果が返ってくるアルゴリズムが望ましいです。 難しい言葉で言えば、O(1) ですね。
くいなちゃん: そんなの無理! って思うかもしれませんが、実は可能です。 Wikipedia で調べると、 f(x) = 1 / √5 ( ((1+√5)/2)^x - ((1-√5)/2)^x) と出てきます。
くいなちゃん: イミフですね! あなたがどう頭を使っても、なかなかこのアルゴリズムに到達することはできないでしょう…。
くいなちゃん: というわけで、今回は、 あなたが頭を使うことなしに、アルゴリズムを自動で作り出すプログラムを作ろう! ということをしたいと思います。
くいなちゃん: どうやって? と思うかもしれませんので、これからその仕組みを簡単に説明しましょう。
遺伝的プログラミング
くいなちゃん: まず、あなたは人間です(たぶん)。 人間は、優れた頭脳や肉体を持っていますが、これは神によって創られたものではありません(たぶん)。 生物学的な「遺伝」によって、生まれました。
くいなちゃん: というわけなので、これをプログラムで同じ感じにやれば、 超複雑なプログラムが、遺伝によって生まれうるのではないか! というわけなのです
くいなちゃん: これを、「遺伝的プログラミング」 と呼びます。 (遺伝的アルゴリズムとはちょっと違う)
くいなちゃん: というわけなので、折角なので このフィボナッチ数を求めるプログラム(アルゴリズム) を、遺伝によって発見させてみることにしましょー☆
くいなちゃん: 次のプログラムと図をご覧ください。 http://kuina.tes.so/6saiconf_3/img0.png(魚拓) ここでは、四則演算と累乗が、木構造として定義されています。 (今回は、Javaですん☆)
public class Tree {
public String value;
public Tree[] children;
public String dump() {
String result = value;
for (int i = 0; i < children.length; i++)
result += " " + children[i].dump();
return result;
}
public double calc(double x) {
switch (value.charAt(0)) {
case '+': return children[0].calc(x) + children[1].calc(x);
case '-': return children[0].calc(x) - children[1].calc(x);
case '*': return children[0].calc(x) * children[1].calc(x);
case '/': return children[0].calc(x) / children[1].calc(x);
case '^': return Math.pow(children[0].calc(x), children[1].calc(x));
case 'x': return x;
}
return Double.parseDouble(value);
}
}
くいなちゃん: 前回も言いましたが、くいなちゃんは ポーランド記法が好きなので、 みなさんもポーランド記法に慣れてくださいね。 例えば、ここで x*(2-3) という式は、ポーランド記法で * x - 2 3 となり、右のような木構造になります。
くいなちゃん: プログラム中の dump() は、式を単に出力する関数で、calc() は、式の計算結果を返す関数になっています。 再帰使ってるけど、大丈夫かな?
ランダムで式を作る
くいなちゃん: で、くいなちゃんは めんどくさがり屋なので、いちいち式を作るのが嫌です。 なので、ランダムで式を作る関数を追加しましょう。 http://kuina.tes.so/6saiconf_3/img1.png(魚拓)
public class Tree {
public String value;
public Tree[] children;
public String dump() {
String result = value;
for (int i = 0; i < children.length; i++)
result += " " + children[i].dump();
return result;
}
public double calc(double x) {
switch (value.charAt(0)) {
case '+': return children[0].calc(x) + children[1].calc(x);
case '-': return children[0].calc(x) - children[1].calc(x);
case '*': return children[0].calc(x) * children[1].calc(x);
case '/': return children[0].calc(x) / children[1].calc(x);
case '^': return Math.pow(children[0].calc(x), children[1].calc(x));
case 'x': return x;
}
return Double.parseDouble(value);
}
static public Tree make(java.util.Random rand, int len) {
Tree result = new Tree();
switch(len >= 5 ? 6 : rand.nextInt(7)) {
case 0: result.value = "+"; result.children = new Tree[2]; break;
case 1: result.value = "-"; result.children = new Tree[2]; break;
case 2: result.value = "*"; result.children = new Tree[2]; break;
case 3: result.value = "/"; result.children = new Tree[2]; break;
case 4: result.value = "^"; result.children = new Tree[2]; break;
case 5: result.value = "x"; result.children = new Tree[0]; break;
default: result.value = ((Integer)(rand.nextInt(10) + 1)).toString(); result.children = new Tree[0];
}
for (int i = 0; i < result.children.length; i++)
result.children[i] = make(rand, len + 1);
return result;
}
}
くいなちゃん: バツしてるところは、さっき書いた部分なので気にしなくてokです
くいなちゃん: これで、+ - * / ^ x 0 1 2 3 4 5 6 7 8 9 の文字がランダムに生成されるようになりました。 make() ですね。
くいなちゃん: はい、では、試しにランダムで式を作って、計算させてみましょう☆ http://kuina.tes.so/6saiconf_3/img2.png(魚拓) 画面の下のほうに実行結果が出ています。 - 4 / 2 - 10 2 = 3.75 たしかにちゃんと計算されてそう。
public class Example {
public static void main(String[] args) {
Tree tree = Tree.make(new java.util.Random(), 0);
System.out.println(tree.dump());
System.out.println("= " + ((Double)tree.calc(1.0)).toString());
}
}
遺伝させる
くいなちゃん: こんな感じで、式が木構造で表現できるようになりました! あとは、これを遺伝させて、フィボナッチ数を求めるアルゴリズム(例の複雑な式)が求まれば、成功ですん☆
くいなちゃん: では、遺伝とは、具体的に何をさせるのか、説明いたしましょう。
くいなちゃん: まず、ランダムで作った式 1つ1つを、「遺伝子」と呼ぶことにしましょう。 遺伝子は あらかじめ 100個くらい用意します。
くいなちゃん: 次に、これらの遺伝子の「子供」を作り、次の世代の遺伝子とすることにします。 次の世代も、親の世代と同じ 100個になるように調整します。
くいなちゃん: あとは、これを延々と繰り返していくわけですが、ここで、子孫を残す親を、「優れた遺伝子」から選ばれるような仕組みにします。 つまり、フィボナッチ数に近いような式になっている遺伝子は、親になる可能性が高くなるということです。
くいなちゃん: これにより、世代を重ねていくごとに、より良い遺伝子(式) になっていくことが期待できるのですー☆
くいなちゃん: 遺伝的プログラミングが、遺伝的アルゴリズムと異なるのは、後者が n個のデータを遺伝させるのに対し、前者は ツリー(式やプログラム)を遺伝させるということです。
くいなちゃん: はい、親を2人選んだら、子供が生まれるわけですが、具体的には「交叉」と「突然変異」というシステムで実現します。 http://kuina.tes.so/6saiconf_3/imga.png(魚拓) この図で、左側が交叉、右側が突然変異を説明しています
くいなちゃん: 交叉は、両親の遺伝子の一部ずつを取ってきて、子に遺伝させるということです。 しかし、ごく微小確率(ここでは5%としている) で、突然変異が起こり、デタラメな式に書き換わってしまうのです。
くいなちゃん: まあこれは、人間においても、遺伝子のコピーミスなどで、ガン細胞が発生したりする過程と同じですん☆
くいなちゃん: はい、以上をプログラムで書いたのが、これです。 Javaのせいで気分が悪くなったら、無理に読まなくて結構ですん http://kuina.tes.so/6saiconf_3/img3.png(魚拓)
public class GP {
private java.util.Random rand;
private Tree[] genes;
private double[] values;
private Tree bestgene;
private double bestvalue;
public GP(int genesnum) {
rand = new java.util.Random();
genes = new Tree[genesnum];
for (int i = 0; i < genes.length; i++)
genes[i] = Tree.make(rand, 0);
values = new double[genesnum];
bestgene = null;
bestvalue = 0.0;
refreshValues();
}
public Tree refreshValues() {
for (int i = 0; i < genes.length; i++) {
// x = 0 1 2 3 4 5 6 7 8 9 10
// f(x) = 0 1 1 2 3 5 8 13 21 34 55
double[] fx = {0.0, 1.0, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0, 34.0, 55.0};
values[i] = 0.0;
for (int j = 0; j < fx.length; j++) {
double v;
if (genes[i].countLeaves() >= 50)
v = 100000000.0;
else {
v = genes[i].calc((double)j) - fx[j];
v *= v;
}
if (Double.isNaN(v))
v = 100000000.0;
values[i] += v;
}
values[i] = 1.0 / values[i];
if (bestvalue < values[i]) {
bestvalue = values[i];
bestgene = genes[i];
}
}
return bestgene;
}
}
くいなちゃん: とはいえ、まだこのプログラムでは、交叉と突然変異を書いていません。 ランダムで遺伝子を100個用意して、その中で フィボナッチ数に最も近そうな遺伝子が見つかるところまでです
くいなちゃん: とりあえず、折角ここまで書いたのでテストしてみましょう。 http://kuina.tes.so/6saiconf_3/img4.png(魚拓) まだ 1世代目なので、そんなに優れた遺伝子はありません。 実行例では、^ x x つまり x^x (xのx乗) がフィボナッチ数列に最も近い式だ、ということになりました。 なるほど、確かにちょっと近そう…?
public class Example {
public static void main(String[] args) {
GP gp = new GP(100);
Tree tree = gp.refreshValues();
System.out.println(tree.dump());
for (int i = 0; i < 11; i++)
System.out.println("f(" + i + ")" + "= " + ((Double)tree.calc((double)i)).toString());
}
}
くいなちゃん: (そうでもない)
くいなちゃん: はい、いよいよ交叉と突然変異を追加するわけですが、 その前に、便利関数として、木のノード数を数える関数や、自分自身のコピー木を作る関数や、任意の番号のノードを取得できる関数などを、数式木のクラスに書き加えました。 http://kuina.tes.so/6saiconf_3/img5.png(魚拓) 図をじっと見てたら、何してるかくらいは判るはず
public class Tree {
public String value;
public Tree[] children;
public String dump() {
String result = value;
for (int i = 0; i < children.length; i++)
result += " " + children[i].dump();
return result;
}
public double calc(double x) {
switch (value.charAt(0)) {
case '+': return children[0].calc(x) + children[1].calc(x);
case '-': return children[0].calc(x) - children[1].calc(x);
case '*': return children[0].calc(x) * children[1].calc(x);
case '/': return children[0].calc(x) / children[1].calc(x);
case '^': return Math.pow(children[0].calc(x), children[1].calc(x));
case 'x': return x;
}
return Double.parseDouble(value);
}
static public Tree make(java.util.Random rand, int len) {
Tree result = new Tree();
switch(len >= 5 ? 6 : rand.nextInt(7)) {
case 0: result.value = "+"; result.children = new Tree[2]; break;
case 1: result.value = "-"; result.children = new Tree[2]; break;
case 2: result.value = "*"; result.children = new Tree[2]; break;
case 3: result.value = "/"; result.children = new Tree[2]; break;
case 4: result.value = "^"; result.children = new Tree[2]; break;
case 5: result.value = "x"; result.children = new Tree[0]; break;
default: result.value = ((Integer)(rand.nextInt(10) + 1)).toString(); result.children = new Tree[0];
}
for (int i = 0; i < result.children.length; i++)
result.children[i] = make(rand, len + 1);
return result;
}
public int countLeaves() {
int result = 1;
for (int i = 0; i < children.length; i++)
result += children[i].countLeaves();
return result;
}
public Tree copy(int[] leaf, Tree other) {
leaf[0]--;
if (leaf[0] == -1)
return other.copy(leaf, null);
Tree result = new Tree();
result.value = value;
result.children = new Tree[children.length];
for (int i = 0; i < children.length; i++)
result.children[i] = children[i].copy(leaf, other);
return result;
}
public Tree select(int[] leaf) {
leaf[0]--;
if (leaf[0] == -1)
return this;
for (int i = 0; i < children.length; i++) {
Tree result = children[i].select(leaf);
if ( result != null)
return result;
}
return null;
}
}
くいなちゃん: 判れ。
くいなちゃん: で、がんばって、交叉と突然変異を実装しました。 http://kuina.tes.so/6saiconf_3/img6.png(魚拓) getGoodGene は、より優れた遺伝子が高確率で選択させるようにした関数で、これで親になる遺伝子を選びます。 inherit は、交叉・突然変異を一まとめにして、「次の世代を作る遺伝処理」をする関数になっています。 突然変異は、0.05 の確率で起こることが、ソースをよく読んでたら解りますね。(チラッ
public class GP {
private java.util.Random rand;
private Tree[] genes;
private double[] values;
private Tree bestgene;
private double bestvalue;
public GP(int genesnum) {
rand = new java.util.Random();
genes = new Tree[genesnum];
for (int i = 0; i < genes.length; i++)
genes[i] = Tree.make(rand, 0);
values = new double[genesnum];
bestgene = null;
bestvalue = 0.0;
refreshValues();
}
public Tree refreshValues() {
for (int i = 0; i < genes.length; i++) {
// x = 0 1 2 3 4 5 6 7 8 9 10
// f(x) = 0 1 1 2 3 5 8 13 21 34 55
double[] fx = {0.0, 1.0, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0, 34.0, 55.0};
values[i] = 0.0;
for (int j = 0; j < fx.length; j++) {
double v;
if (genes[i].countLeaves() >= 50)
v = 100000000.0;
else {
v = genes[i].calc((double)j) - fx[j];
v *= v;
}
if (Double.isNaN(v))
v = 100000000.0;
values[i] += v;
}
values[i] = 1.0 / values[i];
if (bestvalue < values[i]) {
bestvalue = values[i];
bestgene = genes[i];
}
}
return bestgene;
}
public Tree getGoodGene() {
double totalvalue = 0.0;
for (int i = 0; i < genes.length; i++)
totalvalue += values[i];
double r = rand.nextDouble() * totalvalue;
int select = 0;
while (r >= values[select]) {
r -= values[select];
select++;
}
return genes[select];
}
public double inherit() {
Tree[] newgenes = new Tree[genes.length];
for (int i = 0; i < genes.length; i++) {
Tree a = getGoodGene(), b;
if (rand.nextDouble() < 0.05)
b = Tree.make(rand, 0);
else
b = getGoodGene();
if (a == b)
newgenes[i] = a;
else {
int[] leafa = new int[] { rand.nextInt(a.countLeaves()) };
int[] leafb = new int[] { rand.nextInt(b.countLeaves()) };
newgenes[i] = a.copy(leafa, b.select(leafb));
}
}
genes = newgenes;
refreshValues();
return bestvalue;
}
}
くいなちゃん: なるほど、わからん
くいなちゃん: はい、それでは、本当に 遺伝を繰り返すと、良い遺伝子が得られるのか、テストしてみましょう。 http://kuina.tes.so/6saiconf_3/img7.png(魚拓) この出力は、どの程度 フィボナッチ数列に近い遺伝子が見つかったかを示すものです(大きいほうが近い)。 なるほど、確かに、だんだん進化してますね。
public class Example {
public static void main(String[] args) {
GP gp = new GP(100);
for (int i = 0; i < 50; i++)
System.out.println(gp.inherit());
}
}
くいなちゃん: では、せっかくなので、100000回くらい遺伝させてみましょう!!
くいなちゃん: 100000回というと、孫の孫の孫の孫の… と膨大な数の子孫を経た世代ということです。 恐ろしい。
くいなちゃん: その結果… http://kuina.tes.so/6saiconf_3/img8.png(魚拓) なんかすごく近い関数が発見されました!! 右側にフィボナッチ数列を載せていますが、確かに比較すると 近いことが判ると思います。 (ただ、確かに近いけれど、なんか違う…)
public class Example {
public static void main(String[] args) {
GP gp = new GP(100);
for (int i = 0; i < 100001; i++) {
double value = gp.inherit();
if (i % 10000 == 0)
System.out.println(value);
}
Tree bestgene = gp.refreshValues();
System.out.println(bestgene.dump());
for (int i = 0; i < 11; i++)
System.out.println("f(" + i + ")" + "= " + ((Double)bestgene.calc((double)i)).toString());
}
}
くいなちゃん: その式とは、 + x + / x 4 / * … と続く、大変意味不明な式です。 なんですか、この式は…?
くいなちゃん: 期待していた、最初のほうで言った式とは、なんか違いますよね… 気のせいかな?
くいなちゃん: とは言っても、ポーランド記法に慣れていない 皆さんは、ぱっと見ても解らないと思います。 なので、普通の式に整理して書き直してみました☆
くいなちゃん: 今回、このプログラムが発見した フィボナッチ数列の式とは… http://kuina.tes.so/6saiconf_3/img9.png(魚拓) これです!!
くいなちゃん: 一番下のやつ
くいなちゃん: 参考までに、正しいフィボナッチ数列の式を一番上に掲げておきました。
くいなちゃん: なんと、√とか使わない式で、フィボナッチ数列を再現したのです…!
くいなちゃん: はい、分数です
くいなちゃん: そこにある式全部で、一つの式です。 つまり、6次式ですね!
くいなちゃん: 6次式より、高次だった
くいなちゃん: はい、まとめですん☆ 遺伝的プログラムを用いると、こんな感じで、近いアルゴリズムを 自動で発見させることができます。 今回は、簡単な計算式のみを遺伝させましたが、for文や if文なども含めて遺伝させると、ちょー複雑なプログラムも 自動で誕生させることができます。 試してみてください☆