今回は、評価関数を呼び出して1手読みのAIを作ってみます。
評価関数の呼び出し方
評価関数はEval::eval(Position&)を呼び出すと評価値が返ってくる。
ここで注意すべきことは、現在の手番側から見た評価値が返ってくるということだ。昔のソフトでは(将棋ソフト以外でも)先手から見た評価値を返すような評価関数がよく使われていたが[要出典]、現代では、手番側から見た評価値が返るのが普通である。
つまり、評価関数のなかで、後手番なら計算した値の符号を反転してから返すようになっている。
return pos.side_to_move() == BLACK ? score : -score;
こうすることで、先手と後手との非対称性を無くして、先手用のコードを後手でも使えるようになる。そうすると、minmaxのコードがminを求めるコードとmaxを求めるコードが一本化できる。これがNegamaxである。そしてNegamaxからαβ探索に発展していく。詳しくはggrks。
要するに、手番側から見た評価値を返すことによって、コードの簡略化できるようになるから、いまどきはそうするということだ。
1手読みAI
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
void MainThread::think() { auto& pos = rootPos; Move bestMove = MOVE_RESIGN; Value maxValue = -VALUE_INFINITE; StateInfo si; for (auto m : MoveList { // 合法手mで1手進めて、そのときの評価関数を呼び出して、その値が一番良い指し手を選ぶ。 // (1手進めた局面は後手番なので一番小さなものが先手から見たベスト) pos.do_move(m,si); auto value = -Eval::eval(pos); pos.undo_move(m); if (value > maxValue) { maxValue = value; bestMove = m; } } sync_cout << "bestmove " << bestMove << sync_endl; } |
ソースコードは、評価関数を呼び出している以外はいままで出てきたものしか使っていない。
1手読みAIの弱点
コンパイルして将棋所で遊んでみると角道を開けたり、飛車先の歩を突いたり、玉を囲おうとしたり、形よく指すのですが、ただで駒を捨ててくることがあることに気づく。
1手だと、それを取り返されるところまで読めていないので仕方ない意味があるが、進んで歩や金を飛車の利きに捨ててくる。
これは、3駒関係では大駒に当たりになっている駒の評価値が高いためだ。特に、手番を考慮しない3駒関係では、大駒に当っている小駒は価値が高いものとされる。(取れるかも知れないので)
これを回避するには色々な方法が考えられる。もちろん、深くまで探索すれば自然と解決するわけではあるが、深くまで探索せずに解決する方法はないだろうか?
例えば…。
・評価関数で手番も考慮するようにする(手番を考慮した3駒関係)
・最後に移動させた駒を取り返す指し手だけ延長して調べる(recapture延長)
・最後に移動させた升での行われる取り合いによる駒の収支だけ計算する(SEE : Static Exchange Evaluation)
・盤面全体を見て取れる駒をお互い最良だと思われる順で取り合う変化を読む(静止探索)
他にもいくらでも考えられるが、その計算量とそれによる効果が見合うかという問題になってくる。
これらの技術は互いに独立したものではあるが、しかし、すべては局面を(通常の探索なしに、小さなコストで)少しでも正確に評価したいという考えから生まれてきたものだ。
このような技術はたくさんあって(たくさんの可能性が考えられて)、そのなかから探索部や評価関数に見合う、バランスのいいものを採用するというのが正しい開発方針だ。
また、深くまで探索すればそれなりに強くはなるが、本当は3手ぐらいの極めて浅い探索においてもそれなりの強さになるべきで、そのようにチューンしていくのはわりと正しい開発方針である。3手ぐらいの探索において強くならないなら、それは局面を正しく評価出来ていないということなので、どう改善すればいいかを考えていくと、深い探索をするときにも生きてくる。
1手読みAIの改良
さきほどのプログラムは、取られる駒の移動は少し控えるように次のように改良してみる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
void MainThread::think() { auto& pos = rootPos; Move bestMove = MOVE_RESIGN; Value maxValue = -VALUE_INFINITE; StateInfo si; for (auto m : MoveList { pos.do_move(m,si); auto value = -Eval::eval(pos); // toの地点に敵の駒が利いてたら、この駒を損してしまう(ことにする) if (pos.effected_to(pos.side_to_move(), move_to(m))) { // 移動させた駒 auto pc = pos.piece_on(move_to(m)); value -= (Value)Eval::PieceValue[type_of(pc)]*2; } pos.undo_move(m); if (value > maxValue) { maxValue = value; bestMove = m; } } sync_cout << "bestmove " << bestMove << sync_endl; } |
金を飛車の利きに移動させれば、その金はとられるものとして計算する。金がとられるということは、自分の盤上の金が相手の持ち駒の金になるのだから金の価値の2倍損をする。(1,000円を誰かにあげたら、その人との所持金の差は2,000円になるという原理)
これを「交換値は駒の価値の倍」というように言う。
上のPieceValue[]は、駒の価値が入っている配列なので、交換値としてはその倍にしなければならないのだ。
1手読みAI改良の結果
上のコードを入れるとただ捨ては確かに減る。
しかし、当たりになっている駒を逃げない。なぜかというと、その駒で相手の駒をとっても取り返されるからプラマイゼロ、かと言って逃げたところで駒の損得はないので、その場所がその駒にとって良い場所かどうかという話になってしまう。その場所がその駒にとって良い場所ではないなら逃げた分だけマイナスになってしまう。だから逃げないのである。
結果としてただでとられるままの状態で放置してしまい、損をする。
まあ、ただで金を差し出す最初のAIよりはずいぶんマシなので、今回はこれで良しとする。
ここまでのまとめ
評価関数の呼び出し方がわかった。読者の方には、探索を深くする以外で、どうやれば今回の1手読みAIが強くなるのか、色々考えてみて欲しい。それを考えて、手軽に試すことが出来る環境こそがやねうら王miniなのである。
次の記事に続く。
「ここまでのまとめ」内の記述ですが、「1手詰めAI」ではなくて、「1手読みAI」の間違いではないでしょうか。
修正しました!(`・ω・´)ゞ
>1手進めた局面は後手番なので一番小さなものが先手から見たベスト
ソフトは先手もち
そうして、評価関数は後手番の局面では後手のために常に符号を反転して返す、、、と。
次に、先手としは後手局面が次の一手になるのだが、先手から見てより評価値の高い手を指さなくてはいけない。
しかるに、評価関数は後手局面では常に符号をひっくりかえして戻してくる。
従って、先手としては戻ってきた評価関数をもう一度符号をひっくり返してMax値の手をみつけるか、同じことだがそのままの値を使ってMin値を持つ手をさせば良い、、、と。
以上の理解でよろしいでしょうか?
はい、合ってます。
後手の局面を先手から見るからややこしくなるのであって、本来は、後手の局面は後手から見て考えるようなプログラムの書き方をすべきなんですけど、今回はまあ最初なのでまずはこういう書き方になりました。
おぉ、なんからしくなってきましたね。
ここからの盛り上がりに期待します。
あと3回ぐらいでこの連載は終わりまして、次は探索をごりごり書いて強いAIを作る連載が始まります。
評価関数関係の記事に期待します。
自分にとっては、ほんとブラックボックスなので同設計するべきかは一応決めてはあるんですが、現実的な速度で処理できるかは試してないのでわからないのです。
とりあえず、ボクセル評価っていうか、テンソル評価っていうか、多層マトリクス評価というか、そういう多層評価の方法を考えていますが、もっと良い方法がたぶん世の中にはあるんだろうとガクブルしてる最中ですね。
屋根さんの知見に期待してマース。
>このような技術はたくさんあって(たくさんの可能性を考えられて)
やはりどうもひっかかります。
可能性を考えられてーー>可能性が考えられて
ではないかと、、、。
修正しました(`・ω・´)ゞ
誤字脱字、指摘、ありがたいです。
いえいえ。
「うるさい奴・・・」と思われていなくて安心しました。
親切丁寧な記事で本当に参考になります。
ところで実際に、ソースファイルをダウンロードして実行させながら勉強しているのですが、いくつか疑問点があります。自分なりにできるだけのことはやっているのですが、どうしてもわからないため質問させていただきます。
1.eval(pos)というのは現在はevaluate(pos)と書かなければならなくなっていないでしょうか
2.kpp16ap.bin、kkp32ap.binはCSAのサイトからダウンロードしてソースファイルの置かれているフォルダのevalというフォルダにおいているのですが、将棋所実行させようとすると”Error! open evaluation file failed.”というエラーが出て止まってしまいます。私の環境の特有の問題かもしれないとは思いましたが、何かヒントなどありましたらご教授お願いしたいと思い記させていただきました
以上の二点が自分では解決できない疑問点です。どうぞよろしくお願いいたします
1. → その通りです。
2. → ソースファイルではなく実行ファイルの生成されるフォルダにevalというフォルダを作成して、そこにその2つのファイルを配置します。
お忙しい中、お答えいただきありがとうございました。
基本的なところで、大きな勘違いをしていたことに気付きました。お恥ずかしい限りです。できるだけお手を煩わせることのないように更なる熟考を重ねて質問したいと思います
どんまいです。頑張っていきましょう(๑´ڡ`๑)