Rediscover the Monte Carlo

僕個人はゲームの思考ルーチンを作ることなどには興味があるので、みんな知っていることだと思っていたのですが、意外と「現在世界最強の囲碁の思考ルーチンはモンテカルロ」ってのは知られてないみたいですね。うっかりすると「そんなわけないだろー」とか言われてしまう。その根底には「モンテカルロはとても収束が遅くて使いものにならない」という過去の記憶があるのかなー。ちょうどJavaScriptが使いものにならないおもちゃ言語だと思われていたように。

囲碁の思考ルーチンを著しく進化させた新しいモンテカルロが昔の単純なモンテカルロとどう違うかというと、UCB1という評価関数で「もっと探索するとヨサゲな局面」を判断して、ヨサゲな局面から優先的に探索するという点なんだけど、そういう定性的な話をしてもピンと来ないよね。同じ発想をモンテカルロで円周率を求めるプログラムに適用したら収束の速さが定量的にはっきり見えて面白いんじゃないかなーと思って、空間を分割して調査する価値の高いところから優先的に調査するプログラムを作ってみました。

結果

理屈やソースを先に説明すると最後まで読まれないんじゃないかと危惧したのでまずは結果から(笑)

単純なモンテカルロでは200万回の試行をしてもまだ3.14までしかわかっていないのに対し、改良版モンテカルロでは30万回で3.141まで到達して、20万回の試行の後には3.141593と、かなり近いところまで円周率を求められている。圧倒的じゃないか、我が軍は!

単純モンテカルロ
0: pi = 3.133040
1: pi = 3.141780
2: pi = 3.142373
3: pi = 3.141050
4: pi = 3.142184
5: pi = 3.142260
6: pi = 3.142771
7: pi = 3.143790
8: pi = 3.143636
9: pi = 3.142952
10: pi = 3.143011
11: pi = 3.143880
12: pi = 3.144342
13: pi = 3.144023
14: pi = 3.143701
15: pi = 3.143102
16: pi = 3.143104
17: pi = 3.142973
18: pi = 3.143078
19: pi = 3.143240

改良モンテカルロ
0: pi = 3.142376
1: pi = 3.142209
2: pi = 3.141998
3: pi = 3.141850
4: pi = 3.141732
5: pi = 3.141667
6: pi = 3.141708
7: pi = 3.141727
8: pi = 3.141715
9: pi = 3.141703
10: pi = 3.141722
11: pi = 3.141702
12: pi = 3.141652
13: pi = 3.141622
14: pi = 3.141634
15: pi = 3.141627
16: pi = 3.141620
17: pi = 3.141604
18: pi = 3.141598
19: pi = 3.141593

実行時間はさすがに4倍ほど遅くなった。しかし試行回数が4分の1になったとしてもまだ改良版モンテカルロの方が収束が速い。

単純モンテカルロ
real	0m0.106s
user	0m0.038s
sys	0m0.003s

改良版モンテカルロ
real	0m0.422s
user	0m0.406s
sys	0m0.005s

理屈

理屈の解説。要するにモンテカルロで探索する空間をWIDTH * WIDTHのメッシュに分割して、それぞれの升目をscoreが高い順に試行するってだけ。scoreは「その升目の中で1回試行をした場合にどれくらい結果が変動するかの平均値」にしてある。問題はそのscoreの計算方法なのだが…

まず、各々の試行ごとに「円の中にある/ない」の0/1の値が返ってくるわけなので、ベルヌーイ過程とみなせる。a + b回の試行をして1がa回、0がb回でたとすると、次の試行で1が出る確率はいくらか? a / (a + b)じゃあないことは明らかだよね。これだと1回試行して1がでたら「次にも確実に1が出る」という誤った結論を出してしまう。

