ブログの将棋AIが弱すぎたので、AIエージェント群に1日で作り直させた話 — バグ調査からWASM・NNUE蒸留まで
投稿者:
Yudai Yaguchi

個人サイト(meetyudai.com)に置いていた自作将棋AIが、将棋二段の自分から見て「弱すぎる」状態だった。Claude Code のサブエージェントを最大5体、並行で走らせて、丸1日でどこまで強くできるかを試した記録。うまくいった施策だけでなく、A/Bテストで容赦なく棄却された失敗施策も全部残す。原因調査の生ログ、失敗のカタログ、実測値、そして「近似指標をいくら磨いても対局では勝てない」という、何度も足をすくわれた教訓まで。費用はほぼゼロ(電気代とLLMの利用料のみ)。これはその全体像で、生ログとコードは完全版(ブログ)にある。
この記事の要点(先に3行)
- 「意味不明な手」の最大の原因は、探索エンジンでも評価関数でもなく、その手前で定跡フォールバックが探索をバイパスして即答していた配線ミスだった。思考時間ログが決定的証拠になった。
- 手書きエンジンの小改良は、本番相当の持ち時間で測ると8〜9割が「効果なし」。効いたのは構造改革——TypeScript→WebAssembly移植(探索15倍)と、その上に載せたNNUE蒸留。
- 系譜は 勝率19.6% → 32.1% → 77.1%(対・手書き評価)。最後は本番で作者が負けてsigmoid飽和という病を発見し、データの作り直しで根治。最終的に作者が実戦で「確実に強くなった」と確認。
何を作ったか
作ったのは、サーバーもGPUも使わず、ブラウザの中(WebAssembly)だけで完結する将棋AIだ。バックエンドは一切ない。ページを開くと、評価関数の重み(約1.1MBの静的ファイル)を非同期で取りに行き、あとはあなたのブラウザのCPUだけで数手先を読んで指す。スマホのブラウザでも動く。
中身は二本柱でできている。
1つめは探索エンジン——「数手先を読む」部分だ。将棋は1手ごとに合法手が平均80〜100手以上あり、それを何手も掛け合わせると組合せは天文学的になる。全部は読めないので、明らかにダメな枝を早めに切り落としながら読む。使っている技法は、ネガマックス+αβ枝刈り(相手が最善で応じる前提で、見込みのない枝を打ち切る)、PVS(主要変化探索)、置換表(一度読んだ局面の結果を再利用するキャッシュ)、LMR(後回しの手ほど浅く読む)、ヌルムーブ/futility 枝刈り、静止探索(駒の取り合いが落ち着くまで読み延ばす)、そして詰みを専門に探す詰みソルバー。要は「賢く手を抜いて深く読む」ための道具箱だ。
2つめは評価関数——読みの末端で「この局面はどっちがどれだけ有利か」を1つの数値にする"目"だ。探索がいくら深くても、末端の局面を正しく採点できなければ意味がない。最初は人間がルールで書いた評価だったが、最終的にこれを**NNUE(ニューラルネット評価)**に置き換えた。ここが記事の山場になる。
「ブラウザだけで動く」という制約は地味に効いている。学習にはGPUを使うが、動かす側(推論)はCPUだけ。しかも他人のスマホで数百msの持ち時間、という厳しい条件で強さを出す必要がある。この制約が、後半のあらゆる判断(モデルを小さく保つ、量子化する、SIMDで速くする)を縛ってくる。
何故作ったか
将棋二段の自分から見て、サイトに置いていたAIが率直に「弱すぎた」。症状は2つあった。
ひとつは、アマの基本戦法「原始棒銀」に、毎回同じようにはまって負けること。棒銀は▲2六歩→2五歩→3八銀→2七銀→2六銀→1五銀…と銀をまっすぐ繰り出して端を破る、入門書の最初に載る攻めだ。これに毎回沈むのは、対策以前に読みが破綻している証拠だった。
もうひとつは、ときどき完全に意味不明な手を指すこと。自陣に無意味な歩を打つ、玉を自分から危険に晒す。人間なら絶対に指さない手だ。
放置しているのが気持ち悪かった、というのが直接の動機。だがもう一つ、試したいことがあった——AIエージェントを並行で走らせて、ソフトウェアを1日でどこまで作り直せるか、という実験だ。将棋AIはその題材として理想的だった。理由は明快で、正解が存在する(やねうら王という、アマトップを遥かに超える強豪エンジンがある)し、強さを数値で直接測れる。だから「なんとなく良くなった気がする」という自己欺瞞に逃げられない。測れば白黒つく。
どう回したか:AIエージェントの運用
技術詳細に入る前に、進め方そのものを書いておく。ここが半分の見どころだからだ。
最大5体のサブエージェントを並行で走らせた。 担当は「探索の高速化」「評価関数の学習」「定跡の整備」「コードの堅牢化(レビュー対応)」「記事執筆」のように分けた。私自身は統合役に徹し、各エージェントが上げてくる結果を読んで、次に何を試すかを決める。人間がボトルネックにならないよう、独立に進められるタスクは独立に投げる。
問題は、5体が同じリポジトリを同時に編集すると壊れることだ。これは各エージェントを別々の git worktree で作業させることで解いた。worktree は同じリポジトリの作業コピーを複数持てる仕組みで、エージェントAが探索コードを書き換えている隣で、エージェントBが学習スクリプトを触っても、互いのファイルを踏まない。安いコストで分離でき、マージ地獄も起きない。
そして、これが一番大事なのだが、すべての「振る舞いが変わる変更」にA/Bテストを義務づけた。「強くなった気がする」は一切信じない。各変更は、本番相当の持ち時間で旧版とN局対戦し、勝率で本当に上回った時だけ採用する。この規律のおかげで、試した改良の8〜9割は棄却された。これは失敗ではなく、むしろこのプロジェクトの本質だ。もっともらしいアイデアの大半は、実測すると効かない。それを機械的にふるい落とせる仕組みを最初に作ったことが、一番効いた。
もう一つの必須ゲートがビット完全一致だ。後半でエンジンを WebAssembly に移植するのだが、その際「速くなったが、指す手が微妙に変わった」を絶対に許さなかった。移植版が、手書きTypeScript版と同じ手・同じ評価値・同じ探索ノード数・同じ葉ノード数を返すことを、数千局面での照合と、合法手の全数え上げ(perft)で確認する。perftは初期局面から深さ3で25,440手、持ち駒を打てる局面でも既知の数字と一致することを見る。100%一致した版だけを本番に載せた。速度は別物、指し手は同一——これで「高速化がバグを紛れ込ませる」事故を封じた。
フェーズ1:まず「弱い」を再現して、真犯人を特定する
弱いAIを前にすると、つい「評価関数が悪いんだろう」と当たりをつけて直したくなる。だがそれは推測だ。まず物証を取ることにした。
負ける将棋——棒銀に沈む手順——を、固定した棋譜として再現できるスクリプトにした。そのスクリプトが、本番とまったく同じAI呼び出し経路に1手ずつ局面を渡し、返ってきた手・評価値・駒割・思考にかかった時間をログする。
ここで決定的な証拠が出た。AIが1手を1〜23ミリ秒で返していた。 数手先をまともに読めば、数百ミリ秒から数秒はかかる。1〜23msという即答は、「実は読んでいない」ことを意味する。
追いかけると、真相はこうだった。定跡ブックのフォールバック機構が、探索エンジンを丸ごとバイパスしていたのだ。本来この機構は「定跡に載っている局面なら、記録された手を即返す」ためのもの。ところがバグで、定跡を外れた局面でも「それっぽい手」を探索抜きで返してしまっていた。つまり終盤の勝負どころでも、AIは一切読まずに、雰囲気で手を選んでいた。これが「意味不明な手」の正体だった。
ここが最大の発見だ。弱さの原因は、評価関数でも探索アルゴリズムでもなく、その2つの手前にある配線ミスだった。どんなに評価や探索を磨いても、そもそも探索が呼ばれていなければ全部素通りする。この機構を撤去しただけで、即答ナンセンスの一群が根絶された。
教訓は明確だ。症状(悪い手)から原因(評価が悪い)を推測すると、たいてい外す。ログという物証で殴れ。 思考時間という、一見どうでもいい数字が、決め手になった。
フェーズ2:探索を作り直す(小細工はほぼ効かず、構造改革だけが効いた)
即答バグを潰したら、次はまともに読ませた上で強くしたい。まず探索そのものに手を入れた。
試したのは教科書的な小技だ。枝刈りをもう一段追加する、手の並べ替え(良さそうな手から先に読むと枝刈りが効く)を改良する、詰み探索を延長する、各種マージン定数を調整する……。どれも理屈の上では「読みが深くなる」「無駄が減る」はずのものだった。
ところが、本番相当の持ち時間で厳密にA/Bを取ると、その大半が「効果なし」だった。深くなるはずの改良が、勝率をまったく動かさない。これは後で効いてくる伏線でもある——深く読んでも、着いた先の局面を評価関数が正しく裁けなければ、深さは勝ちに変換されないのだ。
効いたのは、小技ではなく土台の載せ替えだった。手書きの TypeScript エンジンを、WebAssembly(WASM)に移植したのだ。WASMは、ブラウザ上でネイティブに近い速度で動く低水準の実行形式で、JavaScriptよりずっと速く数値計算を回せる。同じアルゴリズムを WASM に移しただけで:
- 探索が 約15倍高速化
- 同じ持ち時間で読める深さが +3〜4手深くなった
- 旧エンジンとの直接対決で 10戦全勝
将棋で「+3〜4手深く読める」は、実力にして段位が変わるレベルの差だ。アルゴリズムは1文字も賢くしていない。実行環境を変えただけ。「賢さ」より先に「速さ」がボトルネックだったという、身も蓋もないが重要な教訓だった。
フェーズ3:評価関数を「蒸留」する(NNUE)
探索が速く深くなったので、次はいよいよ末端の"目"、評価関数を強くする番だ。
それまでの評価関数は手書きだった。駒の価値(歩は100点、飛車は約1000点…)に、玉の堅さ、駒の働き、といった項目を人間がルールで足し算する方式。読みやすく、そこそこ働くが、人間が思いつく項目の範囲という天井がある。微妙な形の良し悪しは、ルールでは書ききれない。
そこで機械学習に置き換える。使った手法が**蒸留(distillation)**だ。考え方はこうだ。
- 強豪エンジンやねうら王に、何百万もの局面を「この局面は何点」と固定深さで採点させる。これが教師データになる。やねうら王は強いが重い——ブラウザでは動かせない。だが「答え合わせをしてくれる先生」としては最高だ。
- その採点を、小さなニューラルネットに真似させる。ネットは意図的に極小に設計した。ざっくり 2,268個の盤面特徴 → 256 → 32 → 1 という構造で、途中の活性化はクリップ付きReLU(大きくなりすぎる値を頭打ちにする単純な非線形)、パラメータ総数は約59万個。小さいのは、ブラウザのCPUで一瞬に評価を出す必要があるからだ。この方式がNNUEで、面白いことにもともと将棋のために発明された(後にチェスのStockfishが輸入した)。NNUEの強みは、盤面が1手で少ししか変わらないとき、評価を差分だけ更新して高速に計算できる点にある(この保持役を「アキュムレータ」と呼ぶ)。
- 学習後の重みを16bit整数に量子化(細かい小数を整数に丸める)して約1.1MBに圧縮し、ブラウザに静的配信する。
学習の目標値は sigmoid(cp / 600) にした。エンジンの評価値(センチポーン=歩1/100単位のcp)を、sigmoidという関数で0〜1の「勝率っぽい値」に潰したものだ。この分母の 600 を覚えておいてほしい。終盤でこの設計が牙をむく。
データ:3サイクルの実測値
NNUEは一発では決まらなかった。3周してようやく形になった。それぞれが独立した小さな物語なので、順に。
第1サイクル——大敗(19.6%)。 最初に学習したNNUEを、手書き評価と等時間で自己対戦させると、**わずか19.6%**しか勝てなかった。ボロ負けだ。ところが調べると、推論の実装は完全に正しい——学習フレームワーク、TypeScript、WASM の3者で、数百局面の評価がビット単位まで一致し、等時間でのノード数も同等(差分更新も完璧に機能)。実装は正しく、負けたのはモデルそのものだった。
敗因の分析が、この記事で一番大事な学びにつながる。当時、モデルの良し悪しを「教師(やねうら王)の採点にどれだけ近いか」で測っていた。手書き評価の2〜2.5倍も教師に近い——だから強いはずだ、と。ところがこの指標は、対局の強さの予測子として不適切だった。理由はこうだ。αβ探索で効くのは、評価の絶対値ではなく、兄弟手同士の相対的な順位だ。「この手とあの手、どっちが上か」さえ合っていれば探索は正しく働く。NNUEの平均誤差は、典型的な候補手同士の評価差(100cp未満)より大きく、その順位を壊していた。一方、手書き評価は絶対スケールがズレていても自己一貫しているので、一様なズレは探索に無害だった。「教師に近いが順位はガタガタ」より「教師から遠いが順位は保つ」方が、探索では強い。これは最後まで効いてくる教訓になった。
第2サイクル——逆転(77.1%)。 敗因が「順位を保つには誤差が大きすぎる=データ不足」だと踏んで、教師データを大幅に増やして学習し直した。すると勝率が 19.6% → 32.1% → 77.1% と伸び、手書き評価を明確に上回った。データを増やすだけで、大敗が快勝に反転した。あわせてポンダリング(相手が考えている時間にも、こちらは読み続ける「常時思考」)を本番投入し、平均到達深さを 9.00 → 9.35 に底上げした。ここで初めて本番投入にこぎつけた。
第3サイクル——速度、そして敗北からの根治。 勝てるようになったので、今度は速くする。WASMのSIMD命令(1命令で複数の数値をまとめて計算する)で評価を6.2倍高速化し、さらにマルチスレッド化(Lazy SMP 型の並列探索)も入れた(推定+58 Elo。ただしn=24で統計的有意には届かず、正直に「点推定」と記録した)。ここまでは順調だった。ところが——本番の最高難度で、作者(二段)がそのNNUEに負けた。せっかく強くしたはずが、実戦で足元をすくわれた。そして診断の結果、真犯人が判明する。
一番の敵:sigmoid飽和
思い出してほしい。学習の目標値は sigmoid(cp / 600) だった。この設計に、終盤で牙をむく落とし穴があった。
sigmoidは、入力が大きくなると出力が1に、小さくなると0に、両端で平らに張り付く関数だ。勝勢や敗勢の局面ではセンチポーンが巨大になる(例:+3000cp)。すると sigmoid(3000/600)=sigmoid(5) はほぼ1.0に振り切れる。問題は、そこから少し評価が動いても——+3000でも+3500でも——出力がほとんど変わらないことだ。つまり、本当は価値が違うはずの手が、全部ほぼ同じ数値に潰れて見える。
実際、診断で見つけた問題局面では、合法71手すべてが15cp幅の中に潰れていた。AIから見れば「どの手も同じ」。だから、勝ちが見えている(あるいは負けが込んでいる)局面で、AIはほぼ乱択でナンセンスな手を選んでいた。作者が負けた将棋の「意味不明な手」は、これが原因だった。皮肉なことに、深く読めるようにしたことが、むしろ飽和域の局面に到達しやすくして、症状を悪化させていた。
修正は、探索ではなくデータの作り方にあった。それまでの学習データは、決着がついた局面(大差の局面)を「退屈だから」と薄く間引いていた。まさにその薄い領域で飽和が起きていた。そこで、決着局面を意図的に多めに——全体の約半分まで——混ぜて、524万局面(採点は深さ12)で学習し直した。飽和域をたっぷり学ばせて、大差の中の微差を見分けられるようにする狙いだ。結果:
- 問題局面での手の価値の散らばりが 20cp → 532cp(26倍) に回復。「どれも同じ」が「ちゃんと差がある」に戻った
- その局面の悪手が 8個 → 4個 に半減
- 旧NNUEに 92.2%、手書き評価に 84.4% で勝ち越し(1000ms・2000msの両方で)
- そして最終的に、作者本人が実戦で「確実に強くなった」と確認した
近似指標ではなく、作者が実際に指して認めた——これが唯一信じられる合格判定だった。
分かったこと(失敗も全部残す)
正直なところ、この1日の一番の収穫は、成功よりも失敗の記録だった。
近似指標が良くても、対局で勝つとは限らない。 「教師への近さ」を2倍に上げても、実戦は強くならなかった。最後の審判はいつも同じで、「実際に対局して勝つか」だ。それも、できれば自分が実際に指す戦型を使う人間相手に。ベンチの数字は、あくまで途中の目安でしかない。
検証そのものが罠だらけ。 セルフプレイの統計的縮退(自己対戦は、同じ癖同士だと勝敗が偏って見える)、時間設定のバイアス(測る持ち時間で結論が変わる)、旧版と新版でデフォルト設定がズレていた事故——どれも「強くなった」と誤解しかけた。何度も間違った結論を掴みかけた。防御は一つだけ、本番相当の条件で測ること。
効かなかった施策のカタログ。 理屈で良さそうでも実測で死んだものを、供養として並べておく。KP特徴量(玉と駒の関係を細かく入れる強力な手法だが、100万局面ではデータ希釈で悪化した)、王手延長(A/Bで負け)、ドロップ手のLMP枝刈り(同)、詰みソルバーの深化(変化なし)。そして極めつけが本格ビットボードだ。盤面を整数のビット列として持ち、ビット演算で一括処理するこの技法は、やねうら王のようなネイティブ強豪の高速化の要だ。真似ようとしたが——32bit止まりのJavaScriptでは、81マスが1つの整数に収まらない。飛び駒の利きを求める処理がワードをまたいで面倒になり、飛び駒リスト方式の試作は、JITが既に十分最適化している配列のレイ走査より約2倍遅かった。理屈で速くても、この土俵では逆に遅くなる。ネイティブC++とは、本当に土俵が違うのだと思い知った。
これからどう強くするか
正直に書くと、ブラウザという土俵には天井がある。探索側は、この環境では実用上やり切った。SIMDもマルチスレッドもビット一致の高速化も入れ切って、これ以上「深さが跳ねる」大ジャンプは構造的に難しい。
ここからさらに強くするなら、レバーは評価(データ)側だ。有力なのは自己対戦データループ——今の強いエンジンに自分自身と対戦させて、「実際に強い将棋で出る局面」を集め、それをやねうら王で採点し直して再学習する、という正のループ。ただし採点者はやねうら王のままなので、天井はやねうら王の判断力で頭打ちになる(真の自己強化=勝敗そのものから学ぶAlphaZero式ではない)。もう一つは、作者が指して見つけた具体的な弱点を、狙い撃ちで潰すこと。飽和の発見がまさにそれで、実戦の1局が、どんな近似指標より鋭く急所を教えてくれた。
コストとまとめ
かかった費用は電気代とLLMの利用料だけ、時間は実質1日。「弱すぎる」から「作者が確実に強くなったと認める」まで、AIエージェントの並行運用と、A/B・ビット一致という地味な検証規律で到達した。
振り返ると、これは「1つの賢い発見」の物語ではない。むしろ逆で、測って、失敗を捨てて、生き残ったものだけ残す——その退屈な反復こそが効いた、という話だ。派手なアイデアの8〜9割は死に、残った少数(WASM移植、データを増やす、飽和を潰す)が全部を持っていった。強くするより、弱くする変更を確実に弾く方が、ずっと大事だった。
完全版(生ログ・コード・全数値)
上は全部ダイジェストだ。バグ調査の生ログ、失敗施策の一つひとつの詳細、各サイクルの実測グラフ、コードの中身まで、すべてブログに書いてある。機械学習が初めての人向けに、NNUE・蒸留・誤差逆伝播・量子化をゼロから説明した入門記事も別に用意した。
👉 完全版: https://www.meetyudai.com/blog/applied-algorithms/9fpLthtYgHvnImuFR0Jf 👉 入門編(ML初心者向け): https://www.meetyudai.com/blog/applied-algorithms/H9beqVVY7h5qc3hNfskR
コメント (0)
まだコメントはありません。
コメントするにはログインが必要です。
Googleでログイン