暗棋(アンチー): 自己対戦は+77 Eloと言ったが、実際の数値はゼロだった
暗棋(アンチー、Chinese Dark Chess)向けの古典的なRust製エンジン、MistyBanqiを作った。 MistyBanqiはMistboardでプレイできる。アルファベータとStar1 expectiminimax、手作りの評価関数を使い、対局版にニューラルネットワークは入っていない。
これは最強エンジンだという主張ではない。強い暗棋(アンチー)プログラムであるCLAP_CDCとDarkKnightはクローズドで、より強い。これは自分が出す気のあった予算の範囲で作れた、最も強いオープンソースエンジンの制作報告だ。有用な成果は魔法のような評価項目ひとつではなかった。進歩のほぼすべては、強さの測り方を直したことから来ている。
評価関数はgeorge0828ZhangのオープンソースCDCエンジンを出発点にし、そのエンジンをすべての変更を測るための固定対戦相手として保持した。MistyBanqiは今では十分に強く、そのベンチマークとほぼ互角で、オープンソースだ。
ルールはmistboard.com/rules/banqiにあり、実際に動かせる盤もある。ここで重要なルールはひとつ: 手は通常の移動か取りか、あるいは伏せ駒をめくって未公開の駒の袋からランダムな駒を明かすめくりのいずれかだ。めくりは偶然事象なので、通常のアルファベータに加えて探索は偶然ノードを持ち、それをStar1 expectiminimaxで扱う。残りはいつもの仕掛け、置換表と静止探索だ。局面に点をつける部分はすべて手書きで、それが以下の話の主題そのものだ。
自分自身を相手に測ってはいけない
最初の改良はいつもの探索の仕掛け、置換表と繰り返し検出だった。それを自分のエンジンの以前のビルドとペア方式の自己対戦で測った。勝率60.9%、およそ+77 Eloで、どの統計も向上は本物だと言っていた。
次に同じ変更を参照エンジンに対して走らせた。+77 Eloは消え、結果は互角だった。自分自身を相手に調整したボットは、自分の盲点を突くことに最適化される。自己対戦の数値が測っていたのは、自分のエンジンが考える「難しい相手」であり、それは自分のエンジンそのものだ。そこでそのエンジンを基準とし、以降の変更はすべてそれに対する大規模なペア対戦で測ることにした。片側数百局を、Modal上のコンテナに分散させて各ランが1時間以内に終わるようにした。暗棋(アンチー)は引き分けが多いので、数%の差を見るにはそれだけの局数が必要だ。最初にやっていた20局の対戦はノイズだった。
評価関数の調整
正直な基準が最初に見つけたのは、ずっと抱えていた駒価値テーブルのバグだった。修正後はこうだ:
| 駒 | 価値 |
|---|---|
| 将 | 30 |
| 砲 | 16(以前は12) |
| 士 | 14 |
| 象 | 11 |
| 車 | 9(以前は14) |
| 馬 | 7 |
| 卒 | 4 |
砲と車が逆だった。砲は台となる駒を飛び越えて取るので、取り駒の序列では低い位置にありながら、盤上で最も戦術的に危険な駒になる。車は単純な走り駒だ。この修正ひとつだけで数ポイントの価値があり、評価関数の改善の大部分を占めた。自己対戦では絶対に見つけられなかった。自分のエンジンのどのバージョンも同じ誤ったテーブルを抱えていたからで、それを暴くには外部の相手が必要だった。
より興味深い調整は、駒の価値が定数ではなく盤上に何が残っているかに依存するという点だ。参照エンジンはすでに、卒が取れる唯一の駒である将についてその小さな版を実装していた。将を脅かすものが消えていくにつれて、その価値は上がる。私はそれをすべての駒に一般化した。各駒には自分を取れる敵駒の集合があり、それらが交換で消えていくにつれて価値は「触れられない」方向へ上がる。この項目、適応的支配(adaptive domination)は評価関数で単独最大の改善で、スタック全体で参照エンジンに対し+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 expectiminimaxは偶然の枝を枝刈りしてくれて助かるが、問題がなくなるわけではない。)
第二に、より重要なのは、ノード数を増やしても結果が動かなくなったことだ。1手あたり数十万ノードを超えると、深く指しても参照エンジンに対する成績はそれ以上良くならなかった。ボトルネックは深さではなく、探索が行き着いた局面に点をつける関数、つまり評価関数だった。そこで探索で強さを稼ごうとするのをやめ、対局を見始めた。勝率は負けていることを教えてくれるが、どの手を見るべきかは決して教えてくれない。
病理その一: 勝っている対局を引き分けにする
一例を挙げる。手順を追ってみてほしい。
Misty(赤)は駒が10対2で優勢なのに、三回の同一局面反復で引き分けた。どちらの問題も引き分けの理解の仕方に関わる。本番では履歴なしの現在局面だけが渡されていたので、自分が突っ込んでいく反復に気づけなかった。そして評価関数は勝ちをまとめることに何の報酬も与えないので、圧勝の局面と実際に勝ち切った局面がほぼ同じ点数になる。うろつく代わりに前進する理由がなかったのだ。
修正では実際の対局の手順履歴を探索に通し、エンジンが反復の接近を見て、優勢なら避け、劣勢のときだけそれを狙うようにした(小さなcontempt設定)。これで約+53 Elo。別のガードが最も醜い版を扱う。エンジンが取られる手に駒を差し出し、その後でどうせ引き分けを取る、という単に引き分けを取るより厳密に悪い挙動だ。これはcontemptのバグに見えたが、contemptゼロでも再現した。真の原因は、評価関数が駒損の取りを引き分け値のわずかに上に採点していたことだった。
病理その二: 将を狩られるままにする
将を取れるのは卒だけなので、浮いた将は本当に危ない。1枚の敵の卒がa列でMistyの将を狩る様子を見てほしい。
Misty(ここでは黒)は将をa1の隅へ漂わせる。赤の卒が1枚その列を上がってきて、将は囲い込まれる。隣のマスはまだ伏せ駒で、伏せ駒の上には動けず、将は卒を取れない。最後には合法手がまったくなくなってその場に凍りつき、28手後に取られる。
救いは、プレイヤーが「luftを作る」と呼ぶ手だ。狩り手が到着する前に隣の伏せ駒をめくって将の逃げ道を開ける。まさにそれを誘導する将の安全性の項目を追加し、Mistyが自分の将を失う頻度を35.5%から26%に減らした。これを測るのに何が必要だったか見てほしい。参照エンジンに対しては勝率がほとんど動かなかった。参照エンジンは将を狩らないし、Mistyはこれらの対局をたいてい勝っていたからだ。ベンチマークは修正を見られなかったので、スコアを信じる代わりに惨事そのものを直接測った。(救いが効いたかどうかをエンジン自身の評価関数に聞いている自分にも気づいた。危険が見えていない評価関数が、自身の盲点への修正を採点できるわけがないので、手作業で確認した。)
これは手作りが届く範囲の正直な限界だ。脅威は遅く静かな複数手の行進で、手遅れになるまでどの静的項目にも見えない。まさに学習された評価関数が担うべき長期的判断だ。
最後の計測: この登坂は金を払う価値があるか
安い改善は使い果たし、二つの病理はどちらも同じ修正を指している。局面を数えるのではなく理解する評価関数だ。それは学習された価値ネットワークであり、CLAP_CDCがこのゲームでComputer Olympiadを制したAlphaZeroのレシピだ。
そこでパイプラインを作った(盤面エンコード、小さなResNet、偶然を考慮したMCTS、悪い世代が次を汚染しないようゲート付きの自己対戦)。そして本格的なランに金を払う前に、もう一つ計測をした。価値ネットが手調整のエンジンに実際に勝てるか確かめる、安価なローカルのリスク低減試験だ。
勝てなかった。ノートPCのGPUでリスク低減試験を走らせ、3回のランを通してネットワークはアルファベータエンジンに対する勝率が35%前後で頭打ちになり、改善と見なせる55%には届かなかった。探索予算を倍にしてもほとんど動かないので、局数不足のケースではない。最も可能性の高い原因は、偶然ノードが生む価値ターゲットのノイズと、小さすぎるネットワークだ。どちらも直せるが、その修正を検証するには数時間で速度が落ちるノートPCよりはるかに多い計算力が必要で、ここではクラウドGPUも差すだけでは使えない。ゲームロジックがシングルスレッドのPythonなので、その経路をバッチ推論サーバの背後でRustに書き直すまでは、速いGPUは飢えて待つだけになる。それは1、2週間の作業で、そのうえ実行に数百ドルから低めの数千ドルかかる。手元の証拠では、これは確実な一歩ではなく賭けなので、まだ踏み出していない。安いローカル実験が先だ。
というわけでMistyBanqiは十分に強いアルファベータエンジンであって、最先端だという主張ではない。そしてそれは行き止まりではなく、計測に基づく選択だ。CLAP_CDCやDarkKnight、他の上位CDCプログラムとの再現可能な直接対戦はまだ行っていない。学習ネットは次の登坂で、リスク低減試験が払う価値ありと言った日に取り組む。
ここのどの節の下にもあるパターン: このプロジェクト全体で最も安く最もてこの効いた決定は、何を測るかを選ぶことだった。自己対戦のスコア、勝率、将の喪失率、リスク低減のゲート。毎回、自分が本当に気にしていることを見られる計器を選ぶことが、エンジン自体への変更よりも効果があった。
資料:
- MistboardでMistyBanqiをプレイ、またはルールを読む。
- misty-banqi: エンジン本体、MITライセンス、アルファベータ + Star1探索と上記の評価項目。
- chinese-dark-chess-hw: 評価関数の出発点にし、固定ベンチマークとして使ったオープンなエンジン。
- エンジンシリーズの続き: 闘獣棋エンジンを作る、より大きな盤の完全情報の親戚。