Loading post
Jul 06, 2026

個人サイト(meetyudai.com)に置いていた自作将棋AIが、将棋二段の自分から見て「弱すぎる」状態だった。Claude Code のサブエージェントを最大5体並行に走らせて、1日でどこまで強くできるかやってみた記録。原因調査のログ、失敗した施策のカタログ、成功した施策、実測値をすべて残す。費用はほぼゼロ(電気代とLLMの利用料のみ)。
サイトには2つの将棋ページがある。
/games/shogi — オリジナルUI + 手順列マッチの定跡ブック + エンジン/games/shogi-improved — 高速エンジン用UI + 局面ハッシュマッチの定跡ブック + Web Worker(Lv4/5)エンジンは TypeScript 製。(suji<<4)+dan の1次元配列盤面、make/unmake方式、Zobristハッシュ+置換表(TT)、negamax+αβ+PVS+反復深化という、教科書的には正しい作り。ここに至るまでに ShogiAIImprovedV2〜V18 という改良の地層が積もっていた。今回の作業の出発点を理解するために、まず系譜を整理する。
| 版 | 主な特徴 | 備考 |
|---|---|---|
| V2 | ベース実装:negamax+αβ、PVS、TT、killer/history、MVV-LVA、静止探索 | クローンベース legality の重さが課題 |
| V3 | 千日手検出、null move pruning(上位レベルのみ) | 実験版 |
| V4 | 千日手コンテンプト、王手延長、delta pruning(いずれも初期値OFF)、root fallback | 「強いはずの機能を入れたがOFF」の時代 |
| V5 | mate-distance境界、王手を含む静止探索(quiet check probing) | |
| V6 | 直写像の評価キャッシュ、root専用の序盤らしさ ordering | |
| V7/V8 | root限定の王手延長(master)、root限定の drop 安全性 ordering | |
| V9/V10 | root ordering キャッシュ、パック型TT(Teオブジェクトを複製せず move key を保存) | GC圧の削減 |
| V11 | プール式手生成(Teオブジェクトをply毎に再利用) | 安定ベースラインとして長く比較対象に |
| V12 | 「玉が危険な時は囲い ordering を弱める」圧力ゲート | 「攻められているのに囲う」対策 |
| V13 | 攻めた探索実験 → 自己対戦で不安定・弱く、不採用のまま現存 | 失敗ブランチの記録 |
| V14 | 定跡ブック拡充、ブック検証の軽量評価 evaluateForOpeningBook | |
| V15 | V11の速度+V12の圧力ゲート統合、エンジン内ブック参照 | |
| V16 | drop ordering の目的化(玉近傍優先・遠方打ちのペナルティ)、コンテンプト、delta pruning 有効化 | |
| V17 | SEE風 ordering + ノード毎の利きスキャンキャッシュ、王手延長(実験) | |
| V18 | V16路線 + 利きキャッシュ + 高価値 drop の hanging 判定 ordering | 今回の出発点(本番稼働版) |
この歴史から読み取れるパターンが2つある。(1) 機能は増えるが「怖いのでOFF」「上位レベルのみON」が多い(誰も体系的にA/B検証していない)。(2) 評価関数はV2からほぼ同じ手書き構造(駒割+PSQT+玉安全+囲い+筋防御)で、駒の脅威に関する項が薄い。
初期状態の実測:3秒の思考で内部ノード約4,000・静止探索ノード約51,000、探索深さ5前後。これはJS製将棋エンジンとしても1〜2桁遅く、あとで最大の伸びしろになる。
最初の依頼は「強くして、PRを作って、マージして、強くなるまでテストして」。まず現代のチェス/将棋エンジンで実績のある技法のうち、V18に欠けていたものを実装した(V19):
PRには自動レビュー(Gemini/Copilot)が付く。V19のPRでは9件の指摘のうち、5件は「利き判定関数の引数の意味を逆に読んだ誤検出」だった(getLeastAttackerValue(k, target, teban) の第3引数は「守り側」で、返るのは「その敵の最安攻撃駒」——docコメントに明記されている仕様)。一方で「plyが配列サイズを超えたときの範囲外アクセス」という本物の指摘が2件あり修正した。自動レビューは「全部従う」でも「全部無視」でもなく、1件ずつ裏取りして正誤を判定するのが正しい付き合い方だった。
V19をデプロイした後、著者(将棋二段)からのフィードバックは辛辣だった。「本当に強くなってるの?」「いつも棒銀がうまくいってしまっていた」。原始棒銀はアマチュアの基本戦法。これに毎回負けるのは致命的だ。
勘で直すのではなく、再現スクリプトを書いた。スクリプト化した原始棒銀(▲2六歩→2五歩→3八銀→2七銀→2六銀→1五銀→2四歩…)を本番のAI呼び出し経路にぶつけ、評価値と駒割の推移をログする。結果、mediumのAIは49手で完敗。
ply 9 ▲ 27->26 evalV3(SENTE)=-485 ← 銀が2六に進軍中
ply 10 △ 13->14 evalV3(SENTE)=-640
ply 16 △ 71->62 evalV3(SENTE)=-892 ← 銀が2四に迫る中、AIは無関係の銀を発展
ply 17 ▲ 24->23成 ← 銀成り込み
ply 18 △ 32->23 material=-2000
ply 19 ▲ 28->23成 material=+600 ← 金を取って飛車成り。教科書通りの棒銀成功
ログから3つの原因が特定できた:
対棒銀の受けはプロ棋士の解説記事で確認した。骨子は「▲2五歩には△3三角」「△1四歩で銀を五段目に出させない」。これを評価関数の「棒銀プレッシャー項」(銀の進軍度×受けの形で加点減点、先後ミラー)と、両ブックの△3三角型ラインとして実装。ついでにブックデータの破損(8二→3七のような非合法手が「8五歩」というコメント付きで混入)も修復した。
修正後、スクリプト棒銀は2プランとも銀をタダ取りされて敗退(△1四歩に▲1五銀と突っ込み歩に取られる)。
このとき入れた「敵陣の成駒±1000」は、直後の自己対戦で全体を大幅に弱くしていることが判明(v3評価 vs 基準評価で2勝7敗、従来は5勝2敗3分)。原因切り分けのために環境変数フラグで項を個別にOFFにして対戦させ、±1000項が主犯と特定 → ±350へ。**「意図した局面で正しい大きな項」は、全局面分布では歪みになる。**評価関数の変更は以後すべて「変更前エンジンとの直接対決」を通すルールにした。
さらにこの修正の副作用も面白かった。1手静的評価でブック候補を安全確認する仕組みが、「角がタダ取りされる△7七角成」を**+1000のボーナス付きの最善手と誤認し、比較基準が吊り上がって正しい定跡手を全部棄却**するようになった。対策として、静的検証に「動かした駒がタダ取りされるなら価値分を引く」SEE-lite補正を入れた。評価関数・ブック・検証器は連成系で、1箇所を触ると別の場所が壊れる。
修正デプロイ後、著者から実戦棋譜が届いた。相掛かりの出だしから:
9. ▲2四飛
10. ☖4二飛 ?? ← 飛車先交換に受けず自分の飛車を動かす
11. ▲2三歩打
12. ☖9四歩 ?? ← 角取りの脅威を無視して端歩
▲2四歩△同歩▲同飛への△2三歩は相掛かりの一丁目一番地。これを外すのは異常だった。
局面を再現スクリプトで本番経路に問い合わせると、決定的な証拠が出た:
move 10: AI(hard) plays 82->42 (23ms) ← hardは2秒枠のはず
move 12: AI(hard) plays 93->94 (1ms) ← 1ms!!
エンジンは1手も探索していなかった。 犯人は定跡ブックの「resyncフォールバック」——定跡を外れた局面で「それらしい発展手」(飛車振り+900点、端歩+1400点のようなヒューリスティック)を1手静的チェックだけで即答する、遅いエンジン時代(V16期)の救済機構だった。3つの欠陥が重なっていた:
resyncを完全撤去し「定跡外は必ず探索」へ。同じ局面で△2三歩打(2.0秒探索)を返すようになった。
デバッグの経験則:「多秒の持ち時間があるレベルで、悪手が瞬時に返ってきたら、探索ではなくバイパス経路を疑え」。以後の再現スクリプトには必ず思考時間の表示を入れた。
次の実戦棋譜では、▲3六銀→2五銀→3四銀と歩をかすめ取られた。調査すると、第2章で入れた棒銀プレッシャー項の銀スキャン範囲がdan5〜7限定で、銀が3四(dan4)に侵入した瞬間=攻めが成功した瞬間にペナルティが消えていた。「成駒が評価から消える」バグと完全に同型。しかも自動レビューが「dan1〜2も同じ盲点では」と指摘し、その通りだった。同じ形のバグは同じ場所(境界)に巣を作る。
著者から設計方針の指示が来た。「全てのモデルは最新の思考に統一して、考慮の深さだけ変更。前のモデルに圧倒的に勝つまで精錬して」。もっともだ。V2〜V18の歴史が生んだ「機能はあるが難易度別にON/OFFがバラバラ」状態を清算し、V20では全難易度が同一の探索構成、違いは時間だけ(easy 250ms / medium 1s / hard 2s / expert 4s / master 5s——master旧10秒から半減)にした。
最初の統一版はV18に対して6勝6敗4分と拮抗止まり。プロファイルすると衝撃の無駄が見つかった。
結果:同一3秒で深さ5→7、内部ノード4千→2.2万。セルフプレイ(旧V18には旧来の長い持ち時間を与えたまま):
| レベル | 条件 | 新V20 | 旧V18 | 分 |
|---|---|---|---|---|
| easy | 等時間250ms | 7 | 2 | 3 |
| medium | 1s vs 0.8s | 13 | 2 | 1 |
| hard | 等時間 | 10 | 0 | 2 |
| expert | 4s vs 旧5s | 6 | 2 | 2 |
| master | 5s vs 旧10s | 5 | 2 | 1 |
このフェーズでセルフプレイ検証の落とし穴を3つ踏んだ:
「AIは攻撃されても無視する」問題への対策として、浮き駒(当たりになっている駒)ペナルティを評価に入れた。静止探索は「自分が取る手」しか解決しないので、「取られそうな自駒」は静的評価に見えない——理論は正しい。だが最初の設計(「守られているが安い駒に当たられている」も期待損失の50%で計上)は0勝4敗の大惨敗。歩が銀に触れるだけで±450点動く評価はノイズ製造機だった。「攻撃されていて、かつ誰も守っていない駒だけ、価値の1/3」に絞って正常化。倍率・対象・条件を1つずつ計測して初めて使い物になる。
ここから開発体制を変えた。著者の指示は「全部サブエージェント使って同時並行でやって」。Claude Code のサブエージェントを最大5体並行で走らせる:
.claude/worktrees/agent-xxx/)を持ち、互いのファイルを踏まないps aux や tail -f で人間からも完全に観察できる)途中で検証プロトコルの重要な変更もあった。当初エージェントには「旧V18に勝て」と指示していたが、著者から「対V18じゃなくて対V20(現行)でやって」。もっともな指摘で、以後は変更前のエンジンをv20baseという凍結スナップショットとして登録し、現行との直接対決で採否判定する方式に統一した(感度も再現性も上がる)。
| エージェント | 結果 | 詳細 |
|---|---|---|
| 探索技法 | 1/4採用 | Continuation history採用(固定深さノード40%減、対現行9勝5敗6分)。ProbCut棄却(2方式試すも実対局で悪化——深さ7エンジンの「浅い検証探索」は深さ3で、本物の戦術を刈る)。History gravity棄却(短時間思考では新鮮な履歴を捨てるだけ)。Singular extensionは見送り |
| 詰みソルバー | ✅ 採用 | 王手限定AND/OR探索(〜9手詰み、打ち歩詰め・連続王手千日手対応)。5手詰みを36msで確実に発見(従来は最大1秒+運任せ)。一般局面ELOは中立(3勝3敗4分)——「詰み局面の確実性」が価値で、これを10局の対戦成績で測るのはそもそも筋違い、という測定論の教訓付き |
| 定跡拡充 | ✅ 採用 | 検証済み12ライン追加+ブック機構の実バグ3件発見(角交換の1手静的評価暴走でブック全滅/交換途中の必須応手を返せない±900ゲート/ブックが打つ手を構造的に指せない) |
| Texelチューニング | ❌ 不採用(基盤は採用) | 評価重み16パラメータを座標降下法で最適化 → 直接対決48局で17勝14敗17分、有意差なし。診断も数値で残した:70ms自己対戦の1,698局面では「評価→勝敗」の信号がノイズ以下(適合誤差が定数予測より悪い)。本家Texelは数百万局面。手法は正しいがデータの量と質が足りない。K値推定に「更新中の値を上限に使う無限山登り」バグも発見・修正 |
| WASMスパイク | ✅ 大当たり | 下記 |
もっともらしい改善の生存率は約1/4〜1/3。Stockfish開発でも通る施策は1/5〜1/10と言われる。計測して棄却する仕組みがなければ、これらは全部「入って」いた。
統合時にソルバーの効果を独自再現しようとして「両者とも詰みを逃す」結果が出て焦った。原因は私の再現局面の持ち駒が金1枚(正解は金2枚——▲4二金打と▲6二金打の両方が必要な5手詰み)。つまり詰みが存在しない局面で「詰みを逃した」と測っていた。再現失敗の第一容疑者は自分の再現コード。
決定戦(本番時間32局)の結論は明確だった:JS側の小改良の合算は本番持ち時間では強さ中立(9勝12敗11分)。「アイデアの打率」に頼るのをやめ、効果が機械的に保証される速度に投資する。
いきなりフル移植せず、手生成だけをAssemblyScriptで書いてperftで実測:
| ベンチ | JS | WASM | 倍率 |
|---|---|---|---|
| 初期局面 perft d4 (718k葉) | 798ms | 28.8ms | ×27.7 |
| 持駒局面 perft d4 (25.7M葉) | 28,440ms | 894ms | ×31.8 |
perftの数字はJS実装と全深さで完全一致(=移植の正しさの証明)。出力wasmはわずか5.2KB。この実測を根拠にフル移植を決定し、4フェーズをエージェントの連鎖で実行した。
env.nowインポートによる時間管理)。固定深さ48/48局面でbestMove・スコアだけでなくノード数までバイト一致(探索木が同一という証明)。3秒で深さ11〜12、そして現行エンジンと直接対戦10勝0敗0分移植の最大の武器はパリティテストだった。「だいたい同じ」ではなく「ビット一致」を要求するとバグは全部網にかかる。逆にパリティが取れれば、速度差はそのまま強さになる。CI では ESLint が AssemblyScript のデコレータを TS としてパースして落ちる、という統合あるあるも踏んだ(lint対象から除外して解決)。
デプロイ後、著者(二段)がmasterと対局して「この手は疑問では」という棋譜を送ってきた(△4四角から**△3五角**と飛車取りに当てた手。「角成りで歩を取った方が良かったのでは」)。ローカルのやねうら王(深さ17)に答え合わせさせると:
| 候補 | 評価 |
|---|---|
| △3五角(AIの実戦手) | 後手+150(最善一致)。狙いは次の△5七角成 |
| △6六角(歩得に見える) | 後手-1977 — ▲8八角でタダ取り(6六は角の利きの中) |
| △1七角成(歩得+成に見える) | 後手-2037 — ▲2九桂でタダ取り |
masterのAIがプロ超級エンジンの最善と一致し、人間の改善案は2つとも角をタダで失う手だった。強くなった証拠として、これ以上のものはない。
手書き評価関数の限界(歴史的にアマ有段止まり)を超えるには、機械学習した評価関数が要る。
その定番がNNUE(Efficiently Updatable Neural Network、読み「ぬえ」)——実は将棋発祥の技術だ。2018年に将棋プログラマの那須悠氏が考案し、やねうら王系エンジンで将棋界を席巻、2020年にはチェス最強のStockfishが逆輸入して世界標準になった。本質は名前の通り「効率的に更新できる」構造にある:1手指しても盤面の変化は駒2〜4枚ぶんだけなので、第1層の計算結果を差分更新すれば、毎秒数十万回というαβ探索の評価要求にCPUで応えられる(この差分更新でつまずいた顛末は失敗カタログ(4)で)。本章はこのNNUEの小型版を自作エンジン用に実装し、発明元の系譜であるやねうら王を先生にして学習させる。
なおLLM(ChatGPTのような言語モデル)とは別物である。NNUEは約59万パラメータで盤面から数字1個を返すだけの小さなネット、LLMは数千億パラメータでテキストを生成する巨大なTransformer——規模にして約100万倍違う。このプロジェクトでLLMが働いた場所はただ一つ、このAIを作る作業そのもの(コードを書いたのはClaudeのエージェント群)で、出来上がった対局AIの中にLLMは1ミリも入っていない。
よくある誤解と実際:
自作エンジンの自己対戦(1手25ms + 20%ランダム分岐)
→ 多様な局面をSFEN化(王手中除外、重複排除)
→ やねうら王 8プロセス並列にUSIプロトコル(stdin/stdout)で評価依頼(go depth 8)
→ {"sfen":"...","cp":-2161,...} をJSONLに追記(再開可能・冪等)
実測スループット:ラベリング約1,400局面/秒
10万局面を約65分で生成。途中、やねうら王8プロセスが全滅して生成が止まる事故が起きた——皮肉なことに、その1時間前にレビュー指摘対応で「エンジン全滅時に無限ループする」バグを直したばかりで、修正前の旧コードで走っていたジョブが正にそのシナリオを踏んだ。追記式+再開可能設計だったのでデータ欠損ゼロで復旧。
検証1万局面で「教師(やねうら王)の評価にどれだけ近いか」:
| 評価器 | 平均誤差 | 中央値 | p90 |
|---|---|---|---|
| 蒸留NNUE(float) | 405cp | 263cp | 939cp |
| 手書き評価(最大限有利に線形校正後) | 800cp | 648cp | 1671cp |
| 常に0と答えるベースライン | 1662cp | 1962cp | 2824cp |
蒸留ネットは手書き評価より2.0〜2.5倍教師に近い(全形勢バケットで一貫)。int16量子化の誤差は平均24cp——モデル誤差405cpに対して無視できる。
WASMに素朴に推論を実装したら評価1回6.7µs(手書き0.86µs)で探索深さが11→6に落ちた。NNUEの本質は名前の通り差分更新(Efficiently Updatable):1手で変わる特徴は駒2〜3枚分なので、第1層の累積値をmake/unmakeで差分更新する。
ところが素朴に「指すたびに更新」するとperftが14倍遅くなった(探索は「指してすぐ枝刈りされて戻す」手だらけなので、その全部にacc更新コストを払う)。解は遅延適用:指すときは差分をスタックに積むだけ(数ns)、評価が実際に呼ばれた時だけまとめて折り込む。
最終結果:評価1.15µs(5.4倍高速化、手書きの1.2倍まで接近)、NNUE有効でも探索深さ9〜15を維持。 すべての段階でTypeScript参照実装・PyTorch(int16シミュレーション)とのビット一致検証付き。手番視点の特徴のため「両視点アキュムレータ」を常時維持する標準NNUE設計も踏襲(副産物としてnull moveがゼロコストになる)。
| 内容 | 結果 |
|---|---|
| V19(futility/SEE-lite/countermove等)+対棒銀の評価・定跡修正 | ✅ 対V18 68.5% |
| V20統一エンジン+高速化+時間短縮 | ✅ hard無敗、master半分の時間で勝ち越し |
| resyncフォールバック撤去 | ✅ 即答バグ根絶 |
| 定跡ガントレット(攻め筋自動テスト) | ✅ 回帰基盤 |
| 銀侵入の評価盲点修正 | ✅ かすめ取りルート撃退 |
| WASMエンジン本番投入(P1〜P4)+5エージェント統合 | ✅ 対現行10勝0敗 |
| NNUE蒸留パイプライン | ✅ コードのみ、データはローカル |
| ML堅牢性修正(レビュー10件) | ✅ うち1件は1時間後に実際に発生 |
| NNUE WASM推論(遅延差分アキュムレータ)+ NNUE vs V3 A/Bハーネス | ❌ 実重みA/B 19.6%で不採用。基盤はデフォルト無効で保全 |
| ポンダリング(permanent brain) | ✅ 平均到達深さ 9.00→9.35、本番稼働中 |
NNUEスケール校正 setNnueOutputScale + 単離A/B | ❌ 8.9%——仮説棄却(機構は残置) |
実重みでの等時間自己対戦(両側WASM、評価関数だけが差分、28局)の結果はNNUEスコア19.6%で大敗だった。推論は300局面でtorch/TS/WASMがビット一致、速度も等時間でノード数同等(アキュムレータは完璧に機能)——つまり実装は正しく、負けたのはモデルそのもの。
敗因の分析が面白い。「教師への近さ(手書き評価の2.0〜2.5倍)」という品質ゲートは、対局の強さの予測子として不適切だった。αβ探索に効くのは兄弟局面の相対順位であり、NNUEの誤差405cpは典型的な手同士の評価差(<100cp)より大きく、ランキングを壊す。一方、手書き評価は絶対スケールがズレていても自己一貫しているため、単調なズレは探索に無害。さらに探索側のマージン定数(aspiration/futility/delta)が手書き評価のスケール前提で、真のcpを返すNNUEには実効3.7倍の過大マージンになっていた。
次の一手も記録済み:教師データ10万→100万(生成は無料)、教師の深さ引き上げ、探索マージンのスケール適応、ランキング指向の損失関数。NNUE基盤(推論・アキュムレータ・A/Bハーネス)はデフォルト無効でリポジトリに保全し、いつでも再挑戦できる。——その再挑戦が、次章の第2サイクルだ。
第1サイクルの敗北(19.6%)は、複数の敗因仮説を残して終わった。第2サイクルは3本立てで進めている:(1) 評価関数とは独立に確実に効く構造的改善=ポンダリング、(2) 敗因仮説の単離A/B検証、(3) 本丸の教師データ増強。
将棋・チェスエンジンの古典的テクニック「permanent brain」。AIが着手した直後から、人間が考えている間もWeb Workerは遊んでいる——そこで応手局面(人間が今考えている局面)を探索し続け、対局を通じて持ち越されるWASM置換表(TT)を温めておく。人間が指した瞬間、本探索は熱いTTを引いて、同じ持ち時間でより深くまで到達する。
実装の芯は「同期探索をどう中断可能にするか」だった。WASM探索は同期呼び出しなので、素朴に長時間回すとWorkerがメッセージに耳を塞ぐ。そこで200msの短い探索スライスを setTimeout(0) で繋ぐループにした(その制御コードのヘッダコメントから):
// Why slices:
// - The WASM search is synchronous; a single long call would make the worker
// deaf to incoming messages (the next `bestMove`, `clearTT`, ...). Instead
// we run one short slice (default 200ms), return to the event loop via
// `setTimeout(0)`, and queue the next slice. Any message that arrived during
// a slice is dispatched *before* the queued slice callback, so calling
// `stop()` from `onmessage` reliably cancels pondering with at most one
// slice of latency.
イベントループの仕様上、受信メッセージはキュー済みのスライスコールバックより先に処理されるため、人間の着手から最大~200msで必ず中断できる。安全弁は4つ:
visibilitychange を ponderControl メッセージとして中継。復帰時は残budgetで再開UIコンポーネントのAPI変更はゼロ——Workerが答えを返した直後に、勝手に考え始めるだけだ:
// ワーカーのコード — 答えを先に返し、それから相手の時間で考え始める
ctx.postMessage({ type: 'bestMoveResult', id: msg.id, move });
if (best) startPonder(k, best, msg.difficulty, msg.tesu | 0);
ベンチマーク(同一の40手局面列、hard設定2000ms探索、人間の3秒思考を模擬):平均到達深さ 9.00→9.35(+0.35手)。20局面中9局面で深く、8で同等、3で浅い(TT置換の綾)。TTが温まりやすい序盤では最大+2手(例:5手目 d11→d13)。レビュー指摘(防御的メッセージチェック、performance未定義環境のフォールバック、初期visibility同期、ログのdev限定化)に対応してマージ・本番デプロイ済みである。
第1サイクルの敗因仮説②は筋が通っていた。「NNUEは真のcp尺度で出力するが、探索のマージン定数(aspiration窓300、futility 350/700、delta 150、RFP…)は手書きV3評価の約3.7倍尺度(教師フィット cp ≈ 0.27×v3)でチューニングされている。NNUEにとっては実効3.7倍の過大マージンで、枝刈りが甘くなっているはずだ」。
これを他の変数を一切動かさずに測るため、ASエンジンに setNnueOutputScale(numer, denom) を追加した。cp変換のi64除算に有理数係数を折り込み、truncationは1回だけ:
// 実際のコード(抜粋)
export function nnueEvaluateCp(): i32 {
const outQ = nnueEnabled && !nnueForceFull ? nnueEvaluateFast() : nnueEvaluate();
// Fold the output rescale (numer/denom, default 1/1) into the same i64
// division so there is only ONE truncation — with 1/1 this is bit-identical
// to trunc(out_q * K / 8128).
let cp = (<i64>outQ * <i64>nnueScaleK * <i64>nnueOutNumer) / (<i64>8128 * <i64>nnueOutDenom);
// ...クランプして返す...
}
デフォルト1/1は従来動作とビット一致(回帰ゼロの保証)。セッターにはオーバーフローガードを付けた(numer/denom上限1,000,000、gcdで約分、K×numer≤2^32の強制——どの順でセッターを呼んでもオーバーフローする組が作れない)。
同じrun100k重み(K=600)に校正37/10だけを適用し、同seed・同opening方式で第1サイクルと同条件の再戦(V3ベースライン相手、計28局):
| 条件 | 第1サイクル(無校正) | 第2サイクル(37/10校正) |
|---|---|---|
| 200ms×16局 (seed1) | 2勝14敗0分(12.5%) | 1勝14敗1分(9.4%) |
| 1000ms×6局 (seed2) | 1勝5敗0分(16.7%) | 0勝6敗0分(0.0%) |
| 1000ms×6局 (seed3) | 2勝3敗1分(41.7%) | 1勝5敗0分(16.7%) |
| 合計 | 5.5/28(19.6%) | 2.5/28(8.9%) |
回復しないどころか悪化した(−10.7pt)。仮説は棄却。
解釈はこうだ。無校正では実効マージンが約3.7倍緩く、枝刈りが浅い——つまりノイズの多い評価値(教師MAE≈405cp)を追加の探索で検証する保険が図らずも効いていた。校正で枝刈りが本来の強度に締まると、評価ノイズがそのまま枝刈り判断に乗る。敗因は評価関数そのものの精度(仮説①:評価ノイズが指し手ランキングを破壊)が支配的で、教師データの質・量が本丸——これが確定した。
教訓:もっともらしい機構的仮説も、A/Bで棄却されることがある。 第1サイクルの敗因分析には仮説が3つあり、まとめて対策していたら何が効いて何が効かなかったのか永遠に分からなかった。1変数ずつ単離して測る面倒に、ここで報酬が出た。機構自体は残す——強い重みができたとき、探索定数側を一切触らず1呼び出しでスケールを揃えられる。
確定した本丸に向けて、教師データ生成が回っている。
--balance オプション:|cp|>1200の圧勝局面をラベリング後に採択率30%で確率的に間引き、互角圏の比率を上げる。第1サイクルは|cp|>1000が6割超と終盤の大差局面が過剰で、αβが最も助けを必要とする「序中盤の微妙な差」の学習が薄かった--loss ranking オプション):第1サイクル最大の教訓——αβ探索に必要なのは絶対値の回帰精度ではなく兄弟局面の順位付け——を損失関数に直接反映。ミニバッチ内の教師cp差が一定範囲(既定50〜600cp)の局面ペアにmargin ranking lossを併用し、val_pair_acc(教師cp差>100cpのペアの順位一致率)を毎エポック記録する新指標に据えた蒸留の話を深掘りする前に、主役の「評価関数」を正確に定義しておく。意外と誤解されやすいが、評価関数は「この手は良い/悪い」を直接判定しない。やることは一つだけだ:
盤面を1枚見せられたら、数字を1個返す。 +250なら「先手が歩2.5枚ぶん良い」、−1200なら「後手が勝勢」、0なら「互角」。
では「手の良し悪し」は誰が決めるのか。探索である:
▲2四歩を指したら? → 数手先まで進めて評価関数に聞く → +180
▲7八銀を指したら? → 数手先まで進めて評価関数に聞く → −2500(角がタダ)
→ ▲2四歩を採用
「手の良し悪し」=「その手の先にある局面たちの点数比べ」。評価関数は審査員、探索は候補手の未来を審査員に見せて回るツアーガイドだ。第1サイクルの教訓——審査員に必要なのは点数の正確さより「AとBどちらが良いか」の順位——は、この構図から出てくる。
本番で動いている手書き評価関数は、人間が読めるルールの足し算である(局面クラスの実装骨子):
score = 駒の損得; // 歩=100点、飛車=1040点…の合計差
score += 駒の位置ボーナス; // 「この駒はこのマスで+何点」の表(局面の進行度で重み替え)
score += 玉の守りの枚数; // 玉の周囲の金銀の数
score += 囲いの形; // 矢倉・美濃・穴熊のパターン照合
score += 飛車筋の受け;
score += 棒銀プレッシャー; // 第2章で追加した項
score += 大駒の働き;
return score; // 例: +250
一方ニューラルネット版は、同じ質問(盤面→数字)に答える中身の全く違う機械で、開けても58万個の無名の数字が並んでいるだけ。「玉の守り」という行はどこにもない。それでも探索から見れば両者は完全に交換可能——探索が要求するのは「局面を渡すと数字が返る箱」だけで、箱の中身は問わない。本章のA/Bマッチは文字通り「審査員だけ入れ替えて、同じ探索で対局させる」実験である。
残る疑問はこうだ。中身が読めないのに、なぜ正しい答えを出せるのか? 答え:「正しさ」を説明可能性ではなく、結果で測っているからだ。身近に完璧な前例がある——有段者の大局観である。二段の人は局面を一目見て「これは先手持ち」と感じ取れるが、その判断過程を完全には言語化できない。「駒得だから」と後付けの説明はできても、脳内で起きている計算は本人にも見えない。それでも判断は当たる。ニューラルネットはこの「言語化を経由しない直感」を数字の塊として実装したもので、手書き評価が「言語化できた知識」しか持てないのに対し、やねうら王の判断100万問からパターンを直接吸収する。
読めないものをどう信用するか。読む代わりに、試験する。
val_pair_acc 0.89 = 100回中89回、先生と同じ側を「良い」と言う)この記事で繰り返してきた「最終ゲートは対局」は、「中身が読めないものは行動で審査するしかない」の帰結でもある。
裏返しのリスクも同じ場所にある。ネットが間違えるとき、なぜ間違えたかも読めない。手書き評価なら「棒銀の項がdan4で切れていた」とピンポイントで直せた(実際に直した)。ネットの誤りは「データを変えて再学習」でしか直せない——第1サイクルの19.6%敗北から教師データ増強へ、という一見遠回りの道のりは、実はニューラルネット唯一の修理方法だったのである。
「機械学習」と言うと巨大で不透明なものを想像するかもしれないが、蒸留に使っているネットワークの定義はまるごと引用してもこの分量だ(学習コードの DistillNet 全体。コメントは本記事用の注釈に差し替えてある)。各行が何をしていて、なぜそう設計したのかを注釈する。
class DistillNet(nn.Module):
H1 = 256 # 第1層の幅。NNUEの伝統的な設計に倣った値で、これが実行速度をほぼ決める
H2 = 32 # 第2層の幅。第1層の1/8まで一気に絞るのがNNUE系の特徴
def __init__(self):
super().__init__()
# ① 盤面の入力層。「7六に先手の歩がある」のような『事実』2268種それぞれに
# 256個の数字(重みベクトル)を割り当てた巨大な表。局面の評価は
# 「成立している事実の重みベクトルを全部足す」ことから始まる。
# EmbeddingBag(mode="sum") はその「該当行を引いて足す」を一発でやる部品。
# padding_idx はバッチ整形用のダミー行(常にゼロで評価に寄与しない)
self.board = nn.EmbeddingBag(BOARD_FEATS + 1, self.H1, mode="sum", padding_idx=PAD_IDX)
# ② 持ち駒の入力層。歩を何枚持っているか等14種の枚数 → 同じ256次元へ
self.hand = nn.Linear(HAND_FEATS, self.H1) # bias が第1層の bias を兼ねる
# ③④ 縮約層。256次元に散らばった情報を32次元 → 最終的に1個の数字(評価値)へ
self.l2 = nn.Linear(self.H1, self.H2)
self.l3 = nn.Linear(self.H2, 1)
# 初期値: 盤面テーブルは小さな乱数から出発(ダミー行だけゼロ固定)
nn.init.normal_(self.board.weight, std=0.01)
with torch.no_grad():
self.board.weight[PAD_IDX].zero_()
def forward(self, board_idx, hands):
a1 = self.board(board_idx) + self.hand(hands) # 盤面と持ち駒の寄与を合算
h1 = torch.clamp(a1, 0.0, 1.0) # ClippedReLU: 0〜1に刈り込む。NNUE流の活性化で、
h2 = torch.clamp(self.l2(h1), 0.0, 1.0) # 後でint16に量子化しても壊れない性質を持つ
return self.l3(h2).squeeze(-1) # 出力1個 ≈ cp / 600(600で割った評価値)
サイズ感を言うと、パラメータの大半は①の表——2268行×256列 ≈ 58万個の数字——で、そこには「玉の守り」も「棒銀」も書かれていない。手書き評価関数が「囲いの形」「飛車筋の受け」といった人間が命名した概念の足し算なのに対し、こちらは無名の58万個のダイヤルが学習でひとりでに調整される。ClippedReLUを選んでいるのは趣味ではなく実利で、値域が0〜1に固定されるため、学習後にfloatをint16へ量子化してWASMの整数演算に載せても精度が崩れない。
学習ループの核心部はこうだ:
out = model(b, h) # ネットにミニバッチ分の局面を採点させ、
loss = F.mse_loss(torch.sigmoid(out), t) # やねうら王の採点とのズレを損失にする(= base)
if args.loss == "ranking": # ranking版はここが追加で入る
diff = c.unsqueeze(1) - c.unsqueeze(0) # バッチ内の全ペアについて教師cp差を計算し、
mask = (diff >= args.rank_pair_min) & (diff <= args.rank_pair_max)
# 「微妙に差がある」ペアだけ選ぶ(既定50〜600cp)。
# 50cp未満: どちらでもいい差。600cp超: もう明らかな差。その間こそ探索の勝負どころ
ia, ib = mask.nonzero(as_tuple=True) # 選ばれたペアの添字(Aが良い側/悪い側)
if ia.numel() > 0: # 該当ペアがあるバッチだけ加算
rank_loss = F.relu(rank_margin_logit - (out[ia] - out[ib])).mean()
# 「先生がAの方が良いと言っているのに、お前の採点でAがBをmargin分上回っていない」
# ペアにだけ罰を与える。評価値そのものの正確さは要求しない——順位だけを要求する
loss = loss + args.rank_weight * rank_loss
opt.zero_grad() # 前バッチの勾配をクリアし、
loss.backward() # 誤差逆伝播。損失が減る方向を58万個のダイヤル全部について同時に計算し、
opt.step() # 全ダイヤルを少しずつ回す。これを30万局面×数十周
2つの損失は目標が違う。mse_loss(base)は「先生の点数を当てろ」——模試の点数を教師とピッタリ合わせにいく学習だ。rank_lossは「点数は多少ズレてもいいから、2つの局面のどちらが良いかの向きだけは先生と揃えろ」。αβ探索が評価関数に問うのは常に「兄弟局面AとBのどちらが良いか」であって絶対値ではない、という第1サイクルの教訓を、損失関数の形にそのまま翻訳したものである。学習自体はMacのGPU(MPS)で回して30万局面なら1本2〜4分。「機械学習=大掛かり」というイメージよりずっと軽い。
loss.backward() の中で何が起きているか「損失が減る方向に修正する」と一言で書いたが、その方向は誰がどう知るのか。原理は素朴だ。58万個の重み1個1個について「これをほんの少し増やしたら、lossは増えるか減るか、どのくらい急に?」という傾き(勾配)を求め、lossが減る向きに少しだけ回す。opt.step()の中身は本質的にこの一行である:
新しい重み = 今の重み - 学習率 * 傾き
学習率が「1回にどれだけ回すか」で、大きすぎれば行き過ぎて発散し、小さすぎれば一生収束しない(本プロジェクトは1e-3から始めてコサイン減衰)。
非自明なのは傾きの求め方だ。素朴には「重みを1個ずらしては評価し直す」を58万回——ネットの再評価58万回ぶんかかる。誤差逆伝播(backpropagation)は微分の連鎖律を使い、出力側から入力側へ「誤りの責任」を逆向きに配りながら、たった1回の逆向きパスで全重みの傾きを同時に出す:
出力のズレ「評価値が0.3低すぎた」
↓ l3 の重みへ 「最終判断でこの特徴を軽視したせい」
↓ l2 の重みへ 「その特徴が弱く出たのは、この縮約のせい」
↓ 盤面テーブルへ 「そもそも『7六の歩』の行のこの数字が小さすぎた」
例えるなら、目隠しで霧の山を下りる人だ。山全体の地図(正解の重みの組)は誰にも見えないが、足元の傾きなら毎回計算で求まる(厳密には手元のミニバッチ上の傾き=真の傾きの推定値で、これが確率的勾配降下法の「確率的」の由来)。一番急な下りへ小さく一歩——1ミニバッチが1歩で、100万局面×40エポック≒約15万歩。val_maeが437cpまで下がるとは、この山下りが「平均誤差437cp」の谷まで降りた、という意味である。
そしてranking lossを足す意味も、この絵で見ると明快になる。罰を変えると山の地形が変わる。点数のズレだけを罰する山では「兄弟局面の順位が逆転している」場所の谷が浅く、山下りはそこを重視しない。ranking項がその場所を深い谷に彫り直すから、同じ山下りのアルゴリズムが自然と「順位の正しい」低地へ向かう——どこに降りるかは、何を罰するかで決まる。
30万行に達した時点のスナップショットで両方式を学習し、第1サイクルと完全同条件の28局に出した。
| 条件 | run100k(第1サイクル) | run300k-base | run300k-rank |
|---|---|---|---|
| 200ms×16局 (seed1) | 2勝14敗0分(12.5%) | 6勝10敗0分(37.5%) | 3勝13敗0分(18.8%) |
| 1000ms×6局 (seed2) | 1勝5敗0分(16.7%) | 1勝5敗0分(16.7%) | 3勝3敗0分(50.0%) |
| 1000ms×6局 (seed3) | 2勝3敗1分(41.7%) | 1勝5敗0分(16.7%) | 3勝3敗0分(50.0%) |
| 合計 | 5.5/28(19.6%) | 8/28(28.6%) | 9/28(32.1%) |
読みどころは2つ。第一に、両方式ともrun100kを明確に超えた。学習方法を一切変えていないbaseですらデータ3倍+バランス補正だけで+9pt——「敗因はデータ」という診断への直接の裏付けだ。第二に、持ち時間で勝者が入れ替わる非対称性が出た。rankは1000ms(本番のmedium相当)で手書き評価と**互角の50%**に達する一方、200msの早指しでは沈む。baseはその逆。解釈はこうだ——深い探索ほど「どの枝を先に読むか」の順位精度(rankの強み)が複利で効き、浅い探索では大差局面の点数キャリブレーション(baseの強み)が枝刈りに直接効く。本番の持ち時間は1〜5秒。どちらに賭けるべきかは明白である。
なお同時に、スケール校正37/10をrun300k-rankにも適用してみたが50%→25%と再び悪化した。校正仮説の棺桶に釘がもう一本入った。
1Mの本学習は base / rank(重み1.0) / rank(重み0.3〜0.5) の3本を学習し、200msと1000msの両方・各16局×3シード以上でA/Bする。ゲートは「1000msで安定して50%超」——超えれば、手書き評価関数を置き換える初めてのニューラルネットが本番に載る。
100万局面へ向けた生成の残り時間を見積もると約11時間だった。「もっとGPUを使って速くできないの?」——当然の疑問だが、調べてみると答えは意外な場所にあった。プロセスごとのCPU使用率を見た瞬間に決着がつく:
node (生成ドライバ) : CPU 100% ← 14コア中、たった1コアが限界で回っている
やねうら王 ×8プロセス : CPU ほぼ0% ← 全員暇
マシン全体 : 81% アイドル
パイプラインは「①自作エンジンの低速自己対戦で局面を作る → ②やねうら王8基が採点する」の繰り返しで、チャンクあたりの実測は生成102秒 vs 採点1.5秒。採点側(8基で毎秒1,300局面を捌ける)は完全に持て余していて、律速は最初から最後まで「作る側」だった。GPUは無関係——やねうら王のNNUEはCPU+SIMDで動く設計だし、そもそも暇なのは彼らのほうだ。
作る側がなぜ遅いか。Node.jsはシングルスレッドなので1プロセス=1コアが上限で、しかも指し手選択に使っていたのは自作エンジンのJS版だった。本番用に作ったWASM版(15倍速・JS版とビット一致)がすぐそこにあるのに、である。対策は2段:
--wasmフラグ。局面の分布は同一エンジンの同一探索なので不変。チャンク生成102秒→27秒)[gen] chunk done: +1095 (gen 27.5s, label 1.7s = 1176.5 pos/s) ← WASM化後
合算スループットは約8局面/秒→約110局面/秒(14倍)(データセットは1行=1局面なので行/秒と同じ)、残り時間は11時間→45分になった。
ここで面白いのはコアの使われ方だ。プロセスは合計27個——Node×3と、各Nodeが専属のやねうら王を8基ずつ従えるのでエンジン×24——いるが、平均して忙しいのは3〜4コアだけ。エンジン24基は「1,000問たまるまで座って待ち、たまったら一斉に1.5秒で採点してまた座る」瞬発要員で、常時忙しいのは問題を作り続けるNodeの3コアだけ——「常に忙しい係はコア数ぶんだけ、瞬発力が要る係は多めに待機」というサイジングになっている。
教訓は2つ。待ち時間が長いと感じたら、まずpsでどのプロセスが忙しいかを見る(「GPUで速く」のような常識的な打ち手は、ボトルネックが別の場所なら空振りする)。そして一度作った高速化資産は思わぬ場所で再利用できる——ブラウザの対戦相手を速くするために作ったWASMエンジンが、機械学習の教師データ工場をそのまま14倍にした。
100万局面が揃ってから採否判定までの残り工程を、所要時間つきで並べるとこうなる:
| 工程 | 所要(当初計画) | 短縮できるか |
|---|---|---|
| 学習3本(base / rank w=1.0 / rank w=0.4、MPS) | 30〜45分 | ほぼ不要(GPU1個の直列で十分短い) |
| 量子化 + 3-wayビット一致検証 | 10分 | 不要 |
| A/Bスクリーニング(3モデル×22局) | 〜1.5時間 | できる |
| A/B本戦(上位2モデル×48局×1000ms) | 〜2.7時間 ← 支配的 | できる |
| 合格なら本番組み込みPR | 1〜2時間 | 通常のPRフロー |
| 合計 | 約5時間 | → 約2.5時間に |
支配的なのは対局だ。そして対局には、生成のときとは決定的に違う制約がある——1局の思考時間は削れない。測りたいのは「1000msで思考した時の強さ」なのだから、思考を速くしたら測定対象そのものが変わってしまう。1局≒150手×1秒≒数分は、測定の定義であって無駄ではない。
では何が削れるのか。局と局の間の直列性だ。1つの対局プロセスは実質1コアしか使わない(両者が同一プロセス内で交互に思考する)ので、生成ジョブの停止で空いた14コアに、シード別・モデル別のマッチを6並列で配れる。48局を1本のプロセスで順番に消化すれば2.7時間、6本に分ければ約27分——1局あたりの中身(思考時間・手数)は一切変えず、待ち行列を並べ替えただけ。測定はそのままに、壁時計だけが縮む。
公平性の担保も生成の並列化とは論点が違って面白い。時間制御のエンジン同士の対局は機械の負荷に敏感だが、A/Bの両者は同じプロセスの中で交互に思考するため、負荷の影響は両側に等しくかかる。並列度をコア数に対して十分低く(6並列+学習で8コア/14コア)保てば、「片方だけが不利」という歪みは構造的に起きない。
まとめると、このプロジェクトで繰り返し使った時間短縮の型は3つに集約される:
ps。思い込みで打ち手を選ばない)第2サイクルの合否も、決め方は変わらない。実際に対局させることだ。
100万局面(1,008,878行)が揃い、末尾4,000行をホールドアウトに分離して3本を学習した(40エポック、1本7〜10分)。まず全6世代のモデルを同一の未見4,000局面で採点:
| モデル | MAE | pair_acc | 互角圏(0-300)MAE |
|---|---|---|---|
| run100k | 558.9cp | 0.8370 | 407cp |
| run300k-base | 513.6cp | 0.8471 | 366cp |
| run300k-rank | 645.3cp | 0.8519 | 287cp |
| run1m-base | 458.7cp | 0.8727 | 258cp |
| run1m-rank10 | 699.4cp | 0.8613 | 165cp |
| run1m-rank04 | 622.8cp | 0.8640 | 217cp |
ここで予想外の逆転が起きた。1Mではbaseが順位一致率でもrank系を上回った。300k時点では「ranking lossが順位を直接最適化するから有利」だったのに、データが十分になると素朴な回帰損失だけで順位も学べてしまう——ranking lossはデータ不足時の松葉杖だった。凝った損失関数に手を伸ばす前にデータを増やせ、という教訓がもう一つ増えた。
そして本番の対局。スクリーニング(3モデル×22局)でrank10を落とし、上位2本の本戦——1000ms×16局×3シード:
| シード | run1m-base | run1m-rank04 |
|---|---|---|
| s11 | 12勝3敗1分(78.1%) | 10勝6敗0分(62.5%) |
| s12 | 11勝4敗1分(71.9%) | 10勝5敗1分(65.6%) |
| s13 | 13勝3敗0分(81.3%) | 9勝7敗0分(56.3%) |
| 合計 | 37/48(77.1%) | 29.5/48(61.5%) |
採用ゲート(50%超)を、77.1%・全シード70%超の安定度で通過。 系譜で並べると——run100k 19.6% → run300k-rank 32.1% → run1m-base 77.1%。第1サイクルの敗北のあと「スケール校正仮説」を単離A/Bで棄却して突き止めた**「教師データの質×量が本丸」という診断が、そのまま勝因になった**。
同日中に本番へ。設計は「攻めの切り替え、守りの多重化」:
レビューbotは今回も仕事をした——「WorkerがBlob URL経由でロードされる構成では相対パスのfetchが壊れる」という本番でしか踏まない穴を含む6件を検出し、全対応した。デプロイ後、本番URLから配信される重みのSHA1がリポジトリのファイルと一致することまで確認して、投入完了。
meetyudai.comのmedium以上のAIの「価値観」は、いま、7ヶ月かけて手書きされたルールの塊ではなく、やねうら王の100万の判断から一晩で蒸留された約59万個の数字である。
第2サイクルの定跡監査で「−2500cp級の穴を11個根絶」した話をした。あの時点の定跡は267局面・34ラインだったが、多くのラインが「戦法の形になったところ」で10〜14手で途切れていた。途切れた先は探索に任せる設計なので致命的ではないが、定跡が続くほど序盤の一貫性が上がり、ヒット率(=即座に良い手を返せる割合)も上がる。そこで本サイクルの締めくくりに、定跡をもう一段「深く・広く」した。
そもそも定跡ブックとは何か。 将棋の序盤は数百年かけて研究され尽くしていて、「この局面ではこの手が良い」という定石(じょうせき、将棋では定跡)が確立している。プログラムはこれを「局面→推奨手」の表として持っておけば、序盤で毎回ゼロから考えずに済む。速いし、人間から見て「ちゃんと戦法になっている」自然な指し手になる。問題はその表に悪手を混ぜないこと——定跡は一度外れると探索に切り替わるので、表の中の一手が悪ければ、そこまで一直線に悪い局面へ突っ込んでしまう。
なぜプロ級エンジンで検証するのか。 手で「この手は良さそう」と入れた定跡は、たいていどこかに罠がある(第2章の△7七角がタダ取りされる手順が典型)。そこで、ローカルに置いたやねうら王(NNUE、プロを超える棋力)を「審判」にする。追加したい手を全部 depth18・MultiPV2 で読ませ、エンジンの最善手(またはその50cp以内の次善手)だけを採用する。具体的には、各ラインの末端局面でエンジンに最善手を尋ね、その手を1手足し、また尋ね……とエンジンの読み筋(PV)を数手なぞって延長する。こうすれば、足した手は全て「プロ級エンジンが最善と認めた手」になる。
何手増やしたか。 矢倉・角換わり・相掛かり・雁木・四間飛車・三間飛車・中飛車・相振り・横歩取り・対棒銀ほか21ラインを各2〜6手延長し、局面数は267→354(+87局面)、登録手は298→387に増えた。延長はすべて「その局面でのエンジン最善手」で構成されている(例:角換わり・相掛かりの飛車先交換のあと、△8六歩▲同歩△同飛と“お返しの交換”まで進める、など)。
悪手をどう防いだか。 全登録手を depth18・MultiPV2 で再照合する検証スクリプト(エンジン最善との差が200cp以上なら「悪手」として非ゼロ終了)を、拡張後に走らせた。結果、追加した87局面から新たな悪手はゼロ。それどころか、雁木のあるラインは延長したことで境界局面を通り過ぎ、以前ギリギリ引っかかっていた1件が解消された。唯一残ったのは三間飛車の出だし▲7八飛だが、これは**「振り飛車は居飛車より約180cp低い」という現代エンジンの評価そのもので、depth18の探索ゆらぎで165〜218cpを行き来する境界値(depth20では安定して177cp)。この一手は戦法の定義そのもの(消せば三間飛車が消える)で、しかもエンジンが認める“最善の三間飛車”**なので、そのまま残した。悪手ではなく、戦法選択のコストである。
効いたのか。 拡張版と拡張前をA/Bで直接対決させた(同一エンジンv20で定跡だけ差し替え・evalV3・1000ms・各30局)。定跡を無効化する強制局面を挟むと10勝10敗10分の五分(=悪化なし)。定跡を活かす条件(強制局面なし)では15勝0敗15分——拡張版が一度も負けなかった。定跡ヒットのプローブ数も926→956と増え、本線の在庫が深くなったぶんだけ即答できる範囲が広がっている(book-vs-bookの本線は13手→17手)。
教訓は第2章と同じだ——定跡・評価・検証は連成系。手で足した定跡は必ず超級エンジンで裏取りし、「足した手が最善か、差が何cpか」を1手ずつ数字で確かめる。この規律があれば、定跡は安全に深くできる。
絶対的な段級位は測っていない(それは対人間で決まる)が、各世代間の直接対決はすべて実測してきた。並べると:
| 世代交代 | 実測 | Elo換算の目安 |
|---|---|---|
| V18 → V19 | 37勝17敗12分(68.5%) | +135 |
| V19 → V20 | 対V18: hard 10勝0敗2分・旧版の半分の持ち時間で | +200〜300 |
| V20 JS → WASM | 10勝0敗、同時間で+3〜4手 | +250〜400 |
| + JS小改良群 | 本番時間で中立(9勝12敗11分) | ±0 |
| + ポンダリング | 平均+0.35手 | +20〜40 |
| + 定跡監査 | 自己対戦五分、ただし−2500cp級の穴を11個根絶 | 対人間で実質的 |
| V3 → NNUE | 77.1%(1000ms・48局) | +210 |
合計でざっくり**+800〜1000 Elo相当**——「8級が初段になる」規模のジャンプだ。ただし自己対戦Eloは対人間の強さを過大評価しがちで、絶対位置は不明。本当の答え合わせは、これを読んでいるあなた(と作者本人)が指してみることでしかない。
読者から確実に来る質問なので、先に答えておく。77.1%は天井ではない。 あれは「NN化の第一歩が成功した」段階であって、伸びしろは測定可能な形で残っている。レバーは5本ある。
① KP特徴量——最大の伸びしろ。 現在の入力は「どの駒がどのマスにいるか」の単純なone-hot 2268特徴でしかない。本家NNUEはそうではなく、**「自玉から見た駒の相対配置」(KP)**で盤面を符号化する。同じ「5五の銀」でも、自玉が居飛車の玉形にいるか振り飛車の玉形にいるかで別の特徴になる——つまり玉の文脈を桁違いに細かく表現できる。手書き評価関数を7ヶ月書いて一番難しかったのが「玉の周辺の価値づけ」だったことを思えば、ここが効かないはずがない。期待値は+200〜400 Elo級と見ている。
② 教師データの量と質。 100万局面→500万〜1000万。第2サイクルで生成パイプラインを14倍化済みなので、これは根性ではなく待ち時間の問題でしかない。同時に教師の探索深さをdepth 8→12以上へ——やねうら王により深く読ませた、より正確な模範解答で学ぶ。run100k→run1mの系譜(19.6%→77.1%)が示した通り、この軸はまだ折れていない。
③ WASM SIMD128。 ネットの中身はほぼ積和演算なので、SIMD128で4〜8要素をまとめて計算すれば推論速度が2〜4倍になる。第2サイクルで観測した通り、同じ評価関数でも探索ノードが増えれば順位精度が複利で効く。
④ マルチスレッド探索。 SharedArrayBufferで探索を並列化する。VercelにCOOP/COEPヘッダーの設定が要るが、通れば4コアで単純に4倍のノードが読める。
⑤ 自己対戦ループ。 現在の教師局面は旧エンジンの自己対戦から作っている。これをNNUE版の自分に作らせて再学習する——強くなった分だけ良い局面分布のデータが生まれる、という正のループを回す。
理論上の天井はどこか。ブラウザというハードがプロ級の将棋AIを動かせること自体は、やねうら王のWASM版を採用したlishogi等の他サイトが既に実証している。だから「限界はブラウザではなく、このエンジンの成熟度」だ。ただし本プロジェクトは借り物のエンジンを置く路線は採らない。自作エンジンを測定で育てるのがこのプロジェクトの目的であり、それは作者の明示的な方針でもある。
第3サイクルは、この設計図に沿って進めた。次章がその全記録である。
設計図を書いたあとに、実際に手を動かした。結論から言うと、速さは狙い通り取れた。そして「強さ」の本丸では、第1サイクルと第2サイクルの教訓が両方まとめて返ってきたが——最後にはそれを乗り越え、作者本人が実戦で「強くなった」と認めるところまで漕ぎ着けた。 この章はその一部始終の記録である。設計図の5本のレバーのうち、速度系(SIMD・マルチスレッド)は素直に効き、知識系(KP特徴量)は否定的結果に終わり、そして本番に出したNNUEが実戦の人間相手に崩壊した。その診断から、飽和を根治した524万局面の教師データ、そして作者が「確実に強くなってる」と確認した瞬間までを、数字を省かずに書く。
まず速度から。ここは機械的に保証できる領域なので、狙った通りの成果が出た。
WASM SIMD128。 そもそもSIMDとは何か。「1つの命令で複数のデータをまとめて処理する」仕組みのことだ。ふつうのCPU命令は「a と b を足す」を1回にひとつしか処理しない。SIMDは「a1 と b1、a2 と b2、…、a8 と b8 を同時に足す」を1命令でやる。WebAssemblyには v128 という128ビット幅のレジスタ(=一時的な計算用の箱)があり、そこに16ビットの整数を8個並べて一気に演算できる。
NNUEの推論——ニューラルネットが「この局面は何点か」を計算する処理——は、中身をほどくとほとんどが積和演算だ。「重み × 活性値を足し込む」を何千回も繰り返すだけ。ここがSIMDの独壇場になる。実際のコードでは、アキュムレータ(=途中結果を貯める配列)の更新を、16ビット重みを8個ずつ v128.load で読み、i32x4 レーンに符号拡張して足し込む形に書き換えた。1ループで8要素を処理する。
測ると、こうなった。
| 処理 | スカラー版 | SIMD版 | 倍率 |
|---|---|---|---|
| 評価1回(差分適用込み) | 1191ns | 191ns | 6.2倍 |
| フル再計算(差分なし) | 6425ns | 1156ns | 5.6倍 |
| 実探索(総合) | — | — | 約1.4倍 |
マイクロベンチで6.2倍、フル再計算で5.6倍。ただし実探索での総合効果は約1.4倍にとどまる——探索は評価だけでなく合法手生成やαβの枝刈り判定などもやっているので、評価が6倍速くなっても全体はそこまで伸びない(アムダールの法則そのものだ)。それでも1.4倍は大きい。第2サイクルで学んだ通り、同じ評価関数でも読むノードが増えれば、兄弟手の順位づけ精度が複利で効くからだ。
ここで一番大事なのは正しさの保証だ。NNUE推論は整数演算で組んである。整数の足し算は結合則が成り立つ(順番を変えても答えが変わらない)ので、SIMDでまとめて足してもスカラーで1個ずつ足してもビット単位で完全に同じ答えが出る。浮動小数点だとこうはいかない(丸め誤差が順序で変わる)。実際、SIMD版とスカラー版で同じ局面を探索させ、訪問した探索ノード数が1ノードまで一致することを確認した。速くなったが、指す手は1手も変わっていない。これは第6章のWASM移植で確立した「バイト一致」の規律の延長線上にある。
マルチスレッド探索 / Lazy SMP。 次は並列化だ。訪問者のブラウザで最大4スレッドが同じ局面を同時に探索する。手法は Lazy SMP と呼ばれるもので、名前の通り「ゆるい」並列化だ——各スレッドは独立に探索を進めるが、共有トランスポジションテーブル(=一度計算した局面の結果を貼っておく共有メモ帳。TTと略す)で結果を融通し合う。あるスレッドが「この局面は詰みだ」と分かれば、そのメモを他のスレッドが読んで無駄な再計算を省ける。厳密に仕事を分担するのではなく、探索順のばらつきと共有メモの相乗効果で全体が速くなる、という発想だ。
結果、ノード数は約3.0倍(4スレッドで理想の4倍には届かないが、共有・同期のオーバーヘッドを考えれば妥当)。では強さは? A/B対局で測った——4スレッドのマルチスレッド版(MT)と、1スレッドのシングルスレッド版(ST)を、本番同等の1000ms×24局で戦わせた。
MT 14勝10敗(勝率58.3%、点推定でおよそ+58 Elo)
一見、勝ち越している。だが正直に書くと、n=24は統計的有意水準にまったく届かない「点推定」でしかない。24局で14-10という差は、コインを24回投げて14回表が出る程度には偶然でも起きる。第4章・第9章で何度も刺された「セルフプレイの統計的縮退」の教訓通り、これは「効いていそう」という弱いシグナルであって、確定した数字ではない。本来は100局以上回して詰めるべきところだ。それでもノード×3.0という機構的な裏づけがあるので、方向としては信じている。
技術的なハマりどころも記録しておく。マルチスレッドで局面を共有するには SharedArrayBuffer(スレッド間で共有できるメモリ)が要る。ブラウザはこれをクロスオリジン分離(COOP/COEPという2つのHTTPヘッダーで、ページを他サイトから隔離した状態)でしか許さない——セキュリティ上の理由だ。だが、このヘッダーをサイト全体にかけると、外部から読み込んでいる画像やスクリプトが軒並みブロックされ、ホームページが壊れる。そこでビルド設定で将棋ページのパスだけにCOOP/COEPを限定した(コード中のコメントに「Deliberately NOT site-wide(意図的にサイト全体にはかけない)」と明記してある)。将棋ページだけが分離環境になり、他のページは無傷、というピンポイントの設計だ。
そして——ここが次節への伏線になる——マルチスレッドは「探索を速くする」だけで、後述の「意味不明な手」問題は1ミリも直さない。 速く読もうが遅く読もうが、評価関数が同じ局面を同じように誤って点数化するなら、出てくる手は同じだ。速度と強さは別の軸である、というのがこの章を貫くテーマになる。
設計図で「最大の伸びしろ」と書いたKP特徴量に手をつけた。結論を先に言うと、空振りだった。 だがこの空振りには、第2サイクルの敗因と同じ根がある。
狙い。 現在のNNUEの入力は、盤面を「どの駒がどのマスにいるか」でそのまま符号化した単純な表現だ。本家(プロ級エンジンの)NNUEはこれをやらない。「自玉の位置から見た駒の相対配置」——King-Piece、略してKP——を使う。同じ「5五の銀」でも、自玉が居飛車の玉形にいるか振り飛車の玉形にいるかで別の特徴として区別する。つまり「玉の安全度」という、将棋で一番大事で一番言語化しづらい文脈を、桁違いに細かく表現できる。手書き評価関数を7ヶ月書いていて一番難しかったのが玉周りの価値づけだったので、ここが効かないはずがない、と踏んでいた。期待値は+200〜400 Elo。
実装。 フルのKPは特徴量が爆発するので、自玉位置を6つのバケット(区画)に量子化した縮約KPを作った。玉が盤上のどのあたりにいるかを6分類し、その分類ごとに駒の配置特徴を持つ。さらに factorized版(=「玉の位置」と「駒の位置」を分解して学習させる版。データが少なくても学びやすくする工夫)も用意した。重みファイルは 1.19MB → 6.70MB に膨らんだ。表現力が上がった分、パラメータが増えたということだ。
結果。 holdout(=学習に使わず取っておいた検証用局面)での近似精度も、直接対決の勝率も、現行を上回らなかった。 縮約KP・6バケット版(kpf6)を現行NNUEと戦わせて 勝率37.5%。改善どころか負け越しだ。
なぜか。 原因は「データの希釈」だった。手元の教師データは100万局面しかない。これを6つのバケットに分けると、単純計算で1バケットあたり平均17万局面弱になる。しかも将棋の実戦では玉がまだ初期位置から動いていない局面が全体の約半分を占める——序盤〜中盤の入口が多いからだ。すると「玉が動いていない」バケットにデータが偏り、他のバケットはガラガラになる。表現力を細かくしたのに、その細かさを埋めるデータが足りない。これは**第2サイクルでNNUEが最初に負けたときと完全に同じ「データ不足の壁」**だ。
ここにML初心者向けの直感を1つ。特徴量を細かくすると、モデルの表現力(区別できる状況の数)は増える。だがその増えた区別の一つひとつを学ぶには、それぞれに十分な例が要る。 区別を10倍細かくすれば、原理的には10倍のデータが要る。表現力とデータ量は独立に増やせるものではなく、セットで増やさないと「細かいけど中身が空っぽな特徴」が量産されるだけ——これが希釈だ。KP特徴量は正しい方向だが、100万局面では早すぎた。
だから捨てずに保存した。KPを扱う実装インフラ(縮約・factorize・重みフォーマット)はそのまま残し、後述の500万局面が完成したら再挑戦する。順番の問題であって、否定ではない。
ここがこの章の——おそらくこの記事全体の——山場だ。
背景。 第2サイクルで、NNUEはセルフプレイで旧評価(V3)に**77.1%**で勝った。品質ゲートを堂々と通過したので、本番のmedium以上の難易度に投入した。系譜は19.6%→32.1%→77.1%と綺麗に伸び、一件落着——のはずだった。
ところが。この記事を書いている本人(将棋二段)が、本番のhard(2秒)と実際に対局して、勝ってしまった。 しかも僅差ではなく、「まだ弱すぎる。意味のわからない手を指しまくる」という報告つきで。詰みに関係ない場所に歩を打つ、自分から玉を危険地帯に動かす——人間が見れば一目で「なんだこれ」という手を、フル2秒考えた上で選んでくる。
これは第1サイクルの最大の教訓、「セルフプレイの勝率は、対人間の強さを保証しない」の完全な再来だった。同じ罠に、二度目に落ちたのだ。セルフプレイでV3に77.1%勝つことと、人間の二段に勝つことは、別の話だった。
診断手法。 感想ではなく事実で殴るために、その対局の棋譜(81手)を丸ごと再現ハーネスに流した。本番と同一の呼び出し経路でリプレイし、各手について「NNUEをON にしたときの選択」と「OFFにしたとき(=V3評価)の選択」を比較。さらに一手ごとにやねうら王 depth18(プロ級エンジンにdepth18まで読ませた模範解答)と照合して、どこでどれだけ損をしたかを cp(センチポーン。歩1枚を約100とする評価単位)で数値化した。
判明した事実を、順に並べる。
(1) 悪手は「即答バグ」ではなく、探索がちゃんと考えて選んでいた。 まず疑ったのは、第3章で見つけた「探索をバイパスして即答する」系のバグの再発だ。だが違った。悪手はNNUE経路で再現し(ON: 40手中19手がやねうら王と不一致)、OFF(V3)だと不一致は7手にとどまった。つまりNNUEをONにしているとき、エンジンはフル2秒きっちり探索した上で、その悪手を「最善」だと信じて選んでいる。これはバグではなく、評価関数そのものの欠陥だ。診断としては最悪の部類——直すのが一番難しいタイプだ。
(2) 主犯は「sigmoid飽和」。 これが核心だ。NNUEは学習時、教師の評価値cpを sigmoid(cp/600) という関数に通してから学んでいる。sigmoidは入力を0〜1に押し込むS字カーブで、入力が大きくなると出力が1に貼りついて動かなくなる(=飽和する)。cp/600 を通したsigmoidは、評価が±2500cp付近まで行くと出力がほぼ頭打ちになる。学習時はこれで問題ない——決着済みの局面を細かく区別する必要はないからだ。
だが本番の探索では、これが致命傷になる。詰みかけの局面を考えてみよう。この対局の**72手目、自玉が自分から詰みに突っ込む▲…△1一玉(損失31934cp、cpは歩1枚を約100とする単位なので歩約319枚分に相当する大悪手)**の局面で、何が起きていたか。NNUEに全71通りの合法手を評価させたところ——全71手の評価が、わずか15cp幅の中に潰れて、互いに区別できなくなっていた。 どの手を指しても「だいたい同じ点」に見える。飽和域では、大差の局面の中の「まだマシな手」と「最悪の手」の差が、sigmoidに押し潰されて消えてしまうのだ。
同じ局面をV3評価に見せると、約390cpの識別幅を保っていた。V3は決着局面でも手の優劣をちゃんと区別できる。「勝敗が決した局面で、全部の手が同じ点に見える」——これが『意味不明な手』の直接の原因だった。区別がつかないなら、エンジンは事実上ランダムに近い選択をするしかない。詰みに突っ込むのも、当然そうなる。
(3) 副次的な例:48手目 △8一歩打。 飽和だけではない。48手目、自陣の最奥(8一)へ無意味な歩を打つ手(損失1626cp)が選ばれていた。ここでのNNUEは局面をほぼ互角と誤認しており、その結果「パス的な(何もしないに等しい)手」が評価上位に団子になって並んでいた。自陣の一番奥に歩を打つのは、将棋的には駒を1枚死なせるに等しいコストの高い手だ。だがそのコストがネットにほとんど見えていない。 なぜか——教師データにこの種の手(無意味な自陣への打ち込み)がほぼ皆無だからだ。やねうら王はそんな手を指さないので、ネットは「自陣最奥への歩打ちがどれだけ損か」を一度も教わっていない。見たことのない愚行のコストは、当然評価できない。
(4) さらに副次原因:終盤での探索速度の崩壊。 終盤に入ると探索速度が 27〜41µs/node まで落ち込み、到達深さが4〜6手まで下がっていた(序盤中盤より大幅に浅い)。浅くしか読めなければ手の質は落ちる。ただしこれは評価関数とは無関係の別問題で、NNUEを使わないV3でも同じ症状が出る。飽和とは切り分けて考えるべきバグだ(探索の終盤失速は、また別の宿題として残った)。
(5) 飽和を悪化させた「真犯人」。 ここで話が第2サイクルに繋がる。飽和自体はsigmoidの性質だが、それを致命傷にまで悪化させた原因が教師データの作り方にあった。ここは因果が少し込み入っているので、初心者向けに順を追ってほどく。
まず用語から。決着局面とは、もう勝負がほぼついた局面のことだ。この記事では |cp|>1200(=片方が歩12枚分以上有利)を境目にしている。逆に、互角〜数歩差のヒリつく局面が接戦局面だ。人間の感覚で言えば、決着局面は「もう投了してもいい大差」、接戦局面は「まだどっちに転ぶか分からない」。
間引いた狙いは、じつは良かった。 ランダムな自己対戦で局面を集めると、放っておくと終盤の大差局面ばかりが集まってしまう(対局は終盤に進むほど差が開くので、母数が偏る)。そこで第2サイクルは、--balance というオプションで |cp|>1200 の決着局面を7割捨て(30%だけ残す)、接戦局面の比率を上げた。狙いは「AIが一番迷う中盤・接戦を重点的に練習させる」こと——研修医に、教科書どおりの明快な症例ばかりでなく、判断の難しい微妙な患者を多く診せる、という発想だ。これ自体はまったく理にかなっている。
だが、間引きすぎが飽和を悪化させた。 決着局面を捨てすぎた結果、NNUEは「勝負がついた局面」をほとんど練習しないまま卒業した。だから本番でいざ決着局面に出会うと、手の良し悪しを区別できず、全部が同じ点に見える。もともとsigmoidが数学的に頭打ちになる域(±2500cp付近)と、この「練習不足で未知になった域」が重なって、崩壊が起きた。
噛み砕いた例え(研修医)。 研修医に「診断が難しい微妙な患者」ばかり見せて、「明らかに重体の患者」をほとんど見せずに現場に出したらどうなるか。いざ危篤の患者が運ばれてきても、「どれくらいヤバいか」の相場感がなく、全員が「中くらい」に見えてしまう。決着局面を練習していないNNUEも、これと同じだ。危篤(詰みかけ)の重症度が、そもそも測れない。
この対局の棋譜の72手目が、まさにこれだった。 さっき (2) で見た72手目——もう詰みかけの決着局面なのに、NNUEには全71手が15cp幅(ほぼ同点)に見えていた——は、この「重体患者を練習していない研修医」の症状そのものだ。だから詰みに突っ込む手を平然と選んだ。「意味不明な手」の正体は、飽和という数学的頭打ちと、間引きすぎという分布の穴が、同じ場所で噛み合ったことだった。
つまり二重の罠だ。sigmoidが構造的に飽和する域と、間引きで学習が薄くなった域(英語でOOD=Out-Of-Distribution、=訓練データにほとんど無かった、ネットにとって「未知」の領域)が、まさに同じ「大差の決着局面」で重なった。飽和で区別が潰れるところに、そもそも学習データも足りない。この二つが噛み合って、終盤の崩壊が起きた。第2サイクルの「良かれと思った間引き」が、第3サイクルで牙を剥いたのだ。
即時対応:ホットフィックス。 診断結果が出た以上、崩壊したまま本番に置いておくわけにはいかない。NNUEの適用範囲をmediumのみに縮小した。 冷静に振り返ると、77.1%という数字は1000msでのみ測った実測値で、hard(2000ms)以上は「深く読ませればもっと強いはず」という未検証の外挿でしかなかった。深く読むほど飽和域の局面に到達しやすくなるので、むしろhardの方が崩壊しやすい。そこでhard以上はV3評価に戻し、飽和由来のナンセンスを本番から除去した。ワーカーのコードでは NNUE_DIFFICULTIES を medium..master から medium だけに絞り、コメントに崩壊の症状(「決着局面で全71手が15cp以内」「8一歩打 / 1一玉のようなナンセンス手」)と復帰条件を明記した。
検証基準そのものを変えた(この章で一番重い教訓)。 一番の反省は、「セルフプレイ勝率」を合格基準にしたこと自体が失敗だったという点だ。77.1%は本物の数字だが、それが測っていたのは「V3相手にセルフプレイで勝てるか」であって、「人間の実戦で悪手を出さないか」ではなかった。近似指標に合格しても、肝心の振る舞いが壊れていた。
だから基準を差し替えた。今後の合格ゲートは、**やねうら王depth18と照合した実戦棋譜での「悪手率」**にする。セルフプレイの勝率ではなく、人間との実戦で「やねうら王が選ばない手をどれだけ選んだか」を直接測る。そして——この81手の棋譜そのものを、永久の回帰テストにした。 今後どんな変更を入れても、この棋譜をリプレイして悪手率が上がっていないかを必ず確認する。人間が実際に咎めた失敗ほど、貴重なテストケースはない。
診断で「飽和の主因=決着局面の間引きすぎ、つまりデータ分布の問題」と確定した。対症療法(ホットフィックス)で出血は止めたので、次は根治だ。いま教師データを丸ごと作り直している。
500万局面。 第2サイクルの100万局面の5倍だ。生成にあたって2つを変えた。
なぜdepth 12なのか——20や30ではなく。 ここは初心者が必ず引っかかるので、3点に分けて説明する。
なぜ旧100万データを混ぜないのか。 「せっかく作った100万局面、捨てずに足せば550万で得では?」と思うかもしれない。だが混ぜない。 旧データはdepth 8採点+balance-rate 0.3で作られている——つまりいま直そうとしている2つの問題(浅い採点・決着局面の間引きすぎ=飽和の原因)を、まさに抱えている張本人だ。ここに新データを混ぜれば、せっかくの飽和対策(決着局面を厚く学ぶ)が旧データの薄さで希釈されてしまう。病気の原因を、その治療薬に混ぜてはいけない。 旧100万データは学習には使わず、「倒すべきベースライン」——現在本番で動いている重み——として保存する。新しい500万データ製のネットが、この旧ベースラインを実戦棋譜の悪手率で明確に上回れるか、が合否になる。
2台のMacで分散生成。 500万局面の生成は時間がかかるので、手持ちのMac 2台で並列に回している。局面生成は完全に並列化できる——各局面の採点は互いに独立で、他の局面の結果を待つ必要がないからだ(いわゆる embarrassingly parallel、恥ずかしいほど並列化しやすい問題)。1点だけ注意が要るのは乱数の重複だ。両マシンが同じ乱数列で同じ局面を作ってしまっては二重生成になる。だが乱数の種を 時刻 ^ プロセスID で作っているので、別マシン・別プロセスなら種が衝突せず、生成される局面も重複しない。M4 Pro(14コア)ともう1台(12コア)を並列運転し、最後に両者のファイルを連結する。細かい最適化としては、コア数より少し多めにプロセスを立てるオーバーサブスクリプション(I/O待ちの隙に別プロセスを走らせてコアを遊ばせない)で、遊休コアを詰めている。
もう一つの手として検討した飽和ガード(データ不要のコード対策)。 最初はこう構えていた——sigmoid飽和それ自体は数学的な頭打ちなので、データを厚くしても完全には消えないかもしれない。 決着局面を50%まで戻せば「未知の域」ではなくなるが、sigmoidが±2500cpで平らになる性質そのものは残る。だからデータの作り直し(本命)と並行して、コードだけで飽和を回避する補完策——「NNUE評価が|cp|>1500になった局面では、その局面だけV3評価に切り替える」——を保険として用意していた。飽和して区別が潰れる域だけをV3に肩代わりさせ、互角の局面はNNUEに任せる、という発想だ。だが結論から言うと、この保険は使わずに済んだ。 524万局面の学習だけで、飽和が実用上ほぼ解消したからだ(後述する72手目の散らばり20cp→532cpが、その動かぬ証拠になった)。頭打ちの数学的性質は残っていても、決着局面をデータで厚く学ばせれば、その域でも手を区別できるだけの識別幅が戻る——これが実測で確認できた。「未知の域」を埋めるほうが、コードで飽和域を避けるより本質的だった。
学習し終えたネットを、第3サイクルの新しいゲート——ホールドアウトの近似精度と、**実戦棋譜の飽和ゲート(悪手率)**と、A/B対局の三本立て——で審査した。結果を順に並べる。
(A) ホールドアウト(holdout5m-4k、学習に使わない4000局面)。 現行の本番重み(run1m-base)と比べ、全指標で新版が勝った。とくに大事な決着圏(|cp|>1500)のペア精度(=2局面の優劣を正しく並べられる割合)は 0.8840 → 0.9044 に改善。全体の平均誤差(MAE)は 531.7 → 448.8cp、飽和が一番効く1000〜3000cp帯のMAEは 677 → 529cp。数字が言っているのは単純だ——大差の局面での「手の見分け」が、確かに戻った。
(B) 飽和ゲート(あの81手の実戦棋譜を、やねうら王depth18で照合)。 これが本命の検証だ。永久の回帰テストにしたあの棋譜で、悪手(>300cp)の数が 8 → 4 に半減した。そして——あの72手目(作者の「全71手が同点」報告の核心)が完全に解消した。飽和で15cp幅に潰れていた手価値の散らばりが、**20cp → 532cp(26倍)**に戻ったのだ。現行版が−35281cpの大悪手 G*8f(自陣への金打ち)を選んでいた局面で、新版は真の最善手 6f6g+ を選ぶようになった。研修医がついに「危篤の重症度」を測れるようになった、その瞬間である。
ただし正直に書く。74手目は新版でも悪手が残った。 ここは既に−30000cp級の、どう指しても負けの投了局面なので、手を1つ間違えても大勢に影響はない——「もう詰んでいる局面でどの負け方を選ぶか」の差でしかない。飽和が解けたのは「まだ勝負がつく決着局面」であって、完全に終わった局面まで綺麗になったわけではない。だが実戦で問題になるのは前者であり、そこは直った。
(C) A/B対局。 新版 run5m-base を、現行NNUE(run1m-base)と本番同等の設定で戦わせると 92.2%(29.5/32)。旧評価V3に対しては 1000msで84.4%、2000ms(hard相当)でも84.4%——前回未検証だったhardを、今度は実測でクリアした(飽和崩壊の引き金になった、まさにその時間帯だ)。
(D) base > rank の再確認。 第2サイクルで学んだ「baseの方がrankより飽和対策に向く」が、ここでも再現した。同じ524万局面で ranking loss を使った run5m-rank は、互角圏のMAEやホールドアウト決着圏のペア精度は良い。だが肝心の実戦72手目では、依然として飽和していた(散らばりわずか90cp・大悪手を選択)。ranking loss は「順位さえ合えばいい」と大差域の絶対的な広がりを潰す副作用があり、飽和対策という目的には逆効果だった。飽和を治すには、大差を大差のまま学べる base が明確に優る——第2サイクルの「base > rank」が、別の角度からまた確認された。
500万局面の生成を2台のMacで回す過程で、机上では見えなかった実地の教訓がいくつも出た。ML初心者が自分で同じことをやろうとすると必ず踏む類のものなので、数字を省かずに書いておく。
発見1:depth 12でボトルネックが「移動」した。 局面を1つ作るには、(a) 自己対戦で局面を作る(生成)と、(b) やねうら王で採点する(ラベリング)の2工程がある。第2サイクルのdepth 8では、生成が律速で採点は一瞬だった——採点が浅いので、ほぼ「作るのを待つだけ」の作業だった。ところがdepth 12に上げると、採点が重くなり、生成と採点がほぼ半々(各約27秒 / 約28秒)になった。教訓は単純だ——深く読ませると、採点コストが効いてくる。 どの工程が律速かは設定で動くので、遅くなったら「どっちが重いのか」を毎回測り直す必要がある。
発見2:分散の重複は「種」で自動回避できた。 2台のMacで同じコードを走らせると、素朴には「両方が同じ局面を作って二重生成にならないか?」が心配になる。だが心配は要らなかった。生成の乱数の種(シード)が 時刻 ^ プロセスID で作られているので、別マシン・別プロセスなら種が衝突せず、生成される局面が重複しない。特別な調整(マシンごとに範囲を割り振る、など)は一切不要だった。これは局面生成が**完全並列(embarrassingly parallel、恥ずかしいほど並列化しやすい)**な問題であることの強みだ——各局面は互いに独立なので、ただ2台で同時に回してファイルを連結するだけでいい。
発見3:オーバーサブスクリプションの罠。 「プロセスをたくさん立てれば速い」は、素朴すぎて逆効果になる。2台目は12コアの旧世代Macなのだが、ここで最初 --engines 8(4ドライバ × 8エンジン = 32エンジン)で起動したら、12コアを32エンジンが奪い合って、生成が4倍遅く(117秒)なった。コア数を大きく超えるプロセスを立てると、OSがコアを取り合いさせて全体が詰まる。--engines 2 に減らして詰まりを解消した。さらに観察すると、生成は1ドライバ=1コアで進むのに、遊休コアが8個あった。そこでドライバを 4→8 に増やして遊休コアを埋め、合計スループットを上げた。教訓は——「プロセスを増やすほど速い」ではない。 コア数と、各プロセスの役割(常時忙しい生成 vs バースト的な採点)を見て、配分を決める。
発見4:CPUとGPUの適材適所。 ここは前提知識記事に譲るが、1文だけ。**生成(探索・採点)はCPU、学習はGPU(MacのMPS)**でやっている。理由をひとことで言えば、探索は「読み筋が分岐だらけで先が読めない」処理なのでCPU向き、学習は「大量のデータを一括で同じ計算にかける」処理なのでGPU向き、だからだ。同じ「重い計算」でも、形が違えば向くハードが違う。
発見5:同時書き込みでファイルが壊れた事故。 これは失敗の記録だ。誤って同じ生成コマンドを2回起動してしまい、2つのプロセスが同じ出力ファイルに同時に追記した。結果、行が途中で混ざって壊れ、その壊れたファイルを git grep(で中身を検索)したら Binary file ... matches が返ってきた——テキストとして扱えない不正なバイト列が混じった、という警告だ(この表示を出すのはgit全般ではなく git grep や grep/rg といった検索コマンドで、ファイルをバイナリと判定したときに出る)。同一ファイルへの並行追記は、ほぼ確実に破損する(2つの書き込みが1行の途中で交差する)。幸い壊れたのは少量だったので、消してやり直した。教訓——並列で書くならプロセスごとに別ファイルに書き、最後に連結する(発見2でそうしている)。「同じファイルに2人で同時に書く」だけは避ける。
発見6:GPUは1個、CPUは14コア——役割の違う並列でパイプライン化した。 ここは「並列化」という言葉の思い込みを解く発見だ。Apple Silicon(M4 Pro)は、チップにGPUが1個内蔵されている。「GPUコアは20個」と聞くと20個のGPUがあるように錯覚するが、それは1個のGPUの中に20コアという意味で、CPUの14コア=1個のCPU、というのと同じ構造だ(system_profiler SPDisplaysDataType で確認すると、Metalデバイスは1つしか出てこない)。だから2つの学習(PyTorch/MPS)を同時に走らせても、1個しかないGPUを2つが取り合うだけで速くならない。
ところがここに抜け道がある。A/B対局はCPUの仕事(14コアで局面を読む)、学習はGPUの仕事——この2つは使う資源が違うので、同時に走らせても取り合いにならない。そこでNNUEの検証を、素朴な直列(base学習 → rank学習 → 評価 → 量子化 → A/B対局)で回すのをやめ、パイプライン化した。run5m-baseの学習が終わったら、そのA/B対局(CPUで約2時間かかる)を回しながら、裏で空いたGPUに次のrank学習を並走させる。GPUの遊休時間をCPUの対局で埋めた形だ。これで全体が約4時間 → 約3時間に縮んだ。
一般化した教訓——「並列化=同じ処理をたくさん並べる」だけではない。「資源(CPU / GPU)の違う処理を重ねる」パイプライン化も、同じくらい強力だ。ボトルネック(1個しかないGPU、削れない対局時間)を見極めて、その裏で空いている別資源に別の仕事を流す。発見3が「同じ資源を取り合わせない」話なら、発見6は「違う資源を遊ばせない」話で、コインの裏表になっている。
メタ教訓(再確認)。 これらは全部、「便利な近道(自動化・並列化)にも、現場特有の落とし穴がある」という第1・第2サイクルからの教訓の再確認だ。セルフプレイ77.1%が対人間で通用しなかったのと、根は同じ。机上の「並列化すれば速い」を鵜呑みにせず、生成レートやコア使用率を ps や wc で実測し、その数字を見て配分を決める——これが基本動作になる。
三本のゲートを全部通ったので、run5m-base を本番に載せた。作業自体は第6章・第7章で確立した規律の反復だ。
NNUE_DIFFICULTIES を medium だけから medium..master に戻し、hard 以上でも純NNUEが動くようにした。easy だけは軽さのため引き続きV3。正直に書いておくべき外挿がひとつある。expert(4000ms)と master(5000ms)は、直接A/Bで測っていない。 実測したのは hard 相当の2000msまでだ。それでも再有効化に踏み切ったのは、第2〜第3サイクルを通じて**「探索が深いほどNNUEの優位が広がる」**という傾向が一貫して観測されてきたからだ(浅い1000msより深い2000msの方が対V3の勝率が保たれた、など)。この傾向からの外挿で、より長い持ち時間でも優位は保たれる、と判断した——が、これは実測ではなく外挿であることは明記しておく。次に長時間帯の実戦棋譜が取れたら、そこで裏を取る。
ここが、この章の——そしておそらくこの記事全体の——本当のゴールだ。
本番hardに新NNUEを載せたあと、この記事を書いている本人(将棋二段)が、もう一度hardと実際に対局した。 第2サイクルではセルフプレイ77.1%という立派な数字を出しながら、この同じ人間に負けた。だから今度こそ、と身構えて盤に向かった。
結果——作者は「確実に強くなってる」と認めた。
具体的に何が変わったか。前回敗北した棋譜で問題になった「意味のわからない手」——詰みに関係ない自陣への無意味な歩打ち、自分から詰みに突っ込む玉——が、消えた。代わりにAIは、狙いを持った角打ち、飛車・龍を使った的確な攻めを、一貫した攻めの将棋として指してくるようになった。決着局面で全71手が同点に潰れていた、あの飽和した評価では、こうはならなかった。評価が手を区別できるようになったから、AIは「殴り合える」ようになったのだ。
これが本当のゴールだった。 思い返せば、第2サイクルの反省がこの瞬間を用意していた。セルフプレイでV3に77.1%勝ちながら人間の二段に負けたとき、私たちは検証基準そのものを疑った。そして**「近似指標(セルフプレイ勝率)ではなく、作者の実戦棋譜での悪手率」を合格ゲートに据え直した。** あの81手の棋譜を永久の回帰テストにし、やねうら王depth18で一手ずつ照らし、悪手が減ったかを直接測った。その方針が——遠回りに見えた、あの地味な基準の差し替えが——完全に報われた瞬間だった。近似指標がどれだけ良くても人間が「弱い」と感じたら弱い。逆に、人間が「強い」と認めた時が、本当の勝利だ。
飽和崩壊のときに書いた一文を、ここで裏返して書ける。あのとき「AIの評価が飽和で潰れていた決着局面で、全部の手が同じ点に見えていた」。いまは同じ決着局面で、手をちゃんと区別して殴り合える。
残る宿題も、正直に書く。 評価(NNUE)では殴り合えるようになった。だが診断(11.3)で見つけたもう一つの症状——終盤での探索速度の崩壊(持ち駒が増えると1ノード27〜41µsまで落ち、到達深さが4〜6手に低下する)——は、まだ残っている。これは評価関数とは無関係の、探索側の別問題だ。いま探索の高速化と、詰みソルバーの強化を並行で進めている。目標は変わらない——hardで三段級。「評価と探索の両輪」で、そこまで押し上げる。評価は片輪が回り始めた。次は探索だ。
11.3 と 11.8 で残した宿題——終盤で探索が浅くなる——にいよいよ手をつけた。この節は、成功譚ではなく遠回りの記録だ。うまくいかなかった案を含めて、探索改善の物語をここで完結させる。
まず、なぜ終盤で崩れるのかをプロファイルで突き止めた。 持ち駒が増えた局面で1ノードの中身を計ると、こうだった:
犯人はドロップ手の量的爆発。ここまでは診断どおりだ。
最初に試したのは「王手延長」だった——そして失敗した。 発想はこうだ。浅くしか読めないなら、せめて王手が続く一本道の変化だけは深く読む。詰みや寄せは連続王手で決まることが多いから、そこだけ延長すれば終盤の見落としが減るはず——理屈は正しく見えた。実装して、経路ごとに延長回数の予算を持たせる工夫まで入れた。
だが自己対戦A/B(hard・複数シード)で、勝ち越せなかった。理由は後から腑に落ちた。王手延長は「読む手を変える」改造だ。ある変化を深く読む代わりに、別の変化が浅くなる。終盤全体が速くなるわけではないので、得する局面と損する局面が相殺し、トータルで中立——つまり、崩壊そのものは治っていなかった。この案は**保留(マージせず)**にした。負けを認めるのも記録のうちだ。
そこで方針を根本から変えた。「読む手を変える」のではなく「読む手はそのままに、1手あたりを速くする」。 これなら理屈上、強さは下がりようがない。まったく同じ手を、同じ順で、同じ数だけ読むのに、時間だけ短くなる——浮いた時間で深さが伸びる、という寸法だ。この「挙動は1ビットも変えず速くするだけ」という制約が、実は最大の安全装置になる(後述)。
効いた発想は、拍子抜けするほど単純な将棋のルールだった。 遅延合法性チェック(付録D)は、指した直後に「自玉が王手を放置していないか」を毎回スキャンして確かめる。ここが全体の約49%。ところが——
持ち駒を打つ手(ドロップ)は、盤上の駒を1枚も動かさない。だから、自玉が新たに王手にさらされることは絶対にない。
駒を動かす手は、動かした駒の裏に隠れていた敵の飛び・角の利きが通って自玉が王手になる(=ピン)ことがある。だからチェックが要る。でも打つ手は、盤に味方を1枚「足す」だけ。味方が増えて自玉が突然王手になることはあり得ない。ゆえに——
「その局面で自玉がまだ王手を受けていない」なら、あらゆるドロップは自動的に合法。合法性スキャンをまるごと省ける。自玉がすでに王手を受けている場合だけは、その打つ手が王手を防いでいるかを確かめる必要があるので、従来どおり検査する。
const mover = k.teban; // 手番=この局面で指す側
k.move(te);
// ドロップ(te.from === 0)は味方を足すだけなので自玉を新たな王手にさらせない。
// 打つ前に自玉が王手でなければ(!parentInCheck)、そのドロップは必ず合法 → スキャン省略。
// ド・モルガンで二重否定を畳むと (te.from !== 0 || parentInCheck) と書ける。
if ((te.from !== 0 || parentInCheck) && isKingInCheck(k, mover)) {
k.back(te); continue;
}
終盤ではドロップが手の 8〜9 割を占めるので、この一行で合法性フェーズが約 27% 速くなった(微ベンチ計測)。手生成の順序も、読む手の集合も、ノード数も、1ビットも変えていない。
「1ビットも変えていない」を、どうやって保証したか。 これが今回いちばん大事な部分だ。移植版(JS/WASM 両方に同じ改造を入れた)に対して、既存の3つのパリティ検証を全部通した:
これらが全部緑のまま速くなった。つまり**「強くなったか」を自己対戦で確かめる必要すらない**——読む手が同一だと数学的に保証されているのだから、強さは定義上まったく変わらず、コストだけ下がる。A/Bのばらつきに一喜一憂しないで済む、という意味でも、この「ビット一致の高速化」という縛りは強力だ。
正直な計測結果も書く。 期待したほど劇的ではなかった。固定2秒の終盤探索で到達ノード数はほぼ横ばい(省いたのは合法性スキャンだけで、move/back の本体コストや手生成は残るため)。合法性フェーズ単体では約27%速くなったが、それはノード全体の一部分だ。深さが一段跳ねるほどの効果は、この一手だけでは出ていない。 それでも、強さを1ビットも損なわずにコストを確実に減らしたのは事実で、複数の高速化を積み重ねていく土台としては正しい方向だ。誇張せずに言えば——「小さいが、確実にプラスで、リスクゼロ」の一歩。
詰みソルバーの強化は、今回は見送った。 連続王手だけを読む専用ソルバー(付録は割愛)の読み切り手数を9手→11手に伸ばす案を試したが、手元の終盤局面群で新たに読み切れる詰みは増えなかった(時間だけ余計に食う)。効果が確認できないものを本番に入れて挙動を変えるのは、今回の「リスクゼロで積む」方針に反する。中立なら入れない——これも一つの判断だ。
教訓。 探索の高速化には二種類ある。「読む手を変えて賢くする」(王手延長のような)改造は、得と損が相殺しがちで、必ずA/Bで確かめないと危ない。対して**「読む手はそのままに速くする」**改造は、ビット一致で検証すれば強さのリスクがゼロになる。終盤崩壊という大きな宿題に対して、今回は後者の小さな一歩を、確実な形で置いた。ここから同じ縛りで高速化を積み上げていく——それが、遠回りして見つけた正しい道筋だ。
前節で削ったのは合法性チェック(ノードコストの約49%)だった。残るもう一つの山はドロップ手の「生成」そのもの(約50%)。ここも同じ縛り——読む手の集合も順序もノード数も1ビットも変えない——で速くできないかを次に試した。
犯人はコードの構造にあった。ドロップ生成は「駒種(歩・香・桂・銀・金・角・飛)× 筋(9列)× 段(9段)」の三重ループで、内側で毎回盤上のマスが空きかどうかを読み直していた。持ち駒が7種あれば、同じ81マスを7回スキャンし直す。さらに歩には二歩(同じ筋に自分の歩が二枚)の禁則があり、これも筋ごとに9マス走査していた。空きマスの集合も二歩の有無も駒種によらず一定なのに、駒種の数だけ盤を舐め直していたわけだ。
やったことは単純だ。ドロップ生成に入る前に、盤を1回だけ走査して、筋ごとに「空きマスのビットマスク(9ビット)」と「自分の歩がこの筋にあるか(二歩フラグ)」を先計算する。あとは駒種ループの内側で、盤を読む代わりにビットを1個テストするだけ。二歩判定もフラグ1個の参照になる。81マス走査を「駒種の数ぶん」から「1回」に畳んだ。
// ドロップ生成の前に盤を1パスだけ走査(筋 = suji>>4 を添字に)
for (let suji = 0x10; suji <= 0x90; suji += 0x10) {
let bits = 0, nifu = false;
for (let dan = 1; dan <= 9; dan++) {
const c = ban[suji + dan];
if (c === EMPTY) bits |= 1 << dan; // 空きマスをビットで持つ
else if (c === ownPawn) nifu = true; // 自分の歩 → この筋は二歩で歩打ち禁止
}
emptyBits[suji >> 4] = bits;
sujiHasOwnPawn[suji >> 4] = nifu;
}
// 内側ループ:盤を読み直さず、空きビットのテストと二歩フラグの参照だけ
if (komashu === FU && sujiHasOwnPawn[s]) continue; // 二歩:筋ごと1回で判定
if ((bits & (1 << dan)) === 0) continue; // 空きでなければ打てない
生成の順序も、生成される手の集合も、一切変えていない(駒種→筋→段の三重ループはそのまま、push の順も同じ)。だから前節と同じ3つのパリティが全部そのまま緑:perft 4,184局面 100%一致、固定深さ探索 48/48 EXACT(手・評価値・ノード数・葉ノード数まで)、NNUE 全フォーマットでビット一致。「速くしただけ」を数学的に保証したうえで、WASM 側(AssemblyScript の移植版)にも同じ最適化を入れて --enable simd で再ビルド、base64 を再生成した。
WASM 側には落とし穴が一つあった。二歩フラグと空きビットの一時テーブルをグローバル1個で共有すると、打ち歩詰め(uchifuzume)判定が生成関数を再帰的に呼び戻すため、内側ループの途中でテーブルを上書きされて壊れる。JS 側は打ち歩詰めが別の生成関数を通るので安全だったが、WASM は同じ関数に戻ってくる。探索の深さ(ply)ごとにテーブルを分けて再帰安全にした——移植では「同じロジック」だけでなく「同じ再帰構造での安全性」まで見ないといけない、という一例だ。
計測(微ベンチ、20万回反復)。 ドロップが手の大半を占める終盤局面で、generatePseudoLegalMovesPooled 単体の生成時間を計った。スキャンが主コストの局面(持ち駒5〜21枚)では1呼び出しあたり 15〜27%(中央値 約24%)速くなった——駒種ごとの盤の再走査を消した分がそのまま効いている。一方、打ち歩詰め判定の再帰が支配的な局面ではスキャンの比率が小さいので数%どまり。固定2秒の到達ノードは前節同様おおむね横ばい〜微増で、この一手だけで深さが跳ねるほどではない。だが強さを1ビットも損なわずコストを確実に減らすという縛りは守られており、合法性省略と合わせて「積み上げ」の二段目になった。
「速くするだけ」の縛りは安全だが、深さを一段跳ねさせるには、いつかは読む手を減らすしかない。そこで挙動変更版として、**終盤ドロップの後回し枝刈り(ドロップ版 LMP)**を試した。既存の LMP(Late Move Pruning:浅い深さで、並べ替えの下位に沈んだ静かな手を打ち切る)は、ドロップだけは頑なに対象外にしている——将棋では打つ手が寄せの本筋になりやすく、切ると危ないからだ。ここに、もっとも価値の薄いドロップに限って手を入れた:浅い深さで、十分な手数を読み終えた後の、敵玉から遠い歩・香の打ちだけを打ち切る(金・銀・桂・角・飛は寄せを作る駒なので絶対に切らない、敵玉近傍も切らない)。しきい値も距離条件も、既存 LMP より一段厳しく取った。
これは挙動を変えるので、強さは自己対戦A/Bで勝ち越したときだけ採用——王手延長で学んだ通りだ。本番と同じ hard・2000ms/手で、シードを変えて計48局(色を入れ替えて左右対称)、現行 v20 と直接対決させた。
結果は——負けだった。 3シード合計で v20drop 16勝 / v20 24勝 / 引分8(シード別 7-8、8-8、1-8)。どのシードでも勝ち越せず、トータルで明確に負け越した。理由は王手延長のときと同じ構図だ。ドロップ版LMPは結局「読む手を変える」改造であり、歩・香の打ちを切って稼いだ時間で他が深くなる代わりに、切った打ちがたまに本筋だった局面で取りこぼす。将棋の終盤で「玉から遠い歩・香の打ち」が無意味とは限らない——垂れ歩・成り捨ての伏線・退路封鎖など、遠く見える一手が寄せの一部であることは珍しくない。得と損が相殺どころか、損の方が勝った。
なので、この枝刈りは採用しない(enableDropLmp は既定 OFF のまま)。 本番の挙動はビット一致のまま1ビットも変わらない。コードとA/B用フラグ(v20drop バリアント)は「試したが負けた記録」として残すが、production 経路には一切効かない。中立でも入れないと決めていた前節の方針を、今度は「明確な負け」で再確認した格好だ。今回本番に入るのは、Part A のビット一致な生成高速化だけ——強さを1ビットも損なわず、コストだけ確実に下げる、その一段だけを積む。
まとめると、この二節で分かったのはこうだ。 「読む手を変える」枝刈り(王手延長/ドロップLMP)は二度ともA/Bで勝てなかった。一方「読む手を変えずに速くする」高速化(合法性省略/生成の一括スキャン)は、ビット一致で強さのリスクをゼロにしたまま二段積めた。終盤崩壊を一発で治す銀の弾丸はまだ見つからないが、リスクゼロの高速化を積み、挙動変更は負けたら潔く外す——この規律だけは、遠回りの末に確かなものになった。
前二段(合法性省略・生成の一括スキャン)で削ったのは主にドロップ手の生成だった。だが計測をやり直すと、終盤で意外に重い固定コストがもう一つ見えてきた——王手判定 isKingInCheck(自玉が敵の利きに入っているか)だ。これは合法手を選ぶたびに呼ばれる。V20 の探索は「まず擬似合法手を全部作り、1手ずつ実際に指してみて、自玉に王手がかかっていたら戻す(遅延合法性チェック)」で動くので、1ノードで生成手の数(終盤なら90〜450手)だけ isKingInCheck が走る。微ベンチで測ると 1回あたり 約820ns、終盤局面の1ノードのコストの約半分がこの王手判定だった。前夜のドロップ高速化がノードをあまり動かさなかったのに対し、これは全ノードで必ず払う固定費なので、ここを削ればノード数(=読める深さ)に素直に効く見込みがある。
ここで「ビットボード」の出番だ。 やねうら王のような本格派エンジンが速い理由の一つがこれで、盤面を整数のビット列として持ち(各マスを1ビットに対応させる)、「空きマス」「利きの通り道」「二歩」などを AND / OR / シフトのビット演算で一括処理する。1マスずつ配列を舐める代わりに、64個のマスを1命令でまとめて調べられる——C++ の64bitレジスタと pext 命令があれば、飛び駒の利き(香・角・飛)すら数命令で引ける(magic bitboard)。将棋盤は81マスなので64bitに収まらず複数ワードに分けるが、原理は同じだ。同じ構造改革をうちにも入れれば、王手判定が速くなってノードが伸びるはず——そう考えて着手した。
が、ここで正直に測った。 JavaScript には落とし穴がある。(1) JSのビット演算は32bitまでで、81マスは1ワードに収まらず、香・角・飛の「最初にぶつかる駒」を求めるビットスキャンがワードをまたいで面倒になる。(2) 本物のビットボードは占有ビットボードを差分更新する必要があり、その更新は move()/back()(1手指す・戻す)という最も何度も走るホットパスに載る——王手判定は1ノードで1〜3回なのに、指し手の適用はそれ以上に走る。削った分を、指し手適用の重くなった分が食い潰しかねない。
そこで、いきなり全面ビットボード化する前に、軽い実験で「効くか」を先に確かめた。まず占有リスト方式(角・飛・香など飛び駒だけを別リストで持ち、玉との一直線の利きだけを調べる)を試作して単離計測したところ、盤を舐め直すコストの方が大きく、現行の8方向レイ走査より約2倍遅かった。つまり配列版のレイ走査は、JavaScript のJITにとって既にかなり最適化されていて、飛び駒リストや差分ビットボードにしても維持コストが得を上回る——本格ビットボードは、この規模のJSでは入れると却って遅くなる公算が高いと分かった。前夜の教訓(理屈で速くても実探索で効かないことがある)が、ここでも当たった。
では何も効かないのか——というと、そうでもなかった。 ビットボードの発想のうち「差分状態を持たずに済む部分」だけを取り出した。王手判定の中身を、k.get() というメソッド呼び出し(境界チェック付き)を盤配列 ban[] の直接読みに置き換え、isEnemy/isSelf の毎回の分岐を、手番から先に計算した1個のビットマスク(enemyFlag/selfFlag)に畳む。アルゴリズムも利きテーブル(canMove/canJump)も1ビットも変えない——だから結果は元の実装と完全にビット一致する。差分状態を一切持たないので、move()/back() のコストはゼロ増。要は「ビットボードにはしないが、ビットマスクと直接メモリアクセスというビットボードの下ごしらえだけ頂く」という一段だ。
// 王手判定の内側:k.get() をやめて ban[] 直読み、手番の分岐をマスクに畳む
const ban = k.ban;
const enemyFlag = teban === SENTE ? GOTE : SENTE; // 敵駒のビット(32 or 16)
const selfFlag = teban === SENTE ? SENTE : GOTE; // 自駒のビット
// 近接利き(12方向):敵駒フラグが立っていて、その方向に利くテーブルが真なら王手
for (let d = 0; d < 12; d++) {
const koma = ban[target - diff[d]];
if ((koma & enemyFlag) !== 0 && canMove[d][koma]) return true;
}
// 飛び利き(8方向):空きは透過、自駒で止まる、敵駒なら利きテーブルを引いて終了
for (let d = 0; d < 8; d++) {
const step = diff[d], cj = canJump[d];
let pos = target - step, koma = ban[pos];
while (koma !== WALL) {
if (koma !== EMPTY) { if ((koma & selfFlag) !== 0) break; if (cj[koma]) return true; break; }
pos -= step; koma = ban[pos];
}
}
計測(微ベンチ、50万回反復、終盤6局面)。 isKingInCheck は 820ns → 557ns(約32%減)。これを含む「1ノード=擬似合法手を全生成し、1手ずつ指して王手判定して戻す」内側ループ全体では 約300,700ns/ノード → 約246,900ns/ノード(約18%減)。同じ最適化を WASM 側(AssemblyScript)にも移植して --enable simd で再ビルドすると、perft スループット(生成+王手判定が主コスト)が hirate depth4 で +8.5%、ドロップ局面 depth4 で +9%。本番でメインに走るのは WASM エンジンなので、ここが速くなるのが実利になる。固定2秒のJS探索で終盤6局面の到達ノードを合計すると 13,917 → 14,855(+6.7%)——per-node の速度がそのまま「同じ時間でより多く読める」に変換されている(1手ぶん深さを跳ねさせるには足りないが、ノード数は素直に伸びた)。
検証は前二段と同じ三点セットが全部緑。 挙動を1ビットも変えていないので当然だが、念のため:perft は JS=WASM=既知値でビット一致(hirate d1=30/d2=900/d3=25440、ドロップ局面 d3=418334 など)、パリティ4,184局面100%(Zobrist ハッシュまでビット一致)、固定深さ探索 48/48 EXACT(手・評価値・ノード数・葉ノード数)、NNUE 全フォーマットでビット一致。tsc / eslint / vitest(306件)/ build も通過。ビット一致なので固定深さのA/Bは定義上引き分け——差が出るのは固定時間のときで、速い分だけ多く読めて(同等以上に)強くなる、という安全側の変更だ。
この三段目の教訓はこうだ。 「ビットボードにすれば速くなる」は本当だが、それはC++の64bitレジスタと差分更新が軽い環境での話で、JavaScript にそのまま持ち込むと維持コストが得を食う。実際、飛び駒リストや差分ビットボードは単離計測で現行より遅かった。だがビットボードの部品のうち「差分状態を持たない部分(ビットマスク・直接メモリアクセス)」だけを取り出せば、move()/back() を1ビットも重くせずに王手判定を3割、ノードを2割速くできた。「本にそう書いてある構造」を鵜呑みにせず、自分の環境で単離計測して、効く部品だけ拾う——第3サイクルで何度も学んだこの規律が、ビットボードでもそのまま効いた。銀の弾丸ではないが、リスクゼロの高速化の三段目として、確かに積めた一段だ。
ps・wc で実測し、その数字で配分を決めるのが基本動作。自動化・並列化という近道は、セルフプレイと同じで、便利さの裏に現場特有の落とし穴を隠している将棋二段の著者に「弱すぎる」と言われて始まったこのプロジェクトは、第2サイクルまでを決着させ、第3サイクルで評価の飽和を根治した。定跡の即答バグを潰し、全難易度の思考を統一し、WASMで15倍にし、相手の思考時間まで使い、定跡をプロ級エンジンで監査し——100万局面から一晩で蒸留したニューラルネットが7ヶ月かけて手書きされた評価関数を77.1%で置き換え、そして第3サイクルで、そのNNUEが本番hardで人間に崩壊したところから、524万局面で飽和を治し、決着局面でも手を区別して殴り合えるところまで作り直した。hardでV3を実質置換して純NNUE化し——ついに、作者本人が実戦で「確実に強くなってる」と認めた。いまmeetyudai.comで動いているのはそのAIだ。残る目標も変わらない。評価は殴り合えるようになった。次は探索の終盤崩壊を治し、hardで三段級まで押し上げること。「評価と探索の両輪」で、まだ先がある。
リポジトリはprivateなので、本文で触れた仕組みの核心部分をここに抜粋しておく(説明のため簡略化してある)。
実戦で疑問手が出たら、その棋譜を再現して本番のAI呼び出し経路に問い合わせる。ポイントは思考時間を必ず表示すること。
function askAI(label: string): void {
const t0 = Date.now();
const move = getBestMove(k, GOTE, 'hard', moveNumber, history); // 本番と同一経路
const ms = Date.now() - t0;
console.log(`${label}: AI(hard) -> ${fmt(move)} (${ms}ms)`); // ← ここが命
}
// 出力例:
// move 10: AI(hard) plays 82->42 (23ms) ← hardは2秒枠のはず。探索していない!
// move 12: AI(hard) plays 93->94 (1ms)
「その時点で合法なら指す、ダメなら次の候補へ」という前進スキップ方式で、分岐のある人間の作戦を1本のリストで表現する。
const BOGIN_PLAN: Step[] = [
{ fs: 2, fd: 7, ts: 2, td: 6 }, // ▲2六歩
{ fs: 2, fd: 6, ts: 2, td: 5 }, // ▲2五歩
{ fs: 3, fd: 9, ts: 3, td: 8 }, // ▲3八銀
{ fs: 3, fd: 8, ts: 2, td: 7 }, // ▲2七銀
{ fs: 2, fd: 7, ts: 2, td: 6 }, // ▲2六銀
{ fs: 2, fd: 6, ts: 1, td: 5 }, // ▲1五銀(△1四歩で防がれたらスキップされる)
{ fs: 2, fd: 5, ts: 2, td: 4 }, // ▲2四歩
];
while (planIndex < plan.length && !move) {
const s = plan[planIndex++];
move = legal.find((m) => matches(m, s)) ?? null; // 非合法ならスキップして次へ
}
if (!move) move = getBestMove(k, SENTE, difficulty, n, hist); // プラン終了後はエンジンが継続
const problems: string[] = [];
// 定跡ウィンドウ(12手)以降に200ms未満で返答 → 探索バイパスの疑い
if (ms < 200 && moveNumber > 12) problems.push(`INSTANT(${ms}ms)`);
// AIの手の直後、銀以上の自駒がタダ取り可能(SEE-lite) → 見落としの疑い
if (hang.value >= 900) problems.push(`HANGS(${hang.square}:${hang.value})`);
// 評価が相手側に800以上急変 → 悪手の疑い
if (after - before > 800) problems.push(`EVAL(+${after - before})`);
「疑い」であって断定ではない(角交換の途中などで偽陽性が出る)。フラグは人間がレビューする前提の設計。
生成した全80手に王手放置チェックをかけるのをやめ、「実際に指す直前」に検査する。αβでは大半のノードが1〜3手で枝刈りされるので、これだけで検査回数が激減する。
const moves = generatePseudoLegalMovesPooled(k, pool[ply]); // 王手放置は未検査
for (const te of moves) {
k.move(te);
if (isKingInCheck(k, k.teban)) { k.back(te); continue; } // ← 遅延検査
legalTried++;
k.toggleTeban();
const score = -search(k, depth - 1, -beta, -alpha, ply + 1);
k.toggleTeban();
k.back(te);
// ...αβ更新...
}
// 詰み判定は「合法手を1つも指せなかった」かつ「枝刈りで飛ばした手がない」とき
if (legalTried === 0 && !prunedAny) return inCheck ? -MATE + ply : 0;
// 非王手時の静止探索が探索するのは「取る/成る手」だけ。
// 全手スコアリングは無駄なので、noisy手を配列先頭にswapで寄せ、そこだけ挿入ソート。
let noisyCount = 0;
for (let i = 0; i < moves.length; i++) {
const m = moves[i];
if (m.capture !== EMPTY || m.promote) {
[moves[i], moves[noisyCount]] = [moves[noisyCount], m];
noisyCount++;
}
}
insertionSortByScore(moves, 0, noisyCount); // 数個〜十数個だけ
変更前エンジンを凍結スナップショット(v20base)として登録し、片側だけ時間を変えて対戦できるようにした。
# 新エンジン(200ms) vs 凍結ベースライン(160ms)——本番の時間比を再現
npm run shogi:match -- --engineA v20 --engineB v20base \
--evalA v3 --evalB v3 --difficulty medium \
--games 16 --maxTimeMsA 200 --maxTimeMsB 160 \
--openingPlies 6 --openingMode curated --seed 61
# 教訓: openingPlies>=6 (2手では対局が縮退する) / 最終判定は本番時間で30局以上
// 1プロセスあたり: 標準入出力のテキスト対話(USIプロトコル)
send('position sfen ' + sfen);
send('go depth 8');
// "info ... score cp -2161 ..." を拾い、"bestmove" で確定
const cp = lastInfo.match(/ score cp (-?\d+)/)?.[1];
// プール並列: エンジン8本がpending配列を取り合う
await Promise.all(engines.map(async (engine) => {
for (;;) {
const i = cursor++;
if (i >= pending.length) return;
const res = await engine.evaluate(pending[i].sfen, depth);
if (!res || res.bestmove === 'resign' || res.bestmove === 'win') continue;
lines.push(JSON.stringify({ sfen: pending[i].sfen, cp: res.cp, ... }));
}
}));
// 実測: ラベリング約1,400局面/秒。出力は追記式JSONLでいつでも再開可能
// ×失敗案: makeMove のたびに acc を更新(探索は指してすぐ捨てる手だらけ → perft +1348%)
// ○採用案: makeMove は差分をスタックに積むだけ。評価が呼ばれた時にまとめて折り込む。
function makeMove(te: Move): void {
applyBoard(te); // 盤面は即時更新(数ns)
nnuePending.push(encodeDiff(te)); // accには触らない
}
function nnueEvaluate(): i32 {
while (nnuePending.length > 0) foldDiffIntoAccumulators(nnuePending.shift());
const acc = sideToMove === SENTE ? accSente : accGote; // 両視点accを常時維持
return forwardFromAccumulator(acc); // clamp → 2層目以降
}
function unmakeMove(te: Move): void {
if (wasApplied(te)) unfoldDiff(te); // 適用済みの差分だけ逆適用
else nnuePending.pop(); // 未適用ならキャンセルするだけ
revertBoard(te);
}
// 結果: 評価6.2µs→1.15µs、NNUE有効でも探索深さ9〜15を維持
// ランダム自己対局50局×最大80手 = 4,184局面で、JSとWASMを毎手照合
for (const pos of randomGamePositions) {
assert(jsLegalMoveCount(pos) === wasmLegalMoveCount(pos)); // 合法手数
assert(jsHash(pos) === wasmHash(pos)); // Zobristビット一致
assert(jsEval(pos) === wasmEval(pos)); // 評価の整数一致
}
// 探索は固定深さで bestMove・スコア・ノード数までバイト一致を要求(48/48局面)
// 「だいたい同じ」を許すとバグは網を抜ける。ビット一致ならバグは全部かかる。
これらのコードで本文の実験はすべて再現できる。パイプライン全体(教師生成→学習→量子化→WASM推論)を含め、使ったものはすべて無料のオープンソースだ。