1が出る確率、つまり元のベルヌーイ分布の平均値μ、の平均値がいくらであるのかという話になる。ここでμの共役事前分布はベータ分布で、a + b回の試行をして1がa回、0がb回でた後での事後分布はBeta(a + 1, b + 1)になるとか教科書に書いてあったので(ぇ)、1が出る確率pは(a + 1) / (a + b + 2)と結論できる。

「このマスに円が占める割合」=「このマスで試行したときに1が出る確率の最尤推定量」= a / (a + b)が次の試行でどうupdateされるか考えると、pの確率で(a + 1) / (a + b + 1)、(1 - p)の確率でa / (a + b + 1)に変わるわけだから普通にその2通りでのupdate量に確率を掛けて足しあわせてやればupdate量の平均値が求まる。これが下記コードのscore関数。

(ここ書いていて、今はa / (a + b)の変化量で計算しているけどもしかして(a + 1) / (a + b + 2)の方が適切なのかな?という気がした。マスに円が占める割合の最尤推定量がa / (a + b)なので今の方針であっているとは思うが一瞬不安に思ったので書き留めておく)

(誤解がないように追記しておくと、この評価関数はUCB1ではない。囲碁では「勝てそうな手」を探すのに対しこの円周率推定では「白黒分かれる領域」を探すのでUCB1は使えなかった。)

コード

おかしなところがあれば容赦なく突っ込んでください。大筋では上で述べたように「探索空間をWIDTH * WIDTHに分割して、scoreの高いところから探索する」をやっているだけです。

#include <stdio.h>
#include <stdint.h>
#include <vector>
#include <queue>
using namespace std;

// http://www.jstatsoft.org/v08/i14/xorshift.pdf
uint32_t xor128() {     // 周期は 2^128-1
    static uint32_t x=123456789,y=362436069,z=521288629,w=88675123;
    uint32_t t;
    t=(x^(x<<11));x=y;y=z;z=w; return( w=(w^(w>>19))^(t^(t>>8)) );
}

const size_t ITER_PER_PRINT = 100000;
const size_t NUM_ITER = 20;

int monte(){
  size_t count = 0;
  for(size_t iter = 0; iter < NUM_ITER; iter++){
    for(size_t i = 0; i < ITER_PER_PRINT; i++){
      uint32_t v = xor128();
      uint32_t u = xor128();
      double x = v / 4294967295.0;
      double y = u / 4294967295.0;
      if(x * x + y * y < 1){
	count++;
      }
    }
    printf("%d: pi = %f\n", iter, double(count) / ITER_PER_PRINT / (iter + 1) * 4);
  }
}


inline double score(size_t a_, size_t b_){
  double a = a_ + 1, b = b_ + 1;
  double n = a + b + 2;
  return double(2 * a * b + n) / n / (n + 1) / (n + 2);
}

int monte2(){
  size_t WIDTH = 100;
  size_t N = WIDTH * WIDTH;
  vector<size_t> denom(N);
  vector<size_t> count(N);
  priority_queue<pair<double, size_t> > queue;
  for(size_t i = 0; i < N; i++){
    queue.push(make_pair(score(0, 0), i));
  }
  for(size_t iter = 0; iter < NUM_ITER; iter++){
    for(size_t i = 0; i < ITER_PER_PRINT; i++){
      uint32_t v = xor128();
      uint32_t u = xor128();
      size_t index = queue.top().second;
      queue.pop();
      double x = v / 4294967295.0 / WIDTH + 1.0 / WIDTH * (index % WIDTH);
      double y = u / 4294967295.0 / WIDTH + 1.0 / WIDTH * (index / WIDTH);
      denom[index]++;
      if(x * x + y * y < 1){
	count[index]++;
      }
      queue.push(make_pair(score(count[index], denom[index] - count[index]), index));
    }
    double area = 0.0;
    for(size_t i = 0; i < N; i++){
      area += 1.0 / N * count[i] / denom[i];
    }
    printf("%d: pi = %f\n", iter, area * 4);
  }
}

int main(){
  monte();
  monte2();
}