暗棋(アンチー):自己対局は+77 Eloと言ったが、実際はゼロだった
要点
- MistyBanqiはオープンソースの暗棋(アンチー、中国ダークチェス)エンジンで、ベンチマーク相手にしたオープンなエンジンとほぼ互角。クローズドなプログラムはもっと強い。
- 自己対局では探索の変更が+77 Eloと出たが、外部エンジンに対しては価値ゼロだった。以降の変更はすべてそのエンジンを相手に計測した。
- 外部ベンチマークが砲と車の評価値の入れ替わりを見つけた。最大の評価値の改善は、その駒を取れる駒が盤から消えるほど駒の価値が上がるという項で、全体の積み上げでベンチマークに対して+16.6%に達した。
- 安価なAlphaZeroのテストは手調整エンジンに対して勝率35%で頭打ちになったため、有料の学習実行は保留中。
私は暗棋(アンチー、中国ダークチェス)向けの古典的なRust製エンジン、MistyBanqiを作った。 MistyBanqiとMistboardで対局できる。アルファベータ+Star1エクスペクティミニマックス、手作りの評価関数を使い、対局版にはニューラルネットワークを使っていない。
これは最強エンジンだという主張ではない。強い暗棋プログラムであるCLAP_CDCとDarkKnightはクローズドで、より強い。これは私が出してよいと思った予算の範囲で作れた、最強のオープンソースエンジンの製作報告だ。有益な成果は魔法のような評価項ではなかった。進歩のほぼすべては、強さの測り方を直すことから来た。
評価関数はgeorge0828Zhangのオープンソースの CDC エンジンから出発し、その後そのエンジンを、あらゆる変更を計測する固定の相手として使い続けた。MistyBanqiは今では十分に戦えるレベルで、そのベンチマークとほぼ互角、しかもオープンソースだ。
ルールはmistboard.com/rules/banqiにあり、遊べる盤もある。ここで重要なルールは一つだけ:手は通常の移動か取りか、あるいは伏せ駒を裏返して未公開の駒袋からランダムな駒を現す駒をめくる手だ。駒をめくるのは偶然事象なので、通常のアルファベータに加えて探索は偶然ノードを扱い、これをStar1エクスペクティミニマックスで処理する。残りはお馴染みの仕組み、置換表と静止探索だ。局面に点数をつける部分はすべて手書きで、以降の話はまるごとその話になる。
自分自身を相手に計測してはいけない
最初の改良は定番の探索機構、置換表と繰り返し検出だった。それを自分のエンジンの以前のビルドを相手に、ペア方式の自己対局で計測した。スコアは60.9%、およそ+77 Eloで、どの統計も改善が本物だと言っていた。
次に同じ変更を参照エンジンに対して走らせた。+77 Eloは消え、結果は互角だった。自分自身を相手に調整したボットは、自分の死角を突くことに最適化される。自己対局の数字は、私のエンジンが考える「手強い相手」=結局は私のエンジンを測っていたわけだ。そこでそのエンジンを尺度にし、以降の変更はすべてそれを相手にした大規模なペア対戦で計測した。片側数百局を、Modal上のコンテナに分散させ、各実行が1時間以内に終わるようにした。暗棋は引き分けが多いので、数%の差を見るにはそれだけの局数が要る。最初にやっていた20局のマッチはノイズだった。
評価関数のチューニング
正直な尺度が最初に見つけたのは、ずっと抱えていた駒の評価値テーブルのバグだった。修正後がこれだ:
| 駒 | 評価値 |
|---|---|
| 将 | 30 |
| 砲 | 16(以前は12) |
| 士 | 14 |
| 象 | 11 |
| 車 | 9(以前は14) |
| 馬 | 7 |
| 卒 | 4 |
砲と車が逆だった。砲は間に挟んだ駒を飛び越えて取るため、取り駒の序列では下位にありながら盤上で最も戦術的に危険な駒になる。車は単純な走り駒だ。この修正だけで数ポイントの価値があり、評価値の改善の大半を占めた。自己対局では絶対に見えなかっただろう。自分のエンジンのどのバージョンも同じ誤ったテーブルを抱えていたからで、これを暴くには外部の相手が必要だった。
もっと面白いチューニングは、駒の価値が定数ではなく盤上に残っているものに依存するという点だ。参照エンジンはすでに将についてその小規模版をやっていた。将は卒だけが取れる駒なので、敵の卒が盤から消えるほどその価値が上がる。脅かすものが消えていくからだ。私はそれをすべての駒に一般化した。各駒には自分を取れる敵の駒の集合があり、それらが交換で消えていくにつれて価値は「不可侵」へ向かって上がる。この項、適応的支配は単独で最大の評価値の改善となり、全体の積み上げは参照エンジンに対して+16.6%、負けは3分の1に減った。古い誤った評価値テーブルと意図的に組み合わせても+8.7%あり、これは一人の相手に合わせた数字合わせではなく、暗棋の本物の構造を捉えている証拠だ。
この項は全部で十数行だ。盤上のすべての駒について、それを取れる可能性のある敵の駒が何枚生き残っているか(盤上または駒袋の中)を数え、ボーナスを反比例させる:
// dom_val: a piece's value grows as the enemy pieces that can capture it
// disappear — toward "immortal" when its dominators are gone.
const DOM_K: f64 = 0.5;
for i in 0..NSQ {
let c = self.sq[i];
if !is_piece(c) { continue; }
let role = code_role(c);
let enemy = (1 - code_color(c)) as usize;
// living enemy pieces that can capture this role
// (role 5 = cannon, which screen-captures anything, so it always counts)
let mut dominators = 0;
for d in 0..7 {
if d == 5 || can_capture(d, role) { dominators += alive[enemy][d]; }
}
let bonus = values[role] * DOM_K / (1.0 + dominators as f64);
total += if code_color(c) == persp { bonus } else { -bonus };
}
敵の卒がすべて交換で消えた将、あるいは自分を取れる駒が盤から去った任意の駒は、不可侵へと近づいていく。まさに固定テーブルでは表せない直感だ。(関数の全文)
強さは探索の増加にはなかった
静的な評価項が頭打ちになったので、次は探索深さを狙った。二つのことに阻まれた。
第一に、駒をめくる手だ。めくりノードではエンジンはそのタイルが何の駒になり得るかすべてについて平均を取らなければならず、一度のめくりが十数通りの重み付き結果に広がる。しかもめくれるタイルは多い。この偶然の分岐で木は爆発する。チェスのように素朴に深く読むことは暗棋ではできない。(Star1エクスペクティミニマックスは偶然の枝を枝刈りするので助けにはなるが、問題が消えるわけではない。)
第二に、そしてより重要なのは、ノード数を増やしても結果が動かなくなったことだ。1手あたり数十万ノードを超えると、深く読んでも参照エンジンに対する成績は良くならなかった。ボトルネックは深さではなかった。探索が行き着いた局面に点数をつける関数、つまり評価関数だった。そこで探索で強さを稼ごうとするのをやめ、対局を観察し始めた。勝率は負けていることは教えてくれるが、どの手を見るべきかは決して教えてくれない。
病理その一:勝った局面を引き分けにする
その一例がこれだ。順に追ってみてほしい。
Misty(赤)は10枚対2枚の駒数で勝っていながら、三回同一局面で引き分けになった。どちらの問題も引き分けの理解の仕方に関わる。本番では履歴なしで現在の局面だけを渡されていたので、自分が踏み込んでいく繰り返しが見えていなかった。さらに評価関数は勝ち切ることに報酬を与えないので、大差で勝っている局面と実際に勝ち切った局面がほぼ同じ点数になる。うろうろするのをやめて前進する理由がなかったのだ。
修正では実際の対局の手順履歴を探索に通し、エンジンが繰り返しの接近を見て、優勢なら避け、劣勢のときだけそれを求めるようにした(小さなコンテンプト設定)。これは約+53 Elo分の価値があった。最も醜い版、つまりエンジンが駒を取られる形で捨ててからその後結局引き分けを取る、単に引き分けを取るより厳密に悪い挙動は、別のガードで対処した。これはコンテンプトのバグに見えたが、コンテンプト0でも再現した。真の原因は、評価関数が駒を失う取りを引き分けの値よりわずかに高く採点していたことだった。
病理その二:自分の将を狩られるままにする
将を取れるのは卒、砲、相手の将で、そのうち卒は安い駒なので、無防備な将は本当に危ない。敵の卒1枚がMistyの将をa列で追い詰める様子を見てほしい。
Misty(ここでは黒)は将をa1の隅へ流してしまう。赤の卒1枚が列を進み、将は囲い込まれる。隣の升目はまだ伏せ駒で、伏せ駒の上には動けず、将は卒を取れない。ついに合法手がまったくなくなり、その場に凍りついて、28手後に取られる。
救いは、プレイヤーが言う「空気穴を作る」ことだ。狩り手が来る前に隣の伏せ駒をめくって将に逃げ道を開ける。まさにそれを促す将の安全性の項を加えると、Mistyが自分の将を失う頻度は35.5%から26%に下がった。これを計測するのに何が必要だったかを見てほしい。参照エンジンに対しては勝率はほとんど動かなかった。相手は将を狩ってこないし、Mistyはこうした対局をたいてい勝っていたからだ。ベンチマークではこの修正が見えないので、スコアを信じる代わりに災難そのものを直接計測した。(また、救いが効いたかどうかをエンジン自身の評価関数に尋ねている自分にも気づいた。危険が見えていない評価関数が、自分の盲点に対する修正を採点できるはずがないので、手で確認した。)
これは手作りが届く範囲の正直な限界だ。脅威は遅く静かな複数手の行軍で、手遅れになるまでどの静的な項にも見えない。まさに学習された評価関数が担うべき長期的な判断だ。
最後の計測:この登りには金を払う価値があるか
安い改善は使い切った。そして二つの病理はどちらも同じ修正を指している。局面を数えるのではなく理解する評価関数だ。つまり学習された価値ネットワーク、CLAP_CDCがこのゲームでコンピュータオリンピアードを制したAlphaZeroのレシピである。
そこでパイプラインを作った(盤面のエンコード、小さなResNet、偶然を扱うMCTS、悪い世代が次を汚染しないようゲート付きの自己対局)。そして本番の実行に金を払う前に、もう一つ計測をした。価値ネットが本当に手調整エンジンに勝てるかを確かめる、安価なローカルのリスク低減テストだ。
勝てなかった。リスク低減テストはノートPCのGPUで走らせ、3回の実行を通じてネットワークはアルファベータエンジンに対して勝率35%前後で頭打ちになり、改善と言える55%には届かなかった。探索予算を倍にしてもほとんど動かなかったので、局数不足の話ではない。最も可能性が高い原因は、偶然ノードが生む価値目標のノイズと、ネットワークが小さすぎることだ。どちらも直せるが、修正の検証には数時間で発熱制限がかかるノートPCよりはるかに多い計算資源が要る。しかもクラウドGPUはここではそのまま使えない。ゲームロジックがシングルスレッドのPythonなので、その部分をRustで書き直しバッチ推論サーバの背後に置くまで、速いGPUはそれを待って飢えたままになる。作業は1〜2週間、実行費は数百から1000ドル台前半。手元の証拠からすると、これは確実な一歩ではなく賭けなので、まだ踏み出していない。先に安いローカル実験をやる。
というわけでMistyBanqiは十分に戦えるアルファベータエンジンであって、最先端という主張ではない。そしてそれは行き止まりではなく、計測に基づく選択だ。CLAP_CDC、DarkKnight、その他のトップCDCプログラムとの再現可能な直接対戦はまだ行っていない。学習ネットは次の登りで、リスク低減テストが金を払う価値ありと言った日に着手する。
ここまでのすべての節に共通するパターン:このプロジェクト全体で最も安く最もてこの効いた判断は、何を計測するかを選ぶことだった。自己対局のスコア、勝率、将を失う率、リスク低減の関門。毎回、自分が本当に気にしていることを見られる計器を選ぶことが、エンジン自体へのどんな変更よりも効いた。
リソース:
- MistyBanqiとMistboardで対局する、またはルールを読む。
- misty-banqi:エンジン本体、MITライセンス、アルファベータ+Star1探索と上記の評価項。
- chinese-dark-chess-hw:評価関数の出発点にし、固定のベンチマークとして使ったオープンなエンジン。
- エンジンシリーズの続き:闘獣棋エンジンを作る、より大きな盤の完全情報のいとこ。